Big changes
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
// 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()
|
||||
}
|
||||
Reference in New Issue
Block a user