Replace PlayingItems/Progress with direct UserData writes
All checks were successful
Publier l'image Docker / test (push) Successful in 21s
Publier l'image Docker / publish (push) Successful in 13s

Report: "still don't see my Emby profile updated - watched items
should be marked read, partially-watched ones should show in Resume".
After two rounds of fixing PlayingItems/Progress's request shape
(missing MediaSourceId, then missing PlaySessionId), this pointed at
something more fundamental: this app deliberately never calls the
matching PlayingItems "Start"/"Stop" - a documented v1 scope cut - and
a bare Progress report with no corresponding Start may simply never
reach the resume-position/watched-history data real clients read,
regardless of how correctly-shaped the request is.

Confirmed directly against the real server that this data lives
somewhere else entirely, trivially readable/writable with no playback
session required at all: every item Emby returns already carries the
requesting user's own UserData embedded (PlaybackPositionTicks/Played/
PlayCount/IsFavorite/...) - the exact thing Continue Watching and the
watched checkmark actually read - via a plain
POST /Users/{userId}/Items/{itemId}/UserData.

Switched entirely to that instead of layering a third fix onto
PlayingItems:
- PlaystateService.updatePosition (replacing reportProgress, same
  ~20s sweep in index.ts) updates just PlaybackPositionTicks.
- New PlaystateService.markPlayed sets Played: true and resets
  PlaybackPositionTicks to 0 - called once per connected member (with
  a token) when the party moves past an item for good:
  PartyService.advanceQueue (next episode) and
  MusicQueueService.playNext (a track naturally finishing, or a
  majority vote-skip - both go through this one method). Addresses
  the other half of the report ("watched items marked as watched").
- Both go through one private fetch-merge-write helper: GET the item
  first, apply only the field(s) being changed to a copy of its
  current UserData, POST the whole result back. Emby's spec documents
  the body as the complete UserItemDataDto with nothing indicating
  merge-not-replace semantics, and this is the one write in the whole
  app where guessing wrong risks actually erasing a user's own real
  data (a favorite, a rating) - so never a bare partial POST.

Reverted the MediaSourceId/PlaySessionId plumbing rounds 1-2 added
(CatalogService.negotiateStreamTarget back to returning a bare target
path, PartyStreamService no longer caches either) - nothing needs them
now that UserData doesn't require an active PlayingItems session.

Not yet confirmed end to end against a real server (no safe way to
fire a real, state-mutating test call without one).

Tests: PlaystateService's fetch-merge-write behavior in detail (a
favorite/rating/play-count survives a position-only update byte for
byte, a Played-only update doesn't disturb IsFavorite, a failed read
skips the write entirely, an item with no UserData yet starts from an
empty object), its request/outcome logging including that the token
never leaks into a log line, and that advanceQueue/playNext call
markPlayed for the outgoing item only, only for connected+tokened
members. catalogService/partyStreamService tests reverted to their
simpler pre-existing shape. Full typecheck/test/build green
(107/107 tests). docs/architecture.md and README.md updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-14 23:53:59 +02:00
parent 939beb6fa9
commit b167e321e7
14 changed files with 538 additions and 348 deletions

View file

@ -108,15 +108,21 @@ attempt at a Blu-ray-style subtitle as the thing to double-check here.
unconfirmed (the ffmpeg direct-stream URL, the Playlists endpoint, the unconfirmed (the ffmpeg direct-stream URL, the Playlists endpoint, the
Emby-playstate sync endpoint) were all checked directly against Emby's Emby-playstate sync endpoint) were all checked directly against Emby's
own real OpenAPI spec. Two matched exactly. The third - playstate sync own real OpenAPI spec. Two matched exactly. The third - playstate sync
- took two rounds to actually fix: `PositionTicks`/`IsPaused` were - took three rounds: two request-shape bugs against Emby's
being sent as a JSON body when Emby expects query parameters, alongside `PlayingItems/Progress` check-in endpoint (a missing `MediaSourceId`,
a required `MediaSourceId` that wasn't being sent at all (round 1); a then a missing `PlaySessionId`), then a further report ("still don't
real request logged after that shipped still failed, this time with see this in my Emby profile") that questioned the whole approach - a
Emby's own "Value cannot be null (Parameter 'key')" - traced to a bare `Progress` report with no matching "Start" call may just never
missing `PlaySessionId` (round 2, from new logging added specifically reach the resume-position/watched-history data real clients read,
to catch exactly this). Both fixed, though round 2 hasn't yet been regardless of request shape. Switched entirely to a plain
confirmed against a real server the way round 1's own bad request was `POST /Users/{userId}/Items/{itemId}/UserData` instead - confirmed
- see docs/architecture.md's "Sync watch-party progress back to Emby" directly against the real server that this is exactly the data model
"Continue Watching" and the watched checkmark read, updated with no
playback session required at all - and added marking an item Played
(the other half of the same report) when the party moves past it.
Not yet confirmed end to end against a real server (no safe way to
fire a real, state-mutating test call without one) - see
docs/architecture.md's "Sync watch-party progress back to Emby"
section for the full story. The one-shared-stream-per-party work is a section for the full story. The one-shared-stream-per-party work is a
different kind of unverified: its own Emby API calls were already live, different kind of unverified: its own Emby API calls were already live,
confirmed code, just newly shared instead of per-viewer - what's untested is behavior confirmed code, just newly shared instead of per-viewer - what's untested is behavior
@ -272,7 +278,8 @@ Then put it behind your existing reverse proxy like everything else
- [x] Browse Emby playlists and launch one as a pre-queued party (video - [x] Browse Emby playlists and launch one as a pre-queued party (video
queue or the participative music queue) — see docs/architecture.md queue or the participative music queue) — see docs/architecture.md
- [x] Sync watch-party progress back to Emby's own PlaystateService, per - [x] Sync watch-party progress back to Emby's own PlaystateService, per
member's own account — see docs/architecture.md member's own account, including marking an item watched once the
party moves past it — see docs/architecture.md
- [x] One shared Emby stream per party instead of one per viewer — see - [x] One shared Emby stream per party instead of one per viewer — see
docs/architecture.md docs/architecture.md
- [x] Text subtitle choice joins burn-in as a host-only, party-wide pick - [x] Text subtitle choice joins burn-in as a host-only, party-wide pick

View file

@ -1989,106 +1989,122 @@ thrown - this runs on a plain `setInterval` (~20s, alongside
with no request to fail and no business ever disrupting real playback with no request to fail and no business ever disrupting real playback
over a background sync hiccup. over a background sync hiccup.
**TODO(verify) resolved - and the original guess was wrong.** Checked **Round 1, TODO(verify) resolved - and the original guess was wrong.**
directly against Emby's own real OpenAPI spec (swagger.emby.media, Checked directly against Emby's own real OpenAPI spec
pointed at a real server) after a "watch status" bug report suggested (swagger.emby.media, pointed at a real server) after a "watch status"
this wasn't actually working: `POST /Users/{UserId}/PlayingItems/{Id} bug report suggested this wasn't actually working: `POST /Users
/Progress` does exist at that exact path, but the spec declares /{UserId}/PlayingItems/{Id}/Progress` does exist at that exact path,
`PositionTicks`/`IsPaused` as *query string* parameters, not body but the spec declares `PositionTicks`/`IsPaused` as *query string*
fields - and a third parameter, `MediaSourceId`, is *required* and parameters, not body fields - and a third parameter, `MediaSourceId`,
wasn't being sent at all. The request body schema this endpoint does is *required* and wasn't being sent at all. Fixed: sent as query
list (`Api.OnPlaybackProgress`) covers a handful of unrelated fields parameters instead, `MediaSourceId` resolved from the same
(`PlaylistIndex`/`Shuffle`/`SleepTimerMode`/...), none of them these - `PlaybackInfo` negotiation `CatalogService.negotiateStreamTarget`
so the original guess was sending `PositionTicks`/`IsPaused` somewhere already does for the party's shared stream. While checking this, the
Emby never reads them, while omitting a parameter Emby requires just app's other two Emby-API assumptions (the ffmpeg direct-stream URL,
to accept the call at all. In practice this almost certainly meant the `/Playlists/{Id}/Items` endpoint) were re-checked against the same
every progress report was failing outright, silently (best-effort by real spec too - both already correct.
design - see above - so nothing user-visible ever broke loudly; only
`[watchparty:emby]`'s own status+body capture would have shown it,
and no one had gone looking until this bug report).
Fixed: `reportProgress` now sends `MediaSourceId`/`PositionTicks`/ **Round 2, caught immediately from new logging added specifically to
`IsPaused` as query parameters, no body at all. `MediaSourceId` comes catch exactly this** (every outgoing request + its outcome, under
from `PartyStreamService`'s own cache - `CatalogService `[watchparty:playstate]` - see `Logger.playstate`'s own comment for why
.negotiateStreamTarget` already resolves a real one for every party's this needed to be enough on its own to answer "is this actually
shared stream negotiation (see "One shared Emby stream per party" reaching Emby, and with what"): a real request logged after round 1
below), so `PartyStreamService` now caches it alongside the target shipped still came back `400`, now `Value cannot be null. (Parameter
path and exposes it read-only via a new `getCachedMediaSourceId 'key')` - a bare .NET `Dictionary` null-key exception, and *not* the
(partyId)`. Deliberately passive - it never triggers a negotiation of earlier problem (`MediaSourceId` was present and well-formed).
its own - so a party where no one has started playback yet this `PlaySessionId` was the standout candidate - how Emby/Jellyfin clients
session simply has nothing to report; `index.ts`'s sweep skips it, the normally correlate a report with the specific playback session it
same way it already skips a party with nothing playing at all, rather belongs to - and sending it (resolved the same way `MediaSourceId`
than paying for an extra PlaybackInfo round trip on every single tick was) fixed this particular error.
just to get a value the party's already-negotiated stream produces for
free.
While checking this, the app's other two Emby-API assumptions (the **Round 3: a further report questioned the whole approach, not just
ffmpeg direct-stream URL in `TranscodeService.buildSourceUrl`, and the its request shape** - "still don't see this in my Emby profile /
`/Playlists/{Id}/Items` endpoint in `CatalogService`) were re-checked Continue Watching" - and that turned out to be the right instinct.
against the same real spec - both match with no missing required This app deliberately never calls the matching `POST .../PlayingItems
parameter, consistent with them already having been tried for real /{Id}` ("Start") or `DELETE .../PlayingItems/{Id}` ("Stop") - a
successfully. documented v1 scope cut (see the original framing of this section,
still true) - and a bare `Progress` report with no corresponding
"Start" may simply never reach the resume-position/watched-history
data real clients read, regardless of how correctly-shaped the request
is by that point. Confirmed directly against the real server that this
data lives somewhere else entirely, trivially reachable without any
session lifecycle at all: every item Emby returns already carries the
requesting user's own `UserData` embedded
(`PlaybackPositionTicks`/`Played`/`PlayCount`/`IsFavorite`/...) - the
exact thing "Continue Watching" and the watched checkmark actually
read - and a plain `POST /Users/{UserId}/Items/{ItemId}/UserData`
updates it directly.
**Made the sweep's own traffic visible, not just its failures** - asked **Switched to that entirely** rather than layering a third fix onto
for directly, once fixing the request shape raised the obvious `PlayingItems` - `PlaystateService.updatePosition` (periodic, ~20s,
follow-up question ("is it actually being sent correctly now?"). replacing `reportProgress`) and a new `PlaystateService.markPlayed`
`reportProgress` now logs the exact outgoing request (`-> POST (see below) both go through one private `updateUserData` helper.
/Users/{userId}/PlayingItems/{itemId}/Progress?MediaSourceId=...&...`) Simpler than the endpoint it replaces (one JSON body, no query-string
right before sending it, and its outcome right after (`<- 200 ok` / parameter shape to get wrong) and more direct (writes the exact data
`<- 400 FAILED` / `<- threw ...`), all under the same model the UI reads, rather than a live-session side-channel whose
`[watchparty:playstate]` prefix - so `docker logs | grep effect on that data model was the very thing in doubt). The
watchparty:playstate` answers "what did this send, and did Emby accept `MediaSourceId`/`PlaySessionId` plumbing rounds 1-2 added
it" on its own, without cross-referencing `EmbyClient`'s own generic (`CatalogService.negotiateStreamTarget` returning them,
`[watchparty:emby]` failure log (which still fires too, independently, `PartyStreamService` caching them) was removed along with it -
with the fuller error body). Safe to log the full path here, unlike `negotiateStreamTarget` is back to returning a bare target path, since
`Logger.stream`'s own deliberately-partial logging - this query string nothing needs those two values for anything else.
is only ever `MediaSourceId`/`PlaySessionId`/`PositionTicks`/`IsPaused`,
never a token; the real `embyToken` travels as a header, never in the
URL, and the tests confirm it never ends up in these lines either.
**Round 2, caught immediately from that new logging**: a real request **Fetch-merge-write, deliberately, never a bare partial POST.** Emby's
logged after the fix above shipped still came back `400`, now `Value OpenAPI spec documents the `UserData` endpoint's body as the *complete*
cannot be null. (Parameter 'key')` - a bare .NET `Dictionary` null-key `UserItemDataDto`, with nothing suggesting a partial object merges
exception, and *not* the earlier problem (the logged request's rather than replaces - and this is the one write in the whole app where
`MediaSourceId` was present and well-formed). This exact message had guessing wrong risks actually *erasing* a user's own real data (a
already burned this app once before, in `CatalogService favorite, a rating), not just silently doing nothing the way the
.negotiateStreamTarget`'s hand-built fallback URL (see its own `Progress`-endpoint bugs did. So every call to `updateUserData` first
TODO(verify) comment) - diagnosed there as Emby indexing some internal `GET`s the item (confirmed directly against the real server that Emby
state by a parameter the request hadn't supplied. `PlaySessionId` was always returns it with the requesting user's current `UserData`
the standout remaining candidate: it's how Emby/Jellyfin clients embedded), applies just the one or two fields being changed to a copy
normally correlate a progress report with the specific playback/ of exactly that, and `POST`s the whole result back - correct regardless
transcode session it belongs to, and evidently something in Emby's real of which way the real merge-vs-replace semantics actually go, at the
handler keys a lookup on it even though the OpenAPI spec doesn't mark cost of one extra read before every write.
it required. Now sent too, resolved the exact same way `MediaSourceId`
already was: `CatalogService.negotiateStreamTarget` already reads **Marking items Played, addressing the other half of the same
`info.PlaySessionId` from the very same `PlaybackInfo` response (it report** ("les éléments visionnés marqué comme lu") - not attempted in
already used it in the hand-built fallback URL) - its return type any earlier round. `PlaystateService.markPlayed` sets `Played: true`
(`NegotiatedStreamTarget`) just needed to surface it, so and resets `PlaybackPositionTicks` to `0`, called once per currently-
`PartyStreamService` could cache it alongside `mediaSourceId` and connected member (with a token) at the exact moment the party moves
expose both together from one accessor, past an item for good: `PartyService.advanceQueue` (next episode, for
`getCachedPlaybackIds(partyId)`, replacing the round-1 `getCached the *outgoing* item, captured before the party's own state moves on)
MediaSourceId`. Not independently confirmed against a real server yet and `MusicQueueService.playNext` (a track naturally finishing, or a
(no way to safely fire a real, state-mutating test call without one) - majority vote-skip - both go through this one method, so both get the
flagged here the same way every other real-server-dependent guess in same treatment; `music.current` captured as `outgoing` before it's
this app is, but grounded in this app's own prior, confirmed diagnosis reassigned). Fire-and-forget, same as the periodic position sync - a
of the identical error message rather than a fresh guess. member's bad token or a transient Emby hiccup must never block the
party from actually advancing. This is a deliberate heuristic ("the
party moved on from it" = "done with it"), not a real "did they watch
the whole thing" check - this app has no reliable way to know that
beyond what it already tracks, and most real streaming clients treat a
skip as a play too. Precision beyond that (e.g. only marking Played
past some percentage watched) would need every queued item's own
duration tracked everywhere it's queued, which nothing currently does -
left as a documented gap, not built speculatively.
Verified: full typecheck/test/build on both projects. Unit tests cover Verified: full typecheck/test/build on both projects. Unit tests cover
`getCurrentPlaybackState` (video reads the host, music reads the shared `getCurrentPlaybackState`, `setMemberEmbyToken`,
queue, both branches, plus the empty-queue null case), `PlaystateService.updatePosition`/`markPlayed`'s fetch-merge-write
`setMemberEmbyToken`, `PlaystateService.reportProgress`'s corrected behavior in detail - a favorite/rating/play-count this app never asked
query-string request shape including `PlaySessionId` (and that a thrown to change survives a position update byte for byte, a `Played`-only
failure never propagates), its own request/outcome logging (success, a update via `markPlayed` doesn't disturb `IsFavorite`, a failed *read*
non-ok status, and a thrown error each produce a distinct, greppable skips the write entirely rather than posting incomplete data, an item
line, and the token itself never appears in one), and with no `UserData` at all yet starts cleanly from an empty object - its
`PartyStreamService.getCachedPlaybackIds`'s own request/outcome logging (success, a non-ok status on either call,
cache-hit/cache-miss/dropped-on-failure behavior for both ids together. and a thrown error each produce a distinct, greppable line, and the
`EmbyClient.fetch`'s optional body-encoding capability stays generic token itself never appears in one), and that
plumbing (its own test no longer claims PlaystateService as the reason `PartyService.advanceQueue`/`MusicQueueService.playNext` call
it exists) - no current call site needs it any more, but it costs `markPlayed` for the *outgoing* item only, only for members who are
nothing to keep for whatever future caller does. No client changes at both connected and have a known token, mirroring the exact same guard
all - this is a pure server-side background process with nothing for `index.ts`'s periodic sweep already uses. `EmbyClient.fetch`'s optional
the browser to render or react to. body-encoding capability (added for this feature's very first attempt)
stays generic plumbing - still exercised by this feature's own
`UserData` POST, so unlike after round 2 it's not just kept around for
a hypothetical future caller. No client changes at all - this is a pure
server-side background/event-driven process with nothing for the
browser to render or react to.
## One shared Emby stream per party (not one per viewer) ## One shared Emby stream per party (not one per viewer)

View file

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

View file

@ -503,27 +503,23 @@ setInterval(() => TranscodeService.reapIdleSessions(30 * 60 * 1000), 5 * 60 * 10
// destroy branch (and an item/track change) ending it immediately. // destroy branch (and an item/track change) ending it immediately.
setInterval(() => PartyStreamService.reapIdleSessions(30 * 60 * 1000), 5 * 60 * 1000); setInterval(() => PartyStreamService.reapIdleSessions(30 * 60 * 1000), 5 * 60 * 1000);
// Pushes every connected member's own progress to Emby, under their own // Pushes every connected member's own resume position to Emby (their own
// account - see docs/architecture.md's "Sync watch-party progress back // UserData for the item, under their own account) - see
// to Emby" section for the full design and its deliberate v1 scope // docs/architecture.md's "Sync watch-party progress back to Emby"
// (periodic Progress reports only, no explicit start/stop). Best-effort: // section for the full history of why this writes UserData directly
// PlaystateService.reportProgress swallows its own failures, so one bad // rather than Emby's PlayingItems/Progress check-in endpoint. Best-effort:
// PlaystateService.updatePosition swallows its own failures, so one bad
// token or a transient Emby hiccup never stops the rest of this sweep. // token or a transient Emby hiccup never stops the rest of this sweep.
// MediaSourceId/PlaySessionId come from PartyStreamService's own cache, // Marking an item Played (once the party moves past it) is handled
// read-only (getCachedPlaybackIds never negotiates) - both matter to the // separately, at the source of that transition - see
// real Progress endpoint in practice (see PlaystateService.ts), and // PartyService.advanceQueue and MusicQueueService.playNext.
// skipping a party entirely when nothing's cached yet (no one has hit
// play this session) is simpler and cheaper than this sweep ever
// negotiating a stream of its own just to get them.
setInterval(() => { setInterval(() => {
for (const party of PartyService.listActive()) { for (const party of PartyService.listActive()) {
const state = PartyService.getCurrentPlaybackState(party); const state = PartyService.getCurrentPlaybackState(party);
if (!state) continue; // nothing currently playing (e.g. an empty music queue) if (!state) continue; // nothing currently playing (e.g. an empty music queue)
const playbackIds = PartyStreamService.getCachedPlaybackIds(party.id);
if (!playbackIds) continue; // nothing negotiated yet this session - Progress needs it
for (const member of party.members.values()) { for (const member of party.members.values()) {
if (!member.connection || !member.embyToken) continue; if (!member.connection || !member.embyToken) continue;
PlaystateService.reportProgress(member.embyToken, member.userId, state.itemId, playbackIds.mediaSourceId, playbackIds.playSessionId, state.positionTicks, state.isPaused); PlaystateService.updatePosition(member.embyToken, member.userId, state.itemId, state.positionTicks);
} }
} }
}, 20 * 1000); }, 20 * 1000);

View file

@ -13,13 +13,6 @@ import type { EmbyItem, EmbySession, ItemDto, LibraryDto, MediaType, PlaybackOpt
// actually stream. "Voir sur Emby" always works though. // actually stream. "Voir sur Emby" always works though.
const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio']); const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio']);
/** negotiateStreamTarget's return shape - see its own comment. */
export interface NegotiatedStreamTarget {
targetPath: string;
mediaSourceId: string;
playSessionId: string;
}
// Emby's /Views can return all sorts of things beyond actual media // Emby's /Views can return all sorts of things beyond actual media
// libraries - boxset collections, live TV, photos, books, a mixed-content // libraries - boxset collections, live TV, photos, books, a mixed-content
// folder with no CollectionType at all - none of which this app has any // folder with no CollectionType at all - none of which this app has any
@ -310,23 +303,8 @@ class CatalogServiceClass {
* encoding option this app has no visibility into, sidesteps the * encoding option this app has no visibility into, sidesteps the
* entire class of failure. * entire class of failure.
* *
* Returns `mediaSourceId`/`playSessionId` alongside the target path,
* not just the path - PartyStreamService caches all three, and
* PlaystateService's own Progress report needs both too: MediaSourceId
* is a required query parameter there (confirmed against Emby's real
* OpenAPI spec), and PlaySessionId - though only optional per that
* same spec - turned out to matter in practice too: a real report sent
* without one hit Emby's own "Value cannot be null (Parameter 'key')"
* (a bare .NET Dictionary null-key exception) - the exact error this
* app already ran into once before, in the stream-negotiation fallback
* URL above, for the same underlying reason (see that TODO(verify)
* comment) - so Emby's real Progress handler evidently keys some
* internal lookup (an active transcode/session entry) on it even
* though the spec doesn't mark it required. Resolving both here, once
* per negotiation, means the playstate sweep never has to make its own
* extra PlaybackInfo round trip just to get them.
*/ */
async negotiateStreamTarget(session: EmbySession, itemId: string, isAudio: boolean, audioStreamIndex: string, maxStreamingBitrate: string): Promise<NegotiatedStreamTarget> { async negotiateStreamTarget(session: EmbySession, itemId: string, isAudio: boolean, audioStreamIndex: string, maxStreamingBitrate: string): Promise<string> {
// Applied to the negotiation itself, not just the final URL - a pick // Applied to the negotiation itself, not just the final URL - a pick
// could otherwise silently do nothing if Emby's negotiation didn't // could otherwise silently do nothing if Emby's negotiation didn't
// apply the query-string override the same way its own web client's // apply the query-string override the same way its own web client's
@ -355,7 +333,7 @@ class CatalogServiceClass {
const params = new URLSearchParams(urlQuery || ''); const params = new URLSearchParams(urlQuery || '');
this.applyPlaybackOverrides(params, audioStreamIndex, maxStreamingBitrate); this.applyPlaybackOverrides(params, audioStreamIndex, maxStreamingBitrate);
this.logStreamRequest(itemId, 'negotiated', params); this.logStreamRequest(itemId, 'negotiated', params);
return { targetPath: `${urlPath}?${params}`, mediaSourceId: source.Id || '', playSessionId: info.PlaySessionId || '' }; return `${urlPath}?${params}`;
} }
const params = new URLSearchParams({ const params = new URLSearchParams({
@ -367,7 +345,7 @@ class CatalogServiceClass {
}); });
this.applyPlaybackOverrides(params, audioStreamIndex, maxStreamingBitrate); this.applyPlaybackOverrides(params, audioStreamIndex, maxStreamingBitrate);
this.logStreamRequest(itemId, 'fallback', params); this.logStreamRequest(itemId, 'fallback', params);
return { targetPath: `/${isAudio ? 'Audio' : 'Videos'}/${itemId}/master.m3u8?${params}`, mediaSourceId: source.Id || '', playSessionId: info.PlaySessionId || '' }; return `/${isAudio ? 'Audio' : 'Videos'}/${itemId}/master.m3u8?${params}`;
} }
/** /**

View file

@ -2,6 +2,7 @@
import { Logger } from '../Logger'; import { Logger } from '../Logger';
import { PartyStreamService } from './PartyStreamService'; import { PartyStreamService } from './PartyStreamService';
import { PlaystateService } from './PlaystateService';
import type { AppServer, MusicCurrentPublic, MusicPublicState, MusicTrack, MusicTrackInput, Party } from '../types'; import type { AppServer, MusicCurrentPublic, MusicPublicState, MusicTrack, MusicTrackInput, Party } from '../types';
/** /**
@ -164,12 +165,28 @@ class MusicQueueServiceClass {
private playNext(party: Party): void { private playNext(party: Party): void {
const music = party.music!; const music = party.music!;
const outgoing = music.current; // the track finishing/being skipped, if any - see the markPlayed loop below
this.clearTimer(party); this.clearTimer(party);
music.skipVotes.clear(); music.skipVotes.clear();
// Whatever was previously negotiated (see PartyStreamService) was for // Whatever was previously negotiated (see PartyStreamService) was for
// the outgoing track - never valid for whatever plays next, whether // the outgoing track - never valid for whatever plays next, whether
// that's a new track or nothing at all. // that's a new track or nothing at all.
PartyStreamService.endSession(party.id); PartyStreamService.endSession(party.id);
// The queue moving past `outgoing` - naturally finishing, or a
// majority vote-skip, either way - is this app's own signal that
// it's "done" with that track, for every member connected right now.
// Fire-and-forget, same reasoning as PartyService.advanceQueue's own
// identical loop for video - see PlaystateService.markPlayed. `if
// (outgoing)` skips this on the very first call for a party (the
// queue was empty, nothing outgoing to mark).
if (outgoing) {
for (const member of party.members.values()) {
if (!member.connection || !member.embyToken) continue;
PlaystateService.markPlayed(member.embyToken, member.userId, outgoing.track.itemId);
}
}
const next = music.queue.shift(); const next = music.queue.shift();
if (!next) { if (!next) {
music.current = null; music.current = null;

View file

@ -5,6 +5,7 @@ import { Logger } from '../Logger';
import { MusicQueueService } from './MusicQueueService'; import { MusicQueueService } from './MusicQueueService';
import { TranscodeService } from './TranscodeService'; import { TranscodeService } from './TranscodeService';
import { PartyStreamService } from './PartyStreamService'; import { PartyStreamService } from './PartyStreamService';
import { PlaystateService } from './PlaystateService';
import type { LeaveResult, MediaType, Party, PartyMember, PartyPublicState, QueueItem } from '../types'; import type { LeaveResult, MediaType, Party, PartyMember, PartyPublicState, QueueItem } from '../types';
/** /**
@ -193,6 +194,7 @@ class PartyServiceClass {
if (party.queueIndex >= party.queue.length - 1) { if (party.queueIndex >= party.queue.length - 1) {
return false; return false;
} }
const outgoingItemId = party.itemId; // whatever was just playing - see the markPlayed loop below
party.queueIndex += 1; party.queueIndex += 1;
const next = party.queue[party.queueIndex]!; const next = party.queue[party.queueIndex]!;
party.itemId = next.itemId; party.itemId = next.itemId;
@ -203,6 +205,18 @@ class PartyServiceClass {
member.isPaused = false; member.isPaused = false;
member.lastUpdatedUtc = Date.now(); member.lastUpdatedUtc = Date.now();
} }
// The party moving on from `outgoingItemId` is this app's own signal
// that it's "done" - see PlaystateService.markPlayed's own comment
// for why that's a heuristic, not a real "watched it all" check, and
// why every currently-connected member gets their own account
// updated, not just the host. Fire-and-forget, same as the periodic
// position sync in index.ts - a member's bad token or a transient
// Emby hiccup here must never block the party from actually moving
// on to the next item.
for (const member of party.members.values()) {
if (!member.connection || !member.embyToken) continue;
PlaystateService.markPlayed(member.embyToken, member.userId, outgoingItemId);
}
// A new item's own MediaStreams indices have nothing to do with the // A new item's own MediaStreams indices have nothing to do with the
// old one's - carrying a burn-in or text subtitle pick across would // old one's - carrying a burn-in or text subtitle pick across would
// show whatever track happens to share that index on the new item, // show whatever track happens to share that index on the new item,

View file

@ -11,15 +11,6 @@ interface StreamSession {
audioStreamIndex: string; audioStreamIndex: string;
maxStreamingBitrate: string; maxStreamingBitrate: string;
targetPath: string; targetPath: string;
/** Alongside targetPath, from the same negotiation - see
* CatalogService.negotiateStreamTarget's own comment. Surfaced
* read-only, together, via getCachedPlaybackIds() below for
* PlaystateService's own Progress report, which needs both too (see
* that method's own comment for why) but must never trigger a
* negotiation of its own just to get them - see index.ts's periodic
* sweep. */
mediaSourceId: string;
playSessionId: string;
lastAccessUtc: number; lastAccessUtc: number;
} }
@ -67,30 +58,11 @@ class PartyStreamServiceClass {
return existing.targetPath; return existing.targetPath;
} }
const { targetPath, mediaSourceId, playSessionId } = await CatalogService.negotiateStreamTarget(session, itemId, isAudio, audioStreamIndex, maxStreamingBitrate); const targetPath = await CatalogService.negotiateStreamTarget(session, itemId, isAudio, audioStreamIndex, maxStreamingBitrate);
this.sessions.set(party.id, { itemId, audioStreamIndex, maxStreamingBitrate, targetPath, mediaSourceId, playSessionId, lastAccessUtc: Date.now() }); this.sessions.set(party.id, { itemId, audioStreamIndex, maxStreamingBitrate, targetPath, lastAccessUtc: Date.now() });
return targetPath; return targetPath;
} }
/**
* Read-only: the MediaSourceId/PlaySessionId behind this party's
* cached stream, or null if nothing has been negotiated yet this
* session (e.g. the party just started and no one has hit play).
* Deliberately does NOT call getOrNegotiate/touch lastAccessUtc - this
* is a passive lookup for index.ts's periodic playstate sweep, not
* real streaming traffic, and must never itself keep an otherwise-idle
* session alive (see reapIdleSessions) or trigger a fresh Emby
* negotiation on its own timer tick. Returns both together, from the
* same cache entry, rather than two separate lookups - they always
* come from the same negotiation, and PlaystateService needs both at
* once anyway (see its own comment for why PlaySessionId turned out to
* matter too, not just MediaSourceId).
*/
getCachedPlaybackIds(partyId: string): { mediaSourceId: string; playSessionId: string } | null {
const session = this.sessions.get(partyId);
return session ? { mediaSourceId: session.mediaSourceId, playSessionId: session.playSessionId } : null;
}
/** GET /api/catalog/party-stream/:partyId/master.m3u8 - the caller /** GET /api/catalog/party-stream/:partyId/master.m3u8 - the caller
* (index.ts) has already checked the requester is a member of `party`. */ * (index.ts) has already checked the requester is a member of `party`. */
async serveMasterPlaylist(req: Request, res: Response, party: Party, session: EmbySession): Promise<void> { async serveMasterPlaylist(req: Request, res: Response, party: Party, session: EmbySession): Promise<void> {

View file

@ -3,78 +3,136 @@
import { EmbyClient } from '../emby/EmbyClient'; import { EmbyClient } from '../emby/EmbyClient';
import { Logger } from '../Logger'; import { Logger } from '../Logger';
/** The subset of Emby's own UserItemDataDto this app ever reads/writes -
* see updateUserData's own comment for why this is always read back in
* full before being changed, never posted as a bare partial object. */
interface EmbyUserData {
PlaybackPositionTicks?: number;
PlayCount?: number;
IsFavorite?: boolean;
Played?: boolean;
PlayedPercentage?: number;
LastPlayedDate?: string;
Rating?: number;
}
/** /**
* Reports a party member's own playback progress back to Emby, under * Writes a party member's own watch/listen state back to Emby, under
* *their own* account - the one piece of this app that ever writes * *their own* account - the one piece of this app that ever writes
* anything to Emby rather than just reading/proxying it (see * anything to Emby rather than just reading/proxying it (see
* docs/architecture.md's "Sync watch-party progress back to Emby" * docs/architecture.md's "Sync watch-party progress back to Emby"
* section for the full design and its deliberate v1 scope). Called from * section for the fuller history - this class went through two rounds
* index.ts's periodic sweep, never from a request/response cycle - see * of chasing the wrong endpoint before landing here).
* that file for who actually decides *when* and *with what* to call
* this.
* *
* Emby's own "PlaystateService" (the controller name in both Emby's and * v1 tried Emby's `PlaystateService` "PlayingItems" check-in endpoints
* Jellyfin's server source) is what exposes this route. * (`POST .../Progress`) - the standard shape a live media client uses to
* * report ongoing playback. Two real request-shape bugs there were found
* Endpoint/param shape CONFIRMED against Emby's own real OpenAPI spec * and fixed (a missing required `MediaSourceId`, then a missing
* (swagger.emby.media, pointed at a real server) - the original guess * `PlaySessionId` that crashed Emby's own handler with "Value cannot be
* here was wrong in a way that plausibly broke this entire feature: * null"), but a further report ("still don't see this in my Emby
* PositionTicks/IsPaused were sent as a JSON body, but Emby declares * profile / Continue Watching") pointed at something more fundamental:
* them (and a REQUIRED MediaSourceId this app wasn't sending at all) as * this app deliberately never calls the matching `POST .../PlayingItems
* *query string* parameters instead - the request body schema * /{Id}` ("Start") or `DELETE .../PlayingItems/{Id}` ("Stop") - a
* (Api.OnPlaybackProgress) covers a handful of unrelated fields * documented, deliberate v1 scope cut (see the same architecture.md
* (PlaylistIndex/Shuffle/SleepTimerMode/...), none of them these. A * section) - and Progress reports with no corresponding "Start" may
* missing required query parameter almost certainly meant every one of * simply never reach the resume-position/watched-history data real
* these calls was failing outright. * clients read, regardless of how correctly-shaped the request is.
* * Confirmed against the real server that this data lives somewhere
* Round 2, from a real request logged after that fix shipped: still a * else entirely and is trivially readable/writable directly: every
* 400, now `Value cannot be null. (Parameter 'key')` - a bare .NET * item Emby returns already carries an embedded `UserData` object
* `Dictionary` null-key exception, and *not* the earlier problem * (`PlaybackPositionTicks`/`Played`/`PlayCount`/`IsFavorite`/...) - the
* (MediaSourceId was present and well-formed in the logged request). * exact thing "Continue Watching" and the watched checkmark actually
* This exact message already burned this app once before, in * read - and Emby exposes a plain `POST /Users/{UserId}/Items/{ItemId}
* CatalogService.negotiateStreamTarget's hand-built fallback URL (see * /UserData` to update it directly, no active playback session
* its own TODO(verify) comment) - diagnosed there as Emby's server * required at all. Switched to that entirely instead of layering a
* indexing some internal state by a param this app hadn't supplied. * third fix onto the PlayingItems approach - simpler (one JSON body,
* `PlaySessionId` is the standout candidate: it's how Emby/Jellyfin * no query-string parameter shape to get wrong) and more direct (writes
* clients normally correlate a progress report with the specific * the exact data model the UI reads, rather than a live-session
* playback/transcode session it belongs to, and evidently something on * side-channel whose effect on that data model was the very thing in
* Emby's side keys a lookup on it even though the OpenAPI spec doesn't * doubt).
* mark it required. Now sent too - see PartyStreamService
* .getCachedPlaybackIds, which resolves it the exact same way
* MediaSourceId already was (both come from the one PlaybackInfo
* negotiation CatalogService.negotiateStreamTarget already does for the
* party's shared stream).
*/ */
class PlaystateServiceClass { class PlaystateServiceClass {
/** /**
* Best-effort: any failure (Emby unreachable, a stale/expired token, * Called periodically (~20s, see index.ts's sweep) while a member is
* a 4xx/5xx) is caught and logged here, never thrown - this runs on a * connected to a party with something playing: updates just their own
* timer with no request to fail, and one member's bad token or one * resume position for `itemId`, leaving `Played`/`IsFavorite`/`Rating`/
* transient Emby hiccup must never stop the rest of the sweep, let * everything else exactly as Emby already has it - see updateUserData
* alone touch real playback. `mediaSourceId`/`playSessionId` come from * below for why this never touches those fields, and never posts a
* PartyStreamService's own cache (see index.ts's sweep) - resolving * bare partial object to get there.
* fresh ones here would mean an extra PlaybackInfo round trip on every
* tick, for values the party's already-negotiated stream already
* produced for free.
*
* Every call logs the exact outgoing request (method, path, query -
* everything actually sent, minus the token itself, which travels as
* a header, never in this) and its 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" without
* digging through EmbyClient's own generic failure log.
*/ */
async reportProgress(embyToken: string, userId: string, itemId: string, mediaSourceId: string, playSessionId: string, positionTicks: number, isPaused: boolean): Promise<void> { async updatePosition(embyToken: string, userId: string, itemId: string, positionTicks: number): Promise<void> {
const params = new URLSearchParams({ await this.updateUserData(embyToken, userId, itemId, (current) => ({
MediaSourceId: mediaSourceId, ...current,
PlaySessionId: playSessionId, PlaybackPositionTicks: Math.round(positionTicks)
PositionTicks: String(Math.round(positionTicks)), }));
IsPaused: String(isPaused) }
});
const path = `/Users/${userId}/PlayingItems/${itemId}/Progress?${params}`; /**
Logger.playstate(`-> POST ${path}`); * Called once per member when the party moves past `itemId` for good -
* video's `PartyService.advanceQueue` (next episode) and music's
* `MusicQueueService.playNext` (a track naturally finishing, or a
* majority vote-skip) both call this for the *outgoing* item, for
* every member connected at that moment. Marks it Played and clears
* the resume position, the same way finishing something in Emby's own
* web client leaves it. A deliberate heuristic, not a real "did they
* actually watch the whole thing" check: this app has no reliable way
* to know that beyond "the party moved on from it" - which real
* streaming clients broadly treat as good enough too (a skip still
* counts as a play in most of them). Precision beyond that - e.g. only
* marking Played past some percentage watched - would need this app
* to also track each item's own duration everywhere it's queued,
* which nothing currently does; left as a documented gap rather than
* built speculatively.
*/
async markPlayed(embyToken: string, userId: string, itemId: string): Promise<void> {
await this.updateUserData(embyToken, userId, itemId, (current) => ({
...current,
PlaybackPositionTicks: 0,
Played: true
}));
}
/**
* Fetch-merge-write, always - deliberately never a bare partial POST.
* Emby's own OpenAPI spec documents this endpoint's body as the
* *complete* UserItemDataDto, with nothing in it suggesting a partial
* object merges rather than replaces - and this is the one write in
* this entire app where guessing wrong risks actually *erasing* a
* user's own real data (a favorite, a rating), not just silently
* doing nothing the way the earlier Progress-endpoint bugs did. So:
* GET the item first (confirmed directly against the real server that
* Emby always returns it with the requesting user's current UserData
* embedded), apply `mutate` to a copy of exactly that, POST the whole
* result back - correct regardless of which way the real merge-vs-
* replace semantics actually go, at the cost of one extra read before
* every write.
*
* Best-effort throughout: any failure (Emby unreachable, a stale/
* expired token, a 4xx/5xx on either call) is caught and logged here,
* never thrown - `updatePosition` runs on a timer with no request to
* fail, and `markPlayed` runs from inside a queue-advance/track-skip
* path that must never let one member's bad token or a transient Emby
* hiccup block the rest of the party from moving on. Every call logs
* the exact outgoing requests and their 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" without digging through EmbyClient's own generic failure log.
*/
private async updateUserData(embyToken: string, userId: string, itemId: string, mutate: (current: EmbyUserData) => EmbyUserData): Promise<void> {
const itemPath = `/Users/${userId}/Items/${itemId}`;
Logger.playstate(`-> GET ${itemPath} (reading current UserData before merging - see PlaystateService.updateUserData)`);
try { try {
const res = await EmbyClient.fetch(path, embyToken, { method: 'POST' }); const itemRes = await EmbyClient.fetch(itemPath, embyToken);
if (!itemRes.ok) {
Logger.playstate(`<- ${itemRes.status} FAILED userId=${userId} itemId=${itemId} (could not read current UserData, update skipped)`);
return;
}
const item = (await itemRes.json()) as { UserData?: EmbyUserData };
const updated = mutate(item.UserData || {});
const updatePath = `/Users/${userId}/Items/${itemId}/UserData`;
Logger.playstate(`-> POST ${updatePath} body=${JSON.stringify(updated)}`);
const res = await EmbyClient.fetch(updatePath, embyToken, { method: 'POST', body: updated });
Logger.playstate(`<- ${res.status} ${res.ok ? 'ok' : 'FAILED'} userId=${userId} itemId=${itemId}`); Logger.playstate(`<- ${res.status} ${res.ok ? 'ok' : 'FAILED'} userId=${userId} itemId=${itemId}`);
} catch (err) { } catch (err) {
Logger.playstate(`<- threw userId=${userId} itemId=${itemId}: ${(err as Error).message}`); Logger.playstate(`<- threw userId=${userId} itemId=${itemId}: ${(err as Error).message}`);

View file

@ -47,40 +47,36 @@ describe('CatalogService', () => {
PlaySessionId: 'sess1' PlaySessionId: 'sess1'
})); }));
const { targetPath, mediaSourceId, playSessionId } = await CatalogService.negotiateStreamTarget(session, 'item1', false, '2', ''); const target = await CatalogService.negotiateStreamTarget(session, 'item1', false, '2', '');
assert.ok(targetPath.includes('AudioStreamIndex=2')); assert.ok(target.includes('AudioStreamIndex=2'));
assert.ok(!targetPath.includes('Subtitle'), 'this method never touches subtitle params at all'); assert.ok(!target.includes('Subtitle'), 'this method never touches subtitle params at all');
assert.equal(mediaSourceId, 'src1', 'PlaystateService\'s own Progress report needs this too - see PartyStreamService');
assert.equal(playSessionId, 'sess1', 'so does this - see PlaystateService\'s own "Value cannot be null" comment');
}); });
it('negotiateStreamTarget\'s hand-built fallback picks /Audio/ (and drops VideoCodec) when isAudio is true', async (t) => { it('negotiateStreamTarget\'s hand-built fallback picks /Audio/ (and drops VideoCodec) when isAudio is true', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src2' }], PlaySessionId: 'sess2' })); t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src2' }], PlaySessionId: 'sess2' }));
const { targetPath } = await CatalogService.negotiateStreamTarget(session, 'track1', true, '', ''); const target = await CatalogService.negotiateStreamTarget(session, 'track1', true, '', '');
assert.ok(targetPath.startsWith('/Audio/track1/master.m3u8')); assert.ok(target.startsWith('/Audio/track1/master.m3u8'));
assert.ok(!targetPath.includes('VideoCodec')); assert.ok(!target.includes('VideoCodec'));
}); });
it('negotiateStreamTarget\'s hand-built fallback picks /Videos/ (with VideoCodec) when isAudio is false', async (t) => { it('negotiateStreamTarget\'s hand-built fallback picks /Videos/ (with VideoCodec) when isAudio is false', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src3' }], PlaySessionId: 'sess3' })); t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src3' }], PlaySessionId: 'sess3' }));
const { targetPath, mediaSourceId, playSessionId } = await CatalogService.negotiateStreamTarget(session, 'movie1', false, '', ''); const target = await CatalogService.negotiateStreamTarget(session, 'movie1', false, '', '');
assert.ok(targetPath.startsWith('/Videos/movie1/master.m3u8')); assert.ok(target.startsWith('/Videos/movie1/master.m3u8'));
assert.ok(targetPath.includes('VideoCodec=h264')); assert.ok(target.includes('VideoCodec=h264'));
assert.equal(mediaSourceId, 'src3');
assert.equal(playSessionId, 'sess3', 'the hand-built fallback branch must surface this too, not just the negotiated-TranscodingUrl one');
}); });
it('negotiateStreamTarget applies a quality (MaxStreamingBitrate) pick the same way as an audio pick', async (t) => { it('negotiateStreamTarget applies a quality (MaxStreamingBitrate) pick the same way as an audio pick', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src4' }], PlaySessionId: 'sess4' })); t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src4' }], PlaySessionId: 'sess4' }));
const { targetPath } = await CatalogService.negotiateStreamTarget(session, 'movie2', false, '', '4000000'); const target = await CatalogService.negotiateStreamTarget(session, 'movie2', false, '', '4000000');
assert.ok(targetPath.includes('MaxStreamingBitrate=4000000')); assert.ok(target.includes('MaxStreamingBitrate=4000000'));
}); });
it('negotiateStreamTarget fails cleanly (not a crash) when Emby has no media source for this item', async (t) => { it('negotiateStreamTarget fails cleanly (not a crash) when Emby has no media source for this item', async (t) => {

View file

@ -9,7 +9,8 @@ import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { setTimeout as sleep } from 'node:timers/promises'; import { setTimeout as sleep } from 'node:timers/promises';
import { MusicQueueService } from '../src/services/MusicQueueService'; import { MusicQueueService } from '../src/services/MusicQueueService';
import type { MusicState, Party } from '../src/types'; import { PlaystateService } from '../src/services/PlaystateService';
import type { AppSocket, Party, PartyMember, MusicState } from '../src/types';
function fakeParty(id: string): Party { function fakeParty(id: string): Party {
return { return {
@ -32,6 +33,26 @@ function fakeParty(id: string): Party {
}; };
} }
/** A connected member with a known token - the two things
* playNext's own markPlayed loop (and index.ts's periodic sweep) both
* gate on. Not used by most tests in this file at all: `fakeParty`'s
* own `members` map starts empty, and MusicQueueService's methods never
* populate it themselves (that's PartyService.join's job) - so unless a
* test explicitly adds one via this, playNext's markPlayed loop simply
* has nothing to iterate, same as an empty party in production. */
function fakeMember(userId: string, userName: string, embyToken: string): PartyMember {
return {
userId,
userName,
connection: {} as unknown as AppSocket,
deviceId: null,
embyToken,
isPaused: false,
positionTicks: 0,
lastUpdatedUtc: Date.now()
};
}
function music(party: Party): MusicState { function music(party: Party): MusicState {
return party.music as MusicState; return party.music as MusicState;
} }
@ -68,6 +89,42 @@ describe('MusicQueueService', () => {
assert.equal(music(party).skipVotes.size, 0, 'skipping clears the tally for the new track'); assert.equal(music(party).skipVotes.size, 0, 'skipping clears the tally for the new track');
}); });
it('a skip marks the OUTGOING track Played for every connected member with a token, under their own account', (t) => {
const calls: [string, string, string][] = [];
t.mock.method(PlaystateService, 'markPlayed', async (embyToken: string, userId: string, itemId: string) => {
calls.push([embyToken, userId, itemId]);
});
const party = fakeParty('p3b');
party.members.set('u-alice', fakeMember('u-alice', 'Alice', 'tok-alice'));
party.members.set('u-bob', fakeMember('u-bob', 'Bob', 'tok-bob'));
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
MusicQueueService.addTrack(party, { itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 30_000_000 }, 'u-bob', 'Bob');
assert.equal(calls.length, 0, 'starting the very first track has nothing outgoing to mark');
// connectedMemberCount=2 needs a strict majority (2) to actually
// skip - same arithmetic as the "needs a strict majority" test above.
MusicQueueService.voteSkip(party, 'u-alice', 'Alice', 2);
assert.equal(calls.length, 0, 'one vote of two is not a majority yet - nothing outgoing to mark');
MusicQueueService.voteSkip(party, 'u-bob', 'Bob', 2);
assert.deepEqual(calls.sort(), [['tok-alice', 'u-alice', 't1'], ['tok-bob', 'u-bob', 't1']].sort(), 'marks t1 (outgoing), not t2 (just started)');
});
it('never marks a member Played if they have no connection or no token yet', (t) => {
let called = false;
t.mock.method(PlaystateService, 'markPlayed', async () => { called = true; });
const party = fakeParty('p3c');
party.members.set('u-alice', { ...fakeMember('u-alice', 'Alice', 'tok-alice'), connection: null });
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
MusicQueueService.addTrack(party, { itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 30_000_000 }, 'u-bob', 'Bob');
MusicQueueService.voteSkip(party, 'u-alice', 'Alice', 1);
assert.equal(called, false);
});
it('pause freezes elapsed time; resume continues it from where it froze', async () => { it('pause freezes elapsed time; resume continues it from where it froze', async () => {
const party = fakeParty('p4'); const party = fakeParty('p4');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 600_000_000 }, 'u-alice', 'Alice'); MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 600_000_000 }, 'u-alice', 'Alice');

View file

@ -2,6 +2,8 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; 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 type { AppSocket } from '../src/types';
describe('PartyService', () => { describe('PartyService', () => {
it('create() sets mediaType and seeds the host as the first member', () => { it('create() sets mediaType and seeds the host as the first member', () => {
@ -45,6 +47,46 @@ describe('PartyService', () => {
assert.equal(party.textSubtitleIndex, null); assert.equal(party.textSubtitleIndex, null);
}); });
it('advanceQueue marks the outgoing item Played for every connected member with a token, under their own account - see PlaystateService', (t) => {
const calls: [string, string, string][] = [];
t.mock.method(PlaystateService, 'markPlayed', async (embyToken: string, userId: string, itemId: string) => {
calls.push([embyToken, userId, itemId]);
});
const party = PartyService.create('u-alice', 'Alice', 'ep1', 'S01E01', '/img/ep1', [
{ itemId: 'ep1', itemName: 'S01E01', itemImageUrl: '/img/ep1' },
{ itemId: 'ep2', itemName: 'S01E02', itemImageUrl: '/img/ep2' }
], 'video');
PartyService.join(party, 'u-bob', 'Bob');
PartyService.setMemberEmbyToken(party, 'u-alice', 'tok-alice');
PartyService.setMemberEmbyToken(party, 'u-bob', 'tok-bob');
// A real socket connection isn't needed for anything else this test
// checks - just enough to pass advanceQueue's own "connected" guard,
// same one the periodic sweep in index.ts already uses.
party.members.get('u-alice')!.connection = {} as unknown as AppSocket;
party.members.get('u-bob')!.connection = {} as unknown as AppSocket;
PartyService.advanceQueue(party);
assert.deepEqual(calls.sort(), [['tok-alice', 'u-alice', 'ep1'], ['tok-bob', 'u-bob', 'ep1']].sort(), 'marks the OUTGOING item (ep1), not the new one just moved to');
});
it('advanceQueue never marks a member Played if they have no connection or no token yet', (t) => {
let called = false;
t.mock.method(PlaystateService, 'markPlayed', async () => { called = true; });
const party = PartyService.create('u-alice', 'Alice', 'ep1', 'S01E01', '/img/ep1', [
{ itemId: 'ep1', itemName: 'S01E01', itemImageUrl: '/img/ep1' },
{ itemId: 'ep2', itemName: 'S01E02', itemImageUrl: '/img/ep2' }
], 'video');
// Host never got a connection/token set at all in this test - same as
// a REST-created party nobody has actually joined a socket to yet.
PartyService.advanceQueue(party);
assert.equal(called, false);
});
it('leave(): a non-host leaving neither destroys the party nor hands off', () => { it('leave(): a non-host leaving neither destroys the party nor hands off', () => {
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');
PartyService.join(party, 'u-bob', 'Bob'); PartyService.join(party, 'u-bob', 'Bob');

View file

@ -40,7 +40,7 @@ describe('PartyStreamService.getOrNegotiate', () => {
let negotiateCount = 0; let negotiateCount = 0;
t.mock.method(CatalogService, 'negotiateStreamTarget', async () => { t.mock.method(CatalogService, 'negotiateStreamTarget', async () => {
negotiateCount += 1; negotiateCount += 1;
return { targetPath: '/Videos/movie1/master.m3u8?PlaySessionId=sess1', mediaSourceId: 'src1', playSessionId: 'sess1' }; return '/Videos/movie1/master.m3u8?PlaySessionId=sess1';
}); });
const party = PartyService.create('u-alice', 'Alice', 'movie1', 'Un Film', '/img/movie1', null, 'video'); const party = PartyService.create('u-alice', 'Alice', 'movie1', 'Un Film', '/img/movie1', null, 'video');
@ -49,7 +49,6 @@ describe('PartyStreamService.getOrNegotiate', () => {
assert.equal(negotiateCount, 1, 'the second call must reuse the cached target, not negotiate again'); assert.equal(negotiateCount, 1, 'the second call must reuse the cached target, not negotiate again');
assert.equal(first, second); assert.equal(first, second);
assert.deepEqual(PartyStreamService.getCachedPlaybackIds(party.id), { mediaSourceId: 'src1', playSessionId: 'sess1' }, 'cached alongside the target - see PlaystateService');
PartyStreamService.endSession(party.id); PartyStreamService.endSession(party.id);
}); });
@ -58,7 +57,7 @@ describe('PartyStreamService.getOrNegotiate', () => {
let negotiateCount = 0; let negotiateCount = 0;
t.mock.method(CatalogService, 'negotiateStreamTarget', async () => { t.mock.method(CatalogService, 'negotiateStreamTarget', async () => {
negotiateCount += 1; negotiateCount += 1;
return { targetPath: `/Videos/movie2/master.m3u8?call=${negotiateCount}`, mediaSourceId: `src${negotiateCount}`, playSessionId: `sess${negotiateCount}` }; return `/Videos/movie2/master.m3u8?call=${negotiateCount}`;
}); });
const party = PartyService.create('u-alice', 'Alice', 'movie2', 'Un Film 2', '/img/movie2', null, 'video'); const party = PartyService.create('u-alice', 'Alice', 'movie2', 'Un Film 2', '/img/movie2', null, 'video');
@ -77,7 +76,7 @@ describe('PartyStreamService.getOrNegotiate', () => {
let negotiateCount = 0; let negotiateCount = 0;
t.mock.method(CatalogService, 'negotiateStreamTarget', async () => { t.mock.method(CatalogService, 'negotiateStreamTarget', async () => {
negotiateCount += 1; negotiateCount += 1;
return { targetPath: `/Videos/x/master.m3u8?call=${negotiateCount}`, mediaSourceId: `src${negotiateCount}`, playSessionId: `sess${negotiateCount}` }; return `/Videos/x/master.m3u8?call=${negotiateCount}`;
}); });
const party = PartyService.create('u-alice', 'Alice', 'ep1', 'S01E01', '/img/ep1', [ const party = PartyService.create('u-alice', 'Alice', 'ep1', 'S01E01', '/img/ep1', [
@ -96,7 +95,7 @@ describe('PartyStreamService.getOrNegotiate', () => {
let capturedArgs: unknown[] = []; let capturedArgs: unknown[] = [];
t.mock.method(CatalogService, 'negotiateStreamTarget', async (...args: unknown[]) => { t.mock.method(CatalogService, 'negotiateStreamTarget', async (...args: unknown[]) => {
capturedArgs = args; capturedArgs = args;
return { targetPath: '/Audio/t1/master.m3u8', mediaSourceId: 'src-t1', playSessionId: 'sess-t1' }; return '/Audio/t1/master.m3u8';
}); });
const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music'); const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music');
@ -123,7 +122,7 @@ describe('PartyStreamService failure recovery', () => {
let negotiateCount = 0; let negotiateCount = 0;
t.mock.method(CatalogService, 'negotiateStreamTarget', async () => { t.mock.method(CatalogService, 'negotiateStreamTarget', async () => {
negotiateCount += 1; negotiateCount += 1;
return { targetPath: `/Videos/movie3/master.m3u8?call=${negotiateCount}`, mediaSourceId: `src${negotiateCount}`, playSessionId: `sess${negotiateCount}` }; return `/Videos/movie3/master.m3u8?call=${negotiateCount}`;
}); });
t.mock.method(EmbyClient, 'proxyResponse', async (_req: Request, res: Response) => { t.mock.method(EmbyClient, 'proxyResponse', async (_req: Request, res: Response) => {
(res as unknown as { statusCode: number }).statusCode = 502; // simulate Emby having ended this session on its own (res as unknown as { statusCode: number }).statusCode = 502; // simulate Emby having ended this session on its own
@ -131,7 +130,6 @@ describe('PartyStreamService failure recovery', () => {
const party = PartyService.create('u-alice', 'Alice', 'movie3', 'Un Film 3', '/img/movie3', null, 'video'); const party = PartyService.create('u-alice', 'Alice', 'movie3', 'Un Film 3', '/img/movie3', null, 'video');
await PartyStreamService.serveMasterPlaylist(fakeReq(), fakeRes(), party, FAKE_SESSION); await PartyStreamService.serveMasterPlaylist(fakeReq(), fakeRes(), party, FAKE_SESSION);
assert.equal(PartyStreamService.getCachedPlaybackIds(party.id), null, 'a failed response must drop the MediaSourceId/PlaySessionId too, not just the target path');
await PartyStreamService.serveMasterPlaylist(fakeReq(), fakeRes(), party, FAKE_SESSION); await PartyStreamService.serveMasterPlaylist(fakeReq(), fakeRes(), party, FAKE_SESSION);
assert.equal(negotiateCount, 2, 'a failed response must not be cached - the next request has to renegotiate'); assert.equal(negotiateCount, 2, 'a failed response must not be cached - the next request has to renegotiate');
@ -143,7 +141,7 @@ describe('PartyStreamService failure recovery', () => {
let negotiateCount = 0; let negotiateCount = 0;
t.mock.method(CatalogService, 'negotiateStreamTarget', async () => { t.mock.method(CatalogService, 'negotiateStreamTarget', async () => {
negotiateCount += 1; negotiateCount += 1;
return { targetPath: '/Videos/movie4/master.m3u8', mediaSourceId: 'src-movie4', playSessionId: 'sess-movie4' }; return '/Videos/movie4/master.m3u8';
}); });
t.mock.method(EmbyClient, 'proxyResponse', async () => {}); // res.statusCode stays 200, the fakeRes() default t.mock.method(EmbyClient, 'proxyResponse', async () => {}); // res.statusCode stays 200, the fakeRes() default
@ -162,15 +160,11 @@ describe('PartyStreamService lifecycle', () => {
assert.doesNotThrow(() => PartyStreamService.endSession('no-such-party')); assert.doesNotThrow(() => PartyStreamService.endSession('no-such-party'));
}); });
it('getCachedPlaybackIds is null for a party that never negotiated a stream - never negotiates one itself', () => {
assert.equal(PartyStreamService.getCachedPlaybackIds('no-such-party'), null);
});
it('reapIdleSessions ends sessions past the idle cutoff, leaves a fresh one alone', async (t) => { it('reapIdleSessions ends sessions past the idle cutoff, leaves a fresh one alone', async (t) => {
let negotiateCount = 0; let negotiateCount = 0;
t.mock.method(CatalogService, 'negotiateStreamTarget', async () => { t.mock.method(CatalogService, 'negotiateStreamTarget', async () => {
negotiateCount += 1; negotiateCount += 1;
return { targetPath: `/Videos/x/master.m3u8?call=${negotiateCount}`, mediaSourceId: `src${negotiateCount}`, playSessionId: `sess${negotiateCount}` }; return `/Videos/x/master.m3u8?call=${negotiateCount}`;
}); });
const party = PartyService.create('u-eve', 'Eve', 'movie5', 'Un Film 5', '/img/movie5', null, 'video'); const party = PartyService.create('u-eve', 'Eve', 'movie5', 'Un Film 5', '/img/movie5', null, 'video');

View file

@ -1,116 +1,158 @@
// PlaystateService.reportProgress - mocks EmbyClient.fetch (not the // PlaystateService.updatePosition/markPlayed - mocks EmbyClient.fetch
// global fetch it wraps), same isolation style as catalogService.test.ts, // (not the global fetch it wraps), same isolation style as
// since this is about what PlaystateService itself sends, not // catalogService.test.ts, since this is about what PlaystateService
// EmbyClient's own plumbing (see embyClient.test.ts for that). // itself sends/merges, not EmbyClient's own plumbing (see
// embyClient.test.ts for that).
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { PlaystateService } from '../src/services/PlaystateService'; import { PlaystateService } from '../src/services/PlaystateService';
import { EmbyClient } from '../src/emby/EmbyClient'; import { EmbyClient } from '../src/emby/EmbyClient';
describe('PlaystateService.reportProgress', () => { /** What a real `GET /Users/{userId}/Items/{itemId}` comes back with -
it('POSTs to this user\'s own PlayingItems/Progress with their own token, params in the query string not a body', async (t) => { * confirmed directly against a real server that the requesting user's
let capturedPath = ''; * own UserData is always embedded right on the item like this. */
let capturedToken = ''; function itemResponse(userData: Record<string, unknown> | undefined): Response {
let capturedOptions: unknown; return new Response(JSON.stringify({ Id: 'item1', UserData: userData }), { status: 200 });
}
describe('PlaystateService.updatePosition', () => {
it('GETs the item, then POSTs UserData with only PlaybackPositionTicks changed - everything else survives untouched', async (t) => {
const capturedCalls: { path: string; token: string; options: unknown }[] = [];
t.mock.method(EmbyClient, 'fetch', async (path: string, token: string, options: unknown) => { t.mock.method(EmbyClient, 'fetch', async (path: string, token: string, options: unknown) => {
capturedPath = path; capturedCalls.push({ path, token, options });
capturedToken = token; if (capturedCalls.length === 1) {
capturedOptions = options; return itemResponse({ PlaybackPositionTicks: 0, Played: false, IsFavorite: true, Rating: 9.5, PlayCount: 3 });
}
return new Response('{}', { status: 200 }); return new Response('{}', { status: 200 });
}); });
await PlaystateService.reportProgress('tok-alice', 'u-alice', 'item1', 'src1', 'sess1', 12_345_678, false); await PlaystateService.updatePosition('tok-alice', 'u-alice', 'item1', 12_345_678);
// Query params, not a JSON body - confirmed against Emby's real assert.equal(capturedCalls.length, 2);
// OpenAPI spec (see PlaystateService.ts's own comment): the assert.equal(capturedCalls[0]!.path, '/Users/u-alice/Items/item1');
// original body-based guess was missing a required MediaSourceId assert.equal(capturedCalls[0]!.token, 'tok-alice');
// entirely and would have failed outright. PlaySessionId came next assert.equal(capturedCalls[0]!.options, undefined, 'the GET is a plain fetch, no method/body');
// (round 2 - see the same comment): optional per the spec, but a
// real request without one still hit Emby's own "Value cannot be assert.equal(capturedCalls[1]!.path, '/Users/u-alice/Items/item1/UserData');
// null (Parameter 'key')". assert.equal(capturedCalls[1]!.token, 'tok-alice');
const [path, query] = capturedPath.split('?'); const posted = capturedCalls[1]!.options as { method: string; body: Record<string, unknown> };
assert.equal(path, '/Users/u-alice/PlayingItems/item1/Progress'); assert.equal(posted.method, 'POST');
assert.equal(new URLSearchParams(query).get('MediaSourceId'), 'src1'); // The whole point of fetch-merge-write: a favorite/rating/play count
assert.equal(new URLSearchParams(query).get('PlaySessionId'), 'sess1'); // this app never asked to change must come back out exactly as read -
assert.equal(new URLSearchParams(query).get('PositionTicks'), '12345678'); // guessing wrong here risks actually erasing real user data, not just
assert.equal(new URLSearchParams(query).get('IsPaused'), 'false'); // silently no-op'ing the way the old Progress-endpoint bugs did.
assert.equal(capturedToken, 'tok-alice'); assert.equal(posted.body.IsFavorite, true);
assert.deepEqual(capturedOptions, { method: 'POST' }, 'no body at all - the real body schema (Api.OnPlaybackProgress) has none of these fields'); assert.equal(posted.body.Rating, 9.5);
assert.equal(posted.body.PlayCount, 3);
assert.equal(posted.body.Played, false, 'Played is preserved, not touched by a position-only update');
assert.equal(posted.body.PlaybackPositionTicks, 12345678, 'only this field actually changes');
}); });
it('rounds a fractional positionTicks (Emby expects an integer)', async (t) => { it('rounds a fractional positionTicks (Emby expects an integer)', async (t) => {
let capturedPath = ''; let posted: Record<string, unknown> | undefined;
t.mock.method(EmbyClient, 'fetch', async (path: string) => { t.mock.method(EmbyClient, 'fetch', async (path: string, _token: string, options?: { body?: Record<string, unknown> }) => {
capturedPath = path; if (path.endsWith('/UserData')) { posted = options?.body; return new Response('{}', { status: 200 }); }
return new Response('{}', { status: 200 }); return itemResponse({});
}); });
await PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 'sess1', 100.7, true); await PlaystateService.updatePosition('tok', 'u1', 'item1', 100.7);
const query = capturedPath.split('?')[1]; assert.equal(posted?.PlaybackPositionTicks, 101);
assert.equal(new URLSearchParams(query).get('PositionTicks'), '101');
}); });
it('swallows a failure instead of throwing - this runs on a timer with nothing to catch it', async (t) => { it('has no existing UserData at all (a never-touched item) - still works, starting from an empty object', async (t) => {
let posted: Record<string, unknown> | undefined;
t.mock.method(EmbyClient, 'fetch', async (path: string, _token: string, options?: { body?: Record<string, unknown> }) => {
if (path.endsWith('/UserData')) { posted = options?.body; return new Response('{}', { status: 200 }); }
return itemResponse(undefined);
});
await assert.doesNotReject(() => PlaystateService.updatePosition('tok', 'u1', 'item1', 5000));
assert.equal(posted?.PlaybackPositionTicks, 5000);
});
it('skips the write entirely if the GET fails - never posts with missing/corrupt data', async (t) => {
let postCalled = false;
t.mock.method(EmbyClient, 'fetch', async (path: string) => {
if (path.endsWith('/UserData')) postCalled = true;
return new Response('nope', { status: 404 });
});
await assert.doesNotReject(() => PlaystateService.updatePosition('tok', 'u1', 'item1', 0));
assert.equal(postCalled, false, 'a failed read must not be followed by a write built on nothing');
});
it('swallows a thrown error instead of rejecting - this runs on a timer with nothing to catch it', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => { throw new Error('Emby unreachable'); }); t.mock.method(EmbyClient, 'fetch', async () => { throw new Error('Emby unreachable'); });
await assert.doesNotReject(() => PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 'sess1', 0, false)); await assert.doesNotReject(() => PlaystateService.updatePosition('tok', 'u1', 'item1', 0));
});
});
describe('PlaystateService.markPlayed', () => {
it('POSTs Played: true and resets PlaybackPositionTicks to 0, preserving other fields', async (t) => {
let posted: Record<string, unknown> | undefined;
t.mock.method(EmbyClient, 'fetch', async (path: string, _token: string, options?: { body?: Record<string, unknown> }) => {
if (path.endsWith('/UserData')) { posted = options?.body; return new Response('{}', { status: 200 }); }
return itemResponse({ PlaybackPositionTicks: 3_000_000, Played: false, IsFavorite: true });
});
await PlaystateService.markPlayed('tok', 'u1', 'item1');
assert.equal(posted?.Played, true);
assert.equal(posted?.PlaybackPositionTicks, 0);
assert.equal(posted?.IsFavorite, true, 'a favorite must survive being marked played');
}); });
// Logging: the whole point is being able to answer "what did this it('swallows a thrown error instead of rejecting - this runs from an item-advance path that must never stall on it', async (t) => {
// actually send, and did Emby accept it" from `docker logs` alone - t.mock.method(EmbyClient, 'fetch', async () => { throw new Error('Emby unreachable'); });
// see Logger.playstate's own comment.
it('logs the exact outgoing request, never the token itself', async (t) => { await assert.doesNotReject(() => PlaystateService.markPlayed('tok', 'u1', 'item1'));
});
});
describe('PlaystateService logging', () => {
it('logs both outgoing requests and the final outcome, never the token itself', async (t) => {
const logged: string[] = []; const logged: string[] = [];
t.mock.method(console, 'log', (line: string) => { logged.push(line); }); t.mock.method(console, 'log', (line: string) => { logged.push(line); });
t.mock.method(EmbyClient, 'fetch', async () => new Response('{}', { status: 200 })); t.mock.method(EmbyClient, 'fetch', async (path: string) => {
if (path.endsWith('/UserData')) return new Response('{}', { status: 200 });
return itemResponse({});
});
await PlaystateService.reportProgress('super-secret-token', 'u-alice', 'item1', 'src1', 'sess1', 12_345_678, false); await PlaystateService.updatePosition('super-secret-token', 'u-alice', 'item1', 5_000_000);
const requestLine = logged.find((l) => l.includes('[watchparty:playstate]') && l.includes('POST')); const lines = logged.filter((l) => l.includes('[watchparty:playstate]'));
assert.ok(requestLine, 'the request itself must be logged'); assert.ok(lines.some((l) => l.includes('GET') && l.includes('/Users/u-alice/Items/item1')), 'the read must be logged');
assert.ok(requestLine!.includes('/Users/u-alice/PlayingItems/item1/Progress')); assert.ok(lines.some((l) => l.includes('POST') && l.includes('/Users/u-alice/Items/item1/UserData')), 'the write must be logged');
assert.ok(requestLine!.includes('MediaSourceId=src1')); assert.ok(lines.some((l) => l.includes('200') && l.includes('ok')), 'the outcome must be logged');
assert.ok(requestLine!.includes('PlaySessionId=sess1')); assert.ok(!lines.some((l) => l.includes('super-secret-token')), 'the token travels as a header, never logged');
assert.ok(requestLine!.includes('PositionTicks=12345678'));
assert.ok(requestLine!.includes('IsPaused=false'));
assert.ok(!requestLine!.includes('super-secret-token'), 'the token travels as a header, never logged into the request line');
}); });
it('logs a successful response as ok, with its real HTTP status', async (t) => { it('logs a non-ok write as FAILED, with its real HTTP status', async (t) => {
const logged: string[] = []; const logged: string[] = [];
t.mock.method(console, 'log', (line: string) => { logged.push(line); }); t.mock.method(console, 'log', (line: string) => { logged.push(line); });
t.mock.method(EmbyClient, 'fetch', async () => new Response(null, { status: 204 })); // 204 is a null-body status - a non-null body here would throw t.mock.method(EmbyClient, 'fetch', async (path: string) => {
if (path.endsWith('/UserData')) return new Response('bad request', { status: 400 });
return itemResponse({});
});
await PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 'sess1', 0, false); await PlaystateService.updatePosition('tok', 'u1', 'item1', 0);
const outcomeLine = logged.find((l) => l.includes('[watchparty:playstate]') && l.includes('204'));
assert.ok(outcomeLine, 'the real status code must be logged');
assert.ok(outcomeLine!.includes('ok'));
assert.ok(!outcomeLine!.includes('FAILED'));
});
it('logs a non-ok response as FAILED, with its real HTTP status - the exact case both wrong request shapes hit for real', async (t) => {
const logged: string[] = [];
t.mock.method(console, 'log', (line: string) => { logged.push(line); });
t.mock.method(EmbyClient, 'fetch', async () => new Response("Value cannot be null. (Parameter 'key')", { status: 400 }));
await PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 'sess1', 0, false);
const outcomeLine = logged.find((l) => l.includes('[watchparty:playstate]') && l.includes('400')); const outcomeLine = logged.find((l) => l.includes('[watchparty:playstate]') && l.includes('400'));
assert.ok(outcomeLine, 'the real status code must be logged'); assert.ok(outcomeLine);
assert.ok(outcomeLine!.includes('FAILED')); assert.ok(outcomeLine!.includes('FAILED'));
}); });
it('logs a thrown error (Emby unreachable) distinctly from a non-ok HTTP response', async (t) => { it('logs a failed read distinctly, without ever attempting/logging the write', async (t) => {
const logged: string[] = []; const logged: string[] = [];
t.mock.method(console, 'log', (line: string) => { logged.push(line); }); t.mock.method(console, 'log', (line: string) => { logged.push(line); });
t.mock.method(EmbyClient, 'fetch', async () => { throw new Error('Emby unreachable'); }); t.mock.method(EmbyClient, 'fetch', async () => new Response('not found', { status: 404 }));
await PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 'sess1', 0, false); await PlaystateService.updatePosition('tok', 'u1', 'item1', 0);
const outcomeLine = logged.find((l) => l.includes('[watchparty:playstate]') && l.includes('threw')); const lines = logged.filter((l) => l.includes('[watchparty:playstate]'));
assert.ok(outcomeLine, 'a thrown error must still be logged, not just swallowed silently'); assert.ok(lines.some((l) => l.includes('404') && l.includes('FAILED')));
assert.ok(outcomeLine!.includes('Emby unreachable')); assert.ok(!lines.some((l) => l.includes('POST')), 'no write should even be attempted, let alone logged, once the read failed');
}); });
}); });