diff --git a/docs/MUSIC_URL_STRUCTURE.md b/docs/MUSIC_URL_STRUCTURE.md index 75edb76..1d74d21 100644 --- a/docs/MUSIC_URL_STRUCTURE.md +++ b/docs/MUSIC_URL_STRUCTURE.md @@ -51,7 +51,7 @@ Routes are parsed by splitting the hash on `/`: ``` #/playlist/abc123/ep-42 → { page: 'playlist', parts: ['abc123', 'ep-42'] } #/search/bob marley → { page: 'search', parts: ['bob marley'] } -#/track/98765 → { page: 'track', parts: ['98765'] } +#/track/36787:pubkey:dTag → { page: 'track', parts: ['36787:pubkey:dTag'] } ``` ## URL Examples @@ -73,8 +73,8 @@ vj.html?npub=npub1abc123...&show=saturday-reggae&episode=ep-1744382400 ### Direct track link (works on either page) ``` -music.html#/track/12345678 -vj.html#/track/12345678 +music.html#/track/36787:aaaaaaaa...:my-track-dtag +vj.html#/track/36787:aaaaaaaa...:my-track-dtag ``` ### Search link @@ -95,7 +95,7 @@ music.html#/album/album-id-here | Select/create episode | `?episode={id}` added/updated | `replaceState` | | Search for music | `#/search/{query}` | hash assignment | | Click album/artist | `#/album/{id}` or `#/artist/{id}` | hash assignment | -| Play track from link | `#/track/{id}` | hash assignment | +| Play track from link | `#/track/{id}` (`id` is now typically `36787:pubkey:dTag`) | hash assignment | | Select playlist | `#/playlist/{id}` | hash assignment | | Login / auth change | `?npub={npub}` added | `replaceState` | | Load external user | `?npub={npub}` or `?pubkey={hex}` | page navigation | diff --git a/docs/music.md b/docs/music.md index 9457d85..77d7842 100644 --- a/docs/music.md +++ b/docs/music.md @@ -4,6 +4,12 @@ The music page ([`www/music.html`](www/music.html:1)) uses a **single unified queue** (`musicQueue[]`) as the source of truth for all playback. The audio engine ([`SimplePlayer`](www/js/greyscale-player.mjs:8)) is a pure playback component — it plays whatever stream URL it receives and fires callbacks when tracks end. +As of the Gruuv migration, track discovery/playback is relay-native: +- Track metadata is read from Nostr events (`kind 36787`, `t=gruuv`) via [`GruuvAPI`](www/js/gruuv-api.mjs:333) +- Playlist events are published/read as `kind 34139` via [`gruuv-playlists.mjs`](www/js/gruuv-playlists.mjs:1) +- Track IDs in queue/URLs are now addressable coordinates (`36787:pubkey:dTag`), not numeric Tidal IDs +- Uploads use Blossom + `kind 24242` auth and publish `kind 36787` via [`uploadGruuvTrack()`](www/js/gruuv-upload.mjs:139) + --- ## Architecture @@ -72,7 +78,7 @@ Persisted to localStorage under key `music-queue:{pubkey}`: { "currentIndex": 2, "items": [ - { "id": 123, "title": "...", "artist": "...", "duration": 240, "cover": "...", "albumTitle": "..." } + { "id": "36787::", "title": "...", "artist": "...", "duration": 240, "cover": "...", "albumTitle": "..." } ] } ``` diff --git a/plans/music-greyscale-to-gruuv-migration.md b/plans/music-greyscale-to-gruuv-migration.md new file mode 100644 index 0000000..38ced4b --- /dev/null +++ b/plans/music-greyscale-to-gruuv-migration.md @@ -0,0 +1,187 @@ +# Music: Greyscale → Gruuv API Migration Plan + +## Goal + +Fully replace the Greyscale (Tidal-proxy) music backend used by [`www/music.html`](../www/music.html) with the **Gruuv** Nostr-native protocol. This includes search, browse, streaming, playlists, and a new track upload capability. Greyscale code is removed once the gruuv path is verified. + +A backup of the original page is preserved as [`www/music-greyscale.html`](../www/music-greyscale.html). + +--- + +## Background: Why this is an architecture shift, not a swap + +The two backends are fundamentally different in nature. + +| Aspect | Greyscale (current) | Gruuv (target) | +|---|---|---| +| Catalog source | Tidal proxy REST API over HTTP instance pool | Nostr relays: `kind 36787` track events tagged `t=gruuv` | +| Search | Server endpoint `/search/?s=` | Relay query + client-side text filter on title/artist/album | +| Streaming | DASH/MP3 manifest decoded from instance | Direct Blossom blob URL (the `url` tag) | +| Track ID | Numeric Tidal ID | Addressable coordinate `36787:pubkey:dTag` | +| Album | Rich `/album/?id=` endpoint | Derived by grouping track events on the `album` tag | +| Artist | Rich `/artist/?id=` endpoint | Derived by grouping track events on the `artist` tag | +| Cover art | `resources.tidal.com/images/...` | The `image` tag on the track event | +| Playlists | `kind 30004`, `i` tags `tidal:track:ID` | `kind 34139`, `a` tags `36787:pubkey:dTag`, `t=gruuv` | +| Upload | N/A (read-only Tidal) | Blossom `PUT /upload` + publish `kind 36787` | + +### What already exists in the project (reused, not rebuilt) + +- [`www/js/init-ndk.mjs`](../www/js/init-ndk.mjs) — provides `subscribe()`, `publishEvent()`, `publishRawEvent()`, `getPubkey()`. The page already wires NDK, relay status UI, and the `ndkEvent` window-event stream. +- [`www/js/blossom-api.mjs`](../www/js/blossom-api.mjs) — provides `calculateSHA256()` and `createBlossomAuth()`, which already builds the **`kind 24242`** authorization event that Gruuv's upload spec requires (see [`gruuv/README.md`](../gruuv/README.md)). +- [`www/js/blossom-ui.mjs`](../www/js/blossom-ui.mjs) — provides `getBlossomServers()` for the active Blossom target. +- [`SimplePlayer`](../www/js/greyscale-player.mjs) — its direct-URL playback path already handles plain audio URLs (Blossom URLs are plain `https`), so playback needs no DASH logic for gruuv. + +### Gruuv protocol reference (from `gruuv/`) + +`kind 36787` track event tags (per [`gruuv/upload_gruuv.sh`](../gruuv/upload_gruuv.sh) and [`gruuv/README.md`](../gruuv/README.md)): + +``` +d stable track id / dTag +url blossom file url +title song title +artist artist name +album album name +duration seconds (string) +size bytes (string) +alt "Song: - <artist>" +format mp3 | flac | ... +m audio mime (audio/mpeg, audio/flac, ...) +x sha256 hex +t music +t gruuv +image (optional) cover art url +genre (optional) + t=<genre-lowercase> +released (optional) date-like +track_number (optional) plain numeric string +disc_number (optional, omit when disc 1) +``` + +`kind 34139` playlist event tags: + +``` +d playlist id / dTag +title +description +alt "Playlist: <title>" +t gruuv +image (optional) +a 36787:<pubkey>:<dTag> (repeated, one per track) +``` + +Default relays observed: `wss://relay.primal.net`, `wss://nos.lol`, `wss://relay.damus.io`. Default Blossom: `https://blossom.primal.net`. (The page's own NDK relay config / Blossom server selection should be used in practice.) + +--- + +## Design strategy: drop-in module replacement + +To keep [`www/music.html`](../www/music.html) changes minimal and low-risk, the new gruuv modules will expose the **same method names and return shapes** as the current greyscale modules. Then the migration in `music.html` is mostly an import swap plus targeted fixes for the few places where the data model genuinely differs (IDs, derived albums/artists, upload UI). + +```mermaid +graph LR + subgraph page [music.html UI - mostly unchanged] + Search[Search box] + Browse[Album / Artist views] + Player[SimplePlayer audio] + Playlists[Playlist panel] + Upload[NEW Upload panel] + end + + subgraph mods [New gruuv modules] + API[gruuv-api.mjs] + PL[gruuv-playlists.mjs] + UP[gruuv-upload.mjs] + end + + subgraph infra [Existing project infra] + NDK[init-ndk.mjs subscribe publishEvent] + BLOB[blossom-api.mjs sha256 kind 24242 auth] + BUI[blossom-ui.mjs getBlossomServers] + end + + Search --> API + Browse --> API + Player --> API + Playlists --> PL + Upload --> UP + API --> NDK + PL --> NDK + UP --> BLOB + UP --> BUI + UP --> NDK +``` + +--- + +## Data model mapping (gruuv track event → music.html track shape) + +The UI expects the normalized shape produced by [`GreyscaleAPI.prepareTrack()`](../www/js/greyscale-api.mjs:186): +`{ id, title, artist, duration, cover, albumTitle, raw }`. + +Mapping from a `kind 36787` event: + +| UI field | Source | +|---|---| +| `id` | addressable coordinate `36787:<pubkey>:<d-tag>` (fallback to event id) | +| `title` | `title` tag | +| `artist` | `artist` tag (fallback "Unknown artist") | +| `duration` | `duration` tag parsed to number | +| `cover` | `image` tag (no Tidal URL transform) | +| `albumTitle` | `album` tag | +| `streamUrl` | `url` tag (used by `getTrackStream`) | +| `raw` | full event for later reference | + +`getCoverUrl()` / `getArtistPictureUrl()` become pass-through helpers (return the URL as-is) so existing render code keeps working. + +--- + +## Implementation steps + +### Phase 0 — Safety +1. Confirm [`www/music-greyscale.html`](../www/music-greyscale.html) is a faithful backup of the original greyscale page so it can be referenced/restored. + +### Phase 1 — Read path (search, browse, stream) +2. Create [`www/js/gruuv-api.mjs`](../www/js/gruuv-api.mjs) exposing the same surface as `GreyscaleAPI`: + `searchTracks`, `searchAlbums`, `searchArtists`, `getAlbum`, `getArtist`, `getTrackStream`, `getCoverUrl`, `getArtistPictureUrl`, `prepareTrack`. +3. Implement search by subscribing to `kind 36787` with `#t: ['gruuv']`, collecting events until EOSE (with a timeout), then client-side filtering on title/artist/album. Provide a small in-memory cache of fetched track events to back album/artist derivation. +4. Implement `getAlbum` / `getArtist` by grouping the cached/fetched track events on the `album` and `artist` tags respectively (gruuv has no album/artist DB). `getAlbum(id)` returns `{ album, tracks }`; `getArtist(id)` returns `{ ...artist, albums, eps, tracks }` to match existing render expectations. +5. Implement `getTrackStream` to return `{ streamUrl: <url tag>, isDash: false }`. Cover/artwork helpers return the `image` URL directly. + +### Phase 2 — Playlists +6. Create [`www/js/gruuv-playlists.mjs`](../www/js/gruuv-playlists.mjs) mirroring the exports of [`greyscale-playlists.mjs`](../www/js/greyscale-playlists.mjs): + `PLAYLIST_KIND` (now `34139`), `buildPlaylistEvent`, `parsePlaylistEvent`, `isMusicPlaylistEvent`, `createPlaylistIdentifier`, `playlistEventAddress`, and any helper the page imports. + - Track references use `a` tags of form `36787:<pubkey>:<dTag>` instead of `i`/`tidal:track:` tags. + - Topic tag becomes `t=gruuv` (keep `t=music` for discoverability if useful). + - Keep a content JSON cache of track metadata (title/artist/cover/duration) like the greyscale version so playlists render before track events resolve. + +### Phase 3 — Upload (new capability) +7. Create [`www/js/gruuv-upload.mjs`](../www/js/gruuv-upload.mjs): + - `calculateSHA256()` (reuse from blossom-api). + - Build `kind 24242` auth via `createBlossomAuth('upload', sha256, 'Upload <filename>')`. + - `PUT <activeBlossomServer>/upload` with the `Authorization: Nostr <base64>` header; fall back to `<server>/<sha256>` for the URL if the response lacks one. + - Optionally read duration via an `<audio>`/`AudioContext` decode client-side; allow manual title/artist/album entry. + - Publish `kind 36787` via `publishEvent()` with the full tag set (including `t=music`, `t=gruuv`, `x`, `m`, `format`, `size`, `alt`). + +### Phase 4 — Wire into music.html +8. Swap imports: replace [`GreyscaleAPI`](../www/music.html:1371), the greyscale playlists import block ([lines ~1375–1380](../www/music.html:1375)) with gruuv modules. Keep `SimplePlayer` (it works with direct URLs); the DASH branch simply goes unused. +9. Update search handlers ([~3117](../www/music.html:3117), combined search [~4516](../www/music.html:4516)) and detail loaders ([~3837](../www/music.html:3837)–[~3963](../www/music.html:3963)) for the gruuv id format and the grouped album/artist results. +10. Update playlist subscription filter and event handling ([`subscribeAllPlaylists`](../www/music.html:3288), [`handleMusicNdkEvent`](../www/music.html:3299), [`hydratePlaylistFromEvent`](../www/music.html:3245)) to `kind 34139` with `t=gruuv` and `a`-tag track refs. +11. Add an **Upload Track** UI section (file input, metadata fields, progress/status), gated behind authentication (`promptLoginIfNeeded`), wired to `gruuv-upload.mjs`. +12. Update the download ([`fetchDownloadBlobForTrack`](../www/music.html:4342)) and share flows to use the direct Blossom URL instead of Tidal stream manifests. + +### Phase 5 — Cleanup & verification +13. Remove greyscale modules ([`greyscale-api.mjs`](../www/js/greyscale-api.mjs), [`greyscale-playlists.mjs`](../www/js/greyscale-playlists.mjs), and [`greyscale-player.mjs`](../www/js/greyscale-player.mjs) if no longer referenced) and any greyscale-only code paths — only after the gruuv path is verified. (Player may be retained/renamed if still used for playback.) +14. Run the build inline-check ([`build/.music-inline-check.mjs`](../build/.music-inline-check.mjs)) and do a relay smoke test (cross-check against [`gruuv/list_gruuv_tracks.sh`](../gruuv/list_gruuv_tracks.sh) output). Verify an upload round-trips: publish `36787`, then it appears in search. +15. Update docs: [`docs/music.md`](../docs/music.md) and [`docs/MUSIC_URL_STRUCTURE.md`](../docs/MUSIC_URL_STRUCTURE.md) to reflect gruuv kinds/IDs and the new upload feature. + +--- + +## Risks & decisions + +- **Relay-native search has no ranking/pagination like a server endpoint.** Mitigation: fetch with a sensible `limit`, cache events, filter client-side, debounce queries. Result completeness depends on relay coverage. +- **Albums/artists are derived, not authoritative.** Grouping by free-text `album`/`artist` tags can produce duplicates/variants. Mitigation: normalize/trim, dedupe like the existing `deduplicateAlbums`. +- **Track addressing change** (`36787:pubkey:dTag` vs numeric ID) affects deep links and URL structure — covered by the `MUSIC_URL_STRUCTURE.md` update. +- **Upload duration/metadata** extraction is best-effort in the browser (no ffprobe); manual fields are the reliable fallback. +- **Default relay/Blossom set**: plan uses the page's existing NDK relays and `getBlossomServers()`; gruuv defaults are documented as fallback. Confirm if a specific relay/Blossom set is required. + +## Open question for confirmation +- Should the deep-link/URL scheme keep numeric-style IDs (with a translation layer) or move fully to `36787:pubkey:dTag` coordinates? diff --git a/www/js/gruuv-api.mjs b/www/js/gruuv-api.mjs new file mode 100644 index 0000000..4ee4d38 --- /dev/null +++ b/www/js/gruuv-api.mjs @@ -0,0 +1,542 @@ +import { ndkFetchEvents, queryCache } from './init-ndk.mjs'; + +const TRACK_KIND = 36787; +const GRUUV_TOPIC = 'gruuv'; +const MUSIC_TOPIC = 'music'; +const DEFAULT_LIMIT = 600; + +function normalizeText(value = '') { + return String(value || '').trim(); +} + +function normalizeLower(value = '') { + return normalizeText(value).toLowerCase(); +} + +function safeNumber(value, fallback = 0) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +function getTagValues(tags, key) { + if (!Array.isArray(tags)) return []; + return tags + .filter((t) => Array.isArray(t) && t[0] === key && typeof t[1] === 'string') + .map((t) => t[1]); +} + +function getTagValue(tags, key) { + const values = getTagValues(tags, key); + return values[0] || ''; +} + +function normalizeSlug(value = '') { + return String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 96); +} + +function parseTrackCoordinate(id = '') { + const raw = String(id || '').trim(); + if (!raw.startsWith(`${TRACK_KIND}:`)) return null; + const parts = raw.split(':'); + if (parts.length < 3) return null; + const kind = Number(parts[0]); + const pubkey = String(parts[1] || '').trim().toLowerCase(); + const dTag = parts.slice(2).join(':').trim(); + if (kind !== TRACK_KIND) return null; + if (!/^[a-f0-9]{64}$/i.test(pubkey) || !dTag) return null; + return { kind, pubkey, dTag }; +} + +function isHexEventId(value = '') { + return /^[a-f0-9]{64}$/i.test(String(value || '').trim()); +} + +function inferFormatFromMime(mime = '') { + const m = String(mime || '').toLowerCase(); + if (m.includes('flac')) return 'flac'; + if (m.includes('mpeg') || m.includes('mp3')) return 'mp3'; + if (m.includes('aac')) return 'aac'; + if (m.includes('ogg')) return 'ogg'; + if (m.includes('wav')) return 'wav'; + return ''; +} + +function hasTopic(tags, topic) { + const lower = normalizeLower(topic); + return getTagValues(tags, 't').some((v) => normalizeLower(v) === lower); +} + +function buildTrackIdFromEvent(event) { + const pubkey = normalizeLower(event?.pubkey || ''); + const dTag = normalizeText(getTagValue(event?.tags, 'd')); + if (pubkey && dTag) return `${TRACK_KIND}:${pubkey}:${dTag}`; + return normalizeText(event?.id || ''); +} + +function parseReleasedYear(released = '') { + const match = String(released || '').match(/(\d{4})/); + return match ? Number(match[1]) : 0; +} + +function sortByRecent(a, b) { + return safeNumber(b?.created_at, 0) - safeNumber(a?.created_at, 0); +} + +function dedupeEvents(events, { requireTopic = true } = {}) { + const byAddress = new Map(); + const byEventId = new Map(); + + for (const event of events || []) { + if (!event || typeof event !== 'object') continue; + if (event.kind !== TRACK_KIND || !Array.isArray(event.tags)) continue; + if (requireTopic && !(hasTopic(event.tags, GRUUV_TOPIC) || hasTopic(event.tags, MUSIC_TOPIC))) continue; + + const eventId = normalizeLower(event.id || ''); + if (eventId && byEventId.has(eventId)) continue; + + const address = buildTrackIdFromEvent(event); + const existing = byAddress.get(address); + if (!existing || safeNumber(event.created_at, 0) >= safeNumber(existing.created_at, 0)) { + byAddress.set(address, event); + if (eventId) byEventId.set(eventId, event); + } + } + + return [...byAddress.values()].sort(sortByRecent); +} + +function eventToNormalizedTrack(event) { + const tags = Array.isArray(event?.tags) ? event.tags : []; + const id = buildTrackIdFromEvent(event); + const title = normalizeText(getTagValue(tags, 'title')) || 'Unknown title'; + const artist = normalizeText(getTagValue(tags, 'artist')) || 'Unknown artist'; + const albumTitle = normalizeText(getTagValue(tags, 'album')); + const duration = safeNumber(getTagValue(tags, 'duration'), 0); + const cover = normalizeText(getTagValue(tags, 'image')); + const streamUrl = normalizeText(getTagValue(tags, 'url')); + const format = normalizeText(getTagValue(tags, 'format')) || inferFormatFromMime(getTagValue(tags, 'm')); + const mime = normalizeText(getTagValue(tags, 'm')); + const sha256 = normalizeText(getTagValue(tags, 'x')); + const released = normalizeText(getTagValue(tags, 'released')); + const trackNumber = normalizeText(getTagValue(tags, 'track_number') || getTagValue(tags, 'track')); + const discNumber = normalizeText(getTagValue(tags, 'disc_number') || getTagValue(tags, 'disc')); + const genre = normalizeText(getTagValue(tags, 'genre')); + + const albumId = albumTitle ? `album:${normalizeSlug(`${artist} ${albumTitle}`)}` : ''; + const artistId = artist ? `artist:${normalizeSlug(artist)}` : ''; + + return { + id, + eventId: normalizeText(event?.id || ''), + pubkey: normalizeText(event?.pubkey || ''), + dTag: normalizeText(getTagValue(tags, 'd')), + title, + artist, + artistId, + duration, + cover, + albumTitle, + albumId, + streamUrl, + format, + mime, + sha256, + size: safeNumber(getTagValue(tags, 'size'), 0), + released, + releaseYear: parseReleasedYear(released), + genre, + trackNumber, + discNumber, + createdAt: safeNumber(event?.created_at, 0), + raw: event, + }; +} + +function shapeSearchResponse(items) { + const rows = Array.isArray(items) ? items : []; + return { + items: rows, + limit: rows.length, + offset: 0, + totalNumberOfItems: rows.length, + }; +} + +function trackMatchesQuery(track, query) { + const q = normalizeLower(query); + if (!q) return true; + + const haystacks = [ + track?.title, + track?.artist, + track?.albumTitle, + track?.id, + track?.eventId, + track?.dTag, + track?.genre, + ] + .map((v) => normalizeLower(v)) + .filter(Boolean); + + return haystacks.some((v) => v.includes(q)); +} + +function groupTracksByAlbum(tracks) { + const byAlbum = new Map(); + + for (const track of tracks || []) { + const title = normalizeText(track?.albumTitle); + if (!title) continue; + + const artist = normalizeText(track?.artist || 'Unknown artist'); + const key = `${normalizeLower(artist)}::${normalizeLower(title)}`; + + if (!byAlbum.has(key)) { + byAlbum.set(key, { + id: track.albumId || `album:${normalizeSlug(`${artist} ${title}`)}`, + title, + cover: normalizeText(track?.cover || ''), + artist: { name: artist, id: track.artistId || `artist:${normalizeSlug(artist)}` }, + artistName: artist, + releaseDate: track.released || '', + numberOfTracks: 0, + explicit: false, + type: 'ALBUM', + tracks: [], + }); + } + + const album = byAlbum.get(key); + album.tracks.push(track); + album.numberOfTracks = album.tracks.length; + if (!album.cover && track.cover) album.cover = track.cover; + if (!album.releaseDate && track.released) album.releaseDate = track.released; + } + + return [...byAlbum.values()].sort((a, b) => { + const yearDiff = parseReleasedYear(b.releaseDate) - parseReleasedYear(a.releaseDate); + if (yearDiff !== 0) return yearDiff; + return a.title.localeCompare(b.title); + }); +} + +function groupTracksByArtist(tracks) { + const byArtist = new Map(); + + for (const track of tracks || []) { + const name = normalizeText(track?.artist); + if (!name) continue; + + const key = normalizeLower(name); + if (!byArtist.has(key)) { + byArtist.set(key, { + id: track.artistId || `artist:${normalizeSlug(name)}`, + name, + picture: normalizeText(track?.cover || ''), + tracks: [], + }); + } + + const artist = byArtist.get(key); + artist.tracks.push(track); + if (!artist.picture && track.cover) artist.picture = track.cover; + } + + return [...byArtist.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +async function safeFetchWithFilter(filters, options = {}) { + const [cacheResult, relayResult] = await Promise.allSettled([ + queryCache(filters), + ndkFetchEvents(filters), + ]); + + const cacheEvents = cacheResult.status === 'fulfilled' && Array.isArray(cacheResult.value) + ? cacheResult.value + : []; + const relayEvents = relayResult.status === 'fulfilled' && Array.isArray(relayResult.value) + ? relayResult.value + : []; + + return dedupeEvents([...cacheEvents, ...relayEvents], options); +} + +export class GruuvAPI { + constructor(options = {}) { + this.options = { + limit: Number.isFinite(options.limit) ? Number(options.limit) : DEFAULT_LIMIT, + ...options, + }; + this.trackCache = new Map(); + this.lastTrackSnapshot = []; + } + + async initInstances() { + return { + api: ['nostr:kind36787'], + streaming: ['blossom:url-tag'], + }; + } + + cacheTracks(tracks) { + const rows = Array.isArray(tracks) ? tracks : []; + for (const track of rows) { + if (!track?.id) continue; + this.trackCache.set(String(track.id), track); + if (track.eventId) this.trackCache.set(String(track.eventId), track); + } + this.lastTrackSnapshot = rows; + } + + async fetchTrackEvents(filters = {}) { + const mergedFilter = { + kinds: [TRACK_KIND], + '#t': [GRUUV_TOPIC], + limit: this.options.limit, + ...filters, + }; + + const events = await safeFetchWithFilter(mergedFilter); + const tracks = events.map(eventToNormalizedTrack).filter((track) => track.streamUrl); + this.cacheTracks(tracks); + return tracks; + } + + prepareTrack(track) { + if (!track || typeof track !== 'object') return null; + + if (track.raw && Array.isArray(track.raw.tags)) { + return eventToNormalizedTrack(track.raw); + } + + const normalized = { + id: normalizeText(track.id || ''), + title: normalizeText(track.title || '') || 'Unknown title', + artist: normalizeText(track.artist || '') || 'Unknown artist', + duration: safeNumber(track.duration, 0), + cover: normalizeText(track.cover || ''), + albumTitle: normalizeText(track.albumTitle || ''), + raw: track.raw || track, + }; + + if (!normalized.id && track.raw?.pubkey && Array.isArray(track.raw?.tags)) { + normalized.id = buildTrackIdFromEvent(track.raw); + } + + return normalized; + } + + getCoverUrl(coverId) { + return normalizeText(coverId); + } + + getArtistPictureUrl(id) { + return normalizeText(id); + } + + async searchTracks(query) { + const q = normalizeText(query); + if (!q) return shapeSearchResponse([]); + + const asCoordinate = parseTrackCoordinate(q); + if (asCoordinate) { + const tracks = await this.fetchTrackEvents({ + authors: [asCoordinate.pubkey], + '#d': [asCoordinate.dTag], + limit: 20, + }); + return shapeSearchResponse(tracks.filter((t) => String(t.id) === q)); + } + + const tracks = await this.fetchTrackEvents(); + const filtered = tracks.filter((track) => trackMatchesQuery(track, q)); + return shapeSearchResponse(filtered); + } + + async searchAlbums(query) { + const q = normalizeText(query); + if (!q) return shapeSearchResponse([]); + + const tracks = await this.fetchTrackEvents(); + const albums = groupTracksByAlbum(tracks).filter((album) => { + const haystack = `${album.title} ${album.artistName}`.toLowerCase(); + return haystack.includes(normalizeLower(q)); + }); + + return shapeSearchResponse(albums); + } + + async searchArtists(query) { + const q = normalizeText(query); + if (!q) return shapeSearchResponse([]); + + const tracks = await this.fetchTrackEvents(); + const artists = groupTracksByArtist(tracks).filter((artist) => { + const haystack = `${artist.name}`.toLowerCase(); + return haystack.includes(normalizeLower(q)); + }); + + return shapeSearchResponse(artists); + } + + async getAlbum(id) { + const albumId = normalizeText(id); + if (!albumId) throw new Error('Album id is required'); + + const tracks = await this.fetchTrackEvents(); + const albums = groupTracksByAlbum(tracks); + const album = albums.find((a) => String(a.id) === albumId); + + if (!album) throw new Error('Album not found'); + + const sortedTracks = [...album.tracks].sort((a, b) => { + const aDisc = safeNumber(a.discNumber, 1); + const bDisc = safeNumber(b.discNumber, 1); + if (aDisc !== bDisc) return aDisc - bDisc; + const aTrack = safeNumber(a.trackNumber, Number.MAX_SAFE_INTEGER); + const bTrack = safeNumber(b.trackNumber, Number.MAX_SAFE_INTEGER); + if (aTrack !== bTrack) return aTrack - bTrack; + return a.title.localeCompare(b.title); + }); + + return { album: { ...album, tracks: undefined }, tracks: sortedTracks }; + } + + async getArtist(artistId) { + const id = normalizeText(artistId); + if (!id) throw new Error('Artist id is required'); + + const tracks = await this.fetchTrackEvents(); + const artists = groupTracksByArtist(tracks); + const artist = artists.find((a) => String(a.id) === id); + + if (!artist) throw new Error('Artist not found'); + + const artistTracks = [...artist.tracks].sort((a, b) => { + const yearDiff = safeNumber(b.releaseYear, 0) - safeNumber(a.releaseYear, 0); + if (yearDiff !== 0) return yearDiff; + return a.title.localeCompare(b.title); + }); + + const albums = groupTracksByAlbum(artistTracks) + .map((album) => ({ ...album, tracks: undefined })) + .filter((album) => normalizeLower(album.artistName) === normalizeLower(artist.name)); + + return { + id: artist.id, + name: artist.name, + picture: artist.picture, + albums, + eps: [], + tracks: artistTracks.slice(0, 20), + }; + } + + async getTracksByAddresses(addresses = []) { + const input = Array.isArray(addresses) ? addresses : []; + const normalizedAddresses = [...new Set( + input + .map((value) => parseTrackCoordinate(value)) + .filter(Boolean) + .map((coord) => `${TRACK_KIND}:${coord.pubkey}:${coord.dTag}`) + )]; + + console.log('[gruuv-api] getTracksByAddresses:start', { + requested: input.length, + normalized: normalizedAddresses.length, + normalizedAddresses, + }); + + const byAddress = new Map(); + const unresolvedByAuthor = new Map(); + + for (const address of normalizedAddresses) { + const cached = this.trackCache.get(address); + if (cached?.streamUrl) { + byAddress.set(address, cached); + continue; + } + const coord = parseTrackCoordinate(address); + if (!coord) continue; + const set = unresolvedByAuthor.get(coord.pubkey) || new Set(); + set.add(coord.dTag); + unresolvedByAuthor.set(coord.pubkey, set); + } + + console.log('[gruuv-api] getTracksByAddresses:cache', { + cacheHits: byAddress.size, + unresolvedAuthors: unresolvedByAuthor.size, + }); + + for (const [author, dTagsSet] of unresolvedByAuthor.entries()) { + const dTags = [...dTagsSet].filter(Boolean); + if (!dTags.length) continue; + + const filter = { + kinds: [TRACK_KIND], + authors: [author], + '#d': dTags, + limit: Math.max(120, dTags.length * 3), + }; + + console.log('[gruuv-api] getTracksByAddresses:fetch', { + author, + dTags, + filter, + }); + + const events = await safeFetchWithFilter(filter, { requireTopic: false }); + console.log('[gruuv-api] getTracksByAddresses:events', { + author, + dTagsRequested: dTags.length, + eventsFetched: events.length, + eventIds: events.map((event) => event?.id).filter(Boolean), + }); + + const tracks = events + .map(eventToNormalizedTrack) + .filter((track) => track.streamUrl); + + this.cacheTracks(tracks); + for (const track of tracks) { + if (!track?.id) continue; + byAddress.set(String(track.id), track); + } + } + + console.log('[gruuv-api] getTracksByAddresses:done', { + resolved: byAddress.size, + resolvedIds: [...byAddress.keys()], + }); + + return byAddress; + } + + async getTrackStream(id) { + const target = normalizeText(id); + if (!target) throw new Error('Track id is required'); + + let track = this.trackCache.get(target) || null; + + if (!track && parseTrackCoordinate(target)) { + const lookup = await this.searchTracks(target); + const items = Array.isArray(lookup?.items) ? lookup.items : []; + track = items.find((item) => String(item?.id) === target) || items[0] || null; + } + + if (!track && isHexEventId(target)) { + const tracks = await this.fetchTrackEvents({ ids: [target], limit: 20 }); + track = tracks.find((item) => String(item.eventId) === target) || null; + } + + if (!track) throw new Error('Track not found'); + if (!track.streamUrl) throw new Error('Track has no stream URL'); + + return { streamUrl: track.streamUrl, isDash: false }; + } +} diff --git a/www/js/gruuv-playlists.mjs b/www/js/gruuv-playlists.mjs new file mode 100644 index 0000000..bb350cb --- /dev/null +++ b/www/js/gruuv-playlists.mjs @@ -0,0 +1,199 @@ +const PLAYLIST_KIND = 34139; +const TRACK_KIND = 36787; + +function getTagValue(tags, key) { + if (!Array.isArray(tags)) return ''; + const tag = tags.find((t) => Array.isArray(t) && t[0] === key && typeof t[1] === 'string'); + return tag?.[1] || ''; +} + +function getTagValues(tags, key) { + if (!Array.isArray(tags)) return []; + return tags + .filter((t) => Array.isArray(t) && t[0] === key && typeof t[1] === 'string') + .map((t) => t[1]); +} + +function normalizeSlug(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64); +} + +function safeJsonParse(value, fallback) { + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function parseTrackAddress(address = '') { + const raw = String(address || '').trim(); + const parts = raw.split(':'); + if (parts.length < 3) return null; + if (Number(parts[0]) !== TRACK_KIND) return null; + const pubkey = String(parts[1] || '').trim().toLowerCase(); + const dTag = parts.slice(2).join(':').trim(); + if (!/^[a-f0-9]{64}$/i.test(pubkey) || !dTag) return null; + return { + kind: TRACK_KIND, + pubkey, + dTag, + address: `${TRACK_KIND}:${pubkey}:${dTag}`, + }; +} + +function buildTrackTag(track) { + const id = String(track?.id || '').trim(); + const parsed = parseTrackAddress(id); + if (parsed) return ['a', parsed.address]; + + const rawPubkey = String(track?.raw?.pubkey || track?.pubkey || '').trim().toLowerCase(); + const dTag = String( + track?.dTag + || getTagValue(track?.raw?.tags, 'd') + || track?.identifier + || '' + ).trim(); + + if (/^[a-f0-9]{64}$/i.test(rawPubkey) && dTag) { + return ['a', `${TRACK_KIND}:${rawPubkey}:${dTag}`]; + } + + return null; +} + +function buildTrackCache(track) { + const id = String(track?.id || '').trim(); + if (!id) return null; + + return { + id, + title: typeof track?.title === 'string' ? track.title : '', + artist: typeof track?.artist === 'string' ? track.artist : '', + duration: Number.isFinite(track?.duration) ? Number(track.duration) : 0, + cover: typeof track?.cover === 'string' ? track.cover : '', + albumTitle: typeof track?.albumTitle === 'string' ? track.albumTitle : '', + streamUrl: typeof track?.streamUrl === 'string' ? track.streamUrl : '', + }; +} + +export function createPlaylistIdentifier(title = '') { + const slug = normalizeSlug(title); + if (!slug) return `${Date.now()}`; + return `${slug}-${Date.now()}`; +} + +export function isMusicPlaylistEvent(event) { + if (!event || event.kind !== PLAYLIST_KIND || !Array.isArray(event.tags)) return false; + + const topics = getTagValues(event.tags, 't').map((t) => t.toLowerCase()); + if (topics.includes('gruuv') || topics.includes('music') || topics.includes('playlist')) { + return true; + } + + const hasIdentifier = Boolean(getTagValue(event.tags, 'd')); + const hasTitle = Boolean(getTagValue(event.tags, 'title')); + const hasTrackRefs = getTagValues(event.tags, 'a').some((value) => Boolean(parseTrackAddress(value))); + + return hasIdentifier && (hasTitle || hasTrackRefs); +} + +export function buildPlaylistEvent(playlist, createdAt = Math.floor(Date.now() / 1000)) { + const identifier = playlist?.identifier || playlist?.id || createPlaylistIdentifier(playlist?.title || ''); + const title = typeof playlist?.title === 'string' ? playlist.title.trim() : ''; + const description = typeof playlist?.description === 'string' ? playlist.description.trim() : ''; + const image = typeof playlist?.image === 'string' ? playlist.image.trim() : ''; + const banner = typeof playlist?.banner === 'string' ? playlist.banner.trim() : ''; + + const inputTracks = Array.isArray(playlist?.tracks) ? playlist.tracks : []; + const cacheTracks = inputTracks.map(buildTrackCache).filter(Boolean); + + const seen = new Set(); + const trackTags = []; + + for (const track of inputTracks) { + const tag = buildTrackTag(track); + if (!tag) continue; + const value = tag[1]; + if (seen.has(value)) continue; + seen.add(value); + trackTags.push(tag); + } + + const tags = [ + ['d', identifier], + ['title', title || 'Untitled playlist'], + ['alt', `Playlist: ${title || 'Untitled playlist'}`], + ['t', 'gruuv'], + ['t', 'music'], + ['t', 'playlist'], + ]; + + if (description) tags.push(['description', description]); + if (image) tags.push(['image', image]); + if (banner) tags.push(['banner', banner]); + tags.push(...trackTags); + + return { + created_at: createdAt, + kind: PLAYLIST_KIND, + tags, + content: JSON.stringify({ tracks: cacheTracks }), + }; +} + +export function parsePlaylistEvent(event) { + if (!event || event.kind !== PLAYLIST_KIND) return null; + if (!Array.isArray(event.tags)) return null; + + const identifier = getTagValue(event.tags, 'd') || ''; + const title = getTagValue(event.tags, 'title') || 'Untitled playlist'; + const description = getTagValue(event.tags, 'description') || ''; + const image = getTagValue(event.tags, 'image') || ''; + const banner = getTagValue(event.tags, 'banner') || ''; + + const aValues = getTagValues(event.tags, 'a'); + const trackIdsFromTags = aValues + .map((v) => parseTrackAddress(v)?.address || '') + .filter(Boolean); + + const parsedContent = safeJsonParse(event.content || '', {}); + const cacheTracks = Array.isArray(parsedContent?.tracks) + ? parsedContent.tracks.map(buildTrackCache).filter(Boolean) + : []; + + const cachedById = new Map(cacheTracks.map((t) => [String(t.id), t])); + const trackIds = [...new Set(trackIdsFromTags)]; + + const tracks = trackIds.map((id) => { + const cached = cachedById.get(id); + if (cached) return cached; + return { id, title: '', artist: '', duration: 0, cover: '', albumTitle: '', streamUrl: '' }; + }); + + return { + eventId: event.id || '', + pubkey: event.pubkey || '', + createdAt: Number(event.created_at) || 0, + identifier, + title, + description, + image, + banner, + tracks, + trackIds, + raw: event, + }; +} + +export function playlistEventAddress(pubkey, identifier) { + if (!pubkey || !identifier) return ''; + return `${PLAYLIST_KIND}:${pubkey}:${identifier}`; +} + +export { PLAYLIST_KIND, parseTrackAddress, buildTrackTag }; diff --git a/www/js/gruuv-upload.mjs b/www/js/gruuv-upload.mjs new file mode 100644 index 0000000..4daa9d1 --- /dev/null +++ b/www/js/gruuv-upload.mjs @@ -0,0 +1,204 @@ +import { publishEvent, getPubkey } from './init-ndk.mjs'; +import { calculateSHA256, createBlossomAuth } from './blossom-api.mjs'; +import { getBlossomServers } from './blossom-ui.mjs'; + +const TRACK_KIND = 36787; + +function normalizeText(value = '') { + return String(value || '').trim(); +} + +function extensionFromName(name = '') { + const base = String(name || '').trim(); + const idx = base.lastIndexOf('.'); + if (idx < 0) return ''; + return base.slice(idx + 1).toLowerCase(); +} + +function inferFormat(file) { + const ext = extensionFromName(file?.name || ''); + if (ext) return ext; + + const mime = normalizeText(file?.type || '').toLowerCase(); + if (mime.includes('flac')) return 'flac'; + if (mime.includes('mpeg') || mime.includes('mp3')) return 'mp3'; + if (mime.includes('aac')) return 'aac'; + if (mime.includes('ogg')) return 'ogg'; + if (mime.includes('wav')) return 'wav'; + return 'bin'; +} + +function inferMime(file) { + const mime = normalizeText(file?.type || ''); + if (mime) return mime; + + const format = inferFormat(file); + if (format === 'mp3') return 'audio/mpeg'; + if (format === 'flac') return 'audio/flac'; + if (format === 'ogg') return 'audio/ogg'; + if (format === 'aac') return 'audio/aac'; + if (format === 'wav') return 'audio/wav'; + return 'application/octet-stream'; +} + +function selectUploadServer() { + const servers = getBlossomServers(); + const healthyUpload = servers.find((s) => s?.healthy && s?.upload); + if (healthyUpload?.url) return String(healthyUpload.url).trim().replace(/\/$/, ''); + + const anyUpload = servers.find((s) => s?.upload); + if (anyUpload?.url) return String(anyUpload.url).trim().replace(/\/$/, ''); + + const healthyAny = servers.find((s) => s?.healthy); + if (healthyAny?.url) return String(healthyAny.url).trim().replace(/\/$/, ''); + + if (servers[0]?.url) return String(servers[0].url).trim().replace(/\/$/, ''); + + return ''; +} + +async function getAudioDurationSeconds(file) { + if (!(file instanceof File || file instanceof Blob)) return 0; + + const objectUrl = URL.createObjectURL(file); + try { + const duration = await new Promise((resolve) => { + const audio = document.createElement('audio'); + const cleanup = () => { + audio.removeAttribute('src'); + audio.load(); + }; + + audio.preload = 'metadata'; + audio.onloadedmetadata = () => { + const d = Number(audio.duration); + cleanup(); + resolve(Number.isFinite(d) && d > 0 ? d : 0); + }; + audio.onerror = () => { + cleanup(); + resolve(0); + }; + audio.src = objectUrl; + }); + + return Math.round(duration); + } finally { + URL.revokeObjectURL(objectUrl); + } +} + +function makeDTag({ title, artist, sha256 }) { + const safeTitle = normalizeText(title) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48); + const safeArtist = normalizeText(artist) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 32); + + const prefix = [safeArtist, safeTitle].filter(Boolean).join('-') || 'track'; + return `${prefix}-${String(sha256 || '').slice(0, 8)}-${Date.now()}`; +} + +async function uploadFileToBlossom(file, serverUrl, sha256) { + const authToken = await createBlossomAuth('upload', sha256, `Upload ${file.name || 'audio'}`); + const res = await fetch(`${serverUrl}/upload`, { + method: 'PUT', + mode: 'cors', + cache: 'no-cache', + headers: { + 'Content-Type': inferMime(file), + Authorization: `Nostr ${authToken}`, + }, + body: file, + }); + + if (!res.ok) { + throw new Error(`Upload failed: ${res.status} ${res.statusText}`); + } + + const payload = await res.json().catch(() => ({})); + const url = normalizeText(payload?.url || payload?.blob?.url || ''); + return { + uploadUrl: url || `${serverUrl}/${sha256}`, + raw: payload, + }; +} + +export async function uploadGruuvTrack(file, metadata = {}, options = {}) { + if (!file) throw new Error('No file selected'); + + const pubkey = await getPubkey(); + if (!pubkey) throw new Error('Not authenticated'); + + const serverUrl = normalizeText(options.serverUrl || selectUploadServer()); + if (!serverUrl) throw new Error('No Blossom server available for upload'); + + const sha256 = await calculateSHA256(file); + const uploadResult = await uploadFileToBlossom(file, serverUrl, sha256); + + const title = normalizeText(metadata.title || file.name?.replace(/\.[^.]+$/, '') || 'Untitled'); + const artist = normalizeText(metadata.artist || 'Unknown artist'); + const album = normalizeText(metadata.album || 'Uploads'); + const image = normalizeText(metadata.image || ''); + const genre = normalizeText(metadata.genre || ''); + const released = normalizeText(metadata.released || ''); + const trackNumber = normalizeText(metadata.trackNumber || ''); + const discNumber = normalizeText(metadata.discNumber || ''); + + const duration = Number.isFinite(metadata.duration) + ? Number(metadata.duration) + : await getAudioDurationSeconds(file); + + const format = inferFormat(file); + const mime = inferMime(file); + const dTag = normalizeText(metadata.dTag || makeDTag({ title, artist, sha256 })); + + const tags = [ + ['d', dTag], + ['url', uploadResult.uploadUrl], + ['title', title], + ['artist', artist], + ['album', album], + ['duration', String(Math.max(0, Math.round(duration || 0)))], + ['size', String(file.size || 0)], + ['alt', `Song: ${title} - ${artist}`], + ['format', format], + ['m', mime], + ['x', sha256], + ['t', 'music'], + ['t', 'gruuv'], + ]; + + if (image) tags.push(['image', image]); + if (genre) { + tags.push(['genre', genre]); + tags.push(['t', genre.toLowerCase()]); + } + if (released) tags.push(['released', released]); + if (trackNumber) tags.push(['track_number', trackNumber]); + if (discNumber && discNumber !== '1') tags.push(['disc_number', discNumber]); + + const event = { + created_at: Math.floor(Date.now() / 1000), + kind: TRACK_KIND, + tags, + content: `Listen to my song - ${title} by ${artist}`, + }; + + const publishResult = await publishEvent(event); + + return { + file, + pubkey, + sha256, + url: uploadResult.uploadUrl, + dTag, + event, + publishResult, + }; +} diff --git a/www/js/version.json b/www/js/version.json index bad4cb2..1f5c21d 100644 --- a/www/js/version.json +++ b/www/js/version.json @@ -1,5 +1,5 @@ { - "VERSION": "v0.7.16", - "VERSION_NUMBER": "0.7.16", - "BUILD_DATE": "2026-05-29T14:58:32.174Z" + "VERSION": "v0.7.17", + "VERSION_NUMBER": "0.7.17", + "BUILD_DATE": "2026-06-09T19:15:08.620Z" } diff --git a/www/music-greyscale.html b/www/music-greyscale.html new file mode 100644 index 0000000..57943d8 --- /dev/null +++ b/www/music-greyscale.html @@ -0,0 +1,5600 @@ +<!DOCTYPE html> +<?xml version="1.0" encoding="UTF-8"?> +<html lang="en" dir="ltr"> + +<head> + <meta charset="utf-8" /> + <title>Music + + + + + + + + + + + + + + + + + + +
+ +
+ + +
+
+ +
+ +
+
+
+ +
+ +
+
+ + +
+
+ + + +
+ + +
+
+ + +
+
+ + + +

Ready.

+
+
+ +
+
    +
    +
    + + + + + +
    +
    + + + + +
    +
    +
    +
    +
    0 sats
    +
    + + +
    +
    + +
    + +
    +
    +
    + +
    +
    AI
    +
    +
    No saved providers yet.
    +
    +
    + + +
    +
    + リレー +
    +
    + Loading relays... +
    +
    + +
    + +
    ブロッサム
    + +
    Loading blossom servers...
    + +
    + + +
    + v0.0.1 +
    + + +
    +
    +
    + + + + + + + + + diff --git a/www/music.html b/www/music.html index 57943d8..5c5d717 100644 --- a/www/music.html +++ b/www/music.html @@ -221,6 +221,38 @@ margin: 4px 0 2px; } + #musicUploadSection { + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + padding: 8px; + display: flex; + flex-direction: column; + gap: 6px; + background: rgba(255, 255, 255, 0.02); + } + + #musicUploadSection input, + #musicUploadSection button { + width: 100%; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + padding: 8px; + font-size: 12px; + background: var(--secondary-color); + color: var(--primary-color); + outline: none; + } + + #musicUploadSection button { + cursor: pointer; + } + + #musicUploadStatus { + font-size: 11px; + color: var(--muted-color); + min-height: 16px; + } + #musicPlaylistProfile { border: var(--border); border-radius: var(--border-radius); @@ -1136,7 +1168,10 @@
    MY PLAYLISTS
    -
    FRIENDS
    +
    + FRIENDS + +
    AI IMPORT
    @@ -1152,6 +1187,16 @@
    + +
    UPLOAD TRACK
    +
    + + + + + +
    Sign in to upload.
    +
    @@ -1368,16 +1413,17 @@ import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs'; import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs'; import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs'; -import { GreyscaleAPI } from './js/greyscale-api.mjs'; + import { GruuvAPI } from './js/gruuv-api.mjs'; import { SimplePlayer } from './js/greyscale-player.mjs'; import { sendAiChatJson } from './js/ai-chat.mjs'; + import { uploadGruuvTrack } from './js/gruuv-upload.mjs'; import { PLAYLIST_KIND, buildPlaylistEvent, createPlaylistIdentifier, parsePlaylistEvent, isMusicPlaylistEvent, - } from './js/greyscale-playlists.mjs'; + } from './js/gruuv-playlists.mjs'; import { mountDotMenu } from './js/dot-menu.mjs'; import { initResizableColumns } from './js/resizable-columns.mjs'; import { @@ -1466,6 +1512,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs'; playlistDescriptionInput: document.getElementById('musicPlaylistDescriptionInput'), myPlaylistsList: document.getElementById('musicMyPlaylistsList'), friendPlaylistsList: document.getElementById('musicFriendPlaylistsList'), + loadFriendsBtn: document.getElementById('musicLoadFriendsBtn'), importDropZone: document.getElementById('musicImportDropZone'), importInput: document.getElementById('musicImportInput'), importProcessing: document.getElementById('musicImportProcessing'), @@ -1483,9 +1530,15 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs'; viewerPubkeyInput: document.getElementById('musicViewerPubkeyInput'), viewerLoadBtn: document.getElementById('musicViewerLoadBtn'), viewerInfo: document.getElementById('musicViewerInfo'), + uploadFileInput: document.getElementById('musicUploadFile'), + uploadTitleInput: document.getElementById('musicUploadTitle'), + uploadArtistInput: document.getElementById('musicUploadArtist'), + uploadAlbumInput: document.getElementById('musicUploadAlbum'), + uploadBtn: document.getElementById('musicUploadBtn'), + uploadStatus: document.getElementById('musicUploadStatus'), }; - const musicApi = new GreyscaleAPI(); + const musicApi = new GruuvAPI(); const musicPlayer = new SimplePlayer({ audio: musicEls.audio, progressEl: musicEls.progress, @@ -1502,6 +1555,8 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs'; const myPlaylists = new Map(); const friendPlaylists = new Map(); + let friendsFeedEnabled = false; + let playlistSubscription = null; let musicQueue = []; let queueCurrentIndex = -1; let musicResultsMode = 'search'; @@ -1534,6 +1589,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs'; let pendingShareUrl = ''; let pendingShareArtUrl = ''; let pendingShareLabel = ''; + let isGruuvUploadRunning = false; const BLANK_IMAGE_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='; const MUSIC_COLUMN_STORAGE_KEY = 'music-column-widths:v1'; @@ -1605,6 +1661,76 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs'; musicEls.status.dataset.type = type; } + function setUploadStatus(text) { + if (!musicEls.uploadStatus) return; + musicEls.uploadStatus.textContent = String(text || '').trim(); + } + + function updateUploadUiState() { + const canUpload = Boolean(isAuthenticated && currentPubkey); + if (musicEls.uploadBtn) { + musicEls.uploadBtn.disabled = !canUpload || isGruuvUploadRunning; + } + if (musicEls.uploadFileInput) { + musicEls.uploadFileInput.disabled = isGruuvUploadRunning; + } + if (musicEls.uploadTitleInput) { + musicEls.uploadTitleInput.disabled = isGruuvUploadRunning; + } + if (musicEls.uploadArtistInput) { + musicEls.uploadArtistInput.disabled = isGruuvUploadRunning; + } + if (musicEls.uploadAlbumInput) { + musicEls.uploadAlbumInput.disabled = isGruuvUploadRunning; + } + + if (!canUpload && !isGruuvUploadRunning) { + setUploadStatus('Sign in to upload.'); + } + } + + async function handleGruuvUploadClick() { + if (isGruuvUploadRunning) return; + + try { + await promptLoginIfNeeded(); + } catch (error) { + setUploadStatus(`Login required: ${error?.message || 'Unknown error'}`); + return; + } + + const file = musicEls.uploadFileInput?.files?.[0]; + if (!file) { + setUploadStatus('Choose an audio file first.'); + return; + } + + isGruuvUploadRunning = true; + updateUploadUiState(); + setUploadStatus(`Uploading ${file.name}...`); + + try { + const result = await uploadGruuvTrack(file, { + title: musicEls.uploadTitleInput?.value?.trim() || '', + artist: musicEls.uploadArtistInput?.value?.trim() || '', + album: musicEls.uploadAlbumInput?.value?.trim() || '', + }); + + const title = result?.event?.tags?.find((t) => t?.[0] === 'title')?.[1] || file.name; + setUploadStatus(`Uploaded: ${title}`); + setMusicStatus(`Uploaded track to Gruuv: ${title}`, 'ok'); + + if (musicEls.uploadFileInput) musicEls.uploadFileInput.value = ''; + } catch (error) { + console.error('[music upload] upload failed', error); + setUploadStatus(`Upload failed: ${error?.message || 'Unknown error'}`); + setMusicStatus(`Upload failed: ${error?.message || 'Unknown error'}`, 'error'); + } finally { + isGruuvUploadRunning = false; + updateUploadUiState(); + } + } + function resolveAuthModeFromUrl() { const explicitAuth = getExplicitAuthModeFromUrl(); if (explicitAuth) return explicitAuth; @@ -1687,6 +1813,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs'; if (musicEls.playlistNameInput) { musicEls.playlistNameInput.disabled = isReadOnlyTargetMode(); } + updateUploadUiState(); } async function initializeAuthentication(mode) { @@ -1743,6 +1870,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs'; await initializeAuthenticatedPageFeatures(); syncLoggedInNpubToUrl(); updateViewerControls(); + updateUploadUiState(); return true; } @@ -1839,7 +1967,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs'; function normalizeQueueTrack(track) { if (!track || typeof track !== 'object') return null; return { - id: track.id, + id: String(track.id || ''), title: track.title || 'Unknown title', artist: track.artist || 'Unknown artist', duration: track.duration || 0, @@ -1847,6 +1975,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs'; albumTitle: track.albumTitle || '', albumId: track.albumId || track.album?.id || track.raw?.album?.id || '', artistId: track.artistId || track.artist?.id || track.raw?.artist?.id || track.raw?.artists?.[0]?.id || '', + streamUrl: track.streamUrl || track.raw?.streamUrl || '', }; } @@ -2405,6 +2534,14 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs'; } function renderFriendPlaylists() { + if (!friendsFeedEnabled && !isExternalTargetMode()) { + destroyMountedMenuInstances(mountedMusicMenus.friendPlaylists); + if (musicEls.loadFriendsBtn) musicEls.loadFriendsBtn.textContent = 'Load'; + musicEls.friendPlaylistsList.innerHTML = '
  • Friend playlists are paused. Click Load to fetch.
  • '; + return; + } + + if (musicEls.loadFriendsBtn) musicEls.loadFriendsBtn.textContent = 'Loaded'; const items = Array.from(friendPlaylists.values()).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); if (!items.length) { destroyMountedMenuInstances(mountedMusicMenus.friendPlaylists); @@ -2712,14 +2849,20 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs'; return false; } - const exists = (playlist.tracks || []).some((item) => Number(item?.id) === Number(track?.id)); + const trackId = String(track?.id || '').trim(); + if (!trackId) { + if (!silent) setMusicStatus('Track is missing an id.', 'error'); + return false; + } + + const exists = (playlist.tracks || []).some((item) => String(item?.id || '').trim() === trackId); if (exists) { if (!silent) setMusicStatus('Track already in playlist.', 'neutral'); return false; } playlist.tracks.push({ - id: track.id, + id: trackId, title: track.title || '', artist: track.artist || '', duration: track.duration || 0, @@ -3177,13 +3320,13 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs'; return; } - const existingIds = new Set((playlist.tracks || []).map((track) => Number(track?.id)).filter((id) => Number.isFinite(id))); + const existingIds = new Set((playlist.tracks || []).map((track) => String(track?.id || '').trim()).filter(Boolean)); let added = 0; let skipped = 0; for (const track of importMatchedTracks) { - const id = Number(track?.id); - if (!Number.isFinite(id) || existingIds.has(id)) { + const id = String(track?.id || '').trim(); + if (!id || existingIds.has(id)) { skipped += 1; continue; } @@ -3242,55 +3385,192 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs'; clearPlaylistDropIndicator(); } - function hydratePlaylistFromEvent(event, isFriend = false) { + async function resolveTracksByAddresses(addresses = []) { + const ids = Array.isArray(addresses) ? addresses.filter(Boolean) : []; + if (!ids.length) return new Map(); + + if (musicApi && typeof musicApi.getTracksByAddresses === 'function') { + return musicApi.getTracksByAddresses(ids); + } + + console.warn('[music] Falling back to coordinate search: musicApi.getTracksByAddresses() unavailable'); + const map = new Map(); + for (const id of ids) { + try { + const result = await musicApi.searchTracks(id); + const items = Array.isArray(result?.items) ? result.items : []; + const match = items.find((item) => String(item?.id || '').trim() === id) || items[0] || null; + if (match) { + const prepared = musicApi.prepareTrack(match) || match; + map.set(id, prepared); + } + } catch (error) { + console.warn('[music] resolveTracksByAddresses fallback search failed', { id, error }); + } + } + return map; + } + + async function hydratePlaylistFromEvent(event, isFriend = false) { const parsed = parsePlaylistEvent(event); if (!parsed || !isMusicPlaylistEvent(event)) return; - if (isFriend) { - const key = getFriendPlaylistRouteKey(parsed); - if (key) friendPlaylists.set(key, parsed); - renderFriendPlaylists(); - } else { - const existing = myPlaylists.get(parsed.identifier); - myPlaylists.set(parsed.identifier, { - identifier: parsed.identifier, - title: parsed.title, - description: parsed.description, - image: parsed.image, - banner: parsed.banner || '', - tracks: parsed.tracks, - pubkey: parsed.pubkey, - eventId: parsed.eventId, - createdAt: parsed.createdAt, - raw: parsed.raw, - ...(existing || {}), - }); - savePlaylistsLocal(); - renderMyPlaylists(); - } + const commitPlaylist = (playlist, expectedEventId = '') => { + const shouldSkipAsStale = (existing) => { + if (!existing) return false; + const existingCreatedAt = Number(existing?.createdAt || 0); + const incomingCreatedAt = Number(playlist?.createdAt || 0); + const existingEventId = String(existing?.eventId || '').trim(); + const incomingEventId = String(playlist?.eventId || '').trim(); + + if ( + expectedEventId + && existingEventId + && existingEventId !== expectedEventId + && existingCreatedAt >= incomingCreatedAt + ) { + return true; + } + + if ( + !expectedEventId + && existingEventId + && incomingEventId + && existingEventId !== incomingEventId + && existingCreatedAt > incomingCreatedAt + ) { + return true; + } + + return false; + }; - const urlEpisode = String(getEpisodeFromUrl() || '').trim(); - if (urlEpisode && parsed.identifier === urlEpisode && !selectedPlaylistId) { if (isFriend) { - const key = getFriendPlaylistRouteKey(parsed); - if (key) openFriendPlaylistByKey(key); + const key = getFriendPlaylistRouteKey(playlist); + if (!key) return; + const existing = friendPlaylists.get(key) || null; + if (shouldSkipAsStale(existing)) return; + friendPlaylists.set(key, playlist); + renderFriendPlaylists(); } else { - openMyPlaylistByIdentifier(parsed.identifier); - } - } + const existing = myPlaylists.get(playlist.identifier) || null; + if (shouldSkipAsStale(existing)) return; - if (activePlaylistView?.scope && activePlaylistView?.playlist?.identifier === parsed.identifier) { - activePlaylistView = { ...activePlaylistView, playlist: parsed }; - renderPlaylistProfileCard(); + myPlaylists.set(playlist.identifier, { + ...(existing || {}), + identifier: playlist.identifier, + title: playlist.title, + description: playlist.description, + image: playlist.image, + banner: playlist.banner || '', + tracks: playlist.tracks, + pubkey: playlist.pubkey, + eventId: playlist.eventId, + createdAt: playlist.createdAt, + raw: playlist.raw, + }); + savePlaylistsLocal(); + renderMyPlaylists(); + } + + const activeKey = getFriendPlaylistRouteKey(playlist); + const shouldRefreshVisibleResults = ( + (musicResultsMode === 'my-playlist' && musicResultsPlaylistId === playlist.identifier) + || (musicResultsMode === 'friend-playlist' && !!activeKey && musicResultsPlaylistId === activeKey) + ); + if (shouldRefreshVisibleResults) { + musicTracks = Array.isArray(playlist.tracks) ? [...playlist.tracks] : []; + renderMusicResults(musicTracks); + } + + const urlEpisode = String(getEpisodeFromUrl() || '').trim(); + if (urlEpisode && playlist.identifier === urlEpisode && !selectedPlaylistId) { + if (isFriend) { + const key = getFriendPlaylistRouteKey(playlist); + if (key) openFriendPlaylistByKey(key); + } else { + openMyPlaylistByIdentifier(playlist.identifier); + } + } + + if (activePlaylistView?.scope && activePlaylistView?.playlist?.identifier === playlist.identifier) { + activePlaylistView = { ...activePlaylistView, playlist }; + renderPlaylistProfileCard(); + } + }; + + commitPlaylist(parsed); + + const trackIds = Array.isArray(parsed.trackIds) ? parsed.trackIds.filter(Boolean) : []; + console.log('[music] hydratePlaylistFromEvent parsed', { + identifier: parsed.identifier, + eventId: parsed.eventId, + createdAt: parsed.createdAt, + trackIdsCount: trackIds.length, + trackIds, + }); + if (!trackIds.length) return; + + try { + const resolvedByAddress = await resolveTracksByAddresses(trackIds); + console.log('[music] hydratePlaylistFromEvent resolved', { + identifier: parsed.identifier, + requested: trackIds.length, + resolved: resolvedByAddress?.size || 0, + }); + const hydratedTracks = trackIds.map((id, index) => { + const base = parsed.tracks?.[index] || { id }; + const resolved = resolvedByAddress?.get?.(id) || null; + if (resolved) { + const prepared = musicApi.prepareTrack(resolved) || resolved; + return { + ...base, + ...prepared, + id, + unavailable: false, + }; + } + + return { + ...base, + id, + title: String(base?.title || '').trim() || 'Unavailable track', + artist: String(base?.artist || '').trim() || 'Unknown artist', + duration: Number(base?.duration) || 0, + cover: String(base?.cover || '').trim(), + albumTitle: String(base?.albumTitle || '').trim(), + streamUrl: String(base?.streamUrl || '').trim(), + unavailable: true, + }; + }); + + commitPlaylist({ + ...parsed, + tracks: hydratedTracks, + }, parsed.eventId || ''); + } catch (error) { + console.warn('[music] Failed to hydrate playlist tracks from a-tags', { + playlist: parsed.identifier, + error, + }); } } function subscribeAllPlaylists() { - const filter = { kinds: [PLAYLIST_KIND], '#t': ['music'], limit: 600 }; + const filter = { kinds: [PLAYLIST_KIND], limit: 600 }; if (isExternalTargetMode()) { filter.authors = [targetPubkey]; + } else if (!friendsFeedEnabled && currentPubkey) { + filter.authors = [currentPubkey]; } - subscribe( + + try { + playlistSubscription?.close?.(); + } catch { + // ignore stale subscription handle errors + } + + playlistSubscription = subscribe( filter, { closeOnEose: false, cacheUsage: 'PARALLEL' } ); @@ -3301,6 +3581,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs'; if (!evt || typeof evt !== 'object') return; if (evt.kind !== PLAYLIST_KIND || !isMusicPlaylistEvent(evt)) return; if (isExternalTargetMode() && evt.pubkey !== targetPubkey) return; + if (!friendsFeedEnabled && !isExternalTargetMode() && evt.pubkey !== currentPubkey) return; const treatAsMine = Boolean( currentPubkey @@ -4347,14 +4628,14 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs'; title: track.title || '', }); - const qualityCandidates = ['HIGH', 'MP3_320', 'LOSSLESS']; + const qualityCandidates = ['DIRECT']; let lastError = null; for (const quality of qualityCandidates) { try { console.debug('[music][download] requesting stream', { trackId: track.id, quality }); - const stream = await musicApi.getTrackStream(track.id, quality); + const stream = await musicApi.getTrackStream(track.id); const streamUrl = stream?.streamUrl; if (!streamUrl) { lastError = new Error(`No stream URL for quality ${quality}`); @@ -4583,6 +4864,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs'; installImageFallbacks(); initMusicResizableColumns(); + updateUploadUiState(); musicPlayer.onTrackChanged = (track) => { updateMusicPlayerMeta(track); @@ -4606,6 +4888,10 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs'; applyTargetPubkeyFromInput(); }); + musicEls.uploadBtn?.addEventListener('click', async () => { + await handleGruuvUploadClick(); + }); + musicEls.results.addEventListener('click', async (event) => { const playAlbumNow = event.target.closest('[data-play-album-now]'); @@ -4832,6 +5118,15 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs'; navigate(`/playlist/${encodeURIComponent(pubkey)}/${encodeURIComponent(identifier)}`); }); + musicEls.loadFriendsBtn?.addEventListener('click', () => { + if (isExternalTargetMode()) return; + if (friendsFeedEnabled) return; + friendsFeedEnabled = true; + setMusicStatus('Loading friend playlists…', 'neutral'); + renderFriendPlaylists(); + subscribeAllPlaylists(); + }); + musicEls.playlistNameInput?.addEventListener('keydown', async (event) => { if (event.key !== 'Enter') return; event.preventDefault(); @@ -5125,11 +5420,16 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs'; } loadQueueLocal(); updateViewerControls(); + friendsFeedEnabled = isExternalTargetMode(); + if (musicEls.loadFriendsBtn) { + musicEls.loadFriendsBtn.disabled = isExternalTargetMode(); + musicEls.loadFriendsBtn.textContent = isExternalTargetMode() ? 'Auto' : 'Load'; + } renderMyPlaylists(); renderFriendPlaylists(); renderQueue(); - subscribeAllPlaylists(); window.addEventListener('ndkEvent', handleMusicNdkEvent); + subscribeAllPlaylists(); if (!window.location.hash) { const episodeRoute = getEpisodeRouteFromUrlContext();