sane-tag-music: support tagging album art
This commit is contained in:
@@ -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}"
|
||||||
|
Reference in New Issue
Block a user