// 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) }