Skip to content

Commit e822316

Browse files
dylanjeffersclaude
andauthored
feat(genres): allow custom track genres (#14424)
Fresh branch off main — supersedes #14419. ## Summary - Discovery indexer no longer rejects tracks whose genre isn't in the hardcoded allowlist; just caps at 100 chars. The allowlist is retained as a canonical reference for read-side trending/metrics scoping. - SDK upload schemas for tracks, albums, and playlists changed from `z.enum(Genre)` to `z.string().min(1).max(100)`. SDK regenerated from updated swagger (generated `Genre` enum dropped). Manual `Genre` enum is now the canonical export from `@audius/sdk` with PascalCase keys, plus a new `GenreString = Genre | string` helper type. - Web `SelectGenreField` rewritten from `<SelectField>` to `<TextField>` + native `<datalist>` — users can pick a known genre via autocomplete or type a custom one. `maxLength=100` gives the built-in harmony character counter. Pairs with [AudiusProject/api genres/custom](https://github.com/AudiusProject/api) — the Go API must ship first (or together) so it stops rejecting custom-genre POSTs before this indexer relaxation lands. ## What we deliberately did NOT change - `index_trending.py` and `get_genre_metrics.py` still scope to `genre_allowlist`. Custom-genre tracks silently won't appear in trending or genre-metrics endpoints — intentional MVP cut. - `packages/mobile` has its own genre picker that wasn't touched — follow-up PR needed. - No write-side normalization. "Hip-Hop" / "Hip Hop" / "hip hop" become three distinct genres. ## Breaking change (browser dist only) `packages/sdk/src/sdk/types/Genre.ts` keys changed from SCREAMING_SNAKE to PascalCase to match the previously-generated enum that downstream consumers all imported. External integrations referencing `window.audiusSdk.Genre.HIP_HOP_RAP` etc. via the browser dist need to update to `Genre.HipHopRap`. ## Test plan - [x] `tsc --noEmit` in `packages/sdk` — clean - [x] `vitest run` in `packages/sdk` — 17 files, 196 passed, 2 skipped - [x] SDK regenerated via `node ./src/sdk/api/generator/gen.js --spec <local-edited-swagger>` against the updated swagger - [ ] Type a custom genre in the upload form, confirm it submits - [ ] Existing known-genre flows still work - [ ] Verify >100-char input is rejected client and server side - [ ] Confirm no regression in trending/browse-by-genre for canonical genres 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7f36555 commit e822316

19 files changed

Lines changed: 163 additions & 208 deletions

File tree

packages/discovery-provider/src/tasks/entity_manager/entities/track.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from src.models.users.user import User
2929
from src.tasks.entity_manager.utils import (
3030
CHARACTER_LIMIT_DESCRIPTION,
31+
CHARACTER_LIMIT_GENRE,
3132
TRACK_ID_OFFSET,
3233
Action,
3334
EntityType,
@@ -45,7 +46,6 @@
4546
)
4647
from src.tasks.task_helpers import generate_slug_and_collision_id
4748
from src.utils import helpers
48-
from src.utils.hardcoded_data import genre_allowlist
4949
from src.utils.structured_logger import StructuredLogger
5050

5151
logger = StructuredLogger(__name__)
@@ -534,9 +534,9 @@ def validate_track_tx(params: ManageEntityParameters):
534534
)
535535
track_bio = params.metadata.get("description")
536536
track_genre = params.metadata.get("genre")
537-
if track_genre is not None and track_genre not in genre_allowlist:
537+
if track_genre is not None and len(track_genre) > CHARACTER_LIMIT_GENRE:
538538
raise IndexingValidationError(
539-
f"Track {track_id} attempted to be placed in genre '{track_genre}' which is not in the allow list"
539+
f"Track {track_id} genre exceeds character limit {CHARACTER_LIMIT_GENRE}"
540540
)
541541
if track_bio is not None and len(track_bio) > CHARACTER_LIMIT_DESCRIPTION:
542542
raise IndexingValidationError(

packages/discovery-provider/src/tasks/entity_manager/utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
# limits
6868
CHARACTER_LIMIT_USER_BIO = 256
6969
CHARACTER_LIMIT_DESCRIPTION = 2500
70+
CHARACTER_LIMIT_GENRE = 100
7071
PLAYLIST_TRACK_LIMIT = 5000
7172
COMMENT_BODY_LIMIT = 400
7273

packages/discovery-provider/src/utils/hardcoded_data.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ def has_badwords(s: str) -> bool:
9696
return any(badword in f for badword in handle_badwords_lower)
9797

9898

99+
# Known/canonical genres. No longer enforced as an allowlist at write time —
100+
# track genres can be arbitrary strings (capped at CHARACTER_LIMIT_GENRE).
101+
# Still used by trending (index_trending.py) and genre metrics
102+
# (get_genre_metrics.py) to scope read-side aggregations to canonical values.
99103
genre_allowlist = {
100104
"Acoustic",
101105
"Alternative",

packages/sdk/src/sdk/api/albums/AlbumsApi.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ import {
2020
import { SolanaClient } from '../../services/Solana/programs/SolanaClient'
2121
import { Storage } from '../../services/Storage'
2222
import { StorageNodeSelector } from '../../services/StorageNodeSelector'
23-
import { Configuration, Genre, Mood } from '../generated/default'
23+
import { Genre } from '../../types/Genre'
24+
import { Configuration, Mood } from '../generated/default'
2425
import { PlaylistsApi as GeneratedPlaylistsApi } from '../generated/default/apis/PlaylistsApi'
2526
import type { PlaylistResponse } from '../generated/default/models/PlaylistResponse'
2627
import { TrackUploadHelper } from '../tracks/TrackUploadHelper'

packages/sdk/src/sdk/api/albums/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { PublicKeySchema } from '../../services/Solana'
1414
import { DDEXResourceContributor, DDEXCopyright } from '../../types/DDEX'
1515
import { AudioFile, ImageFile } from '../../types/File'
1616
import { HashId } from '../../types/HashId'
17-
import { Mood, Genre } from '../generated/default'
17+
import { Mood } from '../generated/default'
1818
import type {
1919
CreatePlaylistRequestBody,
2020
UpdatePlaylistRequestBody
@@ -159,7 +159,7 @@ export const CreateAlbumSchema = z
159159
export type EntityManagerCreateAlbumRequest = z.input<typeof CreateAlbumSchema>
160160

161161
export const UploadAlbumMetadataSchema = CreateAlbumMetadataSchema.extend({
162-
genre: z.enum(Object.values(Genre) as [Genre, ...Genre[]]),
162+
genre: z.string().min(1).max(100),
163163
mood: z.optional(z.enum(Object.values(Mood) as [Mood, ...Mood[]])),
164164
tags: z.optional(z.string())
165165
})

packages/sdk/src/sdk/api/generated/default/.openapi-generator/FILES

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,6 @@ models/FollowNotificationAction.ts
191191
models/FollowNotificationActionData.ts
192192
models/FollowersResponse.ts
193193
models/FollowingResponse.ts
194-
models/Genre.ts
195194
models/GetChallenges.ts
196195
models/GetSupportedUsers.ts
197196
models/GetSupporter.ts

packages/sdk/src/sdk/api/generated/default/models/CreatePlaylistRequestBody.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,6 @@ import {
3737
DdexResourceContributorFromJSONTyped,
3838
DdexResourceContributorToJSON,
3939
} from './DdexResourceContributor';
40-
import type { Genre } from './Genre';
41-
import {
42-
GenreFromJSON,
43-
GenreFromJSONTyped,
44-
GenreToJSON,
45-
} from './Genre';
4640
import type { Mood } from './Mood';
4741
import {
4842
MoodFromJSON,
@@ -93,11 +87,20 @@ export interface CreatePlaylistRequestBody {
9387
*/
9488
isAlbum?: boolean;
9589
/**
90+
* Music genre. Any string up to 100 characters is accepted. Known/canonical
91+
* values (shown as autocomplete suggestions in clients): Electronic, Rock,
92+
* Metal, Alternative, Hip-Hop/Rap, Experimental, Punk, Folk, Pop, Ambient,
93+
* Soundtrack, World, Jazz, Acoustic, Funk, R&B/Soul, Devotional, Classical,
94+
* Reggae, Podcasts, Country, Spoken Word, Comedy, Blues, Kids, Audiobooks,
95+
* Latin, Lo-Fi, Hyperpop, Dancehall, Techno, Trap, House, Tech House,
96+
* Deep House, Disco, Electro, Jungle, Progressive House, Hardstyle,
97+
* Glitch Hop, Trance, Future Bass, Future House, Tropical House, Downtempo,
98+
* Drum & Bass, Dubstep, Jersey Club, Vaporwave, Moombahton.
9699
*
97-
* @type {Genre}
100+
* @type {string}
98101
* @memberof CreatePlaylistRequestBody
99102
*/
100-
genre?: Genre;
103+
genre?: string;
101104
/**
102105
*
103106
* @type {Mood}
@@ -227,7 +230,7 @@ export function CreatePlaylistRequestBodyFromJSONTyped(json: any, ignoreDiscrimi
227230
'description': !exists(json, 'description') ? undefined : json['description'],
228231
'isPrivate': !exists(json, 'is_private') ? undefined : json['is_private'],
229232
'isAlbum': !exists(json, 'is_album') ? undefined : json['is_album'],
230-
'genre': !exists(json, 'genre') ? undefined : GenreFromJSON(json['genre']),
233+
'genre': !exists(json, 'genre') ? undefined : json['genre'],
231234
'mood': !exists(json, 'mood') ? undefined : MoodFromJSON(json['mood']),
232235
'tags': !exists(json, 'tags') ? undefined : json['tags'],
233236
'license': !exists(json, 'license') ? undefined : json['license'],
@@ -262,7 +265,7 @@ export function CreatePlaylistRequestBodyToJSON(value?: CreatePlaylistRequestBod
262265
'description': value.description,
263266
'is_private': value.isPrivate,
264267
'is_album': value.isAlbum,
265-
'genre': GenreToJSON(value.genre),
268+
'genre': value.genre,
266269
'mood': MoodToJSON(value.mood),
267270
'tags': value.tags,
268271
'license': value.license,

packages/sdk/src/sdk/api/generated/default/models/CreateTrackRequestBody.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,6 @@ import {
4949
FieldVisibilityFromJSONTyped,
5050
FieldVisibilityToJSON,
5151
} from './FieldVisibility';
52-
import type { Genre } from './Genre';
53-
import {
54-
GenreFromJSON,
55-
GenreFromJSONTyped,
56-
GenreToJSON,
57-
} from './Genre';
5852
import type { Mood } from './Mood';
5953
import {
6054
MoodFromJSON,
@@ -93,11 +87,20 @@ export interface CreateTrackRequestBody {
9387
*/
9488
title: string;
9589
/**
90+
* Music genre. Any string up to 100 characters is accepted. Known/canonical
91+
* values (shown as autocomplete suggestions in clients): Electronic, Rock,
92+
* Metal, Alternative, Hip-Hop/Rap, Experimental, Punk, Folk, Pop, Ambient,
93+
* Soundtrack, World, Jazz, Acoustic, Funk, R&B/Soul, Devotional, Classical,
94+
* Reggae, Podcasts, Country, Spoken Word, Comedy, Blues, Kids, Audiobooks,
95+
* Latin, Lo-Fi, Hyperpop, Dancehall, Techno, Trap, House, Tech House,
96+
* Deep House, Disco, Electro, Jungle, Progressive House, Hardstyle,
97+
* Glitch Hop, Trance, Future Bass, Future House, Tropical House, Downtempo,
98+
* Drum & Bass, Dubstep, Jersey Club, Vaporwave, Moombahton.
9699
*
97-
* @type {Genre}
100+
* @type {string}
98101
* @memberof CreateTrackRequestBody
99102
*/
100-
genre: Genre;
103+
genre: string;
101104
/**
102105
* Track description
103106
* @type {string}
@@ -370,7 +373,7 @@ export function CreateTrackRequestBodyFromJSONTyped(json: any, ignoreDiscriminat
370373

371374
'trackId': !exists(json, 'track_id') ? undefined : json['track_id'],
372375
'title': json['title'],
373-
'genre': GenreFromJSON(json['genre']),
376+
'genre': json['genre'],
374377
'description': !exists(json, 'description') ? undefined : json['description'],
375378
'mood': !exists(json, 'mood') ? undefined : MoodFromJSON(json['mood']),
376379
'bpm': !exists(json, 'bpm') ? undefined : json['bpm'],
@@ -426,7 +429,7 @@ export function CreateTrackRequestBodyToJSON(value?: CreateTrackRequestBody | nu
426429

427430
'track_id': value.trackId,
428431
'title': value.title,
429-
'genre': GenreToJSON(value.genre),
432+
'genre': value.genre,
430433
'description': value.description,
431434
'mood': MoodToJSON(value.mood),
432435
'bpm': value.bpm,

packages/sdk/src/sdk/api/generated/default/models/Genre.ts

Lines changed: 0 additions & 87 deletions
This file was deleted.

packages/sdk/src/sdk/api/generated/default/models/UpdatePlaylistRequestBody.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,6 @@ import {
3737
DdexResourceContributorFromJSONTyped,
3838
DdexResourceContributorToJSON,
3939
} from './DdexResourceContributor';
40-
import type { Genre } from './Genre';
41-
import {
42-
GenreFromJSON,
43-
GenreFromJSONTyped,
44-
GenreToJSON,
45-
} from './Genre';
4640
import type { Mood } from './Mood';
4741
import {
4842
MoodFromJSON,
@@ -87,11 +81,20 @@ export interface UpdatePlaylistRequestBody {
8781
*/
8882
isAlbum?: boolean;
8983
/**
84+
* Music genre. Any string up to 100 characters is accepted. Known/canonical
85+
* values (shown as autocomplete suggestions in clients): Electronic, Rock,
86+
* Metal, Alternative, Hip-Hop/Rap, Experimental, Punk, Folk, Pop, Ambient,
87+
* Soundtrack, World, Jazz, Acoustic, Funk, R&B/Soul, Devotional, Classical,
88+
* Reggae, Podcasts, Country, Spoken Word, Comedy, Blues, Kids, Audiobooks,
89+
* Latin, Lo-Fi, Hyperpop, Dancehall, Techno, Trap, House, Tech House,
90+
* Deep House, Disco, Electro, Jungle, Progressive House, Hardstyle,
91+
* Glitch Hop, Trance, Future Bass, Future House, Tropical House, Downtempo,
92+
* Drum & Bass, Dubstep, Jersey Club, Vaporwave, Moombahton.
9093
*
91-
* @type {Genre}
94+
* @type {string}
9295
* @memberof UpdatePlaylistRequestBody
9396
*/
94-
genre?: Genre;
97+
genre?: string;
9598
/**
9699
*
97100
* @type {Mood}
@@ -219,7 +222,7 @@ export function UpdatePlaylistRequestBodyFromJSONTyped(json: any, ignoreDiscrimi
219222
'description': !exists(json, 'description') ? undefined : json['description'],
220223
'isPrivate': !exists(json, 'is_private') ? undefined : json['is_private'],
221224
'isAlbum': !exists(json, 'is_album') ? undefined : json['is_album'],
222-
'genre': !exists(json, 'genre') ? undefined : GenreFromJSON(json['genre']),
225+
'genre': !exists(json, 'genre') ? undefined : json['genre'],
223226
'mood': !exists(json, 'mood') ? undefined : MoodFromJSON(json['mood']),
224227
'tags': !exists(json, 'tags') ? undefined : json['tags'],
225228
'license': !exists(json, 'license') ? undefined : json['license'],
@@ -253,7 +256,7 @@ export function UpdatePlaylistRequestBodyToJSON(value?: UpdatePlaylistRequestBod
253256
'description': value.description,
254257
'is_private': value.isPrivate,
255258
'is_album': value.isAlbum,
256-
'genre': GenreToJSON(value.genre),
259+
'genre': value.genre,
257260
'mood': MoodToJSON(value.mood),
258261
'tags': value.tags,
259262
'license': value.license,

0 commit comments

Comments
 (0)