Build log

Three weeks — phones, televisions, and what a show is

The first entry covered one day. This one covers the twenty-five since: 62 commits, the test count from 83 to 158, about 18,800 lines of TypeScript on the server and web, another 3,400 for the phone and television clients, and a library that is now 14,503 files, 10,252 of them episodes.

Roughly, the three weeks split into thirds. The first made the web player something you would choose to use. The second put Hearth on devices that are not a browser. The third was about the library itself — what is one film, what is one show — which turned out to be the hardest and most satisfying part.

The player grows up

Subtitles come from three places now: text tracks embedded in the file, sidecar files next to it (Film.en.srt, Film.forced.vtt), and, when a file has neither, OpenSubtitles, matched by the file's hash and the film's IMDb id. All of them are served as WebVTT. ASS subtitles carry drawing commands — vector shapes for signs and karaoke — that come out as gibberish in a converted cue, so those are dropped. Audio tracks default to English and switch from the player. There is a quality menu (Original down to 360p), a theater mode that fills the window without going fullscreen, and the keyboard does what YouTube taught everyone to expect.

Two encoder fixes are worth telling. A ten-bit HEVC file on the full GPU path came out as a green screen: the GPU's ten-to-eight-bit conversion produced all-zero pixels, the encoder encoded them without complaint, and with no error there was no fallback either. Ten-bit sources are now decoded on the card, converted in system memory, and encoded on the card — the same speed as software on this machine, with a real picture. And seeking a little way ahead used to wait for the encoder, which after its initial burst runs at 1.5× realtime; three segments ahead meant twelve seconds of spinner. The decision is now arithmetic: the estimated wait against the measured cost of restarting the pipeline, whichever is shorter.

People, and what they may see

Hearth had accounts on day one, but every member saw everything. Now a library is closed until an admin opens it to someone, a tick per library per person. A member sees those libraries, the videos they uploaded themselves, and anything shared publicly. The rule is one pure function, turned into SQL in exactly one place, and every listing and every media route goes through it — a library you lack is a 404 on the stream, not just a missing tile.

People can upload a video, name it, give it a picture (a chosen frame or an image), and share it at a short public link. Whoever adds a video may edit and share it. There are screen names and comments.

Sharing raised the question I least wanted to get wrong: when something is made private again, is it actually private? Rather than reason about it, there is now a script that proves it. It shares an item, checks that a stranger can reach the short link, the watch page, the stream, both playlists, a segment, the poster and the subtitles; makes it private; and checks that every one of those — plus the API, the comments, progress and downloads — now refuses, while a member still gets through. Thirty checks. It found one real hole: hand-set artwork was served with public, max-age=2592000, immutable, an open invitation for a shared cache to keep a picture of something that has since gone private. It is private now, like every other media response.

A phone

The iPhone app is Expo and React Native, talking to a versioned /api/v1 that the web app does not use, so the two can move separately. Getting it to build took longer than writing it. The current Xcode rejects the newest Expo 57 patch releases outright; 57.0.12 builds with one one-line patch. But pinning Expo alone does nothing, because every Expo module is declared with a ~ range and floats to the newest 57.0.x, whose native code wants a newer core. So every module is pinned to the exact version that Expo release shipped with, and a README in patches/ says why each pin exists and what has to move together on the next upgrade.

The best bug of the month was the first one the phone found. It signed in, and was refused on its very next request. iOS keeps its own cookie jar: it had stored the login's cookie and merged it into the Cookie header the app wrote itself, joined with a comma —

hearth_session=A,hearth_session=A; hearth_viewer=V

— which the server read as one session called A,hearth_session=A. The client had only ever run under Node, which has no jar. The app now sends a bearer token and tells the platform to keep its cookies to itself, and the server tolerates commas there anyway, because the next client will meet the same stack.

The phone plays direct or over HLS, picks audio tracks and subtitles, offers a film's other versions, shows the cast and crew, and remembers where you were. Build three, with all of that, went to TestFlight tonight.

Televisions

There are three television platforms in the house and three different ways in. Samsung's Tizen and LG's webOS both run web apps, so Hearth's TV client is framework-free web code driven entirely by a focus router and the remote, and the two are one app, two packages: the only real differences are a manifest and which key codes the remote sends for Back and captions. Vizio has no app store at all; its doors are Chromecast and AirPlay. So there is also a Google Cast receiver — Hearth's player in Hearth's colours, told explicitly that the HLS segments are MPEG-TS, because it assumes fMP4 otherwise. The packages build; signing certificates and developer mode on the actual sets are the current chores.

One TV lesson generalises: a text field on a remote-controlled screen must never be allowed to keep the focus. Every arrow key goes to the field and none to the rest of the screen, and there is no other way out. The field stays read-only until you press OK on it.

What is one film?

My library, like most real ones, has the same film more than once: a 1080p and a 4K, a rip and a remux, a copy on each of two volumes. 211 films exist as 483 files. Each copy had kept its own resume point, so the copy you didn't watch thought you hadn't started.

A film's files are now its versions: every file matched to the same TMDB film, less anything under half the length of the longest — a trailer that matched the title is not a version, a director's cut a third longer is. Versions share one status: where you were, whether you finished, and which subtitle and audio language you chose (stored by language, since track numbers differ between files). Switching version on the web or the phone carries on from the same second. A one-time backfill gave every diverged group its most recent status, and home shelves show a film once.

While the metadata was in view, details pages gained cast and crew: directed by, written by, the billed cast with faces, and a page for each person with a short biography, IMDb and TMDB links, and whatever of theirs is in the library that you are allowed to see. Credits arrive with the TMDB call Hearth was already making; 2,084 titles now carry 45,201 credits.

What is one show?

Then I looked at the TV list and saw It's Always Sunny in Philadelphia seven times. A show had been identified by its folder name, per library, and reality had supplied It's, Its and its; In and in; a box set whose folder was named …Philadelphia.Season; and a second volume. MobLand and Mobland were two shows that differed only in capitals.

A show is now its name. After every scan, spellings that differ only in case, apostrophes, punctuation, & versus and, a leading The or a trailing Season or Complete Series are folded into one, carrying each library's match, cast and favorites with them. Deliberately no more than that: Doctor Who and Doctor Who 2005 stay apart, and a TMDB match never merges two shows on its own, because a wrong match must not be able to fold real shows together. Shows span libraries, and the same episode in two folders is one episode with two versions, sharing a status exactly as films do. Before shipping, the fold was run against a copy of the production database to read every merge it would make and confirm none lost a match. 209 cards became 166 shows.

The fold uncovered an older bug. The parser had a rule to cut a folder name at its season marker — Mobland Season 1 Mp4 1080p to Mobland — and the rule had never fired, not once. The regular expression contained a literal backspace character where \b, a word boundary, was meant: it had been eaten somewhere between an editor and a file. The tests passed because no test covered a folder name with a season suffix. Now one does.

Tidying the disk

Hearth already copes with any naming, but the files are still a mess for anything else that reads the folders, and for the person browsing them. So there is now a page that says what good names look like —

TV Shows/Show Name (Year)/Season 01/Show Name (Year) - S01E01 - Title.mkv
Movies/Film Name (Year)/Film Name (Year).mkv

— and lists every show that is not there yet: split over folders or libraries, loose in the root, outside a season folder, duplicated. Each show can be previewed and then moved into that layout with one confirmation. The database row keeps its id, so watch history, comments and metadata stay; sidecar subtitles and the cached poster follow the file; duplicate copies get their resolution after the name rather than being deleted; emptied folders are removed; and every move goes to a log so it can be undone by hand. Films had the same treatment a few days earlier, along with a parser that strips a site's name glued to the front of a file.

The last snag was the filesystem's, not Hearth's. Two of the volumes are NFS shares that map every client user to the owner of the files, which is what lets the server write to them. On such a share, asking the kernel "may I write here?" with access() returns no — and the write then succeeds. Hearth now finds out whether it can write somewhere the only honest way: it writes a small file, and deletes it.

What is next

A version picker for the TV app, and a Cast button on the phone for the receiver. Running the television packages on the actual sets. Tidying the rest of the TV library now that every volume can be written to. And, eventually, that proper migration tool: data_migrations has held up for one-time fixes, but the first schema change that is not additive is coming.