Plans 013 and 014, the album page that prompted them, and the smaller fixes they turned up. Changelog, largest first. ## The local library is shaped like files, not like MusicBrainz `audio_files` carries its own tags and points at `albums` and `artists`; `file_genres` is the one real many-to-many. `recordings`, `release_group_recordings`, `artist_credit`, `artist_credit_artist`, `recording_genres`, `release_groups` and `release_to_rg` are gone from the local side, and with them a six-way join in every read, a `MIN(release_group_id)` subquery in eleven queries and a first-credited-artist subquery in nine. Measured on a real 25,966-file library, every many-to-many that model expressed was 1:1 in the data. - Ownership is a file. `GetFilePathsByRecordingMBIDs`, `LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and `pruneStaleLocalCrossReferences` all join `audio_files`, so the 812 orphaned recordings, 216 release groups and 260 artists that library carried are now structurally impossible. - One projection: every track query selects from the `track_metadata` view, one row type, one mapper. Nine hand-rolled copies had drifted far enough to report different years on different screens. - `library_id = 0` means every library, so each list query exists once instead of scoped and unscoped with a branch at every call site. - No migration chain. `sql/schemas/` is the one description of the shape; `sql/migrations/`, `applyMigrations` and `schema_migrations` are squashed away, along with the drift between them that had sqlc generating against a stale schema. - `database.InsertTestTrack` is the one test seeder; twenty test files had been assembling the old FK chain each in its own order. ## The catalog stores its ids as bytes `explore_index`'s three 36-char MBID columns and its entity-type text are 16 raw bytes and a small integer. The table and its six indexes go 780 MB to 405 MB on a real 2,052,200-row catalog, which is why a fresh install is ~0.6 GB rather than ~1.0 GB. - `backend/explore/mbid.go` is the only place the encoding is known; everything above it speaks dashed strings. - `CHECK(length(mbid) = 16)` makes a stringly write fail at the insert rather than silently returning no rows, since SQLite does not coerce between TEXT and BLOB. - The importer asks the artifact what encoding it carries and converts on the way in, so the artifact already published keeps working and no format bump is needed. - `indexRowColumns`/`scanIndexRow` replace four copies of a 22-column list, and `TestStoredEncodingRoundTrips` sweeps every read path. ## An album page that says how much of the album is yours - One question, asked once: is there a file. `filePaths` is filled by a single batched lookup when the tracklist settles, and the badge, the Play count, the dimmed rows and every menu item read it — replacing four claims of decreasing confidence that could show a green tick on an album whose every action did nothing. - Play, Play 7 of 12, or no play button at all. - `total_tracks` on `explore_index` (~2 bytes over 400,677 release groups) and on `audio_files` from tags that have always carried it: a complete MBID-matched album now makes no catalog call at all, where it used to spend the most expensive request the app makes. - A merged cluster shows the running order the most releases agree on, and the version list marks the release you own rather than standing a synthetic entry in for it. - `AlbumReleasesFailed`: a slow fetch is no longer reported as a failed one by a 12-second timer. - Rows not in the library are dimmed in place (with `aria-disabled`) instead of the owned ones wearing a green tick and a legend. ## Caches and cover art get ceilings - Only the three tiers of a cover are stored; the full-resolution copy nothing rendered was 1,134 MB of a 1.4 GB covers directory. - One artist portrait is downloaded and the rest are remembered as URLs — 4.1 GB of a 5.3 GB cache was candidates no code path reads. - `browsedArtBudget` and `httpCacheBudget` bound what an age cannot: the same install held art for 5,770 artists in a 1,301-artist library. - `OrphanedArtistImagesJob` joined a bare MBID onto a sharded directory, so it deleted the rows that were the only record of the files it left behind. `explore.ArtistImageDir` is that layout's one definition now. ## The autotag queue asks whether there is work `tagging_items` was a row per album folder, not a queue, and no query read the `tag_status` column that held the answer. The four queue queries ask the files, which matters most where it is least visible: `startPrefetch` was scoring every album in a tagged library against MusicBrainz. ## Phantom playlist tracks resolve in place An M3U8 imported before its files leaves phantom rows; they now match by path and fall back to position, keep their place in the playlist when resolved, and pair best-first so two phantoms cannot claim the same file. ## Playing a track plays the list it is in Double-click, and Play on a single row's menu, queue the list as displayed with `startIndex` on that row — the album page and the track list used to queue one track and discard the album around it. A multi-row selection still plays exactly itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
1702 lines
37 KiB
Go
1702 lines
37 KiB
Go
package smartplaylist
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"yellowjacket/backend/coverart"
|
|
"yellowjacket/backend/database"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test helpers — seed data
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// seedSmartPlaylistData inserts 8 tracks with the full FK chain
|
|
// required for smart playlist evaluation tests. Extends the
|
|
// seedSearchData pattern from search_test.go with a multi-genre
|
|
// track (ID 8) that has both "Rock" and "Alternative" genres.
|
|
//
|
|
// Track list:
|
|
//
|
|
// ID 1: "Bohemian Rhapsody" by "Queen" album "A Night at the Opera" (1975) genre=Rock
|
|
// ID 2: "Halo" by "Beyoncé" album "Lemonade" (2008) genre=Pop
|
|
// ID 3: "Back in Black" by "AC/DC" album "Back in Black" (1980) genre=Hard Rock
|
|
// ID 4: "Comfortably Numb" by "Pink Floyd" album "The Dark Side" (1979) genre=Progressive Rock
|
|
// ID 5: "Another One" by "Queen" album "The Game" (1980) genre=Funk Rock
|
|
// ID 6: "Thunderstruck" by "AC/DC" album "The Razors Edge" (1990) genre=Hard Rock
|
|
// ID 7: "No One Knows" by "QOTSA" album "Rated R" (2000) genre=Stoner Rock
|
|
// ID 8: "Under the Bridge" by "RHCP" album "Blood Sugar" (1991) genre=Rock+Alternative (multi-genre)
|
|
func seedSmartPlaylistData(t *testing.T, db *database.DB) {
|
|
t.Helper()
|
|
|
|
type track struct {
|
|
id int64
|
|
filePath string
|
|
title string
|
|
artist string
|
|
album string
|
|
trackNum *int64
|
|
discNum *int64
|
|
year int64
|
|
genres []string // supports multi-genre
|
|
composer string
|
|
lenMs int64
|
|
ftID int64
|
|
sr int64
|
|
bd int64
|
|
ch int64
|
|
br int64
|
|
fsize int64
|
|
}
|
|
|
|
intPtr := func(v int64) *int64 { return &v }
|
|
|
|
tracks := []track{
|
|
{
|
|
1, "/music/queen/bohemian_rhapsody.mp3",
|
|
"Bohemian Rhapsody", "Queen",
|
|
"A Night at the Opera", intPtr(11), intPtr(1),
|
|
1975,
|
|
[]string{"Rock"},
|
|
"Freddie Mercury",
|
|
354000, 0, 44100, 16, 2, 320000, 8500000,
|
|
},
|
|
{
|
|
2, "/music/beyonce/halo.flac",
|
|
"Halo", "Beyoncé", "Lemonade",
|
|
intPtr(1), intPtr(1),
|
|
2008,
|
|
[]string{"Pop"},
|
|
"Ryan Tedder",
|
|
261000, 1, 96000, 24, 2, 1411000, 42000000,
|
|
},
|
|
{
|
|
3, "/music/acdc/back_in_black.mp3",
|
|
"Back in Black", "AC/DC", "Back in Black",
|
|
intPtr(1), intPtr(1),
|
|
1980,
|
|
[]string{"Hard Rock"},
|
|
"Angus Young",
|
|
255000, 0, 44100, 16, 2, 320000, 6100000,
|
|
},
|
|
{
|
|
4, "/music/pinkfloyd/comfortably_numb.flac",
|
|
"Comfortably Numb", "Pink Floyd", "The Dark Side",
|
|
intPtr(6), intPtr(1),
|
|
1979,
|
|
[]string{"Progressive Rock"},
|
|
"David Gilmour",
|
|
382000, 1, 96000, 24, 2, 1411000, 54000000,
|
|
},
|
|
{
|
|
5, "/music/queen/another_one.mp3",
|
|
"Another One Bites the Dust", "Queen", "The Game",
|
|
intPtr(3), intPtr(1),
|
|
1980,
|
|
[]string{"Funk Rock"},
|
|
"John Deacon",
|
|
215000, 0, 44100, 16, 2, 320000, 5200000,
|
|
},
|
|
{
|
|
6, "/music/acdc/thunderstruck.mp3",
|
|
"Thunderstruck", "AC/DC", "The Razors Edge",
|
|
intPtr(1), intPtr(1),
|
|
1990,
|
|
[]string{"Hard Rock"},
|
|
"Angus Young",
|
|
292000, 0, 44100, 16, 2, 320000, 7000000,
|
|
},
|
|
{
|
|
7, "/music/qotsa/no_one_knows.mp3",
|
|
"No One Knows", "QOTSA", "Rated R",
|
|
intPtr(1), intPtr(1),
|
|
2000,
|
|
[]string{"Stoner Rock"},
|
|
"Josh Homme",
|
|
310000, 0, 44100, 16, 2, 320000, 7400000,
|
|
},
|
|
{
|
|
8, "/music/rhcp/under_the_bridge.mp3",
|
|
"Under the Bridge", "RHCP", "Blood Sugar",
|
|
intPtr(2), intPtr(1),
|
|
1991,
|
|
[]string{"Rock", "Alternative"},
|
|
"Anthony Kiedis",
|
|
264000, 0, 44100, 16, 2, 320000, 6300000,
|
|
},
|
|
}
|
|
|
|
for _, tr := range tracks {
|
|
var trackNum, discNum int64
|
|
if tr.trackNum != nil {
|
|
trackNum = *tr.trackNum
|
|
}
|
|
|
|
if tr.discNum != nil {
|
|
discNum = *tr.discNum
|
|
}
|
|
|
|
id := database.InsertTestTrack(t, db, database.TestTrack{
|
|
FilePath: tr.filePath,
|
|
Title: tr.title,
|
|
Artist: tr.artist,
|
|
Album: tr.album,
|
|
Genres: tr.genres,
|
|
TrackNumber: trackNum,
|
|
DiscNumber: discNum,
|
|
Year: tr.year,
|
|
LengthMs: tr.lenMs,
|
|
})
|
|
|
|
if _, err := db.ExecContext(
|
|
`UPDATE audio_files
|
|
SET file_type_id = ?, sample_rate = ?, bit_depth = ?,
|
|
channels = ?, bitrate = ?, file_size = ?, composer = ?
|
|
WHERE id = ?`,
|
|
tr.ftID, tr.sr, tr.bd, tr.ch, tr.br, tr.fsize, tr.composer, id,
|
|
); err != nil {
|
|
t.Fatalf("set audio properties for %q: %v", tr.filePath, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BuildWhereClause tests (pure — no DB needed)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestBuildWhereClause_TextIs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clause, args, err := BuildWhereClause([]Rule{
|
|
{Field: "artist", Operator: "is", Value: "Queen"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if clause != "artist_name = ? COLLATE NOCASE" {
|
|
t.Errorf("clause = %q, want %q", clause, "artist_name = ? COLLATE NOCASE")
|
|
}
|
|
|
|
if len(args) != 1 || args[0] != "Queen" {
|
|
t.Errorf("args = %v, want [Queen]", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildWhereClause_TextIsNot(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clause, args, err := BuildWhereClause([]Rule{
|
|
{Field: "artist", Operator: "is_not", Value: "Queen"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if clause != "artist_name != ? COLLATE NOCASE" {
|
|
t.Errorf("clause = %q, want %q",
|
|
clause, "artist_name != ? COLLATE NOCASE")
|
|
}
|
|
|
|
if len(args) != 1 || args[0] != "Queen" {
|
|
t.Errorf("args = %v, want [Queen]", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildWhereClause_TextContains(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clause, args, err := BuildWhereClause([]Rule{
|
|
{Field: "title", Operator: "contains", Value: "Black"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if clause != "title LIKE ?" {
|
|
t.Errorf("clause = %q, want %q", clause, "title LIKE ?")
|
|
}
|
|
|
|
if len(args) != 1 || args[0] != "%Black%" {
|
|
t.Errorf("args = %v, want [%%Black%%]", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildWhereClause_TextDoesNotContain(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clause, args, err := BuildWhereClause([]Rule{
|
|
{
|
|
Field: "title", Operator: "does_not_contain",
|
|
Value: "Black",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if clause != "title NOT LIKE ?" {
|
|
t.Errorf("clause = %q, want %q",
|
|
clause, "title NOT LIKE ?")
|
|
}
|
|
|
|
if len(args) != 1 || args[0] != "%Black%" {
|
|
t.Errorf("args = %v, want [%%Black%%]", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildWhereClause_TextStartsWith(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clause, args, err := BuildWhereClause([]Rule{
|
|
{Field: "title", Operator: "starts_with", Value: "Back"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if clause != "title LIKE ?" {
|
|
t.Errorf("clause = %q, want %q", clause, "title LIKE ?")
|
|
}
|
|
|
|
if len(args) != 1 || args[0] != "Back%" {
|
|
t.Errorf("args = %v, want [Back%%]", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildWhereClause_TextEndsWith(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clause, args, err := BuildWhereClause([]Rule{
|
|
{Field: "title", Operator: "ends_with", Value: "Black"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if clause != "title LIKE ?" {
|
|
t.Errorf("clause = %q, want %q", clause, "title LIKE ?")
|
|
}
|
|
|
|
if len(args) != 1 || args[0] != "%Black" {
|
|
t.Errorf("args = %v, want [%%Black]", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildWhereClause_TextIsAnyOf(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clause, args, err := BuildWhereClause([]Rule{
|
|
{
|
|
Field: "artist", Operator: "is_any_of",
|
|
Value: `["Queen","AC/DC"]`,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if clause != "artist_name IN (? COLLATE NOCASE, ? COLLATE NOCASE)" {
|
|
t.Errorf("clause = %q, want %q",
|
|
clause, "artist_name IN (? COLLATE NOCASE, ? COLLATE NOCASE)")
|
|
}
|
|
|
|
if len(args) != 2 || args[0] != "Queen" || args[1] != "AC/DC" {
|
|
t.Errorf("args = %v, want [Queen AC/DC]", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildWhereClause_NumericIs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clause, args, err := BuildWhereClause([]Rule{
|
|
{Field: "year", Operator: "is", Value: "1980"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if clause != "year = ?" {
|
|
t.Errorf("clause = %q, want %q", clause, "year = ?")
|
|
}
|
|
|
|
if len(args) != 1 || args[0] != int64(1980) {
|
|
t.Errorf("args = %v, want [1980]", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildWhereClause_NumericIsNot(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clause, args, err := BuildWhereClause([]Rule{
|
|
{Field: "year", Operator: "is_not", Value: "1980"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if clause != "year != ?" {
|
|
t.Errorf("clause = %q, want %q", clause, "year != ?")
|
|
}
|
|
|
|
if len(args) != 1 || args[0] != int64(1980) {
|
|
t.Errorf("args = %v, want [1980]", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildWhereClause_NumericGreaterThan(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clause, args, err := BuildWhereClause([]Rule{
|
|
{Field: "year", Operator: "greater_than", Value: "2000"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if clause != "year > ?" {
|
|
t.Errorf("clause = %q, want %q", clause, "year > ?")
|
|
}
|
|
|
|
if len(args) != 1 || args[0] != int64(2000) {
|
|
t.Errorf("args = %v, want [2000]", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildWhereClause_NumericLessThan(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clause, args, err := BuildWhereClause([]Rule{
|
|
{Field: "year", Operator: "less_than", Value: "1980"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if clause != "year < ?" {
|
|
t.Errorf("clause = %q, want %q", clause, "year < ?")
|
|
}
|
|
|
|
if len(args) != 1 || args[0] != int64(1980) {
|
|
t.Errorf("args = %v, want [1980]", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildWhereClause_NumericBetween(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clause, args, err := BuildWhereClause([]Rule{
|
|
{
|
|
Field: "year", Operator: "between",
|
|
Value: "1975,1985",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if clause != "year BETWEEN ? AND ?" {
|
|
t.Errorf("clause = %q, want %q",
|
|
clause, "year BETWEEN ? AND ?")
|
|
}
|
|
|
|
if len(args) != 2 || args[0] != int64(1975) || args[1] != int64(1985) {
|
|
t.Errorf("args = %v, want [1975 1985]", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildWhereClause_NumericBetweenJSON(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clause, args, err := BuildWhereClause([]Rule{
|
|
{
|
|
Field: "year", Operator: "between",
|
|
Value: `["1975","1985"]`,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if clause != "year BETWEEN ? AND ?" {
|
|
t.Errorf("clause = %q, want %q",
|
|
clause, "year BETWEEN ? AND ?")
|
|
}
|
|
|
|
if len(args) != 2 || args[0] != int64(1975) || args[1] != int64(1985) {
|
|
t.Errorf("args = %v, want [1975 1985]", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildWhereClause_GenreIsProducesSubquery(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clause, args, err := BuildWhereClause([]Rule{
|
|
{Field: "genre", Operator: "is", Value: "Rock"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// Must use subquery, NOT "genre = ?"
|
|
if strings.Contains(clause, "genre =") {
|
|
t.Errorf(
|
|
"genre 'is' should use subquery, not direct column match: %q",
|
|
clause,
|
|
)
|
|
}
|
|
|
|
if !strings.Contains(clause, "file_genres") {
|
|
t.Errorf("genre 'is' should reference file_genres: %q",
|
|
clause)
|
|
}
|
|
|
|
if !strings.Contains(clause, "g.name = ? COLLATE NOCASE") {
|
|
t.Errorf("genre 'is' should have g.name = ? COLLATE NOCASE: %q", clause)
|
|
}
|
|
|
|
if len(args) != 1 || args[0] != "Rock" {
|
|
t.Errorf("args = %v, want [Rock]", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildWhereClause_GenreIsNotProducesSubquery(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clause, args, err := BuildWhereClause([]Rule{
|
|
{Field: "genre", Operator: "is_not", Value: "Rock"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if !strings.Contains(clause, "NOT IN") {
|
|
t.Errorf("genre 'is_not' should use NOT IN: %q", clause)
|
|
}
|
|
|
|
if !strings.Contains(clause, "file_genres") {
|
|
t.Errorf(
|
|
"genre 'is_not' should reference file_genres: %q",
|
|
clause,
|
|
)
|
|
}
|
|
|
|
if len(args) != 1 || args[0] != "Rock" {
|
|
t.Errorf("args = %v, want [Rock]", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildWhereClause_GenreIsAnyOfProducesSubquery(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clause, args, err := BuildWhereClause([]Rule{
|
|
{
|
|
Field: "genre", Operator: "is_any_of",
|
|
Value: `["Rock","Pop"]`,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if !strings.Contains(clause, "file_genres") {
|
|
t.Errorf(
|
|
"genre 'is_any_of' should reference file_genres: %q",
|
|
clause,
|
|
)
|
|
}
|
|
|
|
if !strings.Contains(clause, "g.name IN (? COLLATE NOCASE, ? COLLATE NOCASE)") {
|
|
t.Errorf(
|
|
"genre 'is_any_of' should have g.name IN (? COLLATE NOCASE, ? COLLATE NOCASE): %q",
|
|
clause,
|
|
)
|
|
}
|
|
|
|
if len(args) != 2 || args[0] != "Rock" || args[1] != "Pop" {
|
|
t.Errorf("args = %v, want [Rock Pop]", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildWhereClause_GenreContainsUsesSubquery(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clause, args, err := BuildWhereClause([]Rule{
|
|
{Field: "genre", Operator: "contains", Value: "Rock"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// Since the smart playlist query no longer projects a concatenated
|
|
// genre column, "contains" filters genres via file_genres
|
|
// with g.name LIKE applied to individual genre rows.
|
|
if !strings.Contains(clause, "file_genres") {
|
|
t.Errorf(
|
|
"genre 'contains' should use file_genres subquery: %q",
|
|
clause,
|
|
)
|
|
}
|
|
|
|
if !strings.Contains(clause, "g.name LIKE ?") {
|
|
t.Errorf(
|
|
"genre 'contains' should filter with g.name LIKE ?: %q",
|
|
clause,
|
|
)
|
|
}
|
|
|
|
if len(args) != 1 || args[0] != "%Rock%" {
|
|
t.Errorf("args = %v, want [%%Rock%%]", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildWhereClause_MultipleRulesAND(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clause, args, err := BuildWhereClause([]Rule{
|
|
{Field: "artist", Operator: "is", Value: "Queen"},
|
|
{Field: "year", Operator: "greater_than", Value: "1975"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if clause != "artist_name = ? COLLATE NOCASE AND year > ?" {
|
|
t.Errorf("clause = %q, want %q",
|
|
clause, "artist_name = ? COLLATE NOCASE AND year > ?")
|
|
}
|
|
|
|
if len(args) != 2 || args[0] != "Queen" || args[1] != int64(1975) {
|
|
t.Errorf("args = %v, want [Queen 1975]", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildWhereClause_SameFieldMultipleTimes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clause, args, err := BuildWhereClause([]Rule{
|
|
{Field: "genre", Operator: "contains", Value: "Rock"},
|
|
{
|
|
Field: "genre", Operator: "does_not_contain",
|
|
Value: "Punk",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// Genre text ops combine via AND across subqueries against
|
|
// file_genres; the exact SQL shape is asserted elsewhere.
|
|
if !strings.Contains(clause, " AND ") {
|
|
t.Errorf("clause should combine rules with AND: %q", clause)
|
|
}
|
|
|
|
if !strings.Contains(clause, "af.id IN") {
|
|
t.Errorf("clause should include positive IN subquery: %q", clause)
|
|
}
|
|
|
|
if !strings.Contains(clause, "af.id NOT IN") {
|
|
t.Errorf("clause should include NOT IN subquery: %q", clause)
|
|
}
|
|
|
|
if len(args) != 2 ||
|
|
args[0] != "%Rock%" || args[1] != "%Punk%" {
|
|
t.Errorf("args = %v, want [%%Rock%% %%Punk%%]", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildWhereClause_EmptyRules(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clause, args, err := BuildWhereClause(nil)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if clause != "" {
|
|
t.Errorf("clause = %q, want empty string", clause)
|
|
}
|
|
|
|
if args != nil {
|
|
t.Errorf("args = %v, want nil", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildWhereClause_InvalidField(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, _, err := BuildWhereClause([]Rule{
|
|
{
|
|
Field: "nonexistent", Operator: "is",
|
|
Value: "anything",
|
|
},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid field, got nil")
|
|
}
|
|
|
|
if !strings.Contains(err.Error(), "invalid field") {
|
|
t.Errorf(
|
|
"error should mention 'invalid field': %v", err,
|
|
)
|
|
}
|
|
|
|
if !strings.Contains(err.Error(), "nonexistent") {
|
|
t.Errorf(
|
|
"error should include the field name: %v", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestBuildWhereClause_InvalidOperatorForNumeric(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, _, err := BuildWhereClause([]Rule{
|
|
{Field: "year", Operator: "contains", Value: "1980"},
|
|
})
|
|
if err == nil {
|
|
t.Fatal(
|
|
"expected error for text operator on numeric field",
|
|
)
|
|
}
|
|
|
|
if !strings.Contains(err.Error(), "invalid operator") {
|
|
t.Errorf(
|
|
"error should mention 'invalid operator': %v", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestBuildWhereClause_InvalidOperatorForText(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, _, err := BuildWhereClause([]Rule{
|
|
{
|
|
Field: "artist", Operator: "greater_than",
|
|
Value: "Queen",
|
|
},
|
|
})
|
|
if err == nil {
|
|
t.Fatal(
|
|
"expected error for numeric operator on text field",
|
|
)
|
|
}
|
|
|
|
if !strings.Contains(err.Error(), "invalid operator") {
|
|
t.Errorf(
|
|
"error should mention 'invalid operator': %v", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Evaluate tests (with DB)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestEvaluate_TextIs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
seedSmartPlaylistData(t, db)
|
|
|
|
tracks, err := Evaluate(db, RuleSet{
|
|
Rules: []Rule{
|
|
{Field: "artist", Operator: "is", Value: "Queen"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
|
|
if len(tracks) != 2 {
|
|
t.Fatalf("got %d tracks, want 2", len(tracks))
|
|
}
|
|
|
|
for _, tr := range tracks {
|
|
if tr.ArtistName != "Queen" {
|
|
t.Errorf(
|
|
"track %q has artist %q, want Queen",
|
|
tr.TrackName, tr.ArtistName,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEvaluate_ArtworkEnrichment verifies the presentation-only
|
|
// cover-art and MusicBrainz-ID fields are attached to matched tracks
|
|
// by the batched fetchArtwork pass (they are no longer part of the
|
|
// lean filter query).
|
|
func TestEvaluate_ArtworkEnrichment(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
|
|
// One file, fully identified: cover art on its album, MBIDs on the
|
|
// album, the artist and the file itself.
|
|
database.InsertTestTrack(t, db, database.TestTrack{
|
|
FilePath: "/music/bohemian.mp3",
|
|
Title: "Bohemian Rhapsody",
|
|
Artist: "Queen",
|
|
ArtistMBID: "artist-mbid-1",
|
|
Album: "A Night at the Opera",
|
|
AlbumMBID: "rg-mbid-1",
|
|
RecordingMBID: "rec-mbid-1",
|
|
LengthMs: 354000,
|
|
})
|
|
|
|
if _, err := db.ExecContext(
|
|
"INSERT INTO cover_art (id, file_path, mime_type) " +
|
|
"VALUES (1, '/covers/abc123.jpg', 'image/jpeg')",
|
|
); err != nil {
|
|
t.Fatalf("seed cover art: %v", err)
|
|
}
|
|
|
|
if _, err := db.ExecContext(
|
|
"UPDATE albums SET cover_art_id = 1 WHERE name = 'A Night at the Opera'",
|
|
); err != nil {
|
|
t.Fatalf("attach cover art: %v", err)
|
|
}
|
|
|
|
tracks, err := Evaluate(db, RuleSet{
|
|
Rules: []Rule{
|
|
{Field: "artist", Operator: "is", Value: "Queen"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
|
|
if len(tracks) != 1 {
|
|
t.Fatalf("got %d tracks, want 1", len(tracks))
|
|
}
|
|
|
|
got := tracks[0]
|
|
|
|
if got.CoverArtPath != coverart.ResolveURLs("/covers/abc123.jpg").Original {
|
|
t.Errorf("cover art = %q, want the resolved original", got.CoverArtPath)
|
|
}
|
|
|
|
if got.ArtistMBID != "artist-mbid-1" {
|
|
t.Errorf("artist mbid = %q, want artist-mbid-1", got.ArtistMBID)
|
|
}
|
|
|
|
if got.ReleaseGroupMBID != "rg-mbid-1" {
|
|
t.Errorf("release group mbid = %q, want rg-mbid-1", got.ReleaseGroupMBID)
|
|
}
|
|
|
|
if got.RecordingMBID != "rec-mbid-1" {
|
|
t.Errorf("recording mbid = %q, want rec-mbid-1", got.RecordingMBID)
|
|
}
|
|
}
|
|
|
|
func TestEvaluate_TextContains(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
seedSmartPlaylistData(t, db)
|
|
|
|
tracks, err := Evaluate(db, RuleSet{
|
|
Rules: []Rule{
|
|
{
|
|
Field: "title", Operator: "contains",
|
|
Value: "Black",
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
|
|
if len(tracks) != 1 {
|
|
t.Fatalf("got %d tracks, want 1", len(tracks))
|
|
}
|
|
|
|
if tracks[0].TrackName != "Back in Black" {
|
|
t.Errorf("got %q, want %q",
|
|
tracks[0].TrackName, "Back in Black")
|
|
}
|
|
}
|
|
|
|
func TestEvaluate_TextStartsWith(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
seedSmartPlaylistData(t, db)
|
|
|
|
tracks, err := Evaluate(db, RuleSet{
|
|
Rules: []Rule{
|
|
{
|
|
Field: "title", Operator: "starts_with",
|
|
Value: "Back",
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
|
|
if len(tracks) != 1 {
|
|
t.Fatalf("got %d tracks, want 1", len(tracks))
|
|
}
|
|
|
|
if tracks[0].TrackName != "Back in Black" {
|
|
t.Errorf("got %q, want %q",
|
|
tracks[0].TrackName, "Back in Black")
|
|
}
|
|
}
|
|
|
|
func TestEvaluate_TextEndsWith(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
seedSmartPlaylistData(t, db)
|
|
|
|
tracks, err := Evaluate(db, RuleSet{
|
|
Rules: []Rule{
|
|
{
|
|
Field: "title", Operator: "ends_with",
|
|
Value: "Numb",
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
|
|
if len(tracks) != 1 {
|
|
t.Fatalf("got %d tracks, want 1", len(tracks))
|
|
}
|
|
|
|
if tracks[0].TrackName != "Comfortably Numb" {
|
|
t.Errorf("got %q, want %q",
|
|
tracks[0].TrackName, "Comfortably Numb")
|
|
}
|
|
}
|
|
|
|
func TestEvaluate_TextDoesNotContain(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
seedSmartPlaylistData(t, db)
|
|
|
|
tracks, err := Evaluate(db, RuleSet{
|
|
Rules: []Rule{
|
|
{
|
|
Field: "title", Operator: "does_not_contain",
|
|
Value: "the",
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
|
|
// "the" appears in: "Another One Bites the Dust",
|
|
// "Under the Bridge". The rest should be returned.
|
|
for _, tr := range tracks {
|
|
if strings.Contains(
|
|
strings.ToLower(tr.TrackName), "the") {
|
|
t.Errorf(
|
|
"track %q should not contain 'the'",
|
|
tr.TrackName,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEvaluate_TextIsAnyOf(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
seedSmartPlaylistData(t, db)
|
|
|
|
tracks, err := Evaluate(db, RuleSet{
|
|
Rules: []Rule{
|
|
{
|
|
Field: "artist", Operator: "is_any_of",
|
|
Value: `["Queen","AC/DC"]`,
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
|
|
if len(tracks) != 4 {
|
|
t.Fatalf("got %d tracks, want 4 (2 Queen + 2 AC/DC)",
|
|
len(tracks))
|
|
}
|
|
|
|
for _, tr := range tracks {
|
|
if tr.ArtistName != "Queen" &&
|
|
tr.ArtistName != "AC/DC" {
|
|
t.Errorf(
|
|
"unexpected artist %q", tr.ArtistName,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEvaluate_NumericGreaterThan(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
seedSmartPlaylistData(t, db)
|
|
|
|
tracks, err := Evaluate(db, RuleSet{
|
|
Rules: []Rule{
|
|
{
|
|
Field: "year", Operator: "greater_than",
|
|
Value: "2000",
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
|
|
// Only "Halo" (2008) has year > 2000.
|
|
if len(tracks) != 1 {
|
|
t.Fatalf("got %d tracks, want 1", len(tracks))
|
|
}
|
|
|
|
if tracks[0].TrackName != "Halo" {
|
|
t.Errorf("got %q, want Halo", tracks[0].TrackName)
|
|
}
|
|
}
|
|
|
|
func TestEvaluate_NumericLessThan(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
seedSmartPlaylistData(t, db)
|
|
|
|
tracks, err := Evaluate(db, RuleSet{
|
|
Rules: []Rule{
|
|
{
|
|
Field: "year", Operator: "less_than",
|
|
Value: "1980",
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
|
|
// Bohemian Rhapsody (1975) and Comfortably Numb (1979)
|
|
if len(tracks) != 2 {
|
|
t.Fatalf("got %d tracks, want 2", len(tracks))
|
|
}
|
|
|
|
for _, tr := range tracks {
|
|
if tr.Year >= 1980 {
|
|
t.Errorf("track %q year=%d should be < 1980",
|
|
tr.TrackName, tr.Year)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEvaluate_NumericBetween(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
seedSmartPlaylistData(t, db)
|
|
|
|
tracks, err := Evaluate(db, RuleSet{
|
|
Rules: []Rule{
|
|
{
|
|
Field: "year", Operator: "between",
|
|
Value: "1975,1985",
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
|
|
// 1975: Bohemian Rhapsody, 1979: Comfortably Numb,
|
|
// 1980: Back in Black, 1980: Another One Bites the Dust
|
|
if len(tracks) != 4 {
|
|
t.Fatalf("got %d tracks, want 4", len(tracks))
|
|
}
|
|
|
|
for _, tr := range tracks {
|
|
if tr.Year < 1975 || tr.Year > 1985 {
|
|
t.Errorf(
|
|
"track %q year=%d outside 1975-1985",
|
|
tr.TrackName, tr.Year,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEvaluate_GenreIs_MultiGenreTrack(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
seedSmartPlaylistData(t, db)
|
|
|
|
// Genre "is Rock" must match track 8 (Rock+Alternative) and
|
|
// track 1 (Rock). This proves the subquery works correctly
|
|
// with multi-genre tracks.
|
|
tracks, err := Evaluate(db, RuleSet{
|
|
Rules: []Rule{
|
|
{Field: "genre", Operator: "is", Value: "Rock"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
|
|
if len(tracks) != 2 {
|
|
names := make([]string, len(tracks))
|
|
for i, tr := range tracks {
|
|
names[i] = tr.TrackName
|
|
}
|
|
|
|
t.Fatalf(
|
|
"genre 'is Rock' got %d tracks %v, want 2 "+
|
|
"(Bohemian Rhapsody + Under the Bridge)",
|
|
len(tracks), names,
|
|
)
|
|
}
|
|
|
|
foundBR := false
|
|
foundUTB := false
|
|
|
|
for _, tr := range tracks {
|
|
if tr.TrackName == "Bohemian Rhapsody" {
|
|
foundBR = true
|
|
}
|
|
|
|
if tr.TrackName == "Under the Bridge" {
|
|
foundUTB = true
|
|
}
|
|
}
|
|
|
|
if !foundBR {
|
|
t.Error("genre 'is Rock' missing Bohemian Rhapsody")
|
|
}
|
|
|
|
if !foundUTB {
|
|
t.Error(
|
|
"genre 'is Rock' missing Under the Bridge " +
|
|
"(multi-genre track)",
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestEvaluate_GenreIsNot_MultiGenreTrack(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
seedSmartPlaylistData(t, db)
|
|
|
|
// Genre "is_not Rock" must NOT return tracks 1 or 8.
|
|
tracks, err := Evaluate(db, RuleSet{
|
|
Rules: []Rule{
|
|
{
|
|
Field: "genre", Operator: "is_not",
|
|
Value: "Rock",
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
|
|
for _, tr := range tracks {
|
|
if tr.TrackName == "Bohemian Rhapsody" ||
|
|
tr.TrackName == "Under the Bridge" {
|
|
t.Errorf(
|
|
"genre 'is_not Rock' should not return %q",
|
|
tr.TrackName,
|
|
)
|
|
}
|
|
}
|
|
|
|
// Should return the other 6 tracks.
|
|
if len(tracks) != 6 {
|
|
t.Errorf("got %d tracks, want 6", len(tracks))
|
|
}
|
|
}
|
|
|
|
func TestEvaluate_GenreContains(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
seedSmartPlaylistData(t, db)
|
|
|
|
// "contains Rock" on the concatenated genre column should match
|
|
// any track that has "Rock" anywhere in its genre string.
|
|
tracks, err := Evaluate(db, RuleSet{
|
|
Rules: []Rule{
|
|
{
|
|
Field: "genre", Operator: "contains",
|
|
Value: "Rock",
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
|
|
// Rock (1,8), Hard Rock (3,6), Progressive Rock (4),
|
|
// Funk Rock (5), Stoner Rock (7) = 7 tracks
|
|
if len(tracks) != 7 {
|
|
names := make([]string, len(tracks))
|
|
for i, tr := range tracks {
|
|
names[i] = tr.TrackName
|
|
}
|
|
|
|
t.Fatalf(
|
|
"genre 'contains Rock' got %d tracks %v, want 7",
|
|
len(tracks), names,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestEvaluate_MultipleRulesAND(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
seedSmartPlaylistData(t, db)
|
|
|
|
// genre contains "Rock" AND year > 1970 AND year < 1981
|
|
tracks, err := Evaluate(db, RuleSet{
|
|
Rules: []Rule{
|
|
{
|
|
Field: "genre", Operator: "contains",
|
|
Value: "Rock",
|
|
},
|
|
{
|
|
Field: "year", Operator: "greater_than",
|
|
Value: "1970",
|
|
},
|
|
{
|
|
Field: "year", Operator: "less_than",
|
|
Value: "1981",
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
|
|
// Bohemian Rhapsody (1975, Rock), Comfortably Numb (1979, Progressive Rock),
|
|
// Back in Black (1980, Hard Rock), Another One (1980, Funk Rock)
|
|
if len(tracks) != 4 {
|
|
names := make([]string, len(tracks))
|
|
for i, tr := range tracks {
|
|
names[i] = tr.TrackName
|
|
}
|
|
|
|
t.Fatalf("got %d tracks %v, want 4", len(tracks), names)
|
|
}
|
|
}
|
|
|
|
func TestEvaluate_EmptyRulesReturnsAll(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
seedSmartPlaylistData(t, db)
|
|
|
|
tracks, err := Evaluate(db, RuleSet{})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
|
|
if len(tracks) != 8 {
|
|
t.Fatalf("got %d tracks, want 8 (all seeded)",
|
|
len(tracks))
|
|
}
|
|
}
|
|
|
|
func TestEvaluate_Limit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
seedSmartPlaylistData(t, db)
|
|
|
|
tracks, err := Evaluate(db, RuleSet{Limit: 3})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
|
|
if len(tracks) != 3 {
|
|
t.Fatalf("got %d tracks, want 3 (limited)",
|
|
len(tracks))
|
|
}
|
|
}
|
|
|
|
func TestEvaluate_SortByYearASC(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
seedSmartPlaylistData(t, db)
|
|
|
|
tracks, err := Evaluate(db, RuleSet{
|
|
SortField: "year",
|
|
SortDir: "ASC",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
|
|
if len(tracks) < 2 {
|
|
t.Fatalf("got %d tracks, want >= 2", len(tracks))
|
|
}
|
|
|
|
for i := 1; i < len(tracks); i++ {
|
|
if tracks[i].Year < tracks[i-1].Year {
|
|
t.Errorf(
|
|
"sort ASC violated: track[%d].Year=%d < "+
|
|
"track[%d].Year=%d",
|
|
i, tracks[i].Year, i-1, tracks[i-1].Year,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEvaluate_SortByYearDESC(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
seedSmartPlaylistData(t, db)
|
|
|
|
tracks, err := Evaluate(db, RuleSet{
|
|
SortField: "year",
|
|
SortDir: "DESC",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
|
|
if len(tracks) < 2 {
|
|
t.Fatalf("got %d tracks, want >= 2", len(tracks))
|
|
}
|
|
|
|
for i := 1; i < len(tracks); i++ {
|
|
if tracks[i].Year > tracks[i-1].Year {
|
|
t.Errorf(
|
|
"sort DESC violated: track[%d].Year=%d > "+
|
|
"track[%d].Year=%d",
|
|
i, tracks[i].Year, i-1, tracks[i-1].Year,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEvaluate_SortByRandom(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
seedSmartPlaylistData(t, db)
|
|
|
|
// Just verify it doesn't error.
|
|
tracks, err := Evaluate(db, RuleSet{
|
|
SortField: "random",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate with random sort: %v", err)
|
|
}
|
|
|
|
if len(tracks) != 8 {
|
|
t.Fatalf("got %d tracks, want 8", len(tracks))
|
|
}
|
|
}
|
|
|
|
func TestEvaluate_InvalidField(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
seedSmartPlaylistData(t, db)
|
|
|
|
_, err := Evaluate(db, RuleSet{
|
|
Rules: []Rule{
|
|
{Field: "bogus", Operator: "is", Value: "x"},
|
|
},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid field, got nil")
|
|
}
|
|
|
|
if !strings.Contains(err.Error(), "invalid field") {
|
|
t.Errorf("error should mention 'invalid field': %v",
|
|
err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SQL injection tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSQLInjection_FieldName(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, _, err := BuildWhereClause([]Rule{
|
|
{
|
|
Field: "title; DROP TABLE playlists",
|
|
Operator: "is", Value: "x",
|
|
},
|
|
})
|
|
if err == nil {
|
|
t.Fatal(
|
|
"expected error for injected field name, got nil",
|
|
)
|
|
}
|
|
|
|
if !strings.Contains(err.Error(), "invalid field") {
|
|
t.Errorf("error should mention 'invalid field': %v",
|
|
err)
|
|
}
|
|
}
|
|
|
|
func TestSQLInjection_Value(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
seedSmartPlaylistData(t, db)
|
|
|
|
// Value with SQL injection — should produce no error (safe
|
|
// parameterization) and return 0 results.
|
|
tracks, err := Evaluate(db, RuleSet{
|
|
Rules: []Rule{
|
|
{
|
|
Field: "title", Operator: "is",
|
|
Value: "'; DROP TABLE playlists; --",
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error with injection value: %v",
|
|
err)
|
|
}
|
|
|
|
if len(tracks) != 0 {
|
|
t.Errorf("got %d tracks, want 0", len(tracks))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ParseRuleSet tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestParseRuleSet_Valid(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
input := `{
|
|
"rules": [
|
|
{"field": "artist", "operator": "is", "value": "Queen"}
|
|
],
|
|
"limit": 50,
|
|
"sort_field": "year",
|
|
"sort_dir": "DESC"
|
|
}`
|
|
|
|
rs, err := ParseRuleSet(input)
|
|
if err != nil {
|
|
t.Fatalf("ParseRuleSet: %v", err)
|
|
}
|
|
|
|
if len(rs.Rules) != 1 {
|
|
t.Fatalf("got %d rules, want 1", len(rs.Rules))
|
|
}
|
|
|
|
if rs.Rules[0].Field != "artist" {
|
|
t.Errorf("Field = %q, want artist",
|
|
rs.Rules[0].Field)
|
|
}
|
|
|
|
if rs.Limit != 50 {
|
|
t.Errorf("Limit = %d, want 50", rs.Limit)
|
|
}
|
|
|
|
if rs.SortField != "year" {
|
|
t.Errorf("SortField = %q, want year", rs.SortField)
|
|
}
|
|
|
|
if rs.SortDir != "DESC" {
|
|
t.Errorf("SortDir = %q, want DESC", rs.SortDir)
|
|
}
|
|
}
|
|
|
|
func TestParseRuleSet_Invalid(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := ParseRuleSet("not json at all")
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid JSON, got nil")
|
|
}
|
|
|
|
if !strings.Contains(err.Error(), "invalid smart playlist") {
|
|
t.Errorf(
|
|
"error should mention 'invalid smart playlist': %v",
|
|
err,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestParseRuleSet_EmptyRules(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rs, err := ParseRuleSet(`{"rules":[]}`)
|
|
if err != nil {
|
|
t.Fatalf("ParseRuleSet: %v", err)
|
|
}
|
|
|
|
if len(rs.Rules) != 0 {
|
|
t.Errorf("got %d rules, want 0", len(rs.Rules))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Track field mapping test
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestEvaluate_TrackFieldMapping(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
seedSmartPlaylistData(t, db)
|
|
|
|
// Fetch Bohemian Rhapsody and verify all library.Track fields
|
|
// are correctly populated.
|
|
tracks, err := Evaluate(db, RuleSet{
|
|
Rules: []Rule{
|
|
{
|
|
Field: "title", Operator: "is",
|
|
Value: "Bohemian Rhapsody",
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
|
|
if len(tracks) != 1 {
|
|
t.Fatalf("got %d tracks, want 1", len(tracks))
|
|
}
|
|
|
|
tr := tracks[0]
|
|
|
|
if tr.TrackName != "Bohemian Rhapsody" {
|
|
t.Errorf("TrackName = %q", tr.TrackName)
|
|
}
|
|
|
|
if tr.ArtistName != "Queen" {
|
|
t.Errorf("ArtistName = %q", tr.ArtistName)
|
|
}
|
|
|
|
if tr.TrackLength != "354000" {
|
|
t.Errorf("TrackLength = %q, want 354000",
|
|
tr.TrackLength)
|
|
}
|
|
|
|
if tr.FilePath != "/music/queen/bohemian_rhapsody.mp3" {
|
|
t.Errorf("FilePath = %q", tr.FilePath)
|
|
}
|
|
|
|
if tr.TrackNumber != 11 {
|
|
t.Errorf("TrackNumber = %d, want 11", tr.TrackNumber)
|
|
}
|
|
|
|
if tr.DiscNumber != 1 {
|
|
t.Errorf("DiscNumber = %d, want 1", tr.DiscNumber)
|
|
}
|
|
|
|
if tr.Album != "A Night at the Opera" {
|
|
t.Errorf("Album = %q", tr.Album)
|
|
}
|
|
|
|
if len(tr.Genre) != 1 || tr.Genre[0] != "Rock" {
|
|
t.Errorf("Genre = %v, want [Rock]", tr.Genre)
|
|
}
|
|
|
|
if tr.Year != 1975 {
|
|
t.Errorf("Year = %d, want 1975", tr.Year)
|
|
}
|
|
|
|
if tr.Composer != "Freddie Mercury" {
|
|
t.Errorf("Composer = %q", tr.Composer)
|
|
}
|
|
|
|
if tr.FileType != ".mp3" {
|
|
t.Errorf("FileType = %q, want .mp3", tr.FileType)
|
|
}
|
|
|
|
if tr.SampleRate != 44100 {
|
|
t.Errorf("SampleRate = %d", tr.SampleRate)
|
|
}
|
|
|
|
if tr.BitDepth != 16 {
|
|
t.Errorf("BitDepth = %d", tr.BitDepth)
|
|
}
|
|
|
|
if tr.Channels != 2 {
|
|
t.Errorf("Channels = %d", tr.Channels)
|
|
}
|
|
|
|
if tr.Bitrate != 320000 {
|
|
t.Errorf("Bitrate = %d", tr.Bitrate)
|
|
}
|
|
|
|
if tr.FileSize != 8500000 {
|
|
t.Errorf("FileSize = %d", tr.FileSize)
|
|
}
|
|
}
|
|
|
|
// TestEvaluate_MultiGenreTrackGenreField verifies that a track with
|
|
// multiple genres has them split correctly into the []string field.
|
|
func TestEvaluate_MultiGenreTrackGenreField(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
seedSmartPlaylistData(t, db)
|
|
|
|
tracks, err := Evaluate(db, RuleSet{
|
|
Rules: []Rule{
|
|
{
|
|
Field: "title", Operator: "is",
|
|
Value: "Under the Bridge",
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
|
|
if len(tracks) != 1 {
|
|
t.Fatalf("got %d tracks, want 1", len(tracks))
|
|
}
|
|
|
|
tr := tracks[0]
|
|
|
|
if len(tr.Genre) != 2 {
|
|
t.Fatalf("Genre = %v, want 2 genres", tr.Genre)
|
|
}
|
|
|
|
hasRock := false
|
|
hasAlt := false
|
|
|
|
for _, g := range tr.Genre {
|
|
if g == "Rock" {
|
|
hasRock = true
|
|
}
|
|
|
|
if g == "Alternative" {
|
|
hasAlt = true
|
|
}
|
|
}
|
|
|
|
if !hasRock || !hasAlt {
|
|
t.Errorf(
|
|
"Genre = %v, want [Rock, Alternative]", tr.Genre,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestEvaluate_YearUsesOriginalReleaseYear is a regression test for a
|
|
// bug where the smart-playlist year filter tested recordings.year (the
|
|
// file's ID3/reissue tag year) instead of the release group's original
|
|
// first-release year, the way the canonical track_metadata view and the
|
|
// UI do. That made a 1977 album owned as a 2010s reissue leak into a
|
|
// "2010s" year filter even though it displays as 1977.
|
|
func TestEvaluate_YearUsesOriginalReleaseYear(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
|
|
// One track: a 1977 album the user owns as a 2013 reissue. The
|
|
// file's own year is 2013; the album's original-release year is
|
|
// 1977, and that is what a year filter means.
|
|
database.InsertTestTrack(t, db, database.TestTrack{
|
|
FilePath: "/music/b52s/rock_lobster.mp3",
|
|
Title: "Rock Lobster",
|
|
Artist: "The B-52's",
|
|
Album: "Reissue Compilation",
|
|
Year: 2013,
|
|
LengthMs: 300000,
|
|
})
|
|
|
|
if _, err := db.ExecContext(
|
|
"UPDATE albums SET year = 2013, original_year = 1977",
|
|
); err != nil {
|
|
t.Fatalf("set album years: %v", err)
|
|
}
|
|
|
|
// A "2010s" filter must NOT match — the album is originally from 1977.
|
|
tracks, err := Evaluate(db, RuleSet{
|
|
Rules: []Rule{
|
|
{Field: "year", Operator: "between", Value: "2010,2019"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate 2010s: %v", err)
|
|
}
|
|
|
|
if len(tracks) != 0 {
|
|
t.Errorf(
|
|
"2010s filter matched %d tracks, want 0 "+
|
|
"(reissue year leaked in)", len(tracks),
|
|
)
|
|
}
|
|
|
|
// A "1970s" filter must match — original_year is 1977.
|
|
tracks, err = Evaluate(db, RuleSet{
|
|
Rules: []Rule{
|
|
{Field: "year", Operator: "between", Value: "1970,1979"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate 1970s: %v", err)
|
|
}
|
|
|
|
if len(tracks) != 1 {
|
|
t.Fatalf(
|
|
"1970s filter matched %d tracks, want 1", len(tracks),
|
|
)
|
|
}
|
|
|
|
if tracks[0].Year != 1977 {
|
|
t.Errorf("Year = %d, want 1977", tracks[0].Year)
|
|
}
|
|
|
|
// The release_year field, by contrast, tracks the specific release
|
|
// owned (the 2013 reissue), so a 2010s filter on it MUST match.
|
|
tracks, err = Evaluate(db, RuleSet{
|
|
Rules: []Rule{
|
|
{Field: "release_year", Operator: "between", Value: "2010,2019"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate release_year 2010s: %v", err)
|
|
}
|
|
|
|
if len(tracks) != 1 {
|
|
t.Fatalf(
|
|
"release_year 2010s filter matched %d tracks, want 1",
|
|
len(tracks),
|
|
)
|
|
}
|
|
|
|
// And a 1970s release_year filter must NOT match — the owned
|
|
// release is from 2013.
|
|
tracks, err = Evaluate(db, RuleSet{
|
|
Rules: []Rule{
|
|
{Field: "release_year", Operator: "between", Value: "1970,1979"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate release_year 1970s: %v", err)
|
|
}
|
|
|
|
if len(tracks) != 0 {
|
|
t.Errorf(
|
|
"release_year 1970s filter matched %d tracks, want 0",
|
|
len(tracks),
|
|
)
|
|
}
|
|
}
|