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) =====
|
||||
|
||||
Reference in New Issue
Block a user