Add SingletonCache, implement caching for tenor

This commit is contained in:
Rory&
2026-07-07 02:26:33 +02:00
parent 0042d3c5c9
commit 8b70cd42bd
4 changed files with 102 additions and 20 deletions
+53
View File
@@ -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;
}
}
}
+21
View File
@@ -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));
}
+2 -5
View File
@@ -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));
}
@@ -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) {