port jellyfin-news package to this repo
arnaucube git@arnaucube.com
Sun, 18 Oct 2026 10:05:13 +0200
18 files changed,
2564 insertions(+),
0 deletions(-)
jump to
A
jellyfin-news/.env.example
@@ -0,0 +1,35 @@
+JELLYFIN_URL=http://192.168.1.20:8096 +JELLYFIN_API_KEY=replace-with-a-jellyfin-api-key +# Optional: restrict results to libraries visible to this Jellyfin user. +JELLYFIN_USER_ID= + +TZ=Europe/London +LISTEN_ADDR=:8080 + +# Weekly boundary in TZ. Full weekday name and 24-hour HH:MM. +# To start weeks on Sunday at 20:00, use Sunday and 20:00 below. +WEEK_START_DAY=Monday +WEEK_START_TIME=00:00 + +# Optional: customize every phrase printed in copyable recaps. +TEXT_WEEKLY_UPDATE=jellyfin weekly update +TEXT_MONTHLY_UPDATE=jellyfin monthly update +TEXT_MOVIES=Movies +TEXT_SHOWS=Shows +TEXT_SEASON=Season +TEXT_SPECIALS=Specials +TEXT_EPISODE=episode +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
A
jellyfin-news/Dockerfile
@@ -0,0 +1,11 @@
+FROM golang:1.23-alpine AS build +WORKDIR /src +COPY go.mod *.go index.html ./ +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /jellyfin-news . + +FROM alpine:3.22 +RUN apk add --no-cache ca-certificates tzdata && adduser -D -u 10001 app +USER app +COPY --from=build /jellyfin-news /usr/local/bin/jellyfin-news +EXPOSE 8080 +ENTRYPOINT ["jellyfin-news"]
A
jellyfin-news/README.md
@@ -0,0 +1,62 @@
+# 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. + +The page provides: + +- **Current week**: from the configured weekly boundary through now (Monday at 00:00 by default). +- **Selected week**: `-1` means last week, `-2` means two weeks ago, and so on. +- **Selected month**: `-1` means last month, `-2` means two months ago, and so on. +- **Watched in selected period**: generates a Jellystat recap with watched seasons beside each show, for example `Andor (S1, S2)`. The prefix uses the first letter of `TEXT_SEASON`, so `TEXT_SEASON=Temporada` produces `T1, T2`. Seasons are included when available in playback history; season 0 is shown as `Specials`. +- **Copy to clipboard**: copies the generated plain-text recap. +- **Generate poster image**: previews a downloadable PNG collage for the selected week or month, with one poster per movie or show added during that period. Open **Add movies or shows to the poster image** to paste extra entries in the same `Movies:` / `Shows:` bullet format as a newly added recap. Extra titles are included only in the image; their artwork is matched by title (and movie year, when provided) against the Jellyfin library. Missing artwork gets a labelled placeholder. The automatic entries use Jellyfin's added date (`DateCreated`). + +Weekly output looks like: + +```text +2026-08-15 - 2026-08-22 jellyfin weekly update: + +Movies: +• Dune (2021) + +Shows: +• Example Show — Season 3, episodes 1-12 +``` + +## Configuration + +Copy the example and edit it: + +```sh +cp .env.example .env +``` + +Jellyfin requests authenticate with the supported `Authorization: MediaBrowser Token="…"` header, including on Jellyfin 12 with legacy authentication disabled. Keep using your existing `JELLYFIN_API_KEY`. + +## Run with Docker Compose + +```sh +docker compose up -d --build +docker compose logs -f +``` + +Then open: + +```text +http://<home-server-ip>:8080 +``` + +## Run as a local binary + +Go 1.23 or newer is required. + +```sh +go test ./... +go build -o jellyfin-news . +set -a +. ./.env +set +a +./jellyfin-news +``` + +The API key remains on the server and is never sent to the browser. The browser receives recap text, poster metadata, and artwork proxied through this app. This app has no authentication, so keep port 8080 limited to your trusted home LAN and do not expose it directly to the internet.
A
jellyfin-news/compose.yaml
@@ -0,0 +1,7 @@
+services: + jellyfin-news: + build: . + restart: unless-stopped + env_file: .env + ports: + - "8080:8080"
A
jellyfin-news/digest.go
@@ -0,0 +1,170 @@
+package main + +import ( + "fmt" + "sort" + "strings" + "time" +) + +// Item is the subset of Jellyfin item metadata needed by a digest. +type Item struct { + ID string + Type string + Name string + SeriesID string + SeriesName string + SeasonNumber *int + EpisodeNumber *int + ProductionYear *int +} + +// 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 + Shows string + Season string + Specials string + Episode string + Episodes string + UnknownShow string + NoWeeklyItems string + NoMonthlyItems string +} + +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", + Shows: "Shows", + Season: "Season", + Specials: "Specials", + Episode: "episode", + Episodes: "episodes", + UnknownShow: "Unknown show", + NoWeeklyItems: "No new movies or show episodes this week.", + NoMonthlyItems: "No new movies or show episodes last month.", + } +} + +// FormatWeek creates a plain-text weekly message suitable for copying and sharing. +func FormatWeek(start, end time.Time, items []Item, labels Labels) string { + title := start.Format("2006-01-02") + " - " + end.Format("2006-01-02") + " " + labels.WeeklyUpdate + ":" + return format(title, labels.NoWeeklyItems, items, labels) +} + +// FormatMonth creates a recap for a calendar month. +func FormatMonth(month time.Time, items []Item, labels Labels) string { + return format(month.Format("2006-01")+" "+labels.MonthlyUpdate+":", labels.NoMonthlyItems, items, labels) +} + +func format(title, emptyMessage string, items []Item, labels Labels) string { + movies := make([]Item, 0) + type seasonKey struct { + seriesID, seriesName string + season int + } + seasons := make(map[seasonKey]map[int]struct{}) + + for _, item := range items { + switch item.Type { + case "Movie": + movies = append(movies, item) + case "Episode": + if item.SeasonNumber == nil || item.EpisodeNumber == nil { + continue + } + name := strings.TrimSpace(item.SeriesName) + if name == "" { + name = labels.UnknownShow + } + key := seasonKey{seriesID: item.SeriesID, seriesName: name, season: *item.SeasonNumber} + if seasons[key] == nil { + seasons[key] = make(map[int]struct{}) + } + seasons[key][*item.EpisodeNumber] = struct{}{} + } + } + + sort.Slice(movies, func(i, j int) bool { + return strings.ToLower(movies[i].Name) < strings.ToLower(movies[j].Name) + }) + keys := make([]seasonKey, 0, len(seasons)) + for key := range seasons { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + a, b := strings.ToLower(keys[i].seriesName), strings.ToLower(keys[j].seriesName) + if a == b { + return keys[i].season < keys[j].season + } + return a < b + }) + + var out strings.Builder + out.WriteString(title) + out.WriteByte('\n') + if len(movies) == 0 && len(keys) == 0 { + out.WriteByte('\n') + out.WriteString(emptyMessage) + return out.String() + } + if len(movies) > 0 { + fmt.Fprintf(&out, "\n%s:\n", labels.Movies) + for _, movie := range movies { + fmt.Fprintf(&out, "• %s", movie.Name) + if movie.ProductionYear != nil { + fmt.Fprintf(&out, " (%d)", *movie.ProductionYear) + } + out.WriteByte('\n') + } + } + if len(keys) > 0 { + fmt.Fprintf(&out, "\n%s:\n", labels.Shows) + for _, key := range keys { + episodes := make([]int, 0, len(seasons[key])) + for episode := range seasons[key] { + episodes = append(episodes, episode) + } + sort.Ints(episodes) + season := fmt.Sprintf("%s %d", labels.Season, key.season) + if key.season == 0 { + season = labels.Specials + } + label := labels.Episodes + if len(episodes) == 1 { + label = labels.Episode + } + fmt.Fprintf(&out, "• %s — %s, %s %s\n", key.seriesName, season, label, ranges(episodes)) + } + } + return strings.TrimRight(out.String(), "\n") +} + +func ranges(numbers []int) string { + parts := make([]string, 0) + for i := 0; i < len(numbers); { + j := i + for j+1 < len(numbers) && numbers[j+1] == numbers[j]+1 { + j++ + } + if i == j { + parts = append(parts, fmt.Sprint(numbers[i])) + } else { + parts = append(parts, fmt.Sprintf("%d-%d", numbers[i], numbers[j])) + } + i = j + 1 + } + return strings.Join(parts, ", ") +}
A
jellyfin-news/digest_test.go
@@ -0,0 +1,67 @@
+package main + +import ( + "testing" + "time" +) + +func intp(n int) *int { return &n } + +func TestFormatGroupsEpisodes(t *testing.T) { + items := []Item{ + {Type: "Movie", Name: "Dune", ProductionYear: intp(2021)}, + {Type: "Episode", SeriesID: "a", SeriesName: "Example Show", SeasonNumber: intp(3), EpisodeNumber: intp(12)}, + {Type: "Episode", SeriesID: "a", SeriesName: "Example Show", SeasonNumber: intp(3), EpisodeNumber: intp(1)}, + {Type: "Episode", SeriesID: "a", SeriesName: "Example Show", SeasonNumber: intp(3), EpisodeNumber: intp(2)}, + {Type: "Episode", SeriesID: "a", SeriesName: "Example Show", SeasonNumber: intp(3), EpisodeNumber: intp(3)}, + {Type: "Episode", SeriesID: "a", SeriesName: "Example Show", SeasonNumber: intp(3), EpisodeNumber: intp(5)}, + } + start := time.Date(2026, 8, 18, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, 8, 25, 0, 0, 0, 0, time.UTC) + want := "2026-08-18 - 2026-08-25 jellyfin weekly update:\n\nMovies:\n• Dune (2021)\n\nShows:\n• Example Show — Season 3, episodes 1-3, 5, 12" + if got := FormatWeek(start, end, items, DefaultLabels()); got != want { + t.Fatalf("FormatWeek() = %q, want %q", got, want) + } +} + +func TestFormatEmpty(t *testing.T) { + start := time.Date(2026, 8, 18, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, 8, 25, 0, 0, 0, 0, time.UTC) + want := "2026-08-18 - 2026-08-25 jellyfin weekly update:\n\nNo new movies or show episodes this week." + if got := FormatWeek(start, end, nil, DefaultLabels()); got != want { + t.Fatalf("FormatWeek() = %q, want %q", got, want) + } +} + +func TestFormatMonth(t *testing.T) { + want := "2026-07 jellyfin monthly update:\n\nNo new movies or show episodes last month." + if got := FormatMonth(time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), nil, DefaultLabels()); got != want { + t.Fatalf("FormatMonth() = %q, want %q", got, want) + } +} + +func TestFormatWithTranslatedLabels(t *testing.T) { + labels := Labels{ + WeeklyUpdate: "actualización semanal", + MonthlyUpdate: "actualización mensual", + Movies: "Películas", + Shows: "Series", + Season: "Temporada", + Specials: "Especiales", + Episode: "episodio", + Episodes: "episodios", + UnknownShow: "Serie desconocida", + NoWeeklyItems: "No hay novedades esta semana.", + NoMonthlyItems: "No hay novedades el mes pasado.", + } + items := []Item{ + {Type: "Movie", Name: "Una película"}, + {Type: "Episode", SeriesName: "Una serie", SeasonNumber: intp(2), EpisodeNumber: intp(4)}, + } + start := time.Date(2026, 8, 18, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, 8, 25, 0, 0, 0, 0, time.UTC) + want := "2026-08-18 - 2026-08-25 actualización semanal:\n\nPelículas:\n• Una película\n\nSeries:\n• Una serie — Temporada 2, episodio 4" + if got := FormatWeek(start, end, items, labels); got != want { + t.Fatalf("FormatWeek() = %q, want %q", got, want) + } +}
A
jellyfin-news/index.html
@@ -0,0 +1,282 @@
+<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Jellyfin updates</title> + <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, 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; } + #extra-titles { min-height: 9rem; margin-top: .5rem; } + details { margin: 1rem 0; } + summary { cursor: pointer; } + [hidden] { display: none; } + .copy { margin-top: .65rem; } + #collage-preview { display: block; max-width: 100%; height: auto; margin-top: 1rem; } + button:disabled { cursor: wait; } + </style> +</head> +<body> + <h1>Jellyfin updates</h1> + <p>Choose a time period, then generate a newly added or watched recap.</p> + + <div class="controls"> + <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 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> + <span>−1 is last week, −2 is two weeks ago</span> + </div> + + <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> + <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> + <button id="posters">🖼 Generate poster image</button> + </div> + + <details> + <summary>Add movies or shows to the poster image</summary> + <label for="extra-titles">Paste entries from a newly added recap:</label> + <textarea id="extra-titles" placeholder="Movies: • Dune (2021) Shows: • Example Show — Season 3, episodes 1-12"></textarea> + <p>These titles appear in the poster image only. Movies and shows must already exist in Jellyfin to use their artwork.</p> + </details> + + <p id="status" role="status"></p> + <div id="collage" hidden> + <a id="collage-download">Download PNG</a> + <img id="collage-preview" alt="Posters of movies and shows added in the selected period"> + </div> + <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 extraTitles = document.querySelector('#extra-titles'); + const controls = [...document.querySelectorAll('button, input, select')]; + let collageURL; + + // Release the previous export when the selection changes or a new image is requested. + function clearCollage() { + document.querySelector('#collage').hidden = true; + document.querySelector('#collage-preview').removeAttribute('src'); + document.querySelector('#collage-download').removeAttribute('href'); + if (collageURL) URL.revokeObjectURL(collageURL); + collageURL = null; + } + + async function loadReport(url) { + controls.forEach(control => control.disabled = true); + status.textContent = 'Loading…'; + try { + const response = await fetch(url, { headers: { Accept: 'text/plain' } }); + const text = await response.text(); + if (!response.ok) throw new Error(text.trim() || `Request failed (${response.status})`); + output.value = text; + status.textContent = 'Recap ready.'; + } catch (error) { + status.textContent = error.message; + } finally { + controls.forEach(control => control.disabled = false); + } + } + + // 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 = ''; + clearCollage(); + } + period.addEventListener('change', updatePeriod); + document.querySelectorAll('input').forEach(input => input.addEventListener('input', updatePeriod)); + extraTitles.addEventListener('input', clearCollage); + updatePeriod(); + + 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)); + + // Missing artwork keeps its place in the collage with a labelled placeholder. + function loadPoster(url) { + return new Promise(resolve => { + if (!url) return resolve(null); + const image = new Image(); + const timer = setTimeout(() => { image.src = ''; resolve(null); }, 35000); + image.onload = () => { clearTimeout(timer); resolve(image); }; + image.onerror = () => { clearTimeout(timer); resolve(null); }; + image.src = url; + }); + } + + // Wrap captions into two lines and ellipsize long titles without overflowing the tile. + function drawCaption(ctx, text, x, y, width) { + let remaining = text; + for (let line = 0; line < 2 && remaining; line++) { + let length = remaining.length; + while (length > 1 && ctx.measureText(remaining.slice(0, length) + (line === 1 && length < remaining.length ? '…' : '')).width > width) length--; + if (line === 0 && length < remaining.length) { + const space = remaining.lastIndexOf(' ', length); + if (space > 0) length = space; + } + ctx.fillText(remaining.slice(0, length) + (line === 1 && length < remaining.length ? '…' : ''), x, y + line * 23); + remaining = remaining.slice(length).trim(); + } + } + + // Draw in small batches, bounding concurrent requests and canvas memory for large months. + async function renderCollage(data) { + const columns = Math.min(6, Math.ceil(Math.sqrt(data.titles.length))); + const width = columns * 260 + 24; + const sections = new Map(); + for (const title of data.titles) { + if (!sections.has(title.kind)) sections.set(title.kind, []); + sections.get(title.kind).push(title); + } + // Start each category on its own row beneath a single section heading. + const headings = []; + const tiles = []; + let height = 110; + for (const [kind, titles] of sections) { + headings.push({ kind, y: height + 26 }); + height += 48; + titles.forEach((title, index) => tiles.push({ + title, + x: 24 + (index % columns) * 260, + y: height + Math.floor(index / columns) * 420, + })); + height += Math.ceil(titles.length / columns) * 420 + 20; + } + const scale = Math.min(1, 8000 / height, Math.sqrt(16000000 / (width * height))); + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.floor(width * scale)); + canvas.height = Math.max(1, Math.floor(height * scale)); + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Your browser could not create the poster image.'); + ctx.scale(scale, scale); + ctx.fillStyle = '#101827'; + ctx.fillRect(0, 0, width, height); + ctx.fillStyle = '#f8fafc'; + ctx.font = 'bold 28px system-ui, sans-serif'; + ctx.fillText(data.title, 24, 43, width - 48); + ctx.font = '18px system-ui, sans-serif'; + ctx.fillStyle = '#b9c8dc'; + ctx.fillText(`${data.start} – ${data.end}`, 24, 76, width - 48); + ctx.fillStyle = '#f8fafc'; + ctx.font = 'bold 24px system-ui, sans-serif'; + for (const heading of headings) { + ctx.fillText(heading.kind, 24, heading.y, width - 48); + } + let missing = 0; + for (let start = 0; start < tiles.length; start += 4) { + const batch = tiles.slice(start, start + 4); + const images = await Promise.all(batch.map(tile => loadPoster(tile.title.url))); + batch.forEach(({ title, x, y }, offset) => { + const image = images[offset]; + ctx.fillStyle = '#25334a'; + ctx.fillRect(x, y, 236, 354); + if (image) { + const fit = Math.min(236 / image.naturalWidth, 354 / image.naturalHeight); + const w = image.naturalWidth * fit, h = image.naturalHeight * fit; + ctx.drawImage(image, x + (236 - w) / 2, y + (354 - h) / 2, w, h); + } else { + missing++; + ctx.fillStyle = '#b9c8dc'; + ctx.font = '18px system-ui, sans-serif'; + ctx.fillText('Poster unavailable', x + 20, y + 180); + } + ctx.fillStyle = '#f8fafc'; + ctx.font = 'bold 18px system-ui, sans-serif'; + drawCaption(ctx, title.name, x, y + 380, 236); + }); + status.textContent = `Generating image… ${Math.min(start + 4, data.titles.length)}/${data.titles.length}`; + } + const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png')); + if (!blob) throw new Error('Could not export the image. Try a smaller period.'); + return { blob, missing }; + } + + document.querySelector('#posters').addEventListener('click', async () => { + const params = new URLSearchParams({ period: period.value }); + if (period.value !== 'current-week') { + const offset = document.querySelector(`#${period.value}-offset`); + if (!offset.reportValidity()) return; + params.set('offset', offset.value); + } + clearCollage(); + controls.forEach(control => control.disabled = true); + status.textContent = 'Loading posters…'; + try { + const extras = extraTitles.value.trim(); + const response = await fetch(`/api/posters?${params}`, { + method: extras ? 'POST' : 'GET', + headers: { Accept: 'application/json', ...(extras ? { 'Content-Type': 'text/plain; charset=utf-8' } : {}) }, + ...(extras ? { body: extras } : {}), + }); + if (!response.ok) throw new Error((await response.text()).trim() || `Request failed (${response.status})`); + const data = await response.json(); + if (!data.titles.length) { + status.textContent = 'No new movies or show episodes in this period.'; + return; + } + const { blob, missing } = await renderCollage(data); + collageURL = URL.createObjectURL(blob); + document.querySelector('#collage-preview').src = collageURL; + const download = document.querySelector('#collage-download'); + download.href = collageURL; + download.download = `jellyfin-posters-${data.start}-${data.end}.png`; + document.querySelector('#collage').hidden = false; + status.textContent = `Image ready. Use Download PNG to save it.${missing ? ` ${missing} poster(s) unavailable; placeholders included.` : ''}`; + } catch (error) { + status.textContent = error.message; + } finally { + controls.forEach(control => control.disabled = false); + } + }); + + document.querySelector('#copy').addEventListener('click', async () => { + if (!output.value) return; + try { + await navigator.clipboard.writeText(output.value); + } catch (_) { + output.select(); + document.execCommand('copy'); + output.setSelectionRange(0, 0); + } + status.textContent = 'Copied to clipboard.'; + }); + </script> +</body> +</html>
A
jellyfin-news/index_test.js
@@ -0,0 +1,205 @@
+// 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(options = {}) { + const ids = ['period', 'week-controls', 'month-controls', 'week-offset', 'month-offset', 'added', 'watched', 'posters', 'collage', 'collage-preview', 'collage-download', 'copy', 'output', 'status', 'extra-titles']; + const elements = Object.fromEntries(ids.map(id => [id, { + value: id === 'period' ? 'current-week' : id === 'extra-titles' ? '' : '-1', + events: {}, valid: true, + addEventListener(event, handler) { this.events[event] = handler; }, + reportValidity() { return this.valid; }, + removeAttribute(name) { delete this[name]; }, + }])); + const requests = []; + const requestOptions = []; + const revoked = []; + const drawn = []; + const textPositions = []; + const canvas = { + getContext: () => ({ + scale() {}, fillRect() {}, drawImage() { drawn.push('poster'); }, + fillText(text, x, y) { drawn.push(text); textPositions.push({ text, x, y }); }, + measureText: text => ({ width: text.length * 10 }), + }), + toBlob: callback => callback(options.exportFails ? null : new Blob(['png'], { type: 'image/png' })), + }; + const script = fs.readFileSync(`${__dirname}/index.html`, 'utf8').split('<script>')[1].split('</script>')[0]; + vm.runInNewContext(script, { + URLSearchParams, + URL: { createObjectURL: () => 'blob:collage', revokeObjectURL: url => revoked.push(url) }, + setTimeout, clearTimeout, + Image: class { + naturalWidth = 480; + naturalHeight = 720; + set src(value) { if (value) queueMicrotask(() => value.includes('missing') ? this.onerror() : this.onload()); } + }, + document: { + createElement: () => canvas, + querySelector: selector => elements[selector.slice(1)], + querySelectorAll: selector => selector === 'input' + ? [elements['week-offset'], elements['month-offset']] : Object.values(elements), + }, + fetch: async (url, request) => { + requests.push(url); + requestOptions.push(request); + return options.response || { ok: true, text: async () => 'recap', json: async () => options.data || { titles: [] } }; + }, + }); + return { elements, requests, requestOptions, revoked, drawn, textPositions, canvas }; +} + +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); + } +}); + +test('poster generation uses every selected period, including months', async () => { + for (const period of ['current-week', 'week', 'month']) { + const { elements, requests } = page(); + elements.period.value = period; + if (period !== 'current-week') elements[`${period}-offset`].value = '-2'; + await elements.posters.events.click(); + const url = new URL(requests[0], 'http://localhost'); + assert.equal(url.pathname, '/api/posters'); + assert.equal(url.searchParams.get('period'), period); + assert.equal(url.searchParams.get('offset'), period === 'current-week' ? null : '-2'); + assert.equal(elements.collage.hidden, true); + assert.match(elements.status.textContent, /No new movies/); + assert.equal(elements.posters.disabled, false); + } +}); + +test('invalid poster offset prevents fetching', async () => { + const { elements, requests } = page(); + elements.period.value = 'month'; + elements['month-offset'].valid = false; + await elements.posters.events.click(); + assert.deepEqual(requests, []); +}); + +test('extra recap entries are sent only with poster generation', async () => { + const { elements, requests, requestOptions, revoked } = page({ data: collageData }); + const extras = 'Movies:\n• Film (2024)\n\nShows:\n• Series — Season 2, episode 1'; + elements['extra-titles'].value = extras; + elements.added.events.click(); + assert.equal(requests[0], '/api/current-week'); + await elements.posters.events.click(); + assert.equal(new URL(requests[1], 'http://localhost').pathname, '/api/posters'); + assert.equal(requestOptions[1].method, 'POST'); + assert.equal(requestOptions[1].body, extras); + elements['extra-titles'].events.input(); + assert.deepEqual(revoked, ['blob:collage']); + assert.equal(elements.collage.hidden, true); +}); + +const collageData = { + start: '2026-08-01', end: '2026-09-01', title: 'Monthly update', + titles: [ + { name: 'Movie', kind: 'Movies', url: '/api/poster/movie' }, + { name: 'Show', kind: 'Shows', url: '/api/poster/missing' }, + ], +}; + +test('collage groups posters beneath one heading per category, including partial rows', async () => { + for (const kinds of [['Movies', 'Shows'], ['Películas', 'Series'], ['Shows']]) { + const titles = kinds.flatMap(kind => Array.from({ length: 3 }, (_, i) => ({ + name: `${kind} title ${i}`, kind, url: '', + }))); + const { elements, textPositions, canvas } = page({ data: { ...collageData, titles } }); + await elements.posters.events.click(); + assert.equal(elements.collage.hidden, false); + let previousBottom = 0; + for (const kind of kinds) { + const headings = textPositions.filter(item => item.text === kind); + assert.equal(headings.length, 1); + assert.ok(headings[0].y > previousBottom); + const captions = textPositions.filter(item => item.text.startsWith(`${kind} title`)); + assert.equal(captions.length, 3); + assert.ok(captions.every(item => item.y > headings[0].y)); + assert.equal(captions[0].x, headings[0].x); + previousBottom = Math.max(...captions.map(item => item.y)); + } + assert.ok(canvas.height > previousBottom); + if (kinds.length === 1) assert.ok(!textPositions.some(item => item.text === 'Movies')); + } +}); + +test('collage includes missing artwork and can be downloaded and cleared', async () => { + const { elements, drawn, revoked } = page({ data: collageData }); + await elements.posters.events.click(); + assert.equal(elements.collage.hidden, false); + assert.equal(elements['collage-download'].href, 'blob:collage'); + assert.equal(elements['collage-download'].download, 'jellyfin-posters-2026-08-01-2026-09-01.png'); + assert.equal(elements['collage-preview'].src, 'blob:collage'); + assert.ok(drawn.includes('poster')); + assert.ok(drawn.includes('Show')); + assert.ok(drawn.includes('Poster unavailable')); + assert.match(elements.status.textContent, /1 poster\(s\) unavailable/); + elements.period.events.change(); + assert.deepEqual(revoked, ['blob:collage']); + assert.equal(elements.collage.hidden, true); + assert.equal(elements['collage-download'].href, undefined); +}); + +test('poster request and export failures restore controls without a stale download', async () => { + for (const options of [ + { response: { ok: false, text: async () => 'Could not query Jellyfin.' } }, + { data: collageData, exportFails: true }, + ]) { + const { elements } = page(options); + await elements.posters.events.click(); + assert.match(elements.status.textContent, /Could not/); + assert.equal(elements.posters.disabled, false); + assert.equal(elements.period.disabled, false); + assert.equal(elements.collage.hidden, true); + } +});
A
jellyfin-news/jellyfin.go
@@ -0,0 +1,154 @@
+package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +type Client struct { + baseURL string + apiKey string + userID string + http *http.Client +} + +func New(baseURL, apiKey, userID string, httpClient *http.Client) (*Client, error) { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if _, err := url.ParseRequestURI(baseURL); err != nil || !(strings.HasPrefix(baseURL, "http://") || strings.HasPrefix(baseURL, "https://")) { + return nil, fmt.Errorf("invalid JELLYFIN_URL %q", baseURL) + } + if strings.TrimSpace(apiKey) == "" { + return nil, fmt.Errorf("JELLYFIN_API_KEY is required") + } + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + return &Client{baseURL: baseURL, apiKey: apiKey, userID: strings.TrimSpace(userID), http: httpClient}, nil +} + +type response struct { + Items []item `json:"Items"` + TotalRecordCount int `json:"TotalRecordCount"` +} + +type item struct { + ID string `json:"Id"` + Type string `json:"Type"` + Name string `json:"Name"` + SeriesID string `json:"SeriesId"` + SeriesName string `json:"SeriesName"` + ParentIndexNumber *int `json:"ParentIndexNumber"` + IndexNumber *int `json:"IndexNumber"` + ProductionYear *int `json:"ProductionYear"` + DateCreated string `json:"DateCreated"` +} + +// AddedBetween returns movies and episodes whose Jellyfin DateCreated is in [start, end). +func (c *Client) AddedBetween(ctx context.Context, start, end time.Time) ([]Item, error) { + const pageSize = 200 + var result []Item + for offset := 0; ; offset += pageSize { + params := url.Values{ + "IncludeItemTypes": {"Movie,Episode"}, + "Recursive": {"true"}, + "Fields": {"DateCreated"}, + "SortBy": {"DateCreated"}, + "SortOrder": {"Descending"}, + "EnableImages": {"false"}, + "EnableUserData": {"false"}, + "EnableTotalRecordCount": {"true"}, + "StartIndex": {strconv.Itoa(offset)}, + "Limit": {strconv.Itoa(pageSize)}, + } + if c.userID != "" { + params.Set("UserId", c.userID) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/Items?"+params.Encode(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", `MediaBrowser Token="`+c.apiKey+`"`) + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("query Jellyfin: %w", err) + } + var page response + decodeErr := json.NewDecoder(resp.Body).Decode(&page) + resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("Jellyfin returned HTTP %d", resp.StatusCode) + } + if decodeErr != nil { + return nil, fmt.Errorf("decode Jellyfin response: %w", decodeErr) + } + pastStart := false + for _, raw := range page.Items { + created, err := time.Parse(time.RFC3339Nano, raw.DateCreated) + if err != nil { + continue + } + if created.Before(start) { + pastStart = true + continue + } + if !created.Before(end) { + continue + } + result = append(result, Item{ + ID: raw.ID, + Type: raw.Type, Name: raw.Name, SeriesID: raw.SeriesID, + SeriesName: raw.SeriesName, SeasonNumber: raw.ParentIndexNumber, + EpisodeNumber: raw.IndexNumber, ProductionYear: raw.ProductionYear, + }) + } + if pastStart || len(page.Items) < pageSize || (page.TotalRecordCount > 0 && offset+len(page.Items) >= page.TotalRecordCount) { + break + } + } + return result, nil +} + +// FindPosterTitle returns an exact library match so a typed title cannot use unrelated artwork. +func (c *Client) FindPosterTitle(ctx context.Context, kind, name string, year int) (string, error) { + params := url.Values{ + "IncludeItemTypes": {kind}, + "Recursive": {"true"}, + "SearchTerm": {name}, + "Limit": {"50"}, + "EnableImages": {"false"}, + "EnableUserData": {"false"}, + } + if c.userID != "" { + params.Set("UserId", c.userID) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/Items?"+params.Encode(), nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", `MediaBrowser Token="`+c.apiKey+`"`) + resp, err := c.http.Do(req) + if err != nil { + return "", fmt.Errorf("search Jellyfin: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("Jellyfin search returned HTTP %d", resp.StatusCode) + } + var result response + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil { + return "", fmt.Errorf("decode Jellyfin search: %w", err) + } + for _, item := range result.Items { + if item.Type == kind && strings.EqualFold(item.Name, name) && (year == 0 || item.ProductionYear != nil && *item.ProductionYear == year) { + return item.ID, nil + } + } + return "", nil +}
A
jellyfin-news/jellyfin_test.go
@@ -0,0 +1,65 @@
+package main + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + "time" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestAddedBetween(t *testing.T) { + httpClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Header.Get("Authorization") != `MediaBrowser Token="secret"` || r.Header.Get("X-Emby-Token") != "" { + t.Error("expected MediaBrowser API key authorization without legacy token header") + } + if r.URL.Query().Get("IncludeItemTypes") != "Movie,Episode" { + t.Error("wrong item filter") + } + body := `{"Items":[ + {"Id":"new","Type":"Movie","Name":"New","DateCreated":"2026-08-24T10:00:00Z"}, + {"Id":"old","Type":"Movie","Name":"Old","DateCreated":"2026-08-17T09:00:00Z"} + ],"TotalRecordCount":2}` + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + })} + c, err := New("http://jellyfin.test", "secret", "", httpClient) + if err != nil { + t.Fatal(err) + } + start := time.Date(2026, 8, 18, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, 8, 25, 0, 0, 0, 0, time.UTC) + items, err := c.AddedBetween(context.Background(), start, end) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].Name != "New" || items[0].ID != "new" { + t.Fatalf("items = %#v", items) + } +} + +func TestFindPosterTitleMatchesKindNameAndYear(t *testing.T) { + client, err := New("http://jellyfin.test", "secret", "user", &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + q := r.URL.Query() + if r.URL.Path != "/Items" || (q.Get("SearchTerm") != "Dune" && q.Get("SearchTerm") != "Missing") || q.Get("IncludeItemTypes") != "Movie" || q.Get("Recursive") != "true" || q.Get("UserId") != "user" || r.Header.Get("Authorization") != `MediaBrowser Token="secret"` || r.Header.Get("X-Emby-Token") != "" { + t.Errorf("unexpected search request: %s", r.URL) + } + body := `{"Items":[{"Id":"series","Type":"Series","Name":"Dune"},{"Id":"old","Type":"Movie","Name":"Dune","ProductionYear":1984},{"Id":"new","Type":"Movie","Name":"Dune","ProductionYear":2021}]}` + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + })}) + if err != nil { + t.Fatal(err) + } + id, err := client.FindPosterTitle(context.Background(), "Movie", "Dune", 2021) + if err != nil || id != "new" { + t.Fatalf("match = %q, %v", id, err) + } + id, err = client.FindPosterTitle(context.Background(), "Movie", "Missing", 2021) + if err != nil || id != "" { + t.Fatalf("missing = %q, %v", id, err) + } +}
A
jellyfin-news/jellystat.go
@@ -0,0 +1,259 @@
+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"` + SeasonNumber *int `json:"SeasonNumber"` + Results []playback `json:"results"` +} + +type WatchedTitle struct { + ID, Type, Name string + Users int + Seasons []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{} + seasons := map[string]map[int]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 play.SeasonNumber != nil { + if seasons[id] == nil { + seasons[id] = map[int]bool{} + } + seasons[id][*play.SeasonNumber] = 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 + } + title := WatchedTitle{ID: id, Type: item.Type, Name: item.Name, Users: len(viewers)} + if item.Type == "Series" { + for season := range seasons[id] { + title.Seasons = append(title.Seasons, season) + } + sort.Ints(title.Seasons) + } + titles = append(titles, title) + } + 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 groups comma-separated titles without disclosing account identities. +func FormatWatched(start, end time.Time, monthly bool, titles []WatchedTitle, labels Labels) string { + seasonPrefix := "" + for _, initial := range strings.TrimSpace(labels.Season) { + seasonPrefix = string(initial) + break + } + 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: ", section.label) + heading = true + } else { + out.WriteString(", ") + } + out.WriteString(title.Name) + if title.Type == "Series" && len(title.Seasons) > 0 { + parts := make([]string, 0, len(title.Seasons)) + for _, season := range title.Seasons { + label := fmt.Sprintf("%s%d", seasonPrefix, season) + if season == 0 { + label = labels.Specials + } + parts = append(parts, label) + } + fmt.Fprintf(&out, " (%s)", strings.Join(parts, ", ")) + } + if title.Users > 1 { + fmt.Fprintf(&out, " (%d %s)", title.Users, labels.Users) + } + } + if heading { + out.WriteByte('\n') + } + } + return strings.TrimRight(out.String(), "\n") +}
A
jellyfin-news/jellystat_test.go
@@ -0,0 +1,248 @@
+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: movie (2 users)\n\nShows: 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) + } +} + +func TestWatchedTitlesInline(t *testing.T) { + start := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) + titles := []WatchedTitle{ + {Name: "Severance", Type: "Series", Users: 2}, + {Name: "Dune", Type: "Movie", Users: 3}, + {Name: "Arrival", Type: "Movie", Users: 1}, + {Name: "Andor", Type: "Series", Users: 1}, + } + got := FormatWatched(start, start.AddDate(0, 1, 0), true, titles, DefaultLabels()) + want := "2026-08 jellyfin watched recap:\n\nMovies: Arrival, Dune (3 users)\n\nShows: Andor, Severance (2 users)" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestWatchedSeasonsInSelectedPeriod(t *testing.T) { + for _, grouped := range []bool{true, false} { + t.Run(fmt.Sprintf("grouped=%t", grouped), func(t *testing.T) { + start := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) + end := start.AddDate(0, 1, 0) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var rows []playback + switch r.URL.Path { + case "/api/getHistory", "/api/getItemHistory": + if r.URL.Path == "/api/getHistory" && !grouped { + fmt.Fprint(w, `{"pages":1,"results":[{"NowPlayingItemId":"show"}]}`) + return + } + switch r.URL.Query().Get("page") { + case "1": + rows = []playback{ + {ItemID: "show", UserID: "a", Date: start.Format(time.RFC3339), SeasonNumber: intp(2)}, + {ItemID: "show", UserID: "a", Date: start.Add(-time.Second).Format(time.RFC3339), SeasonNumber: intp(3)}, + } + case "2": + rows = []playback{ + {ItemID: "show", UserID: "a", Date: start.Format(time.RFC3339), SeasonNumber: intp(1)}, + {ItemID: "show", UserID: "b", Date: start.Format(time.RFC3339), SeasonNumber: intp(2)}, + {ItemID: "show", UserID: "a", Date: start.Format(time.RFC3339), SeasonNumber: intp(0)}, + {ItemID: "show", UserID: "a", Date: start.Format(time.RFC3339)}, + {ItemID: "show", UserID: "c", Date: end.Format(time.RFC3339), SeasonNumber: intp(4)}, + } + default: + t.Error("unexpected page") + } + if grouped { + rows = []playback{{ItemID: "show", Results: rows}} + } + json.NewEncoder(w).Encode(map[string]any{"pages": 2, "results": rows}) + case "/api/getItemDetails": + fmt.Fprint(w, `[{"Name":"Example","Type":"Series"}]`) + default: + t.Errorf("unexpected endpoint %s", r.URL.Path) + } + })) + defer server.Close() + client, _ := NewJellystat(server.URL, "test-key") + titles, err := client.WatchedBetween(context.Background(), start, end) + if err != nil { + t.Fatal(err) + } + labels := DefaultLabels() + labels.Season = "Temporada" + labels.Specials = "Especiales" + got := FormatWatched(start, end, true, titles, labels) + want := "2026-08 jellyfin watched recap:\n\nShows: Example (Especiales, T1, T2) (2 users)" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } + }) + } +} + +func TestWatchedSeasonInitial(t *testing.T) { + for _, tc := range []struct{ season, want string }{ + {"Season", "S3"}, + {"Temporada", "T3"}, + {"Época", "É3"}, + {" Temporada ", "T3"}, + } { + t.Run(tc.season, func(t *testing.T) { + labels := DefaultLabels() + labels.Season = tc.season + start := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) + titles := []WatchedTitle{{Name: "Example", Type: "Series", Seasons: []int{3}}} + got := FormatWatched(start, start.AddDate(0, 1, 0), true, titles, labels) + want := "2026-08 jellyfin watched recap:\n\nShows: Example (" + tc.want + ")" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } + }) + } +}
A
jellyfin-news/main.go
@@ -0,0 +1,268 @@
+package main + +import ( + "context" + _ "embed" + "errors" + "flag" + "fmt" + "html" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +//go:embed index.html +var indexHTML []byte + +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 { + weekStart weekStart + jellystat watchedSource + jellyfin itemSource + location *time.Location + labels Labels + now func() time.Time +} + +func main() { + listen := flag.String("listen", env("LISTEN_ADDR", ":8080"), "HTTP listen address") + flag.Parse() + + location, err := time.LoadLocation(env("TZ", "Local")) + if err != nil { + log.Fatalf("invalid TZ: %v", err) + } + week, err := loadWeekStart() + if err != nil { + log.Fatal(err) + } + client, err := New(os.Getenv("JELLYFIN_URL"), os.Getenv("JELLYFIN_API_KEY"), os.Getenv("JELLYFIN_USER_ID"), nil) + if err != nil { + log.Fatalf("configuration error: %v", err) + } + + application := &app{weekStart: week, 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(), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 60 * time.Second, + } + log.Printf("Jellyfin updates available at http://%s", displayAddress(*listen)) + if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatal(err) + } +} + +func (a *app) routes() http.Handler { + mux := http.NewServeMux() + 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) + mux.HandleFunc("GET /api/posters", a.posters) + mux.HandleFunc("POST /api/posters", a.posters) + mux.HandleFunc("GET /api/poster/{id}", a.poster) + return mux +} + +func (a *app) index(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(strings.ReplaceAll(string(indexHTML), "{{WATCHED_BUTTON}}", html.EscapeString(a.labels.WatchedButton)))) +} + +func (a *app) currentWeek(w http.ResponseWriter, r *http.Request) { + now := a.now().In(a.location) + start := a.weekStart.start(now) + a.writeWeek(w, r, start, now) +} + +func (a *app) week(w http.ResponseWriter, r *http.Request) { + offset, ok := readOffset(w, r) + if !ok { + return + } + currentWeek := a.weekStart.start(a.now().In(a.location)) + start := currentWeek.AddDate(0, 0, offset*7) + a.writeWeek(w, r, start, start.AddDate(0, 0, 7)) +} + +func (a *app) month(w http.ResponseWriter, r *http.Request) { + offset, ok := readOffset(w, r) + if !ok { + return + } + a.writeMonth(w, r, offset) +} + +func (a *app) writeWeek(w http.ResponseWriter, r *http.Request, start, end time.Time) { + items, err := a.jellyfin.AddedBetween(r.Context(), start, end) + if err != nil { + log.Printf("Jellyfin weekly query failed: %v", err) + http.Error(w, "Could not query Jellyfin. Check the server logs.", http.StatusBadGateway) + return + } + writeText(w, FormatWeek(start, end, items, a.labels)) +} + +func (a *app) writeMonth(w http.ResponseWriter, r *http.Request, offset int) { + now := a.now().In(a.location) + currentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, a.location) + start := currentMonth.AddDate(0, offset, 0) + end := start.AddDate(0, 1, 0) + items, err := a.jellyfin.AddedBetween(r.Context(), start, end) + if err != nil { + log.Printf("Jellyfin monthly query failed: %v", err) + http.Error(w, "Could not query Jellyfin. Check the server logs.", http.StatusBadGateway) + return + } + writeText(w, FormatMonth(start, items, a.labels)) +} + +func writeText(w http.ResponseWriter, text string) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Write([]byte(text)) +} + +func readOffset(w http.ResponseWriter, r *http.Request) (int, bool) { + offset, err := strconv.Atoi(r.URL.Query().Get("offset")) + if err != nil || offset < -1200 || offset > -1 { + http.Error(w, "offset must be an integer between -1200 and -1", http.StatusBadRequest) + return 0, false + } + return offset, true +} + +// weekStart defines a weekly boundary in the report's local timezone. +type weekStart struct { + day time.Weekday + hour, minute int +} + +// loadWeekStart rejects invalid settings rather than silently changing report periods. +func loadWeekStart() (weekStart, error) { + day := env("WEEK_START_DAY", "Monday") + clock := env("WEEK_START_TIME", "00:00") + var result weekStart + found := false + for d := time.Sunday; d <= time.Saturday; d++ { + if strings.EqualFold(day, d.String()) { + result.day = d + found = true + break + } + } + if !found { + return result, fmt.Errorf("WEEK_START_DAY must be a full weekday name (Monday through Sunday)") + } + parsed, err := time.Parse("15:04", clock) + if err != nil || parsed.Format("15:04") != clock { + return result, fmt.Errorf("WEEK_START_TIME must be HH:MM in 24-hour format") + } + result.hour, result.minute = parsed.Hour(), parsed.Minute() + return result, nil +} + +// start finds the latest weekly boundary, including before today's start time. +func (w weekStart) start(now time.Time) time.Time { + days := (int(now.Weekday()) - int(w.day) + 7) % 7 + boundary := time.Date(now.Year(), now.Month(), now.Day()-days, w.hour, w.minute, 0, 0, now.Location()) + if boundary.After(now) { + boundary = boundary.AddDate(0, 0, -7) + } + return boundary +} + +func env(name, fallback string) string { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + return value + } + return fallback +} + +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), + Shows: env("TEXT_SHOWS", defaults.Shows), + Season: env("TEXT_SEASON", defaults.Season), + Specials: env("TEXT_SPECIALS", defaults.Specials), + Episode: env("TEXT_EPISODE", defaults.Episode), + Episodes: env("TEXT_EPISODES", defaults.Episodes), + UnknownShow: env("TEXT_UNKNOWN_SHOW", defaults.UnknownShow), + NoWeeklyItems: env("TEXT_NO_WEEKLY_ITEMS", defaults.NoWeeklyItems), + NoMonthlyItems: env("TEXT_NO_MONTHLY_ITEMS", defaults.NoMonthlyItems), + } +} + +func displayAddress(address string) string { + if strings.HasPrefix(address, ":") { + 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 := a.weekStart.start(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)) +}
A
jellyfin-news/main_test.go
@@ -0,0 +1,269 @@
+package main + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +type fakeSource struct { + start time.Time + end time.Time + items []Item + err error +} + +func (f *fakeSource) AddedBetween(_ context.Context, start, end time.Time) ([]Item, error) { + f.start, f.end = start, end + return f.items, f.err +} + +func testApp(source *fakeSource) *app { + return &app{ + weekStart: weekStart{day: time.Monday}, + jellyfin: source, + location: time.UTC, + labels: DefaultLabels(), + now: func() time.Time { + return time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC) + }, + } +} + +func TestCurrentWeek(t *testing.T) { + source := &fakeSource{} + request := httptest.NewRequest(http.MethodGet, "/api/current-week", nil) + response := httptest.NewRecorder() + testApp(source).routes().ServeHTTP(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %q", response.Code, response.Body.String()) + } + if got := response.Body.String(); !strings.HasPrefix(got, "2026-08-31 - 2026-09-02 jellyfin weekly update:") { + t.Fatalf("body = %q", got) + } + wantStart := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC) + if !source.start.Equal(wantStart) || !source.end.Equal(testApp(source).now()) { + t.Fatalf("range = %v to %v", source.start, source.end) + } +} + +func TestStartOfWeekUsesMondayMidnight(t *testing.T) { + location := time.FixedZone("test", 2*60*60) + now := time.Date(2026, 9, 6, 23, 45, 0, 0, location) // Sunday + want := time.Date(2026, 8, 31, 0, 0, 0, 0, location) + if got := (weekStart{day: time.Monday}).start(now); !got.Equal(want) { + t.Fatalf("startOfWeek() = %v, want %v", got, want) + } +} + +func TestSelectedWeek(t *testing.T) { + source := &fakeSource{} + request := httptest.NewRequest(http.MethodGet, "/api/week?offset=-2", nil) + response := httptest.NewRecorder() + testApp(source).routes().ServeHTTP(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %q", response.Code, response.Body.String()) + } + if got := response.Body.String(); !strings.HasPrefix(got, "2026-08-17 - 2026-08-24 jellyfin weekly update:") { + t.Fatalf("body = %q", got) + } +} + +func TestSelectedWeekRejectsInvalidOffset(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/api/week?offset=0", nil) + response := httptest.NewRecorder() + testApp(&fakeSource{}).routes().ServeHTTP(response, request) + if response.Code != http.StatusBadRequest { + t.Fatalf("status = %d", response.Code) + } +} + +func TestSelectedMonth(t *testing.T) { + source := &fakeSource{} + request := httptest.NewRequest(http.MethodGet, "/api/month?offset=-2", nil) + response := httptest.NewRecorder() + testApp(source).routes().ServeHTTP(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %q", response.Code, response.Body.String()) + } + if got := response.Body.String(); !strings.HasPrefix(got, "2026-07 jellyfin monthly update:") { + t.Fatalf("body = %q", got) + } + if source.start.Month() != time.July || source.end.Month() != time.August { + t.Fatalf("range = %v to %v", source.start, source.end) + } +} + +func TestSelectedMonthRejectsInvalidOffset(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/api/month?offset=0", nil) + response := httptest.NewRecorder() + testApp(&fakeSource{}).routes().ServeHTTP(response, request) + if response.Code != http.StatusBadRequest { + t.Fatalf("status = %d", response.Code) + } +} + +func TestIndex(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/", nil) + response := httptest.NewRecorder() + testApp(&fakeSource{}).routes().ServeHTTP(response, request) + 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") + } +} + +func TestWeekStartSettings(t *testing.T) { + for _, tc := range []struct { + day, clock string + want weekStart + valid bool + }{ + {"", "", weekStart{day: time.Monday}, true}, + {"sUnDaY", "20:00", weekStart{day: time.Sunday, hour: 20}, true}, + {"Friday", "19:30", weekStart{day: time.Friday, hour: 19, minute: 30}, true}, + {"Funday", "20:00", weekStart{}, false}, + {"Sunday", "24:00", weekStart{}, false}, + {"Sunday", "20:60", weekStart{}, false}, + {"Sunday", "8:00", weekStart{}, false}, + } { + t.Run(tc.day+tc.clock, func(t *testing.T) { + t.Setenv("WEEK_START_DAY", tc.day) + t.Setenv("WEEK_START_TIME", tc.clock) + got, err := loadWeekStart() + if (err == nil) != tc.valid { + t.Fatalf("error = %v", err) + } + if tc.valid && got != tc.want { + t.Fatalf("got %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestSundayEveningWeekBoundary(t *testing.T) { + location, err := time.LoadLocation("Europe/London") + if err != nil { + t.Fatal(err) + } + boundary := weekStart{day: time.Sunday, hour: 20, minute: 30} + for _, tc := range []struct{ now, want string }{ + {"2026-09-06T20:29:59+01:00", "2026-08-30T20:30:00+01:00"}, + {"2026-09-06T20:30:00+01:00", "2026-09-06T20:30:00+01:00"}, + {"2026-09-07T01:00:00+01:00", "2026-09-06T20:30:00+01:00"}, + {"2026-03-29T19:00:00+01:00", "2026-03-22T20:30:00Z"}, + {"2026-10-25T19:00:00Z", "2026-10-18T20:30:00+01:00"}, + } { + now, _ := time.Parse(time.RFC3339, tc.now) + if got := boundary.start(now.In(location)).Format(time.RFC3339); got != tc.want { + t.Fatalf("at %s got %s, want %s", tc.now, got, tc.want) + } + } +} + +func TestCustomWeekBothReportTypes(t *testing.T) { + for _, period := range []string{"current-week", "week", "month"} { + for _, watched := range []bool{false, true} { + added := &fakeSource{} + viewed := &fakeWatched{} + a := testApp(added) + a.jellystat = viewed + a.weekStart = weekStart{day: time.Sunday, hour: 20} + path := "/api/" + period + "?offset=-1" + if watched { + path = "/api/watched?period=" + period + "&offset=-1" + } + response := httptest.NewRecorder() + a.routes().ServeHTTP(response, httptest.NewRequest("GET", path, nil)) + if response.Code != 200 { + t.Fatalf("%s: %d", path, response.Code) + } + start, end := added.start, added.end + if watched { + start, end = viewed.start, viewed.end + } + wantStart, wantEnd := "2026-08-30T20:00:00Z", "2026-09-02T12:00:00Z" + if period == "week" { + wantStart, wantEnd = "2026-08-23T20:00:00Z", "2026-08-30T20:00:00Z" + } + if period == "month" { + wantStart, wantEnd = "2026-08-01T00:00:00Z", "2026-09-01T00:00:00Z" + } + if start.Format(time.RFC3339) != wantStart || end.Format(time.RFC3339) != wantEnd { + t.Fatalf("%s: %v to %v", path, start, end) + } + } + } +}
A
jellyfin-news/posters.go
@@ -0,0 +1,275 @@
+package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +type posterTitle struct { + Name string `json:"name"` + Kind string `json:"kind"` + URL string `json:"url"` +} + +type extraTitle struct { + name string + kind string + year int +} + +var movieYear = regexp.MustCompile(`^(.*) \(([0-9]{4})\)$`) + +// parseExtraTitles accepts the headings and bullet entries used in added recaps. +func parseExtraTitles(text string, labels Labels) ([]extraTitle, error) { + var titles []extraTitle + kind := "" + for _, raw := range strings.Split(text, "\n") { + line := strings.TrimSpace(strings.TrimSuffix(raw, "\r")) + switch line { + case "": + continue + case labels.Movies + ":": + kind = "Movie" + continue + case labels.Shows + ":": + kind = "Series" + continue + } + if !strings.HasPrefix(line, "• ") { + // A pasted recap can include its date and title above the list. + if kind == "" && !strings.HasPrefix(line, "•") { + continue + } + return nil, fmt.Errorf("Invalid extra title: %s", line) + } + if kind == "" { + return nil, errors.New("Add a Movies: or Shows: heading before extra titles.") + } + name := strings.TrimSpace(strings.TrimPrefix(line, "• ")) + if kind == "Series" { + name, _, _ = strings.Cut(name, " — ") + } + year := 0 + if kind == "Movie" { + if match := movieYear.FindStringSubmatch(name); match != nil { + name = strings.TrimSpace(match[1]) + year, _ = strconv.Atoi(match[2]) + } + } + if name == "" || len(titles) >= 50 { + return nil, errors.New("Enter 1 to 50 named movies or shows.") + } + titles = append(titles, extraTitle{name: name, kind: kind, year: year}) + } + return titles, nil +} + +// posters uses the same added-date boundaries as the selected text recap. +func (a *app) posters(w http.ResponseWriter, r *http.Request) { + var extras []extraTitle + if r.Method == http.MethodPost { + body, err := io.ReadAll(io.LimitReader(r.Body, 32*1024+1)) + if err != nil || len(body) > 32*1024 { + http.Error(w, "Extra titles are too long.", http.StatusBadRequest) + return + } + extras, err = parseExtraTitles(string(body), a.labels) + if err != nil || len(extras) == 0 { + http.Error(w, "Use Movies: and Shows: headings with bullet entries, up to 50 titles.", http.StatusBadRequest) + return + } + } + now := a.now().In(a.location) + start, end := a.weekStart.start(now), now + switch r.URL.Query().Get("period") { + case "current-week": + case "week", "month": + offset, ok := readOffset(w, r) + if !ok { + return + } + if r.URL.Query().Get("period") == "month" { + start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, a.location).AddDate(0, offset, 0) + end = start.AddDate(0, 1, 0) + } else { + start = start.AddDate(0, 0, offset*7) + end = start.AddDate(0, 0, 7) + } + default: + http.Error(w, "Invalid report period.", http.StatusBadRequest) + return + } + items, err := a.jellyfin.AddedBetween(r.Context(), start, end) + if err != nil { + http.Error(w, "Could not query Jellyfin for posters.", http.StatusBadGateway) + return + } + titles := make([]posterTitle, 0) + seen := make(map[string]bool) + for _, item := range items { + id, name, kind := item.ID, item.Name, a.labels.Movies + switch item.Type { + case "Movie": + if item.ProductionYear != nil { + name = fmt.Sprintf("%s (%d)", name, *item.ProductionYear) + } + case "Episode": + id, name, kind = item.SeriesID, item.SeriesName, a.labels.Shows + if strings.TrimSpace(name) == "" { + name = a.labels.UnknownShow + } + default: + continue + } + key := item.Type + ":" + id + if id == "" { + key += name + } + if seen[key] { + continue + } + seen[key] = true + imageURL := "" + if validPosterID.MatchString(id) { + imageURL = "/api/poster/" + url.PathEscape(id) + } + titles = append(titles, posterTitle{Name: name, Kind: kind, URL: imageURL}) + } + if len(extras) > 0 { + searcher, ok := a.jellyfin.(interface { + FindPosterTitle(context.Context, string, string, int) (string, error) + }) + for _, extra := range extras { + kind := a.labels.Movies + if extra.kind == "Series" { + kind = a.labels.Shows + } + name := extra.name + if extra.year != 0 { + name = fmt.Sprintf("%s (%d)", name, extra.year) + } + key := strings.ToLower(kind + ":" + name) + duplicate := false + for _, title := range titles { + if strings.ToLower(title.Kind+":"+title.Name) == key || + (extra.kind == "Movie" && extra.year == 0 && title.Kind == kind && + movieYear.MatchString(title.Name) && strings.EqualFold(movieYear.FindStringSubmatch(title.Name)[1], name)) { + duplicate = true + break + } + } + if duplicate { + continue + } + imageURL := "" + if ok { + id, err := searcher.FindPosterTitle(r.Context(), extra.kind, extra.name, extra.year) + if err != nil { + http.Error(w, "Could not query Jellyfin for extra posters.", http.StatusBadGateway) + return + } + if validPosterID.MatchString(id) { + imageURL = "/api/poster/" + url.PathEscape(id) + } + } + titles = append(titles, posterTitle{Name: name, Kind: kind, URL: imageURL}) + } + } + sort.Slice(titles, func(i, j int) bool { + if titles[i].Kind != titles[j].Kind { + return titles[i].Kind < titles[j].Kind + } + return strings.ToLower(titles[i].Name) < strings.ToLower(titles[j].Name) + }) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + title := a.labels.WeeklyUpdate + if r.URL.Query().Get("period") == "month" { + title = a.labels.MonthlyUpdate + } + json.NewEncoder(w).Encode(struct { + Start string `json:"start"` + End string `json:"end"` + Title string `json:"title"` + Titles []posterTitle `json:"titles"` + }{start.Format("2006-01-02"), end.Format("2006-01-02"), title, titles}) +} + +var validPosterID = regexp.MustCompile(`^[a-zA-Z0-9-]{1,64}$`) +var errNoPoster = errors.New("poster unavailable") + +// Poster fetches bounded, resized artwork while keeping Jellyfin credentials on the server. +func (c *Client) Poster(ctx context.Context, id string) ([]byte, error) { + if !validPosterID.MatchString(id) { + return nil, errNoPoster + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/Items/"+id+"/Images/Primary?maxWidth=480&maxHeight=720&format=Jpg", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", `MediaBrowser Token="`+c.apiKey+`"`) + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return nil, errNoPoster + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Jellyfin poster returned HTTP %d", resp.StatusCode) + } + const maxSize = 5 << 20 + data, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1)) + if err != nil { + return nil, err + } + if len(data) > maxSize { + return nil, errors.New("poster exceeds size limit") + } + switch http.DetectContentType(data) { + case "image/jpeg", "image/png", "image/webp": + return data, nil + default: + return nil, errors.New("invalid poster image") + } +} + +// poster serves same-origin artwork so the browser can export a canvas without exposing the API key. +func (a *app) poster(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if !validPosterID.MatchString(id) { + http.Error(w, "Invalid poster ID.", http.StatusBadRequest) + return + } + source, ok := a.jellyfin.(interface { + Poster(context.Context, string) ([]byte, error) + }) + if !ok { + http.Error(w, "Posters unavailable.", http.StatusServiceUnavailable) + return + } + data, err := source.Poster(r.Context(), id) + if errors.Is(err, errNoPoster) { + http.NotFound(w, r) + return + } + if err != nil { + http.Error(w, "Could not load poster.", http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", http.DetectContentType(data)) + w.Header().Set("Cache-Control", "private, max-age=3600") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Write(data) +}
A
jellyfin-news/posters_test.go
@@ -0,0 +1,182 @@
+package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "image" + "image/png" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type searchableSource struct { + *fakeSource + calls []extraTitle +} + +func (s *searchableSource) FindPosterTitle(_ context.Context, kind, name string, year int) (string, error) { + s.calls = append(s.calls, extraTitle{name: name, kind: kind, year: year}) + if name == "Old Film" { + return "old-film", nil + } + if name == "Old Show" { + return "old-show", nil + } + return "", nil +} + +func TestPosterExtrasUseRecapFormatAndDeduplicate(t *testing.T) { + year := 2025 + source := &searchableSource{fakeSource: &fakeSource{items: []Item{{ID: "new-film", Type: "Movie", Name: "New Film", ProductionYear: &year}}}} + a := testApp(source.fakeSource) + a.jellyfin = source + body := "2026-08-31 - 2026-09-02 jellyfin weekly update:\n\nMovies:\n• New Film\n• Old Film (2022)\n• Missing Film\n\nShows:\n• Old Show — Season 3, episodes 1-12\n• Old Show — Season 4, episode 1" + r := httptest.NewRecorder() + a.routes().ServeHTTP(r, httptest.NewRequest(http.MethodPost, "/api/posters?period=current-week", strings.NewReader(body))) + if r.Code != http.StatusOK { + t.Fatalf("response = %d %s", r.Code, r.Body) + } + var data struct{ Titles []posterTitle } + if err := json.Unmarshal(r.Body.Bytes(), &data); err != nil { + t.Fatal(err) + } + if len(data.Titles) != 4 { + t.Fatalf("titles = %+v", data.Titles) + } + want := map[string]string{"New Film (2025)": "/api/poster/new-film", "Old Film (2022)": "/api/poster/old-film", "Missing Film": "", "Old Show": "/api/poster/old-show"} + for _, title := range data.Titles { + if title.URL != want[title.Name] { + t.Fatalf("title = %+v", title) + } + delete(want, title.Name) + } + if len(want) != 0 || len(source.calls) != 3 || source.calls[0] != (extraTitle{name: "Old Film", kind: "Movie", year: 2022}) || source.calls[2].name != "Old Show" { + t.Fatalf("unmatched titles = %+v, searches = %+v", want, source.calls) + } +} + +func TestPosterExtrasRejectMalformedAndOversizedInput(t *testing.T) { + for _, body := range []string{"• Film", "Movies:\n• ", "Movies:\n• Film\n•", strings.Repeat("x", 32*1024+1)} { + r := httptest.NewRecorder() + testApp(&fakeSource{}).routes().ServeHTTP(r, httptest.NewRequest(http.MethodPost, "/api/posters?period=current-week", strings.NewReader(body))) + if r.Code != http.StatusBadRequest { + t.Fatalf("input %q: response = %d", body[:min(len(body), 30)], r.Code) + } + } +} + +func TestPosterPeriodsAndGrouping(t *testing.T) { + for _, tc := range []struct{ query, start, end string }{ + {"period=current-week", "2026-08-31", "2026-09-02"}, + {"period=week&offset=-2", "2026-08-17", "2026-08-24"}, + {"period=month&offset=-1", "2026-08-01", "2026-09-01"}, + } { + t.Run(tc.query, func(t *testing.T) { + source := &fakeSource{items: []Item{ + {ID: "movie", Type: "Movie", Name: "Film"}, + {ID: "movie", Type: "Movie", Name: "Film"}, + {ID: "episode1", Type: "Episode", SeriesID: "series", SeriesName: "Show"}, + {ID: "episode2", Type: "Episode", SeriesID: "series", SeriesName: "Show"}, + {ID: "music", Type: "Audio", Name: "Song"}, + }} + r := httptest.NewRecorder() + testApp(source).routes().ServeHTTP(r, httptest.NewRequest("GET", "/api/posters?"+tc.query, nil)) + var data struct { + Start, End, Title string + Titles []posterTitle + } + if r.Code != 200 || json.Unmarshal(r.Body.Bytes(), &data) != nil { + t.Fatalf("response = %d %s", r.Code, r.Body) + } + if data.Start != tc.start || data.End != tc.end || source.start.Format("2006-01-02") != tc.start || source.end.Format("2006-01-02") != tc.end { + t.Fatalf("wrong boundaries: %+v, %v to %v", data, source.start, source.end) + } + if len(data.Titles) != 2 || data.Titles[0].URL != "/api/poster/movie" || data.Titles[1].URL != "/api/poster/series" { + t.Fatalf("titles = %+v", data.Titles) + } + if strings.Contains(tc.query, "month") && data.Title != DefaultLabels().MonthlyUpdate { + t.Fatalf("wrong monthly title: %s", data.Title) + } + }) + } +} + +func TestPostersInvalidSelectionAndQueryFailure(t *testing.T) { + for _, query := range []string{"", "period=years", "period=week", "period=week&offset=0", "period=month&offset=-1201"} { + source := &fakeSource{} + r := httptest.NewRecorder() + testApp(source).routes().ServeHTTP(r, httptest.NewRequest("GET", "/api/posters?"+query, nil)) + if r.Code != 400 || !source.start.IsZero() { + t.Fatalf("%s: status %d, queried %v", query, r.Code, source.start) + } + } + r := httptest.NewRecorder() + testApp(&fakeSource{err: errors.New("private upstream details")}).routes().ServeHTTP(r, httptest.NewRequest("GET", "/api/posters?period=current-week", nil)) + if r.Code != 502 || strings.Contains(r.Body.String(), "private") { + t.Fatalf("response = %d %s", r.Code, r.Body) + } +} + +func TestPostersEmptyAndMissingIDs(t *testing.T) { + for _, items := range [][]Item{nil, {{Type: "Movie", Name: "No poster"}, {Type: "Episode", SeriesName: "No series ID"}}} { + r := httptest.NewRecorder() + testApp(&fakeSource{items: items}).routes().ServeHTTP(r, httptest.NewRequest("GET", "/api/posters?period=current-week", nil)) + var data struct{ Titles []posterTitle } + if err := json.Unmarshal(r.Body.Bytes(), &data); err != nil || data.Titles == nil || len(data.Titles) != len(items) { + t.Fatalf("response = %s, error = %v", r.Body, err) + } + for _, title := range data.Titles { + if title.URL != "" { + t.Fatalf("missing ID should use a placeholder: %+v", title) + } + } + } +} + +func TestPosterProxy(t *testing.T) { + var artwork bytes.Buffer + if err := png.Encode(&artwork, image.NewRGBA(image.Rect(0, 0, 2, 3))); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + upstream, want int + body []byte + }{ + {"image", 200, 200, artwork.Bytes()}, + {"missing", 404, 404, nil}, + {"upstream error", 500, 502, []byte("private error")}, + {"invalid content", 200, 502, []byte("<html>not an image</html>")}, + {"oversized", 200, 502, make([]byte, (5<<20)+1)}, + } { + t.Run(tc.name, func(t *testing.T) { + client, err := New("http://jellyfin.test/base", "secret", "", &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/base/Items/movie/Images/Primary" || r.Header.Get("Authorization") != `MediaBrowser Token="secret"` || r.Header.Get("X-Emby-Token") != "" || r.URL.Query().Get("maxWidth") != "480" { + t.Errorf("wrong poster request path, credentials, or size") + } + return &http.Response{StatusCode: tc.upstream, Body: io.NopCloser(bytes.NewReader(tc.body)), Header: make(http.Header)}, nil + })}) + if err != nil { + t.Fatal(err) + } + a := testApp(&fakeSource{}) + a.jellyfin = client + r := httptest.NewRecorder() + a.routes().ServeHTTP(r, httptest.NewRequest("GET", "/api/poster/movie", nil)) + if r.Code != tc.want || strings.Contains(r.Body.String(), "private error") { + t.Fatalf("unexpected response status %d", r.Code) + } + if tc.want == 200 && (r.Header().Get("Content-Type") != "image/png" || !bytes.Equal(r.Body.Bytes(), artwork.Bytes())) { + t.Fatal("image was not preserved") + } + if _, err := client.Poster(context.Background(), "../bad"); !errors.Is(err, errNoPoster) { + t.Fatalf("invalid ID accepted: %v", err) + } + }) + } +}