Vela

Recommendations with no server and no other viewers, on a phone

Notes from building Vela, a player for iPhone and iPad that signs in to the user's own Xtream account. Vela includes no content and no provider. Its recommendations (Top Picks, the "Because You Liked" rows, More Like This and a match score on every title) are part of Vela Pro, and this is how they are made.

No server, so no collaborative filtering

"People who liked this also liked" needs other people's viewing. Vela has no server and collects nothing; its App Store privacy label reads Data Not Collected. Public datasets such as MovieLens are licensed for non-commercial research and cover a different set of titles from any provider's. So every recommendation comes from one person's own signals, on their own device, against their own provider's library, kept in the same SQLite catalog that search uses. Each saved provider is learned separately, because a category id or a title on one provider means nothing on another.

What stands in for collaborative filtering is item-to-item similarity over content: franchises and people as the links between titles, and how widely the provider carries a title as its popularity.

First, what is a title?

A large provider carries one film many times: EN - The Long Ember, 4K - The Long Ember, DE - The Long Ember (2023). A row that offers two of them has wasted a seat, and someone who has watched one has watched them all. So every title is reduced to its work first: the provider's tag comes off, so do the year and everything after it, and so do bracketed country and quality tags. Copies of a work count as one title everywhere.

Then a title is described by its category; its genres, folded onto one vocabulary across the languages providers file under; its cast in billing order and its director; its franchise, the opening words that enough distinct works share, so a sequel is filed under the first film's words and never under its subtitle (a single shared word counts only when the titles are numbered); and its edition, the language, platform and tag its category and title carry, each folded onto one code, so EN - ACTION and ENGLISH SERIES both say en. How common each of these is in the library takes one pass over every title, so it is counted once when the catalog is fetched and kept on disk, not counted again on every launch.

Signals

Thumbs up and down, favourites, how much of each title was watched, searches and what was picked from them, actors and genres browsed, sheets opened, and which recommendations were shown. Watching follows one implicit-feedback scale: a detail sheet opened is +1, a tenth of a title watched +3, the end +5, and out again within two minutes −5. Thumbs and favourites sit above that scale, because they are said rather than inferred. Signals fade with a half-life (thumbs much more slowly than browsing), and the last few hours count again on top, so three horror sheets opened back to back show up in the next build of the rows.

The profile is a lift per feature

For each feature (a genre, an actor, a franchise, a category, an edition), the profile holds how much more often it turns up in what this person goes for than it does in the library, as a smoothed log ratio:

lift(f) = log(  (mass(f) + β)
             / (mass · base(f) + β) )

Here mass(f) is the interest spread onto that feature, mass the total for its kind, base(f) the feature's share of the library, and β = 2 a fixed pseudo-count of evidence, worth one film watched to the end. Dislikes and bail-outs make a second distribution, and a feature over-represented there is held down by however much it is.

The fixed β replaced a Dirichlet prior of one pseudo-title per feature in the library. On a small library that is a handful of pseudo-titles. On a very large one it held a genre within 4% of neutral after a liked series and a favourite, so the picks fell back on rating and newness. A fixed amount behaves the same on any library: it stops one liked title with a rare actor from reading as an overwhelming taste for that actor, and it is gone once the evidence is several titles deep.

What a lift is measured against. Before anything has been recommended, the library is the only yardstick. After that it is the wrong one: someone shown mostly comedy will mostly play comedy, and counting those plays against the library reads a preference into what is only exposure, closing a loop around a genre the person merely tolerates. So base(f) moves towards the mix the rows actually showed, weighted by days on screen, in proportion to how much has been shown. Comedy played as often as it was shown says nothing; westerns played four times as often as they were shown say a great deal. It is the same correction as inverse-propensity weighting, made with the impression log the app already keeps for its own rows.

The signals are read twice, over months for who this person is and over the last week for what they are into now, and dated signals are also filed by time of day.

Candidates, then a fixed linear ranker

Indexed queries narrow the library to a couple of thousand candidates: the liked categories, genres and people, the best rated and the newest as exploration, and, item to item, the franchise, leads and director of each title the person enjoyed most, looked up through FTS5 with BM25 so the best matches survive the limit.

The ranker is a linear score over a handful of features: fit to the profile by kind (category, genre, people, franchise, edition), fit to the last week, likeness to the person's favourite titles (BM25F over franchise, director, cast, genre, category and edition, weighted by rarity in this library), rating, newness, habit, time of day, fatigue, how widely the provider carries the title, and a small hourly rotation. A perfect rating on a title almost nobody carries is discounted, since it is usually one vote, and carriage is log-scaled, so a title carried sixty times is not far ahead of one carried sixteen.

The weights are fixed. An earlier version learned them per person: every recommendation shown was logged with what the ranker saw and where on the page it sat, and whether it was then played trained a logistic model, with position as a training-only feature to absorb position bias. On ten simulated viewers it scored 0.69 precision at ten against 0.80 for fixed weights, and it got worse the more it learned. One person's few dozen evenings, every click on something this ranker chose to show, cannot separate "this kind of title is wanted" from "this is what was on screen". Learning a ranker needs many people's data. What one device's data can support lives in the profile, where every signal counts and not just clicks on the rows.

The rows

Top Picks is diversified: each pick's fit is discounted for every earlier pick that shares its category, leading genre or franchise; at most two come from one franchise; and a work appears once. Two seats go to exploration, well-rated titles from territory the profile knows nothing about, in a language the person watches. A title offered on five days and never touched comes off the rows. The "Because You Liked" rows are built before Top Picks, so the closest titles to a favourite stay on its own row instead of being taken by the row above. More Like This is BM25 likeness nudged by taste, never the title's own copies, and a person's language gates a franchise's translations: a German copy stays off the row of someone who watches the English ones. The rows are rebuilt every hour.

Who is in a film, asked politely

A provider's film list carries no genre, cast or director. Only get_vod_info does, one request per film. Series bring theirs with the catalog, so until Vela 1.2.0, on a large provider, nearly every film was matched by its category and title alone, and "more with this actor" could not be asked at all.

Fetching every film is out of the question. Fetching the few dozen a build of the rows depends on is not: the person's favourites, the films the profile likes most, and the forty best candidates. The rules are written to go unnoticed by the person and by the provider:

  • One request at a time, three seconds apart, and no more than 300 requests a day, counted from the stored table so a relaunch does not reset the count.
  • Nothing while anything is playing, Picture in Picture included, and nothing in Low Data Mode or Low Power Mode. The requests use their own URLSession, which will not use a constrained network.
  • The first sign that the provider is not answering ends the run until the next build. A film the provider has nothing on is asked about again only after a month.
  • The requests go only to the person's own provider, which a film's detail page already calls.

What comes back lives in a table that catalog refreshes leave alone, and is read wherever a film's traits are. A liked film's cast becomes a taste for its actors, the likeness of two films counts the people they share, and an in-memory index from person to films lets More Like This and Top Picks find films by their actors, the way series are found through the search index.

Why this pick?

Tap a match score and Vela shows how that title was chosen, as a node-link graph: the person's own titles that counted most, the features they share with the pick, and how strongly the profile leans to each. It is computed from the same profile, statistics and ranker that chose the title, and its breakdown is the ranker's own sum, term by term; a check holds it to the score the title was ranked on within 10−9. "Your Taste" shows the whole profile the same way, with "more of this" and "less of this" on any genre, category or person.

How it is measured

Vela collects nothing, so there are no numbers from real users, and these are not user numbers. They come from a simulation that runs the shipping pipeline end to end (the catalog database, the signal store, the profile, the ranker) against simulated viewers with hidden tastes: a utility per genre, some taste for quality and for a few actors, the pull of a franchise they are part-way through, noise per title, and a limited attention span. The library has 16,612 invented titles shaped like a provider's: 150 franchises, 4K copies, and German copies of English films that an English viewer does not want however well they match. The viewers are only as realistic as their model.

With 80 viewers over twelve simulated days, measured on 11 October 2026:

  • Online, each day showing the home screen and watching some of it, so the feedback loop is included: precision at ten on the top row over days 8 to 11 is 0.81, against 0.34 for popularity. Measured against the library alone instead of what was shown, it is 0.74.
  • Held out: thirty titles learned from, the next ten to find. Each held-out title ranked among a hundred unseen ones lands in the top ten 52% of the time, against 29% for rating and popularity alone and 10% for chance.
  • Rating prediction: the ranker's score, calibrated, predicts the viewer's rating with an RMSE of 0.91, against 1.13 for the catalog's rating alone.

The held-out figure is the sampled form, which is known to flatter. Ranked against the whole library, ten answers among sixteen thousand titles are close to a lottery for every method: precision at ten is 0.02 for the recommender and 0.00 for popularity. With ten viewers, as the everyday check runs it, the figures move by several points from one block of viewers to the next (the online figure is 0.88 for the first ten), which is why the figures above are from 80. The repository's checks run this simulation and fail any change that breaks one of its thresholds.

What it adds up to

None of it needs a model server or anyone else's data: a lift per feature with a fixed prior, a correction for what the rows themselves showed, BM25 for likeness between titles, a linear ranker with weights set by measurement, and a polite background fetch for the one thing the film list leaves out. It runs in SQLite on the phone, and the profile it learns stays there.

Vela Xtream: IPTV Player is on the App Store for iPhone and iPad. It needs an account with an Xtream-compatible provider and comes with no content of its own. The recommendations described here are part of Vela Pro; Vela learns on the free plan too.

The other note, on decoding an API with no specification and searching it as you type: Decoding a format with no spec, and searching it as you type, on a phone.