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.
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.
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.