wip on autotagging
This commit is contained in:
+114
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user