wip on autotagging

This commit is contained in:
2026-05-01 11:52:50 -04:00
parent 5cf019a0ac
commit d5140395da
295 changed files with 11105 additions and 40714 deletions
+114 -2
View File
@@ -19,6 +19,7 @@ import (
"github.com/wailsapp/wails/v2/pkg/runtime"
"golang.org/x/sync/errgroup"
"yellowjacket/backend/autotag"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/events"
@@ -1001,8 +1002,19 @@ func (l *Library) saveAudioFile(
basename := filepath.Base(result.absolutePath)
af, err := q.CreateAudioFile(
l.ctx, sqlcgen.CreateAudioFileParams{
groupKey := autotag.GroupKey(
result.libraryID,
result.absolutePath,
tags.DiscNumber,
)
tagStatus := "untagged"
if tags.RecordingMBID != "" {
tagStatus = "user_confirmed"
}
af, err := q.CreateAudioFileWithGroupKey(
l.ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
FilePath: result.absolutePath,
LengthMilliseconds: result.lengthMillis,
FileTypeID: int64(
@@ -1019,6 +1031,8 @@ func (l *Library) saveAudioFile(
FileSize: props.FileSize,
Basename: basename,
LibraryID: result.libraryID,
GroupKey: groupKey,
TagStatus: tagStatus,
})
if err != nil {
return fmt.Errorf(
@@ -1026,6 +1040,24 @@ func (l *Library) saveAudioFile(
)
}
if err := q.UpsertTaggingItemOnTrackAdd(
l.ctx, sqlcgen.UpsertTaggingItemOnTrackAddParams{
GroupKey: groupKey,
LibraryID: result.libraryID,
AlbumName: tags.Album,
AlbumArtist: resolveAlbumArtistName(tags),
DiscNumber: int64(tags.DiscNumber),
},
); err != nil {
l.logger.Warn(
"could not upsert tagging_items row",
"path", result.absolutePath,
"err", err,
)
metrics.addWarning(result.absolutePath, "commit", err)
}
// Index in FTS5 search_index.
title := l.getRecordingName(tags, result.absolutePath)
@@ -1113,6 +1145,16 @@ func (l *Library) updateAudioFileMetadata(
tags = &metadata.TrackMetadata{}
}
if err := l.maybeRebindTaggingGroup(q, result, tags); err != nil {
l.logger.Warn(
"could not rebind tagging group after metadata update",
"path", result.absolutePath,
"err", err,
)
metrics.addWarning(result.absolutePath, "commit", err)
}
title := l.getRecordingName(tags, result.absolutePath)
artistName := tags.Artist
@@ -1299,6 +1341,76 @@ func (l *Library) updateMBIDs(
}
}
// resolveAlbumArtistName returns the album-artist tag for tagging-
// group bookkeeping, falling back to the track artist when the
// album-artist field is empty.
func resolveAlbumArtistName(tags *metadata.TrackMetadata) string {
if tags.AlbumArtist != "" {
return tags.AlbumArtist
}
return tags.Artist
}
// maybeRebindTaggingGroup recomputes the group key from the freshly
// extracted metadata and, if it differs from the row's current
// group_key, migrates the track: decrement the old group's count
// (dropping it if emptied), upsert the new group, and write the new
// key onto the audio_files row. A no-op when the key is unchanged.
func (l *Library) maybeRebindTaggingGroup(
q *sqlcgen.Queries,
result importResult,
tags *metadata.TrackMetadata,
) error {
newKey := autotag.GroupKey(
result.libraryID,
result.absolutePath,
tags.DiscNumber,
)
oldKey, err := q.GetAudioFileGroupKey(l.ctx, result.existingFileID)
if err != nil {
return fmt.Errorf("read existing group_key: %w", err)
}
if oldKey == newKey {
return nil
}
if oldKey != "" {
if err := q.DecrementTaggingItemTrackCount(l.ctx, oldKey); err != nil {
return fmt.Errorf("decrement old group: %w", err)
}
if err := q.DeleteTaggingItemIfEmpty(l.ctx, oldKey); err != nil {
return fmt.Errorf("cleanup old group: %w", err)
}
}
if err := q.UpsertTaggingItemOnTrackAdd(
l.ctx, sqlcgen.UpsertTaggingItemOnTrackAddParams{
GroupKey: newKey,
LibraryID: result.libraryID,
AlbumName: tags.Album,
AlbumArtist: resolveAlbumArtistName(tags),
DiscNumber: int64(tags.DiscNumber),
},
); err != nil {
return fmt.Errorf("upsert new group: %w", err)
}
if err := q.SetAudioFileGroupKey(
l.ctx, sqlcgen.SetAudioFileGroupKeyParams{
GroupKey: newKey,
ID: result.existingFileID,
},
); err != nil {
return fmt.Errorf("write new group_key: %w", err)
}
return nil
}
// processCoverArt saves cover art to disk and upserts the DB record,
// using the cache to skip work for previously seen images. When
// thumbChan is non-nil, thumbnail generation is dispatched to the
+32 -17
View File
@@ -87,24 +87,24 @@ func mapTrackRow(
}
t := Track{
TrackName: title,
ArtistName: artistName,
TrackLength: strconv.FormatInt(lengthMs, 10),
FilePath: filePath,
TrackNumber: trackNumber.Int64,
DiscNumber: discNumber.Int64,
Album: album,
Genre: splitGenres(genre),
Year: year,
Composer: composer,
FileType: fileType,
SampleRate: sampleRate,
BitDepth: bitDepth,
Channels: channels,
Bitrate: bitrate,
FileSize: fileSize,
TrackName: title,
ArtistName: artistName,
TrackLength: strconv.FormatInt(lengthMs, 10),
FilePath: filePath,
TrackNumber: trackNumber.Int64,
DiscNumber: discNumber.Int64,
Album: album,
Genre: splitGenres(genre),
Year: year,
Composer: composer,
FileType: fileType,
SampleRate: sampleRate,
BitDepth: bitDepth,
Channels: channels,
Bitrate: bitrate,
FileSize: fileSize,
PlayCount: playCount,
LastPlayed: lastPlayedStr,
LastPlayed: lastPlayedStr,
ArtistMBID: artistMBID,
ReleaseGroupMBID: releaseGroupMBID,
RecordingMBID: recordingMBID,
@@ -173,6 +173,12 @@ type Artist struct {
}
// Album represents an album for the cover grid display.
//
// Year is the album's preferred display year — the release-group's
// original-release-date (MusicBrainz first-release-date) when known,
// falling back to the file-tag year. ReleaseYear is the file-tag
// year of the specific release in the library; for a 2010 remaster
// of a 1973 album, Year=1973 and ReleaseYear=2010.
type Album struct {
ID int64
Name string
@@ -183,6 +189,7 @@ type Album struct {
CoverArtMedium string
CoverArtLarge string
Year int64
ReleaseYear int64
}
// GetAllTracks returns an array of track structs of every file in the library.
@@ -362,6 +369,8 @@ func (l *Library) GetAllAlbums() ([]Album, error) {
album.Year = row.Year.Int64
}
album.ReleaseYear = row.ReleaseYear
if row.Mbid.Valid {
album.MBID = row.Mbid.String
}
@@ -516,6 +525,8 @@ func (l *Library) GetAlbumsByArtist(
album.Year = row.Year.Int64
}
album.ReleaseYear = row.ReleaseYear
// Convert filesystem path to URL path for the asset handler.
if row.CoverArtPath != "" {
urls := coverart.ResolveURLs(row.CoverArtPath)
@@ -710,6 +721,8 @@ func (l *Library) GetAllAlbumsByLibrary(
album.Year = row.Year.Int64
}
album.ReleaseYear = row.ReleaseYear
if row.Mbid.Valid {
album.MBID = row.Mbid.String
}
@@ -819,6 +832,8 @@ func (l *Library) GetAlbumsByArtistByLibrary(
album.Year = row.Year.Int64
}
album.ReleaseYear = row.ReleaseYear
if row.CoverArtPath != "" {
urls := coverart.ResolveURLs(row.CoverArtPath)
album.CoverArtPath = urls.Original
+251
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"fmt"
"log/slog"
"sync/atomic"
"testing"
"yellowjacket/backend/database"
@@ -742,3 +743,253 @@ func TestEntityCache_EmptyFields(t *testing.T) {
)
}
}
// ---------------------------------------------------------------------------
// commitBatch + tagging_items bookkeeping (phase 008.3)
// ---------------------------------------------------------------------------
func TestCommitBatch_TaggingItemsBookkeeping(t *testing.T) {
t.Parallel()
lib, db := setupTestLibrary(t)
cache := newEntityCache()
metrics := newScanMetrics()
var added, updated, skipped atomic.Int64
batch := []importResult{
{
absolutePath: "/music/Artist/Album 1/01.mp3",
fileType: metadata.MP3,
lengthMillis: 200000,
tags: &metadata.TrackMetadata{
Title: "A1T1", Artist: "Artist", AlbumArtist: "Artist",
Album: "Album 1", TrackNumber: 1, DiscNumber: 0,
},
libraryID: 0,
},
{
absolutePath: "/music/Artist/Album 1/02.mp3",
fileType: metadata.MP3,
lengthMillis: 210000,
tags: &metadata.TrackMetadata{
Title: "A1T2", Artist: "Artist", AlbumArtist: "Artist",
Album: "Album 1", TrackNumber: 2, DiscNumber: 0,
},
libraryID: 0,
},
{
absolutePath: "/music/Artist/Album 2 [Disc 1]/01.mp3",
fileType: metadata.MP3,
lengthMillis: 220000,
tags: &metadata.TrackMetadata{
Title: "A2D1T1", Artist: "Artist", AlbumArtist: "Artist",
Album: "Album 2", TrackNumber: 1, DiscNumber: 1,
},
libraryID: 0,
},
{
absolutePath: "/music/Artist/Album 2 [Disc 2]/01.mp3",
fileType: metadata.MP3,
lengthMillis: 230000,
tags: &metadata.TrackMetadata{
Title: "A2D2T1", Artist: "Artist", AlbumArtist: "Artist",
Album: "Album 2", TrackNumber: 1, DiscNumber: 2,
},
libraryID: 0,
},
{
absolutePath: "/music/Orphan/singleton.mp3",
fileType: metadata.MP3,
lengthMillis: 100000,
tags: &metadata.TrackMetadata{
Title: "Orphan", Artist: "Solo", AlbumArtist: "Solo",
Album: "", TrackNumber: 0, DiscNumber: 0,
},
libraryID: 0,
},
}
if err := lib.commitBatch(batch, cache, metrics, &added, &updated, &skipped, nil); err != nil {
t.Fatalf("commitBatch: %v", err)
}
if added.Load() != int64(len(batch)) {
for _, w := range metrics.Warnings {
t.Logf("warning: path=%s phase=%s err=%s", w.FilePath, w.Phase, w.Err)
}
t.Fatalf("added = %d, want %d (skipped=%d)", added.Load(), len(batch), skipped.Load())
}
groupCount := queryInt(t, db, "SELECT COUNT(*) FROM tagging_items")
if groupCount != 4 {
t.Errorf("tagging_items count = %d, want 4", groupCount)
}
// Each group should carry the expected track_count.
wantCounts := map[[3]any]int64{
{int64(0), "Album 1", int64(0)}: 2,
{int64(0), "Album 2", int64(1)}: 1,
{int64(0), "Album 2", int64(2)}: 1,
{int64(0), "", int64(0)}: 1,
}
for key, want := range wantCounts {
libID, _ := key[0].(int64)
album, _ := key[1].(string)
disc, _ := key[2].(int64)
rows, err := db.QueryContext(
`SELECT track_count FROM tagging_items
WHERE library_id = ? AND album_name = ? AND disc_number = ?`,
libID, album, disc,
)
if err != nil {
t.Errorf("query track_count for %v: %v", key, err)
continue
}
var got int64
if rows.Next() {
if scanErr := rows.Scan(&got); scanErr != nil {
t.Errorf("scan track_count for %v: %v", key, scanErr)
}
}
_ = rows.Close()
if got != want {
t.Errorf("track_count for %v = %d, want %d", key, got, want)
}
}
}
func TestCommitBatch_AlbumTagChangeKeepsGroup(t *testing.T) {
t.Parallel()
// Folder-based grouping: if a track stays in the same folder
// but its album tag changes (very common — autotag itself
// rewrites album tags), the group_key should NOT change. Test
// guards against the old behavior where any album-tag drift
// would split the album into multiple groups.
lib, db := setupTestLibrary(t)
cache := newEntityCache()
metrics := newScanMetrics()
var added, updated, skipped atomic.Int64
initial := []importResult{
{
absolutePath: "/music/Artist/Album Folder/01.mp3",
fileType: metadata.MP3,
lengthMillis: 200000,
tags: &metadata.TrackMetadata{
Title: "Track", Artist: "Artist", AlbumArtist: "Artist",
Album: "Old Album",
},
libraryID: 0,
},
}
if err := lib.commitBatch(
initial,
cache,
metrics,
&added,
&updated,
&skipped,
nil,
); err != nil {
t.Fatalf("initial commitBatch: %v", err)
}
var (
fileID int64
originalGroup string
)
rows, err := db.QueryContext(
`SELECT id, group_key FROM audio_files WHERE file_path = ?`,
"/music/Artist/Album Folder/01.mp3",
)
if err != nil {
t.Fatalf("lookup file: %v", err)
}
if rows.Next() {
if scanErr := rows.Scan(&fileID, &originalGroup); scanErr != nil {
t.Fatalf("scan file: %v", scanErr)
}
}
_ = rows.Close()
update := []importResult{
{
absolutePath: "/music/Artist/Album Folder/01.mp3",
fileType: metadata.MP3,
lengthMillis: 200000,
existingFileID: fileID,
needsUpdate: true,
tags: &metadata.TrackMetadata{
Title: "Track", Artist: "Artist", AlbumArtist: "Artist",
Album: "Albums Canonical Name (Remastered 2024)",
},
libraryID: 0,
},
}
if err := lib.commitBatch(update, cache, metrics, &added, &updated, &skipped, nil); err != nil {
t.Fatalf("update commitBatch: %v", err)
}
groupCount := queryInt(t, db, `SELECT COUNT(*) FROM tagging_items`)
if groupCount != 1 {
t.Errorf("expected exactly 1 tagging_items row, got %d", groupCount)
}
rows2, err := db.QueryContext(
`SELECT group_key FROM audio_files WHERE id = ?`, fileID,
)
if err != nil {
t.Fatalf("lookup post-update: %v", err)
}
var afterGroup string
if rows2.Next() {
if scanErr := rows2.Scan(&afterGroup); scanErr != nil {
t.Fatalf("scan post-update: %v", scanErr)
}
}
_ = rows2.Close()
if afterGroup != originalGroup {
t.Errorf("group_key changed across album tag edit: %q → %q", originalGroup, afterGroup)
}
}
// queryInt runs a single-column scalar query and returns the first
// int64 result; fails the test on any error.
func queryInt(t *testing.T, db *database.DB, query string, args ...any) int64 {
t.Helper()
rows, err := db.QueryContext(query, args...)
if err != nil {
t.Fatalf("query %q: %v", query, err)
}
defer func() { _ = rows.Close() }()
var got int64
if rows.Next() {
if err := rows.Scan(&got); err != nil {
t.Fatalf("scan %q: %v", query, err)
}
}
return got
}