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.
- package/README.md +20 -3
- package/bin/cli.cjs +85 -0
- package/dist/core/parser/TrackExtras.d.ts +150 -1
- package/dist/core/parser/TrackExtras.d.ts.map +1 -1
- package/dist/gateway-CDMPqFEH.js +1320 -0
- package/dist/gateway-DKa45Uz6.cjs +6 -0
- package/dist/gateway.d.ts +3 -1
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +1 -1
- package/dist/gateway.mjs +22 -14
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/playlist-data-engine.js +34 -34
- package/dist/playlist-data-engine.mjs +964 -1240
- package/dist/utils/engineDocs.d.ts +33 -0
- package/dist/utils/engineDocs.d.ts.map +1 -0
- package/dist/utils/playlistUtils.d.ts +37 -0
- package/dist/utils/playlistUtils.d.ts.map +1 -1
- package/dist/utils/validators.d.ts +27 -0
- package/dist/utils/validators.d.ts.map +1 -1
- package/docs/DATA_ENGINE_REFERENCE.md +6660 -0
- package/docs/USAGE_IN_OTHER_PROJECTS.md +587 -0
- package/docs/features/AUDIO_ANALYSIS.md +610 -0
- package/docs/features/BEAT_DETECTION.md +5250 -0
- package/docs/features/COMBAT_SYSTEM.md +1632 -0
- package/docs/features/CONTENT_PACKS.md +464 -0
- package/docs/features/CUSTOM_CONTENT.md +603 -0
- package/docs/features/ENEMY_GENERATION.md +1711 -0
- package/docs/features/EQUIPMENT_SYSTEM.md +2279 -0
- package/docs/features/EXTENSIBILITY_GUIDE.md +1106 -0
- package/docs/features/GATEWAY_RESOLUTION.md +725 -0
- package/docs/features/IRL_SENSORS.md +360 -0
- package/docs/features/PLAYLIST_PARSING.md +446 -0
- package/docs/features/PREREQUISITES.md +571 -0
- package/docs/features/ROLLS_AND_SEEDS.md +687 -0
- package/docs/features/XP_AND_STATS.md +1221 -0
- package/llms.txt +33 -0
- package/package.json +9 -2
- package/skills/playlist-data-engine/SKILL.md +69 -0
- package/dist/gateway-DUk4nCao.cjs +0 -1
- 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).
|