PK!TTgoogle_music_utils/__about__.py__all__ = [ '__author__', '__author_email__', '__copyright__', '__license__', '__summary__', '__title__', '__url__', '__version__', '__version_info__' ] __title__ = 'google-music-utils' __summary__ = 'A set of utility functionality for google-music and related projects.' __url__ = 'https://github.com/thebigmunch/google-music-utils' __version__ = '2.1.0' __version_info__ = tuple(int(i) for i in __version__.split('.') if i.isdigit()) __author__ = 'thebigmunch' __author_email__ = 'mail@thebigmunch.me' __license__ = 'MIT' __copyright__ = f'2018-2019 {__author__} <{__author_email__}>' PK!tl  google_music_utils/__init__.pyfrom .__about__ import * from .compare import * from .constants import * from .filter import * from .metadata import * from .misc import * __all__ = [ *__about__.__all__, *compare.__all__, *constants.__all__, *filter.__all__, *metadata.__all__, *misc.__all__ ] PK![##google_music_utils/compare.py__all__ = ['find_existing_items', 'find_missing_items'] import os import audio_metadata from .constants import FIELD_MAP from .utils import get_field, list_to_single_value, normalize_value def _gather_field_values( item, *, fields=None, field_map=FIELD_MAP, normalize_values=False, normalize_func=normalize_value): """Create a tuple of normalized metadata field values. Parameter: item (~collections.abc.Mapping, str, os.PathLike): Item dict or filepath. fields (list): A list of fields used to compare item dicts. field_map (~collections.abc.Mapping): A mapping field name aliases. Default: :data:`~google_music_utils.constants.FIELD_MAP` normalize_values (bool): Normalize metadata values to remove common differences between sources. Default: ``False`` normalize_func (function): Function to apply to metadata values if ``normalize_values`` is ``True``. Default: :func:`~google_music_utils.utils.normalize_value` Returns: tuple: Values from the given metadata fields. """ if isinstance(item, (str, os.PathLike)): it = audio_metadata.load(item).tags elif isinstance(item, audio_metadata.Format): it = item.tags else: it = item if fields is None: fields = list(it.keys()) normalize = normalize_func if normalize_values else lambda x: str(x) field_values = [] for field in fields: field_values.append( normalize( list_to_single_value( get_field(it, field, field_map=field_map) ) ) ) return tuple(field_values) def find_existing_items( src, dst, *, fields=None, field_map=None, normalize_values=False, normalize_func=normalize_value): """Find items from an item collection that are in another item collection. Parameters: src (list): A list of item dicts or filepaths. dst (list): A list of item dicts or filepaths. fields (list): A list of fields used to compare item dicts. field_map (~collections.abc.Mapping): A mapping field name aliases. Default: :data:`~google_music_utils.constants.FIELD_MAP` normalize_values (bool): Normalize metadata values to remove common differences between sources. Default: ``False`` normalize_func (function): Function to apply to metadata values if ``normalize_values`` is ``True``. Default: :func:`~google_music_utils.utils.normalize_value` Yields: dict: The next item from ``src`` collection in ``dst`` collection. """ if field_map is None: field_map = FIELD_MAP dst_keys = { _gather_field_values( dst_item, fields=fields, field_map=field_map, normalize_values=normalize_values, normalize_func=normalize_func ) for dst_item in dst } for src_item in src: if _gather_field_values( src_item, fields=fields, field_map=field_map, normalize_values=normalize_values, normalize_func=normalize_func ) in dst_keys: yield src_item def find_missing_items( src, dst, *, fields=None, field_map=None, normalize_values=False, normalize_func=normalize_value): """Find items from an item collection that are not in another item collection. Parameters: src (list): A list of item dicts or filepaths. dst (list): A list of item dicts or filepaths. fields (list): A list of fields used to compare item dicts. field_map (~collections.abc.Mapping): A mapping field name aliases. Default: :data:`~google_music_utils.constants.FIELD_MAP` normalize_values (bool): Normalize metadata values to remove common differences between sources. Default: ``False`` normalize_func (function): Function to apply to metadata values if ``normalize_values`` is ``True``. Default: :func:`~google_music_utils.utils.normalize_value` Yields: dict: The next item from ``src`` collection not in ``dst`` collection. """ if field_map is None: field_map = FIELD_MAP dst_keys = { _gather_field_values( dst_item, fields=fields, field_map=field_map, normalize_values=normalize_values, normalize_func=normalize_func ) for dst_item in dst } for src_item in src: if _gather_field_values( src_item, fields=fields, field_map=field_map, normalize_values=normalize_values, normalize_func=normalize_func ) not in dst_keys: yield src_item PK!EqN{{google_music_utils/constants.py__all__ = ['CHARACTER_REPLACEMENTS', 'FIELD_MAP', 'TEMPLATE_PATTERNS'] from multidict import MultiDict CHARACTER_REPLACEMENTS = { '\\': '-', '/': ',', ':': '-', '*': 'x', '<': '[', '>': ']', '|': '!', '?': '', '"': "''" } """dict: Mapping of invalid filepath characters with appropriate replacements.""" _FIELD_MAP_GROUPS = [ ('albumartist', 'album_artist', 'albumArtist'), ('bpm', 'beatsPerMinute'), ('date', 'year'), ('discnumber', 'disc_number', 'discNumber'), ('disctotal', 'total_disc_count', 'totalDiscCount'), ('tracknumber', 'track_number', 'trackNumber'), ('tracktotal', 'total_track_count', 'totalTrackCount') ] _FIELD_MAP = [(field, alias) for group in _FIELD_MAP_GROUPS for field in group for alias in group if field != alias] FIELD_MAP = MultiDict(_FIELD_MAP) """MultiDict: Mapping of field name aliases.""" # TODO: Support other/more metadata field names. TEMPLATE_PATTERNS = { '%album%': ['album'], '%albumartist%': ['albumartist', 'album_artist', 'albumArtist'], '%artist%': ['artist'], '%date%': ['date'], '%disc%': ['discnumber', 'disc_number', 'discNumber'], '%disc2%': ['discnumber', 'disc_number', 'discNumber'], '%discnumber%': ['discnumber', 'disc_number', 'discNumber'], '%discnumber2%': ['discnumber', 'disc_number', 'discNumber'], '%genre%': ['genre'], '%title%': ['title'], '%track%': ['tracknumber', 'track_number', 'trackNumber'], '%track2%': ['tracknumber', 'track_number', 'trackNumber'], '%tracknumber%': ['tracknumber', 'track_number', 'trackNumber'], '%tracknumber2%': ['tracknumber', 'track_number', 'trackNumber'] } """dict: Mapping of template patterns to their metadata field names.""" PK!&Jgoogle_music_utils/filter.py__all__ = ['exclude_items', 'include_items'] import functools import os import re from itertools import filterfalse import audio_metadata from .utils import get_field, normalize_value def _match_field(field_value, pattern, ignore_case=False, normalize_values=False): """Match an item metadata field value by pattern. Note: Metadata values are lowercased when ``normalized_values`` is ``True``, so ``ignore_case`` is automatically set to ``True``. Parameters: field_value (list or str): A metadata field value to check. pattern (str): A regex pattern to check the field value(s) against. ignore_case (bool): Perform case-insensitive matching. Default: ``False`` normalize_values (bool): Normalize metadata values to remove common differences between sources. Default: ``False`` Returns: bool: True if matched, False if not. """ if normalize_values: ignore_case = True normalize = normalize_value if normalize_values else lambda x: str(x) search = functools.partial(re.search, flags=re.I) if ignore_case else re.search # audio_metadata fields contain a list of values. if isinstance(field_value, list): return any(search(pattern, normalize(value)) for value in field_value) else: return search(pattern, normalize(field_value)) def _match_item(item, any_all=any, ignore_case=False, normalize_values=False, **kwargs): """Match items by metadata. Note: Metadata values are lowercased when ``normalized_values`` is ``True``, so ``ignore_case`` is automatically set to ``True``. Parameters: item (~collections.abc.Mapping, str, os.PathLike): Item dict or filepath. any_all (callable): A callable to determine if any or all filters must match to match item. Expected values :obj:`any` (default) or :obj:`all`. ignore_case (bool): Perform case-insensitive matching. Default: ``False`` normalize_values (bool): Normalize metadata values to remove common differences between sources. Default: ``False`` kwargs (list): Lists of values to match the given metadata field. Returns: bool: True if matched, False if not. """ if isinstance(item, (str, os.PathLike)): it = audio_metadata.load(item).tags elif isinstance(item, audio_metadata.Format): it = item.tags else: it = item return any_all( _match_field( get_field(it, field), pattern, ignore_case=ignore_case, normalize_values=normalize_values ) for field, patterns in kwargs.items() for pattern in patterns ) def exclude_items(items, any_all=any, ignore_case=False, normalize_values=False, **kwargs): """Exclude items by matching metadata. Note: Metadata values are lowercased when ``normalized_values`` is ``True``, so ``ignore_case`` is automatically set to ``True``. Parameters: items (list): A list of item dicts or filepaths. any_all (callable): A callable to determine if any or all filters must match to exclude items. Expected values :obj:`any` (default) or :obj:`all`. ignore_case (bool): Perform case-insensitive matching. Default: ``False`` normalize_values (bool): Normalize metadata values to remove common differences between sources. Default: ``False`` kwargs (list): Lists of values to match the given metadata field. Yields: dict: The next item to be included. Example: >>> from google_music_utils import exclude_items >>> list(exclude_items(song_list, any_all=all, ignore_case=True, normalize_values=True, artist=['Beck'], album=['Golden Feelings'])) """ if kwargs: match = functools.partial( _match_item, any_all=any_all, ignore_case=ignore_case, normalize_values=normalize_values, **kwargs ) return filterfalse(match, items) else: return iter(items) def include_items(items, any_all=any, ignore_case=False, normalize_values=False, **kwargs): """Include items by matching metadata. Note: Metadata values are lowercased when ``normalized_values`` is ``True``, so ``ignore_case`` is automatically set to ``True``. Parameters: items (list): A list of item dicts or filepaths. any_all (callable): A callable to determine if any or all filters must match to include items. Expected values :obj:`any` (default) or :obj:`all`. ignore_case (bool): Perform case-insensitive matching. Default: ``False`` normalize_values (bool): Normalize metadata values to remove common differences between sources. Default: ``False`` kwargs (list): Lists of values to match the given metadata field. Yields: dict: The next item to be included. Example: >>> from google_music_utils import exclude_items >>> list(include_items(song_list, any_all=all, ignore_case=True, normalize_values=True, artist=['Beck'], album=['Odelay'])) """ if kwargs: match = functools.partial( _match_item, any_all=any_all, ignore_case=ignore_case, normalize_values=normalize_values, **kwargs ) return filter(match, items) else: return iter(items) PK!]fxI==google_music_utils/metadata.py__all__ = [ 'gm_timestamp', 'from_gm_timestamp', 'to_gm_timestamp', 'is_album_id', 'is_artist_id', 'is_podcast_episode_id', 'is_podcast_series_id', 'is_store_song_id', 'is_uuid' ] import re import time _uuid_re = re.compile(r'^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$') def gm_timestamp(): """Generate a timestamp in microseconds.""" return int(time.time() * 1000000) def from_gm_timestamp(timestamp): """Convert timestamp in microseconds to timestamp in seconds.""" return int(timestamp / 1000000) def to_gm_timestamp(timestamp): """Convert timestamp in seconds to timestamp in microseconds.""" return int(timestamp * 1000000) def is_album_id(item_id): """Validate if ID is in the format of a Google Music album ID.""" return len(item_id) == 27 and item_id.startswith('B') def is_artist_id(item_id): """Validate if ID is in the format of a Google Music artist ID.""" return len(item_id) == 27 and item_id.startswith('A') def is_podcast_episode_id(item_id): """Validate if ID is in the format of a Google Music podcast episode ID.""" return len(item_id) == 27 and item_id.startswith('D') def is_podcast_series_id(item_id): """Validate if ID is in the format of a Google Music series ID.""" return len(item_id) == 27 and item_id.startswith('I') def is_store_song_id(item_id): """Validate if ID is in the format of a Google Music store song ID.""" return len(item_id) == 27 and item_id.startswith('T') def is_uuid(item_id): """Validate if ID is in the format of a UUID (used for library song IDs).""" return bool(_uuid_re.match(item_id)) PK! N ogoogle_music_utils/misc.py__all__ = ['suggest_filename', 'template_to_filepath'] import re from pathlib import Path import more_itertools from .constants import CHARACTER_REPLACEMENTS, TEMPLATE_PATTERNS from .utils import list_to_single_value def _replace_invalid_characters(filepath): for char in CHARACTER_REPLACEMENTS: filepath = filepath.replace(char, CHARACTER_REPLACEMENTS[char]) return filepath def _split_number_field(field): match = re.match(r'(\d+)(?:/\d+)?', field) return match.group(1) if match else field def suggest_filename(metadata): """Generate a filename like Google for a song based on metadata. Parameters: metadata (~collections.abc.Mapping): A metadata dict. Returns: str: A filename string without an extension. """ if 'title' in metadata and 'track_number' in metadata: # Music Manager. suggested_filename = f"{metadata['track_number']:0>2} {metadata['title']}" elif 'title' in metadata and 'trackNumber' in metadata: # Mobile. suggested_filename = f"{metadata['trackNumber']:0>2} {metadata['title']}" elif 'title' in metadata and 'tracknumber' in metadata: # audio-metadata/mutagen. track_number = _split_number_field( list_to_single_value( metadata['tracknumber'] ) ) title = list_to_single_value(metadata['title']) suggested_filename = f"{track_number:0>2} {title}" else: suggested_filename = f"00 {list_to_single_value(metadata.get('title', ['']))}" return _replace_invalid_characters(suggested_filename) def template_to_filepath(template, metadata, template_patterns=None): """Create directory structure and file name based on metadata template. Note: A template meant to be a base directory for suggested names should have a trailing slash or backslash. Parameters: template (str or ~os.PathLike): A filepath which can include template patterns as defined by :param template_patterns:. metadata (~collections.abc.Mapping): A metadata dict. template_patterns (~collections.abc.Mapping): A dict of ``pattern: field`` pairs used to replace patterns with metadata field values. Default: :const:`~google_music_utils.constants.TEMPLATE_PATTERNS` Returns: ~pathlib.Path: A filepath. """ path = Path(template) if template_patterns is None: template_patterns = TEMPLATE_PATTERNS suggested_filename = suggest_filename(metadata) if ( path == Path.cwd() or path == Path('%suggested%') ): filepath = Path(suggested_filename) elif any(template_pattern in path.parts for template_pattern in template_patterns): if template.endswith(('/', '\\')): template += suggested_filename path = Path(template.replace('%suggested%', suggested_filename)) parts = [] for part in path.parts: if part == path.anchor: parts.append(part) else: for key in template_patterns: if ( # pragma: no branch key in part and any(field in metadata for field in template_patterns[key]) ): field = more_itertools.first_true( template_patterns[key], pred=lambda k: k in metadata ) if key.startswith(('%disc', '%track')): number = _split_number_field( str( list_to_single_value( metadata[field] ) ) ) if key.endswith('2%'): metadata[field] = number.zfill(2) else: metadata[field] = number part = part.replace( key, list_to_single_value( metadata[field] ) ) parts.append(_replace_invalid_characters(part)) filepath = Path(*parts) elif '%suggested%' in template: filepath = Path(template.replace('%suggested%', suggested_filename)) elif template.endswith(('/', '\\')): filepath = path / suggested_filename else: filepath = path return filepath PK!(D-google_music_utils/utils.py__all__ = [ 'get_field', 'list_to_single_value', 'normalize_value' ] import re from collections.abc import Mapping from multidict import MultiDict from .constants import FIELD_MAP def get_field(item, field, default='', *, field_map=FIELD_MAP): value = default if item.get(field): value = item[field] elif isinstance(field_map, MultiDict): for alias in field_map.getall(field, []): if item.get(alias): # pragma: no branch value = item[alias] break elif isinstance(field_map, Mapping): # pragma: no branch alias = field_map.get(field) if alias in item: # pragma: no branch value = item[alias] return value def list_to_single_value(value): if not isinstance(value, (list, tuple)): return value return value[0] def normalize_value(value): """Normalize metadata value to improve match accuracy.""" value = str(value) value = value.casefold() value = re.sub(r'\/\s*\d+', '', value) # Remove "/" from track number. value = re.sub(r'^0+([0-9]+)', r'\1', value) # Remove leading zero(s) from track number. value = re.sub(r'^(\d+)\.+', r'\1', value) # Remove dots from track number. value = re.sub(r'[^\w\s]', '', value) # Remove leading non-word characters. value = re.sub(r'^the\s+', '', value) # Remove leading "the". value = re.sub(r'^\s+', '', value) # Remove leading spaces. value = re.sub(r'\s+$', '', value) # Remove trailing spaces. value = re.sub(r'\s+', ' ', value) # Reduce multiple spaces to a single space. return value PK!ulQQ*google_music_utils-2.1.0.dist-info/LICENSEThe MIT License (MIT) Copyright (c) 2018-2019 thebigmunch Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. PK!HڽTU(google_music_utils-2.1.0.dist-info/WHEEL A н#Z;/"d&F[xzw@Zpy3Fv]\fi4WZ^EgM_-]#0(q7PK!H41+google_music_utils-2.1.0.dist-info/METADATAWmS8_ka◼A#@a.Z!qږ+[N1%44/ v}EG5AHƣi G|05Y)Kv2 C*'SŘJ4\4 GTGbY9!1LX,gj8CKa~3k !s!t\OԌ)g=U6C\ (EQ}inv[a@dSZ"9<%1p2'*#}AÐE>OK-[5Ue J3]*˺rQ::2 }S44fæ@Gbr[E{^lU+ܾhӡV} lV &hcA|Ae+BW +nq!ҋrԾ[\"W`iX\w%1ȥN/@=Ç %Mf%juao4:9F[īmӪH~G 2$s5HHsk_m0Ihwl>ߒjW*Ib%~2ɵ$J !] w|Sbm$=5 voB;[yױR[ˊ\\7t%!T950&g45K_/7])-deI -J'iimHA\Phn ՅGz4$ ~FZN+'el|qzp5>=`^8)2tԝҫ)P]oL`a螶zALΑ6CRǙb@L( gӵ\  E|^:;M] ٝ\9 ڍݘu0mnVn CJrV*d2_&GR8Fawd$8w4^AZYhtAMG4X#dB #?S^e?iL̸3uj (M?)Ss &nv_*M7"7Ӆb,ŭaokL&ssrT?j!D={6= ̭Ia?tӎׁ}`~0m~nQ ڞ]h~CoZ%w.LDayv}?PK!HsXD)google_music_utils-2.1.0.dist-info/RECORD˒0}? ؀ܲri *B`!x*EoΩ$oQ&$$]P䏬ګKbnB=wEԊ:.|5Wşz^qQ:>@%8%t= F)5m+ T@x纑E=q4݇U`D~O5a