Merge pull request #7 from Silo-Server/t3code/discover-studios-networks-genres-clean
feat(requests): add media request system
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, "a):
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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{}{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user