wip on autotagging
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, ¬Null, &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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = ?
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¬used, &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
|
||||
}
|
||||
Reference in New Issue
Block a user