- Add plans for date-named episodes and Jellyfin autoscan compat - Update requests handlers, service, and UI hooks - Remove Makefile.local.example
1416 lines
40 KiB
Go
1416 lines
40 KiB
Go
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",
|
|
}
|