384 lines
11 KiB
Go
384 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
"github.com/Silo-Server/silo-server/internal/metadata"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
"github.com/Silo-Server/silo-server/internal/ratelimit"
|
|
)
|
|
|
|
type peopleRepository interface {
|
|
Get(ctx context.Context, id int64) (*models.Person, error)
|
|
Search(ctx context.Context, query string, limit int) ([]models.Person, error)
|
|
Update(ctx context.Context, p models.Person) error
|
|
}
|
|
|
|
type PersonRefreshQueue interface {
|
|
Enqueue(id int64)
|
|
}
|
|
|
|
type PersonRefresher interface {
|
|
RefreshPerson(ctx context.Context, id int64) (*models.Person, error)
|
|
}
|
|
|
|
var personRefreshRate = ratelimit.Rate{
|
|
RequestsPerSecond: 10,
|
|
RequestsPerMinute: 10,
|
|
Burst: 10,
|
|
}
|
|
|
|
// PeopleHandler serves person-related API endpoints.
|
|
type PeopleHandler struct {
|
|
personRepo peopleRepository
|
|
catalogResolver *catalog.CatalogResolver
|
|
detailSvc *catalog.DetailService
|
|
itemsHandler *ItemsHandler
|
|
refreshQueue PersonRefreshQueue
|
|
refresher PersonRefresher
|
|
refreshLimiter ratelimit.RateLimiter
|
|
}
|
|
|
|
// NewPeopleHandler creates a new people handler.
|
|
func NewPeopleHandler(
|
|
personRepo peopleRepository,
|
|
browseRepo *catalog.BrowseRepository,
|
|
itemRepo *catalog.ItemRepository,
|
|
detailSvc *catalog.DetailService,
|
|
) *PeopleHandler {
|
|
return &PeopleHandler{
|
|
personRepo: personRepo,
|
|
catalogResolver: catalog.NewCatalogResolver(browseRepo, itemRepo),
|
|
detailSvc: detailSvc,
|
|
refreshLimiter: ratelimit.NewMemoryLimiter(),
|
|
}
|
|
}
|
|
|
|
// SetItemsHandler sets the items handler for browse response formatting.
|
|
func (h *PeopleHandler) SetItemsHandler(ih *ItemsHandler) {
|
|
h.itemsHandler = ih
|
|
}
|
|
|
|
func (h *PeopleHandler) SetRefreshQueue(queue PersonRefreshQueue) {
|
|
h.refreshQueue = queue
|
|
}
|
|
|
|
func (h *PeopleHandler) SetRefreshService(refresher PersonRefresher) {
|
|
h.refresher = refresher
|
|
}
|
|
|
|
type personResponse struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Bio string `json:"bio,omitempty"`
|
|
BirthDate *string `json:"birth_date,omitempty"`
|
|
DeathDate *string `json:"death_date,omitempty"`
|
|
Birthplace string `json:"birthplace,omitempty"`
|
|
Homepage string `json:"homepage,omitempty"`
|
|
PhotoURL string `json:"photo_url,omitempty"`
|
|
PhotoThumbhash string `json:"photo_thumbhash,omitempty"`
|
|
TmdbID string `json:"tmdb_id,omitempty"`
|
|
ImdbID string `json:"imdb_id,omitempty"`
|
|
TvdbID string `json:"tvdb_id,omitempty"`
|
|
PlexGUID string `json:"plex_guid,omitempty"`
|
|
}
|
|
|
|
// HandleSearch serves GET /api/people?q=&limit=
|
|
func (h *PeopleHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
|
query := r.URL.Query().Get("q")
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
|
|
people, err := h.personRepo.Search(r.Context(), query, limit)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "search_failed", err.Error())
|
|
return
|
|
}
|
|
|
|
resp := make([]personResponse, len(people))
|
|
for i, p := range people {
|
|
resp[i] = h.toResponse(r.Context(), p)
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// HandleGetPerson serves GET /api/people/:id
|
|
func (h *PeopleHandler) HandleGetPerson(w http.ResponseWriter, r *http.Request) {
|
|
idStr := chi.URLParam(r, "id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_id", "invalid person ID")
|
|
return
|
|
}
|
|
|
|
person, err := h.personRepo.Get(r.Context(), id)
|
|
if err != nil {
|
|
slog.Warn("people: get person failed", "id", id, "id_str", idStr, "error", err)
|
|
writeError(w, http.StatusNotFound, "not_found", "person not found")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, h.toResponse(r.Context(), *person))
|
|
}
|
|
|
|
// HandleRefreshPerson serves POST /api/v1/people/:id/refresh.
|
|
func (h *PeopleHandler) HandleRefreshPerson(w http.ResponseWriter, r *http.Request) {
|
|
if h.refreshQueue == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Person refresh is not configured")
|
|
return
|
|
}
|
|
|
|
id, ok := parsePersonID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
userID := apimw.GetUserID(r.Context())
|
|
if userID == 0 {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
|
return
|
|
}
|
|
|
|
result := h.refreshLimiter.Allow(r.Context(), strconv.Itoa(userID), personRefreshRate)
|
|
if !result.Allowed {
|
|
if result.RetryAfter > 0 {
|
|
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(result.RetryAfter.Seconds()))))
|
|
}
|
|
writeError(w, http.StatusTooManyRequests, "rate_limited", "Too many person refresh requests")
|
|
return
|
|
}
|
|
|
|
person, err := h.personRepo.Get(r.Context(), id)
|
|
if err != nil || person == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "person not found")
|
|
return
|
|
}
|
|
|
|
h.refreshQueue.Enqueue(id)
|
|
writeJSON(w, http.StatusAccepted, map[string]any{
|
|
"status": "queued",
|
|
"person_id": id,
|
|
})
|
|
}
|
|
|
|
// HandleAdminRefreshPerson serves POST /api/v1/admin/people/:id/refresh.
|
|
func (h *PeopleHandler) HandleAdminRefreshPerson(w http.ResponseWriter, r *http.Request) {
|
|
if h.refresher == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Person refresh is not configured")
|
|
return
|
|
}
|
|
|
|
id, ok := parsePersonID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
|
defer cancel()
|
|
|
|
person, err := h.refresher.RefreshPerson(ctx, id)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, metadata.ErrPersonNotFound):
|
|
writeError(w, http.StatusNotFound, "not_found", "person not found")
|
|
case errors.Is(err, metadata.ErrPersonMetadataNotFound):
|
|
writeError(w, http.StatusBadGateway, "provider_error", "No person metadata found")
|
|
default:
|
|
slog.Warn("people: admin refresh failed", "id", id, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to refresh person")
|
|
}
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, h.toResponse(r.Context(), *person))
|
|
}
|
|
|
|
type UpdatePersonRequest struct {
|
|
Name *string `json:"name"`
|
|
Bio *string `json:"bio"`
|
|
BirthDate *string `json:"birth_date"`
|
|
DeathDate *string `json:"death_date"`
|
|
Birthplace *string `json:"birthplace"`
|
|
Homepage *string `json:"homepage"`
|
|
TmdbID *string `json:"tmdb_id"`
|
|
ImdbID *string `json:"imdb_id"`
|
|
TvdbID *string `json:"tvdb_id"`
|
|
}
|
|
|
|
// HandleAdminUpdatePerson serves PATCH /api/v1/admin/people/:id.
|
|
func (h *PeopleHandler) HandleAdminUpdatePerson(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := parsePersonID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req UpdatePersonRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
person, err := h.personRepo.Get(r.Context(), id)
|
|
if err != nil || person == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "person not found")
|
|
return
|
|
}
|
|
|
|
if req.Name != nil {
|
|
person.Name = *req.Name
|
|
person.SortName = *req.Name
|
|
}
|
|
if req.Bio != nil {
|
|
person.Bio = *req.Bio
|
|
}
|
|
if req.BirthDate != nil {
|
|
parsed, err := parseOptionalPersonDate(*req.BirthDate)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid birth_date")
|
|
return
|
|
}
|
|
person.BirthDate = parsed
|
|
}
|
|
if req.DeathDate != nil {
|
|
parsed, err := parseOptionalPersonDate(*req.DeathDate)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid death_date")
|
|
return
|
|
}
|
|
person.DeathDate = parsed
|
|
}
|
|
if req.Birthplace != nil {
|
|
person.Birthplace = *req.Birthplace
|
|
}
|
|
if req.Homepage != nil {
|
|
person.Homepage = *req.Homepage
|
|
}
|
|
if req.TmdbID != nil {
|
|
person.TmdbID = *req.TmdbID
|
|
}
|
|
if req.ImdbID != nil {
|
|
person.ImdbID = *req.ImdbID
|
|
}
|
|
if req.TvdbID != nil {
|
|
person.TvdbID = *req.TvdbID
|
|
}
|
|
|
|
if err := h.personRepo.Update(r.Context(), *person); err != nil {
|
|
slog.Error("people: admin update failed", "id", id, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update person")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, h.toResponse(r.Context(), *person))
|
|
}
|
|
|
|
// HandleGetPersonItems serves GET /api/people/:id/items?type=&limit=&offset=
|
|
func (h *PeopleHandler) HandleGetPersonItems(w http.ResponseWriter, r *http.Request) {
|
|
writeDeprecatedReadHeaders(w, "/api/v1/catalog?source=person")
|
|
idStr := chi.URLParam(r, "id")
|
|
if _, err := strconv.ParseInt(idStr, 10, 64); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_id", "invalid person ID")
|
|
return
|
|
}
|
|
|
|
values := url.Values{}
|
|
for key, rawValues := range r.URL.Query() {
|
|
for _, value := range rawValues {
|
|
values.Add(key, value)
|
|
}
|
|
}
|
|
values.Set("source", "person")
|
|
values.Set("person_id", idStr)
|
|
if values.Get("limit") == "" {
|
|
values.Set("limit", "24")
|
|
}
|
|
|
|
req, err := catalog.ParseCatalogRequest(values)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
|
|
result, err := h.catalogResolver.Resolve(r.Context(), req, h.itemsHandler.accessFilter(r))
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "browse_failed", err.Error())
|
|
return
|
|
}
|
|
|
|
items := make([]itemListResponse, 0, len(result.Items))
|
|
for _, item := range result.Items {
|
|
items = append(items, h.itemsHandler.toItemListResponse(r, item))
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, browseResponse{
|
|
Total: result.Total,
|
|
HasMore: req.Offset+len(items) < result.Total,
|
|
Items: items,
|
|
})
|
|
}
|
|
|
|
func (h *PeopleHandler) toResponse(ctx context.Context, p models.Person) personResponse {
|
|
resp := personResponse{
|
|
ID: p.ID,
|
|
Name: p.Name,
|
|
Bio: p.Bio,
|
|
Birthplace: p.Birthplace,
|
|
Homepage: p.Homepage,
|
|
TmdbID: p.TmdbID,
|
|
ImdbID: p.ImdbID,
|
|
TvdbID: p.TvdbID,
|
|
PlexGUID: p.PlexGUID,
|
|
}
|
|
if p.BirthDate != nil {
|
|
s := p.BirthDate.Format("2006-01-02")
|
|
resp.BirthDate = &s
|
|
}
|
|
if p.DeathDate != nil {
|
|
s := p.DeathDate.Format("2006-01-02")
|
|
resp.DeathDate = &s
|
|
}
|
|
if p.PhotoPath != "" && p.PhotoPath != "-" && h.detailSvc != nil {
|
|
resp.PhotoURL = h.detailSvc.PresignURL(ctx, featuredPosterPath(p.PhotoPath), "featured")
|
|
}
|
|
if p.PhotoThumbhash != "" && p.PhotoThumbhash != "-" {
|
|
resp.PhotoThumbhash = p.PhotoThumbhash
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func parseOptionalPersonDate(raw string) (*time.Time, error) {
|
|
if raw == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
parsed, err := time.Parse("2006-01-02", raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &parsed, nil
|
|
}
|
|
|
|
func parsePersonID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
|
idStr := chi.URLParam(r, "id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_id", "invalid person ID")
|
|
return 0, false
|
|
}
|
|
return id, true
|
|
}
|