Skip to content

feat(details): synopsis and cast on long press, and search by cast - #418

Closed
Pierroons wants to merge 5 commits into
oxyroid:masterfrom
Pierroons:feat/channel-details
Closed

feat(details): synopsis and cast on long press, and search by cast#418
Pierroons wants to merge 5 commits into
oxyroid:masterfrom
Pierroons:feat/channel-details

Conversation

@Pierroons

Copy link
Copy Markdown
Contributor

Builds on #417 (accent-insensitive search), which the cast search reuses for
its folded comparison. Review that one first; this branch contains its commit.

Long-pressing a channel opens a sheet offering Favourite, Hide and Create
shortcut, and says nothing about the title itself. Other clients show a
synopsis and a cast there.

Where the data comes from

Xtream keeps it apart from the catalogue: get_vod_streams returns names and
artwork only, so each description costs its own get_vod_info call — measured
at ~0.21 s. Series answer the same shape on get_series_info.

Tapping a channel still plays it immediately. The description sits behind a
long press, so nothing is added between a viewer and their film.

Caching, and what it must survive

Details are cached in channel_details, keyed on
(playlist_url, relation_id) rather than on the channel row id: ids are
regenerated on every re-subscription, which is the only way to refresh a
catalogue, so keying on them would discard the cache each time.

That table deliberately carries no foreign key onto playlists. It had
one, and re-subscribing writes the playlist back with INSERT OR REPLACE
which SQLite performs as a delete followed by an insert, so ON DELETE CASCADE
emptied the whole table on every refresh. Measured on a real database: 401 rows
before, 0 after, each costing a request to earn back. Rows are now deleted
explicitly where a playlist is genuinely unsubscribed.

Requests are serialised behind a mutex. These accounts commonly allow a single
connection and it is shared with playback — several descriptions fetched at
once can cost the viewer their stream.

A row is written even when the panel has nothing to say, so "asked, has no
description" stays distinguishable from "never asked".

Searching by cast

The query left-joins channel_details, so a catalogue whose descriptions have
not been collected yet searches exactly as before. Results carry why they
matched: a search for an actor returns films whose titles share nothing with
the query, and a bare list of them reads as a bug, so the matching cast is
shown under the title.

ChannelDetailsWorker collects the rest in the background — one worker for all
playlists, paced between requests, stepping aside the moment something starts
playing, and periodic because the platform stops any worker after about ten
minutes. Progress lives in the database: each batch asks for channels that
still have no description, so an interrupted run resumes by asking again.

The parser submodule is untouched; only its URL builder is reused, so this
stays a single-repository change.

Verification

On device against a 22 822 film / 5 332 series catalogue, collected in full:
long press shows year, genre, rating, cast, director and synopsis; a search for
an actor returns their films with the matching cast shown; and the cache
survives a full refresh intact.

Pierroons and others added 5 commits August 9, 2026 09:28
"prenom" returned nothing while "prénom" found "Le Prénom". SQLite's LIKE
ignores case for ASCII but never diacritics, so 4 882 of the 40 971 titles
in a French catalogue — 12 % — were unreachable to anyone who did not type
the accent, which on a TV remote is most of the time.

Titles are now stored a second time in a folded form (NFD, combining marks
dropped, lowercased with Locale.ROOT) and queries are folded the same way
before they reach the DAO. The comparison stays symmetric: accented or not,
either side matches.

normalizeForSearch is the single definition of that fold. The migration
backfills existing rows through it in Kotlin rather than through a stack of
SQL REPLACE calls, so the two can never drift — and a drift here is silent,
rows simply stop matching.

titleNormalized sits outside the constructor on purpose: a data class only
copies constructor parameters, so copy(title = …) recomputes it instead of
carrying a stale value that would drop the channel out of every search.

Folding happens in the repositories rather than at each call site, so every
screen searching channels or categories behaves the same way.

No index on the column: '%query%' cannot use one, and it would only slow the
bulk inserts a resubscription performs on tens of thousands of rows.

Verified on a 40 971-row database: migration to v27 completed at startup,
every row backfilled, and "prenom" and "AMELIE" now return the accented
titles on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Long-pressing a channel opened a sheet offering Favourite, Hide and Create
shortcut, and said nothing about the title itself. Other clients show a
synopsis and a cast there.

Xtream keeps that apart from the catalogue: get_vod_streams returns names
and artwork only, so each description costs a get_vod_info call — which is
why it is fetched when the sheet opens rather than up front. Series answer
the same shape on get_series_info.

Tapping a channel still plays it immediately. The description sits behind a
long press so nothing is added between a viewer and their film.

Details are cached in channel_details, keyed on (playlist, channel
reference) rather than on the channel row id: ids are regenerated on every
re-subscription, which is the only way to refresh a catalogue, so keying on
them would discard the cache each time.

Requests are serialised behind a mutex. These accounts commonly allow a
single connection and it is shared with playback — several descriptions
fetched at once can cost the viewer their stream.

A row is written even when the panel has nothing to say, so "asked, has no
description" stays distinguishable from "never asked" and the sheet does
not re-ask on every open.

The call is issued from this module rather than from the parser submodule,
which models the episode list of get_series_info but not the descriptive
block. Only its URL builder is reused, so this stays a single-repository
change.

Panels disagree on their own field types — rating arrives as 5, "5" and
7.4, tmdb_id alternates just as freely, movies say releasedate where series
say releaseDate. A lenient reader keeps a sheet from being lost over a
field nobody reads as a number; five tests pin those shapes down.

Verified on device: long press on a film shows 2017 · Action,
Science-Fiction, Thriller ★ 5.6, its cast, its director and its synopsis,
and the row lands in the cache with the folded cast ready for search.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Searching an actor returned nothing: cast lists were fetched for the detail
sheet but never looked at by search.

The query now left-joins channel_details, so a catalogue whose descriptions
have not been collected yet searches exactly as before — the actor half
simply matches nothing until they arrive.

Results carry why they matched. A search for "Gyllenhaal" returns films
whose titles share nothing with the query, and a bare list of them reads as
a bug, so the matching cast is shown under the title.

ChannelDetailsWorker collects the rest of the catalogue in the background.
Two things shape it:

  - These accounts commonly allow a single connection, shared with
    playback. The sweep steps aside the moment something starts playing and
    paces itself between requests rather than going as fast as the panel
    allows. Collecting metadata should not cost a viewer their stream.
  - Progress lives in the database, not in the worker: each batch asks for
    channels that still have no description, so an interrupted run resumes
    by asking again. Nothing to persist, nothing to lose — which matters
    when twenty-eight thousand titles are spread over several nights.

It starts after a subscription completes, when the catalogue is freshly in
and the descriptions behind it are missing.

Work names are hashed rather than raw: a playlist URL carries the account
credentials in its query string and work names surface in diagnostics.

Verified on device with 400 titles collected: "gyllenhaal" returns three
films — Spirit, Night Call, In the Grey — none of which carry the name in
their title, each showing the cast that matched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Re-subscribing is the only way to refresh an Xtream catalogue, and it
writes the playlist row back with INSERT OR REPLACE. SQLite performs a
REPLACE as a delete followed by an insert, so the ON DELETE CASCADE on
channel_details fired on every refresh. Measured on a real database: 401
rows before, 0 after — and each row costs one network request to earn back,
so the table emptied itself precisely when it was most expensive to refill.

The foreign key is gone; rows are deleted explicitly where a playlist is
genuinely unsubscribed, next to where its channels are. Migration 28 to 29
rebuilds the table without the constraint, keeping what is already cached.

The sweep is now one worker for all playlists instead of one per playlist.
Two of them running side by side halved the pause between requests —
measured at some 247 titles a minute against the 130 intended. The mutex
still kept requests strictly serialised, so the single-connection limit was
never breached, but the pacing was not what it claimed to be.

It is also periodic rather than a single long run. The platform stops any
worker after about ten minutes, so one run was never going to walk
twenty-eight thousand titles; and since playback deliberately interrupts
the sweep, without something bringing it back an evening of watching would
leave it stopped for good.

The existing migration tests were fixed too: they only supplied migrations
up to 26, so raising the schema silently broke all six. They now cover the
whole path, and a new test pins down the behaviour this commit is about —
cached rows surviving the INSERT OR REPLACE a refresh performs.

Verified on device: schema at 29, cache intact through the migration, one
worker, 130 titles a minute, and a search for "judor" returning Roulez
jeunesse, Les Nouvelles Aventures d'Aladin, H and Platane.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
data/.gitignore excluded /src/test, so the module could not carry unit tests
at all. The entry is dropped and five come with it.

They cover what panels actually send: a rating as a bare number, tmdb_id
alternating between number and string, movies spelling releasedate where
series spell releaseDate, cast falling back to actors, plot to description,
and unknown fields appearing without warning. Any of those refused outright
would cost the sheet its content.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@oxyroid

oxyroid commented Aug 9, 2026

Copy link
Copy Markdown
Owner

this PR changes too much codes 😇

@Pierroons

Copy link
Copy Markdown
Contributor Author

Fair, and I should have split it before opening it. Let me close this and send it in pieces:

  1. Detail sheet — synopsis, cast and rating on long press, with its cache
  2. Search by cast — 233 lines on top of that

Much of the bulk is generated Room schema JSON rather than hand-written code, but that doesn't make it easier to review. I'll open the first once #417 is settled, since it builds on it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants