// Package tracearr reads per-user playback history from Tracearr's public API. // // Tracearr is deliberately kept behind the Memby gateway: its operator API key never // reaches a television, and a Tracearr outage can only reduce recommendation quality. package tracearr import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "unicode" "github.com/ponzischeme89/memby/server/internal/timing" ) type Client struct { baseURL string apiKey string serverID string http *http.Client } type Session struct { ID string `json:"id"` ServerID string `json:"serverId"` State string `json:"state"` MediaType string `json:"mediaType"` MediaTitle string `json:"mediaTitle"` ShowTitle string `json:"showTitle"` SeasonNumber *int `json:"seasonNumber"` EpisodeNumber *int `json:"episodeNumber"` Year *int `json:"year"` DurationMs FlexibleInt64 `json:"durationMs"` ProgressMs FlexibleInt64 `json:"progressMs"` TotalDurationMs FlexibleInt64 `json:"totalDurationMs"` StartedAt string `json:"startedAt"` StoppedAt string `json:"stoppedAt"` Watched bool `json:"watched"` Device string `json:"device"` Player string `json:"player"` Product string `json:"product"` Platform string `json:"platform"` IsTranscode bool `json:"isTranscode"` VideoDecision string `json:"videoDecision"` AudioDecision string `json:"audioDecision"` SourceVideoCodec string `json:"sourceVideoCodec"` SourceAudioCodec string `json:"sourceAudioCodec"` User struct { ID string `json:"id"` Username string `json:"username"` } `json:"user"` } // FlexibleInt64 accepts both JSON numbers and quoted integers. Tracearr currently // serialises progress fields as strings while duration is numeric. type FlexibleInt64 int64 func (v *FlexibleInt64) UnmarshalJSON(raw []byte) error { raw = bytes.TrimSpace(raw) if bytes.Equal(raw, []byte("null")) || len(raw) == 0 { *v = 0 return nil } if raw[0] == '"' { var value string if err := json.Unmarshal(raw, &value); err != nil { return err } parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) if err != nil { return fmt.Errorf("tracearr integer %q: %w", value, err) } *v = FlexibleInt64(parsed) return nil } parsed, err := strconv.ParseInt(string(raw), 10, 64) if err != nil { return fmt.Errorf("tracearr integer %q: %w", string(raw), err) } *v = FlexibleInt64(parsed) return nil } type Page struct { Data []Session `json:"data"` Meta struct { Total int `json:"total"` Page int `json:"page"` PageSize int `json:"pageSize"` } `json:"meta"` } type User struct { ID string `json:"id"` Username string `json:"username"` SessionCount int `json:"sessionCount"` } type UserPage struct { Data []User `json:"data"` Meta struct { Total int `json:"total"` Page int `json:"page"` PageSize int `json:"pageSize"` } `json:"meta"` } func New(baseURL, apiKey, serverID string, timeout time.Duration) *Client { return &Client{ baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"), apiKey: strings.TrimSpace(apiKey), serverID: strings.TrimSpace(serverID), http: timing.Instrument(&http.Client{Timeout: timeout}, timing.StageTracearr), } } // History returns the newest sessions belonging to username. Tracearr's public history // endpoint currently has no user filter, so Memby pages through the bounded recent // window and filters locally. Usernames are the stable identity shared by Emby and // Tracearr; no fuzzy matching is used. func (c *Client) History(ctx context.Context, username string, limit int) ([]Session, error) { if c == nil || c.baseURL == "" || c.apiKey == "" || strings.TrimSpace(username) == "" { return nil, nil } if limit <= 0 { limit = 100 } const pageSize = 100 const maxPages = 10 wanted := strings.TrimSpace(username) out := make([]Session, 0, min(limit, pageSize)) for pageNumber := 1; pageNumber <= maxPages && len(out) < limit; pageNumber++ { historyPage, err := c.Page(ctx, pageNumber, pageSize) if err != nil { return nil, err } for _, session := range historyPage.Data { if strings.EqualFold(strings.TrimSpace(session.User.Username), wanted) { out = append(out, session) if len(out) == limit { break } } } if pageNumber*pageSize >= historyPage.Meta.Total || len(historyPage.Data) == 0 { break } } return out, nil } // Page reads one public-history page. Imports use this directly so Tracearr pagination // happens in the background rather than while a television waits. func (c *Client) Page(ctx context.Context, pageNumber, pageSize int) (Page, error) { endpoint, err := c.publicEndpoint("/api/v1/public/history", pageNumber, pageSize) if err != nil { return Page{}, err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) if err != nil { return Page{}, err } req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("Accept", "application/json") resp, err := c.http.Do(req) if err != nil { return Page{}, fmt.Errorf("tracearr history: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return Page{}, fmt.Errorf("tracearr history: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } var result Page if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&result); err != nil { return Page{}, fmt.Errorf("tracearr history: decode: %w", err) } return result, nil } // Users lists the Tracearr identities known to the configured media server. Household // preparation uses exact username matches against Emby; no fuzzy identity guesses are // made here. func (c *Client) Users(ctx context.Context, pageNumber, pageSize int) (UserPage, error) { endpoint, err := c.publicEndpoint("/api/v1/public/users", pageNumber, pageSize) if err != nil { return UserPage{}, err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) if err != nil { return UserPage{}, err } req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("Accept", "application/json") resp, err := c.http.Do(req) if err != nil { return UserPage{}, fmt.Errorf("tracearr users: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return UserPage{}, fmt.Errorf( "tracearr users: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)), ) } var result UserPage if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil { return UserPage{}, fmt.Errorf("tracearr users: decode: %w", err) } return result, nil } func (c *Client) publicEndpoint(path string, pageNumber, pageSize int) (*url.URL, error) { endpoint, err := url.Parse(c.baseURL + path) if err != nil { return nil, err } query := endpoint.Query() query.Set("page", strconv.Itoa(pageNumber)) query.Set("pageSize", strconv.Itoa(pageSize)) if c.serverID != "" { query.Set("serverId", c.serverID) } endpoint.RawQuery = query.Encode() return endpoint, nil } func (c *Client) ConfiguredServerID() string { if c == nil { return "" } return c.serverID } // Completion is the useful fraction of a session. Tracearr's watched flag wins; for an // interrupted play, progress is preferred and aggregate watch time is the fallback. func (s Session) Completion() float64 { if s.Watched { return 1 } if s.TotalDurationMs <= 0 { return 0 } progress := s.ProgressMs if s.DurationMs > progress { progress = s.DurationMs } value := float64(progress) / float64(s.TotalDurationMs) if value < 0 { return 0 } if value > 1 { return 1 } return value } func (s Session) TitleKey() string { if strings.EqualFold(s.MediaType, "episode") && strings.TrimSpace(s.ShowTitle) != "" { return normalizeTitle(s.ShowTitle) } return normalizeTitle(s.MediaTitle) } func (s Session) IsTelevisionSession() bool { value := strings.ToLower(strings.Join([]string{s.Device, s.Player, s.Product, s.Platform}, " ")) return strings.Contains(value, "tv") || strings.Contains(value, "android") || strings.Contains(value, "memby") } func normalizeTitle(value string) string { var b strings.Builder for _, r := range strings.ToLower(value) { if unicode.IsLetter(r) || unicode.IsDigit(r) { b.WriteRune(r) } } return b.String() }