diff --git a/app/common/util.mjs b/app/common/util.mjs index 1cb30f6..6fabd07 100644 --- a/app/common/util.mjs +++ b/app/common/util.mjs @@ -82,3 +82,15 @@ export function getXRankSeasonId(id) { ? `${parts[1]}-${parts[2]}` : id; } + +/** + * Calculate cache expiry timestamp with 5-minute buffer. + * @param {number} expiresIn - Seconds until expiry + * @returns {number} Timestamp to expire the cache (5 minutes early) + */ +export function calculateCacheExpiry(expiresIn) { + let expires = Date.now() + expiresIn * 1000; + + // Expire 5min early to make sure we have time to execute requests + return expires - 5 * 60 * 1000; +} diff --git a/app/data/LocalizationProcessor.mjs b/app/data/LocalizationProcessor.mjs index 1b4c108..4a9f180 100644 --- a/app/data/LocalizationProcessor.mjs +++ b/app/data/LocalizationProcessor.mjs @@ -102,7 +102,10 @@ export class LocalizationProcessor { return JSON.parse(result) || {}; } catch (e) { - // + // File doesn't exist yet or is invalid - return empty object + if (e.code !== 'ENOENT') { + console.warn(`Failed to read localization file ${this.filename}:`, e.message); + } } return {}; diff --git a/app/data/updaters/CoopUpdater.mjs b/app/data/updaters/CoopUpdater.mjs index ddae2ac..e7fbb78 100644 --- a/app/data/updaters/CoopUpdater.mjs +++ b/app/data/updaters/CoopUpdater.mjs @@ -22,7 +22,7 @@ export default class CoopUpdater extends DataUpdater }, ]; - getData(locale) { + async getData(locale) { return this.splatnet(locale).getCoopHistoryData(); } } diff --git a/app/data/updaters/GearUpdater.mjs b/app/data/updaters/GearUpdater.mjs index 3ddf530..c5897a8 100644 --- a/app/data/updaters/GearUpdater.mjs +++ b/app/data/updaters/GearUpdater.mjs @@ -41,7 +41,7 @@ export default class GearUpdater extends DataUpdater }, ]; - getData(locale) { + async getData(locale) { return this.splatnet(locale).getGesotownData(); } } diff --git a/app/social/clients/BlueskyClient.mjs b/app/social/clients/BlueskyClient.mjs index a0e88f4..f8946c0 100644 --- a/app/social/clients/BlueskyClient.mjs +++ b/app/social/clients/BlueskyClient.mjs @@ -31,40 +31,50 @@ export default class BlueskyClient extends Client } async send(status, generator) { - await this.login(); + if (!status.media?.length) { + console.error(`[${this.name}] No media provided for ${generator.key}`); + return; + } - // Upload images - let images = await Promise.all( - status.media.map(async m => { - // We have to convert the PNG to a JPG for Bluesky because of size limits - let jpeg = sharp(m.file).jpeg(); - let metadata = await jpeg.metadata(); - let buffer = await jpeg.toBuffer(); + try { + await this.login(); - let response = await this.#agent.uploadBlob(buffer, { encoding: 'image/jpeg' }); + // Upload images + let images = await Promise.all( + status.media.map(async m => { + // We have to convert the PNG to a JPG for Bluesky because of size limits + let jpeg = sharp(m.file).jpeg(); + let metadata = await jpeg.metadata(); + let buffer = await jpeg.toBuffer(); - return { - image: response.data.blob, - alt: m.altText || '', - aspectRatio: { width: metadata.width, height: metadata.height }, - }; - }), - ); + let response = await this.#agent.uploadBlob(buffer, { encoding: 'image/jpeg' }); - // Send status - const rt = new RichText({ - text: status.status, - }); + return { + image: response.data.blob, + alt: m.altText || '', + aspectRatio: { width: metadata.width, height: metadata.height }, + }; + }), + ); - await rt.detectFacets(this.#agent); + // Send status + const rt = new RichText({ + text: status.status, + }); - await this.#agent.post({ - text: rt.text, - facets: rt.facets, - embed: { - images, - $type: 'app.bsky.embed.images', - }, - }); + await rt.detectFacets(this.#agent); + + await this.#agent.post({ + text: rt.text, + facets: rt.facets, + embed: { + images, + $type: 'app.bsky.embed.images', + }, + }); + } catch (error) { + console.error(`[${this.name}] Failed to post ${generator.key}:`, error.message); + throw error; + } } } diff --git a/app/social/clients/FileWriter.mjs b/app/social/clients/FileWriter.mjs index 44f474f..ea40d3d 100644 --- a/app/social/clients/FileWriter.mjs +++ b/app/social/clients/FileWriter.mjs @@ -11,18 +11,20 @@ export default class FileWriter extends Client { async send(status, generator) { await mkdirp(this.dir); - let imgFilename = `${this.dir}/${generator.key}.png`; - await fs.writeFile(imgFilename, status.media[0].file); + if (status.media?.length > 0) { + let imgFilename = `${this.dir}/${generator.key}.png`; + await fs.writeFile(imgFilename, status.media[0].file); - let text = [ - 'Status:', - status.status, - '', - 'Alt text:', - status.media[0].altText, - ].join('\n'); + let text = [ + 'Status:', + status.status, + '', + 'Alt text:', + status.media[0].altText, + ].join('\n'); - let textFilename = `${this.dir}/${generator.key}.txt`; - await fs.writeFile(textFilename, text); + let textFilename = `${this.dir}/${generator.key}.txt`; + await fs.writeFile(textFilename, text); + } } } diff --git a/app/social/clients/ImageWriter.mjs b/app/social/clients/ImageWriter.mjs index 9560d2e..08fb465 100644 --- a/app/social/clients/ImageWriter.mjs +++ b/app/social/clients/ImageWriter.mjs @@ -9,6 +9,10 @@ export default class ImageWriter extends Client { dir = 'dist/status-screenshots'; // `/screenshots` points to the page used by puppeteer async send(status, generator) { + if (!status.media?.length) { + return; + } + await mkdirp(this.dir); let imgFilename = `${this.dir}/${generator.key}.png`; diff --git a/app/social/clients/MastodonClient.mjs b/app/social/clients/MastodonClient.mjs index e4c3101..cf6447f 100644 --- a/app/social/clients/MastodonClient.mjs +++ b/app/social/clients/MastodonClient.mjs @@ -23,35 +23,45 @@ export default class MastodonClient extends Client } async send(status, generator) { - // Mastodon API - const masto = await createRestAPIClient({ - url: this.#url, - accessToken: this.#accessToken, - disableVersionCheck: true, - disableExperimentalWarning: true, - }); + if (!status.media?.length) { + console.error(`[${this.name}] No media provided for ${generator.key}`); + return; + } - // Upload images - let mediaIds = await Promise.all( - status.media.map(async m => { - let request = { file: new Blob([m.file]) }; - if (m.altText) { - request.description = m.altText; - } + try { + // Mastodon API + const masto = await createRestAPIClient({ + url: this.#url, + accessToken: this.#accessToken, + disableVersionCheck: true, + disableExperimentalWarning: true, + }); - let attachment = await masto.v2.media.create(request); + // Upload images + let mediaIds = await Promise.all( + status.media.map(async m => { + let request = { file: new Blob([m.file]) }; + if (m.altText) { + request.description = m.altText; + } - return attachment.id; - }), - ); + let attachment = await masto.v2.media.create(request); - // Send status - await masto.v1.statuses.create({ - status: status.status, - spoilerText: status.contentWrapper, - sensitive: !!status.contentWrapper, // Without the sensitive property the image is still visible - mediaIds, - visibility: this.#visibility, - }); + return attachment.id; + }), + ); + + // Send status + await masto.v1.statuses.create({ + status: status.status, + spoilerText: status.contentWrapper, + sensitive: !!status.contentWrapper, // Without the sensitive property the image is still visible + mediaIds, + visibility: this.#visibility, + }); + } catch (error) { + console.error(`[${this.name}] Failed to post ${generator.key}:`, error.message); + throw error; + } } } diff --git a/app/social/clients/ThreadsClient.mjs b/app/social/clients/ThreadsClient.mjs index 2870a08..f8af2f8 100644 --- a/app/social/clients/ThreadsClient.mjs +++ b/app/social/clients/ThreadsClient.mjs @@ -29,11 +29,21 @@ export default class ThreadsClient extends Client { } async send(status, generator) { - let jpeg = await sharp(status.media[0].file).jpeg().toBuffer(); + if (!status.media?.length) { + console.error(`[${this.name}] No media provided for ${generator.key}`); + return; + } - await this.#api.publish({ - text: status.status, - image: { type: 'image/jpeg', data: jpeg }, - }); + try { + let jpeg = await sharp(status.media[0].file).jpeg().toBuffer(); + + await this.#api.publish({ + text: status.status, + image: { type: 'image/jpeg', data: jpeg }, + }); + } catch (error) { + console.error(`[${this.name}] Failed to post ${generator.key}:`, error.message); + throw error; + } } } diff --git a/app/social/clients/TwitterClient.mjs b/app/social/clients/TwitterClient.mjs index 2ca54e9..cd5c432 100644 --- a/app/social/clients/TwitterClient.mjs +++ b/app/social/clients/TwitterClient.mjs @@ -30,20 +30,30 @@ export default class TwitterClient extends Client } async send(status, generator) { - // Upload images - let mediaIds = await Promise.all( - status.media.map(async m => { - let id = await this.api().v1.uploadMedia(Buffer.from(m.file), { mimeType: m.type }); + if (!status.media?.length) { + console.error(`[${this.name}] No media provided for ${generator.key}`); + return; + } - if (m.altText) { - await this.api().v1.createMediaMetadata(id, { alt_text: { text: m.altText } }); - } + try { + // Upload images + let mediaIds = await Promise.all( + status.media.map(async m => { + let id = await this.api().v1.uploadMedia(Buffer.from(m.file), { mimeType: m.type }); - return id; - }), - ); + if (m.altText) { + await this.api().v1.createMediaMetadata(id, { alt_text: { text: m.altText } }); + } - // Send status - await this.api().v2.tweet(status.status, { media: { media_ids: mediaIds } }); + return id; + }), + ); + + // Send status + await this.api().v2.tweet(status.status, { media: { media_ids: mediaIds } }); + } catch (error) { + console.error(`[${this.name}] Failed to post ${generator.key}:`, error.message); + throw error; + } } } diff --git a/app/splatnet/NsoClient.mjs b/app/splatnet/NsoClient.mjs index 1e0e31b..132b50b 100644 --- a/app/splatnet/NsoClient.mjs +++ b/app/splatnet/NsoClient.mjs @@ -4,6 +4,7 @@ import { addUserAgent } from 'nxapi'; import pLimit from 'p-limit'; import ValueCache from '../common/ValueCache.mjs'; import prefixedConsole from '../common/prefixedConsole.mjs'; +import { calculateCacheExpiry } from '../common/util.mjs'; const coralLimit = pLimit(1); const webServiceLimit = pLimit(1); @@ -62,13 +63,6 @@ export default class NsoClient return `nso.${token}`; } - _calculateCacheExpiry(expiresIn) { - let expires = Date.now() + expiresIn * 1000; - - // Expire 5min early to make sure we have time to execute requests - return expires - 5 * 60 * 1000; - } - // Coral API _getCoralCache() { @@ -93,7 +87,7 @@ export default class NsoClient this.console.info('Creating Coral session...'); let { data } = await CoralApi.createWithSessionToken(this.nintendoToken); - let expires = this._calculateCacheExpiry(data.credential.expiresIn); + let expires = calculateCacheExpiry(data.credential.expiresIn); this.console.debug(`Caching Coral session until: ${expires}`); await this._getCoralCache().setData(data, expires); @@ -127,7 +121,7 @@ export default class NsoClient this.console.info(`Creating web service token for ID ${id}...`); let { result } = await coral.getWebServiceToken(id); - let expires = this._calculateCacheExpiry(result.expiresIn); + let expires = calculateCacheExpiry(result.expiresIn); this.console.debug(`Caching web service token for ID ${id} until: ${expires}`); await tokenCache.setData(result, expires); diff --git a/app/splatnet/SplatNet3Client.mjs b/app/splatnet/SplatNet3Client.mjs index 0de9bb7..bfb88df 100644 --- a/app/splatnet/SplatNet3Client.mjs +++ b/app/splatnet/SplatNet3Client.mjs @@ -2,6 +2,7 @@ import fs from 'fs/promises'; import pLimit from 'p-limit'; import ValueCache from '../common/ValueCache.mjs'; import prefixedConsole from '../common/prefixedConsole.mjs'; +import { calculateCacheExpiry } from '../common/util.mjs'; export const SPLATNET3_WEB_SERVICE_ID = '4834290508791808'; @@ -24,13 +25,6 @@ export default class SplatNet3Client return !!this.bulletToken; } - _calculateCacheExpiry(expiresIn) { - let expires = Date.now() + expiresIn * 1000; - - // Expire 5min early to make sure we have time to execute requests - return expires - 5 * 60 * 1000; - } - // Query hashes async _loadQueryHashes() { if (!this.queryHashes) { @@ -100,7 +94,7 @@ export default class SplatNet3Client let bulletToken = await response.json(); // We can assume the token expires after 7200 seconds - let expiry = this._calculateCacheExpiry(7200); + let expiry = calculateCacheExpiry(7200); await bulletTokenCache.setData(bulletToken, expiry); this.console.debug(`Caching bullet token until: ${expiry}`); diff --git a/src/App.vue b/src/App.vue index 923baa9..652b17b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -16,13 +16,9 @@ const data = useDataStore(); onMounted(() => data.startUpdating()); onUnmounted(() => data.stopUpdating()); -try { - // Detect mobile browsers - if (navigator.userAgent.match(/iPhone|iPad|Android/i)) { - document.body.classList.add('is-mobile'); - } -} catch (e) { - // +// Detect mobile browsers +if (navigator.userAgent.match(/iPhone|iPad|Android/i)) { + document.body.classList.add('is-mobile'); } diff --git a/src/common/i18n.mjs b/src/common/i18n.mjs index a6f49b5..688967d 100644 --- a/src/common/i18n.mjs +++ b/src/common/i18n.mjs @@ -56,6 +56,7 @@ const datetimeFormats = { }; let i18n = null; +let storageListenerAdded = false; export function initializeI18n() { if (!i18n) { @@ -69,8 +70,11 @@ export function initializeI18n() { }, }); - // Listen for local storage changes - window.addEventListener('storage', reload); + // Listen for local storage changes (guard prevents duplicate listeners in HMR) + if (!storageListenerAdded) { + window.addEventListener('storage', reload); + storageListenerAdded = true; + } reload(); } @@ -122,7 +126,7 @@ async function loadLocale() { let response = await fetch(`/data/locale/${locale}.json`); if (!response.ok) { - console.error(response); + console.error(`Failed to load locale ${locale}: ${response.status} ${response.statusText}`); return; } diff --git a/src/components/SplatfestResultsBox.vue b/src/components/SplatfestResultsBox.vue index 660f9a7..7c32d0d 100644 --- a/src/components/SplatfestResultsBox.vue +++ b/src/components/SplatfestResultsBox.vue @@ -1,5 +1,5 @@