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
|
transcode can't render these as text) — one shared stream per
|
||||||
party, host-driven — see docs/architecture.md
|
party, host-driven — see docs/architecture.md
|
||||||
- [x] Browse Emby playlists and launch one as a pre-queued party (video
|
- [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
|
- [x] Sync watch-party progress back to Emby's own PlaystateService, per
|
||||||
member's own account, including marking an item watched once the
|
member's own account, including marking an item watched once the
|
||||||
party moves past it — see docs/architecture.md
|
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>;
|
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> {
|
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
|
* session's RemotePlayerController events (startCasting's own
|
||||||
* onRemoteStateChanged callback) alike - so the button icon, the
|
* onRemoteStateChanged callback) alike - so the button icon, the
|
||||||
* seek bar, and the party broadcast all stay in lockstep regardless
|
* 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() {
|
function onDriverStateChanged() {
|
||||||
updatePlayPauseBtn();
|
updatePlayPauseBtn();
|
||||||
updateProgressBar();
|
updateProgressBar();
|
||||||
sendLocalState();
|
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;
|
let isDraggingProgress = false;
|
||||||
|
|
@ -582,7 +609,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
video.addEventListener('pause', onDriverStateChanged);
|
video.addEventListener('pause', onDriverStateChanged);
|
||||||
video.addEventListener('seeked', onDriverStateChanged);
|
video.addEventListener('seeked', onDriverStateChanged);
|
||||||
video.addEventListener('ended', () => { if (hasNextQueued) conn?.next(); });
|
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
|
// Burn-in (image) subtitle options are host-only (see
|
||||||
// renderSubtitleOptions) - becoming host mid-session must unlock
|
// 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
|
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
|
* Route `/playlist/:id` - a playlist's own contents. Two actions:
|
||||||
* docs/architecture.md's "Scope" note: no per-item start/add here, only
|
* "Lancer cette playlist" (always from the first item) and, per row,
|
||||||
* views/library's poster tiles and views/album's per-track rows do
|
* "Lire à partir d'ici" - both launch a party pre-queued server-side
|
||||||
* that - a playlist item carries no back-reference to "which playlist
|
* (see index.ts's createPartyFromPlaylist), the second just also
|
||||||
* contains me" the way an Episode's SeriesId does, so "start from item
|
* passing which item to start from. That second one could only ever
|
||||||
* N, queue the rest of the playlist" isn't something this page can
|
* be built here, on this exact page: unlike an Episode's own SeriesId,
|
||||||
* reconstruct the way views/series does). The one action here is
|
* a playlist item carries no back-reference to "which playlist
|
||||||
* "Lancer cette playlist" - launches the whole thing as a party,
|
* contains me", so resolving "item N of playlist P" needs playlist
|
||||||
* pre-queued server-side (see index.ts's createPartyFromPlaylist).
|
* 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> {
|
export async function renderPlaylist(root: HTMLElement, params: RouteParams): Promise<void> {
|
||||||
const playlistId = params.id as string;
|
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>
|
<strong>${i + 1}. ${escapeHtml(item.name)}${duration ? ` <span class="hint">(${escapeHtml(duration)})</span>` : ''}</strong>
|
||||||
<p class="hint">${escapeHtml(meta)}</p>
|
<p class="hint">${escapeHtml(meta)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="startFromBtn" data-item-id="${item.id}">Lire à partir d'ici</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
launchBtn.addEventListener('click', async () => {
|
/**
|
||||||
launchBtn.disabled = true;
|
* Shared by "Lancer cette playlist" (startAtItemId omitted - the
|
||||||
launchBtn.textContent = 'Démarrage...';
|
* 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 {
|
try {
|
||||||
const party = await createParty(playlistId);
|
const party = await createParty(playlistId, startAtItemId);
|
||||||
if (party.mediaType === 'music') {
|
if (party.mediaType === 'music') {
|
||||||
// Same "no dedicated page for music" rule as everywhere else
|
// Same "no dedicated page for music" rule as everywhere else
|
||||||
// (views/album, views/player's own dispatcher) - the persistent
|
// (views/album, views/player's own dispatcher) - the persistent
|
||||||
|
|
@ -83,8 +94,14 @@ export async function renderPlaylist(root: HTMLElement, params: RouteParams): Pr
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert((err as Error).message);
|
alert((err as Error).message);
|
||||||
launchBtn.disabled = false;
|
btn.disabled = false;
|
||||||
launchBtn.textContent = 'Lancer cette playlist';
|
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
|
isn't independently reproducible here, so this is the strongest
|
||||||
confirmation available without one.
|
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
|
## Known limitations
|
||||||
|
|
||||||
- Playback always goes through `hls.js` against whatever Emby's
|
- 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
|
* proxying the master playlist in the first place. TODO(verify): only
|
||||||
* tested against synthetic input, not a real Emby-generated playlist -
|
* tested against synthetic input, not a real Emby-generated playlist -
|
||||||
* see docs/architecture.md.
|
* 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
|
return text
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((line) => {
|
.map((line) => {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed || trimmed.startsWith('#')) return line;
|
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');
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
@ -154,7 +165,8 @@ export class EmbyClient {
|
||||||
|
|
||||||
if (EmbyClient.isPlaylistPath(pathAndQuery) || contentType.includes('mpegurl')) {
|
if (EmbyClient.isPlaylistPath(pathAndQuery) || contentType.includes('mpegurl')) {
|
||||||
const text = await embyRes.text();
|
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;
|
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
|
// tracks at once) that it's its own function rather than folded into
|
||||||
// the single-item path below. See docs/architecture.md.
|
// the single-item path below. See docs/architecture.md.
|
||||||
if (item.Type === 'Playlist') {
|
if (item.Type === 'Playlist') {
|
||||||
await createPartyFromPlaylist(res, session, itemId);
|
await createPartyFromPlaylist(res, session, itemId, req.body?.startAtItemId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -359,18 +359,32 @@ function formatEpisodeName(episode: { ParentIndexNumber?: number; IndexNumber?:
|
||||||
* splitPlaylistByMediaType (CatalogService) decides video vs music from
|
* splitPlaylistByMediaType (CatalogService) decides video vs music from
|
||||||
* the playlist's first item and drops anything that doesn't match (a
|
* the playlist's first item and drops anything that doesn't match (a
|
||||||
* mixed playlist - not something this app's party model can represent).
|
* 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 raw = await CatalogService.getPlaylistItemsRaw(session, playlistId);
|
||||||
const { mediaType, items } = splitPlaylistByMediaType(raw);
|
const { mediaType, items: allItems } = splitPlaylistByMediaType(raw);
|
||||||
if (!items.length) {
|
if (!allItems.length) {
|
||||||
res.status(400).json({ error: 'Cette playlist est vide.' });
|
res.status(400).json({ error: 'Cette playlist est vide.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (items.length !== raw.length) {
|
if (allItems.length !== raw.length) {
|
||||||
Logger.party(`playlist ${playlistId} is mixed-media - keeping only the ${items.length}/${raw.length} ${mediaType} item(s)`);
|
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') {
|
if (mediaType === 'music') {
|
||||||
const first = items[0]!;
|
const first = items[0]!;
|
||||||
const party = PartyService.create(
|
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}`;
|
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$/;
|
const SEGMENT_NAME_RE = /^seg\d{5}\.ts$/;
|
||||||
|
|
||||||
interface TranscodeSession {
|
interface TranscodeSession {
|
||||||
|
|
@ -238,7 +266,14 @@ class TranscodeServiceClass {
|
||||||
|
|
||||||
const playlistPath = path.join(s.outputDir, 'master.m3u8');
|
const playlistPath = path.join(s.outputDir, 'master.m3u8');
|
||||||
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
|
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
|
/** GET /api/catalog/burn/:partyId/:segment - `segment` is validated
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,8 @@ function inspect(res: Response): FakeRes {
|
||||||
return res as unknown as FakeRes;
|
return res as unknown as FakeRes;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeReq(headers: Record<string, string> = {}): Request {
|
function fakeReq(headers: Record<string, string> = {}, query: Record<string, string> = {}): Request {
|
||||||
return { headers } as unknown as Request;
|
return { headers, query } as unknown as Request;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('EmbyClient.fetch', () => {
|
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
|
// proxyResponse itself, never exercised for real before (every other test
|
||||||
// file that touches it mocks it away entirely, to isolate whatever calls
|
// file that touches it mocks it away entirely, to isolate whatever calls
|
||||||
// it) - specifically the caching path added after a real "the catalog is
|
// 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(got.headers['etag'], undefined);
|
||||||
assert.equal(await got.body, 'vtt content');
|
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 { EventEmitter } from 'node:events';
|
||||||
import { CatalogService } from '../src/services/CatalogService';
|
import { CatalogService } from '../src/services/CatalogService';
|
||||||
import { PartyService } from '../src/services/PartyService';
|
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';
|
import type { EmbySession } from '../src/types';
|
||||||
|
|
||||||
const FAKE_SESSION: EmbySession = {
|
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', () => {
|
describe('TranscodeService', () => {
|
||||||
it('starting a session spawns ffmpeg with relative stream specifiers, not Emby\'s absolute indices', async (t) => {
|
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 }));
|
t.mock.method(CatalogService, 'getMediaSource', async () => ({ id: 'src1', container: 'mkv', streams: FAKE_STREAMS }));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue