Compare commits

...

6 commits

Author SHA1 Message Date
7bf8197bfe Merge remote-tracking branch 'origin/master' into feature/sticky-search-and-music-seek
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
# Conflicts:
#	README.md
#	docs/architecture.md
2026-09-16 07:20:57 +02:00
f680f72b1b Merge pull request 'Add skip intro / next episode at credits, via Emby's own detection' (#4) from feature/intro-credits-skip into master
All checks were successful
Publier l'image Docker / test (push) Successful in 19s
Publier l'image Docker / publish (push) Successful in 12s
2026-09-16 07:19:00 +02:00
bc878c8d1c Merge remote-tracking branch 'origin/master' into feature/intro-credits-skip
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
# Conflicts:
#	README.md
#	docs/architecture.md
2026-09-16 07:18:43 +02:00
ff2b9e46f2 Merge pull request 'Report watch-party streams to Emby's own Sessions API (admin dashboard)' (#6) from feature/emby-sessions-dashboard into master
All checks were successful
Publier l'image Docker / test (push) Successful in 21s
Publier l'image Docker / publish (push) Successful in 17s
Reviewed-on: #6
2026-09-16 07:14:11 +02:00
ceea7432ef Report watch-party streams to Emby's own Sessions API (admin dashboard)
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 5s
Publier l'image Docker / publish (pull_request) Has been skipped
Asked for directly: Emby's admin "now playing" dashboard should see
this app's streams the same way it sees a real web/mobile/TV client's.
This is exactly the follow-up "Sync watch-party progress back to Emby"
already anticipated and deliberately deferred at the time ("wiring
clean start/stop into every transition... is real complexity for a
secondary payoff") - that section also explains why this app abandoned
Emby's session-shaped PlayingItems endpoints entirely in favor of
direct UserData writes. Given that history (two separate rounds of a
wrong-but-schema-plausible request shape for that other, now-abandoned
endpoint family), this one was built checking the real API first
rather than assuming.

Confirmed against the real server's own /openapi.json that the actual
session-lifecycle family is POST /Sessions/Playing (start),
/Sessions/Playing/Progress, and /Sessions/Playing/Stopped - distinct
from PlayingItems. None of the three schemas declare a required list
at all, which - given the neighboring history - was read as "don't
trust that": ItemId/MediaSourceId/PlaySessionId/PositionTicks are sent
on every call anyway, matching what a real client obviously would,
rather than risking a repeat of "technically matches the schema, still
400s".

One real Emby session per PARTY MEMBER, not one per party - a faithful
copy of how a real client behaves, and the actual point of "let admin
see who's watching what" would be lost by collapsing everyone onto one
shared entry. New server/src/services/EmbySessionService.ts mirrors
PlaystateService's own best-effort shape (every failure caught and
logged, never thrown) but stays its own class - PlaystateService's own
opening comment is specifically about why it doesn't use a
session-shaped endpoint, so folding this into it would muddy that
exact distinction.

Getting a MediaSourceId/PlaySessionId per member needed a new
CatalogService.negotiatePlaybackSession - a fresh PlaybackInfo
negotiation under that specific member's own account, since Emby scopes
PlaybackInfo per user and PartyStreamService's existing shared
negotiation is never guaranteed valid for reporting under everyone
else's account too. Returns null on any failure rather than throwing -
this only ever feeds an admin-dashboard nicety, never something that
should disrupt real playback.

Driven entirely from index.ts's existing periodic sweep rather than new
hooks scattered across every queue-advance/track-skip call site -
that sweep already visits every connected member on every ~20s tick,
already positioned to notice a member's current item no longer matches
their cached session and react (stop the old one if any, negotiate
fresh ids, start the new one; same item as last tick just sends
Progress with the cached ids). PartyMember gained the nullable
embySession field this tracks. Explicit stop calls also happen at
PartyService.leave() (alongside the existing CastTokenService
.revokeForMember) and removeIdleParties (a backstop for a disconnect
that never triggered a clean leave).

Verified: full typecheck/test/build (134/134 server tests - new
coverage for negotiatePlaybackSession's null-on-any-failure contract,
every EmbySessionService method's exact request shape, that a non-ok
response/thrown error is swallowed not propagated, and that leave()
calls stop with a member's real cached session info but only when they
actually had one open). Not verified: an actual live write against the
real server - deliberately not attempted, since that would mean using
a real account's Emby token for a genuine state-changing call, beyond
the read-only diagnostic use this session's shared API key was ever
meant for. So while the request shape is checked against the real
schema and the per-member logic is unit-tested in full, whether Emby's
dashboard actually displays these sessions the way a native client's
would - correct device/client labeling in particular, since this app
never sends the X-Emby-Authorization Device/Client/Version header a
real client would, only X-Emby-Token - is genuinely unconfirmed. Worth
watching the real dashboard the first time a party runs after this ships.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 23:45:14 +02:00
57bb297bff Add skip intro / next episode at credits, via Emby's own detection
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Asked for directly. Checked the real server before building anything:
no plugin provides this (Cinema Intros is a different feature - pre-show
trailers, not per-episode skip points), and Emby's own Chapters field
turned out to be auto-generated placeholders every 5 minutes, not real
markers.

What the server does have natively, confirmed from its own
/openapi.json rather than assumed: a per-library EnableMarkerDetection
option backed by a built-in "Detect Episode Intros" scheduled task -
audio-fingerprint detection, the same idea as Jellyfin's separate Intro
Skipper plugin, already part of Emby itself. Enabling it (done directly
on the server for the two TV libraries) adds real entries to the same
Chapters array, distinguished by MarkerType: IntroStart/IntroEnd/
CreditsStart.

This made the feature almost entirely "read what Emby already
computed" - no fingerprinting, no new persistence, no background jobs
of this app's own. CatalogService.getPlaybackMarkers fetches
Fields=Chapters and picks out just those three marker types (nulls
when detection hasn't reached this item or isn't enabled for its
library - never an error), behind a new
GET /api/catalog/item/:id/markers route mirroring getPlaybackOptions.

The player gets one new floating button, #skipIntroBtn - an explicit
label, not an icon, matching this app's own convention for every
request-shaped control. It's a sibling of #bottomBar, not inside it,
so it stays reachable through the bar's own auto-hide/.minimal collapse.
Clicking it seeks to introEndTicks through the exact same
activeDriver/onDriverStateChanged path any other seek uses, so it's
automatically host-only and automatically cast-aware with no new
special-casing for either. End credits got no button of their own -
#nextBtn already does exactly "go to the next queued item" and was
already shown whenever hasNextQueued is true; the only gap closed here
is visibility, by un-minimizing the bar once (a one-shot flag, not a
fight against someone deliberately re-minimizing it) the moment
playback crosses creditsStartTicks.

Verified: full typecheck/test/build on both projects (125/125 server
tests, including new coverage for getPlaybackMarkers - real MarkerType
values mixed with auto-generated Chapter ones, and both "no Chapters"
and "no markers among the Chapters" resolving to nulls rather than
throwing). The button's on-screen position was checked directly
(getBoundingClientRect against the real compiled CSS) at two viewport
sizes - clear of #bottomBar with a 10px gap, not overlapping. Not
verified: the detection task had only just been enabled on the real
server and hadn't finished analyzing any episode yet, so the actual
end-to-end "a detected marker makes the button appear at the right
second" behavior needs a real check once it's had time to run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 23:16:38 +02:00
18 changed files with 760 additions and 18 deletions

View file

@ -331,6 +331,15 @@ Then put it behind your existing reverse proxy like everything else
- [x] Sticky search bar on the library/artist/album pages, and letting
anyone seek within the current music track (same no-host-check
model pause/resume already had) — see docs/architecture.md
- [x] Skip intro / jump to next episode at end credits, using Emby's own
native marker detection (`EnableMarkerDetection` + its "Detect
Episode Intros" task) rather than building fingerprinting
ourselves — see docs/architecture.md
- [x] Report each party member's own playback to Emby's real Sessions
API (`/Sessions/Playing`, `/Progress`, `/Stopped`), so the admin
dashboard sees watch-party streams the same way it sees a native
client's — one session per member, under their own account — see
docs/architecture.md
- [ ] 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)

View file

@ -3,7 +3,7 @@
// module just wraps fetch calls to our own /api/* routes. See
// docs/architecture.md for why this changed from the first version (CORS,
// plus not wanting the browser to hold an Emby token at all).
import type { ItemDto, LibraryDto, PlaybackOptionsDto, SeasonDto } from '../types';
import type { ItemDto, LibraryDto, PlaybackMarkersDto, PlaybackOptionsDto, SeasonDto } from '../types';
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(path, {
@ -116,6 +116,10 @@ export function getPlaybackOptions(itemId: string): Promise<PlaybackOptionsDto>
return apiFetch(`/api/catalog/item/${itemId}/playback-options`);
}
export function getPlaybackMarkers(itemId: string): Promise<PlaybackMarkersDto> {
return apiFetch(`/api/catalog/item/${itemId}/markers`);
}
/** The party's own shared Emby stream (server/src/services/
* PartyStreamService.ts) - one negotiated PlaybackInfo session reused
* by every member, instead of each viewer's own <video>/<audio>

View file

@ -50,6 +50,16 @@ export interface PlaybackOptionsDto {
subtitleTracks: PlaybackTrackOption[];
}
/** Emby's own native intro/credits detection - see server/src/types.ts's
* own copy of this shape for where the field names come from. Every
* field is independently nullable ("not detected", never an error) -
* see views/player/Player.ts's updateSkipButton, the only consumer. */
export interface PlaybackMarkersDto {
introStartTicks: number | null;
introEndTicks: number | null;
creditsStartTicks: number | null;
}
export type MediaType = 'video' | 'music';
export interface PublicMember {

View file

@ -196,6 +196,26 @@
transition: padding .25s ease, background-color .25s ease, border-color .25s ease;
}
/* Floating above #bottomBar (higher z-index, positioned clear of its
tallest/expanded height) rather than living inside it - see
Player.html's own comment on why: it must stay reachable through the
bar's own auto-hide/.minimal collapse, not disappear along with it. */
.skipMarkerBtn {
position: absolute;
right: 10px;
bottom: 84px;
z-index: 7;
background: rgba(20, 18, 22, .85);
color: white;
border: 1px solid rgba(255, 255, 255, .25);
padding: 10px 18px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
}
.skipMarkerBtn:hover { background: rgba(20, 18, 22, .95); border-color: rgba(255, 255, 255, .4); }
/* Collapsed down to just the seek bar after a stretch of no activity
(see Player.ts's scheduleControlsHide) - asked for directly: the
full bar, always visible, sits right where a native <track> subtitle

View file

@ -63,6 +63,17 @@
notification rather than a fourth thing bolted onto a fixed layout. -->
<div id="requestBanner" class="requestBanner hide"></div>
<!-- Emby's own native intro detection (see Player.ts's updateSkipButton/
CatalogService.getPlaybackMarkers) - shown independently of #bottomBar
below, and NOT subject to its own auto-hide/.minimal collapse, since
this needs to stay reachable the moment the intro starts even if
nobody's touched the player yet. Host-only, like every other seek -
see updateSkipButton's own comment for why a follower can't get this
button at all. Explicit label, not just an icon - see
docs/architecture.md's player-rework section for why this app
standardized on that for every request-shaped control. -->
<button id="skipIntroBtn" class="skipMarkerBtn hide">Passer l'intro</button>
<!-- One bar, bottom-anchored, replacing what used to be three separate
floating pieces (a centered banner/notice column, a request
toolbar cornered bottom-right, an "Épisode suivant" link up in the

View file

@ -1,14 +1,14 @@
import '../../styles/presence.css';
import './Player.css';
import html from './Player.html';
import { auth, burnInStreamUrl, getPlaybackOptions, partyStreamUrl, subtitleUrl } from '../../api/embyApi';
import { auth, burnInStreamUrl, getPlaybackMarkers, getPlaybackOptions, partyStreamUrl, subtitleUrl } from '../../api/embyApi';
import { getParty, leaveParty, connectParty, issueCastToken, revokeCastToken } from '../../api/party';
import type { PartyConnection } from '../../api/party';
import { navigate } from '../../router';
import type { CleanupFn, RouteParams } from '../../router';
import { colorForName, escapeHtml, formatDuration, initialsFor } from '../../tiles';
import { LOADING_HINT } from '../../loading';
import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequestedMsg, PlaybackOptionsDto, ResumeRequestedMsg, SocketMember, StreamOptionsChangedMsg, TextSubtitleChangedMsg } from '../../types';
import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequestedMsg, PlaybackMarkersDto, PlaybackOptionsDto, ResumeRequestedMsg, SocketMember, StreamOptionsChangedMsg, TextSubtitleChangedMsg } from '../../types';
import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar';
import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../../cast';
import type { PlaybackDriver } from '../../cast';
@ -203,6 +203,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
const playPauseBtn = root.querySelector('#playPauseBtn') as HTMLButtonElement;
const muteBtn = root.querySelector('#muteBtn') as HTMLButtonElement;
const timeLabel = root.querySelector('#timeLabel')!;
const skipIntroBtn = root.querySelector('#skipIntroBtn') as HTMLButtonElement;
qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => `<option value="${q.value}">${q.label}</option>`).join('');
@ -262,6 +263,15 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
// - see its own comment) can re-render the subtitle picker's host-only
// gating for burn-in options without a redundant playback-options fetch.
let lastPlaybackOptions: PlaybackOptionsDto | null = null;
// Emby's own native intro/credits detection for currentItemId, if any -
// see loadMarkers/updateSkipButton below and CatalogService
// .getPlaybackMarkers. Null fields (or this whole thing being null,
// while still loading) just mean no skip button, never an error.
let currentMarkers: PlaybackMarkersDto | null = null;
// One-shot per item - see updateSkipButton's own comment for why this
// must NOT re-fire (force the bar open) on every tick for as long as
// credits are running.
let creditsControlsShown = false;
// "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
@ -427,6 +437,42 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
timeLabel.textContent = duration > 0 ? `${formatDuration(current)} / ${formatDuration(duration)}` : '';
}
/**
* Shows/hides #skipIntroBtn from activeDriver's current position against
* currentMarkers - called continuously (progressTicker, below), same as
* updateProgressBar. Host-only: a follower's own seek never broadcasts
* (see sendLocalState's own gate), so a local jump here would just get
* overwritten by the next correction from the real host - same
* reasoning as progressBar being read-only for a follower.
*
* Credits get no button of their own: #nextBtn already does exactly
* "go to the next queued item" and is already shown whenever
* hasNextQueued is true (see onItemChanged) - detection's only real
* contribution there is making sure it's actually reachable the moment
* credits start, by un-minimizing the bar once, rather than requiring
* the host to have touched the player recently. The `creditsControlsShown`
* guard is what keeps this a one-shot nudge instead of fighting anyone
* who deliberately re-minimizes the bar during a long credits scroll.
*/
function updateSkipButton() {
if (!isHost || !currentMarkers) {
skipIntroBtn.classList.add('hide');
return;
}
const current = activeDriver.currentTime;
const { introStartTicks, introEndTicks, creditsStartTicks } = currentMarkers;
const inIntro = introStartTicks != null && introEndTicks != null
&& current >= introStartTicks / TICKS_PER_SECOND && current < introEndTicks / TICKS_PER_SECOND;
skipIntroBtn.classList.toggle('hide', !inIntro);
const inCredits = creditsStartTicks != null && current >= creditsStartTicks / TICKS_PER_SECOND;
if (inCredits && hasNextQueued && !creditsControlsShown) {
creditsControlsShown = true;
showControls();
}
}
/** Shared by every discrete play/pause/seek event this player reacts
* to - the local <video>'s own native events (becomeHost) and a cast
* session's RemotePlayerController events (startCasting's own
@ -505,6 +551,12 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
updateMuteBtn();
});
skipIntroBtn.addEventListener('click', () => {
if (currentMarkers?.introEndTicks == null) return;
activeDriver.currentTime = currentMarkers.introEndTicks / TICKS_PER_SECOND;
onDriverStateChanged();
});
// Ticks the seek bar/time label between discrete events - currentTime
// moves continuously while playing, with no event of its own for
// "still playing, time passed" the way play/pause/seeked have. Runs
@ -515,6 +567,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
progressTicker = window.setInterval(() => {
if (!currentItemId) return;
updateProgressBar();
updateSkipButton();
}, 500);
updatePlayPauseBtn();
updateMuteBtn();
@ -656,6 +709,24 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
}
}
/** Emby's own native intro/credits markers for `itemId`, if the library
* it's from has detection enabled and the task has already reached it
* - see CatalogService.getPlaybackMarkers. Reset first, synchronously,
* not just on a fetch failure: without this, the OLD item's markers
* (and skip button) would keep showing for however long this fetch
* takes on the new one. */
async function loadMarkers(itemId: string) {
currentMarkers = null;
skipIntroBtn.classList.add('hide');
creditsControlsShown = false;
try {
currentMarkers = await getPlaybackMarkers(itemId);
} catch (err) {
console.error('[watchparty player] playback markers failed', err);
// Non-fatal, same as loadTrackOptions above - playback works fine with no skip button.
}
}
/**
* (Re)builds the subtitle <select> from the last-fetched playback
* options, called both on a genuine item load and whenever burn-in/
@ -940,6 +1011,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
burnInAudioIndex = null;
textSubtitleIndex = null;
streamAudioIndex = null;
loadMarkers(msg.itemId); // not awaited - never needed before playback can start, see loadVideo below
await loadTrackOptions(msg.itemId);
loadVideo(msg.itemId, 0, true);
}
@ -1102,6 +1174,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
// image-subtitle option as disabled, as if they were a follower.
if (party.youAreHost) becomeHost(false);
loadMarkers(party.itemId); // not awaited - see onItemChanged's own comment
await loadTrackOptions(party.itemId);
loadVideo(party.itemId, 0, false);

View file

@ -3176,6 +3176,173 @@ so that was checked by reading `.mainContent`'s own layout directly
`position: sticky` resolves against the page scroll as intended, not
some inner element).
## Skipping the intro/end credits, using Emby's own native detection
Asked for directly: propose skipping a TV episode's opening/end credits,
Netflix-style. Checked directly against the real server before building
anything, rather than assuming a data source existed - no plugin
provides this (the installed "Cinema Intros" plugin is a different
feature entirely - pre-show trailers, not per-episode skip points), and
the embedded `Chapters` Emby already returns turned out to be
auto-generated placeholders every 5 minutes ("Chapitre 1", "Chapitre
2", ...), not real markers.
What the server DOES have, confirmed from its own `/openapi.json`
rather than assumed: a native, per-library `EnableMarkerDetection`
option (`Library/VirtualFolders`'s `LibraryOptions`) backed by a
built-in "Detect Episode Intros" scheduled task - audio-fingerprint
detection, the same idea as Jellyfin's separate Intro Skipper plugin,
just already part of Emby itself. Once enabled and run, it adds real
entries to the exact same `Chapters` array, distinguished from the
auto-generated ones by `MarkerType`: `IntroStart`/`IntroEnd`/
`CreditsStart` (confirmed from the schema, not guessed - there is no
`CreditsEnd`, since credits are assumed to run to the end of the file).
Given that, this feature is almost entirely "read what Emby already
computed and expose it" - no fingerprinting, no new persistence, no
background jobs of this app's own. `CatalogService.getPlaybackMarkers`
fetches `Fields=Chapters` for an item and picks out just those three
marker types (nulls when detection hasn't reached this item, or isn't
enabled for its library - never an error), behind a new
`GET /api/catalog/item/:id/markers` route, mirroring
`getPlaybackOptions`'s own shape exactly.
**The player side** adds one new floating button, `#skipIntroBtn` -
explicit label ("Passer l'intro"), not an icon, matching this app's own
established convention for every request-shaped control. It's
positioned as a sibling of `#bottomBar`, not inside it, specifically so
it stays reachable through the bar's own auto-hide/`.minimal` collapse -
someone who hasn't touched the player still needs to see it the moment
the intro starts. `updateSkipButton` (ticked every 500ms alongside
`updateProgressBar`) compares `activeDriver.currentTime` against the
fetched markers and shows/hides it; clicking it seeks straight to
`introEndTicks` through the exact same `activeDriver`/`onDriverStateChanged`
path any other seek already uses, so it's automatically host-only (a
follower's seek never broadcasts - see `sendLocalState`'s own gate) and
automatically cast-aware (works identically whether `activeDriver` is
the local `<video>` or a Chromecast's `RemotePlayer`) with no new
special-casing for either.
End credits deliberately got no button of their own: `#nextBtn` already
does exactly "go to the next queued item" and was already shown
whenever `hasNextQueued` is true, for the whole episode - not something
this feature needed to duplicate. The only real gap it closes is
*visibility*: if nobody's touched the player in a while, `#bottomBar`
(and `#nextBtn` inside it) is collapsed to `.minimal` right when
credits start. `updateSkipButton` calls the existing `showControls()`
once (a `creditsControlsShown` flag keeps it one-shot, not a fight
against someone deliberately re-minimizing the bar during a long
credits scroll) the moment playback crosses `creditsStartTicks`, so the
already-existing button is actually reachable without requiring a mouse
nudge first.
Verified: full `typecheck`/`test`/`build` on both projects (125/125
server tests, including new coverage for `getPlaybackMarkers` - the
real `MarkerType` values mixed with auto-generated `Chapter` ones, and
both "no Chapters field" and "no markers among the Chapters that are
there" resolving to nulls rather than throwing). The button's on-screen
position was checked directly (`getBoundingClientRect` against the real
compiled CSS, not eyeballed) - it sits clear of `#bottomBar` with a
10px gap at both a 960×540 and a phone-width viewport, since both use
fixed pixel offsets off the same `.playerWrap`. **Not verified**: the
detection task had only just been enabled on the real server (confirmed
via its own `EnableMarkerDetection`/`ScheduledTasks` state) and hadn't
finished analyzing any episode yet at the time this shipped, so the
actual end-to-end "does a detected marker make the button appear at
the right second" behavior is unconfirmed against real generated data -
worth a real check once the task has had time to run.
## Making Emby's own admin dashboard see watch-party streams
Asked for directly: Emby's admin "now playing" dashboard should see
this app's streams the same way it sees a real web/mobile/TV client's -
this is exactly the follow-up "Sync watch-party progress back to Emby"
(above) already anticipated and deliberately deferred, word for word:
"wiring clean start/stop into every transition... is real complexity
for a secondary payoff, left as a documented follow-up rather than
built speculatively." That section also already explains why: this app
switched entirely to direct `UserData` writes for resume-position/
watched-history, abandoning Emby's session-shaped `PlayingItems`
endpoints, precisely because they need a real session lifecycle to mean
anything - and given that section's own history (two separate rounds of
a wrong-but-schema-plausible request shape for that *other*,
now-abandoned endpoint family), this one was built carefully, checking
the real API before writing anything rather than assuming.
**Confirmed against the real server's own `/openapi.json`** (not
assumed) that the actual session-lifecycle family is `POST
/Sessions/Playing` (start), `/Sessions/Playing/Progress`, and
`/Sessions/Playing/Stopped` - distinct from the `PlayingItems` family
`PlaystateService` already walked away from. None of the three schemas
declare a `required` list at all, which - given this exact neighboring
history - was read as "don't trust that," not "everything's optional
in practice": `ItemId`/`MediaSourceId`/`PlaySessionId`/`PositionTicks`
are sent on every call anyway, matching what a real client obviously
would, rather than risking a repeat of the earlier "technically matches
the schema, still 400s" experience.
**One real Emby session per PARTY MEMBER, not one per party** - a
faithful copy of how a real client behaves (every device/account gets
its own), and the actual point of "let admin see who's watching what"
would be lost by collapsing every member onto one shared entry. New
`server/src/services/EmbySessionService.ts` (`start`/`progress`/`stop`)
mirrors `PlaystateService`'s own shape closely - same best-effort
philosophy (every failure caught and logged, never thrown), same
`Logger.playstate` category (broadened to describe both) - but is kept
as its own class rather than folded into `PlaystateService`, since that
class's entire opening comment is specifically about why it *doesn't*
use a session-shaped endpoint; mixing the two would muddy exactly the
distinction that comment goes out of its way to draw.
**Getting a `MediaSourceId`/`PlaySessionId` per member** needed a new
`CatalogService.negotiatePlaybackSession(session, itemId)` - a fresh
`PlaybackInfo` negotiation under that specific member's own account.
This can't reuse `PartyStreamService`'s existing shared negotiation:
that one is negotiated once, under whichever member happened to
trigger it first, and Emby scopes `PlaybackInfo` per user - never
guaranteed valid for reporting under everyone else's own account too.
Returns `null` on any failure rather than throwing (an expired token,
Emby unreachable, no source) - unlike `getMediaSource`'s own hard
failure (which feeds a real ffmpeg source and has to fail loudly), this
one only ever feeds an admin-dashboard nicety.
**Driven entirely from index.ts's existing periodic sweep**, not new
hooks scattered across every queue-advance/track-skip call site the way
`PlaystateService.markPlayed` needed. That sweep already visits every
connected member on every ~20s tick - exactly positioned to notice
"this member's current item no longer matches `PartyMember
.embySession.itemId`" and react: stop the old session if there was
one, negotiate fresh ids, start the new one. The same item as last tick
just sends a Progress report reusing the already-cached ids - no
renegotiation needed for the common case. `PartyMember` gained the
nullable `embySession` field this reads/writes (same null-until-known
shape as `embyToken`/`deviceId`). Explicit `stop` calls also happen at
the two places a member's session should end promptly rather than
waiting on the next sweep tick to just stop reporting: `PartyService
.leave()` (alongside the existing `CastTokenService.revokeForMember`)
and `removeIdleParties` (a defensive backstop for a member whose own
disconnect-driven `leave()` never fired for some reason - a real gap
would otherwise leave a stale dashboard entry indefinitely, not just
until the next tick).
Verified: full `typecheck`/`test`/`build` (140/140 server tests - new
coverage for `negotiatePlaybackSession`'s null-on-any-failure
contract, every `EmbySessionService` method's exact request shape and
that a non-ok response/thrown error is swallowed not propagated, and
that `leave()` calls `stop` with a member's real cached session info
but only when they actually had one open). **Not verified**: an actual
live write against the real server - deliberately not attempted from
here, since doing so would mean using a real account's Emby token for
a genuine state-changing call, beyond the read-only diagnostic use
this session's shared API key was ever meant for. So while the request
shape is checked against the real schema and the per-member logic is
unit-tested in full, whether Emby's dashboard actually displays these
sessions the way a native client's would - correct device/client
labeling in particular, since this app never sends the `X-Emby-
Authorization` Device/Client/Version header a real client would, only
`X-Emby-Token` - is genuinely unconfirmed. Worth watching the real
dashboard the first time a party runs after this ships.
## Known limitations
- Playback always goes through `hls.js` against whatever Emby's

View file

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

View file

@ -66,17 +66,20 @@ export class Logger {
console.log(`[watchparty:transcode] ${new Date().toISOString()} ${message}`);
}
/** The party watch-state sync (index.ts's periodic sweep, and
* PartyService.advanceQueue/MusicQueueService.playNext's own item-
* transition calls - see PlaystateService) - logs every outgoing
* request (method + path, and the POST body for the UserData write)
* and its outcome, not just failures: this is a best-effort
* background sync with nothing else watching it, so `docker logs |
* grep watchparty:playstate` needs to be enough on its own to see
* exactly what was sent and whether Emby accepted it, without cross-
* referencing `emby` above. Safe to log in full here (unlike `stream`
* above) - the real embyToken travels as a header, never logged, and
* PlaystateService's own tests confirm it never leaks into these lines. */
/** Every per-member background write this app makes to Emby under
* someone's own account - the party watch-state sync (index.ts's
* periodic sweep, and PartyService.advanceQueue/MusicQueueService
* .playNext's own item-transition calls - see PlaystateService), and
* the parallel Sessions-API lifecycle reported alongside it (see
* EmbySessionService). Logs every outgoing request (method + path,
* and the POST body for the UserData write) and its outcome, not
* just failures: this is a best-effort background sync with nothing
* else watching it, so `docker logs | grep watchparty:playstate`
* needs to be enough on its own to see exactly what was sent and
* whether Emby accepted it, without cross-referencing `emby` above.
* Safe to log in full here (unlike `stream` above) - the real
* embyToken travels as a header, never logged, and both services'
* own tests confirm it never leaks into these lines. */
static playstate(message: string): void {
console.log(`[watchparty:playstate] ${new Date().toISOString()} ${message}`);
}

View file

@ -14,6 +14,7 @@ import { MusicQueueService } from './services/MusicQueueService';
import { TranscodeService } from './services/TranscodeService';
import { PartyStreamService } from './services/PartyStreamService';
import { PlaystateService } from './services/PlaystateService';
import { EmbySessionService } from './services/EmbySessionService';
import { CastTokenService } from './services/CastTokenService';
import { SocketGateway } from './realtime/SocketGateway';
import type { ClientToServerEvents, EmbySession, MediaType, QueueItem, ServerToClientEvents } from './types';
@ -212,6 +213,13 @@ app.get('/api/catalog/item/:itemId/playback-options', requireSession, asyncRoute
res.json(await CatalogService.getPlaybackOptions(req.embySession!, param(req, 'itemId')));
}));
// Emby's own native intro/credits detection, when the library it's from has
// the feature enabled - powers the player's "Passer l'intro"/"Épisode
// suivant" buttons. See CatalogService.getPlaybackMarkers.
app.get('/api/catalog/item/:itemId/markers', requireSession, asyncRoute(async (req, res) => {
res.json(await CatalogService.getPlaybackMarkers(req.embySession!, param(req, 'itemId')));
}));
app.get('/api/catalog/libraries/:libraryId/items', requireSession, asyncRoute(async (req, res) => {
res.json({ items: await CatalogService.getLibraryItems(req.embySession!, param(req, 'libraryId')) });
}));
@ -606,6 +614,17 @@ setInterval(() => PartyStreamService.reapIdleSessions(30 * 60 * 1000), 5 * 60 *
// Marking an item Played (once the party moves past it) is handled
// separately, at the source of that transition - see
// PartyService.advanceQueue and MusicQueueService.playNext.
//
// Also where EmbySessionService's own start/progress/stop lifecycle is
// driven from, rather than scattered across every queue-advance/track-
// skip call site the way markPlayed needed to be - this loop already
// visits every connected member on every tick, so it's already in
// exactly the right place to notice "this member's current item just
// changed" (member.embySession.itemId no longer matches) and react:
// stop the old session if there was one, negotiate a fresh
// MediaSourceId/PlaySessionId under their own account, and start the
// new one. Same item as last tick just reports Progress with the
// already-cached ids - no renegotiation needed for that, the common case.
setInterval(() => {
for (const party of PartyService.listActive()) {
const state = PartyService.getCurrentPlaybackState(party);
@ -613,6 +632,35 @@ setInterval(() => {
for (const member of party.members.values()) {
if (!member.connection || !member.embyToken) continue;
PlaystateService.updatePosition(member.embyToken, member.userId, state.itemId, state.positionTicks);
if (member.embySession && member.embySession.itemId === state.itemId) {
const s = member.embySession;
EmbySessionService.progress(member.embyToken, s.itemId, s.mediaSourceId, s.playSessionId, state.positionTicks, state.isPaused);
continue;
}
// The item just changed for them (or this is the first tick since
// they started watching/listening) - stop whatever they had open
// before, if anything, then negotiate and start a fresh one.
const previous = member.embySession;
const embyToken = member.embyToken;
member.embySession = null;
if (previous) {
EmbySessionService.stop(embyToken, previous.itemId, previous.mediaSourceId, previous.playSessionId, member.positionTicks);
}
// CatalogService's own methods all take a full EmbySession, for
// consistency with every live user-facing route that already has
// one from requireSession - this sweep only ever has a member's
// bare stored token, same situation PlaystateService/
// EmbySessionService are already in. serverId/createdUtc are safe
// to fake: negotiatePlaybackSession (like every per-member Emby
// write in this app) never reads either, only embyToken/userId.
const fakeSession: EmbySession = { embyToken, userId: member.userId, userName: member.userName, serverId: '', createdUtc: 0 };
CatalogService.negotiatePlaybackSession(fakeSession, state.itemId).then((negotiated) => {
if (!negotiated) return;
member.embySession = { itemId: state.itemId, mediaSourceId: negotiated.mediaSourceId, playSessionId: negotiated.playSessionId };
EmbySessionService.start(embyToken, state.itemId, negotiated.mediaSourceId, negotiated.playSessionId, state.positionTicks, state.isPaused);
});
}
}
}, 20 * 1000);

View file

@ -4,7 +4,7 @@ import type { Request, Response } from 'express';
import { EMBY_SERVER_URL } from '../config';
import { EmbyClient } from '../emby/EmbyClient';
import { Logger } from '../Logger';
import type { EmbyItem, EmbySession, ItemDto, LibraryDto, MediaType, PlaybackOptionsDto, SeasonDto } from '../types';
import type { EmbyItem, EmbySession, ItemDto, LibraryDto, MediaType, PlaybackMarkersDto, PlaybackOptionsDto, SeasonDto } from '../types';
// 'MusicVideo' is Emby's own item type for a musicvideos library's
// contents (distinct from the generic 'Video', which is home videos).
@ -286,6 +286,30 @@ class CatalogServiceClass {
};
}
/**
* Emby's own native intro/credits detection - see PlaybackMarkersDto's
* own comment for what MarkerType actually looks like and where that
* was confirmed. Read from the same Chapters field getRawItem/toItemDto
* never request (they have no use for chapters at all) - a dedicated
* fetch here rather than folding it into getItem, since a chapter list
* is only ever needed by the player deciding whether to show a skip
* button, nowhere a plain ItemDto is used.
*/
async getPlaybackMarkers(session: EmbySession, itemId: string): Promise<PlaybackMarkersDto> {
const data = await this.fetchJson<{ Chapters?: Array<{ StartPositionTicks: number; MarkerType?: string }> }>(
`/Users/${session.userId}/Items/${itemId}?Fields=Chapters`,
session,
'Impossible de récupérer les marqueurs de lecture.'
);
const chapters = data.Chapters || [];
const startOf = (markerType: string) => chapters.find((c) => c.MarkerType === markerType)?.StartPositionTicks ?? null;
return {
introStartTicks: startOf('IntroStart'),
introEndTicks: startOf('IntroEnd'),
creditsStartTicks: startOf('CreditsStart')
};
}
/**
* The negotiated MediaSource's id/container/streams - used by
* TranscodeService to build its own ffmpeg source URL and translate
@ -307,6 +331,33 @@ class CatalogServiceClass {
return { id: source.Id, container: source.Container || 'mkv', streams: source.MediaStreams || [] };
}
/**
* A fresh PlaybackInfo negotiation under `session`'s own account,
* purely to get the MediaSourceId/PlaySessionId EmbySessionService
* needs to open a real Emby Sessions-API entry for this member - see
* its own class comment for why every party member needs their own,
* rather than reusing the one PartyStreamService already negotiated
* for the party's actual shared relay (that one was negotiated once,
* under whichever member happened to trigger it first - never
* guaranteed valid for every OTHER member's own account, since Emby
* scopes PlaybackInfo per user). Null on any failure (an expired
* token, Emby unreachable, no source at all) rather than throwing -
* this is reported to an admin dashboard as a nice-to-have, never
* something that should be allowed to disrupt real playback the way
* getMediaSource's own hard failure is allowed to (that one feeds an
* actual ffmpeg source).
*/
async negotiatePlaybackSession(session: EmbySession, itemId: string): Promise<{ mediaSourceId: string; playSessionId: string } | null> {
try {
const info = await this.fetchJson<PlaybackInfoResponse>(`/Items/${itemId}/PlaybackInfo?UserId=${session.userId}`, session, 'Impossible de négocier une session de lecture.');
const source = (info.MediaSources || [])[0];
if (!source?.Id || !info.PlaySessionId) return null;
return { mediaSourceId: source.Id, playSessionId: info.PlaySessionId };
} catch {
return null;
}
}
// --- Direct proxying (images/video/audio) - these need the raw
// Express req/res, unlike everything above which just returns JSON. ---

View file

@ -0,0 +1,104 @@
'use strict';
import { EmbyClient } from '../emby/EmbyClient';
import { Logger } from '../Logger';
/**
* Reports a party member's own playback to Emby's real Sessions API
* (`POST /Sessions/Playing`, `/Sessions/Playing/Progress`,
* `/Sessions/Playing/Stopped`) - the piece of session lifecycle
* docs/architecture.md's "Sync watch-party progress back to Emby"
* section deliberately deferred at the time ("wiring clean start/stop
* into every transition... is real complexity for a secondary payoff").
* Asked for directly since: without this, this app's own streams never
* showed up in Emby's admin "now playing" dashboard the way a real web/
* mobile/TV client's would - only the resume-position/watched-history
* UserData (PlaystateService) was ever visible anywhere, and only by
* opening the item itself, not from the dashboard's live session list.
*
* One real Emby session per PARTY MEMBER, not one per party - a
* faithful copy of how a real client behaves (every device/account gets
* its own), and the actual point of the request ("let admin see who's
* watching what") would be lost by collapsing every member onto one
* shared entry. Every call needs the member's own `embyToken` (the same
* source every other per-member Emby write in this app already uses -
* see `PartyMember.embyToken`/`PlaystateService`) - Emby's Sessions API
* is inherently per-account, there's no equivalent of "the party's" own
* session to report against instead.
*
* `MediaSourceId`/`PlaySessionId` (see `CatalogService
* .negotiatePlaybackSession`) are required in practice, not just
* present in the schema - confirmed by reading the real server's own
* `/openapi.json` rather than assumed, after this exact neighboring
* area (`PlaystateService`'s own history) already got bitten twice by
* an Emby endpoint accepting a request shape that looked complete but
* wasn't. `index.ts`'s periodic sweep is what actually drives the
* start/progress/stop transitions - see its own comment for why that
* one place could cover this instead of scattering hooks across every
* queue-advance/track-skip call site the way `PlaystateService
* .markPlayed` needed to.
*
* Best-effort throughout, exactly like `PlaystateService`: any failure
* is caught and logged here, never thrown - none of this may ever
* disrupt real playback over an admin-dashboard nicety.
*/
class EmbySessionServiceClass {
/** Called once per member when they start watching/listening to
* `itemId` - a fresh join, or the party moving to a new item while
* they're already a member (see index.ts's sweep, which detects that
* by comparing against `PartyMember.embySession.itemId`). */
async start(embyToken: string, itemId: string, mediaSourceId: string, playSessionId: string, positionTicks: number, isPaused: boolean): Promise<void> {
await this.report('/Sessions/Playing', embyToken, {
ItemId: itemId,
MediaSourceId: mediaSourceId,
PlaySessionId: playSessionId,
PositionTicks: Math.round(positionTicks),
IsPaused: isPaused,
PlayMethod: 'Transcode',
CanSeek: true
});
}
/** Called periodically (~20s, alongside `PlaystateService
* .updatePosition` - see index.ts's sweep) for every member with a
* live session already open for their current item. */
async progress(embyToken: string, itemId: string, mediaSourceId: string, playSessionId: string, positionTicks: number, isPaused: boolean): Promise<void> {
await this.report('/Sessions/Playing/Progress', embyToken, {
ItemId: itemId,
MediaSourceId: mediaSourceId,
PlaySessionId: playSessionId,
PositionTicks: Math.round(positionTicks),
IsPaused: isPaused,
PlayMethod: 'Transcode'
});
}
/** Called once per member when they stop watching/listening to
* `itemId` - leaving the party (`PartyService.leave`), the item
* changing (index.ts's sweep), or the party being destroyed for
* inactivity (`PartyService.removeIdleParties`). */
async stop(embyToken: string, itemId: string, mediaSourceId: string, playSessionId: string, positionTicks: number): Promise<void> {
await this.report('/Sessions/Playing/Stopped', embyToken, {
ItemId: itemId,
MediaSourceId: mediaSourceId,
PlaySessionId: playSessionId,
PositionTicks: Math.round(positionTicks)
});
}
/** Every call logs its own outgoing request + outcome - see
* Logger.playstate's own comment for why this needs to be enough on
* its own, greppable, to answer "is this actually reaching Emby, and
* with what" for this service too, not just PlaystateService. */
private async report(path: string, embyToken: string, body: Record<string, unknown>): Promise<void> {
Logger.playstate(`-> POST ${path} itemId=${body.ItemId}`);
try {
const res = await EmbyClient.fetch(path, embyToken, { method: 'POST', body });
Logger.playstate(`<- ${res.status} ${res.ok ? 'ok' : 'FAILED'} ${path} itemId=${body.ItemId}`);
} catch (err) {
Logger.playstate(`<- threw ${path} itemId=${body.ItemId}: ${(err as Error).message}`);
}
}
}
export const EmbySessionService = new EmbySessionServiceClass();

View file

@ -7,6 +7,7 @@ import { TranscodeService } from './TranscodeService';
import { PartyStreamService } from './PartyStreamService';
import { PlaystateService } from './PlaystateService';
import { CastTokenService } from './CastTokenService';
import { EmbySessionService } from './EmbySessionService';
import type { LeaveResult, MediaType, Party, PartyMember, PartyPublicState, QueueItem } from '../types';
/**
@ -157,6 +158,15 @@ class PartyServiceClass {
party.lastActivityUtc = Date.now();
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"
// No-op if they never had a live Emby Sessions-API entry (never
// connected, or the periodic sweep hadn't reached them yet) - see
// EmbySessionService's own comment. Fire-and-forget, same as every
// other per-member Emby report in this app - it already swallows its
// own failures, and leaving the party must never wait on Emby.
if (leavingMember?.embyToken && leavingMember.embySession) {
const s = leavingMember.embySession;
EmbySessionService.stop(leavingMember.embyToken, s.itemId, s.mediaSourceId, s.playSessionId, leavingMember.positionTicks);
}
let newHostUserId: string | null = party.hostUserId;
if (wasHost) {
@ -256,6 +266,17 @@ class PartyServiceClass {
MusicQueueService.destroy(party); // same leak this class's own destroy path guards against
TranscodeService.endSession(party.id); // same leak - see the "leave" destroy path above
PartyStreamService.endSession(party.id);
// Same reasoning, for any member whose own "leave" (a clean
// disconnect) never fired for whatever reason - a real gap would
// otherwise leave a stale entry sitting in Emby's own dashboard
// indefinitely, not just until the next sweep tick the way a
// still-connected member's stale Progress report would.
for (const member of party.members.values()) {
if (member.embyToken && member.embySession) {
const s = member.embySession;
EmbySessionService.stop(member.embyToken, s.itemId, s.mediaSourceId, s.playSessionId, member.positionTicks);
}
}
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)`);
}
@ -311,7 +332,8 @@ class PartyServiceClass {
embyToken: null,
isPaused: true,
positionTicks: 0,
lastUpdatedUtc: Date.now()
lastUpdatedUtc: Date.now(),
embySession: null
};
}
}

View file

@ -224,6 +224,25 @@ export interface PlaybackOptionsDto {
subtitleTracks: PlaybackTrackOption[];
}
/**
* Emby's own native intro/credits detection (a library's own
* EnableMarkerDetection option, run by its "Detect Episode Intros"
* scheduled task - confirmed directly against a real server's
* /openapi.json: MarkerType is one of Chapter/IntroStart/IntroEnd/
* CreditsStart, mixed into the same Chapters array as the auto-generated
* per-5-minutes ones). Every field is nullable, independently - the task
* may not have reached this item yet, the library may not have the
* feature enabled at all (it's per-library, not global), or a real
* cold-open episode may genuinely have no IntroStart/IntroEnd. None of
* that is an error - see CatalogService.getPlaybackMarkers - a caller
* just checks each field for null before offering the matching skip
* button. */
export interface PlaybackMarkersDto {
introStartTicks: number | null;
introEndTicks: number | null;
creditsStartTicks: number | null;
}
// --- Party (video) -----------------------------------------------------------
/** One entry in a video party's fixed, pre-built queue (a movie/standalone
@ -260,6 +279,22 @@ export interface PartyMember {
lastUpdatedUtc: number;
lastPauseRequestUtc?: number;
lastResumeRequestUtc?: number;
/** This member's own live Emby Sessions-API entry, if one's currently
* open - see EmbySessionService. Null whenever they don't have one
* (not connected/no token yet, or the periodic sweep hasn't started
* one for their current item yet). `itemId` is what the sweep compares
* against on each tick to notice an item change (stop the old
* session, start a fresh one) - `mediaSourceId`/`playSessionId` are
* cached here specifically so a Progress report can reuse them
* without renegotiating PlaybackInfo on every single tick. */
embySession: EmbyPlaybackSession | null;
}
/** See PartyMember.embySession's own comment. */
export interface EmbyPlaybackSession {
itemId: string;
mediaSourceId: string;
playSessionId: string;
}
/** The full internal party record - never sent to the client as-is, see

View file

@ -117,6 +117,42 @@ describe('CatalogService', () => {
assert.equal(options.subtitleTracks.find((t) => t.index === 4)?.isTextBased, true, 'ass is text too');
});
it('getPlaybackMarkers picks out Intro/Credits markers by MarkerType, ignoring the auto-generated Chapter ones', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
Chapters: [
{ StartPositionTicks: 0, Name: 'Chapitre 1', MarkerType: 'Chapter' },
{ StartPositionTicks: 50000000, MarkerType: 'IntroStart' },
{ StartPositionTicks: 900000000, MarkerType: 'IntroEnd' },
{ StartPositionTicks: 3000000000, Name: 'Chapitre 2', MarkerType: 'Chapter' },
{ StartPositionTicks: 20000000000, MarkerType: 'CreditsStart' }
]
}));
const markers = await CatalogService.getPlaybackMarkers(session, 'item1');
assert.equal(markers.introStartTicks, 50000000);
assert.equal(markers.introEndTicks, 900000000);
assert.equal(markers.creditsStartTicks, 20000000000);
});
it('getPlaybackMarkers returns nulls, not an error, when the item has no detected markers at all', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
Chapters: [{ StartPositionTicks: 0, Name: 'Chapitre 1', MarkerType: 'Chapter' }]
}));
const markers = await CatalogService.getPlaybackMarkers(session, 'item1');
assert.deepEqual(markers, { introStartTicks: null, introEndTicks: null, creditsStartTicks: null });
});
it('getPlaybackMarkers returns nulls when the item has no Chapters field at all', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({}));
const markers = await CatalogService.getPlaybackMarkers(session, 'item1');
assert.deepEqual(markers, { introStartTicks: null, introEndTicks: null, creditsStartTicks: null });
});
it('proxySubtitle asks Emby for this track as WebVTT, using the negotiated MediaSourceId', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src7' }] }));
let capturedTarget = '';
@ -169,6 +205,35 @@ describe('CatalogService', () => {
);
});
it('negotiatePlaybackSession returns the MediaSourceId/PlaySessionId a Sessions-API report needs', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
MediaSources: [{ Id: 'src1' }],
PlaySessionId: 'play1'
}));
const result = await CatalogService.negotiatePlaybackSession(session, 'item1');
assert.deepEqual(result, { mediaSourceId: 'src1', playSessionId: 'play1' });
});
it('negotiatePlaybackSession returns null, not a throw, when Emby has no media source at all', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [], PlaySessionId: 'play1' }));
assert.equal(await CatalogService.negotiatePlaybackSession(session, 'item1'), null);
});
it('negotiatePlaybackSession returns null when Emby omits PlaySessionId, even with a real source', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src1' }] }));
assert.equal(await CatalogService.negotiatePlaybackSession(session, 'item1'), null);
});
it('negotiatePlaybackSession returns null, not a throw, when the negotiation itself fails', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => new Response(null, { status: 500 }) as unknown as Response);
assert.equal(await CatalogService.negotiatePlaybackSession(session, 'item1'), null);
});
it('getPlaylistItemsRaw asks Emby\'s own /Playlists endpoint, in order', async (t) => {
let capturedPath = '';
t.mock.method(EmbyClient, 'fetch', async (path: string) => {

View file

@ -0,0 +1,91 @@
// Exercises EmbySessionService directly against a mocked EmbyClient - no
// real Emby server involved. Covers the request shape each of the three
// Sessions-API calls sends and that a failure (a non-ok response, or a
// thrown error) is swallowed rather than propagated - see the class's
// own comment for why none of this may ever be allowed to disrupt real
// playback over an admin-dashboard nicety.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { EmbySessionService } from '../src/services/EmbySessionService';
import { EmbyClient } from '../src/emby/EmbyClient';
function okResponse(): Response {
return new Response(null, { status: 204 }) as unknown as Response;
}
describe('EmbySessionService', () => {
it('start() POSTs to /Sessions/Playing with the negotiated ids and the given position/pause state', async (t) => {
let capturedPath = '';
let capturedToken = '';
let capturedBody: unknown = null;
t.mock.method(EmbyClient, 'fetch', async (path: string, token: string, options: { body?: unknown }) => {
capturedPath = path;
capturedToken = token;
capturedBody = options.body;
return okResponse();
});
await EmbySessionService.start('tok1', 'item1', 'src1', 'play1', 12_345, false);
assert.equal(capturedPath, '/Sessions/Playing');
assert.equal(capturedToken, 'tok1');
assert.deepEqual(capturedBody, {
ItemId: 'item1',
MediaSourceId: 'src1',
PlaySessionId: 'play1',
PositionTicks: 12_345,
IsPaused: false,
PlayMethod: 'Transcode',
CanSeek: true
});
});
it('progress() POSTs to /Sessions/Playing/Progress', async (t) => {
let capturedPath = '';
t.mock.method(EmbyClient, 'fetch', async (path: string) => {
capturedPath = path;
return okResponse();
});
await EmbySessionService.progress('tok1', 'item1', 'src1', 'play1', 999, true);
assert.equal(capturedPath, '/Sessions/Playing/Progress');
});
it('stop() POSTs to /Sessions/Playing/Stopped', async (t) => {
let capturedPath = '';
let capturedBody: unknown = null;
t.mock.method(EmbyClient, 'fetch', async (path: string, _token: string, options: { body?: unknown }) => {
capturedPath = path;
capturedBody = options.body;
return okResponse();
});
await EmbySessionService.stop('tok1', 'item1', 'src1', 'play1', 42);
assert.equal(capturedPath, '/Sessions/Playing/Stopped');
assert.deepEqual(capturedBody, { ItemId: 'item1', MediaSourceId: 'src1', PlaySessionId: 'play1', PositionTicks: 42 });
});
it('rounds a fractional PositionTicks rather than sending a non-integer', async (t) => {
let capturedBody: { PositionTicks?: number } = {};
t.mock.method(EmbyClient, 'fetch', async (_path: string, _token: string, options: { body?: unknown }) => {
capturedBody = options.body as { PositionTicks?: number };
return okResponse();
});
await EmbySessionService.progress('tok1', 'item1', 'src1', 'play1', 12_345.6, false);
assert.equal(capturedBody.PositionTicks, 12_346);
});
it('a non-ok response is logged, not thrown', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => new Response(null, { status: 401 }) as unknown as Response);
await assert.doesNotReject(() => EmbySessionService.start('tok1', 'item1', 'src1', 'play1', 0, false));
});
it('a thrown network error is logged, not propagated', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => { throw new Error('network down'); });
await assert.doesNotReject(() => EmbySessionService.stop('tok1', 'item1', 'src1', 'play1', 0));
});
});

View file

@ -49,7 +49,8 @@ function fakeMember(userId: string, userName: string, embyToken: string): PartyM
embyToken,
isPaused: false,
positionTicks: 0,
lastUpdatedUtc: Date.now()
lastUpdatedUtc: Date.now(),
embySession: null
};
}

View file

@ -4,6 +4,7 @@ import { PartyService } from '../src/services/PartyService';
import { MusicQueueService } from '../src/services/MusicQueueService';
import { PlaystateService } from '../src/services/PlaystateService';
import { CastTokenService } from '../src/services/CastTokenService';
import { EmbySessionService } from '../src/services/EmbySessionService';
import type { AppSocket } from '../src/types';
describe('PartyService', () => {
@ -127,6 +128,33 @@ describe('PartyService', () => {
assert.deepEqual(calls, [[party.id, 'u-bob']]);
});
it('leave() stops whatever Emby Sessions-API entry the leaving member had open', (t) => {
const calls: unknown[][] = [];
t.mock.method(EmbySessionService, 'stop', async (...args: unknown[]) => { calls.push(args); });
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const bob = PartyService.join(party, 'u-bob', 'Bob');
bob.embyToken = 'tok-bob';
bob.positionTicks = 555;
bob.embySession = { itemId: 'm1', mediaSourceId: 'src1', playSessionId: 'play1' };
PartyService.leave(party, 'u-bob');
assert.deepEqual(calls, [['tok-bob', 'm1', 'src1', 'play1', 555]]);
});
it('leave() does not call EmbySessionService.stop for a member with no open session', (t) => {
let called = false;
t.mock.method(EmbySessionService, 'stop', async () => { called = true; });
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
PartyService.join(party, 'u-bob', 'Bob'); // embyToken/embySession both still null - see newMember
PartyService.leave(party, 'u-bob');
assert.equal(called, false);
});
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 bob = PartyService.join(party, 'u-bob', 'Bob');