fix(library): open track details by path, not from the whole library
Track details from the queue, a playlist or a smart playlist looked the track up in libraryStore's whole-library array, and returned silently when it had not loaded, so the menu item did nothing. That dependency is also why every track was fetched eagerly at startup (20.5 MB at 26k tracks). GetTracksByPaths answers for the paths in hand, and trackCache holds the answers: bounded, coalesced into one call per task, and forgetting what the retag, removal and scan events name. Every opener, and track-details' own refresh after a save, go through it. A source sweep pins the whole-library array to the store and the Tracks view. Closes #279
This commit is contained in:
1 parent
3cddf70c4d
commit
4a9fe1d207
14 files changed
+746
-269
No files matched your search
@@ -117,6 +117,9 @@ WHERE library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), li
|
|||||||
-- name: GetTrackByPath :one
|
-- name: GetTrackByPath :one
|
||||||
SELECT * FROM track_metadata WHERE file_path = ? LIMIT 1;
|
SELECT * FROM track_metadata WHERE file_path = ? LIMIT 1;
|
||||||
|
|
||||||
|
-- name: GetTracksByPaths :many
|
||||||
|
SELECT * FROM track_metadata WHERE file_path IN (sqlc.slice('paths'));
|
||||||
|
|
||||||
-- name: GetTracksByAlbum :many
|
-- name: GetTracksByAlbum :many
|
||||||
SELECT * FROM track_metadata
|
SELECT * FROM track_metadata
|
||||||
WHERE album_id = sqlc.arg(album_id)
|
WHERE album_id = sqlc.arg(album_id)
|
||||||
|
|||||||
@@ -830,6 +830,71 @@ func (q *Queries) GetTracksByGenre(ctx context.Context, arg GetTracksByGenrePara
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getTracksByPaths = `-- name: GetTracksByPaths :many
|
||||||
|
SELECT id, file_path, length_milliseconds, title, artist_name, track_number, disc_number, album, genre, year, release_year, composer, file_type, sample_rate, bit_depth, channels, bitrate, file_size, library_id, play_count, last_played, cover_art_path, artist_mbid, release_group_mbid, recording_mbid, album_id, artist_id FROM track_metadata WHERE file_path IN (/*SLICE:paths*/?)
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetTracksByPaths(ctx context.Context, paths []string) ([]TrackMetadatum, error) {
|
||||||
|
query := getTracksByPaths
|
||||||
|
var queryParams []interface{}
|
||||||
|
if len(paths) > 0 {
|
||||||
|
for _, v := range paths {
|
||||||
|
queryParams = append(queryParams, v)
|
||||||
|
}
|
||||||
|
query = strings.Replace(query, "/*SLICE:paths*/?", strings.Repeat(",?", len(paths))[1:], 1)
|
||||||
|
} else {
|
||||||
|
query = strings.Replace(query, "/*SLICE:paths*/?", "NULL", 1)
|
||||||
|
}
|
||||||
|
rows, err := q.db.QueryContext(ctx, query, queryParams...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []TrackMetadatum
|
||||||
|
for rows.Next() {
|
||||||
|
var i TrackMetadatum
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.FilePath,
|
||||||
|
&i.LengthMilliseconds,
|
||||||
|
&i.Title,
|
||||||
|
&i.ArtistName,
|
||||||
|
&i.TrackNumber,
|
||||||
|
&i.DiscNumber,
|
||||||
|
&i.Album,
|
||||||
|
&i.Genre,
|
||||||
|
&i.Year,
|
||||||
|
&i.ReleaseYear,
|
||||||
|
&i.Composer,
|
||||||
|
&i.FileType,
|
||||||
|
&i.SampleRate,
|
||||||
|
&i.BitDepth,
|
||||||
|
&i.Channels,
|
||||||
|
&i.Bitrate,
|
||||||
|
&i.FileSize,
|
||||||
|
&i.LibraryID,
|
||||||
|
&i.PlayCount,
|
||||||
|
&i.LastPlayed,
|
||||||
|
&i.CoverArtPath,
|
||||||
|
&i.ArtistMbid,
|
||||||
|
&i.ReleaseGroupMbid,
|
||||||
|
&i.RecordingMbid,
|
||||||
|
&i.AlbumID,
|
||||||
|
&i.ArtistID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const lookupTrackMetaByPaths = `-- name: LookupTrackMetaByPaths :many
|
const lookupTrackMetaByPaths = `-- name: LookupTrackMetaByPaths :many
|
||||||
SELECT id, file_path, title, artist_name, album, cover_art_path,
|
SELECT id, file_path, title, artist_name, album, cover_art_path,
|
||||||
artist_mbid, release_group_mbid, recording_mbid
|
artist_mbid, release_group_mbid, recording_mbid
|
||||||
|
|||||||
@@ -193,6 +193,48 @@ func (l *Library) GetTracks(libraryID int64) ([]Track, error) {
|
|||||||
return tracksFromRows(rows), nil
|
return tracksFromRows(rows), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pathLookupChunk bounds the paths bound into one IN (...) query, well
|
||||||
|
// under SQLite's bind-variable limit.
|
||||||
|
const pathLookupChunk = 500
|
||||||
|
|
||||||
|
// GetTracksByPaths returns whole tracks for the given file paths, in
|
||||||
|
// the order asked, dropping any path that is not in the library.
|
||||||
|
//
|
||||||
|
// It is how the frontend resolves the tracks a surface is actually
|
||||||
|
// showing (#279). Track details from the queue, a playlist or a smart
|
||||||
|
// playlist used to look the path up in the whole library's track
|
||||||
|
// array, which had to be loaded first — so it was fetched eagerly at
|
||||||
|
// startup, 20.5 MB at 26k tracks, to answer questions about a handful
|
||||||
|
// of rows.
|
||||||
|
func (l *Library) GetTracksByPaths(paths []string) ([]Track, error) {
|
||||||
|
byPath := make(map[string]Track, len(paths))
|
||||||
|
|
||||||
|
for start := 0; start < len(paths); start += pathLookupChunk {
|
||||||
|
chunk := paths[start:min(start+pathLookupChunk, len(paths))]
|
||||||
|
|
||||||
|
rows, err := l.db.ReadQueries.GetTracksByPaths(l.ctx, chunk)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not get tracks by path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
byPath[row.FilePath] = trackFromRow(row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks := make([]Track, 0, len(byPath))
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
|
if t, ok := byPath[path]; ok {
|
||||||
|
tracks = append(tracks, t)
|
||||||
|
// A path asked twice is answered once.
|
||||||
|
delete(byPath, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SearchTracks runs the library's FTS index and returns whole tracks.
|
// SearchTracks runs the library's FTS index and returns whole tracks.
|
||||||
func (l *Library) SearchTracks(query string, libraryID int64) ([]Track, error) {
|
func (l *Library) SearchTracks(query string, libraryID int64) ([]Track, error) {
|
||||||
rows, err := l.db.SearchFTSTracks(query, libraryID, searchTrackLimit)
|
rows, err := l.db.SearchFTSTracks(query, libraryID, searchTrackLimit)
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #279: the track-details openers resolve the rows a surface is showing
|
||||||
|
// by path, instead of finding them in the whole library's array. The
|
||||||
|
// answer must keep the caller's order (a batch dialog lists them as
|
||||||
|
// selected), drop what is not in the library rather than invent it, and
|
||||||
|
// survive more paths than one IN (...) can bind.
|
||||||
|
func TestGetTracksByPaths(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lib, _ := setupTestLibrary(t)
|
||||||
|
seedAlbumsAndGenres(t, lib)
|
||||||
|
|
||||||
|
got, err := lib.GetTracksByPaths([]string{
|
||||||
|
"/other/b1.mp3", "/music/missing.mp3", "/music/a1.mp3", "/other/b1.mp3",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTracksByPaths: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
paths := make([]string, 0, len(got))
|
||||||
|
for _, tr := range got {
|
||||||
|
paths = append(paths, tr.FilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if want := "[/other/b1.mp3 /music/a1.mp3]"; fmt.Sprint(paths) != want {
|
||||||
|
t.Fatalf("paths = %v, want %s (caller order, missing dropped, duplicate once)", paths, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whole tracks, not just the key: details render every field.
|
||||||
|
if got[1].TrackName != "A1" || len(got[1].Genre) != 2 {
|
||||||
|
t.Errorf("a1 = %+v, want title A1 with two genres", got[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTracksByPathsSpansChunks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lib, _ := setupTestLibrary(t)
|
||||||
|
seedAlbumsAndGenres(t, lib)
|
||||||
|
|
||||||
|
// The real path last, behind more misses than one chunk binds, so a
|
||||||
|
// loop that only ran the first chunk would return nothing.
|
||||||
|
paths := make([]string, 0, pathLookupChunk+2)
|
||||||
|
for i := range pathLookupChunk + 1 {
|
||||||
|
paths = append(paths, fmt.Sprintf("/nowhere/%d.mp3", i))
|
||||||
|
}
|
||||||
|
|
||||||
|
paths = append(paths, "/music/a2.mp3")
|
||||||
|
|
||||||
|
got, err := lib.GetTracksByPaths(paths)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTracksByPaths: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != 1 || got[0].FilePath != "/music/a2.mp3" {
|
||||||
|
t.Fatalf("got %d tracks (%v), want only /music/a2.mp3", len(got), got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -222,6 +222,21 @@ export function GetTracksByGenre(genre: string, libraryID: number): $Cancellable
|
|||||||
return $Call.ByID(1674220245, genre, libraryID);
|
return $Call.ByID(1674220245, genre, libraryID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GetTracksByPaths returns whole tracks for the given file paths, in
|
||||||
|
* the order asked, dropping any path that is not in the library.
|
||||||
|
*
|
||||||
|
* It is how the frontend resolves the tracks a surface is actually
|
||||||
|
* showing (#279). Track details from the queue, a playlist or a smart
|
||||||
|
* playlist used to look the path up in the whole library's track
|
||||||
|
* array, which had to be loaded first — so it was fetched eagerly at
|
||||||
|
* startup, 20.5 MB at 26k tracks, to answer questions about a handful
|
||||||
|
* of rows.
|
||||||
|
*/
|
||||||
|
export function GetTracksByPaths(paths: string[] | null): $CancellablePromise<$models.Track[] | null> {
|
||||||
|
return $Call.ByID(3966945290, paths);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IsScanActive returns whether a scan is currently running.
|
* IsScanActive returns whether a scan is currently running.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -56,12 +56,9 @@ import {
|
|||||||
createTrackCardDragImage,
|
createTrackCardDragImage,
|
||||||
removeDragImage,
|
removeDragImage,
|
||||||
} from '@utils/drag-image';
|
} from '@utils/drag-image';
|
||||||
import { libraryStore } from '@store/library-store';
|
|
||||||
import '@components/playlist-picker/playlist-picker.js';
|
import '@components/playlist-picker/playlist-picker.js';
|
||||||
import { loadTrackDetails } from '@utils/lazy-track-details.js';
|
import { showTrackDetailsForPath, showBatchTrackDetailsForPaths } from '@utils/track-details-opener.js';
|
||||||
import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
|
|
||||||
import type { TrackDetails } from '@components/track-details/track-details.js';
|
import type { TrackDetails } from '@components/track-details/track-details.js';
|
||||||
import type { CoverArtUrls } from '@components/track-details/track-details.js';
|
|
||||||
import '@components/phantom-resolver/phantom-resolver.js';
|
import '@components/phantom-resolver/phantom-resolver.js';
|
||||||
import type { PhantomResolver } from '@components/phantom-resolver/phantom-resolver.js';
|
import type { PhantomResolver } from '@components/phantom-resolver/phantom-resolver.js';
|
||||||
import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
|
import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
|
||||||
@@ -758,76 +755,21 @@ export class PlaylistDetails
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async openTrackDetails(filePath: string) {
|
private async openTrackDetails(filePath: string) {
|
||||||
const tracks = libraryStore.getCachedTracks();
|
await showTrackDetailsForPath(
|
||||||
const track = tracks
|
() => this.trackDetailsDialog,
|
||||||
? tracksByFilePath(tracks).get(filePath)
|
filePath,
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (!track) return;
|
|
||||||
|
|
||||||
const ready = await loadTrackDetails(
|
|
||||||
() => void this.openTrackDetails(filePath),
|
() => void this.openTrackDetails(filePath),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!ready) return;
|
|
||||||
|
|
||||||
const coverArt = track.CoverArtPath
|
|
||||||
? {
|
|
||||||
coverArtPath: track.CoverArtPath,
|
|
||||||
coverArtSmall: track.CoverArtSmall,
|
|
||||||
coverArtMedium: track.CoverArtMedium,
|
|
||||||
coverArtLarge: track.CoverArtLarge,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
this.trackDetailsDialog?.show(
|
|
||||||
track,
|
|
||||||
coverArt,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async openBatchTrackDetails(
|
private async openBatchTrackDetails(
|
||||||
filePaths: string[],
|
filePaths: string[],
|
||||||
) {
|
) {
|
||||||
const cachedTracks =
|
await showBatchTrackDetailsForPaths(
|
||||||
libraryStore.getCachedTracks();
|
() => this.trackDetailsDialog,
|
||||||
|
|
||||||
if (!cachedTracks) return;
|
|
||||||
|
|
||||||
const tracks = tracksForPaths(
|
|
||||||
cachedTracks,
|
|
||||||
filePaths,
|
filePaths,
|
||||||
);
|
|
||||||
|
|
||||||
if (tracks.length === 0) return;
|
|
||||||
|
|
||||||
const ready = await loadTrackDetails(
|
|
||||||
() => void this.openBatchTrackDetails(filePaths),
|
() => void this.openBatchTrackDetails(filePaths),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!ready) return;
|
|
||||||
|
|
||||||
const first = tracks[0]!;
|
|
||||||
const albumNames = new Set(tracks.map((t) => t.Album));
|
|
||||||
let coverArt: CoverArtUrls | null = null;
|
|
||||||
let coverArtMixed = false;
|
|
||||||
|
|
||||||
if (albumNames.size === 1 && first.CoverArtPath) {
|
|
||||||
coverArt = {
|
|
||||||
coverArtPath: first.CoverArtPath,
|
|
||||||
coverArtSmall: first.CoverArtSmall,
|
|
||||||
coverArtMedium: first.CoverArtMedium,
|
|
||||||
coverArtLarge: first.CoverArtLarge,
|
|
||||||
};
|
|
||||||
} else if (albumNames.size > 1) {
|
|
||||||
coverArtMixed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.trackDetailsDialog?.showBatch(
|
|
||||||
tracks,
|
|
||||||
coverArt,
|
|
||||||
coverArtMixed,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -51,12 +51,8 @@ import {
|
|||||||
createTrackCardDragImage,
|
createTrackCardDragImage,
|
||||||
removeDragImage,
|
removeDragImage,
|
||||||
} from '@utils/drag-image';
|
} from '@utils/drag-image';
|
||||||
import { libraryStore } from '@store/library-store';
|
import { showTrackDetailsForPath, showBatchTrackDetailsForPaths } from '@utils/track-details-opener.js';
|
||||||
import type * as library from '@go/library/models.js';
|
|
||||||
import { loadTrackDetails } from '@utils/lazy-track-details.js';
|
|
||||||
import { tracksByFilePath } from '@utils/track-index.js';
|
|
||||||
import type { TrackDetails } from '@components/track-details/track-details.js';
|
import type { TrackDetails } from '@components/track-details/track-details.js';
|
||||||
import type { CoverArtUrls } from '@components/track-details/track-details.js';
|
|
||||||
import {
|
import {
|
||||||
creditLink,
|
creditLink,
|
||||||
trackLink,
|
trackLink,
|
||||||
@@ -1542,90 +1538,30 @@ export class QueuePanel
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async openTrackDetails(index: number) {
|
private async openTrackDetails(index: number) {
|
||||||
const queueTrack =
|
const queueTrack = this.queue.tracks[index];
|
||||||
this.queue.tracks[index];
|
|
||||||
|
|
||||||
if (!queueTrack) return;
|
if (!queueTrack) return;
|
||||||
|
|
||||||
const tracks =
|
await showTrackDetailsForPath(
|
||||||
libraryStore.getCachedTracks();
|
() => this.trackDetailsDialog,
|
||||||
const track = tracks
|
|
||||||
? tracksByFilePath(tracks).get(
|
|
||||||
queueTrack.filePath,
|
queueTrack.filePath,
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (!track) return;
|
|
||||||
|
|
||||||
const ready = await loadTrackDetails(
|
|
||||||
() => void this.openTrackDetails(index),
|
() => void this.openTrackDetails(index),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!ready) return;
|
|
||||||
|
|
||||||
const coverArt = track.CoverArtPath
|
|
||||||
? {
|
|
||||||
coverArtPath: track.CoverArtPath,
|
|
||||||
coverArtSmall: track.CoverArtSmall,
|
|
||||||
coverArtMedium: track.CoverArtMedium,
|
|
||||||
coverArtLarge: track.CoverArtLarge,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
this.trackDetailsDialog?.show(
|
|
||||||
track,
|
|
||||||
coverArt,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async openBatchTrackDetails(
|
private async openBatchTrackDetails(
|
||||||
indices: number[],
|
indices: number[],
|
||||||
) {
|
) {
|
||||||
const queueTracks = this.queue.tracks;
|
const queueTracks = this.queue.tracks;
|
||||||
const cachedTracks =
|
const filePaths = indices
|
||||||
libraryStore.getCachedTracks();
|
.map((i) => queueTracks[i]?.filePath)
|
||||||
|
.filter((fp): fp is string => fp != null);
|
||||||
|
|
||||||
if (!cachedTracks) return;
|
await showBatchTrackDetailsForPaths(
|
||||||
|
() => this.trackDetailsDialog,
|
||||||
const byPath = tracksByFilePath(cachedTracks);
|
filePaths,
|
||||||
const tracks = indices
|
|
||||||
.map((i) => queueTracks[i])
|
|
||||||
.filter((qt) => qt != null)
|
|
||||||
.map((qt) => byPath.get(qt.filePath))
|
|
||||||
.filter(
|
|
||||||
(t): t is library.Track =>
|
|
||||||
t != null,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (tracks.length === 0) return;
|
|
||||||
|
|
||||||
const ready = await loadTrackDetails(
|
|
||||||
() => void this.openBatchTrackDetails(indices),
|
() => void this.openBatchTrackDetails(indices),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!ready) return;
|
|
||||||
|
|
||||||
const first = tracks[0]!;
|
|
||||||
const albumNames = new Set(tracks.map((t) => t.Album));
|
|
||||||
let coverArt: CoverArtUrls | null = null;
|
|
||||||
let coverArtMixed = false;
|
|
||||||
|
|
||||||
if (albumNames.size === 1 && first.CoverArtPath) {
|
|
||||||
coverArt = {
|
|
||||||
coverArtPath: first.CoverArtPath,
|
|
||||||
coverArtSmall: first.CoverArtSmall,
|
|
||||||
coverArtMedium: first.CoverArtMedium,
|
|
||||||
coverArtLarge: first.CoverArtLarge,
|
|
||||||
};
|
|
||||||
} else if (albumNames.size > 1) {
|
|
||||||
coverArtMixed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.trackDetailsDialog?.showBatch(
|
|
||||||
tracks,
|
|
||||||
coverArt,
|
|
||||||
coverArtMixed,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private onContextPlaylistActionComplete = () => {
|
private onContextPlaylistActionComplete = () => {
|
||||||
|
|||||||
@@ -52,11 +52,8 @@ import '@lit-labs/virtualizer';
|
|||||||
import type { LitVirtualizer } from '@lit-labs/virtualizer';
|
import type { LitVirtualizer } from '@lit-labs/virtualizer';
|
||||||
import { flow } from '@lit-labs/virtualizer/layouts/flow.js';
|
import { flow } from '@lit-labs/virtualizer/layouts/flow.js';
|
||||||
import '@components/playlist-picker/playlist-picker.js';
|
import '@components/playlist-picker/playlist-picker.js';
|
||||||
import { loadTrackDetails } from '@utils/lazy-track-details.js';
|
import { showTrackDetailsForPath, showBatchTrackDetailsForPaths } from '@utils/track-details-opener.js';
|
||||||
import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
|
|
||||||
import type { TrackDetails } from '@components/track-details/track-details.js';
|
import type { TrackDetails } from '@components/track-details/track-details.js';
|
||||||
import type { CoverArtUrls } from '@components/track-details/track-details.js';
|
|
||||||
import { libraryStore } from '@store/library-store';
|
|
||||||
import { formatMilliseconds } from '@utils/time';
|
import { formatMilliseconds } from '@utils/time';
|
||||||
import {
|
import {
|
||||||
creditLink,
|
creditLink,
|
||||||
@@ -1145,76 +1142,21 @@ export class SmartPlaylistDetails
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async openTrackDetails(filePath: string) {
|
private async openTrackDetails(filePath: string) {
|
||||||
const tracks = libraryStore.getCachedTracks();
|
await showTrackDetailsForPath(
|
||||||
const track = tracks
|
() => this.trackDetailsDialog,
|
||||||
? tracksByFilePath(tracks).get(filePath)
|
filePath,
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (!track) return;
|
|
||||||
|
|
||||||
const ready = await loadTrackDetails(
|
|
||||||
() => void this.openTrackDetails(filePath),
|
() => void this.openTrackDetails(filePath),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!ready) return;
|
|
||||||
|
|
||||||
const coverArt = track.CoverArtPath
|
|
||||||
? {
|
|
||||||
coverArtPath: track.CoverArtPath,
|
|
||||||
coverArtSmall: track.CoverArtSmall,
|
|
||||||
coverArtMedium: track.CoverArtMedium,
|
|
||||||
coverArtLarge: track.CoverArtLarge,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
this.trackDetailsDialog?.show(
|
|
||||||
track,
|
|
||||||
coverArt,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async openBatchTrackDetails(
|
private async openBatchTrackDetails(
|
||||||
filePaths: string[],
|
filePaths: string[],
|
||||||
) {
|
) {
|
||||||
const cachedTracks =
|
await showBatchTrackDetailsForPaths(
|
||||||
libraryStore.getCachedTracks();
|
() => this.trackDetailsDialog,
|
||||||
|
|
||||||
if (!cachedTracks) return;
|
|
||||||
|
|
||||||
const tracks = tracksForPaths(
|
|
||||||
cachedTracks,
|
|
||||||
filePaths,
|
filePaths,
|
||||||
);
|
|
||||||
|
|
||||||
if (tracks.length === 0) return;
|
|
||||||
|
|
||||||
const ready = await loadTrackDetails(
|
|
||||||
() => void this.openBatchTrackDetails(filePaths),
|
() => void this.openBatchTrackDetails(filePaths),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!ready) return;
|
|
||||||
|
|
||||||
const first = tracks[0]!;
|
|
||||||
const albumNames = new Set(tracks.map((t) => t.Album));
|
|
||||||
let coverArt: CoverArtUrls | null = null;
|
|
||||||
let coverArtMixed = false;
|
|
||||||
|
|
||||||
if (albumNames.size === 1 && first.CoverArtPath) {
|
|
||||||
coverArt = {
|
|
||||||
coverArtPath: first.CoverArtPath,
|
|
||||||
coverArtSmall: first.CoverArtSmall,
|
|
||||||
coverArtMedium: first.CoverArtMedium,
|
|
||||||
coverArtLarge: first.CoverArtLarge,
|
|
||||||
};
|
|
||||||
} else if (albumNames.size > 1) {
|
|
||||||
coverArtMixed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.trackDetailsDialog?.showBatch(
|
|
||||||
tracks,
|
|
||||||
coverArt,
|
|
||||||
coverArtMixed,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ import {
|
|||||||
import { GetTrackMBIDs } from '@go/library/library.js';
|
import { GetTrackMBIDs } from '@go/library/library.js';
|
||||||
type TrackMBIDs = library.TrackMBIDs;
|
type TrackMBIDs = library.TrackMBIDs;
|
||||||
import { ImageFilePicker, ReadFile } from '@go/frontendutil/frontendutil.js';
|
import { ImageFilePicker, ReadFile } from '@go/frontendutil/frontendutil.js';
|
||||||
import { libraryStore } from '../../store/library-store';
|
import { trackCache } from '../../store/track-cache';
|
||||||
|
import { batchCoverArt } from '@utils/track-details-opener.js';
|
||||||
import { EventsOn } from '@runtime/runtime';
|
import { EventsOn } from '@runtime/runtime';
|
||||||
import { Events } from '../../events';
|
import { Events } from '../../events';
|
||||||
|
|
||||||
@@ -1317,10 +1318,13 @@ export class TrackDetails extends LitElement {
|
|||||||
const container = img.parentElement;
|
const container = img.parentElement;
|
||||||
|
|
||||||
if (container) {
|
if (container) {
|
||||||
container.innerHTML =
|
const placeholder = document.createElement('div');
|
||||||
'<div class="cover-placeholder">' +
|
const icon = document.createElement('wa-icon');
|
||||||
'<wa-icon name="music"></wa-icon>' +
|
|
||||||
'</div>';
|
placeholder.className = 'cover-placeholder';
|
||||||
|
icon.setAttribute('name', 'music');
|
||||||
|
placeholder.append(icon);
|
||||||
|
container.replaceChildren(placeholder);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1690,20 +1694,11 @@ export class TrackDetails extends LitElement {
|
|||||||
// invalidation, which refreshes all other views.
|
// invalidation, which refreshes all other views.
|
||||||
this.exitEditMode();
|
this.exitEditMode();
|
||||||
|
|
||||||
// Re-fetch track and album data so the dialog
|
// Re-read this one track so the dialog shows the
|
||||||
// shows updated values and cover art. The store
|
// values and cover art just written. Not the whole
|
||||||
// invalidation is already in-flight from the event;
|
// library (#279): the views that hold it refetch on
|
||||||
// these calls await the pending fetch or start one.
|
// the event, and only if something is showing them.
|
||||||
// Awaited for the side effect of refreshing the
|
const [updated] = await trackCache.refresh([filePath]);
|
||||||
// store; the album list is consumed elsewhere.
|
|
||||||
const [tracks] = await Promise.all([
|
|
||||||
libraryStore.getTracks(),
|
|
||||||
libraryStore.getAlbums(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const updated = tracks.find(
|
|
||||||
(t) => t.FilePath === filePath,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (updated) {
|
if (updated) {
|
||||||
this.track = updated;
|
this.track = updated;
|
||||||
@@ -1828,39 +1823,18 @@ export class TrackDetails extends LitElement {
|
|||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
this.cleanupPendingCoverArt();
|
this.cleanupPendingCoverArt();
|
||||||
|
|
||||||
// Refresh data from library store; album list is
|
// Re-read the tracks just written, in the order the batch
|
||||||
// refreshed for side effects only.
|
// holds them (#279) — not the whole library to filter.
|
||||||
const [tracks] = await Promise.all([
|
// `refresh`, because the write's event may not have reached
|
||||||
libraryStore.getTracks(),
|
// the cache before its own result did.
|
||||||
libraryStore.getAlbums(),
|
const refreshed = await trackCache.refresh(this.batchFilePaths);
|
||||||
]);
|
|
||||||
|
|
||||||
// Re-resolve batch tracks.
|
|
||||||
const pathSet = new Set(this.batchFilePaths);
|
|
||||||
const refreshed = tracks.filter((t) =>
|
|
||||||
pathSet.has(t.FilePath),
|
|
||||||
);
|
|
||||||
this.batchTracks = refreshed;
|
this.batchTracks = refreshed;
|
||||||
|
|
||||||
// Re-resolve cover art state.
|
const { coverArt, mixed } = batchCoverArt(refreshed);
|
||||||
const first = refreshed[0];
|
|
||||||
const albumNames = new Set(refreshed.map((t) => t.Album));
|
|
||||||
|
|
||||||
if (albumNames.size === 1 && first?.CoverArtPath) {
|
this.coverArt = coverArt;
|
||||||
this.coverArt = {
|
this.batchCoverArtMixed = mixed;
|
||||||
coverArtPath: first.CoverArtPath,
|
|
||||||
coverArtSmall: first.CoverArtSmall,
|
|
||||||
coverArtMedium: first.CoverArtMedium,
|
|
||||||
coverArtLarge: first.CoverArtLarge,
|
|
||||||
};
|
|
||||||
this.batchCoverArtMixed = false;
|
|
||||||
} else if (albumNames.size > 1) {
|
|
||||||
this.coverArt = null;
|
|
||||||
this.batchCoverArtMixed = true;
|
|
||||||
} else {
|
|
||||||
this.coverArt = null;
|
|
||||||
this.batchCoverArtMixed = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// -- Shared edit logic --
|
// -- Shared edit logic --
|
||||||
@@ -2103,6 +2077,8 @@ export class TrackDetails extends LitElement {
|
|||||||
// as a base64-encoded string (standard encoding/json
|
// as a base64-encoded string (standard encoding/json
|
||||||
// behaviour for []byte).
|
// behaviour for []byte).
|
||||||
const result = await ReadFile(filePath);
|
const result = await ReadFile(filePath);
|
||||||
|
// SAFETY: the binding is typed as the Go []byte, but the
|
||||||
|
// wire value is the base64 string encoding/json made of it.
|
||||||
const b64 = result as unknown as string;
|
const b64 = result as unknown as string;
|
||||||
const binary = atob(b64);
|
const binary = atob(b64);
|
||||||
const bytes = new Uint8Array(binary.length);
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* Whole tracks, looked up by file path, for the rows a surface is
|
||||||
|
* actually showing (#279).
|
||||||
|
*
|
||||||
|
* Track details from the queue, a playlist or a smart playlist used to
|
||||||
|
* find their track in `libraryStore`'s whole-library array. That made
|
||||||
|
* the array a dependency of every surface that can open details, so it
|
||||||
|
* was fetched eagerly at startup — 20.5 MB of JSON at 26 138 tracks —
|
||||||
|
* and when it had *not* landed yet, the openers that read it
|
||||||
|
* synchronously did nothing at all. This asks the backend for the
|
||||||
|
* paths in hand instead.
|
||||||
|
*
|
||||||
|
* Three things are load-bearing, the same three as `credit-store`:
|
||||||
|
*
|
||||||
|
* **Lookups are coalesced.** Every `get()` made in the same task joins
|
||||||
|
* one `GetTracksByPaths` call. A timer rather than a frame: these are
|
||||||
|
* user actions, not row renders, and a frame never fires in a hidden
|
||||||
|
* window, which would leave the caller waiting on nothing.
|
||||||
|
*
|
||||||
|
* **It is bounded.** A batch details dialog over "Select all" asks for
|
||||||
|
* every track in the library; it gets every one of them back, but the
|
||||||
|
* cache keeps only the most recent `TRACK_CACHE_LIMIT`.
|
||||||
|
*
|
||||||
|
* **It forgets what changed.** The events that make `library-store`
|
||||||
|
* refetch drop the affected entries here, and a play count is patched
|
||||||
|
* in place, so a cached track is never older than the last event about
|
||||||
|
* it. An answer that was in flight across an invalidation is still
|
||||||
|
* returned to its caller (it was correct when asked) but not cached.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventsOn } from '@runtime/runtime';
|
||||||
|
import { GetTracksByPaths } from '@go/library/library.js';
|
||||||
|
import type * as library from '@go/library/models.js';
|
||||||
|
import { list } from '@utils/binding';
|
||||||
|
import { LRUMap } from '@utils/lru-map';
|
||||||
|
import { registerCacheProbe } from '@utils/cache-stats';
|
||||||
|
import { Events } from '../events';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks retained. A track is ~25 short fields, so this is a couple of
|
||||||
|
* megabytes at most — sized above any batch a person edits by hand,
|
||||||
|
* far below a library.
|
||||||
|
*/
|
||||||
|
export const TRACK_CACHE_LIMIT = 2_000;
|
||||||
|
|
||||||
|
interface Waiter {
|
||||||
|
paths: readonly string[];
|
||||||
|
resolve: (tracks: library.Track[]) => void;
|
||||||
|
reject: (err: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TrackCache {
|
||||||
|
private cache = new LRUMap<string, library.Track>(TRACK_CACHE_LIMIT);
|
||||||
|
|
||||||
|
/** Requests collected in this task, answered by one binding call. */
|
||||||
|
private waiting: Waiter[] = [];
|
||||||
|
|
||||||
|
private flushHandle: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
/** Bumped by every invalidation; an older answer is not cached. */
|
||||||
|
private gen = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
EventsOn(Events.LibraryScanComplete, () => this.clear());
|
||||||
|
EventsOn(Events.LibraryRemoved, () => this.clear());
|
||||||
|
EventsOn(Events.TrackMetadataChanged, (payload: unknown) => {
|
||||||
|
const p = payload as { filePath?: string } | null;
|
||||||
|
|
||||||
|
// A batch write names no paths: forget everything.
|
||||||
|
if (p?.filePath) this.forget([p.filePath]);
|
||||||
|
else this.clear();
|
||||||
|
});
|
||||||
|
EventsOn(Events.TracksRemovedFromLibrary, (payload: unknown) => {
|
||||||
|
const p = payload as { filePaths?: string[] } | null;
|
||||||
|
|
||||||
|
this.forget(p?.filePaths ?? []);
|
||||||
|
});
|
||||||
|
EventsOn(Events.TrackPlayCountChanged, (payload: unknown) => {
|
||||||
|
this.applyPlayCount(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
registerCacheProbe('tracks-by-path', () => ({
|
||||||
|
entries: this.cache.size,
|
||||||
|
chars: this.retainedChars(),
|
||||||
|
limit: TRACK_CACHE_LIMIT,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tracks at `paths`, in the order given, without the ones that
|
||||||
|
* are not in the library. Cached tracks are answered without a call.
|
||||||
|
*/
|
||||||
|
get(paths: readonly string[]): Promise<library.Track[]> {
|
||||||
|
const answered = this.fromCache(paths);
|
||||||
|
|
||||||
|
if (answered) return Promise.resolve(answered);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.waiting.push({ paths, resolve, reject });
|
||||||
|
this.flushHandle ??= setTimeout(() => void this.flush(), 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One track, or undefined when the path is not in the library. */
|
||||||
|
async getOne(path: string): Promise<library.Track | undefined> {
|
||||||
|
return (await this.get([path]))[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the backend again for `paths`, ignoring the cache.
|
||||||
|
*
|
||||||
|
* For a caller that has just written to these files and must not be
|
||||||
|
* answered from before the write, whether or not the event that
|
||||||
|
* invalidates them has arrived yet.
|
||||||
|
*/
|
||||||
|
refresh(paths: readonly string[]): Promise<library.Track[]> {
|
||||||
|
this.forget(paths);
|
||||||
|
|
||||||
|
return this.get(paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every path cached, or null if any is missing. */
|
||||||
|
private fromCache(paths: readonly string[]): library.Track[] | null {
|
||||||
|
const tracks: library.Track[] = [];
|
||||||
|
|
||||||
|
for (const path of paths) {
|
||||||
|
const track = this.cache.get(path);
|
||||||
|
|
||||||
|
if (!track) return null;
|
||||||
|
|
||||||
|
tracks.push(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async flush(): Promise<void> {
|
||||||
|
this.flushHandle = null;
|
||||||
|
|
||||||
|
const waiting = this.waiting;
|
||||||
|
|
||||||
|
this.waiting = [];
|
||||||
|
|
||||||
|
const missing = new Set<string>();
|
||||||
|
|
||||||
|
for (const w of waiting) {
|
||||||
|
for (const path of w.paths) {
|
||||||
|
if (!this.cache.has(path)) missing.add(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gen = this.gen;
|
||||||
|
let fetched: library.Track[];
|
||||||
|
|
||||||
|
try {
|
||||||
|
fetched = missing.size > 0
|
||||||
|
? await list(GetTracksByPaths([...missing]))
|
||||||
|
: [];
|
||||||
|
} catch (err) {
|
||||||
|
for (const w of waiting) w.reject(err);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Answer from what this call returned plus what was cached when
|
||||||
|
// it was made — not from the cache afterwards, which an LRU
|
||||||
|
// eviction or an invalidation may have emptied in between.
|
||||||
|
const answer = new Map<string, library.Track>();
|
||||||
|
|
||||||
|
for (const w of waiting) {
|
||||||
|
for (const path of w.paths) {
|
||||||
|
const hit = this.cache.get(path);
|
||||||
|
|
||||||
|
if (hit) answer.set(path, hit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const track of fetched) {
|
||||||
|
answer.set(track.FilePath, track);
|
||||||
|
|
||||||
|
if (gen === this.gen) this.cache.set(track.FilePath, track);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const w of waiting) {
|
||||||
|
const tracks: library.Track[] = [];
|
||||||
|
|
||||||
|
for (const path of w.paths) {
|
||||||
|
const track = answer.get(path);
|
||||||
|
|
||||||
|
if (track) tracks.push(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
w.resolve(tracks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private forget(paths: readonly string[]): void {
|
||||||
|
for (const path of paths) this.cache.delete(path);
|
||||||
|
|
||||||
|
this.gen++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clear(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
this.gen++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyPlayCount(payload: unknown): void {
|
||||||
|
const p = payload as {
|
||||||
|
filePath?: string;
|
||||||
|
playCount?: number;
|
||||||
|
lastPlayed?: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
if (!p?.filePath) return;
|
||||||
|
|
||||||
|
const existing = this.cache.get(p.filePath);
|
||||||
|
|
||||||
|
if (!existing) return;
|
||||||
|
|
||||||
|
this.cache.set(p.filePath, {
|
||||||
|
...existing,
|
||||||
|
PlayCount: p.playCount ?? existing.PlayCount,
|
||||||
|
LastPlayed: p.lastPlayed ?? existing.LastPlayed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private retainedChars(): number {
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
for (const t of this.cache.values()) {
|
||||||
|
total += t.FilePath.length + t.TrackName.length
|
||||||
|
+ t.ArtistName.length + t.Album.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const trackCache = new TrackCache();
|
||||||
@@ -8,12 +8,13 @@
|
|||||||
* one key both sides share, and turning it back into a track is the
|
* one key both sides share, and turning it back into a track is the
|
||||||
* work this does.
|
* work this does.
|
||||||
*
|
*
|
||||||
* `libraryStore.getTracks()` is awaited rather than
|
* The queue, playlists and smart playlists are in the same position:
|
||||||
* `getCachedTracks()`-and-bail (which is what `queue-panel` does):
|
* they render their own row type, not a `library.Track`. All of them
|
||||||
* Explore is reachable without ever opening the library views, so a
|
* used to find the track in `libraryStore`'s whole-library array, which
|
||||||
* cold cache is ordinary here rather than a symptom, and silently doing
|
* meant that array had to be loaded for details to open — and the ones
|
||||||
* nothing on a menu item the user just clicked is not an option. The
|
* that read it synchronously silently did nothing when it was not
|
||||||
* fetch is the store's own, shared with every other reader.
|
* (#279). They ask `trackCache` for the paths in hand instead: one
|
||||||
|
* small call, coalesced, answered from cache the second time.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type * as library from '@go/library/models.js';
|
import type * as library from '@go/library/models.js';
|
||||||
@@ -21,9 +22,8 @@ import type {
|
|||||||
CoverArtUrls,
|
CoverArtUrls,
|
||||||
TrackDetails,
|
TrackDetails,
|
||||||
} from '@components/track-details/track-details.js';
|
} from '@components/track-details/track-details.js';
|
||||||
import { libraryStore } from '@store/library-store.js';
|
import { trackCache } from '@store/track-cache.js';
|
||||||
import { loadTrackDetails } from '@utils/lazy-track-details.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. */
|
/** The cover art the dialog shows, or nothing when the track has none. */
|
||||||
function coverArtOf(track: library.Track): CoverArtUrls | undefined {
|
function coverArtOf(track: library.Track): CoverArtUrls | undefined {
|
||||||
@@ -37,6 +37,22 @@ function coverArtOf(track: library.Track): CoverArtUrls | undefined {
|
|||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cover a batch dialog shows: the album's, when every track is on
|
||||||
|
* one album; none and `mixed` when they span several.
|
||||||
|
*/
|
||||||
|
export function batchCoverArt(
|
||||||
|
tracks: readonly library.Track[],
|
||||||
|
): { coverArt: CoverArtUrls | null; mixed: boolean } {
|
||||||
|
const albums = new Set(tracks.map((t) => t.Album));
|
||||||
|
|
||||||
|
if (albums.size > 1) return { coverArt: null, mixed: true };
|
||||||
|
|
||||||
|
const first = tracks[0];
|
||||||
|
|
||||||
|
return { coverArt: first ? coverArtOf(first) ?? null : null, mixed: false };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What became of the attempt.
|
* What became of the attempt.
|
||||||
*
|
*
|
||||||
@@ -62,8 +78,7 @@ export async function showTrackDetailsForPath(
|
|||||||
filePath: string,
|
filePath: string,
|
||||||
retry: () => void,
|
retry: () => void,
|
||||||
): Promise<TrackDetailsOutcome> {
|
): Promise<TrackDetailsOutcome> {
|
||||||
const tracks = await libraryStore.getTracks();
|
const track = await trackCache.getOne(filePath);
|
||||||
const track = tracksByFilePath(tracks).get(filePath);
|
|
||||||
|
|
||||||
if (!track) return 'not-in-library';
|
if (!track) return 'not-in-library';
|
||||||
|
|
||||||
@@ -75,3 +90,28 @@ export async function showTrackDetailsForPath(
|
|||||||
|
|
||||||
return 'shown';
|
return 'shown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the batch details dialog for the library tracks at `filePaths`,
|
||||||
|
* in that order. Paths not in the library are left out; when none are,
|
||||||
|
* nothing is shown.
|
||||||
|
*/
|
||||||
|
export async function showBatchTrackDetailsForPaths(
|
||||||
|
dialog: () => TrackDetails | undefined,
|
||||||
|
filePaths: readonly string[],
|
||||||
|
retry: () => void,
|
||||||
|
): Promise<TrackDetailsOutcome> {
|
||||||
|
const tracks = await trackCache.get(filePaths);
|
||||||
|
|
||||||
|
if (tracks.length === 0) return 'not-in-library';
|
||||||
|
|
||||||
|
const ready = await loadTrackDetails(retry);
|
||||||
|
|
||||||
|
if (!ready) return 'chunk-failed';
|
||||||
|
|
||||||
|
const { coverArt, mixed } = batchCoverArt(tracks);
|
||||||
|
|
||||||
|
dialog()?.showBatch(tracks, coverArt, mixed);
|
||||||
|
|
||||||
|
return 'shown';
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ import type { TrackDetails } from '@components/track-details/track-details';
|
|||||||
const ALBUM_TRACKS = 'library.Library.GetAlbumTracks';
|
const ALBUM_TRACKS = 'library.Library.GetAlbumTracks';
|
||||||
const COMPLETENESS = 'library.Library.GetAlbumCompleteness';
|
const COMPLETENESS = 'library.Library.GetAlbumCompleteness';
|
||||||
const FILE_PATHS = 'library.Library.GetFilePathsByRecordingMBIDs';
|
const FILE_PATHS = 'library.Library.GetFilePathsByRecordingMBIDs';
|
||||||
const ALL_TRACKS = 'library.Library.GetTracks';
|
const TRACKS_BY_PATH = 'library.Library.GetTracksByPaths';
|
||||||
const LOOKUP_RG = 'explore.Service.LookupReleaseGroup';
|
const LOOKUP_RG = 'explore.Service.LookupReleaseGroup';
|
||||||
const BROWSE_RELEASES = 'explore.Service.BrowseReleases';
|
const BROWSE_RELEASES = 'explore.Service.BrowseReleases';
|
||||||
|
|
||||||
@@ -127,14 +127,13 @@ describe('Explore track details', () => {
|
|||||||
stub(ALBUM_TRACKS, [albumTrack]);
|
stub(ALBUM_TRACKS, [albumTrack]);
|
||||||
stub(COMPLETENESS, { known: true, complete: true, owned: 1, expected: 1 });
|
stub(COMPLETENESS, { known: true, complete: true, owned: 1, expected: 1 });
|
||||||
stub(FILE_PATHS, { [MBID]: [PATH] });
|
stub(FILE_PATHS, { [MBID]: [PATH] });
|
||||||
stub(ALL_TRACKS, [libraryTrack]);
|
stub(TRACKS_BY_PATH, [libraryTrack]);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `libraryStore` fetches at import and caches the empty list the
|
* `trackCache` keeps what it was answered for the life of the browser
|
||||||
* shared setup stubs, for the life of the browser session — so a test
|
* session, so a test that changes the answer has to drop it. A
|
||||||
* that wants tracks in it has to say so. A scan-complete event is how
|
* scan-complete event is how the app itself invalidates that cache.
|
||||||
* the app itself invalidates that cache.
|
|
||||||
*/
|
*/
|
||||||
async function primeLibrary() {
|
async function primeLibrary() {
|
||||||
emit(Events.LibraryScanComplete);
|
emit(Events.LibraryScanComplete);
|
||||||
@@ -204,7 +203,7 @@ describe('Explore track details', () => {
|
|||||||
items[details]!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
items[details]!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
|
||||||
// Polled rather than counted: the opener is three awaits deep — the
|
// Polled rather than counted: the opener is three awaits deep — the
|
||||||
// path lookup, the store's tracks, and the dynamic `import()` of
|
// path lookup, the track by path, and the dynamic `import()` of
|
||||||
// the dialog chunk — and a chunk fetch is the one of the three
|
// the dialog chunk — and a chunk fetch is the one of the three
|
||||||
// whose cost depends on what else the suite is doing.
|
// whose cost depends on what else the suite is doing.
|
||||||
const shown = await dialogTrack(el);
|
const shown = await dialogTrack(el);
|
||||||
@@ -230,7 +229,7 @@ describe('Explore track details', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('reports a path with no library track rather than opening empty', async () => {
|
it('reports a path with no library track rather than opening empty', async () => {
|
||||||
stub(ALL_TRACKS, []);
|
stub(TRACKS_BY_PATH, []);
|
||||||
await primeLibrary();
|
await primeLibrary();
|
||||||
|
|
||||||
const outcome = await showTrackDetailsForPath(
|
const outcome = await showTrackDetailsForPath(
|
||||||
|
|||||||
@@ -51,8 +51,12 @@ describe('track-details stays out of the startup chunk', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Directly, or through `utils/track-details-opener`, which awaits
|
||||||
|
// it and is what the path-keyed openers share (#279).
|
||||||
it.each(OPENERS)('%s loads it at the point of use', (_name, source) => {
|
it.each(OPENERS)('%s loads it at the point of use', (_name, source) => {
|
||||||
expect(source).toContain('loadTrackDetails');
|
expect(source).toMatch(
|
||||||
|
/loadTrackDetails|showTrackDetailsForPath|showBatchTrackDetailsForPaths/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* `trackCache` answers "which track is at this path" for the rows a
|
||||||
|
* surface is showing (#279), instead of every surface reaching into the
|
||||||
|
* whole library's array — which had to be fetched eagerly at startup
|
||||||
|
* for that to work, and silently did nothing when it had not been.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
import { trackCache } from '@store/track-cache';
|
||||||
|
import { libraryStore } from '@store/library-store';
|
||||||
|
import { showBatchTrackDetailsForPaths } from '@utils/track-details-opener';
|
||||||
|
import type { TrackDetails } from '@components/track-details/track-details';
|
||||||
|
import { Events } from '../../src/events';
|
||||||
|
import {
|
||||||
|
calls,
|
||||||
|
emit,
|
||||||
|
flush,
|
||||||
|
resetHarness,
|
||||||
|
stub,
|
||||||
|
stubFailure,
|
||||||
|
} from '@test/support/harness';
|
||||||
|
|
||||||
|
const BY_PATHS = 'library.Library.GetTracksByPaths';
|
||||||
|
|
||||||
|
function track(path: string, album = 'Album') {
|
||||||
|
return {
|
||||||
|
FilePath: path,
|
||||||
|
TrackName: path,
|
||||||
|
ArtistName: 'Artist',
|
||||||
|
Album: album,
|
||||||
|
PlayCount: 0,
|
||||||
|
LastPlayed: '',
|
||||||
|
CoverArtPath: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The backend: answers every path that starts with /lib/. */
|
||||||
|
function stubLibrary(): void {
|
||||||
|
stub(BY_PATHS, (paths: string[]) =>
|
||||||
|
paths.filter((p) => p.startsWith('/lib/')).map((p) => track(p)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('track cache', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// The cache is a singleton; a scan completing is how the app empties it.
|
||||||
|
emit(Events.LibraryScanComplete);
|
||||||
|
resetHarness();
|
||||||
|
stubLibrary();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers in the order asked and leaves out paths not in the library', async () => {
|
||||||
|
const got = await trackCache.get(['/lib/b', '/gone', '/lib/a']);
|
||||||
|
|
||||||
|
expect(got.map((t) => t.FilePath)).toEqual(['/lib/b', '/lib/a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coalesces lookups made together into one call', async () => {
|
||||||
|
await Promise.all([
|
||||||
|
trackCache.get(['/lib/a']),
|
||||||
|
trackCache.get(['/lib/b', '/lib/a']),
|
||||||
|
trackCache.getOne('/lib/c'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(calls(BY_PATHS)).toHaveLength(1);
|
||||||
|
expect((calls(BY_PATHS)[0]!.args[0] as string[]).sort()).toEqual([
|
||||||
|
'/lib/a',
|
||||||
|
'/lib/b',
|
||||||
|
'/lib/c',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers a repeat from cache, and asks again after a retag of that file', async () => {
|
||||||
|
await trackCache.get(['/lib/a', '/lib/b']);
|
||||||
|
await trackCache.get(['/lib/a', '/lib/b']);
|
||||||
|
expect(calls(BY_PATHS)).toHaveLength(1);
|
||||||
|
|
||||||
|
emit(Events.TrackMetadataChanged, { filePath: '/lib/a' });
|
||||||
|
await trackCache.get(['/lib/b']);
|
||||||
|
expect(calls(BY_PATHS), 'an unchanged file is still cached').toHaveLength(1);
|
||||||
|
|
||||||
|
await trackCache.get(['/lib/a']);
|
||||||
|
expect(calls(BY_PATHS)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('patches a play count in place without a call', async () => {
|
||||||
|
await trackCache.getOne('/lib/a');
|
||||||
|
emit(Events.TrackPlayCountChanged, {
|
||||||
|
filePath: '/lib/a',
|
||||||
|
playCount: 7,
|
||||||
|
lastPlayed: '2026-10-05 10:00:00',
|
||||||
|
});
|
||||||
|
|
||||||
|
const got = await trackCache.getOne('/lib/a');
|
||||||
|
|
||||||
|
expect([got?.PlayCount, got?.LastPlayed, calls(BY_PATHS).length]).toEqual([
|
||||||
|
7,
|
||||||
|
'2026-10-05 10:00:00',
|
||||||
|
1,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not cache an answer that crossed an invalidation', async () => {
|
||||||
|
let answer: (v: unknown) => void = () => undefined;
|
||||||
|
|
||||||
|
stub(BY_PATHS, () => new Promise((resolve) => {
|
||||||
|
answer = resolve;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pending = trackCache.getOne('/lib/a');
|
||||||
|
|
||||||
|
// Sent, not yet answered: the invalidation lands in between.
|
||||||
|
await flush();
|
||||||
|
expect(calls(BY_PATHS)).toHaveLength(1);
|
||||||
|
emit(Events.TrackMetadataChanged, { batch: true });
|
||||||
|
answer([track('/lib/a')]);
|
||||||
|
expect((await pending)?.FilePath, 'the caller still gets it').toBe('/lib/a');
|
||||||
|
|
||||||
|
stubLibrary();
|
||||||
|
|
||||||
|
await trackCache.getOne('/lib/a');
|
||||||
|
expect(calls(BY_PATHS)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects every waiter in a batch when the call fails', async () => {
|
||||||
|
stubFailure(BY_PATHS);
|
||||||
|
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
trackCache.getOne('/lib/a'),
|
||||||
|
trackCache.getOne('/lib/b'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(results.map((r) => r.status)).toEqual(['rejected', 'rejected']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The bug half of #279: the queue, playlist and smart-playlist openers
|
||||||
|
* read `libraryStore.getCachedTracks()` and returned silently when it
|
||||||
|
* was null. The batch opener is what all three call now, so it is
|
||||||
|
* asserted against a library store that has nothing loaded.
|
||||||
|
*/
|
||||||
|
describe('opening details with no library list loaded', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
emit(Events.LibraryScanComplete);
|
||||||
|
resetHarness();
|
||||||
|
stubLibrary();
|
||||||
|
stub('library.Library.GetTracks', []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the dialog with the tracks asked for', async () => {
|
||||||
|
await flush();
|
||||||
|
expect(libraryStore.getCachedTracks()?.length ?? 0).toBe(0);
|
||||||
|
|
||||||
|
let shown: unknown[] | null = null;
|
||||||
|
const dialog = {
|
||||||
|
showBatch: (tracks: unknown[]) => {
|
||||||
|
shown = tracks;
|
||||||
|
},
|
||||||
|
} as unknown as TrackDetails;
|
||||||
|
|
||||||
|
const outcome = await showBatchTrackDetailsForPaths(
|
||||||
|
() => dialog,
|
||||||
|
['/lib/a', '/lib/b'],
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(outcome).toBe('shown');
|
||||||
|
expect((shown ?? []).map((t) => (t as { FilePath: string }).FilePath)).toEqual([
|
||||||
|
'/lib/a',
|
||||||
|
'/lib/b',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Every source file, as text. */
|
||||||
|
const SOURCES = import.meta.glob<string>('../../src/**/*.ts', {
|
||||||
|
eager: true,
|
||||||
|
query: '?raw',
|
||||||
|
import: 'default',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who may read the whole library's track array. The store owns it and
|
||||||
|
* the Tracks view draws it; anything else that needs a track by path
|
||||||
|
* asks `trackCache`, or the array becomes a startup dependency again.
|
||||||
|
*/
|
||||||
|
const MAY_READ_ALL_TRACKS = [
|
||||||
|
'store/library-store.ts',
|
||||||
|
'store/controllers/library-controller.ts',
|
||||||
|
'components/track-list/track-list.ts',
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALL_TRACKS_READ = /getCachedTracks\(|libraryStore\.getTracks\(|\bcachedTracks\b/;
|
||||||
|
|
||||||
|
describe('the whole-library track array has one reader', () => {
|
||||||
|
it('reads the sources it sweeps', () => {
|
||||||
|
expect(Object.keys(SOURCES).length).toBeGreaterThan(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is read only by the store and the Tracks view', () => {
|
||||||
|
const readers = Object.entries(SOURCES)
|
||||||
|
.filter(([, src]) => ALL_TRACKS_READ.test(src))
|
||||||
|
.map(([path]) => path.replace('../../src/', ''))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
expect(readers).toEqual([...MAY_READ_ALL_TRACKS].sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user