CreateRequest previously read the user's request count outside the insert transaction, so two concurrent submissions at MaxRequests-1 could both pass the quota gate and end up at MaxRequests+1. Move the count inside the same transaction as the insert and acquire a per-user advisory lock so concurrent inserts serialize. The store reports ErrQuotaExceeded when the racing path catches the user at the limit and the service maps it back to QuotaError. normalizeListFilter previously reset limit to 50 when callers asked for more than 100, which is surprising. Clamp to the cap instead so a request for 150 returns 100 and a request for 1_000_000 still cannot hit the database with an unbounded scan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1310 lines
36 KiB
Go
1310 lines
36 KiB
Go
package requests
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"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")
|
|
}
|
|
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")
|
|
}
|
|
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) {
|
|
sections := make([]DiscoverySection, 0, len(discoverySectionOrder))
|
|
for _, key := range discoverySectionOrder {
|
|
section, err := s.Discover(ctx, viewer, key, 1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sections = append(sections, *section)
|
|
}
|
|
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")
|
|
}
|
|
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
|
|
}
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
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) {
|
|
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
|
|
}
|
|
if req.Outcome != OutcomeActive ||
|
|
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)
|
|
}
|
|
|
|
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 {
|
|
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) 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 := integration.Tags[:0]
|
|
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",
|
|
}
|