mirror of
https://github.com/cstaelen/shazarr.git
synced 2026-10-19 01:31:11 -05:00
feat(search): enhance track search (#275)
This commit is contained in:
1 parent
473ce821f0
commit
57b5313c65
6 files changed
+179
-64
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user