Files
client/plans/music-playlists.md
2026-04-17 16:52:51 -04:00

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:

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 d tags
  • Built-in title, image, description support
  • Already used by other Nostr clients for curation
  • Friends' playlists discoverable via follow list + kind filter
  • NIP-73 i tags with tidal:track:<id> give us external content IDs

Trade-offs:

  • Kind 30004 is shared with article curation — we differentiate via t tag containing music or 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-vibes or a random ID
  • title — playlist display name
  • description — optional description
  • image — playlist cover image URL
  • t — hashtags; always include music and playlist for discoverability
  • i — 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:

  1. [+] button on each search result — adds track to the currently selected playlist
  2. "+ New" button — creates a new empty playlist with a name prompt
  3. Click a playlist — loads it into the results area for playback
  4. "My Lists" — playlists authored by the current user
  5. "Friends" — playlists from followed users, grouped by author
  6. 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 data
  • parsePlaylistEvent(event) — parse a kind 30004 event into playlist data
  • isPlaylistEvent(event) — check if a kind 30004 event is a music playlist via t tags

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 #t filter for music

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 i tag 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 d tag

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 #t filter music
  • 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);