playlist-data-engine 1.7.2 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +20 -3
  2. package/bin/cli.cjs +85 -0
  3. package/dist/core/parser/TrackExtras.d.ts +150 -1
  4. package/dist/core/parser/TrackExtras.d.ts.map +1 -1
  5. package/dist/gateway-CDMPqFEH.js +1320 -0
  6. package/dist/gateway-DKa45Uz6.cjs +6 -0
  7. package/dist/gateway.d.ts +3 -1
  8. package/dist/gateway.d.ts.map +1 -1
  9. package/dist/gateway.js +1 -1
  10. package/dist/gateway.mjs +22 -14
  11. package/dist/index.d.ts +4 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/playlist-data-engine.js +34 -34
  14. package/dist/playlist-data-engine.mjs +964 -1240
  15. package/dist/utils/engineDocs.d.ts +33 -0
  16. package/dist/utils/engineDocs.d.ts.map +1 -0
  17. package/dist/utils/playlistUtils.d.ts +37 -0
  18. package/dist/utils/playlistUtils.d.ts.map +1 -1
  19. package/dist/utils/validators.d.ts +27 -0
  20. package/dist/utils/validators.d.ts.map +1 -1
  21. package/docs/DATA_ENGINE_REFERENCE.md +6660 -0
  22. package/docs/USAGE_IN_OTHER_PROJECTS.md +587 -0
  23. package/docs/features/AUDIO_ANALYSIS.md +610 -0
  24. package/docs/features/BEAT_DETECTION.md +5250 -0
  25. package/docs/features/COMBAT_SYSTEM.md +1632 -0
  26. package/docs/features/CONTENT_PACKS.md +464 -0
  27. package/docs/features/CUSTOM_CONTENT.md +603 -0
  28. package/docs/features/ENEMY_GENERATION.md +1711 -0
  29. package/docs/features/EQUIPMENT_SYSTEM.md +2279 -0
  30. package/docs/features/EXTENSIBILITY_GUIDE.md +1106 -0
  31. package/docs/features/GATEWAY_RESOLUTION.md +725 -0
  32. package/docs/features/IRL_SENSORS.md +360 -0
  33. package/docs/features/PLAYLIST_PARSING.md +446 -0
  34. package/docs/features/PREREQUISITES.md +571 -0
  35. package/docs/features/ROLLS_AND_SEEDS.md +687 -0
  36. package/docs/features/XP_AND_STATS.md +1221 -0
  37. package/llms.txt +33 -0
  38. package/package.json +9 -2
  39. package/skills/playlist-data-engine/SKILL.md +69 -0
  40. package/dist/gateway-DUk4nCao.cjs +0 -1
  41. package/dist/gateway-DyR4M-uH.js +0 -681
@@ -0,0 +1,446 @@
1
+ # Playlist Parsing
2
+
3
+ ## Quick Start
4
+
5
+ The playlist parsing pipeline takes a raw `ServerlessPlaylist` object (from Arweave, IPFS, or any JSON source) and flattens each track into a consistent shape — stripping redundant fields and normalizing platform-specific names so you only have what you need.
6
+
7
+ When the playlist JSON sits on Arweave, loading it is three steps — resolve a working gateway, fetch the JSON, parse:
8
+
9
+ ```typescript
10
+ import { PlaylistParser, arweaveGatewayManager } from 'playlist-data-engine';
11
+
12
+ const url = await arweaveGatewayManager.resolveUrl(`https://arweave.net/${txId}`);
13
+ const raw = await (await fetch(url)).json();
14
+
15
+ const parser = new PlaylistParser();
16
+ const playlist = await parser.parse(raw);
17
+
18
+ console.log(`${playlist.tracks.length} tracks loaded`);
19
+ // playlist.name, playlist.image, playlist.creator, ...
20
+ ```
21
+
22
+ JSON already in hand (IPFS, a local file, a test fixture) skips the first two lines — `parse()` takes any raw playlist object. What `resolveUrl()` does is the subject of [GATEWAY_RESOLUTION.md](GATEWAY_RESOLUTION.md).
23
+
24
+ **Options:**
25
+
26
+ | Property | Type | Default | Description |
27
+ |----------|------|---------|-------------|
28
+ | `validateAudioUrls` | boolean | `false` | HEAD-check audio URLs during parsing |
29
+ | `strict` | boolean | `false` | Throw on invalid tracks instead of skipping |
30
+ | `audioUrlValidationTimeout` | number | `5000` | Timeout for audio URL validation (ms) |
31
+ | `resolveImageUrls` | boolean | `false` | Resolve Arweave image URLs to working gateways via HEAD-checking the gateway manager. Adds network requests during parsing. Audio URLs are never resolved. |
32
+
33
+ ---
34
+
35
+ ## Accessing Track Data
36
+
37
+ After parsing, every track is flattened with consistent field names regardless of source platform. Access fields directly:
38
+
39
+ ```typescript
40
+ const track = playlist.tracks[0];
41
+
42
+ track.audio_url // Best audio URL
43
+ track.audio_url_lossless // Highest-fidelity source available, when it differs from audio_url
44
+ track.selected_mix // Name of the pinned mix, when the entry pins one
45
+ track.image_url // Best image/artwork URL (parser accepts artwork_url OR image_url on input, normalizes to image_url)
46
+ track.image_thumb_url // Thumbnail
47
+ track.artist // "Artist Name"
48
+ track.genre // "Electronic"
49
+ track.tags // ["chill", "upbeat"]
50
+ track.duration // 234.5 (seconds)
51
+ track.bpm // 128
52
+ track.id // "AR-abc123..."
53
+ track.chain_name // "AR"
54
+ ```
55
+
56
+ ### Selected Mix
57
+
58
+ A playlist entry can pin one of the track's alternate mixes. That choice belongs to the entry rather than to the track — the same song can sit in two playlists with two different mixes pinned — so it arrives on the raw track wrapper as `selected_mix`, next to `chain_name` and `playlist_index`.
59
+
60
+ When an entry pins a mix the track actually ships, the parser plays that mix: `audio_url` (and `audio_url_lossless`, if the track carries both a lossy and a lossless master under one name) point at the mix rather than at the track's primary audio, and `selected_mix` carries the name.
61
+
62
+ ```typescript
63
+ track.selected_mix // "Extended VIP"
64
+ track.audio_url // → the Extended VIP mp3, not the primary audio
65
+ track.audio_url_lossless // → the Extended VIP wav, when the track ships one
66
+ ```
67
+
68
+ **`selected_mix` is absent when nothing was pinned.** There is no placeholder value — a missing field means "play the primary audio". Older playlists carry the choice as a `Selected Mix` attribute instead, which the parser reads as a fallback, and some of them store the string `"default"` to mean "no mix"; that placeholder is treated as absent. A name that matches no mix on the track is also treated as absent, so a parsed track never claims a mix it cannot play.
69
+
70
+ Inputs are lenient on the read side: a mix whose name arrives wrapped as `{ value: 'Instrumental' }` parses the same as a plain string, and when one name ships twice — a lossy and a lossless master — the pin resolves to the lossy master and puts the lossless one on `audio_url_lossless`.
71
+
72
+ To resolve a selection outside the parser, `resolveSelectedMix(wrapperMix, attributes, mixes)` returns `{ name, audio_url?, audio_url_lossless? }`, or `null` in any of the absent cases above.
73
+
74
+ ### Choosing a Mix
75
+
76
+ As covered in [Selected Mix](#selected-mix), a pinned entry needs nothing extra — the parser already repointed `audio_url` (and `audio_url_lossless`) at the pinned mix, so it plays like any other track. Playing a mix the entry did *not* pin — a user picks the Instrumental mix from a picker, say — is a lookup plus resolution, or one call:
77
+
78
+ ```typescript
79
+ import { findMixByName, resolveMixUrl } from 'playlist-data-engine';
80
+
81
+ const mix = findMixByName(track.extras.mixes, 'instrumental'); // case-insensitive, trimmed
82
+ if (!mix) return; // no such mix on this track
83
+
84
+ const url = await resolveMixUrl(mix); // gateway-resolved, play-ready
85
+ if (url) audio.src = url;
86
+ ```
87
+
88
+ On a raw track, the mixes come from the metadata instead: `getTrackExtras(getTrackMetadata(rawTrack)).mixes`.
89
+
90
+ `selectMix` does the same in one call — find, resolve, and return a play-ready copy of the track:
91
+
92
+ ```typescript
93
+ import { selectMix } from 'playlist-data-engine';
94
+
95
+ const instrumental = await selectMix(track, 'Instrumental', { prefer: 'lossless' });
96
+ if (instrumental) {
97
+ audio.src = instrumental.audio_url; // the mix, gateway-resolved
98
+ instrumental.selected_mix; // 'Instrumental'
99
+ }
100
+ ```
101
+
102
+ `selectMix` mirrors the pin: it swaps `audio_url` and `audio_url_lossless` onto the chosen mix and sets `selected_mix`. It never mutates its input — the return value is a new track object. The entry's own pin still wins at parse time; `selectMix` is for user choice *after* parsing. Pass `conditionsContext` to require the chosen mix's conditions to currently pass (see [Evaluating Mix Conditions](#evaluating-mix-conditions)).
103
+
104
+ **Mix uris are not resolved at parse time.** `MixInfo.uri` is stored as written in the metadata — the parser's only URL resolution is images, and only when `resolveImageUrls: true`. An `ar://` or `ipfs://` uri, or a bare gateway URL, must be resolved before playback: `resolveMixUrl()` runs `arweaveGatewayManager.resolveUrl()` on the final target for you, or you can call the gateway manager yourself — see [GATEWAY_RESOLUTION.md](GATEWAY_RESOLUTION.md). One uri shape is not audio at all: with `mime_type: 'application/json'` (or a `.json` path) the mix points at a metadata file whose audio fields name the actual song, and `resolveMixUrl()` follows that indirection one level before resolving.
105
+
106
+ **Name matching.** The pin and the lookup helpers do not match names the same way:
107
+
108
+ | Where | Rule |
109
+ |-------|------|
110
+ | Entry pin (`selected_mix`, legacy `Selected Mix` attribute) | Exact and case-sensitive — a name matching nothing is treated as unpinned |
111
+ | `findMixByName` / `selectMix` | Case-insensitive, trimmed, alias-aware — `'inst'` finds `'Instrumental'`; whole-word matching, exact names win, `aliases: false` disables. Vocabulary lives in the alias tables in `TrackExtras.ts` |
112
+ | `weather` / `day` condition values | Case-insensitive — `'rain'` matches `'Rain'` |
113
+ | Unknown condition types | Always pass |
114
+
115
+ When one name ships twice — a lossy and a lossless master — `findMixByName` and `selectMix` take `prefer: 'lossy' | 'lossless'`, defaulting to `'lossy'` to match the pin flow.
116
+
117
+ **Listing and grouping.** For pickers and playlist-wide views:
118
+
119
+ ```typescript
120
+ import { getUniqueMixes, getPreferredMixByQuality, getMixes, getMixTracks } from 'playlist-data-engine';
121
+
122
+ // One group per distinct name; lossy/lossless pairs collapse into one group.
123
+ const groups = getUniqueMixes(track.extras.mixes);
124
+ const vip = groups.find(g => g.name === 'Extended VIP');
125
+
126
+ // Pick a master explicitly from a same-name group:
127
+ const lossless = getPreferredMixByQuality(vip?.mixes, 'lossless');
128
+
129
+ // Playlist scope — works on raw or parsed input:
130
+ getMixes(playlist); // MixInfo[] — every mix on every track, in track order
131
+ getMixTracks(playlist); // tracks that ship mixes, each entry's pin already applied
132
+ ```
133
+
134
+ ### Track Extras
135
+
136
+ Extras (stems, mixes, VRMs, lyrics, game charts, etc.) are extracted during parsing and available on every track:
137
+
138
+ ```typescript
139
+ track.extras.hasExtras // boolean
140
+
141
+ // Stems and mixes (only present if track has them)
142
+ track.extras.stems?.[0].name // "Drums"
143
+ track.extras.mixes?.[0].name // "Night Mix"
144
+
145
+ // Media and content
146
+ track.extras.vrm // "https://.../avatar.vrm"
147
+ track.extras.lyrics?.text // "Hello world"
148
+ track.extras.visualizer?.uri
149
+ track.extras.video?.uri
150
+ track.extras.merch?.uri
151
+ track.extras.credits // [{ name: "Producer", credit: "Beat production" }]
152
+ track.extras.midi
153
+ track.extras.step_mania
154
+ track.extras.clone_hero
155
+ track.extras.external_url
156
+ ```
157
+
158
+ ### Raw Metadata Access
159
+
160
+ For non-standard fields not extracted during parsing (youtube_url, or any platform-specific data), access the full raw metadata:
161
+
162
+ ```typescript
163
+ import { getTrackMetadata } from 'playlist-data-engine';
164
+
165
+ const metadata = getTrackMetadata(track);
166
+ metadata?.youtube_url;
167
+ metadata?.credits;
168
+ ```
169
+
170
+ > `getTrackMetadata()` reads the `.metadata` property from any track-like object (raw or parsed) and returns the parsed result. For raw tracks where metadata is a stringified JSON blob, it handles the parsing automatically.
171
+
172
+ ---
173
+
174
+ ## Playlist Utilities
175
+
176
+ Quick functions to extract arrays of data from a playlist. Works with both parsed (`ServerlessPlaylist`) and raw (`RawArweavePlaylist`) formats — and the two agree on mixes: `getTracks` and `getFullTracks` apply the entry's pin to raw input exactly as the parser does at parse time, so the returned `audio_url` is the pinned mix's, `selected_mix` is set, and `getFullTracks` also carries `extras`.
177
+
178
+ ```typescript
179
+ import {
180
+ getAudioUrls, // string[] — all audio URLs
181
+ getImageUrls, // string[] — all image URLs
182
+ getTrackTitles, // string[] — all titles
183
+ getArtists, // string[] — all artists
184
+ getGenres, // string[] — unique genres, sorted
185
+ getTags, // string[] — unique tags, sorted, lowercased
186
+ getTotalDuration, // number — total seconds
187
+ getTrackCount, // number
188
+ getTracks, // SimpleTrack[] — simplified objects with core fields
189
+ getFullTracks, // object[] — all available data
190
+ getMixes, // MixInfo[] — every alternate mix across tracks
191
+ getMixTracks, // MixTrackInfo[] — tracks that ship mixes, pin applied
192
+ getVRMs, // string[] — VRM URLs from tracks that have one
193
+ getVRMTracks, // VRMTrack[] — track data paired with VRM URLs
194
+ } from 'playlist-data-engine';
195
+
196
+ const urls = getAudioUrls(playlist);
197
+ const genres = getGenres(rawPlaylist);
198
+ ```
199
+
200
+ For the full API reference, see [DATA_ENGINE_REFERENCE.md — Playlist Utilities](../DATA_ENGINE_REFERENCE.md#playlist-utilities).
201
+
202
+ ---
203
+
204
+ ## Track Extras (Stems, Mixes, and Conditions)
205
+
206
+ Tracks can carry additional content beyond the primary audio — individual instrument stems and alternate mixes that activate under specific conditions (weather, time of day, play count, etc.).
207
+
208
+ ### Evaluating Mix Conditions
209
+
210
+ ```typescript
211
+ import { evaluateMixConditions, EnvironmentalSensors } from 'playlist-data-engine';
212
+
213
+ const sensors = new EnvironmentalSensors();
214
+ const environment = await sensors.updateSnapshot();
215
+
216
+ const results = evaluateMixConditions(track.extras, {
217
+ environment,
218
+ appState: { playCount: 5, isFavorite: true },
219
+ });
220
+
221
+ for (const result of results) {
222
+ if (result.allMet) {
223
+ console.log(`"${result.mix.name}" is available`);
224
+ } else {
225
+ console.log(`"${result.mix.name}" blocked:`);
226
+ for (const cond of result.unmetConditions) {
227
+ console.log(` ${cond.reason}`);
228
+ }
229
+ }
230
+ }
231
+ ```
232
+
233
+ ### Supported Condition Types
234
+
235
+ | Type | Value Format | Evaluates Against |
236
+ |------|-------------|-------------------|
237
+ | `weather` | Weather type string (e.g., `"Rain"`, `"Clear"`) | `environment.weather.weatherType` |
238
+ | `day` | Day name (e.g., `"Friday"`) | Current day of week |
239
+ | `start_time` | `"HH:MM"` format | Current time (met if after value) |
240
+ | `end_time` | `"HH:MM"` format | Current time (met if before value) |
241
+ | `min_plays` | Integer | `appState.playCount` (met if >= value) |
242
+ | `max_plays` | Integer | `appState.playCount` (met if <= value) |
243
+ | `every_x_plays` | Integer | `appState.playCount` (met if evenly divisible and greater than 0) |
244
+ | `altitude` | Comparison like `">1000"`, `"<=500"` | `environment.geolocation.altitude` |
245
+ | `favorite` | `"true"` or `"false"` | `appState.isFavorite` |
246
+ | `birthday` | `"MM-DD"` format | Current date matches user birthday |
247
+ | `weight` | Number | Not a gate — always passes; used for random selection probability |
248
+ | Unknown types | Any | Always passes (flexible/extensible) |
249
+
250
+ > Condition values coerce to strings during extraction — `{ type: 'min_plays', value: 10 }` parses the same as `value: '10'` instead of arriving as a mix with no conditions (which would evaluate as always available).
251
+
252
+ ### Extras Types
253
+
254
+ | Type | Description |
255
+ |------|-------------|
256
+ | `TrackExtrasInfo` | Summary of extras on a track — `hasExtras` plus any populated fields below |
257
+ | `StemInfo` | `{ name, uri?, mime_type? }` — an individual instrument track |
258
+ | `MixCondition` | `{ type, value }` — a condition on a mix (e.g., weather, time) |
259
+ | `MixInfo` | `{ name, uri?, mime_type?, conditions[] }` — an alternate mix |
260
+ | `MixGroup` | `{ name, mixes[] }` — mixes sharing one name, from `getUniqueMixes()` |
261
+ | `SelectedMixInfo` | `{ name, audio_url?, audio_url_lossless? }` — the mix an entry pinned, resolved against the track's own mixes |
262
+ | `LyricsInfo` | `{ text? }` — song lyrics |
263
+ | `MediaAssetInfo` | `{ mime_type?, uri? }` — a media asset (visualizer, video) |
264
+ | `MerchInfo` | `{ mime_type?, type?, uri? }` — a merchandise asset |
265
+ | `CreditInfo` | `{ name, credit }` — a single credit entry |
266
+ | `MixEvaluationResult` | `{ mix, conditions[], allMet, unmetConditions[] }` — evaluation output |
267
+ | `AppState` | `{ playCount?, isFavorite?, userBirthday? }` — app-level context |
268
+ | `EvaluationContext` | `{ environment?, appState? }` — full evaluation context |
269
+
270
+ ---
271
+
272
+ ## Working with Raw / Unparsed Data
273
+
274
+ If you have a raw track object (before parsing) and need to extract fields manually, use `MetadataExtractor` directly. All methods are static — no instantiation needed.
275
+
276
+ ```typescript
277
+ import { MetadataExtractor } from 'playlist-data-engine';
278
+
279
+ const metadata = MetadataExtractor.parseMetadata(rawTrack.metadata);
280
+
281
+ MetadataExtractor.extractAudioUrl(metadata); // Best audio URL
282
+ MetadataExtractor.extractAudioUrlLossless(metadata); // Lossless audio
283
+ MetadataExtractor.extractImageUrl(metadata); // Best image URL
284
+ MetadataExtractor.extractImageThumbUrl(metadata); // Thumbnail
285
+ MetadataExtractor.extractTitle(metadata); // Track title
286
+ MetadataExtractor.extractArtist(metadata); // Artist
287
+ MetadataExtractor.extractGenre(metadata); // Genre
288
+ MetadataExtractor.extractDescription(metadata); // Track description (flat, attributes, or nested)
289
+ MetadataExtractor.extractAlbumDescription(metadata); // Album description
290
+ MetadataExtractor.extractArtistDescription(metadata); // Artist description
291
+ ```
292
+
293
+ For getting extras from raw tracks (without going through `PlaylistParser`):
294
+
295
+ ```typescript
296
+ import { getTrackMetadata, getTrackExtras } from 'playlist-data-engine';
297
+
298
+ const metadata = getTrackMetadata(rawTrack);
299
+ if (!metadata) return;
300
+
301
+ const extras = getTrackExtras(metadata);
302
+ if (!extras.hasExtras) return;
303
+
304
+ console.log(`${extras.stems?.length ?? 0} stems, ${extras.mixes?.length ?? 0} mixes`);
305
+ ```
306
+
307
+ Populated fields appear only when non-empty — a lyrics-only track has `hasExtras: true` but no `stems` or `mixes` key at all.
308
+
309
+ To resolve which mix a raw entry pinned:
310
+
311
+ ```typescript
312
+ import { MetadataExtractor, getTrackExtras, resolveSelectedMix } from 'playlist-data-engine';
313
+
314
+ const metadata = MetadataExtractor.parseMetadata(rawTrack.metadata);
315
+ const attributes = MetadataExtractor.convertAttributes(metadata?.attributes);
316
+ const selected = resolveSelectedMix(rawTrack.selected_mix, attributes, getTrackExtras(metadata).mixes);
317
+
318
+ if (selected) {
319
+ console.log(`plays the ${selected.name} mix from ${selected.audio_url}`);
320
+ }
321
+ ```
322
+
323
+ ---
324
+
325
+ ## Reference
326
+
327
+ ### Source Files
328
+
329
+ | Component | Location |
330
+ |-----------|----------|
331
+ | PlaylistParser | [src/core/parser/PlaylistParser.ts](../../src/core/parser/PlaylistParser.ts) |
332
+ | MetadataExtractor | [src/core/parser/MetadataExtractor.ts](../../src/core/parser/MetadataExtractor.ts) |
333
+ | Track Extras | [src/core/parser/TrackExtras.ts](../../src/core/parser/TrackExtras.ts) |
334
+ | Playlist Utilities | [src/utils/playlistUtils.ts](../../src/utils/playlistUtils.ts) |
335
+ | Type Definitions | [src/core/types/Playlist.ts](../../src/core/types/Playlist.ts) |
336
+
337
+ ### ServerlessPlaylist
338
+
339
+ ```
340
+ ServerlessPlaylist
341
+ ├── name: string // Playlist name
342
+ ├── description?: string
343
+ ├── image: string // Playlist cover art URL
344
+ ├── creator: string // Curator wallet address
345
+ ├── genre?: string
346
+ ├── tags?: string[]
347
+ ├── playlist_type?: 'new' | 'remix' | 'ep' | 'lp' | 'single' // v0.4
348
+ ├── original_playlist_tx_id?: string // v0.4 (remixes)
349
+ ├── playlist_artist?: string // v0.4 (ep/lp/single)
350
+ ├── platform?: string // v0.4 (directory-imported origin: "contract-wizard" | "nina")
351
+ └── tracks: PlaylistTrack[] // The content
352
+ ```
353
+
354
+ > **v0.4 naming convention:** Fields that travel on the wire (playlist body, track wrapper,
355
+ > metadata interior, Arweave tags) use snake_case for JSON body fields and Pascal-Kebab for
356
+ > Arweave tags. Mint fields (`mint_function`, `mint_price`, `mint_snapshot_time`, `mint_token`)
357
+ > live on the **track wrapper** — the same shallow-read layer as the resolved media fields — and
358
+ > are never emitted as Arweave tags. The parser copies them straight from the raw track onto the
359
+ > parsed `PlaylistTrack`; the metadata interior is never their home (`metadata` mirrors `token_uri`,
360
+ > and mint prices are snapshot state that changes without rewriting metadata).
361
+ >
362
+ > **Image field aliasing:** The ApeTapes app writes `artwork_url` onto track wrappers when uploading
363
+ > playlists. The engine's parser accepts **either** `artwork_url` **or** `image_url` on input and
364
+ > normalizes the value to the engine's canonical output field `image_url` (which pairs with
365
+ > `image_thumb_url`). So input may carry `artwork_url`, but the parsed `PlaylistTrack` always
366
+ > exposes the resolved image as `image_url`.
367
+
368
+ ### PlaylistTrack
369
+
370
+ ```
371
+ PlaylistTrack
372
+ │
373
+ ├── Identity
374
+ │ ├── id: string // e.g. "AR-abc123" or "ethereum-0xContract-1"
375
+ │ ├── uuid: string // Unique instance ID
376
+ │ ├── playlist_index: number // Position in playlist
377
+ │ ├── chain_name: string // "AR", "ethereum", "optimism", etc.
378
+ │ ├── token_address?: string // Contract address (non-AR chains)
379
+ │ ├── token_id?: string // Token ID (non-AR chains)
380
+ │ ├── tx_id?: string // Arweave transaction ID (AR chain only)
381
+ │ └── platform: string // "sound", "catalog", "contract-wizard", etc.
382
+ │
383
+ ├── Content
384
+ │ ├── title: string
385
+ │ ├── artist: string
386
+ │ ├── description?: string
387
+ │ └── album?: string
388
+ │
389
+ ├── Assets
390
+ │ ├── audio_url: string // Best audio URL (compressed)
391
+ │ ├── audio_url_lossless?: string // Lossless audio (WAV/FLAC) if available
392
+ │ ├── image_url: string // Best image URL
393
+ │ ├── image_thumb_url?: string // Thumbnail if available
394
+ │ ├── audio_ipfs_hash?: string // IPFS CID of the audio file (v0.4)
395
+ │ └── artwork_ipfs_hash?: string // IPFS CID of the artwork/image file (v0.4)
396
+ │
397
+ ├── Mint (v0.4 — wrapper-level snapshot, read off the track object)
398
+ │ ├── mint_function?: string // e.g. "mint", "mintCopy"
399
+ │ ├── mint_price?: string // wei
400
+ │ ├── mint_snapshot_time?: number // unix seconds
401
+ │ └── mint_token?: string // ERC20 address, if any
402
+ │
403
+ ├── Metadata
404
+ │ ├── duration: number // Seconds
405
+ │ ├── genre: string
406
+ │ ├── tags: string[]
407
+ │ ├── bpm?: number
408
+ │ ├── key?: string
409
+ │ └── attributes?: Record<string, string | number>
410
+ │
411
+ └── Extras (extracted during parsing — only present if track has extras)
412
+ ├── extras.hasExtras: boolean
413
+ ├── extras.stems?: StemInfo[] // Individual instrument tracks
414
+ ├── extras.mixes?: MixInfo[] // Alternate mixes with conditions
415
+ ├── extras.vrm?: string // 3D avatar model URL
416
+ ├── extras.lyrics?: { text? } // Song lyrics
417
+ ├── extras.visualizer?: { mime_type?, uri? } // Visualizer asset
418
+ ├── extras.video?: { mime_type?, uri? } // Video asset
419
+ ├── extras.merch?: { mime_type?, type?, uri? } // Merchandise asset
420
+ ├── extras.credits?: { name, credit }[] // Credits / acknowledgments
421
+ ├── extras.midi?: string // MIDI file URL
422
+ ├── extras.step_mania?: string // StepMania chart URL
423
+ ├── extras.clone_hero?: string // Clone Hero chart URL
424
+ └── extras.external_url?: string // External link
425
+ ```
426
+
427
+ ### MetadataExtractor Methods
428
+
429
+ Extracts metadata fields from track data. Called automatically during parsing, but can also be used directly on arbitrary metadata objects.
430
+
431
+ | Method | Description |
432
+ |--------|-------------|
433
+ | `extractAudioUrl()` | Best available audio URL across common platform formats |
434
+ | `extractAudioUrlLossless()` | Lossless audio (WAV/FLAC) if present |
435
+ | `extractImageUrl()` | Best available image URL — checks flat fields then nested object paths |
436
+ | `extractImageThumbUrl()` | Thumbnail URL |
437
+ | `extractTitle()` | Track title |
438
+ | `extractArtist()` | Artist name |
439
+ | `extractGenre()` | Genre — string, array, or OpenSea attributes |
440
+ | `extractDescription()` | Track description — flat variants, OpenSea attributes, then a deep search of nested objects |
441
+ | `extractAlbumDescription()` | Album description — flat variants, then scoped to the `album` object when it is an object |
442
+ | `extractArtistDescription()` | Artist description — flat variants, then scoped to the `artist` object when it is an object |
443
+ | `parseMetadata()` | Parses stringified JSON to object |
444
+ | `convertAttributes()` | Converts OpenSea-style `[{ trait_type, value }]` to `{ key: value }` |
445
+
446
+ For the full API reference, see [DATA_ENGINE_REFERENCE.md](../DATA_ENGINE_REFERENCE.md).