jellyfin-news: add recently watched movies and shows, from Jellystat api
arnaucube git@arnaucube.com
Wed, 09 Sep 2026 09:37:23 +0200
9 files changed,
645 insertions(+),
28 deletions(-)
jump to
M
server-dockers/jellyfin-news/.env.example
→
server-dockers/jellyfin-news/.env.example
@@ -18,3 +18,13 @@ TEXT_EPISODES=episodes
TEXT_UNKNOWN_SHOW=Unknown show TEXT_NO_WEEKLY_ITEMS=No new movies or show episodes this week. TEXT_NO_MONTHLY_ITEMS=No new movies or show episodes last month. + +# Optional: enable watched reports using Jellystat's API key (Settings > API keys). +JELLYSTAT_URL= +JELLYSTAT_API_KEY= + +# Watched report labels. TEXT_MOVIES and TEXT_SHOWS above are reused. +TEXT_WATCHED_BUTTON=Watched in selected period +TEXT_WATCHED_UPDATE=jellyfin watched recap +TEXT_NO_WATCHED_ITEMS=No movies or shows watched in this period. +TEXT_USERS=users
M
server-dockers/jellyfin-news/README.md
→
server-dockers/jellyfin-news/README.md
@@ -1,6 +1,6 @@
# Jellyfin update webpage -A small Go web server for generating copyable Jellyfin movie and episode recaps. It serves one embedded `index.html` page, a database, or third-party Go dependencies. +A small Go web server for generating copyable Jellyfin movie and episode recaps. It provides both: newly added movies and shows, and recently watched movies and shows. It serves one embedded `index.html` page, a database, or third-party Go dependencies. The page provides:
M
server-dockers/jellyfin-news/digest.go
→
server-dockers/jellyfin-news/digest.go
@@ -20,6 +20,10 @@ }
// Labels contains every translatable phrase used in generated recap text. type Labels struct { + WatchedUpdate string + NoWatchedItems string + Users string + WatchedButton string WeeklyUpdate string MonthlyUpdate string Movies string@@ -35,6 +39,10 @@ }
func DefaultLabels() Labels { return Labels{ + WatchedUpdate: "jellyfin watched recap", + NoWatchedItems: "No movies or shows watched in this period.", + Users: "users", + WatchedButton: "Watched in selected period", WeeklyUpdate: "jellyfin weekly update", MonthlyUpdate: "jellyfin monthly update", Movies: "Movies",
M
server-dockers/jellyfin-news/index.html
→
server-dockers/jellyfin-news/index.html
@@ -8,47 +8,57 @@ <style>
body { max-width: 760px; margin: 2rem auto; padding: 0 1rem; font: 16px/1.5 system-ui, sans-serif; color: #202124; } h1 { margin-bottom: .25rem; } .controls { display: flex; flex-wrap: wrap; gap: .65rem; margin: 1.5rem 0; align-items: center; } - button, input { font: inherit; padding: .6rem .8rem; } + button, input, select { font: inherit; padding: .6rem .8rem; } button { cursor: pointer; } input { width: 5rem; } #status { min-height: 1.5rem; color: #555; } textarea { box-sizing: border-box; width: 100%; min-height: 22rem; padding: 1rem; font: 15px/1.5 ui-monospace, monospace; resize: vertical; } + [hidden] { display: none; } .copy { margin-top: .65rem; } </style> </head> <body> <h1>Jellyfin updates</h1> - <p>Choose a period, then copy the generated recap.</p> + <p>Choose a time period, then generate a newly added or watched recap.</p> <div class="controls"> - <button data-report="/api/current-week">Current week</button> + <label for="period">Time period:</label> + <select id="period"> + <option value="current-week">Current week</option> + <option value="week">Past week</option> + <option value="month">Past month</option> + </select> </div> - <div class="controls"> + <div id="week-controls" class="controls" hidden> <label for="week-offset">Week offset:</label> <input id="week-offset" type="number" min="-1200" max="-1" value="-1" required> - <button id="selected-week">Load selected week</button> <span>−1 is last week, −2 is two weeks ago</span> </div> - <div class="controls"> + <div id="month-controls" class="controls" hidden> <label for="month-offset">Month offset:</label> <input id="month-offset" type="number" min="-1200" max="-1" value="-1" required> - <button id="selected-month">Load selected month</button> <span>−1 is last month, −2 is two months ago</span> </div> + <div class="controls"> + <button id="added">Newly added in selected period</button> + <button id="watched">{{WATCHED_BUTTON}}</button> + </div> + <p id="status" role="status"></p> <textarea id="output" readonly aria-label="Jellyfin update output" placeholder="Your recap will appear here"></textarea> <button id="copy" class="copy">Copy to clipboard</button> <script> + const period = document.querySelector('#period'); const output = document.querySelector('#output'); const status = document.querySelector('#status'); - const buttons = [...document.querySelectorAll('button')]; + const controls = [...document.querySelectorAll('button, input, select')]; async function loadReport(url) { - buttons.forEach(button => button.disabled = true); + controls.forEach(control => control.disabled = true); status.textContent = 'Loading…'; try { const response = await fetch(url, { headers: { Accept: 'text/plain' } });@@ -59,23 +69,35 @@ status.textContent = 'Recap ready.';
} catch (error) { status.textContent = error.message; } finally { - buttons.forEach(button => button.disabled = false); + controls.forEach(control => control.disabled = false); } } - document.querySelectorAll('[data-report]').forEach(button => { - button.addEventListener('click', () => loadReport(button.dataset.report)); - }); + // Period changes only update the selection; either report can run first. + function updatePeriod() { + document.querySelector('#week-controls').hidden = period.value !== 'week'; + document.querySelector('#month-controls').hidden = period.value !== 'month'; + output.value = ''; + status.textContent = ''; + } + period.addEventListener('change', updatePeriod); + document.querySelectorAll('input').forEach(input => input.addEventListener('input', updatePeriod)); + updatePeriod(); - document.querySelector('#selected-week').addEventListener('click', () => { - const offset = document.querySelector('#week-offset').value; - loadReport(`/api/week?offset=${encodeURIComponent(offset)}`); - }); - - document.querySelector('#selected-month').addEventListener('click', () => { - const offset = document.querySelector('#month-offset').value; - loadReport(`/api/month?offset=${encodeURIComponent(offset)}`); - }); + function generateReport(watched) { + const selectedPeriod = period.value; + const params = new URLSearchParams(); + if (selectedPeriod !== 'current-week') { + const offset = document.querySelector(`#${selectedPeriod}-offset`); + if (!offset.reportValidity()) return; + params.set('offset', offset.value); + } + if (watched) params.set('period', selectedPeriod); + const path = watched ? '/api/watched' : `/api/${selectedPeriod}`; + loadReport(`${path}${params.size ? `?${params}` : ''}`); + } + document.querySelector('#added').addEventListener('click', () => generateReport(false)); + document.querySelector('#watched').addEventListener('click', () => generateReport(true)); document.querySelector('#copy').addEventListener('click', async () => { if (!output.value) return;
A
server-dockers/jellyfin-news/index_test.js
@@ -0,0 +1,78 @@
+// Run with node --test index_test.js; no browser dependencies are required. +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +// Exercise the page's actual handlers against a minimal DOM and fetch stub. +function page() { + const ids = ['period', 'week-controls', 'month-controls', 'week-offset', 'month-offset', 'added', 'watched', 'copy', 'output', 'status']; + const elements = Object.fromEntries(ids.map(id => [id, { + value: id === 'period' ? 'current-week' : '-1', + events: {}, valid: true, + addEventListener(event, handler) { this.events[event] = handler; }, + reportValidity() { return this.valid; }, + }])); + const requests = []; + const script = fs.readFileSync(`${__dirname}/index.html`, 'utf8').split('<script>')[1].split('</script>')[0]; + vm.runInNewContext(script, { + URLSearchParams, + document: { + querySelector: selector => elements[selector.slice(1)], + querySelectorAll: selector => selector === 'input' + ? [elements['week-offset'], elements['month-offset']] : Object.values(elements), + }, + fetch: async url => { requests.push(url); return { ok: true, text: async () => 'recap' }; }, + }); + return { elements, requests }; +} + +test('period selection fetches nothing and watched can run first for every period', () => { + for (const period of ['current-week', 'week', 'month']) { + const { elements, requests } = page(); + elements.output.value = 'old recap'; + elements.period.value = period; + elements.period.events.change(); + assert.deepEqual(requests, []); + assert.equal(elements.output.value, ''); + assert.equal(elements['week-controls'].hidden, period !== 'week'); + assert.equal(elements['month-controls'].hidden, period !== 'month'); + elements.watched.events.click(); + const url = new URL(requests[0], 'http://localhost'); + assert.equal(url.pathname, '/api/watched'); + assert.equal(url.searchParams.get('period'), period); + assert.equal(url.searchParams.get('offset'), period === 'current-week' ? null : '-1'); + } +}); + +test('both report actions use the explicit period and edited offset', () => { + for (const period of ['current-week', 'week', 'month']) { + for (const action of ['added', 'watched']) { + const { elements, requests } = page(); + elements.period.value = period; + elements.period.events.change(); + if (period !== 'current-week') { + elements[`${period}-offset`].value = '-3'; + elements[`${period}-offset`].events.input(); + } + assert.equal(requests.length, 0); + elements[action].events.click(); + const url = new URL(requests[0], 'http://localhost'); + assert.equal(url.pathname, action === 'added' ? `/api/${period}` : '/api/watched'); + assert.equal(url.searchParams.get('offset'), period === 'current-week' ? null : '-3'); + } + } +}); + +test('invalid offsets prevent either report, but do not affect current week', () => { + for (const action of ['added', 'watched']) { + const { elements, requests } = page(); + elements.period.value = 'week'; + elements['week-offset'].valid = false; + elements[action].events.click(); + assert.equal(requests.length, 0); + elements.period.value = 'current-week'; + elements[action].events.click(); + assert.equal(requests.length, 1); + } +});
A
server-dockers/jellyfin-news/jellystat.go
@@ -0,0 +1,223 @@
+package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" +) + +type JellystatClient struct { + baseURL, apiKey string + http *http.Client +} + +// NewJellystat validates the optional playback-history connection. +func NewJellystat(baseURL, apiKey string) (*JellystatClient, error) { + u, err := url.Parse(strings.TrimRight(strings.TrimSpace(baseURL), "/")) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return nil, fmt.Errorf("invalid JELLYSTAT_URL") + } + if strings.TrimSpace(apiKey) == "" { + return nil, fmt.Errorf("JELLYSTAT_API_KEY is required when JELLYSTAT_URL is set") + } + return &JellystatClient{u.String(), strings.TrimSpace(apiKey), &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil +} + +type playback struct { + UserID string `json:"UserId"` + ItemID string `json:"NowPlayingItemId"` + Date string `json:"ActivityDateInserted"` + Results []playback `json:"results"` +} + +type WatchedTitle struct { + ID, Type, Name string + Users int +} + +// request keeps upstream bodies and connection details out of errors and logs. +func (c *JellystatClient) request(ctx context.Context, path string, body []byte, dst any) error { + method := http.MethodGet + if body != nil { + method = http.MethodPost + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create Jellystat request") + } + req.Header.Set("x-api-token", c.apiKey) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("Jellystat request failed") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("Jellystat returned HTTP %d", resp.StatusCode) + } + if err := json.NewDecoder(resp.Body).Decode(dst); err != nil { + return fmt.Errorf("invalid Jellystat response") + } + return nil +} + +// WatchedBetween checks individual plays, because date filters on getHistory apply +// to the latest play in each group and can hide earlier repeat viewings. +func (c *JellystatClient) WatchedBetween(ctx context.Context, start, end time.Time) ([]WatchedTitle, error) { + users := map[string]map[string]bool{} + fallback := map[string][]playback{} + for page := 1; ; page++ { + var response struct { + Pages int `json:"pages"` + Results []playback `json:"results"` + } + params := url.Values{"page": {strconv.Itoa(page)}, "size": {"200"}, "sort": {"ActivityDateInserted"}, "desc": {"true"}} + if err := c.request(ctx, "/api/getHistory?"+params.Encode(), nil, &response); err != nil { + return nil, err + } + if response.Results == nil { + return nil, fmt.Errorf("Jellystat history is missing results") + } + for _, group := range response.Results { + plays := group.Results + if len(plays) == 0 { + // Some Jellystat versions omit nested movie plays. Fetch the + // ungrouped item history instead of treating the latest play as complete. + if group.ItemID == "" { + return nil, fmt.Errorf("Jellystat playback is missing item ID") + } + var ok bool + plays, ok = fallback[group.ItemID] + if !ok { + var err error + plays, err = c.itemHistory(ctx, group.ItemID, start, end) + if err != nil { + return nil, err + } + fallback[group.ItemID] = plays + } + } + for _, play := range plays { + date, err := time.Parse(time.RFC3339Nano, play.Date) + if err != nil { + return nil, fmt.Errorf("invalid Jellystat playback timestamp") + } + if date.Before(start) || !date.Before(end) { + continue + } + id, user := play.ItemID, play.UserID + if id == "" { + id = group.ItemID + } + if user == "" { + user = group.UserID + } + if id == "" || user == "" { + return nil, fmt.Errorf("Jellystat playback is missing item or user ID") + } + if users[id] == nil { + users[id] = map[string]bool{} + } + users[id][user] = true + } + } + if page >= response.Pages { + break + } + if len(response.Results) == 0 { + return nil, fmt.Errorf("incomplete Jellystat history pagination") + } + } + var titles []WatchedTitle + for id, viewers := range users { + body, _ := json.Marshal(map[string]string{"Id": id}) + var details []struct{ Name, Type string } + if err := c.request(ctx, "/api/getItemDetails", body, &details); err != nil { + return nil, err + } + if len(details) == 0 { + return nil, fmt.Errorf("Jellystat item metadata is missing") + } + item := details[0] + if item.Type != "Movie" && item.Type != "Series" { + continue + } + titles = append(titles, WatchedTitle{id, item.Type, item.Name, len(viewers)}) + } + return titles, nil +} + +// itemHistory retrieves raw plays when a grouped response omits its history. +func (c *JellystatClient) itemHistory(ctx context.Context, id string, start, end time.Time) ([]playback, error) { + body, _ := json.Marshal(map[string]string{"itemid": id}) + filters, _ := json.Marshal([]map[string]string{{"field": "ActivityDateInserted", "min": start.UTC().Format(time.RFC3339Nano), "max": end.UTC().Format(time.RFC3339Nano)}}) + var plays []playback + for page := 1; ; page++ { + params := url.Values{"page": {strconv.Itoa(page)}, "size": {"200"}, "filters": {string(filters)}} + var response struct { + Pages int `json:"pages"` + Results []playback `json:"results"` + } + if err := c.request(ctx, "/api/getItemHistory?"+params.Encode(), body, &response); err != nil { + return nil, err + } + if response.Results == nil { + return nil, fmt.Errorf("Jellystat item history is missing results") + } + plays = append(plays, response.Results...) + if page >= response.Pages { + return plays, nil + } + if len(response.Results) == 0 { + return nil, fmt.Errorf("incomplete Jellystat item history pagination") + } + } +} + +// FormatWatched lists each title once without disclosing account identities. +func FormatWatched(start, end time.Time, monthly bool, titles []WatchedTitle, labels Labels) string { + period := start.Format("2006-01-02") + " - " + end.Format("2006-01-02") + if monthly { + period = start.Format("2006-01") + } + var out strings.Builder + fmt.Fprintf(&out, "%s %s:\n", period, labels.WatchedUpdate) + if len(titles) == 0 { + return out.String() + "\n" + labels.NoWatchedItems + } + sorted := append([]WatchedTitle(nil), titles...) + sort.Slice(sorted, func(i, j int) bool { + a, b := strings.ToLower(sorted[i].Name), strings.ToLower(sorted[j].Name) + if a == b { + return sorted[i].ID < sorted[j].ID + } + return a < b + }) + for _, section := range []struct{ kind, label string }{{"Movie", labels.Movies}, {"Series", labels.Shows}} { + heading := false + for _, title := range sorted { + if title.Type != section.kind { + continue + } + if !heading { + fmt.Fprintf(&out, "\n%s:\n", section.label) + heading = true + } + fmt.Fprintf(&out, "• %s", title.Name) + if title.Users > 1 { + fmt.Fprintf(&out, " — %d %s", title.Users, labels.Users) + } + out.WriteByte('\n') + } + } + return strings.TrimRight(out.String(), "\n") +}
A
server-dockers/jellyfin-news/jellystat_test.go
@@ -0,0 +1,154 @@
+package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestJellystatHistoryRepeatsPaginationAndTypes(t *testing.T) { + start := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) + end := start.AddDate(0, 1, 0) + play := func(id, user, date string) playback { return playback{ItemID: id, UserID: user, Date: date} } + calls := map[string]int{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("x-api-token") != "test-key" { + t.Error("missing API key") + } + if r.URL.Path == "/api/getHistory" { + if r.URL.Query().Get("filters") != "" { + t.Error("date filtering would hide historical repeats") + } + var rows []playback + switch r.URL.Query().Get("page") { + case "1": + rows = []playback{ + {ItemID: "movie", UserID: "private-user-a", Date: end.Format(time.RFC3339), Results: []playback{play("movie", "private-user-a", end.Format(time.RFC3339)), play("movie", "private-user-a", start.Format(time.RFC3339))}}, + play("show", "private-user-a", "2026-08-10T12:00:00Z"), + play("excluded", "private-user-a", end.Format(time.RFC3339)), + } + case "2": + rows = []playback{play("movie", "private-user-b", "2026-08-15T12:00:00Z"), play("show", "private-user-a", "2026-08-11T12:00:00Z"), play("music", "private-user-a", "2026-08-10T12:00:00Z")} + default: + t.Error("unexpected page") + } + for i := range rows { + if rows[i].Results == nil { + rows[i].Results = []playback{rows[i]} + } + } + json.NewEncoder(w).Encode(map[string]any{"pages": 2, "results": rows}) + return + } + if r.Method != http.MethodPost || r.URL.Path != "/api/getItemDetails" { + t.Errorf("unexpected request %s", r.URL.Path) + } + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + id := body["Id"] + calls[id]++ + kind := map[string]string{"movie": "Movie", "show": "Series", "music": "Audio"}[id] + json.NewEncoder(w).Encode([]map[string]string{{"Type": kind, "Name": id}}) + })) + defer server.Close() + client, err := NewJellystat(server.URL, "test-key") + if err != nil { + t.Fatal(err) + } + titles, err := client.WatchedBetween(context.Background(), start, end) + if err != nil { + t.Fatal(err) + } + got := FormatWatched(start, end, true, titles, DefaultLabels()) + want := "2026-08 jellyfin watched recap:\n\nMovies:\n• movie — 2 users\n\nShows:\n• show" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } + if calls["show"] != 1 || calls["excluded"] != 0 { + t.Fatalf("metadata calls = %v", calls) + } +} + +func TestJellystatFailures(t *testing.T) { + for _, tc := range []struct { + name, body string + status int + }{ + {"unauthorized", "secret upstream body", 401}, + {"invalid JSON", "not JSON", 200}, + {"missing results", `{}`, 200}, + {"bad timestamp", `{"pages":1,"results":[{"ActivityDateInserted":"bad"}]}`, 200}, + {"incomplete pagination", `{"pages":2,"results":[]}`, 200}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(tc.status); fmt.Fprint(w, tc.body) })) + defer server.Close() + client, _ := NewJellystat(server.URL, "test-key") + _, err := client.WatchedBetween(context.Background(), time.Now().Add(-time.Hour), time.Now()) + if err == nil || strings.Contains(err.Error(), "secret") { + t.Fatalf("error = %v", err) + } + }) + } +} + +func TestWatchedTranslations(t *testing.T) { + labels := DefaultLabels() + labels.WatchedUpdate = "Vistos" + labels.Users = "usuarios" + labels.Movies = "Películas" + labels.NoWatchedItems = "Nada" + now := time.Now() + got := FormatWatched(now, now, false, []WatchedTitle{{Name: "Example", Type: "Movie", Users: 2}}, labels) + for _, text := range []string{"Vistos:", "Películas:", "2 usuarios"} { + if !strings.Contains(got, text) { + t.Fatal(got) + } + } + if got := FormatWatched(now, now, false, nil, labels); !strings.HasSuffix(got, "\n\nNada") { + t.Fatal(got) + } +} + +func TestJellystatMissingGroupedHistory(t *testing.T) { + start := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) + end := start.AddDate(0, 1, 0) + pages := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/getHistory": + fmt.Fprint(w, `{"pages":1,"results":[{"NowPlayingItemId":"movie","UserId":"a","ActivityDateInserted":"2026-09-02T00:00:00Z","results":null}]}`) + case "/api/getItemHistory": + pages++ + if r.Method != "POST" || !strings.Contains(r.URL.Query().Get("filters"), "ActivityDateInserted") { + t.Error("missing raw history filters") + } + switch r.URL.Query().Get("page") { + case "1": + fmt.Fprint(w, `{"pages":2,"results":[{"NowPlayingItemId":"movie","UserId":"a","ActivityDateInserted":"2026-08-01T00:00:00Z"}]}`) + case "2": + fmt.Fprint(w, `{"pages":2,"results":[{"NowPlayingItemId":"movie","UserId":"b","ActivityDateInserted":"2026-08-15T00:00:00Z"},{"NowPlayingItemId":"movie","UserId":"c","ActivityDateInserted":"2026-09-01T00:00:00Z"}]}`) + default: + t.Error("unexpected page") + } + case "/api/getItemDetails": + fmt.Fprint(w, `[{"Name":"Movie","Type":"Movie"}]`) + default: + t.Error("unexpected endpoint") + } + })) + defer server.Close() + client, _ := NewJellystat(server.URL, "test-key") + titles, err := client.WatchedBetween(context.Background(), start, end) + if err != nil { + t.Fatal(err) + } + if len(titles) != 1 || titles[0].Users != 2 || pages != 2 { + t.Fatalf("titles=%v pages=%d", titles, pages) + } +}
M
server-dockers/jellyfin-news/main.go
→
server-dockers/jellyfin-news/main.go
@@ -5,6 +5,7 @@ "context"
_ "embed" "errors" "flag" + "html" "log" "net/http" "os"@@ -20,11 +21,16 @@ type itemSource interface {
AddedBetween(context.Context, time.Time, time.Time) ([]Item, error) } +type watchedSource interface { + WatchedBetween(context.Context, time.Time, time.Time) ([]WatchedTitle, error) +} + type app struct { - jellyfin itemSource - location *time.Location - labels Labels - now func() time.Time + jellystat watchedSource + jellyfin itemSource + location *time.Location + labels Labels + now func() time.Time } func main() {@@ -41,6 +47,12 @@ log.Fatalf("configuration error: %v", err)
} application := &app{jellyfin: client, location: location, labels: loadLabels(), now: time.Now} + if baseURL := os.Getenv("JELLYSTAT_URL"); strings.TrimSpace(baseURL) != "" { + application.jellystat, err = NewJellystat(baseURL, os.Getenv("JELLYSTAT_API_KEY")) + if err != nil { + log.Fatal(err) + } + } server := &http.Server{ Addr: *listen, Handler: application.routes(),@@ -61,12 +73,13 @@ mux.HandleFunc("GET /{$}", a.index)
mux.HandleFunc("GET /api/current-week", a.currentWeek) mux.HandleFunc("GET /api/week", a.week) mux.HandleFunc("GET /api/month", a.month) + mux.HandleFunc("GET /api/watched", a.watched) return mux } func (a *app) index(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write(indexHTML) + w.Write([]byte(strings.ReplaceAll(string(indexHTML), "{{WATCHED_BUTTON}}", html.EscapeString(a.labels.WatchedButton)))) } func (a *app) currentWeek(w http.ResponseWriter, r *http.Request) {@@ -147,6 +160,10 @@
func loadLabels() Labels { defaults := DefaultLabels() return Labels{ + WatchedUpdate: env("TEXT_WATCHED_UPDATE", defaults.WatchedUpdate), + NoWatchedItems: env("TEXT_NO_WATCHED_ITEMS", defaults.NoWatchedItems), + Users: env("TEXT_USERS", defaults.Users), + WatchedButton: env("TEXT_WATCHED_BUTTON", defaults.WatchedButton), WeeklyUpdate: env("TEXT_WEEKLY_UPDATE", defaults.WeeklyUpdate), MonthlyUpdate: env("TEXT_MONTHLY_UPDATE", defaults.MonthlyUpdate), Movies: env("TEXT_MOVIES", defaults.Movies),@@ -167,3 +184,41 @@ return "localhost" + address
} return address } + +// watched uses the same calendar boundaries as the added-content reports. +func (a *app) watched(w http.ResponseWriter, r *http.Request) { + now := a.now().In(a.location) + start, end := startOfWeek(now), now + period := r.URL.Query().Get("period") + switch period { + case "current-week": + case "week", "month": + offset, ok := readOffset(w, r) + if !ok { + return + } + if period == "week" { + start = start.AddDate(0, 0, offset*7) + end = start.AddDate(0, 0, 7) + } else { + start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, a.location).AddDate(0, offset, 0) + end = start.AddDate(0, 1, 0) + } + default: + http.Error(w, "Invalid report period.", http.StatusBadRequest) + return + } + if a.jellystat == nil { + http.Error(w, "Configure JELLYSTAT_URL and JELLYSTAT_API_KEY to use watched reports.", http.StatusServiceUnavailable) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 50*time.Second) + defer cancel() + titles, err := a.jellystat.WatchedBetween(ctx, start, end) + if err != nil { + log.Printf("Jellystat query failed: %v", err) + http.Error(w, "Could not query Jellystat. Check the server logs.", http.StatusBadGateway) + return + } + writeText(w, FormatWatched(start, end, period == "month", titles, a.labels)) +}
M
server-dockers/jellyfin-news/main_test.go
→
server-dockers/jellyfin-news/main_test.go
@@ -2,6 +2,7 @@ package main
import ( "context" + "fmt" "net/http" "net/http/httptest" "strings"@@ -116,3 +117,69 @@ if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "Jellyfin updates") {
t.Fatalf("status = %d, body = %q", response.Code, response.Body.String()) } } + +type fakeWatched struct { + start, end time.Time + err error +} + +func (f *fakeWatched) WatchedBetween(_ context.Context, start, end time.Time) ([]WatchedTitle, error) { + f.start, f.end = start, end + return []WatchedTitle{{ID: "movie", Name: "Example", Type: "Movie", Users: 2}}, f.err +} + +func TestWatchedPeriods(t *testing.T) { + for _, tc := range []struct{ query, start, end string }{ + {"period=current-week", "2026-08-31T00:00:00Z", "2026-09-02T12:00:00Z"}, + {"period=week&offset=-2", "2026-08-17T00:00:00Z", "2026-08-24T00:00:00Z"}, + {"period=month&offset=-1", "2026-08-01T00:00:00Z", "2026-09-01T00:00:00Z"}, + } { + t.Run(tc.query, func(t *testing.T) { + a := testApp(&fakeSource{}) + source := &fakeWatched{} + a.jellystat = source + response := httptest.NewRecorder() + a.routes().ServeHTTP(response, httptest.NewRequest("GET", "/api/watched?"+tc.query, nil)) + if response.Code != 200 || !strings.Contains(response.Body.String(), "Example — 2 users") { + t.Fatalf("response: %d %s", response.Code, response.Body.String()) + } + if source.start.Format(time.RFC3339) != tc.start || source.end.Format(time.RFC3339) != tc.end { + t.Fatalf("range: %v to %v", source.start, source.end) + } + if response.Header().Get("Cache-Control") != "no-store" { + t.Fatal("missing no-store") + } + }) + } +} + +func TestWatchedErrors(t *testing.T) { + for _, tc := range []struct { + query string + source watchedSource + status int + }{ + {"period=current-week", nil, 503}, + {"period=week&offset=0", nil, 400}, + {"period=invalid", nil, 400}, + {"period=current-week", &fakeWatched{err: fmt.Errorf("upstream failure")}, 502}, + } { + a := testApp(&fakeSource{}) + a.jellystat = tc.source + response := httptest.NewRecorder() + a.routes().ServeHTTP(response, httptest.NewRequest("GET", "/api/watched?"+tc.query, nil)) + if response.Code != tc.status { + t.Fatalf("status = %d, want %d", response.Code, tc.status) + } + } +} + +func TestWatchedButtonEscapesTranslation(t *testing.T) { + a := testApp(&fakeSource{}) + a.labels.WatchedButton = "<Watch & copy>" + response := httptest.NewRecorder() + a.routes().ServeHTTP(response, httptest.NewRequest("GET", "/", nil)) + if !strings.Contains(response.Body.String(), "<Watch & copy>") { + t.Fatal("button translation not escaped") + } +}