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 }