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 -12
View File
@@ -51,9 +51,7 @@ describe("favorites service", () => {
await toggleFavorite("item-123", false);
const call = invokeSpy.mock.calls.find(
(c) => c[0] === "storage_toggle_favorite"
);
const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_toggle_favorite");
expect(call).toBeDefined();
expect(call![1]).toHaveProperty("itemId", "item-123");
expect(call![1]).toHaveProperty("isFavorite", true);
@@ -65,9 +63,7 @@ describe("favorites service", () => {
await toggleFavorite("item-123", false);
const call = invokeSpy.mock.calls.find(
(c) => c[0] === "storage_toggle_favorite"
);
const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_toggle_favorite");
expect(call![1]).toHaveProperty("userId", "user-123");
});
@@ -105,9 +101,7 @@ describe("favorites service", () => {
await toggleFavorite("item-123", false);
const markSyncedCall = invokeSpy.mock.calls.find(
(c) => c[0] === "storage_mark_synced"
);
const markSyncedCall = invokeSpy.mock.calls.find((c) => c[0] === "storage_mark_synced");
expect(markSyncedCall).toBeDefined();
expect(markSyncedCall![1]).toHaveProperty("itemId", "item-123");
});
@@ -117,9 +111,7 @@ describe("favorites service", () => {
const authModule = vi.mocked(auth);
authModule.getUserId = vi.fn(() => null);
await expect(toggleFavorite("item-123", false)).rejects.toThrow(
"Not authenticated"
);
await expect(toggleFavorite("item-123", false)).rejects.toThrow("Not authenticated");
});
it("should handle server sync failure gracefully", async () => {
+1 -4
View File
@@ -23,10 +23,7 @@ const log = createLogger("Favorites");
* @returns The new favorite status
* @throws Error if not authenticated or database update fails
*/
export async function toggleFavorite(
itemId: string,
currentIsFavorite: boolean
): Promise<boolean> {
export async function toggleFavorite(itemId: string, currentIsFavorite: boolean): Promise<boolean> {
const userId = auth.getUserId();
if (!userId) {
throw new Error("Not authenticated");
+11 -29
View File
@@ -52,34 +52,22 @@ describe("image cache service", () => {
describe("getCachedImageUrl", () => {
it("should build server URL with default image type", async () => {
const url = await getCachedImageUrl(
"http://server.local:8096",
"item-123"
);
const url = await getCachedImageUrl("http://server.local:8096", "item-123");
expect(url).toContain("http://server.local:8096/Items/item-123/Images/Primary");
});
it("should build server URL with custom image type", async () => {
const url = await getCachedImageUrl(
"http://server.local:8096",
"item-123",
"Backdrop"
);
const url = await getCachedImageUrl("http://server.local:8096", "item-123", "Backdrop");
expect(url).toContain("Backdrop");
});
it("should include image options in URL", async () => {
const url = await getCachedImageUrl(
"http://server.local:8096",
"item-123",
"Primary",
{
maxWidth: 300,
maxHeight: 400,
quality: 90,
tag: "abc123",
}
);
const url = await getCachedImageUrl("http://server.local:8096", "item-123", "Primary", {
maxWidth: 300,
maxHeight: 400,
quality: 90,
tag: "abc123",
});
expect(url).toContain("maxWidth=300");
expect(url).toContain("maxHeight=400");
expect(url).toContain("quality=90");
@@ -92,9 +80,7 @@ describe("image cache service", () => {
await getCachedImageUrl("http://server.local:8096", "item-123");
const saveCall = invokeSpy.mock.calls.find(
(call) => call[0] === "thumbnail_save"
);
const saveCall = invokeSpy.mock.calls.find((call) => call[0] === "thumbnail_save");
expect(saveCall).toBeDefined();
expect(saveCall![1]).toHaveProperty("itemId", "item-123");
expect(saveCall![1]).toHaveProperty("imageType", "Primary");
@@ -117,9 +103,7 @@ describe("image cache service", () => {
const { invoke } = await import("@tauri-apps/api/core");
const invokeSpy = vi.mocked(invoke);
const setLimitCall = invokeSpy.mock.calls.find(
(call) => call[0] === "thumbnail_set_limit"
);
const setLimitCall = invokeSpy.mock.calls.find((call) => call[0] === "thumbnail_set_limit");
expect(setLimitCall).toBeDefined();
expect(setLimitCall![1]).toHaveProperty("limitBytes", limit);
});
@@ -139,9 +123,7 @@ describe("image cache service", () => {
const { invoke } = await import("@tauri-apps/api/core");
const invokeSpy = vi.mocked(invoke);
const deleteCall = invokeSpy.mock.calls.find(
(call) => call[0] === "thumbnail_delete_item"
);
const deleteCall = invokeSpy.mock.calls.find((call) => call[0] === "thumbnail_delete_item");
expect(deleteCall).toBeDefined();
expect(deleteCall![1]).toHaveProperty("itemId", "item-456");
});
+2 -3
View File
@@ -35,7 +35,7 @@ export async function getCachedImageUrl(
maxHeight?: number;
quality?: number;
tag?: string;
} = {}
} = {},
): Promise<string> {
const tag = options.tag || "default";
@@ -114,8 +114,7 @@ export async function deleteItemCache(itemId: string): Promise<void> {
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
+112 -112
View File
@@ -4,153 +4,153 @@
* TRACES: UR-053 | DR-074 | UT-066
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const setNetworkState = vi.fn();
const getDownloadsAllowed = vi.fn();
vi.mock('$lib/api/bindings', () => ({
commands: {
setNetworkState: (...args: unknown[]) => setNetworkState(...args),
getDownloadsAllowed: () => getDownloadsAllowed()
}
vi.mock("$lib/api/bindings", () => ({
commands: {
setNetworkState: (...args: unknown[]) => setNetworkState(...args),
getDownloadsAllowed: () => getDownloadsAllowed(),
},
}));
import {
isNetworkDetectionSupported,
reportNetworkState,
startNetworkReporting,
areDownloadsAllowed
} from './networkType';
isNetworkDetectionSupported,
reportNetworkState,
startNetworkReporting,
areDownloadsAllowed,
} from "./networkType";
/** Install a fake Android bridge on window. */
function installBridge(overrides: Partial<Record<string, unknown>> = {}) {
const bridge = {
currentType: vi.fn(() => 'wifi'),
isUnmetered: vi.fn(() => true),
isAcceptable: vi.fn(() => true),
isSupported: vi.fn(() => true),
...overrides
};
(window as unknown as Record<string, unknown>).AndroidNetworkType = bridge;
return bridge;
const bridge = {
currentType: vi.fn(() => "wifi"),
isUnmetered: vi.fn(() => true),
isAcceptable: vi.fn(() => true),
isSupported: vi.fn(() => true),
...overrides,
};
(window as unknown as Record<string, unknown>).AndroidNetworkType = bridge;
return bridge;
}
function removeBridge() {
delete (window as unknown as Record<string, unknown>).AndroidNetworkType;
delete (window as unknown as Record<string, unknown>).AndroidNetworkType;
}
describe('networkType service', () => {
beforeEach(() => {
vi.clearAllMocks();
setNetworkState.mockResolvedValue(null);
getDownloadsAllowed.mockResolvedValue(true);
removeBridge();
});
describe("networkType service", () => {
beforeEach(() => {
vi.clearAllMocks();
setNetworkState.mockResolvedValue(null);
getDownloadsAllowed.mockResolvedValue(true);
removeBridge();
});
afterEach(() => {
removeBridge();
});
afterEach(() => {
removeBridge();
});
describe('isNetworkDetectionSupported', () => {
it('is false with no Android bridge (desktop)', () => {
expect(isNetworkDetectionSupported()).toBe(false);
});
describe("isNetworkDetectionSupported", () => {
it("is false with no Android bridge (desktop)", () => {
expect(isNetworkDetectionSupported()).toBe(false);
});
it('is true when the Android bridge is present', () => {
installBridge();
expect(isNetworkDetectionSupported()).toBe(true);
});
it("is true when the Android bridge is present", () => {
installBridge();
expect(isNetworkDetectionSupported()).toBe(true);
});
it('is false when the bridge throws', () => {
installBridge({
isSupported: vi.fn(() => {
throw new Error('bridge exploded');
})
});
expect(isNetworkDetectionSupported()).toBe(false);
});
});
it("is false when the bridge throws", () => {
installBridge({
isSupported: vi.fn(() => {
throw new Error("bridge exploded");
}),
});
expect(isNetworkDetectionSupported()).toBe(false);
});
});
describe('reportNetworkState', () => {
it('does not call the backend on desktop', async () => {
await reportNetworkState();
expect(setNetworkState).not.toHaveBeenCalled();
});
describe("reportNetworkState", () => {
it("does not call the backend on desktop", async () => {
await reportNetworkState();
expect(setNetworkState).not.toHaveBeenCalled();
});
it('reports transport and metered-ness from the bridge', async () => {
installBridge({
currentType: vi.fn(() => 'cellular'),
isUnmetered: vi.fn(() => false)
});
it("reports transport and metered-ness from the bridge", async () => {
installBridge({
currentType: vi.fn(() => "cellular"),
isUnmetered: vi.fn(() => false),
});
await reportNetworkState();
await reportNetworkState();
expect(setNetworkState).toHaveBeenCalledWith({
networkType: 'cellular',
unmetered: false
});
});
expect(setNetworkState).toHaveBeenCalledWith({
networkType: "cellular",
unmetered: false,
});
});
it('reports metered WiFi as WiFi-but-metered, not as unmetered', async () => {
// A phone hotspot: WiFi transport, metered connection.
installBridge({
currentType: vi.fn(() => 'wifi'),
isUnmetered: vi.fn(() => false)
});
it("reports metered WiFi as WiFi-but-metered, not as unmetered", async () => {
// A phone hotspot: WiFi transport, metered connection.
installBridge({
currentType: vi.fn(() => "wifi"),
isUnmetered: vi.fn(() => false),
});
await reportNetworkState();
await reportNetworkState();
expect(setNetworkState).toHaveBeenCalledWith({
networkType: 'wifi',
unmetered: false
});
});
expect(setNetworkState).toHaveBeenCalledWith({
networkType: "wifi",
unmetered: false,
});
});
it('swallows backend errors so the UI never breaks', async () => {
installBridge();
setNetworkState.mockRejectedValue(new Error('ipc down'));
it("swallows backend errors so the UI never breaks", async () => {
installBridge();
setNetworkState.mockRejectedValue(new Error("ipc down"));
await expect(reportNetworkState()).resolves.toBeUndefined();
});
});
await expect(reportNetworkState()).resolves.toBeUndefined();
});
});
describe('startNetworkReporting', () => {
it('reports once immediately and again on network change', async () => {
installBridge();
describe("startNetworkReporting", () => {
it("reports once immediately and again on network change", async () => {
installBridge();
const stop = startNetworkReporting();
await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(1));
const stop = startNetworkReporting();
await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(1));
window.dispatchEvent(new CustomEvent('jellytau-network-changed'));
await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(2));
window.dispatchEvent(new CustomEvent("jellytau-network-changed"));
await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(2));
stop();
});
stop();
});
it('stops reporting after teardown', async () => {
installBridge();
it("stops reporting after teardown", async () => {
installBridge();
const stop = startNetworkReporting();
await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(1));
stop();
const stop = startNetworkReporting();
await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(1));
stop();
window.dispatchEvent(new CustomEvent('jellytau-network-changed'));
// Give any stray listener a chance to fire before asserting.
await new Promise((resolve) => setTimeout(resolve, 10));
expect(setNetworkState).toHaveBeenCalledTimes(1);
});
});
window.dispatchEvent(new CustomEvent("jellytau-network-changed"));
// Give any stray listener a chance to fire before asserting.
await new Promise((resolve) => setTimeout(resolve, 10));
expect(setNetworkState).toHaveBeenCalledTimes(1);
});
});
describe('areDownloadsAllowed', () => {
it('returns the backend verdict', async () => {
getDownloadsAllowed.mockResolvedValue(false);
expect(await areDownloadsAllowed()).toBe(false);
});
describe("areDownloadsAllowed", () => {
it("returns the backend verdict", async () => {
getDownloadsAllowed.mockResolvedValue(false);
expect(await areDownloadsAllowed()).toBe(false);
});
it('fails open if the query errors, so the UI never falsely blames WiFi', async () => {
getDownloadsAllowed.mockRejectedValue(new Error('ipc down'));
expect(await areDownloadsAllowed()).toBe(true);
});
});
it("fails open if the query errors, so the UI never falsely blames WiFi", async () => {
getDownloadsAllowed.mockRejectedValue(new Error("ipc down"));
expect(await areDownloadsAllowed()).toBe(true);
});
});
});
+51 -51
View File
@@ -10,41 +10,41 @@
* TRACES: UR-053 | DR-074
*/
import { commands } from '$lib/api/bindings';
import type { NetworkType } from '$lib/api/bindings';
import { createLogger } from '$lib/utils/logger';
import { commands } from "$lib/api/bindings";
import type { NetworkType } from "$lib/api/bindings";
import { createLogger } from "$lib/utils/logger";
const log = createLogger('NetworkType');
const log = createLogger("NetworkType");
/** The Android bridge, present only in the Android WebView. */
interface AndroidNetworkTypeBridge {
currentType(): NetworkType;
isUnmetered(): boolean;
isAcceptable(wifiOnly: boolean): boolean;
isSupported(): boolean;
currentType(): NetworkType;
isUnmetered(): boolean;
isAcceptable(wifiOnly: boolean): boolean;
isSupported(): boolean;
}
declare global {
interface Window {
AndroidNetworkType?: AndroidNetworkTypeBridge;
}
interface Window {
AndroidNetworkType?: AndroidNetworkTypeBridge;
}
}
/** Event dispatched into the WebView by MainActivity on any network change. */
const NETWORK_CHANGED_EVENT = 'jellytau-network-changed';
const NETWORK_CHANGED_EVENT = "jellytau-network-changed";
function bridge(): AndroidNetworkTypeBridge | undefined {
if (typeof window === 'undefined') return undefined;
return window.AndroidNetworkType;
if (typeof window === "undefined") return undefined;
return window.AndroidNetworkType;
}
/** Whether native network detection is available (Android only). */
export function isNetworkDetectionSupported(): boolean {
try {
return bridge()?.isSupported() ?? false;
} catch {
return false;
}
try {
return bridge()?.isSupported() ?? false;
} catch {
return false;
}
}
/**
@@ -54,19 +54,19 @@ export function isNetworkDetectionSupported(): boolean {
* means downloads run unconditionally.
*/
export async function reportNetworkState(): Promise<void> {
const android = bridge();
if (!android) return;
const android = bridge();
if (!android) return;
try {
const networkType = android.currentType();
const unmetered = android.isUnmetered();
try {
const networkType = android.currentType();
const unmetered = android.isUnmetered();
await commands.setNetworkState({ networkType, unmetered });
} catch (error) {
// Never let network reporting break the UI — the gate fails closed on
// the Rust side, so a missed report at worst delays a queued download.
log.warn('Failed to report network state:', error);
}
await commands.setNetworkState({ networkType, unmetered });
} catch (error) {
// Never let network reporting break the UI — the gate fails closed on
// the Rust side, so a missed report at worst delays a queued download.
log.warn("Failed to report network state:", error);
}
}
/**
@@ -77,25 +77,25 @@ export async function reportNetworkState(): Promise<void> {
* Returns a teardown function.
*/
export function startNetworkReporting(): () => void {
if (typeof window === 'undefined') return () => {};
if (typeof window === "undefined") return () => {};
void reportNetworkState();
void reportNetworkState();
const onChange = () => {
void reportNetworkState();
};
const onChange = () => {
void reportNetworkState();
};
window.addEventListener(NETWORK_CHANGED_EVENT, onChange);
// The browser's own online/offline events are a useful extra nudge on
// desktop-style webviews where the native callback may not fire.
window.addEventListener('online', onChange);
window.addEventListener('offline', onChange);
window.addEventListener(NETWORK_CHANGED_EVENT, onChange);
// The browser's own online/offline events are a useful extra nudge on
// desktop-style webviews where the native callback may not fire.
window.addEventListener("online", onChange);
window.addEventListener("offline", onChange);
return () => {
window.removeEventListener(NETWORK_CHANGED_EVENT, onChange);
window.removeEventListener('online', onChange);
window.removeEventListener('offline', onChange);
};
return () => {
window.removeEventListener(NETWORK_CHANGED_EVENT, onChange);
window.removeEventListener("online", onChange);
window.removeEventListener("offline", onChange);
};
}
/**
@@ -103,10 +103,10 @@ export function startNetworkReporting(): () => void {
* downloads UI to show "Waiting for WiFi" instead of a stuck-looking queue.
*/
export async function areDownloadsAllowed(): Promise<boolean> {
try {
return await commands.getDownloadsAllowed();
} catch (error) {
log.warn('Failed to query download gate:', error);
return true;
}
try {
return await commands.getDownloadsAllowed();
} catch (error) {
log.warn("Failed to query download gate:", error);
return true;
}
}
@@ -44,7 +44,7 @@ const h = vi.hoisted(() => {
// issued" and "command accepted".
pending: [] as Array<() => void>,
setShowServerCatalog: vi.fn(
() => new Promise<void>((resolve) => h.pending.push(() => resolve()))
() => new Promise<void>((resolve) => h.pending.push(() => resolve())),
),
};
});
@@ -77,7 +77,7 @@ describe("offline filter refetch signal (DR-143)", () => {
beforeEach(() => {
h.setShowServerCatalog.mockReset();
h.setShowServerCatalog.mockImplementation(
() => new Promise<void>((resolve) => h.pending.push(() => resolve()))
() => new Promise<void>((resolve) => h.pending.push(() => resolve())),
);
h.pending.length = 0;
h.isConnectedStore.reset(true);
+2 -6
View File
@@ -118,9 +118,7 @@ export async function syncCatalog(): Promise<void> {
syncInProgress = true;
try {
const result = await commands.syncFullCatalog(handle);
log.info(
`Synced ${result.itemsCached} items (${result.librariesFailed} libraries failed)`
);
log.info(`Synced ${result.itemsCached} items (${result.librariesFailed} libraries failed)`);
await refreshSyncStatus();
} catch (err) {
log.warn("Full catalog sync failed:", err);
@@ -139,9 +137,7 @@ export async function resumeQueued(): Promise<void> {
try {
const result = await commands.resumeQueuedDownloads(handle);
if (result.resolved > 0 || result.failed > 0) {
log.info(
`Resumed queued downloads: ${result.resolved} resolved, ${result.failed} failed`
);
log.info(`Resumed queued downloads: ${result.resolved} resolved, ${result.failed} failed`);
}
} catch (err) {
log.warn("Failed to resume queued downloads:", err);
+1 -3
View File
@@ -39,9 +39,7 @@ describe("pending sync row description", () => {
});
it("names the item when the catalog knows it, and falls back to the id", () => {
expect(describeSubject(row({ itemName: "The Expanse S01E01" }))).toBe(
"The Expanse S01E01",
);
expect(describeSubject(row({ itemName: "The Expanse S01E01" }))).toBe("The Expanse S01E01");
expect(describeSubject(row({ itemName: null, itemId: "abc123" }))).toBe("abc123");
expect(describeSubject(row({ itemName: null, itemId: null }))).toBe("Unknown item");
});
+10 -27
View File
@@ -47,7 +47,7 @@ describe("playback reporting service", () => {
it("should accept optional contextType and contextId", async () => {
await expect(
reportPlaybackStart("item-123", 0, "container", "container-456")
reportPlaybackStart("item-123", 0, "container", "container-456"),
).resolves.toBeUndefined();
});
@@ -57,9 +57,7 @@ describe("playback reporting service", () => {
await reportPlaybackStart("item-123", 60);
const call = invokeSpy.mock.calls.find(
(c) => c[0] === "storage_update_playback_context"
);
const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_update_playback_context");
expect(call).toBeDefined();
expect(call![1]).toHaveProperty("positionMs", 60000); // 60 seconds
});
@@ -70,9 +68,7 @@ describe("playback reporting service", () => {
await reportPlaybackStart("item-123", 30);
const call = invokeSpy.mock.calls.find(
(c) => c[0] === "storage_update_playback_context"
);
const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_update_playback_context");
expect(call![1]).toHaveProperty("contextType", "single");
expect(call![1]).toHaveProperty("contextId", null);
});
@@ -83,9 +79,7 @@ describe("playback reporting service", () => {
await reportPlaybackStart("item-123", 0);
const call = invokeSpy.mock.calls.find(
(c) => c[0] === "storage_update_playback_context"
);
const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_update_playback_context");
expect(call![1]).toHaveProperty("userId", "user-123");
});
});
@@ -105,9 +99,7 @@ describe("playback reporting service", () => {
await reportPlaybackProgress("item-123", 30);
const call = invokeSpy.mock.calls.find(
(c) => c[0] === "storage_update_playback_progress"
);
const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_update_playback_progress");
expect(call).toBeDefined();
expect(call![1]).toHaveProperty("itemId", "item-123");
});
@@ -118,9 +110,7 @@ describe("playback reporting service", () => {
await reportPlaybackProgress("item-123", 45);
const call = invokeSpy.mock.calls.find(
(c) => c[0] === "storage_update_playback_progress"
);
const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_update_playback_progress");
expect(call![1]).toHaveProperty("positionMs", 45000); // 45 seconds
});
});
@@ -136,9 +126,7 @@ describe("playback reporting service", () => {
await reportPlaybackStopped("item-123", 120);
const call = invokeSpy.mock.calls.find(
(c) => c[0] === "storage_update_playback_progress"
);
const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_update_playback_progress");
expect(call).toBeDefined();
});
@@ -167,7 +155,7 @@ describe("playback reporting service", () => {
expect(mockRepo.reportPlaybackStopped).toHaveBeenCalledWith(
"item-123",
90000 // 90 seconds in ms
90000, // 90 seconds in ms
);
});
@@ -192,9 +180,7 @@ describe("playback reporting service", () => {
await markAsPlayed("item-123");
const call = invokeSpy.mock.calls.find(
(c) => c[0] === "storage_mark_played"
);
const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_mark_played");
expect(call).toBeDefined();
expect(call![1]).toHaveProperty("itemId", "item-123");
});
@@ -215,10 +201,7 @@ describe("playback reporting service", () => {
await markAsPlayed("item-123");
expect(mockRepo.getItem).toHaveBeenCalledWith("item-123");
expect(mockRepo.reportPlaybackStopped).toHaveBeenCalledWith(
"item-123",
10000
);
expect(mockRepo.reportPlaybackStopped).toHaveBeenCalledWith("item-123", 10000);
});
it("should handle items without durationMs", async () => {
+14 -5
View File
@@ -30,7 +30,7 @@ export async function reportPlaybackStart(
itemId: string,
positionSeconds: number,
contextType: "container" | "single" = "single",
contextId: string | null = null
contextId: string | null = null,
): Promise<void> {
const positionMs = Math.floor(positionSeconds * 1000);
const userId = auth.getUserId();
@@ -42,13 +42,19 @@ export async function reportPlaybackStart(
positionSeconds,
"context:",
contextType,
contextId
contextId,
);
// Update local DB with context (always works, even offline)
if (userId) {
try {
await commands.storageUpdatePlaybackContext(userId, itemId, positionMs, contextType, contextId);
await commands.storageUpdatePlaybackContext(
userId,
itemId,
positionMs,
contextType,
contextId,
);
} catch (e) {
log.error("Failed to update playback context:", e);
}
@@ -72,7 +78,7 @@ export async function reportPlaybackStart(
export async function reportPlaybackProgress(
itemId: string,
positionSeconds: number,
_isPaused = false
_isPaused = false,
): Promise<void> {
const positionMs = Math.floor(positionSeconds * 1000);
const userId = auth.getUserId();
@@ -100,7 +106,10 @@ export async function reportPlaybackProgress(
*
* TRACES: UR-005, UR-025 | DR-028
*/
export async function reportPlaybackStopped(itemId: string, positionSeconds: number): Promise<void> {
export async function reportPlaybackStopped(
itemId: string,
positionSeconds: number,
): Promise<void> {
const positionMs = Math.floor(positionSeconds * 1000);
const userId = auth.getUserId();
@@ -168,7 +168,7 @@ describe("Player Events — recoverable errors get one chance before stopping",
it("does not stop the player when Rust re-opened the stream", async () => {
const { invoke } = await import("@tauri-apps/api/core");
vi.mocked(invoke).mockImplementation(async (cmd: string) =>
cmd === "player_recover_stream" ? true : null
cmd === "player_recover_stream" ? true : null,
);
const { initPlayerEvents } = await import("./playerEvents");
@@ -187,7 +187,7 @@ describe("Player Events — recoverable errors get one chance before stopping",
it("stops the player when recovery declines", async () => {
const { invoke } = await import("@tauri-apps/api/core");
vi.mocked(invoke).mockImplementation(async (cmd: string) =>
cmd === "player_recover_stream" ? false : null
cmd === "player_recover_stream" ? false : null,
);
const { initPlayerEvents } = await import("./playerEvents");
+1 -1
View File
@@ -102,7 +102,7 @@ describe("Player Events Service", () => {
// console.error is called with: ("Failed to initialize player events:", Error)
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to initialize player events"),
expect.any(Error)
expect.any(Error),
);
});
});
+2 -2
View File
@@ -139,7 +139,7 @@ function handlePlayerEvent(event: PlayerStatusEvent): void {
event.current_episode,
event.next_episode,
event.countdown_seconds,
event.auto_advance
event.auto_advance,
);
break;
@@ -377,7 +377,7 @@ function handleShowNextEpisodePopup(
currentEpisodeItem: MediaItem,
nextEpisodeItem: MediaItem,
countdownSeconds: number,
autoAdvance: boolean
autoAdvance: boolean,
): void {
// Update next episode store to show popup
nextEpisode.showPopup(currentEpisodeItem, nextEpisodeItem, countdownSeconds, autoAdvance);
+7 -21
View File
@@ -70,9 +70,7 @@ describe("preload service", () => {
await preloadUpcomingTracks();
const call = invokeSpy.mock.calls.find(
(c) => c[0] === "player_preload_upcoming"
);
const call = invokeSpy.mock.calls.find((c) => c[0] === "player_preload_upcoming");
expect(call).toBeDefined();
});
@@ -82,9 +80,7 @@ describe("preload service", () => {
await preloadUpcomingTracks();
const call = invokeSpy.mock.calls.find(
(c) => c[0] === "player_preload_upcoming"
);
const call = invokeSpy.mock.calls.find((c) => c[0] === "player_preload_upcoming");
expect(call![1]).toHaveProperty("userId", "user-123");
});
@@ -94,9 +90,7 @@ describe("preload service", () => {
await preloadUpcomingTracks({ userId: "user-456" });
const call = invokeSpy.mock.calls.find(
(c) => c[0] === "player_preload_upcoming"
);
const call = invokeSpy.mock.calls.find((c) => c[0] === "player_preload_upcoming");
expect(call![1]).toHaveProperty("userId", "user-456");
});
@@ -110,9 +104,7 @@ describe("preload service", () => {
await preloadUpcomingTracks();
const call = invokeSpy.mock.calls.find(
(c) => c[0] === "player_preload_upcoming"
);
const call = invokeSpy.mock.calls.find((c) => c[0] === "player_preload_upcoming");
expect(call).toBeUndefined();
});
@@ -141,9 +133,7 @@ describe("preload service", () => {
await preloadUpcomingTracks({ debug: true, userId: "user-789" });
const call = invokeSpy.mock.calls.find(
(c) => c[0] === "player_preload_upcoming"
);
const call = invokeSpy.mock.calls.find((c) => c[0] === "player_preload_upcoming");
expect(call![1]).toHaveProperty("userId", "user-789");
});
});
@@ -165,9 +155,7 @@ describe("preload service", () => {
const config = makeConfig({ queuePrecacheEnabled: true });
await updateCacheConfig(config);
const call = invokeSpy.mock.calls.find(
(c) => c[0] === "player_set_cache_config"
);
const call = invokeSpy.mock.calls.find((c) => c[0] === "player_set_cache_config");
expect(call).toBeDefined();
expect(call![1]).toHaveProperty("config", config);
});
@@ -203,9 +191,7 @@ describe("preload service", () => {
await getCacheConfig();
const call = invokeSpy.mock.calls.find(
(c) => c[0] === "player_get_cache_config"
);
const call = invokeSpy.mock.calls.find((c) => c[0] === "player_get_cache_config");
expect(call).toBeDefined();
});
+38 -38
View File
@@ -5,18 +5,18 @@
* TRACES: UR-004, UR-011 | DR-006, DR-015
*/
import { commands } from '$lib/api/bindings';
import type { CacheConfig } from '$lib/api/bindings';
import { auth } from '$lib/stores/auth';
import { createLogger } from '$lib/utils/logger';
import { commands } from "$lib/api/bindings";
import type { CacheConfig } from "$lib/api/bindings";
import { auth } from "$lib/stores/auth";
import { createLogger } from "$lib/utils/logger";
const log = createLogger('Preload');
const log = createLogger("Preload");
interface PreloadOptions {
/** Enable debug logging */
debug?: boolean;
/** Override user ID (defaults to current session user) */
userId?: string;
/** Enable debug logging */
debug?: boolean;
/** Override user ID (defaults to current session user) */
userId?: string;
}
/**
@@ -24,51 +24,51 @@ interface PreloadOptions {
* This should be called after playback starts or advances to the next track
*/
export async function preloadUpcomingTracks(options: PreloadOptions = {}): Promise<void> {
const { debug = false, userId: overrideUserId } = options;
const { debug = false, userId: overrideUserId } = options;
try {
// Get current user ID
const userId = overrideUserId || auth.getUserId();
try {
// Get current user ID
const userId = overrideUserId || auth.getUserId();
if (!userId) {
if (debug) log.debug('No active user session, skipping preload');
return;
}
if (!userId) {
if (debug) log.debug("No active user session, skipping preload");
return;
}
if (debug) log.debug('Triggering preload for user:', userId);
if (debug) log.debug("Triggering preload for user:", userId);
// downloadBasePath is currently unused in the backend
const result = await commands.playerPreloadUpcoming(userId, '/downloads');
// downloadBasePath is currently unused in the backend
const result = await commands.playerPreloadUpcoming(userId, "/downloads");
if (debug) {
log.debug('Result:', {
queued: result.queuedCount,
alreadyDownloaded: result.alreadyDownloaded,
skipped: result.skipped
});
}
if (debug) {
log.debug("Result:", {
queued: result.queuedCount,
alreadyDownloaded: result.alreadyDownloaded,
skipped: result.skipped,
});
}
// Log meaningful results
if (result.queuedCount > 0) {
log.debug(`Queued ${result.queuedCount} track(s) for background download`);
}
} catch (error) {
// Fail silently - preloading is a background optimization
// Don't interrupt the user's playback experience
log.warn('Failed to preload upcoming tracks:', error);
}
// Log meaningful results
if (result.queuedCount > 0) {
log.debug(`Queued ${result.queuedCount} track(s) for background download`);
}
} catch (error) {
// Fail silently - preloading is a background optimization
// Don't interrupt the user's playback experience
log.warn("Failed to preload upcoming tracks:", error);
}
}
/**
* Update smart cache configuration
*/
export async function updateCacheConfig(config: CacheConfig): Promise<void> {
await commands.playerSetCacheConfig(config);
await commands.playerSetCacheConfig(config);
}
/**
* Get current cache configuration
*/
export async function getCacheConfig(): Promise<CacheConfig> {
return await commands.playerGetCacheConfig();
return await commands.playerGetCacheConfig();
}
+1 -3
View File
@@ -12,9 +12,7 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
const markAsPlayed = vi.fn(async (_itemId: string) => undefined);
const reportPlaybackStopped = vi.fn(
async (_itemId: string, _positionSeconds: number) => undefined
);
const reportPlaybackStopped = vi.fn(async (_itemId: string, _positionSeconds: number) => undefined);
vi.mock("./playbackReporting", () => ({
markAsPlayed: (itemId: string) => markAsPlayed(itemId),
+8 -7
View File
@@ -63,7 +63,7 @@ class SyncService {
async queueMutation(
operation: SyncOperation,
itemId: string,
payload?: Record<string, unknown>
payload?: Record<string, unknown>,
): Promise<number> {
const userId = auth.getUserId();
if (!userId) {
@@ -74,7 +74,7 @@ class SyncService {
userId,
operation,
itemId,
payload ? JSON.stringify(payload) : null
payload ? JSON.stringify(payload) : null,
);
log.debug(`Queued ${operation} for item ${itemId}, id: ${id}`);
@@ -90,10 +90,7 @@ class SyncService {
* Queue playback progress update
* Also updates local state immediately
*/
async queuePlaybackProgress(
itemId: string,
positionMs: number
): Promise<number> {
async queuePlaybackProgress(itemId: string, positionMs: number): Promise<number> {
// Update local state first
await commands.storageUpdatePlaybackProgress(auth.getUserId() ?? "", itemId, positionMs);
@@ -184,7 +181,11 @@ class SyncService {
return this.queueMutation("playlist_remove_items", playlistId, { entryIds });
}
async queuePlaylistReorderItem(playlistId: string, itemId: string, newIndex: number): Promise<number> {
async queuePlaylistReorderItem(
playlistId: string,
itemId: string,
newIndex: number,
): Promise<number> {
return this.queueMutation("playlist_reorder_item", playlistId, { itemId, newIndex });
}
+1 -1
View File
@@ -60,7 +60,7 @@ async function handleLoad(
url: string,
mediaId: string | null,
position: number,
autoplay: boolean
autoplay: boolean,
): Promise<void> {
if (!audioEl) return;