2 Commits
Author SHA1 Message Date
Andre 276d27ca89 Update auf mpv 0.41.0 mit lua support 2026-04-12 11:48:08 +02:00
Andre b0a2d9446e 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
2026-04-12 09:30:33 +02:00
6 changed files with 347 additions and 22 deletions
+8 -4
View File
@@ -125,14 +125,14 @@ echo ""
echo "=== [5/6] SHA256-Prüfsummen für Manifest-Sources ==="
echo ""
MPV_URL="https://github.com/mpv-player/mpv/archive/refs/tags/v0.38.0.tar.gz"
MPV_URL="https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz"
SL_URL="https://github.com/streamlink/streamlink/archive/refs/tags/6.11.0.tar.gz"
MANIFEST="flatpak/tv.twitch4steamdeck.App.yml"
MPV_ARCHIVE="/tmp/mpv-0.38.0.tar.gz"
MPV_ARCHIVE="/tmp/mpv-0.41.0.tar.gz"
SL_ARCHIVE="/tmp/streamlink-6.11.0.tar.gz"
echo "Lade mpv 0.38.0 ..."
echo "Lade mpv 0.41.0 ..."
curl -L --progress-bar -o "$MPV_ARCHIVE" "$MPV_URL"
MPV_SHA=$(sha256sum "$MPV_ARCHIVE" | awk '{print $1}')
echo " mpv sha256: $MPV_SHA"
@@ -143,7 +143,7 @@ SL_SHA=$(sha256sum "$SL_ARCHIVE" | awk '{print $1}')
echo " streamlink sha256: $SL_SHA"
# Platzhalter im Manifest ersetzen
sed -i "s/FILL_IN_sha256sum_of_mpv_0\.38\.0_tarball/$MPV_SHA/" "$MANIFEST"
sed -i "s/FILL_IN_sha256sum_of_mpv_0\.41\.0_tarball/$MPV_SHA/" "$MANIFEST"
sed -i "s/FILL_IN_sha256sum_of_streamlink_tarball/$SL_SHA/" "$MANIFEST"
echo " ✓ Manifest aktualisiert"
@@ -171,3 +171,7 @@ echo " twitch4steamdeck.flatpak tv.twitch4steamdeck.App"
echo " # Dann twitch4steamdeck.flatpak per USB/SSH auf Deck kopieren"
echo " # Dort: flatpak install --user twitch4steamdeck.flatpak"
echo "======================================================"
echo "=== flatpak build-bundle ==="
flatpak build-bundle ~/.local/share/flatpak/repo twitch4steamdeck.flatpak tv.twitch4steamdeck.App
echo "=== Übertrage auf Steamdeck ==="
scp twitch4steamdeck.flatpak steamdeck:/home/deck/
+35 -4
View File
@@ -74,8 +74,39 @@ modules:
sha256: b964df9e5a066daa5e08d2dc82692c57ca27d00b8cc257e8e960c9f1cf26231b
# ─────────────────────────────────────────────────────────────────────────────
# 1d) mpv — Media-Player für Live-Streams und VODs
# 1d) Lua 5.2 — Laufzeit + Dev-Dateien für mpv-Scripts/OSC/ytdl-Hooks
# ─────────────────────────────────────────────────────────────────────────────
- name: lua
buildsystem: simple
build-commands:
- make clean
- make linux MYCFLAGS="$CFLAGS -fPIC" MYLDFLAGS="$LDFLAGS"
- make INSTALL_TOP=/app INSTALL_INC=/app/include/lua5.2 INSTALL_LIB=/app/lib INSTALL_MAN=/app/share/man install
- mkdir -p /app/lib/pkgconfig
- |
cat > /app/lib/pkgconfig/lua52.pc <<'EOF'
prefix=/app
exec_prefix=${prefix}
libdir=${exec_prefix}/lib
includedir=${prefix}/include/lua5.2
Name: Lua
Description: Embedded scripting language
Version: 5.2.4
Libs: -L${libdir} -llua -lm -ldl
Cflags: -I${includedir}
EOF
- cp /app/lib/pkgconfig/lua52.pc /app/lib/pkgconfig/lua5.2.pc
- cp /app/lib/pkgconfig/lua52.pc /app/lib/pkgconfig/lua-5.2.pc
sources:
- type: archive
url: https://www.lua.org/ftp/lua-5.2.4.tar.gz
sha256: b9e2e4aad6789b3b63a056d442f7b39f0ecfca3ae0f1fc0ae4e9614401b69f4b
# ─────────────────────────────────────────────────────────────────────────────
# 1e) mpv — Media-Player für Live-Streams und VODs
# ffmpeg, libass, libplacebo, libva (VAAPI), X11 verfügbar.
# Lua aktiviert, damit das Bundle näher am Desktop-mpv liegt.
# ─────────────────────────────────────────────────────────────────────────────
- name: mpv
buildsystem: meson
@@ -85,15 +116,15 @@ modules:
- -Dx11=enabled
- -Degl-x11=enabled
- -Dalsa=disabled
- -Dlua=disabled
- -Dlua=enabled
- -Djavascript=disabled
- -Drubberband=disabled
- -Dvapoursynth=disabled
- -Dbuild-date=false
sources:
- type: archive
url: https://github.com/mpv-player/mpv/archive/refs/tags/v0.38.0.tar.gz
sha256: 86d9ef40b6058732f67b46d0bbda24a074fae860b3eaae05bab3145041303066
url: https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz
sha256: ee21092a5ee427353392360929dc64645c54479aefdb5babc5cfbb5fad626209
# ─────────────────────────────────────────────────────────────────────────────
# 2) streamlink (Python) — liefert HLS-URLs aus Twitch-Streams
+206
View File
@@ -0,0 +1,206 @@
import { randomUUID } from 'node:crypto'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
interface ProxySession {
sourceUrl: string
}
function isAbsoluteUrl(value: string): boolean {
return /^https?:\/\//i.test(value)
}
function resolveUrl(baseUrl: string, value: string): string {
if (isAbsoluteUrl(value)) return value
return new URL(value, baseUrl).toString()
}
function rewriteTagUris(line: string, baseUrl: string, sessionId: string): string {
return line.replace(/URI="([^"]+)"/g, (_match, uri: string) => {
const absoluteUrl = resolveUrl(baseUrl, uri)
const proxied = `/segment/${sessionId}?url=${encodeURIComponent(absoluteUrl)}`
return `URI="${proxied}"`
})
}
async function readResponseText(res: Response): Promise<string> {
if (!res.ok) {
throw new Error(`HTTP ${res.status} ${res.statusText}`)
}
return await res.text()
}
export class HlsProxyServer {
private server = createServer((req, res) => {
void this.handleRequest(req, res)
})
private readonly sessions = new Map<string, ProxySession>()
private port: number | null = null
private listenPromise: Promise<void> | null = null
private async ensureListening(): Promise<void> {
if (this.port !== null) return
if (!this.listenPromise) {
this.listenPromise = new Promise((resolve, reject) => {
this.server.once('error', reject)
this.server.listen(0, '127.0.0.1', () => {
this.server.off('error', reject)
const address = this.server.address()
if (!address || typeof address === 'string') {
reject(new Error('HLS-Proxy konnte keinen Port binden'))
return
}
this.port = address.port
resolve()
})
})
}
await this.listenPromise
}
async createSession(sourceUrl: string): Promise<string> {
await this.ensureListening()
const sessionId = randomUUID()
this.sessions.set(sessionId, { sourceUrl })
return `http://127.0.0.1:${this.port}/playlist/${sessionId}.m3u8`
}
removeSession(playlistUrl: string | null | undefined): void {
if (!playlistUrl) return
const match = /\/playlist\/([^/.]+)\.m3u8$/i.exec(playlistUrl)
if (!match) return
this.sessions.delete(match[1])
}
close(): void {
this.sessions.clear()
if (this.port !== null) {
this.server.close()
}
this.port = null
this.listenPromise = null
}
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
try {
const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1')
if (requestUrl.pathname.startsWith('/playlist/')) {
await this.handlePlaylistRequest(requestUrl, res)
return
}
if (requestUrl.pathname.startsWith('/segment/')) {
await this.handlePassthroughRequest(requestUrl, req, res)
return
}
res.writeHead(404).end('Not found')
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
res.writeHead(500).end(message)
}
}
private async handlePlaylistRequest(requestUrl: URL, res: ServerResponse): Promise<void> {
const match = /\/playlist\/([^/.]+)\.m3u8$/i.exec(requestUrl.pathname)
if (!match) {
res.writeHead(404).end('Unknown playlist')
return
}
const session = this.sessions.get(match[1])
if (!session) {
res.writeHead(404).end('Session expired')
return
}
const upstream = await fetch(session.sourceUrl, {
headers: { Accept: 'application/vnd.apple.mpegurl, application/x-mpegURL, text/plain' }
})
const body = await readResponseText(upstream)
const rewritten = this.rewritePlaylist(body, session.sourceUrl, match[1])
res.writeHead(200, {
'Content-Type': 'application/vnd.apple.mpegurl',
'Cache-Control': 'no-store'
})
res.end(rewritten)
}
private rewritePlaylist(body: string, sourceUrl: string, sessionId: string): string {
const lines = body.split(/\r?\n/)
const output: string[] = []
let insertedPlaylistType = false
for (const line of lines) {
const trimmed = line.trim()
if (trimmed.length === 0) {
output.push('')
continue
}
if (trimmed.startsWith('#EXT-X-PLAYLIST-TYPE:')) {
insertedPlaylistType = true
output.push(line)
continue
}
if (trimmed.startsWith('#')) {
output.push(rewriteTagUris(line, sourceUrl, sessionId))
continue
}
const absoluteUrl = resolveUrl(sourceUrl, trimmed)
output.push(`/segment/${sessionId}?url=${encodeURIComponent(absoluteUrl)}`)
}
if (!insertedPlaylistType) {
const headerIndex = output.findIndex((line) => line.startsWith('#EXTM3U'))
if (headerIndex >= 0) {
output.splice(headerIndex + 1, 0, '#EXT-X-PLAYLIST-TYPE:VOD')
}
}
return output.join('\n')
}
private async handlePassthroughRequest(
requestUrl: URL,
req: IncomingMessage,
res: ServerResponse
): Promise<void> {
const upstreamUrl = requestUrl.searchParams.get('url')
if (!upstreamUrl || !isAbsoluteUrl(upstreamUrl)) {
res.writeHead(400).end('Missing upstream URL')
return
}
const headers = new Headers()
const range = req.headers.range
if (typeof range === 'string' && range.length > 0) {
headers.set('Range', range)
}
const userAgent = req.headers['user-agent']
if (typeof userAgent === 'string' && userAgent.length > 0) {
headers.set('User-Agent', userAgent)
}
const upstream = await fetch(upstreamUrl, { headers })
const responseHeaders: Record<string, string> = {
'Cache-Control': 'no-store'
}
for (const [key, value] of upstream.headers.entries()) {
if (key.toLowerCase() === 'transfer-encoding') continue
responseHeaders[key] = value
}
res.writeHead(upstream.status, responseHeaders)
if (!upstream.body) {
res.end()
return
}
for await (const chunk of upstream.body as AsyncIterable<Uint8Array>) {
res.write(chunk)
}
res.end()
}
}
+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 {
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'] })
}
}
+87 -8
View File
@@ -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)
}
@@ -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=<sek>`.
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<number | null> {
@@ -178,9 +229,12 @@ export class PlaybackService extends EventEmitter {
async stop(): Promise<void> {
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)
}
}
+1 -4
View File
@@ -77,10 +77,7 @@ export function spawnMpv(url: string, { ipcPath }: MpvOptions): ChildProcess {
const args = [
url,
`--input-ipc-server=${ipcPath}`,
'--fullscreen',
'--vo=gpu',
'--hwdec=auto',
'--force-window=yes'
'--fullscreen'
]
return spawn(MPV_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] })
}