package jellyfin
import (
"context"
"encoding/json"
"errors"
"math"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/filter"
"github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/slice"
)
func playlistsFolder() dto.BaseItemDto {
return dto.BaseItemDto{
Id: dto.PlaylistsFolderGUID,
Name: "Playlists",
Type: "ManualPlaylistsFolder",
CollectionType: "playlists",
IsFolder: true,
}
}
func (api *Router) playlistError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, model.ErrNotAuthorized), errors.Is(err, model.ErrPlaylistNotEditable):
http.Error(w, "Forbidden", http.StatusForbidden)
case errors.Is(err, model.ErrNotFound):
http.Error(w, "Not Found", http.StatusNotFound)
default:
api.internalError(w, r, err)
}
}
type createPlaylistRequest struct {
Name string `json:"Name"`
Ids []string `json:"Ids"`
MediaType string `json:"MediaType"`
IsPublic *bool `json:"IsPublic"`
}
func (api *Router) createPlaylist(w http.ResponseWriter, r *http.Request) {
var body createPlaylistRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
decoded, ok := dto.DecodeIDs(body.Ids)
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
ids := api.expandContainerIDs(r.Context(), decoded)
id, err := api.playlists.Create(r.Context(), "", body.Name, ids)
if err != nil {
api.internalError(w, r, err)
return
}
if body.IsPublic != nil {
if err := api.playlists.Update(r.Context(), id, nil, nil, body.IsPublic, nil, nil); err != nil {
api.playlistError(w, r, err)
return
}
}
api.ok(w, r, map[string]string{"Id": dto.EncodeID(id)})
}
type updatePlaylistRequest struct {
Name *string `json:"Name"`
Ids *[]string `json:"Ids"`
IsPublic *bool `json:"IsPublic"`
}
func (api *Router) updatePlaylist(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := itemIDParam(w, r, "playlistId")
if !ok {
return
}
var body updatePlaylistRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
if body.Ids != nil {
if len(*body.Ids) == 0 {
if err := api.clearPlaylist(ctx, id); err != nil {
api.playlistError(w, r, err)
return
}
} else {
decoded, ok := dto.DecodeIDs(*body.Ids)
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
ids := api.expandContainerIDs(ctx, decoded)
if _, err := api.playlists.Create(ctx, id, "", ids); err != nil {
api.playlistError(w, r, err)
return
}
}
}
if body.Ids == nil || body.Name != nil || body.IsPublic != nil {
if err := api.playlists.Update(ctx, id, body.Name, nil, body.IsPublic, nil, nil); err != nil {
api.playlistError(w, r, err)
return
}
}
w.WriteHeader(http.StatusNoContent)
}
func (api *Router) clearPlaylist(ctx context.Context, id string) error {
pls, err := api.playlists.GetWithTracks(ctx, id)
if err != nil {
return err
}
if len(pls.Tracks) == 0 {
return nil
}
entryIDs := slice.Map(pls.Tracks, func(t model.PlaylistTrack) string { return t.ID })
return api.playlists.RemoveTracks(ctx, id, entryIDs)
}
func (api *Router) playlistTrackPage(ctx context.Context, repo model.PlaylistTrackRepository, fields dto.Fields, offset, limit int) (itemsResult, error) {
total, err := repo.CountAll(ctx, model.QueryOptions{Filters: notMissing})
if err != nil {
return itemsResult{}, err
}
opts := model.QueryOptions{Sort: "id", Offset: offset, Max: limit, Filters: notMissing}
open := streamCursor(func() (func(func(model.PlaylistTrack, error) bool), error) {
return repo.GetCursor(ctx, opts)
}, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, fields) })
return streamed(open, int(total), offset), nil
}
func trackToBaseItem(t model.PlaylistTrack, fields dto.Fields) dto.BaseItemDto {
item := dto.SongToBaseItem(t.MediaFile, fields)
item.PlaylistItemId = dto.EncodePlaylistEntryID(t.ID)
return item
}
func (api *Router) getPlaylist(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := itemIDParam(w, r, "playlistId")
if !ok {
return
}
pls, err := api.playlists.Get(ctx, id)
if err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
repo, err := api.playlists.Tracks(ctx, id)
if err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
trackIDs, err := repo.GetMediaFileIDs(ctx, model.QueryOptions{Sort: "id", Filters: notMissing})
if err != nil {
api.internalError(w, r, err)
return
}
itemIds := slice.Map(trackIDs, dto.EncodeID)
api.ok(w, r, dto.PlaylistInfo{
OpenAccess: pls.Public,
Shares: []dto.PlaylistUserPermissions{},
ItemIds: itemIds,
})
}
func (api *Router) getPlaylistItems(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := itemIDParam(w, r, "playlistId")
if !ok {
return
}
repo, err := api.playlists.Tracks(ctx, id)
if err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
p := req.Params(r)
fields := dto.ParseFields(p.Strings("fields")...)
res, err := api.playlistTrackPage(ctx, repo, fields, p.IntOr("startindex", 0), p.IntOr("limit", 0))
if err != nil {
api.internalError(w, r, err)
return
}
api.ok(w, r, res)
}
func queryIDs(r *http.Request, key string) []string {
var ids []string
for _, v := range r.URL.Query()[key] {
for id := range strings.SplitSeq(v, ",") {
if id != "" {
ids = append(ids, id)
}
}
}
return ids
}
func (api *Router) expandContainerIDs(ctx context.Context, ids []string) []string {
songs := api.songsByIDs(ctx, ids)
out := make([]string, 0, len(ids))
for _, id := range ids {
if _, ok := songs[id]; ok {
out = append(out, id)
} else if _, err := api.ds.Album().Get(ctx, id); err == nil {
out = append(out, api.songIDs(ctx, filter.SongsByAlbum(id))...)
} else if _, err := api.ds.Artist().Get(ctx, id); err == nil {
out = append(out, api.songIDs(ctx, filter.SongsByArtistID(id))...)
} else if pl, err := api.playlists.GetWithTracks(ctx, id); err == nil {
out = append(out, slice.Map(pl.Tracks, func(t model.PlaylistTrack) string { return t.MediaFileID })...)
} else {
out = append(out, id)
}
}
return out
}
func (api *Router) songIDs(ctx context.Context, opts model.QueryOptions) []string {
mfs, err := api.ds.MediaFile().GetAll(ctx, opts)
if err != nil {
log.Error(ctx, "Jellyfin: error expanding container to tracks", err)
return nil
}
return slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID })
}
func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := itemIDParam(w, r, "playlistId")
if !ok {
return
}
decoded, ok := dto.DecodeIDs(queryIDs(r, "ids"))
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
ids := api.expandContainerIDs(ctx, decoded)
var err error
if position, perr := req.Params(r).Int64("position"); perr == nil {
_, err = api.playlists.InsertTracks(ctx, id, ids, insertPosition(position))
} else {
_, err = api.playlists.AddTracks(ctx, id, ids)
}
if err != nil {
if errors.Is(err, model.ErrPlaylistNotEditable) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
http.Error(w, "Not Found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}
func insertPosition(position int64) int {
return int(min(max(position, 0), math.MaxInt32-1) + 1)
}
func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := itemIDParam(w, r, "playlistId")
if !ok {
return
}
raw := queryIDs(r, "entryids")
ids := make([]string, 0, len(raw))
for _, entryGUID := range raw {
entry, ok := dto.DecodePlaylistEntryID(entryGUID)
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
ids = append(ids, entry)
}
if err := api.playlists.RemoveTracks(ctx, id, ids); err != nil {
if errors.Is(err, model.ErrPlaylistNotEditable) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
http.Error(w, "Not Found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (api *Router) movePlaylistItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := itemIDParam(w, r, "playlistId")
if !ok {
return
}
entry, ok := dto.DecodePlaylistEntryID(chi.URLParam(r, "entryId"))
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
newIndex, err := strconv.Atoi(chi.URLParam(r, "newIndex"))
if err != nil || newIndex < 0 {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
if _, err := api.playlists.Get(ctx, id); err != nil {
api.playlistError(w, r, err)
return
}
pos, _ := strconv.Atoi(entry)
err = api.playlists.ReorderTrack(ctx, id, pos, min(newIndex, math.MaxInt32-1)+1)
if err != nil && !errors.Is(err, model.ErrNotFound) {
api.playlistError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (api *Router) getPlaylistUsers(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := itemIDParam(w, r, "playlistId")
if !ok {
return
}
pls, err := api.playlists.Get(ctx, id)
if err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
u, _ := request.UserFrom(ctx)
api.ok(w, r, []dto.PlaylistUserPermissions{{UserId: dto.EncodeID(u.ID), CanEdit: pls.TracksEditable()}})
}
func (api *Router) getPlaylistUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := itemIDParam(w, r, "playlistId")
if !ok {
return
}
pls, err := api.playlists.Get(ctx, id)
if err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
userId := chi.URLParam(r, "userId")
api.ok(w, r, dto.PlaylistUserPermissions{UserId: userId, CanEdit: pls.TracksEditable()})
}