feat: autotag mixed-bag splitting, search relevance fixes, and multi-library download imports
Build & publish Arch package / arch-package (push) Successful in 2m2s
Search index maintenance / maintain-index (push) Successful in 7s

Autotag: detect "junk drawer" folders with no artist/album consensus
and split them into synthetic per-cluster groups instead of forcing
one match on an unrelated pile of tracks; repair tagging_items rows
left behind by a prior scan orphan-cleanup gap.

Explore: fix an exact artist-name search being drowned out by its own
catalog entries in intent-prior scoring, and prune stale in_library
bookkeeping left behind when a referenced library row is deleted.

Download: fix a multi-library regression where every import failed
with "no library root configured" — the importer resolved the
library root from a legacy single-library config field that nothing
populates in the current multi-library model. It now resolves the
destination library per-request from the request's own library_id.
Also widen the Soulseek search window (12s -> 20s), measured against
real request history to be missing available peers on live queries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
This commit is contained in:
2026-08-10 11:52:26 -04:00
co-authored by Claude Sonnet 5
parent e190fd75b9
commit cbd82a5a74
70 changed files with 3617 additions and 129 deletions
+173 -5
View File
@@ -5,10 +5,13 @@ import (
"context"
"database/sql"
"embed"
"errors"
"fmt"
"io/fs"
"log/slog"
"path"
"sort"
"strconv"
"strings"
_ "modernc.org/sqlite" // Register sqlite driver.
@@ -23,6 +26,9 @@ import (
//go:embed sql/schemas/*.sql
var schemas embed.FS
//go:embed sql/migrations/*.sql
var migrations embed.FS
// DB wraps the SQLite database connection and queries.
//
// Two handles back a single database file. db is the single-writer
@@ -253,12 +259,20 @@ func (d *DB) ResumeExploreIndexFTS() error {
return nil
}
// applySchema creates the full schema on a fresh database.
// applySchema creates the full schema on a fresh database and brings
// an existing one up to date via sql/migrations.
//
// Every statement is CREATE ... IF NOT EXISTS, so this is idempotent and
// runs unconditionally at open. There is no migration chain: the files
// in sql/schemas describe the only schema the app has, and a database
// written by an older build is not supported.
// The schema files under sql/schemas are CREATE ... IF NOT EXISTS,
// so on a genuinely new database they create every table already at
// its current, latest shape — that's the fast path new installs
// take. A database that already has an older shape (e.g. a
// tagging_items missing a column a later build added) needs the gap
// closed, which IF NOT EXISTS can't do: it silently no-ops on a
// table that already exists, columns and all. sql/migrations holds
// small, additive, numbered files (ALTER TABLE, CREATE INDEX, etc.)
// for exactly that gap, tracked in schema_migrations so each applies
// at most once — see applyMigrations for how a fresh database's
// already-current tables tolerate replaying them anyway.
func applySchema(ctx context.Context, db *sql.DB) error {
dirEntries, err := schemas.ReadDir("sql/schemas")
if err != nil {
@@ -288,9 +302,163 @@ func applySchema(ctx context.Context, db *sql.DB) error {
return fmt.Errorf("could not create explore FTS triggers: %w", err)
}
if err := applyMigrations(ctx, db); err != nil {
return fmt.Errorf("could not apply migrations: %w", err)
}
return nil
}
// schemaMigrationsTable tracks which sql/migrations files have run,
// by their leading numeric prefix.
const schemaMigrationsTable = `
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`
// applyMigrations runs every sql/migrations file not yet recorded in
// schema_migrations, in filename order (numeric prefix), one
// statement at a time.
//
// Every migration runs on EVERY database, fresh or old — there is no
// "skip on fresh install" branch. A fresh database's tables already
// carry a migration's effect (sql/schemas declares the target shape
// directly), so its statements are expected to sometimes be no-ops
// there: "duplicate column name" from an ALTER TABLE ADD COLUMN is
// tolerated and treated as "already applied", the same way
// createExploreIndexFTSTriggers tolerates "already exists". Any
// other error is fatal. This is deliberately simpler than detecting
// "is this database fresh" — every migration converges both a fresh
// and an upgraded database to the identical final schema (including
// column order — ALTER TABLE ADD COLUMN always appends at the end,
// so sql/schemas must declare a migrated column last too; see the
// comment on tagging_items.sql and the regression test in
// migrations_test.go).
func applyMigrations(ctx context.Context, db *sql.DB) error {
if _, err := db.ExecContext(ctx, schemaMigrationsTable); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
dirEntries, err := migrations.ReadDir("sql/migrations")
if err != nil {
return fmt.Errorf("could not read migrations directory: %w", err)
}
sort.Slice(dirEntries, func(i, j int) bool {
return dirEntries[i].Name() < dirEntries[j].Name()
})
for _, dirEntry := range dirEntries {
if dirEntry.IsDir() {
continue
}
version, err := migrationVersion(dirEntry.Name())
if err != nil {
return err
}
applied, err := migrationApplied(ctx, db, version)
if err != nil {
return err
}
if applied {
continue
}
filePath := path.Join("sql/migrations", dirEntry.Name())
sqlContent, err := fs.ReadFile(migrations, filePath)
if err != nil {
return fmt.Errorf("could not read file %s: %w", filePath, err)
}
if err := execMigrationStatements(ctx, db, string(sqlContent)); err != nil {
return fmt.Errorf("error executing migration %s: %w", dirEntry.Name(), err)
}
if _, err := db.ExecContext(
ctx, `INSERT INTO schema_migrations (version) VALUES (?)`, version,
); err != nil {
return fmt.Errorf("record migration %d applied: %w", version, err)
}
}
return nil
}
// execMigrationStatements runs a migration file one statement at a
// time — NOT as one multi-statement Exec — so that one statement
// being a tolerable no-op (ALTER TABLE ADD COLUMN on a fresh
// database) doesn't abort the statements after it in the same file
// (e.g. a trailing CREATE INDEX that a fresh database still needs,
// since sql/schemas deliberately doesn't declare an index on a
// migrated column — see the comment on tagging_items.sql).
//
// Splitting on ";" is safe for the simple ALTER/CREATE TABLE/CREATE
// INDEX statements migrations are expected to contain; it is NOT
// safe for statements embedding a literal semicolon (e.g. a CREATE
// TRIGGER body) — write those with executeContext calls in Go
// instead of a sql/migrations file, the same way the explore FTS
// triggers already are.
func execMigrationStatements(ctx context.Context, db *sql.DB, script string) error {
for stmt := range strings.SplitSeq(script, ";") {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
if _, err := db.ExecContext(ctx, stmt); err != nil {
if strings.Contains(err.Error(), "duplicate column name") {
continue
}
return fmt.Errorf("statement %q: %w", stmt, err)
}
}
return nil
}
// migrationVersion extracts the leading integer prefix from a
// migration filename, e.g. "0001_tagging_items_synthetic.sql" -> 1.
func migrationVersion(filename string) (int, error) {
prefix, _, ok := strings.Cut(filename, "_")
if !ok {
return 0, fmt.Errorf("%w: %s", errMigrationFilename, filename)
}
version, err := strconv.Atoi(prefix)
if err != nil {
return 0, fmt.Errorf("%w: %s", errMigrationFilename, filename)
}
return version, nil
}
var errMigrationFilename = errors.New(
"migration filename must start with a numeric prefix followed by '_' (e.g. 0001_description.sql)",
)
func migrationApplied(ctx context.Context, db *sql.DB, version int) (bool, error) {
var v int
err := db.QueryRowContext(
ctx, `SELECT version FROM schema_migrations WHERE version = ?`, version,
).Scan(&v)
switch {
case errors.Is(err, sql.ErrNoRows):
return false, nil
case err != nil:
return false, fmt.Errorf("check migration %d: %w", version, err)
default:
return true, nil
}
}
// applyPRAGMAs configures SQLite connection settings. Called by both
// NewDB and NewTestDB to ensure identical behavior.
func applyPRAGMAs(ctx context.Context, db *sql.DB) error {
+196
View File
@@ -0,0 +1,196 @@
package database
import (
"database/sql"
"testing"
)
// oldTaggingItemsDDL is a frozen snapshot of tagging_items exactly as
// it read before sql/migrations/0001_tagging_items_synthetic.sql —
// i.e. what a real user's existing database looks like today, before
// upgrading to a build that includes that migration.
const oldTaggingItemsDDL = `
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 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';
`
// tableColumns returns the column names of a table in on-disk
// (positional) order, via PRAGMA table_info — the order sqlc's
// generated `SELECT *` scans bind to positionally.
func tableColumns(t *testing.T, db *sql.DB, table string) []string {
t.Helper()
rows, err := db.QueryContext(t.Context(), "PRAGMA table_info("+table+")")
if err != nil {
t.Fatalf("PRAGMA table_info(%s): %v", table, err)
}
defer func() { _ = rows.Close() }()
var cols []string
for rows.Next() {
var (
cid int
name string
ctype string
notnull int
dfltValue sql.NullString
primaryKey int
)
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dfltValue, &primaryKey); err != nil {
t.Fatalf("scan table_info row: %v", err)
}
cols = append(cols, name)
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate table_info: %v", err)
}
return cols
}
func openMemDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:?_busy_timeout=5000&_journal_mode=WAL")
if err != nil {
t.Fatalf("open in-memory db: %v", err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
if err := applyPRAGMAs(t.Context(), db); err != nil {
t.Fatalf("apply pragmas: %v", err)
}
return db
}
// TestMigrations_ColumnOrderMatchesFreshInstall is the regression
// test for the exact failure mode that got the old 48-step migration
// chain torn out (see .planning/NOTES.md, "No migration chain"):
// sql/schemas drifting from what migrations actually produce, so
// sqlc-generated code silently reads the wrong thing.
//
// A fresh install takes tagging_items straight from sql/schemas
// (CREATE TABLE, columns in file order). An existing database takes
// it from sql/schemas (the base shape, unchanged since the table
// already existed) plus sql/migrations/0001 (`ALTER TABLE ADD
// COLUMN`, which SQLite always appends at the END of the column
// list, regardless of where the column sits in the CREATE TABLE
// statement). If sql/schemas ever declares a migrated column
// somewhere other than last, the two paths produce tables with the
// SAME columns in a DIFFERENT order — invisible until a `SELECT *`
// (e.g. GetTaggingItem) silently binds a value to the wrong field.
func TestMigrations_ColumnOrderMatchesFreshInstall(t *testing.T) {
t.Parallel()
fresh := openMemDB(t)
if err := applySchema(t.Context(), fresh); err != nil {
t.Fatalf("apply schema (fresh): %v", err)
}
upgraded := openMemDB(t)
librariesDDL, err := schemas.ReadFile("sql/schemas/libraries.sql")
if err != nil {
t.Fatalf("read libraries schema: %v", err)
}
if _, err := upgraded.ExecContext(t.Context(), string(librariesDDL)); err != nil {
t.Fatalf("create libraries table: %v", err)
}
if _, err := upgraded.ExecContext(t.Context(), oldTaggingItemsDDL); err != nil {
t.Fatalf("create pre-migration tagging_items: %v", err)
}
// sql/schemas no-ops on the pre-existing tagging_items (IF NOT
// EXISTS), then sql/migrations/0001's ALTER TABLE statements
// actually add the missing columns for real this time.
if err := applySchema(t.Context(), upgraded); err != nil {
t.Fatalf("apply schema (upgrade path): %v", err)
}
freshCols := tableColumns(t, fresh, "tagging_items")
upgradedCols := tableColumns(t, upgraded, "tagging_items")
if len(freshCols) != len(upgradedCols) {
t.Fatalf(
"column count mismatch: fresh install has %d (%v), upgraded has %d (%v)",
len(freshCols), freshCols, len(upgradedCols), upgradedCols,
)
}
for i := range freshCols {
if freshCols[i] != upgradedCols[i] {
t.Errorf(
"column order mismatch at position %d: fresh install has %q, upgraded has %q\nfresh: %v\nupgraded: %v",
i,
freshCols[i],
upgradedCols[i],
freshCols,
upgradedCols,
)
}
}
}
// TestMigrations_FreshDatabaseStillRecordsAndGetsIndex confirms a
// brand-new database runs migration 0001 (tolerating "duplicate
// column name" from its ALTER TABLE statements, since sql/schemas
// already declared those columns), records it applied, AND still
// gets the trailing CREATE INDEX statement sql/schemas deliberately
// omits for migrated columns.
func TestMigrations_FreshDatabaseStillRecordsAndGetsIndex(t *testing.T) {
t.Parallel()
fresh := openMemDB(t)
if err := applySchema(t.Context(), fresh); err != nil {
t.Fatalf("apply schema: %v", err)
}
var version int
err := fresh.QueryRowContext(
t.Context(), "SELECT version FROM schema_migrations WHERE version = 1",
).Scan(&version)
if err != nil {
t.Fatalf("expected migration 1 to be recorded as applied on a fresh db: %v", err)
}
var indexName string
err = fresh.QueryRowContext(
t.Context(),
"SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_tagging_items_parent_group_key'",
).Scan(&indexName)
if err != nil {
t.Fatalf("expected idx_tagging_items_parent_group_key to exist on a fresh db: %v", err)
}
}
@@ -0,0 +1,10 @@
-- Adds SplitMixedFolder's synthetic-group bookkeeping to an
-- existing tagging_items table. A fresh database never runs this
-- file: sql/schemas/tagging_items.sql already declares these
-- columns, so applySchema's isFreshDatabase check stamps this
-- version as applied without executing it.
ALTER TABLE tagging_items ADD COLUMN synthetic INTEGER NOT NULL DEFAULT 0;
ALTER TABLE tagging_items ADD COLUMN parent_group_key TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_tagging_items_parent_group_key
ON tagging_items(parent_group_key) WHERE parent_group_key != '';
@@ -0,0 +1,24 @@
-- Repairs tagging_items rows left behind by a library-scan bug: the
-- rescan's orphan-cleanup phase deleted audio_files rows for files
-- removed from disk without decrementing/clearing their tagging
-- group, so a folder whose contents were fully replaced kept a
-- phantom entry (stale track_count, no matching audio_files) in the
-- autotag queue forever. The library scan code no longer has this
-- gap, but a database written before the fix still carries the
-- damage — this is a one-time repair, not ongoing bookkeeping.
--
-- Drop groups with no audio_files left at all.
DELETE FROM tagging_items
WHERE group_key NOT IN (
SELECT DISTINCT group_key FROM audio_files WHERE group_key != ''
);
-- Reconcile track_count for groups that are still alive but drifted
-- (some, not all, of their tracks were removed without decrementing).
UPDATE tagging_items
SET track_count = (
SELECT COUNT(*) FROM audio_files WHERE audio_files.group_key = tagging_items.group_key
)
WHERE track_count != (
SELECT COUNT(*) FROM audio_files WHERE audio_files.group_key = tagging_items.group_key
);
@@ -32,3 +32,11 @@ SELECT
(SELECT COUNT(*) FROM recordings WHERE artist_credit_id = ?1) +
(SELECT COUNT(*) FROM release_groups WHERE album_artist_credit_id = ?1)
AS total;
-- name: GetOrphanedArtistCreditIDs :many
-- Artist credits no longer used by any recording or release group - run
-- after orphaned recordings/release groups are deleted, so a credit
-- that only existed for now-removed tracks is cleaned up too.
SELECT ac.id FROM artist_credit ac
WHERE NOT EXISTS (SELECT 1 FROM recordings r WHERE r.artist_credit_id = ac.id)
AND NOT EXISTS (SELECT 1 FROM release_groups rg WHERE rg.album_artist_credit_id = ac.id);
@@ -18,3 +18,7 @@ WHERE id =?;
-- name: DeleteAllArtistCreditArtists :exec
DELETE FROM artist_credit_artist;
-- name: DeleteArtistCreditArtistByCredit :exec
DELETE FROM artist_credit_artist
WHERE credit_id = ?;
+9
View File
@@ -39,6 +39,15 @@ JOIN artist_credit ac ON ac.id = aca.credit_id
JOIN release_groups rg ON rg.album_artist_credit_id = ac.id
ORDER BY a.name;
-- name: GetOrphanedArtistIDs :many
-- Artists no longer credited on any recording or release group - left
-- behind when a scan's orphan cleanup removes the audio_files that used
-- to justify them, since deleting an audio_files row doesn't cascade.
SELECT a.id FROM artists a
WHERE NOT EXISTS (
SELECT 1 FROM artist_credit_artist aca WHERE aca.artist_id = a.id
);
-- name: GetAlbumArtistsByLibrary :many
SELECT DISTINCT a.id, a.name, a.mbid
FROM artists a
@@ -37,3 +37,11 @@ ORDER BY name;
-- name: CountRecordingsByArtistCredit :one
SELECT COUNT(*) FROM recordings WHERE artist_credit_id = ?;
-- name: GetOrphanedRecordingIDs :many
-- Recordings no longer backed by any audio_files row - left behind
-- when a scan's orphan cleanup deletes the file that used to own them,
-- since deleting audio_files doesn't cascade to recordings.
SELECT r.id FROM recordings r
LEFT JOIN audio_files af ON af.recording_id = r.id
WHERE af.id IS NULL;
@@ -26,3 +26,7 @@ WHERE release_group_id = ? AND recording_id = ?;
-- name: DeleteAllReleaseGroupRecordings :exec
DELETE FROM release_group_recordings;
-- name: DeleteReleaseGroupRecordingsByRecording :exec
DELETE FROM release_group_recordings
WHERE recording_id = ?;
@@ -167,6 +167,14 @@ ORDER BY rg.name;
-- name: CountReleaseGroupRecordings :one
SELECT COUNT(*) FROM release_group_recordings WHERE release_group_id = ?;
-- name: GetOrphanedReleaseGroupIDs :many
-- Release groups with no recordings left in them - run after orphaned
-- recordings (and their release_group_recordings rows) are deleted, so
-- a release group whose last owned track was removed is cleaned up too.
SELECT rg.id FROM release_groups rg
LEFT JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
WHERE rgr.id IS NULL;
-- name: GetAlbumsByArtistByLibrary :many
SELECT
rg.id,
+66 -3
View File
@@ -24,11 +24,63 @@ WHERE group_key = ?;
DELETE FROM tagging_items
WHERE group_key = ? AND track_count <= 0;
-- name: PruneOrphanedTaggingItems :exec
-- Self-healing sweep for rows whose track_count bookkeeping (scan
-- orphan cleanup, maybeRebindTaggingGroup, SplitMixedFolder) never
-- ran or drifted: a cancelled scan, a library move/rename the
-- SoftScanAllLibraries disk-count/mtime heuristic did not catch, or
-- a decrement that landed without its paired delete. Rather than
-- trust track_count, this checks the ground truth directly: any
-- group_key no audio_files row still points at is gone, and its
-- tagging_items row (and cascaded tagging_candidates) should be too.
-- Cheap: one indexed (idx_audio_files_group_key) existence check per
-- row. Called opportunistically wherever the pending list is read,
-- so stale entries cannot linger indefinitely between full rescans.
DELETE FROM tagging_items
WHERE NOT EXISTS (
SELECT 1 FROM audio_files af WHERE af.group_key = tagging_items.group_key
);
-- name: MarkTaggingItemSynthetic :exec
-- Stamps a group as carved out of parent_group_key by
-- SplitMixedFolder. Idempotent: safe to call every time a track
-- is migrated into the synthetic group, not just on first creation.
UPDATE tagging_items
SET synthetic = 1,
parent_group_key = ?
WHERE group_key = ?;
-- name: GetTaggingItem :one
SELECT * FROM tagging_items
WHERE group_key = ?
LIMIT 1;
-- name: ListLikelyMixedBagGroupKeys :many
-- Cheap, whole-library triage pass for autotag.IsMixedBag: one
-- grouped scan over audio_files (indexed on group_key) rather than
-- hydrating every group's full track list in Go. LOWER/TRIM is an
-- approximation of autotag.Normalize (no unicode fold, no qualifier
-- stripping) so this can flag a false positive Normalize would
-- clear, or miss a true one Normalize would catch. Treat it as a
-- triage filter for which groups are worth a real autotag.
-- IsMixedBag check, or a badge at minimum, not the final word.
SELECT ti.group_key
FROM tagging_items ti
JOIN audio_files af ON af.group_key = ti.group_key
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN release_group_recordings rgr ON rgr.recording_id = r.id
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
WHERE ti.synthetic = 0
AND ti.track_count >= 4
AND (
ti.album_artist = ''
OR LOWER(TRIM(ti.album_artist)) IN ('various artists', 'various', 'va', 'v.a.', 'v a', 'unknown')
)
GROUP BY ti.group_key
HAVING COUNT(DISTINCT CASE WHEN ac.text != '' THEN LOWER(TRIM(ac.text)) END) > 1
AND COUNT(DISTINCT CASE WHEN rg.name != '' THEN LOWER(TRIM(rg.name)) END) > 1;
-- name: CountPendingTaggingItems :one
SELECT COUNT(*) FROM tagging_items
WHERE status = 'pending'
@@ -71,7 +123,8 @@ SELECT
ti.score,
ti.last_checked_at,
ti.status,
ti.created_at
ti.created_at,
ti.synthetic
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)
@@ -102,7 +155,8 @@ SELECT
ti.score,
ti.last_checked_at,
ti.status,
ti.created_at
ti.created_at,
ti.synthetic
FROM tagging_items ti
LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE ti.group_key = ?
@@ -130,6 +184,10 @@ ORDER BY ti.created_at DESC, ti.group_key
LIMIT @row_limit OFFSET @row_offset;
-- name: ListAudioFilesInTaggingGroup :many
-- album_name/album_artist are the PER-TRACK tags (via each track's
-- own release_group link), not the folder-level tagging_items
-- values. SplitMixedFolder clusters on these to find sub-albums
-- hiding inside a folder full of unrelated tracks.
SELECT
af.id,
af.file_path,
@@ -140,10 +198,15 @@ SELECT
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
COALESCE(r.mbid, '') AS recording_mbid,
COALESCE(rg.name, '') AS album_name,
COALESCE(rgac.text, '') AS album_artist
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 release_group_recordings rgr ON rgr.recording_id = r.id
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
LEFT JOIN artist_credit rgac ON rg.album_artist_credit_id = rgac.id
WHERE af.group_key = ?
ORDER BY COALESCE(r.disc_number, 0),
COALESCE(r.track_number, 0),
@@ -17,6 +17,28 @@ CREATE TABLE IF NOT EXISTS tagging_items (
-- review state.
cleared_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- synthetic marks a group carved out of a "mixed bag" folder by
-- SplitMixedFolder: its tracks share a folder with unrelated
-- tracks (a junk-drawer directory) but were clustered together by
-- matching album/album-artist tags rather than by directory.
-- Scoring relaxes the missing-track penalty for these groups,
-- since they're a subset pulled out of a bigger folder, not a
-- complete rip of their own directory. parent_group_key is the
-- original folder group they were split from.
--
-- These two columns are declared LAST, after created_at, even
-- though that reads oddly next to the rest of the table: sql/
-- migrations/0001 brings a pre-existing tagging_items up to date
-- with `ALTER TABLE ADD COLUMN`, which SQLite always appends at
-- the end of the column list. A fresh install (this file) and an
-- upgraded database (this file + the migration) must end up with
-- IDENTICAL column order, because sqlc-generated `SELECT *` scans
-- (e.g. GetTaggingItem) bind columns positionally — see the
-- schema/migration column-order test in database_test.go. Put
-- new columns wherever reads best when adding a table for the
-- first time; append-only from the second migration on.
synthetic INTEGER NOT NULL DEFAULT 0,
parent_group_key TEXT NOT NULL DEFAULT '',
FOREIGN KEY(library_id) REFERENCES libraries(id)
);
@@ -25,3 +47,10 @@ CREATE INDEX IF NOT EXISTS idx_tagging_items_library_status
CREATE INDEX IF NOT EXISTS idx_tagging_items_status_pending
ON tagging_items(library_id) WHERE status = 'pending';
-- idx_tagging_items_parent_group_key is NOT declared here on
-- purpose: this file runs unconditionally, before migrations, even
-- against a database that hasn't run 0001 yet — an index predicate
-- referencing parent_group_key would fail on that table. It lives
-- solely in sql/migrations/0001_tagging_items_synthetic.sql, which
-- runs after the column exists either way (see database.go).
@@ -78,6 +78,38 @@ func (q *Queries) GetArtistCreditByText(ctx context.Context, text string) (Artis
return i, err
}
const getOrphanedArtistCreditIDs = `-- name: GetOrphanedArtistCreditIDs :many
SELECT ac.id FROM artist_credit ac
WHERE NOT EXISTS (SELECT 1 FROM recordings r WHERE r.artist_credit_id = ac.id)
AND NOT EXISTS (SELECT 1 FROM release_groups rg WHERE rg.album_artist_credit_id = ac.id)
`
// Artist credits no longer used by any recording or release group - run
// after orphaned recordings/release groups are deleted, so a credit
// that only existed for now-removed tracks is cleaned up too.
func (q *Queries) GetOrphanedArtistCreditIDs(ctx context.Context) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, getOrphanedArtistCreditIDs)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateArtistCredit = `-- name: UpdateArtistCredit :exec
UPDATE artist_credit
SET text = ?
@@ -45,6 +45,16 @@ func (q *Queries) DeleteArtistCreditArtist(ctx context.Context, id int64) error
return err
}
const deleteArtistCreditArtistByCredit = `-- name: DeleteArtistCreditArtistByCredit :exec
DELETE FROM artist_credit_artist
WHERE credit_id = ?
`
func (q *Queries) DeleteArtistCreditArtistByCredit(ctx context.Context, creditID int64) error {
_, err := q.db.ExecContext(ctx, deleteArtistCreditArtistByCredit, creditID)
return err
}
const getArtistCreditArtist = `-- name: GetArtistCreditArtist :one
SELECT id, artist_id, credit_id FROM artist_credit_artist
WHERE id = ? LIMIT 1
@@ -166,6 +166,39 @@ func (q *Queries) GetArtistByName(ctx context.Context, name string) (Artist, err
return i, err
}
const getOrphanedArtistIDs = `-- name: GetOrphanedArtistIDs :many
SELECT a.id FROM artists a
WHERE NOT EXISTS (
SELECT 1 FROM artist_credit_artist aca WHERE aca.artist_id = a.id
)
`
// Artists no longer credited on any recording or release group - left
// behind when a scan's orphan cleanup removes the audio_files that used
// to justify them, since deleting an audio_files row doesn't cascade.
func (q *Queries) GetOrphanedArtistIDs(ctx context.Context) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, getOrphanedArtistIDs)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateArtist = `-- name: UpdateArtist :exec
UPDATE artists
SET name = ?
+2
View File
@@ -361,6 +361,8 @@ type TaggingItem struct {
Status string
ClearedAt sql.NullTime
CreatedAt time.Time
Synthetic int64
ParentGroupKey string
}
type TrackMetadatum struct {
@@ -158,6 +158,38 @@ func (q *Queries) GetAllRecordings(ctx context.Context) ([]Recording, error) {
return items, nil
}
const getOrphanedRecordingIDs = `-- name: GetOrphanedRecordingIDs :many
SELECT r.id FROM recordings r
LEFT JOIN audio_files af ON af.recording_id = r.id
WHERE af.id IS NULL
`
// Recordings no longer backed by any audio_files row - left behind
// when a scan's orphan cleanup deletes the file that used to own them,
// since deleting audio_files doesn't cascade to recordings.
func (q *Queries) GetOrphanedRecordingIDs(ctx context.Context) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, getOrphanedRecordingIDs)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getRecording = `-- name: GetRecording :one
SELECT id, name, artist_credit_id, track_number, disc_number, year, genre, composer, lyrics, comment, mbid FROM recordings
WHERE id = ? LIMIT 1
@@ -75,6 +75,16 @@ func (q *Queries) DeleteReleaseGroupRecordingByFK(ctx context.Context, arg Delet
return err
}
const deleteReleaseGroupRecordingsByRecording = `-- name: DeleteReleaseGroupRecordingsByRecording :exec
DELETE FROM release_group_recordings
WHERE recording_id = ?
`
func (q *Queries) DeleteReleaseGroupRecordingsByRecording(ctx context.Context, recordingID int64) error {
_, err := q.db.ExecContext(ctx, deleteReleaseGroupRecordingsByRecording, recordingID)
return err
}
const getRecordingReleaseGroups = `-- name: GetRecordingReleaseGroups :many
SELECT id, release_group_id, recording_id, track_number, disc_number FROM release_group_recordings
WHERE recording_id = ?
@@ -469,6 +469,38 @@ func (q *Queries) GetAllReleaseGroups(ctx context.Context) ([]ReleaseGroup, erro
return items, nil
}
const getOrphanedReleaseGroupIDs = `-- name: GetOrphanedReleaseGroupIDs :many
SELECT rg.id FROM release_groups rg
LEFT JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
WHERE rgr.id IS NULL
`
// Release groups with no recordings left in them - run after orphaned
// recordings (and their release_group_recordings rows) are deleted, so
// a release group whose last owned track was removed is cleaned up too.
func (q *Queries) GetOrphanedReleaseGroupIDs(ctx context.Context) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, getOrphanedReleaseGroupIDs)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getReleaseGroup = `-- name: GetReleaseGroup :one
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year FROM release_groups
WHERE id = ? LIMIT 1
@@ -136,7 +136,8 @@ SELECT
ti.score,
ti.last_checked_at,
ti.status,
ti.created_at
ti.created_at,
ti.synthetic
FROM tagging_items ti
LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE ti.group_key = ?
@@ -158,6 +159,7 @@ type GetPendingFolderDetailRow struct {
LastCheckedAt sql.NullTime
Status string
CreatedAt time.Time
Synthetic int64
}
func (q *Queries) GetPendingFolderDetail(ctx context.Context, groupKey string) (GetPendingFolderDetailRow, error) {
@@ -178,6 +180,7 @@ func (q *Queries) GetPendingFolderDetail(ctx context.Context, groupKey string) (
&i.LastCheckedAt,
&i.Status,
&i.CreatedAt,
&i.Synthetic,
)
return i, err
}
@@ -197,7 +200,7 @@ func (q *Queries) GetRecordingReleaseGroupID(ctx context.Context, recordingID in
}
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
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, synthetic, parent_group_key FROM tagging_items
WHERE group_key = ?
LIMIT 1
`
@@ -218,6 +221,8 @@ func (q *Queries) GetTaggingItem(ctx context.Context, groupKey string) (TaggingI
&i.Status,
&i.ClearedAt,
&i.CreatedAt,
&i.Synthetic,
&i.ParentGroupKey,
)
return i, err
}
@@ -233,10 +238,15 @@ SELECT
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
COALESCE(r.mbid, '') AS recording_mbid,
COALESCE(rg.name, '') AS album_name,
COALESCE(rgac.text, '') AS album_artist
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 release_group_recordings rgr ON rgr.recording_id = r.id
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
LEFT JOIN artist_credit rgac ON rg.album_artist_credit_id = rgac.id
WHERE af.group_key = ?
ORDER BY COALESCE(r.disc_number, 0),
COALESCE(r.track_number, 0),
@@ -254,8 +264,14 @@ type ListAudioFilesInTaggingGroupRow struct {
Title string
ArtistName string
RecordingMbid string
AlbumName string
AlbumArtist string
}
// album_name/album_artist are the PER-TRACK tags (via each track's
// own release_group link), not the folder-level tagging_items
// values. SplitMixedFolder clusters on these to find sub-albums
// hiding inside a folder full of unrelated tracks.
func (q *Queries) ListAudioFilesInTaggingGroup(ctx context.Context, groupKey string) ([]ListAudioFilesInTaggingGroupRow, error) {
rows, err := q.db.QueryContext(ctx, listAudioFilesInTaggingGroup, groupKey)
if err != nil {
@@ -276,6 +292,8 @@ func (q *Queries) ListAudioFilesInTaggingGroup(ctx context.Context, groupKey str
&i.Title,
&i.ArtistName,
&i.RecordingMbid,
&i.AlbumName,
&i.AlbumArtist,
); err != nil {
return nil, err
}
@@ -290,6 +308,56 @@ func (q *Queries) ListAudioFilesInTaggingGroup(ctx context.Context, groupKey str
return items, nil
}
const listLikelyMixedBagGroupKeys = `-- name: ListLikelyMixedBagGroupKeys :many
SELECT ti.group_key
FROM tagging_items ti
JOIN audio_files af ON af.group_key = ti.group_key
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN release_group_recordings rgr ON rgr.recording_id = r.id
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
WHERE ti.synthetic = 0
AND ti.track_count >= 4
AND (
ti.album_artist = ''
OR LOWER(TRIM(ti.album_artist)) IN ('various artists', 'various', 'va', 'v.a.', 'v a', 'unknown')
)
GROUP BY ti.group_key
HAVING COUNT(DISTINCT CASE WHEN ac.text != '' THEN LOWER(TRIM(ac.text)) END) > 1
AND COUNT(DISTINCT CASE WHEN rg.name != '' THEN LOWER(TRIM(rg.name)) END) > 1
`
// Cheap, whole-library triage pass for autotag.IsMixedBag: one
// grouped scan over audio_files (indexed on group_key) rather than
// hydrating every group's full track list in Go. LOWER/TRIM is an
// approximation of autotag.Normalize (no unicode fold, no qualifier
// stripping) so this can flag a false positive Normalize would
// clear, or miss a true one Normalize would catch. Treat it as a
// triage filter for which groups are worth a real autotag.
// IsMixedBag check, or a badge at minimum, not the final word.
func (q *Queries) ListLikelyMixedBagGroupKeys(ctx context.Context) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listLikelyMixedBagGroupKeys)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var group_key string
if err := rows.Scan(&group_key); err != nil {
return nil, err
}
items = append(items, group_key)
}
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,
@@ -552,7 +620,8 @@ SELECT
ti.score,
ti.last_checked_at,
ti.status,
ti.created_at
ti.created_at,
ti.synthetic
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)
@@ -584,6 +653,7 @@ type ListPendingTaggingItemsByScoreRow struct {
LastCheckedAt sql.NullTime
Status string
CreatedAt time.Time
Synthetic int64
}
func (q *Queries) ListPendingTaggingItemsByScore(ctx context.Context, arg ListPendingTaggingItemsByScoreParams) ([]ListPendingTaggingItemsByScoreRow, error) {
@@ -615,6 +685,7 @@ func (q *Queries) ListPendingTaggingItemsByScore(ctx context.Context, arg ListPe
&i.LastCheckedAt,
&i.Status,
&i.CreatedAt,
&i.Synthetic,
); err != nil {
return nil, err
}
@@ -629,6 +700,49 @@ func (q *Queries) ListPendingTaggingItemsByScore(ctx context.Context, arg ListPe
return items, nil
}
const markTaggingItemSynthetic = `-- name: MarkTaggingItemSynthetic :exec
UPDATE tagging_items
SET synthetic = 1,
parent_group_key = ?
WHERE group_key = ?
`
type MarkTaggingItemSyntheticParams struct {
ParentGroupKey string
GroupKey string
}
// Stamps a group as carved out of parent_group_key by
// SplitMixedFolder. Idempotent: safe to call every time a track
// is migrated into the synthetic group, not just on first creation.
func (q *Queries) MarkTaggingItemSynthetic(ctx context.Context, arg MarkTaggingItemSyntheticParams) error {
_, err := q.db.ExecContext(ctx, markTaggingItemSynthetic, arg.ParentGroupKey, arg.GroupKey)
return err
}
const pruneOrphanedTaggingItems = `-- name: PruneOrphanedTaggingItems :exec
DELETE FROM tagging_items
WHERE NOT EXISTS (
SELECT 1 FROM audio_files af WHERE af.group_key = tagging_items.group_key
)
`
// Self-healing sweep for rows whose track_count bookkeeping (scan
// orphan cleanup, maybeRebindTaggingGroup, SplitMixedFolder) never
// ran or drifted: a cancelled scan, a library move/rename the
// SoftScanAllLibraries disk-count/mtime heuristic did not catch, or
// a decrement that landed without its paired delete. Rather than
// trust track_count, this checks the ground truth directly: any
// group_key no audio_files row still points at is gone, and its
// tagging_items row (and cascaded tagging_candidates) should be too.
// Cheap: one indexed (idx_audio_files_group_key) existence check per
// row. Called opportunistically wherever the pending list is read,
// so stale entries cannot linger indefinitely between full rescans.
func (q *Queries) PruneOrphanedTaggingItems(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, pruneOrphanedTaggingItems)
return err
}
const setAudioFileTagStatus = `-- name: SetAudioFileTagStatus :exec
UPDATE audio_files SET tag_status = ? WHERE id = ?
`