sane-tag-music: split into two operations
This commit is contained in:
parent
b7fd5e78cc
commit
28bfd75114
|
@ -12,7 +12,7 @@ tool which runs over a complete music library or a subset of it and:
|
||||||
|
|
||||||
this tool does NOT move or rename files. it only edits tags.
|
this tool does NOT move or rename files. it only edits tags.
|
||||||
|
|
||||||
USAGE: cd MUSIC_LIBRARY_TOP && sane-tag-music [--dry-run] [options] DIRECTORY [DIRECTORY ...]
|
USAGE: cd MUSIC_LIBRARY_TOP && sane-tag-music [options] fix-tags|fix-paths [more-options] DIRECTORY [DIRECTORY ...]
|
||||||
|
|
||||||
scans DIRECTORY and guesses artist/album/title for each track, based on path relative to pwd.
|
scans DIRECTORY and guesses artist/album/title for each track, based on path relative to pwd.
|
||||||
if the guessed tags look more correct than the existing tags (i.e. if the existing file is missing a tag),
|
if the guessed tags look more correct than the existing tags (i.e. if the existing file is missing a tag),
|
||||||
|
@ -22,8 +22,8 @@ DIRECTORY: specify `.` to scan the entire library.
|
||||||
|
|
||||||
options:
|
options:
|
||||||
--dry-run: only show what would be done, don't actually do it.
|
--dry-run: only show what would be done, don't actually do it.
|
||||||
--force: apply path-based tag to each file, even those which already have tags.
|
|
||||||
--verbose
|
--verbose
|
||||||
|
--force: apply path-based tag to each file, even those which already have tags (only for fix-tags)
|
||||||
--album ALBUM manually specify the tag, rather than guessing from path.
|
--album ALBUM manually specify the tag, rather than guessing from path.
|
||||||
--album-artist ARTIST often combined with DIRECTORY to tag an entire artist or album.
|
--album-artist ARTIST often combined with DIRECTORY to tag an entire artist or album.
|
||||||
--artist ARTIST
|
--artist ARTIST
|
||||||
|
@ -34,6 +34,7 @@ from dataclasses import dataclass
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import os.path
|
import os.path
|
||||||
import mutagen.easyid3
|
import mutagen.easyid3
|
||||||
import mutagen.flac
|
import mutagen.flac
|
||||||
|
@ -71,6 +72,18 @@ def loose_compare_lists(a: list[str], b: list[str]) -> bool:
|
||||||
b = sorted(clean_for_loose_compare(i) for i in b)
|
b = sorted(clean_for_loose_compare(i) for i in b)
|
||||||
return a == b
|
return a == b
|
||||||
|
|
||||||
|
def clean_for_fs(a: str, single_field: bool=False) -> str:
|
||||||
|
preserve = 'abcdefghijklmnopqrstuvwxyz0123456789._-'
|
||||||
|
a = a.replace(" ", ".")
|
||||||
|
if single_field:
|
||||||
|
a = a.replace("-", ".")
|
||||||
|
|
||||||
|
a = "".join(l for l in a if l.lower() in preserve)
|
||||||
|
|
||||||
|
while ".." in a:
|
||||||
|
a = a.replace("..", ".")
|
||||||
|
return a
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Tags:
|
class Tags:
|
||||||
# format matches mutagen's
|
# format matches mutagen's
|
||||||
|
@ -175,6 +188,17 @@ class Tags:
|
||||||
if loose_compare_str(self.album[0], "Singles") or loose_compare_str(self.album[0], artist):
|
if loose_compare_str(self.album[0], "Singles") or loose_compare_str(self.album[0], artist):
|
||||||
self.album = [ artist ]
|
self.album = [ artist ]
|
||||||
|
|
||||||
|
def to_path(self, ext: str) -> str | None:
|
||||||
|
artist = self.albumartist or self.artist
|
||||||
|
if not (artist and self.album and self.tracknumber and self.title and ext):
|
||||||
|
return None
|
||||||
|
|
||||||
|
artist = clean_for_fs(artist[0], single_field=False)
|
||||||
|
album = clean_for_fs(self.album[0], single_field=True)
|
||||||
|
trackno = clean_for_fs(self.tracknumber[0], single_field=True)
|
||||||
|
title_ext = clean_for_fs(self.title[0] + f".{ext}", single_field=True)
|
||||||
|
return f"{artist}/{album}/{trackno}-{title_ext}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_path(p: str) -> 'Tags':
|
def from_path(p: str) -> 'Tags':
|
||||||
"""
|
"""
|
||||||
|
@ -355,16 +379,26 @@ class Tagger:
|
||||||
if self.guard_dry_run("writing tags"):
|
if self.guard_dry_run("writing tags"):
|
||||||
file_.write_tags(new_tags)
|
file_.write_tags(new_tags)
|
||||||
|
|
||||||
def tag_file_tree(self, root: str) -> None:
|
def fix_path(self, path_: str) -> None:
|
||||||
for dir_, subdirs, files_ in os.walk(root):
|
file_ = AudioFile.new(path_)
|
||||||
for f in files_:
|
if not file_:
|
||||||
self.tag_file(os.path.join(dir_, f))
|
logger.debug(f"skipping unsupported file: {path_}")
|
||||||
|
return
|
||||||
|
|
||||||
def tag_file_or_tree(self, path_: str) -> None:
|
tags = self.manual_tags.union(file_.tags_on_disk())
|
||||||
if os.path.isdir(path_):
|
new_path = tags.to_path(os.path.splitext(path_)[1])
|
||||||
self.tag_file_tree(path_)
|
if new_path is None:
|
||||||
else:
|
logger.debug(f"skipping untagged file: {path_}")
|
||||||
self.tag_file(path_)
|
logger.debug(f" {tags}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if new_path == path_:
|
||||||
|
return self.skip_unchanged(path_, tags)
|
||||||
|
|
||||||
|
if self.confirm():
|
||||||
|
if self.guard_dry_run(f"moving file: {path_} -> {new_path}"):
|
||||||
|
# os.renames creates the necessary parents, and then prunes leaf directories
|
||||||
|
os.renames(path_, new_path)
|
||||||
|
|
||||||
def show_tagdif(self, path_: str, old_tags: Tags, new_tags: Tags):
|
def show_tagdif(self, path_: str, old_tags: Tags, new_tags: Tags):
|
||||||
logger.info(f"updating tags for {path_}")
|
logger.info(f"updating tags for {path_}")
|
||||||
|
@ -387,25 +421,45 @@ class Tagger:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def walk_files(*roots: str) -> None:
|
||||||
|
for root in roots:
|
||||||
|
if os.path.isdir(root):
|
||||||
|
for dir_, subdirs, files_ in os.walk(root):
|
||||||
|
for f in files_:
|
||||||
|
yield os.path.join(dir_, f)
|
||||||
|
else:
|
||||||
|
yield root
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
logging.basicConfig()
|
logging.basicConfig()
|
||||||
logging.getLogger().setLevel(logging.INFO)
|
logging.getLogger().setLevel(logging.INFO)
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="augment music tags based on library path")
|
parser = argparse.ArgumentParser(description="augment music tags based on library path")
|
||||||
parser.add_argument("path", nargs="+", help="relative path to a file to tag")
|
|
||||||
parser.add_argument('--dry-run', action='store_true')
|
parser.add_argument('--dry-run', action='store_true')
|
||||||
parser.add_argument('--force', action='store_true', help="give higher credence to path-based and manual tags than any existing tags")
|
|
||||||
parser.add_argument('--verbose', action='store_true')
|
parser.add_argument('--verbose', action='store_true')
|
||||||
parser.add_argument('--album', help="manually specify the tag")
|
parser.add_argument('--album', help="manually specify the tag")
|
||||||
parser.add_argument('--album-artist', help="manually specify the tag")
|
parser.add_argument('--album-artist', help="manually specify the tag")
|
||||||
parser.add_argument('--artist', help="manually specify the tag")
|
parser.add_argument('--artist', help="manually specify the tag")
|
||||||
parser.add_argument('--title', help="manually specify the tag")
|
parser.add_argument('--title', help="manually specify the tag")
|
||||||
|
|
||||||
|
subparsers = parser.add_subparsers(help="operation")
|
||||||
|
|
||||||
|
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("path", nargs="+", help="relative path to a file to tag")
|
||||||
|
|
||||||
|
fix_paths_parser = subparsers.add_parser("fix-paths")
|
||||||
|
fix_paths_parser.set_defaults(subcommand="fix_paths")
|
||||||
|
fix_paths_parser.add_argument("path", nargs="+", help="relative path to a file to tag")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.verbose:
|
if args.verbose:
|
||||||
logging.getLogger().setLevel(logging.DEBUG)
|
logging.getLogger().setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
files = list(walk_files(*args.path))
|
||||||
manual_tags = Tags(
|
manual_tags = Tags(
|
||||||
album=[args.album] if args.album else [],
|
album=[args.album] if args.album else [],
|
||||||
albumartist=[args.album_artist] if args.album_artist else [],
|
albumartist=[args.album_artist] if args.album_artist else [],
|
||||||
|
@ -415,12 +469,19 @@ def main():
|
||||||
|
|
||||||
tagger = Tagger(
|
tagger = Tagger(
|
||||||
dry_run=args.dry_run,
|
dry_run=args.dry_run,
|
||||||
force=args.force,
|
force=getattr(args, "force", False),
|
||||||
manual_tags=manual_tags
|
manual_tags=manual_tags,
|
||||||
)
|
)
|
||||||
|
|
||||||
for p in args.path:
|
if args.subcommand == "fix_tags":
|
||||||
tagger.tag_file_or_tree(p)
|
for p in files:
|
||||||
|
tagger.tag_file(p)
|
||||||
|
elif args.subcommand == "fix_paths":
|
||||||
|
for p in files:
|
||||||
|
print(p)
|
||||||
|
tagger.fix_path(p)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|
Loading…
Reference in New Issue