sane-tag-music: add a method which generalizes tags to one file from the rest of the album (e.g. the album name)

This commit is contained in:
2024-07-12 01:16:59 +00:00
parent 514cfe7b0b
commit e82faa5961

View File

@@ -90,6 +90,7 @@ options:
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
@@ -256,36 +257,42 @@ class Tags:
self.title = title or []
self.tracknumber = tracknumber or []
def __repr__(self) -> str:
return f"artist:{self.producer}/{self.albumartist}/{self.artist}, album:{self.album}, title:{self.title}, trackno:{self.tracknumber}"
def union(self, fallback: 'Tags') -> 'Tags':
def merge(self, other: 'Tags', merge_field) -> 'Tags':
return Tags(
album=merge_field(self.album, other.album),
albumartist=merge_field(self.albumartist, other.albumartist),
artist=merge_field(self.artist, other.artist),
producer=merge_field(self.producer, other.producer),
title=merge_field(self.title, other.title),
tracknumber=merge_field(self.tracknumber, other.tracknumber),
)
def or_(self, other: 'Tags') -> 'Tags':
"""
substitute any tags missing tags in `self` with those from `fallback`.
i.e. `self` takes precedence over `fallback`.
"""
def merge_field(primary: list[str], secondary: list[str]) -> list[str]:
# primary_lower = [i.lower() for i in primary]
# return primary + [i for i in secondary if i.lower() not in primary_lower]
return primary or secondary
# def merge_field(primary: list[str], secondary: list[str]) -> list[str]:
# # primary_lower = [i.lower() for i in primary]
# # return primary + [i for i in secondary if i.lower() not in primary_lower]
# return primary or secondary
return self.merge(other, lambda a, b: a or b)
def union(self, other: 'Tags') -> 'Tags':
def merge_field(a: list[str], b: list[str]) -> list[str]:
return a + [i for i in b if i not in a]
return self.merge(other, merge_field)
album=merge_field(self.album, fallback.album)
albumartist=merge_field(self.albumartist, fallback.albumartist)
artist=merge_field(self.artist, fallback.artist)
producer=merge_field(self.producer, fallback.producer)
title=merge_field(self.title, fallback.title)
tracknumber=merge_field(self.tracknumber, fallback.tracknumber)
return Tags(
album=album,
albumartist=albumartist,
artist=artist,
producer=producer,
title=title,
tracknumber=tracknumber,
)
# def intersection(self, other: 'Tags') -> 'Tags':
# def merge_field(primary: list[str], secondary: list[str]):
# if primary == secondary:
# return primary
# else:
# return []
# return self.merge(other, merge_field)
def trim_fields(self) -> None:
def trim(field: list[str]):
@@ -736,11 +743,11 @@ class Tagger:
path_tags = Tags.from_path(file_.path_)
if self.force:
# manual_tags > path_tags > old_tags
new_tags = self.manual_tags.union(path_tags).union(old_tags)
new_tags = self.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.union(old_tags).union(path_tags)
new_tags = self.manual_tags.or_(old_tags).or_(path_tags)
# special case that explicitly supplying empty tags should delete the existing
if self.manual_tags.album == [""]:
new_tags.album = []
@@ -771,7 +778,7 @@ class Tagger:
file_.write_tags(new_tags)
def fix_path(self, file_: MediaFile) -> None:
tags = self.manual_tags.union(file_.tags_on_disk())
tags = self.manual_tags.or_(file_.tags_on_disk())
new_path = tags.to_path(os.path.splitext(file_.path_)[1])
if new_path is None:
logger.debug(f"skipping untagged file: {file_.path_}")
@@ -828,6 +835,23 @@ def walk_files(paths: list[str], media_type: MediaType):
continue
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.
"""
all_tags = Tags()
for f in files:
all_tags = all_tags.union(f.tags_on_disk())
return Tags(
album=all_tags.album if len(all_tags.album) == 1 else [],
albumartist=all_tags.albumartist if len(all_tags.albumartist) == 1 else [],
artist=all_tags.artist if len(all_tags.artist) == 1 else [],
producer=all_tags.producer if len(all_tags.producer) == 1 else [],
title=all_tags.title if len(all_tags.title) == 1 else [],
tracknumber=all_tags.tracknumber if len(all_tags.tracknumber) == 1 else [],
)
def main():
logging.basicConfig()
logging.getLogger().setLevel(logging.INFO)
@@ -856,6 +880,7 @@ 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")
@@ -867,6 +892,8 @@ def main():
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
files = walk_files(args.path, args.type)
manual_tags = Tags(
album=[args.album] if args.album else [],
albumartist=[args.album_artist] if args.album_artist else [],
@@ -876,14 +903,23 @@ def main():
tracknumber=[args.trackno] if args.trackno is not None else [],
)
if getattr(args, "from_neighbors", False):
files = list(files)
common_tags = extract_common_tags(files)
# clear things which are exceedingly unlikely to generalize:
common_tags.title = []
common_tags.tracknumber = []
if common_tags.albumartist or manual_tags.albumartist:
# prefer albumartist over (track) artist
common_tags.artist = []
manual_tags = manual_tags.or_(common_tags)
tagger = Tagger(
dry_run=args.dry_run,
force=getattr(args, "force", False),
manual_tags=manual_tags,
)
files = walk_files(args.path, args.type)
if args.subcommand == "show":
for f in files:
tagger.show(f)