diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9e2b3a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +!bcao.py +!requirements.txt +!.gitignore +* \ No newline at end of file diff --git a/bcao.py b/bcao.py index 2e33b1b..7e070bd 100755 --- a/bcao.py +++ b/bcao.py @@ -5,45 +5,56 @@ # input: a .zip from bandcamp # output: it organises it, adds cover art, puts it in the right place... -# TODO: this is written like a hacky bash script and i hate it -import subprocess, argparse, sys, os, re, base64 +import argparse +import base64 +import os +import re +import subprocess +import tempfile +import shutil +from zipfile import ZipFile, BadZipFile + +import mutagen +from mutagen.flac import Picture +from mutagen import id3 + +from PIL import Image def log(message: str, importance: int = 0): if not args.quiet or importance > 0: print(message) def die(message: str, code: int = 1): - log(message) + print(message) exit(code) -try: - from mutagen.oggvorbis import OggVorbis - from mutagen.mp3 import MP3 - from mutagen.flac import FLAC - from mutagen.flac import Picture - from mutagen.aac import AAC -except ImportError: - # TODO: requirements.txt - die("Please install python3-mutagen (pip install mutagen)") +def get_tag(m: mutagen.FileType, tag: str): + if tag == "title": + return sanitise(m['title'][0]) + elif tag == "track": + return int(m['tracknumber'][0]) + elif tag == "album": + return sanitise(m['album'][0]) + else: + # may as well try + return sanitise(m[tag]) -try: - # TODO: see if there's a decent zip library for python - subprocess.check_output(["unzip", "--help"]) -except: - die("Please install unzip (apt install unzip)") - -try: - # TODO: don't use which ffs - subprocess.check_output(["which", "convert"]) - subprocess.check_output(["which", "identify"]) -except: - die("Please install imagemagick, and ensure convert and identify are in your $PATH (apt install imagemagick") +def sanitise(input: str): + if args.sanitise: + return re.sub(r"[?\\/:|*\"<>]", "_", input) + else: + return input parser = argparse.ArgumentParser(description="Extracts the given zip file downloaded from Bandcamp and organises it.") parser.add_argument('zip', help='The zip file to use') -parser.add_argument('-d', '--destination', dest='destination', default='/home/lynne/Music/Music/', help="The directory to organise the music into. Default: /home/lynne/Music/Music/") -parser.add_argument('-q', '--quiet', dest='quiet', action='store_true', help='Disable non-error output and assume default artist name.') -parser.add_argument('-t', '--threshold', dest='threshold', nargs=1, default=300, help="Maximum acceptable cover art file size in kilobytes. Default: 300") +parser.add_argument('-d', '--destination', dest='destination', default='/home/lynne/Music/Music/', + help="The directory to organise the music into. Default: /home/lynne/Music/Music/") +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 cover art file size in kilobytes. Default: 300") args = parser.parse_args() @@ -51,129 +62,106 @@ if not os.path.exists(args.zip): die(f"Couldn't find {args.zip}.", 2) log("Extracting...") -zipname = os.path.splitext(os.path.basename(args.zip))[0] -tmp = "/tmp/bcao/{0}".format(zipname) # TODO: use OS specific temp dirs -subprocess.check_output(["rm", "-rf", tmp]) -subprocess.check_output(['mkdir', '-p', tmp]) -subprocess.check_output(["unzip", args.zip, "-d", tmp]) -files = [] -songs = [] +tmp = tempfile.TemporaryDirectory() +cover = None +song_names = [] -for root, dirs, filez in os.walk(tmp): - for file in filez: # for every file - files.append(root + os.sep + file) +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|opus|m4a|aac|oga)$", 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 = os.path.splitext(song_names[0])[1][1:] + +log("Resizing album art to embed in songs...") + +with Image.open(os.path.join(tmp, cover)) as image: + temp_cover = os.path.join(tmp, "cover-lq.jpg") + + image.save(temp_cover, quality=85, optimize=True) + image_smol = image + + # keep shrinking the image by 90% until it's less than {args.threshold} kilobytes + while os.path.getsize(temp_cover) / 1024 > args.threshold: + image_smol = image_smol.resize((round(image_smol.size[0] * 0.9), round(image_smol.size[1] * 0.9))) + image_smol.save(temp_cover, quality=85, optimize=True) + if image_smol.size[0] == 10: + die("Failed to resize image") + + +# read the image file to get the file's raw data +with open(temp_cover, 'r+b') as cover_file: + data = cover_file.read() + +with Image.open(temp_cover) as image: + if song_format == "ogg": + # i hate this + cover = Picture() + cover.data = data + cover.type = mutagen.id3.PictureType.COVER_FRONT + cover.mime = "image/jpeg" + cover.width = image.size[0] + cover.height = image.size[1] + cover.depth = image.bits + cover = base64.b64encode(cover.write()).decode("ascii") + else: + log(f"Format {song_format} is not fully supported - cover images will not be modified", 1) -# TODO: redo everything from here to the cover art resizing -cover = "" artists = [] -album = "" -fileExtensionRegex = re.compile(r"[^.]+$") -probablyASongRegex = re.compile(r"^.+ - .+ - \d{2,} .+\.[^.]+$") # matches "artist - album - 01 track.xyz" but not "some weird bonus thing.mp3" -musicExts = ["ogg", "flac", "alac", "aiff", "wav", "mp3", "opus", "m4a", "aac", "oga"] -bannedCharacters = ["?", "\\", "/", ":", "|", "*", "\"", "<", ">"] # characters disallowed in NTFS filenames -trackCount = 0 +album = None +songs = {} +zeroes = min(len(song_names), 2) +for song in song_names: + ext = os.path.splitext(song)[1:] + m = mutagen.File(os.path.join(tmp, song)) + # add the song's artist to the list, if it hasn't been seen yet + [artists.append(sanitise(artist)) for artist in m['artist'] if artist not in artists] + songs[song] = f"{str(get_tag(m, 'track')).zfill(zeroes)} {get_tag(m, 'title')}.{song_format}" + album = get_tag(m, "album") + # embed cover art + if song_format == "ogg": + m["metadata_block_picture"] = [cover] + m.save() -log("Processing... please wait.") -# use "01 Song.ogg" instead of "1 Song.ogg". Also works for albums with more than 99 tracks if that ever happens -trackNumberLength = len(str(len(files))) -if trackNumberLength < 2: - trackNumberLength = 2 - -for file in files: - if os.path.basename(file).lower() in ["cover.png", "cover.jpg", "cover.gif"]: - # we've found our cover art - cover = file - ext = re.search(fileExtensionRegex, file.lower()).group(0) - if ext in musicExts: - if re.search(probablyASongRegex, file) != None: - # this is definitely a song and probably not a bonus sound file or whatever - if ext == "ogg": - f = OggVorbis(file) - name = "{0} {1}.{2}".format(f["TRACKNUMBER"][0].zfill(trackNumberLength), f["TITLE"][0], ext) - for bc in bannedCharacters: - if bc in name: - name = name.replace(bc, "-") # replace banned characters with dashes - songs.append(name) - if album == "": - album = f["ALBUM"][0] - if f["ARTIST"][0] not in artists: - artists.append(f["ARTIST"][0]) - trackCount = trackCount + 1 - subprocess.check_output(["mv", file, os.path.dirname(file) + os.sep + name]) - - else: - log("UNSUPPORTED FORMAT BECAUSE LYNNE IS A LAZY MORON") -if len(artists) > 1: +if len(artists) > 1 and "Various Artists" not in artists: artists.append("Various Artists") -if cover == "": - # TODO: HANDLE THIS PROPERLY - die("couldn't find the cover art :)))))") - -while os.path.getsize(cover) / 1024 > args.threshold: - if os.path.basename(cover) != "cover-lq.jpg": - nucover = os.path.dirname(cover) + os.sep + "cover-lq.jpg" - subprocess.check_output(["convert", cover, "-quality", "85", "-strip", nucover]) # convert the file to a jpeg - cover = nucover - else: - subprocess.check_output(["convert", cover, "-resize", "90%", cover]) # shrink it slightly - -with open(cover, "rb") as cvr: - data = cvr.read() - -imginfo = subprocess.check_output(["identify", "-ping", "-format", r"%w,%h,%m,%[bit-depth]", cover]).decode("utf-8").split(",") - -# TODO: surely you don't have to do this?? does mutagen not have a higher level way of setting an image? -picture = Picture() -picture.data = data -picture.type = 3 -picture.mime = "image/{0}".format(imginfo[2].lower()) -picture.width = int(imginfo[0]) -picture.height = int(imginfo[1]) -picture.depth = int(imginfo[3]) - -picture_data = picture.write() -encoded_data = base64.b64encode(picture_data) -vcomment_value = encoded_data.decode("ascii") - -for song in songs: - f = OggVorbis(tmp + os.sep + song) - f["metadata_block_picture"] = [vcomment_value] - f.save() - -artist = artists[0] -artists.append("Custom...") - -# log("Please choose the artist name to use when creating the folder.") -choice = 0 -while True: +artist = None +while artist is None: log("Artist directory:") for i in range(len(artists)): - log("{0}) {1}".format(i + 1, artists[i])) - choice = 1 if args.quiet else input("> ") - try: - choice = artists[int(choice) - 1] - except KeyError: - log(f"Please choose an option from 1 to {len(artists)}.") - continue - break -if choice == "Custom...": - log("Enter the name to use.") - choice = input("> ") -# log("Setting artist to {0}".format(choice)) -artist = choice + log(f"{i+1}) {artists[i]}") + log(f"{len(artists) + 1}) Custom...") + choice = "1" if args.quiet else input("> ") + if choice.isdecimal(): + if int(choice) == len(artists) + 1: + log("Enter the name to use.") + else: + try: + artist = artists[int(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}") -mPath = os.path.join(args.destination, artist, album) -subprocess.check_output(["mkdir", "-p", mPath]) - -for root, dirs, filez in os.walk(tmp): - for file in filez: # for every file - # TODO: i'm 99% sure python has a built in move command - # TODO: os.path.join - subprocess.check_output(["mv", root + os.sep + file, mPath + os.sep + file]) - -log("Deleting {}...".format(tmp)) -# TODO: i'm 100% sure python has a built in remove command -subprocess.check_output(["rm", "-rf", tmp]) +destination = os.path.join(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(os.path.join(tmp, source_name), os.path.join(destination, dest_name)) +shutil.move(os.path.join(tmp, "cover.jpg"), os.path.join(destination, "cover.jpg")) +tmp.cleanup() log("Done!") + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..71ed53d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +mutagen~=1.45 +Pillow~=8.0