266 lines
8.7 KiB
Go
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
|
|
}
|