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
@@ -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 = ?
`