sane-tag-media: implement --style video to aid in structuring an album-less library (e.g. an archive of Youtube videos, organized by channel)

This commit is contained in:
2024-08-18 01:51:12 +00:00
parent 7d23f9453e
commit e16a26fad2

View File

@@ -7,7 +7,7 @@ tool which runs over a complete music library (or audiobooks, videos, ebooks (li
- suggests likely tags based on file path - suggests likely tags based on file path
- from correctly-tagged files in the same directory - from correctly-tagged files in the same directory
- or manually specified by the user - or manually specified by the user
- rewrites file paths based on tags (`AlbumArtist/AlbumTitle/TrackNumber-TrackTitle`) - rewrites file paths based on tags (by default: `AlbumArtist/AlbumTitle/TrackNumber-TrackTitle`)
- resulting paths are exclusively `[a-zA-Z0-9\._-]` - resulting paths are exclusively `[a-zA-Z0-9\._-]`
- characters outside this set are mapped to the nearest character. for example: - characters outside this set are mapped to the nearest character. for example:
- `&` is replaced by `And` - `&` is replaced by `And`
@@ -28,17 +28,20 @@ use relative paths here, like `SomeArtist` or `./SomeArtist/SomeAlbum`, so that
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.
--verbose --verbose
--derive apply existing tags (e.g. album) found in the file set to any files in the set missing such tags. --derive apply existing tags (e.g. album) found in the file set to any files in the set missing such tags.
additionally, extrapolate from the file path any missing tags. additionally, extrapolate from the file path any missing tags.
--ignore-existing completely ignore the existing on-disk tags. compute tags only from those manually provided, and what can be derived from the file path (if --derive was passed) --ignore-existing completely ignore the existing on-disk tags. compute tags only from those manually provided, and what can be derived from the file path (if --derive was passed)
--override-existing apply derived tags to each file, even those which already have tags. --override-existing apply derived tags to each file, even those which already have tags.
only makes sense when paired with --derive. only makes sense when paired with --derive.
--type audio|image|text skip files which aren't of some specific media type --type audio|image|text skip files which aren't of some specific media type
manually writing metadata fields: --style music|video file/folder structure to organize library into
music (default): Album.Artist/Album/TrackNumber-Title
video: Album.Artist/Title
options for manually specifying metadata:
--album ALBUM --album ALBUM
--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 track artist; usually the same as album-artist, except for compilation albums. --artist ARTIST track artist; usually the same as album-artist, except for compilation albums.
--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
@@ -166,6 +169,10 @@ class MediaType(Enum):
Text = "text" Text = "text"
Other = "other" Other = "other"
class PathStyle(Enum):
Music = "music"
Video = "video"
def maybe_romanize(a: str) -> str|None: def maybe_romanize(a: str) -> str|None:
if a == "( ͡° ͜ʖ ͡°)": return "Lenny" if a == "( ͡° ͜ʖ ͡°)": return "Lenny"
if a == "かめりあ": return "Camellia" if a == "かめりあ": return "Camellia"
@@ -479,15 +486,11 @@ class Tags:
return False return False
def to_path(self, ext: str) -> str | None: def to_path(self, style: PathStyle, ext: str) -> str | None:
is_artist_item = self.is_artist_item(ext) is_artist_item = self.is_artist_item(ext)
artist = self.albumartist or self.artist artist = clean_fields_for_fs(self.albumartist or self.artist, single_fields=False)
if not (artist and self.album and self.title and ext or is_artist_item):
return None
artist = clean_fields_for_fs(artist, single_fields=False)
album = clean_fields_for_fs(self.album) album = clean_fields_for_fs(self.album)
trackno = self.tracknumber and clean_fields_for_fs(self.tracknumber) trackno = clean_fields_for_fs(self.tracknumber)
trackno = [f"{trackno:>02}"] if trackno else [] trackno = [f"{trackno:>02}"] if trackno else []
if self.artist and self.albumartist == [ "Various Artists" ] \ if self.artist and self.albumartist == [ "Various Artists" ] \
and self.artist != [ "Various Artists" ] \ and self.artist != [ "Various Artists" ] \
@@ -501,17 +504,22 @@ class Tags:
if is_artist_item: if is_artist_item:
return os.path.join(artist, filename) return os.path.join(artist, filename)
if not (artist and album and title): if style == PathStyle.Video:
logger.warning(f"missing artist/album/title after cleaning path: {self} ({artist!r}, {album!r}, {title!r}") return os.path.join(artist, filename)
return None elif style == PathStyle.Music:
if not (artist and album and title):
logger.warning(f"missing artist/album/title after cleaning path: {self} ({artist!r}, {album!r}, {title!r}")
return None
if self.producer: if self.producer:
producer = clean_fields_for_fs(self.producer) producer = clean_fields_for_fs(self.producer)
if artist != "Various.Artists": if artist != "Various.Artists":
album = clean_fields_for_fs([artist, album]) album = clean_fields_for_fs([artist, album])
return os.path.join(producer, album, filename) return os.path.join(producer, album, filename)
else:
return os.path.join(artist, album, filename)
else: else:
return os.path.join(artist, album, filename) assert False, f"unknown PathStyle: {style}"
@staticmethod @staticmethod
def from_path(p: str) -> 'Tags': def from_path(p: str) -> 'Tags':
@@ -922,9 +930,9 @@ 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 fix_path(self, file_: MediaFile) -> None: def fix_path(self, file_: MediaFile, style: PathStyle) -> None:
tags = self.tags_for(file_) tags = self.tags_for(file_)
new_path = tags.to_path(os.path.splitext(file_.path_)[1]) new_path = tags.to_path(style, 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_}")
logger.debug(f" {tags}") logger.debug(f" {tags}")
@@ -1036,6 +1044,7 @@ def main():
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', default=False, help="apply tags already existing in one file (e.g. album tag) to adjacent files in the set") parser.add_argument('--derive', action='store_true', default=False, help="apply tags already existing in one file (e.g. album tag) to adjacent files in the set")
parser.add_argument('--style', type=PathStyle, default=PathStyle.Music, help="how files should be organized into folders/paths")
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")
@@ -1080,6 +1089,7 @@ def main():
gatherer = Gatherer(args.path, args.type, tags_provider) gatherer = Gatherer(args.path, args.type, tags_provider)
tagger = Tagger(dry_run=args.dry_run, tags_provider=tags_provider) tagger = Tagger(dry_run=args.dry_run, tags_provider=tags_provider)
style = args.style
if args.subcommand == "show": if args.subcommand == "show":
for f in gatherer.files(): for f in gatherer.files():
@@ -1093,7 +1103,7 @@ def main():
tagger.tag_file(f) tagger.tag_file(f)
elif args.subcommand == "fix_paths": elif args.subcommand == "fix_paths":
for f in gatherer.files(): for f in gatherer.files():
tagger.fix_path(f) tagger.fix_path(f, style)
else: else:
assert False, f"unrecognized command {args.subcommand}" assert False, f"unrecognized command {args.subcommand}"