From 57bb297bff4aa900ae516ac8082d7a96235e5e6d Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 15 Sep 2026 23:16:38 +0200 Subject: [PATCH] Add skip intro / next episode at credits, via Emby's own detection 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 --- README.md | 4 ++ client/src/api/embyApi.ts | 6 ++- client/src/types.ts | 10 ++++ client/src/views/player/Player.css | 20 +++++++ client/src/views/player/Player.html | 11 ++++ client/src/views/player/Player.ts | 77 ++++++++++++++++++++++++++- docs/architecture.md | 76 ++++++++++++++++++++++++++ server/src/index.ts | 7 +++ server/src/services/CatalogService.ts | 26 ++++++++- server/src/types.ts | 19 +++++++ server/test/catalogService.test.ts | 36 +++++++++++++ 11 files changed, 288 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fd3f6aa..066afae 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/client/src/api/embyApi.ts b/client/src/api/embyApi.ts index 13d71c8..68774a7 100644 --- a/client/src/api/embyApi.ts +++ b/client/src/api/embyApi.ts @@ -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(path: string, options: RequestInit = {}): Promise { const res = await fetch(path, { @@ -116,6 +116,10 @@ export function getPlaybackOptions(itemId: string): Promise return apiFetch(`/api/catalog/item/${itemId}/playback-options`); } +export function getPlaybackMarkers(itemId: string): Promise { + 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