tsplay/internal/api/client.go
28allday 8ee58b905f Initial commit: tuistream-play (tsplay) v0.1
Terminal Jellyfin client, watch-side companion to TUISTREAM.

- LAN auto-discovery (broadcast + unicast subnet sweep)
- mpv-window video playback over JSON IPC (pause/seek/progress/resume)
- headless audio playback with in-terminal now-playing screen
- volume control with remembered level, mute
- audio auto-advance + n/p track skip through the album queue
- Omarchy-themed UI with Nerd Font (nf-md) icons
- cliamp Jellyfin provider auto-config on login
- pacman dependency-installing installer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:36:49 +01:00

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 = "0.1.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) }