289 lines
8.5 KiB
Go
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()
|
||
|
|
}
|