mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
Phase 3 abgeschlossen
Fix: Streamlink auth header entfernt, mpv-Pfad explizit aufgelöst Livestream wird kann jetzt abgespielt werden
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(() => {})
|
||||
}
|
||||
}
|
||||
@@ -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'] })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user