fix(ui/ux): let the select buttons include entries without an assignment

This commit is contained in:
Steppenstreuner
2026-08-29 14:59:48 +02:00
parent acaf7c2068
commit 8a05d2d3c3
11 changed files with 394 additions and 18 deletions
+116
View File
@@ -0,0 +1,116 @@
"""MusicBrainz as a fallback for audio dramas Audible does not carry.
German Hörspiel series are well covered there, often with the episode number in
the title and the *original* release date instead of a later Audible reissue.
The service asks for one request per second and a descriptive user agent; both
are honoured here, which makes it deliberately slower than Audible.
"""
import asyncio
import os
import re
import time
import httpx
from .base import MetadataResult
BASE = "https://musicbrainz.org/ws/2/release"
USER_AGENT = os.environ.get(
"WORDARR_MUSICBRAINZ_UA", "wordarr/0.1 ( https://github.com/wordarr )"
)
# audio drama / audiobook only, otherwise "Lady Bedfort" returns 8000 pop songs
MEDIA_FILTER = '(secondarytype:"audio drama" OR secondarytype:audiobook)'
LANGUAGES = {"german": "deu", "english": "eng"}
MIN_INTERVAL = 1.1 # seconds between requests, per their rate limit
RESULT_LIMIT = 15
# "Die drei ??? 100: Toteninsel" -> series, number, title
TITLE_RE = re.compile(r"^(?P<series>.+?)\s+(?P<num>\d{1,4})\s*[:.\-]\s*(?P<title>.+)$")
_lock = asyncio.Lock()
_last_call = 0.0
async def _get(client: httpx.AsyncClient, query: str) -> dict:
"""One rate limited request; a 503 means we were still too fast."""
global _last_call
async with _lock:
wait = MIN_INTERVAL - (time.monotonic() - _last_call)
if wait > 0:
await asyncio.sleep(wait)
for attempt in (1, 2):
resp = await client.get(
BASE,
params={"query": query, "fmt": "json", "limit": RESULT_LIMIT},
headers={"User-Agent": USER_AGENT},
)
_last_call = time.monotonic()
if resp.status_code == 200:
return resp.json()
if resp.status_code != 503 or attempt == 2:
return {}
await asyncio.sleep(2)
return {}
def _to_result(release: dict) -> MetadataResult:
artists = [a.get("name", "") for a in release.get("artist-credit") or []
if isinstance(a, dict) and a.get("name")]
artist = artists[0] if artists else ""
title = release.get("title", "")
series, volume = artist, None
match = TITLE_RE.match(title)
if match:
series = match.group("series").strip()
volume = int(match.group("num"))
title = match.group("title").strip()
language = (release.get("text-representation") or {}).get("language", "")
date = release.get("date") or ""
return MetadataResult(
media_type="audiobook",
title=title,
# for audio dramas the credited "artist" is the series, not an author
authors="",
series=series,
volume=volume,
language={v: k for k, v in LANGUAGES.items()}.get(language, ""),
external_id=f"mb:{release.get('id', '')}",
year=int(date[:4]) if date[:4].isdigit() else None,
source="musicbrainz",
)
# Lucene metacharacters, "???" in a series name would act as wildcards
LUCENE_SPECIAL = re.compile(r'[+\-&|!(){}\[\]^"~*?:\\/]+')
def _terms(query: str) -> str:
"""Words joined with AND - a quoted phrase would miss "Die drei ??? 100:
Toteninsel" when searching for "Die drei ??? Toteninsel"."""
words = [w for w in LUCENE_SPECIAL.sub(" ", query).split() if len(w) > 1][:8]
return " AND ".join(f"release:{w}" for w in words)
async def search(query: str, language: str = "") -> list[MetadataResult]:
words = _terms(query)
if not words:
return []
terms = [f"({words})", MEDIA_FILTER]
if language in LANGUAGES:
terms.append(f"lang:{LANGUAGES[language]}")
async with httpx.AsyncClient(timeout=25, follow_redirects=True) as client:
data = await _get(client, " AND ".join(terms))
# the same audio drama exists as many pressings; keep the oldest of each,
# that is the original release rather than a reissue
best: dict[tuple, MetadataResult] = {}
for release in data.get("releases", []):
result = _to_result(release)
if not result.title:
continue
key = (result.title.lower(), result.series.lower(), result.volume)
current = best.get(key)
if not current or (result.year and (not current.year or result.year < current.year)):
best[key] = result
return sorted(best.values(), key=lambda r: (r.volume is None, r.volume or 0, r.title))