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.
--title TITLE
--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
fix-tags options:
--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
@@ -537,6 +537,20 @@ class MetadataImpl:
def flush(self) -> None:
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):
def __init__(self, path_: str, muta):
super().__init__(path_=path_)
@@ -647,7 +661,7 @@ class MediaFile:
return os.path.splitext(self.path_)[1][1:].lower()
@staticmethod
def new(f: str) -> "MediaFile | None":
def new(f: str) -> 'MediaFile':
ext = os.path.splitext(f)[1][1:].lower()
tag_field_names = TagFieldNames()
meta = None
@@ -686,7 +700,7 @@ class MediaFile:
logger.debug(f"no metadata handler for {f}")
if meta is None:
return None
meta = InMemoryMetadata(f)
return MediaFile(f, meta, tag_field_names)
@@ -718,18 +732,19 @@ class MediaFile:
self.meta.flush()
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.force = force
self.manual_tags = manual_tags
self.derive_from_path = derive_from_path
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}")
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.
if (tags.artist or tags.albumartist) and tags.album and tags.title:
return True
@@ -738,28 +753,32 @@ class Tagger:
return True
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()
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:
# 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:
# manual_tags > old_tags > 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
if self.manual_tags.album == [""]:
if manual_tags.album == [""]:
new_tags.album = []
if self.manual_tags.albumartist == [""]:
if manual_tags.albumartist == [""]:
new_tags.albumartist = []
if self.manual_tags.artist == [""]:
if manual_tags.artist == [""]:
new_tags.artist = []
if self.manual_tags.producer == [""]:
if manual_tags.producer == [""]:
new_tags.producer = []
if self.manual_tags.title == [""]:
if manual_tags.title == [""]:
new_tags.title = []
if self.manual_tags.tracknumber == [""]:
if manual_tags.tracknumber == [""]:
new_tags.tracknumber = []
new_tags.trim_fields()
new_tags.cleanup_trackno()
@@ -768,6 +787,11 @@ class Tagger:
new_tags.demote_producer()
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:
return self.skip_unchanged(file_.path_, old_tags)
@@ -778,7 +802,7 @@ class Tagger:
file_.write_tags(new_tags)
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])
if new_path is None:
logger.debug(f"skipping untagged file: {file_.path_}")
@@ -825,16 +849,16 @@ def walk_paths(*roots: str) -> None:
else:
yield root
def walk_files(paths: list[str], media_type: MediaType):
def walk_files(paths: list[str]):
for path_ in walk_paths(*paths):
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_
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:
"""
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('--title', 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")
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.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('--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_paths_parser = subparsers.add_parser("fix-paths")
@@ -892,7 +916,7 @@ def main():
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
files = walk_files(args.path, args.type)
files = walk_files(args.path)
manual_tags = Tags(
album=[args.album] if args.album else [],
@@ -903,7 +927,7 @@ def main():
tracknumber=[args.trackno] if args.trackno is not None else [],
)
if getattr(args, "from_neighbors", False):
if getattr(args, "derive", False):
files = list(files)
common_tags = extract_common_tags(files)
# clear things which are exceedingly unlikely to generalize:
@@ -917,9 +941,12 @@ def main():
tagger = Tagger(
dry_run=args.dry_run,
force=getattr(args, "force", False),
derive_from_path=getattr(args, "derive", False),
manual_tags=manual_tags,
)
files = filter_files(files, args.type)
if args.subcommand == "show":
for f in files:
tagger.show(f)