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:
2026-08-21 17:41:44 +02:00
parent d095e1f410
commit ad48d89dfe
199 changed files with 4698 additions and 3453 deletions
+4 -10
View File
@@ -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 -1
View File
@@ -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);
+8 -12
View File
@@ -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
View File
@@ -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);
+5 -6
View File
@@ -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);
+6 -12
View File
@@ -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", () => {
+5 -5
View File
@@ -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));
}
+104 -46
View File
@@ -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",
);
});
});
});
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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
View File
@@ -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);
+1 -6
View File
@@ -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 {
+4 -7
View File
@@ -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).
+1 -3
View File
@@ -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");
+15 -5
View File
@@ -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
+9 -9
View File
@@ -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
View File
@@ -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);
+5 -2
View File
@@ -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,
);
-1
View File
@@ -387,7 +387,6 @@ describe("playbackMode store", () => {
expect(state.remoteSessionId).toBeNull();
expect(mockSelectSession).toHaveBeenCalledWith(null);
});
});
describe("transfer reconciles to Rust on completion", () => {
+19 -12
View File
@@ -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
View File
@@ -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;
}
},
);
+1 -1
View File
@@ -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);
+2 -10
View File
@@ -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);
+1 -1
View File
@@ -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
View File
@@ -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;
});
+3 -9
View File
@@ -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
View File
@@ -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);