155 lines
4.8 KiB
JavaScript
155 lines
4.8 KiB
JavaScript
const PLAYLIST_KIND = 30004;
|
|
|
|
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 parseTidalTrackId(externalId) {
|
|
if (typeof externalId !== 'string') return null;
|
|
const match = externalId.match(/^tidal:track:(\d+)$/);
|
|
if (!match) return null;
|
|
const parsed = Number(match[1]);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
function buildTrackTag(track) {
|
|
const id = Number(track?.id);
|
|
if (!Number.isFinite(id)) return null;
|
|
return ['i', `tidal:track:${id}`];
|
|
}
|
|
|
|
function buildTrackCache(track) {
|
|
const id = Number(track?.id);
|
|
if (!Number.isFinite(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 : '',
|
|
};
|
|
}
|
|
|
|
export function createPlaylistIdentifier(title = '') {
|
|
return Date.now().toString();
|
|
}
|
|
|
|
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());
|
|
return topics.includes('music') || topics.includes('playlist');
|
|
}
|
|
|
|
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 cacheTracks) {
|
|
if (seen.has(track.id)) continue;
|
|
seen.add(track.id);
|
|
trackTags.push(['i', `tidal:track:${track.id}`]);
|
|
}
|
|
|
|
const tags = [
|
|
['d', identifier],
|
|
['title', title || 'Untitled playlist'],
|
|
['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 iValues = getTagValues(event.tags, 'i');
|
|
const trackIdsFromTags = iValues.map(parseTidalTrackId).filter((id) => Number.isFinite(id));
|
|
|
|
const parsedContent = safeJsonParse(event.content || '', {});
|
|
const cacheTracks = Array.isArray(parsedContent?.tracks)
|
|
? parsedContent.tracks.map(buildTrackCache).filter(Boolean)
|
|
: [];
|
|
|
|
const cachedById = new Map(cacheTracks.map((t) => [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: '' };
|
|
});
|
|
|
|
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, parseTidalTrackId, buildTrackTag }; |