feat(stalker): add VOD movie source resolution - #685
Open
ReichiMD wants to merge 5 commits into
Open
Conversation
Stalker/Ministra portals contributed nothing to ARVIO's movie source search: findMovieVodSources only queried Xtream's get_vod_streams and returned empty as soon as no Xtream credentials were configured. This adds a Stalker path alongside the existing Xtream one: - StalkerApi: searchVod (type=vod&action=get_ordered_list with a search term) and resolveVodStreamUrl (type=vod&action=create_link). - IptvRepository: per-portal Stalker VOD search with a bounded result cache, TMDB/IMDb id matching with a normalised title+year fallback, and an additive merge into findMovieVodSources. - StalkerVodLink: the stalker_vod:// placeholder a Stalker source carries until playback, plus isDirectStreamUrl as the single answer to "is this URL playable now, or only once resolved". - StreamRepository.resolveStreamInternal: resolve that placeholder through the existing lazy-resolve hook, so listing a catalogue never triggers create_link. - Autoplay, the source selector and the player rank Stalker sources by isDirectStreamUrl instead of assuming an http URL. - warmXtreamVodCachesIfPossible is renamed to warmVodCachesIfPossible and warms the Stalker portal handshake at all three existing call sites. The Xtream path is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MEhCFVC5VvcTgH1SAjbcyC
Reported from the device: a series that played fine earlier stopped being found, while other series on the same portal kept working, and which ones failed changed between sessions. The logs show the pattern - 'The Gentlemen' answered shows=0 four times in the morning and resolved normally later the same day, with no code in between. Both search paths swallowed their exception and returned the partial (usually empty) result list, so a request that never got through was indistinguishable from a portal that genuinely knows no such title. The repository then cached that empty list for six hours. One timeout, one rate-limited moment, and the title stayed missing for the rest of the day - on a portal that had it all along. searchVod, searchSeries and getSeasons now return null when the request failed and an empty list when the portal answered with nothing, and the three cache wrappers refuse to store the null. A genuine empty answer is still cached, because it does stop a browsed-past show from asking again on every screen, but only for ten minutes: it is a real answer, yet not one worth being wrong about for six hours. getSeasons is included deliberately. A failed season fetch cached as "no seasons" leaves a show bound but unplayable, which looks like a matching bug and is the harder half to diagnose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tcdagn2kP2ZaveybkNEdo9
`matches=0` and `shows=0` could not tell "the portal sent nothing" apart from "the portal sent entries and none of them matched". Both read as a dead end, so diagnosing one cost a full day of guessing. Portals that ignore the `search` parameter answer every lookup with the head of their whole catalogue. Logging the offered count next to the match count names that case on sight: entries offered, none matched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016QQNABzYtmqbKnHmVAWoNL
Searching a Stalker portal for a title it has was answering with nothing. Live TV kept working on the same portal with the same MAC, so the handshake and the token were never in question - only get_ordered_list for type=vod and type=series was. Two parameters explain it, both measured against a full client talking to the same portal, which finds 13 shows and 17 movies for a term we found nothing for: category=* -> category=0 sortby=added -> sortby=name The `*` is the category list's own word for "all", where it is the id of a pseudo category. get_ordered_list does not share that vocabulary: a portal reading `*` as the name of a category finds none, or drops `search` altogether and answers with the head of its catalogue - which is exactly the shape of the failure, since nothing in that head matches. sortby=added explains the rest. Ordered by the date a title was added and stopped after DEFAULT_VOD_SEARCH_PAGES, a match sits behind everything added since; on a catalogue of 104021 movies that is not an edge case. By name, the matches for one term stay inside the cap. getSeasons loses its sortby entirely. A show addressed by movie_id needs no order imposed on it, a full client sends none, and a build that reads the parameter as a filter would answer with nothing. The page cap stays at 3. It is what keeps a search a search rather than a catalogue download, and with the portal both filtering and sorting, three pages hold the matches for a term - the same client needed two. Tests now assert the request address itself, not just that a list comes back: every one of them fails against the old parameters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FXM9ii7CJn4qCR6q7Mz6Pz
A Stalker portal matches `search` literally against its own catalogue name, and that name is not the name TMDB shows the user. Measured against a real portal: TMDB writes "Der Astronaut - Project Hail Mary" with an en dash, the catalogue lists the same film as "DE - Der Astronaut: Project Hail Mary (2026)" with a colon, and the portal answers total_items 0 - while carrying that film eleven more times under its original title. Switching the app's content language to English found and played it within seconds, changing nothing but the search term. One lookup may now spend up to three terms, stopping at the first one that produces a match: 1. the original title (TMDB original_title / original_name) 2. the displayed title, as before 3. the part in front of a subtitle separator, as before None of the three is enough alone. Original-only fails on a title localized without its original name in it - "Die Verurteilten" does not contain "The Shawshank Redemption". Displayed-only fails on the punctuation mismatch above. The head is the rescue anchor for both, and its separator list learns the en and em dash, which is how TMDB punctuates a subtitle where portals write a colon. The common case does not get more expensive: when a user browses in the original language, terms 1 and 2 are the same string and exactly one request goes out, as before. Only a failed lookup escalates. Matching accepts the original title as an equal alternative rather than a fallback. Without that, asking for the original name would find a catalogue that lists a film only under it - "EN - Money Heist" for a user browsing in German - and then discard the entry for not being the displayed title. Portals that supply a tmdb_id never reach this stage and are unaffected. MediaItem carries the original name for that purpose. It is nullable and stays null on items restored from an older JSON cache, so a missing value reads as "unknown", never as "same as the title". Series go through the same term list, and the series warm-up path uses it too - warming has to ask what the real lookup will ask, or it binds a show the lookup then searches for again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tngwo6C91UcEiTC2713hg3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Stalker/Ministra portals currently contribute nothing to ARVIO's movie source search:
findMovieVodSourcesonly queries Xtream and returns empty when no Xtream credentialsare configured, even for a user whose portal carries the film. This adds Stalker as a
second, additive movie source provider. The Xtream path is untouched.
How it looks up a film
A portal serves its catalogue only in small pages (14 entries on a stock Ministra
build), so downloading and indexing it locally would mean thousands of requests per
refresh — 104,020 films at 14 per page is 7,430 page fetches on the portal measured
here. That is exactly the request pattern that gets users throttled or IP-banned by
their provider, so this uses the portal's own
searchto narrow server-side instead,and scores the handful of entries that come back with the same TMDB-id / title+year
matching the Xtream path already uses.
The trade-off is stated openly: this assumes the portal honours
searchonget_ordered_list. A portal that ignores the term answers with the head of itscatalogue, which matches nothing — the app then finds no source rather than a wrong
one. The
offeredcount in the log line separates the two cases (see Diagnostics).Playback stays lazy. Search results carry a
stalker_vod://<portalId>/<cmd>marker,and
StreamRepository.resolveStreamInternalexchanges it for a real URL exactly once,when the user presses play — the same hook the existing HubCloud placeholders use. No
create_linkcall happens while browsing.Finding the right search term
A portal matches
searchliterally against its own catalogue name, and that name isnot the name TMDB shows the user. With the content language set to German, TMDB writes
a film as
Der Astronaut – Project Hail Marywith an en dash, while the portal liststhe very same film as
DE - Der Astronaut: Project Hail Mary (2026)with a colon. Theliteral search answers
total_items: 0— even though that portal carries the filmeleven more times under its original title. Switching the content language to English
found and played it within seconds; nothing changed but the search term.
So one lookup may spend up to three terms, stopping at the first that produces a match:
original_title/original_name)None of the three is enough on its own. Original-only fails on a title localized
without its original name in it — "Die Verurteilten" does not contain "The Shawshank
Redemption". Displayed-only fails on the punctuation mismatch above. The head is the
rescue anchor for both, and its separator list knows the en and em dash, which is how
TMDB punctuates a subtitle where portals write a colon.
This does not make the common case more expensive. When a user browses in the
original language, terms 1 and 2 are the same string and exactly one request goes out.
Only a failed lookup escalates.
Matching accepts the original title as an equal alternative rather than a fallback.
Without that, asking for the original name would find a catalogue that lists a film
only under it — "EN - Project Hail Mary (2026)" for a user browsing in German — and
then discard the entry for not being the displayed title. Portals that supply a
tmdb_idnever reach this stage and are unaffected.MediaItemcarries the original name for that purpose. It is nullable and stays nullon items restored from an older JSON cache, so a missing value reads as "unknown",
never as "same as the title".
Request shape
get_ordered_listis asked withcategory=0andsortby=name. The category listspells "everything" as
id: "*", but this endpoint does not accept it — a portal thatreads
*as a literal category name finds nothing, or dropssearchaltogether andanswers with the head of its catalogue. And sorted by date added instead of by name, a
six-figure catalogue pushes the matches for one term past the page cap purely by age.
Both values are what a full client sends, taken from a packet capture of one.
Caching
Search answers are cached per portal (portal id + a fingerprint of its URL and MAC), so
two portals never read each other's results and re-pointing a portal invalidates only
its own entries. The cache is bounded at 64 entries.
A failed request is not an answer:
searchVodreturns null on failure and an emptylist when the portal replied but knows no such film, and only the second is cached —
otherwise one bad moment would turn into hours of "this portal has no such film". An
empty answer is kept for 10 minutes rather than the full 6 hours, because catalogues
change and a title the portal gains today should not stay invisible for the day.
Diagnostics
The per-lookup log line reports
offered(how many entries the portal sent) next tomatches. A high offered count with zero matches names the portal as the cause — itignored
searchand sent catalogue head — whereas both at zero points at the requestor at the catalogue itself. This is a log line only; there is no behaviour and no test
attached to it.
Not in this PR
Series and episodes go through the same two-level portal model and are a follow-up PR
that builds on this one. Per-portal import switches are a third.
Testing
testSideloadDebugUnitTest: 1011 tests green, up from 973 on the base commit— 38 new ones covering pagination and the page cap, request shape, the null-vs-empty
contract, the term list, ID/title/year matching, portal isolation, marker round-trip
and the merge with Xtream results.
own tests and nothing else — 2 for the request shape, 1 for the null contract, 5 for
the term list and matching. The
offeredcounter is the one change with no test: itis a diagnostic log line.
detekt: 3385 findings against 3376 on the base commit, compared rule by rule —no new rule triggered, the nine are in categories the repository already carries in
the hundreds.
main(checked withgit merge-tree).captured on the wire. A title whose original name is in a different script resolves
in two terms within the same second — the original name answers
total_items: 0, thedisplayed title answers
total_items: 8, and the search stops there — and the chosensource plays:
create_linkreturns an emptyerror, the stream answers 200 with a4.95 GB Matroska body, and range requests answer 206. Measured on a phone; not
exercised on a TV.
created by Claude (Anthropic) on behalf of @ReichiMD