diff --git a/internal/api/library.go b/internal/api/library.go index a6e0f219..7ab669cd 100644 --- a/internal/api/library.go +++ b/internal/api/library.go @@ -6,6 +6,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/auth" @@ -53,14 +54,13 @@ func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) { writeErr(w, apierror.InternalMsg("lookup failed", err)) return } - var tracks []dbq.Track + var userID pgtype.UUID if user, ok := auth.UserFromContext(r.Context()); ok { - tracks, err = q.ListTracksByAlbumForUser(r.Context(), dbq.ListTracksByAlbumForUserParams{ - AlbumID: album.ID, UserID: user.ID, - }) - } else { - tracks, err = q.ListTracksByAlbum(r.Context(), album.ID) + userID = user.ID } + tracks, err := q.ListTracksByAlbum(r.Context(), dbq.ListTracksByAlbumParams{ + AlbumID: album.ID, UserID: userID, + }) if err != nil { h.logger.Error("api: list tracks failed", "err", err) writeErr(w, apierror.InternalMsg("lookup failed", err)) diff --git a/internal/api/media.go b/internal/api/media.go index b5583e10..1e0e7d8e 100644 --- a/internal/api/media.go +++ b/internal/api/media.go @@ -28,7 +28,7 @@ func resolveAlbumCoverPath(ctx context.Context, q *dbq.Queries, album dbq.Album) return *album.CoverArtPath } } - tracks, err := q.ListTracksByAlbum(ctx, album.ID) + tracks, err := q.ListTracksByAlbum(ctx, dbq.ListTracksByAlbumParams{AlbumID: album.ID}) if err != nil || len(tracks) == 0 { return "" } diff --git a/internal/api/search.go b/internal/api/search.go index efba9ae8..c2c5f73e 100644 --- a/internal/api/search.go +++ b/internal/api/search.go @@ -76,29 +76,23 @@ func (h *handlers) handleSearch(w http.ResponseWriter, r *http.Request) { return } - var tracks []dbq.Track + // userID is NULL for unauthenticated callers; the unified queries + // skip the quarantine filter when user_id IS NULL. + var userID pgtype.UUID if user, ok := auth.UserFromContext(r.Context()); ok { - tracks, err = dbQ.SearchTracksForUser(r.Context(), dbq.SearchTracksForUserParams{ - Column1: needle, UserID: user.ID, Limit: int32(limit), Offset: int32(offset), - }) - } else { - tracks, err = dbQ.SearchTracks(r.Context(), dbq.SearchTracksParams{ - Column1: needle, Limit: int32(limit), Offset: int32(offset), - }) + userID = user.ID } + tracks, err := dbQ.SearchTracks(r.Context(), dbq.SearchTracksParams{ + Column1: needle, UserID: userID, Limit: int32(limit), Offset: int32(offset), + }) if err != nil { h.logger.Error("api: search tracks failed", "err", err) writeErr(w, apierror.InternalMsg("search failed", err)) return } - var trackTotal int64 - if user, ok := auth.UserFromContext(r.Context()); ok { - trackTotal, err = dbQ.CountTracksMatchingForUser(r.Context(), dbq.CountTracksMatchingForUserParams{ - Column1: q, UserID: user.ID, - }) - } else { - trackTotal, err = dbQ.CountTracksMatching(r.Context(), q) - } + trackTotal, err := dbQ.CountTracksMatching(r.Context(), dbq.CountTracksMatchingParams{ + Column1: q, UserID: userID, + }) if err != nil { h.logger.Error("api: count tracks matching failed", "err", err) writeErr(w, apierror.InternalMsg("search failed", err)) diff --git a/internal/db/dbq/tracks.sql.go b/internal/db/dbq/tracks.sql.go index 2c3c7ebf..cef03a9f 100644 --- a/internal/db/dbq/tracks.sql.go +++ b/internal/db/dbq/tracks.sql.go @@ -37,17 +37,6 @@ func (q *Queries) CountTracksByArtist(ctx context.Context, artistID pgtype.UUID) } const countTracksMatching = `-- name: CountTracksMatching :one -SELECT COUNT(*) FROM tracks WHERE title ILIKE '%' || $1::text || '%' -` - -func (q *Queries) CountTracksMatching(ctx context.Context, dollar_1 string) (int64, error) { - row := q.db.QueryRow(ctx, countTracksMatching, dollar_1) - var count int64 - err := row.Scan(&count) - return count, err -} - -const countTracksMatchingForUser = `-- name: CountTracksMatchingForUser :one SELECT COUNT(*) FROM tracks WHERE title ILIKE '%' || $1::text || '%' AND NOT EXISTS ( @@ -56,14 +45,14 @@ WHERE title ILIKE '%' || $1::text || '%' ) ` -type CountTracksMatchingForUserParams struct { +type CountTracksMatchingParams struct { Column1 string UserID pgtype.UUID } -// $1 = title query, $2 = user_id. -func (q *Queries) CountTracksMatchingForUser(ctx context.Context, arg CountTracksMatchingForUserParams) (int64, error) { - row := q.db.QueryRow(ctx, countTracksMatchingForUser, arg.Column1, arg.UserID) +// $1 = title query, $2 = user_id (NULL to skip quarantine filter). +func (q *Queries) CountTracksMatching(ctx context.Context, arg CountTracksMatchingParams) (int64, error) { + row := q.db.QueryRow(ctx, countTracksMatching, arg.Column1, arg.UserID) var count int64 err := row.Scan(&count) return count, err @@ -225,46 +214,6 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra } 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 FROM tracks WHERE album_id = $1 ORDER BY disc_number NULLS LAST, track_number NULLS LAST -` - -func (q *Queries) ListTracksByAlbum(ctx context.Context, albumID pgtype.UUID) ([]Track, error) { - rows, err := q.db.Query(ctx, listTracksByAlbum, albumID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Track - for rows.Next() { - var i Track - if err := rows.Scan( - &i.ID, - &i.Title, - &i.AlbumID, - &i.ArtistID, - &i.TrackNumber, - &i.DiscNumber, - &i.DurationMs, - &i.FilePath, - &i.FileSize, - &i.FileFormat, - &i.Bitrate, - &i.Mbid, - &i.Genre, - &i.AddedAt, - &i.UpdatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listTracksByAlbumForUser = `-- name: ListTracksByAlbumForUser :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 FROM tracks WHERE album_id = $1 AND NOT EXISTS ( @@ -274,15 +223,16 @@ WHERE album_id = $1 ORDER BY disc_number NULLS LAST, track_number NULLS LAST ` -type ListTracksByAlbumForUserParams struct { +type ListTracksByAlbumParams struct { AlbumID pgtype.UUID UserID pgtype.UUID } -// Same as ListTracksByAlbum but excludes tracks the user has quarantined. -// $1 = album_id, $2 = user_id. -func (q *Queries) ListTracksByAlbumForUser(ctx context.Context, arg ListTracksByAlbumForUserParams) ([]Track, error) { - rows, err := q.db.Query(ctx, listTracksByAlbumForUser, arg.AlbumID, arg.UserID) +// $1 = album_id, $2 = user_id. Pass pgtype.UUID{Valid: false} (NULL) +// to skip the per-user quarantine filter; the NOT EXISTS clause on +// a NULL user_id never matches a row, so every track passes through. +func (q *Queries) ListTracksByAlbum(ctx context.Context, arg ListTracksByAlbumParams) ([]Track, error) { + rows, err := q.db.Query(ctx, listTracksByAlbum, arg.AlbumID, arg.UserID) if err != nil { return nil, err } @@ -319,56 +269,7 @@ func (q *Queries) ListTracksByAlbumForUser(ctx context.Context, arg ListTracksBy 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 FROM tracks -WHERE title ILIKE '%' || $1 || '%' -ORDER BY title -LIMIT $2 OFFSET $3 -` - -type SearchTracksParams struct { - Column1 *string - Limit int32 - Offset int32 -} - -func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]Track, error) { - rows, err := q.db.Query(ctx, searchTracks, arg.Column1, arg.Limit, arg.Offset) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Track - for rows.Next() { - var i Track - if err := rows.Scan( - &i.ID, - &i.Title, - &i.AlbumID, - &i.ArtistID, - &i.TrackNumber, - &i.DiscNumber, - &i.DurationMs, - &i.FilePath, - &i.FileSize, - &i.FileFormat, - &i.Bitrate, - &i.Mbid, - &i.Genre, - &i.AddedAt, - &i.UpdatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const searchTracksForUser = `-- name: SearchTracksForUser :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 FROM tracks -WHERE title ILIKE '%' || $1 || '%' +WHERE title ILIKE '%' || $1::text || '%' AND NOT EXISTS ( SELECT 1 FROM lidarr_quarantine q WHERE q.user_id = $2 AND q.track_id = tracks.id @@ -377,16 +278,17 @@ ORDER BY title LIMIT $3 OFFSET $4 ` -type SearchTracksForUserParams struct { - Column1 *string +type SearchTracksParams struct { + Column1 string UserID pgtype.UUID Limit int32 Offset int32 } -// $1 = title query, $2 = user_id, $3 = limit, $4 = offset. -func (q *Queries) SearchTracksForUser(ctx context.Context, arg SearchTracksForUserParams) ([]Track, error) { - rows, err := q.db.Query(ctx, searchTracksForUser, +// $1 = title query, $2 = user_id (NULL to skip quarantine filter), +// $3 = limit, $4 = offset. +func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]Track, error) { + rows, err := q.db.Query(ctx, searchTracks, arg.Column1, arg.UserID, arg.Limit, diff --git a/internal/db/queries/tracks.sql b/internal/db/queries/tracks.sql index 7934b390..9168ceb2 100644 --- a/internal/db/queries/tracks.sql +++ b/internal/db/queries/tracks.sql @@ -26,23 +26,9 @@ SELECT * FROM tracks WHERE id = $1; SELECT * FROM tracks WHERE file_path = $1; -- name: ListTracksByAlbum :many -SELECT * FROM tracks WHERE album_id = $1 ORDER BY disc_number NULLS LAST, track_number NULLS LAST; - --- name: CountTracksByAlbum :one -SELECT count(*) FROM tracks WHERE album_id = $1; - --- name: SearchTracks :many -SELECT * FROM tracks -WHERE title ILIKE '%' || $1 || '%' -ORDER BY title -LIMIT $2 OFFSET $3; - --- name: CountTracksMatching :one -SELECT COUNT(*) FROM tracks WHERE title ILIKE '%' || $1::text || '%'; - --- name: ListTracksByAlbumForUser :many --- Same as ListTracksByAlbum but excludes tracks the user has quarantined. --- $1 = album_id, $2 = user_id. +-- $1 = album_id, $2 = user_id. Pass pgtype.UUID{Valid: false} (NULL) +-- to skip the per-user quarantine filter; the NOT EXISTS clause on +-- a NULL user_id never matches a row, so every track passes through. SELECT * FROM tracks WHERE album_id = $1 AND NOT EXISTS ( @@ -51,10 +37,14 @@ WHERE album_id = $1 ) ORDER BY disc_number NULLS LAST, track_number NULLS LAST; --- name: SearchTracksForUser :many --- $1 = title query, $2 = user_id, $3 = limit, $4 = offset. +-- name: CountTracksByAlbum :one +SELECT count(*) FROM tracks WHERE album_id = $1; + +-- name: SearchTracks :many +-- $1 = title query, $2 = user_id (NULL to skip quarantine filter), +-- $3 = limit, $4 = offset. SELECT * FROM tracks -WHERE title ILIKE '%' || $1 || '%' +WHERE title ILIKE '%' || $1::text || '%' AND NOT EXISTS ( SELECT 1 FROM lidarr_quarantine q WHERE q.user_id = $2 AND q.track_id = tracks.id @@ -62,8 +52,8 @@ WHERE title ILIKE '%' || $1 || '%' ORDER BY title LIMIT $3 OFFSET $4; --- name: CountTracksMatchingForUser :one --- $1 = title query, $2 = user_id. +-- name: CountTracksMatching :one +-- $1 = title query, $2 = user_id (NULL to skip quarantine filter). SELECT COUNT(*) FROM tracks WHERE title ILIKE '%' || $1::text || '%' AND NOT EXISTS ( diff --git a/internal/subsonic/browse.go b/internal/subsonic/browse.go index 9405bd65..44cec8bb 100644 --- a/internal/subsonic/browse.go +++ b/internal/subsonic/browse.go @@ -180,7 +180,7 @@ func (b *browseHandlers) getAlbum(w http.ResponseWriter, r *http.Request) { WriteFail(w, r, ErrGeneric, "Failed to load album artist") return } - tracks, err := q.ListTracksByAlbum(r.Context(), id) + tracks, err := q.ListTracksByAlbum(r.Context(), dbq.ListTracksByAlbumParams{AlbumID: id}) if err != nil { WriteFail(w, r, ErrGeneric, "Failed to load tracks") return @@ -377,7 +377,7 @@ func (b *browseHandlers) search3(w http.ResponseWriter, r *http.Request) { if songCount > 0 { tracks, err := q.SearchTracks(r.Context(), dbq.SearchTracksParams{ Column1: needle, Limit: int32(songCount), Offset: int32(songOffset), - }) + }) // UserID left zero (NULL) — Subsonic has no per-user quarantine context if err != nil { WriteFail(w, r, ErrGeneric, "Song search failed") return diff --git a/internal/subsonic/stream.go b/internal/subsonic/stream.go index 3a62b278..c63231b8 100644 --- a/internal/subsonic/stream.go +++ b/internal/subsonic/stream.go @@ -140,7 +140,7 @@ func resolveAlbumCoverPath(ctx context.Context, q *dbq.Queries, album dbq.Album) return *album.CoverArtPath } } - tracks, err := q.ListTracksByAlbum(ctx, album.ID) + tracks, err := q.ListTracksByAlbum(ctx, dbq.ListTracksByAlbumParams{AlbumID: album.ID}) if err != nil || len(tracks) == 0 { return "" }