Fix versuch: Seeking und Resume von VODs ohne Kapitel

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
This commit is contained in:
Andre
2026-04-12 09:30:33 +02:00
parent 77e74a5994
commit b0a2d9446e
3 changed files with 112 additions and 12 deletions
+10 -2
View File
@@ -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 { onEvent(eventName: string, cb: () => void): void {
let observers = this.eventObservers.get(eventName) let observers = this.eventObservers.get(eventName)
if (!observers) { if (!observers) {
@@ -73,13 +81,13 @@ export class MpvController {
seek(seconds: number): void { seek(seconds: number): void {
if (this.socket?.writable) { if (this.socket?.writable) {
this._send({ command: ['seek', seconds, 'relative'] }) this._send({ command: ['seek', seconds, 'relative+keyframes'] })
} }
} }
seekAbsolute(seconds: number): void { seekAbsolute(seconds: number): void {
if (this.socket?.writable) { if (this.socket?.writable) {
this._send({ command: ['seek', seconds, 'absolute'] }) this._send({ command: ['seek', seconds, 'absolute+keyframes'] })
} }
} }
+88 -9
View File
@@ -10,8 +10,15 @@ const POSITION_WRITE_INTERVAL_MS = 5_000
interface CurrentPlayback { interface CurrentPlayback {
process: ChildProcess process: ChildProcess
mpv: MpvController 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 { export class PlaybackService extends EventEmitter {
private current: CurrentPlayback | null = null private current: CurrentPlayback | null = null
private readonly ipcPath = getMpvIpcPath() 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 // Für Live: mpv wird von streamlink gestartet, kein direkter IPC nötig
const mpv = new MpvController(this.ipcPath) 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) 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 effectiveStart = startSeconds !== undefined ? startSeconds : resumePos
const proc = spawnMpv(hlsUrl, { ipcPath: this.ipcPath }) const proc = spawnMpv(hlsUrl, { ipcPath: this.ipcPath, isVod: true })
let mpvStderrBuf = '' let mpvStderrBuf = ''
proc.stdout?.on('data', (d: Buffer) => console.log('[mpv]', d.toString().trim())) 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) 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) this.emit('playback-event', { kind: 'started' } satisfies PlaybackEvent)
// IPC verbinden, initialen Resume-Seek erst nach `file-loaded` setzen und Position tracken. // 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=<sek>`. // als auf `mpv --start=<sek>`.
mpv.connect().then(() => { mpv.connect().then(() => {
let initialSeekDone = effectiveStart <= 0 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 if (initialSeekDone) return
initialSeekDone = true initialSeekDone = true
mpv.seekAbsolute(effectiveStart) 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 let lastWrite = 0
mpv.observeTimePos((seconds) => { const writePosition = (seconds: number): void => {
const now = Date.now() const now = Date.now()
if (now - lastWrite < POSITION_WRITE_INTERVAL_MS) return if (now - lastWrite < POSITION_WRITE_INTERVAL_MS) return
lastWrite = now lastWrite = now
@@ -146,14 +180,28 @@ export class PlaybackService extends EventEmitter {
if (durationSeconds > 0 && pos / durationSeconds > 0.95) { if (durationSeconds > 0 && pos / durationSeconds > 0.95) {
history.markCompleted(vodId) 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(() => { }).catch(() => {
console.warn('[mpv] IPC nicht verfügbar — Resume-Tracking deaktiviert') console.warn('[mpv] IPC nicht verfügbar — Resume-Tracking deaktiviert')
}) })
} }
seek(seconds: number): void { 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 { togglePause(): void {
@@ -169,7 +217,10 @@ export class PlaybackService extends EventEmitter {
} }
seekTo(seconds: number): void { 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<number | null> { getCurrentPosition(): Promise<number | null> {
@@ -178,9 +229,12 @@ export class PlaybackService extends EventEmitter {
async stop(): Promise<void> { async stop(): Promise<void> {
if (!this.current) return if (!this.current) return
const { process: proc, mpv } = this.current const { process: proc, mpv, seekFlushTimer, seekVerifyTimer, stopPolling } = this.current
this.current = null this.current = null
if (seekFlushTimer) clearTimeout(seekFlushTimer)
if (seekVerifyTimer) clearTimeout(seekVerifyTimer)
if (stopPolling) stopPolling()
try { mpv.quit() } catch { /* ignore */ } try { mpv.quit() } catch { /* ignore */ }
mpv.disconnect() mpv.disconnect()
@@ -192,4 +246,29 @@ export class PlaybackService extends EventEmitter {
this.stop().catch(() => {}) 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)
}
} }
+14 -1
View File
@@ -70,10 +70,11 @@ export function getStreamUrl(url: string, quality: string): Promise<string> {
export interface MpvOptions { export interface MpvOptions {
ipcPath: string ipcPath: string
isVod?: boolean
} }
/** Startet mpv direkt mit einer URL (HLS oder lokal). IPC-Socket wird gesetzt. */ /** 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 = [ const args = [
url, url,
`--input-ipc-server=${ipcPath}`, `--input-ipc-server=${ipcPath}`,
@@ -82,5 +83,17 @@ export function spawnMpv(url: string, { ipcPath }: MpvOptions): ChildProcess {
'--hwdec=auto', '--hwdec=auto',
'--force-window=yes' '--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'] }) return spawn(MPV_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] })
} }