Files
memby/server/internal/tracearr/client.go
T
2026-07-29 15:26:55 +12:00

289 lines
8.5 KiB
Go

// 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"
)
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: &http.Client{Timeout: timeout},
}
}
// 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()
}