`explore-album-details` had the rule right for one tracklist and nothing else did: Explore's cards, `top-results-row` and the artist page's three card shapes all mixed owned and unowned with a small badge as the only difference, and drew a green tick on the *common* case — which is the treatment that tracklist's own green ticks were removed for. `utils/ownership.ts` is the rule written once, so eight call sites stop each holding their own version: - owned is plain, and draws no badge at all; - unowned is dimmed *and* says so in its accessible name, because dimming is a colour and cannot be the only signal; - a partly-held album says how partly. **Ownership is a file, and `localId` is the flag that says so.** The album page answers with `filePaths`, a real file per displayed track; a card grid cannot afford that and does not need to, because `local_*_id` is built by queries that all join `audio_files` and cleared by a prune whose existence test is a file test in every case. `inLibrary` is written by the same pass, so the two agree in a healthy database — but it is a one-way ratchet (`MAX(in_library, excluded)`) whose only clearing pass is gated on a non-null local id, so it cannot be un-set on its own. Where they already diverged was the client. Both `explore-view` and `explore-artist-details` kept a `libraryMBIDs` set that accumulated every MBID ever seen with `inLibrary` and cleared it never, in views that never unmount. Both are deleted. And one card answered the question twice and got two answers: `renderReleaseMenuItems` gates Play on `localId > 0` while the badge and `albumTarget.owned` used `inLibrary`, so an album with the flag and no local row drew a tick saying it was in your library, offered no Play, and — the request item being gated on *not* owned — offered no way to ask for it either. The count comes from `completenessStore`, shaped like `credit-store`: `request()` is per-card and coalesces a screenful into one `GetAlbumsCompleteness`, absence is cached as an answer, and the whole cache is dropped on a scan, a retag or a removal rather than aged. `aria-disabled` goes on rows that cannot be activated and deliberately not on cards: an unowned card still navigates to the catalog page for it, which is a perfectly good thing to do with something you do not own. Audited and unchanged: `home-view`, `downloads-view`, `cover-grid`, `artist-details` and `genre-details` cannot show catalog content, so everything on them is owned and "owned is plain" is already what they do. The album page's own header badge stays, because that page is about one entity and the badge is its answer rather than a mark on one of many. Closes #38
164 lines
5.8 KiB
TypeScript
164 lines
5.8 KiB
TypeScript
import { completenessStore } from '@store/completeness-store';
|
|
import { downloadStore } from '@store/download-store';
|
|
import { libraryStore } from '@store/library-store';
|
|
import type * as download from '@go/download/models.js';
|
|
import type { LibraryStatus } from '../components/library-status-indicator/library-status-indicator';
|
|
import { isOwned, type Ownable } from './ownership';
|
|
|
|
/**
|
|
* What the tick/hourglass/plus badge should say about one entity.
|
|
*
|
|
* This exists because the rule was written at all eight call sites and
|
|
* so none of them had the whole of it: every one was a two-way ternary
|
|
* between `in-library` and `not-in-library`, and the badge's third
|
|
* state — `queued`, styled and labelled since it was written — was
|
|
* produced by nothing. An album the user had already asked for through
|
|
* the "Want this" button showed a plus and said it was not in their
|
|
* library, on the same page, forty pixels from a filled button reading
|
|
* "Wanted".
|
|
*
|
|
* Two rules decide the answer, and both are about honesty rather than
|
|
* precedence for its own sake:
|
|
*
|
|
* - **Owning outranks wanting.** A request that has been satisfied by
|
|
* any route — downloaded here, ripped, bought elsewhere — is not
|
|
* news; what the user has is.
|
|
* - **A request is by MBID, and the badge answers about the entity it
|
|
* is on.** A track inside a requested album is not itself requested,
|
|
* so it stays a plus. Saying otherwise would promise that clicking
|
|
* it later would find *that* recording.
|
|
*
|
|
* A `satisfied` request is deliberately not `queued`: nothing is coming.
|
|
* A `paused` one is, because the user did ask for it and it is still on
|
|
* the list — "queued" is a slight overstatement of a paused request and
|
|
* a much smaller one than "not in your library".
|
|
*/
|
|
export function libraryStatusFor(
|
|
owned: boolean,
|
|
mbid?: string | null,
|
|
): LibraryStatus {
|
|
if (owned) return 'in-library';
|
|
|
|
if (!mbid) return 'not-in-library';
|
|
|
|
const request = downloadStore.requestFor(mbid);
|
|
|
|
if (request && request.state !== 'satisfied') return 'queued';
|
|
|
|
return 'not-in-library';
|
|
}
|
|
|
|
/**
|
|
* Everything a badge needs about one entity, decided in one place.
|
|
*
|
|
* `status` is the state; `owned`/`expected` are the counts behind
|
|
* `partial` and are zero for every other state, which is what the badge
|
|
* requires — it documents that a caller with no total must not pass a
|
|
* ring at 0%.
|
|
*/
|
|
export interface BadgeState {
|
|
status: LibraryStatus;
|
|
owned: number;
|
|
expected: number;
|
|
}
|
|
|
|
/**
|
|
* What the badge on an album card should say.
|
|
*
|
|
* Three rules, and the middle one is the whole point of this issue.
|
|
*
|
|
* **Ownership is the local album id**, per `utils/ownership.ts` — a
|
|
* file, not the catalog's `inLibrary` ratchet.
|
|
*
|
|
* **A partly-held album says how partly.** The count comes from
|
|
* `completenessStore`, which batches a screenful into one query;
|
|
* reading it is what asks for it. Before this, an album held 2 tracks
|
|
* of 10 wore the same green tick as one held whole on every grid in
|
|
* the app.
|
|
*
|
|
* **A total that was never declared is not a total of zero.** Where
|
|
* `known` is false — most of an untagged library, and every album until
|
|
* a rescan repopulates `audio_files.total_tracks` — this is a plain
|
|
* `in-library` and says nothing, which is the rule the badge's own
|
|
* documentation states and the reason `Known` exists at all.
|
|
*/
|
|
export function albumBadgeFor(
|
|
album: Ownable | null | undefined,
|
|
mbid?: string | null,
|
|
): BadgeState {
|
|
if (!isOwned(album)) {
|
|
return { status: libraryStatusFor(false, mbid), owned: 0, expected: 0 };
|
|
}
|
|
|
|
const held = completenessStore.completeness(album?.localId);
|
|
|
|
if (held?.known && !held.complete) {
|
|
return {
|
|
status: 'partial',
|
|
owned: held.owned,
|
|
expected: held.expected,
|
|
};
|
|
}
|
|
|
|
return { status: 'in-library', owned: 0, expected: 0 };
|
|
}
|
|
|
|
/** What a badge can ask for. Artists are deliberately absent: a
|
|
* discography subscription is `explore-artist-details`'s Follow
|
|
* button, which can say what it is committing to. */
|
|
export type RequestableEntity = 'album' | 'track';
|
|
|
|
const ENTITY: Record<RequestableEntity, string> = {
|
|
album: 'release-group',
|
|
track: 'recording',
|
|
};
|
|
|
|
/**
|
|
* Add or drop a request for one entity, and report which way it went.
|
|
*
|
|
* The counterpart to `libraryStatusFor`, here rather than in the badge
|
|
* because the badge is one of several things that can ask —
|
|
* `explore-album-details`'s "Want this" button is the other, and two
|
|
* implementations of "what does wanting something mean" is exactly what
|
|
* phase 1 was about.
|
|
*
|
|
* Returns `'wanted'` or `'cancelled'` so a caller can announce what
|
|
* happened; throws if the backend refused, because a badge that
|
|
* silently does nothing is what this whole plan is about.
|
|
*/
|
|
export async function toggleRequest(input: {
|
|
mbid: string;
|
|
entity: RequestableEntity;
|
|
title: string;
|
|
artist?: string;
|
|
}): Promise<'wanted' | 'cancelled'> {
|
|
const existing = downloadStore.requestFor(input.mbid);
|
|
|
|
if (existing) {
|
|
await downloadStore.removeRequest(existing.id);
|
|
|
|
return 'cancelled';
|
|
}
|
|
|
|
// A request belongs to a library because that is where its files
|
|
// will land. There is always at least one by the time anything is
|
|
// on screen — the first-run wizard blocks every pointer event until
|
|
// there is — but an explicit failure beats a request filed against
|
|
// library 0, which no import would ever match.
|
|
const libraryId = await libraryStore.getDefaultLibraryId();
|
|
|
|
if (!libraryId) throw new Error('no library to add this to');
|
|
|
|
await downloadStore.addRequest({
|
|
mbid: input.mbid,
|
|
entity: ENTITY[input.entity],
|
|
libraryId,
|
|
artist: input.artist ?? '',
|
|
title: input.title,
|
|
scope: 'future',
|
|
secondary: false,
|
|
} as download.RequestInput);
|
|
|
|
return 'wanted';
|
|
}
|