perf(library): resolve album and genre file paths in one query
"Play this artist" awaited `GetAlbumTracks` inside a for loop — 13 sequential round trips for a 12-album artist — and every one of the four sites doing that asked for whole track rows to read `FilePath` off them. Five genres cost 6 MB across the IPC. `GetFilePathsByAlbums(ids, libraryID)` and `GetFilePathsByGenres(names, libraryID)` answer once and carry only the paths. Measured at 50 000 tracks: an artist 13 calls / 74.2 kB -> 2 / 19.2 kB, twenty albums 20 / 117.5 kB / 7.8 ms -> 1 / 26.0 kB / 1.7 ms, five genres 5 / 6 014 kB / 213 ms -> 1 / 1 291 kB / 32.6 ms, with the returned path lists identical. They return the paths grouped by album id or genre name rather than flattened, because the caller owns the order — an album list is sorted by name, not by id, and a flattened result would silently reorder a queue — and because the album drag cache stores them per album. A libraryID of 0 means "every library", matching an unset filter.
This commit is contained in:
@@ -295,3 +295,26 @@ LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
|||||||
LEFT JOIN file_types ft ON af.file_type_id = ft.id
|
LEFT JOIN file_types ft ON af.file_type_id = ft.id
|
||||||
WHERE rgr.release_group_id = ? AND af.library_id = ?
|
WHERE rgr.release_group_id = ? AND af.library_id = ?
|
||||||
ORDER BY rgr.disc_number, rgr.track_number;
|
ORDER BY rgr.disc_number, rgr.track_number;
|
||||||
|
|
||||||
|
-- "Play this artist" and "play these albums" wanted file paths and asked
|
||||||
|
-- for whole track rows to get them, one round trip per album (perf.m2).
|
||||||
|
-- These answer the same question in one query and carry only what the
|
||||||
|
-- caller uses; the release group id comes back so the caller can keep
|
||||||
|
-- its own album ordering.
|
||||||
|
|
||||||
|
-- name: GetFilePathsByReleaseGroups :many
|
||||||
|
SELECT rgr.release_group_id, af.file_path
|
||||||
|
FROM release_group_recordings rgr
|
||||||
|
JOIN recordings r ON rgr.recording_id = r.id
|
||||||
|
JOIN audio_files af ON af.recording_id = r.id
|
||||||
|
WHERE rgr.release_group_id IN (sqlc.slice('release_group_ids'))
|
||||||
|
ORDER BY rgr.disc_number, rgr.track_number;
|
||||||
|
|
||||||
|
-- name: GetFilePathsByReleaseGroupsByLibrary :many
|
||||||
|
SELECT rgr.release_group_id, af.file_path
|
||||||
|
FROM release_group_recordings rgr
|
||||||
|
JOIN recordings r ON rgr.recording_id = r.id
|
||||||
|
JOIN audio_files af ON af.recording_id = r.id
|
||||||
|
WHERE rgr.release_group_id IN (sqlc.slice('release_group_ids'))
|
||||||
|
AND af.library_id = ?
|
||||||
|
ORDER BY rgr.disc_number, rgr.track_number;
|
||||||
|
|||||||
@@ -125,3 +125,26 @@ JOIN audio_files af ON af.recording_id = r.id
|
|||||||
WHERE af.library_id = ?
|
WHERE af.library_id = ?
|
||||||
GROUP BY g.id, g.name
|
GROUP BY g.id, g.name
|
||||||
ORDER BY g.name;
|
ORDER BY g.name;
|
||||||
|
|
||||||
|
-- Same as GetFilePathsByReleaseGroups, for "play these genres" (perf.m2):
|
||||||
|
-- one query instead of one per genre, and file paths instead of whole
|
||||||
|
-- track rows, which was 6 MB over the IPC for five genres.
|
||||||
|
|
||||||
|
-- name: GetFilePathsByGenres :many
|
||||||
|
SELECT g.name AS genre_name, af.file_path
|
||||||
|
FROM genres g
|
||||||
|
JOIN recording_genres rg ON g.id = rg.genre_id
|
||||||
|
JOIN recordings r ON rg.recording_id = r.id
|
||||||
|
JOIN audio_files af ON af.recording_id = r.id
|
||||||
|
WHERE g.name IN (sqlc.slice('genre_names'))
|
||||||
|
ORDER BY r.name;
|
||||||
|
|
||||||
|
-- name: GetFilePathsByGenresByLibrary :many
|
||||||
|
SELECT g.name AS genre_name, af.file_path
|
||||||
|
FROM genres g
|
||||||
|
JOIN recording_genres rg ON g.id = rg.genre_id
|
||||||
|
JOIN recordings r ON rg.recording_id = r.id
|
||||||
|
JOIN audio_files af ON af.recording_id = r.id
|
||||||
|
WHERE g.name IN (sqlc.slice('genre_names'))
|
||||||
|
AND af.library_id = ?
|
||||||
|
ORDER BY r.name;
|
||||||
|
|||||||
@@ -907,6 +907,113 @@ func (q *Queries) GetAudioFilesNeedingMetadata(ctx context.Context) ([]AudioFile
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getFilePathsByReleaseGroups = `-- name: GetFilePathsByReleaseGroups :many
|
||||||
|
|
||||||
|
SELECT rgr.release_group_id, af.file_path
|
||||||
|
FROM release_group_recordings rgr
|
||||||
|
JOIN recordings r ON rgr.recording_id = r.id
|
||||||
|
JOIN audio_files af ON af.recording_id = r.id
|
||||||
|
WHERE rgr.release_group_id IN (/*SLICE:release_group_ids*/?)
|
||||||
|
ORDER BY rgr.disc_number, rgr.track_number
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetFilePathsByReleaseGroupsRow struct {
|
||||||
|
ReleaseGroupID int64
|
||||||
|
FilePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Play this artist" and "play these albums" wanted file paths and asked
|
||||||
|
// for whole track rows to get them, one round trip per album (perf.m2).
|
||||||
|
// These answer the same question in one query and carry only what the
|
||||||
|
// caller uses; the release group id comes back so the caller can keep
|
||||||
|
// its own album ordering.
|
||||||
|
func (q *Queries) GetFilePathsByReleaseGroups(ctx context.Context, releaseGroupIds []int64) ([]GetFilePathsByReleaseGroupsRow, error) {
|
||||||
|
query := getFilePathsByReleaseGroups
|
||||||
|
var queryParams []interface{}
|
||||||
|
if len(releaseGroupIds) > 0 {
|
||||||
|
for _, v := range releaseGroupIds {
|
||||||
|
queryParams = append(queryParams, v)
|
||||||
|
}
|
||||||
|
query = strings.Replace(query, "/*SLICE:release_group_ids*/?", strings.Repeat(",?", len(releaseGroupIds))[1:], 1)
|
||||||
|
} else {
|
||||||
|
query = strings.Replace(query, "/*SLICE:release_group_ids*/?", "NULL", 1)
|
||||||
|
}
|
||||||
|
rows, err := q.db.QueryContext(ctx, query, queryParams...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetFilePathsByReleaseGroupsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetFilePathsByReleaseGroupsRow
|
||||||
|
if err := rows.Scan(&i.ReleaseGroupID, &i.FilePath); 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 getFilePathsByReleaseGroupsByLibrary = `-- name: GetFilePathsByReleaseGroupsByLibrary :many
|
||||||
|
SELECT rgr.release_group_id, af.file_path
|
||||||
|
FROM release_group_recordings rgr
|
||||||
|
JOIN recordings r ON rgr.recording_id = r.id
|
||||||
|
JOIN audio_files af ON af.recording_id = r.id
|
||||||
|
WHERE rgr.release_group_id IN (/*SLICE:release_group_ids*/?)
|
||||||
|
AND af.library_id = ?
|
||||||
|
ORDER BY rgr.disc_number, rgr.track_number
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetFilePathsByReleaseGroupsByLibraryParams struct {
|
||||||
|
ReleaseGroupIds []int64
|
||||||
|
LibraryID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetFilePathsByReleaseGroupsByLibraryRow struct {
|
||||||
|
ReleaseGroupID int64
|
||||||
|
FilePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetFilePathsByReleaseGroupsByLibrary(ctx context.Context, arg GetFilePathsByReleaseGroupsByLibraryParams) ([]GetFilePathsByReleaseGroupsByLibraryRow, error) {
|
||||||
|
query := getFilePathsByReleaseGroupsByLibrary
|
||||||
|
var queryParams []interface{}
|
||||||
|
if len(arg.ReleaseGroupIds) > 0 {
|
||||||
|
for _, v := range arg.ReleaseGroupIds {
|
||||||
|
queryParams = append(queryParams, v)
|
||||||
|
}
|
||||||
|
query = strings.Replace(query, "/*SLICE:release_group_ids*/?", strings.Repeat(",?", len(arg.ReleaseGroupIds))[1:], 1)
|
||||||
|
} else {
|
||||||
|
query = strings.Replace(query, "/*SLICE:release_group_ids*/?", "NULL", 1)
|
||||||
|
}
|
||||||
|
queryParams = append(queryParams, arg.LibraryID)
|
||||||
|
rows, err := q.db.QueryContext(ctx, query, queryParams...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetFilePathsByReleaseGroupsByLibraryRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetFilePathsByReleaseGroupsByLibraryRow
|
||||||
|
if err := rows.Scan(&i.ReleaseGroupID, &i.FilePath); 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 getLibraryMaxModifiedAt = `-- name: GetLibraryMaxModifiedAt :one
|
const getLibraryMaxModifiedAt = `-- name: GetLibraryMaxModifiedAt :one
|
||||||
SELECT CAST(COALESCE(MAX(modified_at), 0) AS INTEGER) FROM audio_files
|
SELECT CAST(COALESCE(MAX(modified_at), 0) AS INTEGER) FROM audio_files
|
||||||
WHERE library_id = ?
|
WHERE library_id = ?
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ package sqlcgen
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const countGenreReferences = `-- name: CountGenreReferences :one
|
const countGenreReferences = `-- name: CountGenreReferences :one
|
||||||
@@ -148,6 +149,113 @@ func (q *Queries) GetAllGenresWithCountsByLibrary(ctx context.Context, libraryID
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getFilePathsByGenres = `-- name: GetFilePathsByGenres :many
|
||||||
|
|
||||||
|
SELECT g.name AS genre_name, af.file_path
|
||||||
|
FROM genres g
|
||||||
|
JOIN recording_genres rg ON g.id = rg.genre_id
|
||||||
|
JOIN recordings r ON rg.recording_id = r.id
|
||||||
|
JOIN audio_files af ON af.recording_id = r.id
|
||||||
|
WHERE g.name IN (/*SLICE:genre_names*/?)
|
||||||
|
ORDER BY r.name
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetFilePathsByGenresRow struct {
|
||||||
|
GenreName string
|
||||||
|
FilePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same as GetFilePathsByReleaseGroups, for "play these genres" (perf.m2):
|
||||||
|
// one query instead of one per genre, and file paths instead of whole
|
||||||
|
// track rows, which was 6 MB over the IPC for five genres.
|
||||||
|
func (q *Queries) GetFilePathsByGenres(ctx context.Context, genreNames []string) ([]GetFilePathsByGenresRow, error) {
|
||||||
|
query := getFilePathsByGenres
|
||||||
|
var queryParams []interface{}
|
||||||
|
if len(genreNames) > 0 {
|
||||||
|
for _, v := range genreNames {
|
||||||
|
queryParams = append(queryParams, v)
|
||||||
|
}
|
||||||
|
query = strings.Replace(query, "/*SLICE:genre_names*/?", strings.Repeat(",?", len(genreNames))[1:], 1)
|
||||||
|
} else {
|
||||||
|
query = strings.Replace(query, "/*SLICE:genre_names*/?", "NULL", 1)
|
||||||
|
}
|
||||||
|
rows, err := q.db.QueryContext(ctx, query, queryParams...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetFilePathsByGenresRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetFilePathsByGenresRow
|
||||||
|
if err := rows.Scan(&i.GenreName, &i.FilePath); 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 getFilePathsByGenresByLibrary = `-- name: GetFilePathsByGenresByLibrary :many
|
||||||
|
SELECT g.name AS genre_name, af.file_path
|
||||||
|
FROM genres g
|
||||||
|
JOIN recording_genres rg ON g.id = rg.genre_id
|
||||||
|
JOIN recordings r ON rg.recording_id = r.id
|
||||||
|
JOIN audio_files af ON af.recording_id = r.id
|
||||||
|
WHERE g.name IN (/*SLICE:genre_names*/?)
|
||||||
|
AND af.library_id = ?
|
||||||
|
ORDER BY r.name
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetFilePathsByGenresByLibraryParams struct {
|
||||||
|
GenreNames []string
|
||||||
|
LibraryID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetFilePathsByGenresByLibraryRow struct {
|
||||||
|
GenreName string
|
||||||
|
FilePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetFilePathsByGenresByLibrary(ctx context.Context, arg GetFilePathsByGenresByLibraryParams) ([]GetFilePathsByGenresByLibraryRow, error) {
|
||||||
|
query := getFilePathsByGenresByLibrary
|
||||||
|
var queryParams []interface{}
|
||||||
|
if len(arg.GenreNames) > 0 {
|
||||||
|
for _, v := range arg.GenreNames {
|
||||||
|
queryParams = append(queryParams, v)
|
||||||
|
}
|
||||||
|
query = strings.Replace(query, "/*SLICE:genre_names*/?", strings.Repeat(",?", len(arg.GenreNames))[1:], 1)
|
||||||
|
} else {
|
||||||
|
query = strings.Replace(query, "/*SLICE:genre_names*/?", "NULL", 1)
|
||||||
|
}
|
||||||
|
queryParams = append(queryParams, arg.LibraryID)
|
||||||
|
rows, err := q.db.QueryContext(ctx, query, queryParams...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetFilePathsByGenresByLibraryRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetFilePathsByGenresByLibraryRow
|
||||||
|
if err := rows.Scan(&i.GenreName, &i.FilePath); 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 getGenresByRecordingID = `-- name: GetGenresByRecordingID :many
|
const getGenresByRecordingID = `-- name: GetGenresByRecordingID :many
|
||||||
SELECT g.id, g.name
|
SELECT g.id, g.name
|
||||||
FROM genres g
|
FROM genres g
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"yellowjacket/backend/database/sql/sqlcgen"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedAlbumsAndGenres builds two albums in two libraries, with one track
|
||||||
|
// carrying two genres — enough shape for the batched path lookups to be
|
||||||
|
// wrong in an interesting way if they group or filter incorrectly.
|
||||||
|
func seedAlbumsAndGenres(t *testing.T, lib *Library) (albumIDs []int64, libraryID int64) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx := lib.ctx
|
||||||
|
q := lib.db.Queries
|
||||||
|
|
||||||
|
library, err := q.CreateLibrary(ctx, sqlcgen.CreateLibraryParams{
|
||||||
|
Name: "Main",
|
||||||
|
Path: "/music",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create library: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
other, err := q.CreateLibrary(ctx, sqlcgen.CreateLibraryParams{
|
||||||
|
Name: "Other",
|
||||||
|
Path: "/other",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create other library: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ac, err := q.UpsertArtistCredit(ctx, "Test Artist")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upsert artist credit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
genreIDs := map[string]int64{}
|
||||||
|
|
||||||
|
for _, name := range []string{"Ambient", "Baroque"} {
|
||||||
|
g, err := q.UpsertGenre(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upsert genre %s: %v", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
genreIDs[name] = g.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two albums; the second lives in the other library so the
|
||||||
|
// library-scoped variants have something to exclude.
|
||||||
|
type spec struct {
|
||||||
|
album string
|
||||||
|
track string
|
||||||
|
path string
|
||||||
|
library int64
|
||||||
|
disc int64
|
||||||
|
number int64
|
||||||
|
genres []string
|
||||||
|
}
|
||||||
|
|
||||||
|
specs := []spec{
|
||||||
|
{"First", "A2", "/music/a2.mp3", library.ID, 1, 2, []string{"Ambient"}},
|
||||||
|
{"First", "A1", "/music/a1.mp3", library.ID, 1, 1, []string{"Ambient", "Baroque"}},
|
||||||
|
{"Second", "B1", "/other/b1.mp3", other.ID, 1, 1, []string{"Baroque"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
byAlbum := map[string]int64{}
|
||||||
|
|
||||||
|
for _, s := range specs {
|
||||||
|
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
|
||||||
|
Name: s.track,
|
||||||
|
ArtistCreditID: ac.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create recording: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rgID, ok := byAlbum[s.album]
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
|
||||||
|
Name: s.album,
|
||||||
|
AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upsert release group: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rgID = rg.ID
|
||||||
|
byAlbum[s.album] = rgID
|
||||||
|
albumIDs = append(albumIDs, rgID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := q.CreateReleaseGroupRecording(
|
||||||
|
ctx, sqlcgen.CreateReleaseGroupRecordingParams{
|
||||||
|
ReleaseGroupID: rgID,
|
||||||
|
RecordingID: rec.ID,
|
||||||
|
TrackNumber: sql.NullInt64{Int64: s.number, Valid: true},
|
||||||
|
DiscNumber: sql.NullInt64{Int64: s.disc, Valid: true},
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("link recording: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := q.CreateAudioFile(ctx, sqlcgen.CreateAudioFileParams{
|
||||||
|
FilePath: s.path,
|
||||||
|
LengthMilliseconds: 1000,
|
||||||
|
RecordingID: rec.ID,
|
||||||
|
LibraryID: s.library,
|
||||||
|
Basename: s.track + ".mp3",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create audio file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, g := range s.genres {
|
||||||
|
if err := q.CreateRecordingGenre(
|
||||||
|
ctx, sqlcgen.CreateRecordingGenreParams{
|
||||||
|
RecordingID: rec.ID,
|
||||||
|
GenreID: genreIDs[g],
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("link genre: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return albumIDs, library.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// perf.m2: "play this artist" asked for whole track rows, one round trip
|
||||||
|
// per album, to read one field off each. These two answer in one query,
|
||||||
|
// and the thing worth pinning is that they still group by the entity the
|
||||||
|
// caller ordered by — a flattened result would silently reorder a queue.
|
||||||
|
func TestGetFilePathsByAlbums(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lib, _ := setupTestLibrary(t)
|
||||||
|
albumIDs, libraryID := seedAlbumsAndGenres(t, lib)
|
||||||
|
|
||||||
|
got, err := lib.GetFilePathsByAlbums(albumIDs, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFilePathsByAlbums: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("albums returned = %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordered by disc then track within an album, not by insertion.
|
||||||
|
first := got[albumIDs[0]]
|
||||||
|
if len(first) != 2 || first[0] != "/music/a1.mp3" || first[1] != "/music/a2.mp3" {
|
||||||
|
t.Errorf("first album paths = %v, want [a1 a2] in track order", first)
|
||||||
|
}
|
||||||
|
|
||||||
|
scoped, err := lib.GetFilePathsByAlbums(albumIDs, libraryID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFilePathsByAlbums scoped: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := scoped[albumIDs[1]]; ok {
|
||||||
|
t.Errorf("library-scoped result includes an album from another library")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(scoped[albumIDs[0]]) != 2 {
|
||||||
|
t.Errorf("scoped first album = %v, want 2 paths", scoped[albumIDs[0]])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetFilePathsByAlbums_Empty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lib, _ := setupTestLibrary(t)
|
||||||
|
|
||||||
|
got, err := lib.GetFilePathsByAlbums(nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFilePathsByAlbums(nil): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("got %v, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetFilePathsByGenres(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lib, _ := setupTestLibrary(t)
|
||||||
|
_, libraryID := seedAlbumsAndGenres(t, lib)
|
||||||
|
|
||||||
|
got, err := lib.GetFilePathsByGenres([]string{"Ambient", "Baroque"}, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFilePathsByGenres: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got["Ambient"]) != 2 {
|
||||||
|
t.Errorf("Ambient = %v, want 2 paths", got["Ambient"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// One track is in both genres: the overlap is returned under each,
|
||||||
|
// because de-duplicating is the caller's job — it is the one that
|
||||||
|
// knows the order the genres were selected in.
|
||||||
|
if len(got["Baroque"]) != 2 {
|
||||||
|
t.Errorf("Baroque = %v, want 2 paths", got["Baroque"])
|
||||||
|
}
|
||||||
|
|
||||||
|
scoped, err := lib.GetFilePathsByGenres([]string{"Baroque"}, libraryID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFilePathsByGenres scoped: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(scoped["Baroque"]) != 1 || scoped["Baroque"][0] != "/music/a1.mp3" {
|
||||||
|
t.Errorf("scoped Baroque = %v, want just the main library's track", scoped["Baroque"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetFilePathsByGenres_Empty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lib, _ := setupTestLibrary(t)
|
||||||
|
|
||||||
|
got, err := lib.GetFilePathsByGenres(nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFilePathsByGenres(nil): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("got %v, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1083,3 +1083,124 @@ func (l *Library) GetAllLibrariesWithTrackCounts() ([]Info, error) {
|
|||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetFilePathsByAlbums returns the file paths of every track in the
|
||||||
|
// given albums, grouped by album id.
|
||||||
|
//
|
||||||
|
// "Play this artist", "play these albums" and the album drag cache each
|
||||||
|
// resolved paths with one binding call per album, sequentially, and each
|
||||||
|
// asked for whole track rows to read one field off them (perf.m2). This
|
||||||
|
// is that question asked once. The result is grouped rather than
|
||||||
|
// flattened because the caller owns the ordering — an album list is
|
||||||
|
// sorted by name, not by id — and because the drag cache stores it per
|
||||||
|
// album.
|
||||||
|
//
|
||||||
|
// A library id of 0 means "every library", matching the caller's
|
||||||
|
// selected-library filter being unset.
|
||||||
|
func (l *Library) GetFilePathsByAlbums(
|
||||||
|
albumIDs []int64, libraryID int64,
|
||||||
|
) (map[int64][]string, error) {
|
||||||
|
paths := make(map[int64][]string, len(albumIDs))
|
||||||
|
|
||||||
|
if len(albumIDs) == 0 {
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if libraryID > 0 {
|
||||||
|
rows, err := l.db.ReadQueries.GetFilePathsByReleaseGroupsByLibrary(
|
||||||
|
l.ctx, sqlcgen.GetFilePathsByReleaseGroupsByLibraryParams{
|
||||||
|
ReleaseGroupIds: albumIDs,
|
||||||
|
LibraryID: libraryID,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
l.logger.Error(
|
||||||
|
"could not retrieve album file paths for library",
|
||||||
|
"albums", len(albumIDs),
|
||||||
|
"libraryID", libraryID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("could not get album file paths: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
paths[row.ReleaseGroupID] = append(paths[row.ReleaseGroupID], row.FilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := l.db.ReadQueries.GetFilePathsByReleaseGroups(l.ctx, albumIDs)
|
||||||
|
if err != nil {
|
||||||
|
l.logger.Error(
|
||||||
|
"could not retrieve album file paths",
|
||||||
|
"albums", len(albumIDs),
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("could not get album file paths: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
paths[row.ReleaseGroupID] = append(paths[row.ReleaseGroupID], row.FilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFilePathsByGenres returns the file paths of every track tagged with
|
||||||
|
// the given genres, grouped by genre name. See GetFilePathsByAlbums —
|
||||||
|
// same finding, same shape, and the caller still owns the de-duplication
|
||||||
|
// across genres because it owns the order.
|
||||||
|
func (l *Library) GetFilePathsByGenres(
|
||||||
|
genreNames []string, libraryID int64,
|
||||||
|
) (map[string][]string, error) {
|
||||||
|
paths := make(map[string][]string, len(genreNames))
|
||||||
|
|
||||||
|
if len(genreNames) == 0 {
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if libraryID > 0 {
|
||||||
|
rows, err := l.db.ReadQueries.GetFilePathsByGenresByLibrary(
|
||||||
|
l.ctx, sqlcgen.GetFilePathsByGenresByLibraryParams{
|
||||||
|
GenreNames: genreNames,
|
||||||
|
LibraryID: libraryID,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
l.logger.Error(
|
||||||
|
"could not retrieve genre file paths for library",
|
||||||
|
"genres", len(genreNames),
|
||||||
|
"libraryID", libraryID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("could not get genre file paths: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
paths[row.GenreName] = append(paths[row.GenreName], row.FilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := l.db.ReadQueries.GetFilePathsByGenres(l.ctx, genreNames)
|
||||||
|
if err != nil {
|
||||||
|
l.logger.Error(
|
||||||
|
"could not retrieve genre file paths",
|
||||||
|
"genres", len(genreNames),
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("could not get genre file paths: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
paths[row.GenreName] = append(paths[row.GenreName], row.FilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|||||||
+4
@@ -43,6 +43,10 @@ export function GetAllTracks():Promise<Array<library.Track>>;
|
|||||||
|
|
||||||
export function GetAllTracksByLibrary(arg1:number):Promise<Array<library.Track>>;
|
export function GetAllTracksByLibrary(arg1:number):Promise<Array<library.Track>>;
|
||||||
|
|
||||||
|
export function GetFilePathsByAlbums(arg1:Array<number>,arg2:number):Promise<Record<number, Array<string>>>;
|
||||||
|
|
||||||
|
export function GetFilePathsByGenres(arg1:Array<string>,arg2:number):Promise<Record<string, Array<string>>>;
|
||||||
|
|
||||||
export function GetRemovalImpact(arg1:number):Promise<library.RemovalImpact>;
|
export function GetRemovalImpact(arg1:number):Promise<library.RemovalImpact>;
|
||||||
|
|
||||||
export function GetScanQueueLength():Promise<number>;
|
export function GetScanQueueLength():Promise<number>;
|
||||||
|
|||||||
@@ -78,6 +78,14 @@ export function GetAllTracksByLibrary(arg1) {
|
|||||||
return window['go']['library']['Library']['GetAllTracksByLibrary'](arg1);
|
return window['go']['library']['Library']['GetAllTracksByLibrary'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetFilePathsByAlbums(arg1, arg2) {
|
||||||
|
return window['go']['library']['Library']['GetFilePathsByAlbums'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetFilePathsByGenres(arg1, arg2) {
|
||||||
|
return window['go']['library']['Library']['GetFilePathsByGenres'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetRemovalImpact(arg1) {
|
export function GetRemovalImpact(arg1) {
|
||||||
return window['go']['library']['Library']['GetRemovalImpact'](arg1);
|
return window['go']['library']['Library']['GetRemovalImpact'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user