feat: autotag mixed-bag splitting, search relevance fixes, and multi-library download imports
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:
@@ -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 = ?;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = ?
|
||||
|
||||
@@ -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 = ?
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user