Merge pull request #7 from Silo-Server/t3code/discover-studios-networks-genres-clean

feat(requests): add media request system
This commit is contained in:
Quick
2026-05-25 10:56:57 -04:00
committed by GitHub
70 changed files with 18750 additions and 92 deletions
+8
View File
@@ -110,6 +110,14 @@ func (l *PGLibraryRefreshItemLister) ListLibraryItems(ctx context.Context, libra
OR COALESCE(mi.logo_path, '') LIKE '%//logo/%'
OR mi.refresh_failures > 0
OR mi.episode_metadata_incomplete = TRUE
OR (
LOWER(TRIM(COALESCE(mi.status, ''))) = 'matched'
AND COALESCE(mi.tmdb_id, '') = ''
AND (
COALESCE(mi.tvdb_id, '') <> ''
OR COALESCE(mi.imdb_id, '') <> ''
)
)
OR EXISTS (
SELECT 1
FROM stale_media_ids smi
+2
View File
@@ -1025,6 +1025,8 @@ var sensitiveSettingKeys = map[string]bool{
"tmdb.api_key": true,
"introdb.api_key": true,
"mdblist.api_key": true,
"requests.radarr.api_key": true,
"requests.sonarr.api_key": true,
"watchsync.trakt.client_id": true,
"watchsync.trakt.client_secret": true,
"watchsync.simkl.client_id": true,
+618
View File
@@ -0,0 +1,618 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
)
type RequestService interface {
Search(ctx context.Context, viewer mediarequests.Viewer, query string, mediaType mediarequests.MediaType, page int) (*mediarequests.MediaPage, error)
Discover(ctx context.Context, viewer mediarequests.Viewer, section string, page int) (*mediarequests.DiscoverySection, error)
DiscoverAll(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverySection, error)
GetDetail(ctx context.Context, viewer mediarequests.Viewer, mediaType mediarequests.MediaType, tmdbID int) (*mediarequests.MediaDetail, error)
CreateRequest(ctx context.Context, viewer mediarequests.Viewer, input mediarequests.CreateRequestInput) (*mediarequests.Request, error)
ListMine(ctx context.Context, viewer mediarequests.Viewer, filter mediarequests.ListFilter) ([]*mediarequests.Request, error)
ListAdmin(ctx context.Context, viewer mediarequests.Viewer, filter mediarequests.ListFilter) ([]*mediarequests.Request, error)
GetRequest(ctx context.Context, viewer mediarequests.Viewer, id string) (*mediarequests.Request, error)
Approve(ctx context.Context, viewer mediarequests.Viewer, id string) (*mediarequests.Request, error)
Decline(ctx context.Context, viewer mediarequests.Viewer, id, reason string) (*mediarequests.Request, error)
Cancel(ctx context.Context, viewer mediarequests.Viewer, id, reason string) (*mediarequests.Request, error)
Retry(ctx context.Context, viewer mediarequests.Viewer, id string) (*mediarequests.Request, error)
GetSettings(ctx context.Context, viewer mediarequests.Viewer) (mediarequests.Settings, error)
UpdateSettings(ctx context.Context, viewer mediarequests.Viewer, settings mediarequests.Settings) (mediarequests.Settings, error)
GetUserLimit(ctx context.Context, viewer mediarequests.Viewer, userID int) (*mediarequests.UserLimit, error)
UpsertUserLimit(ctx context.Context, viewer mediarequests.Viewer, limit mediarequests.UserLimit) (*mediarequests.UserLimit, error)
ListIntegrations(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.Integration, error)
UpsertIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error)
UpsertIntegrations(ctx context.Context, viewer mediarequests.Viewer, integrations []mediarequests.Integration) ([]mediarequests.Integration, error)
LoadIntegrationOptions(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error)
ListStudios(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
ListNetworks(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
ListGenres(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
BrowseStudio(ctx context.Context, viewer mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error)
BrowseNetwork(ctx context.Context, viewer mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error)
BrowseGenre(ctx context.Context, viewer mediarequests.Viewer, slug string, mediaType mediarequests.MediaType, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error)
}
type RequestsHandler struct {
service RequestService
}
func NewRequestsHandler(service RequestService) *RequestsHandler {
return &RequestsHandler{service: service}
}
func (h *RequestsHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
page, ok := parsePositiveIntQuery(w, r, "page", 1)
if !ok {
return
}
result, err := h.service.Search(
r.Context(),
viewer,
r.URL.Query().Get("q"),
mediarequests.MediaType(r.URL.Query().Get("media_type")),
page,
)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *RequestsHandler) HandleDiscover(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
sections, err := h.service.DiscoverAll(r.Context(), viewer)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, struct {
Sections []mediarequests.DiscoverySection `json:"sections"`
}{Sections: sections})
}
func (h *RequestsHandler) HandleDiscoverSection(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
page, ok := parsePositiveIntQuery(w, r, "page", 1)
if !ok {
return
}
section, err := h.service.Discover(r.Context(), viewer, chi.URLParam(r, "section"), page)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, section)
}
func (h *RequestsHandler) HandleListStudios(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
studios, err := h.service.ListStudios(r.Context(), viewer)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, struct {
Studios []mediarequests.DiscoverBrandCard `json:"studios"`
}{Studios: studios})
}
func (h *RequestsHandler) HandleListNetworks(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
networks, err := h.service.ListNetworks(r.Context(), viewer)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, struct {
Networks []mediarequests.DiscoverBrandCard `json:"networks"`
}{Networks: networks})
}
func (h *RequestsHandler) HandleListGenres(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
genres, err := h.service.ListGenres(r.Context(), viewer)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, struct {
Genres []mediarequests.DiscoverBrandCard `json:"genres"`
}{Genres: genres})
}
func (h *RequestsHandler) HandleBrowseStudio(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
page, ok := parsePositiveIntQuery(w, r, "page", 1)
if !ok {
return
}
slug := strings.TrimSpace(chi.URLParam(r, "slug"))
sort := strings.TrimSpace(r.URL.Query().Get("sort"))
resp, err := h.service.BrowseStudio(r.Context(), viewer, slug, sort, page)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func (h *RequestsHandler) HandleBrowseNetwork(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
page, ok := parsePositiveIntQuery(w, r, "page", 1)
if !ok {
return
}
slug := strings.TrimSpace(chi.URLParam(r, "slug"))
sort := strings.TrimSpace(r.URL.Query().Get("sort"))
resp, err := h.service.BrowseNetwork(r.Context(), viewer, slug, sort, page)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func (h *RequestsHandler) HandleBrowseGenre(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
page, ok := parsePositiveIntQuery(w, r, "page", 1)
if !ok {
return
}
slug := strings.TrimSpace(chi.URLParam(r, "slug"))
sort := strings.TrimSpace(r.URL.Query().Get("sort"))
mediaType := mediarequests.MediaType(strings.TrimSpace(r.URL.Query().Get("media_type")))
resp, err := h.service.BrowseGenre(r.Context(), viewer, slug, mediaType, sort, page)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func (h *RequestsHandler) HandleGetDetail(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
mediaType := mediarequests.MediaType(strings.TrimSpace(chi.URLParam(r, "media_type")))
tmdbID, err := strconv.Atoi(strings.TrimSpace(chi.URLParam(r, "tmdb_id")))
if err != nil || tmdbID <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid tmdb id")
return
}
detail, err := h.service.GetDetail(r.Context(), viewer, mediaType, tmdbID)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, detail)
}
func (h *RequestsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
var input mediarequests.CreateRequestInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
req, err := h.service.CreateRequest(r.Context(), viewer, input)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, req)
}
func (h *RequestsHandler) HandleListMine(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
requests, err := h.service.ListMine(r.Context(), viewer, parseRequestListFilter(r))
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, struct {
Requests []*mediarequests.Request `json:"requests"`
}{Requests: requests})
}
func (h *RequestsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
req, err := h.service.GetRequest(r.Context(), viewer, chi.URLParam(r, "id"))
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, req)
}
func (h *RequestsHandler) HandleAdminList(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
requests, err := h.service.ListAdmin(r.Context(), viewer, parseRequestListFilter(r))
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, struct {
Requests []*mediarequests.Request `json:"requests"`
}{Requests: requests})
}
func (h *RequestsHandler) HandleApprove(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
req, err := h.service.Approve(r.Context(), viewer, chi.URLParam(r, "id"))
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, req)
}
func (h *RequestsHandler) HandleDecline(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
var body struct {
Reason string `json:"reason"`
}
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
}
req, err := h.service.Decline(r.Context(), viewer, chi.URLParam(r, "id"), body.Reason)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, req)
}
func (h *RequestsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
var body struct {
Reason string `json:"reason"`
}
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
}
req, err := h.service.Cancel(r.Context(), viewer, chi.URLParam(r, "id"), body.Reason)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, req)
}
func (h *RequestsHandler) HandleRetry(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
req, err := h.service.Retry(r.Context(), viewer, chi.URLParam(r, "id"))
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, req)
}
func (h *RequestsHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
settings, err := h.service.GetSettings(r.Context(), viewer)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, settings)
}
func (h *RequestsHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
var settings mediarequests.Settings
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
updated, err := h.service.UpdateSettings(r.Context(), viewer, settings)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, updated)
}
func (h *RequestsHandler) HandleListIntegrations(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
integrations, err := h.service.ListIntegrations(r.Context(), viewer)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, struct {
Integrations []requestIntegrationResponse `json:"integrations"`
}{Integrations: toIntegrationResponses(integrations)})
}
func (h *RequestsHandler) HandleUpdateIntegrations(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
var body struct {
Integrations []mediarequests.Integration `json:"integrations"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
updated, err := h.service.UpsertIntegrations(r.Context(), viewer, body.Integrations)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, struct {
Integrations []requestIntegrationResponse `json:"integrations"`
}{Integrations: toIntegrationResponses(updated)})
}
func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
var integration mediarequests.Integration
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&integration); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
}
integration.Kind = chi.URLParam(r, "kind")
options, err := h.service.LoadIntegrationOptions(r.Context(), viewer, integration)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, options)
}
func (h *RequestsHandler) HandleGetUserLimit(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
userID, ok := parsePositivePathInt(w, r, "user_id")
if !ok {
return
}
limit, err := h.service.GetUserLimit(r.Context(), viewer, userID)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, limit)
}
func (h *RequestsHandler) HandleUpdateUserLimit(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
userID, ok := parsePositivePathInt(w, r, "user_id")
if !ok {
return
}
var limit mediarequests.UserLimit
if err := json.NewDecoder(r.Body).Decode(&limit); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
limit.UserID = userID
updated, err := h.service.UpsertUserLimit(r.Context(), viewer, limit)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, updated)
}
func requestViewer(w http.ResponseWriter, r *http.Request, requireProfile bool) (mediarequests.Viewer, bool) {
claims := apimw.GetClaims(r.Context())
if claims == nil || claims.UserID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return mediarequests.Viewer{}, false
}
profileID := strings.TrimSpace(apimw.GetProfileID(r.Context()))
if requireProfile && profileID == "" {
writeError(w, http.StatusBadRequest, "profile_required", "Profile is required")
return mediarequests.Viewer{}, false
}
return mediarequests.Viewer{
UserID: claims.UserID,
ProfileID: profileID,
IsAdmin: claims.Role == "admin",
}, true
}
func parseRequestListFilter(r *http.Request) mediarequests.ListFilter {
q := r.URL.Query()
limit, _ := strconv.Atoi(q.Get("limit"))
offset, _ := strconv.Atoi(q.Get("offset"))
return mediarequests.ListFilter{
Status: mediarequests.Status(strings.TrimSpace(q.Get("status"))),
Outcome: mediarequests.Outcome(strings.TrimSpace(q.Get("outcome"))),
Limit: limit,
Offset: offset,
}
}
func parsePositiveIntQuery(w http.ResponseWriter, r *http.Request, key string, fallback int) (int, bool) {
raw := strings.TrimSpace(r.URL.Query().Get(key))
if raw == "" {
return fallback, true
}
value, err := strconv.Atoi(raw)
if err != nil || value <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid "+key)
return 0, false
}
return value, true
}
func parsePositivePathInt(w http.ResponseWriter, r *http.Request, key string) (int, bool) {
value, err := strconv.Atoi(strings.TrimSpace(chi.URLParam(r, key)))
if err != nil || value <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid "+key)
return 0, false
}
return value, true
}
type requestIntegrationResponse struct {
Kind string `json:"kind"`
Enabled bool `json:"enabled"`
BaseURL string `json:"base_url"`
HasAPIKey bool `json:"has_api_key"`
RootFolder string `json:"root_folder"`
QualityProfileID *int `json:"quality_profile_id,omitempty"`
Tags []int `json:"tags"`
Options map[string]any `json:"options"`
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
LastCheckStatus string `json:"last_check_status,omitempty"`
LastCheckError string `json:"last_check_error,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
func toIntegrationResponses(integrations []mediarequests.Integration) []requestIntegrationResponse {
out := make([]requestIntegrationResponse, 0, len(integrations))
for _, integration := range integrations {
out = append(out, requestIntegrationResponse{
Kind: integration.Kind,
Enabled: integration.Enabled,
BaseURL: integration.BaseURL,
HasAPIKey: strings.TrimSpace(integration.APIKeyRef) != "",
RootFolder: integration.RootFolder,
QualityProfileID: integration.QualityProfileID,
Tags: integration.Tags,
Options: integration.Options,
LastCheckAt: integration.LastCheckAt,
LastCheckStatus: integration.LastCheckStatus,
LastCheckError: integration.LastCheckError,
UpdatedAt: integration.UpdatedAt,
})
}
return out
}
func writeRequestServiceError(w http.ResponseWriter, err error) {
var quota mediarequests.QuotaError
switch {
case errors.As(err, &quota):
writeJSON(w, http.StatusTooManyRequests, struct {
Error string `json:"error"`
Message string `json:"message"`
Used int `json:"used"`
Limit int `json:"limit"`
WindowDays int `json:"window_days"`
}{
Error: "quota_exceeded",
Message: "Request quota exceeded",
Used: quota.Used,
Limit: quota.Limit,
WindowDays: quota.WindowDays,
})
case errors.Is(err, mediarequests.ErrInvalidInput), errors.Is(err, mediarequests.ErrInvalidMediaType):
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
case errors.Is(err, mediarequests.ErrRequestsDisabled):
writeError(w, http.StatusForbidden, "requests_disabled", "Requests are disabled")
case errors.Is(err, mediarequests.ErrUserBlocked):
writeError(w, http.StatusForbidden, "requesting_blocked", "User is blocked from requesting")
case errors.Is(err, mediarequests.ErrAlreadyAvailable):
writeError(w, http.StatusConflict, "already_available", "Media is already available")
case errors.Is(err, mediarequests.ErrAlreadyRequested):
writeError(w, http.StatusConflict, "already_requested", "Media is already requested")
case errors.Is(err, mediarequests.ErrForbidden):
writeError(w, http.StatusForbidden, "forbidden", "Request access denied")
case errors.Is(err, mediarequests.ErrNotFound):
writeError(w, http.StatusNotFound, "not_found", "Request not found")
case errors.Is(err, mediarequests.ErrInvalidState):
writeError(w, http.StatusConflict, "invalid_state", "Request is not in a valid state for this action")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Request operation failed")
}
}
+241
View File
@@ -0,0 +1,241 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
)
type fakeRequestService struct {
listStudiosFn func() ([]mediarequests.DiscoverBrandCard, error)
listNetworksFn func() ([]mediarequests.DiscoverBrandCard, error)
listGenresFn func() ([]mediarequests.DiscoverBrandCard, error)
browseFn func(kind, slug string, mediaType mediarequests.MediaType, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error)
}
func (f *fakeRequestService) ListStudios(context.Context, mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) {
if f.listStudiosFn != nil {
return f.listStudiosFn()
}
return nil, nil
}
func (f *fakeRequestService) ListNetworks(context.Context, mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) {
if f.listNetworksFn != nil {
return f.listNetworksFn()
}
return nil, nil
}
func (f *fakeRequestService) ListGenres(context.Context, mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) {
if f.listGenresFn != nil {
return f.listGenresFn()
}
return nil, nil
}
func (f *fakeRequestService) BrowseStudio(_ context.Context, _ mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) {
return f.browseFn("studio", slug, mediarequests.MediaTypeMovie, sort, page)
}
func (f *fakeRequestService) BrowseNetwork(_ context.Context, _ mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) {
return f.browseFn("network", slug, mediarequests.MediaTypeSeries, sort, page)
}
func (f *fakeRequestService) BrowseGenre(_ context.Context, _ mediarequests.Viewer, slug string, mediaType mediarequests.MediaType, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) {
return f.browseFn("genre", slug, mediaType, sort, page)
}
func (f *fakeRequestService) Search(context.Context, mediarequests.Viewer, string, mediarequests.MediaType, int) (*mediarequests.MediaPage, error) {
return nil, nil
}
func (f *fakeRequestService) Discover(context.Context, mediarequests.Viewer, string, int) (*mediarequests.DiscoverySection, error) {
return nil, nil
}
func (f *fakeRequestService) DiscoverAll(context.Context, mediarequests.Viewer) ([]mediarequests.DiscoverySection, error) {
return nil, nil
}
func (f *fakeRequestService) GetDetail(context.Context, mediarequests.Viewer, mediarequests.MediaType, int) (*mediarequests.MediaDetail, error) {
return nil, nil
}
func (f *fakeRequestService) CreateRequest(context.Context, mediarequests.Viewer, mediarequests.CreateRequestInput) (*mediarequests.Request, error) {
return nil, nil
}
func (f *fakeRequestService) ListMine(context.Context, mediarequests.Viewer, mediarequests.ListFilter) ([]*mediarequests.Request, error) {
return nil, nil
}
func (f *fakeRequestService) ListAdmin(context.Context, mediarequests.Viewer, mediarequests.ListFilter) ([]*mediarequests.Request, error) {
return nil, nil
}
func (f *fakeRequestService) GetRequest(context.Context, mediarequests.Viewer, string) (*mediarequests.Request, error) {
return nil, nil
}
func (f *fakeRequestService) Approve(context.Context, mediarequests.Viewer, string) (*mediarequests.Request, error) {
return nil, nil
}
func (f *fakeRequestService) Decline(context.Context, mediarequests.Viewer, string, string) (*mediarequests.Request, error) {
return nil, nil
}
func (f *fakeRequestService) Cancel(context.Context, mediarequests.Viewer, string, string) (*mediarequests.Request, error) {
return nil, nil
}
func (f *fakeRequestService) Retry(context.Context, mediarequests.Viewer, string) (*mediarequests.Request, error) {
return nil, nil
}
func (f *fakeRequestService) GetSettings(context.Context, mediarequests.Viewer) (mediarequests.Settings, error) {
return mediarequests.Settings{}, nil
}
func (f *fakeRequestService) UpdateSettings(context.Context, mediarequests.Viewer, mediarequests.Settings) (mediarequests.Settings, error) {
return mediarequests.Settings{}, nil
}
func (f *fakeRequestService) GetUserLimit(context.Context, mediarequests.Viewer, int) (*mediarequests.UserLimit, error) {
return nil, nil
}
func (f *fakeRequestService) UpsertUserLimit(context.Context, mediarequests.Viewer, mediarequests.UserLimit) (*mediarequests.UserLimit, error) {
return nil, nil
}
func (f *fakeRequestService) ListIntegrations(context.Context, mediarequests.Viewer) ([]mediarequests.Integration, error) {
return nil, nil
}
func (f *fakeRequestService) UpsertIntegration(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.Integration, error) {
return nil, nil
}
func (f *fakeRequestService) UpsertIntegrations(context.Context, mediarequests.Viewer, []mediarequests.Integration) ([]mediarequests.Integration, error) {
return nil, nil
}
func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.IntegrationOptions, error) {
return nil, nil
}
func authedRequest(method, target string) *http.Request {
req := httptest.NewRequest(method, target, nil)
ctx := apimw.SetClaims(req.Context(), &auth.Claims{
UserID: 1,
Role: "user",
TokenType: auth.TokenTypeAccess,
})
ctx = apimw.SetProfileID(ctx, "profile-1")
return req.WithContext(ctx)
}
func TestHandleListStudiosReturnsJSON(t *testing.T) {
logo := "https://image.tmdb.org/t/p/w300/x.png"
svc := &fakeRequestService{
listStudiosFn: func() ([]mediarequests.DiscoverBrandCard, error) {
return []mediarequests.DiscoverBrandCard{
{TMDBID: 420, Slug: "marvel-studios", DisplayName: "Marvel Studios", LogoURL: &logo},
}, nil
},
}
h := NewRequestsHandler(svc)
rec := httptest.NewRecorder()
h.HandleListStudios(rec, authedRequest("GET", "/api/v1/requests/discover/studios"))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var body struct {
Studios []mediarequests.DiscoverBrandCard `json:"studios"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if len(body.Studios) != 1 || body.Studios[0].Slug != "marvel-studios" {
t.Errorf("studios = %+v", body.Studios)
}
}
func TestHandleBrowseStudioRejectsUnknownSort(t *testing.T) {
svc := &fakeRequestService{
browseFn: func(kind, slug string, _ mediarequests.MediaType, sort string, _ int) (*mediarequests.DiscoverBrowseResponse, error) {
return nil, mediarequests.ErrInvalidInput
},
}
h := NewRequestsHandler(svc)
req := authedRequest("GET", "/api/v1/requests/discover/browse/studio/marvel-studios?sort=garbage")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("slug", "marvel-studios")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rec := httptest.NewRecorder()
h.HandleBrowseStudio(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
func TestHandleBrowseStudioUnknownSlugReturns404(t *testing.T) {
svc := &fakeRequestService{
browseFn: func(string, string, mediarequests.MediaType, string, int) (*mediarequests.DiscoverBrowseResponse, error) {
return nil, mediarequests.ErrNotFound
},
}
h := NewRequestsHandler(svc)
req := authedRequest("GET", "/api/v1/requests/discover/browse/studio/ghosts")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("slug", "ghosts")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rec := httptest.NewRecorder()
h.HandleBrowseStudio(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
func TestHandleBrowseGenreRequiresMediaType(t *testing.T) {
svc := &fakeRequestService{
browseFn: func(_ string, _ string, mt mediarequests.MediaType, _ string, _ int) (*mediarequests.DiscoverBrowseResponse, error) {
if strings.TrimSpace(string(mt)) == "" {
return nil, mediarequests.ErrInvalidInput
}
return &mediarequests.DiscoverBrowseResponse{Kind: "genre"}, nil
},
}
h := NewRequestsHandler(svc)
req := authedRequest("GET", "/api/v1/requests/discover/browse/genre/action")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("slug", "action")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rec := httptest.NewRecorder()
h.HandleBrowseGenre(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
+52
View File
@@ -46,6 +46,9 @@ import (
"github.com/Silo-Server/silo-server/internal/plugins"
"github.com/Silo-Server/silo-server/internal/ratelimit"
"github.com/Silo-Server/silo-server/internal/recommendations"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
"github.com/Silo-Server/silo-server/internal/requests/radarr"
"github.com/Silo-Server/silo-server/internal/requests/sonarr"
"github.com/Silo-Server/silo-server/internal/s3client"
"github.com/Silo-Server/silo-server/internal/scanner"
"github.com/Silo-Server/silo-server/internal/scanqueue"
@@ -323,6 +326,7 @@ func NewRouter(deps Dependencies) chi.Router {
var detailSvc *catalog.DetailService
var calendarRepo *catalog.CalendarRepository
var webhookSyncHandler *handlers.WebhookSyncHandler
var requestHandler *handlers.RequestsHandler
if deps.DB != nil {
browseRepo := catalog.NewBrowseRepository(deps.DB)
itemRepo = catalog.NewItemRepository(deps.DB)
@@ -382,6 +386,19 @@ func NewRouter(deps Dependencies) chi.Router {
itemsHandler,
)
tmdbAPIKey := ""
if deps.Config != nil {
tmdbAPIKey = deps.Config.TMDBAPIKey
}
requestSvc := mediarequests.NewService(
mediarequests.NewRepository(deps.DB),
tmdb.NewClient(tmdbAPIKey, 40),
mediarequests.NewCatalogPresence(itemRepo, providerIDRepo),
)
requestSvc.SetSecretResolver(settingsRepo)
requestSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil))
requestHandler = handlers.NewRequestsHandler(requestSvc)
if deps.PersonRepo != nil {
peopleHandler = handlers.NewPeopleHandler(deps.PersonRepo, browseRepo, itemRepo, detailSvc)
peopleHandler.SetItemsHandler(itemsHandler)
@@ -1374,6 +1391,26 @@ func NewRouter(deps Dependencies) chi.Router {
})
}
if requestHandler != nil {
r.Route("/requests", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/search", requestHandler.HandleSearch)
r.Get("/discover", requestHandler.HandleDiscover)
r.Get("/discover/studios", requestHandler.HandleListStudios)
r.Get("/discover/networks", requestHandler.HandleListNetworks)
r.Get("/discover/genres", requestHandler.HandleListGenres)
r.Get("/discover/browse/studio/{slug}", requestHandler.HandleBrowseStudio)
r.Get("/discover/browse/network/{slug}", requestHandler.HandleBrowseNetwork)
r.Get("/discover/browse/genre/{slug}", requestHandler.HandleBrowseGenre)
r.Get("/discover/{section}", requestHandler.HandleDiscoverSection)
r.Get("/detail/{media_type}/{tmdb_id}", requestHandler.HandleGetDetail)
r.Post("/", requestHandler.HandleCreate)
r.Get("/mine", requestHandler.HandleListMine)
r.Get("/{id}", requestHandler.HandleGet)
r.Post("/{id}/cancel", requestHandler.HandleCancel)
})
}
// Settings routes (user-scoped, no profile required).
if settingsHandler != nil {
r.Route("/settings", func(r chi.Router) {
@@ -1870,6 +1907,21 @@ func NewRouter(deps Dependencies) chi.Router {
r.Put("/api-keys/{id}/tier", apiKeyHandler.HandleAdminUpdateTier)
}
if requestHandler != nil {
r.Get("/requests", requestHandler.HandleAdminList)
r.Post("/requests/{id}/approve", requestHandler.HandleApprove)
r.Post("/requests/{id}/decline", requestHandler.HandleDecline)
r.Post("/requests/{id}/cancel", requestHandler.HandleCancel)
r.Post("/requests/{id}/retry", requestHandler.HandleRetry)
r.Get("/request-settings", requestHandler.HandleGetSettings)
r.Put("/request-settings", requestHandler.HandleUpdateSettings)
r.Get("/request-users/{user_id}/limit", requestHandler.HandleGetUserLimit)
r.Put("/request-users/{user_id}/limit", requestHandler.HandleUpdateUserLimit)
r.Get("/request-integrations", requestHandler.HandleListIntegrations)
r.Put("/request-integrations", requestHandler.HandleUpdateIntegrations)
r.Post("/request-integrations/{kind}/options", requestHandler.HandleLoadIntegrationOptions)
}
if deps.ActivityLogRepo != nil {
adminIPHandler := handlers.NewAdminIPHandler(deps.ActivityLogRepo)
r.Get("/users/{id}/ips", adminIPHandler.HandleGetUserIPs)
+120 -23
View File
@@ -1330,6 +1330,20 @@ type MediaTMDBRow struct {
Title string
}
type ExternalIDLookupCandidate struct {
TMDBID string
TVDBID string
IMDbID string
}
type ExternalIDMatchRow struct {
QueryTMDBID string
MediaID string
MatchedProvider string
LibraryID string
Title string
}
// LookupTMDBIDs returns one row per matching media item that has its tmdb_id
// in the supplied list and is linked to at least one enabled library.
// mediaType is "movie" or "series" (silo's internal naming).
@@ -1341,38 +1355,121 @@ func (r *ItemRepository) LookupTMDBIDs(ctx context.Context, mediaType string, tm
if len(tmdbIDs) == 0 {
return nil, nil
}
// Convert string IDs to int: TMDB IDs are stored as text in media_items
// but the plugin sends them as strings. We do an ANY match directly on
// the text column so no int conversion is required.
rows, err := r.pool.Query(ctx, `
SELECT DISTINCT ON (mi.content_id)
mi.content_id,
COALESCE(mi.tmdb_id, ''),
mil.media_folder_id::text,
mi.title
FROM media_items mi
JOIN media_item_libraries mil ON mil.content_id = mi.content_id
JOIN media_folders mf ON mf.id = mil.media_folder_id
WHERE mi.tmdb_id = ANY($1)
AND mi.type = $2
AND mf.enabled = true
ORDER BY mi.content_id, mil.media_folder_id ASC
`, tmdbIDs, mediaType)
candidates := make([]ExternalIDLookupCandidate, 0, len(tmdbIDs))
for _, id := range tmdbIDs {
if strings.TrimSpace(id) != "" {
candidates = append(candidates, ExternalIDLookupCandidate{TMDBID: id})
}
}
rows, err := r.LookupExternalIDs(ctx, mediaType, candidates)
if err != nil {
return nil, fmt.Errorf("lookup tmdb ids: %w", err)
return nil, err
}
out := make([]MediaTMDBRow, 0, len(rows))
for _, row := range rows {
out = append(out, MediaTMDBRow{
MediaID: row.MediaID,
TMDBID: row.QueryTMDBID,
LibraryID: row.LibraryID,
Title: row.Title,
})
}
return out, nil
}
func lookupExternalIDsSQL() string {
return `
WITH requested(query_tmdb_id, provider, provider_id, ord) AS (
SELECT * FROM unnest($1::text[], $2::text[], $3::text[], $4::int[])
),
direct_matches AS (
SELECT r.query_tmdb_id, mi.content_id, r.provider, mil.media_folder_id::text, mi.title, r.ord,
CASE r.provider WHEN 'tmdb' THEN 0 WHEN 'tvdb' THEN 1 WHEN 'imdb' THEN 2 ELSE 3 END AS provider_rank
FROM requested r
JOIN media_items mi
ON mi.type = $5
AND (
(r.provider = 'tmdb' AND mi.tmdb_id <> '' AND mi.tmdb_id = r.provider_id)
OR (r.provider = 'tvdb' AND mi.tvdb_id <> '' AND mi.tvdb_id = r.provider_id)
OR (r.provider = 'imdb' AND mi.imdb_id <> '' AND mi.imdb_id = r.provider_id)
)
JOIN media_item_libraries mil ON mil.content_id = mi.content_id
JOIN media_folders mf ON mf.id = mil.media_folder_id
WHERE mf.enabled = true
),
provider_matches AS (
SELECT r.query_tmdb_id, mi.content_id, r.provider, mil.media_folder_id::text, mi.title, r.ord,
CASE r.provider WHEN 'tmdb' THEN 0 WHEN 'tvdb' THEN 1 WHEN 'imdb' THEN 2 ELSE 3 END AS provider_rank
FROM requested r
JOIN media_item_provider_ids mip
ON mip.provider = r.provider
AND mip.provider_id = r.provider_id
AND mip.item_type = $5
JOIN media_items mi ON mi.content_id = mip.content_id AND mi.type = $5
JOIN media_item_libraries mil ON mil.content_id = mi.content_id
JOIN media_folders mf ON mf.id = mil.media_folder_id
WHERE mf.enabled = true
)
SELECT DISTINCT ON (query_tmdb_id)
query_tmdb_id, content_id, provider, media_folder_id, title
FROM (
SELECT * FROM direct_matches
UNION ALL
SELECT * FROM provider_matches
) matches
ORDER BY query_tmdb_id, provider_rank ASC, ord ASC, content_id ASC, media_folder_id ASC`
}
func (r *ItemRepository) LookupExternalIDs(
ctx context.Context,
mediaType string,
candidates []ExternalIDLookupCandidate,
) ([]ExternalIDMatchRow, error) {
if len(candidates) == 0 {
return nil, nil
}
queryTMDBIDs := make([]string, 0, len(candidates)*3)
providers := make([]string, 0, len(candidates)*3)
providerIDs := make([]string, 0, len(candidates)*3)
ordinals := make([]int32, 0, len(candidates)*3)
appendID := func(candidate ExternalIDLookupCandidate, provider, providerID string, ordinal int) {
providerID = strings.TrimSpace(providerID)
if providerID == "" {
return
}
queryTMDBIDs = append(queryTMDBIDs, strings.TrimSpace(candidate.TMDBID))
providers = append(providers, provider)
providerIDs = append(providerIDs, providerID)
ordinals = append(ordinals, int32(ordinal))
}
for i, candidate := range candidates {
appendID(candidate, "tmdb", candidate.TMDBID, i)
appendID(candidate, "tvdb", candidate.TVDBID, i)
appendID(candidate, "imdb", candidate.IMDbID, i)
}
if len(providerIDs) == 0 {
return nil, nil
}
rows, err := r.pool.Query(ctx, lookupExternalIDsSQL(), queryTMDBIDs, providers, providerIDs, ordinals, mediaType)
if err != nil {
return nil, fmt.Errorf("lookup external ids: %w", err)
}
defer rows.Close()
var out []MediaTMDBRow
out := make([]ExternalIDMatchRow, 0)
for rows.Next() {
var row MediaTMDBRow
if err := rows.Scan(&row.MediaID, &row.TMDBID, &row.LibraryID, &row.Title); err != nil {
return nil, fmt.Errorf("scanning tmdb lookup row: %w", err)
var row ExternalIDMatchRow
if err := rows.Scan(&row.QueryTMDBID, &row.MediaID, &row.MatchedProvider, &row.LibraryID, &row.Title); err != nil {
return nil, fmt.Errorf("scanning external id lookup row: %w", err)
}
out = append(out, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating tmdb lookup rows: %w", err)
return nil, fmt.Errorf("iterating external id lookup rows: %w", err)
}
return out, nil
}
+31
View File
@@ -191,6 +191,37 @@ func TestItemRepo_GetByExternalIDs_NilSliceStillBindsArg(t *testing.T) {
_ = args
}
func TestLookupExternalIDsSQLChecksProviderTableAndDirectColumns(t *testing.T) {
sql := lookupExternalIDsSQL()
for _, want := range []string{
"FROM requested r",
"JOIN media_item_provider_ids mip",
"mip.provider = r.provider",
"mip.provider_id = r.provider_id",
"mip.item_type = $5",
"mi.type = $5",
"mi.tmdb_id <> '' AND mi.tmdb_id = r.provider_id",
"mi.tvdb_id <> '' AND mi.tvdb_id = r.provider_id",
"mi.imdb_id <> '' AND mi.imdb_id = r.provider_id",
"JOIN media_folders mf ON mf.id = mil.media_folder_id",
"mf.enabled = true",
} {
if !strings.Contains(sql, want) {
t.Fatalf("lookupExternalIDsSQL missing %q:\n%s", want, sql)
}
}
for _, disallowed := range []string{
"COALESCE(mi.tmdb_id, '') = r.provider_id",
"COALESCE(mi.tvdb_id, '') = r.provider_id",
"COALESCE(mi.imdb_id, '') = r.provider_id",
} {
if strings.Contains(sql, disallowed) {
t.Fatalf("lookupExternalIDsSQL should use indexable direct predicate, found %q:\n%s", disallowed, sql)
}
}
}
// TestItemRepo_Search_UsesWindowCount asserts that buildSearchSQL emits a
// single-pass paged SELECT that includes COUNT(*) OVER () so Search no longer
// needs a separate count query before the data fetch (audit 2026-05-01 §3.11).
+97
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"sort"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
@@ -30,6 +31,102 @@ func NewProviderIDRepository(pool *pgxpool.Pool) *ProviderIDRepository {
return &ProviderIDRepository{pool: pool}
}
func (r *ProviderIDRepository) AttachTMDBID(ctx context.Context, contentID, itemType string, tmdbID int) error {
contentID = strings.TrimSpace(contentID)
itemType = strings.TrimSpace(itemType)
if contentID == "" {
return fmt.Errorf("content_id is required")
}
if itemType == "" {
return fmt.Errorf("item_type is required")
}
if tmdbID <= 0 {
return fmt.Errorf("tmdb_id must be positive")
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin attach tmdb transaction: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
tmdbText := strconv.Itoa(tmdbID)
var existingType, existingTMDBID string
if err := tx.QueryRow(ctx, `
SELECT type, COALESCE(tmdb_id, '')
FROM media_items
WHERE content_id = $1
FOR UPDATE
`, contentID).Scan(&existingType, &existingTMDBID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("loading media item for tmdb attach: content not found")
}
return fmt.Errorf("loading media item for tmdb attach: %w", err)
}
if existingType != itemType {
return fmt.Errorf("media item type mismatch: got %q, want %q", existingType, itemType)
}
if existingTMDBID != "" && existingTMDBID != tmdbText {
return fmt.Errorf("media item tmdb id conflict: got %q, want %q", existingTMDBID, tmdbText)
}
var existingProviderTMDBID string
err = tx.QueryRow(ctx, `
SELECT provider_id
FROM media_item_provider_ids
WHERE content_id = $1 AND provider = 'tmdb'
FOR UPDATE
`, contentID).Scan(&existingProviderTMDBID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("loading media item tmdb provider id: %w", err)
}
if existingProviderTMDBID != "" && existingProviderTMDBID != tmdbText {
return fmt.Errorf("media item tmdb provider id conflict: got %q, want %q", existingProviderTMDBID, tmdbText)
}
var existingOwnerContentID string
err = tx.QueryRow(ctx, `
SELECT content_id
FROM media_items
WHERE type = $1
AND tmdb_id = $2
AND content_id <> $3
LIMIT 1
`, itemType, tmdbText, contentID).Scan(&existingOwnerContentID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("checking tmdb id owner: %w", err)
}
if existingOwnerContentID != "" {
return fmt.Errorf("tmdb id %q already belongs to content_id %q", tmdbText, existingOwnerContentID)
}
if _, err := tx.Exec(ctx, `
UPDATE media_items
SET tmdb_id = $1,
updated_at = NOW()
WHERE content_id = $2
AND type = $3
`, tmdbText, contentID, itemType); err != nil {
return fmt.Errorf("updating media item tmdb id: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO media_item_provider_ids (content_id, item_type, provider, provider_id, created_at, updated_at)
VALUES ($1, $2, 'tmdb', $3, NOW(), NOW())
ON CONFLICT (content_id, provider) DO UPDATE
SET item_type = EXCLUDED.item_type,
provider_id = EXCLUDED.provider_id,
updated_at = NOW()
`, contentID, itemType, tmdbText); err != nil {
return fmt.Errorf("upserting media item tmdb provider id: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit attach tmdb transaction: %w", err)
}
return nil
}
const providerIDColumns = `content_id, item_type, provider, provider_id, created_at, updated_at`
var excludedProviderIDs = map[string]struct{}{
+14
View File
@@ -40,3 +40,17 @@ func TestNormalizeDurableProviderIDsOrdersCanonicalKeysFirst(t *testing.T) {
}
}
}
func TestNormalizeDurableProviderIDsKeepsTMDBFirstForBackfill(t *testing.T) {
entries := normalizeDurableProviderIDs(map[string]string{
"tvdb": "420105",
"imdb": "tt18076310",
"tmdb": "201992",
})
if len(entries) != 3 {
t.Fatalf("len(entries) = %d, want 3", len(entries))
}
if entries[0].Provider != "tmdb" || entries[0].ProviderID != "201992" {
t.Fatalf("first entry = (%q, %q), want tmdb/201992", entries[0].Provider, entries[0].ProviderID)
}
}
+40 -2
View File
@@ -25,12 +25,40 @@ type MatchCandidate struct {
AgreementHints []string `json:"agreement_hints"`
}
var canonicalCandidateIDKeys = []string{"tmdb", "tvdb", "imdb"}
func compatibleProviderIDs(left, right map[string]string) bool {
overlap := false
for _, key := range canonicalCandidateIDKeys {
lv := strings.TrimSpace(left[key])
rv := strings.TrimSpace(right[key])
if lv == "" || rv == "" {
continue
}
if lv != rv {
return false
}
overlap = true
}
return overlap
}
func providerIDRichness(ids map[string]string) int {
score := 0
for _, key := range canonicalCandidateIDKeys {
if strings.TrimSpace(ids[key]) != "" {
score++
}
}
return score
}
// normalizedKey returns a stable grouping key from provider IDs.
// Results with identical provider ID fingerprints (the exact set of
// tmdb/tvdb/imdb key=value pairs) are considered the same candidate.
func normalizedKey(ids map[string]string) string {
var parts []string
for _, k := range []string{"tmdb", "tvdb", "imdb"} {
for _, k := range canonicalCandidateIDKeys {
if v, ok := ids[k]; ok && v != "" {
parts = append(parts, k+"="+v)
}
@@ -59,7 +87,16 @@ func NormalizeCandidates(results []SearchResult, contentType string) []MatchCand
buckets := make(map[string]*bucket)
for _, sr := range results {
key := normalizedKey(sr.ProviderIDs)
key := ""
for _, existingKey := range ordered {
if compatibleProviderIDs(buckets[existingKey].candidate.ProviderIDs, sr.ProviderIDs) {
key = existingKey
break
}
}
if key == "" {
key = normalizedKey(sr.ProviderIDs)
}
if key == "" {
// Cannot group by provider IDs; create a synthetic unique key.
key = sr.Provider + ":" + sr.Name + ":" + strings.Repeat("?", len(ordered))
@@ -193,6 +230,7 @@ func scoreMatchCandidate(hints *MatchHints, candidate MatchCandidate) float64 {
if len(candidate.ProviderIDs) > 0 {
score += 5
score += float64(providerIDRichness(candidate.ProviderIDs))
}
return score
@@ -61,6 +61,61 @@ func TestNormalizeCandidates(t *testing.T) {
}
},
},
{
name: "merge compatible candidates with overlapping provider IDs",
results: []SearchResult{
{
Name: "The Rookie: Feds",
Year: 2022,
Provider: "tvdb",
ProviderIDs: map[string]string{"tvdb": "420105", "imdb": "tt18076310"},
},
{
Name: "The Rookie: Feds",
Year: 2022,
Provider: "tmdb",
ProviderIDs: map[string]string{"tmdb": "201992", "tvdb": "420105", "imdb": "tt18076310"},
},
},
content: "series",
wantLen: 1,
check: func(t *testing.T, candidates []MatchCandidate) {
c := candidates[0]
if c.ProviderIDs["tmdb"] != "201992" {
t.Fatalf("tmdb id = %q, want 201992", c.ProviderIDs["tmdb"])
}
if c.ProviderIDs["tvdb"] != "420105" || c.ProviderIDs["imdb"] != "tt18076310" {
t.Fatalf("provider ids = %+v, want tvdb and imdb preserved", c.ProviderIDs)
}
if len(c.Sources) != 2 {
t.Fatalf("sources = %+v, want two providers", c.Sources)
}
},
},
{
name: "do not merge candidates with conflicting overlapping provider IDs",
results: []SearchResult{
{
Name: "Show A",
Year: 2022,
Provider: "tvdb",
ProviderIDs: map[string]string{"tvdb": "420105", "imdb": "tt18076310"},
},
{
Name: "Show B",
Year: 2022,
Provider: "tmdb",
ProviderIDs: map[string]string{"tmdb": "201992", "tvdb": "999999", "imdb": "tt18076310"},
},
},
content: "series",
wantLen: 2,
check: func(t *testing.T, candidates []MatchCandidate) {
if len(candidates) != 2 {
t.Fatalf("len(candidates) = %d, want 2", len(candidates))
}
},
},
{
name: "no recognized provider IDs gets synthetic key and stays separate",
results: []SearchResult{
+20 -4
View File
@@ -8,10 +8,11 @@ import (
)
const (
RefreshDebtReasonEpisodeIncomplete int64 = 1 << iota
RefreshDebtReasonStaleProviderID
RefreshDebtReasonRefreshFailure
RefreshDebtReasonCoreMetadataIncomplete
RefreshDebtReasonEpisodeIncomplete int64 = 1
RefreshDebtReasonStaleProviderID int64 = 2
RefreshDebtReasonRefreshFailure int64 = 4
RefreshDebtReasonCoreMetadataIncomplete int64 = 8
RefreshDebtReasonProviderIDIncomplete int64 = 16
)
const (
@@ -43,6 +44,8 @@ func refreshDebtPriority(reasonMask int64) int {
return 300
case hasRefreshDebtReason(reasonMask, RefreshDebtReasonStaleProviderID):
return 250
case hasRefreshDebtReason(reasonMask, RefreshDebtReasonProviderIDIncomplete):
return 240
case hasRefreshDebtReason(reasonMask, RefreshDebtReasonRefreshFailure):
return 200
case hasRefreshDebtReason(reasonMask, RefreshDebtReasonCoreMetadataIncomplete):
@@ -95,12 +98,25 @@ func refreshDebtReasonsForItem(item *models.MediaItem) int64 {
if hasCoreMetadataRefreshDebt(item) {
reasonMask |= RefreshDebtReasonCoreMetadataIncomplete
}
if hasProviderIDRefreshDebt(item) {
reasonMask |= RefreshDebtReasonProviderIDIncomplete
}
if item.RefreshFailures > 0 && strings.EqualFold(strings.TrimSpace(item.Status), "matched") {
reasonMask |= RefreshDebtReasonRefreshFailure
}
return reasonMask
}
func hasProviderIDRefreshDebt(item *models.MediaItem) bool {
if item == nil || !strings.EqualFold(strings.TrimSpace(item.Status), "matched") {
return false
}
if strings.TrimSpace(item.TmdbID) != "" {
return false
}
return strings.TrimSpace(item.TvdbID) != "" || strings.TrimSpace(item.ImdbID) != ""
}
func hasCoreMetadataRefreshDebt(item *models.MediaItem) bool {
if item == nil || !strings.EqualFold(strings.TrimSpace(item.Status), "matched") {
return false
+1
View File
@@ -448,6 +448,7 @@ func (r *RefreshDebtRepository) GetMetrics(ctx context.Context, sampleLimit int)
}{
{reason: "episode_incomplete", mask: RefreshDebtReasonEpisodeIncomplete},
{reason: "stale_provider_id", mask: RefreshDebtReasonStaleProviderID},
{reason: "provider_id_incomplete", mask: RefreshDebtReasonProviderIDIncomplete},
{reason: "refresh_failure", mask: RefreshDebtReasonRefreshFailure},
{reason: "core_metadata_incomplete", mask: RefreshDebtReasonCoreMetadataIncomplete},
}
+60
View File
@@ -39,6 +39,66 @@ func TestRefreshDebtReasonsForItemSkipsUnmatchedFailureOnly(t *testing.T) {
}
}
func TestRefreshDebtReasonsForItemFlagsMissingTMDBWithOtherProviderIDs(t *testing.T) {
item := &models.MediaItem{
Type: "series",
Status: "matched",
TvdbID: "420105",
ImdbID: "tt18076310",
TmdbID: "",
}
mask := refreshDebtReasonsForItem(item)
if !hasRefreshDebtReason(mask, RefreshDebtReasonProviderIDIncomplete) {
t.Fatalf("reason mask = %d, want provider id incomplete", mask)
}
}
func TestRefreshDebtReasonsForItemDoesNotFlagProviderIDIncompleteWithoutAlternateIDs(t *testing.T) {
item := &models.MediaItem{
Type: "series",
Status: "matched",
TmdbID: "",
}
mask := refreshDebtReasonsForItem(item)
if hasRefreshDebtReason(mask, RefreshDebtReasonProviderIDIncomplete) {
t.Fatalf("reason mask = %d, did not want provider id incomplete", mask)
}
}
func TestRefreshDebtReasonMaskValuesAreStable(t *testing.T) {
tests := map[string]int64{
"episode_incomplete": 1,
"stale_provider_id": 2,
"refresh_failure": 4,
"core_metadata_incomplete": 8,
"provider_id_incomplete": 16,
}
got := map[string]int64{
"episode_incomplete": RefreshDebtReasonEpisodeIncomplete,
"stale_provider_id": RefreshDebtReasonStaleProviderID,
"refresh_failure": RefreshDebtReasonRefreshFailure,
"core_metadata_incomplete": RefreshDebtReasonCoreMetadataIncomplete,
"provider_id_incomplete": RefreshDebtReasonProviderIDIncomplete,
}
for name, want := range tests {
if got[name] != want {
t.Fatalf("%s mask = %d, want %d", name, got[name], want)
}
}
}
func TestRefreshDebtPriorityProviderIDIncomplete(t *testing.T) {
if got := refreshDebtPriority(RefreshDebtReasonProviderIDIncomplete); got != 240 {
t.Fatalf("provider id incomplete priority = %d, want 240", got)
}
combined := RefreshDebtReasonProviderIDIncomplete | RefreshDebtReasonStaleProviderID
if got := refreshDebtPriority(combined); got != 250 {
t.Fatalf("combined stale/provider priority = %d, want stale priority 250", got)
}
}
func TestNextRefreshDelayEpisodeSchedule(t *testing.T) {
reasonMask := RefreshDebtReasonEpisodeIncomplete
cases := []struct {
+5 -1
View File
@@ -1985,7 +1985,11 @@ func (s *MetadataService) syncRefreshDebtFailure(ctx context.Context, contentID
return err
}
reasonMask |= staleReason
if strings.EqualFold(strings.TrimSpace(item.Status), "matched") {
if strings.EqualFold(strings.TrimSpace(item.Status), "matched") &&
!hasRefreshDebtReason(reasonMask, RefreshDebtReasonProviderIDIncomplete) {
// Items missing provider IDs fail for that reason, not because the
// refresh itself errored. Tagging them with RefreshFailure muddies
// the metrics — the priority logic already prefers ProviderIDIncomplete.
reasonMask |= RefreshDebtReasonRefreshFailure
}
if reasonMask == 0 {
+666 -15
View File
@@ -7,40 +7,55 @@ import (
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/cache"
"golang.org/x/sync/singleflight"
"golang.org/x/time/rate"
)
const (
defaultBaseURL = "https://api.themoviedb.org/3"
defaultAPIKey = "4ef0d7355d9ffb5151e987764708ce96"
projectAPIKey = "4ef0d7355d9ffb5151e987764708ce96"
maxRetries = 3
maxResponseBody = 1 << 20 // 1 MB
maxCollectionPresetResults = 500
defaultResponseCacheTTL = 2 * time.Hour
)
// Client is an HTTP client for the TMDB collection preset API surface.
type Client struct {
httpClient *http.Client
apiKey string
baseURL string
limiter *rate.Limiter
httpClient *http.Client
apiKey string
baseURL string
limiter *rate.Limiter
discoverSectionCache *cache.TTLCache[*MediaPage]
discoverPageCache *cache.TTLCache[*MediaPage]
externalIDCache *cache.TTLCache[*ExternalIDs]
cacheGroup singleflight.Group
responseCacheTTL time.Duration
}
// NewClient creates a TMDB API client with the given API key and rate limit
// (requests per second). If apiKey is empty the built-in project key is used.
// (requests per second). If apiKey is empty, Silo's public project API key is
// used.
func NewClient(apiKey string, rateLimit int) *Client {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
apiKey = defaultAPIKey
apiKey = projectAPIKey
}
return &Client{
httpClient: &http.Client{Timeout: 30 * time.Second},
apiKey: apiKey,
baseURL: defaultBaseURL,
limiter: rate.NewLimiter(rate.Limit(rateLimit), rateLimit),
httpClient: &http.Client{Timeout: 30 * time.Second},
apiKey: apiKey,
baseURL: defaultBaseURL,
limiter: rate.NewLimiter(rate.Limit(rateLimit), rateLimit),
discoverSectionCache: cache.NewTTLCache[*MediaPage](),
discoverPageCache: cache.NewTTLCache[*MediaPage](),
externalIDCache: cache.NewTTLCache[*ExternalIDs](),
responseCacheTTL: defaultResponseCacheTTL,
}
}
@@ -49,6 +64,22 @@ func (c *Client) SetBaseURL(url string) {
c.baseURL = url
}
// Close releases background cache sweepers owned by the client.
func (c *Client) Close() {
if c == nil {
return
}
if c.discoverSectionCache != nil {
c.discoverSectionCache.Close()
}
if c.discoverPageCache != nil {
c.discoverPageCache.Close()
}
if c.externalIDCache != nil {
c.externalIDCache.Close()
}
}
// doGet executes a GET request against the TMDB API with rate limiting,
// exponential backoff on 5xx/429, and JSON decoding into dest.
func (c *Client) doGet(ctx context.Context, path string, dest any) error {
@@ -133,6 +164,250 @@ func retryAfterOrDefault(resp *http.Response, attempt int) time.Duration {
return time.Duration(1<<attempt) * time.Second
}
// SearchMedia searches TMDB directly for movies, TV series, or both. mediaType
// accepts Silo-facing "movie", "series", or "all" values, plus TMDB-facing
// "tv" for callers already working at the provider boundary.
func (c *Client) SearchMedia(ctx context.Context, mediaType, query string, page int) (*MediaPage, error) {
query = strings.TrimSpace(query)
if query == "" {
return nil, fmt.Errorf("tmdb: search query is required")
}
if page <= 0 {
page = 1
}
switch mediaType {
case "movie":
values := url.Values{}
values.Set("query", query)
values.Set("include_adult", "false")
values.Set("page", strconv.Itoa(page))
var resp paginatedResponse[mediaMovieResponse]
if err := c.doGet(ctx, "/search/movie?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeMoviePage(resp), nil
case "series", "tv":
values := url.Values{}
values.Set("query", query)
values.Set("include_adult", "false")
values.Set("page", strconv.Itoa(page))
var resp paginatedResponse[mediaTVResponse]
if err := c.doGet(ctx, "/search/tv?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeTVPage(resp), nil
case "all":
values := url.Values{}
values.Set("query", query)
values.Set("include_adult", "false")
values.Set("page", strconv.Itoa(page))
var resp paginatedResponse[mediaMultiSearchResponse]
if err := c.doGet(ctx, "/search/multi?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeMultiSearchPage(resp), nil
default:
return nil, fmt.Errorf("tmdb: invalid media type for search: %q", mediaType)
}
}
// DiscoverSection fetches one of Silo's request-discovery sections directly
// from TMDB. It intentionally does not go through collection sync or
// collection templates.
func (c *Client) DiscoverSection(ctx context.Context, section string, page int) (*MediaPage, error) {
if page <= 0 {
page = 1
}
cacheKey := "discover_section:" + section + ":" + strconv.Itoa(page)
if c.discoverSectionCache != nil {
if cached, ok := c.discoverSectionCache.Get(cacheKey); ok {
return cloneMediaPage(cached), nil
}
}
value, err, _ := c.cacheGroup.Do(cacheKey, func() (any, error) {
if c.discoverSectionCache != nil {
if cached, ok := c.discoverSectionCache.Get(cacheKey); ok {
return cached, nil
}
}
page, err := c.fetchDiscoverSection(ctx, section, page)
if err != nil {
return nil, err
}
cached := cloneMediaPage(page)
if c.discoverSectionCache != nil && c.responseCacheTTL > 0 {
c.discoverSectionCache.Set(cacheKey, cached, c.responseCacheTTL)
}
return cached, nil
})
if err != nil {
return nil, err
}
pageValue, ok := value.(*MediaPage)
if !ok {
return nil, fmt.Errorf("tmdb: invalid cached discovery section response")
}
return cloneMediaPage(pageValue), nil
}
func (c *Client) fetchDiscoverSection(ctx context.Context, section string, page int) (*MediaPage, error) {
values := url.Values{}
values.Set("page", strconv.Itoa(page))
switch section {
case "trending_movies":
var resp paginatedResponse[mediaMovieResponse]
if err := c.doGet(ctx, "/trending/movie/week?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeMoviePage(resp), nil
case "trending_series":
var resp paginatedResponse[mediaTVResponse]
if err := c.doGet(ctx, "/trending/tv/week?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeTVPage(resp), nil
case "popular_movies":
var resp paginatedResponse[mediaMovieResponse]
if err := c.doGet(ctx, "/movie/popular?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeMoviePage(resp), nil
case "popular_series":
var resp paginatedResponse[mediaTVResponse]
if err := c.doGet(ctx, "/tv/popular?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeTVPage(resp), nil
case "upcoming_movies":
var resp paginatedResponse[mediaMovieResponse]
if err := c.doGet(ctx, "/movie/upcoming?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeMoviePage(resp), nil
case "on_air_series":
var resp paginatedResponse[mediaTVResponse]
if err := c.doGet(ctx, "/tv/on_the_air?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeTVPage(resp), nil
default:
return nil, fmt.Errorf("tmdb: invalid discovery section: %q", section)
}
}
func cloneMediaPage(page *MediaPage) *MediaPage {
if page == nil {
return nil
}
cloned := *page
if page.Results != nil {
cloned.Results = append([]MediaResult(nil), page.Results...)
}
return &cloned
}
func normalizeMoviePage(resp paginatedResponse[mediaMovieResponse]) *MediaPage {
page := &MediaPage{
Page: resp.Page,
TotalPages: resp.TotalPages,
TotalResults: resp.TotalResults,
Results: make([]MediaResult, 0, len(resp.Results)),
}
for _, item := range resp.Results {
page.Results = append(page.Results, MediaResult{
ID: item.ID,
MediaType: "movie",
Title: item.Title,
Overview: item.Overview,
PosterPath: item.PosterPath,
BackdropPath: item.BackdropPath,
ReleaseDate: item.ReleaseDate,
Year: releaseYear(item.ReleaseDate),
Popularity: item.Popularity,
VoteAverage: item.VoteAverage,
})
}
return page
}
func normalizeTVPage(resp paginatedResponse[mediaTVResponse]) *MediaPage {
page := &MediaPage{
Page: resp.Page,
TotalPages: resp.TotalPages,
TotalResults: resp.TotalResults,
Results: make([]MediaResult, 0, len(resp.Results)),
}
for _, item := range resp.Results {
page.Results = append(page.Results, MediaResult{
ID: item.ID,
MediaType: "series",
Title: item.Name,
Overview: item.Overview,
PosterPath: item.PosterPath,
BackdropPath: item.BackdropPath,
ReleaseDate: item.FirstAirDate,
Year: releaseYear(item.FirstAirDate),
Popularity: item.Popularity,
VoteAverage: item.VoteAverage,
})
}
return page
}
func normalizeMultiSearchPage(resp paginatedResponse[mediaMultiSearchResponse]) *MediaPage {
page := &MediaPage{
Page: resp.Page,
TotalPages: resp.TotalPages,
TotalResults: resp.TotalResults,
Results: make([]MediaResult, 0, len(resp.Results)),
}
for _, item := range resp.Results {
switch item.MediaType {
case "movie":
page.Results = append(page.Results, MediaResult{
ID: item.ID,
MediaType: "movie",
Title: item.Title,
Overview: item.Overview,
PosterPath: item.PosterPath,
BackdropPath: item.BackdropPath,
ReleaseDate: item.ReleaseDate,
Year: releaseYear(item.ReleaseDate),
Popularity: item.Popularity,
VoteAverage: item.VoteAverage,
})
case "tv":
page.Results = append(page.Results, MediaResult{
ID: item.ID,
MediaType: "series",
Title: item.Name,
Overview: item.Overview,
PosterPath: item.PosterPath,
BackdropPath: item.BackdropPath,
ReleaseDate: item.FirstAirDate,
Year: releaseYear(item.FirstAirDate),
Popularity: item.Popularity,
VoteAverage: item.VoteAverage,
})
}
}
return page
}
func releaseYear(value string) int {
if len(value) < 4 {
return 0
}
year, err := strconv.Atoi(value[:4])
if err != nil {
return 0
}
return year
}
func normalizeCollectionPreset(preset, mediaType, timeWindow string) (string, string, string, string, error) {
switch preset {
case "trending":
@@ -358,6 +633,75 @@ func (c *Client) Discover(ctx context.Context, mediaType string, params Discover
return results, nil
}
// DiscoverPage fetches a single page from TMDB's /discover/{movie,tv} endpoint
// and returns the full MediaPage shape (with posters, overviews, etc.) so the
// request system can enrich it with availability and request state. Unlike
// Discover (which is intended for collection templates and returns just IDs
// and titles), DiscoverPage exposes single-page semantics: callers control
// pagination explicitly.
func (c *Client) DiscoverPage(ctx context.Context, mediaType string, params DiscoverParams, page int) (*MediaPage, error) {
switch mediaType {
case "movie", "tv":
default:
return nil, fmt.Errorf("tmdb: invalid media type for discover: %q", mediaType)
}
if strings.TrimSpace(params.SortBy) == "" {
return nil, fmt.Errorf("tmdb: discover requires sort_by")
}
if page <= 0 {
page = 1
}
query := buildDiscoverQuery(mediaType, params) + "&page=" + strconv.Itoa(page)
path := "/discover/" + mediaType + "?" + query
cacheKey := "discover_page:" + path
if c.discoverPageCache != nil {
if cached, ok := c.discoverPageCache.Get(cacheKey); ok {
return cloneMediaPage(cached), nil
}
}
value, err, _ := c.cacheGroup.Do(cacheKey, func() (any, error) {
if c.discoverPageCache != nil {
if cached, ok := c.discoverPageCache.Get(cacheKey); ok {
return cached, nil
}
}
page, err := c.fetchDiscoverPage(ctx, mediaType, path)
if err != nil {
return nil, err
}
cached := cloneMediaPage(page)
if c.discoverPageCache != nil && c.responseCacheTTL > 0 {
c.discoverPageCache.Set(cacheKey, cached, c.responseCacheTTL)
}
return cached, nil
})
if err != nil {
return nil, err
}
pageValue, ok := value.(*MediaPage)
if !ok {
return nil, fmt.Errorf("tmdb: invalid cached discover page response")
}
return cloneMediaPage(pageValue), nil
}
func (c *Client) fetchDiscoverPage(ctx context.Context, mediaType, path string) (*MediaPage, error) {
if mediaType == "tv" {
var resp paginatedResponse[mediaTVResponse]
if err := c.doGet(ctx, path, &resp); err != nil {
return nil, err
}
return normalizeTVPage(resp), nil
}
var resp paginatedResponse[mediaMovieResponse]
if err := c.doGet(ctx, path, &resp); err != nil {
return nil, err
}
return normalizeMoviePage(resp), nil
}
// buildDiscoverQuery composes the TMDB discover query string (without the
// leading "?" and without page or api_key — doGet handles those).
func buildDiscoverQuery(mediaType string, params DiscoverParams) string {
@@ -370,6 +714,12 @@ func buildDiscoverQuery(mediaType string, params DiscoverParams) string {
if without := joinIntSlice(params.WithoutGenres, ","); without != "" {
values.Set("without_genres", without)
}
if companies := joinIntSlice(params.WithCompanies, ","); companies != "" {
values.Set("with_companies", companies)
}
if networks := joinIntSlice(params.WithNetworks, ","); networks != "" {
values.Set("with_networks", networks)
}
if params.VoteCountGte > 0 {
values.Set("vote_count.gte", strconv.Itoa(params.VoteCountGte))
}
@@ -464,21 +814,322 @@ func (c *Client) GetCollection(ctx context.Context, id int) (*Collection, error)
}, nil
}
// GetMediaDetail fetches a single TMDB movie or series with credits, external
// IDs, recommendations, and the appropriate certification feed, returning a
// normalized MediaDetail. mediaType accepts Silo-facing "movie" or "series".
//
// Cast is sorted by TMDB billing order and capped at 24 entries to keep the
// payload bounded.
func (c *Client) GetMediaDetail(ctx context.Context, mediaType string, id int) (*MediaDetail, error) {
if id <= 0 {
return nil, fmt.Errorf("tmdb: media id must be > 0 (got %d)", id)
}
switch mediaType {
case "movie":
path := fmt.Sprintf("/movie/%d?append_to_response=credits,external_ids,recommendations,release_dates", id)
var resp movieDetailResponse
if err := c.doGet(ctx, path, &resp); err != nil {
return nil, err
}
return normalizeMovieDetail(&resp), nil
case "series", "tv":
path := fmt.Sprintf("/tv/%d?append_to_response=credits,external_ids,recommendations,content_ratings", id)
var resp tvDetailResponse
if err := c.doGet(ctx, path, &resp); err != nil {
return nil, err
}
return normalizeTVDetail(&resp), nil
default:
return nil, fmt.Errorf("tmdb: invalid media type for detail: %q", mediaType)
}
}
func normalizeMovieDetail(resp *movieDetailResponse) *MediaDetail {
detail := &MediaDetail{
MediaType: "movie",
ID: resp.ID,
IMDbID: resp.IMDbID,
Title: resp.Title,
OriginalTitle: resp.OriginalTitle,
Tagline: resp.Tagline,
Overview: resp.Overview,
PosterPath: resp.PosterPath,
BackdropPath: resp.BackdropPath,
ReleaseDate: resp.ReleaseDate,
Year: releaseYear(resp.ReleaseDate),
Runtime: resp.Runtime,
Genres: namesFromGenres(resp.Genres),
VoteAverage: resp.VoteAverage,
VoteCount: resp.VoteCount,
Status: resp.Status,
Homepage: resp.Homepage,
ContentRating: pickMovieCertification(resp.ReleaseDates),
}
for _, company := range resp.ProductionCompanies {
if name := strings.TrimSpace(company.Name); name != "" {
detail.ProductionCompanies = append(detail.ProductionCompanies, name)
}
}
if resp.ExternalIDs != nil {
detail.TVDBID = resp.ExternalIDs.TVDBID
if detail.IMDbID == "" {
detail.IMDbID = resp.ExternalIDs.IMDbID
}
}
if resp.Credits != nil {
detail.Cast = normalizeCast(resp.Credits.Cast)
detail.Director = pickDirector(resp.Credits.Crew)
}
if resp.Recommendations != nil {
detail.Recommendations = make([]MediaResult, 0, len(resp.Recommendations.Results))
for _, item := range resp.Recommendations.Results {
detail.Recommendations = append(detail.Recommendations, MediaResult{
ID: item.ID,
MediaType: "movie",
Title: item.Title,
Overview: item.Overview,
PosterPath: item.PosterPath,
BackdropPath: item.BackdropPath,
ReleaseDate: item.ReleaseDate,
Year: releaseYear(item.ReleaseDate),
Popularity: item.Popularity,
VoteAverage: item.VoteAverage,
})
}
}
return detail
}
func normalizeTVDetail(resp *tvDetailResponse) *MediaDetail {
detail := &MediaDetail{
MediaType: "series",
ID: resp.ID,
Title: resp.Name,
OriginalTitle: resp.OriginalName,
Tagline: resp.Tagline,
Overview: resp.Overview,
PosterPath: resp.PosterPath,
BackdropPath: resp.BackdropPath,
ReleaseDate: resp.FirstAirDate,
FirstAirDate: resp.FirstAirDate,
LastAirDate: resp.LastAirDate,
Year: releaseYear(resp.FirstAirDate),
Genres: namesFromGenres(resp.Genres),
VoteAverage: resp.VoteAverage,
VoteCount: resp.VoteCount,
Status: resp.Status,
Homepage: resp.Homepage,
NumberOfSeasons: resp.NumberOfSeasons,
NumberOfEpisodes: resp.NumberOfEpisodes,
ContentRating: pickTVRating(resp.ContentRatings),
}
if len(resp.EpisodeRunTime) > 0 {
detail.Runtime = resp.EpisodeRunTime[0]
}
for _, network := range resp.Networks {
if name := strings.TrimSpace(network.Name); name != "" {
detail.Networks = append(detail.Networks, name)
}
}
if resp.ExternalIDs != nil {
detail.IMDbID = resp.ExternalIDs.IMDbID
detail.TVDBID = resp.ExternalIDs.TVDBID
}
if resp.Credits != nil {
detail.Cast = normalizeCast(resp.Credits.Cast)
}
for _, person := range resp.CreatedBy {
if name := strings.TrimSpace(person.Name); name != "" {
detail.Creators = append(detail.Creators, name)
}
}
if resp.Recommendations != nil {
detail.Recommendations = make([]MediaResult, 0, len(resp.Recommendations.Results))
for _, item := range resp.Recommendations.Results {
detail.Recommendations = append(detail.Recommendations, MediaResult{
ID: item.ID,
MediaType: "series",
Title: item.Name,
Overview: item.Overview,
PosterPath: item.PosterPath,
BackdropPath: item.BackdropPath,
ReleaseDate: item.FirstAirDate,
Year: releaseYear(item.FirstAirDate),
Popularity: item.Popularity,
VoteAverage: item.VoteAverage,
})
}
}
return detail
}
func namesFromGenres(genres []genreEntry) []string {
if len(genres) == 0 {
return nil
}
out := make([]string, 0, len(genres))
for _, g := range genres {
if name := strings.TrimSpace(g.Name); name != "" {
out = append(out, name)
}
}
return out
}
// normalizeCast sorts by billing order and caps the result so the response
// payload stays bounded — the request detail UI surfaces only the top of the
// list anyway.
func normalizeCast(cast []castEntry) []MediaCastMember {
if len(cast) == 0 {
return nil
}
sorted := make([]castEntry, len(cast))
copy(sorted, cast)
sort.SliceStable(sorted, func(i, j int) bool {
return sorted[i].Order < sorted[j].Order
})
const maxCast = 24
if len(sorted) > maxCast {
sorted = sorted[:maxCast]
}
out := make([]MediaCastMember, 0, len(sorted))
for _, member := range sorted {
name := strings.TrimSpace(member.Name)
if name == "" {
continue
}
out = append(out, MediaCastMember{
Name: name,
Character: strings.TrimSpace(member.Character),
ProfilePath: member.ProfilePath,
Order: member.Order,
})
}
return out
}
func pickDirector(crew []crewEntry) string {
for _, member := range crew {
if strings.EqualFold(member.Job, "Director") {
return strings.TrimSpace(member.Name)
}
}
return ""
}
// pickMovieCertification picks the US theatrical certification if available,
// then falls back to any non-empty US certification, then any non-empty
// certification at all. Type 3 is theatrical in TMDB's release-type taxonomy.
func pickMovieCertification(rd *releaseDatesResponse) string {
if rd == nil {
return ""
}
var fallbackUS, fallbackAny string
for _, country := range rd.Results {
isUS := strings.EqualFold(country.ISO3166, "US")
for _, entry := range country.ReleaseDates {
cert := strings.TrimSpace(entry.Certification)
if cert == "" {
continue
}
if isUS && entry.Type == 3 {
return cert
}
if isUS && fallbackUS == "" {
fallbackUS = cert
}
if fallbackAny == "" {
fallbackAny = cert
}
}
}
if fallbackUS != "" {
return fallbackUS
}
return fallbackAny
}
func pickTVRating(cr *contentRatingsResponse) string {
if cr == nil {
return ""
}
var fallback string
for _, entry := range cr.Results {
rating := strings.TrimSpace(entry.Rating)
if rating == "" {
continue
}
if strings.EqualFold(entry.ISO3166, "US") {
return rating
}
if fallback == "" {
fallback = rating
}
}
return fallback
}
// GetExternalIDs fetches external IDs for a TMDB movie or TV entry.
// Uses the dedicated external_ids endpoint instead of append_to_response on
// the full detail, which would return a 100+ KB payload to extract a handful
// of identifiers.
func (c *Client) GetExternalIDs(ctx context.Context, mediaType string, id int) (*ExternalIDs, error) {
var path string
switch mediaType {
case "movie":
path = fmt.Sprintf("/movie/%d?append_to_response=external_ids", id)
path = fmt.Sprintf("/movie/%d/external_ids", id)
case "tv":
path = fmt.Sprintf("/tv/%d?append_to_response=external_ids", id)
path = fmt.Sprintf("/tv/%d/external_ids", id)
default:
return nil, fmt.Errorf("tmdb: invalid media type: %q", mediaType)
}
var resp externalIDsResponse
cacheKey := "external_ids:" + path
if c.externalIDCache != nil {
if cached, ok := c.externalIDCache.Get(cacheKey); ok {
return cloneExternalIDs(cached), nil
}
}
value, err, _ := c.cacheGroup.Do(cacheKey, func() (any, error) {
if c.externalIDCache != nil {
if cached, ok := c.externalIDCache.Get(cacheKey); ok {
return cached, nil
}
}
ids, err := c.fetchExternalIDs(ctx, path)
if err != nil {
return nil, err
}
cached := cloneExternalIDs(ids)
if c.externalIDCache != nil && c.responseCacheTTL > 0 {
c.externalIDCache.Set(cacheKey, cached, c.responseCacheTTL)
}
return cached, nil
})
if err != nil {
return nil, err
}
ids, ok := value.(*ExternalIDs)
if !ok {
return nil, fmt.Errorf("tmdb: invalid cached external IDs response")
}
return cloneExternalIDs(ids), nil
}
func cloneExternalIDs(ids *ExternalIDs) *ExternalIDs {
if ids == nil {
return nil
}
cloned := *ids
return &cloned
}
func (c *Client) fetchExternalIDs(ctx context.Context, path string) (*ExternalIDs, error) {
var resp ExternalIDs
if err := c.doGet(ctx, path, &resp); err != nil {
return nil, err
}
return resp.ExternalIDs, nil
return &resp, nil
}
+552 -8
View File
@@ -4,9 +4,28 @@ import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)
func TestNewClientUsesProjectAPIKeyWhenEmpty(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("api_key"); got != projectAPIKey {
t.Fatalf("api_key query = %q, want project API key", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"page":1,"total_pages":1,"total_results":0,"results":[]}`))
}))
defer server.Close()
client := NewClient("", 1000)
client.SetBaseURL(server.URL)
if _, err := client.GetCollectionPreset(context.Background(), "trending", "all", "day", 10); err != nil {
t.Fatalf("GetCollectionPreset returned error: %v", err)
}
}
func TestGetCollectionPresetTrending(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/trending/all/day" {
@@ -52,6 +71,212 @@ func TestGetCollectionPresetTrending(t *testing.T) {
}
}
func TestDiscoverSectionCachesSuccess(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.URL.Path != "/movie/popular" {
http.NotFound(w, r)
return
}
if got := r.URL.Query().Get("page"); got != "1" {
t.Fatalf("page query = %q, want 1", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 1,
"total_pages": 1,
"total_results": 1,
"results": [
{"id": 11, "title": "Cached Movie", "overview": "from tmdb"}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
first, err := client.DiscoverSection(context.Background(), "popular_movies", 1)
if err != nil {
t.Fatalf("first DiscoverSection returned error: %v", err)
}
second, err := client.DiscoverSection(context.Background(), "popular_movies", 1)
if err != nil {
t.Fatalf("second DiscoverSection returned error: %v", err)
}
if got := calls.Load(); got != 1 {
t.Fatalf("upstream calls = %d, want 1", got)
}
for name, page := range map[string]*MediaPage{"first": first, "second": second} {
if page == nil || len(page.Results) != 1 {
t.Fatalf("%s page results = %#v, want one result", name, page)
}
if page.Results[0].ID != 11 || page.Results[0].Title != "Cached Movie" {
t.Fatalf("%s result = %+v", name, page.Results[0])
}
}
}
func TestDiscoverSectionCacheKeyIncludesPage(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.URL.Path != "/movie/popular" {
http.NotFound(w, r)
return
}
page := r.URL.Query().Get("page")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": ` + page + `,
"total_pages": 2,
"total_results": 2,
"results": [
{"id": ` + page + `, "title": "Movie ` + page + `"}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
first, err := client.DiscoverSection(context.Background(), "popular_movies", 1)
if err != nil {
t.Fatalf("page 1 DiscoverSection returned error: %v", err)
}
second, err := client.DiscoverSection(context.Background(), "popular_movies", 2)
if err != nil {
t.Fatalf("page 2 DiscoverSection returned error: %v", err)
}
if got := calls.Load(); got != 2 {
t.Fatalf("upstream calls = %d, want 2", got)
}
if first.Results[0].ID != 1 || second.Results[0].ID != 2 {
t.Fatalf("cached pages collapsed unexpectedly: first=%+v second=%+v", first.Results[0], second.Results[0])
}
}
func TestDiscoverSectionDoesNotCacheClientErrors(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
call := calls.Add(1)
if r.URL.Path != "/movie/popular" {
http.NotFound(w, r)
return
}
if call == 1 {
http.Error(w, `{"status_message":"bad section"}`, http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 1,
"total_pages": 1,
"total_results": 1,
"results": [
{"id": 22, "title": "Recovered Movie"}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
if _, err := client.DiscoverSection(context.Background(), "popular_movies", 1); err == nil {
t.Fatal("first DiscoverSection expected error")
}
page, err := client.DiscoverSection(context.Background(), "popular_movies", 1)
if err != nil {
t.Fatalf("second DiscoverSection returned error: %v", err)
}
if got := calls.Load(); got != 2 {
t.Fatalf("upstream calls = %d, want 2", got)
}
if page == nil || len(page.Results) != 1 || page.Results[0].ID != 22 {
t.Fatalf("second page = %#v, want recovered movie", page)
}
}
func TestDiscoverSectionReturnsClonedCachedPage(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.URL.Path != "/movie/popular" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 1,
"total_pages": 1,
"total_results": 1,
"results": [
{"id": 33, "title": "Immutable Movie"}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
first, err := client.DiscoverSection(context.Background(), "popular_movies", 1)
if err != nil {
t.Fatalf("first DiscoverSection returned error: %v", err)
}
first.Results[0].Title = "mutated by caller"
second, err := client.DiscoverSection(context.Background(), "popular_movies", 1)
if err != nil {
t.Fatalf("second DiscoverSection returned error: %v", err)
}
if got := calls.Load(); got != 1 {
t.Fatalf("upstream calls = %d, want 1", got)
}
if second.Results[0].Title != "Immutable Movie" {
t.Fatalf("cached title = %q, want Immutable Movie", second.Results[0].Title)
}
}
func TestGetExternalIDsCachesSuccess(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.URL.Path != "/movie/123/external_ids" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"imdb_id":"tt123","tvdb_id":456}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
first, err := client.GetExternalIDs(context.Background(), "movie", 123)
if err != nil {
t.Fatalf("first GetExternalIDs returned error: %v", err)
}
second, err := client.GetExternalIDs(context.Background(), "movie", 123)
if err != nil {
t.Fatalf("second GetExternalIDs returned error: %v", err)
}
if got := calls.Load(); got != 1 {
t.Fatalf("upstream calls = %d, want 1", got)
}
if first.IMDbID != "tt123" || first.TVDBID != 456 || second.IMDbID != "tt123" || second.TVDBID != 456 {
t.Fatalf("external IDs = first %+v second %+v", first, second)
}
}
func TestDiscoverMovieAppliesFilters(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -204,6 +429,34 @@ func TestDiscoverTVUsesFirstAirDate(t *testing.T) {
}
}
func TestDiscoverIncludesCompaniesAndNetworks(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if got := q.Get("with_companies"); got != "420,2" {
t.Errorf("with_companies = %q, want 420,2", got)
}
if got := q.Get("with_networks"); got != "213,49" {
t.Errorf("with_networks = %q, want 213,49", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"page":1,"total_pages":1,"total_results":0,"results":[]}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
_, err := client.Discover(context.Background(), "movie", DiscoverParams{
SortBy: "popularity.desc",
WithCompanies: []int{420, 2},
WithNetworks: []int{213, 49},
Limit: 5,
})
if err != nil {
t.Fatalf("Discover returned error: %v", err)
}
}
func TestDiscoverRejectsInvalidMediaType(t *testing.T) {
client := NewClient("test-key", 1000)
_, err := client.Discover(context.Background(), "all", DiscoverParams{SortBy: "popularity.desc"})
@@ -220,6 +473,302 @@ func TestDiscoverRequiresSortBy(t *testing.T) {
}
}
func TestDiscoverPageMovieReturnsFullResults(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/discover/movie" {
http.NotFound(w, r)
return
}
q := r.URL.Query()
if got := q.Get("sort_by"); got != "popularity.desc" {
t.Errorf("sort_by = %q, want popularity.desc", got)
}
if got := q.Get("with_companies"); got != "420" {
t.Errorf("with_companies = %q, want 420", got)
}
if got := q.Get("page"); got != "2" {
t.Errorf("page = %q, want 2", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 2,
"total_pages": 8,
"total_results": 160,
"results": [
{"id": 24428, "title": "The Avengers", "release_date": "2012-04-25", "poster_path": "/p.jpg", "overview": "earth's mightiest", "popularity": 100.5, "vote_average": 7.7}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
page, err := client.DiscoverPage(context.Background(), "movie", DiscoverParams{
SortBy: "popularity.desc",
WithCompanies: []int{420},
}, 2)
if err != nil {
t.Fatalf("DiscoverPage: %v", err)
}
if page.Page != 2 || page.TotalPages != 8 || page.TotalResults != 160 {
t.Fatalf("page = %+v", page)
}
if len(page.Results) != 1 {
t.Fatalf("results = %d, want 1", len(page.Results))
}
got := page.Results[0]
if got.ID != 24428 || got.MediaType != "movie" || got.Title != "The Avengers" || got.Year != 2012 {
t.Errorf("result = %+v", got)
}
if got.PosterPath != "/p.jpg" || got.Overview != "earth's mightiest" {
t.Errorf("result detail mismatch: %+v", got)
}
}
func TestDiscoverPageTVUsesFirstAirDate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/discover/tv" {
http.NotFound(w, r)
return
}
q := r.URL.Query()
if got := q.Get("with_networks"); got != "213" {
t.Errorf("with_networks = %q, want 213", got)
}
if got := q.Get("first_air_date.gte"); got != "" {
t.Errorf("first_air_date.gte = %q, want empty", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 1,
"total_pages": 1,
"total_results": 1,
"results": [
{"id": 1399, "name": "Game of Thrones", "first_air_date": "2011-04-17", "poster_path": "/g.jpg"}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
page, err := client.DiscoverPage(context.Background(), "tv", DiscoverParams{
SortBy: "vote_average.desc",
WithNetworks: []int{213},
}, 1)
if err != nil {
t.Fatalf("DiscoverPage tv: %v", err)
}
if len(page.Results) != 1 {
t.Fatalf("results = %d, want 1", len(page.Results))
}
got := page.Results[0]
if got.MediaType != "series" || got.Title != "Game of Thrones" || got.Year != 2011 {
t.Errorf("result = %+v", got)
}
}
func TestDiscoverPageRejectsInvalidMediaType(t *testing.T) {
client := NewClient("test-key", 1000)
_, err := client.DiscoverPage(context.Background(), "all", DiscoverParams{SortBy: "popularity.desc"}, 1)
if err == nil {
t.Fatal("expected error for invalid media type")
}
}
func TestDiscoverPageRequiresSortBy(t *testing.T) {
client := NewClient("test-key", 1000)
_, err := client.DiscoverPage(context.Background(), "movie", DiscoverParams{}, 1)
if err == nil {
t.Fatal("expected error when sort_by is empty")
}
}
func TestDiscoverPageDefaultsToPage1(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("page"); got != "1" {
t.Errorf("page = %q, want 1", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"page":1,"total_pages":1,"total_results":0,"results":[]}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
if _, err := client.DiscoverPage(context.Background(), "movie", DiscoverParams{SortBy: "popularity.desc"}, 0); err != nil {
t.Fatalf("DiscoverPage: %v", err)
}
}
func TestSearchMediaMovie(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/search/movie" {
http.NotFound(w, r)
return
}
q := r.URL.Query()
if got := q.Get("query"); got != "fight club" {
t.Fatalf("query = %q, want fight club", got)
}
if got := q.Get("include_adult"); got != "false" {
t.Fatalf("include_adult = %q, want false", got)
}
if got := q.Get("page"); got != "2" {
t.Fatalf("page = %q, want 2", got)
}
if got := q.Get("api_key"); got != "test-key" {
t.Fatalf("api_key query = %q, want test-key", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 2,
"total_pages": 5,
"total_results": 50,
"results": [
{
"id": 550,
"title": "Fight Club",
"overview": "overview",
"poster_path": "/poster.jpg",
"backdrop_path": "/backdrop.jpg",
"release_date": "1999-10-15",
"popularity": 10.5,
"vote_average": 8.4
}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
page, err := client.SearchMedia(context.Background(), "movie", "fight club", 2)
if err != nil {
t.Fatalf("SearchMedia returned error: %v", err)
}
if page.Page != 2 || page.TotalPages != 5 || len(page.Results) != 1 {
t.Fatalf("page = %+v, want page metadata and one result", page)
}
result := page.Results[0]
if result.ID != 550 || result.MediaType != "movie" || result.Year != 1999 {
t.Fatalf("result = %+v, want normalized movie result", result)
}
}
func TestSearchMediaAllUsesMultiSearchAndFiltersToMoviesAndSeries(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/search/multi" {
http.NotFound(w, r)
return
}
q := r.URL.Query()
if got := q.Get("query"); got != "fight club" {
t.Fatalf("query = %q, want fight club", got)
}
if got := q.Get("include_adult"); got != "false" {
t.Fatalf("include_adult = %q, want false", got)
}
if got := q.Get("page"); got != "2" {
t.Fatalf("page = %q, want 2", got)
}
if got := q.Get("api_key"); got != "test-key" {
t.Fatalf("api_key query = %q, want test-key", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 2,
"total_pages": 4,
"total_results": 40,
"results": [
{
"id": 550,
"media_type": "movie",
"title": "Fight Club",
"release_date": "1999-10-15"
},
{
"id": 1399,
"media_type": "tv",
"name": "Fight Club: The Series",
"first_air_date": "2020-01-01"
},
{
"id": 123,
"media_type": "person",
"name": "A Performer"
}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
page, err := client.SearchMedia(context.Background(), "all", "fight club", 2)
if err != nil {
t.Fatalf("SearchMedia returned error: %v", err)
}
if page.Page != 2 || page.TotalPages != 4 || page.TotalResults != 40 {
t.Fatalf("page metadata = %+v, want TMDB pagination metadata", page)
}
if len(page.Results) != 2 {
t.Fatalf("len(results) = %d, want 2", len(page.Results))
}
if page.Results[0].ID != 550 || page.Results[0].MediaType != "movie" || page.Results[0].Year != 1999 {
t.Fatalf("results[0] = %+v, want normalized movie", page.Results[0])
}
if page.Results[1].ID != 1399 || page.Results[1].MediaType != "series" || page.Results[1].Year != 2020 {
t.Fatalf("results[1] = %+v, want normalized series", page.Results[1])
}
}
func TestDiscoverSectionTrendingSeries(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/trending/tv/week" {
http.NotFound(w, r)
return
}
if got := r.URL.Query().Get("page"); got != "1" {
t.Fatalf("page = %q, want 1", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 1,
"total_pages": 1,
"total_results": 1,
"results": [
{
"id": 1399,
"name": "Game of Thrones",
"first_air_date": "2011-04-17"
}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
page, err := client.DiscoverSection(context.Background(), "trending_series", 1)
if err != nil {
t.Fatalf("DiscoverSection returned error: %v", err)
}
if len(page.Results) != 1 {
t.Fatalf("len(results) = %d, want 1", len(page.Results))
}
result := page.Results[0]
if result.ID != 1399 || result.MediaType != "series" || result.Year != 2011 {
t.Fatalf("result = %+v, want normalized series result", result)
}
}
func TestGetCollection(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/collection/86311" {
@@ -304,23 +853,18 @@ func TestGetCollectionRejectsNonPositiveID(t *testing.T) {
func TestGetExternalIDs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/movie/123" {
if r.URL.Path != "/movie/123/external_ids" {
http.NotFound(w, r)
return
}
if got := r.URL.Query().Get("append_to_response"); got != "external_ids" {
t.Fatalf("append_to_response = %q, want external_ids", got)
}
if got := r.URL.Query().Get("api_key"); got != "test-key" {
t.Fatalf("api_key query = %q, want test-key", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"external_ids": {
"imdb_id": "tt0133093",
"tvdb_id": 12345
}
"imdb_id": "tt0133093",
"tvdb_id": 12345
}`))
}))
defer server.Close()
+234 -2
View File
@@ -35,12 +35,43 @@ type CollectionResult struct {
Title string
}
// MediaResult is a normalized TMDB movie or TV result for request search and
// discovery surfaces. MediaType is Silo-facing: "movie" or "series".
//
// PosterPath and BackdropPath are raw TMDB path fragments (e.g. "/abc.jpg").
// Callers must compose the full URL by prepending the TMDB image base
// (https://image.tmdb.org/t/p/{size}); the values are not browser-loadable
// on their own.
type MediaResult struct {
ID int
MediaType string
Title string
Overview string
PosterPath string
BackdropPath string
ReleaseDate string
Year int
Popularity float64
VoteAverage float64
}
// MediaPage is a paginated TMDB search/discovery response normalized for the
// request system.
type MediaPage struct {
Page int
TotalPages int
TotalResults int
Results []MediaResult
}
// DiscoverParams mirrors the TMDB `/discover/{movie,tv}` query parameters and
// is shaped to map 1:1 onto TMDBDiscoverSpec. Limit caps the total results
// the client paginates over.
type DiscoverParams struct {
WithGenres []int
WithoutGenres []int
WithCompanies []int
WithNetworks []int
SortBy string
VoteCountGte int
VoteAverageGte float64
@@ -95,11 +126,212 @@ type collectionResponsePart struct {
ReleaseDate string `json:"release_date"`
}
type externalIDsResponse struct {
ExternalIDs *ExternalIDs `json:"external_ids"`
type mediaMovieResponse struct {
ID int `json:"id"`
Title string `json:"title"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
ReleaseDate string `json:"release_date"`
Popularity float64 `json:"popularity"`
VoteAverage float64 `json:"vote_average"`
}
type mediaTVResponse struct {
ID int `json:"id"`
Name string `json:"name"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
FirstAirDate string `json:"first_air_date"`
Popularity float64 `json:"popularity"`
VoteAverage float64 `json:"vote_average"`
}
type mediaMultiSearchResponse struct {
ID int `json:"id"`
MediaType string `json:"media_type"`
Title string `json:"title"`
Name string `json:"name"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
ReleaseDate string `json:"release_date"`
FirstAirDate string `json:"first_air_date"`
Popularity float64 `json:"popularity"`
VoteAverage float64 `json:"vote_average"`
}
type apiError struct {
StatusMessage string `json:"status_message"`
StatusCode int `json:"status_code"`
}
// MediaDetail is a normalized TMDB detail payload for the request system.
// MediaType is Silo-facing: "movie" or "series". Series-specific fields are
// zero-valued for movies and vice versa.
type MediaDetail struct {
MediaType string
ID int
IMDbID string
TVDBID int
Title string
OriginalTitle string
Tagline string
Overview string
PosterPath string
BackdropPath string
ReleaseDate string
Year int
Runtime int
Genres []string
VoteAverage float64
VoteCount int
Status string
Homepage string
ContentRating string
ProductionCompanies []string
NumberOfSeasons int
NumberOfEpisodes int
FirstAirDate string
LastAirDate string
Networks []string
Cast []MediaCastMember
Director string
Creators []string
Recommendations []MediaResult
}
// MediaCastMember is a single cast member entry from a TMDB credits response,
// normalized for the request detail surface.
type MediaCastMember struct {
Name string
Character string
ProfilePath string
Order int
}
// genreEntry / companyEntry / networkEntry / personEntry mirror small object
// shapes from the TMDB JSON. They're internal to the decode path.
type genreEntry struct {
ID int `json:"id"`
Name string `json:"name"`
}
type companyEntry struct {
ID int `json:"id"`
Name string `json:"name"`
}
type networkEntry struct {
ID int `json:"id"`
Name string `json:"name"`
}
type creditsResponse struct {
Cast []castEntry `json:"cast"`
Crew []crewEntry `json:"crew"`
}
type castEntry struct {
ID int `json:"id"`
Name string `json:"name"`
Character string `json:"character"`
ProfilePath string `json:"profile_path"`
Order int `json:"order"`
}
type crewEntry struct {
ID int `json:"id"`
Name string `json:"name"`
Job string `json:"job"`
Department string `json:"department"`
}
type personEntry struct {
ID int `json:"id"`
Name string `json:"name"`
}
type recommendationsMovieResponse struct {
Results []mediaMovieResponse `json:"results"`
}
type recommendationsTVResponse struct {
Results []mediaTVResponse `json:"results"`
}
type releaseDatesResponse struct {
Results []releaseDatesCountryEntry `json:"results"`
}
type releaseDatesCountryEntry struct {
ISO3166 string `json:"iso_3166_1"`
ReleaseDates []releaseDateEntry `json:"release_dates"`
}
type releaseDateEntry struct {
Certification string `json:"certification"`
ReleaseDate string `json:"release_date"`
Type int `json:"type"`
}
type contentRatingsResponse struct {
Results []contentRatingEntry `json:"results"`
}
type contentRatingEntry struct {
ISO3166 string `json:"iso_3166_1"`
Rating string `json:"rating"`
}
type movieDetailResponse struct {
ID int `json:"id"`
IMDbID string `json:"imdb_id"`
Title string `json:"title"`
OriginalTitle string `json:"original_title"`
Tagline string `json:"tagline"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
ReleaseDate string `json:"release_date"`
Runtime int `json:"runtime"`
Genres []genreEntry `json:"genres"`
VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"`
Status string `json:"status"`
Homepage string `json:"homepage"`
ProductionCompanies []companyEntry `json:"production_companies"`
Credits *creditsResponse `json:"credits"`
ExternalIDs *ExternalIDs `json:"external_ids"`
Recommendations *recommendationsMovieResponse `json:"recommendations"`
ReleaseDates *releaseDatesResponse `json:"release_dates"`
}
type tvDetailResponse struct {
ID int `json:"id"`
Name string `json:"name"`
OriginalName string `json:"original_name"`
Tagline string `json:"tagline"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
FirstAirDate string `json:"first_air_date"`
LastAirDate string `json:"last_air_date"`
EpisodeRunTime []int `json:"episode_run_time"`
NumberOfSeasons int `json:"number_of_seasons"`
NumberOfEpisodes int `json:"number_of_episodes"`
Genres []genreEntry `json:"genres"`
VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"`
Status string `json:"status"`
Homepage string `json:"homepage"`
Networks []networkEntry `json:"networks"`
CreatedBy []personEntry `json:"created_by"`
Credits *creditsResponse `json:"credits"`
ExternalIDs *ExternalIDs `json:"external_ids"`
Recommendations *recommendationsTVResponse `json:"recommendations"`
ContentRatings *contentRatingsResponse `json:"content_ratings"`
}
+120
View File
@@ -0,0 +1,120 @@
package arrclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const maxResponseBody = 1 << 20
type Client struct {
baseURL string
apiKey string
httpClient *http.Client
}
type HTTPError struct {
StatusCode int
Body string
}
type DecodeError struct {
StatusCode int
Err error
}
func (e HTTPError) Error() string {
if e.Body == "" {
return fmt.Sprintf("arr: HTTP %d", e.StatusCode)
}
return fmt.Sprintf("arr: HTTP %d: %s", e.StatusCode, e.Body)
}
func (e DecodeError) Error() string {
return fmt.Sprintf("arr: decode response: %v", e.Err)
}
func (e DecodeError) Unwrap() error {
return e.Err
}
func IsEmptyOrTruncatedDecodeError(err error) bool {
var decodeErr DecodeError
if !errors.As(err, &decodeErr) {
return false
}
return errors.Is(decodeErr.Err, io.EOF) || errors.Is(decodeErr.Err, io.ErrUnexpectedEOF)
}
func New(baseURL, apiKey string, httpClient *http.Client) *Client {
if httpClient == nil {
httpClient = &http.Client{Timeout: 30 * time.Second}
}
return &Client{
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
apiKey: strings.TrimSpace(apiKey),
httpClient: httpClient,
}
}
func (c *Client) GetJSON(ctx context.Context, path string, dest any) error {
return c.DoJSON(ctx, http.MethodGet, path, nil, dest)
}
func (c *Client) PostJSON(ctx context.Context, path string, body, dest any) error {
return c.DoJSON(ctx, http.MethodPost, path, body, dest)
}
func (c *Client) DoJSON(ctx context.Context, method, path string, body, dest any) error {
if c.baseURL == "" {
return fmt.Errorf("arr: base url is required")
}
if c.apiKey == "" {
return fmt.Errorf("arr: api key is required")
}
var reader io.Reader
if body != nil {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(body); err != nil {
return fmt.Errorf("arr: encode request: %w", err)
}
reader = &buf
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
if err != nil {
return fmt.Errorf("arr: create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Api-Key", c.apiKey)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("arr: request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
return HTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
}
if dest == nil || resp.StatusCode == http.StatusNoContent {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBody))
return nil
}
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(dest); err != nil {
return DecodeError{StatusCode: resp.StatusCode, Err: err}
}
return nil
}
+34
View File
@@ -0,0 +1,34 @@
package arrclient
import (
"strconv"
"strings"
)
func BoolOption(options map[string]any, key string, fallback bool) bool {
value, ok := options[key]
if !ok {
return fallback
}
switch typed := value.(type) {
case bool:
return typed
case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(typed))
if err == nil {
return parsed
}
}
return fallback
}
func StringOption(options map[string]any, key, fallback string) string {
value, ok := options[key]
if !ok {
return fallback
}
if typed, ok := value.(string); ok && strings.TrimSpace(typed) != "" {
return strings.TrimSpace(typed)
}
return fallback
}
+85
View File
@@ -0,0 +1,85 @@
package arrclient
import "strings"
type QueueResource struct {
ID int `json:"id,omitempty"`
MovieID int `json:"movieId,omitempty"`
SeriesID int `json:"seriesId,omitempty"`
Title string `json:"title,omitempty"`
Status string `json:"status,omitempty"`
TrackedDownloadStatus string `json:"trackedDownloadStatus,omitempty"`
TrackedDownloadState string `json:"trackedDownloadState,omitempty"`
DownloadID string `json:"downloadId,omitempty"`
OutputPath string `json:"outputPath,omitempty"`
}
type QueueEvaluation struct {
State string
ExternalStatus string
Message string
}
const (
QueueStateQueued = "queued"
QueueStateDownloading = "downloading"
QueueStateFailed = "failed"
)
func EvaluateQueue(resources []QueueResource) QueueEvaluation {
if len(resources) == 0 {
return QueueEvaluation{State: QueueStateQueued, ExternalStatus: "not_in_queue"}
}
queued := false
downloadingStatus := ""
for _, resource := range resources {
status := strings.TrimSpace(resource.Status)
state := strings.TrimSpace(resource.TrackedDownloadState)
externalStatus := joinStatus(status, state)
if status == "failed" || state == "failed" || state == "failedPending" {
return QueueEvaluation{
State: QueueStateFailed,
ExternalStatus: externalStatus,
Message: queueFailureMessage(resource, externalStatus),
}
}
if status == "downloading" || state == "downloading" || state == "importPending" || state == "importing" {
if downloadingStatus == "" {
downloadingStatus = externalStatus
}
queued = true
continue
}
queued = true
}
if downloadingStatus != "" {
return QueueEvaluation{State: QueueStateDownloading, ExternalStatus: downloadingStatus}
}
if queued {
return QueueEvaluation{State: QueueStateQueued, ExternalStatus: "queued"}
}
return QueueEvaluation{State: QueueStateQueued, ExternalStatus: "unknown"}
}
func joinStatus(status, state string) string {
switch {
case status != "" && state != "":
return status + "/" + state
case status != "":
return status
case state != "":
return state
default:
return "unknown"
}
}
func queueFailureMessage(resource QueueResource, externalStatus string) string {
title := strings.TrimSpace(resource.Title)
if title == "" {
return "external queue failed: " + externalStatus
}
return "external queue failed for " + title + ": " + externalStatus
}
+20
View File
@@ -0,0 +1,20 @@
package arrclient
import "testing"
func TestEvaluateQueueFailureWinsOverDownloading(t *testing.T) {
result := EvaluateQueue([]QueueResource{
{Status: "downloading", TrackedDownloadState: "downloading"},
{Title: "Bad Release", Status: "queued", TrackedDownloadState: "failedPending"},
})
if result.State != QueueStateFailed {
t.Fatalf("state = %q, want failed", result.State)
}
if result.ExternalStatus != "queued/failedPending" {
t.Fatalf("external status = %q, want queued/failedPending", result.ExternalStatus)
}
if result.Message == "" {
t.Fatal("message should describe the queue failure")
}
}
+103
View File
@@ -0,0 +1,103 @@
package arrclient
import (
"context"
"strconv"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
)
type RootFolderResource struct {
Path string `json:"path"`
FreeSpace int64 `json:"freeSpace"`
TotalSpace int64 `json:"totalSpace"`
Accessible bool `json:"accessible"`
}
type QualityProfileResource struct {
ID int `json:"id"`
Name string `json:"name"`
}
type TagResource struct {
ID int `json:"id"`
Label string `json:"label"`
}
func ListRootFolders(ctx context.Context, client *Client) ([]mediarequests.IntegrationRootFolder, error) {
var resources []RootFolderResource
if err := client.GetJSON(ctx, "/api/v3/rootfolder", &resources); err != nil {
return nil, err
}
out := make([]mediarequests.IntegrationRootFolder, 0, len(resources))
for _, resource := range resources {
out = append(out, mediarequests.IntegrationRootFolder{
Path: resource.Path,
FreeSpace: resource.FreeSpace,
TotalSpace: resource.TotalSpace,
Accessible: resource.Accessible,
})
}
return out, nil
}
func ListQualityProfiles(ctx context.Context, client *Client) ([]mediarequests.IntegrationQualityProfile, error) {
var resources []QualityProfileResource
if err := client.GetJSON(ctx, "/api/v3/qualityprofile", &resources); err != nil {
return nil, err
}
out := make([]mediarequests.IntegrationQualityProfile, 0, len(resources))
for _, resource := range resources {
out = append(out, mediarequests.IntegrationQualityProfile{
ID: resource.ID,
Name: resource.Name,
})
}
return out, nil
}
func ListTags(ctx context.Context, client *Client) ([]mediarequests.IntegrationTag, error) {
var resources []TagResource
if err := client.GetJSON(ctx, "/api/v3/tag", &resources); err != nil {
return nil, err
}
out := make([]mediarequests.IntegrationTag, 0, len(resources))
for _, resource := range resources {
out = append(out, mediarequests.IntegrationTag{
ID: resource.ID,
Label: resource.Label,
})
}
return out, nil
}
// AcceptedWithoutResponse returns a FulfillmentResult marking the submission
// as accepted by the downstream integration but with no external id captured
// (typically a 201 with empty body when lookup recovery also fails).
func AcceptedWithoutResponse(kind string) mediarequests.FulfillmentResult {
return mediarequests.FulfillmentResult{
IntegrationKind: kind,
ExternalStatus: "accepted_without_response",
}
}
// StatusFromQueueEvaluation translates an arrclient.QueueEvaluation into the
// mediarequests.FulfillmentStatus shape shared by Radarr and Sonarr clients.
func StatusFromQueueEvaluation(kind string, externalID int, evaluation QueueEvaluation) mediarequests.FulfillmentStatus {
status := mediarequests.StatusQueued
outcome := mediarequests.Outcome("")
if evaluation.State == QueueStateDownloading {
status = mediarequests.StatusDownloading
}
if evaluation.State == QueueStateFailed {
outcome = mediarequests.OutcomeFailed
}
return mediarequests.FulfillmentStatus{
Status: status,
Outcome: outcome,
IntegrationKind: kind,
ExternalID: strconv.Itoa(externalID),
ExternalStatus: evaluation.ExternalStatus,
Message: evaluation.Message,
}
}
+268
View File
@@ -0,0 +1,268 @@
package requests
import (
"context"
"fmt"
"strings"
"github.com/Silo-Server/silo-server/internal/metadata/tmdb"
)
// DiscoverBrandCard is one card on the Studios / Networks / Genres carousels.
// Studios and networks carry a TMDB ID and a logo URL rendered with TMDB's
// duotone filter; genres carry gradient hints and a display name instead.
type DiscoverBrandCard struct {
TMDBID int `json:"tmdb_id,omitempty"`
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
LogoURL *string `json:"logo_url,omitempty"`
GradientFrom string `json:"gradient_from,omitempty"`
GradientTo string `json:"gradient_to,omitempty"`
SeriesSupported bool `json:"series_supported,omitempty"`
}
// ListStudios returns the bundled studios with their curated logo URLs.
func (s *Service) ListStudios(_ context.Context, _ Viewer) ([]DiscoverBrandCard, error) {
if s == nil {
return nil, fmt.Errorf("request service is not configured")
}
out := make([]DiscoverBrandCard, 0, len(BundledStudios))
for _, studio := range BundledStudios {
out = append(out, DiscoverBrandCard{
TMDBID: studio.TMDBID,
Slug: studio.Slug,
DisplayName: studio.DisplayName,
LogoURL: duotoneLogoURL(studio.LogoPath),
})
}
return out, nil
}
// ListNetworks returns the bundled TV networks with their curated logo URLs.
func (s *Service) ListNetworks(_ context.Context, _ Viewer) ([]DiscoverBrandCard, error) {
if s == nil {
return nil, fmt.Errorf("request service is not configured")
}
out := make([]DiscoverBrandCard, 0, len(BundledNetworks))
for _, network := range BundledNetworks {
out = append(out, DiscoverBrandCard{
TMDBID: network.TMDBID,
Slug: network.Slug,
DisplayName: network.DisplayName,
LogoURL: duotoneLogoURL(network.LogoPath),
})
}
return out, nil
}
// ListGenres returns the bundled genres. Each card carries gradient hints
// (no logo URL) and a SeriesSupported flag for the browse page to decide
// whether to show the Series tab.
func (s *Service) ListGenres(_ context.Context, _ Viewer) ([]DiscoverBrandCard, error) {
if s == nil {
return nil, fmt.Errorf("request service is not configured")
}
out := make([]DiscoverBrandCard, 0, len(BundledGenres))
for _, genre := range BundledGenres {
out = append(out, DiscoverBrandCard{
Slug: genre.Slug,
DisplayName: genre.DisplayName,
GradientFrom: genre.GradientFrom,
GradientTo: genre.GradientTo,
SeriesSupported: genre.SeriesID > 0,
})
}
return out, nil
}
// duotoneLogoURL returns a TMDB CDN URL that recolors the logo into a
// white-on-light-gray duotone. Studio/network logos vary wildly in color
// and contrast; the duotone treatment keeps every card legible against a
// neutral background. Returns nil for empty paths.
func duotoneLogoURL(path string) *string {
if path == "" {
return nil
}
url := "https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)" + path
return &url
}
// DiscoverBrowseResponse is the shape returned by the browse endpoints.
// Results share the same MediaResult shape as search and the existing
// discovery sections, so the frontend can reuse RequestPosterCard.
type DiscoverBrowseResponse struct {
Kind string `json:"kind"`
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
LogoURL *string `json:"logo_url,omitempty"`
MediaType MediaType `json:"media_type"`
Sort string `json:"sort"`
Page int `json:"page"`
TotalPages int `json:"total_pages"`
Results []MediaResult `json:"results"`
}
var validBrowseSorts = map[string]string{
"popularity": "popularity.desc",
"vote_average": "vote_average.desc",
"release_date": "primary_release_date.desc",
}
const defaultBrowseSort = "popularity"
// BrowseStudio returns a page of movies from a bundled studio, enriched with
// Silo availability and request state.
func (s *Service) BrowseStudio(ctx context.Context, viewer Viewer, slug, sort string, page int) (*DiscoverBrowseResponse, error) {
if s == nil || s.tmdb == nil {
return nil, fmt.Errorf("request service is not configured")
}
studio, ok := FindStudioBySlug(strings.TrimSpace(slug))
if !ok {
return nil, ErrNotFound
}
tmdbSort, sortKey, err := normalizeBrowseSort(sort, "movie")
if err != nil {
return nil, err
}
tmdbPage, err := s.tmdb.DiscoverPage(ctx, "movie", tmdb.DiscoverParams{
SortBy: tmdbSort,
WithCompanies: []int{studio.TMDBID},
VoteCountGte: voteCountFloorForSort(sortKey),
}, page)
if err != nil {
return nil, err
}
enriched, err := s.enrichPage(ctx, viewer, tmdbPage)
if err != nil {
return nil, err
}
return &DiscoverBrowseResponse{
Kind: "studio",
Slug: studio.Slug,
DisplayName: studio.DisplayName,
LogoURL: duotoneLogoURL(studio.LogoPath),
MediaType: MediaTypeMovie,
Sort: sortKey,
Page: enriched.Page,
TotalPages: enriched.TotalPages,
Results: enriched.Results,
}, nil
}
// BrowseNetwork returns a page of series from a bundled TV network.
func (s *Service) BrowseNetwork(ctx context.Context, viewer Viewer, slug, sort string, page int) (*DiscoverBrowseResponse, error) {
if s == nil || s.tmdb == nil {
return nil, fmt.Errorf("request service is not configured")
}
network, ok := FindNetworkBySlug(strings.TrimSpace(slug))
if !ok {
return nil, ErrNotFound
}
tmdbSort, sortKey, err := normalizeBrowseSort(sort, "tv")
if err != nil {
return nil, err
}
tmdbPage, err := s.tmdb.DiscoverPage(ctx, "tv", tmdb.DiscoverParams{
SortBy: tmdbSort,
WithNetworks: []int{network.TMDBID},
VoteCountGte: voteCountFloorForSort(sortKey),
}, page)
if err != nil {
return nil, err
}
enriched, err := s.enrichPage(ctx, viewer, tmdbPage)
if err != nil {
return nil, err
}
return &DiscoverBrowseResponse{
Kind: "network",
Slug: network.Slug,
DisplayName: network.DisplayName,
LogoURL: duotoneLogoURL(network.LogoPath),
MediaType: MediaTypeSeries,
Sort: sortKey,
Page: enriched.Page,
TotalPages: enriched.TotalPages,
Results: enriched.Results,
}, nil
}
// BrowseGenre returns a page of movies or series from a bundled genre.
func (s *Service) BrowseGenre(ctx context.Context, viewer Viewer, slug string, rawMediaType MediaType, sort string, page int) (*DiscoverBrowseResponse, error) {
if s == nil || s.tmdb == nil {
return nil, fmt.Errorf("request service is not configured")
}
genre, ok := FindGenreBySlug(strings.TrimSpace(slug))
if !ok {
return nil, ErrNotFound
}
mediaType, err := normalizeMediaType(rawMediaType)
if err != nil {
return nil, fmt.Errorf("%w: media_type is required for genre browse", ErrInvalidInput)
}
var (
tmdbMediaType string
genreID int
)
switch mediaType {
case MediaTypeMovie:
tmdbMediaType = "movie"
genreID = genre.MovieID
case MediaTypeSeries:
tmdbMediaType = "tv"
genreID = genre.SeriesID
}
if genreID == 0 {
return nil, fmt.Errorf("%w: %s has no %s equivalent", ErrInvalidInput, slug, mediaType)
}
tmdbSort, sortKey, err := normalizeBrowseSort(sort, tmdbMediaType)
if err != nil {
return nil, err
}
tmdbPage, err := s.tmdb.DiscoverPage(ctx, tmdbMediaType, tmdb.DiscoverParams{
SortBy: tmdbSort,
WithGenres: []int{genreID},
VoteCountGte: voteCountFloorForSort(sortKey),
}, page)
if err != nil {
return nil, err
}
enriched, err := s.enrichPage(ctx, viewer, tmdbPage)
if err != nil {
return nil, err
}
return &DiscoverBrowseResponse{
Kind: "genre",
Slug: genre.Slug,
DisplayName: genre.DisplayName,
MediaType: mediaType,
Sort: sortKey,
Page: enriched.Page,
TotalPages: enriched.TotalPages,
Results: enriched.Results,
}, nil
}
func normalizeBrowseSort(sort, tmdbMediaType string) (string, string, error) {
sort = strings.TrimSpace(sort)
if sort == "" {
sort = defaultBrowseSort
}
tmdbSort, ok := validBrowseSorts[sort]
if !ok {
return "", "", fmt.Errorf("%w: unknown sort %q", ErrInvalidInput, sort)
}
if sort == "release_date" && tmdbMediaType == "tv" {
tmdbSort = "first_air_date.desc"
}
return tmdbSort, sort, nil
}
func voteCountFloorForSort(sortKey string) int {
if sortKey == "vote_average" {
return 100
}
return 0
}
+108
View File
@@ -0,0 +1,108 @@
package requests
// BundledStudio is a curated movie studio surfaced in the request discover
// section. LogoPath is a TMDB image file path that is rendered through the
// duotone filter for uniform white-on-gray presentation.
type BundledStudio struct {
TMDBID int
Slug string
DisplayName string
LogoPath string
}
// BundledNetwork is a curated TV network surfaced in the request discover
// section. LogoPath is a TMDB image file path rendered through the duotone
// filter.
type BundledNetwork struct {
TMDBID int
Slug string
DisplayName string
LogoPath string
}
// BundledGenre is a curated genre. MovieID is the TMDB movie genre ID;
// SeriesID is the TMDB tv genre ID, or 0 when no TV equivalent exists.
type BundledGenre struct {
Slug string
DisplayName string
GradientFrom string
GradientTo string
MovieID int
SeriesID int
}
// BundledStudios is the compile-time list of studios shown in the Studios
// carousel. TMDB IDs and logo paths follow the curated set used by Overseerr
// so logos resolve reliably through the duotone filter.
var BundledStudios = []BundledStudio{
{TMDBID: 2, Slug: "disney", DisplayName: "Disney", LogoPath: "/wdrCwmRnLFJhEoH8GSfymY85KHT.png"},
{TMDBID: 3, Slug: "pixar", DisplayName: "Pixar", LogoPath: "/1TjvGVDMYsj6JBxOAkUHpPEwLf7.png"},
{TMDBID: 420, Slug: "marvel-studios", DisplayName: "Marvel Studios", LogoPath: "/hUzeosd33nzE5MCNsZxCGEKTXaQ.png"},
{TMDBID: 174, Slug: "warner-bros-pictures", DisplayName: "Warner Bros. Pictures", LogoPath: "/ky0xOc5OrhzkZ1N6KyUxacfQsCk.png"},
{TMDBID: 33, Slug: "universal-pictures", DisplayName: "Universal Pictures", LogoPath: "/8lvHyhjr8oUKOOy2dKXoALWKdp0.png"},
{TMDBID: 4, Slug: "paramount-pictures", DisplayName: "Paramount Pictures", LogoPath: "/fycMZt242LVjagMByZOLUGbCvv3.png"},
{TMDBID: 34, Slug: "sony-pictures", DisplayName: "Sony Pictures", LogoPath: "/GagSvqWlyPdkFHMfQ3pNq6ix9P.png"},
{TMDBID: 127928, Slug: "20th-century-studios", DisplayName: "20th Century Studios", LogoPath: "/h0rjX5vjW5r8yEnUBStFarjcLT4.png"},
{TMDBID: 521, Slug: "dreamworks", DisplayName: "DreamWorks", LogoPath: "/kP7t6RwGz2AvvTkvnI1uteEwHet.png"},
{TMDBID: 41077, Slug: "a24", DisplayName: "A24", LogoPath: "/1ZXsGaFPgrgS6ZZGS37AqD5uU12.png"},
}
// BundledNetworks is the compile-time list of networks shown in the Networks
// carousel.
var BundledNetworks = []BundledNetwork{
{TMDBID: 213, Slug: "netflix", DisplayName: "Netflix", LogoPath: "/wwemzKWzjKYJFfCeiB57q3r4Bcm.png"},
{TMDBID: 2739, Slug: "disney-plus", DisplayName: "Disney+", LogoPath: "/gJ8VX6JSu3ciXHuC2dDGAo2lvwM.png"},
{TMDBID: 1024, Slug: "prime-video", DisplayName: "Prime Video", LogoPath: "/ifhbNuuVnlwYy5oXA5VIb2YR8AZ.png"},
{TMDBID: 2552, Slug: "apple-tv-plus", DisplayName: "Apple TV+", LogoPath: "/4KAy34EHvRM25Ih8wb82AuGU7zJ.png"},
{TMDBID: 453, Slug: "hulu", DisplayName: "Hulu", LogoPath: "/pqUTCleNUiTLAVlelGxUgWn1ELh.png"},
{TMDBID: 49, Slug: "hbo", DisplayName: "HBO", LogoPath: "/tuomPhY2UtuPTqqFnKMVHvSb724.png"},
{TMDBID: 4330, Slug: "paramount-plus", DisplayName: "Paramount+", LogoPath: "/fi83B1oztoS47xxcemFdPMhIzK.png"},
{TMDBID: 174, Slug: "amc", DisplayName: "AMC", LogoPath: "/pmvRmATOCaDykE6JrVoeYxlFHw3.png"},
{TMDBID: 4, Slug: "bbc-one", DisplayName: "BBC One", LogoPath: "/mVn7xESaTNmjBUyUtGNvDQd3CT1.png"},
{TMDBID: 3353, Slug: "peacock", DisplayName: "Peacock", LogoPath: "/gIAcGTjKKr0KOHL5s4O36roJ8p7.png"},
}
// BundledGenres is the compile-time list of genres shown in the Genres
// carousel. SeriesID = 0 means the genre has no direct TV equivalent and
// the browse page hides the Series tab.
var BundledGenres = []BundledGenre{
{Slug: "action", DisplayName: "Action", GradientFrom: "#dc2626", GradientTo: "#7f1d1d", MovieID: 28, SeriesID: 10759},
{Slug: "comedy", DisplayName: "Comedy", GradientFrom: "#fbbf24", GradientTo: "#b45309", MovieID: 35, SeriesID: 35},
{Slug: "drama", DisplayName: "Drama", GradientFrom: "#64748b", GradientTo: "#1e293b", MovieID: 18, SeriesID: 18},
{Slug: "sci-fi", DisplayName: "Sci-Fi", GradientFrom: "#7c3aed", GradientTo: "#312e81", MovieID: 878, SeriesID: 10765},
{Slug: "horror", DisplayName: "Horror", GradientFrom: "#7f1d1d", GradientTo: "#1f2937", MovieID: 27, SeriesID: 0},
{Slug: "romance", DisplayName: "Romance", GradientFrom: "#ec4899", GradientTo: "#831843", MovieID: 10749, SeriesID: 0},
{Slug: "animation", DisplayName: "Animation", GradientFrom: "#06b6d4", GradientTo: "#155e75", MovieID: 16, SeriesID: 16},
{Slug: "documentary", DisplayName: "Documentary", GradientFrom: "#475569", GradientTo: "#0f172a", MovieID: 99, SeriesID: 99},
}
// FindStudioBySlug looks up a bundled studio by slug. Returns (zero, false)
// if not found.
func FindStudioBySlug(slug string) (BundledStudio, bool) {
for _, s := range BundledStudios {
if s.Slug == slug {
return s, true
}
}
return BundledStudio{}, false
}
// FindNetworkBySlug looks up a bundled network by slug.
func FindNetworkBySlug(slug string) (BundledNetwork, bool) {
for _, n := range BundledNetworks {
if n.Slug == slug {
return n, true
}
}
return BundledNetwork{}, false
}
// FindGenreBySlug looks up a bundled genre by slug.
func FindGenreBySlug(slug string) (BundledGenre, bool) {
for _, g := range BundledGenres {
if g.Slug == slug {
return g, true
}
}
return BundledGenre{}, false
}
+145
View File
@@ -0,0 +1,145 @@
package requests
import (
"strings"
"testing"
)
func TestBundleHasExpectedCounts(t *testing.T) {
if len(BundledStudios) != 10 {
t.Errorf("BundledStudios = %d, want 10", len(BundledStudios))
}
if len(BundledNetworks) != 10 {
t.Errorf("BundledNetworks = %d, want 10", len(BundledNetworks))
}
if len(BundledGenres) != 8 {
t.Errorf("BundledGenres = %d, want 8", len(BundledGenres))
}
}
func TestBundleStudiosHaveRequiredFields(t *testing.T) {
for _, s := range BundledStudios {
if s.TMDBID <= 0 {
t.Errorf("studio %q missing TMDBID", s.Slug)
}
if strings.TrimSpace(s.Slug) == "" {
t.Errorf("studio %+v missing Slug", s)
}
if strings.TrimSpace(s.DisplayName) == "" {
t.Errorf("studio %q missing DisplayName", s.Slug)
}
if !strings.HasPrefix(s.LogoPath, "/") || !strings.HasSuffix(s.LogoPath, ".png") {
t.Errorf("studio %q LogoPath must be a TMDB file path (/...png), got %q", s.Slug, s.LogoPath)
}
}
}
func TestBundleNetworksHaveRequiredFields(t *testing.T) {
for _, n := range BundledNetworks {
if n.TMDBID <= 0 {
t.Errorf("network %q missing TMDBID", n.Slug)
}
if strings.TrimSpace(n.Slug) == "" {
t.Errorf("network %+v missing Slug", n)
}
if strings.TrimSpace(n.DisplayName) == "" {
t.Errorf("network %q missing DisplayName", n.Slug)
}
if !strings.HasPrefix(n.LogoPath, "/") || !strings.HasSuffix(n.LogoPath, ".png") {
t.Errorf("network %q LogoPath must be a TMDB file path (/...png), got %q", n.Slug, n.LogoPath)
}
}
}
func TestBundleGenresHaveRequiredFields(t *testing.T) {
for _, g := range BundledGenres {
if strings.TrimSpace(g.Slug) == "" {
t.Errorf("genre %+v missing Slug", g)
}
if strings.TrimSpace(g.DisplayName) == "" {
t.Errorf("genre %q missing DisplayName", g.Slug)
}
if g.MovieID <= 0 {
t.Errorf("genre %q must have MovieID > 0 in v1", g.Slug)
}
if !strings.HasPrefix(g.GradientFrom, "#") || !strings.HasPrefix(g.GradientTo, "#") {
t.Errorf("genre %q gradient must use # hex, got from=%q to=%q", g.Slug, g.GradientFrom, g.GradientTo)
}
}
}
func TestBundleSlugsAreUniqueWithinKind(t *testing.T) {
seen := map[string]string{}
for _, s := range BundledStudios {
key := "studio:" + s.Slug
if prior, ok := seen[key]; ok {
t.Errorf("duplicate studio slug %q (also %q)", s.Slug, prior)
}
seen[key] = s.DisplayName
}
for _, n := range BundledNetworks {
key := "network:" + n.Slug
if prior, ok := seen[key]; ok {
t.Errorf("duplicate network slug %q (also %q)", n.Slug, prior)
}
seen[key] = n.DisplayName
}
for _, g := range BundledGenres {
key := "genre:" + g.Slug
if prior, ok := seen[key]; ok {
t.Errorf("duplicate genre slug %q (also %q)", g.Slug, prior)
}
seen[key] = g.DisplayName
}
}
func TestFindStudioBySlug(t *testing.T) {
got, ok := FindStudioBySlug("marvel-studios")
if !ok {
t.Fatal("expected marvel-studios to exist")
}
if got.DisplayName != "Marvel Studios" {
t.Errorf("display = %q, want Marvel Studios", got.DisplayName)
}
if _, ok := FindStudioBySlug("not-a-real-studio"); ok {
t.Error("expected unknown slug to return false")
}
}
func TestFindNetworkBySlug(t *testing.T) {
got, ok := FindNetworkBySlug("netflix")
if !ok {
t.Fatal("expected netflix to exist")
}
if got.DisplayName != "Netflix" {
t.Errorf("display = %q, want Netflix", got.DisplayName)
}
}
func TestFindGenreBySlug(t *testing.T) {
got, ok := FindGenreBySlug("action")
if !ok {
t.Fatal("expected action to exist")
}
if got.MovieID != 28 {
t.Errorf("movie id = %d, want 28", got.MovieID)
}
}
func TestGenresWithoutTVEquivalentHaveZeroSeriesID(t *testing.T) {
horror, ok := FindGenreBySlug("horror")
if !ok {
t.Fatal("expected horror to exist")
}
if horror.SeriesID != 0 {
t.Errorf("horror.SeriesID = %d, want 0", horror.SeriesID)
}
romance, ok := FindGenreBySlug("romance")
if !ok {
t.Fatal("expected romance to exist")
}
if romance.SeriesID != 0 {
t.Errorf("romance.SeriesID = %d, want 0", romance.SeriesID)
}
}
+30
View File
@@ -0,0 +1,30 @@
package requests
import "errors"
var (
ErrInvalidMediaType = errors.New("invalid media type")
ErrInvalidInput = errors.New("invalid request input")
ErrRequestsDisabled = errors.New("requests are disabled")
ErrUserBlocked = errors.New("user is blocked from requesting")
ErrQuotaExceeded = errors.New("request quota exceeded")
ErrAlreadyAvailable = errors.New("media is already available")
ErrAlreadyRequested = errors.New("media is already requested")
ErrNotFound = errors.New("request not found")
ErrForbidden = errors.New("request forbidden")
ErrInvalidState = errors.New("invalid request state")
)
type QuotaError struct {
Used int
Limit int
WindowDays int
}
func (e QuotaError) Error() string {
return ErrQuotaExceeded.Error()
}
func (e QuotaError) Unwrap() error {
return ErrQuotaExceeded
}
+121
View File
@@ -0,0 +1,121 @@
package requests
import (
"context"
"log/slog"
"strconv"
"strings"
"github.com/Silo-Server/silo-server/internal/catalog"
)
type PresenceCandidate struct {
TMDBID int
TVDBID *int
IMDbID string
}
type PresenceMatch struct {
Available bool
ContentID string
MatchedProvider string
}
type PresenceResolver interface {
Lookup(ctx context.Context, mediaType MediaType, candidates []PresenceCandidate) (map[int]PresenceMatch, error)
}
type presenceItemLookup interface {
LookupExternalIDs(ctx context.Context, mediaType string, candidates []catalog.ExternalIDLookupCandidate) ([]catalog.ExternalIDMatchRow, error)
}
type tmdbBackfiller interface {
AttachTMDBID(ctx context.Context, contentID, itemType string, tmdbID int) error
}
type CatalogPresence struct {
items presenceItemLookup
tmdbBackfill tmdbBackfiller
}
func NewCatalogPresence(items *catalog.ItemRepository, providerIDs ...*catalog.ProviderIDRepository) *CatalogPresence {
var itemLookup presenceItemLookup
if items != nil {
itemLookup = items
}
var backfill tmdbBackfiller
if len(providerIDs) > 0 && providerIDs[0] != nil {
backfill = providerIDs[0]
}
return &CatalogPresence{items: itemLookup, tmdbBackfill: backfill}
}
func (p *CatalogPresence) Lookup(ctx context.Context, mediaType MediaType, candidates []PresenceCandidate) (map[int]PresenceMatch, error) {
out := map[int]PresenceMatch{}
if p == nil || p.items == nil || len(candidates) == 0 {
return out, nil
}
lookupCandidates := make([]catalog.ExternalIDLookupCandidate, 0, len(candidates))
for _, candidate := range candidates {
if candidate.TMDBID <= 0 {
continue
}
row := catalog.ExternalIDLookupCandidate{
TMDBID: strconv.Itoa(candidate.TMDBID),
IMDbID: strings.TrimSpace(candidate.IMDbID),
}
if candidate.TVDBID != nil && *candidate.TVDBID > 0 {
row.TVDBID = strconv.Itoa(*candidate.TVDBID)
}
lookupCandidates = append(lookupCandidates, row)
}
if len(lookupCandidates) == 0 {
return out, nil
}
rows, err := p.items.LookupExternalIDs(ctx, string(mediaType), lookupCandidates)
if err != nil {
return nil, err
}
for _, row := range rows {
id, err := strconv.Atoi(row.QueryTMDBID)
if err != nil || id <= 0 {
continue
}
out[id] = PresenceMatch{
Available: true,
ContentID: row.MediaID,
MatchedProvider: row.MatchedProvider,
}
if row.MatchedProvider != "tmdb" && p.tmdbBackfill != nil {
if err := p.tmdbBackfill.AttachTMDBID(ctx, row.MediaID, string(mediaType), id); err != nil {
slog.Warn("requests: failed to backfill tmdb id from presence lookup",
"content_id", row.MediaID,
"media_type", mediaType,
"tmdb_id", id,
"matched_provider", row.MatchedProvider,
"error", err)
}
}
}
return out, nil
}
func (p *CatalogPresence) LookupTMDB(ctx context.Context, mediaType MediaType, tmdbIDs []int) (map[int]bool, error) {
candidates := make([]PresenceCandidate, 0, len(tmdbIDs))
for _, id := range tmdbIDs {
if id > 0 {
candidates = append(candidates, PresenceCandidate{TMDBID: id})
}
}
matches, err := p.Lookup(ctx, mediaType, candidates)
if err != nil {
return nil, err
}
out := map[int]bool{}
for id, match := range matches {
out[id] = match.Available
}
return out, nil
}
+94
View File
@@ -0,0 +1,94 @@
package requests
import (
"context"
"testing"
"github.com/Silo-Server/silo-server/internal/catalog"
)
type fakePresenceLookup struct {
rows []catalog.ExternalIDMatchRow
got []catalog.ExternalIDLookupCandidate
}
func (f *fakePresenceLookup) LookupExternalIDs(_ context.Context, _ string, candidates []catalog.ExternalIDLookupCandidate) ([]catalog.ExternalIDMatchRow, error) {
f.got = append([]catalog.ExternalIDLookupCandidate(nil), candidates...)
return f.rows, nil
}
type fakeTMDBBackfiller struct {
contentID string
itemType string
tmdbID int
}
func (f *fakeTMDBBackfiller) AttachTMDBID(_ context.Context, contentID, itemType string, tmdbID int) error {
f.contentID = contentID
f.itemType = itemType
f.tmdbID = tmdbID
return nil
}
func TestCatalogPresenceMatchesByTVDBAndBackfillsTMDB(t *testing.T) {
tvdbID := 420105
lookup := &fakePresenceLookup{rows: []catalog.ExternalIDMatchRow{{
QueryTMDBID: "201992",
MediaID: "120983767174086659",
MatchedProvider: "tvdb",
LibraryID: "2",
Title: "The Rookie: Feds",
}}}
backfill := &fakeTMDBBackfiller{}
presence := &CatalogPresence{items: lookup, tmdbBackfill: backfill}
result, err := presence.Lookup(context.Background(), MediaTypeSeries, []PresenceCandidate{{
TMDBID: 201992,
TVDBID: &tvdbID,
IMDbID: "tt18076310",
}})
if err != nil {
t.Fatalf("Lookup returned error: %v", err)
}
if !result[201992].Available {
t.Fatalf("available = false, want true")
}
if result[201992].MatchedProvider != "tvdb" {
t.Fatalf("matched provider = %q, want tvdb", result[201992].MatchedProvider)
}
if backfill.contentID != "120983767174086659" || backfill.itemType != "series" || backfill.tmdbID != 201992 {
t.Fatalf("backfill = %+v, want content 120983767174086659 series tmdb 201992", backfill)
}
}
func TestCatalogPresenceKeepsLookupTMDBCompatibility(t *testing.T) {
lookup := &fakePresenceLookup{rows: []catalog.ExternalIDMatchRow{{
QueryTMDBID: "550",
MediaID: "movie-1",
MatchedProvider: "tmdb",
LibraryID: "1",
Title: "Fight Club",
}}}
presence := &CatalogPresence{items: lookup}
result, err := presence.LookupTMDB(context.Background(), MediaTypeMovie, []int{550})
if err != nil {
t.Fatalf("LookupTMDB returned error: %v", err)
}
if !result[550] {
t.Fatalf("result[550] = false, want true")
}
if len(lookup.got) != 1 || lookup.got[0].TMDBID != "550" {
t.Fatalf("lookup candidates = %+v, want tmdb candidate", lookup.got)
}
}
func TestNewCatalogPresenceIgnoresNilRepositories(t *testing.T) {
presence := NewCatalogPresence(nil, nil)
if presence.items != nil {
t.Fatalf("items = %#v, want nil", presence.items)
}
if presence.tmdbBackfill != nil {
t.Fatalf("tmdbBackfill = %#v, want nil", presence.tmdbBackfill)
}
}
+176
View File
@@ -0,0 +1,176 @@
package radarr
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
"github.com/Silo-Server/silo-server/internal/requests/arrclient"
)
type Client struct {
httpClient *http.Client
}
type movieResource struct {
ID int `json:"id,omitempty"`
Title string `json:"title,omitempty"`
TMDBID int `json:"tmdbId,omitempty"`
Year int `json:"year,omitempty"`
TitleSlug string `json:"titleSlug,omitempty"`
QualityProfileID int `json:"qualityProfileId,omitempty"`
RootFolderPath string `json:"rootFolderPath,omitempty"`
Monitored bool `json:"monitored"`
MinimumAvailability string `json:"minimumAvailability,omitempty"`
Tags []int `json:"tags,omitempty"`
AddOptions addMovieOptions `json:"addOptions,omitempty"`
}
type addMovieOptions struct {
SearchForMovie bool `json:"searchForMovie"`
Monitor string `json:"monitor,omitempty"`
}
func NewClient(httpClient *http.Client) *Client {
return &Client{httpClient: httpClient}
}
func (c *Client) ListMovieIntegrationOptions(ctx context.Context, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error) {
client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient)
rootFolders, err := arrclient.ListRootFolders(ctx, client)
if err != nil {
return nil, err
}
qualityProfiles, err := arrclient.ListQualityProfiles(ctx, client)
if err != nil {
return nil, err
}
tags, err := arrclient.ListTags(ctx, client)
if err != nil {
return nil, err
}
return &mediarequests.IntegrationOptions{
Kind: "radarr",
RootFolders: rootFolders,
QualityProfiles: qualityProfiles,
Tags: tags,
}, nil
}
func (c *Client) SubmitMovie(ctx context.Context, req mediarequests.Request, integration mediarequests.Integration) (mediarequests.FulfillmentResult, error) {
if req.MediaType != mediarequests.MediaTypeMovie {
return mediarequests.FulfillmentResult{}, fmt.Errorf("radarr: request is not a movie")
}
if integration.QualityProfileID == nil {
return mediarequests.FulfillmentResult{}, fmt.Errorf("radarr: quality profile is required")
}
client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient)
movie, err := c.lookupMovie(ctx, client, req.TMDBID)
if err != nil {
return mediarequests.FulfillmentResult{}, err
}
movie.RootFolderPath = integration.RootFolder
movie.QualityProfileID = *integration.QualityProfileID
movie.Monitored = arrclient.BoolOption(integration.Options, "monitored", true)
movie.MinimumAvailability = arrclient.StringOption(integration.Options, "minimum_availability", "released")
movie.Tags = integration.Tags
movie.AddOptions = addMovieOptions{
SearchForMovie: arrclient.BoolOption(
integration.Options,
"search_for_movie",
arrclient.BoolOption(integration.Options, "search_on_add", true),
),
Monitor: arrclient.StringOption(integration.Options, "monitor", "movieOnly"),
}
var created movieResource
if err := client.PostJSON(ctx, "/api/v3/movie", movie, &created); err != nil {
if !arrclient.IsEmptyOrTruncatedDecodeError(err) {
return mediarequests.FulfillmentResult{}, err
}
// POST accepted but Radarr returned an empty body. Recover the
// new movie's Radarr ID by listing movies filtered by TMDB ID;
// without the ID the reconcile loop cannot advance the request.
if found, lookErr := c.findMovieByTMDBID(ctx, client, req.TMDBID); lookErr == nil && found.ID > 0 {
return resultFromMovie(found), nil
}
return arrclient.AcceptedWithoutResponse("radarr"), nil
}
return resultFromMovie(created), nil
}
func (c *Client) findMovieByTMDBID(ctx context.Context, client *arrclient.Client, tmdbID int) (movieResource, error) {
values := url.Values{}
values.Set("tmdbId", strconv.Itoa(tmdbID))
var matches []movieResource
if err := client.GetJSON(ctx, "/api/v3/movie?"+values.Encode(), &matches); err != nil {
return movieResource{}, err
}
for _, m := range matches {
if m.ID > 0 {
return m, nil
}
}
return movieResource{}, fmt.Errorf("radarr: movie not found after add for tmdb_id %d", tmdbID)
}
func (c *Client) CheckMovieStatus(ctx context.Context, req mediarequests.Request, integration mediarequests.Integration) (mediarequests.FulfillmentStatus, error) {
client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient)
movieID, _ := strconv.Atoi(req.ExternalID)
if movieID <= 0 {
return mediarequests.FulfillmentStatus{
Status: mediarequests.StatusQueued,
IntegrationKind: "radarr",
ExternalStatus: "external_id_unavailable",
}, nil
}
queues, err := c.queueDetails(ctx, client, movieID)
if err != nil {
return mediarequests.FulfillmentStatus{}, err
}
evaluation := arrclient.EvaluateQueue(queues)
return arrclient.StatusFromQueueEvaluation("radarr", movieID, evaluation), nil
}
func (c *Client) lookupMovie(ctx context.Context, client *arrclient.Client, tmdbID int) (movieResource, error) {
values := url.Values{}
values.Set("tmdbId", strconv.Itoa(tmdbID))
// Radarr's /api/v3/movie/lookup/tmdb returns a single MovieResource, unlike
// /api/v3/movie/lookup which returns an array. Missing IDs return non-2xx
// (handled as HTTPError upstream), so a successful response is the movie.
var movie movieResource
if err := client.GetJSON(ctx, "/api/v3/movie/lookup/tmdb?"+values.Encode(), &movie); err != nil {
return movieResource{}, err
}
if movie.TMDBID == 0 {
movie.TMDBID = tmdbID
}
return movie, nil
}
func (c *Client) queueDetails(ctx context.Context, client *arrclient.Client, movieID int) ([]arrclient.QueueResource, error) {
values := url.Values{}
values.Set("movieId", strconv.Itoa(movieID))
var queues []arrclient.QueueResource
if err := client.GetJSON(ctx, "/api/v3/queue/details?"+values.Encode(), &queues); err != nil {
return nil, err
}
return queues, nil
}
func resultFromMovie(movie movieResource) mediarequests.FulfillmentResult {
externalID := ""
if movie.ID > 0 {
externalID = strconv.Itoa(movie.ID)
}
return mediarequests.FulfillmentResult{
IntegrationKind: "radarr",
ExternalID: externalID,
ExternalStatus: "queued",
}
}
+236
View File
@@ -0,0 +1,236 @@
package radarr
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
)
func TestSubmitMovieAddsLookupResult(t *testing.T) {
qualityProfileID := 7
var posted movieResource
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Api-Key"); got != "radarr-key" {
t.Fatalf("X-Api-Key = %q, want radarr-key", got)
}
switch r.URL.Path {
case "/api/v3/movie":
if r.Method == http.MethodGet {
if got := r.URL.Query().Get("tmdbId"); got != "550" {
t.Fatalf("tmdbId = %q, want 550", got)
}
w.Write([]byte(`[]`))
return
}
if r.Method == http.MethodPost {
if err := json.NewDecoder(r.Body).Decode(&posted); err != nil {
t.Fatalf("decode posted movie: %v", err)
}
w.Write([]byte(`{"id":42,"tmdbId":550}`))
return
}
case "/api/v3/movie/lookup/tmdb":
if got := r.URL.Query().Get("tmdbId"); got != "550" {
t.Fatalf("lookup tmdbId = %q, want 550", got)
}
w.Write([]byte(`{"title":"Fight Club","tmdbId":550,"titleSlug":"fight-club"}`))
return
}
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}))
defer server.Close()
client := NewClient(server.Client())
result, err := client.SubmitMovie(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
}, mediarequests.Integration{
Kind: "radarr",
BaseURL: server.URL,
APIKeyRef: "radarr-key",
RootFolder: "/movies",
QualityProfileID: &qualityProfileID,
Options: map[string]any{
"search_on_add": false,
},
})
if err != nil {
t.Fatalf("SubmitMovie returned error: %v", err)
}
if result.ExternalID != "42" || result.IntegrationKind != "radarr" {
t.Fatalf("result = %+v, want radarr external id 42", result)
}
if posted.RootFolderPath != "/movies" || posted.QualityProfileID != qualityProfileID {
t.Fatalf("posted movie = %+v, missing root folder/quality profile", posted)
}
if posted.AddOptions.SearchForMovie {
t.Fatalf("searchForMovie = true, want false")
}
}
func TestSubmitMovieRecoversFromEmptyAddResponse(t *testing.T) {
qualityProfileID := 7
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/movie/lookup/tmdb":
w.Write([]byte(`{"title":"Fight Club","tmdbId":550,"titleSlug":"fight-club"}`))
case "/api/v3/movie":
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
return
}
if r.Method == http.MethodGet {
if got := r.URL.Query().Get("tmdbId"); got != "550" {
t.Fatalf("tmdbId = %q, want 550", got)
}
w.Write([]byte(`[{"id":99,"tmdbId":550,"title":"Fight Club"}]`))
return
}
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
}))
defer server.Close()
client := NewClient(server.Client())
result, err := client.SubmitMovie(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
}, mediarequests.Integration{
Kind: "radarr",
BaseURL: server.URL,
APIKeyRef: "radarr-key",
RootFolder: "/movies",
QualityProfileID: &qualityProfileID,
})
if err != nil {
t.Fatalf("SubmitMovie returned error: %v", err)
}
if result.ExternalID != "99" {
t.Fatalf("ExternalID = %q, want 99 (recovered after empty 201)", result.ExternalID)
}
if result.ExternalStatus != "queued" {
t.Fatalf("ExternalStatus = %q, want queued", result.ExternalStatus)
}
}
func TestSubmitMovieFallsBackWhenEmptyResponseAndLookupFails(t *testing.T) {
qualityProfileID := 7
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/movie/lookup/tmdb":
w.Write([]byte(`{"title":"Fight Club","tmdbId":550,"titleSlug":"fight-club"}`))
case "/api/v3/movie":
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
return
}
if r.Method == http.MethodGet {
w.Write([]byte(`[]`))
return
}
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
}))
defer server.Close()
client := NewClient(server.Client())
result, err := client.SubmitMovie(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
}, mediarequests.Integration{
Kind: "radarr",
BaseURL: server.URL,
APIKeyRef: "radarr-key",
RootFolder: "/movies",
QualityProfileID: &qualityProfileID,
})
if err != nil {
t.Fatalf("SubmitMovie returned error: %v", err)
}
if result.ExternalStatus != "accepted_without_response" {
t.Fatalf("ExternalStatus = %q, want accepted_without_response", result.ExternalStatus)
}
}
func TestCheckMovieStatusReadsQueueDetails(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Api-Key"); got != "radarr-key" {
t.Fatalf("X-Api-Key = %q, want radarr-key", got)
}
if r.URL.Path != "/api/v3/queue/details" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
if got := r.URL.Query().Get("movieId"); got != "42" {
t.Fatalf("movieId = %q, want 42", got)
}
w.Write([]byte(`[{"movieId":42,"status":"downloading","trackedDownloadState":"downloading"}]`))
}))
defer server.Close()
client := NewClient(server.Client())
status, err := client.CheckMovieStatus(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeMovie,
TMDBID: 550,
ExternalID: "42",
}, mediarequests.Integration{
Kind: "radarr",
BaseURL: server.URL,
APIKeyRef: "radarr-key",
})
if err != nil {
t.Fatalf("CheckMovieStatus returned error: %v", err)
}
if status.Status != mediarequests.StatusDownloading || status.ExternalStatus != "downloading/downloading" {
t.Fatalf("status = %+v, want downloading", status)
}
}
func TestListMovieIntegrationOptionsLoadsChoices(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Api-Key"); got != "radarr-key" {
t.Fatalf("X-Api-Key = %q, want radarr-key", got)
}
switch r.URL.Path {
case "/api/v3/rootfolder":
w.Write([]byte(`[{"path":"/movies","freeSpace":123,"totalSpace":456,"accessible":true}]`))
case "/api/v3/qualityprofile":
w.Write([]byte(`[{"id":7,"name":"HD-1080p"}]`))
case "/api/v3/tag":
w.Write([]byte(`[{"id":2,"label":"requests"}]`))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer server.Close()
client := NewClient(server.Client())
options, err := client.ListMovieIntegrationOptions(context.Background(), mediarequests.Integration{
Kind: "radarr",
BaseURL: server.URL,
APIKeyRef: "radarr-key",
})
if err != nil {
t.Fatalf("ListMovieIntegrationOptions returned error: %v", err)
}
if options.Kind != "radarr" {
t.Fatalf("kind = %q, want radarr", options.Kind)
}
if len(options.RootFolders) != 1 || options.RootFolders[0].Path != "/movies" {
t.Fatalf("root folders = %+v, want /movies", options.RootFolders)
}
if len(options.QualityProfiles) != 1 || options.QualityProfiles[0].ID != 7 {
t.Fatalf("quality profiles = %+v, want id 7", options.QualityProfiles)
}
if len(options.Tags) != 1 || options.Tags[0].ID != 2 {
t.Fatalf("tags = %+v, want id 2", options.Tags)
}
}
+755
View File
@@ -0,0 +1,755 @@
package requests
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
type Repository struct {
pool *pgxpool.Pool
}
func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
}
func (r *Repository) GetSettings(ctx context.Context) (Settings, error) {
var s Settings
err := r.pool.QueryRow(ctx, `
SELECT requests_enabled, global_max_requests, global_window_days,
global_auto_approval_enabled, updated_at
FROM request_settings
WHERE id = true
`).Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Settings{
RequestsEnabled: false,
GlobalMaxRequests: 5,
GlobalWindowDays: 7,
GlobalAutoApprovalEnabled: false,
}, nil
}
return Settings{}, fmt.Errorf("get request settings: %w", err)
}
return s, nil
}
func (r *Repository) UpdateSettings(ctx context.Context, settings Settings) (Settings, error) {
if settings.GlobalWindowDays <= 0 {
settings.GlobalWindowDays = 7
}
if settings.GlobalMaxRequests < 0 {
settings.GlobalMaxRequests = 0
}
var s Settings
err := r.pool.QueryRow(ctx, `
INSERT INTO request_settings (
id, requests_enabled, global_max_requests, global_window_days,
global_auto_approval_enabled, updated_at
)
VALUES (true, $1, $2, $3, $4, now())
ON CONFLICT (id) DO UPDATE SET
requests_enabled = EXCLUDED.requests_enabled,
global_max_requests = EXCLUDED.global_max_requests,
global_window_days = EXCLUDED.global_window_days,
global_auto_approval_enabled = EXCLUDED.global_auto_approval_enabled,
updated_at = now()
RETURNING requests_enabled, global_max_requests, global_window_days,
global_auto_approval_enabled, updated_at
`, settings.RequestsEnabled, settings.GlobalMaxRequests, settings.GlobalWindowDays, settings.GlobalAutoApprovalEnabled).
Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.UpdatedAt)
if err != nil {
return Settings{}, fmt.Errorf("update request settings: %w", err)
}
return s, nil
}
func (r *Repository) GetUserLimit(ctx context.Context, userID int) (*UserLimit, error) {
var row UserLimit
var max, window sql.NullInt64
err := r.pool.QueryRow(ctx, `
SELECT user_id, limit_mode, max_requests, window_days, approval_mode, updated_at
FROM request_user_limits
WHERE user_id = $1
`, userID).Scan(&row.UserID, &row.LimitMode, &max, &window, &row.ApprovalMode, &row.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("get request user limit: %w", err)
}
if max.Valid {
v := int(max.Int64)
row.MaxRequests = &v
}
if window.Valid {
v := int(window.Int64)
row.WindowDays = &v
}
return &row, nil
}
func (r *Repository) UpsertUserLimit(ctx context.Context, limit UserLimit) (*UserLimit, error) {
var max, window any
if limit.MaxRequests != nil {
max = *limit.MaxRequests
}
if limit.WindowDays != nil {
window = *limit.WindowDays
}
var row UserLimit
var scannedMax, scannedWindow sql.NullInt64
err := r.pool.QueryRow(ctx, `
INSERT INTO request_user_limits (
user_id, limit_mode, max_requests, window_days, approval_mode, updated_at
)
VALUES ($1, $2, $3, $4, $5, now())
ON CONFLICT (user_id) DO UPDATE SET
limit_mode = EXCLUDED.limit_mode,
max_requests = EXCLUDED.max_requests,
window_days = EXCLUDED.window_days,
approval_mode = EXCLUDED.approval_mode,
updated_at = now()
RETURNING user_id, limit_mode, max_requests, window_days, approval_mode, updated_at
`, limit.UserID, limit.LimitMode, max, window, limit.ApprovalMode).
Scan(&row.UserID, &row.LimitMode, &scannedMax, &scannedWindow, &row.ApprovalMode, &row.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("upsert request user limit: %w", err)
}
if scannedMax.Valid {
v := int(scannedMax.Int64)
row.MaxRequests = &v
}
if scannedWindow.Valid {
v := int(scannedWindow.Int64)
row.WindowDays = &v
}
return &row, nil
}
func (r *Repository) CountUserRequestsSince(ctx context.Context, userID int, since time.Time) (int, error) {
var count int
if err := r.pool.QueryRow(ctx, `
SELECT COUNT(*)
FROM media_requests
WHERE requested_by_user_id = $1
AND created_at >= $2
`, userID, since).Scan(&count); err != nil {
return 0, fmt.Errorf("count user requests: %w", err)
}
return count, nil
}
func (r *Repository) ListActiveByTMDB(ctx context.Context, mediaType MediaType, tmdbIDs []int) (map[int]*Request, error) {
if len(tmdbIDs) == 0 {
return map[int]*Request{}, nil
}
rows, err := r.pool.Query(ctx, requestSelectSQL()+`
WHERE media_type = $1
AND provider = 'tmdb'
AND tmdb_id = ANY($2)
AND outcome = 'active'
AND status <> 'completed'
`, mediaType, tmdbIDs)
if err != nil {
return nil, fmt.Errorf("list active requests by tmdb: %w", err)
}
defer rows.Close()
out := map[int]*Request{}
for rows.Next() {
req, err := scanRequest(rows)
if err != nil {
return nil, err
}
out[req.TMDBID] = req
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate active requests by tmdb: %w", err)
}
return out, nil
}
func (r *Repository) DeleteFailedByTMDB(ctx context.Context, mediaType MediaType, tmdbID int) (int, error) {
if tmdbID <= 0 {
return 0, nil
}
tag, err := r.pool.Exec(ctx, `
DELETE FROM media_requests
WHERE media_type = $1
AND provider = 'tmdb'
AND tmdb_id = $2
AND outcome = 'failed'
`, mediaType, tmdbID)
if err != nil {
return 0, fmt.Errorf("delete failed requests by tmdb: %w", err)
}
return int(tag.RowsAffected()), nil
}
// quotaLockNamespace partitions advisory locks so request-quota locks do not
// collide with advisory locks held elsewhere in the database. The value is
// arbitrary; what matters is that it is stable.
const quotaLockNamespace = 139
func (r *Repository) CreateRequest(ctx context.Context, input CreateRequestRecord) (*Request, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin create request transaction: %w", err)
}
defer tx.Rollback(ctx)
if input.Quota != nil {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1::int4, $2::int4)`,
quotaLockNamespace, input.Quota.UserID); err != nil {
return nil, fmt.Errorf("acquire request quota lock: %w", err)
}
var count int
if err := tx.QueryRow(ctx, `
SELECT COUNT(*)
FROM media_requests
WHERE requested_by_user_id = $1
AND created_at >= $2
`, input.Quota.UserID, input.Quota.WindowStart).Scan(&count); err != nil {
return nil, fmt.Errorf("count requests for quota: %w", err)
}
if count >= input.Quota.MaxRequests {
return nil, ErrQuotaExceeded
}
}
now := input.Now
if now.IsZero() {
now = time.Now().UTC()
}
status := input.Status
if status == "" {
status = StatusPending
}
outcome := input.Outcome
if outcome == "" {
outcome = OutcomeActive
}
var approvedAt any
if status != StatusPending {
approvedAt = now
}
req, err := r.insertRequest(ctx, tx, input, status, outcome, now, approvedAt)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return nil, ErrAlreadyRequested
}
return nil, err
}
if err := r.recordEvent(ctx, tx, req.ID, "created", input.Requester, ""); err != nil {
return nil, err
}
if status == StatusApproved {
if err := r.recordEvent(ctx, tx, req.ID, "approved", input.Requester, "auto approved"); err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit create request transaction: %w", err)
}
return req, nil
}
type requestExecutor interface {
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
}
func (r *Repository) insertRequest(
ctx context.Context,
exec requestExecutor,
input CreateRequestRecord,
status Status,
outcome Outcome,
now time.Time,
approvedAt any,
) (*Request, error) {
var tvdbID any
if input.Input.TVDBID != nil {
tvdbID = *input.Input.TVDBID
}
var year any
if input.Input.Year != nil {
year = *input.Input.Year
}
row := exec.QueryRow(ctx, `
INSERT INTO media_requests (
id, provider, media_type, tmdb_id, tvdb_id, imdb_id, title, year,
overview, poster_path, backdrop_path, status, outcome,
requested_by_user_id, requested_by_profile_id, created_at, updated_at, approved_at
)
VALUES (
$1, 'tmdb', $2, $3, $4, $5, $6, $7,
$8, $9, $10, $11, $12,
$13, $14, $15, $15, $16
)
RETURNING `+requestColumns(), input.ID, input.Input.MediaType, input.Input.TMDBID, tvdbID,
strings.TrimSpace(input.Input.IMDbID), strings.TrimSpace(input.Input.Title), year,
strings.TrimSpace(input.Input.Overview), strings.TrimSpace(input.Input.PosterPath),
strings.TrimSpace(input.Input.BackdropPath), status, outcome,
input.Requester.UserID, input.Requester.ProfileID, now, approvedAt)
req, err := scanRequest(row)
if err != nil {
return nil, fmt.Errorf("insert request: %w", err)
}
return req, nil
}
func (r *Repository) GetRequest(ctx context.Context, id string) (*Request, error) {
req, err := scanRequest(r.pool.QueryRow(ctx, requestSelectSQL()+`
WHERE id = $1
`, id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return req, nil
}
func (r *Repository) ListReconciliationCandidates(ctx context.Context, limit int) ([]*Request, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := r.pool.Query(ctx, requestSelectSQL()+`
WHERE outcome = 'active'
AND status IN ('approved', 'queued', 'downloading')
ORDER BY updated_at ASC
LIMIT $1
`, limit)
if err != nil {
return nil, fmt.Errorf("list request reconciliation candidates: %w", err)
}
defer rows.Close()
var out []*Request
for rows.Next() {
req, err := scanRequest(rows)
if err != nil {
return nil, err
}
out = append(out, req)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate request reconciliation candidates: %w", err)
}
return out, nil
}
func (r *Repository) ListMine(ctx context.Context, userID int, filter ListFilter) ([]*Request, error) {
sqlText, args := buildRequestListSQL("requested_by_user_id = $1", []any{userID}, filter)
return r.listRequests(ctx, sqlText, args)
}
func (r *Repository) ListAdmin(ctx context.Context, filter ListFilter) ([]*Request, error) {
sqlText, args := buildRequestListSQL("true", nil, filter)
return r.listRequests(ctx, sqlText, args)
}
func (r *Repository) listRequests(ctx context.Context, sqlText string, args []any) ([]*Request, error) {
rows, err := r.pool.Query(ctx, sqlText, args...)
if err != nil {
return nil, fmt.Errorf("list requests: %w", err)
}
defer rows.Close()
var out []*Request
for rows.Next() {
req, err := scanRequest(rows)
if err != nil {
return nil, err
}
out = append(out, req)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate requests: %w", err)
}
return out, nil
}
func (r *Repository) SetStatus(ctx context.Context, id string, status Status, actor Viewer) (*Request, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin request status transaction: %w", err)
}
defer tx.Rollback(ctx)
req, err := scanRequest(tx.QueryRow(ctx, `
UPDATE media_requests
SET status = $2,
updated_at = now(),
approved_at = CASE WHEN $2 = 'approved' AND approved_at IS NULL THEN now() ELSE approved_at END,
completed_at = CASE WHEN $2 = 'completed' AND completed_at IS NULL THEN now() ELSE completed_at END
WHERE id = $1
RETURNING `+requestColumns(), id, status))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("set request status: %w", err)
}
if err := r.recordEvent(ctx, tx, id, "status_"+string(status), actor, ""); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit request status transaction: %w", err)
}
return req, nil
}
func (r *Repository) MarkQueued(ctx context.Context, id string, update QueueUpdate, actor Viewer) (*Request, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin request queue transaction: %w", err)
}
defer tx.Rollback(ctx)
externalStatus := strings.TrimSpace(update.ExternalStatus)
if externalStatus == "" {
externalStatus = "queued"
}
req, err := scanRequest(tx.QueryRow(ctx, `
UPDATE media_requests
SET status = 'queued',
outcome = 'active',
integration_kind = $2,
external_id = $3,
external_status = $4,
last_error = '',
updated_at = now(),
approved_at = CASE WHEN approved_at IS NULL THEN now() ELSE approved_at END
WHERE id = $1
RETURNING `+requestColumns(), id,
strings.TrimSpace(update.IntegrationKind),
strings.TrimSpace(update.ExternalID),
externalStatus,
))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("mark request queued: %w", err)
}
if err := r.recordEvent(ctx, tx, id, "status_queued", actor, externalStatus); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit request queue transaction: %w", err)
}
return req, nil
}
func (r *Repository) SetOutcome(ctx context.Context, id string, outcome Outcome, actor Viewer, message string) (*Request, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin request outcome transaction: %w", err)
}
defer tx.Rollback(ctx)
req, err := scanRequest(tx.QueryRow(ctx, `
UPDATE media_requests
SET outcome = $2,
last_error = CASE
WHEN $2 = 'failed' THEN $3
WHEN $2 = 'active' THEN ''
ELSE last_error
END,
updated_at = now()
WHERE id = $1
RETURNING `+requestColumns(), id, outcome, strings.TrimSpace(message)))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("set request outcome: %w", err)
}
if err := r.recordEvent(ctx, tx, id, "outcome_"+string(outcome), actor, message); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit request outcome transaction: %w", err)
}
return req, nil
}
func (r *Repository) ListIntegrations(ctx context.Context) ([]Integration, error) {
rows, err := r.pool.Query(ctx, `
SELECT kind, enabled, base_url, api_key_ref, root_folder, quality_profile_id,
tags, options, last_check_at, last_check_status, last_check_error, updated_at
FROM request_integrations
ORDER BY kind
`)
if err != nil {
return nil, fmt.Errorf("list request integrations: %w", err)
}
defer rows.Close()
var out []Integration
for rows.Next() {
integration, err := scanIntegration(rows)
if err != nil {
return nil, err
}
out = append(out, integration)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate request integrations: %w", err)
}
return out, nil
}
func (r *Repository) UpsertIntegration(ctx context.Context, integration Integration) (*Integration, error) {
out, err := r.upsertIntegration(ctx, r.pool, integration)
if err != nil {
return nil, fmt.Errorf("upsert request integration: %w", err)
}
return out, nil
}
func (r *Repository) UpsertIntegrations(ctx context.Context, integrations []Integration) ([]Integration, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin request integrations transaction: %w", err)
}
defer tx.Rollback(ctx)
out := make([]Integration, 0, len(integrations))
for _, integration := range integrations {
updated, err := r.upsertIntegration(ctx, tx, integration)
if err != nil {
return nil, err
}
out = append(out, *updated)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit request integrations transaction: %w", err)
}
return out, nil
}
func (r *Repository) upsertIntegration(ctx context.Context, exec requestExecutor, integration Integration) (*Integration, error) {
if integration.Options == nil {
integration.Options = map[string]any{}
}
options, err := json.Marshal(integration.Options)
if err != nil {
return nil, fmt.Errorf("marshal request integration options: %w", err)
}
tags := int32Slice(integration.Tags)
row := exec.QueryRow(ctx, `
INSERT INTO request_integrations (
kind, enabled, base_url, api_key_ref, root_folder, quality_profile_id,
tags, options, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
ON CONFLICT (kind) DO UPDATE SET
enabled = EXCLUDED.enabled,
base_url = EXCLUDED.base_url,
api_key_ref = CASE
WHEN EXCLUDED.api_key_ref = '' THEN request_integrations.api_key_ref
ELSE EXCLUDED.api_key_ref
END,
root_folder = EXCLUDED.root_folder,
quality_profile_id = EXCLUDED.quality_profile_id,
tags = EXCLUDED.tags,
options = EXCLUDED.options,
updated_at = now()
RETURNING kind, enabled, base_url, api_key_ref, root_folder, quality_profile_id,
tags, options, last_check_at, last_check_status, last_check_error, updated_at
`, integration.Kind, integration.Enabled, strings.TrimSpace(integration.BaseURL),
strings.TrimSpace(integration.APIKeyRef), strings.TrimSpace(integration.RootFolder),
integration.QualityProfileID, tags, options)
out, err := scanIntegration(row)
if err != nil {
return nil, err
}
return &out, nil
}
func (r *Repository) recordEvent(ctx context.Context, exec requestExecutor, requestID, eventType string, actor Viewer, message string) error {
var actorUserID any
if actor.UserID > 0 {
actorUserID = actor.UserID
}
_, err := exec.Exec(ctx, `
INSERT INTO media_request_events (
request_id, event_type, actor_user_id, actor_profile_id, message
)
VALUES ($1, $2, $3, $4, $5)
`, requestID, eventType, actorUserID, actor.ProfileID, strings.TrimSpace(message))
if err != nil {
return fmt.Errorf("record request event: %w", err)
}
return nil
}
func buildRequestListSQL(baseCondition string, baseArgs []any, filter ListFilter) (string, []any) {
args := append([]any(nil), baseArgs...)
conditions := []string{baseCondition}
if filter.Status != "" {
args = append(args, filter.Status)
conditions = append(conditions, "status = $"+strconv.Itoa(len(args)))
}
if filter.Outcome != "" {
args = append(args, filter.Outcome)
conditions = append(conditions, "outcome = $"+strconv.Itoa(len(args)))
}
limit := filter.Limit
if limit <= 0 || limit > 100 {
limit = 50
}
offset := filter.Offset
if offset < 0 {
offset = 0
}
args = append(args, limit, offset)
return requestSelectSQL() + `
WHERE ` + strings.Join(conditions, " AND ") + `
ORDER BY created_at DESC
LIMIT $` + strconv.Itoa(len(args)-1) + ` OFFSET $` + strconv.Itoa(len(args)), args
}
func requestSelectSQL() string {
return "SELECT " + requestColumns() + " FROM media_requests "
}
func requestColumns() string {
return `id, provider, media_type, tmdb_id, tvdb_id, imdb_id, title, year,
overview, poster_path, backdrop_path, status, outcome,
requested_by_user_id, requested_by_profile_id, integration_kind,
external_id, external_status, last_error, created_at, updated_at,
approved_at, completed_at`
}
type requestScanner interface {
Scan(dest ...any) error
}
func scanRequest(row requestScanner) (*Request, error) {
var req Request
var tvdbID, year sql.NullInt64
var approvedAt, completedAt sql.NullTime
if err := row.Scan(
&req.ID,
&req.Provider,
&req.MediaType,
&req.TMDBID,
&tvdbID,
&req.IMDbID,
&req.Title,
&year,
&req.Overview,
&req.PosterPath,
&req.BackdropPath,
&req.Status,
&req.Outcome,
&req.RequestedByUserID,
&req.RequestedByProfileID,
&req.IntegrationKind,
&req.ExternalID,
&req.ExternalStatus,
&req.LastError,
&req.CreatedAt,
&req.UpdatedAt,
&approvedAt,
&completedAt,
); err != nil {
return nil, err
}
if tvdbID.Valid {
v := int(tvdbID.Int64)
req.TVDBID = &v
}
if year.Valid {
v := int(year.Int64)
req.Year = &v
}
if approvedAt.Valid {
req.ApprovedAt = &approvedAt.Time
}
if completedAt.Valid {
req.CompletedAt = &completedAt.Time
}
return &req, nil
}
type integrationScanner interface {
Scan(dest ...any) error
}
func scanIntegration(row integrationScanner) (Integration, error) {
var integration Integration
var quality sql.NullInt64
var tags []int32
var optionsRaw []byte
var lastCheckAt sql.NullTime
if err := row.Scan(
&integration.Kind,
&integration.Enabled,
&integration.BaseURL,
&integration.APIKeyRef,
&integration.RootFolder,
&quality,
&tags,
&optionsRaw,
&lastCheckAt,
&integration.LastCheckStatus,
&integration.LastCheckError,
&integration.UpdatedAt,
); err != nil {
return Integration{}, err
}
if quality.Valid {
v := int(quality.Int64)
integration.QualityProfileID = &v
}
integration.Tags = intsFromInt32(tags)
if len(optionsRaw) > 0 {
if err := json.Unmarshal(optionsRaw, &integration.Options); err != nil {
return Integration{}, fmt.Errorf("unmarshal request integration options for %s: %w", integration.Kind, err)
}
}
if integration.Options == nil {
integration.Options = map[string]any{}
}
if lastCheckAt.Valid {
integration.LastCheckAt = &lastCheckAt.Time
}
return integration, nil
}
func int32Slice(values []int) []int32 {
out := make([]int32, 0, len(values))
for _, value := range values {
out = append(out, int32(value))
}
return out
}
func intsFromInt32(values []int32) []int {
out := make([]int, 0, len(values))
for _, value := range values {
out = append(out, int(value))
}
return out
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+183
View File
@@ -0,0 +1,183 @@
package sonarr
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
"github.com/Silo-Server/silo-server/internal/requests/arrclient"
)
type Client struct {
httpClient *http.Client
}
type seriesResource struct {
ID int `json:"id,omitempty"`
Title string `json:"title,omitempty"`
TVDBID int `json:"tvdbId,omitempty"`
TMDBID int `json:"tmdbId,omitempty"`
TitleSlug string `json:"titleSlug,omitempty"`
QualityProfileID int `json:"qualityProfileId,omitempty"`
RootFolderPath string `json:"rootFolderPath,omitempty"`
SeasonFolder bool `json:"seasonFolder"`
Monitored bool `json:"monitored"`
SeriesType string `json:"seriesType,omitempty"`
Tags []int `json:"tags,omitempty"`
AddOptions addSeriesOptions `json:"addOptions,omitempty"`
}
type addSeriesOptions struct {
Monitor string `json:"monitor,omitempty"`
SearchForMissingEpisodes bool `json:"searchForMissingEpisodes"`
SearchForCutoffUnmetEpisodes bool `json:"searchForCutoffUnmetEpisodes,omitempty"`
}
func NewClient(httpClient *http.Client) *Client {
return &Client{httpClient: httpClient}
}
func (c *Client) ListSeriesIntegrationOptions(ctx context.Context, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error) {
client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient)
rootFolders, err := arrclient.ListRootFolders(ctx, client)
if err != nil {
return nil, err
}
qualityProfiles, err := arrclient.ListQualityProfiles(ctx, client)
if err != nil {
return nil, err
}
tags, err := arrclient.ListTags(ctx, client)
if err != nil {
return nil, err
}
return &mediarequests.IntegrationOptions{
Kind: "sonarr",
RootFolders: rootFolders,
QualityProfiles: qualityProfiles,
Tags: tags,
}, nil
}
func (c *Client) SubmitSeries(ctx context.Context, req mediarequests.Request, integration mediarequests.Integration) (mediarequests.FulfillmentResult, error) {
if req.MediaType != mediarequests.MediaTypeSeries {
return mediarequests.FulfillmentResult{}, fmt.Errorf("sonarr: request is not a series")
}
if integration.QualityProfileID == nil {
return mediarequests.FulfillmentResult{}, fmt.Errorf("sonarr: quality profile is required")
}
if req.TVDBID == nil || *req.TVDBID <= 0 {
return mediarequests.FulfillmentResult{}, fmt.Errorf("sonarr: tvdb_id is required")
}
client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient)
series, err := c.lookupSeries(ctx, client, *req.TVDBID)
if err != nil {
return mediarequests.FulfillmentResult{}, err
}
series.RootFolderPath = integration.RootFolder
series.QualityProfileID = *integration.QualityProfileID
series.SeasonFolder = arrclient.BoolOption(integration.Options, "season_folder", true)
series.Monitored = arrclient.BoolOption(integration.Options, "monitored", true)
series.SeriesType = arrclient.StringOption(integration.Options, "series_type", "standard")
series.Tags = integration.Tags
series.AddOptions = addSeriesOptions{
Monitor: arrclient.StringOption(integration.Options, "monitor", "all"),
SearchForMissingEpisodes: arrclient.BoolOption(
integration.Options,
"search_for_missing_episodes",
arrclient.BoolOption(integration.Options, "search_on_add", true),
),
SearchForCutoffUnmetEpisodes: arrclient.BoolOption(integration.Options, "search_for_cutoff_unmet", false),
}
var created seriesResource
if err := client.PostJSON(ctx, "/api/v3/series", series, &created); err != nil {
if !arrclient.IsEmptyOrTruncatedDecodeError(err) {
return mediarequests.FulfillmentResult{}, err
}
// POST accepted but Sonarr returned an empty body. Recover the
// new series' Sonarr ID by listing series filtered by TVDB ID;
// without the ID the reconcile loop cannot advance the request.
if found, lookErr := c.findSeriesByTVDBID(ctx, client, *req.TVDBID); lookErr == nil && found.ID > 0 {
return resultFromSeries(found), nil
}
return arrclient.AcceptedWithoutResponse("sonarr"), nil
}
return resultFromSeries(created), nil
}
func (c *Client) findSeriesByTVDBID(ctx context.Context, client *arrclient.Client, tvdbID int) (seriesResource, error) {
values := url.Values{}
values.Set("tvdbId", strconv.Itoa(tvdbID))
var matches []seriesResource
if err := client.GetJSON(ctx, "/api/v3/series?"+values.Encode(), &matches); err != nil {
return seriesResource{}, err
}
for _, s := range matches {
if s.ID > 0 && s.TVDBID == tvdbID {
return s, nil
}
}
return seriesResource{}, fmt.Errorf("sonarr: series not found after add for tvdb_id %d", tvdbID)
}
func (c *Client) CheckSeriesStatus(ctx context.Context, req mediarequests.Request, integration mediarequests.Integration) (mediarequests.FulfillmentStatus, error) {
client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient)
seriesID, _ := strconv.Atoi(req.ExternalID)
if seriesID <= 0 {
return mediarequests.FulfillmentStatus{
Status: mediarequests.StatusQueued,
IntegrationKind: "sonarr",
ExternalStatus: "external_id_unavailable",
}, nil
}
queues, err := c.queueDetails(ctx, client, seriesID)
if err != nil {
return mediarequests.FulfillmentStatus{}, err
}
evaluation := arrclient.EvaluateQueue(queues)
return arrclient.StatusFromQueueEvaluation("sonarr", seriesID, evaluation), nil
}
func (c *Client) lookupSeries(ctx context.Context, client *arrclient.Client, tvdbID int) (seriesResource, error) {
values := url.Values{}
values.Set("term", "tvdb:"+strconv.Itoa(tvdbID))
var matches []seriesResource
if err := client.GetJSON(ctx, "/api/v3/series/lookup?"+values.Encode(), &matches); err != nil {
return seriesResource{}, err
}
for _, match := range matches {
if match.TVDBID == tvdbID {
return match, nil
}
}
return seriesResource{}, fmt.Errorf("sonarr: no series found for tvdb_id %d", tvdbID)
}
func (c *Client) queueDetails(ctx context.Context, client *arrclient.Client, seriesID int) ([]arrclient.QueueResource, error) {
values := url.Values{}
values.Set("seriesId", strconv.Itoa(seriesID))
var queues []arrclient.QueueResource
if err := client.GetJSON(ctx, "/api/v3/queue/details?"+values.Encode(), &queues); err != nil {
return nil, err
}
return queues, nil
}
func resultFromSeries(series seriesResource) mediarequests.FulfillmentResult {
externalID := ""
if series.ID > 0 {
externalID = strconv.Itoa(series.ID)
}
return mediarequests.FulfillmentResult{
IntegrationKind: "sonarr",
ExternalID: externalID,
ExternalStatus: "queued",
}
}
+228
View File
@@ -0,0 +1,228 @@
package sonarr
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
)
func TestSubmitSeriesAddsLookupResult(t *testing.T) {
qualityProfileID := 3
tvdbID := 121361
var posted seriesResource
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Api-Key"); got != "sonarr-key" {
t.Fatalf("X-Api-Key = %q, want sonarr-key", got)
}
switch r.URL.Path {
case "/api/v3/series":
if r.Method == http.MethodGet {
if got := r.URL.Query().Get("tvdbId"); got != "121361" {
t.Fatalf("tvdbId = %q, want 121361", got)
}
w.Write([]byte(`[]`))
return
}
if r.Method == http.MethodPost {
if err := json.NewDecoder(r.Body).Decode(&posted); err != nil {
t.Fatalf("decode posted series: %v", err)
}
w.Write([]byte(`{"id":24,"tvdbId":121361}`))
return
}
case "/api/v3/series/lookup":
if got := r.URL.Query().Get("term"); got != "tvdb:121361" {
t.Fatalf("lookup term = %q, want tvdb:121361", got)
}
w.Write([]byte(`[{"title":"Game of Thrones","tvdbId":121361,"titleSlug":"game-of-thrones"}]`))
return
}
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}))
defer server.Close()
client := NewClient(server.Client())
result, err := client.SubmitSeries(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeSeries,
TMDBID: 1399,
TVDBID: &tvdbID,
Title: "Game of Thrones",
}, mediarequests.Integration{
Kind: "sonarr",
BaseURL: server.URL,
APIKeyRef: "sonarr-key",
RootFolder: "/series",
QualityProfileID: &qualityProfileID,
Options: map[string]any{
"monitor": "future",
},
})
if err != nil {
t.Fatalf("SubmitSeries returned error: %v", err)
}
if result.ExternalID != "24" || result.IntegrationKind != "sonarr" {
t.Fatalf("result = %+v, want sonarr external id 24", result)
}
if posted.RootFolderPath != "/series" || posted.QualityProfileID != qualityProfileID {
t.Fatalf("posted series = %+v, missing root folder/quality profile", posted)
}
if posted.AddOptions.Monitor != "future" {
t.Fatalf("monitor = %q, want future", posted.AddOptions.Monitor)
}
}
func TestSubmitSeriesRecoversFromEmptyAddResponse(t *testing.T) {
qualityProfileID := 3
tvdbID := 121361
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/series/lookup":
w.Write([]byte(`[{"title":"Game of Thrones","tvdbId":121361,"titleSlug":"game-of-thrones"}]`))
case "/api/v3/series":
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
return
}
if r.Method == http.MethodGet {
if got := r.URL.Query().Get("tvdbId"); got != "121361" {
t.Fatalf("tvdbId = %q, want 121361", got)
}
w.Write([]byte(`[{"id":77,"tvdbId":121361,"title":"Game of Thrones"}]`))
return
}
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
}))
defer server.Close()
client := NewClient(server.Client())
result, err := client.SubmitSeries(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeSeries,
TMDBID: 1399,
TVDBID: &tvdbID,
Title: "Game of Thrones",
}, mediarequests.Integration{
Kind: "sonarr",
BaseURL: server.URL,
APIKeyRef: "sonarr-key",
RootFolder: "/series",
QualityProfileID: &qualityProfileID,
})
if err != nil {
t.Fatalf("SubmitSeries returned error: %v", err)
}
if result.ExternalID != "77" {
t.Fatalf("ExternalID = %q, want 77 (recovered after empty 201)", result.ExternalID)
}
if result.ExternalStatus != "queued" {
t.Fatalf("ExternalStatus = %q, want queued", result.ExternalStatus)
}
}
func TestSubmitSeriesRejectsNonExactTVDBLookupMatch(t *testing.T) {
qualityProfileID := 3
tvdbID := 121361
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v3/series/lookup" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
w.Write([]byte(`[{"title":"Wrong Show","tvdbId":999,"titleSlug":"wrong-show"}]`))
}))
defer server.Close()
client := NewClient(server.Client())
_, err := client.SubmitSeries(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeSeries,
TMDBID: 1399,
TVDBID: &tvdbID,
Title: "Game of Thrones",
}, mediarequests.Integration{
Kind: "sonarr",
BaseURL: server.URL,
APIKeyRef: "sonarr-key",
RootFolder: "/series",
QualityProfileID: &qualityProfileID,
})
if err == nil {
t.Fatal("expected error for non-exact TVDB lookup match")
}
}
func TestCheckSeriesStatusReadsQueueDetails(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Api-Key"); got != "sonarr-key" {
t.Fatalf("X-Api-Key = %q, want sonarr-key", got)
}
if r.URL.Path != "/api/v3/queue/details" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
if got := r.URL.Query().Get("seriesId"); got != "24" {
t.Fatalf("seriesId = %q, want 24", got)
}
w.Write([]byte(`[{"seriesId":24,"status":"downloading","trackedDownloadState":"importing"}]`))
}))
defer server.Close()
client := NewClient(server.Client())
status, err := client.CheckSeriesStatus(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeSeries,
TMDBID: 1399,
ExternalID: "24",
}, mediarequests.Integration{
Kind: "sonarr",
BaseURL: server.URL,
APIKeyRef: "sonarr-key",
})
if err != nil {
t.Fatalf("CheckSeriesStatus returned error: %v", err)
}
if status.Status != mediarequests.StatusDownloading || status.ExternalStatus != "downloading/importing" {
t.Fatalf("status = %+v, want downloading", status)
}
}
func TestListSeriesIntegrationOptionsLoadsChoices(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Api-Key"); got != "sonarr-key" {
t.Fatalf("X-Api-Key = %q, want sonarr-key", got)
}
switch r.URL.Path {
case "/api/v3/rootfolder":
w.Write([]byte(`[{"path":"/series","freeSpace":123,"totalSpace":456,"accessible":true}]`))
case "/api/v3/qualityprofile":
w.Write([]byte(`[{"id":3,"name":"HD-1080p"}]`))
case "/api/v3/tag":
w.Write([]byte(`[{"id":4,"label":"requests"}]`))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer server.Close()
client := NewClient(server.Client())
options, err := client.ListSeriesIntegrationOptions(context.Background(), mediarequests.Integration{
Kind: "sonarr",
BaseURL: server.URL,
APIKeyRef: "sonarr-key",
})
if err != nil {
t.Fatalf("ListSeriesIntegrationOptions returned error: %v", err)
}
if options.Kind != "sonarr" {
t.Fatalf("kind = %q, want sonarr", options.Kind)
}
if len(options.RootFolders) != 1 || options.RootFolders[0].Path != "/series" {
t.Fatalf("root folders = %+v, want /series", options.RootFolders)
}
if len(options.QualityProfiles) != 1 || options.QualityProfiles[0].ID != 3 {
t.Fatalf("quality profiles = %+v, want id 3", options.QualityProfiles)
}
if len(options.Tags) != 1 || options.Tags[0].ID != 4 {
t.Fatalf("tags = %+v, want id 4", options.Tags)
}
}
+49
View File
@@ -0,0 +1,49 @@
package requests
import (
"context"
"time"
)
type Store interface {
GetSettings(ctx context.Context) (Settings, error)
UpdateSettings(ctx context.Context, settings Settings) (Settings, error)
GetUserLimit(ctx context.Context, userID int) (*UserLimit, error)
UpsertUserLimit(ctx context.Context, limit UserLimit) (*UserLimit, error)
CountUserRequestsSince(ctx context.Context, userID int, since time.Time) (int, error)
ListActiveByTMDB(ctx context.Context, mediaType MediaType, tmdbIDs []int) (map[int]*Request, error)
// DeleteFailedByTMDB removes prior failed requests for a given media so a
// re-request does not leave behind stale rows in user/admin lists.
DeleteFailedByTMDB(ctx context.Context, mediaType MediaType, tmdbID int) (int, error)
CreateRequest(ctx context.Context, input CreateRequestRecord) (*Request, error)
GetRequest(ctx context.Context, id string) (*Request, error)
ListReconciliationCandidates(ctx context.Context, limit int) ([]*Request, error)
ListMine(ctx context.Context, userID int, filter ListFilter) ([]*Request, error)
ListAdmin(ctx context.Context, filter ListFilter) ([]*Request, error)
SetStatus(ctx context.Context, id string, status Status, actor Viewer) (*Request, error)
MarkQueued(ctx context.Context, id string, update QueueUpdate, actor Viewer) (*Request, error)
SetOutcome(ctx context.Context, id string, outcome Outcome, actor Viewer, message string) (*Request, error)
ListIntegrations(ctx context.Context) ([]Integration, error)
UpsertIntegration(ctx context.Context, integration Integration) (*Integration, error)
UpsertIntegrations(ctx context.Context, integrations []Integration) ([]Integration, error)
}
type CreateRequestRecord struct {
ID string
Input CreateRequestInput
Status Status
Outcome Outcome
Requester Viewer
Now time.Time
// Quota, when non-nil, instructs the store to atomically verify the
// requester is below their per-user limit before inserting. The check
// runs inside the same transaction as the insert with a per-user
// advisory lock so concurrent submissions cannot both exceed the limit.
Quota *QuotaCheck
}
type QuotaCheck struct {
UserID int
WindowStart time.Time
MaxRequests int
}
+287
View File
@@ -0,0 +1,287 @@
package requests
import (
"time"
)
type MediaType string
const (
MediaTypeMovie MediaType = "movie"
MediaTypeSeries MediaType = "series"
MediaTypeAll MediaType = "all"
)
type Status string
const (
StatusPending Status = "pending"
StatusApproved Status = "approved"
StatusQueued Status = "queued"
StatusDownloading Status = "downloading"
StatusCompleted Status = "completed"
)
type Outcome string
const (
OutcomeActive Outcome = "active"
OutcomeDeclined Outcome = "declined"
OutcomeCancelled Outcome = "cancelled"
OutcomeFailed Outcome = "failed"
)
type Availability string
const (
AvailabilityMissing Availability = "missing"
AvailabilityAvailable Availability = "available"
)
type LimitMode string
const (
LimitModeInherit LimitMode = "inherit"
LimitModeCustom LimitMode = "custom"
LimitModeUnlimited LimitMode = "unlimited"
LimitModeBlocked LimitMode = "blocked"
)
type ApprovalMode string
const (
ApprovalModeInherit ApprovalMode = "inherit"
ApprovalModeManual ApprovalMode = "manual"
ApprovalModeAuto ApprovalMode = "auto"
ApprovalModeBlocked ApprovalMode = "blocked"
)
type Viewer struct {
UserID int
ProfileID string
IsAdmin bool
}
type Settings struct {
RequestsEnabled bool `json:"requests_enabled"`
GlobalMaxRequests int `json:"global_max_requests"`
GlobalWindowDays int `json:"global_window_days"`
GlobalAutoApprovalEnabled bool `json:"global_auto_approval_enabled"`
UpdatedAt time.Time `json:"updated_at"`
}
type UserLimit struct {
UserID int `json:"user_id"`
LimitMode LimitMode `json:"limit_mode"`
MaxRequests *int `json:"max_requests,omitempty"`
WindowDays *int `json:"window_days,omitempty"`
ApprovalMode ApprovalMode `json:"approval_mode"`
UpdatedAt time.Time `json:"updated_at"`
}
type EffectivePolicy struct {
RequestsEnabled bool
MaxRequests int
WindowDays int
Unlimited bool
Blocked bool
AutoApprove bool
Used int
Remaining int
WindowStart time.Time
}
type Request struct {
ID string `json:"id"`
Provider string `json:"provider"`
MediaType MediaType `json:"media_type"`
TMDBID int `json:"tmdb_id"`
TVDBID *int `json:"tvdb_id,omitempty"`
IMDbID string `json:"imdb_id,omitempty"`
Title string `json:"title"`
Year *int `json:"year,omitempty"`
Overview string `json:"overview,omitempty"`
PosterPath string `json:"poster_path,omitempty"`
BackdropPath string `json:"backdrop_path,omitempty"`
Status Status `json:"status"`
Outcome Outcome `json:"outcome"`
RequestedByUserID int `json:"requested_by_user_id,omitempty"`
RequestedByProfileID string `json:"requested_by_profile_id,omitempty"`
IntegrationKind string `json:"integration_kind,omitempty"`
ExternalID string `json:"external_id,omitempty"`
ExternalStatus string `json:"external_status,omitempty"`
LastError string `json:"last_error,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ApprovedAt *time.Time `json:"approved_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
type RequestEvent struct {
ID int64 `json:"id"`
RequestID string `json:"request_id"`
EventType string `json:"event_type"`
ActorUserID *int `json:"actor_user_id,omitempty"`
ActorProfileID string `json:"actor_profile_id,omitempty"`
Message string `json:"message,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type RequestState struct {
Status Status `json:"status,omitempty"`
Requestable bool `json:"requestable"`
Reason string `json:"reason,omitempty"`
RequestID string `json:"request_id,omitempty"`
}
type MediaResult struct {
MediaType MediaType `json:"media_type"`
TMDBID int `json:"tmdb_id"`
Title string `json:"title"`
Year int `json:"year,omitempty"`
Overview string `json:"overview,omitempty"`
PosterPath string `json:"poster_path,omitempty"`
BackdropPath string `json:"backdrop_path,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
Popularity float64 `json:"popularity,omitempty"`
VoteAverage float64 `json:"vote_average,omitempty"`
Availability Availability `json:"availability"`
Request RequestState `json:"request"`
}
type MediaPage struct {
Page int `json:"page"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
Results []MediaResult `json:"results"`
}
type MediaCastMember struct {
Name string `json:"name"`
Character string `json:"character,omitempty"`
ProfilePath string `json:"profile_path,omitempty"`
Order int `json:"order"`
}
type MediaDetail struct {
MediaType MediaType `json:"media_type"`
TMDBID int `json:"tmdb_id"`
IMDbID string `json:"imdb_id,omitempty"`
TVDBID *int `json:"tvdb_id,omitempty"`
Title string `json:"title"`
OriginalTitle string `json:"original_title,omitempty"`
Tagline string `json:"tagline,omitempty"`
Overview string `json:"overview,omitempty"`
PosterPath string `json:"poster_path,omitempty"`
BackdropPath string `json:"backdrop_path,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
Year int `json:"year,omitempty"`
Runtime int `json:"runtime,omitempty"`
Genres []string `json:"genres,omitempty"`
VoteAverage float64 `json:"vote_average,omitempty"`
VoteCount int `json:"vote_count,omitempty"`
Status string `json:"status,omitempty"`
Homepage string `json:"homepage,omitempty"`
ContentRating string `json:"content_rating,omitempty"`
ProductionCompanies []string `json:"production_companies,omitempty"`
NumberOfSeasons int `json:"number_of_seasons,omitempty"`
NumberOfEpisodes int `json:"number_of_episodes,omitempty"`
FirstAirDate string `json:"first_air_date,omitempty"`
LastAirDate string `json:"last_air_date,omitempty"`
Networks []string `json:"networks,omitempty"`
Cast []MediaCastMember `json:"cast,omitempty"`
Director string `json:"director,omitempty"`
Creators []string `json:"creators,omitempty"`
Recommendations []MediaResult `json:"recommendations,omitempty"`
Availability Availability `json:"availability"`
Request RequestState `json:"request"`
}
type CreateRequestInput struct {
MediaType MediaType `json:"media_type"`
TMDBID int `json:"tmdb_id"`
TVDBID *int `json:"tvdb_id,omitempty"`
IMDbID string `json:"imdb_id,omitempty"`
Title string `json:"title"`
Year *int `json:"year,omitempty"`
Overview string `json:"overview,omitempty"`
PosterPath string `json:"poster_path,omitempty"`
BackdropPath string `json:"backdrop_path,omitempty"`
}
type ListFilter struct {
Status Status
Outcome Outcome
Limit int
Offset int
}
type Integration struct {
Kind string `json:"kind"`
Enabled bool `json:"enabled"`
BaseURL string `json:"base_url"`
APIKeyRef string `json:"api_key_ref,omitempty"`
RootFolder string `json:"root_folder"`
QualityProfileID *int `json:"quality_profile_id,omitempty"`
Tags []int `json:"tags"`
Options map[string]any `json:"options"`
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
LastCheckStatus string `json:"last_check_status,omitempty"`
LastCheckError string `json:"last_check_error,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type IntegrationRootFolder struct {
Path string `json:"path"`
FreeSpace int64 `json:"free_space,omitempty"`
TotalSpace int64 `json:"total_space,omitempty"`
Accessible bool `json:"accessible"`
}
type IntegrationQualityProfile struct {
ID int `json:"id"`
Name string `json:"name"`
}
type IntegrationTag struct {
ID int `json:"id"`
Label string `json:"label"`
}
type IntegrationOptions struct {
Kind string `json:"kind"`
RootFolders []IntegrationRootFolder `json:"root_folders"`
QualityProfiles []IntegrationQualityProfile `json:"quality_profiles"`
Tags []IntegrationTag `json:"tags"`
}
type QueueUpdate struct {
IntegrationKind string
ExternalID string
ExternalStatus string
}
type FulfillmentResult struct {
IntegrationKind string
ExternalID string
ExternalStatus string
}
type FulfillmentStatus struct {
Status Status
Outcome Outcome
IntegrationKind string
ExternalID string
ExternalStatus string
Message string
}
type ReconcileResult struct {
Checked int `json:"checked"`
Submitted int `json:"submitted"`
Downloading int `json:"downloading"`
Completed int `json:"completed"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
Errors int `json:"errors"`
}
@@ -0,0 +1,59 @@
package tasks
import (
"context"
"encoding/json"
"fmt"
"github.com/Silo-Server/silo-server/internal/requests"
"github.com/Silo-Server/silo-server/internal/taskmanager"
)
type RequestReconciler interface {
ReconcileRequests(ctx context.Context, limit int) (requests.ReconcileResult, error)
}
type ReconcileRequestsTask struct {
reconciler RequestReconciler
limit int
}
func NewReconcileRequestsTask(reconciler RequestReconciler, limit int) *ReconcileRequestsTask {
if limit <= 0 {
limit = 100
}
return &ReconcileRequestsTask{reconciler: reconciler, limit: limit}
}
func (t *ReconcileRequestsTask) Key() string { return "reconcile_requests" }
func (t *ReconcileRequestsTask) Name() string { return "Reconcile Requests" }
func (t *ReconcileRequestsTask) Description() string {
return "Checks approved and active media requests against Radarr, Sonarr, and the Silo catalog"
}
func (t *ReconcileRequestsTask) Category() taskmanager.TaskCategory {
return taskmanager.TaskCategoryLibrary
}
func (t *ReconcileRequestsTask) IsHidden() bool { return false }
func (t *ReconcileRequestsTask) DefaultTriggers() []taskmanager.TriggerConfig {
return []taskmanager.TriggerConfig{
{Type: taskmanager.TriggerTypeInterval, IntervalMs: 5 * 60 * 1000},
}
}
func (t *ReconcileRequestsTask) Execute(ctx context.Context, progress taskmanager.ProgressReporter) error {
progress.Report(0, "Reconciling media requests")
if t.reconciler == nil {
progress.Report(100, "Request reconciliation unavailable")
return nil
}
result, err := t.reconciler.ReconcileRequests(ctx, t.limit)
if err != nil {
return fmt.Errorf("reconcile media requests: %w", err)
}
if data, err := json.Marshal(result); err == nil {
progress.SetResultData(data)
}
progress.Report(100, "Request reconciliation complete")
return nil
}