Phase 3 abgeschlossen

Fix: Streamlink auth header entfernt, mpv-Pfad explizit aufgelöst
Livestream wird kann jetzt abgespielt werden
This commit is contained in:
Andre
2026-04-08 17:44:48 +02:00
parent 60d4b4ace7
commit 3e8e5c2078
15 changed files with 773 additions and 182 deletions
+61
View File
@@ -0,0 +1,61 @@
import * as net from 'node:net'
import * as os from 'node:os'
import * as path from 'node:path'
export function getMpvIpcPath(): string {
if (process.platform === 'win32') {
return '\\\\.\\pipe\\twitch4sd-mpv'
}
return path.join(os.tmpdir(), 'twitch4sd-mpv.sock')
}
export class MpvController {
private socket: net.Socket | null = null
constructor(private ipcPath: string) {}
async connect(retries = 12, delayMs = 250): Promise<void> {
for (let i = 0; i < retries; i++) {
try {
await this._tryConnect()
return
} catch {
if (i < retries - 1) {
await new Promise<void>((r) => setTimeout(r, delayMs))
}
}
}
console.warn('[mpv] IPC connect failed after', retries, 'retries — continuing without IPC')
}
private _tryConnect(): Promise<void> {
return new Promise((resolve, reject) => {
const sock = net.createConnection({ path: this.ipcPath })
sock.once('connect', () => {
this.socket = sock
sock.on('error', () => {
/* ignore socket errors after connect */
})
resolve()
})
sock.once('error', reject)
})
}
quit(): void {
if (this.socket?.writable) {
try {
this.socket.write(JSON.stringify({ command: ['quit'] }) + '\n')
} catch {
/* ignore */
}
}
this.socket?.destroy()
this.socket = null
}
disconnect(): void {
this.socket?.destroy()
this.socket = null
}
}
+90
View File
@@ -0,0 +1,90 @@
import { EventEmitter } from 'node:events'
import type { ChildProcess } from 'node:child_process'
import { spawnStreamlink } from './streamlink'
import { getMpvIpcPath, MpvController } from './mpvController'
import type { PlaybackEvent } from './types'
interface CurrentPlayback {
channelLogin: string
streamlink: ChildProcess
mpv: MpvController
}
export class PlaybackService extends EventEmitter {
private current: CurrentPlayback | null = null
private readonly ipcPath = getMpvIpcPath()
constructor() {
super()
}
async startLive(channelLogin: string, quality = 'best'): Promise<void> {
if (this.current) {
await this.stop()
}
const sl = spawnStreamlink({
channelLogin,
quality,
mpvIpcPath: this.ipcPath
})
sl.stdout?.on('data', (d: Buffer) => {
console.log('[streamlink stdout]', d.toString().trim())
})
sl.stderr?.on('data', (d: Buffer) => {
console.log('[streamlink stderr]', d.toString().trim())
})
sl.on('error', (e: Error) => {
console.error('[streamlink] spawn error:', e)
if (this.current?.streamlink === sl) this.current = null
this.emit('playback-event', {
kind: 'error',
message: `streamlink Fehler: ${e.message}`
} satisfies PlaybackEvent)
})
sl.on('exit', (code) => {
if (this.current?.streamlink === sl) this.current = null
if (code !== 0 && code !== null) {
console.warn('[streamlink] exit code', code)
}
this.emit('playback-event', { kind: 'stopped' } satisfies PlaybackEvent)
})
const mpv = new MpvController(this.ipcPath)
this.current = { channelLogin, streamlink: sl, mpv }
this.emit('playback-event', {
kind: 'started',
channelLogin
} satisfies PlaybackEvent)
// IPC-Verbindung zu mpv im Hintergrund (nicht blockierend, nicht kritisch)
mpv.connect().catch(() => {})
}
async stop(): Promise<void> {
if (!this.current) return
const { streamlink: sl, mpv } = this.current
this.current = null
try {
mpv.quit()
} catch {
/* ignore */
}
mpv.disconnect()
await new Promise<void>((r) => setTimeout(r, 400))
if (!sl.killed) {
sl.kill()
}
}
stopAll(): void {
this.stop().catch(() => {})
}
}
+60
View File
@@ -0,0 +1,60 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
function resolveStreamlinkBin(): string {
if (process.platform === 'win32') {
const candidates = [
join(process.env.LOCALAPPDATA ?? '', 'Programs', 'Streamlink', 'bin', 'streamlink.exe'),
join(process.env.ProgramFiles ?? '', 'Streamlink', 'bin', 'streamlink.exe'),
join(process.env['ProgramFiles(x86)'] ?? '', 'Streamlink', 'bin', 'streamlink.exe')
]
for (const p of candidates) {
if (existsSync(p)) return p
}
}
return 'streamlink'
}
function resolveMpvBin(): string {
if (process.platform === 'win32') {
const candidates = [
join(process.env.ProgramFiles ?? '', 'MPV Player', 'mpv.exe'),
join(process.env.LOCALAPPDATA ?? '', 'Programs', 'mpv', 'mpv.exe'),
join(process.env.LOCALAPPDATA ?? '', 'Programs', 'mpv-player', 'mpv.exe')
]
for (const p of candidates) {
if (existsSync(p)) return p
}
}
return 'mpv'
}
const STREAMLINK_BIN = resolveStreamlinkBin()
const MPV_BIN = resolveMpvBin()
export interface StreamlinkOptions {
channelLogin: string
quality: string
mpvIpcPath: string
}
export function spawnStreamlink({
channelLogin,
quality,
mpvIpcPath
}: StreamlinkOptions): ChildProcess {
const hwdec = process.platform === 'win32' ? 'auto' : 'vaapi'
const playerArgs = `--input-ipc-server=${mpvIpcPath} --fullscreen --hwdec=${hwdec}`
const args = [
'--player',
MPV_BIN,
'--player-args',
playerArgs,
`twitch.tv/${channelLogin}`,
quality
]
return spawn(STREAMLINK_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] })
}
+15
View File
@@ -0,0 +1,15 @@
export type Quality =
| 'best'
| '1080p60'
| '720p60'
| '480p'
| '360p'
| '160p'
| 'audio_only'
| 'worst'
export interface PlaybackEvent {
kind: 'started' | 'stopped' | 'error'
channelLogin?: string
message?: string
}