def ask_user_for_input(question: str, abortion: str) -> str: try: user_input = input(question) except (KeyboardInterrupt, EOFError): raise ApitError(abortion) else: if not user_input: raise ApitError(abortion) return user_input
def extract_album_with_songs(metadata_json: str) -> Album: try: itunes_data = json.loads(metadata_json) except json.JSONDecodeError: raise ApitError( 'Apple Music/iTunes Store metadata results format error') if 'results' not in itunes_data or 'resultCount' not in itunes_data or itunes_data[ 'resultCount'] == 0: raise ApitError('Apple Music/iTunes Store metadata results empty') return _find_album_with_songs(itunes_data['results'])
def main(options) -> int: configure_logging(options.verbose_level) logging.info('CLI options: %s', options) files = collect_files(options.path, FILE_FILTER) if len(files) == 0: raise ApitError('No matching files found') logging.info('Input path: %s', options.path) options.cache_path = Path(CACHE_PATH).expanduser() ActionType: Type[Action] = find_action_type(options.command, AVAILAIBLE_ACTIONS) action_options: Dict[str, Any] = ActionType.to_action_options(options) actions: List[Action] = [ActionType(file, action_options) for file in files] if any_action_needs_confirmation(actions): print_actions_preview(actions) ask_user_for_confirmation() for action in actions: action.apply() print_report(actions) return 0 if all_actions_successful(actions) else 1
def _find_album(music_data) -> Album: for item in music_data: if 'collectionType' in item and item['collectionType'] in [ 'Album', 'Compilation' ]: return Album(item) raise ApitError('No album found in metadata')
def fetch_store_json(url: str) -> str: openUrl = urllib.request.urlopen(url) if openUrl.getcode() != 200: raise ApitError( 'Connection to Apple Music/iTunes Store failed with error code: %s' % openUrl.getcode()) return openUrl.read()
def _find_atomicparsley_executable(locations) -> Path: for filename in locations: path = Path(filename).expanduser() if path.is_file(): return path raise ApitError('AtomicParsley executable not found.')
def not_actionable_msg(self) -> str: if not self.action.file_matched: return 'filename not matchable' elif self.action.options['is_original']: # TODO refactor access return 'original iTunes Store file' elif not self.action.metadata_matched: return 'file not matched against metadata' raise ApitError('Unknown state')
def generate_store_lookup_url(user_url: str) -> str: match = REGEX_STORE_URL_COUNTRY_CODE_ID.match(user_url) if not match: raise ApitError(f'Invalid URL format: {user_url}') country_code = match.groupdict()['country_code'] album_id = match.groupdict()['id'] return f'https://itunes.apple.com/lookup?entity=song&country={country_code}&id={album_id}'
def _read_artwork_content(artwork_path: Path) -> mutagen.mp4.MP4Cover: artwork_content = artwork_path.read_bytes() if artwork_path.suffix == '.jpg': return mutagen.mp4.MP4Cover( artwork_content, imageformat=mutagen.mp4.MP4Cover.FORMAT_JPEG) elif artwork_path.suffix == '.png': return mutagen.mp4.MP4Cover( artwork_content, imageformat=mutagen.mp4.MP4Cover.FORMAT_PNG) raise ApitError('Unknown artwork image type')
def generate_lookup_url_by_url(source: str) -> str: match = REGEX_STORE_URL.match(source) if not match: raise ApitError(f'Invalid URL format: {source}') country_code = match.groupdict()['country_code'] album_id = match.groupdict()['id'] return _generate_metadata_lookup_url(album_id, country_code)
def determine_system_country_code() -> str: import locale system_language, _ = locale.getdefaultlocale() country_match = LANGUAGE_COUNTRY_REGEX.match(system_language) if not country_match: raise ApitError( 'Impossible to determine system country code. Use another possibility as metadata input source' ) return country_match.groupdict()['country_code']
def status_msg(self) -> str: # TODO review conditions if not self.action.actionable: return f'[skipped: {self.not_actionable_msg}]' if not self.action.successful: return '[error]' if self.action.executed and self.action.successful: return 'tagged' raise ApitError('Invalid state')
def is_itunes_bought_file(file: Path) -> bool: try: mp4_file = read_metadata(file) if not mp4_file.tags: raise ApitError("No tags present") except ApitError: return False else: return any(map(lambda item: item in mp4_file.tags, BLACKLIST))
def download_metadata(url: str) -> str: try: with urllib.request.urlopen(url) as response: data_read = response.read() return data_read.decode('utf-8') except urllib.error.URLError as e: raise ApitError( 'Connection to Apple Music/iTunes Store failed due to error: %s' % str(e))
def _escape_inner_quotes(string: str) -> str: match = REGEX_OUTER_QUOTE.match(string) if not match: raise ApitError(f'An error occured while escaping: {string}') return ''.join([ match.groupdict()['start'], match.groupdict()['inner'].replace('"', '\\"'), match.groupdict()['end'], ])
def apply(self) -> None: try: result = read_metadata(self.file) if not result.tags: raise ApitError("No tags present") except ApitError as e: self.mark_as_fail(e) else: self.mark_as_success(result)
def get_metadata_json(source: str) -> str: logging.info('Input source: %s', source) if Path(source).exists(): logging.info('Use downloaded metadata file: %s', source) try: return Path(source).read_text() except Exception: raise ApitError('Error while reading metadata file: %s' % Path(source)) elif is_url(source): logging.info('Use URL to download metadata: %s', source) query_url = generate_lookup_url_by_url(source) logging.info('Query URL: %s', query_url) return download_metadata(query_url) elif isinstance(source, str): logging.info('Use URL composition to download metadata: %s', source) query_url = generate_lookup_url_by_str(source) logging.info('Query URL: %s', query_url) return download_metadata(query_url) raise ApitError(f"Invalid input source: {source}")
def add_song(self, song: Song): disc: int = song['discNumber'] track: int = song['trackNumber'] if self.has_song(disc=disc, track=track): raise ApitError( 'Adding a song with duplicate disc {} and track number is impossible' ) self.discs[disc][track] = song
def download_artwork(url: str) -> Tuple[bytes, MIME_TYPE]: try: with urllib.request.urlopen(url) as response: content_type = response.getheader('Content-Type') logging.info('Headers: %s', response.info()) return response.read(), _to_mime_type(content_type) except urllib.error.URLError as e: raise ApitError( 'Connection to Apple Music/iTunes Store failed due to error: %s' % str(e))
def get_metadata_json(source) -> str: logging.info('Input source: %s', source) if is_url(source): logging.info('Use URL to download metadata: %s', source) query_url = generate_store_lookup_url(source) logging.info('Query URL: %s', query_url) return fetch_store_json(query_url) elif Path(source).exists(): logging.info('Use downloaded metadata file: %s', source) return Path(source).read_text() else: raise ApitError(f"Invalid input source: {source}")
def main(options) -> int: configure_logging(options.verbose_level) logging.info('CLI options: %s', options) files = collect_files(options.path, FILE_FILTER) if len(files) == 0: raise ApitError('No matching files found') logging.info('Input path: %s', options.path) options.cache_path = Path(CACHE_PATH).expanduser() CommandType: Type[Command] = determine_command_type(options.command) return CommandType().execute(files, options)
def to_pre_action_options(options) -> Mapping[str, Union[List[Song], bool]]: source: str = options.source if not source: source = ask_user_for_input( question='Input Apple Music/iTunes Store URL (starts with https://music.apple.com/...): ', abortion='Incompatible Apple Music/iTunes Store URL provided' ) metadata_json = get_metadata_json(source) songs = extract_songs(metadata_json) first_song = songs[0] # TODO refactor # TODO fix possible IndexError if options.has_search_result_cache_flag and is_url(source): # TODO find better location for this code if not len(songs): raise ApitError('Failed to generate a cache filename due to missing song') metadata_cache_file = generate_cache_filename(options.cache_path, first_song) save_metadata_to_cache(metadata_json, metadata_cache_file) logging.info('Downloaded metadata cached in: %s', metadata_cache_file) artwork_path = None if options.has_embed_artwork_flag: artwork_path = get_cached_artwork_path_if_exists(first_song, options) if artwork_path: logging.info('Use cached cover: %s', artwork_path) else: size = options.artwork_size upscaled_url = upscale_artwork_url(first_song, size) logging.info('Use cover link (with size %d): %s', size, upscaled_url) logging.info('Download cover (with size %d) from: %s', size, upscaled_url) if options.has_search_result_cache_flag: artwork_cache_path = options.cache_path else: import tempfile artwork_cache_path = Path(tempfile.gettempdir()) artwork_content, image_type = download_artwork(upscaled_url) artwork_path = generate_artwork_filename(artwork_cache_path, first_song, image_type) save_artwork_to_cache(artwork_content, artwork_path) logging.info('Cover cached in: %s', artwork_path) return { 'songs': songs, 'should_backup': options.has_backup_flag, 'cover_path': artwork_path, }
def generate_lookup_url_by_str(source: str) -> str: match = ID_WITH_OPTIONAL_COUNTRY_CODE_AND_SEPARATOR.match(source) if not match: raise ApitError(f'Invalid URL format: {source}') if match.groupdict()['country_code']: # user has provided country code country_code = match.groupdict()['country_code'] else: country_code = determine_system_country_code() country_code = country_code.lower() album_id = match.groupdict()['id'] return _generate_metadata_lookup_url(album_id, country_code)
def test_read_action_apply_error_while_reading(monkeypatch): error = ApitError('mock-error') def _raise(*args): raise error monkeypatch.setattr('apit.commands.show.action.read_metadata', _raise) action = ReadAction(Path('./tests/fixtures/folder-iteration/1 first.m4a'), {}) mock_mark_as_fail = MagicMock() monkeypatch.setattr(action, 'mark_as_fail', mock_mark_as_fail) action.apply() assert mock_mark_as_fail.call_args == call(error)
def update_metadata(file: Path, song: Song, cover_path: Optional[Path] = None) -> mutagen.mp4.MP4: mp4_file = read_metadata(file) if cover_path: artwork = _read_artwork_content(cover_path) _modify_mp4_file(mp4_file, song, artwork) else: _modify_mp4_file(mp4_file, song) # TODO error handling try: mp4_file.save() except Exception as e: raise ApitError(e) else: return mp4_file
def collect_files(path_string: str, filter_ext: Optional[Union[List[str], str]] = None) -> List[Path]: path = Path(path_string).expanduser() if not path.exists(): raise ApitError(f'Invalid path: {path}') if path.is_file(): unfiltered_files = [path] elif path.is_dir(): unfiltered_files = [Path(f) for f in os.scandir(path) if f.is_file()] sorted_files = sorted(unfiltered_files) if not filter_ext: return sorted_files if isinstance(filter_ext, str): filter_ext = [filter_ext] return [f for f in sorted_files if f.suffix in filter_ext]
def test_tag_action_apply_error(monkeypatch, test_song: Song): error = ApitError('mock-error') def _raise(*args): raise error monkeypatch.setattr('apit.commands.tag.action.update_metadata', _raise) action = TagAction(Path('./tests/fixtures/folder-iteration/1 first.m4a'), {}) monkeypatch.setitem(action.options, 'song', test_song) monkeypatch.setitem(action.options, 'disc', test_song.disc_number) monkeypatch.setitem(action.options, 'track', test_song.track_number) monkeypatch.setitem(action.options, 'is_original', False) monkeypatch.setitem(action.options, 'should_backup', False) monkeypatch.setitem(action.options, 'cover_path', None) mock_mark_as_fail = MagicMock() monkeypatch.setattr(action, 'mark_as_fail', mock_mark_as_fail) action.apply() assert mock_mark_as_fail.call_args == call(error)
def status_msg(self) -> str: if not self.action.successful: return '[error]' if self.action.successful: return 'successful' raise ApitError('Invalid state') # TODO refactor
def _to_mime_type(content_type: str) -> MIME_TYPE: try: image_type = MIME_TYPE(content_type) except ValueError: raise ApitError('Unknown artwork content type: %s' % content_type) return image_type
def determine_command_type(command_name: str) -> Type[Command]: try: return AVAILABLE_COMMANDS[command_name] except KeyError: raise ApitError(f"Command '{command_name}' not found")