Files
silo-server/internal/jellycompat/handlers_userdata.go
T

274 lines
8.6 KiB
Go

package jellycompat
import (
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/config"
)
// UserDataHandler serves Jellyfin favorites and played-state routes.
type UserDataHandler struct {
content ContentService
userData UserDataService
codec *ResourceIDCodec
mapper *mapper
}
// NewUserDataHandler creates a new user-data handler.
func NewUserDataHandler(content ContentService, userData UserDataService, codec *ResourceIDCodec, cfg *config.Config) *UserDataHandler {
return &UserDataHandler{
content: content,
userData: userData,
codec: codec,
mapper: newMapper(codec, cfg),
}
}
// HandleGetUserData serves GET /UserItems/{itemId}/UserData.
func (h *UserDataHandler) HandleGetUserData(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
contentID, err := decodeContentID(h.codec, chi.URLParam(r, "itemId"))
if err != nil {
writeError(w, http.StatusNotFound, "NotFound", "Item not found")
return
}
detail, err := h.content.GetItemDetail(r.Context(), session, contentID, nil)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
favMap, progressMap, err := resolveUserStateForContentIDs(
r.Context(), session, h.userData, []string{detail.ContentID},
)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
isFavorite := favMap[detail.ContentID]
progress := progressMap[detail.ContentID]
writeJSON(w, http.StatusOK, h.mapper.itemFromDetail(*detail, isFavorite, progress).UserData)
}
// HandleAddFavorite serves POST /UserFavoriteItems/{itemId}.
func (h *UserDataHandler) HandleAddFavorite(w http.ResponseWriter, r *http.Request) {
h.handleFavoriteMutation(w, r, true)
}
// HandleRemoveFavorite serves DELETE /UserFavoriteItems/{itemId}.
func (h *UserDataHandler) HandleRemoveFavorite(w http.ResponseWriter, r *http.Request) {
h.handleFavoriteMutation(w, r, false)
}
// HandleAddFavoriteLegacy serves POST /Users/{userId}/FavoriteItems/{itemId}.
func (h *UserDataHandler) HandleAddFavoriteLegacy(w http.ResponseWriter, r *http.Request) {
if !validatePseudoUser(w, chi.URLParam(r, "userId"), SessionFromContext(r.Context())) {
return
}
h.handleFavoriteMutation(w, r, true)
}
// HandleRemoveFavoriteLegacy serves DELETE /Users/{userId}/FavoriteItems/{itemId}.
func (h *UserDataHandler) HandleRemoveFavoriteLegacy(w http.ResponseWriter, r *http.Request) {
if !validatePseudoUser(w, chi.URLParam(r, "userId"), SessionFromContext(r.Context())) {
return
}
h.handleFavoriteMutation(w, r, false)
}
// HandleMarkPlayed serves POST /UserPlayedItems/{itemId}.
func (h *UserDataHandler) HandleMarkPlayed(w http.ResponseWriter, r *http.Request) {
h.handlePlayedMutation(w, r, true)
}
// HandleMarkUnplayed serves DELETE /UserPlayedItems/{itemId}.
func (h *UserDataHandler) HandleMarkUnplayed(w http.ResponseWriter, r *http.Request) {
h.handlePlayedMutation(w, r, false)
}
// HandleMarkPlayedLegacy serves POST /Users/{userId}/PlayedItems/{itemId}.
func (h *UserDataHandler) HandleMarkPlayedLegacy(w http.ResponseWriter, r *http.Request) {
if !validatePseudoUser(w, chi.URLParam(r, "userId"), SessionFromContext(r.Context())) {
return
}
h.handlePlayedMutation(w, r, true)
}
// HandleMarkUnplayedLegacy serves DELETE /Users/{userId}/PlayedItems/{itemId}.
func (h *UserDataHandler) HandleMarkUnplayedLegacy(w http.ResponseWriter, r *http.Request) {
if !validatePseudoUser(w, chi.URLParam(r, "userId"), SessionFromContext(r.Context())) {
return
}
h.handlePlayedMutation(w, r, false)
}
// HandleGetUserDataLegacy serves GET /Users/{userId}/Items/{itemId}/UserData.
func (h *UserDataHandler) HandleGetUserDataLegacy(w http.ResponseWriter, r *http.Request) {
if !validatePseudoUser(w, chi.URLParam(r, "userId"), SessionFromContext(r.Context())) {
return
}
// Rewrite "id" param to "itemId" for the shared handler.
h.HandleGetUserData(w, r)
}
// HandleUpdateUserDataLegacy serves POST /Users/{userId}/Items/{itemId}/UserData.
// Accepts the update and returns 204 — individual field updates are handled
// through the dedicated played/favorite endpoints instead.
func (h *UserDataHandler) HandleUpdateUserDataLegacy(w http.ResponseWriter, r *http.Request) {
if !validatePseudoUser(w, chi.URLParam(r, "userId"), SessionFromContext(r.Context())) {
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *UserDataHandler) handleFavoriteMutation(w http.ResponseWriter, r *http.Request, favorite bool) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
contentID, err := decodeContentID(h.codec, chi.URLParam(r, "itemId"))
if err != nil {
writeError(w, http.StatusNotFound, "NotFound", "Item not found")
return
}
if favorite {
err = h.userData.AddFavorite(r.Context(), session, contentID)
} else {
err = h.userData.RemoveFavorite(r.Context(), session, contentID)
}
if err != nil {
writeCompatUpstreamError(w, err)
return
}
detail, err := h.content.GetItemDetail(r.Context(), session, contentID, nil)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
favMap, progressMap, err := resolveUserStateForContentIDs(
r.Context(), session, h.userData, []string{detail.ContentID},
)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
isFavorite := favMap[detail.ContentID]
progress := progressMap[detail.ContentID]
writeJSON(w, http.StatusOK, h.mapper.itemFromDetail(*detail, isFavorite, progress).UserData)
}
func (h *UserDataHandler) handlePlayedMutation(w http.ResponseWriter, r *http.Request, played bool) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
targets, err := h.resolvePlayedTargets(r, session, chi.URLParam(r, "itemId"))
if err != nil {
writeCompatUpstreamError(w, err)
return
}
if played {
err = h.userData.MarkPlayedBatch(r.Context(), session, targets)
} else {
err = h.userData.MarkUnplayedBatch(r.Context(), session, targets)
}
if err != nil {
writeCompatUpstreamError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *UserDataHandler) resolvePlayedTargets(r *http.Request, session *Session, rawItemID string) ([]string, error) {
if contentID, err := decodeItemID(h.codec, rawItemID); err == nil {
return h.resolvePlayedTargetsForItem(r, session, contentID)
}
contentID, err := h.codec.DecodeStringID(EncodedIDSeason, rawItemID)
if err != nil {
return nil, &HTTPError{StatusCode: http.StatusNotFound, Message: "Item not found"}
}
return h.resolvePlayedTargetsForSeason(r, session, contentID)
}
func (h *UserDataHandler) resolvePlayedTargetsForItem(r *http.Request, session *Session, contentID string) ([]string, error) {
detail, err := h.content.GetItemDetail(r.Context(), session, contentID, nil)
if err != nil {
return nil, err
}
switch strings.ToLower(detail.Type) {
case "movie", "episode":
return []string{detail.ContentID}, nil
case "season":
return h.resolvePlayedTargetsForSeason(r, session, detail.ContentID)
case "series":
seasons, err := h.content.ListSeasons(r.Context(), session, detail.ContentID, nil)
if err != nil {
return nil, err
}
targets := make([]string, 0)
seen := make(map[string]struct{})
for _, season := range seasons {
episodes, err := h.content.ListEpisodes(r.Context(), session, detail.ContentID, season.SeasonNumber, nil)
if err != nil {
return nil, err
}
targets = appendUniqueContentIDs(targets, seen, episodeContentIDs(episodes)...)
}
return targets, nil
default:
return nil, &HTTPError{StatusCode: http.StatusNotFound, Message: "Item not found"}
}
}
func (h *UserDataHandler) resolvePlayedTargetsForSeason(r *http.Request, session *Session, contentID string) ([]string, error) {
episodes, err := h.content.ListEpisodesBySeasonID(r.Context(), session, contentID, nil)
if err != nil {
return nil, err
}
return episodeContentIDs(episodes), nil
}
func episodeContentIDs(episodes []upstreamEpisode) []string {
targets := make([]string, 0, len(episodes))
seen := make(map[string]struct{}, len(episodes))
for _, episode := range episodes {
targets = appendUniqueContentIDs(targets, seen, episode.ContentID)
}
return targets
}
func appendUniqueContentIDs(targets []string, seen map[string]struct{}, contentIDs ...string) []string {
for _, contentID := range contentIDs {
if contentID == "" {
continue
}
if _, ok := seen[contentID]; ok {
continue
}
seen[contentID] = struct{}{}
targets = append(targets, contentID)
}
return targets
}