arnaucube
arnaucube/jellyfin-toolsPublic Self-hosted repository
add jellyfin-year-review (like spotify-wrapped)
arnaucube git@arnaucube.com
Sun, 18 Oct 2026 10:10:13 +0200
commit

8057626a473cd9c784b168e84506639a3092009b

parent

f154cd2496a30b02c75c331fafee8ea14ac63d16

65 files changed, 8230 insertions(+), 1 deletions(-)

jump to
A README.md

@@ -0,0 +1,6 @@

+# jellyfin-tools + +> Tools I use around my Jellyfin server. Both packages work for Jellyfin v12.2. + +- jellyfin-news: get weekly updates to share (shows & movies added (text & image), what has been watched by all users) +- jellyfin-year-review: like Spottify-wrapped, a year review for each user
M jellyfin-news/.gitignore → jellyfin-news/.gitignore

@@ -1,2 +1,4 @@

.env +.env.* +!.env.example /jellyfin-news
M jellyfin-news/README.md → jellyfin-news/README.md

@@ -1,4 +1,4 @@

-# Jellyfin update webpage +# jellyfin-news A small Go web server for generating copyable Jellyfin movie and episode recaps. It serves one embedded `index.html` page, a database, or third-party Go dependencies.
A jellyfin-year-review/.dockerignore

@@ -0,0 +1,22 @@

+* +!Dockerfile +!go.mod +!cmd/ +!cmd/** +!internal/ +!internal/** +!web/ +!web/** +internal/**/*_test.go +web/node_modules +web/.svelte-kit +web/build +web/test-results +web/playwright-report +web/tests +web/playwright.config.js +web/.env* +**/.env* +**/*.db +**/*.sqlite* +**/.git
A jellyfin-year-review/.env.example

@@ -0,0 +1,26 @@

+# Compose reads .env for interpolation. Local review commands use exported env vars. +APP_ORIGIN=https://review.example.com +# Server-side internal URL. Include Jellyfin's base path if configured. +JELLYFIN_URL=http://jellyfin:8096 +JELLYFIN_NETWORK=media +# Generation only; create in Jellyfin Dashboard > API Keys. Not used for --input. +JELLYFIN_API_KEY= +REVIEW_TIMEZONE=Europe/London +# Match the owner of ./data ./cache and readable ./input. Never use UID 0. +REVIEW_UID=10001 +REVIEW_GID=10001 +# Local CLI offline serving. compose.offline.yaml sets these explicitly. +# No JELLYFIN_URL or Jellyfin network is required in offline serving mode. +REVIEW_AUTH_MODE=jellyfin +# Set to a private file outside web/ and data/ when REVIEW_AUTH_MODE=offline. +REVIEW_AUTH_FILE= + +# Read-only Jellystat PostgreSQL access for scripts/generate-review.sh. +PGHOST=127.0.0.1 +PGPORT=5432 +PGDATABASE=jfstat +PGUSER=review_readonly +# Set the database password in single quotes to preserve shell special characters. +PGPASSWORD='' +# Optional honest description of the history collection window and gaps. +# REVIEW_COVERAGE='Collected 2025-01-01 through 2025-12-31; no known gaps'
A jellyfin-year-review/.gitignore

@@ -0,0 +1,18 @@

+.env +/data/ +/cache/ +/input/ +/review +web/node_modules/ +web/.svelte-kit/ +web/build/ +web/test-results/ +web/playwright-report/ +.env.* +!.env.example +*.db +*.db-* +*.sqlite* +/auth/ +__pycache__/ +*.pyc
A jellyfin-year-review/Dockerfile

@@ -0,0 +1,22 @@

+FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS web +WORKDIR /web +COPY web/package.json web/package-lock.json ./ +RUN npm ci --no-audit --no-fund +COPY web/ ./ +RUN npm run check && npm run build + +FROM golang:1.27.2-alpine@sha256:f92b6ef800e499660581efdabdf25d9d817a9d124eaf900924f0504e7e27e12d AS go-build +WORKDIR /src +COPY go.mod ./ +COPY cmd/ ./cmd/ +COPY internal/ ./internal/ +RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /review ./cmd/review + +FROM scratch +COPY --from=go-build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=go-build /review /review +COPY --from=web /web/build /web +USER 10001:10001 +EXPOSE 8080 +ENTRYPOINT ["/review"] +CMD ["serve", "--data", "/data", "--web", "/web"]
A jellyfin-year-review/Makefile

@@ -0,0 +1,15 @@

+.PHONY: check build test e2e +check: + test -z "$$(gofmt -l cmd internal scripts)" + go vet ./... + go test -race ./... + python3 -m unittest discover -s scripts -p 'test_*.py' + npm --prefix web run check +build: + npm --prefix web run build + go build -trimpath -o review ./cmd/review +test: + go test ./... +e2e: build + npm --prefix web run test:e2e + REEL_E2E_AUTH_MODE=offline npm --prefix web run test:e2e
A jellyfin-year-review/README.md

@@ -0,0 +1,83 @@

+# jellyfin-year-review + +Run these commands from the project directory. + +## 1. Generate review data + +Requires Go 1.27.2, `psql`, and native Jellystat history. Copy `.env.example` to +`.env` and configure: + +- `JELLYFIN_URL` (reachable from this machine) and `JELLYFIN_API_KEY`. +- `PGHOST`, `PGDATABASE`, `PGUSER`, and `PGPASSWORD` for read-only Jellystat access. +- `REVIEW_TIMEZONE`. Posters come from Jellyfin, with TVmaze as a fallback for + TV shows (no additional API key). + +Set `PGPASSWORD='your-postgresql-password'` in `.env` (keep the quotes). +Keep `.env` private (`chmod 600 .env`), then run: + +```sh +./scripts/generate-review.sh 2025 +``` + +The script reports its progress, loads `.env`, exports history, and generates +`data/`. If it reports a missing `psql`, install the PostgreSQL client on this +machine (for Debian/Ubuntu: `sudo apt install postgresql-client`). Change the year +to generate another recap. Optionally set `REVIEW_COVERAGE` to describe your +collection window and known gaps. + +## 2. Export Jellyfin users + +With Python 3.9+ on the Jellyfin 12.2 host: + +```sh +python3 scripts/export-jellyfin-auth.py --database /path/to/jellyfin.db --output auth/users.json +``` + +Use the actual database path with read access to its WAL/SHM files. The export +contains private password verifiers, never plaintext passwords. Rerun after +password or account changes. Only supported built-in accounts can log in offline. + +## 3. Serve the webapp + +On the serving host, place the project, generated `data/`, and private +`auth/users.json` together. **Upload `data/` and `auth/` separately:** the normal +code-upload rsync excludes them. Do not upload the Jellyfin database or API keys. + +Run either option as the non-root user who owns `data/` and `auth/`. +First set permissions and the public origin: + +```sh +chmod 700 data auth +chmod 600 auth/users.json + +export APP_ORIGIN=https://review.example.com +``` + +With Docker Compose: + +```sh +export REVIEW_UID="$(id -u)" REVIEW_GID="$(id -g)" +docker compose -f compose.offline.yaml up -d --build +``` + +Without Docker (requires Go 1.27.2 and Node.js 24+ to build): + +```sh +npm --prefix web ci +npm --prefix web run build +go build -buildvcs=false -o ./review ./cmd/review + +./review serve --listen 127.0.0.1:8080 \ + --data ./data --web ./web/build \ + --auth-mode offline --auth-file ./auth/users.json +``` + +The server runs in the foreground; Node.js is only needed for the frontend build. + +Point your HTTPS reverse proxy at `127.0.0.1:8080`; `APP_ORIGIN` must match the +public origin exactly, without a trailing slash. Compose reads settings from +`.env`; without Docker, export `APP_ORIGIN` each time you start the server. + +Users log in with their exported Jellyfin credentials. Serving requires no +Jellyfin connection. See [offline setup](docs/offline-auth.md) for secure transfer +and atomic credential-refresh instructions.
A jellyfin-year-review/cmd/review/main.go

@@ -0,0 +1,203 @@

+// review generates immutable datasets or serves the static recap application. +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "net/http" + "os" + "os/signal" + "strconv" + "syscall" + "time" + _ "time/tzdata" + + "reelreview/internal/artwork" + "reelreview/internal/auth" + "reelreview/internal/generate" + "reelreview/internal/model" + "reelreview/internal/server" + "reelreview/internal/source" +) + +func main() { + if err := run(os.Args[1:]); err != nil && !errors.Is(err, flag.ErrHelp) { + fmt.Fprintln(os.Stderr, "review:", err) + os.Exit(1) + } +} +func env(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} +func run(args []string) error { + if len(args) == 0 { + return fmt.Errorf("usage: review generate|serve [flags]") + } + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + switch args[0] { + case "generate": + f := flag.NewFlagSet("generate", flag.ContinueOnError) + year := f.Int("year", 0, "selected year (required)") + output := f.String("output", "./data", "private output directory") + input := f.String("input", "", "canonical snapshot JSON") + history := f.String("history", "", "raw Jellystat JSONL snapshot (requires Jellyfin operator API key)") + captured := f.String("captured-at", "", "Jellystat snapshot timestamp, RFC3339") + coverage := f.String("coverage", "", "explicit source coverage/retention description") + timezone := f.String("timezone", env("REVIEW_TIMEZONE", "UTC"), "default IANA timezone") + zones := f.String("user-timezones", "", "JSON file mapping normalized user IDs to IANA timezones") + minimum := f.Float64("minimum-seconds", 60, "minimum original record watch time") + completion := f.Float64("completion-percent", 90, "movie completion threshold") + gap := f.Duration("marathon-gap", 45*time.Minute, "maximum gap in a marathon") + cache := f.String("cache", "./cache", "generator-only Jellyfin artwork cache") + if e := f.Parse(args[1:]); e != nil { + return e + } + c := generate.DefaultConfig(*year) + c.Timezone = *timezone + c.MinimumSeconds = *minimum + c.Completion = *completion + c.MarathonGap = *gap + if *zones != "" { + b, e := os.ReadFile(*zones) + if e != nil { + return fmt.Errorf("cannot read timezone mapping") + } + if json.Unmarshal(b, &c.UserTimezones) != nil { + return fmt.Errorf("invalid timezone mapping") + } + } + if e := c.Validate(); e != nil { + return e + } + if (*input == "") == (*history == "") { + return fmt.Errorf("choose exactly one of --input or --history") + } + var s model.Snapshot + var jf *source.Jellyfin + key := os.Getenv("JELLYFIN_API_KEY") + var e error + if *input != "" { + s, e = source.Load(*input) + } else { + t, err := time.Parse(time.RFC3339, *captured) + if err != nil { + return fmt.Errorf("--captured-at must be the snapshot RFC3339 timestamp") + } + if *coverage == "" { + return fmt.Errorf("--coverage is required for Jellystat history") + } + if key == "" { + return fmt.Errorf("JELLYFIN_API_KEY is required for metadata inventory") + } + j, err := source.NewJellyfin(os.Getenv("JELLYFIN_URL")) + if err != nil { + return err + } + jf = j + users, items, err := j.Metadata(ctx, key) + if err != nil { + return fmt.Errorf("cannot obtain Jellyfin metadata inventory: %w", err) + } + s, e = source.Jellystat(*history, users, items, t, *coverage) + } + if e != nil { + return e + } + known := map[string]bool{} + for _, u := range s.Users { + known[u.ID] = true + } + orphaned := 0 + for _, p := range s.Playback { + if !known[p.UserID] { + orphaned++ + } + } + if orphaned > 0 { + fmt.Fprintf(os.Stderr, "Skipped %d history records for unavailable/disabled users.\n", orphaned) + } + rs, e := generate.Compute(s, c) + if e != nil { + return e + } + if jf == nil && key != "" && os.Getenv("JELLYFIN_URL") != "" { + jf, e = source.NewJellyfin(os.Getenv("JELLYFIN_URL")) + if e != nil { + return e + } + } + assets, warnings := artwork.New(jf, key, *cache).Resolve(ctx, s.Items, rs) + if ctx.Err() != nil { + return fmt.Errorf("generation canceled") + } + for _, warning := range warnings { + fmt.Fprintln(os.Stderr, warning) + } + for i := range rs { + rs[i].Warnings = append(rs[i].Warnings, warnings...) + } + if e = generate.Publish(*output, rs, assets); e != nil { + return e + } + fmt.Printf("Published %d private reviews for %d.\n", len(rs), *year) + return nil + case "serve": + f := flag.NewFlagSet("serve", flag.ContinueOnError) + data := f.String("data", "./data", "private generated dataset") + web := f.String("web", "./web/build", "static frontend build") + addr := f.String("listen", ":8080", "HTTP listener") + origin := f.String("origin", os.Getenv("APP_ORIGIN"), "exact browser origin") + authMode := f.String("auth-mode", env("REVIEW_AUTH_MODE", "jellyfin"), "authentication backend: jellyfin or offline") + authFile := f.String("auth-file", os.Getenv("REVIEW_AUTH_FILE"), "private offline credential export (outside data and web)") + if e := f.Parse(args[1:]); e != nil { + return e + } + var provider auth.Provider + switch *authMode { + case "offline": + p, e := auth.NewOffline(*authFile) + if e != nil { + return e + } + provider = p + case "jellyfin": + if *authFile != "" { + return fmt.Errorf("--auth-file requires --auth-mode offline") + } + j, e := source.NewJellyfin(os.Getenv("JELLYFIN_URL")) + if e != nil { + return e + } + provider = auth.Online{Jellyfin: j} + default: + return fmt.Errorf("--auth-mode must be jellyfin or offline") + } + s, e := server.NewWithAuth(*data, *web, *origin, provider) + if e != nil { + return e + } + defer s.Close() + httpServer := server.HTTPServer(*addr, s) + go func() { + <-ctx.Done() + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = httpServer.Shutdown(shutdown) + }() + fmt.Println("Review server listening on " + *addr) + e = httpServer.ListenAndServe() + if e == http.ErrServerClosed { + return nil + } + return e + default: + return fmt.Errorf("unknown mode %s", strconv.Quote(args[0])) + } +}
A jellyfin-year-review/cmd/review/main_test.go

@@ -0,0 +1,118 @@

+package main + +import ( + "bytes" + "encoding/json" + "image" + "image/jpeg" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "reelreview/internal/generate" + "reelreview/internal/model" +) + +// Test the actual CLI generation path against source-shaped Jellyfin and native +// Jellystat fixtures, including parent-series artwork and deterministic caching. +func TestGenerateDownloadsJellyfinPosters(t *testing.T) { + const user = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + const movie = "11111111111111111111111111111111" + const series = "22222222222222222222222222222222" + const episode = "33333333333333333333333333333333" + const key = "synthetic-operator-key" + var jpegBytes bytes.Buffer + if e := jpeg.Encode(&jpegBytes, image.NewRGBA(image.Rect(0, 0, 2, 3)), nil); e != nil { + t.Fatal(e) + } + images := 0 + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Jellyfin 12 accepts credentials in Authorization, without legacy headers. + if r.Header.Get("X-Emby-Token") != "" { + t.Error("unexpected legacy authentication header") + } + if !strings.Contains(r.Header.Get("Authorization"), `Token="`+key+`"`) { + w.WriteHeader(401) + return + } + switch r.URL.Path { + case "/jellyfin/Users": + json.NewEncoder(w).Encode([]map[string]any{{"Id": user, "Name": "Viewer"}}) + case "/jellyfin/Items": + json.NewEncoder(w).Encode(map[string]any{"TotalRecordCount": 3, "Items": []map[string]any{ + {"Id": movie, "Name": "Film", "Type": "Movie", "ImageTags": map[string]string{"Primary": "film-v1"}}, + {"Id": series, "Name": "Show", "Type": "Series", "ImageTags": map[string]string{"Primary": "show-v1"}}, + {"Id": episode, "Name": "Episode", "Type": "Episode", "SeriesId": series, "SeriesName": "Show"}, + }}) + case "/jellyfin/Items/" + movie + "/Images/Primary", "/jellyfin/Items/" + series + "/Images/Primary": + images++ + w.Header().Set("Content-Type", "image/jpeg") + w.Write(jpegBytes.Bytes()) + default: + t.Error("unexpected generation request") + w.WriteHeader(404) + } + })) + defer api.Close() + t.Setenv("JELLYFIN_URL", api.URL+"/jellyfin") + t.Setenv("JELLYFIN_API_KEY", key) + root := t.TempDir() + history := filepath.Join(root, "history.jsonl") + f, e := os.Create(history) + if e != nil { + t.Fatal(e) + } + enc := json.NewEncoder(f) + for _, row := range []map[string]any{ + {"Id": "movie-row", "UserId": user, "NowPlayingItemId": movie, "PlaybackDuration": 3600, "ActivityDateInserted": "2025-02-03T19:00:00Z"}, + {"Id": "episode-row", "UserId": user, "NowPlayingItemId": series, "EpisodeId": episode, "PlaybackDuration": 3600, "ActivityDateInserted": "2025-02-03T20:00:00Z"}, + } { + if e = enc.Encode(row); e != nil { + t.Fatal(e) + } + } + f.Close() + output := filepath.Join(root, "data") + args := []string{"generate", "--year", "2025", "--history", history, "--captured-at", "2026-01-01T00:00:00Z", "--coverage", "synthetic native fixture", "--output", output, "--cache", filepath.Join(root, "cache")} + if e = run(args); e != nil { + t.Fatal(e) + } + before, e := generate.Current(output) + if e != nil { + t.Fatal(e) + } + if e = run(args); e != nil { + t.Fatal(e) + } + after, e := generate.Current(output) + if e != nil || before != after || images != 2 { + t.Fatal("generation was not deterministic or cache was not reused") + } + api.Close() // The published files must remain usable without their source. + b, e := os.ReadFile(filepath.Join(after, user, "2025.json")) + if e != nil { + t.Fatal(e) + } + if strings.Contains(string(b), key) || strings.Contains(string(b), api.URL) { + t.Fatal("source credentials or remote image URL entered review JSON") + } + var review model.Review + if e = json.Unmarshal(b, &review); e != nil { + t.Fatal(e) + } + if len(review.Movies) != 1 || len(review.Shows) != 1 || review.Seconds != 7200 { + t.Fatal("incorrect generated source statistics") + } + for _, poster := range []string{review.Movies[0].Poster, review.Shows[0].Poster} { + if !strings.HasPrefix(poster, "/art/") || !generate.SafeAsset(strings.TrimPrefix(poster, "/art/")) { + t.Fatal("nonlocal generated poster") + } + stored, e := os.ReadFile(filepath.Join(after, strings.TrimPrefix(poster, "/"))) + if e != nil || !bytes.Equal(stored, jpegBytes.Bytes()) { + t.Fatal("local poster absent after source shutdown") + } + } +}
A jellyfin-year-review/compose.offline.yaml

@@ -0,0 +1,22 @@

+# Standalone remote serving: no Jellyfin URL, shared media network, or generator. +services: + review: + image: reel-review:1.0.0 + build: . + user: "${REVIEW_UID:-10001}:${REVIEW_GID:-10001}" + restart: unless-stopped + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + environment: + APP_ORIGIN: ${APP_ORIGIN:?Set APP_ORIGIN} + REVIEW_AUTH_MODE: offline + REVIEW_AUTH_FILE: /auth/users.json + volumes: + - ./data:/data:ro + # Mount the directory so atomic file replacements are visible immediately. + - ./auth:/auth:ro + ports: + - "0.0.0.0:8080:8080" + mem_limit: 256m + cpus: 1
A jellyfin-year-review/compose.yaml

@@ -0,0 +1,43 @@

+services: + review: + image: reel-review:1.0.0 + build: . + user: "${REVIEW_UID:-10001}:${REVIEW_GID:-10001}" + restart: unless-stopped + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + environment: + APP_ORIGIN: ${APP_ORIGIN:?Set APP_ORIGIN} + JELLYFIN_URL: ${JELLYFIN_URL:?Set JELLYFIN_URL} + volumes: + - ./data:/data:ro + ports: + - "127.0.0.1:8080:8080" + networks: [media] + mem_limit: 256m + cpus: 1 + generate: + image: reel-review:1.0.0 + build: . + profiles: [tools] + user: "${REVIEW_UID:-10001}:${REVIEW_GID:-10001}" + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + environment: + JELLYFIN_URL: ${JELLYFIN_URL:?Set JELLYFIN_URL} + JELLYFIN_API_KEY: ${JELLYFIN_API_KEY:-} + REVIEW_TIMEZONE: ${REVIEW_TIMEZONE:-UTC} + volumes: + - ./data:/data + - ./cache:/cache + - ./input:/input:ro + networks: [media] + command: [generate, --year, "2025", --input, /input/snapshot.json, --output, /data, --cache, /cache] + mem_limit: 1g + cpus: 2 +networks: + media: + external: true + name: ${JELLYFIN_NETWORK:-media}
A jellyfin-year-review/docs/format.md

@@ -0,0 +1,66 @@

+# JSON contracts v1 + +The machine-readable owner-visible format is [review.schema.json](review.schema.json). The authoritative Go contracts are `internal/model/model.go`. JSON is UTF-8, timestamps are RFC3339 instants with offsets, durations are seconds unless explicitly stated, and all user IDs use lowercase 32-hex UUIDs. Unknown source fields and schema versions are rejected. Generated reviews carry `schemaVersion: 1`; both the server and browser reject unsupported review versions. To evolve the meaning or required shape of fields, increment the version and update both consumers. Optional metadata may be absent; `null` personality/marathon/ranking means unavailable and is accompanied by availability evidence. + +## Canonical source snapshot + +A private snapshot contains: + +| Field | Contract | +|---|---| +| `schemaVersion` | Exactly 1 | +| `capturedAt` | Required timestamp of the fixed snapshot, used as deterministic `generatedAt` | +| `source`, `coverage` | Required honest descriptions of collector/version, collection window, retention and gaps | +| `users` | Array of `{id, name}`; immutable ID and display name, no credentials | +| `items` | Array of unique media metadata records | +| `playback` | Finalized unique activity records, or identical duplicates | + +An item has `id`, `title`, `type` (`Movie`, `Episode`, `Series`, or an intentionally excluded type), `genres` (array); optional `seriesId`, `seriesTitle`, `year`, nonnegative `runtimeSeconds`, `imageTag` (the Jellyfin Primary image cache tag). `poster` is generator-owned and any input value is discarded by the CLI's artwork resolver. When `JELLYFIN_URL` and `JELLYFIN_API_KEY` are configured, the generator downloads posters by actual Jellyfin item UUIDs and optional image tags; without them a canonical snapshot can still generate reviews with poster placeholders. Media identifiers need not be user UUIDs and never become filesystem paths. + +A playback record has `id`, `userId`, `itemId`, positive `seconds`, boolean `exactInterval`, `device`, and `client`. For exact records, provide `start` and `end`, and ensure `seconds = end - start` within 0.01 seconds. For aggregate records, provide `observedAt`, the actual finalized record timestamp; no start/end inference is performed. Optional `positionStart` and `positionEnd` must be supplied together as nonnegative media-position seconds in ascending order; they mean the range actually watched, not just the furthest seek destination. Emit separate ranges if seeking skips content. A zero-length position range is permitted and contributes no coverage. Source times have offsets, so ambiguous local DST timestamps are rejected by normal RFC3339 parsing. + +See `testdata/snapshot.json` for a full, explicitly synthetic two-user/two-year example. Unknown/missing user references are skipped and reported; invalid identities fail. Missing item references are excluded and reported per owner. The file size is bounded at 256 MiB; each raw Jellystat JSONL row is bounded at 1 MiB. Prefer smaller source snapshots for this under-30-user deployment. + +## Private published dataset + +```text +data/ + CURRENT # 64-hex dataset content digest + datasets/<digest>/ + manifest.json # schemaVersion + users: { normalizedId: [years...] } + <normalized-user-id>/<year>.json + art/<sha256>.jpg +``` + +All generated directories use mode 0700, JSON/images 0600. The serving OS account must have read access. A staged dataset is completed and synced before `CURRENT` changes. The private manifest and reviews never sit in the frontend webroot. Old snapshots remain for recovery; no public file server points at `data/`. + +## Owner-visible review + +Required identity/provenance fields: `schemaVersion`, `user: {id, name}`, `year`, `timezone`, `generatedAt`, `source`, `coverage`, `warnings` (array of plain-text strings), `availability` (map of metric → `{available, reason?}`). Availability keys are `exactWatchTime`, `completion`, `marathon`, `personality`, and `ranking`. Their reasons explain unavailable or partially unavailable metrics; zero and unavailable are distinct states. + +`rules` records the effective `minimumSeconds`, `completionPercent`, and `marathonGapSeconds` used for this generation. + +Statistics: `seconds`, `movieSeconds`, `tvSeconds`, `records`, `uniqueTitles`, `activeDays`. `movies`, `shows` and `abandoned` contain `{id, title, seconds, records, poster?, completionPercent?}`. `poster` is a local `/art/<sha256>.jpg` reference. TVmaze fallback posters also include a `posterSource` link to their TVmaze show page for attribution. Completion percentages are present only where actual media coverage supports them. Full content arrays are retained. Movie and show entries include `months`, a sorted list of local calendar months (1–12) in which the title was watched. Exact intervals contribute every month they overlap within the selected year; aggregate records use their observed end month. Episodes are grouped under their series, with each title appearing once per month. The UI shows every title grouped by month, alphabetically within each month. Older exports without `months` remain visible in a separate undated list; regenerate the selected year to enable its timeline. + +`genres`, `devices` and `clients` contain `{name, seconds, records}`. Genre record count is not a unique-title count and is secondary to the documented weighted seconds. `hourly` is length 24, `weekdays` length 7 (Sunday first), `monthly` length 12 (January first). `distributionMetric` is either `watched minutes` or `activity record ends (counts)`; do not infer units from a chart title. + +`marathon` is null or `{start, end, watchedSeconds, elapsedSeconds, records}`. `personality` is null or `{name, explanation}`. `ranking` is null or `{rank, cohort, percentile, averageSeconds}`. No other account identities are included. See [metrics](metrics.md) for exact definitions and tie rules. + +## HTTP API + +All responses are `Cache-Control: no-store`, no CORS access is enabled. Errors use `{error: string}` without upstream error bodies. + +| Method/path | Behavior | +|---|---| +| `GET /api/config` | Public mode-only `{authMode:"jellyfin"}` or `{authMode:"offline"}`; no identities, counts or credentials | +| `POST /api/login` | JSON `{username,password}`; validates through the configured online/offline backend, issues opaque HTTP-only cookie | +| `GET /api/session` | Validates the online token or current offline credential revision, returns `{user:{id,name}, years:[...]}` for that token's actual identity only | +| `GET /api/review?year=2025` | Validates the session and reads only that identity's review; extra query parameters are rejected | +| `POST /api/logout` | Deletes local app session/cookie; online mode also revokes only that app device's Jellyfin token | +| `GET /art/<sha256>.jpg` | Public generated shared JPEG; restricted name, never arbitrary files | + +State-changing requests require `Content-Type: application/json` and exact configured `Origin`. Missing/tampered/expired/revoked sessions return 401; Jellyfin outages return 503 with no private data; unknown reviews return 404; invalid years/selector attempts return 400; CSRF origin failures return 403; login throttling returns 429. There is no user-selecting review route, public manifest, admin impersonation, or statistics endpoint. Deep links `/review` and `/review/<year>` return only the static application shell; authenticated API reads supply the private data. + +## Separate offline credential format v1 + +`auth/users.json` has `{schemaVersion:1, sourceVersion:"12.2", exportedAt:<RFC3339>, users:[...]}`. Each user has normalized UUID `id`, `username`, boolean `disabled`, and `passwordHash`. A disabled entry has an empty hash; enabled entries require a supported salted Jellyfin PHC-style hex PBKDF2 verifier. These fields are **secret input**, never part of the owner-visible review schema or a public API. The exporter writes at most 1 MiB and 1,000 accounts, with mode 0600. The server also accepts mode 0400. See [offline-auth.md](offline-auth.md) for exact hash bounds, account exclusions, atomic refresh, and session revocation semantics.
A jellyfin-year-review/docs/metrics.md

@@ -0,0 +1,65 @@

+# Metric definitions (v1) + +All calculations happen in `generate`. The serving process never recalculates metrics. Sources must contain finalized records, not repeated progress updates. Native Jellystat rows are aggregates and may combine resumes. Canonical `exactInterval: true` rows represent uninterrupted watched wall-clock intervals, with duration exactly matching end minus start. If a collector has pauses, it must emit separate watched intervals, not spread watched time over the entire paused session. + +## Normalization and eligibility + +- Normalize user UUIDs to lowercase, hyphen-free 32-hex strings. Reject malformed or duplicate user identities. Media IDs are case-folded with hyphens removed for consistent Jellyfin matching. +- Duplicate `(userId, recordId)` records are removed only when all canonical fields agree. Conflicting duplicates fail generation. Different record IDs are distinct; no heuristic joining or double summing of cumulative progress updates is attempted. An upstream collector must finalize cumulative updates first. +- Invalid/nonpositive/nonfinite durations, durations longer than 366 days, invalid position bounds, and invalid exact intervals fail generation. Default minimum original-record watch time is **60 seconds**, adjustable with `--minimum-seconds`. Apply this before clipping the local year, so short year-boundary portions of valid sessions survive. +- Only `Movie` and `Episode` contribute. Series are metadata/grouping records. Audio, music videos, trailers, unsupported types, and records with deleted/missing media metadata are excluded. A missing media record produces a warning rather than invented title/type. A trailer incorrectly identified by Jellyfin as a movie cannot be distinguished; correct its metadata upstream. +- Missing device/client names form an explicit `Unknown` group. Names have whitespace collapsed and case-insensitive grouping; the lexicographically smaller display spelling wins. No IP addresses appear in output. +- Missing runtimes never affect measured watch duration. They make media completion unavailable. + +## Year, timezone, and temporal precision + +The effective IANA timezone is the configured default (UTC unless overridden) or a per-user ID mapping. Each local year's boundary is January 1 00:00 inclusive to next January 1 exclusive, converted by Go's timezone rules to actual instants. Exact intervals are clipped to this range. DST and the server's process timezone do not change attribution. + +Exact interval distributions allocate **watched minutes** over actual UTC minute segments and label each segment by local hour, weekday (Sunday index 0), and month (January index 0). Fractional minutes are retained. Repeated DST hours accumulate into the same local-hour bucket; skipped hours contain no fabricated time. Midnight/year crossings split correctly. Simultaneous records contribute their own measured activity seconds and minutes to personal totals: these are summed device activity, not deduplicated human attention. Only marathon watch time unions overlap. + +Jellystat aggregate rows retain their measured duration but assign the entire record to the **local year of its observed end**. Pauses/resumes and the updated stop timestamp mean exact selected-year attribution is unavailable. Distributions then show **activity record ends (counts)**, not watched minutes or session starts. If even one eligible record has aggregate precision, all distributions for that review switch to this consistent unit; exact records in the mixed set use their clipped end. Exact yearly watch-time availability is false. The generator never pretends these rows reconstruct continuous sessions. + +`activeDays` counts dates containing exact watched intervals for exact reviews; for mixed/aggregate reviews it counts attributed end-record dates. It is not a streak. `records` is an activity-record count. `uniqueTitles` counts unique movie and episode IDs, not series. Repeated activity records, completed plays, and unique titles are deliberately distinct. + +## Content, completion, genres, and artwork + +Movie totals group by movie ID. Show totals group by series ID when known; otherwise the episode ID/title remains a standalone entry. Sort by watched seconds descending, then record count descending, then normalized media ID ascending. Equal scores therefore remain deterministic. The frontend presents the top five, while the document retains all entries. + +Completion requires movie runtime **and actual media position ranges for every contributing record**, not duration/runtime arithmetic. Union the recorded position ranges across all the selected year's resumed records, clamp to runtime, then compute `100 * coveredPositions / runtime` (capped at 100). Repeated ranges never inflate coverage. Different resumes covering different parts of a movie combine. Any record without position coverage, without runtime, or clipped at the year's boundary makes that movie unavailable. Jellystat's final `PlayState.PositionTicks` alone does not establish the watched range, so it is never used to invent coverage. + +The default completion threshold is **90%**, configurable with `--completion-percent`. Movies with known coverage below it are classified as abandoned/unfinished **within the selected year**. A later complete resume in the same year prevents an earlier partial record being listed. Completion in a later year is deliberately outside this year's snapshot. Unknown coverage is omitted from abandonment; availability explains that the resulting list may be incomplete. The UI rounds the displayed percentage; classification uses the unrounded value. + +Genre watch seconds are split equally over distinct genres for each media item. Episodes inherit series genres when absent. A missing genre becomes Unknown. Sorting is seconds descending then display label ascending. Device/client charts use the same watch-second weighting, even for aggregate rows (with the same year-attribution limitation). + +Metadata comes from Jellyfin. During generation, posters are downloaded from Jellyfin’s Primary image endpoint using the existing operator API key and exact movie/series IDs; there is no external metadata matching or separate artwork credential. Only titles in the selected year’s generated reviews are downloaded, with repeated titles shared across users and chapters. Show posters use their parent series. Missing, deleted or invalid images keep explicit placeholders. Shared local JPEG names are SHA-256 content hashes, not user identities. Successful downloads are cached by server URL, item ID and Primary image tag; changing a tag fetches new artwork. If no image tag is available, generation refreshes the image each time. Serving reads only the published local copies. + +## Marathons + +Exact intervals only. Sort by start, then end. Intervals belong to one run while the next start is at most **45 minutes** after the current maximum end; configure with `--marathon-gap` (0 through 24h). The watched duration is the union of the run's intervals, so overlapping playback counts once. The elapsed span is last end minus first start, including gaps. The run with most watched seconds wins; earliest start breaks a tie. A single interval is a valid run. Aggregate history yields unavailable, not an estimate of a marathon. + +## Cohort comparisons + +The cohort includes enabled users in the source identity snapshot with at least **3,600 contributed watch seconds** in their effective local year. Users without history and those below one hour are ineligible. Rank is `1 + number of strictly greater totals`, so ties use competition ranks (1, 1, 3). Percentile is `100 * strictlyLowerTotals / (cohortSize - 1)`; a singleton has percentile 0. Equal peers are not treated as lower. The comparison includes rank, cohort size, percentile and cohort average seconds only. It exposes no other identities or histories. For Jellystat history comparisons use end-attributed aggregate totals and carry the same limitation as personal totals. + +## Exactly 14 personalities + +These are playful deterministic viewing labels, never psychological assessments. Every label requires **at least 10 eligible exact records and 5 watched hours**. Aggregate/mixed history or insufficient data is unavailable, not a fifteenth type. Evaluate these rules **in this order**; the first true rule wins. This makes ties explicit and stable. + +| Priority | Label | Evidence | +|---|---|---| +| 1 | Night Owl | At least 40% of watch minutes in local 23:00–05:00 | +| 2 | Early Bird | At least 40% in 05:00–10:00 | +| 3 | Weekend Warrior | At least 60% on Saturday/Sunday | +| 4 | Marathoner | Longest run has at least 6 watched hours | +| 5 | Binge Watcher | At least 70% TV, longest run ≥3 records and ≥3 watched hours | +| 6 | Movie Buff | Movies account for at least 80% of watch time | +| 7 | Series Devotee | TV accounts for at least 80% of watch time | +| 8 | Comfort Rewatcher | `(records - unique movie/episode IDs) / records ≥ 0.5` | +| 9 | Genre Explorer | At least 6 known genres each weighted at ≥3% of watch time | +| 10 | Prime-Time Regular | At least 50% of minutes in local 19:00–23:00 | +| 11 | Daily Viewer | At least 180 distinct watched dates | +| 12 | Seasonal Viewer | At least 40% of minutes in one month | +| 13 | Short-Session Viewer | Mean exact uninterrupted record duration ≤25 minutes | +| 14 | Balanced Viewer | Eligible data with none of the preceding dominant patterns | + +The result includes the matching evidence sentence. Rewatching and short-session labels are record-based proxies: even exact intervals do not establish separate completed plays or intentions. Multiple pause-separated intervals for the same title may affect these proxies. Each supplied exact record's meaning must be documented by its collector in source coverage.
A jellyfin-year-review/docs/offline-auth.md

@@ -0,0 +1,84 @@

+# Serve independently of Jellyfin + +Offline mode lets users log in with the username and password they had in a private Jellyfin **12.2 built-in account snapshot**. Serving needs only the generated `data/`, static frontend, and `auth/users.json`. It creates no Jellyfin client or token and makes no Jellyfin requests for login, session validation, review delivery, or logout. Jellyfin posters and any TVmaze fallback posters are downloaded during generation and served from local files. + +The authentication export is a separate secret artifact, **never part of generated review JSON, its manifest, or the frontend build**. It contains salted password verifiers, not plaintext passwords. Someone who obtains it can attempt offline guessing of the corresponding Jellyfin passwords, so transfer it over SSH and retain private filesystem permissions. The application never returns verifiers to the browser or logs them. + +## 1. Export on the Jellyfin host + +Requires Python 3.9+ with its standard-library SQLite module; no Python package installation or production Python server is needed. The exporter reads the Jellyfin SQLite database containing `Users` and `Permissions`. For Docker installations this is normally the host file mounted at `/config/data/jellyfin.db`; check your own volume mapping. Do not point it at Jellystat, Playback Reporting, or an unsafe copy of a live `.db` file. + +```sh +cd reel-review +umask 077 +mkdir -p auth +chmod 700 auth +python3 scripts/export-jellyfin-auth.py \ + --database /path/to/jellyfin/config/data/jellyfin.db \ + --output ./auth/users.json +``` + +Run as an account allowed to read the database and its WAL/SHM files. The source connection uses SQLite `mode=ro`, `query_only`, and a **single read transaction** spanning schema checks, users and permission reads. This pins one consistent snapshot and includes committed WAL contents without copying the full media database or changing it. Do not use `immutable=1` on a live WAL database. If exporting from another machine or a read-only archival mount, first obtain a safe SQLite online backup or stop Jellyfin cleanly before copying its database; copying only a running `.db` can lose recent credential/status updates. No Jellyfin API access is required for this export. + +The script checks the supported 12.2 schema, normalizes UUIDs, and reports counts only: + +- Enabled local accounts with a supported salted password verifier can log in offline. +- Disabled accounts are exported as blocked entries **without their verifiers**. +- Accounts without remote-access permission, with an access schedule, or requiring a password update are blocked. Offline mode does not implement their interactive/scheduled policies. +- External/LDAP/custom-provider and passwordless accounts are omitted. There is no fallback to another provider or blank-password access. +- Missing status permissions, malformed/unsupported hashes, duplicate identities, or unsupported schemas fail the export and preserve the last successful file. Sign in to Jellyfin to migrate an obsolete hash before exporting again; this application never changes Jellyfin credentials. + +The exporter writes mode 0600 files and atomically replaces the destination after validation and fsync. If replacing an existing private regular file, it first saves `users.json.previous` as a private backup. Symlinks and group/world-readable existing exports are refused. The backup is equally sensitive; restoring it restores the old credential snapshot. The credential format is bounded to 1 MiB and 1,000 accounts, comfortably beyond the intended under-30-user deployment. + +## 2. Transfer reviews and credentials privately + +The project's normal code-upload rsync command excludes `auth/`, `data/`, input databases and environment files through `.gitignore`. Upload runtime data separately. For the first installation, create private `data/` and `auth/` directories on the remote host and transfer both **before starting** the service: + +```sh +ssh user@server 'umask 077; mkdir -p /srv/reel-review/data /srv/reel-review/auth' +rsync -av --chmod=D700,F600 ./data/ user@server:/srv/reel-review/data/ +rsync -av --chmod=F600 ./auth/users.json user@server:/srv/reel-review/auth/users.json.new +ssh user@server 'cd /srv/reel-review/auth && if test -f users.json; then cp -p users.json users.json.previous; fi && mv users.json.new users.json' +``` + +Use your actual destination and ensure the serving UID/GID can read these files. A transfer account that owns private files can also be the non-root Compose runtime UID/GID. Do not upload the Jellyfin database, API keys, or generator cache to the remote serving host. + +## 3. Start offline serving + +Locally, after building and generating reviews: + +```sh +export APP_ORIGIN=https://review.example.com +./review serve --data ./data --web ./web/build \ + --auth-mode offline --auth-file ./auth/users.json +``` + +Or set `REVIEW_AUTH_MODE=offline` and `REVIEW_AUTH_FILE=/absolute/private/path/users.json`. `JELLYFIN_URL` is unnecessary and is not read by offline serving. Credentials must be outside both the frontend directory and the generated-data directory; the server rejects unsafe placement, symlink files and permissive modes (use 0600 or 0400). + +For the separate remote server, use the **standalone** offline Compose file: + +```sh +# .env needs only APP_ORIGIN and optional non-root REVIEW_UID/REVIEW_GID. +docker compose -f compose.offline.yaml build +docker compose -f compose.offline.yaml up -d +``` + +This file has no generator, external Jellyfin network or Jellyfin URL. It mounts `data/` and `auth/` read-only and retains the scratch image, non-root runtime and other hardening. Python runs only on the exporting host. Configure HTTPS and the public origin as in the main README. The login page identifies local snapshot validation rather than claiming to contact Jellyfin. + +## Credential changes and session revocation + +Changes made in Jellyfin are unknown to the disconnected service until you export and upload a replacement. **An old password continues working offline until that refresh**, and newly disabled/deleted Jellyfin users remain eligible until their changes reach the export. There is no background sync or scheduled refresh. + +To refresh, rerun the exporter and repeat the `users.json.new` transfer and remote `mv` above. Mount the **auth directory**, not an individual file, so Docker sees atomic inode replacements. On each private request the server opens and validates the current export; it does not retain a credential cache or require a restart: + +- Removed/disabled accounts immediately fail subsequent validation. +- Changing a user's password verifier or username invalidates that user's existing app sessions. They sign in again using the refreshed credentials. +- Updating only the export timestamp or an unrelated account preserves unaffected sessions. +- Missing, corrupt, unsupported or newly permissive exports fail closed with 503, exposing no private reviews and falling back to neither cached credentials nor Jellyfin. Restore a valid private file to recover availability. +- Logout deletes only the local app session. There is no Jellyfin token to revoke. Expiry remains 12 hours and app restarts end sessions. + +The server uses per-account credential fingerprints only in server memory; hashes and fingerprints are never sent to JavaScript or cookies. Password checks use constant-time derived-key comparison, bounded PBKDF2 parameters and at most two concurrent derivations. Unknown/disabled accounts also perform a default-cost dummy derivation and receive generic credential errors. Existing CSRF, body limits, login rate limits and owner-only authorization still apply. + +Supported verifiers match Jellyfin 12.2: `$PBKDF2-SHA512$iterations=N$HEX_SALT$HEX_HASH` (64-byte output) and legacy `$PBKDF2$iterations=N$HEX_SALT$HEX_HASH` (SHA-1, 32-byte output). Supported salts are 8–128 bytes and iteration counts 1–1,000,000; the current Jellyfin default is SHA-512, 210,000 iterations, a 16-byte salt. These existing verifiers are checked without rehashing or mutating the source. Passwords use UTF-8; ASCII usernames are case-insensitive. For non-ASCII usernames use the exact exported spelling, avoiding differences between .NET and Go Unicode case mappings. + +When updating review data on a running remote service, upload the new immutable `datasets/<digest>/` directory completely **before** atomically replacing its `CURRENT` pointer. Do not overwrite files inside the dataset currently being served. Authentication files remain separate throughout generation and transfer.
A jellyfin-year-review/docs/review.schema.json

@@ -0,0 +1,494 @@

+{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:reel-review:review:1", + "title": "Owner-visible Reel Review v1", + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "rules": { + "type": "object", + "properties": { + "minimumSeconds": { + "type": "number", + "minimum": 0 + }, + "completionPercent": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 100 + }, + "marathonGapSeconds": { + "type": "number", + "minimum": 0, + "maximum": 86400 + } + }, + "required": [ + "minimumSeconds", + "completionPercent", + "marathonGapSeconds" + ], + "additionalProperties": false + }, + "user": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-f0-9]{32}$" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + }, + "year": { + "type": "integer", + "minimum": 1970, + "maximum": 9998 + }, + "timezone": { + "type": "string" + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "string" + }, + "coverage": { + "type": "string" + }, + "availability": { + "type": "object", + "required": [ + "exactWatchTime", + "completion", + "marathon", + "personality", + "ranking" + ], + "additionalProperties": { + "type": "object", + "properties": { + "available": { + "type": "boolean" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "available" + ], + "additionalProperties": false + } + }, + "seconds": { + "type": "number", + "minimum": 0 + }, + "movieSeconds": { + "type": "number", + "minimum": 0 + }, + "tvSeconds": { + "type": "number", + "minimum": 0 + }, + "records": { + "type": "integer", + "minimum": 0 + }, + "uniqueTitles": { + "type": "integer", + "minimum": 0 + }, + "activeDays": { + "type": "integer", + "minimum": 0 + }, + "movies": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "poster": { + "type": "string", + "pattern": "^/art/[a-f0-9]{64}\\.jpg$" + }, + "seconds": { + "type": "number", + "minimum": 0 + }, + "records": { + "type": "integer", + "minimum": 0 + }, + "completionPercent": { + "type": "number", + "minimum": 0, + "maximum": 100 + } + }, + "required": [ + "id", + "title", + "seconds", + "records" + ], + "additionalProperties": false + } + }, + "shows": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "poster": { + "type": "string", + "pattern": "^/art/[a-f0-9]{64}\\.jpg$" + }, + "seconds": { + "type": "number", + "minimum": 0 + }, + "records": { + "type": "integer", + "minimum": 0 + }, + "completionPercent": { + "type": "number", + "minimum": 0, + "maximum": 100 + } + }, + "required": [ + "id", + "title", + "seconds", + "records" + ], + "additionalProperties": false + } + }, + "abandoned": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "poster": { + "type": "string", + "pattern": "^/art/[a-f0-9]{64}\\.jpg$" + }, + "seconds": { + "type": "number", + "minimum": 0 + }, + "records": { + "type": "integer", + "minimum": 0 + }, + "completionPercent": { + "type": "number", + "minimum": 0, + "maximum": 100 + } + }, + "required": [ + "id", + "title", + "seconds", + "records" + ], + "additionalProperties": false + } + }, + "genres": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "seconds": { + "type": "number", + "minimum": 0 + }, + "records": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "name", + "seconds", + "records" + ], + "additionalProperties": false + } + }, + "devices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "seconds": { + "type": "number", + "minimum": 0 + }, + "records": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "name", + "seconds", + "records" + ], + "additionalProperties": false + } + }, + "clients": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "seconds": { + "type": "number", + "minimum": 0 + }, + "records": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "name", + "seconds", + "records" + ], + "additionalProperties": false + } + }, + "distributionMetric": { + "enum": [ + "watched minutes", + "activity record ends (counts)" + ] + }, + "marathon": { + "anyOf": [ + { + "type": "object", + "properties": { + "start": { + "type": "string", + "format": "date-time" + }, + "end": { + "type": "string", + "format": "date-time" + }, + "watchedSeconds": { + "type": "number", + "minimum": 0 + }, + "elapsedSeconds": { + "type": "number", + "minimum": 0 + }, + "records": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "start", + "end", + "watchedSeconds", + "elapsedSeconds", + "records" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "personality": { + "anyOf": [ + { + "type": "object", + "properties": { + "name": { + "enum": [ + "Night Owl", + "Early Bird", + "Weekend Warrior", + "Marathoner", + "Binge Watcher", + "Movie Buff", + "Series Devotee", + "Comfort Rewatcher", + "Genre Explorer", + "Prime-Time Regular", + "Daily Viewer", + "Seasonal Viewer", + "Short-Session Viewer", + "Balanced Viewer" + ] + }, + "explanation": { + "type": "string" + } + }, + "required": [ + "name", + "explanation" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "ranking": { + "anyOf": [ + { + "type": "object", + "properties": { + "rank": { + "type": "integer", + "minimum": 1 + }, + "cohort": { + "type": "integer", + "minimum": 1 + }, + "percentile": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "averageSeconds": { + "type": "number", + "minimum": 0 + } + }, + "required": [ + "rank", + "cohort", + "percentile", + "averageSeconds" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "hourly": { + "type": "array", + "items": { + "type": "number", + "minimum": 0 + }, + "minItems": 24, + "maxItems": 24 + }, + "weekdays": { + "type": "array", + "items": { + "type": "number", + "minimum": 0 + }, + "minItems": 7, + "maxItems": 7 + }, + "monthly": { + "type": "array", + "items": { + "type": "number", + "minimum": 0 + }, + "minItems": 12, + "maxItems": 12 + } + }, + "required": [ + "schemaVersion", + "rules", + "user", + "year", + "timezone", + "generatedAt", + "source", + "coverage", + "availability", + "seconds", + "movieSeconds", + "tvSeconds", + "records", + "uniqueTitles", + "activeDays", + "movies", + "shows", + "abandoned", + "genres", + "devices", + "clients", + "distributionMetric", + "marathon", + "personality", + "ranking", + "warnings", + "hourly", + "weekdays", + "monthly" + ], + "additionalProperties": false +}
A jellyfin-year-review/docs/security.md

@@ -0,0 +1,25 @@

+# Security and operational boundaries + +**Online authentication (default):** the operator configures one Jellyfin API URL and one exact public app origin. The login request cannot choose the API target. Credentials go only server-to-server to `/Users/AuthenticateByName`; passwords are not persisted, logged or returned. They live briefly in request/browser input memory. Jellyfin replies and network errors are never echoed into logs. No request logging middleware, analytics, third-party scripts or error-reporting SDKs are included. + +Each login generates a random, separate app device identity. The authentication result's user object is not trusted for authorization: the returned token is immediately checked with `/Users/Me`. Every private API request checks it again and compares the current ID to the stored identity. Disabled users and deleted/revoked identities lose access. During upstream failure access fails closed with 503. A Jellyfin admin receives their own review, with no cross-account switch. + +App session IDs have 256 bits of cryptographic entropy; only SHA-256 keys are kept in a bounded in-memory map with server-only Jellyfin tokens (online) or per-account credential revisions (offline), and absolute 12-hour expiry. No signed/encrypted client payload is needed. Cookies are HTTP-only, SameSite Strict, path `/`, with Secure for configured HTTPS. Forged/modified IDs are absent from the store; there is no client identity claim to forge. Restart invalidates all app sessions. Maximum 1,000 active sessions; expired entries are cleaned opportunistically. + +In online mode, logout removes the app session/cookie before calling Jellyfin `/Sessions/Logout` with only that device token. A Jellyfin failure still completes local logout and tells the user to revoke the Reel Review device manually later. There is no retry queue and no persisted token recovery. App expiry/restart discards tokens but does not promise upstream device cleanup. Operators may remove stale Reel Review devices from Jellyfin. Other clients' tokens are never touched. + +CSRF defense requires the exact configured Origin on every POST, rejects cross-site fetch metadata, accepts JSON only, and uses SameSite Strict cookies. Forwarded headers never override origin or the peer-IP login limit. JSON request bodies are limited to 4 KiB; headers to 16 KiB. Server/client deadlines bound reads, writes and upstream calls. Login allows 10 attempts per socket peer per minute and 60 globally, with a maximum 4,096 rate buckets; full capacity fails closed. This limit is in memory and resets on restart. Put the service behind an HTTPS reverse proxy on a private network and use its connection/rate controls for public exposure. + +Review IDs come exclusively from validated sessions; a normalized year selects a manifest-listed document. Extra user selectors are rejected. Generated JSON and manifests are outside the public webroot. Paths and artwork filenames are allowlisted. Static frontend reads use Go's rooted filesystem and reject directories, traversal and unknown paths. Dataset directories must remain operator-controlled; never give untrusted users filesystem write access to them. + +The production CSP uses same-origin scripts plus hashes of SvelteKit's compiled inline bootstrap; no `unsafe-inline` script permission or eval. Styles permit inline values for charts; images, fonts and fetches use self only. Objects, frames, base URLs, camera, microphone and geolocation are blocked. HSTS is emitted on configured HTTPS. Svelte escapes all displayed text and the application never uses unsanitized HTML insertion. Artwork is fetched during generation from the configured Jellyfin server, with TVmaze as a fallback for series. Jellyfin redirects are not followed and its operator API key is sent only in request headers to that server. TVmaze uses a separate credential-free client restricted to HTTPS at `api.tvmaze.com` and `static.tvmaze.com`; redirects may remain only on the same host. No user identities, passwords, watch dates or viewing durations are sent to TVmaze; searches use series titles, premiere years and TVDB/IMDb identifiers. Image response sizes and dimensions are bounded, and complete JPEGs are validated before caching/publication. The frontend has no third-party artwork or metadata requests. + +The Docker context uses an allowlist. Private environment files, source exports, generated data, caches, Git metadata, tests, and local builds are excluded. The runtime is non-root, has no shell, and the Compose service has a read-only root and data mount, dropped capabilities and no-new-privileges. Generator mounts are explicitly separate. A private filesystem backup contains sensitive viewing history, even if users later disappear from the active manifest. Apply your own retention policy; the application does not automatically erase archival datasets. + +Resource scope is a small trusted operator deployment. Source JSON snapshots are bounded at 256 MiB, raw rows at 1 MiB, inventory at one million records, metadata responses/JPEGs at 5 MiB and image dimensions at 4096². Processing time grows with history length and exact interval minutes. Generator memory/CPU limits in Compose constrain accidental overload. No live deployment, database mutation, or external CI service is part of this project. + +## Offline authentication + +Offline mode constructs no Jellyfin client. The separately mounted private `auth/users.json` is its authority for logins and account validity; it is reread on every private request. Salted Jellyfin 12.2 PBKDF2 verifiers never enter generated review JSON or the public webroot. Supported current/legacy algorithms use Go's standard-library PBKDF2 and constant-time comparison with bounded parameters and two concurrent derivations. Unknown/disabled accounts perform dummy default-cost derivation and receive the same generic authentication failure. Group/world-readable files, symlinks, unsupported schemas and unsafe credential placement are rejected. Invalid exports fail closed; there is no fallback to live Jellyfin or a stale credential cache. + +Fingerprint changes revoke affected sessions after credential updates. Logout is local-only. No disconnected service can learn of changes made in Jellyfin without a new export; old passwords and stale account status remain authoritative until refresh. Python's source exporter selects Users and Permissions within one read-only SQLite transaction, including WAL contents, and saves a private `.previous` backup before atomic replacement. It excludes external/passwordless accounts and blocks disabled or restricted accounts. Protect both the export and its backup against theft and offline guessing of Jellyfin passwords. Only transfer them privately over SSH. The standalone offline Compose file needs no media network or upstream credentials. Full procedures and supported policies are in [offline-auth.md](offline-auth.md).
A jellyfin-year-review/docs/sources.md

@@ -0,0 +1,33 @@

+# Source investigation and supported contracts + +Verified 2026-10-17 against primary sources, plus synthetic representative fixtures. No production Jellyfin/Jellystat installation was accessed or changed. These are source-verified API/schema contracts, not a claim that every release was integration-tested against a live installation. + +- [Jellyfin current release v12.2](https://github.com/jellyfin/jellyfin/releases/tag/v12.2). Authentication follows the official [UserController](https://github.com/jellyfin/jellyfin/blob/v12.2/Jellyfin.Api/Controllers/UserController.cs) (`POST /Users/AuthenticateByName`, `GET /Users/Me`, `GET /Users`) and [SessionController](https://github.com/jellyfin/jellyfin/blob/v12.2/Jellyfin.Api/Controllers/SessionController.cs) (`POST /Sessions/Logout`). Inventory uses `/Items` with recursive movie/episode/series selection, genres and Primary image tags. A server configured with custom authentication providers must still accept the standard Jellyfin username/password API. No OIDC or account linking is used. API keys need permission to list users and all inventory; fresh user login in the default online mode needs reachable Jellyfin. Offline account export and local validation are described separately below. +- [Jellystat 1.1.12](https://github.com/CyferShepard/Jellystat/releases/tag/1.1.12) raw activity contract; source also inspected at main commit [`e0a38eeeff4c4bbaba60abad5f76e8a54415d1a8`](https://github.com/CyferShepard/Jellystat/tree/e0a38eeeff4c4bbaba60abad5f76e8a54415d1a8). [ActivityMonitor](https://github.com/CyferShepard/Jellystat/blob/1.1.12/backend/tasks/ActivityMonitor.js) accumulates seconds while unpaused, finalizes rows, and can merge resumed records into existing rows. Its stop/merge timestamp updates explain why `ActivityDateInserted - PlaybackDuration` is not a reliable historical watch interval. [Raw activity mapping](https://github.com/CyferShepard/Jellystat/blob/1.1.12/backend/models/jf_playback_activity.js) supplies ID, user, movie/series ID, episode ID, duration and timestamp. `EpisodeId` takes precedence over `NowPlayingItemId` when present. +- Jellystat's [API routes](https://github.com/CyferShepard/Jellystat/blob/1.1.12/backend/routes/api.js) and [Swagger contract](https://github.com/CyferShepard/Jellystat/blob/1.1.12/backend/swagger.json) expose history endpoints, but `/api/getHistory` groups records and replaces durations with sums. Pagination during live collection is also not an immutable snapshot. This implementation deliberately uses a native raw-table export in a repeatable-read transaction instead. It needs read-only SELECT access to the existing Jellystat PostgreSQL instance, no Jellystat API token or new database. +- [Jellystat's plugin import migration](https://github.com/CyferShepard/Jellystat/blob/1.1.12/backend/migrations/101_ji_insert_playback_plugin_data_to_activity_table.js) marks Playback Reporting imports with `imported=true`. Such rows are explicitly excluded/rejected. The task explicitly prohibited Playback Reporting despite later inherited language recommending it, so no plugin schema, plugin connection, or plugin database reader is supported. This project does not claim compatibility with Playback Reporting schemas. Review generation does not query Jellyfin SQLite. The separate, explicitly selected offline authentication exporter reads only the 12.2 account/status schema described below. +- [PostgreSQL repeatable-read transaction semantics](https://www.postgresql.org/docs/current/transaction-iso.html#XACT-REPEATABLE-READ) support the single-table read-only export. [SQLite online backup](https://www.sqlite.org/backup.html) and [WAL behavior](https://www.sqlite.org/wal.html) explain why copying a live database file alone is unsafe. SQLite snapshots are archival guidance only, not a playback input for this app. +- [SvelteKit 3 static adapter](https://svelte.dev/docs/kit/adapter-static), [SPA fallback](https://svelte.dev/docs/kit/single-page-apps) and [v3 migration](https://svelte.dev/docs/kit/migrating-to-sveltekit-3): use Vite-hosted SvelteKit configuration, adapter fallback `index.html`, `ssr=false`, a rest route for deep links, and the current `$app/tsconfig` reference. No production Node process. Exact dependency versions come from official npm package dist-tags and are locked. +- [Go stable downloads](https://go.dev/dl/) and [Go rooted filesystem APIs](https://pkg.go.dev/os#OpenRoot): Go 1.27.2, static build with embedded `time/tzdata` for scratch deployment. + +The representative fixture covers native finalized row fields, numeric-string duration, episode precedence, null/unknown metadata, and duplicate records. It deliberately does not turn a raw Jellystat aggregate row into an exact canonical session. If an installed version has a different schema or mixed-server history, export only the configured Jellyfin server's native rows using a reviewed query. The provided default assumes one Jellyfin server per Jellystat database; cross-server identity collisions are outside the supported contract. + + +## Jellyfin 12.2 offline account export + +The offline exporter and verifier follow the exact `v12.2` sources: [User entity](https://github.com/jellyfin/jellyfin/blob/v12.2/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs), [SQLite model snapshot](https://github.com/jellyfin/jellyfin/blob/v12.2/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs), [PermissionKind](https://github.com/jellyfin/jellyfin/blob/v12.2/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/PermissionKind.cs) (disabled=2, remote access=4), [DefaultAuthenticationProvider](https://github.com/jellyfin/jellyfin/blob/v12.2/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs), [PasswordHash encoding](https://github.com/jellyfin/jellyfin/blob/v12.2/MediaBrowser.Model/Cryptography/PasswordHash.cs), [CryptographyProvider](https://github.com/jellyfin/jellyfin/blob/v12.2/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs), and [cryptography constants](https://github.com/jellyfin/jellyfin/blob/v12.2/MediaBrowser.Model/Cryptography/Constants.cs). Current default is salted PBKDF2-SHA512, 210,000 iterations, 64-byte output; legacy `PBKDF2` means SHA-1 with 32-byte output. The PHC-like format encodes salt/hash as hex, not base64. Only the current built-in provider ID is accepted for export. Unlike Jellyfin itself, offline export omits passwordless accounts and does not migrate hashes. + +Python's [read-only URI connections](https://docs.python.org/3/library/sqlite3.html#how-to-work-with-sqlite-uris) and SQLite's [read-transaction snapshot isolation](https://www.sqlite.org/isolation.html) support a consistent read-only export, including committed WAL content. Source access uses one transaction rather than a raw live `.db` copy. Serving uses Go's standard-library [crypto/pbkdf2](https://pkg.go.dev/crypto/pbkdf2) and requires no database driver or Python runtime. + +## Jellyfin artwork + +Posters use Jellyfin 12.2's [`GET /Items/{itemId}/Images/Primary`](https://github.com/jellyfin/jellyfin/blob/v12.2/Jellyfin.Api/Controllers/ImageController.cs) with `format=Jpg`, maximum dimensions 500×750, quality 85 and the inventory’s Primary image tag. The [ImageFormat enum](https://github.com/jellyfin/jellyfin/blob/v12.2/MediaBrowser.Model/Drawing/ImageFormat.cs) and [BaseItemDto image metadata](https://github.com/jellyfin/jellyfin/blob/v12.2/MediaBrowser.Model/Dto/BaseItemDto.cs) define that format and cache tag. Movie and parent-series IDs select the Jellyfin artwork first. Requests retain the configured Jellyfin base path and use the operator API key in an HTTP header only. Successful JPEG responses are cached privately and copied to the immutable dataset under SHA-256 filenames; the frontend uses `/art/<hash>.jpg` exclusively. Source failures, missing images or invalid JPEGs use placeholders. Redirects are rejected, request deadlines and three-attempt retry limits apply, and 429/5xx backoff is capped at three seconds per wait. + + +## TVmaze fallback artwork + +The [TVmaze public API](https://www.tvmaze.com/api) provides a generation-only fallback for series with unavailable Jellyfin artwork. It requires no API key. TVmaze does not provide a movie catalog, so movies retain placeholders if Jellyfin cannot provide a poster. Matching first tries `/lookup/shows` with Jellyfin TVDB/IMDb provider IDs (including the documented redirect); otherwise `/search/shows` and up to three `/shows/:id/akas` requests must identify one exact normalized title or alias, matching the premiere year when known. Ambiguous results are rejected rather than guessing. + +Posters are downloaded from TVmaze's image CDN, validated and converted to local JPEGs. Successful fallback posters and their attribution are cached, while failed searches/downloads are retried on the next generation. Jellyfin is still tried first every time. API requests are spaced at least 500 ms apart; HTTP 429/5xx responses receive bounded retries and backoff. Only title/provider metadata is sent to TVmaze, using a separate client without Jellyfin credentials. The browser serves the published `/art/` file and never calls either API. + +TVmaze API data is licensed under [CC BY-SA](https://creativecommons.org/licenses/by-sa/4.0/). Each fallback poster retains a `posterSource` link to its TVmaze show page, displayed as a TVmaze credit. Retain that attribution and the applicable license when redistributing the exported TVmaze material.
A jellyfin-year-review/docs/verification.md

@@ -0,0 +1,47 @@

+# Verification performed + +Completed in the isolated `reel-review/` directory on 2026-10-17: + +- `gofmt` check: clean. +- `go vet ./...`: passed with Go 1.27.2. +- `go test -race ./...`: passed. Synthetic tests cover duplicate normalization/conflicts, resumed movie coverage, missing metadata/runtime, invalid sources, native Jellystat versus prohibited imports, local year/midnight/DST boundaries, per-user timezone, minimum thresholds, genre weighting, overlapping/gapped marathon runs, cohort ties/singletons, all 14 personality labels and deterministic priority, deterministic datasets, multi-year preservation, publication failure, and writer serialization. +- HTTP tests: login success/failure, actual token identity overriding login-response identity, cookie properties, expiry, tampering, revocation, fail-closed Jellyfin outage, logout and failed-revocation behavior, login rate limiting, CSRF, body limits, owner-only review delivery, unavailable years, traversal/direct private paths, private manifest isolation, deep-link shell and CSP hashes, unsupported review schemas. +- Artwork tests use controlled Jellyfin image services; no real operator credentials or production image service is used. Current coverage is described below. +- `npm --prefix web run check`: zero errors, zero warnings. +- `npm --prefix web run build`: passed with SvelteKit 3.1.0 / Svelte 5.57.2 / Vite 8.3.4 and adapter-static 4.0.0. +- Playwright Chromium smoke: passed against the compiled static frontend served by the actual Go HTTP handler with a controlled synthetic Jellyfin service and real generated two-year reviews. Login, chapter controls, keyboard navigation, year selection, reload/deep link, mobile overflow, logout and post-logout access rejection were exercised. No browser page errors. Mobile and desktop screenshots were produced in ignored `web/test-results/`; the mobile result was visually inspected. +- Local CLI generation from the explicit synthetic fixture with `America/New_York`: passed. +- `docker compose config --quiet` with safe test origin/internal URL: passed. +- Multi-stage Docker build: passed. Final image is approximately 12 MB, user `10001:10001`, with no production Node process or shell. Official base image digests are pinned. +- Disposable Docker generator smoke: passed with read-only root, dropped capabilities, no-new-privileges, a read-only synthetic fixture bind mount, and private tmpfs output/cache owned by UID 10001. Published both synthetic user reviews. CLI help also exits successfully. + +This container's default home caches were read-only. Verification used temporary npm/Go/Docker caches under `/tmp`. Chromium's missing OS libraries were downloaded and extracted under `/tmp` and supplied through `LD_LIBRARY_PATH`; no system packages or host configuration were modified. On a normal development host, install Playwright's documented Chromium system prerequisites, then run `make e2e` after installing the browser. + +No checks against a live Jellyfin or Jellystat PostgreSQL database were performed. Their documented source contracts were inspected and represented by synthetic fixtures/HTTP services. The PostgreSQL export query should be reviewed against the installed Jellystat schema as described in the README; no real database was queried or modified. No live deployment, commit, or push was performed. Existing parent-repository changes were left untouched. + +## Offline authentication addition + +Verified after adding disconnected serving for Jellyfin 12.2: + +- `make check`: Go formatting, vet and race tests, Python exporter tests, and Svelte checks passed. No new Go or frontend dependencies were added; Python is an operator/test-only standard-library utility. +- Independent Python `hashlib` test vectors authenticate through Go's Jellyfin-compatible SHA-512 and legacy SHA-1 PBKDF2 paths, including a Unicode password. Tests cover unknown/disabled users, wrong passwords, duplicate identities, malformed/bounded hashes, missing account status, private permissions, symlink rejection, cancellation, and refresh-driven revocation. +- SQLite fixtures exercise committed, uncheckpointed WAL state, account permissions, external/passwordless/restricted exclusions, missing schema/status, zero eligible users, atomic export failure preservation and private previous-file backups. All six exporter tests passed. +- Offline HTTP tests cover owner-only delivery, extra-selector rejection, direct credential-file requests, generic credential errors, CSRF, throttling, expiry, local logout, malformed-export fail-closed behavior, changed password/disabled-account revocation, and unsafe credential placement. The mock Jellyfin service records **zero requests** during offline tests. +- Static frontend build passed. Browser login → generated review → year selection/deep-link reload → logout passed in **both** online and offline modes. The offline test server constructs no Jellyfin client or fake Jellyfin service, and its login page correctly labels local snapshot validation. +- Docker image rebuilt successfully. `compose.offline.yaml` validates with `JELLYFIN_URL` unset and has no external media-network dependency. +- A complete disposable runtime smoke created a synthetic 12.2 SQLite database, ran the actual exporter CLI, generated review JSON, then served the built image with **`--network none`**, no Jellyfin configuration, read-only mounts/root, dropped capabilities and a non-root UID. A helper sharing only its loopback namespace verified offline config, unauthorized rejection, successful local login, owner review rendering and logout revocation. No real credentials or production services were used, and test containers were removed afterward. + +Offline credentials are separate private runtime inputs. They are intentionally excluded from ordinary code-upload rsync and Docker contexts. Real Jellyfin password/status changes cannot be known by a disconnected server until an operator refreshes the export; see `docs/offline-auth.md` for transfer and atomic replacement instructions. + +## Jellyfin-only artwork + +Artwork generation now uses the existing Jellyfin operator API key and exact movie/parent-series IDs. No separate metadata/artwork service or credential is used. Only content in the selected recap is downloaded; successful Primary images are cached by server, item and image tag, then published as local SHA-256 JPEG assets. + +- Artwork tests pass for scoped headers/base paths, deduplicated downloads across users/chapters, avoiding unwatched library titles, cache reuse while the source is unreachable, image-tag invalidation, untagged refresh, corrupt-cache recovery, missing/deleted/unauthorized images, invalid/truncated/oversized JPEGs, unsafe item IDs, redirect rejection, bounded 429/5xx retry/backoff and cancellation. +- The CLI integration test uses source-shaped Jellyfin metadata/image responses and native Jellystat rows. It verifies correct movie/series posters, local files surviving source shutdown, deterministic repeat generation and cache reuse, and absence of source URLs/API keys in review JSON. +- The browser test downloads synthetic Jellyfin posters during generation, closes that image source before serving begins, and verifies a real decoded `/art/<hash>.jpg` poster in both online and offline authentication modes. It also asserts that the browser makes no external requests. +- Frontend configuration, assets, attribution and the generator schema no longer contain an external artwork dependency. Existing owner-visible review JSON remains format v1. Canonical source snapshots may supply `imageTag`; poster input URLs are discarded in favor of generated local references. + +The current Go formatting/vet/race suite, Python exporter tests, Svelte checks/production build, both browser flows, Docker image build and offline Compose configuration checks passed. No live Jellyfin data or services were accessed or modified. + +The local CLI binary was rebuilt with `go build -buildvcs=false -trimpath` because this workspace has an unavailable Git metadata mount. Docker builds exclude Git metadata and completed normally.
A jellyfin-year-review/go.mod

@@ -0,0 +1,3 @@

+module reelreview + +go 1.27.2
A jellyfin-year-review/internal/artwork/jellyfin.go

@@ -0,0 +1,257 @@

+// Package artwork caches Jellyfin and fallback posters for offline serving. +package artwork + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "image/jpeg" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strconv" + "time" + + "reelreview/internal/model" + "reelreview/internal/source" +) + +const maxImageBytes = 5 << 20 + +type Client struct { + Jellyfin *source.Jellyfin + Key, Cache string + wait func(context.Context, time.Duration) error + tvmaze *tvmazeClient +} + +func New(j *source.Jellyfin, key, cache string) *Client { + return &Client{Jellyfin: j, Key: key, Cache: cache, wait: waitRetry, tvmaze: newTVmaze()} +} +func digest(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) } + +// validJPEG verifies both allocation bounds and the full image before publication. +func validJPEG(b []byte) bool { + if len(b) > maxImageBytes { + return false + } + cfg, e := jpeg.DecodeConfig(bytes.NewReader(b)) + if e != nil || cfg.Width < 1 || cfg.Height < 1 || cfg.Width > 4096 || cfg.Height > 4096 { + return false + } + _, e = jpeg.Decode(bytes.NewReader(b)) + return e == nil +} +func waitRetry(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} +func retryDelay(header string, attempt int) time.Duration { + delay := time.Duration(attempt+1) * time.Second + if n, e := strconv.Atoi(header); e == nil && n > 0 { + if n > 3 { + n = 3 + } + delay = time.Duration(n) * time.Second + } else if date, e := http.ParseTime(header); e == nil { + if delta := time.Until(date); delta > 0 { + delay = delta + } + } + if delay > 3*time.Second { + delay = 3 * time.Second + } + return delay +} + +// fetch accepts only UUID item paths on the configured Jellyfin server. Image +// tags invalidate successful cache entries; untagged images are always refreshed. +func (c *Client) fetch(ctx context.Context, id, tag string) ([]byte, error) { + normalized, e := model.ID(id) + if e != nil { + return nil, fmt.Errorf("invalid Jellyfin artwork ID") + } + if c.Jellyfin == nil || c.Key == "" { + return nil, fmt.Errorf("Jellyfin artwork is not configured") + } + query := url.Values{"format": {"Jpg"}, "maxWidth": {"500"}, "maxHeight": {"750"}, "quality": {"85"}} + if tag != "" { + query.Set("tag", tag) + } + target := c.Jellyfin.URL + "/Items/" + normalized + "/Images/Primary?" + query.Encode() + if e = os.MkdirAll(c.Cache, 0700); e != nil { + return nil, fmt.Errorf("cannot create artwork cache") + } + file := filepath.Join(c.Cache, digest([]byte("jellyfin-primary-v1\x00"+target))) + if tag != "" { + if f, e := os.Open(file); e == nil { + b, readErr := io.ReadAll(io.LimitReader(f, maxImageBytes+1)) + f.Close() + if readErr == nil && validJPEG(b) { + return b, nil + } + } + } + for attempt := 0; attempt < 3; attempt++ { + if e = ctx.Err(); e != nil { + return nil, e + } + req, e := http.NewRequestWithContext(ctx, "GET", target, nil) + if e != nil { + return nil, fmt.Errorf("invalid artwork request") + } + req.Header.Set("Accept", "image/jpeg") + req.Header.Set("Authorization", fmt.Sprintf(`MediaBrowser Client="Reel Review", Device="Review generator", DeviceId="generator", Version="1.0.0", Token="%s"`, url.QueryEscape(c.Key))) + res, e := c.Jellyfin.HTTP.Do(req) + if e != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + if attempt == 2 { + return nil, fmt.Errorf("Jellyfin artwork unavailable") + } + } else { + b, readErr := io.ReadAll(io.LimitReader(res.Body, maxImageBytes+1)) + res.Body.Close() + if readErr != nil || len(b) > maxImageBytes { + return nil, fmt.Errorf("invalid artwork response") + } + if res.StatusCode == http.StatusOK { + if !validJPEG(b) { + return nil, fmt.Errorf("invalid JPEG artwork") + } + if e = c.cache(file, b); e != nil { + return nil, e + } + return b, nil + } + if res.StatusCode != http.StatusTooManyRequests && res.StatusCode < 500 { + return nil, fmt.Errorf("Jellyfin artwork unavailable (%d)", res.StatusCode) + } + } + if attempt < 2 { + header := "" + if res != nil { + header = res.Header.Get("Retry-After") + } + if e = c.wait(ctx, retryDelay(header, attempt)); e != nil { + return nil, e + } + } + } + return nil, fmt.Errorf("Jellyfin artwork retries exhausted") +} + +// cache atomically replaces validated artwork/cache records without exposing bodies. +func (c *Client) cache(path string, b []byte) error { + f, e := os.CreateTemp(c.Cache, ".poster-") + if e != nil { + return fmt.Errorf("cannot write artwork cache") + } + temp := f.Name() + defer os.Remove(temp) + _, e = f.Write(b) + if e == nil { + e = f.Sync() + } + closeErr := f.Close() + if e == nil { + e = closeErr + } + if e == nil { + e = os.Rename(temp, path) + } + if e != nil { + return fmt.Errorf("cannot write artwork cache") + } + return nil +} + +// Resolve downloads only content present in generated reviews, trying Jellyfin +// before TVmaze for series. Every poster reference points to a local JPEG. +func (c *Client) Resolve(ctx context.Context, items []model.Item, reviews []model.Review) (map[string][]byte, []string) { + assets := map[string][]byte{} + warnings := []string{} + needed := map[string]bool{} + index := map[string]model.Item{} + posters := map[string]string{} + sources := map[string]string{} + series := map[string]model.Item{} + for _, item := range items { + index[source.ItemKey(item.ID)] = item + } + for _, r := range reviews { + for _, v := range r.Shows { + series[v.ID] = model.Item{ID: v.ID, Title: v.Title, Type: "Series"} + } + for _, list := range [][]model.Content{r.Movies, r.Shows, r.Abandoned} { + for _, v := range list { + needed[v.ID] = true + } + } + } + // Discard any poster references supplied by a canonical input snapshot. + assign := func() { + for i := range reviews { + for _, list := range [][]model.Content{reviews[i].Movies, reviews[i].Shows, reviews[i].Abandoned} { + for j := range list { + list[j].Poster = posters[list[j].ID] + list[j].PosterSource = sources[list[j].ID] + } + } + } + } + if len(needed) == 0 { + assign() + return assets, warnings + } + if c.Jellyfin == nil || c.Key == "" { + assign() + return assets, []string{"Jellyfin artwork needs JELLYFIN_URL and JELLYFIN_API_KEY during generation; poster placeholders are used."} + } + ids := make([]string, 0, len(needed)) + for id := range needed { + ids = append(ids, id) + } + sort.Strings(ids) + failed := 0 + for _, id := range ids { + item, ok := index[id] + if !ok { + item = series[id] + } + if item.Type != "Movie" && item.Type != "Series" { + failed++ + continue + } + b, e := c.fetch(ctx, id, item.ImageTag) + if e != nil { + var sourceURL string + b, sourceURL, e = c.fallback(ctx, item) + if e != nil { + failed++ + continue + } + sources[id] = sourceURL + } + name := digest(b) + ".jpg" + assets[name] = b + posters[id] = "/art/" + name + } + assign() + if failed > 0 { + warnings = append(warnings, fmt.Sprintf("Posters unavailable for %d titles after Jellyfin and TVmaze fallback; poster placeholders are used.", failed)) + } + return assets, warnings +}
A jellyfin-year-review/internal/artwork/jellyfin_test.go

@@ -0,0 +1,201 @@

+package artwork + +import ( + "bytes" + "context" + "image" + "image/color" + "image/jpeg" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "reelreview/internal/model" + "reelreview/internal/source" +) + +const movieID = "11111111111111111111111111111111" +const seriesID = "22222222222222222222222222222222" +const unusedID = "33333333333333333333333333333333" + +func picture(t *testing.T, red uint8) []byte { + t.Helper() + var b bytes.Buffer + img := image.NewRGBA(image.Rect(0, 0, 2, 3)) + for y := 0; y < 3; y++ { + for x := 0; x < 2; x++ { + img.Set(x, y, color.RGBA{R: red, A: 255}) + } + } + if e := jpeg.Encode(&b, img, nil); e != nil { + t.Fatal(e) + } + return b.Bytes() +} +func jellyfinClient(t *testing.T, server *httptest.Server, base string) *source.Jellyfin { + t.Helper() + j, e := source.NewJellyfin(server.URL + base) + if e != nil { + t.Fatal(e) + } + return j +} +func TestJellyfinPostersCacheAndOnlyReviewedTitles(t *testing.T) { + imageBytes := picture(t, 180) + calls := 0 + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if !strings.Contains(r.Header.Get("Authorization"), `Token="operator-only-key"`) || !strings.Contains(r.Header.Get("Authorization"), `DeviceId="generator"`) { + t.Error("missing generator credential/identity") + } + if r.URL.Query().Get("format") != "Jpg" || r.URL.Query().Get("maxWidth") != "500" || r.URL.Query().Get("maxHeight") != "750" || strings.Contains(r.URL.RawQuery, "operator-only-key") { + t.Error("unsafe artwork query") + } + if r.URL.Path != "/jellyfin/Items/"+movieID+"/Images/Primary" && r.URL.Path != "/jellyfin/Items/"+seriesID+"/Images/Primary" { + t.Error("unnecessary or incorrect poster request") + } + w.Write(imageBytes) + })) + defer api.Close() + c := New(jellyfinClient(t, api, "/jellyfin"), "operator-only-key", t.TempDir()) + items := []model.Item{{ID: movieID, Type: "Movie", ImageTag: "movie-v1"}, {ID: seriesID, Type: "Series", ImageTag: "show-v1"}, {ID: unusedID, Type: "Movie", ImageTag: "unwatched"}} + reviews := []model.Review{{Movies: []model.Content{{ID: movieID, Title: "Film", Poster: "https://untrusted.invalid/image"}}, Shows: []model.Content{{ID: seriesID, Title: "Show"}}, Abandoned: []model.Content{{ID: movieID}}}, {Movies: []model.Content{{ID: movieID}}}} + assets, warnings := c.Resolve(context.Background(), items, reviews) + want := "/art/" + digest(imageBytes) + ".jpg" + if calls != 2 || len(assets) != 1 || len(warnings) != 0 || reviews[0].Movies[0].Poster != want || reviews[0].Shows[0].Poster != want || reviews[0].Abandoned[0].Poster != want || reviews[1].Movies[0].Poster != want { + t.Fatalf("poster resolution/cache failed: %d calls, %d assets, %v", calls, len(assets), warnings) + } + api.Close() + // A fixed-tag cached image remains available when the source is unreachable. + cached, warnings := c.Resolve(context.Background(), items, reviews) + if !reflect.DeepEqual(cached, assets) || len(warnings) != 0 { + t.Fatal("successful posters were not cached") + } +} +func TestImageTagChangesUntaggedRefreshAndCorruptCache(t *testing.T) { + calls := 0 + red := uint8(0) + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++; w.Write(picture(t, red)) })) + defer api.Close() + cache := t.TempDir() + c := New(jellyfinClient(t, api, ""), "key", cache) + first, e := c.fetch(context.Background(), movieID, "v1") + if e != nil { + t.Fatal(e) + } + red = 255 + second, e := c.fetch(context.Background(), movieID, "v2") + if e != nil || bytes.Equal(first, second) || calls != 2 { + t.Fatal("changed image tag retained old artwork") + } + for i := 0; i < 2; i++ { + if _, e = c.fetch(context.Background(), movieID, ""); e != nil { + t.Fatal(e) + } + } + if calls != 4 { + t.Fatal("untagged image was cached indefinitely") + } + files, e := os.ReadDir(cache) + if e != nil { + t.Fatal(e) + } + for _, f := range files { + if e = os.WriteFile(filepath.Join(cache, f.Name()), []byte("corrupt"), 0600); e != nil { + t.Fatal(e) + } + } + if _, e = c.fetch(context.Background(), movieID, "v2"); e != nil || calls != 5 { + t.Fatal("corrupt cache was not recovered") + } +} +func TestMissingInvalidAndUnconfiguredPosters(t *testing.T) { + for _, status := range []int{404, 401, 200} { + t.Run(http.StatusText(status), func(t *testing.T) { + calls := 0 + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(status) + w.Write([]byte("private upstream error")) + })) + defer api.Close() + c := New(jellyfinClient(t, api, ""), "key", t.TempDir()) + reviews := []model.Review{{Movies: []model.Content{{ID: movieID, Poster: "/old.jpg"}}}} + assets, warnings := c.Resolve(context.Background(), []model.Item{{ID: movieID, Type: "Movie"}}, reviews) + if calls != 1 || len(assets) != 0 || len(warnings) != 1 || reviews[0].Movies[0].Poster != "" || strings.Contains(warnings[0], "private upstream") { + t.Fatal("failed artwork leaked upstream details or retained old reference") + } + }) + } + reviews := []model.Review{{Movies: []model.Content{{ID: movieID, Poster: "https://untrusted.invalid"}}}} + assets, warnings := New(nil, "", t.TempDir()).Resolve(context.Background(), nil, reviews) + if len(assets) != 0 || len(warnings) != 1 || reviews[0].Movies[0].Poster != "" { + t.Fatal("unconfigured artwork did not use placeholders") + } + if _, warnings = New(nil, "", t.TempDir()).Resolve(context.Background(), nil, nil); len(warnings) != 0 { + t.Fatal("empty history needs no artwork") + } +} +func TestRejectsUnsafeIDsRedirectsAndOversizedImages(t *testing.T) { + foreignCalls := 0 + foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { foreignCalls++; w.Write(picture(t, 10)) })) + defer foreign.Close() + calls := 0 + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++; http.Redirect(w, r, foreign.URL+"/poster", 302) })) + defer api.Close() + c := New(jellyfinClient(t, api, ""), "operator-key", t.TempDir()) + if _, e := c.fetch(context.Background(), "../Users/Me", ""); e == nil || calls != 0 { + t.Fatal("unsafe item path reached Jellyfin") + } + if _, e := c.fetch(context.Background(), movieID, ""); e == nil || calls != 1 || foreignCalls != 0 { + t.Fatal("redirect followed with operator credentials") + } + var large bytes.Buffer + if e := jpeg.Encode(&large, image.NewGray(image.Rect(0, 0, 4097, 1)), nil); e != nil { + t.Fatal(e) + } + if validJPEG(large.Bytes()) || validJPEG(bytes.Repeat([]byte{0}, maxImageBytes+1)) { + t.Fatal("image bounds not enforced") + } + valid := picture(t, 10) + if validJPEG(valid[:len(valid)-10]) { + t.Fatal("truncated JPEG accepted") + } +} +func TestRetriesBoundedAndCancellation(t *testing.T) { + for _, status := range []int{429, 503} { + t.Run(http.StatusText(status), func(t *testing.T) { + calls := 0 + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Retry-After", "600") + w.WriteHeader(status) + })) + defer api.Close() + c := New(jellyfinClient(t, api, ""), "key", t.TempDir()) + delays := []time.Duration{} + c.wait = func(ctx context.Context, d time.Duration) error { delays = append(delays, d); return nil } + if _, e := c.fetch(context.Background(), movieID, ""); e == nil || calls != 3 || !reflect.DeepEqual(delays, []time.Duration{3 * time.Second, 3 * time.Second}) { + t.Fatal("unbounded retry behavior") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, e := c.fetch(ctx, movieID, ""); e == nil || calls != 3 { + t.Fatal("canceled request reached source") + } + }) + } + if e := waitRetry(canceledContext(), time.Hour); e == nil { + t.Fatal("backoff ignored cancellation") + } +} +func canceledContext() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx +}
A jellyfin-year-review/internal/artwork/tvmaze.go

@@ -0,0 +1,249 @@

+package artwork + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "image" + "image/jpeg" + _ "image/png" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + "unicode" + + "reelreview/internal/model" +) + +type tvmazeClient struct { + http *http.Client + nextCall time.Time + wait func(context.Context, time.Duration) error +} + +type tvmazeShow struct { + ID int + Name string + Premiered string + Image *struct{ Medium, Original string } +} + +type tvmazePoster struct { + ShowID int `json:"showId"` + JPEG []byte `json:"jpeg"` +} + +func newTVmaze() *tvmazeClient { + t := &tvmazeClient{wait: waitRetry} + t.http = &http.Client{Timeout: 12 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 3 || req.URL.Scheme != "https" || req.URL.User != nil || req.URL.Host != via[0].URL.Host { + return http.ErrUseLastResponse + } + if req.URL.Host == "api.tvmaze.com" { + if e := t.pace(req.Context()); e != nil { + return e + } + } + return nil + }} + return t +} + +// pace includes lookup redirects in the API request rate limit. +func (t *tvmazeClient) pace(ctx context.Context) error { + if delay := time.Until(t.nextCall); delay > 0 { + if e := t.wait(ctx, delay); e != nil { + return e + } + } + t.nextCall = time.Now().Add(500 * time.Millisecond) + return nil +} + +// get uses a separate, credential-free client and only TVmaze's API/image hosts. +func (t *tvmazeClient) get(ctx context.Context, target string, limit int64) ([]byte, error) { + if e := ctx.Err(); e != nil { + return nil, e + } + u, e := url.Parse(target) + if e != nil || u.Scheme != "https" || u.User != nil || u.Fragment != "" || (u.Host != "api.tvmaze.com" && u.Host != "static.tvmaze.com") { + return nil, fmt.Errorf("invalid TVmaze URL") + } + for attempt := 0; attempt < 3; attempt++ { + if e := ctx.Err(); e != nil { + return nil, e + } + if u.Host == "api.tvmaze.com" { + if e = t.pace(ctx); e != nil { + return nil, e + } + } + req, e := http.NewRequestWithContext(ctx, "GET", target, nil) + if e != nil { + return nil, fmt.Errorf("invalid TVmaze request") + } + req.Header.Set("User-Agent", "Jellyfin-Year-Review/1.0") + res, e := t.http.Do(req) + if e != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("TVmaze unavailable") + } + body, readErr := io.ReadAll(io.LimitReader(res.Body, limit+1)) + res.Body.Close() + if readErr != nil || int64(len(body)) > limit { + return nil, fmt.Errorf("invalid TVmaze response") + } + if res.StatusCode == http.StatusOK { + return body, nil + } + if (res.StatusCode != http.StatusTooManyRequests && res.StatusCode < 500) || attempt == 2 { + return nil, fmt.Errorf("TVmaze unavailable (%d)", res.StatusCode) + } + if e = t.wait(ctx, retryDelay(res.Header.Get("Retry-After"), attempt)); e != nil { + return nil, e + } + } + return nil, fmt.Errorf("TVmaze retries exhausted") +} + +// titleKey tolerates punctuation/case differences without accepting fuzzy matches. +func titleKey(title string) string { + return strings.Map(func(r rune) rune { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + return unicode.ToLower(r) + } + return -1 + }, title) +} + +// match prefers provider IDs, then an unambiguous title/alias and premiere year. +// TVmaze contains television series, so movies are deliberately never matched. +func (t *tvmazeClient) match(ctx context.Context, item model.Item) (tvmazeShow, error) { + for _, provider := range []string{"Tvdb", "Imdb"} { + for name, id := range item.ProviderIDs { + if !strings.EqualFold(name, provider) || id == "" { + continue + } + parameter := "imdb" + if provider == "Tvdb" { + parameter = "thetvdb" + } + body, e := t.get(ctx, "https://api.tvmaze.com/lookup/shows?"+parameter+"="+url.QueryEscape(id), 1<<20) + var show tvmazeShow + if e == nil && json.Unmarshal(body, &show) == nil && show.ID > 0 && show.Image != nil { + return show, nil + } + } + } + body, e := t.get(ctx, "https://api.tvmaze.com/search/shows?q="+url.QueryEscape(item.Title), 1<<20) + if e != nil { + return tvmazeShow{}, e + } + var hits []struct{ Show tvmazeShow } + if json.Unmarshal(body, &hits) != nil || len(hits) > 100 { + return tvmazeShow{}, fmt.Errorf("invalid TVmaze search response") + } + eligible := []tvmazeShow{} + exact := []tvmazeShow{} + key := titleKey(item.Title) + for _, hit := range hits { + show := hit.Show + if show.ID < 1 || show.Image == nil || (item.Year != 0 && !strings.HasPrefix(show.Premiered, strconv.Itoa(item.Year)+"-")) { + continue + } + eligible = append(eligible, show) + if titleKey(show.Name) == key { + exact = append(exact, show) + } + } + if len(exact) == 1 { + return exact[0], nil + } + if len(exact) > 1 { + return tvmazeShow{}, fmt.Errorf("ambiguous TVmaze title") + } + // Check a few alternate/localized titles rather than accepting a fuzzy hit. + aliases := []tvmazeShow{} + for i, show := range eligible { + if i == 3 { + break + } + body, e := t.get(ctx, fmt.Sprintf("https://api.tvmaze.com/shows/%d/akas", show.ID), 1<<20) + var names []struct{ Name string } + if e != nil || json.Unmarshal(body, &names) != nil { + continue + } + for _, name := range names { + if titleKey(name.Name) == key { + aliases = append(aliases, show) + break + } + } + } + if len(aliases) == 1 { + return aliases[0], nil + } + return tvmazeShow{}, fmt.Errorf("no unambiguous TVmaze match") +} + +// fallback caches successful TVmaze posters and attribution for offline publishing. +func (c *Client) fallback(ctx context.Context, item model.Item) ([]byte, string, error) { + if item.Type != "Series" || titleKey(item.Title) == "" { + return nil, "", fmt.Errorf("TVmaze fallback requires a series title") + } + identity, _ := json.Marshal(struct { + Title string + Year int + Providers map[string]string + }{item.Title, item.Year, item.ProviderIDs}) + file := filepath.Join(c.Cache, digest(append([]byte("tvmaze-poster-v1\x00"), identity...))) + if f, e := os.Open(file); e == nil { + body, readErr := io.ReadAll(io.LimitReader(f, 8<<20)) + f.Close() + var cached tvmazePoster + if readErr == nil && json.Unmarshal(body, &cached) == nil && cached.ShowID > 0 && validJPEG(cached.JPEG) { + return cached.JPEG, fmt.Sprintf("https://www.tvmaze.com/shows/%d", cached.ShowID), nil + } + } + show, e := c.tvmaze.match(ctx, item) + if e != nil { + return nil, "", e + } + target := show.Image.Medium + if target == "" { + target = show.Image.Original + } + imageURL, e := url.Parse(target) + if e != nil || imageURL.Host != "static.tvmaze.com" { + return nil, "", fmt.Errorf("invalid TVmaze image host") + } + body, e := c.tvmaze.get(ctx, target, maxImageBytes) + if e != nil { + return nil, "", e + } + cfg, _, e := image.DecodeConfig(bytes.NewReader(body)) + if e != nil || cfg.Width < 1 || cfg.Height < 1 || cfg.Width > 4096 || cfg.Height > 4096 { + return nil, "", fmt.Errorf("invalid TVmaze image") + } + img, _, e := image.Decode(bytes.NewReader(body)) + if e != nil { + return nil, "", fmt.Errorf("invalid TVmaze image") + } + var jpegBytes bytes.Buffer + if jpeg.Encode(&jpegBytes, img, &jpeg.Options{Quality: 85}) != nil || !validJPEG(jpegBytes.Bytes()) { + return nil, "", fmt.Errorf("invalid TVmaze image") + } + cached, _ := json.Marshal(tvmazePoster{ShowID: show.ID, JPEG: jpegBytes.Bytes()}) + if os.MkdirAll(c.Cache, 0700) != nil || c.cache(file, cached) != nil { + return nil, "", fmt.Errorf("cannot write artwork cache") + } + return jpegBytes.Bytes(), fmt.Sprintf("https://www.tvmaze.com/shows/%d", show.ID), nil +}
A jellyfin-year-review/internal/artwork/tvmaze_test.go

@@ -0,0 +1,210 @@

+package artwork + +import ( + "bytes" + "context" + "encoding/json" + "image" + "image/png" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" + + "reelreview/internal/model" +) + +type tvmazeTransport func(*http.Request) (*http.Response, error) + +func (f tvmazeTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +// Keep synthetic tests on a private transport while validating real API hostnames. +func fakeTVmaze(t *testing.T, c *Client, handler http.HandlerFunc) { + t.Helper() + c.tvmaze.wait = func(context.Context, time.Duration) error { return nil } + c.tvmaze.http.Transport = tvmazeTransport(func(r *http.Request) (*http.Response, error) { + if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" || strings.Contains(r.URL.String(), c.Key) { + t.Error("private credentials reached TVmaze") + } + if r.Header.Get("User-Agent") != "Jellyfin-Year-Review/1.0" { + t.Error("missing application identity") + } + w := httptest.NewRecorder() + handler(w, r) + return w.Result(), nil + }) +} + +func TestTVmazeFallbackProviderLookupCacheAndJellyfinPriority(t *testing.T) { + jellyfinRecovered := false + jellyfinImage := picture(t, 25) + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if jellyfinRecovered { + w.Write(jellyfinImage) + } else { + w.WriteHeader(404) + } + })) + defer api.Close() + c := New(jellyfinClient(t, api, ""), "secret-jellyfin-key", t.TempDir()) + calls := []string{} + fakeTVmaze(t, c, func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.URL.Host+r.URL.Path) + switch r.URL.Path { + case "/lookup/shows": + if r.URL.Query().Get("thetvdb") != "123" { + t.Error("missing Jellyfin provider ID") + } + w.Header().Set("Location", "https://api.tvmaze.com/shows/42") + w.WriteHeader(302) + case "/shows/42": + w.Write([]byte(`{"id":42,"name":"Frieren","image":{"medium":"https://static.tvmaze.com/poster.png"}}`)) + case "/poster.png": + png.Encode(w, image.NewRGBA(image.Rect(0, 0, 2, 3))) + default: + t.Error("unexpected TVmaze request") + w.WriteHeader(404) + } + }) + items := []model.Item{{ID: seriesID, Title: "Frieren", Type: "Series", ProviderIDs: map[string]string{"Tvdb": "123"}}, {ID: movieID, Title: "Frieren", Type: "Movie"}} + reviews := []model.Review{{Shows: []model.Content{{ID: seriesID, Title: "Frieren"}}, Movies: []model.Content{{ID: movieID, Title: "Frieren"}}}} + assets, warnings := c.Resolve(context.Background(), items, reviews) + show := reviews[0].Shows[0] + if len(calls) != 3 || len(assets) != 1 || len(warnings) != 1 || show.PosterSource != "https://www.tvmaze.com/shows/42" || !strings.HasPrefix(show.Poster, "/art/") || reviews[0].Movies[0].Poster != "" { + t.Fatalf("fallback not published correctly: calls=%d assets=%d warnings=%v", len(calls), len(assets), warnings) + } + for _, b := range assets { + if !validJPEG(b) { + t.Error("TVmaze PNG was not converted to a valid local JPEG") + } + } + cached, _ := c.Resolve(context.Background(), items, reviews) + if len(calls) != 3 || !reflect.DeepEqual(assets, cached) { + t.Fatal("successful fallback was not cached") + } + jellyfinRecovered = true + _, warnings = c.Resolve(context.Background(), items, reviews) + if len(calls) != 3 || len(warnings) != 0 || reviews[0].Shows[0].PosterSource != "" || reviews[0].Shows[0].Poster != "/art/"+digest(jellyfinImage)+".jpg" { + t.Fatal("Jellyfin did not take priority over cached fallback") + } +} + +func TestTVmazeTitleAliasAndYearMatching(t *testing.T) { + cases := []struct { + name, title, results, aliases string + year, want int + }{ + {"title", "A Show!", `[{"show":{"id":1,"name":"a show","premiered":"2023-01-01","image":{"medium":"https://static.tvmaze.com/1.jpg"}}}]`, `[]`, 2023, 1}, + {"alias", "Frieren: Beyond Journey's End", `[{"show":{"id":2,"name":"Sousou no Frieren","premiered":"2023-09-29","image":{"medium":"https://static.tvmaze.com/2.jpg"}}}]`, `[{"name":"Frieren: Beyond Journey's End"}]`, 2023, 2}, + {"wrong year", "A Show", `[{"show":{"id":1,"name":"A Show","premiered":"2000-01-01","image":{"medium":"https://static.tvmaze.com/1.jpg"}}}]`, `[]`, 2023, 0}, + {"ambiguous", "A Show", `[{"show":{"id":1,"name":"A Show","image":{}}},{"show":{"id":2,"name":"A Show","image":{}}}]`, `[]`, 0, 0}, + {"no image", "A Show", `[{"show":{"id":1,"name":"A Show","image":null}}]`, `[]`, 0, 0}, + {"no match", "A Show", `[]`, `[]`, 0, 0}, + {"fuzzy mismatch", "A Show", `[{"show":{"id":1,"name":"A Different Show","image":{}}}]`, `[{"name":"Another Show"}]`, 0, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := New(nil, "private-key", t.TempDir()) + fakeTVmaze(t, c, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/search/shows" { + if r.URL.Query().Get("q") != tc.title { + t.Error("incorrect title search") + } + w.Write([]byte(tc.results)) + } else { + w.Write([]byte(tc.aliases)) + } + }) + show, e := c.tvmaze.match(context.Background(), model.Item{Title: tc.title, Year: tc.year}) + if tc.want == 0 && e == nil || tc.want != 0 && (e != nil || show.ID != tc.want) { + t.Fatalf("unexpected match: id=%d error=%v", show.ID, e) + } + }) + } +} + +func TestTVmazeRejectsUnsafeImagesAndInvalidResponses(t *testing.T) { + for _, target := range []string{"https://private.invalid/secret", "http://static.tvmaze.com/image", "https://static.tvmaze.com/image"} { + t.Run(target, func(t *testing.T) { + c := New(nil, "private-key", t.TempDir()) + fakeTVmaze(t, c, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/search/shows" { + json.NewEncoder(w).Encode([]any{map[string]any{"show": map[string]any{"id": 1, "name": "Show", "image": map[string]string{"medium": target}}}}) + } else if r.URL.Host == "static.tvmaze.com" { + w.Write([]byte("private upstream error")) + } else { + t.Error("unexpected request to foreign host") + } + }) + _, _, e := c.fallback(context.Background(), model.Item{Title: "Show", Type: "Series"}) + if e == nil || strings.Contains(e.Error(), "private upstream") || strings.Contains(e.Error(), "private-key") { + t.Fatal("unsafe or invalid fallback accepted") + } + }) + } +} + +func TestTVmazeRetryRedirectBoundsAndCancellation(t *testing.T) { + c := New(nil, "private-key", t.TempDir()) + calls := 0 + fakeTVmaze(t, c, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Retry-After", "600") + w.WriteHeader(429) + }) + _, e := c.tvmaze.get(context.Background(), "https://api.tvmaze.com/search/shows?q=Show", 1<<20) + if e == nil || calls != 3 { + t.Fatal("TVmaze retries were not bounded") + } + calls = 0 + fakeTVmaze(t, c, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Location", "https://private.invalid/secret") + w.WriteHeader(302) + }) + _, e = c.tvmaze.get(context.Background(), "https://api.tvmaze.com/lookup/shows?thetvdb=1", 1<<20) + if e == nil || calls != 1 { + t.Fatal("unsafe redirect followed") + } + _, e = c.tvmaze.get(canceledContext(), "https://api.tvmaze.com/search/shows?q=Show", 1<<20) + if e == nil || calls != 1 { + t.Fatal("canceled request reached TVmaze") + } + fakeTVmaze(t, c, func(w http.ResponseWriter, r *http.Request) { w.Write(bytes.Repeat([]byte("x"), 20)) }) + if _, e := c.tvmaze.get(context.Background(), "https://api.tvmaze.com/search/shows?q=Show", 10); e == nil { + t.Fatal("oversized response accepted") + } +} + +func TestTVmazeFailuresKeepPlaceholderAndAreNotCached(t *testing.T) { + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(404) })) + defer api.Close() + c := New(jellyfinClient(t, api, ""), "private-key", t.TempDir()) + available := false + searches := 0 + fakeTVmaze(t, c, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/search/shows" { + searches++ + if available { + w.Write([]byte(`[{"show":{"id":42,"name":"Show","image":{"medium":"https://static.tvmaze.com/image.jpg"}}}]`)) + } else { + w.Write([]byte(`[]`)) + } + } else { + w.Write(picture(t, 10)) + } + }) + items := []model.Item{{ID: seriesID, Title: "Show", Type: "Series"}} + reviews := []model.Review{{Shows: []model.Content{{ID: seriesID, Title: "Show", Poster: "https://untrusted.invalid", PosterSource: "https://untrusted.invalid"}}}} + assets, warnings := c.Resolve(context.Background(), items, reviews) + if len(assets) != 0 || len(warnings) != 1 || reviews[0].Shows[0].Poster != "" || reviews[0].Shows[0].PosterSource != "" { + t.Fatal("failed fallback did not clear unsafe references") + } + available = true + assets, warnings = c.Resolve(context.Background(), items, reviews) + if searches != 2 || len(assets) != 1 || len(warnings) != 0 || reviews[0].Shows[0].PosterSource != "https://www.tvmaze.com/shows/42" { + t.Fatal("failed fallback was permanently cached") + } +}
A jellyfin-year-review/internal/auth/offline.go

@@ -0,0 +1,251 @@

+package auth + +import ( + "bytes" + "context" + "crypto/pbkdf2" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + "unicode/utf8" + + "reelreview/internal/model" + "reelreview/internal/source" +) + +const maxAuthBytes = 1 << 20 + +type Credential struct { + ID string `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"passwordHash"` + Disabled bool `json:"disabled"` +} + +// UnmarshalJSON requires explicit account status rather than treating a missing +// disabled flag as an enabled account in a malformed private export. +func (c *Credential) UnmarshalJSON(b []byte) error { + var fields struct { + ID *string `json:"id"` + Username *string `json:"username"` + PasswordHash *string `json:"passwordHash"` + Disabled *bool `json:"disabled"` + } + d := json.NewDecoder(bytes.NewReader(b)) + d.DisallowUnknownFields() + if d.Decode(&fields) != nil || fields.ID == nil || fields.Username == nil || fields.PasswordHash == nil || fields.Disabled == nil { + return source.ErrUnavailable + } + *c = Credential{ID: *fields.ID, Username: *fields.Username, PasswordHash: *fields.PasswordHash, Disabled: *fields.Disabled} + return nil +} + +type CredentialFile struct { + Version int `json:"schemaVersion"` + SourceVersion string `json:"sourceVersion"` + ExportedAt time.Time `json:"exportedAt"` + Users []Credential `json:"users"` +} +type verifier struct { + algorithm string + iterations int + salt, hash []byte +} +type offlineUser struct { + credential Credential + verifier verifier + revision string +} +type offlineUsers struct { + byName map[string]offlineUser + byID map[string]offlineUser +} + +// Offline rereads a private, atomically replaced file for every authentication +// check. Invalid or missing files fail closed without cached credential fallback. +type Offline struct { + path string + work chan struct{} +} + +func NewOffline(path string) (*Offline, error) { + absolute, e := filepath.Abs(path) + if e != nil || path == "" { + return nil, fmt.Errorf("offline mode requires a private credential file") + } + p := &Offline{path: absolute, work: make(chan struct{}, 2)} + if _, e = p.load(); e != nil { + return nil, fmt.Errorf("cannot load offline credentials: check file format, path and private permissions") + } + return p, nil +} +func (p *Offline) Mode() string { return "offline" } +func (p *Offline) Path() string { return p.path } + +// loginKey preserves exact spelling for non-ASCII names to avoid differing .NET +// and Go Unicode normalization versions. ASCII account names ignore case. +func loginKey(name string) string { + for _, r := range name { + if r > 127 { + return "unicode:" + name + } + } + return "ascii:" + strings.ToLower(name) +} + +// parseVerifier accepts Jellyfin hex PBKDF2 strings within bounded work parameters. +func parseVerifier(encoded string) (verifier, error) { + var v verifier + fields := strings.Split(encoded, "$") + if len(fields) != 5 || fields[0] != "" || !strings.HasPrefix(fields[2], "iterations=") { + return v, source.ErrUnavailable + } + v.algorithm = fields[1] + size := 64 + if v.algorithm == "PBKDF2" { + size = 32 + } else if v.algorithm != "PBKDF2-SHA512" { + return v, source.ErrUnavailable + } + var e error + v.iterations, e = strconv.Atoi(strings.TrimPrefix(fields[2], "iterations=")) + if e != nil || v.iterations < 1 || v.iterations > 1000000 { + return v, source.ErrUnavailable + } + v.salt, e = hex.DecodeString(fields[3]) + if e != nil || len(v.salt) < 8 || len(v.salt) > 128 { + return v, source.ErrUnavailable + } + v.hash, e = hex.DecodeString(fields[4]) + if e != nil || len(v.hash) != size { + return v, source.ErrUnavailable + } + return v, nil +} +func revision(c Credential) string { + b, _ := json.Marshal(c) + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) +} + +// load validates the complete current file before any account is trusted. +func (p *Offline) load() (offlineUsers, error) { + users := offlineUsers{byName: map[string]offlineUser{}, byID: map[string]offlineUser{}} + // Refuse symlinks and group/world-readable files, including operator mistakes. + st, e := os.Lstat(p.path) + if e != nil || !st.Mode().IsRegular() || st.Mode().Perm()&0077 != 0 || st.Size() > maxAuthBytes { + return users, source.ErrUnavailable + } + f, e := os.Open(p.path) + if e != nil { + return users, source.ErrUnavailable + } + defer f.Close() + opened, e := f.Stat() + if e != nil || !os.SameFile(st, opened) || !opened.Mode().IsRegular() || opened.Mode().Perm()&0077 != 0 { + return users, source.ErrUnavailable + } + b, e := io.ReadAll(io.LimitReader(f, maxAuthBytes+1)) + if e != nil || len(b) > maxAuthBytes { + return users, source.ErrUnavailable + } + var file CredentialFile + d := json.NewDecoder(bytes.NewReader(b)) + d.DisallowUnknownFields() + if d.Decode(&file) != nil || d.Decode(new(any)) != io.EOF || file.Version != 1 || file.SourceVersion != "12.2" || file.ExportedAt.IsZero() || file.Users == nil || len(file.Users) > 1000 { + return users, source.ErrUnavailable + } + for _, c := range file.Users { + id, e := model.ID(c.ID) + if e != nil || id != c.ID || c.Username == "" || len(c.Username) > 256 || !utf8.ValidString(c.Username) || strings.ContainsAny(c.Username, "\x00\r\n") { + return users, source.ErrUnavailable + } + key := loginKey(c.Username) + if _, ok := users.byName[key]; ok { + return users, source.ErrUnavailable + } + if _, ok := users.byID[id]; ok { + return users, source.ErrUnavailable + } + var v verifier + if !c.Disabled { + v, e = parseVerifier(c.PasswordHash) + if e != nil { + return users, source.ErrUnavailable + } + } else if c.PasswordHash != "" { + return users, source.ErrUnavailable + } + u := offlineUser{credential: c, verifier: v, revision: revision(c)} + users.byName[key] = u + users.byID[id] = u + } + return users, nil +} + +// Login checks the imported verifier without creating an upstream session. +func (p *Offline) Login(ctx context.Context, name, password, device string) (source.Account, string, error) { + select { + case p.work <- struct{}{}: + defer func() { <-p.work }() + case <-ctx.Done(): + return source.Account{}, "", source.ErrUnavailable + } + users, e := p.load() + if e != nil { + return source.Account{}, "", e + } + u, ok := users.byName[loginKey(name)] + // Unknown and disabled accounts perform a real default-cost derivation too. + v := u.verifier + if !ok || u.credential.Disabled { + v = verifier{algorithm: "PBKDF2-SHA512", iterations: 210000, salt: make([]byte, 16), hash: make([]byte, 64)} + } + var actual []byte + if v.algorithm == "PBKDF2" { + actual, e = pbkdf2.Key(sha1.New, password, v.salt, v.iterations, 32) + } else { + actual, e = pbkdf2.Key(sha512.New, password, v.salt, v.iterations, 64) + } + if e != nil || ctx.Err() != nil { + return source.Account{}, "", source.ErrUnavailable + } + matches := subtle.ConstantTimeCompare(actual, v.hash) == 1 + if !ok || u.credential.Disabled || !matches { + return source.Account{}, "", source.ErrDenied + } + // Recheck after the expensive derivation so a concurrent replacement can revoke it. + session := Session{Account: source.Account{ID: u.credential.ID, Name: u.credential.Username}, Token: u.revision} + account, e := p.Validate(ctx, session) + if e != nil { + return source.Account{}, "", e + } + return account, u.revision, nil +} + +// Validate revokes changed or removed accounts on their next private request. +func (p *Offline) Validate(ctx context.Context, s Session) (source.Account, error) { + if ctx.Err() != nil { + return source.Account{}, source.ErrUnavailable + } + users, e := p.load() + if e != nil { + return source.Account{}, e + } + u, ok := users.byID[s.Account.ID] + if !ok || u.credential.Disabled || subtle.ConstantTimeCompare([]byte(s.Token), []byte(u.revision)) != 1 { + return source.Account{}, source.ErrDenied + } + return source.Account{ID: u.credential.ID, Name: u.credential.Username}, nil +} +func (p *Offline) Logout(context.Context, Session) error { return nil }
A jellyfin-year-review/internal/auth/offline_test.go

@@ -0,0 +1,201 @@

+package auth + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "reelreview/internal/source" +) + +func credentials(t *testing.T) (string, CredentialFile) { + t.Helper() + b, e := os.ReadFile("../../testdata/offline-auth.json") + if e != nil { + t.Fatal(e) + } + var f CredentialFile + if e = json.Unmarshal(b, &f); e != nil { + t.Fatal(e) + } + path := filepath.Join(t.TempDir(), "users.json") + if e = os.WriteFile(path, b, 0600); e != nil { + t.Fatal(e) + } + return path, f +} +func replaceCredentials(t *testing.T, path string, f CredentialFile) { + t.Helper() + b, e := json.Marshal(f) + if e != nil { + t.Fatal(e) + } + temp := path + ".new" + if e = os.WriteFile(temp, b, 0600); e != nil { + t.Fatal(e) + } + if e = os.Rename(temp, path); e != nil { + t.Fatal(e) + } +} +func TestOfflineJellyfinPasswordVectors(t *testing.T) { + path, _ := credentials(t) + p, e := NewOffline(path) + if e != nil { + t.Fatal(e) + } + for _, tt := range []struct{ name, password, id string }{{"ALICE", "fixture-only", strings.Repeat("a", 32)}, {"Legacy", "pässwörd 🎬", strings.Repeat("c", 32)}} { + a, revision, e := p.Login(context.Background(), tt.name, tt.password, "unused") + if e != nil || a.ID != tt.id || revision == "" { + t.Fatal("Python-derived Jellyfin password vector did not authenticate", e) + } + if _, e = p.Validate(context.Background(), Session{Account: a, Token: revision}); e != nil { + t.Fatal(e) + } + } + for _, tt := range []struct{ name, password string }{{"Alice", "wrong"}, {"Unknown", "fixture-only"}, {"Disabled", ""}, {"Alice", ""}} { + _, _, e = p.Login(context.Background(), tt.name, tt.password, "") + if !errors.Is(e, source.ErrDenied) { + t.Fatal("unknown/disabled/incorrect login did not fail generically") + } + } +} +func TestOfflineReplacementsRevokeOnlyAffectedAccounts(t *testing.T) { + for _, change := range []string{"password", "rename", "remove", "disable"} { + t.Run(change, func(t *testing.T) { + path, file := credentials(t) + p, e := NewOffline(path) + if e != nil { + t.Fatal(e) + } + a, token, e := p.Login(context.Background(), "Alice", "fixture-only", "") + if e != nil { + t.Fatal(e) + } + s := Session{Account: a, Token: token} + // Metadata refresh and other users changing must leave Alice's revision usable. + file.ExportedAt = file.ExportedAt.Add(time.Hour) + file.Users[1].Username = "Other name" + replaceCredentials(t, path, file) + if _, e = p.Validate(context.Background(), s); e != nil { + t.Fatal("unrelated refresh revoked Alice") + } + switch change { + case "password": + file.Users[0].PasswordHash = strings.Replace(file.Users[0].PasswordHash, "001122", "112233", 1) + case "rename": + file.Users[0].Username = "Renamed" + case "remove": + file.Users = file.Users[1:] + case "disable": + file.Users[0].Disabled = true + file.Users[0].PasswordHash = "" + } + replaceCredentials(t, path, file) + if _, e = p.Validate(context.Background(), s); !errors.Is(e, source.ErrDenied) { + t.Fatal("changed account retained session") + } + }) + } +} +func TestOfflineFailsClosedWithoutLeakingVerifier(t *testing.T) { + path, file := credentials(t) + p, e := NewOffline(path) + if e != nil { + t.Fatal(e) + } + for _, mutate := range []func(*CredentialFile){ + func(f *CredentialFile) { f.Version = 99 }, func(f *CredentialFile) { f.SourceVersion = "future" }, + func(f *CredentialFile) { f.Users = append(f.Users, f.Users[0]) }, func(f *CredentialFile) { f.Users[0].PasswordHash = "plaintext-password" }, + func(f *CredentialFile) { + f.Users[0].PasswordHash = strings.Replace(f.Users[0].PasswordHash, "210000", "1000001", 1) + }, + func(f *CredentialFile) { f.Users[0].ID = "../../escape" }, + } { + copy := file + copy.Users = append([]Credential{}, file.Users...) + mutate(&copy) + replaceCredentials(t, path, copy) + if _, _, e = p.Login(context.Background(), "Alice", "fixture-only", ""); !errors.Is(e, source.ErrUnavailable) || strings.Contains(e.Error(), "plaintext") || strings.Contains(e.Error(), "PBKDF2") { + t.Fatal("malformed credentials did not fail closed/generically") + } + } + replaceCredentials(t, path, file) + if e = os.Chmod(path, 0644); e != nil { + t.Fatal(e) + } + if _, e = NewOffline(path); e == nil { + t.Fatal("publicly readable credentials accepted") + } + if e = os.Chmod(path, 0600); e != nil { + t.Fatal(e) + } + link := filepath.Join(t.TempDir(), "link.json") + if e = os.Symlink(path, link); e != nil { + t.Fatal(e) + } + if _, e = NewOffline(link); e == nil { + t.Fatal("credential symlink accepted") + } + if e = os.Remove(path); e != nil { + t.Fatal(e) + } + if _, e = p.Validate(context.Background(), Session{}); !errors.Is(e, source.ErrUnavailable) { + t.Fatal("missing file fell back to credentials") + } +} +func TestOfflineBoundedVerificationAndUnicodeNames(t *testing.T) { + path, f := credentials(t) + f.Users[0].Username = "Vïewer" + replaceCredentials(t, path, f) + p, e := NewOffline(path) + if e != nil { + t.Fatal(e) + } + if _, _, e = p.Login(context.Background(), "Vïewer", "fixture-only", ""); e != nil { + t.Fatal("exact Unicode username failed") + } + if loginKey("Vïewer") == loginKey("vïewer") { + t.Fatal("ambiguous Unicode casing was normalized") + } + p.work <- struct{}{} + p.work <- struct{}{} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, _, e = p.Login(ctx, "Vïewer", "fixture-only", ""); !errors.Is(e, source.ErrUnavailable) { + t.Fatal("queued canceled verification did not stop") + } +} + +func TestOfflineRequiresExplicitAccountStatus(t *testing.T) { + path, _ := credentials(t) + p, e := NewOffline(path) + if e != nil { + t.Fatal(e) + } + b, e := os.ReadFile(path) + if e != nil { + t.Fatal(e) + } + var document map[string]any + if e = json.Unmarshal(b, &document); e != nil { + t.Fatal(e) + } + user := document["users"].([]any)[0].(map[string]any) + delete(user, "disabled") + b, e = json.Marshal(document) + if e != nil { + t.Fatal(e) + } + if e = os.WriteFile(path, b, 0600); e != nil { + t.Fatal(e) + } + if _, e = p.load(); !errors.Is(e, source.ErrUnavailable) { + t.Fatal("missing account status became enabled") + } +}
A jellyfin-year-review/internal/auth/provider.go

@@ -0,0 +1,29 @@

+package auth + +import ( + "context" + + "reelreview/internal/source" +) + +// Provider isolates serving from the mechanism that validates account credentials. +// Token is a server-only Jellyfin token or an offline credential revision. +type Provider interface { + Login(context.Context, string, string, string) (source.Account, string, error) + Validate(context.Context, Session) (source.Account, error) + Logout(context.Context, Session) error + Mode() string +} + +type Online struct{ Jellyfin *source.Jellyfin } + +func (p Online) Mode() string { return "jellyfin" } +func (p Online) Login(ctx context.Context, name, password, device string) (source.Account, string, error) { + return p.Jellyfin.Login(ctx, name, password, device) +} +func (p Online) Validate(ctx context.Context, s Session) (source.Account, error) { + return p.Jellyfin.Me(ctx, s.Token, s.Device) +} +func (p Online) Logout(ctx context.Context, s Session) error { + return p.Jellyfin.Call(ctx, "POST", "/Sessions/Logout", s.Token, s.Device, nil, nil) +}
A jellyfin-year-review/internal/auth/sessions.go

@@ -0,0 +1,112 @@

+// Package auth validates online or exported credentials and keeps opaque sessions in server memory. +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "sync" + "time" + + "reelreview/internal/source" +) + +type Session struct { + Account source.Account + Token string + Device string + Expires time.Time +} +type Store struct { + mu sync.Mutex + sessions map[[32]byte]Session + Now func() time.Time + TTL time.Duration +} + +func New() *Store { + return &Store{sessions: map[[32]byte]Session{}, Now: time.Now, TTL: 12 * time.Hour} +} +func Random() string { + var b [32]byte + if _, e := rand.Read(b[:]); e != nil { + panic("system randomness unavailable") + } + return hex.EncodeToString(b[:]) +} +func (s *Store) Create(a source.Account, token, device string) (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + now := s.Now() + for k, v := range s.sessions { + if !now.Before(v.Expires) { + delete(s.sessions, k) + } + } + if len(s.sessions) >= 1000 { + return "", false + } + id := Random() + s.sessions[sha256.Sum256([]byte(id))] = Session{Account: a, Token: token, Device: device, Expires: now.Add(s.TTL)} + return id, true +} +func (s *Store) Get(id string) (Session, bool) { + s.mu.Lock() + defer s.mu.Unlock() + key := sha256.Sum256([]byte(id)) + v, ok := s.sessions[key] + if len(id) != 64 || !ok || !s.Now().Before(v.Expires) { + delete(s.sessions, key) + return Session{}, false + } + return v, true +} +func (s *Store) Delete(id string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.sessions, sha256.Sum256([]byte(id))) +} + +type bucket struct { + Count int + Reset time.Time +} +type Limiter struct { + mu sync.Mutex + entries map[string]bucket + Now func() time.Time +} + +func NewLimiter() *Limiter { return &Limiter{entries: map[string]bucket{}, Now: time.Now} } + +// Allow uses socket peer IP, never untrusted forwarded headers. A bounded global +// bucket also limits distributed login traffic and upstream authentication load. +func (l *Limiter) Allow(ip string) bool { + l.mu.Lock() + defer l.mu.Unlock() + now := l.Now() + for k, v := range l.entries { + if !now.Before(v.Reset) { + delete(l.entries, k) + } + } + if len(l.entries) >= 4096 { + return false + } + for _, key := range []string{"global", ip} { + v := l.entries[key] + if v.Reset.IsZero() { + v.Reset = now.Add(time.Minute) + } + limit := 10 + if key == "global" { + limit = 60 + } + if v.Count >= limit { + return false + } + v.Count++ + l.entries[key] = v + } + return true +}
A jellyfin-year-review/internal/generate/personality.go

@@ -0,0 +1,63 @@

+package generate + +import "reelreview/internal/model" + +// personality uses ordered, evidence-based gates; the first match wins. The final +// balanced label applies only after the same minimum-data gate as every other type. +func personality(r model.Review, exact bool) *model.Personality { + if !exact || r.Records < 10 || r.Seconds < 18000 { + return nil + } + sum := func(a, b int) float64 { + n := 0.0 + for i := a; i < b; i++ { + n += r.Hourly[i] + } + return n + } + minutes := r.Seconds / 60 + night := (sum(0, 5) + sum(23, 24)) / minutes + early := sum(5, 10) / minutes + weekend := (r.Weekdays[0] + r.Weekdays[6]) / minutes + repeated := float64(r.Records-r.UniqueTitles) / float64(r.Records) + maxMonth := 0.0 + for _, v := range r.Monthly { + if v > maxMonth { + maxMonth = v + } + } + rules := []struct { + name, why string + match bool + }{ + {"Night Owl", "At least 40% of your watch time falls between 23:00 and 05:00.", night >= .4}, + {"Early Bird", "At least 40% of your watch time falls between 05:00 and 10:00.", early >= .4}, + {"Weekend Warrior", "At least 60% of your watch time falls on weekends.", weekend >= .6}, + {"Marathoner", "Your longest run includes at least six watched hours.", r.Marathon != nil && r.Marathon.Watched >= 21600}, + {"Binge Watcher", "TV dominates your viewing and your longest run spans at least three records and three watched hours.", r.TVSeconds/r.Seconds >= .7 && r.Marathon != nil && r.Marathon.Records >= 3 && r.Marathon.Watched >= 10800}, + {"Movie Buff", "Movies account for at least 80% of your watch time.", r.MovieSeconds/r.Seconds >= .8}, + {"Series Devotee", "TV accounts for at least 80% of your watch time.", r.TVSeconds/r.Seconds >= .8}, + {"Comfort Rewatcher", "At least half your records revisit a previously seen title or episode.", repeated >= .5}, + {"Genre Explorer", "You watched at least six known genres with meaningful watch time.", knownGenres(r) >= 6}, + {"Prime-Time Regular", "At least half your watch time falls between 19:00 and 23:00.", sum(19, 23)/minutes >= .5}, + {"Daily Viewer", "You watched on at least 180 different dates.", r.ActiveDays >= 180}, + {"Seasonal Viewer", "At least 40% of your watch time falls in one month.", maxMonth/minutes >= .4}, + {"Short-Session Viewer", "Your average uninterrupted record lasts at most 25 minutes.", r.Seconds/float64(r.Records) <= 1500}, + {"Balanced Viewer", "Your viewing spans several habits without one of the stronger patterns dominating.", true}, + } + for _, v := range rules { + if v.match { + return &model.Personality{Name: v.name, Explanation: v.why} + } + } + return nil +} +func knownGenres(r model.Review) int { + n := 0 + for _, g := range r.Genres { + if g.Name != "Unknown" && g.Seconds >= r.Seconds*.03 { + n++ + } + } + return n +}
A jellyfin-year-review/internal/generate/stats.go

@@ -0,0 +1,429 @@

+// Package generate computes all owner-visible statistics before publishing a dataset. +package generate + +import ( + "fmt" + "math" + "sort" + "strings" + "time" + + "reelreview/internal/model" + "reelreview/internal/source" +) + +type Config struct { + Year int + Timezone string + UserTimezones map[string]string + MinimumSeconds float64 + Completion float64 + MarathonGap time.Duration +} + +func DefaultConfig(year int) Config { + return Config{Year: year, Timezone: "UTC", MinimumSeconds: 60, Completion: 90, MarathonGap: 45 * time.Minute} +} +func (c Config) Validate() error { + if c.Year < 1970 || c.Year > 9998 || c.MinimumSeconds < 0 || math.IsNaN(c.MinimumSeconds) || math.IsInf(c.MinimumSeconds, 0) || math.IsNaN(c.Completion) || c.Completion <= 0 || c.Completion > 100 || c.MarathonGap < 0 || c.MarathonGap > 24*time.Hour { + return fmt.Errorf("invalid generation thresholds or year") + } + if _, err := time.LoadLocation(c.Timezone); err != nil { + return fmt.Errorf("invalid default timezone") + } + for id, z := range c.UserTimezones { + n, err := model.ID(id) + if err != nil || n != id { + return fmt.Errorf("timezone keys must be normalized UUIDs") + } + if _, err := time.LoadLocation(z); err != nil { + return fmt.Errorf("invalid user timezone") + } + } + return nil +} + +type span struct{ start, end time.Time } +type mediaSpan struct{ start, end float64 } + +func normalizeName(s string) string { + s = strings.Join(strings.Fields(s), " ") + if s == "" { + return "Unknown" + } + return s +} +func named(m map[string]*model.Named) []model.Named { + out := []model.Named{} + for _, v := range m { + out = append(out, *v) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Seconds != out[j].Seconds { + return out[i].Seconds > out[j].Seconds + } + return out[i].Name < out[j].Name + }) + return out +} +func add(m map[string]*model.Named, name string, seconds float64) { + name = normalizeName(name) + key := strings.ToLower(name) + v := m[key] + if v == nil { + v = &model.Named{Name: name} + m[key] = v + } + if name < v.Name { + v.Name = name + } + v.Seconds += seconds + v.Records++ +} +func content(m map[string]*model.Content) []model.Content { + out := []model.Content{} + for _, v := range m { + sort.Ints(v.Months) + out = append(out, *v) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Seconds != out[j].Seconds { + return out[i].Seconds > out[j].Seconds + } + if out[i].Records != out[j].Records { + return out[i].Records > out[j].Records + } + return out[i].ID < out[j].ID + }) + return out +} + +// Compute clips exact intervals at local year boundaries. Aggregate-only records +// are explicitly attributed to their observed end year; no history is inferred. +func Compute(s model.Snapshot, c Config) ([]model.Review, error) { + if err := c.Validate(); err != nil { + return nil, err + } + if err := source.Validate(&s); err != nil { + return nil, err + } + items := map[string]model.Item{} + for _, i := range s.Items { + items[i.ID] = i + } + out := []model.Review{} + for _, u := range s.Users { + zone := c.Timezone + if z := c.UserTimezones[u.ID]; z != "" { + zone = z + } + loc, _ := time.LoadLocation(zone) + begin := time.Date(c.Year, 1, 1, 0, 0, 0, 0, loc) + end := begin.AddDate(1, 0, 0) + r := model.Review{Rules: model.Rules{MinimumSeconds: c.MinimumSeconds, CompletionPercent: c.Completion, MarathonGapSeconds: c.MarathonGap.Seconds()}, Version: 1, User: u, Year: c.Year, Timezone: zone, GeneratedAt: s.CapturedAt, Source: s.Source, Coverage: s.Coverage, Availability: map[string]model.Availability{}, Warnings: []string{}, Movies: []model.Content{}, Shows: []model.Content{}, Abandoned: []model.Content{}, DistributionMetric: "watched minutes"} + movies, shows := map[string]*model.Content{}, map[string]*model.Content{} + genres, devices, clients := map[string]*model.Named{}, map[string]*model.Named{}, map[string]*model.Named{} + positions := map[string][]mediaSpan{} + positionUnknown := map[string]bool{} + unique := map[string]bool{} + days := map[string]bool{} + intervals := []span{} + exact := true + completionKnown := true + deleted := 0 + aggregate := 0 + for _, p := range s.Playback { + if p.UserID != u.ID || p.Seconds < c.MinimumSeconds { + continue + } + a, b := p.Start, p.End + seconds := p.Seconds + if p.Exact { + if !b.After(begin) || !a.Before(end) { + continue + } + if a.Before(begin) { + a = begin + } + if b.After(end) { + b = end + } + seconds = b.Sub(a).Seconds() + } else { + if p.ObservedAt.Before(begin) || !p.ObservedAt.Before(end) { + continue + } + } + item, ok := items[p.ItemID] + if !ok { + deleted++ + continue + } + if item.Type != "Movie" && item.Type != "Episode" { + continue + } + if !p.Exact { + aggregate++ + exact = false + } + r.Seconds += seconds + r.Records++ + unique[item.ID] = true + key, title, poster := item.ID, item.Title, item.Poster + m := movies + if item.Type == "Episode" { + r.TVSeconds += seconds + m = shows + if item.SeriesID != "" { + key = item.SeriesID + title = item.SeriesTitle + if series, ok := items[key]; ok { + title = series.Title + poster = series.Poster + if len(item.Genres) == 0 { + item.Genres = series.Genres + } + } + } + } else { + r.MovieSeconds += seconds + } + if title == "" { + title = "Unknown title" + } + v := m[key] + if v == nil { + v = &model.Content{ID: key, Title: title, Poster: poster} + m[key] = v + } + v.Seconds += seconds + v.Records++ + if p.Exact { + // Include every local month touched by the clipped half-open interval. + lastMonth := int(b.Add(-time.Nanosecond).In(loc).Month()) + for month := int(a.In(loc).Month()); month <= lastMonth; month++ { + addContentMonth(v, month) + } + } else { + addContentMonth(v, int(p.ObservedAt.In(loc).Month())) + } + gs := map[string]string{} + for _, g := range item.Genres { + name := normalizeName(g) + key := strings.ToLower(name) + if previous, ok := gs[key]; !ok || name < previous { + gs[key] = name + } + } + if len(gs) == 0 { + gs["unknown"] = "Unknown" + } + for _, g := range gs { + add(genres, g, seconds/float64(len(gs))) + } + add(devices, p.Device, seconds) + add(clients, p.Client, seconds) + if item.Type == "Movie" { + if item.Runtime <= 0 || p.PositionStart == nil || !p.Exact || !p.Start.Equal(a) || !p.End.Equal(b) { + positionUnknown[item.ID] = true + completionKnown = false + } else { + positions[item.ID] = append(positions[item.ID], mediaSpan{math.Min(*p.PositionStart, item.Runtime), math.Min(*p.PositionEnd, item.Runtime)}) + } + } + if p.Exact { + intervals = append(intervals, span{a, b}) + allocate(&r, a, b, loc, days) + } else { + t := p.ObservedAt.In(loc) + r.Hourly[t.Hour()]++ + r.Weekdays[t.Weekday()]++ + r.Monthly[int(t.Month())-1]++ + days[t.Format("2006-01-02")] = true + } + } + // Mixed precision uses end-record counts for all distributions rather than mixing units. + if !exact { + r.Hourly = [24]float64{} + r.Weekdays = [7]float64{} + r.Monthly = [12]float64{} + days = map[string]bool{} + for _, p := range s.Playback { + i, ok := items[p.ItemID] + if !ok || p.UserID != u.ID || p.Seconds < c.MinimumSeconds || (i.Type != "Movie" && i.Type != "Episode") { + continue + } + t := p.ObservedAt + if p.Exact { + if !p.End.After(begin) || !p.Start.Before(end) { + continue + } + t = p.End.Add(-time.Nanosecond) + if !t.Before(end) { + t = end.Add(-time.Nanosecond) + } + } + if t.Before(begin) || !t.Before(end) { + continue + } + t = t.In(loc) + r.Hourly[t.Hour()]++ + r.Weekdays[t.Weekday()]++ + r.Monthly[int(t.Month())-1]++ + days[t.Format("2006-01-02")] = true + } + r.DistributionMetric = "activity record ends (counts)" + r.Warnings = append(r.Warnings, "Aggregate history is assigned to the record end year; exact yearly watch time, play counts, and timing cannot be recovered.") + } + r.UniqueTitles = len(unique) + r.ActiveDays = len(days) + r.Movies = content(movies) + r.Shows = content(shows) + r.Genres = named(genres) + r.Devices = named(devices) + r.Clients = named(clients) + for i := range r.Movies { + v := &r.Movies[i] + if positionUnknown[v.ID] { + continue + } + ranges := positions[v.ID] + if len(ranges) == 0 { + continue + } + sort.Slice(ranges, func(i, j int) bool { return ranges[i].start < ranges[j].start }) + covered, finish := 0.0, 0.0 + for _, p := range ranges { + if p.end > finish { + covered += p.end - math.Max(p.start, finish) + finish = p.end + } + } + pct := math.Min(100, 100*covered/items[v.ID].Runtime) + v.Completion = &pct + if pct < c.Completion { + r.Abandoned = append(r.Abandoned, *v) + } + } + if exact { + r.Marathon = marathon(intervals, c.MarathonGap) + } + r.Availability["exactWatchTime"] = model.Availability{Available: exact, Reason: reason(exact, "Jellystat end-attributed totals are estimates of selected-year viewing.")} + r.Availability["marathon"] = model.Availability{Available: exact && r.Records > 0, Reason: reason(exact && r.Records > 0, "Exact uninterrupted intervals are required.")} + r.Availability["completion"] = model.Availability{Available: completionKnown, Reason: reason(completionKnown, "Some movies lack runtime or actual media-position ranges; they are omitted from abandonment results.")} + if deleted > 0 { + r.Warnings = append(r.Warnings, fmt.Sprintf("%d records excluded because media metadata was deleted or unavailable.", deleted)) + } + if aggregate > 0 { + r.Warnings = append(r.Warnings, fmt.Sprintf("%d aggregate activity records; records may combine resumed viewing.", aggregate)) + } + r.Personality = personality(r, exact) + r.Availability["personality"] = model.Availability{Available: r.Personality != nil, Reason: reason(r.Personality != nil, "At least 10 exact records and 5 watched hours are required.")} + out = append(out, r) + } + rank(out) + return out, nil +} + +// addContentMonth deduplicates resumed movies and episodes of the same series. +func addContentMonth(v *model.Content, month int) { + for _, existing := range v.Months { + if existing == month { + return + } + } + v.Months = append(v.Months, month) +} + +func reason(ok bool, s string) string { + if ok { + return "" + } + return s +} + +// allocate walks real UTC minute boundaries, preserving repeated/skipped DST hours. +func allocate(r *model.Review, a, b time.Time, loc *time.Location, days map[string]bool) { + for a.Before(b) { + next := a.Truncate(time.Minute).Add(time.Minute) + if next.After(b) { + next = b + } + n := next.Sub(a).Minutes() + t := a.In(loc) + r.Hourly[t.Hour()] += n + r.Weekdays[t.Weekday()] += n + r.Monthly[int(t.Month())-1] += n + days[t.Format("2006-01-02")] = true + a = next + } +} +func marathon(v []span, gap time.Duration) *model.Marathon { + if len(v) == 0 { + return nil + } + sort.Slice(v, func(i, j int) bool { + if v[i].start.Equal(v[j].start) { + return v[i].end.Before(v[j].end) + } + return v[i].start.Before(v[j].start) + }) + var best *model.Marathon + var run model.Marathon + save := func() { + run.Elapsed = run.End.Sub(run.Start).Seconds() + if best == nil || run.Watched > best.Watched || (run.Watched == best.Watched && run.Start.Before(best.Start)) { + copy := run + best = &copy + } + } + for _, s := range v { + if run.Records == 0 || s.start.After(run.End.Add(gap)) { + if run.Records > 0 { + save() + } + run = model.Marathon{Start: s.start, End: s.end, Watched: s.end.Sub(s.start).Seconds(), Records: 1} + continue + } + run.Records++ + if s.end.After(run.End) { + a := s.start + if a.Before(run.End) { + a = run.End + } + run.Watched += s.end.Sub(a).Seconds() + run.End = s.end + } + } + save() + return best +} +func rank(rs []model.Review) { + eligible := []int{} + total := 0.0 + for i, r := range rs { + if r.Seconds >= 3600 { + eligible = append(eligible, i) + total += r.Seconds + } + } + for _, i := range eligible { + higher, lower := 0, 0 + for _, j := range eligible { + if rs[j].Seconds > rs[i].Seconds { + higher++ + } + if rs[j].Seconds < rs[i].Seconds { + lower++ + } + } + pct := 0.0 + if len(eligible) > 1 { + pct = 100 * float64(lower) / float64(len(eligible)-1) + } + rs[i].Ranking = &model.Ranking{Rank: higher + 1, Cohort: len(eligible), Percentile: pct, Average: total / float64(len(eligible))} + } + for i := range rs { + rs[i].Availability["ranking"] = model.Availability{Available: rs[i].Ranking != nil, Reason: reason(rs[i].Ranking != nil, "At least one watched hour is required for cohort eligibility.")} + } +}
A jellyfin-year-review/internal/generate/stats_test.go

@@ -0,0 +1,316 @@

+package generate + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "reelreview/internal/model" +) + +const alice = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +const bob = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +func instant(s string) time.Time { + t, e := time.Parse(time.RFC3339, s) + if e != nil { + panic(e) + } + return t +} +func number(n float64) *float64 { return &n } +func fixture() model.Snapshot { + return model.Snapshot{Version: 1, CapturedAt: instant("2026-01-01T00:00:00Z"), Source: "synthetic exact intervals", Coverage: "2025 full synthetic fixture", Users: []model.User{{ID: alice, Name: "Alice"}}, Items: []model.Item{{ID: "film", Title: "A Film", Type: "Movie", Runtime: 7200, Genres: []string{"Drama", "Adventure"}}}} +} +func play(id, start, end string) model.Playback { + a, b := instant(start), instant(end) + return model.Playback{ID: id, UserID: alice, ItemID: "film", Start: a, End: b, Exact: true, Seconds: b.Sub(a).Seconds(), PositionStart: number(0), PositionEnd: number(b.Sub(a).Seconds())} +} +func compute(t *testing.T, s model.Snapshot, c Config) []model.Review { + t.Helper() + r, e := Compute(s, c) + if e != nil { + t.Fatal(e) + } + return r +} +func TestResumedCoverageAndDuplicate(t *testing.T) { + s := fixture() + a := play("a", "2025-01-02T12:00:00Z", "2025-01-02T13:00:00Z") + b := play("b", "2025-01-03T12:00:00Z", "2025-01-03T13:00:00Z") + b.PositionStart = number(3600) + b.PositionEnd = number(7200) + s.Playback = []model.Playback{a, a, b} + r := compute(t, s, DefaultConfig(2025))[0] + if r.Seconds != 7200 || r.Records != 2 || len(r.Abandoned) != 0 || *r.Movies[0].Completion != 100 { + t.Fatalf("resumed/duplicate result: %+v", r) + } + b.PositionStart = number(0) + b.PositionEnd = number(3600) + s.Playback = []model.Playback{a, b} + r = compute(t, s, DefaultConfig(2025))[0] + if len(r.Abandoned) != 1 || *r.Abandoned[0].Completion != 50 { + t.Fatal("repeated positions inflated completion") + } + b.ID = a.ID + s.Playback = []model.Playback{a, b} + if _, e := Compute(s, DefaultConfig(2025)); e == nil { + t.Fatal("conflicting duplicate accepted") + } +} +func TestYearMidnightAndDST(t *testing.T) { + tests := []struct { + name, zone, start, end string + seconds float64 + hours map[int]float64 + days int + }{ + {"new year", "UTC", "2024-12-31T23:30:00Z", "2025-01-01T00:30:00Z", 1800, map[int]float64{0: 30}, 1}, + {"local boundary", "America/New_York", "2025-01-01T04:30:00Z", "2025-01-01T05:30:00Z", 1800, map[int]float64{0: 30}, 1}, + {"spring", "America/New_York", "2025-03-09T06:30:00Z", "2025-03-09T07:30:00Z", 3600, map[int]float64{1: 30, 2: 0, 3: 30}, 1}, + {"fall", "America/New_York", "2025-11-02T05:30:00Z", "2025-11-02T06:30:00Z", 3600, map[int]float64{1: 60}, 1}, + {"midnight", "UTC", "2025-04-01T23:30:00Z", "2025-04-02T00:30:00Z", 3600, map[int]float64{23: 30, 0: 30}, 2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := fixture() + s.Playback = []model.Playback{play("a", tt.start, tt.end)} + c := DefaultConfig(2025) + c.Timezone = tt.zone + r := compute(t, s, c)[0] + if r.Seconds != tt.seconds || r.ActiveDays != tt.days { + t.Fatalf("bad boundary: %f, %d", r.Seconds, r.ActiveDays) + } + for h, want := range tt.hours { + if r.Hourly[h] != want { + t.Fatalf("hour %d got %v want %v", h, r.Hourly[h], want) + } + } + }) + } +} +func TestMarathonOverlapGapAndTie(t *testing.T) { + v := []span{{instant("2025-01-01T00:00:00Z"), instant("2025-01-01T02:00:00Z")}, {instant("2025-01-01T01:00:00Z"), instant("2025-01-01T03:00:00Z")}, {instant("2025-01-01T03:45:00Z"), instant("2025-01-01T04:45:00Z")}, {instant("2025-01-02T00:00:00Z"), instant("2025-01-02T04:00:00Z")}} + r := marathon(v, 45*time.Minute) + if r.Watched != 14400 || r.Elapsed != 17100 || r.Records != 3 || !r.Start.Equal(v[0].start) { + t.Fatalf("overlap/gap/tie: %+v", r) + } + r = marathon(v, 44*time.Minute) + if r.Records != 1 || !r.Start.Equal(v[3].start) { + t.Fatal("gap was not enforced") + } +} +func TestRankingTiesAndSmallCohort(t *testing.T) { + rs := []model.Review{{Seconds: 7200, Availability: map[string]model.Availability{}}, {Seconds: 7200, Availability: map[string]model.Availability{}}, {Seconds: 3600, Availability: map[string]model.Availability{}}, {Seconds: 100, Availability: map[string]model.Availability{}}} + rank(rs) + if rs[0].Ranking.Rank != 1 || rs[1].Ranking.Rank != 1 || rs[2].Ranking.Rank != 3 || rs[0].Ranking.Percentile != 50 || rs[3].Ranking != nil { + t.Fatal("ranking ties wrong") + } + rank(rs[:1]) + if rs[0].Ranking.Percentile != 0 || rs[0].Ranking.Cohort != 1 { + t.Fatal("solo cohort") + } +} +func TestMissingMetadataAndAggregateAvailability(t *testing.T) { + s := fixture() + s.Playback = []model.Playback{{ID: "aggregate", UserID: alice, ItemID: "film", ObservedAt: instant("2025-02-03T19:00:00Z"), Seconds: 3600}, {ID: "deleted", UserID: alice, ItemID: "deleted", ObservedAt: instant("2025-01-01T00:00:00Z"), Seconds: 900}} + r := compute(t, s, DefaultConfig(2025))[0] + if r.Seconds != 3600 || r.Hourly[19] != 1 || r.Marathon != nil || r.Personality != nil || len(r.Abandoned) != 0 || r.Availability["completion"].Available { + t.Fatalf("invented unavailable metrics: %+v", r) + } + s.Playback = nil + r = compute(t, s, DefaultConfig(2025))[0] + if r.Seconds != 0 || r.Records != 0 || r.Ranking != nil || r.Marathon != nil { + t.Fatal("empty history") + } + s.Items[0].Runtime = 0 + s.Playback = []model.Playback{play("a", "2025-01-02T12:00:00Z", "2025-01-02T13:00:00Z")} + r = compute(t, s, DefaultConfig(2025))[0] + if r.Movies[0].Completion != nil { + t.Fatal("missing runtime fabricated completion") + } +} +func TestPersonalityGateAndPriority(t *testing.T) { + r := model.Review{Records: 10, Seconds: 36000, MovieSeconds: 36000} + r.Hourly[0] = 600 + r.Weekdays[0] = 600 + p := personality(r, true) + if p == nil || p.Name != "Night Owl" { + t.Fatal("deterministic first-match priority") + } + if personality(r, false) != nil { + t.Fatal("aggregate personality invented") + } + r.Records = 9 + if personality(r, true) != nil { + t.Fatal("minimum data gate") + } +} +func TestDeterministicAndAtomicPublication(t *testing.T) { + s := fixture() + s.Playback = []model.Playback{play("a", "2025-01-02T12:00:00Z", "2025-01-02T13:00:00Z")} + c := DefaultConfig(2025) + a := compute(t, s, c) + b := compute(t, s, c) + ja, _ := json.Marshal(a) + jb, _ := json.Marshal(b) + if !reflect.DeepEqual(ja, jb) { + t.Fatal("nondeterministic computation") + } + root := t.TempDir() + if e := Publish(root, a, nil); e != nil { + t.Fatal(e) + } + before, _ := os.ReadFile(filepath.Join(root, "CURRENT")) + if e := Publish(root, a, map[string][]byte{"../unsafe": {1}}); e == nil { + t.Fatal("unsafe asset accepted") + } + after, _ := os.ReadFile(filepath.Join(root, "CURRENT")) + if string(before) != string(after) { + t.Fatal("failed generation changed pointer") + } + if e := Publish(root, a, nil); e != nil { + t.Fatal(e) + } + after, _ = os.ReadFile(filepath.Join(root, "CURRENT")) + if string(before) != string(after) { + t.Fatal("non deterministic publication") + } + c.Year = 2024 + if e := Publish(root, compute(t, s, c), nil); e != nil { + t.Fatal(e) + } + m, _, e := Manifest(root) + if e != nil || !reflect.DeepEqual(m.Users[alice], []int{2024, 2025}) { + t.Fatal("year was lost") + } + if e := os.WriteFile(filepath.Join(root, ".generate.lock"), nil, 0600); e != nil { + t.Fatal(e) + } + if e := Publish(root, a, nil); e == nil { + t.Fatal("concurrent writer allowed") + } +} + +func TestPerUserTimezoneThresholdAndGenreWeight(t *testing.T) { + s := fixture() + s.Users = append(s.Users, model.User{ID: bob, Name: "Bob"}) + s.Items[0].Genres = []string{"Drama", "drama", "Adventure"} + a := play("a", "2025-01-01T00:00:00Z", "2025-01-01T01:00:00Z") + b := a + b.UserID = bob + tiny := play("tiny", "2025-01-02T12:00:00Z", "2025-01-02T12:00:30Z") + s.Playback = []model.Playback{a, b, tiny} + c := DefaultConfig(2025) + c.UserTimezones = map[string]string{bob: "America/New_York"} + rs := compute(t, s, c) + if rs[0].Seconds != 3600 || rs[0].Records != 1 || rs[0].Genres[0].Seconds != 1800 || rs[0].Genres[1].Seconds != 1800 { + t.Fatal("minimum threshold or distinct genre weighting") + } + if rs[1].Seconds != 0 || rs[1].Timezone != "America/New_York" { + t.Fatal("per-user local year not honored") + } +} + +func TestAllFourteenPersonalityRulesReachable(t *testing.T) { + base := func(seconds float64) model.Review { + r := model.Review{Seconds: seconds, MovieSeconds: seconds / 2, TVSeconds: seconds / 2, Records: 100, UniqueTitles: 100, ActiveDays: 100} + for i := range r.Hourly { + r.Hourly[i] = seconds / 60 / 24 + } + for i := range r.Weekdays { + r.Weekdays[i] = seconds / 60 / 7 + } + for i := range r.Monthly { + r.Monthly[i] = seconds / 60 / 12 + } + return r + } + cases := []struct { + name string + change func(*model.Review) + }{ + {"Night Owl", func(r *model.Review) { r.Hourly = [24]float64{0: r.Seconds / 60} }}, + {"Early Bird", func(r *model.Review) { r.Hourly = [24]float64{6: r.Seconds / 60} }}, + {"Weekend Warrior", func(r *model.Review) { r.Weekdays = [7]float64{0: r.Seconds / 60} }}, + {"Marathoner", func(r *model.Review) { r.Marathon = &model.Marathon{Watched: 21600} }}, + {"Binge Watcher", func(r *model.Review) { + r.TVSeconds = r.Seconds * .7 + r.MovieSeconds = r.Seconds * .3 + r.Marathon = &model.Marathon{Watched: 10800, Records: 3} + }}, + {"Movie Buff", func(r *model.Review) { r.MovieSeconds = r.Seconds * .8 }}, + {"Series Devotee", func(r *model.Review) { r.TVSeconds = r.Seconds * .8 }}, + {"Comfort Rewatcher", func(r *model.Review) { r.UniqueTitles = 40 }}, + {"Genre Explorer", func(r *model.Review) { + for _, n := range []string{"A", "B", "C", "D", "E", "F"} { + r.Genres = append(r.Genres, model.Named{Name: n, Seconds: r.Seconds * .05}) + } + }}, + {"Prime-Time Regular", func(r *model.Review) { r.Hourly = [24]float64{20: r.Seconds / 60} }}, + {"Daily Viewer", func(r *model.Review) { r.ActiveDays = 180 }}, + {"Seasonal Viewer", func(r *model.Review) { r.Monthly = [12]float64{0: r.Seconds / 60} }}, + {"Short-Session Viewer", func(r *model.Review) { *r = base(60000) }}, + {"Balanced Viewer", func(r *model.Review) {}}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + r := base(360000) + tt.change(&r) + p := personality(r, true) + if p == nil || p.Name != tt.name { + t.Fatalf("got %+v", p) + } + }) + } +} + +func TestContentViewingMonths(t *testing.T) { + cases := []struct { + name, zone string + playback []model.Playback + months []int + }{ + {"month boundary", "UTC", []model.Playback{play("a", "2025-01-31T23:30:00Z", "2025-02-01T00:30:00Z")}, []int{1, 2}}, + {"exclusive end", "UTC", []model.Playback{play("a", "2025-01-31T23:00:00Z", "2025-02-01T00:00:00Z")}, []int{1}}, + {"local month", "America/New_York", []model.Playback{play("a", "2025-02-01T01:00:00Z", "2025-02-01T02:00:00Z")}, []int{1}}, + {"year clipping", "UTC", []model.Playback{play("a", "2024-12-31T23:30:00Z", "2025-01-01T00:30:00Z"), play("b", "2025-12-31T23:30:00Z", "2026-01-01T00:30:00Z")}, []int{1, 12}}, + {"resumed and repeated", "UTC", []model.Playback{play("c", "2025-03-01T12:00:00Z", "2025-03-01T13:00:00Z"), play("a", "2025-01-01T12:00:00Z", "2025-01-01T13:00:00Z"), play("b", "2025-01-02T12:00:00Z", "2025-01-02T13:00:00Z")}, []int{1, 3}}, + {"aggregate observed month", "America/New_York", []model.Playback{{ID: "a", UserID: alice, ItemID: "film", Seconds: 3600, ObservedAt: instant("2025-03-01T02:00:00Z")}}, []int{2}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := fixture() + s.Playback = tc.playback + c := DefaultConfig(2025) + c.Timezone = tc.zone + r := compute(t, s, c)[0] + if len(r.Movies) != 1 || !reflect.DeepEqual(r.Movies[0].Months, tc.months) { + t.Fatalf("unexpected movie months: %+v", r.Movies) + } + }) + } +} + +func TestSeriesViewingMonthsCombineEpisodes(t *testing.T) { + s := fixture() + s.Items = append(s.Items, + model.Item{ID: "series", Title: "A Series", Type: "Series"}, + model.Item{ID: "episode1", Title: "Episode One", Type: "Episode", SeriesID: "series"}, + model.Item{ID: "episode2", Title: "Episode Two", Type: "Episode", SeriesID: "series"}) + for i, date := range []string{"2025-01-01", "2025-01-02", "2025-03-01"} { + p := play(date, date+"T12:00:00Z", date+"T13:00:00Z") + p.ItemID = "episode1" + if i > 0 { + p.ItemID = "episode2" + } + s.Playback = append(s.Playback, p) + } + r := compute(t, s, DefaultConfig(2025))[0] + if len(r.Shows) != 1 || r.Shows[0].ID != "series" || r.Shows[0].Records != 3 || !reflect.DeepEqual(r.Shows[0].Months, []int{1, 3}) { + t.Fatalf("episodes did not combine into monthly series entries: %+v", r.Shows) + } +}
A jellyfin-year-review/internal/generate/store.go

@@ -0,0 +1,234 @@

+package generate + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + + "reelreview/internal/model" +) + +var datasetName = regexp.MustCompile(`^[a-f0-9]{64}$`) +var assetName = regexp.MustCompile(`^[a-f0-9]{64}\.jpg$`) + +func SafeAsset(s string) bool { return assetName.MatchString(s) } + +// Current resolves an atomic pointer to a complete immutable dataset. +func Current(root string) (string, error) { + b, err := os.ReadFile(filepath.Join(root, "CURRENT")) + if err != nil { + return "", fmt.Errorf("no published dataset") + } + name := string(b) + if !datasetName.MatchString(name) { + return "", fmt.Errorf("invalid dataset pointer") + } + return filepath.Join(root, "datasets", name), nil +} +func Manifest(root string) (model.Manifest, string, error) { + var m model.Manifest + p, err := Current(root) + if err != nil { + return m, "", err + } + b, err := os.ReadFile(filepath.Join(p, "manifest.json")) + if err != nil { + return m, "", err + } + if json.Unmarshal(b, &m) != nil || m.Version != 1 || m.Users == nil { + return m, "", fmt.Errorf("unsupported manifest") + } + for id, years := range m.Users { + n, e := model.ID(id) + if e != nil || n != id { + return m, "", fmt.Errorf("invalid manifest identity") + } + for _, y := range years { + if y < 1970 || y > 9998 { + return m, "", fmt.Errorf("invalid manifest year") + } + } + } + return m, p, nil +} +func writeJSON(path string, v any) error { + b, e := json.MarshalIndent(v, "", " ") + if e != nil { + return e + } + return durableWrite(path, b, 0600) +} +func durableWrite(path string, b []byte, mode os.FileMode) error { + f, e := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) + if e != nil { + return e + } + if _, e = f.Write(b); e == nil { + e = f.Sync() + } + closeErr := f.Close() + if e == nil { + e = closeErr + } + return e +} +func syncDir(path string) error { + f, e := os.Open(path) + if e != nil { + return e + } + defer f.Close() + return f.Sync() +} + +// Publish clones the last successful dataset, replaces one year, then switches a +// synced pointer. A lock serializes writers; a failed write never switches readers. +func Publish(root string, rs []model.Review, assets map[string][]byte) error { + if len(rs) == 0 { + return fmt.Errorf("no users to publish") + } + if err := os.MkdirAll(filepath.Join(root, "datasets"), 0700); err != nil { + return err + } + lock, e := os.OpenFile(filepath.Join(root, ".generate.lock"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if e != nil { + return fmt.Errorf("another generator is active (or stale .generate.lock exists)") + } + lock.Close() + defer os.Remove(filepath.Join(root, ".generate.lock")) + stage, e := os.MkdirTemp(filepath.Join(root, "datasets"), ".stage-") + if e != nil { + return e + } + defer os.RemoveAll(stage) + m := model.Manifest{Version: 1, Users: map[string][]int{}} + if _, err := os.Stat(filepath.Join(root, "CURRENT")); err == nil { + previous, p, err := Manifest(root) + if err != nil { + return err + } + m = previous + err = filepath.WalkDir(p, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(p, path) + if rel == "." { + return nil + } + dest := filepath.Join(stage, rel) + if d.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("symlinks forbidden in dataset") + } + if d.IsDir() { + return os.MkdirAll(dest, 0700) + } + b, err := os.ReadFile(path) + if err != nil { + return err + } + return durableWrite(dest, b, 0600) + }) + if err != nil { + return err + } + } + year := rs[0].Year + // Remove that year's previous cohort, including users removed from Jellyfin. + for id, years := range m.Users { + keep := []int{} + for _, y := range years { + if y != year { + keep = append(keep, y) + } + } + m.Users[id] = keep + _ = os.Remove(filepath.Join(stage, id, strconv.Itoa(year)+".json")) + if len(keep) == 0 { + delete(m.Users, id) + } + } + for _, r := range rs { + id, e := model.ID(r.User.ID) + if e != nil || id != r.User.ID || r.Year != year { + return fmt.Errorf("inconsistent review cohort") + } + if e = r.Validate(id, year); e != nil { + return e + } + dir := filepath.Join(stage, id) + if e = os.MkdirAll(dir, 0700); e != nil { + return e + } + if e = writeJSON(filepath.Join(dir, strconv.Itoa(year)+".json"), r); e != nil { + return e + } + m.Users[id] = append(m.Users[id], year) + sort.Ints(m.Users[id]) + } + if e = os.MkdirAll(filepath.Join(stage, "art"), 0700); e != nil { + return e + } + for name, b := range assets { + if !SafeAsset(name) { + return fmt.Errorf("unsafe artwork name") + } + if e = durableWrite(filepath.Join(stage, "art", name), b, 0600); e != nil { + return e + } + } + if e = writeJSON(filepath.Join(stage, "manifest.json"), m); e != nil { + return e + } + h := sha256.New() + e = filepath.WalkDir(stage, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return syncDir(path) + } + rel, _ := filepath.Rel(stage, path) + _, _ = io.WriteString(h, rel+"\x00") + b, err := os.ReadFile(path) + if err != nil { + return err + } + _, _ = h.Write(b) + return nil + }) + if e != nil { + return e + } + name := hex.EncodeToString(h.Sum(nil)) + dest := filepath.Join(root, "datasets", name) + if _, err := os.Stat(dest); os.IsNotExist(err) { + if e = os.Rename(stage, dest); e != nil { + return e + } + } + if e = syncDir(filepath.Join(root, "datasets")); e != nil { + return e + } + temp, e := os.CreateTemp(root, ".current-") + if e != nil { + return e + } + path := temp.Name() + temp.Close() + defer os.Remove(path) + if e = durableWrite(path, []byte(name), 0600); e != nil { + return e + } + if e = os.Rename(path, filepath.Join(root, "CURRENT")); e != nil { + return e + } + return syncDir(root) +}
A jellyfin-year-review/internal/model/model.go

@@ -0,0 +1,154 @@

+// Package model defines the private source snapshot and public-to-owner review v1 contracts. +package model + +import ( + "encoding/hex" + "fmt" + "strings" + "time" +) + +const Version = 1 + +// ID converts Jellyfin UUIDs to lowercase, hyphen-free identity keys. +func ID(s string) (string, error) { + s = strings.ToLower(strings.ReplaceAll(s, "-", "")) + if len(s) != 32 { + return "", fmt.Errorf("invalid Jellyfin UUID") + } + if _, err := hex.DecodeString(s); err != nil { + return "", fmt.Errorf("invalid Jellyfin UUID") + } + return s, nil +} + +type User struct { + ID string `json:"id"` + Name string `json:"name"` +} +type Item struct { + ID string `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + SeriesID string `json:"seriesId,omitempty"` + SeriesTitle string `json:"seriesTitle,omitempty"` + Year int `json:"year,omitempty"` + Runtime float64 `json:"runtimeSeconds,omitempty"` + Genres []string `json:"genres"` + ImageTag string `json:"imageTag,omitempty"` + ProviderIDs map[string]string `json:"providerIds,omitempty"` + Poster string `json:"poster,omitempty"` +} + +// Playback is a finalized activity row, not a progress update. Exact records describe +// uninterrupted watched intervals; position ranges describe actual media coverage. +type Playback struct { + ID string `json:"id"` + UserID string `json:"userId"` + ItemID string `json:"itemId"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + ObservedAt time.Time `json:"observedAt"` + Seconds float64 `json:"seconds"` + Exact bool `json:"exactInterval"` + PositionStart *float64 `json:"positionStart,omitempty"` + PositionEnd *float64 `json:"positionEnd,omitempty"` + Device string `json:"device"` + Client string `json:"client"` +} +type Snapshot struct { + Version int `json:"schemaVersion"` + CapturedAt time.Time `json:"capturedAt"` + Source string `json:"source"` + Coverage string `json:"coverage"` + Users []User `json:"users"` + Items []Item `json:"items"` + Playback []Playback `json:"playback"` +} +type Availability struct { + Available bool `json:"available"` + Reason string `json:"reason,omitempty"` +} +type Named struct { + Name string `json:"name"` + Seconds float64 `json:"seconds"` + Records int `json:"records"` +} +type Content struct { + ID string `json:"id"` + Title string `json:"title"` + Poster string `json:"poster,omitempty"` + PosterSource string `json:"posterSource,omitempty"` + Seconds float64 `json:"seconds"` + Records int `json:"records"` + Months []int `json:"months,omitempty"` + Completion *float64 `json:"completionPercent,omitempty"` +} +type Marathon struct { + Start time.Time `json:"start"` + End time.Time `json:"end"` + Watched float64 `json:"watchedSeconds"` + Elapsed float64 `json:"elapsedSeconds"` + Records int `json:"records"` +} +type Personality struct { + Name string `json:"name"` + Explanation string `json:"explanation"` +} +type Ranking struct { + Rank int `json:"rank"` + Cohort int `json:"cohort"` + Percentile float64 `json:"percentile"` + Average float64 `json:"averageSeconds"` +} +type Rules struct { + MinimumSeconds float64 `json:"minimumSeconds"` + CompletionPercent float64 `json:"completionPercent"` + MarathonGapSeconds float64 `json:"marathonGapSeconds"` +} +type Review struct { + Rules Rules `json:"rules"` + Version int `json:"schemaVersion"` + User User `json:"user"` + Year int `json:"year"` + Timezone string `json:"timezone"` + GeneratedAt time.Time `json:"generatedAt"` + Source string `json:"source"` + Coverage string `json:"coverage"` + Availability map[string]Availability `json:"availability"` + Seconds float64 `json:"seconds"` + MovieSeconds float64 `json:"movieSeconds"` + TVSeconds float64 `json:"tvSeconds"` + Records int `json:"records"` + UniqueTitles int `json:"uniqueTitles"` + ActiveDays int `json:"activeDays"` + Movies []Content `json:"movies"` + Shows []Content `json:"shows"` + Abandoned []Content `json:"abandoned"` + Genres []Named `json:"genres"` + Devices []Named `json:"devices"` + Clients []Named `json:"clients"` + Hourly [24]float64 `json:"hourly"` + Weekdays [7]float64 `json:"weekdays"` + Monthly [12]float64 `json:"monthly"` + DistributionMetric string `json:"distributionMetric"` + Marathon *Marathon `json:"marathon"` + Personality *Personality `json:"personality"` + Ranking *Ranking `json:"ranking"` + Warnings []string `json:"warnings"` +} +type Manifest struct { + Version int `json:"schemaVersion"` + Users map[string][]int `json:"users"` +} + +// Validate rejects incompatible, corrupted or incorrectly addressed documents. +func (r Review) Validate(id string, year int) error { + if r.Version != Version || r.User.ID != id || r.Year != year || r.Seconds < 0 || r.Availability == nil { + return fmt.Errorf("invalid or unsupported review") + } + if _, err := time.LoadLocation(r.Timezone); err != nil { + return fmt.Errorf("invalid review timezone") + } + return nil +}
A jellyfin-year-review/internal/server/server.go

@@ -0,0 +1,354 @@

+// Package server serves only static frontend files and authenticated owner-scoped APIs. +package server + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "reelreview/internal/auth" + "reelreview/internal/generate" + "reelreview/internal/model" + "reelreview/internal/source" +) + +type Server struct { + Data, Web, Origin string + Auth auth.Provider + Sessions *auth.Store + Limiter *auth.Limiter + secure bool + shell []byte + csp string + webRoot *os.Root +} + +func New(data, web, origin string, j *source.Jellyfin) (*Server, error) { + if j == nil { + return nil, fmt.Errorf("Jellyfin authentication is not configured") + } + return NewWithAuth(data, web, origin, auth.Online{Jellyfin: j}) +} + +// NewWithAuth requires an explicit backend; offline mode has no Jellyfin client. +func NewWithAuth(data, web, origin string, provider auth.Provider) (*Server, error) { + if provider == nil { + return nil, fmt.Errorf("authentication is not configured") + } + if offline, ok := provider.(*auth.Offline); ok { + for _, dir := range []string{web, data} { + root, err := filepath.Abs(dir) + if err != nil { + return nil, err + } + if resolved, err := filepath.EvalSymlinks(root); err == nil { + root = resolved + } + private, err := filepath.EvalSymlinks(offline.Path()) + if err != nil { + return nil, fmt.Errorf("offline credential file unavailable") + } + rel, err := filepath.Rel(root, private) + if err != nil || rel == "." || (!strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && rel != "..") { + return nil, fmt.Errorf("offline credentials must be outside the frontend and review data directories") + } + } + } + u, e := url.Parse(origin) + if e != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil { + return nil, fmt.Errorf("APP_ORIGIN must be an exact HTTP(S) origin without a trailing slash") + } + root, e := os.OpenRoot(web) + if e != nil { + return nil, fmt.Errorf("frontend build missing") + } + f, e := root.Open("index.html") + if e != nil { + root.Close() + return nil, fmt.Errorf("frontend index.html missing") + } + shell, e := io.ReadAll(io.LimitReader(f, 1<<20)) + f.Close() + if e != nil { + root.Close() + return nil, e + } + csp := "default-src 'none'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; connect-src 'self'; img-src 'self'; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'" + re := regexp.MustCompile(`(?s)<script\b[^>]*>(.*?)</script>`) + for _, m := range re.FindAllSubmatch(shell, -1) { + if len(m[1]) > 0 { + h := sha256.Sum256(m[1]) + csp += " 'sha256-" + base64.StdEncoding.EncodeToString(h[:]) + "'" + } + } + return &Server{Data: data, Web: web, Origin: origin, Auth: provider, Sessions: auth.New(), Limiter: auth.NewLimiter(), secure: u.Scheme == "https", shell: shell, csp: csp, webRoot: root}, nil +} +func (s *Server) authUnavailable(w http.ResponseWriter) { + if s.Auth.Mode() == "offline" { + fail(w, 503, "Offline authentication is unavailable. Ask the operator to check the private credential export.") + return + } + fail(w, 503, "Jellyfin authentication is unavailable. Please retry.") +} +func (s *Server) Close() error { return s.webRoot.Close() } +func fail(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": message}) +} +func respond(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} +func (s *Server) cookie(w http.ResponseWriter, id string, remove bool) { + max := int(s.Sessions.TTL.Seconds()) + if remove { + max = -1 + } + http.SetCookie(w, &http.Cookie{Name: "review_session", Value: id, Path: "/", HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteStrictMode, MaxAge: max}) +} +func cookieID(r *http.Request) string { + c, e := r.Cookie("review_session") + if e != nil { + return "" + } + return c.Value +} +func (s *Server) session(w http.ResponseWriter, r *http.Request) (auth.Session, bool) { + id := cookieID(r) + v, ok := s.Sessions.Get(id) + if !ok { + fail(w, 401, "Your session has expired. Please sign in.") + return v, false + } + a, err := s.Auth.Validate(r.Context(), v) + if err != nil || a.ID != v.Account.ID { + if errors.Is(err, source.ErrUnavailable) { + s.authUnavailable(w) + } else { + s.Sessions.Delete(id) + s.cookie(w, "", true) + fail(w, 401, "Your session has expired. Please sign in.") + } + return v, false + } + v.Account = a + return v, true +} +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Content-Security-Policy", s.csp) + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + if s.secure { + w.Header().Set("Strict-Transport-Security", "max-age=31536000") + } + w.Header().Set("Cache-Control", "no-store") + if r.Method != "GET" && r.Method != "HEAD" && r.Method != "POST" { + fail(w, 405, "Method not allowed") + return + } + if r.Method == "POST" { + if r.Header.Get("Origin") != s.Origin || r.Header.Get("Sec-Fetch-Site") == "cross-site" { + fail(w, 403, "Invalid request origin") + return + } + if r.Header.Get("Content-Type") != "application/json" { + fail(w, 415, "Expected application/json") + return + } + r.Body = http.MaxBytesReader(w, r.Body, 4096) + } + path := r.URL.Path + if path == "/api/config" && r.Method == "GET" { + respond(w, map[string]string{"authMode": s.Auth.Mode()}) + return + } + if path == "/api/login" && r.Method == "POST" { + s.login(w, r) + return + } + if path == "/api/logout" && r.Method == "POST" { + id := cookieID(r) + v, ok := s.Sessions.Get(id) + s.Sessions.Delete(id) + s.cookie(w, "", true) + if ok { + if e := s.Auth.Logout(r.Context(), v); e != nil && !errors.Is(e, source.ErrDenied) { + fail(w, 503, "Signed out locally. Jellyfin token revocation failed; revoke the Reel Review device in Jellyfin.") + return + } + } + respond(w, map[string]bool{"ok": true}) + return + } + if strings.HasPrefix(path, "/api/") { + if r.Method != "GET" { + fail(w, 405, "Method not allowed") + return + } + if path != "/api/session" && path != "/api/review" { + fail(w, 404, "Not found") + return + } + v, ok := s.session(w, r) + if !ok { + return + } + m, dir, e := generate.Manifest(s.Data) + if e != nil { + fail(w, 503, "No valid review dataset is available. Ask the operator to generate one.") + return + } + years := m.Users[v.Account.ID] + if years == nil { + years = []int{} + } + if path == "/api/session" { + respond(w, map[string]any{"user": model.User{ID: v.Account.ID, Name: v.Account.Name}, "years": years}) + return + } + q := r.URL.Query() + year, e := strconv.Atoi(q.Get("year")) + if e != nil || year < 1970 || year > 9998 || len(q) != 1 || len(q["year"]) != 1 { + fail(w, 400, "Invalid year") + return + } + found := false + for _, y := range years { + if y == year { + found = true + } + } + if !found { + fail(w, 404, "No review was generated for this year.") + return + } + b, e := os.ReadFile(filepath.Join(dir, v.Account.ID, strconv.Itoa(year)+".json")) + if e != nil { + fail(w, 503, "Review unavailable") + return + } + var review model.Review + if json.Unmarshal(b, &review) != nil || review.Validate(v.Account.ID, year) != nil { + fail(w, 503, "Unsupported or invalid review format") + return + } + respond(w, review) + return + } + if strings.HasPrefix(path, "/art/") && r.Method == "GET" { + name := strings.TrimPrefix(path, "/art/") + if !generate.SafeAsset(name) { + http.NotFound(w, r) + return + } + dir, e := generate.Current(s.Data) + if e != nil { + http.NotFound(w, r) + return + } + b, e := os.ReadFile(filepath.Join(dir, "art", name)) + if e != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "image/jpeg") + w.Header().Set("Cache-Control", "public, max-age=86400") + _, _ = w.Write(b) + return + } + if r.Method != "GET" && r.Method != "HEAD" { + http.NotFound(w, r) + return + } + if strings.Contains(path, "..") || strings.Contains(path, "\\") || strings.HasPrefix(path, "/.") || strings.HasPrefix(path, "/data") { + http.NotFound(w, r) + return + } + name := strings.TrimPrefix(path, "/") + if strings.HasPrefix(path, "/_app/") || path == "/favicon.svg" { + f, e := s.webRoot.Open(name) + if e != nil { + http.NotFound(w, r) + return + } + defer f.Close() + st, e := f.Stat() + if e != nil || !st.Mode().IsRegular() { + http.NotFound(w, r) + return + } + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + http.ServeContent(w, r, name, st.ModTime(), f) + return + } + if path != "/" && path != "/review" && !regexp.MustCompile(`^/review/[0-9]{4}/?$`).MatchString(path) { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if r.Method != "HEAD" { + _, _ = w.Write(s.shell) + } +} +func (s *Server) login(w http.ResponseWriter, r *http.Request) { + ip, _, _ := net.SplitHostPort(r.RemoteAddr) + if !s.Limiter.Allow(ip) { + w.Header().Set("Retry-After", "60") + fail(w, 429, "Too many sign-in attempts. Try again in a minute.") + return + } + var body struct { + Username string `json:"username"` + Password string `json:"password"` + } + d := json.NewDecoder(r.Body) + d.DisallowUnknownFields() + if d.Decode(&body) != nil || d.Decode(new(any)) != io.EOF || body.Username == "" || len(body.Username) > 256 { + fail(w, 400, "Invalid login request") + return + } + device := auth.Random() + a, token, e := s.Auth.Login(r.Context(), body.Username, body.Password, device) + body.Password = "" + if e != nil { + if errors.Is(e, source.ErrUnavailable) { + s.authUnavailable(w) + } else { + fail(w, 401, "Unable to sign in with those credentials.") + } + return + } + id, ok := s.Sessions.Create(a, token, device) + if !ok { + _ = s.Auth.Logout(r.Context(), auth.Session{Account: a, Token: token, Device: device}) + fail(w, 503, "Session capacity reached. Try again later.") + return + } + if old := cookieID(r); old != "" { + if previous, exists := s.Sessions.Get(old); exists { + _ = s.Auth.Logout(r.Context(), previous) + } + s.Sessions.Delete(old) + } + s.cookie(w, id, false) + respond(w, map[string]bool{"ok": true}) +} + +// HTTPServer applies bounds even when the app is behind a reverse proxy. +func HTTPServer(addr string, h http.Handler) *http.Server { + return &http.Server{Addr: addr, Handler: h, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16 << 10} +}
A jellyfin-year-review/internal/server/server_test.go

@@ -0,0 +1,379 @@

+package server + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "reelreview/internal/auth" + "reelreview/internal/generate" + "reelreview/internal/model" + "reelreview/internal/source" +) + +const aID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +const bID = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +type harness struct { + s *Server + revoked, unavailable bool + logout int + authCalls int +} + +func setup(t *testing.T) *harness { + t.Helper() + h := &harness{} + jf := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.authCalls++ + if h.unavailable { + w.WriteHeader(503) + return + } + switch r.URL.Path { + case "/Users/AuthenticateByName": + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + if body["Pw"] != "valid" { + w.WriteHeader(401) + return + } + if !strings.Contains(r.Header.Get("Authorization"), `Client="Reel Review"`) { + t.Error("missing separate app identity") + } + json.NewEncoder(w).Encode(map[string]any{"AccessToken": "private-token", "User": map[string]any{"Id": bID}}) + case "/Users/Me": + if h.revoked || !strings.Contains(r.Header.Get("Authorization"), `Token="private-token"`) { + w.WriteHeader(401) + return + } + json.NewEncoder(w).Encode(map[string]any{"Id": aID, "Name": "Alice", "Policy": map[string]bool{"IsAdministrator": true}}) + case "/Sessions/Logout": + h.logout++ + w.WriteHeader(204) + default: + w.WriteHeader(404) + } + })) + t.Cleanup(jf.Close) + j, e := source.NewJellyfin(jf.URL) + if e != nil { + t.Fatal(e) + } + data := t.TempDir() + for _, year := range []int{2024, 2025} { + rs := []model.Review{} + for _, id := range []string{aID, bID} { + rs = append(rs, model.Review{Version: 1, User: model.User{ID: id, Name: id}, Year: year, Timezone: "UTC", Availability: map[string]model.Availability{}, Seconds: 3600}) + } + if e := generate.Publish(data, rs, nil); e != nil { + t.Fatal(e) + } + } + web := t.TempDir() + os.WriteFile(filepath.Join(web, "index.html"), []byte(`<html><script>console.log('shell')</script></html>`), 0600) + h.s, e = New(data, web, "https://review.test", j) + if e != nil { + t.Fatal(e) + } + t.Cleanup(func() { h.s.Close() }) + return h +} +func req(h *harness, method, path, body string, cookie *http.Cookie, origin string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.RemoteAddr = "127.0.0.1:1234" + if method == "POST" { + r.Header.Set("Content-Type", "application/json") + r.Header.Set("Origin", origin) + } + if cookie != nil { + r.AddCookie(cookie) + } + w := httptest.NewRecorder() + h.s.ServeHTTP(w, r) + return w +} +func signin(t *testing.T, h *harness) *http.Cookie { + t.Helper() + w := req(h, "POST", "/api/login", `{"username":"Alice","password":"valid"}`, nil, h.s.Origin) + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + cookies := w.Result().Cookies() + if len(cookies) != 1 || !cookies[0].HttpOnly || !cookies[0].Secure || cookies[0].SameSite != http.SameSiteStrictMode { + t.Fatal("insecure cookie") + } + if strings.Contains(w.Body.String(), "token") { + t.Fatal("token leaked") + } + return cookies[0] +} +func TestOwnerAuthorizationAndPrivatePaths(t *testing.T) { + h := setup(t) + w := req(h, "GET", "/api/review?year=2025", "", nil, "") + if w.Code != 401 { + t.Fatal("unauthenticated review") + } + cookie := signin(t, h) + w = req(h, "GET", "/api/review?year=2025", "", cookie, "") + if w.Code != 200 || !strings.Contains(w.Body.String(), aID) || strings.Contains(w.Body.String(), bID) { + t.Fatal("actual token identity not used", w.Body.String()) + } + for _, path := range []string{"/manifest.json", "/data/CURRENT", "/" + bID + "/2025.json", "/api/users", "/art/../../CURRENT", "/art/%2e%2e%2fCURRENT", "/_app/../../CURRENT"} { + w = req(h, "GET", path, "", cookie, "") + if w.Code != 404 { + t.Fatalf("private path %s = %d", path, w.Code) + } + } + for _, path := range []string{"/api/review?year=2025&userId=" + bID, "/api/review?year=../../2025", "/api/review?year=2025&year=2024"} { + if w = req(h, "GET", path, "", cookie, ""); w.Code != 400 { + t.Fatal("unsafe selector", path, w.Code) + } + } + w = req(h, "GET", "/api/session", "", cookie, "") + if w.Code != 200 || strings.Contains(w.Body.String(), bID) || !strings.Contains(w.Body.String(), "2024") { + t.Fatal("session manifest leak or missing year") + } + w = req(h, "GET", "/review/2025", "", nil, "") + if w.Code != 200 || !strings.Contains(w.Header().Get("Content-Security-Policy"), "sha256-") { + t.Fatal("deep link or CSP bootstrap broken") + } +} +func TestExpiryTamperingRevocationAndOutage(t *testing.T) { + h := setup(t) + cookie := signin(t, h) + tampered := *cookie + tampered.Value = "f" + tampered.Value[1:] + "a" + if req(h, "GET", "/api/session", "", &tampered, "").Code != 401 { + t.Fatal("tampered session") + } + h.unavailable = true + if req(h, "GET", "/api/review?year=2025", "", cookie, "").Code != 503 { + t.Fatal("stale data on auth outage") + } + h.unavailable = false + h.revoked = true + if req(h, "GET", "/api/session", "", cookie, "").Code != 401 { + t.Fatal("revoked token") + } + h.revoked = false + cookie = signin(t, h) + h.s.Sessions.Now = func() time.Time { return time.Now().Add(13 * time.Hour) } + if req(h, "GET", "/api/session", "", cookie, "").Code != 401 { + t.Fatal("expired app session") + } +} +func TestLoginCSRFRateLimitsAndLogout(t *testing.T) { + h := setup(t) + if req(h, "POST", "/api/login", `{"username":"A","password":"valid"}`, nil, "https://evil.test").Code != 403 { + t.Fatal("CSRF") + } + for i := 0; i < 10; i++ { + w := req(h, "POST", "/api/login", `{"username":"A","password":"bad"}`, nil, h.s.Origin) + if w.Code != 401 || strings.Contains(w.Body.String(), "Alice") { + t.Fatal("non-generic failure") + } + } + if req(h, "POST", "/api/login", `{"username":"A","password":"valid"}`, nil, h.s.Origin).Code != 429 { + t.Fatal("no login limit") + } + h = setup(t) + cookie := signin(t, h) + if req(h, "POST", "/api/logout", "{}", cookie, "https://evil.test").Code != 403 { + t.Fatal("logout CSRF") + } + if req(h, "POST", "/api/logout", "{}", cookie, h.s.Origin).Code != 200 || h.logout != 1 { + t.Fatal("logout did not revoke only app token") + } + if req(h, "GET", "/api/session", "", cookie, "").Code != 401 { + t.Fatal("local session survived logout") + } + cookie = signin(t, h) + h.unavailable = true + w := req(h, "POST", "/api/logout", "{}", cookie, h.s.Origin) + if w.Code != 503 || !strings.Contains(w.Body.String(), "Signed out locally") { + t.Fatal("outage logout behavior") + } + h.unavailable = false + if req(h, "GET", "/api/session", "", cookie, "").Code != 401 { + t.Fatal("outage retained session") + } +} +func TestRequestBoundsAndUnsupportedDocuments(t *testing.T) { + h := setup(t) + if req(h, "POST", "/api/login", `{"username":"`+strings.Repeat("a", 5000)+`","password":"valid"}`, nil, h.s.Origin).Code != 400 { + t.Fatal("body limit") + } + cookie := signin(t, h) + _, dir, e := generate.Manifest(h.s.Data) + if e != nil { + t.Fatal(e) + } + path := filepath.Join(dir, aID, "2025.json") + f, e := os.Create(path) + if e != nil { + t.Fatal(e) + } + io.WriteString(f, `{"schemaVersion":99}`) + f.Close() + if req(h, "GET", "/api/review?year=2025", "", cookie, "").Code != 503 { + t.Fatal("unsupported schema accepted") + } +} + +func offlineHarness(t *testing.T) (*harness, string, auth.CredentialFile) { + t.Helper() + h := setup(t) + b, e := os.ReadFile("../../testdata/offline-auth.json") + if e != nil { + t.Fatal(e) + } + var file auth.CredentialFile + if e = json.Unmarshal(b, &file); e != nil { + t.Fatal(e) + } + path := filepath.Join(t.TempDir(), "users.json") + if e = os.WriteFile(path, b, 0600); e != nil { + t.Fatal(e) + } + p, e := auth.NewOffline(path) + if e != nil { + t.Fatal(e) + } + // Constructor validation must work without constructing any Jellyfin client. + s, e := NewWithAuth(h.s.Data, h.s.Web, h.s.Origin, p) + if e != nil { + t.Fatal(e) + } + s.Close() + h.s.Auth = p + h.unavailable = true + return h, path, file +} +func offlineSignin(t *testing.T, h *harness, name, password string) *http.Cookie { + t.Helper() + b, _ := json.Marshal(map[string]string{"username": name, "password": password}) + w := req(h, "POST", "/api/login", string(b), nil, h.s.Origin) + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + return w.Result().Cookies()[0] +} +func replaceAuth(t *testing.T, path string, file auth.CredentialFile) { + t.Helper() + b, _ := json.Marshal(file) + if e := os.WriteFile(path+".new", b, 0600); e != nil { + t.Fatal(e) + } + if e := os.Rename(path+".new", path); e != nil { + t.Fatal(e) + } +} +func TestOfflineLoginReviewAndLogoutWithNoJellyfin(t *testing.T) { + h, path, _ := offlineHarness(t) + w := req(h, "GET", "/api/config", "", nil, "") + if w.Code != 200 || w.Body.String() != "{\"authMode\":\"offline\"}\n" { + t.Fatal("config leaks more than auth mode") + } + cookie := offlineSignin(t, h, "ALICE", "fixture-only") + w = req(h, "GET", "/api/review?year=2025", "", cookie, "") + if w.Code != 200 || !strings.Contains(w.Body.String(), aID) || strings.Contains(w.Body.String(), bID) || strings.Contains(w.Body.String(), "PBKDF2") { + t.Fatal("offline owner boundary or verifier exposure") + } + if req(h, "GET", "/api/review?year=2025&userId="+bID, "", cookie, "").Code != 400 { + t.Fatal("offline impersonation") + } + for _, route := range []string{"/auth/users.json", "/users.json", "/auth/users.json.previous", "/api/auth-export"} { + if req(h, "GET", route, "", cookie, "").Code != 404 { + t.Fatal("private credentials exposed") + } + } + if req(h, "POST", "/api/logout", "{}", cookie, h.s.Origin).Code != 200 { + t.Fatal("offline logout needs network") + } + if req(h, "GET", "/api/session", "", cookie, "").Code != 401 { + t.Fatal("offline session survived logout") + } + cookie = offlineSignin(t, h, "Alice", "fixture-only") + h.s.Sessions.Now = func() time.Time { return time.Now().Add(13 * time.Hour) } + if req(h, "GET", "/api/session", "", cookie, "").Code != 401 { + t.Fatal("offline session expiry") + } + if h.authCalls != 0 { + t.Fatal("offline serving contacted Jellyfin") + } + // Never accept credential files in a directory that serving can publish. + for _, dir := range []string{h.s.Web, h.s.Data} { + b, e := os.ReadFile(path) + if e != nil { + t.Fatal(e) + } + private := filepath.Join(dir, "users.json") + if e = os.WriteFile(private, b, 0600); e != nil { + t.Fatal(e) + } + p, e := auth.NewOffline(private) + if e != nil { + t.Fatal(e) + } + if s, e := NewWithAuth(h.s.Data, h.s.Web, h.s.Origin, p); e == nil { + s.Close() + t.Fatal("credentials accepted in web/data directory") + } + } +} +func TestOfflineCredentialRefreshAndCorruption(t *testing.T) { + h, path, file := offlineHarness(t) + cookie := offlineSignin(t, h, "Alice", "fixture-only") + if e := os.WriteFile(path, []byte(`{"schemaVersion":99}`), 0600); e != nil { + t.Fatal(e) + } + w := req(h, "GET", "/api/review?year=2025", "", cookie, "") + if w.Code != 503 || strings.Contains(w.Body.String(), aID) { + t.Fatal("invalid export fell back to old credentials") + } + replaceAuth(t, path, file) + if req(h, "GET", "/api/session", "", cookie, "").Code != 200 { + t.Fatal("valid restored export unavailable") + } + file.Users[0].PasswordHash = file.Users[1].PasswordHash + replaceAuth(t, path, file) + if req(h, "GET", "/api/session", "", cookie, "").Code != 401 { + t.Fatal("password update retained offline session") + } + w = req(h, "POST", "/api/login", `{"username":"Alice","password":"fixture-only"}`, nil, h.s.Origin) + if w.Code != 401 { + t.Fatal("old offline password survived refresh") + } + cookie = offlineSignin(t, h, "Alice", "pässwörd 🎬") + file.Users[0].Disabled = true + file.Users[0].PasswordHash = "" + replaceAuth(t, path, file) + if req(h, "GET", "/api/review?year=2025", "", cookie, "").Code != 401 { + t.Fatal("disabled account retained access") + } + if h.authCalls != 0 { + t.Fatal("offline refresh contacted Jellyfin") + } +} +func TestOfflineLoginCSRFAndRateLimit(t *testing.T) { + h, _, _ := offlineHarness(t) + if req(h, "POST", "/api/login", `{"username":"Alice","password":"fixture-only"}`, nil, "https://evil.test").Code != 403 { + t.Fatal("offline CSRF") + } + for i := 0; i < 10; i++ { + w := req(h, "POST", "/api/login", `{"username":"Alice","password":"wrong"}`, nil, h.s.Origin) + if w.Code != 401 || strings.Contains(w.Body.String(), "PBKDF2") { + t.Fatal("offline generic failure") + } + } + if req(h, "POST", "/api/login", `{"username":"Alice","password":"fixture-only"}`, nil, h.s.Origin).Code != 429 { + t.Fatal("offline rate limit") + } +}
A jellyfin-year-review/internal/source/jellyfin.go

@@ -0,0 +1,156 @@

+// Package source reads immutable history exports and Jellyfin API metadata. +package source + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "reelreview/internal/model" +) + +var ErrDenied = errors.New("authentication denied") +var ErrUnavailable = errors.New("Jellyfin unavailable") + +type Jellyfin struct { + URL string + HTTP *http.Client +} +type Account struct { + ID string `json:"Id"` + Name string `json:"Name"` + Policy struct { + Disabled bool `json:"IsDisabled"` + } `json:"Policy"` +} + +func NewJellyfin(raw string) (*Jellyfin, error) { + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return nil, fmt.Errorf("invalid JELLYFIN_URL") + } + return &Jellyfin{URL: strings.TrimRight(raw, "/"), HTTP: &http.Client{Timeout: 12 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil +} + +// Call never returns server bodies or transport URLs, which may contain sensitive data. +func (j *Jellyfin) Call(ctx context.Context, method, path, token, device string, body, out any) error { + var b bytes.Buffer + if body != nil { + if err := json.NewEncoder(&b).Encode(body); err != nil { + return err + } + } + req, err := http.NewRequestWithContext(ctx, method, j.URL+path, &b) + if err != nil { + return ErrUnavailable + } + req.Header.Set("Content-Type", "application/json") + authorization := fmt.Sprintf(`MediaBrowser Client="Reel Review", Device="Review browser", DeviceId="%s", Version="1.0.0"`, url.QueryEscape(device)) + if token != "" { + authorization += fmt.Sprintf(`, Token="%s"`, url.QueryEscape(token)) + } + req.Header.Set("Authorization", authorization) + res, err := j.HTTP.Do(req) + if err != nil { + return ErrUnavailable + } + defer res.Body.Close() + if res.StatusCode == 401 || res.StatusCode == 403 { + return ErrDenied + } + if res.StatusCode < 200 || res.StatusCode >= 300 { + return ErrUnavailable + } + if out != nil { + if err := json.NewDecoder(io.LimitReader(res.Body, 32<<20)).Decode(out); err != nil { + return ErrUnavailable + } + } + return nil +} +func (j *Jellyfin) Me(ctx context.Context, token, device string) (Account, error) { + var a Account + err := j.Call(ctx, "GET", "/Users/Me", token, device, nil, &a) + if err != nil { + return a, err + } + id, err := model.ID(a.ID) + if err != nil || a.Policy.Disabled { + return a, ErrDenied + } + a.ID = id + return a, nil +} +func (j *Jellyfin) Login(ctx context.Context, name, password, device string) (Account, string, error) { + var r struct { + AccessToken string + User Account + } + err := j.Call(ctx, "POST", "/Users/AuthenticateByName", "", device, map[string]string{"Username": name, "Pw": password}, &r) + if err != nil { + return Account{}, "", err + } + if r.AccessToken == "" { + return Account{}, "", ErrDenied + } + a, err := j.Me(ctx, r.AccessToken, device) + if err != nil { + _ = j.Call(ctx, "POST", "/Sessions/Logout", r.AccessToken, device, nil, nil) + return Account{}, "", err + } + return a, r.AccessToken, nil +} + +// Metadata takes a bounded inventory once; API responses become the generation snapshot. +func (j *Jellyfin) Metadata(ctx context.Context, key string) ([]model.User, []model.Item, error) { + var users []Account + if err := j.Call(ctx, "GET", "/Users", key, "generator", nil, &users); err != nil { + return nil, nil, err + } + result := []model.User{} + for _, u := range users { + id, err := model.ID(u.ID) + if err != nil { + return nil, nil, err + } + if !u.Policy.Disabled { + result = append(result, model.User{ID: id, Name: u.Name}) + } + } + items := []model.Item{} + for start := 0; start < 1000000; start += 500 { + var page struct { + Items []struct { + ID string `json:"Id"` + Name, Type, SeriesID, SeriesName string + ProductionYear int + RunTimeTicks int64 + Genres []string + ImageTags map[string]string + ProviderIDs map[string]string + } + TotalRecordCount int + } + path := fmt.Sprintf("/Items?Recursive=true&IncludeItemTypes=Movie,Episode,Series&Fields=Genres,ProviderIds&EnableImages=true&StartIndex=%d&Limit=500", start) + if err := j.Call(ctx, "GET", path, key, "generator", nil, &page); err != nil { + return nil, nil, err + } + for _, i := range page.Items { + items = append(items, model.Item{ID: i.ID, Title: i.Name, Type: i.Type, SeriesID: i.SeriesID, SeriesTitle: i.SeriesName, Year: i.ProductionYear, Runtime: float64(i.RunTimeTicks) / 1e7, Genres: i.Genres, ImageTag: i.ImageTags["Primary"], ProviderIDs: i.ProviderIDs}) + } + if start+len(page.Items) >= page.TotalRecordCount { + return result, items, nil + } + if len(page.Items) == 0 { + return nil, nil, fmt.Errorf("incomplete Jellyfin inventory") + } + } + return nil, nil, fmt.Errorf("Jellyfin inventory exceeds limit") +}
A jellyfin-year-review/internal/source/jellyfin_test.go

@@ -0,0 +1,37 @@

+package source + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestMetadataIncludesPrimaryImageTags(t *testing.T) { + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.Header.Get("Authorization"), `Token="operator-key"`) { + t.Error("missing operator credential") + } + switch r.URL.Path { + case "/jellyfin/Users": + w.Write([]byte(`[{"Id":"AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA","Name":"Viewer"}]`)) + case "/jellyfin/Items": + if r.URL.Query().Get("EnableImages") != "true" || !strings.Contains(r.URL.Query().Get("Fields"), "ProviderIds") { + t.Error("image metadata not requested") + } + w.Write([]byte(`{"Items":[{"Id":"11111111111111111111111111111111","Name":"Film","Type":"Movie","RunTimeTicks":72000000000,"Genres":["Drama"],"ImageTags":{"Primary":"image-version"},"ProviderIds":{"Tvdb":"123","Imdb":"tt123"}}],"TotalRecordCount":1}`)) + default: + w.WriteHeader(404) + } + })) + defer api.Close() + j, e := NewJellyfin(api.URL + "/jellyfin") + if e != nil { + t.Fatal(e) + } + users, items, e := j.Metadata(context.Background(), "operator-key") + if e != nil || len(users) != 1 || len(items) != 1 || items[0].ProviderIDs["Tvdb"] != "123" || items[0].ProviderIDs["Imdb"] != "tt123" || items[0].ImageTag != "image-version" || items[0].Runtime != 7200 || items[0].Genres[0] != "Drama" { + t.Fatal("incomplete Jellyfin metadata", e) + } +}
A jellyfin-year-review/internal/source/snapshot.go

@@ -0,0 +1,159 @@

+package source + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "math" + "os" + "sort" + "strconv" + "strings" + "time" + + "reelreview/internal/model" +) + +func ItemKey(s string) string { return strings.ToLower(strings.ReplaceAll(s, "-", "")) } + +// Load validates a finalized canonical snapshot; inconsistent duplicates fail closed. +func Load(path string) (model.Snapshot, error) { + var s model.Snapshot + f, err := os.Open(path) + if err != nil { + return s, fmt.Errorf("cannot open source snapshot") + } + defer f.Close() + if st, e := f.Stat(); e != nil || st.Size() > 256<<20 { + return s, fmt.Errorf("source snapshot exceeds size limit or cannot be read") + } + d := json.NewDecoder(io.LimitReader(f, 256<<20)) + d.DisallowUnknownFields() + if err := d.Decode(&s); err != nil { + return s, fmt.Errorf("invalid source snapshot: %w", err) + } + if d.Decode(new(any)) != io.EOF { + return s, fmt.Errorf("trailing or oversized source data") + } + return s, Validate(&s) +} +func Validate(s *model.Snapshot) error { + if s.Version != 1 || s.CapturedAt.IsZero() || s.Source == "" || s.Coverage == "" { + return fmt.Errorf("snapshot requires v1, capturedAt, source and explicit coverage") + } + users := map[string]bool{} + for i := range s.Users { + u := &s.Users[i] + id, err := model.ID(u.ID) + if err != nil || users[id] { + return fmt.Errorf("invalid or duplicate source user") + } + u.ID = id + users[id] = true + } + items := map[string]bool{} + for i := range s.Items { + v := &s.Items[i] + v.ID = ItemKey(v.ID) + v.SeriesID = ItemKey(v.SeriesID) + if v.ID == "" || items[v.ID] || v.Runtime < 0 || math.IsNaN(v.Runtime) || math.IsInf(v.Runtime, 0) { + return fmt.Errorf("invalid or duplicate media") + } + items[v.ID] = true + } + seen := map[string]string{} + out := []model.Playback{} + for _, p := range s.Playback { + var err error + p.UserID, err = model.ID(p.UserID) + if err != nil { + return fmt.Errorf("invalid playback user") + } + p.ItemID = ItemKey(p.ItemID) + if p.ID == "" || p.ItemID == "" || math.IsNaN(p.Seconds) || math.IsInf(p.Seconds, 0) || p.Seconds <= 0 || p.Seconds > 366*86400 { + return fmt.Errorf("invalid playback duration or identity") + } + if p.Exact { + if !p.End.After(p.Start) || p.Start.IsZero() || math.Abs(p.End.Sub(p.Start).Seconds()-p.Seconds) > 0.01 { + return fmt.Errorf("exact intervals must match watched seconds") + } + } else if p.ObservedAt.IsZero() { + return fmt.Errorf("aggregate activity requires observedAt") + } + if (p.PositionStart == nil) != (p.PositionEnd == nil) { + return fmt.Errorf("both position bounds required") + } + if p.PositionStart != nil && (math.IsNaN(*p.PositionStart) || math.IsInf(*p.PositionStart, 0) || *p.PositionStart < 0 || *p.PositionEnd < *p.PositionStart || math.IsNaN(*p.PositionEnd) || math.IsInf(*p.PositionEnd, 0)) { + return fmt.Errorf("invalid position range") + } + b, _ := json.Marshal(p) + key := p.UserID + ":" + p.ID + if prev, ok := seen[key]; ok { + if prev != string(b) { + return fmt.Errorf("conflicting duplicate playback ID") + } + continue + } + seen[key] = string(b) + out = append(out, p) + } + s.Playback = out + sort.Slice(s.Users, func(i, j int) bool { return s.Users[i].ID < s.Users[j].ID }) + sort.Slice(s.Items, func(i, j int) bool { return s.Items[i].ID < s.Items[j].ID }) + sort.Slice(s.Playback, func(i, j int) bool { + a, b := s.Playback[i], s.Playback[j] + if a.UserID != b.UserID { + return a.UserID < b.UserID + } + return a.ID < b.ID + }) + return nil +} + +// Jellystat reads the raw table JSONL contract, deliberately rejecting grouped API +// history: grouped durations and resumed records cannot recover exact watch intervals. +func Jellystat(path string, users []model.User, items []model.Item, captured time.Time, coverage string) (model.Snapshot, error) { + s := model.Snapshot{Version: 1, CapturedAt: captured, Source: "Jellystat raw finalized activity", Coverage: coverage, Users: users, Items: items} + f, err := os.Open(path) + if err != nil { + return s, fmt.Errorf("cannot open Jellystat export") + } + defer f.Close() + if st, e := f.Stat(); e != nil || st.Size() > 256<<20 { + return s, fmt.Errorf("Jellystat snapshot exceeds size limit or cannot be read") + } + scan := bufio.NewScanner(io.LimitReader(f, 256<<20)) + scan.Buffer(make([]byte, 4096), 1<<20) + for scan.Scan() { + var row struct { + ID string `json:"Id"` + UserID string `json:"UserId"` + Item string `json:"NowPlayingItemId"` + Episode string `json:"EpisodeId"` + Duration json.Number `json:"PlaybackDuration"` + At time.Time `json:"ActivityDateInserted"` + Device string `json:"DeviceName"` + Client string `json:"Client"` + Imported bool `json:"Imported"` + } + if err := json.Unmarshal(scan.Bytes(), &row); err != nil { + return s, fmt.Errorf("invalid Jellystat raw row") + } + if row.Imported { + return s, fmt.Errorf("Playback Reporting imports are not supported; export native Jellystat activity only") + } + n, err := strconv.ParseFloat(string(row.Duration), 64) + if err != nil { + return s, fmt.Errorf("invalid Jellystat PlaybackDuration") + } + if row.Episode != "" { + row.Item = row.Episode + } + s.Playback = append(s.Playback, model.Playback{ID: row.ID, UserID: row.UserID, ItemID: row.Item, ObservedAt: row.At, Seconds: n, Device: row.Device, Client: row.Client}) + } + if scan.Err() != nil { + return s, fmt.Errorf("Jellystat export exceeds row limit or cannot be read") + } + return s, Validate(&s) +}
A jellyfin-year-review/internal/source/snapshot_test.go

@@ -0,0 +1,53 @@

+package source + +import ( + "os" + "path/filepath" + "testing" + "time" + + "reelreview/internal/model" +) + +func TestJellystatRawRows(t *testing.T) { + path := filepath.Join(t.TempDir(), "rows.jsonl") + row := `{"Id":"record1","UserId":"AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA","NowPlayingItemId":"series","EpisodeId":"episode","PlaybackDuration":"1800","ActivityDateInserted":"2025-05-01T19:00:00Z","DeviceName":"TV","Client":"Jellyfin"}` + if e := os.WriteFile(path, []byte(row+"\n"+row+"\n"), 0600); e != nil { + t.Fatal(e) + } + s, e := Jellystat(path, []model.User{{ID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}, nil, time.Now(), "test coverage") + if e != nil { + t.Fatal(e) + } + if len(s.Playback) != 1 || s.Playback[0].ItemID != "episode" || s.Playback[0].Exact || s.Playback[0].Seconds != 1800 { + t.Fatalf("incorrect raw source semantics %+v", s) + } + if e := os.WriteFile(path, []byte(`{"results":[{}],"TotalDuration":1800}`), 0600); e != nil { + t.Fatal(e) + } + if _, e := Jellystat(path, nil, nil, time.Now(), "test"); e == nil { + t.Fatal("grouped API result accepted") + } +} +func TestInvalidSource(t *testing.T) { + s := model.Snapshot{Version: 1, CapturedAt: time.Now(), Source: "test", Coverage: "test", Users: []model.User{{ID: "../escape"}}} + if Validate(&s) == nil { + t.Fatal("invalid user ID") + } + s.Users = nil + s.Playback = []model.Playback{{ID: "x", UserID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ItemID: "x", Seconds: -1}} + if Validate(&s) == nil { + t.Fatal("negative seconds") + } +} + +func TestPlaybackReportingImportsRejected(t *testing.T) { + path := filepath.Join(t.TempDir(), "rows.jsonl") + row := `{"Id":"record1","UserId":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","NowPlayingItemId":"film","PlaybackDuration":1800,"ActivityDateInserted":"2025-05-01T19:00:00Z","imported":true}` + if e := os.WriteFile(path, []byte(row), 0600); e != nil { + t.Fatal(e) + } + if _, err := Jellystat(path, nil, nil, time.Now(), "test"); err == nil { + t.Fatal("prohibited plugin import accepted") + } +}
A jellyfin-year-review/scripts/e2e-server.go

@@ -0,0 +1,149 @@

+// This controlled test service binds loopback only and uses disposable synthetic data. +// Production has no demo login or test-service mode. +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "image" + "image/jpeg" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reelreview/internal/artwork" + "reelreview/internal/auth" + "strings" + + "reelreview/internal/generate" + "reelreview/internal/server" + "reelreview/internal/source" +) + +func main() { + root, e := os.MkdirTemp("", "reel-e2e-") + if e != nil { + panic(e) + } + defer os.RemoveAll(root) + data := filepath.Join(root, "data") + snapshot, e := source.Load("testdata/snapshot.json") + if e != nil { + panic(e) + } + // Give the synthetic snapshot real UUID-shaped media IDs and fetch posters + // from a controlled generation-only source that closes before serving starts. + ids := map[string]string{} + for i, item := range snapshot.Items { + ids[item.ID] = fmt.Sprintf("%032x", i+1) + } + for i := range snapshot.Items { + item := &snapshot.Items[i] + item.ID = ids[item.ID] + item.SeriesID = ids[item.SeriesID] + item.ImageTag = "fixture-image-v1" + } + for i := range snapshot.Playback { + snapshot.Playback[i].ItemID = ids[snapshot.Playback[i].ItemID] + } + var jpegBytes bytes.Buffer + if e = jpeg.Encode(&jpegBytes, image.NewRGBA(image.Rect(0, 0, 2, 3)), nil); e != nil { + panic(e) + } + images := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.Header.Get("Authorization"), `Token="fixture-art-key"`) { + w.WriteHeader(401) + return + } + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write(jpegBytes.Bytes()) + })) + imageSource, e := source.NewJellyfin(images.URL) + if e != nil { + panic(e) + } + posters := artwork.New(imageSource, "fixture-art-key", filepath.Join(root, "cache")) + + for _, year := range []int{2024, 2025} { + c := generate.DefaultConfig(year) + rs, e := generate.Compute(snapshot, c) + if e != nil { + panic(e) + } + assets, warnings := posters.Resolve(context.Background(), snapshot.Items, rs) + if len(warnings) > 0 { + panic("synthetic posters unavailable") + } + if e = generate.Publish(data, rs, assets); e != nil { + panic(e) + } + } + images.Close() + if os.Getenv("REEL_E2E_AUTH_MODE") == "offline" { + b, e := os.ReadFile("testdata/offline-auth.json") + if e != nil { + panic(e) + } + path := filepath.Join(root, "users.json") + if e = os.WriteFile(path, b, 0600); e != nil { + panic(e) + } + p, e := auth.NewOffline(path) + if e != nil { + panic(e) + } + s, e := server.NewWithAuth(data, "web/build", "http://127.0.0.1:18180", p) + if e != nil { + panic(e) + } + defer s.Close() + fmt.Println("SYNTHETIC OFFLINE TEST SERVICE: no Jellyfin client or server") + if e = server.HTTPServer("127.0.0.1:18180", s).ListenAndServe(); e != nil { + panic(e) + } + return + } + + revoked := false + jf := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/Users/AuthenticateByName": + var b map[string]string + json.NewDecoder(r.Body).Decode(&b) + if b["Username"] != "fixture" || b["Pw"] != "fixture-only" { + w.WriteHeader(401) + return + } + revoked = false + json.NewEncoder(w).Encode(map[string]string{"AccessToken": "test-service-token"}) + case "/Users/Me": + if revoked || !strings.Contains(r.Header.Get("Authorization"), `Token="test-service-token"`) { + w.WriteHeader(401) + return + } + json.NewEncoder(w).Encode(map[string]any{"Id": snapshot.Users[0].ID, "Name": "Fixture Viewer"}) + case "/Sessions/Logout": + revoked = true + w.WriteHeader(204) + default: + w.WriteHeader(404) + } + })) + defer jf.Close() + j, e := source.NewJellyfin(jf.URL) + if e != nil { + panic(e) + } + s, e := server.New(data, "web/build", "http://127.0.0.1:18180", j) + if e != nil { + panic(e) + } + defer s.Close() + fmt.Println("SYNTHETIC TEST SERVICE on loopback :18180") + h := server.HTTPServer("127.0.0.1:18180", s) + if e = h.ListenAndServe(); e != nil { + panic(e) + } +}
A jellyfin-year-review/scripts/export-jellyfin-auth.py

@@ -0,0 +1,186 @@

+#!/usr/bin/env python3 +"""Export local Jellyfin 12.2 verifiers without passwords or database writes.""" + +import argparse +import contextlib +import datetime +import json +import os +from pathlib import Path +import re +import sqlite3 +import stat +import sys +import tempfile +import time +import uuid + +PROVIDER = "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider" +MAX_USERS = 1000 +MAX_BYTES = 1 << 20 + + +def identity(value): + """Match Jellyfin's immutable Guid identity across its two string forms.""" + if not isinstance(value, str): + raise ValueError("Expected text UUIDs in the Jellyfin 12.2 schema") + try: + return uuid.UUID(value).hex + except ValueError: + raise ValueError("Invalid account UUID") from None + + +def login_key(name): + """Use ASCII case folding and exact spelling for Unicode account names.""" + return ("ascii:" + name.lower()) if name.isascii() else ("unicode:" + name) + + +def validate_hash(encoded): + """Accept the two bounded, salted PBKDF2 encodings supported by Go serving.""" + if not isinstance(encoded, str): + raise ValueError("Unsupported password verifier; sign in to Jellyfin to migrate it") + match = re.fullmatch( + r"\$(PBKDF2-SHA512|PBKDF2)\$iterations=([0-9]{1,7})\$([0-9a-fA-F]+)\$([0-9a-fA-F]+)", + encoded, + ) + if not match: + raise ValueError("Unsupported password verifier; sign in to Jellyfin to migrate it") + algorithm, iterations, salt_hex, hash_hex = match.groups() + size = 64 if algorithm == "PBKDF2-SHA512" else 32 + if not 1 <= int(iterations) <= 1_000_000 or len(salt_hex) % 2 or not 16 <= len(salt_hex) <= 256 or len(hash_hex) != size * 2: + raise ValueError("Password verifier parameters are outside supported bounds") + + +def read_accounts(database): + """Pin one read-only SQLite transaction, including committed WAL contents.""" + counts = {"external": 0, "passwordless": 0, "disabled": 0, "restricted": 0} + deadline = time.monotonic() + 60 + with contextlib.closing(sqlite3.connect(database.as_uri() + "?mode=ro", uri=True, timeout=5)) as db: + db.execute("PRAGMA query_only=ON") + db.set_progress_handler(lambda: int(time.monotonic() > deadline), 1000) + db.execute("BEGIN") + required = { + "Users": {"Id", "Username", "Password", "AuthenticationProviderId", "MustUpdatePassword"}, + "Permissions": {"UserId", "Kind", "Value"}, + } + for table, columns in required.items(): + actual = {row[1] for row in db.execute(f'PRAGMA table_info("{table}")')} + if not columns <= actual: + raise ValueError("Unsupported database schema; expected Jellyfin 12.2 Users and Permissions") + rows = db.execute('SELECT Id, Username, Password, AuthenticationProviderId, MustUpdatePassword FROM Users LIMIT 1001').fetchall() + if len(rows) > MAX_USERS: + raise ValueError("Account count exceeds the supported limit") + permissions = {} + for user_id, kind, value in db.execute('SELECT UserId, Kind, Value FROM Permissions WHERE Kind IN (2, 4)'): + key = (identity(user_id), kind) + if key in permissions or value not in (0, 1): + raise ValueError("Invalid or duplicate account status permissions") + permissions[key] = bool(value) + scheduled = set() + if db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='AccessSchedules'").fetchone(): + scheduled = {identity(row[0]) for row in db.execute('SELECT DISTINCT UserId FROM AccessSchedules')} + users, ids, names = [], set(), set() + for user_id, name, password_hash, provider, must_update in rows: + user_id = identity(user_id) + if not isinstance(name, str) or not name or len(name.encode("utf-8")) > 256 or any(c in name for c in "\x00\r\n"): + raise ValueError("Invalid account username") + name_key = login_key(name) + if user_id in ids or name_key in names: + raise ValueError("Duplicate account identity or login name") + ids.add(user_id) + names.add(name_key) + if not isinstance(provider, str) or provider.lower() != PROVIDER.lower(): + counts["external"] += 1 + continue + if (user_id, 2) not in permissions or (user_id, 4) not in permissions: + raise ValueError("Missing explicit disabled or remote-access account permission") + disabled = permissions[user_id, 2] + restricted = not permissions[user_id, 4] or bool(must_update) or user_id in scheduled + if disabled or restricted: + counts["disabled" if disabled else "restricted"] += 1 + users.append({"id": user_id, "username": name, "passwordHash": "", "disabled": True}) + continue + if password_hash is None or password_hash == "": + counts["passwordless"] += 1 + continue + validate_hash(password_hash) + users.append({"id": user_id, "username": name, "passwordHash": password_hash, "disabled": False}) + db.rollback() + return sorted(users, key=lambda user: user["id"]), counts + + +def private_existing(path): + """Refuse accidental symlinks, oversized files or permissive secret copies.""" + try: + info = path.lstat() + except FileNotFoundError: + return None + if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) & 0o077 or info.st_size > MAX_BYTES: + raise ValueError("Existing credential file must be regular, private (0600/0400) and within 1 MiB") + return path.read_bytes() + + +def atomic_write(path, content): + """Publish a private synced file with atomic rename on the same filesystem.""" + descriptor, temporary = tempfile.mkstemp(prefix=".auth-", dir=path.parent) + try: + with os.fdopen(descriptor, "wb") as file: + file.write(content) + file.flush() + os.fsync(file.fileno()) + os.replace(temporary, path) + directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def export(database, output, exported_at=None): + """Keep the last successful credential export intact on source failures.""" + database = Path(database).resolve(strict=True) + output = Path(output).absolute() + backup = output.with_name(output.name + ".previous") + if database in (output.resolve(), backup.resolve()): + raise ValueError("Credential output cannot replace the source database") + users, counts = read_accounts(database) + document = { + "schemaVersion": 1, + "sourceVersion": "12.2", + "exportedAt": exported_at or datetime.datetime.now(datetime.timezone.utc).isoformat(), + "users": users, + } + payload = (json.dumps(document, ensure_ascii=False, indent=2) + "\n").encode("utf-8") + if len(payload) > MAX_BYTES: + raise ValueError("Credential export exceeds 1 MiB") + output.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + previous = private_existing(output) + private_existing(backup) + if previous is not None: + atomic_write(backup, previous) + atomic_write(output, payload) + return len([user for user in users if not user["disabled"]]), counts + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--database", required=True, help="readable live Jellyfin 12.2 SQLite DB or safe backup") + parser.add_argument("--output", required=True, help="private auth/users.json, outside web and review data") + args = parser.parse_args() + try: + enabled, counts = export(args.database, args.output) + except ValueError as error: + print(f"export-auth: {error}", file=sys.stderr) + return 1 + except (sqlite3.Error, OSError): + print("export-auth: cannot read the database or publish the private export; check schema, paths and permissions", file=sys.stderr) + return 1 + print(f"Exported {enabled} enabled local accounts. Excluded: {counts['external']} external, {counts['passwordless']} passwordless; blocked: {counts['disabled']} disabled, {counts['restricted']} restricted.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())
A jellyfin-year-review/scripts/export-jellystat.sh

@@ -0,0 +1,12 @@

+#!/bin/sh +# PGHOST/PGDATABASE/PGUSER and PGPASSWORD configure read-only access. +set -eu +if [ "$#" -ne 1 ]; then echo 'usage: scripts/export-jellystat.sh input/history.jsonl' >&2; exit 1; fi +: "${PGPASSWORD:?Set PGPASSWORD in .env or export it before running this script}" +export PGPASSWORD +umask 077 +reel_export_tmp=$(mktemp "${1}.tmp.XXXXXX") +trap 'rm -f "$reel_export_tmp"' EXIT HUP INT TERM +# Disable interactive prompts; psql still reads the password from PGPASSWORD. +psql --no-password -XqAt -v ON_ERROR_STOP=1 -f "$(dirname "$0")/jellystat-export.sql" > "$reel_export_tmp" +mv "$reel_export_tmp" "$1"
A jellyfin-year-review/scripts/generate-review.sh

@@ -0,0 +1,49 @@

+#!/bin/sh +# Generate a selected year using the operator's .env and native Jellystat history. +set -eu + +if [ "$#" -ne 1 ]; then + echo 'usage: scripts/generate-review.sh YEAR' >&2 + exit 1 +fi +case "$1" in + [0-9][0-9][0-9][0-9]) ;; + *) echo 'Year must be between 1970 and 9998.' >&2; exit 1 ;; +esac +if [ "$1" -lt 1970 ] || [ "$1" -gt 9998 ]; then + echo 'Year must be between 1970 and 9998.' >&2 + exit 1 +fi + +cd "$(dirname "$0")/.." +umask 077 +printf 'review: Preparing recap generation for %s...\n' "$1" >&2 +if [ -f .env ]; then + set -a + . ./.env + set +a +fi +: "${JELLYFIN_URL:?Set JELLYFIN_URL in .env}" +: "${JELLYFIN_API_KEY:?Set JELLYFIN_API_KEY in .env}" +for reel_dependency in go psql; do + if ! command -v "$reel_dependency" >/dev/null 2>&1; then + printf 'review: Missing required command: %s. Install Go 1.27.2 and the PostgreSQL client (psql), then retry.\n' "$reel_dependency" >&2 + exit 127 + fi +done + +mkdir -p input data cache +reel_captured_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) +printf 'review: Exporting Jellystat history...\n' >&2 +if scripts/export-jellystat.sh input/jellystat.jsonl; then + : +else + reel_export_status=$? + printf 'review: History export failed. Check PostgreSQL access and PGPASSWORD; review generation was not started.\n' >&2 + exit "$reel_export_status" +fi +printf 'review: Generating the recap and downloading Jellyfin posters...\n' >&2 +exec go run ./cmd/review generate --year "$1" \ + --history input/jellystat.jsonl --captured-at "$reel_captured_at" \ + --coverage "${REVIEW_COVERAGE:-Native Jellystat export; collection coverage not verified}" \ + --output data --cache cache
A jellyfin-year-review/scripts/jellystat-export.sql

@@ -0,0 +1,13 @@

+-- Run with psql -XqAt -v ON_ERROR_STOP=1 -f scripts/jellystat-export.sql +-- The role needs SELECT only; the transaction takes one consistent PostgreSQL snapshot. +BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY; +SELECT json_build_object( + 'Id', "Id", 'UserId', "UserId", 'NowPlayingItemId', "NowPlayingItemId", + 'EpisodeId', "EpisodeId", 'PlaybackDuration', "PlaybackDuration", + 'ActivityDateInserted', to_char("ActivityDateInserted" AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'), + 'DeviceName', "DeviceName", 'Client', "Client", 'Imported', false +)::text +FROM public.jf_playback_activity +WHERE COALESCE(imported, false) = false +ORDER BY "Id"; +COMMIT;
A jellyfin-year-review/scripts/test_export_jellyfin_auth.py

@@ -0,0 +1,131 @@

+"""Synthetic Jellyfin 12.2 SQLite fixtures; never access a real media database.""" + +import importlib.util +import json +import os +from pathlib import Path +import sqlite3 +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("export_auth", Path(__file__).with_name("export-jellyfin-auth.py")) +exporter = importlib.util.module_from_spec(spec) +spec.loader.exec_module(exporter) +FIXTURE = json.loads((Path(__file__).parents[1] / "testdata/offline-auth.json").read_text()) + + +class ExportTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.database = self.root / "jellyfin.db" + self.output = self.root / "auth/users.json" + self.db = sqlite3.connect(self.database) + self.addCleanup(self.db.close) + self.db.executescript(''' + PRAGMA journal_mode=WAL; + PRAGMA wal_autocheckpoint=0; + CREATE TABLE Users (Id TEXT, Username TEXT, Password TEXT, AuthenticationProviderId TEXT, MustUpdatePassword INTEGER); + CREATE TABLE Permissions (UserId TEXT, Kind INTEGER, Value INTEGER); + CREATE TABLE AccessSchedules (UserId TEXT); + ''') + self.add_user("a", "Alice") + + def add_user(self, char, name, provider=exporter.PROVIDER, password="fixture", disabled=0, remote=1, must_update=0): + user_id = char * 32 + verifier = FIXTURE["users"][0]["passwordHash"] if password == "fixture" else password + self.db.execute("INSERT INTO Users VALUES (?,?,?,?,?)", (user_id, name, verifier, provider, must_update)) + self.db.executemany("INSERT INTO Permissions VALUES (?,?,?)", [(user_id, 2, disabled), (user_id, 4, remote)]) + self.db.commit() + return user_id + + def export(self): + return exporter.export(self.database, self.output, "2026-01-01T00:00:00Z") + + def test_export_sees_committed_wal_and_no_plaintext(self): + # Keep the writer open, forcing the exporter to read committed WAL pages. + self.add_user("c", "Legacy", password=FIXTURE["users"][1]["passwordHash"]) + before = self.db.total_changes + enabled, _ = self.export() + doc = json.loads(self.output.read_text()) + self.assertEqual(enabled, 2) + self.assertEqual(doc["users"][0]["id"], "a" * 32) + self.assertEqual(doc["users"][0]["passwordHash"], FIXTURE["users"][0]["passwordHash"]) + self.assertNotIn("fixture-only", self.output.read_text()) + self.assertEqual(self.output.stat().st_mode & 0o777, 0o600) + self.assertEqual(self.db.total_changes, before) + self.assertEqual(self.db.execute("SELECT count(*) FROM Users").fetchone()[0], 2) + + def test_blocked_external_passwordless_and_restricted_users(self): + self.add_user("b", "Disabled", disabled=1) + self.add_user("c", "LDAP", provider="External.Provider") + self.add_user("d", "NoPassword", password=None) + self.add_user("e", "LocalOnly", remote=0) + self.add_user("f", "MustChange", must_update=1) + uid = self.add_user("1", "Scheduled") + self.db.execute("INSERT INTO AccessSchedules VALUES (?)", (uid,)) + self.db.commit() + enabled, counts = self.export() + self.assertEqual(enabled, 1) + self.assertEqual(counts, {"external": 1, "passwordless": 1, "disabled": 1, "restricted": 3}) + doc = json.loads(self.output.read_text()) + for user in doc["users"]: + if user["disabled"]: + self.assertEqual(user["passwordHash"], "") + + def test_failed_exports_preserve_previous_and_success_creates_backup(self): + self.export() + previous = self.output.read_bytes() + self.db.execute("UPDATE Users SET Password='not-a-hash'") + self.db.commit() + with self.assertRaises(ValueError): + self.export() + self.assertEqual(self.output.read_bytes(), previous) + self.db.execute("UPDATE Users SET Password=?", (FIXTURE["users"][0]["passwordHash"],)) + self.db.commit() + self.export() + self.assertEqual(Path(str(self.output) + ".previous").read_bytes(), previous) + self.assertEqual(Path(str(self.output) + ".previous").stat().st_mode & 0o777, 0o600) + with patch.object(exporter.os, "replace", side_effect=OSError("synthetic failure")): + with self.assertRaises(OSError): + self.export() + self.assertEqual(self.output.read_bytes(), previous) + + def test_missing_permissions_schema_and_unsafe_destinations(self): + self.db.execute("DELETE FROM Permissions WHERE Kind=2") + self.db.commit() + with self.assertRaises(ValueError): + self.export() + self.db.execute("DROP TABLE Permissions") + self.db.commit() + with self.assertRaises(ValueError): + self.export() + with self.assertRaises(ValueError): + exporter.export(self.database, self.database) + + def test_private_file_and_symlink_checks(self): + self.export() + self.output.chmod(0o644) + with self.assertRaises(ValueError): + self.export() + self.output.chmod(0o600) + link = self.root / "linked.json" + link.symlink_to(self.output) + with self.assertRaises(ValueError): + exporter.export(self.database, link) + + def test_zero_accounts_is_valid_and_bounds_reject_dangerous_hashes(self): + self.db.execute("DELETE FROM Users") + self.db.commit() + enabled, _ = self.export() + self.assertEqual(enabled, 0) + self.assertEqual(json.loads(self.output.read_text())["users"], []) + for encoded in ["plaintext", "$PBKDF2-SHA512$iterations=9999999$0011223344556677$" + "00" * 64]: + with self.assertRaises(ValueError): + exporter.validate_hash(encoded) + + +if __name__ == "__main__": + unittest.main()
A jellyfin-year-review/scripts/test_generate_review.py

@@ -0,0 +1,123 @@

+"""Exercise helper startup and export failures without production services.""" + +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +class GenerateHelperTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="reel-helper-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + (self.root / "scripts").mkdir() + (self.root / "bin").mkdir() + for name in ("generate-review.sh", "export-jellystat.sh", "jellystat-export.sql"): + shutil.copy2(Path(__file__).parent / name, self.root / "scripts" / name) + for name in ("dirname", "mkdir", "date", "mktemp", "mv", "rm"): + (self.root / "bin" / name).symlink_to(shutil.which(name)) + (self.root / ".env").write_text( + "JELLYFIN_URL=http://fixture.invalid\n" + "JELLYFIN_API_KEY=synthetic-key-do-not-log\n" + "PGPASSWORD='synthetic password $dollar #hash !'\n" + ) + self.args = self.root / "args" + self.env = {**os.environ, "PATH": str(self.root / "bin"), "REEL_CHECK_ARGS": str(self.args)} + self.command("go", 'printf "%s\\n" "$@" > "$REEL_CHECK_ARGS"\n') + self.command("psql", 'printf "%s\\n" "{}"\n') + + def command(self, name, script): + path = self.root / "bin" / name + path.write_text("#!/bin/sh\n" + script) + path.chmod(0o755) + + def run_helper(self, year="2026"): + result = subprocess.run( + ["/bin/sh", str(self.root / "scripts/generate-review.sh"), year], + env=self.env, capture_output=True, text=True, timeout=5, + ) + self.assertNotIn("synthetic-key-do-not-log", result.stdout + result.stderr) + self.assertNotIn("synthetic password", result.stdout + result.stderr) + return result + + def test_missing_dependencies_report_error_before_creating_data(self): + for name in ("go", "psql"): + with self.subTest(command=name): + path = self.root / "bin" / name + original = path.read_text() + path.unlink() + result = self.run_helper() + self.assertEqual(result.returncode, 127) + self.assertIn(f"Missing required command: {name}", result.stderr) + self.assertFalse((self.root / "data").exists()) + self.assertFalse(self.args.exists()) + path.write_text(original) + path.chmod(0o755) + + def test_success_reports_stages_and_forwards_year(self): + result = self.run_helper() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Preparing recap generation for 2026", result.stderr) + self.assertIn("Exporting Jellystat history", result.stderr) + self.assertIn("downloading Jellyfin posters", result.stderr) + self.assertEqual(self.args.read_text().splitlines()[:5], ["run", "./cmd/review", "generate", "--year", "2026"]) + self.assertEqual((self.root / "input/jellystat.jsonl").read_text(), "{}\n") + self.assertTrue((self.root / "data").is_dir()) + self.assertEqual((self.root / "data").stat().st_mode & 0o777, 0o700) + + def test_export_failure_preserves_history_and_stops_generation(self): + (self.root / "input").mkdir() + history = self.root / "input/jellystat.jsonl" + history.write_text("previous successful export\n") + self.command("psql", 'echo "synthetic database error" >&2\nexit 7\n') + result = self.run_helper() + self.assertEqual(result.returncode, 7) + self.assertIn("History export failed", result.stderr) + self.assertFalse(self.args.exists()) + self.assertEqual(history.read_text(), "previous successful export\n") + self.assertFalse(list((self.root / "input").glob("*.tmp.*"))) + + def test_postgres_never_waits_for_a_password_prompt(self): + self.command("psql", 'if [ "$1" != "--no-password" ]; then exit 9; fi\nprintf "%s\\n" "{}"\n') + result = self.run_helper() + self.assertEqual(result.returncode, 0, result.stderr) + + def test_password_reaches_postgres_through_environment_only(self): + self.command("psql", ''' +if [ "$PGPASSWORD" != 'synthetic password $dollar #hash !' ]; then exit 8; fi +for argument do + case "$argument" in *"$PGPASSWORD"*) exit 9 ;; esac +done +printf '%s\\n' '{}' +''') + result = self.run_helper() + self.assertEqual(result.returncode, 0, result.stderr) + + def test_empty_password_stops_export_without_replacing_history(self): + with (self.root / ".env").open("a") as config: + config.write("PGPASSWORD=''\n") + (self.root / "input").mkdir() + history = self.root / "input/jellystat.jsonl" + history.write_text("previous successful export\n") + self.command("psql", 'echo "psql should not run" >&2\nexit 9\n') + result = self.run_helper() + self.assertNotEqual(result.returncode, 0) + self.assertIn("Set PGPASSWORD", result.stderr) + self.assertNotIn("psql should not run", result.stderr) + self.assertFalse(self.args.exists()) + self.assertEqual(history.read_text(), "previous successful export\n") + self.assertFalse(list((self.root / "input").glob("*.tmp.*"))) + + def test_invalid_year_does_not_start_work(self): + result = self.run_helper("bad-year") + self.assertNotEqual(result.returncode, 0) + self.assertIn("Year must be", result.stderr) + self.assertFalse(self.args.exists()) + self.assertFalse((self.root / "data").exists()) + + +if __name__ == "__main__": + unittest.main()
A jellyfin-year-review/testdata/offline-auth.json

@@ -0,0 +1,25 @@

+{ + "schemaVersion": 1, + "sourceVersion": "12.2", + "exportedAt": "2026-01-01T00:00:00Z", + "users": [ + { + "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "username": "Alice", + "passwordHash": "$PBKDF2-SHA512$iterations=210000$00112233445566778899AABBCCDDEEFF$E2923DBF9936103DFABC5D3966A54BCE0710FCB120AD39312233FF3C006338FCE720F97B0595CCD5E96F1605DFAF7E2D0370ED54D7F451BD16885637E70BBB66", + "disabled": false + }, + { + "id": "cccccccccccccccccccccccccccccccc", + "username": "Legacy", + "passwordHash": "$PBKDF2$iterations=1000$00112233445566778899AABBCCDDEEFF$2955556E34CF42367AD3669D140ECA65451A1FC1AC3D5E2059B675825D60FD32", + "disabled": false + }, + { + "id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "username": "Disabled", + "passwordHash": "", + "disabled": true + } + ] +}
A jellyfin-year-review/testdata/snapshot.json

@@ -0,0 +1,386 @@

+{ + "schemaVersion": 1, + "capturedAt": "2026-01-01T00:00:00Z", + "source": "SYNTHETIC FIXTURE \u2014 exact intervals", + "coverage": "Synthetic records in 2024 and 2025; no production viewing history.", + "users": [ + { + "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Fixture Viewer" + }, + { + "id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Empty Viewer" + } + ], + "items": [ + { + "id": "film-a", + "title": "The Last Lighthouse", + "type": "Movie", + "runtimeSeconds": 7200, + "year": 2025, + "genres": [ + "Drama", + "Mystery" + ] + }, + { + "id": "series", + "title": "Orbit Station", + "type": "Series", + "genres": [ + "Science Fiction" + ] + }, + { + "id": "ep1", + "title": "Arrival", + "type": "Episode", + "seriesId": "series", + "seriesTitle": "Orbit Station", + "runtimeSeconds": 3600, + "genres": [ + "Science Fiction" + ] + } + ], + "playback": [ + { + "id": "2024-0", + "userId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "itemId": "film-a", + "start": "2024-01-02T19:00:00+00:00", + "end": "2024-01-02T20:00:00+00:00", + "observedAt": "2024-01-02T20:00:00+00:00", + "seconds": 3600, + "exactInterval": true, + "positionStart": 0, + "positionEnd": 3600, + "device": "Living Room TV", + "client": "Jellyfin Web" + }, + { + "id": "2024-1", + "userId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "itemId": "ep1", + "start": "2024-02-03T19:00:00+00:00", + "end": "2024-02-03T20:00:00+00:00", + "observedAt": "2024-02-03T20:00:00+00:00", + "seconds": 3600, + "exactInterval": true, + "positionStart": 0, + "positionEnd": 3600, + "device": "Living Room TV", + "client": "Jellyfin Web" + }, + { + "id": "2024-2", + "userId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "itemId": "film-a", + "start": "2024-03-04T19:00:00+00:00", + "end": "2024-03-04T20:00:00+00:00", + "observedAt": "2024-03-04T20:00:00+00:00", + "seconds": 3600, + "exactInterval": true, + "positionStart": 3600, + "positionEnd": 7200, + "device": "Living Room TV", + "client": "Jellyfin Web" + }, + { + "id": "2024-3", + "userId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "itemId": "ep1", + "start": "2024-04-05T19:00:00+00:00", + "end": "2024-04-05T20:00:00+00:00", + "observedAt": "2024-04-05T20:00:00+00:00", + "seconds": 3600, + "exactInterval": true, + "positionStart": 0, + "positionEnd": 3600, + "device": "Living Room TV", + "client": "Jellyfin Web" + }, + { + "id": "2024-4", + "userId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "itemId": "film-a", + "start": "2024-05-06T19:00:00+00:00", + "end": "2024-05-06T20:00:00+00:00", + "observedAt": "2024-05-06T20:00:00+00:00", + "seconds": 3600, + "exactInterval": true, + "positionStart": 0, + "positionEnd": 3600, + "device": "Living Room TV", + "client": "Jellyfin Web" + }, + { + "id": "2024-5", + "userId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "itemId": "ep1", + "start": "2024-06-07T19:00:00+00:00", + "end": "2024-06-07T20:00:00+00:00", + "observedAt": "2024-06-07T20:00:00+00:00", + "seconds": 3600, + "exactInterval": true, + "positionStart": 0, + "positionEnd": 3600, + "device": "Living Room TV", + "client": "Jellyfin Web" + }, + { + "id": "2024-6", + "userId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "itemId": "film-a", + "start": "2024-01-08T19:00:00+00:00", + "end": "2024-01-08T20:00:00+00:00", + "observedAt": "2024-01-08T20:00:00+00:00", + "seconds": 3600, + "exactInterval": true, + "positionStart": 3600, + "positionEnd": 7200, + "device": "Living Room TV", + "client": "Jellyfin Web" + }, + { + "id": "2024-7", + "userId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "itemId": "ep1", + "start": "2024-02-09T19:00:00+00:00", + "end": "2024-02-09T20:00:00+00:00", + "observedAt": "2024-02-09T20:00:00+00:00", + "seconds": 3600, + "exactInterval": true, + "positionStart": 0, + "positionEnd": 3600, + "device": "Living Room TV", + "client": "Jellyfin Web" + }, + { + "id": "2024-8", + "userId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "itemId": "film-a", + "start": "2024-03-10T19:00:00+00:00", + "end": "2024-03-10T20:00:00+00:00", + "observedAt": "2024-03-10T20:00:00+00:00", + "seconds": 3600, + "exactInterval": true, + "positionStart": 0, + "positionEnd": 3600, + "device": "Living Room TV", + "client": "Jellyfin Web" + }, + { + "id": "2024-9", + "userId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "itemId": "ep1", + "start": "2024-04-11T19:00:00+00:00", + "end": "2024-04-11T20:00:00+00:00", + "observedAt": "2024-04-11T20:00:00+00:00", + "seconds": 3600, + "exactInterval": true, + "positionStart": 0, + "positionEnd": 3600, + "device": "Living Room TV", + "client": "Jellyfin Web" + }, + { + "id": "2024-10", + "userId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "itemId": "film-a", + "start": "2024-05-12T19:00:00+00:00", + "end": "2024-05-12T20:00:00+00:00", + "observedAt": "2024-05-12T20:00:00+00:00", + "seconds": 3600, + "exactInterval": true, + "positionStart": 3600, + "positionEnd": 7200, + "device": "Living Room TV", + "client": "Jellyfin Web" + }, + { + "id": "2024-11", + "userId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "itemId": "ep1", + "start": "2024-06-13T19:00:00+00:00", + "end": "2024-06-13T20:00:00+00:00", + "observedAt": "2024-06-13T20:00:00+00:00", + "seconds": 3600, + "exactInterval": true, + "positionStart": 0, + "positionEnd": 3600, + "device": "Living Room TV", + "client": "Jellyfin Web" + }, + { + "id": "2025-0", + "userId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "itemId": "film-a", + "start": "2025-01-02T19:00:00+00:00", + "end": "2025-01-02T20:00:00+00:00", + "observedAt": "2025-01-02T20:00:00+00:00", + "seconds": 3600, + "exactInterval": true, + "positionStart": 0, + "positionEnd": 3600, + "device": "Living Room TV", + "client": "Jellyfin Web" + }, + { + "id": "2025-1", + "userId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "itemId": "ep1", + "start": "2025-02-03T19:00:00+00:00", + "end": "2025-02-03T20:00:00+00:00", + "observedAt": "2025-02-03T20:00:00+00:00", + "seconds": 3600, + "exactInterval": true, + "positionStart": 0, + "positionEnd": 3600, + "device": "Living Room TV", + "client": "Jellyfin Web" + }, + { + "id": "2025-2", + "userId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "itemId": "film-a", + "start": "2025-03-04T19:00:00+00:00", + "end": "2025-03-04T20:00:00+00:00", + "observedAt": "2025-03-04T20:00:00+00:00", + "seconds": 3600, + "exactInterval": true, + "positionStart": 3600, + "positionEnd": 7200, + "device": "Living Room TV", + "client": "Jellyfin Web" + }, + { + "id": "2025-3", + "userId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "itemId": "ep1", + "start": "2025-04-05T19:00:00+00:00", + "end": "2025-04-05T20:00:00+00:00", + "observedAt": "2025-04-05T20:00:00+00:00", + "seconds": 3600, + "exactInterval": true, + "positionStart": 0, + "positionEnd": 3600, + "device": "Living Room TV", + "client": "Jellyfin Web" + }, + { + "id": "2025-4", + "userId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "itemId": "film-a", + "start": "2025-05-06T19:00:00+00:00", + "end": "2025-05-06T20:00:00+00:00", + "observedAt": "2025-05-06T20:00:00+00:00", + "seconds": 3600, + "exactInterval": true, + "positionStart": 0, + "positionEnd": 3600, + "device": "Living Room TV", + "client": "Jellyfin Web" + }, + { + "id": "2025-5", + "userId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "itemId": "ep1", + "start": "2025-06-07T19:00:00+00:00", + "end": "2025-06-07T20:00:00+00:00", + "observedAt": "2025-06-07T20:00:00+00:00", + "seconds": 3600, + "exactInterval": true, + "positionStart": 0, + "positionEnd": 3600, + "device": "Living Room TV", + "client": "Jellyfin Web" + }, + { + "id": "2025-6", + "userId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "itemId": "film-a", + "start": "2025-01-08T19:00:00+00:00", + "end": "2025-01-08T20:00:00+00:00", + "observedAt": "2025-01-08T20:00:00+00:00", + "seconds": 3600, + "exactInterval": true, + "positionStart": 3600, + "positionEnd": 7200, + "device": "Living Room TV", + "client": "Jellyfin Web" + }, + { + "id": "2025-7", + "userId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "itemId": "ep1", + "start": "2025-02-09T19:00:00+00:00", + "end": "2025-02-09T20:00:00+00:00", + "observedAt": "2025-02-09T20:00:00+00:00", + "seconds": 3600, + "exactInterval": true, + "positionStart": 0, + "positionEnd": 3600, + "device": "Living Room TV", + "client": "Jellyfin Web" + }, + { + "id": "2025-8", + "userId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "itemId": "film-a", + "start": "2025-03-10T19:00:00+00:00", + "end": "2025-03-10T20:00:00+00:00", + "observedAt": "2025-03-10T20:00:00+00:00", + "seconds": 3600, + "exactInterval": true, + "positionStart": 0, + "positionEnd": 3600, + "device": "Living Room TV", + "client": "Jellyfin Web" + }, + { + "id": "2025-9", + "userId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "itemId": "ep1", + "start": "2025-04-11T19:00:00+00:00", + "end": "2025-04-11T20:00:00+00:00", + "observedAt": "2025-04-11T20:00:00+00:00", + "seconds": 3600, + "exactInterval": true, + "positionStart": 0, + "positionEnd": 3600, + "device": "Living Room TV", + "client": "Jellyfin Web" + }, + { + "id": "2025-10", + "userId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "itemId": "film-a", + "start": "2025-05-12T19:00:00+00:00", + "end": "2025-05-12T20:00:00+00:00", + "observedAt": "2025-05-12T20:00:00+00:00", + "seconds": 3600, + "exactInterval": true, + "positionStart": 3600, + "positionEnd": 7200, + "device": "Living Room TV", + "client": "Jellyfin Web" + }, + { + "id": "2025-11", + "userId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "itemId": "ep1", + "start": "2025-06-13T19:00:00+00:00", + "end": "2025-06-13T20:00:00+00:00", + "observedAt": "2025-06-13T20:00:00+00:00", + "seconds": 3600, + "exactInterval": true, + "positionStart": 0, + "positionEnd": 3600, + "device": "Living Room TV", + "client": "Jellyfin Web" + } + ] +}
A jellyfin-year-review/web/jsconfig.json

@@ -0,0 +1,6 @@

+{ + "extends": "$app/tsconfig", + "include": ["src", "tests", "*.js"], + "exclude": ["src/service-worker"], + "compilerOptions": { "allowJs": true, "checkJs": false, "resolveJsonModule": true, "skipLibCheck": true } +}
A jellyfin-year-review/web/package-lock.json

@@ -0,0 +1,1373 @@

+{ + "name": "reel-review-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "reel-review-web", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "1.64.1", + "@sveltejs/adapter-static": "4.0.0", + "@sveltejs/kit": "3.1.0", + "@sveltejs/vite-plugin-svelte": "7.3.1", + "svelte": "5.57.2", + "svelte-check": "4.7.7", + "typescript": "6.0.3", + "vite": "8.3.4" + }, + "engines": { + "node": ">=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-4.0.0.tgz", + "integrity": "sha512-aF1J23R1ebKqUxQ4/qLr/Lxu63NFmLAtg8UZl3HFLxaoA1D/nhTVGcD9lTqx65iUbjNR2iOL0TgOxSX82x6Paw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^3.0.0-next.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-3.1.0.tgz", + "integrity": "sha512-JTWlq2HQlkqx6vIoxhC0R6sc4WVqbQoRyEmr5YlqKmiYbzY1U3awtSWTmjMLlS056a49AmkQR6rnNANXIE8B0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1", + "@standard-schema/spec": "^1.1.0", + "cookie": "^2.0.1", + "devalue": "^5.9.4", + "esm-env": "^1.2.2", + "magic-string": "^1.2.3", + "mrmime": "^2.0.1", + "sirv": "^3.0.2" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=22.17" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "svelte": "^5.57.1", + "typescript": "^6.0.0", + "vite": "^8.0.12" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.1.tgz", + "integrity": "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^1.0.0", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz", + "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.154.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.57.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz", + "integrity": "sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.6.0", + "@sveltejs/acorn-typescript": "^1.0.13", + "@types/estree": "^1.0.9", + "acorn": "^8.18.0", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.9.2", + "esm-env": "^1.2.1", + "esrap": "^2.3.6", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.7.tgz", + "integrity": "sha512-Ge2IMdeTpbJuzNFGRiiMg0RWgbScysKQu7xwcyFJ22+DU9F5nPX51+Q3gHmGqRxYQMx2knreVWt5oCtfe0vf2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.3", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/svelte/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "dev": true, + "license": "MIT" + } + } +}
A jellyfin-year-review/web/package.json

@@ -0,0 +1,23 @@

+{ + "name": "reel-review-web", + "version": "1.0.0", + "private": true, + "type": "module", + "engines": { "node": ">=24.0.0" }, + "scripts": { + "dev": "vite --host 127.0.0.1", + "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json", + "build": "vite build", + "test:e2e": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.64.1", + "@sveltejs/adapter-static": "4.0.0", + "@sveltejs/kit": "3.1.0", + "@sveltejs/vite-plugin-svelte": "7.3.1", + "svelte": "5.57.2", + "svelte-check": "4.7.7", + "typescript": "6.0.3", + "vite": "8.3.4" + } +}
A jellyfin-year-review/web/playwright.config.js

@@ -0,0 +1,7 @@

+import { defineConfig } from '@playwright/test'; +export default defineConfig({ + testDir: './tests', + timeout: 30000, + use: { baseURL: 'http://127.0.0.1:18180', browserName: 'chromium', viewport: { width: 390, height: 844 }, reducedMotion: 'reduce' }, + webServer: { command: 'cd .. && go run ./scripts/e2e-server.go', url: 'http://127.0.0.1:18180', reuseExistingServer: false, timeout: 120000 } +});
A jellyfin-year-review/web/src/app.css

@@ -0,0 +1,1 @@

+:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#f7f5f0;background:#131224;font-synthesis:none;font-weight:450;--lime:#c5f76b;--muted:#aaa6bc}*{box-sizing:border-box}body{margin:0}button,input,select{font:inherit}button,a,input,select,summary{-webkit-tap-highlight-color:transparent}button,a{touch-action:manipulation}button{cursor:pointer}button:disabled{cursor:default;opacity:.4}a{color:inherit}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:3px solid var(--lime);outline-offset:5px}button{color:inherit}button.quiet{border:0;background:transparent;padding:10px 12px;font-size:.85rem}.app-shell{max-width:1280px;margin:auto;padding:24px max(24px,env(safe-area-inset-right)) max(20px,env(safe-area-inset-bottom)) max(24px,env(safe-area-inset-left))}.topbar{display:flex;align-items:center;justify-content:space-between;gap:15px;min-height:60px;margin-bottom:32px}.brand{text-decoration:none;display:flex;align-items:center;gap:11px;font-size:24px;font-weight:850;letter-spacing:-1px;line-height:1}.brand-icon{background:var(--lime);color:#141323;border-radius:11px;padding:10px 12px;font-weight:950}.brand-light{font-weight:350}.brand small{display:block;font-size:8px;letter-spacing:2px;margin-top:8px;font-weight:600}.privacy-tag{font-size:9px;letter-spacing:1.4px;color:#c6c1d7}.privacy-tag span{color:var(--lime);margin-right:7px}.account{display:flex;align-items:center;gap:16px}.year-picker{display:flex;align-items:center;gap:8px;font-size:11px;color:#cbc5dc}.year-picker select{background:#29263c;border:1px solid #514968;color:white;border-radius:9px;padding:8px}h1,h2,h3,p{margin-top:0}h1,h2{font-weight:800;letter-spacing:-.065em;line-height:1.04;margin-bottom:26px}h1{font-size:clamp(3.7rem,7.8vw,7rem)}h2{font-size:clamp(2.7rem,6.4vw,5.8rem)}em{font-family:Georgia,serif;font-weight:450}p{line-height:1.7}.eyebrow{font-size:10px;font-weight:750;letter-spacing:2px;margin-bottom:30px}.lede,.story-lede{font-size:clamp(1rem,1.7vw,1.25rem);line-height:1.7;max-width:540px}.app-shell.login-only{min-height:100svh;display:grid;align-items:center}.login-layout{display:flex;justify-content:center;padding:24px 0}.login-intro h1 em{color:#c5f76b}.login-intro .lede{color:#c0bacf;max-width:445px}.login-panel{width:100%;max-width:460px;background:linear-gradient(145deg,#302947,#201e35);border:1px solid #484058;border-radius:24px;padding:42px;box-shadow:0 20px 80px #0003}.pill{border:1px solid #69637a;border-radius:20px;padding:7px 10px;font-size:8px;letter-spacing:1.7px;display:inline-block;margin-bottom:24px}.login-panel h1{font-size:3.4rem;letter-spacing:-.06em}.login-panel p{color:#d0c9de;font-size:.9rem}.login-panel label{display:block;font-size:12px;font-weight:600;margin:22px 0 9px}.login-panel input{width:100%;border:1px solid #736780;background:#13122466;border-radius:10px;padding:14px;color:white}.primary{display:inline-flex;justify-content:space-between;align-items:center;gap:30px;background:var(--lime);color:#141323;border:0;border-radius:30px;padding:16px 25px;font-weight:750;min-height:52px}.login-panel .primary{width:100%;margin:28px 0 17px}.login-note{color:#c0b7cb;font-size:11px;line-height:1.65;display:block}.film-mark{display:flex;align-items:center;gap:15px;color:#9a92af;margin-top:45px;font-size:8px;letter-spacing:1.5px}.film-mark b{font-size:28px;color:var(--lime)}.review-meta{display:flex;justify-content:space-between;text-transform:uppercase;font-size:10px;letter-spacing:1.7px;gap:12px;color:#c5bfd8}.progress{display:flex;gap:6px;margin:14px 0}.progress button{flex:1;min-width:0;background:transparent;border:0;padding:8px 0}.progress button span{display:block;height:4px;border-radius:8px;background:#454056}.progress button.active span{background:var(--lime)}.progress button.visited span{background:#9b8cb9}.story{position:relative;isolation:isolate;overflow:hidden;border-radius:26px;padding:48px 6% 20px;min-height:calc(100svh - 230px);background:#c7ed72;color:#1d2630;touch-action:pan-y}.tone-0{background:radial-gradient(at 90% 85%,#a2db79,transparent 60%),#d2f48b}.tone-1{background:radial-gradient(at 90% 85%,#8d74d4,transparent 65%),#ded2f8;color:#251c39}.tone-2{background:radial-gradient(at 90% 85%,#f8785a,transparent 65%),#ffba8f;color:#3b1b28}.tone-3{background:radial-gradient(at 90% 85%,#3c96b3,transparent 65%),#b7e3d6;color:#11343c}.story-body{position:relative;z-index:1;min-height:510px;animation:enter .35s ease-out}.story h1,.story h2{max-width:850px}.story .eyebrow{opacity:.8}.story-orbit{position:absolute;z-index:0;width:750px;height:750px;right:-420px;top:-100px;border:1px solid #15262c1a;border-radius:50%;box-shadow:0 0 0 60px #15262c08,0 0 0 120px #15262c07,0 0 0 180px #15262c06;pointer-events:none}.year-stamp{display:block;font-size:12px;font-weight:800;letter-spacing:5px;margin-bottom:30px}.giant-play{position:absolute;right:0;bottom:10px;font-size:300px;line-height:1;color:#19243b0b;pointer-events:none;z-index:-1}.story .primary{background:#202436;color:white;margin:20px 0}.story .primary:focus-visible,.story button:focus-visible{outline-color:#273146}.big-stat{font-size:clamp(5rem,11vw,10rem);font-weight:850;letter-spacing:-.08em;line-height:1.05;margin:25px 0 32px}.big-stat span{display:block;font-size:14px;letter-spacing:.02em;font-weight:600;margin:8px 0}.stat-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));max-width:720px;gap:22px;margin:30px 0}.stat-grid b{display:block;font-size:clamp(1.6rem,3vw,2.5rem);letter-spacing:-1px;line-height:1.2}.stat-grid span{display:block;font-size:12px;line-height:1.5;margin-top:7px}.footnote{font-size:11px;line-height:1.7;max-width:700px;margin:24px 0 12px;opacity:.85}.story-bottom{position:relative;display:flex;align-items:center;justify-content:space-between;padding-top:30px;font-size:12px;font-weight:650}.muted{opacity:.55}.story-controls{display:flex;gap:8px}.story-controls button{border:1px solid currentColor;border-radius:50%;width:45px;height:45px;color:inherit;background:transparent;font-size:22px}.content-list{list-style:none;padding:0;display:grid;gap:12px;max-width:720px}.content-list li{display:flex;align-items:center;gap:20px;border-bottom:1px solid #17142624;padding-bottom:12px}.rank-number{font-size:18px;font-weight:750;min-width:25px}.poster{flex:none;width:58px;aspect-ratio:2/3;border-radius:6px;overflow:hidden;background:#222135;color:#fff}.poster img{width:100%;height:100%;object-fit:cover}.poster-fallback{height:100%;padding:7px;background:linear-gradient(150deg,#302946,#515d6d);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:7px;text-align:center}.poster-fallback span{font-size:20px}.poster-fallback small{font-size:7px;max-height:25px;overflow:hidden}.content-list h3{font-size:18px;letter-spacing:-.5px;margin:0 0 6px}.content-list p{font-size:12px;line-height:1.5;margin:0}.chart{list-style:none;padding:0;display:grid;gap:14px;margin:30px 0;max-width:850px}.chart li{display:grid;grid-template-columns:minmax(65px,1fr) minmax(50px,3fr) minmax(55px,1fr);gap:15px;align-items:center;font-size:12px;min-width:0}.chart-label{overflow-wrap:anywhere}.chart-value{text-align:right;font-variant-numeric:tabular-nums}.chart-value small{font-size:9px}.track{display:block;height:13px;border-radius:2px;background:#1a183614;overflow:hidden}.fill{display:block;height:100%;background:currentColor;min-width:0;border-radius:2px}.hour-chart .chart{grid-template-columns:repeat(2,minmax(0,1fr));column-gap:35px;gap:10px 35px}.hour-chart .chart li{grid-template-columns:40px 1fr 75px;gap:7px;font-size:10px}.two-columns{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:40px}.two-columns h3{margin-top:15px}.personality-symbol{font-size:120px;line-height:1;display:block;margin:20px 0}.personality-name{font-size:clamp(2.5rem,6vw,5rem);letter-spacing:-.06em;margin:18px 0}.empty-note{font-size:20px;line-height:1.8;max-width:650px;border-left:3px solid currentColor;padding:10px 22px;margin:35px 0}.message{padding:18px 22px;border:1px solid #8e839f;border-radius:12px;margin:18px 0;font-size:14px;line-height:1.6}.message.error{background:#542c40;border-color:#cb8f9e}.message button{margin-left:10px}.state-screen{min-height:60vh;display:flex;flex-direction:column;align-items:flex-start;justify-content:center;max-width:750px;margin:auto;padding:40px 0}.state-screen h1{font-size:3.7rem}.state-screen p{color:#c2bbd0}.loading-orbit{width:44px;height:44px;border:3px solid #57506c;border-top-color:var(--lime);border-radius:50%;animation:spin 1s linear infinite;margin-bottom:30px}.source-notes{font-size:11px;color:#c5bed5;margin-top:24px;line-height:1.7}.source-notes summary{cursor:pointer;padding:12px 0}.source-notes p{margin:10px 0;max-width:850px}footer{display:flex;align-items:center;gap:20px;justify-content:space-between;margin-top:35px;color:#b5acc6;font-size:9px}footer>span{letter-spacing:1.3px}footer p{margin:0;max-width:450px;text-align:right;font-size:9px}footer svg{display:block}footer a{flex:none}@keyframes enter{from{opacity:.5;transform:translateY(8px)}to{opacity:1;transform:none}}@keyframes spin{to{transform:rotate(360deg)}}@media(min-width:1400px){.story{min-height:730px}}@media(max-width:760px){.app-shell{padding:15px max(15px,env(safe-area-inset-right)) max(20px,env(safe-area-inset-bottom)) max(15px,env(safe-area-inset-left))}.topbar{margin-bottom:22px}.brand{font-size:20px}.brand-icon{padding:9px 11px}.brand small{font-size:6px}.privacy-tag{font-size:7px;max-width:110px;text-align:right;line-height:1.8}.login-layout{padding:12px 0}.login-intro h1{font-size:4.2rem}.login-intro .lede{font-size:1rem}.film-mark{display:none}.login-panel{padding:30px 26px}.account{gap:3px}.year-picker{font-size:0}.year-picker select{font-size:12px;padding:7px}.quiet{font-size:11px!important}.story{padding:30px 25px 17px;min-height:calc(100svh - 175px);border-radius:18px}.story-body{min-height:480px}.eyebrow{font-size:8px;letter-spacing:1.6px;margin-bottom:25px}h1{font-size:3.7rem}h2{font-size:3.2rem}.story-lede{font-size:1rem}.year-stamp{margin-bottom:22px}.stat-grid{gap:20px 12px}.content-list li{gap:12px}.content-list h3{font-size:15px}.content-list p{font-size:11px}.poster{width:45px}.rank-number{font-size:14px;min-width:20px}.chart li{grid-template-columns:75px 1fr 75px;gap:10px;font-size:11px}.hour-chart .chart{grid-template-columns:1fr;gap:9px}.hour-chart .chart li{grid-template-columns:40px 1fr 75px}.two-columns{grid-template-columns:1fr;gap:5px}.two-columns .chart{margin-top:15px}.big-stat{font-size:6.3rem}.big-stat span{font-size:12px;line-height:1.5}.story-bottom{padding-top:20px}.story-controls button{width:42px;height:42px}.giant-play{font-size:200px}.source-notes{font-size:10px}footer{flex-wrap:wrap;gap:15px;font-size:8px}footer p{order:3;text-align:left;width:100%;font-size:8px}.state-screen h1{font-size:3rem}.progress{gap:4px}.progress button{padding:9px 0}}@media(max-height:600px) and (orientation:landscape){.story-body{min-height:330px}.story{min-height:0}.login-layout{min-height:0}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}@media(max-width:350px){.story{padding:25px 17px}h1{font-size:3rem}h2{font-size:2.7rem}.chart li{grid-template-columns:62px 1fr 65px}}
A jellyfin-year-review/web/src/app.html

@@ -0,0 +1,11 @@

+<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> + <meta name="theme-color" content="#131224" /> + <link rel="icon" href="/favicon.svg" /> + %sveltekit.head% + </head> + <body data-sveltekit-preload-data="hover"><div style="display: contents">%sveltekit.body%</div></body> +</html>
A jellyfin-year-review/web/src/lib/Chart.svelte

@@ -0,0 +1,10 @@

+<script> + let { values, labels, metric = 'minutes' } = $props(); + const fmt = (v) => new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(v); + let max = $derived(Math.max(1, ...values)); +</script> +<ol class="chart" aria-label={metric}> + {#each values as value, index} + <li><span class="chart-label">{labels[index]}</span><span class="track"><span class="fill" style:width="{Math.max(0, value / max * 100)}%"></span></span><span class="chart-value">{fmt(value)} <small>{metric}</small></span></li> + {/each} +</ol>
A jellyfin-year-review/web/src/lib/Poster.svelte

@@ -0,0 +1,19 @@

+<script> + let { item } = $props(); + let failed = $state(false); +</script> +<div class="poster-block"> + <div class="poster"> + {#if item.poster && !failed} + <img src={item.poster} alt="Poster for {item.title}" onerror={() => failed = true} loading="lazy" /> + {:else} + <div class="poster-fallback" aria-label="Poster unavailable"><span>▶</span><small>{item.title}</small></div> + {/if} + </div> + {#if item.posterSource}<a class="poster-credit" href={item.posterSource} target="_blank" rel="noreferrer">TVmaze</a>{/if} +</div> + +<style> + .poster-block { flex: none; } + .poster-credit { display: block; width: fit-content; font-size: 9px; line-height: 1.5; margin-top: 4px; } +</style>
A jellyfin-year-review/web/src/lib/RankingPosition.svelte

@@ -0,0 +1,50 @@

+<script> + let { ranking } = $props(); + let above = $derived(ranking.rank - 1); + // The percentile counts strictly lower totals, excluding the current viewer. + let below = $derived(Math.round(ranking.percentile * (ranking.cohort - 1) / 100)); + let tied = $derived(ranking.cohort - above - below - 1); + let viewers = $derived([ + ...Array.from({ length: above }, () => 'above'), + 'you', + ...Array.from({ length: tied }, () => 'tied'), + ...Array.from({ length: below }, () => 'below') + ]); + const labels = { above: 'Viewer with more watch time', tied: 'Viewer with the same watch time', below: 'Viewer with less watch time' }; +</script> + +<figure class="ranking-position"> + <figcaption>Your position among {ranking.cohort} {ranking.cohort === 1 ? 'viewer' : 'viewers'}</figcaption> + {#if ranking.cohort > 1}<div class="ranking-direction" aria-hidden="true"><span>Most watch time</span><span>Least watch time →</span></div>{/if} + <ol aria-label="Viewers ordered from most to least watch time"> + {#each viewers as kind, i (i)} + <li class={kind} aria-label={kind === 'you' ? `You: rank ${ranking.rank} of ${ranking.cohort}` : labels[kind]} aria-current={kind === 'you' ? 'true' : undefined}> + <span class="viewer-icon"> + <svg viewBox="0 0 32 40" aria-hidden="true"><circle cx="16" cy="10" r="7" /><path d="M3 36v-6a13 13 0 0 1 26 0v6Z" /></svg> + </span> + {#if kind === 'you'}<strong>You</strong>{/if} + </li> + {/each} + </ol> + {#if ranking.cohort === 1} + <p>You are the only eligible viewer.</p> + {:else} + <p>{above} above you · {tied} tied with you · {below} below you</p> + {/if} +</figure> + +<style> + .ranking-position { margin: 28px 0; max-width: 850px; } + figcaption { font-weight: 700; font-size: 1rem; margin-bottom: 16px; } + .ranking-direction { display: flex; justify-content: space-between; gap: 12px; font-size: 11px; margin-bottom: 10px; } + ol { display: grid; grid-template-columns: repeat(auto-fit, minmax(42px, 1fr)); gap: 12px 8px; list-style: none; margin: 0; padding: 0; } + li { display: flex; flex-direction: column; align-items: center; gap: 5px; min-height: 82px; font-size: 12px; } + .viewer-icon { display: grid; place-items: center; width: 42px; height: 56px; border: 2px solid transparent; border-radius: 12px; } + svg { width: 28px; height: 36px; fill: currentColor; } + .above .viewer-icon { opacity: .65; } + .below .viewer-icon { opacity: .3; } + .tied .viewer-icon { border-color: currentColor; border-style: dashed; } + .you .viewer-icon { background: #202436; color: white; box-shadow: 0 0 0 3px #fff9; } + strong { font-weight: 800; } + p { font-size: 12px; margin: 12px 0 0; } +</style>
A jellyfin-year-review/web/src/lib/ViewingTimeline.svelte

@@ -0,0 +1,51 @@

+<script> + import Poster from './Poster.svelte'; + + let { items, year, kind } = $props(); + const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + let titles = $derived([...items].sort((a, b) => a.title.localeCompare(b.title) || a.id.localeCompare(b.id))); + let groups = $derived(monthNames.map((name, i) => ({ + month: i + 1, name, items: titles.filter(item => item.months?.includes(i + 1)) + })).filter(group => group.items.length)); + // Keep older exports usable without inventing viewing dates for their titles. + let undated = $derived(titles.filter(item => !item.months?.length)); +</script> + +{#if items.length} + <div class="viewing-timeline" aria-label="{kind} watched by month"> + {#each groups as group (group.month)} + <section class="timeline-month" aria-label="{group.name} {year}"> + <h3><time datetime="{year}-{String(group.month).padStart(2, '0')}">{group.name} {year}</time></h3> + <ul class="timeline-titles"> + {#each group.items as item (item.id)} + <li><Poster {item} /><h4>{item.title}</h4></li> + {/each} + </ul> + </section> + {/each} + {#if undated.length} + <section class="timeline-month" aria-label="Viewing dates unavailable"> + <h3>Viewing dates unavailable</h3> + <ul class="timeline-titles"> + {#each undated as item (item.id)} + <li><Poster {item} /><h4>{item.title}</h4></li> + {/each} + </ul> + </section> + {/if} + </div> +{:else} + <p class="story-lede">No {kind.toLowerCase()} watched in {year}.</p> +{/if} + +<style> + .viewing-timeline { margin-top: 32px; padding-left: 24px; border-left: 2px solid currentColor; } + .timeline-month { position: relative; padding-bottom: 28px; } + .timeline-month:last-child { padding-bottom: 0; } + .timeline-month::before { content: ''; position: absolute; width: 10px; height: 10px; border-radius: 50%; background: currentColor; left: -30px; top: 6px; } + h3 { font-size: 1.2rem; margin-bottom: 16px; } + .timeline-titles { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); gap: 16px 24px; } + li { display: flex; align-items: center; gap: 14px; min-width: 0; } + h4 { margin: 0; font-size: 1rem; line-height: 1.5; overflow-wrap: anywhere; } + @media (max-width: 760px) { .viewing-timeline { padding-left: 18px; } .timeline-month::before { left: -24px; } } +</style>
A jellyfin-year-review/web/src/routes/+layout.js

@@ -0,0 +1,1 @@

+export const ssr = false;
A jellyfin-year-review/web/src/routes/+layout.svelte

@@ -0,0 +1,2 @@

+<script>import '../app.css'; let { children } = $props();</script> +{@render children()}
A jellyfin-year-review/web/src/routes/[...path]/+page.svelte

@@ -0,0 +1,186 @@

+<script> + import { onMount } from 'svelte'; + import Poster from '../../lib/Poster.svelte'; + import Chart from '../../lib/Chart.svelte'; + import ViewingTimeline from '../../lib/ViewingTimeline.svelte'; + import RankingPosition from '../../lib/RankingPosition.svelte'; + + let user = $state(null), years = $state([]), year = $state(''), review = $state(null); + let loading = $state(true), signingIn = $state(false), error = $state(''), notice = $state(''); + let username = $state(''), password = $state(''), slide = $state(0); + let touch = null, request = 0; + let authMode = $state('jellyfin'); + const chapters = [ + { id: 0, name: 'Your viewing recap' }, + { id: 1, name: 'Your watch time', availability: 'exactWatchTime' }, + { id: 2, name: 'Movies watched' }, + { id: 3, name: 'TV shows watched' }, + { id: 4, name: 'Unfinished movies', availability: 'completion' }, + { id: 5, name: 'Your top genres' }, + { id: 6, name: 'Viewing by hour' }, + { id: 7, name: 'Viewing by weekday' }, + { id: 8, name: 'Viewing by month' }, + { id: 9, name: 'Your devices and apps' }, + { id: 10, name: 'Your longest viewing session', availability: 'marathon' }, + { id: 11, name: 'Your viewing profile', availability: 'personality' }, + { id: 12, name: 'Your watch-time ranking', availability: 'ranking' }, + { id: 13, name: 'Your year in summary' } + ]; + // Navigation and chapter numbering follow only the available cards for this year. + let cards = $derived(chapters.filter(chapter => !chapter.availability || review?.availability?.[chapter.availability]?.available !== false)); + let card = $derived(cards[slide]?.id); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const count = (n) => new Intl.NumberFormat().format(n); + const hours = (n) => new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(n / 3600); + const duration = (n) => `${hours(n)} h`; + const interactive = (target) => target instanceof Element && !!target.closest('button, input, select, a, textarea'); + + async function api(path, options = {}) { + const res = await fetch(path, { credentials: 'same-origin', ...options }); + const body = await res.json(); + if (!res.ok) { + if (res.status === 401 && path !== '/api/login') { user = null; review = null; } + throw new Error(body.error || 'The request could not be completed.'); + } + return body; + } + async function loadReview() { + if (!year) return; + const current = ++request; + loading = true; error = ''; review = null; slide = 0; + try { + const data = await api(`/api/review?year=${encodeURIComponent(year)}`); + if (current !== request) return; + if (data.schemaVersion !== 1) throw new Error('This review format is not supported. Ask the operator to update the app.'); + review = data; + history.replaceState(null, '', `/review/${year}`); + } catch (e) { if (current === request) error = e.message; } + finally { if (current === request) loading = false; } + } + async function bootstrap() { + loading = true; error = ''; + try { + const config = await api('/api/config'); authMode = config.authMode; + const session = await api('/api/session'); user = session.user; years = session.years; + const pathYear = location.pathname.split('/').filter(Boolean)[1]; + year = String(years.includes(Number(pathYear)) ? Number(pathYear) : years.at(-1) || ''); + if (year) await loadReview(); + } catch (e) { if (user || !e.message.includes('expired')) error = e.message; } + finally { loading = false; } + } + async function login(event) { + event.preventDefault(); signingIn = true; error = ''; notice = ''; + try { + await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); + password = ''; await bootstrap(); + } catch (e) { error = e.message; } + finally { password = ''; signingIn = false; } + } + async function logout() { + notice = ''; error = ''; + try { await api('/api/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); } + catch (e) { notice = e.message; } + finally { request++; user = null; review = null; years = []; password = ''; history.replaceState(null, '', '/'); } + } + function navigate(delta) { slide = Math.max(0, Math.min(cards.length - 1, slide + delta)); } + function keydown(event) { + if (!review || interactive(event.target) || event.altKey || event.ctrlKey || event.metaKey) return; + if (event.key === 'ArrowRight') { event.preventDefault(); navigate(1); } + if (event.key === 'ArrowLeft') { event.preventDefault(); navigate(-1); } + if (event.key === 'Home') { event.preventDefault(); slide = 0; } + if (event.key === 'End') { event.preventDefault(); slide = cards.length - 1; } + } + function pointerdown(event) { if (event.pointerType === 'touch' && !interactive(event.target)) touch = { x: event.clientX, y: event.clientY }; } + function pointerup(event) { + if (!touch) return; + const dx = event.clientX - touch.x, dy = event.clientY - touch.y; touch = null; + if (Math.abs(dx) > 65 && Math.abs(dx) > Math.abs(dy) * 1.5) navigate(dx < 0 ? 1 : -1); + } + onMount(bootstrap); +</script> + +<svelte:head><title>Jellyfin Year Review</title><meta name="description" content="Your private Jellyfin year in movies and television." /></svelte:head> +<svelte:window onkeydown={keydown} onpointerup={pointerup} onpointercancel={() => touch = null} /> + +<div class="app-shell" class:login-only={!user}> + {#if user} + <header class="topbar"> + <a class="brand" href="/" aria-label="Jellyfin Year Review home"><span class="brand-icon">J</span><span>Jellyfin <span class="brand-light">Year Review</span><small>A YEAR WORTH REPLAYING</small></span></a> + {#if user} + <div class="account"> + {#if years.length > 1}<label class="year-picker">Year<select aria-label="Review year" bind:value={year} onchange={loadReview}>{#each years as y}<option value={String(y)}>{y}</option>{/each}</select></label>{/if} + <button class="quiet" onclick={logout}>Sign out</button> + </div> + {/if} + </header> + {/if} + + <main> + {#if user} + {#if notice}<p class="message" role="status">{notice}</p>{/if} + {#if error}<div class="message error" role="alert">{error}<button class="quiet" onclick={bootstrap}>Try again</button></div>{/if} + {/if} + {#if !user} + <section class="login-layout" aria-label="Sign in"> + <form class="login-panel" onsubmit={login}> + <h1>Your year,<br />replayed.</h1><p>Sign in with your existing Jellyfin account.</p> + {#if notice}<p class="message" role="status">{notice}</p>{/if} + {#if error}<div class="message error" role="alert">{error}</div>{/if} + <label for="username">Jellyfin username</label><input id="username" name="username" autocomplete="username" required maxlength="256" bind:value={username} /> + <label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" bind:value={password} /> + <button class="primary" type="submit" disabled={loading || signingIn}>{loading ? 'Loading…' : signingIn ? 'Signing in…' : 'Roll the credits'} <span aria-hidden="true">↗</span></button> + <small class="login-note">{authMode === 'offline' ? 'Your credentials are checked locally against a private Jellyfin account snapshot. Password changes take effect after the operator refreshes it.' : 'Your credentials are validated by your Jellyfin server. Your review stays private.'}</small> + </form> + </section> + {:else if loading} + <section class="state-screen"><span class="loading-orbit" aria-hidden="true"></span><h1>Setting the scene…</h1><p role="status">Loading your private review</p></section> + {:else if !years.length} + <section class="state-screen"><p class="eyebrow">NOT QUITE SHOWTIME</p><h1>Your story is on its way.</h1><p>No review has been generated for your account. Ask your server operator to generate a year.</p><button class="primary" onclick={bootstrap}>Check again</button></section> + {:else if review} + <div class="review-meta"><span>{review.year} / {user.name}</span></div> + <nav class="progress" aria-label="Review chapters">{#each cards as chapter, i}<button class:active={slide === i} class:visited={slide > i} aria-label="{i + 1}. {chapter.name}" aria-current={slide === i ? 'step' : undefined} onclick={() => slide = i}><span></span></button>{/each}</nav> + <section class="story tone-{slide % 4}" aria-roledescription="carousel" aria-label="Your yearly review" onpointerdown={pointerdown}> + <div class="story-orbit" aria-hidden="true"></div> + <div class="story-body" aria-live="polite" aria-atomic="true"> + <p class="eyebrow">CHAPTER {String(slide + 1).padStart(2, '0')} / {cards[slide].name.toUpperCase()}</p> + {#if card === 0} + <span class="year-stamp">{review.year}</span><h1>Your viewing recap</h1><p class="story-lede">{user.name}, {review.records ? 'see the movies, TV shows, and viewing habits that made up your year.' : 'no viewing activity was recorded for this year.'}</p><button class="primary" onclick={() => navigate(1)}>View my recap <span aria-hidden="true">→</span></button><span class="giant-play" aria-hidden="true">▶</span> + {:else if card === 1} + <h2>Your watch time</h2><div class="big-stat">{hours(review.seconds)}<span>hours watched{review.availability.exactWatchTime.available ? '' : ' · end-attributed estimate'}</span></div> + <div class="stat-grid"><div><b>{duration(review.movieSeconds)}</b><span>Movies</span></div><div><b>{duration(review.tvSeconds)}</b><span>Television</span></div><div><b>{count(review.records)}</b><span>Activity records</span></div><div><b>{count(review.uniqueTitles)}</b><span>Unique movies & episodes</span></div></div> + {:else if card === 2 || card === 3} + {@const items = card === 2 ? review.movies : review.shows} + <h2>{card === 2 ? 'Movies watched' : 'TV shows watched'}</h2> + <p>Everything you watched in {review.year}, grouped by month.</p> + <ViewingTimeline {items} year={review.year} kind={card === 2 ? 'Movies' : 'TV shows'} /> + {:else if card === 4} + <h2>Unfinished movies</h2><p class="story-lede">Movies where you watched less than {review.rules.completionPercent}% of the runtime.</p> + {#if review.abandoned.length}<ol class="content-list compact">{#each review.abandoned.slice(0, 5) as item}<li><Poster {item} /><div><h3>{item.title}</h3><p>{Math.round(item.completionPercent)}% of the movie watched</p></div></li>{/each}</ol>{:else}<p class="empty-note">{review.availability.completion.available ? 'No unfinished movies found in supported records.' : 'Completion is unavailable without runtime and actual position ranges.'}</p>{/if} + {:else if card === 5} + <h2>Your top genres</h2><p>Genres ranked by watch time.</p>{#if review.genres.length}<Chart values={review.genres.slice(0, 7).map(v => v.seconds / 3600)} labels={review.genres.slice(0, 7).map(v => v.name)} metric="h" />{:else}<p class="empty-note">No genre history is available.</p>{/if} + {:else if card === 6} + <h2>Viewing by hour</h2><p>Your viewing activity across the hours of the day.</p><div class="hour-chart"><Chart values={review.hourly} labels={Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`)} metric={review.distributionMetric === 'watched minutes' ? 'min' : 'records'} /></div> + {:else if card === 7} + <h2>Viewing by weekday</h2><p>Your viewing activity for each day of the week.</p><Chart values={review.weekdays} labels={weekdays} metric={review.distributionMetric === 'watched minutes' ? 'min' : 'records'} /> + {:else if card === 8} + <h2>Viewing by month</h2><p>Your viewing activity for each month of {review.year}.</p><Chart values={review.monthly} labels={months} metric={review.distributionMetric === 'watched minutes' ? 'min' : 'records'} /> + {:else if card === 9} + <h2>Your devices and apps</h2><p>Your most-used devices and apps, ranked by watch time.</p><div class="two-columns"><div><h3>Devices</h3><Chart values={review.devices.slice(0, 5).map(v => v.seconds / 3600)} labels={review.devices.slice(0, 5).map(v => v.name)} metric="h" /></div><div><h3>Apps & clients</h3><Chart values={review.clients.slice(0, 5).map(v => v.seconds / 3600)} labels={review.clients.slice(0, 5).map(v => v.name)} metric="h" /></div></div> + {:else if card === 10} + <h2>Your longest viewing session</h2>{#if review.marathon}<div class="big-stat">{hours(review.marathon.watchedSeconds)}<span>hours watched in your longest session</span></div><p class="story-lede">{duration(review.marathon.elapsedSeconds)} elapsed · {review.marathon.records} records</p><p>{new Date(review.marathon.start).toLocaleString(undefined, { timeZone: review.timezone })}</p>{:else}<p class="empty-note">{review.availability.marathon.reason}</p>{/if} + {:else if card === 11} + <h2>Your viewing profile</h2>{#if review.personality}<span class="personality-symbol" aria-hidden="true">✳</span><h3 class="personality-name">{review.personality.name}</h3><p class="story-lede">{review.personality.explanation}</p>{:else}<p class="empty-note">{review.availability.personality.reason}</p>{/if} + {:else if card === 12} + <h2>Your watch-time ranking</h2><p>Your total watch time compared with other eligible viewers.</p>{#if review.ranking}<div class="big-stat">#{review.ranking.rank}<span>of {review.ranking.cohort} eligible viewers</span></div><RankingPosition ranking={review.ranking} /><div class="stat-grid"><div><b>{Math.round(review.ranking.percentile)}%</b><span>Viewers with less watch time</span></div><div><b>{duration(review.seconds)}</b><span>Your total hours watched · movies &amp; TV shows</span></div></div>{:else}<p class="empty-note">{review.availability.ranking.reason}</p>{/if} + {:else} + <p class="year-stamp">SUMMARY / {review.year}</p><h2>Your year in summary</h2><div class="stat-grid">{#if review.availability.exactWatchTime.available !== false}<div><b>{duration(review.seconds)}</b><span>Your watch time</span></div>{/if}{#if review.availability.personality.available !== false}<div><b>{review.personality?.name || 'No viewing profile'}</b><span>Your viewing profile</span></div>{/if}</div><p class="story-lede">You have reached the end of your viewing recap.</p><button class="primary" onclick={() => slide = 0}>View recap again ↺</button> + {/if} + </div> + <div class="story-bottom"><span>{String(slide + 1).padStart(2, '0')} <span class="muted">/ {cards.length}</span></span><div class="story-controls"><button aria-label="Previous chapter" onclick={() => navigate(-1)} disabled={slide === 0}>←</button><button aria-label="Next chapter" onclick={() => navigate(1)} disabled={slide === cards.length - 1}>→</button></div></div> + </section> + <details class="source-notes"><summary>Behind your review · source & availability</summary><p>{review.source}</p><p>{review.coverage}</p><p>Snapshot: {new Date(review.generatedAt).toISOString()} · Format v{review.schemaVersion}</p>{#each review.warnings as warning}<p>{warning}</p>{/each}</details> + {:else}<section class="state-screen"><h1>Let’s try that again.</h1><button class="primary" onclick={bootstrap}>Reload review</button></section>{/if} + </main> + {#if user}<footer><span>MADE FOR THE LOVE OF THE WATCH.</span></footer>{/if} +</div>
A jellyfin-year-review/web/static/favicon.svg

@@ -0,0 +1,1 @@

+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="18" fill="#bbf75b"/><path d="M19 14h16a13 13 0 0 1 2 26l11 12H32L19 36zm12 11v8h4a4 4 0 0 0 0-8z" fill="#161522"/></svg>
A jellyfin-year-review/web/tests/review.spec.js

@@ -0,0 +1,165 @@

+import { test, expect } from '@playwright/test'; +const offline = process.env.REEL_E2E_AUTH_MODE === 'offline'; + +test('private review: login, navigation, years, responsive layout, logout', async ({ page }) => { + const errors = []; + const externalRequests = []; + page.on('request', request => { if (new URL(request.url()).origin !== 'http://127.0.0.1:18180') externalRequests.push(request.url()); }); + page.on('pageerror', e => errors.push(e.message)); + await page.goto('/review/2025'); + await expect(page.getByRole('heading', { name: 'Your year, replayed.' })).toBeVisible(); + await expect(page.locator('header, footer, .login-intro')).toHaveCount(0); + if (offline) await expect(page.getByText(/checked locally against a private Jellyfin account snapshot/)).toBeVisible(); + await page.getByLabel('Jellyfin username').fill(offline ? 'Alice' : 'fixture'); + await page.getByLabel('Password', { exact: true }).fill('fixture-only'); + await page.getByRole('button', { name: 'Roll the credits' }).click(); + await expect(page.getByRole('heading', { name: 'Your viewing recap' })).toBeVisible(); + await page.getByRole('button', { name: 'View my recap' }).click(); + await expect(page.getByText('hours watched', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: '3. Movies watched', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'The Last Lighthouse' }).first()).toBeVisible(); + const poster = page.getByRole('img', { name: 'Poster for The Last Lighthouse' }).first(); + await expect(poster).toBeVisible(); + await expect(poster).toHaveAttribute('src', /^\/art\/[a-f0-9]{64}\.jpg$/); + await expect.poll(() => poster.evaluate(image => image.naturalWidth)).toBeGreaterThan(0); + await page.locator('main').click({ position: { x: 5, y: 5 } }); + await page.keyboard.press('ArrowRight'); + await expect(page.getByRole('heading', { name: 'Orbit Station' }).first()).toBeVisible(); + await page.getByLabel('Review year').selectOption('2024'); + await expect(page).toHaveURL(/2024$/); + await expect(page.getByRole('heading', { name: 'Your viewing recap' })).toBeVisible(); + await page.reload(); + await expect(page.getByLabel('Review year')).toHaveValue('2024'); + for (const label of [/Viewing by hour$/, /Your longest viewing session$/, /Your viewing profile$/, /Your year in summary$/]) { + await page.getByRole('button', { name: label, exact: true }).click(); + await expect(page.locator('.story')).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy(); + } + await page.screenshot({ path: 'test-results/review-mobile.png', fullPage: true }); + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.screenshot({ path: 'test-results/review-desktop.png', fullPage: true }); + await page.getByRole('button', { name: 'Sign out' }).click(); + await expect(page.getByLabel('Jellyfin username')).toBeVisible(); + await expect(page.locator('header, footer, .login-intro')).toHaveCount(0); + expect((await page.request.get('/api/review?year=2025')).status()).toBe(401); + expect(errors).toEqual([]); + expect(externalRequests).toEqual([]); +}); + + +test('unavailable cards are skipped and chapter navigation updates when changing years', async ({ page }) => { + await page.route('**/api/review?year=*', async route => { + const response = await route.fetch(); + const review = await response.json(); + for (const availability of Object.values(review.availability)) { + availability.available = review.year !== 2025; + } + await route.fulfill({ response, json: review }); + }); + await page.goto('/review/2025'); + await page.getByLabel('Jellyfin username').fill(offline ? 'Alice' : 'fixture'); + await page.getByLabel('Password', { exact: true }).fill('fixture-only'); + await page.getByRole('button', { name: 'Roll the credits' }).click(); + const chapters = page.getByRole('navigation', { name: 'Review chapters' }); + await expect(chapters.getByRole('button')).toHaveCount(9); + await expect(chapters.getByRole('button', { name: /Your watch time|Unfinished movies|Your longest viewing session|Your viewing profile|Your watch-time ranking/ })).toHaveCount(0); + await page.getByRole('button', { name: 'View my recap' }).click(); + await expect(page.getByRole('heading', { name: 'Movies watched' })).toBeVisible(); + await page.locator('main').click({ position: { x: 5, y: 5 } }); + await page.keyboard.press('ArrowRight'); + await expect(page.getByRole('heading', { name: 'TV shows watched' })).toBeVisible(); + await page.getByRole('button', { name: 'Next chapter' }).click(); + await expect(page.getByRole('heading', { name: 'Your top genres' })).toBeVisible(); + await page.locator('main').click({ position: { x: 5, y: 5 } }); + await page.keyboard.press('End'); + await expect(page.getByRole('heading', { name: 'Your year in summary' })).toBeVisible(); + await expect(page.locator('.story-bottom')).toContainText('09 / 9'); + await expect(page.getByRole('button', { name: 'Next chapter' })).toBeDisabled(); + await expect(page.locator('.stat-grid')).not.toContainText('Your viewing profile'); + await page.getByLabel('Review year').selectOption('2024'); + await expect(chapters.getByRole('button')).toHaveCount(14); + await expect(page.getByRole('heading', { name: 'Your viewing recap' })).toBeVisible(); + await chapters.getByRole('button', { name: '5. Unfinished movies', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Unfinished movies' })).toBeVisible(); + await page.getByLabel('Review year').selectOption('2025'); + await expect(chapters.getByRole('button')).toHaveCount(9); + await expect(page.getByRole('heading', { name: 'Your viewing recap' })).toBeVisible(); +}); + +test('complete movie and show timelines include every title and support older exports', async ({ page }) => { + let mode = 'timeline'; + await page.route('**/api/review?year=*', async route => { + const response = await route.fetch(); + const review = await response.json(); + for (const [field, title] of [['movies', 'Movie'], ['shows', 'Show']]) { + review[field] = mode === 'empty' ? [] : Array.from({ length: 7 }, (_, i) => ({ + id: `${field}-${i}`, title: `${title} ${i + 1}`, seconds: 3600, records: 1, + ...(i === 0 && field === 'shows' ? { poster: review.shows[0]?.poster, posterSource: 'https://www.tvmaze.com/shows/42' } : {}), + ...(mode === 'legacy' ? {} : { months: i === 0 ? [1, 3] : i === 6 ? [12] : [1] }) + })); + } + await route.fulfill({ response, json: review }); + }); + await page.goto('/review/2025'); + await page.getByLabel('Jellyfin username').fill(offline ? 'Alice' : 'fixture'); + await page.getByLabel('Password', { exact: true }).fill('fixture-only'); + await page.getByRole('button', { name: 'Roll the credits' }).click(); + for (const [label, title] of [['Movies watched', 'Movie'], ['TV shows watched', 'Show']]) { + await page.getByRole('button', { name: new RegExp(`${label}$`) }).click(); + await expect(page.locator('.timeline-month > h3')).toHaveText(['January 2025', 'March 2025', 'December 2025']); + await expect(page.getByRole('region', { name: 'January 2025', exact: true }).getByRole('listitem')).toHaveCount(6); + await expect(page.getByRole('region', { name: 'March 2025', exact: true }).getByRole('heading', { name: `${title} 1`, exact: true })).toBeVisible(); + await expect(page.getByRole('region', { name: 'December 2025', exact: true }).getByRole('heading', { name: `${title} 7` })).toBeVisible(); + await expect(page.locator('.timeline-titles li')).toHaveCount(8); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy(); + } + await expect(page.getByRole('link', { name: 'TVmaze', exact: true })).toHaveCount(2); + await expect(page.getByRole('link', { name: 'TVmaze', exact: true }).first()).toHaveAttribute('href', 'https://www.tvmaze.com/shows/42'); + mode = 'legacy'; + await page.reload(); + await page.getByRole('button', { name: /Movies watched$/ }).click(); + await expect(page.getByRole('region', { name: 'Viewing dates unavailable' }).getByRole('listitem')).toHaveCount(7); + mode = 'empty'; + await page.reload(); + await page.getByRole('button', { name: /Movies watched$/ }).click(); + await expect(page.getByText('No movies watched in 2025.')).toBeVisible(); + await expect(page.locator('.viewing-timeline')).toHaveCount(0); +}); + +test('anonymous ranking highlights your position and handles ties and edge positions', async ({ page }) => { + let ranking = { rank: 3, cohort: 6, percentile: 40 }; + await page.route('**/api/review?year=*', async route => { + const response = await route.fetch(); + const review = await response.json(); + review.ranking = ranking; + review.availability.ranking.available = true; + await route.fulfill({ response, json: review }); + }); + await page.goto('/review/2025'); + await page.getByLabel('Jellyfin username').fill(offline ? 'Alice' : 'fixture'); + await page.getByLabel('Password', { exact: true }).fill('fixture-only'); + await page.getByRole('button', { name: 'Roll the credits' }).click(); + const chart = page.locator('.ranking-position'); + await expect(page.getByRole('heading', { name: 'Your viewing recap' })).toBeVisible(); + for (const sample of [ + { rank: 3, cohort: 6, percentile: 40, above: 2, tied: 1, below: 2 }, + { rank: 1, cohort: 6, percentile: 100, above: 0, tied: 0, below: 5 }, + { rank: 6, cohort: 6, percentile: 0, above: 5, tied: 0, below: 0 }, + { rank: 1, cohort: 6, percentile: 0, above: 0, tied: 5, below: 0 }, + { rank: 1, cohort: 1, percentile: 0, above: 0, tied: 0, below: 0 } + ]) { + ranking = { rank: sample.rank, cohort: sample.cohort, percentile: sample.percentile }; + await page.reload(); + await page.getByRole('button', { name: /Your watch-time ranking$/ }).click(); + await expect(chart.getByRole('listitem')).toHaveCount(sample.cohort); + await expect(chart.getByRole('listitem', { name: `You: rank ${sample.rank} of ${sample.cohort}`, exact: true })).toHaveCount(1); + await expect(chart.locator('.above')).toHaveCount(sample.above); + await expect(chart.locator('.tied')).toHaveCount(sample.tied); + await expect(chart.locator('.below')).toHaveCount(sample.below); + await expect(chart).not.toContainText(offline ? 'Alice' : 'Fixture Viewer'); + await expect(chart.locator('a')).toHaveCount(0); + if (sample.rank === 3) await chart.screenshot({ path: 'test-results/ranking-mobile.png' }); + if (sample.cohort === 1) await expect(chart).toContainText('You are the only eligible viewer.'); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy(); + } +});
A jellyfin-year-review/web/vite.config.js

@@ -0,0 +1,8 @@

+import { sveltekit } from '@sveltejs/kit/vite'; +import adapter from '@sveltejs/adapter-static'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit({ adapter: adapter({ fallback: 'index.html' }) })], + server: { proxy: { '/api': 'http://127.0.0.1:8080', '/art': 'http://127.0.0.1:8080' } } +});