One shared Emby stream per party, not one per viewer
All checks were successful
Publier l'image Docker / test (push) Successful in 21s
Publier l'image Docker / publish (push) Successful in 20s

Asked for directly: exactly one stream should ever be open between this
app and Emby per party, no matter how many members it has. Before this,
every viewer's own <video>/<audio> independently negotiated its own
PlaybackInfo and got its own PlaySessionId - a party of 5 meant Emby
transcoding the same title 5 separate times.

A bigger, higher-regression-risk change than most of this session's
other work: it replaces the core playback path every existing party
already relies on (CatalogService.proxyStream and its route are gone
entirely) rather than adding something alongside it - there's no other
consumer of the per-viewer path, and the ask was explicit that there
should only ever be one mechanism.

- server/src/services/CatalogService.ts: proxyStream's negotiate-then-
  build-a-target logic extracted into negotiateStreamTarget(session,
  itemId, isAudio, audioStreamIndex, maxStreamingBitrate) - identical
  logic, returns the target instead of proxying it directly so it can
  be cached. proxyStream/proxyRawStreamAsset removed.
- server/src/services/PartyStreamService.ts (new): one negotiated
  target per party.id, reused by every member; renegotiates when the
  desired item/audio/quality changes. Recovers from a dead upstream
  session with no new plumbing - EmbyClient.proxyResponse already sets
  res.statusCode before writing the body, so a failed proxied response
  is detected right after it resolves and the cache entry is dropped,
  so the next request (a manual retry, or the next segment fetch)
  renegotiates fresh instead of repeating the same dead target forever.
- server/src/emby/EmbyClient.ts: rewritePlaylist/proxyResponse's
  trailing itemId param genericized to routeKey - it's only ever read
  back to build the raw-asset URL, so it's party.id for the new stream
  routes and an inert real itemId for proxyImage/proxySubtitle (which
  never reach that branch anyway).
- server/src/index.ts: GET /api/catalog/party-stream/:partyId/
  master.m3u8 and .../raw replace /api/catalog/stream/:itemId/...
  entirely, membership-checked like the burn-in routes; a new idle-reap
  interval for PartyStreamService, same shape as TranscodeService's own.
- Forced consequence of "one stream", not a free design choice: audio
  track/quality become a host-only, party-wide choice for video -
  Party.streamAudioIndex/streamQuality, setStreamOptions/
  streamOptionsChanged, byte-for-byte the same shape
  setBurnInSubtitle/burnInChanged already established for burn-in
  subtitles. server/src/realtime/SocketGateway.ts gains the host-only,
  video-only handler; server/src/services/PartyService.ts resets
  streamAudioIndex (not streamQuality - a bandwidth preference isn't
  item-specific) on advanceQueue, same reasoning as burnIn* already
  gets; MusicQueueService.playNext ends the party's cached session on
  a track change.
- client/src/api/embyApi.ts: streamUrl/audioStreamUrl replaced by one
  partyStreamUrl(partyId) - no query params needed any more, the party
  itself is the source of truth for what it's streaming.
- client/src/api/party.ts: setStreamOptions/onStreamOptionsChanged.
- client/src/views/player/Player.ts: audio/quality <select>s disabled
  for a follower (applyStreamOptionsGating, mirroring
  renderSubtitleOptions' own gating), restore the party's current
  choice on load/broadcast, and a host's change calls setStreamOptions
  instead of reloading locally - every member (host included) only
  ever reacts to the resulting broadcast, same discipline the rest of
  this player already uses. Dead reloadWithCurrentOptions removed.
- client/src/musicBar/MusicBar.ts: loadTrack points at
  partyStreamUrl(activePartyId) - no picker to gate, MusicBar never had
  one.
- server/test/catalogService.test.ts: proxyStream's old cases moved to
  exercise negotiateStreamTarget instead, plus new quality-override and
  no-source-fails cases. server/test/partyStreamService.test.ts (new):
  cache hit/miss/param-change/item-change, the failure-invalidation
  path, endSession, idle reap. server/test/socketGateway.test.ts:
  setStreamOptions host-only gating + broadcast, mirroring the existing
  setBurnInSubtitle tests.
- docs/architecture.md, README.md: design, the forced audio/quality
  trade-off spelled out, and what's still unverified (a different kind
  than this session's other Emby-API guesses - the calls themselves
  were already live code, what's untested is real multi-viewer load).

Verified: full typecheck+test+build on both projects (85/85 server
tests, 12 new). A real-browser check (mocked-fetch + real bundle, this
session's standing harness) confirmed a follower's audio/quality
selects render disabled and a host's render enabled, from the same
party data with only youAreHost flipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-14 20:05:34 +02:00
parent 11f003d269
commit f78b2f1408
20 changed files with 898 additions and 188 deletions

View file

@ -29,7 +29,9 @@ including why this replaced an earlier Emby-plugin attempt (still in
just hit "Épisode suivant" instead of restarting a party each time. If
the host leaves, the party hands off to another member automatically
(their player unlocks live, no reload needed) — only closing once
everyone's gone.
everyone's gone. Everyone also shares the exact same stream from
Emby — one transcode per party, not one per person — so the host's
language/quality pick applies for the whole party too.
5. Everyone present shows up as a small avatar overlay on the video (their
initials, on the player itself). If you're not the host, a small
toolbar lets you ask the host to pause or resume — everyone sees the
@ -37,8 +39,8 @@ including why this replaced an earlier Emby-plugin attempt (still in
6. Subtitles render natively in the browser for any text-based track. A
Blu-ray/DVD-style image subtitle (PGS/VobSub/DVB) is different — the
host can pick one to have it burned into the video by this app's own
ffmpeg, shared live by everyone in the party (audio/quality selection
is fixed while that's active, and it restarts the stream for
ffmpeg, shared live by everyone in the party (picking one restarts
the stream for
everyone, so it's a host-only choice, like play/pause already is).
7. Music works differently, on purpose: pick a track (browsing Artist →
Album → Track) to start a listening party. There's no host for
@ -104,7 +106,13 @@ thing to double-check here too. Same again for the Emby-playstate sync
(the exact `PlaystateService` endpoint/body shape) and the Playlists
endpoint - three Emby-API assumptions total now waiting on a real
server to actually confirm, all otherwise built and unit-tested the
same way.
same way. The one-shared-stream-per-party work is a different kind of
unverified: its own Emby API calls were already live, confirmed code,
just newly shared instead of per-viewer - what's untested is behavior
under real multi-viewer load (whether a dead Emby session actually
surfaces as an HTTP failure the way the cache-invalidation logic
assumes, how a real transcode's seek handling feels once several
viewers depend on the exact same one).
## Project layout
@ -221,7 +229,9 @@ Then put it behind your existing reverse proxy like everything else
- [x] Design + scaffold the standalone app
- [x] Proxy all Emby access server-side; Emby-themed UI (sidebar, carousels)
- [x] TV show navigation (Series → Seasons → Episodes) + episode auto-queue
- [x] Player language/subtitle/quality pickers
- [x] Player language/subtitle/quality pickers (language/quality are now
host-only, party-wide choices — see docs/architecture.md's "One
shared Emby stream per party")
- [x] Socket.IO instead of raw WebSockets; fixed two real memory leaks
(dead-connection cleanup server-side, view teardown on navigation
client-side) — see docs/architecture.md
@ -251,6 +261,8 @@ Then put it behind your existing reverse proxy like everything else
queue or the participative music queue) — see docs/architecture.md
- [x] Sync watch-party progress back to Emby's own PlaystateService, per
member's own account — see docs/architecture.md
- [x] One shared Emby stream per party instead of one per viewer — see
docs/architecture.md
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
- [ ] Tune transcoding target / support direct play when possible

View file

@ -116,21 +116,19 @@ export function getPlaybackOptions(itemId: string): Promise<PlaybackOptionsDto>
return apiFetch(`/api/catalog/item/${itemId}/playback-options`);
}
/** `options` may carry AudioStreamIndex/MaxStreamingBitrate/mediaType - not
* subtitles, which are their own separate request now (see subtitleUrl
* below and Player.ts's applySubtitleTrack for why). */
export function streamUrl(itemId: string, options: Record<string, string | number | null | undefined> = {}): string {
const entries = Object.entries(options).filter(([, v]) => v !== '' && v != null) as [string, string][];
const qs = new URLSearchParams(entries).toString();
return `/api/catalog/stream/${itemId}/master.m3u8${qs ? '?' + qs : ''}`;
}
/** Same stream, tagged for a music track - see server/src/services/
* CatalogService.ts's proxyStream for why mediaType is the one thing
* that matters here (its hand-built-fallback path is genuinely different
* for audio). */
export function audioStreamUrl(itemId: string): string {
return streamUrl(itemId, { mediaType: 'audio' });
/** The party's own shared Emby stream (server/src/services/
* PartyStreamService.ts) - one negotiated PlaybackInfo session reused
* by every member, instead of each viewer's own <video>/<audio>
* negotiating independently the way this app used to work. Keyed by
* partyId, not itemId: the party itself carries what it's currently
* streaming and with which audio/quality choice (Party.streamAudioIndex/
* streamQuality for video - see Player.ts's subtitle-picker-shaped
* audio/quality selects; music has no such picker at all, see
* MusicBar.ts) - no query params needed here any more, unlike this
* function's own predecessor (streamUrl/audioStreamUrl), since there's
* no longer a per-viewer choice to encode into the URL. */
export function partyStreamUrl(partyId: string): string {
return `/api/catalog/party-stream/${partyId}/master.m3u8`;
}
/** A subtitle track's own text, as WebVTT (server/src/services/

View file

@ -8,7 +8,7 @@
// No join codes: joining happens straight from the home page's live list
// (connectLobby below) by party id - see docs/architecture.md.
import { getDeviceId } from '../deviceId';
import type { BurnInChangedMsg, ItemChangedMsg, LobbyPartyDto, MusicPublicState, MusicTrackInput, PartyPublicState, PauseRequestedMsg, ResumeRequestedMsg, SocketMember } from '../types';
import type { BurnInChangedMsg, ItemChangedMsg, LobbyPartyDto, MusicPublicState, MusicTrackInput, PartyPublicState, PauseRequestedMsg, ResumeRequestedMsg, SocketMember, StreamOptionsChangedMsg } from '../types';
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(path, {
@ -88,6 +88,9 @@ export interface PartyConnectionCallbacks {
/** The host picked (or cleared) the party's shared burn-in stream - see
* setBurnInSubtitle below and Player.ts. */
onBurnInChanged?: (msg: BurnInChangedMsg) => void;
/** The host changed the party's shared stream's audio track/quality -
* see setStreamOptions below and Player.ts. */
onStreamOptionsChanged?: (msg: StreamOptionsChangedMsg) => void;
onError?: (message: string) => void;
onClose?: () => void;
}
@ -117,6 +120,12 @@ export interface PartyConnection {
* subtitle - that stays the existing per-viewer client-side <track>,
* no socket call at all. */
setBurnInSubtitle(subtitleIndex: string | null, audioIndex?: string): void;
/** Host-only, video-only: picks the audio track/quality of the party's
* shared normal stream (PartyStreamService) - there's only ever one
* stream now, so this is necessarily party-wide, not each viewer's
* own local pick the way it was before streams were shared. Either
* argument null/omitted means "Emby's own default". */
setStreamOptions(audioStreamIndex?: string | null, quality?: string | null): void;
leave(): void;
}
@ -127,7 +136,7 @@ export interface PartyConnection {
* alive via closures, never gets released.
*/
export function connectParty(partyId: string, callbacks: PartyConnectionCallbacks = {}): PartyConnection {
const { onMembers, onPlaystate, onItemChanged, onHostChanged, onPauseRequested, onResumeRequested, onQueueUpdated, onDeviceConflict, onBurnInChanged, onError, onClose } = callbacks;
const { onMembers, onPlaystate, onItemChanged, onHostChanged, onPauseRequested, onResumeRequested, onQueueUpdated, onDeviceConflict, onBurnInChanged, onStreamOptionsChanged, onError, onClose } = callbacks;
const socket = io();
const deviceId = getDeviceId();
@ -150,6 +159,7 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback
if (onQueueUpdated) socket.on('queueUpdated', (m) => onQueueUpdated(m as MusicPublicState)); // music parties only - see musicBar/MusicBar.ts
if (onDeviceConflict) socket.on('deviceConflict', () => onDeviceConflict());
if (onBurnInChanged) socket.on('burnInChanged', (m) => onBurnInChanged(m as BurnInChangedMsg));
if (onStreamOptionsChanged) socket.on('streamOptionsChanged', (m) => onStreamOptionsChanged(m as StreamOptionsChangedMsg));
if (onError) socket.on('errorMessage', (m) => onError((m as { message: string }).message));
if (onClose) socket.on('disconnect', () => onClose());
@ -178,6 +188,9 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback
setBurnInSubtitle(subtitleIndex, audioIndex) {
socket.emit('setBurnInSubtitle', { subtitleIndex, audioIndex });
},
setStreamOptions(audioStreamIndex, quality) {
socket.emit('setStreamOptions', { audioStreamIndex, quality });
},
leave() {
socket.emit('leave');
socket.disconnect();

View file

@ -2,7 +2,7 @@ import '../styles/presence.css';
import '../styles/trackList.css';
import './MusicBar.css';
import html from './MusicBar.html';
import { audioStreamUrl } from '../api/embyApi';
import { partyStreamUrl } from '../api/embyApi';
import { connectParty, leaveParty } from '../api/party';
import type { PartyConnection } from '../api/party';
import { colorForName, escapeHtml, formatDuration, initialsFor } from '../tiles';
@ -172,6 +172,12 @@ export function leaveMusicParty(): void {
// Emby's PlaybackInfo negotiation isn't video-specific, so a music
// track's TranscodingUrl just as plausibly comes back HLS-shaped, and
// most browsers besides Safari have no native .m3u8 support at all.
// Party-shared now too (PartyStreamService), same as the video player -
// every listener points at the same partyStreamUrl(activePartyId), one
// negotiated Emby session for the whole listen party instead of each
// listener's own independent one. There's no audio-track/quality picker
// here to worry about (never existed for music), so unlike the video
// player this needed no new UI at all.
function loadTrack(track: MusicCurrentPublic, elapsedSeconds: number, isPaused: boolean): void {
const audio = barEl!.querySelector('#barAudio') as HTMLAudioElement;
const trackImage = barEl!.querySelector('#trackImage') as HTMLImageElement;
@ -187,7 +193,7 @@ function loadTrack(track: MusicCurrentPublic, elapsedSeconds: number, isPaused:
if (hls) { hls.destroy(); hls = null; }
const src = audioStreamUrl(track.itemId);
const src = partyStreamUrl(activePartyId!); // loadTrack is only ever called while a party is active - see applyMusicState/joinMusicParty
const resume = () => {
suppressLocalEvents = true;
audio.currentTime = elapsedSeconds || 0;

View file

@ -111,10 +111,19 @@ export interface PartyPublicState {
music: MusicPublicState | null;
// Video only - the host's current pick for the party's shared burn-in
// stream (see server/src/services/TranscodeService.ts), null when the
// party is just using the normal per-viewer Emby-proxied stream. Both
// null or both set together - see SocketGateway's own comment.
// party is just using its normal party-shared Emby stream instead
// (PartyStreamService - also shared, just proxied rather than
// transcoded by us). Both null or both set together - see
// SocketGateway's own comment.
burnInSubtitleIndex: string | null;
burnInAudioIndex: string | null;
// Video only - the host's current pick for the party's normal shared
// stream (PartyStreamService) - null means "Emby's own default".
// There's only ever one stream now, so this is party-wide, host-set
// state, not a per-viewer local choice the way it was before streams
// were shared - see SocketGateway's "setStreamOptions" handler.
streamAudioIndex: string | null;
streamQuality: string | null;
}
/** The socket "members" broadcast's shape - smaller than
@ -169,8 +178,17 @@ export interface ResumeRequestedMsg {
/** "burnInChanged" - see api/party.ts's setBurnInSubtitle/onBurnInChanged
* and PartyPublicState.burnInSubtitleIndex above (this is the live-push
* half; that's the on-load/rejoin half). Both null together clears the
* party's burn-in stream back to the normal per-viewer one. */
* party's burn-in stream back to its normal party-shared one instead. */
export interface BurnInChangedMsg {
subtitleIndex: string | null;
audioIndex: string | null;
}
/** "streamOptionsChanged" - see api/party.ts's setStreamOptions/
* onStreamOptionsChanged and PartyPublicState.streamAudioIndex/
* streamQuality above (this is the live-push half; that's the
* on-load/rejoin half). */
export interface StreamOptionsChangedMsg {
audioStreamIndex: string | null;
quality: string | null;
}

View file

@ -1,13 +1,13 @@
import '../../styles/presence.css';
import './Player.css';
import html from './Player.html';
import { auth, burnInStreamUrl, getPlaybackOptions, streamUrl, subtitleUrl } from '../../api/embyApi';
import { auth, burnInStreamUrl, getPlaybackOptions, partyStreamUrl, subtitleUrl } from '../../api/embyApi';
import { getParty, leaveParty, connectParty } from '../../api/party';
import type { PartyConnection } from '../../api/party';
import { navigate } from '../../router';
import type { CleanupFn, RouteParams } from '../../router';
import { colorForName, escapeHtml, initialsFor } from '../../tiles';
import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequestedMsg, PlaybackOptionsDto, ResumeRequestedMsg, SocketMember } from '../../types';
import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequestedMsg, PlaybackOptionsDto, ResumeRequestedMsg, SocketMember, StreamOptionsChangedMsg } from '../../types';
import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar';
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
@ -17,12 +17,13 @@ const REQUEST_HIGHLIGHT_MS = 4000; // how long the banner + the orange badge sta
// hls.js's own defaults (10s manifest/level/frag load timeouts) assume a
// file server or a source already being direct-played/remuxed - too tight
// for this app, where every load/reload (audio track, quality, the
// for this app, where every load/reload (an audio/quality change, the
// initial load itself) chains through our own proxy into a FRESH Emby
// transcode (see CatalogService.proxyStream). Confirmed for real: a
// forced re-encode (this used to be how subtitles worked too, before they
// moved to a native <track> - see proxyStream's own comment for why)
// measurably took Emby longer than 10s to produce a first segment for,
// transcode (see CatalogService.negotiateStreamTarget/PartyStreamService).
// Confirmed for real: a forced re-encode (this used to be how subtitles
// worked too, before they moved to a native <track> - see
// negotiateStreamTarget's own comment for why) measurably took Emby
// longer than 10s to produce a first segment for,
// and hls.js gave up with a fatal fragLoadTimeOut after its default 4
// retries - not a hang, a real but slow transcode start being mistaken
// for a dead server. Same config duplicated in musicBar/MusicBar.ts (its
@ -181,6 +182,14 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
// up immediately, not just from a live "burnInChanged" broadcast.
let burnInSubtitleIndex: string | null = party.burnInSubtitleIndex;
let burnInAudioIndex: string | null = party.burnInAudioIndex;
// The party's shared normal stream's audio/quality choice (see
// PartyStreamService) - there's only one stream now, not one per
// viewer, so - like burnIn* above - this is party-wide state read from
// the party itself on load, not each viewer's own local pick any more.
// null means "Emby's own default", same as an untouched picker meant
// before streams were shared.
let streamAudioIndex: string | null = party.streamAudioIndex;
let streamQuality: string | null = party.streamQuality;
// Cached purely so becomeHost (host status can change live mid-session
// - see its own comment) can re-render the subtitle picker's host-only
// gating for burn-in options without a redundant playback-options fetch.
@ -242,7 +251,10 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
// Burn-in (image) subtitle options are host-only (see
// renderSubtitleOptions) - becoming host mid-session must unlock
// them immediately, not just on this player's next full re-render.
// Same reasoning for the audio/quality selects themselves now that
// there's only one shared stream to control, not each viewer's own.
renderSubtitleOptions();
applyStreamOptionsGating();
if (notify) alert("L'hôte précédent a quitté la party - tu es maintenant l'hôte.");
}
@ -254,7 +266,16 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
audioSelect.innerHTML = options.audioTracks.length
? options.audioTracks.map((t) => `<option value="${t.index}">${escapeHtml(t.label)}</option>`).join('')
: '<option value="">Piste par défaut</option>';
// Restores the party's *current* audio/quality choice - only
// meaningful for audio now that its options actually exist to
// match against; quality's own options are static (QUALITY_PRESETS,
// set once at the top of renderVideoPlayer) so this is really only
// needed here for symmetry/simplicity, not because it couldn't be
// set any earlier.
audioSelect.value = streamAudioIndex || '';
qualitySelect.value = streamQuality || '';
renderSubtitleOptions();
applyStreamOptionsGating();
} catch (err) {
console.error('[watchparty player] playback-options failed', err);
// Non-fatal: playback can still work with Emby's own defaults, just without a picker.
@ -300,6 +321,24 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
burnInHint.classList.toggle('hide', !burnInSubtitleIndex);
}
/**
* Audio track/quality are host-only now too, for the same underlying
* reason burn-in options are: there's only one shared stream per party
* (PartyStreamService), not each viewer's own independent copy any
* more, so there's only room for one set of encoding parameters behind
* it - see docs/architecture.md's "One shared Emby stream per party"
* section. Also disabled while a burn-in stream is active (unchanged
* from before - that's ffmpeg's own fixed encode, and neither pick
* applies to it either). Called on every load (loadVideo already runs
* on every relevant transition) and from becomeHost directly, since
* becoming host doesn't otherwise trigger a reload on its own.
*/
function applyStreamOptionsGating() {
const disabled = !!burnInSubtitleIndex || !isHost;
audioSelect.disabled = disabled;
qualitySelect.disabled = disabled;
}
/**
* Adds/replaces the video's own <track> element for whichever subtitle
* (if any) is currently selected - a native browser feature, entirely
@ -344,28 +383,26 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
// A burn-in stream already has its subtitle composited into the
// video itself - a real <track> on top would be redundant at best.
// Re-applied on every (re)load otherwise, not just when the subtitle
// picker itself changes: an audio/quality change reloads this same
// item through here too (see reloadWithCurrentOptions), and the
// subtitle that was showing should survive that - subtitleSelect.value
// is untouched in that case, so this just re-adds the same track. A
// genuinely new item (onItemChanged/init) already reset the select
// to "Aucun" before this runs (see loadTrackOptions), so this
// correctly clears any leftover track from whatever played before.
// picker itself changes: an audio/quality change now reloads through
// here too (via onStreamOptionsChanged, once the broadcast comes
// back - see that handler and the setStreamOptions change listener
// below), and the subtitle that was showing should survive that -
// subtitleSelect.value is untouched in that case, so this just
// re-adds the same track. A genuinely new item (onItemChanged/init)
// already reset the select to "Aucun" before this runs (see
// loadTrackOptions), so this correctly clears any leftover track
// from whatever played before.
const usingBurnIn = !!burnInSubtitleIndex;
applySubtitleTrack(usingBurnIn ? '' : subtitleSelect.value);
// The burn-in stream is one fixed ffmpeg encode for the whole party -
// neither of these picks apply to it (see renderSubtitleOptions'
// burnInHint). Re-enabled automatically once burn-in is cleared,
// since this runs on every load/reload.
audioSelect.disabled = usingBurnIn;
qualitySelect.disabled = usingBurnIn;
applyStreamOptionsGating();
const src = usingBurnIn
? burnInStreamUrl(partyId)
: streamUrl(itemId, {
AudioStreamIndex: audioSelect.value,
MaxStreamingBitrate: qualitySelect.value
});
// Both branches are already party-shared, one way or the other -
// burnInStreamUrl's own ffmpeg, or PartyStreamService's cached Emby
// negotiation - so neither needs any audio/quality query params any
// more; the party itself (streamAudioIndex/streamQuality, set via
// setStreamOptions below) is what the server reads instead. See
// docs/architecture.md's "One shared Emby stream per party" section.
const src = usingBurnIn ? burnInStreamUrl(partyId) : partyStreamUrl(partyId);
const resume = () => {
loadingNotice.classList.add('hide');
@ -398,18 +435,19 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
}
}
// Changing language/quality means a fresh transcode (Emby bakes the
// selection in server-side - see CatalogService) - reload at the same
// position instead of restarting from zero. Subtitles are NOT part of
// this - see the subtitleSelect listener below, and applySubtitleTrack.
function reloadWithCurrentOptions() {
if (currentItemId) loadVideo(currentItemId, video.currentTime, !video.paused);
}
// Changing audio/quality picks the party's *shared* stream's encoding
// parameters now (PartyStreamService) - host-only (the selects are
// disabled for a follower to begin with, so this listener only
// realistically ever fires for the host - same defensive shape as the
// subtitleSelect listener below), and does NOT reload here directly:
// every member (host included) only ever reacts to the resulting
// "streamOptionsChanged" broadcast, see onStreamOptionsChanged below -
// same discipline as onItemChanged/applyRemoteState never applying
// anything optimistically before the server's own broadcast confirms it.
[audioSelect, qualitySelect].forEach((sel) => {
sel.addEventListener('change', () => {
settingsPanel.classList.add('hide');
reloadWithCurrentOptions();
conn?.setStreamOptions(audioSelect.value || null, qualitySelect.value || null);
});
});
@ -446,9 +484,13 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
// A new item's own stream indices have nothing to do with the old
// one's - PartyService.advanceQueue already resets this server-side
// too (see its own comment); this just keeps this client in sync
// without waiting on a separate "burnInChanged" for the same event.
// without waiting on a separate broadcast for the same event.
// streamQuality is deliberately left alone - a bandwidth/quality
// preference isn't tied to any one item, same reasoning as
// PartyService.advanceQueue's own comment.
burnInSubtitleIndex = null;
burnInAudioIndex = null;
streamAudioIndex = null;
await loadTrackOptions(msg.itemId);
loadVideo(msg.itemId, 0, true);
}
@ -468,6 +510,22 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
if (currentItemId) loadVideo(currentItemId, video.currentTime, !video.paused);
}
/** The host changed the party's shared normal stream's audio track/
* quality - see the audioSelect/qualitySelect change listener above
* for why every member (host included) only ever reacts to this
* broadcast. Reloads for every member - there's only one shared
* stream now, so there's no cheaper per-viewer path any more. A fresh
* page load/rejoin instead reads the party's already-current
* streamAudioIndex/streamQuality directly - see the outer variable
* declarations above. */
function onStreamOptionsChanged(msg: StreamOptionsChangedMsg) {
streamAudioIndex = msg.audioStreamIndex;
streamQuality = msg.quality;
audioSelect.value = streamAudioIndex || '';
qualitySelect.value = streamQuality || '';
if (currentItemId) loadVideo(currentItemId, video.currentTime, !video.paused);
}
// Presence overlay: one semi-transparent circle per member, initials
// only (no photos - Emby accounts don't reliably have one). Purely
// presence now - asking the host for something is the requestToolbar's
@ -558,6 +616,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
onPauseRequested,
onResumeRequested,
onBurnInChanged,
onStreamOptionsChanged,
onDeviceConflict: () => {
// This socket is still connected but inert (see SocketGateway's
// "join" handler / api/party.ts) - a confirm() is fine here even

View file

@ -1977,6 +1977,115 @@ GET, still every other call site, never grows a stray `Content-Type`).
No client changes at all - this is a pure server-side background
process with nothing for the browser to render or react to.
## One shared Emby stream per party (not one per viewer)
Asked for directly: exactly one stream should ever be open between this
app and Emby per party, no matter how many members it has - before
this, every viewer's own `<video>`/`<audio>` independently negotiated
its own `PlaybackInfo` and got its own `PlaySessionId`, so a party of 5
meant Emby transcoding the same title 5 separate times. This is a
bigger, higher-regression-risk change than most of this session's other
work: it replaces the core playback path every existing party already
relies on (`CatalogService.proxyStream` and its route are gone
entirely) rather than adding something new alongside it - there was no
other consumer of the per-viewer path to preserve for, and the ask was
explicit that there should only ever be one mechanism, not two.
**A new cache, not new state on `CatalogService`** (that class is
deliberately stateless - its own comment says so): `server/src/services/
PartyStreamService.ts` owns `Map<partyId, {itemId, audioStreamIndex,
maxStreamingBitrate, targetPath, lastAccessUtc}>` - the same shape as
`TranscodeService`'s own session map, for the same reason (one entry
per party, replaced when the desired parameters change, reaped when
idle). `getOrNegotiate(party, session)` reads what the party *currently*
wants to stream (video: `party.itemId`; music: `party.music.current`'s
track - there's no host to read a position from the way video does,
but there's also only ever one shared position/track for the whole
party already, see "Music" above) and either reuses the cached target
or renegotiates.
**The negotiation itself was extracted, not duplicated**:
`CatalogService.proxyStream`'s old body (PlaybackInfo, then Emby's own
`TranscodingUrl` or a hand-built fallback, with the audio/quality
override applied twice - once to the negotiation, once to whatever URL
results, so a pick can't silently no-op regardless of which path Emby's
response takes) is now `negotiateStreamTarget(session, itemId, isAudio,
audioStreamIndex, maxStreamingBitrate): Promise<string>` - identical
logic, just returning the target instead of proxying it directly, so
`PartyStreamService` can cache what comes back.
**Recovering from a dead upstream session** needed no new plumbing:
Emby's own transcode isn't guaranteed to live forever, and a cached
target could start failing if Emby ends it on its own side.
`EmbyClient.proxyResponse` already calls `res.status(...)` before
writing the body, so `res.statusCode` is readable right after it
resolves - `PartyStreamService` checks that and drops the cache entry
on any failure, so the *next* request (a manual retry via the player's
own "Réessayer" button, or simply the next segment fetch) renegotiates
fresh instead of repeating the same dead target forever. This is the
same recovery a single failed per-viewer session already relied on
before - just applied once per party instead of once per viewer.
**Routes**, keyed by `partyId` (membership-checked, same pattern as the
burn-in routes above): `GET /api/catalog/party-stream/:partyId/
master.m3u8` and `.../raw?u=...` replace `/api/catalog/stream/:itemId/
...` entirely. `EmbyClient.rewritePlaylist`/`proxyResponse`'s trailing
param (`itemId` before this) is genericized to `routeKey` - it's only
ever read back out to build that second URL, so it's a real itemId for
`proxyImage`/`proxySubtitle` (which never actually reach the
playlist-rewrite branch anyway - wrong content-type/extension, so this
was always inert for them) and `party.id` for the new stream routes.
**Forced consequence, not a free design choice**: with only one stream,
there's only room for one set of encoding parameters behind it - a
video party's audio track and quality became a **host-only, party-wide**
choice (`Party.streamAudioIndex`/`streamQuality`, `setStreamOptions`/
`streamOptionsChanged`), byte-for-byte the same shape
`setBurnInSubtitle`/`burnInChanged` already established for the
image-subtitle case. `Player.ts`'s audio/quality `<select>`s are
disabled for a follower (`applyStreamOptionsGating`, mirroring
`renderSubtitleOptions`'s own gating) and restore the party's *current*
choice on load or broadcast instead of defaulting to the first option;
a host's change calls `setStreamOptions` instead of reloading locally,
and - like every other party-wide change this app makes - every member
(host included) only ever reacts to the resulting broadcast, never
applies a pick optimistically. `streamAudioIndex` resets on an item
change (an audio *track* index is exactly as item-specific as a
burn-in subtitle index is); `streamQuality` deliberately doesn't - a
bandwidth preference isn't tied to any one item, and wasn't reset
across an item change even before streams were shared either
(`qualitySelect`'s options were never rebuilt per-item).
**Music needed no picker at all** - `MusicBar.ts` never had an
audio-track/quality `<select>` in the first place, so sharing its
stream (`partyStreamUrl(activePartyId)`, same function the video player
uses) was the caching mechanism alone, no permission model to design.
A track change (`MusicQueueService.playNext`) ends the party's cached
session the same way `PartyService.advanceQueue` already does for
video - whatever was negotiated was for the outgoing track.
Verified: full typecheck/test/build on both projects. Unit tests cover
`negotiateStreamTarget` (moved from `proxyStream`'s old coverage) and
`PartyStreamService`'s cache hit/miss/param-change/item-change behavior,
including the failure-invalidation path, plus `setStreamOptions`'s
host-only gating and broadcast (mirroring the existing `setBurnInSubtitle`
tests). A real-browser check (mocked-fetch + real bundle, this
session's standing harness) confirmed a follower's audio/quality
selects render disabled and a host's render enabled, from the same
party data with only `youAreHost` flipped.
**TODO(verify)**: this is a different kind of unverified than this
session's other Emby-API guesses (the ffmpeg direct-stream URL, the
Playlists endpoint, the playstate-sync endpoint) - the actual endpoint
shape here (`PlaybackInfo`, `TranscodingUrl`) was already live,
confirmed code, just newly shared instead of per-viewer. What's
genuinely unverified is behavior under real multi-viewer load: whether
a real dead Emby session actually surfaces as an HTTP failure the way
`invalidateOnFailure` assumes, and how a real transcode's own seek
handling behaves once several viewers depend on the exact same one
instead of each having their own. Worth trying with two real accounts,
same standing item already on this project's own roadmap.
## Known limitations
- Playback always goes through `hls.js` against whatever Emby's
@ -2013,3 +2122,11 @@ process with nothing for the browser to render or react to.
position, not their own exact one (there's no per-follower tracking
today) - a reasonable approximation given the player's own drift
correction, not a precise per-viewer number.
- Now that every party shares one Emby stream (see "One shared Emby
stream per party" above), a rough patch in Emby's own transcode/seek
handling is felt by the whole party at once instead of just the one
viewer who caused it - not a new failure mode (Emby's own engine is
unchanged), just a wider blast radius for whatever it already was.
Audio track/quality changing from a per-viewer to a host-only,
party-wide choice is the explicit, intended trade-off for one shared
stream, not a limitation to fix - see that section for why.

View file

@ -9,7 +9,7 @@
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"typecheck": "tsc -p tsconfig.test.json",
"test": "tsx --test test/catalogService.test.ts test/embyClient.test.ts test/musicQueueService.test.ts test/partyService.test.ts test/playstateService.test.ts test/socketGateway.test.ts test/transcodeService.test.ts"
"test": "tsx --test test/catalogService.test.ts test/embyClient.test.ts test/musicQueueService.test.ts test/partyService.test.ts test/partyStreamService.test.ts test/playstateService.test.ts test/socketGateway.test.ts test/transcodeService.test.ts"
},
"engines": {
"node": ">=18"

View file

@ -71,21 +71,21 @@ export class EmbyClient {
/**
* Rewrites every URI line in an HLS playlist so it routes back through
* our own proxy (`/api/catalog/stream/:itemId/raw?u=...`) instead of
* Emby directly - whether Emby wrote that line as a relative or an
* absolute URL. Without this, hls.js would follow the playlist's own
* URLs straight to Emby, defeating the point of 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.
* our own proxy (`/api/catalog/party-stream/:routeKey/raw?u=...`)
* instead of Emby directly - whether Emby wrote that line as a
* relative or an absolute URL. Without this, hls.js would follow the
* playlist's own URLs straight to Emby, defeating the point of
* 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.
*/
static rewritePlaylist(text: string, itemId: string): string {
static rewritePlaylist(text: string, routeKey: string): string {
return text
.split('\n')
.map((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return line;
return `/api/catalog/stream/${itemId}/raw?u=${encodeURIComponent(trimmed)}`;
return `/api/catalog/party-stream/${routeKey}/raw?u=${encodeURIComponent(trimmed)}`;
})
.join('\n');
}
@ -95,9 +95,15 @@ export class EmbyClient {
* content-type, and the headers that make byte-range seeking work
* (content-range/accept-ranges), rewriting HLS playlists in transit and
* streaming everything else (images, video/audio segments) through
* untouched - no buffering, so this doesn't hold a whole segment in memory.
* untouched - no buffering, so this doesn't hold a whole segment in
* memory. `routeKey` is only ever read back out by `rewritePlaylist`
* above - a real itemId for `proxyImage`/`proxySubtitle` (which never
* actually reach that branch - wrong content-type/extension - so it's
* inert for them either way) and `party.id` for `PartyStreamService`,
* whose whole point is routing every member back to the *same* shared
* stream regardless of which item they think they're watching.
*/
static async proxyResponse(req: Request, res: Response, pathAndQuery: string, token: string, itemId: string): Promise<void> {
static async proxyResponse(req: Request, res: Response, pathAndQuery: string, token: string, routeKey: string): Promise<void> {
let embyRes: globalThis.Response;
try {
embyRes = await EmbyClient.fetch(pathAndQuery, token, { range: req.headers.range });
@ -116,7 +122,7 @@ export class EmbyClient {
if (EmbyClient.isPlaylistPath(pathAndQuery) || contentType.includes('mpegurl')) {
const text = await embyRes.text();
res.send(EmbyClient.rewritePlaylist(text, itemId));
res.send(EmbyClient.rewritePlaylist(text, routeKey));
return;
}

View file

@ -12,6 +12,7 @@ import { CatalogService, CatalogFetchError, splitPlaylistByMediaType } from './s
import { PartyService } from './services/PartyService';
import { MusicQueueService } from './services/MusicQueueService';
import { TranscodeService } from './services/TranscodeService';
import { PartyStreamService } from './services/PartyStreamService';
import { PlaystateService } from './services/PlaystateService';
import { SocketGateway } from './realtime/SocketGateway';
import type { ClientToServerEvents, EmbySession, MediaType, QueueItem, ServerToClientEvents } from './types';
@ -176,18 +177,39 @@ app.get('/api/catalog/image/:itemId', asyncRoute(async (req, res) => {
await CatalogService.proxyImage(req, res, session, param(req, 'itemId'));
}));
app.get('/api/catalog/stream/:itemId/master.m3u8', asyncRoute(async (req, res) => {
// The party's own shared Emby stream - one negotiated PlaybackInfo
// session reused by every member, instead of each viewer's own
// <video>/<audio> negotiating (and getting its own PlaySessionId)
// independently - see PartyStreamService and docs/architecture.md's
// "One shared Emby stream per party" section. Keyed by partyId, not
// itemId - the party object itself is the source of truth for what
// it's currently streaming and with which audio/quality choice, so
// there's nothing else a client could usefully put in this URL.
// requireSession's JSON error doesn't fit here either, same reasoning
// as the routes above (a <video>/<audio> element can't carry a bearer
// header).
app.get('/api/catalog/party-stream/:partyId/master.m3u8', asyncRoute(async (req, res) => {
const session = AuthService.sessionFromRequest(req);
if (!session) { res.status(401).end(); return; }
await CatalogService.proxyStream(req, res, session, param(req, 'itemId'));
const party = PartyService.getById(param(req, 'partyId'));
if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return;
}
await PartyStreamService.serveMasterPlaylist(req, res, party, session);
}));
// Everything an HLS playlist references (variant playlists, segments)
// comes back through here - see EmbyClient.rewritePlaylist.
app.get('/api/catalog/stream/:itemId/raw', asyncRoute(async (req, res) => {
app.get('/api/catalog/party-stream/:partyId/raw', asyncRoute(async (req, res) => {
const session = AuthService.sessionFromRequest(req);
if (!session) { res.status(401).end(); return; }
await CatalogService.proxyRawStreamAsset(req, res, session, param(req, 'itemId'));
const party = PartyService.getById(param(req, 'partyId'));
if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return;
}
await PartyStreamService.serveRawAsset(req, res, party, session);
}));
// A subtitle track's own text (WebVTT), for the player's native <track> -
@ -201,12 +223,13 @@ app.get('/api/catalog/subtitle/:itemId/:subtitleIndex', asyncRoute(async (req, r
// The party's own self-hosted-ffmpeg burn-in stream (PGS/image subtitles
// only - see TranscodeService and Party.burnInSubtitleIndex's own
// comment); every other party keeps streaming through
// /api/catalog/stream above, completely untouched. Keyed by partyId, not
// itemId - the party object itself is the source of truth for which
// audio/subtitle combination is currently burned in, so there's nothing
// else a client could usefully put in this URL. requireSession's JSON
// error doesn't fit here either, same reasoning as the routes above.
// comment); every other party keeps streaming through the party-stream
// routes above instead - PartyStreamService, not our own ffmpeg. Keyed
// by partyId, not itemId, same reasoning as those routes: the party
// object itself is the source of truth for which audio/subtitle
// combination is currently burned in, so there's nothing else a client
// could usefully put in this URL. requireSession's JSON error doesn't
// fit here either, same reasoning as the routes above.
app.get('/api/catalog/burn/:partyId/master.m3u8', asyncRoute(async (req, res) => {
const session = AuthService.sessionFromRequest(req);
if (!session) { res.status(401).end(); return; }
@ -473,6 +496,12 @@ setInterval(() => PartyService.removeIdleParties(4 * 60 * 60 * 1000), 10 * 60 *
// abandoned one is worth reclaiming sooner. Mostly a backstop - the
// normal path is PartyService.leave's destroy branch ending it immediately.
setInterval(() => TranscodeService.reapIdleSessions(30 * 60 * 1000), 5 * 60 * 1000);
// Same reasoning as TranscodeService's own reaper just above - a cached
// negotiation is mostly harmless to keep around, but there's no reason
// to hold one for a party nobody's actively streaming from any more.
// Mostly a backstop too - the normal path is PartyService.leave's
// destroy branch (and an item/track change) ending it immediately.
setInterval(() => PartyStreamService.reapIdleSessions(30 * 60 * 1000), 5 * 60 * 1000);
// Pushes every connected member's own progress to Emby, under their own
// account - see docs/architecture.md's "Sync watch-party progress back

View file

@ -42,6 +42,12 @@ import type { AppServer, LeaveResult, LobbyPartyDto, Party, SocketMemberDto } fr
* TranscodeService. A text subtitle never goes through here - that
* stays the existing per-viewer client-side <track>.)
* "burnInChanged" (server -> client)
* "setStreamOptions" {audioStreamIndex?, quality?} (host only - picks
* the audio track/quality of the party's *shared* normal Emby stream -
* see PartyStreamService. There's only ever one stream per party now
* (not one per viewer), so this is necessarily party-wide, not each
* viewer's own local pick the way it was before streams were shared.)
* "streamOptionsChanged" (server -> client)
*
* -- mediaType: 'music' parties (see MusicQueueService for the model) --
@ -212,6 +218,23 @@ class SocketGatewayClass {
io.to(party.id).emit('burnInChanged', { subtitleIndex: party.burnInSubtitleIndex, audioIndex: party.burnInAudioIndex });
}));
// Host-only, video-only: picks the audio track/quality of the
// party's *shared* normal Emby stream - see PartyStreamService.
// Deliberately doesn't touch PartyStreamService directly here -
// same reasoning as "setBurnInSubtitle" above not touching
// TranscodeService: this only updates party state and broadcasts
// it; PartyStreamService.getOrNegotiate already compares against
// the party's *current* values on every request, so the next one
// naturally renegotiates once these differ from whatever's cached.
socket.on('setStreamOptions', this.safe((msg: { audioStreamIndex?: string | null; quality?: string | null }) => {
if (!party || party.mediaType !== 'video' || userId !== party.hostUserId) return;
party.streamAudioIndex = msg?.audioStreamIndex || null;
party.streamQuality = msg?.quality || null;
party.lastActivityUtc = Date.now();
Logger.party(`streamOptionsChanged partyId=${party.id} audioStreamIndex=${party.streamAudioIndex ?? '(default)'} quality=${party.streamQuality ?? '(auto)'}`);
io.to(party.id).emit('streamOptionsChanged', { audioStreamIndex: party.streamAudioIndex, quality: party.streamQuality });
}));
// Majority of currently CONNECTED members, not everyone who's ever
// joined - see MusicQueueService.
socket.on('voteSkip', this.safe(() => {

View file

@ -269,7 +269,8 @@ class CatalogServiceClass {
}
/**
* Ask Emby how it wants this played rather than hand-building a
* Negotiates Emby's own transcode target for `itemId` - ask Emby how
* it wants this played rather than hand-building a
* /Videos/{id}/master.m3u8 URL ourselves: the first version did that
* with only VideoCodec/AudioCodec, and Emby's server threw "Value
* cannot be null (Parameter 'key')" - almost certainly an internal
@ -277,42 +278,42 @@ class CatalogServiceClass {
* never supplied. PlaybackInfo returns a MediaSource with its own
* ready-to-use TranscodingUrl carrying whatever Emby actually needs.
*
* Music tracks stream through this exact method too (same PlaybackInfo
* -> TranscodingUrl dance - Emby's negotiation isn't video-specific) -
* the only place that actually differs is the hand-built fallback path,
* which is genuinely different between the two (/Videos/ vs /Audio/)
* and has no way to know which without being told: PlaybackInfo's own
* Returns the target path - doesn't proxy it itself (contrast with
* proxyImage/proxySubtitle below, which do their own proxying
* directly) - so `PartyStreamService` can cache what this returns and
* reuse it for every member of a party instead of each negotiating
* (and getting their own separate PlaySessionId) independently - see
* docs/architecture.md's "One shared Emby stream per party" section.
* `isAudio` picks the hand-built fallback's path (/Audio/ vs /Videos/,
* plus whether to force a VideoCodec at all) - PlaybackInfo's own
* response doesn't reliably carry the item's Type back, and looking it
* up would be an extra Emby round trip for what the client already
* knows - so the client passes `mediaType=audio` (see audioStreamUrl()
* in client/src/api/embyApi.ts).
* up would be an extra Emby round trip for what the caller already
* knows.
*
* Subtitles deliberately do NOT go through here (see proxySubtitle
* instead, and its own comment for why): forcing SubtitleMethod=Encode
* onto this negotiation used to be how this app handled them, until a
* long real-server debugging session (docs/architecture.md's player-
* track-selection section, Rounds 3-6) found it unreliable in ways
* ranging from missing fonts to formats libass can't burn at all to
* ffmpeg failing outright on otherwise-unremarkable files - none of
* which this app's own request could ever have controlled or worked
* around, since it's all inside Emby's own transcode. Rendering
* subtitles client-side, from real subtitle data instead of an
* encoding option this app has no visibility into, sidesteps the
* entire class of failure.
*/
async proxyStream(req: Request, res: Response, session: EmbySession, itemId: string): Promise<void> {
// AudioStreamIndex/MaxStreamingBitrate come from the player's
// language/quality pickers, applied to the negotiation itself, not
// just the final URL - a pick could otherwise silently do nothing if
// Emby's negotiation didn't apply the query-string override the same
// way its own web client's POST-with-DeviceProfile request would
// have. Still not trusted to have taken effect on its own, though
// (this app never sends that body) - applyPlaybackOverrides runs a
// second time below, on whatever URL results, so a pick can't
// silently no-op regardless of which path Emby's response takes.
//
// Subtitles deliberately do NOT go through here (see proxySubtitle
// instead, and its own comment for why): forcing SubtitleMethod=Encode
// onto this negotiation used to be how this app handled them, until a
// long real-server debugging session (docs/architecture.md's player-
// track-selection section, Rounds 3-6) found it unreliable in ways
// ranging from missing fonts to formats libass can't burn at all to
// ffmpeg failing outright on otherwise-unremarkable files - none of
// which this app's own request could ever have controlled or worked
// around, since it's all inside Emby's own transcode. Rendering
// subtitles client-side, from real subtitle data instead of an
// encoding option this app has no visibility into, sidesteps the
// entire class of failure.
const query = req.query as Record<string, string | undefined>;
async negotiateStreamTarget(session: EmbySession, itemId: string, isAudio: boolean, audioStreamIndex: string, maxStreamingBitrate: string): Promise<string> {
// Applied to the negotiation itself, not just the final URL - a pick
// could otherwise silently do nothing if Emby's negotiation didn't
// apply the query-string override the same way its own web client's
// POST-with-DeviceProfile request would have. Still not trusted to
// have taken effect on its own, though (this app never sends that
// body) - applyPlaybackOverrides runs a second time below, on
// whatever URL results, so a pick can't silently no-op regardless of
// which path Emby's response takes.
const infoParams = new URLSearchParams({ UserId: session.userId });
this.applyPlaybackOverrides(infoParams, query);
this.applyPlaybackOverrides(infoParams, audioStreamIndex, maxStreamingBitrate);
const info = await this.fetchJson<PlaybackInfoResponse>(`/Items/${itemId}/PlaybackInfo?${infoParams}`, session, 'Impossible de préparer la lecture de ce titre.');
const source = (info.MediaSources || [])[0];
@ -326,45 +327,24 @@ class CatalogServiceClass {
// supplies the parameters PlaybackInfo reveals (MediaSourceId,
// PlaySessionId, UserId) instead of guessing, but is still a
// fallback, not the primary path.
const isAudio = query.mediaType === 'audio';
let target: string;
if (source.TranscodingUrl) {
const [urlPath, urlQuery] = source.TranscodingUrl.split('?');
const params = new URLSearchParams(urlQuery || '');
this.applyPlaybackOverrides(params, query);
target = `${urlPath}?${params}`;
this.applyPlaybackOverrides(params, audioStreamIndex, maxStreamingBitrate);
this.logStreamRequest(itemId, 'negotiated', params);
} else {
const params = new URLSearchParams({
UserId: session.userId,
MediaSourceId: source.Id || '',
PlaySessionId: info.PlaySessionId || '',
AudioCodec: 'aac',
...(isAudio ? {} : { VideoCodec: 'h264' })
});
this.applyPlaybackOverrides(params, query);
target = `/${isAudio ? 'Audio' : 'Videos'}/${itemId}/master.m3u8?${params}`;
this.logStreamRequest(itemId, 'fallback', params);
return `${urlPath}?${params}`;
}
await EmbyClient.proxyResponse(req, res, target, session.embyToken, itemId);
}
/** Everything an HLS playlist references (variant playlists, segments)
* comes back through here - see EmbyClient.rewritePlaylist. */
async proxyRawStreamAsset(req: Request, res: Response, session: EmbySession, itemId: string): Promise<void> {
const target = (req.query.u as string) || '';
let pathAndQuery: string;
if (/^https?:\/\//i.test(target)) {
if (!target.startsWith(EMBY_SERVER_URL)) {
res.status(400).end(); // never proxy an arbitrary URL, only our own Emby server
return;
}
pathAndQuery = target;
} else {
pathAndQuery = `/Videos/${itemId}/${target}`;
}
await EmbyClient.proxyResponse(req, res, pathAndQuery, session.embyToken, itemId);
const params = new URLSearchParams({
UserId: session.userId,
MediaSourceId: source.Id || '',
PlaySessionId: info.PlaySessionId || '',
AudioCodec: 'aac',
...(isAudio ? {} : { VideoCodec: 'h264' })
});
this.applyPlaybackOverrides(params, audioStreamIndex, maxStreamingBitrate);
this.logStreamRequest(itemId, 'fallback', params);
return `/${isAudio ? 'Audio' : 'Videos'}/${itemId}/master.m3u8?${params}`;
}
/**
@ -375,11 +355,12 @@ class CatalogServiceClass {
* apply the query-string override the same way its own web client's
* POST-with-DeviceProfile request would have. Subtitles used to be
* forced here too (SubtitleStreamIndex/SubtitleMethod=Encode) - see
* proxyStream's own comment for why that moved to proxySubtitle instead.
* negotiateStreamTarget's own comment for why that moved to
* proxySubtitle instead.
*/
private applyPlaybackOverrides(params: URLSearchParams, query: Record<string, string | undefined>): void {
if (query.AudioStreamIndex) params.set('AudioStreamIndex', query.AudioStreamIndex);
if (query.MaxStreamingBitrate) params.set('MaxStreamingBitrate', query.MaxStreamingBitrate);
private applyPlaybackOverrides(params: URLSearchParams, audioStreamIndex: string, maxStreamingBitrate: string): void {
if (audioStreamIndex) params.set('AudioStreamIndex', audioStreamIndex);
if (maxStreamingBitrate) params.set('MaxStreamingBitrate', maxStreamingBitrate);
}
// Deliberately logs only these specific override values, never the full

View file

@ -1,6 +1,7 @@
'use strict';
import { Logger } from '../Logger';
import { PartyStreamService } from './PartyStreamService';
import type { AppServer, MusicCurrentPublic, MusicPublicState, MusicTrack, MusicTrackInput, Party } from '../types';
/**
@ -165,6 +166,10 @@ class MusicQueueServiceClass {
const music = party.music!;
this.clearTimer(party);
music.skipVotes.clear();
// Whatever was previously negotiated (see PartyStreamService) was for
// the outgoing track - never valid for whatever plays next, whether
// that's a new track or nothing at all.
PartyStreamService.endSession(party.id);
const next = music.queue.shift();
if (!next) {
music.current = null;

View file

@ -4,6 +4,7 @@ import crypto from 'crypto';
import { Logger } from '../Logger';
import { MusicQueueService } from './MusicQueueService';
import { TranscodeService } from './TranscodeService';
import { PartyStreamService } from './PartyStreamService';
import type { LeaveResult, MediaType, Party, PartyMember, PartyPublicState, QueueItem } from '../types';
/**
@ -61,7 +62,9 @@ class PartyServiceClass {
lastActivityUtc: Date.now(),
members: new Map(),
burnInSubtitleIndex: null,
burnInAudioIndex: null
burnInAudioIndex: null,
streamAudioIndex: null,
streamQuality: null
};
party.members.set(hostUserId, this.newMember(hostUserId, hostUserName));
@ -167,6 +170,7 @@ class PartyServiceClass {
this.partiesById.delete(party.id);
MusicQueueService.destroy(party); // clears the auto-advance timer, if any - see its own comment
TranscodeService.endSession(party.id); // no-op if this party never had a burn-in stream running
PartyStreamService.endSession(party.id); // same - no-op if this party never negotiated a shared stream
Logger.party(`destroyed partyId=${party.id} (last member ${whoLeft} left)`);
} else if (wasHost && newHostUserId != null) {
const newHost = party.members.get(newHostUserId)!;
@ -201,13 +205,21 @@ class PartyServiceClass {
// A new item's own MediaStreams indices have nothing to do with the
// old one's - carrying a burn-in pick across would burn in whatever
// stream happens to share that index on the new item, not the same
// subtitle. TranscodeService.endSession also releases the old item's
// now-irrelevant ffmpeg process/disk usage immediately rather than
// waiting for the idle reaper - see index.ts's own comment on why
// that's a backstop, not the normal path.
// subtitle; same reasoning for streamAudioIndex (an audio *track*
// index is just as item-specific). streamQuality is left alone -
// that's a bandwidth/quality preference, not tied to any one item,
// and already persisted across an item change even before streams
// were shared (qualitySelect was never rebuilt per-item either).
// TranscodeService/PartyStreamService.endSession also release the
// old item's now-irrelevant ffmpeg process or negotiated Emby target
// immediately rather than waiting for their idle reapers - see
// index.ts's own comment on why those are a backstop, not the
// normal path.
party.burnInSubtitleIndex = null;
party.burnInAudioIndex = null;
party.streamAudioIndex = null;
TranscodeService.endSession(party.id);
PartyStreamService.endSession(party.id);
party.lastActivityUtc = Date.now();
Logger.party(`advanced partyId=${party.id} to item="${next.itemName}" (${party.queueIndex + 1}/${party.queue.length})`);
return true;
@ -225,6 +237,7 @@ class PartyServiceClass {
this.partiesById.delete(party.id);
MusicQueueService.destroy(party); // same leak this class's own destroy path guards against
TranscodeService.endSession(party.id); // same leak - see the "leave" destroy path above
PartyStreamService.endSession(party.id);
const idleMinutes = Math.round((Date.now() - party.lastActivityUtc) / 60000);
Logger.party(`destroyed partyId=${party.id} (idle ${idleMinutes}min, ${party.members.size} member(s) still connected)`);
}
@ -264,7 +277,9 @@ class PartyServiceClass {
})),
music: party.mediaType === 'music' ? MusicQueueService.getState(party) : null,
burnInSubtitleIndex: party.burnInSubtitleIndex,
burnInAudioIndex: party.burnInAudioIndex
burnInAudioIndex: party.burnInAudioIndex,
streamAudioIndex: party.streamAudioIndex,
streamQuality: party.streamQuality
};
}

View file

@ -0,0 +1,139 @@
'use strict';
import { EMBY_SERVER_URL } from '../config';
import { EmbyClient } from '../emby/EmbyClient';
import { CatalogService, CatalogFetchError } from './CatalogService';
import type { Request, Response } from 'express';
import type { EmbySession, Party } from '../types';
interface StreamSession {
itemId: string;
audioStreamIndex: string;
maxStreamingBitrate: string;
targetPath: string;
lastAccessUtc: number;
}
/**
* One negotiated Emby stream per PARTY, shared by every member, instead
* of each viewer's own `<video>`/`<audio>` independently negotiating
* its own `PlaybackInfo` (and getting its own separate `PlaySessionId`
* from Emby) the way this app used to. See docs/architecture.md's "One
* shared Emby stream per party" section for the full design - this is
* the *normal* streaming path's equivalent of what `TranscodeService`
* already does for the self-hosted ffmpeg burn-in case; that one is
* untouched by this, so every video party ends up party-shared one way
* or the other.
*
* Keyed by `party.id`, same shape as `TranscodeService`'s own session
* map, for the same reason: one entry per party, replaced when the
* desired parameters change, reaped when idle.
*/
class PartyStreamServiceClass {
private sessions = new Map<string, StreamSession>();
/**
* Returns the party's current negotiated target - reusing the cached
* one if it still matches what the party currently wants (same item,
* same audio/quality choice), renegotiating (and replacing the cache
* entry) otherwise. Throws a `CatalogFetchError` if there's genuinely
* nothing to stream right now (an empty music queue) - the caller
* (this class's own serveMasterPlaylist, wrapped in index.ts's
* asyncRoute) turns that into a clean HTTP response, not a crash.
*/
async getOrNegotiate(party: Party, session: EmbySession): Promise<string> {
const isAudio = party.mediaType === 'music';
const itemId = isAudio ? party.music?.current?.track.itemId : party.itemId;
if (!itemId) {
throw new CatalogFetchError("Rien n'est en train de jouer pour cette party pour le moment.", 409);
}
// Video only - music never had an audio-track/quality picker to
// begin with (see MusicBar.ts), so there's nothing to read here for it.
const audioStreamIndex = isAudio ? '' : (party.streamAudioIndex || '');
const maxStreamingBitrate = isAudio ? '' : (party.streamQuality || '');
const existing = this.sessions.get(party.id);
if (existing && existing.itemId === itemId && existing.audioStreamIndex === audioStreamIndex && existing.maxStreamingBitrate === maxStreamingBitrate) {
existing.lastAccessUtc = Date.now();
return existing.targetPath;
}
const targetPath = await CatalogService.negotiateStreamTarget(session, itemId, isAudio, audioStreamIndex, maxStreamingBitrate);
this.sessions.set(party.id, { itemId, audioStreamIndex, maxStreamingBitrate, targetPath, lastAccessUtc: Date.now() });
return targetPath;
}
/** GET /api/catalog/party-stream/:partyId/master.m3u8 - the caller
* (index.ts) has already checked the requester is a member of `party`. */
async serveMasterPlaylist(req: Request, res: Response, party: Party, session: EmbySession): Promise<void> {
const targetPath = await this.getOrNegotiate(party, session);
await EmbyClient.proxyResponse(req, res, targetPath, session.embyToken, party.id);
this.invalidateOnFailure(res, party.id);
}
/** Everything an HLS playlist references (variant playlists, segments)
* comes back through here - see EmbyClient.rewritePlaylist. Keyed by
* partyId (not itemId) so every member's segment fetches land on the
* same party's cached session regardless of which item they think
* they're watching. */
async serveRawAsset(req: Request, res: Response, party: Party, session: EmbySession): Promise<void> {
const target = (req.query.u as string) || '';
let pathAndQuery: string;
if (/^https?:\/\//i.test(target)) {
if (!target.startsWith(EMBY_SERVER_URL)) {
res.status(400).end(); // never proxy an arbitrary URL, only our own Emby server
return;
}
pathAndQuery = target;
} else {
const isAudio = party.mediaType === 'music';
const itemId = isAudio ? party.music?.current?.track.itemId : party.itemId;
if (!itemId) {
res.status(404).end();
return;
}
pathAndQuery = `/${isAudio ? 'Audio' : 'Videos'}/${itemId}/${target}`;
}
const existing = this.sessions.get(party.id);
if (existing) existing.lastAccessUtc = Date.now(); // segment traffic keeps the session alive too, not just master.m3u8 fetches
await EmbyClient.proxyResponse(req, res, pathAndQuery, session.embyToken, party.id);
this.invalidateOnFailure(res, party.id);
}
/**
* Drops the cache entry on any failed proxied response - Emby's own
* transcode isn't guaranteed to live forever, and a cached target that
* started failing must not be repeated forever. `EmbyClient
* .proxyResponse` already calls `res.status(...)` before writing the
* body, so `res.statusCode` reflects Emby's own response status right
* after it resolves - no extra plumbing needed. The *current* request
* still surfaces whatever error Emby gave; the next one (a manual
* retry via the player's own "Réessayer" button, or simply the next
* segment fetch) renegotiates fresh instead.
*/
private invalidateOnFailure(res: Response, partyId: string): void {
if (res.statusCode >= 400) this.sessions.delete(partyId);
}
/** Called when a party's negotiation is no longer relevant - the
* party ending (PartyService.leave's destroy path / removeIdleParties)
* or its item/track changing (a new item's negotiation has nothing to
* do with the old one's). A harmless no-op if this party never had a
* cached session. */
endSession(partyId: string): void {
this.sessions.delete(partyId);
}
/** Backstop for parties that don't end cleanly - mirrors
* TranscodeService.reapIdleSessions, same wiring in index.ts's own
* setInterval. */
reapIdleSessions(idleTimeoutMs: number): void {
const cutoff = Date.now() - idleTimeoutMs;
for (const [partyId, session] of this.sessions) {
if (session.lastAccessUtc < cutoff) this.sessions.delete(partyId);
}
}
}
export const PartyStreamService = new PartyStreamServiceClass();

View file

@ -36,10 +36,20 @@ export interface ClientToServerEvents {
/** Host-only (checked server-side exactly like "next") - picks which
* audio/subtitle combination the party's *shared* burn-in stream uses,
* see TranscodeService. A null subtitleIndex clears it, dropping the
* party back to the normal per-viewer Emby-proxied stream. Not for
* party back to its normal party-shared Emby stream (PartyStreamService -
* also shared, just proxied rather than transcoded by us). Not for
* text subtitles (those stay the existing per-viewer client-side
* <track>, no server involvement) - see docs/architecture.md. */
setBurnInSubtitle: (msg: { subtitleIndex: string | null; audioIndex?: string }) => void;
/** Host-only, video-only (checked server-side exactly like "next") -
* picks the audio track/quality the party's *shared* normal stream
* uses (PartyStreamService) - see docs/architecture.md's "One shared
* Emby stream per party" section for why this had to become a
* party-wide choice instead of each viewer's own: there's only ever
* one stream now, so there's only room for one set of encoding
* parameters behind it. Either field null/omitted means "Emby's own
* default" - same as an untouched picker meant before this. */
setStreamOptions: (msg: { audioStreamIndex?: string | null; quality?: string | null }) => void;
leave: () => void;
}
@ -83,6 +93,13 @@ export interface ServerToClientEvents {
* mid-party) reads instead - this event is only for someone already
* connected when it changes live. */
burnInChanged: (msg: { subtitleIndex: string | null; audioIndex: string | null }) => void;
/** Broadcast to the whole party room whenever the host changes the
* party's shared stream's audio track/quality - see
* "setStreamOptions" above and PartyStreamService. Mirrors
* party.streamAudioIndex/streamQuality below, which is what a fresh
* "getParty" (a follower joining mid-party) reads instead - this
* event is only for someone already connected when it changes live. */
streamOptionsChanged: (msg: { audioStreamIndex: string | null; quality: string | null }) => void;
errorMessage: (msg: { message: string }) => void;
}
@ -237,14 +254,25 @@ export interface Party {
music?: MusicState;
/** Video only - the host's current pick for the party's *shared*
* burn-in stream (see TranscodeService), null when nobody's picked one
* and the party is just using the normal per-viewer Emby-proxied
* stream. Both null or both set - never one without the other, see
* SocketGateway's "setBurnInSubtitle" handler. Persisted here (not
* just broadcast live) so a follower joining mid-party, or a page
* reload, picks up the party's current choice from a plain
* and the party is just using its normal party-shared Emby stream
* instead (PartyStreamService - also shared, just proxied rather than
* transcoded by us). Both null or both set - never one without the
* other, see SocketGateway's "setBurnInSubtitle" handler. Persisted
* here (not just broadcast live) so a follower joining mid-party, or
* a page reload, picks up the party's current choice from a plain
* "getParty" - same reason itemId/queueIndex live here too. */
burnInSubtitleIndex: string | null;
burnInAudioIndex: string | null;
/** Video only - the host's current pick for the party's normal
* *shared* Emby stream (PartyStreamService) - null means "Emby's own
* default", same as an untouched picker meant before streams were
* shared. There is only ever one of these now (not one per viewer),
* so - like burnIn* above - this is party-wide, host-set state, not
* something each client tracks locally; see SocketGateway's
* "setStreamOptions" handler. Meaningless for a music party -
* MusicBar.ts never had this picker in the first place. */
streamAudioIndex: string | null;
streamQuality: string | null;
}
export interface PublicMember {
@ -276,6 +304,8 @@ export interface PartyPublicState {
music: MusicPublicState | null;
burnInSubtitleIndex: string | null;
burnInAudioIndex: string | null;
streamAudioIndex: string | null;
streamQuality: string | null;
}
export interface LobbyPartyDto {

View file

@ -41,40 +41,51 @@ describe('CatalogService', () => {
assert.deepEqual(libraries.map((l) => l.name).sort(), ['Clips', 'Films', 'Musique', 'Series']);
});
it('proxyStream forces an audio pick onto a negotiated TranscodingUrl (subtitles are a separate concern - see proxySubtitle)', async (t) => {
it('negotiateStreamTarget forces an audio pick onto a negotiated TranscodingUrl (subtitles are a separate concern - see proxySubtitle)', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
MediaSources: [{ Id: 'src1', TranscodingUrl: '/videos/item1/master.m3u8?MediaSourceId=src1&AudioStreamIndex=1' }],
PlaySessionId: 'sess1'
}));
let capturedTarget = '';
t.mock.method(EmbyClient, 'proxyResponse', async (_req: Request, _res: Response, target: string) => { capturedTarget = target; });
await CatalogService.proxyStream(fakeReq({ AudioStreamIndex: '2' }), fakeRes(), session, 'item1');
const target = await CatalogService.negotiateStreamTarget(session, 'item1', false, '2', '');
assert.ok(capturedTarget.includes('AudioStreamIndex=2'));
assert.ok(!capturedTarget.includes('Subtitle'), 'this route never touches subtitle params at all any more');
assert.ok(target.includes('AudioStreamIndex=2'));
assert.ok(!target.includes('Subtitle'), 'this method never touches subtitle params at all');
});
it('proxyStream\'s hand-built fallback picks /Audio/ (and drops VideoCodec) for a music track', async (t) => {
it('negotiateStreamTarget\'s hand-built fallback picks /Audio/ (and drops VideoCodec) when isAudio is true', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src2' }], PlaySessionId: 'sess2' }));
let capturedTarget = '';
t.mock.method(EmbyClient, 'proxyResponse', async (_req: Request, _res: Response, target: string) => { capturedTarget = target; });
await CatalogService.proxyStream(fakeReq({ mediaType: 'audio' }), fakeRes(), session, 'track1');
const target = await CatalogService.negotiateStreamTarget(session, 'track1', true, '', '');
assert.ok(capturedTarget.startsWith('/Audio/track1/master.m3u8'));
assert.ok(!capturedTarget.includes('VideoCodec'));
assert.ok(target.startsWith('/Audio/track1/master.m3u8'));
assert.ok(!target.includes('VideoCodec'));
});
it('proxyStream\'s hand-built fallback picks /Videos/ (with VideoCodec) for anything else', async (t) => {
it('negotiateStreamTarget\'s hand-built fallback picks /Videos/ (with VideoCodec) when isAudio is false', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src3' }], PlaySessionId: 'sess3' }));
let capturedTarget = '';
t.mock.method(EmbyClient, 'proxyResponse', async (_req: Request, _res: Response, target: string) => { capturedTarget = target; });
await CatalogService.proxyStream(fakeReq({}), fakeRes(), session, 'movie1');
const target = await CatalogService.negotiateStreamTarget(session, 'movie1', false, '', '');
assert.ok(capturedTarget.startsWith('/Videos/movie1/master.m3u8'));
assert.ok(capturedTarget.includes('VideoCodec=h264'));
assert.ok(target.startsWith('/Videos/movie1/master.m3u8'));
assert.ok(target.includes('VideoCodec=h264'));
});
it('negotiateStreamTarget applies a quality (MaxStreamingBitrate) pick the same way as an audio pick', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src4' }], PlaySessionId: 'sess4' }));
const target = await CatalogService.negotiateStreamTarget(session, 'movie2', false, '', '4000000');
assert.ok(target.includes('MaxStreamingBitrate=4000000'));
});
it('negotiateStreamTarget fails cleanly (not a crash) when Emby has no media source for this item', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [] }));
await assert.rejects(
() => CatalogService.negotiateStreamTarget(session, 'missing', false, '', ''),
(err: Error & { status?: number }) => err.status === 502
);
});
it('getRawItem preserves Emby\'s own status (a real 404), not a blanket 502', async (t) => {

View file

@ -25,7 +25,9 @@ function fakeParty(id: string): Party {
lastActivityUtc: Date.now(),
members: new Map(),
burnInSubtitleIndex: null,
burnInAudioIndex: null
burnInAudioIndex: null,
streamAudioIndex: null,
streamQuality: null
};
}

View file

@ -0,0 +1,193 @@
// Exercises PartyStreamService's own caching logic without ever calling
// real Emby - CatalogService.negotiateStreamTarget and EmbyClient
// .proxyResponse are both mocked, same "mock the one real dependency, run
// everything else for real" approach as transcodeService.test.ts (which
// mocks CatalogService.getMediaSource for the same reason).
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { Request, Response } from 'express';
import { CatalogService } from '../src/services/CatalogService';
import { EmbyClient } from '../src/emby/EmbyClient';
import { MusicQueueService } from '../src/services/MusicQueueService';
import { PartyService } from '../src/services/PartyService';
import { PartyStreamService } from '../src/services/PartyStreamService';
import type { EmbySession } from '../src/types';
const FAKE_SESSION: EmbySession = {
embyToken: 'tok-alice',
userId: 'u-alice',
userName: 'Alice',
serverId: 'server1',
createdUtc: Date.now()
};
function fakeReq(): Request {
return { query: {} } as unknown as Request;
}
/** statusCode starts at 200, same default Express itself uses before
* anything calls .status() - proxyResponse (mocked in most tests here)
* is what would normally set it; a couple of tests set it directly to
* simulate a failed proxied response, matching how
* PartyStreamService.invalidateOnFailure actually reads it (right after
* EmbyClient.proxyResponse resolves, not via any other channel). */
function fakeRes(): Response {
return { statusCode: 200 } as unknown as Response;
}
describe('PartyStreamService.getOrNegotiate', () => {
it('negotiates once and reuses the cached target for a repeat call with the same desired options', async (t) => {
let negotiateCount = 0;
t.mock.method(CatalogService, 'negotiateStreamTarget', async () => {
negotiateCount += 1;
return '/Videos/movie1/master.m3u8?PlaySessionId=sess1';
});
const party = PartyService.create('u-alice', 'Alice', 'movie1', 'Un Film', '/img/movie1', null, 'video');
const first = await PartyStreamService.getOrNegotiate(party, FAKE_SESSION);
const second = await PartyStreamService.getOrNegotiate(party, FAKE_SESSION);
assert.equal(negotiateCount, 1, 'the second call must reuse the cached target, not negotiate again');
assert.equal(first, second);
PartyStreamService.endSession(party.id);
});
it('renegotiates when the host changes the audio/quality pick', async (t) => {
let negotiateCount = 0;
t.mock.method(CatalogService, 'negotiateStreamTarget', async () => {
negotiateCount += 1;
return `/Videos/movie2/master.m3u8?call=${negotiateCount}`;
});
const party = PartyService.create('u-alice', 'Alice', 'movie2', 'Un Film 2', '/img/movie2', null, 'video');
const first = await PartyStreamService.getOrNegotiate(party, FAKE_SESSION);
party.streamAudioIndex = '2';
const second = await PartyStreamService.getOrNegotiate(party, FAKE_SESSION);
assert.equal(negotiateCount, 2);
assert.notEqual(first, second);
PartyStreamService.endSession(party.id);
});
it('renegotiates when the item changes (e.g. advanceQueue)', async (t) => {
let negotiateCount = 0;
t.mock.method(CatalogService, 'negotiateStreamTarget', async () => {
negotiateCount += 1;
return `/Videos/x/master.m3u8?call=${negotiateCount}`;
});
const party = PartyService.create('u-alice', 'Alice', 'ep1', 'S01E01', '/img/ep1', [
{ itemId: 'ep1', itemName: 'S01E01', itemImageUrl: '/img/ep1' },
{ itemId: 'ep2', itemName: 'S01E02', itemImageUrl: '/img/ep2' }
], 'video');
await PartyStreamService.getOrNegotiate(party, FAKE_SESSION);
PartyService.advanceQueue(party); // also calls PartyStreamService.endSession itself - see PartyService.ts
await PartyStreamService.getOrNegotiate(party, FAKE_SESSION);
assert.equal(negotiateCount, 2, 'advancing to a new item must not reuse the old item\'s cached target');
});
it('music: reads the current queue track, never an audio/quality override (MusicBar has no such picker)', async (t) => {
let capturedArgs: unknown[] = [];
t.mock.method(CatalogService, 'negotiateStreamTarget', async (...args: unknown[]) => {
capturedArgs = args;
return '/Audio/t1/master.m3u8';
});
const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 60_000_000 }, 'u-alice', 'Alice');
await PartyStreamService.getOrNegotiate(party, FAKE_SESSION);
assert.deepEqual(capturedArgs, [FAKE_SESSION, 't1', true, '', '']);
});
it('music: fails cleanly (409, not a crash) when the queue is empty - nothing to stream', async () => {
const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music');
MusicQueueService.init(party, null, 'u-alice', 'Alice');
await assert.rejects(
() => PartyStreamService.getOrNegotiate(party, FAKE_SESSION),
(err: Error & { status?: number }) => err.status === 409
);
});
});
describe('PartyStreamService failure recovery', () => {
it('serveMasterPlaylist drops the cached session when the proxied response fails, so the next call renegotiates', async (t) => {
let negotiateCount = 0;
t.mock.method(CatalogService, 'negotiateStreamTarget', async () => {
negotiateCount += 1;
return `/Videos/movie3/master.m3u8?call=${negotiateCount}`;
});
t.mock.method(EmbyClient, 'proxyResponse', async (_req: Request, res: Response) => {
(res as unknown as { statusCode: number }).statusCode = 502; // simulate Emby having ended this session on its own
});
const party = PartyService.create('u-alice', 'Alice', 'movie3', 'Un Film 3', '/img/movie3', null, 'video');
await PartyStreamService.serveMasterPlaylist(fakeReq(), fakeRes(), party, FAKE_SESSION);
await PartyStreamService.serveMasterPlaylist(fakeReq(), fakeRes(), party, FAKE_SESSION);
assert.equal(negotiateCount, 2, 'a failed response must not be cached - the next request has to renegotiate');
PartyStreamService.endSession(party.id);
});
it('a successful proxied response keeps the cache - the next call does not renegotiate', async (t) => {
let negotiateCount = 0;
t.mock.method(CatalogService, 'negotiateStreamTarget', async () => {
negotiateCount += 1;
return '/Videos/movie4/master.m3u8';
});
t.mock.method(EmbyClient, 'proxyResponse', async () => {}); // res.statusCode stays 200, the fakeRes() default
const party = PartyService.create('u-alice', 'Alice', 'movie4', 'Un Film 4', '/img/movie4', null, 'video');
await PartyStreamService.serveMasterPlaylist(fakeReq(), fakeRes(), party, FAKE_SESSION);
await PartyStreamService.serveMasterPlaylist(fakeReq(), fakeRes(), party, FAKE_SESSION);
assert.equal(negotiateCount, 1);
PartyStreamService.endSession(party.id);
});
});
describe('PartyStreamService lifecycle', () => {
it('endSession is a harmless no-op for a party that never negotiated a stream', () => {
assert.doesNotThrow(() => PartyStreamService.endSession('no-such-party'));
});
it('reapIdleSessions ends sessions past the idle cutoff, leaves a fresh one alone', async (t) => {
let negotiateCount = 0;
t.mock.method(CatalogService, 'negotiateStreamTarget', async () => {
negotiateCount += 1;
return `/Videos/x/master.m3u8?call=${negotiateCount}`;
});
const party = PartyService.create('u-eve', 'Eve', 'movie5', 'Un Film 5', '/img/movie5', null, 'video');
await PartyStreamService.getOrNegotiate(party, FAKE_SESSION);
// A generous timeout must not touch a session just negotiated.
PartyStreamService.reapIdleSessions(30 * 60 * 1000);
await PartyStreamService.getOrNegotiate(party, FAKE_SESSION);
assert.equal(negotiateCount, 1, 'a fresh session must not be reaped');
// A *negative* timeout pushes the cutoff strictly into the future,
// so it reaps regardless of exactly when lastAccessUtc was set -
// unlike 0, which computes cutoff = Date.now() at reap time and can
// tie with a lastAccessUtc set within the same millisecond (this
// failed intermittently with 0 - Date.now()'s own resolution isn't
// fine-grained enough to guarantee cutoff is strictly later). The
// simplest way to exercise the sweep itself without a lastAccessUtc
// setter (getOrNegotiate only ever returns the target string, not
// the session object, unlike TranscodeService's own equivalent method).
PartyStreamService.reapIdleSessions(-1);
await PartyStreamService.getOrNegotiate(party, FAKE_SESSION);
assert.equal(negotiateCount, 2, 'a past-the-future cutoff must reap even a just-touched session');
PartyStreamService.endSession(party.id);
});
});

View file

@ -418,4 +418,57 @@ describe('SocketGateway', () => {
assert.equal(party.burnInSubtitleIndex, null);
assert.ok(!io.roomEmits.some((e) => e.event === 'burnInChanged'));
});
it('setStreamOptions is host-only and a no-op for anyone else (same pattern as "next")', () => {
const io = fakeIo();
attach(io);
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
PartyService.join(party, 'u-bob', 'Bob');
const bobSocket = fakeSocket('u-bob');
io.trigger('connection', bobSocket as unknown as AppSocket);
bobSocket.trigger('join', { partyId: party.id, deviceId: 'device-bob' });
bobSocket.trigger('setStreamOptions', { audioStreamIndex: '2', quality: '4000000' });
assert.equal(party.streamAudioIndex, null, 'a non-host request must not change party state');
assert.equal(party.streamQuality, null);
assert.ok(!io.roomEmits.some((e) => e.event === 'streamOptionsChanged'), 'and must not broadcast anything');
});
it('setStreamOptions from the host updates the party and broadcasts streamOptionsChanged to the room', () => {
const io = fakeIo();
attach(io);
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const aliceSocket = fakeSocket('u-alice');
io.trigger('connection', aliceSocket as unknown as AppSocket);
aliceSocket.trigger('join', { partyId: party.id, deviceId: 'device-alice' });
aliceSocket.trigger('setStreamOptions', { audioStreamIndex: '2', quality: '4000000' });
assert.equal(party.streamAudioIndex, '2');
assert.equal(party.streamQuality, '4000000');
const broadcast = io.roomEmits.find((e) => e.room === party.id && e.event === 'streamOptionsChanged');
assert.deepEqual(broadcast?.payload, { audioStreamIndex: '2', quality: '4000000' });
// Clearing a pick (back to "Emby's own default") resets it to null,
// independently of the other field.
aliceSocket.trigger('setStreamOptions', { audioStreamIndex: null, quality: '4000000' });
assert.equal(party.streamAudioIndex, null);
assert.equal(party.streamQuality, '4000000');
});
it('setStreamOptions is a no-op on a music party (no picker exists there at all)', () => {
const io = fakeIo();
attach(io);
const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music');
const aliceSocket = fakeSocket('u-alice');
io.trigger('connection', aliceSocket as unknown as AppSocket);
aliceSocket.trigger('join', { partyId: party.id, deviceId: 'device-alice' });
aliceSocket.trigger('setStreamOptions', { audioStreamIndex: '2' });
assert.equal(party.streamAudioIndex, null);
assert.ok(!io.roomEmits.some((e) => e.event === 'streamOptionsChanged'));
});
});