sane-tag-music: allow deriving tags for all operations, not just fix-tags
This commit is contained in:
@@ -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)
|
||||||
|
Reference in New Issue
Block a user