formatters, device profile, media matching, subtitle utils, syncplay, trakt

This commit is contained in:
2026-03-24 10:48:59 +02:00
parent 292b3f42cf
commit 996a85de76
41 changed files with 4306 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
import { jellyfinClient } from '../api/jellyfin'
/**
* Lightweight Jellyfin WebSocket wrapper scoped to SyncPlay messages.
* The socket is opened on demand (when a SyncPlay group is joined),
* pushes JSON-decoded messages to subscribers, and reconnects with
* exponential backoff if the server drops us.
*
* We intentionally don't ship a full WS client - other features in the
* app (push notifications, library updates) would need different
* subscription scopes, so a generic event bus would do more harm than
* good. When those land they can graduate this into a shared singleton.
*/
export type SyncPlayMessage =
| { MessageType: 'SyncPlayGroupUpdate'; Data: SyncPlayGroupUpdate }
| { MessageType: 'SyncPlayCommand'; Data: SyncPlayCommand }
| { MessageType: string; Data?: unknown }
export interface SyncPlayGroupUpdate {
GroupId?: string
Type?: string
Data?: unknown
}
export interface SyncPlayCommand {
GroupId?: string
Command?: 'Pause' | 'Unpause' | 'Stop' | 'Seek' | string
PositionTicks?: number
When?: string
EmittedAt?: string
}
type Listener = (msg: SyncPlayMessage) => void
let socket: WebSocket | null = null
const listeners = new Set<Listener>()
let retry = 0
let retryTimer: number | null = null
let pingTimer: number | null = null
let stopped = false
function wsUrl(): string | null {
const auth = jellyfinClient.getAuthState()
const api = jellyfinClient.getApi()
if (!auth || !api) return null
const base = auth.serverUrl.replace(/^http/, 'ws')
const deviceId = (api.deviceInfo?.id) || 'jf-desktop'
return `${base}/socket?api_key=${encodeURIComponent(auth.token)}&deviceId=${encodeURIComponent(deviceId)}`
}
function clearRetryTimer() {
if (retryTimer != null) {
clearTimeout(retryTimer)
retryTimer = null
}
}
function clearPingTimer() {
if (pingTimer != null) {
clearInterval(pingTimer)
pingTimer = null
}
}
function connect() {
if (stopped) return
const url = wsUrl()
if (!url) return
try {
socket = new WebSocket(url)
} catch {
scheduleReconnect()
return
}
socket.onopen = () => {
retry = 0
// Server keeps the socket alive while it sees periodic KeepAlive
// pings. We send the start-keepalive sentinel, then heartbeat every
// 30s ourselves so we look active to the gateway in case the server
// skips its own pings.
try {
socket?.send(JSON.stringify({ MessageType: 'KeepAlive' }))
} catch { /* noop */ }
clearPingTimer()
pingTimer = window.setInterval(() => {
try {
socket?.send(JSON.stringify({ MessageType: 'KeepAlive' }))
} catch { /* noop */ }
}, 30_000)
}
socket.onmessage = ev => {
try {
const data = JSON.parse(ev.data) as SyncPlayMessage
if (!data.MessageType) return
if (data.MessageType.startsWith('SyncPlay')) {
for (const l of listeners) l(data)
}
} catch { /* skip malformed frames */ }
}
socket.onclose = () => {
clearPingTimer()
socket = null
scheduleReconnect()
}
socket.onerror = () => {
try { socket?.close() } catch { /* noop */ }
}
}
function scheduleReconnect() {
if (stopped) return
clearRetryTimer()
const delay = Math.min(30_000, 1000 * 2 ** Math.min(retry, 5))
retry++
retryTimer = window.setTimeout(connect, delay)
}
export function startSyncPlaySocket() {
stopped = false
if (socket) return
connect()
}
export function stopSyncPlaySocket() {
stopped = true
listeners.clear()
clearRetryTimer()
clearPingTimer()
if (socket) {
try { socket.close() } catch { /* noop */ }
socket = null
}
}
export function subscribeSyncPlay(listener: Listener): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}