diff --git a/internal/db/dbq/events.sql.go b/internal/db/dbq/events.sql.go index ab656653..c9a1e290 100644 --- a/internal/db/dbq/events.sql.go +++ b/internal/db/dbq/events.sql.go @@ -261,7 +261,7 @@ func (q *Queries) InsertSkipEvent(ctx context.Context, arg InsertSkipEventParams } const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many -SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at FROM tracks t +SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at, t.emptied_at FROM tracks t JOIN play_events pe ON pe.track_id = t.id WHERE pe.session_id = $1 AND pe.started_at < $2 @@ -312,6 +312,7 @@ func (q *Queries) ListRecentSessionTracks(ctx context.Context, arg ListRecentSes &i.SongKey, &i.SourceVerdict, &i.SourceVerdictAt, + &i.EmptiedAt, ); err != nil { return nil, err } diff --git a/internal/db/dbq/history.sql.go b/internal/db/dbq/history.sql.go index 8c9cd61e..a07cc104 100644 --- a/internal/db/dbq/history.sql.go +++ b/internal/db/dbq/history.sql.go @@ -14,7 +14,7 @@ import ( const listUserHistory = `-- name: ListUserHistory :many SELECT pe.id AS event_id, pe.started_at, - t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at, + t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at, t.emptied_at, albums.title AS album_title, artists.name AS artist_name FROM play_events pe @@ -86,6 +86,7 @@ func (q *Queries) ListUserHistory(ctx context.Context, arg ListUserHistoryParams &i.Track.SongKey, &i.Track.SourceVerdict, &i.Track.SourceVerdictAt, + &i.Track.EmptiedAt, &i.AlbumTitle, &i.ArtistName, ); err != nil { diff --git a/internal/db/dbq/likes.sql.go b/internal/db/dbq/likes.sql.go index d7480f00..311b1a52 100644 --- a/internal/db/dbq/likes.sql.go +++ b/internal/db/dbq/likes.sql.go @@ -281,7 +281,7 @@ func (q *Queries) ListLikedTrackIDs(ctx context.Context, userID pgtype.UUID) ([] } const listLikedTrackRows = `-- name: ListLikedTrackRows :many -SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at FROM tracks t +SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at, t.emptied_at FROM tracks t JOIN (SELECT DISTINCT ON (lt.song_key) l.track_id, l.liked_at FROM general_likes l JOIN tracks lt ON lt.id = l.track_id @@ -335,6 +335,7 @@ func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRows &i.SongKey, &i.SourceVerdict, &i.SourceVerdictAt, + &i.EmptiedAt, ); err != nil { return nil, err } diff --git a/internal/db/dbq/missing_pairs.sql.go b/internal/db/dbq/missing_pairs.sql.go index d03ce2f0..3730c5f5 100644 --- a/internal/db/dbq/missing_pairs.sql.go +++ b/internal/db/dbq/missing_pairs.sql.go @@ -35,6 +35,7 @@ cand AS ( AND lower(p.title) = lower(m.title) WHERE m.missing_since IS NOT NULL AND (m.mbid IS NULL OR p.mbid IS NULL OR m.mbid = p.mbid) + AND (m.emptied_at IS DISTINCT FROM m.missing_since OR p.added_at > m.emptied_at) ), pairs AS ( -- A pair both rules found is one candidate, credited to the stronger rule. @@ -87,6 +88,10 @@ type ListMissingTrackPairsRow struct { // A pair whose MBIDs are both set and differ is not a title match: the tags say // they are different recordings. The caller folds a pair only when each side has // exactly one candidate; anything else is ambiguous and left alone. +// +// A slot the duplicate resolver emptied (emptied_at, migration 0095) pairs by +// title only with a track added since: the album held no copy of the song as +// long as it is, so one already there under the title is another song. func (q *Queries) ListMissingTrackPairs(ctx context.Context) ([]ListMissingTrackPairsRow, error) { rows, err := q.db.Query(ctx, listMissingTrackPairs) if err != nil { diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index ff72c52e..96e155aa 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -926,6 +926,7 @@ type Track struct { SongKey pgtype.UUID SourceVerdict *string SourceVerdictAt pgtype.Timestamptz + EmptiedAt pgtype.Timestamptz } type TrackAcoustidLookup struct { diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index 09bd0b99..c054f198 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -208,7 +208,7 @@ WITH plays AS ( WHERE user_id = $2 AND was_skipped = false GROUP BY track_id ) -SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at, +SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at, t.emptied_at, albums.title AS album_title, artists.name AS artist_name FROM plays p @@ -275,6 +275,7 @@ func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMos &i.Track.SongKey, &i.Track.SourceVerdict, &i.Track.SourceVerdictAt, + &i.Track.EmptiedAt, &i.AlbumTitle, &i.ArtistName, ); err != nil { @@ -295,7 +296,7 @@ WITH plays AS ( WHERE user_id = $1 AND was_skipped = false GROUP BY track_id ) -SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at, +SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at, t.emptied_at, albums.title AS album_title, artists.name AS artist_name FROM plays p @@ -364,6 +365,7 @@ func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostP &i.Track.SongKey, &i.Track.SourceVerdict, &i.Track.SourceVerdictAt, + &i.Track.EmptiedAt, &i.AlbumTitle, &i.ArtistName, ); err != nil { @@ -703,7 +705,7 @@ func (q *Queries) ListRediscoverArtistsForUser(ctx context.Context, arg ListRedi const loadRadioCandidates = `-- name: LoadRadioCandidates :many SELECT - t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at, + t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at, t.emptied_at, (l.user_id IS NOT NULL)::bool AS is_liked, pe.last_played_at::timestamptz AS last_played_at, pe.play_count, @@ -789,6 +791,7 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat &i.Track.SongKey, &i.Track.SourceVerdict, &i.Track.SourceVerdictAt, + &i.Track.EmptiedAt, &i.IsLiked, &i.LastPlayedAt, &i.PlayCount, @@ -937,7 +940,7 @@ random_fill AS ( LIMIT $9 ) SELECT - t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at, + t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at, t.emptied_at, (l.user_id IS NOT NULL)::bool AS is_liked, pe.last_played_at::timestamptz AS last_played_at, pe.play_count, @@ -1069,6 +1072,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid &i.Track.SongKey, &i.Track.SourceVerdict, &i.Track.SourceVerdictAt, + &i.Track.EmptiedAt, &i.IsLiked, &i.LastPlayedAt, &i.PlayCount, diff --git a/internal/db/dbq/tracks.sql.go b/internal/db/dbq/tracks.sql.go index ea0716be..2bd38335 100644 --- a/internal/db/dbq/tracks.sql.go +++ b/internal/db/dbq/tracks.sql.go @@ -299,7 +299,7 @@ func (q *Queries) FlagSuspectSourceTracks(ctx context.Context, pattern string) ( } const getTrackByID = `-- name: GetTrackByID :one -SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at FROM tracks WHERE id = $1 +SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at, emptied_at FROM tracks WHERE id = $1 ` func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, error) { @@ -330,12 +330,13 @@ func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, erro &i.SongKey, &i.SourceVerdict, &i.SourceVerdictAt, + &i.EmptiedAt, ) return i, err } const getTrackByPath = `-- name: GetTrackByPath :one -SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at FROM tracks WHERE file_path = $1 +SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at, emptied_at FROM tracks WHERE file_path = $1 ` func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, error) { @@ -366,12 +367,13 @@ func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, e &i.SongKey, &i.SourceVerdict, &i.SourceVerdictAt, + &i.EmptiedAt, ) return i, err } const getTracksByIDs = `-- name: GetTracksByIDs :many -SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at FROM tracks WHERE id = ANY($1::uuid[]) +SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at, emptied_at FROM tracks WHERE id = ANY($1::uuid[]) ` // Batched lookup used by /api/library/sync to hydrate upsert payloads @@ -410,6 +412,7 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([ &i.SongKey, &i.SourceVerdict, &i.SourceVerdictAt, + &i.EmptiedAt, ); err != nil { return nil, err } @@ -422,7 +425,7 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([ } const listArtistTracksForUser = `-- name: ListArtistTracksForUser :many -SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at, +SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at, t.emptied_at, albums.title AS album_title, artists.name AS artist_name FROM tracks t @@ -487,6 +490,7 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra &i.Track.SongKey, &i.Track.SourceVerdict, &i.Track.SourceVerdictAt, + &i.Track.EmptiedAt, &i.AlbumTitle, &i.ArtistName, ); err != nil { @@ -587,7 +591,7 @@ func (q *Queries) ListMissingTracks(ctx context.Context, arg ListMissingTracksPa } const listRandomTracksForUser = `-- name: ListRandomTracksForUser :many -SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at, +SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at, t.emptied_at, albums.title AS album_title, artists.name AS artist_name FROM tracks t @@ -649,6 +653,7 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra &i.Track.SongKey, &i.Track.SourceVerdict, &i.Track.SourceVerdictAt, + &i.Track.EmptiedAt, &i.AlbumTitle, &i.ArtistName, ); err != nil { @@ -836,7 +841,7 @@ func (q *Queries) ListTrackPathsForReconcile(ctx context.Context) ([]ListTrackPa } const listTracksByAlbum = `-- name: ListTracksByAlbum :many -SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at FROM tracks +SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at, emptied_at FROM tracks WHERE album_id = $1 AND NOT EXISTS ( SELECT 1 FROM quarantine q @@ -887,6 +892,7 @@ func (q *Queries) ListTracksByAlbum(ctx context.Context, arg ListTracksByAlbumPa &i.SongKey, &i.SourceVerdict, &i.SourceVerdictAt, + &i.EmptiedAt, ); err != nil { return nil, err } @@ -936,6 +942,21 @@ func (q *Queries) ListTracksMissingMbidWithPath(ctx context.Context, limit int32 return items, nil } +const markTracksEmptied = `-- name: MarkTracksEmptied :exec +UPDATE tracks + SET emptied_at = missing_since + WHERE id = ANY($1::uuid[]) + AND missing_since IS NOT NULL +` + +// Stamps rows the duplicate resolver emptied, whose song the album holds no +// other copy of, with the time they went missing (migration 0095): missing-pair +// folding then waits for a copy fetched again. +func (q *Queries) MarkTracksEmptied(ctx context.Context, ids []pgtype.UUID) error { + _, err := q.db.Exec(ctx, markTracksEmptied, ids) + return err +} + const markTracksMissing = `-- name: MarkTracksMissing :execrows UPDATE tracks SET missing_since = now() @@ -960,7 +981,7 @@ func (q *Queries) MarkTracksMissing(ctx context.Context, ids []pgtype.UUID) (int } const searchTracks = `-- name: SearchTracks :many -SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at FROM tracks +SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at, emptied_at FROM tracks WHERE title ILIKE '%' || $1::text || '%' AND NOT EXISTS ( SELECT 1 FROM quarantine q @@ -1018,6 +1039,7 @@ func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]T &i.SongKey, &i.SourceVerdict, &i.SourceVerdictAt, + &i.EmptiedAt, ); err != nil { return nil, err } @@ -1096,7 +1118,7 @@ ON CONFLICT (file_path) DO UPDATE SET -- next scan can short-circuit them again (#2499). tag_read_version = EXCLUDED.tag_read_version, updated_at = now() -RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at +RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at, emptied_at ` type UpsertTrackParams struct { @@ -1160,6 +1182,7 @@ func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) (Track &i.SongKey, &i.SourceVerdict, &i.SourceVerdictAt, + &i.EmptiedAt, ) return i, err } diff --git a/internal/db/migrations/0095_tracks_emptied_at.down.sql b/internal/db/migrations/0095_tracks_emptied_at.down.sql new file mode 100644 index 00000000..f3feee97 --- /dev/null +++ b/internal/db/migrations/0095_tracks_emptied_at.down.sql @@ -0,0 +1 @@ +ALTER TABLE tracks DROP COLUMN emptied_at; diff --git a/internal/db/migrations/0095_tracks_emptied_at.up.sql b/internal/db/migrations/0095_tracks_emptied_at.up.sql new file mode 100644 index 00000000..a4d0bac8 --- /dev/null +++ b/internal/db/migrations/0095_tracks_emptied_at.up.sql @@ -0,0 +1,13 @@ +-- 0095_tracks_emptied_at.up.sql — a slot the duplicate resolver emptied +-- waits for a copy fetched again (Scribe milestone #498). +-- +-- When the resolver removes a file filed under another song's title and the +-- album holds no copy of that song as long as the song is, the row is marked +-- missing and stamped here with the same time. Missing-pair folding then pairs +-- it by title only with a track added after that time, a copy fetched again, +-- and not with one already on the album: on 2026-10-20 an emptied "Open the +-- Gateway" was folded into a file tagged so that holds another song. +-- +-- The stamp counts only while it equals missing_since: a row whose file came +-- back and later went missing again is an ordinary missing row. +ALTER TABLE tracks ADD COLUMN emptied_at timestamptz; diff --git a/internal/db/queries/missing_pairs.sql b/internal/db/queries/missing_pairs.sql index ef8e3eb0..89479d22 100644 --- a/internal/db/queries/missing_pairs.sql +++ b/internal/db/queries/missing_pairs.sql @@ -25,6 +25,10 @@ -- A pair whose MBIDs are both set and differ is not a title match: the tags say -- they are different recordings. The caller folds a pair only when each side has -- exactly one candidate; anything else is ambiguous and left alone. +-- +-- A slot the duplicate resolver emptied (emptied_at, migration 0095) pairs by +-- title only with a track added since: the album held no copy of the song as +-- long as it is, so one already there under the title is another song. WITH grp AS ( SELECT id AS album_id, COALESCE(release_group_mbid, id::text) AS g FROM albums @@ -47,6 +51,7 @@ cand AS ( AND lower(p.title) = lower(m.title) WHERE m.missing_since IS NOT NULL AND (m.mbid IS NULL OR p.mbid IS NULL OR m.mbid = p.mbid) + AND (m.emptied_at IS DISTINCT FROM m.missing_since OR p.added_at > m.emptied_at) ), pairs AS ( -- A pair both rules found is one candidate, credited to the stronger rule. diff --git a/internal/db/queries/tracks.sql b/internal/db/queries/tracks.sql index e609adc2..5dc38e4d 100644 --- a/internal/db/queries/tracks.sql +++ b/internal/db/queries/tracks.sql @@ -226,6 +226,15 @@ UPDATE tracks SET mbid = NULL, mbid_source = NULL WHERE id = ANY(sqlc.arg(ids)::uuid[]); +-- name: MarkTracksEmptied :exec +-- Stamps rows the duplicate resolver emptied, whose song the album holds no +-- other copy of, with the time they went missing (migration 0095): missing-pair +-- folding then waits for a copy fetched again. +UPDATE tracks + SET emptied_at = missing_since + WHERE id = ANY(sqlc.arg(ids)::uuid[]) + AND missing_since IS NOT NULL; + -- name: ClearTracksMissing :execrows -- Clears the mark on rows whose file is back. Runs independently of the mtime -- skip check, so a file that reappears unchanged is un-marked even though the diff --git a/internal/library/duplicate_resolve_test.go b/internal/library/duplicate_resolve_test.go index 6eb44c72..58ccd0db 100644 --- a/internal/library/duplicate_resolve_test.go +++ b/internal/library/duplicate_resolve_test.go @@ -417,6 +417,54 @@ func TestResolveDuplicates_RemovesACopyFromAnotherSongsSlot_Integration(t *testi } } +// A slot emptied of another song's file folds into a copy of its own song the +// album already holds (Savant's VOID DLC "Weeper" beside Void's). A copy under +// the title that is not as long as the song is another song (the emptied "Open +// the Gateway" folded into a file holding Onward, 2026-10-20): the slot waits +// for a copy fetched again, and folds into that. +func TestResolveDuplicates_EmptiedSlotFoldsOnlyIntoItsOwnSong_Integration(t *testing.T) { + for _, tc := range []struct { + name string + heldMs int32 + foldsFirst int + }{ + {"the album holds Reach", 281500, 1}, + {"the album holds a file titled Reach that is not", 220000, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newOneAlbumFixture(t, "acoustic", "Void", "Reach") + ctx := context.Background() + q := dbq.New(f.pool) + seedTrackList(t, f.pool, f.album, map[string]int{"Void": 220500, "Reach": 281000}) + held := func(name string, ms int32) { + t.Helper() + if _, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ + Title: "Reach", AlbumID: f.album.ID, ArtistID: f.a.ArtistID, + DurationMs: ms, FilePath: filepath.Join(filepath.Dir(f.aPath), name), FileSize: 100, FileFormat: "m4a", + }); err != nil { + t.Fatal(err) + } + } + held("17 - Reach (other edition).m4a", tc.heldMs) + res, err := ResolveDuplicates(ctx, f.pool, nil, "", true) + if err != nil || res.WrongFiles != 1 { + t.Fatalf("resolve = %+v, %v; want Reach's slot emptied", res, err) + } + pairs, err := PairMissingTracks(ctx, f.pool, nil) + if err != nil || pairs.Folded() != tc.foldsFirst { + t.Fatalf("missing-pair pass = %+v, %v; want %d folded", pairs, err, tc.foldsFirst) + } + if tc.foldsFirst == 1 { + return + } + held("17 - Reach (fetched again).m4a", 281200) + if pairs, err := PairMissingTracks(ctx, f.pool, nil); err != nil || pairs.Folded() != 1 { + t.Fatalf("missing-pair pass after the fetch = %+v, %v; want the slot folded into the copy fetched", pairs, err) + } + }) + } +} + // One recording filling two slots whose songs are both clearly something else // (Shnabubula's whole album in one file): both copies go, nothing is kept. func TestResolveDuplicates_RemovesEveryCopyWhenNoSlotIsTheirs_Integration(t *testing.T) { diff --git a/internal/library/duplicate_settle.go b/internal/library/duplicate_settle.go index 0ca67436..b73ac791 100644 --- a/internal/library/duplicate_settle.go +++ b/internal/library/duplicate_settle.go @@ -238,7 +238,8 @@ func settleWrongSlots( // Set aside before the files go: a pass that dies between the two // still does not fetch the same files again. blocked := blockReleasesHolding(ctx, pool, logger, g) - n, err := removeWrongSlots(ctx, pool, logger, g, keep, losers, blocked) + emptied := emptiedSlots(ctx, q, logger, g, losers, tracks) + n, err := removeWrongSlots(ctx, pool, logger, g, keep, losers, emptied, blocked) removed += n if err != nil { logger.Warn("duplicate resolve: removing wrong-slot copies failed", "group_id", syncpkg.FormatUUID(g.id), "err", err) @@ -385,6 +386,46 @@ func blockReleasesHolding(ctx context.Context, pool *pgxpool.Pool, logger *slog. return names } +// emptiedSlots is the losers whose song the album holds no other copy of: +// no present track on it under that title as long as the song's slot. Those +// wait for a copy fetched again (migration 0095). The rest are the song held +// twice, and missing-pair folding folds them into the copy held: Savant's +// VOID DLC "Weeper" beside Void's. A failed read counts every loser as +// emptied, which only makes the album wait for a copy it may not need. +func emptiedSlots( + ctx context.Context, q *dbq.Queries, logger *slog.Logger, g *resolveGroup, losers []int, tracks []dbq.MbReleaseTrack, +) []pgtype.UUID { + held, err := q.ListTracksByAlbum(ctx, dbq.ListTracksByAlbumParams{AlbumID: g.members[0].AlbumID}) + if err != nil { + logger.Warn("duplicate resolve: reading the album's tracks failed", "group_id", syncpkg.FormatUUID(g.id), "err", err) + } + inGroup := map[pgtype.UUID]bool{} + for _, m := range g.members { + inGroup[m.TrackID] = true + } + var emptied []pgtype.UUID + for _, i := range losers { + m := g.members[i] + key := MatchTitleKey(m.Title, m.ArtistName) + covered := false + for _, h := range held { + if covered || h.MissingSince.Valid || inGroup[h.ID] || MatchTitleKey(h.Title, m.ArtistName) != key { + continue + } + for _, t := range tracks { + if t.LengthMs > 0 && MatchTitleKey(t.Title, m.ArtistName) == key && absMs(t.LengthMs-h.DurationMs) <= slotFit { + covered = true + break + } + } + } + if !covered { + emptied = append(emptied, m.TrackID) + } + } + return emptied +} + // neverRead reports whether the catalogue has never read a release group's // releases. A group read and found empty is not asked about again: the // catalogue's own refresh revisits it. @@ -397,12 +438,14 @@ func neverRead(ctx context.Context, q *dbq.Queries, rg string) bool { // the same transaction that closes the group. The row is kept, with its likes // and plays: it is the slot, and a good copy fetched again takes it over. Its // recording MBID is dropped, being the wrong file's, so missing-pair folding -// does not fold the slot into the copy that was kept. blocked names the -// releases set aside, for the record. A file that cannot be removed stops the -// group; the ones already gone are still marked, and the group waits for the -// next pass. +// does not fold the slot into the copy that was kept. The slots in emptied +// are stamped to wait for a copy fetched again (emptiedSlots). blocked names +// the releases set aside, for the record. A file that cannot be removed stops +// the group; the ones already gone are still marked, and the group waits for +// the next pass. func removeWrongSlots( - ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, g *resolveGroup, keep int, losers []int, blocked []string, + ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, g *resolveGroup, keep int, losers []int, + emptied []pgtype.UUID, blocked []string, ) (int, error) { var gone []pgtype.UUID var removedFiles []map[string]string @@ -429,6 +472,9 @@ func removeWrongSlots( if err := tq.ClearTrackMbids(ctx, gone); err != nil { return fmt.Errorf("clear mbids: %w", err) } + if err := tq.MarkTracksEmptied(ctx, emptied); err != nil { + return fmt.Errorf("mark emptied: %w", err) + } if err := logTrackChanges(ctx, tq, gone); err != nil { return fmt.Errorf("log changes: %w", err) }