feat(search): enhance track search (#275)

This commit is contained in:
cl.st authored and GitHub committed 2026-04-21 00:13:28 +02:00
1 parent 473ce821f0
commit 57b5313c65
6 files changed
+179 -64

No files matched your search

+18 -4
View File
@@ -9,6 +9,8 @@ type LidarrAlbum = {
id: number;
title: string;
artistId: number;
monitored: boolean;
statistics?: { percentOfTracks: number };
};
type LidarrArtistLookup = {
@@ -30,7 +32,7 @@ type LidarrArtist = {
};
export type LidarrAutoSearchResult =
| { success: true }
| { success: true; status?: "queued" | "wanted" | "available" }
| { success: false; message: string };
function delay(ms: number) {
@@ -136,6 +138,14 @@ async function ensureArtist(
return { success: true, artistId: created.id, created: true };
}
function getAlbumStatus(album: LidarrAlbum): "available" | "wanted" | "queued" {
const pct = album.statistics?.percentOfTracks ?? 0;
if (pct === 100) return "available";
if (album.monitored) return "wanted";
return "queued";
}
async function getAlbumsByArtist(
config: LidarrConfig,
artistId: number
@@ -147,7 +157,7 @@ async function waitForAlbum(
config: LidarrConfig,
artistId: number,
normalizedAlbum: string,
timeoutMs = 30_000,
timeoutMs = 60_000,
pollIntervalMs = 2_500
): Promise<LidarrAlbum | null> {
const started = Date.now();
@@ -204,8 +214,10 @@ export async function lidarrAutoSearch(
const albums = await getAlbumsByArtist(config, matchedLocal.id);
const album = albums.find((a) => normalize(a.title) === normalizedAlbum);
if (album) {
const albumStatus = getAlbumStatus(album);
if (albumStatus === "available") return { success: true, status: "available" };
await triggerAlbumSearch(config, album.id);
return { success: true };
return { success: true, status: albumStatus };
}
return { success: false, message: "Album not found" };
}
@@ -234,8 +246,10 @@ export async function lidarrAutoSearch(
);
if (album) {
const albumStatus = getAlbumStatus(album);
if (albumStatus === "available") return { success: true, status: "available" };
await triggerAlbumSearch(config, album.id);
return { success: true };
return { success: true, status: albumStatus };
}
if (ensured.created) {
+5 -2
View File
@@ -47,9 +47,12 @@ async function request<T>(
...(options.headers ?? {}),
},
});
if (!res.ok) throw new Error(`Tidarr API error (${res.status}): ${await res.text()}`);
const contentType = res.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) return undefined as T;
if (!res.ok) {
const body = contentType.includes("application/json") ? await res.text() : "";
throw new Error(`Tidarr API error (${res.status})${body ? `: ${body}` : ""}`);
}
if (!contentType.includes("application/json")) throw new Error("Tidarr unreachable");
const text = await res.text();
return text ? JSON.parse(text) : (undefined as T);
}
+69 -25
View File
@@ -1,10 +1,24 @@
import { useState } from "react";
import { Button, CircularProgress } from "@mui/material";
import { OpenInNew } from "@mui/icons-material";
import { Button, CircularProgress, IconButton, Stack, Tooltip } from "@mui/material";
import lidarrLogo from "../../../resources/lidarr.png";
import { useConfigProvider } from "../../Config/useConfig";
import { lidarrAutoSearch } from "../api/lidarr";
function formatLidarrError(message: string): string {
if (/failed to fetch|network|networkerror/i.test(message)) return "Lidarr unreachable";
if (/<!doctype|not valid json|unexpected token/i.test(message)) return "Lidarr unreachable";
if (/401|unauthorized/i.test(message)) return "Invalid API key";
if (/403|forbidden/i.test(message)) return "Access denied";
if (/404/i.test(message)) return "Lidarr not found — check URL";
if (/5\d\d|service unavailable|bad gateway/i.test(message)) return "Lidarr unreachable";
if (/album not found/i.test(message)) return "Album not found in Lidarr";
if (/artist not found/i.test(message)) return "Artist not found";
if (/missing/i.test(message)) return "Missing album or artist name";
return message || "Error — try manually";
}
type LidarrStatus = "idle" | "loading" | "success" | "error";
export default function LidarrButton({
@@ -18,6 +32,8 @@ export default function LidarrButton({
}) {
const { isNetworkConnected, config } = useConfigProvider();
const [status, setStatus] = useState<LidarrStatus>("idle");
const [successMessage, setSuccessMessage] = useState("Search triggered in Lidarr!");
const [errorMessage, setErrorMessage] = useState("Error — try manually");
const apiKey = config?.lidarr_api_key;
@@ -32,7 +48,16 @@ export default function LidarrButton({
setStatus("loading");
const result = await lidarrAutoSearch(url, apiKey, albumTitle, artistName);
setStatus(result.success ? "success" : "error");
if (result.success) {
const s = result.status ?? "queued";
setSuccessMessage(
s === "available" ? "Already in Lidarr library" : "Search triggered in Lidarr!"
);
setStatus("success");
} else {
setErrorMessage(formatLidarrError(result.message));
setStatus("error");
}
setTimeout(() => setStatus("idle"), 3000);
}
@@ -40,31 +65,50 @@ export default function LidarrButton({
const label = {
idle: "Download with Lidarr",
loading: "Searching...",
success: "Added to Lidarr!",
error: "Not found — try manually",
success: successMessage,
error: errorMessage,
}[status];
const searchUrl = `${url}/add/search?term=${encodeURIComponent(`${albumTitle} ${artistName}`)}`;
return (
<Button
variant="contained"
disabled={!isNetworkConnected || status === "loading"}
startIcon={
status === "loading" ? (
<CircularProgress size={20} color="inherit" />
) : (
<img src={lidarrLogo} alt="" width="32" height="32" />
)
}
color={
status === "success"
? "success"
: status === "error"
? "warning"
: "primary"
}
onClick={handleClick}
>
<strong>{label}</strong>
</Button>
<Stack direction="row" spacing={1}>
<Button
variant="contained"
fullWidth
disabled={!isNetworkConnected || status === "loading"}
startIcon={
status === "loading" ? (
<CircularProgress size={20} color="inherit" />
) : (
<img src={lidarrLogo} alt="" width="32" height="32" />
)
}
color={
status === "success"
? "success"
: status === "error"
? "warning"
: "primary"
}
onClick={handleClick}
>
<strong>{label}</strong>
</Button>
{apiKey && (
<Tooltip title="Open Lidarr search">
<span>
<IconButton
color="primary"
onClick={() => window.open(searchUrl, "_blank")}
disabled={!isNetworkConnected}
sx={{ border: 2, borderRadius: 1 }}
>
<OpenInNew />
</IconButton>
</span>
</Tooltip>
)}
</Stack>
);
}
+61 -21
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Album, Download, Favorite } from "@mui/icons-material";
import { Album, Download, Favorite, OpenInNew } from "@mui/icons-material";
import {
Box,
Button,
@@ -7,8 +7,10 @@ import {
Dialog,
DialogContent,
DialogTitle,
IconButton,
Stack,
styled,
Tooltip,
Typography,
} from "@mui/material";
@@ -23,6 +25,18 @@ import {
tidarrSearchAlbums,
} from "../api/tidarr";
function formatTidarrError(message: string): string {
if (/failed to fetch|network|networkerror/i.test(message)) return "Tidarr unreachable";
if (/<!doctype|not valid json|unexpected token/i.test(message)) return "Tidarr unreachable";
if (/401|unauthorized/i.test(message)) return "Invalid API key";
if (/403|forbidden/i.test(message)) return "Access denied";
if (/404/i.test(message)) return "Tidarr not found — check URL";
if (/5\d\d|service unavailable|bad gateway/i.test(message)) return "Tidarr unreachable";
if (/track not found/i.test(message)) return "Track not found on Tidal";
if (/credentials/i.test(message)) return "Tidal not logged in — check Tidarr";
return message || "Error — try manually";
}
type TidarrStatus = "idle" | "loading" | "success" | "error";
export default function TidarrButton({
@@ -39,11 +53,18 @@ export default function TidarrButton({
const { isNetworkConnected, config } = useConfigProvider();
const [status, setStatus] = useState<TidarrStatus>("idle");
const [successMessage, setSuccessMessage] = useState("Added to Tidarr!");
const [errorMessage, setErrorMessage] = useState("Error — try manually");
const [albums, setAlbums] = useState<TidalAlbum[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const apiKey = config?.tidarr_api_key;
function setError(msg: string) {
setErrorMessage(formatTidarrError(msg));
setStatus("error");
setTimeout(() => setStatus("idle"), 3000);
}
async function handleClick() {
if (!apiKey) {
window.open(
@@ -57,16 +78,14 @@ export default function TidarrButton({
try {
const results = await tidarrSearchAlbums(url, apiKey, artistName, albumTitle, trackTitle);
if (results.length === 0) {
setStatus("error");
setTimeout(() => setStatus("idle"), 3000);
setError("Track not found on Tidal");
return;
}
setAlbums(results);
setDialogOpen(true);
setStatus("idle");
} catch {
setStatus("error");
setTimeout(() => setStatus("idle"), 3000);
} catch (e) {
setError(e instanceof Error ? e.message : "");
}
}
@@ -74,35 +93,41 @@ export default function TidarrButton({
setDialogOpen(false);
setStatus("loading");
let result: TidarrQueueResult;
if (type === "album") {
result = await tidarrQueueAlbum(url, apiKey!, album, artistName);
} else if (type === "track") {
result = await tidarrQueueTrack(url, apiKey!, album.id, trackTitle);
} else {
result = await tidarrFavoriteTrack(url, apiKey!, album.id, trackTitle);
}
try {
let result: TidarrQueueResult;
if (type === "album") {
result = await tidarrQueueAlbum(url, apiKey!, album, artistName);
} else if (type === "track") {
result = await tidarrQueueTrack(url, apiKey!, album.id, trackTitle);
} else {
result = await tidarrFavoriteTrack(url, apiKey!, album.id, trackTitle);
}
if (result.success) {
if (result.message) setSuccessMessage(result.message);
setStatus("success");
} else {
setStatus("error");
if (result.success) {
if (result.message) setSuccessMessage(result.message);
setStatus("success");
setTimeout(() => setStatus("idle"), 3000);
} else {
setError(result.message);
}
} catch (e) {
setError(e instanceof Error ? e.message : "");
}
setTimeout(() => setStatus("idle"), 3000);
}
const label = {
idle: "Download with Tidarr",
loading: "Searching...",
success: successMessage,
error: "Not found — try manually",
error: errorMessage,
}[status];
return (
<>
<Stack direction="row" spacing={1}>
<Button
variant="contained"
fullWidth
disabled={!isNetworkConnected || status === "loading"}
startIcon={
status === "loading" ? (
@@ -122,6 +147,21 @@ export default function TidarrButton({
>
<strong>{label}</strong>
</Button>
{apiKey && (
<Tooltip title="Open Tidarr search">
<span>
<IconButton
color="primary"
onClick={() => window.open(`${url}/search/${encodeURIComponent(`${trackTitle} ${artistName}`)}`, "_blank")}
disabled={!isNetworkConnected}
sx={{ border: 2, borderRadius: 1 }}
>
<OpenInNew />
</IconButton>
</span>
</Tooltip>
)}
</Stack>
<Dialog
open={dialogOpen}
+17 -7
View File
@@ -29,20 +29,29 @@ async function openYakuzaResult(page: Page) {
await expect(page.locator(".MuiTypography-h5")).toHaveText("Yakuza");
}
function mockLidarrRoutes(page: Page, overrides: { artistLookup?: unknown[]; albums?: unknown[] } = {}) {
function mockLidarrRoutes(
page: Page,
overrides: {
artistLookup?: unknown[];
albums?: unknown[];
} = {},
) {
return page.route(`${LIDARR_URL}/**`, async (route) => {
const url = route.request().url();
const method = route.request().method();
if (url.includes("/api/v1/artist") && !url.includes("lookup") && method === "GET") {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]) });
const body: unknown[] = [];
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
} else if (url.includes("/api/v1/artist/lookup")) {
const body = overrides.artistLookup ?? [{ foreignArtistId: "abc-123", artistName: "Szymon" }];
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
} else if (url.includes("/api/v1/artist") && method === "POST") {
await route.fulfill({ status: 201, contentType: "application/json", body: JSON.stringify({ id: 1 }) });
} else if (url.includes("/api/v1/album") && url.includes("artistId=1")) {
const body = overrides.albums ?? [{ id: 10, title: "Blue Coloured Mountain", artistId: 1 }];
} else if (url.includes("/api/v1/album") && url.includes("artistId=")) {
const body = overrides.albums ?? [
{ id: 10, title: "Blue Coloured Mountain", artistId: 1, monitored: false, statistics: { percentOfTracks: 0 } },
];
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
} else if (url.includes("/api/v1/qualityprofile")) {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([{ id: 1, name: "Any" }]) });
@@ -87,18 +96,19 @@ test("Lidarr: With API key — calls API and shows success", async ({ page }) =>
await page.getByRole("button", { name: "Download with Lidarr" }).click();
await expect(page.getByRole("button", { name: /Searching/i })).toBeVisible();
await expect(page.getByRole("button", { name: "Added to Lidarr!" })).toBeVisible({
await expect(page.getByRole("button", { name: "Search triggered in Lidarr!" })).toBeVisible({
timeout: 15000,
});
});
test("Lidarr: With API key — shows error when not found", async ({ page }) => {
test("Lidarr: With API key — shows error when artist not found", async ({ page }) => {
await mockLidarrRoutes(page, { artistLookup: [] });
await gotoWithConfig(page, { lidarr_url: LIDARR_URL, lidarr_api_key: LIDARR_API_KEY });
await openYakuzaResult(page);
await page.getByRole("button", { name: "Download with Lidarr" }).click();
await expect(
page.getByRole("button", { name: "Not found — try manually" }),
page.getByRole("button", { name: "Artist not found" }),
).toBeVisible({ timeout: 10000 });
});
+9 -5
View File
@@ -47,17 +47,20 @@ async function openYakuzaResult(page: Page) {
await expect(page.locator(".MuiTypography-h5")).toHaveText("Yakuza");
}
function mockTidarrRoutes(page: Page, overrides: Record<string, unknown> = {}) {
function mockTidarrRoutes(
page: Page,
overrides: { search?: unknown; albumTracks?: unknown } = {},
) {
return page.route(`${TIDARR_URL}/**`, async (route) => {
const url = route.request().url();
if (url.includes("/proxy/tidal/v2/search")) {
const body = (overrides.search ?? { albums: { items: [MOCK_ALBUM] } });
const body = overrides.search ?? { albums: { items: [MOCK_ALBUM] } };
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
} else if (url.includes("/proxy/tidal/v1/pages/album")) {
const body = (overrides.albumTracks ?? {
const body = overrides.albumTracks ?? {
rows: [null, { modules: [{ pagedList: { items: [{ item: MOCK_TRACK }] } }] }],
});
};
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
} else if (url.includes("/api/settings")) {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(MOCK_SETTINGS) });
@@ -112,6 +115,7 @@ test("Tidarr: With API key — shows error when no albums found", async ({ page
await page.getByRole("button", { name: "Download with Tidarr" }).click();
await expect(
page.getByRole("button", { name: "Not found — try manually" }),
page.getByRole("button", { name: "Track not found on Tidal" }),
).toBeVisible({ timeout: 10000 });
});