add(metadata) search also for narrator if same title

This commit is contained in:
Steppenstreuner
2026-08-28 17:32:26 +02:00
parent 077b75bbb4
commit 9791469017
9 changed files with 64 additions and 4 deletions
+8 -2
View File
@@ -127,6 +127,7 @@ $("#search-form").addEventListener("submit", async (e) => {
<div class="card-body">
<strong>${esc(r.title)}</strong>
<span>${esc(r.authors)}</span>
${r.narrator ? `<span class="muted">🎙 ${esc(r.narrator)}</span>` : ""}
<span class="muted">${r.year ?? ""} ${r.external_id ? "· " + esc(r.external_id) : ""}</span>
${type === "comic" ? `<input type="number" min="0" placeholder="Band" class="volume" data-vol="${i}">` : ""}
<div class="row">
@@ -149,7 +150,8 @@ $("#search-form").addEventListener("submit", async (e) => {
method: "POST",
body: JSON.stringify({
library_id: parseInt(libId),
title: r.title, authors: r.authors, external_id: r.external_id,
title: r.title, authors: r.authors, narrator: r.narrator || "",
external_id: r.external_id,
year: r.year, series: r.series, cover_url: r.cover_url,
volume: volInput && volInput.value ? parseInt(volInput.value) : null,
}),
@@ -192,6 +194,7 @@ $("#manual-form").addEventListener("submit", async (e) => {
body: JSON.stringify({
library_id: parseInt(data.library_id),
series: data.series || data.title, authors: data.authors,
narrator: data.narrator || "",
volume_from: parseInt(data.volume),
volume_to: parseInt(data.volume_to),
year: data.year ? parseInt(data.year) : null,
@@ -204,6 +207,7 @@ $("#manual-form").addEventListener("submit", async (e) => {
body: JSON.stringify({
library_id: parseInt(data.library_id),
title: data.title, authors: data.authors, series: data.series,
narrator: data.narrator || "",
external_id: data.external_id,
year: data.year ? parseInt(data.year) : null,
volume: data.volume ? parseInt(data.volume) : null,
@@ -393,6 +397,7 @@ async function runQuickSearch() {
<div class="card-body">
<strong>${esc(r.title)}</strong>
<span>${esc(r.authors)}</span>
${r.narrator ? `<span class="muted">🎙 ${esc(r.narrator)}</span>` : ""}
<span class="muted">${r.year ?? ""}</span>
<div class="row"><button data-pick="${j}">Anfragen &amp; verbinden</button></div>
</div>
@@ -423,7 +428,8 @@ async function pickQuickResult(r) {
method: "POST",
body: JSON.stringify({
library_id: parseInt(libId),
title: r.title, authors: r.authors, external_id: r.external_id,
title: r.title, authors: r.authors, narrator: r.narrator || "",
external_id: r.external_id,
year: r.year, series: r.series, cover_url: r.cover_url,
}),
});
+3 -1
View File
@@ -46,6 +46,7 @@
</label>
<label>Titel <input name="title" required /></label>
<label>Autor(en) <input name="authors" /></label>
<label>Sprecher <input name="narrator" /></label>
<label>Serie <input name="series" /></label>
<label>Band <input name="volume" type="number" min="0" /></label>
<label
@@ -180,7 +181,8 @@
<button type="submit">Anlegen</button>
</form>
<p class="muted">
Platzhalter: {Author} {Authors} {Title} {Year} {Series} {Volume}
Platzhalter: {Author} {Authors} {Narrator} {Title} {Year} {Series}
{Volume}
</p>
</section>
</main>
+34
View File
@@ -168,3 +168,37 @@ def test_audio_tags_written_on_import(client):
assert str(tags["TXXX:SERIES"]) == "Die drei ??? Kids"
assert str(tags["TXXX:SERIES-PART"]) == "85"
assert str(tags["TRCK"]) == "1/2"
def test_narrator_in_path_and_tags(client):
from mutagen.id3 import ID3
root = client.tmp_path / "library" / "hp"
lib_id = client.post("/api/libraries", json={
"name": "HP", "media_type": "audiobook", "root_path": str(root),
"folder_template": "{Author}/{Title} ({Narrator})", "file_template": "{Title}",
}).json()["id"]
beck = client.post("/api/requests", json={
"library_id": lib_id, "title": "Harry Potter und der Stein der Weisen",
"authors": "J.K. Rowling", "narrator": "Rufus Beck",
}).json()
client.post("/api/requests", json={
"library_id": lib_id, "title": "Harry Potter und der Stein der Weisen",
"authors": "J.K. Rowling", "narrator": "Felix von Manteuffel",
})
f = client.downloads / "Harry Potter Stein der Weisen (Rufus Beck).mp3"
f.write_bytes(b"")
items = client.get("/api/import/scan").json()["items"]
assert items[0]["suggested_request_id"] == beck["id"]
resp = client.post("/api/import", json={"items": [{
"path": items[0]["path"], "is_dir": False,
"files": items[0]["files"], "request_id": beck["id"],
}]})
assert resp.json()["results"][0]["ok"]
dest = (root / "J.K. Rowling"
/ "Harry Potter und der Stein der Weisen (Rufus Beck)"
/ "Harry Potter und der Stein der Weisen.mp3")
assert dest.is_file()
assert str(ID3(dest)["TCOM"]) == "Rufus Beck"
+3
View File
@@ -12,6 +12,7 @@ class RequestIn(BaseModel):
library_id: int
title: str
authors: str = ""
narrator: str = ""
external_id: str = ""
year: int | None = None
series: str = ""
@@ -65,6 +66,7 @@ class BulkRequestIn(BaseModel):
library_id: int
series: str
authors: str = ""
narrator: str = ""
volume_from: int
volume_to: int
year: int | None = None
@@ -88,6 +90,7 @@ def create_bulk(data: BulkRequestIn, session: Session = Depends(get_session)):
library_id=lib.id,
title=f"{data.series} Folge {n:0{width}d}",
authors=data.authors,
narrator=data.narrator,
series=data.series,
volume=n,
year=data.year,
+5
View File
@@ -23,6 +23,11 @@ def score(item_name: str, request) -> float:
result = max(s, title_only * 0.95)
# for numbered volumes/episodes the number must match, otherwise every
# episode of a series scores alike on the shared title tokens
# narrator in the file name (e.g. "gelesen von Rufus Beck") disambiguates
# otherwise identical titles
narrator = getattr(request, "narrator", "") or ""
if narrator and fuzz.partial_ratio(normalize(narrator), name) > 80:
result = min(100.0, result + 5)
if request.volume is not None:
nums = _numbers(item_name)
if request.volume in nums:
+8 -1
View File
@@ -5,7 +5,7 @@ import logging
from pathlib import Path
from mutagen.flac import FLAC
from mutagen.id3 import ID3, TALB, TIT2, TPE1, TRCK, TXXX
from mutagen.id3 import ID3, TALB, TCOM, TIT2, TPE1, TRCK, TXXX
from mutagen.mp4 import MP4
from mutagen.oggopus import OggOpus
from mutagen.oggvorbis import OggVorbis
@@ -23,6 +23,7 @@ def tag_audio(path: Path, request, track: int | None = None, total: int | None =
def _tag_audio(path: Path, request, track, total):
author = (request.authors or "").split(",")[0].strip()
narrator = getattr(request, "narrator", "") or ""
title = request.title or ""
series = request.series or ""
part = str(request.volume) if request.volume is not None else ""
@@ -40,6 +41,8 @@ def _tag_audio(path: Path, request, track, total):
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 narrator: # Audiobookshelf reads the narrator from the composer tag
tags.setall("TCOM", [TCOM(encoding=3, text=narrator)])
if series:
tags.setall("TXXX:SERIES", [TXXX(encoding=3, desc="SERIES", text=series)])
if part:
@@ -53,6 +56,8 @@ def _tag_audio(path: Path, request, track, total):
mp4["\xa9ART"] = [author]
if track:
mp4["trkn"] = [(track, total or 0)]
if narrator:
mp4["\xa9wrt"] = [narrator]
if series:
mp4["----:com.apple.iTunes:SERIES"] = [series.encode()]
if part:
@@ -66,6 +71,8 @@ def _tag_audio(path: Path, request, track, total):
audio["ARTIST"] = author
if track:
audio["TRACKNUMBER"] = str(track)
if narrator:
audio["COMPOSER"] = narrator
if series:
audio["SERIES"] = series
if part:
+1
View File
@@ -45,6 +45,7 @@ async def search(query: str) -> list[MetadataResult]:
media_type="audiobook",
title=p.get("title", ""),
authors=", ".join(a.get("name", "") for a in p.get("authors") or []),
narrator=", ".join(n.get("name", "") for n in p.get("narrators") or []),
external_id=asin,
year=int(release[:4]) if release[:4].isdigit() else None,
cover_url=next(iter(images.values()), ""),
+1
View File
@@ -5,6 +5,7 @@ class MetadataResult(BaseModel):
media_type: str
title: str
authors: str = ""
narrator: str = ""
external_id: str = ""
year: int | None = None
series: str = ""
+1
View File
@@ -17,6 +17,7 @@ def render_template(template: str, request) -> str:
"Authors": request.authors or author,
"Title": request.title or "Unknown Title",
"Year": str(request.year) if request.year else "",
"Narrator": getattr(request, "narrator", "") or "",
"Series": request.series or request.title or "Unknown Series",
"Volume": f"{request.volume:02d}" if request.volume is not None else "",
}