mirror of
https://github.com/cstaelen/shazarr.git
synced 2026-10-19 01:31:11 -05:00
feat(lidarr): add lidarr search fallback (#277)
* feat(lidarr): add search fallback * make lidarr more accurate * fix apk
This commit is contained in:
1 parent
19b8788263
commit
5bfd54ab2c
5 files changed
+264
-167
No files matched your search
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user