feat(database): shape the library like files, and shrink the catalog
Plans 013 and 014, the album page that prompted them, and the smaller fixes they turned up. Changelog, largest first. ## The local library is shaped like files, not like MusicBrainz `audio_files` carries its own tags and points at `albums` and `artists`; `file_genres` is the one real many-to-many. `recordings`, `release_group_recordings`, `artist_credit`, `artist_credit_artist`, `recording_genres`, `release_groups` and `release_to_rg` are gone from the local side, and with them a six-way join in every read, a `MIN(release_group_id)` subquery in eleven queries and a first-credited-artist subquery in nine. Measured on a real 25,966-file library, every many-to-many that model expressed was 1:1 in the data. - Ownership is a file. `GetFilePathsByRecordingMBIDs`, `LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and `pruneStaleLocalCrossReferences` all join `audio_files`, so the 812 orphaned recordings, 216 release groups and 260 artists that library carried are now structurally impossible. - One projection: every track query selects from the `track_metadata` view, one row type, one mapper. Nine hand-rolled copies had drifted far enough to report different years on different screens. - `library_id = 0` means every library, so each list query exists once instead of scoped and unscoped with a branch at every call site. - No migration chain. `sql/schemas/` is the one description of the shape; `sql/migrations/`, `applyMigrations` and `schema_migrations` are squashed away, along with the drift between them that had sqlc generating against a stale schema. - `database.InsertTestTrack` is the one test seeder; twenty test files had been assembling the old FK chain each in its own order. ## The catalog stores its ids as bytes `explore_index`'s three 36-char MBID columns and its entity-type text are 16 raw bytes and a small integer. The table and its six indexes go 780 MB to 405 MB on a real 2,052,200-row catalog, which is why a fresh install is ~0.6 GB rather than ~1.0 GB. - `backend/explore/mbid.go` is the only place the encoding is known; everything above it speaks dashed strings. - `CHECK(length(mbid) = 16)` makes a stringly write fail at the insert rather than silently returning no rows, since SQLite does not coerce between TEXT and BLOB. - The importer asks the artifact what encoding it carries and converts on the way in, so the artifact already published keeps working and no format bump is needed. - `indexRowColumns`/`scanIndexRow` replace four copies of a 22-column list, and `TestStoredEncodingRoundTrips` sweeps every read path. ## An album page that says how much of the album is yours - One question, asked once: is there a file. `filePaths` is filled by a single batched lookup when the tracklist settles, and the badge, the Play count, the dimmed rows and every menu item read it — replacing four claims of decreasing confidence that could show a green tick on an album whose every action did nothing. - Play, Play 7 of 12, or no play button at all. - `total_tracks` on `explore_index` (~2 bytes over 400,677 release groups) and on `audio_files` from tags that have always carried it: a complete MBID-matched album now makes no catalog call at all, where it used to spend the most expensive request the app makes. - A merged cluster shows the running order the most releases agree on, and the version list marks the release you own rather than standing a synthetic entry in for it. - `AlbumReleasesFailed`: a slow fetch is no longer reported as a failed one by a 12-second timer. - Rows not in the library are dimmed in place (with `aria-disabled`) instead of the owned ones wearing a green tick and a legend. ## Caches and cover art get ceilings - Only the three tiers of a cover are stored; the full-resolution copy nothing rendered was 1,134 MB of a 1.4 GB covers directory. - One artist portrait is downloaded and the rest are remembered as URLs — 4.1 GB of a 5.3 GB cache was candidates no code path reads. - `browsedArtBudget` and `httpCacheBudget` bound what an age cannot: the same install held art for 5,770 artists in a 1,301-artist library. - `OrphanedArtistImagesJob` joined a bare MBID onto a sharded directory, so it deleted the rows that were the only record of the files it left behind. `explore.ArtistImageDir` is that layout's one definition now. ## The autotag queue asks whether there is work `tagging_items` was a row per album folder, not a queue, and no query read the `tag_status` column that held the answer. The four queue queries ask the files, which matters most where it is least visible: `startPrefetch` was scoring every album in a tagged library against MusicBrainz. ## Phantom playlist tracks resolve in place An M3U8 imported before its files leaves phantom rows; they now match by path and fall back to position, keep their place in the playlist when resolved, and pair best-first so two phantoms cannot claim the same file. ## Playing a track plays the list it is in Double-click, and Play on a single row's menu, queue the list as displayed with `startIndex` on that row — the album page and the track list used to queue one track and discard the album around it. A multi-row selection still plays exactly itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
This commit is contained in:
@@ -11,10 +11,6 @@
|
||||
// @ts-ignore: Unused imports
|
||||
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as jobs$0 from "../jobs/models.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
@@ -197,21 +193,6 @@ export function SelectSearchCandidate(groupKey: string, kind: string, mbid: stri
|
||||
return $Call.ByID(2540832563, groupKey, kind, mbid);
|
||||
}
|
||||
|
||||
/**
|
||||
* SetJobRegistry wires the background job registry so an apply reports
|
||||
* progress and offers a cancel like every other long-running operation.
|
||||
*
|
||||
* Before this, apply was a bare goroutine whose progress lived in a
|
||||
* component field that navigation discarded, with no cancel and no
|
||||
* record of where it stopped (errors.C3). Everything routed through the
|
||||
* registry gets progress, cancel and the global indicator for free; the
|
||||
* three subsystems that lacked them were the three that were not
|
||||
* registered.
|
||||
*/
|
||||
export function SetJobRegistry(reg: jobs$0.Registry | null): $CancellablePromise<void> {
|
||||
return $Call.ByID(3755409662, reg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip marks the current group as skipped — it stays in the
|
||||
* queue but renders in the "Skipped" section at the bottom of
|
||||
|
||||
@@ -29,7 +29,6 @@ export type {
|
||||
Field,
|
||||
MatchScore,
|
||||
QualityScore,
|
||||
Reconciler,
|
||||
Request,
|
||||
RequestInput,
|
||||
SearchRequest,
|
||||
|
||||
@@ -498,12 +498,6 @@ export interface QualityScore {
|
||||
"mixed": boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciler works the request list.
|
||||
*/
|
||||
export interface Reconciler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Request is one row of the durable request list.
|
||||
*/
|
||||
|
||||
@@ -163,15 +163,6 @@ export function SetPreferences(prefs: $models.AutoDownloadPrefs): $CancellablePr
|
||||
return $Call.ByID(3789717356, prefs);
|
||||
}
|
||||
|
||||
/**
|
||||
* SetReconciler wires the request-list loop. Optional: without it the
|
||||
* request list still stores and lists requests, it just never acts on
|
||||
* them.
|
||||
*/
|
||||
export function SetReconciler(r: $models.Reconciler | null): $CancellablePromise<void> {
|
||||
return $Call.ByID(2390832784, r);
|
||||
}
|
||||
|
||||
/**
|
||||
* StartDownload searches for a release and either auto-picks a clear
|
||||
* winner or returns ranked candidates for the user to choose from.
|
||||
|
||||
@@ -23,8 +23,6 @@ export type {
|
||||
MBReleaseGroup,
|
||||
MBSearchResult,
|
||||
MBTrack,
|
||||
MusicBrainzClient,
|
||||
RateLimiter,
|
||||
Shelf,
|
||||
ShelfPage,
|
||||
ThumbnailRequest,
|
||||
|
||||
@@ -86,7 +86,7 @@ export interface LBTopReleaseGroup {
|
||||
* into the camelCase shape the frontend consumes.
|
||||
*/
|
||||
export interface LyricsResult {
|
||||
"recordingId": number;
|
||||
"audioFileId": number;
|
||||
"filePath": string;
|
||||
"lengthMs": number;
|
||||
"title": string;
|
||||
@@ -228,6 +228,16 @@ export interface MBReleaseGroup {
|
||||
* local release_group row ID
|
||||
*/
|
||||
"localId"?: number;
|
||||
|
||||
/**
|
||||
* TotalTracks is the catalog's track count for this release group,
|
||||
* or 0 for "the catalog does not say". It answers "how much of
|
||||
* this album do I have" for an album whose files declared no total
|
||||
* -- the case GetAlbumCompleteness cannot answer -- and it is not
|
||||
* filled by the MusicBrainz path below, which has the real
|
||||
* tracklist and does not need a denominator.
|
||||
*/
|
||||
"totalTracks": number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -254,38 +264,6 @@ export interface MBTrack {
|
||||
"localId"?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* MusicBrainzClient wraps the musicbrainzws2 library with a local
|
||||
* response cache. Every API call checks the cache first and stores
|
||||
* successful responses for future hits.
|
||||
*
|
||||
* A proactive rate limiter gates all outgoing requests at 1 req/sec
|
||||
* to avoid triggering MusicBrainz 429 responses. The underlying
|
||||
* musicbrainzws2.Client still retries on 429 as a safety net, but
|
||||
* the limiter should prevent most rate-limit hits.
|
||||
*/
|
||||
export interface MusicBrainzClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* RateLimiter enforces a maximum request rate using a token bucket.
|
||||
* MusicBrainz requires ≤1 request per second and rejects ALL
|
||||
* requests (not just excess) when the rate is exceeded, so callers
|
||||
* block proactively via Wait rather than retrying reactively.
|
||||
*
|
||||
* A limiter may carry a second, slower **background lane** (see
|
||||
* WithBackgroundLane). A caller marked by WithBackgroundPriority is
|
||||
* paced by that lane *and* yields to interactive callers: while any
|
||||
* interactive Wait is outstanding, background waits do not take a
|
||||
* token at all. This is what keeps a multi-thousand-request backfill
|
||||
* from putting the album page the user is looking at right now behind
|
||||
* hours of queued work.
|
||||
*
|
||||
* RateLimiter is safe for concurrent use.
|
||||
*/
|
||||
export interface RateLimiter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Shelf is one horizontal row on the Explore page.
|
||||
*
|
||||
|
||||
@@ -17,9 +17,6 @@ import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wails
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as time$0 from "../../../time/models.js";
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as jobs$0 from "../jobs/models.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
@@ -93,14 +90,6 @@ export function BrowseReleases(releaseGroupMBID: string): $CancellablePromise<$m
|
||||
return $Call.ByID(2551207897, releaseGroupMBID);
|
||||
}
|
||||
|
||||
/**
|
||||
* CAALimiter returns the shared Cover Art Archive rate limiter.
|
||||
* Consumers must respect it for any fresh CAA HTTP GETs.
|
||||
*/
|
||||
export function CAALimiter(): $CancellablePromise<$models.RateLimiter | null> {
|
||||
return $Call.ByID(1239092428);
|
||||
}
|
||||
|
||||
/**
|
||||
* CheckLibraryMBIDs returns which of the given MBIDs exist in the
|
||||
* local music library. Returns a map of MBID → entity type
|
||||
@@ -293,14 +282,14 @@ export function GetThumbnails(requests: $models.ThumbnailRequest[] | null): $Can
|
||||
}
|
||||
|
||||
/**
|
||||
* GetTrackLyrics returns lyrics for a recording. If the library
|
||||
* GetTrackLyrics returns lyrics for a file. If the library
|
||||
* already has them (from embedded tags) they're returned as-is;
|
||||
* otherwise it fetches from LRCLIB, persists them (updating the FTS
|
||||
* index), and returns them. Never returns an error to the frontend —
|
||||
* a miss just yields an empty result.
|
||||
*/
|
||||
export function GetTrackLyrics(recordingID: number): $CancellablePromise<$models.TrackLyrics> {
|
||||
return $Call.ByID(1131284622, recordingID);
|
||||
export function GetTrackLyrics(audioFileID: number): $CancellablePromise<$models.TrackLyrics> {
|
||||
return $Call.ByID(1131284622, audioFileID);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -396,14 +385,6 @@ export function LookupReleaseGroup(mbid: string): $CancellablePromise<$models.MB
|
||||
return $Call.ByID(2946174711, mbid);
|
||||
}
|
||||
|
||||
/**
|
||||
* MusicBrainz returns the shared cached MB client so other services
|
||||
* (e.g. autotag) can reuse it without spinning up a second limiter.
|
||||
*/
|
||||
export function MusicBrainz(): $CancellablePromise<$models.MusicBrainzClient | null> {
|
||||
return $Call.ByID(3453528034);
|
||||
}
|
||||
|
||||
/**
|
||||
* PopulateLocalCrossReferences updates the local_*_id columns on
|
||||
* explore_index after a library scan.
|
||||
@@ -542,14 +523,6 @@ export function SetAlbumComplete(fn: $models.AlbumCompleteFunc): $CancellablePro
|
||||
return $Call.ByID(942474493, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* SetJobRegistry wires the background job registry into the search
|
||||
* index so its build reports progress and controls to the frontend.
|
||||
*/
|
||||
export function SetJobRegistry(reg: jobs$0.Registry | null): $CancellablePromise<void> {
|
||||
return $Call.ByID(4291900709, reg);
|
||||
}
|
||||
|
||||
/**
|
||||
* SimilarArtists returns artists similar to the given artist MBID.
|
||||
*/
|
||||
|
||||
@@ -16,7 +16,6 @@ export type {
|
||||
Caps,
|
||||
Job,
|
||||
LogEntry,
|
||||
Registry,
|
||||
Stage,
|
||||
Stat
|
||||
} from "./models.js";
|
||||
|
||||
@@ -103,13 +103,6 @@ export interface LogEntry {
|
||||
"detail"?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry owns every known job and pushes coalesced snapshots to the
|
||||
* frontend. It is safe for concurrent use.
|
||||
*/
|
||||
export interface Registry {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage is one named sub-step of a multi-stage job, such as an index
|
||||
* build tier. Jobs with a single linear phase leave Stages empty.
|
||||
|
||||
@@ -12,12 +12,9 @@ export type {
|
||||
Artist,
|
||||
GenreWithCount,
|
||||
Info,
|
||||
RemovalHooks,
|
||||
RemovalImpact,
|
||||
RemovalResult,
|
||||
RemovalSummary,
|
||||
RescanHooks,
|
||||
ScanHooks,
|
||||
ScanMetrics,
|
||||
ScanWarning,
|
||||
Track,
|
||||
|
||||
@@ -13,24 +13,11 @@ import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wails
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as sqlcgen$0 from "../database/sql/sqlcgen/models.js";
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as jobs$0 from "../jobs/models.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
|
||||
/**
|
||||
* AcquirePipelineLock acquires the pipeline mutex for a tag write
|
||||
* operation. The caller must call ReleasePipelineLock when done.
|
||||
* If a scan is currently in progress, AcquirePipelineLock blocks
|
||||
* until it completes (and vice versa).
|
||||
*/
|
||||
export function AcquirePipelineLock(): $CancellablePromise<void> {
|
||||
return $Call.ByID(2056761494);
|
||||
}
|
||||
|
||||
/**
|
||||
* AddLibrary creates a new library from a directory path, emits a
|
||||
* LibraryAdded event, and starts an asynchronous scan.
|
||||
@@ -82,12 +69,6 @@ export function FullRescan(): $CancellablePromise<$models.ScanMetrics | null> {
|
||||
* GetAlbumCompleteness answers "do I have all of this album" from the
|
||||
* tags read at scan time, with no network.
|
||||
*
|
||||
* The album page used to ask MusicBrainz, because the only track total
|
||||
* it had was the length of whatever tracklist it was already showing —
|
||||
* which for a library copy is a tautology. The denominator in a file's
|
||||
* "5/12" is a real answer and it is already on disk; this is where it
|
||||
* gets read.
|
||||
*
|
||||
* Complete is deliberately >= rather than ==: bonus and hidden tracks
|
||||
* routinely put a folder over its declared total, and that is a
|
||||
* complete album, not a broken one.
|
||||
@@ -97,99 +78,38 @@ export function GetAlbumCompleteness(albumID: number): $CancellablePromise<$mode
|
||||
}
|
||||
|
||||
/**
|
||||
* GetAlbumTracks returns all tracks for a given album (release group), ordered by disc and track number.
|
||||
* GetAlbumTracks returns one album's tracks in disc/track order.
|
||||
*/
|
||||
export function GetAlbumTracks(albumID: number): $CancellablePromise<$models.Track[] | null> {
|
||||
return $Call.ByID(300451334, albumID);
|
||||
export function GetAlbumTracks(albumID: number, libraryID: number): $CancellablePromise<$models.Track[] | null> {
|
||||
return $Call.ByID(300451334, albumID, libraryID);
|
||||
}
|
||||
|
||||
/**
|
||||
* GetAlbumTracksByLibrary returns tracks for the given album,
|
||||
* scoped to the given library.
|
||||
* GetAlbums returns every album, or those with a file in one library.
|
||||
*/
|
||||
export function GetAlbumTracksByLibrary(albumID: number, libraryID: number): $CancellablePromise<$models.Track[] | null> {
|
||||
return $Call.ByID(3304485554, albumID, libraryID);
|
||||
export function GetAlbums(libraryID: number): $CancellablePromise<$models.Album[] | null> {
|
||||
return $Call.ByID(2870789667, libraryID);
|
||||
}
|
||||
|
||||
/**
|
||||
* GetAlbumsByArtist returns all albums where the given artist is the album artist.
|
||||
* GetAlbumsByArtist returns the albums credited to an artist by name.
|
||||
*/
|
||||
export function GetAlbumsByArtist(artistID: number): $CancellablePromise<$models.Album[] | null> {
|
||||
return $Call.ByID(1456840721, artistID);
|
||||
export function GetAlbumsByArtist(artist: string, libraryID: number): $CancellablePromise<$models.Album[] | null> {
|
||||
return $Call.ByID(1456840721, artist, libraryID);
|
||||
}
|
||||
|
||||
/**
|
||||
* GetAlbumsByArtistByLibrary returns albums for the given artist
|
||||
* that have tracks in the given library.
|
||||
*/
|
||||
export function GetAlbumsByArtistByLibrary(artistID: number, libraryID: number): $CancellablePromise<$models.Album[] | null> {
|
||||
return $Call.ByID(2809291, artistID, libraryID);
|
||||
}
|
||||
|
||||
/**
|
||||
* GetAllAlbums returns all albums with cover art and artist info for the cover grid.
|
||||
*/
|
||||
export function GetAllAlbums(): $CancellablePromise<$models.Album[] | null> {
|
||||
return $Call.ByID(2015458954);
|
||||
}
|
||||
|
||||
/**
|
||||
* GetAllAlbumsByLibrary returns albums that have tracks in the given library.
|
||||
*/
|
||||
export function GetAllAlbumsByLibrary(libraryID: number): $CancellablePromise<$models.Album[] | null> {
|
||||
return $Call.ByID(4023050470, libraryID);
|
||||
}
|
||||
|
||||
/**
|
||||
* GetAllArtists returns artists that are credited as album artists, ordered by name.
|
||||
*/
|
||||
export function GetAllArtists(): $CancellablePromise<$models.Artist[] | null> {
|
||||
return $Call.ByID(2529088294);
|
||||
}
|
||||
|
||||
/**
|
||||
* GetAllArtistsByLibrary returns artists that have albums with tracks
|
||||
* in the given library.
|
||||
*/
|
||||
export function GetAllArtistsByLibrary(libraryID: number): $CancellablePromise<$models.Artist[] | null> {
|
||||
return $Call.ByID(1170594642, libraryID);
|
||||
}
|
||||
|
||||
/**
|
||||
* GetAllGenresWithCounts returns all genres with their track counts.
|
||||
*/
|
||||
export function GetAllGenresWithCounts(): $CancellablePromise<$models.GenreWithCount[] | null> {
|
||||
return $Call.ByID(602231298);
|
||||
}
|
||||
|
||||
/**
|
||||
* GetAllGenresWithCountsByLibrary returns genres with track counts
|
||||
* scoped to the given library.
|
||||
*/
|
||||
export function GetAllGenresWithCountsByLibrary(libraryID: number): $CancellablePromise<$models.GenreWithCount[] | null> {
|
||||
return $Call.ByID(772684334, libraryID);
|
||||
}
|
||||
|
||||
/**
|
||||
* GetAllLibrariesWithTrackCounts returns all libraries with their
|
||||
* audio file counts. Typically 1-5 libraries so the loop is trivial.
|
||||
* GetAllLibrariesWithTrackCounts lists the libraries and their sizes.
|
||||
*/
|
||||
export function GetAllLibrariesWithTrackCounts(): $CancellablePromise<$models.Info[] | null> {
|
||||
return $Call.ByID(3420301148);
|
||||
}
|
||||
|
||||
/**
|
||||
* GetAllTracks returns an array of track structs of every file in the library.
|
||||
* GetArtists returns the album artists in a library.
|
||||
*/
|
||||
export function GetAllTracks(): $CancellablePromise<$models.Track[] | null> {
|
||||
return $Call.ByID(2991050010);
|
||||
}
|
||||
|
||||
/**
|
||||
* GetAllTracksByLibrary returns tracks scoped to a specific library.
|
||||
*/
|
||||
export function GetAllTracksByLibrary(libraryID: number): $CancellablePromise<$models.Track[] | null> {
|
||||
return $Call.ByID(3882999766, libraryID);
|
||||
export function GetArtists(libraryID: number): $CancellablePromise<$models.Artist[] | null> {
|
||||
return $Call.ByID(2231116965, libraryID);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -200,8 +120,8 @@ export function GetAllTracksByLibrary(libraryID: number): $CancellablePromise<$m
|
||||
* resolved paths with one binding call per album, sequentially, and each
|
||||
* asked for whole track rows to read one field off them (perf.m2). This
|
||||
* is that question asked once. The result is grouped rather than
|
||||
* flattened because the caller owns the ordering — an album list is
|
||||
* sorted by name, not by id — and because the drag cache stores it per
|
||||
* flattened because the caller owns the ordering - an album list is
|
||||
* sorted by name, not by id - and because the drag cache stores it per
|
||||
* album.
|
||||
*
|
||||
* A library id of 0 means "every library", matching the caller's
|
||||
@@ -212,36 +132,33 @@ export function GetFilePathsByAlbums(albumIDs: number[] | null, libraryID: numbe
|
||||
}
|
||||
|
||||
/**
|
||||
* GetFilePathsByGenres returns the file paths of every track tagged with
|
||||
* the given genres, grouped by genre name. See GetFilePathsByAlbums —
|
||||
* same finding, same shape, and the caller still owns the de-duplication
|
||||
* across genres because it owns the order.
|
||||
* GetFilePathsByGenres returns file paths grouped by genre name.
|
||||
*/
|
||||
export function GetFilePathsByGenres(genreNames: string[] | null, libraryID: number): $CancellablePromise<{ [_ in string]?: string[] | null } | null> {
|
||||
return $Call.ByID(1180707302, genreNames, libraryID);
|
||||
}
|
||||
|
||||
/**
|
||||
* GetFilePathsByRecordingMBIDs returns the file paths of every track
|
||||
* whose recording MBID is in mbids, grouped by MBID.
|
||||
* GetFilePathsByRecordingMBIDs answers "which of these catalog
|
||||
* recordings do I actually have a file for", grouped by MBID.
|
||||
*
|
||||
* This is the catalog side of GetFilePathsByAlbums. An Explore album
|
||||
* page knows what the user owns as a set of recording MBIDs and nothing
|
||||
* else: that is exactly how the backend decides a track's InLibrary
|
||||
* flag (markReleasesInLibrary → CheckMBIDs), and MBTrack.LocalID is a
|
||||
* declared field that nothing writes, so there is no id to ask by.
|
||||
*
|
||||
* Grouped rather than flattened for the same two reasons as its
|
||||
* siblings — the caller owns the order (the tracklist's, not the
|
||||
* database's), and one recording can have more than one file, which is
|
||||
* what this app's duplicate detection exists for.
|
||||
*
|
||||
* A library id of 0 means "every library".
|
||||
* It asks audio_files, which is the only table whose rows are files.
|
||||
* The version of this question that asked the metadata tables said yes
|
||||
* for 129 tracks in a real library that had no file at all - a
|
||||
* retagged file left its old recording row behind, the catalog matched
|
||||
* it, and every action on the row then failed.
|
||||
*/
|
||||
export function GetFilePathsByRecordingMBIDs(mbids: string[] | null, libraryID: number): $CancellablePromise<{ [_ in string]?: string[] | null } | null> {
|
||||
return $Call.ByID(2789061644, mbids, libraryID);
|
||||
}
|
||||
|
||||
/**
|
||||
* GetGenres returns every genre with its track count.
|
||||
*/
|
||||
export function GetGenres(libraryID: number): $CancellablePromise<$models.GenreWithCount[] | null> {
|
||||
return $Call.ByID(2817241511, libraryID);
|
||||
}
|
||||
|
||||
/**
|
||||
* GetRemovalImpact returns pre-removal counts for the confirmation
|
||||
* dialog. All queries are read-only.
|
||||
@@ -259,26 +176,30 @@ export function GetScanQueueLength(): $CancellablePromise<number> {
|
||||
}
|
||||
|
||||
/**
|
||||
* GetTrackMBIDs returns the MusicBrainz IDs for the track at the
|
||||
* given file path. Returns empty strings for entities without MBIDs.
|
||||
* GetTrackMBIDs returns the MusicBrainz ids for one file.
|
||||
*/
|
||||
export function GetTrackMBIDs(filePath: string): $CancellablePromise<$models.TrackMBIDs> {
|
||||
return $Call.ByID(56752473, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* GetTracksByGenre returns all tracks tagged with the given genre.
|
||||
* GetTracks returns every track in a library, or in all of them when
|
||||
* libraryID is 0.
|
||||
*
|
||||
* The library id is a parameter rather than a second method because the
|
||||
* two used to be separate queries, separate bindings and a branch at
|
||||
* every call site - and the scoped form costs nothing (measured: 23 ms
|
||||
* against 21 ms over 26k rows).
|
||||
*/
|
||||
export function GetTracksByGenre(genreName: string): $CancellablePromise<$models.Track[] | null> {
|
||||
return $Call.ByID(1674220245, genreName);
|
||||
export function GetTracks(libraryID: number): $CancellablePromise<$models.Track[] | null> {
|
||||
return $Call.ByID(933082923, libraryID);
|
||||
}
|
||||
|
||||
/**
|
||||
* GetTracksByGenreByLibrary returns tracks tagged with the given
|
||||
* genre, scoped to the given library.
|
||||
* GetTracksByGenre returns every track carrying a genre.
|
||||
*/
|
||||
export function GetTracksByGenreByLibrary(genreName: string, libraryID: number): $CancellablePromise<$models.Track[] | null> {
|
||||
return $Call.ByID(4240564783, genreName, libraryID);
|
||||
export function GetTracksByGenre(genre: string, libraryID: number): $CancellablePromise<$models.Track[] | null> {
|
||||
return $Call.ByID(1674220245, genre, libraryID);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -318,13 +239,6 @@ export function QueuedLibraryNames(): $CancellablePromise<string[] | null> {
|
||||
return $Call.ByID(2036951097);
|
||||
}
|
||||
|
||||
/**
|
||||
* ReleasePipelineLock releases the pipeline mutex after a tag write.
|
||||
*/
|
||||
export function ReleasePipelineLock(): $CancellablePromise<void> {
|
||||
return $Call.ByID(2053843147);
|
||||
}
|
||||
|
||||
/**
|
||||
* RemoveFromLibrary deletes the database rows for the given file paths
|
||||
* and records each path as excluded, so the next scan does not import
|
||||
@@ -392,51 +306,10 @@ export function ScanLibrary(id: number): $CancellablePromise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* SearchTracks performs an FTS5 full-text search and returns
|
||||
* matching tracks with full metadata.
|
||||
* SearchTracks runs the library's FTS index and returns whole tracks.
|
||||
*/
|
||||
export function SearchTracks(query: string): $CancellablePromise<$models.Track[] | null> {
|
||||
return $Call.ByID(3848515709, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* SearchTracksByLibrary performs an FTS5 search scoped to a specific
|
||||
* library and returns matching tracks with full metadata.
|
||||
*/
|
||||
export function SearchTracksByLibrary(query: string, libraryID: number): $CancellablePromise<$models.Track[] | null> {
|
||||
return $Call.ByID(152384327, query, libraryID);
|
||||
}
|
||||
|
||||
/**
|
||||
* SetJobRegistry wires the background job registry so scans report
|
||||
* progress, logs, and pause/cancel controls to the frontend.
|
||||
*/
|
||||
export function SetJobRegistry(reg: jobs$0.Registry | null): $CancellablePromise<void> {
|
||||
return $Call.ByID(4271773525, reg);
|
||||
}
|
||||
|
||||
/**
|
||||
* SetRemovalHooks provides optional hooks for cross-cutting
|
||||
* orchestration during RemoveLibrary.
|
||||
*/
|
||||
export function SetRemovalHooks(h: $models.RemovalHooks): $CancellablePromise<void> {
|
||||
return $Call.ByID(3190933207, h);
|
||||
}
|
||||
|
||||
/**
|
||||
* SetRescanHooks provides optional hooks for cross-cutting
|
||||
* orchestration during FullRescan.
|
||||
*/
|
||||
export function SetRescanHooks(h: $models.RescanHooks): $CancellablePromise<void> {
|
||||
return $Call.ByID(1944064333, h);
|
||||
}
|
||||
|
||||
/**
|
||||
* SetScanHooks provides optional hooks for cross-cutting
|
||||
* orchestration after each library scan.
|
||||
*/
|
||||
export function SetScanHooks(h: $models.ScanHooks): $CancellablePromise<void> {
|
||||
return $Call.ByID(520513414, h);
|
||||
export function SearchTracks(query: string, libraryID: number): $CancellablePromise<$models.Track[] | null> {
|
||||
return $Call.ByID(3848515709, query, libraryID);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,11 +8,10 @@ import * as time$0 from "../../../time/models.js";
|
||||
/**
|
||||
* Album represents an album for the cover grid display.
|
||||
*
|
||||
* Year is the album's preferred display year — the release-group's
|
||||
* original-release-date (MusicBrainz first-release-date) when known,
|
||||
* falling back to the file-tag year. ReleaseYear is the file-tag
|
||||
* year of the specific release in the library; for a 2010 remaster
|
||||
* of a 1973 album, Year=1973 and ReleaseYear=2010.
|
||||
* Year is the album's preferred display year - MusicBrainz's
|
||||
* first-release-date when known, falling back to the file-tag year.
|
||||
* ReleaseYear is the file-tag year of the specific copy in the library;
|
||||
* for a 2010 remaster of a 1973 album, Year=1973 and ReleaseYear=2010.
|
||||
*/
|
||||
export interface Album {
|
||||
"ID": number;
|
||||
@@ -58,7 +57,7 @@ export interface Artist {
|
||||
}
|
||||
|
||||
/**
|
||||
* GenreWithCount holds a genre name and its associated track count.
|
||||
* GenreWithCount is a genre and how many tracks carry it.
|
||||
*/
|
||||
export interface GenreWithCount {
|
||||
"Name": string;
|
||||
@@ -66,8 +65,7 @@ export interface GenreWithCount {
|
||||
}
|
||||
|
||||
/**
|
||||
* Info contains library metadata enriched with track count
|
||||
* for the frontend settings UI.
|
||||
* Info is one library and how many files are in it.
|
||||
*/
|
||||
export interface Info {
|
||||
"id": number;
|
||||
@@ -76,29 +74,6 @@ export interface Info {
|
||||
"trackCount": number;
|
||||
}
|
||||
|
||||
/**
|
||||
* RemovalHooks contains callbacks invoked during library removal.
|
||||
* These break circular dependencies between the library, player,
|
||||
* and queue packages.
|
||||
*/
|
||||
export interface RemovalHooks {
|
||||
/**
|
||||
* StopPlayback stops the currently-playing track.
|
||||
*/
|
||||
"StopPlayback": any;
|
||||
|
||||
/**
|
||||
* CompactQueue reloads queue state after cascade deletes.
|
||||
*/
|
||||
"CompactQueue": any;
|
||||
|
||||
/**
|
||||
* PostRemove runs after the removal commits, for cross-cutting
|
||||
* invalidation (e.g. clearing library-sync "ready" markers).
|
||||
*/
|
||||
"PostRemove": any;
|
||||
}
|
||||
|
||||
/**
|
||||
* RemovalImpact contains pre-removal counts for the confirmation dialog.
|
||||
*/
|
||||
@@ -136,53 +111,6 @@ export interface RemovalSummary {
|
||||
"queueItemsRemoved": number;
|
||||
}
|
||||
|
||||
/**
|
||||
* RescanHooks holds optional callbacks that run before and after
|
||||
* the library-clear-and-scan phase of a full rescan. The app
|
||||
* layer sets these to coordinate cross-cutting concerns (e.g.
|
||||
* clearing the queue, restoring playlists) without the library
|
||||
* needing to know about those packages.
|
||||
*/
|
||||
export interface RescanHooks {
|
||||
/**
|
||||
* PreClear runs before library data is wiped
|
||||
* (e.g. clear queue and stop playback).
|
||||
*/
|
||||
"PreClear": any;
|
||||
|
||||
/**
|
||||
* PostScan runs after the scan completes
|
||||
* (e.g. restore playlists from M3U8 files).
|
||||
*/
|
||||
"PostScan": any;
|
||||
}
|
||||
|
||||
/**
|
||||
* ScanHooks contains callbacks invoked after a library scan
|
||||
* completes. The app layer wires these so the library package
|
||||
* does not depend on the playlist package directly.
|
||||
*/
|
||||
export interface ScanHooks {
|
||||
/**
|
||||
* RepopulatePlaylists re-imports tracks for playlists that
|
||||
* lost their playlist_tracks rows (e.g., from a pre-fix
|
||||
* FullRescan). Runs before ResolvePhantoms.
|
||||
*/
|
||||
"RepopulatePlaylists": any;
|
||||
|
||||
/**
|
||||
* ResolvePhantoms re-links phantom playlist tracks whose
|
||||
* files now exist in the library after scanning.
|
||||
*/
|
||||
"ResolvePhantoms": any;
|
||||
|
||||
/**
|
||||
* OnAllScansComplete runs after ALL queued scans finish
|
||||
* (queue drained).
|
||||
*/
|
||||
"OnAllScansComplete": any;
|
||||
}
|
||||
|
||||
/**
|
||||
* ScanMetrics holds timing and count data collected during a library scan.
|
||||
* Worker-pool fields are protected by a mutex; DB-writer fields are
|
||||
@@ -198,7 +126,6 @@ export interface ScanMetrics {
|
||||
"extractionWallClock": time$0.Duration;
|
||||
"dbWritesWallClock": time$0.Duration;
|
||||
"orphanCleanup": time$0.Duration;
|
||||
"postScanVariants": time$0.Duration;
|
||||
|
||||
/**
|
||||
* Per-format extraction (cumulative across workers).
|
||||
@@ -274,7 +201,7 @@ export interface ScanWarning {
|
||||
}
|
||||
|
||||
/**
|
||||
* Track represents a playable audio file in the library.
|
||||
* Track is one audio file with everything a list needs to draw it.
|
||||
*/
|
||||
export interface Track {
|
||||
"TrackName": string;
|
||||
@@ -305,8 +232,7 @@ export interface Track {
|
||||
}
|
||||
|
||||
/**
|
||||
* TrackMBIDs holds MusicBrainz identifiers for a track, resolved
|
||||
* from the recording, release group, and artist tables.
|
||||
* TrackMBIDs are the MusicBrainz ids a file's tags carry.
|
||||
*/
|
||||
export interface TrackMBIDs {
|
||||
"recordingMbid": string;
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export type {
|
||||
Handler
|
||||
} from "./models.js";
|
||||
@@ -1,7 +0,0 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* Handler manages the OS media control integration.
|
||||
*/
|
||||
export type Handler = any;
|
||||
@@ -14,10 +14,6 @@
|
||||
// @ts-ignore: Unused imports
|
||||
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as mediacontrols$0 from "../mediacontrols/models.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
@@ -138,24 +134,6 @@ export function Seek(targetSeconds: number): $CancellablePromise<void> {
|
||||
return $Call.ByID(829056707, targetSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* SetMediaControls provides an OS media controls handler. When set,
|
||||
* the player pushes metadata, playback state, volume, and seek
|
||||
* notifications to the OS media overlay.
|
||||
*/
|
||||
export function SetMediaControls(h: mediacontrols$0.Handler): $CancellablePromise<void> {
|
||||
return $Call.ByID(1896286877, h);
|
||||
}
|
||||
|
||||
/**
|
||||
* SetPlaybackFinishedHandler sets a callback invoked when a track
|
||||
* finishes naturally. This allows the queue to drive auto-advance
|
||||
* without circular imports.
|
||||
*/
|
||||
export function SetPlaybackFinishedHandler(handler: any): $CancellablePromise<void> {
|
||||
return $Call.ByID(2023377546, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* SetVolume sets the playback volume (0-100), emits a
|
||||
* VolumeChanged event, and persists the new level.
|
||||
|
||||
@@ -10,7 +10,6 @@ export type {
|
||||
CandidateTrack,
|
||||
DuplicateCheckResult,
|
||||
DuplicateTrackInfo,
|
||||
FavoritesConfigProvider,
|
||||
PhantomMatch,
|
||||
PhantomSearchResult,
|
||||
Summary,
|
||||
|
||||
@@ -35,12 +35,6 @@ export interface DuplicateTrackInfo {
|
||||
"Duration": string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FavoritesConfigProvider is a narrow interface for reading and
|
||||
* writing the default-playlist configuration.
|
||||
*/
|
||||
export type FavoritesConfigProvider = any;
|
||||
|
||||
/**
|
||||
* PhantomMatch represents a high-confidence pairing of a phantom
|
||||
* track to a library track.
|
||||
|
||||
@@ -309,14 +309,6 @@ export function SearchLibrary(query: string): $CancellablePromise<$models.Candid
|
||||
return $Call.ByID(3912116995, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* SetFavoritesConfig sets the provider used to read and write
|
||||
* the default-playlist configuration.
|
||||
*/
|
||||
export function SetFavoritesConfig(provider: $models.FavoritesConfigProvider): $CancellablePromise<void> {
|
||||
return $Call.ByID(2117418507, provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* ToggleDefaultPlaylistTrack adds or removes a single track
|
||||
* from the default playlist. Returns true if the track is now
|
||||
|
||||
@@ -11,9 +11,7 @@ export {
|
||||
} from "./models.js";
|
||||
|
||||
export type {
|
||||
FallbackSource,
|
||||
Source,
|
||||
State,
|
||||
Track,
|
||||
TrackLoader
|
||||
Track
|
||||
} from "./models.js";
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* FallbackSource resolves what should auto-play, if anything, once the
|
||||
* queue is exhausted. Implemented outside this package (see app.go) so
|
||||
* the queue does not need to know about config, playlists or
|
||||
* similarity data.
|
||||
*/
|
||||
export type FallbackSource = any;
|
||||
|
||||
/**
|
||||
* RepeatMode represents the queue repeat behavior.
|
||||
*/
|
||||
@@ -65,8 +57,3 @@ export interface Track {
|
||||
"releaseGroupMbid": string;
|
||||
"recordingMbid": string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TrackLoader is the interface the queue uses to tell the player to load a file.
|
||||
*/
|
||||
export type TrackLoader = any;
|
||||
|
||||
@@ -180,22 +180,6 @@ export function SaveState(): $CancellablePromise<void> {
|
||||
return $Call.ByID(2913531465);
|
||||
}
|
||||
|
||||
/**
|
||||
* SetFallbackSource provides the queue with what to auto-play, if
|
||||
* anything, once it runs out. A nil source (the default) leaves
|
||||
* today's behavior: the queue just goes idle.
|
||||
*/
|
||||
export function SetFallbackSource(fs: $models.FallbackSource): $CancellablePromise<void> {
|
||||
return $Call.ByID(1344903240, fs);
|
||||
}
|
||||
|
||||
/**
|
||||
* SetPlayer provides the queue with a reference to the player for auto-advance.
|
||||
*/
|
||||
export function SetPlayer(player: $models.TrackLoader): $CancellablePromise<void> {
|
||||
return $Call.ByID(2806250342, player);
|
||||
}
|
||||
|
||||
/**
|
||||
* SetQueue replaces the entire queue with new tracks and starts playing.
|
||||
* When shuffleStart is true and shuffle mode is active, a random first
|
||||
|
||||
@@ -250,7 +250,7 @@ export class ArtistDetails extends LitElement {
|
||||
try {
|
||||
const albums =
|
||||
await this.libraryCtrl.getAlbumsByArtist(
|
||||
this.artistId,
|
||||
this.artistName,
|
||||
);
|
||||
|
||||
const result = albums ?? [];
|
||||
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
|
||||
import {
|
||||
GetAlbumsByArtist,
|
||||
GetAlbumsByArtistByLibrary,
|
||||
GetFilePathsByAlbums,
|
||||
} from '@go/library/library.js';
|
||||
import * as library from '@go/library/models.js';
|
||||
@@ -1057,12 +1056,7 @@ export class ArtistsView
|
||||
this.libraryCtrl.selectedLibraryId;
|
||||
|
||||
const albums = await list(
|
||||
libId !== null
|
||||
? GetAlbumsByArtistByLibrary(
|
||||
artist.ID,
|
||||
libId,
|
||||
)
|
||||
: GetAlbumsByArtist(artist.ID),
|
||||
GetAlbumsByArtist(artist.Name, libId ?? 0),
|
||||
);
|
||||
|
||||
const byAlbum = await dict(
|
||||
|
||||
@@ -1034,6 +1034,39 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* The "nothing to tag" state. A finished queue is the
|
||||
normal resting state of this page on a tagged library,
|
||||
not a failure, so it gets a settled look rather than
|
||||
the bare sentence the other .empty slots use. */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
text-align: center;
|
||||
padding: 4rem 1.5rem;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
}
|
||||
|
||||
.empty-state wa-icon {
|
||||
font-size: 2.5rem;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--yj-text-primary, #f1f3f5);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
max-width: 34ch;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(200, 90, 90, 0.15);
|
||||
color: #f99;
|
||||
@@ -2918,6 +2951,33 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
||||
`;
|
||||
}
|
||||
|
||||
/** The queue came back with nothing in it — every folder the
|
||||
* scan found already carries tags. This is the resting state
|
||||
* of the page on a tagged library, so it says so in words
|
||||
* rather than leaving the skeleton up: an endless shimmer reads
|
||||
* as a page that is still working. */
|
||||
private renderEmptyQueue(): TemplateResult {
|
||||
const filtered = this.currentLibraryFilter !== null;
|
||||
|
||||
return html`
|
||||
<div class="main">
|
||||
<div class="empty-state">
|
||||
<wa-icon name="circle-check"></wa-icon>
|
||||
<h3>Nothing to tag</h3>
|
||||
<p>
|
||||
${filtered
|
||||
? html`No untagged files in the selected library.
|
||||
Switch the library filter, or add new music
|
||||
and it will appear here after the next scan.`
|
||||
: html`No untagged files. Add new music to your
|
||||
library and it will appear here after the
|
||||
next scan.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMain() {
|
||||
// A scoring error on the selected folder surfaces as an error,
|
||||
// not an endless skeleton.
|
||||
@@ -2939,14 +2999,29 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
||||
}
|
||||
|
||||
if (!this.current) {
|
||||
// A folder list that failed to load is not an empty
|
||||
// queue. Without this the one message the page cannot
|
||||
// honestly show — "there is nothing to tag" — is exactly
|
||||
// what a failed ListPendingFolders renders.
|
||||
if (this.folders.length === 0 && this.errorMessage) {
|
||||
return html`
|
||||
<div class="main">
|
||||
<div class="error">
|
||||
<span>${this.errorMessage}</span>
|
||||
<button @click=${this.onDismissError}>Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.folders.length === 0) {
|
||||
return this.renderEmptyQueue();
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="main">
|
||||
<div class="empty">
|
||||
${this.folders.length === 0
|
||||
? (this.currentLibraryFilter !== null
|
||||
? 'No pending folders in the selected library. Switch the library filter or scan to find untagged albums.'
|
||||
: 'No pending folders. Untagged albums appear here after a library scan.')
|
||||
: 'Pick a folder from the list on the left to review.'}
|
||||
Pick a folder from the list on the left to review.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
GetAlbumTracks,
|
||||
GetAlbumTracksByLibrary,
|
||||
GetFilePathsByAlbums,
|
||||
} from '@go/library/library.js';
|
||||
import { libraryStore } from '@store/library-store';
|
||||
@@ -59,11 +58,7 @@ export class AlbumSelectionManager {
|
||||
const libId =
|
||||
libraryStore.getSelectedLibraryId();
|
||||
|
||||
return list(
|
||||
libId !== null
|
||||
? GetAlbumTracksByLibrary(albumId, libId)
|
||||
: GetAlbumTracks(albumId),
|
||||
);
|
||||
return list(GetAlbumTracks(albumId, libId ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
|
||||
import {
|
||||
GetAlbumTracks,
|
||||
GetAlbumTracksByLibrary,
|
||||
} from '@go/library/library.js';
|
||||
import * as library from '@go/library/models.js';
|
||||
import { LibraryController } from '@store/controllers/library-controller';
|
||||
@@ -935,12 +934,7 @@ export class CoverGrid
|
||||
this.libraryCtrl.selectedLibraryId;
|
||||
|
||||
const tracks = await list(
|
||||
libId !== null
|
||||
? GetAlbumTracksByLibrary(
|
||||
album.ID,
|
||||
libId,
|
||||
)
|
||||
: GetAlbumTracks(album.ID),
|
||||
GetAlbumTracks(album.ID, libId ?? 0),
|
||||
);
|
||||
|
||||
if (this.expandedAlbumId === album.ID) {
|
||||
@@ -1494,6 +1488,33 @@ export class CoverGrid
|
||||
|
||||
switch (action) {
|
||||
case 'play':
|
||||
// One track row is a position in the expanded album, so
|
||||
// it queues that album from there - the same thing
|
||||
// double-clicking the row does. Anything else (several
|
||||
// rows, or an album card) is already an explicit choice
|
||||
// of exactly what to play.
|
||||
if (
|
||||
this.contextMenuTarget.kind === 'track' &&
|
||||
filePaths.length === 1
|
||||
) {
|
||||
const start = this.expandedTracks.findIndex(
|
||||
(t) => t.FilePath === filePaths[0],
|
||||
);
|
||||
|
||||
if (start >= 0) {
|
||||
queueStore.setQueue(
|
||||
this.expandedTracks.map(
|
||||
(t) => t.FilePath,
|
||||
),
|
||||
start,
|
||||
false,
|
||||
source,
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
queueStore.setQueue(filePaths, 0, true, source);
|
||||
break;
|
||||
case 'add-to-queue':
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import {
|
||||
GetAlbumTracks,
|
||||
GetAlbumCompleteness,
|
||||
GetFilePathsByAlbums,
|
||||
GetFilePathsByRecordingMBIDs,
|
||||
} from '@go/library/library.js';
|
||||
import * as library from '@go/library/models.js';
|
||||
@@ -47,7 +46,10 @@ import type { ContextMenuHost } from '@utils/context-menu-controller.js';
|
||||
import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
||||
import { dict, dictByName } from '@utils/binding';
|
||||
import { dictByName } from '@utils/binding';
|
||||
import type { TrackDetails } from '@components/track-details/track-details.js';
|
||||
import { showTrackDetailsForPath } from '@utils/track-details-opener.js';
|
||||
import '@components/playlist-picker/playlist-picker.js';
|
||||
|
||||
/**
|
||||
* The region the album header's own failures are rendered in.
|
||||
@@ -199,6 +201,35 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
*/
|
||||
@state() private localTracks: MBTrack[] = [];
|
||||
|
||||
/**
|
||||
* The file behind each displayed track, resolved once when the
|
||||
* tracklist settles rather than per click.
|
||||
*
|
||||
* This is the page's one answer to "do I own this". It used to be
|
||||
* asked three different ways — a local album id, the backend's
|
||||
* cross-reference, a cached MBID match, or *any* track flagged
|
||||
* inLibrary — none of which is "there is a file", and then answered
|
||||
* a fourth way at the moment the user clicked something. So a row
|
||||
* could render owned, offer Play, and fail; on a real library 129
|
||||
* catalog rows were in exactly that state.
|
||||
*
|
||||
* A path here means the track plays. Nothing else on this page is
|
||||
* allowed to mean it.
|
||||
*/
|
||||
@state() private filePaths = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Which MBIDs have been *asked* about, which is not the same as
|
||||
* which resolved.
|
||||
*
|
||||
* A track the library does not have never lands in `filePaths`, so
|
||||
* a guard keyed on the answer asks about it again on every render —
|
||||
* an unbounded query loop for exactly the tracks the user does not
|
||||
* own. This is not `@state`: it records work done, and changing it
|
||||
* must not schedule a render.
|
||||
*/
|
||||
private askedFor = new Set<string>();
|
||||
|
||||
/** Open state of the "find this album" dialog. */
|
||||
@state() private pickerOpen = false;
|
||||
|
||||
@@ -231,17 +262,20 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
@query('#track-context-menu')
|
||||
private contextMenuPopup!: WaPopup;
|
||||
|
||||
@query('#playlist-submenu')
|
||||
private playlistSubmenuPopup?: WaPopup;
|
||||
|
||||
@query('track-details')
|
||||
private trackDetailsDialog?: TrackDetails;
|
||||
|
||||
// -- ContextMenuHost interface --
|
||||
// No playlist submenu on this page — every action here resolves a
|
||||
// single track's file lazily by MBID, and the submenu exists for a
|
||||
// caller that already has file paths in hand.
|
||||
|
||||
getContextMenuPopup(): WaPopup | undefined {
|
||||
return this.contextMenuPopup;
|
||||
}
|
||||
|
||||
getPlaylistSubmenuPopup(): WaPopup | undefined {
|
||||
return undefined;
|
||||
return this.playlistSubmenuPopup;
|
||||
}
|
||||
|
||||
onContextMenuClose(): void {
|
||||
@@ -763,6 +797,14 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
private hasScrolledToHighlight = false;
|
||||
|
||||
override updated() {
|
||||
// Whatever is displayed needs its files known, and the
|
||||
// tracklist can change from four directions - the local
|
||||
// hydrate, the catalog browse, the cluster build, the version
|
||||
// dropdown. Asking here covers all of them; resolveFilePaths
|
||||
// returns immediately once every displayed MBID is in the map,
|
||||
// so this settles after one pass.
|
||||
void this.resolveFilePaths();
|
||||
|
||||
if (
|
||||
(this.highlightTrackMBID || this.highlightTrackTitle) &&
|
||||
!this.hasScrolledToHighlight &&
|
||||
@@ -860,6 +902,8 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
this.versionEntries = [];
|
||||
this.selectedVersionKey = '';
|
||||
this.localTracks = [];
|
||||
this.filePaths = new Map();
|
||||
this.askedFor = new Set();
|
||||
|
||||
// Local-only album (no MBID) — populate entirely from library.
|
||||
if (!mbid && this.localAlbumId) {
|
||||
@@ -935,7 +979,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
|
||||
/**
|
||||
* Hydrate album info and tracklist from the local library store.
|
||||
* Uses GetAlbumTracks(album.ID) — same local DB call as cover-grid.
|
||||
* Uses GetAlbumTracks(album.ID, libraryStore.libraryFilter()) — same local DB call as cover-grid.
|
||||
* Returns true if a tracklist was populated from local data.
|
||||
*/
|
||||
/**
|
||||
@@ -971,7 +1015,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
// Fetch tracks via the same local DB call the cover-grid uses.
|
||||
let tracks: Awaited<ReturnType<typeof GetAlbumTracks>>;
|
||||
try {
|
||||
tracks = await GetAlbumTracks(this.localAlbumId);
|
||||
tracks = await GetAlbumTracks(this.localAlbumId, libraryStore.libraryFilter());
|
||||
} catch {
|
||||
this.loadingReleases = false;
|
||||
return;
|
||||
@@ -1007,6 +1051,10 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
private mapLocalTracks(
|
||||
tracks: Awaited<ReturnType<typeof GetAlbumTracks>>,
|
||||
): MBTrack[] {
|
||||
// The rows carry the file paths; this is where they stop being
|
||||
// thrown away.
|
||||
this.rememberLocalPaths(tracks);
|
||||
|
||||
const mapped: MBTrack[] = (tracks ?? []).map((t) => ({
|
||||
mbid: t.RecordingMBID || '',
|
||||
title: t.TrackName,
|
||||
@@ -1055,7 +1103,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
*/
|
||||
private async loadLocalTracks(albumId: number): Promise<void> {
|
||||
try {
|
||||
const tracks = await GetAlbumTracks(albumId);
|
||||
const tracks = await GetAlbumTracks(albumId, libraryStore.libraryFilter());
|
||||
|
||||
this.localTracks = this.mapLocalTracks(tracks);
|
||||
} catch {
|
||||
@@ -1065,7 +1113,14 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
// A catalog fetch may have already built the version list
|
||||
// without a "Your Library" entry to point at — rebuild now
|
||||
// that there's local data to match against it.
|
||||
if (this.releases.length > 0) this.buildClusters();
|
||||
//
|
||||
// Unconditionally, including when the catalog returned nothing:
|
||||
// `buildVersionEntries` synthesises the library entry *from*
|
||||
// these tracks, so the no-releases case is exactly the one that
|
||||
// needs this. Guarded on `releases.length` before, an album the
|
||||
// catalog could not answer for showed "No release data
|
||||
// available" over a tracklist it was holding in memory.
|
||||
this.buildClusters();
|
||||
}
|
||||
|
||||
private async hydrateFromLibrary(mbid: string): Promise<boolean> {
|
||||
@@ -1114,7 +1169,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
// Fetch tracks via the same local DB call the cover-grid uses.
|
||||
let tracks: Awaited<ReturnType<typeof GetAlbumTracks>>;
|
||||
try {
|
||||
tracks = await GetAlbumTracks(libraryAlbum.ID);
|
||||
tracks = await GetAlbumTracks(libraryAlbum.ID, libraryStore.libraryFilter());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -1749,8 +1804,8 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
// Guarded on `known` rather than on "fewer tracks than the
|
||||
// cluster", which would swap in a catalog tracklist for
|
||||
// every album whose tags simply never declared a total.
|
||||
const incomplete = this.completeness?.known
|
||||
&& !this.completeness.complete;
|
||||
const answer = this.completenessAnswer();
|
||||
const incomplete = answer?.known && !answer.complete;
|
||||
|
||||
if (incomplete) {
|
||||
const fullRelease = this.findLibraryCluster(clusters);
|
||||
@@ -1759,7 +1814,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
return {
|
||||
key: 'synthetic:library',
|
||||
label: 'Your Library',
|
||||
sublabel: `${this.completeness?.owned ?? 0} of ${this.completeness?.expected ?? 0} tracks · ${this.clusterLabel(fullRelease)}`,
|
||||
sublabel: `${answer?.owned ?? 0} of ${answer?.expected ?? 0} tracks · ${this.clusterLabel(fullRelease)}`,
|
||||
group: 'aggregate',
|
||||
syntheticKind: 'library',
|
||||
tracks: fullRelease.representative.tracks ?? [],
|
||||
@@ -1948,75 +2003,183 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
* the album may be on the request list, which the button directly
|
||||
* below this badge has reported as "Wanted" all along.
|
||||
*/
|
||||
/**
|
||||
* How much of this album is here, from whichever side can say.
|
||||
*
|
||||
* The files answer first: `GetAlbumCompleteness` reads the "5/12"
|
||||
* totals off the tags, which is exact and costs no network. A great
|
||||
* deal of any library declares no total at all, and for those the
|
||||
* *catalog* carries one — a per-release-group track count in
|
||||
* `explore_index`, shipped in the artifact for the price of about
|
||||
* two bytes a row.
|
||||
*
|
||||
* The numerator stays the local one either way: how many distinct
|
||||
* track numbers are on disk. Only the denominator is borrowed, and
|
||||
* only when the tags have none — a catalog total is a statement
|
||||
* about the canonical release, and the files' own total, where they
|
||||
* declare one, is a statement about the release the user actually
|
||||
* has.
|
||||
*
|
||||
* Zero still means "the catalog does not say", so an album neither
|
||||
* side can total stays `known: false` and wears no ring.
|
||||
*/
|
||||
private completenessAnswer(): library.AlbumCompleteness | null {
|
||||
const local = this.completeness;
|
||||
|
||||
if (local?.known) return local;
|
||||
|
||||
const expected = this.releaseGroup?.totalTracks ?? 0;
|
||||
if (expected <= 0 || !local) return local;
|
||||
|
||||
return {
|
||||
...local,
|
||||
expected,
|
||||
known: true,
|
||||
complete: local.owned >= expected,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the badge beside the album title shows.
|
||||
*
|
||||
* `albumLibraryStatus()` answers "is any of this yours", which is
|
||||
* the right question for a tick and the wrong one for a ring. The
|
||||
* ring needs a denominator, and it only exists when the files
|
||||
* declared one — so an owned album with untotalled tags keeps the
|
||||
* plain tick rather than wearing an arc drawn from a guess.
|
||||
* ring needs a denominator, and an album neither the files nor the
|
||||
* catalog can total keeps the plain tick rather than wearing an arc
|
||||
* drawn from a guess.
|
||||
*/
|
||||
private albumBadgeStatus(): LibraryStatus {
|
||||
const owned = this.albumLibraryStatus();
|
||||
|
||||
if (owned !== 'in-library') return owned;
|
||||
|
||||
const c = this.completeness;
|
||||
const c = this.completenessAnswer();
|
||||
if (c?.known && !c.complete) return 'partial';
|
||||
|
||||
return 'in-library';
|
||||
}
|
||||
|
||||
/**
|
||||
* How a displayed track is identified in `filePaths`.
|
||||
*
|
||||
* A recording MBID where there is one, and disc/track/title where
|
||||
* there is not — a library-only album's tracks are synthesised from
|
||||
* the files' own tags and may carry no MBID at all, which is the
|
||||
* case an MBID-keyed lookup silently misses.
|
||||
*/
|
||||
private static trackKey(t: MBTrack): string {
|
||||
if (t.mbid) return t.mbid;
|
||||
|
||||
return `${t.discNumber || 1}:${t.position}:${t.title.toLowerCase()}`;
|
||||
}
|
||||
|
||||
/** The file behind a displayed track, or '' if the user has none. */
|
||||
private filePathFor(t: MBTrack): string {
|
||||
return this.filePaths.get(ExploreAlbumDetails.trackKey(t)) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the files behind the local album's own tracks.
|
||||
*
|
||||
* These cost nothing: `GetAlbumTracks` already returned the paths,
|
||||
* and this is the one place they were being thrown away.
|
||||
*/
|
||||
private rememberLocalPaths(
|
||||
rows: Awaited<ReturnType<typeof GetAlbumTracks>>,
|
||||
): void {
|
||||
const paths = new Map(this.filePaths);
|
||||
|
||||
for (const row of rows ?? []) {
|
||||
if (!row.FilePath) continue;
|
||||
|
||||
const key = ExploreAlbumDetails.trackKey({
|
||||
mbid: row.RecordingMBID || '',
|
||||
title: row.TrackName,
|
||||
position: row.TrackNumber || 0,
|
||||
discNumber: row.DiscNumber || 1,
|
||||
} as MBTrack);
|
||||
|
||||
paths.set(key, row.FilePath);
|
||||
}
|
||||
|
||||
this.filePaths = paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the catalog tracklist's files in one query.
|
||||
*
|
||||
* Called when the displayed tracklist changes rather than when a
|
||||
* user clicks: the answer decides what the rows look like and which
|
||||
* menu items exist, so it has to be known before either is drawn.
|
||||
*/
|
||||
private async resolveFilePaths(): Promise<void> {
|
||||
const tracks = this.currentVersion()?.tracks ?? [];
|
||||
const wanted = tracks
|
||||
.map((t) => t.mbid)
|
||||
.filter((mbid) => mbid && !this.askedFor.has(mbid));
|
||||
|
||||
if (wanted.length === 0) return;
|
||||
|
||||
for (const mbid of wanted) this.askedFor.add(mbid);
|
||||
|
||||
try {
|
||||
const byMBID = await dictByName(
|
||||
GetFilePathsByRecordingMBIDs(wanted, libraryStore.libraryFilter()),
|
||||
);
|
||||
|
||||
const paths = new Map(this.filePaths);
|
||||
|
||||
for (const [mbid, forMBID] of Object.entries(byMBID)) {
|
||||
const first = forMBID?.[0];
|
||||
if (first) paths.set(mbid, first);
|
||||
}
|
||||
|
||||
this.filePaths = paths;
|
||||
} catch (error) {
|
||||
// A failure here means the page cannot say what is owned, so
|
||||
// it says nothing rather than guessing: rows stay dimmed and
|
||||
// the actions that need a file stay absent.
|
||||
console.error('Could not resolve library files for this album:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any of this album is the user's.
|
||||
*
|
||||
* One question, asked once: does any displayed track have a file.
|
||||
* It used to be four claims of decreasing confidence OR'd into a
|
||||
* single tick — a local album id, the backend's cross-reference, a
|
||||
* cached MBID match, and finally *any* track flagged `inLibrary` —
|
||||
* none of which is "there is a file", which is why the badge could
|
||||
* say yes about an album whose every action failed.
|
||||
*
|
||||
* When it is not owned the answer is not automatically "no": the
|
||||
* album may be on the request list, which the button below the
|
||||
* badge has reported as "Wanted" all along.
|
||||
*/
|
||||
private albumLibraryStatus(): LibraryStatus {
|
||||
if (this.localAlbumId > 0) return 'in-library';
|
||||
if (this.ownership().owned > 0) return 'in-library';
|
||||
|
||||
if (this.releaseGroup?.inLibrary) return 'in-library';
|
||||
|
||||
const mbid = this.releaseGroupMBID;
|
||||
if (mbid) {
|
||||
const cachedAlbums = libraryStore.cachedAlbums;
|
||||
if (cachedAlbums) {
|
||||
for (const a of cachedAlbums) {
|
||||
if (a.MBID === mbid) return 'in-library';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const current = this.currentVersion();
|
||||
if (current) {
|
||||
for (const t of current.tracks) {
|
||||
if (t.inLibrary) return 'in-library';
|
||||
}
|
||||
}
|
||||
|
||||
// None of the five ownership claims held, so the badge falls
|
||||
// through to the one thing this page already knew and never
|
||||
// said: whether the album is on the request list. The button
|
||||
// below it has read "Wanted" all along.
|
||||
return libraryStatusFor(false, this.releaseGroupMBID);
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the shown release the user actually has.
|
||||
*
|
||||
* The tick beside the title is a yes/no answer to "is any of this
|
||||
* mine", and four of its five branches can be true when one track
|
||||
* of forty matches. That is fine for a badge and useless for a
|
||||
* button: "Play" that plays one track of a forty-track release is
|
||||
* worse than no Play button, so the header asks this instead.
|
||||
* Counted off the tracklist being displayed, by how many of its
|
||||
* tracks resolved to a file. That is the only claim on this page
|
||||
* that is not an inference: a path means the track plays.
|
||||
*
|
||||
* It is counted off the *tracklist being displayed*, which is the
|
||||
* one thing on this page that is not an inference — each track's
|
||||
* `inLibrary` is set by the backend from its recording MBID
|
||||
* (`markReleasesInLibrary`), the same key the file paths are
|
||||
* fetched by.
|
||||
* It is what the header's buttons key off, because "Play" that
|
||||
* plays one track of a forty-track release is worse than no Play
|
||||
* button — and it is now also what the badge above them uses, so
|
||||
* the two can no longer disagree.
|
||||
*/
|
||||
private ownership(): { owned: number; total: number } {
|
||||
const tracks = this.currentVersion()?.tracks ?? [];
|
||||
|
||||
return {
|
||||
owned: tracks.filter((t) => t.inLibrary).length,
|
||||
owned: tracks.filter((t) => this.filePathFor(t) !== '').length,
|
||||
total: tracks.length,
|
||||
};
|
||||
}
|
||||
@@ -2064,6 +2227,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
${this.renderVersionSelector()}
|
||||
${this.renderTracklist()}
|
||||
</div>
|
||||
<track-details></track-details>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -2130,8 +2294,8 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
<span class="album-title-text">${this.albumName}</span>
|
||||
<library-status-indicator
|
||||
status=${this.albumBadgeStatus()}
|
||||
.owned=${this.completeness?.owned ?? 0}
|
||||
.expected=${this.completeness?.expected ?? 0}
|
||||
.owned=${this.completenessAnswer()?.owned ?? 0}
|
||||
.expected=${this.completenessAnswer()?.expected ?? 0}
|
||||
entity-type="album"
|
||||
label=${this.albumName}
|
||||
size="22"
|
||||
@@ -2181,7 +2345,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
size="small"
|
||||
appearance="filled"
|
||||
data-testid="album-play"
|
||||
@click=${() => void this.playOwned(false)}
|
||||
@click=${() => this.playOwned(false)}
|
||||
>
|
||||
<wa-icon slot="start" name="play"></wa-icon>
|
||||
${playLabel}
|
||||
@@ -2190,7 +2354,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
size="small"
|
||||
appearance="outlined"
|
||||
data-testid="album-shuffle"
|
||||
@click=${() => void this.playOwned(true)}
|
||||
@click=${() => this.playOwned(true)}
|
||||
>
|
||||
<wa-icon slot="start" name="shuffle"></wa-icon>
|
||||
Shuffle album
|
||||
@@ -2199,7 +2363,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
size="small"
|
||||
appearance="outlined"
|
||||
data-testid="album-queue"
|
||||
@click=${() => void this.queueOwned()}
|
||||
@click=${() => this.queueOwned()}
|
||||
>
|
||||
<wa-icon slot="start" name="list"></wa-icon>
|
||||
Add to queue
|
||||
@@ -2240,163 +2404,103 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
return { type: 'album', id: this.localAlbumId, label: this.albumName };
|
||||
}
|
||||
|
||||
private async ownedFilePaths(): Promise<string[]> {
|
||||
const libraryID = libraryStore.getSelectedLibraryId() ?? 0;
|
||||
|
||||
// The local album id is the better key whenever there is one:
|
||||
// it needs no MBIDs at all, and a library-only album has none —
|
||||
// its tracks are synthesised from `GetAlbumTracks` with
|
||||
// `mbid: RecordingMBID || ''`, so an untagged library resolves
|
||||
// to an empty set and the Play button silently does nothing.
|
||||
// That is exactly what the first version of this did.
|
||||
if (this.localAlbumId > 0) {
|
||||
const byAlbum = await dict(
|
||||
GetFilePathsByAlbums([this.localAlbumId], libraryID),
|
||||
);
|
||||
|
||||
return byAlbum[this.localAlbumId] ?? [];
|
||||
}
|
||||
|
||||
// Catalog-only: the page knows what is owned as recording MBIDs
|
||||
// and nothing else — which is how the backend decided each
|
||||
// track's `inLibrary` in the first place.
|
||||
const tracks = this.currentVersion()?.tracks ?? [];
|
||||
const mbids = tracks
|
||||
.filter((t) => t.inLibrary && t.mbid)
|
||||
.map((t) => t.mbid);
|
||||
|
||||
if (mbids.length === 0) return [];
|
||||
|
||||
const byMBID = await dictByName(
|
||||
GetFilePathsByRecordingMBIDs(mbids, libraryID),
|
||||
);
|
||||
|
||||
// Walked in tracklist order rather than flattened, because the
|
||||
// grouping is what lets the caller keep its own order. A
|
||||
// recording with more than one file is a duplicate; play the
|
||||
// first and leave the rest to the feature that exists for them.
|
||||
/**
|
||||
* The files behind the displayed tracklist, in its order.
|
||||
*
|
||||
* No query: the paths were resolved when the tracklist settled.
|
||||
* This used to be two different lookups chosen by a branch - by
|
||||
* local album id, or by recording MBID for a catalog-only album -
|
||||
* and the second silently returned nothing for an untagged library,
|
||||
* because those tracks carry no MBID at all.
|
||||
*/
|
||||
private ownedFilePaths(): string[] {
|
||||
const paths: string[] = [];
|
||||
|
||||
for (const mbid of mbids) {
|
||||
const first = byMBID[mbid]?.[0];
|
||||
for (const track of this.currentVersion()?.tracks ?? []) {
|
||||
const path = this.filePathFor(track);
|
||||
|
||||
if (first) paths.push(first);
|
||||
// A recording with more than one file is a duplicate; the
|
||||
// map holds the first and the rest are the duplicate
|
||||
// feature's business.
|
||||
if (path) paths.push(path);
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
/** Play what the user owns of this release, optionally shuffled. */
|
||||
private async playOwned(shuffle: boolean): Promise<void> {
|
||||
try {
|
||||
const paths = await this.ownedFilePaths();
|
||||
private playOwned(shuffle: boolean): void {
|
||||
const paths = this.ownedFilePaths();
|
||||
|
||||
if (paths.length === 0) {
|
||||
notificationStore.inline(ExploreAlbumRegion, {
|
||||
text: 'None of these tracks could be found in your library.',
|
||||
});
|
||||
// The button is only rendered when there is something to play,
|
||||
// so an empty set here is not a state the user can reach.
|
||||
if (paths.length === 0) return;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// `shuffleStart` only picks a random first track when
|
||||
// shuffle mode is *already* on — it does not turn it on —
|
||||
// so the mode has to be set before the queue, not after.
|
||||
if (shuffle && !queueStore.getState().shuffleMode) {
|
||||
queueStore.toggleShuffle();
|
||||
}
|
||||
|
||||
queueStore.setQueue(paths, 0, shuffle, this.queueSource());
|
||||
} catch (error) {
|
||||
console.error('Could not play album:', error);
|
||||
notificationStore.inline(ExploreAlbumRegion, {
|
||||
text: describeError(error, 'Could not play this album.'),
|
||||
});
|
||||
// `shuffleStart` only picks a random first track when shuffle
|
||||
// mode is *already* on — it does not turn it on — so the mode
|
||||
// has to be set before the queue, not after.
|
||||
if (shuffle && !queueStore.getState().shuffleMode) {
|
||||
queueStore.toggleShuffle();
|
||||
}
|
||||
|
||||
queueStore.setQueue(paths, 0, shuffle, this.queueSource());
|
||||
}
|
||||
|
||||
/** Append what the user owns of this release to the queue. */
|
||||
private async queueOwned(): Promise<void> {
|
||||
try {
|
||||
const paths = await this.ownedFilePaths();
|
||||
private queueOwned(): void {
|
||||
const paths = this.ownedFilePaths();
|
||||
|
||||
if (paths.length === 0) {
|
||||
notificationStore.inline(ExploreAlbumRegion, {
|
||||
text: 'None of these tracks could be found in your library.',
|
||||
});
|
||||
if (paths.length === 0) return;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
queueStore.addTracksToQueue(paths);
|
||||
} catch (error) {
|
||||
console.error('Could not queue album:', error);
|
||||
notificationStore.inline(ExploreAlbumRegion, {
|
||||
text: describeError(
|
||||
error,
|
||||
'Could not add this album to the queue.',
|
||||
),
|
||||
});
|
||||
}
|
||||
queueStore.addTracksToQueue(paths);
|
||||
}
|
||||
|
||||
/**
|
||||
* File path for one owned track, resolved by recording MBID — the
|
||||
* same key the backend used to mark it `inLibrary` in the first
|
||||
* place. Unlike `ownedFilePaths()` this does not special-case
|
||||
* `localAlbumId`: a single track's own MBID is enough, and every
|
||||
* `MBTrack` carries one regardless of how the album itself was
|
||||
* matched.
|
||||
* Play an owned track *in the context of the release it is on*:
|
||||
* the whole owned tracklist is queued and playback starts at that
|
||||
* track. Activating a row is a position in an album, not a request
|
||||
* to throw the album away - "Add to Queue" and "Play Next" are what
|
||||
* a caller reaches for when it wants the one track.
|
||||
*/
|
||||
private async trackFilePath(track: MBTrack): Promise<string | null> {
|
||||
if (!track.inLibrary || !track.mbid) return null;
|
||||
private playTrack(track: MBTrack): void {
|
||||
const path = this.filePathFor(track);
|
||||
|
||||
const libraryID = libraryStore.getSelectedLibraryId() ?? 0;
|
||||
const byMBID = await dictByName(
|
||||
GetFilePathsByRecordingMBIDs([track.mbid], libraryID),
|
||||
);
|
||||
// Every path into this is gated on the row having a file: the
|
||||
// row is not activatable without one and the menu offers
|
||||
// nothing that needs one. There is no "could not be found in
|
||||
// your library" any more, because the page no longer offers an
|
||||
// action it cannot perform.
|
||||
if (!path) return;
|
||||
|
||||
return byMBID[track.mbid]?.[0] ?? null;
|
||||
}
|
||||
|
||||
/** Play a single owned track now. A no-op for a track not in the library. */
|
||||
private async playTrack(track: MBTrack): Promise<void> {
|
||||
try {
|
||||
const path = await this.trackFilePath(track);
|
||||
|
||||
if (!path) {
|
||||
notificationStore.inline(ExploreAlbumRegion, {
|
||||
text: 'This track could not be found in your library.',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
const paths = this.ownedFilePaths();
|
||||
const start = paths.indexOf(path);
|
||||
|
||||
// `start` is only -1 if the row is not in the version currently
|
||||
// displayed, which no gesture on this page can produce; playing
|
||||
// the one track is the honest answer to it either way.
|
||||
if (start < 0) {
|
||||
queueStore.setQueue([path], 0, false, this.queueSource());
|
||||
} catch (error) {
|
||||
console.error('Could not play track:', error);
|
||||
notificationStore.inline(ExploreAlbumRegion, {
|
||||
text: describeError(error, 'Could not play this track.'),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
queueStore.setQueue(paths, start, false, this.queueSource());
|
||||
}
|
||||
|
||||
private async queueTrackNext(track: MBTrack): Promise<void> {
|
||||
const path = await this.trackFilePath(track);
|
||||
private queueTrackNext(track: MBTrack): void {
|
||||
const path = this.filePathFor(track);
|
||||
|
||||
if (path) queueStore.playNext(path);
|
||||
}
|
||||
|
||||
private async addTrackToQueue(track: MBTrack): Promise<void> {
|
||||
const path = await this.trackFilePath(track);
|
||||
private addTrackToQueue(track: MBTrack): void {
|
||||
const path = this.filePathFor(track);
|
||||
|
||||
if (path) queueStore.addToQueue(path);
|
||||
}
|
||||
|
||||
private onTrackRowDblClick(track: MBTrack): void {
|
||||
if (!track.inLibrary) return;
|
||||
|
||||
void this.playTrack(track);
|
||||
this.playTrack(track);
|
||||
}
|
||||
|
||||
private onTrackRowKeydown(e: KeyboardEvent, track: MBTrack): void {
|
||||
@@ -2408,9 +2512,9 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((e.key === 'Enter' || e.key === ' ') && track.inLibrary) {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && this.filePathFor(track)) {
|
||||
e.preventDefault();
|
||||
void this.playTrack(track);
|
||||
this.playTrack(track);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2422,30 +2526,81 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
this.ctxMenu.openAt(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
private onContextMenuAction(action: 'play' | 'add-to-queue' | 'play-next'): void {
|
||||
private onContextMenuAction(
|
||||
action: 'play' | 'add-to-queue' | 'play-next' | 'track-details',
|
||||
): void {
|
||||
const track = this.ctxMenuTrack;
|
||||
|
||||
this.ctxMenu.close();
|
||||
|
||||
if (!track || !track.inLibrary) return;
|
||||
if (!track || !this.filePathFor(track)) return;
|
||||
|
||||
switch (action) {
|
||||
case 'play':
|
||||
void this.playTrack(track);
|
||||
this.playTrack(track);
|
||||
break;
|
||||
case 'add-to-queue':
|
||||
void this.addTrackToQueue(track);
|
||||
this.addTrackToQueue(track);
|
||||
break;
|
||||
case 'play-next':
|
||||
void this.queueTrackNext(track);
|
||||
this.queueTrackNext(track);
|
||||
break;
|
||||
case 'track-details':
|
||||
void this.openTrackDetails(track);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the "Add to Playlist" submenu for the track the menu is on.
|
||||
*
|
||||
* No await and no guards: the file was resolved when the tracklist
|
||||
* settled, so the submenu opens or the item was never rendered.
|
||||
* This used to resolve on demand, which meant a hover could report
|
||||
* a failure for a menu the user was passing through.
|
||||
*/
|
||||
private openPlaylistSubmenu(): void {
|
||||
const track = this.ctxMenuTrack;
|
||||
if (!track) return;
|
||||
|
||||
const path = this.filePathFor(track);
|
||||
if (!path) return;
|
||||
|
||||
this.ctxMenu.clearSubmenuCloseTimer();
|
||||
void this.ctxMenu.showPlaylistSubmenu([path]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The details dialog for an owned track.
|
||||
*
|
||||
* It needs the library's own `Track`, which this page never has —
|
||||
* its rows are the catalog's — so the file path is the way in, and
|
||||
* the shared opener turns it back into a track.
|
||||
*/
|
||||
private async openTrackDetails(track: MBTrack): Promise<void> {
|
||||
const path = this.filePathFor(track);
|
||||
if (!path) return;
|
||||
|
||||
const outcome = await showTrackDetailsForPath(
|
||||
() => this.trackDetailsDialog,
|
||||
path,
|
||||
() => void this.openTrackDetails(track),
|
||||
);
|
||||
|
||||
// The file exists but the library store does not know it: a
|
||||
// rescan removed it since the page loaded, which is the one
|
||||
// case the resolved map cannot rule out.
|
||||
if (outcome === 'not-in-library') {
|
||||
notificationStore.inline(ExploreAlbumRegion, {
|
||||
text: 'This track is no longer in your library.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explore's tracks carry a recording MBID whether or not the user
|
||||
* owns them — this is the one context-menu action that works on a
|
||||
* track the library doesn't have, since it needs no file at all.
|
||||
* track the library does not have, since it needs no file at all.
|
||||
*/
|
||||
private viewTrackOnMusicBrainz(): void {
|
||||
const track = this.ctxMenuTrack;
|
||||
@@ -2612,7 +2767,12 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
* to `unavailable`.
|
||||
*/
|
||||
private catalogScope(): CatalogScope {
|
||||
if (!this.releaseGroupMBID) return 'library';
|
||||
// A library-only album says nothing: the header names it, the
|
||||
// badge says it is yours, and the tracklist is the files'
|
||||
// own — there is nothing absent for a notice to warn about.
|
||||
// The artist page keeps its 'library' state because there a
|
||||
// missing catalog means missing *sections*.
|
||||
if (!this.releaseGroupMBID) return 'catalog';
|
||||
if (this.catalogReleasesLoaded) return 'catalog';
|
||||
|
||||
// A complete, MBID-matched album is not missing anything the
|
||||
@@ -2919,20 +3079,27 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${discTracks.map(
|
||||
(track) => html`
|
||||
${discTracks.map((track) => {
|
||||
// A row is owned if a file is behind it.
|
||||
// It used to be the backend's inLibrary
|
||||
// flag, which was set from a metadata
|
||||
// row and could be true for a track
|
||||
// that could not be played.
|
||||
const owned = this.filePathFor(track) !== '';
|
||||
|
||||
return html`
|
||||
<div
|
||||
class=${classMap({
|
||||
'track-row': true,
|
||||
owned: track.inLibrary,
|
||||
unowned: !track.inLibrary,
|
||||
owned,
|
||||
unowned: !owned,
|
||||
})}
|
||||
data-track-mbid="${track.mbid}"
|
||||
data-track-title="${track.title}"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-disabled=${track.inLibrary ? 'false' : 'true'}
|
||||
aria-label=${track.inLibrary
|
||||
aria-disabled=${owned ? 'false' : 'true'}
|
||||
aria-label=${owned
|
||||
? `Play “${track.title}”`
|
||||
: `${track.title} — not in your library`}
|
||||
@dblclick=${() => this.onTrackRowDblClick(track)}
|
||||
@@ -2952,7 +3119,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
track.length,
|
||||
)}</span
|
||||
>
|
||||
${track.inLibrary
|
||||
${owned
|
||||
? nothing
|
||||
: html`
|
||||
<library-status-indicator
|
||||
@@ -2968,8 +3135,8 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
></library-status-indicator>
|
||||
`}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
`;
|
||||
})}
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
@@ -2992,23 +3159,55 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
${this.ctxMenu.contextMenuOpen && track
|
||||
? html`
|
||||
<div class="context-menu-panel" role="menu" aria-label="Track actions">
|
||||
${track.inLibrary
|
||||
${this.filePathFor(track) !== ''
|
||||
? html`
|
||||
<wa-dropdown-item @click=${() => this.onContextMenuAction('play')}>
|
||||
<wa-dropdown-item
|
||||
@click=${() => this.onContextMenuAction('play')}
|
||||
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon slot="icon" name="play"></wa-icon>
|
||||
Play
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item @click=${() => this.onContextMenuAction('add-to-queue')}>
|
||||
<wa-dropdown-item
|
||||
@click=${() => this.onContextMenuAction('add-to-queue')}
|
||||
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon slot="icon" name="plus"></wa-icon>
|
||||
Add to Queue
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item @click=${() => this.onContextMenuAction('play-next')}>
|
||||
<wa-dropdown-item
|
||||
@click=${() => this.onContextMenuAction('play-next')}
|
||||
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon slot="icon" name="forward-step"></wa-icon>
|
||||
Play Next
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item
|
||||
class="submenu-item"
|
||||
@mouseenter=${() => this.openPlaylistSubmenu()}
|
||||
@mouseleave=${this.ctxMenu.scheduleSubmenuClose}
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
this.openPlaylistSubmenu();
|
||||
}}
|
||||
>
|
||||
<wa-icon slot="icon" name="plus"></wa-icon>
|
||||
Add to Playlist
|
||||
<span class="submenu-arrow">▶</span>
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item
|
||||
@click=${() => this.onContextMenuAction('track-details')}
|
||||
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon slot="icon" name="circle-info"></wa-icon>
|
||||
Track Details
|
||||
</wa-dropdown-item>
|
||||
`
|
||||
: nothing}
|
||||
<wa-dropdown-item @click=${() => this.viewTrackOnMusicBrainz()}>
|
||||
<wa-dropdown-item
|
||||
@click=${() => this.viewTrackOnMusicBrainz()}
|
||||
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon slot="icon" name="globe"></wa-icon>
|
||||
View on MusicBrainz
|
||||
</wa-dropdown-item>
|
||||
@@ -3016,6 +3215,29 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
`
|
||||
: nothing}
|
||||
</wa-popup>
|
||||
|
||||
<wa-popup
|
||||
id="playlist-submenu"
|
||||
placement="right-start"
|
||||
flip
|
||||
shift
|
||||
.active=${this.ctxMenu.playlistSubmenuOpen}
|
||||
>
|
||||
${this.ctxMenu.playlistSubmenuOpen
|
||||
? html`
|
||||
<div
|
||||
@mouseenter=${() => this.ctxMenu.clearSubmenuCloseTimer()}
|
||||
@mouseleave=${this.ctxMenu.scheduleSubmenuClose}
|
||||
>
|
||||
<playlist-picker
|
||||
.filePaths=${this.ctxMenu.playlistFilePaths}
|
||||
@playlist-action-complete=${this.ctxMenu.onPlaylistActionComplete}
|
||||
@click=${(e: Event) => e.stopPropagation()}
|
||||
></playlist-picker>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</wa-popup>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
||||
import { dict, dictByName } from '@utils/binding';
|
||||
import type { TrackDetails } from '@components/track-details/track-details.js';
|
||||
import { showTrackDetailsForPath } from '@utils/track-details-opener.js';
|
||||
import '@components/playlist-picker/playlist-picker.js';
|
||||
|
||||
/* ── Constants ── */
|
||||
|
||||
@@ -199,20 +202,33 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
@query('#context-menu')
|
||||
private contextMenuPopup!: WaPopup;
|
||||
|
||||
@query('#playlist-submenu')
|
||||
private playlistSubmenuPopup?: WaPopup;
|
||||
|
||||
@query('track-details')
|
||||
private trackDetailsDialog?: TrackDetails;
|
||||
|
||||
/**
|
||||
* The open menu's file paths, resolved once per open — see the same
|
||||
* field on the album page. Only a track menu ever has any: a
|
||||
* release's tracks are a different question, and adding a whole
|
||||
* album to a playlist from here is not what this item says.
|
||||
*/
|
||||
private ctxMenuPaths: Promise<string[]> | null = null;
|
||||
|
||||
// -- ContextMenuHost interface --
|
||||
// No playlist submenu here, for the same reason as the album page:
|
||||
// every action resolves one recording's file lazily by MBID.
|
||||
|
||||
getContextMenuPopup(): WaPopup | undefined {
|
||||
return this.contextMenuPopup;
|
||||
}
|
||||
|
||||
getPlaylistSubmenuPopup(): WaPopup | undefined {
|
||||
return undefined;
|
||||
return this.playlistSubmenuPopup;
|
||||
}
|
||||
|
||||
onContextMenuClose(): void {
|
||||
this.ctxMenuTarget = null;
|
||||
this.ctxMenuPaths = null;
|
||||
}
|
||||
|
||||
/** The open menu's track, or null when it is not a track menu. */
|
||||
@@ -1313,10 +1329,13 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
}
|
||||
}
|
||||
|
||||
// Discography: fetch albums by artist ID and seed thumbnails
|
||||
// Discography: fetch the artist's albums and seed thumbnails
|
||||
// directly from library cover art.
|
||||
try {
|
||||
const albums = await GetAlbumsByArtist(this.localArtistId);
|
||||
const albums = await GetAlbumsByArtist(
|
||||
this.artistName,
|
||||
libraryStore.libraryFilter(),
|
||||
);
|
||||
const thumbUpdates = new Map(this.thumbnailURLs);
|
||||
let thumbsChanged = false;
|
||||
|
||||
@@ -2231,7 +2250,9 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
);
|
||||
}
|
||||
|
||||
private onContextMenuAction(action: 'play' | 'add-to-queue' | 'play-next'): void {
|
||||
private onContextMenuAction(
|
||||
action: 'play' | 'add-to-queue' | 'play-next' | 'track-details',
|
||||
): void {
|
||||
const track = this.ctxMenuTrack;
|
||||
|
||||
this.ctxMenu.close();
|
||||
@@ -2248,6 +2269,88 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
case 'play-next':
|
||||
void this.queueTrackNext(track);
|
||||
break;
|
||||
case 'track-details':
|
||||
void this.openTrackDetails(track);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the "Add to Playlist" submenu for the track the menu is on.
|
||||
*
|
||||
* The path is resolved on demand rather than at menu-open time —
|
||||
* the release menus that share this panel never need one, and a
|
||||
* right-click on a track is not a statement that a playlist is
|
||||
* coming. A menu closed while the lookup was in flight must not
|
||||
* sprout a submenu afterwards.
|
||||
*/
|
||||
private async openPlaylistSubmenu(explicit: boolean): Promise<void> {
|
||||
const track = this.ctxMenuTrack;
|
||||
|
||||
if (!track || !this.isTrackOwned(track)) return;
|
||||
|
||||
this.ctxMenu.clearSubmenuCloseTimer();
|
||||
this.ctxMenuPaths ??= this.trackFilePath(track).then((p) =>
|
||||
p ? [p] : []);
|
||||
|
||||
let paths: string[] = [];
|
||||
|
||||
try {
|
||||
paths = await this.ctxMenuPaths;
|
||||
} catch (error) {
|
||||
console.error('Could not resolve the track’s file:', error);
|
||||
this.ctxMenuPaths = null;
|
||||
}
|
||||
|
||||
if (!this.ctxMenu.contextMenuOpen) return;
|
||||
|
||||
if (paths.length === 0) {
|
||||
// Only an explicit activation gets an answer. A hover is how
|
||||
// a submenu is *reached*, including on the way to the item
|
||||
// below it — reporting a failure from one would put an error
|
||||
// on screen for a menu the user was only passing through,
|
||||
// and closing the menu under the pointer is worse still.
|
||||
if (!explicit) return;
|
||||
|
||||
this.ctxMenu.close();
|
||||
notificationStore.inline(ExploreArtistRegion, {
|
||||
text: 'This track could not be found in your library.',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.ctxMenu.showPlaylistSubmenu(paths);
|
||||
}
|
||||
|
||||
/**
|
||||
* The details dialog for an owned top track.
|
||||
*
|
||||
* The dialog wants the library's `Track` and this page has the
|
||||
* catalog's recording, so the route in is the same MBID → file path
|
||||
* resolution the playback actions use.
|
||||
*/
|
||||
private async openTrackDetails(track: LBTopRecording): Promise<void> {
|
||||
try {
|
||||
const path = await this.trackFilePath(track);
|
||||
const outcome = path
|
||||
? await showTrackDetailsForPath(
|
||||
() => this.trackDetailsDialog,
|
||||
path,
|
||||
() => void this.openTrackDetails(track),
|
||||
)
|
||||
: 'not-in-library';
|
||||
|
||||
if (outcome === 'not-in-library') {
|
||||
notificationStore.inline(ExploreArtistRegion, {
|
||||
text: 'This track could not be found in your library.',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Could not open track details:', error);
|
||||
notificationStore.inline(ExploreArtistRegion, {
|
||||
text: describeError(error, 'Could not open track details.'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2458,6 +2561,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
testid="artist-action-message"
|
||||
></inline-notice>
|
||||
${this.renderContextMenu()}
|
||||
<track-details></track-details>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -2529,6 +2633,29 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
`
|
||||
: nothing}
|
||||
</wa-popup>
|
||||
|
||||
<wa-popup
|
||||
id="playlist-submenu"
|
||||
placement="right-start"
|
||||
flip
|
||||
shift
|
||||
.active=${this.ctxMenu.playlistSubmenuOpen}
|
||||
>
|
||||
${this.ctxMenu.playlistSubmenuOpen
|
||||
? html`
|
||||
<div
|
||||
@mouseenter=${() => this.ctxMenu.clearSubmenuCloseTimer()}
|
||||
@mouseleave=${this.ctxMenu.scheduleSubmenuClose}
|
||||
>
|
||||
<playlist-picker
|
||||
.filePaths=${this.ctxMenu.playlistFilePaths}
|
||||
@playlist-action-complete=${this.ctxMenu.onPlaylistActionComplete}
|
||||
@click=${(e: Event) => e.stopPropagation()}
|
||||
></playlist-picker>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</wa-popup>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -2536,21 +2663,53 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
return html`
|
||||
${this.isTrackOwned(track)
|
||||
? html`
|
||||
<wa-dropdown-item @click=${() => this.onContextMenuAction('play')}>
|
||||
<wa-dropdown-item
|
||||
@click=${() => this.onContextMenuAction('play')}
|
||||
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon slot="icon" name="play"></wa-icon>
|
||||
Play
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item @click=${() => this.onContextMenuAction('add-to-queue')}>
|
||||
<wa-dropdown-item
|
||||
@click=${() => this.onContextMenuAction('add-to-queue')}
|
||||
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon slot="icon" name="plus"></wa-icon>
|
||||
Add to Queue
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item @click=${() => this.onContextMenuAction('play-next')}>
|
||||
<wa-dropdown-item
|
||||
@click=${() => this.onContextMenuAction('play-next')}
|
||||
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon slot="icon" name="forward-step"></wa-icon>
|
||||
Play Next
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item
|
||||
class="submenu-item"
|
||||
@mouseenter=${() => void this.openPlaylistSubmenu(false)}
|
||||
@mouseleave=${this.ctxMenu.scheduleSubmenuClose}
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
void this.openPlaylistSubmenu(true);
|
||||
}}
|
||||
>
|
||||
<wa-icon slot="icon" name="plus"></wa-icon>
|
||||
Add to Playlist
|
||||
<span class="submenu-arrow">▶</span>
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item
|
||||
@click=${() => this.onContextMenuAction('track-details')}
|
||||
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon slot="icon" name="circle-info"></wa-icon>
|
||||
Track Details
|
||||
</wa-dropdown-item>
|
||||
`
|
||||
: nothing}
|
||||
<wa-dropdown-item @click=${() => this.viewTrackOnMusicBrainz()}>
|
||||
<wa-dropdown-item
|
||||
@click=${() => this.viewTrackOnMusicBrainz()}
|
||||
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon slot="icon" name="globe"></wa-icon>
|
||||
View on MusicBrainz
|
||||
</wa-dropdown-item>
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import * as library from '@go/library/models.js';
|
||||
import {
|
||||
GetTracksByGenre,
|
||||
GetTracksByGenreByLibrary,
|
||||
} from '@go/library/library.js';
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import { Events } from '../../events';
|
||||
@@ -209,12 +208,7 @@ export class GenreDetails extends LitElement {
|
||||
libraryStore.getSelectedLibraryId();
|
||||
|
||||
this.tracks = await list(
|
||||
libId !== null
|
||||
? GetTracksByGenreByLibrary(
|
||||
this.genreName,
|
||||
libId,
|
||||
)
|
||||
: GetTracksByGenre(this.genreName),
|
||||
GetTracksByGenre(this.genreName, libId ?? 0),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error loading genre tracks:', error);
|
||||
|
||||
@@ -381,7 +381,7 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
|
||||
|
||||
private async playAlbum(album: library.Album): Promise<void> {
|
||||
try {
|
||||
const tracks = await GetAlbumTracks(album.ID);
|
||||
const tracks = await GetAlbumTracks(album.ID, libraryStore.libraryFilter());
|
||||
const paths = (tracks ?? []).map((t) => t.FilePath).filter(Boolean);
|
||||
|
||||
if (paths.length === 0) return;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '../notifications/inline-notice';
|
||||
|
||||
import {
|
||||
FindPhantomMatches,
|
||||
@@ -18,9 +19,16 @@ import type * as playlist from '@go/playlist/models.js';
|
||||
import { formatMilliseconds } from '@utils/time';
|
||||
import { nameDialogsIn } from '@utils/name-dialog';
|
||||
import { list } from '@utils/binding';
|
||||
import { notificationStore } from '@store/notification-store';
|
||||
import { describeError } from '@utils/describe-error';
|
||||
import { srOnly } from '../../styles/sr-only.css';
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 400;
|
||||
|
||||
/** Failures here render inside the dialog: it is modal, so an
|
||||
* app-level notification would sit behind it. */
|
||||
export const PhantomResolverRegion = 'phantom-resolver';
|
||||
|
||||
/**
|
||||
* A modal dialog for resolving phantom (unmatched) tracks
|
||||
* in imported playlists.
|
||||
@@ -129,6 +137,17 @@ export class PhantomResolver extends LitElement {
|
||||
'Failed to find phantom matches:',
|
||||
err,
|
||||
);
|
||||
notificationStore.inline(
|
||||
PhantomResolverRegion,
|
||||
{
|
||||
text: describeError(
|
||||
err,
|
||||
'These tracks could not be matched against the library.',
|
||||
),
|
||||
key: 'phantom-find',
|
||||
detail: String(err),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -156,6 +175,17 @@ export class PhantomResolver extends LitElement {
|
||||
err,
|
||||
);
|
||||
this.candidates = [];
|
||||
notificationStore.inline(
|
||||
PhantomResolverRegion,
|
||||
{
|
||||
text: describeError(
|
||||
err,
|
||||
'No candidates could be looked up for that track.',
|
||||
),
|
||||
key: 'phantom-candidates',
|
||||
detail: String(err),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
this.candidatesLoading = false;
|
||||
}
|
||||
@@ -180,6 +210,17 @@ export class PhantomResolver extends LitElement {
|
||||
err,
|
||||
);
|
||||
this.searchResults = [];
|
||||
notificationStore.inline(
|
||||
PhantomResolverRegion,
|
||||
{
|
||||
text: describeError(
|
||||
err,
|
||||
'The library could not be searched.',
|
||||
),
|
||||
key: 'phantom-search',
|
||||
detail: String(err),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
this.searching = false;
|
||||
}
|
||||
@@ -194,11 +235,84 @@ export class PhantomResolver extends LitElement {
|
||||
void this.loadCandidatesForSelected();
|
||||
}
|
||||
|
||||
private handleCandidateDblClick(
|
||||
/**
|
||||
* The unmatched list is a single-select listbox and selection
|
||||
* follows focus: choosing a track is what fills the panel beside
|
||||
* it, so there is nothing to "activate" separately.
|
||||
*/
|
||||
private handlePhantomKeydown(
|
||||
e: KeyboardEvent,
|
||||
path: string,
|
||||
): void {
|
||||
const items = this.unmatched;
|
||||
const current = items.indexOf(path);
|
||||
|
||||
let next = current;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
next = Math.min(current + 1, items.length - 1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
next = Math.max(current - 1, 0);
|
||||
break;
|
||||
case 'Home':
|
||||
next = 0;
|
||||
break;
|
||||
case 'End':
|
||||
next = items.length - 1;
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
this.handlePhantomClick(path);
|
||||
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const target = items[next];
|
||||
if (target === undefined || next === current) return;
|
||||
|
||||
this.handlePhantomClick(target);
|
||||
void this.focusPhantomItem(next);
|
||||
}
|
||||
|
||||
/** The row is re-rendered with a new tabindex, so focus is taken
|
||||
* after that update rather than on the element that had it. */
|
||||
private async focusPhantomItem(index: number): Promise<void> {
|
||||
await this.updateComplete;
|
||||
|
||||
const items =
|
||||
this.shadowRoot?.querySelectorAll<HTMLElement>(
|
||||
'.phantom-item',
|
||||
);
|
||||
|
||||
items?.[index]?.focus();
|
||||
}
|
||||
|
||||
private chooseCandidate(
|
||||
candidate: playlist.CandidateTrack,
|
||||
): void {
|
||||
if (!this.selectedPhantom) return;
|
||||
|
||||
if (this.claimedPaths.has(candidate.FilePath)) {
|
||||
notificationStore.inline(
|
||||
PhantomResolverRegion,
|
||||
{
|
||||
text:
|
||||
'That track is already standing in for another ' +
|
||||
'unmatched track.',
|
||||
key: 'phantom-claimed',
|
||||
},
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.confirmedMatches.set(
|
||||
this.selectedPhantom,
|
||||
candidate.FilePath,
|
||||
@@ -308,6 +422,17 @@ export class PhantomResolver extends LitElement {
|
||||
'Failed to remove phantom tracks:',
|
||||
err,
|
||||
);
|
||||
notificationStore.inline(
|
||||
PhantomResolverRegion,
|
||||
{
|
||||
text: describeError(
|
||||
err,
|
||||
'Those tracks could not be removed from the playlist.',
|
||||
),
|
||||
key: 'phantom-remove',
|
||||
detail: String(err),
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -339,6 +464,20 @@ export class PhantomResolver extends LitElement {
|
||||
'Failed to resolve phantom tracks:',
|
||||
err,
|
||||
);
|
||||
// The dialog stays open on this path, so the message has to
|
||||
// be in it: nothing else would tell the user why Apply did
|
||||
// nothing.
|
||||
notificationStore.inline(
|
||||
PhantomResolverRegion,
|
||||
{
|
||||
text: describeError(
|
||||
err,
|
||||
'Those matches could not be applied to the playlist.',
|
||||
),
|
||||
key: 'phantom-apply',
|
||||
detail: String(err),
|
||||
},
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -376,6 +515,74 @@ export class PhantomResolver extends LitElement {
|
||||
).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Library files already standing in for some *other* phantom track.
|
||||
* One file cannot resolve two of them — it would be added to the
|
||||
* playlist twice — which is the rule `FindPhantomMatches` applies to
|
||||
* its own auto-matches and the backend now enforces on apply.
|
||||
*/
|
||||
private get claimedPaths(): Set<string> {
|
||||
const claimed = new Set<string>();
|
||||
|
||||
for (const m of this.effectiveAutoMatched) {
|
||||
claimed.add(m.Candidate.FilePath);
|
||||
}
|
||||
|
||||
for (const [phantom, resolved] of this.confirmedMatches) {
|
||||
if (phantom !== this.selectedPhantom) claimed.add(resolved);
|
||||
}
|
||||
|
||||
return claimed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search results the candidate list is not already showing.
|
||||
*
|
||||
* The two lists are rendered one under the other in the same panel,
|
||||
* and a search for the obvious title returns exactly what scoring
|
||||
* already found — so without this the same track appears twice, once
|
||||
* with its score and once without.
|
||||
*/
|
||||
/** What the live region says: this dialog's work is all async and
|
||||
* none of it is announced by the lists changing under it. */
|
||||
private get liveStatus(): string {
|
||||
if (this.loading) return 'Searching for matches.';
|
||||
if (this.candidatesLoading) return 'Loading candidates.';
|
||||
if (this.searching) return 'Searching the library.';
|
||||
|
||||
const extra = this.extraSearchResults.length;
|
||||
|
||||
if (this.searchQuery.trim() && this.searchResults.length > 0) {
|
||||
return extra === 0
|
||||
? 'Every match for that search is already listed.'
|
||||
: `${extra} further ${
|
||||
extra === 1 ? 'result' : 'results'
|
||||
} from the library.`;
|
||||
}
|
||||
|
||||
if (!this.selectedPhantom) return '';
|
||||
|
||||
return `${this.candidates.length} ${
|
||||
this.candidates.length === 1 ? 'candidate' : 'candidates'
|
||||
} for the selected track.`;
|
||||
}
|
||||
|
||||
private get extraSearchResults(): playlist.CandidateTrack[] {
|
||||
if (this.searchResults.length === 0) return [];
|
||||
|
||||
const shown = new Set(
|
||||
this.candidates.map((c) => c.FilePath),
|
||||
);
|
||||
|
||||
return this.searchResults.filter((c) => {
|
||||
if (shown.has(c.FilePath)) return false;
|
||||
|
||||
shown.add(c.FilePath);
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Formatting helpers ─────────────────────────
|
||||
|
||||
private formatDuration(ms: string): string {
|
||||
@@ -395,11 +602,31 @@ export class PhantomResolver extends LitElement {
|
||||
// ─── Rendering ──────────────────────────────────
|
||||
|
||||
static override styles = [
|
||||
srOnly,
|
||||
css`
|
||||
wa-dialog {
|
||||
--width: 860px;
|
||||
}
|
||||
|
||||
/* The disclosure is a <button> now; it keeps the header's
|
||||
own look rather than the UA's. */
|
||||
button.auto-match-header {
|
||||
width: 100%;
|
||||
border: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* A file already standing in for another unmatched track:
|
||||
shown, so the user can see where it went, but not
|
||||
selectable. Dimming is a colour, so aria-disabled carries
|
||||
the same fact to anyone not seeing it. */
|
||||
.candidate-item.claimed {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
wa-dialog::part(dialog) {
|
||||
background: var(
|
||||
--yj-bg-surface,
|
||||
@@ -950,6 +1177,15 @@ export class PhantomResolver extends LitElement {
|
||||
|
||||
private renderContent() {
|
||||
return html`
|
||||
<inline-notice
|
||||
region=${PhantomResolverRegion}
|
||||
testid="phantom-resolver-message"
|
||||
></inline-notice>
|
||||
<!-- Rendered empty and always present: a live region added
|
||||
with its text already in it is not announced. -->
|
||||
<div class="sr-only" role="status" aria-live="polite">
|
||||
${this.liveStatus}
|
||||
</div>
|
||||
${this.renderAutoMatchSection()}
|
||||
${this.unmatched.length > 0 ||
|
||||
this.confirmedMatches.size > 0
|
||||
@@ -964,9 +1200,17 @@ export class PhantomResolver extends LitElement {
|
||||
|
||||
if (matches.length === 0) return nothing;
|
||||
|
||||
// A disclosure is a button and says what it controls, or the
|
||||
// review list behind it cannot be reached from the keyboard —
|
||||
// the same fix `config-section` carries.
|
||||
return html`
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
class="auto-match-header"
|
||||
aria-expanded=${this.autoMatchExpanded
|
||||
? 'true'
|
||||
: 'false'}
|
||||
aria-controls="auto-match-list"
|
||||
@click=${() => {
|
||||
this.autoMatchExpanded =
|
||||
!this.autoMatchExpanded;
|
||||
@@ -991,11 +1235,12 @@ export class PhantomResolver extends LitElement {
|
||||
>
|
||||
(click to review)
|
||||
</span>
|
||||
</div>
|
||||
${this.autoMatchExpanded
|
||||
? html`<div
|
||||
class="auto-match-list"
|
||||
>
|
||||
</button>
|
||||
<div
|
||||
id="auto-match-list"
|
||||
class="auto-match-list"
|
||||
?hidden=${!this.autoMatchExpanded}
|
||||
>
|
||||
${matches.map(
|
||||
(m) => html`
|
||||
<div
|
||||
@@ -1062,8 +1307,7 @@ export class PhantomResolver extends LitElement {
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1075,9 +1319,13 @@ export class PhantomResolver extends LitElement {
|
||||
Unmatched
|
||||
(${this.unresolvedCount})
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div
|
||||
class="panel-body"
|
||||
role="listbox"
|
||||
aria-label="Unmatched tracks"
|
||||
>
|
||||
${this.unmatched.map(
|
||||
(path) => {
|
||||
(path, index) => {
|
||||
const isSelected =
|
||||
this
|
||||
.selectedPhantom ===
|
||||
@@ -1105,10 +1353,26 @@ export class PhantomResolver extends LitElement {
|
||||
: ''} ${isMatched
|
||||
? 'matched'
|
||||
: ''}"
|
||||
role="option"
|
||||
tabindex=${isSelected ||
|
||||
(!this.selectedPhantom &&
|
||||
index === 0)
|
||||
? '0'
|
||||
: '-1'}
|
||||
aria-selected=${isSelected
|
||||
? 'true'
|
||||
: 'false'}
|
||||
@click=${() =>
|
||||
this.handlePhantomClick(
|
||||
path,
|
||||
)}
|
||||
@keydown=${(
|
||||
e: KeyboardEvent,
|
||||
) =>
|
||||
this.handlePhantomKeydown(
|
||||
e,
|
||||
path,
|
||||
)}
|
||||
title=${path}
|
||||
>
|
||||
${isMatched
|
||||
@@ -1188,7 +1452,7 @@ export class PhantomResolver extends LitElement {
|
||||
found. Try
|
||||
searching below.
|
||||
</div>`}
|
||||
${this.searchResults.length > 0
|
||||
${this.extraSearchResults.length > 0
|
||||
? html`
|
||||
<div
|
||||
class="dbl-click-hint"
|
||||
@@ -1197,7 +1461,7 @@ export class PhantomResolver extends LitElement {
|
||||
Library search
|
||||
results
|
||||
</div>
|
||||
${this.searchResults.map(
|
||||
${this.extraSearchResults.map(
|
||||
(c) =>
|
||||
this.renderCandidateItem(
|
||||
c,
|
||||
@@ -1205,6 +1469,13 @@ export class PhantomResolver extends LitElement {
|
||||
)}
|
||||
`
|
||||
: nothing}
|
||||
${this.searchResults.length > 0 &&
|
||||
this.extraSearchResults.length === 0
|
||||
? html`<div class="empty-message">
|
||||
Every match for that search is already
|
||||
listed above.
|
||||
</div>`
|
||||
: nothing}
|
||||
${this.searching
|
||||
? html`<div
|
||||
class="empty-message"
|
||||
@@ -1240,14 +1511,26 @@ export class PhantomResolver extends LitElement {
|
||||
const meta = [c.Artist, c.Album]
|
||||
.filter(Boolean)
|
||||
.join(' \u2014 ');
|
||||
const claimed = this.claimedPaths.has(c.FilePath);
|
||||
|
||||
// A double-click is the pointer shortcut, not the only way in:
|
||||
// the row is a button, so Enter and Space match it too.
|
||||
return html`
|
||||
<div
|
||||
class="candidate-item"
|
||||
@dblclick=${() =>
|
||||
this.handleCandidateDblClick(
|
||||
c,
|
||||
)}
|
||||
class="candidate-item ${claimed ? 'claimed' : ''}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-disabled=${claimed ? 'true' : 'false'}
|
||||
aria-label=${`Match with ${title}${
|
||||
meta ? `, ${meta}` : ''
|
||||
}${claimed ? ' (already used)' : ''}`}
|
||||
@dblclick=${() => this.chooseCandidate(c)}
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
|
||||
e.preventDefault();
|
||||
this.chooseCandidate(c);
|
||||
}}
|
||||
title=${c.FilePath}
|
||||
>
|
||||
<div class="candidate-info">
|
||||
|
||||
@@ -450,7 +450,19 @@ export class PlaylistDetails
|
||||
|
||||
switch (action) {
|
||||
case 'play':
|
||||
queueStore.setQueue(filePaths, 0, true, { type: 'playlist', id: this.playlistId, label: this.playlistName });
|
||||
// One row is a position in the playlist, so it queues
|
||||
// the playlist from there - the same thing
|
||||
// double-clicking the row does. Several rows are an
|
||||
// explicit choice of *those* tracks and become the
|
||||
// queue on their own.
|
||||
if (filePaths.length === 1) {
|
||||
this.handleTrackDblClick(
|
||||
this.selection.getSelectedIndices()[0]!,
|
||||
);
|
||||
} else {
|
||||
queueStore.setQueue(filePaths, 0, true, { type: 'playlist', id: this.playlistId, label: this.playlistName });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'add-to-queue':
|
||||
queueStore.addTracksToQueue(filePaths);
|
||||
|
||||
@@ -919,7 +919,19 @@ export class SmartPlaylistDetails
|
||||
|
||||
switch (action) {
|
||||
case 'play':
|
||||
queueStore.setQueue(filePaths, 0, true, { type: 'smartPlaylist', id: this.playlistId, label: this.playlistName });
|
||||
// One row is a position in the playlist, so it queues
|
||||
// the playlist from there - the same thing
|
||||
// double-clicking the row does. Several rows are an
|
||||
// explicit choice of *those* tracks and become the
|
||||
// queue on their own.
|
||||
if (filePaths.length === 1) {
|
||||
this.handleTrackDblClick(
|
||||
this.selection.getSelectedIndices()[0]!,
|
||||
);
|
||||
} else {
|
||||
queueStore.setQueue(filePaths, 0, true, { type: 'smartPlaylist', id: this.playlistId, label: this.playlistName });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'add-to-queue':
|
||||
queueStore.addTracksToQueue(filePaths);
|
||||
|
||||
@@ -1242,12 +1242,32 @@ export class TrackList
|
||||
* has existed in the defaults and in Settings since it was written
|
||||
* and has never had anything on the other end of it. */
|
||||
private handleShortcutPlay = (): void => {
|
||||
const filePaths = this.selection.getSelectedKeysOrdered();
|
||||
this.playSelection(this.selection.getSelectedKeysOrdered());
|
||||
};
|
||||
|
||||
/**
|
||||
* "Play" means the same thing from the menu and from Enter, and it
|
||||
* asks how much the user selected. One row is a position in the
|
||||
* list - it queues the list from there, exactly as double-clicking
|
||||
* does. Several rows are an explicit choice of *those* tracks, so
|
||||
* they become the queue on their own (and `shuffleStart` applies,
|
||||
* since no one row was named as the place to start).
|
||||
*/
|
||||
private playSelection(filePaths: string[]): void {
|
||||
if (filePaths.length === 0) return;
|
||||
|
||||
if (filePaths.length === 1) {
|
||||
const index = this.displayIndexOf(filePaths[0]!);
|
||||
|
||||
if (index >= 0) {
|
||||
this.playFromRow(index);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
queueStore.setQueue(filePaths, 0, true, this.effectiveQueueSource);
|
||||
};
|
||||
}
|
||||
|
||||
override willUpdate(
|
||||
changed: Map<PropertyKey, unknown>,
|
||||
@@ -1544,7 +1564,7 @@ export class TrackList
|
||||
private onDelegatedDblClick = (e: MouseEvent) => {
|
||||
const hit = this.resolveTrackFromEvent(e);
|
||||
|
||||
if (hit) this.onTrackRowDblClick(hit.track);
|
||||
if (hit) this.onTrackRowDblClick(hit.track, hit.index);
|
||||
};
|
||||
|
||||
private onDelegatedContextMenu = (e: MouseEvent) => {
|
||||
@@ -1574,9 +1594,45 @@ export class TrackList
|
||||
this.selection.handleItemClick(e, track.FilePath, index);
|
||||
}
|
||||
|
||||
private onTrackRowDblClick(track: library.Track) {
|
||||
private onTrackRowDblClick(_track: library.Track, index: number) {
|
||||
this.selection.clear();
|
||||
queueStore.setQueue([track.FilePath], 0, false, this.effectiveQueueSource);
|
||||
this.playFromRow(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activating one row plays the list that row is in, from that row -
|
||||
* the library, the artist or the genre the user is looking at, not
|
||||
* a queue of one. The paths come from `cachedSortedTracks`, so it
|
||||
* is the list as *displayed*: whatever the current sort, search and
|
||||
* library filter have made of it, which is the only order the user
|
||||
* can see and therefore the only one they can mean.
|
||||
*/
|
||||
private playFromRow(index: number) {
|
||||
const filePaths = this.cachedSortedTracks.map(
|
||||
(t) => t.FilePath,
|
||||
);
|
||||
|
||||
if (filePaths.length === 0 || index < 0) return;
|
||||
|
||||
queueStore.setQueue(
|
||||
filePaths,
|
||||
index,
|
||||
false,
|
||||
this.effectiveQueueSource,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a "play this" command lands in the displayed list, or -1.
|
||||
*
|
||||
* Selection keys are file paths, which survive the re-sorts and
|
||||
* refetches an index does not - so the index is looked up at the
|
||||
* moment it is used rather than remembered.
|
||||
*/
|
||||
private displayIndexOf(filePath: string): number {
|
||||
return this.cachedSortedTracks.findIndex(
|
||||
(t) => t.FilePath === filePath,
|
||||
);
|
||||
}
|
||||
|
||||
private onTrackContextMenu(e: MouseEvent, track: library.Track) {
|
||||
@@ -1648,7 +1704,7 @@ export class TrackList
|
||||
|
||||
switch (action) {
|
||||
case 'play':
|
||||
queueStore.setQueue(filePaths, 0, true, this.effectiveQueueSource);
|
||||
this.playSelection(filePaths);
|
||||
break;
|
||||
case 'add-to-queue':
|
||||
queueStore.addTracksToQueue(filePaths);
|
||||
|
||||
@@ -72,9 +72,9 @@ export class LibraryController implements ReactiveController {
|
||||
}
|
||||
|
||||
async getAlbumsByArtist(
|
||||
artistID: number,
|
||||
artist: string,
|
||||
): Promise<library.Album[]> {
|
||||
return libraryStore.getAlbumsByArtist(artistID);
|
||||
return libraryStore.getAlbumsByArtist(artist);
|
||||
}
|
||||
|
||||
getAlbumsByArtistNameCached(
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import {
|
||||
GetAllTracks,
|
||||
GetAllAlbums,
|
||||
GetAllArtists,
|
||||
GetAllGenresWithCounts,
|
||||
GetTracks,
|
||||
GetAlbums,
|
||||
GetArtists,
|
||||
GetGenres,
|
||||
GetAlbumsByArtist,
|
||||
GetAllTracksByLibrary,
|
||||
GetAllAlbumsByLibrary,
|
||||
GetAllArtistsByLibrary,
|
||||
GetAllGenresWithCountsByLibrary,
|
||||
GetAlbumsByArtistByLibrary,
|
||||
GetAllLibrariesWithTrackCounts,
|
||||
} from '@go/library/library.js';
|
||||
import type * as library from '@go/library/models.js';
|
||||
@@ -228,11 +223,9 @@ class LibraryStore {
|
||||
|
||||
if (pending) return pending;
|
||||
|
||||
const id = this.selectedLibraryIdValue;
|
||||
|
||||
return this.track(
|
||||
'tracks',
|
||||
list(id !== null ? GetAllTracksByLibrary(id) : GetAllTracks()),
|
||||
list(GetTracks(this.libraryFilter())),
|
||||
(tracks) => {
|
||||
this.tracks = tracks;
|
||||
},
|
||||
@@ -249,11 +242,9 @@ class LibraryStore {
|
||||
|
||||
if (pending) return pending;
|
||||
|
||||
const id = this.selectedLibraryIdValue;
|
||||
|
||||
return this.track(
|
||||
'albums',
|
||||
list(id !== null ? GetAllAlbumsByLibrary(id) : GetAllAlbums()),
|
||||
list(GetAlbums(this.libraryFilter())),
|
||||
(albums) => {
|
||||
this.albums = albums;
|
||||
},
|
||||
@@ -270,11 +261,9 @@ class LibraryStore {
|
||||
|
||||
if (pending) return pending;
|
||||
|
||||
const id = this.selectedLibraryIdValue;
|
||||
|
||||
return this.track(
|
||||
'artists',
|
||||
list(id !== null ? GetAllArtistsByLibrary(id) : GetAllArtists()),
|
||||
list(GetArtists(this.libraryFilter())),
|
||||
(artists) => {
|
||||
this.artists = artists;
|
||||
},
|
||||
@@ -291,15 +280,9 @@ class LibraryStore {
|
||||
|
||||
if (pending) return pending;
|
||||
|
||||
const id = this.selectedLibraryIdValue;
|
||||
|
||||
return this.track(
|
||||
'genres',
|
||||
list(
|
||||
id !== null
|
||||
? GetAllGenresWithCountsByLibrary(id)
|
||||
: GetAllGenresWithCounts(),
|
||||
),
|
||||
list(GetGenres(this.libraryFilter())),
|
||||
(genres) => {
|
||||
this.genres = genres;
|
||||
},
|
||||
@@ -308,15 +291,21 @@ class LibraryStore {
|
||||
}
|
||||
|
||||
async getAlbumsByArtist(
|
||||
artistID: number,
|
||||
artist: string,
|
||||
): Promise<library.Album[]> {
|
||||
const id = this.selectedLibraryIdValue;
|
||||
return list(GetAlbumsByArtist(artist, this.libraryFilter()));
|
||||
}
|
||||
|
||||
return list(
|
||||
id !== null
|
||||
? GetAlbumsByArtistByLibrary(artistID, id)
|
||||
: GetAlbumsByArtist(artistID),
|
||||
);
|
||||
/**
|
||||
* The library id every backend query takes, where 0 means "all of
|
||||
* them".
|
||||
*
|
||||
* Each of these used to be two bindings and a branch here, because
|
||||
* the backend had a scoped and an unscoped query for every list.
|
||||
* One query answers both now, so the branch is a `?? 0`.
|
||||
*/
|
||||
libraryFilter(): number {
|
||||
return this.selectedLibraryIdValue ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,8 +8,17 @@
|
||||
* the destinations and attributes differ per source type, and there is
|
||||
* no MBID/local-id fallback dance to share — a queue source always
|
||||
* carries a local id (`tracks` is the one exception, needing none).
|
||||
*
|
||||
* An album is the one source that needs more than its local id.
|
||||
* `explore-album-details` is a *catalog* page and decides what it is
|
||||
* showing from `release-group-mbid` alone — with only a local id it
|
||||
* says "library only" about an album that is perfectly well tagged,
|
||||
* which is not what the same album opened from the albums grid says.
|
||||
* So the MBID is read off the library row here, exactly as
|
||||
* `cover-grid` reads it off the card it navigates from.
|
||||
*/
|
||||
|
||||
import { libraryStore } from '../store/library-store';
|
||||
import type { QueueSource } from '../store/queue-store';
|
||||
|
||||
/** Fire a navigate event from the clicked element. */
|
||||
@@ -75,6 +84,26 @@ export function describeQueueSource(source: QueueSource): string | null {
|
||||
return `Playing from ${source.label}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The release-group MBID of a library album, or '' when it has none.
|
||||
*
|
||||
* Reads the album cache synchronously when it is warm — the albums
|
||||
* view populates it, and so does anything else that has asked for the
|
||||
* collection — and only awaits a fetch when nothing has yet.
|
||||
*/
|
||||
function albumMBID(id: number): string | Promise<string> {
|
||||
const find = (albums: readonly { ID: number; MBID: string }[]): string =>
|
||||
albums.find((a) => a.ID === id)?.MBID ?? '';
|
||||
|
||||
const cached = libraryStore.cachedAlbums;
|
||||
if (cached) return find(cached);
|
||||
|
||||
return libraryStore
|
||||
.getAlbums()
|
||||
.then(find)
|
||||
.catch(() => '');
|
||||
}
|
||||
|
||||
/** Navigate to the collection a queue was built from. */
|
||||
export function navigateToQueueSource(
|
||||
target: EventTarget,
|
||||
@@ -83,5 +112,25 @@ export function navigateToQueueSource(
|
||||
const buildDetail = SOURCE_NAVIGATE_DETAIL[source.type];
|
||||
if (!buildDetail) return;
|
||||
|
||||
navigate(target, buildDetail(source));
|
||||
const detail = buildDetail(source);
|
||||
|
||||
if (source.type !== 'album') {
|
||||
navigate(target, detail);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const mbid = albumMBID(source.id);
|
||||
|
||||
if (typeof mbid === 'string') {
|
||||
if (mbid) detail.releaseGroupMBID = mbid;
|
||||
navigate(target, detail);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
void mbid.then((resolved) => {
|
||||
if (resolved) detail.releaseGroupMBID = resolved;
|
||||
navigate(target, detail);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Open `<track-details>` for a file path.
|
||||
*
|
||||
* The five library-side hosts already hold the `library.Track` the
|
||||
* dialog wants — they render it. Explore's rows do not: a tracklist row
|
||||
* is an `MBTrack`/`LBTopRecording` from the catalog, and all it can say
|
||||
* about the library is *which file is behind it*. So the path is the
|
||||
* one key both sides share, and turning it back into a track is the
|
||||
* work this does.
|
||||
*
|
||||
* `libraryStore.getTracks()` is awaited rather than
|
||||
* `getCachedTracks()`-and-bail (which is what `queue-panel` does):
|
||||
* Explore is reachable without ever opening the library views, so a
|
||||
* cold cache is ordinary here rather than a symptom, and silently doing
|
||||
* nothing on a menu item the user just clicked is not an option. The
|
||||
* fetch is the store's own, shared with every other reader.
|
||||
*/
|
||||
|
||||
import type * as library from '@go/library/models.js';
|
||||
import type {
|
||||
CoverArtUrls,
|
||||
TrackDetails,
|
||||
} from '@components/track-details/track-details.js';
|
||||
import { libraryStore } from '@store/library-store.js';
|
||||
import { loadTrackDetails } from '@utils/lazy-track-details.js';
|
||||
import { tracksByFilePath } from '@utils/track-index.js';
|
||||
|
||||
/** The cover art the dialog shows, or nothing when the track has none. */
|
||||
function coverArtOf(track: library.Track): CoverArtUrls | undefined {
|
||||
return track.CoverArtPath
|
||||
? {
|
||||
coverArtPath: track.CoverArtPath,
|
||||
coverArtSmall: track.CoverArtSmall,
|
||||
coverArtMedium: track.CoverArtMedium,
|
||||
coverArtLarge: track.CoverArtLarge,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* What became of the attempt.
|
||||
*
|
||||
* `chunk-failed` is separate from `not-in-library` because
|
||||
* `loadTrackDetails` has already told the user about it — a caller that
|
||||
* treated the two alike would report a missing track over the top of a
|
||||
* notification saying the dialog itself could not be fetched.
|
||||
*/
|
||||
export type TrackDetailsOutcome = 'shown' | 'not-in-library' | 'chunk-failed';
|
||||
|
||||
/**
|
||||
* Show the details dialog for the library track at `filePath`.
|
||||
*
|
||||
* @param dialog A getter, not the element: `@query` resolves an
|
||||
* un-upgraded `<track-details>` before the chunk lands,
|
||||
* and only the read *after* `loadTrackDetails` is
|
||||
* guaranteed to have `show()` on it.
|
||||
* @param retry Re-runs the action that wanted the dialog, offered to
|
||||
* the user if the chunk could not be fetched.
|
||||
*/
|
||||
export async function showTrackDetailsForPath(
|
||||
dialog: () => TrackDetails | undefined,
|
||||
filePath: string,
|
||||
retry: () => void,
|
||||
): Promise<TrackDetailsOutcome> {
|
||||
const tracks = await libraryStore.getTracks();
|
||||
const track = tracksByFilePath(tracks).get(filePath);
|
||||
|
||||
if (!track) return 'not-in-library';
|
||||
|
||||
const ready = await loadTrackDetails(retry);
|
||||
|
||||
if (!ready) return 'chunk-failed';
|
||||
|
||||
dialog()?.show(track, coverArtOf(track));
|
||||
|
||||
return 'shown';
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import type { LitElement } from 'lit';
|
||||
|
||||
import '@components/explore-album-details/explore-album-details';
|
||||
import { stub, flush, resetHarness, calls } from '@test/support/harness';
|
||||
import { stub, flush, resetHarness, calls, lastArgs } from '@test/support/harness';
|
||||
import { fixture, shadow, shadowAll, text } from '@test/support/render';
|
||||
|
||||
type Version = {
|
||||
@@ -52,6 +52,12 @@ function track(n: number, owned: boolean) {
|
||||
* The component builds its versions from fetched releases; this reaches
|
||||
* past that and sets the state the header actually reads, which is the
|
||||
* only part under test here.
|
||||
*
|
||||
* Owning a track means the library has a *file* for it — the page
|
||||
* resolves the displayed tracklist's paths once and every action, badge
|
||||
* and dimmed row reads that one answer. So the fixture says which
|
||||
* tracks have files rather than setting an `inLibrary` flag, which is
|
||||
* what used to be able to claim ownership of something unplayable.
|
||||
*/
|
||||
async function withVersion(
|
||||
owned: number,
|
||||
@@ -61,11 +67,20 @@ async function withVersion(
|
||||
albumName: 'Glass Harbour',
|
||||
});
|
||||
|
||||
const tracks = Array.from({ length: total }, (_, i) => track(i + 1, i < owned));
|
||||
|
||||
const paths: Record<string, string[]> = {};
|
||||
for (const t of tracks.filter((t) => t.inLibrary)) {
|
||||
paths[t.mbid] = [`/music/${t.mbid}.mp3`];
|
||||
}
|
||||
|
||||
stub('library.Library.GetFilePathsByRecordingMBIDs', paths);
|
||||
|
||||
const version: Version = {
|
||||
key: 'v1',
|
||||
label: '2019',
|
||||
sublabel: `${total} tracks`,
|
||||
tracks: Array.from({ length: total }, (_, i) => track(i + 1, i < owned)),
|
||||
tracks,
|
||||
};
|
||||
|
||||
Object.assign(el, {
|
||||
@@ -125,23 +140,28 @@ describe('the album header’s primary action', () => {
|
||||
expect(shadow(el, '[data-testid="album-queue"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('asks for the owned tracks’ paths once, by the key it owns them by', async () => {
|
||||
// `perf.m2`'s rule: ask for what the caller uses, once. The caller
|
||||
// here uses file paths and knows its tracks only as recording
|
||||
// MBIDs — `MBTrack.localId` is declared and never written by
|
||||
// anything in the backend.
|
||||
it('asks for the tracklist’s paths once, on load, and not on click', async () => {
|
||||
// `perf.m2`'s rule — ask for what the caller uses, once — and the
|
||||
// ownership rule with it. The page asks about the *whole* displayed
|
||||
// tracklist when it settles, because whether a track is owned is
|
||||
// that query's answer and not something to be inferred first. Every
|
||||
// action, badge and dimmed row then reads the one result, so a
|
||||
// click asks nothing and cannot fail.
|
||||
const el = await withVersion(7, 12);
|
||||
|
||||
const onLoad = calls('library.Library.GetFilePathsByRecordingMBIDs');
|
||||
|
||||
expect(onLoad).toHaveLength(1);
|
||||
expect(onLoad[0]!.args[0]).toHaveLength(12);
|
||||
// No empty MBID — an empty string matches every untagged recording
|
||||
// in the library.
|
||||
expect(onLoad[0]!.args[0]).not.toContain('');
|
||||
|
||||
shadow<HTMLElement>(el, '[data-testid="album-play"]')!.click();
|
||||
await flush();
|
||||
|
||||
const asked = calls('library.Library.GetFilePathsByRecordingMBIDs');
|
||||
|
||||
expect(asked).toHaveLength(1);
|
||||
// Only the owned ones, and no empty MBID — an empty string matches
|
||||
// every untagged recording in the library.
|
||||
expect(asked[0]!.args[0]).toHaveLength(7);
|
||||
expect(asked[0]!.args[0]).not.toContain('');
|
||||
expect(calls('library.Library.GetFilePathsByRecordingMBIDs')).toHaveLength(1);
|
||||
expect(lastArgs('queue.Queue.SetQueue')?.[0]).toHaveLength(7);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -164,8 +164,17 @@ describe('an album the library already holds in full', () => {
|
||||
stub('explore.Service.GetThumbnail', '');
|
||||
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
|
||||
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
|
||||
// A local album's rows carry their file paths, and that is what
|
||||
// "the library holds this" means now — the page records them as it
|
||||
// maps the tracks, so nothing has to be asked again later.
|
||||
stub('library.Library.GetAlbumTracks', [
|
||||
{ TrackName: 'Track 1', TrackNumber: 1, DiscNumber: 1, TrackLength: '3:20' },
|
||||
{
|
||||
TrackName: 'Track 1',
|
||||
TrackNumber: 1,
|
||||
DiscNumber: 1,
|
||||
TrackLength: '3:20',
|
||||
FilePath: '/music/glass-harbour/01.mp3',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -253,4 +262,72 @@ describe('an album the library already holds in full', () => {
|
||||
const badge = shadow(el, 'library-status-indicator');
|
||||
expect(badge?.getAttribute('status')).toBe('partial');
|
||||
});
|
||||
|
||||
/**
|
||||
* The denominator the files could not supply.
|
||||
*
|
||||
* A great deal of any library declares no track total at all, and
|
||||
* "unknown" is a third state that must render as neither complete nor
|
||||
* incomplete — so an album like this used to wear a plain tick no
|
||||
* matter how much of it was missing. The catalog carries a per-
|
||||
* release-group total in the artifact for about two bytes a row, and
|
||||
* that is what fills the gap: the numerator stays local (how many
|
||||
* distinct track numbers are on disk), only the denominator is
|
||||
* borrowed.
|
||||
*/
|
||||
it('borrows the catalog total when the tags declared none', async () => {
|
||||
stub('explore.Service.LookupReleaseGroup', {
|
||||
mbid: MBID,
|
||||
title: 'Glass Harbour',
|
||||
artistCredit: 'Tideline',
|
||||
totalTracks: 12,
|
||||
});
|
||||
stub('library.Library.GetAlbumCompleteness', {
|
||||
owned: 9,
|
||||
expected: 0,
|
||||
known: false,
|
||||
complete: false,
|
||||
});
|
||||
|
||||
const el = await fixture<LitElement>('explore-album-details', {
|
||||
releaseGroupMBID: MBID,
|
||||
localAlbumId: 7,
|
||||
albumName: 'Glass Harbour',
|
||||
});
|
||||
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
const badge = shadow(el, 'library-status-indicator');
|
||||
expect(badge?.getAttribute('status')).toBe('partial');
|
||||
expect((badge as unknown as { expected: number }).expected).toBe(12);
|
||||
expect((badge as unknown as { owned: number }).owned).toBe(9);
|
||||
});
|
||||
|
||||
/**
|
||||
* And when neither side can total it, nothing is invented: zero means
|
||||
* "the catalog does not say", which is the same third state the local
|
||||
* answer has, so the badge stays a plain tick.
|
||||
*/
|
||||
it('draws no ring when neither the tags nor the catalog say', async () => {
|
||||
stub('library.Library.GetAlbumCompleteness', {
|
||||
owned: 9,
|
||||
expected: 0,
|
||||
known: false,
|
||||
complete: false,
|
||||
});
|
||||
|
||||
const el = await fixture<LitElement>('explore-album-details', {
|
||||
releaseGroupMBID: MBID,
|
||||
localAlbumId: 7,
|
||||
albumName: 'Glass Harbour',
|
||||
});
|
||||
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(
|
||||
shadow(el, 'library-status-indicator')?.getAttribute('status'),
|
||||
).toBe('in-library');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,10 +81,10 @@ async function expandFirstCard(el: LitElement): Promise<void> {
|
||||
describe('the album dropdown', () => {
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
stub('library.Library.GetAllAlbums', ALBUMS);
|
||||
stub('library.Library.GetAllTracks', []);
|
||||
stub('library.Library.GetAlbums', ALBUMS);
|
||||
stub('library.Library.GetTracks', []);
|
||||
stub('library.Library.GetAlbumTracks', TRACKS);
|
||||
stub('library.Library.GetAlbumTracks', TRACKS);
|
||||
stub('library.Library.GetAlbumTracksByLibrary', TRACKS);
|
||||
emit(Events.LibraryScanComplete);
|
||||
});
|
||||
|
||||
@@ -154,8 +154,8 @@ describe('the album dropdown', () => {
|
||||
describe('the albums grid scrolls', () => {
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
stub('library.Library.GetAllAlbums', ALBUMS);
|
||||
stub('library.Library.GetAllTracks', []);
|
||||
stub('library.Library.GetAlbums', ALBUMS);
|
||||
stub('library.Library.GetTracks', []);
|
||||
emit(Events.LibraryScanComplete);
|
||||
});
|
||||
|
||||
|
||||
@@ -210,6 +210,17 @@ describe('an album the library holds part of', () => {
|
||||
});
|
||||
|
||||
it('draws the whole release, with the missing tracks dimmed', async () => {
|
||||
// Ownership is a *file*, not the catalog row's `inLibrary` flag —
|
||||
// nine of the twelve recordings resolve to a path, so three rows
|
||||
// dim. Stating it as flags is what let the page claim an album it
|
||||
// could not play a note of.
|
||||
stub(
|
||||
'library.Library.GetFilePathsByRecordingMBIDs',
|
||||
Object.fromEntries(
|
||||
Array.from({ length: 9 }, (_, i) => [`rec-${i + 1}`, [`/music/0${i + 1}.mp3`]]),
|
||||
),
|
||||
);
|
||||
|
||||
const el = await albumWith(
|
||||
[release('rel-1', '2019-04-01', 12, 9)],
|
||||
{ owned: 9, expected: 12, known: true, complete: false },
|
||||
|
||||
@@ -76,8 +76,8 @@ describe('the track list says how it is sorted', () => {
|
||||
beforeEach(async () => {
|
||||
resetHarness();
|
||||
searchStore.setTerm('');
|
||||
stub('library.Library.GetAllTracks', TRACKS);
|
||||
stub('library.Library.GetAllAlbums', []);
|
||||
stub('library.Library.GetTracks', TRACKS);
|
||||
stub('library.Library.GetAlbums', []);
|
||||
emit(Events.LibraryScanComplete);
|
||||
});
|
||||
|
||||
@@ -128,11 +128,11 @@ describe('the track list has a voice for its own state', () => {
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
searchStore.setTerm('');
|
||||
stub('library.Library.GetAllAlbums', []);
|
||||
stub('library.Library.GetAlbums', []);
|
||||
});
|
||||
|
||||
it('announces the result of a search that matches nothing', async () => {
|
||||
stub('library.Library.GetAllTracks', TRACKS);
|
||||
stub('library.Library.GetTracks', TRACKS);
|
||||
emit(Events.LibraryScanComplete);
|
||||
|
||||
const el = await fixture<LitElement>('track-list');
|
||||
@@ -161,10 +161,10 @@ describe('a selectable grid is a listbox, not a row of buttons', () => {
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
searchStore.setTerm('');
|
||||
stub('library.Library.GetAllArtists', ARTISTS);
|
||||
stub('library.Library.GetAllGenresWithCounts', GENRES);
|
||||
stub('library.Library.GetAllTracks', []);
|
||||
stub('library.Library.GetAllAlbums', []);
|
||||
stub('library.Library.GetArtists', ARTISTS);
|
||||
stub('library.Library.GetGenres', GENRES);
|
||||
stub('library.Library.GetTracks', []);
|
||||
stub('library.Library.GetAlbums', []);
|
||||
emit(Events.LibraryScanComplete);
|
||||
});
|
||||
|
||||
@@ -197,8 +197,8 @@ describe('a clipped value is readable somewhere', () => {
|
||||
beforeEach(async () => {
|
||||
resetHarness();
|
||||
searchStore.setTerm('');
|
||||
stub('library.Library.GetAllTracks', TRACKS);
|
||||
stub('library.Library.GetAllAlbums', []);
|
||||
stub('library.Library.GetTracks', TRACKS);
|
||||
stub('library.Library.GetAlbums', []);
|
||||
emit(Events.LibraryScanComplete);
|
||||
});
|
||||
|
||||
@@ -240,8 +240,8 @@ describe('the playing row is more than a colour', () => {
|
||||
beforeEach(async () => {
|
||||
resetHarness();
|
||||
searchStore.setTerm('');
|
||||
stub('library.Library.GetAllTracks', TRACKS);
|
||||
stub('library.Library.GetAllAlbums', []);
|
||||
stub('library.Library.GetTracks', TRACKS);
|
||||
stub('library.Library.GetAlbums', []);
|
||||
emit(Events.LibraryScanComplete);
|
||||
});
|
||||
|
||||
|
||||
@@ -56,10 +56,10 @@ async function settle(el: LitElement): Promise<void> {
|
||||
describe('a card grid shows its selection', () => {
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
stub('library.Library.GetAllArtists', ARTISTS);
|
||||
stub('library.Library.GetAllGenresWithCounts', GENRES);
|
||||
stub('library.Library.GetAllTracks', []);
|
||||
stub('library.Library.GetAllAlbums', []);
|
||||
stub('library.Library.GetArtists', ARTISTS);
|
||||
stub('library.Library.GetGenres', GENRES);
|
||||
stub('library.Library.GetTracks', []);
|
||||
stub('library.Library.GetAlbums', []);
|
||||
// The views read through LibraryController, whose cache is only
|
||||
// primed by a scan-complete; without it they render nothing and the
|
||||
// assertion below fails for the wrong reason.
|
||||
|
||||
@@ -128,7 +128,7 @@ describe('<library-filter>', () => {
|
||||
select?.dispatchEvent(new Event('change'));
|
||||
await flush();
|
||||
|
||||
expect(lastArgs('library.Library.GetAllTracksByLibrary')).toEqual([8]);
|
||||
expect(lastArgs('library.Library.GetTracks')).toEqual([8]);
|
||||
});
|
||||
|
||||
it('picks up a library added while it was on screen', async () => {
|
||||
|
||||
@@ -15,10 +15,10 @@ import { fixture, shadow, text } from '@test/support/render';
|
||||
/** Drop the library store's cache so the list has to fetch. */
|
||||
async function emptyLibrary(): Promise<void> {
|
||||
resetHarness();
|
||||
stub('library.Library.GetAllTracks', []);
|
||||
stub('library.Library.GetAllAlbums', []);
|
||||
stub('library.Library.GetAllArtists', []);
|
||||
stub('library.Library.GetAllGenresWithCounts', []);
|
||||
stub('library.Library.GetTracks', []);
|
||||
stub('library.Library.GetAlbums', []);
|
||||
stub('library.Library.GetArtists', []);
|
||||
stub('library.Library.GetGenres', []);
|
||||
emit(Events.LibraryScanComplete);
|
||||
await flush();
|
||||
}
|
||||
@@ -40,7 +40,7 @@ describe('<track-list> empty, loading and failed', () => {
|
||||
});
|
||||
|
||||
it('says the query failed, and offers to try again', async () => {
|
||||
stubFailure('library.Library.GetAllTracks', 'sql: database is locked');
|
||||
stubFailure('library.Library.GetTracks', 'sql: database is locked');
|
||||
emit(Events.LibraryScanComplete);
|
||||
await flush();
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* "Track Details" and "Add to Playlist" on Explore's owned tracks.
|
||||
*
|
||||
* The library-side lists have had this item for as long as the dialog
|
||||
* has existed; Explore's tracklists — the album page's and the artist
|
||||
* page's top tracks — had Play, Add to Queue and Play Next and stopped
|
||||
* there. The reason it is worth a test rather than being one more
|
||||
* `<wa-dropdown-item>` is that the two sides hold different things: a
|
||||
* library row *is* a `library.Track`, and an Explore row is the
|
||||
* catalog's, which can name a file only through its recording MBID.
|
||||
*
|
||||
* So what this pins is the join. Both items appear only for a track the
|
||||
* user owns (an unowned one has no file, and both are about a file):
|
||||
* Track Details resolves MBID → path → the library's own track and
|
||||
* hands *that* to the dialog, and Add to Playlist resolves the same
|
||||
* path and hands it to the shared picker.
|
||||
*/
|
||||
import type { LitElement } from 'lit';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import '@components/explore-album-details/explore-album-details';
|
||||
import { emit, flush, stub } from '@test/support/harness';
|
||||
import { Events } from '../../src/events';
|
||||
import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||
import { showTrackDetailsForPath } from '@utils/track-details-opener';
|
||||
import type { TrackDetails } from '@components/track-details/track-details';
|
||||
|
||||
const ALBUM_TRACKS = 'library.Library.GetAlbumTracks';
|
||||
const COMPLETENESS = 'library.Library.GetAlbumCompleteness';
|
||||
const FILE_PATHS = 'library.Library.GetFilePathsByRecordingMBIDs';
|
||||
const ALL_TRACKS = 'library.Library.GetTracks';
|
||||
const LOOKUP_RG = 'explore.Service.LookupReleaseGroup';
|
||||
const BROWSE_RELEASES = 'explore.Service.BrowseReleases';
|
||||
|
||||
const PATH = '/music/an-album/01.flac';
|
||||
const MBID = 'rec-1';
|
||||
|
||||
/** One row as `GetAlbumTracks` returns it. */
|
||||
const albumTrack = {
|
||||
ID: 1,
|
||||
FilePath: PATH,
|
||||
TrackName: 'A Song',
|
||||
TrackNumber: 1,
|
||||
DiscNumber: 1,
|
||||
TrackLength: '3:00',
|
||||
RecordingMBID: MBID,
|
||||
};
|
||||
|
||||
/** The same track as the library's own model, which is what the dialog wants. */
|
||||
const libraryTrack = {
|
||||
ID: 1,
|
||||
FilePath: PATH,
|
||||
Title: 'A Song',
|
||||
Artist: 'An Artist',
|
||||
Album: 'An Album',
|
||||
CoverArtPath: '/covers/a.jpg',
|
||||
CoverArtSmall: '/covers/a-64.jpg',
|
||||
CoverArtMedium: '/covers/a-256.jpg',
|
||||
CoverArtLarge: '/covers/a-512.jpg',
|
||||
};
|
||||
|
||||
/**
|
||||
* Mount the album page as a library-only album, which is the cheapest
|
||||
* route to a rendered tracklist: no MBID means it hydrates entirely
|
||||
* from `GetAlbumTracks` and asks the catalog nothing.
|
||||
*/
|
||||
async function albumPage() {
|
||||
return fixture('explore-album-details', { localAlbumId: 7 });
|
||||
}
|
||||
|
||||
/** Open the context menu on the first track row and return its items. */
|
||||
async function openTrackMenu(el: LitElement) {
|
||||
const row = shadow(el, '.track-row');
|
||||
|
||||
expect(row, 'a track row is rendered').not.toBeNull();
|
||||
|
||||
row!.dispatchEvent(
|
||||
new MouseEvent('contextmenu', { bubbles: true, cancelable: true }),
|
||||
);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
return shadowAll(el, '.context-menu-panel wa-dropdown-item');
|
||||
}
|
||||
|
||||
/** The track the page's `<track-details>` was opened on, once it has one. */
|
||||
async function dialogTrack(
|
||||
el: LitElement,
|
||||
attempts = 100,
|
||||
): Promise<{ FilePath: string } | null> {
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
const dialog = shadow<TrackDetails>(el, 'track-details') as unknown as {
|
||||
track?: { FilePath: string } | null;
|
||||
} | null;
|
||||
|
||||
if (dialog?.track) return dialog.track;
|
||||
|
||||
await flush();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The file paths handed to the playlist picker, once it is mounted. */
|
||||
async function pickerPaths(
|
||||
el: LitElement,
|
||||
attempts = 100,
|
||||
): Promise<string[] | null> {
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
const picker = shadow(el, 'playlist-picker') as unknown as {
|
||||
filePaths?: string[];
|
||||
} | null;
|
||||
|
||||
if (picker?.filePaths?.length) return picker.filePaths;
|
||||
|
||||
await flush();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const labels = (items: Element[]) =>
|
||||
items.map((i) => (i.textContent ?? '').trim());
|
||||
|
||||
describe('Explore track details', () => {
|
||||
beforeEach(() => {
|
||||
stub(ALBUM_TRACKS, [albumTrack]);
|
||||
stub(COMPLETENESS, { known: true, complete: true, owned: 1, expected: 1 });
|
||||
stub(FILE_PATHS, { [MBID]: [PATH] });
|
||||
stub(ALL_TRACKS, [libraryTrack]);
|
||||
});
|
||||
|
||||
/**
|
||||
* `libraryStore` fetches at import and caches the empty list the
|
||||
* shared setup stubs, for the life of the browser session — so a test
|
||||
* that wants tracks in it has to say so. A scan-complete event is how
|
||||
* the app itself invalidates that cache.
|
||||
*/
|
||||
async function primeLibrary() {
|
||||
emit(Events.LibraryScanComplete);
|
||||
await flush();
|
||||
}
|
||||
|
||||
it('offers Track Details on an owned track', async () => {
|
||||
const el = await albumPage();
|
||||
const items = await openTrackMenu(el);
|
||||
|
||||
expect(labels(items)).toContain('Track Details');
|
||||
});
|
||||
|
||||
it('does not offer it on a track the library does not have', async () => {
|
||||
// A catalog album the user owns nothing of: the tracklist renders
|
||||
// from the browse, and every row is unowned.
|
||||
stub(ALBUM_TRACKS, []);
|
||||
stub(LOOKUP_RG, {
|
||||
mbid: 'rg-1',
|
||||
title: 'An Album',
|
||||
artistCredit: 'An Artist',
|
||||
firstReleaseDate: '1994',
|
||||
primaryType: 'Album',
|
||||
});
|
||||
stub(BROWSE_RELEASES, [
|
||||
{
|
||||
mbid: 'rel-1',
|
||||
title: 'An Album',
|
||||
date: '1994',
|
||||
country: 'GB',
|
||||
tracks: [
|
||||
{
|
||||
mbid: 'rec-2',
|
||||
title: 'Another Song',
|
||||
position: 1,
|
||||
length: 180000,
|
||||
discNumber: 1,
|
||||
inLibrary: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const el = await fixture<LitElement>('explore-album-details', {
|
||||
releaseGroupMBID: 'rg-1',
|
||||
});
|
||||
const items = await openTrackMenu(el);
|
||||
|
||||
expect(labels(items)).not.toContain('Track Details');
|
||||
expect(
|
||||
labels(items).some((l) => l.startsWith('Add to Playlist')),
|
||||
'nothing to add to a playlist when there is no file',
|
||||
).toBe(false);
|
||||
// The one item a track nobody owns still has, which is what makes
|
||||
// the assertion above about the gate rather than about an empty
|
||||
// menu that never opened.
|
||||
expect(labels(items)).toContain('View on MusicBrainz');
|
||||
});
|
||||
|
||||
it('opens the dialog on the library track behind the row', async () => {
|
||||
await primeLibrary();
|
||||
|
||||
const el = await albumPage();
|
||||
const items = await openTrackMenu(el);
|
||||
const details = labels(items).indexOf('Track Details');
|
||||
|
||||
items[details]!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
||||
// Polled rather than counted: the opener is three awaits deep — the
|
||||
// path lookup, the store's tracks, and the dynamic `import()` of
|
||||
// the dialog chunk — and a chunk fetch is the one of the three
|
||||
// whose cost depends on what else the suite is doing.
|
||||
const shown = await dialogTrack(el);
|
||||
|
||||
expect(shown?.FilePath).toBe(PATH);
|
||||
});
|
||||
|
||||
it('opens the playlist submenu on the row’s own file', async () => {
|
||||
const el = await albumPage();
|
||||
const items = await openTrackMenu(el);
|
||||
// Its label carries the submenu arrow, so match the prefix.
|
||||
const add = labels(items).findIndex((l) => l.startsWith('Add to Playlist'));
|
||||
|
||||
expect(add, 'the submenu item is in the menu').toBeGreaterThan(-1);
|
||||
|
||||
items[add]!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
||||
// Resolved by MBID at the moment the submenu opens, so the picker
|
||||
// is not there on the first tick the way a library list's is.
|
||||
const picker = await pickerPaths(el);
|
||||
|
||||
expect(picker).toEqual([PATH]);
|
||||
});
|
||||
|
||||
it('reports a path with no library track rather than opening empty', async () => {
|
||||
stub(ALL_TRACKS, []);
|
||||
await primeLibrary();
|
||||
|
||||
const outcome = await showTrackDetailsForPath(
|
||||
() => undefined,
|
||||
PATH,
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
expect(outcome).toBe('not-in-library');
|
||||
});
|
||||
});
|
||||
@@ -132,7 +132,7 @@ describe('home view', () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(seen).toEqual([]);
|
||||
expect(lastArgs('library.Library.GetAlbumTracks')).toEqual([1]);
|
||||
expect(lastArgs('library.Library.GetAlbumTracks')).toEqual([1, 0]);
|
||||
expect(lastArgs('queue.Queue.SetQueue')).toEqual([
|
||||
['/music/1.mp3', '/music/2.mp3'],
|
||||
0,
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('<queue-panel> when closed', () => {
|
||||
|
||||
describe('<track-list> roving tabindex', () => {
|
||||
beforeEach(() => {
|
||||
stub('library.Library.GetAllTracks', TRACKS);
|
||||
stub('library.Library.GetTracks', TRACKS);
|
||||
});
|
||||
|
||||
it('offers exactly one tab stop, however many rows there are', async () => {
|
||||
|
||||
@@ -132,8 +132,8 @@ describe('<explore-view> badges', () => {
|
||||
stub('explore.Service.GetThumbnail', '');
|
||||
stub('explore.Service.GetArtistImageURL', '');
|
||||
stub('explore.Service.GetExploreShelves', { shelves: [], state: 'ready' });
|
||||
stub('library.Library.GetAllAlbums', []);
|
||||
stub('library.Library.GetAllTracks', []);
|
||||
stub('library.Library.GetAlbums', []);
|
||||
stub('library.Library.GetTracks', []);
|
||||
await withRequests([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* The phantom resolver showed the same library track twice.
|
||||
*
|
||||
* Its right-hand panel renders two lists one under the other — the
|
||||
* scored candidates and the library search results — and a search for
|
||||
* the obvious title returns exactly what scoring already found. So the
|
||||
* track appeared once with a score and once without, and double-clicking
|
||||
* either did the same thing.
|
||||
*
|
||||
* The second half is what a match *means*: one library file cannot stand
|
||||
* in for two unmatched tracks, or applying adds it to the playlist
|
||||
* twice. `FindPhantomMatches` has always claimed candidates on the
|
||||
* auto-match path; the manual path had no such rule.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import type { LitElement } from 'lit';
|
||||
|
||||
import '@components/phantom-resolver/phantom-resolver';
|
||||
import { flush, stub } from '@test/support/harness';
|
||||
import { fixture } from '@test/support/render';
|
||||
|
||||
interface Candidate {
|
||||
FilePath: string;
|
||||
Title: string;
|
||||
Artist: string;
|
||||
Album: string;
|
||||
Duration: string;
|
||||
Score: number;
|
||||
}
|
||||
|
||||
function candidate(
|
||||
path: string,
|
||||
title: string,
|
||||
score = 0.5,
|
||||
): Candidate {
|
||||
return {
|
||||
FilePath: path,
|
||||
Title: title,
|
||||
Artist: 'An Artist',
|
||||
Album: 'An Album',
|
||||
Duration: '200000',
|
||||
Score: score,
|
||||
};
|
||||
}
|
||||
|
||||
const PHANTOM_A = '/music/gone/one.mp3';
|
||||
const PHANTOM_B = '/music/gone/two.mp3';
|
||||
|
||||
/** Mount the dialog with two unmatched tracks and no auto-matches. */
|
||||
async function open(
|
||||
candidates: Candidate[],
|
||||
searchResults: Candidate[] = [],
|
||||
): Promise<HTMLElement & { updateComplete: Promise<unknown> }> {
|
||||
stub('playlist.Service.FindPhantomMatches', {
|
||||
AutoMatched: [],
|
||||
Unmatched: [PHANTOM_A, PHANTOM_B],
|
||||
});
|
||||
stub('playlist.Service.GetPhantomCandidates', candidates);
|
||||
stub('playlist.Service.SearchLibrary', searchResults);
|
||||
|
||||
const el = await fixture<
|
||||
LitElement & { show(id: number, tracks: unknown[]): void }
|
||||
>('phantom-resolver');
|
||||
|
||||
el.show(1, [
|
||||
{ FilePath: PHANTOM_A, Title: 'One', Phantom: true },
|
||||
{ FilePath: PHANTOM_B, Title: 'Two', Phantom: true },
|
||||
]);
|
||||
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
function candidateRows(el: HTMLElement): HTMLElement[] {
|
||||
return [
|
||||
...(el.shadowRoot?.querySelectorAll<HTMLElement>('.candidate-item') ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
/** The dialog renders the file path as each row's `title`. */
|
||||
function rowPaths(el: HTMLElement): string[] {
|
||||
return candidateRows(el).map((r) => r.getAttribute('title') ?? '');
|
||||
}
|
||||
|
||||
describe('the phantom resolver', () => {
|
||||
beforeEach(() => {
|
||||
stub('playlist.Service.ResolvePhantomTracks', null);
|
||||
stub('playlist.Service.RemovePhantomTracks', null);
|
||||
});
|
||||
|
||||
it('lists a search result the candidates already show only once', async () => {
|
||||
const shared = candidate('/music/have/one.mp3', 'One', 0.7);
|
||||
const el = await open([shared], [shared, candidate('/music/have/x.mp3', 'X', 0)]);
|
||||
|
||||
// Type into the search box and let the debounce fire.
|
||||
const input = el.shadowRoot?.querySelector<HTMLInputElement>(
|
||||
'.search-input',
|
||||
);
|
||||
|
||||
expect(input, 'the search box is rendered').toBeTruthy();
|
||||
|
||||
input!.value = 'one';
|
||||
input!.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
const paths = rowPaths(el);
|
||||
|
||||
expect(paths.filter((p) => p === shared.FilePath)).toHaveLength(1);
|
||||
expect(paths).toContain('/music/have/x.mp3');
|
||||
});
|
||||
|
||||
it('matches a candidate from the keyboard, not only a double-click', async () => {
|
||||
const el = await open([candidate('/music/have/one.mp3', 'One', 0.7)]);
|
||||
const row = candidateRows(el)[0];
|
||||
|
||||
expect(row, 'a candidate row is rendered').toBeTruthy();
|
||||
expect(row!.getAttribute('role')).toBe('button');
|
||||
expect(row!.getAttribute('tabindex')).toBe('0');
|
||||
|
||||
row!.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
||||
);
|
||||
await el.updateComplete;
|
||||
|
||||
// Matching the first phantom advances to the second, whose row
|
||||
// shows the check mark for the one just confirmed.
|
||||
const matched = el.shadowRoot?.querySelectorAll('.phantom-item.matched');
|
||||
|
||||
expect(matched).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('will not spend one library file on two unmatched tracks', async () => {
|
||||
const only = candidate('/music/have/one.mp3', 'One', 0.7);
|
||||
const el = await open([only]);
|
||||
|
||||
candidateRows(el)[0]!.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
||||
);
|
||||
await el.updateComplete;
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
// The second phantom is selected now and offered the same file,
|
||||
// which is already standing in for the first.
|
||||
const row = candidateRows(el)[0];
|
||||
|
||||
expect(row!.classList.contains('claimed')).toBe(true);
|
||||
expect(row!.getAttribute('aria-disabled')).toBe('true');
|
||||
|
||||
row!.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
||||
);
|
||||
await el.updateComplete;
|
||||
|
||||
// Still one confirmed match, not two.
|
||||
expect(
|
||||
el.shadowRoot?.querySelectorAll('.phantom-item.matched'),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('gives the auto-match disclosure a keyboard-reachable control', async () => {
|
||||
stub('playlist.Service.FindPhantomMatches', {
|
||||
AutoMatched: [
|
||||
{
|
||||
PhantomPath: PHANTOM_A,
|
||||
PhantomTitle: 'One',
|
||||
Candidate: candidate('/music/have/one.mp3', 'One', 0.95),
|
||||
},
|
||||
],
|
||||
Unmatched: [PHANTOM_B],
|
||||
});
|
||||
stub('playlist.Service.GetPhantomCandidates', []);
|
||||
|
||||
const el = await fixture<
|
||||
LitElement & { show(id: number, tracks: unknown[]): void }
|
||||
>('phantom-resolver');
|
||||
|
||||
el.show(1, [{ FilePath: PHANTOM_A, Title: 'One', Phantom: true }]);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
const header = el.shadowRoot?.querySelector('.auto-match-header');
|
||||
|
||||
expect(header?.tagName).toBe('BUTTON');
|
||||
expect(header?.getAttribute('aria-expanded')).toBe('false');
|
||||
|
||||
// aria-controls has to name an element that is in the DOM, so the
|
||||
// list renders collapsed rather than not at all.
|
||||
const controls = header?.getAttribute('aria-controls');
|
||||
|
||||
expect(controls).toBeTruthy();
|
||||
expect(el.shadowRoot?.getElementById(controls!)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Playing a track plays the list it is in.
|
||||
*
|
||||
* Double-clicking a row — or picking Play from its context menu — used
|
||||
* to call `SetQueue([thatOnePath], 0)` on the album page, in the track
|
||||
* list and in the two playlist views' menus: the queue became one
|
||||
* track, the rest of the album was discarded, and playback stopped at
|
||||
* the end of it. What a player means by activating a row is "start
|
||||
* here", and the here is a position in the list on screen.
|
||||
*
|
||||
* So the queue is the *displayed* list and `startIndex` is the row.
|
||||
* Two rules ride along and are what these tests are mostly for:
|
||||
*
|
||||
* - The album page's list is the tracks it has files for, so the index
|
||||
* is into that and not into the tracklist that includes the dimmed
|
||||
* rows — off-by-however-many-you-do-not-own is silent, it just plays
|
||||
* the wrong song.
|
||||
* - A menu asks how much is selected. One row means "from here"; an
|
||||
* explicit multi-row selection means play exactly those, which is
|
||||
* the one case where a queue of the selection is what was asked for.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import type { LitElement } from 'lit';
|
||||
|
||||
import '@components/explore-album-details/explore-album-details';
|
||||
import '@components/track-list/track-list';
|
||||
import '@components/playlist-details/playlist-details';
|
||||
import { stub, flush, resetHarness, lastArgs } from '@test/support/harness';
|
||||
import { fixture, shadowAll } from '@test/support/render';
|
||||
|
||||
/** What `Queue.SetQueue` was last asked to play, and from where. */
|
||||
function queued(): { paths: string[]; startIndex: number } {
|
||||
const args = lastArgs('queue.Queue.SetQueue');
|
||||
|
||||
if (!args) throw new Error('nothing was queued');
|
||||
|
||||
return {
|
||||
paths: args[0] as string[],
|
||||
startIndex: args[1] as number,
|
||||
};
|
||||
}
|
||||
|
||||
function dblclick(el: Element): void {
|
||||
el.dispatchEvent(
|
||||
new MouseEvent('dblclick', { bubbles: true, composed: true }),
|
||||
);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// The album page
|
||||
// =====================================================================
|
||||
|
||||
function albumTrack(n: number, owned: boolean) {
|
||||
return {
|
||||
position: n,
|
||||
discNumber: 1,
|
||||
title: `Track ${n}`,
|
||||
length: 200000,
|
||||
mbid: `mbid-${n}`,
|
||||
inLibrary: owned,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A release on the page without the network, owned every `nth` track.
|
||||
* The fixture says which tracks have *files*, because that is the one
|
||||
* question the page asks about ownership.
|
||||
*/
|
||||
async function album(total: number, ownedMbids: string[]): Promise<LitElement> {
|
||||
const el = await fixture<LitElement>('explore-album-details', {
|
||||
albumName: 'Glass Harbour',
|
||||
});
|
||||
|
||||
const tracks = Array.from({ length: total }, (_, i) =>
|
||||
albumTrack(i + 1, ownedMbids.includes(`mbid-${i + 1}`)),
|
||||
);
|
||||
|
||||
const paths: Record<string, string[]> = {};
|
||||
for (const mbid of ownedMbids) paths[mbid] = [`/music/${mbid}.mp3`];
|
||||
|
||||
stub('library.Library.GetFilePathsByRecordingMBIDs', paths);
|
||||
|
||||
Object.assign(el, {
|
||||
versionEntries: [
|
||||
{ key: 'v1', label: '2019', sublabel: `${total} tracks`, tracks },
|
||||
],
|
||||
selectedVersionKey: 'v1',
|
||||
loadingReleases: false,
|
||||
loadingInfo: false,
|
||||
});
|
||||
el.requestUpdate();
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
describe('double-clicking a track on the album page', () => {
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
stub('library.Library.GetFilePathsByAlbums', {});
|
||||
stub('library.Library.GetAlbumTracks', []);
|
||||
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
|
||||
});
|
||||
|
||||
it('queues the album and starts on that track', async () => {
|
||||
const el = await album(6, ['mbid-1', 'mbid-2', 'mbid-3', 'mbid-4', 'mbid-5', 'mbid-6']);
|
||||
|
||||
dblclick(shadowAll(el, '.track-row')[2]!);
|
||||
await flush();
|
||||
|
||||
expect(queued()).toEqual({
|
||||
paths: [1, 2, 3, 4, 5, 6].map((n) => `/music/mbid-${n}.mp3`),
|
||||
startIndex: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('indexes into what is owned, not into the rows on screen', async () => {
|
||||
// Tracks 2, 5 and 6 have files; the other three rows are dimmed and
|
||||
// are not in the queue at all. Row 5 is therefore the *second*
|
||||
// thing that will play, and an index taken from the row would start
|
||||
// this album past its end.
|
||||
const el = await album(6, ['mbid-2', 'mbid-5', 'mbid-6']);
|
||||
|
||||
dblclick(shadowAll(el, '.track-row')[4]!);
|
||||
await flush();
|
||||
|
||||
expect(queued()).toEqual({
|
||||
paths: ['/music/mbid-2.mp3', '/music/mbid-5.mp3', '/music/mbid-6.mp3'],
|
||||
startIndex: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('does nothing at all on a row with no file behind it', async () => {
|
||||
const el = await album(6, ['mbid-2']);
|
||||
|
||||
dblclick(shadowAll(el, '.track-row')[0]!);
|
||||
await flush();
|
||||
|
||||
expect(lastArgs('queue.Queue.SetQueue')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// The track list
|
||||
// =====================================================================
|
||||
|
||||
const LIST = Array.from({ length: 12 }, (_, i) => ({
|
||||
FilePath: `/music/track-${i}.mp3`,
|
||||
TrackName: `Track ${i}`,
|
||||
ArtistName: 'An Artist',
|
||||
Album: 'An Album',
|
||||
Duration: 180,
|
||||
})) as never[];
|
||||
|
||||
describe('double-clicking a row in the track list', () => {
|
||||
let el: LitElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
resetHarness();
|
||||
localStorage.removeItem('track-list-column-widths');
|
||||
|
||||
el = await fixture<LitElement>('track-list', { externalTracks: LIST });
|
||||
el.style.display = 'block';
|
||||
el.style.height = '600px';
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
});
|
||||
|
||||
it('queues the list as displayed and starts on that row', async () => {
|
||||
const rows = shadowAll(el, '.track-row');
|
||||
const row = rows.find((r) => r.getAttribute('data-index') === '3');
|
||||
|
||||
dblclick(row!);
|
||||
await flush();
|
||||
|
||||
const { paths, startIndex } = queued();
|
||||
|
||||
expect([paths.length, paths[startIndex]]).toEqual([
|
||||
12,
|
||||
'/music/track-3.mp3',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// A playlist's context menu
|
||||
// =====================================================================
|
||||
|
||||
function playlistTracks(n: number) {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
ID: i + 1,
|
||||
FilePath: `/music/track-${i}.mp3`,
|
||||
Title: `Track ${i}`,
|
||||
Artist: 'An Artist',
|
||||
Album: 'An Album',
|
||||
Duration: 180000,
|
||||
Phantom: false,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('Play from a playlist row’s context menu', () => {
|
||||
let el: LitElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
resetHarness();
|
||||
stub('playlist.Service.GetPlaylistTracks', playlistTracks(8));
|
||||
stub('playlist.Service.GetAllPlaylists', []);
|
||||
|
||||
el = await fixture<LitElement>('playlist-details', {
|
||||
playlistId: 1,
|
||||
playlistName: 'A playlist',
|
||||
});
|
||||
el.style.display = 'block';
|
||||
el.style.height = '600px';
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
});
|
||||
|
||||
/** Right-click a row, then click the menu's Play item. */
|
||||
async function playFromMenu(index: number): Promise<void> {
|
||||
const row = shadowAll(el, '.track-item').find(
|
||||
(r) => r.getAttribute('data-index') === String(index),
|
||||
);
|
||||
|
||||
row!.dispatchEvent(
|
||||
new MouseEvent('contextmenu', { bubbles: true, composed: true }),
|
||||
);
|
||||
await el.updateComplete;
|
||||
|
||||
const items = shadowAll<HTMLElement>(el, 'wa-dropdown-item');
|
||||
const play = items.find((i) => i.textContent?.trim().startsWith('Play') &&
|
||||
!i.textContent.includes('Next'));
|
||||
|
||||
play!.click();
|
||||
await flush();
|
||||
}
|
||||
|
||||
it('starts the playlist from the row that was clicked', async () => {
|
||||
await playFromMenu(5);
|
||||
|
||||
const { paths, startIndex } = queued();
|
||||
|
||||
expect([paths.length, startIndex]).toEqual([8, 5]);
|
||||
});
|
||||
|
||||
it('plays only the selection when several rows are selected', async () => {
|
||||
// The one case where a queue of the selection is what was asked
|
||||
// for: the user said which tracks, not where to start.
|
||||
const rows = shadowAll(el, '.track-item');
|
||||
const click = (i: number, modifiers: MouseEventInit) =>
|
||||
rows
|
||||
.find((r) => r.getAttribute('data-index') === String(i))!
|
||||
.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, composed: true, ...modifiers }),
|
||||
);
|
||||
|
||||
click(1, {});
|
||||
click(4, { ctrlKey: true });
|
||||
await el.updateComplete;
|
||||
|
||||
await playFromMenu(4);
|
||||
|
||||
expect(queued().paths).toEqual([
|
||||
'/music/track-1.mp3',
|
||||
'/music/track-4.mp3',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -117,10 +117,10 @@ const TAGS = [
|
||||
*/
|
||||
function stubEmptyBackend(): void {
|
||||
const emptyLists = [
|
||||
'library.Library.GetAllTracks',
|
||||
'library.Library.GetAllAlbums',
|
||||
'library.Library.GetAllArtists',
|
||||
'library.Library.GetAllGenresWithCounts',
|
||||
'library.Library.GetTracks',
|
||||
'library.Library.GetAlbums',
|
||||
'library.Library.GetArtists',
|
||||
'library.Library.GetGenres',
|
||||
'library.Library.GetAllLibrariesWithTrackCounts',
|
||||
'playlist.Service.GetAllPlaylists',
|
||||
'playlist.Service.GetAllPlaylistsWithTracks',
|
||||
|
||||
@@ -233,10 +233,10 @@ const CACHED_VIEWS = [
|
||||
* binding resolves undefined, which is not what Go sends. */
|
||||
function stubEmptyBackend(): void {
|
||||
for (const path of [
|
||||
'library.Library.GetAllTracks',
|
||||
'library.Library.GetAllAlbums',
|
||||
'library.Library.GetAllArtists',
|
||||
'library.Library.GetAllGenresWithCounts',
|
||||
'library.Library.GetTracks',
|
||||
'library.Library.GetAlbums',
|
||||
'library.Library.GetArtists',
|
||||
'library.Library.GetGenres',
|
||||
'library.Library.GetAllLibrariesWithTrackCounts',
|
||||
'playlist.Service.GetAllPlaylists',
|
||||
'playlist.Service.GetAllPlaylistsWithTracks',
|
||||
|
||||
@@ -35,10 +35,10 @@ const importTimeDefaults: Array<[string, unknown]> = [
|
||||
// libraryStore and playlistStore fetch eagerly at import. Left
|
||||
// unstubbed they would cache `undefined` — not the empty list Go
|
||||
// sends — and every consumer would then crash on `.length`.
|
||||
['library.Library.GetAllTracks', []],
|
||||
['library.Library.GetAllAlbums', []],
|
||||
['library.Library.GetAllArtists', []],
|
||||
['library.Library.GetAllGenresWithCounts', []],
|
||||
['library.Library.GetTracks', []],
|
||||
['library.Library.GetAlbums', []],
|
||||
['library.Library.GetArtists', []],
|
||||
['library.Library.GetGenres', []],
|
||||
['library.Library.GetAllLibrariesWithTrackCounts', []],
|
||||
['playlist.Service.GetAllPlaylistsWithTracks', []],
|
||||
];
|
||||
|
||||
@@ -33,16 +33,16 @@ const LIBRARIES = [{ id: 7, name: 'Music' }, { id: 8, name: 'Field' }];
|
||||
/** Stub every read binding the store can reach. Unstubbed bindings
|
||||
* resolve undefined, which the store would cache as if it were data. */
|
||||
function stubReads(): void {
|
||||
stub('library.Library.GetAllTracks', TRACKS);
|
||||
stub('library.Library.GetAllAlbums', ALBUMS);
|
||||
stub('library.Library.GetAllArtists', ARTISTS);
|
||||
stub('library.Library.GetAllGenresWithCounts', GENRES);
|
||||
stub('library.Library.GetAllTracksByLibrary', TRACKS);
|
||||
stub('library.Library.GetAllAlbumsByLibrary', ALBUMS);
|
||||
stub('library.Library.GetAllArtistsByLibrary', ARTISTS);
|
||||
stub('library.Library.GetAllGenresWithCountsByLibrary', GENRES);
|
||||
stub('library.Library.GetTracks', TRACKS);
|
||||
stub('library.Library.GetAlbums', ALBUMS);
|
||||
stub('library.Library.GetArtists', ARTISTS);
|
||||
stub('library.Library.GetGenres', GENRES);
|
||||
stub('library.Library.GetTracks', TRACKS);
|
||||
stub('library.Library.GetAlbums', ALBUMS);
|
||||
stub('library.Library.GetArtists', ARTISTS);
|
||||
stub('library.Library.GetGenres', GENRES);
|
||||
stub('library.Library.GetAlbumsByArtist', ALBUMS);
|
||||
stub('library.Library.GetAlbumsByArtist', ALBUMS);
|
||||
stub('library.Library.GetAlbumsByArtistByLibrary', ALBUMS);
|
||||
stub('library.Library.GetAllLibrariesWithTrackCounts', LIBRARIES);
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('library store: caching', () => {
|
||||
it('serves a second read from cache without touching the backend', async () => {
|
||||
await libraryStore.getTracks();
|
||||
|
||||
expect(calls('library.Library.GetAllTracks')).toHaveLength(0);
|
||||
expect(calls('library.Library.GetTracks')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('deduplicates concurrent first reads into one backend call', async () => {
|
||||
@@ -79,7 +79,7 @@ describe('library store: caching', () => {
|
||||
libraryStore.getArtists(),
|
||||
]);
|
||||
|
||||
expect([a, b, calls('library.Library.GetAllArtists').length]).toEqual([
|
||||
expect([a, b, calls('library.Library.GetArtists').length]).toEqual([
|
||||
ARTISTS,
|
||||
ARTISTS,
|
||||
1,
|
||||
@@ -100,10 +100,10 @@ describe('library store: caching', () => {
|
||||
await flush();
|
||||
|
||||
expect(calls().map((c) => c.path).sort()).toEqual([
|
||||
'library.Library.GetAllAlbums',
|
||||
'library.Library.GetAllArtists',
|
||||
'library.Library.GetAllGenresWithCounts',
|
||||
'library.Library.GetAllTracks',
|
||||
'library.Library.GetAlbums',
|
||||
'library.Library.GetArtists',
|
||||
'library.Library.GetGenres',
|
||||
'library.Library.GetTracks',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('library store: caching', () => {
|
||||
emit(Events.TrackMetadataChanged, { filePath: '/a.mp3' });
|
||||
await flush();
|
||||
|
||||
expect(calls('library.Library.GetAllTracks')).toHaveLength(1);
|
||||
expect(calls('library.Library.GetTracks')).toHaveLength(1);
|
||||
});
|
||||
|
||||
/*
|
||||
@@ -192,7 +192,7 @@ describe('library store: caching', () => {
|
||||
});
|
||||
|
||||
it('does not refetch the tracks', () => {
|
||||
expect(calls('library.Library.GetAllTracks')).toHaveLength(0);
|
||||
expect(calls('library.Library.GetTracks')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('splices the removed track out in place', () => {
|
||||
@@ -204,9 +204,9 @@ describe('library store: caching', () => {
|
||||
it('reloads the summaries, whose counts changed', () => {
|
||||
expect(
|
||||
[
|
||||
'library.Library.GetAllAlbums',
|
||||
'library.Library.GetAllArtists',
|
||||
'library.Library.GetAllGenresWithCounts',
|
||||
'library.Library.GetAlbums',
|
||||
'library.Library.GetArtists',
|
||||
'library.Library.GetGenres',
|
||||
].map((path) => calls(path).length),
|
||||
).toEqual([1, 1, 1]);
|
||||
});
|
||||
@@ -258,7 +258,7 @@ describe('library store: library filter', () => {
|
||||
libraryStore.setSelectedLibrary(7);
|
||||
await flush();
|
||||
|
||||
expect(lastArgs('library.Library.GetAllTracksByLibrary')).toEqual([7]);
|
||||
expect(lastArgs('library.Library.GetTracks')).toEqual([7]);
|
||||
});
|
||||
|
||||
it('ignores a redundant selection instead of invalidating', async () => {
|
||||
@@ -284,10 +284,10 @@ describe('library store: library filter', () => {
|
||||
|
||||
it('scopes an artist drill-down to the selected library', async () => {
|
||||
libraryStore.setSelectedLibrary(8);
|
||||
await libraryStore.getAlbumsByArtist(3);
|
||||
await libraryStore.getAlbumsByArtist('Artist');
|
||||
|
||||
expect(lastArgs('library.Library.GetAlbumsByArtistByLibrary')).toEqual([
|
||||
3, 8,
|
||||
expect(lastArgs('library.Library.GetAlbumsByArtist')).toEqual([
|
||||
'Artist', 8,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -309,7 +309,7 @@ describe('library store: a fetch that is overtaken', () => {
|
||||
// Only the track fetch is held open; the other three settle at once,
|
||||
// so the test is about the overtaking and nothing else.
|
||||
stub(
|
||||
'library.Library.GetAllTracksByLibrary',
|
||||
'library.Library.GetTracks',
|
||||
(id: number) =>
|
||||
new Promise((resolve) => {
|
||||
pending.push({ id, resolve });
|
||||
@@ -331,7 +331,7 @@ describe('library store: a fetch that is overtaken', () => {
|
||||
});
|
||||
|
||||
it('settles the waiters when the fetch they are waiting on fails', async () => {
|
||||
stubFailure('library.Library.GetAllTracks', 'sql: database is locked');
|
||||
stubFailure('library.Library.GetTracks', 'sql: database is locked');
|
||||
// Invalidation drops the cache and starts the fetch that fails.
|
||||
emit(Events.LibraryScanComplete);
|
||||
|
||||
@@ -447,7 +447,7 @@ describe('library store: albums by artist name', () => {
|
||||
});
|
||||
|
||||
it('filters the album cache by artist name', () => {
|
||||
stub('library.Library.GetAllAlbums', [...ALBUMS, ...OTHER_ALBUMS]);
|
||||
stub('library.Library.GetAlbums', [...ALBUMS, ...OTHER_ALBUMS]);
|
||||
|
||||
expect(libraryStore.getAlbumsByArtistNameCached('Artist')).toEqual(ALBUMS);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
import {
|
||||
describeQueueSource,
|
||||
isQueueSourceNavigable,
|
||||
navigateToQueueSource,
|
||||
} from '@utils/queue-source-link';
|
||||
import { libraryStore } from '@store/library-store';
|
||||
import type { QueueSource } from '@store/queue-store';
|
||||
import { Events } from '../../src/events';
|
||||
import { emit, flush, stub } from '@test/support/harness';
|
||||
|
||||
/**
|
||||
* The albums the library cache holds for these tests: one tagged, one
|
||||
* not, since which of the two an album is decides whether the album
|
||||
* page opens on the catalog or says it is library-only.
|
||||
*/
|
||||
const ALBUMS = [
|
||||
{ ID: 7, Name: 'Scary Monsters', ArtistName: 'David Bowie', MBID: 'rg-7' },
|
||||
{ ID: 8, Name: 'Untagged', ArtistName: 'Nobody', MBID: '' },
|
||||
];
|
||||
|
||||
/** Drop the cache and refill it, the way a scan completing does. */
|
||||
async function warmAlbumCache(): Promise<void> {
|
||||
stub('library.Library.GetAlbums', ALBUMS);
|
||||
emit(Events.LibraryScanComplete);
|
||||
await flush();
|
||||
await libraryStore.getAlbums();
|
||||
}
|
||||
|
||||
describe('describeQueueSource', () => {
|
||||
it('returns null for an empty source', () => {
|
||||
@@ -40,6 +61,10 @@ describe('isQueueSourceNavigable', () => {
|
||||
});
|
||||
|
||||
describe('navigateToQueueSource', () => {
|
||||
beforeEach(async () => {
|
||||
await warmAlbumCache();
|
||||
});
|
||||
|
||||
function fireOn(source: QueueSource): unknown {
|
||||
const target = document.createElement('div');
|
||||
let detail: unknown;
|
||||
@@ -53,10 +78,29 @@ describe('navigateToQueueSource', () => {
|
||||
return detail;
|
||||
}
|
||||
|
||||
it('builds the album navigate detail', () => {
|
||||
expect(fireOn({ type: 'album', id: 7, label: 'Scary Monsters' })).toEqual(
|
||||
{ view: 'explore-album-details', localAlbumId: 7, albumName: 'Scary Monsters' },
|
||||
);
|
||||
it('builds the album navigate detail, carrying the release group MBID', () => {
|
||||
expect(fireOn({ type: 'album', id: 7, label: 'Scary Monsters' })).toEqual({
|
||||
view: 'explore-album-details',
|
||||
localAlbumId: 7,
|
||||
albumName: 'Scary Monsters',
|
||||
releaseGroupMBID: 'rg-7',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the MBID for an untagged album, which really is library-only', () => {
|
||||
expect(fireOn({ type: 'album', id: 8, label: 'Untagged' })).toEqual({
|
||||
view: 'explore-album-details',
|
||||
localAlbumId: 8,
|
||||
albumName: 'Untagged',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the MBID for an album the cache does not know', () => {
|
||||
expect(fireOn({ type: 'album', id: 99, label: 'Gone' })).toEqual({
|
||||
view: 'explore-album-details',
|
||||
localAlbumId: 99,
|
||||
albumName: 'Gone',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds the playlist navigate detail', () => {
|
||||
|
||||
Reference in New Issue
Block a user