200 lines
6.0 KiB
JavaScript
200 lines
6.0 KiB
JavaScript
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 };
|