#!/usr/bin/env python3 # BCAO - BandCamp Automatic Organiser # copyright 2018-2019 @LynnearSoftware@fedi.lynnesbian.space # Licensed under the GPLv3: https://www.gnu.org/licenses/gpl-3.0.html#content # input: a .zip from bandcamp # output: it organises it, adds cover art, puts it in the right place... import argparse import io import os import re import sys import tempfile import shutil from os import path from base64 import b64encode from zipfile import ZipFile from pathlib import Path from typing import Optional, Union, List, Dict # pycharm tells me some of these classes shouldn't be imported because they're not declared in __all__. # however, the mutagen docs show example code where someone creates a mutagen.flac.Picture by referring to it as # Picture(), implying that they had imported mutagen.flac.Picture, and therefore i'm right and the computer is WRONG # https://mutagen.readthedocs.io/en/latest/api/flac.html#mutagen.Picture.data import mutagen # noinspection PyProtectedMember from mutagen.flac import Picture, FLAC from mutagen.oggvorbis import OggVorbis from mutagen.mp3 import MP3 from mutagen.mp4 import MP4, MP4Cover # noinspection PyProtectedMember from mutagen.id3 import APIC, PictureType from PIL import Image fully_supported: List[str] = ["ogg", "flac", "mp3", "m4a", "wav"] MutagenFile = Union[MP3, FLAC, OggVorbis, mutagen.FileType] MutagenTags = Union[mutagen.id3.ID3Tags, mutagen.mp4.Tags, mutagen.oggvorbis.OggVCommentDict] args: argparse.Namespace class SongInfo: tag_lookup: Dict[str, Dict[str, str]] = { "track": {"id3": "TRCK", "m4a": "trkn", "vorbis": "tracknumber"}, "artist": {"id3": "TPE1", "m4a": "©ART", "vorbis": "artist"}, "title": {"id3": "TIT2", "m4a": "©nam", "vorbis": "title"}, "album": {"id3": "TALB", "m4a": "©alb", "vorbis": "album"}, "album_artist": {"id3": "TPE2", "m4a": "aART", "vorbis": "albumartist"} } format_lookup: Dict[str, str] = { "mp3": "id3", "m4a": "m4a", "ogg": "vorbis", "flac": "vorbis" } def __init__(self, file_name: Path): self.m_file: MutagenFile = mutagen.File(file_name) self.m_tags: MutagenTags = self.m_file.tags self.file_name = str(file_name.name) self.format = path.splitext(file_name)[1][1:] self.fallback = False fallbacks = re.match( r"^(?P.+) - (?P.+) - (?P\d{2,}) (?P.+)\.(?:ogg|flac|alac|aiff|wav|mp3|m4a)$", self.file_name ) if fallbacks is None: die("Couldn't determine fallback tags!") return # needed for mypy # set default values for the tags, in case the file is missing any (or all!) of them self.tags: Dict[str, str] = { "track": fallbacks.group("track"), "artist": fallbacks.group("artist"), "title": fallbacks.group("title"), "album": fallbacks.group("album"), "album_artist": fallbacks.group("artist") } # set list_tags to the default tags in list form # i.e. for every tag, set list_tags[x] = [tags[x]] self.list_tags: Dict[str, List[str]] = dict((x[0], [x[1]]) for x in self.tags.items()) if self.m_tags is None: self.fallback = True else: for standard_name, tag_set in self.tag_lookup.items(): tag = tag_set[self.format_lookup[self.format]] if tag not in self.m_tags: print(f"{tag} not in self.m_tags") self.fallback = True continue value_list = self.m_tags[tag] if self.format == "m4a" and tag == "track": # every tag in the MP4 file (from what i can tell) is a list # this includes the track number tag, which is a tuple of ints in a list. # because every other format is either a non-list, or a list of non-lists, we need to account for this case # (a list of lists of non-lists) specially, by turning it into a list of non-lists. value_list = value_list[0] if not isinstance(value_list, (list, tuple)): value_list = [value_list] # convert the list of strings/ID3 frames/ints/whatevers to sanitised strings value_list = [sanitise(str(val)) for val in value_list] self.tags[standard_name] = value_list[0] self.list_tags[standard_name] = value_list def get_target_name(self, zeroes: int): return f"{self.tags['track'].zfill(zeroes)} {self.tags['title']}.{self.format}" def has_cover(self): if self.format == "ogg": return "metadata_block_picture" in self.m_tags and len(self.m_tags["metadata_block_picture"]) != 0 if self.format == "flac": return len(self.m_file.pictures) != 0 if self.format == "mp3": apics: List[APIC] = self.m_tags.getall("APIC") for apic in apics: if apic.type == PictureType.COVER_FRONT: return True return False if self.format == "m4a": return 'covr' in self.m_tags and len(self.m_tags['covr']) != 0 raise NotImplementedError("Song format not yet implemented.") def set_cover(self, to_embed: Union[Picture, APIC, MP4Cover]): # embed cover art if self.format == "ogg": self.m_tags["metadata_block_picture"] = [b64encode(to_embed.write()).decode("ascii")] elif self.format == "flac": self.m_file.clear_pictures() self.m_file.add_picture(to_embed) elif self.format == "mp3": self.m_tags.add(to_embed) elif self.format == "m4a": self.m_tags['covr'] = [to_embed] self.m_file.save() def __getitem__(self, item): return self.tags[item] def log(message: str, importance: int = 0): if not args.quiet or importance > 0: print(message) def die(message: str, code: int = 1): print(message) sys.exit(code) 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 isinstance(mut_song, FLAC): return len(mut_song.pictures) != 0 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 if isinstance(mut_song, MP4): return 'covr' in mut_song and len(mut_song['covr']) != 0 raise NotImplementedError("Song format not yet implemented.") def sanitise(in_str: str) -> str: if args.sanitise: return re.sub(r"[?\\/:|*\"<>]", "_", in_str) return in_str def main(): # noinspection PyTypeChecker parser = argparse.ArgumentParser(usage='%(prog)s zip [options]', 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()}.\n" "If the song is in any other format, %(prog)s will behave as though you passed '-c n', " "but will otherwise work normally.\nIf the song files contain no metadata, %(prog)s will attempt " "to parse the song's filenames to retrieve the artist, album, title, and track number.") parser.add_argument('zip', help='The zip file to use.') 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.\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.\nDefault: %(default)s") global args args = parser.parse_args() # convert args.threshold to bytes args.threshold *= 1024 if not path.exists(args.zip): die(f"Couldn't find {args.zip}.", 2) log("Extracting...") tmp_dir: tempfile.TemporaryDirectory = tempfile.TemporaryDirectory() tmp: str = tmp_dir.name cover: Optional[str] = None song_names: List[str] = [] with ZipFile(args.zip, 'r') as zip_file: for file in zip_file.namelist(): if re.match(r"^(.+ - ){2}\d{2,} .+\.(ogg|flac|alac|aiff|wav|mp3|m4a)$", file): # bandcamp zips contains songs with names formatted like "Album - Artist - 01 Song.mp3" # for example, "King Crimson - In the Wake of Poseidon - 02 Pictures of a City.ogg" # this regex should match only on those, and cut out (hopefully) all of the bonus material stuff, which shouldn't # be added to the music folder (since i sync that to my phone, and "making of" videos are cool, but i don't # have the space for it). song_names.append(file) zip_file.extract(file, tmp) elif cover is None and re.match(r"cover\.(jpe?g|png)", file): cover = file zip_file.extract(file, tmp) # 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 cover is None: die("Unable to find cover image!") return # needed for mypy if args.process_cover != 'n': log("Resizing album art to embed in songs...") with Image.open(str(Path(tmp, cover))) as image: temp_cover: Path = Path(tmp, "cover-lq.jpg") if image.mode in ["RGBA", "P"]: # remove alpha channel image = image.convert("RGB") image.save(temp_cover, quality=85, optimize=True) image_smol = image while path.getsize(temp_cover) > args.threshold: # keep shrinking the image by 90% until it's less than {args.threshold} kilobytes ratio = 0.9 if path.getsize(temp_cover) > args.threshold * 2: # if the file size of the cover is more than double the threshold, resize the cover image size by 80% instead ratio = 0.8 image_smol = image_smol.resize([round(n * ratio) for n in image_smol.size]) image_smol.save(temp_cover, quality=85, optimize=True) if image_smol.size[0] == 10: # something very bad has happened here die("Failed to resize image") # read the image file to get its raw data with open(temp_cover, 'r+b') as cover_file: data = cover_file.read() # it's really strange that the more annoying the file's metadata is, the *less* annoying it is to create cover art # for it in mutagen. # vorbis: open standard, so easy to use that mutagen supplies a bunch of "easy" wrappers around other formats to # make them work more like mutagen. # cover-annoy-o-meter: high. mutagen requires you to specify the width, height, colour depth, etc etc # id3: well documented, but rather cryptic (which is more understandable, "album_artist" or "TPE2"). # cover-annoy-o-meter: not bad at all - at least you get a constructor this time - although it is kinda annoying # that you have to specify the file encoding, and how you need both a type and a desc. # m4a: scarce documentation, closed format, half reverse engineered from whatever itunes is doing, exists pretty # much exclusively in the realm of apple stuff. # cover-annoy-o-meter: all you need is the file data and the format type. if song_format in ["ogg", "flac"]: # i hate this with Image.open(io.BytesIO(data)) as image: embed_cover = Picture() embed_cover.data = data 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 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 ) elif song_format == "m4a": embed_cover = MP4Cover( data=data, imageformat=MP4Cover.FORMAT_JPEG ) artists: List[str] = [] album: Optional[str] = None songs: Dict[str, str] = {} zeroes = min(len(song_names), 2) first_loop: bool = True for song_name in song_names: song = SongInfo(Path(tmp, song_name)) if first_loop: # the first item in the artists list should be the album artist artists.append(song["album_artist"]) album = song["album"] first_loop = False # add the song's artist(s) to the list map(artists.append, song.list_tags["artist"]) songs[song_name] = song.get_target_name(zeroes) if args.process_cover == 'a' or (args.process_cover == 'w' and song.has_cover() is False): song.set_cover(embed_cover) # remove duplicate artists artists = list(dict.fromkeys(artists)) if len(artists) > 1 and "Various Artists" not in artists: artists.append("Various Artists") artist: Optional[str] = None while artist is None: log("Artist directory:") for i, artist_name in enumerate(artists): log(f"{i+1}) {artist_name}") log(f"{len(artists) + 1}) Custom...") user_choice: str = "1" if args.quiet else input("> ") if user_choice.isdecimal(): choice: int = int(user_choice) if choice == len(artists) + 1: log("Enter the name to use:") artist = input("> ") else: try: artist = artists[choice - 1] except KeyError: log(f"Please choose a number between 1 and {len(artists) + 1}.") else: log(f"Please choose a number between 1 and {len(artists) + 1}") destination: Path = Path(args.destination, artist, album) log(f"Moving files to \"{destination}\"...") os.makedirs(destination, exist_ok=True) for source_name, dest_name in songs.items(): shutil.move(str(Path(tmp, source_name)), str(Path(destination, dest_name))) shutil.move(str(Path(tmp, cover)), str(Path(destination, cover))) tmp_dir.cleanup() log("Done!") if __name__ == "__main__": main()