Replace PlayingItems/Progress with direct UserData writes
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:
parent
939beb6fa9
commit
b167e321e7
14 changed files with 538 additions and 348 deletions
27
README.md
27
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue