Skip to content

feat(stalker): add VOD movie source resolution - #685

Open
ReichiMD wants to merge 5 commits into
ProdigyV21:mainfrom
ReichiMD:claude/magical-meitner-l3by2e
Open

feat(stalker): add VOD movie source resolution#685
ReichiMD wants to merge 5 commits into
ProdigyV21:mainfrom
ReichiMD:claude/magical-meitner-l3by2e

Conversation

@ReichiMD

Copy link
Copy Markdown
Contributor

Summary

Stalker/Ministra portals currently contribute nothing to ARVIO's movie source search:
findMovieVodSources only queries Xtream and returns empty when no Xtream credentials
are 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 search to 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 search on
get_ordered_list. A portal that ignores the term answers with the head of its
catalogue, which matches nothing — the app then finds no source rather than a wrong
one. The offered count in the log line separates the two cases (see Diagnostics).

Playback stays lazy. Search results carry a stalker_vod://<portalId>/<cmd> marker,
and StreamRepository.resolveStreamInternal exchanges it for a real URL exactly once,
when the user presses play — the same hook the existing HubCloud placeholders use. No
create_link call happens while browsing.

Finding the right search term

A portal matches search literally against its own catalogue name, and that name is
not the name TMDB shows the user. With the content language set to German, TMDB writes
a film as Der Astronaut – Project Hail Mary with an en dash, while the portal lists
the very same film as DE - Der Astronaut: Project Hail Mary (2026) with a colon. The
literal search answers total_items: 0 — even though that portal carries the film
eleven 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:

  1. the original title (TMDB original_title / original_name)
  2. the displayed title
  3. the part in front of a subtitle separator

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_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".

Request shape

get_ordered_list is asked with category=0 and sortby=name. The category list
spells "everything" as id: "*", but this endpoint does not accept it — a portal that
reads * as a literal category name finds nothing, or drops search altogether and
answers 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: searchVod returns null on failure and an empty
list 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 to
matches. A high offered count with zero matches names the portal as the cause — it
ignored search and sent catalogue head — whereas both at zero points at the request
or 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.
  • Counter-check on a throwaway branch: taking each fix back out again fails exactly its
    own tests and nothing else — 2 for the request shape, 1 for the null contract, 5 for
    the term list and matching. The offered counter is the one change with no test: it
    is 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.
  • Merges cleanly into the current main (checked with git merge-tree).
  • On device: verified against a live portal with the content language set to German,
    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, the
    displayed title answers total_items: 8, and the search stops there — and the chosen
    source plays: create_link returns an empty error, the stream answers 200 with a
    4.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

claude and others added 5 commits September 10, 2026 19:59
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
@github-actions github-actions Bot added the area: android Changes to the Android app or Gradle build label Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: android Changes to the Android app or Gradle build

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants