perf(tmdb): send catalog title matches in small requests (#1891)

CatalogTitleMatchService.matchTitles sent a caller's whole batch as one DB_MATCH_TITLES request, and the DB worker answers one request at a time: a recommendations batch held every other read for 95 s on a 3.9M-title library. The service now deduplicates the titles, keeps the 200-title cap, and sends requests of five titles one after another, concatenating the answers in order. The answer stays all-or-nothing: a failed request returns no matches, a lock that starts withholding everything stops the batch, and a batch starts over when ParentalLockService.version() changes between requests (no matches after three changing attempts).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-10-11 14:04:31 +02:00
1 parent 43358e10f2
commit dd815676b4
7 files changed
+287 -20

No files matched your search

@@ -51,8 +51,8 @@ const MAX_ITEMS = 18;
* the enrichment facade (items whose detail view was opened come straight
* from the SQLite cache, where `recommendations` ride along with every
* details payload), the per-seed lists are interleaved and deduplicated,
* already watched/favorited titles are dropped, and ONE batched worker
* request keeps only titles that exist in an imported library.
* already watched/favorited titles are dropped, and ONE batched
* `matchTitles` call keeps only titles that exist in an imported library.
*
* Requires the TMDB opt-in and the Electron DB worker — hidden in the PWA.
* A load is keyed by the seed set AND the watched/favorited exclusion set,
@@ -173,7 +173,8 @@ export class DashboardRecommendationsService {
// match never resolves either, so this rail re-runs on
// each visit for that user — bounded work, since the
// enrichment misses are cached and the catalog match is
// one batched worker call.
// one batched `matchTitles` call (worker requests of
// five titles, other reads in between).
this.loadedKey = perSeed.every((seed) => seed.resolved)
? loadKey
: null;
@@ -182,7 +183,8 @@ export class DashboardRecommendationsService {
// latched: an empty match result is indistinguishable
// from a transient worker failure at this layer
// (matchTitles maps failures to []), and re-running is
// cheap (cached enrichment + one batched worker call).
// cheap (cached enrichment + one batched `matchTitles`
// call).
// Mirrors the trending rail's retry-on-empty semantics.
// The PREVIOUS key must reset too: it described the
// rail that was just cleared, and returning to those
@@ -449,8 +451,8 @@ export class DashboardRecommendationsService {
if (candidates.length === 0) {
return EMPTY_LOAD;
}
// Both aliases go into the ONE batched request; the index lookup
// prefers the localized form. Built with a loop rather than
// Both aliases go into the ONE batched `matchTitles` call; the index
// lookup prefers the localized form. Built with a loop rather than
// flatMap — the web app compiles this lib against `lib: es2018`,
// which predates Array.prototype.flatMap.
const queryTitles: string[] = [];
@@ -18,7 +18,7 @@ export interface DashboardTrendingItem extends TmdbTrendingEntry {
* "Trending this week" dashboard rail data. Requires the TMDB opt-in and
* (for availability matching / navigation) the Electron DB worker — the
* rail is hidden in the PWA. Trending lists are cached for a day, and the
* title match runs as ONE batched worker request, fired only after the
* title match runs as ONE batched `matchTitles` call, fired only after the
* dashboard's own data has loaded so it never competes for the worker.
*/
@Injectable({ providedIn: 'root' })