Architecture
Search
The one question that ignores the division the rest of the app is built on, and why it asks it once.
# Why it is not three searches
Everything else in the app is organised by kind: a repository answers "what is in this category" for one destination at a time, and Live, Movies and Series each own a screen. Search is the one question that cuts across all of it.
A viewer looking for Fargo does not know, and should not have to know, whether their provider filed it as a film or as a series. Panels routinely list the same title as both — a film, and a one-episode "series" — so a search that answers for one kind is a search that appears to have found nothing.
Hence a repository of its own rather than a fourth method on the browse one. It returns results already separated by kind, because that is how they are read, but it asks all three questions from one call.
# One-shot reads, not flows
Every read here is suspend and returns once. That is a deliberate departure
from the rest of the data layer, which is flows almost everywhere.
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 the viewer has already moved past. Keystrokes are debounced above, and the latest question cancels the previous one.
A blank query with no genre returns nothing rather than everything. An empty search box is not a request for sixty-seven thousand rows, and the browse tabs already exist for looking at the whole catalogue.
# The genre filter, and its coverage figure
Genres are not a fixed list. They are derived from the metadata cache, so the filter offers only genres something the viewer actually owns is filed under — offering "Western" against a catalogue holding none is a control that can only disappoint.
That has a consequence the UI cannot hide, so it states it: the filter is only as complete as the cache. Hence the coverage percentage on screen beside it. A filter running against a tenth of a library is telling less than the whole truth, and silently omitting nine films in ten is worse than saying so. The scanner is what raises the figure.
How coverage is counted
Two details in the arithmetic, both there to stop the number lying:
- Over distinct cleaned titles, not rows. A provider listing one film four times in four qualities has one title to look up. Counting rows would report a quarter of the coverage actually held.
- Titles that clean away to nothing are excluded from both halves — a name written entirely in a non-Latin script, a bare language tag. They will never be looked up, so leaving them in the denominator would cap the figure below 100% permanently and make a complete cache look like a broken one.
Live channels are matched differently, on purpose
Films and series are matched through the cache, by cleaned title. Live channels have no metadata and never will, so they are matched on the genre word appearing in the channel's own name. That is a weaker rule, chosen knowingly: it is how a channel called "CRIME NETWORK HD" comes back for "Crime", and the alternative is a live column that is always empty.
# Where the work happens
The plain search is SQL — a LIKE against the channel table, capped per kind,
with the favourite join done in the query so a result already knows whether it is favourited
by whoever is watching.
The genre filter cannot be. It compares a cleaned form of every film and series title against the cache, and cleaning is a regex pass SQLite cannot express. On the account this project is tested against that is around sixty thousand passes, so it runs on the default dispatcher rather than on the caller's thread — the same reasoning as the browse mapping defect, which dropped frames for exactly this reason.
When a genre is selected, films and series share one result cap rather than getting one each, and the split back into columns happens after the rows are read. A genre held mostly by series would otherwise return almost no films.