Cast a party's video to a Chromecast
All checks were successful
Publier l'image Docker / test (push) Successful in 20s
Publier l'image Docker / publish (push) Successful in 16s

Implements a detailed spec (Google Cast / Chromecast integration) asking
for a member to cast the party's video to a Chromecast's Default Media
Receiver, with play/pause/seek shown as a remote on the sender's own
screen. The spec's description of this app's architecture (one shared
Emby stream per party, the server proxying 100% of Emby access) was
verified directly against the real code before building anything, since
it named specific existing services/routes as the integration points.

A Chromecast is a new kind of client for the existing party-stream/burn
relay: it can't carry the sender's httpOnly session cookie at all, so it
needed its own narrow way in without weakening "the server holds the
only real Emby token" the rest of the app is built around.

- New CastTokenService (same shape as AuthService: in-memory Map, opaque
  crypto.randomUUID() keys, TTL-checked) issues a token scoped to one
  {partyId, userId}, standing in for that member's own EmbySession.
  New POST/DELETE /api/parties/:partyId/cast-token to mint/revoke one.
- The 4 routes a Chromecast actually needs (party-stream/master.m3u8,
  party-stream/raw, burn/master.m3u8, burn/:segment) gained one small
  fallback (sessionForStream in index.ts): try the session cookie first,
  same as ever, then a castToken query param - either way the result is
  the same EmbySession type, so PartyStreamService/TranscodeService/
  EmbyClient needed zero changes. The image/subtitle routes stay
  cookie-only, outside the spec's own 4-route list.
- PartyService.leave() now revokes whatever cast token the leaving
  member held, unconditionally - a token dies the instant its owner
  actually leaves the party regardless of TTL or whether the client got
  a chance to call the explicit stop-casting route.
- New client/src/cast.ts is the only module touching the CAF Sender SDK
  (loaded as a pinned CDN script, same pattern as hls.js/socket.io) -
  exposes a small PlaybackDriver interface (currentTime/paused/play/
  pause) with two implementations, one over the local <video>, one over
  CAF's RemotePlayer/RemotePlayerController. This was the only new
  abstraction the feature needed, not a new sync mechanism: Player.ts's
  existing sendLocalState/applyRemoteState already read and write "the
  player" in one place each, so pointing them at whichever driver is
  active carried the host-only gate, drift-tolerance reconciliation, and
  3s heartbeat over unchanged. Seek was already "the host's next
  broadcast" with no separate event, so seeking on the TV is party-wide
  for free; a non-host caster already only ever received state, so
  redirecting that to the cast driver reproduces "still dependent on the
  host" automatically.
- Cast button in the player's action bar, shown to any member (not just
  the host) once cast.framework reports a receiver on the network.
  Starting a cast tears down the local hls.js instance; ending one
  (user-stopped, or the receiver dropping off on its own) resyncs local
  playback via the same loadVideo path an audio/quality change already
  uses. A real reload for any other reason (item change, burn-in/
  subtitle/stream-options change) simply ends an active cast first,
  rather than keeping a live session in sync with every kind of party
  transition - not worth the complexity for v1.

Scoped to video parties only - the spec's every concrete reference is
video-specific, and MusicBar.ts has no comparable control surface today.

Tests: CastTokenService (issue/resolve/reissue-invalidates-the-old-one/
revoke/wrong-party-rejection), PartyService.leave()'s new call. Full
typecheck/test/build clean on both projects (115/115 server tests).
Confirmed against the real running server that the 4 streaming routes
still reject a request with no auth exactly as before, and now also
reject an invalid castToken - both a plain 401, no regression. Confirmed
in a real browser that the Cast SDK loads and initializes correctly. Not
verified: no real Chromecast or Emby login credentials in this
environment to take a cast session, or the button's own live behavior,
any further - see docs/architecture.md's "Casting to a Chromecast"
section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-15 10:13:12 +02:00
parent b167e321e7
commit b4c81d820c
15 changed files with 747 additions and 15 deletions

View file

@ -131,6 +131,16 @@ surfaces as an HTTP failure the way the cache-invalidation logic
assumes, how a real transcode's seek handling feels once several assumes, how a real transcode's seek handling feels once several
viewers depend on the exact same one). viewers depend on the exact same one).
Casting to a Chromecast (see docs/architecture.md) is the newest,
least-verified piece: the security-critical parts were checked against
the real running server directly (the party-stream/burn routes reject a
request with no auth exactly as before, and now also reject an invalid
`castToken`, both a plain 401) and the Cast SDK itself was confirmed to
load and initialize in a real browser, but there's neither a real
Chromecast nor Emby login credentials in this environment to take it
any further - the actual on-TV playback and the cast button's own
live behavior are unconfirmed.
## Project layout ## Project layout
Both `server/` and `client/` are TypeScript now (see Both `server/` and `client/` are TypeScript now (see
@ -289,7 +299,12 @@ Then put it behind your existing reverse proxy like everything else
- [x] Fixed catalog browsing feeling slow: image thumbnails were never - [x] Fixed catalog browsing feeling slow: image thumbnails were never
cacheable (Emby's own Cache-Control/ETag were silently dropped) — cacheable (Emby's own Cache-Control/ETag were silently dropped) —
see docs/architecture.md see docs/architecture.md
- [x] Cast a party's video to a Chromecast (Default Media Receiver),
with a play/pause/seek remote on the sender's own screen — 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)
- [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
confirmed here — no real device/session)
- [ ] Tune transcoding target / support direct play when possible - [ ] Tune transcoding target / support direct play when possible
No chat, no OIDC — deliberately out of scope (Emby-only auth was the No chat, no OIDC — deliberately out of scope (Emby-only auth was the

View file

@ -53,6 +53,25 @@ export function addTrackToQueue(partyId: string, track: MusicTrackInput): Promis
return apiFetch(`/api/parties/${partyId}/tracks`, { method: 'POST', body: JSON.stringify(track) }); return apiFetch(`/api/parties/${partyId}/tracks`, { method: 'POST', body: JSON.stringify(track) });
} }
/**
* Mints a short-lived, party-scoped token a Chromecast can use to reach
* this party's own shared stream routes, which otherwise only accept the
* sender's httpOnly session cookie - something a Chromecast can never
* carry (see server/src/services/CastTokenService.ts). Called right
* before starting a cast - see cast.ts/Player.ts.
*/
export function issueCastToken(partyId: string): Promise<{ token: string }> {
return apiFetch(`/api/parties/${partyId}/cast-token`, { method: 'POST' });
}
/** Invalidates whatever cast token this member currently holds for this
* party - called when a cast session ends, from either side (the user
* stopping it here, or the receiver itself ending it - see
* Player.ts/cast.ts's onCastSessionEnded). Harmless if nothing was cast. */
export function revokeCastToken(partyId: string): Promise<{ ok: true }> {
return apiFetch(`/api/parties/${partyId}/cast-token`, { method: 'DELETE' });
}
/** /**
* Live "watch parties en cours" feed for the home page. `onParties` gets * Live "watch parties en cours" feed for the home page. `onParties` gets
* called with the full current list on subscribe, and again every time any * called with the full current list on subscribe, and again every time any

159
client/src/cast.ts Normal file
View file

@ -0,0 +1,159 @@
/**
* Google Cast (Chromecast) sender integration - the only module that talks
* to the Cast Application Framework SDK directly (a CDN global, loaded via
* client/src/index.html's <script async> - see global.d.ts for its ambient
* types, same pattern as hls.js/socket.io). Exposes a small PlaybackDriver
* interface with two implementations - one over the local <video>, one
* over CAF's RemotePlayer/RemotePlayerController - so Player.ts's own sync
* logic (sendLocalState/applyRemoteState) can read/write "the player"
* without caring which one is actually driving playback right now. See
* docs/architecture.md's "Casting to a Chromecast" section for the full
* design and why this is the only new abstraction that was needed.
*/
/** What Player.ts's sync logic needs from a player, whether that's the
* local <video> element or a Chromecast's RemotePlayer. */
export interface PlaybackDriver {
currentTime: number;
readonly paused: boolean;
play(): void;
pause(): void;
}
/** The local <video>, wrapped in the same shape a cast driver uses -
* trivial, since a <video> element already looks like this. */
export function localDriver(video: HTMLVideoElement): PlaybackDriver {
return {
get currentTime() { return video.currentTime; },
set currentTime(seconds: number) { video.currentTime = seconds; },
get paused() { return video.paused; },
play() { video.play().catch(() => {}); },
pause() { video.pause(); }
};
}
let initialized = false;
let currentlyAvailable = false;
const availabilityListeners = new Set<(available: boolean) => void>();
const sessionEndedListeners = new Set<() => void>();
function setAvailable(available: boolean): void {
currentlyAvailable = available;
availabilityListeners.forEach((cb) => cb(available));
}
/** Only ever called once the SDK has confirmed it's loaded (see initCast
* below) - window.cast/window.chrome.cast are guaranteed present here,
* per the Cast SDK's own contract for __onGCastApiAvailable(true). */
function onSdkReady(): void {
const framework = window.cast!.framework;
const context = framework.CastContext.getInstance();
context.setOptions({
receiverApplicationId: window.chrome!.cast!.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
autoJoinPolicy: window.chrome!.cast!.AutoJoinPolicy.ORIGIN_SCOPED
});
// The event listener alone only reports the *next* change - a device
// already on the network before this runs needs this one explicit
// read too, or the cast button would stay hidden until something else
// happened to change availability.
setAvailable(context.getCastState() !== framework.CastState.NO_DEVICES_AVAILABLE);
context.addEventListener(framework.CastContextEventType.CAST_STATE_CHANGED, (event) => {
setAvailable(event.castState !== framework.CastState.NO_DEVICES_AVAILABLE);
});
context.addEventListener(framework.CastContextEventType.SESSION_STATE_CHANGED, (event) => {
if (event.sessionState === framework.SessionState.SESSION_ENDED) {
sessionEndedListeners.forEach((cb) => cb());
}
});
}
/**
* Registers the CAF-required global callback and configures the default
* receiver (no custom receiver app, no Google Cast Developer Console
* registration needed - see docs/architecture.md). Safe to call more than
* once (e.g. every time a video party mounts): only the first call
* actually does anything. Handles both possible orderings against the
* SDK's own <script async> tag - the callback firing after this runs (the
* normal case), or the SDK having already loaded and fired it before this
* ever got a chance to register (a fast network, or a second party opened
* in the same page load).
*/
export function initCast(): void {
if (initialized) return;
initialized = true;
if (window.chrome?.cast?.isAvailable) {
onSdkReady();
} else {
window.__onGCastApiAvailable = (isAvailable) => { if (isAvailable) onSdkReady(); };
}
}
/**
* Reports whether a Cast device is currently on the network - immediately
* with whatever's already known (false until the SDK's finished loading
* and found one), then again on every change. Returns an unsubscribe
* function - callers MUST call it from their own cleanup (see Player.ts's
* router-driven cleanup pattern - this app has a real history of leaked
* listeners/timers, see docs/architecture.md) so this module's listener
* set never outlives the component that registered it.
*/
export function onCastAvailabilityChanged(listener: (available: boolean) => void): () => void {
availabilityListeners.add(listener);
listener(currentlyAvailable);
return () => availabilityListeners.delete(listener);
}
/** Fires whenever the current Cast session ends, however that happens -
* the user stopping it from this page, from the Chromecast's own remote,
* or the receiver dropping off the network. Same unsubscribe contract as
* onCastAvailabilityChanged above. */
export function onCastSessionEnded(listener: () => void): () => void {
sessionEndedListeners.add(listener);
return () => sessionEndedListeners.delete(listener);
}
/**
* Starts casting `contentUrl` (an absolute HLS URL carrying its own
* castToken - see Player.ts and CastTokenService) and returns a
* PlaybackDriver over the resulting RemotePlayer. Throws if the user
* cancels the device picker or the load fails - the caller is expected to
* just leave the local <video> driving in that case, same as any other
* "the thing the user tried didn't work" path in this app.
* `onRemoteStateChanged` fires whenever the TV side's own play/pause/seek
* state changes (the physical remote, or the receiver's own on-screen
* controls) - Player.ts uses it exactly like the local <video>'s
* 'play'/'pause'/'seeked' events, to know when to re-broadcast as host.
*/
export async function startCast(contentUrl: string, title: string, onRemoteStateChanged: () => void): Promise<PlaybackDriver> {
const framework = window.cast!.framework;
const context = framework.CastContext.getInstance();
await context.requestSession();
const session = context.getCurrentSession();
if (!session) throw new Error('Aucune session Cast active après requestSession().');
const mediaInfo = new window.chrome!.cast!.media.MediaInfo(contentUrl, 'application/vnd.apple.mpegurl');
mediaInfo.metadata = { title };
await session.loadMedia(new window.chrome!.cast!.media.LoadRequest(mediaInfo));
const player = new framework.RemotePlayer();
const controller = new framework.RemotePlayerController(player);
controller.addEventListener(framework.RemotePlayerEventType.IS_PAUSED_CHANGED, onRemoteStateChanged);
controller.addEventListener(framework.RemotePlayerEventType.CURRENT_TIME_CHANGED, onRemoteStateChanged);
return {
get currentTime() { return player.currentTime; },
set currentTime(seconds: number) { player.currentTime = seconds; controller.seek(); },
get paused() { return player.isPaused; },
play() { if (player.isPaused) controller.playOrPause(); },
pause() { if (!player.isPaused) controller.playOrPause(); }
};
}
/** Ends the current Cast session (if any) - called when the user clicks
* "stop casting". A harmless no-op if nothing is currently cast. */
export function stopCast(): void {
window.cast?.framework.CastContext.getInstance().getCurrentSession()?.endSession(true);
}

View file

@ -67,3 +67,70 @@ declare const Hls: HlsStatic | undefined;
interface Window { interface Window {
Hls?: HlsStatic; Hls?: HlsStatic;
} }
// --- Google Cast Sender SDK (CDN global, see client/src/index.html) --------
// Minimal shape covering only what client/src/cast.ts actually calls - not
// the full Cast Application Framework API surface. Accessed only via
// `window.chrome`/`window.cast` there (never the bare identifiers), same
// reasoning as `window.Hls` above: neither is guaranteed to exist at all
// outside a Chromium-family browser, and a bare undeclared identifier read
// (unlike a property read off `window`) throws instead of yielding undefined.
interface CastMediaInfo {
metadata?: { title?: string };
}
interface CastSession {
loadMedia(request: unknown): Promise<void>;
endSession(stopCasting: boolean): void;
}
interface CastContextEvent {
castState?: string;
sessionState?: string;
}
interface CastContext {
setOptions(options: { receiverApplicationId: string; autoJoinPolicy: string }): void;
requestSession(): Promise<void>;
getCurrentSession(): CastSession | null;
getCastState(): string;
addEventListener(type: string, handler: (event: CastContextEvent) => void): void;
}
interface RemotePlayer {
isPaused: boolean;
currentTime: number;
}
interface RemotePlayerController {
playOrPause(): void;
seek(): void;
addEventListener(type: string, handler: () => void): void;
}
interface CastFramework {
CastContext: { getInstance(): CastContext };
CastContextEventType: { CAST_STATE_CHANGED: string; SESSION_STATE_CHANGED: string };
CastState: { NO_DEVICES_AVAILABLE: string };
SessionState: { SESSION_ENDED: string };
RemotePlayer: new () => RemotePlayer;
RemotePlayerController: new (player: RemotePlayer) => RemotePlayerController;
RemotePlayerEventType: { IS_PAUSED_CHANGED: string; CURRENT_TIME_CHANGED: string };
}
interface Window {
chrome?: {
cast?: {
isAvailable?: boolean;
AutoJoinPolicy: { ORIGIN_SCOPED: string };
media: {
DEFAULT_MEDIA_RECEIVER_APP_ID: string;
MediaInfo: new (contentId: string, contentType: string) => CastMediaInfo;
LoadRequest: new (mediaInfo: CastMediaInfo) => unknown;
};
};
};
cast?: { framework: CastFramework };
__onGCastApiAvailable?: (isAvailable: boolean) => void;
}

View file

@ -7,6 +7,11 @@
<link rel="stylesheet" href="app.css" /> <link rel="stylesheet" href="app.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.17/hls.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.17/hls.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.5/socket.io.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.5/socket.io.min.js"></script>
<!-- Google Cast (Chromecast) sender SDK - see client/src/cast.ts. Not on
cdnjs/versioned like the two above: Google only ever serves this from
its own gstatic host, calling window.__onGCastApiAvailable once ready
rather than being usable synchronously right after load. -->
<script src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1" async></script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View file

@ -62,6 +62,15 @@
.iconBtn:hover { background: rgba(255, 255, 255, .12); } .iconBtn:hover { background: rgba(255, 255, 255, .12); }
/* #castBtn while actively casting - see Player.ts's toggleCast. Same
"clearly-on" treatment as .avatarActionRequested's orange, not just a
background tint, so it reads at a glance whether the TV or this screen
is the one actually playing right now. */
.iconBtn[aria-pressed="true"] {
color: #ff9800;
background: rgba(255, 152, 0, .18);
}
.settingsPanel { .settingsPanel {
position: absolute; position: absolute;
top: 100%; top: 100%;

View file

@ -13,6 +13,15 @@
<span id="members" class="members"></span> <span id="members" class="members"></span>
<button id="nextBtn" class="linklike hide">Épisode suivant &rarr;</button> <button id="nextBtn" class="linklike hide">Épisode suivant &rarr;</button>
<button id="castBtn" class="iconBtn hide" title="Caster sur un Chromecast" aria-label="Caster sur un Chromecast" aria-pressed="false">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<rect x="1" y="4" width="22" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2"></rect>
<path d="M4 15a5 5 0 0 1 5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
<path d="M4 11a9 9 0 0 1 9 9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
<circle cx="4" cy="20" r="1.5" fill="currentColor"></circle>
</svg>
</button>
<div id="settingsWrap" class="settingsWrap hide"> <div id="settingsWrap" class="settingsWrap hide">
<button id="settingsBtn" class="iconBtn" title="Paramètres de lecture" aria-label="Paramètres de lecture">&#9881;</button> <button id="settingsBtn" class="iconBtn" title="Paramètres de lecture" aria-label="Paramètres de lecture">&#9881;</button>
<div id="settingsPanel" class="settingsPanel hide"> <div id="settingsPanel" class="settingsPanel hide">
@ -42,6 +51,7 @@
<div class="bottomOverlay"> <div class="bottomOverlay">
<div id="requestBanner" class="requestBanner hide"></div> <div id="requestBanner" class="requestBanner hide"></div>
<p id="followerNotice" class="followerNotice hide">L'hôte contrôle la lecture.</p> <p id="followerNotice" class="followerNotice hide">L'hôte contrôle la lecture.</p>
<p id="castingNotice" class="followerNotice hide">Diffusion en cours sur le Chromecast.</p>
</div> </div>
<div id="requestToolbar" class="requestToolbar hide"> <div id="requestToolbar" class="requestToolbar hide">

View file

@ -2,7 +2,7 @@ import '../../styles/presence.css';
import './Player.css'; import './Player.css';
import html from './Player.html'; import html from './Player.html';
import { auth, burnInStreamUrl, getPlaybackOptions, partyStreamUrl, subtitleUrl } from '../../api/embyApi'; import { auth, burnInStreamUrl, getPlaybackOptions, partyStreamUrl, subtitleUrl } from '../../api/embyApi';
import { getParty, leaveParty, connectParty } from '../../api/party'; import { getParty, leaveParty, connectParty, issueCastToken, revokeCastToken } 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';
@ -10,6 +10,8 @@ import { colorForName, escapeHtml, initialsFor } from '../../tiles';
import { LOADING_HINT } from '../../loading'; import { LOADING_HINT } from '../../loading';
import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequestedMsg, PlaybackOptionsDto, ResumeRequestedMsg, SocketMember, StreamOptionsChangedMsg, TextSubtitleChangedMsg } from '../../types'; import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequestedMsg, PlaybackOptionsDto, ResumeRequestedMsg, SocketMember, StreamOptionsChangedMsg, TextSubtitleChangedMsg } from '../../types';
import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar'; import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar';
import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../../cast';
import type { PlaybackDriver } from '../../cast';
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
const DRIFT_TOLERANCE_SECONDS = 1.5; const DRIFT_TOLERANCE_SECONDS = 1.5;
@ -146,6 +148,8 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
const subtitleSelect = root.querySelector('#subtitleSelect') as HTMLSelectElement; const subtitleSelect = root.querySelector('#subtitleSelect') as HTMLSelectElement;
const qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement; const qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement;
const burnInHint = root.querySelector('#burnInHint')!; const burnInHint = root.querySelector('#burnInHint')!;
const castBtn = root.querySelector('#castBtn') as HTMLButtonElement;
const castingNotice = root.querySelector('#castingNotice')!;
qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => `<option value="${q.value}">${q.label}</option>`).join(''); qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => `<option value="${q.value}">${q.label}</option>`).join('');
@ -203,6 +207,71 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
// - 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.
let lastPlaybackOptions: PlaybackOptionsDto | null = null; let lastPlaybackOptions: PlaybackOptionsDto | null = null;
// "The player" sendLocalState/applyRemoteState actually read and write -
// the local <video> by default, swapped for a Chromecast's RemotePlayer
// while casting (see toggleCast/cast.ts). Casting is available to any
// member, not just the host - a non-host caster still only ever
// *receives* applyRemoteState, same as their local <video> already did
// (see docs/architecture.md's "Casting to a Chromecast" section).
let activeDriver: PlaybackDriver = localDriver(video);
let isCasting = false;
function stopCasting() {
if (!isCasting) return;
isCasting = false;
castBtn.setAttribute('aria-pressed', 'false');
castingNotice.classList.add('hide');
activeDriver = localDriver(video);
stopCast();
revokeCastToken(partyId).catch(() => {});
}
// Used by both the cast button's own "stop casting" click and a session
// ending on its own (the receiver's remote, the TV losing power) -
// captures wherever the TV left off before tearing the cast driver
// down, then rebuilds local playback from there via the same loadVideo
// path any other reload already uses (audio/quality change, "Réessayer").
function endCastAndResync() {
const resumeSeconds = activeDriver.currentTime;
const wasPlaying = !activeDriver.paused;
stopCasting();
if (currentItemId) loadVideo(currentItemId, resumeSeconds, wasPlaying);
}
/**
* Starts casting the party's current shared stream - available to any
* member, not just the host (see docs/architecture.md). Mints a
* short-lived castToken (CastTokenService) scoping the Chromecast into
* the exact same party-stream/burn relay every other client already
* uses - never a second Emby stream, and the real Emby token never
* leaves this server. On success, tears down the local hls.js/`<video>`
* (nothing left for this device to decode while the TV does) and
* re-anchors the party to wherever the TV actually started, same as any
* other driver swap.
*/
async function startCasting() {
try {
const { token } = await issueCastToken(partyId);
const streamPath = burnInSubtitleIndex ? burnInStreamUrl(partyId) : partyStreamUrl(partyId);
const contentUrl = `${location.origin}${streamPath}?castToken=${encodeURIComponent(token)}`;
const driver = await startCast(contentUrl, itemTitleEl.textContent || 'Watch Party', sendLocalState);
if (hls) { hls.destroy(); hls = null; }
video.pause();
activeDriver = driver;
isCasting = true;
castBtn.setAttribute('aria-pressed', 'true');
castingNotice.classList.remove('hide');
sendLocalState();
} catch (err) {
// The user cancelling the device picker rejects requestSession() the
// same way a real failure does - neither is worth an alert() here,
// just leave the local <video> driving, same as any other "the thing
// the user tried didn't work" path in this app.
console.error('[watchparty player] cast failed', err);
revokeCastToken(partyId).catch(() => {});
}
}
function showError(message: string, detail?: unknown) { function showError(message: string, detail?: unknown) {
loadingNotice.classList.add('hide'); loadingNotice.classList.add('hide');
@ -218,17 +287,23 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
function applyRemoteState(msg: { isPaused: boolean; positionTicks: number }) { function applyRemoteState(msg: { isPaused: boolean; positionTicks: number }) {
suppressLocalEvents = true; suppressLocalEvents = true;
const targetSeconds = msg.positionTicks / TICKS_PER_SECOND; const targetSeconds = msg.positionTicks / TICKS_PER_SECOND;
if (Math.abs(video.currentTime - targetSeconds) > DRIFT_TOLERANCE_SECONDS) { if (Math.abs(activeDriver.currentTime - targetSeconds) > DRIFT_TOLERANCE_SECONDS) {
video.currentTime = targetSeconds; activeDriver.currentTime = targetSeconds;
} }
if (msg.isPaused && !video.paused) video.pause(); if (msg.isPaused && !activeDriver.paused) activeDriver.pause();
if (!msg.isPaused && video.paused) video.play().catch(() => {}); if (!msg.isPaused && activeDriver.paused) activeDriver.play();
setTimeout(() => { suppressLocalEvents = false; }, 250); setTimeout(() => { suppressLocalEvents = false; }, 250);
} }
// Reads/writes activeDriver, not `video` directly - the local <video>
// element or a Chromecast's RemotePlayer, whichever is actually
// driving playback right now (see toggleCast/cast.ts). Both the local
// <video>'s own play/pause/seeked listeners (becomeHost, below) and a
// cast session's RemotePlayerController events (startCast's own
// onRemoteStateChanged callback) funnel through this same function.
function sendLocalState() { function sendLocalState() {
if (!isHost || suppressLocalEvents || !conn) return; if (!isHost || suppressLocalEvents || !conn) return;
conn.sendPlaystate(video.paused, Math.round(video.currentTime * TICKS_PER_SECOND)); conn.sendPlaystate(activeDriver.paused, Math.round(activeDriver.currentTime * TICKS_PER_SECOND));
} }
/** /**
@ -389,6 +464,15 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
} }
function loadVideo(itemId: string, resumeSeconds: number, wasPlaying: boolean) { function loadVideo(itemId: string, resumeSeconds: number, wasPlaying: boolean) {
// A real reload (a new item, a burn-in/subtitle/stream-options change,
// "Réessayer") always means going back to local playback - keeping a
// live cast session in sync with every one of those transitions
// instead was judged not worth the complexity for v1 (see
// docs/architecture.md's "Casting to a Chromecast" section); casting
// again afterward is one click away, and CAF's own ORIGIN_SCOPED
// auto-join policy (see cast.ts) means that click typically reconnects
// instead of prompting a fresh device picker.
stopCasting();
currentItemId = itemId; currentItemId = itemId;
clearError(); clearError();
loadingNotice.classList.remove('hide'); loadingNotice.classList.remove('hide');
@ -494,6 +578,26 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
if (currentItemId) loadVideo(currentItemId, video.currentTime, true); if (currentItemId) loadVideo(currentItemId, video.currentTime, true);
}); });
initCast();
// Visible to any member the moment a Chromecast is on the network - not
// host-gated, unlike the settings picker above (see startCasting's own
// comment). Both subscriptions return an unsubscribe function this
// component MUST call on cleanup (below) - see cast.ts's own comment on
// why, and this app's general history with leaked listeners/timers
// (docs/architecture.md).
const unsubscribeCastAvailability = onCastAvailabilityChanged((available) => {
castBtn.classList.toggle('hide', !available);
});
// Fires whichever way a cast session actually ends - the Chromecast's
// own remote, the TV losing power, or this same click handler ending it
// (harmless to also reach this via that path - stopCasting()/
// endCastAndResync() below both just no-op if isCasting is already false).
const unsubscribeCastSessionEnded = onCastSessionEnded(endCastAndResync);
castBtn.addEventListener('click', () => {
if (isCasting) endCastAndResync();
else startCasting();
});
async function onItemChanged(msg: ItemChangedMsg) { async function onItemChanged(msg: ItemChangedMsg) {
itemTitleEl.textContent = msg.itemName || ''; itemTitleEl.textContent = msg.itemName || '';
hasNextQueued = !!msg.hasNext; hasNextQueued = !!msg.hasNext;
@ -715,6 +819,12 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
if (requestBannerTimer) clearTimeout(requestBannerTimer); if (requestBannerTimer) clearTimeout(requestBannerTimer);
if (hls) hls.destroy(); if (hls) hls.destroy();
document.removeEventListener('click', onDocumentClick); document.removeEventListener('click', onDocumentClick);
unsubscribeCastAvailability();
unsubscribeCastSessionEnded();
// Deliberately NOT stopping an active cast here - leaving this page
// must not yank the stream off the TV; casting is meant to keep
// going independently of the sender tab, same as any other
// Chromecast-enabled app (see docs/architecture.md).
conn?.leave(); conn?.leave();
leaveParty(partyId).catch(() => {}); leaveParty(partyId).catch(() => {});
}; };

View file

@ -2394,6 +2394,132 @@ correctly inline with text in both a plain `.hint` and the player's
sampled twice, 300ms apart, showed a different `transform` matrix each sampled twice, 300ms apart, showed a different `transform` matrix each
time). time).
## Casting to a Chromecast
Asked for directly, with a detailed spec naming the exact services/files
to touch: let a party member cast the party's video onto a Chromecast
(Google's Default Media Receiver - no custom receiver, so no Google Cast
Developer Console app registration needed at all) with play/pause/seek
shown as a remote on the sender's own screen. The spec's description of
this app's architecture was verified directly against the real code
before building anything: every party already streams through one
shared, party-keyed relay (`PartyStreamService`/`TranscodeService`,
proxied via `EmbyClient`, see "One shared Emby stream per party" above)
as standard HLS, and the server proxies 100% of Emby access - the
browser never holds an Emby token, only the `wp_session` cookie (see
"Goals"/`AuthService`). A Chromecast is a new *kind* of client for that
relay: it can't carry the sender's httpOnly cookie at all, so it needed
its own narrow way in without weakening "the server holds the only real
Emby token" that the rest of the app is built around.
**Scoped to video parties only** - the spec's every concrete reference
(the player, the party-stream/burn routes, the burn-in fallback) is
video-specific, and `MusicBar.ts` has no comparable control surface
today. Casting a listen-party would be a fast follow, not this.
**`CastTokenService`** (new, same shape as `AuthService`: an in-memory
`Map`, opaque `crypto.randomUUID()` keys, TTL-checked on read) issues a
short-lived token scoped to one `{partyId, userId}`, standing in for
that member's own already-authenticated `EmbySession` wherever a
Chromecast needs to reach the party's stream. Two new routes:
`POST /api/parties/:partyId/cast-token` (normal cookie auth, mints one)
and `DELETE /api/parties/:partyId/cast-token` (revokes it - called when
a cast session ends). The 4 routes a Chromecast actually needs to reach
- `party-stream/:partyId/master.m3u8`/`raw`, `burn/:partyId/master.m3u8`/
`:segment` - gained one small fallback (`sessionForStream` in `index.ts`):
try the session cookie first, same as ever, then a `castToken` query
param. Either way the result is the exact same `EmbySession` type every
other route already uses, so nothing downstream
(`PartyStreamService`/`TranscodeService`/`EmbyClient`) had to change at
all. The image/subtitle routes were deliberately left cookie-only -
outside the spec's own 4-route list, and a Chromecast's Default Receiver
has no use for either here.
A token's TTL (12h) is deliberately generous - it's only a backstop.
The real invalidation is explicit and unconditional: `PartyService
.leave()` already runs a few unconditional per-leaving-member cleanups
(`MusicQueueService.removeVote`, right after the member is removed from
the map) - `CastTokenService.revokeForMember` joined that list, so a
token dies the instant its owner actually leaves the party, whether or
not the client ever got a chance to call the explicit "stop casting"
route (a crashed tab, a Chromecast that just loses power). Even a token
that somehow outlived both of those is no more powerful than "this
party member's own streaming access" - every one of the 4 routes still
separately checks `party.members.has(session.userId)`, exactly like a
cookie session already does.
**The sender side** loads the CAF Sender SDK the same way `hls.js`/
`socket.io` already are - a pinned CDN `<script>` (Google only ever
serves this from its own `gstatic` host, not a versioned CDN like the
other two, and it signals readiness via a global callback rather than
being usable synchronously right after load). New `client/src/cast.ts`
is the only module that touches the SDK directly, and exposes a small
`PlaybackDriver` interface (`currentTime`, `paused`, `play()`,
`pause()`) with two implementations - one over the local `<video>`, one
over CAF's `RemotePlayer`/`RemotePlayerController` (which only ever
*pushes* state via events, so the wrapper caches it behind plain
getters). This turned out to be the **only** new abstraction the whole
feature needed, not a new sync mechanism: `Player.ts`'s existing
`sendLocalState`/`applyRemoteState` already read and write "the player"
in exactly one place each, so pointing them at whichever driver is
active - instead of `video` directly - carried over the host-only gate,
the drift-tolerance reconciliation, and the 3s heartbeat unchanged for
either one. Seek was already "the host's next state broadcast" with no
separate seek event, so seeking on the TV is already party-wide for
free. A non-host caster already only ever *received* `applyRemoteState`
(the host-only check in `sendLocalState` already blocks a follower from
pushing) - redirecting that to the cast driver instead of the local
`<video>` reproduces "still dependent on the host" automatically, with
zero cast-specific code for either of those two behaviors.
Starting a cast: `POST cast-token`, build an absolute
`partyStreamUrl`/`burnInStreamUrl` carrying it as `?castToken=...`,
`requestSession()` → `loadMedia()`, then tear down the local `hls.js`
instance (`hls.destroy()` - the same full teardown-and-rebuild this app
already uses for every other stream-parameter change, not a new
pause-in-place path) and swap the active driver. Ending one - the user
stopping it, or CAF's own `SESSION_ENDED` firing because the receiver
dropped off on its own - swaps back and calls the *existing*
`loadVideo(itemId, driver.currentTime, !driver.paused)` to resync,
reusing the exact path an audio/quality change or "Réessayer" already
uses rather than inventing a second one. A genuinely new reload for any
other reason (an item change, a burn-in/subtitle/stream-options change)
simply ends an active cast first rather than trying to keep a live
session in sync with every kind of party transition - judged not worth
the added complexity for v1; re-casting afterward is one click, and
CAF's `ORIGIN_SCOPED` auto-join policy means that click typically
reconnects to the same receiver instead of prompting a fresh device
picker. One related gap, also accepted for v1: reopening the same
party page after navigating away mid-cast (an SPA route change, not a
reload) doesn't try to detect and re-attach to a still-live session -
the new page mount just starts from local playback again.
Security, checked against the spec's own constraints: the real Emby
token never reaches the Chromecast (only the opaque `castToken` ever
appears in `contentId`, by construction - same as every other proxied
route); no second Emby stream is ever opened for a cast (it hits the
exact same party-keyed cache everything else does); a cast token is
only ever consulted by `sessionForStream`'s 4 call sites - `requireSession`,
used by every other route, is untouched.
Verified: full `typecheck`/`test`/`build` on both projects: unit tests
for `CastTokenService` (issue/resolve/reissue-invalidates-the-old-one/
revoke/wrong-party-rejection) and for `PartyService.leave()`'s new
call. Confirmed against the real running server (no login needed for
this part) that the 4 streaming routes still reject a request with no
auth at all exactly as before, and now also reject one with an invalid
`castToken` - both a plain `401`, no regression on the existing cookie
path. Confirmed in a real browser that the Cast SDK's `<script>` loads
and initializes correctly (`window.cast`/`window.chrome.cast` both
populated) with no console errors. **Not verified**: this sandbox has
no real Chromecast to finish a `requestSession()`/`loadMedia()` round
trip against, and no Emby login credentials to reach the player page at
all - so the actual on-TV playback, the cast button's live show/hide
behavior, and whether the Default Receiver truly renders this app's HLS
output cleanly are all unconfirmed, the same way this project already
flags its other can't-verify-without-the-real-thing assumptions. Worth
trying with a real device once deployed.
## Known limitations ## Known limitations
- Playback always goes through `hls.js` against whatever Emby's - Playback always goes through `hls.js` against whatever Emby's

View file

@ -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/partyStreamService.test.ts test/playstateService.test.ts test/socketGateway.test.ts test/transcodeService.test.ts" "test": "tsx --test test/castTokenService.test.ts 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"

View file

@ -14,6 +14,7 @@ import { MusicQueueService } from './services/MusicQueueService';
import { TranscodeService } from './services/TranscodeService'; import { TranscodeService } from './services/TranscodeService';
import { PartyStreamService } from './services/PartyStreamService'; import { PartyStreamService } from './services/PartyStreamService';
import { PlaystateService } from './services/PlaystateService'; import { PlaystateService } from './services/PlaystateService';
import { CastTokenService } from './services/CastTokenService';
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';
@ -64,6 +65,18 @@ function param(req: Request, name: string): string {
return req.params[name] as string; return req.params[name] as string;
} }
/**
* Resolves the Emby session for one of the 4 streaming routes (the
* party's shared normal/burn-in stream) - normally the caller's own
* session cookie, same as every other route, but a Chromecast can't
* carry that cookie at all, so these 4 specifically also accept a
* short-lived, party-scoped `castToken` query param instead - see
* CastTokenService. Nothing else in the app ever looks at that param.
*/
function sessionForStream(req: Request, partyId: string): EmbySession | null {
return AuthService.sessionFromRequest(req) || CastTokenService.sessionFromToken(String(req.query.castToken || ''), partyId);
}
// --- Auth ---------------------------------------------------------------- // --- Auth ----------------------------------------------------------------
// The browser only ever sends a username/password once, to us. From then on // The browser only ever sends a username/password once, to us. From then on
// it holds an opaque session cookie - never Emby's own access token. // it holds an opaque session cookie - never Emby's own access token.
@ -189,9 +202,10 @@ app.get('/api/catalog/image/:itemId', asyncRoute(async (req, res) => {
// as the routes above (a <video>/<audio> element can't carry a bearer // as the routes above (a <video>/<audio> element can't carry a bearer
// header). // header).
app.get('/api/catalog/party-stream/:partyId/master.m3u8', asyncRoute(async (req, res) => { app.get('/api/catalog/party-stream/:partyId/master.m3u8', asyncRoute(async (req, res) => {
const session = AuthService.sessionFromRequest(req); const partyId = param(req, 'partyId');
const session = sessionForStream(req, partyId);
if (!session) { res.status(401).end(); return; } if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(param(req, 'partyId')); const party = PartyService.getById(partyId);
if (!party || !party.members.has(session.userId)) { if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." }); res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return; return;
@ -202,9 +216,10 @@ app.get('/api/catalog/party-stream/:partyId/master.m3u8', asyncRoute(async (req,
// 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/party-stream/:partyId/raw', asyncRoute(async (req, res) => { app.get('/api/catalog/party-stream/:partyId/raw', asyncRoute(async (req, res) => {
const session = AuthService.sessionFromRequest(req); const partyId = param(req, 'partyId');
const session = sessionForStream(req, partyId);
if (!session) { res.status(401).end(); return; } if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(param(req, 'partyId')); const party = PartyService.getById(partyId);
if (!party || !party.members.has(session.userId)) { if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." }); res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return; return;
@ -231,9 +246,10 @@ app.get('/api/catalog/subtitle/:itemId/:subtitleIndex', asyncRoute(async (req, r
// could usefully put in this URL. requireSession's JSON error doesn't // could usefully put in this URL. requireSession's JSON error doesn't
// fit here either, same reasoning as the routes above. // 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 partyId = param(req, 'partyId');
const session = sessionForStream(req, partyId);
if (!session) { res.status(401).end(); return; } if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(param(req, 'partyId')); const party = PartyService.getById(partyId);
if (!party || !party.members.has(session.userId)) { if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." }); res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return; return;
@ -246,9 +262,10 @@ app.get('/api/catalog/burn/:partyId/master.m3u8', asyncRoute(async (req, res) =>
})); }));
app.get('/api/catalog/burn/:partyId/:segment', asyncRoute(async (req, res) => { app.get('/api/catalog/burn/:partyId/:segment', asyncRoute(async (req, res) => {
const session = AuthService.sessionFromRequest(req); const partyId = param(req, 'partyId');
const session = sessionForStream(req, partyId);
if (!session) { res.status(401).end(); return; } if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(param(req, 'partyId')); const party = PartyService.getById(partyId);
if (!party || !party.members.has(session.userId)) { if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." }); res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return; return;
@ -460,6 +477,30 @@ app.get('/api/parties/:partyId', requireSession, (req, res) => {
res.json(PartyService.toPublicState(party, req.embySession!.userId)); res.json(PartyService.toPublicState(party, req.embySession!.userId));
}); });
// Lets a member's Chromecast into the party's existing shared stream
// without ever handing it the sender's own session cookie (which a
// Chromecast can't carry) or the real Emby token - see CastTokenService
// and docs/architecture.md's "Casting to a Chromecast" section. Normal
// cookie auth here (requireSession) since this is called from the
// sender's own page, not the Chromecast itself.
app.post('/api/parties/:partyId/cast-token', requireSession, (req, res) => {
const party = PartyService.getById(param(req, 'partyId'));
const session = req.embySession!;
if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return;
}
res.json({ token: CastTokenService.issue(party.id, session) });
});
// Called when the sender stops casting - see CastTokenService
// .revokeForMember's own comment for why this isn't the only way a
// token gets invalidated (leaving the party revokes it too, unconditionally).
app.delete('/api/parties/:partyId/cast-token', requireSession, (req, res) => {
CastTokenService.revokeForMember(param(req, 'partyId'), req.embySession!.userId);
res.json({ ok: true });
});
// Catches whatever asyncRoute() forwarded (or a synchronous throw) - // Catches whatever asyncRoute() forwarded (or a synchronous throw) -
// CatalogFetchError carries its own status/message; anything else is an // CatalogFetchError carries its own status/message; anything else is an
// unexpected bug, logged in full server-side but never leaked to the // unexpected bug, logged in full server-side but never leaked to the

View file

@ -0,0 +1,85 @@
'use strict';
import crypto from 'crypto';
import type { EmbySession } from '../types';
const CAST_TOKEN_TTL_MS = 12 * 60 * 60 * 1000; // 12h - see sessionFromToken's own comment for why this can afford to be generous
interface CastTokenEntry {
partyId: string;
userId: string;
session: EmbySession;
createdUtc: number;
}
/**
* Scopes a Chromecast into a party's existing streaming relay without
* giving it the sender's own httpOnly session cookie (which a Chromecast
* can't carry at all - see AuthService) or the real Emby token (which
* nothing outside this server ever sees). An opaque, short-lived token
* standing in for the caster's own already-authenticated EmbySession,
* scoped to one party - see index.ts's `sessionForStream`, the only
* place this is ever consulted, and only for the 4 routes a Chromecast
* actually needs to reach (the party's shared normal/burn-in stream) -
* see docs/architecture.md's "Casting to a Chromecast" section.
*
* Same shape as AuthService: an in-memory Map, no persistence - a
* restart just means casting has to be started again, same as a restart
* already signs everyone out of everything else.
*/
class CastTokenServiceClass {
private tokens = new Map<string, CastTokenEntry>();
/**
* Mints a new token for `session` casting `partyId`, replacing any
* token this same member already held for this party - a member is
* only ever actively casting one stream of one party at a time, so
* there's never a reason to keep an older one alive alongside a new one.
*/
issue(partyId: string, session: EmbySession): string {
this.revokeForMember(partyId, session.userId);
const token = crypto.randomUUID();
this.tokens.set(token, { partyId, userId: session.userId, session, createdUtc: Date.now() });
return token;
}
/**
* Resolves a cast token back to the EmbySession it was issued for -
* but only for the SAME party it was issued for (a token minted while
* casting party A must never work against party B's stream, even for
* the same user) and only within its TTL. The caller (index.ts) still
* separately checks `party.members.has(session.userId)` on every
* request, exactly like a cookie session already does - so even a
* token that outlives its own cast session is no more powerful than
* "this party member's own streaming access", and stops working the
* instant they actually leave the party regardless of TTL (see
* PartyService.leave).
*/
sessionFromToken(token: string, partyId: string): EmbySession | null {
if (!token) return null;
const entry = this.tokens.get(token);
if (!entry || entry.partyId !== partyId) return null;
if (Date.now() - entry.createdUtc > CAST_TOKEN_TTL_MS) {
this.tokens.delete(token);
return null;
}
return entry.session;
}
/**
* Drops whatever cast token `userId` currently holds for `partyId`, if
* any - called both when they explicitly stop casting (DELETE
* /api/parties/:partyId/cast-token) and unconditionally whenever they
* leave the party at all (see PartyService.leave), so a token never
* outlives its owner's membership even if the client never gets a
* chance to call the explicit stop-casting route (a crashed tab, a
* Chromecast that just loses power).
*/
revokeForMember(partyId: string, userId: string): void {
for (const [token, entry] of this.tokens) {
if (entry.partyId === partyId && entry.userId === userId) this.tokens.delete(token);
}
}
}
export const CastTokenService = new CastTokenServiceClass();

View file

@ -6,6 +6,7 @@ import { MusicQueueService } from './MusicQueueService';
import { TranscodeService } from './TranscodeService'; import { TranscodeService } from './TranscodeService';
import { PartyStreamService } from './PartyStreamService'; import { PartyStreamService } from './PartyStreamService';
import { PlaystateService } from './PlaystateService'; import { PlaystateService } from './PlaystateService';
import { CastTokenService } from './CastTokenService';
import type { LeaveResult, MediaType, Party, PartyMember, PartyPublicState, QueueItem } from '../types'; import type { LeaveResult, MediaType, Party, PartyMember, PartyPublicState, QueueItem } from '../types';
/** /**
@ -155,6 +156,7 @@ class PartyServiceClass {
party.members.delete(userId); party.members.delete(userId);
party.lastActivityUtc = Date.now(); party.lastActivityUtc = Date.now();
MusicQueueService.removeVote(party, userId); // no-op for a video party (party.music is unset) MusicQueueService.removeVote(party, userId); // no-op for a video party (party.music is unset)
CastTokenService.revokeForMember(party.id, userId); // no-op if they never cast - see its own comment for why this runs unconditionally, not just on an explicit "stop casting"
let newHostUserId: string | null = party.hostUserId; let newHostUserId: string | null = party.hostUserId;
if (wasHost) { if (wasHost) {

View file

@ -0,0 +1,71 @@
// CastTokenService is a small in-memory Map, same shape as AuthService -
// exercised directly, no mocking needed. Each test uses its own
// partyId/userId (never reused across `it` blocks) since the service is
// a module-level singleton shared by every test in this file - see
// musicQueueService.test.ts for the same convention.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { CastTokenService } from '../src/services/CastTokenService';
import type { EmbySession } from '../src/types';
function fakeSession(userId: string): EmbySession {
return { embyToken: `tok-${userId}`, userId, userName: userId, serverId: 'server1', createdUtc: Date.now() };
}
describe('CastTokenService', () => {
it('issue() then sessionFromToken() resolves back to the same session, for the issuing party only', () => {
const session = fakeSession('u-alice');
const token = CastTokenService.issue('party1', session);
assert.deepEqual(CastTokenService.sessionFromToken(token, 'party1'), session);
assert.equal(CastTokenService.sessionFromToken(token, 'party2'), null, 'a token minted for one party must not resolve against another');
});
it('an unknown or empty token resolves to null', () => {
assert.equal(CastTokenService.sessionFromToken('does-not-exist', 'party1'), null);
assert.equal(CastTokenService.sessionFromToken('', 'party1'), null);
});
it('issuing a second token for the same {partyId, userId} invalidates the first', () => {
const session = fakeSession('u-bob');
const first = CastTokenService.issue('party2', session);
const second = CastTokenService.issue('party2', session);
assert.notEqual(first, second);
assert.equal(CastTokenService.sessionFromToken(first, 'party2'), null, 'the old token must stop working once a new one is issued');
assert.deepEqual(CastTokenService.sessionFromToken(second, 'party2'), session);
});
it('the same user casting two different parties gets independent tokens', () => {
const session = fakeSession('u-carol');
const tokenA = CastTokenService.issue('partyA', session);
const tokenB = CastTokenService.issue('partyB', session);
assert.deepEqual(CastTokenService.sessionFromToken(tokenA, 'partyA'), session);
assert.deepEqual(CastTokenService.sessionFromToken(tokenB, 'partyB'), session);
});
it('revokeForMember() drops the token immediately', () => {
const session = fakeSession('u-dave');
const token = CastTokenService.issue('party3', session);
CastTokenService.revokeForMember('party3', 'u-dave');
assert.equal(CastTokenService.sessionFromToken(token, 'party3'), null);
});
it('revokeForMember() only drops the named member - others casting the same party are untouched', () => {
const alice = fakeSession('u-eve');
const bob = fakeSession('u-frank');
const tokenAlice = CastTokenService.issue('party4', alice);
const tokenBob = CastTokenService.issue('party4', bob);
CastTokenService.revokeForMember('party4', 'u-eve');
assert.equal(CastTokenService.sessionFromToken(tokenAlice, 'party4'), null);
assert.deepEqual(CastTokenService.sessionFromToken(tokenBob, 'party4'), bob);
});
it('revokeForMember() is a harmless no-op when nothing was ever cast', () => {
assert.doesNotThrow(() => CastTokenService.revokeForMember('party-nope', 'u-nobody'));
});
});

View file

@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { PartyService } from '../src/services/PartyService'; import { PartyService } from '../src/services/PartyService';
import { MusicQueueService } from '../src/services/MusicQueueService'; import { MusicQueueService } from '../src/services/MusicQueueService';
import { PlaystateService } from '../src/services/PlaystateService'; import { PlaystateService } from '../src/services/PlaystateService';
import { CastTokenService } from '../src/services/CastTokenService';
import type { AppSocket } from '../src/types'; import type { AppSocket } from '../src/types';
describe('PartyService', () => { describe('PartyService', () => {
@ -114,6 +115,18 @@ describe('PartyService', () => {
assert.equal(PartyService.getById(party.id), null); assert.equal(PartyService.getById(party.id), null);
}); });
it('leave() revokes whatever cast token the leaving member held for this party, even when they weren\'t the host and the party survives', (t) => {
const calls: [string, string][] = [];
t.mock.method(CastTokenService, 'revokeForMember', (partyId: string, userId: string) => { calls.push([partyId, userId]); });
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
PartyService.join(party, 'u-bob', 'Bob');
PartyService.leave(party, 'u-bob');
assert.deepEqual(calls, [[party.id, 'u-bob']]);
});
it('join() is idempotent: re-joining an existing member returns it as-is instead of resetting it', () => { it('join() is idempotent: re-joining an existing member returns it as-is instead of resetting it', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video'); const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const bob = PartyService.join(party, 'u-bob', 'Bob'); const bob = PartyService.join(party, 'u-bob', 'Bob');