diff --git a/src/components/VAudioTrack/VAudioTrack.vue b/src/components/VAudioTrack/VAudioTrack.vue index 55c8361d30..0b33815957 100644 --- a/src/components/VAudioTrack/VAudioTrack.vue +++ b/src/components/VAudioTrack/VAudioTrack.vue @@ -257,7 +257,7 @@ export default defineComponent({ const mediaStore = useMediaStore() if ( route.value.params.id === props.audio.id || - mediaStore.getItemById(props.audio.id, AUDIO) + mediaStore.getItemById(AUDIO, props.audio.id) ) { /** * If switching to any route other than the single result diff --git a/src/components/VGlobalAudioSection/VGlobalAudioSection.vue b/src/components/VGlobalAudioSection/VGlobalAudioSection.vue index 943992f939..abaee14684 100644 --- a/src/components/VGlobalAudioSection/VGlobalAudioSection.vue +++ b/src/components/VGlobalAudioSection/VGlobalAudioSection.vue @@ -17,7 +17,7 @@ import { AUDIO } from '~/constants/media' import { useActiveAudio } from '~/composables/use-active-audio' import { useActiveMediaStore } from '~/stores/active-media' import { useMediaStore } from '~/stores/media' -import { useRelatedMediaStore } from '~/stores/media/related-media' +import { useSingleResultStore } from '~/stores/media/single-result' import VIconButton from '~/components/VIconButton/VIconButton.vue' import VGlobalAudioTrack from '~/components/VAudioTrack/VGlobalAudioTrack.vue' @@ -33,20 +33,19 @@ export default { setup() { const activeMediaStore = useActiveMediaStore() const mediaStore = useMediaStore() - const relatedMediaStore = useRelatedMediaStore() const route = useRoute() const activeAudio = useActiveAudio() /* Active audio track */ const getAudioItemById = (trackId) => { - if (trackId === mediaStore.state.audio?.id) { - return mediaStore.state.audio - } else { - return ( - mediaStore.getItemById(trackId, AUDIO) || - relatedMediaStore.getItemById(trackId) - ) + const audioFromMediaStore = mediaStore.getItemById(AUDIO, trackId) + if (audioFromMediaStore) { + return audioFromMediaStore + } + const singleResultStore = useSingleResultStore() + if (singleResultStore.mediaItem?.id === trackId) { + return singleResultStore.mediaItem } } const audio = computed(() => { diff --git a/src/locales/po-files/openverse.pot b/src/locales/po-files/openverse.pot index ff329d4e70..cc9e15bd52 100644 --- a/src/locales/po-files/openverse.pot +++ b/src/locales/po-files/openverse.pot @@ -4,7 +4,7 @@ msgid "" msgstr "" "Project-Id-Version: Openverse \n" "Report-Msgid-Bugs-To: https://github.com/wordpress/openverse/issues \n" -"POT-Creation-Date: 2022-04-12T10:22:32+00:00\n" +"POT-Creation-Date: 2022-04-14T06:58:09+00:00\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" @@ -1078,13 +1078,13 @@ msgid "An error occurred" msgstr "" #. Do not translate words between ### ###. -#: src/pages/image/_id.vue:150 +#: src/pages/image/_id.vue:144 msgctxt "error.image-not-found" msgid "Couldn't find image with id ###id###" msgstr "" #. Do not translate words between ### ###. -#: src/pages/audio/_id.vue:73 +#: src/pages/audio/_id.vue:71 msgctxt "error.media-not-found" msgid "Couldn't find ###mediaType### with id ###id###" msgstr "" diff --git a/src/pages/audio/_id.vue b/src/pages/audio/_id.vue index 93ef57dd2d..c0a0dd2a75 100644 --- a/src/pages/audio/_id.vue +++ b/src/pages/audio/_id.vue @@ -22,8 +22,9 @@ import { computed } from '@nuxtjs/composition-api' import { AUDIO } from '~/constants/media' -import { useMediaStore } from '~/stores/media' + import { useRelatedMediaStore } from '~/stores/media/related-media' +import { useSingleResultStore } from '~/stores/media/single-result' import VAudioDetails from '~/components/VAudioDetails/VAudioDetails.vue' import VAudioTrack from '~/components/VAudioTrack/VAudioTrack.vue' @@ -46,26 +47,23 @@ const AudioDetailPage = { } }, setup() { - const mediaStore = useMediaStore() const relatedMediaStore = useRelatedMediaStore() - const audio = computed(() => mediaStore.state.audio) const relatedMedia = computed(() => relatedMediaStore.media) const relatedFetchState = computed(() => relatedMediaStore.fetchState) - return { audio, relatedMedia, relatedFetchState } + return { relatedMedia, relatedFetchState } }, async asyncData({ route, error, app, $pinia }) { + const audioId = route.params.id + const singleResultStore = useSingleResultStore($pinia) + try { - const mediaStore = useMediaStore($pinia) - await mediaStore.fetchMediaItem({ - id: route.params.id, - mediaType: AUDIO, - }) - const relatedMediaStore = useRelatedMediaStore($pinia) - await relatedMediaStore.fetchMedia(AUDIO, route.params.id) + await singleResultStore.fetchMediaItem(AUDIO, audioId) + const audio = singleResultStore.mediaItem + return { - id: route.params.id, + audio, } } catch (err) { error({ diff --git a/src/pages/image/_id.vue b/src/pages/image/_id.vue index c541c6c450..b6e992cdb6 100644 --- a/src/pages/image/_id.vue +++ b/src/pages/image/_id.vue @@ -79,7 +79,7 @@ import axios from 'axios' import { computed } from '@nuxtjs/composition-api' import { IMAGE } from '~/constants/media' -import { useMediaStore } from '~/stores/media' +import { useSingleResultStore } from '~/stores/media/single-result' import { useRelatedMediaStore } from '~/stores/media/related-media' import VButton from '~/components/VButton.vue' @@ -114,14 +114,12 @@ const VImageDetailsPage = { } }, setup() { - const mediaStore = useMediaStore() const relatedMediaStore = useRelatedMediaStore() - const image = computed(() => mediaStore.state.image) - const relatedMedia = computed(() => relatedMediaStore.media) const relatedFetchState = computed(() => relatedMediaStore.fetchState) - return { image, relatedMedia, relatedFetchState } + + return { relatedMedia, relatedFetchState } }, computed: { sketchFabUid() { @@ -135,16 +133,12 @@ const VImageDetailsPage = { }, async asyncData({ app, error, route, $pinia }) { const imageId = route.params.id - const mediaStore = useMediaStore($pinia) - const relatedMediaStore = useRelatedMediaStore($pinia) + const singleResultStore = useSingleResultStore($pinia) try { - await mediaStore.fetchMediaItem({ - id: imageId, - mediaType: IMAGE, - }) - await relatedMediaStore.fetchMedia(IMAGE, imageId) + await singleResultStore.fetchMediaItem(IMAGE, imageId) + const image = singleResultStore.mediaItem return { - imageId: imageId, + image, } } catch (err) { const errorMessage = app.i18n.t('error.image-not-found', { diff --git a/src/pages/index.vue b/src/pages/index.vue index c1c1893c37..f4125c0479 100644 --- a/src/pages/index.vue +++ b/src/pages/index.vue @@ -182,6 +182,7 @@ const HomePage = { */ onMounted(() => { searchStore.$reset() + mediaStore.$reset() }) const featuredSearches = imageInfo.sets.map((setItem) => ({ diff --git a/src/stores/media/index.ts b/src/stores/media/index.ts index ffaae2f15b..c6beda3715 100644 --- a/src/stores/media/index.ts +++ b/src/stores/media/index.ts @@ -1,8 +1,15 @@ -import { computed, ComputedRef, reactive } from '@nuxtjs/composition-api' import { defineStore } from 'pinia' + import axios from 'axios' +import { hash, rand as prng } from '~/utils/prng' import prepareSearchQueryParams from '~/utils/prepare-search-query-params' +import type { DetailFromMediaType, Media } from '~/models/media' +import { + FetchState, + initialFetchState, + updateFetchState, +} from '~/composables/use-fetch-state' import { ALL_MEDIA, AUDIO, @@ -10,12 +17,10 @@ import { SupportedMediaType, supportedMediaTypes, } from '~/constants/media' -import type { Media, DetailFromMediaType } from '~/models/media' -import { hash, rand as prng } from '~/utils/prng' import { services } from '~/stores/media/services' -import { useProviderStore } from '~/stores/provider' import { useSearchStore } from '~/stores/search' -import { FetchState, useFetchState } from '~/composables/use-fetch-state' +import { useRelatedMediaStore } from '~/stores/media/related-media' +import { deepFreeze } from '~/utils/deep-freeze' export type MediaStoreResult = { count: number @@ -29,391 +34,339 @@ export interface MediaState { audio: MediaStoreResult image: MediaStoreResult } - fetchState: { + mediaFetchState: { audio: FetchState image: FetchState } - audio: DetailFromMediaType<'audio'> | null - image: DetailFromMediaType<'image'> | null } -export const useMediaStore = defineStore('media', () => { - /* State */ - /** - * TODO: Split media store into single-type media stores. - */ - const fetchStates = { - [AUDIO]: useFetchState(), - [IMAGE]: useFetchState(), - } - const state: MediaState = reactive({ +export const initialResults = deepFreeze({ + count: 0, + page: undefined, + pageCount: 0, + items: {}, +}) as MediaStoreResult + +export const useMediaStore = defineStore('media', { + state: (): MediaState => ({ results: { - [IMAGE]: { - count: 0, - page: undefined, - pageCount: 0, - items: {}, - }, - [AUDIO]: { - count: 0, - page: undefined, - pageCount: 0, - items: {}, - }, + [AUDIO]: { ...initialResults }, + [IMAGE]: { ...initialResults }, }, - fetchState: { - audio: fetchStates[AUDIO].fetchState, - image: fetchStates[AUDIO].fetchState, + mediaFetchState: { + [AUDIO]: { ...initialFetchState }, + [IMAGE]: { ...initialFetchState }, }, - audio: null, - image: null, - }) - - /* Getters */ + }), - const getItemById = (id: string, mediaType: SupportedMediaType) => { - return state.results[mediaType].items[id] - } - /** - * Returns object with a key for each supported media type and arrays of media items for each. - */ - const resultItems = computed(() => { - return supportedMediaTypes.reduce( - (items, type) => ({ - ...items, - [type]: Object.values(state.results[type].items), - }), - {} as Record - ) - }) - /** - * Returns result item counts for each supported media type. - */ - const resultCountsPerMediaType: ComputedRef<[SupportedMediaType, number][]> = - computed(() => - supportedMediaTypes.map((type) => [type, state.results[type].count]) - ) - /** - * Returns the total count of results for selected search type, sums all media results for ALL_MEDIA. - * If the count is more than 10000, returns 10000 to match the API result. - */ - const resultCount = computed(() => { - const types = ( - searchType.value === ALL_MEDIA ? supportedMediaTypes : [searchType.value] - ) as SupportedMediaType[] - const count = types.reduce( - (sum, mediaType) => sum + state.results[mediaType].count, - 0 - ) - return Math.min(count, 10000) - }) - /** - * Search fetching state for selected search type. For 'All content', aggregates - * the values for supported media types. - */ - const fetchState = computed(() => { - if (searchType.value === ALL_MEDIA) { - /** - * For all_media, we return the error for the first media type that has an error. - */ - const findFirstError = () => { - for (const type of supportedMediaTypes) { - if (fetchStates[type].fetchState.fetchingError) { - return fetchStates[type].fetchState.fetchingError - } - } - return null + getters: { + _searchType() { + return useSearchStore().searchType + }, + getItemById: (state) => { + return (mediaType: SupportedMediaType, id: string): Media | undefined => { + const itemFromSearchResults = state.results[mediaType].items[id] + if (itemFromSearchResults) return itemFromSearchResults + return useRelatedMediaStore().getItemById(id) } - const atLeastOne = (property: keyof FetchState) => - supportedMediaTypes.some( - (type) => fetchStates[type].fetchState[property] - ) + }, - return { - isFetching: atLeastOne('isFetching'), - fetchingError: findFirstError(), - canFetch: atLeastOne('canFetch'), - hasStarted: atLeastOne('hasStarted'), - isFinished: supportedMediaTypes.every( - (type) => fetchStates[type].fetchState.isFinished - ), - } - } else { - return fetchStates[searchType.value].fetchState - } - }) - const searchType = computed(() => useSearchStore().searchType) + /** + * Returns object with a key for each supported media type and arrays of media items for each. + */ + resultItems(state) { + return supportedMediaTypes.reduce( + (items, type) => ({ + ...items, + [type]: Object.values(state.results[type].items), + }), + {} as Record + ) + }, - const allMedia = computed(() => { - const media = resultItems.value + /** + * Returns result item counts for each supported media type. + */ + resultCountsPerMediaType(): [SupportedMediaType, number][] { + return supportedMediaTypes.map((type) => [type, this.results[type].count]) + }, - // Seed the random number generator with the ID of - // the first search result, so the non-image - // distribution is the same on repeated searches - const seedString = media[IMAGE][0]?.id - let seed: number - if (typeof seedString === 'string') { - seed = hash(seedString) - } else { - let otherTypeId = 'string' - for (const type of supportedMediaTypes.slice(1)) { - if (typeof media[type][0]?.id === 'string') { - otherTypeId = media[type][0].id - break - } - } - seed = hash(otherTypeId) - } - const rand = prng(seed) - const randomIntegerInRange = (min: number, max: number) => - Math.floor(rand() * (max - min + 1)) + min /** - * When navigating from All page to Audio page, VAllResultsGrid is displayed - * for a short period of time. Then media['image'] is undefined, and it throws an error - * `TypeError: can't convert undefined to object`. To fix it, we add `|| {}` to the media['image']. + * Returns the total count of results for selected search type, sums all media results for ALL_MEDIA. + * If the count is more than 10000, returns 10000 to match the API result. */ + resultCount(state) { + const types = ( + this._searchType === ALL_MEDIA + ? supportedMediaTypes + : [this._searchType] + ) as SupportedMediaType[] + const count = types.reduce( + (sum, mediaType) => sum + state.results[mediaType].count, + 0 + ) + return Math.min(count, 10000) + }, + /** - * First, set the results to all images + * Search fetching state for selected search type. For 'All content', aggregates + * the values for supported media types. */ - const newResults = media.image + fetchState(): FetchState { + if (this._searchType === ALL_MEDIA) { + /** + * For all_media, we return the error for the first media type that has an error. + */ + const findFirstError = () => { + for (const type of supportedMediaTypes) { + if (this.mediaFetchState[type].fetchingError) { + return this.mediaFetchState[type].fetchingError + } + } + return null + } + const atLeastOne = (property: keyof FetchState) => + supportedMediaTypes.some( + (type) => this.mediaFetchState[type][property] + ) - // push other items into the list, using a random index. - let nonImageIndex = 1 - for (const type of supportedMediaTypes.slice(1)) { - for (const item of media[type]) { - newResults.splice(nonImageIndex, 0, item) - // TODO: Fix the algorithm. Currently, when there is no images, the nonImageIndex can get higher - // than general index, and items can get discarded. - if (nonImageIndex > newResults.length + 1) break - nonImageIndex = randomIntegerInRange( - nonImageIndex + 1, - nonImageIndex + 6 - ) + return { + isFetching: atLeastOne('isFetching'), + fetchingError: findFirstError(), + canFetch: atLeastOne('canFetch'), + hasStarted: atLeastOne('hasStarted'), + isFinished: supportedMediaTypes.every( + (type) => this.mediaFetchState[type].isFinished + ), + } + } else { + return this.mediaFetchState[this._searchType] } - } + }, - return newResults - }) + allMedia(): Media[] { + const media = this.resultItems - /* Actions */ + // Seed the random number generator with the ID of + // the first search result, so the non-image + // distribution is the same on repeated searches + const seedString = media[IMAGE][0]?.id + let seed: number + if (typeof seedString === 'string') { + seed = hash(seedString) + } else { + let otherTypeId = 'string' + for (const type of supportedMediaTypes.slice(1)) { + if (typeof media[type][0]?.id === 'string') { + otherTypeId = media[type][0].id + break + } + } + seed = hash(otherTypeId) + } + const rand = prng(seed) + const randomIntegerInRange = (min: number, max: number) => + Math.floor(rand() * (max - min + 1)) + min + /** + * When navigating from All page to Audio page, VAllResultsGrid is displayed + * for a short period of time. Then media['image'] is undefined, and it throws an error + * `TypeError: can't convert undefined to object`. To fix it, we add `|| {}` to the media['image']. + */ + /** + * First, set the results to all images + */ + const newResults = media.image - const setMedia = (params: { - mediaType: T - media: Record> - mediaCount: number - page: number | undefined - pageCount: number - shouldPersistMedia: boolean | undefined - }) => { - const { - mediaType, - media, - mediaCount, - page, - pageCount, - shouldPersistMedia, - } = params - let mediaToSet - if (shouldPersistMedia) { - mediaToSet = { ...state.results[mediaType].items, ...media } as Record< - string, - DetailFromMediaType - > - } else { - mediaToSet = media - } - const mediaPage = page || 1 - state.results[mediaType].items = mediaToSet - state.results[mediaType].count = mediaCount || 0 - state.results[mediaType].page = mediaCount === 0 ? undefined : mediaPage - state.results[mediaType].pageCount = pageCount - if (mediaPage >= pageCount) { - fetchStates[mediaType].setFinished() - } - } - const mediaNotFound = (mediaType: SupportedMediaType) => { - throw new Error(`Media of type ${mediaType} not found`) - } - /** - * Clears the items for all passed media types, and resets fetch state. - */ - const resetMedia = (mediaType: SupportedMediaType) => { - state.results[mediaType].items = {} - state.results[mediaType].count = 0 - state.results[mediaType].page = undefined - state.results[mediaType].pageCount = 0 - } - const resetFetchState = () => { - for (const mediaType of supportedMediaTypes) { - fetchStates[mediaType].reset() - } - } + // push other items into the list, using a random index. + let nonImageIndex = 1 + for (const type of supportedMediaTypes.slice(1)) { + for (const item of media[type]) { + newResults.splice(nonImageIndex, 0, item) + // TODO: Fix the algorithm. Currently, when there is no images, the nonImageIndex can get higher + // than general index, and items can get discarded. + if (nonImageIndex > newResults.length + 1) break + nonImageIndex = randomIntegerInRange( + nonImageIndex + 1, + nonImageIndex + 6 + ) + } + } - /** - * Calls `fetchSingleMediaType` for selected media type(s). Can be called by changing the search query - * (search term or filter item), or by clicking 'Load more' button. - * If the search query changed, fetch state is reset, otherwise only the media types for which - * fetchState.isFinished is not true are fetched. - */ - const fetchMedia = async (payload: { shouldPersistMedia?: boolean } = {}) => { - const mediaType = searchType.value - if (!payload.shouldPersistMedia) { - resetFetchState() - } - const types = ( - mediaType !== ALL_MEDIA ? [mediaType] : [IMAGE, AUDIO] - ) as SupportedMediaType[] - const mediaToFetch = types.filter( - (type) => fetchStates[type].fetchState.canFetch - ) + return newResults + }, + }, - await Promise.all( - mediaToFetch.map((type) => - fetchSingleMediaType({ - mediaType: type, - shouldPersistMedia: Boolean(payload.shouldPersistMedia), - }) + actions: { + _updateFetchState( + mediaType: SupportedMediaType, + action: 'reset' | 'start' | 'end' | 'finish', + option?: string + ) { + this.mediaFetchState[mediaType] = updateFetchState( + this.mediaFetchState[mediaType], + action, + option ) - ) - } - - const clearMedia = () => { - supportedMediaTypes.forEach((mediaType) => { - resetMedia(mediaType) - }) - } - /** - * @param mediaType - the mediaType to fetch (do not use 'All_media' here) - * @param shouldPersistMedia - whether the existing media should be added to or replaced. - */ - const fetchSingleMediaType = async ({ - mediaType, - shouldPersistMedia, - }: { - mediaType: SupportedMediaType - shouldPersistMedia: boolean - }) => { - const queryParams = prepareSearchQueryParams({ - ...useSearchStore().searchQueryParams, - }) - let page - if (shouldPersistMedia) { - /** - * If `shouldPersistMedia` is true, then we increment the page that was set by a previous - * fetch. Normally, if `shouldPersistMedia` is true, `page` should have been set to 1 by the - * previous fetch. But if it wasn't and is still undefined, we set it to 0, and increment it. - */ - page = (state.results[mediaType].page ?? 0) + 1 - queryParams.page = `${page}` - } - fetchStates[mediaType].startFetching() - try { - const data = await services[mediaType].search(queryParams) + }, - const mediaCount = data.result_count - let errorMessage - if (!mediaCount) { - errorMessage = `No ${mediaType} found for this query` - page = undefined - } - fetchStates[mediaType].endFetching(errorMessage) - setMedia({ + setMedia(params: { + mediaType: T + media: Record> + mediaCount: number + page: number | undefined + pageCount: number + shouldPersistMedia: boolean | undefined + }) { + const { mediaType, - media: data.results, + media, mediaCount, - pageCount: data.page_count, - shouldPersistMedia, page, - }) - } catch (error) { - await handleMediaError({ mediaType, error }) - } - } - /** - * - */ - const fetchMediaItem = async (params: { - mediaType: SupportedMediaType - id: string - }) => { - const { mediaType } = params - try { - const mediaDetail = await services[mediaType].getMediaDetail(params.id) - const providerStore = useProviderStore() - mediaDetail.providerName = providerStore.getProviderName( - mediaDetail.provider, - mediaType - ) - if (mediaDetail.source) { - mediaDetail.sourceName = providerStore.getProviderName( - mediaDetail.source, - mediaType - ) - } - /** - * TODO: Fix this! Reason for disabling: TS incorrectly interprets the type of value with a - * dynamic key as `null` instead of Media|null. Replacing with state.image solves the typing - * error, but isn't flexible. - */ - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - state[mediaType] = mediaDetail - } catch (error: unknown) { - state[mediaType] = null - if (axios.isAxiosError(error) && error.response?.status === 404) { - mediaNotFound(mediaType) + pageCount, + shouldPersistMedia, + } = params + let mediaToSet + if (shouldPersistMedia) { + mediaToSet = { ...this.results[mediaType].items, ...media } as Record< + string, + DetailFromMediaType + > } else { - await handleMediaError({ mediaType, error }) + mediaToSet = media } - } - } - /** - * - */ - const handleMediaError = async ({ - mediaType, - error, - }: { - mediaType: SupportedMediaType - error: unknown - }) => { - let errorMessage - if (axios.isAxiosError(error)) { - errorMessage = - error.response?.status === 500 - ? 'There was a problem with our servers' - : `Request failed with status ${error.response?.status ?? 'unknown'}` - } else { - errorMessage = - error instanceof Error ? error.message : 'Oops! Something went wrong' - } - fetchStates[mediaType].endFetching(errorMessage) - if (!axios.isAxiosError(error)) { - throw new Error(errorMessage) - } - } + const mediaPage = page || 1 + this.results[mediaType].items = mediaToSet + this.results[mediaType].count = mediaCount || 0 + this.results[mediaType].page = mediaCount === 0 ? undefined : mediaPage + this.results[mediaType].pageCount = pageCount + if (mediaPage >= pageCount) { + this._updateFetchState(mediaType, 'finish') + } + }, + + mediaNotFound(mediaType: SupportedMediaType) { + throw new Error(`Media of type ${mediaType} not found`) + }, + + /** + * Clears the items for all passed media types, and resets fetch state. + */ + resetMedia(mediaType: SupportedMediaType) { + this.results[mediaType].items = {} + this.results[mediaType].count = 0 + this.results[mediaType].page = undefined + this.results[mediaType].pageCount = 0 + }, + + resetFetchState() { + for (const mediaType of supportedMediaTypes) { + this._updateFetchState(mediaType, 'reset') + } + }, - return { - state, + /** + * Calls `fetchSingleMediaType` for selected media type(s). Can be called by changing the search query + * (search term or filter item), or by clicking 'Load more' button. + * If the search query changed, fetch state is reset, otherwise only the media types for which + * fetchState.isFinished is not true are fetched. + */ + async fetchMedia(payload: { shouldPersistMedia?: boolean } = {}) { + const mediaType = this._searchType + if (!payload.shouldPersistMedia) { + this.resetFetchState() + } + const mediaToFetch = ( + (mediaType !== ALL_MEDIA + ? [mediaType] + : [IMAGE, AUDIO]) as SupportedMediaType[] + ).filter((type) => this.mediaFetchState[type].canFetch) + await Promise.all( + mediaToFetch.map((type) => + this.fetchSingleMediaType({ + mediaType: type, + shouldPersistMedia: Boolean(payload.shouldPersistMedia), + }) + ) + ) + }, - getItemById, - resultItems, - resultCountsPerMediaType, - resultCount, - fetchState, - allMedia, + clearMedia() { + supportedMediaTypes.forEach((mediaType) => { + this.resetMedia(mediaType) + }) + }, - fetchSingleMediaType, - fetchMediaItem, - fetchMedia, - clearMedia, + /** + * @param mediaType - the mediaType to fetch (do not use 'All_media' here) + * @param shouldPersistMedia - whether the existing media should be added to or replaced. + */ + async fetchSingleMediaType({ + mediaType, + shouldPersistMedia, + }: { + mediaType: SupportedMediaType + shouldPersistMedia: boolean + }) { + const queryParams = prepareSearchQueryParams({ + ...useSearchStore().searchQueryParams, + }) + let page + if (shouldPersistMedia) { + /** + * If `shouldPersistMedia` is true, then we increment the page that was set by a previous + * fetch. Normally, if `shouldPersistMedia` is true, `page` should have been set to 1 by the + * previous fetch. But if it wasn't and is still undefined, we set it to 0, and increment it. + */ + page = (this.results[mediaType].page ?? 0) + 1 + queryParams.page = `${page}` + } + this._updateFetchState(mediaType, 'start') + try { + const data = await services[mediaType].search(queryParams) - // Elements exported only for testing, should not be used by components - test: { - setMedia, - mediaNotFound, - handleMediaError, - fetchStates, + const mediaCount = data.result_count + let errorMessage + if (!mediaCount) { + errorMessage = `No ${mediaType} found for this query` + page = undefined + } + this._updateFetchState(mediaType, 'end', errorMessage) + this.setMedia({ + mediaType, + media: data.results, + mediaCount, + pageCount: data.page_count, + shouldPersistMedia, + page, + }) + } catch (error) { + await this.handleMediaError({ mediaType, error }) + } }, - } + + async handleMediaError({ + mediaType, + error, + }: { + mediaType: SupportedMediaType + error: unknown + }) { + let errorMessage + if (axios.isAxiosError(error)) { + errorMessage = + error.response?.status === 500 + ? 'There was a problem with our servers' + : `Request failed with status ${ + error.response?.status ?? 'unknown' + }` + } else { + errorMessage = + error instanceof Error ? error.message : 'Oops! Something went wrong' + } + this._updateFetchState(mediaType, 'end', errorMessage) + if (!axios.isAxiosError(error)) { + throw new Error(errorMessage) + } + }, + }, }) diff --git a/src/stores/media/related-media.ts b/src/stores/media/related-media.ts index 3bae6bac2d..970a8265d4 100644 --- a/src/stores/media/related-media.ts +++ b/src/stores/media/related-media.ts @@ -6,7 +6,7 @@ import { updateFetchState, } from '~/composables/use-fetch-state' import { services } from '~/stores/media/services' -import type { DetailFromMediaType, Media } from '~/models/media' +import type { Media } from '~/models/media' import type { SupportedMediaType } from '~/constants/media' interface RelatedMediaState { @@ -23,17 +23,19 @@ export const useRelatedMediaStore = defineStore('related-media', { }), getters: { - getItemById: (state) => (id: string) => - state.media.find((item) => item.id === id), + getItemById: + (state) => + (id: string): Media | undefined => + state.media.find((item) => item.id === id), }, actions: { async fetchMedia(mediaType: SupportedMediaType, id: string) { this.mainMediaId = id this.fetchState = updateFetchState(this.fetchState, 'start') - let media: DetailFromMediaType[] = [] + this.media = [] try { - media = ( + this.media = ( await services[mediaType].getRelatedMedia(id) ).results this.fetchState = updateFetchState(this.fetchState, 'end') @@ -43,8 +45,6 @@ export const useRelatedMediaStore = defineStore('related-media', { 'end', `Could not fetch related ${mediaType} for id ${id}` ) - } finally { - this.media = media } }, }, diff --git a/src/stores/media/single-result.ts b/src/stores/media/single-result.ts new file mode 100644 index 0000000000..3df6d388dc --- /dev/null +++ b/src/stores/media/single-result.ts @@ -0,0 +1,103 @@ +import { defineStore } from 'pinia' + +import axios from 'axios' + +import type { Media } from '~/models/media' +import type { SupportedMediaType } from '~/constants/media' +import { + FetchState, + initialFetchState, + updateFetchState, +} from '~/composables/use-fetch-state' +import { services } from '~/stores/media/services' +import { useMediaStore } from '~/stores/media/index' +import { IMAGE } from '~/constants/media' +import { useRelatedMediaStore } from '~/stores/media/related-media' +import { useProviderStore } from '~/stores/provider' + +export interface MediaItemState { + mediaItem: Media | null + mediaType: SupportedMediaType + fetchState: FetchState +} + +export const useSingleResultStore = defineStore('single-result', { + state: (): MediaItemState => ({ + mediaItem: null, + mediaType: IMAGE, + fetchState: initialFetchState, + }), + + actions: { + _updateFetchState(action: 'end' | 'start', option?: string) { + this.fetchState = updateFetchState(this.fetchState, action, option) + }, + + _addProviderName(mediaItem: Media) { + const providerStore = useProviderStore() + + mediaItem.providerName = providerStore.getProviderName( + mediaItem.provider, + mediaItem.frontendMediaType + ) + if (mediaItem.source) { + mediaItem.sourceName = providerStore.getProviderName( + mediaItem.source, + mediaItem.frontendMediaType + ) + } + return mediaItem + }, + + async fetchMediaItem(type: SupportedMediaType, id: string) { + const mediaStore = useMediaStore() + const existingItem = mediaStore.getItemById(type, id) + + // Not awaiting to make this call non-blocking + useRelatedMediaStore().fetchMedia(type, id) + if (existingItem) { + this.mediaType = existingItem.frontendMediaType + this.mediaItem = this._addProviderName(existingItem) + } else { + try { + this._updateFetchState('start') + this.mediaItem = this._addProviderName( + await services[type].getMediaDetail(id) + ) + this.mediaType = type + + this._updateFetchState('end') + } catch (error: unknown) { + this.mediaItem = null + this.mediaType = type + if (axios.isAxiosError(error) && error.response?.status === 404) { + throw new Error(`Media of type ${type} with id ${id} not found`) + } else { + this.handleMediaError(error) + } + } + } + }, + + /** + * Throws a new error with a new error message. + */ + handleMediaError(error: unknown) { + let errorMessage + if (axios.isAxiosError(error)) { + errorMessage = + error.response?.status === 500 + ? 'There was a problem with our servers' + : `Request failed with status ${ + error.response?.status ?? 'unknown' + }` + } else { + errorMessage = + error instanceof Error ? error.message : 'Oops! Something went wrong' + } + + this._updateFetchState('end', errorMessage) + throw new Error(errorMessage) + }, + }, +}) diff --git a/test/unit/specs/stores/media-store.spec.js b/test/unit/specs/stores/media-store.spec.js index 78d0015351..d71c0774ef 100644 --- a/test/unit/specs/stores/media-store.spec.js +++ b/test/unit/specs/stores/media-store.spec.js @@ -1,28 +1,15 @@ import { createPinia, setActivePinia } from 'pinia' -import { useMediaStore } from '~/stores/media' -import { ALL_MEDIA, AUDIO, IMAGE, supportedMediaTypes } from '~/constants/media' - +import { initialResults, useMediaStore } from '~/stores/media' import { useSearchStore } from '~/stores/search' +import { ALL_MEDIA, AUDIO, IMAGE, supportedMediaTypes } from '~/constants/media' import { services } from '~/stores/media/services' +import { initialFetchState } from '~/composables/use-fetch-state' jest.mock('axios', () => ({ ...jest.requireActual('axios'), isAxiosError: jest.fn((obj) => 'response' in obj), })) -const initialResults = { - count: 0, - items: {}, - page: undefined, - pageCount: 0, -} -const initialFetchState = { - canFetch: true, - fetchingError: null, - hasStarted: false, - isFetching: false, - isFinished: false, -} const uuids = [ '0dea3af1-27a4-4635-bab6-4b9fb76a59f5', @@ -53,10 +40,6 @@ const testResult = (mediaType) => ({ pageCount: 20, }) -const detailData = { - [AUDIO]: { title: 'audioDetails' }, - [IMAGE]: { title: 'ImageDetail' }, -} const searchResults = (mediaType) => ({ results: testResultItems(mediaType), result_count: 22, @@ -80,9 +63,6 @@ for (const mediaType of [AUDIO, IMAGE]) { services[mediaType].search.mockImplementation(() => Promise.resolve({ ...searchResults(mediaType) }) ) - services[mediaType].getMediaDetail.mockImplementation(() => - Promise.resolve(detailData[mediaType]) - ) } describe('Media Store', () => { @@ -91,16 +71,14 @@ describe('Media Store', () => { setActivePinia(createPinia()) const mediaStore = useMediaStore() - expect(mediaStore.state.results).toEqual({ - image: initialResults, - audio: initialResults, + expect(mediaStore.results).toEqual({ + image: { ...initialResults }, + audio: { ...initialResults }, }) - expect(mediaStore.state.fetchState).toEqual({ + expect(mediaStore.mediaFetchState).toEqual({ audio: initialFetchState, image: initialFetchState, }) - expect(mediaStore.state.audio).toEqual(null) - expect(mediaStore.state.image).toEqual(null) }) }) @@ -110,28 +88,20 @@ describe('Media Store', () => { }) it('getItemById returns undefined if there are no items', () => { const mediaStore = useMediaStore() - expect(mediaStore.getItemById('foo', IMAGE)).toBe(undefined) + expect(mediaStore.getItemById(IMAGE, 'foo')).toBe(undefined) }) it('getItemById returns correct item', () => { const mediaStore = useMediaStore() const expectedItem = { id: 'foo', title: 'ImageFoo' } - mediaStore.$patch({ - state: { - results: { - image: { items: { foo: expectedItem } }, - }, - }, - }) - expect(mediaStore.getItemById('foo', IMAGE)).toBe(expectedItem) + mediaStore.results.image.items = { foo: expectedItem } + expect(mediaStore.getItemById(IMAGE, 'foo')).toEqual(expectedItem) }) it('resultItems returns correct items', () => { const mediaStore = useMediaStore() - mediaStore.$patch({ - state: { - results: { [AUDIO]: testResult(AUDIO), [IMAGE]: testResult(IMAGE) }, - }, - }) + mediaStore.results.audio = testResult(AUDIO) + mediaStore.results.image = testResult(IMAGE) + expect(mediaStore.resultItems).toEqual({ [AUDIO]: audioItems, [IMAGE]: imageItems, @@ -139,11 +109,9 @@ describe('Media Store', () => { }) it('allMedia returns correct items', () => { const mediaStore = useMediaStore() - mediaStore.$patch({ - state: { - results: { [AUDIO]: testResult(AUDIO), image: testResult(IMAGE) }, - }, - }) + mediaStore.results.audio = testResult(AUDIO) + mediaStore.results.image = testResult(IMAGE) + expect(mediaStore.allMedia).toEqual([ imageItems[0], audioItems[0], @@ -163,11 +131,8 @@ describe('Media Store', () => { */ it('allMedia returns items even if there are no images', () => { const mediaStore = useMediaStore() - mediaStore.$patch({ - state: { - results: { [AUDIO]: testResult(AUDIO) }, - }, - }) + mediaStore.results.audio = testResult(AUDIO) + expect(mediaStore.allMedia).toEqual([ audioItems[0], audioItems[1], @@ -176,11 +141,8 @@ describe('Media Store', () => { }) it('resultCountsPerMediaType returns correct items for %s', () => { const mediaStore = useMediaStore() - mediaStore.$patch({ - state: { - results: { [IMAGE]: testResult(IMAGE) }, - }, - }) + mediaStore.results.image = testResult(IMAGE) + // image is first in the returned list expect(mediaStore.resultCountsPerMediaType).toEqual([ [IMAGE, testResult(IMAGE).count], @@ -197,17 +159,14 @@ describe('Media Store', () => { const mediaStore = useMediaStore() const searchStore = useSearchStore() searchStore.setSearchType(searchType) - mediaStore.$patch({ - state: { - results: { [IMAGE]: testResult(IMAGE) }, - }, - }) + mediaStore.results.image = testResult(IMAGE) + expect(mediaStore.resultCount).toEqual(count) }) it.each` searchType | fetchState - ${ALL_MEDIA} | ${{ canFetch: false, hasStarted: true, fetchingError: 'Error', isFetching: true, isFinished: false }} + ${ALL_MEDIA} | ${{ canFetch: false, fetchingError: 'Error', hasStarted: true, isFetching: true, isFinished: false }} ${AUDIO} | ${{ canFetch: false, fetchingError: 'Error', hasStarted: true, isFetching: false, isFinished: true }} ${IMAGE} | ${{ canFetch: false, fetchingError: null, hasStarted: true, isFetching: true, isFinished: false }} `( @@ -216,8 +175,8 @@ describe('Media Store', () => { const mediaStore = useMediaStore() const searchStore = useSearchStore() searchStore.setSearchType(searchType) - mediaStore.test.fetchStates.audio.endFetching('Error') - mediaStore.test.fetchStates.image.startFetching() + mediaStore._updateFetchState(AUDIO, 'end', 'Error') + mediaStore._updateFetchState(IMAGE, 'start') expect(mediaStore.fetchState).toEqual(fetchState) } @@ -252,7 +211,7 @@ describe('Media Store', () => { creator: 'bar', tags: [], } - mediaStore.state.results.image.items = { [img1.id]: img1 } + mediaStore.results.image.items = { [img1.id]: img1 } const params = { media: { [img2.id]: img2 }, mediaCount: 2, @@ -260,20 +219,20 @@ describe('Media Store', () => { shouldPersistMedia: true, mediaType: IMAGE, } - mediaStore.test.setMedia(params) + mediaStore.setMedia(params) - expect(mediaStore.state.results.image.items).toEqual({ + expect(mediaStore.results.image.items).toEqual({ [img1.id]: img1, [img2.id]: img2, }) - expect(mediaStore.state.results.image.count).toBe(params.mediaCount) - expect(mediaStore.state.results.image.page).toBe(params.page) + expect(mediaStore.results.image.count).toBe(params.mediaCount) + expect(mediaStore.results.image.page).toBe(params.page) }) it('setMedia updates state not persisting images', () => { const mediaStore = useMediaStore() const img = { title: 'Foo', creator: 'bar', tags: [] } - mediaStore.state.results.image.items = ['img1'] + mediaStore.results.image.items = ['img1'] const params = { media: [img], mediaCount: 2, @@ -281,9 +240,9 @@ describe('Media Store', () => { shouldPersistMedia: false, mediaType: IMAGE, } - mediaStore.test.setMedia(params) + mediaStore.setMedia(params) - expect(mediaStore.state.results.image).toEqual({ + expect(mediaStore.results.image).toEqual({ items: [img], count: params.mediaCount, page: params.page, @@ -294,19 +253,19 @@ describe('Media Store', () => { const mediaStore = useMediaStore() const img = { title: 'Foo', creator: 'bar', tags: [] } - mediaStore.state.results.image.items = ['img1'] + mediaStore.results.image.items = ['img1'] const params = { media: [img], mediaType: IMAGE } - mediaStore.test.setMedia(params) + mediaStore.setMedia(params) - expect(mediaStore.state.results.image.count).toBe(0) - expect(mediaStore.state.results.image.page).toBe(1) + expect(mediaStore.results.image.count).toBe(0) + expect(mediaStore.results.image.page).toBe(1) }) it('mediaNotFound throws an error', () => { const mediaStore = useMediaStore() - expect(() => mediaStore.test.mediaNotFound(AUDIO)).toThrow( + expect(() => mediaStore.mediaNotFound(AUDIO)).toThrow( 'Media of type audio not found' ) }) @@ -330,7 +289,7 @@ describe('Media Store', () => { page: 1, pageCount: expectedApiResult.page_count, } - const actualResult = mediaStore.state.results[mediaType] + const actualResult = mediaStore.results[mediaType] expect(actualResult).toEqual(expectedResult) } ) @@ -355,7 +314,7 @@ describe('Media Store', () => { } await mediaStore.fetchSingleMediaType(params) - expect(mediaStore.state.results[mediaType]).toEqual(initialResults) + expect(mediaStore.results[mediaType]).toEqual(initialResults) expect(mediaStore.fetchState).toEqual({ isFetching: false, canFetch: true, @@ -375,7 +334,7 @@ describe('Media Store', () => { } const expectedResult = searchResults(mediaType) await mediaStore.fetchSingleMediaType(params) - expect(mediaStore.state.results[mediaType]).toEqual({ + expect(mediaStore.results[mediaType]).toEqual({ count: expectedResult.result_count, items: expectedResult.results, page: 1, @@ -403,60 +362,16 @@ describe('Media Store', () => { mediaStore.fetchMedia() mediaStore.clearMedia() supportedMediaTypes.forEach((mediaType) => { - expect(mediaStore.state.results[mediaType]).toEqual(initialResults) + expect(mediaStore.results[mediaType]).toEqual(initialResults) }) }) - it.each(supportedMediaTypes)( - 'fetchMediaItem (%s) on success', - async (mediaType) => { - const mediaStore = useMediaStore() - - const params = { id: 'foo', mediaType } - await mediaStore.fetchMediaItem(params) - expect(mediaStore.state[mediaType]).toEqual(detailData[mediaType]) - } - ) - - it.each(supportedMediaTypes)( - 'fetchMediaItem throws not found error on request error', - async (mediaType) => { - const expectedErrorMessage = 'error' - - services[mediaType].getMediaDetail.mockImplementationOnce(() => - Promise.reject(new Error(expectedErrorMessage)) - ) - - const mediaStore = useMediaStore() - - const params = { id: 'foo', mediaType } - await expect(() => mediaStore.fetchMediaItem(params)).rejects.toThrow( - expectedErrorMessage - ) - } - ) - - it.each(supportedMediaTypes)( - 'fetchMediaItem on 404 sets fetchingError and throws a new error', - async (mediaType) => { - services[mediaType].getMediaDetail.mockImplementationOnce(() => - Promise.reject({ response: { status: 404 } }) - ) - - const mediaStore = useMediaStore() - const params = { id: 'foo', mediaType } - await expect(() => mediaStore.fetchMediaItem(params)).rejects.toThrow( - `Media of type ${mediaType} not found` - ) - } - ) - it('handleMediaError handles 500 error', () => { const mediaType = AUDIO const error = { response: { status: 500, message: 'Server error' } } const mediaStore = useMediaStore() - mediaStore.test.handleMediaError({ mediaType, error }) - expect(mediaStore.state.fetchState[mediaType].fetchingError).toEqual( + mediaStore.handleMediaError({ mediaType, error }) + expect(mediaStore.mediaFetchState[mediaType].fetchingError).toEqual( 'There was a problem with our servers' ) }) @@ -465,8 +380,8 @@ describe('Media Store', () => { const mediaType = AUDIO const error = { response: { status: 403 } } const mediaStore = useMediaStore() - mediaStore.test.handleMediaError({ mediaType, error }) - expect(mediaStore.state.fetchState[mediaType].fetchingError).toEqual( + mediaStore.handleMediaError({ mediaType, error }) + expect(mediaStore.mediaFetchState[mediaType].fetchingError).toEqual( 'Request failed with status 403' ) }) @@ -476,7 +391,7 @@ describe('Media Store', () => { const error = new Error('Server did not respond') await expect( - mediaStore.test.handleMediaError({ mediaType: AUDIO, error }) + mediaStore.handleMediaError({ mediaType: AUDIO, error }) ).rejects.toThrow(error.message) }) }) diff --git a/test/unit/specs/stores/single-result-store.spec.js b/test/unit/specs/stores/single-result-store.spec.js new file mode 100644 index 0000000000..1a63b0221c --- /dev/null +++ b/test/unit/specs/stores/single-result-store.spec.js @@ -0,0 +1,102 @@ +import { createPinia, setActivePinia } from 'pinia' + +import { initialFetchState } from '~/composables/use-fetch-state' +import { AUDIO, IMAGE, supportedMediaTypes } from '~/constants/media' +import { useMediaStore } from '~/stores/media' +import { useSingleResultStore } from '~/stores/media/single-result' +import { services } from '~/stores/media/services' + +const detailData = { + [AUDIO]: { title: 'audioDetails', id: 'audio1', frontendMediaType: AUDIO }, + [IMAGE]: { title: 'imageDetails', id: 'image1', frontendMediaType: IMAGE }, +} +jest.mock('axios', () => ({ + ...jest.requireActual('axios'), + isAxiosError: jest.fn((obj) => 'response' in obj), +})) +jest.mock('~/stores/media/services', () => ({ + services: { + audio: /** @type {import('~/data/services').MediaService} */ ({ + getMediaDetail: jest.fn(), + }), + image: /** @type {import('~/data/services').MediaService} */ ({ + getMediaDetail: jest.fn(), + }), + }, +})) +for (const mediaType of [AUDIO, IMAGE]) { + services[mediaType].getMediaDetail.mockImplementation(() => + Promise.resolve(detailData[mediaType]) + ) +} +describe('Media Item Store', () => { + describe('state', () => { + it('sets default state', () => { + setActivePinia(createPinia()) + const singleResultStore = useSingleResultStore() + expect(singleResultStore.fetchState).toEqual(initialFetchState) + expect(singleResultStore.mediaItem).toEqual(null) + expect(singleResultStore.mediaType).toEqual(IMAGE) + }) + }) + + describe('actions', () => { + beforeEach(() => { + setActivePinia(createPinia()) + useMediaStore() + }) + + it.each(supportedMediaTypes)( + 'fetchMediaItem (%s) fetches a new media if none is found in the store', + async (type) => { + const singleResultStore = useSingleResultStore() + + await singleResultStore.fetchMediaItem(type, 'foo') + expect(singleResultStore.mediaItem).toEqual(detailData[type]) + } + ) + it.each(supportedMediaTypes)( + 'fetchMediaItem (%s) re-uses existing media from the store', + async (type) => { + const singleResultStore = useSingleResultStore() + const mediaStore = useMediaStore() + mediaStore.results[type].items = { + [`${type}1`]: detailData[type], + } + await singleResultStore.fetchMediaItem(type, `${type}1`) + expect(singleResultStore.mediaItem).toEqual(detailData[type]) + } + ) + + it.each(supportedMediaTypes)( + 'fetchMediaItem throws not found error on request error', + async (type) => { + const expectedErrorMessage = 'error' + + services[type].getMediaDetail.mockImplementationOnce(() => + Promise.reject(new Error(expectedErrorMessage)) + ) + + const singleResultStore = useSingleResultStore() + + await expect(() => + singleResultStore.fetchMediaItem(type, 'foo') + ).rejects.toThrow(expectedErrorMessage) + } + ) + + it.each(supportedMediaTypes)( + 'fetchMediaItem on 404 sets fetchingError and throws a new error', + async (type) => { + services[type].getMediaDetail.mockImplementationOnce(() => + Promise.reject({ response: { status: 404 } }) + ) + const singleResultStore = useSingleResultStore() + const id = 'foo' + await expect(() => + singleResultStore.fetchMediaItem(type, id) + ).rejects.toThrow(`Media of type ${type} with id ${id} not found`) + } + ) + }) +})