Code reference · Core
:core:data
Repositories — the seam between storage, the network and ViewModels.
dev.quiblo.core.data
Nothing above this layer touches a DAO or an HTTP client. A repository decides what is cached, what is fetched, and on which thread the work happens.
classChannelRepository
The browse feed, the favourite toggle, and per-item details.
The single most performance-sensitive class in the project. Two things about it are load-bearing:
The mapping runs on an injected dispatcher. A Flow operator runs in its
collector's context, and every collector here is a
stateIn(viewModelScope, …) — the main thread. Without the flowOn,
one object per row was allocated on the UI thread on every emission, which at 67,000 channels
is an ANR. The dispatcher is a constructor parameter so a test can hold it to that.
Details are cached in memory for the session, not in the database. Unlike film metadata, these come from the user's own panel and describe what it is serving now — an episode list can gain an episode. A session is the honest lifetime. Failures are deliberately not cached, so a panel coming out of a block recovers without a restart.
observeBrowse(...) | The browse feed. Category, search and favourites-only are optional predicates on one query, so the combinations cannot drift apart. |
|---|---|
observeFavorites(...) | Favourites across every content type. |
toggleFavorite(channel) | Keyed by stable identity, never by row id — that is what lets a favourite survive a refresh. |
findByStableKey(...) | How a history entry gets back to a playable row after a refresh has reassigned every id. |
getSeriesDetails / getVodDetails | Session-cached. Re-opening the same title costs nothing. |
classSearchRepository
One search across live, films and series, plus the genre index behind the filter.
Separate from ChannelRepository because search is the one question that ignores
the division the rest of the app is built on: a viewer looking for a title does not know which
kind their provider filed it under.
Every read here is one-shot. A search is a question asked and answered, not a subscription — three open flows would re-run on every write to the channel table while somebody is still typing, and recompute an answer for a term already moved past.
A blank query with no genre returns nothing rather than everything. An empty box is not a request for 67,000 rows.
search(...) | Everything matching a term, optionally narrowed to one genre. Capped per kind. |
|---|---|
genreIndex(sourceId) | Which genres can be filtered by, and how much of the catalogue has been described. |
matchDispatcher | Injected. The genre filter cleans ~60,000 titles in Kotlin, which must not happen on the caller's thread. |
data classGenreIndex
The filterable genres, and the coverage percentage quoted beside them.
Coverage is on screen rather than hidden because a genre filter built on a cache that has seen a tenth of a catalogue is telling less than the whole truth. Counted over distinct cleaned titles, so a film listed four times in four qualities counts once.
classProfileRepository
Who is watching, and the switching of it.
The active profile is a StateFlow with a synchronous value, and that
shape is load-bearing. Every profile-scoped read needs the id — a browse query, a
favourite toggle, a resume point from the player — and a suspending lookup in each would be a
database round trip per call.
It falls back to Profile.NONE_ID rather than to a real profile. That id matches
no row, so reads come back empty and writes land nowhere, which is correct for the moment
before the chooser has been answered and means no call site needs a guard.
activeProfile | The chosen profile, or null when the chooser should be shown. Derived from the stored id and the rows, so a deleted profile puts the chooser back. |
|---|---|
endGuestSessions() | Called at startup. Deleting the row takes its favourites and resume points by foreign key. |
startGuestSession(name) | At most one guest exists at a time. |
classTitleMetadataScanner
Fills the metadata cache for a whole catalogue, resumably.
Four workers and no throttle of its own — the pacing is the client's token bucket, which every worker waits on. A second rate limit would be two things to keep in agreement.
One refusal stops everything: a volatile flag is read by each worker before it asks, so a rate limit stops the requests rather than merely stopping the counting. Work is computed by subtracting what is already cached, which is what makes starting again a resume.
state | Idle, Preparing, Running, Finished, Stopped or Cancelled. |
|---|---|
progressFraction | Null while preparing — a bar drawn against a total of zero means nothing. Shared by both settings screens so they cannot disagree. |
sealed interfaceMetadataScanState
How far a scan got, and how it ended.
Stopped carries a ScanRefusal — rate limited, key rejected, or unavailable —
because those call for different actions from the viewer. Collapsing them into "something went
wrong" leaves the actionable one indistinguishable from the two that are not.
A restatement of the TMDB client's refusal rather than a reuse of it, so nothing above
:core:data has to know a metadata service exists.
classCategoryRepository
Categories with local edits applied, and the edits themselves.
Two entry points on purpose: observeCategories hides hidden ones and is what
browsing uses; observeAllCategories returns everything and is for the screen that
edits them. A category has no id — it is derived by grouping — so the provider's title is the
only available key, and the join is done in code rather than SQL.
classGuideRepository
What is on now, now/next for one channel, and a whole listing across a window.
observeSchedule reads from storage rather than from a fetch, for the reason
everything else here does: with no connection the timeline still draws whatever was last
stored. Its query asks for programmes that overlap the window rather than start inside
it, because the programme a viewer is watching began before the window did and a timeline that
omitted it would open with a hole where "now" is.
refreshFullGuideFor is a separate entry point from refreshGuideFor
on purpose — it is the heavier call, made on an explicit request — but both share one private
refresh that owns the guards, the backoff and the wholesale replace. Two copies would give the
two paths two chances to disagree about when a panel has had enough, and the panel does not care
which of them asked.
classSourceRepository
Adding, refreshing and deleting sources. Owns the refresh transaction.
A refresh replaces a source's catalogue wholesale in a single transaction, chunked because SQLite binds a limited number of variables per statement. It is the only bulk network operation in the app.
classWatchHistoryRepository
Resume points, and the continue-watching list.
Collapses a series to the single episode last touched — ordered by when it was watched, not by how far through it is, because the furthest-through episode is not the one a viewer was last on.
classTitleMetadataRepository
The optional film and series information, and its cache.
Caches negative answers too. "The service was asked and had nothing" is an answer, and re-requesting it on every visit is the most wasteful thing a cache can do.
It never caches a failure. A rate limit or an unreachable host leaves a
title unknown rather than recording it as unmatched — see TmdbAnswer.
classChannelLogoRepository
Fills in logos for channels a playlist gave none for. Off by default.
One download of a single index file rather than a request per channel, guarded by a mutex so a browse screen asking about every visible row at once does not start a dozen downloads of the same several-megabyte file.
classPlayerSettingsRepository
Player settings and appearance, as flows the player and the UI share.
classBackupRepository
Export and import of configuration, as versioned JSON.
Credentials are never written. A backup from a newer schema is refused by name — the message states both the file's version and the build's, because a user told only "wrong format" has nothing to act on.
sealed interfaceRefreshOutcome / ImportResult
Success-or-failure results, with the reason attached.