Commit graph

4 commits

Author SHA1 Message Date
mithral
2c424ae67c Expose the television tree: catalog reads, API routes, Jellyfin shapes
The scanner builds library -> series -> season -> episode; this makes it
reachable. Three reads, all grant-checked through catalog.Store (ADR-005):
a series' seasons, a season's episodes, and the next unwatched episode.

Each loads the parent through ItemFor first, so forbidden and absent are the
same ErrNotFound, and the child query is scoped to the parent's own
library_id so a corrupt parent_id cannot cross a library boundary. Verified
red-green: swapping ItemFor for Item returns two rows to an ungranted
caller.

Episodes come back in episode order, not insertion order, and the ordering
is verified red-green too - dropping the clause yields [3 1 10 2].

Routes stay inside the existing family: /api/v1/items/{id}/seasons,
/episodes and /next. No parallel route surface.

Jellyfin gains SeriesDto, SeasonDto and EpisodeDto. Jellyfin's wire type is
BaseItemDto, so these carry the folder/file split plus IndexNumber and
ParentIndexNumber. ItemType now takes the item rather than the kind, because
an episode IS kind video - that is the one signature change, and the dto
tests were updated with it.

Verified end-to-end against a running server: a library holding S01E02,
1x02, a bare-number file and a film produced two series, three seasons and
six episodes, with the film left alone; the routes returned the seasons in
order, the episodes in episode order, and the next unwatched episode.

Note for later: Subsonic and the web UI are untouched, so a series browses
there as it did before. The scanner also has no separate 'tv' kind, so a
video library mixing films and shows builds both shapes side by side.
2026-10-12 01:41:05 -04:00
mithral
2bdfc6d139 Serve the Jellyfin surface under /emby as well as at the root
Many Jellyfin clients probe GET /emby/System/Info/Public during their
handshake. There was no /emby prefix anywhere in the tree, so that 404ed
and the client abandoned discovery and fell back to its manual "enter your
server URL" screen - the exact experience mDNS exists to remove.

Register installs every pattern twice through a mount helper, with the same
handler value both times, so the alias cannot drift from the root surface.
The JSON 404 fallbacks get /emby forms too, plus one subtree handler,
because static.go's isAPIPath knows only /healthz and /api/ and an unknown
/emby/... path would otherwise be served the SPA shell.

No new endpoint, no handler behaviour changed, and the bare root paths are
untouched: Jellyfin's own clients use them.
2026-10-12 01:04:33 -04:00
mithral
6e04e957b8 Rate limit every surface that checks a password
The limiter covered only /api/v1/auth/login. Jellyfin's AuthenticateByName
and Subsonic's authenticate both called auth.Authenticate with no limiting,
so an attacker brute-forced through those instead: the same password, the
same argon2, no ceiling.

The limiter moves from auth.Handlers onto *Authenticator, which all three
surfaces already hold, so they share one budget and splitting guesses
across surfaces gains nothing. main.go builds one authenticator and hands
it to both compat layers, so the sharing is real rather than intended.

Subsonic needs care: it has no login step, authenticate runs on every
request, so a naive check would charge playback against the login budget
and lock a listener out mid-album. The limiter is consulted only when a
credential was actually supplied, and a success or an uncredentialed
request spends nothing.

Verified red-green on all three: removing the Jellyfin gate fails
TestAuthenticateByNameIsRateLimited with 401 instead of 429; removing the
Subsonic credential guard fails
TestSuccessAndUncredentialedDoNotConsumeTheBudget; neutering AllowLogin or
ResetLogin fails the auth tests.

Known gap, unchanged: the limiter is in-process, so it resets on restart
and does not span replicas. Fine for one binary against one SQLite file.
2026-10-12 00:57:56 -04:00
mithral
608ac5c6d6 Close the Jellyfin test gap
internal/jellyfin is the most client-visible surface in the tree — real Jellyfin
apps parse its JSON — and it had zero test files. Phase 2 had just moved its
DTOs, so this closes the gap before the next refactor.

Coverage: internal/jellyfin 0.0% -> 35.7%, internal/jellyfin/dto -> 68.9%.

The tests, and why each exists:

- Credential forms. X-Emby-Token, X-MediaBrowser-Token, ?api_key= and
  Authorization: MediaBrowser Token="...". The query form matters most: <video>
  and <audio> elements cannot set headers, so a client that can only use
  ?api_key= must still authenticate.
- Grant scoping. A user with no grants sees zero items, and an item they cannot
  read returns a response BYTE-IDENTICAL to one that does not exist. A 403 would
  confirm the library is there and leak its existence. The test asserts the two
  responses are indistinguishable, not merely both non-200.
- The transcode refusal. A client that disables both direct play and direct
  stream is demanding a transcode, and gets ErrorCode NoCompatibleStream rather
  than a TranscodingUrl that would never resolve. The normal path is asserted to
  advertise SupportsTranscoding FALSE, because a client told otherwise waits
  forever for a stream that never starts.
- Streaming with ONLY ?api_key= plus a Range header returns 206 with a correct
  Content-Range and the right bytes, and a token cannot stream a file it cannot
  read.
- AuthenticateByName mints a token that then authenticates, and returns no token
  for bad credentials.
- DTO mapping: exact JSON key set, RunTimeTicks as ms x 10000, id round-trip,
  ImageTags only with a cover, and golden JSON for both the item and the media
  source.

Both halves are proven able to FAIL, because a test that cannot fail is
decoration:
  - flipping SupportsTranscoding to true fails TestMediaSourceNeverTranscodes
  - disabling the grant check in catalog.LoadFor fails
    TestGrantScopingIsIndistinguishableFromAbsent AND TestStreamRefusesUnreadableItem
Both were restored afterwards and the non-test files verified byte-unchanged.

Process note: the agent for this task failed twice (HTTP 400) and left a test
file that did not compile — a bad slices.Delete call and a helper it never
wrote. I fixed those, corrected one wrong expectation (a video item legitimately
carries MediaType; the test's want-list omitted it), and wrote the handler tests
myself since that was the half that never got reached.

gofmt clean, build exit 0, vet exit 0, 25 packages pass, svelte-check 0/0.
2026-10-11 16:10:58 -04:00