From dfb6cd784e38cca7e4b86f21cab54abe82ca3a15 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Thu, 18 Jun 2026 11:33:35 -0700 Subject: [PATCH 1/2] feat(genres): dynamic genre filter on trending page from popular genres API Replace the hardcoded trending genre list with the top genres ranked by recent activity, fetched from /v1/genres/popular. Adds a `usePopularGenres` tan-query hook (top 25, ~15min staleTime to match the backend cache TTL) that merges the ranked genres with the static list, deduped and popular-first. Trending genre filter (web desktop FilterButton, web mobile genre page, and the native mobile drawer) now shows the top ranked genres by default with a search field that reveals the long-tail static genres. Freeform/community genres are now selectable via `toTrendingGenreValue`, which (unlike `toTrendingGenre`) does not drop values outside the static GENRES list. Also surfaces the ranked genres in the Explore "Trending Genres" section (web + mobile) and powers the edit-track genre suggestions, all from the same hook. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/common/src/api/index.ts | 2 + .../common/src/api/tan-query/queryKeys.ts | 2 + .../tan-query/search/useGenreSuggestions.ts | 55 ++++++++ .../api/tan-query/search/usePopularGenres.ts | 53 ++++++++ packages/common/src/messages/explore.ts | 1 + packages/common/src/models/Analytics.ts | 1 + packages/common/src/utils/genres.test.ts | 35 +++++ packages/common/src/utils/genres.ts | 122 ++++++++++++++++++ .../screens/SelectGenreScreen.tsx | 36 ++++-- .../components/ExploreContent.tsx | 2 + .../components/TrendingGenres.tsx | 86 ++++++++++++ .../trending-screen/TrendingFilterDrawer.tsx | 39 +++--- .../default/.openapi-generator/FILES | 6 + .../api/generated/default/apis/GenresApi.ts | 80 ++++++++++++ .../sdk/api/generated/default/apis/index.ts | 1 + .../generated/default/models/PopularGenre.ts | 75 +++++++++++ .../default/models/PopularGenresResponse.ts | 73 +++++++++++ .../sdk/api/generated/default/models/index.ts | 8 +- packages/sdk/src/sdk/createSdk.ts | 2 + packages/sdk/src/sdk/createSdkWithServices.ts | 3 + packages/sdk/src/sdk/index.ts | 1 + .../edit/fields/SelectGenreField.tsx | 11 +- .../TrendingGenreSelectionPage.tsx | 18 ++- .../components/TrendingGenreSelectionPage.tsx | 5 +- .../components/desktop/SearchExplorePage.tsx | 6 + .../desktop/TrendingGenresSection.tsx | 72 +++++++++++ .../components/GenreSelectionList.tsx | 12 +- .../desktop/TrendingPageContent.tsx | 36 ++++-- 28 files changed, 785 insertions(+), 58 deletions(-) create mode 100644 packages/common/src/api/tan-query/search/useGenreSuggestions.ts create mode 100644 packages/common/src/api/tan-query/search/usePopularGenres.ts create mode 100644 packages/common/src/utils/genres.test.ts create mode 100644 packages/mobile/src/screens/explore-screen/components/TrendingGenres.tsx create mode 100644 packages/sdk/src/sdk/api/generated/default/apis/GenresApi.ts create mode 100644 packages/sdk/src/sdk/api/generated/default/models/PopularGenre.ts create mode 100644 packages/sdk/src/sdk/api/generated/default/models/PopularGenresResponse.ts create mode 100644 packages/web/src/pages/search-explore-page/components/desktop/TrendingGenresSection.tsx diff --git a/packages/common/src/api/index.ts b/packages/common/src/api/index.ts index 8ddf1e8d279..82f5d93f7cd 100644 --- a/packages/common/src/api/index.ts +++ b/packages/common/src/api/index.ts @@ -85,6 +85,8 @@ export * from './tan-query/remixes/useRemixes' export * from './tan-query/search/useSearchAutocomplete' export * from './tan-query/search/useSearchResults' export * from './tan-query/search/useTopTags' +export * from './tan-query/search/useGenreSuggestions' +export * from './tan-query/search/usePopularGenres' // Tracks export * from './tan-query/tracks/useDeleteTrack' diff --git a/packages/common/src/api/tan-query/queryKeys.ts b/packages/common/src/api/tan-query/queryKeys.ts index d2387368408..7e8b20656fe 100644 --- a/packages/common/src/api/tan-query/queryKeys.ts +++ b/packages/common/src/api/tan-query/queryKeys.ts @@ -68,6 +68,8 @@ export const QUERY_KEYS = { remixersCount: 'remixersCount', trackHistory: 'trackHistory', topTags: 'topTags', + genreSuggestions: 'genreSuggestions', + popularGenres: 'popularGenres', feed: 'feed', feedTab: 'feedTab', feedFilter: 'feedFilter', diff --git a/packages/common/src/api/tan-query/search/useGenreSuggestions.ts b/packages/common/src/api/tan-query/search/useGenreSuggestions.ts new file mode 100644 index 00000000000..9dfd1c4f6ec --- /dev/null +++ b/packages/common/src/api/tan-query/search/useGenreSuggestions.ts @@ -0,0 +1,55 @@ +import { useQuery } from '@tanstack/react-query' + +import { + GenreSuggestion, + mergeGenreSuggestions, + normalizeGenre +} from '~/utils/genres' + +import { QUERY_KEYS } from '../queryKeys' +import { QueryKey, QueryOptions } from '../types' +import { useQueryContext } from '../utils' + +const COMMUNITY_GENRE_LOOKBACK_DAYS = 30 +const MIN_COMMUNITY_GENRE_COUNT = 2 + +const getGenreMetricsStartTime = () => { + const lookbackMs = COMMUNITY_GENRE_LOOKBACK_DAYS * 24 * 60 * 60 * 1000 + return Math.floor((Date.now() - lookbackMs) / 1000) +} + +export const getGenreSuggestionsQueryKey = () => + [QUERY_KEYS.genreSuggestions] as unknown as QueryKey + +export const useGenreSuggestions = (options?: QueryOptions) => { + const { audiusSdk } = useQueryContext() + + return useQuery({ + queryKey: getGenreSuggestionsQueryKey(), + queryFn: async () => { + try { + const sdk = await audiusSdk() + const json = await sdk.genres.getPopularGenres({ + startTime: getGenreMetricsStartTime(), + minCount: MIN_COMMUNITY_GENRE_COUNT + }) + const communityGenres = + json.data + ?.filter((genre) => genre.count >= MIN_COMMUNITY_GENRE_COUNT) + .map((genre) => ({ + label: normalizeGenre(genre.name), + value: genre.name, + count: genre.count + })) ?? [] + + return mergeGenreSuggestions(communityGenres) + } catch { + return mergeGenreSuggestions([]) + } + }, + staleTime: 24 * 60 * 60 * 1000, + gcTime: 24 * 60 * 60 * 1000, + ...options, + enabled: options?.enabled !== false + }) +} diff --git a/packages/common/src/api/tan-query/search/usePopularGenres.ts b/packages/common/src/api/tan-query/search/usePopularGenres.ts new file mode 100644 index 00000000000..f491d9806bf --- /dev/null +++ b/packages/common/src/api/tan-query/search/usePopularGenres.ts @@ -0,0 +1,53 @@ +import { useQuery } from '@tanstack/react-query' + +import { + GenreSuggestion, + POPULAR_GENRES_LIMIT, + mergeGenreSuggestions, + normalizeGenre +} from '~/utils/genres' + +import { QUERY_KEYS } from '../queryKeys' +import { QueryKey, QueryOptions } from '../types' +import { useQueryContext } from '../utils' + +const STALE_TIME = 15 * 60 * 1000 // 15 minutes + +export const getPopularGenresQueryKey = () => + [QUERY_KEYS.popularGenres] as unknown as QueryKey + +/** + * Fetches the currently top-ranked genres from /v1/genres/popular and merges + * them with the static genre list. Popular (ranked) genres carry a `count`; + * use {@link getTrendingGenreSuggestions} to narrow to the ranked subset. + * Powers the trending genre filter and the Explore "Trending Genres" section. + */ +export const usePopularGenres = (options?: QueryOptions) => { + const { audiusSdk } = useQueryContext() + + return useQuery({ + queryKey: getPopularGenresQueryKey(), + queryFn: async () => { + try { + const sdk = await audiusSdk() + const json = await sdk.genres.getPopularGenres({ + limit: POPULAR_GENRES_LIMIT + }) + const communityGenres = + json.data?.map((genre) => ({ + label: normalizeGenre(genre.name), + value: genre.name, + count: genre.count + })) ?? [] + + return mergeGenreSuggestions(communityGenres) + } catch { + return mergeGenreSuggestions([]) + } + }, + staleTime: STALE_TIME, + gcTime: STALE_TIME, + ...options, + enabled: options?.enabled !== false + }) +} diff --git a/packages/common/src/messages/explore.ts b/packages/common/src/messages/explore.ts index a71f9af5432..76551450db9 100644 --- a/packages/common/src/messages/explore.ts +++ b/packages/common/src/messages/explore.ts @@ -15,6 +15,7 @@ export const exploreMessages = { bestSellingAlbums: 'Best Selling Albums', exploreByMood: (category?: string) => `Explore${category ? ` ${category}` : ''} by Mood`, + trendingGenres: 'Trending Genres', quickSearch: 'Quick Search', activeDiscussions: 'Active Discussions', mostShared: 'Most Shared Tracks This Week', diff --git a/packages/common/src/models/Analytics.ts b/packages/common/src/models/Analytics.ts index b67005904fd..d2fbb4e0c8a 100644 --- a/packages/common/src/models/Analytics.ts +++ b/packages/common/src/models/Analytics.ts @@ -1313,6 +1313,7 @@ export type ExploreSectionName = | 'Fan Clubs' | 'Featured Remix Contests' | 'Underground Trending Tracks' + | 'Trending Genres' | 'Artist Spotlight' | 'Label Spotlight' | 'Active Discussions' diff --git a/packages/common/src/utils/genres.test.ts b/packages/common/src/utils/genres.test.ts new file mode 100644 index 00000000000..8e38be7ccd8 --- /dev/null +++ b/packages/common/src/utils/genres.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' + +import { mergeGenreSuggestions, normalizeGenre } from './genres' + +describe('genre suggestions', () => { + it('normalizes whitespace and casing for community genres', () => { + expect(normalizeGenre(' afro beat ')).toBe('Afro Beat') + expect(normalizeGenre('UK GARAGE')).toBe('UK Garage') + }) + + it('dedupes obvious duplicate formatting and keeps the strongest count', () => { + const suggestions = mergeGenreSuggestions( + [ + { label: 'hip hop', value: 'hip hop', count: 12 }, + { label: 'hip-hop', value: 'hip-hop', count: 5 }, + { label: 'Afro Beat', value: 'Afro Beat', count: 4 } + ], + [] + ) + + expect(suggestions).toEqual([ + { label: 'Hip Hop', value: 'Hip Hop', count: 12 }, + { label: 'Afro Beat', value: 'Afro Beat', count: 4 } + ]) + }) + + it('uses the static genre value when a community genre has the same key', () => { + const suggestions = mergeGenreSuggestions( + [{ label: 'Lo Fi', value: 'Lo Fi', count: 12 }], + [{ label: 'Lo-Fi', value: 'Lo-Fi' }] + ) + + expect(suggestions).toEqual([{ label: 'Lo-Fi', value: 'Lo-Fi', count: 12 }]) + }) +}) diff --git a/packages/common/src/utils/genres.ts b/packages/common/src/utils/genres.ts index 0a2c441dcbb..2d5aaa6ce51 100644 --- a/packages/common/src/utils/genres.ts +++ b/packages/common/src/utils/genres.ts @@ -121,8 +121,130 @@ export const toTrendingGenre = (value: string | null): SDKGenre | null => { return convertGenreLabelToValue(value as GenreLabel) } +/** + * Converts a genre label from the trending filter UI into the value stored in + * trending state. Unlike {@link toTrendingGenre}, this accepts freeform / + * community genres that are not part of the static {@link GENRES} list, so the + * dynamic popular-genres filter can select long-tail genres. Strips the + * Electronic prefix for the static electronic subgenres and maps the + * {@link ALL_GENRES} sentinel / empty values to null. + */ +export const toTrendingGenreValue = (value: string | null): SDKGenre | null => { + if (value === null || value === '' || value === ALL_GENRES) return null + return ( + value.startsWith(ELECTRONIC_PREFIX) + ? value.slice(ELECTRONIC_PREFIX.length) + : value + ) as SDKGenre +} + const NEWLY_ADDED_GENRES: string[] = [] export const TRENDING_GENRES = GENRES.filter( (g) => !NEWLY_ADDED_GENRES.includes(g) ) + +export type GenreSuggestion = { + label: string + value: string + count?: number +} + +const GENRE_WORDS_TO_UPPERCASE = new Set([ + 'dj', + 'edm', + 'idm', + 'r&b', + 'uk', + 'us' +]) + +export const normalizeGenre = (genre: string) => { + const trimmed = genre.trim().replace(/\s+/g, ' ') + if (!trimmed) return '' + + return trimmed + .split(/(\s|-|\/)/) + .map((part) => { + if (part === ' ' || part === '-' || part === '/') return part + + const lower = part.toLowerCase() + if (GENRE_WORDS_TO_UPPERCASE.has(lower)) return lower.toUpperCase() + return lower.charAt(0).toUpperCase() + lower.slice(1) + }) + .join('') +} + +export const getGenreSuggestionKey = (genre: string) => + genre.toLowerCase().replace(/[^a-z0-9]/g, '') + +export const getStaticGenreSuggestions = () => + GENRES.map((genre) => ({ + label: genre, + value: convertGenreLabelToValue(genre) + })) + +export const mergeGenreSuggestions = ( + communityGenres: GenreSuggestion[], + staticGenres: GenreSuggestion[] = getStaticGenreSuggestions() +) => { + const suggestionsByKey = new Map() + + communityGenres.forEach((genre) => { + const normalized = normalizeGenre(genre.value) + const key = getGenreSuggestionKey(normalized) + if (!normalized || !key) return + + const existingGenre = suggestionsByKey.get(key) + if ((existingGenre?.count ?? -1) > (genre.count ?? -1)) return + + suggestionsByKey.set(key, { + label: normalized, + value: normalized, + count: genre.count + }) + }) + + staticGenres.forEach((genre) => { + const key = getGenreSuggestionKey(genre.value) + if (!key) return + + const communityGenre = suggestionsByKey.get(key) + suggestionsByKey.set(key, { + ...communityGenre, + label: genre.label, + value: genre.value + }) + }) + + return Array.from(suggestionsByKey.values()).sort((a, b) => { + const countDiff = (b.count ?? -1) - (a.count ?? -1) + if (countDiff !== 0) return countDiff + return a.label.localeCompare(b.label) + }) +} + +/** Number of top genres to request from the popular-genres endpoint. */ +export const POPULAR_GENRES_LIMIT = 25 + +/** + * Selects the genre suggestions to display in the trending genre filter for a + * given search query. With no query, returns the popular/ranked genres (those + * with a recent-activity count) so the top genres are shown by default; while + * searching, matches labels across the full set so long-tail genres remain + * discoverable. Falls back to the full list when no popular genres are + * available (e.g. the popular-genres request failed). + */ +export const getTrendingGenreSuggestions = ( + suggestions: GenreSuggestion[], + searchValue = '' +): GenreSuggestion[] => { + const query = searchValue.trim().toLowerCase() + if (query) { + return suggestions.filter((genre) => + genre.label.toLowerCase().includes(query) + ) + } + const popular = suggestions.filter((genre) => genre.count !== undefined) + return popular.length > 0 ? popular : suggestions +} diff --git a/packages/mobile/src/screens/edit-track-screen/screens/SelectGenreScreen.tsx b/packages/mobile/src/screens/edit-track-screen/screens/SelectGenreScreen.tsx index 4d376c4de71..597b26644a8 100644 --- a/packages/mobile/src/screens/edit-track-screen/screens/SelectGenreScreen.tsx +++ b/packages/mobile/src/screens/edit-track-screen/screens/SelectGenreScreen.tsx @@ -1,6 +1,11 @@ import { useMemo, useState } from 'react' -import { GENRES, convertGenreLabelToValue } from '@audius/common/utils' +import { useGenreSuggestions } from '@audius/common/api' +import { + getGenreSuggestionKey, + getStaticGenreSuggestions, + normalizeGenre +} from '@audius/common/utils' import { useField } from 'formik' import { Flex } from '@audius/harmony-native' @@ -17,10 +22,7 @@ const messages = { useCustom: (value: string) => `Use "${value}" as a custom genre` } -const knownGenres = GENRES.map((genre) => ({ - value: convertGenreLabelToValue(genre), - label: genre -})) +const staticSuggestions = getStaticGenreSuggestions() const useStyles = makeStyles(({ spacing, typography }) => ({ searchInput: { @@ -32,30 +34,38 @@ const useStyles = makeStyles(({ spacing, typography }) => ({ export const SelectGenreScreen = () => { const [{ value }, , { setValue }] = useField('genre') const [input, setInput] = useState('') + const { data: suggestions = staticSuggestions } = useGenreSuggestions() const styles = useStyles() const trimmed = input.trim() const lower = trimmed.toLowerCase() + const normalized = normalizeGenre(trimmed) + const normalizedKey = getGenreSuggestionKey(normalized) const filtered = useMemo(() => { - if (trimmed === '') return knownGenres - return knownGenres.filter( + if (trimmed === '') return suggestions + return suggestions.filter( (g) => g.label.toLowerCase().includes(lower) || g.value.toLowerCase().includes(lower) ) - }, [trimmed, lower]) + }, [trimmed, lower, suggestions]) const data = useMemo(() => { - if (trimmed === '') return knownGenres - const matchesKnownExactly = knownGenres.some( - (g) => g.label.toLowerCase() === lower || g.value.toLowerCase() === lower + if (trimmed === '') return suggestions + const matchesKnownExactly = suggestions.some( + (g) => + getGenreSuggestionKey(g.label) === normalizedKey || + getGenreSuggestionKey(g.value) === normalizedKey ) if (matchesKnownExactly || trimmed.length > MAX_GENRE_LENGTH) { return filtered } - return [{ value: trimmed, label: messages.useCustom(trimmed) }, ...filtered] - }, [trimmed, lower, filtered]) + return [ + { value: normalized, label: messages.useCustom(normalized) }, + ...filtered + ] + }, [trimmed, normalized, normalizedKey, suggestions, filtered]) return ( { const [category] = useSearchCategory() @@ -32,6 +33,7 @@ export const ExploreContent = () => { {showTrackContent && showUserContextualContent && } {showPlaylistContent && } + {showTrackContent && } {showAlbumContent && } {showAlbumContent && } {showAlbumContent && } diff --git a/packages/mobile/src/screens/explore-screen/components/TrendingGenres.tsx b/packages/mobile/src/screens/explore-screen/components/TrendingGenres.tsx new file mode 100644 index 00000000000..e047cba5f1e --- /dev/null +++ b/packages/mobile/src/screens/explore-screen/components/TrendingGenres.tsx @@ -0,0 +1,86 @@ +import React, { useCallback } from 'react' + +import { usePopularGenres } from '@audius/common/api' +import { exploreMessages as messages } from '@audius/common/messages' +import { trendingPageActions } from '@audius/common/store' +import { toTrendingGenreValue } from '@audius/common/utils' +import { ScrollView } from 'react-native' +import { useDispatch } from 'react-redux' + +import { Flex, SelectablePill, Skeleton } from '@audius/harmony-native' +import { useNavigation } from 'app/hooks/useNavigation' + +import { useExploreSectionTracking } from '../hooks/useExploreSectionTracking' + +import { ExploreSection } from './ExploreSection' + +const { setTrendingGenre } = trendingPageActions + +const SKELETON_COUNT = 8 +const MAX_GENRES = 10 + +export const TrendingGenres = () => { + const { inView, InViewWrapper } = useExploreSectionTracking('Trending Genres') + const dispatch = useDispatch() + const navigation = useNavigation() + + const { data: genreSuggestions, isPending } = usePopularGenres({ + enabled: inView + }) + + const handleGenrePress = useCallback( + (genre: string) => { + dispatch(setTrendingGenre(toTrendingGenreValue(genre))) + navigation.navigate('trending', { screen: 'Trending' }) + }, + [dispatch, navigation] + ) + + // Only the ranked (popular) genres carry a count; the merged static genres + // do not. Show the top ranked genres in popularity order. + const topGenres = (genreSuggestions ?? []) + .filter((genre) => genre.count !== undefined) + .slice(0, MAX_GENRES) + + const showSkeleton = !inView || isPending + + // Hide the section gracefully when there are no popular genres (empty + // response or a failed request that fell back to the static list). + if (!showSkeleton && topGenres.length === 0) { + return null + } + + return ( + + + + + {showSkeleton + ? Array.from({ length: SKELETON_COUNT }).map((_, i) => ( + + )) + : topGenres.map((genre) => ( + handleGenrePress(genre.value)} + /> + ))} + + + + + ) +} diff --git a/packages/mobile/src/screens/trending-screen/TrendingFilterDrawer.tsx b/packages/mobile/src/screens/trending-screen/TrendingFilterDrawer.tsx index ddb372d480f..eb07f75575e 100644 --- a/packages/mobile/src/screens/trending-screen/TrendingFilterDrawer.tsx +++ b/packages/mobile/src/screens/trending-screen/TrendingFilterDrawer.tsx @@ -1,16 +1,15 @@ import { useCallback, useMemo, useState } from 'react' +import { usePopularGenres } from '@audius/common/api' import { modalsActions, trendingPageActions, trendingPageSelectors } from '@audius/common/store' import { - type Genre, - ELECTRONIC_PREFIX, - ELECTRONIC_SUBGENRES, - GENRES, - ALL_GENRES + ALL_GENRES, + getTrendingGenreSuggestions, + toTrendingGenreValue } from '@audius/common/utils' import { FlatList, @@ -47,8 +46,6 @@ const messages = { selected: 'Selected' } -const trendingGenres = [ALL_GENRES, ...GENRES] - const useStyles = makeStyles(({ palette, spacing, typography }) => ({ root: { flex: 1 @@ -90,6 +87,7 @@ export const TrendingFilterDrawer = () => { const trendingGenre = useSelector(getTrendingGenre) ?? ALL_GENRES const { onClose } = useDrawerState(MODAL_NAME) const dispatch = useDispatch() + const { data: genreSuggestions = [] } = usePopularGenres() const handleBack = useCallback(() => { dispatch(setVisibility({ modal: MODAL_NAME, visible: false })) @@ -122,20 +120,20 @@ export const TrendingFilterDrawer = () => { ) const genres = useMemo(() => { - const searchValueLower = searchValue.toLowerCase() - return trendingGenres.filter((genre) => - genre.toLowerCase().includes(searchValueLower) - ) - }, [searchValue]) + // Show the top popular genres by default; while searching, match across the + // full set so long-tail genres remain discoverable. + const filtered = getTrendingGenreSuggestions( + genreSuggestions, + searchValue + ).map((genre) => genre.label) + const query = searchValue.trim().toLowerCase() + const includeAllGenres = !query || ALL_GENRES.toLowerCase().includes(query) + return includeAllGenres ? [ALL_GENRES, ...filtered] : filtered + }, [genreSuggestions, searchValue]) const handleSelect = useCallback( (genre: string) => { - const trimmedGenre = - genre === ALL_GENRES - ? null - : (genre.replace(ELECTRONIC_PREFIX, '') as Genre) - - dispatch(setTrendingGenre(trimmedGenre)) + dispatch(setTrendingGenre(toTrendingGenreValue(genre))) Keyboard.dismiss() onClose() }, @@ -168,8 +166,9 @@ export const TrendingFilterDrawer = () => { ItemSeparatorComponent={Divider} renderItem={({ item: genre }) => { const isSelected = - ELECTRONIC_SUBGENRES[genre] === trendingGenre || - genre === trendingGenre + genre === ALL_GENRES + ? trendingGenre === ALL_GENRES + : toTrendingGenreValue(genre) === trendingGenre return ( handleSelect(genre)}> diff --git a/packages/sdk/src/sdk/api/generated/default/.openapi-generator/FILES b/packages/sdk/src/sdk/api/generated/default/.openapi-generator/FILES index a2a5b73fb01..2c8c24f19b5 100644 --- a/packages/sdk/src/sdk/api/generated/default/.openapi-generator/FILES +++ b/packages/sdk/src/sdk/api/generated/default/.openapi-generator/FILES @@ -8,6 +8,7 @@ apis/DeveloperAppsApi.ts apis/EventsApi.ts apis/ExploreApi.ts apis/FanClubApi.ts +apis/GenresApi.ts apis/NotificationsApi.ts apis/PlaylistsApi.ts apis/PrizesApi.ts @@ -236,6 +237,8 @@ models/PlaylistUpdates.ts models/PlaylistUpdatesResponse.ts models/PlaylistWithoutTracks.ts models/PlaylistsResponse.ts +models/PopularGenre.ts +models/PopularGenresResponse.ts models/PrizeClaimRequestBody.ts models/PrizeClaimResponse.ts models/PrizePublic.ts @@ -352,6 +355,9 @@ models/TrackAddedToPurchasedAlbumNotification.ts models/TrackAddedToPurchasedAlbumNotificationAction.ts models/TrackAddedToPurchasedAlbumNotificationActionData.ts models/TrackArtwork.ts +models/TrackCollaboratorNotification.ts +models/TrackCollaboratorNotificationAction.ts +models/TrackCollaboratorNotificationActionData.ts models/TrackCommentCountResponse.ts models/TrackCommentNotificationResponse.ts models/TrackCommentsResponse.ts diff --git a/packages/sdk/src/sdk/api/generated/default/apis/GenresApi.ts b/packages/sdk/src/sdk/api/generated/default/apis/GenresApi.ts new file mode 100644 index 00000000000..112431beed4 --- /dev/null +++ b/packages/sdk/src/sdk/api/generated/default/apis/GenresApi.ts @@ -0,0 +1,80 @@ +/* tslint:disable */ +// @ts-nocheck +/* eslint-disable */ +/** + * Audius API + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import * as runtime from '../runtime'; +import type { + PopularGenresResponse, +} from '../models'; +import { + PopularGenresResponseFromJSON, + PopularGenresResponseToJSON, +} from '../models'; + +export interface GetPopularGenresRequest { + startTime?: number; + limit?: number; + offset?: number; + minCount?: number; +} + +/** + * + */ +export class GenresApi extends runtime.BaseAPI { + + /** + * @hidden + * Get popular genres from recently created tracks. + */ + async getPopularGenresRaw(params: GetPopularGenresRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const queryParameters: any = {}; + + if (params.startTime !== undefined) { + queryParameters['start_time'] = params.startTime; + } + + if (params.limit !== undefined) { + queryParameters['limit'] = params.limit; + } + + if (params.offset !== undefined) { + queryParameters['offset'] = params.offset; + } + + if (params.minCount !== undefined) { + queryParameters['min_count'] = params.minCount; + } + + const headerParameters: runtime.HTTPHeaders = {}; + + const response = await this.request({ + path: `/genres/popular`, + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => PopularGenresResponseFromJSON(jsonValue)); + } + + /** + * Get popular genres from recently created tracks. + */ + async getPopularGenres(params: GetPopularGenresRequest = {}, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.getPopularGenresRaw(params, initOverrides); + return await response.value(); + } + +} diff --git a/packages/sdk/src/sdk/api/generated/default/apis/index.ts b/packages/sdk/src/sdk/api/generated/default/apis/index.ts index 5b37636fc85..ef4de686493 100644 --- a/packages/sdk/src/sdk/api/generated/default/apis/index.ts +++ b/packages/sdk/src/sdk/api/generated/default/apis/index.ts @@ -9,6 +9,7 @@ export * from './DeveloperAppsApi'; export * from './EventsApi'; export * from './ExploreApi'; export * from './FanClubApi'; +export * from './GenresApi'; export * from './NotificationsApi'; export * from './PlaylistsApi'; export * from './PrizesApi'; diff --git a/packages/sdk/src/sdk/api/generated/default/models/PopularGenre.ts b/packages/sdk/src/sdk/api/generated/default/models/PopularGenre.ts new file mode 100644 index 00000000000..0c5357a4221 --- /dev/null +++ b/packages/sdk/src/sdk/api/generated/default/models/PopularGenre.ts @@ -0,0 +1,75 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * Audius API + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +/** + * + * @export + * @interface PopularGenre + */ +export interface PopularGenre { + /** + * Genre name + * @type {string} + * @memberof PopularGenre + */ + name: string; + /** + * Number of tracks in this genre for the requested time window + * @type {number} + * @memberof PopularGenre + */ + count: number; +} + +/** + * Check if a given object implements the PopularGenre interface. + */ +export function instanceOfPopularGenre(value: object): value is PopularGenre { + let isInstance = true; + isInstance = isInstance && "name" in value && value["name"] !== undefined; + isInstance = isInstance && "count" in value && value["count"] !== undefined; + + return isInstance; +} + +export function PopularGenreFromJSON(json: any): PopularGenre { + return PopularGenreFromJSONTyped(json, false); +} + +export function PopularGenreFromJSONTyped(json: any, ignoreDiscriminator: boolean): PopularGenre { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'name': json['name'], + 'count': json['count'], + }; +} + +export function PopularGenreToJSON(value?: PopularGenre | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'name': value.name, + 'count': value.count, + }; +} + diff --git a/packages/sdk/src/sdk/api/generated/default/models/PopularGenresResponse.ts b/packages/sdk/src/sdk/api/generated/default/models/PopularGenresResponse.ts new file mode 100644 index 00000000000..33f3ae25d31 --- /dev/null +++ b/packages/sdk/src/sdk/api/generated/default/models/PopularGenresResponse.ts @@ -0,0 +1,73 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * Audius API + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +import type { PopularGenre } from './PopularGenre'; +import { + PopularGenreFromJSON, + PopularGenreFromJSONTyped, + PopularGenreToJSON, +} from './PopularGenre'; + +/** + * + * @export + * @interface PopularGenresResponse + */ +export interface PopularGenresResponse { + /** + * + * @type {Array} + * @memberof PopularGenresResponse + */ + data: Array; +} + +/** + * Check if a given object implements the PopularGenresResponse interface. + */ +export function instanceOfPopularGenresResponse(value: object): value is PopularGenresResponse { + let isInstance = true; + isInstance = isInstance && "data" in value && value["data"] !== undefined; + + return isInstance; +} + +export function PopularGenresResponseFromJSON(json: any): PopularGenresResponse { + return PopularGenresResponseFromJSONTyped(json, false); +} + +export function PopularGenresResponseFromJSONTyped(json: any, ignoreDiscriminator: boolean): PopularGenresResponse { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'data': ((json['data'] as Array).map(PopularGenreFromJSON)), + }; +} + +export function PopularGenresResponseToJSON(value?: PopularGenresResponse | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'data': ((value.data as Array).map(PopularGenreToJSON)), + }; +} + diff --git a/packages/sdk/src/sdk/api/generated/default/models/index.ts b/packages/sdk/src/sdk/api/generated/default/models/index.ts index cf2391adaa6..aeff13f34c5 100644 --- a/packages/sdk/src/sdk/api/generated/default/models/index.ts +++ b/packages/sdk/src/sdk/api/generated/default/models/index.ts @@ -214,6 +214,8 @@ export * from './PlaylistUpdates'; export * from './PlaylistUpdatesResponse'; export * from './PlaylistWithoutTracks'; export * from './PlaylistsResponse'; +export * from './PopularGenre'; +export * from './PopularGenresResponse'; export * from './PrizeClaimRequestBody'; export * from './PrizeClaimResponse'; export * from './PrizePublic'; @@ -269,9 +271,6 @@ export * from './RepostOfRepostNotificationActionData'; export * from './RepostRequestBody'; export * from './Reposts'; export * from './RequestManagerNotification'; -export * from './TrackCollaboratorNotification'; -export * from './TrackCollaboratorNotificationAction'; -export * from './TrackCollaboratorNotificationActionData'; export * from './RequestManagerNotificationAction'; export * from './RequestManagerNotificationActionData'; export * from './RewardCodeErrorResponse'; @@ -333,6 +332,9 @@ export * from './TrackAddedToPurchasedAlbumNotification'; export * from './TrackAddedToPurchasedAlbumNotificationAction'; export * from './TrackAddedToPurchasedAlbumNotificationActionData'; export * from './TrackArtwork'; +export * from './TrackCollaboratorNotification'; +export * from './TrackCollaboratorNotificationAction'; +export * from './TrackCollaboratorNotificationActionData'; export * from './TrackCommentCountResponse'; export * from './TrackCommentNotificationResponse'; export * from './TrackCommentsResponse'; diff --git a/packages/sdk/src/sdk/createSdk.ts b/packages/sdk/src/sdk/createSdk.ts index 312437383bc..aafd91e63c2 100644 --- a/packages/sdk/src/sdk/createSdk.ts +++ b/packages/sdk/src/sdk/createSdk.ts @@ -10,6 +10,7 @@ import { DeveloperAppsApi, EventsApi, ExploreApi, + GenresApi, NotificationsApi, PlaylistsApi, PrizesApi, @@ -161,6 +162,7 @@ export const createSdk = (config: SdkConfig) => { notifications: new NotificationsApi(apiConfig), events: new EventsApi(apiConfig), explore: new ExploreApi(apiConfig), + genres: new GenresApi(apiConfig), search: new SearchApi(apiConfig), coins: new CoinsApi(apiConfig), wallets: new WalletApi(apiConfig), diff --git a/packages/sdk/src/sdk/createSdkWithServices.ts b/packages/sdk/src/sdk/createSdkWithServices.ts index dc6b9797f4a..3f62c3e7d31 100644 --- a/packages/sdk/src/sdk/createSdkWithServices.ts +++ b/packages/sdk/src/sdk/createSdkWithServices.ts @@ -14,6 +14,7 @@ import { CoinsApi, Configuration, ExploreApi, + GenresApi, PrizesApi, RewardsApi, SearchApi, @@ -465,6 +466,7 @@ const initializeApis = ({ const events = new EventsApi(apiClientConfig, services) const explore = new ExploreApi(apiClientConfig) + const genres = new GenresApi(apiClientConfig) const search = new SearchApi(apiClientConfig) const uploads = new UploadsApi(services) @@ -488,6 +490,7 @@ const initializeApis = ({ notifications, events, explore, + genres, search, coins, wallets, diff --git a/packages/sdk/src/sdk/index.ts b/packages/sdk/src/sdk/index.ts index 728c5265dd0..b311450469b 100644 --- a/packages/sdk/src/sdk/index.ts +++ b/packages/sdk/src/sdk/index.ts @@ -18,6 +18,7 @@ export { DeveloperAppsApi } from './api/developer-apps/DeveloperAppsApi' export { DashboardWalletUsersApi } from './api/dashboard-wallet-users/DashboardWalletUsersApi' export { UsersApi } from './api/users/UsersApi' export { ResolveApi } from './api/ResolveApi' +export { GenresApi } from './api/generated/default' export { GetAudioTransactionHistorySortMethodEnum, GetAudioTransactionHistorySortDirectionEnum, diff --git a/packages/web/src/components/edit/fields/SelectGenreField.tsx b/packages/web/src/components/edit/fields/SelectGenreField.tsx index d7847d78c2b..0f09873d634 100644 --- a/packages/web/src/components/edit/fields/SelectGenreField.tsx +++ b/packages/web/src/components/edit/fields/SelectGenreField.tsx @@ -1,4 +1,5 @@ -import { GENRES, convertGenreLabelToValue } from '@audius/common/utils' +import { useGenreSuggestions } from '@audius/common/api' +import { getStaticGenreSuggestions } from '@audius/common/utils' import { TextField, TextFieldProps } from 'components/form-fields' @@ -13,11 +14,11 @@ type SelectGenreFieldProps = Partial & { name: string } -const suggestions = Array.from( - new Set(GENRES.map((genre) => convertGenreLabelToValue(genre))) -) +const staticSuggestions = getStaticGenreSuggestions() export const SelectGenreField = (props: SelectGenreFieldProps) => { + const { data: suggestions = staticSuggestions } = useGenreSuggestions() + return ( <> { {...props} /> - {suggestions.map((value) => ( + {suggestions.map(({ value }) => ( diff --git a/packages/web/src/components/trending-genre-selection/TrendingGenreSelectionPage.tsx b/packages/web/src/components/trending-genre-selection/TrendingGenreSelectionPage.tsx index 37eb65cc776..14bfc69aa18 100644 --- a/packages/web/src/components/trending-genre-selection/TrendingGenreSelectionPage.tsx +++ b/packages/web/src/components/trending-genre-selection/TrendingGenreSelectionPage.tsx @@ -1,3 +1,4 @@ +import { usePopularGenres } from '@audius/common/api' import { TimeRange } from '@audius/common/models' import { trendingPageActions, @@ -5,8 +6,8 @@ import { } from '@audius/common/store' import { Genre, - TRENDING_GENRES, - toTrendingGenre, + getTrendingGenreSuggestions, + toTrendingGenreValue, route } from '@audius/common/utils' import { connect } from 'react-redux' @@ -35,14 +36,23 @@ const ConnectedTrendingGenreSelectionPage = ({ setTrendingTimeRange, goToTrending }: ConnectedTrendingGenreSelectionPageProps) => { + const { data: genreSuggestions = [] } = usePopularGenres() + const setTrimmedGenre = (genre: string | null) => { - setTrendingGenre(toTrendingGenre(genre)) + setTrendingGenre(toTrendingGenreValue(genre)) setTrendingTimeRange(timeRange) goToTrending() } + + const allGenres = genreSuggestions.map((g) => g.label) + const topGenres = getTrendingGenreSuggestions(genreSuggestions).map( + (g) => g.label + ) + return ( diff --git a/packages/web/src/components/trending-genre-selection/components/TrendingGenreSelectionPage.tsx b/packages/web/src/components/trending-genre-selection/components/TrendingGenreSelectionPage.tsx index 638c643f32d..5cb044f796e 100644 --- a/packages/web/src/components/trending-genre-selection/components/TrendingGenreSelectionPage.tsx +++ b/packages/web/src/components/trending-genre-selection/components/TrendingGenreSelectionPage.tsx @@ -10,6 +10,7 @@ type TrendingGenreSelectionPageProps = { selectedGenre: string | null didSelectGenre: (genre: string | null) => void genres: string[] + topGenres?: string[] } const messages = { @@ -19,7 +20,8 @@ const messages = { const TrendingGenreSelectionPage = ({ selectedGenre, didSelectGenre, - genres + genres, + topGenres }: TrendingGenreSelectionPageProps) => { const { setLeft, setCenter, setRight } = useContext(NavContext)! @@ -33,6 +35,7 @@ const TrendingGenreSelectionPage = ({ }, + { + key: 'trendingGenres', + shouldRender: showTrackContent, + element: + }, { key: 'topAlbumsThisMonth', shouldRender: showAlbumContent, diff --git a/packages/web/src/pages/search-explore-page/components/desktop/TrendingGenresSection.tsx b/packages/web/src/pages/search-explore-page/components/desktop/TrendingGenresSection.tsx new file mode 100644 index 00000000000..0fe7965bf92 --- /dev/null +++ b/packages/web/src/pages/search-explore-page/components/desktop/TrendingGenresSection.tsx @@ -0,0 +1,72 @@ +import { useCallback } from 'react' + +import { usePopularGenres } from '@audius/common/api' +import { exploreMessages as messages } from '@audius/common/messages' +import { route } from '@audius/common/utils' +import { Flex, SelectablePill, Text } from '@audius/harmony' +import { useNavigate } from 'react-router' + +import Skeleton from 'components/skeleton/Skeleton' +import { useIsMobile } from 'hooks/useIsMobile' + +import { useExploreSectionTracking } from './useExploreSectionTracking' + +const SKELETON_COUNT = 10 +const MAX_GENRES = 10 + +export const TrendingGenresSection = () => { + const { ref, inView } = useExploreSectionTracking('Trending Genres') + const navigate = useNavigate() + const isMobile = useIsMobile() + + const { data: genreSuggestions, isPending } = usePopularGenres({ + enabled: inView + }) + + const handleGenrePress = useCallback( + (genre: string) => { + navigate(`${route.TRENDING_PAGE}?genre=${encodeURIComponent(genre)}`) + }, + [navigate] + ) + + // Only the ranked (popular) genres carry a count; the merged static genres + // do not. Show the top ranked genres in popularity order. + const topGenres = (genreSuggestions ?? []) + .filter((genre) => genre.count !== undefined) + .slice(0, MAX_GENRES) + + const showSkeleton = !inView || isPending + + // Hide the section gracefully when there are no popular genres (empty + // response or a failed request that fell back to the static list). + if (!showSkeleton && topGenres.length === 0) { + return null + } + + return ( + + + {messages.trendingGenres} + + + {showSkeleton + ? Array.from({ length: SKELETON_COUNT }).map((_, i) => ( + + )) + : topGenres.map((genre) => ( + handleGenrePress(genre.value)} + /> + ))} + + + ) +} diff --git a/packages/web/src/pages/trending-page/components/GenreSelectionList.tsx b/packages/web/src/pages/trending-page/components/GenreSelectionList.tsx index 2868a55afde..6896b20cc8a 100644 --- a/packages/web/src/pages/trending-page/components/GenreSelectionList.tsx +++ b/packages/web/src/pages/trending-page/components/GenreSelectionList.tsx @@ -34,6 +34,12 @@ const GenreButton = ({ type GenreSelectionListProps = { genres: string[] + /** + * Genres shown by default (no active search). When provided, `genres` is + * treated as the searchable superset so the top genres display first while + * long-tail genres remain discoverable via search. + */ + topGenres?: string[] didSelectGenre: (genre: string | null) => void selectedGenre: string | null containerClassName?: string @@ -52,6 +58,7 @@ const messages = { // trending on mobile and desktop. const GenreSelectionList = ({ genres, + topGenres, didSelectGenre, selectedGenre, containerClassName, @@ -64,7 +71,10 @@ const GenreSelectionList = ({ didSelectGenre, { selectedGenre } ) - const filteredGenreList = genres.filter((g) => + // Show the top genres by default; search across the full list so long-tail + // genres remain discoverable. + const sourceGenres = !searchValue.trim() && topGenres ? topGenres : genres + const filteredGenreList = sourceGenres.filter((g) => g.toLowerCase().includes(searchValue.toLowerCase()) ) const selectedIndex = diff --git a/packages/web/src/pages/trending-page/components/desktop/TrendingPageContent.tsx b/packages/web/src/pages/trending-page/components/desktop/TrendingPageContent.tsx index b968f594bed..b36945235fe 100644 --- a/packages/web/src/pages/trending-page/components/desktop/TrendingPageContent.tsx +++ b/packages/web/src/pages/trending-page/components/desktop/TrendingPageContent.tsx @@ -5,19 +5,15 @@ import { getTrendingUndergroundQueryKey, TRENDING_INITIAL_PAGE_SIZE, useTrending, - useTrendingUnderground + useTrendingUnderground, + usePopularGenres } from '@audius/common/api' import { Name, TimeRange } from '@audius/common/models' import { trendingPageActions, trendingPageSelectors } from '@audius/common/store' -import { - getCanonicalName, - route, - toTrendingGenre, - TRENDING_GENRES -} from '@audius/common/utils' +import { route, toTrendingGenreValue } from '@audius/common/utils' import { FilterButton, Flex, @@ -58,6 +54,7 @@ const messages = { thisMonth: 'This Month', allTime: 'All Time', allGenres: 'All Genres', + searchGenres: 'Search Genres', genres: 'Genres', endOfLineupDescription: "Looks like you've reached the end of this list..." } @@ -116,6 +113,7 @@ const TrendingPageContent = ({ containerRef }: TrendingPageContentProps) => { const trendingGenre = useSelector(getTrendingGenre) const trendingTimeRange = useSelector(getTrendingTimeRange) + const { data: genreSuggestions = [] } = usePopularGenres() // Desktop viewports + fast trackpad / wheel scroll need bigger pages than // the shared default (mobile-tuned) so successive load-mores keep up with a @@ -216,7 +214,11 @@ const TrendingPageContent = ({ containerRef }: TrendingPageContentProps) => { const setTrendingGenre = useCallback( (genre: string | null) => { - dispatch(trendingPageActions.setTrendingGenre(toTrendingGenre(genre))) + // toTrendingGenreValue keeps freeform/community genres (from the dynamic + // popular-genres filter) while mapping the all-genres sentinel to null. + dispatch( + trendingPageActions.setTrendingGenre(toTrendingGenreValue(genre)) + ) }, [dispatch] ) @@ -280,11 +282,13 @@ const TrendingPageContent = ({ containerRef }: TrendingPageContentProps) => { { label: messages.allTime, value: TimeRange.ALL_TIME } ] + // Popular genres (ranked by recent activity) lead the list, followed by the + // long-tail static genres; all are searchable via the filter input. const genreOptions = [ { label: messages.allGenres, value: 'all' }, - ...TRENDING_GENRES.map((g) => ({ - label: getCanonicalName(g), - value: g + ...genreSuggestions.map((g) => ({ + label: g.label, + value: g.value })) ] @@ -320,6 +324,11 @@ const TrendingPageContent = ({ containerRef }: TrendingPageContentProps) => { variant='replaceLabel' onChange={handleGenreChange} options={genreOptions} + showFilterInput + filterInputProps={{ + label: messages.searchGenres, + placeholder: messages.searchGenres + }} virtualized menuProps={{ maxHeight: 320, width: 240 }} /> @@ -372,6 +381,11 @@ const TrendingPageContent = ({ containerRef }: TrendingPageContentProps) => { variant='replaceLabel' onChange={handleGenreChange} options={genreOptions} + showFilterInput + filterInputProps={{ + label: messages.searchGenres, + placeholder: messages.searchGenres + }} virtualized menuProps={{ maxHeight: 320, width: 240 }} /> From 881fd0895edf2c3808ded3ab235aa584f896bc1e Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Thu, 18 Jun 2026 13:08:58 -0700 Subject: [PATCH 2/2] fix(trending): restore freeform genre from URL param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isValidGenre only validated against the static GENRES list, so a freeform genre in the URL (e.g. /trending?genre=Hyper+Pop) was dropped on load. Make it permissive — accept any non-empty value that isn't the ALL_GENRES sentinel — since canonical validation is now server-side via normalization. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/web/src/pages/trending-page/utils.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/web/src/pages/trending-page/utils.ts b/packages/web/src/pages/trending-page/utils.ts index e9ad8fd6ff1..e43151e7241 100644 --- a/packages/web/src/pages/trending-page/utils.ts +++ b/packages/web/src/pages/trending-page/utils.ts @@ -1,5 +1,5 @@ import { TimeRange } from '@audius/common/models' -import { GENRES, Genre } from '@audius/common/utils' +import { ALL_GENRES, Genre } from '@audius/common/utils' import { URL_PARAM_KEYS } from './constants' @@ -37,10 +37,14 @@ export const isValidWinnersWeek = (week: string | null): boolean => week !== null && WEEK_YYYY_MM_DD_REGEX.test(week) /** - * Validate if a genre string is a valid genre + * Validate if a genre string is usable as a trending filter. Permissive by + * design: now that freeform genres are supported, the canonical validation + * happens server-side via normalization. We accept any non-empty value that + * isn't the {@link ALL_GENRES} sentinel so freeform genres in the URL param + * (e.g. `?genre=Hyper+Pop`) restore on page load instead of being dropped. */ export const isValidGenre = (genre: string | null): boolean => { - return genre !== null && Object.values(GENRES).includes(genre as any) + return genre !== null && genre.trim() !== '' && genre !== ALL_GENRES } /**