arnaucube
arnaucube/configsPublic Self-hosted repository
configs/server-dockers/jellyfin-news/index.html
server-dockers/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:&#10;• Dune (2021)&#10;&#10;Shows:&#10;• 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>