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
+19 -12
View File
@@ -50,21 +50,13 @@ void main() {
});
group('titleMatchCandidates', () {
test('emits a title immediately followed by its stripped form', () {
test('emits each title immediately followed by its stripped form', () {
final candidates = titleMatchCandidates([
'You and I Are Polar Opposites Season 2',
'Seihantai na Kimi to Boku 2nd Season',
]);
// Capped at two, so the second provider title never gets a slot — the
// stripped form of the first is worth far more than an alias.
expect(candidates, ['You and I Are Polar Opposites Season 2', 'You and I Are Polar Opposites']);
});
test('honours a wider cap by interleaving, never by listing raw titles first', () {
final candidates = titleMatchCandidates([
'You and I Are Polar Opposites Season 2',
'Seihantai na Kimi to Boku 2nd Season',
], limit: 4);
// The second family is the only route to a copy filed under the romaji
// title (#2098), and each family carries its own parent-show form.
expect(candidates, [
'You and I Are Polar Opposites Season 2',
'You and I Are Polar Opposites',
@@ -72,6 +64,14 @@ void main() {
'Seihantai na Kimi to Boku',
]);
});
test('honours a narrower cap by interleaving, never by listing raw titles first', () {
final candidates = titleMatchCandidates([
'You and I Are Polar Opposites Season 2',
'Seihantai na Kimi to Boku 2nd Season',
], limit: 2);
expect(candidates, ['You and I Are Polar Opposites Season 2', 'You and I Are Polar Opposites']);
});
test('normalizes typographic punctuation both backends miss on', () {
// Verified live: Plex and Jellyfin both return 0 for the curly form.
expect(titleMatchCandidates(['Frieren: Beyond Journey\u2019s End']), ["Frieren: Beyond Journey's End"]);
@@ -103,7 +103,14 @@ void main() {
'Mushoku Tensei 2',
'MT2',
]);
expect(candidates, ['Mushoku Tensei: Jobless Reincarnation Season 2', 'Mushoku Tensei: Jobless Reincarnation']);
// `II:` mid-title is not a sequel suffix, so the second family has no
// stripped form and the third alias takes the last slot.
expect(candidates, [
'Mushoku Tensei: Jobless Reincarnation Season 2',
'Mushoku Tensei: Jobless Reincarnation',
'Mushoku Tensei II: Isekai Ittara Honki Dasu',
'Mushoku Tensei 2',
]);
});
});
}