Files
silo-server/internal/collections/templates/validate.go
T

266 lines
8.7 KiB
Go

package templates
import (
"errors"
"fmt"
"net/url"
"strings"
"time"
)
// validate sanity-checks a template definition before it joins the registry.
// Failures here indicate a programming mistake in the curated catalog, so the
// registry panics on them at startup — easier to catch in tests than to debug
// from a confused admin UI.
func validate(t Template) error {
switch {
case strings.TrimSpace(t.ID) == "":
return errors.New("id is required")
case strings.TrimSpace(t.Title) == "":
return errors.New("title is required")
case t.Category == "":
return errors.New("category is required")
case t.MediaKind == "":
return errors.New("media_kind is required")
}
switch t.Source {
case SourceTMDB:
if t.TMDB == nil {
return errors.New("tmdb spec is required for tmdb source")
}
if t.Trakt != nil || t.MDBList != nil || t.TMDBDiscover != nil || t.TMDBCollection != nil {
return errors.New("only one source spec may be set")
}
return validateTMDB(*t.TMDB)
case SourceTrakt:
if t.Trakt == nil {
return errors.New("trakt spec is required for trakt source")
}
if t.TMDB != nil || t.MDBList != nil || t.TMDBDiscover != nil || t.TMDBCollection != nil {
return errors.New("only one source spec may be set")
}
return validateTrakt(*t.Trakt, t.RequiresProfile)
case SourceMDBList:
if t.MDBList == nil {
return errors.New("mdblist spec is required for mdblist source")
}
if t.TMDB != nil || t.Trakt != nil || t.TMDBDiscover != nil || t.TMDBCollection != nil {
return errors.New("only one source spec may be set")
}
return validateMDBList(*t.MDBList)
case SourceTMDBDiscover:
if t.TMDBDiscover == nil {
return errors.New("tmdb_discover spec is required for tmdb_discover source")
}
if t.TMDB != nil || t.Trakt != nil || t.MDBList != nil || t.TMDBCollection != nil {
return errors.New("only one source spec may be set")
}
return validateTMDBDiscover(*t.TMDBDiscover)
case SourceTMDBCollection:
if t.TMDBCollection == nil {
return errors.New("tmdb_collection spec is required for tmdb_collection source")
}
if t.TMDB != nil || t.Trakt != nil || t.MDBList != nil || t.TMDBDiscover != nil {
return errors.New("only one source spec may be set")
}
return validateTMDBCollection(*t.TMDBCollection)
default:
return fmt.Errorf("unknown source %q", t.Source)
}
}
func (r *Registry) validateBundleLocked(b Bundle) error {
switch {
case strings.TrimSpace(b.ID) == "":
return errors.New("id is required")
case strings.TrimSpace(b.Title) == "":
return errors.New("title is required")
case len(b.TemplateIDs) == 0:
return errors.New("template_ids is required")
}
seen := make(map[string]struct{}, len(b.TemplateIDs))
for _, id := range b.TemplateIDs {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
return errors.New("template_ids cannot contain empty IDs")
}
if _, exists := seen[trimmed]; exists {
return fmt.Errorf("duplicate template ID %q", trimmed)
}
seen[trimmed] = struct{}{}
idx, ok := r.byID[trimmed]
if !ok {
return fmt.Errorf("unknown template ID %q", trimmed)
}
if r.templates[idx].RequiresProfile {
return fmt.Errorf("template %q requires a profile", trimmed)
}
}
return nil
}
func validateTMDB(spec TMDBSpec) error {
switch spec.Preset {
case "trending":
switch spec.MediaType {
case "movie", "tv", "all":
default:
return fmt.Errorf("tmdb trending: media_type must be movie, tv, or all (got %q)", spec.MediaType)
}
switch spec.TimeWindow {
case "day", "week":
default:
return fmt.Errorf("tmdb trending: time_window must be day or week (got %q)", spec.TimeWindow)
}
case "popular", "top_rated":
switch spec.MediaType {
case "movie", "tv":
default:
return fmt.Errorf("tmdb %s: media_type must be movie or tv", spec.Preset)
}
if spec.TimeWindow != "" {
return fmt.Errorf("tmdb %s: time_window is not allowed", spec.Preset)
}
case "now_playing", "upcoming":
if spec.MediaType != "movie" {
return fmt.Errorf("tmdb %s requires media_type=movie", spec.Preset)
}
case "airing_today", "on_the_air":
if spec.MediaType != "tv" {
return fmt.Errorf("tmdb %s requires media_type=tv", spec.Preset)
}
default:
return fmt.Errorf("tmdb: unsupported preset %q", spec.Preset)
}
return nil
}
func validateTrakt(spec TraktSpec, requiresProfile bool) error {
switch spec.Preset {
case "trending", "popular":
if requiresProfile {
return errors.New("trakt: trending/popular do not require a profile")
}
case "recommended":
if !requiresProfile {
return errors.New("trakt: recommended must set requires_profile=true")
}
default:
return fmt.Errorf("trakt: unsupported preset %q", spec.Preset)
}
switch spec.MediaType {
case "movie", "tv":
default:
return fmt.Errorf("trakt: media_type must be movie or tv (got %q)", spec.MediaType)
}
return nil
}
// tmdbDiscoverSortByValues enumerates the sort_by values TMDB documents for
// /discover/movie and /discover/tv. Validation is intentionally lenient — we
// accept the documented set rather than introspecting per-media-type quirks
// because TMDB silently ignores irrelevant sorts.
var tmdbDiscoverSortByValues = map[string]struct{}{
"popularity.desc": {},
"popularity.asc": {},
"vote_average.desc": {},
"vote_average.asc": {},
"primary_release_date.desc": {},
"primary_release_date.asc": {},
"first_air_date.desc": {},
"first_air_date.asc": {},
"revenue.desc": {},
"revenue.asc": {},
"vote_count.desc": {},
"vote_count.asc": {},
}
func validateTMDBDiscover(spec TMDBDiscoverSpec) error {
switch spec.MediaType {
case "movie", "tv":
default:
return fmt.Errorf("tmdb_discover: media_type must be movie or tv (got %q)", spec.MediaType)
}
sortBy := strings.TrimSpace(spec.SortBy)
if sortBy == "" {
return errors.New("tmdb_discover: sort_by is required")
}
if _, ok := tmdbDiscoverSortByValues[sortBy]; !ok {
return fmt.Errorf("tmdb_discover: unsupported sort_by %q", sortBy)
}
if spec.VoteCountGte < 0 {
return fmt.Errorf("tmdb_discover: vote_count_gte must be >= 0 (got %d)", spec.VoteCountGte)
}
if spec.VoteAverageGte < 0 {
return fmt.Errorf("tmdb_discover: vote_average_gte must be >= 0 (got %v)", spec.VoteAverageGte)
}
if spec.ReleaseDateGte != "" {
if _, err := time.Parse("2006-01-02", spec.ReleaseDateGte); err != nil {
return fmt.Errorf("tmdb_discover: release_date_gte must be YYYY-MM-DD (got %q)", spec.ReleaseDateGte)
}
}
if spec.ReleaseDateLte != "" {
if _, err := time.Parse("2006-01-02", spec.ReleaseDateLte); err != nil {
return fmt.Errorf("tmdb_discover: release_date_lte must be YYYY-MM-DD (got %q)", spec.ReleaseDateLte)
}
}
for i, cert := range spec.Certifications {
if strings.TrimSpace(cert) == "" {
return fmt.Errorf("tmdb_discover: certifications[%d] must not be empty", i)
}
}
if spec.WithRuntimeGte < 0 {
return fmt.Errorf("tmdb_discover: with_runtime_gte must be >= 0 (got %d)", spec.WithRuntimeGte)
}
if spec.WithRuntimeLte < 0 {
return fmt.Errorf("tmdb_discover: with_runtime_lte must be >= 0 (got %d)", spec.WithRuntimeLte)
}
if spec.WithRuntimeGte > 0 && spec.WithRuntimeLte > 0 && spec.WithRuntimeGte > spec.WithRuntimeLte {
return fmt.Errorf("tmdb_discover: with_runtime_gte (%d) must be <= with_runtime_lte (%d)", spec.WithRuntimeGte, spec.WithRuntimeLte)
}
if lang := strings.TrimSpace(spec.OriginalLanguage); lang != "" && len(lang) != 2 {
return fmt.Errorf("tmdb_discover: original_language must be a 2-letter ISO 639-1 code (got %q)", spec.OriginalLanguage)
}
return nil
}
// validateTMDBCollection checks a TMDB collection franchise spec.
//
// CollectionID == 0 is a deliberate placeholder/sentinel: the catalog ships a
// generic "TMDB Franchise" template that admins fill in at apply-time. Such
// placeholder templates land in the gallery but the resulting collection's
// sync will fail loudly until the admin edits the source config and supplies
// a real TMDB collection ID (see syncTMDBFranchiseCollection in the catalog
// package). Validation therefore allows zero, but never negative IDs.
func validateTMDBCollection(spec TMDBCollectionSpec) error {
if spec.CollectionID < 0 {
return fmt.Errorf("tmdb_collection: collection_id must be >= 0 (got %d)", spec.CollectionID)
}
return nil
}
func validateMDBList(spec MDBListSpec) error {
trimmed := strings.TrimSpace(spec.URL)
if trimmed == "" {
// Empty URL is allowed; it means the template is a "bring your own URL"
// placeholder that simply opens the standard MDBList import form.
return nil
}
parsed, err := url.Parse(trimmed)
if err != nil {
return fmt.Errorf("mdblist url: %w", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("mdblist url: scheme must be http or https")
}
return nil
}