flac support and an argument for choosing when to embed cover art

This commit is contained in:
Lynne Megido 2020-10-17 17:26:25 +10:00
parent 801b7369c4
commit 124f0f7b42
Signed by: lynnesbian
GPG Key ID: F0A184B5213D9F90

117
bcao.py
View File

@ -6,19 +6,20 @@
# 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...
import argparse import argparse
import base64
import os import os
import re import re
import sys import sys
import tempfile import tempfile
import shutil import shutil
from os import path from os import path
from base64 import b64encode
from zipfile import ZipFile from zipfile import ZipFile
from pathlib import Path from pathlib import Path
from typing import Optional, List, Dict from typing import Optional, List, Dict
import mutagen import mutagen
from mutagen.flac import Picture from mutagen.flac import Picture, FLAC
from mutagen.oggvorbis import OggVorbis
from mutagen import id3 from mutagen import id3
from PIL import Image from PIL import Image
@ -37,24 +38,39 @@ def get_tag(mut_song: mutagen.FileType, tag: str) -> str:
tag = tag.replace("_", "") tag = tag.replace("_", "")
return sanitise(mut_song[tag][0] if type(mut_song[tag]) is list else mut_song[tag]) return sanitise(mut_song[tag][0] if type(mut_song[tag]) is list else mut_song[tag])
def has_cover(mut_song: mutagen.FileType):
if type(mut_song) is OggVorbis:
return "metadata_block_picture" in mut_song and len(mut_song["metadata_block_picture"]) != 0
if type(mut_song) is FLAC:
return len(mut_song.pictures) != 0
raise Exception(f"what is {type(mut_song)}")
def sanitise(in_str: str) -> str: def sanitise(in_str: str) -> str:
if args.sanitise: if args.sanitise:
return re.sub(r"[?\\/:|*\"<>]", "_", in_str) return re.sub(r"[?\\/:|*\"<>]", "_", in_str)
return in_str return in_str
parser = argparse.ArgumentParser(description="Extracts the given zip file downloaded from Bandcamp and organises it.") def parse_add_cover_images(value: str) -> str:
parser.add_argument('zip', help='The zip file to use') if value not in ["n", "a", "w"]:
raise argparse.ArgumentTypeError("Must be one of n, a, w")
return value
parser = argparse.ArgumentParser(usage='%(prog)s zip [options]',
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('-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('-d', '--destination', dest='destination', default='/home/lynne/Music/Music/', 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/") help="The directory to organise the music into. Default: %(default)s")
parser.add_argument('-q', '--quiet', dest='quiet', action='store_true', parser.add_argument('-q', '--quiet', dest='quiet', action='store_true',
help='Disable non-error output and assume default artist name.') help='Disable non-error output and assume default artist name.')
parser.add_argument('-u', '--unsanitised', dest='sanitise', action='store_false', parser.add_argument('-u', '--unsanitised', dest='sanitise', action='store_false',
help="Don't replace NTFS-unsafe characters with underscores. Not recommended.") help="Don't replace NTFS-unsafe characters with underscores. Not recommended.")
parser.add_argument('-t', '--threshold', dest='threshold', nargs=1, default=300, parser.add_argument('-t', '--threshold', dest='threshold', nargs=1, default=300,
help="Maximum acceptable cover art file size in kilobytes. Default: 300") help="Maximum acceptable file size for cover art, in kilobytes. Default: %(default)s")
args = parser.parse_args() args = parser.parse_args()
# convert args.treshold to bytes # convert args.threshold to bytes
args.threshold *= 1024 args.threshold *= 1024
if not path.exists(args.zip): if not path.exists(args.zip):
@ -68,7 +84,7 @@ song_names: List[str] = []
with ZipFile(args.zip, 'r') as zip_file: with ZipFile(args.zip, 'r') as zip_file:
for file in zip_file.namelist(): for file in zip_file.namelist():
if re.match(r"^(.+ - ){2}\d{2,} .+\.(ogg|flac|alac|aiff|wav|mp3|opus|m4a|aac|oga)$", file): if re.match(r"^(.+ - ){2}\d{2,} .+\.(ogg|flac|alac|aiff|wav|mp3|opus|m4a|aac)$", file):
# bandcamp zips contains songs with names formatted like "Album - Artist - 01 Song.mp3" # 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" # 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 # this regex should match only on those, and cut out (hopefully) all of the bonus material stuff, which shouldn't
@ -84,49 +100,48 @@ with ZipFile(args.zip, 'r') as zip_file:
# we'll need this to know what metadata format we should write # we'll need this to know what metadata format we should write
song_format: str = path.splitext(song_names[0])[1][1:] song_format: str = path.splitext(song_names[0])[1][1:]
log("Resizing album art to embed in songs...") if args.process_cover != 'n':
with Image.open(str(Path(tmp, cover))) as image: log("Resizing album art to embed in songs...")
temp_cover: Path = Path(tmp, "cover-lq.jpg") with Image.open(str(Path(tmp, cover))) as image:
temp_cover: Path = Path(tmp, "cover-lq.jpg")
if image.mode in ["RGBA", "P"]: if image.mode in ["RGBA", "P"]:
# remove alpha channel # remove alpha channel
image = image.convert("RGB") image = image.convert("RGB")
image.save(temp_cover, quality=85, optimize=True) image.save(temp_cover, quality=85, optimize=True)
image_smol = image image_smol = image
while path.getsize(temp_cover) > args.threshold: while path.getsize(temp_cover) > args.threshold:
# keep shrinking the image by 90% until it's less than {args.threshold} kilobytes # keep shrinking the image by 90% until it's less than {args.threshold} kilobytes
ratio = 0.9 ratio = 0.9
if path.getsize(temp_cover) > args.threshold * 2: 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 # if the file size of the cover is more than double the threshold, resize the cover image size by 80% instead
ratio = 0.8 ratio = 0.8
image_smol = image_smol.resize([round(n * ratio) for n in image_smol.size]) image_smol = image_smol.resize([round(n * ratio) for n in image_smol.size])
image_smol.save(temp_cover, quality=85, optimize=True) image_smol.save(temp_cover, quality=85, optimize=True)
if image_smol.size[0] == 10: if image_smol.size[0] == 10:
# something very bad has happened here # something very bad has happened here
die("Failed to resize image") 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()
# read the image file to get the file's raw data with Image.open(temp_cover) as image:
with open(temp_cover, 'r+b') as cover_file: if song_format == "ogg" or song_format == "flac":
data = cover_file.read() # i hate this
embed_cover = Picture()
with Image.open(temp_cover) as image: embed_cover.data = data
if song_format == "ogg": embed_cover.type = mutagen.id3.PictureType.COVER_FRONT
# i hate this embed_cover.mime = "image/jpeg"
embed_cover = Picture() embed_cover.width = image.size[0]
embed_cover.data = data embed_cover.height = image.size[1]
embed_cover.type = mutagen.id3.PictureType.COVER_FRONT embed_cover.depth = image.bits
embed_cover.mime = "image/jpeg" else:
embed_cover.width = image.size[0] log(f"Format {song_format} is not fully supported - cover images will not be modified", 1)
embed_cover.height = image.size[1]
embed_cover.depth = image.bits
embed_cover = base64.b64encode(embed_cover.write()).decode("ascii")
else:
log(f"Format {song_format} is not fully supported - cover images will not be modified", 1)
artists: List[str] = [] artists: List[str] = []
album: Optional[str] = None album: Optional[str] = None
@ -146,10 +161,16 @@ for song in song_names:
map(lambda x: artists.append(sanitise(x)), m['artist']) map(lambda x: artists.append(sanitise(x)), m['artist'])
songs[song] = f"{str(get_tag(m, 'track')).zfill(zeroes)} {get_tag(m, 'title')}.{song_format}" songs[song] = f"{str(get_tag(m, 'track')).zfill(zeroes)} {get_tag(m, 'title')}.{song_format}"
# embed cover art if args.process_cover == 'a' or (args.process_cover == 'w' and has_cover(m) is False):
if song_format == "ogg": log("Embedding cover art...")
m["metadata_block_picture"] = [embed_cover] # embed cover art
m.save() 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()
# remove duplicate artists # remove duplicate artists
artists = list(dict.fromkeys(artists)) artists = list(dict.fromkeys(artists))