Add skip intro / next episode at credits, via Emby's own detection
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped

Asked for directly. Checked the real server before building anything:
no plugin provides this (Cinema Intros is a different feature - pre-show
trailers, not per-episode skip points), and Emby's own Chapters field
turned out to be auto-generated placeholders every 5 minutes, not real
markers.

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-15 23:16:38 +02:00
parent 78b6b00ae2
commit 57bb297bff
11 changed files with 288 additions and 4 deletions

View file

@ -328,6 +328,10 @@ Then put it behind your existing reverse proxy like everything else
as its own always-on-top bar, duplicating the cast icon and
overlapping the new bottom bar in fullscreen) in favor of fully
custom play/pause, a seek bar, and mute — 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
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
- [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
confirmed here — no real device/session)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3101,6 +3101,82 @@ and play the stream end to end - no device reachable from this
sandbox, same standing limitation as the rest of "Casting to a
Chromecast".
## 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.
## Known limitations
- Playback always goes through `hls.js` against whatever Emby's

View file

@ -212,6 +212,13 @@ app.get('/api/catalog/item/:itemId/playback-options', requireSession, asyncRoute
res.json(await CatalogService.getPlaybackOptions(req.embySession!, param(req, 'itemId')));
}));
// Emby's own native intro/credits detection, when the library it's from has
// the feature enabled - powers the player's "Passer l'intro"/"Épisode
// suivant" buttons. See CatalogService.getPlaybackMarkers.
app.get('/api/catalog/item/:itemId/markers', requireSession, asyncRoute(async (req, res) => {
res.json(await CatalogService.getPlaybackMarkers(req.embySession!, param(req, 'itemId')));
}));
app.get('/api/catalog/libraries/:libraryId/items', requireSession, asyncRoute(async (req, res) => {
res.json({ items: await CatalogService.getLibraryItems(req.embySession!, param(req, 'libraryId')) });
}));

View file

@ -4,7 +4,7 @@ import type { Request, Response } from 'express';
import { EMBY_SERVER_URL } from '../config';
import { EmbyClient } from '../emby/EmbyClient';
import { Logger } from '../Logger';
import type { EmbyItem, EmbySession, ItemDto, LibraryDto, MediaType, PlaybackOptionsDto, SeasonDto } from '../types';
import type { EmbyItem, EmbySession, ItemDto, LibraryDto, MediaType, PlaybackMarkersDto, PlaybackOptionsDto, SeasonDto } from '../types';
// 'MusicVideo' is Emby's own item type for a musicvideos library's
// contents (distinct from the generic 'Video', which is home videos).
@ -272,6 +272,30 @@ class CatalogServiceClass {
};
}
/**
* Emby's own native intro/credits detection - see PlaybackMarkersDto's
* own comment for what MarkerType actually looks like and where that
* was confirmed. Read from the same Chapters field getRawItem/toItemDto
* never request (they have no use for chapters at all) - a dedicated
* fetch here rather than folding it into getItem, since a chapter list
* is only ever needed by the player deciding whether to show a skip
* button, nowhere a plain ItemDto is used.
*/
async getPlaybackMarkers(session: EmbySession, itemId: string): Promise<PlaybackMarkersDto> {
const data = await this.fetchJson<{ Chapters?: Array<{ StartPositionTicks: number; MarkerType?: string }> }>(
`/Users/${session.userId}/Items/${itemId}?Fields=Chapters`,
session,
'Impossible de récupérer les marqueurs de lecture.'
);
const chapters = data.Chapters || [];
const startOf = (markerType: string) => chapters.find((c) => c.MarkerType === markerType)?.StartPositionTicks ?? null;
return {
introStartTicks: startOf('IntroStart'),
introEndTicks: startOf('IntroEnd'),
creditsStartTicks: startOf('CreditsStart')
};
}
/**
* The negotiated MediaSource's id/container/streams - used by
* TranscodeService to build its own ffmpeg source URL and translate

View file

@ -219,6 +219,25 @@ export interface PlaybackOptionsDto {
subtitleTracks: PlaybackTrackOption[];
}
/**
* Emby's own native intro/credits detection (a library's own
* EnableMarkerDetection option, run by its "Detect Episode Intros"
* scheduled task - confirmed directly against a real server's
* /openapi.json: MarkerType is one of Chapter/IntroStart/IntroEnd/
* CreditsStart, mixed into the same Chapters array as the auto-generated
* per-5-minutes ones). Every field is nullable, independently - the task
* may not have reached this item yet, the library may not have the
* feature enabled at all (it's per-library, not global), or a real
* cold-open episode may genuinely have no IntroStart/IntroEnd. None of
* that is an error - see CatalogService.getPlaybackMarkers - a caller
* just checks each field for null before offering the matching skip
* button. */
export interface PlaybackMarkersDto {
introStartTicks: number | null;
introEndTicks: number | null;
creditsStartTicks: number | null;
}
// --- Party (video) -----------------------------------------------------------
/** One entry in a video party's fixed, pre-built queue (a movie/standalone

View file

@ -117,6 +117,42 @@ describe('CatalogService', () => {
assert.equal(options.subtitleTracks.find((t) => t.index === 4)?.isTextBased, true, 'ass is text too');
});
it('getPlaybackMarkers picks out Intro/Credits markers by MarkerType, ignoring the auto-generated Chapter ones', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
Chapters: [
{ StartPositionTicks: 0, Name: 'Chapitre 1', MarkerType: 'Chapter' },
{ StartPositionTicks: 50000000, MarkerType: 'IntroStart' },
{ StartPositionTicks: 900000000, MarkerType: 'IntroEnd' },
{ StartPositionTicks: 3000000000, Name: 'Chapitre 2', MarkerType: 'Chapter' },
{ StartPositionTicks: 20000000000, MarkerType: 'CreditsStart' }
]
}));
const markers = await CatalogService.getPlaybackMarkers(session, 'item1');
assert.equal(markers.introStartTicks, 50000000);
assert.equal(markers.introEndTicks, 900000000);
assert.equal(markers.creditsStartTicks, 20000000000);
});
it('getPlaybackMarkers returns nulls, not an error, when the item has no detected markers at all', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
Chapters: [{ StartPositionTicks: 0, Name: 'Chapitre 1', MarkerType: 'Chapter' }]
}));
const markers = await CatalogService.getPlaybackMarkers(session, 'item1');
assert.deepEqual(markers, { introStartTicks: null, introEndTicks: null, creditsStartTicks: null });
});
it('getPlaybackMarkers returns nulls when the item has no Chapters field at all', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({}));
const markers = await CatalogService.getPlaybackMarkers(session, 'item1');
assert.deepEqual(markers, { introStartTicks: null, introEndTicks: null, creditsStartTicks: null });
});
it('proxySubtitle asks Emby for this track as WebVTT, using the negotiated MediaSourceId', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src7' }] }));
let capturedTarget = '';