/** * LMS multi-room sync ("fuse zones") store. * * The JellyLMS plugin registers each LMS player as a Jellyfin session whose * device id is `lms-{MacAddress}`. We use that prefix both to detect which cast * targets are fuseable LMS zones and to recover the MAC the SyncGroups API needs. * * Fusing model (per product decision): the currently-controlled zone is the sync * master; other selected zones join it. Toggling an already-grouped zone off * decouples just that zone; toggling the master off dissolves the group. * * TRACES: UR-010 | JA-021, JA-025 | DR-037 */ import { writable, get } from "svelte/store"; import { commands } from "$lib/api/bindings"; import type { Session } from "$lib/api/types"; import type { LmsSyncGroup } from "$lib/api/bindings"; import { createLogger } from "$lib/utils/logger"; const log = createLogger("LmsSync"); const LMS_DEVICE_PREFIX = "lms-"; /** Is this cast target an LMS zone (and therefore fuseable)? */ export function isLmsSession(session: Session | null | undefined): boolean { return !!session?.deviceId?.startsWith(LMS_DEVICE_PREFIX); } /** Recover the LMS player MAC from a session, or null if it isn't an LMS zone. */ export function macForSession(session: Session | null | undefined): string | null { const deviceId = session?.deviceId; if (!deviceId?.startsWith(LMS_DEVICE_PREFIX)) return null; return deviceId.slice(LMS_DEVICE_PREFIX.length); } interface LmsSyncState { groups: LmsSyncGroup[]; isBusy: boolean; error: string | null; } function createLmsSyncStore() { const { subscribe, update, set } = writable({ groups: [], isBusy: false, error: null, }); /** Refresh the list of current sync groups from the server. */ async function refresh(): Promise { try { const groups = await commands.lmsGetSyncGroups(); update((s) => ({ ...s, groups, error: null })); } catch (error) { // The plugin may not be installed; treat as "no groups" rather than fatal. log.warn("Failed to load sync groups:", error); update((s) => ({ ...s, groups: [] })); } } /** All MACs that are part of any sync group (master or slave). */ function groupedMacs(): Set { const macs = new Set(); for (const g of get({ subscribe }).groups) { macs.add(g.masterMac); for (const slave of g.slaveMacs ?? []) macs.add(slave); } return macs; } /** * Add an LMS zone to the master's sync group (fuse it in). * Preserves any zones already grouped with the master. */ async function fuseZone(masterMac: string, zoneMac: string): Promise { if (masterMac === zoneMac) return; update((s) => ({ ...s, isBusy: true, error: null })); try { const existing = get({ subscribe }).groups.find((g) => g.masterMac === masterMac); const slaves = new Set(existing?.slaveMacs ?? []); slaves.add(zoneMac); await commands.lmsCreateSyncGroup(masterMac, [...slaves]); await refresh(); } catch (error) { const message = error instanceof Error ? error.message : "Failed to fuse zone"; update((s) => ({ ...s, error: message })); throw error; } finally { update((s) => ({ ...s, isBusy: false })); } } /** * Remove a single zone from its group. If the zone is a group's master, the * whole group is dissolved (a group can't outlive its master). */ async function decoupleZone(zoneMac: string): Promise { update((s) => ({ ...s, isBusy: true, error: null })); try { const asMaster = get({ subscribe }).groups.find((g) => g.masterMac === zoneMac); if (asMaster) { await commands.lmsDissolveSyncGroup(zoneMac); } else { await commands.lmsUnsyncPlayer(zoneMac); } await refresh(); } catch (error) { const message = error instanceof Error ? error.message : "Failed to decouple zone"; update((s) => ({ ...s, error: message })); throw error; } finally { update((s) => ({ ...s, isBusy: false })); } } function clearError(): void { update((s) => ({ ...s, error: null })); } function reset(): void { set({ groups: [], isBusy: false, error: null }); } return { subscribe, refresh, groupedMacs, fuseZone, decoupleZone, clearError, reset, }; } export const lmsSync = createLmsSyncStore();