Quiblo wiki

Architecture

The database

Eleven schema versions, why favourites are their own table, and how identity survives a refresh.

# Identity across a refresh

The single most important idea in the schema.

channels, before a refresh channels, after favorites · resume_positions id = 4193 stableKey = "xt:live:80421" id = 91782 ← different stableKey = "xt:live:80421" ← same keyed by stableKey, never by id
A refresh deletes and reinserts every row, so every primary key changes. Anything that must outlive a refresh is keyed by the provider's own identity instead — which is why favourites are a separate table rather than a column.

A refresh replaces a source's catalogue wholesale: delete every row, insert the new ones. Every primary key therefore changes, and anything keyed by primary key is destroyed.

So each row carries a stable key — the provider's own identity for that item — and everything that must outlive a refresh is keyed by it: favourites, resume points, guide entries, cached metadata. It is why favourites are a separate table rather than a boolean column, and the join is done in SQL so a twenty-thousand-row list is not re-mapped every time one is toggled.

# The tables

TableHoldsNote
sourcesConfigured playlists and accountsNo credentials. Those are encrypted in DataStore
channelsEvery playable item: live, film and series rowsReplaced wholesale on refresh
profilesWho is watchingOwns favourites and resume points, and nothing else
favoritesFavouritesKeyed by profile + source + stable key
resume_positionsWhere you stopped, and enough to list it as historyKeyed by profile + stable key. Denormalised on purpose — see below
programmesGuide entriesSource-agnostic: adding XMLTV later needs no migration
title_metadataCached film and series informationKeyed by cleaned title and kind
channel_logosThe optional logo reference indexA cache of a public catalogue, not user data
category_overridesLocal renames and hidesKeyed by the provider's own title

# Why history is denormalised

resume_positions carries the title, artwork, duration and — when the row is an episode — its season and episode numbers. That looks like duplication of the channel row, and a join would seem tidier.

It would also be wrong. An episode is never a row in the channel table. An episode's identity is its stream URL; episodes are fetched from the panel per series and held for a session. A history list that joined would show films and silently drop every episode — which is most of what anyone actually resumes.

Rows written before those columns existed keep resuming correctly and are excluded from the history list by their empty title: a tile with no name and no artwork is worse than one row of history missing.

# How a profile scopes anything

Two tables gained a profileId in their primary key, not merely as a column: favorites is keyed by profile, source and stable key, and resume_positions by profile and stable key. Two people can therefore hold the same favourite and stop at different points in the same film, which is the entire feature.

The foreign key to profiles cascades on delete, and that is doing real work. Guest data does not outlive its session because the database enforces it, not because every screen remembers to help: deleting the guest row takes its favourites and its resume points with it, atomically, from one statement.

Above the database, the active profile is a StateFlow with a synchronous value. Every profile-scoped read needs the id — a browse query, a favourite toggle, a resume point written from the player — and a suspending lookup in each of those would be a database round trip per call. Held in memory, it is a field read.

The id that matches no row

Before anybody has chosen, the active id is 0. Ids are generated from 1, so that value matches nothing: every profile-scoped read comes back empty and every write lands nowhere.

That is not a hole to be guarded at each call site — it is the correct behaviour for a moment when the app does not yet know whose data it would be touching, and it means no screen needs a special case for "no profile". The chooser then stands in front of the app so the question does not stay open.

# Why metadata is keyed by title and kind

Cached film and series information is keyed by the cleaned search title plus the kind, rather than by channel id.

By id, a refresh would reassign every id and throw the whole cache away for no reason. By title alone, "Fargo" the film and "Fargo" the series would be one record, and whichever tab you opened first would answer for the other.

Negative answers are cached 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.

# Migrations

Eleven versions so far, every one with a real migration. Destructive migration is deliberately not enabled — silently dropping a user's configured sources on a schema change would be a data-loss bug, and an acceptance criterion requires version mismatches to be handled explicitly rather than by discarding state.

The schema JSON is exported on every build and committed. Room validates the live database against it at open time, so a migration that does not produce exactly what the entities declare fails loudly at launch rather than subtly later.

VersionChange
1 → 2Resume positions
2 → 3Programmes, with its two indices
4 → 5Film metadata cache
5 → 6Category overrides
6 → 7Metadata reworked to cover series as well as films
7 → 8History columns on resume positions
8 → 9Channel logo index
9 → 10The two missing indices — see the browse defects
10 → 11Profiles, and re-keying favourites and resume positions onto them

Version 10 is pure index creation: no data moves and no columns change, so it cannot lose anything. It costs a moment on the first launch after upgrading, once, against a saving on every emission thereafter.

Version 11 is the opposite kind, and the most careful one written so far. SQLite cannot add a column to a primary key, so both tables are rebuilt: create the new shape, copy every row across, drop the old, rename. The copy is where the user's data is, and it is a single INSERT … SELECT that assigns every existing row to profile 1.

Profile 1 is created by the same migration and named Default, because everything on the device at that moment belongs to whoever has been using the app. An upgrade that offered a fresh empty profile instead would look exactly like having lost every favourite.