Library screens:
- Add dedicated music, TV, and movie landing pages (hero banner +
horizontal carousels) backed by new music/tv/movies stores.
- Route tvshows libraries to /library/tv; surface rediscover ("haven't
listened to in a while") albums via a new repository method across
online/offline/hybrid repos plus the repository_get_rediscover_albums
command.
- Add an A-Z jump bar for long alphabetically-sorted lists, with grid
index anchors in LibraryGrid/LibraryListView/TrackList.
- Filter the "Podcasts" folder out of music library queries.
Downloads:
- Add a backend queue pump: enqueue_download / enqueue_video_downloads
persist the resolved stream URL + target dir on each row (migration
017), and the pump starts up to max_concurrent and drains the rest
automatically as slots free, instead of the frontend silently dropping
items past the concurrency limit. Album/series/season buttons now
enqueue rather than calling start_download directly.
Other fixes:
- Hybrid search now returns instant cache results and pushes the merged
cache+server union via a request-id-tagged search-event, so superseded
queries can't clobber fresher results.
- URL-encode SearchTerm / genres / item types in online repo requests.
- Android: pause on audio-becoming-noisy (headphone/BT disconnect).
549 lines
18 KiB
TypeScript
549 lines
18 KiB
TypeScript
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");
|
|
|
|
const mockInvoke = vi.mocked(invoke);
|
|
|
|
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", "high");
|
|
|
|
expect(downloadUrl).toBe(mockUrl);
|
|
expect(invoke).toHaveBeenCalledWith("repository_get_video_download_url", {
|
|
handle: "test-handle-123",
|
|
itemId: "item123",
|
|
quality: "high",
|
|
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", "high", "medium", "low"];
|
|
|
|
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", "medium", "source789");
|
|
|
|
expect(invoke).toHaveBeenCalledWith("repository_get_video_download_url", {
|
|
handle: expect.any(String),
|
|
itemId: "item123",
|
|
quality: "medium",
|
|
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,
|
|
},
|
|
requestId: 0,
|
|
});
|
|
});
|
|
});
|
|
|
|
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("Playlist Methods", () => {
|
|
beforeEach(async () => {
|
|
(invoke as any).mockResolvedValueOnce("test-handle-123");
|
|
await client.create("https://server.com", "user1", "token123", "server1");
|
|
});
|
|
|
|
it("should create a playlist", async () => {
|
|
const mockResult = { id: "playlist-001" };
|
|
(invoke as any).mockResolvedValueOnce(mockResult);
|
|
|
|
const result = await client.createPlaylist("My Playlist", ["track1", "track2"]);
|
|
|
|
expect(result).toEqual(mockResult);
|
|
expect(invoke).toHaveBeenCalledWith("playlist_create", {
|
|
handle: "test-handle-123",
|
|
name: "My Playlist",
|
|
itemIds: ["track1", "track2"],
|
|
});
|
|
});
|
|
|
|
it("should create a playlist without initial items", async () => {
|
|
const mockResult = { id: "playlist-002" };
|
|
(invoke as any).mockResolvedValueOnce(mockResult);
|
|
|
|
await client.createPlaylist("Empty Playlist");
|
|
|
|
expect(invoke).toHaveBeenCalledWith("playlist_create", {
|
|
handle: "test-handle-123",
|
|
name: "Empty Playlist",
|
|
itemIds: null,
|
|
});
|
|
});
|
|
|
|
it("should delete a playlist", async () => {
|
|
(invoke as any).mockResolvedValueOnce(undefined);
|
|
|
|
await client.deletePlaylist("playlist-001");
|
|
|
|
expect(invoke).toHaveBeenCalledWith("playlist_delete", {
|
|
handle: "test-handle-123",
|
|
playlistId: "playlist-001",
|
|
});
|
|
});
|
|
|
|
it("should rename a playlist", async () => {
|
|
(invoke as any).mockResolvedValueOnce(undefined);
|
|
|
|
await client.renamePlaylist("playlist-001", "New Name");
|
|
|
|
expect(invoke).toHaveBeenCalledWith("playlist_rename", {
|
|
handle: "test-handle-123",
|
|
playlistId: "playlist-001",
|
|
name: "New Name",
|
|
});
|
|
});
|
|
|
|
it("should get playlist items", async () => {
|
|
const mockItems = [
|
|
{ playlistItemId: "entry1", id: "track1", name: "Track 1", type: "Audio" },
|
|
{ playlistItemId: "entry2", id: "track2", name: "Track 2", type: "Audio" },
|
|
];
|
|
(invoke as any).mockResolvedValueOnce(mockItems);
|
|
|
|
const items = await client.getPlaylistItems("playlist-001");
|
|
|
|
expect(items).toEqual(mockItems);
|
|
expect(invoke).toHaveBeenCalledWith("playlist_get_items", {
|
|
handle: "test-handle-123",
|
|
playlistId: "playlist-001",
|
|
});
|
|
});
|
|
|
|
it("should add items to a playlist", async () => {
|
|
(invoke as any).mockResolvedValueOnce(undefined);
|
|
|
|
await client.addToPlaylist("playlist-001", ["track3", "track4"]);
|
|
|
|
expect(invoke).toHaveBeenCalledWith("playlist_add_items", {
|
|
handle: "test-handle-123",
|
|
playlistId: "playlist-001",
|
|
itemIds: ["track3", "track4"],
|
|
});
|
|
});
|
|
|
|
it("should remove items from a playlist using entry IDs", async () => {
|
|
(invoke as any).mockResolvedValueOnce(undefined);
|
|
|
|
await client.removeFromPlaylist("playlist-001", ["entry1", "entry2"]);
|
|
|
|
expect(invoke).toHaveBeenCalledWith("playlist_remove_items", {
|
|
handle: "test-handle-123",
|
|
playlistId: "playlist-001",
|
|
entryIds: ["entry1", "entry2"],
|
|
});
|
|
});
|
|
|
|
it("should move a playlist item", async () => {
|
|
(invoke as any).mockResolvedValueOnce(undefined);
|
|
|
|
await client.movePlaylistItem("playlist-001", "track1", 3);
|
|
|
|
expect(invoke).toHaveBeenCalledWith("playlist_move_item", {
|
|
handle: "test-handle-123",
|
|
playlistId: "playlist-001",
|
|
itemId: "track1",
|
|
newIndex: 3,
|
|
});
|
|
});
|
|
|
|
it("should throw error if not initialized before playlist operations", async () => {
|
|
const newClient = new RepositoryClient();
|
|
await expect(newClient.getPlaylistItems("pl-1")).rejects.toThrow("Repository not initialized");
|
|
await expect(newClient.createPlaylist("test")).rejects.toThrow("Repository not initialized");
|
|
await expect(newClient.deletePlaylist("pl-1")).rejects.toThrow("Repository not initialized");
|
|
});
|
|
});
|
|
|
|
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,
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|