marquee-ai 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: marquee-ai
3
+ Version: 1.0.0
4
+ Summary: Marquee -- Plex-style recommendation rows for a media server library
5
+ Author: KernelMedia
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://huggingface.co/KernelMedia
8
+ Keywords: recommendations,media-server,plex,jellyfin,llm
9
+ Classifier: License :: OSI Approved :: Apache Software License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Multimedia :: Video
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: numpy>=1.24
15
+ Requires-Dist: requests>=2.31
16
+ Requires-Dist: sentence-transformers>=3.0
17
+ Provides-Extra: api
18
+ Requires-Dist: fastapi>=0.110; extra == "api"
19
+ Requires-Dist: uvicorn>=0.27; extra == "api"
20
+ Requires-Dist: pydantic>=2.6; extra == "api"
21
+ Provides-Extra: harvest
22
+ Requires-Dist: tqdm>=4.66; extra == "harvest"
23
+
24
+ # Marquee
25
+
26
+ Turns one item in a media library into Plex-style rows of other items in **that same
27
+ library**. Built for self-hosted media servers.
28
+
29
+ ```
30
+ IN: "The Sopranos"
31
+
32
+ OUT:
33
+ Shows like The Sopranos Gomorrah, Boardwalk Empire, The Wire, ZeroZeroZero
34
+ More from Tim Van Patten Game of Thrones, Black Mirror, Boardwalk Empire
35
+ More with Edie Falco Nurse Jackie, Oz
36
+ Shot by Alik Sakharov Game of Thrones, House of Cards
37
+ ```
38
+
39
+ ## Two halves, both required
40
+
41
+ A **retriever** decides what goes in the rows, by joining your library on shared cast,
42
+ crew and franchise and by embedding similarity. A small fine-tuned **model** decides
43
+ which rows are worth showing, their order, and what to call them.
44
+
45
+ The model never sees item ids — it answers with index references into candidates it was
46
+ handed, so it cannot invent a title that isn't in your library.
47
+
48
+ ```bash
49
+ pip install marquee-ai
50
+ ```
51
+
52
+ Model weights: [KernelMedia/marquee-ai](https://huggingface.co/KernelMedia/marquee-ai)
53
+
54
+ ## Quickstart
55
+
56
+ ```bash
57
+ # the model (needs ollama)
58
+ ollama create marquee-4b -f Modelfile
59
+
60
+ # metadata -- free TMDB key from themoviedb.org/settings/api
61
+ echo 'TMDB_API_KEY=your_key' >> .env
62
+ marquee harvest --out tmdb_full.jsonl # ~55 min, ~120k titles
63
+
64
+ # join it to your server's library
65
+ marquee ingest --server library.jsonl --catalog tmdb_full.jsonl \
66
+ --out lib.jsonl --mode requestable
67
+
68
+ # serve
69
+ marquee serve --library lib.jsonl --model marquee-4b --port 8080
70
+ ```
71
+
72
+ Your server exports one JSON object per line — only `tmdb_id` and `media_type` are
73
+ required:
74
+
75
+ ```json
76
+ {"id": "myserver-04471", "tmdb_id": 335984, "media_type": "movie"}
77
+ ```
78
+
79
+ Anything missing from the harvest is fetched from TMDB on demand at ingest, so coverage
80
+ is complete however you filtered.
81
+
82
+ ## API
83
+
84
+ ```bash
85
+ POST /recommend {"seed_id": "myserver-04471", "row_size": 20}
86
+ GET /search?q=Scorsese # matches titles AND people
87
+ GET /person?name=Roger%20Deakins
88
+ GET /health
89
+ DELETE /cache # after a library rescan
90
+ ```
91
+
92
+ `/search` and `/person` are pure index lookups — no model call, effectively instant.
93
+ Only `/recommend` uses the GPU.
94
+
95
+ Items carry an `owned` flag, so a UI can offer a Request button on anything not on the
96
+ server. Ambiguous titles return **409 with the candidates** rather than guessing —
97
+ `Godzilla` matches three films. Narrow with `year` and `media_type`, or write
98
+ `"Top Gun (1986)"` directly.
99
+
100
+ ## Use it as a library
101
+
102
+ The HTTP API is optional. If your server is Python, skip it:
103
+
104
+ ```python
105
+ from marquee import Library
106
+ from marquee.contract import messages, parse, repair, expand_rows
107
+
108
+ lib = Library.load("lib.jsonl")
109
+ lib.build_embeddings(cache=Path("lib.emb.npz")) # once at startup
110
+
111
+ request = lib.build_request(seed_id, semantic_k=14, row_size=20)
112
+ raw = call_your_llm(messages(request)) # any OpenAI-compatible endpoint
113
+ rows = expand_rows(repair(parse(raw), request), request, 20)
114
+ ```
115
+
116
+ ## Requirements
117
+
118
+ | | |
119
+ |---|---|
120
+ | VRAM | ~3.5 GB (Q4_K_M). Runs on a 4 GB card, or CPU. |
121
+ | RAM | ~2.2 GB for a 120k-title catalog |
122
+ | Latency | ~1 s per request on an RTX 3060; cached responses are instant |
123
+
124
+ Full setup and troubleshooting: [SETUP.md](https://huggingface.co/KernelMedia/marquee-ai/blob/main/SETUP.md)
125
+
126
+ ## Data
127
+
128
+ You harvest TMDB yourself with your own free key; no catalog is distributed. TMDB is
129
+ free for non-commercial use and requires attribution — commercial use needs a written
130
+ agreement with them.
131
+
132
+ This product uses the TMDB API but is not endorsed or certified by TMDB.
133
+
134
+ ## Licence
135
+
136
+ Apache-2.0.
@@ -0,0 +1,113 @@
1
+ # Marquee
2
+
3
+ Turns one item in a media library into Plex-style rows of other items in **that same
4
+ library**. Built for self-hosted media servers.
5
+
6
+ ```
7
+ IN: "The Sopranos"
8
+
9
+ OUT:
10
+ Shows like The Sopranos Gomorrah, Boardwalk Empire, The Wire, ZeroZeroZero
11
+ More from Tim Van Patten Game of Thrones, Black Mirror, Boardwalk Empire
12
+ More with Edie Falco Nurse Jackie, Oz
13
+ Shot by Alik Sakharov Game of Thrones, House of Cards
14
+ ```
15
+
16
+ ## Two halves, both required
17
+
18
+ A **retriever** decides what goes in the rows, by joining your library on shared cast,
19
+ crew and franchise and by embedding similarity. A small fine-tuned **model** decides
20
+ which rows are worth showing, their order, and what to call them.
21
+
22
+ The model never sees item ids — it answers with index references into candidates it was
23
+ handed, so it cannot invent a title that isn't in your library.
24
+
25
+ ```bash
26
+ pip install marquee-ai
27
+ ```
28
+
29
+ Model weights: [KernelMedia/marquee-ai](https://huggingface.co/KernelMedia/marquee-ai)
30
+
31
+ ## Quickstart
32
+
33
+ ```bash
34
+ # the model (needs ollama)
35
+ ollama create marquee-4b -f Modelfile
36
+
37
+ # metadata -- free TMDB key from themoviedb.org/settings/api
38
+ echo 'TMDB_API_KEY=your_key' >> .env
39
+ marquee harvest --out tmdb_full.jsonl # ~55 min, ~120k titles
40
+
41
+ # join it to your server's library
42
+ marquee ingest --server library.jsonl --catalog tmdb_full.jsonl \
43
+ --out lib.jsonl --mode requestable
44
+
45
+ # serve
46
+ marquee serve --library lib.jsonl --model marquee-4b --port 8080
47
+ ```
48
+
49
+ Your server exports one JSON object per line — only `tmdb_id` and `media_type` are
50
+ required:
51
+
52
+ ```json
53
+ {"id": "myserver-04471", "tmdb_id": 335984, "media_type": "movie"}
54
+ ```
55
+
56
+ Anything missing from the harvest is fetched from TMDB on demand at ingest, so coverage
57
+ is complete however you filtered.
58
+
59
+ ## API
60
+
61
+ ```bash
62
+ POST /recommend {"seed_id": "myserver-04471", "row_size": 20}
63
+ GET /search?q=Scorsese # matches titles AND people
64
+ GET /person?name=Roger%20Deakins
65
+ GET /health
66
+ DELETE /cache # after a library rescan
67
+ ```
68
+
69
+ `/search` and `/person` are pure index lookups — no model call, effectively instant.
70
+ Only `/recommend` uses the GPU.
71
+
72
+ Items carry an `owned` flag, so a UI can offer a Request button on anything not on the
73
+ server. Ambiguous titles return **409 with the candidates** rather than guessing —
74
+ `Godzilla` matches three films. Narrow with `year` and `media_type`, or write
75
+ `"Top Gun (1986)"` directly.
76
+
77
+ ## Use it as a library
78
+
79
+ The HTTP API is optional. If your server is Python, skip it:
80
+
81
+ ```python
82
+ from marquee import Library
83
+ from marquee.contract import messages, parse, repair, expand_rows
84
+
85
+ lib = Library.load("lib.jsonl")
86
+ lib.build_embeddings(cache=Path("lib.emb.npz")) # once at startup
87
+
88
+ request = lib.build_request(seed_id, semantic_k=14, row_size=20)
89
+ raw = call_your_llm(messages(request)) # any OpenAI-compatible endpoint
90
+ rows = expand_rows(repair(parse(raw), request), request, 20)
91
+ ```
92
+
93
+ ## Requirements
94
+
95
+ | | |
96
+ |---|---|
97
+ | VRAM | ~3.5 GB (Q4_K_M). Runs on a 4 GB card, or CPU. |
98
+ | RAM | ~2.2 GB for a 120k-title catalog |
99
+ | Latency | ~1 s per request on an RTX 3060; cached responses are instant |
100
+
101
+ Full setup and troubleshooting: [SETUP.md](https://huggingface.co/KernelMedia/marquee-ai/blob/main/SETUP.md)
102
+
103
+ ## Data
104
+
105
+ You harvest TMDB yourself with your own free key; no catalog is distributed. TMDB is
106
+ free for non-commercial use and requires attribution — commercial use needs a written
107
+ agreement with them.
108
+
109
+ This product uses the TMDB API but is not endorsed or certified by TMDB.
110
+
111
+ ## Licence
112
+
113
+ Apache-2.0.
@@ -0,0 +1,51 @@
1
+ """Marquee -- Plex-style recommendation rows for a media server library.
2
+
3
+ The model is only half of this. It decides which rows to show and what to call them;
4
+ the retriever decides what goes in them. Ship them together or neither works.
5
+
6
+ from marquee import Library, build_rows
7
+ """
8
+
9
+ from .contract import (
10
+ CONTRACT_VERSION,
11
+ MAX_ROW_ITEMS,
12
+ MAX_ROWS,
13
+ ROW_ORDER,
14
+ SYSTEM_PROMPT,
15
+ expand_rows,
16
+ messages,
17
+ parse,
18
+ render_human,
19
+ repair,
20
+ response_format,
21
+ to_compact,
22
+ validate,
23
+ verify_model,
24
+ )
25
+ from .retriever import Library
26
+ from .schema import MediaItem, Person, read_jsonl, write_jsonl
27
+
28
+ __version__ = "1.0.0"
29
+
30
+ __all__ = [
31
+ "CONTRACT_VERSION",
32
+ "verify_model",
33
+ "to_compact",
34
+ "Library",
35
+ "MediaItem",
36
+ "Person",
37
+ "read_jsonl",
38
+ "write_jsonl",
39
+ "SYSTEM_PROMPT",
40
+ "ROW_ORDER",
41
+ "MAX_ROWS",
42
+ "MAX_ROW_ITEMS",
43
+ "messages",
44
+ "parse",
45
+ "repair",
46
+ "validate",
47
+ "expand_rows",
48
+ "render_human",
49
+ "response_format",
50
+ "__version__",
51
+ ]
@@ -0,0 +1,386 @@
1
+ """Marquee HTTP API.
2
+
3
+ What a media server actually talks to. Wraps the retriever, the prompt contract and
4
+ the model behind one endpoint:
5
+
6
+ POST /recommend {"seed_id": "myserver-04471", "row_size": 20}
7
+ -> {"rows": [{"type", "title", "items": [{"id", "title", "owned", ...}]}]}
8
+
9
+ The model runs behind any OpenAI-compatible endpoint -- Ollama is the expected one
10
+ for self-hosters:
11
+
12
+ ollama serve
13
+ uvicorn serve.api:app --host 0.0.0.0 --port 8080
14
+
15
+ Config via environment:
16
+ MARQUEE_LIBRARY path to the library JSONL from data/ingest.py (required)
17
+ MARQUEE_MODEL model name (default curator-4b)
18
+ MARQUEE_BASE_URL OpenAI-compatible endpoint (default http://localhost:11434/v1)
19
+ MARQUEE_EMB_CACHE embedding cache path (default alongside the library)
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import re
26
+ from collections import OrderedDict
27
+ from pathlib import Path
28
+
29
+ import requests
30
+ from fastapi import FastAPI, HTTPException
31
+ from pydantic import BaseModel, Field
32
+
33
+ from .retriever import Library
34
+ from .contract import (
35
+ CONTRACT_VERSION,
36
+ ValidationError,
37
+ expand_rows,
38
+ messages,
39
+ parse,
40
+ repair,
41
+ response_format,
42
+ verify_model,
43
+ )
44
+
45
+ # Recommendations for an item are stable until the library changes, so serving the
46
+ # same seed twice should never cost a second model call. This is the single biggest
47
+ # latency win available without retraining.
48
+ CACHE_SIZE = int(os.environ.get("MARQUEE_CACHE_SIZE", "2048"))
49
+
50
+ LIBRARY_PATH = os.environ.get("MARQUEE_LIBRARY", "")
51
+ MODEL = os.environ.get("MARQUEE_MODEL", "marquee-4b")
52
+ BASE_URL = os.environ.get("MARQUEE_BASE_URL", "http://localhost:11434/v1")
53
+
54
+ app = FastAPI(title="Marquee", version="1.0")
55
+ _lib: Library | None = None
56
+ _cache: "OrderedDict[tuple, dict]" = OrderedDict()
57
+ _hits = _misses = 0
58
+ _verified: tuple[bool, str] | None = None
59
+
60
+
61
+ def _raw_call(msgs: list[dict]) -> str:
62
+ """Model call with NO grammar, used by the self-test."""
63
+ resp = requests.post(
64
+ BASE_URL.rstrip("/") + "/chat/completions",
65
+ json={"model": MODEL, "messages": msgs, "temperature": 0.1, "max_tokens": 1700},
66
+ timeout=180,
67
+ )
68
+ resp.raise_for_status()
69
+ return resp.json()["choices"][0]["message"]["content"]
70
+
71
+
72
+ @app.on_event("startup")
73
+ def _startup_selftest() -> None:
74
+ """Fail loudly at boot rather than serving quietly wrong rows.
75
+
76
+ Uses a real item from the loaded library: a synthetic probe is far enough out of
77
+ distribution that a healthy model can fail it.
78
+ """
79
+ global _verified
80
+ try:
81
+ lib = library()
82
+ seed = max(lib.items, key=lambda i: i.popularity)
83
+ probe = lib.build_request(seed.id, semantic_k=10)
84
+ except Exception as exc: # noqa: BLE001
85
+ _verified = (False, f"could not build a probe request: {exc}")
86
+ else:
87
+ _verified = verify_model(_raw_call, probe)
88
+ ok, msg = _verified
89
+ print(f"[marquee] contract v{CONTRACT_VERSION} self-test: "
90
+ f"{'PASS' if ok else 'FAIL'} - {msg}")
91
+
92
+
93
+ class RecommendRequest(BaseModel):
94
+ seed_id: str | None = Field(None, description="library id -- always unambiguous, prefer this")
95
+ title: str | None = Field(None, description="fallback lookup when you have no id")
96
+ year: int | None = Field(None, description="disambiguates remakes when matching by title")
97
+ media_type: str | None = Field(None, description="movie | tv, to narrow a title match")
98
+ row_size: int = Field(0, ge=0, le=50, description="top rows up to N items; 0 = model's own")
99
+ owned_only: bool = Field(False, description="hide items not on this server")
100
+ semantic_k: int = Field(14, ge=4, le=40)
101
+
102
+
103
+ class Item(BaseModel):
104
+ id: str
105
+ title: str
106
+ owned: bool = True
107
+ media_type: str = ""
108
+ year: int | None = None
109
+ rating: float = 0.0
110
+
111
+
112
+ class Row(BaseModel):
113
+ type: str
114
+ title: str
115
+ items: list[Item]
116
+
117
+
118
+ class RecommendResponse(BaseModel):
119
+ seed: Item
120
+ rows: list[Row]
121
+
122
+
123
+ def library() -> Library:
124
+ global _lib
125
+ if _lib is None:
126
+ if not LIBRARY_PATH:
127
+ raise HTTPException(500, "MARQUEE_LIBRARY is not set")
128
+ path = Path(LIBRARY_PATH)
129
+ if not path.exists():
130
+ raise HTTPException(500, f"library not found: {path}")
131
+ lib = Library.load(path)
132
+ cache = Path(os.environ.get("MARQUEE_EMB_CACHE", path.with_suffix(".emb.npz")))
133
+ lib.build_embeddings(cache=cache)
134
+ _lib = lib
135
+ return _lib
136
+
137
+
138
+ def _call_model(msgs: list[dict]) -> str:
139
+ resp = requests.post(
140
+ BASE_URL.rstrip("/") + "/chat/completions",
141
+ json={
142
+ "model": MODEL,
143
+ "messages": msgs,
144
+ "temperature": 0.1,
145
+ "max_tokens": 1700,
146
+ "response_format": response_format(),
147
+ },
148
+ timeout=180,
149
+ )
150
+ if not resp.ok:
151
+ raise HTTPException(502, f"model backend {resp.status_code}: {resp.text[:200]}")
152
+ return resp.json()["choices"][0]["message"]["content"]
153
+
154
+
155
+ @app.get("/health")
156
+ def health() -> dict:
157
+ lib = library()
158
+ owned = sum(1 for i in lib.items if i.owned)
159
+ ok, msg = _verified or (None, "not run")
160
+ return {
161
+ "status": "ok" if ok else "degraded",
162
+ "contract_version": CONTRACT_VERSION,
163
+ "model_self_test": {"passed": ok, "detail": msg},
164
+ "cache": {"entries": len(_cache), "hits": _hits, "misses": _misses,
165
+ "max": CACHE_SIZE},
166
+ "library_items": len(lib.items),
167
+ "owned": owned,
168
+ "requestable": len(lib.items) - owned,
169
+ "model": MODEL,
170
+ "backend": BASE_URL,
171
+ }
172
+
173
+
174
+ def split_title_year(text: str) -> tuple[str, int | None]:
175
+ """Accept "Top Gun (1986)" as well as a bare title.
176
+
177
+ Writing the year in parentheses is how people habitually disambiguate remakes,
178
+ so treat it as the year filter rather than as part of the title to match.
179
+ """
180
+ m = re.search(r"^(.*?)\s*\((\d{4})\)\s*$", text.strip())
181
+ if m:
182
+ return m.group(1).strip(), int(m.group(2))
183
+ return text.strip(), None
184
+
185
+
186
+ def _find(lib: Library, title: str, year: int | None, media_type: str | None) -> list:
187
+ """Title lookup, narrowed by year and media type when given.
188
+
189
+ Exact title matches win outright: searching "Dune" should not also return
190
+ "Dune: Part Two" when an exact "Dune" exists.
191
+ """
192
+ title_text, inline_year = split_title_year(title)
193
+ year = year if year is not None else inline_year
194
+ q = title_text.lower()
195
+ pool = [i for i in lib.items if q in i.title.lower()]
196
+ exact = [i for i in pool if i.title.lower() == q]
197
+ if exact:
198
+ pool = exact
199
+ if year is not None:
200
+ pool = [i for i in pool if i.year == year]
201
+ if media_type:
202
+ pool = [i for i in pool if i.media_type == media_type]
203
+ return sorted(pool, key=lambda i: -i.popularity)
204
+
205
+
206
+ @app.get("/search")
207
+ def search(
208
+ q: str,
209
+ media_type: str | None = None,
210
+ kind: str = "all",
211
+ limit: int = 20,
212
+ ) -> dict:
213
+ """Search titles and people.
214
+
215
+ `kind` is "title", "person" or "all". A query like "Scorsese" matches no title but
216
+ plenty of credits, so searching both by default is what a user actually expects.
217
+ """
218
+ lib = library()
219
+ out: dict = {"query": q, "titles": [], "people": []}
220
+
221
+ if kind in ("title", "all"):
222
+ out["titles"] = [
223
+ {"id": i.id, "title": i.title, "year": i.year, "media_type": i.media_type,
224
+ "rating": round(i.rating, 1), "owned": i.owned}
225
+ for i in _find(lib, q, None, media_type)[:limit]
226
+ ]
227
+ if kind in ("person", "all"):
228
+ out["people"] = lib.find_people(q, limit=limit)
229
+
230
+ out["count"] = len(out["titles"]) + len(out["people"])
231
+ return out
232
+
233
+
234
+ @app.get("/person")
235
+ def person(
236
+ key: str | None = None,
237
+ name: str | None = None,
238
+ media_type: str | None = None,
239
+ owned_only: bool = False,
240
+ limit: int = 40,
241
+ by_role: bool = False,
242
+ ) -> dict:
243
+ """Everything a person is credited on.
244
+
245
+ Films and shows in one rating-ranked list by default, deduplicated across roles.
246
+ `by_role=true` splits it into per-role shelves instead.
247
+
248
+ No model call: this is a pure index lookup, so it is effectively instant.
249
+ """
250
+ lib = library()
251
+ if not key:
252
+ if not name:
253
+ raise HTTPException(400, "provide key or name")
254
+ hits = lib.find_people(name, limit=5)
255
+ if not hits:
256
+ raise HTTPException(404, f"no person matching {name!r}")
257
+ if len(hits) > 1 and hits[0]["name"].lower() != name.strip().lower():
258
+ raise HTTPException(409, {"error": "ambiguous name", "matches": hits})
259
+ key = hits[0]["key"]
260
+
261
+ if not by_role:
262
+ credits = lib.person_credits(key, limit=limit, media_type=media_type,
263
+ owned_only=owned_only)
264
+ if not credits:
265
+ raise HTTPException(404, f"no credits for {key!r}")
266
+ return {
267
+ "person": lib.person_names.get(key, key),
268
+ "count": len(credits),
269
+ "credits": credits,
270
+ }
271
+
272
+ rows = lib.person_rows(key, max_per_row=limit, media_type=media_type,
273
+ owned_only=owned_only)
274
+ if not rows:
275
+ raise HTTPException(404, f"no credits for {key!r}")
276
+
277
+ titles = {
278
+ "director": "Directed by {name}", "author": "Written by {name}",
279
+ "actor": "Starring {name}", "writer": "Written by {name}",
280
+ "cinematographer": "Shot by {name}", "composer": "Scored by {name}",
281
+ "narrator": "Narrated by {name}",
282
+ }
283
+ return {
284
+ "person": lib.person_names.get(key, key),
285
+ "rows": [
286
+ {
287
+ "role": r["role"],
288
+ "title": titles.get(r["role"], "{name}").format(name=r["anchor"]),
289
+ "items": [
290
+ {"id": i, "title": lib.by_id[i].title, "year": lib.by_id[i].year,
291
+ "media_type": lib.by_id[i].media_type,
292
+ "rating": round(lib.by_id[i].rating, 1),
293
+ "owned": lib.by_id[i].owned}
294
+ for i in r["items"]
295
+ ],
296
+ }
297
+ for r in rows
298
+ ],
299
+ }
300
+
301
+
302
+ @app.delete("/cache")
303
+ def clear_cache() -> dict:
304
+ """Call this after a library rescan -- cached rows go stale when items change."""
305
+ n = len(_cache)
306
+ _cache.clear()
307
+ return {"cleared": n}
308
+
309
+
310
+ @app.post("/recommend", response_model=RecommendResponse)
311
+ def recommend(req: RecommendRequest) -> RecommendResponse:
312
+ global _hits, _misses
313
+ lib = library()
314
+
315
+ if req.seed_id:
316
+ if req.seed_id not in lib.by_id:
317
+ raise HTTPException(404, f"no library item with id {req.seed_id!r}")
318
+ seed = lib.by_id[req.seed_id]
319
+ elif req.title:
320
+ matches = _find(lib, req.title, req.year, req.media_type)
321
+ if not matches:
322
+ raise HTTPException(404, f"no library item matching {req.title!r}")
323
+ # 2.2% of titles collide (remakes, and occasional duplicate catalog entries).
324
+ # Guessing by popularity silently returns the wrong film, so say so instead
325
+ # and let the caller pick -- /search exists for exactly this.
326
+ if len(matches) > 1:
327
+ raise HTTPException(
328
+ 409,
329
+ {
330
+ "error": f"{len(matches)} items match {req.title!r}; pass seed_id, "
331
+ "or narrow with year/media_type",
332
+ "matches": [
333
+ {"id": m.id, "title": m.title, "year": m.year,
334
+ "media_type": m.media_type}
335
+ for m in matches[:10]
336
+ ],
337
+ },
338
+ )
339
+ seed = matches[0]
340
+ else:
341
+ raise HTTPException(400, "provide seed_id or title")
342
+
343
+ key = (seed.id, req.row_size, req.owned_only, req.semantic_k)
344
+ if key in _cache:
345
+ _cache.move_to_end(key)
346
+ _hits += 1
347
+ return RecommendResponse(**_cache[key])
348
+ _misses += 1
349
+
350
+ request = lib.build_request(
351
+ seed.id,
352
+ semantic_k=req.semantic_k,
353
+ row_size=req.row_size,
354
+ owned_only=req.owned_only,
355
+ )
356
+
357
+ try:
358
+ response = parse(_call_model(messages(request)))
359
+ except ValidationError as exc:
360
+ raise HTTPException(502, f"model returned unusable output: {exc}") from exc
361
+
362
+ # Repair before serving: the same normalisation used to build the training data,
363
+ # so a stray row never reaches the client.
364
+ response = repair(response, request)
365
+ if req.row_size:
366
+ response = expand_rows(response, request, req.row_size)
367
+
368
+ def as_item(item_id: str) -> Item:
369
+ it = lib.by_id[item_id]
370
+ return Item(
371
+ id=it.id, title=it.title, owned=it.owned,
372
+ media_type=it.media_type, year=it.year, rating=round(it.rating, 1),
373
+ )
374
+
375
+ result = RecommendResponse(
376
+ seed=as_item(seed.id),
377
+ rows=[
378
+ Row(type=r["type"], title=r["title"], items=[as_item(i) for i in r["items"]])
379
+ for r in response.get("rows", [])
380
+ ],
381
+ )
382
+
383
+ _cache[key] = result.model_dump()
384
+ while len(_cache) > CACHE_SIZE:
385
+ _cache.popitem(last=False)
386
+ return result