205 lines
6.1 KiB
JavaScript
205 lines
6.1 KiB
JavaScript
import { publishEvent, getPubkey } from './init-ndk.mjs';
|
|
import { calculateSHA256, createBlossomAuth } from './blossom-api.mjs';
|
|
import { getBlossomServers } from './blossom-ui.mjs';
|
|
|
|
const TRACK_KIND = 36787;
|
|
|
|
function normalizeText(value = '') {
|
|
return String(value || '').trim();
|
|
}
|
|
|
|
function extensionFromName(name = '') {
|
|
const base = String(name || '').trim();
|
|
const idx = base.lastIndexOf('.');
|
|
if (idx < 0) return '';
|
|
return base.slice(idx + 1).toLowerCase();
|
|
}
|
|
|
|
function inferFormat(file) {
|
|
const ext = extensionFromName(file?.name || '');
|
|
if (ext) return ext;
|
|
|
|
const mime = normalizeText(file?.type || '').toLowerCase();
|
|
if (mime.includes('flac')) return 'flac';
|
|
if (mime.includes('mpeg') || mime.includes('mp3')) return 'mp3';
|
|
if (mime.includes('aac')) return 'aac';
|
|
if (mime.includes('ogg')) return 'ogg';
|
|
if (mime.includes('wav')) return 'wav';
|
|
return 'bin';
|
|
}
|
|
|
|
function inferMime(file) {
|
|
const mime = normalizeText(file?.type || '');
|
|
if (mime) return mime;
|
|
|
|
const format = inferFormat(file);
|
|
if (format === 'mp3') return 'audio/mpeg';
|
|
if (format === 'flac') return 'audio/flac';
|
|
if (format === 'ogg') return 'audio/ogg';
|
|
if (format === 'aac') return 'audio/aac';
|
|
if (format === 'wav') return 'audio/wav';
|
|
return 'application/octet-stream';
|
|
}
|
|
|
|
function selectUploadServer() {
|
|
const servers = getBlossomServers();
|
|
const healthyUpload = servers.find((s) => s?.healthy && s?.upload);
|
|
if (healthyUpload?.url) return String(healthyUpload.url).trim().replace(/\/$/, '');
|
|
|
|
const anyUpload = servers.find((s) => s?.upload);
|
|
if (anyUpload?.url) return String(anyUpload.url).trim().replace(/\/$/, '');
|
|
|
|
const healthyAny = servers.find((s) => s?.healthy);
|
|
if (healthyAny?.url) return String(healthyAny.url).trim().replace(/\/$/, '');
|
|
|
|
if (servers[0]?.url) return String(servers[0].url).trim().replace(/\/$/, '');
|
|
|
|
return '';
|
|
}
|
|
|
|
async function getAudioDurationSeconds(file) {
|
|
if (!(file instanceof File || file instanceof Blob)) return 0;
|
|
|
|
const objectUrl = URL.createObjectURL(file);
|
|
try {
|
|
const duration = await new Promise((resolve) => {
|
|
const audio = document.createElement('audio');
|
|
const cleanup = () => {
|
|
audio.removeAttribute('src');
|
|
audio.load();
|
|
};
|
|
|
|
audio.preload = 'metadata';
|
|
audio.onloadedmetadata = () => {
|
|
const d = Number(audio.duration);
|
|
cleanup();
|
|
resolve(Number.isFinite(d) && d > 0 ? d : 0);
|
|
};
|
|
audio.onerror = () => {
|
|
cleanup();
|
|
resolve(0);
|
|
};
|
|
audio.src = objectUrl;
|
|
});
|
|
|
|
return Math.round(duration);
|
|
} finally {
|
|
URL.revokeObjectURL(objectUrl);
|
|
}
|
|
}
|
|
|
|
function makeDTag({ title, artist, sha256 }) {
|
|
const safeTitle = normalizeText(title)
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 48);
|
|
const safeArtist = normalizeText(artist)
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 32);
|
|
|
|
const prefix = [safeArtist, safeTitle].filter(Boolean).join('-') || 'track';
|
|
return `${prefix}-${String(sha256 || '').slice(0, 8)}-${Date.now()}`;
|
|
}
|
|
|
|
async function uploadFileToBlossom(file, serverUrl, sha256) {
|
|
const authToken = await createBlossomAuth('upload', sha256, `Upload ${file.name || 'audio'}`);
|
|
const res = await fetch(`${serverUrl}/upload`, {
|
|
method: 'PUT',
|
|
mode: 'cors',
|
|
cache: 'no-cache',
|
|
headers: {
|
|
'Content-Type': inferMime(file),
|
|
Authorization: `Nostr ${authToken}`,
|
|
},
|
|
body: file,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`Upload failed: ${res.status} ${res.statusText}`);
|
|
}
|
|
|
|
const payload = await res.json().catch(() => ({}));
|
|
const url = normalizeText(payload?.url || payload?.blob?.url || '');
|
|
return {
|
|
uploadUrl: url || `${serverUrl}/${sha256}`,
|
|
raw: payload,
|
|
};
|
|
}
|
|
|
|
export async function uploadGruuvTrack(file, metadata = {}, options = {}) {
|
|
if (!file) throw new Error('No file selected');
|
|
|
|
const pubkey = await getPubkey();
|
|
if (!pubkey) throw new Error('Not authenticated');
|
|
|
|
const serverUrl = normalizeText(options.serverUrl || selectUploadServer());
|
|
if (!serverUrl) throw new Error('No Blossom server available for upload');
|
|
|
|
const sha256 = await calculateSHA256(file);
|
|
const uploadResult = await uploadFileToBlossom(file, serverUrl, sha256);
|
|
|
|
const title = normalizeText(metadata.title || file.name?.replace(/\.[^.]+$/, '') || 'Untitled');
|
|
const artist = normalizeText(metadata.artist || 'Unknown artist');
|
|
const album = normalizeText(metadata.album || 'Uploads');
|
|
const image = normalizeText(metadata.image || '');
|
|
const genre = normalizeText(metadata.genre || '');
|
|
const released = normalizeText(metadata.released || '');
|
|
const trackNumber = normalizeText(metadata.trackNumber || '');
|
|
const discNumber = normalizeText(metadata.discNumber || '');
|
|
|
|
const duration = Number.isFinite(metadata.duration)
|
|
? Number(metadata.duration)
|
|
: await getAudioDurationSeconds(file);
|
|
|
|
const format = inferFormat(file);
|
|
const mime = inferMime(file);
|
|
const dTag = normalizeText(metadata.dTag || makeDTag({ title, artist, sha256 }));
|
|
|
|
const tags = [
|
|
['d', dTag],
|
|
['url', uploadResult.uploadUrl],
|
|
['title', title],
|
|
['artist', artist],
|
|
['album', album],
|
|
['duration', String(Math.max(0, Math.round(duration || 0)))],
|
|
['size', String(file.size || 0)],
|
|
['alt', `Song: ${title} - ${artist}`],
|
|
['format', format],
|
|
['m', mime],
|
|
['x', sha256],
|
|
['t', 'music'],
|
|
['t', 'gruuv'],
|
|
];
|
|
|
|
if (image) tags.push(['image', image]);
|
|
if (genre) {
|
|
tags.push(['genre', genre]);
|
|
tags.push(['t', genre.toLowerCase()]);
|
|
}
|
|
if (released) tags.push(['released', released]);
|
|
if (trackNumber) tags.push(['track_number', trackNumber]);
|
|
if (discNumber && discNumber !== '1') tags.push(['disc_number', discNumber]);
|
|
|
|
const event = {
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
kind: TRACK_KIND,
|
|
tags,
|
|
content: `Listen to my song - ${title} by ${artist}`,
|
|
};
|
|
|
|
const publishResult = await publishEvent(event);
|
|
|
|
return {
|
|
file,
|
|
pubkey,
|
|
sha256,
|
|
url: uploadResult.uploadUrl,
|
|
dTag,
|
|
event,
|
|
publishResult,
|
|
};
|
|
}
|