arnaucube
arnaucube/jellyfin-toolsPublic Self-hosted repository
jellyfin-year-review: add platform breakdown and localize viewing recaps

- add catalan and english catalogs with locale-aware formatting
- group watched shows by platform and keep Other last
- read Jellyfin studio metadata and deduplicate platform title counts
- add genre radar charts and ranking confetti with reduced-motion support
- display weekdays Monday first and refresh chapter titles
arnaucube git@arnaucube.com
Sun, 18 Oct 2026 22:00:19 +0200
commit

756db02eacddf75f055cf4da573bec2603bdd0fe

parent

8057626a473cd9c784b168e84506639a3092009b

M jellyfin-year-review/Makefile → jellyfin-year-review/Makefile

@@ -5,6 +5,7 @@ go vet ./...

go test -race ./... python3 -m unittest discover -s scripts -p 'test_*.py' npm --prefix web run check + npm --prefix web run test:unit build: npm --prefix web run build go build -trimpath -o review ./cmd/review
M jellyfin-year-review/README.md → jellyfin-year-review/README.md

@@ -2,6 +2,58 @@ # jellyfin-year-review

Run these commands from the project directory. +## Translate the webapp + +Edit [`web/src/lib/translations.json`](web/src/lib/translations.json). It includes +Catalan (`ca`) and English (`en`). Set the top-level `language` to select the +language used by the webapp. Catalan is selected by default: + +```json +{ + "language": "ca", + "languages": { + "ca": { + "locale": "ca", + "direction": "ltr", + "strings": { + "Sign out": "Tanca la sessió" + } + }, + "en": { + "locale": "en", + "direction": "ltr", + "strings": { + "Sign out": "Sign out" + } + } + } +} +``` + +This example shows only one string; keep the full catalogs in the actual file. +To use English, change only `"language": "ca"` to `"language": "en"`. +An unknown language reports a configuration error. + +To add another language, copy a catalog under `languages` with a new language +code. Under its `strings`, keep the English keys and translate the values. +Keep placeholders such as `{year}`, `{name}`, and `{count}` unchanged; you can +move them within the sentence. `\n` adds a line break in the sign-in heading. +Each catalog's `locale` controls numbers, dates, and the page language; it does +not select the catalog. Set `direction` to `rtl` for right-to-left languages, +or keep `ltr`. + +The file covers interface text, accessibility labels, months and weekdays, +viewing profiles, and known server errors and review warnings. Usernames, media +titles, and other source data stay as supplied. To translate custom source or +coverage descriptions and genre labels, add their exact original text as a key. +Missing entries fall back to the original text. Values are plain text, not HTML. + +Run `npm --prefix web run test:unit` to check translations and placeholders, +then `npm --prefix web run build` and restart the serving process. For Docker, +rebuild the image using the Compose command below. Language selection and +translation edits apply to everyone using that build; no review data needs to +be regenerated. + ## 1. Generate review data Requires Go 1.27.2, `psql`, and native Jellystat history. Copy `.env.example` to
M jellyfin-year-review/docs/format.md → jellyfin-year-review/docs/format.md

@@ -15,7 +15,7 @@ | `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. +An item has `id`, `title`, `type` (`Movie`, `Episode`, `Series`, or an intentionally excluded type), `genres` (array); optional `studios` (studio/network names), `platforms` (explicit platform associations), `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.

@@ -41,6 +41,8 @@

`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. + +`platforms` optionally contains `{name, movies, shows}`, counting distinct eligible watched movie IDs and series IDs per platform. Explicit item `platforms` take precedence; otherwise recognized service aliases in Jellyfin `Studios` are used. Series metadata supplies show associations. Unrecognized production studios and missing metadata count under `Other`. Rewatches, resumed playback, and multiple episodes do not increase the title count. Titles associated with multiple platforms count once under each. Named platforms sort by total titles descending, then name; `Other` always appears last. These associations do not describe regional streaming availability or the service used for playback. Older exports without this field display an unavailable message; regenerate the year to populate it. `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.
M jellyfin-year-review/docs/review.schema.json → jellyfin-year-review/docs/review.schema.json

@@ -457,6 +457,31 @@ "minimum": 0

}, "minItems": 12, "maxItems": 12 + }, + "platforms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "movies": { + "type": "integer", + "minimum": 0 + }, + "shows": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "name", + "movies", + "shows" + ], + "additionalProperties": false + } } }, "required": [
A jellyfin-year-review/internal/generate/platforms.go

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

+package generate + +import ( + "sort" + "strings" + + "reelreview/internal/model" +) + +// platformName folds common service aliases without treating production studios +// as evidence of streaming availability. +func platformName(name string) string { + switch strings.ToLower(strings.Join(strings.Fields(name), " ")) { + case "netflix", "netflix studios": + return "Netflix" + case "hbo", "hbo max", "max", "home box office", "hbo films": + return "HBO" + case "apple tv", "apple tv+", "appletv", "appletv+", "apple tv plus", "apple original films", "apple studios": + return "Apple TV+" + case "amazon", "amazon prime", "amazon prime video", "prime video", "amazon studios", "amazon mgm studios": + return "Prime Video" + case "disney+", "disney plus": + return "Disney+" + case "hulu": + return "Hulu" + case "paramount+", "paramount plus", "cbs all access": + return "Paramount+" + case "peacock": + return "Peacock" + case "showtime": + return "Showtime" + case "starz": + return "Starz" + case "discovery+", "discovery plus": + return "Discovery+" + case "amc+", "amc plus": + return "AMC+" + case "crunchyroll": + return "Crunchyroll" + } + return "" +} + +// itemPlatforms prefers explicit snapshot associations; only recognized service +// names are inferred from Jellyfin studios. Unknown studios remain unclassified. +func itemPlatforms(item model.Item) []string { + seen := map[string]string{} + for _, name := range item.Platforms { + name = strings.Join(strings.Fields(name), " ") + if canonical := platformName(name); canonical != "" { + name = canonical + } + if name != "" { + key := strings.ToLower(name) + if previous, ok := seen[key]; !ok || name < previous { + seen[key] = name + } + } + } + if len(seen) == 0 { + for _, studio := range item.Studios { + if name := platformName(studio); name != "" { + seen[strings.ToLower(name)] = name + } + } + } + names := []string{} + for _, name := range seen { + names = append(names, name) + } + return names +} + +// platforms consumes the already filtered distinct titles, so episodes, resumes, +// and repeat viewings never multiply a title's contribution to a service. +func platforms(movies, shows []model.Content, items map[string]model.Item) []model.Platform { + counts := map[string]*model.Platform{} + for kind, titles := range [][]model.Content{movies, shows} { + for _, title := range titles { + names := itemPlatforms(items[title.ID]) + if len(names) == 0 { + names = []string{"Other"} + } + for _, name := range names { + key := strings.ToLower(name) + row := counts[key] + if row == nil { + row = &model.Platform{Name: name} + counts[key] = row + } + if name < row.Name { + row.Name = name + } + if kind == 0 { + row.Movies++ + } else { + row.Shows++ + } + } + } + } + out := []model.Platform{} + for _, row := range counts { + out = append(out, *row) + } + sort.Slice(out, func(i, j int) bool { + a, b := out[i], out[j] + if (a.Name == "Other") != (b.Name == "Other") { + return b.Name == "Other" + } + if a.Movies+a.Shows != b.Movies+b.Shows { + return a.Movies+a.Shows > b.Movies+b.Shows + } + return a.Name < b.Name + }) + return out +}
A jellyfin-year-review/internal/generate/platforms_test.go

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

+package generate + +import ( + "reflect" + "testing" + + "reelreview/internal/model" +) + +func TestPlatformsCountDistinctEligibleTitles(t *testing.T) { + s := fixture() + s.Users = append(s.Users, model.User{ID: bob, Name: "Bob"}) + s.Items[0].Studios = []string{"Netflix", "NETFLIX", "Unrelated studio"} + s.Items = append(s.Items, + model.Item{ID: "series", Title: "Show", Type: "Series", Studios: []string{"HBO Max", "HBO"}}, + model.Item{ID: "e1", Type: "Episode", SeriesID: "series"}, + model.Item{ID: "e2", Type: "Episode", SeriesID: "series"}, + model.Item{ID: "apple", Type: "Movie", Studios: []string{"Apple Original Films"}}, + model.Item{ID: "unknown", Type: "Movie", Studios: []string{"Warner Bros. Pictures"}}, + model.Item{ID: "shared", Type: "Movie", Platforms: []string{" Netflix ", "netflix", "AppleTV", "Apple TV+"}}, + model.Item{ID: "excluded", Type: "Movie", Platforms: []string{"Hulu"}}, + ) + for i, item := range []string{"film", "film", "e1", "e2", "e1", "apple", "unknown", "shared", "excluded"} { + p := play(item+string(rune('a'+i)), "2025-03-01T12:00:00Z", "2025-03-01T13:00:00Z") + p.ItemID = item + if item == "excluded" { + p.UserID = bob + } + s.Playback = append(s.Playback, p) + } + outside := play("outside", "2024-03-01T12:00:00Z", "2024-03-01T13:00:00Z") + outside.ItemID = "excluded" + short := play("short", "2025-03-01T12:00:00Z", "2025-03-01T12:00:30Z") + short.ItemID = "excluded" + s.Playback = append(s.Playback, outside, short) + want := []model.Platform{{Name: "Apple TV+", Movies: 2}, {Name: "Netflix", Movies: 2}, {Name: "HBO", Shows: 1}, {Name: "Other", Movies: 1}} + if got := compute(t, s, DefaultConfig(2025))[0].Platforms; !reflect.DeepEqual(got, want) { + t.Fatalf("platforms = %+v, want %+v", got, want) + } +} + +func TestPlatformMetadataAndEmptyHistory(t *testing.T) { + for _, tc := range []struct { + name string + item model.Item + want []string + }{ + {"explicit overrides studios", model.Item{Platforms: []string{" Custom Service ", "custom service"}, Studios: []string{"Netflix"}}, []string{"Custom Service"}}, + {"blank explicit uses studios", model.Item{Platforms: []string{" "}, Studios: []string{"amazon prime video"}}, []string{"Prime Video"}}, + {"missing metadata", model.Item{}, []string{}}, + {"no guessed Disney availability", model.Item{Studios: []string{"Walt Disney Pictures"}}, []string{}}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := itemPlatforms(tc.item); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("platforms = %v, want %v", got, tc.want) + } + }) + } + if got := compute(t, fixture(), DefaultConfig(2025))[0].Platforms; len(got) != 0 { + t.Fatalf("empty history produced counts: %+v", got) + } +} + +// Other stays last even when most watched titles lack platform metadata. +func TestPlatformsKeepOtherLast(t *testing.T) { + movies := []model.Content{{ID: "a"}, {ID: "b"}, {ID: "c"}, {ID: "netflix"}} + items := map[string]model.Item{"netflix": {Studios: []string{"Netflix"}}} + want := []model.Platform{{Name: "Netflix", Movies: 1}, {Name: "Other", Movies: 3}} + if got := platforms(movies, nil, items); !reflect.DeepEqual(got, want) { + t.Fatalf("platforms = %+v, want %+v", got, want) + } +}
M jellyfin-year-review/internal/generate/stats.go → jellyfin-year-review/internal/generate/stats.go

@@ -282,6 +282,7 @@ r.Shows = content(shows)

r.Genres = named(genres) r.Devices = named(devices) r.Clients = named(clients) + r.Platforms = platforms(r.Movies, r.Shows, items) for i := range r.Movies { v := &r.Movies[i] if positionUnknown[v.ID] {
M jellyfin-year-review/internal/model/model.go → jellyfin-year-review/internal/model/model.go

@@ -35,6 +35,8 @@ SeriesTitle string `json:"seriesTitle,omitempty"`

Year int `json:"year,omitempty"` Runtime float64 `json:"runtimeSeconds,omitempty"` Genres []string `json:"genres"` + Studios []string `json:"studios,omitempty"` + Platforms []string `json:"platforms,omitempty"` ImageTag string `json:"imageTag,omitempty"` ProviderIDs map[string]string `json:"providerIds,omitempty"` Poster string `json:"poster,omitempty"`

@@ -73,6 +75,13 @@ type Named struct {

Name string `json:"name"` Seconds float64 `json:"seconds"` Records int `json:"records"` +} + +// Platform counts distinct watched movies and series associated with a service. +type Platform struct { + Name string `json:"name"` + Movies int `json:"movies"` + Shows int `json:"shows"` } type Content struct { ID string `json:"id"`

@@ -124,6 +133,7 @@ UniqueTitles int `json:"uniqueTitles"`

ActiveDays int `json:"activeDays"` Movies []Content `json:"movies"` Shows []Content `json:"shows"` + Platforms []Platform `json:"platforms,omitempty"` Abandoned []Content `json:"abandoned"` Genres []Named `json:"genres"` Devices []Named `json:"devices"`
M jellyfin-year-review/internal/source/jellyfin.go → jellyfin-year-review/internal/source/jellyfin.go

@@ -132,18 +132,23 @@ ID string `json:"Id"`

Name, Type, SeriesID, SeriesName string ProductionYear int RunTimeTicks int64 + Studios []struct{ Name string } 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) + path := fmt.Sprintf("/Items?Recursive=true&IncludeItemTypes=Movie,Episode,Series&Fields=Genres,ProviderIds,Studios&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}) + studios := []string{} + for _, studio := range i.Studios { + studios = append(studios, studio.Name) + } + 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, Studios: studios, ImageTag: i.ImageTags["Primary"], ProviderIDs: i.ProviderIDs}) } if start+len(page.Items) >= page.TotalRecordCount { return result, items, nil
M jellyfin-year-review/internal/source/jellyfin_test.go → jellyfin-year-review/internal/source/jellyfin_test.go

@@ -17,10 +17,10 @@ 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") { + if r.URL.Query().Get("EnableImages") != "true" || !strings.Contains(r.URL.Query().Get("Fields"), "ProviderIds") || !strings.Contains(r.URL.Query().Get("Fields"), "Studios") { 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}`)) + w.Write([]byte(`{"Items":[{"Id":"11111111111111111111111111111111","Name":"Film","Type":"Movie","RunTimeTicks":72000000000,"Genres":["Drama"],"Studios":[{"Name":"Netflix"}],"ImageTags":{"Primary":"image-version"},"ProviderIds":{"Tvdb":"123","Imdb":"tt123"}}],"TotalRecordCount":1}`)) default: w.WriteHeader(404) }

@@ -31,7 +31,7 @@ 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" { + 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" || len(items[0].Studios) != 1 || items[0].Studios[0] != "Netflix" { t.Fatal("incomplete Jellyfin metadata", e) } }
M jellyfin-year-review/web/package.json → jellyfin-year-review/web/package.json

@@ -8,6 +8,7 @@ "scripts": {

"dev": "vite --host 127.0.0.1", "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json", "build": "vite build", + "test:unit": "node --test unit/*.test.js", "test:e2e": "playwright test" }, "devDependencies": {
M jellyfin-year-review/web/src/lib/Chart.svelte → jellyfin-year-review/web/src/lib/Chart.svelte

@@ -1,6 +1,7 @@

<script> - let { values, labels, metric = 'minutes' } = $props(); - const fmt = (v) => new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(v); + import { t, locale } from './i18n.js'; + let { values, labels, metric = t("minutes") } = $props(); + const fmt = (v) => new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(v); let max = $derived(Math.max(1, ...values)); </script> <ol class="chart" aria-label={metric}>
A jellyfin-year-review/web/src/lib/Confetti.svelte

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

+<script> + const colors = ['#c5f76b', '#a78bfa', '#ff7869', '#ffd166', '#65d6ed', '#ff86c8']; + // Spread a short shower across the viewport, with varied timing and tumbling. + const pieces = Array.from({ length: 90 }, (_, i) => ({ + left: (i * 37) % 100, + delay: ((i * 13) % 120) / 100, + duration: 3 + ((i * 17) % 140) / 100, + drift: ((i * 29) % 240) - 120, + rotation: (i % 2 ? 1 : -1) * (540 + (i * 31) % 720), + color: colors[i % colors.length] + })); +</script> + +<div class="confetti" aria-hidden="true"> + {#each pieces as piece, i} + <span style="left: {piece.left}%; background: {piece.color}; --delay: {piece.delay}s; --duration: {piece.duration}s; --drift: {piece.drift}px; --rotation: {piece.rotation}deg; border-radius: {i % 3 === 0 ? '50%' : '1px'};"></span> + {/each} +</div> + +<style> + .confetti { position: fixed; inset: 0; z-index: 100; overflow: hidden; pointer-events: none; } + span { position: absolute; top: -24px; width: 8px; height: 14px; animation: fall var(--duration) var(--delay) linear both; } + @keyframes fall { + from { transform: translate3d(0, 0, 0) rotate(0deg); } + to { transform: translate3d(var(--drift), calc(100dvh + 48px), 0) rotateZ(var(--rotation)) rotateY(720deg); } + } + @media (prefers-reduced-motion: reduce) { + .confetti { display: none; } + } +</style>
A jellyfin-year-review/web/src/lib/PlatformBreakdown.svelte

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

+<script> + import { t, locale } from './i18n.js'; + + let { platforms = [], records = 0 } = $props(); + // Normalize existing exports too, keeping their fallback row after named services. + let rows = $derived(platforms.map(row => ({ + ...row, name: row.name === 'Unknown platform' ? 'Other' : row.name + })).sort((a, b) => Number(a.name === 'Other') - Number(b.name === 'Other'))); + const count = value => new Intl.NumberFormat(locale).format(value); + let maximum = $derived(Math.max(1, ...platforms.map(row => row.shows))); +</script> + +{#if platforms.length} + <div class="platform-list"> + <table> + <caption class="sr-only">{t('TV shows watched by platform')}</caption> + <thead><tr><th scope="col">{t('Platform')}</th><th scope="col">{t('TV shows')}</th></tr></thead> + <tbody> + {#each rows as platform} + <tr> + <th scope="row"> + <span>{platform.name === 'Other' ? t('Other') : platform.name}</span> + <div class="platform-bar" aria-hidden="true" style:width="{100 * platform.shows / maximum}%"> + <span class="shows"></span> + </div> + </th> + <td>{count(platform.shows)}</td> + </tr> + {/each} + </tbody> + </table> + </div> +{:else} + <p class="empty-note">{records ? t('Platform data is unavailable for this review. Regenerate the year to include it.') : t('No TV shows watched this year.')}</p> +{/if} + +<style> + .platform-list { max-height: 26rem; overflow: auto; margin-top: 1.5rem; } + table { width: 100%; border-collapse: collapse; table-layout: fixed; } + th, td { padding: .8rem .4rem; border-bottom: 1px solid #ffffff1f; } + th { text-align: left; } + thead th { font-size: .8rem; color: var(--muted, #b9bac9); } + th:first-child { width: 54%; } + thead th:not(:first-child), td { text-align: right; } + tbody th { font-weight: 600; overflow-wrap: anywhere; } + td { font-variant-numeric: tabular-nums; } + .platform-bar { display: flex; height: .35rem; margin-top: .6rem; border-radius: 1rem; overflow: hidden; } + .shows { width: 100%; background: #69dfce; } + .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } +</style>
M jellyfin-year-review/web/src/lib/Poster.svelte → jellyfin-year-review/web/src/lib/Poster.svelte

@@ -1,16 +1,17 @@

<script> + import { t } from './i18n.js'; 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" /> + <img src={item.poster} alt={t("Poster for {title}", { title: item.title })} onerror={() => failed = true} loading="lazy" /> {:else} - <div class="poster-fallback" aria-label="Poster unavailable"><span>▶</span><small>{item.title}</small></div> + <div class="poster-fallback" aria-label={t("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} + {#if item.posterSource}<a class="poster-credit" href={item.posterSource} target="_blank" rel="noreferrer">{t("TVmaze")}</a>{/if} </div> <style>
A jellyfin-year-review/web/src/lib/RadarChart.svelte

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

+<script> + import { t, locale } from './i18n.js'; + import { radarGeometry } from './radar.js'; + + let { values, labels, metric = t('h') } = $props(); + const fmt = value => new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value); + let geometry = $derived(radarGeometry(values)); + let points = $derived(geometry.axes.slice(0, values.length).map(axis => `${280 + axis.valueX * 150},${240 + axis.valueY * 150}`).join(' ')); + let description = $derived(labels.map((label, index) => `${label}: ${fmt(values[index])} ${metric}`).join('; ')); +</script> + +<figure class="radar-chart"> + <figcaption>{t('Genre radar chart')}</figcaption> + <div class="radar-plot"> + <svg viewBox="0 0 560 480" role="img" aria-label={description}> + <title>{t('Genre radar chart')}</title> + {#each [0.25, 0.5, 0.75, 1] as level} + <polygon class="grid-ring" points={geometry.axes.map(axis => `${280 + axis.x * 150 * level},${240 + axis.y * 150 * level}`).join(' ')} /> + <text class="scale" x="286" y={240 - 150 * level + 12}>{fmt(geometry.max * level)} {metric}</text> + {/each} + {#each geometry.axes as axis} + <line class="axis" x1="280" y1="240" x2={280 + axis.x * 150} y2={240 + axis.y * 150} /> + {/each} + {#if values.length >= 3} + <polygon class="radar-area" {points} /> + {:else} + <polyline class="radar-line" {points} /> + {/if} + {#each values as value, index} + {@const axis = geometry.axes[index]} + <circle class="radar-point" cx={280 + axis.valueX * 150} cy={240 + axis.valueY * 150} r="4"> + <title>{labels[index]}: {fmt(value)} {metric}</title> + </circle> + {/each} + </svg> + {#each values as value, index} + {@const axis = geometry.axes[index]} + <div class="axis-label" style:left="{50 + axis.x * 194 / 560 * 100}%" style:top="{50 + axis.y * 194 / 480 * 100}%" aria-hidden="true" title={labels[index]}> + <b>{labels[index]}</b><span>{fmt(value)} {metric}</span> + </div> + {/each} + </div> +</figure> + +<style> + .radar-chart { width: 100%; max-width: 850px; margin: 36px 0 0; } + figcaption { font-size: 1rem; font-weight: 700; } + .radar-plot { position: relative; max-width: 650px; margin: 0 auto; } + svg { display: block; width: 100%; height: auto; } + .grid-ring { fill: none; stroke: currentColor; stroke-opacity: .2; } + .axis { stroke: currentColor; stroke-opacity: .15; } + .radar-area { fill: currentColor; fill-opacity: .18; stroke: currentColor; stroke-width: 2; stroke-linejoin: round; } + .radar-line { fill: none; stroke: currentColor; stroke-width: 2; } + .radar-point { fill: currentColor; } + .scale { fill: currentColor; font-size: 10px; opacity: .65; } + .axis-label { position: absolute; transform: translate(-50%, -50%); width: 23%; display: flex; flex-direction: column; gap: 3px; text-align: center; font-size: clamp(10px, 1.3vw, 14px); line-height: 1.2; overflow-wrap: anywhere; } + .axis-label b { display: -webkit-box; line-clamp: 2; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } + .axis-label span { font-size: .85em; } + @media (max-width: 760px) { .scale { font-size: 18px; } } +</style>
M jellyfin-year-review/web/src/lib/RankingPosition.svelte → jellyfin-year-review/web/src/lib/RankingPosition.svelte

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

<script> + import { t } from './i18n.js'; let { ranking } = $props(); let above = $derived(ranking.rank - 1); // The percentile counts strictly lower totals, excluding the current viewer.

@@ -10,26 +11,26 @@ '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' }; + const labels = { above: t("Viewer with more watch time"), tied: t("Viewer with the same watch time"), below: t("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"> + <figcaption>{t(ranking.cohort === 1 ? "Your position among {count} viewer" : "Your position among {count} viewers", { count: ranking.cohort })}</figcaption> + {#if ranking.cohort > 1}<div class="ranking-direction" aria-hidden="true"><span>{t("Most watch time")}</span><span>{t("Least watch time →")}</span></div>{/if} + <ol aria-label={t("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}> + <li class={kind} aria-label={kind === 'you' ? t("You: rank {rank} of {count}", { rank: ranking.rank, count: 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} + {#if kind === 'you'}<strong>{t("You")}</strong>{/if} </li> {/each} </ol> {#if ranking.cohort === 1} - <p>You are the only eligible viewer.</p> + <p>{t("You are the only eligible viewer.")}</p> {:else} - <p>{above} above you · {tied} tied with you · {below} below you</p> + <p>{t("{above} above you · {tied} tied with you · {below} below you", { above, tied, below })}</p> {/if} </figure>
M jellyfin-year-review/web/src/lib/ViewingTimeline.svelte → jellyfin-year-review/web/src/lib/ViewingTimeline.svelte

@@ -1,9 +1,10 @@

<script> + import { t, locale } from './i18n.js'; 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))); + const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'].map(value => t(value)); + let titles = $derived([...items].sort((a, b) => a.title.localeCompare(b.title, locale) || 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));

@@ -12,10 +13,10 @@ let undated = $derived(titles.filter(item => !item.months?.length));

</script> {#if items.length} - <div class="viewing-timeline" aria-label="{kind} watched by month"> + <div class="viewing-timeline" aria-label={t(kind === 'movies' ? "Movies watched by month" : "TV shows 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> + <section class="timeline-month" aria-label={t("{month} {year}", { month: group.name, year })}> + <h3><time datetime="{year}-{String(group.month).padStart(2, '0')}">{t("{month} {year}", { month: group.name, year })}</time></h3> <ul class="timeline-titles"> {#each group.items as item (item.id)} <li><Poster {item} /><h4>{item.title}</h4></li>

@@ -24,8 +25,8 @@ </ul>

</section> {/each} {#if undated.length} - <section class="timeline-month" aria-label="Viewing dates unavailable"> - <h3>Viewing dates unavailable</h3> + <section class="timeline-month" aria-label={t("Viewing dates unavailable")}> + <h3>{t("Viewing dates unavailable")}</h3> <ul class="timeline-titles"> {#each undated as item (item.id)} <li><Poster {item} /><h4>{item.title}</h4></li>

@@ -35,7 +36,7 @@ </section>

{/if} </div> {:else} - <p class="story-lede">No {kind.toLowerCase()} watched in {year}.</p> + <p class="story-lede">{t(kind === 'movies' ? "No movies watched in {year}." : "No tv shows watched in {year}.", { year })}</p> {/if} <style>
A jellyfin-year-review/web/src/lib/i18n.js

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

+import translations from './translations.json' with { type: 'json' }; + +// Select one catalog for the entire build, including page language and formatting. +if (!Object.hasOwn(translations.languages, translations.language)) { + throw new Error(`Unknown translation language: ${translations.language}`); +} +const catalog = translations.languages[translations.language]; +export const locale = catalog.locale; +export const direction = catalog.direction; + +// Replace named placeholders once, keeping inserted names and titles as plain text. +export function t(english, values = {}) { + const translated = Object.hasOwn(catalog.strings, english) ? catalog.strings[english] : english; + return translated.replace(/\{(\w+)\}/g, (placeholder, name) => Object.hasOwn(values, name) ? String(values[name]) : placeholder); +} + +const countedMessages = [ + '{count} records excluded because media metadata was deleted or unavailable.', + '{count} aggregate activity records; records may combine resumed viewing.' +]; + +// Review exports contain English messages; translate them without changing stored data. +export function message(english) { + if (!english) return ''; + for (const template of countedMessages) { + const suffix = template.slice('{count}'.length); + if (english.endsWith(suffix)) { + const count = english.slice(0, -suffix.length); + if (/^\d+$/.test(count)) return t(template, { count: new Intl.NumberFormat(locale).format(Number(count)) }); + } + } + return t(english); +}
A jellyfin-year-review/web/src/lib/radar.js

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

+// Use the same zero-based scale as the bar chart, with room for sparse datasets. +export function radarGeometry(values) { + const max = Math.max(1, ...values); + const axisCount = Math.max(3, values.length); + const axes = Array.from({ length: axisCount }, (_, index) => { + const angle = index * 2 * Math.PI / axisCount - Math.PI / 2; + const x = Math.cos(angle), y = Math.sin(angle); + const fraction = Math.max(0, values[index] ?? 0) / max; + return { x, y, valueX: x * fraction, valueY: y * fraction }; + }); + return { max, axes }; +}
A jellyfin-year-review/web/src/lib/translations.json

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

+{ + "language": "ca", + "languages": { + "ca": { + "locale": "ca", + "direction": "ltr", + "strings": { + "Shows you've watched are from platforms...": "Les sèries que has vist són de les plataformes...", + "Distinct TV shows watched, grouped by platform.": "Sèries diferents que has vist, agrupades per plataforma.", + "TV shows watched by platform": "Sèries vistes per plataforma", + "Platform": "Plataforma", + "TV shows": "Sèries", + "Other": "Altres", + "Platform data is unavailable for this review. Regenerate the year to include it.": "Les dades de plataformes no estan disponibles en aquest resum. Torna a generar l’any per incloure-les.", + "No TV shows watched this year.": "No has vist cap sèrie aquest any.", + "Your viewing recap": "El teu resum de visualitzacions", + "Your watch time": "El teu temps de visualització", + "Movies watched": "Pel·lícules vistes", + "TV shows watched": "Sèries vistes", + "Unfinished movies": "Pel·lícules inacabades", + "Your top genres": "Els teus gèneres preferits", + "Viewing by hour": "Visualitzacions per hora", + "Viewing by weekday": "Visualitzacions per dia de la setmana", + "Viewing by month": "Visualitzacions per mes", + "Your devices and apps": "Els teus dispositius i aplicacions", + "Your longest viewing session": "La teva sessió més llarga", + "Your viewing profile": "El teu perfil de visualització", + "It's not a competition... but this is your watch-time ranking": "No és una competició... però aquesta és la teva posició per temps de visualització", + "Your year in summary": "El teu any en resum", + "The request could not be completed.": "No s'ha pogut completar la sol·licitud.", + "This review format is not supported. Ask the operator to update the app.": "Aquest format de resum no és compatible. Demana a l'administrador que actualitzi l'aplicació.", + "Loading…": "S'està carregant…", + "Signing in…": "S'està iniciant la sessió…", + "Roll the credits": "Que passin els crèdits", + "Your credentials are checked locally against a private Jellyfin account snapshot. Password changes take effect after the operator refreshes it.": "Les teves credencials es comproven localment amb una còpia privada dels comptes de Jellyfin. Els canvis de contrasenya es fan efectius quan l'administrador actualitza aquesta còpia.", + "Your credentials are validated by your Jellyfin server. Your review stays private.": "El teu servidor de Jellyfin valida les credencials. El teu resum es manté privat.", + "No unfinished movies found in supported records.": "No s'han trobat pel·lícules inacabades als registres compatibles.", + "Completion is unavailable without runtime and actual position ranges.": "No es pot determinar el percentatge vist sense la durada i els intervals de reproducció reals.", + "No viewing profile": "Sense perfil de visualització", + "min": "min", + "records": "registres", + "Jan": "gen.", + "Feb": "febr.", + "Mar": "març", + "Apr": "abr.", + "May": "maig", + "Jun": "juny", + "Jul": "jul.", + "Aug": "ag.", + "Sep": "set.", + "Oct": "oct.", + "Nov": "nov.", + "Dec": "des.", + "Sun": "dg.", + "Mon": "dl.", + "Tue": "dt.", + "Wed": "dc.", + "Thu": "dj.", + "Fri": "dv.", + "Sat": "ds.", + "{hours} h": "{hours} h", + "Your year,\nreplayed.": "El teu any,\nuna altra vegada.", + "CHAPTER {number} / {name}": "CAPÍTOL {number} / {name}", + "{name}, see the movies, TV shows, and viewing habits that made up your year.": "{name}, descobreix les pel·lícules, les sèries i els hàbits de visualització que han marcat el teu any.", + "{name}, no viewing activity was recorded for this year.": "{name}, no s'ha registrat cap activitat de visualització aquest any.", + "hours watched": "hores de visualització", + "hours watched · end-attributed estimate": "hores de visualització · estimació atribuïda a la data de finalització", + "Everything you watched in {year}, grouped by month.": "Tot el que has vist durant l'any {year}, agrupat per mesos.", + "Movies where you watched less than {percent}% of the runtime.": "Pel·lícules de les quals has vist menys del {percent}% de la durada.", + "{percent}% of the movie watched": "{percent}% de la pel·lícula vista", + "Your viewing activity for each month of {year}.": "La teva activitat de visualització per a cada mes de l'any {year}.", + "{duration} elapsed · {records} records": "{duration} de temps transcorregut · {records} registres", + "of {count} eligible viewers": "d'un total de {count} espectadors que compleixen els requisits", + "SUMMARY / {year}": "RESUM / {year}", + "Snapshot: {date} · Format v{version}": "Còpia de dades: {date} · Format v{version}", + "Jellyfin Year Review": "Resum anual de Jellyfin", + "J": "J", + "Year Review": "Resum anual", + "A YEAR WORTH REPLAYING": "UN ANY QUE VAL LA PENA TORNAR A VEURE", + "Year": "Any", + "Sign out": "Tanca la sessió", + "Try again": "Torna-ho a provar", + "Sign in with your existing Jellyfin account.": "Inicia la sessió amb el teu compte de Jellyfin.", + "Jellyfin username": "Nom d'usuari de Jellyfin", + "Password": "Contrasenya", + "Setting the scene…": "S'està preparant l'escena…", + "Loading your private review": "S'està carregant el teu resum privat", + "NOT QUITE SHOWTIME": "ENCARA NO ÉS L'HORA", + "Your story is on its way.": "La teva història està en camí.", + "No review has been generated for your account. Ask your server operator to generate a year.": "No s'ha generat cap resum per al teu compte. Demana a l'administrador del servidor que en generi un per a un any.", + "Check again": "Torna-ho a comprovar", + "Movies": "Pel·lícules", + "Television": "Televisió", + "Activity records": "Registres d'activitat", + "Unique movies & episodes": "Pel·lícules i episodis diferents", + "Genres ranked by watch time.": "Gèneres ordenats per temps de visualització.", + "No genre history is available.": "No hi ha cap historial de gèneres disponible.", + "Your viewing activity across the hours of the day.": "La teva activitat de visualització al llarg de les hores del dia.", + "Your viewing activity for each day of the week.": "La teva activitat de visualització per a cada dia de la setmana.", + "Your most-used devices and apps, ranked by watch time.": "Els dispositius i les aplicacions que més fas servir, ordenats per temps de visualització.", + "Devices": "Dispositius", + "Apps & clients": "Aplicacions i clients", + "hours watched in your longest session": "hores de visualització en la teva sessió més llarga", + "Your total watch time compared with other eligible viewers.": "El teu temps total de visualització comparat amb el dels altres espectadors que compleixen els requisits.", + "Viewers with less watch time": "Espectadors amb menys temps de visualització", + "Your total hours watched · movies & TV shows": "El teu total d'hores de visualització · pel·lícules i sèries", + "You have reached the end of your viewing recap.": "Has arribat al final del teu resum de visualitzacions.", + "View recap again ↺": "Torna a veure el resum ↺", + "Behind your review · source & availability": "Darrere del teu resum · origen i disponibilitat", + "Let’s try that again.": "Tornem-ho a provar.", + "Reload review": "Torna a carregar el resum", + "MADE FOR THE LOVE OF THE WATCH.": "FET PER PASSIÓ PEL CINEMA I LES SÈRIES.", + "Your private Jellyfin year in movies and television.": "El teu any de pel·lícules i sèries a Jellyfin, en privat.", + "Jellyfin Year Review home": "Inici del resum anual de Jellyfin", + "Review year": "Any del resum", + "Sign in": "Inicia la sessió", + "Review chapters": "Capítols del resum", + "carousel": "carrusel", + "Your yearly review": "El teu resum anual", + "Previous chapter": "Capítol anterior", + "Next chapter": "Capítol següent", + "Poster for {title}": "Cartell de {title}", + "TVmaze": "TVmaze", + "Poster unavailable": "Cartell no disponible", + "January": "gener", + "February": "febrer", + "March": "març", + "April": "abril", + "June": "juny", + "July": "juliol", + "August": "agost", + "September": "setembre", + "October": "octubre", + "November": "novembre", + "December": "desembre", + "Movies watched by month": "Pel·lícules vistes per mes", + "TV shows watched by month": "Sèries vistes per mes", + "No movies watched in {year}.": "No has vist cap pel·lícula durant l'any {year}.", + "No tv shows watched in {year}.": "No has vist cap sèrie durant l'any {year}.", + "Viewing dates unavailable": "Dates de visualització no disponibles", + "Viewer with more watch time": "Espectador amb més temps de visualització", + "Viewer with the same watch time": "Espectador amb el mateix temps de visualització", + "Viewer with less watch time": "Espectador amb menys temps de visualització", + "Your position among {count} viewer": "La teva posició entre {count} espectador", + "Your position among {count} viewers": "La teva posició entre {count} espectadors", + "You: rank {rank} of {count}": "Tu: posició {rank} de {count}", + "{above} above you · {tied} tied with you · {below} below you": "{above} per davant teu · {tied} empatats amb tu · {below} per darrere teu", + "Most watch time": "Més temps de visualització", + "Least watch time →": "Menys temps de visualització →", + "You": "Tu", + "You are the only eligible viewer.": "Ets l'únic espectador que compleix els requisits.", + "Viewers ordered from most to least watch time": "Espectadors ordenats de més a menys temps de visualització", + "minutes": "minuts", + "Offline authentication is unavailable. Ask the operator to check the private credential export.": "L'autenticació fora de línia no està disponible. Demana a l'administrador que comprovi l'exportació privada de credencials.", + "Jellyfin authentication is unavailable. Please retry.": "L'autenticació de Jellyfin no està disponible. Torna-ho a provar.", + "Your session has expired. Please sign in.": "La sessió ha caducat. Torna a iniciar la sessió.", + "Method not allowed": "Mètode no permès", + "Invalid request origin": "Origen de la sol·licitud no vàlid", + "Expected application/json": "S'esperava application/json", + "Signed out locally. Jellyfin token revocation failed; revoke the Reel Review device in Jellyfin.": "S'ha tancat la sessió localment. No s'ha pogut revocar el testimoni de Jellyfin; revoca el dispositiu Reel Review a Jellyfin.", + "Not found": "No s'ha trobat", + "No valid review dataset is available. Ask the operator to generate one.": "No hi ha cap conjunt de dades de resum vàlid disponible. Demana a l'administrador que en generi un.", + "Invalid year": "Any no vàlid", + "No review was generated for this year.": "No s'ha generat cap resum per a aquest any.", + "Review unavailable": "Resum no disponible", + "Unsupported or invalid review format": "Format de resum incompatible o no vàlid", + "Too many sign-in attempts. Try again in a minute.": "Massa intents d'inici de sessió. Torna-ho a provar d'aquí a un minut.", + "Invalid login request": "Sol·licitud d'inici de sessió no vàlida", + "Unable to sign in with those credentials.": "No s'ha pogut iniciar la sessió amb aquestes credencials.", + "Session capacity reached. Try again later.": "S'ha arribat al límit de sessions. Torna-ho a provar més tard.", + "Jellystat end-attributed totals are estimates of selected-year viewing.": "Els totals de Jellystat atribuïts a la data de finalització són estimacions de les visualitzacions de l'any seleccionat.", + "Exact uninterrupted intervals are required.": "Calen intervals exactes de reproducció ininterrompuda.", + "Some movies lack runtime or actual media-position ranges; they are omitted from abandonment results.": "A algunes pel·lícules els falta la durada o els intervals reals de posició de reproducció; no s'inclouen als resultats de pel·lícules inacabades.", + "At least 10 exact records and 5 watched hours are required.": "Calen almenys 10 registres exactes i 5 hores de visualització.", + "At least one watched hour is required for cohort eligibility.": "Cal almenys una hora de visualització per formar part del grup de comparació.", + "Aggregate history is assigned to the record end year; exact yearly watch time, play counts, and timing cannot be recovered.": "L'historial agregat s'assigna a l'any de finalització del registre; no es poden recuperar el temps exacte de visualització anual, el nombre de reproduccions ni la seva distribució temporal.", + "{count} records excluded because media metadata was deleted or unavailable.": "S'han exclòs {count} registres perquè les metadades del contingut s'han suprimit o no estan disponibles.", + "{count} aggregate activity records; records may combine resumed viewing.": "{count} registres d'activitat agregats; els registres poden combinar visualitzacions represes.", + "Night Owl": "Mussol nocturn", + "At least 40% of your watch time falls between 23:00 and 05:00.": "Almenys el 40% del teu temps de visualització és entre les 23:00 i les 05:00.", + "Early Bird": "Ocell matiner", + "At least 40% of your watch time falls between 05:00 and 10:00.": "Almenys el 40% del teu temps de visualització és entre les 05:00 i les 10:00.", + "Weekend Warrior": "Guerrer de cap de setmana", + "At least 60% of your watch time falls on weekends.": "Almenys el 60% del teu temps de visualització és durant els caps de setmana.", + "Marathoner": "Maratonià", + "Your longest run includes at least six watched hours.": "La teva sessió més llarga inclou almenys sis hores de visualització.", + "Binge Watcher": "Devorador de sèries", + "TV dominates your viewing and your longest run spans at least three records and three watched hours.": "Les sèries dominen les teves visualitzacions i la teva sessió més llarga abasta almenys tres registres i tres hores de visualització.", + "Movie Buff": "Cinèfil", + "Movies account for at least 80% of your watch time.": "Les pel·lícules representen almenys el 80% del teu temps de visualització.", + "Series Devotee": "Amant de les sèries", + "TV accounts for at least 80% of your watch time.": "Les sèries representen almenys el 80% del teu temps de visualització.", + "Comfort Rewatcher": "Amant dels retrobaments", + "At least half your records revisit a previously seen title or episode.": "Almenys la meitat dels teus registres corresponen a títols o episodis que ja havies vist.", + "Genre Explorer": "Explorador de gèneres", + "You watched at least six known genres with meaningful watch time.": "Has vist almenys sis gèneres coneguts amb un temps de visualització significatiu.", + "Prime-Time Regular": "Fidel a l'horari de màxima audiència", + "At least half your watch time falls between 19:00 and 23:00.": "Almenys la meitat del teu temps de visualització és entre les 19:00 i les 23:00.", + "Daily Viewer": "Espectador diari", + "You watched on at least 180 different dates.": "Has vist contingut en almenys 180 dies diferents.", + "Seasonal Viewer": "Espectador de temporada", + "At least 40% of your watch time falls in one month.": "Almenys el 40% del teu temps de visualització es concentra en un sol mes.", + "Short-Session Viewer": "Espectador de sessions curtes", + "Your average uninterrupted record lasts at most 25 minutes.": "Els teus registres ininterromputs duren, de mitjana, com a màxim 25 minuts.", + "Balanced Viewer": "Espectador equilibrat", + "Your viewing spans several habits without one of the stronger patterns dominating.": "Les teves visualitzacions combinen diversos hàbits sense que en domini cap dels més marcats.", + "Jellystat raw finalized activity": "Activitat original finalitzada de Jellystat", + "Unknown": "Desconegut", + "h": "h", + "Jellyfin": "Jellyfin", + "View my recap": "Mostra el meu resum", + "{month} {year}": "{month} {year}", + "Genre radar chart": "Gràfic de radar dels gèneres" + } + }, + "en": { + "locale": "en", + "direction": "ltr", + "strings": { + "Shows you've watched are from platforms...": "Shows you've watched are from platforms...", + "Distinct TV shows watched, grouped by platform.": "Distinct TV shows watched, grouped by platform.", + "TV shows watched by platform": "TV shows watched by platform", + "Platform": "Platform", + "TV shows": "TV shows", + "Other": "Other", + "Platform data is unavailable for this review. Regenerate the year to include it.": "Platform data is unavailable for this review. Regenerate the year to include it.", + "No TV shows watched this year.": "No TV shows watched this year.", + "Your viewing recap": "Your viewing recap", + "Your watch time": "Your watch time", + "Movies watched": "Movies watched", + "TV shows watched": "TV shows watched", + "Unfinished movies": "Unfinished movies", + "Your top genres": "Your top genres", + "Viewing by hour": "Viewing by hour", + "Viewing by weekday": "Viewing by weekday", + "Viewing by month": "Viewing by month", + "Your devices and apps": "Your devices and apps", + "Your longest viewing session": "Your longest viewing session", + "Your viewing profile": "Your viewing profile", + "It's not a competition... but this is your watch-time ranking": "It's not a competition... but this is your watch-time ranking", + "Your year in summary": "Your year in summary", + "The request could not be completed.": "The request could not be completed.", + "This review format is not supported. Ask the operator to update the app.": "This review format is not supported. Ask the operator to update the app.", + "Loading…": "Loading…", + "Signing in…": "Signing in…", + "Roll the credits": "Roll the credits", + "Your credentials are checked locally against a private Jellyfin account snapshot. Password changes take effect after the operator refreshes it.": "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.": "Your credentials are validated by your Jellyfin server. Your review stays private.", + "No unfinished movies found in supported records.": "No unfinished movies found in supported records.", + "Completion is unavailable without runtime and actual position ranges.": "Completion is unavailable without runtime and actual position ranges.", + "No viewing profile": "No viewing profile", + "min": "min", + "records": "records", + "Jan": "Jan", + "Feb": "Feb", + "Mar": "Mar", + "Apr": "Apr", + "May": "May", + "Jun": "Jun", + "Jul": "Jul", + "Aug": "Aug", + "Sep": "Sep", + "Oct": "Oct", + "Nov": "Nov", + "Dec": "Dec", + "Sun": "Sun", + "Mon": "Mon", + "Tue": "Tue", + "Wed": "Wed", + "Thu": "Thu", + "Fri": "Fri", + "Sat": "Sat", + "{hours} h": "{hours} h", + "Your year,\nreplayed.": "Your year,\nreplayed.", + "CHAPTER {number} / {name}": "CHAPTER {number} / {name}", + "{name}, see the movies, TV shows, and viewing habits that made up your year.": "{name}, see the movies, TV shows, and viewing habits that made up your year.", + "{name}, no viewing activity was recorded for this year.": "{name}, no viewing activity was recorded for this year.", + "hours watched": "hours watched", + "hours watched · end-attributed estimate": "hours watched · end-attributed estimate", + "Everything you watched in {year}, grouped by month.": "Everything you watched in {year}, grouped by month.", + "Movies where you watched less than {percent}% of the runtime.": "Movies where you watched less than {percent}% of the runtime.", + "{percent}% of the movie watched": "{percent}% of the movie watched", + "Your viewing activity for each month of {year}.": "Your viewing activity for each month of {year}.", + "{duration} elapsed · {records} records": "{duration} elapsed · {records} records", + "of {count} eligible viewers": "of {count} eligible viewers", + "SUMMARY / {year}": "SUMMARY / {year}", + "Snapshot: {date} · Format v{version}": "Snapshot: {date} · Format v{version}", + "Jellyfin Year Review": "Jellyfin Year Review", + "J": "J", + "Year Review": "Year Review", + "A YEAR WORTH REPLAYING": "A YEAR WORTH REPLAYING", + "Year": "Year", + "Sign out": "Sign out", + "Try again": "Try again", + "Sign in with your existing Jellyfin account.": "Sign in with your existing Jellyfin account.", + "Jellyfin username": "Jellyfin username", + "Password": "Password", + "Setting the scene…": "Setting the scene…", + "Loading your private review": "Loading your private review", + "NOT QUITE SHOWTIME": "NOT QUITE SHOWTIME", + "Your story is on its way.": "Your story is on its way.", + "No review has been generated for your account. Ask your server operator to generate a year.": "No review has been generated for your account. Ask your server operator to generate a year.", + "Check again": "Check again", + "Movies": "Movies", + "Television": "Television", + "Activity records": "Activity records", + "Unique movies & episodes": "Unique movies & episodes", + "Genres ranked by watch time.": "Genres ranked by watch time.", + "No genre history is available.": "No genre history is available.", + "Your viewing activity across the hours of the day.": "Your viewing activity across the hours of the day.", + "Your viewing activity for each day of the week.": "Your viewing activity for each day of the week.", + "Your most-used devices and apps, ranked by watch time.": "Your most-used devices and apps, ranked by watch time.", + "Devices": "Devices", + "Apps & clients": "Apps & clients", + "hours watched in your longest session": "hours watched in your longest session", + "Your total watch time compared with other eligible viewers.": "Your total watch time compared with other eligible viewers.", + "Viewers with less watch time": "Viewers with less watch time", + "Your total hours watched · movies & TV shows": "Your total hours watched · movies & TV shows", + "You have reached the end of your viewing recap.": "You have reached the end of your viewing recap.", + "View recap again ↺": "View recap again ↺", + "Behind your review · source & availability": "Behind your review · source & availability", + "Let’s try that again.": "Let’s try that again.", + "Reload review": "Reload review", + "MADE FOR THE LOVE OF THE WATCH.": "MADE FOR THE LOVE OF THE WATCH.", + "Your private Jellyfin year in movies and television.": "Your private Jellyfin year in movies and television.", + "Jellyfin Year Review home": "Jellyfin Year Review home", + "Review year": "Review year", + "Sign in": "Sign in", + "Review chapters": "Review chapters", + "carousel": "carousel", + "Your yearly review": "Your yearly review", + "Previous chapter": "Previous chapter", + "Next chapter": "Next chapter", + "Poster for {title}": "Poster for {title}", + "TVmaze": "TVmaze", + "Poster unavailable": "Poster unavailable", + "January": "January", + "February": "February", + "March": "March", + "April": "April", + "June": "June", + "July": "July", + "August": "August", + "September": "September", + "October": "October", + "November": "November", + "December": "December", + "Movies watched by month": "Movies watched by month", + "TV shows watched by month": "TV shows watched by month", + "No movies watched in {year}.": "No movies watched in {year}.", + "No tv shows watched in {year}.": "No tv shows watched in {year}.", + "Viewing dates unavailable": "Viewing dates unavailable", + "Viewer with more watch time": "Viewer with more watch time", + "Viewer with the same watch time": "Viewer with the same watch time", + "Viewer with less watch time": "Viewer with less watch time", + "Your position among {count} viewer": "Your position among {count} viewer", + "Your position among {count} viewers": "Your position among {count} viewers", + "You: rank {rank} of {count}": "You: rank {rank} of {count}", + "{above} above you · {tied} tied with you · {below} below you": "{above} above you · {tied} tied with you · {below} below you", + "Most watch time": "Most watch time", + "Least watch time →": "Least watch time →", + "You": "You", + "You are the only eligible viewer.": "You are the only eligible viewer.", + "Viewers ordered from most to least watch time": "Viewers ordered from most to least watch time", + "minutes": "minutes", + "Offline authentication is unavailable. Ask the operator to check the private credential export.": "Offline authentication is unavailable. Ask the operator to check the private credential export.", + "Jellyfin authentication is unavailable. Please retry.": "Jellyfin authentication is unavailable. Please retry.", + "Your session has expired. Please sign in.": "Your session has expired. Please sign in.", + "Method not allowed": "Method not allowed", + "Invalid request origin": "Invalid request origin", + "Expected application/json": "Expected application/json", + "Signed out locally. Jellyfin token revocation failed; revoke the Reel Review device in Jellyfin.": "Signed out locally. Jellyfin token revocation failed; revoke the Reel Review device in Jellyfin.", + "Not found": "Not found", + "No valid review dataset is available. Ask the operator to generate one.": "No valid review dataset is available. Ask the operator to generate one.", + "Invalid year": "Invalid year", + "No review was generated for this year.": "No review was generated for this year.", + "Review unavailable": "Review unavailable", + "Unsupported or invalid review format": "Unsupported or invalid review format", + "Too many sign-in attempts. Try again in a minute.": "Too many sign-in attempts. Try again in a minute.", + "Invalid login request": "Invalid login request", + "Unable to sign in with those credentials.": "Unable to sign in with those credentials.", + "Session capacity reached. Try again later.": "Session capacity reached. Try again later.", + "Jellystat end-attributed totals are estimates of selected-year viewing.": "Jellystat end-attributed totals are estimates of selected-year viewing.", + "Exact uninterrupted intervals are required.": "Exact uninterrupted intervals are required.", + "Some movies lack runtime or actual media-position ranges; they are omitted from abandonment results.": "Some movies lack runtime or actual media-position ranges; they are omitted from abandonment results.", + "At least 10 exact records and 5 watched hours are required.": "At least 10 exact records and 5 watched hours are required.", + "At least one watched hour is required for cohort eligibility.": "At least one watched hour is required for cohort eligibility.", + "Aggregate history is assigned to the record end year; exact yearly watch time, play counts, and timing cannot be recovered.": "Aggregate history is assigned to the record end year; exact yearly watch time, play counts, and timing cannot be recovered.", + "{count} records excluded because media metadata was deleted or unavailable.": "{count} records excluded because media metadata was deleted or unavailable.", + "{count} aggregate activity records; records may combine resumed viewing.": "{count} aggregate activity records; records may combine resumed viewing.", + "Night Owl": "Night Owl", + "At least 40% of your watch time falls between 23:00 and 05:00.": "At least 40% of your watch time falls between 23:00 and 05:00.", + "Early Bird": "Early Bird", + "At least 40% of your watch time falls between 05:00 and 10:00.": "At least 40% of your watch time falls between 05:00 and 10:00.", + "Weekend Warrior": "Weekend Warrior", + "At least 60% of your watch time falls on weekends.": "At least 60% of your watch time falls on weekends.", + "Marathoner": "Marathoner", + "Your longest run includes at least six watched hours.": "Your longest run includes at least six watched hours.", + "Binge Watcher": "Binge Watcher", + "TV dominates your viewing and your longest run spans at least three records and three watched hours.": "TV dominates your viewing and your longest run spans at least three records and three watched hours.", + "Movie Buff": "Movie Buff", + "Movies account for at least 80% of your watch time.": "Movies account for at least 80% of your watch time.", + "Series Devotee": "Series Devotee", + "TV accounts for at least 80% of your watch time.": "TV accounts for at least 80% of your watch time.", + "Comfort Rewatcher": "Comfort Rewatcher", + "At least half your records revisit a previously seen title or episode.": "At least half your records revisit a previously seen title or episode.", + "Genre Explorer": "Genre Explorer", + "You watched at least six known genres with meaningful watch time.": "You watched at least six known genres with meaningful watch time.", + "Prime-Time Regular": "Prime-Time Regular", + "At least half your watch time falls between 19:00 and 23:00.": "At least half your watch time falls between 19:00 and 23:00.", + "Daily Viewer": "Daily Viewer", + "You watched on at least 180 different dates.": "You watched on at least 180 different dates.", + "Seasonal Viewer": "Seasonal Viewer", + "At least 40% of your watch time falls in one month.": "At least 40% of your watch time falls in one month.", + "Short-Session Viewer": "Short-Session Viewer", + "Your average uninterrupted record lasts at most 25 minutes.": "Your average uninterrupted record lasts at most 25 minutes.", + "Balanced Viewer": "Balanced Viewer", + "Your viewing spans several habits without one of the stronger patterns dominating.": "Your viewing spans several habits without one of the stronger patterns dominating.", + "Jellystat raw finalized activity": "Jellystat raw finalized activity", + "Unknown": "Unknown", + "h": "h", + "Jellyfin": "Jellyfin", + "View my recap": "View my recap", + "{month} {year}": "{month} {year}", + "Genre radar chart": "Genre radar chart" + } + } + } +}
M jellyfin-year-review/web/src/routes/+layout.svelte → jellyfin-year-review/web/src/routes/+layout.svelte

@@ -1,2 +1,11 @@

-<script>import '../app.css'; let { children } = $props();</script> +<script> + import { onMount } from 'svelte'; + import { locale, direction } from '../lib/i18n.js'; + import '../app.css'; + let { children } = $props(); + onMount(() => { + document.documentElement.lang = locale; + document.documentElement.dir = direction; + }); +</script> {@render children()}
M jellyfin-year-review/web/src/routes/[...path]/+page.svelte → jellyfin-year-review/web/src/routes/[...path]/+page.svelte

@@ -1,9 +1,13 @@

<script> import { onMount } from 'svelte'; + import { t, message, locale } from '../../lib/i18n.js'; import Poster from '../../lib/Poster.svelte'; + import PlatformBreakdown from '../../lib/PlatformBreakdown.svelte'; import Chart from '../../lib/Chart.svelte'; + import RadarChart from '../../lib/RadarChart.svelte'; import ViewingTimeline from '../../lib/ViewingTimeline.svelte'; import RankingPosition from '../../lib/RankingPosition.svelte'; + import Confetti from '../../lib/Confetti.svelte'; let user = $state(null), years = $state([]), year = $state(''), review = $state(null); let loading = $state(true), signingIn = $state(false), error = $state(''), notice = $state('');

@@ -11,37 +15,43 @@ 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' } + { id: 0, name: t("Your viewing recap") }, + { id: 1, name: t("Your watch time"), availability: 'exactWatchTime' }, + { id: 2, name: t("Movies watched") }, + { id: 3, name: t("TV shows watched") }, + { id: 4, name: t("Unfinished movies"), availability: 'completion' }, + { id: 14, name: t("Shows you've watched are from platforms...") }, + { id: 5, name: t("Your top genres") }, + { id: 6, name: t("Viewing by hour") }, + { id: 7, name: t("Viewing by weekday") }, + { id: 8, name: t("Viewing by month") }, + { id: 9, name: t("Your devices and apps") }, + { id: 10, name: t("Your longest viewing session"), availability: 'marathon' }, + { id: 11, name: t("Your viewing profile"), availability: 'personality' }, + { id: 12, name: t("It's not a competition... but this is your watch-time ranking"), availability: 'ranking' }, + { id: 13, name: t("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 months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'].map(value => t(value)); + const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(value => t(value)); + const count = (n) => new Intl.NumberFormat(locale).format(n); + const hours = (n) => new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(n / 3600); + const duration = (n) => t("{hours} h", { hours: hours(n) }); 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(); + let res, body; + try { + res = await fetch(path, { credentials: 'same-origin', ...options }); + body = await res.json(); + } catch { + throw new Error(t("The request could not be completed.")); + } 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.'); + throw Object.assign(new Error(message(body.error) || t("The request could not be completed.")), { status: res.status }); } return body; }

@@ -52,7 +62,7 @@ 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.'); + if (data.schemaVersion !== 1) throw new Error(t("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; }

@@ -66,7 +76,7 @@ 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; } + } catch (e) { if (user || e.status !== 401) error = e.message; } finally { loading = false; } } async function login(event) {

@@ -100,17 +110,17 @@ }

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:head><title>{t("Jellyfin Year Review")}</title><meta name="description" content={t("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> + <a class="brand" href="/" aria-label={t("Jellyfin Year Review home")}><span class="brand-icon">{t("J")}</span><span>{t("Jellyfin")} <span class="brand-light">{t("Year Review")}</span><small>{t("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> + {#if years.length > 1}<label class="year-picker">{t("Year")}<select aria-label={t("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}>{t("Sign out")}</button> </div> {/if} </header>

@@ -119,68 +129,80 @@

<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 error}<div class="message error" role="alert">{error}<button class="quiet" onclick={bootstrap}>{t("Try again")}</button></div>{/if} {/if} {#if !user} - <section class="login-layout" aria-label="Sign in"> + <section class="login-layout" aria-label={t("Sign in")}> <form class="login-panel" onsubmit={login}> - <h1>Your year,<br />replayed.</h1><p>Sign in with your existing Jellyfin account.</p> + <h1 style="white-space: pre-line">{t("Your year,\nreplayed.")}</h1><p>{t("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> + <label for="username">{t("Jellyfin username")}</label><input id="username" name="username" autocomplete="username" required maxlength="256" bind:value={username} /> + <label for="password">{t("Password")}</label><input id="password" name="password" type="password" autocomplete="current-password" bind:value={password} /> + <button class="primary" type="submit" disabled={loading || signingIn}>{loading ? t("Loading…") : signingIn ? t("Signing in…") : t("Roll the credits")} <span aria-hidden="true">↗</span></button> + <small class="login-note">{authMode === 'offline' ? t("Your credentials are checked locally against a private Jellyfin account snapshot. Password changes take effect after the operator refreshes it.") : t("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> + <section class="state-screen"><span class="loading-orbit" aria-hidden="true"></span><h1>{t("Setting the scene…")}</h1><p role="status">{t("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> + <section class="state-screen"><p class="eyebrow">{t("NOT QUITE SHOWTIME")}</p><h1>{t("Your story is on its way.")}</h1><p>{t("No review has been generated for your account. Ask your server operator to generate a year.")}</p><button class="primary" onclick={bootstrap}>{t("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}> + <nav class="progress" aria-label={t("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={t("carousel")} aria-label={t("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> + <p class="eyebrow">{t("CHAPTER {number} / {name}", { number: String(slide + 1).padStart(2, '0'), name: cards[slide].name.toLocaleUpperCase(locale) })}</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> + <span class="year-stamp">{review.year}</span><h1>{t("Your viewing recap")}</h1><p class="story-lede">{t(review.records ? "{name}, see the movies, TV shows, and viewing habits that made up your year." : "{name}, no viewing activity was recorded for this year.", { name: user.name })}</p><button class="primary" onclick={() => navigate(1)}>{t("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> + <h2>{t("Your watch time")}</h2><div class="big-stat">{hours(review.seconds)}<span>{t(review.availability.exactWatchTime.available ? "hours watched" : "hours watched · end-attributed estimate")}</span></div> + <div class="stat-grid"><div><b>{duration(review.movieSeconds)}</b><span>{t("Movies")}</span></div><div><b>{duration(review.tvSeconds)}</b><span>{t("Television")}</span></div><div><b>{count(review.records)}</b><span>{t("Activity records")}</span></div><div><b>{count(review.uniqueTitles)}</b><span>{t("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'} /> + <h2>{card === 2 ? t("Movies watched") : t("TV shows watched")}</h2> + <p>{t("Everything you watched in {year}, grouped by month.", { year: review.year })}</p> + <ViewingTimeline {items} year={review.year} kind={card === 2 ? 'movies' : '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} + <h2>{t("Unfinished movies")}</h2><p class="story-lede">{t("Movies where you watched less than {percent}% of the runtime.", { percent: review.rules.completionPercent })}</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>{t("{percent}% of the movie watched", { percent: Math.round(item.completionPercent) })}</p></div></li>{/each}</ol>{:else}<p class="empty-note">{review.availability.completion.available ? t("No unfinished movies found in supported records.") : t("Completion is unavailable without runtime and actual position ranges.")}</p>{/if} + {:else if card === 14} + <h2>{t("Shows you've watched are from platforms...")}</h2> + <p>{t("Distinct TV shows watched, grouped by platform.")}</p> + <PlatformBreakdown platforms={review.platforms ?? []} records={review.records} /> {: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} + <h2>{t("Your top genres")}</h2><p>{t("Genres ranked by watch time.")}</p> + {#if review.genres.length} + {@const values = review.genres.slice(0, 7).map(v => v.seconds / 3600)} + {@const labels = review.genres.slice(0, 7).map(v => message(v.name))} + <Chart {values} {labels} metric={t("h")} /> + <RadarChart {values} {labels} metric={t("h")} /> + {:else}<p class="empty-note">{t("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> + <h2>{t("Viewing by hour")}</h2><p>{t("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' ? t("min") : t("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'} /> + <h2>{t("Viewing by weekday")}</h2><p>{t("Your viewing activity for each day of the week.")}</p><Chart values={[...review.weekdays.slice(1), review.weekdays[0]]} labels={weekdays} metric={review.distributionMetric === 'watched minutes' ? t("min") : t("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'} /> + <h2>{t("Viewing by month")}</h2><p>{t("Your viewing activity for each month of {year}.", { year: review.year })}</p><Chart values={review.monthly} labels={months} metric={review.distributionMetric === 'watched minutes' ? t("min") : t("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> + <h2>{t("Your devices and apps")}</h2><p>{t("Your most-used devices and apps, ranked by watch time.")}</p><div class="two-columns"><div><h3>{t("Devices")}</h3><Chart values={review.devices.slice(0, 5).map(v => v.seconds / 3600)} labels={review.devices.slice(0, 5).map(v => message(v.name))} metric={t("h")} /></div><div><h3>{t("Apps & clients")}</h3><Chart values={review.clients.slice(0, 5).map(v => v.seconds / 3600)} labels={review.clients.slice(0, 5).map(v => message(v.name))} metric={t("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} + <h2>{t("Your longest viewing session")}</h2>{#if review.marathon}<div class="big-stat">{hours(review.marathon.watchedSeconds)}<span>{t("hours watched in your longest session")}</span></div><p class="story-lede">{t("{duration} elapsed · {records} records", { duration: duration(review.marathon.elapsedSeconds), records: count(review.marathon.records) })}</p><p>{new Date(review.marathon.start).toLocaleString(locale, { timeZone: review.timezone })}</p>{:else}<p class="empty-note">{message(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} + <h2>{t("Your viewing profile")}</h2>{#if review.personality}<span class="personality-symbol" aria-hidden="true">✳</span><h3 class="personality-name">{message(review.personality.name)}</h3><p class="story-lede">{message(review.personality.explanation)}</p>{:else}<p class="empty-note">{message(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} + <h2>{t("It's not a competition... but this is your watch-time ranking")}</h2><p>{t("Your total watch time compared with other eligible viewers.")}</p>{#if review.ranking}<div class="big-stat">#{review.ranking.rank}<span>{t("of {count} eligible viewers", { count: count(review.ranking.cohort) })}</span></div><RankingPosition ranking={review.ranking} /><div class="stat-grid"><div><b>{Math.round(review.ranking.percentile)}%</b><span>{t("Viewers with less watch time")}</span></div><div><b>{duration(review.seconds)}</b><span>{t("Your total hours watched · movies & TV shows")}</span></div></div>{:else}<p class="empty-note">{message(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> + <p class="year-stamp">{t("SUMMARY / {year}", { year: review.year })}</p><h2>{t("Your year in summary")}</h2><div class="stat-grid">{#if review.availability.exactWatchTime.available !== false}<div><b>{duration(review.seconds)}</b><span>{t("Your watch time")}</span></div>{/if}{#if review.availability.personality.available !== false}<div><b>{message(review.personality?.name) || t("No viewing profile")}</b><span>{t("Your viewing profile")}</span></div>{/if}</div><p class="story-lede">{t("You have reached the end of your viewing recap.")}</p><button class="primary" onclick={() => slide = 0}>{t("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> + <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={t("Previous chapter")} onclick={() => navigate(-1)} disabled={slide === 0}>←</button><button aria-label={t("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} + <details class="source-notes"><summary>{t("Behind your review · source & availability")}</summary><p>{message(review.source)}</p><p>{message(review.coverage)}</p><p>{t("Snapshot: {date} · Format v{version}", { date: new Date(review.generatedAt).toISOString(), version: review.schemaVersion })}</p>{#each review.warnings as warning}<p>{message(warning)}</p>{/each}</details> + {:else}<section class="state-screen"><h1>{t("Let’s try that again.")}</h1><button class="primary" onclick={bootstrap}>{t("Reload review")}</button></section>{/if} </main> - {#if user}<footer><span>MADE FOR THE LOVE OF THE WATCH.</span></footer>{/if} + {#if user}<footer><span>{t("MADE FOR THE LOVE OF THE WATCH.")}</span></footer>{/if} </div> + +{#if user && !loading && review?.ranking && card === 12}<Confetti />{/if}
M jellyfin-year-review/web/tests/review.spec.js → jellyfin-year-review/web/tests/review.spec.js

@@ -61,28 +61,30 @@ 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 expect(chapters.getByRole('button')).toHaveCount(10); + await expect(chapters.getByRole('button', { name: /Your watch time|Unfinished movies|Your longest viewing session|Your viewing profile|It's not a competition\.\.\. but this is 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: "Shows you've watched are from platforms..." })).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.locator('.story-bottom')).toContainText('10 / 10'); 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(chapters.getByRole('button')).toHaveCount(15); 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(chapters.getByRole('button')).toHaveCount(10); await expect(page.getByRole('heading', { name: 'Your viewing recap' })).toBeVisible(); });

@@ -124,6 +126,42 @@ 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('ranking celebrates on entry, clears on exit, and respects reduced motion', async ({ page }) => { + await page.route('**/api/review?year=*', async route => { + const response = await route.fetch(); + const review = await response.json(); + review.ranking = { rank: 3, cohort: 6, percentile: 40 }; + review.availability.ranking.available = true; + await route.fulfill({ response, json: review }); + }); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + 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(); + await expect(page.getByRole('heading', { name: 'Your viewing recap' })).toBeVisible(); + const confetti = page.locator('.confetti'); + await expect(confetti).toHaveCount(0); + await page.getByRole('button', { name: /It's not a competition\.\.\. but this is your watch-time ranking$/ }).click(); + await expect(confetti).toBeVisible(); + await expect(confetti).toHaveAttribute('aria-hidden', 'true'); + expect(await confetti.evaluate(element => { + const animations = element.getAnimations({ subtree: true }); + if (!animations.length) return false; + for (const animation of animations) animation.finish(); + return [...element.children].every(piece => piece.getBoundingClientRect().top > innerHeight); + })).toBeTruthy(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy(); + await page.getByRole('button', { name: 'Next chapter' }).click(); + await expect(confetti).toHaveCount(0); + await page.getByRole('button', { name: 'Previous chapter' }).click(); + await expect(confetti).toBeVisible(); + expect(await confetti.evaluate(element => element.getAnimations({ subtree: true }).some(animation => animation.playState === 'running'))).toBeTruthy(); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await expect(confetti).toBeHidden(); + await expect(page.getByRole('heading', { name: "It's not a competition... but this is your watch-time ranking" })).toBeVisible(); }); test('anonymous ranking highlights your position and handles ties and edge positions', async ({ page }) => {

@@ -150,7 +188,7 @@ { 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 page.getByRole('button', { name: /It's not a competition\.\.\. but this is 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);

@@ -163,3 +201,49 @@ if (sample.cohort === 1) await expect(chart).toContainText('You are the only eligible viewer.');

expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy(); } }); + +test('platform card shows only TV show counts and handles missing data', async ({ page }) => { + let mode = 'populated'; + await page.route('**/api/review?year=*', async route => { + const response = await route.fetch(); + const review = await response.json(); + if (mode === 'populated' || mode === 'old-label') review.platforms = [ + { name: 'Netflix', movies: 3, shows: 2 }, + { name: 'HBO', movies: 0, shows: 1 }, + { name: 'Apple TV+', movies: 1, shows: 0 }, + { name: 'Other', movies: 20, shows: 1 } + ]; + else delete review.platforms; + if (mode === 'old-label') { + review.platforms.pop(); + review.platforms.unshift({ name: 'Unknown platform', movies: 20, shows: 1 }); + } + if (mode === 'empty') review.records = 0; + 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(); + await page.getByRole('button', { name: /Shows you've watched are from platforms\.\.\.$/ }).click(); + const table = page.getByRole('table', { name: 'TV shows watched by platform' }); + await expect(table.getByRole('columnheader')).toHaveText(['Platform', 'TV shows']); + for (const [name, shows] of [['Netflix', '2'], ['HBO', '1'], ['Apple TV+', '0'], ['Other', '1']]) { + const row = table.getByRole('row').filter({ has: page.getByRole('rowheader', { name, exact: true }) }); + await expect(row.getByRole('cell')).toHaveText([shows]); + } + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy(); + await expect(table.getByRole('rowheader').last()).toHaveText('Other'); + mode = 'old-label'; + await page.reload(); + await page.getByRole('button', { name: /Shows you've watched are from platforms\.\.\.$/ }).click(); + await expect(table.getByRole('rowheader')).toHaveText(['Netflix', 'HBO', 'Apple TV+', 'Other']); + mode = 'legacy'; + await page.reload(); + await page.getByRole('button', { name: /Shows you've watched are from platforms\.\.\.$/ }).click(); + await expect(page.getByText('Platform data is unavailable for this review. Regenerate the year to include it.')).toBeVisible(); + mode = 'empty'; + await page.reload(); + await page.getByRole('button', { name: /Shows you've watched are from platforms\.\.\.$/ }).click(); + await expect(page.getByText('No TV shows watched this year.')).toBeVisible(); +});
A jellyfin-year-review/web/unit/i18n.test.js

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

+import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import translations from '../src/lib/translations.json' with { type: 'json' }; +import { t, message } from '../src/lib/i18n.js'; + +// Exercise edits to the same catalog the app imports, restoring it after each test. +function withTranslations(values, run) { + const catalog = translations.languages[translations.language]; + const original = { ...catalog.strings }; + Object.assign(catalog.strings, values); + try { run(); } + finally { catalog.strings = original; } +} + +test('top-level language selects English or Catalan labels and formatting', async () => { + const original = translations.language; + try { + for (const [language, label, formatted] of [['en', 'Sign out', '1.5'], ['ca', 'Tanca la sessió', '1,5']]) { + translations.language = language; + const active = await import(`../src/lib/i18n.js?selection=${language}`); + assert.equal(active.t('Sign out'), label); + assert.equal(new Intl.NumberFormat(active.locale).format(1.5), formatted); + assert.equal(active.direction, translations.languages[language].direction); + } + } finally { translations.language = original; } +}); + +test('an unknown language reports a configuration error', async () => { + const original = translations.language; + translations.language = 'missing'; + try { + await assert.rejects(import('../src/lib/i18n.js?selection=missing'), /Unknown translation language: missing/); + } finally { translations.language = original; } +}); + +test('catalog edits replace labels and allow reordered, repeated placeholders', () => { + withTranslations({ + 'Sign out': 'Sair', + 'You: rank {rank} of {count}': 'Entre {count}, você está em {rank} de {count}' + }, () => { + assert.equal(t('Sign out'), 'Sair'); + assert.equal(t('You: rank {rank} of {count}', { rank: 3, count: 6 }), 'Entre 6, você está em 3 de 6'); + }); +}); + +test('missing translations fall back to English and inserted data stays literal', () => { + assert.equal(t('Unlisted text for {name}', { name: '<b>{year}</b>', year: 2025 }), 'Unlisted text for <b>{year}</b>'); + assert.equal(t('constructor'), 'constructor'); + assert.equal(t('Missing {value}'), 'Missing {value}'); + const catalog = translations.languages[translations.language]; + const original = catalog.strings['Sign out']; + delete catalog.strings['Sign out']; + try { assert.equal(t('Sign out'), 'Sign out'); } + finally { catalog.strings['Sign out'] = original; } +}); + +test('review messages, profiles and counted warnings use translated strings', () => { + withTranslations({ + 'Night Owl': 'Coruja noturna', + 'Unable to sign in with those credentials.': 'Credenciais inválidas.', + '{count} records excluded because media metadata was deleted or unavailable.': 'Registros excluídos: {count}.' + }, () => { + assert.equal(message('Night Owl'), 'Coruja noturna'); + assert.equal(message('Unable to sign in with those credentials.'), 'Credenciais inválidas.'); + assert.equal(message('12 records excluded because media metadata was deleted or unavailable.'), 'Registros excluídos: 12.'); + assert.equal(message('Custom source description'), 'Custom source description'); + assert.equal(message(undefined), ''); + }); +}); + +test('translations preserve all named placeholders', () => { + const placeholders = text => [...text.matchAll(/\{\w+\}/g)].map(match => match[0]).sort(); + for (const [language, catalog] of Object.entries(translations.languages)) { + for (const [english, translated] of Object.entries(catalog.strings)) { + assert.deepEqual([...new Set(placeholders(translated))], [...new Set(placeholders(english))], `${language}: ${english}`); + } + } +});
A jellyfin-year-review/web/unit/radar.test.js

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

+import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { radarGeometry } from '../src/lib/radar.js'; + +test('radar preserves genre order and scales watch time from zero to the largest value', () => { + const values = [20, 10, 5, 0]; + const { max, axes } = radarGeometry(values); + assert.equal(max, 20); + for (let i = 0; i < values.length; i++) { + assert.ok(Math.abs(Math.hypot(axes[i].valueX, axes[i].valueY) - values[i] / 20) < 1e-10); + } + assert.ok(axes[0].y < 0 && Math.abs(axes[0].x) < 1e-10); + assert.ok(axes[1].x > 0 && Math.abs(axes[1].y) < 1e-10); +}); + +test('empty, zero and sparse data keep the radar grid and points finite', () => { + for (const values of [[], [0, 0, 0], [4], [4, 2]]) { + const { max, axes } = radarGeometry(values); + assert.ok(max > 0); + assert.ok(axes.length >= 3); + assert.ok(axes.every(axis => Object.values(axis).every(Number.isFinite))); + assert.ok(axes.slice(values.length).every(axis => axis.valueX === 0 && axis.valueY === 0)); + } +});