Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .tests/frontend/library-query-invalidation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,6 @@ test("library requests forward caller cancellation", async (t) => {
page: 1,
pageSize: 100,
source: "all",
availableOnly: "false",
}),
);
await assertQueryCancellation(() => getLibraryFavorites(), queryKeys.libraryFavorites);
Expand Down
189 changes: 189 additions & 0 deletions .tests/library/canonical-available-only-route.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
cleanupIsolatedState,
resetDatabase,
setupIsolatedBackend,
} from "../helpers/backendTestHarness.js";

const [isolatedState, { db }, { dbOps }, libraryStore] = await setupIsolatedBackend(
"canonical-available-only-route",
"backend/config/db-sqlite.js",
"backend/db/helpers/index.js",
"backend/services/libraryMediaStore.js",
);

const { registerCanonical } = await import(
"../../backend/routes/library/handlers/canonical.js"
);
const {
linkLibraryAlbumTrack,
upsertLibraryAlbum,
upsertLibraryArtist,
upsertLibraryMediaFile,
upsertLibraryTrack,
} = libraryStore;

function getRoute(path) {
const routes = new Map();
registerCanonical({
get(routePath, ...handlers) {
routes.set(`GET ${routePath}`, handlers.at(-1));
},
post(routePath, ...handlers) {
routes.set(`POST ${routePath}`, handlers.at(-1));
},
});
return routes.get(path);
}

function callCanonical(query) {
let body;
getRoute("GET /canonical")(
{ query },
{
status() {
return this;
},
json(value) {
body = value;
return this;
},
},
);
return body;
}

function setAvailableOnly(value) {
dbOps.updateSettings({ integrations: { lidarr: { availableOnly: value } } });
}

test.before(() => {
resetDatabase(db);

// Artist who owns some but not all of their discography (the issue's "Muse" case).
const partialArtist = upsertLibraryArtist({
identityKey: "partial-artist",
name: "Owns Some",
});
const ownedAlbum = upsertLibraryAlbum({
identityKey: "owned-album",
artistId: partialArtist.id,
title: "Owned Album",
});
const ownedTrack = upsertLibraryTrack({
identityKey: "owned-track",
title: "Owned Track",
artistName: partialArtist.name,
});
linkLibraryAlbumTrack({ albumId: ownedAlbum.id, trackId: ownedTrack.id, trackNumber: 1 });
upsertLibraryMediaFile({
trackId: ownedTrack.id,
albumId: ownedAlbum.id,
source: "lidarr",
path: "/library/Owns Some/Owned Album/01 Owned Track.flac",
available: true,
});

const catalogAlbum = upsertLibraryAlbum({
identityKey: "catalog-album",
artistId: partialArtist.id,
title: "Catalog Album",
});
const catalogTrack = upsertLibraryTrack({
identityKey: "catalog-track",
title: "Catalog Track",
artistName: partialArtist.name,
});
linkLibraryAlbumTrack({ albumId: catalogAlbum.id, trackId: catalogTrack.id, trackNumber: 1 });
upsertLibraryMediaFile({
trackId: catalogTrack.id,
albumId: catalogAlbum.id,
source: "lidarr",
path: "/library/Owns Some/Catalog Album/01 Catalog Track.flac",
available: false,
});

// Artist who owns nothing (e.g. deleted in Lidarr but lingering) — issue #750.
const emptyArtist = upsertLibraryArtist({
identityKey: "empty-artist",
name: "Owns Nothing",
});
const emptyAlbum = upsertLibraryAlbum({
identityKey: "empty-album",
artistId: emptyArtist.id,
title: "Unowned Album",
});
const emptyTrack = upsertLibraryTrack({
identityKey: "empty-track",
title: "Unowned Track",
artistName: emptyArtist.name,
});
linkLibraryAlbumTrack({ albumId: emptyAlbum.id, trackId: emptyTrack.id, trackNumber: 1 });
upsertLibraryMediaFile({
trackId: emptyTrack.id,
albumId: emptyAlbum.id,
source: "lidarr",
path: "/library/Owns Nothing/Unowned Album/01 Unowned Track.flac",
available: false,
});
});

test.after(async () => {
await cleanupIsolatedState(isolatedState);
});

test("albums list hides unavailable albums when the setting is on (default)", () => {
setAvailableOnly(true);
const page = callCanonical({ kind: "albums", page: "1", pageSize: "50" });
assert.deepEqual(page.items.map((album) => album.title), ["Owned Album"]);
assert.equal(page.total, 1);
});

test("albums list shows the full catalog when the setting is off", () => {
setAvailableOnly(false);
const page = callCanonical({ kind: "albums", page: "1", pageSize: "50" });
assert.deepEqual(
page.items.map((album) => album.title).sort(),
["Catalog Album", "Owned Album", "Unowned Album"],
);
assert.equal(page.total, 3);
});

test("artists with zero available albums disappear when the setting is on (#750)", () => {
setAvailableOnly(true);
const page = callCanonical({ kind: "artists", page: "1", pageSize: "50" });
assert.deepEqual(page.items.map((entry) => entry.name), ["Owns Some"]);
assert.equal(page.total, 1);
});

test("artists with zero available albums remain when the setting is off", () => {
setAvailableOnly(false);
const page = callCanonical({ kind: "artists", page: "1", pageSize: "50" });
assert.deepEqual(
page.items.map((entry) => entry.name).sort(),
["Owns Nothing", "Owns Some"],
);
assert.equal(page.total, 2);
});

test("an explicit availableOnly query param overrides the setting", () => {
setAvailableOnly(false);
const filtered = callCanonical({
kind: "albums",
page: "1",
pageSize: "50",
availableOnly: "true",
});
assert.deepEqual(filtered.items.map((album) => album.title), ["Owned Album"]);

setAvailableOnly(true);
const unfiltered = callCanonical({
kind: "albums",
page: "1",
pageSize: "50",
availableOnly: "false",
});
assert.equal(unfiltered.total, 3);
});
33 changes: 33 additions & 0 deletions .tests/library/canonical-available-only-setting.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import test from "node:test";

import { resolveCanonicalAvailableOnly } from "../../backend/routes/library/handlers/canonical.js";

test("explicit availableOnly query param overrides the configured default", () => {
const settingsOn = { integrations: { lidarr: { availableOnly: true } } };
const settingsOff = { integrations: { lidarr: { availableOnly: false } } };

assert.equal(resolveCanonicalAvailableOnly("true", settingsOff), true);
assert.equal(resolveCanonicalAvailableOnly("false", settingsOn), false);
});

test("absent availableOnly query param follows the lidarr setting", () => {
assert.equal(
resolveCanonicalAvailableOnly(undefined, { integrations: { lidarr: { availableOnly: true } } }),
true,
);
assert.equal(
resolveCanonicalAvailableOnly(undefined, { integrations: { lidarr: { availableOnly: false } } }),
false,
);
});

test("absent query param and unset setting defaults to available-only (on)", () => {
assert.equal(resolveCanonicalAvailableOnly(undefined, undefined), true);
assert.equal(resolveCanonicalAvailableOnly(undefined, {}), true);
assert.equal(resolveCanonicalAvailableOnly(undefined, { integrations: {} }), true);
assert.equal(
resolveCanonicalAvailableOnly(undefined, { integrations: { lidarr: {} } }),
true,
);
});
2 changes: 1 addition & 1 deletion .tests/library/canonical-favorites-routes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ test("native favorites include the canonical favorite subset", () => {
test("canonical library pages return bounded collection responses", () => {
const response = responseFor();
getRoute("GET /canonical")(
{ user, query: { kind: "tracks", page: "1", pageSize: "1" } },
{ user, query: { kind: "tracks", page: "1", pageSize: "1", availableOnly: "false" } },
response,
);

Expand Down
1 change: 1 addition & 0 deletions backend/config/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export const defaultData = {
tagId: null,
defaultMonitorOption: "none",
searchOnAdd: false,
availableOnly: true,
},
metadata: {
provider: "brainzmash",
Expand Down
16 changes: 15 additions & 1 deletion backend/routes/library/handlers/canonical.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { noCache } from "../../../middleware/cache.js";
import { requireAuth } from "../../../middleware/requirePermission.js";
import { dbOps } from "../../../db/helpers/index.js";
import { buildImageProxyUrl } from "../../../services/imageProxyService.js";
import {
getCanonicalFavoriteTargetKeys,
Expand Down Expand Up @@ -29,6 +30,16 @@ export function stripFilesystemPaths(value) {
);
}

// Resolves the effective availableOnly flag for a canonical library read.
// An explicit query param always wins so detail/track views can force a value;
// otherwise the Lidarr "show available music only" setting decides, defaulting
// to on so the Library shows owned music rather than the full discography.
export function resolveCanonicalAvailableOnly(queryValue, settings) {
if (queryValue === "true") return true;
if (queryValue === "false") return false;
return settings?.integrations?.lidarr?.availableOnly !== false;
}

function getAlbumCoverUrl(album) {
const image = (Array.isArray(album?.metadata?.images) ? album.metadata.images : []).find(
(entry) => /^https?:\/\//i.test(entry?.remoteUrl || entry?.imageUrl || entry?.url || ""),
Expand Down Expand Up @@ -125,7 +136,10 @@ export function registerCanonical(router) {
}
return res.json(toPublicLibraryPage(getCanonicalLibraryPage({
source: req.query.source,
availableOnly: req.query.availableOnly === "true",
availableOnly: resolveCanonicalAvailableOnly(
req.query.availableOnly,
dbOps.getSettings(),
),
kind,
page: req.query.page,
pageSize: requestedPageSize,
Expand Down
19 changes: 19 additions & 0 deletions docs/src/content/docs/integrations/lidarr.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,27 @@ Enter these values in first-run setup or in **Settings > Lidarr**:
- The URL Aurral can reach (often `http://lidarr:8686` in Docker). Aurral supports both HTTP and HTTPS. Use HTTPS when the connection leaves your trusted network.
- Your Lidarr API key
- Default quality profile, metadata profile, tag, monitoring option, and search-on-add behavior
- **Show available music only**, which controls whether the Library lists your whole Lidarr catalog or only downloaded music
- Optional **Davo's Community Lidarr Guide** for quality profiles and file names

## Library display

Lidarr reports an artist's full discography, including albums you have not
downloaded. **Show available music only** (in **Settings > Lidarr**) keeps the
Library focused on music you actually have:

- When **on** (the default), the Library — the Albums list, Album artists list,
and the home **Recently added** shelf — shows only albums with downloaded
files, and the library counts reflect that. Artists with no downloaded music
drop off the list, so an artist you remove in Lidarr disappears from the
Library on the next scan.
- When **off**, the Library shows each artist's full Lidarr discography,
including albums with no files (labelled `0/10 available`).

This setting only changes what the Library **lists**. You can still search for
and request unmonitored or undownloaded albums from the artist's discography,
and the request and monitor workflows are unchanged.

## Metadata providers

Aurral keeps the MusicBrainz UUID for artist routes and stores Lidarr's provider ID separately. If Lidarr uses a provider such as Tubifarry Deezer or Discogs, Aurral verifies the submitted MusicBrainz artist, accepts names recorded as MusicBrainz aliases, uses Lidarr's lookup to identify the active provider, and retries a provider-ID format error with the canonical provider ID. Existing provider-ID artists are verified and backfilled when Aurral reads the library. If the identities do not match, search for the artist in Lidarr first or use MusicBrainz metadata.
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/pages/FlowPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -1192,6 +1192,8 @@ function FlowPage({ mode = "all" }) {
page: 1,
pageSize: 100,
query: track.artistName,
// Resolve for navigation even if nothing is available yet.
availableOnly: false,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
canonicalId = canonicalLibraryId(
findCanonicalArtistByName(page?.items, track.artistName),
Expand Down Expand Up @@ -1228,6 +1230,8 @@ function FlowPage({ mode = "all" }) {
page: 1,
pageSize: 100,
query: track.albumName,
// Resolve for navigation even if nothing is available yet.
availableOnly: false,
});
canonicalId = canonicalLibraryId(
findCanonicalAlbumByName(page?.items, track.albumName, track.artistName),
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/pages/LibraryPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,9 @@ function LibraryPage() {
albumId: routeAlbumId,
page: 1,
pageSize,
// An album detail view shows the full tracklist regardless of the
// library-wide availability setting; owned/missing is shown per row.
availableOnly: false,
}, { signal })
: await Promise.all([
fetchCanonicalLibraryPage({
Expand All @@ -621,6 +624,8 @@ function LibraryPage() {
page: 1,
pageSize,
sort: "newest",
// "Recently added" defers to the Lidarr "available only"
// setting (omitted param) like the Albums/Artists tabs.
}, { signal }),
fetchCanonicalLibraryPage({
kind: "tracks",
Expand All @@ -638,7 +643,9 @@ function LibraryPage() {
genre: selectedGenre,
sort: sortMode,
direction: sortDirection,
availableOnly: tab === "tracks",
// Albums/artists defer to the Lidarr "available only" setting
// (omitted param); tracks always filter to playable files.
availableOnly: tab === "tracks" ? true : undefined,
}, { signal });
const pageResults = section === "favorites"
? [nextData?.library || EMPTY_LIBRARY]
Expand Down Expand Up @@ -747,6 +754,9 @@ function LibraryPage() {
albumId: album.id,
page: 1,
pageSize,
// Full tracklist regardless of the library-wide availability setting;
// availability is surfaced per-track via badges after merging metadata.
availableOnly: false,
}, { signal });
const ownedTracks = Array.isArray(page?.items) ? page.items : [];
const pageArtist = page?.artists?.[0] || null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,18 @@ export function LidarrSettingsSection({
aria-label="Search for missing albums when artists are added"
/>
</SettingsArrFormGroup>

<SettingsArrFormGroup
label="Show available music only"
help="Hide albums and artists with no downloaded files from the Library. You can still search for and request unmonitored albums. Turn off to browse each artist's full Lidarr discography."
>
<PillToggle
className="settings-toggle"
checked={settings.integrations?.lidarr?.availableOnly !== false}
onChange={(e) => updateLidarr({ availableOnly: e.target.checked })}
aria-label="Show only albums with downloaded files in the Library"
/>
</SettingsArrFormGroup>
</SettingsArrFieldSet>

<SettingsArrFieldSet legend="Community guide">
Expand Down
Loading