274 lines
8.6 KiB
Go
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
|
|
}
|