diff --git a/frontend/server/api/sources.ts b/frontend/server/api/sources.ts new file mode 100644 index 00000000000..7351ed58115 --- /dev/null +++ b/frontend/server/api/sources.ts @@ -0,0 +1,52 @@ +import { consola } from "consola" +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_SECONDS = 60 * 60 // 1 hour + +type Sources = { + [K in SupportedMediaType]: MediaProvider[] +} + +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, + } +) + +/** + * 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 sources: Sources = { audio: [], image: [] } + + for (const mediaType of supportedMediaTypes) { + sources[mediaType] = await getSources(mediaType, event) + } + + 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) } }, 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 )