Add support for fusing/unfusing JellyLMS zones into synchronized

multi-room groups, addressed by MAC (derived from the `lms-{mac}` device id).
This commit is contained in:
2026-06-26 19:27:37 +02:00
parent ff8f35084b
commit f1d25c4f4d
10 changed files with 582 additions and 6 deletions
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { get } from "svelte/store";
import type { Session } from "$lib/api/types";
const mockInvoke = vi.fn();
vi.mock("$lib/api/bindings", () => ({
commands: {
lmsGetSyncGroups: () => mockInvoke("lms_get_sync_groups"),
lmsCreateSyncGroup: (masterMac: string, slaveMacs: string[]) =>
mockInvoke("lms_create_sync_group", { masterMac, slaveMacs }),
lmsUnsyncPlayer: (mac: string) => mockInvoke("lms_unsync_player", { mac }),
lmsDissolveSyncGroup: (masterMac: string) =>
mockInvoke("lms_dissolve_sync_group", { masterMac }),
},
}));
import { lmsSync, isLmsSession, macForSession } from "./lmsSync";
function session(deviceId: string | null): Session {
return { id: "s", deviceId } as unknown as Session;
}
describe("LMS session detection", () => {
it("detects LMS zones by the lms- device-id prefix", () => {
expect(isLmsSession(session("lms-aa:bb:cc:dd:ee:ff"))).toBe(true);
expect(isLmsSession(session("chromecast-123"))).toBe(false);
expect(isLmsSession(session(null))).toBe(false);
expect(isLmsSession(null)).toBe(false);
});
it("recovers the MAC from an LMS session, null otherwise", () => {
expect(macForSession(session("lms-aa:bb:cc:dd:ee:ff"))).toBe("aa:bb:cc:dd:ee:ff");
expect(macForSession(session("web-xyz"))).toBeNull();
expect(macForSession(null)).toBeNull();
});
});
describe("lmsSync store", () => {
beforeEach(() => {
mockInvoke.mockReset();
lmsSync.reset();
});
it("fusing preserves existing slaves and adds the new zone", async () => {
// Initial group: master M with slave A.
mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]);
await lmsSync.refresh();
// create returns void; refresh after returns the updated group.
mockInvoke.mockResolvedValueOnce(undefined);
mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A", "B"], slaveNames: [] }]);
await lmsSync.fuseZone("M", "B");
expect(mockInvoke).toHaveBeenCalledWith("lms_create_sync_group", {
masterMac: "M",
slaveMacs: ["A", "B"],
});
expect(get(lmsSync).groups[0].slaveMacs).toEqual(["A", "B"]);
});
it("decoupling a slave unsyncs just that player", async () => {
mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]);
await lmsSync.refresh();
mockInvoke.mockResolvedValueOnce(undefined); // unsync
mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: [], slaveNames: [] }]);
await lmsSync.decoupleZone("A");
expect(mockInvoke).toHaveBeenCalledWith("lms_unsync_player", { mac: "A" });
});
it("decoupling the master dissolves the whole group", async () => {
mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]);
await lmsSync.refresh();
mockInvoke.mockResolvedValueOnce(undefined); // dissolve
mockInvoke.mockResolvedValueOnce([]);
await lmsSync.decoupleZone("M");
expect(mockInvoke).toHaveBeenCalledWith("lms_dissolve_sync_group", { masterMac: "M" });
});
it("treats a missing plugin (refresh error) as no groups, not fatal", async () => {
mockInvoke.mockRejectedValueOnce(new Error("404"));
await lmsSync.refresh();
expect(get(lmsSync).groups).toEqual([]);
});
});
+133
View File
@@ -0,0 +1,133 @@
/**
* 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";
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.
console.warn("[LmsSync] 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();
+14
View File
@@ -198,6 +198,12 @@ function createPlaybackModeStore() {
// Get repository for handle (backend will fetch playback info via player_play_tracks)
const repository = auth.getRepository();
// Mark the whole sequence as a transfer in the *Rust* manager too. Without
// this, player_play_tracks sees mode=Remote and casts the track back to the
// remote session instead of playing it locally (the frontend's own
// isTransferring flag is invisible to Rust). Cleared in `finally`.
await commands.playbackModeSetTransferring(true);
// Start local playback (events allowed through because isTransferring=true)
// Use player_play_tracks - backend fetches all metadata from single ID
const repositoryHandle = repository.getHandle();
@@ -248,6 +254,14 @@ function createPlaybackModeStore() {
console.error("Transfer to local failed:", error);
throw error;
} finally {
// Always lower the Rust transferring flag so it can't stick on if any step
// above threw (transfer_to_local lowers it on success, but not if we never
// reached it). Safe to call unconditionally.
try {
await commands.playbackModeSetTransferring(false);
} catch (e) {
console.warn("[PlaybackMode] Failed to clear transferring flag:", e);
}
currentTransferAbort = null;
}
}