Six rounds of real-server debugging (see docs/architecture.md's player-
track-selection section) found three different, unrelated reasons a
server-side burn-in could fail - missing fonts, a bitmap format libass
fundamentally can't burn, ffmpeg failing outright on an otherwise
unremarkable file - none of which this app's own request ever caused or
could have fixed, all downstream of the one thing every attempt had in
common: asking Emby for a real-time video re-encode. Not needing that
re-encode at all closes the whole class of failure instead of chasing
each one.
- server/src/services/CatalogService.ts: new proxySubtitle asks Emby to
convert the track to WebVTT (GET /Videos/{id}/{mediaSourceId}/
Subtitles/{index}/Stream.vtt - a real Emby endpoint their own web
client already uses for this) - entirely separate from the video
transcode. applyPlaybackOverrides lost its SubtitleStreamIndex/
SubtitleMethod=Encode branch - the video request no longer needs to
know a subtitle was picked at all.
- server/src/types.ts, client/src/types.ts: PlaybackTrackOption's flag
renamed supportsBurnIn -> isTextBased - same underlying fact (image vs
text codec), but the name shouldn't keep describing a mechanism this
app no longer uses.
- server/src/index.ts: new GET /api/catalog/subtitle/:itemId/:subtitleIndex
route, grouped with the other cookie-authed media-element routes (a
<track> can't send a bearer header either).
- client/src/api/embyApi.ts: new subtitleUrl(); streamUrl no longer
carries SubtitleStreamIndex at all.
- client/src/views/player/Player.ts: the subtitle <select> no longer
reloads the video (reloadWithCurrentOptions, still used for audio/
quality, never touches subtitles now). New applySubtitleTrack()
removes/re-adds a fresh <track> and sets video.textTracks[0].mode =
'showing' synchronously right after appending - deliberately not
waiting on the <track>'s own "load" event, confirmed against a real
browser that it doesn't fire reliably at all (a known cross-browser
quirk), which would have left a subtitle silently never shown - the
exact symptom this whole saga started from. Re-applied on every video
(re)load so an audio/quality change (which does still reload the
video) doesn't drop whatever subtitle was showing.
- server/test/catalogService.test.ts: updated/new cases for
proxySubtitle and the renamed isTextBased flag; removed the test for
the now-deleted subtitle-forcing behavior.
Verified against the real compiled bundle: picking a text track adds one
<track> with the right URL and flips it to showing; "Aucun" removes it;
an image-coded track is listed but disabled; changing audio reloads the
video but the subtitle survives it, same element, still showing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two different items both failed the same way when picking a subtitle -
without knowing whether the second one was also a PGS (image/bitmap)
track like the first confirmed one, that result was ambiguous: still the
same known issue, or something new? Rather than keep digging through
server logs per item, this app can just know the answer itself - Emby's
PlaybackInfo response already includes each subtitle stream's Codec.
- server/src/services/CatalogService.ts: getPlaybackOptions reads it and
reports supportsBurnIn: false for image-based subtitle codecs
(hdmv_pgs_subtitle/dvd_subtitle/dvb_subtitle - IMAGE_SUBTITLE_CODECS).
This app forces SubtitleMethod=Encode whenever a subtitle is picked
(applyPlaybackOverrides), asking ffmpeg's `subtitles` filter (libass)
to burn it in - libass renders text via a font, it has no path for
compositing a pre-rendered bitmap at all, confirmed against a real
server (see docs/architecture.md's Round 4/5).
- server/src/types.ts, client/src/types.ts: PlaybackTrackOption gains
supportsBurnIn (subtitle tracks only, undefined for audio).
- client/src/views/player/Player.ts: the subtitle <select> now disables
these options with a "(image, non disponible)" label instead of
letting anyone pick a track that's guaranteed to break playback -
listed so it's clear why, not silently missing.
- server/test/catalogService.test.ts: new case covering subrip/ass
(supportsBurnIn: true) vs hdmv_pgs_subtitle (false) from the same
MediaStreams response.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- server/test/*.test.ts: formalizes this project's many ad-hoc scratchpad
test scripts into a committed suite, one file per service
(musicQueueService, partyService, socketGateway, catalogService), using
Node's built-in node:test + node:assert/strict - zero new dependencies.
Runs straight against TypeScript source via `tsx --test` (npm test), no
compile step.
- server/tsconfig.test.json: extends the base config, overrides rootDir to
"." and adds test/**/*.ts to include, so `npm run typecheck` covers the
test files too without pulling them into dist/ (npm run build still
uses the unmodified base tsconfig, scoped to src/ only).
- server/package.json: typecheck now runs against tsconfig.test.json; new
test script.
- .forgejo/workflows/publish-docker.yml: new `test` job (node:20-alpine
container - the publish job's docker:27-cli has no Node) running server
typecheck+test and client typecheck+build; publish now depends on it via
needs: test. Removed the stale "no test suite yet" comment.
- README.md, docs/architecture.md: documented the test suite, how to run
it locally, and the new CI job/gating.
Verified locally: server typecheck, server test (25/25 passing), server
build, client typecheck, and client build all pass individually - the
same commands the new CI job runs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>