fix(explore): find library copies through the Plex search index and report servers that could not answer

Explore showed "Not in your library" for titles the user owns. Two causes:
Plex's /library/all?title= filter is an ordered word-prefix substring match,
so `Oshi no Ko` never found a library titled `[Oshi no Ko]` and a romaji or
localized title was unreachable once the first candidate hit; and a server
that answered slower than the 10s header budget was treated as a dead
endpoint, dropped from the wave, cached as a negative for ten minutes and
cascaded through its stale LAN candidates.

Plex title candidates now go through /hubs/search with includeGuids, the
full-text index behind Plex's own search, which also covers originalTitle.
Both backends search every candidate concurrently and union the id-verified
copies instead of stopping at the first title that hit; the candidate cap
becomes two title families so a sequel can reach its alternate title. Lookup
requests run under a dedicated deadline with endpoint failover off. The
aggregation layer reports per-server failures, the matcher only memoizes
complete positive waves, and the detail screen shows "Couldn't check N
servers" instead of claiming absence.

close #2098
This commit is contained in:
edde746
2026-09-05 14:44:01 +02:00
parent a629a3407a
commit a2c9bea041
39 changed files with 808 additions and 492 deletions
+9
View File
@@ -26,6 +26,15 @@ class MediaServerTimeouts {
/// worst case was the dominant cold-start stall in #1784.
static const libraryHubDeadline = Duration(seconds: 20);
/// Whole-request deadline for the Explore reverse library lookup
/// (`findByExternalIds`: Plex `/hubs/search` and `/library/all?guid=`,
/// Jellyfin `/Items?SearchTerm=`). It runs behind an in-page progress row
/// after a tap, so it can afford the same budget as a library hub row. Used
/// with endpoint failover off: a large library that answers slowly is not a
/// dead endpoint, and treating the timeout as one used to cascade the whole
/// client through its stale LAN candidates (#2098).
static const libraryLookup = Duration(seconds: 20);
/// Timeout for probing a cached/preferred endpoint (used in
/// [PlexServer.findBestWorkingConnection]).
static const preferredEndpointProbe = Duration(milliseconds: 1500);
+12 -10
View File
@@ -60,21 +60,23 @@ String _normalize(String title) {
/// title is the only candidate filter available and a sequel entry's own
/// title — `You and I Are Polar Opposites Season 2` — never matches the parent
/// show. Each input contributes itself plus its season-stripped form; the
/// caller tries them in order and stops at the first candidate whose external
/// ids verify, so a broader title can never widen what actually matches.
/// backend searches every candidate concurrently and verifies external ids,
/// so a candidate can only ever add genuine copies.
///
/// [limit] bounds the request fan-out, and 2 is deliberate: the entry's own
/// title plus its season-stripped form matched 77 of 113 real sequel entries
/// against a 267-show Plex library, where the unexpanded title alone matched
/// 3. Raising it to 6 (adding romaji/native/synonym variants) reached only 81
/// — four more entries for up to five more requests per lookup that finds
/// nothing, which is the common case on a discovery tab. Two candidates cost
/// the same two requests the single-title lookup already spent.
/// [limit] is the request budget per server per lookup. A title is a key into
/// the server's index, and a copy filed under a localized or romaji title is
/// reachable only through that title (#2098), so the budget buys title
/// *families*: 4 is the entry's own title and its first alternate, each with
/// its stripped form. Against a 267-show Plex library, the own family alone
/// matched 77 of 113 real sequel entries and six candidates reached 81; the
/// second family is not for hit rate but for the copies the first family
/// cannot name, and every request beyond it buys near nothing on the miss
/// path that dominates a discovery tab.
///
/// Each title is emitted immediately followed by its stripped form rather than
/// in two passes, so the cap can never spend every slot on unstripped titles
/// and never try the one candidate that actually reaches the parent show.
List<String> titleMatchCandidates(Iterable<String?> titles, {int limit = 2}) {
List<String> titleMatchCandidates(Iterable<String?> titles, {int limit = 4}) {
final out = <String>[];
final seen = <String>{};