PK!nDD!google_music_scripts/__about__.py__all__ = [ '__author__', '__author_email__', '__copyright__', '__license__', '__summary__', '__title__', '__url__', '__version__', '__version_info__' ] __title__ = 'google-music-scripts' __summary__ = 'A collection of scripts to interact with Google Music.' __url__ = 'https://github.com/thebigmunch/google-music-scripts' __version__ = '1.1.1' __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 {__author__} <{__author_email__}>' PK!q22 google_music_scripts/__init__.pyfrom .__about__ import ( __author__, __author_email__, __copyright__, __license__, __summary__, __title__, __url__, __version__, __version_info__ ) __all__ = [ '__author__', '__author_email__', '__copyright__', '__license__', '__summary__', '__title__', '__url__', '__version__', '__version_info__' ] PK!^PP google_music_scripts/__main__.py#!/usr/bin/env python3 from .cli import gms if __name__ == '__main__': gms() PK! \ZT T google_music_scripts/cli.py"""Command line interface of google-music-scripts.""" import os import re from collections import defaultdict import click from . import __title__, __version__ from .constants import UNIX_PATH_RE from .utils import convert_cygwin_path CMD_ALIASES = { 'del': 'delete', 'down': 'download', 'up': 'upload' } FILTER_RE = re.compile(r'(([+-]+)?(.*?)\[(.*?)\])', re.I) PLUGIN_DIR = os.path.join(os.path.dirname(__file__), 'commands') class AliasedGroup(click.Group): def get_command(self, ctx, alias): cmd = CMD_ALIASES.get(alias, alias) ns = {} filepath = os.path.join(os.path.join(PLUGIN_DIR, cmd + '.py')) with open(filepath) as f: code = compile(f.read(), filepath, 'exec') eval(code, ns, ns) return ns[cmd] def list_commands(self, ctx): rv = [] for filename in os.listdir(PLUGIN_DIR): if filename.endswith('.py') and not filename == '__init__.py': rv.append(filename[:-3]) rv.sort() return rv # I use Windows Python install from Cygwin. # This custom click type converts Unix-style paths to Windows-style paths in this case. class CustomPath(click.Path): def convert(self, value, param, ctx): if os.name == 'nt' and UNIX_PATH_RE.match(value): value = convert_cygwin_path(value) return super().convert(value, param, ctx) # Callback used to allow input/output arguments to default to current directory. # Necessary because click does not support setting a default directly when using nargs=-1. def default_to_cwd(ctx, param, value): if not value: value = (os.getcwd(),) return value def parse_filters(ctx, param, value): filters = [] for filter_ in value: conditions = FILTER_RE.findall(filter_) if not conditions: raise ValueError(f"'{filter_}' is not a valid filter.") filters.append(conditions) return filters # Callback to split filter strings into dict containing field:value_list items. def split_filter_strings(ctx, param, value): filters = defaultdict(list) for filt in value: field, val = filt.split(':', 1) filters[field].append(val) return filters CONTEXT_SETTINGS = dict(max_content_width=200, help_option_names=['-h', '--help']) @click.command(cls=AliasedGroup, context_settings=CONTEXT_SETTINGS) @click.version_option(__version__, '-V', '--version', prog_name=__title__, message="%(prog)s %(version)s") def gms(): """A collection of scripts to interact with Google Music.""" pass PK!)google_music_scripts/commands/__init__.pyPK!-*W W 'google_music_scripts/commands/delete.pyimport sys import click import google_music from logzero import logger from google_music_scripts.__about__ import __title__, __version__ from google_music_scripts.cli import CONTEXT_SETTINGS, parse_filters from google_music_scripts.config import configure_logging from google_music_scripts.core import filter_songs @click.command(context_settings=CONTEXT_SETTINGS) @click.version_option(__version__, '-V', '--version', prog_name=__title__, message="%(prog)s %(version)s") @click.option('-l', '--log', is_flag=True, default=False, help="Log to file.") @click.option('-v', '--verbose', count=True) @click.option('-q', '--quiet', count=True) @click.option('-n', '--dry-run', is_flag=True, default=False, help="Output list of songs that would be deleted.") @click.option( '-u', '--username', metavar='USERNAME', default='', help="Your Google username or e-mail address.\nUsed to separate saved credentials." ) @click.option('--device-id', metavar='ID', help="A mobile device id.") @click.option('-y', '--yes', is_flag=True, default=False, help="Delete songs without asking for confirmation.") @click.option( '-f', '--filters', metavar='FILTER', multiple=True, callback=parse_filters, help="Metadata filters." ) def delete( log, verbose, quiet, dry_run, username, device_id, yes, filters): """Delete songs from a Google Music library.""" configure_logging(verbose - quiet, log_to_file=log) logger.info("Logging in to Google Music") mc = google_music.mobileclient(username, device_id=device_id) if not mc.is_authenticated: sys.exit("Failed to authenticate client.") to_delete = filter_songs(mc.songs(), filters) if not to_delete: logger.info("No songs to delete") elif dry_run: logger.info(f"Found {len(to_delete)} songs to delete") for song in to_delete: title = song.get('title', "") artist = song.get('artist', "") album = song.get('album', "") song_id = song['id'] logger.info(f"{title} -- {artist} -- {album} ({song_id})") else: confirm = yes or input( f"\nAre you sure you want to delete {len(to_delete)} song(s) from Google Music? (y/n) " ) in ("y", "Y") if confirm: song_num = 0 total = len(to_delete) pad = len(str(total)) for song in to_delete: song_num += 1 logger.debug(f"Deleting {title} -- {artist} -- {album} ({song_id})") mc.song_delete(song) title = song.get('title', "") artist = song.get('artist', "") album = song.get('album', "") song_id = song['id'] logger.info(f"Deleted {song_num:>{pad}}/{total}") else: logger.info("No songs deleted") mc.logout() logger.info("All done!") PK!qOJ )google_music_scripts/commands/download.pyimport os import sys import click import google_music from logzero import logger from google_music_scripts.__about__ import __title__, __version__ from google_music_scripts.cli import CONTEXT_SETTINGS, CustomPath, parse_filters from google_music_scripts.config import configure_logging from google_music_scripts.core import download_songs, filter_songs @click.command(context_settings=CONTEXT_SETTINGS) @click.version_option(__version__, '-V', '--version', prog_name=__title__, message="%(prog)s %(version)s") @click.option('-l', '--log', is_flag=True, default=False, help="Log to file.") @click.option('-v', '--verbose', count=True) @click.option('-q', '--quiet', count=True) @click.option('-n', '--dry-run', is_flag=True, default=False, help="Output list of songs that would be downloaded.") @click.option( '-u', '--username', metavar='USERNAME', default='', help="Your Google username or e-mail address.\nUsed to separate saved credentials." ) @click.option( '--uploader-id', metavar='ID', help="A unique id given as a MAC address (e.g. '00:11:22:33:AA:BB').\nThis should only be provided when the default does not work." ) @click.option( '-o', '--output', metavar='TEMPLATE_PATH', default=os.getcwd(), type=CustomPath(), help="Output file or directory name which can include template patterns." ) @click.option( '-f', '--filters', metavar='FILTER', multiple=True, callback=parse_filters, help="Metadata filters." ) def download( log, verbose, quiet, dry_run, username, uploader_id, output, filters): """Download songs from a Google Music library.""" configure_logging(verbose - quiet, log_to_file=log) logger.info("Logging in to Google Music") mm = google_music.musicmanager(username, uploader_id=uploader_id) if not mm.is_authenticated: sys.exit("Failed to authenticate client.") to_download = filter_songs(mm.songs(), filters) to_download.sort(key=lambda song: (song.get('artist'), song.get('album'), song.get('track_number'))) if not to_download: logger.info("No songs to download") elif dry_run: logger.info(f"Found {len(to_download)} songs to download") if logger.level <= 10: for song in to_download: title = song.get('title', "") artist = song.get('artist', "<artist>") album = song.get('album', "<album>") song_id = song['id'] logger.debug(f"{title} -- {artist} -- {album} ({song_id})") else: download_songs(mm, to_download, template=output) mm.logout() logger.info("All done!") PK�������!�׾����&���google_music_scripts/commands/quota.pyimport sys import click import google_music from logzero import logger from google_music_scripts.__about__ import __title__, __version__ from google_music_scripts.cli import CONTEXT_SETTINGS from google_music_scripts.config import configure_logging @click.command(context_settings=CONTEXT_SETTINGS) @click.version_option(__version__, '-V', '--version', prog_name=__title__, message="%(prog)s %(version)s") @click.option('-l', '--log', is_flag=True, default=False, help="Log to file.") @click.option( '-u', '--username', metavar='USERNAME', default='', help="Your Google username or e-mail address.\nUsed to separate saved credentials." ) @click.option( '--uploader-id', metavar='ID', help="A unique id given as a MAC address (e.g. '00:11:22:33:AA:BB').\nThis should only be provided when the default does not work." ) def quota( log, username, uploader_id): """Get the uploaded track count and allowance.""" configure_logging(0, log_to_file=log) logger.info("Logging in to Google Music") mm = google_music.musicmanager(username, uploader_id=uploader_id) if not mm.is_authenticated: sys.exit("Failed to authenticate client.") uploaded, allowed = mm.quota() logger.info(f"Quota -- {uploaded}/{allowed} ({uploaded / allowed:.2%})") PK�������!�ʪ����'���google_music_scripts/commands/search.pyimport sys import click import google_music from logzero import logger from google_music_scripts.__about__ import __title__, __version__ from google_music_scripts.cli import CONTEXT_SETTINGS, parse_filters from google_music_scripts.config import configure_logging from google_music_scripts.core import filter_songs @click.command(context_settings=CONTEXT_SETTINGS) @click.help_option('-h', '--help') @click.version_option(__version__, '-V', '--version', prog_name=__title__, message="%(prog)s %(version)s") @click.option('-l', '--log', is_flag=True, default=False, help="Log to file.") @click.option('-v', '--verbose', count=True) @click.option('-q', '--quiet', count=True) @click.option( '-u', '--username', metavar='USERNAME', default='', help="Your Google username or e-mail address.\nUsed to separate saved credentials." ) @click.option('-y', '--yes', is_flag=True, default=False, help="Display results without asking for confirmation.") @click.option('--device-id', metavar='ID', help="A mobile device id.") @click.option( '-f', '--filters', metavar='FILTER', multiple=True, callback=parse_filters, help="Metadata filters." ) def search( log, verbose, quiet, username, device_id, yes, filters): """Search a Google Music library for songs.""" configure_logging(verbose - quiet, log_to_file=log) logger.info("Logging in to Google Music") mc = google_music.mobileclient(username, device_id=device_id) if not mc.is_authenticated: sys.exit("Failed to authenticate client.") search_results = filter_songs(mc.songs(), filters) search_results.sort(key=lambda song: (song.get('artist'), song.get('album'), song.get('trackNumber'))) if search_results: result_num = 0 total = len(search_results) pad = len(str(total)) confirm = yes or input(f"\nDisplay {len(search_results)} results? (y/n) ") in ("y", "Y") if confirm: for result in search_results: result_num += 1 title = result.get('title', "<empty>") artist = result.get('artist', "<empty>") album = result.get('album', "<empty>") song_id = result['id'] logger.info(f"{result_num:>{pad}}/{total} {title} -- {artist} -- {album} ({song_id})") else: logger.info("No songs found matching query") mc.logout() logger.info("All done!") PK�������!�1m����%���google_music_scripts/commands/sync.pyimport os import sys import click import google_music import google_music_utils as gm_utils from click_default_group import DefaultGroup from logzero import logger from google_music_scripts.__about__ import __title__, __version__ from google_music_scripts.cli import CONTEXT_SETTINGS, CustomPath, default_to_cwd, parse_filters from google_music_scripts.config import configure_logging from google_music_scripts.core import ( download_songs, filter_songs, get_local_songs, upload_songs ) from google_music_scripts.utils import template_to_base_path @click.group(cls=DefaultGroup, default='up', default_if_no_args=True, context_settings=CONTEXT_SETTINGS) @click.version_option(__version__, '-V', '--version', prog_name=__title__, message="%(prog)s %(version)s") def sync(): """Sync songs to/from a Google Music library.""" pass @sync.command('down') @click.version_option(__version__, '-V', '--version', prog_name=__title__, message="%(prog)s %(version)s") @click.option('-l', '--log', is_flag=True, default=False, help="Log to file.") @click.option('-v', '--verbose', count=True) @click.option('-q', '--quiet', count=True) @click.option('-n', '--dry-run', is_flag=True, default=False, help="Output list of songs that would be downloaded.") @click.option( '-u', '--username', metavar='USERNAME', default='', help="Your Google username or e-mail address.\nUsed to separate saved credentials." ) @click.option( '--uploader-id', metavar='ID', help="A unique id given as a MAC address (e.g. '00:11:22:33:AA:BB').\nThis should only be provided when the default does not work." ) @click.option( '--no-recursion', is_flag=True, default=False, help="Disable recursion when scanning for local files.\nRecursion is enabled by default." ) @click.option( '--max-depth', metavar='DEPTH', type=int, help="Set maximum depth of recursion when scanning for local files.\nDefault is infinite recursion." ) @click.option( '-o', '--output', metavar='TEMPLATE_PATH', default=os.getcwd(), type=CustomPath(), help="Output file or directory name which can include template patterns." ) @click.option( '-f', '--filters', metavar='FILTER', multiple=True, callback=parse_filters, help="Metadata filters." ) @click.argument('include-paths', nargs=-1, type=CustomPath(resolve_path=True)) def sync_down( log, verbose, quiet, dry_run, username, uploader_id, no_recursion, max_depth, output, filters, include_paths): """Sync songs from a Google Music library.""" configure_logging(verbose - quiet, log_to_file=log) logger.info("Logging in to Google Music") mm = google_music.musicmanager(username, uploader_id=uploader_id) if not mm.is_authenticated: sys.exit("Failed to authenticate client.") if no_recursion: max_depth = 0 elif max_depth is None: max_depth = float('inf') google_songs = filter_songs(mm.songs(), filters) base_path = template_to_base_path(output, google_songs) filepaths = [base_path] if include_paths: filepaths.extend(include_paths) local_songs = get_local_songs(filepaths, max_depth=max_depth) logger.info("Comparing song collections") to_download = list( gm_utils.find_missing_items( google_songs, local_songs, fields=['artist', 'album', 'title', 'tracknumber'], normalize_values=True ) ) to_download.sort(key=lambda song: (song.get('artist'), song.get('album'), song.get('track_number'))) if not to_download: logger.info("No songs to download") elif dry_run: logger.info(f"Found {len(to_download)} songs to download") if logger.level <= 10: for song in to_download: title = song.get('title', "<title>") artist = song.get('artist', "<artist>") album = song.get('album', "<album>") song_id = song['id'] logger.debug(f"{title} -- {artist} -- {album} ({song_id})") else: download_songs(mm, to_download, template=output) mm.logout() logger.info("All done!") @sync.command('up') @click.version_option(__version__, '-V', '--version', prog_name=__title__, message="%(prog)s %(version)s") @click.option('-l', '--log', is_flag=True, default=False, help="Log to file.") @click.option('-v', '--verbose', count=True) @click.option('-q', '--quiet', count=True) @click.option('-n', '--dry-run', is_flag=True, default=False, help="Output list of songs that would be uploaded.") @click.option( '-u', '--username', metavar='USERNAME', default='', help="Your Google username or e-mail address.\nUsed to separate saved credentials." ) @click.option( '--uploader-id', metavar='ID', help="A unique id given as a MAC address (e.g. '00:11:22:33:AA:BB').\nThis should only be provided when the default does not work." ) @click.option( '--no-recursion', is_flag=True, default=False, help="Disable recursion when scanning for local files.\nRecursion is enabled by default." ) @click.option( '--max-depth', metavar='DEPTH', type=int, help="Set maximum depth of recursion when scanning for local files.\nDefault is infinite recursion." ) @click.option('--delete-on-success', is_flag=True, default=False, help="Delete successfully uploaded local files.") @click.option( '--transcode-lossless/--no-transcode-lossless', is_flag=True, default=True, help="Transcode lossless files to MP3 for upload." ) @click.option( '--transcode-lossy/--no-transcode-lossy', is_flag=True, default=True, help="Transcode non-MP3 lossy files to MP3 for upload." ) @click.option( '-f', '--filters', metavar='FILTER', multiple=True, callback=parse_filters, help="Metadata filters." ) @click.argument('input-paths', nargs=-1, type=CustomPath(resolve_path=True), callback=default_to_cwd) def sync_up( log, verbose, quiet, dry_run, username, uploader_id, no_recursion, max_depth, delete_on_success, transcode_lossless, transcode_lossy, filters, input_paths): """Sync songs to a Google Music library.""" configure_logging(verbose - quiet, log_to_file=log) logger.info("Logging in to Google Music") mm = google_music.musicmanager(username, uploader_id=uploader_id) if not mm.is_authenticated: sys.exit("Failed to authenticate client.") if no_recursion: max_depth = 0 elif max_depth is None: max_depth = float('inf') google_songs = mm.songs() local_songs = get_local_songs(input_paths, filters=filters, max_depth=max_depth) logger.info("Comparing song collections") to_upload = sorted( gm_utils.find_missing_items( local_songs, google_songs, fields=['artist', 'album', 'title', 'tracknumber'], normalize_values=True ) ) if not to_upload: logger.info("No songs to upload") elif dry_run: logger.info(f"Found {len(to_upload)} songs to upload") if logger.level <= 10: for song in to_upload: logger.debug(song) else: upload_songs( mm, to_upload, transcode_lossless=transcode_lossless, transcode_lossy=transcode_lossy, delete_on_success=delete_on_success ) mm.logout() logger.info("All done!") PK�������!�: ��: ��'���google_music_scripts/commands/upload.pyimport sys import click import google_music from logzero import logger from google_music_scripts.__about__ import __title__, __version__ from google_music_scripts.cli import CONTEXT_SETTINGS, CustomPath, default_to_cwd, parse_filters from google_music_scripts.config import configure_logging from google_music_scripts.core import get_local_songs, upload_songs @click.command(context_settings=CONTEXT_SETTINGS) @click.version_option(__version__, '-V', '--version', prog_name=__title__, message="%(prog)s %(version)s") @click.option('-l', '--log', is_flag=True, default=False, help="Log to file.") @click.option('-v', '--verbose', count=True) @click.option('-q', '--quiet', count=True) @click.option('-n', '--dry-run', is_flag=True, default=False, help="Output list of songs that would be uploaded.") @click.option( '-u', '--username', metavar='USERNAME', default='', help="Your Google username or e-mail address.\nUsed to separate saved credentials." ) @click.option( '--uploader-id', metavar='ID', help="A unique id given as a MAC address (e.g. '00:11:22:33:AA:BB').\nThis should only be provided when the default does not work." ) @click.option( '--no-recursion', is_flag=True, default=False, help="Disable recursion when scanning for local files.\nRecursion is enabled by default." ) @click.option( '--max-depth', metavar='DEPTH', type=int, help="Set maximum depth of recursion when scanning for local files.\nDefault is infinite recursion." ) @click.option('--delete-on-success', is_flag=True, default=False, help="Delete successfully uploaded local files.") @click.option( '--transcode-lossless/--no-transcode-lossless', is_flag=True, default=True, help="Transcode lossless files to MP3 for upload." ) @click.option( '--transcode-lossy/--no-transcode-lossy', is_flag=True, default=True, help="Transcode non-MP3 lossy files to MP3 for upload." ) @click.option( '-f', '--filters', metavar='FILTER', multiple=True, callback=parse_filters, help="Metadata filters." ) @click.argument('input-paths', nargs=-1, type=CustomPath(resolve_path=True), callback=default_to_cwd) def upload( log, verbose, quiet, dry_run, username, uploader_id, no_recursion, max_depth, delete_on_success, transcode_lossless, transcode_lossy, filters, input_paths): """Upload songs to a Google Music library.""" configure_logging(verbose - quiet, log_to_file=log) logger.info("Logging in to Google Music") mm = google_music.musicmanager(username, uploader_id=uploader_id) if not mm.is_authenticated: sys.exit("Failed to authenticate client.") if no_recursion: max_depth = 0 elif max_depth is None: max_depth = float('inf') to_upload = get_local_songs(input_paths, filters=filters, max_depth=max_depth) to_upload.sort() if not to_upload: logger.info("No songs to upload") elif dry_run: logger.info(f"Found {len(to_upload)} songs to upload") if logger.level <= 10: for song in to_upload: logger.debug(song) else: upload_songs( mm, to_upload, transcode_lossless=transcode_lossless, transcode_lossy=transcode_lossy, delete_on_success=delete_on_success ) mm.logout() logger.info("All done!") PK�������!�f[t��t�����google_music_scripts/config.pyimport logging import os import time import appdirs import logzero from .__about__ import __author__, __title__ LOG_FILEPATH = os.path.join(appdirs.user_data_dir(__title__, __author__), 'logs') LOG_FORMAT = '%(color)s[%(asctime)s]%(end_color)s %(message)s' LOG_FILE_FORMAT = '[%(asctime)s] %(message)s' LOGZERO_COLORS = logzero.LogFormatter.DEFAULT_COLORS LOG_COLORS = LOGZERO_COLORS.copy() # LOG_COLORS.update({25: LOGZERO_COLORS[20], 15: LOGZERO_COLORS[10]}) VERBOSITY_LOG_LEVELS = { 0: logging.CRITICAL, 1: logging.ERROR, 2: logging.WARNING, 3: logging.INFO, 4: logging.DEBUG } def ensure_log_filepath(): try: os.makedirs(LOG_FILEPATH) except OSError: if not os.path.isdir(LOG_FILEPATH): raise # TODO: For now, copying most of logzero's LogFormatter to hack in a different color # for upload/download success without changing the log level. class ResultFormatter(logzero.LogFormatter): def format(self, record): # noqa try: message = record.getMessage() assert isinstance(message, logzero.basestring_type) # guaranteed by logging # Encoding notes: The logging module prefers to work with character # strings, but only enforces that log messages are instances of # basestring. In python 2, non-ascii bytestrings will make # their way through the logging framework until they blow up with # an unhelpful decoding error (with this formatter it happens # when we attach the prefix, but there are other opportunities for # exceptions further along in the framework). # # If a byte string makes it this far, convert it to unicode to # ensure it will make it out to the logs. Use repr() as a fallback # to ensure that all byte strings can be converted successfully, # but don't do it by default so we don't add extra quotes to ascii # bytestrings. This is a bit of a hacky place to do this, but # it's worth it since the encoding errors that would otherwise # result are so useless (and tornado is fond of using utf8-encoded # byte strings whereever possible). record.message = logzero._safe_unicode(message) except Exception as e: record.message = "Bad message (%r): %r" % (e, record.__dict__) record.asctime = self.formatTime(record, self.datefmt) if record.levelno in self._colors: record.color = self._colors[record.levelno] record.end_color = self._normal else: record.color = record.end_color = '' if record.__dict__.get('success', True) is False: record.color = self._colors[30] formatted = self._fmt % record.__dict__ if record.exc_info: if not record.exc_text: record.exc_text = self.formatException(record.exc_info) if record.exc_text: # exc_text contains multiple lines. We need to _safe_unicode # each line separately so that non-utf8 bytes don't cause # all the newlines to turn into '\n'. lines = [formatted.rstrip()] lines.extend( logzero._safe_unicode(ln) for ln in record.exc_text.split('\n')) formatted = '\n'.join(lines) return formatted.replace("\n", "\n ") def configure_logging(modifier=0, log_to_file=False): stream_formatter = ResultFormatter(fmt=LOG_FORMAT, datefmt='%Y-%m-%d %H:%M:%S', colors=LOG_COLORS) logzero.setup_default_logger(formatter=stream_formatter) verbosity = 3 + modifier if verbosity < 0: verbosity = 0 elif verbosity > 4: verbosity = 4 log_level = VERBOSITY_LOG_LEVELS[verbosity] logzero.loglevel(log_level) if log_to_file: ensure_log_filepath() log_file = os.path.join(LOG_FILEPATH, time.strftime('%Y-%m-%d_%H-%M-%S') + '.log') file_formatter = logzero.LogFormatter(fmt=LOG_FILE_FORMAT, datefmt='%Y-%m-%d %H:%M:%S') logzero.logfile(log_file, formatter=file_formatter) PK�������!�]"u����!���google_music_scripts/constants.py__all__ = [ 'CHARACTER_REPLACEMENTS', 'TEMPLATE_PATTERNS', 'UNIX_PATH_RE' ] import re CHARACTER_REPLACEMENTS = { '\\': '-', '/': ',', ':': '-', '*': 'x', '<': '[', '>': ']', '|': '!', '?': '', '"': "''" } """dict: Mapping of invalid filepath characters with appropriate replacements.""" TEMPLATE_PATTERNS = { '%artist%': 'artist', '%title%': 'title', '%track%': 'tracknumber', '%track2%': 'tracknumber', '%album%': 'album', '%date%': 'date', '%genre%': 'genre', '%albumartist%': 'albumartist', '%disc%': 'discnumber' } """dict: Mapping of template patterns to their mutagen 'easy' field name.""" UNIX_PATH_RE = re.compile("^(?:/[^/]+)*/?$") """Regex pattern matching UNIX-style filepaths.""" PK�������!�Ww{h��h�����google_music_scripts/core.pyimport os import shutil import tempfile from collections import defaultdict import audio_metadata import google_music_utils as gm_utils from logzero import logger from .utils import get_supported_filepaths def download_songs(mm, songs, template=None): logger.info(f"Downloading {len(songs)} songs from Google Music") if not template: template = os.getcwd() songnum = 0 total = len(songs) pad = len(str(total)) for song in songs: songnum += 1 try: audio, _ = mm.download(song) except Exception as e: # TODO: More specific exception. logger.info( f"({songnum:>{pad}}/{total}) Failed -- {song} | {e}", extra={'success': False} ) else: temp = tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) temp.write(audio) tags = audio_metadata.load(temp.name).tags filepath = gm_utils.template_to_filepath(template, tags) + '.mp3' dirname = os.path.dirname(filepath) if dirname: try: os.makedirs(dirname) except OSError: if not os.path.isdir(dirname): raise temp.close() shutil.move(temp.name, filepath) logger.info( f"({songnum:>{pad}}/{total}) Downloaded -- {filepath} ({song['id']})", extra={'success': True} ) def filter_songs(songs, filters): if filters: logger.info("Filtering songs") matched_songs = [] for filter_ in filters: include_filters = defaultdict(list) exclude_filters = defaultdict(list) for _, oper, field, value in filter_: if oper in ['+', '']: include_filters[field].append(value) elif oper == '-': exclude_filters[field].append(value) matched = songs # Use all if multiple conditions for inclusion. i_use_all = (len(include_filters) > 1) or any(len(v) > 1 for v in include_filters.values()) i_any_all = all if i_use_all else any matched = gm_utils.include_items( matched, any_all=i_any_all, ignore_case=True, **include_filters ) # Use any if multiple conditions for exclusion. e_use_all = not ((len(exclude_filters) > 1) or any(len(v) > 1 for v in exclude_filters.values())) e_any_all = all if e_use_all else any matched = gm_utils.exclude_items( matched, any_all=e_any_all, ignore_case=True, **exclude_filters ) for song in matched: if song not in matched_songs: matched_songs.append(song) else: matched_songs = songs return matched_songs def get_local_songs( filepaths, *, filters=None, max_depth=float('inf')): logger.info("Loading local songs") local_songs = get_supported_filepaths(filepaths, max_depth=max_depth) matched_songs = filter_songs(local_songs, filters) return matched_songs def upload_songs( mm, filepaths, include_album_art=True, transcode_lossless=True, transcode_lossy=True, transcode_quality='320k', delete_on_success=False ): logger.info(f"Uploading {len(filepaths)} songs to Google Music") filenum = 0 total = len(filepaths) pad = len(str(total)) for song in filepaths: filenum += 1 result = mm.upload( song, transcode_lossless=transcode_lossless, transcode_lossy=transcode_lossy ) if result['reason'] == 'Uploaded': logger.info( f"({filenum:>{pad}}/{total}) Uploaded -- {result['filepath']} ({result['song_id']})", extra={'success': True} ) elif result['reason'] == 'Matched': logger.info( f"({filenum:>{pad}}/{total}) Matched -- {result['filepath']} ({result['song_id']})", extra={'success': True} ) else: if 'song_id' in result: logger.info( f"({filenum:>{pad}}/{total}) Already exists -- {result['filepath']} ({result['song_id']})", extra={'success': True} ) else: logger.info( f"({filenum:>{pad}}/{total}) Failed -- {result['filepath']} | {result['reason']}", extra={'success': False} ) if delete_on_success and 'song_id' in result: try: os.remove(result['filepath']) except (OSError): logger.warning(f"Failed to remove {result['filepath']} after successful upload") PK�������!�Ur ��r �����google_music_scripts/utils.py__all__ = [ 'convert_cygwin_path', 'delete_file', 'get_supported_filepaths', 'template_to_base_path', 'walk_depth' ] import os import subprocess import audio_metadata import google_music_utils as gm_utils from logzero import logger def convert_cygwin_path(filepath): """Convert Unix filepath string from Cygwin to Windows format. Parameters: filepath (str): A filepath string. Returns: str: A filepath string in Windows format. Raises: FileNotFoundError subprocess.CalledProcessError """ try: win_path = subprocess.run( ["cygpath", "-aw", filepath], check=True, stdout=subprocess.PIPE, universal_newlines=True ).stdout.strip() except (FileNotFoundError, subprocess.CalledProcessError): logger.exception("Call to cygpath failed.") raise return win_path def delete_file(filepath): try: os.remove(filepath) except (OSError, PermissionError): logger.warning(f"Failed to remove file: {filepath}.") def get_supported_filepaths(filepaths, max_depth=float('inf')): """Get supported audio files from given filepaths. Parameters: filepaths (list): Filepath(s) to check. max_depth (int): The depth in the directory tree to walk. A depth of '0' limits the walk to the top directory. Default: No limit. Returns: list: A list of filepaths with supported extensions. """ supported_filepaths = [] for path in filepaths: if os.path.isdir(path): for root, __, files in walk_depth(path, max_depth): for file_ in files: filepath = os.path.join(root, file_) with open(filepath, 'rb') as f: if audio_metadata.determine_format(f.read(4), extension=os.path.splitext(filepath)[1]) is not None: supported_filepaths.append(filepath) elif os.path.isfile(path): with open(path, 'rb') as f: if audio_metadata.determine_format(f.read(4), extension=os.path.splitext(path)[1]) is not None: supported_filepaths.append(path) return supported_filepaths def template_to_base_path(template, google_songs): """Get base output path for a list of songs for download.""" if template == os.getcwd() or template == '%suggested%': base_path = os.getcwd() else: song_paths = [os.path.abspath(gm_utils.template_to_filepath(template, song)) for song in google_songs] base_path = os.path.commonpath(song_paths) return base_path def walk_depth(path, max_depth=float('inf')): """Walk a directory tree with configurable depth. Parameters: path (str): A directory path to walk. max_depth (int): The depth in the directory tree to walk. A depth of '0' limits the walk to the top directory. Default: No limit. Yields: tuple: A 3-tuple ``(root, dirs, files)`` same as :func:`os.walk`. """ path = os.path.abspath(path) start_level = path.count(os.path.sep) for dir_entry in os.walk(path): root, dirs, _ = dir_entry level = root.count(os.path.sep) - start_level yield dir_entry if level >= max_depth: dirs[:] = [] PK������!H:f���(��5���google_music_scripts-1.1.1.dist-info/entry_points.txt}A @ѽ�A')Gnߘ?&+CGEÄ!2|f ٱ ֡|fJ-,Db#mU{w*(ڕz�PK�������!�݅L��L��,���google_music_scripts-1.1.1.dist-info/LICENSEThe MIT License (MIT) Copyright (c) 2018 thebigmunch <mail@thebigmunch.me> 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+dU���T���*���google_music_scripts-1.1.1.dist-info/WHEEL HM K-*ϳR03rOK-J,/R(O-)T0343 /, (-JLR()*M IL*4KM̫�PK������!H0^e"��H ��-���google_music_scripts-1.1.1.dist-info/METADATAVkO8_ᝑ5>tBˣXZAhE&ۡd^'-*Es_ x% 5Ni]q`&d)}2%GkIBE%{?\\*W,Ȝ9*2yfF# 4S*]ێ{[cQ^LjJ4>N\͸Ş eq+ 3</%}5nr~Ld!E]r6,LM]Quj|Ikomb@= ă%hpr/b$?&U, /=jwS{d!* uF}ױ/~;زg% yH<7cz iZtA#Ouދ,F.uwǒ ervt_ܝɩd"ۏzش\gRE<0XPAq*'棼H^ yy1 ȸd 5NKqʜ,amwy5n~�FܗL%%g @ZQT,y՞BV|F_[qN & }@IAh*vDq#*]ӌɳ ߕY®&T*TSdi2̪[Ktjvab1PN$@]yXQ7߆ԮlEc$-<KPJka! ǢUnPqEg3>lЕ]9紐d16ʞdFӻRcZT_KףAL/ATE =xʔ?s*`EȒeIUQGHsA zPEtn/<֖++$i`{1KTmUvtr1ܿ O$ 2LzY'!P 9R;(?8#gX![&#ǖXq+O*($x!AcixZ_M>~_aw 3X:{Z h;q 4!tn4`R3hJ^ m}wrpLfl8w$?#CIek0mmce 8++hRf*�gh\#fzH<W? zR* eop<u:]7V[߆ _ja#lRܟd:uOFs{=j}!OGt:űy09,A3؃�5\nΠ8 2(_E[r;فG4gW7k?PK������!HnK����+���google_music_scripts-1.1.1.dist-info/RECORDɒHE@ @bC0 $  !KQ?}/kS<Y<ecG')aWdf~^3}>)v-#eqUcBsطຫjK<& ol j-S~FBb;nu?\hݜkasj]ݘtU!#fMBȭk%@!\stDÒ$ԬPAr&hۤ?bw8U4>imҺTN[ڶz=d͊ϋ |G;gOa'<g?yP{PoFշ ~ϮIb`:\Ive ocwGlvG0 3 PƠ<�}U }zZ9-7"8A_"ꅯLL1JD<{6IIώ3ӂ{E,Ai:&Y}q k4jpnRZ²�a8%՜#:ksUpww b& K)=ыV@@H|m .k"WORB%7a$6j5f(d&tpz!/ zSG?h\ǽi;^q>rXS8,~&t/#WZz3פa_t]xH-.D{iggDWd*m!ZѺ+] \UNp3{zܙіwq7#_י??LMmOpz Mj_GyLg\O2o$TeZz+>9twI°<~BP,EFe| ,$섓n$>A, *[KG]2SToDž�! WAPK��������!�nD��D��!���������������google_music_scripts/__about__.pyPK��������!�q2��2�� �������������google_music_scripts/__init__.pyPK��������!�^P���P��� �������������google_music_scripts/__main__.pyPK��������!� \ZT ��T ���������������google_music_scripts/cli.pyPK��������!�������������)�������������google_music_scripts/commands/__init__.pyPK��������!�-*W ��W ��'�����������U��google_music_scripts/commands/delete.pyPK��������!�qOJ �� ��)�������������google_music_scripts/commands/download.pyPK��������!�׾����&�����������"��google_music_scripts/commands/quota.pyPK��������!�ʪ����'������������(��google_music_scripts/commands/search.pyPK��������!�1m����%����������� 1��google_music_scripts/commands/sync.pyPK��������!�: ��: ��'�����������K��google_music_scripts/commands/upload.pyPK��������!�f[t��t�������������^X��google_music_scripts/config.pyPK��������!�]"u����!�����������g��google_music_scripts/constants.pyPK��������!�Ww{h��h������������� j��google_music_scripts/core.pyPK��������!�Ur ��r �������������y��google_music_scripts/utils.pyPK�������!H:f���(��5�����������Z��google_music_scripts-1.1.1.dist-info/entry_points.txtPK��������!�݅L��L��,�������������google_music_scripts-1.1.1.dist-info/LICENSEPK�������!H+dU���T���*�������������google_music_scripts-1.1.1.dist-info/WHEELPK�������!H0^e"��H ��-�����������F��google_music_scripts-1.1.1.dist-info/METADATAPK�������!HnK����+�������������google_music_scripts-1.1.1.dist-info/RECORDPK������������