`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
209 lines
7.4 KiB
TypeScript
209 lines
7.4 KiB
TypeScript
/**
|
|
* How much of an album is here, keyed by local album id.
|
|
*
|
|
* The album page has always been able to say "9 of 12"; a card could
|
|
* not, so an album held two tracks of ten wore a plain green tick on
|
|
* every grid in the app — which is the complaint the badge-accuracy
|
|
* issue was filed about, one surface over. A ring with a count needs a
|
|
* numerator and a denominator per album, and one `GetAlbumCompleteness`
|
|
* per card is fifty round trips for a grid of fifty.
|
|
*
|
|
* So this is `credit-store` one question over, and for the same three
|
|
* reasons.
|
|
*
|
|
* **The lookup is batched and coalesced across callers.** `request()`
|
|
* is per-card, cheap and safe to call from a render: it collects into a
|
|
* pending set and flushes once on the next frame, which turns a
|
|
* screenful into exactly one `GetAlbumsCompleteness`. A frame rather
|
|
* than a microtask because the point is to collect every card an update
|
|
* pass renders, and those do not land inside one microtask checkpoint.
|
|
*
|
|
* **Absence is cached as an answer.** An album with no files at all is
|
|
* omitted by the backend deliberately — "I have none of this" and "I
|
|
* have no idea" are the third state `known` exists to keep apart — so a
|
|
* miss stored as a hit would re-ask about it forever. What is cached is
|
|
* *asked*, and the unknown answer is a first-class value.
|
|
*
|
|
* **It is invalidated rather than aged.** Completeness is a fact about
|
|
* files on disk, and the two events that change it — a scan finishing
|
|
* and tags being rewritten — are exactly the ones `library-store`
|
|
* already discards everything on. `TracksRemovedFromLibrary` is the
|
|
* third: it changes a numerator without a rescan.
|
|
*/
|
|
|
|
import { GetAlbumsCompleteness } from '@go/library/library.js';
|
|
import type * as library from '@go/library/models.js';
|
|
import { EventsOn } from '@runtime/runtime';
|
|
import { Events } from '../events';
|
|
import { LRUMap } from '../utils/lru-map';
|
|
import { compact } from '../utils/binding';
|
|
import { registerCacheProbe } from '../utils/cache-stats';
|
|
|
|
/**
|
|
* Entries retained. Four small numbers each, so the bound is about
|
|
* unbounded growth rather than bytes — and it is set well above any one
|
|
* grid, since a cap below the visible count evicts cards that are still
|
|
* on screen and the re-render fetches them straight back.
|
|
*/
|
|
export const COMPLETENESS_CACHE_LIMIT = 20_000;
|
|
|
|
/** What the app asks of a cached entry. */
|
|
export type Completeness = library.AlbumCompleteness;
|
|
|
|
/**
|
|
* The answer for an album nobody has a file of.
|
|
*
|
|
* Not a zero-of-zero: `known` false is what every consumer already
|
|
* reads as "say nothing", and it is the same value the album page's
|
|
* `completenessAnswer()` treats as unknowable.
|
|
*/
|
|
const NOTHING_HERE: Completeness = {
|
|
owned: 0,
|
|
expected: 0,
|
|
known: false,
|
|
complete: false,
|
|
} as Completeness;
|
|
|
|
class CompletenessStore {
|
|
private cache = new LRUMap<number, Completeness>(COMPLETENESS_CACHE_LIMIT);
|
|
|
|
/** Ids with a request in flight, so a re-render does not refetch. */
|
|
private inFlight = new Set<number>();
|
|
|
|
private listeners = new Set<() => void>();
|
|
|
|
/** Collected by request(), flushed as one batch on the next frame. */
|
|
private pending = new Set<number>();
|
|
|
|
private flushHandle: number | null = null;
|
|
|
|
constructor() {
|
|
registerCacheProbe('albumCompleteness', () => ({
|
|
entries: this.cache.size,
|
|
chars: this.cache.size * 4,
|
|
limit: COMPLETENESS_CACHE_LIMIT,
|
|
}));
|
|
|
|
EventsOn(Events.LibraryScanComplete, () => this.invalidate());
|
|
EventsOn(Events.TrackMetadataChanged, () => this.invalidate());
|
|
EventsOn(Events.TracksRemovedFromLibrary, () => this.invalidate());
|
|
}
|
|
|
|
/**
|
|
* Subscribe to "some answers arrived".
|
|
*
|
|
* Deliberately not per-album, for `credit-store`'s reason: a grid
|
|
* fetches its cards in one call and re-renders once, so a
|
|
* fine-grained signal would buy nothing and cost a listener a card.
|
|
*/
|
|
subscribe(fn: () => void): () => void {
|
|
this.listeners.add(fn);
|
|
|
|
return () => this.listeners.delete(fn);
|
|
}
|
|
|
|
/** The answer for one album, or undefined until it has been asked. */
|
|
get(albumID: number | undefined | null): Completeness | undefined {
|
|
if (!albumID || albumID <= 0) return undefined;
|
|
|
|
return this.cache.get(albumID);
|
|
}
|
|
|
|
/**
|
|
* Ask about one album, joining whatever batch is forming.
|
|
*
|
|
* Safe from inside a render: a set insert and a scheduled flush,
|
|
* with anything cached or in flight dropped. It does not loop —
|
|
* after a flush every id asked for is cached, so the re-render's
|
|
* requests are all dropped and nothing notifies again.
|
|
*/
|
|
request(albumID: number | undefined | null): void {
|
|
if (!albumID || albumID <= 0) return;
|
|
if (this.cache.has(albumID)) return;
|
|
if (this.inFlight.has(albumID)) return;
|
|
if (this.pending.has(albumID)) return;
|
|
|
|
this.pending.add(albumID);
|
|
|
|
if (this.flushHandle !== null) return;
|
|
|
|
this.flushHandle = requestAnimationFrame(() => {
|
|
this.flushHandle = null;
|
|
|
|
const batch = [...this.pending];
|
|
|
|
this.pending.clear();
|
|
|
|
void this.ensure(batch);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Ask and read in one call, for use inside a template.
|
|
*
|
|
* A getter with a side effect, deliberately — `credit-store` makes
|
|
* the same trade and for the same reason: the alternative is every
|
|
* call site writing `request(x)` beside `get(x)` and one of them
|
|
* eventually forgetting, which renders a permanently unknown
|
|
* completeness that looks exactly like an album with no totals.
|
|
*/
|
|
completeness(albumID: number | undefined | null): Completeness | undefined {
|
|
this.request(albumID);
|
|
|
|
return this.get(albumID);
|
|
}
|
|
|
|
/** Fetch for a list, skipping anything known or already in flight. */
|
|
async ensure(albumIDs: readonly number[]): Promise<void> {
|
|
const wanted = new Set<number>();
|
|
|
|
for (const id of albumIDs) {
|
|
if (!id || id <= 0) continue;
|
|
// `has` rather than `get`: probing must not mark an entry
|
|
// recently-used, or scrolling past a card would keep it
|
|
// alive ahead of one actually being rendered.
|
|
if (this.cache.has(id)) continue;
|
|
if (this.inFlight.has(id)) continue;
|
|
|
|
wanted.add(id);
|
|
}
|
|
|
|
if (wanted.size === 0) return;
|
|
|
|
const batch = [...wanted];
|
|
|
|
for (const id of batch) this.inFlight.add(id);
|
|
|
|
try {
|
|
const found = compact(await GetAlbumsCompleteness(batch));
|
|
|
|
for (const id of batch) {
|
|
this.cache.set(id, found[String(id)] ?? NOTHING_HERE);
|
|
}
|
|
|
|
this.notify();
|
|
} catch (err) {
|
|
// A count is an enrichment: without it a card shows the
|
|
// plain "you have this", which is what it showed before and
|
|
// is a weaker answer rather than a broken one.
|
|
console.error('Failed to load album completeness', err);
|
|
} finally {
|
|
for (const id of batch) this.inFlight.delete(id);
|
|
}
|
|
}
|
|
|
|
/** Drop everything: the files on disk changed. */
|
|
invalidate(): void {
|
|
this.cache = new LRUMap<number, Completeness>(
|
|
COMPLETENESS_CACHE_LIMIT,
|
|
);
|
|
this.notify();
|
|
}
|
|
|
|
private notify(): void {
|
|
for (const fn of this.listeners) fn();
|
|
}
|
|
}
|
|
|
|
export const completenessStore = new CompletenessStore();
|