jellyfin-tools/jellyfin-year-review/cmd/review/main.go
jellyfin-year-review/cmd/review/main.goRaw
1// review generates immutable datasets or serves the static recap application.
2package main
3
4import (
5 "context"
6 "encoding/json"
7 "errors"
8 "flag"
9 "fmt"
10 "net/http"
11 "os"
12 "os/signal"
13 "strconv"
14 "syscall"
15 "time"
16 _ "time/tzdata"
17
18 "reelreview/internal/artwork"
19 "reelreview/internal/auth"
20 "reelreview/internal/generate"
21 "reelreview/internal/model"
22 "reelreview/internal/server"
23 "reelreview/internal/source"
24)
25
26func main() {
27 if err := run(os.Args[1:]); err != nil && !errors.Is(err, flag.ErrHelp) {
28 fmt.Fprintln(os.Stderr, "review:", err)
29 os.Exit(1)
30 }
31}
32func env(key, fallback string) string {
33 if v := os.Getenv(key); v != "" {
34 return v
35 }
36 return fallback
37}
38func run(args []string) error {
39 if len(args) == 0 {
40 return fmt.Errorf("usage: review generate|serve [flags]")
41 }
42 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
43 defer cancel()
44 switch args[0] {
45 case "generate":
46 f := flag.NewFlagSet("generate", flag.ContinueOnError)
47 year := f.Int("year", 0, "selected year (required)")
48 output := f.String("output", "./data", "private output directory")
49 input := f.String("input", "", "canonical snapshot JSON")
50 history := f.String("history", "", "raw Jellystat JSONL snapshot (requires Jellyfin operator API key)")
51 captured := f.String("captured-at", "", "Jellystat snapshot timestamp, RFC3339")
52 coverage := f.String("coverage", "", "explicit source coverage/retention description")
53 timezone := f.String("timezone", env("REVIEW_TIMEZONE", "UTC"), "default IANA timezone")
54 zones := f.String("user-timezones", "", "JSON file mapping normalized user IDs to IANA timezones")
55 minimum := f.Float64("minimum-seconds", 60, "minimum original record watch time")
56 completion := f.Float64("completion-percent", 90, "movie completion threshold")
57 gap := f.Duration("marathon-gap", 45*time.Minute, "maximum gap in a marathon")
58 cache := f.String("cache", "./cache", "generator-only Jellyfin artwork cache")
59 if e := f.Parse(args[1:]); e != nil {
60 return e
61 }
62 c := generate.DefaultConfig(*year)
63 c.Timezone = *timezone
64 c.MinimumSeconds = *minimum
65 c.Completion = *completion
66 c.MarathonGap = *gap
67 if *zones != "" {
68 b, e := os.ReadFile(*zones)
69 if e != nil {
70 return fmt.Errorf("cannot read timezone mapping")
71 }
72 if json.Unmarshal(b, &c.UserTimezones) != nil {
73 return fmt.Errorf("invalid timezone mapping")
74 }
75 }
76 if e := c.Validate(); e != nil {
77 return e
78 }
79 if (*input == "") == (*history == "") {
80 return fmt.Errorf("choose exactly one of --input or --history")
81 }
82 var s model.Snapshot
83 var jf *source.Jellyfin
84 key := os.Getenv("JELLYFIN_API_KEY")
85 var e error
86 if *input != "" {
87 s, e = source.Load(*input)
88 } else {
89 t, err := time.Parse(time.RFC3339, *captured)
90 if err != nil {
91 return fmt.Errorf("--captured-at must be the snapshot RFC3339 timestamp")
92 }
93 if *coverage == "" {
94 return fmt.Errorf("--coverage is required for Jellystat history")
95 }
96 if key == "" {
97 return fmt.Errorf("JELLYFIN_API_KEY is required for metadata inventory")
98 }
99 j, err := source.NewJellyfin(os.Getenv("JELLYFIN_URL"))
100 if err != nil {
101 return err
102 }
103 jf = j
104 users, items, err := j.Metadata(ctx, key)
105 if err != nil {
106 return fmt.Errorf("cannot obtain Jellyfin metadata inventory: %w", err)
107 }
108 s, e = source.Jellystat(*history, users, items, t, *coverage)
109 }
110 if e != nil {
111 return e
112 }
113 known := map[string]bool{}
114 for _, u := range s.Users {
115 known[u.ID] = true
116 }
117 orphaned := 0
118 for _, p := range s.Playback {
119 if !known[p.UserID] {
120 orphaned++
121 }
122 }
123 if orphaned > 0 {
124 fmt.Fprintf(os.Stderr, "Skipped %d history records for unavailable/disabled users.\n", orphaned)
125 }
126 rs, e := generate.Compute(s, c)
127 if e != nil {
128 return e
129 }
130 if jf == nil && key != "" && os.Getenv("JELLYFIN_URL") != "" {
131 jf, e = source.NewJellyfin(os.Getenv("JELLYFIN_URL"))
132 if e != nil {
133 return e
134 }
135 }
136 assets, warnings := artwork.New(jf, key, *cache).Resolve(ctx, s.Items, rs)
137 if ctx.Err() != nil {
138 return fmt.Errorf("generation canceled")
139 }
140 for _, warning := range warnings {
141 fmt.Fprintln(os.Stderr, warning)
142 }
143 for i := range rs {
144 rs[i].Warnings = append(rs[i].Warnings, warnings...)
145 }
146 if e = generate.Publish(*output, rs, assets); e != nil {
147 return e
148 }
149 fmt.Printf("Published %d private reviews for %d.\n", len(rs), *year)
150 return nil
151 case "serve":
152 f := flag.NewFlagSet("serve", flag.ContinueOnError)
153 data := f.String("data", "./data", "private generated dataset")
154 web := f.String("web", "./web/build", "static frontend build")
155 addr := f.String("listen", ":8080", "HTTP listener")
156 origin := f.String("origin", os.Getenv("APP_ORIGIN"), "exact browser origin")
157 authMode := f.String("auth-mode", env("REVIEW_AUTH_MODE", "jellyfin"), "authentication backend: jellyfin or offline")
158 authFile := f.String("auth-file", os.Getenv("REVIEW_AUTH_FILE"), "private offline credential export (outside data and web)")
159 if e := f.Parse(args[1:]); e != nil {
160 return e
161 }
162 var provider auth.Provider
163 switch *authMode {
164 case "offline":
165 p, e := auth.NewOffline(*authFile)
166 if e != nil {
167 return e
168 }
169 provider = p
170 case "jellyfin":
171 if *authFile != "" {
172 return fmt.Errorf("--auth-file requires --auth-mode offline")
173 }
174 j, e := source.NewJellyfin(os.Getenv("JELLYFIN_URL"))
175 if e != nil {
176 return e
177 }
178 provider = auth.Online{Jellyfin: j}
179 default:
180 return fmt.Errorf("--auth-mode must be jellyfin or offline")
181 }
182 s, e := server.NewWithAuth(*data, *web, *origin, provider)
183 if e != nil {
184 return e
185 }
186 defer s.Close()
187 httpServer := server.HTTPServer(*addr, s)
188 go func() {
189 <-ctx.Done()
190 shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
191 defer cancel()
192 _ = httpServer.Shutdown(shutdown)
193 }()
194 fmt.Println("Review server listening on " + *addr)
195 e = httpServer.ListenAndServe()
196 if e == http.ErrServerClosed {
197 return nil
198 }
199 return e
200 default:
201 return fmt.Errorf("unknown mode %s", strconv.Quote(args[0]))
202 }
203}