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 - 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:
parent
11f003d269
commit
f78b2f1408
20 changed files with 898 additions and 188 deletions
22
README.md
22
README.md
|
|
@ -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
|
just hit "Épisode suivant" instead of restarting a party each time. If
|
||||||
the host leaves, the party hands off to another member automatically
|
the host leaves, the party hands off to another member automatically
|
||||||
(their player unlocks live, no reload needed) — only closing once
|
(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
|
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
|
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
|
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
|
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
|
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
|
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
|
ffmpeg, shared live by everyone in the party (picking one restarts
|
||||||
is fixed while that's active, and it restarts the stream for
|
the stream for
|
||||||
everyone, so it's a host-only choice, like play/pause already is).
|
everyone, so it's a host-only choice, like play/pause already is).
|
||||||
7. Music works differently, on purpose: pick a track (browsing Artist →
|
7. Music works differently, on purpose: pick a track (browsing Artist →
|
||||||
Album → Track) to start a listening party. There's no host for
|
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
|
(the exact `PlaystateService` endpoint/body shape) and the Playlists
|
||||||
endpoint - three Emby-API assumptions total now waiting on a real
|
endpoint - three Emby-API assumptions total now waiting on a real
|
||||||
server to actually confirm, all otherwise built and unit-tested the
|
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
|
## Project layout
|
||||||
|
|
||||||
|
|
@ -221,7 +229,9 @@ Then put it behind your existing reverse proxy like everything else
|
||||||
- [x] Design + scaffold the standalone app
|
- [x] Design + scaffold the standalone app
|
||||||
- [x] Proxy all Emby access server-side; Emby-themed UI (sidebar, carousels)
|
- [x] Proxy all Emby access server-side; Emby-themed UI (sidebar, carousels)
|
||||||
- [x] TV show navigation (Series → Seasons → Episodes) + episode auto-queue
|
- [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
|
- [x] Socket.IO instead of raw WebSockets; fixed two real memory leaks
|
||||||
(dead-connection cleanup server-side, view teardown on navigation
|
(dead-connection cleanup server-side, view teardown on navigation
|
||||||
client-side) — see docs/architecture.md
|
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
|
queue or the participative music queue) — 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 — see docs/architecture.md
|
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)
|
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
|
||||||
- [ ] Tune transcoding target / support direct play when possible
|
- [ ] Tune transcoding target / support direct play when possible
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -116,21 +116,19 @@ export function getPlaybackOptions(itemId: string): Promise<PlaybackOptionsDto>
|
||||||
return apiFetch(`/api/catalog/item/${itemId}/playback-options`);
|
return apiFetch(`/api/catalog/item/${itemId}/playback-options`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `options` may carry AudioStreamIndex/MaxStreamingBitrate/mediaType - not
|
/** The party's own shared Emby stream (server/src/services/
|
||||||
* subtitles, which are their own separate request now (see subtitleUrl
|
* PartyStreamService.ts) - one negotiated PlaybackInfo session reused
|
||||||
* below and Player.ts's applySubtitleTrack for why). */
|
* by every member, instead of each viewer's own <video>/<audio>
|
||||||
export function streamUrl(itemId: string, options: Record<string, string | number | null | undefined> = {}): string {
|
* negotiating independently the way this app used to work. Keyed by
|
||||||
const entries = Object.entries(options).filter(([, v]) => v !== '' && v != null) as [string, string][];
|
* partyId, not itemId: the party itself carries what it's currently
|
||||||
const qs = new URLSearchParams(entries).toString();
|
* streaming and with which audio/quality choice (Party.streamAudioIndex/
|
||||||
return `/api/catalog/stream/${itemId}/master.m3u8${qs ? '?' + qs : ''}`;
|
* 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
|
||||||
/** Same stream, tagged for a music track - see server/src/services/
|
* function's own predecessor (streamUrl/audioStreamUrl), since there's
|
||||||
* CatalogService.ts's proxyStream for why mediaType is the one thing
|
* no longer a per-viewer choice to encode into the URL. */
|
||||||
* that matters here (its hand-built-fallback path is genuinely different
|
export function partyStreamUrl(partyId: string): string {
|
||||||
* for audio). */
|
return `/api/catalog/party-stream/${partyId}/master.m3u8`;
|
||||||
export function audioStreamUrl(itemId: string): string {
|
|
||||||
return streamUrl(itemId, { mediaType: 'audio' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A subtitle track's own text, as WebVTT (server/src/services/
|
/** A subtitle track's own text, as WebVTT (server/src/services/
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
// No join codes: joining happens straight from the home page's live list
|
// No join codes: joining happens straight from the home page's live list
|
||||||
// (connectLobby below) by party id - see docs/architecture.md.
|
// (connectLobby below) by party id - see docs/architecture.md.
|
||||||
import { getDeviceId } from '../deviceId';
|
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> {
|
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const res = await fetch(path, {
|
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
|
/** The host picked (or cleared) the party's shared burn-in stream - see
|
||||||
* setBurnInSubtitle below and Player.ts. */
|
* setBurnInSubtitle below and Player.ts. */
|
||||||
onBurnInChanged?: (msg: BurnInChangedMsg) => void;
|
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;
|
onError?: (message: string) => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -117,6 +120,12 @@ export interface PartyConnection {
|
||||||
* subtitle - that stays the existing per-viewer client-side <track>,
|
* subtitle - that stays the existing per-viewer client-side <track>,
|
||||||
* no socket call at all. */
|
* no socket call at all. */
|
||||||
setBurnInSubtitle(subtitleIndex: string | null, audioIndex?: string): void;
|
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;
|
leave(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,7 +136,7 @@ export interface PartyConnection {
|
||||||
* alive via closures, never gets released.
|
* alive via closures, never gets released.
|
||||||
*/
|
*/
|
||||||
export function connectParty(partyId: string, callbacks: PartyConnectionCallbacks = {}): PartyConnection {
|
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 socket = io();
|
||||||
const deviceId = getDeviceId();
|
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 (onQueueUpdated) socket.on('queueUpdated', (m) => onQueueUpdated(m as MusicPublicState)); // music parties only - see musicBar/MusicBar.ts
|
||||||
if (onDeviceConflict) socket.on('deviceConflict', () => onDeviceConflict());
|
if (onDeviceConflict) socket.on('deviceConflict', () => onDeviceConflict());
|
||||||
if (onBurnInChanged) socket.on('burnInChanged', (m) => onBurnInChanged(m as BurnInChangedMsg));
|
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 (onError) socket.on('errorMessage', (m) => onError((m as { message: string }).message));
|
||||||
if (onClose) socket.on('disconnect', () => onClose());
|
if (onClose) socket.on('disconnect', () => onClose());
|
||||||
|
|
||||||
|
|
@ -178,6 +188,9 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback
|
||||||
setBurnInSubtitle(subtitleIndex, audioIndex) {
|
setBurnInSubtitle(subtitleIndex, audioIndex) {
|
||||||
socket.emit('setBurnInSubtitle', { subtitleIndex, audioIndex });
|
socket.emit('setBurnInSubtitle', { subtitleIndex, audioIndex });
|
||||||
},
|
},
|
||||||
|
setStreamOptions(audioStreamIndex, quality) {
|
||||||
|
socket.emit('setStreamOptions', { audioStreamIndex, quality });
|
||||||
|
},
|
||||||
leave() {
|
leave() {
|
||||||
socket.emit('leave');
|
socket.emit('leave');
|
||||||
socket.disconnect();
|
socket.disconnect();
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import '../styles/presence.css';
|
||||||
import '../styles/trackList.css';
|
import '../styles/trackList.css';
|
||||||
import './MusicBar.css';
|
import './MusicBar.css';
|
||||||
import html from './MusicBar.html';
|
import html from './MusicBar.html';
|
||||||
import { audioStreamUrl } from '../api/embyApi';
|
import { partyStreamUrl } from '../api/embyApi';
|
||||||
import { connectParty, leaveParty } from '../api/party';
|
import { connectParty, leaveParty } from '../api/party';
|
||||||
import type { PartyConnection } from '../api/party';
|
import type { PartyConnection } from '../api/party';
|
||||||
import { colorForName, escapeHtml, formatDuration, initialsFor } from '../tiles';
|
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
|
// Emby's PlaybackInfo negotiation isn't video-specific, so a music
|
||||||
// track's TranscodingUrl just as plausibly comes back HLS-shaped, and
|
// track's TranscodingUrl just as plausibly comes back HLS-shaped, and
|
||||||
// most browsers besides Safari have no native .m3u8 support at all.
|
// 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 {
|
function loadTrack(track: MusicCurrentPublic, elapsedSeconds: number, isPaused: boolean): void {
|
||||||
const audio = barEl!.querySelector('#barAudio') as HTMLAudioElement;
|
const audio = barEl!.querySelector('#barAudio') as HTMLAudioElement;
|
||||||
const trackImage = barEl!.querySelector('#trackImage') as HTMLImageElement;
|
const trackImage = barEl!.querySelector('#trackImage') as HTMLImageElement;
|
||||||
|
|
@ -187,7 +193,7 @@ function loadTrack(track: MusicCurrentPublic, elapsedSeconds: number, isPaused:
|
||||||
|
|
||||||
if (hls) { hls.destroy(); hls = null; }
|
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 = () => {
|
const resume = () => {
|
||||||
suppressLocalEvents = true;
|
suppressLocalEvents = true;
|
||||||
audio.currentTime = elapsedSeconds || 0;
|
audio.currentTime = elapsedSeconds || 0;
|
||||||
|
|
|
||||||
|
|
@ -111,10 +111,19 @@ export interface PartyPublicState {
|
||||||
music: MusicPublicState | null;
|
music: MusicPublicState | null;
|
||||||
// Video only - the host's current pick for the party's shared burn-in
|
// Video only - the host's current pick for the party's shared burn-in
|
||||||
// stream (see server/src/services/TranscodeService.ts), null when the
|
// stream (see server/src/services/TranscodeService.ts), null when the
|
||||||
// party is just using the normal per-viewer Emby-proxied stream. Both
|
// party is just using its normal party-shared Emby stream instead
|
||||||
// null or both set together - see SocketGateway's own comment.
|
// (PartyStreamService - also shared, just proxied rather than
|
||||||
|
// transcoded by us). Both null or both set together - see
|
||||||
|
// SocketGateway's own comment.
|
||||||
burnInSubtitleIndex: string | null;
|
burnInSubtitleIndex: string | null;
|
||||||
burnInAudioIndex: 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
|
/** The socket "members" broadcast's shape - smaller than
|
||||||
|
|
@ -169,8 +178,17 @@ export interface ResumeRequestedMsg {
|
||||||
/** "burnInChanged" - see api/party.ts's setBurnInSubtitle/onBurnInChanged
|
/** "burnInChanged" - see api/party.ts's setBurnInSubtitle/onBurnInChanged
|
||||||
* and PartyPublicState.burnInSubtitleIndex above (this is the live-push
|
* and PartyPublicState.burnInSubtitleIndex above (this is the live-push
|
||||||
* half; that's the on-load/rejoin half). Both null together clears the
|
* 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 {
|
export interface BurnInChangedMsg {
|
||||||
subtitleIndex: string | null;
|
subtitleIndex: string | null;
|
||||||
audioIndex: 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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import '../../styles/presence.css';
|
import '../../styles/presence.css';
|
||||||
import './Player.css';
|
import './Player.css';
|
||||||
import html from './Player.html';
|
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 { getParty, leaveParty, connectParty } from '../../api/party';
|
||||||
import type { PartyConnection } from '../../api/party';
|
import type { PartyConnection } from '../../api/party';
|
||||||
import { navigate } from '../../router';
|
import { navigate } from '../../router';
|
||||||
import type { CleanupFn, RouteParams } from '../../router';
|
import type { CleanupFn, RouteParams } from '../../router';
|
||||||
import { colorForName, escapeHtml, initialsFor } from '../../tiles';
|
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';
|
import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar';
|
||||||
|
|
||||||
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
|
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
|
// 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
|
// 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
|
// initial load itself) chains through our own proxy into a FRESH Emby
|
||||||
// transcode (see CatalogService.proxyStream). Confirmed for real: a
|
// transcode (see CatalogService.negotiateStreamTarget/PartyStreamService).
|
||||||
// forced re-encode (this used to be how subtitles worked too, before they
|
// Confirmed for real: a forced re-encode (this used to be how subtitles
|
||||||
// moved to a native <track> - see proxyStream's own comment for why)
|
// worked too, before they moved to a native <track> - see
|
||||||
// measurably took Emby longer than 10s to produce a first segment for,
|
// 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
|
// 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
|
// retries - not a hang, a real but slow transcode start being mistaken
|
||||||
// for a dead server. Same config duplicated in musicBar/MusicBar.ts (its
|
// 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.
|
// up immediately, not just from a live "burnInChanged" broadcast.
|
||||||
let burnInSubtitleIndex: string | null = party.burnInSubtitleIndex;
|
let burnInSubtitleIndex: string | null = party.burnInSubtitleIndex;
|
||||||
let burnInAudioIndex: string | null = party.burnInAudioIndex;
|
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
|
// Cached purely so becomeHost (host status can change live mid-session
|
||||||
// - see its own comment) can re-render the subtitle picker's host-only
|
// - see its own comment) can re-render the subtitle picker's host-only
|
||||||
// gating for burn-in options without a redundant playback-options fetch.
|
// 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
|
// Burn-in (image) subtitle options are host-only (see
|
||||||
// renderSubtitleOptions) - becoming host mid-session must unlock
|
// renderSubtitleOptions) - becoming host mid-session must unlock
|
||||||
// them immediately, not just on this player's next full re-render.
|
// 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();
|
renderSubtitleOptions();
|
||||||
|
applyStreamOptionsGating();
|
||||||
|
|
||||||
if (notify) alert("L'hôte précédent a quitté la party - tu es maintenant l'hôte.");
|
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
|
audioSelect.innerHTML = options.audioTracks.length
|
||||||
? options.audioTracks.map((t) => `<option value="${t.index}">${escapeHtml(t.label)}</option>`).join('')
|
? options.audioTracks.map((t) => `<option value="${t.index}">${escapeHtml(t.label)}</option>`).join('')
|
||||||
: '<option value="">Piste par défaut</option>';
|
: '<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();
|
renderSubtitleOptions();
|
||||||
|
applyStreamOptionsGating();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[watchparty player] playback-options failed', err);
|
console.error('[watchparty player] playback-options failed', err);
|
||||||
// Non-fatal: playback can still work with Emby's own defaults, just without a picker.
|
// 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);
|
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
|
* Adds/replaces the video's own <track> element for whichever subtitle
|
||||||
* (if any) is currently selected - a native browser feature, entirely
|
* (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
|
// A burn-in stream already has its subtitle composited into the
|
||||||
// video itself - a real <track> on top would be redundant at best.
|
// video itself - a real <track> on top would be redundant at best.
|
||||||
// Re-applied on every (re)load otherwise, not just when the subtitle
|
// Re-applied on every (re)load otherwise, not just when the subtitle
|
||||||
// picker itself changes: an audio/quality change reloads this same
|
// picker itself changes: an audio/quality change now reloads through
|
||||||
// item through here too (see reloadWithCurrentOptions), and the
|
// here too (via onStreamOptionsChanged, once the broadcast comes
|
||||||
// subtitle that was showing should survive that - subtitleSelect.value
|
// back - see that handler and the setStreamOptions change listener
|
||||||
// is untouched in that case, so this just re-adds the same track. A
|
// below), and the subtitle that was showing should survive that -
|
||||||
// genuinely new item (onItemChanged/init) already reset the select
|
// subtitleSelect.value is untouched in that case, so this just
|
||||||
// to "Aucun" before this runs (see loadTrackOptions), so this
|
// re-adds the same track. A genuinely new item (onItemChanged/init)
|
||||||
// correctly clears any leftover track from whatever played before.
|
// 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;
|
const usingBurnIn = !!burnInSubtitleIndex;
|
||||||
applySubtitleTrack(usingBurnIn ? '' : subtitleSelect.value);
|
applySubtitleTrack(usingBurnIn ? '' : subtitleSelect.value);
|
||||||
// The burn-in stream is one fixed ffmpeg encode for the whole party -
|
applyStreamOptionsGating();
|
||||||
// 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;
|
|
||||||
|
|
||||||
const src = usingBurnIn
|
// Both branches are already party-shared, one way or the other -
|
||||||
? burnInStreamUrl(partyId)
|
// burnInStreamUrl's own ffmpeg, or PartyStreamService's cached Emby
|
||||||
: streamUrl(itemId, {
|
// negotiation - so neither needs any audio/quality query params any
|
||||||
AudioStreamIndex: audioSelect.value,
|
// more; the party itself (streamAudioIndex/streamQuality, set via
|
||||||
MaxStreamingBitrate: qualitySelect.value
|
// 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 = () => {
|
const resume = () => {
|
||||||
loadingNotice.classList.add('hide');
|
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
|
// Changing audio/quality picks the party's *shared* stream's encoding
|
||||||
// selection in server-side - see CatalogService) - reload at the same
|
// parameters now (PartyStreamService) - host-only (the selects are
|
||||||
// position instead of restarting from zero. Subtitles are NOT part of
|
// disabled for a follower to begin with, so this listener only
|
||||||
// this - see the subtitleSelect listener below, and applySubtitleTrack.
|
// realistically ever fires for the host - same defensive shape as the
|
||||||
function reloadWithCurrentOptions() {
|
// subtitleSelect listener below), and does NOT reload here directly:
|
||||||
if (currentItemId) loadVideo(currentItemId, video.currentTime, !video.paused);
|
// 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) => {
|
[audioSelect, qualitySelect].forEach((sel) => {
|
||||||
sel.addEventListener('change', () => {
|
sel.addEventListener('change', () => {
|
||||||
settingsPanel.classList.add('hide');
|
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
|
// A new item's own stream indices have nothing to do with the old
|
||||||
// one's - PartyService.advanceQueue already resets this server-side
|
// one's - PartyService.advanceQueue already resets this server-side
|
||||||
// too (see its own comment); this just keeps this client in sync
|
// 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;
|
burnInSubtitleIndex = null;
|
||||||
burnInAudioIndex = null;
|
burnInAudioIndex = null;
|
||||||
|
streamAudioIndex = null;
|
||||||
await loadTrackOptions(msg.itemId);
|
await loadTrackOptions(msg.itemId);
|
||||||
loadVideo(msg.itemId, 0, true);
|
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);
|
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
|
// Presence overlay: one semi-transparent circle per member, initials
|
||||||
// only (no photos - Emby accounts don't reliably have one). Purely
|
// only (no photos - Emby accounts don't reliably have one). Purely
|
||||||
// presence now - asking the host for something is the requestToolbar's
|
// presence now - asking the host for something is the requestToolbar's
|
||||||
|
|
@ -558,6 +616,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
onPauseRequested,
|
onPauseRequested,
|
||||||
onResumeRequested,
|
onResumeRequested,
|
||||||
onBurnInChanged,
|
onBurnInChanged,
|
||||||
|
onStreamOptionsChanged,
|
||||||
onDeviceConflict: () => {
|
onDeviceConflict: () => {
|
||||||
// This socket is still connected but inert (see SocketGateway's
|
// This socket is still connected but inert (see SocketGateway's
|
||||||
// "join" handler / api/party.ts) - a confirm() is fine here even
|
// "join" handler / api/party.ts) - a confirm() is fine here even
|
||||||
|
|
|
||||||
|
|
@ -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
|
No client changes at all - this is a pure server-side background
|
||||||
process with nothing for the browser to render or react to.
|
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
|
## Known limitations
|
||||||
|
|
||||||
- Playback always goes through `hls.js` against whatever Emby's
|
- 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
|
position, not their own exact one (there's no per-follower tracking
|
||||||
today) - a reasonable approximation given the player's own drift
|
today) - a reasonable approximation given the player's own drift
|
||||||
correction, not a precise per-viewer number.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"typecheck": "tsc -p tsconfig.test.json",
|
"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": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|
|
||||||
|
|
@ -71,21 +71,21 @@ export class EmbyClient {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rewrites every URI line in an HLS playlist so it routes back through
|
* Rewrites every URI line in an HLS playlist so it routes back through
|
||||||
* our own proxy (`/api/catalog/stream/:itemId/raw?u=...`) instead of
|
* our own proxy (`/api/catalog/party-stream/:routeKey/raw?u=...`)
|
||||||
* Emby directly - whether Emby wrote that line as a relative or an
|
* instead of Emby directly - whether Emby wrote that line as a
|
||||||
* absolute URL. Without this, hls.js would follow the playlist's own
|
* relative or an absolute URL. Without this, hls.js would follow the
|
||||||
* URLs straight to Emby, defeating the point of proxying the master
|
* playlist's own URLs straight to Emby, defeating the point of
|
||||||
* playlist in the first place. TODO(verify): only tested against
|
* proxying the master playlist in the first place. TODO(verify): only
|
||||||
* synthetic input, not a real Emby-generated playlist - see
|
* tested against synthetic input, not a real Emby-generated playlist -
|
||||||
* docs/architecture.md.
|
* see docs/architecture.md.
|
||||||
*/
|
*/
|
||||||
static rewritePlaylist(text: string, itemId: string): string {
|
static rewritePlaylist(text: string, routeKey: string): string {
|
||||||
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/stream/${itemId}/raw?u=${encodeURIComponent(trimmed)}`;
|
return `/api/catalog/party-stream/${routeKey}/raw?u=${encodeURIComponent(trimmed)}`;
|
||||||
})
|
})
|
||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
@ -95,9 +95,15 @@ export class EmbyClient {
|
||||||
* content-type, and the headers that make byte-range seeking work
|
* content-type, and the headers that make byte-range seeking work
|
||||||
* (content-range/accept-ranges), rewriting HLS playlists in transit and
|
* (content-range/accept-ranges), rewriting HLS playlists in transit and
|
||||||
* streaming everything else (images, video/audio segments) through
|
* 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;
|
let embyRes: globalThis.Response;
|
||||||
try {
|
try {
|
||||||
embyRes = await EmbyClient.fetch(pathAndQuery, token, { range: req.headers.range });
|
embyRes = await EmbyClient.fetch(pathAndQuery, token, { range: req.headers.range });
|
||||||
|
|
@ -116,7 +122,7 @@ 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, itemId));
|
res.send(EmbyClient.rewritePlaylist(text, routeKey));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { CatalogService, CatalogFetchError, splitPlaylistByMediaType } from './s
|
||||||
import { PartyService } from './services/PartyService';
|
import { PartyService } from './services/PartyService';
|
||||||
import { MusicQueueService } from './services/MusicQueueService';
|
import { MusicQueueService } from './services/MusicQueueService';
|
||||||
import { TranscodeService } from './services/TranscodeService';
|
import { TranscodeService } from './services/TranscodeService';
|
||||||
|
import { PartyStreamService } from './services/PartyStreamService';
|
||||||
import { PlaystateService } from './services/PlaystateService';
|
import { PlaystateService } from './services/PlaystateService';
|
||||||
import { SocketGateway } from './realtime/SocketGateway';
|
import { SocketGateway } from './realtime/SocketGateway';
|
||||||
import type { ClientToServerEvents, EmbySession, MediaType, QueueItem, ServerToClientEvents } from './types';
|
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'));
|
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);
|
const session = AuthService.sessionFromRequest(req);
|
||||||
if (!session) { res.status(401).end(); return; }
|
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)
|
// Everything an HLS playlist references (variant playlists, segments)
|
||||||
// comes back through here - see EmbyClient.rewritePlaylist.
|
// 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);
|
const session = AuthService.sessionFromRequest(req);
|
||||||
if (!session) { res.status(401).end(); return; }
|
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> -
|
// 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
|
// The party's own self-hosted-ffmpeg burn-in stream (PGS/image subtitles
|
||||||
// only - see TranscodeService and Party.burnInSubtitleIndex's own
|
// only - see TranscodeService and Party.burnInSubtitleIndex's own
|
||||||
// comment); every other party keeps streaming through
|
// comment); every other party keeps streaming through the party-stream
|
||||||
// /api/catalog/stream above, completely untouched. Keyed by partyId, not
|
// routes above instead - PartyStreamService, not our own ffmpeg. Keyed
|
||||||
// itemId - the party object itself is the source of truth for which
|
// by partyId, not itemId, same reasoning as those routes: the party
|
||||||
// audio/subtitle combination is currently burned in, so there's nothing
|
// object itself is the source of truth for which audio/subtitle
|
||||||
// else a client could usefully put in this URL. requireSession's JSON
|
// combination is currently burned in, so there's nothing else a client
|
||||||
// error doesn't fit here either, same reasoning as the routes above.
|
// 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) => {
|
app.get('/api/catalog/burn/:partyId/master.m3u8', asyncRoute(async (req, res) => {
|
||||||
const session = AuthService.sessionFromRequest(req);
|
const session = AuthService.sessionFromRequest(req);
|
||||||
if (!session) { res.status(401).end(); return; }
|
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
|
// abandoned one is worth reclaiming sooner. Mostly a backstop - the
|
||||||
// normal path is PartyService.leave's destroy branch ending it immediately.
|
// normal path is PartyService.leave's destroy branch ending it immediately.
|
||||||
setInterval(() => TranscodeService.reapIdleSessions(30 * 60 * 1000), 5 * 60 * 1000);
|
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
|
// Pushes every connected member's own progress to Emby, under their own
|
||||||
// account - see docs/architecture.md's "Sync watch-party progress back
|
// account - see docs/architecture.md's "Sync watch-party progress back
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,12 @@ import type { AppServer, LeaveResult, LobbyPartyDto, Party, SocketMemberDto } fr
|
||||||
* TranscodeService. A text subtitle never goes through here - that
|
* TranscodeService. A text subtitle never goes through here - that
|
||||||
* stays the existing per-viewer client-side <track>.)
|
* stays the existing per-viewer client-side <track>.)
|
||||||
* "burnInChanged" (server -> client)
|
* "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) --
|
* -- 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 });
|
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
|
// Majority of currently CONNECTED members, not everyone who's ever
|
||||||
// joined - see MusicQueueService.
|
// joined - see MusicQueueService.
|
||||||
socket.on('voteSkip', this.safe(() => {
|
socket.on('voteSkip', this.safe(() => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
* /Videos/{id}/master.m3u8 URL ourselves: the first version did that
|
||||||
* with only VideoCodec/AudioCodec, and Emby's server threw "Value
|
* with only VideoCodec/AudioCodec, and Emby's server threw "Value
|
||||||
* cannot be null (Parameter 'key')" - almost certainly an internal
|
* cannot be null (Parameter 'key')" - almost certainly an internal
|
||||||
|
|
@ -277,42 +278,42 @@ class CatalogServiceClass {
|
||||||
* never supplied. PlaybackInfo returns a MediaSource with its own
|
* never supplied. PlaybackInfo returns a MediaSource with its own
|
||||||
* ready-to-use TranscodingUrl carrying whatever Emby actually needs.
|
* ready-to-use TranscodingUrl carrying whatever Emby actually needs.
|
||||||
*
|
*
|
||||||
* Music tracks stream through this exact method too (same PlaybackInfo
|
* Returns the target path - doesn't proxy it itself (contrast with
|
||||||
* -> TranscodingUrl dance - Emby's negotiation isn't video-specific) -
|
* proxyImage/proxySubtitle below, which do their own proxying
|
||||||
* the only place that actually differs is the hand-built fallback path,
|
* directly) - so `PartyStreamService` can cache what this returns and
|
||||||
* which is genuinely different between the two (/Videos/ vs /Audio/)
|
* reuse it for every member of a party instead of each negotiating
|
||||||
* and has no way to know which without being told: PlaybackInfo's own
|
* (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
|
* 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
|
* up would be an extra Emby round trip for what the caller already
|
||||||
* knows - so the client passes `mediaType=audio` (see audioStreamUrl()
|
* knows.
|
||||||
* in client/src/api/embyApi.ts).
|
*
|
||||||
|
* 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> {
|
async negotiateStreamTarget(session: EmbySession, itemId: string, isAudio: boolean, audioStreamIndex: string, maxStreamingBitrate: string): Promise<string> {
|
||||||
// AudioStreamIndex/MaxStreamingBitrate come from the player's
|
// Applied to the negotiation itself, not just the final URL - a pick
|
||||||
// language/quality pickers, applied to the negotiation itself, not
|
// could otherwise silently do nothing if Emby's negotiation didn't
|
||||||
// just the final URL - a pick could otherwise silently do nothing if
|
// apply the query-string override the same way its own web client's
|
||||||
// Emby's negotiation didn't apply the query-string override the same
|
// POST-with-DeviceProfile request would have. Still not trusted to
|
||||||
// way its own web client's POST-with-DeviceProfile request would
|
// have taken effect on its own, though (this app never sends that
|
||||||
// have. Still not trusted to have taken effect on its own, though
|
// body) - applyPlaybackOverrides runs a second time below, on
|
||||||
// (this app never sends that body) - applyPlaybackOverrides runs a
|
// whatever URL results, so a pick can't silently no-op regardless of
|
||||||
// second time below, on whatever URL results, so a pick can't
|
// which path Emby's response takes.
|
||||||
// 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>;
|
|
||||||
const infoParams = new URLSearchParams({ UserId: session.userId });
|
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 info = await this.fetchJson<PlaybackInfoResponse>(`/Items/${itemId}/PlaybackInfo?${infoParams}`, session, 'Impossible de préparer la lecture de ce titre.');
|
||||||
|
|
||||||
const source = (info.MediaSources || [])[0];
|
const source = (info.MediaSources || [])[0];
|
||||||
|
|
@ -326,15 +327,14 @@ class CatalogServiceClass {
|
||||||
// supplies the parameters PlaybackInfo reveals (MediaSourceId,
|
// supplies the parameters PlaybackInfo reveals (MediaSourceId,
|
||||||
// PlaySessionId, UserId) instead of guessing, but is still a
|
// PlaySessionId, UserId) instead of guessing, but is still a
|
||||||
// fallback, not the primary path.
|
// fallback, not the primary path.
|
||||||
const isAudio = query.mediaType === 'audio';
|
|
||||||
let target: string;
|
|
||||||
if (source.TranscodingUrl) {
|
if (source.TranscodingUrl) {
|
||||||
const [urlPath, urlQuery] = source.TranscodingUrl.split('?');
|
const [urlPath, urlQuery] = source.TranscodingUrl.split('?');
|
||||||
const params = new URLSearchParams(urlQuery || '');
|
const params = new URLSearchParams(urlQuery || '');
|
||||||
this.applyPlaybackOverrides(params, query);
|
this.applyPlaybackOverrides(params, audioStreamIndex, maxStreamingBitrate);
|
||||||
target = `${urlPath}?${params}`;
|
|
||||||
this.logStreamRequest(itemId, 'negotiated', params);
|
this.logStreamRequest(itemId, 'negotiated', params);
|
||||||
} else {
|
return `${urlPath}?${params}`;
|
||||||
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
UserId: session.userId,
|
UserId: session.userId,
|
||||||
MediaSourceId: source.Id || '',
|
MediaSourceId: source.Id || '',
|
||||||
|
|
@ -342,29 +342,9 @@ class CatalogServiceClass {
|
||||||
AudioCodec: 'aac',
|
AudioCodec: 'aac',
|
||||||
...(isAudio ? {} : { VideoCodec: 'h264' })
|
...(isAudio ? {} : { VideoCodec: 'h264' })
|
||||||
});
|
});
|
||||||
this.applyPlaybackOverrides(params, query);
|
this.applyPlaybackOverrides(params, audioStreamIndex, maxStreamingBitrate);
|
||||||
target = `/${isAudio ? 'Audio' : 'Videos'}/${itemId}/master.m3u8?${params}`;
|
|
||||||
this.logStreamRequest(itemId, 'fallback', params);
|
this.logStreamRequest(itemId, 'fallback', params);
|
||||||
}
|
return `/${isAudio ? 'Audio' : 'Videos'}/${itemId}/master.m3u8?${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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -375,11 +355,12 @@ class CatalogServiceClass {
|
||||||
* apply the query-string override the same way its own web client's
|
* apply the query-string override the same way its own web client's
|
||||||
* POST-with-DeviceProfile request would have. Subtitles used to be
|
* POST-with-DeviceProfile request would have. Subtitles used to be
|
||||||
* forced here too (SubtitleStreamIndex/SubtitleMethod=Encode) - see
|
* 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 {
|
private applyPlaybackOverrides(params: URLSearchParams, audioStreamIndex: string, maxStreamingBitrate: string): void {
|
||||||
if (query.AudioStreamIndex) params.set('AudioStreamIndex', query.AudioStreamIndex);
|
if (audioStreamIndex) params.set('AudioStreamIndex', audioStreamIndex);
|
||||||
if (query.MaxStreamingBitrate) params.set('MaxStreamingBitrate', query.MaxStreamingBitrate);
|
if (maxStreamingBitrate) params.set('MaxStreamingBitrate', maxStreamingBitrate);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deliberately logs only these specific override values, never the full
|
// Deliberately logs only these specific override values, never the full
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
import { Logger } from '../Logger';
|
import { Logger } from '../Logger';
|
||||||
|
import { PartyStreamService } from './PartyStreamService';
|
||||||
import type { AppServer, MusicCurrentPublic, MusicPublicState, MusicTrack, MusicTrackInput, Party } from '../types';
|
import type { AppServer, MusicCurrentPublic, MusicPublicState, MusicTrack, MusicTrackInput, Party } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -165,6 +166,10 @@ class MusicQueueServiceClass {
|
||||||
const music = party.music!;
|
const music = party.music!;
|
||||||
this.clearTimer(party);
|
this.clearTimer(party);
|
||||||
music.skipVotes.clear();
|
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();
|
const next = music.queue.shift();
|
||||||
if (!next) {
|
if (!next) {
|
||||||
music.current = null;
|
music.current = null;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import crypto from 'crypto';
|
||||||
import { Logger } from '../Logger';
|
import { Logger } from '../Logger';
|
||||||
import { MusicQueueService } from './MusicQueueService';
|
import { MusicQueueService } from './MusicQueueService';
|
||||||
import { TranscodeService } from './TranscodeService';
|
import { TranscodeService } from './TranscodeService';
|
||||||
|
import { PartyStreamService } from './PartyStreamService';
|
||||||
import type { LeaveResult, MediaType, Party, PartyMember, PartyPublicState, QueueItem } from '../types';
|
import type { LeaveResult, MediaType, Party, PartyMember, PartyPublicState, QueueItem } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -61,7 +62,9 @@ class PartyServiceClass {
|
||||||
lastActivityUtc: Date.now(),
|
lastActivityUtc: Date.now(),
|
||||||
members: new Map(),
|
members: new Map(),
|
||||||
burnInSubtitleIndex: null,
|
burnInSubtitleIndex: null,
|
||||||
burnInAudioIndex: null
|
burnInAudioIndex: null,
|
||||||
|
streamAudioIndex: null,
|
||||||
|
streamQuality: null
|
||||||
};
|
};
|
||||||
|
|
||||||
party.members.set(hostUserId, this.newMember(hostUserId, hostUserName));
|
party.members.set(hostUserId, this.newMember(hostUserId, hostUserName));
|
||||||
|
|
@ -167,6 +170,7 @@ class PartyServiceClass {
|
||||||
this.partiesById.delete(party.id);
|
this.partiesById.delete(party.id);
|
||||||
MusicQueueService.destroy(party); // clears the auto-advance timer, if any - see its own comment
|
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
|
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)`);
|
Logger.party(`destroyed partyId=${party.id} (last member ${whoLeft} left)`);
|
||||||
} else if (wasHost && newHostUserId != null) {
|
} else if (wasHost && newHostUserId != null) {
|
||||||
const newHost = party.members.get(newHostUserId)!;
|
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
|
// 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
|
// 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
|
// stream happens to share that index on the new item, not the same
|
||||||
// subtitle. TranscodeService.endSession also releases the old item's
|
// subtitle; same reasoning for streamAudioIndex (an audio *track*
|
||||||
// now-irrelevant ffmpeg process/disk usage immediately rather than
|
// index is just as item-specific). streamQuality is left alone -
|
||||||
// waiting for the idle reaper - see index.ts's own comment on why
|
// that's a bandwidth/quality preference, not tied to any one item,
|
||||||
// that's a backstop, not the normal path.
|
// 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.burnInSubtitleIndex = null;
|
||||||
party.burnInAudioIndex = null;
|
party.burnInAudioIndex = null;
|
||||||
|
party.streamAudioIndex = null;
|
||||||
TranscodeService.endSession(party.id);
|
TranscodeService.endSession(party.id);
|
||||||
|
PartyStreamService.endSession(party.id);
|
||||||
party.lastActivityUtc = Date.now();
|
party.lastActivityUtc = Date.now();
|
||||||
Logger.party(`advanced partyId=${party.id} to item="${next.itemName}" (${party.queueIndex + 1}/${party.queue.length})`);
|
Logger.party(`advanced partyId=${party.id} to item="${next.itemName}" (${party.queueIndex + 1}/${party.queue.length})`);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -225,6 +237,7 @@ class PartyServiceClass {
|
||||||
this.partiesById.delete(party.id);
|
this.partiesById.delete(party.id);
|
||||||
MusicQueueService.destroy(party); // same leak this class's own destroy path guards against
|
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
|
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);
|
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)`);
|
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,
|
music: party.mediaType === 'music' ? MusicQueueService.getState(party) : null,
|
||||||
burnInSubtitleIndex: party.burnInSubtitleIndex,
|
burnInSubtitleIndex: party.burnInSubtitleIndex,
|
||||||
burnInAudioIndex: party.burnInAudioIndex
|
burnInAudioIndex: party.burnInAudioIndex,
|
||||||
|
streamAudioIndex: party.streamAudioIndex,
|
||||||
|
streamQuality: party.streamQuality
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
139
server/src/services/PartyStreamService.ts
Normal file
139
server/src/services/PartyStreamService.ts
Normal 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();
|
||||||
|
|
@ -36,10 +36,20 @@ export interface ClientToServerEvents {
|
||||||
/** Host-only (checked server-side exactly like "next") - picks which
|
/** Host-only (checked server-side exactly like "next") - picks which
|
||||||
* audio/subtitle combination the party's *shared* burn-in stream uses,
|
* audio/subtitle combination the party's *shared* burn-in stream uses,
|
||||||
* see TranscodeService. A null subtitleIndex clears it, dropping the
|
* 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
|
* text subtitles (those stay the existing per-viewer client-side
|
||||||
* <track>, no server involvement) - see docs/architecture.md. */
|
* <track>, no server involvement) - see docs/architecture.md. */
|
||||||
setBurnInSubtitle: (msg: { subtitleIndex: string | null; audioIndex?: string }) => void;
|
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;
|
leave: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,6 +93,13 @@ export interface ServerToClientEvents {
|
||||||
* mid-party) reads instead - this event is only for someone already
|
* mid-party) reads instead - this event is only for someone already
|
||||||
* connected when it changes live. */
|
* connected when it changes live. */
|
||||||
burnInChanged: (msg: { subtitleIndex: string | null; audioIndex: string | null }) => void;
|
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;
|
errorMessage: (msg: { message: string }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -237,14 +254,25 @@ export interface Party {
|
||||||
music?: MusicState;
|
music?: MusicState;
|
||||||
/** Video only - the host's current pick for the party's *shared*
|
/** Video only - the host's current pick for the party's *shared*
|
||||||
* burn-in stream (see TranscodeService), null when nobody's picked one
|
* burn-in stream (see TranscodeService), null when nobody's picked one
|
||||||
* and the party is just using the normal per-viewer Emby-proxied
|
* and the party is just using its normal party-shared Emby stream
|
||||||
* stream. Both null or both set - never one without the other, see
|
* instead (PartyStreamService - also shared, just proxied rather than
|
||||||
* SocketGateway's "setBurnInSubtitle" handler. Persisted here (not
|
* transcoded by us). Both null or both set - never one without the
|
||||||
* just broadcast live) so a follower joining mid-party, or a page
|
* other, see SocketGateway's "setBurnInSubtitle" handler. Persisted
|
||||||
* reload, picks up the party's current choice from a plain
|
* 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. */
|
* "getParty" - same reason itemId/queueIndex live here too. */
|
||||||
burnInSubtitleIndex: string | null;
|
burnInSubtitleIndex: string | null;
|
||||||
burnInAudioIndex: 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 {
|
export interface PublicMember {
|
||||||
|
|
@ -276,6 +304,8 @@ export interface PartyPublicState {
|
||||||
music: MusicPublicState | null;
|
music: MusicPublicState | null;
|
||||||
burnInSubtitleIndex: string | null;
|
burnInSubtitleIndex: string | null;
|
||||||
burnInAudioIndex: string | null;
|
burnInAudioIndex: string | null;
|
||||||
|
streamAudioIndex: string | null;
|
||||||
|
streamQuality: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LobbyPartyDto {
|
export interface LobbyPartyDto {
|
||||||
|
|
|
||||||
|
|
@ -41,40 +41,51 @@ describe('CatalogService', () => {
|
||||||
assert.deepEqual(libraries.map((l) => l.name).sort(), ['Clips', 'Films', 'Musique', 'Series']);
|
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({
|
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
|
||||||
MediaSources: [{ Id: 'src1', TranscodingUrl: '/videos/item1/master.m3u8?MediaSourceId=src1&AudioStreamIndex=1' }],
|
MediaSources: [{ Id: 'src1', TranscodingUrl: '/videos/item1/master.m3u8?MediaSourceId=src1&AudioStreamIndex=1' }],
|
||||||
PlaySessionId: 'sess1'
|
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(target.includes('AudioStreamIndex=2'));
|
||||||
assert.ok(!capturedTarget.includes('Subtitle'), 'this route never touches subtitle params at all any more');
|
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' }));
|
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(target.startsWith('/Audio/track1/master.m3u8'));
|
||||||
assert.ok(!capturedTarget.includes('VideoCodec'));
|
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' }));
|
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(target.startsWith('/Videos/movie1/master.m3u8'));
|
||||||
assert.ok(capturedTarget.includes('VideoCodec=h264'));
|
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) => {
|
it('getRawItem preserves Emby\'s own status (a real 404), not a blanket 502', async (t) => {
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,9 @@ function fakeParty(id: string): Party {
|
||||||
lastActivityUtc: Date.now(),
|
lastActivityUtc: Date.now(),
|
||||||
members: new Map(),
|
members: new Map(),
|
||||||
burnInSubtitleIndex: null,
|
burnInSubtitleIndex: null,
|
||||||
burnInAudioIndex: null
|
burnInAudioIndex: null,
|
||||||
|
streamAudioIndex: null,
|
||||||
|
streamQuality: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
193
server/test/partyStreamService.test.ts
Normal file
193
server/test/partyStreamService.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -418,4 +418,57 @@ describe('SocketGateway', () => {
|
||||||
assert.equal(party.burnInSubtitleIndex, null);
|
assert.equal(party.burnInSubtitleIndex, null);
|
||||||
assert.ok(!io.roomEmits.some((e) => e.event === 'burnInChanged'));
|
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'));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue