Scoped library payloads: load the track list when a view needs it #287
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
|
||||
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
|
||||
SELECT * FROM track_metadata
|
||||
WHERE album_id = sqlc.arg(album_id)
|
||||
|
||||
@@ -830,6 +830,71 @@ func (q *Queries) GetTracksByGenre(ctx context.Context, arg GetTracksByGenrePara
|
||||
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
|
||||
SELECT id, file_path, title, artist_name, album, cover_art_path,
|
||||
artist_mbid, release_group_mbid, recording_mbid
|
||||
|
||||
@@ -193,6 +193,48 @@ func (l *Library) GetTracks(libraryID int64) ([]Track, error) {
|
||||
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.
|
||||
func (l *Library) SearchTracks(query string, libraryID int64) ([]Track, error) {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -56,12 +56,9 @@ import {
|
||||
createTrackCardDragImage,
|
||||
removeDragImage,
|
||||
} from '@utils/drag-image';
|
||||
import { libraryStore } from '@store/library-store';
|
||||
import '@components/playlist-picker/playlist-picker.js';
|
||||
import { loadTrackDetails } from '@utils/lazy-track-details.js';
|
||||
import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
|
||||
import { showTrackDetailsForPath, showBatchTrackDetailsForPaths } from '@utils/track-details-opener.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 type { PhantomResolver } from '@components/phantom-resolver/phantom-resolver.js';
|
||||
import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
|
||||
@@ -758,76 +755,21 @@ export class PlaylistDetails
|
||||
}
|
||||
|
||||
private async openTrackDetails(filePath: string) {
|
||||
const tracks = libraryStore.getCachedTracks();
|
||||
const track = tracks
|
||||
? tracksByFilePath(tracks).get(filePath)
|
||||
: undefined;
|
||||
|
||||
if (!track) return;
|
||||
|
||||
const ready = await loadTrackDetails(
|
||||
await showTrackDetailsForPath(
|
||||
() => this.trackDetailsDialog,
|
||||
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(
|
||||
filePaths: string[],
|
||||
) {
|
||||
const cachedTracks =
|
||||
libraryStore.getCachedTracks();
|
||||
|
||||
if (!cachedTracks) return;
|
||||
|
||||
const tracks = tracksForPaths(
|
||||
cachedTracks,
|
||||
await showBatchTrackDetailsForPaths(
|
||||
() => this.trackDetailsDialog,
|
||||
filePaths,
|
||||
);
|
||||
|
||||
if (tracks.length === 0) return;
|
||||
|
||||
const ready = await loadTrackDetails(
|
||||
() => 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,
|
||||
removeDragImage,
|
||||
} from '@utils/drag-image';
|
||||
import { libraryStore } from '@store/library-store';
|
||||
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 { showTrackDetailsForPath, showBatchTrackDetailsForPaths } from '@utils/track-details-opener.js';
|
||||
import type { TrackDetails } from '@components/track-details/track-details.js';
|
||||
import type { CoverArtUrls } from '@components/track-details/track-details.js';
|
||||
import {
|
||||
creditLink,
|
||||
trackLink,
|
||||
@@ -1542,90 +1538,30 @@ export class QueuePanel
|
||||
}
|
||||
|
||||
private async openTrackDetails(index: number) {
|
||||
const queueTrack =
|
||||
this.queue.tracks[index];
|
||||
const queueTrack = this.queue.tracks[index];
|
||||
|
||||
if (!queueTrack) return;
|
||||
|
||||
const tracks =
|
||||
libraryStore.getCachedTracks();
|
||||
const track = tracks
|
||||
? tracksByFilePath(tracks).get(
|
||||
queueTrack.filePath,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (!track) return;
|
||||
|
||||
const ready = await loadTrackDetails(
|
||||
await showTrackDetailsForPath(
|
||||
() => this.trackDetailsDialog,
|
||||
queueTrack.filePath,
|
||||
() => 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(
|
||||
indices: number[],
|
||||
) {
|
||||
const queueTracks = this.queue.tracks;
|
||||
const cachedTracks =
|
||||
libraryStore.getCachedTracks();
|
||||
const filePaths = indices
|
||||
.map((i) => queueTracks[i]?.filePath)
|
||||
.filter((fp): fp is string => fp != null);
|
||||
|
||||
if (!cachedTracks) return;
|
||||
|
||||
const byPath = tracksByFilePath(cachedTracks);
|
||||
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(
|
||||
await showBatchTrackDetailsForPaths(
|
||||
() => this.trackDetailsDialog,
|
||||
filePaths,
|
||||
() => 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 = () => {
|
||||
|
||||
@@ -52,11 +52,8 @@ import '@lit-labs/virtualizer';
|
||||
import type { LitVirtualizer } from '@lit-labs/virtualizer';
|
||||
import { flow } from '@lit-labs/virtualizer/layouts/flow.js';
|
||||
import '@components/playlist-picker/playlist-picker.js';
|
||||
import { loadTrackDetails } from '@utils/lazy-track-details.js';
|
||||
import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
|
||||
import { showTrackDetailsForPath, showBatchTrackDetailsForPaths } from '@utils/track-details-opener.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 {
|
||||
creditLink,
|
||||
@@ -1145,76 +1142,21 @@ export class SmartPlaylistDetails
|
||||
}
|
||||
|
||||
private async openTrackDetails(filePath: string) {
|
||||
const tracks = libraryStore.getCachedTracks();
|
||||
const track = tracks
|
||||
? tracksByFilePath(tracks).get(filePath)
|
||||
: undefined;
|
||||
|
||||
if (!track) return;
|
||||
|
||||
const ready = await loadTrackDetails(
|
||||
await showTrackDetailsForPath(
|
||||
() => this.trackDetailsDialog,
|
||||
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(
|
||||
filePaths: string[],
|
||||
) {
|
||||
const cachedTracks =
|
||||
libraryStore.getCachedTracks();
|
||||
|
||||
if (!cachedTracks) return;
|
||||
|
||||
const tracks = tracksForPaths(
|
||||
cachedTracks,
|
||||
await showBatchTrackDetailsForPaths(
|
||||
() => this.trackDetailsDialog,
|
||||
filePaths,
|
||||
);
|
||||
|
||||
if (tracks.length === 0) return;
|
||||
|
||||
const ready = await loadTrackDetails(
|
||||
() => 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';
|
||||
type TrackMBIDs = library.TrackMBIDs;
|
||||
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 { Events } from '../../events';
|
||||
|
||||
@@ -1317,10 +1318,13 @@ export class TrackDetails extends LitElement {
|
||||
const container = img.parentElement;
|
||||
|
||||
if (container) {
|
||||
container.innerHTML =
|
||||
'<div class="cover-placeholder">' +
|
||||
'<wa-icon name="music"></wa-icon>' +
|
||||
'</div>';
|
||||
const placeholder = document.createElement('div');
|
||||
const icon = document.createElement('wa-icon');
|
||||
|
||||
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.
|
||||
this.exitEditMode();
|
||||
|
||||
// Re-fetch track and album data so the dialog
|
||||
// shows updated values and cover art. The store
|
||||
// invalidation is already in-flight from the event;
|
||||
// these calls await the pending fetch or start one.
|
||||
// Awaited for the side effect of refreshing the
|
||||
// store; the album list is consumed elsewhere.
|
||||
const [tracks] = await Promise.all([
|
||||
libraryStore.getTracks(),
|
||||
libraryStore.getAlbums(),
|
||||
]);
|
||||
|
||||
const updated = tracks.find(
|
||||
(t) => t.FilePath === filePath,
|
||||
);
|
||||
// Re-read this one track so the dialog shows the
|
||||
// values and cover art just written. Not the whole
|
||||
// library (#279): the views that hold it refetch on
|
||||
// the event, and only if something is showing them.
|
||||
const [updated] = await trackCache.refresh([filePath]);
|
||||
|
||||
if (updated) {
|
||||
this.track = updated;
|
||||
@@ -1828,39 +1823,18 @@ export class TrackDetails extends LitElement {
|
||||
this.errorMessage = '';
|
||||
this.cleanupPendingCoverArt();
|
||||
|
||||
// Refresh data from library store; album list is
|
||||
// refreshed for side effects only.
|
||||
const [tracks] = await Promise.all([
|
||||
libraryStore.getTracks(),
|
||||
libraryStore.getAlbums(),
|
||||
]);
|
||||
// Re-read the tracks just written, in the order the batch
|
||||
// holds them (#279) — not the whole library to filter.
|
||||
// `refresh`, because the write's event may not have reached
|
||||
// the cache before its own result did.
|
||||
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;
|
||||
|
||||
// Re-resolve cover art state.
|
||||
const first = refreshed[0];
|
||||
const albumNames = new Set(refreshed.map((t) => t.Album));
|
||||
const { coverArt, mixed } = batchCoverArt(refreshed);
|
||||
|
||||
if (albumNames.size === 1 && first?.CoverArtPath) {
|
||||
this.coverArt = {
|
||||
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;
|
||||
}
|
||||
this.coverArt = coverArt;
|
||||
this.batchCoverArtMixed = mixed;
|
||||
};
|
||||
|
||||
// -- Shared edit logic --
|
||||
@@ -2103,6 +2077,8 @@ export class TrackDetails extends LitElement {
|
||||
// as a base64-encoded string (standard encoding/json
|
||||
// behaviour for []byte).
|
||||
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 binary = atob(b64);
|
||||
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
|
||||
* 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.
|
||||
* The queue, playlists and smart playlists are in the same position:
|
||||
* they render their own row type, not a `library.Track`. All of them
|
||||
* used to find the track in `libraryStore`'s whole-library array, which
|
||||
* meant that array had to be loaded for details to open — and the ones
|
||||
* that read it synchronously silently did nothing when it was not
|
||||
* (#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';
|
||||
@@ -21,9 +22,8 @@ import type {
|
||||
CoverArtUrls,
|
||||
TrackDetails,
|
||||
} 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 { 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 {
|
||||
@@ -37,6 +37,22 @@ function coverArtOf(track: library.Track): CoverArtUrls | 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.
|
||||
*
|
||||
@@ -62,8 +78,7 @@ export async function showTrackDetailsForPath(
|
||||
filePath: string,
|
||||
retry: () => void,
|
||||
): Promise<TrackDetailsOutcome> {
|
||||
const tracks = await libraryStore.getTracks();
|
||||
const track = tracksByFilePath(tracks).get(filePath);
|
||||
const track = await trackCache.getOne(filePath);
|
||||
|
||||
if (!track) return 'not-in-library';
|
||||
|
||||
@@ -75,3 +90,28 @@ export async function showTrackDetailsForPath(
|
||||
|
||||
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 COMPLETENESS = 'library.Library.GetAlbumCompleteness';
|
||||
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 BROWSE_RELEASES = 'explore.Service.BrowseReleases';
|
||||
|
||||
@@ -127,14 +127,13 @@ describe('Explore track details', () => {
|
||||
stub(ALBUM_TRACKS, [albumTrack]);
|
||||
stub(COMPLETENESS, { known: true, complete: true, owned: 1, expected: 1 });
|
||||
stub(FILE_PATHS, { [MBID]: [PATH] });
|
||||
stub(ALL_TRACKS, [libraryTrack]);
|
||||
stub(TRACKS_BY_PATH, [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.
|
||||
* `trackCache` keeps what it was answered for the life of the browser
|
||||
* session, so a test that changes the answer has to drop it. A
|
||||
* scan-complete event is how the app itself invalidates that cache.
|
||||
*/
|
||||
async function primeLibrary() {
|
||||
emit(Events.LibraryScanComplete);
|
||||
@@ -204,7 +203,7 @@ describe('Explore 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
|
||||
// path lookup, the track by path, 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);
|
||||
@@ -230,7 +229,7 @@ describe('Explore track details', () => {
|
||||
});
|
||||
|
||||
it('reports a path with no library track rather than opening empty', async () => {
|
||||
stub(ALL_TRACKS, []);
|
||||
stub(TRACKS_BY_PATH, []);
|
||||
await primeLibrary();
|
||||
|
||||
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) => {
|
||||
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