From 104428b2d1f75e36beb48ac2322686b14c6e7c51 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:46:48 -0400 Subject: [PATCH] Cache the Unsplash wallpaper photo on the server Keep the chosen photo for 24 hours per key and collection, share one in-flight request, and replace Unsplash's error text with a fixed message. --- api/src/routes/wallpaper.js | 52 ++++++++++++--- api/test/wallpaper-unsplash.test.js | 98 +++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 api/test/wallpaper-unsplash.test.js diff --git a/api/src/routes/wallpaper.js b/api/src/routes/wallpaper.js index c187089e..baa70d82 100644 --- a/api/src/routes/wallpaper.js +++ b/api/src/routes/wallpaper.js @@ -105,17 +105,45 @@ function mayWrite(req, res) { return true; } +/* Keep the cache. Without it every request, including one sent by a page on + another site, spends a call of the key's hourly quota. */ +const PHOTO_TTL_MS = 24 * 60 * 60 * 1000; +/** @type {{ key: string, at: number, url: string } | null} */ +let _photo = null; +/** @type {{ key: string, promise: Promise<{ url: string|null, error?: string }> } | null} */ +let _pending = null; + +/** @param {{ apiKey?: string, collection?: string }} bg */ +function randomPhoto(bg) { + const key = `${bg.apiKey || ''}\n${bg.collection || ''}`; + if (_photo && _photo.key === key && Date.now() - _photo.at < PHOTO_TTL_MS) + return Promise.resolve({ url: _photo.url }); + if (_pending?.key === key) return _pending.promise; + const promise = (async () => { + const p = new URLSearchParams({ orientation: 'landscape', content_filter: 'high', client_id: bg.apiKey || '' }); + if (bg.collection) p.set('collections', bg.collection); + const r = await fetchUnchecked(`https://api.unsplash.com/photos/random?${p}`); + const raw = r.data?.urls?.raw; + if (!raw) { + log.warn('Unsplash returned no image', { status: r.status }); + return { url: null, error: 'No image returned' }; + } + const url = `${raw}&w=2800&h=1800&q=85&fm=jpg&fit=crop&crop=entropy`; + _photo = { key, at: Date.now(), url }; + return { url }; + })().finally(() => { + if (_pending?.promise === promise) _pending = null; + }); + _pending = { key, promise }; + return promise; +} + on('GET', '/api/wallpaper', async (_, res) => { const cfg = loadConfig(), bg = cfg.settings?.background || {}; if (bg.type !== 'unsplash') return json(res, 200, { url: null }); try { - const p = new URLSearchParams({ orientation: 'landscape', content_filter: 'high', client_id: bg.apiKey || '' }); - if (bg.collection) p.set('collections', bg.collection); - const r = await fetchUnchecked(`https://api.unsplash.com/photos/random?${p}`); - const raw = r.data?.urls?.raw; - if (!raw) return json(res, 200, { url: null, error: r.data?.errors?.[0] || 'No image returned' }); - json(res, 200, { url: `${raw}&w=2800&h=1800&q=85&fm=jpg&fit=crop&crop=entropy` }); + json(res, 200, await randomPhoto(bg)); } catch (e) { json(res, 200, Object.assign({ url: null }, errorBody(e))); } @@ -199,4 +227,14 @@ on('POST', '/api/wallpaper/fetch', async (req, res) => { } }); -module.exports = { storeWallpaper, pruneWallpapers, wallpapersToDrop, WALLPAPER_URL_BASE, PENDING_MS }; +module.exports = { + storeWallpaper, + pruneWallpapers, + wallpapersToDrop, + WALLPAPER_URL_BASE, + PENDING_MS, + _resetPhoto: () => { + _photo = null; + _pending = null; + }, +}; diff --git a/api/test/wallpaper-unsplash.test.js b/api/test/wallpaper-unsplash.test.js new file mode 100644 index 00000000..265ddf46 --- /dev/null +++ b/api/test/wallpaper-unsplash.test.js @@ -0,0 +1,98 @@ +const path = require('node:path'); +const { tmpDir } = require('../test-support/tmp'); +process.env.CONFIG_PATH = path.join(tmpDir('wallpaper-unsplash'), 'apps.json'); + +const { test, before, after, beforeEach, mock } = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'); + +const proxy = require('../src/proxy'); +/** @type {string[]} */ +let calls = []; +/** @type {(url: string) => Promise} */ +let reply = async () => ({ + status: 200, + data: { urls: { raw: `https://images.unsplash.com/photo-${calls.length}?ixid=x` } }, +}); +proxy.fetchUnchecked = url => { + calls.push(url); + return reply(url); +}; + +require('../src/routes'); +const { dispatch } = require('../src/router'); +const { saveConfig } = require('../src/config'); +const { _resetPhoto } = require('../src/routes/wallpaper'); + +let server, port; +let clock = Date.now(); + +function getWallpaper() { + return new Promise((resolve, reject) => { + http + .get({ host: '127.0.0.1', port, path: '/api/wallpaper', headers: { Host: '127.0.0.1' } }, res => { + let b = ''; + res.on('data', c => (b += c)); + res.on('end', () => resolve(JSON.parse(b))); + }) + .on('error', reject); + }); +} + +const unsplash = (extra = {}) => + saveConfig({ items: [], settings: { background: { type: 'unsplash', apiKey: 'key-1', ...extra } } }); + +before(async () => { + mock.method(Date, 'now', () => clock); + server = http.createServer(dispatch); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + port = server.address().port; +}); +after(() => new Promise(r => server.close(r))); + +beforeEach(() => { + calls = []; + _resetPhoto(); + unsplash(); + clock += 1000; +}); + +test('repeated requests share one Unsplash call for a day', async () => { + const first = await getWallpaper(); + for (let i = 0; i < 5; i++) assert.deepEqual(await getWallpaper(), first); + assert.equal(calls.length, 1); + clock += 24 * 60 * 60 * 1000; + assert.notDeepEqual(await getWallpaper(), first); + assert.equal(calls.length, 2); +}); + +test('requests that arrive together share one call', async () => { + const results = await Promise.all(Array.from({ length: 6 }, getWallpaper)); + assert.equal(calls.length, 1); + for (const r of results) assert.deepEqual(r, results[0]); +}); + +test('a new key or collection asks again', async () => { + await getWallpaper(); + unsplash({ apiKey: 'key-2' }); + await getWallpaper(); + unsplash({ apiKey: 'key-2', collection: 'abc' }); + await getWallpaper(); + assert.equal(calls.length, 3); + assert.ok(calls[2].includes('collections=abc')); +}); + +test("Unsplash's own error text is not passed on, and a failure is not kept", async () => { + reply = async () => ({ status: 401, data: { errors: ['OAuth error: The access token is invalid'] } }); + try { + const r = await getWallpaper(); + assert.deepEqual(r, { url: null, error: 'No image returned' }); + await getWallpaper(); + assert.equal(calls.length, 2); + } finally { + reply = async () => ({ + status: 200, + data: { urls: { raw: `https://images.unsplash.com/photo-${calls.length}?ixid=x` } }, + }); + } +});