diff --git a/internal/api/admin_requests.go b/internal/api/admin_requests.go index e8ffa0e4..04c6a118 100644 --- a/internal/api/admin_requests.go +++ b/internal/api/admin_requests.go @@ -9,6 +9,7 @@ import ( "github.com/go-chi/chi/v5" "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" ) @@ -55,9 +56,12 @@ func (h *handlers) handleListAdminRequests(w http.ResponseWriter, r *http.Reques return } + q := dbq.New(h.pool) out := make([]requestView, 0, len(rows)) for _, row := range rows { - out = append(out, requestViewFrom(row)) + v := requestViewFrom(row) + fillProgress(r.Context(), q, row, &v) + out = append(out, v) } writeJSON(w, http.StatusOK, out) } diff --git a/internal/api/requests.go b/internal/api/requests.go index 09d25e50..8c3e4e20 100644 --- a/internal/api/requests.go +++ b/internal/api/requests.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "errors" "net/http" @@ -38,6 +39,12 @@ type requestView struct { MatchedArtistID pgtype.UUID `json:"matched_artist_id,omitempty"` RequestedAt pgtype.Timestamptz `json:"requested_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + // Progress counters, computed at response time from the matched + // entity's children. 0 when nothing has been ingested yet (or when + // the request has no matched entity yet). For artist requests both + // counters fill; for album/track requests only ImportedTrackCount. + ImportedAlbumCount int `json:"imported_album_count"` + ImportedTrackCount int `json:"imported_track_count"` } func requestViewFrom(row dbq.LidarrRequest) requestView { @@ -66,6 +73,43 @@ func requestViewFrom(row dbq.LidarrRequest) requestView { } } +// fillProgress fills ImportedAlbumCount + ImportedTrackCount on a view by +// querying the count of children for the request's matched entity. +// +// kind=artist → counts all albums + tracks under the matched artist. +// kind=album → counts tracks under the matched album. +// kind=track → ImportedTrackCount = 1 once matched_track_id is set. +// +// N+1 against the request list: acceptable at admin scale (dozens of +// in-flight requests at most). Errors are swallowed — progress is a +// reporting nicety, not a correctness gate. +func fillProgress(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest, view *requestView) { + switch row.Kind { + case dbq.LidarrRequestKindArtist: + if !row.MatchedArtistID.Valid { + return + } + if n, err := q.CountAlbumsByArtist(ctx, row.MatchedArtistID); err == nil { + view.ImportedAlbumCount = int(n) + } + if n, err := q.CountTracksByArtist(ctx, row.MatchedArtistID); err == nil { + view.ImportedTrackCount = int(n) + } + case dbq.LidarrRequestKindAlbum: + if !row.MatchedAlbumID.Valid { + return + } + view.ImportedAlbumCount = 1 + if n, err := q.CountTracksByAlbum(ctx, row.MatchedAlbumID); err == nil { + view.ImportedTrackCount = int(n) + } + case dbq.LidarrRequestKindTrack: + if row.MatchedTrackID.Valid { + view.ImportedTrackCount = 1 + } + } +} + // createRequestBody is the decoded JSON for POST /api/requests. type createRequestBody struct { Kind string `json:"kind"` @@ -128,9 +172,12 @@ func (h *handlers) handleListRequests(w http.ResponseWriter, r *http.Request) { return } + q := dbq.New(h.pool) out := make([]requestView, 0, len(rows)) for _, row := range rows { - out = append(out, requestViewFrom(row)) + v := requestViewFrom(row) + fillProgress(r.Context(), q, row, &v) + out = append(out, v) } writeJSON(w, http.StatusOK, out) } @@ -167,7 +214,9 @@ func (h *handlers) handleGetRequest(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusOK, requestViewFrom(row)) + v := requestViewFrom(row) + fillProgress(r.Context(), dbq.New(h.pool), row, &v) + writeJSON(w, http.StatusOK, v) } // handleCancelRequest implements DELETE /api/requests/:id. diff --git a/internal/db/dbq/albums.sql.go b/internal/db/dbq/albums.sql.go index e9c47ef1..d4d9b6a2 100644 --- a/internal/db/dbq/albums.sql.go +++ b/internal/db/dbq/albums.sql.go @@ -23,6 +23,19 @@ func (q *Queries) CountAlbums(ctx context.Context) (int64, error) { return count, err } +const countAlbumsByArtist = `-- name: CountAlbumsByArtist :one +SELECT COUNT(*) FROM albums WHERE artist_id = $1 +` + +// Used by request-progress reporting to count how many albums have been +// ingested for a matched artist while a request is still in flight. +func (q *Queries) CountAlbumsByArtist(ctx context.Context, artistID pgtype.UUID) (int64, error) { + row := q.db.QueryRow(ctx, countAlbumsByArtist, artistID) + var count int64 + err := row.Scan(&count) + return count, err +} + const countAlbumsMatching = `-- name: CountAlbumsMatching :one SELECT COUNT(*) FROM albums WHERE title ILIKE '%' || $1::text || '%' ` diff --git a/internal/db/dbq/tracks.sql.go b/internal/db/dbq/tracks.sql.go index c2440931..88eec5c8 100644 --- a/internal/db/dbq/tracks.sql.go +++ b/internal/db/dbq/tracks.sql.go @@ -22,6 +22,20 @@ func (q *Queries) CountTracksByAlbum(ctx context.Context, albumID pgtype.UUID) ( return count, err } +const countTracksByArtist = `-- name: CountTracksByArtist :one +SELECT COUNT(*) FROM tracks WHERE artist_id = $1 +` + +// Used by request-progress reporting to count tracks ingested under a +// matched artist (sum across all the artist's albums) while a request +// is still in flight. +func (q *Queries) CountTracksByArtist(ctx context.Context, artistID pgtype.UUID) (int64, error) { + row := q.db.QueryRow(ctx, countTracksByArtist, artistID) + var count int64 + err := row.Scan(&count) + return count, err +} + const countTracksMatching = `-- name: CountTracksMatching :one SELECT COUNT(*) FROM tracks WHERE title ILIKE '%' || $1::text || '%' ` diff --git a/internal/db/queries/albums.sql b/internal/db/queries/albums.sql index 10f9eb8c..1130d15e 100644 --- a/internal/db/queries/albums.sql +++ b/internal/db/queries/albums.sql @@ -80,3 +80,8 @@ LIMIT $1 OFFSET $2; -- name: CountAlbums :one -- M6a: total album count for the /api/library/albums envelope. SELECT COUNT(*) FROM albums; + +-- name: CountAlbumsByArtist :one +-- 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; diff --git a/internal/db/queries/tracks.sql b/internal/db/queries/tracks.sql index e2bf4251..492257dc 100644 --- a/internal/db/queries/tracks.sql +++ b/internal/db/queries/tracks.sql @@ -90,3 +90,9 @@ WHERE t.artist_id = $1 ) ORDER BY albums.release_date NULLS LAST, albums.sort_title, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.id; + +-- name: CountTracksByArtist :one +-- Used by request-progress reporting to count tracks ingested under a +-- matched artist (sum across all the artist's albums) while a request +-- is still in flight. +SELECT COUNT(*) FROM tracks WHERE artist_id = $1; diff --git a/web/src/lib/api/admin.test.ts b/web/src/lib/api/admin.test.ts index 2654ed42..3c919af1 100644 --- a/web/src/lib/api/admin.test.ts +++ b/web/src/lib/api/admin.test.ts @@ -66,7 +66,9 @@ const baseRow: LidarrRequest = { matched_album_id: null, matched_artist_id: null, requested_at: '2026-01-01T00:00:00Z', - updated_at: '2026-01-01T00:00:00Z' + updated_at: '2026-01-01T00:00:00Z', + imported_album_count: 0, + imported_track_count: 0 }; beforeEach(() => { diff --git a/web/src/lib/api/requests.test.ts b/web/src/lib/api/requests.test.ts index b63fb5c9..94db17fd 100644 --- a/web/src/lib/api/requests.test.ts +++ b/web/src/lib/api/requests.test.ts @@ -41,7 +41,9 @@ const mockRow: LidarrRequest = { matched_album_id: null, matched_artist_id: null, requested_at: '2026-01-01T00:00:00Z', - updated_at: '2026-01-01T00:00:00Z' + updated_at: '2026-01-01T00:00:00Z', + imported_album_count: 0, + imported_track_count: 0 }; beforeEach(() => { diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index f863c76d..db76065b 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -123,6 +123,11 @@ export type LidarrRequest = { matched_artist_id?: string | null; requested_at: string; updated_at: string; + // Live ingest counters. Server fills these from the matched entity's + // children. Useful for in-flight requests so the operator can see + // "5 albums · 47 tracks" while Lidarr is still working on the rest. + imported_album_count: number; + imported_track_count: number; }; export type LidarrConfig = { diff --git a/web/src/routes/admin/requests/+page.svelte b/web/src/routes/admin/requests/+page.svelte index 810af4fb..1acd5b37 100644 --- a/web/src/routes/admin/requests/+page.svelte +++ b/web/src/routes/admin/requests/+page.svelte @@ -256,6 +256,18 @@
{rowMeta(r)}
+ {#if r.imported_album_count > 0 || r.imported_track_count > 0} +
+ {#if r.kind === 'artist'} + {r.imported_album_count} {r.imported_album_count === 1 ? 'album' : 'albums'} + · {r.imported_track_count} {r.imported_track_count === 1 ? 'track' : 'tracks'} ingested + {:else if r.kind === 'album'} + {r.imported_track_count} {r.imported_track_count === 1 ? 'track' : 'tracks'} ingested + {:else} + Track ingested + {/if} +
+ {/if} {#if r.kind === 'track' && r.album_title}
Approving will add the album {r.album_title}. diff --git a/web/src/routes/admin/requests/requests.test.ts b/web/src/routes/admin/requests/requests.test.ts index 799d1b0c..a0a3add1 100644 --- a/web/src/routes/admin/requests/requests.test.ts +++ b/web/src/routes/admin/requests/requests.test.ts @@ -37,7 +37,9 @@ const baseRow: LidarrRequest = { artist_name: 'Boards of Canada', album_title: 'Geogaddi', requested_at: '2026-04-29T10:00:00Z', - updated_at: '2026-04-29T10:00:00Z' + updated_at: '2026-04-29T10:00:00Z', + imported_album_count: 0, + imported_track_count: 0 }; afterEach(() => vi.clearAllMocks()); diff --git a/web/src/routes/requests/+page.svelte b/web/src/routes/requests/+page.svelte index bfb108e2..4b373c09 100644 --- a/web/src/routes/requests/+page.svelte +++ b/web/src/routes/requests/+page.svelte @@ -103,6 +103,18 @@
{rowMeta(r)}
+ {#if r.imported_album_count > 0 || r.imported_track_count > 0} +
+ {#if r.kind === 'artist'} + {r.imported_album_count} {r.imported_album_count === 1 ? 'album' : 'albums'} + · {r.imported_track_count} {r.imported_track_count === 1 ? 'track' : 'tracks'} ingested + {:else if r.kind === 'album'} + {r.imported_track_count} {r.imported_track_count === 1 ? 'track' : 'tracks'} ingested + {:else} + Track ingested + {/if} +
+ {/if} {#if r.status === 'rejected' && r.notes}
{r.notes} diff --git a/web/src/routes/requests/requests.test.ts b/web/src/routes/requests/requests.test.ts index 097fd9e9..88a1ea86 100644 --- a/web/src/routes/requests/requests.test.ts +++ b/web/src/routes/requests/requests.test.ts @@ -60,6 +60,8 @@ function req(over: Partial = {}): LidarrRequest { matched_artist_id: null, requested_at: '2026-04-28T12:00:00Z', updated_at: '2026-04-28T12:00:00Z', + imported_album_count: 0, + imported_track_count: 0, ...over }; }