navidrome/server/jellyfin/playlists.go
Deluan Quintão b293b96256
refactor(persistence): stateless repositories with per-call context (#6149)
* refactor(persistence): adopt generic deluan/rest repository API

Pin deluan/rest to the refactor branch. REST-facing repository methods
take a context and return typed values. Drop DataStore.Resource and
ResourceRepository; the native API names typed repositories directly
through a per-request adapter that later commits remove.

* refactor(persistence): base repository helpers take a context

* refactor(persistence): LibraryRepository takes a context per call

* refactor(persistence): PropertyRepository takes a context per call

* refactor(persistence): UserPropsRepository takes a context per call

* refactor(persistence): TranscodingRepository takes a context per call

* refactor(persistence): ShareRepository takes a context per call

* refactor(persistence): PlayerRepository takes a context per call

* refactor(persistence): RadioRepository takes a context per call

* refactor(persistence): PlayQueueRepository takes a context per call

* refactor(persistence): Tag and Genre repositories take a context per call

* refactor(persistence): PluginRepository takes a context per call

* refactor(persistence): Scrobble repositories take a context per call

* refactor(persistence): FolderRepository takes a context per call

* refactor(persistence): Artwork repositories take a context per call

* refactor(persistence): UserRepository takes a context per call

* refactor(persistence): ArtistRepository takes a context per call

ReadAll no longer rewrites the shared sort mappings for the role filter;
it works on a per-call copy.

* test(persistence): assert artist role sort sanitization in ReadAll

* refactor(persistence): AlbumRepository takes a context per call

* test(persistence): pass the test context to album repository helpers

* refactor(persistence): MediaFileRepository takes a context per call

* refactor(persistence): Playlist repositories take a context per call

* refactor(persistence): build all repositories once per store

* refactor(core): REST repository wrappers are built once

* refactor(persistence): repositories are stateless

Remove the context field from the base repository and the per-request
REST adapter. Enable the containedctx linter so no repository can hold a
request context again.

* chore(lint): skip containedctx in test files

* refactor: share simplifications from the stateless repositories sweep

Add deleteOwnedAll on sqlRepository and use it in player/share Delete
to remove the duplicated bulk-delete loop; have Share.Repository()
return model.ShareRepository so subsonic sharing.go drops its repeated
type assertions.

* chore(core): assert REST wrappers implement Persistable

* chore: reformat imports

* perf(persistence): build repositories on first use

Each transaction store used to construct all 21 repositories up front,
paying for filter and sort mapping setup the block never touched. Fields
are now sync.OnceValue thunks, so a store only builds what it uses.

* fix(persistence): clean plugin references per deleted user

A bulk user delete that fails on a later id had already removed the
earlier rows but skipped their plugin cleanup. Cleanup now runs right
after each successful delete.

* fix(core): unload disabled plugins even when a user delete fails

A bulk delete can fail on a later id after earlier users were removed
and their plugins auto-disabled. The wrapper returned before unloading,
leaving those plugins running until the next successful delete or a
restart.

* chore(deps): pin deluan/rest to v1.0.1

Replaces the pseudo-version of the refactor branch with the tagged
release. REST error messages now name the bare type (Artist, not
model.Artist).

* test: use the spec context instead of context.Background()

Replace the context.Background()/context.TODO() calls this branch added
to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so
repository calls are bound to the running spec's lifetime.

* test: declare the spec context once per Describe

Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls.
2026-09-25 18:06:10 -04:00

403 lines
14 KiB
Go

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"
)
// playlistsFolder is the item returned for a ManualPlaylistsFolder query. CollectionType must be
// "playlists" — how the client identifies it; without it Jellify's playlist-library query loops.
func playlistsFolder() dto.BaseItemDto {
return dto.BaseItemDto{
Id: dto.PlaylistsFolderGUID,
Name: "Playlists",
Type: "ManualPlaylistsFolder",
CollectionType: "playlists",
IsFolder: true,
}
}
// playlistError maps core/playlists write errors to HTTP status: ownership or locked -> 403,
// missing/invisible -> 404 (never revealing another user's private playlist), else -> 500.
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"`
}
// createPlaylist always creates a new playlist (playlistId "" tells core/playlists.Create not to
// replace an existing one), owned by the authenticated user.
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
}
// Create takes no visibility, so a requested one costs a second write.
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)})
}
// updatePlaylistRequest mirrors Jellyfin's NewPlaylist body. Pointers so an absent field means
// "leave unchanged", distinguishing an omitted Ids (no change) from an explicit empty list (clear).
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
}
// A present Ids replaces the track list. An empty list must clear it explicitly, since Create
// can't persist an empty track list (the repository skips track writes when the list is empty).
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)
}
// clearPlaylist removes every track from a playlist. RemoveTracks enforces ownership.
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)
}
// playlistTrackPage streams one page of a playlist's tracks. Streams because a playlist can be the
// whole library (a smart playlist matching everything) and clients may omit Limit. Excludes missing
// tracks, and counts the same set, like GetWithTracks.
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
}
// trackToBaseItem maps a playlist entry to a BaseItemDto, tagging it with PlaylistItemId (the
// entry's id, model.PlaylistTrack.ID, not the song id). Clients echo it back via
// DELETE .../Items?EntryIds= to remove a specific occurrence, so duplicates of the same song remain
// individually removable.
func trackToBaseItem(t model.PlaylistTrack, fields dto.Fields) dto.BaseItemDto {
item := dto.SongToBaseItem(t.MediaFile, fields)
item.PlaylistItemId = dto.EncodePlaylistEntryID(t.ID)
return item
}
// getPlaylist returns a playlist's visibility flag and item ids (Finamp reads OpenAccess before the
// edit screen). Get and Tracks enforce visibility; any error maps to 404 so private playlists can't
// be probed.
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
}
// PlaylistInfo carries every track id, so this can't be paged — but it needs no track data.
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,
})
}
// getPlaylistItems relies on Tracks to enforce visibility; any error maps to a generic 404 so a
// playlist id can't probe for private playlists.
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)
}
// queryIDs reads an id-list query param that clients spell two ways: comma-separated in a single
// param (Finamp: ids=X,Y) or as repeated params (Jellify's @jellyfin/sdk: ids=X&ids=Y). It returns
// the flattened, non-empty ids across both forms.
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
}
// expandContainerIDs expands the container ids (albums, artists, playlists) a client sends when
// building a playlist into their track ids, in order, since core/playlists only understands media
// file ids. Unknown ids pass through unchanged. Songs are classified with one batched query; only
// the rest pays per-id container probes.
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) // already a song
} 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) // well-formed but unresolved — left for the caller's write to handle
}
}
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 })
}
// addToPlaylist adds items by id (containers expand to tracks), inserting at the zero-based position
// when given. Core enforces ownership: a locked playlist maps to 403, any other error to 404.
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)
}
// insertPosition maps Jellyfin's zero-based position to a 1-based one, clamped in int64 first so
// it can't wrap on 32-bit builds.
func insertPosition(position int64) int {
return int(min(max(position, 0), math.MaxInt32-1) + 1)
}
// removeFromPlaylist removes entries by entryIds — playlist-entry ids (PlaylistItemId), not media
// file ids, since RemoveTracks deletes playlist_tracks rows by that id. RemoveTracks enforces
// ownership; a locked playlist maps to 403, any other error to 404.
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)
}
// movePlaylistItem moves an entry to Jellyfin's zero-based newIndex. Reorder clamps past-the-end
// indexes and rejects unknown entries, which Jellyfin treats as a no-op.
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
}
// Resolve first, so a missing or hidden playlist is still a 404 below the unknown-entry no-op.
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)
}
// Clients probe these before offering edits. Navidrome has no per-playlist ACL, so CanEdit carries
// only editability; ownership is enforced on write, and a lookup error 404s to prevent probing.
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()})
}