Load newest albums and complete song pages, add a Songs view, bundle FFmpeg, persist credentials securely, and serve the managed library on the LAN
Hop-State: A_06FNEJ4B52YWW6V4DQKVHXR Hop-Proposal: R_06FNEJ37KHGP1VCKVNTV95R Hop-Task: T_06FNEEBRXR47KT7H71N5H68 Hop-Attempt: AT_06FNEEBRXS5YEZ0KZ4MTDZG
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { pollForLibrary } from "./librarySync";
|
||||
import { fetchAllPages, pollForLibrary } from "./librarySync";
|
||||
|
||||
describe("pollForLibrary", () => {
|
||||
it("waits until Navidrome returns indexed tracks", async () => {
|
||||
@@ -24,4 +24,30 @@ describe("pollForLibrary", () => {
|
||||
await expect(pollForLibrary(load, { attempts: 3, wait })).resolves.toBeNull();
|
||||
expect(load).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("can wait for albums and tracks to finish indexing together", async () => {
|
||||
const load = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ albums: [], tracks: [{ id: "early" }] })
|
||||
.mockResolvedValueOnce({ albums: [{ id: "album" }], tracks: [{ id: "ready" }] });
|
||||
|
||||
await expect(pollForLibrary<{ albums: { id: string }[]; tracks: { id: string }[] }>(load, {
|
||||
attempts: 2,
|
||||
wait: async () => undefined,
|
||||
isReady: (snapshot) => snapshot.albums.length > 0 && snapshot.tracks.length > 0,
|
||||
})).resolves.toEqual({ albums: [{ id: "album" }], tracks: [{ id: "ready" }] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchAllPages", () => {
|
||||
it("loads successive pages until the server returns a partial page", async () => {
|
||||
const loadPage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(["one", "two"])
|
||||
.mockResolvedValueOnce(["three"]);
|
||||
|
||||
await expect(fetchAllPages(loadPage, 2)).resolves.toEqual(["one", "two", "three"]);
|
||||
expect(loadPage).toHaveBeenNthCalledWith(1, 0, 2);
|
||||
expect(loadPage).toHaveBeenNthCalledWith(2, 2, 2);
|
||||
});
|
||||
});
|
||||
|
||||
+23
-3
@@ -2,22 +2,42 @@ export interface LibrarySnapshot<TTrack> {
|
||||
tracks: readonly TTrack[];
|
||||
}
|
||||
|
||||
interface PollOptions {
|
||||
export async function fetchAllPages<T>(
|
||||
loadPage: (offset: number, size: number) => Promise<readonly T[]>,
|
||||
pageSize = 500,
|
||||
maxPages = 100,
|
||||
): Promise<T[]> {
|
||||
const items: T[] = [];
|
||||
for (let page = 0; page < maxPages; page += 1) {
|
||||
const batch = await loadPage(page * pageSize, pageSize);
|
||||
items.push(...batch);
|
||||
if (batch.length < pageSize) break;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
interface PollOptions<TSnapshot> {
|
||||
attempts?: number;
|
||||
delayMs?: number;
|
||||
wait?: (delayMs: number) => Promise<void>;
|
||||
isReady?: (snapshot: TSnapshot) => boolean;
|
||||
}
|
||||
|
||||
const waitFor = (delayMs: number) => new Promise<void>((resolve) => window.setTimeout(resolve, delayMs));
|
||||
|
||||
export async function pollForLibrary<T extends LibrarySnapshot<unknown>>(
|
||||
load: () => Promise<T>,
|
||||
{ attempts = 120, delayMs = 1_000, wait = waitFor }: PollOptions = {},
|
||||
{
|
||||
attempts = 120,
|
||||
delayMs = 1_000,
|
||||
wait = waitFor,
|
||||
isReady = (snapshot) => snapshot.tracks.length > 0,
|
||||
}: PollOptions<T> = {},
|
||||
): Promise<T | null> {
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
try {
|
||||
const snapshot = await load();
|
||||
if (snapshot.tracks.length > 0) return snapshot;
|
||||
if (isReady(snapshot)) return snapshot;
|
||||
} catch (error) {
|
||||
if (attempt === attempts - 1) throw error;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getManagedServerStatus, isDesktopRuntime } from "./managedServer";
|
||||
import { getManagedServerStatus, getSavedCredentials, isDesktopRuntime } from "./managedServer";
|
||||
|
||||
describe("managed server bridge", () => {
|
||||
it("reports the browser preview as a non-desktop runtime", () => {
|
||||
@@ -10,7 +10,12 @@ describe("managed server bridge", () => {
|
||||
await expect(getManagedServerStatus()).resolves.toMatchObject({
|
||||
running: false,
|
||||
url: "http://127.0.0.1:4533",
|
||||
lanUrls: [],
|
||||
version: "0.61.2",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not expose credentials in the browser preview", async () => {
|
||||
await expect(getSavedCredentials()).resolves.toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,16 @@ import { open } from "@tauri-apps/plugin-dialog";
|
||||
export interface ManagedServerStatus {
|
||||
running: boolean;
|
||||
url: string;
|
||||
lanUrls: string[];
|
||||
musicFolder?: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface SavedCredentials {
|
||||
localPassword?: string;
|
||||
remotePassword?: string;
|
||||
}
|
||||
|
||||
export function isDesktopRuntime(): boolean {
|
||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||
}
|
||||
@@ -24,11 +30,21 @@ export async function chooseMusicFolder(): Promise<string | null> {
|
||||
|
||||
export async function getManagedServerStatus(): Promise<ManagedServerStatus> {
|
||||
if (!isDesktopRuntime()) {
|
||||
return { running: false, url: "http://127.0.0.1:4533", version: "0.61.2" };
|
||||
return { running: false, url: "http://127.0.0.1:4533", lanUrls: [], version: "0.61.2" };
|
||||
}
|
||||
return invoke<ManagedServerStatus>("managed_server_status");
|
||||
}
|
||||
|
||||
export async function getSavedCredentials(): Promise<SavedCredentials> {
|
||||
if (!isDesktopRuntime()) return {};
|
||||
return invoke<SavedCredentials>("load_saved_credentials");
|
||||
}
|
||||
|
||||
export async function saveRemotePassword(password: string): Promise<void> {
|
||||
if (!isDesktopRuntime()) return;
|
||||
await invoke("save_remote_password", { password });
|
||||
}
|
||||
|
||||
export async function startManagedServer(musicFolder: string, password: string): Promise<ManagedServerStatus> {
|
||||
if (!isDesktopRuntime()) throw new Error("The managed library is available in the Resonant desktop app.");
|
||||
return invoke<ManagedServerStatus>("start_managed_server", { musicFolder, password });
|
||||
|
||||
Reference in New Issue
Block a user