mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
Phase 5 abgeschlossen
- SQLite-Setup: `src/main/store/db.ts` (better-sqlite3, Migration) - Lokaler VOD-Verlauf (Resume) - mpv `time-pos`-Observer schreibt alle ~5s in DB - Auto-Resume: beim VOD-Start Position aus DB lesen → `--start=<s>` -Resume-Indikator auf VOD-Karte - Completed-Indikator (dunkles Overlay + Checkmark)
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { spawnStreamlink } from './streamlink'
|
||||
import { spawnStreamlink, getStreamUrl, spawnMpv } from './streamlink'
|
||||
import { getMpvIpcPath, MpvController } from './mpvController'
|
||||
import type { PlaybackEvent } from './types'
|
||||
import * as history from '../store/historyRepo'
|
||||
|
||||
const POSITION_WRITE_INTERVAL_MS = 5_000
|
||||
|
||||
interface CurrentPlayback {
|
||||
channelLogin: string
|
||||
streamlink: ChildProcess
|
||||
process: ChildProcess
|
||||
mpv: MpvController
|
||||
}
|
||||
|
||||
@@ -18,117 +21,116 @@ export class PlaybackService extends EventEmitter {
|
||||
}
|
||||
|
||||
async startLive(channelLogin: string, quality = 'best'): Promise<void> {
|
||||
if (this.current) {
|
||||
await this.stop()
|
||||
}
|
||||
if (this.current) await this.stop()
|
||||
|
||||
const sl = spawnStreamlink({
|
||||
url: `twitch.tv/${channelLogin}`,
|
||||
quality,
|
||||
mpvIpcPath: this.ipcPath
|
||||
})
|
||||
const proc = spawnStreamlink(`twitch.tv/${channelLogin}`, quality)
|
||||
this._attachLogs(proc)
|
||||
|
||||
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
|
||||
proc.on('error', (e: Error) => {
|
||||
if (this.current?.process === proc) 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)
|
||||
}
|
||||
proc.on('exit', (code) => {
|
||||
if (this.current?.process === proc) this.current = null
|
||||
if (code !== 0 && code !== null) console.warn('[streamlink] exit code', code)
|
||||
this.emit('playback-event', { kind: 'stopped' } satisfies PlaybackEvent)
|
||||
})
|
||||
|
||||
// Für Live: mpv wird von streamlink gestartet, kein direkter IPC nötig
|
||||
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(() => {})
|
||||
this.current = { process: proc, mpv }
|
||||
this.emit('playback-event', { kind: 'started', channelLogin } satisfies PlaybackEvent)
|
||||
}
|
||||
|
||||
async startVod(vodId: string, startSeconds = 0): Promise<void> {
|
||||
if (this.current) {
|
||||
await this.stop()
|
||||
}
|
||||
async startVod(
|
||||
vodId: string,
|
||||
channelLogin: string,
|
||||
title: string,
|
||||
durationSeconds: number
|
||||
): Promise<void> {
|
||||
if (this.current) await this.stop()
|
||||
|
||||
const sl = spawnStreamlink({
|
||||
url: `https://www.twitch.tv/videos/${vodId}`,
|
||||
quality: 'best',
|
||||
mpvIpcPath: this.ipcPath,
|
||||
startSeconds
|
||||
const resumePos = history.getPosition(vodId)
|
||||
history.upsertVod({
|
||||
vod_id: vodId,
|
||||
channel_login: channelLogin,
|
||||
title,
|
||||
duration_seconds: durationSeconds,
|
||||
watched_at: Date.now()
|
||||
})
|
||||
|
||||
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
|
||||
let hlsUrl: string
|
||||
try {
|
||||
hlsUrl = await getStreamUrl(`https://www.twitch.tv/videos/${vodId}`, 'best')
|
||||
} catch (e) {
|
||||
this.emit('playback-event', {
|
||||
kind: 'error',
|
||||
message: `streamlink Fehler: ${e.message}`
|
||||
message: `Stream-URL konnte nicht abgerufen werden: ${e}`
|
||||
} satisfies PlaybackEvent)
|
||||
return
|
||||
}
|
||||
|
||||
const proc = spawnMpv(hlsUrl, { ipcPath: this.ipcPath, startSeconds: resumePos })
|
||||
this._attachLogs(proc)
|
||||
|
||||
proc.on('error', (e: Error) => {
|
||||
if (this.current?.process === proc) this.current = null
|
||||
this.emit('playback-event', {
|
||||
kind: 'error',
|
||||
message: `mpv 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)
|
||||
}
|
||||
proc.on('exit', (code) => {
|
||||
if (this.current?.process === proc) this.current = null
|
||||
if (code !== 0 && code !== null) console.warn('[mpv] exit code', code)
|
||||
this.emit('playback-event', { kind: 'stopped' } satisfies PlaybackEvent)
|
||||
})
|
||||
|
||||
const mpv = new MpvController(this.ipcPath)
|
||||
this.current = { channelLogin: vodId, streamlink: sl, mpv }
|
||||
|
||||
this.current = { process: proc, mpv }
|
||||
this.emit('playback-event', { kind: 'started' } satisfies PlaybackEvent)
|
||||
mpv.connect().catch(() => {})
|
||||
|
||||
// IPC verbinden und Position tracken
|
||||
mpv.connect().then(() => {
|
||||
let lastWrite = 0
|
||||
mpv.observeTimePos((seconds) => {
|
||||
const now = Date.now()
|
||||
if (now - lastWrite < POSITION_WRITE_INTERVAL_MS) return
|
||||
lastWrite = now
|
||||
const pos = Math.floor(seconds)
|
||||
history.updatePosition(vodId, pos)
|
||||
if (durationSeconds > 0 && pos / durationSeconds > 0.95) {
|
||||
history.markCompleted(vodId)
|
||||
}
|
||||
})
|
||||
}).catch(() => {
|
||||
console.warn('[mpv] IPC nicht verfügbar — Resume-Tracking deaktiviert')
|
||||
})
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.current) return
|
||||
const { streamlink: sl, mpv } = this.current
|
||||
const { process: proc, mpv } = this.current
|
||||
this.current = null
|
||||
|
||||
try {
|
||||
mpv.quit()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try { mpv.quit() } catch { /* ignore */ }
|
||||
mpv.disconnect()
|
||||
|
||||
await new Promise<void>((r) => setTimeout(r, 400))
|
||||
|
||||
if (!sl.killed) {
|
||||
sl.kill()
|
||||
}
|
||||
if (!proc.killed) proc.kill()
|
||||
}
|
||||
|
||||
stopAll(): void {
|
||||
this.stop().catch(() => {})
|
||||
}
|
||||
|
||||
private _attachLogs(proc: ChildProcess): void {
|
||||
proc.stdout?.on('data', (d: Buffer) => console.log('[player stdout]', d.toString().trim()))
|
||||
proc.stderr?.on('data', (d: Buffer) => console.log('[player stderr]', d.toString().trim()))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user