sane-tag-music: support tagging album art

This commit is contained in:
2024-07-09 00:52:00 +00:00
parent 79c8521f38
commit 607845d495

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env nix-shell #!/usr/bin/env nix-shell
#!nix-shell -i python3 -p python3 -p python3.pkgs.mutagen #!nix-shell -i python3 -p python3 -p python3.pkgs.mutagen -p python3.pkgs.pyexiftool
# vim: set filetype=python : # vim: set filetype=python :
# #
# mutagen docs: # mutagen docs:
@@ -45,6 +45,7 @@ import mutagen.flac
import mutagen.mp3 import mutagen.mp3
import mutagen.oggopus import mutagen.oggopus
import mutagen.oggvorbis import mutagen.oggvorbis
import exiftool
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -217,7 +218,8 @@ class Tags:
def to_path(self, ext: str) -> str | None: def to_path(self, ext: str) -> str | None:
artist = self.albumartist or self.artist artist = self.albumartist or self.artist
is_single = self.album in [self.albumartist, self.artist] is_single = self.album in [self.albumartist, self.artist]
if not (artist and self.album and self.title and (self.tracknumber or is_single) and ext): # if not (artist and self.album and self.title and (self.tracknumber or is_single) and ext):
if not (artist and self.album and self.title and ext):
return None return None
artist = clean_for_fs(artist[0], single_field=False) artist = clean_for_fs(artist[0], single_field=False)
@@ -309,10 +311,39 @@ class Tags:
return tags return tags
class MediaFile:
class AudioFile:
def __init__(self, path_: str): def __init__(self, path_: str):
self.path_ = path_ self.path_ = path_
def __repr__(self) -> str:
return self.path_
@staticmethod
def new(f: 'str | MediaFile | None') -> 'MediaFile | None':
if f is None:
return None
if isinstance(f, str):
lower = f.lower()
if lower.endswith(".png") or lower.endswith(".jpg") or lower.endswith("jpeg"):
f = ImageFile(f)
else:
f = AudioFile(f)
if isinstance(f, AudioFile) and f.muta is None:
return None
if isinstance(f, ImageFile) and f.exif is None:
return None
return f
def tags_on_disk(self) -> Tags:
raise NotImplementedError()
def write_tags(self, tags: Tags) -> bool:
raise NotImplementedError()
class AudioFile(MediaFile):
def __init__(self, path_: str):
super().__init__(path_)
self.muta = None self.muta = None
_base, ext = os.path.splitext(path_) _base, ext = os.path.splitext(path_)
@@ -337,18 +368,6 @@ class AudioFile:
except Exception as e: except Exception as e:
logger.warning(f"failed to open {path_}: {e}") logger.warning(f"failed to open {path_}: {e}")
def __repr__(self) -> str:
return self.path_
@staticmethod
def new(f: 'str | AudioFile | None') -> 'AudioFile | None':
if f is None:
return None
if not isinstance(f, AudioFile):
f = AudioFile(f)
if f.muta is not None:
return f
def tags_on_disk(self) -> Tags: def tags_on_disk(self) -> Tags:
return Tags( return Tags(
artist=self.muta.get('artist', []) if self.muta else [], artist=self.muta.get('artist', []) if self.muta else [],
@@ -369,24 +388,88 @@ class AudioFile:
elif name in self.muta: elif name in self.muta:
del self.muta[name] del self.muta[name]
set_tag('artist', tags.artist)
set_tag('album', tags.album) set_tag('album', tags.album)
set_tag('title', tags.title)
set_tag('albumartist', tags.albumartist) set_tag('albumartist', tags.albumartist)
set_tag('artist', tags.artist)
set_tag('title', tags.title)
set_tag('tracknumber', tags.tracknumber) set_tag('tracknumber', tags.tracknumber)
logger.debug(f"writing full tags: {self.muta}") logger.debug(f"writing full tags: {self.muta}")
self.muta.save() self.muta.save()
class ImageFile(MediaFile):
_exiftool = None # static, lazy init
# standard EXIF tag names: <https://exiftool.org/TagNames/EXIF.html>
def __init__(self, path_: str):
super().__init__(path_)
self.exif = None
try:
self.exif, = self.exiftool.get_metadata([path_])
except Exception as e:
logger.warning(f"failed to open {path_}: {e}")
@property
def exiftool(self):
if ImageFile._exiftool is None:
ImageFile._exiftool = exiftool.ExifToolHelper()
return ImageFile._exiftool
def tags_on_disk(self) -> Tags:
# print(self.exif)
def get_tag(name: str) -> list:
if name in self.exif:
return [ self.exif[name] ]
elif f"EXIF:{name}" in self.exif:
return [ self.exif[f"EXIF:{name}"] ]
elif f"XMP:{name}" in self.exif:
return [ self.exif[f"XMP:{name}"] ]
else:
return []
return Tags(
artist=get_tag("Photographer"),
album=get_tag("XPSubject"),
albumartist=get_tag("Artist"),
title=get_tag("Title"),
tracknumber=get_tag("ImageNumber"),
)
def write_tags(self, tags: Tags) -> bool:
if self.exif is None:
logger.debug(f"not writing tags because unable to load existing: {self.path_}")
return False
def set_tag(name: str, val: list):
if len(val) == 1:
self.exif[name] = val[0]
elif len(val) == 0:
if name in self.exif:
del self.exif[name]
else:
logger.warning(f"multiple values for tag {name}: {val!r}")
# self.exif.CameraLabel = tags.album
# self.exif.PageName = tags.album
# self.exif.UserComment = tags.album
set_tag("XPSubject", tags.album)
set_tag("Photographer", tags.artist)
set_tag("Artist", tags.albumartist)
set_tag("Title", tags.title)
set_tag("ImageNumber", tags.tracknumber)
logger.debug(f"writing full tags: {self.exif}")
self.exiftool.set_tags([ self.path_ ], tags=self.exif, params=["-P", "-overwrite_original"])
class Tagger: class Tagger:
def __init__(self, dry_run: bool, force: bool, manual_tags: Tags): def __init__(self, dry_run: bool, force: bool, manual_tags: Tags):
self.dry_run = dry_run self.dry_run = dry_run
self.force = force self.force = force
self.manual_tags = manual_tags self.manual_tags = manual_tags
def show(self, path_: str | AudioFile) -> None: def show(self, path_: str | MediaFile) -> None:
file_ = AudioFile.new(path_) file_ = MediaFile.new(path_)
if not file_: if not file_:
logger.debug(f"skipping unsupported file: {path_}") logger.debug(f"skipping unsupported file: {path_}")
return return
@@ -395,8 +478,8 @@ class Tagger:
logger.info(f"tags for {path_}:") logger.info(f"tags for {path_}:")
logger.info(f" {tags}") logger.info(f" {tags}")
def is_sufficiently_tagged(self, file_: str | AudioFile) -> bool: def is_sufficiently_tagged(self, file_: str | MediaFile) -> bool:
file_ = AudioFile.new(file_) file_ = MediaFile.new(file_)
if not file_: if not file_:
return False return False
tags = file_.tags_on_disk() tags = file_.tags_on_disk()
@@ -404,7 +487,7 @@ class Tagger:
return (tags.artist or tags.albumartist) and tags.album and tags.title return (tags.artist or tags.albumartist) and tags.album and tags.title
def tag_file(self, path_: str) -> None: def tag_file(self, path_: str) -> None:
file_ = AudioFile.new(path_) file_ = MediaFile.new(path_)
if not file_: if not file_:
logger.debug(f"skipping unsupported file: {path_}") logger.debug(f"skipping unsupported file: {path_}")
return return
@@ -438,7 +521,7 @@ class Tagger:
file_.write_tags(new_tags) file_.write_tags(new_tags)
def fix_path(self, path_: str) -> None: def fix_path(self, path_: str) -> None:
file_ = AudioFile.new(path_) file_ = MediaFile.new(path_)
if not file_: if not file_:
logger.debug(f"skipping unsupported file: {path_}") logger.debug(f"skipping unsupported file: {path_}")
return return
@@ -455,6 +538,8 @@ class Tagger:
if self.confirm(): if self.confirm():
if self.guard_dry_run(f"moving file: {path_} -> {new_path}"): if self.guard_dry_run(f"moving file: {path_} -> {new_path}"):
logger.info(f"moving file: {path_} -> {new_path}")
assert not os.path.exists(new_path), f"{path_} -> {new_path} would clobber destination!"
# os.renames creates the necessary parents, and then prunes leaf directories # os.renames creates the necessary parents, and then prunes leaf directories
os.renames(path_, new_path) os.renames(path_, new_path)
@@ -546,7 +631,7 @@ def main():
tagger.show(p) tagger.show(p)
elif args.subcommand == "print_missing": elif args.subcommand == "print_missing":
for p in files: for p in files:
f = AudioFile.new(p) f = MediaFile.new(p)
if not tagger.is_sufficiently_tagged(f): if not tagger.is_sufficiently_tagged(f):
tagger.show(f) tagger.show(f)
elif args.subcommand == "fix_tags": elif args.subcommand == "fix_tags":
@@ -554,7 +639,6 @@ def main():
tagger.tag_file(p) tagger.tag_file(p)
elif args.subcommand == "fix_paths": elif args.subcommand == "fix_paths":
for p in files: for p in files:
print(p)
tagger.fix_path(p) tagger.fix_path(p)
else: else:
assert False, f"unrecognized command {args.subcommand}" assert False, f"unrecognized command {args.subcommand}"