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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user