trackstr
Version:
Command line tool for decentralized music scrobbling on Nostr
691 lines (604 loc) âĸ 25.6 kB
HTML
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create Scrobble - trackstr</title>
<script src="https://unpkg.com/preact@10.23.1/dist/preact.umd.js"></script>
<script src="https://unpkg.com/preact@10.23.1/hooks/dist/hooks.umd.js"></script>
<script src="https://unpkg.com/htm@3.1.1/dist/htm.umd.js"></script>
<script src="https://unpkg.com/nostr-tools@2.15.1/lib/nostr.bundle.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
background: #0f172a;
min-height: 100vh;
color: #e2e8f0;
line-height: 1.5;
font-size: 14px;
antialiased: true;
}
.container {
max-width: 768px;
margin: 0 auto;
padding: 24px 16px;
}
.header {
text-align: center;
margin-bottom: 48px;
}
.logo {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(to right, #8b5cf6, #06b6d4, #10b981);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 16px;
letter-spacing: -0.025em;
}
.subtitle {
color: #94a3b8;
font-size: 1.125rem;
margin-bottom: 32px;
font-weight: 400;
}
.nav {
display: flex;
justify-content: center;
gap: 16px;
margin-bottom: 32px;
}
.nav-link {
color: #94a3b8;
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.15s ease;
}
.nav-link:hover {
color: #06b6d4;
background: #1e293b;
}
.nav-link.active {
color: #06b6d4;
background: #1e293b;
}
.form-container {
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
padding: 32px;
margin-bottom: 32px;
}
.form-title {
font-size: 1.5rem;
font-weight: 600;
color: #f8fafc;
margin-bottom: 24px;
text-align: center;
}
.form-group {
margin-bottom: 24px;
}
.form-label {
display: block;
color: #cbd5e1;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 8px;
}
.form-input {
width: 100%;
background: #0f172a;
border: 1px solid #334155;
border-radius: 8px;
padding: 12px 16px;
color: #e2e8f0;
font-size: 0.875rem;
transition: border-color 0.15s ease;
}
.form-input:focus {
outline: none;
border-color: #06b6d4;
box-shadow: 0 0 0 3px rgba(6, 182, 212, 0.1);
}
.form-input::placeholder {
color: #64748b;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.form-actions {
display: flex;
gap: 16px;
justify-content: center;
margin-top: 32px;
}
.btn {
padding: 12px 24px;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
border: none;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-primary {
background: #06b6d4;
color: #ffffff;
}
.btn-primary:hover:not(:disabled) {
background: #0891b2;
}
.btn-primary:disabled {
background: #334155;
color: #64748b;
cursor: not-allowed;
}
.btn-secondary {
background: #334155;
color: #cbd5e1;
}
.btn-secondary:hover {
background: #475569;
}
.key-setup {
background: #0f172a;
border: 1px solid #334155;
border-radius: 8px;
padding: 20px;
margin-bottom: 24px;
}
.key-setup-title {
font-weight: 600;
color: #f8fafc;
margin-bottom: 12px;
font-size: 0.875rem;
}
.key-setup-text {
color: #94a3b8;
font-size: 0.8125rem;
margin-bottom: 16px;
}
.key-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.btn-small {
padding: 8px 16px;
font-size: 0.8125rem;
}
.alert {
padding: 16px;
border-radius: 8px;
margin-bottom: 24px;
font-size: 0.875rem;
}
.alert-success {
background: #064e3b;
border: 1px solid #065f46;
color: #6ee7b7;
}
.alert-error {
background: #7f1d1d;
border: 1px solid #991b1b;
color: #fca5a5;
}
.alert-info {
background: #0c4a6e;
border: 1px solid #075985;
color: #7dd3fc;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid #334155;
border-top: 2px solid #06b6d4;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.help-text {
color: #64748b;
font-size: 0.8125rem;
margin-top: 4px;
}
@media (max-width: 640px) {
.container {
padding: 20px 16px;
}
.form-container {
padding: 24px;
}
.logo {
font-size: 2rem;
}
.subtitle {
font-size: 1rem;
}
.form-row {
grid-template-columns: 1fr;
}
.form-actions {
flex-direction: column;
}
.key-actions {
flex-direction: column;
}
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module">
const { createElement: h, render } = preact;
const { useState, useEffect } = preactHooks;
const html = htm.bind(h);
const { generateSecretKey, getPublicKey, finalizeEvent, SimplePool } = NostrTools;
// Nostr relays for publishing scrobbles
const RELAYS = [
'wss://relay.damus.io',
'wss://nostr.wine',
'wss://relay.snort.social',
'wss://nostr.mutinywallet.com',
'wss://relay.nostr.band',
'wss://nos.lol',
'wss://relay.current.fyi',
'wss://nostr.land'
];
// Main Create Component
const CreateScrobble = () => {
const [privateKey, setPrivateKey] = useState('');
const [formData, setFormData] = useState({
title: '',
artist: '',
album: '',
spotify: '',
isrc: ''
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [message, setMessage] = useState({ type: '', text: '' });
const [publishedEventId, setPublishedEventId] = useState('');
useEffect(() => {
// Check for stored private key
const storedKey = localStorage.getItem('trackstr_private_key');
if (storedKey) {
setPrivateKey(storedKey);
}
}, []);
const generateNewKey = () => {
const sk = generateSecretKey();
const skHex = Array.from(sk).map(byte => byte.toString(16).padStart(2, '0')).join('');
setPrivateKey(skHex);
setMessage({ type: 'info', text: 'New private key generated. Save it somewhere safe!' });
};
const saveKey = () => {
if (privateKey) {
localStorage.setItem('trackstr_private_key', privateKey);
setMessage({ type: 'success', text: 'Private key saved to browser storage.' });
}
};
const clearKey = () => {
localStorage.removeItem('trackstr_private_key');
setPrivateKey('');
setMessage({ type: 'info', text: 'Private key cleared from storage.' });
};
const handleInputChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
if (message.type === 'error') {
setMessage({ type: '', text: '' });
}
};
const validateForm = () => {
if (!formData.title.trim()) {
setMessage({ type: 'error', text: 'Track title is required.' });
return false;
}
if (!formData.artist.trim()) {
setMessage({ type: 'error', text: 'Artist name is required.' });
return false;
}
if (!privateKey) {
setMessage({ type: 'error', text: 'Private key is required to sign the scrobble.' });
return false;
}
return true;
};
const createScrobbleEvent = () => {
const tags = [
['title', formData.title.trim()],
['artist', formData.artist.trim()]
];
if (formData.album.trim()) {
tags.push(['album', formData.album.trim()]);
}
if (formData.spotify.trim()) {
const spotifyId = formData.spotify.trim().replace(/^https:\/\/open\.spotify\.com\/track\//, '');
tags.push(['i', `spotify:track:${spotifyId}`]);
}
if (formData.isrc.trim()) {
tags.push(['i', `isrc:${formData.isrc.trim()}`]);
}
const event = {
kind: 1073,
created_at: Math.floor(Date.now() / 1000),
tags: tags,
content: `đĩ Now playing: ${formData.title} by ${formData.artist}${formData.album ? ` from ${formData.album}` : ''}`
};
try {
const skBytes = new Uint8Array(privateKey.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
return finalizeEvent(event, skBytes);
} catch (error) {
throw new Error('Invalid private key format');
}
};
const publishToRelay = async (relayUrl, event) => {
return new Promise((resolve, reject) => {
console.log(`đ Connecting to ${relayUrl}`);
const ws = new WebSocket(relayUrl);
let published = false;
const timeout = setTimeout(() => {
if (!published) {
console.log(`â° Timeout for ${relayUrl}`);
ws.close();
reject(new Error(`Timeout: ${relayUrl}`));
}
}, 15000);
ws.onopen = () => {
console.log(`â
Connected to ${relayUrl}`);
const eventMessage = JSON.stringify(['EVENT', event]);
console.log(`đ¤ Sending event to ${relayUrl}:`, event.id);
ws.send(eventMessage);
};
ws.onmessage = (message) => {
try {
const data = JSON.parse(message.data);
console.log(`đĨ Response from ${relayUrl}:`, data);
if (data[0] === 'OK') {
if (data[1] === event.id) {
if (data[2] === true) {
console.log(`đ Successfully published to ${relayUrl}`);
published = true;
clearTimeout(timeout);
ws.close();
resolve(relayUrl);
} else {
console.log(`â Rejected by ${relayUrl}: ${data[3] || 'Unknown reason'}`);
clearTimeout(timeout);
ws.close();
reject(new Error(`Rejected: ${data[3] || 'Unknown reason'}`));
}
}
} else if (data[0] === 'NOTICE') {
console.log(`âšī¸ Notice from ${relayUrl}: ${data[1]}`);
}
} catch (e) {
console.error(`â Error parsing response from ${relayUrl}:`, e);
}
};
ws.onerror = (error) => {
console.log(`â Connection error to ${relayUrl}:`, error);
clearTimeout(timeout);
reject(new Error(`Connection failed: ${relayUrl}`));
};
ws.onclose = () => {
console.log(`đ Connection closed to ${relayUrl}`);
};
});
};
const submitScrobble = async () => {
if (!validateForm()) return;
setIsSubmitting(true);
setMessage({ type: '', text: '' });
setPublishedEventId('');
try {
console.log('đĩ Creating scrobble event...');
const event = createScrobbleEvent();
console.log('đ Event created:', event);
setPublishedEventId(event.id);
console.log(`đ Publishing to ${RELAYS.length} relays...`);
// Publish to all relays
const publishPromises = RELAYS.map(relayUrl =>
publishToRelay(relayUrl, event).catch(error => {
console.log(`â Failed to publish to ${relayUrl}:`, error.message);
return null;
})
);
const results = await Promise.allSettled(publishPromises);
const successful = results.filter(r => r.status === 'fulfilled' && r.value !== null);
console.log(`đ Publishing results: ${successful.length}/${RELAYS.length} successful`);
if (successful.length > 0) {
const successfulRelays = successful.map(r => r.value).join(', ');
setMessage({
type: 'success',
text: `đ Scrobble published successfully to ${successful.length}/${RELAYS.length} relays!`
});
// Clear form
setFormData({
title: '',
artist: '',
album: '',
spotify: '',
isrc: ''
});
} else {
setMessage({
type: 'error',
text: 'â Failed to publish to any relays. Please check your connection and try again.'
});
}
} catch (error) {
console.error('đĨ Publishing error:', error);
setMessage({
type: 'error',
text: `Failed to create event: ${error.message}`
});
} finally {
setIsSubmitting(false);
}
};
const getPublicKeyFromPrivate = () => {
if (!privateKey) return '';
try {
const skBytes = new Uint8Array(privateKey.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
return getPublicKey(skBytes);
} catch {
return 'Invalid key format';
}
};
return html`
<div class="container">
<header class="header">
<div class="logo">
<span class="music-note">âĒ</span> trackstr <span class="music-note">âĢ</span>
</div>
<div class="subtitle">
Share Your Music with the World
</div>
<nav class="nav">
<a href="index.html" class="nav-link">View Feed</a>
<a href="create.html" class="nav-link active">Create Scrobble</a>
</nav>
</header>
${message.text && html`
<div class=${`alert alert-${message.type}`}>
${message.text}
${message.type === 'success' && publishedEventId && html`
<div style="margin-top: 12px;">
<a
href="https://nostr.eu/${publishedEventId}"
target="_blank"
style="color: #10b981; text-decoration: underline;"
>
View your scrobble on nostr.eu â
</a>
</div>
`}
</div>
`}
<div class="key-setup">
<div class="key-setup-title">đ Nostr Identity</div>
<div class="key-setup-text">
${privateKey
? `Your public key: ${getPublicKeyFromPrivate().substring(0, 16)}...`
: 'You need a Nostr private key to publish scrobbles. Generate one or paste your existing key.'
}
</div>
<div class="key-actions">
<button onclick=${generateNewKey} class="btn btn-secondary btn-small">
Generate New Key
</button>
${privateKey && html`
<button onclick=${saveKey} class="btn btn-secondary btn-small">
Save to Browser
</button>
<button onclick=${clearKey} class="btn btn-secondary btn-small">
Clear Key
</button>
`}
</div>
<input
type="password"
class="form-input"
style="margin-top: 16px"
placeholder="Or paste your private key here (hex format)"
value=${privateKey}
oninput=${(e) => setPrivateKey(e.target.value)}
/>
</div>
<div class="form-container">
<div class="form-title">đĩ Create New Scrobble</div>
<div class="form-group">
<label class="form-label">Track Title *</label>
<input
type="text"
class="form-input"
placeholder="Enter the track title"
value=${formData.title}
oninput=${(e) => handleInputChange('title', e.target.value)}
/>
</div>
<div class="form-group">
<label class="form-label">Artist *</label>
<input
type="text"
class="form-input"
placeholder="Enter the artist name"
value=${formData.artist}
oninput=${(e) => handleInputChange('artist', e.target.value)}
/>
</div>
<div class="form-group">
<label class="form-label">Album</label>
<input
type="text"
class="form-input"
placeholder="Enter the album name (optional)"
value=${formData.album}
oninput=${(e) => handleInputChange('album', e.target.value)}
/>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Spotify Link</label>
<input
type="text"
class="form-input"
placeholder="https://open.spotify.com/track/..."
value=${formData.spotify}
oninput=${(e) => handleInputChange('spotify', e.target.value)}
/>
<div class="help-text">Optional: Link to Spotify track</div>
</div>
<div class="form-group">
<label class="form-label">ISRC Code</label>
<input
type="text"
class="form-input"
placeholder="USAT21904578"
value=${formData.isrc}
oninput=${(e) => handleInputChange('isrc', e.target.value)}
/>
<div class="help-text">Optional: International Standard Recording Code</div>
</div>
</div>
<div class="form-actions">
<button
onclick=${submitScrobble}
disabled=${isSubmitting}
class="btn btn-primary"
>
${isSubmitting ? html`<div class="spinner"></div>` : 'đĩ'}
${isSubmitting ? 'Publishing...' : 'Publish Scrobble'}
</button>
<a href="index.html" class="btn btn-secondary">
Cancel
</a>
</div>
</div>
</div>
`;
};
// Render the app
render(html`<${CreateScrobble} />`, document.getElementById('app'));
</script>
</body>
</html>