diff --git a/README.md b/README.md index 65927023..8e64a840 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Widgets and the services they read: - **Disk health**: TrueNAS, Unraid, Scrutiny - **Backup**: Duplicati, Kopia, Proxmox Backup Server, Backrest - **Downloads**: qBittorrent, Transmission, Deluge, SABnzbd, NZBGet +- **Up Next**: the next episodes, films and albums from Sonarr, Radarr and Lidarr - **Connections**: Gluetun, Psiphon Conduit, Netbird, Plausible, Umami - **Dashboard switch**: links to your other Stackyard dashboards diff --git a/api/src/widgets.js b/api/src/widgets.js index 5113c847..fa3075c7 100644 --- a/api/src/widgets.js +++ b/api/src/widgets.js @@ -26,6 +26,7 @@ const VALID_GLYPHS = new Set([ 'merge', 'panels', 'parcel', + 'calendar', ]); const VALID_FIELDTYPES = new Set([ 'text', diff --git a/api/test/config-secrets.test.js b/api/test/config-secrets.test.js index bd501b6a..7e4828b4 100644 --- a/api/test/config-secrets.test.js +++ b/api/test/config-secrets.test.js @@ -417,3 +417,64 @@ test('a login password is not refilled for another username', () => { preserveAllSecrets(incoming, stored); assert.equal(incoming.items[0].monitoring.activity.login.password, ''); }); + +function upNextWith(sources, extra = {}) { + return { items: [{ id: 'u1', type: 'widget', widgetType: 'upnext', widgetConfig: { sources, ...extra } }] }; +} +const SONARR = { app: 'sonarr', url: 'http://sonarr:8989', apiKey: 'SON', skipTls: false }; +const RADARR = { + app: 'radarr', + url: 'http://radarr:7878', + apiKey: 'RAD', + skipTls: false, + releases: ['cinema', 'digital'], +}; +const sentAs = (row, savedRow) => { + const { apiKey, ...rest } = row; + return { ...rest, apiKeySet: true, savedRow }; +}; + +test('Up Next: switching unmonitored, release types or a Link URL keeps every app key', () => { + const next = upNextWith( + [sentAs({ ...SONARR, href: 'https://tv.example' }, 0), sentAs({ ...RADARR, releases: ['physical'] }, 1)], + { unmonitored: true }, + ); + const { withheld } = preserveAllSecrets(next, upNextWith([SONARR, RADARR])); + assert.deepEqual(withheld, []); + assert.deepEqual( + next.items[0].widgetConfig.sources.map(r => r.apiKey), + ['SON', 'RAD'], + ); +}); + +test('Up Next: removing the first app keeps the second app its own key', () => { + const next = upNextWith([sentAs(RADARR, 1)]); + const { withheld } = preserveAllSecrets(next, upNextWith([SONARR, RADARR])); + assert.deepEqual(withheld, []); + assert.equal(next.items[0].widgetConfig.sources[0].apiKey, 'RAD'); +}); + +test('Up Next: an app whose address or app type changed loses only its own key', () => { + for (const change of [{ url: 'http://elsewhere:8989' }, { app: 'lidarr' }, { skipTls: true }]) { + const next = upNextWith([sentAs({ ...SONARR, ...change }, 0), sentAs(RADARR, 1)]); + const { withheld } = preserveAllSecrets(next, upNextWith([SONARR, RADARR])); + assert.deepEqual(withheld, [{ id: 'u1', label: 'u1' }], JSON.stringify(change)); + assert.deepEqual( + next.items[0].widgetConfig.sources.map(r => r.apiKey), + [undefined, 'RAD'], + JSON.stringify(change), + ); + } +}); + +test('Up Next: keys never reach the browser', () => { + const sent = scrubAllSecrets(structuredClone(upNextWith([SONARR, RADARR]))); + const rows = sent.items[0].widgetConfig.sources; + assert.deepEqual( + rows.map(r => [r.apiKey, r.apiKeySet]), + [ + [undefined, true], + [undefined, true], + ], + ); +}); diff --git a/api/test/upnext-data.test.js b/api/test/upnext-data.test.js new file mode 100644 index 00000000..20849979 --- /dev/null +++ b/api/test/upnext-data.test.js @@ -0,0 +1,447 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const widget = path.join(__dirname, '..', '..', 'ui', 'widgets', 'upnext'); +const items = require(path.join(widget, 'upnext-items.js')); +const data = require(path.join(widget, 'data.js')); +const { errorParts } = require('../test-support/widget-ctx'); +const { normalizeBase } = require('../src/widget-data'); +const { serviceLink } = require('../../ui/js/service-link.js'); + +const HOUR = 3600 * 1000; +const DAY = 24 * HOUR; +const NOW = Date.parse('2026-10-25T18:00:00Z'); + +function ctxFor(config, route) { + const calls = []; + const ctx = { + config, + normalizeBase, + serviceLink, + fetchJSON: async (url, opts = {}) => { + calls.push({ url, opts }); + return route(url, opts); + }, + ...errorParts(), + }; + return { ctx, calls }; +} + +const idOf = title => [...title].reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 100000, 7) + 1; +const ep = (title, at, season, episode, extra = {}) => ({ + seriesId: idOf(title), + series: { title, titleSlug: title.toLowerCase().replace(/ /g, '-'), runtime: 50 }, + airDateUtc: at, + seasonNumber: season, + episodeNumber: episode, + title: 'Name ' + episode, + ...extra, +}); + +const sonarrServe = episodes => url => { + const one = /\/api\/v3\/series\/(\d+)$/.exec(url); + if (one) { + const e = episodes.find(x => x.seriesId === Number(one[1])); + return e ? { status: 200, data: e.series } : { status: 404, data: {} }; + } + if (url.includes('/api/v3/calendar')) return { status: 200, data: episodes.map(({ series, ...e }) => e) }; + throw new Error('unexpected ' + url); +}; + +test('Sonarr: an episode keeps its time, code and name; TBA names are dropped', () => { + const [a, b] = items.sonarrItems([ + ep('Northbound', '2026-10-25T21:00:00Z', 2, 4), + ep('Fieldwork', '2026-10-26T20:00:00Z', 1, 8, { title: 'TBA' }), + ]); + assert.equal(a.at, '2026-10-25T21:00:00.000Z'); + assert.deepEqual([a.season, a.episode, a.name, a.path], [2, 4, 'Name 4', '/series/northbound']); + assert.equal(a.until, Date.parse('2026-10-25T21:50:00Z'), 'ends after the series runtime'); + assert.equal(b.name, ''); +}); + +test('Sonarr: a season dropped at once is one row with an episode range', () => { + const t = '2026-10-30T07:00:00Z'; + const out = items.sonarrItems( + [1, 2, 3, 4].map(n => ep('Small Hours', t, 3, n, n === 4 ? { finaleType: 'season' } : {})), + ); + assert.equal(out.length, 1); + assert.deepEqual( + [out[0].episode, out[0].lastEpisode, out[0].name, out[0].finale, out[0].premiere], + [1, 4, '', 'season', true], + ); +}); + +test('Sonarr: a daily show drops its date-based code and name, and keeps any other name', () => { + const [a, b] = items.sonarrItems([ + ep('Quiz Hour', '2026-10-26T23:00:00Z', 2026, 178, { + series: { title: 'Quiz Hour', seriesType: 'daily' }, + title: 'Mon, Oct 26, 2026', + }), + ep('Quiz Hour', '2026-10-27T23:00:00Z', 2026, 179, { series: { title: 'Quiz Hour' } }), + ]); + assert.deepEqual([a.season, a.episode, a.name, a.premiere], [null, null, '', false]); + assert.notEqual(a.at, b.at, 'two daily episodes stay two rows'); + assert.deepEqual([b.season, b.episode], [null, null], 'a season numbered by year is date-based too'); + assert.equal(b.name, 'Name 179', 'a guest list or topic is not a date'); + assert.equal( + items.sonarrItems([ep('Talk', '2026-10-27T23:00:00Z', 3, 9, { title: 'October 27, 2026' })])[0].name, + '', + ); +}); + +test('Sonarr: specials are not premieres, and rows with no series or time are skipped', () => { + const out = items.sonarrItems([ + ep('Northbound', '2026-10-25T21:00:00Z', 0, 1), + { airDateUtc: '2026-10-25T21:00:00Z', seasonNumber: 1, episodeNumber: 1 }, + ep('Fieldwork', 'not a date', 1, 1), + null, + 'x', + ]); + assert.equal(out.length, 1); + assert.equal(out[0].premiere, false); +}); + +test('Radarr: each selected release date in the window is a day row, read as a calendar day', () => { + const m = { + title: 'Glass Harbor', + titleSlug: 'glass-harbor-2026', + year: 2026, + inCinemas: '2026-09-01T00:00:00Z', + digitalRelease: '2026-10-27T00:00:00Z', + physicalRelease: '2026-11-20T00:00:00Z', + }; + const from = NOW - DAY; + const to = NOW + 14 * DAY; + const all = items.radarrItems([m], from, to); + assert.deepEqual( + all.map(i => [i.release, i.day, i.at]), + [['digital', '2026-10-27', null]], + ); + assert.equal(all[0].path, '/movie/glass-harbor-2026'); + assert.deepEqual( + items.radarrItems([m], from, NOW + 60 * DAY, ['physical']).map(i => i.release), + ['physical'], + ); + assert.deepEqual(items.radarrItems([m], from, to, []), [], 'no release types selected shows no films'); +}); + +test('Lidarr: albums carry artist and type, and link by album id', () => { + const [a] = items.lidarrItems([ + { + title: 'Low Tide', + releaseDate: '2026-10-30T00:00:00Z', + albumType: 'Album', + foreignAlbumId: 'abc', + artist: { artistName: 'Marrow Lane' }, + }, + ]); + assert.deepEqual( + [a.kind, a.day, a.artist, a.albumType, a.path], + ['album', '2026-10-30', 'Marrow Lane', 'album', '/album/abc'], + ); + assert.equal(items.lidarrItems([{ title: 'x', releaseDate: '', albumType: 'Broadcast' }]).length, 0); +}); + +test('merge: drops what has ended, removes a second instance’s copy, sorts and caps', () => { + const s1 = items.sonarrItems([ + ep('Northbound', '2026-10-25T21:00:00Z', 2, 4), + ep('Old', '2026-10-25T10:00:00Z', 1, 1), + ]); + const s2 = items.sonarrItems([ep('Northbound', '2026-10-25T21:00:00Z', 2, 4)]); + const films = items.lidarrItems([ + { title: 'Low Tide', releaseDate: '2026-10-25T00:00:00Z', artist: { artistName: 'A' } }, + ]); + const out = items.mergeItems([s1, s2, films], NOW); + assert.deepEqual( + out.map(i => i.title), + ['Low Tide', 'Northbound'], + ); + assert.ok(!('key' in out[0]) && !('until' in out[0])); + assert.equal(out[1].ends, '2026-10-25T21:50:00.000Z'); + const many = items.sonarrItems( + Array.from({ length: 30 }, (_, n) => ep('S' + n, new Date(NOW + (n + 1) * HOUR).toISOString(), 1, 2)), + ); + assert.equal(items.mergeItems([many], NOW).length, items.MAX_ITEMS); +}); + +test('data: reads every app with its key, merges, and links each row to its page', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const many = Array.from({ length: 9 }, (_, n) => ep('Show ' + n, new Date(NOW + (n + 1) * DAY).toISOString(), 1, 2)); + const { ctx, calls } = ctxFor( + { + sources: [ + { app: 'sonarr', url: 'sonarr.lan:8989', apiKey: 'k1' }, + { app: 'radarr', url: 'http://radarr.lan:7878/', apiKey: 'k2', href: 'https://films.example' }, + ], + }, + url => { + if (url.includes(':8989/')) return sonarrServe(many)(url); + if (url.includes(':7878/api/v3/calendar')) + return { + status: 200, + data: [{ title: 'Glass Harbor', titleSlug: 'glass-harbor', digitalRelease: '2026-10-27T00:00:00Z' }], + }; + throw new Error('unexpected ' + url); + }, + ); + const out = await data(ctx); + const calendars = calls.filter(c => c.url.includes('/calendar')); + assert.equal(calendars.length, 2, 'enough rows in 14 days: no second, longer read'); + assert.equal(calendars[0].opts.headers['X-Api-Key'], 'k1'); + assert.doesNotMatch(calendars[0].url, /includeSeries|unmonitored/); + assert.equal( + calls.filter(c => c.url.includes('/api/v3/series/')).length, + 9, + 'one lookup per series that can reach the list', + ); + assert.equal(out.days, 14); + assert.deepEqual(out.missing, []); + const film = out.items.find(i => i.kind === 'film'); + assert.equal(film.link, 'https://films.example/movie/glass-harbor'); + assert.ok(!('path' in film)); + assert.equal(out.items[0].link, 'http://sonarr.lan:8989/series/show-0'); +}); + +test('data: a quiet fortnight reads 90 days ahead', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const { ctx, calls } = ctxFor( + { sources: [{ app: 'lidarr', url: 'lidarr.lan', apiKey: 'k' }], unmonitored: true }, + () => ({ status: 200, data: [] }), + ); + const out = await data(ctx); + assert.equal(calls.length, 2); + assert.match(calls[0].url, /\/api\/v1\/calendar\?.*unmonitored=true.*includeArtist=true/); + const end = new URL(calls[1].url).searchParams.get('end'); + assert.equal(Date.parse(end), NOW + 90 * DAY); + assert.equal(out.days, 90); + assert.deepEqual(out.items, []); +}); + +test('data: one app failing leaves the rest and names it; all failing reports the first', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const sources = [ + { app: 'sonarr', url: 'sonarr.lan', apiKey: 'k' }, + { app: 'lidarr', url: 'lidarr.lan', apiKey: 'bad' }, + ]; + const { ctx } = ctxFor({ sources }, url => { + if (url.includes('lidarr')) return { status: 401, data: {} }; + return sonarrServe([ep('Northbound', '2026-10-25T21:00:00Z', 2, 4)])(url); + }); + const out = await data(ctx); + assert.deepEqual(out.missing, [{ app: 'lidarr', kind: 'auth' }]); + assert.equal(out.items.length, 1); + + const down = ctxFor({ sources }, () => { + throw Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }); + }); + await assert.rejects(data(down.ctx), /refused/); +}); + +test('data: a missing app, URL or key, or a key that cannot be a header, is not set up', async () => { + for (const config of [ + {}, + { sources: [{ app: 'plex', url: 'x', apiKey: 'k' }] }, + { sources: [{ app: 'sonarr', url: '', apiKey: 'k' }] }, + { sources: [{ app: 'sonarr', url: 'x', apiKey: '' }] }, + { sources: [{ app: 'sonarr', url: 'x', apiKey: 'a\r\nb' }] }, + ]) { + const { ctx, calls } = ctxFor(config, () => ({ status: 200, data: [] })); + await assert.rejects(data(ctx), e => e.kind === 'invalid', JSON.stringify(config)); + assert.equal(calls.length, 0); + } +}); + +test('data: an HTML or object reply is not a calendar', async () => { + const { ctx } = ctxFor({ sources: [{ app: 'radarr', url: 'r', apiKey: 'k' }] }, () => ({ + status: 200, + data: '', + })); + await assert.rejects(data(ctx), /not a calendar/); +}); + +test('Sonarr: only the series that can reach the list are looked up, soonest first', () => { + const eps = [ + ep('Ended', '2026-10-25T10:00:00Z', 1, 1), + ...Array.from({ length: 20 }, (_, n) => ep('S' + n, new Date(NOW + (n + 1) * HOUR).toISOString(), 1, 1)), + { ...ep('S0', new Date(NOW + 30 * HOUR).toISOString(), 1, 2) }, + { seriesId: 'x', airDateUtc: new Date(NOW + HOUR).toISOString() }, + ]; + const ids = items.sonarrSeriesIds(eps, NOW, 12); + assert.equal(ids.length, 12); + assert.equal(ids[0], idOf('S0')); + assert.ok(!ids.includes(idOf('Ended')), 'an episode that has ended needs no series'); +}); + +test('data: series lookups are cached per server, and a series that is gone is skipped', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const eps = [ep('Kept', '2026-10-25T21:00:00Z', 1, 2), ep('Gone', '2026-10-25T22:00:00Z', 1, 3)]; + const serve = sonarrServe(eps); + const route = url => (url.endsWith('/series/' + idOf('Gone')) ? { status: 404, data: {} } : serve(url)); + const first = ctxFor({ sources: [{ app: 'sonarr', url: 'cache.lan', apiKey: 'k' }] }, route); + const out = await data(first.ctx); + assert.deepEqual( + out.items.map(i => i.title), + ['Kept'], + ); + const again = ctxFor({ sources: [{ app: 'sonarr', url: 'cache.lan', apiKey: 'k' }] }, route); + await data(again.ctx); + assert.equal(again.calls.filter(c => c.url.endsWith('/series/' + idOf('Kept'))).length, 0, 'served from the cache'); +}); + +test('data: the card links to the first app calendar a browser can open', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const { ctx } = ctxFor( + { + sources: [ + { app: 'sonarr', url: 'http://sonarr:8989', apiKey: 'k' }, + { app: 'radarr', url: 'http://radarr.lan:7878', apiKey: 'k' }, + ], + }, + url => (url.includes('8989') ? sonarrServe([])(url) : { status: 200, data: [] }), + ); + const out = await data(ctx); + assert.equal(out.calendar, 'http://radarr.lan:7878/calendar', 'a bare container name is not a browser address'); +}); + +test('worst case: a large calendar is cut to the list without a series lookup per episode', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const eps = Array.from({ length: 20000 }, (_, n) => + ep('Show ' + (n % 400), new Date(NOW + (n + 1) * 60000).toISOString(), 1 + (n % 9), 1 + (n % 30)), + ); + const { ctx, calls } = ctxFor({ sources: [{ app: 'sonarr', url: 'big.lan', apiKey: 'k' }] }, sonarrServe(eps)); + const out = await data(ctx); + assert.equal(out.items.length, items.MAX_ITEMS); + assert.ok(calls.filter(c => c.url.includes('/series/')).length <= items.MAX_ITEMS); +}); + +test('worst case: a timeout or refusal names that app, and a failed longer read keeps the shorter one and its days', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + let radarrReads = 0; + const { ctx } = ctxFor( + { + sources: [ + { app: 'radarr', url: 'slow.lan', apiKey: 'k' }, + { app: 'lidarr', url: 'down.lan', apiKey: 'k' }, + ], + }, + url => { + if (url.includes('down.lan')) throw Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }); + radarrReads++; + if (radarrReads > 1) throw new Error('Timed out'); + return { status: 200, data: [{ title: 'Glass Harbor', digitalRelease: '2026-10-27T00:00:00Z' }] }; + }, + ); + const out = await data(ctx); + assert.deepEqual(out.missing, [{ app: 'lidarr', kind: 'network' }]); + assert.equal(out.days, 14, 'the empty state must not claim 90 days'); + assert.deepEqual( + out.items.map(i => i.title), + ['Glass Harbor'], + 'the 14-day read stands in for the failed 90-day one', + ); +}); + +test('bad config: more than six apps are cut to six, and unknown apps are ignored', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const sources = [ + { app: 'plex', url: 'x', apiKey: 'k' }, + ...Array.from({ length: 9 }, (_, n) => ({ app: 'radarr', url: `r${n}.lan`, apiKey: 'k' })), + ]; + const { ctx, calls } = ctxFor({ sources }, () => ({ status: 200, data: [] })); + await data(ctx); + const hosts = new Set(calls.map(c => new URL(c.url).hostname)); + assert.equal(hosts.size, 5, 'six rows read, one of them unknown'); +}); + +test('bad data: wrong types in a reply never throw', () => { + const junk = [ + null, + 1, + 'x', + [], + {}, + { airDateUtc: 5 }, + { series: 'x', airDateUtc: '2026-10-25T21:00:00Z' }, + { title: {}, digitalRelease: 7 }, + ]; + assert.deepEqual(items.sonarrItems(junk), []); + assert.deepEqual(items.radarrItems(junk, 0, Infinity), []); + assert.deepEqual(items.lidarrItems(junk), []); + assert.deepEqual(items.sonarrSeriesIds(junk, NOW, 12), []); + assert.deepEqual(items.sonarrItems('nope'), []); +}); + +test('data: a timeout from the proxy or the widget budget is named a timeout', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const { ctx } = ctxFor( + { + sources: [ + { app: 'sonarr', url: 'sonarr.lan', apiKey: 'k' }, + { app: 'radarr', url: 'slow.lan', apiKey: 'k' }, + ], + }, + url => { + if (url.includes('slow.lan')) throw new Error('Timed out'); + return sonarrServe([ep('Northbound', '2026-10-25T21:00:00Z', 2, 4)])(url); + }, + ); + const out = await data(ctx); + assert.deepEqual(out.missing, [{ app: 'radarr', kind: 'timeout' }]); +}); + +test('time zones: a day item outlives UTC midnight until its day ends at UTC-12', () => { + const album = items.lidarrItems([ + { title: 'Low Tide', releaseDate: '2026-10-30T00:00:00Z', artist: { artistName: 'A' } }, + ]); + const film = items.radarrItems( + [{ title: 'Glass Harbor', digitalRelease: '2026-10-30T00:00:00Z' }], + Date.parse('2026-10-29T00:00:00Z'), + Date.parse('2026-11-30T00:00:00Z'), + ['digital'], + ); + const losAngelesEvening = Date.parse('2026-10-31T00:30:00Z'); + assert.equal(items.mergeItems([album, film], losAngelesEvening).length, 2); + const lastZoneMidnight = Date.parse('2026-10-31T12:00:00Z'); + assert.equal(items.mergeItems([album, film], lastZoneMidnight).length, 0); +}); + +test('bad data: a runtime or air date past the Date range skips that row, not the widget', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const odd = [ + ep('Forever', '2026-10-25T20:00:00Z', 1, 2, { runtime: 1e15 }), + ep('Far', '+275760-09-13T00:00:00Z', 1, 2), + ]; + const { ctx } = ctxFor( + { + sources: [ + { app: 'sonarr', url: 'sonarr.lan', apiKey: 'k' }, + { app: 'radarr', url: 'radarr.lan', apiKey: 'k' }, + ], + }, + url => + url.includes('radarr') + ? { status: 200, data: [{ title: 'Glass Harbor', digitalRelease: '2026-10-27T00:00:00Z' }] } + : sonarrServe(odd)(url), + ); + const out = await data(ctx); + assert.deepEqual( + out.items.map(i => i.title), + ['Forever', 'Glass Harbor'], + ); + assert.equal(out.items[0].ends, '2026-10-25T20:50:00.000Z', 'an impossible runtime falls back to the series runtime'); +}); + +test('worst case: the series cache keeps short text only, whatever the reply size', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const huge = 'x'.repeat(100000); + const e = ep('Huge', '2026-10-25T21:00:00Z', 1, 2, { + seriesId: 424242, + series: { title: huge, titleSlug: huge, runtime: 1e15, seriesType: { a: huge } }, + }); + const { ctx } = ctxFor({ sources: [{ app: 'sonarr', url: 'huge.lan', apiKey: 'k' }] }, sonarrServe([e])); + const [row] = (await data(ctx)).items; + assert.equal(row.title.length, 300); + assert.ok(row.link.length < 400); + assert.equal(row.ends, '2026-10-25T22:00:00.000Z'); +}); diff --git a/ui/admin/index.html b/ui/admin/index.html index 7a36a740..2a66528c 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -318,6 +318,6 @@