10 KiB
Music Playlists on Nostr — Architecture Plan
Goal
Add the ability to create, save, and publish playlists on Nostr from the music page, and view playlists from followed users.
NIP Analysis — Which Event Kind?
After reviewing the NIPs, there are several viable approaches:
Option A: NIP-51 Curation Sets — kind 30004 ✅ RECOMMENDED
NIP-51 "Curation sets" are addressable events designed for exactly this: user-curated collections of content. They already support title, image, description tags and use the d tag as a unique identifier.
The pattern from NIP-51:
{
"kind": 30004,
"tags": [
["d", "my-playlist-id"],
["title", "Chill Vibes"],
["image", "https://resources.tidal.com/images/.../320x320.jpg"],
["description", "My favorite chill tracks"],
["i", "tidal:track:12345", "https://tidal.com/track/12345"],
["i", "tidal:track:67890", "https://tidal.com/track/67890"],
["t", "chill"],
["t", "music"]
],
"content": ""
}
Why this is the best fit:
- Addressable events — can be updated without creating duplicates
- Multiple playlists per user via different
dtags - Built-in
title,image,descriptionsupport - Already used by other Nostr clients for curation
- Friends' playlists discoverable via follow list + kind filter
- NIP-73
itags withtidal:track:<id>give us external content IDs
Trade-offs:
- Kind 30004 is shared with article curation — we differentiate via
ttag containingmusicor a custom label - Track metadata like title/artist/duration must be stored per-item or re-fetched from the API
Option B: NIP-78 Application-specific Data — kind 30078
We already use kind 30078 for user settings. We could store playlists as app-specific data:
{
"kind": 30078,
"tags": [["d", "greyscale:playlist:my-playlist-id"]],
"content": "{\"title\":\"Chill Vibes\",\"tracks\":[...]}"
}
Pros: Full control over format, no interop concerns Cons: Not discoverable by other music clients, not standard, JSON blob in content
Option C: Custom kind in the 30000-39999 range
Define our own addressable kind for music playlists. No existing NIP defines a music-specific playlist kind.
Pros: Clean separation Cons: Non-standard, no interop, relays may not store unknown kinds
Recommendation: Option A — NIP-51 kind 30004
Use kind 30004 curation sets with NIP-73 external content IDs. This is the most Nostr-native approach, gives us interoperability with other clients, and the infrastructure already exists.
Event Format Design
Playlist Event — kind 30004
{
"kind": 30004,
"tags": [
["d", "<unique-playlist-slug>"],
["title", "My Playlist Name"],
["description", "Optional description"],
["image", "https://resources.tidal.com/images/<cover-uuid>/320x320.jpg"],
["t", "music"],
["t", "playlist"],
["i", "tidal:track:12345"],
["i", "tidal:track:67890"],
["i", "tidal:track:11111"]
],
"content": "<optional JSON with track metadata cache>"
}
Tag breakdown:
d— unique playlist identifier per user, e.g.chill-vibesor a random IDtitle— playlist display namedescription— optional descriptionimage— playlist cover image URLt— hashtags; always includemusicandplaylistfor discoverabilityi— one per track, using NIP-73 external content ID format:tidal:track:<tidal_id>
Content field — track metadata cache:
To avoid re-fetching every track from the Tidal API when displaying a playlist, we store a JSON metadata cache in content:
{
"tracks": [
{
"id": 12345,
"title": "Song Name",
"artist": "Artist Name",
"duration": 245,
"cover": "uuid-string",
"albumTitle": "Album Name"
}
]
}
This is a cache — the i tags are the source of truth for which tracks are in the playlist. The content JSON provides display metadata so we don't need API calls just to render the list.
Architecture
flowchart TB
subgraph Music Page
SEARCH[Search + Play - existing]
PLMGR[Playlist Manager UI]
PLIST[Playlist View/Browse]
end
subgraph www/js modules
API[greyscale-api.mjs]
PLAYER[greyscale-player.mjs]
PLMOD[greyscale-playlists.mjs - NEW]
NDK[init-ndk.mjs]
end
subgraph Nostr
PUB[publishEvent - kind 30004]
SUB[subscribe - kind 30004]
FOLLOWS[Follow list - kind 3]
end
SEARCH --> API
SEARCH --> PLAYER
PLMGR --> PLMOD
PLIST --> PLMOD
PLMOD --> NDK
PLMOD --> API
NDK --> PUB
NDK --> SUB
NDK --> FOLLOWS
UI Design
The music page layout expands to include a playlist sidebar or tab system:
┌─────────────────────────────────────────────────────┐
│ ☰ Music │
├──────────────┬──────────────────────────────────────┤
│ │ │
│ PLAYLISTS │ [Search input...........] [Search] │
│ │ Status: Ready. │
│ + New │ │
│ │ ┌──────────────────────────────┐ │
│ My Lists │ │ [img] Track Title 3:42 │ │
│ ───────── │ │ Artist — Album [+] │ │
│ Chill Vibes │ ├──────────────────────────────┤ │
│ Workout │ │ [img] Track Title 4:15 │ │
│ Focus │ │ Artist — Album [+] │ │
│ │ └──────────────────────────────┘ │
│ Friends │ │
│ ───────── │ │
│ @alice │ │
│ Party Mix │ │
│ @bob │ │
│ Road Trip │ │
│ │ │
├──────────────┴──────────────────────────────────────┤
│ [img] Title [◀][▶/❚❚][▶▶] │
│ Artist 0:00 ═══════○═══ 3:42 │
├─────────────────────────────────────────────────────┤
│ [relay dots] [center] [right] [sats] │
└─────────────────────────────────────────────────────┘
Key UI interactions:
- [+] button on each search result — adds track to the currently selected playlist
- "+ New" button — creates a new empty playlist with a name prompt
- Click a playlist — loads it into the results area for playback
- "My Lists" — playlists authored by the current user
- "Friends" — playlists from followed users, grouped by author
- Right-click / long-press playlist — rename, delete, publish options
Implementation Steps
Step 1: Create www/js/greyscale-playlists.mjs
New module with these functions:
buildPlaylistEvent(playlist)— construct a kind 30004 event from playlist dataparsePlaylistEvent(event)— parse a kind 30004 event into playlist dataisPlaylistEvent(event)— check if a kind 30004 event is a music playlist viattags
Step 2: Add playlist state management to music.html
- Track the current user's playlists in memory
- Subscribe to kind 30004 events from the current user on page load
- Subscribe to kind 30004 events from followed users with
#tfilter formusic
Step 3: Add playlist sidebar HTML/CSS
- Left sidebar panel with "My Lists" and "Friends" sections
- Playlist items with title and track count
- "+ New" button at top
- Responsive: collapse sidebar on narrow screens
Step 4: Add "add to playlist" button on search results
- Each search result gets a [+] button
- Clicking opens a dropdown of user's playlists to add to
- Adding a track appends the
itag and updates the content JSON cache
Step 5: Implement playlist save/publish
- When user modifies a playlist, auto-save locally
- "Publish" button publishes the kind 30004 event via
publishEvent() - Updating an existing playlist replaces the event with same
dtag
Step 6: Implement playlist loading and playback
- Clicking a playlist loads its tracks into the player queue
- Track metadata comes from the content JSON cache
- If cache is stale or missing, re-fetch from API using track IDs
Step 7: Implement friends' playlist discovery
- On page load, get follow list via kind 3
- Subscribe to kind 30004 from followed pubkeys with
#tfiltermusic - Group results by author in the sidebar
Step 8: Implement playlist deletion
- Publish a kind 5 deletion request event referencing the playlist event
Files Changed
| File | Action | Description |
|---|---|---|
www/js/greyscale-playlists.mjs |
New | Playlist event builder/parser module |
www/music.html |
Modified | Add sidebar UI, playlist state, save/load/publish logic |
Nostr Queries
Load my playlists
subscribe(
{ kinds: [30004], authors: [myPubkey], '#t': ['music'] },
{ closeOnEose: true, cacheUsage: 'CACHE_FIRST' }
);
Load friends' playlists
// First get follow list
const follows = getFollowedPubkeys(); // from kind 3
subscribe(
{ kinds: [30004], authors: follows, '#t': ['music'] },
{ closeOnEose: true, cacheUsage: 'CACHE_FIRST' }
);
Publish a playlist
const event = {
kind: 30004,
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', playlistId],
['title', playlistTitle],
['t', 'music'],
['t', 'playlist'],
...tracks.map(t => ['i', `tidal:track:${t.id}`])
],
content: JSON.stringify({ tracks: tracksMetadata })
};
await publishEvent(event);