Files
silo-server/internal/api/handlers/catalog_resources.go
QuickandGitHub 73488d1bfa feat(metadata): add original-language preferences (#526)
* feat(metadata): add original-language preferences

* fix(settings): show metadata exceptions immediately

* docs(settings): add metadata language screenshot

* fix(settings): make language exceptions responsive

* fix(settings): standardize language display names
2026-07-31 15:23:30 -04:00

569 lines
18 KiB
Go

package handlers
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"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/models"
)
// CatalogResourceHandler serves canonical catalog resource read routes.
type CatalogResourceHandler struct {
items *ItemsHandler
}
// NewCatalogResourceHandler creates a new canonical catalog resource handler.
func NewCatalogResourceHandler(items *ItemsHandler) *CatalogResourceHandler {
return &CatalogResourceHandler{items: items}
}
func (h *CatalogResourceHandler) HandleGetItemDetail(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required")
return
}
detail, err := h.items.detailSvc.GetItemDetail(r.Context(), id, h.items.accessFilter(r))
if err != nil {
if isNotFound(err) {
syntheticDetail, syntheticErr := h.syntheticSeasonDetail(r, id)
if syntheticErr != nil {
if isNotFound(syntheticErr) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item detail")
return
}
h.enrichItemDetail(r, syntheticDetail)
h.items.maybeRequestStaleDetailMetadataRefresh(r.Context(), syntheticDetail)
writeJSON(w, http.StatusOK, syntheticDetail)
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item detail")
return
}
h.enrichItemDetail(r, detail)
h.items.maybeRequestStaleDetailMetadataRefresh(r.Context(), detail)
writeJSON(w, http.StatusOK, detail)
}
func (h *CatalogResourceHandler) HandleGetItemVersions(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required")
return
}
detail, err := h.items.detailSvc.GetItemDetail(r.Context(), id, h.items.accessFilter(r))
if err != nil {
if isNotFound(err) {
if _, _, ok := parseSyntheticSeasonID(id); ok {
writeJSON(w, http.StatusOK, []catalog.FileVersion{})
return
}
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item versions")
return
}
if !h.items.requestCanViewFilePaths(r) {
for i := range detail.Versions {
detail.Versions[i].FilePath = ""
}
}
writeJSON(w, http.StatusOK, detail.Versions)
}
// HandleGetMangaFiles returns the local file listing for a manga series (the
// series "View Details" dialog): folder paths plus per-chapter file rows.
// Folder and file paths are stripped for viewers without file-path visibility,
// matching the item-versions policy; file names and sizes remain.
func (h *CatalogResourceHandler) HandleGetMangaFiles(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required")
return
}
files, err := h.items.detailSvc.GetMangaChapterFiles(r.Context(), id, h.items.accessFilter(r))
if err != nil {
if isNotFound(err) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get manga files")
return
}
if !h.items.requestCanViewFilePaths(r) {
files.FolderPaths = nil
for i := range files.Files {
files.Files[i].FilePath = ""
}
}
writeJSON(w, http.StatusOK, files)
}
func (h *CatalogResourceHandler) HandleGetItemEpisodes(w http.ResponseWriter, r *http.Request) {
filter := h.items.accessFilter(r)
id := chi.URLParam(r, "id")
if id == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required")
return
}
if h.items.seasonRepo == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
season, err := h.items.seasonRepo.GetByID(r.Context(), id)
if err != nil {
if errors.Is(err, catalog.ErrSeasonNotFound) {
seriesID, seasonNum, ok := parseSyntheticSeasonID(id)
if !ok {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
if err := h.items.itemRepo.EnsureAccessible(r.Context(), seriesID, filter); err != nil {
if isNotFound(err) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list item episodes")
return
}
if err := h.items.ensurePresentationLibraryAccess(r.Context(), seriesID, filter); err != nil {
if isNotFound(err) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list item episodes")
return
}
episodes, err := h.items.episodeRepo.ListBySeason(r.Context(), seriesID, seasonNum)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list item episodes")
return
}
if len(episodes) == 0 {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
h.writeEpisodeResponses(w, r, episodes)
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list item episodes")
return
}
if err := h.items.itemRepo.EnsureAccessible(r.Context(), season.SeriesID, filter); err != nil {
if isNotFound(err) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list item episodes")
return
}
if err := h.items.ensurePresentationLibraryAccess(r.Context(), season.SeriesID, filter); err != nil {
if isNotFound(err) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list item episodes")
return
}
episodes, err := h.items.episodeRepo.ListBySeasonID(r.Context(), season.ContentID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list item episodes")
return
}
h.items.maybeRequestStaleSeasonMetadataRefresh(r.Context(), season.ContentID, episodes)
h.writeEpisodeResponses(w, r, episodes)
}
func (h *CatalogResourceHandler) HandleGetSeasons(w http.ResponseWriter, r *http.Request) {
filter := h.items.accessFilter(r)
id := chi.URLParam(r, "id")
if id == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Series ID is required")
return
}
if err := h.items.itemRepo.EnsureAccessible(r.Context(), id, filter); err != nil {
if isNotFound(err) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list seasons")
return
}
if err := h.items.ensurePresentationLibraryAccess(r.Context(), id, filter); err != nil {
if isNotFound(err) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list seasons")
return
}
if h.items.seasonRepo != nil {
seasons, err := h.items.seasonRepo.ListBySeries(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list seasons")
return
}
if len(seasons) > 0 {
if h.items.detailSvc != nil {
if localized, locErr := h.items.detailSvc.LocalizeSeasonModels(r.Context(), seasons, filter); locErr == nil && len(localized) == len(seasons) {
seasons = localized
}
}
episodesBySeason, err := h.items.episodeRepo.ListBySeriesGroupedBySeason(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list seasons")
return
}
progressMap, hasProgressMap := h.items.progressMapForEpisodes(r, flattenEpisodeGroups(episodesBySeason))
resp := make([]seasonResponse, 0, len(seasons))
for _, s := range seasons {
episodes := episodesBySeason[s.SeasonNumber]
if len(episodes) == 0 {
continue
}
var userData *catalog.SeasonUserData
if hasProgressMap {
userData = catalog.EpisodeRollupUserData(episodes, progressMap)
}
sr := h.items.seasonResponseFromEpisodes(r, s, episodes, userData)
resp = append(resp, sr)
}
writeJSON(w, http.StatusOK, seasonsResponse{Seasons: resp})
return
}
}
summaries, err := h.items.episodeRepo.ListSeasons(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list seasons")
return
}
resp := make([]seasonResponse, 0, len(summaries))
for _, s := range summaries {
title := "Season " + strconv.Itoa(s.SeasonNumber)
if s.SeasonNumber == 0 {
title = "Specials"
}
episodes, _ := h.items.episodeRepo.ListBySeason(r.Context(), id, s.SeasonNumber)
userData := h.items.getAggregateUserData(r, episodes)
resp = append(resp, seasonResponse{
ContentID: fmt.Sprintf("%s-S%02d", id, s.SeasonNumber),
SeasonNumber: s.SeasonNumber,
IsSpecials: s.SeasonNumber == 0,
EpisodeCount: s.EpisodeCount,
Title: title,
UserData: userData,
})
}
writeJSON(w, http.StatusOK, seasonsResponse{Seasons: resp})
}
func (h *CatalogResourceHandler) HandleGetSeason(w http.ResponseWriter, r *http.Request) {
filter := h.items.accessFilter(r)
id := chi.URLParam(r, "id")
numStr := chi.URLParam(r, "num")
if id == "" || numStr == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Series ID and season number are required")
return
}
num, err := strconv.Atoi(numStr)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid season number")
return
}
if err := h.items.itemRepo.EnsureAccessible(r.Context(), id, filter); err != nil {
if isNotFound(err) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get season")
return
}
if err := h.items.ensurePresentationLibraryAccess(r.Context(), id, filter); err != nil {
if isNotFound(err) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get season")
return
}
if h.items.seasonRepo != nil {
season, err := h.items.seasonRepo.GetBySeriesAndNumber(r.Context(), id, num)
switch {
case err == nil:
episodes, err := h.items.episodeRepo.ListBySeasonID(r.Context(), season.ContentID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get season")
return
}
if len(episodes) == 0 {
episodes, err = h.items.episodeRepo.ListBySeason(r.Context(), id, season.SeasonNumber)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get season")
return
}
}
h.items.maybeRequestStaleSeasonMetadataRefresh(r.Context(), season.ContentID, episodes)
writeJSON(w, http.StatusOK, seasonDetailResponse{
Season: h.items.toSeasonResponseFromEpisodes(
r,
id,
season,
episodes,
h.items.getAggregateUserData(r, episodes),
),
})
return
case !errors.Is(err, catalog.ErrSeasonNotFound):
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get season")
return
}
}
episodes, err := h.items.episodeRepo.ListBySeason(r.Context(), id, num)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get season")
return
}
if len(episodes) == 0 {
writeError(w, http.StatusNotFound, "not_found", "Season not found")
return
}
title := "Season " + strconv.Itoa(num)
if num == 0 {
title = "Specials"
}
seasonID := fmt.Sprintf("%s-S%02d", id, num)
resp := seasonResponse{
ContentID: seasonID,
SeasonNumber: num,
IsSpecials: num == 0,
Title: title,
EpisodeCount: len(episodes),
UserData: h.items.getAggregateUserData(r, episodes),
}
writeJSON(w, http.StatusOK, seasonDetailResponse{Season: resp})
}
func (h *CatalogResourceHandler) HandleGetEpisodes(w http.ResponseWriter, r *http.Request) {
filter := h.items.accessFilter(r)
id := chi.URLParam(r, "id")
numStr := chi.URLParam(r, "num")
if id == "" || numStr == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Series ID and season number are required")
return
}
num, err := strconv.Atoi(numStr)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid season number")
return
}
if err := h.items.itemRepo.EnsureAccessible(r.Context(), id, filter); err != nil {
if isNotFound(err) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list episodes")
return
}
if err := h.items.ensurePresentationLibraryAccess(r.Context(), id, filter); err != nil {
if isNotFound(err) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list episodes")
return
}
episodes, err := h.items.episodeRepo.ListBySeason(r.Context(), id, num)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list episodes")
return
}
if h.items.seasonRepo != nil {
if season, seasonErr := h.items.seasonRepo.GetBySeriesAndNumber(r.Context(), id, num); seasonErr == nil && season != nil {
h.items.maybeRequestStaleSeasonMetadataRefresh(r.Context(), season.ContentID, episodes)
}
}
h.writeEpisodeResponses(w, r, episodes)
}
func (h *CatalogResourceHandler) syntheticSeasonDetail(r *http.Request, seasonID string) (*catalog.ItemDetail, error) {
seriesID, seasonNum, ok := parseSyntheticSeasonID(seasonID)
if !ok {
return nil, catalog.ErrItemNotFound
}
if h.items.detailSvc == nil || h.items.episodeRepo == nil {
return nil, catalog.ErrItemNotFound
}
seriesDetail, err := h.items.detailSvc.GetItemDetail(r.Context(), seriesID, h.items.accessFilter(r))
if err != nil {
return nil, err
}
episodes, err := h.items.episodeRepo.ListBySeason(r.Context(), seriesID, seasonNum)
if err != nil {
return nil, fmt.Errorf("listing season episodes: %w", err)
}
if len(episodes) == 0 {
return nil, catalog.ErrItemNotFound
}
season := &models.Season{
ContentID: seasonID,
SeriesID: seriesID,
SeasonNumber: seasonNum,
}
if seasonNum == 0 {
season.Title = "Specials"
} else {
season.Title = "Season " + strconv.Itoa(seasonNum)
}
seasonResp := h.items.toSeasonResponseFromEpisodes(
r,
seriesID,
season,
episodes,
h.items.getAggregateUserData(r, episodes),
)
return &catalog.ItemDetail{
ContentID: seasonID,
Type: "season",
Title: seasonResp.Title,
Overview: seasonResp.Overview,
SeriesID: seriesID,
SeriesTitle: seriesDetail.Title,
SeasonNumber: &seasonNum,
EpisodeCount: &seasonResp.EpisodeCount,
IsSpecials: seasonNum == 0,
SeasonUserData: seasonResp.UserData,
Cast: seriesDetail.Cast,
Crew: seriesDetail.Crew,
BackdropURL: seriesDetail.BackdropURL,
BackdropThumbhash: seriesDetail.BackdropThumbhash,
PosterThumbhash: seasonResp.PosterThumbhash,
Versions: []catalog.FileVersion{},
Subtitles: []catalog.SubtitleInfo{},
}, nil
}
func (h *CatalogResourceHandler) writeEpisodeResponses(w http.ResponseWriter, r *http.Request, episodes []*models.Episode) {
writeJSON(w, http.StatusOK, episodesListResponse{Episodes: h.items.buildEpisodeResponses(r, episodes)})
}
func parseSyntheticSeasonID(contentID string) (string, int, bool) {
seriesID, seasonPart, ok := strings.Cut(contentID, "-S")
if !ok || seriesID == "" || seasonPart == "" {
return "", 0, false
}
seasonNum, err := strconv.Atoi(seasonPart)
if err != nil {
return "", 0, false
}
return seriesID, seasonNum, true
}
func (h *CatalogResourceHandler) enrichItemDetail(r *http.Request, detail *catalog.ItemDetail) {
switch detail.Type {
case "season":
if h.items.episodeRepo != nil {
episodes, err := h.items.episodeRepo.ListBySeasonID(r.Context(), detail.ContentID)
if err == nil {
detail.SeasonUserData = h.items.getAggregateUserData(r, episodes)
}
}
case "series":
if h.items.episodeRepo != nil {
episodes, err := h.items.episodeRepo.ListBySeries(r.Context(), detail.ContentID)
if err == nil {
detail.SeasonUserData = h.items.getAggregateUserData(r, episodes)
}
}
case "movie", "episode", "audiobook", "ebook":
detail.SeasonUserData = h.items.getLeafUserData(r, detail.ContentID, detail.Type)
applyEffectiveEditionPreference(detail.SeasonUserData, &detail.EffectiveVersionEditionKey)
}
if !h.items.requestCanViewFilePaths(r) {
for i := range detail.Versions {
detail.Versions[i].FilePath = ""
}
detail.FolderPaths = nil
}
h.enrichViewerState(r, detail)
}
func (h *CatalogResourceHandler) enrichViewerState(r *http.Request, detail *catalog.ItemDetail) {
store, profileID, ok := h.items.userStoreForRequest(r)
if !ok || detail == nil {
return
}
isFavorite, err := store.IsFavorite(r.Context(), profileID, detail.ContentID)
if err != nil {
return
}
inWatchlist, err := store.InWatchlist(r.Context(), profileID, detail.ContentID)
if err != nil {
return
}
detail.UserState = &catalog.ItemUserState{
Played: detail.SeasonUserData != nil && detail.SeasonUserData.Played,
IsFavorite: isFavorite,
InWatchlist: inWatchlist,
}
if h.items.ratingsRepo == nil {
return
}
userID := apimw.GetUserID(r.Context())
if userID == 0 {
return
}
rating, err := h.items.ratingsRepo.Get(r.Context(), userID, profileID, detail.ContentID)
if err != nil || rating == nil {
return
}
detail.UserRating = &rating.Rating
}