Sticky catalog search bar, and let anyone seek within a music track #5
18 changed files with 760 additions and 18 deletions
|
|
@ -331,6 +331,15 @@ Then put it behind your existing reverse proxy like everything else
|
||||||
- [x] Sticky search bar on the library/artist/album pages, and letting
|
- [x] Sticky search bar on the library/artist/album pages, and letting
|
||||||
anyone seek within the current music track (same no-host-check
|
anyone seek within the current music track (same no-host-check
|
||||||
model pause/resume already had) — see docs/architecture.md
|
model pause/resume already had) — see docs/architecture.md
|
||||||
|
- [x] Skip intro / jump to next episode at end credits, using Emby's own
|
||||||
|
native marker detection (`EnableMarkerDetection` + its "Detect
|
||||||
|
Episode Intros" task) rather than building fingerprinting
|
||||||
|
ourselves — see docs/architecture.md
|
||||||
|
- [x] Report each party member's own playback to Emby's real Sessions
|
||||||
|
API (`/Sessions/Playing`, `/Progress`, `/Stopped`), so the admin
|
||||||
|
dashboard sees watch-party streams the same way it sees a native
|
||||||
|
client's — one session per member, under their own account — see
|
||||||
|
docs/architecture.md
|
||||||
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
|
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
|
||||||
- [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
|
- [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
|
||||||
confirmed here — no real device/session)
|
confirmed here — no real device/session)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
// module just wraps fetch calls to our own /api/* routes. See
|
// module just wraps fetch calls to our own /api/* routes. See
|
||||||
// docs/architecture.md for why this changed from the first version (CORS,
|
// docs/architecture.md for why this changed from the first version (CORS,
|
||||||
// plus not wanting the browser to hold an Emby token at all).
|
// plus not wanting the browser to hold an Emby token at all).
|
||||||
import type { ItemDto, LibraryDto, PlaybackOptionsDto, SeasonDto } from '../types';
|
import type { ItemDto, LibraryDto, PlaybackMarkersDto, PlaybackOptionsDto, SeasonDto } from '../types';
|
||||||
|
|
||||||
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
|
|
@ -116,6 +116,10 @@ export function getPlaybackOptions(itemId: string): Promise<PlaybackOptionsDto>
|
||||||
return apiFetch(`/api/catalog/item/${itemId}/playback-options`);
|
return apiFetch(`/api/catalog/item/${itemId}/playback-options`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPlaybackMarkers(itemId: string): Promise<PlaybackMarkersDto> {
|
||||||
|
return apiFetch(`/api/catalog/item/${itemId}/markers`);
|
||||||
|
}
|
||||||
|
|
||||||
/** The party's own shared Emby stream (server/src/services/
|
/** The party's own shared Emby stream (server/src/services/
|
||||||
* PartyStreamService.ts) - one negotiated PlaybackInfo session reused
|
* PartyStreamService.ts) - one negotiated PlaybackInfo session reused
|
||||||
* by every member, instead of each viewer's own <video>/<audio>
|
* by every member, instead of each viewer's own <video>/<audio>
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,16 @@ export interface PlaybackOptionsDto {
|
||||||
subtitleTracks: PlaybackTrackOption[];
|
subtitleTracks: PlaybackTrackOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Emby's own native intro/credits detection - see server/src/types.ts's
|
||||||
|
* own copy of this shape for where the field names come from. Every
|
||||||
|
* field is independently nullable ("not detected", never an error) -
|
||||||
|
* see views/player/Player.ts's updateSkipButton, the only consumer. */
|
||||||
|
export interface PlaybackMarkersDto {
|
||||||
|
introStartTicks: number | null;
|
||||||
|
introEndTicks: number | null;
|
||||||
|
creditsStartTicks: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export type MediaType = 'video' | 'music';
|
export type MediaType = 'video' | 'music';
|
||||||
|
|
||||||
export interface PublicMember {
|
export interface PublicMember {
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,26 @@
|
||||||
transition: padding .25s ease, background-color .25s ease, border-color .25s ease;
|
transition: padding .25s ease, background-color .25s ease, border-color .25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Floating above #bottomBar (higher z-index, positioned clear of its
|
||||||
|
tallest/expanded height) rather than living inside it - see
|
||||||
|
Player.html's own comment on why: it must stay reachable through the
|
||||||
|
bar's own auto-hide/.minimal collapse, not disappear along with it. */
|
||||||
|
.skipMarkerBtn {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 84px;
|
||||||
|
z-index: 7;
|
||||||
|
background: rgba(20, 18, 22, .85);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid rgba(255, 255, 255, .25);
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skipMarkerBtn:hover { background: rgba(20, 18, 22, .95); border-color: rgba(255, 255, 255, .4); }
|
||||||
|
|
||||||
/* Collapsed down to just the seek bar after a stretch of no activity
|
/* Collapsed down to just the seek bar after a stretch of no activity
|
||||||
(see Player.ts's scheduleControlsHide) - asked for directly: the
|
(see Player.ts's scheduleControlsHide) - asked for directly: the
|
||||||
full bar, always visible, sits right where a native <track> subtitle
|
full bar, always visible, sits right where a native <track> subtitle
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,17 @@
|
||||||
notification rather than a fourth thing bolted onto a fixed layout. -->
|
notification rather than a fourth thing bolted onto a fixed layout. -->
|
||||||
<div id="requestBanner" class="requestBanner hide"></div>
|
<div id="requestBanner" class="requestBanner hide"></div>
|
||||||
|
|
||||||
|
<!-- Emby's own native intro detection (see Player.ts's updateSkipButton/
|
||||||
|
CatalogService.getPlaybackMarkers) - shown independently of #bottomBar
|
||||||
|
below, and NOT subject to its own auto-hide/.minimal collapse, since
|
||||||
|
this needs to stay reachable the moment the intro starts even if
|
||||||
|
nobody's touched the player yet. Host-only, like every other seek -
|
||||||
|
see updateSkipButton's own comment for why a follower can't get this
|
||||||
|
button at all. Explicit label, not just an icon - see
|
||||||
|
docs/architecture.md's player-rework section for why this app
|
||||||
|
standardized on that for every request-shaped control. -->
|
||||||
|
<button id="skipIntroBtn" class="skipMarkerBtn hide">Passer l'intro</button>
|
||||||
|
|
||||||
<!-- One bar, bottom-anchored, replacing what used to be three separate
|
<!-- One bar, bottom-anchored, replacing what used to be three separate
|
||||||
floating pieces (a centered banner/notice column, a request
|
floating pieces (a centered banner/notice column, a request
|
||||||
toolbar cornered bottom-right, an "Épisode suivant" link up in the
|
toolbar cornered bottom-right, an "Épisode suivant" link up in the
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import '../../styles/presence.css';
|
import '../../styles/presence.css';
|
||||||
import './Player.css';
|
import './Player.css';
|
||||||
import html from './Player.html';
|
import html from './Player.html';
|
||||||
import { auth, burnInStreamUrl, getPlaybackOptions, partyStreamUrl, subtitleUrl } from '../../api/embyApi';
|
import { auth, burnInStreamUrl, getPlaybackMarkers, getPlaybackOptions, partyStreamUrl, subtitleUrl } from '../../api/embyApi';
|
||||||
import { getParty, leaveParty, connectParty, issueCastToken, revokeCastToken } from '../../api/party';
|
import { getParty, leaveParty, connectParty, issueCastToken, revokeCastToken } from '../../api/party';
|
||||||
import type { PartyConnection } from '../../api/party';
|
import type { PartyConnection } from '../../api/party';
|
||||||
import { navigate } from '../../router';
|
import { navigate } from '../../router';
|
||||||
import type { CleanupFn, RouteParams } from '../../router';
|
import type { CleanupFn, RouteParams } from '../../router';
|
||||||
import { colorForName, escapeHtml, formatDuration, initialsFor } from '../../tiles';
|
import { colorForName, escapeHtml, formatDuration, initialsFor } from '../../tiles';
|
||||||
import { LOADING_HINT } from '../../loading';
|
import { LOADING_HINT } from '../../loading';
|
||||||
import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequestedMsg, PlaybackOptionsDto, ResumeRequestedMsg, SocketMember, StreamOptionsChangedMsg, TextSubtitleChangedMsg } from '../../types';
|
import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequestedMsg, PlaybackMarkersDto, PlaybackOptionsDto, ResumeRequestedMsg, SocketMember, StreamOptionsChangedMsg, TextSubtitleChangedMsg } from '../../types';
|
||||||
import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar';
|
import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar';
|
||||||
import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../../cast';
|
import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../../cast';
|
||||||
import type { PlaybackDriver } from '../../cast';
|
import type { PlaybackDriver } from '../../cast';
|
||||||
|
|
@ -203,6 +203,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
const playPauseBtn = root.querySelector('#playPauseBtn') as HTMLButtonElement;
|
const playPauseBtn = root.querySelector('#playPauseBtn') as HTMLButtonElement;
|
||||||
const muteBtn = root.querySelector('#muteBtn') as HTMLButtonElement;
|
const muteBtn = root.querySelector('#muteBtn') as HTMLButtonElement;
|
||||||
const timeLabel = root.querySelector('#timeLabel')!;
|
const timeLabel = root.querySelector('#timeLabel')!;
|
||||||
|
const skipIntroBtn = root.querySelector('#skipIntroBtn') as HTMLButtonElement;
|
||||||
|
|
||||||
qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => `<option value="${q.value}">${q.label}</option>`).join('');
|
qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => `<option value="${q.value}">${q.label}</option>`).join('');
|
||||||
|
|
||||||
|
|
@ -262,6 +263,15 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
// - see its own comment) can re-render the subtitle picker's host-only
|
// - see its own comment) can re-render the subtitle picker's host-only
|
||||||
// gating for burn-in options without a redundant playback-options fetch.
|
// gating for burn-in options without a redundant playback-options fetch.
|
||||||
let lastPlaybackOptions: PlaybackOptionsDto | null = null;
|
let lastPlaybackOptions: PlaybackOptionsDto | null = null;
|
||||||
|
// Emby's own native intro/credits detection for currentItemId, if any -
|
||||||
|
// see loadMarkers/updateSkipButton below and CatalogService
|
||||||
|
// .getPlaybackMarkers. Null fields (or this whole thing being null,
|
||||||
|
// while still loading) just mean no skip button, never an error.
|
||||||
|
let currentMarkers: PlaybackMarkersDto | null = null;
|
||||||
|
// One-shot per item - see updateSkipButton's own comment for why this
|
||||||
|
// must NOT re-fire (force the bar open) on every tick for as long as
|
||||||
|
// credits are running.
|
||||||
|
let creditsControlsShown = false;
|
||||||
// "The player" sendLocalState/applyRemoteState actually read and write -
|
// "The player" sendLocalState/applyRemoteState actually read and write -
|
||||||
// the local <video> by default, swapped for a Chromecast's RemotePlayer
|
// the local <video> by default, swapped for a Chromecast's RemotePlayer
|
||||||
// while casting (see toggleCast/cast.ts). Casting is available to any
|
// while casting (see toggleCast/cast.ts). Casting is available to any
|
||||||
|
|
@ -427,6 +437,42 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
timeLabel.textContent = duration > 0 ? `${formatDuration(current)} / ${formatDuration(duration)}` : '';
|
timeLabel.textContent = duration > 0 ? `${formatDuration(current)} / ${formatDuration(duration)}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows/hides #skipIntroBtn from activeDriver's current position against
|
||||||
|
* currentMarkers - called continuously (progressTicker, below), same as
|
||||||
|
* updateProgressBar. Host-only: a follower's own seek never broadcasts
|
||||||
|
* (see sendLocalState's own gate), so a local jump here would just get
|
||||||
|
* overwritten by the next correction from the real host - same
|
||||||
|
* reasoning as progressBar being read-only for a follower.
|
||||||
|
*
|
||||||
|
* Credits get no button of their own: #nextBtn already does exactly
|
||||||
|
* "go to the next queued item" and is already shown whenever
|
||||||
|
* hasNextQueued is true (see onItemChanged) - detection's only real
|
||||||
|
* contribution there is making sure it's actually reachable the moment
|
||||||
|
* credits start, by un-minimizing the bar once, rather than requiring
|
||||||
|
* the host to have touched the player recently. The `creditsControlsShown`
|
||||||
|
* guard is what keeps this a one-shot nudge instead of fighting anyone
|
||||||
|
* who deliberately re-minimizes the bar during a long credits scroll.
|
||||||
|
*/
|
||||||
|
function updateSkipButton() {
|
||||||
|
if (!isHost || !currentMarkers) {
|
||||||
|
skipIntroBtn.classList.add('hide');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = activeDriver.currentTime;
|
||||||
|
const { introStartTicks, introEndTicks, creditsStartTicks } = currentMarkers;
|
||||||
|
|
||||||
|
const inIntro = introStartTicks != null && introEndTicks != null
|
||||||
|
&& current >= introStartTicks / TICKS_PER_SECOND && current < introEndTicks / TICKS_PER_SECOND;
|
||||||
|
skipIntroBtn.classList.toggle('hide', !inIntro);
|
||||||
|
|
||||||
|
const inCredits = creditsStartTicks != null && current >= creditsStartTicks / TICKS_PER_SECOND;
|
||||||
|
if (inCredits && hasNextQueued && !creditsControlsShown) {
|
||||||
|
creditsControlsShown = true;
|
||||||
|
showControls();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Shared by every discrete play/pause/seek event this player reacts
|
/** Shared by every discrete play/pause/seek event this player reacts
|
||||||
* to - the local <video>'s own native events (becomeHost) and a cast
|
* to - the local <video>'s own native events (becomeHost) and a cast
|
||||||
* session's RemotePlayerController events (startCasting's own
|
* session's RemotePlayerController events (startCasting's own
|
||||||
|
|
@ -505,6 +551,12 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
updateMuteBtn();
|
updateMuteBtn();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
skipIntroBtn.addEventListener('click', () => {
|
||||||
|
if (currentMarkers?.introEndTicks == null) return;
|
||||||
|
activeDriver.currentTime = currentMarkers.introEndTicks / TICKS_PER_SECOND;
|
||||||
|
onDriverStateChanged();
|
||||||
|
});
|
||||||
|
|
||||||
// Ticks the seek bar/time label between discrete events - currentTime
|
// Ticks the seek bar/time label between discrete events - currentTime
|
||||||
// moves continuously while playing, with no event of its own for
|
// moves continuously while playing, with no event of its own for
|
||||||
// "still playing, time passed" the way play/pause/seeked have. Runs
|
// "still playing, time passed" the way play/pause/seeked have. Runs
|
||||||
|
|
@ -515,6 +567,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
progressTicker = window.setInterval(() => {
|
progressTicker = window.setInterval(() => {
|
||||||
if (!currentItemId) return;
|
if (!currentItemId) return;
|
||||||
updateProgressBar();
|
updateProgressBar();
|
||||||
|
updateSkipButton();
|
||||||
}, 500);
|
}, 500);
|
||||||
updatePlayPauseBtn();
|
updatePlayPauseBtn();
|
||||||
updateMuteBtn();
|
updateMuteBtn();
|
||||||
|
|
@ -656,6 +709,24 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Emby's own native intro/credits markers for `itemId`, if the library
|
||||||
|
* it's from has detection enabled and the task has already reached it
|
||||||
|
* - see CatalogService.getPlaybackMarkers. Reset first, synchronously,
|
||||||
|
* not just on a fetch failure: without this, the OLD item's markers
|
||||||
|
* (and skip button) would keep showing for however long this fetch
|
||||||
|
* takes on the new one. */
|
||||||
|
async function loadMarkers(itemId: string) {
|
||||||
|
currentMarkers = null;
|
||||||
|
skipIntroBtn.classList.add('hide');
|
||||||
|
creditsControlsShown = false;
|
||||||
|
try {
|
||||||
|
currentMarkers = await getPlaybackMarkers(itemId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[watchparty player] playback markers failed', err);
|
||||||
|
// Non-fatal, same as loadTrackOptions above - playback works fine with no skip button.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* (Re)builds the subtitle <select> from the last-fetched playback
|
* (Re)builds the subtitle <select> from the last-fetched playback
|
||||||
* options, called both on a genuine item load and whenever burn-in/
|
* options, called both on a genuine item load and whenever burn-in/
|
||||||
|
|
@ -940,6 +1011,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
burnInAudioIndex = null;
|
burnInAudioIndex = null;
|
||||||
textSubtitleIndex = null;
|
textSubtitleIndex = null;
|
||||||
streamAudioIndex = null;
|
streamAudioIndex = null;
|
||||||
|
loadMarkers(msg.itemId); // not awaited - never needed before playback can start, see loadVideo below
|
||||||
await loadTrackOptions(msg.itemId);
|
await loadTrackOptions(msg.itemId);
|
||||||
loadVideo(msg.itemId, 0, true);
|
loadVideo(msg.itemId, 0, true);
|
||||||
}
|
}
|
||||||
|
|
@ -1102,6 +1174,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
// image-subtitle option as disabled, as if they were a follower.
|
// image-subtitle option as disabled, as if they were a follower.
|
||||||
if (party.youAreHost) becomeHost(false);
|
if (party.youAreHost) becomeHost(false);
|
||||||
|
|
||||||
|
loadMarkers(party.itemId); // not awaited - see onItemChanged's own comment
|
||||||
await loadTrackOptions(party.itemId);
|
await loadTrackOptions(party.itemId);
|
||||||
loadVideo(party.itemId, 0, false);
|
loadVideo(party.itemId, 0, false);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3176,6 +3176,173 @@ so that was checked by reading `.mainContent`'s own layout directly
|
||||||
`position: sticky` resolves against the page scroll as intended, not
|
`position: sticky` resolves against the page scroll as intended, not
|
||||||
some inner element).
|
some inner element).
|
||||||
|
|
||||||
|
## Skipping the intro/end credits, using Emby's own native detection
|
||||||
|
|
||||||
|
Asked for directly: propose skipping a TV episode's opening/end credits,
|
||||||
|
Netflix-style. Checked directly against the real server before building
|
||||||
|
anything, rather than assuming a data source existed - no plugin
|
||||||
|
provides this (the installed "Cinema Intros" plugin is a different
|
||||||
|
feature entirely - pre-show trailers, not per-episode skip points), and
|
||||||
|
the embedded `Chapters` Emby already returns turned out to be
|
||||||
|
auto-generated placeholders every 5 minutes ("Chapitre 1", "Chapitre
|
||||||
|
2", ...), not real markers.
|
||||||
|
|
||||||
|
What the server DOES have, confirmed from its own `/openapi.json`
|
||||||
|
rather than assumed: a native, per-library `EnableMarkerDetection`
|
||||||
|
option (`Library/VirtualFolders`'s `LibraryOptions`) backed by a
|
||||||
|
built-in "Detect Episode Intros" scheduled task - audio-fingerprint
|
||||||
|
detection, the same idea as Jellyfin's separate Intro Skipper plugin,
|
||||||
|
just already part of Emby itself. Once enabled and run, it adds real
|
||||||
|
entries to the exact same `Chapters` array, distinguished from the
|
||||||
|
auto-generated ones by `MarkerType`: `IntroStart`/`IntroEnd`/
|
||||||
|
`CreditsStart` (confirmed from the schema, not guessed - there is no
|
||||||
|
`CreditsEnd`, since credits are assumed to run to the end of the file).
|
||||||
|
|
||||||
|
Given that, this feature is almost entirely "read what Emby already
|
||||||
|
computed and expose it" - no fingerprinting, no new persistence, no
|
||||||
|
background jobs of this app's own. `CatalogService.getPlaybackMarkers`
|
||||||
|
fetches `Fields=Chapters` for an item and picks out just those three
|
||||||
|
marker types (nulls when detection hasn't reached this item, or isn't
|
||||||
|
enabled for its library - never an error), behind a new
|
||||||
|
`GET /api/catalog/item/:id/markers` route, mirroring
|
||||||
|
`getPlaybackOptions`'s own shape exactly.
|
||||||
|
|
||||||
|
**The player side** adds one new floating button, `#skipIntroBtn` -
|
||||||
|
explicit label ("Passer l'intro"), not an icon, matching this app's own
|
||||||
|
established convention for every request-shaped control. It's
|
||||||
|
positioned as a sibling of `#bottomBar`, not inside it, specifically so
|
||||||
|
it stays reachable through the bar's own auto-hide/`.minimal` collapse -
|
||||||
|
someone who hasn't touched the player still needs to see it the moment
|
||||||
|
the intro starts. `updateSkipButton` (ticked every 500ms alongside
|
||||||
|
`updateProgressBar`) compares `activeDriver.currentTime` against the
|
||||||
|
fetched markers and shows/hides it; clicking it seeks straight to
|
||||||
|
`introEndTicks` through the exact same `activeDriver`/`onDriverStateChanged`
|
||||||
|
path any other seek already uses, so it's automatically host-only (a
|
||||||
|
follower's seek never broadcasts - see `sendLocalState`'s own gate) and
|
||||||
|
automatically cast-aware (works identically whether `activeDriver` is
|
||||||
|
the local `<video>` or a Chromecast's `RemotePlayer`) with no new
|
||||||
|
special-casing for either.
|
||||||
|
|
||||||
|
End credits deliberately got no button of their own: `#nextBtn` already
|
||||||
|
does exactly "go to the next queued item" and was already shown
|
||||||
|
whenever `hasNextQueued` is true, for the whole episode - not something
|
||||||
|
this feature needed to duplicate. The only real gap it closes is
|
||||||
|
*visibility*: if nobody's touched the player in a while, `#bottomBar`
|
||||||
|
(and `#nextBtn` inside it) is collapsed to `.minimal` right when
|
||||||
|
credits start. `updateSkipButton` calls the existing `showControls()`
|
||||||
|
once (a `creditsControlsShown` flag keeps it one-shot, not a fight
|
||||||
|
against someone deliberately re-minimizing the bar during a long
|
||||||
|
credits scroll) the moment playback crosses `creditsStartTicks`, so the
|
||||||
|
already-existing button is actually reachable without requiring a mouse
|
||||||
|
nudge first.
|
||||||
|
|
||||||
|
Verified: full `typecheck`/`test`/`build` on both projects (125/125
|
||||||
|
server tests, including new coverage for `getPlaybackMarkers` - the
|
||||||
|
real `MarkerType` values mixed with auto-generated `Chapter` ones, and
|
||||||
|
both "no Chapters field" and "no markers among the Chapters that are
|
||||||
|
there" resolving to nulls rather than throwing). The button's on-screen
|
||||||
|
position was checked directly (`getBoundingClientRect` against the real
|
||||||
|
compiled CSS, not eyeballed) - it sits clear of `#bottomBar` with a
|
||||||
|
10px gap at both a 960×540 and a phone-width viewport, since both use
|
||||||
|
fixed pixel offsets off the same `.playerWrap`. **Not verified**: the
|
||||||
|
detection task had only just been enabled on the real server (confirmed
|
||||||
|
via its own `EnableMarkerDetection`/`ScheduledTasks` state) and hadn't
|
||||||
|
finished analyzing any episode yet at the time this shipped, so the
|
||||||
|
actual end-to-end "does a detected marker make the button appear at
|
||||||
|
the right second" behavior is unconfirmed against real generated data -
|
||||||
|
worth a real check once the task has had time to run.
|
||||||
|
|
||||||
|
## Making Emby's own admin dashboard see watch-party streams
|
||||||
|
|
||||||
|
Asked for directly: Emby's admin "now playing" dashboard should see
|
||||||
|
this app's streams the same way it sees a real web/mobile/TV client's -
|
||||||
|
this is exactly the follow-up "Sync watch-party progress back to Emby"
|
||||||
|
(above) already anticipated and deliberately deferred, word for word:
|
||||||
|
"wiring clean start/stop into every transition... is real complexity
|
||||||
|
for a secondary payoff, left as a documented follow-up rather than
|
||||||
|
built speculatively." That section also already explains why: this app
|
||||||
|
switched entirely to direct `UserData` writes for resume-position/
|
||||||
|
watched-history, abandoning Emby's session-shaped `PlayingItems`
|
||||||
|
endpoints, precisely because they need a real session lifecycle to mean
|
||||||
|
anything - and given that section's own history (two separate rounds of
|
||||||
|
a wrong-but-schema-plausible request shape for that *other*,
|
||||||
|
now-abandoned endpoint family), this one was built carefully, checking
|
||||||
|
the real API before writing anything rather than assuming.
|
||||||
|
|
||||||
|
**Confirmed against the real server's own `/openapi.json`** (not
|
||||||
|
assumed) that the actual session-lifecycle family is `POST
|
||||||
|
/Sessions/Playing` (start), `/Sessions/Playing/Progress`, and
|
||||||
|
`/Sessions/Playing/Stopped` - distinct from the `PlayingItems` family
|
||||||
|
`PlaystateService` already walked away from. None of the three schemas
|
||||||
|
declare a `required` list at all, which - given this exact neighboring
|
||||||
|
history - was read as "don't trust that," not "everything's optional
|
||||||
|
in practice": `ItemId`/`MediaSourceId`/`PlaySessionId`/`PositionTicks`
|
||||||
|
are sent on every call anyway, matching what a real client obviously
|
||||||
|
would, rather than risking a repeat of the earlier "technically matches
|
||||||
|
the schema, still 400s" experience.
|
||||||
|
|
||||||
|
**One real Emby session per PARTY MEMBER, not one per party** - a
|
||||||
|
faithful copy of how a real client behaves (every device/account gets
|
||||||
|
its own), and the actual point of "let admin see who's watching what"
|
||||||
|
would be lost by collapsing every member onto one shared entry. New
|
||||||
|
`server/src/services/EmbySessionService.ts` (`start`/`progress`/`stop`)
|
||||||
|
mirrors `PlaystateService`'s own shape closely - same best-effort
|
||||||
|
philosophy (every failure caught and logged, never thrown), same
|
||||||
|
`Logger.playstate` category (broadened to describe both) - but is kept
|
||||||
|
as its own class rather than folded into `PlaystateService`, since that
|
||||||
|
class's entire opening comment is specifically about why it *doesn't*
|
||||||
|
use a session-shaped endpoint; mixing the two would muddy exactly the
|
||||||
|
distinction that comment goes out of its way to draw.
|
||||||
|
|
||||||
|
**Getting a `MediaSourceId`/`PlaySessionId` per member** needed a new
|
||||||
|
`CatalogService.negotiatePlaybackSession(session, itemId)` - a fresh
|
||||||
|
`PlaybackInfo` negotiation under that specific member's own account.
|
||||||
|
This can't reuse `PartyStreamService`'s existing shared negotiation:
|
||||||
|
that one is negotiated once, under whichever member happened to
|
||||||
|
trigger it first, and Emby scopes `PlaybackInfo` per user - never
|
||||||
|
guaranteed valid for reporting under everyone else's own account too.
|
||||||
|
Returns `null` on any failure rather than throwing (an expired token,
|
||||||
|
Emby unreachable, no source) - unlike `getMediaSource`'s own hard
|
||||||
|
failure (which feeds a real ffmpeg source and has to fail loudly), this
|
||||||
|
one only ever feeds an admin-dashboard nicety.
|
||||||
|
|
||||||
|
**Driven entirely from index.ts's existing periodic sweep**, not new
|
||||||
|
hooks scattered across every queue-advance/track-skip call site the way
|
||||||
|
`PlaystateService.markPlayed` needed. That sweep already visits every
|
||||||
|
connected member on every ~20s tick - exactly positioned to notice
|
||||||
|
"this member's current item no longer matches `PartyMember
|
||||||
|
.embySession.itemId`" and react: stop the old session if there was
|
||||||
|
one, negotiate fresh ids, start the new one. The same item as last tick
|
||||||
|
just sends a Progress report reusing the already-cached ids - no
|
||||||
|
renegotiation needed for the common case. `PartyMember` gained the
|
||||||
|
nullable `embySession` field this reads/writes (same null-until-known
|
||||||
|
shape as `embyToken`/`deviceId`). Explicit `stop` calls also happen at
|
||||||
|
the two places a member's session should end promptly rather than
|
||||||
|
waiting on the next sweep tick to just stop reporting: `PartyService
|
||||||
|
.leave()` (alongside the existing `CastTokenService.revokeForMember`)
|
||||||
|
and `removeIdleParties` (a defensive backstop for a member whose own
|
||||||
|
disconnect-driven `leave()` never fired for some reason - a real gap
|
||||||
|
would otherwise leave a stale dashboard entry indefinitely, not just
|
||||||
|
until the next tick).
|
||||||
|
|
||||||
|
Verified: full `typecheck`/`test`/`build` (140/140 server tests - new
|
||||||
|
coverage for `negotiatePlaybackSession`'s null-on-any-failure
|
||||||
|
contract, every `EmbySessionService` method's exact request shape and
|
||||||
|
that a non-ok response/thrown error is swallowed not propagated, and
|
||||||
|
that `leave()` calls `stop` with a member's real cached session info
|
||||||
|
but only when they actually had one open). **Not verified**: an actual
|
||||||
|
live write against the real server - deliberately not attempted from
|
||||||
|
here, since doing so would mean using a real account's Emby token for
|
||||||
|
a genuine state-changing call, beyond the read-only diagnostic use
|
||||||
|
this session's shared API key was ever meant for. So while the request
|
||||||
|
shape is checked against the real schema and the per-member logic is
|
||||||
|
unit-tested in full, whether Emby's dashboard actually displays these
|
||||||
|
sessions the way a native client's would - correct device/client
|
||||||
|
labeling in particular, since this app never sends the `X-Emby-
|
||||||
|
Authorization` Device/Client/Version header a real client would, only
|
||||||
|
`X-Emby-Token` - is genuinely unconfirmed. Worth watching the real
|
||||||
|
dashboard the first time a party runs after this ships.
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
- Playback always goes through `hls.js` against whatever Emby's
|
- Playback always goes through `hls.js` against whatever Emby's
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"typecheck": "tsc -p tsconfig.test.json",
|
"typecheck": "tsc -p tsconfig.test.json",
|
||||||
"test": "tsx --test test/castTokenService.test.ts test/catalogService.test.ts test/embyClient.test.ts test/musicQueueService.test.ts test/partyService.test.ts test/partyStreamService.test.ts test/playstateService.test.ts test/socketGateway.test.ts test/transcodeService.test.ts"
|
"test": "tsx --test test/castTokenService.test.ts test/catalogService.test.ts test/embyClient.test.ts test/embySessionService.test.ts test/musicQueueService.test.ts test/partyService.test.ts test/partyStreamService.test.ts test/playstateService.test.ts test/socketGateway.test.ts test/transcodeService.test.ts"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|
|
||||||
|
|
@ -66,17 +66,20 @@ export class Logger {
|
||||||
console.log(`[watchparty:transcode] ${new Date().toISOString()} ${message}`);
|
console.log(`[watchparty:transcode] ${new Date().toISOString()} ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The party watch-state sync (index.ts's periodic sweep, and
|
/** Every per-member background write this app makes to Emby under
|
||||||
* PartyService.advanceQueue/MusicQueueService.playNext's own item-
|
* someone's own account - the party watch-state sync (index.ts's
|
||||||
* transition calls - see PlaystateService) - logs every outgoing
|
* periodic sweep, and PartyService.advanceQueue/MusicQueueService
|
||||||
* request (method + path, and the POST body for the UserData write)
|
* .playNext's own item-transition calls - see PlaystateService), and
|
||||||
* and its outcome, not just failures: this is a best-effort
|
* the parallel Sessions-API lifecycle reported alongside it (see
|
||||||
* background sync with nothing else watching it, so `docker logs |
|
* EmbySessionService). Logs every outgoing request (method + path,
|
||||||
* grep watchparty:playstate` needs to be enough on its own to see
|
* and the POST body for the UserData write) and its outcome, not
|
||||||
* exactly what was sent and whether Emby accepted it, without cross-
|
* just failures: this is a best-effort background sync with nothing
|
||||||
* referencing `emby` above. Safe to log in full here (unlike `stream`
|
* else watching it, so `docker logs | grep watchparty:playstate`
|
||||||
* above) - the real embyToken travels as a header, never logged, and
|
* needs to be enough on its own to see exactly what was sent and
|
||||||
* PlaystateService's own tests confirm it never leaks into these lines. */
|
* whether Emby accepted it, without cross-referencing `emby` above.
|
||||||
|
* Safe to log in full here (unlike `stream` above) - the real
|
||||||
|
* embyToken travels as a header, never logged, and both services'
|
||||||
|
* own tests confirm it never leaks into these lines. */
|
||||||
static playstate(message: string): void {
|
static playstate(message: string): void {
|
||||||
console.log(`[watchparty:playstate] ${new Date().toISOString()} ${message}`);
|
console.log(`[watchparty:playstate] ${new Date().toISOString()} ${message}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { MusicQueueService } from './services/MusicQueueService';
|
||||||
import { TranscodeService } from './services/TranscodeService';
|
import { TranscodeService } from './services/TranscodeService';
|
||||||
import { PartyStreamService } from './services/PartyStreamService';
|
import { PartyStreamService } from './services/PartyStreamService';
|
||||||
import { PlaystateService } from './services/PlaystateService';
|
import { PlaystateService } from './services/PlaystateService';
|
||||||
|
import { EmbySessionService } from './services/EmbySessionService';
|
||||||
import { CastTokenService } from './services/CastTokenService';
|
import { CastTokenService } from './services/CastTokenService';
|
||||||
import { SocketGateway } from './realtime/SocketGateway';
|
import { SocketGateway } from './realtime/SocketGateway';
|
||||||
import type { ClientToServerEvents, EmbySession, MediaType, QueueItem, ServerToClientEvents } from './types';
|
import type { ClientToServerEvents, EmbySession, MediaType, QueueItem, ServerToClientEvents } from './types';
|
||||||
|
|
@ -212,6 +213,13 @@ app.get('/api/catalog/item/:itemId/playback-options', requireSession, asyncRoute
|
||||||
res.json(await CatalogService.getPlaybackOptions(req.embySession!, param(req, 'itemId')));
|
res.json(await CatalogService.getPlaybackOptions(req.embySession!, param(req, 'itemId')));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Emby's own native intro/credits detection, when the library it's from has
|
||||||
|
// the feature enabled - powers the player's "Passer l'intro"/"Épisode
|
||||||
|
// suivant" buttons. See CatalogService.getPlaybackMarkers.
|
||||||
|
app.get('/api/catalog/item/:itemId/markers', requireSession, asyncRoute(async (req, res) => {
|
||||||
|
res.json(await CatalogService.getPlaybackMarkers(req.embySession!, param(req, 'itemId')));
|
||||||
|
}));
|
||||||
|
|
||||||
app.get('/api/catalog/libraries/:libraryId/items', requireSession, asyncRoute(async (req, res) => {
|
app.get('/api/catalog/libraries/:libraryId/items', requireSession, asyncRoute(async (req, res) => {
|
||||||
res.json({ items: await CatalogService.getLibraryItems(req.embySession!, param(req, 'libraryId')) });
|
res.json({ items: await CatalogService.getLibraryItems(req.embySession!, param(req, 'libraryId')) });
|
||||||
}));
|
}));
|
||||||
|
|
@ -606,6 +614,17 @@ setInterval(() => PartyStreamService.reapIdleSessions(30 * 60 * 1000), 5 * 60 *
|
||||||
// Marking an item Played (once the party moves past it) is handled
|
// Marking an item Played (once the party moves past it) is handled
|
||||||
// separately, at the source of that transition - see
|
// separately, at the source of that transition - see
|
||||||
// PartyService.advanceQueue and MusicQueueService.playNext.
|
// PartyService.advanceQueue and MusicQueueService.playNext.
|
||||||
|
//
|
||||||
|
// Also where EmbySessionService's own start/progress/stop lifecycle is
|
||||||
|
// driven from, rather than scattered across every queue-advance/track-
|
||||||
|
// skip call site the way markPlayed needed to be - this loop already
|
||||||
|
// visits every connected member on every tick, so it's already in
|
||||||
|
// exactly the right place to notice "this member's current item just
|
||||||
|
// changed" (member.embySession.itemId no longer matches) and react:
|
||||||
|
// stop the old session if there was one, negotiate a fresh
|
||||||
|
// MediaSourceId/PlaySessionId under their own account, and start the
|
||||||
|
// new one. Same item as last tick just reports Progress with the
|
||||||
|
// already-cached ids - no renegotiation needed for that, the common case.
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
for (const party of PartyService.listActive()) {
|
for (const party of PartyService.listActive()) {
|
||||||
const state = PartyService.getCurrentPlaybackState(party);
|
const state = PartyService.getCurrentPlaybackState(party);
|
||||||
|
|
@ -613,6 +632,35 @@ setInterval(() => {
|
||||||
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.updatePosition(member.embyToken, member.userId, state.itemId, state.positionTicks);
|
PlaystateService.updatePosition(member.embyToken, member.userId, state.itemId, state.positionTicks);
|
||||||
|
|
||||||
|
if (member.embySession && member.embySession.itemId === state.itemId) {
|
||||||
|
const s = member.embySession;
|
||||||
|
EmbySessionService.progress(member.embyToken, s.itemId, s.mediaSourceId, s.playSessionId, state.positionTicks, state.isPaused);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The item just changed for them (or this is the first tick since
|
||||||
|
// they started watching/listening) - stop whatever they had open
|
||||||
|
// before, if anything, then negotiate and start a fresh one.
|
||||||
|
const previous = member.embySession;
|
||||||
|
const embyToken = member.embyToken;
|
||||||
|
member.embySession = null;
|
||||||
|
if (previous) {
|
||||||
|
EmbySessionService.stop(embyToken, previous.itemId, previous.mediaSourceId, previous.playSessionId, member.positionTicks);
|
||||||
|
}
|
||||||
|
// CatalogService's own methods all take a full EmbySession, for
|
||||||
|
// consistency with every live user-facing route that already has
|
||||||
|
// one from requireSession - this sweep only ever has a member's
|
||||||
|
// bare stored token, same situation PlaystateService/
|
||||||
|
// EmbySessionService are already in. serverId/createdUtc are safe
|
||||||
|
// to fake: negotiatePlaybackSession (like every per-member Emby
|
||||||
|
// write in this app) never reads either, only embyToken/userId.
|
||||||
|
const fakeSession: EmbySession = { embyToken, userId: member.userId, userName: member.userName, serverId: '', createdUtc: 0 };
|
||||||
|
CatalogService.negotiatePlaybackSession(fakeSession, state.itemId).then((negotiated) => {
|
||||||
|
if (!negotiated) return;
|
||||||
|
member.embySession = { itemId: state.itemId, mediaSourceId: negotiated.mediaSourceId, playSessionId: negotiated.playSessionId };
|
||||||
|
EmbySessionService.start(embyToken, state.itemId, negotiated.mediaSourceId, negotiated.playSessionId, state.positionTicks, state.isPaused);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 20 * 1000);
|
}, 20 * 1000);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import type { Request, Response } from 'express';
|
||||||
import { EMBY_SERVER_URL } from '../config';
|
import { EMBY_SERVER_URL } from '../config';
|
||||||
import { EmbyClient } from '../emby/EmbyClient';
|
import { EmbyClient } from '../emby/EmbyClient';
|
||||||
import { Logger } from '../Logger';
|
import { Logger } from '../Logger';
|
||||||
import type { EmbyItem, EmbySession, ItemDto, LibraryDto, MediaType, PlaybackOptionsDto, SeasonDto } from '../types';
|
import type { EmbyItem, EmbySession, ItemDto, LibraryDto, MediaType, PlaybackMarkersDto, PlaybackOptionsDto, SeasonDto } from '../types';
|
||||||
|
|
||||||
// 'MusicVideo' is Emby's own item type for a musicvideos library's
|
// 'MusicVideo' is Emby's own item type for a musicvideos library's
|
||||||
// contents (distinct from the generic 'Video', which is home videos).
|
// contents (distinct from the generic 'Video', which is home videos).
|
||||||
|
|
@ -286,6 +286,30 @@ class CatalogServiceClass {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emby's own native intro/credits detection - see PlaybackMarkersDto's
|
||||||
|
* own comment for what MarkerType actually looks like and where that
|
||||||
|
* was confirmed. Read from the same Chapters field getRawItem/toItemDto
|
||||||
|
* never request (they have no use for chapters at all) - a dedicated
|
||||||
|
* fetch here rather than folding it into getItem, since a chapter list
|
||||||
|
* is only ever needed by the player deciding whether to show a skip
|
||||||
|
* button, nowhere a plain ItemDto is used.
|
||||||
|
*/
|
||||||
|
async getPlaybackMarkers(session: EmbySession, itemId: string): Promise<PlaybackMarkersDto> {
|
||||||
|
const data = await this.fetchJson<{ Chapters?: Array<{ StartPositionTicks: number; MarkerType?: string }> }>(
|
||||||
|
`/Users/${session.userId}/Items/${itemId}?Fields=Chapters`,
|
||||||
|
session,
|
||||||
|
'Impossible de récupérer les marqueurs de lecture.'
|
||||||
|
);
|
||||||
|
const chapters = data.Chapters || [];
|
||||||
|
const startOf = (markerType: string) => chapters.find((c) => c.MarkerType === markerType)?.StartPositionTicks ?? null;
|
||||||
|
return {
|
||||||
|
introStartTicks: startOf('IntroStart'),
|
||||||
|
introEndTicks: startOf('IntroEnd'),
|
||||||
|
creditsStartTicks: startOf('CreditsStart')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The negotiated MediaSource's id/container/streams - used by
|
* The negotiated MediaSource's id/container/streams - used by
|
||||||
* TranscodeService to build its own ffmpeg source URL and translate
|
* TranscodeService to build its own ffmpeg source URL and translate
|
||||||
|
|
@ -307,6 +331,33 @@ class CatalogServiceClass {
|
||||||
return { id: source.Id, container: source.Container || 'mkv', streams: source.MediaStreams || [] };
|
return { id: source.Id, container: source.Container || 'mkv', streams: source.MediaStreams || [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fresh PlaybackInfo negotiation under `session`'s own account,
|
||||||
|
* purely to get the MediaSourceId/PlaySessionId EmbySessionService
|
||||||
|
* needs to open a real Emby Sessions-API entry for this member - see
|
||||||
|
* its own class comment for why every party member needs their own,
|
||||||
|
* rather than reusing the one PartyStreamService already negotiated
|
||||||
|
* for the party's actual shared relay (that one was negotiated once,
|
||||||
|
* under whichever member happened to trigger it first - never
|
||||||
|
* guaranteed valid for every OTHER member's own account, since Emby
|
||||||
|
* scopes PlaybackInfo per user). Null on any failure (an expired
|
||||||
|
* token, Emby unreachable, no source at all) rather than throwing -
|
||||||
|
* this is reported to an admin dashboard as a nice-to-have, never
|
||||||
|
* something that should be allowed to disrupt real playback the way
|
||||||
|
* getMediaSource's own hard failure is allowed to (that one feeds an
|
||||||
|
* actual ffmpeg source).
|
||||||
|
*/
|
||||||
|
async negotiatePlaybackSession(session: EmbySession, itemId: string): Promise<{ mediaSourceId: string; playSessionId: string } | null> {
|
||||||
|
try {
|
||||||
|
const info = await this.fetchJson<PlaybackInfoResponse>(`/Items/${itemId}/PlaybackInfo?UserId=${session.userId}`, session, 'Impossible de négocier une session de lecture.');
|
||||||
|
const source = (info.MediaSources || [])[0];
|
||||||
|
if (!source?.Id || !info.PlaySessionId) return null;
|
||||||
|
return { mediaSourceId: source.Id, playSessionId: info.PlaySessionId };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Direct proxying (images/video/audio) - these need the raw
|
// --- Direct proxying (images/video/audio) - these need the raw
|
||||||
// Express req/res, unlike everything above which just returns JSON. ---
|
// Express req/res, unlike everything above which just returns JSON. ---
|
||||||
|
|
||||||
|
|
|
||||||
104
server/src/services/EmbySessionService.ts
Normal file
104
server/src/services/EmbySessionService.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import { EmbyClient } from '../emby/EmbyClient';
|
||||||
|
import { Logger } from '../Logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports a party member's own playback to Emby's real Sessions API
|
||||||
|
* (`POST /Sessions/Playing`, `/Sessions/Playing/Progress`,
|
||||||
|
* `/Sessions/Playing/Stopped`) - the piece of session lifecycle
|
||||||
|
* docs/architecture.md's "Sync watch-party progress back to Emby"
|
||||||
|
* section deliberately deferred at the time ("wiring clean start/stop
|
||||||
|
* into every transition... is real complexity for a secondary payoff").
|
||||||
|
* Asked for directly since: without this, this app's own streams never
|
||||||
|
* showed up in Emby's admin "now playing" dashboard the way a real web/
|
||||||
|
* mobile/TV client's would - only the resume-position/watched-history
|
||||||
|
* UserData (PlaystateService) was ever visible anywhere, and only by
|
||||||
|
* opening the item itself, not from the dashboard's live session list.
|
||||||
|
*
|
||||||
|
* One real Emby session per PARTY MEMBER, not one per party - a
|
||||||
|
* faithful copy of how a real client behaves (every device/account gets
|
||||||
|
* its own), and the actual point of the request ("let admin see who's
|
||||||
|
* watching what") would be lost by collapsing every member onto one
|
||||||
|
* shared entry. Every call needs the member's own `embyToken` (the same
|
||||||
|
* source every other per-member Emby write in this app already uses -
|
||||||
|
* see `PartyMember.embyToken`/`PlaystateService`) - Emby's Sessions API
|
||||||
|
* is inherently per-account, there's no equivalent of "the party's" own
|
||||||
|
* session to report against instead.
|
||||||
|
*
|
||||||
|
* `MediaSourceId`/`PlaySessionId` (see `CatalogService
|
||||||
|
* .negotiatePlaybackSession`) are required in practice, not just
|
||||||
|
* present in the schema - confirmed by reading the real server's own
|
||||||
|
* `/openapi.json` rather than assumed, after this exact neighboring
|
||||||
|
* area (`PlaystateService`'s own history) already got bitten twice by
|
||||||
|
* an Emby endpoint accepting a request shape that looked complete but
|
||||||
|
* wasn't. `index.ts`'s periodic sweep is what actually drives the
|
||||||
|
* start/progress/stop transitions - see its own comment for why that
|
||||||
|
* one place could cover this instead of scattering hooks across every
|
||||||
|
* queue-advance/track-skip call site the way `PlaystateService
|
||||||
|
* .markPlayed` needed to.
|
||||||
|
*
|
||||||
|
* Best-effort throughout, exactly like `PlaystateService`: any failure
|
||||||
|
* is caught and logged here, never thrown - none of this may ever
|
||||||
|
* disrupt real playback over an admin-dashboard nicety.
|
||||||
|
*/
|
||||||
|
class EmbySessionServiceClass {
|
||||||
|
/** Called once per member when they start watching/listening to
|
||||||
|
* `itemId` - a fresh join, or the party moving to a new item while
|
||||||
|
* they're already a member (see index.ts's sweep, which detects that
|
||||||
|
* by comparing against `PartyMember.embySession.itemId`). */
|
||||||
|
async start(embyToken: string, itemId: string, mediaSourceId: string, playSessionId: string, positionTicks: number, isPaused: boolean): Promise<void> {
|
||||||
|
await this.report('/Sessions/Playing', embyToken, {
|
||||||
|
ItemId: itemId,
|
||||||
|
MediaSourceId: mediaSourceId,
|
||||||
|
PlaySessionId: playSessionId,
|
||||||
|
PositionTicks: Math.round(positionTicks),
|
||||||
|
IsPaused: isPaused,
|
||||||
|
PlayMethod: 'Transcode',
|
||||||
|
CanSeek: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called periodically (~20s, alongside `PlaystateService
|
||||||
|
* .updatePosition` - see index.ts's sweep) for every member with a
|
||||||
|
* live session already open for their current item. */
|
||||||
|
async progress(embyToken: string, itemId: string, mediaSourceId: string, playSessionId: string, positionTicks: number, isPaused: boolean): Promise<void> {
|
||||||
|
await this.report('/Sessions/Playing/Progress', embyToken, {
|
||||||
|
ItemId: itemId,
|
||||||
|
MediaSourceId: mediaSourceId,
|
||||||
|
PlaySessionId: playSessionId,
|
||||||
|
PositionTicks: Math.round(positionTicks),
|
||||||
|
IsPaused: isPaused,
|
||||||
|
PlayMethod: 'Transcode'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called once per member when they stop watching/listening to
|
||||||
|
* `itemId` - leaving the party (`PartyService.leave`), the item
|
||||||
|
* changing (index.ts's sweep), or the party being destroyed for
|
||||||
|
* inactivity (`PartyService.removeIdleParties`). */
|
||||||
|
async stop(embyToken: string, itemId: string, mediaSourceId: string, playSessionId: string, positionTicks: number): Promise<void> {
|
||||||
|
await this.report('/Sessions/Playing/Stopped', embyToken, {
|
||||||
|
ItemId: itemId,
|
||||||
|
MediaSourceId: mediaSourceId,
|
||||||
|
PlaySessionId: playSessionId,
|
||||||
|
PositionTicks: Math.round(positionTicks)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every call logs its own outgoing request + outcome - see
|
||||||
|
* Logger.playstate's own comment for why this needs to be enough on
|
||||||
|
* its own, greppable, to answer "is this actually reaching Emby, and
|
||||||
|
* with what" for this service too, not just PlaystateService. */
|
||||||
|
private async report(path: string, embyToken: string, body: Record<string, unknown>): Promise<void> {
|
||||||
|
Logger.playstate(`-> POST ${path} itemId=${body.ItemId}`);
|
||||||
|
try {
|
||||||
|
const res = await EmbyClient.fetch(path, embyToken, { method: 'POST', body });
|
||||||
|
Logger.playstate(`<- ${res.status} ${res.ok ? 'ok' : 'FAILED'} ${path} itemId=${body.ItemId}`);
|
||||||
|
} catch (err) {
|
||||||
|
Logger.playstate(`<- threw ${path} itemId=${body.ItemId}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmbySessionService = new EmbySessionServiceClass();
|
||||||
|
|
@ -7,6 +7,7 @@ import { TranscodeService } from './TranscodeService';
|
||||||
import { PartyStreamService } from './PartyStreamService';
|
import { PartyStreamService } from './PartyStreamService';
|
||||||
import { PlaystateService } from './PlaystateService';
|
import { PlaystateService } from './PlaystateService';
|
||||||
import { CastTokenService } from './CastTokenService';
|
import { CastTokenService } from './CastTokenService';
|
||||||
|
import { EmbySessionService } from './EmbySessionService';
|
||||||
import type { LeaveResult, MediaType, Party, PartyMember, PartyPublicState, QueueItem } from '../types';
|
import type { LeaveResult, MediaType, Party, PartyMember, PartyPublicState, QueueItem } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -157,6 +158,15 @@ class PartyServiceClass {
|
||||||
party.lastActivityUtc = Date.now();
|
party.lastActivityUtc = Date.now();
|
||||||
MusicQueueService.removeVote(party, userId); // no-op for a video party (party.music is unset)
|
MusicQueueService.removeVote(party, userId); // no-op for a video party (party.music is unset)
|
||||||
CastTokenService.revokeForMember(party.id, userId); // no-op if they never cast - see its own comment for why this runs unconditionally, not just on an explicit "stop casting"
|
CastTokenService.revokeForMember(party.id, userId); // no-op if they never cast - see its own comment for why this runs unconditionally, not just on an explicit "stop casting"
|
||||||
|
// No-op if they never had a live Emby Sessions-API entry (never
|
||||||
|
// connected, or the periodic sweep hadn't reached them yet) - see
|
||||||
|
// EmbySessionService's own comment. Fire-and-forget, same as every
|
||||||
|
// other per-member Emby report in this app - it already swallows its
|
||||||
|
// own failures, and leaving the party must never wait on Emby.
|
||||||
|
if (leavingMember?.embyToken && leavingMember.embySession) {
|
||||||
|
const s = leavingMember.embySession;
|
||||||
|
EmbySessionService.stop(leavingMember.embyToken, s.itemId, s.mediaSourceId, s.playSessionId, leavingMember.positionTicks);
|
||||||
|
}
|
||||||
|
|
||||||
let newHostUserId: string | null = party.hostUserId;
|
let newHostUserId: string | null = party.hostUserId;
|
||||||
if (wasHost) {
|
if (wasHost) {
|
||||||
|
|
@ -256,6 +266,17 @@ class PartyServiceClass {
|
||||||
MusicQueueService.destroy(party); // same leak this class's own destroy path guards against
|
MusicQueueService.destroy(party); // same leak this class's own destroy path guards against
|
||||||
TranscodeService.endSession(party.id); // same leak - see the "leave" destroy path above
|
TranscodeService.endSession(party.id); // same leak - see the "leave" destroy path above
|
||||||
PartyStreamService.endSession(party.id);
|
PartyStreamService.endSession(party.id);
|
||||||
|
// Same reasoning, for any member whose own "leave" (a clean
|
||||||
|
// disconnect) never fired for whatever reason - a real gap would
|
||||||
|
// otherwise leave a stale entry sitting in Emby's own dashboard
|
||||||
|
// indefinitely, not just until the next sweep tick the way a
|
||||||
|
// still-connected member's stale Progress report would.
|
||||||
|
for (const member of party.members.values()) {
|
||||||
|
if (member.embyToken && member.embySession) {
|
||||||
|
const s = member.embySession;
|
||||||
|
EmbySessionService.stop(member.embyToken, s.itemId, s.mediaSourceId, s.playSessionId, member.positionTicks);
|
||||||
|
}
|
||||||
|
}
|
||||||
const idleMinutes = Math.round((Date.now() - party.lastActivityUtc) / 60000);
|
const idleMinutes = Math.round((Date.now() - party.lastActivityUtc) / 60000);
|
||||||
Logger.party(`destroyed partyId=${party.id} (idle ${idleMinutes}min, ${party.members.size} member(s) still connected)`);
|
Logger.party(`destroyed partyId=${party.id} (idle ${idleMinutes}min, ${party.members.size} member(s) still connected)`);
|
||||||
}
|
}
|
||||||
|
|
@ -311,7 +332,8 @@ class PartyServiceClass {
|
||||||
embyToken: null,
|
embyToken: null,
|
||||||
isPaused: true,
|
isPaused: true,
|
||||||
positionTicks: 0,
|
positionTicks: 0,
|
||||||
lastUpdatedUtc: Date.now()
|
lastUpdatedUtc: Date.now(),
|
||||||
|
embySession: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,25 @@ export interface PlaybackOptionsDto {
|
||||||
subtitleTracks: PlaybackTrackOption[];
|
subtitleTracks: PlaybackTrackOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emby's own native intro/credits detection (a library's own
|
||||||
|
* EnableMarkerDetection option, run by its "Detect Episode Intros"
|
||||||
|
* scheduled task - confirmed directly against a real server's
|
||||||
|
* /openapi.json: MarkerType is one of Chapter/IntroStart/IntroEnd/
|
||||||
|
* CreditsStart, mixed into the same Chapters array as the auto-generated
|
||||||
|
* per-5-minutes ones). Every field is nullable, independently - the task
|
||||||
|
* may not have reached this item yet, the library may not have the
|
||||||
|
* feature enabled at all (it's per-library, not global), or a real
|
||||||
|
* cold-open episode may genuinely have no IntroStart/IntroEnd. None of
|
||||||
|
* that is an error - see CatalogService.getPlaybackMarkers - a caller
|
||||||
|
* just checks each field for null before offering the matching skip
|
||||||
|
* button. */
|
||||||
|
export interface PlaybackMarkersDto {
|
||||||
|
introStartTicks: number | null;
|
||||||
|
introEndTicks: number | null;
|
||||||
|
creditsStartTicks: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Party (video) -----------------------------------------------------------
|
// --- Party (video) -----------------------------------------------------------
|
||||||
|
|
||||||
/** One entry in a video party's fixed, pre-built queue (a movie/standalone
|
/** One entry in a video party's fixed, pre-built queue (a movie/standalone
|
||||||
|
|
@ -260,6 +279,22 @@ export interface PartyMember {
|
||||||
lastUpdatedUtc: number;
|
lastUpdatedUtc: number;
|
||||||
lastPauseRequestUtc?: number;
|
lastPauseRequestUtc?: number;
|
||||||
lastResumeRequestUtc?: number;
|
lastResumeRequestUtc?: number;
|
||||||
|
/** This member's own live Emby Sessions-API entry, if one's currently
|
||||||
|
* open - see EmbySessionService. Null whenever they don't have one
|
||||||
|
* (not connected/no token yet, or the periodic sweep hasn't started
|
||||||
|
* one for their current item yet). `itemId` is what the sweep compares
|
||||||
|
* against on each tick to notice an item change (stop the old
|
||||||
|
* session, start a fresh one) - `mediaSourceId`/`playSessionId` are
|
||||||
|
* cached here specifically so a Progress report can reuse them
|
||||||
|
* without renegotiating PlaybackInfo on every single tick. */
|
||||||
|
embySession: EmbyPlaybackSession | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** See PartyMember.embySession's own comment. */
|
||||||
|
export interface EmbyPlaybackSession {
|
||||||
|
itemId: string;
|
||||||
|
mediaSourceId: string;
|
||||||
|
playSessionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The full internal party record - never sent to the client as-is, see
|
/** The full internal party record - never sent to the client as-is, see
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,42 @@ describe('CatalogService', () => {
|
||||||
assert.equal(options.subtitleTracks.find((t) => t.index === 4)?.isTextBased, true, 'ass is text too');
|
assert.equal(options.subtitleTracks.find((t) => t.index === 4)?.isTextBased, true, 'ass is text too');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('getPlaybackMarkers picks out Intro/Credits markers by MarkerType, ignoring the auto-generated Chapter ones', async (t) => {
|
||||||
|
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
|
||||||
|
Chapters: [
|
||||||
|
{ StartPositionTicks: 0, Name: 'Chapitre 1', MarkerType: 'Chapter' },
|
||||||
|
{ StartPositionTicks: 50000000, MarkerType: 'IntroStart' },
|
||||||
|
{ StartPositionTicks: 900000000, MarkerType: 'IntroEnd' },
|
||||||
|
{ StartPositionTicks: 3000000000, Name: 'Chapitre 2', MarkerType: 'Chapter' },
|
||||||
|
{ StartPositionTicks: 20000000000, MarkerType: 'CreditsStart' }
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
|
||||||
|
const markers = await CatalogService.getPlaybackMarkers(session, 'item1');
|
||||||
|
|
||||||
|
assert.equal(markers.introStartTicks, 50000000);
|
||||||
|
assert.equal(markers.introEndTicks, 900000000);
|
||||||
|
assert.equal(markers.creditsStartTicks, 20000000000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getPlaybackMarkers returns nulls, not an error, when the item has no detected markers at all', async (t) => {
|
||||||
|
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
|
||||||
|
Chapters: [{ StartPositionTicks: 0, Name: 'Chapitre 1', MarkerType: 'Chapter' }]
|
||||||
|
}));
|
||||||
|
|
||||||
|
const markers = await CatalogService.getPlaybackMarkers(session, 'item1');
|
||||||
|
|
||||||
|
assert.deepEqual(markers, { introStartTicks: null, introEndTicks: null, creditsStartTicks: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getPlaybackMarkers returns nulls when the item has no Chapters field at all', async (t) => {
|
||||||
|
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({}));
|
||||||
|
|
||||||
|
const markers = await CatalogService.getPlaybackMarkers(session, 'item1');
|
||||||
|
|
||||||
|
assert.deepEqual(markers, { introStartTicks: null, introEndTicks: null, creditsStartTicks: null });
|
||||||
|
});
|
||||||
|
|
||||||
it('proxySubtitle asks Emby for this track as WebVTT, using the negotiated MediaSourceId', async (t) => {
|
it('proxySubtitle asks Emby for this track as WebVTT, using the negotiated MediaSourceId', async (t) => {
|
||||||
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src7' }] }));
|
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src7' }] }));
|
||||||
let capturedTarget = '';
|
let capturedTarget = '';
|
||||||
|
|
@ -169,6 +205,35 @@ describe('CatalogService', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('negotiatePlaybackSession returns the MediaSourceId/PlaySessionId a Sessions-API report needs', async (t) => {
|
||||||
|
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
|
||||||
|
MediaSources: [{ Id: 'src1' }],
|
||||||
|
PlaySessionId: 'play1'
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await CatalogService.negotiatePlaybackSession(session, 'item1');
|
||||||
|
|
||||||
|
assert.deepEqual(result, { mediaSourceId: 'src1', playSessionId: 'play1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('negotiatePlaybackSession returns null, not a throw, when Emby has no media source at all', async (t) => {
|
||||||
|
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [], PlaySessionId: 'play1' }));
|
||||||
|
|
||||||
|
assert.equal(await CatalogService.negotiatePlaybackSession(session, 'item1'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('negotiatePlaybackSession returns null when Emby omits PlaySessionId, even with a real source', async (t) => {
|
||||||
|
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src1' }] }));
|
||||||
|
|
||||||
|
assert.equal(await CatalogService.negotiatePlaybackSession(session, 'item1'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('negotiatePlaybackSession returns null, not a throw, when the negotiation itself fails', async (t) => {
|
||||||
|
t.mock.method(EmbyClient, 'fetch', async () => new Response(null, { status: 500 }) as unknown as Response);
|
||||||
|
|
||||||
|
assert.equal(await CatalogService.negotiatePlaybackSession(session, 'item1'), null);
|
||||||
|
});
|
||||||
|
|
||||||
it('getPlaylistItemsRaw asks Emby\'s own /Playlists endpoint, in order', async (t) => {
|
it('getPlaylistItemsRaw asks Emby\'s own /Playlists endpoint, in order', async (t) => {
|
||||||
let capturedPath = '';
|
let capturedPath = '';
|
||||||
t.mock.method(EmbyClient, 'fetch', async (path: string) => {
|
t.mock.method(EmbyClient, 'fetch', async (path: string) => {
|
||||||
|
|
|
||||||
91
server/test/embySessionService.test.ts
Normal file
91
server/test/embySessionService.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
// Exercises EmbySessionService directly against a mocked EmbyClient - no
|
||||||
|
// real Emby server involved. Covers the request shape each of the three
|
||||||
|
// Sessions-API calls sends and that a failure (a non-ok response, or a
|
||||||
|
// thrown error) is swallowed rather than propagated - see the class's
|
||||||
|
// own comment for why none of this may ever be allowed to disrupt real
|
||||||
|
// playback over an admin-dashboard nicety.
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { EmbySessionService } from '../src/services/EmbySessionService';
|
||||||
|
import { EmbyClient } from '../src/emby/EmbyClient';
|
||||||
|
|
||||||
|
function okResponse(): Response {
|
||||||
|
return new Response(null, { status: 204 }) as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EmbySessionService', () => {
|
||||||
|
it('start() POSTs to /Sessions/Playing with the negotiated ids and the given position/pause state', async (t) => {
|
||||||
|
let capturedPath = '';
|
||||||
|
let capturedToken = '';
|
||||||
|
let capturedBody: unknown = null;
|
||||||
|
t.mock.method(EmbyClient, 'fetch', async (path: string, token: string, options: { body?: unknown }) => {
|
||||||
|
capturedPath = path;
|
||||||
|
capturedToken = token;
|
||||||
|
capturedBody = options.body;
|
||||||
|
return okResponse();
|
||||||
|
});
|
||||||
|
|
||||||
|
await EmbySessionService.start('tok1', 'item1', 'src1', 'play1', 12_345, false);
|
||||||
|
|
||||||
|
assert.equal(capturedPath, '/Sessions/Playing');
|
||||||
|
assert.equal(capturedToken, 'tok1');
|
||||||
|
assert.deepEqual(capturedBody, {
|
||||||
|
ItemId: 'item1',
|
||||||
|
MediaSourceId: 'src1',
|
||||||
|
PlaySessionId: 'play1',
|
||||||
|
PositionTicks: 12_345,
|
||||||
|
IsPaused: false,
|
||||||
|
PlayMethod: 'Transcode',
|
||||||
|
CanSeek: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('progress() POSTs to /Sessions/Playing/Progress', async (t) => {
|
||||||
|
let capturedPath = '';
|
||||||
|
t.mock.method(EmbyClient, 'fetch', async (path: string) => {
|
||||||
|
capturedPath = path;
|
||||||
|
return okResponse();
|
||||||
|
});
|
||||||
|
|
||||||
|
await EmbySessionService.progress('tok1', 'item1', 'src1', 'play1', 999, true);
|
||||||
|
|
||||||
|
assert.equal(capturedPath, '/Sessions/Playing/Progress');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stop() POSTs to /Sessions/Playing/Stopped', async (t) => {
|
||||||
|
let capturedPath = '';
|
||||||
|
let capturedBody: unknown = null;
|
||||||
|
t.mock.method(EmbyClient, 'fetch', async (path: string, _token: string, options: { body?: unknown }) => {
|
||||||
|
capturedPath = path;
|
||||||
|
capturedBody = options.body;
|
||||||
|
return okResponse();
|
||||||
|
});
|
||||||
|
|
||||||
|
await EmbySessionService.stop('tok1', 'item1', 'src1', 'play1', 42);
|
||||||
|
|
||||||
|
assert.equal(capturedPath, '/Sessions/Playing/Stopped');
|
||||||
|
assert.deepEqual(capturedBody, { ItemId: 'item1', MediaSourceId: 'src1', PlaySessionId: 'play1', PositionTicks: 42 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rounds a fractional PositionTicks rather than sending a non-integer', async (t) => {
|
||||||
|
let capturedBody: { PositionTicks?: number } = {};
|
||||||
|
t.mock.method(EmbyClient, 'fetch', async (_path: string, _token: string, options: { body?: unknown }) => {
|
||||||
|
capturedBody = options.body as { PositionTicks?: number };
|
||||||
|
return okResponse();
|
||||||
|
});
|
||||||
|
|
||||||
|
await EmbySessionService.progress('tok1', 'item1', 'src1', 'play1', 12_345.6, false);
|
||||||
|
|
||||||
|
assert.equal(capturedBody.PositionTicks, 12_346);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a non-ok response is logged, not thrown', async (t) => {
|
||||||
|
t.mock.method(EmbyClient, 'fetch', async () => new Response(null, { status: 401 }) as unknown as Response);
|
||||||
|
await assert.doesNotReject(() => EmbySessionService.start('tok1', 'item1', 'src1', 'play1', 0, false));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a thrown network error is logged, not propagated', async (t) => {
|
||||||
|
t.mock.method(EmbyClient, 'fetch', async () => { throw new Error('network down'); });
|
||||||
|
await assert.doesNotReject(() => EmbySessionService.stop('tok1', 'item1', 'src1', 'play1', 0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -49,7 +49,8 @@ function fakeMember(userId: string, userName: string, embyToken: string): PartyM
|
||||||
embyToken,
|
embyToken,
|
||||||
isPaused: false,
|
isPaused: false,
|
||||||
positionTicks: 0,
|
positionTicks: 0,
|
||||||
lastUpdatedUtc: Date.now()
|
lastUpdatedUtc: Date.now(),
|
||||||
|
embySession: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { PartyService } from '../src/services/PartyService';
|
||||||
import { MusicQueueService } from '../src/services/MusicQueueService';
|
import { MusicQueueService } from '../src/services/MusicQueueService';
|
||||||
import { PlaystateService } from '../src/services/PlaystateService';
|
import { PlaystateService } from '../src/services/PlaystateService';
|
||||||
import { CastTokenService } from '../src/services/CastTokenService';
|
import { CastTokenService } from '../src/services/CastTokenService';
|
||||||
|
import { EmbySessionService } from '../src/services/EmbySessionService';
|
||||||
import type { AppSocket } from '../src/types';
|
import type { AppSocket } from '../src/types';
|
||||||
|
|
||||||
describe('PartyService', () => {
|
describe('PartyService', () => {
|
||||||
|
|
@ -127,6 +128,33 @@ describe('PartyService', () => {
|
||||||
assert.deepEqual(calls, [[party.id, 'u-bob']]);
|
assert.deepEqual(calls, [[party.id, 'u-bob']]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('leave() stops whatever Emby Sessions-API entry the leaving member had open', (t) => {
|
||||||
|
const calls: unknown[][] = [];
|
||||||
|
t.mock.method(EmbySessionService, 'stop', async (...args: unknown[]) => { calls.push(args); });
|
||||||
|
|
||||||
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
||||||
|
const bob = PartyService.join(party, 'u-bob', 'Bob');
|
||||||
|
bob.embyToken = 'tok-bob';
|
||||||
|
bob.positionTicks = 555;
|
||||||
|
bob.embySession = { itemId: 'm1', mediaSourceId: 'src1', playSessionId: 'play1' };
|
||||||
|
|
||||||
|
PartyService.leave(party, 'u-bob');
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [['tok-bob', 'm1', 'src1', 'play1', 555]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leave() does not call EmbySessionService.stop for a member with no open session', (t) => {
|
||||||
|
let called = false;
|
||||||
|
t.mock.method(EmbySessionService, 'stop', async () => { called = true; });
|
||||||
|
|
||||||
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
||||||
|
PartyService.join(party, 'u-bob', 'Bob'); // embyToken/embySession both still null - see newMember
|
||||||
|
|
||||||
|
PartyService.leave(party, 'u-bob');
|
||||||
|
|
||||||
|
assert.equal(called, false);
|
||||||
|
});
|
||||||
|
|
||||||
it('join() is idempotent: re-joining an existing member returns it as-is instead of resetting it', () => {
|
it('join() is idempotent: re-joining an existing member returns it as-is instead of resetting it', () => {
|
||||||
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
||||||
const bob = PartyService.join(party, 'u-bob', 'Bob');
|
const bob = PartyService.join(party, 'u-bob', 'Bob');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue