arnaucube
arnaucube/jellyfin-toolsPublic Self-hosted repository
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}