arnaucube
arnaucube/jellyfin-toolsPublic Self-hosted repository
jellyfin-tools/jellyfin-year-review/internal/model/model.go
jellyfin-year-review/internal/model/model.goRaw
  1// Package model defines the private source snapshot and public-to-owner review v1 contracts.
  2package model
  3
  4import (
  5	"encoding/hex"
  6	"fmt"
  7	"strings"
  8	"time"
  9)
 10
 11const Version = 1
 12
 13// ID converts Jellyfin UUIDs to lowercase, hyphen-free identity keys.
 14func ID(s string) (string, error) {
 15	s = strings.ToLower(strings.ReplaceAll(s, "-", ""))
 16	if len(s) != 32 {
 17		return "", fmt.Errorf("invalid Jellyfin UUID")
 18	}
 19	if _, err := hex.DecodeString(s); err != nil {
 20		return "", fmt.Errorf("invalid Jellyfin UUID")
 21	}
 22	return s, nil
 23}
 24
 25type User struct {
 26	ID   string `json:"id"`
 27	Name string `json:"name"`
 28}
 29type Item struct {
 30	ID          string            `json:"id"`
 31	Title       string            `json:"title"`
 32	Type        string            `json:"type"`
 33	SeriesID    string            `json:"seriesId,omitempty"`
 34	SeriesTitle string            `json:"seriesTitle,omitempty"`
 35	Year        int               `json:"year,omitempty"`
 36	Runtime     float64           `json:"runtimeSeconds,omitempty"`
 37	Genres      []string          `json:"genres"`
 38	ImageTag    string            `json:"imageTag,omitempty"`
 39	ProviderIDs map[string]string `json:"providerIds,omitempty"`
 40	Poster      string            `json:"poster,omitempty"`
 41}
 42
 43// Playback is a finalized activity row, not a progress update. Exact records describe
 44// uninterrupted watched intervals; position ranges describe actual media coverage.
 45type Playback struct {
 46	ID            string    `json:"id"`
 47	UserID        string    `json:"userId"`
 48	ItemID        string    `json:"itemId"`
 49	Start         time.Time `json:"start"`
 50	End           time.Time `json:"end"`
 51	ObservedAt    time.Time `json:"observedAt"`
 52	Seconds       float64   `json:"seconds"`
 53	Exact         bool      `json:"exactInterval"`
 54	PositionStart *float64  `json:"positionStart,omitempty"`
 55	PositionEnd   *float64  `json:"positionEnd,omitempty"`
 56	Device        string    `json:"device"`
 57	Client        string    `json:"client"`
 58}
 59type Snapshot struct {
 60	Version    int        `json:"schemaVersion"`
 61	CapturedAt time.Time  `json:"capturedAt"`
 62	Source     string     `json:"source"`
 63	Coverage   string     `json:"coverage"`
 64	Users      []User     `json:"users"`
 65	Items      []Item     `json:"items"`
 66	Playback   []Playback `json:"playback"`
 67}
 68type Availability struct {
 69	Available bool   `json:"available"`
 70	Reason    string `json:"reason,omitempty"`
 71}
 72type Named struct {
 73	Name    string  `json:"name"`
 74	Seconds float64 `json:"seconds"`
 75	Records int     `json:"records"`
 76}
 77type Content struct {
 78	ID           string   `json:"id"`
 79	Title        string   `json:"title"`
 80	Poster       string   `json:"poster,omitempty"`
 81	PosterSource string   `json:"posterSource,omitempty"`
 82	Seconds      float64  `json:"seconds"`
 83	Records      int      `json:"records"`
 84	Months       []int    `json:"months,omitempty"`
 85	Completion   *float64 `json:"completionPercent,omitempty"`
 86}
 87type Marathon struct {
 88	Start   time.Time `json:"start"`
 89	End     time.Time `json:"end"`
 90	Watched float64   `json:"watchedSeconds"`
 91	Elapsed float64   `json:"elapsedSeconds"`
 92	Records int       `json:"records"`
 93}
 94type Personality struct {
 95	Name        string `json:"name"`
 96	Explanation string `json:"explanation"`
 97}
 98type Ranking struct {
 99	Rank       int     `json:"rank"`
100	Cohort     int     `json:"cohort"`
101	Percentile float64 `json:"percentile"`
102	Average    float64 `json:"averageSeconds"`
103}
104type Rules struct {
105	MinimumSeconds     float64 `json:"minimumSeconds"`
106	CompletionPercent  float64 `json:"completionPercent"`
107	MarathonGapSeconds float64 `json:"marathonGapSeconds"`
108}
109type Review struct {
110	Rules              Rules                   `json:"rules"`
111	Version            int                     `json:"schemaVersion"`
112	User               User                    `json:"user"`
113	Year               int                     `json:"year"`
114	Timezone           string                  `json:"timezone"`
115	GeneratedAt        time.Time               `json:"generatedAt"`
116	Source             string                  `json:"source"`
117	Coverage           string                  `json:"coverage"`
118	Availability       map[string]Availability `json:"availability"`
119	Seconds            float64                 `json:"seconds"`
120	MovieSeconds       float64                 `json:"movieSeconds"`
121	TVSeconds          float64                 `json:"tvSeconds"`
122	Records            int                     `json:"records"`
123	UniqueTitles       int                     `json:"uniqueTitles"`
124	ActiveDays         int                     `json:"activeDays"`
125	Movies             []Content               `json:"movies"`
126	Shows              []Content               `json:"shows"`
127	Abandoned          []Content               `json:"abandoned"`
128	Genres             []Named                 `json:"genres"`
129	Devices            []Named                 `json:"devices"`
130	Clients            []Named                 `json:"clients"`
131	Hourly             [24]float64             `json:"hourly"`
132	Weekdays           [7]float64              `json:"weekdays"`
133	Monthly            [12]float64             `json:"monthly"`
134	DistributionMetric string                  `json:"distributionMetric"`
135	Marathon           *Marathon               `json:"marathon"`
136	Personality        *Personality            `json:"personality"`
137	Ranking            *Ranking                `json:"ranking"`
138	Warnings           []string                `json:"warnings"`
139}
140type Manifest struct {
141	Version int              `json:"schemaVersion"`
142	Users   map[string][]int `json:"users"`
143}
144
145// Validate rejects incompatible, corrupted or incorrectly addressed documents.
146func (r Review) Validate(id string, year int) error {
147	if r.Version != Version || r.User.ID != id || r.Year != year || r.Seconds < 0 || r.Availability == nil {
148		return fmt.Errorf("invalid or unsupported review")
149	}
150	if _, err := time.LoadLocation(r.Timezone); err != nil {
151		return fmt.Errorf("invalid review timezone")
152	}
153	return nil
154}