From 7cb6545096a361e2d015ea6f37372e94f4c18cbf Mon Sep 17 00:00:00 2001 From: Lynnesbian Date: Sat, 17 Oct 2020 19:42:38 +1000 Subject: [PATCH] mp3 support! more helpful interface! better code! yahoo!! --- bcao.py | 117 ++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 88 insertions(+), 29 deletions(-) diff --git a/bcao.py b/bcao.py index fc4f78e..101807e 100755 --- a/bcao.py +++ b/bcao.py @@ -15,15 +15,26 @@ from os import path from base64 import b64encode from zipfile import ZipFile from pathlib import Path -from typing import Optional, List, Dict +from typing import Optional, Union, List, Dict import mutagen from mutagen.flac import Picture, FLAC from mutagen.oggvorbis import OggVorbis -from mutagen import id3 +from mutagen.mp3 import MP3 +from mutagen.id3 import APIC, PictureType from PIL import Image +vorbis_to_id3: Dict[str, str] = { + "track": "TRCK", + "artist": "TPE1", + "title": "TIT2", + "album": "TALB", + "album_artist": "TPE2" +} +fully_supported: List[str] = ["ogg", "flac", "mp3"] +MutagenFile = Union[MP3, FLAC, OggVorbis, mutagen.FileType] + def log(message: str, importance: int = 0): if not args.quiet or importance > 0: print(message) @@ -32,42 +43,63 @@ def die(message: str, code: int = 1): print(message) sys.exit(code) -def get_tag(mut_song: mutagen.FileType, tag: str) -> str: - if tag == "track": - tag = "tracknumber" - tag = tag.replace("_", "") - return sanitise(mut_song[tag][0] if type(mut_song[tag]) is list else mut_song[tag]) +def get_tag(mut_song: MutagenFile, tag: str, allow_list: bool = False, allow_sanitising: bool = True) -> str: + if isinstance(mut_song, MP3): + tag = vorbis_to_id3[tag] + tag_list = [str(x) for x in mut_song.tags.getall(tag)] + else: + if tag == "track": + tag = "tracknumber" + tag = tag.replace("_", "") + tag_list = mut_song[tag] if isinstance(mut_song[tag], list) else [mut_song[tag]] -def has_cover(mut_song: mutagen.FileType): - if type(mut_song) is OggVorbis: + # sanitise everything + if allow_sanitising: + tag_list = [sanitise(tag) for tag in tag_list] + + if allow_list: + return tag_list + + return tag_list[0] + +def has_cover(mut_song: MutagenFile): + if isinstance(mut_song, OggVorbis): return "metadata_block_picture" in mut_song and len(mut_song["metadata_block_picture"]) != 0 - if type(mut_song) is FLAC: + + if isinstance(mut_song, FLAC): return len(mut_song.pictures) != 0 - raise Exception(f"what is {type(mut_song)}") + + if isinstance(mut_song, MP3): + apics: List[APIC] = mut_song.tags.getall("APIC") + for apic in apics: + if apic.type == PictureType.COVER_FRONT: + return True + return False + + return False def sanitise(in_str: str) -> str: if args.sanitise: return re.sub(r"[?\\/:|*\"<>]", "_", in_str) return in_str -def parse_add_cover_images(value: str) -> str: - if value not in ["n", "a", "w"]: - raise argparse.ArgumentTypeError("Must be one of n, a, w") - return value - +# noinspection PyTypeChecker parser = argparse.ArgumentParser(usage='%(prog)s zip [options]', - description="Extracts the given zip file downloaded from Bandcamp and organises it.") + formatter_class=argparse.RawTextHelpFormatter, + description="Extracts the given zip file downloaded from Bandcamp and organises it.", + epilog=f"Cover art can only be embedded in files of the following types: {', '.join(fully_supported).upper()}.\nIf " + "the music is in any other format, %(prog)s will behave as though you passed the flag '-c n'.") parser.add_argument('zip', help='The zip file to use.') -parser.add_argument('-c', '--add-cover-images', dest='process_cover', default='w', type=parse_add_cover_images, - help="When to embed cover art into songs. Options: [n]ever, [a]lways, [w]hen necessary. Default: %(default)s") +parser.add_argument('-c', '--add-cover-images', dest='process_cover', default='w', choices=['n', 'a', 'w'], + help="When to embed cover art into songs.\nOptions: [n]ever, [a]lways, [w]hen necessary.\nDefault: %(default)s") parser.add_argument('-d', '--destination', dest='destination', default='/home/lynne/Music/Music/', - help="The directory to organise the music into. Default: %(default)s") + help="The directory to organise the music into.\nDefault: %(default)s") parser.add_argument('-q', '--quiet', dest='quiet', action='store_true', help='Disable non-error output and assume default artist name.') parser.add_argument('-u', '--unsanitised', dest='sanitise', action='store_false', help="Don't replace NTFS-unsafe characters with underscores. Not recommended.") parser.add_argument('-t', '--threshold', dest='threshold', nargs=1, default=300, - help="Maximum acceptable file size for cover art, in kilobytes. Default: %(default)s") + help="Maximum acceptable file size for cover art, in kilobytes.\nDefault: %(default)s") args = parser.parse_args() # convert args.threshold to bytes @@ -99,6 +131,9 @@ with ZipFile(args.zip, 'r') as zip_file: # save the format of the songs (ogg, mp3, etc) # we'll need this to know what metadata format we should write song_format: str = path.splitext(song_names[0])[1][1:] +if song_format not in fully_supported: + log(f"Format {song_format} is not fully supported - cover images will not be modified", 1) + args.process_cover = 'n' if args.process_cover != 'n': log("Resizing album art to embed in songs...") @@ -131,17 +166,39 @@ if args.process_cover != 'n': data = cover_file.read() with Image.open(temp_cover) as image: - if song_format == "ogg" or song_format == "flac": + if song_format in ["ogg", "flac"]: # i hate this embed_cover = Picture() embed_cover.data = data - embed_cover.type = mutagen.id3.PictureType.COVER_FRONT + embed_cover.type = PictureType.COVER_FRONT embed_cover.mime = "image/jpeg" embed_cover.width = image.size[0] embed_cover.height = image.size[1] embed_cover.depth = image.bits - else: - log(f"Format {song_format} is not fully supported - cover images will not be modified", 1) + elif song_format == "mp3": + # apparently APIC files get compressed on save if they are "large": + # https://mutagen.readthedocs.io/en/latest/api/id3_frames.html#mutagen.id3.APIC + # i don't know what that means (lossless text compression? automatic JPEG conversion?) and i don't know if or how + # i can disable it, which kinda sucks... + # if, for example, mutagen's threshold for "large" is 200KiB, then any file over that size would be reduced to + # below it, either by resizing or JPEG quality reduction or whatever, making the -t flag useless for values above + # 200 when saving MP3 files. + # the most i can tell is that mutagen uses zlib compression in some way or another for reading ID3 tags: + # https://github.com/quodlibet/mutagen/blob/release-1.45.1/mutagen/id3/_frames.py#L265 + # however, it seems not to use zlib when *writing* tags, citing itunes incompatibility, in particular with APIC: + # https://github.com/quodlibet/mutagen/blob/release-1.45.1/mutagen/id3/_tags.py#L510 + # given that this is the only reference to compression that i could find in the source code, and it says that + # ID3v2 compression was disabled for itunes compatibility, i'm going to assume/hope it doesn't do anything weird. + # it's worth noting that mutagen has no dependencies outside of python's stdlib, which (currently) doesn't contain + # any method for JPEG compression, so i'm 99% sure the files won't be mangled. + + embed_cover = APIC( + encoding=3, # utf-8 + mime="image/jpeg", + type=PictureType.COVER_FRONT, + desc='cover', + data=data + ) artists: List[str] = [] album: Optional[str] = None @@ -150,7 +207,7 @@ zeroes = min(len(song_names), 2) first_loop: bool = True for song in song_names: - m = mutagen.File(Path(tmp, song)) + m: MutagenFile = mutagen.File(Path(tmp, song)) if first_loop: # the first item in the artists list should be the album artist artists.append(get_tag(m, "album_artist")) @@ -158,7 +215,7 @@ for song in song_names: first_loop = False # add the song's artist(s) to the list - map(lambda x: artists.append(sanitise(x)), m['artist']) + map(artists.append, get_tag(m, "artist", allow_list=True)) songs[song] = f"{str(get_tag(m, 'track')).zfill(zeroes)} {get_tag(m, 'title')}.{song_format}" if args.process_cover == 'a' or (args.process_cover == 'w' and has_cover(m) is False): @@ -166,11 +223,13 @@ for song in song_names: # embed cover art if song_format == "ogg": m["metadata_block_picture"] = [b64encode(embed_cover.write()).decode("ascii")] - m.save() elif song_format == "flac": m.clear_pictures() m.add_picture(embed_cover) - m.save() + elif song_format == "mp3": + m.tags.add(embed_cover) + + m.save() # remove duplicate artists artists = list(dict.fromkeys(artists))