Skip to content

Commit

Permalink
Add sources cache to Nuxt app (#5288)
Browse files Browse the repository at this point in the history
* Add a server cache for sources

* Use nitro `defineCachedFunction` for caching

* Fix the unit test
  • Loading branch information
obulat authored Dec 19, 2024
1 parent a048c41 commit 7b473e2
Show file tree
Hide file tree
Showing 3 changed files with 92 additions and 59 deletions.
52 changes: 52 additions & 0 deletions frontend/server/api/sources.ts
Original file line number Diff line number Diff line change
@@ -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<MediaProvider[]>(
`${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
})
90 changes: 35 additions & 55 deletions frontend/src/stores/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useNuxtApp } from "#imports"
import { defineStore } from "pinia"

import {
ALL_MEDIA,
AUDIO,
IMAGE,
type SupportedMediaType,
Expand All @@ -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[]
Expand Down Expand Up @@ -49,41 +46,29 @@ 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]: [],
},
}),

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)
}
},

Expand Down Expand Up @@ -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<void> {
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<Record<SupportedMediaType, MediaProvider[]>>(
`/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)
}
},

Expand Down
9 changes: 5 additions & 4 deletions frontend/test/unit/specs/stores/provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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,
})
})

Expand All @@ -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
)
Expand Down

0 comments on commit 7b473e2

Please sign in to comment.