import { EventsOn } from '@runtime/runtime'; import { AddProvider, AddRequest, Cancel, Candidates, ClearFinished, ClearSatisfiedRequests, DeleteProvider, ImportExternalRequests, ListDownloads, ListProviders, ListRequests, PauseRequest, Pick, ProviderKinds, ReconcileRequests, RemoveRequest, StartDownload, TestProvider, UpdateProvider, } from '@go/download/service.js'; import type * as download from '@go/download/models.js'; import { Events } from '../events'; export type DownloadCandidate = download.Candidate; export type DownloadProvider = download.Config; export type DownloadDescriptor = download.Descriptor; export type DownloadView = download.DownloadView; export type ProviderField = download.Field; export type Request = download.Request; export type RequestSummary = download.Summary; /** * What a request's MBID names. Mirrors backend/download.Entity — the * request list makes no other type distinction, because an MBID plus * what it names is the whole of a request. */ export type RequestEntity = 'artist' | 'release-group' | 'release' | 'recording'; /** * Where a request sits. There is deliberately no "failed": an attempt can * fail, a request cannot — something unfindable today is still requested. */ export type RequestState = 'wanted' | 'satisfied' | 'paused'; /** * How much of an artist's output a subscription covers. 'future' is the * default so subscribing does not silently queue a back catalogue. */ export type RequestScope = 'future' | 'all'; /** Lifecycle states a download can be in. Mirrors backend/download.State. */ export type DownloadLifecycleState = | 'searching' | 'found' | 'queued' | 'grabbing' | 'verifying' | 'tagging' | 'importing' | 'complete' | 'cancelled' | 'failed'; type Subscriber = () => void; const TERMINAL_STATES: ReadonlySet = new Set([ 'complete', 'cancelled', 'failed', ]); export function isDownloadTerminal(view: DownloadView): boolean { return TERMINAL_STATES.has(view.state); } /** * Human-readable label for a download state. Kept here rather than in the * components so the downloads list and the picker never disagree about * what a state is called. */ export function stateLabel(state: string): string { switch (state) { case 'searching': return 'Searching'; case 'found': return 'Waiting for you to choose'; case 'queued': return 'Queued'; case 'grabbing': return 'Downloading'; case 'verifying': return 'Verifying'; case 'tagging': return 'Tagging'; case 'importing': return 'Importing'; case 'complete': return 'Complete'; case 'cancelled': return 'Cancelled'; case 'failed': return 'Failed'; default: return state; } } /** * Formats a 0..1 score as a percentage for display. */ export function scorePercent(score: number): string { return `${Math.round(score * 100)}%`; } /** * Describes why a candidate ranks where it does, in the user's terms. * * Match and quality are reported separately on purpose: a perfect match * at low bitrate and a great-sounding copy of the wrong album are * different problems, and only the user knows which they will accept. */ export function candidateSummary(candidate: DownloadCandidate): string { const audio = (candidate.files ?? []).filter((f) => f.isAudio); const formats = new Set(audio.map((f) => f.format).filter(Boolean)); const parts: string[] = []; const [onlyFormat] = [...formats]; if (formats.size === 1 && onlyFormat) { parts.push(onlyFormat.toUpperCase()); } else if (formats.size > 1) { parts.push('Mixed formats'); } if (audio.length > 0) { parts.push(`${audio.length} track${audio.length === 1 ? '' : 's'}`); } if (candidate.totalSize > 0) { parts.push(formatBytes(candidate.totalSize)); } if (candidate.origin) { parts.push(candidate.origin); } return parts.join(' · '); } export function formatBytes(bytes: number): string { if (!bytes || bytes <= 0) return ''; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let value = bytes; let unit = 0; while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; } return `${value < 10 && unit > 0 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`; } /** * Reactive singleton for the download subsystem. * * Per-transfer progress deliberately does not flow through here — that * lives in the jobs registry, which already coalesces high-frequency * updates into one event. This store handles the coarse changes: which * providers exist, which downloads exist, and what the user is being * asked to choose between. */ class DownloadStore { private providersValue: DownloadProvider[] = []; private descriptorsValue: DownloadDescriptor[] = []; private downloadsValue: DownloadView[] = []; private requestsValue: Request[] = []; private subscribers = new Set(); private notifyScheduled = false; private initialized = false; private providersLoaded = false; constructor() { EventsOn(Events.DownloadProvidersChanged, () => { void this.refreshProviders(); }); EventsOn(Events.DownloadsChanged, () => { void this.refreshDownloads(); }); // The request list changes on its own — a background reconcile // pass expands an artist, retires something the library gained, // or starts a download nobody asked for just now. So it is // event-driven rather than fetched once on mount. EventsOn(Events.RequestsChanged, () => { void this.refreshRequests(); }); } /** * Loads providers, downloads and requests once. Safe to call from * every component's connectedCallback — subsequent calls are no-ops. */ async init(): Promise { if (this.initialized) return; this.initialized = true; await Promise.all([ this.refreshDescriptors(), this.refreshProviders(), this.refreshDownloads(), this.refreshRequests(), ]); } get providers(): DownloadProvider[] { return this.providersValue; } /** Providers the user has switched on. */ get enabledProviders(): DownloadProvider[] { return this.providersValue.filter((p) => p.enabled); } /** Provider types available to add. */ get descriptors(): DownloadDescriptor[] { return this.descriptorsValue; } get downloads(): DownloadView[] { return this.downloadsValue; } get activeDownloads(): DownloadView[] { return this.downloadsValue.filter((d) => !isDownloadTerminal(d)); } /** * True when at least one provider is configured and enabled. The UI * uses this to decide whether to offer downloading at all, rather * than letting the user start a search that cannot succeed. */ get available(): boolean { return this.enabledProviders.length > 0; } subscribe(callback: Subscriber): () => void { this.subscribers.add(callback); return () => this.subscribers.delete(callback); } /** * Coalesces notifications into one microtask so a burst of refreshes * causes a single render pass. */ private notify(): void { if (this.notifyScheduled) return; this.notifyScheduled = true; queueMicrotask(() => { this.notifyScheduled = false; this.subscribers.forEach((callback) => callback()); }); } async refreshDescriptors(): Promise { try { this.descriptorsValue = (await ProviderKinds()) ?? []; this.notify(); } catch (err) { console.error('Failed to load download client types:', err); } } /** * Loads the providers, and only those, once. * * `init()` additionally fetches the descriptors, the downloads and * the request list, which is right for a page about downloading and * wrong for the sidebar: it only needs `available`, to decide * whether the Downloads destination exists at all (#25), and that * is one query. `DownloadProvidersChanged` keeps it current * afterwards, so configuring a client makes the tab appear without * a restart. */ async ensureProviders(): Promise { if (this.providersLoaded) return; this.providersLoaded = true; await this.refreshProviders(); } async refreshProviders(): Promise { try { this.providersValue = (await ListProviders()) ?? []; this.notify(); } catch (err) { console.error('Failed to load download clients:', err); } } async refreshDownloads(): Promise { try { this.downloadsValue = (await ListDownloads(50)) ?? []; this.notify(); } catch (err) { console.error('Failed to load downloads:', err); } } // ----------------------------------------------------------------- // Provider configuration // ----------------------------------------------------------------- async addProvider( kind: string, name: string, settings: Record, ): Promise { const id = await AddProvider(kind, name, settings); await this.refreshProviders(); return id; } async updateProvider( id: number, name: string, enabled: boolean, priority: number, settings: Record, ): Promise { await UpdateProvider(id, name, enabled, priority, settings); await this.refreshProviders(); } async deleteProvider(id: number): Promise { await DeleteProvider(id); await this.refreshProviders(); } /** * Tests a provider's connection. Resolves on success and rejects * with the backend's message, which is what the settings page * shows — these errors are the user's main debugging tool for a * misconfigured client. */ async testProvider(id: number): Promise { await TestProvider(id); } // ----------------------------------------------------------------- // Downloads (one search+grab attempt) // ----------------------------------------------------------------- /** * Starts a download. Returns the ranked candidates plus whether the * pipeline already picked one, so the caller knows whether to open * the picker or just show progress. */ async start(request: download.SearchRequest): Promise { const result = await StartDownload(request); await this.refreshDownloads(); return result; } async pick(downloadId: string, candidateId: string): Promise { await Pick(downloadId, candidateId); await this.refreshDownloads(); } async cancel(downloadId: string): Promise { await Cancel(downloadId); await this.refreshDownloads(); } async candidates(downloadId: string): Promise { return (await Candidates(downloadId)) ?? []; } async clearFinished(): Promise { await ClearFinished(); await this.refreshDownloads(); } // ----------------------------------------------------------------- // Requests (durable "I asked for this") // ----------------------------------------------------------------- get requests(): Request[] { return this.requestsValue; } /** Requests still being looked for. */ get activeRequests(): Request[] { return this.requestsValue.filter((r) => r.state === 'wanted'); } /** Artist subscriptions, which expand rather than download. */ get subscriptions(): Request[] { return this.requestsValue.filter((r) => r.entity === 'artist'); } async refreshRequests(): Promise { try { this.requestsValue = (await ListRequests()) ?? []; this.notify(); } catch (err) { console.error('Failed to load the requests list:', err); } } /** * True when this MBID is already requested. * * Checked against the locally cached request list rather than the * `IsRequested` RPC: the list is already kept current via * `RequestsChanged`, and a local lookup keeps this usable * synchronously from render — the same shape callers relied on * before the rename. */ isRequested(mbid: string): boolean { const needle = mbid.trim().toLowerCase(); return this.requestsValue.some((r) => r.mbid === needle); } /** The request for an MBID, if one exists. */ requestFor(mbid: string): Request | undefined { const needle = mbid.trim().toLowerCase(); return this.requestsValue.find((r) => r.mbid === needle); } async addRequest(request: download.RequestInput): Promise { const id = await AddRequest(request); await this.refreshRequests(); return id; } async removeRequest(id: number): Promise { await RemoveRequest(id); await this.refreshRequests(); } async pauseRequest(id: number, paused: boolean): Promise { await PauseRequest(id, paused); await this.refreshRequests(); } async clearSatisfiedRequests(): Promise { await ClearSatisfiedRequests(); await this.refreshRequests(); } /** * Runs a reconcile pass now, for the "check now" button. Resolves * with what the pass did so the UI can say something concrete * rather than just stopping its spinner. */ async reconcileRequests(): Promise { const summary = await ReconcileRequests(); await Promise.all([this.refreshRequests(), this.refreshDownloads()]); return summary; } /** Adopts a provider's own list, e.g. Lidarr's monitored artists. */ async importExternalRequests( providerId: number, libraryId: number, ): Promise { const count = await ImportExternalRequests(providerId, libraryId); await this.refreshRequests(); return count; } } export const downloadStore = new DownloadStore();