sane-tag-music: init
This commit is contained in:
@@ -44,6 +44,7 @@ in
|
|||||||
"sane-scripts.shutdown"
|
"sane-scripts.shutdown"
|
||||||
"sane-scripts.sudo-redirect"
|
"sane-scripts.sudo-redirect"
|
||||||
"sane-scripts.sync-from-servo"
|
"sane-scripts.sync-from-servo"
|
||||||
|
"sane-scripts.tag-music"
|
||||||
"sane-scripts.vpn"
|
"sane-scripts.vpn"
|
||||||
"sane-scripts.which"
|
"sane-scripts.which"
|
||||||
"sane-scripts.wipe-browser"
|
"sane-scripts.wipe-browser"
|
||||||
|
@@ -197,6 +197,11 @@ let
|
|||||||
pkgs = [ "ffmpeg" "sox" ];
|
pkgs = [ "ffmpeg" "sox" ];
|
||||||
pyPkgs = [ "unidecode" ];
|
pyPkgs = [ "unidecode" ];
|
||||||
};
|
};
|
||||||
|
tag-music = static-nix-shell.mkPython3Bin {
|
||||||
|
pname = "sane-tag-music";
|
||||||
|
src = ./src;
|
||||||
|
pyPkgs = [ "mutagen" ];
|
||||||
|
};
|
||||||
vpn = static-nix-shell.mkBash {
|
vpn = static-nix-shell.mkBash {
|
||||||
pname = "sane-vpn";
|
pname = "sane-vpn";
|
||||||
src = ./src;
|
src = ./src;
|
||||||
|
189
pkgs/additional/sane-scripts/src/sane-tag-music
Executable file
189
pkgs/additional/sane-scripts/src/sane-tag-music
Executable file
@@ -0,0 +1,189 @@
|
|||||||
|
#!/usr/bin/env nix-shell
|
||||||
|
#!nix-shell -i python3 -p "python3.withPackages (ps: [ ps.mutagen ])"
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import os.path
|
||||||
|
import mutagen.flac
|
||||||
|
import mutagen.mp3
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Tags:
|
||||||
|
# format matches mutagen's
|
||||||
|
# these tags could be technically valid, but semantically invalid
|
||||||
|
# e.g. a tracknumber that's not a number
|
||||||
|
artist: list[str]
|
||||||
|
album: list[str]
|
||||||
|
title: list[str]
|
||||||
|
albumartist: list[str]
|
||||||
|
tracknumber: list[str]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
artist: list[str] = None,
|
||||||
|
album: list[str] = None,
|
||||||
|
title: list[str] = None,
|
||||||
|
albumartist: list[str] = None,
|
||||||
|
tracknumber: list[str] = None,
|
||||||
|
):
|
||||||
|
self.artist = artist or []
|
||||||
|
self.album = album or []
|
||||||
|
self.title = title or []
|
||||||
|
self.albumartist = albumartist or []
|
||||||
|
self.tracknumber = tracknumber or []
|
||||||
|
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"artist:{self.artist}/{self.albumartist}, album:{self.album}, title:{self.title}, trackno:{self.tracknumber}"
|
||||||
|
|
||||||
|
def union(self, fallback: 'Tags') -> 'Tags':
|
||||||
|
def merge_field(first: list[str], second: list[str]) -> list[str]:
|
||||||
|
first_lower = [i.lower() for i in first]
|
||||||
|
return first + [i for i in second if i.lower() not in first_lower]
|
||||||
|
|
||||||
|
return Tags(
|
||||||
|
artist=merge_field(self.artist, fallback.artist),
|
||||||
|
album=merge_field(self.album, fallback.album),
|
||||||
|
title=merge_field(self.title, fallback.title),
|
||||||
|
albumartist=merge_field(self.albumartist, fallback.albumartist),
|
||||||
|
tracknumber=merge_field(self.tracknumber, fallback.tracknumber),
|
||||||
|
)
|
||||||
|
|
||||||
|
def promote_albumartist(self) -> None:
|
||||||
|
"""
|
||||||
|
if there's only an album artist, and no track artist, turn the album artist into the track artist.
|
||||||
|
otherise: no-op
|
||||||
|
"""
|
||||||
|
if self.artist == []:
|
||||||
|
self.artist = self.albumartist
|
||||||
|
self.albumartist = []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_path(p: str) -> 'Tags':
|
||||||
|
"""
|
||||||
|
path cases:
|
||||||
|
- artist/album/track
|
||||||
|
- label/artist - album/track
|
||||||
|
track naming:
|
||||||
|
- could have many fields. the title will always be last. trackno could be embedded or not.
|
||||||
|
- artist - album - trackno title
|
||||||
|
"""
|
||||||
|
comps = p.split('/')
|
||||||
|
tags = Tags()
|
||||||
|
if len(comps) == 3:
|
||||||
|
tags.albumartist = comps[0]
|
||||||
|
album_part = comps[1].split('-')
|
||||||
|
if len(album_part) == 2:
|
||||||
|
# artist/artist-album/track
|
||||||
|
tags.albumartist, tags.album = [album_part[0].strip()], [album_part[1].strip()]
|
||||||
|
else:
|
||||||
|
# artist/album/track
|
||||||
|
tags.album = [comps[1]]
|
||||||
|
track_part = comps[2].split('-')
|
||||||
|
if len(track_part) == 1:
|
||||||
|
tags.title = [comps[2]]
|
||||||
|
# TODO: handle the else case
|
||||||
|
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class AudioFile:
|
||||||
|
def __init__(self, path_: str):
|
||||||
|
self.path_ = path_
|
||||||
|
|
||||||
|
_base, ext = os.path.splitext(path_)
|
||||||
|
if ext == '.flac':
|
||||||
|
self.muta = mutagen.flac.Open(path_)
|
||||||
|
# elif ext == '.mp3':
|
||||||
|
# self.muta = mutagen.mp3.Open(path_)
|
||||||
|
else:
|
||||||
|
self.muta = None
|
||||||
|
|
||||||
|
def tags_on_disk(self) -> Tags:
|
||||||
|
return Tags(
|
||||||
|
artist=self.muta.get('artist', []) if self.muta else [],
|
||||||
|
album=self.muta.get('album', []) if self.muta else [],
|
||||||
|
title=self.muta.get('title', []) if self.muta else [],
|
||||||
|
albumartist=self.muta.get('albumartist', []) if self.muta else [],
|
||||||
|
tracknumber=self.muta.get('tracknumber', []) if self.muta else [],
|
||||||
|
)
|
||||||
|
|
||||||
|
def write_tags(self, tags: Tags) -> bool:
|
||||||
|
if not self.muta: return False
|
||||||
|
|
||||||
|
self.muta['artist'] = tags.artist
|
||||||
|
self.muta['album'] = tags.album
|
||||||
|
self.muta['title'] = tags.title
|
||||||
|
self.muta['albumartist'] = tags.albumartist
|
||||||
|
self.muta['tracknumber'] = tags.tracknumber
|
||||||
|
|
||||||
|
logger.debug(f"writing full tags: {self.muta}")
|
||||||
|
|
||||||
|
self.muta.save()
|
||||||
|
|
||||||
|
class Tagger:
|
||||||
|
def __init__(self, dry_run: bool):
|
||||||
|
self.dry_run = dry_run
|
||||||
|
|
||||||
|
def tag_file(self, path_: str) -> None:
|
||||||
|
file_ = AudioFile(path_)
|
||||||
|
old_tags = file_.tags_on_disk()
|
||||||
|
|
||||||
|
path_tags = Tags.from_path(path_)
|
||||||
|
new_tags = old_tags.union(path_tags)
|
||||||
|
new_tags.promote_albumartist()
|
||||||
|
|
||||||
|
if new_tags == old_tags:
|
||||||
|
return self.skip_unchanged(path_, old_tags)
|
||||||
|
|
||||||
|
self.show_tagdif(path_, old_tags, new_tags)
|
||||||
|
|
||||||
|
if self.confirm():
|
||||||
|
if self.guard_dry_run("writing tags"):
|
||||||
|
file_.write_tags(new_tags)
|
||||||
|
|
||||||
|
def show_tagdif(self, path_: str, old_tags: Tags, new_tags: Tags):
|
||||||
|
logger.info(f"updating tags for {path_}")
|
||||||
|
logger.info(f" {old_tags}")
|
||||||
|
logger.info(f" -> {new_tags}")
|
||||||
|
|
||||||
|
def skip_unchanged(self, path_: str, tags: Tags):
|
||||||
|
logger.debug(f"skipping unchanged {path_}")
|
||||||
|
logger.debug(f" {tags}")
|
||||||
|
|
||||||
|
def confirm(self) -> bool:
|
||||||
|
# TODO: actually prompt
|
||||||
|
return True
|
||||||
|
|
||||||
|
def guard_dry_run(self, msg: str) -> bool:
|
||||||
|
if self.dry_run:
|
||||||
|
print(f"dry run: not {msg}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
logging.basicConfig()
|
||||||
|
logging.getLogger().setLevel(logging.INFO)
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="augment music tags based on library path")
|
||||||
|
parser.add_argument("path", help="relative path to a file to tag")
|
||||||
|
parser.add_argument('--dry-run', action='store_true')
|
||||||
|
parser.add_argument('--verbose', action='store_true')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.verbose:
|
||||||
|
logging.getLogger().setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
tagger = Tagger(dry_run=args.dry_run)
|
||||||
|
tagger.tag_file(args.path)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
Reference in New Issue
Block a user