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

339 lines
10 KiB
Go

package catalog
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// NextUpQuery controls what the next-up lookup returns.
type NextUpQuery struct {
UserID int
ProfileID string
LibraryID *int
LibraryIDs []int
SeriesID string // optional: filter to single series
AccessFilter AccessFilter
Limit int
EnableResumable bool // include in-progress episodes
EnableRewatching bool // accepted but deferred (no-op)
DateCutoff *time.Time // only series with activity after this date
}
// NextUpResult is one row from the next-up query.
type NextUpResult struct {
ContentID string
SeriesID string
SeriesTitle string
SeasonNumber int
EpisodeNumber int
CompletedAt time.Time // when the preceding episode was completed
IsResumable bool // true if this is an in-progress item (enableResumable)
}
// NextUpRepository queries for next unwatched episodes per series.
type NextUpRepository struct {
pool *pgxpool.Pool
storeProvider userstore.UserStoreProvider
}
// NewNextUpRepository creates a NextUpRepository.
func NewNextUpRepository(pool *pgxpool.Pool, storeProvider userstore.UserStoreProvider) *NextUpRepository {
return &NextUpRepository{pool: pool, storeProvider: storeProvider}
}
// ListNextUp returns the next unwatched episode per series for the given user.
func (r *NextUpRepository) ListNextUp(ctx context.Context, q NextUpQuery) ([]NextUpResult, error) {
if r.storeProvider == nil || q.UserID <= 0 || q.ProfileID == "" {
return nil, nil
}
store, err := r.storeProvider.ForUser(ctx, q.UserID)
if err != nil {
return nil, fmt.Errorf("getting user store: %w", err)
}
// Get recently completed episodes (limit 100 for performance)
completedEntries, err := store.ListProgress(ctx, q.ProfileID, "completed", 100, 0)
if err != nil {
return nil, fmt.Errorf("listing completed progress: %w", err)
}
if len(completedEntries) == 0 {
if q.EnableResumable {
return r.listResumableFirstEpisodes(ctx, q)
}
return nil, nil
}
completedIDs := make([]string, 0, len(completedEntries))
for _, entry := range completedEntries {
completedIDs = append(completedIDs, entry.MediaItemID)
}
limit := q.Limit
if limit <= 0 {
limit = 20
}
query, args := buildListNextUpQuery(q, completedIDs, limit)
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("querying next-up episodes: %w", err)
}
defer rows.Close()
var results []NextUpResult
for rows.Next() {
var res NextUpResult
if err := rows.Scan(
&res.ContentID, &res.SeriesID, &res.SeriesTitle,
&res.SeasonNumber, &res.EpisodeNumber, &res.CompletedAt,
); err != nil {
return nil, fmt.Errorf("scanning next-up row: %w", err)
}
results = append(results, res)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating next-up rows: %w", err)
}
if q.EnableResumable {
resumable, rErr := r.listResumableFirstEpisodes(ctx, q)
if rErr != nil {
return nil, rErr
}
if q.SeriesID != "" && len(resumable) > 0 {
// Show-detail tile: the in-progress episode wins over whatever
// "next aired" row the completed-episodes branch produced.
// The user is mid-watching this episode; surfacing the
// following one would skip past their actual position.
return resumable, nil
}
// Global next-up: dedup by series, completed-next row takes priority.
seen := make(map[string]bool, len(results))
for _, res := range results {
seen[res.SeriesID] = true
}
for _, res := range resumable {
if !seen[res.SeriesID] {
results = append(results, res)
}
}
}
return results, nil
}
func buildListNextUpQuery(q NextUpQuery, completedIDs []string, limit int) (string, []interface{}) {
args := []interface{}{q.UserID, q.ProfileID, completedIDs, limit}
argIdx := 5
seriesFilter := ""
if q.SeriesID != "" {
seriesFilter = fmt.Sprintf(" AND e.series_id = $%d", argIdx)
args = append(args, q.SeriesID)
argIdx++
}
dateCutoffFilter := ""
if q.DateCutoff != nil {
dateCutoffFilter = fmt.Sprintf(" AND uwp.updated_at >= $%d", argIdx)
args = append(args, *q.DateCutoff)
argIdx++
}
// When resumable items are disabled, suppress next-up only if the series has
// newer in-progress activity than the most recent completed episode. Older
// partial watches should not block the user's current progression.
inProgressExclusion := ""
if !q.EnableResumable {
inProgressExclusion = `
,
eligible_series AS (
SELECT ce.*
FROM completed_episodes ce
WHERE NOT EXISTS (
SELECT 1 FROM user_watch_progress uwp_ip
JOIN episodes e_ip ON e_ip.content_id = uwp_ip.media_item_id
WHERE uwp_ip.user_id = $1
AND uwp_ip.profile_id = $2
AND uwp_ip.completed = FALSE
AND e_ip.series_id = ce.series_id
AND uwp_ip.updated_at > ce.updated_at
)
)`
}
sourceTable := "completed_episodes"
if !q.EnableResumable {
sourceTable = "eligible_series"
}
query := fmt.Sprintf(`
WITH completed_episodes AS (
SELECT DISTINCT ON (e.series_id)
e.series_id,
e.season_number,
e.episode_number,
uwp.updated_at
FROM user_watch_progress uwp
JOIN episodes e ON e.content_id = uwp.media_item_id
WHERE uwp.user_id = $1
AND uwp.profile_id = $2
AND uwp.completed = TRUE
AND uwp.media_item_id = ANY($3)
%s
%s
ORDER BY e.series_id, uwp.updated_at DESC
)
%s
SELECT
next_ep.content_id,
next_ep.series_id,
si.title,
next_ep.season_number,
next_ep.episode_number,
es.updated_at
FROM %s es
JOIN media_items si ON si.content_id = es.series_id
JOIN LATERAL (
SELECT e2.content_id, e2.series_id, e2.season_number, e2.episode_number
FROM episodes e2
WHERE e2.series_id = es.series_id
AND (e2.season_number, e2.episode_number) > (es.season_number, es.episode_number)
AND EXISTS (SELECT 1 FROM media_files mf WHERE mf.episode_id = e2.content_id AND mf.missing_since IS NULL)
AND NOT EXISTS (
SELECT 1 FROM user_watch_progress uwp2
WHERE uwp2.user_id = $1
AND uwp2.profile_id = $2
AND uwp2.media_item_id = e2.content_id
)
ORDER BY e2.season_number, e2.episode_number
LIMIT 1
) next_ep ON true
ORDER BY es.updated_at DESC
LIMIT $4`, seriesFilter, dateCutoffFilter, inProgressExclusion, sourceTable)
return query, args
}
// buildListResumableFirstEpisodesQuery builds the SQL + args for the
// resumable-first-episode lookup. Pulled out of listResumableFirstEpisodes
// so the SeriesID-driven gate-drop is unit-testable without a live database.
//
// When q.SeriesID is set the WHERE clause is scoped to that series and the
// "no completed episodes for this series" exclusion is dropped: the
// show-detail tile is supposed to surface the in-progress episode even when
// the user already finished earlier episodes of the same show. Without this
// gate-drop the endpoint silently falls through to buildListNextUpQuery's
// "next aired" row, which is the bug Codex flagged on PR #64.
//
// When q.SeriesID is empty (global /Shows/NextUp) the gate is preserved —
// series with completed episodes go through the main query, and this branch
// only contributes "started but no episode finished yet" series.
func buildListResumableFirstEpisodesQuery(q NextUpQuery, inProgressIDs []string) (string, []interface{}) {
args := []interface{}{q.UserID, q.ProfileID, inProgressIDs}
seriesFilter := ""
if q.SeriesID != "" {
args = append(args, q.SeriesID)
seriesFilter = fmt.Sprintf(" AND e.series_id = $%d", len(args))
}
completedSeriesGate := `
AND NOT EXISTS (
SELECT 1 FROM user_watch_progress uwp_c
JOIN episodes e_c ON e_c.content_id = uwp_c.media_item_id
WHERE uwp_c.user_id = $1
AND uwp_c.profile_id = $2
AND uwp_c.completed = TRUE
AND e_c.series_id = e.series_id
)`
if q.SeriesID != "" {
completedSeriesGate = ""
}
query := fmt.Sprintf(`
SELECT DISTINCT ON (e.series_id)
e.content_id,
e.series_id,
si.title,
e.season_number,
e.episode_number,
uwp.updated_at
FROM user_watch_progress uwp
JOIN episodes e ON e.content_id = uwp.media_item_id
JOIN media_items si ON si.content_id = e.series_id
WHERE uwp.user_id = $1
AND uwp.profile_id = $2
AND uwp.completed = FALSE
AND uwp.media_item_id = ANY($3)%s%s
ORDER BY e.series_id, uwp.updated_at DESC`, seriesFilter, completedSeriesGate)
return query, args
}
// listResumableFirstEpisodes finds in-progress episodes for the user.
//
// Two callers, two semantics, one query:
//
// - Global next-up (q.SeriesID == ""): only contribute series the user has
// never completed any episode of. Series with completed episodes go
// through buildListNextUpQuery's "next unwatched after the last
// completed" path; the resumable branch fills the gap for series that
// don't have a completed-episode anchor yet.
// - Show-detail tile (q.SeriesID != ""): the in-progress episode wins
// unconditionally, even when the user has already completed earlier
// episodes in the same series. The completed-then-mid-watching-the-
// next-one case is exactly what the show-detail "continue watching"
// tile is for, so the no-completed-episodes gate would defeat the
// endpoint's purpose. ListNextUp also flips its dedup priority to let
// this row win over the completed-next row from the main query.
func (r *NextUpRepository) listResumableFirstEpisodes(ctx context.Context, q NextUpQuery) ([]NextUpResult, error) {
store, err := r.storeProvider.ForUser(ctx, q.UserID)
if err != nil {
return nil, fmt.Errorf("getting user store: %w", err)
}
inProgressEntries, err := store.ListProgress(ctx, q.ProfileID, "in_progress", 100, 0)
if err != nil {
return nil, fmt.Errorf("listing in-progress: %w", err)
}
if len(inProgressEntries) == 0 {
return nil, nil
}
inProgressIDs := make([]string, 0, len(inProgressEntries))
for _, entry := range inProgressEntries {
inProgressIDs = append(inProgressIDs, entry.MediaItemID)
}
query, args := buildListResumableFirstEpisodesQuery(q, inProgressIDs)
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("querying resumable episodes: %w", err)
}
defer rows.Close()
var results []NextUpResult
for rows.Next() {
var res NextUpResult
res.IsResumable = true
if err := rows.Scan(
&res.ContentID, &res.SeriesID, &res.SeriesTitle,
&res.SeasonNumber, &res.EpisodeNumber, &res.CompletedAt,
); err != nil {
return nil, fmt.Errorf("scanning resumable row: %w", err)
}
results = append(results, res)
}
return results, rows.Err()
}