Files
yellowjacket/frontend/src/store/download-store.ts
T
logan 43d78a731a feat(shell): draw only the destinations the user kept
The navigation reads the resolved map from the backend rather than
holding a copy of the defaults, which would be the copy that shipped in
the binary rather than the one being edited.

Hiding takes away the nav item and nothing else: `navigate` still
resolves a hidden view, which detail views and the launch page depend
on. No special case was needed for the highlight, because #72 moved
that onto `active-view-store` -- the sidebar asks `isActive(id)` per
*rendered* item, so a hidden view lights nothing exactly as a detail
view does.

Downloads is gated at the nav on `downloadStore.available` rather than
in the config, so switching it on in Settings still means what it says
once a client exists, and the tab appears without a restart. `available`
is false until the providers have loaded, which makes the item appear on
a fresh launch rather than appearing and then vanishing.

The tab bar honours the toggles too, and the reason is local rather than
a general rule about phones: "More" opens the *same* `<app-sidebar>`,
which filters, so an unfiltered bar would contradict its own drawer one
tap away. Which four tabs is still plan 016's subset; this only removes
from it, and "More" is never filtered.

`services/view-meta.ts` is the destination list, on `shortcut-meta.ts`'s
pattern, because Settings is now a second reader of the same labels in
the same order.

Two existing sidebar tests had to say which world they describe: eleven
destinations now assumes a configured download client.
2026-08-19 19:33:44 -04:00

504 lines
14 KiB
TypeScript

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<string> = 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<Subscriber>();
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<void> {
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<void> {
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<void> {
if (this.providersLoaded) return;
this.providersLoaded = true;
await this.refreshProviders();
}
async refreshProviders(): Promise<void> {
try {
this.providersValue = (await ListProviders()) ?? [];
this.notify();
} catch (err) {
console.error('Failed to load download clients:', err);
}
}
async refreshDownloads(): Promise<void> {
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<string, string>,
): Promise<number> {
const id = await AddProvider(kind, name, settings);
await this.refreshProviders();
return id;
}
async updateProvider(
id: number,
name: string,
enabled: boolean,
priority: number,
settings: Record<string, string>,
): Promise<void> {
await UpdateProvider(id, name, enabled, priority, settings);
await this.refreshProviders();
}
async deleteProvider(id: number): Promise<void> {
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<void> {
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<download.StartResult> {
const result = await StartDownload(request);
await this.refreshDownloads();
return result;
}
async pick(downloadId: string, candidateId: string): Promise<void> {
await Pick(downloadId, candidateId);
await this.refreshDownloads();
}
async cancel(downloadId: string): Promise<void> {
await Cancel(downloadId);
await this.refreshDownloads();
}
async candidates(downloadId: string): Promise<DownloadCandidate[]> {
return (await Candidates(downloadId)) ?? [];
}
async clearFinished(): Promise<void> {
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<void> {
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<number> {
const id = await AddRequest(request);
await this.refreshRequests();
return id;
}
async removeRequest(id: number): Promise<void> {
await RemoveRequest(id);
await this.refreshRequests();
}
async pauseRequest(id: number, paused: boolean): Promise<void> {
await PauseRequest(id, paused);
await this.refreshRequests();
}
async clearSatisfiedRequests(): Promise<void> {
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<RequestSummary> {
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<number> {
const count = await ImportExternalRequests(providerId, libraryId);
await this.refreshRequests();
return count;
}
}
export const downloadStore = new DownloadStore();