Files
silo-server/internal/catalog/episode_repo.go
T

894 lines
28 KiB
Go

package catalog
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/models"
)
// Sentinel errors for episode repository operations.
var (
ErrEpisodeNotFound = errors.New("episode not found")
)
// EpisodeRepository provides CRUD operations for the episodes table.
type EpisodeRepository struct {
pool *pgxpool.Pool
}
// NewEpisodeRepository creates a new EpisodeRepository backed by the given pool.
func NewEpisodeRepository(pool *pgxpool.Pool) *EpisodeRepository {
return &EpisodeRepository{pool: pool}
}
// episodeColumns is the list of columns returned by all SELECT queries on episodes.
const episodeColumns = `content_id, series_id, season_id, season_number, episode_number,
title, default_metadata_language, overview, air_date, runtime,
rating_imdb, rating_tmdb,
imdb_id, tmdb_id, tvdb_id,
still_path, still_thumbhash,
metadata_s3_path, metadata_etag, metadata_source,
created_at, updated_at`
// updateSeriesLastAirDateSQL maintains the denormalized
// media_items.last_air_date_at column for a single series after an Upsert.
// Audit 2026-05-01 §2.1 hot path #1: replaces a per-row correlated
// subquery with a column read.
//
// MAINTENANCE INVARIANT: This column is currently kept fresh only on
// Upsert/BulkUpsert. The repo has no episode-delete path, and
// TestNoEpisodeDeletePath_ProtectsLastAirDateDenorm pins that absence.
// If you add a delete path, also recompute last_air_date_at for the
// parent series (or add a DB trigger).
const updateSeriesLastAirDateSQL = `
UPDATE media_items mi
SET last_air_date_at = sub.last_aired
FROM (
SELECT MAX(e.air_date) AS last_aired
FROM episodes e
WHERE e.series_id = $1 AND e.air_date IS NOT NULL AND e.air_date <= CURRENT_DATE
) sub
WHERE mi.content_id = $1 AND mi.type = 'series'
AND (mi.last_air_date_at IS DISTINCT FROM sub.last_aired)`
// batchUpdateSeriesLastAirDateSQL is the multi-series form used by
// BulkUpsert. Single round-trip regardless of episode count.
//
// Drive the subquery from UNNEST($1) with a LEFT JOIN so every input
// series_id produces exactly one row in `sub` — including series whose
// entire episode set has air_date NULL or in the future, where MAX is
// NULL. Without this, GROUP BY over a filtered `episodes` scan would
// drop those series and leave a stale media_items.last_air_date_at
// from an earlier sync (e.g., a series whose air dates were corrected
// to NULL would never reset). The IS DISTINCT FROM guard handles NULL
// vs non-NULL comparison correctly.
const batchUpdateSeriesLastAirDateSQL = `
UPDATE media_items mi
SET last_air_date_at = sub.last_aired
FROM (
SELECT s.series_id, MAX(e.air_date) AS last_aired
FROM unnest($1::text[]) AS s(series_id)
LEFT JOIN episodes e
ON e.series_id = s.series_id
AND e.air_date IS NOT NULL
AND e.air_date <= CURRENT_DATE
GROUP BY s.series_id
) sub
WHERE mi.content_id = sub.series_id
AND mi.type = 'series'
AND (mi.last_air_date_at IS DISTINCT FROM sub.last_aired)`
// buildUpdateSeriesLastAirDateSQL exposes the single-series maintenance
// SQL for unit-test inspection.
func (r *EpisodeRepository) buildUpdateSeriesLastAirDateSQL() string {
return updateSeriesLastAirDateSQL
}
// buildBatchUpdateLastAirDateSQL exposes the bulk maintenance SQL for
// unit-test inspection.
func (r *EpisodeRepository) buildBatchUpdateLastAirDateSQL() string {
return batchUpdateSeriesLastAirDateSQL
}
const episodeAvailabilityPredicate = `EXISTS (
SELECT 1
FROM episode_libraries el
WHERE el.episode_id = episodes.content_id
)`
// scanEpisode scans a single row into a *models.Episode.
func scanEpisode(row pgx.Row) (*models.Episode, error) {
var ep models.Episode
var seasonID *string
var runtime *int
var overview *string
var imdbID *string
var tmdbID *string
var tvdbID *string
var stillPath *string
var stillThumbhash *string
var metadataS3Path *string
var metadataEtag *string
err := row.Scan(
&ep.ContentID,
&ep.SeriesID,
&seasonID,
&ep.SeasonNumber,
&ep.EpisodeNumber,
&ep.Title,
&ep.DefaultMetadataLanguage,
&overview,
&ep.AirDate,
&runtime,
&ep.RatingIMDB,
&ep.RatingTMDB,
&imdbID,
&tmdbID,
&tvdbID,
&stillPath,
&stillThumbhash,
&metadataS3Path,
&metadataEtag,
&ep.MetadataSource,
&ep.CreatedAt,
&ep.UpdatedAt,
)
if seasonID != nil {
ep.SeasonID = *seasonID
}
if runtime != nil {
ep.Runtime = *runtime
}
if overview != nil {
ep.Overview = *overview
}
if imdbID != nil {
ep.ImdbID = *imdbID
}
if tmdbID != nil {
ep.TmdbID = *tmdbID
}
if tvdbID != nil {
ep.TvdbID = *tvdbID
}
if stillPath != nil {
ep.StillPath = *stillPath
}
if stillThumbhash != nil {
ep.StillThumbhash = *stillThumbhash
}
if metadataS3Path != nil {
ep.MetadataS3Path = *metadataS3Path
}
if metadataEtag != nil {
ep.MetadataEtag = *metadataEtag
}
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrEpisodeNotFound
}
return nil, fmt.Errorf("scanning episode: %w", err)
}
return &ep, nil
}
// scanEpisodes scans multiple rows into a []*models.Episode slice.
func scanEpisodes(rows pgx.Rows) ([]*models.Episode, error) {
var episodes []*models.Episode
for rows.Next() {
var ep models.Episode
var seasonID *string
var runtime *int
var overview *string
var imdbID *string
var tmdbID *string
var tvdbID *string
var stillPath *string
var stillThumbhash *string
var metadataS3Path *string
var metadataEtag *string
err := rows.Scan(
&ep.ContentID,
&ep.SeriesID,
&seasonID,
&ep.SeasonNumber,
&ep.EpisodeNumber,
&ep.Title,
&ep.DefaultMetadataLanguage,
&overview,
&ep.AirDate,
&runtime,
&ep.RatingIMDB,
&ep.RatingTMDB,
&imdbID,
&tmdbID,
&tvdbID,
&stillPath,
&stillThumbhash,
&metadataS3Path,
&metadataEtag,
&ep.MetadataSource,
&ep.CreatedAt,
&ep.UpdatedAt,
)
if seasonID != nil {
ep.SeasonID = *seasonID
}
if runtime != nil {
ep.Runtime = *runtime
}
if overview != nil {
ep.Overview = *overview
}
if imdbID != nil {
ep.ImdbID = *imdbID
}
if tmdbID != nil {
ep.TmdbID = *tmdbID
}
if tvdbID != nil {
ep.TvdbID = *tvdbID
}
if stillPath != nil {
ep.StillPath = *stillPath
}
if stillThumbhash != nil {
ep.StillThumbhash = *stillThumbhash
}
if metadataS3Path != nil {
ep.MetadataS3Path = *metadataS3Path
}
if metadataEtag != nil {
ep.MetadataEtag = *metadataEtag
}
if err != nil {
return nil, fmt.Errorf("scanning episode row: %w", err)
}
episodes = append(episodes, &ep)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating episode rows: %w", err)
}
return episodes, nil
}
// Upsert inserts a new episode or updates all mutable fields if the
// (series_id, season_number, episode_number) natural key already exists. The
// stored content ID is preserved on update and written back to ep.ContentID.
func (r *EpisodeRepository) Upsert(ctx context.Context, ep *models.Episode) error {
var seasonID *string
if ep.SeasonID != "" {
seasonID = &ep.SeasonID
}
// Clear stale external IDs from other episodes in the same series as a
// separate statement. This MUST run before the INSERT because PostgreSQL
// data-modifying CTEs share the same snapshot as the main query, so an
// inline CTE's UPDATE is invisible to the INSERT's unique-constraint check.
clearQuery := `
UPDATE episodes SET
imdb_id = CASE WHEN $2 <> '' AND imdb_id = $2 THEN '' ELSE imdb_id END,
tmdb_id = CASE WHEN $3 <> '' AND tmdb_id = $3 THEN '' ELSE tmdb_id END,
tvdb_id = CASE WHEN $4 <> '' AND tvdb_id = $4 THEN '' ELSE tvdb_id END
WHERE series_id = $1
AND (season_number, episode_number) <> ($5, $6)
AND (($2 <> '' AND imdb_id = $2) OR ($3 <> '' AND tmdb_id = $3) OR ($4 <> '' AND tvdb_id = $4))`
if _, err := r.pool.Exec(ctx, clearQuery,
ep.SeriesID,
ep.ImdbID,
ep.TmdbID,
ep.TvdbID,
ep.SeasonNumber,
ep.EpisodeNumber,
); err != nil {
return fmt.Errorf("clearing stale episode external IDs: %w", err)
}
query := `
INSERT INTO episodes (
content_id, series_id, season_id, season_number, episode_number,
title, default_metadata_language, overview, air_date, runtime,
rating_imdb, rating_tmdb,
imdb_id, tmdb_id, tvdb_id,
still_path, still_thumbhash,
metadata_s3_path, metadata_etag, metadata_source
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9, $10,
$11, $12,
$13, $14, $15,
$16, $17,
$18, $19, $20
)
ON CONFLICT (series_id, season_number, episode_number) DO UPDATE SET
season_id = COALESCE(EXCLUDED.season_id, episodes.season_id),
title = EXCLUDED.title,
default_metadata_language = COALESCE(NULLIF(episodes.default_metadata_language, ''), EXCLUDED.default_metadata_language),
overview = EXCLUDED.overview,
air_date = EXCLUDED.air_date,
runtime = EXCLUDED.runtime,
rating_imdb = EXCLUDED.rating_imdb,
rating_tmdb = EXCLUDED.rating_tmdb,
imdb_id = COALESCE(NULLIF(EXCLUDED.imdb_id, ''), episodes.imdb_id),
tmdb_id = COALESCE(NULLIF(EXCLUDED.tmdb_id, ''), episodes.tmdb_id),
tvdb_id = COALESCE(NULLIF(EXCLUDED.tvdb_id, ''), episodes.tvdb_id),
still_path = EXCLUDED.still_path,
still_thumbhash = EXCLUDED.still_thumbhash,
metadata_s3_path = EXCLUDED.metadata_s3_path,
metadata_etag = EXCLUDED.metadata_etag,
metadata_source = EXCLUDED.metadata_source,
updated_at = NOW()
RETURNING content_id`
var storedContentID string
err := r.pool.QueryRow(ctx, query,
ep.ContentID,
ep.SeriesID,
seasonID,
ep.SeasonNumber,
ep.EpisodeNumber,
ep.Title,
ep.DefaultMetadataLanguage,
ep.Overview,
ep.AirDate,
ep.Runtime,
ep.RatingIMDB,
ep.RatingTMDB,
ep.ImdbID,
ep.TmdbID,
ep.TvdbID,
ep.StillPath,
ep.StillThumbhash,
ep.MetadataS3Path,
ep.MetadataEtag,
ep.MetadataSource,
).Scan(&storedContentID)
if err != nil {
return fmt.Errorf("upserting episode: %w", err)
}
ep.ContentID = storedContentID
// Maintain denormalized media_items.last_air_date_at for the parent
// series (audit 2026-05-01 §2.1 hot path #1).
if _, err := r.pool.Exec(ctx, updateSeriesLastAirDateSQL, ep.SeriesID); err != nil {
return fmt.Errorf("update series last_air_date_at: %w", err)
}
return nil
}
// BulkUpsert inserts or updates multiple episodes for a single series in two
// round-trips: one to clear stale external IDs, one for the multi-row upsert.
// Stored content IDs are written back to each Episode struct.
func (r *EpisodeRepository) BulkUpsert(ctx context.Context, seriesID string, episodes []*models.Episode) error {
if len(episodes) == 0 {
return nil
}
// Collect external IDs and their owning (season_number, episode_number)
// pairs for the batch stale-ID clear.
imdbIDs := make([]string, 0, len(episodes))
tmdbIDs := make([]string, 0, len(episodes))
tvdbIDs := make([]string, 0, len(episodes))
ownerSNs := make([]int32, 0, len(episodes))
ownerENs := make([]int32, 0, len(episodes))
for _, ep := range episodes {
if ep.ImdbID != "" || ep.TmdbID != "" || ep.TvdbID != "" {
imdbIDs = append(imdbIDs, ep.ImdbID)
tmdbIDs = append(tmdbIDs, ep.TmdbID)
tvdbIDs = append(tvdbIDs, ep.TvdbID)
ownerSNs = append(ownerSNs, int32(ep.SeasonNumber))
ownerENs = append(ownerENs, int32(ep.EpisodeNumber))
}
}
// Step 1: Clear stale external IDs. This MUST run as a separate statement
// before the INSERT (PostgreSQL data-modifying CTEs share the same snapshot).
if len(imdbIDs) > 0 {
clearQuery := `
UPDATE episodes SET
imdb_id = CASE WHEN imdb_id <> '' AND imdb_id = ANY($2::text[])
AND NOT EXISTS (
SELECT 1 FROM UNNEST($5::int[], $6::int[], $2::text[]) AS t(sn, en, eid)
WHERE t.sn = episodes.season_number AND t.en = episodes.episode_number AND t.eid = episodes.imdb_id
) THEN '' ELSE imdb_id END,
tmdb_id = CASE WHEN tmdb_id <> '' AND tmdb_id = ANY($3::text[])
AND NOT EXISTS (
SELECT 1 FROM UNNEST($5::int[], $6::int[], $3::text[]) AS t(sn, en, eid)
WHERE t.sn = episodes.season_number AND t.en = episodes.episode_number AND t.eid = episodes.tmdb_id
) THEN '' ELSE tmdb_id END,
tvdb_id = CASE WHEN tvdb_id <> '' AND tvdb_id = ANY($4::text[])
AND NOT EXISTS (
SELECT 1 FROM UNNEST($5::int[], $6::int[], $4::text[]) AS t(sn, en, eid)
WHERE t.sn = episodes.season_number AND t.en = episodes.episode_number AND t.eid = episodes.tvdb_id
) THEN '' ELSE tvdb_id END
WHERE series_id = $1
AND (
(imdb_id <> '' AND imdb_id = ANY($2::text[]))
OR (tmdb_id <> '' AND tmdb_id = ANY($3::text[]))
OR (tvdb_id <> '' AND tvdb_id = ANY($4::text[]))
)`
if _, err := r.pool.Exec(ctx, clearQuery,
seriesID, imdbIDs, tmdbIDs, tvdbIDs, ownerSNs, ownerENs,
); err != nil {
return fmt.Errorf("bulk clearing stale episode external IDs: %w", err)
}
}
// Step 2: Multi-row upsert.
contentIDs := make([]string, len(episodes))
epSeriesIDs := make([]string, len(episodes))
seasonIDs := make([]*string, len(episodes))
seasonNums := make([]int32, len(episodes))
episodeNums := make([]int32, len(episodes))
titles := make([]string, len(episodes))
defaultMetadataLanguages := make([]string, len(episodes))
overviews := make([]string, len(episodes))
airDates := make([]*time.Time, len(episodes))
runtimes := make([]int32, len(episodes))
ratingsIMDB := make([]*float64, len(episodes))
ratingsTMDB := make([]*float64, len(episodes))
epImdbIDs := make([]string, len(episodes))
epTmdbIDs := make([]string, len(episodes))
epTvdbIDs := make([]string, len(episodes))
stillPaths := make([]string, len(episodes))
stillThumbs := make([]string, len(episodes))
metaS3Paths := make([]string, len(episodes))
metaEtags := make([]string, len(episodes))
metaSources := make([]string, len(episodes))
for i, ep := range episodes {
contentIDs[i] = ep.ContentID
epSeriesIDs[i] = ep.SeriesID
if ep.SeasonID != "" {
seasonIDs[i] = &ep.SeasonID
}
seasonNums[i] = int32(ep.SeasonNumber)
episodeNums[i] = int32(ep.EpisodeNumber)
titles[i] = ep.Title
defaultMetadataLanguages[i] = ep.DefaultMetadataLanguage
overviews[i] = ep.Overview
airDates[i] = ep.AirDate
runtimes[i] = int32(ep.Runtime)
ratingsIMDB[i] = ep.RatingIMDB
ratingsTMDB[i] = ep.RatingTMDB
epImdbIDs[i] = ep.ImdbID
epTmdbIDs[i] = ep.TmdbID
epTvdbIDs[i] = ep.TvdbID
stillPaths[i] = ep.StillPath
stillThumbs[i] = ep.StillThumbhash
metaS3Paths[i] = ep.MetadataS3Path
metaEtags[i] = ep.MetadataEtag
metaSources[i] = ep.MetadataSource
}
query := `
INSERT INTO episodes (
content_id, series_id, season_id, season_number, episode_number,
title, default_metadata_language, overview, air_date, runtime,
rating_imdb, rating_tmdb,
imdb_id, tmdb_id, tvdb_id,
still_path, still_thumbhash,
metadata_s3_path, metadata_etag, metadata_source
)
SELECT * FROM UNNEST(
$1::text[], $2::text[], $3::text[], $4::int[], $5::int[],
$6::text[], $7::text[], $8::text[], $9::date[], $10::int[],
$11::float8[], $12::float8[],
$13::text[], $14::text[], $15::text[],
$16::text[], $17::text[],
$18::text[], $19::text[], $20::text[]
)
ON CONFLICT (series_id, season_number, episode_number) DO UPDATE SET
season_id = COALESCE(EXCLUDED.season_id, episodes.season_id),
title = EXCLUDED.title,
default_metadata_language = COALESCE(NULLIF(episodes.default_metadata_language, ''), EXCLUDED.default_metadata_language),
overview = EXCLUDED.overview,
air_date = EXCLUDED.air_date,
runtime = EXCLUDED.runtime,
rating_imdb = EXCLUDED.rating_imdb,
rating_tmdb = EXCLUDED.rating_tmdb,
imdb_id = COALESCE(NULLIF(EXCLUDED.imdb_id, ''), episodes.imdb_id),
tmdb_id = COALESCE(NULLIF(EXCLUDED.tmdb_id, ''), episodes.tmdb_id),
tvdb_id = COALESCE(NULLIF(EXCLUDED.tvdb_id, ''), episodes.tvdb_id),
still_path = EXCLUDED.still_path,
still_thumbhash = EXCLUDED.still_thumbhash,
metadata_s3_path = EXCLUDED.metadata_s3_path,
metadata_etag = EXCLUDED.metadata_etag,
metadata_source = EXCLUDED.metadata_source,
updated_at = NOW()
RETURNING content_id, season_number, episode_number`
rows, err := r.pool.Query(ctx, query,
contentIDs, epSeriesIDs, seasonIDs, seasonNums, episodeNums,
titles, defaultMetadataLanguages, overviews, airDates, runtimes,
ratingsIMDB, ratingsTMDB,
epImdbIDs, epTmdbIDs, epTvdbIDs,
stillPaths, stillThumbs,
metaS3Paths, metaEtags, metaSources,
)
if err != nil {
return fmt.Errorf("bulk upserting episodes: %w", err)
}
defer rows.Close()
// Build a map to write back content IDs by (season_number, episode_number).
type epKey struct {
sn, en int32
}
returnedIDs := make(map[epKey]string, len(episodes))
for rows.Next() {
var cid string
var sn, en int32
if err := rows.Scan(&cid, &sn, &en); err != nil {
return fmt.Errorf("scanning bulk upsert episode result: %w", err)
}
returnedIDs[epKey{sn, en}] = cid
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterating bulk upsert episode results: %w", err)
}
for _, ep := range episodes {
if cid, ok := returnedIDs[epKey{int32(ep.SeasonNumber), int32(ep.EpisodeNumber)}]; ok {
ep.ContentID = cid
}
}
// Maintain denormalized media_items.last_air_date_at for the parent
// series (audit 2026-05-01 §2.1 hot path #1). BulkUpsert is called for
// a single series, but the SQL uses ANY($1::text[]) to be future-proof
// for multi-series scanner batches.
if _, err := r.pool.Exec(ctx, batchUpdateSeriesLastAirDateSQL, []string{seriesID}); err != nil {
return fmt.Errorf("batch update last_air_date_at: %w", err)
}
return nil
}
// GetBySeriesAndNumber retrieves a specific episode by series ID, season, and
// episode number.
func (r *EpisodeRepository) GetBySeriesAndNumber(ctx context.Context, seriesID string, season, episode int) (*models.Episode, error) {
query := `SELECT ` + episodeColumns + `
FROM episodes
WHERE series_id = $1 AND season_number = $2 AND episode_number = $3`
return scanEpisode(r.pool.QueryRow(ctx, query, seriesID, season, episode))
}
// ListBySeriesAndAirDates retrieves provider episode rows for a series keyed by
// air date. It intentionally does not use episodeAvailabilityPredicate because
// callers use it to link files before episode_libraries rows exist.
func (r *EpisodeRepository) ListBySeriesAndAirDates(ctx context.Context, seriesID string, airDates []string) (map[string][]*models.Episode, error) {
result := make(map[string][]*models.Episode, len(airDates))
if len(airDates) == 0 {
return result, nil
}
seen := make(map[string]struct{}, len(airDates))
args := []any{seriesID}
placeholders := make([]string, 0, len(airDates))
for _, airDate := range airDates {
airDate = strings.TrimSpace(airDate)
if airDate == "" {
continue
}
if _, ok := seen[airDate]; ok {
continue
}
seen[airDate] = struct{}{}
args = append(args, airDate)
placeholders = append(placeholders, fmt.Sprintf("$%d::date", len(args)))
}
if len(placeholders) == 0 {
return result, nil
}
query := `SELECT ` + episodeColumns + `
FROM episodes
WHERE series_id = $1
AND air_date IN (` + strings.Join(placeholders, ", ") + `)
ORDER BY air_date ASC, season_number ASC, episode_number ASC`
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("listing episodes by series and air dates: %w", err)
}
defer rows.Close()
episodes, err := scanEpisodes(rows)
if err != nil {
return nil, err
}
for _, ep := range episodes {
if ep.AirDate == nil {
continue
}
key := ep.AirDate.Format("2006-01-02")
result[key] = append(result[key], ep)
}
return result, nil
}
// GetByID retrieves a specific episode by its content ID.
func (r *EpisodeRepository) GetByID(ctx context.Context, contentID string) (*models.Episode, error) {
query := `SELECT ` + episodeColumns + `
FROM episodes
WHERE content_id = $1`
return scanEpisode(r.pool.QueryRow(ctx, query, contentID))
}
// GetByIDs retrieves multiple episodes by their content IDs.
func (r *EpisodeRepository) GetByIDs(ctx context.Context, contentIDs []string) ([]*models.Episode, error) {
if len(contentIDs) == 0 {
return nil, nil
}
query := `SELECT ` + episodeColumns + ` FROM episodes WHERE content_id = ANY($1)`
rows, err := r.pool.Query(ctx, query, contentIDs)
if err != nil {
return nil, fmt.Errorf("fetching episodes by IDs: %w", err)
}
defer rows.Close()
return scanEpisodes(rows)
}
// ListBySeries returns all episodes for a given series, ordered by season and
// episode number.
func (r *EpisodeRepository) ListBySeries(ctx context.Context, seriesID string) ([]*models.Episode, error) {
query := `SELECT ` + episodeColumns + `
FROM episodes
WHERE series_id = $1
AND ` + episodeAvailabilityPredicate + `
ORDER BY season_number ASC, episode_number ASC`
rows, err := r.pool.Query(ctx, query, seriesID)
if err != nil {
return nil, fmt.Errorf("listing episodes by series: %w", err)
}
defer rows.Close()
return scanEpisodes(rows)
}
func (r *EpisodeRepository) ListBySeriesIDs(ctx context.Context, seriesIDs []string) (map[string][]*models.Episode, error) {
result := make(map[string][]*models.Episode, len(seriesIDs))
if len(seriesIDs) == 0 {
return result, nil
}
query := `SELECT ` + episodeColumns + `
FROM episodes
WHERE series_id = ANY($1)
AND ` + episodeAvailabilityPredicate + `
ORDER BY series_id ASC, season_number ASC, episode_number ASC`
rows, err := r.pool.Query(ctx, query, seriesIDs)
if err != nil {
return nil, fmt.Errorf("listing episodes by series ids: %w", err)
}
defer rows.Close()
episodes, err := scanEpisodes(rows)
if err != nil {
return nil, err
}
for _, episode := range episodes {
result[episode.SeriesID] = append(result[episode.SeriesID], episode)
}
return result, nil
}
// buildListBySeriesGroupedBySeasonQuery returns the SQL and bound args used by
// ListBySeriesGroupedBySeason. Extracted so tests can assert SQL shape without
// a live Postgres pool.
func (r *EpisodeRepository) buildListBySeriesGroupedBySeasonQuery(seriesID string) (string, []any) {
sql := `SELECT ` + episodeColumns + `
FROM episodes
WHERE series_id = $1
AND ` + episodeAvailabilityPredicate + `
ORDER BY season_number ASC, episode_number ASC`
return sql, []any{seriesID}
}
// ListBySeriesGroupedBySeason returns all available episodes for a series
// grouped by season number. Replaces N+1 per-season ListBySeason calls
// (audit 2026-05-01 §2.2).
func (r *EpisodeRepository) ListBySeriesGroupedBySeason(ctx context.Context, seriesID string) (map[int][]*models.Episode, error) {
query, args := r.buildListBySeriesGroupedBySeasonQuery(seriesID)
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("listing episodes grouped by season: %w", err)
}
defer rows.Close()
episodes, err := scanEpisodes(rows)
if err != nil {
return nil, err
}
grouped := make(map[int][]*models.Episode)
for _, ep := range episodes {
grouped[ep.SeasonNumber] = append(grouped[ep.SeasonNumber], ep)
}
return grouped, nil
}
// SeasonSummary contains summary info about a season.
type SeasonSummary struct {
SeasonNumber int `json:"season_number"`
EpisodeCount int `json:"episode_count"`
}
// ListSeasons returns a summary of all seasons for a given series.
func (r *EpisodeRepository) ListSeasons(ctx context.Context, seriesID string) ([]SeasonSummary, error) {
query := `SELECT season_number, COUNT(*) AS episode_count
FROM episodes
WHERE series_id = $1
AND ` + episodeAvailabilityPredicate + `
GROUP BY season_number
ORDER BY season_number ASC`
rows, err := r.pool.Query(ctx, query, seriesID)
if err != nil {
return nil, fmt.Errorf("listing seasons: %w", err)
}
defer rows.Close()
var seasons []SeasonSummary
for rows.Next() {
var s SeasonSummary
if err := rows.Scan(&s.SeasonNumber, &s.EpisodeCount); err != nil {
return nil, fmt.Errorf("scanning season summary: %w", err)
}
seasons = append(seasons, s)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating season rows: %w", err)
}
return seasons, nil
}
// CountBySeries returns the number of episodes for a given series.
func (r *EpisodeRepository) CountBySeries(ctx context.Context, seriesID string) (int, error) {
var count int
err := r.pool.QueryRow(ctx,
`SELECT COUNT(*)
FROM episodes
WHERE series_id = $1
AND `+episodeAvailabilityPredicate, seriesID,
).Scan(&count)
if err != nil {
return 0, fmt.Errorf("counting episodes by series: %w", err)
}
return count, nil
}
// ListBySeason returns all episodes for a specific season of a series, ordered
// by episode number.
func (r *EpisodeRepository) ListBySeason(ctx context.Context, seriesID string, seasonNum int) ([]*models.Episode, error) {
query := `SELECT ` + episodeColumns + `
FROM episodes
WHERE series_id = $1 AND season_number = $2
AND ` + episodeAvailabilityPredicate + `
ORDER BY episode_number ASC`
rows, err := r.pool.Query(ctx, query, seriesID, seasonNum)
if err != nil {
return nil, fmt.Errorf("listing episodes by season: %w", err)
}
defer rows.Close()
return scanEpisodes(rows)
}
// ListBySeasonID returns all episodes for a specific season row, ordered by
// episode number.
func (r *EpisodeRepository) ListBySeasonID(ctx context.Context, seasonID string) ([]*models.Episode, error) {
query := `SELECT ` + episodeColumns + `
FROM episodes
WHERE season_id = $1
AND ` + episodeAvailabilityPredicate + `
ORDER BY episode_number ASC`
rows, err := r.pool.Query(ctx, query, seasonID)
if err != nil {
return nil, fmt.Errorf("listing episodes by season_id: %w", err)
}
defer rows.Close()
return scanEpisodes(rows)
}
func (r *EpisodeRepository) ListBySeasonIDs(ctx context.Context, seasonIDs []string) (map[string][]*models.Episode, error) {
result := make(map[string][]*models.Episode, len(seasonIDs))
if len(seasonIDs) == 0 {
return result, nil
}
query := `SELECT ` + episodeColumns + `
FROM episodes
WHERE season_id = ANY($1)
AND ` + episodeAvailabilityPredicate + `
ORDER BY season_id ASC, season_number ASC, episode_number ASC`
rows, err := r.pool.Query(ctx, query, seasonIDs)
if err != nil {
return nil, fmt.Errorf("listing episodes by season ids: %w", err)
}
defer rows.Close()
episodes, err := scanEpisodes(rows)
if err != nil {
return nil, err
}
for _, episode := range episodes {
result[episode.SeasonID] = append(result[episode.SeasonID], episode)
}
return result, nil
}
// UpdateMetadata builds a dynamic UPDATE query for the episodes table,
// setting only the non-nil fields in upd. Always bumps updated_at.
// Returns ErrEpisodeNotFound if no row matches contentID.
func (r *EpisodeRepository) UpdateMetadata(ctx context.Context, contentID string, upd *MetadataUpdate) error {
var setClauses []string
var args []any
argIdx := 1
addString := func(col string, val *string) {
if val != nil {
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, argIdx))
args = append(args, *val)
argIdx++
}
}
addInt := func(col string, val *int) {
if val != nil {
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, argIdx))
args = append(args, *val)
argIdx++
}
}
addFloat := func(col string, val *float64) {
if val != nil {
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, argIdx))
args = append(args, *val)
argIdx++
}
}
addString("title", upd.Title)
addString("overview", upd.Overview)
addInt("episode_number", upd.EpisodeNumber)
addInt("season_number", upd.SeasonNumber)
addString("air_date", upd.AirDate)
addInt("runtime", upd.Runtime)
addFloat("rating_imdb", upd.RatingIMDB)
addFloat("rating_tmdb", upd.RatingTMDB)
addString("imdb_id", upd.ImdbID)
addString("tmdb_id", upd.TmdbID)
addString("tvdb_id", upd.TvdbID)
addString("still_path", upd.StillPath)
addString("still_thumbhash", upd.StillThumbhash)
setClauses = append(setClauses, "updated_at = NOW()")
query := fmt.Sprintf("UPDATE episodes SET %s WHERE content_id = $%d",
strings.Join(setClauses, ", "), argIdx)
args = append(args, contentID)
tag, err := r.pool.Exec(ctx, query, args...)
if err != nil {
return fmt.Errorf("updating episode metadata: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrEpisodeNotFound
}
return nil
}