From 8b70cd42bd9fe1b0f9f38dc5f2a6bd6e7badb6bf Mon Sep 17 00:00:00 2001 From: Rory& Date: Tue, 7 Jul 2026 02:26:33 +0200 Subject: [PATCH] Add SingletonCache, implement caching for tenor --- src/extensions/SingletonCache.ts | 53 +++++++++++++++++++ src/extensions/Sleep.ts | 21 ++++++++ src/extensions/index.ts | 7 +-- .../gifs/providers/TenorGifProvider.ts | 41 ++++++++------ 4 files changed, 102 insertions(+), 20 deletions(-) create mode 100644 src/extensions/SingletonCache.ts create mode 100644 src/extensions/Sleep.ts diff --git a/src/extensions/SingletonCache.ts b/src/extensions/SingletonCache.ts new file mode 100644 index 000000000..7f9f7895c --- /dev/null +++ b/src/extensions/SingletonCache.ts @@ -0,0 +1,53 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2026 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import { TimeSpan } from "./Timespan"; +import { sleep } from "./index"; + +export class SingletonCache { + private expiry: TimeSpan; + private lastUpdated: Date = new Date(0); + private isLocked: boolean = false; + private cachedValue: T; + + constructor(expiry: TimeSpan) { + this.expiry = expiry; + } + + async getOrUpdate(factory: () => Promise): Promise { + if (new TimeSpan(this.lastUpdated.getTime(), Date.now()).totalMillis < this.expiry.totalMillis) return this.cachedValue; + + if (this.isLocked) { + // avoid running the factory twice (async) + while (this.isLocked) await sleep(50); + return this.cachedValue; + } + + this.isLocked = true; + try { + const result = await factory(); + this.cachedValue = result; + return result; + } catch (e) { + console.log(`[SingletonCache] Factory method failed, returning stale value:`, e); + return this.cachedValue; + } finally { + this.isLocked = false; + } + } +} diff --git a/src/extensions/Sleep.ts b/src/extensions/Sleep.ts new file mode 100644 index 000000000..3104e0d89 --- /dev/null +++ b/src/extensions/Sleep.ts @@ -0,0 +1,21 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2026 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +export async function sleep(ms: number) { + return new Promise((resolve) => void setTimeout(resolve, ms)); +} diff --git a/src/extensions/index.ts b/src/extensions/index.ts index f22d3d3bc..67f4f9ab0 100644 --- a/src/extensions/index.ts +++ b/src/extensions/index.ts @@ -22,12 +22,9 @@ export * from "./DateBuilder"; export * from "./ElapsedTime"; export * from "./Math"; export * from "./Random"; +export * from "./SingletonCache"; +export * from "./Sleep"; export * from "./Stopwatch"; export * from "./String"; export * from "./Timespan"; export * from "./Url"; - -// TODO: move to a separate file -export async function sleep(ms: number) { - return new Promise((resolve) => void setTimeout(resolve, ms)); -} diff --git a/src/integrations/gifs/providers/TenorGifProvider.ts b/src/integrations/gifs/providers/TenorGifProvider.ts index 91b365ab9..11f5cd553 100644 --- a/src/integrations/gifs/providers/TenorGifProvider.ts +++ b/src/integrations/gifs/providers/TenorGifProvider.ts @@ -19,12 +19,18 @@ import type { IGifProvider } from "../IGifProvider"; import { GifsResponse, GifTrendingCategory, GifMediaTypes } from "@spacebar/schemas"; import { Config } from "@spacebar/util"; +import { SingletonCache, TimeSpan } from "@spacebar/extensions"; + +const TRENDING_CATEGORIES_CACHE_DURATION = TimeSpan.fromSeconds(24 * 60 * 60); // 1 day +const TRENDING_GIFS_CACHE_DURATION = TimeSpan.fromSeconds(60 * 60); // 1 hour // Tenor V1 API... Not yet shut down as of writing, but is going soon... export default class TenorGifProvider implements IGifProvider { id = "tenor"; available = true; #apiKey: string; + #trendingCategoryCache = new SingletonCache(TRENDING_CATEGORIES_CACHE_DURATION); + #trendingGifsCache = new SingletonCache(TRENDING_GIFS_CACHE_DURATION); async init(): Promise { if (!(Config.get().gif.enabled && Config.get().gif.apiKey)) { @@ -47,26 +53,31 @@ export default class TenorGifProvider implements IGifProvider { } async getTrendingCategories(query: { media_format: string; locale: string }): Promise { - const response = await fetch(`https://g.tenor.com/v1/categories?locale=${query.locale}&key=${this.#apiKey}`, { - method: "get", - headers: { "Content-Type": "application/json" }, - }); + return await this.#trendingCategoryCache.getOrUpdate(async () => { + const response = await fetch(`https://g.tenor.com/v1/categories?locale=${query.locale}&key=${this.#apiKey}`, { + method: "get", + headers: { "Content-Type": "application/json" }, + }); - const responseData = (await response.json()) as TenorCategoriesResults; - return responseData.tags.map((x) => ({ - name: x.searchterm, - src: x.image, - })) satisfies GifTrendingCategory[]; + const responseData = (await response.json()) as TenorCategoriesResults; + return responseData.tags.map((x) => ({ + name: x.searchterm, + src: x.image, + })) satisfies GifTrendingCategory[]; + }); } async getTrendingGifs(query: { media_format: string; locale: string }): Promise { - const response = await fetch(`https://g.tenor.com/v1/trending?media_format=${query.media_format}&locale=${query.locale}&key=${this.#apiKey}`, { - method: "get", - headers: { "Content-Type": "application/json" }, - }); + return await this.#trendingGifsCache.getOrUpdate(async () => { + const response = await fetch(`https://g.tenor.com/v1/trending?media_format=${query.media_format}&locale=${query.locale}&key=${this.#apiKey}`, { + method: "get", + headers: { "Content-Type": "application/json" }, + }); - const responseData = (await response.json()) as TenorTrendingResults; - return responseData.results.map(this.convertGifResult); + console.log(response); + const responseData = (await response.json()) as TenorTrendingResults; + return responseData.results.map(this.convertGifResult); + }); } private convertGifResult(result: TenorGif) {