Files
client/www/js/gruuv-api.mjs
T

543 lines
16 KiB
JavaScript

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 };
}
}