a huge HUGE rewrite that made it faster and less buggy and awful and dependent on linux and BOY this was overdue

This commit is contained in:
Lynne Megido 2020-10-16 23:30:26 +10:00
parent 5eaa942eab
commit 83458f9f30
Signed by: lynnesbian
GPG key ID: F0A184B5213D9F90
3 changed files with 136 additions and 142 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
!bcao.py
!requirements.txt
!.gitignore
*

272
bcao.py
View file

@ -5,45 +5,56 @@
# input: a .zip from bandcamp # input: a .zip from bandcamp
# output: it organises it, adds cover art, puts it in the right place... # 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 argparse
import subprocess, argparse, sys, os, re, base64 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): def log(message: str, importance: int = 0):
if not args.quiet or importance > 0: if not args.quiet or importance > 0:
print(message) print(message)
def die(message: str, code: int = 1): def die(message: str, code: int = 1):
log(message) print(message)
exit(code) exit(code)
try: def get_tag(m: mutagen.FileType, tag: str):
from mutagen.oggvorbis import OggVorbis if tag == "title":
from mutagen.mp3 import MP3 return sanitise(m['title'][0])
from mutagen.flac import FLAC elif tag == "track":
from mutagen.flac import Picture return int(m['tracknumber'][0])
from mutagen.aac import AAC elif tag == "album":
except ImportError: return sanitise(m['album'][0])
# TODO: requirements.txt else:
die("Please install python3-mutagen (pip install mutagen)") # may as well try
return sanitise(m[tag])
try: def sanitise(input: str):
# TODO: see if there's a decent zip library for python if args.sanitise:
subprocess.check_output(["unzip", "--help"]) return re.sub(r"[?\\/:|*\"<>]", "_", input)
except: else:
die("Please install unzip (apt install unzip)") return input
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")
parser = argparse.ArgumentParser(description="Extracts the given zip file downloaded from Bandcamp and organises it.") 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('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('-d', '--destination', dest='destination', 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.') help="The directory to organise the music into. Default: /home/lynne/Music/Music/")
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('-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() args = parser.parse_args()
@ -51,129 +62,106 @@ if not os.path.exists(args.zip):
die(f"Couldn't find {args.zip}.", 2) die(f"Couldn't find {args.zip}.", 2)
log("Extracting...") log("Extracting...")
zipname = os.path.splitext(os.path.basename(args.zip))[0] tmp = tempfile.TemporaryDirectory()
tmp = "/tmp/bcao/{0}".format(zipname) # TODO: use OS specific temp dirs cover = None
subprocess.check_output(["rm", "-rf", tmp]) song_names = []
subprocess.check_output(['mkdir', '-p', tmp])
subprocess.check_output(["unzip", args.zip, "-d", tmp])
files = []
songs = []
for root, dirs, filez in os.walk(tmp): with ZipFile(args.zip, 'r') as zip_file:
for file in filez: # for every file for file in zip_file.namelist():
files.append(root + os.sep + file) 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 = [] artists = []
album = "" album = None
fileExtensionRegex = re.compile(r"[^.]+$") songs = {}
probablyASongRegex = re.compile(r"^.+ - .+ - \d{2,} .+\.[^.]+$") # matches "artist - album - 01 track.xyz" but not "some weird bonus thing.mp3" zeroes = min(len(song_names), 2)
musicExts = ["ogg", "flac", "alac", "aiff", "wav", "mp3", "opus", "m4a", "aac", "oga"] for song in song_names:
bannedCharacters = ["?", "\\", "/", ":", "|", "*", "\"", "<", ">"] # characters disallowed in NTFS filenames ext = os.path.splitext(song)[1:]
trackCount = 0 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.") if len(artists) > 1 and "Various Artists" not in artists:
# 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:
artists.append("Various Artists") artists.append("Various Artists")
if cover == "": artist = None
# TODO: HANDLE THIS PROPERLY while artist is None:
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:
log("Artist directory:") log("Artist directory:")
for i in range(len(artists)): for i in range(len(artists)):
log("{0}) {1}".format(i + 1, artists[i])) log(f"{i+1}) {artists[i]}")
choice = 1 if args.quiet else input("> ") log(f"{len(artists) + 1}) Custom...")
try: choice = "1" if args.quiet else input("> ")
choice = artists[int(choice) - 1] if choice.isdecimal():
except KeyError: if int(choice) == len(artists) + 1:
log(f"Please choose an option from 1 to {len(artists)}.") log("Enter the name to use.")
continue else:
break try:
if choice == "Custom...": artist = artists[int(choice) - 1]
log("Enter the name to use.") except KeyError:
choice = input("> ") log(f"Please choose a number between 1 and {len(artists) + 1}.")
# log("Setting artist to {0}".format(choice)) else:
artist = choice log(f"Please choose a number between 1 and {len(artists) + 1}")
mPath = os.path.join(args.destination, artist, album) destination = os.path.join(args.destination, artist, album)
subprocess.check_output(["mkdir", "-p", mPath]) log(f"Moving files to {destination}...")
os.makedirs(destination, exist_ok = True)
for root, dirs, filez in os.walk(tmp): for source_name, dest_name in songs.items():
for file in filez: # for every file shutil.move(os.path.join(tmp, source_name), os.path.join(destination, dest_name))
# TODO: i'm 99% sure python has a built in move command shutil.move(os.path.join(tmp, "cover.jpg"), os.path.join(destination, "cover.jpg"))
# 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])
tmp.cleanup()
log("Done!") log("Done!")

2
requirements.txt Normal file
View file

@ -0,0 +1,2 @@
mutagen~=1.45
Pillow~=8.0