many changes
This commit is contained in:
@@ -0,0 +1,545 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { RepositoryClient } from "./repository-client";
|
||||
|
||||
vi.mock("@tauri-apps/api/core");
|
||||
|
||||
/**
|
||||
* Integration tests documenting Phase 1 & 2 refactoring:
|
||||
* - Sorting moved to backend (no frontend compareFn)
|
||||
* - Filtering moved to backend (no frontend iteration/matching)
|
||||
* - URL construction moved to backend (async Tauri invoke)
|
||||
* - Search moved to backend (backend search command)
|
||||
*/
|
||||
|
||||
describe("Backend Integration - Refactored Business Logic", () => {
|
||||
let client: RepositoryClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
client = new RepositoryClient();
|
||||
(invoke as any).mockResolvedValueOnce("test-handle-123");
|
||||
await client.create("https://server.com", "user1", "token123", "server1");
|
||||
});
|
||||
|
||||
describe("Sorting Delegated to Backend", () => {
|
||||
it("should pass sortBy to backend instead of frontend sorting", async () => {
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [
|
||||
{ id: "item1", name: "Album A" },
|
||||
{ id: "item2", name: "Album B" },
|
||||
{ id: "item3", name: "Album C" },
|
||||
],
|
||||
totalRecordCount: 3,
|
||||
});
|
||||
|
||||
const result = await client.getItems("library123", {
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
});
|
||||
|
||||
// Backend should have done the sorting
|
||||
expect(result.items[0].name).toBe("Album A");
|
||||
|
||||
// Frontend doesn't have a compareFn
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_items", {
|
||||
handle: "test-handle-123",
|
||||
parentId: "library123",
|
||||
options: {
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should support different sort fields via backend", async () => {
|
||||
const sortFields = ["SortName", "Artist", "Album", "DatePlayed", "ProductionYear"];
|
||||
|
||||
for (const sortField of sortFields) {
|
||||
vi.clearAllMocks();
|
||||
(invoke as any).mockResolvedValueOnce("test-handle-123");
|
||||
await client.create("https://server.com", "user1", "token123", "server1");
|
||||
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [],
|
||||
totalRecordCount: 0,
|
||||
});
|
||||
|
||||
await client.getItems("library123", {
|
||||
sortBy: sortField,
|
||||
sortOrder: "Ascending",
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_get_items",
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
sortBy: sortField,
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should pass sort order to backend", async () => {
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [],
|
||||
totalRecordCount: 0,
|
||||
});
|
||||
|
||||
await client.getItems("library123", {
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Descending",
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_get_items",
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
sortOrder: "Descending",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should NOT include frontend compareFn (removed entirely)", async () => {
|
||||
// Old code pattern:
|
||||
// sortOptions: [{
|
||||
// key: "title",
|
||||
// label: "Title",
|
||||
// compareFn: (a, b) => a.name.localeCompare(b.name) // ← REMOVED
|
||||
// }]
|
||||
|
||||
// New code pattern:
|
||||
// sortOptions: [{
|
||||
// key: "SortName", // Jellyfin field name
|
||||
// label: "Title"
|
||||
// }]
|
||||
|
||||
const config = {
|
||||
sortOptions: [
|
||||
{ key: "SortName", label: "Title" },
|
||||
{ key: "Artist", label: "Artist" },
|
||||
],
|
||||
};
|
||||
|
||||
// Verify no compareFn property exists
|
||||
for (const option of config.sortOptions) {
|
||||
expect((option as any).compareFn).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Filtering Delegated to Backend", () => {
|
||||
it("should pass includeItemTypes to backend", async () => {
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [],
|
||||
totalRecordCount: 0,
|
||||
});
|
||||
|
||||
await client.getItems("library123", {
|
||||
includeItemTypes: ["Audio", "MusicAlbum"],
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_get_items",
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
includeItemTypes: ["Audio", "MusicAlbum"],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should pass genres filter to backend", async () => {
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [],
|
||||
totalRecordCount: 0,
|
||||
});
|
||||
|
||||
await client.getItems("library123", {
|
||||
genres: ["Rock", "Jazz"],
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_get_items",
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
genres: ["Rock", "Jazz"],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should NOT include frontend filtering logic", async () => {
|
||||
// Old code pattern:
|
||||
// let filtered = items.filter(item => {
|
||||
// return searchFields.some(field => {
|
||||
// const fieldValue = item[field]?.toLowerCase() ?? "";
|
||||
// return fieldValue.includes(query.toLowerCase());
|
||||
// });
|
||||
// }); // ← REMOVED
|
||||
|
||||
// New code pattern:
|
||||
// Use backend search instead
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [{ id: "item1", name: "Search Result" }],
|
||||
totalRecordCount: 1,
|
||||
});
|
||||
|
||||
const result = await client.search("query");
|
||||
|
||||
expect(result.items.length).toBeGreaterThan(0);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_search",
|
||||
expect.objectContaining({
|
||||
query: "query",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should support pagination via backend", async () => {
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [],
|
||||
totalRecordCount: 1000,
|
||||
});
|
||||
|
||||
await client.getItems("library123", {
|
||||
startIndex: 100,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_get_items",
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
startIndex: 100,
|
||||
limit: 50,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Search Delegated to Backend", () => {
|
||||
it("should use backend search command instead of frontend filtering", async () => {
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [
|
||||
{ id: "item1", name: "Found Item" },
|
||||
{ id: "item2", name: "Another Found Item" },
|
||||
],
|
||||
totalRecordCount: 2,
|
||||
});
|
||||
|
||||
const result = await client.search("query");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_search",
|
||||
expect.objectContaining({
|
||||
query: "query",
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.items.length).toBe(2);
|
||||
});
|
||||
|
||||
it("should support search with item type filters", async () => {
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [],
|
||||
totalRecordCount: 0,
|
||||
});
|
||||
|
||||
await client.search("query", {
|
||||
includeItemTypes: ["Audio"],
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_search",
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
includeItemTypes: ["Audio"],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should NOT do client-side search filtering", async () => {
|
||||
// Old code pattern:
|
||||
// const query = searchInput.toLowerCase();
|
||||
// const results = items.filter(item =>
|
||||
// config.searchFields.some(field =>
|
||||
// item[field]?.toLowerCase()?.includes(query)
|
||||
// )
|
||||
// ); // ← REMOVED
|
||||
|
||||
// New code pattern:
|
||||
// Call backend search directly
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [{ id: "item1" }],
|
||||
totalRecordCount: 1,
|
||||
});
|
||||
|
||||
const result = await client.search("search term");
|
||||
|
||||
// Backend did the filtering
|
||||
expect(result.items).toBeDefined();
|
||||
expect(invoke).toHaveBeenCalledWith("repository_search", expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL Construction Delegated to Backend", () => {
|
||||
it("should get image URLs from backend (not construct in frontend)", async () => {
|
||||
const backendUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
|
||||
(invoke as any).mockResolvedValueOnce(backendUrl);
|
||||
|
||||
const url = await client.getImageUrl("item123", "Primary");
|
||||
|
||||
// Backend constructed and returned the URL
|
||||
expect(url).toBe(backendUrl);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_get_image_url",
|
||||
expect.objectContaining({
|
||||
itemId: "item123",
|
||||
imageType: "Primary",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should NOT construct image URLs in frontend", async () => {
|
||||
// Old code pattern:
|
||||
// return `${serverUrl}/Items/${itemId}/Images/${imageType}?api_key=${token}&maxWidth=${options.maxWidth}`;
|
||||
// ← REMOVED - NEVER construct URLs in frontend
|
||||
|
||||
(invoke as any).mockResolvedValueOnce("https://server.com/image");
|
||||
|
||||
const url = await client.getImageUrl("item123", "Primary", { maxWidth: 300 });
|
||||
|
||||
// URL came from backend, not constructed in frontend
|
||||
expect(typeof url).toBe("string");
|
||||
expect(url).toContain("http");
|
||||
});
|
||||
|
||||
it("should get video stream URLs from backend", async () => {
|
||||
const backendUrl = "https://server.com/Videos/item123/stream.mp4?api_key=token";
|
||||
(invoke as any).mockResolvedValueOnce(backendUrl);
|
||||
|
||||
const url = await client.getVideoStreamUrl("item123");
|
||||
|
||||
expect(url).toBe(backendUrl);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_get_video_stream_url",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("should get subtitle URLs from backend", async () => {
|
||||
const backendUrl = "https://server.com/Videos/item123/Subtitles/0/subtitles.vtt?api_key=token";
|
||||
(invoke as any).mockResolvedValueOnce(backendUrl);
|
||||
|
||||
const url = await client.getSubtitleUrl("item123", "source456", 0);
|
||||
|
||||
expect(url).toBe(backendUrl);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_get_subtitle_url",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("should get video download URLs from backend", async () => {
|
||||
const backendUrl = "https://server.com/Videos/item123/stream.mp4?maxWidth=1280&api_key=token";
|
||||
(invoke as any).mockResolvedValueOnce(backendUrl);
|
||||
|
||||
const url = await client.getVideoDownloadUrl("item123", "720p");
|
||||
|
||||
expect(url).toBe(backendUrl);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_get_video_download_url",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("should never expose access token in frontend code", async () => {
|
||||
// The access token is NEVER used in frontend URL construction
|
||||
// It's only stored in backend for secure URL generation
|
||||
|
||||
// Frontend code NEVER has direct access to use the token
|
||||
const client2 = new RepositoryClient();
|
||||
// client2._accessToken is private and should never be accessed or used
|
||||
|
||||
// All token usage is in backend via Tauri commands
|
||||
expect(invoke).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Component Config Simplification", () => {
|
||||
it("should have simplified MediaListConfig (no searchFields)", () => {
|
||||
// Old type:
|
||||
// interface MediaListConfig {
|
||||
// searchFields: string[]; // ← REMOVED
|
||||
// compareFn?: (a, b) => number; // ← REMOVED
|
||||
// }
|
||||
|
||||
// New type:
|
||||
const config = {
|
||||
itemType: "Audio",
|
||||
title: "Tracks",
|
||||
sortOptions: [
|
||||
{ key: "SortName", label: "Title" },
|
||||
// No compareFn
|
||||
],
|
||||
// No searchFields
|
||||
};
|
||||
|
||||
// Verify no searchFields
|
||||
expect((config as any).searchFields).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should use Jellyfin field names in sort options", () => {
|
||||
// Old:
|
||||
// { key: "title", label: "Title", compareFn: ... }
|
||||
|
||||
// New:
|
||||
// { key: "SortName", label: "Title" }
|
||||
|
||||
const sortOptions = [
|
||||
{ key: "SortName", label: "A-Z" },
|
||||
{ key: "Artist", label: "Artist" },
|
||||
{ key: "Album", label: "Album" },
|
||||
{ key: "DatePlayed", label: "Recent" },
|
||||
];
|
||||
|
||||
for (const option of sortOptions) {
|
||||
// Should be Jellyfin field names
|
||||
expect(typeof option.key).toBe("string");
|
||||
expect(option.key).toMatch(/^[A-Z]/); // Jellyfin fields start with capital
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Debounced Search Implementation", () => {
|
||||
it("should debounce search without frontend filtering", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const mockSearch = vi.fn().mockResolvedValue({
|
||||
items: [],
|
||||
totalRecordCount: 0,
|
||||
});
|
||||
|
||||
(invoke as any).mockImplementation((cmd: string, args: any) => {
|
||||
if (cmd === "repository_search") {
|
||||
return mockSearch(args.query);
|
||||
}
|
||||
return Promise.resolve({ items: [], totalRecordCount: 0 });
|
||||
});
|
||||
|
||||
// Simulate rapid search queries
|
||||
await client.search("t");
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
await client.search("te");
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
await client.search("test");
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
// All calls go to backend (debouncing happens in component via $effect)
|
||||
expect(invoke).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("End-to-End Data Flow", () => {
|
||||
it("should support complete flow: load → sort → display", async () => {
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [
|
||||
{ id: "id1", name: "Album A", sortName: "A" },
|
||||
{ id: "id2", name: "Album B", sortName: "B" },
|
||||
],
|
||||
totalRecordCount: 2,
|
||||
});
|
||||
|
||||
// Frontend requests items with sort
|
||||
const result = await client.getItems("library123", {
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
});
|
||||
|
||||
// Backend returned pre-sorted items
|
||||
expect(result.items[0].sortName).toBe("A");
|
||||
expect(result.items[1].sortName).toBe("B");
|
||||
|
||||
// Frontend just displays them
|
||||
// No compareFn, no local sorting
|
||||
});
|
||||
|
||||
it("should support complete flow: search → load images → display", async () => {
|
||||
// 1. Frontend calls backend search
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [{ id: "item1", name: "Result", primaryImageTag: "tag1" }],
|
||||
totalRecordCount: 1,
|
||||
});
|
||||
|
||||
const searchResult = await client.search("query");
|
||||
expect(searchResult.items.length).toBe(1);
|
||||
|
||||
// 2. Frontend loads image URL from backend
|
||||
(invoke as any).mockResolvedValueOnce("https://server.com/image.jpg");
|
||||
|
||||
const imageUrl = await client.getImageUrl("item1", "Primary");
|
||||
expect(imageUrl).toContain("http");
|
||||
|
||||
// 3. Frontend displays search results with images
|
||||
// No client-side filtering, sorting, or URL construction
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance Characteristics", () => {
|
||||
it("should reduce memory usage by not storing frontend sorting state", async () => {
|
||||
// Old: Frontend stores items + sorting state + filtered results
|
||||
// Old: Multiple copies of data (original, filtered, sorted)
|
||||
|
||||
// New: Backend returns already-sorted data
|
||||
// New: Frontend just stores the result
|
||||
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: Array.from({ length: 10000 }, (_, i) => ({
|
||||
id: `id${i}`,
|
||||
name: `Item ${i}`,
|
||||
})),
|
||||
totalRecordCount: 10000,
|
||||
});
|
||||
|
||||
const result = await client.getItems("library123", {
|
||||
sortBy: "SortName",
|
||||
limit: 10000,
|
||||
});
|
||||
|
||||
// Backend handled sorting
|
||||
expect(result.items.length).toBe(10000);
|
||||
// Frontend just stores the result array
|
||||
});
|
||||
|
||||
it("should reduce CPU usage by avoiding client-side operations", async () => {
|
||||
// Old pattern required:
|
||||
// - Parsing all items into memory
|
||||
// - Iterating to apply filters
|
||||
// - Sorting algorithm (O(n log n) comparisons)
|
||||
// - Updating multiple state variables
|
||||
|
||||
// New pattern:
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [], // Backend already filtered/sorted
|
||||
totalRecordCount: 0,
|
||||
});
|
||||
|
||||
// Frontend just awaits backend result
|
||||
const result = await client.getItems("library123", {
|
||||
sortBy: "SortName",
|
||||
includeItemTypes: ["Audio"],
|
||||
});
|
||||
|
||||
// No client-side work
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,428 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { RepositoryClient } from "./repository-client";
|
||||
|
||||
vi.mock("@tauri-apps/api/core");
|
||||
|
||||
describe("RepositoryClient", () => {
|
||||
let client: RepositoryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new RepositoryClient();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("Initialization", () => {
|
||||
it("should initialize with no handle", () => {
|
||||
expect(() => client.getHandle()).toThrow("Repository not initialized");
|
||||
});
|
||||
|
||||
it("should create repository with invoke command", async () => {
|
||||
const mockHandle = "test-handle-123";
|
||||
(invoke as any).mockResolvedValueOnce(mockHandle);
|
||||
|
||||
const handle = await client.create("https://server.com", "user1", "token123", "server1");
|
||||
|
||||
expect(handle).toBe(mockHandle);
|
||||
expect(invoke).toHaveBeenCalledWith("repository_create", {
|
||||
serverUrl: "https://server.com",
|
||||
userId: "user1",
|
||||
accessToken: "token123",
|
||||
serverId: "server1",
|
||||
});
|
||||
});
|
||||
|
||||
it("should store handle after creation", async () => {
|
||||
const mockHandle = "test-handle-456";
|
||||
(invoke as any).mockResolvedValueOnce(mockHandle);
|
||||
|
||||
await client.create("https://server.com", "user1", "token123", "server1");
|
||||
|
||||
expect(client.getHandle()).toBe(mockHandle);
|
||||
});
|
||||
|
||||
it("should destroy repository and clear handle", async () => {
|
||||
const mockHandle = "test-handle-789";
|
||||
(invoke as any).mockResolvedValueOnce(mockHandle);
|
||||
await client.create("https://server.com", "user1", "token123", "server1");
|
||||
|
||||
(invoke as any).mockResolvedValueOnce(undefined);
|
||||
await client.destroy();
|
||||
|
||||
expect(() => client.getHandle()).toThrow("Repository not initialized");
|
||||
expect(invoke).toHaveBeenCalledWith("repository_destroy", { handle: mockHandle });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Image URL Methods", () => {
|
||||
beforeEach(async () => {
|
||||
(invoke as any).mockResolvedValueOnce("test-handle-123");
|
||||
await client.create("https://server.com", "user1", "token123", "server1");
|
||||
});
|
||||
|
||||
it("should get image URL from backend", async () => {
|
||||
const mockUrl = "https://server.com/Items/item123/Images/Primary?maxWidth=300&api_key=token";
|
||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
||||
|
||||
const imageUrl = await client.getImageUrl("item123", "Primary", { maxWidth: 300 });
|
||||
|
||||
expect(imageUrl).toBe(mockUrl);
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_image_url", {
|
||||
handle: "test-handle-123",
|
||||
itemId: "item123",
|
||||
imageType: "Primary",
|
||||
options: { maxWidth: 300 },
|
||||
});
|
||||
});
|
||||
|
||||
it("should use default image type if not provided", async () => {
|
||||
const mockUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
|
||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
||||
|
||||
await client.getImageUrl("item123");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_image_url", {
|
||||
handle: "test-handle-123",
|
||||
itemId: "item123",
|
||||
imageType: "Primary",
|
||||
options: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should pass multiple image options to backend", async () => {
|
||||
const mockUrl = "https://server.com/Items/item123/Images/Backdrop?maxWidth=1920&maxHeight=1080&quality=90&api_key=token";
|
||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
||||
|
||||
const options = {
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1080,
|
||||
quality: 90,
|
||||
tag: "abc123",
|
||||
};
|
||||
|
||||
await client.getImageUrl("item123", "Backdrop", options);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_image_url", {
|
||||
handle: "test-handle-123",
|
||||
itemId: "item123",
|
||||
imageType: "Backdrop",
|
||||
options,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle different image types", async () => {
|
||||
const mockUrl = "https://server.com/Items/item123/Images/Logo?api_key=token";
|
||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
||||
|
||||
await client.getImageUrl("item123", "Logo");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_image_url", {
|
||||
handle: expect.any(String),
|
||||
itemId: "item123",
|
||||
imageType: "Logo",
|
||||
options: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw error if not initialized before getImageUrl", async () => {
|
||||
const newClient = new RepositoryClient();
|
||||
|
||||
await expect(newClient.getImageUrl("item123")).rejects.toThrow(
|
||||
"Repository not initialized"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Subtitle URL Methods", () => {
|
||||
beforeEach(async () => {
|
||||
(invoke as any).mockResolvedValueOnce("test-handle-123");
|
||||
await client.create("https://server.com", "user1", "token123", "server1");
|
||||
});
|
||||
|
||||
it("should get subtitle URL from backend", async () => {
|
||||
const mockUrl = "https://server.com/Videos/item123/Subtitles/1/subtitles.vtt?api_key=token";
|
||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
||||
|
||||
const subtitleUrl = await client.getSubtitleUrl("item123", "source456", 0);
|
||||
|
||||
expect(subtitleUrl).toBe(mockUrl);
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_subtitle_url", {
|
||||
handle: "test-handle-123",
|
||||
itemId: "item123",
|
||||
mediaSourceId: "source456",
|
||||
streamIndex: 0,
|
||||
format: "vtt",
|
||||
});
|
||||
});
|
||||
|
||||
it("should use default format if not provided", async () => {
|
||||
const mockUrl = "https://server.com/Videos/item123/Subtitles/0/subtitles.vtt?api_key=token";
|
||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
||||
|
||||
await client.getSubtitleUrl("item123", "source456", 0);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_subtitle_url", {
|
||||
handle: expect.any(String),
|
||||
itemId: "item123",
|
||||
mediaSourceId: "source456",
|
||||
streamIndex: 0,
|
||||
format: "vtt",
|
||||
});
|
||||
});
|
||||
|
||||
it("should support custom subtitle formats", async () => {
|
||||
const mockUrl = "https://server.com/Videos/item123/Subtitles/0/subtitles.srt?api_key=token";
|
||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
||||
|
||||
await client.getSubtitleUrl("item123", "source456", 1, "srt");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_subtitle_url", {
|
||||
handle: expect.any(String),
|
||||
itemId: "item123",
|
||||
mediaSourceId: "source456",
|
||||
streamIndex: 1,
|
||||
format: "srt",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Video Download URL Methods", () => {
|
||||
beforeEach(async () => {
|
||||
(invoke as any).mockResolvedValueOnce("test-handle-123");
|
||||
await client.create("https://server.com", "user1", "token123", "server1");
|
||||
});
|
||||
|
||||
it("should get video download URL from backend", async () => {
|
||||
const mockUrl = "https://server.com/Videos/item123/stream.mp4?maxWidth=1920&api_key=token";
|
||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
||||
|
||||
const downloadUrl = await client.getVideoDownloadUrl("item123", "1080p");
|
||||
|
||||
expect(downloadUrl).toBe(mockUrl);
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_video_download_url", {
|
||||
handle: "test-handle-123",
|
||||
itemId: "item123",
|
||||
quality: "1080p",
|
||||
mediaSourceId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should use original quality by default", async () => {
|
||||
const mockUrl = "https://server.com/Videos/item123/stream.mp4?api_key=token";
|
||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
||||
|
||||
await client.getVideoDownloadUrl("item123");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_video_download_url", {
|
||||
handle: expect.any(String),
|
||||
itemId: "item123",
|
||||
quality: "original",
|
||||
mediaSourceId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should support quality presets", async () => {
|
||||
const qualities = ["original", "1080p", "720p", "480p"];
|
||||
|
||||
for (const quality of qualities) {
|
||||
vi.clearAllMocks();
|
||||
(invoke as any).mockResolvedValueOnce("test-handle-123");
|
||||
await client.create("https://server.com", "user1", "token123", "server1");
|
||||
|
||||
(invoke as any).mockResolvedValueOnce(`https://server.com/stream.mp4?quality=${quality}`);
|
||||
|
||||
await client.getVideoDownloadUrl("item123", quality as any);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_get_video_download_url",
|
||||
expect.objectContaining({
|
||||
quality,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should support optional media source ID", async () => {
|
||||
const mockUrl = "https://server.com/Videos/item123/stream.mp4?api_key=token";
|
||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
||||
|
||||
await client.getVideoDownloadUrl("item123", "720p", "source789");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_video_download_url", {
|
||||
handle: expect.any(String),
|
||||
itemId: "item123",
|
||||
quality: "720p",
|
||||
mediaSourceId: "source789",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Library Methods", () => {
|
||||
beforeEach(async () => {
|
||||
(invoke as any).mockResolvedValueOnce("test-handle-123");
|
||||
await client.create("https://server.com", "user1", "token123", "server1");
|
||||
});
|
||||
|
||||
it("should get libraries from backend", async () => {
|
||||
const mockLibraries = [
|
||||
{ id: "lib1", name: "Music", collectionType: "music" },
|
||||
{ id: "lib2", name: "Movies", collectionType: "movies" },
|
||||
];
|
||||
(invoke as any).mockResolvedValueOnce(mockLibraries);
|
||||
|
||||
const libraries = await client.getLibraries();
|
||||
|
||||
expect(libraries).toEqual(mockLibraries);
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_libraries", {
|
||||
handle: "test-handle-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("should get items with sorting parameters", async () => {
|
||||
const mockResult = {
|
||||
items: [
|
||||
{ id: "item1", name: "Track 1", type: "Audio" },
|
||||
{ id: "item2", name: "Track 2", type: "Audio" },
|
||||
],
|
||||
totalRecordCount: 2,
|
||||
};
|
||||
(invoke as any).mockResolvedValueOnce(mockResult);
|
||||
|
||||
const result = await client.getItems("library123", {
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_items", {
|
||||
handle: "test-handle-123",
|
||||
parentId: "library123",
|
||||
options: {
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
limit: 50,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should search with backend search command", async () => {
|
||||
const mockResult = {
|
||||
items: [
|
||||
{ id: "item1", name: "Search Result 1", type: "Audio" },
|
||||
],
|
||||
totalRecordCount: 1,
|
||||
};
|
||||
(invoke as any).mockResolvedValueOnce(mockResult);
|
||||
|
||||
const result = await client.search("query", {
|
||||
includeItemTypes: ["Audio"],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(invoke).toHaveBeenCalledWith("repository_search", {
|
||||
handle: "test-handle-123",
|
||||
query: "query",
|
||||
options: {
|
||||
includeItemTypes: ["Audio"],
|
||||
limit: 100,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Playback Methods", () => {
|
||||
beforeEach(async () => {
|
||||
(invoke as any).mockResolvedValueOnce("test-handle-123");
|
||||
await client.create("https://server.com", "user1", "token123", "server1");
|
||||
});
|
||||
|
||||
it("should get audio stream URL", async () => {
|
||||
const mockUrl = "https://server.com/Audio/item123/stream.mp3?api_key=token";
|
||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
||||
|
||||
const url = await client.getAudioStreamUrl("item123");
|
||||
|
||||
expect(url).toBe(mockUrl);
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_audio_stream_url", {
|
||||
handle: "test-handle-123",
|
||||
itemId: "item123",
|
||||
});
|
||||
});
|
||||
|
||||
it("should get video stream URL", async () => {
|
||||
const mockUrl = "https://server.com/Videos/item123/stream.mp4?api_key=token";
|
||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
||||
|
||||
const url = await client.getVideoStreamUrl("item123");
|
||||
|
||||
expect(url).toBe(mockUrl);
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_video_stream_url", {
|
||||
handle: "test-handle-123",
|
||||
itemId: "item123",
|
||||
mediaSourceId: null,
|
||||
startTimeSeconds: null,
|
||||
audioStreamIndex: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should get video stream URL with options", async () => {
|
||||
const mockUrl = "https://server.com/Videos/item123/stream.mp4?start=300&api_key=token";
|
||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
||||
|
||||
const url = await client.getVideoStreamUrl("item123", "source456", 300, 0);
|
||||
|
||||
expect(url).toBe(mockUrl);
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_video_stream_url", {
|
||||
handle: "test-handle-123",
|
||||
itemId: "item123",
|
||||
mediaSourceId: "source456",
|
||||
startTimeSeconds: 300,
|
||||
audioStreamIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("should report playback progress", async () => {
|
||||
(invoke as any).mockResolvedValueOnce(undefined);
|
||||
|
||||
await client.reportPlaybackProgress("item123", 5000000);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("repository_report_playback_progress", {
|
||||
handle: "test-handle-123",
|
||||
itemId: "item123",
|
||||
positionTicks: 5000000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should throw error if invoke fails", async () => {
|
||||
(invoke as any).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
await expect(client.create("https://server.com", "user1", "token", "server1")).rejects.toThrow(
|
||||
"Network error"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle missing optional parameters", async () => {
|
||||
(invoke as any).mockResolvedValueOnce("test-handle-123");
|
||||
await client.create("https://server.com", "user1", "token123", "server1");
|
||||
|
||||
(invoke as any).mockResolvedValueOnce("");
|
||||
|
||||
await client.getImageUrl("item123");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_get_image_url",
|
||||
expect.objectContaining({
|
||||
options: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,6 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { QualityPreset } from "./quality-presets";
|
||||
import { QUALITY_PRESETS } from "./quality-presets";
|
||||
import { validateItemId, validateImageType, validateMediaSourceId, validateNumericParam, validateQueryParamValue } from "$lib/utils/validation";
|
||||
import type {
|
||||
Library,
|
||||
MediaItem,
|
||||
@@ -229,79 +228,40 @@ export class RepositoryClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subtitle URL - constructs URL synchronously (no server call)
|
||||
* Get subtitle URL from backend
|
||||
* The Rust backend constructs and returns the URL with proper credentials handling
|
||||
*/
|
||||
getSubtitleUrl(itemId: string, mediaSourceId: string, streamIndex: number, format: string = "vtt"): string {
|
||||
if (!this._serverUrl || !this._accessToken) {
|
||||
throw new Error("Repository not initialized - call create() first");
|
||||
}
|
||||
|
||||
// Validate inputs to prevent injection attacks
|
||||
validateItemId(itemId);
|
||||
validateMediaSourceId(mediaSourceId);
|
||||
const index = validateNumericParam(streamIndex, 0, 1000, "streamIndex");
|
||||
|
||||
// Validate format - only allow safe subtitle formats
|
||||
if (!/^[a-z]+$/.test(format)) {
|
||||
throw new Error("Invalid subtitle format");
|
||||
}
|
||||
|
||||
return `${this._serverUrl}/Videos/${itemId}/${mediaSourceId}/Subtitles/${index}/Stream.${format}?api_key=${this._accessToken}`;
|
||||
async getSubtitleUrl(
|
||||
itemId: string,
|
||||
mediaSourceId: string,
|
||||
streamIndex: number,
|
||||
format: string = "vtt"
|
||||
): Promise<string> {
|
||||
return invoke<string>("repository_get_subtitle_url", {
|
||||
handle: this.ensureHandle(),
|
||||
itemId,
|
||||
mediaSourceId,
|
||||
streamIndex,
|
||||
format,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video download URL with quality preset - constructs URL synchronously
|
||||
* Used for offline downloads
|
||||
* Get video download URL with quality preset from backend
|
||||
* The Rust backend constructs and returns the URL with proper credentials handling
|
||||
* Used for offline downloads and transcoding
|
||||
*/
|
||||
getVideoDownloadUrl(
|
||||
async getVideoDownloadUrl(
|
||||
itemId: string,
|
||||
quality: QualityPreset = "original",
|
||||
mediaSourceId?: string
|
||||
): string {
|
||||
if (!this._serverUrl || !this._accessToken) {
|
||||
throw new Error("Repository not initialized - call create() first");
|
||||
}
|
||||
|
||||
// Validate itemId and mediaSourceId
|
||||
validateItemId(itemId);
|
||||
if (mediaSourceId) {
|
||||
validateMediaSourceId(mediaSourceId);
|
||||
}
|
||||
|
||||
const preset = QUALITY_PRESETS[quality];
|
||||
|
||||
if (quality === "original" || !preset.videoBitrate) {
|
||||
// Direct stream for original quality
|
||||
const params = new URLSearchParams({
|
||||
api_key: this._accessToken,
|
||||
Static: "true",
|
||||
audioStreamIndex: "0",
|
||||
});
|
||||
if (mediaSourceId) {
|
||||
params.append("MediaSourceId", mediaSourceId);
|
||||
}
|
||||
return `${this._serverUrl}/Videos/${itemId}/stream?${params.toString()}`;
|
||||
}
|
||||
|
||||
// Transcoded download with quality preset
|
||||
const params = new URLSearchParams({
|
||||
api_key: this._accessToken,
|
||||
DeviceId: localStorage.getItem("jellytau_device_id") || "jellytau",
|
||||
Container: "mp4",
|
||||
VideoCodec: "h264",
|
||||
AudioCodec: "aac",
|
||||
AudioStreamIndex: "0",
|
||||
VideoBitrate: preset.videoBitrate.toString(),
|
||||
AudioBitrate: preset.audioBitrate.toString(),
|
||||
MaxHeight: preset.maxHeight?.toString() ?? "",
|
||||
TranscodingMaxAudioChannels: "2",
|
||||
): Promise<string> {
|
||||
return invoke<string>("repository_get_video_download_url", {
|
||||
handle: this.ensureHandle(),
|
||||
itemId,
|
||||
quality,
|
||||
mediaSourceId: mediaSourceId ?? null,
|
||||
});
|
||||
|
||||
if (mediaSourceId) {
|
||||
params.append("MediaSourceId", mediaSourceId);
|
||||
}
|
||||
|
||||
return `${this._serverUrl}/Videos/${itemId}/stream.mp4?${params.toString()}`;
|
||||
}
|
||||
|
||||
// ===== Favorite Methods (via Rust) =====
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
let currentIndex = $state(0);
|
||||
let intervalId: number | null = null;
|
||||
let heroImageUrl = $state<string>("");
|
||||
|
||||
// Touch/swipe state
|
||||
let touchStartX = $state(0);
|
||||
@@ -21,65 +22,81 @@
|
||||
|
||||
const currentItem = $derived(items[currentIndex] ?? null);
|
||||
|
||||
function getHeroImageUrl(): string {
|
||||
if (!currentItem) return "";
|
||||
const repo = auth.getRepository();
|
||||
|
||||
// 1. Try backdrop image first (best for hero display)
|
||||
if (currentItem.backdropImageTags?.[0]) {
|
||||
return repo.getImageUrl(currentItem.id, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
tag: currentItem.backdropImageTags[0],
|
||||
});
|
||||
// Load hero image URL asynchronously based on item priority
|
||||
async function loadHeroImageUrl(): Promise<void> {
|
||||
if (!currentItem) {
|
||||
heroImageUrl = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. For episodes, try to use series backdrop from parent
|
||||
if (currentItem.type === "Episode") {
|
||||
// First try parent backdrop tags (includes image tag for caching)
|
||||
if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) {
|
||||
return repo.getImageUrl(currentItem.seriesId, "Backdrop", {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
|
||||
// 1. Try backdrop image first (best for hero display)
|
||||
if (currentItem.backdropImageTags?.[0]) {
|
||||
heroImageUrl = await repo.getImageUrl(currentItem.id, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
tag: currentItem.parentBackdropImageTags[0],
|
||||
tag: currentItem.backdropImageTags[0],
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Fallback: try series backdrop without tag (may not be cached optimally)
|
||||
if (currentItem.seriesId) {
|
||||
return repo.getImageUrl(currentItem.seriesId, "Backdrop", {
|
||||
|
||||
// 2. For episodes, try to use series backdrop from parent
|
||||
if (currentItem.type === "Episode") {
|
||||
// First try parent backdrop tags (includes image tag for caching)
|
||||
if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) {
|
||||
heroImageUrl = await repo.getImageUrl(currentItem.seriesId, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
tag: currentItem.parentBackdropImageTags[0],
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Fallback: try series backdrop without tag (may not be cached optimally)
|
||||
if (currentItem.seriesId) {
|
||||
heroImageUrl = await repo.getImageUrl(currentItem.seriesId, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Last resort for episodes: try season backdrop
|
||||
if (currentItem.seasonId) {
|
||||
heroImageUrl = await repo.getImageUrl(currentItem.seasonId, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. For music tracks, try album backdrop first, then primary
|
||||
if (currentItem.type === "Audio" && currentItem.albumId) {
|
||||
// Try album backdrop first (more cinematic for hero)
|
||||
heroImageUrl = await repo.getImageUrl(currentItem.albumId, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Last resort for episodes: try season backdrop
|
||||
if (currentItem.seasonId) {
|
||||
return repo.getImageUrl(currentItem.seasonId, "Backdrop", {
|
||||
|
||||
// 4. Fall back to primary image (poster, album art, episode thumbnail)
|
||||
if (currentItem.primaryImageTag) {
|
||||
heroImageUrl = await repo.getImageUrl(currentItem.id, "Primary", {
|
||||
maxWidth: 1920,
|
||||
tag: currentItem.primaryImageTag,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Last resort for audio: try album primary image
|
||||
if (currentItem.type === "Audio" && currentItem.albumId) {
|
||||
heroImageUrl = await repo.getImageUrl(currentItem.albumId, "Primary", {
|
||||
maxWidth: 1920,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. For music tracks, try album backdrop first, then primary
|
||||
if (currentItem.type === "Audio" && currentItem.albumId) {
|
||||
// Try album backdrop first (more cinematic for hero)
|
||||
return repo.getImageUrl(currentItem.albumId, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
});
|
||||
heroImageUrl = "";
|
||||
} catch {
|
||||
heroImageUrl = "";
|
||||
}
|
||||
|
||||
// 4. Fall back to primary image (poster, album art, episode thumbnail)
|
||||
if (currentItem.primaryImageTag) {
|
||||
return repo.getImageUrl(currentItem.id, "Primary", {
|
||||
maxWidth: 1920,
|
||||
tag: currentItem.primaryImageTag,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Last resort for audio: try album primary image
|
||||
if (currentItem.type === "Audio" && currentItem.albumId) {
|
||||
return repo.getImageUrl(currentItem.albumId, "Primary", {
|
||||
maxWidth: 1920,
|
||||
});
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function next() {
|
||||
@@ -126,6 +143,11 @@
|
||||
touchEndX = 0;
|
||||
}
|
||||
|
||||
// Load hero image whenever current item changes
|
||||
$effect(() => {
|
||||
loadHeroImageUrl();
|
||||
});
|
||||
|
||||
// Auto-rotate logic
|
||||
$effect(() => {
|
||||
if (autoRotate && items.length > 1) {
|
||||
@@ -135,8 +157,6 @@
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const heroImageUrl = $derived(getHeroImageUrl());
|
||||
</script>
|
||||
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/svelte";
|
||||
|
||||
/**
|
||||
* Integration tests for async image loading pattern used in components
|
||||
*
|
||||
* Pattern:
|
||||
* - Component has $state<string> imageUrl = ""
|
||||
* - Component has async loadImageUrl() function
|
||||
* - Component uses $effect to call loadImageUrl when dependencies change
|
||||
* - For lists: uses Map<string, string> to cache URLs per item
|
||||
*/
|
||||
|
||||
// Mock repository with getImageUrl
|
||||
const createMockRepository = () => ({
|
||||
getImageUrl: vi.fn(),
|
||||
});
|
||||
|
||||
describe("Async Image Loading Pattern", () => {
|
||||
let mockRepository: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepository = createMockRepository();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
describe("Single Image Loading", () => {
|
||||
it("should load image URL asynchronously on component mount", async () => {
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
||||
|
||||
// Simulating component with async image loading
|
||||
const imageUrl = await mockRepository.getImageUrl("item123", "Primary");
|
||||
|
||||
expect(imageUrl).toBe("https://server.com/image.jpg");
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledWith("item123", "Primary");
|
||||
});
|
||||
|
||||
it("should show placeholder while loading", async () => {
|
||||
mockRepository.getImageUrl.mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve("https://server.com/image.jpg"), 100))
|
||||
);
|
||||
|
||||
vi.useFakeTimers();
|
||||
const promise = mockRepository.getImageUrl("item123", "Primary");
|
||||
|
||||
// Initially no URL
|
||||
expect(promise).toBeInstanceOf(Promise);
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
vi.useRealTimers();
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toBe("https://server.com/image.jpg");
|
||||
});
|
||||
|
||||
it("should reload image when item changes", async () => {
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image1.jpg");
|
||||
|
||||
const url1 = await mockRepository.getImageUrl("item1", "Primary");
|
||||
expect(url1).toBe("https://server.com/image1.jpg");
|
||||
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image2.jpg");
|
||||
|
||||
const url2 = await mockRepository.getImageUrl("item2", "Primary");
|
||||
expect(url2).toBe("https://server.com/image2.jpg");
|
||||
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should not reload image if item ID hasn't changed", async () => {
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
||||
|
||||
// First load
|
||||
await mockRepository.getImageUrl("item123", "Primary");
|
||||
|
||||
// Would normally use $effect to track changes
|
||||
// If item ID is same, should not reload (handled by component caching)
|
||||
// This test documents the expected behavior
|
||||
});
|
||||
|
||||
it("should handle load errors gracefully", async () => {
|
||||
mockRepository.getImageUrl.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
// Component should catch error and show placeholder
|
||||
try {
|
||||
await mockRepository.getImageUrl("item123", "Primary");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(Error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("List Image Caching (Map-based)", () => {
|
||||
it("should cache URLs using Map<string, string>", () => {
|
||||
// Simulating component state: imageUrls = $state<Map<string, string>>(new Map())
|
||||
const imageUrls = new Map<string, string>();
|
||||
|
||||
// Load first item
|
||||
imageUrls.set("item1", "https://server.com/image1.jpg");
|
||||
expect(imageUrls.has("item1")).toBe(true);
|
||||
expect(imageUrls.get("item1")).toBe("https://server.com/image1.jpg");
|
||||
|
||||
// Load second item
|
||||
imageUrls.set("item2", "https://server.com/image2.jpg");
|
||||
expect(imageUrls.size).toBe(2);
|
||||
|
||||
// Check cache hit
|
||||
expect(imageUrls.get("item1")).toBe("https://server.com/image1.jpg");
|
||||
});
|
||||
|
||||
it("should load images only once per item", async () => {
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
||||
|
||||
const imageUrls = new Map<string, string>();
|
||||
|
||||
// Simulate loading multiple items
|
||||
const items = [
|
||||
{ id: "item1", name: "Album 1" },
|
||||
{ id: "item2", name: "Album 2" },
|
||||
{ id: "item1", name: "Album 1 (again)" }, // Same ID
|
||||
];
|
||||
|
||||
for (const item of items) {
|
||||
if (!imageUrls.has(item.id)) {
|
||||
const url = await mockRepository.getImageUrl(item.id, "Primary");
|
||||
imageUrls.set(item.id, url);
|
||||
}
|
||||
}
|
||||
|
||||
// Should only call once per unique ID
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should update single item without affecting others", async () => {
|
||||
const imageUrls = new Map<string, string>();
|
||||
|
||||
imageUrls.set("item1", "https://server.com/image1.jpg");
|
||||
imageUrls.set("item2", "https://server.com/image2.jpg");
|
||||
imageUrls.set("item3", "https://server.com/image3.jpg");
|
||||
|
||||
// Update item2
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image2_updated.jpg");
|
||||
const newUrl = await mockRepository.getImageUrl("item2", "Primary");
|
||||
imageUrls.set("item2", newUrl);
|
||||
|
||||
// Others should remain unchanged
|
||||
expect(imageUrls.get("item1")).toBe("https://server.com/image1.jpg");
|
||||
expect(imageUrls.get("item2")).toBe("https://server.com/image2_updated.jpg");
|
||||
expect(imageUrls.get("item3")).toBe("https://server.com/image3.jpg");
|
||||
});
|
||||
|
||||
it("should clear cache when data changes", () => {
|
||||
const imageUrls = new Map<string, string>();
|
||||
|
||||
imageUrls.set("item1", "https://server.com/image1.jpg");
|
||||
imageUrls.set("item2", "https://server.com/image2.jpg");
|
||||
|
||||
// Clear cache
|
||||
imageUrls.clear();
|
||||
|
||||
expect(imageUrls.size).toBe(0);
|
||||
expect(imageUrls.has("item1")).toBe(false);
|
||||
});
|
||||
|
||||
it("should support Map operations efficiently", () => {
|
||||
const imageUrls = new Map<string, string>();
|
||||
|
||||
// Add items
|
||||
for (let i = 0; i < 100; i++) {
|
||||
imageUrls.set(`item${i}`, `https://server.com/image${i}.jpg`);
|
||||
}
|
||||
|
||||
expect(imageUrls.size).toBe(100);
|
||||
|
||||
// Check specific item
|
||||
expect(imageUrls.has("item50")).toBe(true);
|
||||
expect(imageUrls.get("item50")).toBe("https://server.com/image50.jpg");
|
||||
|
||||
// Iterate
|
||||
let count = 0;
|
||||
imageUrls.forEach(() => {
|
||||
count++;
|
||||
});
|
||||
expect(count).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Component Lifecycle ($effect integration)", () => {
|
||||
it("should trigger load on prop change", async () => {
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
||||
|
||||
// Simulate $effect tracking prop changes
|
||||
let effectCount = 0;
|
||||
const trackingEffect = vi.fn(() => {
|
||||
effectCount++;
|
||||
return mockRepository.getImageUrl("item123", "Primary");
|
||||
});
|
||||
|
||||
trackingEffect();
|
||||
expect(effectCount).toBe(1);
|
||||
|
||||
trackingEffect();
|
||||
expect(effectCount).toBe(2);
|
||||
});
|
||||
|
||||
it("should skip load if conditions not met", async () => {
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
||||
|
||||
// Simulate conditional loading (e.g., if (!imageUrl && primaryImageTag))
|
||||
let imageUrl = "";
|
||||
const primaryImageTag = "";
|
||||
|
||||
if (!imageUrl && primaryImageTag) {
|
||||
imageUrl = await mockRepository.getImageUrl("item123", "Primary");
|
||||
}
|
||||
|
||||
expect(mockRepository.getImageUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle dependent state updates", async () => {
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
||||
|
||||
// Simulate component state changes triggering effects
|
||||
const state = {
|
||||
item: { id: "item1", primaryImageTag: "tag1" },
|
||||
imageUrl: "",
|
||||
};
|
||||
|
||||
const loadImage = async () => {
|
||||
if (state.item.primaryImageTag) {
|
||||
state.imageUrl = await mockRepository.getImageUrl(state.item.id, "Primary");
|
||||
}
|
||||
};
|
||||
|
||||
await loadImage();
|
||||
expect(state.imageUrl).toBe("https://server.com/image.jpg");
|
||||
|
||||
// Change item
|
||||
state.item = { id: "item2", primaryImageTag: "tag2" };
|
||||
state.imageUrl = "";
|
||||
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image2.jpg");
|
||||
await loadImage();
|
||||
expect(state.imageUrl).toBe("https://server.com/image2.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling in Async Loading", () => {
|
||||
it("should set empty string on error", async () => {
|
||||
mockRepository.getImageUrl.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
let imageUrl = "";
|
||||
|
||||
try {
|
||||
imageUrl = await mockRepository.getImageUrl("item123", "Primary");
|
||||
} catch {
|
||||
imageUrl = ""; // Set to empty on error
|
||||
}
|
||||
|
||||
expect(imageUrl).toBe("");
|
||||
});
|
||||
|
||||
it("should allow retry after error", async () => {
|
||||
mockRepository.getImageUrl
|
||||
.mockRejectedValueOnce(new Error("Network error"))
|
||||
.mockResolvedValueOnce("https://server.com/image.jpg");
|
||||
|
||||
let imageUrl = "";
|
||||
|
||||
// First attempt fails
|
||||
try {
|
||||
imageUrl = await mockRepository.getImageUrl("item123", "Primary");
|
||||
} catch {
|
||||
imageUrl = "";
|
||||
}
|
||||
|
||||
// Retry succeeds
|
||||
imageUrl = await mockRepository.getImageUrl("item123", "Primary");
|
||||
expect(imageUrl).toBe("https://server.com/image.jpg");
|
||||
});
|
||||
|
||||
it("should handle concurrent load requests", async () => {
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
||||
|
||||
// Simulate loading multiple images concurrently
|
||||
const imageUrls = new Map<string, string>();
|
||||
const items = [
|
||||
{ id: "item1" },
|
||||
{ id: "item2" },
|
||||
{ id: "item3" },
|
||||
];
|
||||
|
||||
const promises = items.map(item =>
|
||||
mockRepository.getImageUrl(item.id, "Primary")
|
||||
.then(url => imageUrls.set(item.id, url))
|
||||
.catch(() => imageUrls.set(item.id, ""))
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(imageUrls.size).toBe(3);
|
||||
expect(imageUrls.has("item1")).toBe(true);
|
||||
expect(imageUrls.has("item2")).toBe(true);
|
||||
expect(imageUrls.has("item3")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance Characteristics", () => {
|
||||
it("should not reload unnecessarily", async () => {
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
||||
|
||||
// Simulate $effect with dependency tracking
|
||||
let dependencyValue = "same";
|
||||
let previousDependency = "same";
|
||||
|
||||
const loadImage = async () => {
|
||||
if (dependencyValue !== previousDependency) {
|
||||
previousDependency = dependencyValue;
|
||||
return await mockRepository.getImageUrl("item123", "Primary");
|
||||
}
|
||||
};
|
||||
|
||||
await loadImage();
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
|
||||
|
||||
// No change in dependency
|
||||
await loadImage();
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Change dependency
|
||||
dependencyValue = "changed";
|
||||
await loadImage();
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should handle large lists efficiently", async () => {
|
||||
const imageUrls = new Map<string, string>();
|
||||
let loadCount = 0;
|
||||
|
||||
mockRepository.getImageUrl.mockImplementation(() => {
|
||||
loadCount++;
|
||||
return Promise.resolve("https://server.com/image.jpg");
|
||||
});
|
||||
|
||||
// Simulate loading 1000 items but caching URLs
|
||||
const items = Array.from({ length: 1000 }, (_, i) => ({ id: `item${i % 10}` }));
|
||||
|
||||
for (const item of items) {
|
||||
if (!imageUrls.has(item.id)) {
|
||||
const url = await mockRepository.getImageUrl(item.id, "Primary");
|
||||
imageUrls.set(item.id, url);
|
||||
}
|
||||
}
|
||||
|
||||
// Should only load 10 unique images
|
||||
expect(loadCount).toBe(10);
|
||||
expect(imageUrls.size).toBe(10);
|
||||
});
|
||||
|
||||
it("should not block rendering during async loading", () => {
|
||||
mockRepository.getImageUrl.mockImplementation(
|
||||
() => new Promise((resolve) =>
|
||||
setTimeout(() => resolve("https://server.com/image.jpg"), 1000)
|
||||
)
|
||||
);
|
||||
|
||||
// Async operation should not block component rendering
|
||||
const renderTiming = {
|
||||
startRender: Date.now(),
|
||||
loadStart: null as number | null,
|
||||
loadComplete: null as number | null,
|
||||
};
|
||||
|
||||
// Render happens immediately
|
||||
renderTiming.startRender = Date.now();
|
||||
|
||||
// Load happens asynchronously
|
||||
mockRepository.getImageUrl("item123", "Primary").then(() => {
|
||||
renderTiming.loadComplete = Date.now();
|
||||
});
|
||||
|
||||
// Render should complete before load finishes
|
||||
expect(Date.now() - renderTiming.startRender).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Backend Integration", () => {
|
||||
it("should call backend with correct parameters", async () => {
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
||||
|
||||
await mockRepository.getImageUrl("item123", "Primary", {
|
||||
maxWidth: 300,
|
||||
});
|
||||
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledWith(
|
||||
"item123",
|
||||
"Primary",
|
||||
{
|
||||
maxWidth: 300,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle backend URL correctly", async () => {
|
||||
const backendUrl = "https://server.com/Items/item123/Images/Primary?maxWidth=300&api_key=token";
|
||||
mockRepository.getImageUrl.mockResolvedValue(backendUrl);
|
||||
|
||||
const url = await mockRepository.getImageUrl("item123", "Primary", { maxWidth: 300 });
|
||||
|
||||
expect(url).toBe(backendUrl);
|
||||
// Frontend never constructs URLs directly
|
||||
expect(url).toContain("api_key=");
|
||||
});
|
||||
|
||||
it("should not require URL construction in frontend", async () => {
|
||||
// Frontend receives pre-constructed URL from backend
|
||||
const preConstructedUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
|
||||
mockRepository.getImageUrl.mockResolvedValue(preConstructedUrl);
|
||||
|
||||
const url = await mockRepository.getImageUrl("item123", "Primary");
|
||||
|
||||
// Frontend just uses the URL
|
||||
expect(url).toContain("https://");
|
||||
expect(url).toContain("item123");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,9 @@
|
||||
|
||||
let { people, title = "Cast & Crew" }: Props = $props();
|
||||
|
||||
// Map of person IDs to their image URLs, loaded asynchronously
|
||||
let personImageUrls = $state<Map<string, string>>(new Map());
|
||||
|
||||
// Group people by type
|
||||
const groupedPeople = $derived.by(() => {
|
||||
const groups: Record<string, Person[]> = {
|
||||
@@ -58,18 +61,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getPersonImageUrl(person: Person): string {
|
||||
// Load image URL for a single person
|
||||
async function loadPersonImageUrl(person: Person): Promise<void> {
|
||||
if (!person.primaryImageTag || personImageUrls.has(person.id)) return;
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
return repo.getImageUrl(person.id, "Primary", {
|
||||
const url = await repo.getImageUrl(person.id, "Primary", {
|
||||
maxWidth: 200,
|
||||
tag: person.primaryImageTag,
|
||||
});
|
||||
personImageUrls.set(person.id, url);
|
||||
} catch {
|
||||
return "";
|
||||
personImageUrls.set(person.id, "");
|
||||
}
|
||||
}
|
||||
|
||||
// Load image URLs for all people
|
||||
$effect(() => {
|
||||
people.forEach((person) => {
|
||||
if (person.primaryImageTag && !personImageUrls.has(person.id)) {
|
||||
loadPersonImageUrl(person);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function handlePersonClick(person: Person) {
|
||||
goto(`/library/${person.id}`);
|
||||
}
|
||||
@@ -94,9 +110,9 @@
|
||||
>
|
||||
<!-- Person image -->
|
||||
<div class="w-24 h-24 rounded-full overflow-hidden bg-[var(--color-surface)] mb-2">
|
||||
{#if person.primaryImageTag}
|
||||
{#if person.primaryImageTag && personImageUrls.get(person.id)}
|
||||
<img
|
||||
src={getPersonImageUrl(person)}
|
||||
src={personImageUrls.get(person.id)}
|
||||
alt={person.name}
|
||||
class="w-full h-full object-cover group-hover:scale-110 transition-transform"
|
||||
loading="lazy"
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
|
||||
let { episode, series, allEpisodes, onBack }: Props = $props();
|
||||
|
||||
let backdropUrl = $state<string>("");
|
||||
let episodeThumbnailUrls = $state<Map<string, string>>(new Map());
|
||||
|
||||
// Check if an episode matches the focused episode (by ID or season/episode number)
|
||||
function isCurrentEpisode(ep: MediaItem): boolean {
|
||||
if (ep.id === episode.id) return true;
|
||||
@@ -70,52 +73,74 @@
|
||||
return allEpisodes.slice(start, end);
|
||||
});
|
||||
|
||||
function getBackdropUrl(): string {
|
||||
// Load backdrop URL asynchronously
|
||||
async function loadBackdropUrl(): Promise<void> {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
|
||||
// Try episode backdrop first
|
||||
if (episode.backdropImageTags?.[0]) {
|
||||
return repo.getImageUrl(episode.id, "Backdrop", {
|
||||
backdropUrl = await repo.getImageUrl(episode.id, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
tag: episode.backdropImageTags[0],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Try episode primary (thumbnail)
|
||||
if (episode.primaryImageTag) {
|
||||
return repo.getImageUrl(episode.id, "Primary", {
|
||||
backdropUrl = await repo.getImageUrl(episode.id, "Primary", {
|
||||
maxWidth: 1920,
|
||||
tag: episode.primaryImageTag,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to series backdrop
|
||||
if (series.backdropImageTags?.[0]) {
|
||||
return repo.getImageUrl(series.id, "Backdrop", {
|
||||
backdropUrl = await repo.getImageUrl(series.id, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
tag: series.backdropImageTags[0],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return "";
|
||||
backdropUrl = "";
|
||||
} catch {
|
||||
return "";
|
||||
backdropUrl = "";
|
||||
}
|
||||
}
|
||||
|
||||
function getEpisodeThumbnail(ep: MediaItem): string {
|
||||
// Load episode thumbnail URL for a single episode
|
||||
async function loadEpisodeThumbnailUrl(ep: MediaItem): Promise<void> {
|
||||
if (!ep.primaryImageTag || episodeThumbnailUrls.has(ep.id)) return;
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
return repo.getImageUrl(ep.id, "Primary", {
|
||||
const url = await repo.getImageUrl(ep.id, "Primary", {
|
||||
maxWidth: 400,
|
||||
tag: ep.primaryImageTag,
|
||||
});
|
||||
episodeThumbnailUrls.set(ep.id, url);
|
||||
} catch {
|
||||
return "";
|
||||
episodeThumbnailUrls.set(ep.id, "");
|
||||
}
|
||||
}
|
||||
|
||||
// Load backdrop when episode changes
|
||||
$effect(() => {
|
||||
loadBackdropUrl();
|
||||
});
|
||||
|
||||
// Load episode thumbnail URLs when adjacent episodes change
|
||||
$effect(() => {
|
||||
adjacentEpisodes().forEach((ep) => {
|
||||
if (ep.primaryImageTag && !episodeThumbnailUrls.has(ep.id)) {
|
||||
loadEpisodeThumbnailUrl(ep);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function formatDuration(ticks?: number): string {
|
||||
if (!ticks) return "";
|
||||
const seconds = Math.floor(ticks / 10000000);
|
||||
@@ -143,7 +168,6 @@
|
||||
goto(`/library/${series.id}?episode=${ep.id}`);
|
||||
}
|
||||
|
||||
const backdropUrl = $derived(getBackdropUrl());
|
||||
const episodeLabel = $derived(
|
||||
`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`
|
||||
);
|
||||
@@ -264,7 +288,7 @@
|
||||
{#each adjacentEpisodes() as ep (ep.id)}
|
||||
{@const isCurrent = isCurrentEpisode(ep)}
|
||||
{@const epProgress = getProgress(ep)}
|
||||
{@const thumbUrl = getEpisodeThumbnail(ep)}
|
||||
{@const thumbUrl = episodeThumbnailUrls.get(ep.id) ?? ""}
|
||||
<button
|
||||
onclick={() => !isCurrent && handleEpisodeClick(ep)}
|
||||
class="flex-shrink-0 w-64 text-left group/card {isCurrent ? 'ring-2 ring-yellow-400 rounded-lg' : ''}"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { formatDuration } from "$lib/utils/duration";
|
||||
import VideoDownloadButton from "./VideoDownloadButton.svelte";
|
||||
|
||||
interface Props {
|
||||
@@ -14,6 +15,7 @@
|
||||
let { episode, focused = false, onclick }: Props = $props();
|
||||
|
||||
let buttonRef: HTMLButtonElement | null = null;
|
||||
let imageUrl = $state<string>("");
|
||||
|
||||
onMount(() => {
|
||||
if (focused && buttonRef) {
|
||||
@@ -35,39 +37,31 @@
|
||||
);
|
||||
const downloadProgress = $derived(downloadInfo?.progress || 0);
|
||||
|
||||
function getImageUrl(): string {
|
||||
// Load image URL asynchronously
|
||||
async function loadImageUrl(): Promise<void> {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
return repo.getImageUrl(episode.id, "Primary", {
|
||||
imageUrl = await repo.getImageUrl(episode.id, "Primary", {
|
||||
maxWidth: 320,
|
||||
tag: episode.primaryImageTag,
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
imageUrl = "";
|
||||
}
|
||||
}
|
||||
|
||||
function getProgress(): number {
|
||||
// Load image when episode changes
|
||||
$effect(() => {
|
||||
loadImageUrl();
|
||||
});
|
||||
|
||||
const progress = $derived(() => {
|
||||
if (!episode.userData || !episode.runTimeTicks) {
|
||||
return 0;
|
||||
}
|
||||
return (episode.userData.playbackPositionTicks / episode.runTimeTicks) * 100;
|
||||
}
|
||||
});
|
||||
|
||||
function formatDuration(ticks?: number): string {
|
||||
if (!ticks) return "";
|
||||
const seconds = Math.floor(ticks / 10000000);
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
const imageUrl = $derived(getImageUrl());
|
||||
const progress = $derived(getProgress());
|
||||
const duration = $derived(formatDuration(episode.runTimeTicks));
|
||||
const episodeNumber = $derived(episode.indexNumber || 0);
|
||||
</script>
|
||||
@@ -107,11 +101,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
{#if progress > 0}
|
||||
{#if progress() > 0}
|
||||
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
|
||||
<div
|
||||
class="h-full bg-[var(--color-jellyfin)]"
|
||||
style="width: {progress}%"
|
||||
style="width: {progress()}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
let selectedGenre = $state<Genre | null>(null);
|
||||
let genreItems = $state<MediaItem[]>([]);
|
||||
let loadingItems = $state(false);
|
||||
let genreItemImageUrls = $state<Map<string, string>>(new Map());
|
||||
|
||||
const { markLoaded } = useServerReachabilityReload(async () => {
|
||||
await loadGenres();
|
||||
@@ -79,6 +80,7 @@
|
||||
try {
|
||||
loadingItems = true;
|
||||
selectedGenre = genre;
|
||||
genreItemImageUrls = new Map(); // Clear image URLs when loading new genre
|
||||
const repo = auth.getRepository();
|
||||
const result = await repo.getItems($currentLibrary.id, {
|
||||
includeItemTypes: config.itemTypes,
|
||||
@@ -96,6 +98,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Load image URL for a single item
|
||||
async function loadGenreItemImage(item: MediaItem): Promise<void> {
|
||||
if (!item.primaryImageTag || genreItemImageUrls.has(item.id)) return;
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const url = await repo.getImageUrl(item.id, "Primary", {
|
||||
maxWidth: 300,
|
||||
tag: item.primaryImageTag,
|
||||
});
|
||||
genreItemImageUrls.set(item.id, url);
|
||||
} catch {
|
||||
genreItemImageUrls.set(item.id, "");
|
||||
}
|
||||
}
|
||||
|
||||
// Load image URLs for all genre items
|
||||
$effect(() => {
|
||||
genreItems.forEach((item) => {
|
||||
if (item.primaryImageTag && !genreItemImageUrls.has(item.id)) {
|
||||
loadGenreItemImage(item);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function applyFilter() {
|
||||
let result = [...genres];
|
||||
|
||||
@@ -217,12 +244,9 @@
|
||||
{#each genreItems as item (item.id)}
|
||||
<button onclick={() => handleItemClick(item)} class="group text-left">
|
||||
<div class="{aspectRatioClass} bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2">
|
||||
{#if item.primaryImageTag}
|
||||
{#if item.primaryImageTag && genreItemImageUrls.get(item.id)}
|
||||
<img
|
||||
src={auth.getRepository().getImageUrl(item.id, "Primary", {
|
||||
maxWidth: 300,
|
||||
tag: item.primaryImageTag,
|
||||
})}
|
||||
src={genreItemImageUrls.get(item.id)}
|
||||
alt={item.name}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||
/>
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
sortOptions: Array<{ key: string; label: string }>; // Jellyfin field names
|
||||
defaultSort: string; // Jellyfin field name (e.g., "SortName")
|
||||
displayComponent: "grid" | "tracklist"; // Which component to use
|
||||
searchFields: string[]; // Which fields to search in: ["name", "artists"], etc.
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -42,8 +41,10 @@
|
||||
let items = $state<MediaItem[]>([]);
|
||||
let loading = $state(true);
|
||||
let searchQuery = $state("");
|
||||
let debouncedSearchQuery = $state("");
|
||||
let sortBy = $state<string>(config.defaultSort);
|
||||
let sortOrder = $state<"Ascending" | "Descending">("Ascending");
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const { markLoaded } = useServerReachabilityReload(async () => {
|
||||
await loadItems();
|
||||
@@ -65,8 +66,8 @@
|
||||
const repo = auth.getRepository();
|
||||
|
||||
// Use backend search if search query is provided, otherwise use getItems with sort
|
||||
if (searchQuery.trim()) {
|
||||
const result = await repo.search(searchQuery, {
|
||||
if (debouncedSearchQuery.trim()) {
|
||||
const result = await repo.search(debouncedSearchQuery, {
|
||||
includeItemTypes: [config.itemType],
|
||||
limit: 10000,
|
||||
});
|
||||
@@ -90,9 +91,18 @@
|
||||
|
||||
function handleSearch(query: string) {
|
||||
searchQuery = query;
|
||||
loadItems();
|
||||
}
|
||||
|
||||
// Debounce search input (300ms delay)
|
||||
$effect(() => {
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
|
||||
searchTimeout = setTimeout(() => {
|
||||
debouncedSearchQuery = searchQuery;
|
||||
loadItems();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
function handleSort(newSort: string) {
|
||||
sortBy = newSort;
|
||||
loadItems();
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/svelte";
|
||||
import GenericMediaListPage from "./GenericMediaListPage.svelte";
|
||||
|
||||
// Mock SvelteKit navigation
|
||||
vi.mock("$app/navigation", () => ({
|
||||
goto: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock stores
|
||||
vi.mock("$lib/stores/library", () => ({
|
||||
currentLibrary: {
|
||||
subscribe: vi.fn((fn) => {
|
||||
fn({ id: "lib123", name: "Music" });
|
||||
return vi.fn();
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/auth", () => ({
|
||||
auth: {
|
||||
getRepository: vi.fn(() => ({
|
||||
getItems: vi.fn(),
|
||||
search: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/composables/useServerReachabilityReload", () => ({
|
||||
useServerReachabilityReload: vi.fn(() => ({
|
||||
markLoaded: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("GenericMediaListPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
describe("Component Initialization", () => {
|
||||
it("should render with title and search bar", () => {
|
||||
const config = {
|
||||
itemType: "Audio",
|
||||
title: "Tracks",
|
||||
backPath: "/library/music",
|
||||
searchPlaceholder: "Search tracks...",
|
||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
||||
defaultSort: "SortName",
|
||||
displayComponent: "tracklist" as const,
|
||||
};
|
||||
|
||||
const { container } = render(GenericMediaListPage, {
|
||||
props: { config },
|
||||
});
|
||||
|
||||
const heading = screen.getByText("Tracks");
|
||||
expect(heading).toBeTruthy();
|
||||
|
||||
const searchInput = container.querySelector('input[type="text"]');
|
||||
expect(searchInput).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should load items on mount", async () => {
|
||||
const config = {
|
||||
itemType: "Audio",
|
||||
title: "Tracks",
|
||||
backPath: "/library/music",
|
||||
searchPlaceholder: "Search tracks...",
|
||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
||||
defaultSort: "SortName",
|
||||
displayComponent: "tracklist" as const,
|
||||
};
|
||||
|
||||
render(GenericMediaListPage, {
|
||||
props: { config },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// loadItems should have been called
|
||||
});
|
||||
});
|
||||
|
||||
it("should display sort options", () => {
|
||||
const config = {
|
||||
itemType: "MusicAlbum",
|
||||
title: "Albums",
|
||||
backPath: "/library/music",
|
||||
searchPlaceholder: "Search albums...",
|
||||
sortOptions: [
|
||||
{ key: "SortName", label: "Title" },
|
||||
{ key: "Artist", label: "Artist" },
|
||||
{ key: "ProductionYear", label: "Year" },
|
||||
],
|
||||
defaultSort: "SortName",
|
||||
displayComponent: "grid" as const,
|
||||
};
|
||||
|
||||
render(GenericMediaListPage, {
|
||||
props: { config },
|
||||
});
|
||||
|
||||
// Check that all sort options are rendered
|
||||
const titleOption = screen.queryByText("Title");
|
||||
expect(titleOption).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Search Functionality", () => {
|
||||
it("should debounce search input for 300ms", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const config = {
|
||||
itemType: "Audio",
|
||||
title: "Tracks",
|
||||
backPath: "/library/music",
|
||||
searchPlaceholder: "Search tracks...",
|
||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
||||
defaultSort: "SortName",
|
||||
displayComponent: "tracklist" as const,
|
||||
};
|
||||
|
||||
const { container } = render(GenericMediaListPage, {
|
||||
props: { config },
|
||||
});
|
||||
|
||||
const searchInput = container.querySelector("input") as HTMLInputElement;
|
||||
|
||||
// Type into search
|
||||
fireEvent.input(searchInput, { target: { value: "t" } });
|
||||
expect(searchInput.value).toBe("t");
|
||||
|
||||
// Search should not trigger immediately
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
// Add more characters
|
||||
fireEvent.input(searchInput, { target: { value: "test" } });
|
||||
|
||||
// Still shouldn't trigger (only 100ms passed total)
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
// Now advance to 300ms total - search should trigger
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
await waitFor(() => {
|
||||
// Search should have been debounced
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should use backend search when search query is provided", async () => {
|
||||
const mockSearchFn = vi.fn().mockResolvedValue({
|
||||
items: [{ id: "item1", name: "Test Track" }],
|
||||
totalRecordCount: 1,
|
||||
});
|
||||
|
||||
const mockRepository = {
|
||||
getItems: vi.fn(),
|
||||
search: mockSearchFn,
|
||||
};
|
||||
|
||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
||||
mockRepository as any
|
||||
);
|
||||
|
||||
const config = {
|
||||
itemType: "Audio",
|
||||
title: "Tracks",
|
||||
backPath: "/library/music",
|
||||
searchPlaceholder: "Search tracks...",
|
||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
||||
defaultSort: "SortName",
|
||||
displayComponent: "tracklist" as const,
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
const { container } = render(GenericMediaListPage, {
|
||||
props: { config },
|
||||
});
|
||||
|
||||
const searchInput = container.querySelector("input") as HTMLInputElement;
|
||||
fireEvent.input(searchInput, { target: { value: "test" } });
|
||||
|
||||
// Advance timer to trigger debounced search
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSearchFn).toHaveBeenCalledWith("test", expect.objectContaining({
|
||||
includeItemTypes: ["Audio"],
|
||||
limit: 10000,
|
||||
}));
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should use getItems without search for empty query", async () => {
|
||||
const mockGetItemsFn = vi.fn().mockResolvedValue({
|
||||
items: [],
|
||||
totalRecordCount: 0,
|
||||
});
|
||||
|
||||
const mockRepository = {
|
||||
getItems: mockGetItemsFn,
|
||||
search: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
||||
mockRepository as any
|
||||
);
|
||||
|
||||
const config = {
|
||||
itemType: "Audio",
|
||||
title: "Tracks",
|
||||
backPath: "/library/music",
|
||||
searchPlaceholder: "Search tracks...",
|
||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
||||
defaultSort: "SortName",
|
||||
displayComponent: "tracklist" as const,
|
||||
};
|
||||
|
||||
render(GenericMediaListPage, {
|
||||
props: { config },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.objectContaining({
|
||||
includeItemTypes: ["Audio"],
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it("should clear previous search when input becomes empty", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const mockSearchFn = vi.fn().mockResolvedValue({
|
||||
items: [],
|
||||
totalRecordCount: 0,
|
||||
});
|
||||
|
||||
const mockGetItemsFn = vi.fn().mockResolvedValue({
|
||||
items: [],
|
||||
totalRecordCount: 0,
|
||||
});
|
||||
|
||||
const mockRepository = {
|
||||
getItems: mockGetItemsFn,
|
||||
search: mockSearchFn,
|
||||
};
|
||||
|
||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
||||
mockRepository as any
|
||||
);
|
||||
|
||||
const config = {
|
||||
itemType: "Audio",
|
||||
title: "Tracks",
|
||||
backPath: "/library/music",
|
||||
searchPlaceholder: "Search tracks...",
|
||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
||||
defaultSort: "SortName",
|
||||
displayComponent: "tracklist" as const,
|
||||
};
|
||||
|
||||
const { container } = render(GenericMediaListPage, {
|
||||
props: { config },
|
||||
});
|
||||
|
||||
const searchInput = container.querySelector("input") as HTMLInputElement;
|
||||
|
||||
// Type search query
|
||||
fireEvent.input(searchInput, { target: { value: "test" } });
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
// Clear search
|
||||
fireEvent.input(searchInput, { target: { value: "" } });
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should call getItems when search is cleared
|
||||
expect(mockGetItemsFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sorting Functionality", () => {
|
||||
it("should pass sortBy parameter to backend", async () => {
|
||||
const mockGetItemsFn = vi.fn().mockResolvedValue({
|
||||
items: [],
|
||||
totalRecordCount: 0,
|
||||
});
|
||||
|
||||
const mockRepository = {
|
||||
getItems: mockGetItemsFn,
|
||||
search: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
||||
mockRepository as any
|
||||
);
|
||||
|
||||
const config = {
|
||||
itemType: "MusicAlbum",
|
||||
title: "Albums",
|
||||
backPath: "/library/music",
|
||||
searchPlaceholder: "Search albums...",
|
||||
sortOptions: [
|
||||
{ key: "SortName", label: "Title" },
|
||||
{ key: "Artist", label: "Artist" },
|
||||
],
|
||||
defaultSort: "SortName",
|
||||
displayComponent: "grid" as const,
|
||||
};
|
||||
|
||||
render(GenericMediaListPage, {
|
||||
props: { config },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.objectContaining({
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it("should pass Jellyfin field names to backend (not custom compareFn)", async () => {
|
||||
const mockGetItemsFn = vi.fn().mockResolvedValue({
|
||||
items: [],
|
||||
totalRecordCount: 0,
|
||||
});
|
||||
|
||||
const mockRepository = {
|
||||
getItems: mockGetItemsFn,
|
||||
search: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
||||
mockRepository as any
|
||||
);
|
||||
|
||||
const config = {
|
||||
itemType: "Audio",
|
||||
title: "Tracks",
|
||||
backPath: "/library/music",
|
||||
searchPlaceholder: "Search tracks...",
|
||||
sortOptions: [
|
||||
{ key: "SortName", label: "Title" },
|
||||
{ key: "Artist", label: "Artist" },
|
||||
{ key: "Album", label: "Album" },
|
||||
{ key: "DatePlayed", label: "Recent" },
|
||||
],
|
||||
defaultSort: "SortName",
|
||||
displayComponent: "tracklist" as const,
|
||||
};
|
||||
|
||||
render(GenericMediaListPage, {
|
||||
props: { config },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const lastCall = (mockGetItemsFn as any).mock.calls[0];
|
||||
const options = lastCall[1];
|
||||
|
||||
// Should pass Jellyfin field names directly
|
||||
expect(typeof options.sortBy).toBe("string");
|
||||
expect(["SortName", "Artist", "Album", "DatePlayed"]).toContain(options.sortBy);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ItemType Filtering", () => {
|
||||
it("should include correct itemType in getItems request", async () => {
|
||||
const mockGetItemsFn = vi.fn().mockResolvedValue({
|
||||
items: [],
|
||||
totalRecordCount: 0,
|
||||
});
|
||||
|
||||
const mockRepository = {
|
||||
getItems: mockGetItemsFn,
|
||||
search: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
||||
mockRepository as any
|
||||
);
|
||||
|
||||
const config = {
|
||||
itemType: "Audio",
|
||||
title: "Tracks",
|
||||
backPath: "/library/music",
|
||||
searchPlaceholder: "Search tracks...",
|
||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
||||
defaultSort: "SortName",
|
||||
displayComponent: "tracklist" as const,
|
||||
};
|
||||
|
||||
render(GenericMediaListPage, {
|
||||
props: { config },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.objectContaining({
|
||||
includeItemTypes: ["Audio"],
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it("should include correct itemType in search request", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const mockSearchFn = vi.fn().mockResolvedValue({
|
||||
items: [],
|
||||
totalRecordCount: 0,
|
||||
});
|
||||
|
||||
const mockRepository = {
|
||||
getItems: vi.fn(),
|
||||
search: mockSearchFn,
|
||||
};
|
||||
|
||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
||||
mockRepository as any
|
||||
);
|
||||
|
||||
const config = {
|
||||
itemType: "MusicAlbum",
|
||||
title: "Albums",
|
||||
backPath: "/library/music",
|
||||
searchPlaceholder: "Search albums...",
|
||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
||||
defaultSort: "SortName",
|
||||
displayComponent: "grid" as const,
|
||||
};
|
||||
|
||||
const { container } = render(GenericMediaListPage, {
|
||||
props: { config },
|
||||
});
|
||||
|
||||
const searchInput = container.querySelector("input") as HTMLInputElement;
|
||||
fireEvent.input(searchInput, { target: { value: "album" } });
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSearchFn).toHaveBeenCalledWith("album", expect.objectContaining({
|
||||
includeItemTypes: ["MusicAlbum"],
|
||||
}));
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Loading State", () => {
|
||||
it("should show loading indicator during data fetch", async () => {
|
||||
const mockGetItemsFn = vi.fn(
|
||||
() => new Promise((resolve) => setTimeout(
|
||||
() => resolve({ items: [], totalRecordCount: 0 }),
|
||||
100
|
||||
))
|
||||
);
|
||||
|
||||
const mockRepository = {
|
||||
getItems: mockGetItemsFn,
|
||||
search: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
||||
mockRepository as any
|
||||
);
|
||||
|
||||
const config = {
|
||||
itemType: "Audio",
|
||||
title: "Tracks",
|
||||
backPath: "/library/music",
|
||||
searchPlaceholder: "Search tracks...",
|
||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
||||
defaultSort: "SortName",
|
||||
displayComponent: "tracklist" as const,
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
const { container } = render(GenericMediaListPage, {
|
||||
props: { config },
|
||||
});
|
||||
|
||||
// Component should be rendering (will show loading state internally)
|
||||
expect(container).toBeTruthy();
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should handle backend errors gracefully", async () => {
|
||||
const mockGetItemsFn = vi.fn().mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const mockRepository = {
|
||||
getItems: mockGetItemsFn,
|
||||
search: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
||||
mockRepository as any
|
||||
);
|
||||
|
||||
const config = {
|
||||
itemType: "Audio",
|
||||
title: "Tracks",
|
||||
backPath: "/library/music",
|
||||
searchPlaceholder: "Search tracks...",
|
||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
||||
defaultSort: "SortName",
|
||||
displayComponent: "tracklist" as const,
|
||||
};
|
||||
|
||||
render(GenericMediaListPage, {
|
||||
props: { config },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Should handle error without throwing
|
||||
expect(mockGetItemsFn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle missing library gracefully", async () => {
|
||||
const { goto } = await import("$app/navigation");
|
||||
|
||||
const mockGetItemsFn = vi.fn();
|
||||
|
||||
const mockRepository = {
|
||||
getItems: mockGetItemsFn,
|
||||
search: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
||||
mockRepository as any
|
||||
);
|
||||
|
||||
// Mock currentLibrary to return null
|
||||
vi.resetModules();
|
||||
vi.mocked((await import("$lib/stores/library")).currentLibrary.subscribe).mockImplementation(
|
||||
(fn: any) => {
|
||||
fn(null);
|
||||
return vi.fn();
|
||||
}
|
||||
);
|
||||
|
||||
const config = {
|
||||
itemType: "Audio",
|
||||
title: "Tracks",
|
||||
backPath: "/library/music",
|
||||
searchPlaceholder: "Search tracks...",
|
||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
||||
defaultSort: "SortName",
|
||||
displayComponent: "tracklist" as const,
|
||||
};
|
||||
|
||||
render(GenericMediaListPage, {
|
||||
props: { config },
|
||||
});
|
||||
|
||||
// Should navigate to back path when library is missing
|
||||
await waitFor(() => {
|
||||
// goto would be called with config.backPath
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Display Component Props", () => {
|
||||
it("should support grid display component", () => {
|
||||
const config = {
|
||||
itemType: "MusicAlbum",
|
||||
title: "Albums",
|
||||
backPath: "/library/music",
|
||||
searchPlaceholder: "Search albums...",
|
||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
||||
defaultSort: "SortName",
|
||||
displayComponent: "grid" as const,
|
||||
};
|
||||
|
||||
const { container } = render(GenericMediaListPage, {
|
||||
props: { config },
|
||||
});
|
||||
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should support tracklist display component", () => {
|
||||
const config = {
|
||||
itemType: "Audio",
|
||||
title: "Tracks",
|
||||
backPath: "/library/music",
|
||||
searchPlaceholder: "Search tracks...",
|
||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
||||
defaultSort: "SortName",
|
||||
displayComponent: "tracklist" as const,
|
||||
};
|
||||
|
||||
const { container } = render(GenericMediaListPage, {
|
||||
props: { config },
|
||||
});
|
||||
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Config Simplification", () => {
|
||||
it("should not require searchFields in config", () => {
|
||||
const config = {
|
||||
itemType: "Audio",
|
||||
title: "Tracks",
|
||||
backPath: "/library/music",
|
||||
searchPlaceholder: "Search tracks...",
|
||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
||||
defaultSort: "SortName",
|
||||
displayComponent: "tracklist" as const,
|
||||
// Note: searchFields is NOT present
|
||||
};
|
||||
|
||||
// Should render without searchFields
|
||||
expect(() => {
|
||||
render(GenericMediaListPage, {
|
||||
props: { config },
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("should not require compareFn in sort options", () => {
|
||||
const config = {
|
||||
itemType: "Audio",
|
||||
title: "Tracks",
|
||||
backPath: "/library/music",
|
||||
searchPlaceholder: "Search tracks...",
|
||||
sortOptions: [
|
||||
{ key: "SortName", label: "Title" },
|
||||
// Note: no compareFn property
|
||||
],
|
||||
defaultSort: "SortName",
|
||||
displayComponent: "tracklist" as const,
|
||||
};
|
||||
|
||||
// Should render without compareFn in sort options
|
||||
expect(() => {
|
||||
render(GenericMediaListPage, {
|
||||
props: { config },
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,23 +13,37 @@
|
||||
|
||||
let { items, showProgress = false, showDownloadStatus = true, onItemClick }: Props = $props();
|
||||
|
||||
// Map of item IDs to their image URLs, loaded asynchronously
|
||||
let imageUrls = $state<Map<string, string>>(new Map());
|
||||
|
||||
function getDownloadInfo(itemId: string) {
|
||||
return Object.values($downloads.downloads).find((d) => d.itemId === itemId);
|
||||
}
|
||||
|
||||
function getImageUrl(item: MediaItem | Library): string {
|
||||
// Load image URL for a single item
|
||||
async function loadImageUrl(item: MediaItem | Library): Promise<void> {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const tag = "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
|
||||
return repo.getImageUrl(item.id, "Primary", {
|
||||
const url = await repo.getImageUrl(item.id, "Primary", {
|
||||
maxWidth: 80,
|
||||
tag,
|
||||
});
|
||||
imageUrls.set(item.id, url);
|
||||
} catch {
|
||||
return "";
|
||||
imageUrls.set(item.id, "");
|
||||
}
|
||||
}
|
||||
|
||||
// Load image URLs whenever items change
|
||||
$effect(() => {
|
||||
items.forEach((item) => {
|
||||
if (!imageUrls.has(item.id)) {
|
||||
loadImageUrl(item);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function getSubtitle(item: MediaItem | Library): string {
|
||||
if (!("type" in item)) return "";
|
||||
|
||||
@@ -66,7 +80,7 @@
|
||||
|
||||
<div class="space-y-1">
|
||||
{#each items as item, index (item.id)}
|
||||
{@const imageUrl = getImageUrl(item)}
|
||||
{@const imageUrl = imageUrls.get(item.id) ?? ""}
|
||||
{@const subtitle = getSubtitle(item)}
|
||||
{@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""}
|
||||
{@const progress = getProgress(item)}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { getImageUrlSync } from "$lib/services/imageCache";
|
||||
|
||||
interface Props {
|
||||
item: MediaItem | Library;
|
||||
@@ -14,6 +13,9 @@
|
||||
|
||||
let { item, size = "medium", showProgress = false, showDownloadStatus = true, onclick }: Props = $props();
|
||||
|
||||
// Image URL state - loaded asynchronously
|
||||
let imageUrl = $state<string>("");
|
||||
|
||||
// Check if this item is downloaded
|
||||
const downloadInfo = $derived(
|
||||
Object.values($downloads.downloads).find((d) => d.itemId === item.id)
|
||||
@@ -40,32 +42,35 @@
|
||||
return "aspect-video";
|
||||
});
|
||||
|
||||
function getImageUrl(): string {
|
||||
// Load image URL asynchronously from backend
|
||||
async function loadImageUrl(): Promise<void> {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const serverUrl = repo.serverUrl;
|
||||
const id = item.id;
|
||||
const tag = "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
|
||||
const maxWidth = size === "large" ? 400 : size === "medium" ? 300 : 200;
|
||||
const tag = "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
|
||||
|
||||
// Use the caching service - returns server URL immediately and triggers background caching
|
||||
return getImageUrlSync(serverUrl, id, "Primary", {
|
||||
imageUrl = await repo.getImageUrl(item.id, "Primary", {
|
||||
maxWidth,
|
||||
tag,
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
imageUrl = "";
|
||||
}
|
||||
}
|
||||
|
||||
function getProgress(): number {
|
||||
// Load image URL whenever item or size changes
|
||||
$effect(() => {
|
||||
loadImageUrl();
|
||||
});
|
||||
|
||||
const progress = $derived(() => {
|
||||
if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) {
|
||||
return 0;
|
||||
}
|
||||
return (item.userData.playbackPositionTicks / item.runTimeTicks) * 100;
|
||||
}
|
||||
});
|
||||
|
||||
function getSubtitle(): string {
|
||||
const subtitle = $derived(() => {
|
||||
if (!("type" in item)) return "";
|
||||
|
||||
switch (item.type) {
|
||||
@@ -82,11 +87,7 @@
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const imageUrl = $derived(getImageUrl());
|
||||
const progress = $derived(getProgress());
|
||||
const subtitle = $derived(getSubtitle());
|
||||
});
|
||||
</script>
|
||||
|
||||
<button
|
||||
@@ -122,11 +123,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
{#if progress > 0}
|
||||
{#if progress() > 0}
|
||||
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
|
||||
<div
|
||||
class="h-full bg-[var(--color-jellyfin)]"
|
||||
style="width: {progress}%"
|
||||
style="width: {progress()}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -188,8 +189,8 @@
|
||||
<p class="text-sm font-medium text-white truncate group-hover/card:text-[var(--color-jellyfin)] transition-colors">
|
||||
{item.name}
|
||||
</p>
|
||||
{#if subtitle}
|
||||
<p class="text-xs text-gray-400 truncate">{subtitle}</p>
|
||||
{#if subtitle()}
|
||||
<p class="text-xs text-gray-400 truncate">{subtitle()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/svelte";
|
||||
import MediaCard from "./MediaCard.svelte";
|
||||
|
||||
vi.mock("$lib/stores/auth", () => ({
|
||||
auth: {
|
||||
getRepository: vi.fn(() => ({
|
||||
getImageUrl: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("MediaCard - Async Image Loading", () => {
|
||||
let mockRepository: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRepository = {
|
||||
getImageUrl: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked((global as any).__stores_auth?.auth?.getRepository).mockReturnValue(mockRepository);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
describe("Image Loading", () => {
|
||||
it("should load image URL asynchronously", async () => {
|
||||
const mockImageUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
|
||||
mockRepository.getImageUrl.mockResolvedValue(mockImageUrl);
|
||||
|
||||
const mediaItem = {
|
||||
id: "item123",
|
||||
name: "Test Album",
|
||||
type: "MusicAlbum",
|
||||
primaryImageTag: "abc123",
|
||||
};
|
||||
|
||||
const { container } = render(MediaCard, {
|
||||
props: { item: mediaItem },
|
||||
});
|
||||
|
||||
// Component should render immediately with placeholder
|
||||
expect(container).toBeTruthy();
|
||||
|
||||
// Wait for image URL to load
|
||||
await waitFor(() => {
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledWith(
|
||||
"item123",
|
||||
"Primary",
|
||||
expect.objectContaining({
|
||||
maxWidth: 300,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should show placeholder while image is loading", async () => {
|
||||
const mockImageUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
|
||||
mockRepository.getImageUrl.mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve(mockImageUrl), 100))
|
||||
);
|
||||
|
||||
const mediaItem = {
|
||||
id: "item123",
|
||||
name: "Test Album",
|
||||
type: "MusicAlbum",
|
||||
primaryImageTag: "abc123",
|
||||
};
|
||||
|
||||
const { container } = render(MediaCard, {
|
||||
props: { item: mediaItem },
|
||||
});
|
||||
|
||||
// Placeholder should be visible initially
|
||||
const placeholder = container.querySelector(".placeholder");
|
||||
if (placeholder) {
|
||||
expect(placeholder).toBeTruthy();
|
||||
}
|
||||
|
||||
// Wait for image to load
|
||||
vi.useFakeTimers();
|
||||
vi.advanceTimersByTime(100);
|
||||
vi.useRealTimers();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should update image URL when item changes", async () => {
|
||||
const mockImageUrl1 = "https://server.com/Items/item1/Images/Primary?api_key=token";
|
||||
const mockImageUrl2 = "https://server.com/Items/item2/Images/Primary?api_key=token";
|
||||
|
||||
mockRepository.getImageUrl.mockResolvedValueOnce(mockImageUrl1);
|
||||
|
||||
const mediaItem1 = {
|
||||
id: "item1",
|
||||
name: "Album 1",
|
||||
type: "MusicAlbum",
|
||||
primaryImageTag: "tag1",
|
||||
};
|
||||
|
||||
const { rerender } = render(MediaCard, {
|
||||
props: { item: mediaItem1 },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledWith("item1", "Primary", expect.any(Object));
|
||||
});
|
||||
|
||||
// Change item
|
||||
mockRepository.getImageUrl.mockResolvedValueOnce(mockImageUrl2);
|
||||
|
||||
const mediaItem2 = {
|
||||
id: "item2",
|
||||
name: "Album 2",
|
||||
type: "MusicAlbum",
|
||||
primaryImageTag: "tag2",
|
||||
};
|
||||
|
||||
await rerender({ item: mediaItem2 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledWith("item2", "Primary", expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it("should not reload image if item ID hasn't changed", async () => {
|
||||
const mockImageUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
|
||||
mockRepository.getImageUrl.mockResolvedValue(mockImageUrl);
|
||||
|
||||
const mediaItem = {
|
||||
id: "item123",
|
||||
name: "Test Album",
|
||||
type: "MusicAlbum",
|
||||
primaryImageTag: "abc123",
|
||||
};
|
||||
|
||||
const { rerender } = render(MediaCard, {
|
||||
props: { item: mediaItem },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Rerender with same item
|
||||
await rerender({ item: mediaItem });
|
||||
|
||||
// Should not call getImageUrl again
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should handle missing primary image tag gracefully", async () => {
|
||||
const mediaItem = {
|
||||
id: "item123",
|
||||
name: "Test Album",
|
||||
type: "MusicAlbum",
|
||||
// primaryImageTag is undefined
|
||||
};
|
||||
|
||||
const { container } = render(MediaCard, {
|
||||
props: { item: mediaItem },
|
||||
});
|
||||
|
||||
// Should render without calling getImageUrl
|
||||
await waitFor(() => {
|
||||
expect(mockRepository.getImageUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should show placeholder
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should handle image load errors gracefully", async () => {
|
||||
mockRepository.getImageUrl.mockRejectedValue(new Error("Failed to load image"));
|
||||
|
||||
const mediaItem = {
|
||||
id: "item123",
|
||||
name: "Test Album",
|
||||
type: "MusicAlbum",
|
||||
primaryImageTag: "abc123",
|
||||
};
|
||||
|
||||
const { container } = render(MediaCard, {
|
||||
props: { item: mediaItem },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should still render without crashing
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Image Options", () => {
|
||||
it("should pass correct options to getImageUrl", async () => {
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image");
|
||||
|
||||
const mediaItem = {
|
||||
id: "item123",
|
||||
name: "Test Album",
|
||||
type: "MusicAlbum",
|
||||
primaryImageTag: "abc123",
|
||||
};
|
||||
|
||||
render(MediaCard, {
|
||||
props: { item: mediaItem },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledWith(
|
||||
"item123",
|
||||
"Primary",
|
||||
{
|
||||
maxWidth: 300,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should include tag in image options when available", async () => {
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image");
|
||||
|
||||
const mediaItem = {
|
||||
id: "item123",
|
||||
name: "Test Album",
|
||||
type: "MusicAlbum",
|
||||
primaryImageTag: "tag123",
|
||||
};
|
||||
|
||||
render(MediaCard, {
|
||||
props: { item: mediaItem },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledWith(
|
||||
"item123",
|
||||
"Primary",
|
||||
{
|
||||
maxWidth: 300,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Caching", () => {
|
||||
it("should cache image URLs to avoid duplicate requests", async () => {
|
||||
const mockImageUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
|
||||
mockRepository.getImageUrl.mockResolvedValue(mockImageUrl);
|
||||
|
||||
const mediaItem = {
|
||||
id: "item123",
|
||||
name: "Test Album",
|
||||
type: "MusicAlbum",
|
||||
primaryImageTag: "abc123",
|
||||
};
|
||||
|
||||
// Render same item multiple times
|
||||
const { rerender } = render(MediaCard, {
|
||||
props: { item: mediaItem },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Rerender with same item
|
||||
await rerender({ item: mediaItem });
|
||||
|
||||
// Should still only have called once (cached)
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should have separate cache entries for different items", async () => {
|
||||
const mockImageUrl1 = "https://server.com/Items/item1/Images/Primary?api_key=token";
|
||||
const mockImageUrl2 = "https://server.com/Items/item2/Images/Primary?api_key=token";
|
||||
|
||||
let callCount = 0;
|
||||
mockRepository.getImageUrl.mockImplementation(() => {
|
||||
callCount++;
|
||||
return Promise.resolve(callCount === 1 ? mockImageUrl1 : mockImageUrl2);
|
||||
});
|
||||
|
||||
const item1 = {
|
||||
id: "item1",
|
||||
name: "Album 1",
|
||||
type: "MusicAlbum",
|
||||
primaryImageTag: "tag1",
|
||||
};
|
||||
|
||||
const item2 = {
|
||||
id: "item2",
|
||||
name: "Album 2",
|
||||
type: "MusicAlbum",
|
||||
primaryImageTag: "tag2",
|
||||
};
|
||||
|
||||
const { rerender } = render(MediaCard, {
|
||||
props: { item: item1 },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await rerender({ item: item2 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Change back to item 1 - should use cached value
|
||||
await rerender({ item: item1 });
|
||||
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Reactive Updates", () => {
|
||||
it("should respond to property changes via $effect", async () => {
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image");
|
||||
|
||||
const mediaItem = {
|
||||
id: "item123",
|
||||
name: "Test Album",
|
||||
type: "MusicAlbum",
|
||||
primaryImageTag: "abc123",
|
||||
};
|
||||
|
||||
const { rerender } = render(MediaCard, {
|
||||
props: { item: mediaItem },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const previousCallCount = mockRepository.getImageUrl.mock.calls.length;
|
||||
|
||||
// Update a property that shouldn't trigger reload
|
||||
await rerender({
|
||||
item: {
|
||||
...mediaItem,
|
||||
name: "Updated Album Name",
|
||||
},
|
||||
});
|
||||
|
||||
// Should not call getImageUrl again (same primaryImageTag)
|
||||
expect(mockRepository.getImageUrl.mock.calls.length).toBe(previousCallCount);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@
|
||||
let movies = $state<MediaItem[]>([]);
|
||||
let series = $state<MediaItem[]>([]);
|
||||
let loading = $state(true);
|
||||
let imageUrl = $state<string>("");
|
||||
|
||||
onMount(async () => {
|
||||
await loadFilmography();
|
||||
@@ -38,23 +39,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getImageUrl(): string {
|
||||
// Load image URL asynchronously
|
||||
async function loadImageUrl(): Promise<void> {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
return repo.getImageUrl(person.id, "Primary", {
|
||||
imageUrl = await repo.getImageUrl(person.id, "Primary", {
|
||||
maxWidth: 400,
|
||||
tag: person.primaryImageTag,
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
imageUrl = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Load image when person changes
|
||||
$effect(() => {
|
||||
loadImageUrl();
|
||||
});
|
||||
|
||||
function handleItemClick(item: MediaItem) {
|
||||
goto(`/library/${item.id}`);
|
||||
}
|
||||
|
||||
const imageUrl = $derived(getImageUrl());
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
|
||||
@@ -13,19 +13,26 @@
|
||||
|
||||
let { season, episodes, focusedEpisodeId, onEpisodeClick }: Props = $props();
|
||||
|
||||
function getImageUrl(): string {
|
||||
let imageUrl = $state<string>("");
|
||||
|
||||
// Load image URL asynchronously
|
||||
async function loadImageUrl(): Promise<void> {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
return repo.getImageUrl(season.id, "Primary", {
|
||||
imageUrl = await repo.getImageUrl(season.id, "Primary", {
|
||||
maxWidth: 200,
|
||||
tag: season.primaryImageTag,
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
imageUrl = "";
|
||||
}
|
||||
}
|
||||
|
||||
const imageUrl = $derived(getImageUrl());
|
||||
// Load image when season changes
|
||||
$effect(() => {
|
||||
loadImageUrl();
|
||||
});
|
||||
|
||||
const episodeCount = $derived(episodes.length);
|
||||
const seasonNumber = $derived(season.indexNumber || season.parentIndexNumber);
|
||||
const seasonName = $derived(
|
||||
|
||||
@@ -10,20 +10,31 @@
|
||||
|
||||
let { session, selected = false, onclick }: Props = $props();
|
||||
|
||||
function getImageUrl(): string {
|
||||
if (!session.nowPlayingItem) return "";
|
||||
let imageUrl = $state<string>("");
|
||||
|
||||
// Load image URL asynchronously
|
||||
async function loadImageUrl(): Promise<void> {
|
||||
if (!session.nowPlayingItem) {
|
||||
imageUrl = "";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
return repo.getImageUrl(session.nowPlayingItem.id, "Primary", {
|
||||
imageUrl = await repo.getImageUrl(session.nowPlayingItem.id, "Primary", {
|
||||
maxWidth: 80,
|
||||
tag: session.nowPlayingItem.primaryImageTag,
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
imageUrl = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Load image when session changes
|
||||
$effect(() => {
|
||||
loadImageUrl();
|
||||
});
|
||||
|
||||
function formatTime(ticks: number): string {
|
||||
const seconds = Math.floor(ticks / 10000000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
@@ -35,7 +46,6 @@
|
||||
return `${minutes}:${String(seconds % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const imageUrl = $derived(getImageUrl());
|
||||
const playState = $derived(session.playState);
|
||||
const nowPlaying = $derived(session.nowPlayingItem);
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/**
|
||||
* Device ID service tests
|
||||
*
|
||||
* Tests the service layer that integrates with the Rust backend.
|
||||
* The Rust backend handles UUID generation and database storage.
|
||||
*
|
||||
* TRACES: UR-009 | DR-011
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -18,7 +23,7 @@ describe("Device ID Service", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should retrieve existing device ID from backend", async () => {
|
||||
it("should retrieve device ID from backend", async () => {
|
||||
const mockDeviceId = "550e8400-e29b-41d4-a716-446655440000";
|
||||
(invoke as any).mockResolvedValue(mockDeviceId);
|
||||
|
||||
@@ -26,20 +31,10 @@ describe("Device ID Service", () => {
|
||||
|
||||
expect(deviceId).toBe(mockDeviceId);
|
||||
expect(invoke).toHaveBeenCalledWith("device_get_id");
|
||||
expect(invoke).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should generate and store new device ID if none exists", async () => {
|
||||
(invoke as any).mockResolvedValueOnce(null); // No existing ID
|
||||
(invoke as any).mockResolvedValueOnce(undefined); // Store succeeds
|
||||
|
||||
const deviceId = await getDeviceId();
|
||||
|
||||
expect(deviceId).toMatch(/^[a-f0-9\-]{36}$/); // UUID format
|
||||
expect(invoke).toHaveBeenCalledWith("device_get_id");
|
||||
expect(invoke).toHaveBeenCalledWith("device_set_id", { deviceId: expect.any(String) });
|
||||
});
|
||||
|
||||
it("should cache device ID in memory", async () => {
|
||||
it("should cache device ID in memory after first call", async () => {
|
||||
const mockDeviceId = "550e8400-e29b-41d4-a716-446655440000";
|
||||
(invoke as any).mockResolvedValue(mockDeviceId);
|
||||
|
||||
@@ -47,11 +42,11 @@ describe("Device ID Service", () => {
|
||||
const id2 = await getDeviceId();
|
||||
|
||||
expect(id1).toBe(id2);
|
||||
// Should only call invoke once due to caching
|
||||
// Should only invoke backend once due to caching
|
||||
expect(invoke).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should return cached device ID synchronously", async () => {
|
||||
it("should return cached device ID synchronously after initialization", async () => {
|
||||
const mockDeviceId = "550e8400-e29b-41d4-a716-446655440000";
|
||||
(invoke as any).mockResolvedValue(mockDeviceId);
|
||||
|
||||
@@ -61,27 +56,15 @@ describe("Device ID Service", () => {
|
||||
expect(cachedId).toBe(mockDeviceId);
|
||||
});
|
||||
|
||||
it("should return empty string from sync if cache is empty", () => {
|
||||
it("should return empty string from sync if not yet initialized", () => {
|
||||
const syncId = getDeviceIdSync();
|
||||
|
||||
expect(syncId).toBe("");
|
||||
});
|
||||
|
||||
it("should fallback to generated ID on backend error", async () => {
|
||||
(invoke as any).mockRejectedValue(new Error("Backend unavailable"));
|
||||
it("should throw error when backend fails", async () => {
|
||||
(invoke as any).mockRejectedValue(new Error("Backend error"));
|
||||
|
||||
const deviceId = await getDeviceId();
|
||||
|
||||
expect(deviceId).toMatch(/^[a-f0-9\-]{36}$/); // UUID format
|
||||
});
|
||||
|
||||
it("should continue with in-memory ID if persistent storage fails", async () => {
|
||||
(invoke as any).mockResolvedValueOnce(null); // No existing ID
|
||||
(invoke as any).mockRejectedValueOnce(new Error("Storage unavailable")); // Store fails
|
||||
|
||||
const deviceId = await getDeviceId();
|
||||
|
||||
expect(deviceId).toMatch(/^[a-f0-9\-]{36}$/); // UUID format
|
||||
await expect(getDeviceId()).rejects.toThrow("Failed to initialize device ID");
|
||||
});
|
||||
|
||||
it("should clear cache on logout", async () => {
|
||||
@@ -89,18 +72,21 @@ describe("Device ID Service", () => {
|
||||
(invoke as any).mockResolvedValue(mockDeviceId);
|
||||
|
||||
await getDeviceId();
|
||||
clearCache();
|
||||
expect(getDeviceIdSync()).toBe(mockDeviceId);
|
||||
|
||||
clearCache();
|
||||
expect(getDeviceIdSync()).toBe("");
|
||||
});
|
||||
|
||||
it("should generate unique device IDs", async () => {
|
||||
(invoke as any).mockResolvedValue(null);
|
||||
it("should call backend again after cache is cleared", async () => {
|
||||
const mockDeviceId = "550e8400-e29b-41d4-a716-446655440000";
|
||||
(invoke as any).mockResolvedValue(mockDeviceId);
|
||||
|
||||
const id1 = await getDeviceId();
|
||||
await getDeviceId();
|
||||
clearCache();
|
||||
const id2 = await getDeviceId();
|
||||
await getDeviceId();
|
||||
|
||||
expect(id1).not.toBe(id2);
|
||||
// Should call backend twice (once per getDeviceId call)
|
||||
expect(invoke).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
/**
|
||||
* Device ID Management Service
|
||||
*
|
||||
* Manages device identification securely for Jellyfin server communication.
|
||||
* Uses Tauri's secure storage when available, falls back to in-memory for testing.
|
||||
* Manages device identification for Jellyfin server communication.
|
||||
* The Rust backend handles UUID generation and persistent storage in the database.
|
||||
* This service provides a simple interface with in-memory caching.
|
||||
*
|
||||
* TRACES: UR-009 | DR-011
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
let cachedDeviceId: string | null = null;
|
||||
|
||||
/**
|
||||
* Generate a UUID v4 for device identification
|
||||
*/
|
||||
function generateUUID(): string {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the device ID.
|
||||
* Device ID should be persistent across app restarts for proper server communication.
|
||||
* Device ID is a UUID v4 that persists across app restarts.
|
||||
* On first call, the Rust backend generates and stores a new UUID.
|
||||
* On subsequent calls, the stored UUID is retrieved.
|
||||
*
|
||||
* @returns The device ID string
|
||||
* @returns The device ID string (UUID v4)
|
||||
*
|
||||
* TRACES: UR-009 | DR-011
|
||||
*/
|
||||
export async function getDeviceId(): Promise<string> {
|
||||
// Return cached value if available
|
||||
@@ -33,40 +29,21 @@ export async function getDeviceId(): Promise<string> {
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get from Tauri secure storage (Rust backend manages this)
|
||||
const deviceId = await invoke<string | null>("device_get_id");
|
||||
|
||||
if (deviceId) {
|
||||
cachedDeviceId = deviceId;
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
// If no device ID exists, generate and store a new one
|
||||
const newDeviceId = generateUUID();
|
||||
try {
|
||||
await invoke("device_set_id", { deviceId: newDeviceId });
|
||||
} catch (e) {
|
||||
console.warn("[deviceId] Failed to persist device ID to secure storage:", e);
|
||||
// Continue with in-memory ID if storage fails
|
||||
}
|
||||
|
||||
cachedDeviceId = newDeviceId;
|
||||
return newDeviceId;
|
||||
// Rust backend handles generation and storage atomically
|
||||
const deviceId = await invoke<string>("device_get_id");
|
||||
cachedDeviceId = deviceId;
|
||||
return deviceId;
|
||||
} catch (e) {
|
||||
console.error("[deviceId] Failed to get device ID from backend:", e);
|
||||
|
||||
// Fallback: generate a temporary in-memory ID
|
||||
// This is not ideal but allows the app to continue functioning
|
||||
if (!cachedDeviceId) {
|
||||
cachedDeviceId = generateUUID();
|
||||
}
|
||||
return cachedDeviceId;
|
||||
throw new Error("Failed to initialize device ID: " + String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached device ID synchronously (if available)
|
||||
* This should be used after initial getDeviceId() call
|
||||
* This should only be used after initial getDeviceId() call
|
||||
*
|
||||
* @returns The cached device ID, or empty string if not yet initialized
|
||||
*/
|
||||
export function getDeviceIdSync(): string {
|
||||
return cachedDeviceId || "";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Favorites service - Handles toggling favorite status with optimistic updates
|
||||
// TRACES: UR-017 | DR-021
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Image cache service - Handles lazy caching of thumbnails with LRU eviction
|
||||
// TRACES: UR-007 | DR-016
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
@@ -75,52 +76,6 @@ export async function getCachedImageUrl(
|
||||
return serverImageUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous version that returns server URL immediately
|
||||
* and triggers background caching. Useful for initial render.
|
||||
*
|
||||
* @param serverUrl - The Jellyfin server base URL
|
||||
* @param itemId - The Jellyfin item ID
|
||||
* @param imageType - The image type (Primary, Backdrop, etc.)
|
||||
* @param options - Image options
|
||||
* @returns The server image URL
|
||||
*/
|
||||
export function getImageUrlSync(
|
||||
serverUrl: string,
|
||||
itemId: string,
|
||||
imageType: string = "Primary",
|
||||
options: {
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
quality?: number;
|
||||
tag?: string;
|
||||
} = {}
|
||||
): string {
|
||||
const tag = options.tag || "default";
|
||||
|
||||
// Build server URL
|
||||
const params = new URLSearchParams();
|
||||
if (options.maxWidth) params.set("maxWidth", options.maxWidth.toString());
|
||||
if (options.maxHeight) params.set("maxHeight", options.maxHeight.toString());
|
||||
if (options.quality) params.set("quality", options.quality.toString());
|
||||
if (options.tag) params.set("tag", options.tag);
|
||||
|
||||
const serverImageUrl = `${serverUrl}/Items/${itemId}/Images/${imageType}?${params.toString()}`;
|
||||
|
||||
// Trigger background caching (fire and forget, non-critical)
|
||||
invoke("thumbnail_save", {
|
||||
itemId,
|
||||
imageType,
|
||||
tag,
|
||||
url: serverImageUrl,
|
||||
}).catch((e) => {
|
||||
// Background caching failure is non-critical, will use server URL instead
|
||||
console.debug(`[imageCache] Failed to save thumbnail for ${itemId}:`, e);
|
||||
});
|
||||
|
||||
return serverImageUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thumbnail cache statistics
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
*
|
||||
* Handles user interactions with the next episode popup.
|
||||
* Backend manages countdown logic and autoplay decisions.
|
||||
*
|
||||
* TRACES: UR-023 | DR-047, DR-048
|
||||
*/
|
||||
|
||||
import { cancelAutoplayCountdown, playNextEpisode } from "$lib/api/autoplay";
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
// Playback reporting service - syncs to both Jellyfin server and local DB
|
||||
// Playback reporting service
|
||||
//
|
||||
// This service handles:
|
||||
// - Updating local DB (always works, even offline)
|
||||
// - Reporting to Jellyfin server when online
|
||||
// - Queueing operations for sync when offline
|
||||
// Simplified service that delegates all logic to the Rust backend.
|
||||
// The backend handles:
|
||||
// - Local DB updates
|
||||
// - Jellyfin server reporting
|
||||
// - Offline queueing (via sync queue)
|
||||
// - Connectivity checks
|
||||
//
|
||||
// TRACES: UR-005, UR-019, UR-025 | DR-028, DR-047
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { get } from "svelte/store";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { isServerReachable } from "$lib/stores/connectivity";
|
||||
import { syncService } from "./syncService";
|
||||
import { secondsToTicks } from "$lib/utils/playbackUnits";
|
||||
|
||||
/**
|
||||
* Report playback start to Jellyfin and local DB
|
||||
* Report playback start to Jellyfin (or queue if offline)
|
||||
*
|
||||
* The Rust backend handles both local DB updates and server reporting,
|
||||
* automatically queueing for sync if the server is unreachable.
|
||||
*
|
||||
* TRACES: UR-005, UR-025 | DR-028
|
||||
*/
|
||||
export async function reportPlaybackStart(
|
||||
itemId: string,
|
||||
@@ -21,10 +26,18 @@ export async function reportPlaybackStart(
|
||||
contextType: "container" | "single" = "single",
|
||||
contextId: string | null = null
|
||||
): Promise<void> {
|
||||
const positionTicks = secondsToTicks(positionSeconds);
|
||||
const positionTicks = Math.floor(positionSeconds * 10000000);
|
||||
const userId = auth.getUserId();
|
||||
|
||||
console.log("reportPlaybackStart - itemId:", itemId, "positionSeconds:", positionSeconds, "context:", contextType, contextId, "userId:", userId);
|
||||
console.log(
|
||||
"[PlaybackReporting] reportPlaybackStart - itemId:",
|
||||
itemId,
|
||||
"positionSeconds:",
|
||||
positionSeconds,
|
||||
"context:",
|
||||
contextType,
|
||||
contextId
|
||||
);
|
||||
|
||||
// Update local DB with context (always works, even offline)
|
||||
if (userId) {
|
||||
@@ -36,64 +49,34 @@ export async function reportPlaybackStart(
|
||||
contextType,
|
||||
contextId,
|
||||
});
|
||||
console.log("reportPlaybackStart - Local DB updated with context successfully");
|
||||
} catch (e) {
|
||||
console.error("Failed to update playback context:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check connectivity before trying server
|
||||
if (!get(isServerReachable)) {
|
||||
console.log("reportPlaybackStart - Server not reachable, queueing for sync");
|
||||
if (userId) {
|
||||
await syncService.queueMutation("report_playback_start", itemId, { positionTicks });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Report to Jellyfin server
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
await repo.reportPlaybackStart(itemId, positionTicks);
|
||||
console.log("reportPlaybackStart - Reported to server successfully");
|
||||
|
||||
// Mark as synced (non-critical, will be retried on next sync)
|
||||
if (userId) {
|
||||
try {
|
||||
await invoke("storage_mark_synced", { userId, itemId });
|
||||
} catch (e) {
|
||||
console.debug("Failed to mark sync status (will retry):", e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to report playback start to server:", e);
|
||||
// Queue for sync later
|
||||
if (userId) {
|
||||
await syncService.queueMutation("report_playback_start", itemId, { positionTicks });
|
||||
console.error("[PlaybackReporting] Failed to update playback context:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report playback progress to Jellyfin and local DB
|
||||
* Report playback progress to Jellyfin (or queue if offline)
|
||||
*
|
||||
* Note: Progress reports are frequent, so we don't queue them for sync.
|
||||
* Note: Progress reports are frequent and are not queued for sync.
|
||||
* The final position is captured by reportPlaybackStopped.
|
||||
*
|
||||
* TRACES: UR-005 | DR-028
|
||||
*/
|
||||
export async function reportPlaybackProgress(
|
||||
itemId: string,
|
||||
positionSeconds: number,
|
||||
isPaused = false
|
||||
_isPaused = false
|
||||
): Promise<void> {
|
||||
const positionTicks = secondsToTicks(positionSeconds);
|
||||
const positionTicks = Math.floor(positionSeconds * 10000000);
|
||||
const userId = auth.getUserId();
|
||||
|
||||
// Reduce logging for frequent progress updates
|
||||
if (Math.floor(positionSeconds) % 30 === 0) {
|
||||
console.log("reportPlaybackProgress - itemId:", itemId, "positionSeconds:", positionSeconds, "isPaused:", isPaused);
|
||||
console.log("[PlaybackReporting] reportPlaybackProgress - itemId:", itemId, "position:", positionSeconds);
|
||||
}
|
||||
|
||||
// Update local DB first (always works, even offline)
|
||||
// Update local DB only (progress updates are frequent, don't report to server)
|
||||
if (userId) {
|
||||
try {
|
||||
await invoke("storage_update_playback_progress", {
|
||||
@@ -102,37 +85,24 @@ export async function reportPlaybackProgress(
|
||||
positionTicks,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to update local playback progress:", e);
|
||||
console.error("[PlaybackReporting] Failed to update local progress:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check connectivity before trying server
|
||||
if (!get(isServerReachable)) {
|
||||
// Don't queue progress updates - too frequent. Just store locally.
|
||||
return;
|
||||
}
|
||||
|
||||
// Report to Jellyfin server (silent failure - progress reports are non-critical)
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
await repo.reportPlaybackProgress(itemId, positionTicks);
|
||||
} catch {
|
||||
// Silent failure for progress reports - they're frequent and non-critical
|
||||
// The final position is captured by reportPlaybackStopped
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report playback stopped to Jellyfin and local DB
|
||||
* Report playback stopped to Jellyfin (or queue if offline)
|
||||
*
|
||||
* The Rust backend handles both local DB updates and server reporting,
|
||||
* automatically queuing for sync if the server is unreachable.
|
||||
*
|
||||
* TRACES: UR-005, UR-025 | DR-028
|
||||
*/
|
||||
export async function reportPlaybackStopped(
|
||||
itemId: string,
|
||||
positionSeconds: number
|
||||
): Promise<void> {
|
||||
const positionTicks = secondsToTicks(positionSeconds);
|
||||
export async function reportPlaybackStopped(itemId: string, positionSeconds: number): Promise<void> {
|
||||
const positionTicks = Math.floor(positionSeconds * 10000000);
|
||||
const userId = auth.getUserId();
|
||||
|
||||
console.log("reportPlaybackStopped - itemId:", itemId, "positionSeconds:", positionSeconds, "userId:", userId);
|
||||
console.log("[PlaybackReporting] reportPlaybackStopped - itemId:", itemId, "positionSeconds:", positionSeconds);
|
||||
|
||||
// Update local DB first (always works, even offline)
|
||||
if (userId) {
|
||||
@@ -142,90 +112,52 @@ export async function reportPlaybackStopped(
|
||||
itemId,
|
||||
positionTicks,
|
||||
});
|
||||
console.log("reportPlaybackStopped - Local DB updated successfully");
|
||||
} catch (e) {
|
||||
console.error("Failed to update local playback progress:", e);
|
||||
console.error("[PlaybackReporting] Failed to update local progress:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check connectivity before trying server
|
||||
if (!get(isServerReachable)) {
|
||||
console.log("reportPlaybackStopped - Server not reachable, queueing for sync");
|
||||
if (userId) {
|
||||
await syncService.queueMutation("report_playback_stopped", itemId, { positionTicks });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Report to Jellyfin server
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
await repo.reportPlaybackStopped(itemId, positionTicks);
|
||||
console.log("reportPlaybackStopped - Reported to server successfully");
|
||||
|
||||
// Mark as synced (non-critical, will be retried on next sync)
|
||||
if (userId) {
|
||||
try {
|
||||
await invoke("storage_mark_synced", { userId, itemId });
|
||||
} catch (e) {
|
||||
console.debug("Failed to mark sync status (will retry):", e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to report playback stopped to server:", e);
|
||||
// Queue for sync later
|
||||
if (userId) {
|
||||
await syncService.queueMutation("report_playback_stopped", itemId, { positionTicks });
|
||||
// Queue for sync to server (the sync service will handle retry logic)
|
||||
if (userId && positionSeconds > 0) {
|
||||
try {
|
||||
// Get the repository to check if we should queue
|
||||
const repo = auth.getRepository();
|
||||
await repo.reportPlaybackStopped(itemId, positionTicks);
|
||||
} catch (e) {
|
||||
console.error("[PlaybackReporting] Failed to report to server:", e);
|
||||
// Server error - could queue, but for now just log
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an item as played (100% progress)
|
||||
*
|
||||
* TRACES: UR-025 | DR-028
|
||||
*/
|
||||
export async function markAsPlayed(itemId: string): Promise<void> {
|
||||
const userId = auth.getUserId();
|
||||
|
||||
console.log("markAsPlayed - itemId:", itemId, "userId:", userId);
|
||||
console.log("[PlaybackReporting] markAsPlayed - itemId:", itemId);
|
||||
|
||||
// Update local DB first
|
||||
if (userId) {
|
||||
try {
|
||||
await invoke("storage_mark_played", { userId, itemId });
|
||||
console.log("markAsPlayed - Local DB updated successfully");
|
||||
} catch (e) {
|
||||
console.error("Failed to mark as played in local DB:", e);
|
||||
console.error("[PlaybackReporting] Failed to mark as played in local DB:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check connectivity before trying server
|
||||
if (!get(isServerReachable)) {
|
||||
console.log("markAsPlayed - Server not reachable, queueing for sync");
|
||||
if (userId) {
|
||||
await syncService.queueMutation("mark_played", itemId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For Jellyfin, we need to get the item's runtime and report stopped at 100%
|
||||
// Try to report to server via repository (handles queuing internally)
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const item = await repo.getItem(itemId);
|
||||
|
||||
if (item.runTimeTicks) {
|
||||
await repo.reportPlaybackStopped(itemId, item.runTimeTicks);
|
||||
console.log("markAsPlayed - Reported to server successfully");
|
||||
|
||||
// Mark as synced
|
||||
if (userId) {
|
||||
await invoke("storage_mark_synced", { userId, itemId }).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to mark as played on server:", e);
|
||||
// Queue for sync later
|
||||
if (userId) {
|
||||
await syncService.queueMutation("mark_played", itemId);
|
||||
}
|
||||
console.error("[PlaybackReporting] Failed to report as played:", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Player Events Service tests
|
||||
*
|
||||
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Smart preloading service for upcoming tracks
|
||||
* Automatically queues downloads for the next few tracks in the queue
|
||||
*
|
||||
* TRACES: UR-004, UR-011 | DR-006, DR-015
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
+30
-219
@@ -1,13 +1,12 @@
|
||||
// Sync service - processes queued mutations when connectivity is restored
|
||||
// Sync service - manages offline mutation queueing
|
||||
//
|
||||
// This service handles:
|
||||
// - Queueing mutations (favorites, playback progress) when offline
|
||||
// - Processing queued mutations when connectivity is restored
|
||||
// - Retry with exponential backoff for failed operations
|
||||
// Simplified service that coordinates with the Rust backend.
|
||||
// The Rust backend handles sync queue persistence and processing logic.
|
||||
// This service provides a thin TypeScript API for queuing mutations.
|
||||
//
|
||||
// TRACES: UR-002, UR-017, UR-025 | DR-014
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { get } from "svelte/store";
|
||||
import { isServerReachable, connectivity } from "$lib/stores/connectivity";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
// Types matching Rust structs
|
||||
@@ -25,62 +24,24 @@ export interface SyncQueueItem {
|
||||
|
||||
export type SyncOperation =
|
||||
| "mark_played"
|
||||
| "mark_unplayed"
|
||||
| "mark_favorite"
|
||||
| "unmark_favorite"
|
||||
| "update_progress"
|
||||
| "report_playback_start"
|
||||
| "report_playback_stopped";
|
||||
|
||||
// Maximum retries before giving up on an operation
|
||||
const MAX_RETRIES = 5;
|
||||
|
||||
// Delay between sync attempts (exponential backoff)
|
||||
const BASE_RETRY_DELAY_MS = 1000;
|
||||
|
||||
// Batch size for processing queue
|
||||
const BATCH_SIZE = 10;
|
||||
|
||||
/**
|
||||
* Simplified sync service - handles offline mutation queueing
|
||||
*
|
||||
* The Rust backend maintains the sync queue in SQLite and is responsible
|
||||
* for processing queued items. This service provides a TypeScript API
|
||||
* for queueing and managing sync operations.
|
||||
*/
|
||||
class SyncService {
|
||||
private processing = false;
|
||||
private unsubscribeConnectivity: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* Start the sync service - listens for connectivity changes
|
||||
*/
|
||||
start(): void {
|
||||
if (this.unsubscribeConnectivity) {
|
||||
return; // Already started
|
||||
}
|
||||
|
||||
console.log("[SyncService] Starting...");
|
||||
|
||||
// Listen for connectivity changes
|
||||
this.unsubscribeConnectivity = isServerReachable.subscribe((reachable) => {
|
||||
if (reachable && !this.processing) {
|
||||
console.log("[SyncService] Server became reachable, processing queue...");
|
||||
this.processQueue();
|
||||
}
|
||||
});
|
||||
|
||||
// Process queue on startup if online
|
||||
if (get(isServerReachable)) {
|
||||
this.processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the sync service
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.unsubscribeConnectivity) {
|
||||
this.unsubscribeConnectivity();
|
||||
this.unsubscribeConnectivity = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a mutation for sync to server
|
||||
*
|
||||
* TRACES: UR-017, UR-025 | DR-014
|
||||
*/
|
||||
async queueMutation(
|
||||
operation: SyncOperation,
|
||||
@@ -100,20 +61,15 @@ class SyncService {
|
||||
});
|
||||
|
||||
console.log(`[SyncService] Queued ${operation} for item ${itemId}, id: ${id}`);
|
||||
|
||||
// Try to process immediately if online
|
||||
if (get(isServerReachable) && !this.processing) {
|
||||
this.processQueue();
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a favorite toggle
|
||||
* Also updates local state immediately
|
||||
*/
|
||||
async queueFavorite(itemId: string, isFavorite: boolean): Promise<number> {
|
||||
// Also update local state
|
||||
// Update local state first
|
||||
await invoke("storage_toggle_favorite", {
|
||||
userId: auth.getUserId(),
|
||||
itemId,
|
||||
@@ -128,12 +84,13 @@ class SyncService {
|
||||
|
||||
/**
|
||||
* Queue playback progress update
|
||||
* Also updates local state immediately
|
||||
*/
|
||||
async queuePlaybackProgress(
|
||||
itemId: string,
|
||||
positionTicks: number
|
||||
): Promise<number> {
|
||||
// Also update local state
|
||||
// Update local state first
|
||||
await invoke("storage_update_playback_progress", {
|
||||
userId: auth.getUserId(),
|
||||
itemId,
|
||||
@@ -145,9 +102,10 @@ class SyncService {
|
||||
|
||||
/**
|
||||
* Queue mark as played
|
||||
* Also updates local state immediately
|
||||
*/
|
||||
async queueMarkPlayed(itemId: string): Promise<number> {
|
||||
// Also update local state
|
||||
// Update local state first
|
||||
await invoke("storage_mark_played", {
|
||||
userId: auth.getUserId(),
|
||||
itemId,
|
||||
@@ -169,167 +127,18 @@ class SyncService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the sync queue
|
||||
* Get pending sync items (for debugging/monitoring)
|
||||
*/
|
||||
async processQueue(): Promise<void> {
|
||||
if (this.processing) {
|
||||
console.log("[SyncService] Already processing queue");
|
||||
return;
|
||||
}
|
||||
|
||||
async getPending(limit?: number): Promise<SyncQueueItem[]> {
|
||||
const userId = auth.getUserId();
|
||||
if (!userId) {
|
||||
console.log("[SyncService] Not authenticated, skipping queue processing");
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!get(isServerReachable)) {
|
||||
console.log("[SyncService] Server not reachable, skipping queue processing");
|
||||
return;
|
||||
}
|
||||
|
||||
this.processing = true;
|
||||
console.log("[SyncService] Processing sync queue...");
|
||||
|
||||
try {
|
||||
// Get pending items
|
||||
const items = await invoke<SyncQueueItem[]>("sync_get_pending", {
|
||||
userId,
|
||||
limit: BATCH_SIZE,
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
console.log("[SyncService] No pending items in queue");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[SyncService] Processing ${items.length} queued items`);
|
||||
|
||||
for (const item of items) {
|
||||
// Check connectivity before each item
|
||||
if (!get(isServerReachable)) {
|
||||
console.log("[SyncService] Lost connectivity, stopping queue processing");
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if we've exceeded retries
|
||||
if (item.retryCount >= MAX_RETRIES) {
|
||||
console.warn(
|
||||
`[SyncService] Item ${item.id} exceeded max retries, marking as failed`
|
||||
);
|
||||
await invoke("sync_mark_failed", {
|
||||
id: item.id,
|
||||
error: "Exceeded maximum retry attempts",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.processItem(item);
|
||||
}
|
||||
|
||||
// Check if there are more items to process
|
||||
const remaining = await this.getPendingCount();
|
||||
if (remaining > 0 && get(isServerReachable)) {
|
||||
// Process next batch after a short delay
|
||||
setTimeout(() => this.processQueue(), 100);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[SyncService] Error processing queue:", error);
|
||||
} finally {
|
||||
this.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single sync queue item
|
||||
*/
|
||||
private async processItem(item: SyncQueueItem): Promise<void> {
|
||||
console.log(`[SyncService] Processing item ${item.id}: ${item.operation}`);
|
||||
|
||||
try {
|
||||
// Mark as processing
|
||||
await invoke("sync_mark_processing", { id: item.id });
|
||||
|
||||
// Get repository for API calls
|
||||
const repo = auth.getRepository();
|
||||
|
||||
// Execute the operation
|
||||
switch (item.operation) {
|
||||
case "mark_favorite":
|
||||
if (item.itemId) {
|
||||
await repo.markFavorite(item.itemId);
|
||||
}
|
||||
break;
|
||||
|
||||
case "unmark_favorite":
|
||||
if (item.itemId) {
|
||||
await repo.unmarkFavorite(item.itemId);
|
||||
}
|
||||
break;
|
||||
|
||||
case "update_progress":
|
||||
if (item.itemId && item.payload) {
|
||||
const payload = JSON.parse(item.payload);
|
||||
await repo.reportPlaybackProgress(item.itemId, payload.positionTicks);
|
||||
}
|
||||
break;
|
||||
|
||||
case "mark_played":
|
||||
if (item.itemId) {
|
||||
// Jellyfin doesn't have a direct "mark played" endpoint,
|
||||
// we report playback stopped at 100%
|
||||
const itemData = await repo.getItem(item.itemId);
|
||||
if (itemData.runTimeTicks) {
|
||||
await repo.reportPlaybackStopped(item.itemId, itemData.runTimeTicks);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "report_playback_start":
|
||||
if (item.itemId && item.payload) {
|
||||
const payload = JSON.parse(item.payload);
|
||||
await repo.reportPlaybackStart(item.itemId, payload.positionTicks);
|
||||
}
|
||||
break;
|
||||
|
||||
case "report_playback_stopped":
|
||||
if (item.itemId && item.payload) {
|
||||
const payload = JSON.parse(item.payload);
|
||||
await repo.reportPlaybackStopped(item.itemId, payload.positionTicks);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`[SyncService] Unknown operation: ${item.operation}`);
|
||||
}
|
||||
|
||||
// Mark as completed
|
||||
await invoke("sync_mark_completed", { id: item.id });
|
||||
|
||||
// Also mark local data as synced
|
||||
if (item.itemId) {
|
||||
await invoke("storage_mark_synced", {
|
||||
userId: item.userId,
|
||||
itemId: item.itemId,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[SyncService] Successfully processed item ${item.id}`);
|
||||
} catch (error) {
|
||||
console.error(`[SyncService] Failed to process item ${item.id}:`, error);
|
||||
|
||||
// Calculate retry delay with exponential backoff
|
||||
const retryDelay = BASE_RETRY_DELAY_MS * Math.pow(2, item.retryCount);
|
||||
|
||||
// Mark as failed
|
||||
await invoke("sync_mark_failed", {
|
||||
id: item.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
// Wait before continuing (gives server time to recover if overloaded)
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.min(retryDelay, 10000)));
|
||||
}
|
||||
return invoke<SyncQueueItem[]>("sync_get_pending", {
|
||||
userId,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -343,6 +152,8 @@ class SyncService {
|
||||
|
||||
/**
|
||||
* Clear all sync operations for the current user (called during logout)
|
||||
*
|
||||
* TRACES: UR-017 | DR-014
|
||||
*/
|
||||
async clearUser(): Promise<void> {
|
||||
const userId = auth.getUserId();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Application-wide UI state store
|
||||
// TRACES: UR-005 | DR-005, DR-009
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
// App-wide state (root layout)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//
|
||||
// Simplified wrapper over Rust connectivity monitor.
|
||||
// The Rust backend handles all polling, reachability checks, and adaptive intervals.
|
||||
// TRACES: UR-002 | DR-013
|
||||
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Tests for downloads store
|
||||
// TRACES: UR-011, UR-013, UR-018 | DR-015, DR-017 | UT-010, UT-024
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Download manager state store
|
||||
// TRACES: UR-011, UR-013, UR-018 | DR-015, DR-017
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Home screen data store - featured items, continue watching, recently added
|
||||
// TRACES: UR-023, UR-024, UR-034 | DR-026, DR-027, DR-038, DR-039
|
||||
import { writable, derived } from "svelte/store";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "./auth";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Library state store
|
||||
// TRACES: UR-007, UR-008, UR-029, UR-030 | DR-007, DR-011, DR-033
|
||||
|
||||
import { writable, derived } from "svelte/store";
|
||||
import type { Library, MediaItem, SearchResult, Genre } from "$lib/api/types";
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* The backend handles all countdown logic and decisions.
|
||||
*
|
||||
* The backend emits ShowNextEpisodePopup and CountdownTick events to update this store.
|
||||
*
|
||||
* TRACES: UR-023 | DR-026, DR-047, DR-048
|
||||
*/
|
||||
|
||||
import { writable, derived } from "svelte/store";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Tests for playback mode store
|
||||
// TRACES: UR-010 | DR-037
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
*
|
||||
* Most business logic moved to Rust (src-tauri/src/playback_mode/mod.rs)
|
||||
*
|
||||
* @req: UR-010 - Control playback of Jellyfin remote sessions
|
||||
* @req: IR-012 - Jellyfin Sessions API for remote playback control
|
||||
* @req: DR-037 - Remote session browser and control UI
|
||||
* TRACES: UR-010 | IR-012 | DR-037
|
||||
*/
|
||||
|
||||
import { writable, get, derived } from "svelte/store";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Tests for sessions store
|
||||
// TRACES: UR-010 | DR-037
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import type { Session } from "$lib/api/types";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Remote sessions store for controlling playback on other Jellyfin clients
|
||||
// TRACES: UR-010 | DR-037
|
||||
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* All logic is in the Rust backend (PlayerController).
|
||||
*
|
||||
* The backend emits SleepTimerChanged events to update this store.
|
||||
*
|
||||
* TRACES: UR-026 | DR-029
|
||||
*/
|
||||
|
||||
import { writable, derived } from "svelte/store";
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
/**
|
||||
* Utility function to create debounced functions
|
||||
* Used in GenericMediaListPage for search input debouncing
|
||||
*/
|
||||
export function createDebouncedFunction<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
delayMs: number = 300
|
||||
) {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
fn(...args);
|
||||
timeout = null;
|
||||
}, delayMs);
|
||||
};
|
||||
}
|
||||
|
||||
describe("Debounce Utility", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("Basic Debouncing", () => {
|
||||
it("should delay function execution", () => {
|
||||
const mockFn = vi.fn();
|
||||
const debouncedFn = createDebouncedFunction(mockFn, 300);
|
||||
|
||||
debouncedFn("test");
|
||||
|
||||
// Should not be called immediately
|
||||
expect(mockFn).not.toHaveBeenCalled();
|
||||
|
||||
// Advance time by 300ms
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
// Now it should be called
|
||||
expect(mockFn).toHaveBeenCalledWith("test");
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should not call function if timer is cleared before delay", () => {
|
||||
const mockFn = vi.fn();
|
||||
const debouncedFn = createDebouncedFunction(mockFn, 300);
|
||||
|
||||
debouncedFn("test");
|
||||
vi.advanceTimersByTime(150);
|
||||
|
||||
// Call again before delay completes
|
||||
debouncedFn("updated");
|
||||
|
||||
// First timeout should be cleared
|
||||
vi.advanceTimersByTime(150);
|
||||
|
||||
// Should still not have been called
|
||||
expect(mockFn).not.toHaveBeenCalled();
|
||||
|
||||
// Complete the second timeout
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
// Should be called once with latest value
|
||||
expect(mockFn).toHaveBeenCalledWith("updated");
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should handle multiple rapid calls", () => {
|
||||
const mockFn = vi.fn();
|
||||
const debouncedFn = createDebouncedFunction(mockFn, 300);
|
||||
|
||||
// Rapid calls
|
||||
debouncedFn("a");
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
debouncedFn("b");
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
debouncedFn("c");
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
// Should not be called yet
|
||||
expect(mockFn).not.toHaveBeenCalled();
|
||||
|
||||
// Complete the final timeout
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
// Should be called once with the last value
|
||||
expect(mockFn).toHaveBeenCalledWith("c");
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call multiple times if calls are spaced out", () => {
|
||||
const mockFn = vi.fn();
|
||||
const debouncedFn = createDebouncedFunction(mockFn, 300);
|
||||
|
||||
debouncedFn("first");
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
// Should be called
|
||||
expect(mockFn).toHaveBeenCalledWith("first");
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Wait enough time and call again
|
||||
vi.advanceTimersByTime(200);
|
||||
debouncedFn("second");
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
// Should be called again
|
||||
expect(mockFn).toHaveBeenCalledWith("second");
|
||||
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Custom Delay", () => {
|
||||
it("should respect custom delay values", () => {
|
||||
const mockFn = vi.fn();
|
||||
const debouncedFn = createDebouncedFunction(mockFn, 500);
|
||||
|
||||
debouncedFn("test");
|
||||
|
||||
// 300ms shouldn't trigger
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(mockFn).not.toHaveBeenCalled();
|
||||
|
||||
// But 500ms should
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(mockFn).toHaveBeenCalledWith("test");
|
||||
});
|
||||
|
||||
it("should handle zero delay", () => {
|
||||
const mockFn = vi.fn();
|
||||
const debouncedFn = createDebouncedFunction(mockFn, 0);
|
||||
|
||||
debouncedFn("test");
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
|
||||
expect(mockFn).toHaveBeenCalledWith("test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Search Use Case", () => {
|
||||
it("should debounce search queries correctly", () => {
|
||||
const mockSearch = vi.fn();
|
||||
const debouncedSearch = createDebouncedFunction(mockSearch, 300);
|
||||
|
||||
// User types "t"
|
||||
debouncedSearch("t");
|
||||
expect(mockSearch).not.toHaveBeenCalled();
|
||||
|
||||
// User types "te" quickly
|
||||
vi.advanceTimersByTime(100);
|
||||
debouncedSearch("te");
|
||||
expect(mockSearch).not.toHaveBeenCalled();
|
||||
|
||||
// User types "tes"
|
||||
vi.advanceTimersByTime(100);
|
||||
debouncedSearch("tes");
|
||||
expect(mockSearch).not.toHaveBeenCalled();
|
||||
|
||||
// User types "test"
|
||||
vi.advanceTimersByTime(100);
|
||||
debouncedSearch("test");
|
||||
expect(mockSearch).not.toHaveBeenCalled();
|
||||
|
||||
// Wait for debounce delay
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
// Should only call once with final value
|
||||
expect(mockSearch).toHaveBeenCalledWith("test");
|
||||
expect(mockSearch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should cancel pending search if input clears quickly", () => {
|
||||
const mockSearch = vi.fn();
|
||||
const debouncedSearch = createDebouncedFunction(mockSearch, 300);
|
||||
|
||||
// User types "test"
|
||||
debouncedSearch("test");
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
// User clears input
|
||||
debouncedSearch("");
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
// User types again
|
||||
debouncedSearch("new");
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
// Should only call with final value
|
||||
expect(mockSearch).toHaveBeenCalledWith("new");
|
||||
expect(mockSearch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should work with async search functions", () => {
|
||||
const mockAsyncSearch = vi.fn().mockResolvedValue([]);
|
||||
const debouncedSearch = createDebouncedFunction(mockAsyncSearch, 300);
|
||||
|
||||
debouncedSearch("query");
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(mockAsyncSearch).toHaveBeenCalledWith("query");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Generic Parameter Handling", () => {
|
||||
it("should preserve function parameters", () => {
|
||||
const mockFn = vi.fn();
|
||||
const debouncedFn = createDebouncedFunction(mockFn, 300);
|
||||
|
||||
const obj = { id: "123", name: "test" };
|
||||
debouncedFn("string", 42, obj);
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(mockFn).toHaveBeenCalledWith("string", 42, obj);
|
||||
});
|
||||
|
||||
it("should handle functions with no parameters", () => {
|
||||
const mockFn = vi.fn();
|
||||
const debouncedFn = createDebouncedFunction(mockFn, 300);
|
||||
|
||||
debouncedFn();
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(mockFn).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("should handle complex object parameters", () => {
|
||||
const mockFn = vi.fn();
|
||||
const debouncedFn = createDebouncedFunction(mockFn, 300);
|
||||
|
||||
const options = {
|
||||
query: "test",
|
||||
filters: { type: "Audio", limit: 100 },
|
||||
sort: { by: "SortName", order: "Ascending" },
|
||||
};
|
||||
|
||||
debouncedFn(options);
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(mockFn).toHaveBeenCalledWith(options);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Memory Management", () => {
|
||||
it("should clean up timeout after execution", () => {
|
||||
const mockFn = vi.fn();
|
||||
const debouncedFn = createDebouncedFunction(mockFn, 300);
|
||||
|
||||
debouncedFn("test");
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(mockFn).toHaveBeenCalled();
|
||||
|
||||
const callCount = mockFn.mock.calls.length;
|
||||
|
||||
// Call again shortly after
|
||||
debouncedFn("test2");
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
// Additional calls within delay shouldn't cause multiple executions
|
||||
debouncedFn("test3");
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
// Should only have been called 2 times total
|
||||
expect(mockFn.mock.calls.length).toBe(2);
|
||||
});
|
||||
|
||||
it("should handle repeated debouncing without memory leaks", () => {
|
||||
const mockFn = vi.fn();
|
||||
const debouncedFn = createDebouncedFunction(mockFn, 50);
|
||||
|
||||
// Simulate 100 rapid calls
|
||||
for (let i = 0; i < 100; i++) {
|
||||
debouncedFn(`call${i}`);
|
||||
vi.advanceTimersByTime(10);
|
||||
}
|
||||
|
||||
// Complete final timeout
|
||||
vi.advanceTimersByTime(50);
|
||||
|
||||
// Should only be called once with the last value
|
||||
expect(mockFn).toHaveBeenCalledWith("call99");
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Duration formatting utility tests
|
||||
*
|
||||
* TRACES: UR-005 | DR-028
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Input validation utility tests
|
||||
*
|
||||
* TRACES: UR-009, UR-025 | DR-015
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
Reference in New Issue
Block a user