mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 09:33:09 +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:
+8
-1
@@ -2,6 +2,7 @@ import { app, BrowserWindow, shell } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { AuthService } from './auth/authService'
|
||||
import { HelixClient } from './twitch/helixClient'
|
||||
import { PlaybackService } from './playback/playbackService'
|
||||
import { registerIpcHandlers } from './ipc/handlers'
|
||||
|
||||
const isDev = !app.isPackaged
|
||||
@@ -44,7 +45,13 @@ app.whenReady().then(async () => {
|
||||
const auth = new AuthService(clientId)
|
||||
await auth.init()
|
||||
const helix = new HelixClient(clientId, () => auth.getValidAccessToken())
|
||||
registerIpcHandlers(auth, helix)
|
||||
const playback = new PlaybackService()
|
||||
|
||||
registerIpcHandlers(auth, helix, playback)
|
||||
|
||||
app.on('before-quit', () => {
|
||||
playback.stopAll()
|
||||
})
|
||||
|
||||
if (!clientId) {
|
||||
console.warn(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import type { AuthEvent, AuthService } from '../auth/authService'
|
||||
import type { HelixClient } from '../twitch/helixClient'
|
||||
import type { PlaybackService } from '../playback/playbackService'
|
||||
import type { PlaybackEvent } from '../playback/types'
|
||||
|
||||
export const IPC = {
|
||||
authStatus: 'auth:get-status',
|
||||
@@ -11,10 +13,19 @@ export const IPC = {
|
||||
/** main → renderer */
|
||||
authEvent: 'auth:event',
|
||||
|
||||
twitchGetFollowed: 'twitch:get-followed'
|
||||
twitchGetFollowed: 'twitch:get-followed',
|
||||
|
||||
playbackStartLive: 'playback:start-live',
|
||||
playbackStop: 'playback:stop',
|
||||
/** main → renderer */
|
||||
playbackEvent: 'playback:event'
|
||||
} as const
|
||||
|
||||
export function registerIpcHandlers(auth: AuthService, helix: HelixClient): void {
|
||||
export function registerIpcHandlers(
|
||||
auth: AuthService,
|
||||
helix: HelixClient,
|
||||
playback: PlaybackService
|
||||
): void {
|
||||
ipcMain.handle(IPC.authStatus, () => auth.getStatus())
|
||||
ipcMain.handle(IPC.authConfigured, () => auth.isConfigured())
|
||||
ipcMain.handle(IPC.authStart, () => auth.startDeviceFlow())
|
||||
@@ -30,4 +41,16 @@ export function registerIpcHandlers(auth: AuthService, helix: HelixClient): void
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.twitchGetFollowed, () => helix.getFollowedWithLiveStatus())
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.playbackStartLive,
|
||||
(_e, channelLogin: string, quality?: string) => playback.startLive(channelLogin, quality)
|
||||
)
|
||||
ipcMain.handle(IPC.playbackStop, () => playback.stop())
|
||||
|
||||
playback.on('playback-event', (event: PlaybackEvent) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(IPC.playbackEvent, event)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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