311 lines
11 KiB
Go
311 lines
11 KiB
Go
package catalog
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// CalendarEvent represents a single calendar entry — a movie release,
|
|
// episode air date, or season premiere.
|
|
type CalendarEvent struct {
|
|
ContentID string
|
|
Type string // "movie" | "episode" | "season_premiere"
|
|
Title string // movie title or series title
|
|
EpisodeTitle *string
|
|
SeriesID *string
|
|
SeasonNumber *int
|
|
EpisodeNumber *int
|
|
AirDate time.Time
|
|
AirTime *string
|
|
PosterPath string
|
|
PosterThumbhash string
|
|
IsPremiere bool
|
|
IsFinale bool
|
|
}
|
|
|
|
// CalendarFilter holds the parameters for a calendar query.
|
|
type CalendarFilter struct {
|
|
Start time.Time
|
|
End time.Time
|
|
Filter string // "all" | "favorites" | "watchlist"
|
|
LibraryID *int
|
|
AllowedLibraryIDs []int
|
|
DisabledLibraryIDs []int
|
|
MaxContentRating string
|
|
UserID int
|
|
ProfileID string
|
|
}
|
|
|
|
// CalendarRepository provides calendar queries across movies, episodes, and seasons.
|
|
type CalendarRepository struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
const calendarEventsOrderByClause = `ORDER BY air_date ASC, air_time ASC NULLS LAST, title ASC, season_number ASC NULLS FIRST, episode_number ASC NULLS FIRST`
|
|
|
|
// NewCalendarRepository creates a new CalendarRepository.
|
|
func NewCalendarRepository(pool *pgxpool.Pool) *CalendarRepository {
|
|
return &CalendarRepository{pool: pool}
|
|
}
|
|
|
|
// ListEvents returns all calendar events within the given date range,
|
|
// ordered by air_date ascending.
|
|
func (r *CalendarRepository) ListEvents(ctx context.Context, f CalendarFilter) ([]CalendarEvent, error) {
|
|
query, args := r.buildListEventsQuery(f)
|
|
|
|
rows, err := r.pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("calendar query: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var events []CalendarEvent
|
|
for rows.Next() {
|
|
var ev CalendarEvent
|
|
var episodeTitle, seriesID *string
|
|
var seasonNum, episodeNum *int
|
|
if err := rows.Scan(
|
|
&ev.ContentID, &ev.Type, &ev.Title, &episodeTitle, &seriesID,
|
|
&seasonNum, &episodeNum, &ev.AirDate, &ev.AirTime,
|
|
&ev.PosterPath, &ev.PosterThumbhash,
|
|
&ev.IsPremiere, &ev.IsFinale,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scanning calendar row: %w", err)
|
|
}
|
|
ev.EpisodeTitle = episodeTitle
|
|
ev.SeriesID = seriesID
|
|
ev.SeasonNumber = seasonNum
|
|
ev.EpisodeNumber = episodeNum
|
|
events = append(events, ev)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterating calendar rows: %w", err)
|
|
}
|
|
|
|
return events, nil
|
|
}
|
|
|
|
func (r *CalendarRepository) buildListEventsQuery(f CalendarFilter) (string, []any) {
|
|
var args []any
|
|
argIdx := 1
|
|
|
|
// Shared date range args.
|
|
startArg := argIdx
|
|
args = append(args, f.Start)
|
|
argIdx++
|
|
endArg := argIdx
|
|
args = append(args, f.End)
|
|
argIdx++
|
|
|
|
movieBranch := r.buildMovieBranch(startArg, endArg, f, &args, &argIdx)
|
|
filteredEpisodes := r.buildFilteredEpisodesCTE(startArg, endArg, f, &args, &argIdx)
|
|
filteredSeasons := r.buildFilteredSeasonsCTE(startArg, endArg, f, &args, &argIdx)
|
|
episodeBranch := r.buildEpisodeBranch()
|
|
seasonBranch := r.buildSeasonBranch()
|
|
|
|
query := fmt.Sprintf(`WITH filtered_episodes AS (%s),
|
|
episode_seasons AS (
|
|
SELECT DISTINCT series_id, season_number
|
|
FROM filtered_episodes
|
|
),
|
|
season_finales AS (
|
|
SELECT e.series_id, e.season_number, MAX(e.episode_number) AS max_episode_number
|
|
FROM episodes e
|
|
JOIN episode_seasons es ON es.series_id = e.series_id AND es.season_number = e.season_number
|
|
GROUP BY e.series_id, e.season_number
|
|
),
|
|
filtered_seasons AS (%s),
|
|
episode_one_with_air_date AS (
|
|
SELECT DISTINCT e.series_id, e.season_number
|
|
FROM episodes e
|
|
JOIN filtered_seasons fs ON fs.series_id = e.series_id AND fs.season_number = e.season_number
|
|
WHERE e.episode_number = 1 AND e.air_date IS NOT NULL
|
|
)
|
|
SELECT content_id, type, title, episode_title, series_id,
|
|
season_number, episode_number, air_date, air_time,
|
|
poster_path, poster_thumbhash,
|
|
is_premiere, is_finale
|
|
FROM (
|
|
%s UNION ALL %s UNION ALL %s
|
|
) combined
|
|
%s`,
|
|
filteredEpisodes, filteredSeasons, movieBranch, episodeBranch, seasonBranch, calendarEventsOrderByClause)
|
|
|
|
return query, args
|
|
}
|
|
|
|
func (r *CalendarRepository) buildMovieBranch(startArg, endArg int, f CalendarFilter, args *[]any, argIdx *int) string {
|
|
conditions := []string{
|
|
"mi.type = 'movie'",
|
|
fmt.Sprintf("mi.release_date BETWEEN $%d::date AND $%d::date", startArg, endArg),
|
|
}
|
|
r.appendLibraryExistsClauses("mi.content_id", f, &conditions, args, argIdx)
|
|
r.appendContentRatingClause("mi", f, &conditions, args, argIdx)
|
|
r.appendPersonalFilterClause("mi.content_id", f, &conditions, args, argIdx)
|
|
|
|
return fmt.Sprintf(`SELECT mi.content_id, 'movie'::text AS type,
|
|
mi.title, NULL::text AS episode_title, NULL::text AS series_id,
|
|
NULL::int AS season_number, NULL::int AS episode_number,
|
|
mi.release_date AS air_date, NULL::text AS air_time,
|
|
mi.poster_path, mi.poster_thumbhash,
|
|
FALSE AS is_premiere, FALSE AS is_finale
|
|
FROM media_items mi
|
|
WHERE %s`, strings.Join(conditions, " AND "))
|
|
}
|
|
|
|
func (r *CalendarRepository) buildFilteredEpisodesCTE(startArg, endArg int, f CalendarFilter, args *[]any, argIdx *int) string {
|
|
conditions := []string{
|
|
fmt.Sprintf("e.air_date BETWEEN $%d::date AND $%d::date", startArg, endArg),
|
|
"e.season_number > 0", // exclude specials
|
|
}
|
|
r.appendLibraryExistsClauses("e.series_id", f, &conditions, args, argIdx)
|
|
r.appendContentRatingClause("mi", f, &conditions, args, argIdx)
|
|
r.appendPersonalFilterClause("e.series_id", f, &conditions, args, argIdx)
|
|
|
|
return fmt.Sprintf(`SELECT e.content_id, e.series_id, e.season_number,
|
|
e.episode_number, e.title AS episode_title, e.air_date,
|
|
mi.title AS title, mi.air_time,
|
|
mi.poster_path, mi.poster_thumbhash
|
|
FROM episodes e
|
|
JOIN media_items mi ON mi.content_id = e.series_id
|
|
WHERE %s`, strings.Join(conditions, " AND "))
|
|
}
|
|
|
|
func (r *CalendarRepository) buildEpisodeBranch() string {
|
|
return `SELECT fe.content_id, 'episode'::text AS type,
|
|
fe.title, fe.episode_title, fe.series_id,
|
|
fe.season_number, fe.episode_number,
|
|
fe.air_date, fe.air_time,
|
|
fe.poster_path, fe.poster_thumbhash,
|
|
(fe.episode_number = 1) AS is_premiere,
|
|
(fe.episode_number = sf.max_episode_number) AS is_finale
|
|
FROM filtered_episodes fe
|
|
LEFT JOIN season_finales sf ON sf.series_id = fe.series_id AND sf.season_number = fe.season_number`
|
|
}
|
|
|
|
func (r *CalendarRepository) buildFilteredSeasonsCTE(startArg, endArg int, f CalendarFilter, args *[]any, argIdx *int) string {
|
|
conditions := []string{
|
|
fmt.Sprintf("s.air_date BETWEEN $%d::date AND $%d::date", startArg, endArg),
|
|
"s.season_number > 0", // exclude specials
|
|
}
|
|
r.appendLibraryExistsClauses("s.series_id", f, &conditions, args, argIdx)
|
|
r.appendContentRatingClause("mi", f, &conditions, args, argIdx)
|
|
r.appendPersonalFilterClause("s.series_id", f, &conditions, args, argIdx)
|
|
|
|
return fmt.Sprintf(`SELECT s.content_id, s.series_id, s.season_number,
|
|
s.title AS episode_title, s.air_date, mi.title AS title, mi.air_time,
|
|
COALESCE(NULLIF(s.poster_path, ''), mi.poster_path) AS poster_path,
|
|
COALESCE(NULLIF(s.poster_thumbhash, ''), mi.poster_thumbhash) AS poster_thumbhash
|
|
FROM seasons s
|
|
JOIN media_items mi ON mi.content_id = s.series_id
|
|
WHERE %s`, strings.Join(conditions, " AND "))
|
|
}
|
|
|
|
func (r *CalendarRepository) buildSeasonBranch() string {
|
|
return `SELECT fs.content_id, 'season_premiere'::text AS type,
|
|
fs.title, fs.episode_title, fs.series_id,
|
|
fs.season_number, NULL::int AS episode_number,
|
|
fs.air_date, fs.air_time,
|
|
fs.poster_path, fs.poster_thumbhash,
|
|
TRUE AS is_premiere, FALSE AS is_finale
|
|
FROM filtered_seasons fs
|
|
LEFT JOIN episode_one_with_air_date e1
|
|
ON e1.series_id = fs.series_id AND e1.season_number = fs.season_number
|
|
WHERE e1.series_id IS NULL`
|
|
}
|
|
|
|
// appendContentRatingClause adds content rating ceiling enforcement.
|
|
func (r *CalendarRepository) appendContentRatingClause(miAlias string, f CalendarFilter, conditions *[]string, args *[]any, argIdx *int) {
|
|
if f.MaxContentRating != "" {
|
|
applyAccessFilter(miAlias, AccessFilter{MaxContentRating: f.MaxContentRating}, conditions, args, argIdx)
|
|
}
|
|
}
|
|
|
|
// appendLibraryExistsClauses adds library-scoping via EXISTS subqueries.
|
|
// Using EXISTS instead of JOIN avoids row multiplication when an item
|
|
// belongs to multiple libraries, eliminating the need for DISTINCT.
|
|
func (r *CalendarRepository) appendLibraryExistsClauses(contentIDExpr string, f CalendarFilter, conditions *[]string, args *[]any, argIdx *int) {
|
|
if f.LibraryID != nil {
|
|
if f.AllowedLibraryIDs != nil {
|
|
allowed := false
|
|
for _, id := range f.AllowedLibraryIDs {
|
|
if id == *f.LibraryID {
|
|
allowed = true
|
|
break
|
|
}
|
|
}
|
|
if !allowed {
|
|
*conditions = append(*conditions, "1 = 0")
|
|
return
|
|
}
|
|
}
|
|
|
|
*conditions = append(*conditions, fmt.Sprintf(
|
|
"EXISTS (SELECT 1 FROM media_item_libraries mil WHERE mil.content_id = %s AND mil.media_folder_id = $%d)",
|
|
contentIDExpr, *argIdx,
|
|
))
|
|
*args = append(*args, *f.LibraryID)
|
|
*argIdx++
|
|
} else if f.AllowedLibraryIDs != nil {
|
|
if len(f.AllowedLibraryIDs) == 0 {
|
|
*conditions = append(*conditions, "1 = 0")
|
|
return
|
|
}
|
|
*conditions = append(*conditions, fmt.Sprintf(
|
|
"EXISTS (SELECT 1 FROM media_item_libraries mil WHERE mil.content_id = %s AND mil.media_folder_id = ANY($%d))",
|
|
contentIDExpr, *argIdx,
|
|
))
|
|
*args = append(*args, f.AllowedLibraryIDs)
|
|
*argIdx++
|
|
} else {
|
|
// No library restrictions but still need to verify the item is in at least one library.
|
|
*conditions = append(*conditions, fmt.Sprintf(
|
|
"EXISTS (SELECT 1 FROM media_item_libraries mil WHERE mil.content_id = %s)",
|
|
contentIDExpr,
|
|
))
|
|
}
|
|
|
|
if len(f.DisabledLibraryIDs) == 0 {
|
|
return
|
|
}
|
|
|
|
*conditions = append(*conditions, fmt.Sprintf(
|
|
"NOT EXISTS (SELECT 1 FROM media_item_libraries mil_disabled WHERE mil_disabled.content_id = %s AND mil_disabled.media_folder_id = ANY($%d))",
|
|
contentIDExpr, *argIdx,
|
|
))
|
|
*args = append(*args, f.DisabledLibraryIDs)
|
|
*argIdx++
|
|
}
|
|
|
|
// appendPersonalFilterClause adds favorites/watchlist EXISTS subqueries.
|
|
func (r *CalendarRepository) appendPersonalFilterClause(itemIDExpr string, f CalendarFilter, conditions *[]string, args *[]any, argIdx *int) {
|
|
switch f.Filter {
|
|
case "favorites":
|
|
userArg := *argIdx
|
|
*args = append(*args, f.UserID)
|
|
*argIdx++
|
|
profileArg := *argIdx
|
|
*args = append(*args, f.ProfileID)
|
|
*argIdx++
|
|
*conditions = append(*conditions, fmt.Sprintf(
|
|
"EXISTS (SELECT 1 FROM user_favorites uf WHERE uf.user_id = $%d AND uf.profile_id = $%d AND uf.media_item_id = %s)",
|
|
userArg, profileArg, itemIDExpr,
|
|
))
|
|
case "watchlist":
|
|
userArg := *argIdx
|
|
*args = append(*args, f.UserID)
|
|
*argIdx++
|
|
profileArg := *argIdx
|
|
*args = append(*args, f.ProfileID)
|
|
*argIdx++
|
|
*conditions = append(*conditions, fmt.Sprintf(
|
|
"EXISTS (SELECT 1 FROM user_watchlist uw WHERE uw.user_id = $%d AND uw.profile_id = $%d AND uw.media_item_id = %s)",
|
|
userArg, profileArg, itemIDExpr,
|
|
))
|
|
}
|
|
}
|