Files
client/www/js/gruuv-playlists.mjs

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