From b0a2d9446e104d7302ed09c29d50adc492792450 Mon Sep 17 00:00:00 2001 From: Andre <200930841+Andre-Bzk@users.noreply.github.com> Date: Sun, 12 Apr 2026 09:30:33 +0200 Subject: [PATCH] Fix versuch: Seeking und Resume von VODs ohne Kapitel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit streamlink.ts — 3 neue mpv-Argumente für VODs - --demuxer-max-bytes=150M / --demuxer-max-back-bytes=75M — größerer Buffer, verhindert Flush bei Seeks - --hr-seek=no — Keyframe-only Seeks, zuverlässiger für HLS mpvController.ts — 2 Änderungen - Relative Seeks jetzt relative+keyframes (statt relative) — konsistent mit seekAbsolute - Neue pollTimePos()-Methode als Fallback wenn observe_property nicht feuert playbackService.ts — Kerndfix - playback-restart statt file-loaded für den initialen Resume-Seek — wartet bis der erste Frame dekodiert ist, statt nur auf das Manifest - Seek-Verify nach 5s — prüft ob der Seek geklappt hat, Retry falls nicht - Poll-Fallback nach 10s — startet Polling falls time-pos nie über observe_property kommt - Cleanup in stop() für die neuen Timer/Callbacks --- src/main/playback/mpvController.ts | 12 +++- src/main/playback/playbackService.ts | 97 +++++++++++++++++++++++++--- src/main/playback/streamlink.ts | 15 ++++- 3 files changed, 112 insertions(+), 12 deletions(-) diff --git a/src/main/playback/mpvController.ts b/src/main/playback/mpvController.ts index 8548128..a924158 100644 --- a/src/main/playback/mpvController.ts +++ b/src/main/playback/mpvController.ts @@ -62,6 +62,14 @@ export class MpvController { }) } + pollTimePos(intervalMs: number, cb: (seconds: number) => void): () => void { + const id = setInterval(async () => { + const pos = await this.getTimePos() + if (pos !== null) cb(pos) + }, intervalMs) + return () => clearInterval(id) + } + onEvent(eventName: string, cb: () => void): void { let observers = this.eventObservers.get(eventName) if (!observers) { @@ -73,13 +81,13 @@ export class MpvController { seek(seconds: number): void { if (this.socket?.writable) { - this._send({ command: ['seek', seconds, 'relative'] }) + this._send({ command: ['seek', seconds, 'relative+keyframes'] }) } } seekAbsolute(seconds: number): void { if (this.socket?.writable) { - this._send({ command: ['seek', seconds, 'absolute'] }) + this._send({ command: ['seek', seconds, 'absolute+keyframes'] }) } } diff --git a/src/main/playback/playbackService.ts b/src/main/playback/playbackService.ts index f26510f..79d576c 100644 --- a/src/main/playback/playbackService.ts +++ b/src/main/playback/playbackService.ts @@ -10,8 +10,15 @@ const POSITION_WRITE_INTERVAL_MS = 5_000 interface CurrentPlayback { process: ChildProcess mpv: MpvController + pendingRelativeSeekSeconds: number + pendingAbsoluteSeekSeconds: number | null + seekFlushTimer: NodeJS.Timeout | null + seekVerifyTimer?: NodeJS.Timeout | null + stopPolling?: (() => void) | null } +const SEEK_FLUSH_DELAY_MS = 180 + export class PlaybackService extends EventEmitter { private current: CurrentPlayback | null = null private readonly ipcPath = getMpvIpcPath() @@ -56,7 +63,13 @@ export class PlaybackService extends EventEmitter { // Für Live: mpv wird von streamlink gestartet, kein direkter IPC nötig const mpv = new MpvController(this.ipcPath) - this.current = { process: proc, mpv } + this.current = { + process: proc, + mpv, + pendingRelativeSeekSeconds: 0, + pendingAbsoluteSeekSeconds: null, + seekFlushTimer: null + } this.emit('playback-event', { kind: 'started', channelLogin } satisfies PlaybackEvent) } @@ -90,7 +103,7 @@ export class PlaybackService extends EventEmitter { } const effectiveStart = startSeconds !== undefined ? startSeconds : resumePos - const proc = spawnMpv(hlsUrl, { ipcPath: this.ipcPath }) + const proc = spawnMpv(hlsUrl, { ipcPath: this.ipcPath, isVod: true }) let mpvStderrBuf = '' proc.stdout?.on('data', (d: Buffer) => console.log('[mpv]', d.toString().trim())) @@ -122,7 +135,13 @@ export class PlaybackService extends EventEmitter { }) const mpv = new MpvController(this.ipcPath) - this.current = { process: proc, mpv } + this.current = { + process: proc, + mpv, + pendingRelativeSeekSeconds: 0, + pendingAbsoluteSeekSeconds: null, + seekFlushTimer: null + } this.emit('playback-event', { kind: 'started' } satisfies PlaybackEvent) // IPC verbinden, initialen Resume-Seek erst nach `file-loaded` setzen und Position tracken. @@ -130,14 +149,29 @@ export class PlaybackService extends EventEmitter { // als auf `mpv --start=`. mpv.connect().then(() => { let initialSeekDone = effectiveStart <= 0 - mpv.onEvent('file-loaded', () => { + + // playback-restart feuert nach dem ersten dekodierten Frame — + // sicherer als file-loaded, weil der HLS-Demuxer dann bereit ist. + mpv.onEvent('playback-restart', () => { if (initialSeekDone) return initialSeekDone = true mpv.seekAbsolute(effectiveStart) + + // Nach 5s prüfen ob der Seek geklappt hat, sonst Retry + const verifyTimer = setTimeout(async () => { + if (this.current) this.current.seekVerifyTimer = null + const pos = await mpv.getTimePos() + if (pos !== null && effectiveStart > 30 && Math.abs(pos - effectiveStart) > 30) { + console.warn(`[mpv] Seek verify: at ${pos}, expected ~${effectiveStart}. Retry.`) + mpv.seekAbsolute(effectiveStart) + } + }, 5000) + if (this.current) this.current.seekVerifyTimer = verifyTimer }) + // Position-Tracking let lastWrite = 0 - mpv.observeTimePos((seconds) => { + const writePosition = (seconds: number): void => { const now = Date.now() if (now - lastWrite < POSITION_WRITE_INTERVAL_MS) return lastWrite = now @@ -146,14 +180,28 @@ export class PlaybackService extends EventEmitter { if (durationSeconds > 0 && pos / durationSeconds > 0.95) { history.markCompleted(vodId) } - }) + } + + mpv.observeTimePos(writePosition) + + // Fallback: Falls observe_property nie feuert, nach 10s auf Polling wechseln + setTimeout(() => { + if (lastWrite === 0 && this.current?.mpv === mpv) { + console.warn('[mpv] observe_property time-pos nicht aktiv, starte Poll-Fallback') + const stop = mpv.pollTimePos(POSITION_WRITE_INTERVAL_MS, writePosition) + if (this.current) this.current.stopPolling = stop + } + }, 10_000) }).catch(() => { console.warn('[mpv] IPC nicht verfügbar — Resume-Tracking deaktiviert') }) } seek(seconds: number): void { - this.current?.mpv.seek(seconds) + if (!this.current) return + this.current.pendingRelativeSeekSeconds += seconds + this.current.pendingAbsoluteSeekSeconds = null + this.scheduleSeekFlush() } togglePause(): void { @@ -169,7 +217,10 @@ export class PlaybackService extends EventEmitter { } seekTo(seconds: number): void { - this.current?.mpv.seekAbsolute(seconds) + if (!this.current) return + this.current.pendingRelativeSeekSeconds = 0 + this.current.pendingAbsoluteSeekSeconds = seconds + this.scheduleSeekFlush() } getCurrentPosition(): Promise { @@ -178,9 +229,12 @@ export class PlaybackService extends EventEmitter { async stop(): Promise { if (!this.current) return - const { process: proc, mpv } = this.current + const { process: proc, mpv, seekFlushTimer, seekVerifyTimer, stopPolling } = this.current this.current = null + if (seekFlushTimer) clearTimeout(seekFlushTimer) + if (seekVerifyTimer) clearTimeout(seekVerifyTimer) + if (stopPolling) stopPolling() try { mpv.quit() } catch { /* ignore */ } mpv.disconnect() @@ -192,4 +246,29 @@ export class PlaybackService extends EventEmitter { this.stop().catch(() => {}) } + private scheduleSeekFlush(): void { + if (!this.current) return + if (this.current.seekFlushTimer) { + clearTimeout(this.current.seekFlushTimer) + } + + this.current.seekFlushTimer = setTimeout(() => { + if (!this.current) return + this.current.seekFlushTimer = null + + const absoluteSeek = this.current.pendingAbsoluteSeekSeconds + if (absoluteSeek !== null) { + this.current.pendingAbsoluteSeekSeconds = null + this.current.mpv.seekAbsolute(absoluteSeek) + return + } + + const relativeSeek = this.current.pendingRelativeSeekSeconds + this.current.pendingRelativeSeekSeconds = 0 + if (relativeSeek !== 0) { + this.current.mpv.seek(relativeSeek) + } + }, SEEK_FLUSH_DELAY_MS) + } + } diff --git a/src/main/playback/streamlink.ts b/src/main/playback/streamlink.ts index 9bde15b..3915084 100644 --- a/src/main/playback/streamlink.ts +++ b/src/main/playback/streamlink.ts @@ -70,10 +70,11 @@ export function getStreamUrl(url: string, quality: string): Promise { export interface MpvOptions { ipcPath: string + isVod?: boolean } /** Startet mpv direkt mit einer URL (HLS oder lokal). IPC-Socket wird gesetzt. */ -export function spawnMpv(url: string, { ipcPath }: MpvOptions): ChildProcess { +export function spawnMpv(url: string, { ipcPath, isVod = false }: MpvOptions): ChildProcess { const args = [ url, `--input-ipc-server=${ipcPath}`, @@ -82,5 +83,17 @@ export function spawnMpv(url: string, { ipcPath }: MpvOptions): ChildProcess { '--hwdec=auto', '--force-window=yes' ] + if (isVod) { + args.push( + '--cache=yes', + '--demuxer-seekable-cache=yes', + '--force-seekable=yes', + '--cache-pause-initial=yes', + '--cache-pause-wait=2', + '--demuxer-max-bytes=150M', + '--demuxer-max-back-bytes=75M', + '--hr-seek=no' + ) + } return spawn(MPV_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] }) }