feat: data lifecycle rewrite, download clients, wanted list, and central catalog index
Build & publish Arch package / arch-package (push) Successful in 2m12s
Search index maintenance / maintain-index (push) Successful in 2h22m28s

Ships the fresh-start schema cleanup: rebuilt explore catalog index
pipeline (dump import, artifact fetch/build, incremental listen-count
refresh), a new download subsystem (Lidarr/Prowlarr/qBittorrent/SABnzbd/
slskd/yt-dlp providers, staging, reconciliation, wanted list), and the
supporting schema/query/store changes across backend and frontend.

Also includes two smaller follow-ups: bump the central index's
rebuild-after cadence from 90 to 180 days, and remove the Explore
"library only" online/offline toggle entirely (frontend-only, no
backend counterpart) rather than carry unused UI/state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
This commit is contained in:
2026-08-06 17:12:01 -04:00
co-authored by Claude Sonnet 5
parent d0d86f85d5
commit e190fd75b9
165 changed files with 31088 additions and 5192 deletions
@@ -9,7 +9,6 @@ import {
SimilarArtists,
GetArtistImageURL,
GetArtistImageCachedPath,
GetLibrarySimilarArtists,
GetThumbnail,
GetThumbnails,
GetTrackThumbnail,
@@ -24,8 +23,9 @@ type LBTopRecording = explore.LBTopRecording;
type LBTopReleaseGroup = explore.LBTopReleaseGroup;
type LBSimilarArtist = explore.LBSimilarArtist;
import { exploreCache } from '../../store/explore-cache';
import { exploreSettings } from '../../store/explore-settings';
import { libraryStore } from '../../store/library-store';
import { downloadStore } from '../../store/download-store';
import '@awesome.me/webawesome/dist/components/button/button.js';
import { trackLink, exploreLinkStyles } from '../../utils/explore-link';
import { GetAlbumsByArtist } from '@go/library/Library';
import { EventsOn } from '@runtime/runtime';
@@ -194,6 +194,10 @@ export class ExploreArtistDetails extends LitElement {
object-fit: cover;
}
.artist-follow {
margin-top: 10px;
}
.artist-info {
display: flex;
flex-direction: column;
@@ -830,7 +834,6 @@ export class ExploreArtistDetails extends LitElement {
/* ── Lifecycle ── */
private unsubSettings?: () => void;
private unsubDiscogReady?: () => void;
private unsubSimilarReady?: () => void;
/** MBIDs whose ArtistSimilarReady event we've already handled, so a
@@ -843,19 +846,20 @@ export class ExploreArtistDetails extends LitElement {
* background discography fetch never signals readiness. */
private discogFallbackTimer?: number;
/** Unsubscribe handle for the wanted list. */
private unsubWanted: (() => void) | null = null;
override connectedCallback() {
super.connectedCallback();
if (this.artistMBID || this.localArtistId) {
void this.loadAllData();
}
// Re-render when library-only mode toggles.
this.unsubSettings = exploreSettings.subscribe(() => {
this.requestUpdate();
if (this.artistMBID || this.localArtistId) {
void this.loadAllData();
}
});
// Keep the follow button in step with the wanted list, which a
// background reconcile pass can change without this page doing
// anything.
this.unsubWanted = downloadStore.subscribe(() => this.requestUpdate());
void downloadStore.init().then(() => this.requestUpdate());
// A background discography fetch (top tracks / top releases for an
// artist that wasn't indexed yet) finished — re-fetch those two
@@ -893,7 +897,8 @@ export class ExploreArtistDetails extends LitElement {
override disconnectedCallback() {
super.disconnectedCallback();
this.unsubSettings?.();
this.unsubWanted?.();
this.unsubWanted = null;
this.unsubDiscogReady?.();
this.unsubSimilarReady?.();
if (this.discogFallbackTimer) clearTimeout(this.discogFallbackTimer);
@@ -1027,90 +1032,6 @@ export class ExploreArtistDetails extends LitElement {
// Phase 0: hydrate from caches (instant, no Go calls).
this.hydrateFromCache(mbid);
if (exploreSettings.libraryOnly) {
// Library-only mode: no external API calls.
// Discography comes from library store (already hydrated).
// Similar artists from pre-computed DB table.
this.loadingArtist = false;
this.loadingTracks = false;
this.loadingTopReleases = false;
this.loadingReleases = false;
this.loadingSimilar = false;
// If the library store hasn't eagerly fetched yet,
// await it and re-hydrate. Covers the race between
// navigation and the deferred eagerFetch on DOMContentLoaded.
if (!libraryStore.cachedArtists || !libraryStore.cachedAlbums) {
try {
const pending: Promise<unknown>[] = [];
if (!libraryStore.cachedArtists) {
pending.push(libraryStore.getArtists());
}
if (!libraryStore.cachedAlbums) {
pending.push(libraryStore.getAlbums());
}
await Promise.all(pending);
this.hydrateFromCache(mbid);
} catch {
// Ignore — we'll fall through to the Wails-cached path.
}
}
// Artist image: if hydrateFromCache didn't find one (e.g.
// the library store's cached artist row has an empty
// ImageMedium because the on-disk file post-dates the
// store's last fetch), fall back to a disk-only Wails
// call. This just asks the backend whether
// /artist-images/.../primary_md.jpg exists — no network,
// no base64 transfer.
if (!this.artistImageURL && mbid) {
void GetArtistImageCachedPath(mbid)
.then((url) => {
if (url) {
this.artistImageURL = url;
} else {
// Final fallback: album art by artist name.
this.fallbackArtistImageFromAlbumArt();
}
})
.catch(() => {
this.fallbackArtistImageFromAlbumArt();
});
}
// Fetch library-only similar artists (single Go call, no external API).
try {
const similar = await GetLibrarySimilarArtists(mbid);
// Dedupe by MBID as a safety net — the backend query
// should already return unique rows but multiple library
// artists can share an MBID (ensemble credits), so this
// guards against any future query regression.
const seen = new Set<string>();
const deduped: LBSimilarArtist[] = [];
for (const s of similar ?? []) {
const key = s.artistMbid || s.name;
if (!key || seen.has(key)) continue;
seen.add(key);
deduped.push(s);
}
this.similarArtists = deduped;
// Resolve similar artist images from library cache —
// no Go calls, no network. These artists are all in
// the library (that's the filter GetLibrarySimilarArtists
// applies), so the library store has their image paths.
this.seedSimilarArtistImagesFromLibrary();
} catch {
this.similarArtists = [];
}
console.log(
`[explore-artist] loaded (library-only): "${this.artistName}"`,
);
return;
}
// Fresh load for this artist: allow the top sections one
// background-fetch re-fetch, and arm a fallback so they can't spin
// forever if ArtistDiscographyReady never arrives.
@@ -1604,7 +1525,7 @@ export class ExploreArtistDetails extends LitElement {
/**
* Populate similarImageURLs for the current similarArtists list
* using only library-store data and disk-cached artist images.
* Makes ZERO network calls — safe for library-only mode.
* Makes ZERO network calls.
*
* Resolution priority per artist:
* 1. libraryStore.cachedArtists[mbid].ImageMedium (in-memory)
@@ -1694,10 +1615,10 @@ export class ExploreArtistDetails extends LitElement {
}
private async fetchSimilarArtistImages() {
// Phase 1: instant seed from library store + disk cache.
// This mirrors the library-only path so any card whose image
// is already on disk appears immediately, without waiting
// for a network-enabled GetArtistImageURL round-trip.
// Phase 1: instant seed from library store + disk cache, so any
// card whose image is already on disk appears immediately,
// without waiting for a network-enabled GetArtistImageURL
// round-trip.
await this.seedSimilarArtistImagesFromLibrary();
// Phase 2: network fetch for any similars still without
@@ -1961,9 +1882,10 @@ export class ExploreArtistDetails extends LitElement {
? html`<div class="artist-native-name">${this.artist.name}</div>`
: nothing}
${this.renderArtistMeta()}
${this.artist?.popularity && this.artist.popularity > 0 && !exploreSettings.libraryOnly
${this.artist?.popularity && this.artist.popularity > 0
? html`<span class="artist-meta">${formatListenCount(this.artist.popularity)} plays on ListenBrainz</span>`
: nothing}
${this.renderFollowAction()}
</div>
</div>
<div class="content">
@@ -1973,6 +1895,61 @@ export class ExploreArtistDetails extends LitElement {
`;
}
/**
* Subscribes to an artist: their new releases go on the wanted list
* as they come out.
*
* The default is new releases only. Following an artist should not
* silently queue forty albums — someone who wants the back
* catalogue can widen it from the wanted list, and will not be
* surprised by having done so.
*/
private renderFollowAction() {
if (!this.artistMBID) return nothing;
const want = downloadStore.wantFor(this.artistMBID);
return html`
<div class="artist-follow">
<wa-button
size="small"
appearance=${want ? 'filled' : 'outlined'}
@click=${() => void this.toggleFollow(want?.id)}
>
<wa-icon
slot="start"
name=${want ? 'bookmark-check' : 'bookmark'}
></wa-icon>
${want ? 'Following' : 'Follow for new releases'}
</wa-button>
</div>
`;
}
private async toggleFollow(wantId: number | undefined): Promise<void> {
if (!this.artistMBID) return;
try {
if (wantId) {
await downloadStore.removeWant(wantId);
} else {
await downloadStore.addWant({
mbid: this.artistMBID,
entity: 'artist',
libraryId: libraryStore.getSelectedLibraryId() ?? 0,
artist: this.displayName,
title: this.displayName,
scope: 'future',
secondary: false,
} as never);
}
} catch (err) {
console.error('Could not update the wanted list:', err);
}
this.requestUpdate();
}
private renderArtistMeta() {
if (this.loadingArtist) {
return html`<span class="artist-meta section-loading"
@@ -2034,9 +2011,6 @@ export class ExploreArtistDetails extends LitElement {
}
private renderTopSection() {
// Library-only mode: no top tracks/releases from LB.
if (exploreSettings.libraryOnly) return nothing;
const hasTracks = !this.loadingTracks && this.topTracks.length > 0;
const hasReleases = !this.loadingTopReleases && this.topReleaseGroups.length > 0;
const tracksLoading = this.loadingTracks;
@@ -2362,10 +2336,8 @@ export class ExploreArtistDetails extends LitElement {
return nothing;
}
// Library-only mode: show all library-matching similar artists
// (up to the 20 stored per seed). Online mode: cap at 10 to
// avoid a very long list.
const maxSimilar = exploreSettings.libraryOnly ? 20 : 10;
// Cap the similar-artists list at 10 to avoid a very long list.
const maxSimilar = 10;
const artists = this.similarArtists.slice(0, maxSimilar);
const showToggle = artists.length > this.discoRowSize;
const collapsed = !this.similarExpanded && showToggle;