package database import ( "context" "encoding/json" "regexp" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) type DB struct { pool *pgxpool.Pool } func New(ctx context.Context, databaseURL string) (*DB, error) { pool, err := pgxpool.New(ctx, databaseURL) if err != nil { return nil, err } if err := pool.Ping(ctx); err != nil { return nil, err } return &DB{pool: pool}, nil } func (db *DB) Close() { db.pool.Close() } type Artist struct { ID string Name string SortName string ArtistType string Description string Genres []Genre ExternalIDs []ExternalID } type Album struct { ID string Title string AlbumType string ReleaseDate string Genres []Genre } type Genre struct { ID string `json:"id"` Name string `json:"name"` } type ExternalID struct { Source string `json:"source"` SourceID string `json:"source_id"` URL string `json:"url"` } type ArtistMetadataRow struct { ID uuid.UUID `json:"id"` ForeignArtistID *string `json:"foreign_artist_id"` Name string `json:"name"` SortName *string `json:"sort_name"` ArtistType *string `json:"artist_type"` Genres json.RawMessage `json:"genres"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type AlbumRow struct { ID uuid.UUID `json:"id"` ArtistMetadataID uuid.UUID `json:"artist_metadata_id"` ForeignAlbumID *string `json:"foreign_album_id"` Title string `json:"title"` AlbumType *string `json:"album_type"` ReleaseDate *time.Time `json:"release_date"` Monitored bool `json:"monitored"` AddedAt time.Time `json:"added_at"` } type AlbumWithArtistRow struct { ID uuid.UUID `json:"id"` ForeignAlbumID *string `json:"foreign_album_id"` Title string `json:"title"` AlbumType *string `json:"album_type"` ReleaseDate *time.Time `json:"release_date"` Monitored bool `json:"monitored"` AddedAt time.Time `json:"added_at"` ArtistID uuid.UUID `json:"artist_id"` ArtistName string `json:"artist_name"` } func (db *DB) UpsertArtistMetadata(ctx context.Context, artist *Artist) (uuid.UUID, error) { id, err := uuid.Parse(artist.ID) if err != nil { id = uuid.New() } genres, _ := json.Marshal(artist.Genres) links, _ := json.Marshal(artist.ExternalIDs) var resultID uuid.UUID err = db.pool.QueryRow(ctx, ` INSERT INTO artist_metadata ( id, foreign_artist_id, name, sort_name, disambiguation, artist_type, status, overview, genres, links, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW()) ON CONFLICT (foreign_artist_id) DO UPDATE SET name = EXCLUDED.name, sort_name = EXCLUDED.sort_name, artist_type = EXCLUDED.artist_type, overview = EXCLUDED.overview, genres = EXCLUDED.genres, links = EXCLUDED.links, updated_at = NOW() RETURNING id `, id, artist.ID, artist.Name, artist.SortName, artist.Description, artist.ArtistType, "active", artist.Description, genres, links).Scan(&resultID) return resultID, err } var cleanTitleRegex = regexp.MustCompile(`[^a-z0-9]`) func (db *DB) UpsertAlbum(ctx context.Context, album *Album, artistMetadataID uuid.UUID) (uuid.UUID, error) { id, err := uuid.Parse(album.ID) if err != nil { id = uuid.New() } genres, _ := json.Marshal(album.Genres) images, _ := json.Marshal([]any{}) var releaseDate *time.Time if album.ReleaseDate != "" { if t, err := time.Parse("2006-01-02", album.ReleaseDate); err == nil { releaseDate = &t } } cleanTitle := cleanTitleRegex.ReplaceAllString(strings.ToLower(album.Title), "") var resultID uuid.UUID err = db.pool.QueryRow(ctx, ` INSERT INTO albums ( id, artist_metadata_id, foreign_album_id, title, clean_title, overview, album_type, release_date, images, genres ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (foreign_album_id) DO UPDATE SET title = EXCLUDED.title, album_type = EXCLUDED.album_type, release_date = EXCLUDED.release_date, genres = EXCLUDED.genres RETURNING id `, id, artistMetadataID, album.ID, album.Title, cleanTitle, "", album.AlbumType, releaseDate, images, genres).Scan(&resultID) return resultID, err } func (db *DB) ListArtists(ctx context.Context) ([]ArtistMetadataRow, error) { rows, err := db.pool.Query(ctx, ` SELECT id, foreign_artist_id, name, sort_name, artist_type, genres, created_at, updated_at FROM artist_metadata ORDER BY name `) if err != nil { return nil, err } defer rows.Close() var artists []ArtistMetadataRow for rows.Next() { var a ArtistMetadataRow err := rows.Scan(&a.ID, &a.ForeignArtistID, &a.Name, &a.SortName, &a.ArtistType, &a.Genres, &a.CreatedAt, &a.UpdatedAt) if err != nil { return nil, err } artists = append(artists, a) } return artists, nil } func (db *DB) ListAlbumsByArtist(ctx context.Context, artistMetadataID uuid.UUID) ([]AlbumRow, error) { rows, err := db.pool.Query(ctx, ` SELECT id, artist_metadata_id, foreign_album_id, title, album_type, release_date, monitored, added_at FROM albums WHERE artist_metadata_id = $1 ORDER BY release_date DESC NULLS LAST `, artistMetadataID) if err != nil { return nil, err } defer rows.Close() var albums []AlbumRow for rows.Next() { var a AlbumRow err := rows.Scan(&a.ID, &a.ArtistMetadataID, &a.ForeignAlbumID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.Monitored, &a.AddedAt) if err != nil { return nil, err } albums = append(albums, a) } return albums, nil } func (db *DB) ListAllAlbums(ctx context.Context) ([]AlbumWithArtistRow, error) { rows, err := db.pool.Query(ctx, ` SELECT a.id, a.foreign_album_id, a.title, a.album_type, a.release_date, a.monitored, a.added_at, am.id as artist_id, am.name as artist_name FROM albums a JOIN artist_metadata am ON a.artist_metadata_id = am.id ORDER BY a.added_at DESC `) if err != nil { return nil, err } defer rows.Close() var albums []AlbumWithArtistRow for rows.Next() { var a AlbumWithArtistRow err := rows.Scan(&a.ID, &a.ForeignAlbumID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.Monitored, &a.AddedAt, &a.ArtistID, &a.ArtistName) if err != nil { return nil, err } albums = append(albums, a) } return albums, nil } func (db *DB) CountArtists(ctx context.Context) (int64, error) { var count int64 err := db.pool.QueryRow(ctx, "SELECT COUNT(*) FROM artist_metadata").Scan(&count) return count, err } func (db *DB) CountAlbums(ctx context.Context) (int64, error) { var count int64 err := db.pool.QueryRow(ctx, "SELECT COUNT(*) FROM albums").Scan(&count) return count, err } func (db *DB) CountAlbumsByArtist(ctx context.Context, artistMetadataID uuid.UUID) (int64, error) { var count int64 err := db.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM albums WHERE artist_metadata_id = $1 `, artistMetadataID).Scan(&count) return count, err } func (db *DB) TouchArtistUpdatedAt(ctx context.Context, artistMetadataID uuid.UUID) error { _, err := db.pool.Exec(ctx, ` UPDATE artist_metadata SET updated_at = NOW() WHERE id = $1 `, artistMetadataID) return err } func (db *DB) DeleteArtistByForeignID(ctx context.Context, foreignArtistID string) (bool, error) { result, err := db.pool.Exec(ctx, ` DELETE FROM artist_metadata WHERE foreign_artist_id = $1 `, foreignArtistID) if err != nil { return false, err } return result.RowsAffected() > 0, nil } type ArtistRow struct { ID uuid.UUID `json:"id"` MetadataID uuid.UUID `json:"metadata_id"` ForeignArtistID string `json:"foreign_artist_id"` Name string `json:"name"` QualityProfileID *uuid.UUID `json:"quality_profile_id"` MetadataProfileID *uuid.UUID `json:"metadata_profile_id"` RootFolderID *uuid.UUID `json:"root_folder_id"` Path *string `json:"path"` Monitored bool `json:"monitored"` MonitorNewItems string `json:"monitor_new_items"` } func (db *DB) UpsertArtist(ctx context.Context, metadataID uuid.UUID) (uuid.UUID, error) { var existingID uuid.UUID err := db.pool.QueryRow(ctx, ` SELECT id FROM artists WHERE metadata_id = $1 `, metadataID).Scan(&existingID) if err == nil { return existingID, nil } var resultID uuid.UUID err = db.pool.QueryRow(ctx, ` INSERT INTO artists (metadata_id, monitored, monitor_new_items) VALUES ($1, true, 'all') RETURNING id `, metadataID).Scan(&resultID) return resultID, err } func (db *DB) GetArtistByForeignID(ctx context.Context, foreignArtistID string) (*ArtistRow, error) { var a ArtistRow err := db.pool.QueryRow(ctx, ` SELECT a.id, a.metadata_id, am.foreign_artist_id, am.name, a.quality_profile_id, a.metadata_profile_id, a.root_folder_id, a.path, a.monitored, a.monitor_new_items FROM artists a JOIN artist_metadata am ON a.metadata_id = am.id WHERE am.foreign_artist_id = $1 `, foreignArtistID).Scan( &a.ID, &a.MetadataID, &a.ForeignArtistID, &a.Name, &a.QualityProfileID, &a.MetadataProfileID, &a.RootFolderID, &a.Path, &a.Monitored, &a.MonitorNewItems, ) if err != nil { return nil, err } return &a, nil } type ArtistUpdate struct { QualityProfileID *string `json:"quality_profile_id"` MetadataProfileID *string `json:"metadata_profile_id"` RootFolderID *string `json:"root_folder_id"` Path *string `json:"path"` Monitored *bool `json:"monitored"` MonitorNewItems *string `json:"monitor_new_items"` } func (db *DB) UpdateArtistByForeignID(ctx context.Context, foreignArtistID string, update ArtistUpdate) (*ArtistRow, error) { metadataRow, err := db.GetArtistMetadataByForeignID(ctx, foreignArtistID) if err != nil { return nil, err } if update.Monitored != nil { _, err = db.pool.Exec(ctx, ` UPDATE artists SET monitored = $1 WHERE metadata_id = $2 `, *update.Monitored, metadataRow.ID) if err != nil { return nil, err } } if update.Path != nil { _, err = db.pool.Exec(ctx, ` UPDATE artists SET path = $1 WHERE metadata_id = $2 `, *update.Path, metadataRow.ID) if err != nil { return nil, err } } if update.QualityProfileID != nil { var qpID *uuid.UUID if *update.QualityProfileID != "" { parsed, err := uuid.Parse(*update.QualityProfileID) if err == nil { qpID = &parsed } } _, err = db.pool.Exec(ctx, ` UPDATE artists SET quality_profile_id = $1 WHERE metadata_id = $2 `, qpID, metadataRow.ID) if err != nil { return nil, err } } if update.RootFolderID != nil { var rfID *uuid.UUID if *update.RootFolderID != "" { parsed, err := uuid.Parse(*update.RootFolderID) if err == nil { rfID = &parsed } } _, err = db.pool.Exec(ctx, ` UPDATE artists SET root_folder_id = $1 WHERE metadata_id = $2 `, rfID, metadataRow.ID) if err != nil { return nil, err } } if update.MonitorNewItems != nil { _, err = db.pool.Exec(ctx, ` UPDATE artists SET monitor_new_items = $1 WHERE metadata_id = $2 `, *update.MonitorNewItems, metadataRow.ID) if err != nil { return nil, err } } return db.GetArtistByForeignID(ctx, foreignArtistID) } func (db *DB) GetArtistMetadataByForeignID(ctx context.Context, foreignArtistID string) (*ArtistMetadataRow, error) { var a ArtistMetadataRow err := db.pool.QueryRow(ctx, ` SELECT id, foreign_artist_id, name, sort_name, artist_type, genres, created_at, updated_at FROM artist_metadata WHERE foreign_artist_id = $1 `, foreignArtistID).Scan(&a.ID, &a.ForeignArtistID, &a.Name, &a.SortName, &a.ArtistType, &a.Genres, &a.CreatedAt, &a.UpdatedAt) if err != nil { return nil, err } return &a, nil } func (db *DB) GetAlbumByID(ctx context.Context, albumID uuid.UUID) (*AlbumRow, error) { var a AlbumRow err := db.pool.QueryRow(ctx, ` SELECT id, artist_metadata_id, foreign_album_id, title, album_type, release_date, monitored, added_at FROM albums WHERE id = $1 `, albumID).Scan(&a.ID, &a.ArtistMetadataID, &a.ForeignAlbumID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.Monitored, &a.AddedAt) if err != nil { return nil, err } return &a, nil } type AlbumDetailRow struct { ID uuid.UUID `json:"id"` ArtistMetadataID uuid.UUID `json:"artist_metadata_id"` ForeignAlbumID *string `json:"foreign_album_id"` Title string `json:"title"` AlbumType *string `json:"album_type"` ReleaseDate *time.Time `json:"release_date"` Monitored bool `json:"monitored"` AddedAt time.Time `json:"added_at"` ArtistName string `json:"artist_name"` ForeignArtistID *string `json:"foreign_artist_id"` } func (db *DB) GetAlbumDetailByID(ctx context.Context, albumID uuid.UUID) (*AlbumDetailRow, error) { var a AlbumDetailRow err := db.pool.QueryRow(ctx, ` SELECT a.id, a.artist_metadata_id, a.foreign_album_id, a.title, a.album_type, a.release_date, a.monitored, a.added_at, am.name, am.foreign_artist_id FROM albums a JOIN artist_metadata am ON a.artist_metadata_id = am.id WHERE a.id = $1 `, albumID).Scan(&a.ID, &a.ArtistMetadataID, &a.ForeignAlbumID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.Monitored, &a.AddedAt, &a.ArtistName, &a.ForeignArtistID) if err != nil { return nil, err } return &a, nil } func (db *DB) UpdateAlbumMonitored(ctx context.Context, albumID uuid.UUID, monitored bool) error { _, err := db.pool.Exec(ctx, ` UPDATE albums SET monitored = $1 WHERE id = $2 `, monitored, albumID) return err } func (db *DB) BulkUpdateAlbumsMonitored(ctx context.Context, artistMetadataID uuid.UUID, monitored bool) (int64, error) { result, err := db.pool.Exec(ctx, ` UPDATE albums SET monitored = $1 WHERE artist_metadata_id = $2 `, monitored, artistMetadataID) if err != nil { return 0, err } return result.RowsAffected(), nil } func (db *DB) GetMonitoredAlbumsByArtist(ctx context.Context, artistMetadataID uuid.UUID) ([]AlbumRow, error) { rows, err := db.pool.Query(ctx, ` SELECT id, artist_metadata_id, foreign_album_id, title, album_type, release_date, monitored, added_at FROM albums WHERE artist_metadata_id = $1 AND monitored = true ORDER BY release_date DESC NULLS LAST `, artistMetadataID) if err != nil { return nil, err } defer rows.Close() var albums []AlbumRow for rows.Next() { var a AlbumRow err := rows.Scan(&a.ID, &a.ArtistMetadataID, &a.ForeignAlbumID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.Monitored, &a.AddedAt) if err != nil { return nil, err } albums = append(albums, a) } return albums, nil } type WantedAlbumRow struct { ID uuid.UUID `json:"id"` AlbumID uuid.UUID `json:"album_id"` Priority int `json:"priority"` SearchCount int `json:"search_count"` LastSearchedAt *time.Time `json:"last_searched_at"` AddedAt time.Time `json:"added_at"` } func (db *DB) AddToWantedAlbums(ctx context.Context, albumID uuid.UUID) error { _, err := db.pool.Exec(ctx, ` INSERT INTO wanted_albums (album_id) VALUES ($1) ON CONFLICT (album_id) DO NOTHING `, albumID) return err } func (db *DB) RemoveFromWantedAlbums(ctx context.Context, albumID uuid.UUID) error { _, err := db.pool.Exec(ctx, ` DELETE FROM wanted_albums WHERE album_id = $1 `, albumID) return err } func (db *DB) IsAlbumWanted(ctx context.Context, albumID uuid.UUID) (bool, error) { var count int64 err := db.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM wanted_albums WHERE album_id = $1 `, albumID).Scan(&count) return count > 0, err } func (db *DB) HasTrackFiles(ctx context.Context, albumID uuid.UUID) (bool, error) { var count int64 err := db.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM track_files WHERE album_id = $1 `, albumID).Scan(&count) return count > 0, err } type BlocklistEntry struct { ID uuid.UUID `json:"id"` ArtistID uuid.UUID `json:"artist_id"` AlbumID uuid.UUID `json:"album_id"` SourceTitle string `json:"source_title"` TorrentHash *string `json:"torrent_hash"` Indexer *string `json:"indexer"` Message *string `json:"message"` } func (db *DB) AddToBlocklist(ctx context.Context, artistID, albumID uuid.UUID, sourceTitle string, torrentHash, indexer *string) error { _, err := db.pool.Exec(ctx, ` INSERT INTO blocklist (artist_id, album_id, source_title, torrent_hash, indexer) VALUES ($1, $2, $3, $4, $5) `, artistID, albumID, sourceTitle, torrentHash, indexer) return err } func (db *DB) IsBlocklisted(ctx context.Context, sourceTitle string, torrentHash *string) (bool, error) { var count int64 if torrentHash != nil && *torrentHash != "" { err := db.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM blocklist WHERE source_title = $1 OR torrent_hash = $2 `, sourceTitle, *torrentHash).Scan(&count) return count > 0, err } err := db.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM blocklist WHERE source_title = $1 `, sourceTitle).Scan(&count) return count > 0, err } func (db *DB) ListBlocklist(ctx context.Context) ([]BlocklistEntry, error) { rows, err := db.pool.Query(ctx, ` SELECT id, artist_id, album_id, source_title, torrent_hash, indexer, message FROM blocklist ORDER BY date DESC `) if err != nil { return nil, err } defer rows.Close() var entries []BlocklistEntry for rows.Next() { var e BlocklistEntry err := rows.Scan(&e.ID, &e.ArtistID, &e.AlbumID, &e.SourceTitle, &e.TorrentHash, &e.Indexer, &e.Message) if err != nil { return nil, err } entries = append(entries, e) } return entries, nil } func (db *DB) GetArtistIDByAlbum(ctx context.Context, albumID uuid.UUID) (*uuid.UUID, error) { var artistID uuid.UUID err := db.pool.QueryRow(ctx, ` SELECT ar.id FROM artists ar JOIN artist_metadata am ON ar.metadata_id = am.id JOIN albums a ON a.artist_metadata_id = am.id WHERE a.id = $1 `, albumID).Scan(&artistID) if err != nil { return nil, err } return &artistID, nil } type DownloadQueueRow struct { ID uuid.UUID `json:"id"` ArtistID *uuid.UUID `json:"artist_id"` AlbumID *uuid.UUID `json:"album_id"` DownloadID *string `json:"download_id"` Title string `json:"title"` Size int64 `json:"size"` SizeLeft int64 `json:"size_left"` Status string `json:"status"` Progress float32 `json:"progress"` ErrorMessage *string `json:"error_message"` Protocol string `json:"protocol"` Indexer *string `json:"indexer"` DownloadClient *string `json:"download_client"` TorrentHash *string `json:"torrent_hash"` OutputPath *string `json:"output_path"` AddedAt time.Time `json:"added_at"` CompletedAt *time.Time `json:"completed_at"` } func (db *DB) AddToDownloadQueue(ctx context.Context, title string, size int64, torrentHash, indexer *string, albumID, artistID *uuid.UUID) (uuid.UUID, error) { var id uuid.UUID err := db.pool.QueryRow(ctx, ` INSERT INTO download_queue (title, size, torrent_hash, indexer, album_id, artist_id, status) VALUES ($1, $2, $3, $4, $5, $6, 'queued') RETURNING id `, title, size, torrentHash, indexer, albumID, artistID).Scan(&id) return id, err } func (db *DB) GetDownloadQueueItem(ctx context.Context, id uuid.UUID) (*DownloadQueueRow, error) { var row DownloadQueueRow err := db.pool.QueryRow(ctx, ` SELECT id, artist_id, album_id, download_id, title, size, size_left, status, progress, error_message, protocol, indexer, download_client, torrent_hash, output_path, added_at, completed_at FROM download_queue WHERE id = $1 `, id).Scan(&row.ID, &row.ArtistID, &row.AlbumID, &row.DownloadID, &row.Title, &row.Size, &row.SizeLeft, &row.Status, &row.Progress, &row.ErrorMessage, &row.Protocol, &row.Indexer, &row.DownloadClient, &row.TorrentHash, &row.OutputPath, &row.AddedAt, &row.CompletedAt) if err != nil { return nil, err } return &row, nil } func (db *DB) ListDownloadQueue(ctx context.Context, status *string) ([]DownloadQueueRow, error) { var rows []DownloadQueueRow var query string var args []any if status != nil { query = ` SELECT id, artist_id, album_id, download_id, title, size, size_left, status, progress, error_message, protocol, indexer, download_client, torrent_hash, output_path, added_at, completed_at FROM download_queue WHERE status = $1 ORDER BY added_at DESC ` args = []any{*status} } else { query = ` SELECT id, artist_id, album_id, download_id, title, size, size_left, status, progress, error_message, protocol, indexer, download_client, torrent_hash, output_path, added_at, completed_at FROM download_queue ORDER BY added_at DESC ` } dbRows, err := db.pool.Query(ctx, query, args...) if err != nil { return nil, err } defer dbRows.Close() for dbRows.Next() { var row DownloadQueueRow err := dbRows.Scan(&row.ID, &row.ArtistID, &row.AlbumID, &row.DownloadID, &row.Title, &row.Size, &row.SizeLeft, &row.Status, &row.Progress, &row.ErrorMessage, &row.Protocol, &row.Indexer, &row.DownloadClient, &row.TorrentHash, &row.OutputPath, &row.AddedAt, &row.CompletedAt) if err != nil { return nil, err } rows = append(rows, row) } return rows, nil } func (db *DB) UpdateDownloadQueueStatus(ctx context.Context, id uuid.UUID, status string, errorMessage *string) error { if status == "completed" { _, err := db.pool.Exec(ctx, ` UPDATE download_queue SET status = $1, completed_at = NOW() WHERE id = $2 `, status, id) return err } if errorMessage != nil { _, err := db.pool.Exec(ctx, ` UPDATE download_queue SET status = $1, error_message = $2 WHERE id = $3 `, status, *errorMessage, id) return err } _, err := db.pool.Exec(ctx, ` UPDATE download_queue SET status = $1 WHERE id = $2 `, status, id) return err } func (db *DB) UpdateDownloadQueueProgress(ctx context.Context, id uuid.UUID, progress float32, sizeLeft int64, status string) error { _, err := db.pool.Exec(ctx, ` UPDATE download_queue SET progress = $1, size_left = $2, status = $3 WHERE id = $4 `, progress, sizeLeft, status, id) return err } func (db *DB) UpdateDownloadQueueHash(ctx context.Context, id uuid.UUID, hash string) error { _, err := db.pool.Exec(ctx, `UPDATE download_queue SET torrent_hash = $1 WHERE id = $2`, hash, id) return err } func (db *DB) DeleteDownloadQueueItem(ctx context.Context, id uuid.UUID) error { _, err := db.pool.Exec(ctx, `DELETE FROM download_queue WHERE id = $1`, id) return err } func (db *DB) GetDownloadQueueByTorrentHash(ctx context.Context, hash string) (*DownloadQueueRow, error) { var row DownloadQueueRow err := db.pool.QueryRow(ctx, ` SELECT id, artist_id, album_id, download_id, title, size, size_left, status, progress, error_message, protocol, indexer, download_client, torrent_hash, output_path, added_at, completed_at FROM download_queue WHERE torrent_hash = $1 `, hash).Scan(&row.ID, &row.ArtistID, &row.AlbumID, &row.DownloadID, &row.Title, &row.Size, &row.SizeLeft, &row.Status, &row.Progress, &row.ErrorMessage, &row.Protocol, &row.Indexer, &row.DownloadClient, &row.TorrentHash, &row.OutputPath, &row.AddedAt, &row.CompletedAt) if err != nil { return nil, err } return &row, nil } type DownloadQueueStats struct { Total int64 `json:"total"` Downloading int64 `json:"downloading"` Queued int64 `json:"queued"` Completed int64 `json:"completed"` Failed int64 `json:"failed"` } func (db *DB) GetDownloadQueueStats(ctx context.Context) (*DownloadQueueStats, error) { var stats DownloadQueueStats err := db.pool.QueryRow(ctx, ` SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE status = 'downloading') as downloading, COUNT(*) FILTER (WHERE status = 'queued') as queued, COUNT(*) FILTER (WHERE status = 'completed') as completed, COUNT(*) FILTER (WHERE status = 'failed') as failed FROM download_queue `).Scan(&stats.Total, &stats.Downloading, &stats.Queued, &stats.Completed, &stats.Failed) return &stats, err }