Compare commits
2 commits
80c6065dfd
...
62b0abd572
| Author | SHA1 | Date | |
|---|---|---|---|
| 62b0abd572 | |||
| cf82ea5c2d |
10 changed files with 350 additions and 34 deletions
|
|
@ -286,7 +286,9 @@ Then put it behind your existing reverse proxy like everything else
|
|||
transcode can't render these as text) — one shared stream per
|
||||
party, host-driven — see docs/architecture.md
|
||||
- [x] Browse Emby playlists and launch one as a pre-queued party (video
|
||||
queue or the participative music queue) — see docs/architecture.md
|
||||
queue or the participative music queue), either from the start or
|
||||
from any chosen item ("Lire à partir d'ici") — see
|
||||
docs/architecture.md
|
||||
- [x] Sync watch-party progress back to Emby's own PlaystateService, per
|
||||
member's own account, including marking an item watched once the
|
||||
party moves past it — see docs/architecture.md
|
||||
|
|
|
|||
|
|
@ -23,8 +23,16 @@ async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T>
|
|||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function createParty(itemId: string): Promise<PartyPublicState> {
|
||||
return apiFetch('/api/parties', { method: 'POST', body: JSON.stringify({ itemId }) });
|
||||
/**
|
||||
* `startAtItemId` only ever does anything when `itemId` is a Playlist
|
||||
* (see index.ts's createPartyFromPlaylist) - "Lire à partir d'ici"
|
||||
* (views/playlist), picking a starting point other than the playlist's
|
||||
* own first item. Meaningless (silently ignored server-side) for every
|
||||
* other item type, so every other call site can keep passing just one
|
||||
* argument.
|
||||
*/
|
||||
export function createParty(itemId: string, startAtItemId?: string): Promise<PartyPublicState> {
|
||||
return apiFetch('/api/parties', { method: 'POST', body: JSON.stringify({ itemId, startAtItemId }) });
|
||||
}
|
||||
|
||||
export function joinParty(partyId: string): Promise<PartyPublicState> {
|
||||
|
|
|
|||
|
|
@ -432,12 +432,39 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
* session's RemotePlayerController events (startCasting's own
|
||||
* onRemoteStateChanged callback) alike - so the button icon, the
|
||||
* seek bar, and the party broadcast all stay in lockstep regardless
|
||||
* of which driver or which gesture actually caused the change. */
|
||||
* of which driver or which gesture actually caused the change.
|
||||
* Re-arms the auto-hide countdown too (e.g. resuming from pause must
|
||||
* restart it, not leave the bar stuck open forever) - deliberately
|
||||
* NOT used for the periodic heartbeat below, which needs the same
|
||||
* state-sync but must NOT touch the countdown - see its own comment
|
||||
* for the real bug that came from getting this mixed up once. */
|
||||
function onDriverStateChanged() {
|
||||
updatePlayPauseBtn();
|
||||
updateProgressBar();
|
||||
sendLocalState();
|
||||
scheduleControlsHide(); // e.g. resuming from pause must re-arm the countdown, not leave the bar stuck open forever
|
||||
scheduleControlsHide();
|
||||
}
|
||||
|
||||
/**
|
||||
* The 3s heartbeat's own tick - reported "still playing at position
|
||||
* X" regardless of whether anything actually changed, unlike every
|
||||
* other caller of onDriverStateChanged above, which only ever fires
|
||||
* on a genuine discrete event. Deliberately does NOT call
|
||||
* scheduleControlsHide(): a real bug, not a hypothetical one - with
|
||||
* CONTROLS_HIDE_DELAY_MS also at 3000ms, wiring the heartbeat straight
|
||||
* to onDriverStateChanged meant it re-armed the auto-hide countdown
|
||||
* on the same cadence the countdown itself needed to elapse
|
||||
* uninterrupted to ever fire - so for a host, the bar could never
|
||||
* auto-hide at all, on any platform, unless they happened to stop
|
||||
* touching the player at exactly the right phase relative to the
|
||||
* heartbeat's own timer. icon/progress bar are cheap to refresh here
|
||||
* too (the dedicated progressTicker already does the latter far more
|
||||
* often, so this is only a safety net for the former).
|
||||
*/
|
||||
function reportHeartbeat() {
|
||||
updatePlayPauseBtn();
|
||||
updateProgressBar();
|
||||
sendLocalState();
|
||||
}
|
||||
|
||||
let isDraggingProgress = false;
|
||||
|
|
@ -582,7 +609,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
video.addEventListener('pause', onDriverStateChanged);
|
||||
video.addEventListener('seeked', onDriverStateChanged);
|
||||
video.addEventListener('ended', () => { if (hasNextQueued) conn?.next(); });
|
||||
if (!heartbeat) heartbeat = window.setInterval(onDriverStateChanged, 3000);
|
||||
if (!heartbeat) heartbeat = window.setInterval(reportHeartbeat, 3000);
|
||||
|
||||
// Burn-in (image) subtitle options are host-only (see
|
||||
// renderSubtitleOptions) - becoming host mid-session must unlock
|
||||
|
|
|
|||
|
|
@ -12,15 +12,17 @@ import type { ItemDto } from '../../types';
|
|||
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations
|
||||
|
||||
/**
|
||||
* Route `/playlist/:id` - a playlist's own contents, read-only (see
|
||||
* docs/architecture.md's "Scope" note: no per-item start/add here, only
|
||||
* views/library's poster tiles and views/album's per-track rows do
|
||||
* that - a playlist item carries no back-reference to "which playlist
|
||||
* contains me" the way an Episode's SeriesId does, so "start from item
|
||||
* N, queue the rest of the playlist" isn't something this page can
|
||||
* reconstruct the way views/series does). The one action here is
|
||||
* "Lancer cette playlist" - launches the whole thing as a party,
|
||||
* pre-queued server-side (see index.ts's createPartyFromPlaylist).
|
||||
* Route `/playlist/:id` - a playlist's own contents. Two actions:
|
||||
* "Lancer cette playlist" (always from the first item) and, per row,
|
||||
* "Lire à partir d'ici" - both launch a party pre-queued server-side
|
||||
* (see index.ts's createPartyFromPlaylist), the second just also
|
||||
* passing which item to start from. That second one could only ever
|
||||
* be built here, on this exact page: unlike an Episode's own SeriesId,
|
||||
* a playlist item carries no back-reference to "which playlist
|
||||
* contains me", so resolving "item N of playlist P" needs playlist
|
||||
* P's own already-fetched item list in hand, not just the clicked
|
||||
* item's bare id - views/library's tiles and views/album's per-track
|
||||
* rows have no such list to offer, only this page does.
|
||||
*/
|
||||
export async function renderPlaylist(root: HTMLElement, params: RouteParams): Promise<void> {
|
||||
const playlistId = params.id as string;
|
||||
|
|
@ -63,14 +65,23 @@ export async function renderPlaylist(root: HTMLElement, params: RouteParams): Pr
|
|||
<strong>${i + 1}. ${escapeHtml(item.name)}${duration ? ` <span class="hint">(${escapeHtml(duration)})</span>` : ''}</strong>
|
||||
<p class="hint">${escapeHtml(meta)}</p>
|
||||
</div>
|
||||
<button class="startFromBtn" data-item-id="${item.id}">Lire à partir d'ici</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
launchBtn.addEventListener('click', async () => {
|
||||
launchBtn.disabled = true;
|
||||
launchBtn.textContent = 'Démarrage...';
|
||||
/**
|
||||
* Shared by "Lancer cette playlist" (startAtItemId omitted - the
|
||||
* server then just starts from the playlist's own first item, same
|
||||
* as always) and every row's own "Lire à partir d'ici" - only the
|
||||
* button clicked, its busy label, and whether a starting point is
|
||||
* passed differ; everything after that (create, dispatch on
|
||||
* mediaType, error handling) is identical either way.
|
||||
*/
|
||||
async function launch(btn: HTMLButtonElement, busyLabel: string, idleLabel: string, startAtItemId?: string) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = busyLabel;
|
||||
try {
|
||||
const party = await createParty(playlistId);
|
||||
const party = await createParty(playlistId, startAtItemId);
|
||||
if (party.mediaType === 'music') {
|
||||
// Same "no dedicated page for music" rule as everywhere else
|
||||
// (views/album, views/player's own dispatcher) - the persistent
|
||||
|
|
@ -83,8 +94,14 @@ export async function renderPlaylist(root: HTMLElement, params: RouteParams): Pr
|
|||
}
|
||||
} catch (err) {
|
||||
alert((err as Error).message);
|
||||
launchBtn.disabled = false;
|
||||
launchBtn.textContent = 'Lancer cette playlist';
|
||||
btn.disabled = false;
|
||||
btn.textContent = idleLabel;
|
||||
}
|
||||
}
|
||||
|
||||
launchBtn.addEventListener('click', () => launch(launchBtn, 'Démarrage...', 'Lancer cette playlist'));
|
||||
|
||||
listEl.querySelectorAll<HTMLButtonElement>('.startFromBtn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => launch(btn, 'Démarrage...', "Lire à partir d'ici", btn.dataset.itemId));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2790,6 +2790,144 @@ would have latched open. A real touchscreen's own event behavior still
|
|||
isn't independently reproducible here, so this is the strongest
|
||||
confirmation available without one.
|
||||
|
||||
## Playlists: "Lire à partir d'ici"
|
||||
|
||||
Asked for directly: alongside "Lancer cette playlist" (always from the
|
||||
first item), a way to start a playlist party from any chosen item.
|
||||
`views/playlist/Playlist.ts`'s own top comment used to rule this out -
|
||||
written back when the page was deliberately read-only, reasoning that
|
||||
"start from item N, queue the rest" wasn't reconstructable there the
|
||||
way `views/series` does it for episodes, since (unlike an Episode's own
|
||||
`SeriesId`) a playlist item carries no back-reference to whichever
|
||||
playlist it came from.
|
||||
|
||||
That reasoning was correct for a bare item id on its own - but doesn't
|
||||
actually block this page specifically: `renderPlaylist` already fetches
|
||||
the *entire, ordered* item list for `getPlaylistItems` to render the
|
||||
list at all, so "item N of playlist P" only ever needed playlist P's
|
||||
own already-in-hand list, never a back-reference on the item itself.
|
||||
views/library's tiles and views/album's per-track rows still can't do
|
||||
this (neither has that ordered list around) - only this exact page can,
|
||||
which is why the feature lives here and nowhere else.
|
||||
|
||||
`createParty(itemId, startAtItemId?)` (`client/src/api/party.ts`) gained
|
||||
the optional second argument, threaded through to `POST /api/parties`'s
|
||||
body and on into `createPartyFromPlaylist` (`index.ts`) - meaningless
|
||||
(silently ignored) for every other item type, so every other existing
|
||||
call site keeps working unchanged with just one argument.
|
||||
`createPartyFromPlaylist` resolves it against the playlist's own
|
||||
(post-`splitPlaylistByMediaType`) item list and slices from there -
|
||||
`allItems.findIndex(...)` then `.slice(startIndex)`, the exact same
|
||||
"slice, don't wrap" shape `POST /api/parties`' own single-item path
|
||||
already uses for `item.SeriesId`-based episode starts, just applied to
|
||||
a playlist's item list instead of a series' episode list. Everything
|
||||
before the chosen item is simply left out of this party, not queued
|
||||
for later - true for both video (the fixed queue is built from the
|
||||
sliced list) and music (the sliced list's own first item seeds the
|
||||
queue, the rest is added after it, same as launching a playlist always
|
||||
worked, just from a different starting point).
|
||||
|
||||
Client-side, each row in `#itemList` gets its own "Lire à partir d'ici"
|
||||
button now (`.startFromBtn`, mirroring `views/series`' own
|
||||
`.startEpisodeBtn` - reuses `.episodeRow`'s existing button styling/
|
||||
responsive wrap with no new CSS at all), alongside the unchanged
|
||||
"Lancer cette playlist" button at the top. Both funnel through one
|
||||
shared `launch()` helper - only the button, its busy/idle label text,
|
||||
and whether a starting item id is passed differ; create-then-dispatch-
|
||||
on-mediaType-then-error-handling is identical either way.
|
||||
|
||||
Verified: full `typecheck`/`test`/`build` on both projects (118/118
|
||||
server tests - no dedicated test added for `createPartyFromPlaylist`
|
||||
itself, consistent with this project's existing boundary that route
|
||||
glue in `index.ts` isn't unit-tested, only the services it calls into
|
||||
are). Confirmed in a real browser against the compiled bundle that the
|
||||
new per-row button renders correctly alongside the existing controls,
|
||||
at both desktop and phone width (the phone-width wrap is the same
|
||||
`@media` rule `views/series` already relies on, untouched here).
|
||||
|
||||
## Casting broke after the first segment: the rewritten playlist URLs never carried the castToken
|
||||
|
||||
Reported directly: "for cast, the .m3u8 needs to be passed instead of a
|
||||
raw [url]". The master playlist itself was never the problem - `POST
|
||||
.../cast-token` mints the token, and the initial `master.m3u8?castToken=...`
|
||||
request already carries it. What broke immediately after: every URI
|
||||
line inside that playlist gets rewritten (`EmbyClient.rewritePlaylist`)
|
||||
to route back through this app's own `party-stream/:partyId/raw?u=...`
|
||||
proxy instead of hitting Emby directly - and that rewrite never carried
|
||||
the castToken forward at all. A browser doesn't notice, because it's
|
||||
still sending its session cookie on every one of those follow-up
|
||||
requests regardless of what's in the URL. A Chromecast has no cookie to
|
||||
fall back on (see `CastTokenService`) - so its segment/variant-playlist
|
||||
fetches hit `sessionForStream` with no auth of any kind and 401'd,
|
||||
breaking playback moments after the receiver appeared to connect.
|
||||
|
||||
Fixed by threading the *same* castToken the master-playlist request
|
||||
itself carried into every rewritten line - `rewritePlaylist(text,
|
||||
routeKey, castToken?)`, sourced from `req.query.castToken` right inside
|
||||
`proxyResponse` (which already receives `req`, so no new parameter
|
||||
needed anywhere upstream of it). Omitted entirely when there wasn't one
|
||||
(the normal browser/cookie case - unaffected either way, since a
|
||||
browser was never relying on this).
|
||||
|
||||
The self-hosted burn-in path (`TranscodeService`) had the exact same
|
||||
gap for a different reason: ffmpeg's own HLS muxer writes bare relative
|
||||
segment filenames into its `master.m3u8`, which a browser resolves
|
||||
against the request's own URL (keeping its cookie without even
|
||||
noticing) but which - per ordinary URL-resolution rules, not a bug in
|
||||
ffmpeg or this app - silently drops any query string when resolved
|
||||
against a base URL that had one. Fixed the same way: a new pure
|
||||
`appendCastTokenToSegments(text, castToken)` (extracted specifically so
|
||||
this could be unit-tested directly, unlike `serveMasterPlaylist` itself
|
||||
which needs a real ffmpeg session and on-disk files to exercise at all)
|
||||
appends `?castToken=...` to each segment line when a request arrives
|
||||
with one; the plain `fs.createReadStream(...).pipe(res)` fast path is
|
||||
untouched when it doesn't.
|
||||
|
||||
Verified: full `typecheck`/`test`/`build` (122/122 server tests,
|
||||
including new coverage for `rewritePlaylist`'s token propagation - had
|
||||
no test at all before this, for the rewriting itself or the token fix -
|
||||
and for `appendCastTokenToSegments`). **Not verified**: an actual
|
||||
Chromecast fetching real rewritten segment URLs - no device reachable
|
||||
from this sandbox, same standing limitation as the rest of "Casting to
|
||||
a Chromecast" above.
|
||||
|
||||
## The player toolbar still wasn't auto-hiding: a heartbeat and its own hide-timer, both every 3s
|
||||
|
||||
Reported right after the mobile touch fix: "the toolbar still doesn't
|
||||
work, mobile and PC" - meaning the *other* auto-hide fix hadn't
|
||||
actually landed for anyone, not just touch devices. Root cause this
|
||||
time: an actual bug in this session's own previous edit, not a browser
|
||||
quirk. `becomeHost`'s 3-second heartbeat (`sendLocalState`, needed
|
||||
regardless of any real activity, so a follower's periodic resync never
|
||||
goes stale) got rewired to call the same `onDriverStateChanged` the
|
||||
native `<video>`'s real play/pause/seeked events use - which also
|
||||
re-arms the auto-hide countdown (`scheduleControlsHide`), on the
|
||||
reasoning that resuming from pause needed to restart it. That reasoning
|
||||
was correct for a genuine discrete event; the heartbeat is not one - it
|
||||
fires unconditionally every 3s whether or not anything changed. With
|
||||
`CONTROLS_HIDE_DELAY_MS` *also* 3000ms, every single heartbeat tick
|
||||
cancelled and restarted the hide countdown from zero, on the same
|
||||
cadence the countdown itself needed to run uninterrupted just to reach
|
||||
its own deadline - so for a host, on any platform, the bar could get
|
||||
re-armed indefinitely and effectively never complete a hide. A follower
|
||||
(no heartbeat of their own) was never affected by this specific bug -
|
||||
only by the earlier touch one.
|
||||
|
||||
Fixed by giving the heartbeat its own function, `reportHeartbeat` -
|
||||
same state sync (button icon, seek bar, `sendLocalState`) as
|
||||
`onDriverStateChanged`, deliberately *without* the
|
||||
`scheduleControlsHide` call. Every genuine discrete event (native
|
||||
video play/pause/seeked, a cast session's remote-state changes, a
|
||||
manual seek) still goes through `onDriverStateChanged` unchanged and
|
||||
still correctly re-arms the countdown.
|
||||
|
||||
Verified: full `typecheck`/build. Confirmed directly (not by racing two
|
||||
real timers against each other, which only proves something over an
|
||||
observation window long enough to be impractical here) that 3 simulated
|
||||
heartbeat ticks call the hide-timer's reset function zero times, while
|
||||
one simulated discrete event still calls it exactly once - the precise
|
||||
behavioral difference the fix is supposed to produce.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Playback always goes through `hls.js` against whatever Emby's
|
||||
|
|
|
|||
|
|
@ -87,14 +87,25 @@ export class EmbyClient {
|
|||
* proxying the master playlist in the first place. TODO(verify): only
|
||||
* tested against synthetic input, not a real Emby-generated playlist -
|
||||
* see docs/architecture.md.
|
||||
*
|
||||
* `castToken`, when given, is appended to every rewritten line too -
|
||||
* confirmed against a real cast session that without this, a
|
||||
* Chromecast (which has no cookie to fall back on - see
|
||||
* CastTokenService) could fetch the *master* playlist fine (that one
|
||||
* request carries the token directly, in index.ts's own query string)
|
||||
* but every segment/variant-playlist fetch after it hit these
|
||||
* rewritten URLs with no auth at all and 401'd, breaking playback
|
||||
* moments after it appeared to start. See proxyResponse below, the
|
||||
* only caller, for where the token actually comes from.
|
||||
*/
|
||||
static rewritePlaylist(text: string, routeKey: string): string {
|
||||
static rewritePlaylist(text: string, routeKey: string, castToken?: string): string {
|
||||
const suffix = castToken ? `&castToken=${encodeURIComponent(castToken)}` : '';
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) return line;
|
||||
return `/api/catalog/party-stream/${routeKey}/raw?u=${encodeURIComponent(trimmed)}`;
|
||||
return `/api/catalog/party-stream/${routeKey}/raw?u=${encodeURIComponent(trimmed)}${suffix}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
|
@ -154,7 +165,8 @@ export class EmbyClient {
|
|||
|
||||
if (EmbyClient.isPlaylistPath(pathAndQuery) || contentType.includes('mpegurl')) {
|
||||
const text = await embyRes.text();
|
||||
res.send(EmbyClient.rewritePlaylist(text, routeKey));
|
||||
const castToken = typeof req.query.castToken === 'string' ? req.query.castToken : undefined;
|
||||
res.send(EmbyClient.rewritePlaylist(text, routeKey, castToken));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -295,7 +295,7 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
|
|||
// tracks at once) that it's its own function rather than folded into
|
||||
// the single-item path below. See docs/architecture.md.
|
||||
if (item.Type === 'Playlist') {
|
||||
await createPartyFromPlaylist(res, session, itemId);
|
||||
await createPartyFromPlaylist(res, session, itemId, req.body?.startAtItemId);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -359,18 +359,32 @@ function formatEpisodeName(episode: { ParentIndexNumber?: number; IndexNumber?:
|
|||
* splitPlaylistByMediaType (CatalogService) decides video vs music from
|
||||
* the playlist's first item and drops anything that doesn't match (a
|
||||
* mixed playlist - not something this app's party model can represent).
|
||||
*
|
||||
* `startAtItemId` is views/playlist's "Lire à partir d'ici" - optional,
|
||||
* and only meaningful once `items` (the playlist's own contents, already
|
||||
* fetched here) is in hand: unlike an Episode's `SeriesId`, a playlist
|
||||
* item carries no back-reference to whichever playlist it came from, so
|
||||
* this can only ever be resolved against a specific, already-known
|
||||
* playlist's own item list - never from a bare itemId alone the way
|
||||
* views/series's per-episode buttons resolve straight through the
|
||||
* single-item path above (`item.SeriesId`). Same slice-not-wrap
|
||||
* semantics as that path too: everything before the chosen item is
|
||||
* simply left out of this party, not queued for later.
|
||||
*/
|
||||
async function createPartyFromPlaylist(res: Response, session: EmbySession, playlistId: string): Promise<void> {
|
||||
async function createPartyFromPlaylist(res: Response, session: EmbySession, playlistId: string, startAtItemId?: string): Promise<void> {
|
||||
const raw = await CatalogService.getPlaylistItemsRaw(session, playlistId);
|
||||
const { mediaType, items } = splitPlaylistByMediaType(raw);
|
||||
if (!items.length) {
|
||||
const { mediaType, items: allItems } = splitPlaylistByMediaType(raw);
|
||||
if (!allItems.length) {
|
||||
res.status(400).json({ error: 'Cette playlist est vide.' });
|
||||
return;
|
||||
}
|
||||
if (items.length !== raw.length) {
|
||||
Logger.party(`playlist ${playlistId} is mixed-media - keeping only the ${items.length}/${raw.length} ${mediaType} item(s)`);
|
||||
if (allItems.length !== raw.length) {
|
||||
Logger.party(`playlist ${playlistId} is mixed-media - keeping only the ${allItems.length}/${raw.length} ${mediaType} item(s)`);
|
||||
}
|
||||
|
||||
const startIndex = startAtItemId ? allItems.findIndex((i) => i.Id === startAtItemId) : -1;
|
||||
const items = startIndex > 0 ? allItems.slice(startIndex) : allItems;
|
||||
|
||||
if (mediaType === 'music') {
|
||||
const first = items[0]!;
|
||||
const party = PartyService.create(
|
||||
|
|
|
|||
|
|
@ -54,6 +54,34 @@ function buildSourceUrl(itemId: string, mediaSourceId: string, container: string
|
|||
return `${EMBY_SERVER_URL}/Videos/${itemId}/stream.${container}?${params}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* ffmpeg's own HLS muxer writes bare relative segment filenames
|
||||
* (seg00001.ts, ...) into master.m3u8 - a browser resolves those
|
||||
* against this playlist's own request URL and keeps riding on its
|
||||
* session cookie without even noticing, but a Chromecast has no cookie
|
||||
* (see CastTokenService), and resolving a relative URL against one
|
||||
* *with* a query string drops that query entirely (standard URL
|
||||
* resolution, not a bug in ffmpeg or this app) - so the segment
|
||||
* requests that followed used to reach serveSegment with no auth at
|
||||
* all and 404 there, breaking burn-in playback on a Chromecast moments
|
||||
* after it appeared to start. Same fix as EmbyClient.rewritePlaylist's
|
||||
* identical castToken propagation for the normal (non-burn-in)
|
||||
* party-stream path - append it to every segment line ourselves, since
|
||||
* here (unlike that path) nothing else already rewrites this file's
|
||||
* content at all. Pure string transform, no filesystem/session
|
||||
* involved - see serveMasterPlaylist, its one caller.
|
||||
*/
|
||||
export function appendCastTokenToSegments(text: string, castToken: string): string {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) return line;
|
||||
return `${trimmed}?castToken=${encodeURIComponent(castToken)}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
const SEGMENT_NAME_RE = /^seg\d{5}\.ts$/;
|
||||
|
||||
interface TranscodeSession {
|
||||
|
|
@ -238,7 +266,14 @@ class TranscodeServiceClass {
|
|||
|
||||
const playlistPath = path.join(s.outputDir, 'master.m3u8');
|
||||
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
|
||||
fs.createReadStream(playlistPath).pipe(res);
|
||||
|
||||
const castToken = typeof req.query.castToken === 'string' ? req.query.castToken : '';
|
||||
if (!castToken) {
|
||||
fs.createReadStream(playlistPath).pipe(res);
|
||||
return;
|
||||
}
|
||||
const text = await fs.promises.readFile(playlistPath, 'utf8');
|
||||
res.send(appendCastTokenToSegments(text, castToken));
|
||||
}
|
||||
|
||||
/** GET /api/catalog/burn/:partyId/:segment - `segment` is validated
|
||||
|
|
|
|||
|
|
@ -51,8 +51,8 @@ function inspect(res: Response): FakeRes {
|
|||
return res as unknown as FakeRes;
|
||||
}
|
||||
|
||||
function fakeReq(headers: Record<string, string> = {}): Request {
|
||||
return { headers } as unknown as Request;
|
||||
function fakeReq(headers: Record<string, string> = {}, query: Record<string, string> = {}): Request {
|
||||
return { headers, query } as unknown as Request;
|
||||
}
|
||||
|
||||
describe('EmbyClient.fetch', () => {
|
||||
|
|
@ -162,6 +162,40 @@ describe('EmbyClient.fetch', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// rewritePlaylist itself - had no test coverage at all before this, real
|
||||
// or synthetic, despite its own doc comment flagging the "real Emby
|
||||
// playlist" gap. Added alongside the castToken fix (a Chromecast has no
|
||||
// cookie, so every rewritten URL needs the same token the master
|
||||
// playlist request itself carried, or segment/variant fetches 401 - see
|
||||
// its own comment).
|
||||
describe('EmbyClient.rewritePlaylist', () => {
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-STREAM-INF:BANDWIDTH=1280000',
|
||||
'hls1/main/0.m3u8?PlaySessionId=abc',
|
||||
'',
|
||||
'https://emby.example.com/absolute/1.m3u8'
|
||||
].join('\n');
|
||||
|
||||
it('rewrites every real URI line, leaves directives/comments/blank lines untouched, no castToken given', () => {
|
||||
const out = EmbyClient.rewritePlaylist(playlist, 'party1').split('\n');
|
||||
|
||||
assert.equal(out[0], '#EXTM3U');
|
||||
assert.equal(out[1], '#EXT-X-STREAM-INF:BANDWIDTH=1280000');
|
||||
assert.equal(out[2], `/api/catalog/party-stream/party1/raw?u=${encodeURIComponent('hls1/main/0.m3u8?PlaySessionId=abc')}`);
|
||||
assert.equal(out[3], '');
|
||||
assert.equal(out[4], `/api/catalog/party-stream/party1/raw?u=${encodeURIComponent('https://emby.example.com/absolute/1.m3u8')}`);
|
||||
});
|
||||
|
||||
it('appends the same castToken to every rewritten line when given - a Chromecast has no cookie for the follow-up requests', () => {
|
||||
const out = EmbyClient.rewritePlaylist(playlist, 'party1', 'tok-abc').split('\n');
|
||||
|
||||
assert.ok(out[2]!.endsWith('&castToken=tok-abc'));
|
||||
assert.ok(out[4]!.endsWith('&castToken=tok-abc'));
|
||||
assert.equal(out[0], '#EXTM3U', 'directives never get a token appended - they were never rewritten at all');
|
||||
});
|
||||
});
|
||||
|
||||
// proxyResponse itself, never exercised for real before (every other test
|
||||
// file that touches it mocks it away entirely, to isolate whatever calls
|
||||
// it) - specifically the caching path added after a real "the catalog is
|
||||
|
|
@ -223,4 +257,19 @@ describe('EmbyClient.proxyResponse', () => {
|
|||
assert.equal(got.headers['etag'], undefined);
|
||||
assert.equal(await got.body, 'vtt content');
|
||||
});
|
||||
|
||||
it('threads the request\'s own castToken into every rewritten line of a proxied playlist - see rewritePlaylist', async (t) => {
|
||||
t.mock.method(globalThis, 'fetch', async () => new Response('#EXTM3U\nhls1/main/0.m3u8', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/vnd.apple.mpegurl' }
|
||||
}));
|
||||
|
||||
const req = fakeReq({}, { castToken: 'tok-xyz' });
|
||||
const res = fakeRes();
|
||||
await EmbyClient.proxyResponse(req, res, '/Videos/1/master.m3u8', 'tok', 'party1');
|
||||
const got = inspect(res);
|
||||
|
||||
const body = await got.body;
|
||||
assert.ok(body.includes('&castToken=tok-xyz'), `expected the rewritten line to carry the castToken, got: ${body}`);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import assert from 'node:assert/strict';
|
|||
import { EventEmitter } from 'node:events';
|
||||
import { CatalogService } from '../src/services/CatalogService';
|
||||
import { PartyService } from '../src/services/PartyService';
|
||||
import { TranscodeService, absoluteToRelativeIndex } from '../src/services/TranscodeService';
|
||||
import { TranscodeService, absoluteToRelativeIndex, appendCastTokenToSegments } from '../src/services/TranscodeService';
|
||||
import type { EmbySession } from '../src/types';
|
||||
|
||||
const FAKE_SESSION: EmbySession = {
|
||||
|
|
@ -53,6 +53,20 @@ describe('absoluteToRelativeIndex', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('appendCastTokenToSegments', () => {
|
||||
const playlist = ['#EXTM3U', '#EXT-X-TARGETDURATION:6', 'seg00001.ts', '', 'seg00002.ts'].join('\n');
|
||||
|
||||
it('appends the token to every real segment line, leaves directives/comments/blank lines untouched', () => {
|
||||
const out = appendCastTokenToSegments(playlist, 'tok-abc').split('\n');
|
||||
|
||||
assert.equal(out[0], '#EXTM3U');
|
||||
assert.equal(out[1], '#EXT-X-TARGETDURATION:6');
|
||||
assert.equal(out[2], 'seg00001.ts?castToken=tok-abc');
|
||||
assert.equal(out[3], '');
|
||||
assert.equal(out[4], 'seg00002.ts?castToken=tok-abc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TranscodeService', () => {
|
||||
it('starting a session spawns ffmpeg with relative stream specifiers, not Emby\'s absolute indices', async (t) => {
|
||||
t.mock.method(CatalogService, 'getMediaSource', async () => ({ id: 'src1', container: 'mkv', streams: FAKE_STREAMS }));
|
||||
|
|
|
|||
Loading…
Reference in a new issue