package requests import ( "context" "errors" "fmt" "log/slog" "strings" "time" "github.com/Silo-Server/silo-server/internal/idgen" "github.com/Silo-Server/silo-server/internal/metadata/tmdb" "golang.org/x/sync/errgroup" ) type TMDBClient interface { SearchMedia(ctx context.Context, mediaType, query string, page int) (*tmdb.MediaPage, error) DiscoverSection(ctx context.Context, section string, page int) (*tmdb.MediaPage, error) GetMediaDetail(ctx context.Context, mediaType string, id int) (*tmdb.MediaDetail, error) DiscoverPage(ctx context.Context, mediaType string, params tmdb.DiscoverParams, page int) (*tmdb.MediaPage, error) } type TMDBExternalIDClient interface { GetExternalIDs(ctx context.Context, mediaType string, id int) (*tmdb.ExternalIDs, error) } const externalIDHydrationConcurrency = 4 type SecretResolver interface { Get(ctx context.Context, key string) (string, error) } type MovieFulfillmentAdapter interface { SubmitMovie(ctx context.Context, req Request, integration Integration) (FulfillmentResult, error) } type SeriesFulfillmentAdapter interface { SubmitSeries(ctx context.Context, req Request, integration Integration) (FulfillmentResult, error) } type MovieStatusAdapter interface { CheckMovieStatus(ctx context.Context, req Request, integration Integration) (FulfillmentStatus, error) } type SeriesStatusAdapter interface { CheckSeriesStatus(ctx context.Context, req Request, integration Integration) (FulfillmentStatus, error) } type MovieIntegrationOptionsAdapter interface { ListMovieIntegrationOptions(ctx context.Context, integration Integration) (*IntegrationOptions, error) } type SeriesIntegrationOptionsAdapter interface { ListSeriesIntegrationOptions(ctx context.Context, integration Integration) (*IntegrationOptions, error) } type Service struct { store Store tmdb TMDBClient presence PresenceResolver secrets SecretResolver movieAdapter MovieFulfillmentAdapter seriesAdapter SeriesFulfillmentAdapter Now func() time.Time } type DiscoverySection struct { Key string `json:"key"` Title string `json:"title"` Page int `json:"page"` TotalPages int `json:"total_pages"` TotalResults int `json:"total_results"` Results []MediaResult `json:"results"` } func NewService(store Store, tmdbClient TMDBClient, presence PresenceResolver) *Service { return &Service{ store: store, tmdb: tmdbClient, presence: presence, Now: func() time.Time { return time.Now().UTC() }, } } func (s *Service) SetSecretResolver(resolver SecretResolver) { s.secrets = resolver } func (s *Service) SetFulfillmentAdapters(movie MovieFulfillmentAdapter, series SeriesFulfillmentAdapter) { s.movieAdapter = movie s.seriesAdapter = series } func (s *Service) Search(ctx context.Context, viewer Viewer, query string, mediaType MediaType, page int) (*MediaPage, error) { if s == nil || s.store == nil || s.tmdb == nil { return nil, fmt.Errorf("request service is not configured") } if err := s.ensureRequestsEnabled(ctx); err != nil { return nil, err } mediaType, err := normalizeSearchMediaType(mediaType) if err != nil { return nil, err } query = strings.TrimSpace(query) if query == "" { return nil, fmt.Errorf("%w: query is required", ErrInvalidInput) } raw, err := s.tmdb.SearchMedia(ctx, string(mediaType), query, page) if err != nil { return nil, err } return s.enrichPage(ctx, viewer, raw) } func (s *Service) Discover(ctx context.Context, viewer Viewer, section string, page int) (*DiscoverySection, error) { if s == nil || s.store == nil || s.tmdb == nil { return nil, fmt.Errorf("request service is not configured") } if err := s.ensureRequestsEnabled(ctx); err != nil { return nil, err } section = strings.TrimSpace(section) if _, ok := discoverySectionTitles[section]; !ok { return nil, fmt.Errorf("%w: invalid discovery section", ErrInvalidInput) } raw, err := s.tmdb.DiscoverSection(ctx, section, page) if err != nil { return nil, err } enriched, err := s.enrichPage(ctx, viewer, raw) if err != nil { return nil, err } return &DiscoverySection{ Key: section, Title: discoverySectionTitles[section], Page: enriched.Page, TotalPages: enriched.TotalPages, TotalResults: enriched.TotalResults, Results: enriched.Results, }, nil } func (s *Service) DiscoverAll(ctx context.Context, viewer Viewer) ([]DiscoverySection, error) { if s == nil || s.store == nil || s.tmdb == nil { return nil, fmt.Errorf("request service is not configured") } if err := s.ensureRequestsEnabled(ctx); err != nil { return nil, err } sections := make([]DiscoverySection, len(discoverySectionOrder)) group, gctx := errgroup.WithContext(ctx) group.SetLimit(externalIDHydrationConcurrency) for i, key := range discoverySectionOrder { i, key := i, key group.Go(func() error { section, err := s.Discover(gctx, viewer, key, 1) if err != nil { return err } sections[i] = *section return nil }) } if err := group.Wait(); err != nil { return nil, err } return sections, nil } // GetDetail fetches a TMDB detail payload and overlays the same availability / // request-state signals used by search and discovery. Recommendations carry // their own per-item state so the detail page can render them as request cards. func (s *Service) GetDetail(ctx context.Context, viewer Viewer, mediaType MediaType, tmdbID int) (*MediaDetail, error) { if s == nil || s.store == nil || s.tmdb == nil { return nil, fmt.Errorf("request service is not configured") } if err := s.ensureRequestsEnabled(ctx); err != nil { return nil, err } mediaType, err := normalizeMediaType(mediaType) if err != nil { return nil, err } if tmdbID <= 0 { return nil, fmt.Errorf("%w: tmdb id is required", ErrInvalidInput) } raw, err := s.tmdb.GetMediaDetail(ctx, string(mediaType), tmdbID) if err != nil { return nil, err } if raw == nil { return nil, ErrNotFound } policy, err := s.EffectivePolicy(ctx, viewer.UserID) if err != nil { return nil, err } primaryAvailable, err := s.lookupAvailable(ctx, mediaType, []int{raw.ID}) if err != nil { return nil, err } primaryRequests, err := s.store.ListActiveByTMDB(ctx, mediaType, []int{raw.ID}) if err != nil { return nil, err } detail := &MediaDetail{ MediaType: mediaType, TMDBID: raw.ID, IMDbID: raw.IMDbID, Title: raw.Title, OriginalTitle: raw.OriginalTitle, Tagline: raw.Tagline, Overview: raw.Overview, PosterPath: raw.PosterPath, BackdropPath: raw.BackdropPath, ReleaseDate: raw.ReleaseDate, Year: raw.Year, Runtime: raw.Runtime, Genres: raw.Genres, VoteAverage: raw.VoteAverage, VoteCount: raw.VoteCount, Status: raw.Status, Homepage: raw.Homepage, ContentRating: raw.ContentRating, ProductionCompanies: raw.ProductionCompanies, NumberOfSeasons: raw.NumberOfSeasons, NumberOfEpisodes: raw.NumberOfEpisodes, FirstAirDate: raw.FirstAirDate, LastAirDate: raw.LastAirDate, Networks: raw.Networks, Director: raw.Director, Creators: raw.Creators, Availability: availabilityValue(primaryAvailable[raw.ID]), Request: requestStateFor(viewer, policy, primaryAvailable[raw.ID], primaryRequests[raw.ID]), } if raw.TVDBID > 0 { tvdb := raw.TVDBID detail.TVDBID = &tvdb } if len(raw.Cast) > 0 { detail.Cast = make([]MediaCastMember, 0, len(raw.Cast)) for _, member := range raw.Cast { detail.Cast = append(detail.Cast, MediaCastMember{ Name: member.Name, Character: member.Character, ProfilePath: member.ProfilePath, Order: member.Order, }) } } if len(raw.Recommendations) > 0 { recPage := &tmdb.MediaPage{Results: raw.Recommendations} enriched, err := s.enrichPage(ctx, viewer, recPage) if err != nil { return nil, err } detail.Recommendations = enriched.Results } return detail, nil } func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input CreateRequestInput) (*Request, error) { if err := validateViewer(viewer); err != nil { return nil, err } if s == nil || s.store == nil { return nil, fmt.Errorf("request service is not configured") } if err := s.ensureRequestsEnabled(ctx); err != nil { return nil, err } normalized, err := normalizeCreateInput(input) if err != nil { return nil, err } s.enrichExternalIDs(ctx, &normalized) matches, err := s.lookupPresence(ctx, normalized.MediaType, []PresenceCandidate{createPresenceCandidate(normalized)}) if err != nil { return nil, err } if matches[normalized.TMDBID].Available { return nil, ErrAlreadyAvailable } active, err := s.store.ListActiveByTMDB(ctx, normalized.MediaType, []int{normalized.TMDBID}) if err != nil { return nil, err } if active[normalized.TMDBID] != nil { return nil, ErrAlreadyRequested } // Re-requesting media that previously failed (e.g., transient integration // error) should not leave stale failed rows behind in user/admin lists. if _, err := s.store.DeleteFailedByTMDB(ctx, normalized.MediaType, normalized.TMDBID); err != nil { return nil, err } policy, err := s.EffectivePolicy(ctx, viewer.UserID) if err != nil { return nil, err } if err := validateCreatePolicy(policy); err != nil { return nil, err } id, err := idgen.NextID() if err != nil { return nil, err } status := StatusPending if policy.AutoApprove { configured, err := s.integrationConfigured(ctx, normalized.MediaType) if err == nil && configured { status = StatusApproved } } record := CreateRequestRecord{ ID: id, Input: normalized, Status: status, Outcome: OutcomeActive, Requester: viewer, Now: s.now(), } if !policy.Unlimited { record.Quota = &QuotaCheck{ UserID: viewer.UserID, WindowStart: policy.WindowStart, MaxRequests: policy.MaxRequests, } } req, err := s.store.CreateRequest(ctx, record) if err != nil { if errors.Is(err, ErrAlreadyRequested) { return nil, ErrAlreadyRequested } if errors.Is(err, ErrQuotaExceeded) { return nil, QuotaError{ Used: policy.MaxRequests, Limit: policy.MaxRequests, WindowDays: policy.WindowDays, } } return nil, err } if req.Status == StatusApproved { return s.submitApprovedRequest(ctx, *req, viewer) } return req, nil } func (s *Service) ListMine(ctx context.Context, viewer Viewer, filter ListFilter) ([]*Request, error) { if viewer.UserID == 0 { return nil, ErrForbidden } if err := s.ensureRequestsEnabled(ctx); err != nil { return nil, err } return s.store.ListMine(ctx, viewer.UserID, normalizeListFilter(filter)) } func (s *Service) ListAdmin(ctx context.Context, viewer Viewer, filter ListFilter) ([]*Request, error) { if !viewer.IsAdmin { return nil, ErrForbidden } return s.store.ListAdmin(ctx, normalizeListFilter(filter)) } func (s *Service) GetRequest(ctx context.Context, viewer Viewer, id string) (*Request, error) { if err := s.ensureRequestsEnabled(ctx); err != nil { return nil, err } req, err := s.store.GetRequest(ctx, strings.TrimSpace(id)) if err != nil { return nil, err } if !viewer.IsAdmin && req.RequestedByUserID != viewer.UserID { return nil, ErrForbidden } return req, nil } func (s *Service) Approve(ctx context.Context, viewer Viewer, id string) (*Request, error) { if !viewer.IsAdmin { return nil, ErrForbidden } req, err := s.store.GetRequest(ctx, strings.TrimSpace(id)) if err != nil { return nil, err } if req.Outcome != OutcomeActive || req.Status != StatusPending { return nil, ErrInvalidState } approved, err := s.store.SetStatus(ctx, req.ID, StatusApproved, viewer) if err != nil { return nil, err } return s.submitApprovedRequest(ctx, *approved, viewer) } func (s *Service) Decline(ctx context.Context, viewer Viewer, id, reason string) (*Request, error) { if !viewer.IsAdmin { return nil, ErrForbidden } req, err := s.store.GetRequest(ctx, strings.TrimSpace(id)) if err != nil { return nil, err } // Approved requests are pending submission by the reconciler; declining // while submission may be in flight risks a divergent external state. if req.Outcome != OutcomeActive || req.Status == StatusApproved || req.Status == StatusCompleted || req.Status == StatusQueued || req.Status == StatusDownloading || strings.TrimSpace(req.ExternalID) != "" || strings.TrimSpace(req.IntegrationKind) != "" { return nil, ErrInvalidState } return s.store.SetOutcome(ctx, req.ID, OutcomeDeclined, viewer, reason) } // Cancel withdraws a request that has not yet been submitted to a downstream // integration. Owners can cancel their own pending requests; admins can cancel // any active request that has not entered the fulfillment pipeline. Requests // already approved, queued, downloading, or completed cannot be cancelled — // callers should decline (admin) or wait for completion in those cases. func (s *Service) Cancel(ctx context.Context, viewer Viewer, id, reason string) (*Request, error) { if viewer.UserID == 0 { return nil, ErrForbidden } if !viewer.IsAdmin { if err := s.ensureRequestsEnabled(ctx); err != nil { return nil, err } } req, err := s.store.GetRequest(ctx, strings.TrimSpace(id)) if err != nil { return nil, err } if !viewer.IsAdmin && req.RequestedByUserID != viewer.UserID { return nil, ErrForbidden } if req.Outcome != OutcomeActive || req.Status == StatusApproved || req.Status == StatusCompleted || req.Status == StatusQueued || req.Status == StatusDownloading || strings.TrimSpace(req.ExternalID) != "" || strings.TrimSpace(req.IntegrationKind) != "" { return nil, ErrInvalidState } return s.store.SetOutcome(ctx, req.ID, OutcomeCancelled, viewer, reason) } func (s *Service) Retry(ctx context.Context, viewer Viewer, id string) (*Request, error) { if !viewer.IsAdmin { return nil, ErrForbidden } req, err := s.store.GetRequest(ctx, strings.TrimSpace(id)) if err != nil { return nil, err } if req.Outcome != OutcomeFailed { return nil, ErrInvalidState } active, err := s.store.SetOutcome(ctx, req.ID, OutcomeActive, viewer, "retry requested") if err != nil { return nil, err } if active.Status == StatusApproved || active.Status == StatusQueued || active.Status == StatusDownloading { retry := *active retry.Status = StatusApproved return s.submitApprovedRequest(ctx, retry, viewer) } return active, nil } func (s *Service) ReconcileRequests(ctx context.Context, limit int) (ReconcileResult, error) { if s == nil || s.store == nil { return ReconcileResult{}, fmt.Errorf("request service is not configured") } if limit <= 0 || limit > 500 { limit = 100 } candidates, err := s.store.ListReconciliationCandidates(ctx, limit) if err != nil { return ReconcileResult{}, err } result := ReconcileResult{Checked: len(candidates)} for _, req := range candidates { if err := ctx.Err(); err != nil { return result, err } change, err := s.reconcileRequest(ctx, *req) if err != nil { slog.WarnContext(ctx, "request reconcile failed", "request_id", req.ID, "media_type", req.MediaType, "tmdb_id", req.TMDBID, "status", req.Status, "integration_kind", req.IntegrationKind, "err", err, ) result.Errors++ continue } switch change { case reconcileSubmitted: result.Submitted++ case reconcileDownloading: result.Downloading++ case reconcileCompleted: result.Completed++ case reconcileFailed: result.Failed++ case reconcileSkipped: result.Skipped++ } } return result, nil } func (s *Service) GetSettings(ctx context.Context, viewer Viewer) (Settings, error) { if !viewer.IsAdmin { return Settings{}, ErrForbidden } return s.store.GetSettings(ctx) } func (s *Service) GetFeatureStatus(ctx context.Context, _ Viewer) (FeatureStatus, error) { settings, err := s.store.GetSettings(ctx) if err != nil { return FeatureStatus{}, err } return FeatureStatus{RequestsEnabled: settings.RequestsEnabled}, nil } func (s *Service) ensureRequestsEnabled(ctx context.Context) error { settings, err := s.store.GetSettings(ctx) if err != nil { return err } if !settings.RequestsEnabled { return ErrRequestsDisabled } return nil } func (s *Service) UpdateSettings(ctx context.Context, viewer Viewer, settings Settings) (Settings, error) { if !viewer.IsAdmin { return Settings{}, ErrForbidden } if settings.GlobalMaxRequests < 0 || settings.GlobalWindowDays <= 0 { return Settings{}, fmt.Errorf("%w: invalid request settings", ErrInvalidInput) } return s.store.UpdateSettings(ctx, settings) } func (s *Service) GetUserLimit(ctx context.Context, viewer Viewer, userID int) (*UserLimit, error) { if !viewer.IsAdmin { return nil, ErrForbidden } if userID <= 0 { return nil, fmt.Errorf("%w: invalid user id", ErrInvalidInput) } limit, err := s.store.GetUserLimit(ctx, userID) if err != nil { return nil, err } if limit != nil { return limit, nil } return &UserLimit{ UserID: userID, LimitMode: LimitModeInherit, ApprovalMode: ApprovalModeInherit, }, nil } func (s *Service) UpsertUserLimit(ctx context.Context, viewer Viewer, limit UserLimit) (*UserLimit, error) { if !viewer.IsAdmin { return nil, ErrForbidden } normalized, err := normalizeUserLimit(limit) if err != nil { return nil, err } return s.store.UpsertUserLimit(ctx, normalized) } func (s *Service) ListIntegrations(ctx context.Context, viewer Viewer) ([]Integration, error) { if !viewer.IsAdmin { return nil, ErrForbidden } return s.store.ListIntegrations(ctx) } func (s *Service) UpsertIntegration(ctx context.Context, viewer Viewer, integration Integration) (*Integration, error) { if !viewer.IsAdmin { return nil, ErrForbidden } normalized, err := normalizeIntegration(integration) if err != nil { return nil, err } return s.store.UpsertIntegration(ctx, normalized) } func (s *Service) UpsertIntegrations(ctx context.Context, viewer Viewer, integrations []Integration) ([]Integration, error) { if !viewer.IsAdmin { return nil, ErrForbidden } normalized := make([]Integration, 0, len(integrations)) for _, integration := range integrations { item, err := normalizeIntegration(integration) if err != nil { return nil, err } normalized = append(normalized, item) } return s.store.UpsertIntegrations(ctx, normalized) } func (s *Service) LoadIntegrationOptions(ctx context.Context, viewer Viewer, integration Integration) (*IntegrationOptions, error) { if !viewer.IsAdmin { return nil, ErrForbidden } normalized, err := normalizeIntegrationConnection(integration) if err != nil { return nil, err } if err := s.applyStoredIntegrationCredentials(ctx, &normalized); err != nil { return nil, err } if strings.TrimSpace(normalized.BaseURL) == "" { return nil, fmt.Errorf("%w: base_url is required", ErrInvalidInput) } if strings.TrimSpace(normalized.APIKeyRef) == "" { return nil, fmt.Errorf("%w: api_key_ref is required", ErrInvalidInput) } resolved := normalized apiKey, err := s.resolveAPIKey(ctx, resolved) if err != nil { return nil, err } resolved.APIKeyRef = apiKey switch resolved.Kind { case "radarr": adapter, ok := s.movieAdapter.(MovieIntegrationOptionsAdapter) if !ok { return nil, fmt.Errorf("request radarr integration options are not configured") } return adapter.ListMovieIntegrationOptions(ctx, resolved) case "sonarr": adapter, ok := s.seriesAdapter.(SeriesIntegrationOptionsAdapter) if !ok { return nil, fmt.Errorf("request sonarr integration options are not configured") } return adapter.ListSeriesIntegrationOptions(ctx, resolved) default: return nil, fmt.Errorf("%w: invalid integration kind", ErrInvalidInput) } } func (s *Service) EffectivePolicy(ctx context.Context, userID int) (EffectivePolicy, error) { settings, err := s.store.GetSettings(ctx) if err != nil { return EffectivePolicy{}, err } limit, err := s.store.GetUserLimit(ctx, userID) if err != nil { return EffectivePolicy{}, err } policy := EffectivePolicy{ RequestsEnabled: settings.RequestsEnabled, MaxRequests: settings.GlobalMaxRequests, WindowDays: settings.GlobalWindowDays, AutoApprove: settings.GlobalAutoApprovalEnabled, } if policy.WindowDays <= 0 { policy.WindowDays = 7 } if limit != nil { switch limit.LimitMode { case LimitModeBlocked: policy.Blocked = true case LimitModeUnlimited: policy.Unlimited = true case LimitModeCustom: if limit.MaxRequests != nil { policy.MaxRequests = *limit.MaxRequests } if limit.WindowDays != nil && *limit.WindowDays > 0 { policy.WindowDays = *limit.WindowDays } } switch limit.ApprovalMode { case ApprovalModeBlocked: policy.Blocked = true case ApprovalModeManual: policy.AutoApprove = false case ApprovalModeAuto: policy.AutoApprove = true } } policy.WindowStart = s.now().AddDate(0, 0, -policy.WindowDays) if !policy.Unlimited { used, err := s.store.CountUserRequestsSince(ctx, userID, policy.WindowStart) if err != nil { return EffectivePolicy{}, err } policy.Used = used policy.Remaining = policy.MaxRequests - used if policy.Remaining < 0 { policy.Remaining = 0 } } return policy, nil } func (s *Service) enrichPage(ctx context.Context, viewer Viewer, raw *tmdb.MediaPage) (*MediaPage, error) { if raw == nil { return &MediaPage{Results: []MediaResult{}}, nil } policy, err := s.EffectivePolicy(ctx, viewer.UserID) if err != nil { return nil, err } idsByType := map[MediaType][]int{} for _, item := range raw.Results { mediaType, err := normalizeMediaType(MediaType(item.MediaType)) if err != nil || item.ID <= 0 { continue } idsByType[mediaType] = append(idsByType[mediaType], item.ID) } available := map[MediaType]map[int]bool{} active := map[MediaType]map[int]*Request{} for mediaType, ids := range idsByType { presence, err := s.lookupAvailable(ctx, mediaType, ids) if err != nil { return nil, err } available[mediaType] = presence requests, err := s.store.ListActiveByTMDB(ctx, mediaType, ids) if err != nil { return nil, err } active[mediaType] = requests } out := &MediaPage{ Page: raw.Page, TotalPages: raw.TotalPages, TotalResults: raw.TotalResults, Results: make([]MediaResult, 0, len(raw.Results)), } for _, item := range raw.Results { mediaType, err := normalizeMediaType(MediaType(item.MediaType)) if err != nil || item.ID <= 0 { continue } isAvailable := available[mediaType][item.ID] activeRequest := active[mediaType][item.ID] out.Results = append(out.Results, MediaResult{ MediaType: mediaType, TMDBID: item.ID, Title: item.Title, Year: item.Year, Overview: item.Overview, PosterPath: item.PosterPath, BackdropPath: item.BackdropPath, ReleaseDate: item.ReleaseDate, Popularity: item.Popularity, VoteAverage: item.VoteAverage, Availability: availabilityValue(isAvailable), Request: requestStateFor(viewer, policy, isAvailable, activeRequest), }) } return out, nil } func (s *Service) lookupPresence(ctx context.Context, mediaType MediaType, candidates []PresenceCandidate) (map[int]PresenceMatch, error) { if s.presence == nil { return map[int]PresenceMatch{}, nil } return s.presence.Lookup(ctx, mediaType, candidates) } func availabilityBoolMap(matches map[int]PresenceMatch) map[int]bool { out := map[int]bool{} for id, match := range matches { out[id] = match.Available } return out } func requestPresenceCandidate(req Request) PresenceCandidate { candidate := PresenceCandidate{ TMDBID: req.TMDBID, IMDbID: strings.TrimSpace(req.IMDbID), } if req.TVDBID != nil && *req.TVDBID > 0 { tvdbID := *req.TVDBID candidate.TVDBID = &tvdbID } return candidate } func createPresenceCandidate(input CreateRequestInput) PresenceCandidate { candidate := PresenceCandidate{ TMDBID: input.TMDBID, IMDbID: strings.TrimSpace(input.IMDbID), } if input.TVDBID != nil && *input.TVDBID > 0 { tvdbID := *input.TVDBID candidate.TVDBID = &tvdbID } return candidate } func (s *Service) hydratePresenceCandidate(ctx context.Context, mediaType MediaType, candidate PresenceCandidate) PresenceCandidate { if candidate.TMDBID <= 0 { return candidate } client, ok := s.tmdb.(TMDBExternalIDClient) if !ok { return candidate } externalIDs, err := client.GetExternalIDs(ctx, tmdbMediaType(mediaType), candidate.TMDBID) if err != nil || externalIDs == nil { return candidate } if candidate.IMDbID == "" { candidate.IMDbID = strings.TrimSpace(externalIDs.IMDbID) } if candidate.TVDBID == nil && externalIDs.TVDBID > 0 { tvdbID := externalIDs.TVDBID candidate.TVDBID = &tvdbID } return candidate } func (s *Service) hydratePresenceCandidates(ctx context.Context, mediaType MediaType, candidates []PresenceCandidate) []PresenceCandidate { if len(candidates) == 0 { return candidates } if _, ok := s.tmdb.(TMDBExternalIDClient); !ok { return candidates } hydrated := append([]PresenceCandidate(nil), candidates...) if externalIDHydrationConcurrency <= 1 { for i := range hydrated { if ctx.Err() != nil { return hydrated } hydrated[i] = s.hydratePresenceCandidate(ctx, mediaType, hydrated[i]) } return hydrated } group, groupCtx := errgroup.WithContext(ctx) group.SetLimit(externalIDHydrationConcurrency) for i := range hydrated { if groupCtx.Err() != nil { break } i := i group.Go(func() error { if err := groupCtx.Err(); err != nil { return err } hydrated[i] = s.hydratePresenceCandidate(groupCtx, mediaType, hydrated[i]) return nil }) } _ = group.Wait() return hydrated } func tmdbMediaType(mediaType MediaType) string { if mediaType == MediaTypeSeries { return "tv" } return "movie" } func (s *Service) lookupAvailable(ctx context.Context, mediaType MediaType, ids []int) (map[int]bool, error) { if s.presence == nil { return map[int]bool{}, nil } candidates := make([]PresenceCandidate, 0, len(ids)) for _, id := range ids { if id > 0 { candidates = append(candidates, PresenceCandidate{TMDBID: id}) } } candidates = s.hydratePresenceCandidates(ctx, mediaType, candidates) matches, err := s.lookupPresence(ctx, mediaType, candidates) if err != nil { return nil, err } return availabilityBoolMap(matches), nil } func (s *Service) enrichExternalIDs(ctx context.Context, input *CreateRequestInput) { if input == nil { return } client, ok := s.tmdb.(TMDBExternalIDClient) if !ok { return } externalIDs, err := client.GetExternalIDs(ctx, tmdbMediaType(input.MediaType), input.TMDBID) if err != nil || externalIDs == nil { return } if input.IMDbID == "" { input.IMDbID = strings.TrimSpace(externalIDs.IMDbID) } if input.TVDBID == nil && externalIDs.TVDBID > 0 { tvdbID := externalIDs.TVDBID input.TVDBID = &tvdbID } } func (s *Service) integrationConfigured(ctx context.Context, mediaType MediaType) (bool, error) { integration, err := s.integrationForMediaType(ctx, mediaType) if err != nil { return false, err } if integration == nil || !integrationIsConfigured(*integration) { return false, nil } apiKey, err := s.resolveAPIKey(ctx, *integration) if err != nil { return false, err } return apiKey != "", nil } func (s *Service) integrationForMediaType(ctx context.Context, mediaType MediaType) (*Integration, error) { want := integrationKindForMediaType(mediaType) integrations, err := s.store.ListIntegrations(ctx) if err != nil { return nil, err } for _, integration := range integrations { if integration.Kind == want { integration := integration return &integration, nil } } return nil, nil } func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor Viewer) (*Request, error) { if req.Outcome != OutcomeActive || req.Status != StatusApproved { return &req, nil } integration, err := s.integrationForMediaType(ctx, req.MediaType) if err != nil { return nil, err } if integration == nil || !integrationIsConfigured(*integration) { return &req, nil } resolved := *integration apiKey, err := s.resolveAPIKey(ctx, resolved) if err != nil { return s.markSubmissionFailed(ctx, req.ID, actor, err) } if apiKey == "" { return &req, nil } resolved.APIKeyRef = apiKey var result FulfillmentResult switch req.MediaType { case MediaTypeMovie: if s.movieAdapter == nil { return &req, nil } result, err = s.movieAdapter.SubmitMovie(ctx, req, resolved) case MediaTypeSeries: if s.seriesAdapter == nil { return &req, nil } result, err = s.seriesAdapter.SubmitSeries(ctx, req, resolved) default: return &req, nil } if err != nil { return s.markSubmissionFailed(ctx, req.ID, actor, err) } if result.IntegrationKind == "" { result.IntegrationKind = resolved.Kind } return s.store.MarkQueued(ctx, req.ID, QueueUpdate{ IntegrationKind: result.IntegrationKind, ExternalID: result.ExternalID, ExternalStatus: result.ExternalStatus, }, actor) } func (s *Service) markSubmissionFailed(ctx context.Context, requestID string, actor Viewer, submitErr error) (*Request, error) { failed, err := s.store.SetOutcome(ctx, requestID, OutcomeFailed, actor, submitErr.Error()) if err != nil { return nil, fmt.Errorf("submit request failed: %w; mark failed: %v", submitErr, err) } return failed, nil } type reconcileChange string const ( reconcileUnchanged reconcileChange = "unchanged" reconcileSkipped reconcileChange = "skipped" reconcileSubmitted reconcileChange = "submitted" reconcileDownloading reconcileChange = "downloading" reconcileCompleted reconcileChange = "completed" reconcileFailed reconcileChange = "failed" ) func (s *Service) reconcileRequest(ctx context.Context, req Request) (reconcileChange, error) { completed, err := s.requestAvailable(ctx, req) if err != nil { return reconcileUnchanged, err } if completed { if req.Status == StatusCompleted { return reconcileUnchanged, nil } if _, err := s.store.SetStatus(ctx, req.ID, StatusCompleted, Viewer{}); err != nil { return reconcileUnchanged, err } return reconcileCompleted, nil } if req.Status == StatusApproved { updated, err := s.submitApprovedRequest(ctx, req, Viewer{}) if err != nil { return reconcileUnchanged, err } switch { case updated.Outcome == OutcomeFailed: return reconcileFailed, nil case updated.Status == StatusQueued: return reconcileSubmitted, nil default: return reconcileSkipped, nil } } status, err := s.checkFulfillmentStatus(ctx, req) if err != nil { return reconcileUnchanged, err } if status.Status == "" && status.Outcome == "" { return reconcileSkipped, nil } if status.Outcome == OutcomeFailed { message := strings.TrimSpace(status.Message) if message == "" { message = strings.TrimSpace(status.ExternalStatus) } if message == "" { message = "external fulfillment failed" } if _, err := s.store.SetOutcome(ctx, req.ID, OutcomeFailed, Viewer{}, message); err != nil { return reconcileUnchanged, err } return reconcileFailed, nil } if status.Status == StatusDownloading && req.Status != StatusDownloading { if _, err := s.store.SetStatus(ctx, req.ID, StatusDownloading, Viewer{}); err != nil { return reconcileUnchanged, err } return reconcileDownloading, nil } return reconcileUnchanged, nil } func (s *Service) requestAvailable(ctx context.Context, req Request) (bool, error) { matches, err := s.lookupPresence(ctx, req.MediaType, []PresenceCandidate{requestPresenceCandidate(req)}) if err != nil { return false, err } return matches[req.TMDBID].Available, nil } func (s *Service) checkFulfillmentStatus(ctx context.Context, req Request) (FulfillmentStatus, error) { integration, err := s.integrationForMediaType(ctx, req.MediaType) if err != nil { return FulfillmentStatus{}, err } if integration == nil || !integrationIsConfigured(*integration) { return FulfillmentStatus{}, nil } resolved := *integration apiKey, err := s.resolveAPIKey(ctx, resolved) if err != nil { return FulfillmentStatus{}, err } if apiKey == "" { return FulfillmentStatus{}, nil } resolved.APIKeyRef = apiKey switch req.MediaType { case MediaTypeMovie: checker, ok := s.movieAdapter.(MovieStatusAdapter) if !ok { return FulfillmentStatus{}, nil } return checker.CheckMovieStatus(ctx, req, resolved) case MediaTypeSeries: checker, ok := s.seriesAdapter.(SeriesStatusAdapter) if !ok { return FulfillmentStatus{}, nil } return checker.CheckSeriesStatus(ctx, req, resolved) default: return FulfillmentStatus{}, nil } } func (s *Service) resolveAPIKey(ctx context.Context, integration Integration) (string, error) { value := strings.TrimSpace(integration.APIKeyRef) if value == "" || s.secrets == nil { return value, nil } resolved, err := s.secrets.Get(ctx, value) if err != nil { return "", err } resolved = strings.TrimSpace(resolved) if resolved == "" { return value, nil } return resolved, nil } func integrationKindForMediaType(mediaType MediaType) string { if mediaType == MediaTypeSeries { return "sonarr" } return "radarr" } func integrationIsConfigured(integration Integration) bool { return integration.Enabled && strings.TrimSpace(integration.BaseURL) != "" && strings.TrimSpace(integration.APIKeyRef) != "" && strings.TrimSpace(integration.RootFolder) != "" && integration.QualityProfileID != nil } func (s *Service) now() time.Time { if s.Now != nil { return s.Now() } return time.Now().UTC() } func requestStateFor(viewer Viewer, policy EffectivePolicy, available bool, req *Request) RequestState { if req != nil { state := RequestState{ Status: req.Status, Requestable: false, Reason: "already_requested", } if viewer.IsAdmin || req.RequestedByUserID == viewer.UserID { state.RequestID = req.ID } return state } switch { case available: return RequestState{Requestable: false, Reason: "already_available"} case !policy.RequestsEnabled: return RequestState{Requestable: false, Reason: "requests_disabled"} case policy.Blocked: return RequestState{Requestable: false, Reason: "blocked"} case !policy.Unlimited && policy.Used >= policy.MaxRequests: return RequestState{Requestable: false, Reason: "quota_exceeded"} default: return RequestState{Requestable: true} } } func validateCreatePolicy(policy EffectivePolicy) error { switch { case !policy.RequestsEnabled: return ErrRequestsDisabled case policy.Blocked: return ErrUserBlocked case !policy.Unlimited && policy.Used >= policy.MaxRequests: return QuotaError{Used: policy.Used, Limit: policy.MaxRequests, WindowDays: policy.WindowDays} default: return nil } } func validateViewer(viewer Viewer) error { if viewer.UserID == 0 { return ErrForbidden } if strings.TrimSpace(viewer.ProfileID) == "" { return fmt.Errorf("%w: profile is required", ErrInvalidInput) } return nil } func normalizeCreateInput(input CreateRequestInput) (CreateRequestInput, error) { mediaType, err := normalizeMediaType(input.MediaType) if err != nil { return CreateRequestInput{}, err } input.MediaType = mediaType input.Title = strings.TrimSpace(input.Title) input.IMDbID = strings.TrimSpace(input.IMDbID) input.Overview = strings.TrimSpace(input.Overview) input.PosterPath = strings.TrimSpace(input.PosterPath) input.BackdropPath = strings.TrimSpace(input.BackdropPath) if input.TMDBID <= 0 { return CreateRequestInput{}, fmt.Errorf("%w: tmdb_id is required", ErrInvalidInput) } if input.Title == "" { return CreateRequestInput{}, fmt.Errorf("%w: title is required", ErrInvalidInput) } return input, nil } func normalizeUserLimit(limit UserLimit) (UserLimit, error) { if limit.UserID <= 0 { return UserLimit{}, fmt.Errorf("%w: invalid user id", ErrInvalidInput) } switch limit.LimitMode { case "", LimitModeInherit: limit.LimitMode = LimitModeInherit limit.MaxRequests = nil limit.WindowDays = nil case LimitModeCustom: if limit.MaxRequests == nil || limit.WindowDays == nil || *limit.MaxRequests < 0 || *limit.WindowDays <= 0 { return UserLimit{}, fmt.Errorf("%w: custom limits require max_requests >= 0 and window_days > 0", ErrInvalidInput) } case LimitModeUnlimited: limit.MaxRequests = nil limit.WindowDays = nil case LimitModeBlocked: limit.MaxRequests = nil limit.WindowDays = nil default: return UserLimit{}, fmt.Errorf("%w: invalid limit mode", ErrInvalidInput) } switch limit.ApprovalMode { case "", ApprovalModeInherit: limit.ApprovalMode = ApprovalModeInherit case ApprovalModeManual, ApprovalModeAuto, ApprovalModeBlocked: default: return UserLimit{}, fmt.Errorf("%w: invalid approval mode", ErrInvalidInput) } return limit, nil } func normalizeIntegration(integration Integration) (Integration, error) { var err error integration, err = normalizeIntegrationConnection(integration) if err != nil { return Integration{}, err } integration.RootFolder = strings.TrimSpace(integration.RootFolder) if integration.QualityProfileID != nil && *integration.QualityProfileID <= 0 { return Integration{}, fmt.Errorf("%w: quality_profile_id must be positive", ErrInvalidInput) } filteredTags := make([]int, 0, len(integration.Tags)) for _, tag := range integration.Tags { if tag > 0 { filteredTags = append(filteredTags, tag) } } integration.Tags = filteredTags if integration.Options == nil { integration.Options = map[string]any{} } return integration, nil } func normalizeIntegrationConnection(integration Integration) (Integration, error) { integration.Kind = strings.ToLower(strings.TrimSpace(integration.Kind)) switch integration.Kind { case "radarr", "sonarr": default: return Integration{}, fmt.Errorf("%w: invalid integration kind", ErrInvalidInput) } integration.BaseURL = strings.TrimRight(strings.TrimSpace(integration.BaseURL), "/") integration.APIKeyRef = strings.TrimSpace(integration.APIKeyRef) if integration.Options == nil { integration.Options = map[string]any{} } return integration, nil } func (s *Service) applyStoredIntegrationCredentials(ctx context.Context, integration *Integration) error { if integration == nil || s.store == nil { return nil } if strings.TrimSpace(integration.BaseURL) != "" && strings.TrimSpace(integration.APIKeyRef) != "" { return nil } stored, err := s.store.ListIntegrations(ctx) if err != nil { return err } for _, candidate := range stored { if candidate.Kind != integration.Kind { continue } if strings.TrimSpace(integration.BaseURL) == "" { integration.BaseURL = candidate.BaseURL } if strings.TrimSpace(integration.APIKeyRef) == "" { integration.APIKeyRef = candidate.APIKeyRef } return nil } return nil } func normalizeMediaType(mediaType MediaType) (MediaType, error) { switch MediaType(strings.ToLower(strings.TrimSpace(string(mediaType)))) { case MediaTypeMovie: return MediaTypeMovie, nil case MediaTypeSeries, "tv": return MediaTypeSeries, nil default: return "", ErrInvalidMediaType } } func normalizeSearchMediaType(mediaType MediaType) (MediaType, error) { switch MediaType(strings.ToLower(strings.TrimSpace(string(mediaType)))) { case "", MediaTypeAll: return MediaTypeAll, nil case MediaTypeMovie: return MediaTypeMovie, nil case MediaTypeSeries, "tv": return MediaTypeSeries, nil default: return "", ErrInvalidMediaType } } const ( defaultRequestListLimit = 50 maxRequestListLimit = 100 ) func normalizeListFilter(filter ListFilter) ListFilter { if filter.Limit <= 0 { filter.Limit = defaultRequestListLimit } if filter.Limit > maxRequestListLimit { filter.Limit = maxRequestListLimit } if filter.Offset < 0 { filter.Offset = 0 } return filter } func availabilityValue(available bool) Availability { if available { return AvailabilityAvailable } return AvailabilityMissing } var discoverySectionOrder = []string{ "trending_movies", "trending_series", "popular_movies", "popular_series", "upcoming_movies", "on_air_series", } var discoverySectionTitles = map[string]string{ "trending_movies": "Trending Movies", "trending_series": "Trending Series", "popular_movies": "Popular Movies", "popular_series": "Popular Series", "upcoming_movies": "Upcoming Movies", "on_air_series": "On Air Series", }