feat: autotag scoring overhaul, dump-based explore index, and lyrics search
Consolidates in-progress work across autotag, explore, and library: - autotag: beets/Picard-informed scoring engine — ID-first matching, VA handling, recommendation tiers, and a merged distance/rank cascade, with an eval harness for regression tracking. - explore: offline MusicBrainz dump import/incremental refresh replaces the legacy tier crawl; index-first local search with fuzzy matching and a dedicated ranker; disk-free guards for dump downloads. - library: artist-credit extraction and matching. - lyrics: owned-library lyric search (FTS) with LRCLIB backfill. Also: rewrite README to be user-focused, and migrate upstream to git.ljones.me/yonlu/yellowjacket. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"yellowjacket/backend/coverart"
|
||||
"yellowjacket/backend/database"
|
||||
"yellowjacket/backend/library"
|
||||
)
|
||||
@@ -54,6 +55,7 @@ var fieldMap = map[string]string{
|
||||
"album": "album",
|
||||
"genre": "genre",
|
||||
"year": "year",
|
||||
"release_year": "release_year",
|
||||
"composer": "composer",
|
||||
"file_type": "file_type",
|
||||
"duration": "length_milliseconds",
|
||||
@@ -72,6 +74,7 @@ var fieldMap = map[string]string{
|
||||
// numericFields identifies fields that accept numeric operators.
|
||||
var numericFields = map[string]bool{
|
||||
"year": true,
|
||||
"release_year": true,
|
||||
"duration": true,
|
||||
"sample_rate": true,
|
||||
"bit_depth": true,
|
||||
@@ -552,7 +555,11 @@ const leanTrackQuery = `SELECT
|
||||
af.bitrate,
|
||||
af.file_size,
|
||||
af.play_count,
|
||||
COALESCE(af.last_played, '') AS last_played
|
||||
COALESCE(af.last_played, '') AS last_played,
|
||||
af.cover_art_path,
|
||||
af.artist_mbid,
|
||||
af.release_group_mbid,
|
||||
af.recording_mbid
|
||||
FROM (
|
||||
SELECT
|
||||
af.id,
|
||||
@@ -564,7 +571,17 @@ FROM (
|
||||
r.track_number,
|
||||
r.disc_number,
|
||||
COALESCE(rg.name, '') AS album,
|
||||
COALESCE(r.year, 0) AS year,
|
||||
-- Two year fields, matching the canonical track_metadata view:
|
||||
-- year — the album's original (first-release) year,
|
||||
-- the default users filter on. A 1977 album owned
|
||||
-- as a 2010s reissue still filters as 1977.
|
||||
-- release_year — the year of the specific release in the library
|
||||
-- (the file/release-group tag), e.g. 2013 for that
|
||||
-- reissue.
|
||||
-- Both fall back through rg.year → r.year so a track without full
|
||||
-- MusicBrainz data still gets a sensible year.
|
||||
COALESCE(rg.original_year, rg.year, r.year, 0) AS year,
|
||||
COALESCE(rg.year, r.year, 0) AS release_year,
|
||||
COALESCE(r.composer, '') AS composer,
|
||||
COALESCE(ft.extension, '') AS file_type,
|
||||
af.sample_rate,
|
||||
@@ -574,7 +591,15 @@ FROM (
|
||||
af.file_size,
|
||||
af.library_id,
|
||||
af.play_count,
|
||||
af.last_played
|
||||
af.last_played,
|
||||
COALESCE(ca.file_path, '') AS cover_art_path,
|
||||
COALESCE((SELECT a.mbid
|
||||
FROM artist_credit_artist aca
|
||||
JOIN artists a ON a.id = aca.artist_id
|
||||
WHERE aca.credit_id = ac.id
|
||||
LIMIT 1), '') AS artist_mbid,
|
||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
||||
COALESCE(r.mbid, '') AS recording_mbid
|
||||
FROM audio_files af
|
||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
@@ -585,6 +610,7 @@ FROM (
|
||||
GROUP BY recording_id
|
||||
) rgr ON r.id = rgr.recording_id
|
||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
||||
LEFT JOIN file_types ft ON af.file_type_id = ft.id
|
||||
) af`
|
||||
|
||||
@@ -752,6 +778,11 @@ func scanTracks(rows *sql.Rows) ([]library.Track, []int64, error) {
|
||||
fileSize int64
|
||||
playCount int64
|
||||
lastPlayed string
|
||||
|
||||
coverArtPath string
|
||||
artistMBID string
|
||||
releaseGroupMBID string
|
||||
recordingMBID string
|
||||
)
|
||||
|
||||
if err := rows.Scan(
|
||||
@@ -761,31 +792,46 @@ func scanTracks(rows *sql.Rows) ([]library.Track, []int64, error) {
|
||||
&sampleRate, &bitDepth, &channels,
|
||||
&bitrate, &fileSize,
|
||||
&playCount, &lastPlayed,
|
||||
&coverArtPath, &artistMBID,
|
||||
&releaseGroupMBID, &recordingMBID,
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf(
|
||||
"could not scan smart playlist row: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
tracks = append(tracks, library.Track{
|
||||
TrackName: title,
|
||||
ArtistName: artistName,
|
||||
TrackLength: strconv.FormatInt(lengthMs, 10),
|
||||
FilePath: filePath,
|
||||
TrackNumber: trackNumber.Int64,
|
||||
DiscNumber: discNumber.Int64,
|
||||
Album: album,
|
||||
Year: year,
|
||||
Composer: composer,
|
||||
FileType: fileType,
|
||||
SampleRate: sampleRate,
|
||||
BitDepth: bitDepth,
|
||||
Channels: channels,
|
||||
Bitrate: bitrate,
|
||||
FileSize: fileSize,
|
||||
PlayCount: playCount,
|
||||
LastPlayed: lastPlayed,
|
||||
})
|
||||
track := library.Track{
|
||||
TrackName: title,
|
||||
ArtistName: artistName,
|
||||
TrackLength: strconv.FormatInt(lengthMs, 10),
|
||||
FilePath: filePath,
|
||||
TrackNumber: trackNumber.Int64,
|
||||
DiscNumber: discNumber.Int64,
|
||||
Album: album,
|
||||
Year: year,
|
||||
Composer: composer,
|
||||
FileType: fileType,
|
||||
SampleRate: sampleRate,
|
||||
BitDepth: bitDepth,
|
||||
Channels: channels,
|
||||
Bitrate: bitrate,
|
||||
FileSize: fileSize,
|
||||
PlayCount: playCount,
|
||||
LastPlayed: lastPlayed,
|
||||
ArtistMBID: artistMBID,
|
||||
ReleaseGroupMBID: releaseGroupMBID,
|
||||
RecordingMBID: recordingMBID,
|
||||
}
|
||||
|
||||
if coverArtPath != "" {
|
||||
urls := coverart.ResolveURLs(coverArtPath)
|
||||
track.CoverArtPath = urls.Original
|
||||
track.CoverArtSmall = urls.Small
|
||||
track.CoverArtMedium = urls.Medium
|
||||
track.CoverArtLarge = urls.Large
|
||||
}
|
||||
|
||||
tracks = append(tracks, track)
|
||||
recordingIDs = append(recordingIDs, recordingID.Int64)
|
||||
}
|
||||
|
||||
|
||||
@@ -1618,3 +1618,120 @@ func TestEvaluate_MultiGenreTrackGenreField(t *testing.T) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// tag / recording year is 2013, but the release group's original
|
||||
// (first-release) year is 1977.
|
||||
exec := func(query string, args ...any) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := db.ExecContext(query, args...); err != nil {
|
||||
t.Fatalf("exec %q: %v", query, err)
|
||||
}
|
||||
}
|
||||
|
||||
exec("INSERT INTO artist_credit (id, text) VALUES (1, ?)", "The B-52's")
|
||||
exec(
|
||||
"INSERT INTO release_groups (id, name, year, original_year) "+
|
||||
"VALUES (1, ?, 2013, 1977)",
|
||||
"Reissue Compilation",
|
||||
)
|
||||
exec(
|
||||
"INSERT INTO recordings (id, name, artist_credit_id, year) "+
|
||||
"VALUES (1, ?, 1, 2013)",
|
||||
"Rock Lobster",
|
||||
)
|
||||
exec(
|
||||
"INSERT INTO audio_files (id, file_path, length_milliseconds, "+
|
||||
"file_type_id, recording_id) VALUES (1, ?, 300000, 1, 1)",
|
||||
"/music/b52s/rock_lobster.mp3",
|
||||
)
|
||||
exec(
|
||||
"INSERT INTO release_group_recordings " +
|
||||
"(release_group_id, recording_id) VALUES (1, 1)",
|
||||
)
|
||||
|
||||
// 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user