add(aduio): tagging files
This commit is contained in:
@@ -10,6 +10,7 @@ dependencies = [
|
||||
"httpx>=0.27",
|
||||
"rapidfuzz>=3.6",
|
||||
"pydantic>=2.6",
|
||||
"mutagen>=1.47",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -134,3 +134,37 @@ def test_bulk_requests_and_split_scan(client):
|
||||
by_name = {i["name"]: i["suggested_request_id"] for i in items}
|
||||
expected = {f"Die drei Fragezeichen - Folge {n:03d}": created[n - 1]["id"] for n in (1, 2, 3)}
|
||||
assert by_name == expected
|
||||
|
||||
|
||||
def test_audio_tags_written_on_import(client):
|
||||
from mutagen.id3 import ID3
|
||||
|
||||
root = client.tmp_path / "library" / "kids_audio"
|
||||
lib_id = client.post("/api/libraries", json={
|
||||
"name": "KidsAudio", "media_type": "audiobook", "root_path": str(root),
|
||||
}).json()["id"]
|
||||
req_id = client.post("/api/requests", json={
|
||||
"library_id": lib_id, "title": "Die drei ??? Kids Folge 085",
|
||||
"authors": "Ulf Blanck", "series": "Die drei ??? Kids", "volume": 85,
|
||||
}).json()["id"]
|
||||
|
||||
book_dir = client.downloads / "Kids 85 Schnueffler"
|
||||
book_dir.mkdir()
|
||||
for i in range(2):
|
||||
(book_dir / f"track{i}.mp3").write_bytes(b"")
|
||||
|
||||
items = client.get("/api/import/scan").json()["items"]
|
||||
resp = client.post("/api/import", json={"items": [{
|
||||
"path": items[0]["path"], "is_dir": True,
|
||||
"files": items[0]["files"], "request_id": req_id,
|
||||
}]})
|
||||
assert resp.json()["results"][0]["ok"]
|
||||
|
||||
dest = root / "Ulf Blanck" / "Die drei Kids Folge 085" # "?" is stripped from paths
|
||||
files = sorted(dest.iterdir())
|
||||
tags = ID3(files[0])
|
||||
assert str(tags["TALB"]) == "Die drei ??? Kids Folge 085"
|
||||
assert str(tags["TPE1"]) == "Ulf Blanck"
|
||||
assert str(tags["TXXX:SERIES"]) == "Die drei ??? Kids"
|
||||
assert str(tags["TXXX:SERIES-PART"]) == "85"
|
||||
assert str(tags["TRCK"]) == "1/2"
|
||||
|
||||
@@ -2,6 +2,7 @@ import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from ..naming import render_template, sanitize
|
||||
from . import tagger
|
||||
|
||||
|
||||
def import_item(item_path: str, files: list[str], is_dir: bool, request, library) -> str:
|
||||
@@ -18,6 +19,8 @@ def import_item(item_path: str, files: list[str], is_dir: bool, request, library
|
||||
for i, src in enumerate(paths, 1):
|
||||
dest = folder / sanitize(f"{base} - Part {i:0{width}d}{src.suffix.lower()}")
|
||||
shutil.move(str(src), dest)
|
||||
if request.media_type == "audiobook":
|
||||
tagger.tag_audio(dest, request, track=i, total=len(paths))
|
||||
# remove the source dir if nothing meaningful is left
|
||||
src_dir = Path(item_path)
|
||||
leftovers = [p for p in src_dir.rglob("*") if p.is_file()]
|
||||
@@ -30,4 +33,6 @@ def import_item(item_path: str, files: list[str], is_dir: bool, request, library
|
||||
if dest.exists():
|
||||
raise FileExistsError(f"Destination already exists: {dest}")
|
||||
shutil.move(str(src), dest)
|
||||
if request.media_type == "audiobook":
|
||||
tagger.tag_audio(dest, request)
|
||||
return str(dest)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Write metadata tags into imported audio files so Audiobookshelf & Co. can
|
||||
build series (album/artist/track plus SERIES / SERIES-PART)."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from mutagen.flac import FLAC
|
||||
from mutagen.id3 import ID3, TALB, TIT2, TPE1, TRCK, TXXX
|
||||
from mutagen.mp4 import MP4
|
||||
from mutagen.oggopus import OggOpus
|
||||
from mutagen.oggvorbis import OggVorbis
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def tag_audio(path: Path, request, track: int | None = None, total: int | None = None):
|
||||
"""Best effort — a tagging failure must never break the import."""
|
||||
try:
|
||||
_tag_audio(path, request, track, total)
|
||||
except Exception as exc:
|
||||
log.warning("tagging failed for %s: %s", path, exc)
|
||||
|
||||
|
||||
def _tag_audio(path: Path, request, track, total):
|
||||
author = (request.authors or "").split(",")[0].strip()
|
||||
title = request.title or ""
|
||||
series = request.series or ""
|
||||
part = str(request.volume) if request.volume is not None else ""
|
||||
track_title = f"{title} - Part {track:02d}" if track else title
|
||||
ext = path.suffix.lower()
|
||||
|
||||
if ext == ".mp3":
|
||||
try:
|
||||
tags = ID3(path)
|
||||
except Exception:
|
||||
tags = ID3()
|
||||
tags.setall("TALB", [TALB(encoding=3, text=title)])
|
||||
tags.setall("TIT2", [TIT2(encoding=3, text=track_title)])
|
||||
if author:
|
||||
tags.setall("TPE1", [TPE1(encoding=3, text=author)])
|
||||
if track:
|
||||
tags.setall("TRCK", [TRCK(encoding=3, text=f"{track}/{total}" if total else str(track))])
|
||||
if series:
|
||||
tags.setall("TXXX:SERIES", [TXXX(encoding=3, desc="SERIES", text=series)])
|
||||
if part:
|
||||
tags.setall("TXXX:SERIES-PART", [TXXX(encoding=3, desc="SERIES-PART", text=part)])
|
||||
tags.save(path)
|
||||
elif ext in (".m4b", ".m4a"):
|
||||
mp4 = MP4(path)
|
||||
mp4["\xa9alb"] = [title]
|
||||
mp4["\xa9nam"] = [track_title]
|
||||
if author:
|
||||
mp4["\xa9ART"] = [author]
|
||||
if track:
|
||||
mp4["trkn"] = [(track, total or 0)]
|
||||
if series:
|
||||
mp4["----:com.apple.iTunes:SERIES"] = [series.encode()]
|
||||
if part:
|
||||
mp4["----:com.apple.iTunes:SERIES-PART"] = [part.encode()]
|
||||
mp4.save()
|
||||
elif ext in (".flac", ".ogg", ".opus"):
|
||||
audio = {".flac": FLAC, ".ogg": OggVorbis, ".opus": OggOpus}[ext](path)
|
||||
audio["ALBUM"] = title
|
||||
audio["TITLE"] = track_title
|
||||
if author:
|
||||
audio["ARTIST"] = author
|
||||
if track:
|
||||
audio["TRACKNUMBER"] = str(track)
|
||||
if series:
|
||||
audio["SERIES"] = series
|
||||
if part:
|
||||
audio["SERIES-PART"] = part
|
||||
audio.save()
|
||||
Reference in New Issue
Block a user