From 6a9babe93175ad8623cc5cd28f017c1c8517e6a0 Mon Sep 17 00:00:00 2001 From: Olga Bulat Date: Tue, 3 Dec 2024 14:38:21 +0300 Subject: [PATCH 1/3] Add a server cache for sources --- frontend/server/api/sources.ts | 77 ++++++++++++++++++++++++++++ frontend/src/stores/provider.ts | 90 +++++++++++++-------------------- 2 files changed, 112 insertions(+), 55 deletions(-) create mode 100644 frontend/server/api/sources.ts diff --git a/frontend/server/api/sources.ts b/frontend/server/api/sources.ts new file mode 100644 index 00000000000..85cdf705e5d --- /dev/null +++ b/frontend/server/api/sources.ts @@ -0,0 +1,77 @@ +import { ofetch } from "ofetch" +import { consola } from "consola" +import { defineEventHandler } from "h3" +import { useRuntimeConfig } from "nitropack/runtime" +import { useStorage } from "nitropack/runtime/storage" + +import { supportedMediaTypes } from "#shared/constants/media" +import type { SupportedMediaType } from "#shared/constants/media" +import { mediaSlug } from "#shared/utils/query-utils" + +const UPDATE_FREQUENCY = 1000 * 60 * 60 // 1 hour + +const needsUpdate = (lastUpdated: Date | null | undefined) => { + if (!lastUpdated) { + return true + } + const timePassed = new Date().getTime() - new Date(lastUpdated).getTime() + return timePassed > UPDATE_FREQUENCY +} + +type MediaTypeCache = { data: MediaProvider[]; updatedAt: Date | null } + +type Sources = { + [K in SupportedMediaType]: MediaProvider[] +} + +const SOURCES = "sources" + +/** + * This endpoint that returns cached sources data for all supported media types, + * if the data is no older than `UPDATE_FREQUENCY`. + * Otherwise, it fetches the data from the API, caches it and returns the updated data. + */ +export default defineEventHandler(async (event) => { + const cache = useStorage(SOURCES) + + const sources = { image: [], audio: [] } as Sources + + for (const mediaType of supportedMediaTypes) { + const cacheKey = `${SOURCES}:${mediaType}` + + const cachedSources = await cache.getItem(cacheKey) + const expired = needsUpdate(cachedSources?.updatedAt) + + if (cachedSources && cachedSources.data?.length && !expired) { + sources[mediaType] = cachedSources.data + consola.debug(`Using cached ${mediaType} sources data.`) + } else { + const reason = !cachedSources + ? "cache is not set" + : !cachedSources.data?.length + ? "the cached array is empty" + : "cache is outdated" + + consola.debug(`Fetching ${mediaType} sources data because ${reason}.`) + const apiUrl = useRuntimeConfig(event).public.apiUrl + try { + const res = await ofetch( + `${apiUrl}v1/${mediaSlug(mediaType)}/stats`, + { headers: event.headers } + ) + const updatedAt = new Date() + await cache.setItem(cacheKey, { data: res, updatedAt }) + consola.info( + `Fetched ${res.length} ${mediaType} sources data on ${updatedAt.toISOString()}.` + ) + sources[mediaType] = res + } catch (error) { + consola.error("Error fetching sources data", error) + event.context.$sentry.captureException(error) + sources[mediaType] = cachedSources?.data ?? [] + } + } + } + + return sources +}) diff --git a/frontend/src/stores/provider.ts b/frontend/src/stores/provider.ts index 7fb625eac8b..e3bd51b5ab6 100644 --- a/frontend/src/stores/provider.ts +++ b/frontend/src/stores/provider.ts @@ -3,6 +3,7 @@ import { useNuxtApp } from "#imports" import { defineStore } from "pinia" import { + ALL_MEDIA, AUDIO, IMAGE, type SupportedMediaType, @@ -11,17 +12,13 @@ import { import { capitalCase } from "#shared/utils/case" import type { MediaProvider } from "#shared/types/media-provider" import type { FetchingError, FetchState } from "#shared/types/fetch-state" -import { useApiClient } from "~/composables/use-api-client" export interface ProviderState { providers: { audio: MediaProvider[] image: MediaProvider[] } - fetchState: { - audio: FetchState - image: FetchState - } + fetchState: FetchState sourceNames: { audio: string[] image: string[] @@ -49,10 +46,7 @@ export const useProviderStore = defineStore("provider", { [AUDIO]: [], [IMAGE]: [], }, - fetchState: { - [AUDIO]: { isFetching: false, hasStarted: false, fetchingError: null }, - [IMAGE]: { isFetching: false, hasStarted: false, fetchingError: null }, - }, + fetchState: { isFetching: false, fetchingError: null }, sourceNames: { [AUDIO]: [], [IMAGE]: [], @@ -60,30 +54,21 @@ export const useProviderStore = defineStore("provider", { }), actions: { - _endFetching(mediaType: SupportedMediaType, error?: FetchingError) { - this.fetchState[mediaType].fetchingError = error || null + _endFetching(error?: FetchingError) { + this.fetchState.fetchingError = error || null if (error) { - this.fetchState[mediaType].isFinished = true - this.fetchState[mediaType].hasStarted = true - } else { - this.fetchState[mediaType].hasStarted = true + this.fetchState.isFinished = true } - this.fetchState[mediaType].isFetching = false }, - _startFetching(mediaType: SupportedMediaType) { - this.fetchState[mediaType].isFetching = true - this.fetchState[mediaType].hasStarted = true + _startFetching() { + this.fetchState.isFetching = true }, - _updateFetchState( - mediaType: SupportedMediaType, - action: "start" | "end", - option?: FetchingError - ) { + _updateFetchState(action: "start" | "end", option?: FetchingError) { if (action === "start") { - this._startFetching(mediaType) + this._startFetching() } else { - this._endFetching(mediaType, option) + this._endFetching(option) } }, @@ -113,40 +98,35 @@ export const useProviderStore = defineStore("provider", { return this._getProvider(providerCode, mediaType)?.source_url }, - async fetchProviders() { - await Promise.allSettled( - supportedMediaTypes.map((mediaType) => - this.fetchMediaTypeProviders(mediaType) - ) - ) + setMediaTypeProviders( + mediaType: SupportedMediaType, + providers: MediaProvider[] + ) { + if (!providers.length) { + return + } + this.providers[mediaType] = sortProviders(providers) + this.sourceNames[mediaType] = providers.map((p) => p.source_name) }, - /** - * Fetches provider stats for a set media type. - * Does not update provider stats if there's an error. - */ - async fetchMediaTypeProviders( - mediaType: SupportedMediaType - ): Promise { - this._updateFetchState(mediaType, "start") - let sortedProviders = [] as MediaProvider[] - - const client = useApiClient() - + async fetchProviders() { + this._updateFetchState("start") try { - const res = await client.stats(mediaType) - sortedProviders = sortProviders(res ?? []) - this._updateFetchState(mediaType, "end") + const res = + await $fetch>( + `/api/sources/` + ) + if (!res) { + throw new Error("No sources data returned from the API") + } + for (const mediaType of supportedMediaTypes) { + this.setMediaTypeProviders(mediaType, res[mediaType]) + } + this._updateFetchState("end") } catch (error: unknown) { const { $processFetchingError } = useNuxtApp() - const errorData = $processFetchingError(error, mediaType, "provider") - - // Fallback on existing providers if there was an error - sortedProviders = this.providers[mediaType] - this._updateFetchState(mediaType, "end", errorData) - } finally { - this.providers[mediaType] = sortedProviders - this.sourceNames[mediaType] = sortedProviders.map((p) => p.source_name) + const errorData = $processFetchingError(error, ALL_MEDIA, "provider") + this._updateFetchState("end", errorData) } }, From 98e607ec4ac0b15133368909869d123b3c44df2c Mon Sep 17 00:00:00 2001 From: Olga Bulat Date: Wed, 4 Dec 2024 07:46:47 +0300 Subject: [PATCH 2/3] Use nitro `defineCachedFunction` for caching --- frontend/server/api/sources.ts | 93 +++++++++++++--------------------- 1 file changed, 34 insertions(+), 59 deletions(-) diff --git a/frontend/server/api/sources.ts b/frontend/server/api/sources.ts index 85cdf705e5d..7351ed58115 100644 --- a/frontend/server/api/sources.ts +++ b/frontend/server/api/sources.ts @@ -1,76 +1,51 @@ -import { ofetch } from "ofetch" import { consola } from "consola" -import { defineEventHandler } from "h3" -import { useRuntimeConfig } from "nitropack/runtime" -import { useStorage } from "nitropack/runtime/storage" - -import { supportedMediaTypes } from "#shared/constants/media" -import type { SupportedMediaType } from "#shared/constants/media" +import { useRuntimeConfig, defineCachedFunction } from "nitropack/runtime" +import { defineEventHandler, getProxyRequestHeaders, type H3Event } from "h3" + +import { + supportedMediaTypes, + type SupportedMediaType, +} from "#shared/constants/media" +import { userAgentHeader } from "#shared/constants/user-agent.mjs" import { mediaSlug } from "#shared/utils/query-utils" -const UPDATE_FREQUENCY = 1000 * 60 * 60 // 1 hour - -const needsUpdate = (lastUpdated: Date | null | undefined) => { - if (!lastUpdated) { - return true - } - const timePassed = new Date().getTime() - new Date(lastUpdated).getTime() - return timePassed > UPDATE_FREQUENCY -} - -type MediaTypeCache = { data: MediaProvider[]; updatedAt: Date | null } +const UPDATE_FREQUENCY_SECONDS = 60 * 60 // 1 hour type Sources = { [K in SupportedMediaType]: MediaProvider[] } -const SOURCES = "sources" +const getSources = defineCachedFunction( + async (mediaType: SupportedMediaType, event: H3Event) => { + const apiUrl = useRuntimeConfig(event).public.apiUrl + + consola.info(`Fetching sources for ${mediaType} media`) + + return await $fetch( + `${apiUrl}v1/${mediaSlug(mediaType)}/stats/`, + { + headers: { + ...getProxyRequestHeaders(event), + ...userAgentHeader, + }, + } + ) + }, + { + maxAge: UPDATE_FREQUENCY_SECONDS, + name: "sources", + getKey: (mediaType) => mediaType, + } +) /** - * This endpoint that returns cached sources data for all supported media types, - * if the data is no older than `UPDATE_FREQUENCY`. - * Otherwise, it fetches the data from the API, caches it and returns the updated data. + * The cached function uses stale-while-revalidate (SWR) to fetch sources for each media type only once per hour. */ export default defineEventHandler(async (event) => { - const cache = useStorage(SOURCES) - - const sources = { image: [], audio: [] } as Sources + const sources: Sources = { audio: [], image: [] } for (const mediaType of supportedMediaTypes) { - const cacheKey = `${SOURCES}:${mediaType}` - - const cachedSources = await cache.getItem(cacheKey) - const expired = needsUpdate(cachedSources?.updatedAt) - - if (cachedSources && cachedSources.data?.length && !expired) { - sources[mediaType] = cachedSources.data - consola.debug(`Using cached ${mediaType} sources data.`) - } else { - const reason = !cachedSources - ? "cache is not set" - : !cachedSources.data?.length - ? "the cached array is empty" - : "cache is outdated" - - consola.debug(`Fetching ${mediaType} sources data because ${reason}.`) - const apiUrl = useRuntimeConfig(event).public.apiUrl - try { - const res = await ofetch( - `${apiUrl}v1/${mediaSlug(mediaType)}/stats`, - { headers: event.headers } - ) - const updatedAt = new Date() - await cache.setItem(cacheKey, { data: res, updatedAt }) - consola.info( - `Fetched ${res.length} ${mediaType} sources data on ${updatedAt.toISOString()}.` - ) - sources[mediaType] = res - } catch (error) { - consola.error("Error fetching sources data", error) - event.context.$sentry.captureException(error) - sources[mediaType] = cachedSources?.data ?? [] - } - } + sources[mediaType] = await getSources(mediaType, event) } return sources From cd46aad33d5279b998da52ed0207e71ecdea01f8 Mon Sep 17 00:00:00 2001 From: Olga Bulat Date: Wed, 18 Dec 2024 13:35:39 +0300 Subject: [PATCH 3/3] Fix the unit test --- frontend/test/unit/specs/stores/provider.spec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/test/unit/specs/stores/provider.spec.ts b/frontend/test/unit/specs/stores/provider.spec.ts index efe766f2c64..5f8ae4912ad 100644 --- a/frontend/test/unit/specs/stores/provider.spec.ts +++ b/frontend/test/unit/specs/stores/provider.spec.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it } from "vitest" import { setActivePinia, createPinia } from "~~/test/unit/test-utils/pinia" -import { AUDIO, IMAGE } from "#shared/constants/media" +import { IMAGE } from "#shared/constants/media" import type { MediaProvider } from "#shared/types/media-provider" import { useProviderStore } from "~/stores/provider" @@ -42,8 +42,8 @@ describe("provider store", () => { expect(providerStore.providers.audio.length).toEqual(0) expect(providerStore.providers.image.length).toEqual(0) expect(providerStore.fetchState).toEqual({ - [AUDIO]: { hasStarted: false, isFetching: false, fetchingError: null }, - [IMAGE]: { hasStarted: false, isFetching: false, fetchingError: null }, + isFetching: false, + fetchingError: null, }) }) @@ -55,7 +55,8 @@ describe("provider store", () => { `( "getProviderName returns provider name or capitalizes providerCode", async ({ providerCode, displayName }) => { - await providerStore.$patch({ providers: { [IMAGE]: testProviders } }) + providerStore.$patch({ providers: { [IMAGE]: testProviders } }) + expect(providerStore.getProviderName(providerCode, IMAGE)).toEqual( displayName )