Waves 9-10: Jellyfin and Subsonic clients can browse and play
Vellum now speaks two foreign protocols, so the clients people already own can
use it. Both are translation layers over queries that already exist, and both go
through the same grant chokepoint as the web app — a token is a second way to be
a user, never a second set of permissions.
Shared foundation (coordinator, written before dispatch)
- migrations/0012_api_tokens.sql + internal/apitoken. Both protocols need a
credential that is not the session cookie, and letting two agents each invent
a token table would have produced two incompatible ones.
- The table stores two shapes for a real reason: a Jellyfin token is a bearer
value so only its hash is kept, while a Subsonic key must be recoverable
because verifying `t` means computing md5(key + salt) server-side. token_plain
is therefore the one column in the schema holding a usable credential. It is a
generated random secret, never a user password, and it is revocable.
Wave 9 - Jellyfin
- /System/Info/Public, /Users/AuthenticateByName, /Users/Me, /Users/{id},
/Items, /Users/{id}/Items, /Items/{id}, /Items/{id}/Images/Primary,
/Items/{id}/PlaybackInfo, /Videos/{id}/stream, /Audio/{id}/stream,
/Sessions/Playing{,/Progress,/Stopped}.
- Auth accepts X-Emby-Token, X-MediaBrowser-Token, ?api_key=, and
Authorization: MediaBrowser Token="...". The ?api_key= form is the one that
matters: <video> elements cannot set headers.
- Honest about the one thing Vellum cannot do. PlaybackInfo reports
SupportsDirectPlay/SupportsDirectStream true and SupportsTranscoding FALSE,
with a DirectStreamUrl carrying the api_key. A client that disables both
direct modes is demanding a transcode and is refused with ErrorCode
"NoCompatibleStream" — rather than handed a TranscodingUrl that would never
resolve, which is how a client hangs forever.
Wave 10 - Subsonic
- /rest/{method} dispatch with ping, getLicense, getMusicFolders, getIndexes,
getArtists/getArtist/getAlbum, getMusicDirectory, getSong, search3, stream,
getCoverArt, getAlbumList2, getRandomSongs, scrobble, getUser, getPlaylists,
plus GET /api/v1/subsonic/credentials to hand a user their key.
- Auth via t=md5(key+salt), p=key, constant-time comparison. A bad credential
returns HTTP 200 with the envelope status "failed" and error code 40 — NOT an
HTTP 401. Subsonic clients read the envelope, and getting this wrong is the
single most common integration failure in that protocol.
- stream supports format=raw and never invokes ffmpeg.
Coordinator wired both into main.go (the line agents were told to leave alone)
and verified against real requests.
Verified end to end, 18 packages pass, gofmt/vet clean, svelte-check 0/0:
- Jellyfin: discovery, AuthenticateByName minting a token, /Items reporting 18,
PlaybackInfo showing SupportsTranscoding=false, and a Range request to
/Videos/{id}/stream using ONLY ?api_key= returning 206 with Content-Range.
- Subsonic: ping as JSON and XML, md5(key+salt) auth succeeding, and a bad token
returning the failed envelope with code 40.
- Scoping, both protocols, with a genuinely non-admin user: 0 items, 404 on an
admin-only item, 404 on its stream, 0 music folders; admin still sees 18.
- The SPA is not shadowed by the root-mounted compat paths: /, /healthz and
/api/v1/version all still answer correctly.
Both agents failed with an HTTP 400 at the REPORT stage, not during the work;
their files were complete and compile, so the work was salvaged and verified
here rather than redone.
This commit is contained in:
parent
67f145a9b1
commit
6da194045b
21 changed files with 4371 additions and 0 deletions
|
|
@ -18,6 +18,7 @@ import (
|
||||||
"github.com/mithral/vellum/internal/cli"
|
"github.com/mithral/vellum/internal/cli"
|
||||||
"github.com/mithral/vellum/internal/core"
|
"github.com/mithral/vellum/internal/core"
|
||||||
"github.com/mithral/vellum/internal/httpapi"
|
"github.com/mithral/vellum/internal/httpapi"
|
||||||
|
"github.com/mithral/vellum/internal/jellyfin"
|
||||||
"github.com/mithral/vellum/internal/jobs"
|
"github.com/mithral/vellum/internal/jobs"
|
||||||
"github.com/mithral/vellum/internal/library"
|
"github.com/mithral/vellum/internal/library"
|
||||||
"github.com/mithral/vellum/internal/media"
|
"github.com/mithral/vellum/internal/media"
|
||||||
|
|
@ -28,6 +29,7 @@ import (
|
||||||
_ "github.com/mithral/vellum/internal/modules/photos" // registers the thumbnail extractor
|
_ "github.com/mithral/vellum/internal/modules/photos" // registers the thumbnail extractor
|
||||||
"github.com/mithral/vellum/internal/playback"
|
"github.com/mithral/vellum/internal/playback"
|
||||||
"github.com/mithral/vellum/internal/search"
|
"github.com/mithral/vellum/internal/search"
|
||||||
|
"github.com/mithral/vellum/internal/subsonic"
|
||||||
"github.com/mithral/vellum/migrations"
|
"github.com/mithral/vellum/migrations"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -110,6 +112,15 @@ func run(cfg core.Config, log *slog.Logger) error {
|
||||||
// CLIENT declares — a browser offers "html5", a native client can offer mpv.
|
// CLIENT declares — a browser offers "html5", a native client can offer mpv.
|
||||||
playback.NewHandlers(db, log, authenticator).Register(api.Mux())
|
playback.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||||
|
|
||||||
|
// Client compatibility layers. Neither speaks the session cookie; both
|
||||||
|
// authenticate with an api_tokens credential and then go through the same
|
||||||
|
// grant chokepoint as the web app. They mount at the root because that is
|
||||||
|
// where the real clients look, and both report SupportsTranscoding=false:
|
||||||
|
// this server has no CPU for it, so a client that demands a transcode is
|
||||||
|
// refused rather than left waiting for one that will never start.
|
||||||
|
jellyfin.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||||
|
subsonic.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||||
|
|
||||||
// Scans run on the queue rather than inside the request: a real library
|
// Scans run on the queue rather than inside the request: a real library
|
||||||
// takes longer than any sensible timeout, and a hanging request is
|
// takes longer than any sensible timeout, and a hanging request is
|
||||||
// indistinguishable from a broken server. The result is written back so
|
// indistinguishable from a broken server. The result is written back so
|
||||||
|
|
|
||||||
172
internal/apitoken/apitoken.go
Normal file
172
internal/apitoken/apitoken.go
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
// Package apitoken issues and verifies the tokens non-browser clients use.
|
||||||
|
//
|
||||||
|
// Jellyfin clients send `X-Emby-Token` (or `api_key=`); Subsonic clients send
|
||||||
|
// `t=md5(key+salt)`. Neither speaks the web app's session cookie, so these
|
||||||
|
// tokens are a second, revocable way to be a user. They do not weaken the cookie
|
||||||
|
// model: a token resolves to the same auth.User and every caller still goes
|
||||||
|
// through the same grant chokepoint.
|
||||||
|
//
|
||||||
|
// Two storage shapes, deliberately:
|
||||||
|
//
|
||||||
|
// - A Jellyfin token is a bearer value, so only its hash is stored — the same
|
||||||
|
// reasoning as session tokens. A stolen database yields nothing usable.
|
||||||
|
// - A Subsonic key must be recoverable, because verifying `t` means computing
|
||||||
|
// md5(key + salt) server-side. So Subsonic keys are stored in the clear.
|
||||||
|
// That is the Navidrome approach, and it is why the key is a generated
|
||||||
|
// random secret rather than anything derived from the user's password.
|
||||||
|
package apitoken
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/mithral/vellum/internal/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kinds of token. A client family, not a permission.
|
||||||
|
const (
|
||||||
|
KindJellyfin = "jellyfin"
|
||||||
|
KindSubsonic = "subsonic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotFound means no token matched.
|
||||||
|
var ErrNotFound = errors.New("apitoken: not found")
|
||||||
|
|
||||||
|
// Token is one issued credential. It never carries the secret itself.
|
||||||
|
type Token struct {
|
||||||
|
ID int64
|
||||||
|
UserID int64
|
||||||
|
Kind string
|
||||||
|
Label string
|
||||||
|
CreatedAt string
|
||||||
|
LastUsedAt string
|
||||||
|
ExpiresAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint issues a new token for a user and returns the secret exactly once.
|
||||||
|
//
|
||||||
|
// The caller must show it to the user immediately; it is not recoverable for
|
||||||
|
// Jellyfin afterwards (only its hash is kept). For Subsonic it is recoverable,
|
||||||
|
// because that protocol requires it — see the package comment.
|
||||||
|
func Mint(ctx context.Context, db *sql.DB, userID int64, kind, label string) (string, error) {
|
||||||
|
if kind != KindJellyfin && kind != KindSubsonic {
|
||||||
|
return "", fmt.Errorf("apitoken: unknown kind %q", kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw [32]byte
|
||||||
|
if _, err := rand.Read(raw[:]); err != nil {
|
||||||
|
return "", fmt.Errorf("apitoken: read random: %w", err)
|
||||||
|
}
|
||||||
|
secret := hex.EncodeToString(raw[:])
|
||||||
|
|
||||||
|
plain := ""
|
||||||
|
if kind == KindSubsonic {
|
||||||
|
// Recoverable by necessity: verifying `t` needs the key itself.
|
||||||
|
plain = secret
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO api_tokens (user_id, kind, label, token_hash, token_plain, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
userID, kind, label, Hash(secret), plain, core.Now())
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("apitoken: insert: %w", err)
|
||||||
|
}
|
||||||
|
return secret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash is the lookup digest for a token. Exported so the Jellyfin layer can
|
||||||
|
// resolve a bearer value without duplicating the scheme.
|
||||||
|
func Hash(secret string) string {
|
||||||
|
sum := sha256.Sum256([]byte(secret))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve finds the user a token belongs to, and records the use.
|
||||||
|
//
|
||||||
|
// An empty or unknown token is ErrNotFound; the caller turns that into a 401.
|
||||||
|
// An expired token is also ErrNotFound, so callers cannot accidentally treat an
|
||||||
|
// expired credential as valid by checking only the error's absence.
|
||||||
|
func Resolve(ctx context.Context, db *sql.DB, secret string) (int64, error) {
|
||||||
|
if secret == "" {
|
||||||
|
return 0, ErrNotFound
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
id int64
|
||||||
|
userID int64
|
||||||
|
expires string
|
||||||
|
)
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, user_id, expires_at FROM api_tokens WHERE token_hash = ?`,
|
||||||
|
Hash(secret)).Scan(&id, &userID, &expires)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("apitoken: resolve: %w", err)
|
||||||
|
}
|
||||||
|
if expires != "" && expires < core.Now() {
|
||||||
|
return 0, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort: a failed touch must not fail the request.
|
||||||
|
_, _ = db.ExecContext(ctx, `UPDATE api_tokens SET last_used_at = ? WHERE id = ?`, core.Now(), id)
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain returns the recoverable secret for a Subsonic token. Jellyfin tokens
|
||||||
|
// have none by design, so this returns ErrNotFound for them.
|
||||||
|
func Plain(ctx context.Context, db *sql.DB, userID int64) (string, error) {
|
||||||
|
var plain string
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT token_plain FROM api_tokens
|
||||||
|
WHERE user_id = ? AND kind = ? AND token_plain != ''
|
||||||
|
ORDER BY id LIMIT 1`,
|
||||||
|
userID, KindSubsonic).Scan(&plain)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("apitoken: plain: %w", err)
|
||||||
|
}
|
||||||
|
return plain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForUser lists a user's tokens, newest first.
|
||||||
|
func ForUser(ctx context.Context, db *sql.DB, userID int64) ([]Token, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT id, user_id, kind, label, created_at, last_used_at, expires_at
|
||||||
|
FROM api_tokens WHERE user_id = ? ORDER BY id DESC`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("apitoken: list: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []Token{}
|
||||||
|
for rows.Next() {
|
||||||
|
var t Token
|
||||||
|
if err := rows.Scan(&t.ID, &t.UserID, &t.Kind, &t.Label,
|
||||||
|
&t.CreatedAt, &t.LastUsedAt, &t.ExpiresAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke deletes a token, scoped to its owner so one user cannot revoke
|
||||||
|
// another's. Reports whether anything was removed.
|
||||||
|
func Revoke(ctx context.Context, db *sql.DB, userID, tokenID int64) (bool, error) {
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`DELETE FROM api_tokens WHERE id = ? AND user_id = ?`, tokenID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("apitoken: revoke: %w", err)
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
return n > 0, err
|
||||||
|
}
|
||||||
89
internal/jellyfin/auth.go
Normal file
89
internal/jellyfin/auth.go
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
package jellyfin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mithral/vellum/internal/apitoken"
|
||||||
|
"github.com/mithral/vellum/internal/auth"
|
||||||
|
"github.com/mithral/vellum/internal/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// require resolves the caller to an auth.User from a Jellyfin credential and
|
||||||
|
// hands them to fn. It is the Jellyfin twin of auth.Authenticator.Require:
|
||||||
|
// the same shape, but token-based.
|
||||||
|
//
|
||||||
|
// On any failure it has already written a 401. Note the deliberate difference
|
||||||
|
// from the web layer: a Jellyfin token resolves to the same auth.User, and
|
||||||
|
// every handler below still filters through VisibleLibraryIDs /
|
||||||
|
// CanAccessLibrary, so a token can never see another user's libraries.
|
||||||
|
func (h *Handlers) require(fn func(http.ResponseWriter, *http.Request, auth.User)) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u, ok := h.currentUser(r)
|
||||||
|
if !ok {
|
||||||
|
httpx.WriteError(w, http.StatusUnauthorized, "authentication required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fn(w, r, u)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentUser resolves the request's Jellyfin credential through
|
||||||
|
// apitoken.Resolve and loads the owning user.
|
||||||
|
func (h *Handlers) currentUser(r *http.Request) (auth.User, bool) {
|
||||||
|
secret := tokenFromRequest(r)
|
||||||
|
if secret == "" {
|
||||||
|
return auth.User{}, false
|
||||||
|
}
|
||||||
|
userID, err := apitoken.Resolve(r.Context(), h.db, secret)
|
||||||
|
if err != nil {
|
||||||
|
// ErrNotFound and any storage error both mean "not authenticated".
|
||||||
|
return auth.User{}, false
|
||||||
|
}
|
||||||
|
u, err := auth.GetUserByID(r.Context(), h.db, userID)
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, false
|
||||||
|
}
|
||||||
|
return u, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenFromRequest extracts the credential from the three places Jellyfin
|
||||||
|
// clients put it, in order of reliability:
|
||||||
|
//
|
||||||
|
// - X-Emby-Token header (modern clients)
|
||||||
|
// - api_key query parameter (media elements cannot set headers)
|
||||||
|
// - Authorization: MediaBrowser Token="..." (older clients)
|
||||||
|
//
|
||||||
|
// X-MediaBrowser-Token is also accepted; some clients send it.
|
||||||
|
func tokenFromRequest(r *http.Request) string {
|
||||||
|
if v := strings.TrimSpace(r.Header.Get("X-Emby-Token")); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(r.Header.Get("X-MediaBrowser-Token")); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(r.URL.Query().Get("api_key")); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return mediaBrowserToken(r.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// mediaBrowserToken parses an Authorization header of the form
|
||||||
|
//
|
||||||
|
// MediaBrowser Token="abc", UserId="...", Client="...", Device="..."
|
||||||
|
//
|
||||||
|
// The token may be quoted or bare, and the parameter list may be comma- or
|
||||||
|
// space-separated in the wild.
|
||||||
|
func mediaBrowserToken(header string) string {
|
||||||
|
if header == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Normalise commas to spaces so both separators split the same way.
|
||||||
|
fields := strings.Fields(strings.ReplaceAll(header, ",", " "))
|
||||||
|
for _, f := range fields {
|
||||||
|
if v, ok := strings.CutPrefix(f, "Token="); ok {
|
||||||
|
return strings.Trim(v, `"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
291
internal/jellyfin/dto.go
Normal file
291
internal/jellyfin/dto.go
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
package jellyfin
|
||||||
|
|
||||||
|
import "database/sql"
|
||||||
|
|
||||||
|
// This file defines a documented SUBSET of the Jellyfin DTOs. Jellyfin's
|
||||||
|
// BaseItemDto has roughly a hundred fields; real clients tolerate the absence
|
||||||
|
// of most of them, and the ones below are what browse and playback actually
|
||||||
|
// consume. Every struct is PascalCase on the wire because that is the Jellyfin
|
||||||
|
// convention (the web app's own API is snake_case and is untouched).
|
||||||
|
|
||||||
|
// UserDto is the subset of the Jellyfin user shape clients read.
|
||||||
|
type UserDto struct {
|
||||||
|
Name string `json:"Name"`
|
||||||
|
ServerId string `json:"ServerId,omitempty"`
|
||||||
|
Id string `json:"Id"`
|
||||||
|
HasPassword bool `json:"HasPassword"`
|
||||||
|
HasConfiguredPassword bool `json:"HasConfiguredPassword"`
|
||||||
|
Policy *UserPolicy `json:"Policy,omitempty"`
|
||||||
|
Configuration *UserConfig `json:"Configuration,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserPolicy carries the two things a client needs from a user's policy: the
|
||||||
|
// admin flag and which library folders the account can see. EnabledFolders is
|
||||||
|
// the user's VisibleLibraryIDs rendered as Jellyfin ids, so a client can hide
|
||||||
|
// views it is not allowed to open.
|
||||||
|
type UserPolicy struct {
|
||||||
|
IsAdministrator bool `json:"IsAdministrator"`
|
||||||
|
IsDisabled bool `json:"IsDisabled"`
|
||||||
|
EnableAllFolders bool `json:"EnableAllFolders"`
|
||||||
|
EnabledFolders []string `json:"EnabledFolders"`
|
||||||
|
EnableMediaPlayback bool `json:"EnableMediaPlayback"`
|
||||||
|
EnableContentDownloading bool `json:"EnableContentDownloading"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserConfig is a near-empty subset; clients only require the key to exist.
|
||||||
|
type UserConfig struct {
|
||||||
|
EnableNextEpisodeAutoPlay bool `json:"EnableNextEpisodeAutoPlay"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserItemDataDto is per-user state for one item.
|
||||||
|
//
|
||||||
|
// Ticks are 100ns units. Vellum stores milliseconds, so the conversion is
|
||||||
|
// x10000 (ms -> ticks) and /10000 (ticks -> ms).
|
||||||
|
type UserItemDataDto struct {
|
||||||
|
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
|
||||||
|
PlayedPercentage float64 `json:"PlayedPercentage,omitempty"`
|
||||||
|
PlayCount int `json:"PlayCount"`
|
||||||
|
IsFavorite bool `json:"IsFavorite"`
|
||||||
|
Played bool `json:"Played"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseItemDto is the browse shape. Type is the Jellyfin item type
|
||||||
|
// ("Movie"/"Audio"/"Book"/"Photo"/"CollectionFolder"/"Folder"), mapped from
|
||||||
|
// Vellum's media kind.
|
||||||
|
type BaseItemDto struct {
|
||||||
|
Id string `json:"Id"`
|
||||||
|
Name string `json:"Name"`
|
||||||
|
Type string `json:"Type"`
|
||||||
|
MediaType string `json:"MediaType,omitempty"`
|
||||||
|
CollectionType string `json:"CollectionType,omitempty"`
|
||||||
|
Container string `json:"Container,omitempty"`
|
||||||
|
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
|
||||||
|
ProductionYear int `json:"ProductionYear,omitempty"`
|
||||||
|
ParentId string `json:"ParentId,omitempty"`
|
||||||
|
IsFolder bool `json:"IsFolder"`
|
||||||
|
SortName string `json:"SortName,omitempty"`
|
||||||
|
Path string `json:"Path,omitempty"`
|
||||||
|
ImageTags map[string]string `json:"ImageTags,omitempty"`
|
||||||
|
UserData *UserItemDataDto `json:"UserData,omitempty"`
|
||||||
|
MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaSourceInfo is one playable source. Vellum never transcodes, so
|
||||||
|
// SupportsTranscoding is always false and TranscodingUrl is never set.
|
||||||
|
type MediaSourceInfo struct {
|
||||||
|
Id string `json:"Id"`
|
||||||
|
Name string `json:"Name"`
|
||||||
|
Path string `json:"Path,omitempty"`
|
||||||
|
Container string `json:"Container,omitempty"`
|
||||||
|
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
|
||||||
|
Size int64 `json:"Size,omitempty"`
|
||||||
|
SupportsDirectPlay bool `json:"SupportsDirectPlay"`
|
||||||
|
SupportsDirectStream bool `json:"SupportsDirectStream"`
|
||||||
|
SupportsTranscoding bool `json:"SupportsTranscoding"`
|
||||||
|
DirectStreamUrl string `json:"DirectStreamUrl,omitempty"`
|
||||||
|
Protocol string `json:"Protocol"`
|
||||||
|
Type string `json:"Type"`
|
||||||
|
IsRemote bool `json:"IsRemote"`
|
||||||
|
MediaStreams []MediaStream `json:"MediaStreams,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaStream describes one track inside a file, using the codec facts the
|
||||||
|
// scanner wrote from ffprobe.
|
||||||
|
type MediaStream struct {
|
||||||
|
Type string `json:"Type"` // "Video" | "Audio"
|
||||||
|
Codec string `json:"Codec,omitempty"`
|
||||||
|
Index int `json:"Index"`
|
||||||
|
IsDefault bool `json:"IsDefault"`
|
||||||
|
Width int `json:"Width,omitempty"`
|
||||||
|
Height int `json:"Height,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlaybackInfoResponse is the response to PlaybackInfo. ErrorCode is set only
|
||||||
|
// when the client demanded a transcode Vellum will not perform.
|
||||||
|
type PlaybackInfoResponse struct {
|
||||||
|
MediaSources []MediaSourceInfo `json:"MediaSources"`
|
||||||
|
PlaySessionId string `json:"PlaySessionId,omitempty"`
|
||||||
|
ErrorCode string `json:"ErrorCode,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// itemQueryResult is the browse envelope.
|
||||||
|
type itemQueryResult struct {
|
||||||
|
Items []BaseItemDto `json:"Items"`
|
||||||
|
TotalRecordCount int `json:"TotalRecordCount"`
|
||||||
|
StartIndex int `json:"StartIndex"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// dbItem is one media_items row plus the file facts and per-user state a DTO
|
||||||
|
// needs. It is the single shape both the list and single-item paths build.
|
||||||
|
type dbItem struct {
|
||||||
|
ID int64
|
||||||
|
LibraryID int64
|
||||||
|
Kind string
|
||||||
|
Title string
|
||||||
|
SortTitle string
|
||||||
|
Year int
|
||||||
|
ParentID sql.NullInt64
|
||||||
|
Container string
|
||||||
|
DurationMS int64
|
||||||
|
VCodec string
|
||||||
|
ACodec string
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
Size int64
|
||||||
|
Path string
|
||||||
|
HasCover bool
|
||||||
|
|
||||||
|
// Per-user state, zero when the user has never touched the item.
|
||||||
|
PositionMS int64
|
||||||
|
Percent float64
|
||||||
|
Finished bool
|
||||||
|
HasProgress bool
|
||||||
|
PlayCount int
|
||||||
|
Favorite bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// itemType maps a Vellum media kind to the Jellyfin item type. The four types
|
||||||
|
// the task requires are Movie, Audio, Book and Photo; anything Vellum adds
|
||||||
|
// later (games) degrades to Folder so a client can still show it.
|
||||||
|
func itemType(kind string) string {
|
||||||
|
switch kind {
|
||||||
|
case "video":
|
||||||
|
return "Movie"
|
||||||
|
case "audiobooks", "music":
|
||||||
|
return "Audio"
|
||||||
|
case "photos":
|
||||||
|
return "Photo"
|
||||||
|
case "ebooks", "comics":
|
||||||
|
return "Book"
|
||||||
|
default:
|
||||||
|
return "Folder"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mediaType maps a Vellum media kind to the Jellyfin MediaType. It is the
|
||||||
|
// coarser classification ("Video"/"Audio"/"Book"/"Photo").
|
||||||
|
func mediaType(kind string) string {
|
||||||
|
switch kind {
|
||||||
|
case "video":
|
||||||
|
return "Video"
|
||||||
|
case "audiobooks", "music":
|
||||||
|
return "Audio"
|
||||||
|
case "photos":
|
||||||
|
return "Photo"
|
||||||
|
case "ebooks", "comics":
|
||||||
|
return "Book"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// kindsForTypes maps requested Jellyfin IncludeItemTypes back to Vellum kinds,
|
||||||
|
// so a client asking for Movies does not receive ebooks. An unknown type maps
|
||||||
|
// to nothing rather than to everything.
|
||||||
|
func kindsForTypes(types []string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, t := range types {
|
||||||
|
switch t {
|
||||||
|
case "Movie":
|
||||||
|
out = append(out, "video")
|
||||||
|
case "Audio":
|
||||||
|
out = append(out, "audiobooks", "music")
|
||||||
|
case "Book":
|
||||||
|
out = append(out, "ebooks", "comics")
|
||||||
|
case "Photo":
|
||||||
|
out = append(out, "photos")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// toBaseItemDto renders one dbItem. includeMediaSources is set from the Fields
|
||||||
|
// query parameter; lists normally omit sources to keep responses small.
|
||||||
|
func toBaseItemDto(it dbItem, includeMediaSources bool, apiKey string) BaseItemDto {
|
||||||
|
dto := BaseItemDto{
|
||||||
|
Id: itemID(it.ID),
|
||||||
|
Name: it.Title,
|
||||||
|
Type: itemType(it.Kind),
|
||||||
|
MediaType: mediaType(it.Kind),
|
||||||
|
Container: it.Container,
|
||||||
|
RunTimeTicks: msToTicks(it.DurationMS),
|
||||||
|
ProductionYear: it.Year,
|
||||||
|
IsFolder: false,
|
||||||
|
SortName: it.SortTitle,
|
||||||
|
Path: it.Path,
|
||||||
|
}
|
||||||
|
if it.ParentID.Valid {
|
||||||
|
dto.ParentId = itemID(it.ParentID.Int64)
|
||||||
|
}
|
||||||
|
if it.HasCover {
|
||||||
|
// The tag is the item id: stable, and it changes when the item changes.
|
||||||
|
dto.ImageTags = map[string]string{"Primary": itemID(it.ID)}
|
||||||
|
}
|
||||||
|
dto.UserData = &UserItemDataDto{
|
||||||
|
PlaybackPositionTicks: msToTicks(it.PositionMS),
|
||||||
|
PlayedPercentage: it.Percent * 100,
|
||||||
|
PlayCount: it.PlayCount,
|
||||||
|
IsFavorite: it.Favorite,
|
||||||
|
Played: it.Finished,
|
||||||
|
}
|
||||||
|
if includeMediaSources {
|
||||||
|
dto.MediaSources = []MediaSourceInfo{buildMediaSource(it, apiKey)}
|
||||||
|
}
|
||||||
|
return dto
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMediaSource renders the single direct-play source for an item. It is
|
||||||
|
// shared by PlaybackInfo and by list responses that requested the
|
||||||
|
// MediaSources field.
|
||||||
|
func buildMediaSource(it dbItem, apiKey string) MediaSourceInfo {
|
||||||
|
ms := MediaSourceInfo{
|
||||||
|
Id: itemID(it.ID),
|
||||||
|
Name: it.Title,
|
||||||
|
Path: it.Path,
|
||||||
|
Container: it.Container,
|
||||||
|
RunTimeTicks: msToTicks(it.DurationMS),
|
||||||
|
Size: it.Size,
|
||||||
|
SupportsDirectPlay: true,
|
||||||
|
SupportsDirectStream: true,
|
||||||
|
// The hard constraint: no transcoding, ever.
|
||||||
|
SupportsTranscoding: false,
|
||||||
|
Protocol: "File",
|
||||||
|
Type: "Default",
|
||||||
|
IsRemote: false,
|
||||||
|
}
|
||||||
|
ms.DirectStreamUrl = directStreamURL(it, apiKey)
|
||||||
|
|
||||||
|
if it.VCodec != "" {
|
||||||
|
ms.MediaStreams = append(ms.MediaStreams, MediaStream{
|
||||||
|
Type: "Video", Codec: it.VCodec, Index: 0, IsDefault: true,
|
||||||
|
Width: it.Width, Height: it.Height,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if it.ACodec != "" {
|
||||||
|
ms.MediaStreams = append(ms.MediaStreams, MediaStream{
|
||||||
|
Type: "Audio", Codec: it.ACodec, Index: 1, IsDefault: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
|
||||||
|
// directStreamURL builds the URL a client should play. Video items stream from
|
||||||
|
// /Videos, everything else (audio, books, photos) from /Audio, which is the
|
||||||
|
// same file-serving path either way. The api_key is embedded because media
|
||||||
|
// elements cannot set headers.
|
||||||
|
func directStreamURL(it dbItem, apiKey string) string {
|
||||||
|
base := "/Audio/" + itemID(it.ID) + "/stream"
|
||||||
|
if it.Kind == "video" {
|
||||||
|
base = "/Videos/" + itemID(it.ID) + "/stream"
|
||||||
|
}
|
||||||
|
if apiKey == "" {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
return base + "?api_key=" + apiKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// msToTicks converts Vellum's milliseconds to Jellyfin's 100ns ticks.
|
||||||
|
func msToTicks(ms int64) int64 { return ms * 10000 }
|
||||||
|
|
||||||
|
// ticksToMS converts Jellyfin's 100ns ticks to Vellum's milliseconds.
|
||||||
|
func ticksToMS(ticks int64) int64 { return ticks / 10000 }
|
||||||
51
internal/jellyfin/ids.go
Normal file
51
internal/jellyfin/ids.go
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
package jellyfin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Jellyfin identifies every object with an opaque 32-character lowercase hex
|
||||||
|
// id (a GUID with the dashes removed). Vellum's ids are int64, and both
|
||||||
|
// libraries and items must appear as Jellyfin ids — including as a ParentId,
|
||||||
|
// which a client hands back verbatim. Rendering both as plain decimal would
|
||||||
|
// make ParentId ambiguous between a library and an item, so libraries get a
|
||||||
|
// leading 'f' marker. A positive int64 rendered as 32 hex digits always starts
|
||||||
|
// with 0-7, so 'f' can never collide with an item id.
|
||||||
|
//
|
||||||
|
// The mapping is stable and deterministic: the same Vellum row always yields
|
||||||
|
// the same Jellyfin id, which clients rely on for caching and favourites.
|
||||||
|
|
||||||
|
// itemID renders an item's Jellyfin id.
|
||||||
|
func itemID(id int64) string { return fmt.Sprintf("%032x", uint64(id)) }
|
||||||
|
|
||||||
|
// libraryID renders a library's Jellyfin id. The leading 'f' namespaces it
|
||||||
|
// away from item ids.
|
||||||
|
func libraryID(id int64) string { return fmt.Sprintf("f%031x", uint64(id)) }
|
||||||
|
|
||||||
|
// decodeID reverses itemID/libraryID. isLibrary reports which namespace the id
|
||||||
|
// came from; ok is false for anything that is not one of our 32-char hex ids.
|
||||||
|
// Input is lowercased first, because clients sometimes normalise case.
|
||||||
|
func decodeID(s string) (id int64, isLibrary, ok bool) {
|
||||||
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
if len(s) != 32 {
|
||||||
|
return 0, false, false
|
||||||
|
}
|
||||||
|
if s[0] == 'f' {
|
||||||
|
n, err := strconv.ParseUint(s[1:], 16, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, false
|
||||||
|
}
|
||||||
|
return int64(n), true, true
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseUint(s, 16, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, false
|
||||||
|
}
|
||||||
|
// Item ids are positive int64; a value with the top bit set is malformed.
|
||||||
|
if n > 1<<63-1 {
|
||||||
|
return 0, false, false
|
||||||
|
}
|
||||||
|
return int64(n), false, true
|
||||||
|
}
|
||||||
284
internal/jellyfin/items.go
Normal file
284
internal/jellyfin/items.go
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
package jellyfin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mithral/vellum/internal/auth"
|
||||||
|
"github.com/mithral/vellum/internal/httpx"
|
||||||
|
"github.com/mithral/vellum/internal/library"
|
||||||
|
"github.com/mithral/vellum/internal/stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleItems is the browse workhorse for GET /Items. The /Users/{userId}/Items
|
||||||
|
// form delegates here; the token's user is authoritative for scoping.
|
||||||
|
func (h *Handlers) handleItems(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
ctx := r.Context()
|
||||||
|
q := r.URL.Query()
|
||||||
|
|
||||||
|
libs, err := auth.VisibleLibraryIDs(ctx, h.db, u)
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, "visible libraries", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if libs == nil {
|
||||||
|
libs = []int64{}
|
||||||
|
}
|
||||||
|
|
||||||
|
startIndex := atoiDefault(q.Get("StartIndex"), 0)
|
||||||
|
if startIndex < 0 {
|
||||||
|
startIndex = 0
|
||||||
|
}
|
||||||
|
limit := atoiDefault(q.Get("Limit"), defaultLimit)
|
||||||
|
if limit <= 0 || limit > maxLimit {
|
||||||
|
limit = maxLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
parentRaw := strings.TrimSpace(q.Get("ParentId"))
|
||||||
|
recursive := parseBool(q.Get("Recursive"))
|
||||||
|
search := strings.TrimSpace(q.Get("SearchTerm"))
|
||||||
|
|
||||||
|
// Top level: no parent, not recursive, no search. Real clients expect the
|
||||||
|
// user's views here, so the visible libraries are returned as
|
||||||
|
// CollectionFolder items to drill into.
|
||||||
|
if parentRaw == "" && !recursive && search == "" {
|
||||||
|
h.writeViews(w, ctx, libs, startIndex, limit)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A user with no grants gets an empty page, never an error and never
|
||||||
|
// content from another library.
|
||||||
|
if len(libs) == 0 {
|
||||||
|
writeItems(w, startIndex, 0, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := listQuery{
|
||||||
|
userID: u.ID,
|
||||||
|
libraryIDs: libs,
|
||||||
|
sortColumn: sortColumn(q.Get("SortBy")),
|
||||||
|
descending: isDescending(q.Get("SortOrder")),
|
||||||
|
limit: limit,
|
||||||
|
offset: startIndex,
|
||||||
|
searchTerm: search,
|
||||||
|
filters: splitCSV(q.Get("Filters")),
|
||||||
|
kinds: kindsForTypes(splitCSV(q.Get("IncludeItemTypes"))),
|
||||||
|
includeSources: containsFold(splitCSV(q.Get("Fields")), "MediaSources"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if parentRaw != "" {
|
||||||
|
pid, isLib, ok := decodeID(parentRaw)
|
||||||
|
if !ok {
|
||||||
|
// An unknown ParentId is an empty page, not an error: clients probe
|
||||||
|
// ids and expect to be told there is nothing there.
|
||||||
|
writeItems(w, startIndex, 0, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isLib {
|
||||||
|
if !containsInt64(libs, pid) {
|
||||||
|
writeItems(w, startIndex, 0, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query.libraryIDs = []int64{pid}
|
||||||
|
} else {
|
||||||
|
// Children of an item, and only if that item is visible.
|
||||||
|
visible, err := h.itemVisible(ctx, u, pid)
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, "check parent", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !visible {
|
||||||
|
writeItems(w, startIndex, 0, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query.parentItemID = pid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items, total, err := h.queryItems(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, "list items", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey := tokenFromRequest(r)
|
||||||
|
dtos := make([]BaseItemDto, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
dtos = append(dtos, toBaseItemDto(it, query.includeSources, apiKey))
|
||||||
|
}
|
||||||
|
writeItems(w, startIndex, total, dtos)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUserItems serves GET /Users/{userId}/Items. The token's user is
|
||||||
|
// authoritative; a mismatched path id is a 404 (cross-user reads are not
|
||||||
|
// implemented — see the package notes).
|
||||||
|
func (h *Handlers) handleUserItems(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
id, _, ok := decodeID(r.PathValue("userId"))
|
||||||
|
if !ok || id != u.ID {
|
||||||
|
httpx.WriteError(w, http.StatusNotFound, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.handleItems(w, r, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleItem serves one BaseItemDto. A library id is answered as a folder so a
|
||||||
|
// client that treats views as items still works.
|
||||||
|
func (h *Handlers) handleItem(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
id, isLib, ok := decodeID(r.PathValue("itemId"))
|
||||||
|
if !ok {
|
||||||
|
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isLib {
|
||||||
|
lib, err := library.Get(r.Context(), h.db, id)
|
||||||
|
if errors.Is(err, library.ErrNotFound) {
|
||||||
|
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, "load library", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, id)
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, "check access", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.WriteJSON(w, http.StatusOK, libraryDTO(lib))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
it, ok := h.authorizedItem(w, r, u, id)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.WriteJSON(w, http.StatusOK, toBaseItemDto(it, true, tokenFromRequest(r)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleImagePrimary streams the cover bytes the scanner/web already extracted.
|
||||||
|
//
|
||||||
|
// It does not trigger extraction: NewHandlers has no cache directory, and the
|
||||||
|
// extraction path belongs to the web layer. An item whose cover has not been
|
||||||
|
// extracted yet simply 404s, which clients treat as "no artwork".
|
||||||
|
func (h *Handlers) handleImagePrimary(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
id, isLib, ok := decodeID(r.PathValue("itemId"))
|
||||||
|
if !ok || isLib {
|
||||||
|
httpx.WriteError(w, http.StatusNotFound, "no cover available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := h.authorizedItem(w, r, u, id); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var path string
|
||||||
|
err := h.db.QueryRowContext(r.Context(),
|
||||||
|
`SELECT path FROM assets WHERE item_id = ? AND role = 'cover' LIMIT 1`, id).Scan(&path)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
httpx.WriteError(w, http.StatusNotFound, "no cover available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, "load cover", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
|
if err := stream.ServeFile(w, r, path); err != nil {
|
||||||
|
httpx.WriteError(w, http.StatusNotFound, "no cover available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorizedItem loads an item and confirms access, writing a 404 (never a 403,
|
||||||
|
// so existence is not disclosed) on failure.
|
||||||
|
func (h *Handlers) authorizedItem(w http.ResponseWriter, r *http.Request, u auth.User, id int64) (dbItem, bool) {
|
||||||
|
it, err := h.loadDBItem(r.Context(), u.ID, id)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
||||||
|
return dbItem{}, false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, "load item", err)
|
||||||
|
return dbItem{}, false
|
||||||
|
}
|
||||||
|
allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, it.LibraryID)
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, "check access", err)
|
||||||
|
return dbItem{}, false
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
||||||
|
return dbItem{}, false
|
||||||
|
}
|
||||||
|
return it, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeViews returns the caller's visible libraries as CollectionFolder items,
|
||||||
|
// paginated like any other browse result.
|
||||||
|
func (h *Handlers) writeViews(w http.ResponseWriter, ctx context.Context, libs []int64, startIndex, limit int) {
|
||||||
|
list, err := library.ListVisible(ctx, h.db, libs)
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, "list libraries", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total := len(list)
|
||||||
|
if startIndex > total {
|
||||||
|
startIndex = total
|
||||||
|
}
|
||||||
|
end := startIndex + limit
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
items := make([]BaseItemDto, 0, end-startIndex)
|
||||||
|
for _, lib := range list[startIndex:end] {
|
||||||
|
items = append(items, libraryDTO(lib))
|
||||||
|
}
|
||||||
|
writeItems(w, startIndex, total, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// libraryDTO renders a library as a Jellyfin folder.
|
||||||
|
func libraryDTO(lib library.Library) BaseItemDto {
|
||||||
|
return BaseItemDto{
|
||||||
|
Id: libraryID(lib.ID),
|
||||||
|
Name: lib.Name,
|
||||||
|
Type: "CollectionFolder",
|
||||||
|
CollectionType: collectionType(lib.Kind),
|
||||||
|
IsFolder: true,
|
||||||
|
SortName: lib.Name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectionType maps a Vellum kind to the Jellyfin collection type, which
|
||||||
|
// tells a client how to render the folder.
|
||||||
|
func collectionType(kind string) string {
|
||||||
|
switch kind {
|
||||||
|
case "video":
|
||||||
|
return "movies"
|
||||||
|
case "audiobooks", "music":
|
||||||
|
return "music"
|
||||||
|
case "photos":
|
||||||
|
return "homevideos"
|
||||||
|
case "ebooks", "comics":
|
||||||
|
return "books"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeItems(w http.ResponseWriter, startIndex, total int, items []BaseItemDto) {
|
||||||
|
if items == nil {
|
||||||
|
items = []BaseItemDto{}
|
||||||
|
}
|
||||||
|
httpx.WriteJSON(w, http.StatusOK, itemQueryResult{
|
||||||
|
Items: items,
|
||||||
|
TotalRecordCount: total,
|
||||||
|
StartIndex: startIndex,
|
||||||
|
})
|
||||||
|
}
|
||||||
157
internal/jellyfin/jellyfin.go
Normal file
157
internal/jellyfin/jellyfin.go
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
// Package jellyfin translates Vellum's existing storage into the subset of the
|
||||||
|
// Jellyfin HTTP API that real clients (iOS, Android, Roku, TV apps) use to
|
||||||
|
// browse and play.
|
||||||
|
//
|
||||||
|
// It is a translation layer, not a second media server. Every query goes
|
||||||
|
// through the existing media_items / media_files / progress / assets tables,
|
||||||
|
// and every request resolves to an auth.User through the same grant chokepoint
|
||||||
|
// the web app uses (auth.VisibleLibraryIDs / auth.CanAccessLibrary). A Jellyfin
|
||||||
|
// token minted here is an apitoken.KindJellyfin token, so revoking it in the
|
||||||
|
// web UI revokes Jellyfin access too.
|
||||||
|
//
|
||||||
|
// # No transcoding
|
||||||
|
//
|
||||||
|
// Vellum runs on a headless, GPU-less host. PlaybackInfo therefore honestly
|
||||||
|
// reports SupportsTranscoding=false and hands out a direct stream URL.
|
||||||
|
// A client that demands a transcode (it disabled both direct play and direct
|
||||||
|
// stream) is told so via PlaybackInfo's ErrorCode "NoCompatibleStream" rather
|
||||||
|
// than being handed a TranscodingUrl that would 404 or burn CPU the host does
|
||||||
|
// not have.
|
||||||
|
//
|
||||||
|
// # Root mounting
|
||||||
|
//
|
||||||
|
// The routes are mounted at the ROOT because that is where Jellyfin clients
|
||||||
|
// look (/System/Info/Public, /Users/AuthenticateByName, /Items, ...). They do
|
||||||
|
// not collide with the web app: internal/httpapi/static.go owns "/", "/healthz"
|
||||||
|
// and "/api/", and every Jellyfin path is a more specific ServeMux pattern, so
|
||||||
|
// requests route here first. isAPIPath in static.go does not know these
|
||||||
|
// prefixes, so each Jellyfin subtree installs its own JSON 404 handler; an
|
||||||
|
// unknown /Items/... path returns JSON rather than the SPA shell.
|
||||||
|
package jellyfin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/mithral/vellum/internal/auth"
|
||||||
|
"github.com/mithral/vellum/internal/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handlers serves the Jellyfin-compatible subset.
|
||||||
|
type Handlers struct {
|
||||||
|
db *sql.DB
|
||||||
|
log *slog.Logger
|
||||||
|
// a is kept for parity with the other HTTP layers and for ClientIP, but
|
||||||
|
// token resolution is done here: the authenticator's middleware only knows
|
||||||
|
// session cookies, which Jellyfin clients do not send.
|
||||||
|
a *auth.Authenticator
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
serverID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandlers builds the Jellyfin HTTP layer. It deliberately takes no cache
|
||||||
|
// directory: cover bytes are served from the assets table the scanner already
|
||||||
|
// populated, rather than triggering a fresh extraction.
|
||||||
|
func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers {
|
||||||
|
return &Handlers{db: db, log: log, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register mounts the routes. See the package comment for why they live at the
|
||||||
|
// root and why each subtree has a JSON 404 fallback.
|
||||||
|
func (h *Handlers) Register(mux *http.ServeMux) {
|
||||||
|
// Public discovery.
|
||||||
|
mux.HandleFunc("GET /System/Info/Public", h.handleSystemInfoPublic)
|
||||||
|
mux.HandleFunc("POST /Users/AuthenticateByName", h.handleAuthenticateByName)
|
||||||
|
|
||||||
|
// Authenticated: users.
|
||||||
|
mux.Handle("GET /Users/Me", h.require(h.handleUserMe))
|
||||||
|
mux.Handle("GET /Users/{userId}", h.require(h.handleUserByID))
|
||||||
|
|
||||||
|
// Authenticated: browsing.
|
||||||
|
mux.Handle("GET /Items", h.require(h.handleItems))
|
||||||
|
mux.Handle("GET /Users/{userId}/Items", h.require(h.handleUserItems))
|
||||||
|
mux.Handle("GET /Items/{itemId}", h.require(h.handleItem))
|
||||||
|
mux.Handle("GET /Items/{itemId}/Images/Primary", h.require(h.handleImagePrimary))
|
||||||
|
mux.Handle("GET /Items/{itemId}/PlaybackInfo", h.require(h.handlePlaybackInfo))
|
||||||
|
|
||||||
|
// Media. These MUST accept api_key in the query string: <video>/<audio>
|
||||||
|
// elements cannot set request headers, so a Jellyfin client appends
|
||||||
|
// ?api_key=... to the URL it plays.
|
||||||
|
mux.Handle("GET /Videos/{itemId}/stream", h.require(h.handleVideoStream))
|
||||||
|
mux.Handle("GET /Audio/{itemId}/stream", h.require(h.handleAudioStream))
|
||||||
|
// Real clients ask for stream.<container> (stream.mp4, stream.mkv,
|
||||||
|
// stream.mp3). ServeMux wildcards are whole segments only, so a suffix
|
||||||
|
// pattern is impossible; a subtree handler dispatches those instead. The
|
||||||
|
// exact /stream pattern above is more specific and still wins.
|
||||||
|
mux.Handle("GET /Videos/{itemId}/", h.require(h.handleVideoStream))
|
||||||
|
mux.Handle("GET /Audio/{itemId}/", h.require(h.handleAudioStream))
|
||||||
|
|
||||||
|
// Playback reporting. This is how a Jellyfin client tells us its resume
|
||||||
|
// position; it is written through the existing progress store.
|
||||||
|
mux.Handle("POST /Sessions/Playing", h.require(h.handlePlaying))
|
||||||
|
mux.Handle("POST /Sessions/Playing/Progress", h.require(h.handlePlayingProgress))
|
||||||
|
mux.Handle("POST /Sessions/Playing/Stopped", h.require(h.handlePlayingStopped))
|
||||||
|
|
||||||
|
// JSON 404s for the rest of each Jellyfin subtree. Without these an
|
||||||
|
// unknown /Items/... falls through to the SPA shell, because static.go's
|
||||||
|
// isAPIPath only recognises /healthz and /api/.
|
||||||
|
for _, prefix := range []string{
|
||||||
|
"/System/", "/Users/", "/Items/", "/Videos/", "/Audio/", "/Sessions/",
|
||||||
|
} {
|
||||||
|
mux.HandleFunc(prefix, h.handleNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serverID returns a stable, per-installation server id, created on first use
|
||||||
|
// and stored in the existing settings table so it survives restarts. Jellyfin
|
||||||
|
// clients cache it for discovery and treat a changing id as a different server.
|
||||||
|
func (h *Handlers) serverIDFor(ctx context.Context) string {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
if h.serverID != "" {
|
||||||
|
return h.serverID
|
||||||
|
}
|
||||||
|
|
||||||
|
var v string
|
||||||
|
err := h.db.QueryRowContext(ctx,
|
||||||
|
`SELECT v FROM settings WHERE k = 'jellyfin.server_id'`).Scan(&v)
|
||||||
|
if err == nil && v != "" {
|
||||||
|
h.serverID = v
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw [16]byte
|
||||||
|
if _, err := rand.Read(raw[:]); err != nil {
|
||||||
|
// A random id is a nicety; fall back to a fixed one rather than fail
|
||||||
|
// a discovery request.
|
||||||
|
h.serverID = "vellum0000000000000000000000000000"
|
||||||
|
return h.serverID
|
||||||
|
}
|
||||||
|
v = hex.EncodeToString(raw[:])
|
||||||
|
_, _ = h.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO settings (k, v) VALUES ('jellyfin.server_id', ?)
|
||||||
|
ON CONFLICT (k) DO NOTHING`, v)
|
||||||
|
|
||||||
|
// Re-read: a concurrent first request may have won the insert.
|
||||||
|
if err := h.db.QueryRowContext(ctx,
|
||||||
|
`SELECT v FROM settings WHERE k = 'jellyfin.server_id'`).Scan(&v); err != nil || v == "" {
|
||||||
|
v = "vellum0000000000000000000000000000"
|
||||||
|
}
|
||||||
|
h.serverID = v
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) handleNotFound(w http.ResponseWriter, r *http.Request) {
|
||||||
|
httpx.WriteError(w, http.StatusNotFound, "not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) fail(w http.ResponseWriter, what string, err error) {
|
||||||
|
h.log.Error(what, "err", err)
|
||||||
|
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
|
||||||
|
}
|
||||||
126
internal/jellyfin/playback.go
Normal file
126
internal/jellyfin/playback.go
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
package jellyfin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mithral/vellum/internal/auth"
|
||||||
|
"github.com/mithral/vellum/internal/httpx"
|
||||||
|
"github.com/mithral/vellum/internal/stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handlePlaybackInfo answers GET /Items/{itemId}/PlaybackInfo.
|
||||||
|
//
|
||||||
|
// The response always describes the one thing Vellum can do — direct play — and
|
||||||
|
// reports SupportsTranscoding=false. If the client has disabled both direct
|
||||||
|
// play and direct stream it is asking for a transcode; that is refused with
|
||||||
|
// ErrorCode "NoCompatibleStream" and a warning, rather than by handing back a
|
||||||
|
// TranscodingUrl that would not exist.
|
||||||
|
func (h *Handlers) handlePlaybackInfo(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
id, isLib, ok := decodeID(r.PathValue("itemId"))
|
||||||
|
if !ok || isLib {
|
||||||
|
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
it, ok := h.authorizedItem(w, r, u, id)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := r.URL.Query()
|
||||||
|
directPlay := queryBoolDefault(q.Get("EnableDirectPlay"), true)
|
||||||
|
directStream := queryBoolDefault(q.Get("EnableDirectStream"), true)
|
||||||
|
|
||||||
|
src := buildMediaSource(it, tokenFromRequest(r))
|
||||||
|
resp := PlaybackInfoResponse{MediaSources: []MediaSourceInfo{src}}
|
||||||
|
if !directPlay && !directStream {
|
||||||
|
resp.ErrorCode = "NoCompatibleStream"
|
||||||
|
h.log.Warn("jellyfin client requires transcoding; refusing",
|
||||||
|
"item", it.ID, "kind", it.Kind)
|
||||||
|
}
|
||||||
|
httpx.WriteJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleVideoStream serves a video item's file.
|
||||||
|
func (h *Handlers) handleVideoStream(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
h.serveStream(w, r, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAudioStream serves an audio (or other non-video) item's file. Jellyfin
|
||||||
|
// clients fetch audiobook and music tracks here.
|
||||||
|
func (h *Handlers) handleAudioStream(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
h.serveStream(w, r, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveStream streams the item's first file through internal/stream, so Range
|
||||||
|
// requests (seeking) work exactly as they do for the web app. Auth already came
|
||||||
|
// from tokenFromRequest, which includes the ?api_key= form that <video>/<audio>
|
||||||
|
// elements require.
|
||||||
|
func (h *Handlers) serveStream(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
// Accept /stream and /stream.<ext> (stream.mp4, stream.mkv, ...). The
|
||||||
|
// subtree pattern also catches other suffixes; refuse those.
|
||||||
|
last := path.Base(strings.TrimSuffix(r.URL.Path, "/"))
|
||||||
|
if last != "stream" && !strings.HasPrefix(last, "stream.") {
|
||||||
|
httpx.WriteError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, isLib, ok := decodeID(r.PathValue("itemId"))
|
||||||
|
if !ok || isLib {
|
||||||
|
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := h.authorizedItem(w, r, u, id); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var root, rel string
|
||||||
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
|
SELECT l.root_path, f.path
|
||||||
|
FROM media_items i
|
||||||
|
JOIN libraries l ON l.id = i.library_id
|
||||||
|
JOIN media_files f ON f.item_id = i.id
|
||||||
|
WHERE i.id = ?
|
||||||
|
ORDER BY f.id LIMIT 1`, id).Scan(&root, &rel)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
httpx.WriteError(w, http.StatusNotFound, "item has no file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, "load file", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Disposition", "inline")
|
||||||
|
if err := stream.ServeFile(w, r, filepath.Join(root, filepath.FromSlash(rel))); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
httpx.WriteError(w, http.StatusNotFound, "file is not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, stream.ErrNotAFile) {
|
||||||
|
httpx.WriteError(w, http.StatusBadRequest, "item is not a file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.fail(w, "serve file", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryBoolDefault parses a Jellyfin boolean query parameter, returning def
|
||||||
|
// when it is absent or unparseable.
|
||||||
|
func queryBoolDefault(v string, def bool) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||||
|
case "":
|
||||||
|
return def
|
||||||
|
case "1", "true", "yes":
|
||||||
|
return true
|
||||||
|
case "0", "false", "no":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
301
internal/jellyfin/query.go
Normal file
301
internal/jellyfin/query.go
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
package jellyfin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mithral/vellum/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultLimit caps an unbounded browse so a client that omits Limit cannot
|
||||||
|
// pull an entire library in one response. maxLimit is the hard ceiling.
|
||||||
|
const (
|
||||||
|
defaultLimit = 300
|
||||||
|
maxLimit = 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
// itemColumns / itemFrom are shared by the list and single-item queries so the
|
||||||
|
// two paths can never disagree about what a dbItem contains.
|
||||||
|
//
|
||||||
|
// File facts come from the item's first media_files row (multi-file items such
|
||||||
|
// as audiobook chapters are still one browsable item). Per-user state comes
|
||||||
|
// from a windowed progress join that picks the most recently updated row per
|
||||||
|
// item, plus item_views and favorites.
|
||||||
|
const itemColumns = `i.id, i.library_id, i.kind, i.title, i.sort_title, COALESCE(i.year,0),
|
||||||
|
i.parent_id,
|
||||||
|
COALESCE(f.container,''), COALESCE(f.duration_ms,0), COALESCE(f.vcodec,''), COALESCE(f.acodec,''),
|
||||||
|
COALESCE(f.width,0), COALESCE(f.height,0), COALESCE(f.size,0), COALESCE(f.path,''),
|
||||||
|
p.item_id, COALESCE(p.position,0), COALESCE(p.percent,0), COALESCE(p.finished,0),
|
||||||
|
COALESCE(v.views,0), COALESCE(fa.user_id,0),
|
||||||
|
COALESCE((SELECT 1 FROM assets a WHERE a.item_id=i.id AND a.role='cover' LIMIT 1),0)`
|
||||||
|
|
||||||
|
const itemFrom = `FROM media_items i
|
||||||
|
LEFT JOIN media_files f ON f.id = (SELECT id FROM media_files WHERE item_id=i.id ORDER BY id LIMIT 1)
|
||||||
|
LEFT JOIN (SELECT item_id, position, percent, finished,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY item_id ORDER BY updated_at DESC) AS rn
|
||||||
|
FROM progress WHERE user_id = ?) p ON p.item_id = i.id AND p.rn = 1
|
||||||
|
LEFT JOIN item_views v ON v.user_id = ? AND v.item_id = i.id
|
||||||
|
LEFT JOIN favorites fa ON fa.user_id = ? AND fa.item_id = i.id`
|
||||||
|
|
||||||
|
// listQuery is everything the browse query needs.
|
||||||
|
type listQuery struct {
|
||||||
|
userID int64
|
||||||
|
libraryIDs []int64
|
||||||
|
parentItemID int64 // 0 means "not scoped to an item's children"
|
||||||
|
kinds []string
|
||||||
|
sortColumn string
|
||||||
|
descending bool
|
||||||
|
limit int
|
||||||
|
offset int
|
||||||
|
searchTerm string
|
||||||
|
filters []string
|
||||||
|
includeSources bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryItems runs the browse query and its matching count.
|
||||||
|
func (h *Handlers) queryItems(ctx context.Context, q listQuery) ([]dbItem, int, error) {
|
||||||
|
where, whereArgs := buildWhere(q)
|
||||||
|
|
||||||
|
var total int
|
||||||
|
if err := h.db.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM media_items i WHERE `+where, whereArgs...).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
order := q.sortColumn
|
||||||
|
if q.descending {
|
||||||
|
order += " DESC"
|
||||||
|
} else {
|
||||||
|
order += " ASC"
|
||||||
|
}
|
||||||
|
sqlText := `SELECT ` + itemColumns + ` ` + itemFrom +
|
||||||
|
` WHERE ` + where + ` ORDER BY ` + order + ` LIMIT ? OFFSET ?`
|
||||||
|
|
||||||
|
args := []any{q.userID, q.userID, q.userID}
|
||||||
|
args = append(args, whereArgs...)
|
||||||
|
args = append(args, q.limit, q.offset)
|
||||||
|
|
||||||
|
rows, err := h.db.QueryContext(ctx, sqlText, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []dbItem{}
|
||||||
|
for rows.Next() {
|
||||||
|
it, err := scanItem(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out = append(out, it)
|
||||||
|
}
|
||||||
|
return out, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadDBItem loads one item with the same columns as the browse query.
|
||||||
|
func (h *Handlers) loadDBItem(ctx context.Context, userID, id int64) (dbItem, error) {
|
||||||
|
row := h.db.QueryRowContext(ctx,
|
||||||
|
`SELECT `+itemColumns+` `+itemFrom+` WHERE i.id = ?`,
|
||||||
|
userID, userID, userID, id)
|
||||||
|
return scanItem(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// itemVisible reports whether the caller may read the item's library. A
|
||||||
|
// missing item is false, not an error.
|
||||||
|
func (h *Handlers) itemVisible(ctx context.Context, u auth.User, id int64) (bool, error) {
|
||||||
|
var libID int64
|
||||||
|
err := h.db.QueryRowContext(ctx,
|
||||||
|
`SELECT library_id FROM media_items WHERE id = ?`, id).Scan(&libID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return auth.CanAccessLibrary(ctx, h.db, u, libID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanItem reads one row of the shared item column list.
|
||||||
|
func scanItem(row interface{ Scan(...any) error }) (dbItem, error) {
|
||||||
|
var (
|
||||||
|
it dbItem
|
||||||
|
parentID sql.NullInt64
|
||||||
|
hasProgress sql.NullInt64
|
||||||
|
finished int
|
||||||
|
favUser int64
|
||||||
|
hasCover int
|
||||||
|
)
|
||||||
|
err := row.Scan(
|
||||||
|
&it.ID, &it.LibraryID, &it.Kind, &it.Title, &it.SortTitle, &it.Year,
|
||||||
|
&parentID,
|
||||||
|
&it.Container, &it.DurationMS, &it.VCodec, &it.ACodec,
|
||||||
|
&it.Width, &it.Height, &it.Size, &it.Path,
|
||||||
|
&hasProgress, &it.PositionMS, &it.Percent, &finished,
|
||||||
|
&it.PlayCount, &favUser, &hasCover,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return dbItem{}, err
|
||||||
|
}
|
||||||
|
it.ParentID = parentID
|
||||||
|
it.HasProgress = hasProgress.Valid
|
||||||
|
it.Finished = finished == 1
|
||||||
|
it.Favorite = favUser != 0
|
||||||
|
it.HasCover = hasCover == 1
|
||||||
|
return it, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildWhere assembles the WHERE clause and its arguments. An empty library set
|
||||||
|
// matches nothing, which is the correct outcome for a user with no grants.
|
||||||
|
func buildWhere(q listQuery) (string, []any) {
|
||||||
|
clauses := []string{"i.missing = 0"}
|
||||||
|
var args []any
|
||||||
|
|
||||||
|
if len(q.libraryIDs) == 0 {
|
||||||
|
clauses = append(clauses, "1 = 0")
|
||||||
|
} else {
|
||||||
|
in, inArgs := placeholders(q.libraryIDs)
|
||||||
|
clauses = append(clauses, "i.library_id IN ("+in+")")
|
||||||
|
args = append(args, inArgs...)
|
||||||
|
}
|
||||||
|
if q.parentItemID > 0 {
|
||||||
|
clauses = append(clauses, "i.parent_id = ?")
|
||||||
|
args = append(args, q.parentItemID)
|
||||||
|
}
|
||||||
|
if len(q.kinds) > 0 {
|
||||||
|
in, inArgs := placeholdersStr(q.kinds)
|
||||||
|
clauses = append(clauses, "i.kind IN ("+in+")")
|
||||||
|
args = append(args, inArgs...)
|
||||||
|
}
|
||||||
|
if q.searchTerm != "" {
|
||||||
|
clauses = append(clauses, "i.title LIKE ?")
|
||||||
|
args = append(args, "%"+q.searchTerm+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filters are sorted so the SQL (and its argument order) is deterministic.
|
||||||
|
filters := append([]string(nil), q.filters...)
|
||||||
|
sort.Strings(filters)
|
||||||
|
for _, f := range filters {
|
||||||
|
switch f {
|
||||||
|
case "IsResumable":
|
||||||
|
clauses = append(clauses,
|
||||||
|
`EXISTS (SELECT 1 FROM progress p WHERE p.user_id = ? AND p.item_id = i.id AND p.finished = 0 AND p.position > 0)`)
|
||||||
|
args = append(args, q.userID)
|
||||||
|
case "IsPlayed":
|
||||||
|
clauses = append(clauses,
|
||||||
|
`EXISTS (SELECT 1 FROM progress p WHERE p.user_id = ? AND p.item_id = i.id AND p.finished = 1)`)
|
||||||
|
args = append(args, q.userID)
|
||||||
|
case "IsUnplayed":
|
||||||
|
clauses = append(clauses,
|
||||||
|
`NOT EXISTS (SELECT 1 FROM progress p WHERE p.user_id = ? AND p.item_id = i.id AND p.finished = 1)`)
|
||||||
|
args = append(args, q.userID)
|
||||||
|
case "IsFavorite":
|
||||||
|
clauses = append(clauses,
|
||||||
|
`EXISTS (SELECT 1 FROM favorites f WHERE f.user_id = ? AND f.item_id = i.id)`)
|
||||||
|
args = append(args, q.userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(clauses, " AND "), args
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── small parsing helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
func placeholders(ids []int64) (string, []any) {
|
||||||
|
parts := make([]string, len(ids))
|
||||||
|
args := make([]any, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
parts[i] = "?"
|
||||||
|
args[i] = id
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ","), args
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeholdersStr(vals []string) (string, []any) {
|
||||||
|
parts := make([]string, len(vals))
|
||||||
|
args := make([]any, len(vals))
|
||||||
|
for i, v := range vals {
|
||||||
|
parts[i] = "?"
|
||||||
|
args[i] = v
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ","), args
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitCSV splits a Jellyfin comma list, dropping empties.
|
||||||
|
func splitCSV(s string) []string {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := []string{}
|
||||||
|
for _, p := range strings.Split(s, ",") {
|
||||||
|
if p = strings.TrimSpace(p); p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsFold(list []string, want string) bool {
|
||||||
|
for _, v := range list {
|
||||||
|
if strings.EqualFold(v, want) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsInt64(list []int64, want int64) bool {
|
||||||
|
for _, v := range list {
|
||||||
|
if v == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func atoiDefault(s string, def int) int {
|
||||||
|
if s == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBool(s string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||||
|
case "1", "true", "yes":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortColumn maps a Jellyfin SortBy value to a SQL column. Only the first
|
||||||
|
// recognised name is used; unknown names fall back to sort_title.
|
||||||
|
func sortColumn(sortBy string) string {
|
||||||
|
for _, s := range strings.Split(sortBy, ",") {
|
||||||
|
switch strings.TrimSpace(s) {
|
||||||
|
case "DateCreated":
|
||||||
|
return "i.added_at"
|
||||||
|
case "ProductionYear":
|
||||||
|
return "i.year"
|
||||||
|
case "Random":
|
||||||
|
return "RANDOM()"
|
||||||
|
case "SortName":
|
||||||
|
return "i.sort_title"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "i.sort_title"
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDescending reads a Jellyfin SortOrder ("Descending"/"Desc"/"desc").
|
||||||
|
func isDescending(order string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(order)) {
|
||||||
|
case "descending", "desc":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
106
internal/jellyfin/sessions.go
Normal file
106
internal/jellyfin/sessions.go
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
package jellyfin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mithral/vellum/internal/auth"
|
||||||
|
"github.com/mithral/vellum/internal/httpx"
|
||||||
|
"github.com/mithral/vellum/internal/progress"
|
||||||
|
)
|
||||||
|
|
||||||
|
// playReport is the body of the three playback-reporting endpoints. Jellyfin
|
||||||
|
// spells these fields in PascalCase and sends PositionTicks in 100ns units.
|
||||||
|
type playReport struct {
|
||||||
|
ItemId string `json:"ItemId"`
|
||||||
|
PositionTicks int64 `json:"PositionTicks"`
|
||||||
|
PlaySessionId string `json:"PlaySessionId"`
|
||||||
|
MediaSourceId string `json:"MediaSourceId"`
|
||||||
|
IsPaused bool `json:"IsPaused"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePlaying is POST /Sessions/Playing: the client has started (or resumed)
|
||||||
|
// an item.
|
||||||
|
func (h *Handlers) handlePlaying(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
h.writePlaybackProgress(w, r, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePlayingProgress is POST /Sessions/Playing/Progress: periodic position
|
||||||
|
// updates, which are what keep the resume point current.
|
||||||
|
func (h *Handlers) handlePlayingProgress(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
h.writePlaybackProgress(w, r, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePlayingStopped is POST /Sessions/Playing/Stopped. The final position is
|
||||||
|
// written the same way; progress.Put decides whether it counts as finished.
|
||||||
|
func (h *Handlers) handlePlayingStopped(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
h.writePlaybackProgress(w, r, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePlaybackProgress turns a Jellyfin report into a progress row. This is
|
||||||
|
// how a Jellyfin client reports resume position, so it is written through the
|
||||||
|
// existing progress store rather than kept anywhere new.
|
||||||
|
func (h *Handlers) writePlaybackProgress(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
var rep playReport
|
||||||
|
// Not httpx.DecodeJSON: real clients add fields we do not model, and a
|
||||||
|
// rejected body would lose the user's position.
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&rep); err != nil {
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
httpx.WriteError(w, http.StatusBadRequest, "request body is empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.WriteError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, isLib, ok := decodeID(rep.ItemId)
|
||||||
|
if !ok || isLib {
|
||||||
|
httpx.WriteError(w, http.StatusBadRequest, "invalid ItemId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
it, ok := h.authorizedItem(w, r, u, id)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duration comes from the item's first file, the same source the browse
|
||||||
|
// DTOs use, so PlayedPercentage is consistent across endpoints.
|
||||||
|
var durationMS int64
|
||||||
|
if err := h.db.QueryRowContext(r.Context(),
|
||||||
|
`SELECT COALESCE((SELECT duration_ms FROM media_files WHERE item_id = ? ORDER BY id LIMIT 1), 0)`,
|
||||||
|
id).Scan(&durationMS); err != nil {
|
||||||
|
h.fail(w, "load duration", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
posMS := ticksToMS(rep.PositionTicks)
|
||||||
|
if posMS < 0 {
|
||||||
|
posMS = 0
|
||||||
|
}
|
||||||
|
p := progress.Progress{
|
||||||
|
ItemID: id,
|
||||||
|
Kind: it.Kind,
|
||||||
|
Position: posMS,
|
||||||
|
Duration: durationMS,
|
||||||
|
}
|
||||||
|
if durationMS > 0 {
|
||||||
|
p.Percent = float64(posMS) / float64(durationMS)
|
||||||
|
if p.Percent > 1 {
|
||||||
|
p.Percent = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := progress.Put(r.Context(), h.db, u.ID, p); err != nil {
|
||||||
|
if errors.Is(err, progress.ErrNoFile) {
|
||||||
|
// Nothing to attach progress to. Jellyfin clients expect 204, and
|
||||||
|
// there is genuinely nothing to store, so do not fail playback.
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.fail(w, "save progress", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
199
internal/jellyfin/system.go
Normal file
199
internal/jellyfin/system.go
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
package jellyfin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mithral/vellum/internal/apitoken"
|
||||||
|
"github.com/mithral/vellum/internal/auth"
|
||||||
|
"github.com/mithral/vellum/internal/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// systemInfoPublic is the discovery document clients fetch before they have a
|
||||||
|
// token. Only the four fields the task requires (plus a couple clients read).
|
||||||
|
type systemInfoPublic struct {
|
||||||
|
Id string `json:"Id"`
|
||||||
|
ServerName string `json:"ServerName"`
|
||||||
|
Version string `json:"Version"`
|
||||||
|
ProductName string `json:"ProductName"`
|
||||||
|
OperatingSystem string `json:"OperatingSystem,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// version is the Jellyfin API version string clients compare against. Vellum
|
||||||
|
// reports a conservative, widely-supported version rather than its own build
|
||||||
|
// number, because clients gate features on this value.
|
||||||
|
const version = "10.8.10"
|
||||||
|
|
||||||
|
func (h *Handlers) handleSystemInfoPublic(w http.ResponseWriter, r *http.Request) {
|
||||||
|
httpx.WriteJSON(w, http.StatusOK, systemInfoPublic{
|
||||||
|
Id: h.serverIDFor(r.Context()),
|
||||||
|
ServerName: "Vellum",
|
||||||
|
Version: version,
|
||||||
|
ProductName: "Vellum",
|
||||||
|
OperatingSystem: "Linux",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// authByNameRequest is the AuthenticateByName body. Jellyfin spells the
|
||||||
|
// password "Pw".
|
||||||
|
type authByNameRequest struct {
|
||||||
|
Username string `json:"Username"`
|
||||||
|
Pw string `json:"Pw"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) handleAuthenticateByName(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Deliberately not httpx.DecodeJSON: that rejects unknown fields, and real
|
||||||
|
// clients add extras. We only read Username and Pw.
|
||||||
|
var req authByNameRequest
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
|
||||||
|
httpx.WriteError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Username == "" || req.Pw == "" {
|
||||||
|
httpx.WriteError(w, http.StatusBadRequest, "username and password are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := auth.Authenticate(r.Context(), h.db, req.Username, req.Pw)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, auth.ErrInvalidCredentials) {
|
||||||
|
// Same message for unknown user and wrong password, as the web
|
||||||
|
// login does, so usernames are not enumerable.
|
||||||
|
httpx.WriteError(w, http.StatusUnauthorized, "invalid username or password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.fail(w, "authenticate", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
label := deviceLabel(r)
|
||||||
|
token, err := apitoken.Mint(r.Context(), h.db, u.ID, apitoken.KindJellyfin, label)
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, "mint token", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dto, err := h.userDTO(r.Context(), u)
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, "build user", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.log.Info("jellyfin login", "user", u.Username, "device", label)
|
||||||
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"User": dto,
|
||||||
|
"AccessToken": token,
|
||||||
|
"ServerId": h.serverIDFor(r.Context()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) handleUserMe(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
dto, err := h.userDTO(r.Context(), u)
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, "build user", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.WriteJSON(w, http.StatusOK, dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) handleUserByID(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
id, _, ok := decodeID(r.PathValue("userId"))
|
||||||
|
if !ok || id != u.ID {
|
||||||
|
// A token may only address its own user. Cross-user reads (which
|
||||||
|
// Jellyfin permits for admins) are NOT implemented; a mismatched id is
|
||||||
|
// a 404 so account existence is not disclosed.
|
||||||
|
httpx.WriteError(w, http.StatusNotFound, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dto, err := h.userDTO(r.Context(), u)
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, "build user", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.WriteJSON(w, http.StatusOK, dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
// userDTO renders the Jellyfin user shape, including the visible library
|
||||||
|
// folders so a client can scope its views.
|
||||||
|
func (h *Handlers) userDTO(ctx context.Context, u auth.User) (UserDto, error) {
|
||||||
|
libs, err := auth.VisibleLibraryIDs(ctx, h.db, u)
|
||||||
|
if err != nil {
|
||||||
|
return UserDto{}, err
|
||||||
|
}
|
||||||
|
folders := make([]string, 0, len(libs))
|
||||||
|
for _, id := range libs {
|
||||||
|
folders = append(folders, libraryID(id))
|
||||||
|
}
|
||||||
|
return UserDto{
|
||||||
|
Name: displayName(u),
|
||||||
|
ServerId: h.serverIDFor(ctx),
|
||||||
|
Id: itemID(u.ID),
|
||||||
|
HasPassword: u.PasswordHash != "",
|
||||||
|
HasConfiguredPassword: u.PasswordHash != "",
|
||||||
|
Policy: &UserPolicy{
|
||||||
|
IsAdministrator: u.IsAdmin,
|
||||||
|
IsDisabled: false,
|
||||||
|
EnableAllFolders: u.IsAdmin,
|
||||||
|
EnabledFolders: folders,
|
||||||
|
EnableMediaPlayback: true,
|
||||||
|
EnableContentDownloading: true,
|
||||||
|
},
|
||||||
|
Configuration: &UserConfig{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayName falls back to the username when no display name is set.
|
||||||
|
func displayName(u auth.User) string {
|
||||||
|
if u.DisplayName != "" {
|
||||||
|
return u.DisplayName
|
||||||
|
}
|
||||||
|
return u.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceLabel best-effort extracts a human device name from the
|
||||||
|
// X-Emby-Authorization header so the token list in the UI is recognisable.
|
||||||
|
func deviceLabel(r *http.Request) string {
|
||||||
|
hdr := r.Header.Get("X-Emby-Authorization")
|
||||||
|
if hdr == "" {
|
||||||
|
hdr = r.Header.Get("Authorization")
|
||||||
|
}
|
||||||
|
for _, f := range splitParams(hdr) {
|
||||||
|
if v, ok := cutParam(f, "Device="); ok && v != "" {
|
||||||
|
return "Jellyfin: " + v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Jellyfin"
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitParams splits a MediaBrowser-style parameter list on commas or spaces.
|
||||||
|
func splitParams(s string) []string {
|
||||||
|
out := []string{}
|
||||||
|
cur := ""
|
||||||
|
for _, r := range s {
|
||||||
|
if r == ',' || r == ' ' {
|
||||||
|
if cur != "" {
|
||||||
|
out = append(out, cur)
|
||||||
|
}
|
||||||
|
cur = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cur += string(r)
|
||||||
|
}
|
||||||
|
if cur != "" {
|
||||||
|
out = append(out, cur)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cutParam(field, prefix string) (string, bool) {
|
||||||
|
if len(field) < len(prefix) || field[:len(prefix)] != prefix {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
v := field[len(prefix):]
|
||||||
|
if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' {
|
||||||
|
v = v[1 : len(v)-1]
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
113
internal/subsonic/auth.go
Normal file
113
internal/subsonic/auth.go
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
package subsonic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/subtle"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mithral/vellum/internal/apitoken"
|
||||||
|
"github.com/mithral/vellum/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// authenticate resolves the Subsonic credentials on a request to a user.
|
||||||
|
//
|
||||||
|
// Every accepted form is checked against the user's recoverable Subsonic key
|
||||||
|
// (apitoken.Plain), which is the only credential this protocol can verify:
|
||||||
|
//
|
||||||
|
// - t = md5(key + s), with s the salt — the preferred form.
|
||||||
|
// - p = key directly (Navidrome accepts this, and so must we for older
|
||||||
|
// clients).
|
||||||
|
// - apiKey = key directly, the OpenSubsonic extension.
|
||||||
|
// - token = key-derived md5 with s — the same as t under the alternate
|
||||||
|
// parameter name some clients use.
|
||||||
|
//
|
||||||
|
// A missing user, a user with no Subsonic key, or a wrong credential all
|
||||||
|
// collapse to "not authenticated": the caller must not be able to tell them
|
||||||
|
// apart, and the response is always error code 40.
|
||||||
|
func (h *Handlers) authenticate(r *http.Request) (auth.User, bool) {
|
||||||
|
ctx := r.Context()
|
||||||
|
q := r.FormValue
|
||||||
|
|
||||||
|
username := strings.TrimSpace(q("u"))
|
||||||
|
apiKey := strings.TrimSpace(q("apiKey"))
|
||||||
|
|
||||||
|
// apiKey auth carries no username; resolve the key to its owner.
|
||||||
|
if username == "" {
|
||||||
|
if apiKey == "" {
|
||||||
|
return auth.User{}, false
|
||||||
|
}
|
||||||
|
userID, err := apitoken.Resolve(ctx, h.db, apiKey)
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, false
|
||||||
|
}
|
||||||
|
u, err := auth.GetUserByID(ctx, h.db, userID)
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, false
|
||||||
|
}
|
||||||
|
return u, true
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := auth.GetUserByUsername(ctx, h.db, username)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return auth.User{}, false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.log.Error("subsonic: load user", "err", err)
|
||||||
|
return auth.User{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := apitoken.Plain(ctx, h.db, u.ID)
|
||||||
|
if errors.Is(err, apitoken.ErrNotFound) {
|
||||||
|
// The user has no Subsonic key yet; they must mint one from the web
|
||||||
|
// UI. Treated as a credential failure, not a server error.
|
||||||
|
return auth.User{}, false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.log.Error("subsonic: load key", "err", err)
|
||||||
|
return auth.User{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// p = the key directly. "enc:" is the spec's hex-encoded form.
|
||||||
|
if p := q("p"); p != "" {
|
||||||
|
if raw, ok := strings.CutPrefix(p, "enc:"); ok {
|
||||||
|
decoded, err := hex.DecodeString(raw)
|
||||||
|
if err == nil {
|
||||||
|
p = string(decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if constantTimeEqual(p, key) {
|
||||||
|
return u, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiKey alongside u: same direct comparison.
|
||||||
|
if apiKey != "" && constantTimeEqual(apiKey, key) {
|
||||||
|
return u, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// t (or token) = md5(key + s).
|
||||||
|
token := q("t")
|
||||||
|
if token == "" {
|
||||||
|
token = q("token")
|
||||||
|
}
|
||||||
|
salt := q("s")
|
||||||
|
if token != "" && salt != "" {
|
||||||
|
sum := md5.Sum([]byte(key + salt))
|
||||||
|
want := hex.EncodeToString(sum[:])
|
||||||
|
if constantTimeEqual(strings.ToLower(token), want) {
|
||||||
|
return u, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return auth.User{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// constantTimeEqual compares two credential strings without leaking their
|
||||||
|
// common prefix through timing.
|
||||||
|
func constantTimeEqual(a, b string) bool {
|
||||||
|
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||||
|
}
|
||||||
263
internal/subsonic/browse.go
Normal file
263
internal/subsonic/browse.go
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
package subsonic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mithral/vellum/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// directoryForLibrary renders the root of one audio library.
|
||||||
|
func (h *Handlers) directoryForLibrary(w http.ResponseWriter, r *http.Request, u auth.User, libID int64) {
|
||||||
|
libs, err := h.audioLibraries(r.Context(), u)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "directory", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := ""
|
||||||
|
for _, l := range libs {
|
||||||
|
if l.id == libID {
|
||||||
|
name = l.name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
h.fail(w, r, errDataNotFound, "directory not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeDirectory(w, r, u, libID, "", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// directoryForPath renders one directory inside an audio library.
|
||||||
|
func (h *Handlers) directoryForPath(w http.ResponseWriter, r *http.Request, u auth.User, libID int64, rel string) {
|
||||||
|
// Confirm the library is one the caller may read before exposing anything.
|
||||||
|
libs, err := h.audioLibraryIDs(r.Context(), u)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "directory", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !containsID(libs, libID) {
|
||||||
|
h.fail(w, r, errDataNotFound, "directory not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rel = normalizeRel(rel)
|
||||||
|
name := path.Base(rel)
|
||||||
|
if rel == "." {
|
||||||
|
name = ""
|
||||||
|
}
|
||||||
|
h.writeDirectory(w, r, u, libID, rel, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeDirectory builds a <directory> element for a library path prefix.
|
||||||
|
//
|
||||||
|
// Vellum's scanner groups audio by directory and never writes parent_id, so the
|
||||||
|
// tree is reconstructed here from media_items.path: every item whose parent
|
||||||
|
// directory is the prefix is a direct album child, and every deeper item
|
||||||
|
// contributes one directory child for the next path segment.
|
||||||
|
func (h *Handlers) writeDirectory(w http.ResponseWriter, r *http.Request, u auth.User, libID int64, rel, name string) {
|
||||||
|
rel = normalizeRel(rel)
|
||||||
|
if name == "" {
|
||||||
|
name = path.Base(rel)
|
||||||
|
if rel == "." {
|
||||||
|
if root, err := h.libraryRoot(r.Context(), libID); err == nil {
|
||||||
|
name = path.Base(root)
|
||||||
|
}
|
||||||
|
if name == "" || name == "." || name == "/" {
|
||||||
|
name = "Music"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.db.QueryContext(r.Context(), `
|
||||||
|
SELECT id, path, title FROM media_items
|
||||||
|
WHERE library_id = ? AND missing = 0 AND kind IN ('music','audiobooks')
|
||||||
|
ORDER BY path`, libID)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "directory", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var (
|
||||||
|
childAlbumIDs []int64
|
||||||
|
subdirs = map[string]bool{}
|
||||||
|
)
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
id int64
|
||||||
|
ipath string
|
||||||
|
title string
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&id, &ipath, &title); err != nil {
|
||||||
|
h.internal(w, r, "directory", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dir := normalizeRel(ipath)
|
||||||
|
parent := path.Dir(dir)
|
||||||
|
if parent == "" {
|
||||||
|
parent = "."
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case parent == rel:
|
||||||
|
childAlbumIDs = append(childAlbumIDs, id)
|
||||||
|
case rel == "." && dir != ".":
|
||||||
|
subdirs[firstSegment(dir)] = true
|
||||||
|
case rel != "." && strings.HasPrefix(dir, rel+"/"):
|
||||||
|
rest := strings.TrimPrefix(dir, rel+"/")
|
||||||
|
if seg := firstSegment(rest); seg != "" {
|
||||||
|
subdirs[seg] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
h.internal(w, r, "directory", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dirNode := el("directory",
|
||||||
|
at("id", dirID(libID, rel)),
|
||||||
|
at("name", name),
|
||||||
|
).arrays("child")
|
||||||
|
|
||||||
|
subNames := make([]string, 0, len(subdirs))
|
||||||
|
for s := range subdirs {
|
||||||
|
subNames = append(subNames, s)
|
||||||
|
}
|
||||||
|
sort.Strings(subNames)
|
||||||
|
for _, seg := range subNames {
|
||||||
|
sub := seg
|
||||||
|
if rel != "." {
|
||||||
|
sub = rel + "/" + seg
|
||||||
|
}
|
||||||
|
dirNode.add(el("child",
|
||||||
|
at("id", dirID(libID, sub)),
|
||||||
|
at("parent", dirID(libID, rel)),
|
||||||
|
at("isDir", true),
|
||||||
|
at("title", path.Base(seg)),
|
||||||
|
at("album", path.Base(seg)),
|
||||||
|
at("artist", ""),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
albums, err := h.albumsByIDs(r.Context(), libID, childAlbumIDs)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "directory albums", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, a := range albums {
|
||||||
|
dirNode.add(childNode(a))
|
||||||
|
}
|
||||||
|
|
||||||
|
root := envelope("ok")
|
||||||
|
root.add(dirNode)
|
||||||
|
h.write(w, r, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// directoryForAlbum renders an album as a directory whose children are songs.
|
||||||
|
func (h *Handlers) directoryForAlbum(w http.ResponseWriter, r *http.Request, u auth.User, itemID int64) {
|
||||||
|
a, ok, err := h.loadAlbum(r.Context(), u, itemID)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "directory", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
h.fail(w, r, errDataNotFound, "directory not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tracks, err := h.tracksForItem(r.Context(), itemID)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "directory songs", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dirNode := el("directory",
|
||||||
|
at("id", albumID(a.ID)),
|
||||||
|
at("parent", dirID(a.LibraryID, parentDirOf(a.Path))),
|
||||||
|
at("name", a.Title),
|
||||||
|
).arrays("child")
|
||||||
|
for _, t := range tracks {
|
||||||
|
child := songNode(t)
|
||||||
|
child.name = "child"
|
||||||
|
dirNode.add(child)
|
||||||
|
}
|
||||||
|
root := envelope("ok")
|
||||||
|
root.add(dirNode)
|
||||||
|
h.write(w, r, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// directoryForArtist renders an artist as a directory whose children are the
|
||||||
|
// artist's albums. Legacy clients reach an artist through getMusicDirectory
|
||||||
|
// rather than getArtist.
|
||||||
|
func (h *Handlers) directoryForArtist(w http.ResponseWriter, r *http.Request, u auth.User, personID int64) {
|
||||||
|
libs, err := h.audioLibraryIDs(r.Context(), u)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "directory", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
albums, err := h.albumsByArtist(r.Context(), libs, personID)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "directory albums", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(albums) == 0 {
|
||||||
|
h.fail(w, r, errDataNotFound, "directory not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name, _ := h.artistName(r.Context(), personID)
|
||||||
|
|
||||||
|
dirNode := el("directory",
|
||||||
|
at("id", artistID(personID)),
|
||||||
|
at("name", name),
|
||||||
|
).arrays("child")
|
||||||
|
for _, a := range albums {
|
||||||
|
dirNode.add(childNode(a))
|
||||||
|
}
|
||||||
|
root := envelope("ok")
|
||||||
|
root.add(dirNode)
|
||||||
|
h.write(w, r, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// albumsByIDs loads the album projection for a specific id set within one
|
||||||
|
// library, preserving album-title order.
|
||||||
|
func (h *Handlers) albumsByIDs(ctx context.Context, libID int64, ids []int64) ([]albumInfo, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return []albumInfo{}, nil
|
||||||
|
}
|
||||||
|
in, args := inClause(ids)
|
||||||
|
return h.albumsWhere(ctx, []int64{libID}, " AND i.id IN ("+in+")", args, "i.sort_title", 500, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsID(ids []int64, id int64) bool {
|
||||||
|
for _, v := range ids {
|
||||||
|
if v == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeRel maps the library root, spelled "" or ".", to the canonical ".".
|
||||||
|
func normalizeRel(rel string) string {
|
||||||
|
rel = strings.Trim(path.Clean("/"+rel), "/")
|
||||||
|
if rel == "" {
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
return rel
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstSegment(p string) string {
|
||||||
|
if i := strings.IndexByte(p, '/'); i >= 0 {
|
||||||
|
return p[:i]
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// parentDirOf returns the parent directory of a media_items path, mapping the
|
||||||
|
// root to ".".
|
||||||
|
func parentDirOf(p string) string {
|
||||||
|
p = normalizeRel(p)
|
||||||
|
return path.Dir(p)
|
||||||
|
}
|
||||||
31
internal/subsonic/credentials.go
Normal file
31
internal/subsonic/credentials.go
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
package subsonic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mithral/vellum/internal/apitoken"
|
||||||
|
"github.com/mithral/vellum/internal/auth"
|
||||||
|
"github.com/mithral/vellum/internal/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleCredentials returns the username and Subsonic key a user configures a
|
||||||
|
// client with. The key is minted on first request so the user never has to
|
||||||
|
// manage tokens by hand; a later request returns the same key. This endpoint is
|
||||||
|
// cookie-authenticated, not Subsonic-authenticated: the user is in the web UI,
|
||||||
|
// not in the client.
|
||||||
|
func (h *Handlers) handleCredentials(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
key, err := apitoken.Plain(r.Context(), h.db, u.ID)
|
||||||
|
if errors.Is(err, apitoken.ErrNotFound) {
|
||||||
|
key, err = apitoken.Mint(r.Context(), h.db, u.ID, apitoken.KindSubsonic, "Subsonic client")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.log.Error("subsonic: credentials", "err", err)
|
||||||
|
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"username": u.Username,
|
||||||
|
"key": key,
|
||||||
|
})
|
||||||
|
}
|
||||||
623
internal/subsonic/handlers.go
Normal file
623
internal/subsonic/handlers.go
Normal file
|
|
@ -0,0 +1,623 @@
|
||||||
|
package subsonic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mithral/vellum/internal/auth"
|
||||||
|
"github.com/mithral/vellum/internal/progress"
|
||||||
|
"github.com/mithral/vellum/internal/stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ignoredArticles is advertised in getIndexes/getArtists. It is the list the
|
||||||
|
// Subsonic schema documents; the indexer strips a leading article so "The
|
||||||
|
// Beatles" files under B.
|
||||||
|
const ignoredArticles = "The El La Los Las Le Les"
|
||||||
|
|
||||||
|
// Handlers serves the Subsonic-compatible subset.
|
||||||
|
type Handlers struct {
|
||||||
|
db *sql.DB
|
||||||
|
log *slog.Logger
|
||||||
|
// auth is kept for parity with the other HTTP layers and for the cookie
|
||||||
|
// credentials endpoint. Subsonic requests authenticate themselves from
|
||||||
|
// query/form parameters, because the middleware only knows session cookies.
|
||||||
|
auth *auth.Authenticator
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandlers builds the Subsonic HTTP layer. It takes no cache directory:
|
||||||
|
// cover bytes are served from the assets table the scanner already populated,
|
||||||
|
// rather than triggering a fresh extraction.
|
||||||
|
func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers {
|
||||||
|
return &Handlers{db: db, log: log, auth: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register mounts the routes. See the package comment for why /rest/ lives at
|
||||||
|
// the root and why it installs its own 404 rather than relying on the SPA
|
||||||
|
// fallback in internal/httpapi/static.go.
|
||||||
|
func (h *Handlers) Register(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /rest/{method}", h.dispatch)
|
||||||
|
mux.HandleFunc("POST /rest/{method}", h.dispatch)
|
||||||
|
|
||||||
|
// Subtree fallback for /rest/ itself and multi-segment paths. Without it an
|
||||||
|
// unknown /rest/... falls through to "/" and static.go's isAPIPath — which
|
||||||
|
// does not recognise /rest/ — would serve the SPA shell as text/html.
|
||||||
|
mux.HandleFunc("/rest/", h.handleUnknownRest)
|
||||||
|
|
||||||
|
// Client configuration. This one IS cookie-authenticated: the user is in
|
||||||
|
// the web UI, not a Subsonic client.
|
||||||
|
mux.Handle("GET /api/v1/subsonic/credentials", h.auth.Require(h.handleCredentials))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) handleUnknownRest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.fail(w, r, errGeneric, "unknown Subsonic method")
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatch authenticates and routes one /rest/<method>.view call. Every method
|
||||||
|
// shares the same credential scheme, so authentication happens once, here.
|
||||||
|
func (h *Handlers) dispatch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
method := strings.ToLower(strings.TrimSuffix(r.PathValue("method"), ".view"))
|
||||||
|
|
||||||
|
u, ok := h.authenticate(r)
|
||||||
|
if !ok {
|
||||||
|
// The single most common integration failure: a bad credential must be
|
||||||
|
// an envelope with code 40, not an HTTP 401. Clients read the envelope.
|
||||||
|
h.fail(w, r, errWrongCredentials, "Wrong username or password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch method {
|
||||||
|
case "ping":
|
||||||
|
h.handlePing(w, r)
|
||||||
|
case "getlicense":
|
||||||
|
h.handleGetLicense(w, r)
|
||||||
|
case "getmusicfolders":
|
||||||
|
h.handleGetMusicFolders(w, r, u)
|
||||||
|
case "getindexes":
|
||||||
|
h.handleGetIndexes(w, r, u)
|
||||||
|
case "getartists":
|
||||||
|
h.handleGetArtists(w, r, u)
|
||||||
|
case "getartist":
|
||||||
|
h.handleGetArtist(w, r, u)
|
||||||
|
case "getalbum":
|
||||||
|
h.handleGetAlbum(w, r, u)
|
||||||
|
case "getmusicdirectory":
|
||||||
|
h.handleGetMusicDirectory(w, r, u)
|
||||||
|
case "getsong":
|
||||||
|
h.handleGetSong(w, r, u)
|
||||||
|
case "search3":
|
||||||
|
h.handleSearch3(w, r, u)
|
||||||
|
case "stream":
|
||||||
|
h.handleStream(w, r, u)
|
||||||
|
case "getcoverart":
|
||||||
|
h.handleGetCoverArt(w, r, u)
|
||||||
|
case "getalbumlist2":
|
||||||
|
h.handleGetAlbumList2(w, r, u)
|
||||||
|
case "getrandomsongs":
|
||||||
|
h.handleGetRandomSongs(w, r, u)
|
||||||
|
case "scrobble":
|
||||||
|
h.handleScrobble(w, r, u)
|
||||||
|
case "getuser":
|
||||||
|
h.handleGetUser(w, r, u)
|
||||||
|
case "getplaylists":
|
||||||
|
h.handleGetPlaylists(w, r)
|
||||||
|
default:
|
||||||
|
h.fail(w, r, errGeneric, "unknown method: "+method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── system ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handlers) handlePing(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.write(w, r, envelope("ok"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) handleGetLicense(w http.ResponseWriter, r *http.Request) {
|
||||||
|
root := envelope("ok")
|
||||||
|
// Vellum is not Subsonic and has no licence or trial. Clients only check
|
||||||
|
// `valid`; a permanent valid licence is the honest answer.
|
||||||
|
root.add(el("license", at("valid", true)))
|
||||||
|
h.write(w, r, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── browsing ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handlers) handleGetMusicFolders(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
libs, err := h.audioLibraries(r.Context(), u)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "music folders", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
folders := el("musicFolders").arrays("musicFolder")
|
||||||
|
for _, lib := range libs {
|
||||||
|
folders.add(el("musicFolder", at("id", lib.id), at("name", lib.name)))
|
||||||
|
}
|
||||||
|
root := envelope("ok")
|
||||||
|
root.add(folders)
|
||||||
|
h.write(w, r, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) handleGetIndexes(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
artists, err := h.artists(r.Context(), u)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "indexes", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
indexes := el("indexes", at("ignoredArticles", ignoredArticles)).arrays("index")
|
||||||
|
addIndexes(indexes, artists, false)
|
||||||
|
root := envelope("ok")
|
||||||
|
root.add(indexes)
|
||||||
|
h.write(w, r, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) handleGetArtists(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
artists, err := h.artists(r.Context(), u)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "artists", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
node := el("artists", at("ignoredArticles", ignoredArticles)).arrays("index")
|
||||||
|
addIndexes(node, artists, true)
|
||||||
|
root := envelope("ok")
|
||||||
|
root.add(node)
|
||||||
|
h.write(w, r, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) handleGetArtist(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
personID, ok := parseID(r.FormValue("id"), prefixArtist)
|
||||||
|
if !ok {
|
||||||
|
// The synthetic "unknown artist" bucket uses ar-0.
|
||||||
|
if r.FormValue("id") != prefixArtist+"0" {
|
||||||
|
h.fail(w, r, errDataNotFound, "artist not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
personID = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
libs, err := h.audioLibraryIDs(r.Context(), u)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "artist", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
albums, err := h.albumsByArtist(r.Context(), libs, personID)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "artist albums", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(albums) == 0 {
|
||||||
|
h.fail(w, r, errDataNotFound, "artist not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name, _ := h.artistName(r.Context(), personID)
|
||||||
|
artist := el("artist",
|
||||||
|
at("id", artistID(personID)),
|
||||||
|
at("name", name),
|
||||||
|
at("albumCount", int64(len(albums))),
|
||||||
|
).arrays("album")
|
||||||
|
if cover := firstCover(albums); cover != 0 {
|
||||||
|
artist.attrs = append(artist.attrs, at("coverArt", albumID(cover)))
|
||||||
|
}
|
||||||
|
for _, a := range albums {
|
||||||
|
artist.add(albumNode(a))
|
||||||
|
}
|
||||||
|
root := envelope("ok")
|
||||||
|
root.add(artist)
|
||||||
|
h.write(w, r, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
itemID, ok := parseID(r.FormValue("id"), prefixAlbum)
|
||||||
|
if !ok {
|
||||||
|
h.fail(w, r, errDataNotFound, "album not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a, ok, err := h.loadAlbum(r.Context(), u, itemID)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "album", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
h.fail(w, r, errDataNotFound, "album not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tracks, err := h.tracksForItem(r.Context(), itemID)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "album songs", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
node := albumNode(a).arrays("song")
|
||||||
|
for _, t := range tracks {
|
||||||
|
node.add(songNode(t))
|
||||||
|
}
|
||||||
|
root := envelope("ok")
|
||||||
|
root.add(node)
|
||||||
|
h.write(w, r, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) handleGetSong(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
fileIDVal, ok := parseID(r.FormValue("id"), prefixFile)
|
||||||
|
if !ok {
|
||||||
|
h.fail(w, r, errDataNotFound, "song not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, ok, err := h.loadTrack(r.Context(), u, fileIDVal)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "song", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
h.fail(w, r, errDataNotFound, "song not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root := envelope("ok")
|
||||||
|
root.add(songNode(t))
|
||||||
|
h.write(w, r, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetMusicDirectory browses one level of the tree. Vellum does not store
|
||||||
|
// a parent_id for audio (the scanner groups by directory), so the hierarchy is
|
||||||
|
// derived from media_items.path: a directory id names a path prefix, and its
|
||||||
|
// children are the items whose parent directory is that prefix plus the next
|
||||||
|
// path segment of every deeper item.
|
||||||
|
func (h *Handlers) handleGetMusicDirectory(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
id := r.FormValue("id")
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(id, prefixFolder):
|
||||||
|
libID, ok := parseID(id, prefixFolder)
|
||||||
|
if !ok {
|
||||||
|
h.fail(w, r, errDataNotFound, "directory not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.directoryForLibrary(w, r, u, libID)
|
||||||
|
case strings.HasPrefix(id, prefixDir):
|
||||||
|
libID, rel, ok := parseDirID(id)
|
||||||
|
if !ok {
|
||||||
|
h.fail(w, r, errDataNotFound, "directory not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.directoryForPath(w, r, u, libID, rel)
|
||||||
|
case strings.HasPrefix(id, prefixAlbum):
|
||||||
|
itemID, ok := parseID(id, prefixAlbum)
|
||||||
|
if !ok {
|
||||||
|
h.fail(w, r, errDataNotFound, "directory not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.directoryForAlbum(w, r, u, itemID)
|
||||||
|
case strings.HasPrefix(id, prefixArtist):
|
||||||
|
personID, ok := parseID(id, prefixArtist)
|
||||||
|
if !ok {
|
||||||
|
h.fail(w, r, errDataNotFound, "directory not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.directoryForArtist(w, r, u, personID)
|
||||||
|
default:
|
||||||
|
h.fail(w, r, errDataNotFound, "directory not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── search ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// handleSearch3 is the endpoint clients lean on hardest. It is answered with
|
||||||
|
// plain LIKE queries scoped to the caller's visible audio libraries rather than
|
||||||
|
// the FTS index, because the FTS index is over media_items (albums), while a
|
||||||
|
// Subsonic song is a media_files row and a Subsonic artist is a person. An
|
||||||
|
// empty query returns everything, which OpenSubsonic requires for offline sync.
|
||||||
|
func (h *Handlers) handleSearch3(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
q := r.FormValue("query")
|
||||||
|
artistCount, artistOffset := countOffset(r, "artistCount", "artistOffset")
|
||||||
|
albumCount, albumOffset := countOffset(r, "albumCount", "albumOffset")
|
||||||
|
songCount, songOffset := countOffset(r, "songCount", "songOffset")
|
||||||
|
|
||||||
|
libs, err := h.audioLibraryIDs(r.Context(), u)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "search", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
artists, err := h.searchArtists(r.Context(), libs, q, artistCount, artistOffset)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "search artists", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
albums, err := h.searchAlbums(r.Context(), libs, q, albumCount, albumOffset)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "search albums", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
songs, err := h.searchSongs(r.Context(), libs, q, songCount, songOffset)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "search songs", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := el("searchResult3").arrays("artist", "album", "song")
|
||||||
|
for _, a := range artists {
|
||||||
|
artist := el("artist", at("id", artistID(a.id)), at("name", a.name), at("albumCount", int64(a.albumCount)))
|
||||||
|
if a.coverAlbum != 0 {
|
||||||
|
artist.attrs = append(artist.attrs, at("coverArt", albumID(a.coverAlbum)))
|
||||||
|
}
|
||||||
|
result.add(artist)
|
||||||
|
}
|
||||||
|
for _, a := range albums {
|
||||||
|
result.add(albumNode(a))
|
||||||
|
}
|
||||||
|
for _, s := range songs {
|
||||||
|
result.add(songNode(s))
|
||||||
|
}
|
||||||
|
root := envelope("ok")
|
||||||
|
root.add(result)
|
||||||
|
h.write(w, r, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── lists ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handlers) handleGetAlbumList2(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
kind := strings.ToLower(r.FormValue("type"))
|
||||||
|
size := intParam(r, "size", 10, 500)
|
||||||
|
offset := intParam(r, "offset", 0, 1_000_000)
|
||||||
|
|
||||||
|
libs, err := h.audioLibraryIDs(r.Context(), u)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "album list", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only newest and random are required; the alphabetical sorts are cheap and
|
||||||
|
// are what a client pages through, so they are included. Types this server
|
||||||
|
// cannot answer meaningfully (highest, frequent, starred, byGenre) fall back
|
||||||
|
// to newest rather than returning an error, so a client is never stuck.
|
||||||
|
order := "i.added_at DESC"
|
||||||
|
where := ""
|
||||||
|
var args []any
|
||||||
|
switch kind {
|
||||||
|
case "random":
|
||||||
|
order = "RANDOM()"
|
||||||
|
case "alphabeticalbyname":
|
||||||
|
order = "i.sort_title"
|
||||||
|
case "alphabeticalbyartist":
|
||||||
|
order = "artist_name, i.sort_title"
|
||||||
|
case "byyear":
|
||||||
|
from := intParam(r, "fromYear", 0, 9999)
|
||||||
|
to := intParam(r, "toYear", 0, 9999)
|
||||||
|
where = " AND COALESCE(i.year,0) >= ? AND COALESCE(i.year,0) <= ?"
|
||||||
|
args = append(args, from, to)
|
||||||
|
default: // newest, recent, and every unsupported type
|
||||||
|
order = "i.added_at DESC"
|
||||||
|
}
|
||||||
|
|
||||||
|
albums, err := h.albumsWhere(r.Context(), libs, where, args, order, size, offset)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "album list", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
list := el("albumList2").arrays("album")
|
||||||
|
for _, a := range albums {
|
||||||
|
list.add(albumNode(a))
|
||||||
|
}
|
||||||
|
root := envelope("ok")
|
||||||
|
root.add(list)
|
||||||
|
h.write(w, r, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) handleGetRandomSongs(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
size := intParam(r, "size", 10, 500)
|
||||||
|
libs, err := h.audioLibraryIDs(r.Context(), u)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "random songs", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
songs, err := h.songsWhere(r.Context(), libs, "", nil, "RANDOM()", size, 0)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "random songs", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
node := el("randomSongs").arrays("song")
|
||||||
|
for _, s := range songs {
|
||||||
|
node.add(songNode(s))
|
||||||
|
}
|
||||||
|
root := envelope("ok")
|
||||||
|
root.add(node)
|
||||||
|
h.write(w, r, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── media ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// handleStream serves the original file. Vellum never transcodes: a request for
|
||||||
|
// any format other than raw (or none) is refused with a clear message rather
|
||||||
|
// than silently handing back the original under a different label. Range
|
||||||
|
// requests are handled by stream.ServeFile via http.ServeContent, so seeking
|
||||||
|
// works.
|
||||||
|
func (h *Handlers) handleStream(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
format := strings.ToLower(strings.TrimSpace(r.FormValue("format")))
|
||||||
|
if format != "" && format != "raw" {
|
||||||
|
h.fail(w, r, errGeneric, "transcoding is not supported by this server; request format=raw")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// maxBitRate and estimateContentLength are accepted and honestly ignored:
|
||||||
|
// there is no transcoder to apply either to. They are not rejected because
|
||||||
|
// clients send them unconditionally.
|
||||||
|
abs, ok, err := h.resolveAudioFile(r.Context(), u, r.FormValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "stream", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
h.fail(w, r, errDataNotFound, "song not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := stream.ServeFile(w, r, abs); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
h.fail(w, r, errDataNotFound, "file is not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.log.Error("subsonic: stream file", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) handleGetCoverArt(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
itemID, ok, err := h.coverItemID(r.Context(), u, r.FormValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "cover", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
h.fail(w, r, errDataNotFound, "cover art not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var coverPath string
|
||||||
|
err = h.db.QueryRowContext(r.Context(),
|
||||||
|
`SELECT path FROM assets WHERE item_id = ? AND role = 'cover' LIMIT 1`, itemID).Scan(&coverPath)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
h.fail(w, r, errDataNotFound, "cover art not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "cover", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(coverPath); statErr != nil {
|
||||||
|
h.fail(w, r, errDataNotFound, "cover art not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
|
if err := stream.ServeFile(w, r, coverPath); err != nil {
|
||||||
|
h.log.Error("subsonic: serve cover", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── playback reporting ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// handleScrobble writes the client's playback position through the existing
|
||||||
|
// progress store. Subsonic's scrobble carries a wall-clock time rather than a
|
||||||
|
// position, but several clients additionally send `position` in milliseconds;
|
||||||
|
// when it is present it is honoured so the web app can resume where the client
|
||||||
|
// left off. A "now playing" notification (submission=false) records the start.
|
||||||
|
func (h *Handlers) handleScrobble(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
ids := r.Form["id"]
|
||||||
|
if len(ids) == 0 {
|
||||||
|
h.fail(w, r, errMissingParameter, "Required parameter is missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submission := strings.ToLower(r.FormValue("submission")) != "false"
|
||||||
|
|
||||||
|
for _, id := range ids {
|
||||||
|
fileIDVal, ok := parseID(id, prefixFile)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t, ok, err := h.loadTrack(r.Context(), u, fileIDVal)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "scrobble", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
position := int64(0)
|
||||||
|
if v := r.FormValue("position"); v != "" {
|
||||||
|
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n >= 0 {
|
||||||
|
position = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p := progress.Progress{
|
||||||
|
ItemID: t.ItemID,
|
||||||
|
FileID: t.FileID,
|
||||||
|
Kind: t.Kind,
|
||||||
|
Position: position,
|
||||||
|
Duration: t.DurationMS,
|
||||||
|
}
|
||||||
|
if t.DurationMS > 0 {
|
||||||
|
p.Percent = float64(position) / float64(t.DurationMS)
|
||||||
|
}
|
||||||
|
if submission && t.DurationMS > 0 && position >= t.DurationMS {
|
||||||
|
p.Finished = true
|
||||||
|
}
|
||||||
|
if _, err := progress.Put(r.Context(), h.db, u.ID, p); err != nil && !errors.Is(err, progress.ErrNoFile) {
|
||||||
|
h.internal(w, r, "scrobble progress", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h.write(w, r, envelope("ok"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── user ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handlers) handleGetUser(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
target := strings.TrimSpace(r.FormValue("username"))
|
||||||
|
|
||||||
|
// The parameter is required by the spec; defaulting to the caller is the
|
||||||
|
// behaviour clients expect when they omit it.
|
||||||
|
if target != "" && !strings.EqualFold(target, u.Username) {
|
||||||
|
if !u.IsAdmin {
|
||||||
|
h.fail(w, r, errNotAuthorized, "User is not authorized for the given operation")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
other, err := auth.GetUserByUsername(r.Context(), h.db, target)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
h.fail(w, r, errDataNotFound, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "user", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u = other
|
||||||
|
}
|
||||||
|
|
||||||
|
libIDs, err := auth.VisibleLibraryIDs(r.Context(), h.db, u)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(w, r, "user folders", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := el("user",
|
||||||
|
at("username", u.Username),
|
||||||
|
at("email", ""),
|
||||||
|
at("scrobblingEnabled", true),
|
||||||
|
at("adminRole", u.IsAdmin),
|
||||||
|
at("settingsRole", u.IsAdmin),
|
||||||
|
at("downloadRole", true),
|
||||||
|
at("uploadRole", false),
|
||||||
|
at("playlistRole", true),
|
||||||
|
at("coverArtRole", true),
|
||||||
|
at("commentRole", false),
|
||||||
|
at("podcastRole", false),
|
||||||
|
at("streamRole", true),
|
||||||
|
at("jukeboxRole", false),
|
||||||
|
at("shareRole", false),
|
||||||
|
at("videoConversionRole", false),
|
||||||
|
).arrays("folder")
|
||||||
|
for _, id := range libIDs {
|
||||||
|
user.add(el("folder", at("id", id)))
|
||||||
|
}
|
||||||
|
root := envelope("ok")
|
||||||
|
root.add(user)
|
||||||
|
h.write(w, r, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetPlaylists returns an empty list. Vellum has a collections table but
|
||||||
|
// no Subsonic playlist support yet; an empty <playlists/> is what keeps a
|
||||||
|
// client from erroring on login.
|
||||||
|
func (h *Handlers) handleGetPlaylists(w http.ResponseWriter, r *http.Request) {
|
||||||
|
root := envelope("ok")
|
||||||
|
root.add(el("playlists").arrays("playlist"))
|
||||||
|
h.write(w, r, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handlers) internal(w http.ResponseWriter, r *http.Request, what string, err error) {
|
||||||
|
h.log.Error("subsonic: "+what, "err", err)
|
||||||
|
h.fail(w, r, errGeneric, "internal server error")
|
||||||
|
}
|
||||||
206
internal/subsonic/ids.go
Normal file
206
internal/subsonic/ids.go
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
package subsonic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mithral/vellum/internal/library"
|
||||||
|
"github.com/mithral/vellum/internal/stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Vellum has two id spaces — media_items and media_files — and Subsonic has
|
||||||
|
// one opaque string id, so every id this layer emits is prefixed to say what it
|
||||||
|
// names. The prefix also lets stream/getCoverArt resolve an id without a
|
||||||
|
// separate lookup table.
|
||||||
|
//
|
||||||
|
// al-<itemID> an album / directory-grouped item
|
||||||
|
// mf-<fileID> a song, i.e. one physical media_files row
|
||||||
|
// ar-<personID> an artist, from people/item_people
|
||||||
|
// fd-<libID> a music folder (a whole audio library)
|
||||||
|
// d-<libID>:<relpath> a browsable directory within a library
|
||||||
|
const (
|
||||||
|
prefixAlbum = "al-"
|
||||||
|
prefixFile = "mf-"
|
||||||
|
prefixArtist = "ar-"
|
||||||
|
prefixFolder = "fd-"
|
||||||
|
prefixDir = "d-"
|
||||||
|
)
|
||||||
|
|
||||||
|
func albumID(itemID int64) string { return prefixAlbum + strconv.FormatInt(itemID, 10) }
|
||||||
|
func fileID(fileID int64) string { return prefixFile + strconv.FormatInt(fileID, 10) }
|
||||||
|
func artistID(personID int64) string { return prefixArtist + strconv.FormatInt(personID, 10) }
|
||||||
|
func folderID(libID int64) string { return prefixFolder + strconv.FormatInt(libID, 10) }
|
||||||
|
func dirID(libID int64, rel string) string {
|
||||||
|
return prefixDir + strconv.FormatInt(libID, 10) + ":" + rel
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseID splits a prefixed id, returning the numeric part.
|
||||||
|
func parseID(id, prefix string) (int64, bool) {
|
||||||
|
rest, ok := strings.CutPrefix(id, prefix)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(rest, 10, 64)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDirID splits "d-<libID>:<relpath>". An empty relpath is the library root.
|
||||||
|
func parseDirID(id string) (libID int64, rel string, ok bool) {
|
||||||
|
rest, ok := strings.CutPrefix(id, prefixDir)
|
||||||
|
if !ok {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
lib, rel, found := strings.Cut(rest, ":")
|
||||||
|
if !found {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(lib, 10, 64)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
return n, rel, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── entities ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// albumInfo is one album: a directory-grouped media_items row of an audio kind.
|
||||||
|
type albumInfo struct {
|
||||||
|
ID int64
|
||||||
|
LibraryID int64
|
||||||
|
Kind string
|
||||||
|
Path string
|
||||||
|
Title string
|
||||||
|
Year int
|
||||||
|
AddedAt string
|
||||||
|
ArtistID int64
|
||||||
|
Artist string
|
||||||
|
SongCount int
|
||||||
|
DurationMS int64
|
||||||
|
HasCover bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// track is one song: a media_files row together with its album's context.
|
||||||
|
type track struct {
|
||||||
|
FileID int64
|
||||||
|
ItemID int64
|
||||||
|
LibraryID int64
|
||||||
|
Kind string
|
||||||
|
AlbumPath string
|
||||||
|
AlbumTitle string
|
||||||
|
Year int
|
||||||
|
AddedAt string
|
||||||
|
RelPath string
|
||||||
|
Size int64
|
||||||
|
DurationMS int64
|
||||||
|
Container string
|
||||||
|
ArtistID int64
|
||||||
|
Artist string
|
||||||
|
Ordinal int
|
||||||
|
HasCover bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// songNode renders a track as the Subsonic Child/Song element. getSong returns
|
||||||
|
// it bare; getAlbum nests it under <album>.
|
||||||
|
func songNode(t track) *node {
|
||||||
|
ct := stream.ContentType(t.RelPath)
|
||||||
|
suffix := strings.TrimPrefix(strings.ToLower(filepath.Ext(t.RelPath)), ".")
|
||||||
|
|
||||||
|
n := el("song",
|
||||||
|
at("id", fileID(t.FileID)),
|
||||||
|
at("parent", albumID(t.ItemID)),
|
||||||
|
at("isDir", false),
|
||||||
|
at("title", library.CleanTitle(filepath.Base(t.RelPath))),
|
||||||
|
at("album", t.AlbumTitle),
|
||||||
|
at("albumId", albumID(t.ItemID)),
|
||||||
|
at("artist", t.Artist),
|
||||||
|
at("type", "music"),
|
||||||
|
at("isVideo", false),
|
||||||
|
)
|
||||||
|
if t.ArtistID > 0 {
|
||||||
|
n.attrs = append(n.attrs, at("artistId", artistID(t.ArtistID)))
|
||||||
|
}
|
||||||
|
if t.HasCover {
|
||||||
|
n.attrs = append(n.attrs, at("coverArt", albumID(t.ItemID)))
|
||||||
|
}
|
||||||
|
if t.Ordinal > 0 {
|
||||||
|
n.attrs = append(n.attrs, at("track", int64(t.Ordinal)))
|
||||||
|
}
|
||||||
|
if t.Year > 0 {
|
||||||
|
n.attrs = append(n.attrs, at("year", int64(t.Year)))
|
||||||
|
}
|
||||||
|
n.attrs = append(n.attrs,
|
||||||
|
at("size", t.Size),
|
||||||
|
at("contentType", ct),
|
||||||
|
at("suffix", suffix),
|
||||||
|
at("duration", t.DurationMS/1000),
|
||||||
|
at("path", t.RelPath),
|
||||||
|
)
|
||||||
|
if t.AddedAt != "" {
|
||||||
|
n.attrs = append(n.attrs, at("created", t.AddedAt))
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// albumNode renders an album as the Subsonic AlbumID3 element. Extra legacy
|
||||||
|
// fields (title, album, isDir) are included because older clients read them and
|
||||||
|
// they cost nothing.
|
||||||
|
func albumNode(a albumInfo) *node {
|
||||||
|
parent := folderID(a.LibraryID)
|
||||||
|
if a.ArtistID > 0 {
|
||||||
|
parent = artistID(a.ArtistID)
|
||||||
|
}
|
||||||
|
|
||||||
|
n := el("album",
|
||||||
|
at("id", albumID(a.ID)),
|
||||||
|
at("parent", parent),
|
||||||
|
at("name", a.Title),
|
||||||
|
at("title", a.Title),
|
||||||
|
at("album", a.Title),
|
||||||
|
at("isDir", true),
|
||||||
|
at("artist", a.Artist),
|
||||||
|
at("songCount", int64(a.SongCount)),
|
||||||
|
at("duration", a.DurationMS/1000),
|
||||||
|
at("playCount", int64(0)),
|
||||||
|
)
|
||||||
|
if a.ArtistID > 0 {
|
||||||
|
n.attrs = append(n.attrs, at("artistId", artistID(a.ArtistID)))
|
||||||
|
}
|
||||||
|
if a.HasCover {
|
||||||
|
n.attrs = append(n.attrs, at("coverArt", albumID(a.ID)))
|
||||||
|
}
|
||||||
|
if a.Year > 0 {
|
||||||
|
n.attrs = append(n.attrs, at("year", int64(a.Year)))
|
||||||
|
}
|
||||||
|
if a.AddedAt != "" {
|
||||||
|
n.attrs = append(n.attrs, at("created", a.AddedAt))
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// childNode renders an album or directory entry for getMusicDirectory /
|
||||||
|
// getIndexes, where the element is a generic Child rather than AlbumID3.
|
||||||
|
func childNode(a albumInfo) *node {
|
||||||
|
n := el("child",
|
||||||
|
at("id", albumID(a.ID)),
|
||||||
|
at("parent", folderID(a.LibraryID)),
|
||||||
|
at("isDir", true),
|
||||||
|
at("title", a.Title),
|
||||||
|
at("album", a.Title),
|
||||||
|
at("artist", a.Artist),
|
||||||
|
at("isVideo", false),
|
||||||
|
)
|
||||||
|
if a.ArtistID > 0 {
|
||||||
|
n.attrs = append(n.attrs, at("artistId", artistID(a.ArtistID)))
|
||||||
|
}
|
||||||
|
if a.HasCover {
|
||||||
|
n.attrs = append(n.attrs, at("coverArt", albumID(a.ID)))
|
||||||
|
}
|
||||||
|
if a.Year > 0 {
|
||||||
|
n.attrs = append(n.attrs, at("year", int64(a.Year)))
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
182
internal/subsonic/node.go
Normal file
182
internal/subsonic/node.go
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
package subsonic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// attr is one attribute of an element. The value is typed — string, int64 or
|
||||||
|
// bool — rather than a string so the JSON form can emit a real number or
|
||||||
|
// boolean where the protocol expects one, while XML always stringifies it.
|
||||||
|
type attr struct {
|
||||||
|
name string
|
||||||
|
value any
|
||||||
|
}
|
||||||
|
|
||||||
|
// node is one element in a response tree. Attributes and children are kept in
|
||||||
|
// insertion order, which XML needs (attribute order is cosmetic but stable
|
||||||
|
// output is easier to test) and which lets JSON grouping stay deterministic.
|
||||||
|
//
|
||||||
|
// The tree exists instead of Go structs because Subsonic's XML and JSON forms
|
||||||
|
// are not the same shape: XML uses attributes, while JSON flattens attributes
|
||||||
|
// and child elements into one object and turns repeated child names into
|
||||||
|
// arrays. A single tree with two serializers keeps the two in lockstep.
|
||||||
|
type node struct {
|
||||||
|
name string
|
||||||
|
attrs []attr
|
||||||
|
kids []*node
|
||||||
|
// arrayKids names child elements to emit as JSON arrays even when there is
|
||||||
|
// exactly one (or none). Whether "song" is an object or an array depends on
|
||||||
|
// the parent: getSong returns one object, but album/searchResult3 contain
|
||||||
|
// arrays. That is a property of the container, so it is recorded here rather
|
||||||
|
// than inferred from the element name.
|
||||||
|
arrayKids map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// el starts an element.
|
||||||
|
func el(name string, attrs ...attr) *node {
|
||||||
|
return &node{name: name, attrs: attrs}
|
||||||
|
}
|
||||||
|
|
||||||
|
// arrays marks child names that should always be JSON arrays under this node,
|
||||||
|
// and that should appear as [] when there are none, so a client that indexes
|
||||||
|
// the array never sees a missing key.
|
||||||
|
func (n *node) arrays(names ...string) *node {
|
||||||
|
if n.arrayKids == nil {
|
||||||
|
n.arrayKids = make(map[string]bool, len(names))
|
||||||
|
}
|
||||||
|
for _, name := range names {
|
||||||
|
n.arrayKids[name] = true
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// at is one attribute.
|
||||||
|
func at(name string, value any) attr { return attr{name: name, value: value} }
|
||||||
|
|
||||||
|
// add appends child elements and returns the parent, so trees can be built
|
||||||
|
// inline.
|
||||||
|
func (n *node) add(kids ...*node) *node {
|
||||||
|
n.kids = append(n.kids, kids...)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// nodeJSON converts a node into the JSON value Subsonic clients expect. A
|
||||||
|
// "folder" leaf whose only attribute is id is flattened to the bare id, which
|
||||||
|
// is how getuser's folder list is specified ([1, 3], not [{"id":1}]).
|
||||||
|
func nodeJSON(n *node) any {
|
||||||
|
if n.name == "folder" && len(n.attrs) == 1 && len(n.kids) == 0 && n.attrs[0].name == "id" {
|
||||||
|
return n.attrs[0].value
|
||||||
|
}
|
||||||
|
|
||||||
|
m := make(map[string]any, len(n.attrs)+len(n.kids))
|
||||||
|
for _, a := range n.attrs {
|
||||||
|
if a.name == "xmlns" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m[a.name] = a.value
|
||||||
|
}
|
||||||
|
|
||||||
|
order := make([]string, 0, len(n.kids))
|
||||||
|
groups := make(map[string][]*node, len(n.kids))
|
||||||
|
for _, k := range n.kids {
|
||||||
|
if _, ok := groups[k.name]; !ok {
|
||||||
|
order = append(order, k.name)
|
||||||
|
}
|
||||||
|
groups[k.name] = append(groups[k.name], k)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range order {
|
||||||
|
kids := groups[name]
|
||||||
|
if len(kids) == 1 && !n.arrayKids[name] {
|
||||||
|
m[name] = nodeJSON(kids[0])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
arr := make([]any, 0, len(kids))
|
||||||
|
for _, k := range kids {
|
||||||
|
arr = append(arr, nodeJSON(k))
|
||||||
|
}
|
||||||
|
m[name] = arr
|
||||||
|
}
|
||||||
|
|
||||||
|
for name := range n.arrayKids {
|
||||||
|
if _, ok := m[name]; !ok {
|
||||||
|
m[name] = []any{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeJSONResponse writes the JSON envelope. HTML escaping is disabled so a
|
||||||
|
// path containing "&" or "<" stays readable rather than becoming \u0026.
|
||||||
|
func writeJSONResponse(w http.ResponseWriter, root *node) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
enc := json.NewEncoder(w)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
_ = enc.Encode(map[string]any{"subsonic-response": nodeJSON(root)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── XML ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// writeXMLResponse writes the XML envelope, including the namespace the XSD
|
||||||
|
// declares.
|
||||||
|
func writeXMLResponse(w http.ResponseWriter, root *node) {
|
||||||
|
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
|
||||||
|
_, _ = io.WriteString(w, xml.Header)
|
||||||
|
_ = writeXML(w, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeXML(w io.Writer, n *node) error {
|
||||||
|
var b bytes.Buffer
|
||||||
|
b.WriteByte('<')
|
||||||
|
b.WriteString(n.name)
|
||||||
|
for _, a := range n.attrs {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
b.WriteString(a.name)
|
||||||
|
b.WriteString(`="`)
|
||||||
|
_ = xml.EscapeText(&b, []byte(xmlString(a.value)))
|
||||||
|
b.WriteByte('"')
|
||||||
|
}
|
||||||
|
if len(n.kids) == 0 {
|
||||||
|
b.WriteString("/>")
|
||||||
|
_, err := w.Write(b.Bytes())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.WriteByte('>')
|
||||||
|
if _, err := w.Write(b.Bytes()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, k := range n.kids {
|
||||||
|
if err := writeXML(w, k); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := io.WriteString(w, "</"+n.name+">")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// xmlString renders a typed attribute value for XML. Booleans are lower-case,
|
||||||
|
// which is what the Subsonic schema uses.
|
||||||
|
func xmlString(v any) string {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case string:
|
||||||
|
return t
|
||||||
|
case bool:
|
||||||
|
if t {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
case int:
|
||||||
|
return strconv.Itoa(t)
|
||||||
|
case int64:
|
||||||
|
return strconv.FormatInt(t, 10)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
529
internal/subsonic/store.go
Normal file
529
internal/subsonic/store.go
Normal file
|
|
@ -0,0 +1,529 @@
|
||||||
|
package subsonic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/mithral/vellum/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// libraryRef is an audio library as getMusicFolders shows it.
|
||||||
|
type libraryRef struct {
|
||||||
|
id int64
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// audioLibraries returns the caller's visible music/audiobook libraries. The
|
||||||
|
// web UI's video, ebook and photo libraries are not Subsonic music folders.
|
||||||
|
func (h *Handlers) audioLibraries(ctx context.Context, u auth.User) ([]libraryRef, error) {
|
||||||
|
ids, err := auth.VisibleLibraryIDs(ctx, h.db, u)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return []libraryRef{}, nil
|
||||||
|
}
|
||||||
|
in, args := inClause(ids)
|
||||||
|
rows, err := h.db.QueryContext(ctx, `
|
||||||
|
SELECT id, name FROM libraries
|
||||||
|
WHERE kind IN ('music','audiobooks') AND id IN (`+in+`)
|
||||||
|
ORDER BY name`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []libraryRef{}
|
||||||
|
for rows.Next() {
|
||||||
|
var l libraryRef
|
||||||
|
if err := rows.Scan(&l.id, &l.name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) audioLibraryIDs(ctx context.Context, u auth.User) ([]int64, error) {
|
||||||
|
libs, err := h.audioLibraries(ctx, u)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]int64, 0, len(libs))
|
||||||
|
for _, l := range libs {
|
||||||
|
out = append(out, l.id)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// libraryRoot returns the absolute root path of a library.
|
||||||
|
func (h *Handlers) libraryRoot(ctx context.Context, libID int64) (string, error) {
|
||||||
|
var root string
|
||||||
|
err := h.db.QueryRowContext(ctx, `SELECT root_path FROM libraries WHERE id = ?`, libID).Scan(&root)
|
||||||
|
return root, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── albums ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const albumSelect = `
|
||||||
|
SELECT i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year,0), i.added_at,
|
||||||
|
COALESCE((SELECT ip.person_id FROM item_people ip
|
||||||
|
WHERE ip.item_id = i.id AND ip.role = 'author' ORDER BY ip.ord LIMIT 1), 0) AS artist_id,
|
||||||
|
(SELECT count(*) FROM media_files f WHERE f.item_id = i.id AND f.missing = 0) AS song_count,
|
||||||
|
COALESCE((SELECT sum(f.duration_ms) FROM media_files f WHERE f.item_id = i.id AND f.missing = 0), 0) AS duration_ms,
|
||||||
|
EXISTS(SELECT 1 FROM assets a WHERE a.item_id = i.id AND a.role = 'cover') AS has_cover,
|
||||||
|
COALESCE((SELECT p.name FROM item_people ip JOIN people p ON p.id = ip.person_id
|
||||||
|
WHERE ip.item_id = i.id AND ip.role = 'author' ORDER BY ip.ord LIMIT 1), '') AS artist_name
|
||||||
|
FROM media_items i`
|
||||||
|
|
||||||
|
// albumsWhere runs the album projection with a caller-supplied extra predicate
|
||||||
|
// and order. order MUST come from a fixed set, never from user input; the
|
||||||
|
// callers below pass only literals. The final projected column is aliased
|
||||||
|
// artist_name so alphabeticalByArtist can order by it without a second join.
|
||||||
|
func (h *Handlers) albumsWhere(ctx context.Context, libs []int64, extra string, extraArgs []any, order string, limit, offset int) ([]albumInfo, error) {
|
||||||
|
if limit <= 0 || limit > 500 {
|
||||||
|
limit = 500
|
||||||
|
}
|
||||||
|
in, args := inClause(libs)
|
||||||
|
args = append(args, extraArgs...)
|
||||||
|
|
||||||
|
q := albumSelect + `
|
||||||
|
WHERE i.missing = 0 AND i.kind IN ('music','audiobooks')
|
||||||
|
AND i.library_id IN (` + in + `)` + extra + `
|
||||||
|
ORDER BY ` + order + ` LIMIT ? OFFSET ?`
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
|
||||||
|
rows, err := h.db.QueryContext(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanAlbums(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) albumsByArtist(ctx context.Context, libs []int64, personID int64) ([]albumInfo, error) {
|
||||||
|
if personID == 0 {
|
||||||
|
return h.albumsWhere(ctx, libs,
|
||||||
|
` AND NOT EXISTS (SELECT 1 FROM item_people ip WHERE ip.item_id = i.id AND ip.role = 'author')`,
|
||||||
|
nil, "i.sort_title", 500, 0)
|
||||||
|
}
|
||||||
|
return h.albumsWhere(ctx, libs,
|
||||||
|
` AND EXISTS (SELECT 1 FROM item_people ip WHERE ip.item_id = i.id AND ip.role = 'author' AND ip.person_id = ?)`,
|
||||||
|
[]any{personID}, "i.sort_title", 500, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) loadAlbum(ctx context.Context, u auth.User, itemID int64) (albumInfo, bool, error) {
|
||||||
|
libs, err := h.audioLibraryIDs(ctx, u)
|
||||||
|
if err != nil {
|
||||||
|
return albumInfo{}, false, err
|
||||||
|
}
|
||||||
|
albums, err := h.albumsWhere(ctx, libs, ` AND i.id = ?`, []any{itemID}, "i.id", 1, 0)
|
||||||
|
if err != nil {
|
||||||
|
return albumInfo{}, false, err
|
||||||
|
}
|
||||||
|
if len(albums) == 0 {
|
||||||
|
return albumInfo{}, false, nil
|
||||||
|
}
|
||||||
|
return albums[0], true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAlbums(rows *sql.Rows) ([]albumInfo, error) {
|
||||||
|
out := []albumInfo{}
|
||||||
|
for rows.Next() {
|
||||||
|
var a albumInfo
|
||||||
|
var hasCover int
|
||||||
|
if err := rows.Scan(&a.ID, &a.LibraryID, &a.Kind, &a.Path, &a.Title, &a.Year, &a.AddedAt,
|
||||||
|
&a.ArtistID, &a.SongCount, &a.DurationMS, &hasCover, &a.Artist); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a.HasCover = hasCover == 1
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── tracks ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const trackSelect = `
|
||||||
|
SELECT f.id, f.item_id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year,0), i.added_at,
|
||||||
|
f.path, f.size, f.duration_ms, f.container,
|
||||||
|
COALESCE((SELECT ip.person_id FROM item_people ip
|
||||||
|
WHERE ip.item_id = i.id AND ip.role = 'author' ORDER BY ip.ord LIMIT 1), 0),
|
||||||
|
COALESCE((SELECT p.name FROM item_people ip JOIN people p ON p.id = ip.person_id
|
||||||
|
WHERE ip.item_id = i.id AND ip.role = 'author' ORDER BY ip.ord LIMIT 1), ''),
|
||||||
|
EXISTS(SELECT 1 FROM assets a WHERE a.item_id = i.id AND a.role = 'cover')
|
||||||
|
FROM media_files f
|
||||||
|
JOIN media_items i ON i.id = f.item_id`
|
||||||
|
|
||||||
|
func (h *Handlers) tracksForItem(ctx context.Context, itemID int64) ([]track, error) {
|
||||||
|
rows, err := h.db.QueryContext(ctx, trackSelect+`
|
||||||
|
WHERE f.item_id = ? AND f.missing = 0 AND i.missing = 0
|
||||||
|
ORDER BY f.path`, itemID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
tracks, err := scanTracks(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range tracks {
|
||||||
|
tracks[i].Ordinal = i + 1
|
||||||
|
}
|
||||||
|
return tracks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) loadTrack(ctx context.Context, u auth.User, fileIDVal int64) (track, bool, error) {
|
||||||
|
rows, err := h.db.QueryContext(ctx, trackSelect+`
|
||||||
|
WHERE f.id = ? AND f.missing = 0 AND i.missing = 0`, fileIDVal)
|
||||||
|
if err != nil {
|
||||||
|
return track{}, false, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
tracks, err := scanTracks(rows)
|
||||||
|
if err != nil {
|
||||||
|
return track{}, false, err
|
||||||
|
}
|
||||||
|
if len(tracks) == 0 {
|
||||||
|
return track{}, false, nil
|
||||||
|
}
|
||||||
|
allowed, err := auth.CanAccessLibrary(ctx, h.db, u, tracks[0].LibraryID)
|
||||||
|
if err != nil {
|
||||||
|
return track{}, false, err
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
return track{}, false, nil
|
||||||
|
}
|
||||||
|
return tracks[0], true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) songsWhere(ctx context.Context, libs []int64, extra string, extraArgs []any, order string, limit, offset int) ([]track, error) {
|
||||||
|
if limit <= 0 || limit > 500 {
|
||||||
|
limit = 500
|
||||||
|
}
|
||||||
|
in, args := inClause(libs)
|
||||||
|
args = append(args, extraArgs...)
|
||||||
|
|
||||||
|
q := trackSelect + `
|
||||||
|
WHERE i.missing = 0 AND f.missing = 0
|
||||||
|
AND i.kind IN ('music','audiobooks')
|
||||||
|
AND i.library_id IN (` + in + `)` + extra + `
|
||||||
|
ORDER BY ` + order + ` LIMIT ? OFFSET ?`
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
|
||||||
|
rows, err := h.db.QueryContext(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanTracks(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanTracks(rows *sql.Rows) ([]track, error) {
|
||||||
|
out := []track{}
|
||||||
|
for rows.Next() {
|
||||||
|
var t track
|
||||||
|
var hasCover int
|
||||||
|
if err := rows.Scan(&t.FileID, &t.ItemID, &t.LibraryID, &t.Kind, &t.AlbumPath, &t.AlbumTitle,
|
||||||
|
&t.Year, &t.AddedAt, &t.RelPath, &t.Size, &t.DurationMS, &t.Container,
|
||||||
|
&t.ArtistID, &t.Artist, &hasCover); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.HasCover = hasCover == 1
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── artists ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type artistInfo struct {
|
||||||
|
id int64
|
||||||
|
name string
|
||||||
|
albumCount int
|
||||||
|
coverAlbum int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// artists returns every artist with at least one visible audio album, plus a
|
||||||
|
// synthetic "Unknown Artist" bucket when some albums carry no author credit.
|
||||||
|
func (h *Handlers) artists(ctx context.Context, u auth.User) ([]artistInfo, error) {
|
||||||
|
libs, err := h.audioLibraryIDs(ctx, u)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return h.searchArtists(ctx, libs, "", 500, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) searchArtists(ctx context.Context, libs []int64, q string, limit, offset int) ([]artistInfo, error) {
|
||||||
|
if limit <= 0 || limit > 500 {
|
||||||
|
limit = 500
|
||||||
|
}
|
||||||
|
in, args := inClause(libs)
|
||||||
|
args = append(args, q, q, limit, offset)
|
||||||
|
|
||||||
|
rows, err := h.db.QueryContext(ctx, `
|
||||||
|
SELECT p.id, p.name, count(DISTINCT i.id),
|
||||||
|
COALESCE((SELECT a.item_id
|
||||||
|
FROM item_people ip2
|
||||||
|
JOIN media_items i2 ON i2.id = ip2.item_id
|
||||||
|
JOIN assets a ON a.item_id = i2.id AND a.role = 'cover'
|
||||||
|
WHERE ip2.person_id = p.id AND ip2.role = 'author' AND i2.missing = 0
|
||||||
|
ORDER BY i2.id LIMIT 1), 0)
|
||||||
|
FROM people p
|
||||||
|
JOIN item_people ip ON ip.person_id = p.id
|
||||||
|
JOIN media_items i ON i.id = ip.item_id
|
||||||
|
WHERE ip.role = 'author' AND i.missing = 0
|
||||||
|
AND i.kind IN ('music','audiobooks')
|
||||||
|
AND i.library_id IN (`+in+`)
|
||||||
|
AND (? = '' OR p.name LIKE '%' || ? || '%')
|
||||||
|
GROUP BY p.id, p.name
|
||||||
|
ORDER BY p.name
|
||||||
|
LIMIT ? OFFSET ?`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []artistInfo{}
|
||||||
|
for rows.Next() {
|
||||||
|
var a artistInfo
|
||||||
|
if err := rows.Scan(&a.id, &a.name, &a.albumCount, &a.coverAlbum); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include the uncredited bucket only when there is something in it, and
|
||||||
|
// only on the unfiltered listing (a name search should not surface it).
|
||||||
|
if q == "" {
|
||||||
|
n, err := h.uncreditedAlbumCount(ctx, libs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
out = append(out, artistInfo{id: 0, name: "Unknown Artist", albumCount: n})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) uncreditedAlbumCount(ctx context.Context, libs []int64) (int, error) {
|
||||||
|
in, args := inClause(libs)
|
||||||
|
var n int
|
||||||
|
err := h.db.QueryRowContext(ctx, `
|
||||||
|
SELECT count(*) FROM media_items i
|
||||||
|
WHERE i.missing = 0 AND i.kind IN ('music','audiobooks')
|
||||||
|
AND i.library_id IN (`+in+`)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM item_people ip WHERE ip.item_id = i.id AND ip.role = 'author')`,
|
||||||
|
args...).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) artistName(ctx context.Context, personID int64) (string, error) {
|
||||||
|
if personID == 0 {
|
||||||
|
return "Unknown Artist", nil
|
||||||
|
}
|
||||||
|
var name string
|
||||||
|
err := h.db.QueryRowContext(ctx, `SELECT name FROM people WHERE id = ?`, personID).Scan(&name)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return "Unknown Artist", nil
|
||||||
|
}
|
||||||
|
return name, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchAlbums filters the album projection by title or credited artist.
|
||||||
|
func (h *Handlers) searchAlbums(ctx context.Context, libs []int64, q string, limit, offset int) ([]albumInfo, error) {
|
||||||
|
extra := ` AND (? = '' OR i.title LIKE '%' || ? || '%'
|
||||||
|
OR EXISTS (SELECT 1 FROM item_people ip JOIN people p ON p.id = ip.person_id
|
||||||
|
WHERE ip.item_id = i.id AND ip.role = 'author' AND p.name LIKE '%' || ? || '%'))`
|
||||||
|
return h.albumsWhere(ctx, libs, extra, []any{q, q, q}, "i.sort_title", limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) searchSongs(ctx context.Context, libs []int64, q string, limit, offset int) ([]track, error) {
|
||||||
|
extra := ` AND (? = '' OR f.path LIKE '%' || ? || '%' OR i.title LIKE '%' || ? || '%')`
|
||||||
|
return h.songsWhere(ctx, libs, extra, []any{q, q, q}, "i.sort_title, f.path", limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addIndexes groups artists by their index letter and appends <index> children
|
||||||
|
// to parent. withCount controls whether the ArtistID3 albumCount is emitted
|
||||||
|
// (getArtists) or the leaner Child shape is used (getIndexes).
|
||||||
|
func addIndexes(parent *node, artists []artistInfo, withCount bool) {
|
||||||
|
sorted := append([]artistInfo(nil), artists...)
|
||||||
|
sort.Slice(sorted, func(i, j int) bool {
|
||||||
|
li, lj := indexLetter(sorted[i].name), indexLetter(sorted[j].name)
|
||||||
|
if li != lj {
|
||||||
|
return li < lj
|
||||||
|
}
|
||||||
|
return sorted[i].name < sorted[j].name
|
||||||
|
})
|
||||||
|
|
||||||
|
var current *node
|
||||||
|
currentLetter := ""
|
||||||
|
for _, a := range sorted {
|
||||||
|
letter := indexLetter(a.name)
|
||||||
|
if current == nil || letter != currentLetter {
|
||||||
|
current = el("index", at("name", letter)).arrays("artist")
|
||||||
|
parent.add(current)
|
||||||
|
currentLetter = letter
|
||||||
|
}
|
||||||
|
artist := el("artist", at("id", artistID(a.id)), at("name", a.name))
|
||||||
|
if withCount {
|
||||||
|
artist.attrs = append(artist.attrs, at("albumCount", int64(a.albumCount)))
|
||||||
|
}
|
||||||
|
if a.coverAlbum != 0 {
|
||||||
|
artist.attrs = append(artist.attrs, at("coverArt", albumID(a.coverAlbum)))
|
||||||
|
}
|
||||||
|
current.add(artist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexLetter returns the letter an artist files under, skipping a leading
|
||||||
|
// ignored article ("The Beatles" -> B) and folding anything non-alphabetic
|
||||||
|
// into "#".
|
||||||
|
func indexLetter(name string) string {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
for _, article := range strings.Fields(ignoredArticles) {
|
||||||
|
prefix := strings.ToLower(article) + " "
|
||||||
|
if strings.HasPrefix(lower, prefix) {
|
||||||
|
name = name[len(prefix):]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range name {
|
||||||
|
if unicode.IsLetter(r) {
|
||||||
|
return strings.ToUpper(string(r))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return "#"
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstCover(albums []albumInfo) int64 {
|
||||||
|
for _, a := range albums {
|
||||||
|
if a.HasCover {
|
||||||
|
return a.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── id resolution ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// resolveAudioFile turns a Subsonic song/album id into an absolute path on
|
||||||
|
// disk, after the library-access chokepoint.
|
||||||
|
func (h *Handlers) resolveAudioFile(ctx context.Context, u auth.User, id string) (string, bool, error) {
|
||||||
|
var (
|
||||||
|
rel string
|
||||||
|
lib int64
|
||||||
|
)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(id, prefixFile):
|
||||||
|
fileIDVal, ok := parseID(id, prefixFile)
|
||||||
|
if !ok {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
t, ok, err := h.loadTrack(ctx, u, fileIDVal)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
rel, lib = t.RelPath, t.LibraryID
|
||||||
|
case strings.HasPrefix(id, prefixAlbum):
|
||||||
|
itemID, ok := parseID(id, prefixAlbum)
|
||||||
|
if !ok {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
a, ok, err := h.loadAlbum(ctx, u, itemID)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
tracks, err := h.tracksForItem(ctx, itemID)
|
||||||
|
if err != nil || len(tracks) == 0 {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
rel, lib = tracks[0].RelPath, a.LibraryID
|
||||||
|
default:
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
root, err := h.libraryRoot(ctx, lib)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return filepath.Join(root, filepath.FromSlash(rel)), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// coverItemID maps a coverArt id to the media_items row that owns the cover.
|
||||||
|
// Artist covers are not produced by any extractor, so an ar- id resolves to
|
||||||
|
// nothing rather than an arbitrary album.
|
||||||
|
func (h *Handlers) coverItemID(ctx context.Context, u auth.User, id string) (int64, bool, error) {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(id, prefixAlbum):
|
||||||
|
itemID, ok := parseID(id, prefixAlbum)
|
||||||
|
if !ok {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
if _, ok, err := h.loadAlbum(ctx, u, itemID); err != nil || !ok {
|
||||||
|
return 0, false, err
|
||||||
|
}
|
||||||
|
return itemID, true, nil
|
||||||
|
case strings.HasPrefix(id, prefixFile):
|
||||||
|
fileIDVal, ok := parseID(id, prefixFile)
|
||||||
|
if !ok {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
t, ok, err := h.loadTrack(ctx, u, fileIDVal)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return 0, false, err
|
||||||
|
}
|
||||||
|
return t.ItemID, true, nil
|
||||||
|
default:
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── small helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// inClause builds a placeholder list for an IN (...) predicate. An empty set
|
||||||
|
// yields "NULL", which matches nothing — never "all", which is the whole point
|
||||||
|
// of the access-control model.
|
||||||
|
func inClause(ids []int64) (string, []any) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return "NULL", nil
|
||||||
|
}
|
||||||
|
ph := make([]string, len(ids))
|
||||||
|
args := make([]any, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
ph[i] = "?"
|
||||||
|
args[i] = id
|
||||||
|
}
|
||||||
|
return strings.Join(ph, ","), args
|
||||||
|
}
|
||||||
|
|
||||||
|
func intParam(r *http.Request, name string, def, max int) int {
|
||||||
|
v := r.FormValue(name)
|
||||||
|
if v == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil || n < 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
if n > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func countOffset(r *http.Request, countName, offsetName string) (int, int) {
|
||||||
|
return intParam(r, countName, 20, 500), intParam(r, offsetName, 0, 1_000_000)
|
||||||
|
}
|
||||||
102
internal/subsonic/subsonic.go
Normal file
102
internal/subsonic/subsonic.go
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
// Package subsonic translates Vellum's storage into the frozen Subsonic REST
|
||||||
|
// API (1.16.1) that real clients — Symfonium, Ultrasonic, play:Sub, DSub,
|
||||||
|
// Substreamer, Feishin — speak to browse and stream.
|
||||||
|
//
|
||||||
|
// It is a translation layer, not a second media server. Every query goes
|
||||||
|
// through media_items / media_files / people / item_people / assets, and every
|
||||||
|
// request resolves to an auth.User through the same grant chokepoint the web
|
||||||
|
// app uses (auth.VisibleLibraryIDs / auth.CanAccessLibrary). Credentials are
|
||||||
|
// apitoken.KindSubsonic tokens, so revoking one in the web UI revokes the
|
||||||
|
// client too.
|
||||||
|
//
|
||||||
|
// # Authentication
|
||||||
|
//
|
||||||
|
// Subsonic carries its credentials on every request, not in a cookie: u names
|
||||||
|
// the user and one of p (the key directly), t=md5(key+salt) with s, or apiKey.
|
||||||
|
// A bad credential is a Subsonic error envelope with code 40, never an HTTP
|
||||||
|
// 401 — clients read the envelope and many treat a non-200 as a network fault.
|
||||||
|
//
|
||||||
|
// # No transcoding
|
||||||
|
//
|
||||||
|
// Vellum runs on a headless, GPU-less host. stream serves the original file
|
||||||
|
// (format=raw) with full Range support so seeking works, and refuses a request
|
||||||
|
// for any other target format with a clear error instead of pretending. ffmpeg
|
||||||
|
// is never invoked.
|
||||||
|
//
|
||||||
|
// # Root mounting
|
||||||
|
//
|
||||||
|
// Routes live under /rest/ at the ROOT because that is where Subsonic clients
|
||||||
|
// look. internal/httpapi/static.go owns "/", "/healthz" and "/api/"; "/rest/"
|
||||||
|
// is a more specific ServeMux pattern, so requests route here first and the SPA
|
||||||
|
// fallback is never shadowed. isAPIPath in static.go does not know /rest/, so
|
||||||
|
// this package installs its own JSON 404 for the subtree rather than letting an
|
||||||
|
// unknown /rest/... path fall through to the SPA shell.
|
||||||
|
package subsonic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// protocolVersion is the Subsonic API version this layer implements. It is
|
||||||
|
// advertised in every envelope.
|
||||||
|
protocolVersion = "1.16.1"
|
||||||
|
// serverType identifies the server implementation in the envelope.
|
||||||
|
serverType = "vellum"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServerVersion is advertised as serverVersion in the envelope. The main
|
||||||
|
// binary may set it to its build version; it defaults to "dev".
|
||||||
|
var ServerVersion = "dev"
|
||||||
|
|
||||||
|
// Subsonic error codes. The protocol defines these; clients branch on them.
|
||||||
|
const (
|
||||||
|
errGeneric = 0 // a generic error
|
||||||
|
errMissingParameter = 10 // required parameter is missing
|
||||||
|
errWrongCredentials = 40 // wrong username or password
|
||||||
|
errNotAuthorized = 50 // user is not authorized for the operation
|
||||||
|
errDataNotFound = 70 // the requested data was not found
|
||||||
|
)
|
||||||
|
|
||||||
|
// envelope starts a response. XML needs the namespace; nodeJSON drops it.
|
||||||
|
func envelope(status string) *node {
|
||||||
|
return el("subsonic-response",
|
||||||
|
at("xmlns", "http://subsonic.org/restapi"),
|
||||||
|
at("status", status),
|
||||||
|
at("version", protocolVersion),
|
||||||
|
at("type", serverType),
|
||||||
|
at("serverVersion", ServerVersion),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantsJSON reports whether the caller asked for the JSON form. The default,
|
||||||
|
// per the spec, is XML. jsonp is treated as json: the wrapping callback is a
|
||||||
|
// browser convenience this LAN server does not need, and returning the plain
|
||||||
|
// JSON body is still parseable by a jsonp client that ignores the callback.
|
||||||
|
func wantsJSON(r *http.Request) bool {
|
||||||
|
switch strings.ToLower(r.FormValue("f")) {
|
||||||
|
case "json", "jsonp":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// write serialises a response in the requested format.
|
||||||
|
func (h *Handlers) write(w http.ResponseWriter, r *http.Request, root *node) {
|
||||||
|
if wantsJSON(r) {
|
||||||
|
writeJSONResponse(w, root)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeXMLResponse(w, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fail writes a Subsonic error envelope. The HTTP status stays 200 on purpose:
|
||||||
|
// Subsonic clients read the envelope's status/error, and a non-200 is often
|
||||||
|
// surfaced as a transport error rather than "wrong password".
|
||||||
|
func (h *Handlers) fail(w http.ResponseWriter, r *http.Request, code int, msg string) {
|
||||||
|
root := envelope("failed")
|
||||||
|
root.add(el("error", at("code", code), at("message", msg)))
|
||||||
|
h.write(w, r, root)
|
||||||
|
}
|
||||||
501
internal/subsonic/subsonic_test.go
Normal file
501
internal/subsonic/subsonic_test.go
Normal file
|
|
@ -0,0 +1,501 @@
|
||||||
|
package subsonic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mithral/vellum/internal/apitoken"
|
||||||
|
"github.com/mithral/vellum/internal/auth"
|
||||||
|
"github.com/mithral/vellum/internal/core"
|
||||||
|
"github.com/mithral/vellum/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fixture is one seeded Subsonic server.
|
||||||
|
type fixture struct {
|
||||||
|
db *sql.DB
|
||||||
|
userID int64
|
||||||
|
key string
|
||||||
|
libID int64
|
||||||
|
album int64
|
||||||
|
file int64
|
||||||
|
root string
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := core.OpenDB(filepath.Join(t.TempDir(), "subsonic.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
if err := core.Migrate(context.Background(), db, migrations.FS, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
|
||||||
|
t.Fatalf("Migrate: %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
// newFixture seeds one non-admin user with a granted music library holding a
|
||||||
|
// single three-track album, an artist credit and a cover.
|
||||||
|
func newFixture(t *testing.T) (*httptest.Server, *fixture) {
|
||||||
|
t.Helper()
|
||||||
|
db := testDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
for _, name := range []string{
|
||||||
|
"Artist/Album/01 - One.mp3",
|
||||||
|
"Artist/Album/02 - Two.mp3",
|
||||||
|
"Artist/Album/03 - Three.mp3",
|
||||||
|
} {
|
||||||
|
p := filepath.Join(root, filepath.FromSlash(name))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 100 deterministic bytes so a Range read is verifiable.
|
||||||
|
if err := os.WriteFile(p, []byte(strings.Repeat("v", 100)), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
coverPath := filepath.Join(root, "cover.png")
|
||||||
|
if err := os.WriteFile(coverPath, []byte("\x89PNG\r\n\x1a\ncoverbytes"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := auth.CreateUser(ctx, db, "alice", "Alice", "password123", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var libID int64
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`INSERT INTO libraries (name, kind, root_path) VALUES ('Music','music',?) RETURNING id`,
|
||||||
|
root).Scan(&libID); err != nil {
|
||||||
|
t.Fatalf("insert library: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx,
|
||||||
|
`INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?,?,1,1)`,
|
||||||
|
user.ID, libID); err != nil {
|
||||||
|
t.Fatalf("grant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var albumID int64
|
||||||
|
if err := db.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO media_items (library_id, kind, path, title, sort_title, year)
|
||||||
|
VALUES (?, 'music', 'Artist/Album', 'Album', 'album', 2013) RETURNING id`,
|
||||||
|
libID).Scan(&albumID); err != nil {
|
||||||
|
t.Fatalf("insert album: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstFile int64
|
||||||
|
for i, name := range []string{
|
||||||
|
"Artist/Album/01 - One.mp3",
|
||||||
|
"Artist/Album/02 - Two.mp3",
|
||||||
|
"Artist/Album/03 - Three.mp3",
|
||||||
|
} {
|
||||||
|
var id int64
|
||||||
|
if err := db.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO media_files (item_id, path, size, container, duration_ms)
|
||||||
|
VALUES (?,?,100,'mp3',60000) RETURNING id`,
|
||||||
|
albumID, name).Scan(&id); err != nil {
|
||||||
|
t.Fatalf("insert file: %v", err)
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
firstFile = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var personID int64
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`INSERT INTO people (name) VALUES ('The Artist') RETURNING id`).Scan(&personID); err != nil {
|
||||||
|
t.Fatalf("insert person: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx,
|
||||||
|
`INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?,?,'author',0)`,
|
||||||
|
albumID, personID); err != nil {
|
||||||
|
t.Fatalf("insert credit: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx,
|
||||||
|
`INSERT INTO assets (item_id, role, path, bytes) VALUES (?, 'cover', ?, 20)`,
|
||||||
|
albumID, coverPath); err != nil {
|
||||||
|
t.Fatalf("insert asset: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := apitoken.Mint(ctx, db, user.ID, apitoken.KindSubsonic, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Mint: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticator := auth.NewAuthenticator(db, "vellum_session", 0, false, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
h := NewHandlers(db, slog.New(slog.NewTextHandler(io.Discard, nil)), authenticator)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.Register(mux)
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
return srv, &fixture{db: db, userID: user.ID, key: key, libID: libID, album: albumID, file: firstFile, root: root}
|
||||||
|
}
|
||||||
|
|
||||||
|
// authQuery is the preferred t=md5(key+salt) credential form.
|
||||||
|
func (f *fixture) authQuery() string {
|
||||||
|
salt := "abc123"
|
||||||
|
sum := md5.Sum([]byte(f.key + salt))
|
||||||
|
return "u=alice&s=" + salt + "&t=" + hex.EncodeToString(sum[:]) + "&v=1.16.1&c=test"
|
||||||
|
}
|
||||||
|
|
||||||
|
// get performs a request and returns the recorder.
|
||||||
|
func get(t *testing.T, url string) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET %s: %v", url, err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBody(t *testing.T, resp *http.Response) string {
|
||||||
|
t.Helper()
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read body: %v", err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonEnvelope decodes a Subsonic JSON response into the root object.
|
||||||
|
func jsonEnvelope(t *testing.T, body string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var out map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||||
|
t.Fatalf("decode json %q: %v", body, err)
|
||||||
|
}
|
||||||
|
root, ok := out["subsonic-response"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing subsonic-response in %q", body)
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── auth ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// TestAuthTokenForm proves the preferred md5(key+salt) path succeeds.
|
||||||
|
func TestAuthTokenForm(t *testing.T) {
|
||||||
|
srv, f := newFixture(t)
|
||||||
|
body := readBody(t, get(t, srv.URL+"/rest/ping.view?"+f.authQuery()+"&f=json"))
|
||||||
|
root := jsonEnvelope(t, body)
|
||||||
|
if root["status"] != "ok" {
|
||||||
|
t.Fatalf("status = %v, want ok; body=%s", root["status"], body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuthWrongTokenIsEnvelope pins the single most common integration failure:
|
||||||
|
// a bad token is HTTP 200 with status failed and error code 40, never a 401.
|
||||||
|
func TestAuthWrongTokenIsEnvelope(t *testing.T) {
|
||||||
|
srv, _ := newFixture(t)
|
||||||
|
resp := get(t, srv.URL+"/rest/ping.view?u=alice&s=abc123&t=deadbeef&v=1.16.1&c=test&f=json")
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("HTTP status = %d, want 200 (errors belong in the envelope)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
body := readBody(t, resp)
|
||||||
|
root := jsonEnvelope(t, body)
|
||||||
|
if root["status"] != "failed" {
|
||||||
|
t.Fatalf("status = %v, want failed; body=%s", root["status"], body)
|
||||||
|
}
|
||||||
|
errObj, ok := root["error"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing error object; body=%s", body)
|
||||||
|
}
|
||||||
|
if code, _ := errObj["code"].(float64); int(code) != 40 {
|
||||||
|
t.Errorf("error code = %v, want 40; body=%s", errObj["code"], body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuthPlainKey proves p=<key> is accepted (Navidrome compatibility).
|
||||||
|
func TestAuthPlainKey(t *testing.T) {
|
||||||
|
srv, f := newFixture(t)
|
||||||
|
body := readBody(t, get(t, srv.URL+"/rest/ping.view?u=alice&p="+f.key+"&v=1.16.1&c=test&f=json"))
|
||||||
|
if root := jsonEnvelope(t, body); root["status"] != "ok" {
|
||||||
|
t.Fatalf("p=<key> status = %v, want ok; body=%s", root["status"], body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuthUnknownUserIsCode40 proves an unknown user is the same envelope error.
|
||||||
|
func TestAuthUnknownUserIsCode40(t *testing.T) {
|
||||||
|
srv, _ := newFixture(t)
|
||||||
|
body := readBody(t, get(t, srv.URL+"/rest/ping.view?u=nobody&p=x&v=1.16.1&c=test&f=json"))
|
||||||
|
root := jsonEnvelope(t, body)
|
||||||
|
if root["status"] != "failed" {
|
||||||
|
t.Fatalf("unknown user status = %v, want failed", root["status"])
|
||||||
|
}
|
||||||
|
if code, _ := root["error"].(map[string]any)["code"].(float64); int(code) != 40 {
|
||||||
|
t.Errorf("unknown user code = %v, want 40", root["error"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── system / browse ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestPingJSONAndXML(t *testing.T) {
|
||||||
|
srv, f := newFixture(t)
|
||||||
|
|
||||||
|
jroot := jsonEnvelope(t, readBody(t, get(t, srv.URL+"/rest/ping.view?"+f.authQuery()+"&f=json")))
|
||||||
|
if jroot["status"] != "ok" || jroot["version"] != protocolVersion || jroot["type"] != serverType {
|
||||||
|
t.Errorf("json envelope = %v", jroot)
|
||||||
|
}
|
||||||
|
|
||||||
|
xmlBody := readBody(t, get(t, srv.URL+"/rest/ping.view?"+f.authQuery()+"&f=xml"))
|
||||||
|
var parsed struct {
|
||||||
|
XMLName xml.Name `xml:"subsonic-response"`
|
||||||
|
Status string `xml:"status,attr"`
|
||||||
|
Version string `xml:"version,attr"`
|
||||||
|
}
|
||||||
|
if err := xml.Unmarshal([]byte(xmlBody), &parsed); err != nil {
|
||||||
|
t.Fatalf("xml parse: %v; body=%s", err, xmlBody)
|
||||||
|
}
|
||||||
|
if parsed.Status != "ok" || parsed.Version != protocolVersion {
|
||||||
|
t.Errorf("xml envelope = %+v", parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMusicFolders(t *testing.T) {
|
||||||
|
srv, f := newFixture(t)
|
||||||
|
body := readBody(t, get(t, srv.URL+"/rest/getMusicFolders.view?"+f.authQuery()+"&f=json"))
|
||||||
|
root := jsonEnvelope(t, body)
|
||||||
|
folders, ok := root["musicFolders"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing musicFolders; body=%s", body)
|
||||||
|
}
|
||||||
|
list, ok := folders["musicFolder"].([]any)
|
||||||
|
if !ok || len(list) != 1 {
|
||||||
|
t.Fatalf("musicFolder = %v, want one entry; body=%s", folders["musicFolder"], body)
|
||||||
|
}
|
||||||
|
if list[0].(map[string]any)["name"] != "Music" {
|
||||||
|
t.Errorf("folder = %v", list[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetIndexesAndArtists(t *testing.T) {
|
||||||
|
srv, f := newFixture(t)
|
||||||
|
|
||||||
|
body := readBody(t, get(t, srv.URL+"/rest/getArtists.view?"+f.authQuery()+"&f=json"))
|
||||||
|
root := jsonEnvelope(t, body)
|
||||||
|
artists := root["artists"].(map[string]any)
|
||||||
|
indexes := artists["index"].([]any)
|
||||||
|
if len(indexes) != 1 {
|
||||||
|
t.Fatalf("indexes = %v", indexes)
|
||||||
|
}
|
||||||
|
first := indexes[0].(map[string]any)
|
||||||
|
if first["name"] != "A" {
|
||||||
|
t.Errorf("index name = %v, want A (The Artist)", first["name"])
|
||||||
|
}
|
||||||
|
if first["artist"].([]any)[0].(map[string]any)["name"] != "The Artist" {
|
||||||
|
t.Errorf("artist = %v", first["artist"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// getIndexes returns the same index but a leaner artist element.
|
||||||
|
body = readBody(t, get(t, srv.URL+"/rest/getIndexes.view?"+f.authQuery()+"&f=json"))
|
||||||
|
root = jsonEnvelope(t, body)
|
||||||
|
if _, ok := root["indexes"]; !ok {
|
||||||
|
t.Fatalf("missing indexes; body=%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch3(t *testing.T) {
|
||||||
|
srv, f := newFixture(t)
|
||||||
|
body := readBody(t, get(t, srv.URL+"/rest/search3.view?"+f.authQuery()+"&query=Artist&f=json"))
|
||||||
|
root := jsonEnvelope(t, body)
|
||||||
|
result := root["searchResult3"].(map[string]any)
|
||||||
|
|
||||||
|
albums := result["album"].([]any)
|
||||||
|
if len(albums) != 1 {
|
||||||
|
t.Fatalf("albums = %v, want one; body=%s", albums, body)
|
||||||
|
}
|
||||||
|
if albums[0].(map[string]any)["name"] != "Album" {
|
||||||
|
t.Errorf("album = %v", albums[0])
|
||||||
|
}
|
||||||
|
if len(result["song"].([]any)) != 3 {
|
||||||
|
t.Errorf("songs = %v, want three", result["song"])
|
||||||
|
}
|
||||||
|
if len(result["artist"].([]any)) != 1 {
|
||||||
|
t.Errorf("artists = %v, want one", result["artist"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAlbumList2Newest(t *testing.T) {
|
||||||
|
srv, f := newFixture(t)
|
||||||
|
body := readBody(t, get(t, srv.URL+"/rest/getAlbumList2.view?"+f.authQuery()+"&type=newest&f=json"))
|
||||||
|
root := jsonEnvelope(t, body)
|
||||||
|
list := root["albumList2"].(map[string]any)
|
||||||
|
albums := list["album"].([]any)
|
||||||
|
if len(albums) != 1 {
|
||||||
|
t.Fatalf("albums = %v; body=%s", albums, body)
|
||||||
|
}
|
||||||
|
if albums[0].(map[string]any)["songCount"].(float64) != 3 {
|
||||||
|
t.Errorf("songCount = %v, want 3", albums[0].(map[string]any)["songCount"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAlbumAndSong(t *testing.T) {
|
||||||
|
srv, f := newFixture(t)
|
||||||
|
|
||||||
|
body := readBody(t, get(t, srv.URL+"/rest/getAlbum.view?"+f.authQuery()+"&id="+albumID(f.album)+"&f=json"))
|
||||||
|
root := jsonEnvelope(t, body)
|
||||||
|
album := root["album"].(map[string]any)
|
||||||
|
if len(album["song"].([]any)) != 3 {
|
||||||
|
t.Errorf("getAlbum songs = %v", album["song"])
|
||||||
|
}
|
||||||
|
|
||||||
|
body = readBody(t, get(t, srv.URL+"/rest/getSong.view?"+f.authQuery()+"&id="+fileID(f.file)+"&f=json"))
|
||||||
|
root = jsonEnvelope(t, body)
|
||||||
|
song := root["song"].(map[string]any)
|
||||||
|
if song["album"] != "Album" || song["artist"] != "The Artist" {
|
||||||
|
t.Errorf("song = %v", song)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMusicDirectory(t *testing.T) {
|
||||||
|
srv, f := newFixture(t)
|
||||||
|
body := readBody(t, get(t, srv.URL+"/rest/getMusicDirectory.view?"+f.authQuery()+"&id="+folderID(f.libID)+"&f=json"))
|
||||||
|
root := jsonEnvelope(t, body)
|
||||||
|
dir := root["directory"].(map[string]any)
|
||||||
|
children := dir["child"].([]any)
|
||||||
|
if len(children) != 1 || children[0].(map[string]any)["isDir"] != true {
|
||||||
|
t.Fatalf("root children = %v; body=%s", children, body)
|
||||||
|
}
|
||||||
|
// Drill into the album directory and confirm its songs appear.
|
||||||
|
body = readBody(t, get(t, srv.URL+"/rest/getMusicDirectory.view?"+f.authQuery()+"&id="+albumID(f.album)+"&f=json"))
|
||||||
|
root = jsonEnvelope(t, body)
|
||||||
|
dir = root["directory"].(map[string]any)
|
||||||
|
if len(dir["child"].([]any)) != 3 {
|
||||||
|
t.Errorf("album directory children = %v", dir["child"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUser(t *testing.T) {
|
||||||
|
srv, f := newFixture(t)
|
||||||
|
body := readBody(t, get(t, srv.URL+"/rest/getUser.view?"+f.authQuery()+"&f=json"))
|
||||||
|
root := jsonEnvelope(t, body)
|
||||||
|
user := root["user"].(map[string]any)
|
||||||
|
if user["username"] != "alice" {
|
||||||
|
t.Errorf("username = %v", user["username"])
|
||||||
|
}
|
||||||
|
if user["adminRole"] != false || user["streamRole"] != true {
|
||||||
|
t.Errorf("roles = %v", user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPlaylistsEmpty(t *testing.T) {
|
||||||
|
srv, f := newFixture(t)
|
||||||
|
body := readBody(t, get(t, srv.URL+"/rest/getPlaylists.view?"+f.authQuery()+"&f=json"))
|
||||||
|
root := jsonEnvelope(t, body)
|
||||||
|
pl, ok := root["playlists"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing playlists; body=%s", body)
|
||||||
|
}
|
||||||
|
if list, ok := pl["playlist"].([]any); !ok || len(list) != 0 {
|
||||||
|
t.Errorf("playlist = %v, want empty array", pl["playlist"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── media ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestStreamRawAndRange(t *testing.T) {
|
||||||
|
srv, f := newFixture(t)
|
||||||
|
|
||||||
|
resp := get(t, srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+fileID(f.file)+"&format=raw")
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("stream status = %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if got := readBody(t, resp); len(got) != 100 {
|
||||||
|
t.Fatalf("stream body length = %d, want 100", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet,
|
||||||
|
srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+fileID(f.file)+"&format=raw", nil)
|
||||||
|
req.Header.Set("Range", "bytes=0-9")
|
||||||
|
rangeResp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("range request: %v", err)
|
||||||
|
}
|
||||||
|
defer rangeResp.Body.Close()
|
||||||
|
if rangeResp.StatusCode != http.StatusPartialContent {
|
||||||
|
t.Fatalf("range status = %d, want 206", rangeResp.StatusCode)
|
||||||
|
}
|
||||||
|
if cr := rangeResp.Header.Get("Content-Range"); cr != "bytes 0-9/100" {
|
||||||
|
t.Errorf("Content-Range = %q, want bytes 0-9/100", cr)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(rangeResp.Body)
|
||||||
|
if len(body) != 10 {
|
||||||
|
t.Errorf("range body length = %d, want 10", len(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStreamRejectsTranscode proves a non-raw format is refused honestly rather
|
||||||
|
// than silently served untranscoded.
|
||||||
|
func TestStreamRejectsTranscode(t *testing.T) {
|
||||||
|
srv, f := newFixture(t)
|
||||||
|
body := readBody(t, get(t, srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+fileID(f.file)+"&format=mp3&f=json"))
|
||||||
|
root := jsonEnvelope(t, body)
|
||||||
|
if root["status"] != "failed" {
|
||||||
|
t.Fatalf("format=mp3 status = %v, want failed; body=%s", root["status"], body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(root["error"].(map[string]any)["message"].(string), "transcod") {
|
||||||
|
t.Errorf("error message = %v, want a transcoding refusal", root["error"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCoverArt(t *testing.T) {
|
||||||
|
srv, f := newFixture(t)
|
||||||
|
resp := get(t, srv.URL+"/rest/getCoverArt.view?"+f.authQuery()+"&id="+albumID(f.album))
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("cover status = %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if got := readBody(t, resp); !strings.HasPrefix(got, "\x89PNG") {
|
||||||
|
t.Errorf("cover bytes = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScrobbleWritesProgress proves scrobble lands in the progress store.
|
||||||
|
func TestScrobbleWritesProgress(t *testing.T) {
|
||||||
|
srv, f := newFixture(t)
|
||||||
|
body := readBody(t, get(t, srv.URL+"/rest/scrobble.view?"+f.authQuery()+
|
||||||
|
"&id="+fileID(f.file)+"&submission=true&position=30000&f=json"))
|
||||||
|
if root := jsonEnvelope(t, body); root["status"] != "ok" {
|
||||||
|
t.Fatalf("scrobble status = %v; body=%s", root["status"], body)
|
||||||
|
}
|
||||||
|
|
||||||
|
var position, duration int64
|
||||||
|
if err := f.db.QueryRow(
|
||||||
|
`SELECT position, duration FROM progress WHERE user_id = ? AND item_id = ? AND file_id = ?`,
|
||||||
|
f.userID, f.album, f.file).Scan(&position, &duration); err != nil {
|
||||||
|
t.Fatalf("progress row: %v", err)
|
||||||
|
}
|
||||||
|
if position != 30000 || duration != 60000 {
|
||||||
|
t.Errorf("progress = (%d,%d), want (30000,60000)", position, duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAccessScopedToGrants proves a user without a grant cannot stream.
|
||||||
|
func TestAccessScopedToGrants(t *testing.T) {
|
||||||
|
srv, f := newFixture(t)
|
||||||
|
// Revoke the grant, then request the same file.
|
||||||
|
if _, err := f.db.Exec(`DELETE FROM library_grants WHERE user_id = ?`, f.userID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body := readBody(t, get(t, srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+fileID(f.file)+"&format=raw&f=json"))
|
||||||
|
root := jsonEnvelope(t, body)
|
||||||
|
if root["status"] != "failed" {
|
||||||
|
t.Fatalf("revoked grant stream status = %v, want failed; body=%s", root["status"], body)
|
||||||
|
}
|
||||||
|
}
|
||||||
34
migrations/0012_api_tokens.sql
Normal file
34
migrations/0012_api_tokens.sql
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
-- 0012_api_tokens.sql — tokens for non-browser clients.
|
||||||
|
--
|
||||||
|
-- Jellyfin clients send `X-Emby-Token`, Subsonic clients send `t=`, and neither
|
||||||
|
-- speaks the session cookie the web app uses. Rather than weaken the cookie
|
||||||
|
-- model, these live beside it: a token is a second, revocable way to be a user,
|
||||||
|
-- and every request still resolves to the same auth.User and goes through the
|
||||||
|
-- same grant chokepoint.
|
||||||
|
--
|
||||||
|
-- Two storage shapes, for a real reason:
|
||||||
|
--
|
||||||
|
-- * Jellyfin sends the token as a bearer value, so only a hash is needed —
|
||||||
|
-- same reasoning as sessions. A stolen database yields no usable token.
|
||||||
|
-- * Subsonic's `t` parameter is md5(apiKey + salt), and the server must
|
||||||
|
-- compute that to compare. That requires the KEY itself, so a Subsonic key
|
||||||
|
-- is stored in the clear. It is a generated random secret, never a user's
|
||||||
|
-- password, and it is revocable — but it is worth being explicit that this
|
||||||
|
-- column is the one place a usable credential sits in the database.
|
||||||
|
--
|
||||||
|
-- `token_plain` is therefore empty for Jellyfin and populated for Subsonic.
|
||||||
|
|
||||||
|
CREATE TABLE api_tokens (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL, -- 'jellyfin' | 'subsonic'
|
||||||
|
label TEXT NOT NULL DEFAULT '', -- shown in the UI, e.g. "Living room TV"
|
||||||
|
token_hash TEXT NOT NULL, -- sha256 hex, used for lookup
|
||||||
|
token_plain TEXT NOT NULL DEFAULT '', -- Subsonic only; see above
|
||||||
|
created_at TEXT NOT NULL DEFAULT '',
|
||||||
|
last_used_at TEXT NOT NULL DEFAULT '',
|
||||||
|
expires_at TEXT NOT NULL DEFAULT '' -- '' means never
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_api_tokens_hash ON api_tokens(token_hash);
|
||||||
|
CREATE INDEX idx_api_tokens_user ON api_tokens(user_id, kind);
|
||||||
Loading…
Add table
Reference in a new issue