diff --git a/flatpak/build-flatpak.sh b/flatpak/build-flatpak.sh index fbec135..b22c6d3 100644 --- a/flatpak/build-flatpak.sh +++ b/flatpak/build-flatpak.sh @@ -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/ \ No newline at end of file diff --git a/flatpak/tv.twitch4steamdeck.App.yml b/flatpak/tv.twitch4steamdeck.App.yml index bb6e22d..a66913e 100644 --- a/flatpak/tv.twitch4steamdeck.App.yml +++ b/flatpak/tv.twitch4steamdeck.App.yml @@ -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 diff --git a/src/main/playback/hlsProxy.ts b/src/main/playback/hlsProxy.ts new file mode 100644 index 0000000..f535098 --- /dev/null +++ b/src/main/playback/hlsProxy.ts @@ -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 { + 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() + private port: number | null = null + private listenPromise: Promise | null = null + + private async ensureListening(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 = { + '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) { + res.write(chunk) + } + res.end() + } +} diff --git a/src/main/playback/playbackService.ts b/src/main/playback/playbackService.ts index 79d576c..383a0bb 100644 --- a/src/main/playback/playbackService.ts +++ b/src/main/playback/playbackService.ts @@ -103,7 +103,7 @@ export class PlaybackService extends EventEmitter { } const effectiveStart = startSeconds !== undefined ? startSeconds : resumePos - const proc = spawnMpv(hlsUrl, { ipcPath: this.ipcPath, isVod: true }) + const proc = spawnMpv(hlsUrl, { ipcPath: this.ipcPath }) let mpvStderrBuf = '' proc.stdout?.on('data', (d: Buffer) => console.log('[mpv]', d.toString().trim())) diff --git a/src/main/playback/streamlink.ts b/src/main/playback/streamlink.ts index 3915084..4182603 100644 --- a/src/main/playback/streamlink.ts +++ b/src/main/playback/streamlink.ts @@ -70,30 +70,14 @@ 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, isVod = false }: MpvOptions): ChildProcess { +export function spawnMpv(url: string, { ipcPath }: MpvOptions): ChildProcess { const args = [ url, `--input-ipc-server=${ipcPath}`, - '--fullscreen', - '--vo=gpu', - '--hwdec=auto', - '--force-window=yes' + '--fullscreen' ] - 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'] }) }