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
+550 -6
View File
@@ -18,6 +18,7 @@ import (
"github.com/BurntSushi/toml"
_ "modernc.org/sqlite" // Register sqlite driver.
"yellowjacket/backend/autotag"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/profiling"
"yellowjacket/backend/system"
@@ -717,7 +718,9 @@ func runMigrations(
}
if version < 27 { //nolint:mnd
logger.Info("applying migration 27: split explore_cache into http_cache and artist_metadata")
logger.Info(
"applying migration 27: split explore_cache into http_cache and artist_metadata",
)
// Create the new tables (no-op if schemas/*.sql already created them).
if _, err := db.ExecContext(ctx, `
@@ -810,7 +813,9 @@ func runMigrations(
}
if version < 28 { //nolint:mnd
logger.Info("applying migration 28: repair broken similar_artist_map data from multi-seed labs bug")
logger.Info(
"applying migration 28: repair broken similar_artist_map data from multi-seed labs bug",
)
// The multi-seed POST form of the labs similar-artists endpoint
// returns mis-grouped results — each seed ends up with a random
@@ -830,7 +835,11 @@ func runMigrations(
"DELETE FROM explore_index_meta WHERE key = 'tier4_built'",
); err != nil {
// Not fatal — the meta table might not exist yet.
logger.Warn("migration 28: clear tier4_built failed (ok on fresh install)", "error", err)
logger.Warn(
"migration 28: clear tier4_built failed (ok on fresh install)",
"error",
err,
)
}
if _, err := db.ExecContext(ctx,
@@ -849,7 +858,9 @@ func runMigrations(
}
if version < 29 { //nolint:mnd
logger.Info("applying migration 29: discog_fetched column to track full indexer pipeline coverage")
logger.Info(
"applying migration 29: discog_fetched column to track full indexer pipeline coverage",
)
// Add a discog_fetched column to explore_index. When set to 1
// on an artist row, the indexer's fetchTopRecordings/
@@ -868,7 +879,11 @@ func runMigrations(
`); err != nil {
// May fail if migration runs against a fresh schema (column
// will be created by the schema file instead). Don't bail.
logger.Warn("migration 29: add discog_fetched column failed (ok if fresh)", "error", err)
logger.Warn(
"migration 29: add discog_fetched column failed (ok if fresh)",
"error",
err,
)
}
// Backfill: any artist with at least 5 recordings was almost
@@ -899,7 +914,9 @@ func runMigrations(
}
if version < 30 { //nolint:mnd
logger.Info("applying migration 30: invalidate MB browse-releases cache for recording MBID fix")
logger.Info(
"applying migration 30: invalidate MB browse-releases cache for recording MBID fix",
)
// Earlier versions of convertRelease used the MusicBrainz
// track MBID instead of the recording MBID for MBTrack.MBID.
@@ -923,8 +940,76 @@ func runMigrations(
}
}
if version < 31 { //nolint:mnd
if err := migration31TagStatus(ctx, db, logger); err != nil {
return err
}
}
if version < 32 { //nolint:mnd
if err := migration32TaggingItems(ctx, db, logger); err != nil {
return err
}
}
if version < 33 { //nolint:mnd
if err := migration33AutotagWarning(ctx, db, logger); err != nil {
return err
}
}
if version < 34 { //nolint:mnd
if err := migration34FolderBasedGroupKey(ctx, db, logger); err != nil {
return err
}
}
if version < 35 { //nolint:mnd
if err := migration35OriginalYear(ctx, db, logger); err != nil {
return err
}
}
if version < 36 { //nolint:mnd
if err := migration36ClearedAt(ctx, db, logger); err != nil {
return err
}
}
return nil
}
// migration36ClearedAt adds tagging_items.cleared_at — a nullable
// timestamp set when the user invokes "clear completed entries".
// Cleared rows stay in the table (so a re-scan doesn't resurrect
// them as pending) but get filtered from the review queue.
func migration36ClearedAt(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 36: tagging_items.cleared_at")
if _, err := db.ExecContext(
ctx,
`ALTER TABLE tagging_items ADD COLUMN cleared_at DATETIME`,
); err != nil {
if !strings.Contains(err.Error(), "duplicate column name") {
return fmt.Errorf("migration 36: add cleared_at: %w", err)
}
logger.Warn("migration 36: cleared_at already present (ok if fresh)", "err", err)
}
if _, err := db.ExecContext(ctx, "PRAGMA user_version = 36"); err != nil {
return fmt.Errorf("migration 36: set user_version: %w", err)
}
logger.Info("migration 36 complete")
return nil
}
// backfills it from file_path, creates the basename index, and
// populates the FTS5 search_index table.
func migration2BasenameAndFTS(
@@ -2749,3 +2834,462 @@ func migration20TrackRecordingMBID(
return nil
}
// migration31TagStatus adds the tag_status column to audio_files,
// indexes the "untagged" slice for the pending-count badge, and
// backfills rows whose recording already carries an MBID as
// `user_confirmed`. Everything else stays at the `untagged`
// default. The column-level CHECK constraint is added inline with
// the ALTER TABLE — SQLite supports column constraints in ADD
// COLUMN, so existing DBs pick it up too.
func migration31TagStatus(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 31: tag_status column")
if _, err := db.ExecContext(ctx, `
ALTER TABLE audio_files
ADD COLUMN tag_status TEXT NOT NULL DEFAULT 'untagged'
CHECK(tag_status IN (
'untagged', 'auto_matched', 'user_confirmed', 'user_skipped_permanent'
))
`); err != nil && !isDuplicateColumnErr(err) {
return fmt.Errorf("migration 31: add tag_status: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_audio_files_tag_status_untagged
ON audio_files(library_id) WHERE tag_status = 'untagged'
`); err != nil {
return fmt.Errorf("migration 31: create index: %w", err)
}
if _, err := db.ExecContext(ctx, `
UPDATE audio_files
SET tag_status = 'user_confirmed'
WHERE tag_status = 'untagged'
AND recording_id IN (
SELECT id FROM recordings
WHERE mbid IS NOT NULL AND mbid != ''
)
`); err != nil {
return fmt.Errorf("migration 31: backfill tag_status: %w", err)
}
if _, err := db.ExecContext(ctx,
"PRAGMA user_version = 31",
); err != nil {
return fmt.Errorf("migration 31: set user_version: %w", err)
}
logger.Info("migration 31 complete")
return nil
}
// migration32TaggingItems creates the tagging_items table and adds
// the group_key column to audio_files, then backfills both from the
// current `audio_files` / `recordings` / `release_groups` state.
// The Go-side autotag.GroupKey helper is the single source of truth
// for the key format (keeps the hash algorithm decoupled from SQL).
//
// SAFETY: Hand-crafted ALTER TABLE + CREATE TABLE + streaming
// backfill inside a single transaction.
func migration32TaggingItems(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 32: tagging_items + group_key")
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS tagging_items (
group_key TEXT PRIMARY KEY,
library_id INTEGER NOT NULL,
track_count INTEGER NOT NULL DEFAULT 0,
album_name TEXT NOT NULL DEFAULT '',
album_artist TEXT NOT NULL DEFAULT '',
disc_number INTEGER NOT NULL DEFAULT 0,
best_match_release_mbid TEXT,
score REAL,
last_checked_at DATETIME,
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending', 'matched', 'confirmed', 'skipped')),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(library_id) REFERENCES libraries(id)
)
`); err != nil {
return fmt.Errorf("migration 32: create tagging_items: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_tagging_items_library_status
ON tagging_items(library_id, status)
`); err != nil {
return fmt.Errorf("migration 32: create library_status index: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_tagging_items_status_pending
ON tagging_items(library_id) WHERE status = 'pending'
`); err != nil {
return fmt.Errorf("migration 32: create pending index: %w", err)
}
if _, err := db.ExecContext(ctx, `
ALTER TABLE audio_files
ADD COLUMN group_key TEXT NOT NULL DEFAULT ''
`); err != nil && !isDuplicateColumnErr(err) {
return fmt.Errorf("migration 32: add group_key: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_audio_files_group_key
ON audio_files(group_key) WHERE group_key != ''
`); err != nil {
return fmt.Errorf("migration 32: create group_key index: %w", err)
}
if err := backfillGroupKeys(ctx, db, logger); err != nil {
return fmt.Errorf("migration 32: backfill group_key: %w", err)
}
if err := aggregateTaggingItems(ctx, db, logger); err != nil {
return fmt.Errorf("migration 32: aggregate tagging_items: %w", err)
}
if _, err := db.ExecContext(ctx,
"PRAGMA user_version = 32",
); err != nil {
return fmt.Errorf("migration 32: set user_version: %w", err)
}
logger.Info("migration 32 complete")
return nil
}
// backfillGroupKeys streams existing audio_files rows in batches of
// ~500 and writes the computed group_key back via a single UPDATE
// per row inside one transaction. It joins to release_groups for
// the album name and recordings for the disc number; both fall back
// to the zero value when absent.
func backfillGroupKeys(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
const batchSize = 500
type row struct {
id int64
libraryID int64
filePath string
discNumber int64
}
for {
// Each pass reads the next N rows with group_key still empty;
// once updated, they drop out of the filter, so no OFFSET
// bookkeeping is needed.
rows, err := db.QueryContext(ctx, `
SELECT af.id, af.library_id, af.file_path,
COALESCE(r.disc_number, 0)
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
WHERE af.group_key = ''
ORDER BY af.id
LIMIT ?
`, batchSize)
if err != nil {
return fmt.Errorf("select batch: %w", err)
}
batch := make([]row, 0, batchSize)
for rows.Next() {
var r row
if scanErr := rows.Scan(
&r.id, &r.libraryID, &r.filePath, &r.discNumber,
); scanErr != nil {
_ = rows.Close()
return fmt.Errorf("scan row: %w", scanErr)
}
batch = append(batch, r)
}
if closeErr := rows.Close(); closeErr != nil {
return fmt.Errorf("close rows: %w", closeErr)
}
if len(batch) == 0 {
break
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
for _, r := range batch {
key := autotag.GroupKey(
r.libraryID, r.filePath, int(r.discNumber),
)
if _, err := tx.ExecContext(ctx,
`UPDATE audio_files SET group_key = ? WHERE id = ?`,
key, r.id,
); err != nil {
_ = tx.Rollback()
return fmt.Errorf("update row %d: %w", r.id, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
logger.Debug(
"migration 32: backfilled group_key batch", "count", len(batch),
)
if len(batch) < batchSize {
break
}
}
return nil
}
// aggregateTaggingItems populates tagging_items from the now-
// populated audio_files.group_key, one row per (group_key,
// library_id) pair. Status defaults to `confirmed` when every
// track in the group already has tag_status `user_confirmed`,
// otherwise `pending`.
func aggregateTaggingItems(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
result, err := db.ExecContext(ctx, `
INSERT INTO tagging_items (
group_key, library_id, track_count,
album_name, album_artist, disc_number, status
)
SELECT
af.group_key,
af.library_id,
COUNT(*) AS track_count,
COALESCE(MAX(rg.name), '') AS album_name,
COALESCE(MAX(ac.text), '') AS album_artist,
COALESCE(MAX(r.disc_number), 0) AS disc_number,
CASE WHEN SUM(CASE WHEN af.tag_status = 'user_confirmed' THEN 0 ELSE 1 END) = 0
THEN 'confirmed' ELSE 'pending' END AS status
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN release_group_recordings rgr ON rgr.recording_id = r.id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
WHERE af.group_key != ''
GROUP BY af.group_key, af.library_id
ON CONFLICT(group_key) DO NOTHING
`)
if err != nil {
return fmt.Errorf("insert aggregates: %w", err)
}
if n, rowsErr := result.RowsAffected(); rowsErr == nil {
logger.Debug(
"migration 32: aggregated tagging_items rows",
"count", n,
)
}
return nil
}
// migration33AutotagWarning adds the per-library flag that records
// whether the user has seen (and dismissed) the first-time autotag
// apply warning. Zero means "still warn"; one means acknowledged.
func migration33AutotagWarning(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 33: libraries.autotag_warning_acked")
if _, err := db.ExecContext(ctx, `
ALTER TABLE libraries
ADD COLUMN autotag_warning_acked INTEGER NOT NULL DEFAULT 0
`); err != nil && !isDuplicateColumnErr(err) {
return fmt.Errorf("migration 33: add column: %w", err)
}
if _, err := db.ExecContext(ctx,
"PRAGMA user_version = 33",
); err != nil {
return fmt.Errorf("migration 33: set user_version: %w", err)
}
logger.Info("migration 33 complete")
return nil
}
// migration35OriginalYear adds release_groups.original_year (the
// release-group's MusicBrainz first-release-date year) and rebuilds
// the track_metadata view so its "year" column prefers the original
// release year over the file-tag year. This makes a 1973 album
// show as 1973 in the tracklist and smart-playlist year rules even
// when the user owns the 2010 remaster. release_year is added as
// a separate view column for callers that need the file-tag year.
func migration35OriginalYear(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 35: release_groups.original_year + view rebuild")
if _, err := db.ExecContext(
ctx,
`ALTER TABLE release_groups ADD COLUMN original_year INTEGER`,
); err != nil {
// Tolerate duplicate-column on re-run / fresh-DB schema race.
if !strings.Contains(err.Error(), "duplicate column name") {
return fmt.Errorf("migration 35: add original_year: %w", err)
}
logger.Warn("migration 35: original_year already present (ok if fresh)", "err", err)
}
// Drop and recreate the track_metadata view so its year column
// picks up the new fallback chain. CREATE VIEW IF NOT EXISTS
// in the schema file is a no-op once the view exists, so we have
// to do this explicitly here for existing DBs.
//
// The body must match sql/schemas/track_metadata_view.sql.
if _, err := db.ExecContext(ctx, `DROP VIEW IF EXISTS track_metadata`); err != nil {
return fmt.Errorf("migration 35: drop view: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE VIEW track_metadata AS
SELECT
af.id,
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
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,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size,
af.library_id,
af.play_count,
af.last_played,
COALESCE(ca.file_path, '') AS cover_art_path,
COALESCE(a.mbid, '') 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
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN (
SELECT recording_id,
MIN(release_group_id) AS release_group_id
FROM release_group_recordings
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
`); err != nil {
return fmt.Errorf("migration 35: recreate view: %w", err)
}
if _, err := db.ExecContext(ctx, "PRAGMA user_version = 35"); err != nil {
return fmt.Errorf("migration 35: set user_version: %w", err)
}
logger.Info("migration 35 complete")
return nil
}
// migration34FolderBasedGroupKey recomputes every audio_files
// row's group_key with the new folder-based algorithm (album tag
// dropped from the hash inputs). Tracks in the same parent
// directory + same disc number now share a key regardless of any
// per-track variation in their album tag — fixes the fragmenting
// behaviour where one album would produce N one-track tagging
// groups when its tracks carried slightly different album strings.
//
// After the recompute, tagging_items is wiped and re-aggregated
// from the new keys. The user's review state is reset; this is a
// blunt instrument but the right one — a partial migration would
// leave fragments of the old shape stranded in pending status.
//
// SAFETY: clears tagging_items unconditionally on existing DBs.
// On fresh DBs (test_data) the tagging_items aggregate at the end
// of the migration just no-ops since audio_files is empty.
func migration34FolderBasedGroupKey(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 34: folder-based group_key")
// Wipe stale state first so the recompute can stream into a
// clean tagging_items table.
if _, err := db.ExecContext(ctx, `DELETE FROM tagging_items`); err != nil {
return fmt.Errorf("migration 34: clear tagging_items: %w", err)
}
// Force every audio_files.group_key back to '' so the existing
// backfill logic (which filters WHERE group_key = '') can
// recompute every row.
if _, err := db.ExecContext(ctx,
`UPDATE audio_files SET group_key = ''`,
); err != nil {
return fmt.Errorf("migration 34: clear group_keys: %w", err)
}
if err := backfillGroupKeys(ctx, db, logger); err != nil {
return fmt.Errorf("migration 34: backfill: %w", err)
}
if err := aggregateTaggingItems(ctx, db, logger); err != nil {
return fmt.Errorf("migration 34: aggregate: %w", err)
}
if _, err := db.ExecContext(ctx,
"PRAGMA user_version = 34",
); err != nil {
return fmt.Errorf("migration 34: set user_version: %w", err)
}
logger.Info("migration 34 complete")
return nil
}
+6
View File
@@ -1079,11 +1079,13 @@ func TestMigration11ExploreCache(t *testing.T) {
if !verRows.Next() {
_ = verRows.Close()
t.Fatal("PRAGMA user_version: no row returned")
}
if err := verRows.Scan(&version); err != nil {
_ = verRows.Close()
t.Fatalf("scan user_version: %v", err)
}
@@ -1111,6 +1113,7 @@ func TestMigration11ExploreCache(t *testing.T) {
if err := tblRows.Scan(&tableCount); err != nil {
_ = tblRows.Close()
t.Fatalf("scan table count: %v", err)
}
@@ -1151,6 +1154,7 @@ func TestMigration11ExploreCache(t *testing.T) {
&cid, &name, &colType, &notNull, &dfltValue, &pk,
); err != nil {
_ = colRows.Close()
t.Fatalf("scan table_info row: %v", err)
}
@@ -1182,6 +1186,7 @@ func TestMigration11ExploreCache(t *testing.T) {
if err := idxRows.Scan(&name); err != nil {
_ = idxRows.Close()
t.Fatalf("scan index name: %v", err)
}
@@ -1216,6 +1221,7 @@ func TestMigration11ExploreCache(t *testing.T) {
if !rows.Next() {
_ = rows.Close()
t.Fatal("explore_cache row not found")
}
@@ -2,6 +2,21 @@
INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *;
-- name: CreateAudioFileWithGroupKey :one
INSERT INTO audio_files (
file_path, length_milliseconds, file_type_id, recording_id,
sample_rate, bit_depth, channels, bitrate, file_size, basename,
library_id, group_key, tag_status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *;
-- name: GetAudioFileGroupKey :one
SELECT group_key FROM audio_files
WHERE id = ? LIMIT 1;
-- name: SetAudioFileGroupKey :exec
UPDATE audio_files SET group_key = ? WHERE id = ?;
-- name: GetAudioFile :one
SELECT * FROM audio_files
WHERE id = ? LIMIT 1;
@@ -19,3 +19,6 @@ DELETE FROM libraries WHERE id = ?;
-- name: CountLibraries :one
SELECT COUNT(*) AS count FROM libraries;
-- name: AckLibraryAutotagWarning :exec
UPDATE libraries SET autotag_warning_acked = 1 WHERE id = ?;
@@ -24,6 +24,13 @@ ON CONFLICT(name, album_artist_credit_id) DO UPDATE SET
year = COALESCE(excluded.year, release_groups.year)
RETURNING *;
-- name: SetReleaseGroupOriginalYear :exec
-- Set the release group's original-release-year (release-group's
-- first-release-date from MusicBrainz). Called from autotag apply
-- when the user confirms a candidate; the file-tag year stays in
-- the year column.
UPDATE release_groups SET original_year = ? WHERE id = ?;
-- name: UpdateReleaseGroup :exec
UPDATE release_groups
SET name = ?
@@ -49,7 +56,12 @@ ORDER BY name;
SELECT
rg.id,
rg.name,
rg.year,
-- year prefers original release year (MB first-release-date)
-- over the file-tag year so the UI surfaces the album's
-- original year by default. release_year keeps the file-tag
-- year accessible.
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
rg.mbid,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
COALESCE(ca.file_path, '') as cover_art_path
@@ -69,7 +81,12 @@ ORDER BY rg.name;
SELECT
rg.id,
rg.name,
rg.year,
-- year prefers original release year (MB first-release-date)
-- over the file-tag year so the UI surfaces the album's
-- original year by default. release_year keeps the file-tag
-- year accessible.
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
rg.mbid,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
COALESCE(ca.file_path, '') as cover_art_path
@@ -96,7 +113,8 @@ ORDER BY rg.name;
SELECT
rg.id,
rg.name,
rg.year,
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
COALESCE(ca.file_path, '') as cover_art_path
FROM release_groups rg
@@ -120,7 +138,8 @@ SELECT COUNT(*) FROM release_group_recordings WHERE release_group_id = ?;
SELECT
rg.id,
rg.name,
rg.year,
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
COALESCE(ca.file_path, '') as cover_art_path
FROM release_groups rg
@@ -0,0 +1,236 @@
-- name: UpsertTaggingItemOnTrackAdd :exec
INSERT INTO tagging_items (
group_key, library_id, track_count,
album_name, album_artist, disc_number, status
)
VALUES (?, ?, 1, ?, ?, ?, 'pending')
ON CONFLICT(group_key) DO UPDATE SET
track_count = tagging_items.track_count + 1,
album_name = CASE
WHEN tagging_items.album_name = '' THEN excluded.album_name
ELSE tagging_items.album_name
END,
album_artist = CASE
WHEN tagging_items.album_artist = '' THEN excluded.album_artist
ELSE tagging_items.album_artist
END;
-- name: DecrementTaggingItemTrackCount :exec
UPDATE tagging_items
SET track_count = track_count - 1
WHERE group_key = ?;
-- name: DeleteTaggingItemIfEmpty :exec
DELETE FROM tagging_items
WHERE group_key = ? AND track_count <= 0;
-- name: GetTaggingItem :one
SELECT * FROM tagging_items
WHERE group_key = ?
LIMIT 1;
-- name: CountPendingTaggingItems :one
SELECT COUNT(*) FROM tagging_items
WHERE status = 'pending'
AND (CAST(@library_id AS INTEGER) = 0 OR library_id = @library_id);
-- name: ListPendingTaggingItemsAlphabetical :many
SELECT
ti.group_key,
ti.library_id,
COALESCE(lb.name, '') AS library_name,
ti.track_count,
ti.album_name,
ti.album_artist,
ti.disc_number,
ti.best_match_release_mbid,
ti.score,
ti.last_checked_at,
ti.status,
ti.created_at
FROM tagging_items ti
LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
AND (CAST(@status_filter AS TEXT) = 'all' OR ti.status = @status_filter)
AND ti.cleared_at IS NULL
ORDER BY LOWER(ti.album_artist), LOWER(ti.album_name), ti.disc_number
LIMIT @row_limit OFFSET @row_offset;
-- name: ListPendingTaggingItemsByScore :many
SELECT
ti.group_key,
ti.library_id,
COALESCE(lb.name, '') AS library_name,
COALESCE(lb.path, '') AS library_path,
CAST(COALESCE((SELECT af.file_path FROM audio_files af WHERE af.group_key = ti.group_key LIMIT 1), '') AS TEXT) AS sample_file_path,
ti.track_count,
ti.album_name,
ti.album_artist,
ti.disc_number,
ti.best_match_release_mbid,
ti.score,
ti.last_checked_at,
ti.status,
ti.created_at
FROM tagging_items ti
LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
AND (CAST(@status_filter AS TEXT) = 'all' OR ti.status = @status_filter)
AND ti.cleared_at IS NULL
ORDER BY ti.score IS NULL, ti.score DESC, LOWER(ti.album_artist), LOWER(ti.album_name)
LIMIT @row_limit OFFSET @row_offset;
-- name: ClearCompletedTaggingItems :exec
UPDATE tagging_items
SET cleared_at = CURRENT_TIMESTAMP
WHERE status = 'confirmed'
AND cleared_at IS NULL
AND (CAST(@library_id AS INTEGER) = 0 OR library_id = @library_id);
-- name: GetPendingFolderDetail :one
SELECT
ti.group_key,
ti.library_id,
COALESCE(lb.name, '') AS library_name,
COALESCE(lb.path, '') AS library_path,
CAST(COALESCE((SELECT af.file_path FROM audio_files af WHERE af.group_key = ti.group_key LIMIT 1), '') AS TEXT) AS sample_file_path,
ti.track_count,
ti.album_name,
ti.album_artist,
ti.disc_number,
ti.best_match_release_mbid,
ti.score,
ti.last_checked_at,
ti.status,
ti.created_at
FROM tagging_items ti
LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE ti.group_key = ?
LIMIT 1;
-- name: ListPendingTaggingItemsByRecent :many
SELECT
ti.group_key,
ti.library_id,
COALESCE(lb.name, '') AS library_name,
ti.track_count,
ti.album_name,
ti.album_artist,
ti.disc_number,
ti.best_match_release_mbid,
ti.score,
ti.last_checked_at,
ti.status,
ti.created_at
FROM tagging_items ti
LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
AND (CAST(@status_filter AS TEXT) = 'all' OR ti.status = @status_filter)
ORDER BY ti.created_at DESC, ti.group_key
LIMIT @row_limit OFFSET @row_offset;
-- name: ListAudioFilesInTaggingGroup :many
SELECT
af.id,
af.file_path,
af.basename,
af.length_milliseconds,
af.tag_status,
COALESCE(r.track_number, 0) AS track_number,
COALESCE(r.disc_number, 0) AS disc_number,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
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
WHERE af.group_key = ?
ORDER BY COALESCE(r.disc_number, 0),
COALESCE(r.track_number, 0),
af.file_path;
-- name: ListLocalReleaseGroupCandidates :many
-- Returns one row per (release_group, track) combination for any
-- local release_group that has an MBID. Callers group these in Go
-- and filter by normalized album-name match. Joined case-insensitive
-- on name to pre-filter cheaply; Go does the real normalization.
SELECT
rg.id AS release_group_id,
rg.mbid AS release_group_mbid,
rg.name AS album_name,
COALESCE(rg.year, 0) AS year,
COALESCE(ac.text, '') AS artist_credit,
COALESCE(rgr.track_number, 0) AS track_number,
COALESCE(rgr.disc_number, 0) AS disc_number,
COALESCE(r.name, '') AS track_title,
COALESCE(r.mbid, '') AS recording_mbid,
COALESCE(local_af.length_milliseconds, 0) AS length_milliseconds
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN recordings r ON r.id = rgr.recording_id
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
LEFT JOIN audio_files local_af ON local_af.recording_id = r.id
WHERE rg.mbid IS NOT NULL
AND rg.mbid != ''
AND r.mbid IS NOT NULL
AND r.mbid != ''
AND rg.name = ? COLLATE NOCASE
ORDER BY rg.id, rgr.disc_number, rgr.track_number;
-- name: SetTaggingItemBestMatch :exec
UPDATE tagging_items
SET best_match_release_mbid = ?,
score = ?,
status = ?,
last_checked_at = CURRENT_TIMESTAMP
WHERE group_key = ?;
-- name: SetTaggingItemScore :exec
UPDATE tagging_items
SET best_match_release_mbid = ?,
score = ?,
last_checked_at = CURRENT_TIMESTAMP
WHERE group_key = ?;
-- name: SetTaggingItemStatus :exec
UPDATE tagging_items
SET status = ?,
last_checked_at = CURRENT_TIMESTAMP
WHERE group_key = ?;
-- name: SetAudioFileTagStatus :exec
UPDATE audio_files SET tag_status = ? WHERE id = ?;
-- name: SetRecordingMBID :exec
UPDATE recordings SET mbid = ? WHERE id = ?;
-- name: SetReleaseGroupMBID :exec
UPDATE release_groups SET mbid = ? WHERE id = ?;
-- name: GetRecordingReleaseGroupID :one
SELECT COALESCE(rgr.release_group_id, 0) AS release_group_id
FROM release_group_recordings rgr
WHERE rgr.recording_id = ?
LIMIT 1;
-- name: GetNextPendingTaggingItem :one
SELECT
ti.group_key,
ti.library_id,
COALESCE(lb.name, '') AS library_name,
ti.track_count,
ti.album_name,
ti.album_artist,
ti.disc_number,
ti.best_match_release_mbid,
ti.score,
ti.last_checked_at,
ti.status,
ti.created_at
FROM tagging_items ti
LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE ti.status = 'pending'
AND (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
AND ti.group_key > @after_group_key
ORDER BY ti.group_key
LIMIT 1;
+5 -4
View File
@@ -1,6 +1,7 @@
CREATE TABLE IF NOT EXISTS libraries (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
autotag_warning_acked INTEGER NOT NULL DEFAULT 0
);
@@ -13,6 +13,11 @@ CREATE TABLE IF NOT EXISTS audio_files (
library_id int NOT NULL DEFAULT 0,
play_count int NOT NULL DEFAULT 0,
last_played datetime,
tag_status TEXT NOT NULL DEFAULT 'untagged'
CHECK(tag_status IN (
'untagged', 'auto_matched', 'user_confirmed', 'user_skipped_permanent'
)),
group_key TEXT NOT NULL DEFAULT '',
FOREIGN KEY(file_type_id) REFERENCES file_types(id),
FOREIGN KEY(recording_id) REFERENCES recordings(id),
FOREIGN KEY(library_id) REFERENCES libraries(id)
@@ -24,3 +29,11 @@ CREATE INDEX IF NOT EXISTS idx_audio_files_recording_id
-- idx_audio_files_library_id is created by migration 6 (not here) because
-- on existing databases this schema file is a no-op (CREATE TABLE IF NOT EXISTS)
-- and the library_id column doesn't exist until the migration adds it.
--
-- idx_audio_files_tag_status_untagged + idx_audio_files_group_key are
-- created by migrations 31 and 32 for the same reason — on a pre-31
-- database the partial index predicates (`WHERE tag_status = '...'`
-- and `WHERE group_key != ''`) would reference columns that don't
-- yet exist, since CREATE TABLE IF NOT EXISTS does not add columns
-- to existing tables. sqlc still sees the columns above, and fresh
-- DBs pick up the indexes inside the migrations.
@@ -3,7 +3,18 @@ CREATE TABLE IF NOT EXISTS release_groups (
name TEXT NOT NULL,
cover_art_id INTEGER,
album_artist_credit_id INTEGER,
-- year is the *technical release year* of the album as it lives
-- in the user's library — typically the file's ID3 year tag,
-- which for remasters/reissues is the reissue year.
year INTEGER,
-- original_year is the album's *first-release-date* year sourced
-- from MusicBrainz' release-group.first-release-date. For a 2010
-- remaster of a 1973 album, year=2010 and original_year=1973.
-- Populated by autotag apply; NULL until the user accepts a
-- candidate (or for libraries that have never been autotagged).
-- Reads should COALESCE(original_year, year) to get the
-- preferred user-facing year.
original_year INTEGER,
total_tracks INTEGER,
total_discs INTEGER,
mbid TEXT,
@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS tagging_items (
group_key TEXT PRIMARY KEY,
library_id INTEGER NOT NULL,
track_count INTEGER NOT NULL DEFAULT 0,
album_name TEXT NOT NULL DEFAULT '',
album_artist TEXT NOT NULL DEFAULT '',
disc_number INTEGER NOT NULL DEFAULT 0,
best_match_release_mbid TEXT,
score REAL,
last_checked_at DATETIME,
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending', 'matched', 'confirmed', 'skipped')),
-- cleared_at is set when the user explicitly removes the item
-- from the queue ("clear completed entries"). Cleared rows are
-- excluded from the queue list but kept in the table so a
-- subsequent rescan of the same folder doesn't reset the
-- review state.
cleared_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(library_id) REFERENCES libraries(id)
);
CREATE INDEX IF NOT EXISTS idx_tagging_items_library_status
ON tagging_items(library_id, status);
CREATE INDEX IF NOT EXISTS idx_tagging_items_status_pending
ON tagging_items(library_id) WHERE status = 'pending';
@@ -15,7 +15,13 @@ SELECT
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
-- Year defaults to the release group's original release year
-- (MusicBrainz first-release-date) so a 1973 album shows as
-- 1973 even if the user owns the 2010 remaster. Falls back
-- to release-group year (file tag), then to recording year.
-- See release_groups.original_year for full semantics.
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,
+107 -6
View File
@@ -35,7 +35,7 @@ func (q *Queries) CountAudioFilesByLibrary(ctx context.Context, libraryID int64)
const createAudioFile = `-- name: CreateAudioFile :one
INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played
RETURNING id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key
`
type CreateAudioFileParams struct {
@@ -82,6 +82,71 @@ func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams
&i.LibraryID,
&i.PlayCount,
&i.LastPlayed,
&i.TagStatus,
&i.GroupKey,
)
return i, err
}
const createAudioFileWithGroupKey = `-- name: CreateAudioFileWithGroupKey :one
INSERT INTO audio_files (
file_path, length_milliseconds, file_type_id, recording_id,
sample_rate, bit_depth, channels, bitrate, file_size, basename,
library_id, group_key, tag_status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key
`
type CreateAudioFileWithGroupKeyParams struct {
FilePath string
LengthMilliseconds int64
FileTypeID int64
RecordingID int64
SampleRate int64
BitDepth int64
Channels int64
Bitrate int64
FileSize int64
Basename string
LibraryID int64
GroupKey string
TagStatus string
}
func (q *Queries) CreateAudioFileWithGroupKey(ctx context.Context, arg CreateAudioFileWithGroupKeyParams) (AudioFile, error) {
row := q.db.QueryRowContext(ctx, createAudioFileWithGroupKey,
arg.FilePath,
arg.LengthMilliseconds,
arg.FileTypeID,
arg.RecordingID,
arg.SampleRate,
arg.BitDepth,
arg.Channels,
arg.Bitrate,
arg.FileSize,
arg.Basename,
arg.LibraryID,
arg.GroupKey,
arg.TagStatus,
)
var i AudioFile
err := row.Scan(
&i.ID,
&i.FilePath,
&i.LengthMilliseconds,
&i.FileTypeID,
&i.RecordingID,
&i.SampleRate,
&i.BitDepth,
&i.Channels,
&i.Bitrate,
&i.FileSize,
&i.Basename,
&i.LibraryID,
&i.PlayCount,
&i.LastPlayed,
&i.TagStatus,
&i.GroupKey,
)
return i, err
}
@@ -138,7 +203,7 @@ func (q *Queries) GetAllAudioFilePaths(ctx context.Context) ([]GetAllAudioFilePa
}
const getAllAudioFiles = `-- name: GetAllAudioFiles :many
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played FROM audio_files
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key FROM audio_files
`
func (q *Queries) GetAllAudioFiles(ctx context.Context) ([]AudioFile, error) {
@@ -165,6 +230,8 @@ func (q *Queries) GetAllAudioFiles(ctx context.Context) ([]AudioFile, error) {
&i.LibraryID,
&i.PlayCount,
&i.LastPlayed,
&i.TagStatus,
&i.GroupKey,
); err != nil {
return nil, err
}
@@ -460,7 +527,7 @@ func (q *Queries) GetAllTracksWithFullMetadataByLibrary(ctx context.Context, lib
}
const getAudioFile = `-- name: GetAudioFile :one
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played FROM audio_files
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key FROM audio_files
WHERE id = ? LIMIT 1
`
@@ -482,12 +549,14 @@ func (q *Queries) GetAudioFile(ctx context.Context, id int64) (AudioFile, error)
&i.LibraryID,
&i.PlayCount,
&i.LastPlayed,
&i.TagStatus,
&i.GroupKey,
)
return i, err
}
const getAudioFileByPath = `-- name: GetAudioFileByPath :one
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played FROM audio_files
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key FROM audio_files
WHERE file_path = ? LIMIT 1
`
@@ -509,12 +578,26 @@ func (q *Queries) GetAudioFileByPath(ctx context.Context, filePath string) (Audi
&i.LibraryID,
&i.PlayCount,
&i.LastPlayed,
&i.TagStatus,
&i.GroupKey,
)
return i, err
}
const getAudioFileGroupKey = `-- name: GetAudioFileGroupKey :one
SELECT group_key FROM audio_files
WHERE id = ? LIMIT 1
`
func (q *Queries) GetAudioFileGroupKey(ctx context.Context, id int64) (string, error) {
row := q.db.QueryRowContext(ctx, getAudioFileGroupKey, id)
var group_key string
err := row.Scan(&group_key)
return group_key, err
}
const getAudioFilesByLibrary = `-- name: GetAudioFilesByLibrary :many
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played FROM audio_files WHERE library_id = ?
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key FROM audio_files WHERE library_id = ?
`
func (q *Queries) GetAudioFilesByLibrary(ctx context.Context, libraryID int64) ([]AudioFile, error) {
@@ -541,6 +624,8 @@ func (q *Queries) GetAudioFilesByLibrary(ctx context.Context, libraryID int64) (
&i.LibraryID,
&i.PlayCount,
&i.LastPlayed,
&i.TagStatus,
&i.GroupKey,
); err != nil {
return nil, err
}
@@ -769,7 +854,7 @@ func (q *Queries) GetAudioFilesByReleaseGroupByLibrary(ctx context.Context, arg
}
const getAudioFilesNeedingMetadata = `-- name: GetAudioFilesNeedingMetadata :many
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played FROM audio_files
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key FROM audio_files
WHERE recording_id = 0
`
@@ -797,6 +882,8 @@ func (q *Queries) GetAudioFilesNeedingMetadata(ctx context.Context) ([]AudioFile
&i.LibraryID,
&i.PlayCount,
&i.LastPlayed,
&i.TagStatus,
&i.GroupKey,
); err != nil {
return nil, err
}
@@ -999,6 +1086,20 @@ func (q *Queries) SearchAudioFilesByBasename(ctx context.Context, arg SearchAudi
return items, nil
}
const setAudioFileGroupKey = `-- name: SetAudioFileGroupKey :exec
UPDATE audio_files SET group_key = ? WHERE id = ?
`
type SetAudioFileGroupKeyParams struct {
GroupKey string
ID int64
}
func (q *Queries) SetAudioFileGroupKey(ctx context.Context, arg SetAudioFileGroupKeyParams) error {
_, err := q.db.ExecContext(ctx, setAudioFileGroupKey, arg.GroupKey, arg.ID)
return err
}
const updateAudioFile = `-- name: UpdateAudioFile :exec
UPDATE audio_files
SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?, basename = ?
+17 -4
View File
@@ -9,6 +9,15 @@ import (
"context"
)
const ackLibraryAutotagWarning = `-- name: AckLibraryAutotagWarning :exec
UPDATE libraries SET autotag_warning_acked = 1 WHERE id = ?
`
func (q *Queries) AckLibraryAutotagWarning(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, ackLibraryAutotagWarning, id)
return err
}
const countLibraries = `-- name: CountLibraries :one
SELECT COUNT(*) AS count FROM libraries
`
@@ -22,7 +31,7 @@ func (q *Queries) CountLibraries(ctx context.Context) (int64, error) {
const createLibrary = `-- name: CreateLibrary :one
INSERT INTO libraries (name, path) VALUES (?, ?)
RETURNING id, name, path, created_at
RETURNING id, name, path, created_at, autotag_warning_acked
`
type CreateLibraryParams struct {
@@ -38,6 +47,7 @@ func (q *Queries) CreateLibrary(ctx context.Context, arg CreateLibraryParams) (L
&i.Name,
&i.Path,
&i.CreatedAt,
&i.AutotagWarningAcked,
)
return i, err
}
@@ -52,7 +62,7 @@ func (q *Queries) DeleteLibrary(ctx context.Context, id int64) error {
}
const getAllLibraries = `-- name: GetAllLibraries :many
SELECT id, name, path, created_at FROM libraries ORDER BY name
SELECT id, name, path, created_at, autotag_warning_acked FROM libraries ORDER BY name
`
func (q *Queries) GetAllLibraries(ctx context.Context) ([]Library, error) {
@@ -69,6 +79,7 @@ func (q *Queries) GetAllLibraries(ctx context.Context) ([]Library, error) {
&i.Name,
&i.Path,
&i.CreatedAt,
&i.AutotagWarningAcked,
); err != nil {
return nil, err
}
@@ -84,7 +95,7 @@ func (q *Queries) GetAllLibraries(ctx context.Context) ([]Library, error) {
}
const getLibrary = `-- name: GetLibrary :one
SELECT id, name, path, created_at FROM libraries WHERE id = ? LIMIT 1
SELECT id, name, path, created_at, autotag_warning_acked FROM libraries WHERE id = ? LIMIT 1
`
func (q *Queries) GetLibrary(ctx context.Context, id int64) (Library, error) {
@@ -95,12 +106,13 @@ func (q *Queries) GetLibrary(ctx context.Context, id int64) (Library, error) {
&i.Name,
&i.Path,
&i.CreatedAt,
&i.AutotagWarningAcked,
)
return i, err
}
const getLibraryByPath = `-- name: GetLibraryByPath :one
SELECT id, name, path, created_at FROM libraries WHERE path = ? LIMIT 1
SELECT id, name, path, created_at, autotag_warning_acked FROM libraries WHERE path = ? LIMIT 1
`
func (q *Queries) GetLibraryByPath(ctx context.Context, path string) (Library, error) {
@@ -111,6 +123,7 @@ func (q *Queries) GetLibraryByPath(ctx context.Context, path string) (Library, e
&i.Name,
&i.Path,
&i.CreatedAt,
&i.AutotagWarningAcked,
)
return i, err
}
+24 -4
View File
@@ -48,6 +48,8 @@ type AudioFile struct {
LibraryID int64
PlayCount int64
LastPlayed sql.NullTime
TagStatus string
GroupKey string
}
type CoverArt struct {
@@ -76,10 +78,11 @@ type HttpCache struct {
}
type Library struct {
ID int64
Name string
Path string
CreatedAt time.Time
ID int64
Name string
Path string
CreatedAt time.Time
AutotagWarningAcked int64
}
type PlayHistory struct {
@@ -160,6 +163,7 @@ type ReleaseGroup struct {
CoverArtID sql.NullInt64
AlbumArtistCreditID sql.NullInt64
Year sql.NullInt64
OriginalYear sql.NullInt64
TotalTracks sql.NullInt64
TotalDiscs sql.NullInt64
Mbid sql.NullString
@@ -180,6 +184,21 @@ type SearchIndex struct {
Album string
}
type TaggingItem struct {
GroupKey string
LibraryID int64
TrackCount int64
AlbumName string
AlbumArtist string
DiscNumber int64
BestMatchReleaseMbid sql.NullString
Score sql.NullFloat64
LastCheckedAt sql.NullTime
Status string
ClearedAt sql.NullTime
CreatedAt time.Time
}
type TrackMetadatum struct {
ID int64
FilePath string
@@ -191,6 +210,7 @@ type TrackMetadatum struct {
Album string
Genre string
Year int64
ReleaseYear int64
Composer string
FileType string
SampleRate int64
@@ -23,7 +23,7 @@ func (q *Queries) CountReleaseGroupRecordings(ctx context.Context, releaseGroupI
const createReleaseGroup = `-- name: CreateReleaseGroup :one
INSERT INTO release_groups (name) VALUES (?)
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid
RETURNING id, name, cover_art_id, album_artist_credit_id, year, original_year, total_tracks, total_discs, mbid
`
func (q *Queries) CreateReleaseGroup(ctx context.Context, name string) (ReleaseGroup, error) {
@@ -35,6 +35,7 @@ func (q *Queries) CreateReleaseGroup(ctx context.Context, name string) (ReleaseG
&i.CoverArtID,
&i.AlbumArtistCreditID,
&i.Year,
&i.OriginalYear,
&i.TotalTracks,
&i.TotalDiscs,
&i.Mbid,
@@ -46,7 +47,7 @@ const createReleaseGroupFull = `-- name: CreateReleaseGroupFull :one
INSERT INTO release_groups (
name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs
) VALUES (?, ?, ?, ?, ?, ?)
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid
RETURNING id, name, cover_art_id, album_artist_credit_id, year, original_year, total_tracks, total_discs, mbid
`
type CreateReleaseGroupFullParams struct {
@@ -74,6 +75,7 @@ func (q *Queries) CreateReleaseGroupFull(ctx context.Context, arg CreateReleaseG
&i.CoverArtID,
&i.AlbumArtistCreditID,
&i.Year,
&i.OriginalYear,
&i.TotalTracks,
&i.TotalDiscs,
&i.Mbid,
@@ -104,7 +106,8 @@ const getAlbumsByArtist = `-- name: GetAlbumsByArtist :many
SELECT
rg.id,
rg.name,
rg.year,
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
COALESCE(ca.file_path, '') as cover_art_path
FROM release_groups rg
@@ -126,6 +129,7 @@ type GetAlbumsByArtistRow struct {
ID int64
Name string
Year sql.NullInt64
ReleaseYear int64
ArtistName string
CoverArtPath string
}
@@ -143,6 +147,7 @@ func (q *Queries) GetAlbumsByArtist(ctx context.Context, artistID int64) ([]GetA
&i.ID,
&i.Name,
&i.Year,
&i.ReleaseYear,
&i.ArtistName,
&i.CoverArtPath,
); err != nil {
@@ -163,7 +168,8 @@ const getAlbumsByArtistByLibrary = `-- name: GetAlbumsByArtistByLibrary :many
SELECT
rg.id,
rg.name,
rg.year,
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
COALESCE(ca.file_path, '') as cover_art_path
FROM release_groups rg
@@ -197,6 +203,7 @@ type GetAlbumsByArtistByLibraryRow struct {
ID int64
Name string
Year sql.NullInt64
ReleaseYear int64
ArtistName string
CoverArtPath string
}
@@ -214,6 +221,7 @@ func (q *Queries) GetAlbumsByArtistByLibrary(ctx context.Context, arg GetAlbumsB
&i.ID,
&i.Name,
&i.Year,
&i.ReleaseYear,
&i.ArtistName,
&i.CoverArtPath,
); err != nil {
@@ -234,7 +242,12 @@ const getAllAlbumsWithDetails = `-- name: GetAllAlbumsWithDetails :many
SELECT
rg.id,
rg.name,
rg.year,
-- year prefers original release year (MB first-release-date)
-- over the file-tag year so the UI surfaces the album's
-- original year by default. release_year keeps the file-tag
-- year accessible.
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
rg.mbid,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
COALESCE(ca.file_path, '') as cover_art_path
@@ -255,6 +268,7 @@ type GetAllAlbumsWithDetailsRow struct {
ID int64
Name string
Year sql.NullInt64
ReleaseYear int64
Mbid sql.NullString
ArtistName string
CoverArtPath string
@@ -273,6 +287,7 @@ func (q *Queries) GetAllAlbumsWithDetails(ctx context.Context) ([]GetAllAlbumsWi
&i.ID,
&i.Name,
&i.Year,
&i.ReleaseYear,
&i.Mbid,
&i.ArtistName,
&i.CoverArtPath,
@@ -294,7 +309,12 @@ const getAllAlbumsWithDetailsByLibrary = `-- name: GetAllAlbumsWithDetailsByLibr
SELECT
rg.id,
rg.name,
rg.year,
-- year prefers original release year (MB first-release-date)
-- over the file-tag year so the UI surfaces the album's
-- original year by default. release_year keeps the file-tag
-- year accessible.
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
rg.mbid,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
COALESCE(ca.file_path, '') as cover_art_path
@@ -322,6 +342,7 @@ type GetAllAlbumsWithDetailsByLibraryRow struct {
ID int64
Name string
Year sql.NullInt64
ReleaseYear int64
Mbid sql.NullString
ArtistName string
CoverArtPath string
@@ -340,6 +361,7 @@ func (q *Queries) GetAllAlbumsWithDetailsByLibrary(ctx context.Context, libraryI
&i.ID,
&i.Name,
&i.Year,
&i.ReleaseYear,
&i.Mbid,
&i.ArtistName,
&i.CoverArtPath,
@@ -358,7 +380,7 @@ func (q *Queries) GetAllAlbumsWithDetailsByLibrary(ctx context.Context, libraryI
}
const getAllReleaseGroups = `-- name: GetAllReleaseGroups :many
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid FROM release_groups
SELECT id, name, cover_art_id, album_artist_credit_id, year, original_year, total_tracks, total_discs, mbid FROM release_groups
ORDER BY name
`
@@ -377,6 +399,7 @@ func (q *Queries) GetAllReleaseGroups(ctx context.Context) ([]ReleaseGroup, erro
&i.CoverArtID,
&i.AlbumArtistCreditID,
&i.Year,
&i.OriginalYear,
&i.TotalTracks,
&i.TotalDiscs,
&i.Mbid,
@@ -395,7 +418,7 @@ func (q *Queries) GetAllReleaseGroups(ctx context.Context) ([]ReleaseGroup, erro
}
const getReleaseGroup = `-- name: GetReleaseGroup :one
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid FROM release_groups
SELECT id, name, cover_art_id, album_artist_credit_id, year, original_year, total_tracks, total_discs, mbid FROM release_groups
WHERE id = ? LIMIT 1
`
@@ -408,6 +431,7 @@ func (q *Queries) GetReleaseGroup(ctx context.Context, id int64) (ReleaseGroup,
&i.CoverArtID,
&i.AlbumArtistCreditID,
&i.Year,
&i.OriginalYear,
&i.TotalTracks,
&i.TotalDiscs,
&i.Mbid,
@@ -416,7 +440,7 @@ func (q *Queries) GetReleaseGroup(ctx context.Context, id int64) (ReleaseGroup,
}
const getReleaseGroupByNameAndArtist = `-- name: GetReleaseGroupByNameAndArtist :one
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid FROM release_groups
SELECT id, name, cover_art_id, album_artist_credit_id, year, original_year, total_tracks, total_discs, mbid FROM release_groups
WHERE name = ? AND album_artist_credit_id = ? LIMIT 1
`
@@ -434,6 +458,7 @@ func (q *Queries) GetReleaseGroupByNameAndArtist(ctx context.Context, arg GetRel
&i.CoverArtID,
&i.AlbumArtistCreditID,
&i.Year,
&i.OriginalYear,
&i.TotalTracks,
&i.TotalDiscs,
&i.Mbid,
@@ -441,6 +466,24 @@ func (q *Queries) GetReleaseGroupByNameAndArtist(ctx context.Context, arg GetRel
return i, err
}
const setReleaseGroupOriginalYear = `-- name: SetReleaseGroupOriginalYear :exec
UPDATE release_groups SET original_year = ? WHERE id = ?
`
type SetReleaseGroupOriginalYearParams struct {
OriginalYear sql.NullInt64
ID int64
}
// Set the release group's original-release-year (release-group's
// first-release-date from MusicBrainz). Called from autotag apply
// when the user confirms a candidate; the file-tag year stays in
// the year column.
func (q *Queries) SetReleaseGroupOriginalYear(ctx context.Context, arg SetReleaseGroupOriginalYearParams) error {
_, err := q.db.ExecContext(ctx, setReleaseGroupOriginalYear, arg.OriginalYear, arg.ID)
return err
}
const updateReleaseGroup = `-- name: UpdateReleaseGroup :exec
UPDATE release_groups
SET name = ?
@@ -479,7 +522,7 @@ VALUES (?, ?, ?)
ON CONFLICT(name, album_artist_credit_id) DO UPDATE SET
album_artist_credit_id = COALESCE(excluded.album_artist_credit_id, release_groups.album_artist_credit_id),
year = COALESCE(excluded.year, release_groups.year)
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid
RETURNING id, name, cover_art_id, album_artist_credit_id, year, original_year, total_tracks, total_discs, mbid
`
type UpsertReleaseGroupParams struct {
@@ -497,6 +540,7 @@ func (q *Queries) UpsertReleaseGroup(ctx context.Context, arg UpsertReleaseGroup
&i.CoverArtID,
&i.AlbumArtistCreditID,
&i.Year,
&i.OriginalYear,
&i.TotalTracks,
&i.TotalDiscs,
&i.Mbid,
@@ -0,0 +1,771 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: tagging_items.sql
package sqlcgen
import (
"context"
"database/sql"
"time"
)
const clearCompletedTaggingItems = `-- name: ClearCompletedTaggingItems :exec
UPDATE tagging_items
SET cleared_at = CURRENT_TIMESTAMP
WHERE status = 'confirmed'
AND cleared_at IS NULL
AND (CAST(?1 AS INTEGER) = 0 OR library_id = ?1)
`
func (q *Queries) ClearCompletedTaggingItems(ctx context.Context, libraryID int64) error {
_, err := q.db.ExecContext(ctx, clearCompletedTaggingItems, libraryID)
return err
}
const countPendingTaggingItems = `-- name: CountPendingTaggingItems :one
SELECT COUNT(*) FROM tagging_items
WHERE status = 'pending'
AND (CAST(?1 AS INTEGER) = 0 OR library_id = ?1)
`
func (q *Queries) CountPendingTaggingItems(ctx context.Context, libraryID int64) (int64, error) {
row := q.db.QueryRowContext(ctx, countPendingTaggingItems, libraryID)
var count int64
err := row.Scan(&count)
return count, err
}
const decrementTaggingItemTrackCount = `-- name: DecrementTaggingItemTrackCount :exec
UPDATE tagging_items
SET track_count = track_count - 1
WHERE group_key = ?
`
func (q *Queries) DecrementTaggingItemTrackCount(ctx context.Context, groupKey string) error {
_, err := q.db.ExecContext(ctx, decrementTaggingItemTrackCount, groupKey)
return err
}
const deleteTaggingItemIfEmpty = `-- name: DeleteTaggingItemIfEmpty :exec
DELETE FROM tagging_items
WHERE group_key = ? AND track_count <= 0
`
func (q *Queries) DeleteTaggingItemIfEmpty(ctx context.Context, groupKey string) error {
_, err := q.db.ExecContext(ctx, deleteTaggingItemIfEmpty, groupKey)
return err
}
const getNextPendingTaggingItem = `-- name: GetNextPendingTaggingItem :one
SELECT
ti.group_key,
ti.library_id,
COALESCE(lb.name, '') AS library_name,
ti.track_count,
ti.album_name,
ti.album_artist,
ti.disc_number,
ti.best_match_release_mbid,
ti.score,
ti.last_checked_at,
ti.status,
ti.created_at
FROM tagging_items ti
LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE ti.status = 'pending'
AND (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1)
AND ti.group_key > ?2
ORDER BY ti.group_key
LIMIT 1
`
type GetNextPendingTaggingItemParams struct {
LibraryID int64
AfterGroupKey string
}
type GetNextPendingTaggingItemRow struct {
GroupKey string
LibraryID int64
LibraryName string
TrackCount int64
AlbumName string
AlbumArtist string
DiscNumber int64
BestMatchReleaseMbid sql.NullString
Score sql.NullFloat64
LastCheckedAt sql.NullTime
Status string
CreatedAt time.Time
}
func (q *Queries) GetNextPendingTaggingItem(ctx context.Context, arg GetNextPendingTaggingItemParams) (GetNextPendingTaggingItemRow, error) {
row := q.db.QueryRowContext(ctx, getNextPendingTaggingItem, arg.LibraryID, arg.AfterGroupKey)
var i GetNextPendingTaggingItemRow
err := row.Scan(
&i.GroupKey,
&i.LibraryID,
&i.LibraryName,
&i.TrackCount,
&i.AlbumName,
&i.AlbumArtist,
&i.DiscNumber,
&i.BestMatchReleaseMbid,
&i.Score,
&i.LastCheckedAt,
&i.Status,
&i.CreatedAt,
)
return i, err
}
const getPendingFolderDetail = `-- name: GetPendingFolderDetail :one
SELECT
ti.group_key,
ti.library_id,
COALESCE(lb.name, '') AS library_name,
COALESCE(lb.path, '') AS library_path,
CAST(COALESCE((SELECT af.file_path FROM audio_files af WHERE af.group_key = ti.group_key LIMIT 1), '') AS TEXT) AS sample_file_path,
ti.track_count,
ti.album_name,
ti.album_artist,
ti.disc_number,
ti.best_match_release_mbid,
ti.score,
ti.last_checked_at,
ti.status,
ti.created_at
FROM tagging_items ti
LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE ti.group_key = ?
LIMIT 1
`
type GetPendingFolderDetailRow struct {
GroupKey string
LibraryID int64
LibraryName string
LibraryPath string
SampleFilePath string
TrackCount int64
AlbumName string
AlbumArtist string
DiscNumber int64
BestMatchReleaseMbid sql.NullString
Score sql.NullFloat64
LastCheckedAt sql.NullTime
Status string
CreatedAt time.Time
}
func (q *Queries) GetPendingFolderDetail(ctx context.Context, groupKey string) (GetPendingFolderDetailRow, error) {
row := q.db.QueryRowContext(ctx, getPendingFolderDetail, groupKey)
var i GetPendingFolderDetailRow
err := row.Scan(
&i.GroupKey,
&i.LibraryID,
&i.LibraryName,
&i.LibraryPath,
&i.SampleFilePath,
&i.TrackCount,
&i.AlbumName,
&i.AlbumArtist,
&i.DiscNumber,
&i.BestMatchReleaseMbid,
&i.Score,
&i.LastCheckedAt,
&i.Status,
&i.CreatedAt,
)
return i, err
}
const getRecordingReleaseGroupID = `-- name: GetRecordingReleaseGroupID :one
SELECT COALESCE(rgr.release_group_id, 0) AS release_group_id
FROM release_group_recordings rgr
WHERE rgr.recording_id = ?
LIMIT 1
`
func (q *Queries) GetRecordingReleaseGroupID(ctx context.Context, recordingID int64) (int64, error) {
row := q.db.QueryRowContext(ctx, getRecordingReleaseGroupID, recordingID)
var release_group_id int64
err := row.Scan(&release_group_id)
return release_group_id, err
}
const getTaggingItem = `-- name: GetTaggingItem :one
SELECT group_key, library_id, track_count, album_name, album_artist, disc_number, best_match_release_mbid, score, last_checked_at, status, cleared_at, created_at FROM tagging_items
WHERE group_key = ?
LIMIT 1
`
func (q *Queries) GetTaggingItem(ctx context.Context, groupKey string) (TaggingItem, error) {
row := q.db.QueryRowContext(ctx, getTaggingItem, groupKey)
var i TaggingItem
err := row.Scan(
&i.GroupKey,
&i.LibraryID,
&i.TrackCount,
&i.AlbumName,
&i.AlbumArtist,
&i.DiscNumber,
&i.BestMatchReleaseMbid,
&i.Score,
&i.LastCheckedAt,
&i.Status,
&i.ClearedAt,
&i.CreatedAt,
)
return i, err
}
const listAudioFilesInTaggingGroup = `-- name: ListAudioFilesInTaggingGroup :many
SELECT
af.id,
af.file_path,
af.basename,
af.length_milliseconds,
af.tag_status,
COALESCE(r.track_number, 0) AS track_number,
COALESCE(r.disc_number, 0) AS disc_number,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
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
WHERE af.group_key = ?
ORDER BY COALESCE(r.disc_number, 0),
COALESCE(r.track_number, 0),
af.file_path
`
type ListAudioFilesInTaggingGroupRow struct {
ID int64
FilePath string
Basename string
LengthMilliseconds int64
TagStatus string
TrackNumber int64
DiscNumber int64
Title string
ArtistName string
RecordingMbid string
}
func (q *Queries) ListAudioFilesInTaggingGroup(ctx context.Context, groupKey string) ([]ListAudioFilesInTaggingGroupRow, error) {
rows, err := q.db.QueryContext(ctx, listAudioFilesInTaggingGroup, groupKey)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListAudioFilesInTaggingGroupRow
for rows.Next() {
var i ListAudioFilesInTaggingGroupRow
if err := rows.Scan(
&i.ID,
&i.FilePath,
&i.Basename,
&i.LengthMilliseconds,
&i.TagStatus,
&i.TrackNumber,
&i.DiscNumber,
&i.Title,
&i.ArtistName,
&i.RecordingMbid,
); 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 listLocalReleaseGroupCandidates = `-- name: ListLocalReleaseGroupCandidates :many
SELECT
rg.id AS release_group_id,
rg.mbid AS release_group_mbid,
rg.name AS album_name,
COALESCE(rg.year, 0) AS year,
COALESCE(ac.text, '') AS artist_credit,
COALESCE(rgr.track_number, 0) AS track_number,
COALESCE(rgr.disc_number, 0) AS disc_number,
COALESCE(r.name, '') AS track_title,
COALESCE(r.mbid, '') AS recording_mbid,
COALESCE(local_af.length_milliseconds, 0) AS length_milliseconds
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN recordings r ON r.id = rgr.recording_id
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
LEFT JOIN audio_files local_af ON local_af.recording_id = r.id
WHERE rg.mbid IS NOT NULL
AND rg.mbid != ''
AND r.mbid IS NOT NULL
AND r.mbid != ''
AND rg.name = ? COLLATE NOCASE
ORDER BY rg.id, rgr.disc_number, rgr.track_number
`
type ListLocalReleaseGroupCandidatesRow struct {
ReleaseGroupID int64
ReleaseGroupMbid sql.NullString
AlbumName string
Year int64
ArtistCredit string
TrackNumber int64
DiscNumber int64
TrackTitle string
RecordingMbid string
LengthMilliseconds int64
}
// Returns one row per (release_group, track) combination for any
// local release_group that has an MBID. Callers group these in Go
// and filter by normalized album-name match. Joined case-insensitive
// on name to pre-filter cheaply; Go does the real normalization.
func (q *Queries) ListLocalReleaseGroupCandidates(ctx context.Context, name string) ([]ListLocalReleaseGroupCandidatesRow, error) {
rows, err := q.db.QueryContext(ctx, listLocalReleaseGroupCandidates, name)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListLocalReleaseGroupCandidatesRow
for rows.Next() {
var i ListLocalReleaseGroupCandidatesRow
if err := rows.Scan(
&i.ReleaseGroupID,
&i.ReleaseGroupMbid,
&i.AlbumName,
&i.Year,
&i.ArtistCredit,
&i.TrackNumber,
&i.DiscNumber,
&i.TrackTitle,
&i.RecordingMbid,
&i.LengthMilliseconds,
); 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 listPendingTaggingItemsAlphabetical = `-- name: ListPendingTaggingItemsAlphabetical :many
SELECT
ti.group_key,
ti.library_id,
COALESCE(lb.name, '') AS library_name,
ti.track_count,
ti.album_name,
ti.album_artist,
ti.disc_number,
ti.best_match_release_mbid,
ti.score,
ti.last_checked_at,
ti.status,
ti.created_at
FROM tagging_items ti
LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1)
AND (CAST(?2 AS TEXT) = 'all' OR ti.status = ?2)
AND ti.cleared_at IS NULL
ORDER BY LOWER(ti.album_artist), LOWER(ti.album_name), ti.disc_number
LIMIT ?4 OFFSET ?3
`
type ListPendingTaggingItemsAlphabeticalParams struct {
LibraryID int64
StatusFilter string
RowOffset int64
RowLimit int64
}
type ListPendingTaggingItemsAlphabeticalRow struct {
GroupKey string
LibraryID int64
LibraryName string
TrackCount int64
AlbumName string
AlbumArtist string
DiscNumber int64
BestMatchReleaseMbid sql.NullString
Score sql.NullFloat64
LastCheckedAt sql.NullTime
Status string
CreatedAt time.Time
}
func (q *Queries) ListPendingTaggingItemsAlphabetical(ctx context.Context, arg ListPendingTaggingItemsAlphabeticalParams) ([]ListPendingTaggingItemsAlphabeticalRow, error) {
rows, err := q.db.QueryContext(ctx, listPendingTaggingItemsAlphabetical,
arg.LibraryID,
arg.StatusFilter,
arg.RowOffset,
arg.RowLimit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListPendingTaggingItemsAlphabeticalRow
for rows.Next() {
var i ListPendingTaggingItemsAlphabeticalRow
if err := rows.Scan(
&i.GroupKey,
&i.LibraryID,
&i.LibraryName,
&i.TrackCount,
&i.AlbumName,
&i.AlbumArtist,
&i.DiscNumber,
&i.BestMatchReleaseMbid,
&i.Score,
&i.LastCheckedAt,
&i.Status,
&i.CreatedAt,
); 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 listPendingTaggingItemsByRecent = `-- name: ListPendingTaggingItemsByRecent :many
SELECT
ti.group_key,
ti.library_id,
COALESCE(lb.name, '') AS library_name,
ti.track_count,
ti.album_name,
ti.album_artist,
ti.disc_number,
ti.best_match_release_mbid,
ti.score,
ti.last_checked_at,
ti.status,
ti.created_at
FROM tagging_items ti
LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1)
AND (CAST(?2 AS TEXT) = 'all' OR ti.status = ?2)
ORDER BY ti.created_at DESC, ti.group_key
LIMIT ?4 OFFSET ?3
`
type ListPendingTaggingItemsByRecentParams struct {
LibraryID int64
StatusFilter string
RowOffset int64
RowLimit int64
}
type ListPendingTaggingItemsByRecentRow struct {
GroupKey string
LibraryID int64
LibraryName string
TrackCount int64
AlbumName string
AlbumArtist string
DiscNumber int64
BestMatchReleaseMbid sql.NullString
Score sql.NullFloat64
LastCheckedAt sql.NullTime
Status string
CreatedAt time.Time
}
func (q *Queries) ListPendingTaggingItemsByRecent(ctx context.Context, arg ListPendingTaggingItemsByRecentParams) ([]ListPendingTaggingItemsByRecentRow, error) {
rows, err := q.db.QueryContext(ctx, listPendingTaggingItemsByRecent,
arg.LibraryID,
arg.StatusFilter,
arg.RowOffset,
arg.RowLimit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListPendingTaggingItemsByRecentRow
for rows.Next() {
var i ListPendingTaggingItemsByRecentRow
if err := rows.Scan(
&i.GroupKey,
&i.LibraryID,
&i.LibraryName,
&i.TrackCount,
&i.AlbumName,
&i.AlbumArtist,
&i.DiscNumber,
&i.BestMatchReleaseMbid,
&i.Score,
&i.LastCheckedAt,
&i.Status,
&i.CreatedAt,
); 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 listPendingTaggingItemsByScore = `-- name: ListPendingTaggingItemsByScore :many
SELECT
ti.group_key,
ti.library_id,
COALESCE(lb.name, '') AS library_name,
COALESCE(lb.path, '') AS library_path,
CAST(COALESCE((SELECT af.file_path FROM audio_files af WHERE af.group_key = ti.group_key LIMIT 1), '') AS TEXT) AS sample_file_path,
ti.track_count,
ti.album_name,
ti.album_artist,
ti.disc_number,
ti.best_match_release_mbid,
ti.score,
ti.last_checked_at,
ti.status,
ti.created_at
FROM tagging_items ti
LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1)
AND (CAST(?2 AS TEXT) = 'all' OR ti.status = ?2)
AND ti.cleared_at IS NULL
ORDER BY ti.score IS NULL, ti.score DESC, LOWER(ti.album_artist), LOWER(ti.album_name)
LIMIT ?4 OFFSET ?3
`
type ListPendingTaggingItemsByScoreParams struct {
LibraryID int64
StatusFilter string
RowOffset int64
RowLimit int64
}
type ListPendingTaggingItemsByScoreRow struct {
GroupKey string
LibraryID int64
LibraryName string
LibraryPath string
SampleFilePath string
TrackCount int64
AlbumName string
AlbumArtist string
DiscNumber int64
BestMatchReleaseMbid sql.NullString
Score sql.NullFloat64
LastCheckedAt sql.NullTime
Status string
CreatedAt time.Time
}
func (q *Queries) ListPendingTaggingItemsByScore(ctx context.Context, arg ListPendingTaggingItemsByScoreParams) ([]ListPendingTaggingItemsByScoreRow, error) {
rows, err := q.db.QueryContext(ctx, listPendingTaggingItemsByScore,
arg.LibraryID,
arg.StatusFilter,
arg.RowOffset,
arg.RowLimit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListPendingTaggingItemsByScoreRow
for rows.Next() {
var i ListPendingTaggingItemsByScoreRow
if err := rows.Scan(
&i.GroupKey,
&i.LibraryID,
&i.LibraryName,
&i.LibraryPath,
&i.SampleFilePath,
&i.TrackCount,
&i.AlbumName,
&i.AlbumArtist,
&i.DiscNumber,
&i.BestMatchReleaseMbid,
&i.Score,
&i.LastCheckedAt,
&i.Status,
&i.CreatedAt,
); 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 setAudioFileTagStatus = `-- name: SetAudioFileTagStatus :exec
UPDATE audio_files SET tag_status = ? WHERE id = ?
`
type SetAudioFileTagStatusParams struct {
TagStatus string
ID int64
}
func (q *Queries) SetAudioFileTagStatus(ctx context.Context, arg SetAudioFileTagStatusParams) error {
_, err := q.db.ExecContext(ctx, setAudioFileTagStatus, arg.TagStatus, arg.ID)
return err
}
const setRecordingMBID = `-- name: SetRecordingMBID :exec
UPDATE recordings SET mbid = ? WHERE id = ?
`
type SetRecordingMBIDParams struct {
Mbid sql.NullString
ID int64
}
func (q *Queries) SetRecordingMBID(ctx context.Context, arg SetRecordingMBIDParams) error {
_, err := q.db.ExecContext(ctx, setRecordingMBID, arg.Mbid, arg.ID)
return err
}
const setReleaseGroupMBID = `-- name: SetReleaseGroupMBID :exec
UPDATE release_groups SET mbid = ? WHERE id = ?
`
type SetReleaseGroupMBIDParams struct {
Mbid sql.NullString
ID int64
}
func (q *Queries) SetReleaseGroupMBID(ctx context.Context, arg SetReleaseGroupMBIDParams) error {
_, err := q.db.ExecContext(ctx, setReleaseGroupMBID, arg.Mbid, arg.ID)
return err
}
const setTaggingItemBestMatch = `-- name: SetTaggingItemBestMatch :exec
UPDATE tagging_items
SET best_match_release_mbid = ?,
score = ?,
status = ?,
last_checked_at = CURRENT_TIMESTAMP
WHERE group_key = ?
`
type SetTaggingItemBestMatchParams struct {
BestMatchReleaseMbid sql.NullString
Score sql.NullFloat64
Status string
GroupKey string
}
func (q *Queries) SetTaggingItemBestMatch(ctx context.Context, arg SetTaggingItemBestMatchParams) error {
_, err := q.db.ExecContext(ctx, setTaggingItemBestMatch,
arg.BestMatchReleaseMbid,
arg.Score,
arg.Status,
arg.GroupKey,
)
return err
}
const setTaggingItemScore = `-- name: SetTaggingItemScore :exec
UPDATE tagging_items
SET best_match_release_mbid = ?,
score = ?,
last_checked_at = CURRENT_TIMESTAMP
WHERE group_key = ?
`
type SetTaggingItemScoreParams struct {
BestMatchReleaseMbid sql.NullString
Score sql.NullFloat64
GroupKey string
}
func (q *Queries) SetTaggingItemScore(ctx context.Context, arg SetTaggingItemScoreParams) error {
_, err := q.db.ExecContext(ctx, setTaggingItemScore, arg.BestMatchReleaseMbid, arg.Score, arg.GroupKey)
return err
}
const setTaggingItemStatus = `-- name: SetTaggingItemStatus :exec
UPDATE tagging_items
SET status = ?,
last_checked_at = CURRENT_TIMESTAMP
WHERE group_key = ?
`
type SetTaggingItemStatusParams struct {
Status string
GroupKey string
}
func (q *Queries) SetTaggingItemStatus(ctx context.Context, arg SetTaggingItemStatusParams) error {
_, err := q.db.ExecContext(ctx, setTaggingItemStatus, arg.Status, arg.GroupKey)
return err
}
const upsertTaggingItemOnTrackAdd = `-- name: UpsertTaggingItemOnTrackAdd :exec
INSERT INTO tagging_items (
group_key, library_id, track_count,
album_name, album_artist, disc_number, status
)
VALUES (?, ?, 1, ?, ?, ?, 'pending')
ON CONFLICT(group_key) DO UPDATE SET
track_count = tagging_items.track_count + 1,
album_name = CASE
WHEN tagging_items.album_name = '' THEN excluded.album_name
ELSE tagging_items.album_name
END,
album_artist = CASE
WHEN tagging_items.album_artist = '' THEN excluded.album_artist
ELSE tagging_items.album_artist
END
`
type UpsertTaggingItemOnTrackAddParams struct {
GroupKey string
LibraryID int64
AlbumName string
AlbumArtist string
DiscNumber int64
}
func (q *Queries) UpsertTaggingItemOnTrackAdd(ctx context.Context, arg UpsertTaggingItemOnTrackAddParams) error {
_, err := q.db.ExecContext(ctx, upsertTaggingItemOnTrackAdd,
arg.GroupKey,
arg.LibraryID,
arg.AlbumName,
arg.AlbumArtist,
arg.DiscNumber,
)
return err
}
+443
View File
@@ -0,0 +1,443 @@
package database_test
import (
"strings"
"testing"
"yellowjacket/backend/autotag"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
)
// ---------------------------------------------------------------------------
// Migration 31: tag_status column
// ---------------------------------------------------------------------------
func TestMigration31_TagStatusDefaultAndCheck(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
seedAF(t, db, "/music/a.mp3", 0, 0, "", "")
got := scalarString(t, db,
`SELECT tag_status FROM audio_files WHERE file_path = ?`,
"/music/a.mp3",
)
if got != "untagged" {
t.Errorf("default tag_status = %q, want %q", got, "untagged")
}
// CHECK constraint is applied inline with ALTER TABLE ADD COLUMN
// in migration 31, so both fresh and upgraded DBs enforce it.
_, err := db.ExecContext(
`UPDATE audio_files SET tag_status = 'bogus' WHERE file_path = ?`,
"/music/a.mp3",
)
if err == nil {
t.Error("expected CHECK constraint failure for invalid tag_status")
} else if !strings.Contains(err.Error(), "CHECK") &&
!strings.Contains(err.Error(), "constraint") {
t.Errorf("unexpected error: %v", err)
}
}
func TestMigration31_BackfillFromRecordingMBID(t *testing.T) {
t.Parallel()
// Migration 31 runs against a DB that already exists — NewTestDB
// creates a fresh DB and applies schemas + migrations. To
// exercise the backfill we seed audio_files with recordings whose
// mbid field varies, then re-run the same UPDATE the migration
// issues and verify each row lands on the expected status.
db := database.NewTestDB(t)
seedAF(t, db, "/music/no-mb.mp3", 0, 0, "Song A", "")
seedAF(t, db, "/music/empty-mb.mp3", 0, 0, "Song B", "")
seedAF(t, db, "/music/valid-mb.mp3", 0, 0, "Song C",
"11111111-2222-3333-4444-555555555555",
)
// Clear any status first so the backfill has work to do.
if _, err := db.ExecContext(
`UPDATE audio_files SET tag_status = 'untagged'`,
); err != nil {
t.Fatalf("reset tag_status: %v", err)
}
if _, err := db.ExecContext(`
UPDATE audio_files
SET tag_status = 'user_confirmed'
WHERE tag_status = 'untagged'
AND recording_id IN (
SELECT id FROM recordings
WHERE mbid IS NOT NULL AND mbid != ''
)
`); err != nil {
t.Fatalf("backfill: %v", err)
}
cases := map[string]string{
"/music/no-mb.mp3": "untagged",
"/music/empty-mb.mp3": "untagged",
"/music/valid-mb.mp3": "user_confirmed",
}
for path, want := range cases {
got := scalarString(t, db,
`SELECT tag_status FROM audio_files WHERE file_path = ?`, path,
)
if got != want {
t.Errorf("tag_status for %s = %q, want %q", path, got, want)
}
}
}
// ---------------------------------------------------------------------------
// Migration 32: tagging_items table + group_key column
// ---------------------------------------------------------------------------
func TestMigration32_TaggingItemsAndGroupKey(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
// The schema already has group_key and tagging_items. Simulate
// the migration backfill by inserting a couple of audio_files
// without group_key, then running the Go helper to set it, and
// verify the aggregate-into-tagging_items step produces one row
// per (group_key, library_id).
seedAF(t, db, "/music/Artist/Album/01.mp3", 0, 1, "T1", "")
seedAF(t, db, "/music/Artist/Album/02.mp3", 0, 1, "T2", "")
seedAF(t, db, "/music/Artist/Other/01.mp3", 0, 1, "T3", "")
// Clear any auto-populated group_key from CreateAudioFile.
if _, err := db.ExecContext(
`UPDATE audio_files SET group_key = ''`,
); err != nil {
t.Fatalf("reset group_key: %v", err)
}
// Run the same backfill logic inline.
rows, err := db.QueryContext(
`SELECT id, library_id, file_path FROM audio_files ORDER BY id`,
)
if err != nil {
t.Fatalf("select rows: %v", err)
}
type afRow struct {
id int64
libraryID int64
path string
}
var afs []afRow
for rows.Next() {
var r afRow
if scanErr := rows.Scan(&r.id, &r.libraryID, &r.path); scanErr != nil {
t.Fatalf("scan: %v", scanErr)
}
afs = append(afs, r)
}
_ = rows.Close()
// Album for first two files, Other for third — shared parent dirs
// produce shared group_keys.
for _, r := range afs {
key := autotag.GroupKey(r.libraryID, r.path, 0)
if _, err := db.ExecContext(
`UPDATE audio_files SET group_key = ? WHERE id = ?`,
key, r.id,
); err != nil {
t.Fatalf("set group_key: %v", err)
}
}
// Aggregate.
if _, err := db.ExecContext(`
INSERT INTO tagging_items (
group_key, library_id, track_count,
album_name, album_artist, disc_number, status
)
SELECT
af.group_key, af.library_id, COUNT(*),
'', '', 0,
CASE WHEN SUM(CASE WHEN af.tag_status = 'user_confirmed' THEN 0 ELSE 1 END) = 0
THEN 'confirmed' ELSE 'pending' END
FROM audio_files af
WHERE af.group_key != ''
GROUP BY af.group_key, af.library_id
ON CONFLICT(group_key) DO NOTHING
`); err != nil {
t.Fatalf("aggregate: %v", err)
}
got := scalarInt(t, db, `SELECT COUNT(*) FROM tagging_items`)
if got != 2 {
t.Errorf("tagging_items count = %d, want 2", got)
}
// The 2-track Album group should carry track_count = 2.
albumKey := autotag.GroupKey(0, "/music/Artist/Album/01.mp3", 0)
count := scalarInt(t, db,
`SELECT track_count FROM tagging_items WHERE group_key = ?`, albumKey,
)
if count != 2 {
t.Errorf("album track_count = %d, want 2", count)
}
}
// ---------------------------------------------------------------------------
// 008.4 — sqlc queries, pagination, and partial-index usage
// ---------------------------------------------------------------------------
func TestTaggingItems_ListPendingAndCount(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
seedTaggingItem(t, db, "g1", 0, "Album A", "Artist A", 2, "pending")
seedTaggingItem(t, db, "g2", 0, "Album B", "Artist B", 1, "pending")
seedTaggingItem(t, db, "g3", 0, "Album C", "Artist C", 3, "confirmed")
count, err := db.Queries.CountPendingTaggingItems(db.Ctx, 0)
if err != nil {
t.Fatalf("count: %v", err)
}
if count != 2 { //nolint:mnd
t.Errorf("pending count = %d, want 2", count)
}
items, err := db.Queries.ListPendingTaggingItemsAlphabetical(
db.Ctx,
sqlcgen.ListPendingTaggingItemsAlphabeticalParams{
LibraryID: 0,
StatusFilter: "pending",
RowLimit: 50,
RowOffset: 0,
},
)
if err != nil {
t.Fatalf("list alphabetical: %v", err)
}
if len(items) != 2 { //nolint:mnd
t.Fatalf("list len = %d, want 2", len(items))
}
if items[0].AlbumArtist != "Artist A" || items[1].AlbumArtist != "Artist B" {
t.Errorf(
"unexpected order: %q, %q",
items[0].AlbumArtist, items[1].AlbumArtist,
)
}
}
func TestCountPendingTaggingItems_UsesPartialIndex(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
seedTaggingItem(t, db, "g1", 0, "Album A", "Artist A", 2, "pending")
rows, err := db.QueryContext(`
EXPLAIN QUERY PLAN
SELECT COUNT(*) FROM tagging_items
WHERE status = 'pending'
AND (CAST(0 AS INTEGER) = 0 OR library_id = 0)
`)
if err != nil {
t.Fatalf("explain: %v", err)
}
defer func() { _ = rows.Close() }()
var plan strings.Builder
for rows.Next() {
var id, parent, notused int
var detail string
if scanErr := rows.Scan(&id, &parent, &notused, &detail); scanErr != nil {
t.Fatalf("scan: %v", scanErr)
}
plan.WriteString(detail)
plan.WriteString("\n")
}
// Must show the partial index is being used — if a future schema
// change drops or renames it, this assertion fires loudly.
if !strings.Contains(plan.String(), "idx_tagging_items_status_pending") {
t.Errorf(
"badge query plan does not use idx_tagging_items_status_pending:\n%s",
plan.String(),
)
}
}
func TestGetTaggingItemAndListAudioFilesInGroup(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
seedTaggingItem(t, db, "g1", 0, "Album A", "Artist A", 2, "pending")
// Seed two audio files pointing at the same group.
id1 := seedAF(t, db, "/music/A/01.mp3", 0, 0, "T1", "")
id2 := seedAF(t, db, "/music/A/02.mp3", 0, 0, "T2", "")
if _, err := db.ExecContext(
`UPDATE audio_files SET group_key = 'g1' WHERE id IN (?, ?)`,
id1, id2,
); err != nil {
t.Fatalf("bind group_key: %v", err)
}
item, err := db.Queries.GetTaggingItem(db.Ctx, "g1")
if err != nil {
t.Fatalf("get: %v", err)
}
if item.AlbumName != "Album A" {
t.Errorf("album_name = %q", item.AlbumName)
}
files, err := db.Queries.ListAudioFilesInTaggingGroup(db.Ctx, "g1")
if err != nil {
t.Fatalf("list files: %v", err)
}
if len(files) != 2 { //nolint:mnd
t.Errorf("files in group = %d, want 2", len(files))
}
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
// seedAF inserts a minimal recording + audio_files pair and returns
// the new audio_files id. All FK-satisfying rows (artist_credit,
// recordings, file_types[0]) are created inline.
func seedAF(
t *testing.T,
db *database.DB,
filePath string,
libraryID, discNumber int64,
recordingName, recordingMBID string,
) int64 {
t.Helper()
ac, err := db.Queries.UpsertArtistCredit(db.Ctx, "Test Artist")
if err != nil {
t.Fatalf("upsert artist credit: %v", err)
}
rec, err := db.Queries.CreateRecordingFull(
db.Ctx,
sqlcgen.CreateRecordingFullParams{
Name: recordingName,
ArtistCreditID: ac.ID,
},
)
if err != nil {
t.Fatalf("create recording: %v", err)
}
if recordingMBID != "" {
if _, err := db.ExecContext(
`UPDATE recordings SET mbid = ? WHERE id = ?`,
recordingMBID, rec.ID,
); err != nil {
t.Fatalf("set mbid: %v", err)
}
}
af, err := db.Queries.CreateAudioFile(
db.Ctx,
sqlcgen.CreateAudioFileParams{
FilePath: filePath,
LengthMilliseconds: 1000,
FileTypeID: 0,
RecordingID: rec.ID,
Basename: filePath,
LibraryID: libraryID,
},
)
if err != nil {
t.Fatalf("create audio file: %v", err)
}
_ = discNumber // reserved for callers that want specific disc values
return af.ID
}
func seedTaggingItem(
t *testing.T,
db *database.DB,
groupKey string,
libraryID int64,
album, artist string,
trackCount int,
status string,
) {
t.Helper()
if _, err := db.ExecContext(`
INSERT INTO tagging_items (
group_key, library_id, track_count,
album_name, album_artist, disc_number, status
) VALUES (?, ?, ?, ?, ?, 0, ?)
`, groupKey, libraryID, trackCount, album, artist, status); err != nil {
t.Fatalf("seed tagging_item: %v", err)
}
}
func scalarString(t *testing.T, db *database.DB, query string, args ...any) string {
t.Helper()
rows, err := db.QueryContext(query, args...)
if err != nil {
t.Fatalf("query %q: %v", query, err)
}
defer func() { _ = rows.Close() }()
var got string
if rows.Next() {
if scanErr := rows.Scan(&got); scanErr != nil {
t.Fatalf("scan %q: %v", query, scanErr)
}
}
return got
}
func scalarInt(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 scanErr := rows.Scan(&got); scanErr != nil {
t.Fatalf("scan %q: %v", query, scanErr)
}
}
return got
}