chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
This commit is contained in:
@@ -13,10 +13,7 @@ const invokeResponses: Map<string, any> = new Map();
|
||||
/**
|
||||
* Mock invoke function that captures calls
|
||||
*/
|
||||
export const mockInvoke = async (
|
||||
command: string,
|
||||
args?: Record<string, any>
|
||||
): Promise<any> => {
|
||||
export const mockInvoke = async (command: string, args?: Record<string, any>): Promise<any> => {
|
||||
const callArgs = args || {};
|
||||
invokeHistory.push({ command, args: callArgs });
|
||||
|
||||
@@ -72,10 +69,7 @@ export const clearInvokeHistory = (): void => {
|
||||
/**
|
||||
* Verify a command was called with expected parameters
|
||||
*/
|
||||
export const expectInvokeCall = (
|
||||
command: string,
|
||||
expectedArgs: Record<string, any>
|
||||
): void => {
|
||||
export const expectInvokeCall = (command: string, expectedArgs: Record<string, any>): void => {
|
||||
const calls = getInvokeCalls_ForCommand(command);
|
||||
|
||||
if (calls.length === 0) {
|
||||
@@ -92,7 +86,7 @@ export const expectInvokeCall = (
|
||||
throw new Error(
|
||||
`Parameter "${key}" mismatch:\n` +
|
||||
` Expected: ${JSON.stringify(expectedValue)}\n` +
|
||||
` Actual: ${JSON.stringify(actualValue)}`
|
||||
` Actual: ${JSON.stringify(actualValue)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -104,7 +98,7 @@ export const expectInvokeCall = (
|
||||
export const getInvokeParameter = (
|
||||
command: string,
|
||||
paramName: string,
|
||||
callIndex = -1 // -1 = last call
|
||||
callIndex = -1, // -1 = last call
|
||||
): any => {
|
||||
const calls = getInvokeCalls_ForCommand(command);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Application-wide UI state store
|
||||
// TRACES: UR-005 | DR-005, DR-009
|
||||
import { writable } from 'svelte/store';
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
// App-wide state (root layout)
|
||||
export const isInitialized = writable(false);
|
||||
|
||||
@@ -237,9 +237,7 @@ describe("auth store", () => {
|
||||
// Expected - might fail due to mocking
|
||||
}
|
||||
|
||||
const call = invokeSpy.mock.calls.find(
|
||||
(c) => c[0] === "auth_connect_to_server"
|
||||
);
|
||||
const call = invokeSpy.mock.calls.find((c) => c[0] === "auth_connect_to_server");
|
||||
expect(call).toBeDefined();
|
||||
expect(call![1]).toHaveProperty("serverUrl");
|
||||
});
|
||||
@@ -265,9 +263,7 @@ describe("auth store", () => {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const invokeSpy = vi.mocked(invoke);
|
||||
|
||||
const loginCall = invokeSpy.mock.calls.find(
|
||||
(c) => c[0] === "auth_login"
|
||||
);
|
||||
const loginCall = invokeSpy.mock.calls.find((c) => c[0] === "auth_login");
|
||||
expect(loginCall).toBeDefined();
|
||||
expect(loginCall![1]).toHaveProperty("username", "testuser");
|
||||
expect(loginCall![1]).toHaveProperty("password", "password123");
|
||||
@@ -288,7 +284,7 @@ describe("auth store", () => {
|
||||
expect.objectContaining({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -323,8 +319,10 @@ describe("auth store", () => {
|
||||
await auth.logout();
|
||||
|
||||
// Either auth_get_session or auth_logout should be called
|
||||
const callNames = invokeSpy.mock.calls.map(c => c[0]);
|
||||
expect(callNames.some(name => ["auth_get_session", "player_disable_jellyfin"].includes(name))).toBe(true);
|
||||
const callNames = invokeSpy.mock.calls.map((c) => c[0]);
|
||||
expect(
|
||||
callNames.some((name) => ["auth_get_session", "player_disable_jellyfin"].includes(name)),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -340,9 +338,7 @@ describe("auth store", () => {
|
||||
|
||||
await auth.getCurrentSession();
|
||||
|
||||
const call = invokeSpy.mock.calls.find(
|
||||
(c) => c[0] === "auth_get_session"
|
||||
);
|
||||
const call = invokeSpy.mock.calls.find((c) => c[0] === "auth_get_session");
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
+38
-21
@@ -172,7 +172,12 @@ function createAuthStore() {
|
||||
// we mark authenticated — the first screen (library overview) reads
|
||||
// through it — so keep it awaited.
|
||||
repository = new RepositoryClient();
|
||||
await repository.create(session.serverUrl, session.userId, session.accessToken, session.serverId);
|
||||
await repository.create(
|
||||
session.serverUrl,
|
||||
session.userId,
|
||||
session.accessToken,
|
||||
session.serverId,
|
||||
);
|
||||
|
||||
// Configure the Rust player for playback reporting. This is NOT needed to
|
||||
// render the first screen (it only matters once playback starts), so run
|
||||
@@ -185,7 +190,7 @@ function createAuthStore() {
|
||||
session.serverUrl,
|
||||
session.accessToken,
|
||||
session.userId,
|
||||
deviceId
|
||||
deviceId,
|
||||
);
|
||||
log.debug("Rust player configured for automatic playback reporting");
|
||||
} catch (error) {
|
||||
@@ -209,19 +214,21 @@ function createAuthStore() {
|
||||
|
||||
// Start connectivity monitoring early to avoid appearing offline on startup
|
||||
log.debug("Starting early connectivity monitoring...");
|
||||
connectivity.startMonitoring(session.serverUrl, {
|
||||
onServerReconnected: () => {
|
||||
// Retry session verification when server becomes reachable
|
||||
retryVerification();
|
||||
// Resume downloads queued while offline, then refresh the catalog.
|
||||
// Lazy import to avoid an auth <-> offlineCatalog import cycle.
|
||||
import("$lib/services/offlineCatalog")
|
||||
.then((m) => m.onReconnected())
|
||||
.catch((err) => log.warn("Catalog reconnect failed:", err));
|
||||
},
|
||||
}).catch((error) => {
|
||||
log.error("Failed to start connectivity monitoring:", error);
|
||||
});
|
||||
connectivity
|
||||
.startMonitoring(session.serverUrl, {
|
||||
onServerReconnected: () => {
|
||||
// Retry session verification when server becomes reachable
|
||||
retryVerification();
|
||||
// Resume downloads queued while offline, then refresh the catalog.
|
||||
// Lazy import to avoid an auth <-> offlineCatalog import cycle.
|
||||
import("$lib/services/offlineCatalog")
|
||||
.then((m) => m.onReconnected())
|
||||
.catch((err) => log.warn("Catalog reconnect failed:", err));
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("Failed to start connectivity monitoring:", error);
|
||||
});
|
||||
|
||||
// Start background session verification — fire-and-forget. This is
|
||||
// already asynchronous work (results arrive via the auth:* events wired
|
||||
@@ -313,7 +320,7 @@ function createAuthStore() {
|
||||
authResult.user.id,
|
||||
authResult.serverId,
|
||||
authResult.user.name,
|
||||
authResult.accessToken
|
||||
authResult.accessToken,
|
||||
);
|
||||
|
||||
await commands.storageSetActiveUser(authResult.user.id, authResult.serverId);
|
||||
@@ -332,7 +339,12 @@ function createAuthStore() {
|
||||
|
||||
// Create RepositoryClient
|
||||
repository = new RepositoryClient();
|
||||
await repository.create(serverUrl, authResult.user.id, authResult.accessToken, authResult.serverId);
|
||||
await repository.create(
|
||||
serverUrl,
|
||||
authResult.user.id,
|
||||
authResult.accessToken,
|
||||
authResult.serverId,
|
||||
);
|
||||
|
||||
// Configure Rust player
|
||||
try {
|
||||
@@ -341,7 +353,7 @@ function createAuthStore() {
|
||||
serverUrl,
|
||||
authResult.accessToken,
|
||||
authResult.user.id,
|
||||
playerDeviceId
|
||||
playerDeviceId,
|
||||
);
|
||||
log.debug("Rust player configured for playback reporting");
|
||||
} catch (error) {
|
||||
@@ -398,7 +410,7 @@ function createAuthStore() {
|
||||
authResult.user.id,
|
||||
authResult.serverId,
|
||||
authResult.user.name,
|
||||
authResult.accessToken
|
||||
authResult.accessToken,
|
||||
);
|
||||
|
||||
// Recreate repository with new credentials
|
||||
@@ -406,7 +418,12 @@ function createAuthStore() {
|
||||
await repository.destroy();
|
||||
const session = await commands.authGetSession();
|
||||
if (session) {
|
||||
await repository.create(session.serverUrl, authResult.user.id, authResult.accessToken, authResult.serverId);
|
||||
await repository.create(
|
||||
session.serverUrl,
|
||||
authResult.user.id,
|
||||
authResult.accessToken,
|
||||
authResult.serverId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,7 +434,7 @@ function createAuthStore() {
|
||||
repository ? await getCurrentSessionServerUrl() : "",
|
||||
authResult.accessToken,
|
||||
authResult.user.id,
|
||||
playerDeviceId
|
||||
playerDeviceId,
|
||||
);
|
||||
} catch (error) {
|
||||
log.error("Failed to reconfigure player:", error);
|
||||
|
||||
@@ -166,8 +166,10 @@ function createConnectivityStore() {
|
||||
isChecking: status.isChecking,
|
||||
}));
|
||||
|
||||
log.debug("Started monitoring. Initial status:",
|
||||
status.isServerReachable ? "ONLINE" : "OFFLINE");
|
||||
log.debug(
|
||||
"Started monitoring. Initial status:",
|
||||
status.isServerReachable ? "ONLINE" : "OFFLINE",
|
||||
);
|
||||
} catch (error) {
|
||||
log.error("Failed to start monitoring:", error);
|
||||
update((s) => ({
|
||||
@@ -246,8 +248,5 @@ export const isServerReachable = derived(connectivity, ($c) => $c.isServerReacha
|
||||
// a brief full-catalog flash before the first probe beats flipping the app to
|
||||
// "offline" on launch. See docs/architecture/07-connectivity.md.
|
||||
// TRACES: UR-052 | DR-079
|
||||
export const isConnected = derived(
|
||||
connectivity,
|
||||
($c) => $c.isServerReachable
|
||||
);
|
||||
export const isConnected = derived(connectivity, ($c) => $c.isServerReachable);
|
||||
export const connectionError = derived(connectivity, ($c) => $c.connectionError);
|
||||
|
||||
@@ -14,16 +14,13 @@
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import {
|
||||
filterSupersededResumeItems,
|
||||
filterInProgressNextUpItems,
|
||||
} from "./continueWatchingFilter";
|
||||
import { filterSupersededResumeItems, filterInProgressNextUpItems } from "./continueWatchingFilter";
|
||||
|
||||
function episode(
|
||||
id: string,
|
||||
seriesId: string,
|
||||
season: number | undefined,
|
||||
index: number | undefined
|
||||
index: number | undefined,
|
||||
): MediaItem {
|
||||
return {
|
||||
id,
|
||||
@@ -115,15 +112,12 @@ describe("filterSupersededResumeItems", () => {
|
||||
|
||||
const result = filterSupersededResumeItems(resume, nextUp);
|
||||
|
||||
expect(result.map(i => i.id)).toEqual(["a-s1e2", "c-s1e3"]);
|
||||
expect(result.map((i) => i.id)).toEqual(["a-s1e2", "c-s1e3"]);
|
||||
});
|
||||
|
||||
it("uses the furthest-ahead next-up entry for a series", () => {
|
||||
const resume = [episode("s1e2", "series-a", 1, 2)];
|
||||
const nextUp = [
|
||||
episode("s1e1", "series-a", 1, 1),
|
||||
episode("s1e8", "series-a", 1, 8),
|
||||
];
|
||||
const nextUp = [episode("s1e1", "series-a", 1, 1), episode("s1e8", "series-a", 1, 8)];
|
||||
|
||||
expect(filterSupersededResumeItems(resume, nextUp)).toEqual([]);
|
||||
});
|
||||
@@ -143,7 +137,7 @@ describe("filterInProgressNextUpItems", () => {
|
||||
const resume = [episode("s1e4", "series-a", 1, 4)];
|
||||
const nextUp = [episode("s1e5", "series-a", 1, 5)];
|
||||
|
||||
expect(filterInProgressNextUpItems(nextUp, resume).map(i => i.id)).toEqual(["s1e5"]);
|
||||
expect(filterInProgressNextUpItems(nextUp, resume).map((i) => i.id)).toEqual(["s1e5"]);
|
||||
});
|
||||
|
||||
it("only suppresses the started episode, not the rest of the row", () => {
|
||||
@@ -156,7 +150,7 @@ describe("filterInProgressNextUpItems", () => {
|
||||
|
||||
const result = filterInProgressNextUpItems(nextUp, resume);
|
||||
|
||||
expect(result.map(i => i.id)).toEqual(["b-s1e1", "c-s2e3"]);
|
||||
expect(result.map((i) => i.id)).toEqual(["b-s1e1", "c-s2e3"]);
|
||||
});
|
||||
|
||||
it("is a no-op when nothing is in progress", () => {
|
||||
|
||||
@@ -54,7 +54,7 @@ function isAheadOf(a: MediaItem, b: MediaItem): boolean {
|
||||
*/
|
||||
export function filterSupersededResumeItems(
|
||||
resumeItems: MediaItem[],
|
||||
nextUpItems: MediaItem[]
|
||||
nextUpItems: MediaItem[],
|
||||
): MediaItem[] {
|
||||
if (nextUpItems.length === 0) return resumeItems;
|
||||
|
||||
@@ -69,7 +69,7 @@ export function filterSupersededResumeItems(
|
||||
}
|
||||
}
|
||||
|
||||
return resumeItems.filter(item => {
|
||||
return resumeItems.filter((item) => {
|
||||
if (item.kind !== "episode" || !item.seriesId) return true;
|
||||
const ahead = frontier.get(item.seriesId);
|
||||
if (!ahead) return true;
|
||||
@@ -92,10 +92,10 @@ export function filterSupersededResumeItems(
|
||||
*/
|
||||
export function filterInProgressNextUpItems(
|
||||
nextUpItems: MediaItem[],
|
||||
resumeItems: MediaItem[]
|
||||
resumeItems: MediaItem[],
|
||||
): MediaItem[] {
|
||||
if (resumeItems.length === 0) return nextUpItems;
|
||||
|
||||
const inProgress = new Set(resumeItems.map(item => item.id));
|
||||
return nextUpItems.filter(item => !inProgress.has(item.id));
|
||||
const inProgress = new Set(resumeItems.map((item) => item.id));
|
||||
return nextUpItems.filter((item) => !inProgress.has(item.id));
|
||||
}
|
||||
|
||||
@@ -24,10 +24,12 @@ describe("downloads store", () => {
|
||||
mockInvoke.mockReset();
|
||||
|
||||
// Capture the event handler when listen is called
|
||||
mockListen.mockImplementation((_event: string, handler: (event: { payload: unknown }) => void) => {
|
||||
eventHandler = handler;
|
||||
return Promise.resolve(() => {});
|
||||
});
|
||||
mockListen.mockImplementation(
|
||||
(_event: string, handler: (event: { payload: unknown }) => void) => {
|
||||
eventHandler = handler;
|
||||
return Promise.resolve(() => {});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -76,7 +78,7 @@ describe("downloads store", () => {
|
||||
"user-1",
|
||||
"/path/to/file.mp3",
|
||||
"audio/mpeg",
|
||||
10
|
||||
10,
|
||||
);
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith("download_item", {
|
||||
@@ -98,34 +100,32 @@ describe("downloads store", () => {
|
||||
it("should refresh downloads after queuing", async () => {
|
||||
const { downloads } = await import("./downloads");
|
||||
|
||||
mockInvoke
|
||||
.mockResolvedValueOnce(123)
|
||||
.mockResolvedValueOnce({
|
||||
downloads: [
|
||||
{
|
||||
id: 123,
|
||||
itemId: "item-1",
|
||||
userId: "user-1",
|
||||
filePath: "/path/to/file.mp3",
|
||||
status: "pending",
|
||||
progress: 0,
|
||||
bytesDownloaded: 0,
|
||||
queuedAt: "2024-01-01T00:00:00Z",
|
||||
retryCount: 0,
|
||||
priority: 10,
|
||||
mediaType: "audio",
|
||||
downloadSource: "user",
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
total: 1,
|
||||
activeCount: 0,
|
||||
queuedCount: 1,
|
||||
completedCount: 0,
|
||||
failedCount: 0,
|
||||
pausedCount: 0,
|
||||
mockInvoke.mockResolvedValueOnce(123).mockResolvedValueOnce({
|
||||
downloads: [
|
||||
{
|
||||
id: 123,
|
||||
itemId: "item-1",
|
||||
userId: "user-1",
|
||||
filePath: "/path/to/file.mp3",
|
||||
status: "pending",
|
||||
progress: 0,
|
||||
bytesDownloaded: 0,
|
||||
queuedAt: "2024-01-01T00:00:00Z",
|
||||
retryCount: 0,
|
||||
priority: 10,
|
||||
mediaType: "audio",
|
||||
downloadSource: "user",
|
||||
},
|
||||
});
|
||||
],
|
||||
stats: {
|
||||
total: 1,
|
||||
activeCount: 0,
|
||||
queuedCount: 1,
|
||||
completedCount: 0,
|
||||
failedCount: 0,
|
||||
pausedCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await downloads.downloadItem("item-1", "user-1", "/path/to/file.mp3");
|
||||
|
||||
@@ -315,18 +315,76 @@ describe("downloads store", () => {
|
||||
// `transfers` derivation: active + pending + failed.
|
||||
// TRACES: UR-055 | DR-084 | UT-052
|
||||
it("transfers set excludes completed downloads", async () => {
|
||||
const { downloads, activeDownloads, pendingDownloads, failedDownloads } = await import(
|
||||
"./downloads"
|
||||
);
|
||||
const { downloads, activeDownloads, pendingDownloads, failedDownloads } =
|
||||
await import("./downloads");
|
||||
|
||||
mockInvoke.mockResolvedValueOnce({
|
||||
downloads: [
|
||||
{ id: 1, itemId: "a", userId: "u", filePath: "/a", status: "downloading", progress: 0.5, bytesDownloaded: 5, queuedAt: "t", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user" },
|
||||
{ id: 2, itemId: "b", userId: "u", filePath: "/b", status: "pending", progress: 0, bytesDownloaded: 0, queuedAt: "t", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user" },
|
||||
{ id: 3, itemId: "c", userId: "u", filePath: "/c", status: "completed", progress: 1, bytesDownloaded: 9, queuedAt: "t", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user" },
|
||||
{ id: 4, itemId: "d", userId: "u", filePath: "/d", status: "failed", progress: 0, bytesDownloaded: 0, queuedAt: "t", retryCount: 1, priority: 0, mediaType: "audio", downloadSource: "user" },
|
||||
{
|
||||
id: 1,
|
||||
itemId: "a",
|
||||
userId: "u",
|
||||
filePath: "/a",
|
||||
status: "downloading",
|
||||
progress: 0.5,
|
||||
bytesDownloaded: 5,
|
||||
queuedAt: "t",
|
||||
retryCount: 0,
|
||||
priority: 0,
|
||||
mediaType: "audio",
|
||||
downloadSource: "user",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
itemId: "b",
|
||||
userId: "u",
|
||||
filePath: "/b",
|
||||
status: "pending",
|
||||
progress: 0,
|
||||
bytesDownloaded: 0,
|
||||
queuedAt: "t",
|
||||
retryCount: 0,
|
||||
priority: 0,
|
||||
mediaType: "audio",
|
||||
downloadSource: "user",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
itemId: "c",
|
||||
userId: "u",
|
||||
filePath: "/c",
|
||||
status: "completed",
|
||||
progress: 1,
|
||||
bytesDownloaded: 9,
|
||||
queuedAt: "t",
|
||||
retryCount: 0,
|
||||
priority: 0,
|
||||
mediaType: "audio",
|
||||
downloadSource: "user",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
itemId: "d",
|
||||
userId: "u",
|
||||
filePath: "/d",
|
||||
status: "failed",
|
||||
progress: 0,
|
||||
bytesDownloaded: 0,
|
||||
queuedAt: "t",
|
||||
retryCount: 1,
|
||||
priority: 0,
|
||||
mediaType: "audio",
|
||||
downloadSource: "user",
|
||||
},
|
||||
],
|
||||
stats: { total: 4, activeCount: 1, queuedCount: 1, completedCount: 1, failedCount: 1, pausedCount: 0 },
|
||||
stats: {
|
||||
total: 4,
|
||||
activeCount: 1,
|
||||
queuedCount: 1,
|
||||
completedCount: 1,
|
||||
failedCount: 1,
|
||||
pausedCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await downloads.refresh("u");
|
||||
@@ -920,9 +978,9 @@ describe("downloads store", () => {
|
||||
return Promise.reject(new Error(`Unexpected command: ${command}`));
|
||||
});
|
||||
|
||||
await expect(
|
||||
downloads.downloadItem("item-1", "user-1", "/path/to/file.mp3")
|
||||
).rejects.toThrow("Backend error: failed to queue download");
|
||||
await expect(downloads.downloadItem("item-1", "user-1", "/path/to/file.mp3")).rejects.toThrow(
|
||||
"Backend error: failed to queue download",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error when refresh fails", async () => {
|
||||
@@ -935,9 +993,9 @@ describe("downloads store", () => {
|
||||
return Promise.reject(new Error(`Unexpected command: ${command}`));
|
||||
});
|
||||
|
||||
await expect(
|
||||
downloads.refresh("user-1")
|
||||
).rejects.toThrow("Backend error: failed to fetch downloads");
|
||||
await expect(downloads.refresh("user-1")).rejects.toThrow(
|
||||
"Backend error: failed to fetch downloads",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+536
-528
File diff suppressed because it is too large
Load Diff
@@ -56,7 +56,7 @@ export function clearAllFavorites(): void {
|
||||
*/
|
||||
export function resolveIsFavorite(
|
||||
item: Pick<MediaItem, "id" | "userData"> | null | undefined,
|
||||
overrideMap: Map<string, boolean>
|
||||
overrideMap: Map<string, boolean>,
|
||||
): boolean {
|
||||
if (!item) return false;
|
||||
const override = overrideMap.get(item.id);
|
||||
@@ -80,7 +80,7 @@ export function isFavoriteNow(item: Pick<MediaItem, "id" | "userData">): boolean
|
||||
*/
|
||||
export function retainFavorites<T extends Pick<MediaItem, "id" | "userData">>(
|
||||
items: T[],
|
||||
overrideMap: Map<string, boolean>
|
||||
overrideMap: Map<string, boolean>,
|
||||
): T[] {
|
||||
return items.filter((item) => resolveIsFavorite(item, overrideMap));
|
||||
}
|
||||
|
||||
+21
-18
@@ -3,10 +3,7 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "./auth";
|
||||
import {
|
||||
filterSupersededResumeItems,
|
||||
filterInProgressNextUpItems,
|
||||
} from "./continueWatchingFilter";
|
||||
import { filterSupersededResumeItems, filterInProgressNextUpItems } from "./continueWatchingFilter";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("HomeStore");
|
||||
@@ -45,7 +42,11 @@ function createHomeStore() {
|
||||
|
||||
async function loadHomeSections() {
|
||||
// Only show loading spinner when no data is available yet
|
||||
update(s => ({ ...s, isLoading: s.heroItems.length === 0 && s.latestItems.length === 0, error: null }));
|
||||
update((s) => ({
|
||||
...s,
|
||||
isLoading: s.heroItems.length === 0 && s.latestItems.length === 0,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
@@ -67,7 +68,9 @@ function createHomeStore() {
|
||||
]);
|
||||
|
||||
const valueOr = <T>(i: number, fallback: T): T =>
|
||||
settled[i].status === "fulfilled" ? (settled[i] as PromiseFulfilledResult<T>).value : fallback;
|
||||
settled[i].status === "fulfilled"
|
||||
? (settled[i] as PromiseFulfilledResult<T>).value
|
||||
: fallback;
|
||||
|
||||
const rawResume = valueOr(0, [] as typeof initialState.resumeItems);
|
||||
const rawNextUp = valueOr(1, [] as typeof initialState.nextUpItems);
|
||||
@@ -90,7 +93,7 @@ function createHomeStore() {
|
||||
// Use resume items or latest as hero items
|
||||
const hero = resume.length >= 3 ? resume.slice(0, 5) : latest.slice(0, 5);
|
||||
|
||||
update(s => ({
|
||||
update((s) => ({
|
||||
...s,
|
||||
heroItems: hero,
|
||||
resumeItems: resume,
|
||||
@@ -105,7 +108,7 @@ function createHomeStore() {
|
||||
}));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load home sections";
|
||||
update(s => ({ ...s, isLoading: false, error: message }));
|
||||
update((s) => ({ ...s, isLoading: false, error: message }));
|
||||
log.error("Failed to load home sections:", error);
|
||||
}
|
||||
}
|
||||
@@ -124,13 +127,13 @@ function createHomeStore() {
|
||||
export const home = createHomeStore();
|
||||
|
||||
// Derived stores for convenience
|
||||
export const heroItems = derived(home, $home => $home.heroItems);
|
||||
export const resumeItems = derived(home, $home => $home.resumeItems);
|
||||
export const nextUpItems = derived(home, $home => $home.nextUpItems);
|
||||
export const latestItems = derived(home, $home => $home.latestItems);
|
||||
export const recentlyPlayedAudio = derived(home, $home => $home.recentlyPlayedAudio);
|
||||
export const resumeMovies = derived(home, $home => $home.resumeMovies);
|
||||
export const favoriteMovies = derived(home, $home => $home.favoriteMovies);
|
||||
export const favoriteShows = derived(home, $home => $home.favoriteShows);
|
||||
export const favoriteMusic = derived(home, $home => $home.favoriteMusic);
|
||||
export const isHomeLoading = derived(home, $home => $home.isLoading);
|
||||
export const heroItems = derived(home, ($home) => $home.heroItems);
|
||||
export const resumeItems = derived(home, ($home) => $home.resumeItems);
|
||||
export const nextUpItems = derived(home, ($home) => $home.nextUpItems);
|
||||
export const latestItems = derived(home, ($home) => $home.latestItems);
|
||||
export const recentlyPlayedAudio = derived(home, ($home) => $home.recentlyPlayedAudio);
|
||||
export const resumeMovies = derived(home, ($home) => $home.resumeMovies);
|
||||
export const favoriteMovies = derived(home, ($home) => $home.favoriteMovies);
|
||||
export const favoriteShows = derived(home, ($home) => $home.favoriteShows);
|
||||
export const favoriteMusic = derived(home, ($home) => $home.favoriteMusic);
|
||||
export const isHomeLoading = derived(home, ($home) => $home.isLoading);
|
||||
|
||||
@@ -50,12 +50,7 @@ export {
|
||||
} from "./queue";
|
||||
|
||||
// Sessions store
|
||||
export {
|
||||
sessions,
|
||||
activeSessions,
|
||||
selectedSession,
|
||||
controllableSessions,
|
||||
} from "./sessions";
|
||||
export { sessions, activeSessions, selectedSession, controllableSessions } from "./sessions";
|
||||
|
||||
// Sleep timer store
|
||||
export {
|
||||
|
||||
@@ -115,7 +115,7 @@ function createLibraryStore() {
|
||||
|
||||
async function loadItems(
|
||||
parentId: string,
|
||||
options: { startIndex?: number; limit?: number; genres?: string[] } = {}
|
||||
options: { startIndex?: number; limit?: number; genres?: string[] } = {},
|
||||
) {
|
||||
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
|
||||
|
||||
@@ -206,7 +206,7 @@ function createLibraryStore() {
|
||||
const item = await repo.getItem(itemId);
|
||||
|
||||
log.debug(`loadItem(${itemId}): ${item.name} (${item.kind})`);
|
||||
log.debug(`- Has people? ${item.people ? `YES (${item.people.length})` : 'NO'}`);
|
||||
log.debug(`- Has people? ${item.people ? `YES (${item.people.length})` : "NO"}`);
|
||||
if (item.people && item.people.length > 0) {
|
||||
item.people.forEach((p, i) => {
|
||||
log.debug(` [${i}] ${p.name} (type: "${p.type}", id: "${p.id}")`);
|
||||
@@ -257,7 +257,7 @@ function createLibraryStore() {
|
||||
|
||||
// Add 10-second timeout to prevent indefinite hanging
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Search timeout - please try again")), 10000)
|
||||
setTimeout(() => reject(new Error("Search timeout - please try again")), 10000),
|
||||
);
|
||||
|
||||
// Phase 1: the command resolves with instant local-cache results. The
|
||||
@@ -267,10 +267,7 @@ function createLibraryStore() {
|
||||
// never names a Jellyfin item type in connection with search.
|
||||
const options: SearchOptions = { limit: 10000, scope };
|
||||
|
||||
const result = await Promise.race([
|
||||
repo.search(query, options, requestId),
|
||||
timeoutPromise
|
||||
]);
|
||||
const result = await Promise.race([repo.search(query, options, requestId), timeoutPromise]);
|
||||
|
||||
// Only apply if this is still the active query (a newer search may have
|
||||
// started while we awaited).
|
||||
|
||||
@@ -98,9 +98,7 @@ describe("library.search scoping", () => {
|
||||
// First search resolves *after* a newer one has already started; its
|
||||
// results must not clobber the fresher ones.
|
||||
let resolveFirst: (value: unknown) => void = () => {};
|
||||
searchMock.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (resolveFirst = resolve))
|
||||
);
|
||||
searchMock.mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve)));
|
||||
searchMock.mockResolvedValueOnce(result([{ id: "new", type: "Movie" }]));
|
||||
|
||||
const first = library.search("old", "all");
|
||||
|
||||
@@ -43,12 +43,16 @@ describe("lmsSync store", () => {
|
||||
|
||||
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: [] }]);
|
||||
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: [] }]);
|
||||
mockInvoke.mockResolvedValueOnce([
|
||||
{ masterMac: "M", masterName: "M", slaveMacs: ["A", "B"], slaveNames: [] },
|
||||
]);
|
||||
|
||||
await lmsSync.fuseZone("M", "B");
|
||||
|
||||
@@ -60,11 +64,15 @@ describe("lmsSync store", () => {
|
||||
});
|
||||
|
||||
it("decoupling a slave unsyncs just that player", async () => {
|
||||
mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]);
|
||||
mockInvoke.mockResolvedValueOnce([
|
||||
{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] },
|
||||
]);
|
||||
await lmsSync.refresh();
|
||||
|
||||
mockInvoke.mockResolvedValueOnce(undefined); // unsync
|
||||
mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: [], slaveNames: [] }]);
|
||||
mockInvoke.mockResolvedValueOnce([
|
||||
{ masterMac: "M", masterName: "M", slaveMacs: [], slaveNames: [] },
|
||||
]);
|
||||
|
||||
await lmsSync.decoupleZone("A");
|
||||
|
||||
@@ -72,7 +80,9 @@ describe("lmsSync store", () => {
|
||||
});
|
||||
|
||||
it("decoupling the master dissolves the whole group", async () => {
|
||||
mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]);
|
||||
mockInvoke.mockResolvedValueOnce([
|
||||
{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] },
|
||||
]);
|
||||
await lmsSync.refresh();
|
||||
|
||||
mockInvoke.mockResolvedValueOnce(undefined); // dissolve
|
||||
|
||||
@@ -51,7 +51,7 @@ function createMoviesStore() {
|
||||
!!(i.backdropImageTags && i.backdropImageTags.length > 0) || !!i.imageId;
|
||||
|
||||
async function loadSections(libraryId: string) {
|
||||
update(s => ({
|
||||
update((s) => ({
|
||||
...s,
|
||||
isLoading: s.continueWatching.length === 0 && s.recentlyAdded.length === 0,
|
||||
error: null,
|
||||
@@ -72,7 +72,7 @@ function createMoviesStore() {
|
||||
recursive: true,
|
||||
limit: SECTION_LIMIT,
|
||||
})
|
||||
.then(r => r.items)
|
||||
.then((r) => r.items)
|
||||
.catch(() => [] as MediaItem[]),
|
||||
]);
|
||||
|
||||
@@ -80,7 +80,7 @@ function createMoviesStore() {
|
||||
// additions, then random picks from across the library.
|
||||
const heroItems = buildHeroMix([resume, latest, surprise], hasArt);
|
||||
|
||||
update(s => ({
|
||||
update((s) => ({
|
||||
...s,
|
||||
continueWatching: resume,
|
||||
recentlyAdded: latest,
|
||||
@@ -93,7 +93,7 @@ function createMoviesStore() {
|
||||
loadGenreRows(libraryId);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load movie sections";
|
||||
update(s => ({ ...s, isLoading: false, error: message }));
|
||||
update((s) => ({ ...s, isLoading: false, error: message }));
|
||||
log.error("Failed to load movie sections:", error);
|
||||
}
|
||||
}
|
||||
@@ -126,15 +126,15 @@ function createMoviesStore() {
|
||||
log.warn(`Failed to load genre row "${genre.name}":`, e);
|
||||
return { id: genre.id, name: genre.name, items: [] };
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
const genreRows = rows
|
||||
.filter(row => row.items.length > 0)
|
||||
.filter((row) => row.items.length > 0)
|
||||
.sort((a, b) => b.items.length - a.items.length)
|
||||
.slice(0, MAX_GENRE_ROWS);
|
||||
|
||||
update(s => ({ ...s, genreRows }));
|
||||
update((s) => ({ ...s, genreRows }));
|
||||
} catch (e) {
|
||||
log.warn("Failed to load movie genre rows:", e);
|
||||
}
|
||||
@@ -153,5 +153,5 @@ function createMoviesStore() {
|
||||
|
||||
export const movies = createMoviesStore();
|
||||
|
||||
export const moviesHeroItems = derived(movies, $m => $m.heroItems);
|
||||
export const isMoviesLoading = derived(movies, $m => $m.isLoading);
|
||||
export const moviesHeroItems = derived(movies, ($m) => $m.heroItems);
|
||||
export const isMoviesLoading = derived(movies, ($m) => $m.isLoading);
|
||||
|
||||
+40
-40
@@ -60,7 +60,7 @@ function createMusicStore() {
|
||||
!!i.imageId || !!(i.backdropImageTags && i.backdropImageTags.length > 0);
|
||||
|
||||
async function loadSections(libraryId: string) {
|
||||
update(s => ({
|
||||
update((s) => ({
|
||||
...s,
|
||||
isLoading: s.recentlyPlayed.length === 0 && s.newlyAdded.length === 0,
|
||||
error: null,
|
||||
@@ -69,35 +69,37 @@ function createMusicStore() {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
|
||||
const [recentlyPlayed, newlyAdded, playlistsResult, rediscover, surprise] = await Promise.all([
|
||||
repo.getRecentlyPlayedAudio(SECTION_LIMIT),
|
||||
repo.getItems(libraryId, {
|
||||
includeItemTypes: ["MusicAlbum"],
|
||||
sortBy: "DateCreated",
|
||||
sortOrder: "Descending",
|
||||
recursive: true,
|
||||
limit: SECTION_LIMIT,
|
||||
}),
|
||||
repo.getItems(libraryId, {
|
||||
includeItemTypes: ["Playlist"],
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
recursive: true,
|
||||
limit: SECTION_LIMIT,
|
||||
}),
|
||||
repo.getRediscoverAlbums(libraryId, SECTION_LIMIT),
|
||||
// Random pool so the hero rotation changes between visits (SortBy=Random
|
||||
// shuffles server-side online, and via SQLite RANDOM() offline).
|
||||
repo
|
||||
.getItems(libraryId, {
|
||||
const [recentlyPlayed, newlyAdded, playlistsResult, rediscover, surprise] = await Promise.all(
|
||||
[
|
||||
repo.getRecentlyPlayedAudio(SECTION_LIMIT),
|
||||
repo.getItems(libraryId, {
|
||||
includeItemTypes: ["MusicAlbum"],
|
||||
sortBy: "Random",
|
||||
sortBy: "DateCreated",
|
||||
sortOrder: "Descending",
|
||||
recursive: true,
|
||||
limit: SECTION_LIMIT,
|
||||
})
|
||||
.then(r => r.items)
|
||||
.catch(() => [] as MediaItem[]),
|
||||
]);
|
||||
}),
|
||||
repo.getItems(libraryId, {
|
||||
includeItemTypes: ["Playlist"],
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
recursive: true,
|
||||
limit: SECTION_LIMIT,
|
||||
}),
|
||||
repo.getRediscoverAlbums(libraryId, SECTION_LIMIT),
|
||||
// Random pool so the hero rotation changes between visits (SortBy=Random
|
||||
// shuffles server-side online, and via SQLite RANDOM() offline).
|
||||
repo
|
||||
.getItems(libraryId, {
|
||||
includeItemTypes: ["MusicAlbum"],
|
||||
sortBy: "Random",
|
||||
recursive: true,
|
||||
limit: SECTION_LIMIT,
|
||||
})
|
||||
.then((r) => r.items)
|
||||
.catch(() => [] as MediaItem[]),
|
||||
],
|
||||
);
|
||||
|
||||
// Nothing is filtered here: folders the user chose to hide are already
|
||||
// gone, dropped by the repository layer that answered these queries.
|
||||
@@ -107,7 +109,7 @@ function createMusicStore() {
|
||||
// random albums from across the library.
|
||||
const heroItems = buildHeroMix([recentlyPlayed, rediscover, surprise], hasArt);
|
||||
|
||||
update(s => ({
|
||||
update((s) => ({
|
||||
...s,
|
||||
recentlyPlayed,
|
||||
newlyAdded: newlyAdded.items,
|
||||
@@ -122,7 +124,7 @@ function createMusicStore() {
|
||||
loadGenreRows(libraryId);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load music sections";
|
||||
update(s => ({ ...s, isLoading: false, error: message }));
|
||||
update((s) => ({ ...s, isLoading: false, error: message }));
|
||||
log.error("Failed to load music sections:", error);
|
||||
}
|
||||
}
|
||||
@@ -172,19 +174,17 @@ function createMusicStore() {
|
||||
// Treat that as "no usable counts" and fall through to the probe path,
|
||||
// which ranks by genres' real album counts instead.
|
||||
const positiveCounts = genres
|
||||
.map(g => g.albumCount)
|
||||
.map((g) => g.albumCount)
|
||||
.filter((c): c is number => c != null && c > 0);
|
||||
const hasUsefulCounts = new Set(positiveCounts).size > 1;
|
||||
|
||||
let genreRows: GenreRow[];
|
||||
if (hasUsefulCounts) {
|
||||
// Rank by reported count, pick a diverse subset, then fetch only those.
|
||||
const ranked = [...genres].sort(
|
||||
(a, b) => (b.albumCount ?? 0) - (a.albumCount ?? 0)
|
||||
);
|
||||
const ranked = [...genres].sort((a, b) => (b.albumCount ?? 0) - (a.albumCount ?? 0));
|
||||
const chosen = selectDiverseGenres(ranked, MAX_GENRE_ROWS);
|
||||
genreRows = (await Promise.all(chosen.map(g => loadGenreRow(libraryId, g)))).filter(
|
||||
row => row.items.length > 0
|
||||
genreRows = (await Promise.all(chosen.map((g) => loadGenreRow(libraryId, g)))).filter(
|
||||
(row) => row.items.length > 0,
|
||||
);
|
||||
} else {
|
||||
// No usable counts (offline, a server that ignores Fields=ItemCounts,
|
||||
@@ -194,14 +194,14 @@ function createMusicStore() {
|
||||
// list instead, so the probe pool spans A→Z; then drop empties, rank
|
||||
// by what came back, and pick a diverse subset.
|
||||
const probed = sampleAcross(genres, MAX_GENRES_PROBED);
|
||||
const rows = await Promise.all(probed.map(g => loadGenreRow(libraryId, g)));
|
||||
const rows = await Promise.all(probed.map((g) => loadGenreRow(libraryId, g)));
|
||||
const populated = rows
|
||||
.filter(row => row.items.length > 0)
|
||||
.filter((row) => row.items.length > 0)
|
||||
.sort((a, b) => b.items.length - a.items.length);
|
||||
genreRows = selectDiverseGenres(populated, MAX_GENRE_ROWS);
|
||||
}
|
||||
|
||||
update(s => ({ ...s, genreRows }));
|
||||
update((s) => ({ ...s, genreRows }));
|
||||
} catch (e) {
|
||||
log.warn("Failed to load music genre rows:", e);
|
||||
}
|
||||
@@ -220,5 +220,5 @@ function createMusicStore() {
|
||||
|
||||
export const music = createMusicStore();
|
||||
|
||||
export const musicHeroItems = derived(music, $m => $m.heroItems);
|
||||
export const isMusicLoading = derived(music, $m => $m.isLoading);
|
||||
export const musicHeroItems = derived(music, ($m) => $m.heroItems);
|
||||
export const isMusicLoading = derived(music, ($m) => $m.isLoading);
|
||||
|
||||
@@ -47,7 +47,7 @@ function createNextEpisodeStore() {
|
||||
currentEpisode: MediaItem,
|
||||
nextEpisode: MediaItem,
|
||||
countdownSeconds: number,
|
||||
autoPlayEnabled: boolean
|
||||
autoPlayEnabled: boolean,
|
||||
): void {
|
||||
update((s) => ({
|
||||
...s,
|
||||
@@ -105,4 +105,7 @@ export const currentEpisodeItem = derived(nextEpisode, ($ne) => $ne.currentEpiso
|
||||
export const countdownSeconds = derived(nextEpisode, ($ne) => $ne.countdownSeconds);
|
||||
export const initialCountdownSeconds = derived(nextEpisode, ($ne) => $ne.initialCountdownSeconds);
|
||||
export const isAutoPlayEnabled = derived(nextEpisode, ($ne) => $ne.autoPlayEnabled);
|
||||
export const isCountdownActive = derived(nextEpisode, ($ne) => $ne.isVisible && $ne.countdownSeconds > 0);
|
||||
export const isCountdownActive = derived(
|
||||
nextEpisode,
|
||||
($ne) => $ne.isVisible && $ne.countdownSeconds > 0,
|
||||
);
|
||||
|
||||
@@ -387,7 +387,6 @@ describe("playbackMode store", () => {
|
||||
expect(state.remoteSessionId).toBeNull();
|
||||
expect(mockSelectSession).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("transfer reconciles to Rust on completion", () => {
|
||||
|
||||
@@ -107,7 +107,12 @@ function createPlaybackModeStore() {
|
||||
|
||||
// Rust handles everything - just wait for it to complete
|
||||
// It includes its own 5-second timeout for track loading
|
||||
log.debug("About to invoke playback_mode_transfer_to_remote with sessionId:", sessionId, "position:", positionOverride);
|
||||
log.debug(
|
||||
"About to invoke playback_mode_transfer_to_remote with sessionId:",
|
||||
sessionId,
|
||||
"position:",
|
||||
positionOverride,
|
||||
);
|
||||
await commands.playbackModeTransferToRemote(sessionId ?? "", positionOverride);
|
||||
log.debug("Invoke completed successfully");
|
||||
|
||||
@@ -298,9 +303,7 @@ function createPlaybackModeStore() {
|
||||
const currentState = get({ subscribe });
|
||||
if (currentState.mode === "remote") {
|
||||
log.debug("Lockscreen requested disconnect; transferring to local");
|
||||
transferToLocal().catch((e) =>
|
||||
log.error("Lockscreen-triggered transfer failed:", e),
|
||||
);
|
||||
transferToLocal().catch((e) => log.error("Lockscreen-triggered transfer failed:", e));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -317,8 +320,7 @@ function createPlaybackModeStore() {
|
||||
return;
|
||||
}
|
||||
const mode = event.payload.mode as PlaybackMode;
|
||||
const remoteSessionId =
|
||||
mode === "remote" ? event.payload.session_id ?? null : null;
|
||||
const remoteSessionId = mode === "remote" ? (event.payload.session_id ?? null) : null;
|
||||
|
||||
// Ignore no-op re-broadcasts. The backend re-emits on every set_mode, and
|
||||
// local playback drives set_mode("local") from BOTH the frontend
|
||||
@@ -327,10 +329,7 @@ function createPlaybackModeStore() {
|
||||
// one, deselecting the remote session mid-cast and tripping the
|
||||
// disconnect-to-idle watchdog (breaking the lockscreen card, remote
|
||||
// volume, and — via the resulting mode flap — local audio).
|
||||
if (
|
||||
currentState.mode === mode &&
|
||||
currentState.remoteSessionId === remoteSessionId
|
||||
) {
|
||||
if (currentState.mode === mode && currentState.remoteSessionId === remoteSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -352,8 +351,16 @@ function createPlaybackModeStore() {
|
||||
|
||||
// If we're in remote mode but session is gone or lost control capability
|
||||
// Don't interfere during an active transfer (we intentionally clear the session)
|
||||
if (currentState.mode === "remote" && currentState.remoteSessionId && !currentState.isTransferring) {
|
||||
if (!session || session.id !== currentState.remoteSessionId || !session.supportsMediaControl) {
|
||||
if (
|
||||
currentState.mode === "remote" &&
|
||||
currentState.remoteSessionId &&
|
||||
!currentState.isTransferring
|
||||
) {
|
||||
if (
|
||||
!session ||
|
||||
session.id !== currentState.remoteSessionId ||
|
||||
!session.supportsMediaControl
|
||||
) {
|
||||
consecutiveMisses++;
|
||||
log.warn(`Remote session miss ${consecutiveMisses}/${DISCONNECT_THRESHOLD}`);
|
||||
|
||||
|
||||
+18
-18
@@ -93,7 +93,7 @@ function createPlayerStore() {
|
||||
...s.state,
|
||||
position,
|
||||
// Update duration if provided and valid
|
||||
duration: duration !== undefined && duration > 0 ? duration : s.state.duration
|
||||
duration: duration !== undefined && duration > 0 ? duration : s.state.duration,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -174,10 +174,13 @@ function nowPlayingToMediaItem(npi: NowPlayingItem): MediaItem {
|
||||
// (Type, runTimeTicks, primaryImageTag). Map it onto the neutral MediaItem the
|
||||
// UI consumes. Only the coarse audio/video split matters here for display.
|
||||
const kind: MediaKind =
|
||||
npi.Type === "Movie" ? "movie" :
|
||||
npi.Type === "Episode" ? "episode" :
|
||||
npi.Type === "MusicAlbum" ? "album" :
|
||||
"track";
|
||||
npi.Type === "Movie"
|
||||
? "movie"
|
||||
: npi.Type === "Episode"
|
||||
? "episode"
|
||||
: npi.Type === "MusicAlbum"
|
||||
? "album"
|
||||
: "track";
|
||||
return {
|
||||
id: npi.id ?? "",
|
||||
name: npi.name ?? "",
|
||||
@@ -194,15 +197,12 @@ function nowPlayingToMediaItem(npi: NowPlayingItem): MediaItem {
|
||||
export const mergedMedia = derived<
|
||||
[typeof isRemoteMode, typeof selectedSession, typeof currentMedia],
|
||||
MediaItem | null
|
||||
>(
|
||||
[isRemoteMode, selectedSession, currentMedia],
|
||||
([$isRemote, $session, $local]) => {
|
||||
if ($isRemote && $session?.nowPlayingItem) {
|
||||
return nowPlayingToMediaItem($session.nowPlayingItem);
|
||||
}
|
||||
return $local ?? null;
|
||||
>([isRemoteMode, selectedSession, currentMedia], ([$isRemote, $session, $local]) => {
|
||||
if ($isRemote && $session?.nowPlayingItem) {
|
||||
return nowPlayingToMediaItem($session.nowPlayingItem);
|
||||
}
|
||||
);
|
||||
return $local ?? null;
|
||||
});
|
||||
|
||||
/**
|
||||
* Merged isPlaying state - prefers remote session when in remote mode
|
||||
@@ -214,7 +214,7 @@ export const mergedIsPlaying = derived(
|
||||
return !$session.playState.isPaused;
|
||||
}
|
||||
return $localIsPlaying;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -227,7 +227,7 @@ export const mergedPosition = derived(
|
||||
return ticksToSeconds($session.playState.positionTicks ?? 0);
|
||||
}
|
||||
return $localPosition;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -240,7 +240,7 @@ export const mergedDuration = derived(
|
||||
return ticksToSeconds($session.nowPlayingItem.runTimeTicks);
|
||||
}
|
||||
return $localDuration;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -255,7 +255,7 @@ export const mergedVolume = derived(
|
||||
return ($session.playState.volumeLevel ?? 100) / 100;
|
||||
}
|
||||
return $localVolume;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -317,5 +317,5 @@ export const shouldShowAudioMiniPlayer = derived(
|
||||
|
||||
// playing / paused / loading / seeking — audio is active, show the bar.
|
||||
return true;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -192,7 +192,7 @@ export const queue = createQueueStore();
|
||||
export const queueItems = derived(queue, ($q) => $q.items);
|
||||
export const currentQueueIndex = derived(queue, ($q) => $q.currentIndex);
|
||||
export const currentQueueItem = derived(queue, ($q) =>
|
||||
$q.currentIndex !== null ? $q.items[$q.currentIndex] : null
|
||||
$q.currentIndex !== null ? $q.items[$q.currentIndex] : null,
|
||||
);
|
||||
export const isShuffle = derived(queue, ($q) => $q.shuffle);
|
||||
export const repeatMode = derived(queue, ($q) => $q.repeat);
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("searchGroupOrder", () => {
|
||||
it("loads a stored order", async () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify(["episodes", "shows", "movies", "songs", "albums", "artists", "people"])
|
||||
JSON.stringify(["episodes", "shows", "movies", "songs", "albums", "artists", "people"]),
|
||||
);
|
||||
const { searchGroupOrder } = await import("./searchGroupOrder");
|
||||
expect(get(searchGroupOrder)).toEqual([
|
||||
@@ -97,15 +97,7 @@ describe("searchGroupOrder", () => {
|
||||
// Default is shows, episodes, movies, songs, … — move movies up one.
|
||||
searchGroupOrder.move("movies", -1);
|
||||
|
||||
const expected = [
|
||||
"shows",
|
||||
"movies",
|
||||
"episodes",
|
||||
"songs",
|
||||
"albums",
|
||||
"artists",
|
||||
"people",
|
||||
];
|
||||
const expected = ["shows", "movies", "episodes", "songs", "albums", "artists", "people"];
|
||||
expect(get(searchGroupOrder)).toEqual(expected);
|
||||
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual(expected);
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ describe("sessions store", () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Ensure window.setInterval and window.clearInterval are available
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
global.window = window as any;
|
||||
}
|
||||
});
|
||||
|
||||
+27
-22
@@ -33,7 +33,9 @@ function createSessionsStore() {
|
||||
const sessions = event.payload.sessions as unknown as Session[];
|
||||
log.debug(`Received ${sessions.length} sessions from backend`);
|
||||
sessions.forEach((s, i) => {
|
||||
log.debug(`Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`);
|
||||
log.debug(
|
||||
`Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`,
|
||||
);
|
||||
});
|
||||
update((s) => ({
|
||||
...s,
|
||||
@@ -55,7 +57,9 @@ function createSessionsStore() {
|
||||
|
||||
log.debug(`Manual refresh returned ${sessions.length} sessions`);
|
||||
sessions.forEach((s, i) => {
|
||||
log.debug(`Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`);
|
||||
log.debug(
|
||||
`Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`,
|
||||
);
|
||||
});
|
||||
|
||||
update((s) => ({
|
||||
@@ -76,7 +80,6 @@ function createSessionsStore() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Select a session for control
|
||||
*/
|
||||
@@ -140,7 +143,10 @@ function createSessionsStore() {
|
||||
/**
|
||||
* Seek to position (in ticks)
|
||||
*/
|
||||
async function sendSeek(sessionId: string | null | undefined, positionTicks: number): Promise<void> {
|
||||
async function sendSeek(
|
||||
sessionId: string | null | undefined,
|
||||
positionTicks: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await commands.remoteSessionSeek(sessionId ?? "", positionTicks);
|
||||
// Don't refresh immediately for seek to avoid UI lag
|
||||
@@ -182,7 +188,7 @@ function createSessionsStore() {
|
||||
async function playOnSession(
|
||||
sessionId: string | null | undefined,
|
||||
itemIds: string[],
|
||||
startIndex = 0
|
||||
startIndex = 0,
|
||||
): Promise<void> {
|
||||
log.debug("========== playOnSession called ==========");
|
||||
log.debug("sessionId:", sessionId);
|
||||
@@ -224,9 +230,8 @@ export const sessions = createSessionsStore();
|
||||
/**
|
||||
* Sessions that are currently playing media
|
||||
*/
|
||||
export const activeSessions = derived(
|
||||
sessions,
|
||||
($sessions) => $sessions.sessions.filter((s) => s.nowPlayingItem !== null)
|
||||
export const activeSessions = derived(sessions, ($sessions) =>
|
||||
$sessions.sessions.filter((s) => s.nowPlayingItem !== null),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -234,22 +239,22 @@ export const activeSessions = derived(
|
||||
*/
|
||||
export const selectedSession = derived(
|
||||
sessions,
|
||||
($sessions) =>
|
||||
$sessions.sessions.find((s) => s.id === $sessions.selectedSessionId) ?? null
|
||||
($sessions) => $sessions.sessions.find((s) => s.id === $sessions.selectedSessionId) ?? null,
|
||||
);
|
||||
|
||||
/**
|
||||
* Controllable sessions (support remote control)
|
||||
*/
|
||||
export const controllableSessions = derived(
|
||||
sessions,
|
||||
($sessions) => {
|
||||
const controllable = $sessions.sessions.filter((s) => s.supportsRemoteControl);
|
||||
log.debug(`Filtering ${$sessions.sessions.length} total sessions, ${controllable.length} are controllable`);
|
||||
$sessions.sessions.forEach((s, i) => {
|
||||
const status = s.supportsRemoteControl ? "✓ CONTROLLABLE" : "✗ NOT CONTROLLABLE";
|
||||
log.debug(` ${status}: ${s.deviceName} (id=${s.id}, supportsRemoteControl=${s.supportsRemoteControl})`);
|
||||
});
|
||||
return controllable;
|
||||
}
|
||||
);
|
||||
export const controllableSessions = derived(sessions, ($sessions) => {
|
||||
const controllable = $sessions.sessions.filter((s) => s.supportsRemoteControl);
|
||||
log.debug(
|
||||
`Filtering ${$sessions.sessions.length} total sessions, ${controllable.length} are controllable`,
|
||||
);
|
||||
$sessions.sessions.forEach((s, i) => {
|
||||
const status = s.supportsRemoteControl ? "✓ CONTROLLABLE" : "✗ NOT CONTROLLABLE";
|
||||
log.debug(
|
||||
` ${status}: ${s.deviceName} (id=${s.id}, supportsRemoteControl=${s.supportsRemoteControl})`,
|
||||
);
|
||||
});
|
||||
return controllable;
|
||||
});
|
||||
|
||||
@@ -69,16 +69,10 @@ export const sleepTimerExpiredSignal = writable(0);
|
||||
// Derived stores for convenient access
|
||||
export const sleepTimerMode = derived(sleepTimer, ($s) => $s.mode);
|
||||
|
||||
export const sleepTimerActive = derived(
|
||||
sleepTimer,
|
||||
($s) => $s.mode.kind !== "off"
|
||||
);
|
||||
export const sleepTimerActive = derived(sleepTimer, ($s) => $s.mode.kind !== "off");
|
||||
|
||||
export const sleepTimerRemainingSeconds = derived(
|
||||
sleepTimer,
|
||||
($s) => $s.remainingSeconds
|
||||
);
|
||||
export const sleepTimerRemainingSeconds = derived(sleepTimer, ($s) => $s.remainingSeconds);
|
||||
|
||||
export const sleepTimerRemainingEpisodes = derived(sleepTimer, ($s) =>
|
||||
$s.mode.kind === "episodes" ? $s.mode.remaining : 0
|
||||
$s.mode.kind === "episodes" ? $s.mode.remaining : 0,
|
||||
);
|
||||
|
||||
+12
-15
@@ -5,10 +5,7 @@ import { writable, derived } from "svelte/store";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "./auth";
|
||||
import { buildHeroMix } from "$lib/utils/heroMix";
|
||||
import {
|
||||
filterSupersededResumeItems,
|
||||
filterInProgressNextUpItems,
|
||||
} from "./continueWatchingFilter";
|
||||
import { filterSupersededResumeItems, filterInProgressNextUpItems } from "./continueWatchingFilter";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("TvStore");
|
||||
@@ -60,7 +57,7 @@ function createTvStore() {
|
||||
!!i.imageId;
|
||||
|
||||
async function loadSections(libraryId: string) {
|
||||
update(s => ({
|
||||
update((s) => ({
|
||||
...s,
|
||||
isLoading: s.continueWatching.length === 0 && s.recentlyAdded.length === 0,
|
||||
error: null,
|
||||
@@ -82,7 +79,7 @@ function createTvStore() {
|
||||
recursive: true,
|
||||
limit: SECTION_LIMIT,
|
||||
})
|
||||
.then(r => r.items)
|
||||
.then((r) => r.items)
|
||||
.catch(() => [] as MediaItem[]),
|
||||
]);
|
||||
|
||||
@@ -91,8 +88,8 @@ function createTvStore() {
|
||||
// Then drop episodes the user has moved past — a stale partial position
|
||||
// behind the series' Next Up entry isn't something to continue.
|
||||
const continueWatching = filterSupersededResumeItems(
|
||||
resume.filter(i => i.kind === "episode" || i.kind === "movie"),
|
||||
rawNextUp
|
||||
resume.filter((i) => i.kind === "episode" || i.kind === "movie"),
|
||||
rawNextUp,
|
||||
);
|
||||
// And drop from Next Up the episodes that are already under way — those
|
||||
// are Continue Watching's, or the two rows show the same cards.
|
||||
@@ -102,7 +99,7 @@ function createTvStore() {
|
||||
// recent additions, and random series from across the library.
|
||||
const heroItems = buildHeroMix([continueWatching, nextUp, latest, surprise], hasArt);
|
||||
|
||||
update(s => ({
|
||||
update((s) => ({
|
||||
...s,
|
||||
continueWatching,
|
||||
nextUp,
|
||||
@@ -116,7 +113,7 @@ function createTvStore() {
|
||||
loadGenreRows(libraryId);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load TV sections";
|
||||
update(s => ({ ...s, isLoading: false, error: message }));
|
||||
update((s) => ({ ...s, isLoading: false, error: message }));
|
||||
log.error("Failed to load TV sections:", error);
|
||||
}
|
||||
}
|
||||
@@ -149,15 +146,15 @@ function createTvStore() {
|
||||
log.warn(`Failed to load genre row "${genre.name}":`, e);
|
||||
return { id: genre.id, name: genre.name, items: [] };
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
const genreRows = rows
|
||||
.filter(row => row.items.length > 0)
|
||||
.filter((row) => row.items.length > 0)
|
||||
.sort((a, b) => b.items.length - a.items.length)
|
||||
.slice(0, MAX_GENRE_ROWS);
|
||||
|
||||
update(s => ({ ...s, genreRows }));
|
||||
update((s) => ({ ...s, genreRows }));
|
||||
} catch (e) {
|
||||
log.warn("Failed to load TV genre rows:", e);
|
||||
}
|
||||
@@ -176,5 +173,5 @@ function createTvStore() {
|
||||
|
||||
export const tv = createTvStore();
|
||||
|
||||
export const tvHeroItems = derived(tv, $t => $t.heroItems);
|
||||
export const isTvLoading = derived(tv, $t => $t.isLoading);
|
||||
export const tvHeroItems = derived(tv, ($t) => $t.heroItems);
|
||||
export const isTvLoading = derived(tv, ($t) => $t.isLoading);
|
||||
|
||||
Reference in New Issue
Block a user