clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

main
airwaves / internal / jellyfin / jellyfin.go
jellyfin.go 10.1K
1// Package jellyfin is a small client for a Jellyfin media server: signing2// in, browsing series, movies, collections and playlists, and the URLs that3// stream them. Airwaves uses it to build channels from a friend's library.4//5// The password and access token never appear in errors or URLs; requests6// carry the token in the Authorization header only.7package jellyfin89import (10 "bytes"11 "context"12 "crypto/sha256"13 "encoding/hex"14 "encoding/json"15 "errors"16 "fmt"17 "io"18 "net/http"19 "net/url"20 "os"21 "strings"22 "sync"23 "time"24)2526// Account is how Airwaves signs in to a Jellyfin server: a user and27// password, or a Token, which is an API key or comes from Quick Connect.28type Account struct {29 Server string `json:"server"`30 User string `json:"user,omitempty"`31 Password string `json:"password,omitempty"`32 Token string `json:"token,omitempty"`33 UserID string `json:"userId,omitempty"` // set by Quick Connect along with Token34 DeviceID string `json:"deviceId,omitempty"` // the device Token was issued to35}3637// LoadAccount reads an account file such as <channels>/.jellyfin.json.38func LoadAccount(path string) (Account, error) {39 raw, err := os.ReadFile(path)40 if err != nil {41 return Account{}, err42 }43 var a Account44 if err := json.Unmarshal(raw, &a); err != nil {45 return Account{}, fmt.Errorf("%s: %w", path, err)46 }47 return a, nil48}4950// baseURL is the server address without a trailing slash, https by default.51func (a Account) baseURL() string {52 s := strings.TrimRight(strings.TrimSpace(a.Server), "/")53 if s != "" && !strings.Contains(s, "://") {54 s = "https://" + s55 }56 return s57}5859// clientVersion is the version Airwaves reports to the server.60const clientVersion = "0.2"6162// Client talks to one server as one account. Clients for the same account63// share a sign-in, so several channels on one server log in once.64type Client struct {65 s *session66 hc *http.Client67}6869// session is a sign-in shared by the clients of one account.70type session struct {71 acct Account72 server string // acct.baseURL()73 deviceID string7475 mu sync.Mutex76 token string // the API key, or the access token from signing in77 userID string78 resolved bool // userID is known; API keys may have no user79 // refused is set when the server turned the password down. Signing in80 // isn't tried again until Login or Retry, since repeated failures lock81 // a Jellyfin account.82 refused error83 refusedAt time.Time84}8586var sessions struct {87 sync.Mutex88 m map[string]*session89}9091// defaultHTTP bounds each request, so a stalled server can't hold a92// sign-in, which other clients of the account wait on, for long.93var defaultHTTP = &http.Client{Timeout: time.Minute}9495// New returns a client for acct. deviceID identifies Airwaves to the server96// and should stay the same across restarts; acct.DeviceID takes its place97// when set, and clients sharing a sign-in use the first one's. hc may be98// nil.99func New(acct Account, deviceID string, hc *http.Client) *Client {100 if hc == nil {101 hc = defaultHTTP102 }103 sum := sha256.Sum256([]byte(acct.Password + "\x00" + acct.Token))104 key := acct.baseURL() + "\x00" + strings.ToLower(acct.User) + "\x00" + hex.EncodeToString(sum[:])105 sessions.Lock()106 defer sessions.Unlock()107 if sessions.m == nil {108 sessions.m = map[string]*session{}109 }110 s := sessions.m[key]111 if s == nil {112 if acct.DeviceID != "" {113 deviceID = acct.DeviceID114 }115 s = &session{acct: acct, server: acct.baseURL(), deviceID: strings.ReplaceAll(deviceID, `"`, "")}116 sessions.m[key] = s117 }118 return &Client{s: s, hc: hc}119}120121// Server is the server's base URL.122func (c *Client) Server() string { return c.s.server }123124// Login checks the account works, signing in again even if the server125// refused the password before.126func (c *Client) Login(ctx context.Context) error {127 c.s.mu.Lock()128 c.s.refused = nil129 c.s.mu.Unlock()130 _, err := c.Libraries(ctx)131 return err132}133134// Retry allows signing in again after the server refused the password, if135// the account's details have changed since: changed is when they last did.136func (c *Client) Retry(changed time.Time) {137 c.s.mu.Lock()138 defer c.s.mu.Unlock()139 if c.s.refused != nil && changed.After(c.s.refusedAt) {140 c.s.refused = nil141 }142}143144// Authorization is the Authorization header value for requests made145// outside the client, such as ffmpeg reading a stream. It holds the token:146// never log it.147func (c *Client) Authorization() string {148 c.s.mu.Lock()149 defer c.s.mu.Unlock()150 return c.s.header(c.s.token)151}152153func (s *session) header(token string) string {154 h := `MediaBrowser Client="Airwaves", Device="airwavesd"`155 if s.deviceID != "" {156 h += fmt.Sprintf(`, DeviceId="%s"`, s.deviceID)157 }158 h += fmt.Sprintf(`, Version="%s"`, clientVersion)159 if token != "" {160 h += fmt.Sprintf(`, Token="%s"`, token)161 }162 return h163}164165// signIn returns the access token and user, signing in when there is no166// token yet.167func (c *Client) signIn(ctx context.Context) (token, userID string, err error) {168 s := c.s169 s.mu.Lock()170 defer s.mu.Unlock()171 switch {172 case s.server == "":173 return "", "", fmt.Errorf("jellyfin: no server")174 case s.acct.Token == "" && s.acct.User == "":175 return "", "", fmt.Errorf("jellyfin: %s: no user or token", s.server)176 case s.refused != nil:177 return "", "", s.refused178 }179 if s.acct.Token != "" {180 s.token = s.acct.Token181 if !s.resolved {182 s.userID = s.acct.UserID183 if s.userID == "" {184 s.userID = c.tokenUser(ctx)185 }186 s.resolved = true187 }188 return s.token, s.userID, nil189 }190 if s.token != "" {191 return s.token, s.userID, nil192 }193 var auth struct {194 AccessToken string195 User struct {196 ID string `json:"Id"`197 }198 }199 body := map[string]string{"Username": s.acct.User, "Pw": s.acct.Password}200 status, err := c.do(ctx, http.MethodPost, "/Users/AuthenticateByName", nil, body, "", &auth)201 if status == http.StatusUnauthorized || status == http.StatusForbidden {202 s.refused = fmt.Errorf("jellyfin: %s refused the password for %q", s.server, s.acct.User)203 s.refusedAt = time.Now()204 return "", "", s.refused205 }206 if err != nil {207 return "", "", err208 }209 if auth.AccessToken == "" {210 return "", "", fmt.Errorf("jellyfin: %s: signing in gave no token", s.server)211 }212 s.token, s.userID, s.resolved = auth.AccessToken, auth.User.ID, true213 return s.token, s.userID, nil214}215216// tokenUser finds the user a token acts as: its own user, else the217// account's user by name, else none (an API key sees every library). s.mu218// is held.219func (c *Client) tokenUser(ctx context.Context) string {220 var me struct {221 ID string `json:"Id"`222 }223 if _, err := c.do(ctx, http.MethodGet, "/Users/Me", nil, nil, c.s.token, &me); err == nil && me.ID != "" {224 return me.ID225 }226 if c.s.acct.User == "" {227 return ""228 }229 var users []struct {230 ID string `json:"Id"`231 Name string232 }233 if _, err := c.do(ctx, http.MethodGet, "/Users", nil, nil, c.s.token, &users); err == nil {234 for _, u := range users {235 if strings.EqualFold(u.Name, c.s.acct.User) {236 return u.ID237 }238 }239 }240 return ""241}242243// expire drops token after the server rejected it, unless another request244// has already replaced it.245func (s *session) expire(token string) {246 s.mu.Lock()247 defer s.mu.Unlock()248 if s.token == token && s.acct.Token == "" {249 s.token = ""250 }251}252253// get fetches path as JSON into out, signed in. A rejected access token is254// renewed once, by signing in again.255func (c *Client) get(ctx context.Context, path string, q url.Values, out any) error {256 for retried := false; ; retried = true {257 token, userID, err := c.signIn(ctx)258 if err != nil {259 return err260 }261 if userID != "" && q != nil && !q.Has("userId") {262 q.Set("userId", userID)263 }264 status, err := c.do(ctx, http.MethodGet, path, q, nil, token, out)265 if status == http.StatusUnauthorized && !retried && c.s.acct.Token == "" {266 c.s.expire(token)267 continue268 }269 if status == http.StatusUnauthorized && c.s.acct.Token != "" {270 return fmt.Errorf("jellyfin: %s refused the token", c.s.server)271 }272 return err273 }274}275276// fetch gets path, signed in, returning at most limit bytes of it. A277// rejected access token is renewed once, as get does.278func (c *Client) fetch(ctx context.Context, path string, limit int64) ([]byte, error) {279 for retried := false; ; retried = true {280 token, _, err := c.signIn(ctx)281 if err != nil {282 return nil, err283 }284 resp, err := c.request(ctx, http.MethodGet, path, nil, nil, token)285 if err != nil {286 return nil, err287 }288 b, err := io.ReadAll(io.LimitReader(resp.Body, limit))289 resp.Body.Close()290 switch {291 case resp.StatusCode == http.StatusUnauthorized && !retried && c.s.acct.Token == "":292 c.s.expire(token)293 continue294 case resp.StatusCode != http.StatusOK:295 return nil, fmt.Errorf("jellyfin: GET %s: %s", path, resp.Status)296 case err != nil:297 return nil, fmt.Errorf("jellyfin: GET %s: %w", path, err)298 }299 return b, nil300 }301}302303// do makes one request, decoding a JSON response into out, and returns the304// HTTP status (0 if there was none).305func (c *Client) do(ctx context.Context, method, path string, q url.Values, body any, token string, out any) (int, error) {306 resp, err := c.request(ctx, method, path, q, body, token)307 if err != nil {308 return 0, err309 }310 defer resp.Body.Close()311 if resp.StatusCode != http.StatusOK {312 _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))313 return resp.StatusCode, fmt.Errorf("jellyfin: %s %s: %s", method, path, resp.Status)314 }315 if out != nil {316 if err := json.NewDecoder(resp.Body).Decode(out); err != nil {317 return resp.StatusCode, fmt.Errorf("jellyfin: %s %s: %w", method, path, err)318 }319 }320 return resp.StatusCode, nil321}322323func (c *Client) request(ctx context.Context, method, path string, q url.Values, body any, token string) (*http.Response, error) {324 u := c.s.server + path325 if len(q) > 0 {326 u += "?" + q.Encode()327 }328 var rd io.Reader329 if body != nil {330 b, err := json.Marshal(body)331 if err != nil {332 return nil, err333 }334 rd = bytes.NewReader(b)335 }336 req, err := http.NewRequestWithContext(ctx, method, u, rd)337 var resp *http.Response338 if err == nil {339 req.Header.Set("Authorization", c.s.header(token))340 if body != nil {341 req.Header.Set("Content-Type", "application/json")342 }343 resp, err = c.hc.Do(req)344 }345 if uerr := (*url.Error)(nil); errors.As(err, &uerr) {346 // Leave the query, which can hold a Quick Connect secret, out.347 return nil, fmt.Errorf("jellyfin: %s %s: %w", method, path, uerr.Err)348 }349 return resp, err350}