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"> <div class="card-body">
<strong>${esc(r.title)}</strong> <strong>${esc(r.title)}</strong>
<span>${esc(r.authors)}</span> <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> <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}">` : ""} ${type === "comic" ? `<input type="number" min="0" placeholder="Band" class="volume" data-vol="${i}">` : ""}
<div class="row"> <div class="row">
@@ -149,7 +150,8 @@ $("#search-form").addEventListener("submit", async (e) => {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
library_id: parseInt(libId), 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, year: r.year, series: r.series, cover_url: r.cover_url,
volume: volInput && volInput.value ? parseInt(volInput.value) : null, volume: volInput && volInput.value ? parseInt(volInput.value) : null,
}), }),
@@ -192,6 +194,7 @@ $("#manual-form").addEventListener("submit", async (e) => {
body: JSON.stringify({ body: JSON.stringify({
library_id: parseInt(data.library_id), library_id: parseInt(data.library_id),
series: data.series || data.title, authors: data.authors, series: data.series || data.title, authors: data.authors,
narrator: data.narrator || "",
volume_from: parseInt(data.volume), volume_from: parseInt(data.volume),
volume_to: parseInt(data.volume_to), volume_to: parseInt(data.volume_to),
year: data.year ? parseInt(data.year) : null, year: data.year ? parseInt(data.year) : null,
@@ -204,6 +207,7 @@ $("#manual-form").addEventListener("submit", async (e) => {
body: JSON.stringify({ body: JSON.stringify({
library_id: parseInt(data.library_id), library_id: parseInt(data.library_id),
title: data.title, authors: data.authors, series: data.series, title: data.title, authors: data.authors, series: data.series,
narrator: data.narrator || "",
external_id: data.external_id, external_id: data.external_id,
year: data.year ? parseInt(data.year) : null, year: data.year ? parseInt(data.year) : null,
volume: data.volume ? parseInt(data.volume) : null, volume: data.volume ? parseInt(data.volume) : null,
@@ -393,6 +397,7 @@ async function runQuickSearch() {
<div class="card-body"> <div class="card-body">
<strong>${esc(r.title)}</strong> <strong>${esc(r.title)}</strong>
<span>${esc(r.authors)}</span> <span>${esc(r.authors)}</span>
${r.narrator ? `<span class="muted">🎙 ${esc(r.narrator)}</span>` : ""}
<span class="muted">${r.year ?? ""}</span> <span class="muted">${r.year ?? ""}</span>
<div class="row"><button data-pick="${j}">Anfragen &amp; verbinden</button></div> <div class="row"><button data-pick="${j}">Anfragen &amp; verbinden</button></div>
</div> </div>
@@ -423,7 +428,8 @@ async function pickQuickResult(r) {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
library_id: parseInt(libId), 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, year: r.year, series: r.series, cover_url: r.cover_url,
}), }),
}); });
+3 -1
View File
@@ -46,6 +46,7 @@
</label> </label>
<label>Titel <input name="title" required /></label> <label>Titel <input name="title" required /></label>
<label>Autor(en) <input name="authors" /></label> <label>Autor(en) <input name="authors" /></label>
<label>Sprecher <input name="narrator" /></label>
<label>Serie <input name="series" /></label> <label>Serie <input name="series" /></label>
<label>Band <input name="volume" type="number" min="0" /></label> <label>Band <input name="volume" type="number" min="0" /></label>
<label <label
@@ -180,7 +181,8 @@
<button type="submit">Anlegen</button> <button type="submit">Anlegen</button>
</form> </form>
<p class="muted"> <p class="muted">
Platzhalter: {Author} {Authors} {Title} {Year} {Series} {Volume} Platzhalter: {Author} {Authors} {Narrator} {Title} {Year} {Series}
{Volume}
</p> </p>
</section> </section>
</main> </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"]) == "Die drei ??? Kids"
assert str(tags["TXXX:SERIES-PART"]) == "85" assert str(tags["TXXX:SERIES-PART"]) == "85"
assert str(tags["TRCK"]) == "1/2" 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 library_id: int
title: str title: str
authors: str = "" authors: str = ""
narrator: str = ""
external_id: str = "" external_id: str = ""
year: int | None = None year: int | None = None
series: str = "" series: str = ""
@@ -65,6 +66,7 @@ class BulkRequestIn(BaseModel):
library_id: int library_id: int
series: str series: str
authors: str = "" authors: str = ""
narrator: str = ""
volume_from: int volume_from: int
volume_to: int volume_to: int
year: int | None = None year: int | None = None
@@ -88,6 +90,7 @@ def create_bulk(data: BulkRequestIn, session: Session = Depends(get_session)):
library_id=lib.id, library_id=lib.id,
title=f"{data.series} Folge {n:0{width}d}", title=f"{data.series} Folge {n:0{width}d}",
authors=data.authors, authors=data.authors,
narrator=data.narrator,
series=data.series, series=data.series,
volume=n, volume=n,
year=data.year, year=data.year,
+5
View File
@@ -23,6 +23,11 @@ def score(item_name: str, request) -> float:
result = max(s, title_only * 0.95) result = max(s, title_only * 0.95)
# for numbered volumes/episodes the number must match, otherwise every # for numbered volumes/episodes the number must match, otherwise every
# episode of a series scores alike on the shared title tokens # 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: if request.volume is not None:
nums = _numbers(item_name) nums = _numbers(item_name)
if request.volume in nums: if request.volume in nums:
+8 -1
View File
@@ -5,7 +5,7 @@ import logging
from pathlib import Path from pathlib import Path
from mutagen.flac import FLAC 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.mp4 import MP4
from mutagen.oggopus import OggOpus from mutagen.oggopus import OggOpus
from mutagen.oggvorbis import OggVorbis 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): def _tag_audio(path: Path, request, track, total):
author = (request.authors or "").split(",")[0].strip() author = (request.authors or "").split(",")[0].strip()
narrator = getattr(request, "narrator", "") or ""
title = request.title or "" title = request.title or ""
series = request.series or "" series = request.series or ""
part = str(request.volume) if request.volume is not None else "" 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)]) tags.setall("TPE1", [TPE1(encoding=3, text=author)])
if track: if track:
tags.setall("TRCK", [TRCK(encoding=3, text=f"{track}/{total}" if total else str(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: if series:
tags.setall("TXXX:SERIES", [TXXX(encoding=3, desc="SERIES", text=series)]) tags.setall("TXXX:SERIES", [TXXX(encoding=3, desc="SERIES", text=series)])
if part: if part:
@@ -53,6 +56,8 @@ def _tag_audio(path: Path, request, track, total):
mp4["\xa9ART"] = [author] mp4["\xa9ART"] = [author]
if track: if track:
mp4["trkn"] = [(track, total or 0)] mp4["trkn"] = [(track, total or 0)]
if narrator:
mp4["\xa9wrt"] = [narrator]
if series: if series:
mp4["----:com.apple.iTunes:SERIES"] = [series.encode()] mp4["----:com.apple.iTunes:SERIES"] = [series.encode()]
if part: if part:
@@ -66,6 +71,8 @@ def _tag_audio(path: Path, request, track, total):
audio["ARTIST"] = author audio["ARTIST"] = author
if track: if track:
audio["TRACKNUMBER"] = str(track) audio["TRACKNUMBER"] = str(track)
if narrator:
audio["COMPOSER"] = narrator
if series: if series:
audio["SERIES"] = series audio["SERIES"] = series
if part: if part:
+1
View File
@@ -45,6 +45,7 @@ async def search(query: str) -> list[MetadataResult]:
media_type="audiobook", media_type="audiobook",
title=p.get("title", ""), title=p.get("title", ""),
authors=", ".join(a.get("name", "") for a in p.get("authors") or []), 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, external_id=asin,
year=int(release[:4]) if release[:4].isdigit() else None, year=int(release[:4]) if release[:4].isdigit() else None,
cover_url=next(iter(images.values()), ""), cover_url=next(iter(images.values()), ""),
+1
View File
@@ -5,6 +5,7 @@ class MetadataResult(BaseModel):
media_type: str media_type: str
title: str title: str
authors: str = "" authors: str = ""
narrator: str = ""
external_id: str = "" external_id: str = ""
year: int | None = None year: int | None = None
series: str = "" series: str = ""
+1
View File
@@ -17,6 +17,7 @@ def render_template(template: str, request) -> str:
"Authors": request.authors or author, "Authors": request.authors or author,
"Title": request.title or "Unknown Title", "Title": request.title or "Unknown Title",
"Year": str(request.year) if request.year else "", "Year": str(request.year) if request.year else "",
"Narrator": getattr(request, "narrator", "") or "",
"Series": request.series or request.title or "Unknown Series", "Series": request.series or request.title or "Unknown Series",
"Volume": f"{request.volume:02d}" if request.volume is not None else "", "Volume": f"{request.volume:02d}" if request.volume is not None else "",
} }