TRACES: | DR-204 484 ungated `console.*` calls across 63 non-test frontend files shipped to end users with no way to turn them off. Mechanical substitution, no control flow, error handling or message semantics changed: console.log / console.debug -> log.debug console.info -> log.info console.warn -> log.warn console.error -> log.error Hand-written `"[Scope] …"` prefixes are dropped where the logger's scope now carries them; scope names that already existed are preserved verbatim (`[Auth]`, `[VideoPlayer]`, `[PiP]`, …) and inferred from the filename where a file had none. `src/routes/player/[id]/+page.svelte` keeps its `NextEpisode` and `AutoPlay` sub-scopes as separate loggers rather than flattening them into the page scope. `grep -rn 'console\.' src/` now matches nothing outside the tests and the facade itself.
137 lines
4.3 KiB
TypeScript
137 lines
4.3 KiB
TypeScript
/**
|
|
* 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<LmsSyncState>({
|
|
groups: [],
|
|
isBusy: false,
|
|
error: null,
|
|
});
|
|
|
|
/** Refresh the list of current sync groups from the server. */
|
|
async function refresh(): Promise<void> {
|
|
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<string> {
|
|
const macs = new Set<string>();
|
|
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<void> {
|
|
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<void> {
|
|
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();
|