- Switch servers from the browser (s) with auto-discovery; auto-fallback to discovery when a saved server is unreachable, so you're never locked to a rebuilt box. cliamp's Jellyfin provider is re-synced on every (re)login. - Ignore late LAN-discovery results once off the login screen (footer/input clobber fix). - install.sh: curl|bash one-liner that downloads the per-arch release binary, still builds from a clone when Go is present; adds a floating Walker entry (TUI.float app-id) + icon on Omarchy desktops. - Docs: USER_GUIDE.md plain-English walkthrough; README install one-liner, accurate keybindings, features section. - Bump version to 1.0.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
220 lines
7.6 KiB
Go
220 lines
7.6 KiB
Go
// Package api is a minimal Jellyfin REST client: just enough to authenticate,
|
|
// list libraries and their contents, resolve a direct-play stream URL for mpv,
|
|
// and report playback progress so "Continue Watching" and resume points work.
|
|
//
|
|
// All requests carry the Emby-style Authorization header. Before login only the
|
|
// client/device fields are set; after login the token is added as Token=.
|
|
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Version is stamped into the Authorization header and User-Agent.
|
|
const Version = "1.0.0"
|
|
|
|
// Client talks to one Jellyfin server as one user.
|
|
type Client struct {
|
|
BaseURL string // e.g. https://media.example.com (no trailing slash)
|
|
Token string // access token; empty before login
|
|
UserID string // logged-in user id; empty before login
|
|
DeviceID string // stable per-install id
|
|
http *http.Client
|
|
}
|
|
|
|
// New builds a Client. baseURL may have a trailing slash; it is trimmed.
|
|
func New(baseURL, token, userID, deviceID string) *Client {
|
|
return &Client{
|
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
|
Token: token,
|
|
UserID: userID,
|
|
DeviceID: deviceID,
|
|
http: &http.Client{Timeout: 30 * time.Second},
|
|
}
|
|
}
|
|
|
|
// authHeader builds the MediaBrowser authorization header. Token is included
|
|
// only once we have one (post-login).
|
|
func (c *Client) authHeader() string {
|
|
h := fmt.Sprintf(`MediaBrowser Client="tsplay", Device="tsplay-tui", DeviceId=%q, Version=%q`,
|
|
c.DeviceID, Version)
|
|
if c.Token != "" {
|
|
h += fmt.Sprintf(`, Token=%q`, c.Token)
|
|
}
|
|
return h
|
|
}
|
|
|
|
func (c *Client) do(ctx context.Context, method, path string, body any, out any) error {
|
|
var rdr io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rdr = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rdr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", c.authHeader())
|
|
req.Header.Set("User-Agent", "tsplay/"+Version)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
return fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, strings.TrimSpace(string(msg)))
|
|
}
|
|
if out != nil {
|
|
return json.NewDecoder(resp.Body).Decode(out)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AuthResult is the subset of AuthenticateByName we care about.
|
|
type AuthResult struct {
|
|
AccessToken string `json:"AccessToken"`
|
|
User struct {
|
|
ID string `json:"Id"`
|
|
Name string `json:"Name"`
|
|
} `json:"User"`
|
|
}
|
|
|
|
// Login authenticates by username/password and populates Token/UserID on the
|
|
// client. Returns the result so the caller can persist it.
|
|
func (c *Client) Login(ctx context.Context, username, password string) (*AuthResult, error) {
|
|
var res AuthResult
|
|
body := map[string]string{"Username": username, "Pw": password}
|
|
if err := c.do(ctx, http.MethodPost, "/Users/AuthenticateByName", body, &res); err != nil {
|
|
return nil, err
|
|
}
|
|
if res.AccessToken == "" {
|
|
return nil, fmt.Errorf("login failed: no access token returned")
|
|
}
|
|
c.Token = res.AccessToken
|
|
c.UserID = res.User.ID
|
|
return &res, nil
|
|
}
|
|
|
|
// Item is a generic Jellyfin item (library, folder, movie, episode, audio…).
|
|
// Only the fields the TUI needs are mapped.
|
|
type Item struct {
|
|
ID string `json:"Id"`
|
|
Name string `json:"Name"`
|
|
Type string `json:"Type"` // Movie, Series, Season, Episode, Audio, MusicAlbum, CollectionFolder…
|
|
CollectionType string `json:"CollectionType"` // movies, tvshows, music… (on libraries)
|
|
IsFolder bool `json:"IsFolder"`
|
|
ProductionYear int `json:"ProductionYear"`
|
|
RunTimeTicks int64 `json:"RunTimeTicks"` // 1 tick = 100ns
|
|
SeriesName string `json:"SeriesName"`
|
|
IndexNumber int `json:"IndexNumber"` // episode/track number
|
|
ParentIndexNumber int `json:"ParentIndexNumber"` // season number
|
|
UserData struct {
|
|
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
|
|
PlayedPercentage float64 `json:"PlayedPercentage"`
|
|
Played bool `json:"Played"`
|
|
} `json:"UserData"`
|
|
}
|
|
|
|
// Playable reports whether selecting this item should start mpv rather than
|
|
// drill into a folder.
|
|
func (i Item) Playable() bool {
|
|
switch i.Type {
|
|
case "Movie", "Episode", "Audio", "Video", "MusicVideo":
|
|
return true
|
|
}
|
|
return !i.IsFolder
|
|
}
|
|
|
|
// AudioOnly reports whether this item is music (so mpv should run headless,
|
|
// with no video window). MusicVideo is intentionally excluded — it has a
|
|
// picture worth showing.
|
|
func (i Item) AudioOnly() bool {
|
|
switch i.Type {
|
|
case "Audio", "MusicAlbum", "MusicArtist":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
type itemsResponse struct {
|
|
Items []Item `json:"Items"`
|
|
}
|
|
|
|
// Views returns the user's top-level libraries (Movies, Shows, Music…).
|
|
func (c *Client) Views(ctx context.Context) ([]Item, error) {
|
|
var r itemsResponse
|
|
err := c.do(ctx, http.MethodGet, "/Users/"+c.UserID+"/Views", nil, &r)
|
|
return r.Items, err
|
|
}
|
|
|
|
// Children lists the items directly under parentID for this user, sorted
|
|
// sensibly for browsing. recursive=false keeps it one level at a time.
|
|
func (c *Client) Children(ctx context.Context, parentID string) ([]Item, error) {
|
|
q := url.Values{}
|
|
q.Set("ParentId", parentID)
|
|
q.Set("SortBy", "IsFolder,SortName")
|
|
q.Set("SortOrder", "Ascending")
|
|
q.Set("Fields", "ProductionYear,SeriesName,IndexNumber,ParentIndexNumber")
|
|
var r itemsResponse
|
|
err := c.do(ctx, http.MethodGet, "/Users/"+c.UserID+"/Items?"+q.Encode(), nil, &r)
|
|
return r.Items, err
|
|
}
|
|
|
|
// Resume returns the user's "Continue Watching" list across all libraries.
|
|
func (c *Client) Resume(ctx context.Context) ([]Item, error) {
|
|
q := url.Values{}
|
|
q.Set("Limit", "24")
|
|
q.Set("Fields", "ProductionYear,SeriesName,IndexNumber,ParentIndexNumber")
|
|
q.Set("MediaTypes", "Video")
|
|
var r itemsResponse
|
|
err := c.do(ctx, http.MethodGet, "/Users/"+c.UserID+"/Items/Resume?"+q.Encode(), nil, &r)
|
|
return r.Items, err
|
|
}
|
|
|
|
// StreamURL returns a direct-play URL mpv can open. static=true asks the server
|
|
// not to transcode; mpv handles nearly every container/codec natively. The
|
|
// api_key query param authenticates the bare GET that mpv makes.
|
|
func (c *Client) StreamURL(itemID string) string {
|
|
q := url.Values{}
|
|
q.Set("static", "true")
|
|
q.Set("api_key", c.Token)
|
|
return fmt.Sprintf("%s/Videos/%s/stream?%s", c.BaseURL, itemID, q.Encode())
|
|
}
|
|
|
|
// ReportStart tells the server playback began (creates a session row, marks the
|
|
// item as being watched). Best-effort: errors are returned but callers may
|
|
// ignore them.
|
|
func (c *Client) ReportStart(ctx context.Context, itemID string) error {
|
|
body := map[string]any{"ItemId": itemID, "PlayMethod": "DirectStream"}
|
|
return c.do(ctx, http.MethodPost, "/Sessions/Playing", body, nil)
|
|
}
|
|
|
|
// ReportProgress updates the resume point (positionTicks) periodically.
|
|
func (c *Client) ReportProgress(ctx context.Context, itemID string, positionTicks int64, paused bool) error {
|
|
body := map[string]any{"ItemId": itemID, "PositionTicks": positionTicks, "IsPaused": paused, "PlayMethod": "DirectStream"}
|
|
return c.do(ctx, http.MethodPost, "/Sessions/Playing/Progress", body, nil)
|
|
}
|
|
|
|
// ReportStop finalises the resume point when playback ends.
|
|
func (c *Client) ReportStop(ctx context.Context, itemID string, positionTicks int64) error {
|
|
body := map[string]any{"ItemId": itemID, "PositionTicks": positionTicks}
|
|
return c.do(ctx, http.MethodPost, "/Sessions/Playing/Stopped", body, nil)
|
|
}
|
|
|
|
// SecondsToTicks converts a position in seconds to Jellyfin's 100ns ticks.
|
|
func SecondsToTicks(s float64) int64 { return int64(s * 1e7) }
|