mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 09:33:09 +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:
@@ -3,6 +3,7 @@ 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'
|
||||
import { getProgressMap } from '../store/historyRepo'
|
||||
|
||||
export const IPC = {
|
||||
authStatus: 'auth:get-status',
|
||||
@@ -15,6 +16,7 @@ export const IPC = {
|
||||
|
||||
twitchGetFollowed: 'twitch:get-followed',
|
||||
twitchGetVideos: 'twitch:get-videos',
|
||||
historyGetProgress: 'history:get-progress',
|
||||
|
||||
playbackStartLive: 'playback:start-live',
|
||||
playbackStartVod: 'playback:start-vod',
|
||||
@@ -47,6 +49,10 @@ export function registerIpcHandlers(
|
||||
IPC.twitchGetVideos,
|
||||
(_e, broadcasterId: string) => helix.getVideos(broadcasterId)
|
||||
)
|
||||
ipcMain.handle(
|
||||
IPC.historyGetProgress,
|
||||
(_e, vodIds: string[]) => getProgressMap(vodIds)
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.playbackStartLive,
|
||||
@@ -54,7 +60,8 @@ export function registerIpcHandlers(
|
||||
)
|
||||
ipcMain.handle(
|
||||
IPC.playbackStartVod,
|
||||
(_e, vodId: string, startSeconds?: number) => playback.startVod(vodId, startSeconds)
|
||||
(_e, vodId: string, channelLogin: string, title: string, durationSeconds: number) =>
|
||||
playback.startVod(vodId, channelLogin, title, durationSeconds)
|
||||
)
|
||||
ipcMain.handle(IPC.playbackStop, () => playback.stop())
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export function getMpvIpcPath(): string {
|
||||
|
||||
export class MpvController {
|
||||
private socket: net.Socket | null = null
|
||||
private buf = ''
|
||||
|
||||
constructor(private ipcPath: string) {}
|
||||
|
||||
@@ -33,22 +34,51 @@ export class MpvController {
|
||||
const sock = net.createConnection({ path: this.ipcPath })
|
||||
sock.once('connect', () => {
|
||||
this.socket = sock
|
||||
sock.on('error', () => {
|
||||
/* ignore socket errors after connect */
|
||||
})
|
||||
sock.on('error', () => { /* ignore socket errors after connect */ })
|
||||
resolve()
|
||||
})
|
||||
sock.once('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
/** Beobachtet time-pos; ruft cb mit Sekunden auf. Throttling liegt beim Aufrufer. */
|
||||
observeTimePos(cb: (seconds: number) => void): void {
|
||||
if (!this.socket?.writable) return
|
||||
|
||||
// Observer registrieren
|
||||
this._send({ command: ['observe_property', 1, 'time-pos'] })
|
||||
|
||||
this.socket.on('data', (chunk: Buffer) => {
|
||||
this.buf += chunk.toString()
|
||||
const lines = this.buf.split('\n')
|
||||
this.buf = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
try {
|
||||
const msg = JSON.parse(line) as {
|
||||
event?: string
|
||||
name?: string
|
||||
data?: unknown
|
||||
}
|
||||
if (
|
||||
msg.event === 'property-change' &&
|
||||
msg.name === 'time-pos' &&
|
||||
typeof msg.data === 'number'
|
||||
) {
|
||||
cb(msg.data)
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed lines */
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
quit(): void {
|
||||
if (this.socket?.writable) {
|
||||
try {
|
||||
this.socket.write(JSON.stringify({ command: ['quit'] }) + '\n')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this._send({ command: ['quit'] })
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
this.socket?.destroy()
|
||||
this.socket = null
|
||||
@@ -57,5 +87,10 @@ export class MpvController {
|
||||
disconnect(): void {
|
||||
this.socket?.destroy()
|
||||
this.socket = null
|
||||
this.buf = ''
|
||||
}
|
||||
|
||||
private _send(msg: object): void {
|
||||
this.socket?.write(JSON.stringify(msg) + '\n')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,33 +33,52 @@ function resolveMpvBin(): string {
|
||||
const STREAMLINK_BIN = resolveStreamlinkBin()
|
||||
const MPV_BIN = resolveMpvBin()
|
||||
|
||||
export interface StreamlinkOptions {
|
||||
url: string
|
||||
quality: string
|
||||
mpvIpcPath: string
|
||||
/** Startet streamlink so dass es mpv als Player verwendet (für Live-Streams). */
|
||||
export function spawnStreamlink(url: string, quality: string): ChildProcess {
|
||||
return spawn(STREAMLINK_BIN, ['--player', MPV_BIN, '--player-args', '--fullscreen', url, quality], {
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragt streamlink nach der direkten HLS-URL für einen VOD.
|
||||
* mpv kann diese URL dann nativ laden und darin seeked.
|
||||
*/
|
||||
export function getStreamUrl(url: string, quality: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(STREAMLINK_BIN, ['--stream-url', url, quality], {
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
let out = ''
|
||||
proc.stdout?.on('data', (d: Buffer) => { out += d.toString() })
|
||||
proc.on('error', reject)
|
||||
proc.on('exit', (code) => {
|
||||
const hlsUrl = out.trim()
|
||||
if (code === 0 && hlsUrl) {
|
||||
resolve(hlsUrl)
|
||||
} else {
|
||||
reject(new Error(`streamlink --stream-url exit ${code}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export interface MpvOptions {
|
||||
ipcPath: string
|
||||
startSeconds?: number
|
||||
}
|
||||
|
||||
export function spawnStreamlink({
|
||||
url,
|
||||
quality,
|
||||
mpvIpcPath,
|
||||
startSeconds
|
||||
}: StreamlinkOptions): ChildProcess {
|
||||
/** Startet mpv direkt mit einer URL (HLS oder lokal). IPC-Socket wird gesetzt. */
|
||||
export function spawnMpv(url: string, { ipcPath, startSeconds }: MpvOptions): ChildProcess {
|
||||
const hwdec = process.platform === 'win32' ? 'auto' : 'vaapi'
|
||||
let playerArgs = `--input-ipc-server=${mpvIpcPath} --fullscreen --hwdec=${hwdec}`
|
||||
if (startSeconds && startSeconds > 0) {
|
||||
playerArgs += ` --start=${startSeconds}`
|
||||
}
|
||||
|
||||
const args = [
|
||||
'--player',
|
||||
MPV_BIN,
|
||||
'--player-args',
|
||||
playerArgs,
|
||||
url,
|
||||
quality
|
||||
`--input-ipc-server=${ipcPath}`,
|
||||
'--fullscreen',
|
||||
`--hwdec=${hwdec}`
|
||||
]
|
||||
|
||||
return spawn(STREAMLINK_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
if (startSeconds && startSeconds > 0) {
|
||||
args.push(`--start=${startSeconds}`)
|
||||
}
|
||||
return spawn(MPV_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { app } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
|
||||
let _db: Database.Database | null = null
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (!_db) {
|
||||
const dbPath = join(app.getPath('userData'), 'history.db')
|
||||
_db = new Database(dbPath)
|
||||
_db.pragma('journal_mode = WAL')
|
||||
migrate(_db)
|
||||
}
|
||||
return _db
|
||||
}
|
||||
|
||||
function migrate(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS vod_history (
|
||||
vod_id TEXT PRIMARY KEY,
|
||||
channel_login TEXT NOT NULL,
|
||||
title TEXT,
|
||||
duration_seconds INTEGER,
|
||||
resume_position_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
watched_at INTEGER NOT NULL,
|
||||
completed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_history_watched ON vod_history(watched_at DESC);
|
||||
`)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { getDb } from './db'
|
||||
|
||||
export interface VodHistoryEntry {
|
||||
vod_id: string
|
||||
channel_login: string
|
||||
title: string | null
|
||||
duration_seconds: number | null
|
||||
resume_position_seconds: number
|
||||
watched_at: number
|
||||
completed: number
|
||||
}
|
||||
|
||||
export function upsertVod(entry: Omit<VodHistoryEntry, 'resume_position_seconds' | 'completed'>): void {
|
||||
const db = getDb()
|
||||
db.prepare(`
|
||||
INSERT INTO vod_history (vod_id, channel_login, title, duration_seconds, watched_at)
|
||||
VALUES (@vod_id, @channel_login, @title, @duration_seconds, @watched_at)
|
||||
ON CONFLICT(vod_id) DO UPDATE SET
|
||||
watched_at = excluded.watched_at,
|
||||
title = excluded.title,
|
||||
duration_seconds = excluded.duration_seconds
|
||||
`).run(entry)
|
||||
}
|
||||
|
||||
export function updatePosition(vodId: string, positionSeconds: number): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`UPDATE vod_history SET resume_position_seconds = ? WHERE vod_id = ?`
|
||||
)
|
||||
.run(positionSeconds, vodId)
|
||||
}
|
||||
|
||||
export function markCompleted(vodId: string): void {
|
||||
getDb()
|
||||
.prepare(`UPDATE vod_history SET completed = 1 WHERE vod_id = ?`)
|
||||
.run(vodId)
|
||||
}
|
||||
|
||||
export interface VodProgress {
|
||||
resumePositionSeconds: number
|
||||
watchedAt: number
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
/** Gibt für alle übergebenen vodIds vorhandene History-Einträge zurück. */
|
||||
export function getProgressMap(vodIds: string[]): Record<string, VodProgress> {
|
||||
if (vodIds.length === 0) return {}
|
||||
const placeholders = vodIds.map(() => '?').join(',')
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`SELECT vod_id, resume_position_seconds, watched_at, completed
|
||||
FROM vod_history WHERE vod_id IN (${placeholders})`
|
||||
)
|
||||
.all(...vodIds) as {
|
||||
vod_id: string
|
||||
resume_position_seconds: number
|
||||
watched_at: number
|
||||
completed: number
|
||||
}[]
|
||||
const map: Record<string, VodProgress> = {}
|
||||
for (const r of rows) {
|
||||
map[r.vod_id] = {
|
||||
resumePositionSeconds: r.resume_position_seconds,
|
||||
watchedAt: r.watched_at,
|
||||
completed: r.completed === 1
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export function getPosition(vodId: string): number {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
`SELECT resume_position_seconds, completed FROM vod_history WHERE vod_id = ?`
|
||||
)
|
||||
.get(vodId) as { resume_position_seconds: number; completed: number } | undefined
|
||||
// Completed VODs: start from beginning
|
||||
if (!row || row.completed) return 0
|
||||
return row.resume_position_seconds
|
||||
}
|
||||
Reference in New Issue
Block a user