Skip to content

Releases: DPilat-Dev/Apollo

v1.13.0

Choose a tag to compare

@DPilat-Dev DPilat-Dev released this 18 Sep 04:12

PGS subtitles arrive in seconds instead of minutes — or not at all.

Reported as subtitles that took a long time, never appeared, and then restarted the film. It turned out to be four separate bugs.

The wait

The first request for a subtitle track inside a Matroska file does not return a file; it starts one being made, and the server sends nothing until it finishes. That takes about 8.6 seconds per gigabyte of container, whatever the subtitle format — so a 30 GB film means four and a half minutes, and a 74 GB one means ten.

The renderer allowed sixty seconds for all of it: a worker starting, a several-megabyte download, and that extraction. Long enough to be a wait, never long enough to succeed. On expiry it fell back to burning the track in, which rebuilds the stream and restarts the film — and aborting destroyed the extraction that was still running, so the next attempt began from nothing.

The choice

Waiting more gracefully is not enough when the wait is minutes. The cost is decided by the file, not the format, and PGS only ever feels slow because PGS only exists in Blu-ray remuxes. So the decision is now made per file: small enough and the track is drawn here, instantly switchable and adjustable; large enough and the server burns it in, which extracts nothing and starts in a few seconds.

Measured on cold films: 1.7 s, 2.5 s, 5.9 s, 8.3 s to playing, against 75 s, 267 s, 74 s and 82 s of extraction before. Episodes keep the renderer and cost 4–10 seconds.

This is the same call jellyfin-web makes — it has this renderer too, behind a setting that ships turned off.

Two more, found on the way

  • Asking for a burn-in did nothing while the device profile declared pgssub as handled here. The server took that at its word and delivered externally, so the subtitle arrived by neither route.
  • Choosing a track reverted it. Automatic selection re-ran on the new stream that burning in produced, and put the preferred language straight back. Nothing appeared to change because it changed twice.

Also

The message while waiting stops claiming it is loading after twelve seconds and says the server is preparing them, and that it only happens once.

v1.12.1

Choose a tag to compare

@DPilat-Dev DPilat-Dev released this 13 Sep 06:12

Subtitles no longer burn themselves into the picture uninvited.

A PlaybackInfo request that names no subtitle stream does not mean "none" to Jellyfin — it means the file's default. For a file whose default track is a bitmap, the server built a transcode with that track re-encoded into the picture, so the episode came up with subtitles on for a viewer who had them off. Choosing Off sent another request naming no stream, and the server defaulted again: there was no way back.

Measured across the same five frames of the same episode — near-white pixels along the bottom of the decoded picture: 177 with subtitles off, 22,134 with the track deliberately chosen, 124 after choosing Off again. The middle number is burn-in working as it should. The other two were impossible before.

Also:

  • A deep link to a PGS track (?subtitle=N) went to the server to be burned in, rather than being drawn here as the menu already does — a transcode and a reload for a track that swaps instantly.
  • Every media-segment request was a 400, on every item. Jellyfin reads a comma-separated enum list as flags syntax, so two values parsed by accident and three did not. Repeated parameters are the form that binds. This one is latent — it matters once something is actually producing intro and credits ranges.

mov_text was checked at the same time and needed nothing: the server converts it to WebVTT cleanly, and it has been taking the ordinary subtitle path correctly all along.

v1.12.0

Choose a tag to compare

@DPilat-Dev DPilat-Dev released this 10 Sep 02:49

ASS typesetting now follows the picture under Fill and Stretch.

The subtitle renderer laid its canvas out as the video's letterboxed box and always had — which is exactly right under Fit, and why this looked correct for as long as nobody touched the aspect control. Under Fill the browser crops the picture to fill the screen, and under Stretch it distorts it; in both, the picture stopped being where the subtitles had been drawn. A sign pinned to a shop front landed somewhere else in the frame, and the dialogue sat inside a letterbox that was no longer there.

All three modes centre the picture on the same point, so the subtitles are now scaled about that centre onto wherever the picture actually is. Verified against a picture rectangle worked out independently of the code: all three modes line up to the pixel, and on a heavily typeset frame every sign stays on the object it labels when the aspect changes.

This was previously documented as a reason to turn typesetting off. It no longer is.

One trade-off worth knowing: the subtitles are drawn at the letterboxed size and scaled up, so Fill and Stretch soften the text a little — imperceptible at ordinary aspect mismatches, more noticeable at extreme ones.

v1.11.3

Choose a tag to compare

@DPilat-Dev DPilat-Dev released this 10 Sep 02:35

The year recap now says which show it was, when it was mostly one show.

A year that is 70% or more a single series gets that as its headline, naming the title and the share. It used to be described by genre — a year that was 94% South Park was called a certified cartoon adult, which is true and the least interesting true thing available. A favourite show at 30% is still a favourite show; at 70% it is the whole year. The quieter One show, mostly badge steps aside when this fires, so the same observation is not made twice.

Films person was last in the order, below the streak rule — so a year of films watched on consecutive evenings was described as a streak instead. It now sits above the shape-of-the-year rules, and still below the genre ones: what someone watched is more interesting than what format it arrived in.

Not a films year fired only at exactly zero films. One film in twelve months is the same story, and now says so.

v1.11.2

Choose a tag to compare

@DPilat-Dev DPilat-Dev released this 10 Sep 01:08

A private window now signs in, rather than being handed an address to click Connect on.

1.11.1 got the configured address to a fresh browser and stopped there, leaving it sitting in the box next to a Connect button — the same manual step it was meant to remove, only later. The auto-connect step reads the address known synchronously, and a private window has none; the fetched one arrives after that step has already looked and found nothing. So fetching it was only half the job.

A storage-free browser now lands on the account picker in about 150 ms with no address step at all.

This only ever happens when the install actually has VITE_JELLYFIN_SERVER set. Without it the server reports an empty address, and the sign-in screen leaves the box empty and reaches for nothing — verified with no request of any kind leaving the page. An address typed by hand also wins over a reply that lands mid-keystroke.

Updating

Ordinary update. If you are coming from a version before 1.11.1, run --service once afterwards to pick up the unit-file change:

/opt/apollo/scripts/update.sh
/opt/apollo/scripts/update.sh --service

v1.11.1

Choose a tag to compare

@DPilat-Dev DPilat-Dev released this 10 Sep 00:56

The server address survives a private window again.

Since 1.9.0 the address collected by the installer stopped reaching the sign-in screen. It was read through import.meta.env, which Vite replaces at build time — fine while every box built its own client, but these are built by CI, which has no .env. Sign-in only still knew the address because localStorage remembered the last one, so a private window, or any fresh browser, came up blank.

It is configuration, so it is read at run time now: the server reports it on /__apollo/config, and the unit file gained an EnvironmentFile line so the process can see /opt/apollo/.env at all. A remembered address still wins, and a from-source build with a .env behaves exactly as before.

Updating

update.sh runs from a pinned copy of whichever version started, so this release cannot install its own unit-file change — the copy driving the update predates it. Two commands, once:

/opt/apollo/scripts/update.sh
/opt/apollo/scripts/update.sh --service

The second re-renders the unit with the port this machine chose, reloads systemd and restarts. No fetch, no rebuild. From here on update.sh keeps the unit current by itself.

Also

  • --service reinstalls the systemd unit and nothing else
  • .claude/ is excluded from the test globs, so an abandoned agent worktree can no longer fail the suite with assertions about its own version number

Apollo v1.11.0

Choose a tag to compare

@DPilat-Dev DPilat-Dev released this 09 Sep 04:08

Your year, described — and Continue Watching finally telling the truth.

Continue Watching updates when you leave

Reported as "it only saves my place if I pause", and then, more usefully, as "the time left does not change until I refresh".

The second one is what explained it. The position was always being saved. The server had it. The card on the home page is drawn from a cache, and nothing in the player ever told that cache it was out of date — so the shelf kept showing the time remaining from before, until a reload happened to ask again. It also explains why pausing appeared to help: it did not save on pause either, but pausing is what people do before switching away, and switching back is what makes the page fetch again.

Leaving the player now refreshes the shelves it affects. Watched from 5 minutes in to 13, the card goes from "19m left" to "10m left" without a reload anywhere.

Three real faults turned up alongside it. Pausing reported nothing at all, so the position was only recorded by the ten-second heartbeat — it now reports the moment you pause, and the moment the tab or the app is hidden. The back arrow could leave the app entirely if you had opened the video directly or refreshed on it, and a page being torn down never gets to say where you had reached. And a sendBeacon meant to cover a closing tab had never worked in its life: a beacon cannot send the content type Jellyfin requires. A keepalive request in its place did not work either. Both are gone rather than left as reassurance.

One thing that is not the client's doing, in case it looks like this: Jellyfin itself keeps no resume point below 5% of an item's runtime or above 90%. Stopping a minute into a twenty-four minute episode, or a minute from the end, is meant to leave nothing behind.

The year recap says what kind of year it was

After the totals, a line about who you were while you watched them — and a few smaller ones beneath it. Thirteen of them, from the rarest signal to the commonest, because a year that is 8% one thing and 76% another is more interesting for the 8%.

Anime is told apart from cartoons properly, which took fixing something underneath: Jellyfin does not copy a show's genres onto its episodes, so a year of anime counted no anime at all — the word is on the series and nowhere else. Of 400 episodes sampled from one library, exactly one carried any genre. The shows behind a year are now looked up once and their genres folded in, which also means the top-genres panel describes the year rather than whichever handful of episodes happened to be tagged.

A year with fewer than ten things in it gets no label. Four evenings is not a personality.

Full changelog: v1.10.2...v1.11.0

Apollo v1.10.2

Choose a tag to compare

@DPilat-Dev DPilat-Dev released this 09 Sep 01:25

The year recap took three and a half seconds to show anything. It now takes about one.

Why it was slow

Not the server. The recap walks back through your watch history until it reaches the start of the year, and each page it fetches decides whether another is needed — so they cannot be asked for all at once. Every one waited for the last to arrive and for the page to redraw before the next went out. Eight of those in a row was the delay.

Measured against a real server, the round trips were the entire expense: two hundred items take 161ms and a thousand take 214ms. So it asks for a thousand at a time now, and needs one or two requests where it used to need eight.

A bug that came with it

Larger pages made the recap describe itself wrongly. The walk stops either when it reaches the start of the year or when it runs out of room, and it was checking for running out of room first. With small pages the year was always reached comfortably before the limit; with large ones both arrived together, the limit won, and a complete year was labelled "these are the most recent 1,200 and the real totals are higher" when they were not.

It checks the year first now, and decides whether to show that warning by whether it actually reached the start of the year rather than by counting what it loaded.

Verified against a real library: the same 405 hours before and after, and the warning correctly absent.

Full changelog: v1.10.1...v1.10.2

Apollo v1.10.1

Choose a tag to compare

@DPilat-Dev DPilat-Dev released this 09 Sep 00:48

A crash on the sign-in screen, present since 1.9.0.

Signing in works again

Anyone without a session — a new browser, a new device, or anyone who had signed out — was shown "This screen hit a problem" instead of somewhere to sign in. Existing sessions were unaffected, which is why it survived three releases: every browser it was tested in was already signed in.

The cause: a helper that deliberately throws when there is no session, called from the one component that runs before there is one. Settings sync added the call in 1.9.0. It now reads the same value from a place where being signed out is an expected answer rather than an error.

There is a test for it. Nothing in this suite mounts a component, so no amount of unit testing could have caught it; instead it reads the files that render above the sign-in gate and requires that none of them call the helper that throws there.

Full changelog: v1.10.0...v1.10.1

Apollo v1.10.0

Choose a tag to compare

@DPilat-Dev DPilat-Dev released this 08 Sep 22:59

One change: library and browse grids only render the rows you can see.

Why

A grid pages itself in as you scroll and kept every card. By the bottom of one library that was 12,142 elements and 415 decoded images — and the cost that actually showed on a slow phone was not holding them but making them. Each new page of sixty was built in one go, and the pauses landed exactly there.

What it does

Measured on a phone at a quarter speed, scrolling a screen at a time and pausing to look — how anyone actually reads a library:

before after
pauses 2 none
worst pause 181ms none
elements held 3,585 1,416
images held 120 45

Two visible stutters become none.

Dragged from top to bottom without stopping it is worse — more, smaller interruptions instead of a few large ones. That is a finger on the scrollbar rather than someone reading, and the same run holds 923 elements against 8,804 and 11 MB of memory against 20.

The page stays exactly as tall as it would have been, so the scrollbar does not move under you and coming back to a library still returns to where you were.

What it costs

Find-in-page only matches cards that are rendered, and so does tab order. That is the trade every implementation of this makes, and it cannot be avoided while the point is to not render things. A few rows beyond the viewport in each direction are kept so that arrowing or tabbing past the edge lands on something real.

Verified

1299 tests, 0 lint errors, clean build. In a browser: paging in still works, every sample point down the viewport lands on a card, the hover effect still grows past its box and paints over its neighbour, and going back still returns to the same offset.

Full changelog: v1.9.1...v1.10.0