feat(lidarr): add lidarr search fallback (#277)

* feat(lidarr): add search fallback

* make lidarr more accurate

* fix apk
This commit is contained in:
cl.st authored and GitHub committed 2026-04-28 18:10:52 +02:00
1 parent 19b8788263
commit 5bfd54ab2c
5 files changed
+264 -167

No files matched your search

+16 -14
View File
@@ -67,26 +67,28 @@ jobs:
run: chmod +x gradlew
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
uses: gradle/actions/setup-gradle@v4
- name: Execute Gradle build
working-directory: ./android
run: ./gradlew build --scan
run: ./gradlew assembleRelease
- name: Sign APK
id: sign_app
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: android/app/build/outputs/apk/release/
signingKeyBase64: ${{ secrets.KEYSTORE }}
alias: ${{ secrets.SIGNING_KEY_ALIAS }}
keyStorePassword: ${{ secrets.SIGNING_KEYSTORE_PASSWORD }}
keyPassword: ${{ secrets.SIGNING_KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
- name: Rename APK
run: mv ${{steps.sign_app.outputs.signedReleaseFile}} android/app/build/outputs/apk/release/shazarr-signed.apk
KEYSTORE_B64: ${{ secrets.KEYSTORE }}
KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}
KS_PASS: ${{ secrets.SIGNING_KEYSTORE_PASSWORD }}
KEY_PASS: ${{ secrets.SIGNING_KEY_PASSWORD }}
run: |
echo "$KEYSTORE_B64" | base64 --decode > /tmp/keystore.jks
$ANDROID_SDK_ROOT/build-tools/$BUILD_TOOL_VERSION/apksigner sign \
--ks /tmp/keystore.jks \
--ks-key-alias "$KEY_ALIAS" \
--ks-pass "pass:$KS_PASS" \
--key-pass "pass:$KEY_PASS" \
--out android/app/build/outputs/apk/release/shazarr-signed.apk \
android/app/build/outputs/apk/release/app-release-unsigned.apk
rm /tmp/keystore.jks
- name: Upload app apk as artifact
uses: actions/upload-artifact@v4
+2
View File
@@ -32,6 +32,7 @@ export default function ShazarrResults() {
<Stack spacing={2} sx={{ marginBottom: 2 }}>
{config?.lidarr_url && (
<LidarrButton
key={`lidarr-${shazarrResponse.key}`}
albumTitle={albumName || shazarrResponse.title}
artistName={shazarrResponse.subtitle}
url={config.lidarr_url as string}
@@ -39,6 +40,7 @@ export default function ShazarrResults() {
)}
{config?.tidarr_url && (
<TidarrButton
key={`tidarr-${shazarrResponse.key}`}
albumTitle={albumName || shazarrResponse.title}
trackTitle={shazarrResponse.title}
artistName={shazarrResponse.subtitle}
+168 -140
View File
@@ -1,4 +1,4 @@
import { normalize } from "./utils";
import { cleanAlbumTitle, normalize } from "./utils";
type LidarrConfig = {
url: string;
@@ -13,6 +13,12 @@ type LidarrAlbum = {
statistics?: { percentOfTracks: number };
};
type LidarrArtist = {
id: number;
artistName: string;
foreignArtistId: string;
};
type LidarrArtistLookup = {
artistName: string;
foreignArtistId: string;
@@ -25,92 +31,82 @@ type LidarrArtistLookup = {
status?: string;
};
type LidarrArtist = {
id: number;
artistName: string;
foreignArtistId: string;
type LidarrSearchResult = {
foreignId: string;
album?: {
id: number;
title: string;
foreignAlbumId: string;
artistId: number;
monitored: boolean;
statistics?: { percentOfTracks: number };
artist?: { foreignArtistId: string; artistName: string };
};
};
export type LidarrAutoSearchResult =
| { success: true; status?: "queued" | "wanted" | "available" }
| { success: false; message: string };
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function request<T>(
config: LidarrConfig,
path: string,
options: RequestInit = {}
): Promise<T> {
async function request<T>(config: LidarrConfig, path: string, options: RequestInit = {}): Promise<T> {
const baseUrl = `${config.url.replace(/\/$/, "")}/api/v1`;
console.log("[lidarr] fetch", options.method ?? "GET", `${baseUrl}${path}`, options.body ?? "");
const res = await fetch(`${baseUrl}${path}`, {
// Build URL via URL object — Capacitor double-encodes string concatenation
const [pathname, search] = path.split("?");
const url = new URL(`${baseUrl}${pathname}`);
if (search) {
for (const part of search.split("&")) {
const eq = part.indexOf("=");
url.searchParams.set(eq === -1 ? part : part.slice(0, eq), eq === -1 ? "" : part.slice(eq + 1));
}
}
const res = await fetch(url.toString(), {
...options,
headers: {
"Content-Type": "application/json",
"X-Api-Key": config.apiKey,
...(options.headers ?? {}),
},
headers: { "Content-Type": "application/json", "X-Api-Key": config.apiKey, ...(options.headers ?? {}) },
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Lidarr API error (${res.status}): ${text}`);
}
return res.json();
const text = await res.text();
return text ? JSON.parse(text) : (undefined as unknown as T);
}
async function getQualityProfileId(config: LidarrConfig): Promise<number> {
const profiles = await request<{ id: number }[]>(config, "/qualityprofile");
if (!profiles?.length) throw new Error("No Lidarr quality profiles found");
return profiles[0].id;
async function getAllArtists(config: LidarrConfig): Promise<LidarrArtist[]> {
return request<LidarrArtist[]>(config, "/artist");
}
async function getMetadataProfileId(config: LidarrConfig): Promise<number> {
const profiles = await request<{ id: number }[]>(config, "/metadataprofile");
if (!profiles?.length) throw new Error("No Lidarr metadata profiles found");
return profiles[0].id;
async function getAlbumsByArtist(config: LidarrConfig, artistId: number): Promise<LidarrAlbum[]> {
return request<LidarrAlbum[]>(config, `/album?artistId=${artistId}`);
}
async function getRootFolderPath(config: LidarrConfig): Promise<string> {
const folders = await request<{ path: string }[]>(config, "/rootfolder");
if (!folders?.length) throw new Error("No Lidarr root folders configured");
return folders[0].path;
}
async function lookupArtist(
config: LidarrConfig,
term: string
): Promise<LidarrArtistLookup[]> {
async function lookupArtist(config: LidarrConfig, term: string): Promise<LidarrArtistLookup[]> {
if (!term?.trim()) return [];
return request<LidarrArtistLookup[]>(
config,
`/artist/lookup?term=${encodeURIComponent(term)}`
);
return request<LidarrArtistLookup[]>(config, `/artist/lookup?term=${term}`);
}
async function ensureArtist(
config: LidarrConfig,
artist: LidarrArtistLookup,
existingArtists: LidarrArtist[]
): Promise<
| { success: true; artistId: number; created: boolean }
| { success: false; message: string }
> {
const found = existingArtists.find(
(a) => a.foreignArtistId === artist.foreignArtistId
);
if (found?.id) {
return { success: true, artistId: found.id, created: false };
}
artist: LidarrArtistLookup
): Promise<{ success: true; artistId: number; created: boolean } | { success: false; message: string }> {
if (!artist?.foreignArtistId) return { success: false, message: "Invalid artist: missing foreignArtistId" };
const [rootFolderPath, qualityProfileId, metadataProfileId] =
await Promise.all([
getRootFolderPath(config),
getQualityProfileId(config),
getMetadataProfileId(config),
]);
const existing = await getAllArtists(config);
const found = existing.find((a) => a.foreignArtistId === artist.foreignArtistId);
if (found?.id) return { success: true, artistId: found.id, created: false };
const [rootFolders, qualityProfiles, metadataProfiles] = await Promise.all([
request<{ path: string }[]>(config, "/rootfolder"),
request<{ id: number }[]>(config, "/qualityprofile"),
request<{ id: number; name: string }[]>(config, "/metadataprofile"),
]);
if (!rootFolders?.length) return { success: false, message: "No Lidarr root folders configured" };
if (!qualityProfiles?.length) return { success: false, message: "No Lidarr quality profiles found" };
if (!metadataProfiles?.length) return { success: false, message: "No Lidarr metadata profiles found" };
// Prefer a profile that includes all release types (singles, live, etc.)
const inclusiveProfile = metadataProfiles.find((p) => /^(none|all|standard)$/i.test(p.name));
const metadataProfileId = (inclusiveProfile ?? metadataProfiles[0]).id;
const created = await request<{ id?: number }>(config, "/artist", {
method: "POST",
@@ -124,20 +120,33 @@ async function ensureArtist(
genres: artist.genres || [],
ratings: artist.ratings || {},
status: artist.status || "active",
qualityProfileId,
qualityProfileId: qualityProfiles[0].id,
metadataProfileId,
rootFolderPath,
rootFolderPath: rootFolders[0].path,
monitored: false,
addOptions: { searchForMissingAlbums: true },
addOptions: { searchForMissingAlbums: false },
}),
});
if (!created?.id) {
return { success: false, message: "Artist added but no id returned" };
}
if (!created?.id) return { success: false, message: "Artist added but no id returned" };
return { success: true, artistId: created.id, created: true };
}
async function deleteArtist(config: LidarrConfig, artistId: number): Promise<void> {
try {
await request(config, `/artist/${artistId}?deleteFiles=false`, { method: "DELETE" });
} catch {
// best effort
}
}
async function triggerAlbumSearch(config: LidarrConfig, albumId: number): Promise<void> {
await request(config, "/command", {
method: "POST",
body: JSON.stringify({ name: "AlbumSearch", albumIds: [albumId] }),
});
}
function getAlbumStatus(album: LidarrAlbum): "available" | "wanted" | "queued" {
const pct = album.statistics?.percentOfTracks ?? 0;
if (pct === 100) return "available";
@@ -145,19 +154,11 @@ function getAlbumStatus(album: LidarrAlbum): "available" | "wanted" | "queued" {
return "queued";
}
async function getAlbumsByArtist(
config: LidarrConfig,
artistId: number
): Promise<LidarrAlbum[]> {
return request<LidarrAlbum[]>(config, `/album?artistId=${artistId}`);
}
async function waitForAlbum(
config: LidarrConfig,
artistId: number,
normalizedAlbum: string,
timeoutMs = 60_000,
timeoutMs = 90_000,
pollIntervalMs = 2_500
): Promise<LidarrAlbum | null> {
const started = Date.now();
@@ -166,28 +167,69 @@ async function waitForAlbum(
if (albums.length > 0) {
return albums.find((a) => normalize(a.title) === normalizedAlbum) ?? null;
}
await delay(pollIntervalMs);
await new Promise((r) => setTimeout(r, pollIntervalMs));
}
return null;
}
async function triggerAlbumSearch(
async function findAndAddAlbum(
config: LidarrConfig,
albumId: number
): Promise<void> {
await request(config, "/command", {
albumTitle: string,
artistName: string,
normalizedAlbum: string,
normalizedArtist: string,
addedArtistIds: number[]
): Promise<LidarrAlbum | null> {
const results = await request<LidarrSearchResult[]>(config, `/search?term=${albumTitle} ${artistName}`);
const matched = results?.find(
(r) =>
r.album &&
normalize(r.album.title) === normalizedAlbum &&
r.album.artist &&
normalize(r.album.artist.artistName) === normalizedArtist
);
if (!matched?.album) return null;
if (matched.album.id) return matched.album as LidarrAlbum;
// Artist not yet in Lidarr — ensure it exists
const artistLookupResults = await lookupArtist(config, artistName);
const artistCandidate =
artistLookupResults.find((a) => normalize(a.artistName) === normalizedArtist) ?? artistLookupResults[0];
if (!artistCandidate) return null;
const ensured = await ensureArtist(config, artistCandidate);
if (!ensured.success) return null;
if (!addedArtistIds.includes(ensured.artistId)) addedArtistIds.push(ensured.artistId);
const artists = await getAllArtists(config);
const fullArtist = artists.find((a) => a.id === ensured.artistId);
if (!fullArtist) return null;
const added = await request<LidarrAlbum>(config, "/album", {
method: "POST",
body: JSON.stringify({ name: "AlbumSearch", albumIds: [albumId] }),
body: JSON.stringify({
foreignAlbumId: matched.album.foreignAlbumId,
monitored: true,
anyReleaseOk: true,
images: [],
artist: fullArtist,
addOptions: { searchForNewAlbum: true, monitored: true },
}),
});
return added?.id ? added : null;
}
async function deleteArtist(
async function triggerOrAddAlbum(
config: LidarrConfig,
artistId: number
): Promise<void> {
await request(config, `/artist/${artistId}?deleteFiles=false`, {
method: "DELETE",
});
album: LidarrAlbum
): Promise<LidarrAutoSearchResult> {
const status = getAlbumStatus(album);
if (status === "available") return { success: true, status: "available" };
await triggerAlbumSearch(config, album.id);
return { success: true, status };
}
export async function lidarrAutoSearch(
@@ -196,72 +238,58 @@ export async function lidarrAutoSearch(
albumTitle: string,
artistName: string
): Promise<LidarrAutoSearchResult> {
if (!albumTitle || !artistName) {
return { success: false, message: "Missing album or artist name" };
}
if (!albumTitle || !artistName) return { success: false, message: "Missing album or artist name" };
const config: LidarrConfig = { url, apiKey };
const normalizedAlbum = normalize(albumTitle);
const normalizedAlbum = normalize(cleanAlbumTitle(albumTitle));
const normalizedArtist = normalize(artistName);
try {
const allArtists = await request<LidarrArtist[]>(config, "/artist");
const localArtists = await getAllArtists(config);
const matchedArtist = localArtists.find((a) => normalize(a.artistName) === normalizedArtist);
const matchedLocal = allArtists.find(
(a) => normalize(a.artistName) === normalizedArtist
);
if (matchedLocal) {
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, status: albumStatus };
if (matchedArtist) {
const albums = await getAlbumsByArtist(config, matchedArtist.id);
if (albums.length > 0) {
const album = albums.find((a) => normalize(a.title) === normalizedAlbum);
if (album) return triggerOrAddAlbum(config, album);
// Album not in local list — try search+add (e.g. excluded by metadata profile)
const found = await findAndAddAlbum(config, albumTitle, artistName, normalizedAlbum, normalizedArtist, []);
if (found) return triggerOrAddAlbum(config, found);
return { success: false, message: "Album not found" };
}
// Artist exists but 0 albums — try search+add directly
const found = await findAndAddAlbum(config, albumTitle, artistName, normalizedAlbum, normalizedArtist, []);
if (found) return triggerOrAddAlbum(config, found);
return { success: false, message: "Album not found" };
}
const lookupResults = await lookupArtist(config, artistName);
if (!lookupResults?.length) {
return { success: false, message: "Artist not found in lookup" };
if (!lookupResults?.length) return { success: false, message: "Artist not found in lookup" };
const exactMatch = lookupResults.filter((a) => normalize(a.artistName) === normalizedArtist);
const candidate = exactMatch[0] ?? lookupResults[0];
const preExistingIds = new Set(localArtists.map((a) => a.id));
const addedArtistIds: number[] = [];
const ensured = await ensureArtist(config, candidate);
if (!ensured.success) return { success: false, message: ensured.message };
if (!preExistingIds.has(ensured.artistId)) addedArtistIds.push(ensured.artistId);
let album = await waitForAlbum(config, ensured.artistId, normalizedAlbum);
if (!album) {
album = await findAndAddAlbum(config, albumTitle, artistName, normalizedAlbum, normalizedArtist, addedArtistIds);
}
const candidates = lookupResults
.filter((a) => normalize(a.artistName) === normalizedArtist)
.slice(0, 3);
const fallbackCandidates = candidates.length
? candidates
: lookupResults.slice(0, 1);
for (const candidate of fallbackCandidates) {
const ensured = await ensureArtist(config, candidate, allArtists);
if (!ensured.success) continue;
const album = await waitForAlbum(
config,
ensured.artistId,
normalizedAlbum
);
if (album) {
const albumStatus = getAlbumStatus(album);
if (albumStatus === "available") return { success: true, status: "available" };
await triggerAlbumSearch(config, album.id);
return { success: true, status: albumStatus };
}
if (ensured.created) {
await deleteArtist(config, ensured.artistId);
}
}
if (album) return triggerOrAddAlbum(config, album);
await Promise.all(addedArtistIds.map((id) => deleteArtist(config, id)));
return { success: false, message: "Album not found" };
} catch (e: unknown) {
return {
success: false,
message: e instanceof Error ? e.message : "Album download failed",
};
return { success: false, message: e instanceof Error ? e.message : "Album download failed" };
}
}
+18 -1
View File
@@ -1,3 +1,20 @@
export function normalize(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]/gi, "").trim();
return value
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
}
const ALBUM_SUFFIX_PATTERNS = [
/\s*[([]\s*(single|ep|deluxe|deluxe\s+edition|expanded|remaster(ed)?|reissue|anniversary\s+edition|bonus\s+tracks?|explicit)\s*[\])]/gi,
/\s*-\s*(single|ep|deluxe|deluxe\s+edition|remaster(ed)?|reissue|explicit)\s*$/gi,
];
export function cleanAlbumTitle(title: string): string {
let cleaned = title;
for (const pattern of ALBUM_SUFFIX_PATTERNS) {
cleaned = cleaned.replace(pattern, "");
}
return cleaned.trim();
}
+60 -12
View File
@@ -29,30 +29,42 @@ async function openYakuzaResult(page: Page) {
await expect(page.locator(".MuiTypography-h5")).toHaveText("Yakuza");
}
async function openModjoResult(page: Page) {
await page.getByRole("button", { name: "Show records (3)" }).click();
await page.locator("div:nth-child(1) > .MuiCardActions-root > button").first().click();
await expect(page.locator(".MuiTypography-h5")).toHaveText("Chillin'");
}
const DEFAULT_ARTIST = { id: 1, artistName: "Szymon", foreignArtistId: "abc-123" };
const DEFAULT_ALBUM = { id: 10, title: "Blue Coloured Mountain", artistId: 1, monitored: false, statistics: { percentOfTracks: 0 } };
const DEFAULT_SEARCH = [{ foreignId: "abc-album-123", album: { id: 10, title: "Blue Coloured Mountain", foreignAlbumId: "abc-album-123", artistId: 1, monitored: false, artist: { foreignArtistId: "abc-123", artistName: "Szymon" } } }];
function mockLidarrRoutes(
page: Page,
overrides: {
artists?: unknown[];
artistLookup?: unknown[];
albums?: unknown[];
albumByForeignId?: unknown[];
search?: 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") {
const body: unknown[] = [];
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
if (url.includes("/api/v1/search")) {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(overrides.search ?? DEFAULT_SEARCH) });
} else if (url.includes("/api/v1/artist") && !url.includes("lookup") && method === "GET") {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(overrides.artists ?? [DEFAULT_ARTIST]) });
} 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) });
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(overrides.artistLookup ?? [DEFAULT_ARTIST]) });
} 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("foreignAlbumId=")) {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(overrides.albumByForeignId ?? [DEFAULT_ALBUM]) });
} 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) });
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(overrides.albums ?? [DEFAULT_ALBUM]) });
} else if (url.includes("/api/v1/qualityprofile")) {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([{ id: 1, name: "Any" }]) });
} else if (url.includes("/api/v1/metadataprofile")) {
@@ -101,14 +113,50 @@ test("Lidarr: With API key — calls API and shows success", async ({ page }) =>
});
});
test("Lidarr: With API key — shows error when artist not found", async ({ page }) => {
await mockLidarrRoutes(page, { artistLookup: [] });
test("Lidarr: With API key — matches album despite (Remastered) suffix in title", async ({ page }) => {
// Modjo fixture has album "Modjo (Remastered)" — Lidarr stores it as "Modjo"
const modjoArtist = { id: 1, artistName: "Modjo", foreignArtistId: "modjo-123" };
const modjoAlbum = { id: 20, title: "Modjo", artistId: 1, monitored: false, statistics: { percentOfTracks: 0 } };
await mockLidarrRoutes(page, {
artists: [modjoArtist],
artistLookup: [modjoArtist],
albums: [modjoAlbum],
albumByForeignId: [modjoAlbum],
search: [{ foreignId: "modjo-album-123", album: { id: 20, title: "Modjo", foreignAlbumId: "modjo-album-123", artistId: 1, monitored: false, artist: { foreignArtistId: "modjo-123", artistName: "Modjo" } } }],
});
await gotoWithConfig(page, { lidarr_url: LIDARR_URL, lidarr_api_key: LIDARR_API_KEY });
await openModjoResult(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: "Search triggered in Lidarr!" })).toBeVisible({
timeout: 15000,
});
});
test("Lidarr: With API key — finds album via /search fallback when not in local artist albums", async ({ page }) => {
// Artist exists locally but album is missing (e.g. excluded by metadata profile) — found via /search
const searchResult = [{ foreignId: "abc-album-123", album: { id: 10, title: "Blue Coloured Mountain", foreignAlbumId: "abc-album-123", artistId: 1, monitored: false, artist: { foreignArtistId: "abc-123", artistName: "Szymon" } } }];
await mockLidarrRoutes(page, { albums: [], search: searchResult });
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: /Searching/i })).toBeVisible();
await expect(page.getByRole("button", { name: "Search triggered in Lidarr!" })).toBeVisible({
timeout: 15000,
});
});
test("Lidarr: With API key — shows error when album not found in search", async ({ page }) => {
// Artist found but album not in local list and not found via /search
await mockLidarrRoutes(page, { albums: [], search: [] });
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: "Artist not found" }),
page.getByRole("button", { name: "Album not found in Lidarr" }),
).toBeVisible({ timeout: 10000 });
});