sane-tag-music: allow deriving tags for all operations, not just fix-tags

This commit is contained in:
2024-07-12 02:31:54 +00:00
parent e82faa5961
commit 987cd93ce3

View File

@@ -86,11 +86,11 @@ options:
--producer PRODUCER use when the artist is a pseudonym, and this is their umbrella name. --producer PRODUCER use when the artist is a pseudonym, and this is their umbrella name.
--title TITLE --title TITLE
--trackno TRACK_NUMBER --trackno TRACK_NUMBER
--derive apply existing tags (e.g. album) found in the file set to any files in the set missing such tags
--type audio|image skip files which aren't of some specific media type --type audio|image skip files which aren't of some specific media type
fix-tags options: fix-tags options:
--force: apply path-based tag to each file, even those which already have tags --force: apply path-based tag to each file, even those which already have tags
--from-neighbors: apply existing tags (e.g. album) found in the file set to any files in the set missing such tags
""" """
from dataclasses import dataclass from dataclasses import dataclass
@@ -537,6 +537,20 @@ class MetadataImpl:
def flush(self) -> None: def flush(self) -> None:
raise NotImplementedError() raise NotImplementedError()
class InMemoryMetadata(MetadataImpl):
def __init__(self, path_: str):
super().__init__(path_)
self.tags = {}
def get_tag(self, name: str) -> list[str]:
return self.tags.get(name, [])
def set_tag(self, name: str, value: list[str]) -> None:
self.tags[name] = value
def flush(self) -> None:
logger.debug(f"not writing tags for unsupported file {self.path_}")
class MutagenMetadata(MetadataImpl): class MutagenMetadata(MetadataImpl):
def __init__(self, path_: str, muta): def __init__(self, path_: str, muta):
super().__init__(path_=path_) super().__init__(path_=path_)
@@ -647,7 +661,7 @@ class MediaFile:
return os.path.splitext(self.path_)[1][1:].lower() return os.path.splitext(self.path_)[1][1:].lower()
@staticmethod @staticmethod
def new(f: str) -> "MediaFile | None": def new(f: str) -> 'MediaFile':
ext = os.path.splitext(f)[1][1:].lower() ext = os.path.splitext(f)[1][1:].lower()
tag_field_names = TagFieldNames() tag_field_names = TagFieldNames()
meta = None meta = None
@@ -686,7 +700,7 @@ class MediaFile:
logger.debug(f"no metadata handler for {f}") logger.debug(f"no metadata handler for {f}")
if meta is None: if meta is None:
return None meta = InMemoryMetadata(f)
return MediaFile(f, meta, tag_field_names) return MediaFile(f, meta, tag_field_names)
@@ -718,18 +732,19 @@ class MediaFile:
self.meta.flush() self.meta.flush()
class Tagger: class Tagger:
def __init__(self, dry_run: bool, force: bool, manual_tags: Tags): def __init__(self, dry_run: bool, force: bool, derive_from_path: 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
self.derive_from_path = derive_from_path
def show(self, file_: MediaFile) -> None: def show(self, file_: MediaFile) -> None:
tags = file_.tags_on_disk() tags = self.tags_for(file_)
logger.info(f"tags for {file_.path_}:") logger.info(f"tags for {file_.path_}:")
logger.info(f" {tags}") logger.info(f" {tags}")
def is_sufficiently_tagged(self, file_: MediaFile) -> bool: def is_sufficiently_tagged(self, file_: MediaFile) -> bool:
tags = file_.tags_on_disk() tags = self.tags_for(file_)
# N.B.: track number isn't wholly necessary; just a nice-to-have. # N.B.: track number isn't wholly necessary; just a nice-to-have.
if (tags.artist or tags.albumartist) and tags.album and tags.title: if (tags.artist or tags.albumartist) and tags.album and tags.title:
return True return True
@@ -738,28 +753,32 @@ class Tagger:
return True return True
return False return False
def tag_file(self, file_: MediaFile) -> None: def tags_for(self, file_: MediaFile) -> Tags:
"""
return the tags stored in @file_, plus any we can derive from its path or our manual tags.
"""
old_tags = file_.tags_on_disk() old_tags = file_.tags_on_disk()
path_tags = Tags.from_path(file_.path_) path_tags = Tags.from_path(file_.path_) if self.derive_from_path else Tags()
manual_tags = self.manual_tags
if self.force: if self.force:
# manual_tags > path_tags > old_tags # manual_tags > path_tags > old_tags
new_tags = self.manual_tags.or_(path_tags).or_(old_tags) new_tags = manual_tags.or_(path_tags).or_(old_tags)
else: else:
# manual_tags > old_tags > path_tags # manual_tags > old_tags > path_tags
# old_tags overrule path_tags # old_tags overrule path_tags
new_tags = self.manual_tags.or_(old_tags).or_(path_tags) new_tags = manual_tags.or_(old_tags).or_(path_tags)
# special case that explicitly supplying empty tags should delete the existing # special case that explicitly supplying empty tags should delete the existing
if self.manual_tags.album == [""]: if manual_tags.album == [""]:
new_tags.album = [] new_tags.album = []
if self.manual_tags.albumartist == [""]: if manual_tags.albumartist == [""]:
new_tags.albumartist = [] new_tags.albumartist = []
if self.manual_tags.artist == [""]: if manual_tags.artist == [""]:
new_tags.artist = [] new_tags.artist = []
if self.manual_tags.producer == [""]: if manual_tags.producer == [""]:
new_tags.producer = [] new_tags.producer = []
if self.manual_tags.title == [""]: if manual_tags.title == [""]:
new_tags.title = [] new_tags.title = []
if self.manual_tags.tracknumber == [""]: if manual_tags.tracknumber == [""]:
new_tags.tracknumber = [] new_tags.tracknumber = []
new_tags.trim_fields() new_tags.trim_fields()
new_tags.cleanup_trackno() new_tags.cleanup_trackno()
@@ -768,6 +787,11 @@ class Tagger:
new_tags.demote_producer() new_tags.demote_producer()
new_tags.rewrite_singles() new_tags.rewrite_singles()
return new_tags
def tag_file(self, file_: MediaFile) -> None:
old_tags = file_.tags_on_disk()
new_tags = self.tags_for(file_)
if new_tags == old_tags: if new_tags == old_tags:
return self.skip_unchanged(file_.path_, old_tags) return self.skip_unchanged(file_.path_, old_tags)
@@ -778,7 +802,7 @@ class Tagger:
file_.write_tags(new_tags) file_.write_tags(new_tags)
def fix_path(self, file_: MediaFile) -> None: def fix_path(self, file_: MediaFile) -> None:
tags = self.manual_tags.or_(file_.tags_on_disk()) tags = self.tags_for(file_)
new_path = tags.to_path(os.path.splitext(file_.path_)[1]) new_path = tags.to_path(os.path.splitext(file_.path_)[1])
if new_path is None: if new_path is None:
logger.debug(f"skipping untagged file: {file_.path_}") logger.debug(f"skipping untagged file: {file_.path_}")
@@ -825,16 +849,16 @@ def walk_paths(*roots: str) -> None:
else: else:
yield root yield root
def walk_files(paths: list[str], media_type: MediaType): def walk_files(paths: list[str]):
for path_ in walk_paths(*paths): for path_ in walk_paths(*paths):
file_ = MediaFile.new(path_) file_ = MediaFile.new(path_)
if not file_:
logger.debug(f"skipping unsupported file: {path_}")
continue
if media_type is not None and not file_.is_type(media_type):
continue
yield file_ yield file_
def filter_files(files: list[MediaFile], media_type: MediaType|None = None):
for file_ in files:
if media_type is None or file_.is_type(media_type):
yield file_
def extract_common_tags(files: list[MediaFile]) -> Tags: def extract_common_tags(files: list[MediaFile]) -> Tags:
""" """
return a set of tags which wouldn't contradict any existing tags among these files. return a set of tags which wouldn't contradict any existing tags among these files.
@@ -865,6 +889,7 @@ def main():
parser.add_argument('--producer', help="manually specify the tag") parser.add_argument('--producer', help="manually specify the tag")
parser.add_argument('--title', help="manually specify the tag") parser.add_argument('--title', help="manually specify the tag")
parser.add_argument('--trackno', help="manually specify the tag") parser.add_argument('--trackno', help="manually specify the tag")
parser.add_argument('--derive', action='store_true', help="apply tags already existing in one file (e.g. album tag) to adjacent files in the set")
parser.add_argument('--type', type=MediaType, help="only apply operation to a specific type of media") parser.add_argument('--type', type=MediaType, help="only apply operation to a specific type of media")
subparsers = parser.add_subparsers(help="what to do") subparsers = parser.add_subparsers(help="what to do")
@@ -880,7 +905,6 @@ def main():
fix_tags_parser = subparsers.add_parser("fix-tags") fix_tags_parser = subparsers.add_parser("fix-tags")
fix_tags_parser.set_defaults(subcommand="fix_tags") fix_tags_parser.set_defaults(subcommand="fix_tags")
fix_tags_parser.add_argument('--force', action='store_true', help="give higher credence to path-based and manual tags than any existing tags") fix_tags_parser.add_argument('--force', action='store_true', help="give higher credence to path-based and manual tags than any existing tags")
fix_tags_parser.add_argument('--from-neighbors', action='store_true', help="apply tags already existing in one file (e.g. album tag) to adjacent files in the set")
fix_tags_parser.add_argument("path", nargs="+", help="relative path to a file to tag") fix_tags_parser.add_argument("path", nargs="+", help="relative path to a file to tag")
fix_paths_parser = subparsers.add_parser("fix-paths") fix_paths_parser = subparsers.add_parser("fix-paths")
@@ -892,7 +916,7 @@ def main():
if args.verbose: if args.verbose:
logging.getLogger().setLevel(logging.DEBUG) logging.getLogger().setLevel(logging.DEBUG)
files = walk_files(args.path, args.type) files = walk_files(args.path)
manual_tags = Tags( manual_tags = Tags(
album=[args.album] if args.album else [], album=[args.album] if args.album else [],
@@ -903,7 +927,7 @@ def main():
tracknumber=[args.trackno] if args.trackno is not None else [], tracknumber=[args.trackno] if args.trackno is not None else [],
) )
if getattr(args, "from_neighbors", False): if getattr(args, "derive", False):
files = list(files) files = list(files)
common_tags = extract_common_tags(files) common_tags = extract_common_tags(files)
# clear things which are exceedingly unlikely to generalize: # clear things which are exceedingly unlikely to generalize:
@@ -917,9 +941,12 @@ def main():
tagger = Tagger( tagger = Tagger(
dry_run=args.dry_run, dry_run=args.dry_run,
force=getattr(args, "force", False), force=getattr(args, "force", False),
derive_from_path=getattr(args, "derive", False),
manual_tags=manual_tags, manual_tags=manual_tags,
) )
files = filter_files(files, args.type)
if args.subcommand == "show": if args.subcommand == "show":
for f in files: for f in files:
tagger.show(f) tagger.show(f)