sane-tag-music: support tagging album art
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
#!/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 :
|
||||
#
|
||||
# mutagen docs:
|
||||
@@ -45,6 +45,7 @@ import mutagen.flac
|
||||
import mutagen.mp3
|
||||
import mutagen.oggopus
|
||||
import mutagen.oggvorbis
|
||||
import exiftool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -217,7 +218,8 @@ class Tags:
|
||||
def to_path(self, ext: str) -> str | None:
|
||||
artist = self.albumartist or 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
|
||||
|
||||
artist = clean_for_fs(artist[0], single_field=False)
|
||||
@@ -309,10 +311,39 @@ class Tags:
|
||||
|
||||
return tags
|
||||
|
||||
|
||||
class AudioFile:
|
||||
class MediaFile:
|
||||
def __init__(self, path_: str):
|
||||
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
|
||||
|
||||
_base, ext = os.path.splitext(path_)
|
||||
@@ -337,18 +368,6 @@ class AudioFile:
|
||||
except Exception as 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:
|
||||
return Tags(
|
||||
artist=self.muta.get('artist', []) if self.muta else [],
|
||||
@@ -369,24 +388,88 @@ class AudioFile:
|
||||
elif name in self.muta:
|
||||
del self.muta[name]
|
||||
|
||||
set_tag('artist', tags.artist)
|
||||
set_tag('album', tags.album)
|
||||
set_tag('title', tags.title)
|
||||
set_tag('albumartist', tags.albumartist)
|
||||
set_tag('artist', tags.artist)
|
||||
set_tag('title', tags.title)
|
||||
set_tag('tracknumber', tags.tracknumber)
|
||||
|
||||
logger.debug(f"writing full tags: {self.muta}")
|
||||
|
||||
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:
|
||||
def __init__(self, dry_run: bool, force: bool, manual_tags: Tags):
|
||||
self.dry_run = dry_run
|
||||
self.force = force
|
||||
self.manual_tags = manual_tags
|
||||
|
||||
def show(self, path_: str | AudioFile) -> None:
|
||||
file_ = AudioFile.new(path_)
|
||||
def show(self, path_: str | MediaFile) -> None:
|
||||
file_ = MediaFile.new(path_)
|
||||
if not file_:
|
||||
logger.debug(f"skipping unsupported file: {path_}")
|
||||
return
|
||||
@@ -395,8 +478,8 @@ class Tagger:
|
||||
logger.info(f"tags for {path_}:")
|
||||
logger.info(f" {tags}")
|
||||
|
||||
def is_sufficiently_tagged(self, file_: str | AudioFile) -> bool:
|
||||
file_ = AudioFile.new(file_)
|
||||
def is_sufficiently_tagged(self, file_: str | MediaFile) -> bool:
|
||||
file_ = MediaFile.new(file_)
|
||||
if not file_:
|
||||
return False
|
||||
tags = file_.tags_on_disk()
|
||||
@@ -404,7 +487,7 @@ class Tagger:
|
||||
return (tags.artist or tags.albumartist) and tags.album and tags.title
|
||||
|
||||
def tag_file(self, path_: str) -> None:
|
||||
file_ = AudioFile.new(path_)
|
||||
file_ = MediaFile.new(path_)
|
||||
if not file_:
|
||||
logger.debug(f"skipping unsupported file: {path_}")
|
||||
return
|
||||
@@ -438,7 +521,7 @@ class Tagger:
|
||||
file_.write_tags(new_tags)
|
||||
|
||||
def fix_path(self, path_: str) -> None:
|
||||
file_ = AudioFile.new(path_)
|
||||
file_ = MediaFile.new(path_)
|
||||
if not file_:
|
||||
logger.debug(f"skipping unsupported file: {path_}")
|
||||
return
|
||||
@@ -455,6 +538,8 @@ class Tagger:
|
||||
|
||||
if self.confirm():
|
||||
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(path_, new_path)
|
||||
|
||||
@@ -546,7 +631,7 @@ def main():
|
||||
tagger.show(p)
|
||||
elif args.subcommand == "print_missing":
|
||||
for p in files:
|
||||
f = AudioFile.new(p)
|
||||
f = MediaFile.new(p)
|
||||
if not tagger.is_sufficiently_tagged(f):
|
||||
tagger.show(f)
|
||||
elif args.subcommand == "fix_tags":
|
||||
@@ -554,7 +639,6 @@ def main():
|
||||
tagger.tag_file(p)
|
||||
elif args.subcommand == "fix_paths":
|
||||
for p in files:
|
||||
print(p)
|
||||
tagger.fix_path(p)
|
||||
else:
|
||||
assert False, f"unrecognized command {args.subcommand}"
|
||||
|
Reference in New Issue
Block a user