diff --git a/internal/db/dbq/albums.sql.go b/internal/db/dbq/albums.sql.go index d4d9b6a2..152a5842 100644 --- a/internal/db/dbq/albums.sql.go +++ b/internal/db/dbq/albums.sql.go @@ -47,6 +47,30 @@ func (q *Queries) CountAlbumsMatching(ctx context.Context, dollar_1 string) (int return count, err } +const deleteAlbumIfEmpty = `-- name: DeleteAlbumIfEmpty :one +DELETE FROM albums a +WHERE a.id = $1 + AND NOT EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id) +RETURNING a.id, a.artist_id +` + +type DeleteAlbumIfEmptyRow struct { + ID pgtype.UUID + ArtistID pgtype.UUID +} + +// M7 #372: deletes the album row only when it has no remaining tracks. +// Used after a track delete to tidy up orphaned albums. RETURNING gives +// us the artist_id so the service can chain into the artist-empty check. +// Returns no rows when the album still has tracks; the service treats +// pgx.ErrNoRows as "album not orphaned, skip cascade". +func (q *Queries) DeleteAlbumIfEmpty(ctx context.Context, id pgtype.UUID) (DeleteAlbumIfEmptyRow, error) { + row := q.db.QueryRow(ctx, deleteAlbumIfEmpty, id) + var i DeleteAlbumIfEmptyRow + err := row.Scan(&i.ID, &i.ArtistID) + return i, err +} + const getAlbumByArtistAndTitle = `-- name: GetAlbumByArtistAndTitle :one SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at FROM albums WHERE artist_id = $1 AND title = $2 LIMIT 1 ` diff --git a/internal/db/dbq/artists.sql.go b/internal/db/dbq/artists.sql.go index 4fcd8172..98e6a5df 100644 --- a/internal/db/dbq/artists.sql.go +++ b/internal/db/dbq/artists.sql.go @@ -33,6 +33,24 @@ func (q *Queries) CountArtistsMatching(ctx context.Context, dollar_1 string) (in return count, err } +const deleteArtistIfEmpty = `-- name: DeleteArtistIfEmpty :one +DELETE FROM artists a +WHERE a.id = $1 + AND NOT EXISTS (SELECT 1 FROM albums al WHERE al.artist_id = a.id) + AND NOT EXISTS (SELECT 1 FROM tracks t WHERE t.artist_id = a.id) +RETURNING a.id +` + +// M7 #372: deletes the artist row only when it has no remaining albums +// AND no remaining tracks (defensive — tracks can in principle exist +// without an album, though the scanner doesn't write that shape today). +func (q *Queries) DeleteArtistIfEmpty(ctx context.Context, id pgtype.UUID) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, deleteArtistIfEmpty, id) + var id_2 pgtype.UUID + err := row.Scan(&id_2) + return id_2, err +} + const getArtistByID = `-- name: GetArtistByID :one SELECT id, name, sort_name, mbid, created_at, updated_at FROM artists WHERE id = $1 ` diff --git a/internal/db/dbq/tracks.sql.go b/internal/db/dbq/tracks.sql.go index 88eec5c8..2c3c7ebf 100644 --- a/internal/db/dbq/tracks.sql.go +++ b/internal/db/dbq/tracks.sql.go @@ -69,6 +69,37 @@ func (q *Queries) CountTracksMatchingForUser(ctx context.Context, arg CountTrack return count, err } +const deleteTrack = `-- name: DeleteTrack :one +DELETE FROM tracks WHERE id = $1 +RETURNING id, album_id, artist_id, file_path, mbid +` + +type DeleteTrackRow struct { + ID pgtype.UUID + AlbumID pgtype.UUID + ArtistID pgtype.UUID + FilePath string + Mbid *string +} + +// M7 #372: hard delete with FK cascade. The CASCADE on track_id from +// play_events / general_likes_tracks / lidarr_quarantine / +// lidarr_quarantine_actions handles their cleanup. RETURNING gives us +// album_id + artist_id for the album-empty / artist-empty cascade +// checks the service does next. +func (q *Queries) DeleteTrack(ctx context.Context, id pgtype.UUID) (DeleteTrackRow, error) { + row := q.db.QueryRow(ctx, deleteTrack, id) + var i DeleteTrackRow + err := row.Scan( + &i.ID, + &i.AlbumID, + &i.ArtistID, + &i.FilePath, + &i.Mbid, + ) + return i, err +} + 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 FROM tracks WHERE id = $1 ` diff --git a/internal/db/queries/albums.sql b/internal/db/queries/albums.sql index 1130d15e..68c4bed4 100644 --- a/internal/db/queries/albums.sql +++ b/internal/db/queries/albums.sql @@ -85,3 +85,14 @@ SELECT COUNT(*) FROM albums; -- Used by request-progress reporting to count how many albums have been -- ingested for a matched artist while a request is still in flight. SELECT COUNT(*) FROM albums WHERE artist_id = $1; + +-- name: DeleteAlbumIfEmpty :one +-- M7 #372: deletes the album row only when it has no remaining tracks. +-- Used after a track delete to tidy up orphaned albums. RETURNING gives +-- us the artist_id so the service can chain into the artist-empty check. +-- Returns no rows when the album still has tracks; the service treats +-- pgx.ErrNoRows as "album not orphaned, skip cascade". +DELETE FROM albums a +WHERE a.id = $1 + AND NOT EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id) +RETURNING a.id, a.artist_id; diff --git a/internal/db/queries/artists.sql b/internal/db/queries/artists.sql index 6d2bf4f9..a9cf20c5 100644 --- a/internal/db/queries/artists.sql +++ b/internal/db/queries/artists.sql @@ -64,3 +64,13 @@ LEFT JOIN LATERAL ( ) cnt ON true ORDER BY artists.sort_name, artists.name, artists.id LIMIT $1 OFFSET $2; + +-- name: DeleteArtistIfEmpty :one +-- M7 #372: deletes the artist row only when it has no remaining albums +-- AND no remaining tracks (defensive — tracks can in principle exist +-- without an album, though the scanner doesn't write that shape today). +DELETE FROM artists a +WHERE a.id = $1 + AND NOT EXISTS (SELECT 1 FROM albums al WHERE al.artist_id = a.id) + AND NOT EXISTS (SELECT 1 FROM tracks t WHERE t.artist_id = a.id) +RETURNING a.id; diff --git a/internal/db/queries/tracks.sql b/internal/db/queries/tracks.sql index 492257dc..7934b390 100644 --- a/internal/db/queries/tracks.sql +++ b/internal/db/queries/tracks.sql @@ -96,3 +96,12 @@ ORDER BY albums.release_date NULLS LAST, albums.sort_title, -- matched artist (sum across all the artist's albums) while a request -- is still in flight. SELECT COUNT(*) FROM tracks WHERE artist_id = $1; + +-- name: DeleteTrack :one +-- M7 #372: hard delete with FK cascade. The CASCADE on track_id from +-- play_events / general_likes_tracks / lidarr_quarantine / +-- lidarr_quarantine_actions handles their cleanup. RETURNING gives us +-- album_id + artist_id for the album-empty / artist-empty cascade +-- checks the service does next. +DELETE FROM tracks WHERE id = $1 +RETURNING id, album_id, artist_id, file_path, mbid;