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