543 lines
16 KiB
JavaScript
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 };
|
|
}
|
|
}
|