Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add sources cache to Nuxt app #5288

Merged
merged 3 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading