summary refs log tree commit diff
diff options
context:
space:
mode:
authorRory& <root@rory.gay>2026-07-07 02:26:33 +0200
committerRory& <root@rory.gay>2026-07-08 01:20:38 +0200
commit8b70cd42bd9fe1b0f9f38dc5f2a6bd6e7badb6bf (patch)
treebf002d4a810576a7c4db9202d0be6ef7d6ddf75c
parentSome cleanup with gifs (diff)
downloadserver-ts-8b70cd42bd9fe1b0f9f38dc5f2a6bd6e7badb6bf.tar.xz
Add SingletonCache, implement caching for tenor
-rw-r--r--src/extensions/SingletonCache.ts53
-rw-r--r--src/extensions/Sleep.ts21
-rw-r--r--src/extensions/index.ts7
-rw-r--r--src/integrations/gifs/providers/TenorGifProvider.ts41
4 files changed, 102 insertions, 20 deletions
diff --git a/src/extensions/SingletonCache.ts b/src/extensions/SingletonCache.ts
new file mode 100644

index 00000000..7f9f7895 --- /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 <https://www.gnu.org/licenses/>. +*/ + +import { TimeSpan } from "./Timespan"; +import { sleep } from "./index"; + +export class SingletonCache<T> { + 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<T>): Promise<T> { + 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 00000000..3104e0d8 --- /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 <https://www.gnu.org/licenses/>. +*/ + +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 f22d3d3b..67f4f9ab 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 91b365ab..11f5cd55 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<GifTrendingCategory[]>(TRENDING_CATEGORIES_CACHE_DURATION); + #trendingGifsCache = new SingletonCache<GifsResponse>(TRENDING_GIFS_CACHE_DURATION); async init(): Promise<void> { 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<GifTrendingCategory[]> { - 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<GifsResponse> { - 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) {