jellyfin-news: add img generation with past week/month posters of added media
jump to
@@ -7,7 +7,9 @@
- **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:@@ -55,5 +57,4 @@ set +a
./jellyfin-news ``` -The API key remains on the server and is never sent to the browser. The browser only receives formatted recap text. This app has no authentication, so keep port 8080 limited to your trusted home LAN and do not expose it directly to the internet. - +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.
@@ -9,6 +9,7 @@ )
// Item is the subset of Jellyfin item metadata needed by a digest. type Item struct { + ID string Type string Name string SeriesID string
@@ -13,8 +13,13 @@ 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>@@ -45,9 +50,21 @@
<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>@@ -55,7 +72,18 @@ <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);@@ -79,9 +107,11 @@ 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) {@@ -98,6 +128,143 @@ 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;
@@ -5,26 +5,51 @@ 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']; +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' : '-1', + 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 => { requests.push(url); return { ok: true, text: async () => 'recap' }; }, + 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 }; + return { elements, requests, requestOptions, revoked, drawn, textPositions, canvas }; } test('period selection fetches nothing and watched can run first for every period', () => {@@ -76,3 +101,105 @@ 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); + } +});
@@ -4,6 +4,7 @@ import (
"context" "encoding/json" "fmt" + "io" "net/http" "net/url" "strconv"@@ -38,6 +39,7 @@ TotalRecordCount int `json:"TotalRecordCount"`
} type item struct { + ID string `json:"Id"` Type string `json:"Type"` Name string `json:"Name"` SeriesID string `json:"SeriesId"`@@ -100,6 +102,7 @@ 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,@@ -111,3 +114,41 @@ }
} 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("X-Emby-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 +}
@@ -37,7 +37,29 @@ items, err := c.AddedBetween(context.Background(), start, end)
if err != nil { t.Fatal(err) } - if len(items) != 1 || items[0].Name != "New" { + 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("X-Emby-Token") != "secret" { + 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) + } +}
@@ -31,15 +31,17 @@ 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"` + 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.@@ -74,6 +76,7 @@ // 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 {@@ -128,6 +131,12 @@ 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 {@@ -151,7 +160,14 @@ item := details[0]
if item.Type != "Movie" && item.Type != "Series" { continue } - titles = append(titles, WatchedTitle{id, item.Type, item.Name, len(viewers)}) + 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 }@@ -185,6 +201,11 @@ }
// 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")@@ -215,6 +236,17 @@ } 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) }
@@ -167,3 +167,82 @@ 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) + } + }) + } +}
@@ -80,6 +80,9 @@ 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 }
@@ -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("X-Emby-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) +}
@@ -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("X-Emby-Token") != "secret" || 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) + } + }) + } +}