mp3 support! more helpful interface! better code! yahoo!!

This commit is contained in:
Lynne Megido 2020-10-17 19:42:38 +10:00
parent 124f0f7b42
commit 7cb6545096
Signed by: lynnesbian
GPG key ID: F0A184B5213D9F90

117
bcao.py
View file

@ -15,15 +15,26 @@ from os import path
from base64 import b64encode 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, Union, List, Dict
import mutagen import mutagen
from mutagen.flac import Picture, FLAC from mutagen.flac import Picture, FLAC
from mutagen.oggvorbis import OggVorbis from mutagen.oggvorbis import OggVorbis
from mutagen import id3 from mutagen.mp3 import MP3
from mutagen.id3 import APIC, PictureType
from PIL import Image 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): 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)
@ -32,42 +43,63 @@ def die(message: str, code: int = 1):
print(message) print(message)
sys.exit(code) sys.exit(code)
def get_tag(mut_song: mutagen.FileType, tag: str) -> str: def get_tag(mut_song: MutagenFile, tag: str, allow_list: bool = False, allow_sanitising: bool = True) -> str:
if tag == "track": if isinstance(mut_song, MP3):
tag = "tracknumber" tag = vorbis_to_id3[tag]
tag = tag.replace("_", "") tag_list = [str(x) for x in mut_song.tags.getall(tag)]
return sanitise(mut_song[tag][0] if type(mut_song[tag]) is list else mut_song[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): # sanitise everything
if type(mut_song) is OggVorbis: 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 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 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: 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
def parse_add_cover_images(value: str) -> str: # noinspection PyTypeChecker
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]', 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('zip', help='The zip file to use.')
parser.add_argument('-c', '--add-cover-images', dest='process_cover', default='w', type=parse_add_cover_images, parser.add_argument('-c', '--add-cover-images', dest='process_cover', default='w', choices=['n', 'a', 'w'],
help="When to embed cover art into songs. Options: [n]ever, [a]lways, [w]hen necessary. Default: %(default)s") 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/', 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', 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 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() args = parser.parse_args()
# convert args.threshold to bytes # 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) # save the format of the songs (ogg, mp3, etc)
# 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:]
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': if args.process_cover != 'n':
log("Resizing album art to embed in songs...") log("Resizing album art to embed in songs...")
@ -131,17 +166,39 @@ if args.process_cover != 'n':
data = cover_file.read() data = cover_file.read()
with Image.open(temp_cover) as image: with Image.open(temp_cover) as image:
if song_format == "ogg" or song_format == "flac": if song_format in ["ogg", "flac"]:
# i hate this # i hate this
embed_cover = Picture() embed_cover = Picture()
embed_cover.data = data 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.mime = "image/jpeg"
embed_cover.width = image.size[0] embed_cover.width = image.size[0]
embed_cover.height = image.size[1] embed_cover.height = image.size[1]
embed_cover.depth = image.bits embed_cover.depth = image.bits
else: elif song_format == "mp3":
log(f"Format {song_format} is not fully supported - cover images will not be modified", 1) # 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] = [] artists: List[str] = []
album: Optional[str] = None album: Optional[str] = None
@ -150,7 +207,7 @@ zeroes = min(len(song_names), 2)
first_loop: bool = True first_loop: bool = True
for song in song_names: for song in song_names:
m = mutagen.File(Path(tmp, song)) m: MutagenFile = mutagen.File(Path(tmp, song))
if first_loop: if first_loop:
# the first item in the artists list should be the album artist # the first item in the artists list should be the album artist
artists.append(get_tag(m, "album_artist")) artists.append(get_tag(m, "album_artist"))
@ -158,7 +215,7 @@ for song in song_names:
first_loop = False first_loop = False
# add the song's artist(s) to the list # 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}" 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): 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 # embed cover art
if song_format == "ogg": if song_format == "ogg":
m["metadata_block_picture"] = [b64encode(embed_cover.write()).decode("ascii")] m["metadata_block_picture"] = [b64encode(embed_cover.write()).decode("ascii")]
m.save()
elif song_format == "flac": elif song_format == "flac":
m.clear_pictures() m.clear_pictures()
m.add_picture(embed_cover) m.add_picture(embed_cover)
m.save() elif song_format == "mp3":
m.tags.add(embed_cover)
m.save()
# remove duplicate artists # remove duplicate artists
artists = list(dict.fromkeys(artists)) artists = list(dict.fromkeys(artists))