mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-29 14:37:49 +01:00
Finamp edits a playlist (make public, rename, reorder) via POST
/Playlists/{id}, which we didn't route, so every edit 404'd. Implement it:
Ids present -> replace track list (Create with existing id, preserving
name); otherwise update Name/IsPublic via core/playlists.Update. Adds a
shared playlistError helper (403/404/500) reused by deleteItem, plus e2e
coverage.
156 lines
6.5 KiB
Go
156 lines
6.5 KiB
Go
package jellyfin
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"sync"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/navidrome/navidrome/core"
|
|
"github.com/navidrome/navidrome/core/artwork"
|
|
"github.com/navidrome/navidrome/core/playlists"
|
|
"github.com/navidrome/navidrome/core/scrobbler"
|
|
"github.com/navidrome/navidrome/core/stream"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/server"
|
|
)
|
|
|
|
type Router struct {
|
|
http.Handler
|
|
ds model.DataStore
|
|
artwork artwork.Artwork
|
|
streamer stream.MediaStreamer
|
|
transcodeDecider stream.TranscodeDecider
|
|
players core.Players
|
|
scrobbler scrobbler.PlayTracker
|
|
playlists playlists.Playlists
|
|
serverIDOnce sync.Once
|
|
serverIDVal string
|
|
}
|
|
|
|
func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer,
|
|
transcodeDecider stream.TranscodeDecider, players core.Players,
|
|
scrobbler scrobbler.PlayTracker, playlists playlists.Playlists) *Router {
|
|
r := &Router{
|
|
ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider,
|
|
players: players, scrobbler: scrobbler, playlists: playlists,
|
|
}
|
|
r.Handler = r.routes()
|
|
return r
|
|
}
|
|
|
|
func (api *Router) routes() http.Handler {
|
|
inner := chi.NewRouter()
|
|
|
|
// Public (no auth): handshake + login.
|
|
inner.Get("/System/Info/Public", api.getPublicSystemInfo)
|
|
inner.Get("/System/Ping", api.ping)
|
|
inner.Post("/System/Ping", api.ping)
|
|
inner.Get("/QuickConnect/Enabled", api.quickConnectEnabled)
|
|
inner.Post("/Users/AuthenticateByName", api.authenticateByName)
|
|
inner.Get("/Users/Public", api.getPublicUsers)
|
|
|
|
// Images are intentionally fully public and do not require authentication: artwork isn't
|
|
// sensitive media content, and this matches Jellyfin's lenient image handling.
|
|
inner.Get("/Items/{itemId}/Images/{type}", api.getItemImage)
|
|
inner.Get("/Items/{itemId}/Images/{type}/{index}", api.getItemImage)
|
|
|
|
inner.Group(func(r chi.Router) {
|
|
r.Use(api.authenticate)
|
|
r.Get("/UserViews", api.getUserViews)
|
|
r.Get("/Users/{userId}/Views", api.getUserViews)
|
|
r.Get("/Users/Me", api.getCurrentUser)
|
|
r.Get("/Users/{userId}", api.getCurrentUser)
|
|
|
|
r.Get("/Items", api.getItems)
|
|
r.Get("/Users/{userId}/Items", api.getItems)
|
|
r.Get("/Items/{itemId}", api.getItem)
|
|
r.Get("/Users/{userId}/Items/{itemId}", api.getItem)
|
|
r.Delete("/Items/{itemId}", api.deleteItem)
|
|
r.Get("/Users/{userId}/Items/Latest", api.getLatest)
|
|
|
|
r.Post("/Users/{userId}/FavoriteItems/{itemId}", api.markFavorite)
|
|
r.Delete("/Users/{userId}/FavoriteItems/{itemId}", api.unmarkFavorite)
|
|
r.Post("/Users/{userId}/Items/{itemId}/Rating", api.setRating)
|
|
r.Delete("/Users/{userId}/Items/{itemId}/Rating", api.removeRating)
|
|
|
|
r.Get("/Artists", api.getArtists)
|
|
r.Get("/Artists/AlbumArtists", api.getArtists)
|
|
r.Get("/Genres", api.getGenres)
|
|
r.Get("/MusicGenres", api.getGenres)
|
|
|
|
r.Post("/Playlists", api.createPlaylist)
|
|
r.Get("/Playlists/{playlistId}", api.getPlaylist)
|
|
r.Post("/Playlists/{playlistId}", api.updatePlaylist)
|
|
r.Get("/Playlists/{playlistId}/Items", api.getPlaylistItems)
|
|
r.Post("/Playlists/{playlistId}/Items", api.addToPlaylist)
|
|
r.Delete("/Playlists/{playlistId}/Items", api.removeFromPlaylist)
|
|
r.Get("/Playlists/{playlistId}/Users", api.getPlaylistUsers)
|
|
r.Get("/Playlists/{playlistId}/Users/{userId}", api.getPlaylistUser)
|
|
|
|
// Cover upload/delete: only playlists are writable through this API (see
|
|
// postItemImage); the GET routes above stay public and unauthenticated.
|
|
r.Post("/Items/{itemId}/Images/{type}", api.postItemImage)
|
|
r.Delete("/Items/{itemId}/Images/{type}", api.deleteItemImage)
|
|
|
|
r.Get("/Audio/{itemId}/stream", api.streamAudio)
|
|
r.Get("/Audio/{itemId}/stream.{container}", api.streamAudio)
|
|
r.Get("/Audio/{itemId}/universal", api.streamAudio)
|
|
r.Get("/Items/{itemId}/PlaybackInfo", api.getPlaybackInfo)
|
|
r.Post("/Items/{itemId}/PlaybackInfo", api.getPlaybackInfo)
|
|
// Direct-file endpoints: some clients (e.g. Finamp's just_audio) fetch playback audio
|
|
// here instead of /Audio/{id}/stream after PlaybackInfo; /Download reuses the same
|
|
// direct-play handler since Jellyfin serves the same original file for both.
|
|
r.Get("/Items/{itemId}/File", api.streamFile)
|
|
r.Get("/Items/{itemId}/Download", api.streamFile)
|
|
|
|
// Playback reports carry only the caller's own play data (see reportPlaybackStart
|
|
// doc comment), so no library-access gate is needed here.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(api.withPlayer)
|
|
r.Post("/Sessions/Playing", api.reportPlaybackStart)
|
|
r.Post("/Sessions/Playing/Progress", api.reportPlaybackProgress)
|
|
r.Post("/Sessions/Playing/Stopped", api.reportPlaybackStopped)
|
|
})
|
|
r.Post("/Sessions/Capabilities", api.postCapabilities)
|
|
r.Post("/Sessions/Capabilities/Full", api.postCapabilities)
|
|
|
|
// Real-time clients (e.g. Finamp) open this right after login; without it they
|
|
// 404-loop-reconnect instead of settling into a working session.
|
|
r.Get("/socket", api.handleSocket)
|
|
})
|
|
|
|
// Logged at Debug (not Warn/Error) because a real client probing for optional/legacy
|
|
// endpoints is expected traffic, not a problem; this just surfaces what's missing.
|
|
inner.NotFound(api.notFound)
|
|
inner.MethodNotAllowed(api.notFound)
|
|
|
|
// Real Jellyfin clients route case-insensitively; chi does not.
|
|
return server.CaseInsensitivePaths(inner)
|
|
}
|
|
|
|
// ok writes payload as JSON.
|
|
func (api *Router) ok(w http.ResponseWriter, r *http.Request, payload any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
|
log.Error(r.Context(), "Jellyfin API: error encoding response", err)
|
|
}
|
|
}
|
|
|
|
// notFound handles both unmatched routes and unsupported methods on known routes, so any
|
|
// endpoint a real client needs that we haven't implemented shows up in the logs instead of
|
|
// silently confusing the client with chi's default plain-text 404/405.
|
|
func (api *Router) notFound(w http.ResponseWriter, r *http.Request) {
|
|
log.Debug(r.Context(), "Jellyfin API: unhandled route", "method", r.Method, "path", r.URL.Path)
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{}`))
|
|
}
|
|
|
|
// internalError logs the real error server-side and writes a generic 500 response, so ffmpeg
|
|
// output, file paths or other internal detail in err never reaches the client.
|
|
func (api *Router) internalError(w http.ResponseWriter, r *http.Request, err error) {
|
|
log.Error(r.Context(), "Jellyfin API: internal error", "method", r.Method, "path", r.URL.Path, err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
}
|