jellyfin-tools/jellyfin-news/index.html
jellyfin-news/index.htmlRaw
1<!doctype html>
2<html lang="en">
3<head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1">
6 <title>Jellyfin updates</title>
7 <style>
8 body { max-width: 760px; margin: 2rem auto; padding: 0 1rem; font: 16px/1.5 system-ui, sans-serif; color: #202124; }
9 h1 { margin-bottom: .25rem; }
10 .controls { display: flex; flex-wrap: wrap; gap: .65rem; margin: 1.5rem 0; align-items: center; }
11 button, input, select { font: inherit; padding: .6rem .8rem; }
12 button { cursor: pointer; }
13 input { width: 5rem; }
14 #status { min-height: 1.5rem; color: #555; }
15 textarea { box-sizing: border-box; width: 100%; min-height: 22rem; padding: 1rem; font: 15px/1.5 ui-monospace, monospace; resize: vertical; }
16 #extra-titles { min-height: 9rem; margin-top: .5rem; }
17 details { margin: 1rem 0; }
18 summary { cursor: pointer; }
19 [hidden] { display: none; }
20 .copy { margin-top: .65rem; }
21 #collage-preview { display: block; max-width: 100%; height: auto; margin-top: 1rem; }
22 button:disabled { cursor: wait; }
23 </style>
24</head>
25<body>
26 <h1>Jellyfin updates</h1>
27 <p>Choose a time period, then generate a newly added or watched recap.</p>
28
29 <div class="controls">
30 <label for="period">Time period:</label>
31 <select id="period">
32 <option value="current-week">Current week</option>
33 <option value="week">Past week</option>
34 <option value="month">Past month</option>
35 </select>
36 </div>
37
38 <div id="week-controls" class="controls" hidden>
39 <label for="week-offset">Week offset:</label>
40 <input id="week-offset" type="number" min="-1200" max="-1" value="-1" required>
41 <span>−1 is last week, −2 is two weeks ago</span>
42 </div>
43
44 <div id="month-controls" class="controls" hidden>
45 <label for="month-offset">Month offset:</label>
46 <input id="month-offset" type="number" min="-1200" max="-1" value="-1" required>
47 <span>−1 is last month, −2 is two months ago</span>
48 </div>
49
50 <div class="controls">
51 <button id="added">Newly added in selected period</button>
52 <button id="watched">{{WATCHED_BUTTON}}</button>
53 <button id="posters">🖼 Generate poster image</button>
54 </div>
55
56 <details>
57 <summary>Add movies or shows to the poster image</summary>
58 <label for="extra-titles">Paste entries from a newly added recap:</label>
59 <textarea id="extra-titles" placeholder="Movies: • Dune (2021) Shows: • Example Show — Season 3, episodes 1-12"></textarea>
60 <p>These titles appear in the poster image only. Movies and shows must already exist in Jellyfin to use their artwork.</p>
61 </details>
62
63 <p id="status" role="status"></p>
64 <div id="collage" hidden>
65 <a id="collage-download">Download PNG</a>
66 <img id="collage-preview" alt="Posters of movies and shows added in the selected period">
67 </div>
68 <textarea id="output" readonly aria-label="Jellyfin update output" placeholder="Your recap will appear here"></textarea>
69 <button id="copy" class="copy">Copy to clipboard</button>
70
71 <script>
72 const period = document.querySelector('#period');
73 const output = document.querySelector('#output');
74 const status = document.querySelector('#status');
75 const extraTitles = document.querySelector('#extra-titles');
76 const controls = [...document.querySelectorAll('button, input, select')];
77 let collageURL;
78
79 // Release the previous export when the selection changes or a new image is requested.
80 function clearCollage() {
81 document.querySelector('#collage').hidden = true;
82 document.querySelector('#collage-preview').removeAttribute('src');
83 document.querySelector('#collage-download').removeAttribute('href');
84 if (collageURL) URL.revokeObjectURL(collageURL);
85 collageURL = null;
86 }
87
88 async function loadReport(url) {
89 controls.forEach(control => control.disabled = true);
90 status.textContent = 'Loading…';
91 try {
92 const response = await fetch(url, { headers: { Accept: 'text/plain' } });
93 const text = await response.text();
94 if (!response.ok) throw new Error(text.trim() || `Request failed (${response.status})`);
95 output.value = text;
96 status.textContent = 'Recap ready.';
97 } catch (error) {
98 status.textContent = error.message;
99 } finally {
100 controls.forEach(control => control.disabled = false);
101 }
102 }
103
104 // Period changes only update the selection; either report can run first.
105 function updatePeriod() {
106 document.querySelector('#week-controls').hidden = period.value !== 'week';
107 document.querySelector('#month-controls').hidden = period.value !== 'month';
108 output.value = '';
109 status.textContent = '';
110 clearCollage();
111 }
112 period.addEventListener('change', updatePeriod);
113 document.querySelectorAll('input').forEach(input => input.addEventListener('input', updatePeriod));
114 extraTitles.addEventListener('input', clearCollage);
115 updatePeriod();
116
117 function generateReport(watched) {
118 const selectedPeriod = period.value;
119 const params = new URLSearchParams();
120 if (selectedPeriod !== 'current-week') {
121 const offset = document.querySelector(`#${selectedPeriod}-offset`);
122 if (!offset.reportValidity()) return;
123 params.set('offset', offset.value);
124 }
125 if (watched) params.set('period', selectedPeriod);
126 const path = watched ? '/api/watched' : `/api/${selectedPeriod}`;
127 loadReport(`${path}${params.size ? `?${params}` : ''}`);
128 }
129 document.querySelector('#added').addEventListener('click', () => generateReport(false));
130 document.querySelector('#watched').addEventListener('click', () => generateReport(true));
131
132 // Missing artwork keeps its place in the collage with a labelled placeholder.
133 function loadPoster(url) {
134 return new Promise(resolve => {
135 if (!url) return resolve(null);
136 const image = new Image();
137 const timer = setTimeout(() => { image.src = ''; resolve(null); }, 35000);
138 image.onload = () => { clearTimeout(timer); resolve(image); };
139 image.onerror = () => { clearTimeout(timer); resolve(null); };
140 image.src = url;
141 });
142 }
143
144 // Wrap captions into two lines and ellipsize long titles without overflowing the tile.
145 function drawCaption(ctx, text, x, y, width) {
146 let remaining = text;
147 for (let line = 0; line < 2 && remaining; line++) {
148 let length = remaining.length;
149 while (length > 1 && ctx.measureText(remaining.slice(0, length) + (line === 1 && length < remaining.length ? '…' : '')).width > width) length--;
150 if (line === 0 && length < remaining.length) {
151 const space = remaining.lastIndexOf(' ', length);
152 if (space > 0) length = space;
153 }
154 ctx.fillText(remaining.slice(0, length) + (line === 1 && length < remaining.length ? '…' : ''), x, y + line * 23);
155 remaining = remaining.slice(length).trim();
156 }
157 }
158
159 // Draw in small batches, bounding concurrent requests and canvas memory for large months.
160 async function renderCollage(data) {
161 const columns = Math.min(6, Math.ceil(Math.sqrt(data.titles.length)));
162 const width = columns * 260 + 24;
163 const sections = new Map();
164 for (const title of data.titles) {
165 if (!sections.has(title.kind)) sections.set(title.kind, []);
166 sections.get(title.kind).push(title);
167 }
168 // Start each category on its own row beneath a single section heading.
169 const headings = [];
170 const tiles = [];
171 let height = 110;
172 for (const [kind, titles] of sections) {
173 headings.push({ kind, y: height + 26 });
174 height += 48;
175 titles.forEach((title, index) => tiles.push({
176 title,
177 x: 24 + (index % columns) * 260,
178 y: height + Math.floor(index / columns) * 420,
179 }));
180 height += Math.ceil(titles.length / columns) * 420 + 20;
181 }
182 const scale = Math.min(1, 8000 / height, Math.sqrt(16000000 / (width * height)));
183 const canvas = document.createElement('canvas');
184 canvas.width = Math.max(1, Math.floor(width * scale));
185 canvas.height = Math.max(1, Math.floor(height * scale));
186 const ctx = canvas.getContext('2d');
187 if (!ctx) throw new Error('Your browser could not create the poster image.');
188 ctx.scale(scale, scale);
189 ctx.fillStyle = '#101827';
190 ctx.fillRect(0, 0, width, height);
191 ctx.fillStyle = '#f8fafc';
192 ctx.font = 'bold 28px system-ui, sans-serif';
193 ctx.fillText(data.title, 24, 43, width - 48);
194 ctx.font = '18px system-ui, sans-serif';
195 ctx.fillStyle = '#b9c8dc';
196 ctx.fillText(`${data.start} – ${data.end}`, 24, 76, width - 48);
197 ctx.fillStyle = '#f8fafc';
198 ctx.font = 'bold 24px system-ui, sans-serif';
199 for (const heading of headings) {
200 ctx.fillText(heading.kind, 24, heading.y, width - 48);
201 }
202 let missing = 0;
203 for (let start = 0; start < tiles.length; start += 4) {
204 const batch = tiles.slice(start, start + 4);
205 const images = await Promise.all(batch.map(tile => loadPoster(tile.title.url)));
206 batch.forEach(({ title, x, y }, offset) => {
207 const image = images[offset];
208 ctx.fillStyle = '#25334a';
209 ctx.fillRect(x, y, 236, 354);
210 if (image) {
211 const fit = Math.min(236 / image.naturalWidth, 354 / image.naturalHeight);
212 const w = image.naturalWidth * fit, h = image.naturalHeight * fit;
213 ctx.drawImage(image, x + (236 - w) / 2, y + (354 - h) / 2, w, h);
214 } else {
215 missing++;
216 ctx.fillStyle = '#b9c8dc';
217 ctx.font = '18px system-ui, sans-serif';
218 ctx.fillText('Poster unavailable', x + 20, y + 180);
219 }
220 ctx.fillStyle = '#f8fafc';
221 ctx.font = 'bold 18px system-ui, sans-serif';
222 drawCaption(ctx, title.name, x, y + 380, 236);
223 });
224 status.textContent = `Generating image… ${Math.min(start + 4, data.titles.length)}/${data.titles.length}`;
225 }
226 const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
227 if (!blob) throw new Error('Could not export the image. Try a smaller period.');
228 return { blob, missing };
229 }
230
231 document.querySelector('#posters').addEventListener('click', async () => {
232 const params = new URLSearchParams({ period: period.value });
233 if (period.value !== 'current-week') {
234 const offset = document.querySelector(`#${period.value}-offset`);
235 if (!offset.reportValidity()) return;
236 params.set('offset', offset.value);
237 }
238 clearCollage();
239 controls.forEach(control => control.disabled = true);
240 status.textContent = 'Loading posters…';
241 try {
242 const extras = extraTitles.value.trim();
243 const response = await fetch(`/api/posters?${params}`, {
244 method: extras ? 'POST' : 'GET',
245 headers: { Accept: 'application/json', ...(extras ? { 'Content-Type': 'text/plain; charset=utf-8' } : {}) },
246 ...(extras ? { body: extras } : {}),
247 });
248 if (!response.ok) throw new Error((await response.text()).trim() || `Request failed (${response.status})`);
249 const data = await response.json();
250 if (!data.titles.length) {
251 status.textContent = 'No new movies or show episodes in this period.';
252 return;
253 }
254 const { blob, missing } = await renderCollage(data);
255 collageURL = URL.createObjectURL(blob);
256 document.querySelector('#collage-preview').src = collageURL;
257 const download = document.querySelector('#collage-download');
258 download.href = collageURL;
259 download.download = `jellyfin-posters-${data.start}-${data.end}.png`;
260 document.querySelector('#collage').hidden = false;
261 status.textContent = `Image ready. Use Download PNG to save it.${missing ? ` ${missing} poster(s) unavailable; placeholders included.` : ''}`;
262 } catch (error) {
263 status.textContent = error.message;
264 } finally {
265 controls.forEach(control => control.disabled = false);
266 }
267 });
268
269 document.querySelector('#copy').addEventListener('click', async () => {
270 if (!output.value) return;
271 try {
272 await navigator.clipboard.writeText(output.value);
273 } catch (_) {
274 output.select();
275 document.execCommand('copy');
276 output.setSelectionRange(0, 0);
277 }
278 status.textContent = 'Copied to clipboard.';
279 });
280 </script>
281</body>
282</html>