mirror of https://github.com/Nezreka/SoulSync.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
7736 lines
359 KiB
7736 lines
359 KiB
// IMPORT PAGE (full page, replaces old modal)
|
|
// ===================================================================
|
|
|
|
let importJobIdCounter = 0;
|
|
|
|
const importPageState = {
|
|
stagingFiles: [],
|
|
selectedSingles: new Set(),
|
|
albumData: null, // response from /api/import/album/match
|
|
matchOverrides: {}, // { trackIndex: stagingFileIndex } — manual drag-drop overrides
|
|
singlesManualMatches: {}, // { stagingFileIndex: { id, name, artist, album, ... } }
|
|
initialized: false,
|
|
activeTab: 'album',
|
|
tapSelectedChip: null, // for mobile tap-to-assign fallback
|
|
};
|
|
|
|
// ===============================
|
|
// STATS PAGE
|
|
// ===============================
|
|
|
|
let _statsRange = '7d';
|
|
let _statsTimelineChart = null;
|
|
let _statsGenreChart = null;
|
|
let _statsDbStorageChart = null;
|
|
let _statsInitialized = false;
|
|
|
|
function initializeStatsPage() {
|
|
if (_statsInitialized) {
|
|
loadStatsData();
|
|
return;
|
|
}
|
|
_statsInitialized = true;
|
|
|
|
// Time range buttons
|
|
const rangeContainer = document.getElementById('stats-time-range');
|
|
if (rangeContainer) {
|
|
rangeContainer.addEventListener('click', (e) => {
|
|
const btn = e.target.closest('.stats-range-btn');
|
|
if (!btn) return;
|
|
_statsRange = btn.dataset.range;
|
|
rangeContainer.querySelectorAll('.stats-range-btn').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
loadStatsData();
|
|
});
|
|
}
|
|
|
|
loadStatsData();
|
|
_updateStatsLastSynced();
|
|
}
|
|
|
|
async function triggerStatsSync() {
|
|
const btn = document.getElementById('stats-sync-btn');
|
|
if (btn) btn.classList.add('syncing');
|
|
|
|
try {
|
|
const resp = await fetch('/api/listening-stats/sync', { method: 'POST' });
|
|
const data = await resp.json();
|
|
if (data.success) {
|
|
showToast('Syncing listening data...', 'info');
|
|
// Wait a few seconds for the sync to complete, then reload
|
|
setTimeout(async () => {
|
|
await loadStatsData();
|
|
_updateStatsLastSynced();
|
|
if (btn) btn.classList.remove('syncing');
|
|
showToast('Listening stats updated', 'success');
|
|
}, 5000);
|
|
} else {
|
|
showToast(data.error || 'Sync failed', 'error');
|
|
if (btn) btn.classList.remove('syncing');
|
|
}
|
|
} catch (e) {
|
|
showToast('Sync failed', 'error');
|
|
if (btn) btn.classList.remove('syncing');
|
|
}
|
|
}
|
|
|
|
async function _updateStatsLastSynced() {
|
|
const el = document.getElementById('stats-last-synced');
|
|
if (!el) return;
|
|
try {
|
|
const resp = await fetch('/api/listening-stats/status');
|
|
const data = await resp.json();
|
|
if (data.stats && data.stats.last_poll) {
|
|
el.textContent = `Last synced: ${data.stats.last_poll}`;
|
|
} else {
|
|
el.textContent = 'Not synced yet';
|
|
}
|
|
} catch {
|
|
el.textContent = '';
|
|
}
|
|
}
|
|
|
|
async function loadStatsData() {
|
|
// Show loading state
|
|
document.querySelectorAll('.stats-card-value').forEach(el => el.style.opacity = '0.3');
|
|
|
|
// Single cached endpoint — instant response
|
|
let data;
|
|
try {
|
|
const resp = await fetch(`/api/stats/cached?range=${_statsRange}`);
|
|
data = await resp.json();
|
|
} catch {
|
|
data = {};
|
|
}
|
|
|
|
if (!data.success) {
|
|
// Cache not available — show empty state, user should hit Sync
|
|
data = {
|
|
overview: {}, top_artists: [], top_albums: [], top_tracks: [],
|
|
timeline: [], genres: [], recent: [], health: {}
|
|
};
|
|
}
|
|
|
|
const overview = data.overview || {};
|
|
const emptyEl = document.getElementById('stats-empty');
|
|
const hasData = (overview.total_plays || 0) > 0;
|
|
|
|
if (emptyEl) {
|
|
emptyEl.classList.toggle('hidden', hasData);
|
|
}
|
|
// Hide main content sections when no data
|
|
const mainSections = document.querySelectorAll('.stats-overview, .stats-main-grid, .stats-full-width');
|
|
mainSections.forEach(el => el.style.display = hasData ? '' : 'none');
|
|
|
|
// Overview cards
|
|
const _fmt = (n) => {
|
|
if (!n) return '0';
|
|
if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
|
|
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
|
|
return n.toLocaleString();
|
|
};
|
|
const _fmtTime = (ms) => {
|
|
if (!ms) return '0h';
|
|
const hours = Math.floor(ms / 3600000);
|
|
const mins = Math.floor((ms % 3600000) / 60000);
|
|
if (hours > 0) return `${hours}h ${mins}m`;
|
|
return `${mins}m`;
|
|
};
|
|
|
|
// Restore opacity
|
|
document.querySelectorAll('.stats-card-value').forEach(el => el.style.opacity = '1');
|
|
|
|
_setText('stats-total-plays', _fmt(overview.total_plays));
|
|
_setText('stats-listening-time', _fmtTime(overview.total_time_ms));
|
|
_setText('stats-unique-artists', _fmt(overview.unique_artists));
|
|
_setText('stats-unique-albums', _fmt(overview.unique_albums));
|
|
_setText('stats-unique-tracks', _fmt(overview.unique_tracks));
|
|
|
|
// Top Artists — visual bubbles
|
|
_renderTopArtistsVisual(data.top_artists || []);
|
|
|
|
// Top Artists — ranked list
|
|
_renderRankedList('stats-top-artists', data.top_artists || [], (item, i) => `
|
|
<div class="stats-ranked-item">
|
|
<span class="stats-ranked-num">${i + 1}</span>
|
|
${item.image_url ? `<img class="stats-ranked-img" src="${item.image_url}" alt="" onerror="this.style.display='none'">` : ''}
|
|
<div class="stats-ranked-info">
|
|
<div class="stats-ranked-name">${item.id ? `<a class="stats-artist-link" onclick="navigateToPage('library');setTimeout(()=>navigateToArtistDetail('${item.id}','${_esc(item.name).replace(/'/g, "\\'")}'),300)">${_esc(item.name)}</a>` : _esc(item.name)}${item.soul_id && !String(item.soul_id).startsWith('soul_unnamed_') ? ' <img src="/static/trans2.png" style="width:12px;height:12px;vertical-align:middle;opacity:0.5;" title="SoulID">' : ''}</div>
|
|
<div class="stats-ranked-meta">${item.global_listeners ? _fmt(item.global_listeners) + ' global listeners' : ''}</div>
|
|
</div>
|
|
<span class="stats-ranked-count">${_fmt(item.play_count)} plays</span>
|
|
</div>
|
|
`);
|
|
|
|
// Top Albums
|
|
_renderRankedList('stats-top-albums', data.top_albums || [], (item, i) => `
|
|
<div class="stats-ranked-item">
|
|
<span class="stats-ranked-num">${i + 1}</span>
|
|
${item.image_url ? `<img class="stats-ranked-img" src="${item.image_url}" alt="" onerror="this.style.display='none'">` : ''}
|
|
<div class="stats-ranked-info">
|
|
<div class="stats-ranked-name">${_esc(item.name)}</div>
|
|
<div class="stats-ranked-meta">${item.artist_id ? `<a class="stats-artist-link" onclick="navigateToPage('library');setTimeout(()=>navigateToArtistDetail('${item.artist_id}','${_esc(item.artist || '').replace(/'/g, "\\'")}'),300)">${_esc(item.artist || '')}</a>` : _esc(item.artist || '')}</div>
|
|
</div>
|
|
<span class="stats-ranked-count">${_fmt(item.play_count)} plays</span>
|
|
</div>
|
|
`);
|
|
|
|
// Top Tracks
|
|
_renderRankedList('stats-top-tracks', data.top_tracks || [], (item, i) => `
|
|
<div class="stats-ranked-item">
|
|
<span class="stats-ranked-num">${i + 1}</span>
|
|
${item.image_url ? `<img class="stats-ranked-img" src="${item.image_url}" alt="" onerror="this.style.display='none'">` : ''}
|
|
<div class="stats-ranked-info">
|
|
<div class="stats-ranked-name">${_esc(item.name)}</div>
|
|
<div class="stats-ranked-meta">${item.artist_id ? `<a class="stats-artist-link" onclick="navigateToPage('library');setTimeout(()=>navigateToArtistDetail('${item.artist_id}','${_esc(item.artist || '').replace(/'/g, "\\'")}'),300)">${_esc(item.artist || '')}</a>` : _esc(item.artist || '')}${item.album ? ' · ' + _esc(item.album) : ''}</div>
|
|
</div>
|
|
<button class="stats-play-btn" onclick="event.stopPropagation();playStatsTrack('${_esc(item.name).replace(/'/g, "\\'")}','${_esc(item.artist || '').replace(/'/g, "\\'")}','${_esc(item.album || '').replace(/'/g, "\\'")}')" title="Play">▶</button>
|
|
<span class="stats-ranked-count">${_fmt(item.play_count)} plays</span>
|
|
</div>
|
|
`);
|
|
|
|
// Timeline chart
|
|
_renderTimelineChart(data.timeline || []);
|
|
|
|
// Genre chart
|
|
_renderGenreChart(data.genres || []);
|
|
|
|
// Library health
|
|
_renderLibraryHealth(data.health || {});
|
|
|
|
// DB storage chart (separate fetch — not part of cached stats)
|
|
_loadDbStorageChart();
|
|
// Library disk usage (separate fetch — populated by deep scan)
|
|
_loadLibraryDiskUsage();
|
|
|
|
// Recent plays
|
|
_renderRecentPlays(data.recent || []);
|
|
}
|
|
|
|
function _renderTopArtistsVisual(artists) {
|
|
const el = document.getElementById('stats-top-artists-visual');
|
|
if (!el || !artists.length) { if (el) el.innerHTML = ''; return; }
|
|
|
|
const top5 = artists.slice(0, 5);
|
|
const maxPlays = top5[0]?.play_count || 1;
|
|
const _fmt = (n) => {
|
|
if (!n) return '0';
|
|
if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
|
|
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
|
|
return n.toString();
|
|
};
|
|
|
|
el.innerHTML = `<div class="stats-artist-bubbles">
|
|
${top5.map((a, i) => {
|
|
const pct = Math.round((a.play_count / maxPlays) * 100);
|
|
const size = 44 + (4 - i) * 6; // Largest first: 68, 62, 56, 50, 44
|
|
return `<div class="stats-artist-bubble" onclick="${a.id ? `navigateToPage('library');setTimeout(()=>navigateToArtistDetail('${a.id}','${_esc(a.name).replace(/'/g, "\\\\'")}'),300)` : ''}" style="cursor:${a.id ? 'pointer' : 'default'}">
|
|
<div class="stats-bubble-img" style="width:${size}px;height:${size}px;${a.image_url ? `background-image:url('${a.image_url}')` : ''}">
|
|
${!a.image_url ? `<span>${(a.name || '?')[0]}</span>` : ''}
|
|
</div>
|
|
<div class="stats-bubble-bar-container">
|
|
<div class="stats-bubble-bar" style="width:${pct}%"></div>
|
|
</div>
|
|
<div class="stats-bubble-name">${_esc(a.name)}</div>
|
|
<div class="stats-bubble-count">${_fmt(a.play_count)}</div>
|
|
</div>`;
|
|
}).join('')}
|
|
</div>`;
|
|
}
|
|
|
|
function _setText(id, text) {
|
|
const el = document.getElementById(id);
|
|
if (el) el.textContent = text;
|
|
}
|
|
|
|
function _renderRankedList(containerId, items, template) {
|
|
const el = document.getElementById(containerId);
|
|
if (!el) return;
|
|
el.innerHTML = items.length
|
|
? items.map((item, i) => template(item, i)).join('')
|
|
: '<div style="color:rgba(255,255,255,0.3);font-size:0.85em;padding:12px;">No data yet</div>';
|
|
}
|
|
|
|
function _renderTimelineChart(data) {
|
|
const canvas = document.getElementById('stats-timeline-chart');
|
|
if (!canvas || typeof Chart === 'undefined') return;
|
|
|
|
if (_statsTimelineChart) _statsTimelineChart.destroy();
|
|
|
|
_statsTimelineChart = new Chart(canvas, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: data.map(d => d.date),
|
|
datasets: [{
|
|
label: 'Plays',
|
|
data: data.map(d => d.plays),
|
|
backgroundColor: `rgba(${getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '29,185,84'}, 0.5)`,
|
|
borderColor: `rgba(${getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '29,185,84'}, 0.8)`,
|
|
borderWidth: 1,
|
|
borderRadius: 4,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
x: { grid: { display: false }, ticks: { color: 'rgba(255,255,255,0.3)', font: { size: 10 }, maxTicksLimit: 12 } },
|
|
y: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: 'rgba(255,255,255,0.3)', font: { size: 10 } }, beginAtZero: true },
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function _renderGenreChart(data) {
|
|
const canvas = document.getElementById('stats-genre-chart');
|
|
const legend = document.getElementById('stats-genre-legend');
|
|
if (!canvas || typeof Chart === 'undefined') return;
|
|
|
|
if (_statsGenreChart) _statsGenreChart.destroy();
|
|
|
|
const colors = [
|
|
'#1db954', '#1ed760', '#4ade80', '#7c3aed', '#a855f7',
|
|
'#ec4899', '#f43f5e', '#f97316', '#eab308', '#06b6d4',
|
|
'#3b82f6', '#6366f1', '#14b8a6', '#84cc16', '#f59e0b',
|
|
];
|
|
|
|
const top = data.slice(0, 10);
|
|
|
|
_statsGenreChart = new Chart(canvas, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: top.map(g => g.genre),
|
|
datasets: [{
|
|
data: top.map(g => g.play_count),
|
|
backgroundColor: colors.slice(0, top.length),
|
|
borderWidth: 0,
|
|
hoverOffset: 6,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: true,
|
|
cutout: '65%',
|
|
plugins: { legend: { display: false } },
|
|
}
|
|
});
|
|
|
|
if (legend) {
|
|
legend.innerHTML = top.map((g, i) => `
|
|
<div class="stats-genre-legend-item">
|
|
<span class="stats-genre-dot" style="background:${colors[i]}"></span>
|
|
<span>${g.genre}</span>
|
|
<span class="stats-genre-pct">${g.percentage}%</span>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
}
|
|
|
|
function _renderLibraryHealth(data) {
|
|
if (!data || !data.total_tracks) return;
|
|
|
|
const _fmt = (n) => {
|
|
if (!n) return '0';
|
|
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
|
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
|
return n.toLocaleString();
|
|
};
|
|
|
|
_setText('stats-unplayed', `${_fmt(data.unplayed_count)} (${data.unplayed_percentage || 0}%)`);
|
|
_setText('stats-total-duration', data.total_duration_ms ? `${Math.floor(data.total_duration_ms / 3600000)}h` : '0h');
|
|
_setText('stats-total-tracks-count', _fmt(data.total_tracks));
|
|
|
|
// Format bar
|
|
const bar = document.getElementById('stats-format-bar');
|
|
if (bar && data.format_breakdown) {
|
|
const total = Object.values(data.format_breakdown).reduce((s, v) => s + v, 0) || 1;
|
|
const fmtColors = { FLAC: '#3b82f6', MP3: '#f97316', Opus: '#a855f7', AAC: '#14b8a6', OGG: '#eab308', WAV: '#ec4899', Other: '#555' };
|
|
|
|
bar.innerHTML = Object.entries(data.format_breakdown).map(([fmt, count]) => {
|
|
const pct = (count / total * 100).toFixed(1);
|
|
return `<div class="stats-format-segment" style="flex:${count};background:${fmtColors[fmt] || '#555'}" title="${fmt}: ${count} tracks (${pct}%)">${pct > 8 ? fmt : ''}</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
// Enrichment coverage
|
|
const enrichEl = document.getElementById('stats-enrichment-coverage');
|
|
if (enrichEl && data.enrichment_coverage) {
|
|
const ec = data.enrichment_coverage;
|
|
const services = [
|
|
{ name: 'Spotify', pct: ec.spotify || 0, color: '#1db954' },
|
|
{ name: 'MusicBrainz', pct: ec.musicbrainz || 0, color: '#ba55d3' },
|
|
{ name: 'Deezer', pct: ec.deezer || 0, color: '#a238ff' },
|
|
{ name: 'Last.fm', pct: ec.lastfm || 0, color: '#d51007' },
|
|
{ name: 'iTunes', pct: ec.itunes || 0, color: '#fc3c44' },
|
|
{ name: 'AudioDB', pct: ec.audiodb || 0, color: '#1a9fff' },
|
|
{ name: 'Genius', pct: ec.genius || 0, color: '#ffff64' },
|
|
{ name: 'Tidal', pct: ec.tidal || 0, color: '#00ffff' },
|
|
{ name: 'Qobuz', pct: ec.qobuz || 0, color: '#4285f4' },
|
|
];
|
|
enrichEl.innerHTML = services.map(s => `
|
|
<div class="stats-enrich-item">
|
|
<span class="stats-enrich-name">${s.name}</span>
|
|
<div class="stats-enrich-bar"><div class="stats-enrich-fill" style="width:${s.pct}%;background:${s.color}"></div></div>
|
|
<span class="stats-enrich-pct">${s.pct}%</span>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
}
|
|
|
|
async function _loadDbStorageChart() {
|
|
try {
|
|
const resp = await fetch('/api/stats/db-storage');
|
|
const data = await resp.json();
|
|
if (!data.success || !data.tables || !data.tables.length) return;
|
|
_renderDbStorageChart(data.tables, data.total_file_size, data.method);
|
|
} catch (e) {
|
|
console.debug('DB storage chart load failed:', e);
|
|
}
|
|
}
|
|
|
|
async function _loadLibraryDiskUsage() {
|
|
try {
|
|
const resp = await fetch('/api/stats/library-disk-usage');
|
|
const data = await resp.json();
|
|
if (!data.success) return;
|
|
_renderLibraryDiskUsage(data);
|
|
} catch (e) {
|
|
console.debug('Library disk usage load failed:', e);
|
|
}
|
|
}
|
|
|
|
function _formatBytes(n) {
|
|
if (!n || n <= 0) return '0 B';
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
let i = 0;
|
|
let v = n;
|
|
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
|
return `${v.toFixed(v < 10 ? 2 : 1)} ${units[i]}`;
|
|
}
|
|
|
|
function _renderLibraryDiskUsage(data) {
|
|
const totalEl = document.getElementById('stats-disk-total-value');
|
|
const metaEl = document.getElementById('stats-disk-total-meta');
|
|
const formatsEl = document.getElementById('stats-disk-formats');
|
|
if (!totalEl || !metaEl || !formatsEl) return;
|
|
|
|
if (!data.has_data || !data.total_bytes) {
|
|
totalEl.textContent = '—';
|
|
metaEl.textContent = data.tracks_without_size > 0
|
|
? `Run a Deep Scan to populate (${data.tracks_without_size.toLocaleString()} tracks pending)`
|
|
: 'No tracks in library yet';
|
|
formatsEl.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
totalEl.textContent = _formatBytes(data.total_bytes);
|
|
|
|
const withSize = data.tracks_with_size || 0;
|
|
const withoutSize = data.tracks_without_size || 0;
|
|
const trackBits = `${withSize.toLocaleString()} tracks measured`;
|
|
const pendingBits = withoutSize > 0
|
|
? ` (+${withoutSize.toLocaleString()} pending next Deep Scan)`
|
|
: '';
|
|
metaEl.textContent = trackBits + pendingBits;
|
|
|
|
// Per-format bars sorted by size descending. Skip if no breakdown.
|
|
const formats = Object.entries(data.by_format || {}).sort((a, b) => b[1] - a[1]);
|
|
if (!formats.length) { formatsEl.innerHTML = ''; return; }
|
|
|
|
const max = formats[0][1] || 1;
|
|
formatsEl.innerHTML = formats.map(([ext, bytes]) => {
|
|
const pct = Math.max(2, Math.round((bytes / max) * 100));
|
|
return `
|
|
<div class="stats-disk-format-row">
|
|
<span class="stats-disk-format-name">${ext.toUpperCase()}</span>
|
|
<div class="stats-disk-format-bar">
|
|
<div class="stats-disk-format-fill" style="width:${pct}%"></div>
|
|
</div>
|
|
<span class="stats-disk-format-size">${_formatBytes(bytes)}</span>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function _renderDbStorageChart(tables, totalFileSize, method) {
|
|
const canvas = document.getElementById('stats-db-storage-chart');
|
|
if (!canvas || typeof Chart === 'undefined') return;
|
|
|
|
if (_statsDbStorageChart) _statsDbStorageChart.destroy();
|
|
|
|
// Top 8 tables, group rest as "Other"
|
|
const top = tables.slice(0, 8);
|
|
const rest = tables.slice(8);
|
|
const restSize = rest.reduce((s, t) => s + t.size, 0);
|
|
if (restSize > 0) top.push({ name: 'Other', size: restSize });
|
|
|
|
const colors = ['#3b82f6', '#f97316', '#a855f7', '#14b8a6', '#eab308', '#ec4899', '#6366f1', '#22c55e', '#555'];
|
|
|
|
_statsDbStorageChart = new Chart(canvas, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: top.map(t => t.name),
|
|
datasets: [{
|
|
data: top.map(t => t.size),
|
|
backgroundColor: colors.slice(0, top.length),
|
|
borderWidth: 0,
|
|
hoverOffset: 4,
|
|
}],
|
|
},
|
|
options: {
|
|
responsive: false,
|
|
cutout: '65%',
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: {
|
|
callbacks: {
|
|
label: (ctx) => {
|
|
const val = ctx.parsed;
|
|
if (method === 'dbstat') {
|
|
if (val > 1048576) return ` ${(val / 1048576).toFixed(1)} MB`;
|
|
return ` ${(val / 1024).toFixed(0)} KB`;
|
|
}
|
|
return ` ${val.toLocaleString()} rows`;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
},
|
|
});
|
|
|
|
// Center label — total file size
|
|
const totalEl = document.getElementById('stats-db-total');
|
|
if (totalEl) {
|
|
let sizeStr;
|
|
if (totalFileSize > 1073741824) sizeStr = (totalFileSize / 1073741824).toFixed(2) + ' GB';
|
|
else if (totalFileSize > 1048576) sizeStr = (totalFileSize / 1048576).toFixed(1) + ' MB';
|
|
else sizeStr = (totalFileSize / 1024).toFixed(0) + ' KB';
|
|
totalEl.innerHTML = `<div class="stats-db-total-value">${sizeStr}</div><div class="stats-db-total-label">Total Size</div>`;
|
|
}
|
|
|
|
// Legend
|
|
const legendEl = document.getElementById('stats-db-legend');
|
|
if (legendEl) {
|
|
legendEl.innerHTML = top.map((t, i) => {
|
|
let sizeLabel;
|
|
if (method === 'dbstat') {
|
|
if (t.size > 1048576) sizeLabel = (t.size / 1048576).toFixed(1) + ' MB';
|
|
else sizeLabel = (t.size / 1024).toFixed(0) + ' KB';
|
|
} else {
|
|
sizeLabel = t.size.toLocaleString() + ' rows';
|
|
}
|
|
return `<div class="stats-db-legend-item">
|
|
<span class="stats-db-legend-dot" style="background:${colors[i]}"></span>
|
|
<span class="stats-db-legend-name">${t.name}</span>
|
|
<span class="stats-db-legend-size">${sizeLabel}</span>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
}
|
|
|
|
async function playStatsTrack(title, artist, album) {
|
|
// 1. Try the library first — fastest and best quality if owned.
|
|
try {
|
|
const resp = await fetch('/api/stats/resolve-track', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title, artist }),
|
|
});
|
|
const data = await resp.json();
|
|
if (data.success && data.track) {
|
|
const t = data.track;
|
|
playLibraryTrack({
|
|
id: t.id,
|
|
title: t.title,
|
|
file_path: t.file_path,
|
|
bitrate: t.bitrate,
|
|
artist_id: t.artist_id,
|
|
album_id: t.album_id,
|
|
_stats_image: t.image_url || null,
|
|
}, t.album_title || album || '', t.artist_name || artist || '');
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
console.debug('Library resolve failed, will try streaming fallback:', e);
|
|
}
|
|
|
|
// 2. Library miss — fall back to streaming via the enhanced-search streamer
|
|
// (Soulseek → YouTube → other configured sources, same pipeline used by
|
|
// the search results' play button).
|
|
if (typeof showLoadingOverlay === 'function') {
|
|
showLoadingOverlay(`Searching for ${title}...`);
|
|
}
|
|
try {
|
|
const streamResp = await fetch('/api/enhanced-search/stream-track', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
track_name: title,
|
|
artist_name: artist,
|
|
album_name: album || '',
|
|
duration_ms: 0,
|
|
}),
|
|
});
|
|
const streamData = await streamResp.json();
|
|
if (typeof hideLoadingOverlay === 'function') hideLoadingOverlay();
|
|
|
|
if (streamData.success && streamData.result) {
|
|
if (typeof startStream === 'function') {
|
|
await startStream(streamData.result);
|
|
} else {
|
|
showToast('Streaming not available', 'error');
|
|
}
|
|
} else {
|
|
showToast(streamData.error || 'Track not found in library or any source', 'error');
|
|
}
|
|
} catch (e) {
|
|
if (typeof hideLoadingOverlay === 'function') hideLoadingOverlay();
|
|
showToast('Failed to play track', 'error');
|
|
console.error('Stream fallback failed:', e);
|
|
}
|
|
}
|
|
|
|
function _renderRecentPlays(tracks) {
|
|
const el = document.getElementById('stats-recent-plays');
|
|
if (!el) return;
|
|
|
|
if (!tracks.length) {
|
|
el.innerHTML = '<div style="color:rgba(255,255,255,0.3);font-size:0.85em;padding:12px;">No recent plays</div>';
|
|
return;
|
|
}
|
|
|
|
const _ago = (dateStr) => {
|
|
if (!dateStr) return '';
|
|
const diff = Date.now() - new Date(dateStr).getTime();
|
|
const mins = Math.floor(diff / 60000);
|
|
if (mins < 60) return `${mins}m ago`;
|
|
const hours = Math.floor(mins / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.floor(hours / 24);
|
|
if (days < 30) return `${days}d ago`;
|
|
return `${Math.floor(days / 30)}mo ago`;
|
|
};
|
|
|
|
el.innerHTML = tracks.map(t => `
|
|
<div class="stats-recent-item">
|
|
<button class="stats-play-btn stats-play-btn-sm" onclick="event.stopPropagation();playStatsTrack('${_esc(t.title).replace(/'/g, "\\'")}','${_esc(t.artist || '').replace(/'/g, "\\'")}','${_esc(t.album || '').replace(/'/g, "\\'")}')" title="Play">▶</button>
|
|
<span class="stats-recent-title">${_esc(t.title)}</span>
|
|
<span class="stats-recent-artist">${_esc(t.artist || '')}</span>
|
|
<span class="stats-recent-time">${_ago(t.played_at)}</span>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
// --- Initialization ---
|
|
|
|
function initializeImportPage() {
|
|
if (!importPageState.initialized) {
|
|
importPageState.initialized = true;
|
|
importPageRefreshStaging();
|
|
importPageLoadAutoGroups();
|
|
importPageLoadSuggestions();
|
|
}
|
|
}
|
|
|
|
async function importPageRefreshStaging() {
|
|
// Clear finished jobs from the queue
|
|
importPageClearFinishedJobs();
|
|
|
|
try {
|
|
const resp = await fetch('/api/import/staging/files');
|
|
const data = await resp.json();
|
|
if (!data.success) {
|
|
document.getElementById('import-page-staging-path').textContent = `Import folder: error`;
|
|
return;
|
|
}
|
|
|
|
importPageState.stagingFiles = data.files || [];
|
|
document.getElementById('import-page-staging-path').textContent = `Import: ${data.staging_path || 'Not configured'}`;
|
|
|
|
const totalSize = importPageState.stagingFiles.reduce((s, f) => s + (f.size || 0), 0);
|
|
const sizeStr = totalSize > 1073741824 ? `${(totalSize / 1073741824).toFixed(1)} GB`
|
|
: totalSize > 1048576 ? `${(totalSize / 1048576).toFixed(0)} MB`
|
|
: `${(totalSize / 1024).toFixed(0)} KB`;
|
|
document.getElementById('import-page-staging-stats').textContent =
|
|
`${importPageState.stagingFiles.length} file${importPageState.stagingFiles.length !== 1 ? 's' : ''}${totalSize ? ' · ' + sizeStr : ''}`;
|
|
|
|
// Refresh the current tab view after data is loaded
|
|
if (importPageState.activeTab === 'singles') {
|
|
importPageRenderSinglesList();
|
|
} else if (importPageState.activeTab === 'album') {
|
|
importPageLoadAutoGroups();
|
|
}
|
|
// Always refresh suggestions and groups in background
|
|
importPageLoadSuggestions();
|
|
} catch (err) {
|
|
console.error('Failed to refresh staging:', err);
|
|
}
|
|
}
|
|
|
|
function importPageSwitchTab(tab) {
|
|
importPageState.activeTab = tab;
|
|
document.getElementById('import-page-tab-album').classList.toggle('active', tab === 'album');
|
|
document.getElementById('import-page-tab-singles').classList.toggle('active', tab === 'singles');
|
|
document.getElementById('import-page-tab-auto')?.classList.toggle('active', tab === 'auto');
|
|
document.getElementById('import-page-album-content').classList.toggle('active', tab === 'album');
|
|
document.getElementById('import-page-singles-content')?.classList.toggle('active', tab === 'singles');
|
|
document.getElementById('import-page-auto-content')?.classList.toggle('active', tab === 'auto');
|
|
|
|
if (tab === 'singles' && importPageState.stagingFiles.length > 0) {
|
|
importPageRenderSinglesList();
|
|
}
|
|
if (tab === 'auto') {
|
|
_autoImportLoadStatus();
|
|
_autoImportLoadResults();
|
|
_autoImportStartPolling();
|
|
} else {
|
|
_autoImportStopPolling();
|
|
}
|
|
}
|
|
|
|
// ── Auto-Import Tab ──
|
|
let _autoImportPollInterval = null;
|
|
let _autoImportFilter = 'all';
|
|
let _autoImportLastStatus = null;
|
|
|
|
function _autoImportStartPolling() {
|
|
_autoImportStopPolling();
|
|
_autoImportPollInterval = setInterval(async () => {
|
|
if (importPageState.activeTab === 'auto') {
|
|
await _autoImportLoadStatus();
|
|
_autoImportLoadResults();
|
|
}
|
|
}, 5000);
|
|
}
|
|
|
|
function _autoImportStopPolling() {
|
|
if (_autoImportPollInterval) { clearInterval(_autoImportPollInterval); _autoImportPollInterval = null; }
|
|
}
|
|
|
|
async function _autoImportToggle(enabled) {
|
|
// Optimistically update toggle state so it doesn't flicker
|
|
const toggle = document.getElementById('auto-import-enabled');
|
|
if (toggle) toggle.checked = enabled;
|
|
const statusText = document.getElementById('auto-import-status-text');
|
|
if (statusText) statusText.textContent = enabled ? 'Starting...' : 'Stopping...';
|
|
|
|
try {
|
|
const res = await fetch('/api/auto-import/toggle', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ enabled })
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
showToast(enabled ? 'Auto-import enabled' : 'Auto-import disabled', 'success');
|
|
_autoImportLoadStatus();
|
|
} else {
|
|
// Revert on failure
|
|
if (toggle) toggle.checked = !enabled;
|
|
}
|
|
} catch (e) {
|
|
showToast('Error: ' + e.message, 'error');
|
|
if (toggle) toggle.checked = !enabled;
|
|
}
|
|
}
|
|
|
|
async function _autoImportLoadStatus() {
|
|
try {
|
|
const res = await fetch('/api/auto-import/status');
|
|
const data = await res.json();
|
|
if (!data.success) return;
|
|
_autoImportLastStatus = data;
|
|
|
|
const toggle = document.getElementById('auto-import-enabled');
|
|
const statusText = document.getElementById('auto-import-status-text');
|
|
const settingsRow = document.getElementById('auto-import-settings-row');
|
|
const scanNowBtn = document.getElementById('auto-import-scan-now');
|
|
const progressEl = document.getElementById('auto-import-progress');
|
|
const progressText = document.getElementById('auto-import-progress-text');
|
|
|
|
if (toggle) toggle.checked = data.running;
|
|
if (settingsRow) settingsRow.style.display = data.running ? '' : 'none';
|
|
if (scanNowBtn) scanNowBtn.style.display = data.running ? '' : 'none';
|
|
|
|
// Live scan + per-track processing progress
|
|
if (progressEl) {
|
|
if (data.current_status === 'processing') {
|
|
progressEl.style.display = '';
|
|
if (progressText) {
|
|
const idx = data.current_track_index || 0;
|
|
const total = data.current_track_total || 0;
|
|
const trackName = data.current_track_name || '';
|
|
const folder = data.current_folder || '...';
|
|
if (total > 0) {
|
|
progressText.textContent = `Processing ${folder} — track ${idx}/${total}: ${trackName}`;
|
|
} else {
|
|
progressText.textContent = `Processing: ${folder}`;
|
|
}
|
|
}
|
|
} else if (data.current_status === 'scanning') {
|
|
progressEl.style.display = '';
|
|
if (progressText) {
|
|
const stats = data.stats || {};
|
|
progressText.textContent = `Scanning: ${data.current_folder || '...'} (${stats.scanned || 0} processed)`;
|
|
}
|
|
} else {
|
|
progressEl.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
if (statusText) {
|
|
if (data.paused) statusText.textContent = 'Paused';
|
|
else if (data.current_status === 'processing') statusText.textContent = 'Processing...';
|
|
else if (data.current_status === 'scanning') statusText.textContent = 'Scanning...';
|
|
else if (data.running) {
|
|
// Show last scan time
|
|
let watchText = 'Watching';
|
|
if (data.last_scan_time) {
|
|
try {
|
|
const lastScan = new Date(data.last_scan_time);
|
|
const diffS = Math.floor((Date.now() - lastScan) / 1000);
|
|
if (diffS < 60) watchText = `Watching (scanned ${diffS}s ago)`;
|
|
else if (diffS < 3600) watchText = `Watching (scanned ${Math.floor(diffS / 60)}m ago)`;
|
|
} catch (e) {}
|
|
}
|
|
statusText.textContent = watchText;
|
|
} else statusText.textContent = 'Disabled';
|
|
const _runningClass = data.current_status === 'scanning'
|
|
? 'scanning'
|
|
: data.current_status === 'processing'
|
|
? 'processing'
|
|
: 'active';
|
|
statusText.className = 'auto-import-status ' + (data.running ? _runningClass : 'disabled');
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
async function _autoImportLoadResults() {
|
|
const container = document.getElementById('auto-import-results');
|
|
if (!container) return;
|
|
try {
|
|
const res = await fetch('/api/auto-import/results?limit=100');
|
|
const data = await res.json();
|
|
if (!data.success || !data.results || data.results.length === 0) {
|
|
if (!container.querySelector('.auto-import-card')) {
|
|
container.innerHTML = `<div class="auto-import-empty">
|
|
<p>No imports yet. Drop album folders or single tracks into your import folder.</p>
|
|
</div>`;
|
|
}
|
|
// Hide stats and filters
|
|
const statsEl = document.getElementById('auto-import-stats');
|
|
const filtersEl = document.getElementById('auto-import-filters');
|
|
if (statsEl) statsEl.style.display = 'none';
|
|
if (filtersEl) filtersEl.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
// Compute stats
|
|
const allResults = data.results;
|
|
const importedCount = allResults.filter(r => r.status === 'completed' || r.status === 'approved').length;
|
|
const reviewCount = allResults.filter(r => r.status === 'pending_review').length;
|
|
const failedCount = allResults.filter(r => r.status === 'failed' || r.status === 'needs_identification').length;
|
|
|
|
// Update stats
|
|
const statsEl = document.getElementById('auto-import-stats');
|
|
if (statsEl) {
|
|
statsEl.style.display = '';
|
|
document.getElementById('auto-import-stat-imported').textContent = `${importedCount} imported`;
|
|
document.getElementById('auto-import-stat-review').textContent = `${reviewCount} review`;
|
|
document.getElementById('auto-import-stat-failed').textContent = `${failedCount} failed`;
|
|
}
|
|
|
|
// Show filters
|
|
const filtersEl = document.getElementById('auto-import-filters');
|
|
if (filtersEl) {
|
|
filtersEl.style.display = '';
|
|
// Show batch action buttons when applicable
|
|
const approveAllBtn = document.getElementById('auto-import-approve-all');
|
|
const clearBtn = document.getElementById('auto-import-clear-completed');
|
|
if (approveAllBtn) approveAllBtn.style.display = reviewCount > 0 ? '' : 'none';
|
|
if (clearBtn) clearBtn.style.display = (importedCount + failedCount) > 0 ? '' : 'none';
|
|
}
|
|
|
|
// Apply filter
|
|
let filtered = allResults;
|
|
if (_autoImportFilter === 'pending') filtered = allResults.filter(r => r.status === 'pending_review');
|
|
else if (_autoImportFilter === 'imported') filtered = allResults.filter(r => r.status === 'completed' || r.status === 'approved');
|
|
else if (_autoImportFilter === 'failed') filtered = allResults.filter(r => r.status === 'failed' || r.status === 'needs_identification');
|
|
|
|
if (filtered.length === 0) {
|
|
const filterName = _autoImportFilter === 'pending' ? 'pending review' : _autoImportFilter;
|
|
container.innerHTML = `<div class="auto-import-empty"><p>No ${filterName} items.</p></div>`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = filtered.map((r, idx) => {
|
|
const confPct = Math.round((r.confidence || 0) * 100);
|
|
const confClass = confPct >= 90 ? 'high' : confPct >= 70 ? 'medium' : 'low';
|
|
const statusLabels = {
|
|
'completed': 'Imported', 'pending_review': 'Needs Review',
|
|
'needs_identification': 'Unidentified', 'failed': 'Failed',
|
|
'scanning': 'Scanning...', 'matched': 'Matched',
|
|
'rejected': 'Dismissed', 'approved': 'Approved',
|
|
'processing': 'Processing',
|
|
};
|
|
const statusIcons = {
|
|
'completed': '\u2713', 'pending_review': '\u26A0',
|
|
'needs_identification': '\u2717', 'failed': '\u2717',
|
|
'scanning': '\u231B', 'matched': '\u2713',
|
|
'rejected': '\u2715', 'approved': '\u2713',
|
|
'processing': '\u29D7',
|
|
};
|
|
const statusLabel = statusLabels[r.status] || r.status;
|
|
const statusIcon = statusIcons[r.status] || '';
|
|
const statusClass = r.status === 'completed' ? 'completed' : r.status === 'pending_review' ? 'review' :
|
|
r.status === 'failed' || r.status === 'needs_identification' ? 'failed' :
|
|
r.status === 'processing' ? 'processing' : 'neutral';
|
|
|
|
// Live per-track progress for the row currently being processed.
|
|
// Match by folder_name since the worker only tracks one folder at a time.
|
|
const liveStatus = _autoImportLastStatus;
|
|
const isLiveProcessing = r.status === 'processing'
|
|
&& liveStatus && liveStatus.current_status === 'processing'
|
|
&& liveStatus.current_folder === r.folder_name;
|
|
const liveTrackIdx = isLiveProcessing ? (liveStatus.current_track_index || 0) : 0;
|
|
const liveTrackTotal = isLiveProcessing ? (liveStatus.current_track_total || 0) : 0;
|
|
const liveTrackName = isLiveProcessing ? (liveStatus.current_track_name || '') : '';
|
|
|
|
// Parse match data for track details
|
|
let matchCount = 0, totalTracks = 0, trackDetails = [];
|
|
if (r.match_data) {
|
|
try {
|
|
const md = typeof r.match_data === 'string' ? JSON.parse(r.match_data) : r.match_data;
|
|
matchCount = md.matched_count || 0;
|
|
totalTracks = md.total_tracks || 0;
|
|
if (md.matches) {
|
|
trackDetails = md.matches.map(m => ({
|
|
name: m.track_name || m.track?.name || 'Unknown',
|
|
file: m.file ? m.file.split(/[/\\]/).pop() : '?',
|
|
confidence: Math.round((m.confidence || 0) * 100),
|
|
}));
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
let matchSummary = totalTracks > 0 ? `${matchCount}/${totalTracks} tracks` : `${r.total_files} files`;
|
|
if (isLiveProcessing && liveTrackTotal > 0) {
|
|
matchSummary = `track ${liveTrackIdx}/${liveTrackTotal}: ${liveTrackName}`;
|
|
}
|
|
const methodLabels = { tags: 'Tags', folder_name: 'Folder Name', acoustid: 'AcoustID', filename: 'Filename' };
|
|
const methodLabel = methodLabels[r.identification_method] || r.identification_method || '';
|
|
|
|
// Time ago
|
|
let timeAgo = '';
|
|
if (r.created_at) {
|
|
try {
|
|
const d = new Date(r.created_at);
|
|
const diffM = Math.floor((Date.now() - d) / 60000);
|
|
if (diffM < 1) timeAgo = 'just now';
|
|
else if (diffM < 60) timeAgo = `${diffM}m ago`;
|
|
else if (diffM < 1440) timeAgo = `${Math.floor(diffM / 60)}h ago`;
|
|
else timeAgo = `${Math.floor(diffM / 1440)}d ago`;
|
|
} catch (e) {}
|
|
}
|
|
|
|
let actions = '';
|
|
if (r.status === 'pending_review') {
|
|
actions = `<div class="auto-import-actions">
|
|
<button class="watchlist-action-btn watchlist-action-primary" onclick="event.stopPropagation(); _autoImportApprove(${r.id})">Approve & Import</button>
|
|
<button class="watchlist-action-btn watchlist-action-secondary" onclick="event.stopPropagation(); _autoImportReject(${r.id})">Dismiss</button>
|
|
</div>`;
|
|
}
|
|
|
|
// Expanded track list (hidden by default)
|
|
let trackListHtml = '';
|
|
if (trackDetails.length > 0) {
|
|
trackListHtml = `<div class="auto-import-track-list" id="auto-import-tracks-${idx}">
|
|
<div class="auto-import-track-list-header">
|
|
<span>Track</span><span>Matched File</span><span>Conf</span>
|
|
</div>
|
|
${trackDetails.map((t, tIdx) => {
|
|
const tConfClass = t.confidence >= 90 ? 'high' : t.confidence >= 70 ? 'medium' : 'low';
|
|
// 1-based liveTrackIdx — current row glows, prior rows dim as "done".
|
|
let rowState = '';
|
|
if (isLiveProcessing && liveTrackIdx > 0) {
|
|
if (tIdx + 1 === liveTrackIdx) rowState = ' auto-import-track-row-active';
|
|
else if (tIdx + 1 < liveTrackIdx) rowState = ' auto-import-track-row-done';
|
|
}
|
|
return `<div class="auto-import-track-row${rowState}">
|
|
<span class="auto-import-track-name">${escapeHtml(t.name)}</span>
|
|
<span class="auto-import-track-file">${escapeHtml(t.file)}</span>
|
|
<span class="auto-import-track-conf auto-import-conf-${tConfClass}">${t.confidence}%</span>
|
|
</div>`;
|
|
}).join('')}
|
|
</div>`;
|
|
}
|
|
|
|
return `<div class="auto-import-card auto-import-${statusClass}" onclick="_autoImportToggleDetail(${idx})" style="cursor:pointer">
|
|
<div class="auto-import-card-top">
|
|
<div class="auto-import-card-left">
|
|
${r.image_url ? `<img class="auto-import-card-art" src="${r.image_url}" alt="" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"><div class="auto-import-card-art-fallback" style="display:none">\uD83D\uDCBF</div>` : `<div class="auto-import-card-art-fallback">\uD83D\uDCBF</div>`}
|
|
</div>
|
|
<div class="auto-import-card-center">
|
|
<div class="auto-import-card-album">${escapeHtml(r.album_name || r.folder_name)}</div>
|
|
<div class="auto-import-card-artist">${escapeHtml(r.artist_name || 'Unknown Artist')}</div>
|
|
<div class="auto-import-card-meta">
|
|
<span>${matchSummary}</span>
|
|
${methodLabel ? `<span class="auto-import-method-badge">${methodLabel}</span>` : ''}
|
|
${timeAgo ? `<span>${timeAgo}</span>` : ''}
|
|
</div>
|
|
${r.error_message ? `<div class="auto-import-card-error">${escapeHtml(r.error_message)}</div>` : ''}
|
|
</div>
|
|
<div class="auto-import-card-right">
|
|
<div class="auto-import-status-badge auto-import-badge-${statusClass}">${statusIcon} ${statusLabel}</div>
|
|
<div class="auto-import-confidence-bar">
|
|
<div class="auto-import-confidence-fill auto-import-conf-${confClass}" style="width:${confPct}%"></div>
|
|
</div>
|
|
<div class="auto-import-confidence-text">${confPct}% confidence</div>
|
|
${actions}
|
|
</div>
|
|
</div>
|
|
<div class="auto-import-card-folder-path">${escapeHtml(r.folder_name)}</div>
|
|
${trackListHtml}
|
|
</div>`;
|
|
}).join('');
|
|
|
|
} catch (e) {}
|
|
}
|
|
|
|
async function _autoImportSaveSettings() {
|
|
const confidence = (document.getElementById('auto-import-confidence')?.value || 90) / 100;
|
|
const interval = parseInt(document.getElementById('auto-import-interval')?.value || 60);
|
|
try {
|
|
await fetch('/api/auto-import/settings', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ confidence_threshold: confidence, scan_interval: interval })
|
|
});
|
|
showToast('Settings saved', 'success');
|
|
} catch (e) { showToast('Error', 'error'); }
|
|
}
|
|
|
|
function _autoImportSetFilter(filter) {
|
|
_autoImportFilter = filter;
|
|
document.querySelectorAll('#auto-import-filters .adl-pill').forEach(p =>
|
|
p.classList.toggle('active', p.dataset.filter === filter));
|
|
_autoImportLoadResults();
|
|
}
|
|
|
|
async function _autoImportScanNow() {
|
|
try {
|
|
const res = await fetch('/api/auto-import/scan-now', { method: 'POST' });
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
showToast('Scan triggered', 'success');
|
|
_autoImportLoadStatus();
|
|
} else {
|
|
showToast(data.error || 'Failed to trigger scan', 'error');
|
|
}
|
|
} catch (e) { showToast('Error: ' + e.message, 'error'); }
|
|
}
|
|
|
|
async function _autoImportApproveAll() {
|
|
const confirmed = await showConfirmDialog({
|
|
title: 'Approve All',
|
|
message: 'Approve and import all pending review items?',
|
|
confirmText: 'Approve All',
|
|
});
|
|
if (!confirmed) return;
|
|
try {
|
|
const res = await fetch('/api/auto-import/approve-all', { method: 'POST' });
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
showToast(`Approved ${data.count || 0} items`, 'success');
|
|
_autoImportLoadResults();
|
|
} else {
|
|
showToast(data.error || 'Failed', 'error');
|
|
}
|
|
} catch (e) { showToast('Error: ' + e.message, 'error'); }
|
|
}
|
|
|
|
async function _autoImportClearCompleted() {
|
|
try {
|
|
const res = await fetch('/api/auto-import/clear-completed', { method: 'POST' });
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
showToast(`Cleared ${data.count || 0} imported items`, 'success');
|
|
_autoImportLoadResults();
|
|
} else {
|
|
showToast(data.error || 'Failed', 'error');
|
|
}
|
|
} catch (e) { showToast('Error: ' + e.message, 'error'); }
|
|
}
|
|
|
|
function _autoImportToggleDetail(idx) {
|
|
const trackList = document.getElementById(`auto-import-tracks-${idx}`);
|
|
if (trackList) {
|
|
trackList.classList.toggle('expanded');
|
|
}
|
|
}
|
|
window._autoImportToggleDetail = _autoImportToggleDetail;
|
|
window._autoImportSetFilter = _autoImportSetFilter;
|
|
window._autoImportScanNow = _autoImportScanNow;
|
|
window._autoImportApproveAll = _autoImportApproveAll;
|
|
window._autoImportClearCompleted = _autoImportClearCompleted;
|
|
|
|
async function _autoImportApprove(id) {
|
|
try {
|
|
const res = await fetch(`/api/auto-import/approve/${id}`, { method: 'POST' });
|
|
const data = await res.json();
|
|
if (data.success) { showToast('Approved', 'success'); _autoImportLoadResults(); }
|
|
else showToast(data.error || 'Failed', 'error');
|
|
} catch (e) { showToast('Error', 'error'); }
|
|
}
|
|
|
|
async function _autoImportReject(id) {
|
|
try {
|
|
const res = await fetch(`/api/auto-import/reject/${id}`, { method: 'POST' });
|
|
const data = await res.json();
|
|
if (data.success) { showToast('Dismissed', 'success'); _autoImportLoadResults(); }
|
|
else showToast(data.error || 'Failed', 'error');
|
|
} catch (e) { showToast('Error', 'error'); }
|
|
}
|
|
|
|
// --- Album Tab: Auto-Detected Groups (from file tags) ---
|
|
|
|
async function importPageLoadAutoGroups() {
|
|
const grid = document.getElementById('import-page-suggestions-grid');
|
|
if (!grid) return;
|
|
|
|
try {
|
|
const resp = await fetch('/api/import/staging/groups');
|
|
if (!resp.ok) return;
|
|
const data = await resp.json();
|
|
|
|
if (!data.success || !data.groups || data.groups.length === 0) return;
|
|
|
|
// Build auto-groups section above suggestions
|
|
let groupsContainer = document.getElementById('import-page-auto-groups');
|
|
if (!groupsContainer) {
|
|
groupsContainer = document.createElement('div');
|
|
groupsContainer.id = 'import-page-auto-groups';
|
|
groupsContainer.style.marginBottom = '16px';
|
|
const suggestionsSection = document.getElementById('import-page-suggestions');
|
|
if (suggestionsSection) {
|
|
suggestionsSection.parentNode.insertBefore(groupsContainer, suggestionsSection);
|
|
} else {
|
|
grid.parentNode.insertBefore(groupsContainer, grid);
|
|
}
|
|
}
|
|
|
|
groupsContainer.innerHTML = `
|
|
<div style="font-size:0.82em;font-weight:600;letter-spacing:0.05em;text-transform:uppercase;color:rgba(255,255,255,0.5);margin-bottom:10px;">
|
|
Auto-Detected Albums
|
|
</div>
|
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:10px;">
|
|
${data.groups.map((g, idx) => `
|
|
<div class="import-page-album-card" style="cursor:pointer;display:flex;align-items:center;gap:12px;padding:12px;border-radius:10px;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06);transition:all 0.2s;"
|
|
onmouseenter="this.style.borderColor='rgba(255,255,255,0.12)';this.style.background='rgba(255,255,255,0.05)'"
|
|
onmouseleave="this.style.borderColor='rgba(255,255,255,0.06)';this.style.background='rgba(255,255,255,0.03)'"
|
|
onclick="importPageMatchAutoGroup(${idx})">
|
|
<div style="width:48px;height:48px;border-radius:8px;background:rgba(var(--accent-rgb,29,185,84),0.15);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:1.2em;">
|
|
${g.file_count}
|
|
</div>
|
|
<div style="min-width:0;">
|
|
<div style="font-size:0.92em;font-weight:500;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="${_escAttr(g.album)}">${_esc(g.album)}</div>
|
|
<div style="font-size:0.8em;color:rgba(255,255,255,0.5);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="${_escAttr(g.artist)}">${_esc(g.artist)} · ${g.file_count} tracks</div>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
|
|
// Store groups for click handler
|
|
importPageState._autoGroups = data.groups;
|
|
} catch (err) {
|
|
console.warn('Failed to load auto-groups:', err);
|
|
}
|
|
}
|
|
|
|
async function importPageMatchAutoGroup(groupIdx) {
|
|
const group = importPageState._autoGroups?.[groupIdx];
|
|
if (!group) return;
|
|
|
|
// Search for the album by name + artist
|
|
const query = `${group.artist} ${group.album}`;
|
|
const searchInput = document.getElementById('import-page-album-search-input');
|
|
if (searchInput) searchInput.value = query;
|
|
|
|
// Hide suggestions/groups, show search results
|
|
const suggestionsEl = document.getElementById('import-page-suggestions');
|
|
const groupsEl = document.getElementById('import-page-auto-groups');
|
|
if (suggestionsEl) suggestionsEl.style.display = 'none';
|
|
if (groupsEl) groupsEl.style.display = 'none';
|
|
|
|
const grid = document.getElementById('import-page-album-results');
|
|
if (grid) grid.innerHTML = '<div style="color:#888;text-align:center;padding:20px;">Searching...</div>';
|
|
|
|
try {
|
|
const resp = await fetch(`/api/import/search/albums?q=${encodeURIComponent(query)}&limit=12`);
|
|
const data = await resp.json();
|
|
|
|
if (data.success && data.albums && data.albums.length > 0) {
|
|
// Store file_paths filter so match only includes this group's files
|
|
importPageState._autoGroupFilePaths = group.file_paths;
|
|
|
|
// Render results — user picks the right album
|
|
grid.innerHTML = data.albums.map(a => _renderSuggestionCard(a)).join('');
|
|
} else {
|
|
grid.innerHTML = '<div style="color:#888;text-align:center;padding:20px;">No albums found — try searching manually</div>';
|
|
}
|
|
} catch (err) {
|
|
console.error('Auto-group search failed:', err);
|
|
if (grid) grid.innerHTML = '<div style="color:#888;text-align:center;padding:20px;">Search failed</div>';
|
|
}
|
|
}
|
|
|
|
// --- Album Tab: Suggestions (server-side cache, just fetch and render) ---
|
|
|
|
async function importPageLoadSuggestions() {
|
|
const section = document.getElementById('import-page-suggestions');
|
|
const grid = document.getElementById('import-page-suggestions-grid');
|
|
if (!section || !grid) return;
|
|
|
|
try {
|
|
const resp = await fetch('/api/import/staging/suggestions');
|
|
if (!resp.ok) return;
|
|
const data = await resp.json();
|
|
|
|
if (!data.success || !data.suggestions || data.suggestions.length === 0) {
|
|
if (!data.ready) {
|
|
// Server is still building cache — show placeholder, retry shortly
|
|
section.style.display = '';
|
|
grid.innerHTML = '<div style="color:#888;font-size:13px;padding:8px;">Loading suggestions...</div>';
|
|
setTimeout(() => importPageLoadSuggestions(), 3000);
|
|
} else {
|
|
section.style.display = 'none';
|
|
grid.innerHTML = '';
|
|
}
|
|
return;
|
|
}
|
|
|
|
section.style.display = '';
|
|
grid.innerHTML = data.suggestions.map(a => _renderSuggestionCard(a)).join('');
|
|
} catch (err) {
|
|
// Network error or server not ready — fail silently
|
|
console.warn('Failed to load import suggestions:', err);
|
|
}
|
|
}
|
|
|
|
function _renderSuggestionCard(a) {
|
|
return `<div class="import-page-album-card" onclick="importPageSelectAlbum('${a.id}')">
|
|
<img src="${a.image_url || '/static/placeholder.png'}" alt="${_escAttr(a.name)}" loading="lazy" onerror="this.src='/static/placeholder.png'">
|
|
<div class="import-page-album-card-title" title="${_escAttr(a.name)}">${_esc(a.name)}</div>
|
|
<div class="import-page-album-card-artist" title="${_escAttr(a.artist)}">${_esc(a.artist)}</div>
|
|
<div class="import-page-album-card-meta">${a.total_tracks} tracks · ${a.release_date ? a.release_date.substring(0, 4) : ''}</div>
|
|
</div>`;
|
|
}
|
|
|
|
// --- Album Tab: Search ---
|
|
|
|
async function importPageSearchAlbum() {
|
|
const query = document.getElementById('import-page-album-search-input').value.trim();
|
|
if (!query) return;
|
|
|
|
document.getElementById('import-page-suggestions').style.display = 'none';
|
|
const groupsEl = document.getElementById('import-page-auto-groups');
|
|
if (groupsEl) groupsEl.style.display = 'none';
|
|
const grid = document.getElementById('import-page-album-results');
|
|
grid.innerHTML = '<div style="color:#888;text-align:center;padding:20px;">Searching...</div>';
|
|
|
|
try {
|
|
const resp = await fetch(`/api/import/search/albums?q=${encodeURIComponent(query)}&limit=12`);
|
|
const data = await resp.json();
|
|
if (!data.success || !data.albums.length) {
|
|
grid.innerHTML = '<div style="color:#888;text-align:center;padding:20px;">No albums found</div>';
|
|
return;
|
|
}
|
|
grid.innerHTML = data.albums.map(a => `
|
|
<div class="import-page-album-card" onclick="importPageSelectAlbum('${a.id}')">
|
|
<img src="${a.image_url || '/static/placeholder.png'}" alt="${_escAttr(a.name)}" loading="lazy" onerror="this.src='/static/placeholder.png'">
|
|
<div class="import-page-album-card-title" title="${_escAttr(a.name)}">${_esc(a.name)}</div>
|
|
<div class="import-page-album-card-artist" title="${_escAttr(a.artist)}">${_esc(a.artist)}</div>
|
|
<div class="import-page-album-card-meta">${a.total_tracks} tracks · ${a.release_date ? a.release_date.substring(0, 4) : ''}</div>
|
|
</div>
|
|
`).join('');
|
|
document.getElementById('import-page-album-clear-btn').classList.remove('hidden');
|
|
} catch (err) {
|
|
grid.innerHTML = `<div style="color:#ef4444;text-align:center;padding:20px;">Error: ${err.message}</div>`;
|
|
}
|
|
}
|
|
|
|
// --- Album Tab: Select Album & Match ---
|
|
|
|
async function importPageSelectAlbum(albumId) {
|
|
document.getElementById('import-page-album-search-section').classList.add('hidden');
|
|
document.getElementById('import-page-album-match-section').classList.remove('hidden');
|
|
|
|
const matchList = document.getElementById('import-page-match-list');
|
|
matchList.innerHTML = '<div style="color:#888;text-align:center;padding:20px;">Matching files to tracklist...</div>';
|
|
|
|
try {
|
|
// Include file_paths filter if matching from an auto-group
|
|
const matchBody = { album_id: albumId };
|
|
if (importPageState._autoGroupFilePaths) {
|
|
matchBody.file_paths = importPageState._autoGroupFilePaths;
|
|
importPageState._autoGroupFilePaths = null; // clear after use
|
|
}
|
|
const resp = await fetch('/api/import/album/match', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(matchBody)
|
|
});
|
|
const data = await resp.json();
|
|
if (!data.success) {
|
|
matchList.innerHTML = `<div style="color:#ef4444;padding:20px;">Error: ${data.error}</div>`;
|
|
return;
|
|
}
|
|
|
|
importPageState.albumData = data;
|
|
importPageState.matchOverrides = {};
|
|
|
|
// Render hero
|
|
const album = data.album;
|
|
document.getElementById('import-page-album-hero').innerHTML = `
|
|
<img src="${album.image_url || '/static/placeholder.png'}" alt="${_escAttr(album.name)}" loading="lazy" onerror="this.src='/static/placeholder.png'">
|
|
<div class="import-page-album-hero-info">
|
|
<div class="import-page-album-hero-title">${_esc(album.name)}</div>
|
|
<div class="import-page-album-hero-artist">${_esc(album.artist)}</div>
|
|
<div class="import-page-album-hero-meta">${album.total_tracks} tracks · ${album.release_date ? album.release_date.substring(0, 4) : ''}</div>
|
|
</div>
|
|
`;
|
|
|
|
importPageRenderMatchList();
|
|
} catch (err) {
|
|
matchList.innerHTML = `<div style="color:#ef4444;padding:20px;">Error: ${err.message}</div>`;
|
|
}
|
|
}
|
|
|
|
function importPageRenderMatchList() {
|
|
const data = importPageState.albumData;
|
|
if (!data) return;
|
|
|
|
const matchList = document.getElementById('import-page-match-list');
|
|
const overrides = importPageState.matchOverrides;
|
|
|
|
// Build effective matches: auto-match overridden by manual overrides
|
|
// Also track which staging files are used (auto or override)
|
|
const usedStagingFiles = new Set();
|
|
|
|
// First pass: collect overridden indices
|
|
Object.values(overrides).forEach(sfIdx => usedStagingFiles.add(sfIdx));
|
|
|
|
// Build rows
|
|
let matchedCount = 0;
|
|
const rows = data.matches.map((m, idx) => {
|
|
const trackInfo = _importPageGetTrackDisplayInfo(m, idx);
|
|
let file = null;
|
|
let confidence = m.confidence;
|
|
let isOverride = false;
|
|
|
|
if (overrides.hasOwnProperty(idx)) {
|
|
const sfIdx = overrides[idx];
|
|
if (sfIdx === -1) {
|
|
// Forcibly unmatched — no file
|
|
file = null;
|
|
} else {
|
|
// Manual override
|
|
file = importPageState.stagingFiles[sfIdx] || null;
|
|
confidence = 1.0;
|
|
isOverride = true;
|
|
usedStagingFiles.add(sfIdx);
|
|
}
|
|
} else if (m.staging_file) {
|
|
file = m.staging_file;
|
|
// Check if this file was reassigned to another track via override
|
|
const autoFileName = m.staging_file.filename;
|
|
const reassigned = Object.entries(overrides).some(([tIdx, sfIdx]) => {
|
|
const sf = importPageState.stagingFiles[sfIdx];
|
|
return sf && sf.filename === autoFileName && parseInt(tIdx) !== idx;
|
|
});
|
|
if (!reassigned) {
|
|
usedStagingFiles.add(-1); // placeholder — auto-matched file
|
|
} else {
|
|
file = null; // file was reassigned elsewhere
|
|
}
|
|
}
|
|
|
|
if (file) matchedCount++;
|
|
const confPercent = Math.round(confidence * 100);
|
|
const confClass = confidence >= 0.7 ? '' : 'low';
|
|
|
|
return `
|
|
<div class="import-page-match-row ${file ? 'matched' : ''}"
|
|
ondragover="importPageHandleDragOver(event)" ondragleave="this.classList.remove('drag-over')" ondrop="importPageHandleDrop(event, ${idx})"
|
|
onclick="importPageTapAssign(${idx})">
|
|
<span class="import-page-match-num">${trackInfo.displayTrackNumber}</span>
|
|
<span class="import-page-match-track">${_esc(trackInfo.name)}</span>
|
|
<span class="import-page-match-file ${file ? 'has-file' : ''}">
|
|
${file
|
|
? `<span class="import-page-match-file-name">${_esc(file.filename)}</span>
|
|
<span class="import-page-match-confidence ${confClass}">${confPercent}%</span>`
|
|
: `<span class="import-page-match-drop-zone">Drop a file here</span>`}
|
|
</span>
|
|
<span>${file ? `<button class="import-page-match-unmatch" onclick="event.stopPropagation(); importPageUnmatchTrack(${idx})">✕</button>` : ''}</span>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
matchList.innerHTML = rows.join('');
|
|
|
|
// Unmatched file pool
|
|
const unmatchedFiles = [];
|
|
importPageState.stagingFiles.forEach((f, i) => {
|
|
// Check if used by override
|
|
if (Object.values(overrides).includes(i)) return;
|
|
// Check if used by auto-match (not overridden away)
|
|
const autoUsed = data.matches.some((m, mIdx) => {
|
|
if (overrides.hasOwnProperty(mIdx)) return false;
|
|
return m.staging_file && m.staging_file.filename === f.filename;
|
|
});
|
|
if (autoUsed) return;
|
|
unmatchedFiles.push({ file: f, index: i });
|
|
});
|
|
|
|
const poolChips = document.getElementById('import-page-pool-chips');
|
|
document.getElementById('import-page-unmatched-count').textContent = unmatchedFiles.length;
|
|
|
|
if (unmatchedFiles.length === 0) {
|
|
poolChips.innerHTML = '<span class="import-page-pool-empty">All files matched</span>';
|
|
} else {
|
|
poolChips.innerHTML = unmatchedFiles.map(({ file, index }) => `
|
|
<span class="import-page-file-chip ${importPageState.tapSelectedChip === index ? 'selected' : ''}"
|
|
draggable="true" ondragstart="importPageStartDrag(event, ${index})"
|
|
onclick="event.stopPropagation(); importPageTapSelectChip(${index})">
|
|
${_esc(file.filename)}
|
|
</span>
|
|
`).join('');
|
|
}
|
|
|
|
// Stats & button
|
|
document.getElementById('import-page-match-stats').textContent = `${matchedCount} of ${data.matches.length} tracks matched`;
|
|
const processBtn = document.getElementById('import-page-album-process-btn');
|
|
processBtn.disabled = matchedCount === 0;
|
|
processBtn.textContent = `Process ${matchedCount} Track${matchedCount !== 1 ? 's' : ''}`;
|
|
}
|
|
|
|
function _importPageGetTrackDisplayInfo(item, index) {
|
|
const track = item?.track || item?.spotify_track || {};
|
|
const rawTrackNumber = track.track_number ?? track.trackNumber ?? null;
|
|
const trackNumber = rawTrackNumber === null || rawTrackNumber === undefined || rawTrackNumber === ''
|
|
? null
|
|
: String(rawTrackNumber).split('/')[0].trim();
|
|
|
|
return {
|
|
track,
|
|
name: track.name || track.title || `Track ${index + 1}`,
|
|
trackNumber,
|
|
displayTrackNumber: trackNumber || String(index + 1),
|
|
};
|
|
}
|
|
|
|
// --- Album Tab: Drag and Drop ---
|
|
|
|
function importPageStartDrag(event, stagingFileIndex) {
|
|
event.dataTransfer.setData('text/plain', stagingFileIndex.toString());
|
|
event.dataTransfer.effectAllowed = 'move';
|
|
}
|
|
|
|
function importPageHandleDragOver(event) {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = 'move';
|
|
event.currentTarget.classList.add('drag-over');
|
|
// Remove drag-over from others
|
|
document.querySelectorAll('.import-page-match-row.drag-over').forEach(el => {
|
|
if (el !== event.currentTarget) el.classList.remove('drag-over');
|
|
});
|
|
}
|
|
|
|
function importPageHandleDrop(event, trackIndex) {
|
|
event.preventDefault();
|
|
event.currentTarget.classList.remove('drag-over');
|
|
const stagingFileIndex = parseInt(event.dataTransfer.getData('text/plain'));
|
|
if (isNaN(stagingFileIndex)) return;
|
|
|
|
// Remove this staging file from any other track it was assigned to
|
|
Object.keys(importPageState.matchOverrides).forEach(k => {
|
|
if (importPageState.matchOverrides[k] === stagingFileIndex) {
|
|
delete importPageState.matchOverrides[k];
|
|
}
|
|
});
|
|
|
|
importPageState.matchOverrides[trackIndex] = stagingFileIndex;
|
|
importPageState.tapSelectedChip = null;
|
|
importPageRenderMatchList();
|
|
}
|
|
|
|
// Mobile tap-to-assign fallback
|
|
function importPageTapSelectChip(stagingFileIndex) {
|
|
if (importPageState.tapSelectedChip === stagingFileIndex) {
|
|
importPageState.tapSelectedChip = null;
|
|
} else {
|
|
importPageState.tapSelectedChip = stagingFileIndex;
|
|
}
|
|
importPageRenderMatchList();
|
|
}
|
|
|
|
function importPageTapAssign(trackIndex) {
|
|
if (importPageState.tapSelectedChip === null) return;
|
|
const stagingFileIndex = importPageState.tapSelectedChip;
|
|
|
|
// Remove from any other track
|
|
Object.keys(importPageState.matchOverrides).forEach(k => {
|
|
if (importPageState.matchOverrides[k] === stagingFileIndex) {
|
|
delete importPageState.matchOverrides[k];
|
|
}
|
|
});
|
|
|
|
importPageState.matchOverrides[trackIndex] = stagingFileIndex;
|
|
importPageState.tapSelectedChip = null;
|
|
importPageRenderMatchList();
|
|
}
|
|
|
|
function importPageUnmatchTrack(trackIndex) {
|
|
delete importPageState.matchOverrides[trackIndex];
|
|
// Also remove auto-match by setting override to -1 special value? No — just delete override and let auto-match stay.
|
|
// Actually, to truly unmatch: we need to suppress the auto-match too.
|
|
// We'll use a sentinel: override = -1 means "forcibly unmatched"
|
|
const m = importPageState.albumData?.matches[trackIndex];
|
|
if (m && m.staging_file) {
|
|
importPageState.matchOverrides[trackIndex] = -1; // sentinel: force no match
|
|
}
|
|
importPageRenderMatchList();
|
|
}
|
|
|
|
function importPageAutoRematch() {
|
|
importPageState.matchOverrides = {};
|
|
importPageState.tapSelectedChip = null;
|
|
importPageRenderMatchList();
|
|
}
|
|
|
|
// --- Album Tab: Process ---
|
|
|
|
function importPageProcessAlbum() {
|
|
const data = importPageState.albumData;
|
|
if (!data) return;
|
|
|
|
// Build effective matches with overrides applied
|
|
const overrides = importPageState.matchOverrides;
|
|
const effectiveMatches = [];
|
|
data.matches.forEach((m, idx) => {
|
|
if (overrides.hasOwnProperty(idx)) {
|
|
if (overrides[idx] === -1) return; // forcibly unmatched — skip
|
|
const sf = importPageState.stagingFiles[overrides[idx]];
|
|
effectiveMatches.push({ ...m, staging_file: sf, confidence: 1.0 });
|
|
} else if (m.staging_file !== null) {
|
|
effectiveMatches.push(m);
|
|
}
|
|
});
|
|
|
|
if (effectiveMatches.length === 0) return;
|
|
|
|
// Add to queue and reset search immediately so user can queue more
|
|
const album = data.album;
|
|
_importQueueAdd({
|
|
type: 'album',
|
|
label: album.name,
|
|
sublabel: `${album.artist} · ${effectiveMatches.length} tracks`,
|
|
imageUrl: album.image_url,
|
|
items: effectiveMatches,
|
|
albumData: album,
|
|
});
|
|
|
|
importPageResetAlbumSearch();
|
|
}
|
|
|
|
function importPageResetAlbumSearch() {
|
|
importPageState.albumData = null;
|
|
importPageState.matchOverrides = {};
|
|
importPageState.tapSelectedChip = null;
|
|
importPageState._autoGroupFilePaths = null;
|
|
|
|
document.getElementById('import-page-album-search-section').classList.remove('hidden');
|
|
document.getElementById('import-page-album-match-section').classList.add('hidden');
|
|
|
|
// Clear search
|
|
document.getElementById('import-page-album-results').innerHTML = '';
|
|
document.getElementById('import-page-album-search-input').value = '';
|
|
document.getElementById('import-page-album-clear-btn').classList.add('hidden');
|
|
|
|
// Re-show auto-groups
|
|
const groupsEl = document.getElementById('import-page-auto-groups');
|
|
if (groupsEl) groupsEl.style.display = '';
|
|
|
|
// Refresh suggestions & staging
|
|
importPageLoadAutoGroups();
|
|
importPageLoadSuggestions();
|
|
importPageRefreshStaging();
|
|
}
|
|
|
|
// --- Singles Tab ---
|
|
|
|
function importPageRenderSinglesList() {
|
|
const list = document.getElementById('import-page-singles-list');
|
|
const files = importPageState.stagingFiles;
|
|
|
|
if (files.length === 0) {
|
|
list.innerHTML = '<div class="import-page-empty-state">No audio files found in import folder</div>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = files.map((f, i) => {
|
|
const isSelected = importPageState.selectedSingles.has(i);
|
|
const manualMatch = importPageState.singlesManualMatches[i];
|
|
const searchOpen = document.querySelector(`[data-singles-search="${i}"]`);
|
|
|
|
let html = `
|
|
<div class="import-page-single-item ${manualMatch ? 'matched' : ''}" data-single-idx="${i}">
|
|
<div class="import-page-single-checkbox ${isSelected ? 'checked' : ''}"
|
|
onclick="importPageToggleSingle(${i})"></div>
|
|
<div class="import-page-single-info">
|
|
<div class="import-page-single-filename">${_esc(f.filename)}</div>
|
|
<div class="import-page-single-meta">
|
|
${f.title ? `<span>${_esc(f.title)}</span>` : ''}
|
|
${f.artist ? `<span>${_esc(f.artist)}</span>` : ''}
|
|
${f.extension ? `<span>${f.extension}</span>` : ''}
|
|
</div>
|
|
${manualMatch ? `
|
|
<div class="import-page-single-matched-info">
|
|
✓ ${_esc(manualMatch.name)} - ${_esc(manualMatch.artist)}
|
|
<span class="import-page-single-matched-change" onclick="event.stopPropagation(); importPageOpenSingleSearch(${i})">change</span>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
<div class="import-page-single-actions">
|
|
<button class="import-page-identify-btn" onclick="event.stopPropagation(); importPageOpenSingleSearch(${i})">
|
|
🔍 Identify
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
return html;
|
|
}).join('');
|
|
|
|
importPageUpdateSinglesProcessButton();
|
|
}
|
|
|
|
function importPageToggleSingle(idx) {
|
|
if (importPageState.selectedSingles.has(idx)) {
|
|
importPageState.selectedSingles.delete(idx);
|
|
} else {
|
|
importPageState.selectedSingles.add(idx);
|
|
}
|
|
// Update checkbox UI without full re-render
|
|
const item = document.querySelector(`[data-single-idx="${idx}"]`);
|
|
if (item) {
|
|
const cb = item.querySelector('.import-page-single-checkbox');
|
|
if (cb) cb.classList.toggle('checked', importPageState.selectedSingles.has(idx));
|
|
}
|
|
importPageUpdateSinglesProcessButton();
|
|
}
|
|
|
|
function importPageSelectAllSingles() {
|
|
const allSelected = importPageState.selectedSingles.size === importPageState.stagingFiles.length;
|
|
if (allSelected) {
|
|
importPageState.selectedSingles.clear();
|
|
} else {
|
|
importPageState.stagingFiles.forEach((_, i) => importPageState.selectedSingles.add(i));
|
|
}
|
|
document.getElementById('import-page-select-all-text').textContent = allSelected ? 'Select All' : 'Deselect All';
|
|
// Update all checkboxes
|
|
document.querySelectorAll('.import-page-single-checkbox').forEach((cb, i) => {
|
|
cb.classList.toggle('checked', importPageState.selectedSingles.has(i));
|
|
});
|
|
importPageUpdateSinglesProcessButton();
|
|
}
|
|
|
|
function importPageUpdateSinglesProcessButton() {
|
|
const btn = document.getElementById('import-page-singles-process-btn');
|
|
const count = importPageState.selectedSingles.size;
|
|
btn.textContent = `Process Selected (${count})`;
|
|
btn.disabled = count === 0;
|
|
}
|
|
|
|
function importPageOpenSingleSearch(fileIdx) {
|
|
const item = document.querySelector(`[data-single-idx="${fileIdx}"]`);
|
|
if (!item) return;
|
|
|
|
// Remove any existing search panel
|
|
const existing = item.querySelector('.import-page-single-search-panel');
|
|
if (existing) {
|
|
existing.remove();
|
|
return;
|
|
}
|
|
|
|
// Close other open panels
|
|
document.querySelectorAll('.import-page-single-search-panel').forEach(p => p.remove());
|
|
|
|
const f = importPageState.stagingFiles[fileIdx];
|
|
const defaultQuery = [f.artist, f.title].filter(Boolean).join(' ') || f.filename.replace(/\.[^.]+$/, '');
|
|
|
|
const panel = document.createElement('div');
|
|
panel.className = 'import-page-single-search-panel';
|
|
panel.innerHTML = `
|
|
<div class="import-page-single-search-bar">
|
|
<input type="text" class="import-page-single-search-input"
|
|
value="${_escAttr(defaultQuery)}" placeholder="Search artist - title..."
|
|
onkeydown="if(event.key==='Enter')importPageSearchSingleTrack(${fileIdx}, this.value)">
|
|
<button class="import-page-single-search-go"
|
|
onclick="importPageSearchSingleTrack(${fileIdx}, this.previousElementSibling.value)">Search</button>
|
|
</div>
|
|
<div class="import-page-single-search-results" id="import-single-results-${fileIdx}"></div>
|
|
`;
|
|
item.appendChild(panel);
|
|
|
|
// Auto-search
|
|
const input = panel.querySelector('input');
|
|
input.focus();
|
|
if (defaultQuery) {
|
|
importPageSearchSingleTrack(fileIdx, defaultQuery);
|
|
}
|
|
}
|
|
|
|
async function importPageSearchSingleTrack(fileIdx, query) {
|
|
if (!query || !query.trim()) return;
|
|
|
|
const resultsDiv = document.getElementById(`import-single-results-${fileIdx}`);
|
|
if (!resultsDiv) return;
|
|
resultsDiv.innerHTML = '<div style="color:#888;padding:8px;font-size:12px;">Searching...</div>';
|
|
|
|
try {
|
|
const resp = await fetch(`/api/import/search/tracks?q=${encodeURIComponent(query.trim())}&limit=6`);
|
|
const data = await resp.json();
|
|
if (!data.success || !data.tracks.length) {
|
|
resultsDiv.innerHTML = '<div style="color:#888;padding:8px;font-size:12px;">No results found</div>';
|
|
return;
|
|
}
|
|
// Store results in a temp cache so we can reference by index
|
|
window._importSingleSearchResults = window._importSingleSearchResults || {};
|
|
window._importSingleSearchResults[fileIdx] = data.tracks;
|
|
|
|
resultsDiv.innerHTML = data.tracks.map((t, tIdx) => {
|
|
const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : '';
|
|
return `
|
|
<div class="import-page-single-result-item" onclick="importPageSelectSingleMatch(${fileIdx}, ${tIdx})">
|
|
${t.image_url ? `<img class="import-page-single-result-img" src="${t.image_url}" onerror="this.src='/static/placeholder.png'">` : ''}
|
|
<div class="import-page-single-result-info">
|
|
<div class="import-page-single-result-name">${_esc(t.name)} - ${_esc(t.artist)}</div>
|
|
<div class="import-page-single-result-detail">${_esc(t.album)}${dur ? ' · ' + dur : ''}</div>
|
|
</div>
|
|
<button class="import-page-single-result-select">Select</button>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
} catch (err) {
|
|
resultsDiv.innerHTML = `<div style="color:#ef4444;padding:8px;font-size:12px;">Error: ${err.message}</div>`;
|
|
}
|
|
}
|
|
|
|
function importPageSelectSingleMatch(fileIdx, trackIdx) {
|
|
const trackData = window._importSingleSearchResults?.[fileIdx]?.[trackIdx];
|
|
if (!trackData) return;
|
|
importPageState.singlesManualMatches[fileIdx] = trackData;
|
|
|
|
// Auto-select this file
|
|
importPageState.selectedSingles.add(fileIdx);
|
|
|
|
// Close search panel and re-render this item
|
|
importPageRenderSinglesList();
|
|
}
|
|
|
|
// --- Singles Tab: Process ---
|
|
|
|
function importPageProcessSingles() {
|
|
if (importPageState.selectedSingles.size === 0) return;
|
|
|
|
const filesToProcess = Array.from(importPageState.selectedSingles).map(i => {
|
|
const f = importPageState.stagingFiles[i];
|
|
const manualMatch = importPageState.singlesManualMatches[i];
|
|
if (manualMatch) {
|
|
return { ...f, manual_match: manualMatch };
|
|
}
|
|
return f;
|
|
});
|
|
|
|
// Add to queue and reset immediately
|
|
_importQueueAdd({
|
|
type: 'singles',
|
|
label: `${filesToProcess.length} Single${filesToProcess.length !== 1 ? 's' : ''}`,
|
|
sublabel: filesToProcess.map(f => f.title || f.filename).slice(0, 3).join(', ') + (filesToProcess.length > 3 ? '...' : ''),
|
|
imageUrl: null,
|
|
items: filesToProcess,
|
|
});
|
|
|
|
importPageState.selectedSingles.clear();
|
|
importPageState.singlesManualMatches = {};
|
|
importPageUpdateSinglesProcessButton();
|
|
importPageRefreshStaging();
|
|
}
|
|
|
|
// --- Processing Queue ---
|
|
|
|
const _importQueue = []; // { id, type, label, sublabel, imageUrl, status, processed, total, errors }
|
|
|
|
function _importQueueAdd(job) {
|
|
const id = ++importJobIdCounter;
|
|
const entry = {
|
|
id,
|
|
type: job.type,
|
|
label: job.label,
|
|
sublabel: job.sublabel,
|
|
imageUrl: job.imageUrl,
|
|
status: 'running', // running | done | error
|
|
processed: 0,
|
|
total: job.items.length,
|
|
errors: [],
|
|
};
|
|
_importQueue.push(entry);
|
|
_importQueueRender();
|
|
|
|
// Fire and forget — runs in background
|
|
_importQueueRunJob(entry, job);
|
|
}
|
|
|
|
async function _importQueueRunJob(entry, job) {
|
|
for (let i = 0; i < job.items.length; i++) {
|
|
const itemName = job.type === 'album'
|
|
? _importPageGetTrackDisplayInfo(job.items[i], i).name
|
|
: (job.items[i].title || job.items[i].filename || `File ${i + 1}`);
|
|
|
|
// Update status with current track info
|
|
entry.sublabel = `Processing ${i + 1}/${job.items.length}: ${itemName}`;
|
|
_importQueueRender();
|
|
|
|
try {
|
|
let resp;
|
|
if (job.type === 'album') {
|
|
resp = await fetch('/api/import/album/process', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
album: job.albumData,
|
|
matches: [job.items[i]]
|
|
})
|
|
});
|
|
} else {
|
|
resp = await fetch('/api/import/singles/process', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ files: [job.items[i]] })
|
|
});
|
|
}
|
|
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
const data = await resp.json();
|
|
if (data.success) entry.processed += (data.processed || 0);
|
|
if (data.errors && data.errors.length > 0) entry.errors.push(...data.errors);
|
|
} catch (err) {
|
|
entry.errors.push(`${itemName}: ${err.message}`);
|
|
}
|
|
|
|
_importQueueRender();
|
|
}
|
|
|
|
entry.status = entry.errors.length > 0 && entry.processed === 0 ? 'error' : 'done';
|
|
_importQueueRender();
|
|
|
|
// Refresh staging and suggestions since files moved
|
|
importPageRefreshStaging();
|
|
importPageLoadSuggestions();
|
|
}
|
|
|
|
function _importQueueRender() {
|
|
const container = document.getElementById('import-page-queue');
|
|
const list = document.getElementById('import-page-queue-list');
|
|
const clearBtn = document.getElementById('import-page-queue-clear');
|
|
if (!container || !list) return;
|
|
|
|
if (_importQueue.length === 0) {
|
|
container.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
container.classList.remove('hidden');
|
|
|
|
// Show clear button only if there are finished jobs
|
|
const hasFinished = _importQueue.some(j => j.status !== 'running');
|
|
clearBtn.style.display = hasFinished ? '' : 'none';
|
|
|
|
list.innerHTML = _importQueue.map(j => {
|
|
const pct = j.total > 0 ? Math.round((j.processed / j.total) * 100) : 0;
|
|
const fillClass = j.status === 'error' ? 'error' : '';
|
|
let statusText, statusClass;
|
|
if (j.status === 'running') {
|
|
statusText = `${j.processed}/${j.total}`;
|
|
statusClass = '';
|
|
} else if (j.status === 'done') {
|
|
statusText = j.errors.length > 0 ? `${j.processed}/${j.total} (${j.errors.length} err)` : 'Done';
|
|
statusClass = j.errors.length > 0 ? 'error' : 'done';
|
|
} else {
|
|
statusText = 'Failed';
|
|
statusClass = 'error';
|
|
}
|
|
|
|
return `
|
|
<div class="import-page-queue-item">
|
|
${j.imageUrl
|
|
? `<img class="import-page-queue-art" src="${j.imageUrl}" onerror="this.src='/static/placeholder.png'">`
|
|
: `<div class="import-page-queue-art" style="background:rgba(255,255,255,0.06);display:flex;align-items:center;justify-content:center;font-size:18px;color:rgba(255,255,255,0.3);">♪</div>`}
|
|
<div class="import-page-queue-info">
|
|
<div class="import-page-queue-name">${_esc(j.label)}</div>
|
|
<div class="import-page-queue-detail">${_esc(j.sublabel)}</div>
|
|
</div>
|
|
<div class="import-page-queue-progress">
|
|
<div class="import-page-queue-bar">
|
|
<div class="import-page-queue-fill ${fillClass}" style="width:${j.status === 'done' || j.status === 'error' ? 100 : pct}%"></div>
|
|
</div>
|
|
<div class="import-page-queue-status ${statusClass}">${statusText}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function importPageClearFinishedJobs() {
|
|
for (let i = _importQueue.length - 1; i >= 0; i--) {
|
|
if (_importQueue[i].status !== 'running') {
|
|
_importQueue.splice(i, 1);
|
|
}
|
|
}
|
|
_importQueueRender();
|
|
}
|
|
|
|
// ── Import File Tab ──────────────────────────────────────────────────
|
|
|
|
let _importFileState = {
|
|
rawText: '',
|
|
fileName: '',
|
|
fileType: '', // 'csv' or 'text'
|
|
headers: [], // CSV column headers
|
|
rows: [], // raw parsed rows (arrays for csv, strings for text)
|
|
columnMap: {}, // { columnIndex: 'track_name' | 'artist_name' | 'album_name' | 'duration' | 'skip' }
|
|
parsedTracks: [] // final [{track_name, artist_name, album_name, duration_ms}]
|
|
};
|
|
|
|
function _initImportFileTab() {
|
|
const dropzone = document.getElementById('import-file-dropzone');
|
|
const fileInput = document.getElementById('import-file-input');
|
|
if (!dropzone || !fileInput) return;
|
|
|
|
dropzone.addEventListener('click', () => fileInput.click());
|
|
dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('drag-over'); });
|
|
dropzone.addEventListener('dragleave', () => dropzone.classList.remove('drag-over'));
|
|
dropzone.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
dropzone.classList.remove('drag-over');
|
|
const file = e.dataTransfer.files[0];
|
|
if (file) _importFileRead(file);
|
|
});
|
|
fileInput.addEventListener('change', () => {
|
|
if (fileInput.files[0]) _importFileRead(fileInput.files[0]);
|
|
fileInput.value = '';
|
|
});
|
|
|
|
// Enable/disable import button based on playlist name
|
|
const nameInput = document.getElementById('import-file-playlist-name');
|
|
if (nameInput) {
|
|
nameInput.addEventListener('input', () => {
|
|
const btn = document.getElementById('import-file-import-btn');
|
|
if (btn) btn.disabled = !nameInput.value.trim();
|
|
});
|
|
}
|
|
}
|
|
|
|
function _importFileRead(file) {
|
|
const ext = file.name.split('.').pop().toLowerCase();
|
|
if (!['csv', 'tsv', 'txt'].includes(ext)) {
|
|
showToast('Unsupported file type. Use CSV, TSV, or TXT.', 'error');
|
|
return;
|
|
}
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
_importFileState.rawText = e.target.result;
|
|
_importFileState.fileName = file.name;
|
|
_importFileState.fileType = (ext === 'txt') ? 'text' : 'csv';
|
|
_importFileParseAndPreview();
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
function _importFileDetectDelimiter(firstLine) {
|
|
const tab = (firstLine.match(/\t/g) || []).length;
|
|
const semi = (firstLine.match(/;/g) || []).length;
|
|
const comma = (firstLine.match(/,/g) || []).length;
|
|
if (tab >= comma && tab >= semi && tab > 0) return '\t';
|
|
if (semi >= comma && semi > 0) return ';';
|
|
return ',';
|
|
}
|
|
|
|
function _importFileParseCsv(text, delimiter) {
|
|
const lines = text.split(/\r?\n/).filter(l => l.trim());
|
|
if (lines.length < 2) return { headers: [], rows: [] };
|
|
|
|
// Parse CSV with basic quote handling
|
|
function parseLine(line) {
|
|
const result = [];
|
|
let current = '';
|
|
let inQuotes = false;
|
|
for (let i = 0; i < line.length; i++) {
|
|
const ch = line[i];
|
|
if (ch === '"') {
|
|
if (inQuotes && i + 1 < line.length && line[i + 1] === '"') {
|
|
current += '"';
|
|
i++;
|
|
} else {
|
|
inQuotes = !inQuotes;
|
|
}
|
|
} else if (ch === delimiter && !inQuotes) {
|
|
result.push(current.trim());
|
|
current = '';
|
|
} else {
|
|
current += ch;
|
|
}
|
|
}
|
|
result.push(current.trim());
|
|
return result;
|
|
}
|
|
|
|
const headers = parseLine(lines[0]);
|
|
const rows = [];
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const row = parseLine(lines[i]);
|
|
if (row.some(cell => cell)) rows.push(row);
|
|
}
|
|
return { headers, rows };
|
|
}
|
|
|
|
function _importFileAutoMapColumns(headers) {
|
|
const map = {};
|
|
const lowerHeaders = headers.map(h => h.toLowerCase().trim());
|
|
|
|
const trackPatterns = ['track_name', 'track name', 'track', 'title', 'song', 'song_name', 'song name', 'name'];
|
|
const artistPatterns = ['artist_name', 'artist name', 'artist', 'artists', 'performer'];
|
|
const albumPatterns = ['album_name', 'album name', 'album'];
|
|
const durationPatterns = ['duration', 'duration_ms', 'length', 'time'];
|
|
|
|
function findMatch(patterns) {
|
|
for (const p of patterns) {
|
|
const idx = lowerHeaders.indexOf(p);
|
|
if (idx !== -1 && !(idx in map)) return idx;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
const trackIdx = findMatch(trackPatterns);
|
|
if (trackIdx !== -1) map[trackIdx] = 'track_name';
|
|
|
|
const artistIdx = findMatch(artistPatterns);
|
|
if (artistIdx !== -1) map[artistIdx] = 'artist_name';
|
|
|
|
const albumIdx = findMatch(albumPatterns);
|
|
if (albumIdx !== -1) map[albumIdx] = 'album_name';
|
|
|
|
const durIdx = findMatch(durationPatterns);
|
|
if (durIdx !== -1) map[durIdx] = 'duration';
|
|
|
|
return map;
|
|
}
|
|
|
|
function _importFileParseAndPreview() {
|
|
const state = _importFileState;
|
|
const text = state.rawText;
|
|
|
|
if (state.fileType === 'text') {
|
|
// Plain text: one track per line
|
|
const lines = text.split(/\r?\n/).filter(l => l.trim());
|
|
state.rows = lines;
|
|
state.headers = [];
|
|
state.columnMap = {};
|
|
} else {
|
|
// CSV/TSV
|
|
const firstLine = text.split(/\r?\n/)[0] || '';
|
|
const delimiter = _importFileDetectDelimiter(firstLine);
|
|
const { headers, rows } = _importFileParseCsv(text, delimiter);
|
|
state.headers = headers;
|
|
state.rows = rows;
|
|
state.columnMap = _importFileAutoMapColumns(headers);
|
|
}
|
|
|
|
_importFileBuildTracks();
|
|
_importFileRenderPreview();
|
|
}
|
|
|
|
function _importFileBuildTracks() {
|
|
const state = _importFileState;
|
|
state.parsedTracks = [];
|
|
|
|
if (state.fileType === 'text') {
|
|
const orderEl = document.getElementById('import-file-text-order');
|
|
const sepEl = document.getElementById('import-file-text-separator');
|
|
const order = orderEl ? orderEl.value : 'artist-title';
|
|
const sep = sepEl ? sepEl.value : ' - ';
|
|
|
|
for (const line of state.rows) {
|
|
const parts = line.split(sep);
|
|
if (parts.length >= 2) {
|
|
const a = parts[0].trim();
|
|
const b = parts.slice(1).join(sep).trim();
|
|
state.parsedTracks.push({
|
|
track_name: order === 'artist-title' ? b : a,
|
|
artist_name: order === 'artist-title' ? a : b,
|
|
album_name: '',
|
|
duration_ms: 0
|
|
});
|
|
} else {
|
|
// Can't split — treat whole line as track name
|
|
state.parsedTracks.push({
|
|
track_name: line.trim(),
|
|
artist_name: '',
|
|
album_name: '',
|
|
duration_ms: 0
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
// CSV mapped
|
|
const map = state.columnMap;
|
|
const trackCol = Object.keys(map).find(k => map[k] === 'track_name');
|
|
const artistCol = Object.keys(map).find(k => map[k] === 'artist_name');
|
|
const albumCol = Object.keys(map).find(k => map[k] === 'album_name');
|
|
const durCol = Object.keys(map).find(k => map[k] === 'duration');
|
|
|
|
for (const row of state.rows) {
|
|
const track = trackCol !== undefined ? (row[trackCol] || '') : '';
|
|
const artist = artistCol !== undefined ? (row[artistCol] || '') : '';
|
|
const album = albumCol !== undefined ? (row[albumCol] || '') : '';
|
|
let dur = durCol !== undefined ? (row[durCol] || '') : '';
|
|
|
|
// Parse duration: could be ms, seconds, or mm:ss
|
|
let durationMs = 0;
|
|
if (dur) {
|
|
dur = dur.trim();
|
|
if (dur.includes(':')) {
|
|
const parts = dur.split(':');
|
|
durationMs = (parseInt(parts[0]) * 60 + parseInt(parts[1] || 0)) * 1000;
|
|
} else {
|
|
const num = parseFloat(dur);
|
|
durationMs = num > 10000 ? num : num * 1000; // assume ms if > 10000, else seconds
|
|
}
|
|
if (isNaN(durationMs)) durationMs = 0;
|
|
}
|
|
|
|
state.parsedTracks.push({
|
|
track_name: track,
|
|
artist_name: artist,
|
|
album_name: album,
|
|
duration_ms: durationMs
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function _importFileRenderPreview() {
|
|
const state = _importFileState;
|
|
const validTracks = state.parsedTracks.filter(t => t.track_name || t.artist_name);
|
|
|
|
// Show/hide sections
|
|
document.getElementById('import-file-upload-zone').style.display = 'none';
|
|
document.getElementById('import-file-preview-section').style.display = '';
|
|
|
|
// File info
|
|
document.getElementById('import-file-name-label').textContent = state.fileName;
|
|
document.getElementById('import-file-track-count').textContent = `${validTracks.length} track${validTracks.length !== 1 ? 's' : ''} parsed`;
|
|
|
|
// Show format controls based on file type
|
|
document.getElementById('import-file-text-format').style.display = state.fileType === 'text' ? '' : 'none';
|
|
document.getElementById('import-file-column-mapping').style.display = state.fileType === 'csv' ? '' : 'none';
|
|
|
|
// Render column mapping for CSV
|
|
if (state.fileType === 'csv') {
|
|
_importFileRenderColumnMapping();
|
|
}
|
|
|
|
// Pre-fill playlist name from filename (strip extension)
|
|
const nameInput = document.getElementById('import-file-playlist-name');
|
|
if (nameInput && !nameInput.value) {
|
|
nameInput.value = state.fileName.replace(/\.[^.]+$/, '');
|
|
}
|
|
// Update button state
|
|
const btn = document.getElementById('import-file-import-btn');
|
|
if (btn) btn.disabled = !nameInput.value.trim();
|
|
|
|
// Render preview table
|
|
const tbody = document.getElementById('import-file-preview-tbody');
|
|
tbody.innerHTML = '';
|
|
|
|
state.parsedTracks.forEach((t, i) => {
|
|
const valid = !!(t.track_name || t.artist_name);
|
|
const tr = document.createElement('tr');
|
|
if (!valid) tr.classList.add('invalid-row');
|
|
tr.innerHTML = `
|
|
<td>${i + 1}</td>
|
|
<td>${_esc(t.track_name)}</td>
|
|
<td>${_esc(t.artist_name)}</td>
|
|
<td>${_esc(t.album_name)}</td>
|
|
`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
function _importFileRenderColumnMapping() {
|
|
const state = _importFileState;
|
|
const container = document.getElementById('import-file-mapping-selects');
|
|
container.innerHTML = '';
|
|
|
|
const options = ['skip', 'track_name', 'artist_name', 'album_name', 'duration'];
|
|
const optLabels = { skip: 'Skip', track_name: 'Track', artist_name: 'Artist', album_name: 'Album', duration: 'Duration' };
|
|
|
|
state.headers.forEach((header, idx) => {
|
|
const mapped = state.columnMap[idx] || 'skip';
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'import-file-col-map';
|
|
if (mapped === 'track_name') wrap.classList.add('mapped-track');
|
|
else if (mapped === 'artist_name') wrap.classList.add('mapped-artist');
|
|
else if (mapped === 'album_name') wrap.classList.add('mapped-album');
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'import-file-col-label';
|
|
label.textContent = header;
|
|
label.title = header;
|
|
|
|
const sel = document.createElement('select');
|
|
sel.className = 'import-file-select';
|
|
options.forEach(o => {
|
|
const opt = document.createElement('option');
|
|
opt.value = o;
|
|
opt.textContent = optLabels[o];
|
|
if (o === mapped) opt.selected = true;
|
|
sel.appendChild(opt);
|
|
});
|
|
sel.addEventListener('change', () => {
|
|
if (sel.value === 'skip') {
|
|
delete state.columnMap[idx];
|
|
} else {
|
|
// Remove this mapping from any other column
|
|
for (const k of Object.keys(state.columnMap)) {
|
|
if (state.columnMap[k] === sel.value) delete state.columnMap[k];
|
|
}
|
|
state.columnMap[idx] = sel.value;
|
|
}
|
|
_importFileBuildTracks();
|
|
_importFileRenderPreview();
|
|
});
|
|
|
|
wrap.appendChild(label);
|
|
wrap.appendChild(sel);
|
|
container.appendChild(wrap);
|
|
});
|
|
}
|
|
|
|
function importFileReparse() {
|
|
_importFileBuildTracks();
|
|
_importFileRenderPreview();
|
|
}
|
|
|
|
function importFileClear() {
|
|
_importFileState = {
|
|
rawText: '', fileName: '', fileType: '',
|
|
headers: [], rows: [], columnMap: {}, parsedTracks: []
|
|
};
|
|
document.getElementById('import-file-upload-zone').style.display = '';
|
|
document.getElementById('import-file-preview-section').style.display = 'none';
|
|
document.getElementById('import-file-playlist-name').value = '';
|
|
document.getElementById('import-file-preview-tbody').innerHTML = '';
|
|
}
|
|
|
|
function importFileSubmit() {
|
|
const nameInput = document.getElementById('import-file-playlist-name');
|
|
const name = nameInput ? nameInput.value.trim() : '';
|
|
if (!name) {
|
|
showToast('Please enter a playlist name.', 'error');
|
|
nameInput && nameInput.focus();
|
|
return;
|
|
}
|
|
|
|
const tracks = _importFileState.parsedTracks.filter(t => t.track_name || t.artist_name);
|
|
if (!tracks.length) {
|
|
showToast('No valid tracks to import.', 'error');
|
|
return;
|
|
}
|
|
|
|
// Use a unique ID based on timestamp so multiple imports don't collide
|
|
const sourceId = `file_${Date.now()}`;
|
|
|
|
mirrorPlaylist('file', sourceId, name, tracks, {
|
|
description: `Imported from ${_importFileState.fileName}`,
|
|
owner: 'local'
|
|
});
|
|
|
|
showToast(`Imported "${name}" with ${tracks.length} tracks`, 'success');
|
|
importFileClear();
|
|
|
|
// Switch to mirrored tab so user sees the result
|
|
const mirroredBtn = document.querySelector('.sync-tab-button[data-tab="mirrored"]');
|
|
if (mirroredBtn) {
|
|
mirroredBtn.click();
|
|
// Reload mirrored playlists to show the new one
|
|
setTimeout(() => loadMirroredPlaylists(), 500);
|
|
}
|
|
}
|
|
|
|
// ── Mirrored Playlists ────────────────────────────────────────────────
|
|
|
|
let mirroredPlaylistsLoaded = false;
|
|
|
|
/**
|
|
* Fire-and-forget helper: send parsed playlist data to be mirrored on the backend.
|
|
*/
|
|
function mirrorPlaylist(source, sourceId, name, tracks, metadata = {}) {
|
|
const normalizedTracks = tracks.map(t => ({
|
|
track_name: t.track_name || t.name || '',
|
|
artist_name: t.artist_name || (Array.isArray(t.artists) ? (typeof t.artists[0] === 'object' ? t.artists[0].name : t.artists[0]) : t.artists || ''),
|
|
album_name: t.album_name || (typeof t.album === 'object' ? (t.album && t.album.name) : t.album) || '',
|
|
duration_ms: t.duration_ms || 0,
|
|
image_url: t.image_url || (t.album && typeof t.album === 'object' && t.album.images && t.album.images[0] ? t.album.images[0].url : null),
|
|
source_track_id: t.source_track_id || t.id || t.spotify_track_id || '',
|
|
extra_data: t.extra_data || null
|
|
}));
|
|
|
|
fetch('/api/mirror-playlist', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
source,
|
|
source_playlist_id: String(sourceId),
|
|
name,
|
|
tracks: normalizedTracks,
|
|
description: metadata.description || '',
|
|
owner: metadata.owner || '',
|
|
image_url: metadata.image_url || ''
|
|
})
|
|
}).then(r => r.json()).then(data => {
|
|
if (data.success) console.log(`Mirrored ${source} playlist: ${name} (${normalizedTracks.length} tracks)`);
|
|
}).catch(err => console.warn('Mirror save failed:', err));
|
|
}
|
|
|
|
/**
|
|
* Load and render all mirrored playlists into the Mirrored tab.
|
|
*/
|
|
async function loadMirroredPlaylists() {
|
|
const container = document.getElementById('mirrored-playlist-container');
|
|
if (!container) return;
|
|
container.innerHTML = `<div class="playlist-placeholder">Loading mirrored playlists...</div>`;
|
|
|
|
try {
|
|
const res = await fetch('/api/mirrored-playlists');
|
|
const playlists = await res.json();
|
|
if (playlists.error) throw new Error(playlists.error);
|
|
|
|
if (!playlists.length) {
|
|
container.innerHTML = `<div class="playlist-placeholder">Playlists you parse from any service will appear here as persistent backups.</div>`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = '';
|
|
playlists.forEach(p => renderMirroredCard(p, container));
|
|
mirroredPlaylistsLoaded = true;
|
|
|
|
// Hydrate discovery states from backend (survives page refresh)
|
|
await hydrateMirroredDiscoveryStates();
|
|
} catch (err) {
|
|
container.innerHTML = `<div class="playlist-placeholder">Error loading mirrored playlists: ${err.message}</div>`;
|
|
}
|
|
}
|
|
|
|
function renderMirroredCard(p, container) {
|
|
const ago = timeAgo(p.updated_at || p.mirrored_at);
|
|
const hash = `mirrored_${p.id}`;
|
|
const state = youtubePlaylistStates[hash];
|
|
const phase = state ? state.phase : null;
|
|
|
|
// Build phase indicator
|
|
let phaseHtml = '';
|
|
if (phase === 'discovering') {
|
|
const pct = state.discoveryProgress || state.discovery_progress || 0;
|
|
phaseHtml = `<span style="color:#a78bfa;">Discovering ${pct}%</span>`;
|
|
} else if (phase === 'discovered') {
|
|
const matches = state.spotifyMatches || state.spotify_matches || 0;
|
|
const total = state.spotify_total || p.track_count;
|
|
phaseHtml = `<span style="color:#22c55e;">Discovered ${matches}/${total}</span>`;
|
|
} else if (phase === 'syncing' || phase === 'sync_complete') {
|
|
phaseHtml = `<span style="color:#3b82f6;">${phase === 'syncing' ? 'Syncing...' : 'Synced'}</span>`;
|
|
} else if (phase === 'downloading') {
|
|
phaseHtml = `<span style="color:#f59e0b;">Downloading...</span>`;
|
|
} else if (phase === 'download_complete') {
|
|
phaseHtml = `<span style="color:#22c55e;">Downloaded</span>`;
|
|
}
|
|
|
|
const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛', file: '📄' };
|
|
const srcIcon = sourceIcons[p.source] || '📋';
|
|
|
|
// Discovery ratio
|
|
const disc = p.discovered_count || 0;
|
|
const tot = p.total_count || p.track_count || 0;
|
|
let ratioHtml = '';
|
|
if (disc > 0) {
|
|
const complete = disc >= tot;
|
|
const srcName = typeof currentMusicSourceName !== 'undefined' ? currentMusicSourceName : 'metadata';
|
|
ratioHtml = `<span class="discovery-ratio${complete ? ' complete' : ''}">${disc}/${tot} discovered on ${srcName}</span>`;
|
|
}
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'mirrored-playlist-card';
|
|
card.id = `mirrored-card-${p.id}`;
|
|
card.innerHTML = `
|
|
<div class="source-icon ${_escAttr(p.source)}">${srcIcon}</div>
|
|
<div class="mirrored-card-info">
|
|
<div class="card-name">${_esc(p.name)}</div>
|
|
<div class="card-meta">
|
|
<span class="source-badge ${_escAttr(p.source)}">${_esc(p.source)}</span>
|
|
<span>${p.track_count} tracks</span>
|
|
<span>Mirrored ${ago}</span>
|
|
${ratioHtml}
|
|
${phaseHtml}
|
|
</div>
|
|
</div>
|
|
${disc > 0 ? `<button class="mirrored-card-clear" onclick="event.stopPropagation(); clearMirroredDiscovery(${p.id}, '${_escAttr(p.name)}')" title="Clear discovery data">↺</button>` : ''}
|
|
<button class="mirrored-card-delete" onclick="event.stopPropagation(); deleteMirroredPlaylist(${p.id}, '${_escAttr(p.name)}')" title="Delete mirror">✕</button>
|
|
`;
|
|
card.addEventListener('click', () => {
|
|
const st = youtubePlaylistStates[hash];
|
|
// Treat as non-fresh if phase is set, or if a poller/discovery modal exists
|
|
const hasActiveDiscovery = activeYouTubePollers[hash] || document.getElementById(`youtube-discovery-modal-${hash}`);
|
|
if (st && ((st.phase && st.phase !== 'fresh') || hasActiveDiscovery)) {
|
|
if (st.phase === 'downloading' || st.phase === 'download_complete') {
|
|
// Open download modal directly (follows Tidal/YouTube card click pattern)
|
|
const spotifyPlaylistId = st.convertedSpotifyPlaylistId;
|
|
if (spotifyPlaylistId && activeDownloadProcesses[spotifyPlaylistId]) {
|
|
// Modal already exists — just show it
|
|
const process = activeDownloadProcesses[spotifyPlaylistId];
|
|
if (process.modalElement) {
|
|
if (process.status === 'complete') {
|
|
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
|
|
}
|
|
process.modalElement.style.display = 'flex';
|
|
}
|
|
} else if (spotifyPlaylistId) {
|
|
// Need to rehydrate the download modal
|
|
rehydrateMirroredDownloadModal(hash, st);
|
|
} else {
|
|
// No converted playlist ID yet, fall back to discovery modal
|
|
openYouTubeDiscoveryModal(hash);
|
|
}
|
|
} else {
|
|
openYouTubeDiscoveryModal(hash);
|
|
if (st.phase === 'discovering' && !activeYouTubePollers[hash]) {
|
|
startYouTubeDiscoveryPolling(hash);
|
|
}
|
|
}
|
|
} else {
|
|
openMirroredPlaylistModal(p.id);
|
|
}
|
|
});
|
|
container.appendChild(card);
|
|
}
|
|
|
|
function updateMirroredCardPhase(urlHash, phase) {
|
|
// Update the state phase (updateYouTubeCardPhase skips this for mirrored playlists due to no cardElement)
|
|
const state = youtubePlaylistStates[urlHash];
|
|
if (state) state.phase = phase;
|
|
|
|
// Extract the numeric ID from urlHash (e.g., 'mirrored_3' → '3')
|
|
const mirroredId = urlHash.replace('mirrored_', '');
|
|
const card = document.getElementById(`mirrored-card-${mirroredId}`);
|
|
if (!card) return;
|
|
|
|
const metaEl = card.querySelector('.card-meta');
|
|
if (!metaEl) return;
|
|
|
|
// Remove old phase indicator
|
|
const oldPhase = metaEl.querySelector('span[style]');
|
|
if (oldPhase) oldPhase.remove();
|
|
|
|
// Add new phase indicator
|
|
let phaseHtml = '';
|
|
switch (phase) {
|
|
case 'discovering':
|
|
phaseHtml = `<span style="color:#a78bfa;">Discovering...</span>`;
|
|
break;
|
|
case 'discovered':
|
|
const matches = state?.spotifyMatches || state?.spotify_matches || 0;
|
|
const total = state?.spotify_total || 0;
|
|
phaseHtml = `<span style="color:#22c55e;">Discovered ${matches}/${total}</span>`;
|
|
break;
|
|
case 'syncing':
|
|
phaseHtml = `<span style="color:#3b82f6;">Syncing...</span>`;
|
|
break;
|
|
case 'sync_complete':
|
|
phaseHtml = `<span style="color:#3b82f6;">Synced</span>`;
|
|
break;
|
|
case 'downloading':
|
|
phaseHtml = `<span style="color:#f59e0b;">Downloading...</span>`;
|
|
break;
|
|
case 'download_complete':
|
|
phaseHtml = `<span style="color:#22c55e;">Downloaded</span>`;
|
|
break;
|
|
}
|
|
if (phaseHtml) {
|
|
metaEl.insertAdjacentHTML('beforeend', phaseHtml);
|
|
}
|
|
}
|
|
|
|
async function rehydrateMirroredDownloadModal(urlHash, state) {
|
|
try {
|
|
if (!state || !state.playlist) {
|
|
showToast('Cannot open download modal - invalid playlist data', 'error');
|
|
return;
|
|
}
|
|
|
|
console.log(`💧 [Rehydration] Rehydrating mirrored download modal for: ${state.playlist.name}`);
|
|
|
|
// Get discovery results from backend if not already loaded
|
|
let discoveryRes = state.discoveryResults || state.discovery_results;
|
|
if (!discoveryRes || discoveryRes.length === 0) {
|
|
console.log(`🔍 Fetching discovery results from backend for mirrored playlist: ${urlHash}`);
|
|
const stateResponse = await fetch(`/api/youtube/state/${urlHash}`);
|
|
if (stateResponse.ok) {
|
|
const fullState = await stateResponse.json();
|
|
state.discovery_results = fullState.discovery_results;
|
|
state.discoveryResults = fullState.discovery_results;
|
|
state.convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id;
|
|
state.download_process_id = fullState.download_process_id;
|
|
discoveryRes = fullState.discovery_results;
|
|
console.log(`✅ Loaded ${discoveryRes?.length || 0} discovery results from backend`);
|
|
} else {
|
|
showToast('Error loading playlist data', 'error');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Extract Spotify tracks from discovery results
|
|
const spotifyTracks = (discoveryRes || [])
|
|
.filter(r => r.spotify_data || (r.spotify_track && r.status_class === 'found'))
|
|
.map(r => {
|
|
if (r.spotify_data) return r.spotify_data;
|
|
const albumData = r.spotify_album || 'Unknown Album';
|
|
return {
|
|
id: r.spotify_id || 'unknown',
|
|
name: r.spotify_track || 'Unknown Track',
|
|
artists: r.spotify_artist ? [r.spotify_artist] : ['Unknown Artist'],
|
|
album: typeof albumData === 'object' ? albumData : { name: albumData, album_type: 'album', images: [] },
|
|
duration_ms: 0
|
|
};
|
|
});
|
|
|
|
if (spotifyTracks.length === 0) {
|
|
showToast('No Spotify matches found for download', 'error');
|
|
return;
|
|
}
|
|
|
|
const virtualPlaylistId = state.convertedSpotifyPlaylistId;
|
|
const playlistName = state.playlist.name;
|
|
|
|
// Create the download modal
|
|
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
|
|
|
|
// If we have a download process ID, set up the modal for the running/complete state
|
|
if (state.download_process_id) {
|
|
const process = activeDownloadProcesses[virtualPlaylistId];
|
|
if (process) {
|
|
process.status = state.phase === 'download_complete' ? 'complete' : 'running';
|
|
process.batchId = state.download_process_id;
|
|
|
|
const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
|
|
const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
|
|
|
|
if (state.phase === 'downloading') {
|
|
if (beginBtn) beginBtn.style.display = 'none';
|
|
if (cancelBtn) cancelBtn.style.display = 'inline-block';
|
|
|
|
// Start polling for live updates
|
|
startModalDownloadPolling(virtualPlaylistId);
|
|
console.log(`🔄 Started polling for active mirrored download: ${state.download_process_id}`);
|
|
} else if (state.phase === 'download_complete') {
|
|
if (beginBtn) beginBtn.style.display = 'none';
|
|
if (cancelBtn) cancelBtn.style.display = 'none';
|
|
console.log(`✅ Showing completed mirrored download results: ${state.download_process_id}`);
|
|
|
|
// Fetch final results to populate the modal
|
|
try {
|
|
const response = await fetch(`/api/playlists/${state.download_process_id}/download_status`);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
if (data.phase === 'complete' && data.tasks) {
|
|
updateCompletedModalResults(virtualPlaylistId, data);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('Could not load completed download results:', err);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`✅ Successfully rehydrated mirrored download modal for: ${state.playlist.name}`);
|
|
} catch (error) {
|
|
console.error('❌ Error rehydrating mirrored download modal:', error);
|
|
showToast('Error opening download modal', 'error');
|
|
}
|
|
}
|
|
|
|
async function hydrateMirroredDiscoveryStates() {
|
|
try {
|
|
const res = await fetch('/api/mirrored-playlists/discovery-states');
|
|
const data = await res.json();
|
|
if (data.error || !data.states || data.states.length === 0) return;
|
|
|
|
console.log(`Hydrating ${data.states.length} mirrored discovery states`);
|
|
|
|
for (const s of data.states) {
|
|
const hash = s.url_hash;
|
|
|
|
youtubePlaylistStates[hash] = {
|
|
playlist: s.playlist,
|
|
phase: s.phase,
|
|
discovery_results: s.discovery_results || [],
|
|
discoveryResults: s.discovery_results || [],
|
|
discovery_progress: s.discovery_progress || 0,
|
|
discoveryProgress: s.discovery_progress || 0,
|
|
spotify_matches: s.spotify_matches || 0,
|
|
spotifyMatches: s.spotify_matches || 0,
|
|
spotify_total: s.spotify_total || 0,
|
|
status: s.status || '',
|
|
url: s.playlist?.url || '',
|
|
sync_playlist_id: null,
|
|
converted_spotify_playlist_id: s.converted_spotify_playlist_id,
|
|
convertedSpotifyPlaylistId: s.converted_spotify_playlist_id,
|
|
download_process_id: s.download_process_id,
|
|
created_at: Date.now() / 1000,
|
|
last_accessed: Date.now() / 1000,
|
|
discovery_future: null,
|
|
sync_progress: {},
|
|
is_mirrored_playlist: true,
|
|
mirrored_source: s.playlist?.source || ''
|
|
};
|
|
|
|
// Update the card to reflect the current phase
|
|
const card = document.getElementById(`mirrored-card-${s.playlist_id}`);
|
|
if (card) {
|
|
const metaEl = card.querySelector('.card-meta');
|
|
if (metaEl) {
|
|
// Remove old phase span and add new one
|
|
const oldPhase = metaEl.querySelector('span[style]');
|
|
if (oldPhase) oldPhase.remove();
|
|
|
|
if (s.phase === 'discovering') {
|
|
metaEl.insertAdjacentHTML('beforeend', `<span style="color:#a78bfa;">Discovering ${s.discovery_progress || 0}%</span>`);
|
|
} else if (s.phase === 'discovered') {
|
|
metaEl.insertAdjacentHTML('beforeend', `<span style="color:#22c55e;">Discovered ${s.spotify_matches || 0}/${s.spotify_total || 0}</span>`);
|
|
} else if (s.phase === 'syncing' || s.phase === 'sync_complete') {
|
|
metaEl.insertAdjacentHTML('beforeend', `<span style="color:#3b82f6;">${s.phase === 'syncing' ? 'Syncing...' : 'Synced'}</span>`);
|
|
} else if (s.phase === 'downloading') {
|
|
metaEl.insertAdjacentHTML('beforeend', `<span style="color:#f59e0b;">Downloading...</span>`);
|
|
} else if (s.phase === 'download_complete') {
|
|
metaEl.insertAdjacentHTML('beforeend', `<span style="color:#22c55e;">Downloaded</span>`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Resume polling if discovery is in progress
|
|
if (s.phase === 'discovering' && !activeYouTubePollers[hash]) {
|
|
startYouTubeDiscoveryPolling(hash);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('Failed to hydrate mirrored discovery states:', err);
|
|
}
|
|
}
|
|
|
|
function timeAgo(dateStr) {
|
|
if (!dateStr) return '';
|
|
// Handle ISO formats: "Z" suffix, "+00:00" offset, or bare (assume UTC)
|
|
let ts = dateStr;
|
|
if (!ts.includes('Z') && !ts.includes('+') && !ts.includes('-', 10)) ts += 'Z';
|
|
const diff = Date.now() - new Date(ts).getTime();
|
|
const secs = Math.floor(diff / 1000);
|
|
if (secs < 5) return 'just now';
|
|
if (secs < 60) return `${secs}s ago`;
|
|
const mins = Math.floor(secs / 60);
|
|
if (mins < 60) return `${mins}m ago`;
|
|
const hrs = Math.floor(mins / 60);
|
|
if (hrs < 24) return `${hrs}h ago`;
|
|
const days = Math.floor(hrs / 24);
|
|
if (days < 30) return `${days}d ago`;
|
|
return `${Math.floor(days / 30)}mo ago`;
|
|
}
|
|
|
|
/**
|
|
* Open modal showing all tracks in a mirrored playlist.
|
|
*/
|
|
async function openMirroredPlaylistModal(playlistId) {
|
|
showLoadingOverlay('Loading mirrored playlist...');
|
|
try {
|
|
const res = await fetch(`/api/mirrored-playlists/${playlistId}`);
|
|
const data = await res.json();
|
|
if (data.error) throw new Error(data.error);
|
|
|
|
hideLoadingOverlay();
|
|
|
|
// Remove any existing modal
|
|
const old = document.getElementById('mirrored-track-modal');
|
|
if (old) old.remove();
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.id = 'mirrored-track-modal';
|
|
overlay.className = 'mirrored-modal-overlay';
|
|
|
|
const tracks = data.tracks || [];
|
|
const source = data.source || 'unknown';
|
|
const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛' };
|
|
const sourceIcon = sourceIcons[source] || '📋';
|
|
|
|
const trackRows = tracks.map(t => {
|
|
const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : '';
|
|
return `<div class="mirrored-track-row">
|
|
<span class="track-pos">${t.position}</span>
|
|
<span class="track-title">${_esc(t.track_name)}</span>
|
|
<span class="track-artist">${_esc(t.artist_name)}</span>
|
|
<span class="track-album">${_esc(t.album_name)}</span>
|
|
<span class="track-duration">${dur}</span>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
overlay.innerHTML = `
|
|
<div class="mirrored-modal">
|
|
<div class="mirrored-modal-header">
|
|
<div class="mirrored-modal-hero">
|
|
<div class="mirrored-modal-hero-icon ${_escAttr(source)}">${sourceIcon}</div>
|
|
<div class="mirrored-modal-hero-info">
|
|
<h2 class="mirrored-modal-hero-title">${_esc(data.name)}</h2>
|
|
<div class="mirrored-modal-hero-subtitle">
|
|
<span class="mirrored-modal-hero-badge">${_esc(source)}</span>
|
|
<span>${tracks.length} tracks</span>
|
|
<span>·</span>
|
|
<span>Mirrored ${timeAgo(data.updated_at || data.mirrored_at)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<span class="mirrored-modal-close" onclick="closeMirroredModal()">×</span>
|
|
</div>
|
|
<div class="mirrored-modal-tracks">
|
|
<div class="mirrored-track-header">
|
|
<span>#</span><span>Track</span><span>Artist</span><span>Album</span><span style="text-align:right">Time</span>
|
|
</div>
|
|
${trackRows}
|
|
</div>
|
|
<div class="mirrored-modal-footer">
|
|
<div class="mirrored-modal-footer-left">
|
|
<button class="mirrored-btn-delete" onclick="closeMirroredModal(); deleteMirroredPlaylist(${playlistId}, '${_escAttr(data.name)}')">Delete Mirror</button>
|
|
</div>
|
|
<div class="mirrored-modal-footer-right" style="display:flex;gap:10px;">
|
|
<button class="mirrored-btn-close" onclick="closeMirroredModal()">Close</button>
|
|
<button class="mirrored-btn-discover" onclick="discoverMirroredPlaylist(${playlistId})">Discover</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
overlay.addEventListener('click', e => { if (e.target === overlay) closeMirroredModal(); });
|
|
document.body.appendChild(overlay);
|
|
} catch (err) {
|
|
hideLoadingOverlay();
|
|
showToast(`Error: ${err.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
function closeMirroredModal() {
|
|
const m = document.getElementById('mirrored-track-modal');
|
|
if (m) m.remove();
|
|
}
|
|
|
|
/**
|
|
* Delete a mirrored playlist after confirmation.
|
|
*/
|
|
async function clearMirroredDiscovery(playlistId, name) {
|
|
if (!await showConfirmDialog({ title: 'Clear Discovery Data', message: `Clear discovery data for "${name}"? You can re-discover afterwards to get updated cover art.` })) return;
|
|
try {
|
|
const res = await fetch(`/api/mirrored-playlists/${playlistId}/clear-discovery`, { method: 'POST' });
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
showToast(`Cleared discovery for ${name} (${data.cleared} tracks)`, 'success');
|
|
// Signal cancellation to any running worker, then clear state
|
|
const hash = `mirrored_${playlistId}`;
|
|
if (youtubePlaylistStates[hash]) {
|
|
youtubePlaylistStates[hash].phase = 'cancelled';
|
|
}
|
|
delete youtubePlaylistStates[hash];
|
|
const staleModal = document.getElementById(`youtube-discovery-modal-${hash}`);
|
|
if (staleModal) staleModal.remove();
|
|
loadMirroredPlaylists();
|
|
} else {
|
|
showToast(data.error || 'Failed to clear discovery', 'error');
|
|
}
|
|
} catch (err) {
|
|
showToast(`Error: ${err.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
// ==================== Discovery Pool Modal ====================
|
|
|
|
let _discoveryPoolOverlay = null;
|
|
let _discoveryPoolData = null;
|
|
let _discoveryPoolView = 'categories'; // 'categories' | 'failed' | 'matched'
|
|
let _discoveryPoolPlaylistFilter = null;
|
|
|
|
async function loadDiscoveryPoolStats() {
|
|
try {
|
|
const res = await fetch('/api/discovery-pool');
|
|
const data = await res.json();
|
|
const matchedEl = document.getElementById('discovery-pool-matched-count');
|
|
const failedEl = document.getElementById('discovery-pool-failed-count');
|
|
if (matchedEl) matchedEl.textContent = data.stats.matched || 0;
|
|
if (failedEl) failedEl.textContent = data.stats.failed || 0;
|
|
} catch (e) { }
|
|
}
|
|
|
|
async function openDiscoveryPoolModal(playlistId = null) {
|
|
_discoveryPoolPlaylistFilter = playlistId;
|
|
_discoveryPoolView = 'categories';
|
|
|
|
// Fetch pool data
|
|
let url = '/api/discovery-pool';
|
|
if (playlistId) url += `?playlist_id=${playlistId}`;
|
|
try {
|
|
const res = await fetch(url);
|
|
_discoveryPoolData = await res.json();
|
|
} catch (err) {
|
|
showToast('Failed to load discovery pool', 'error');
|
|
return;
|
|
}
|
|
|
|
// Remove existing overlay if present
|
|
if (_discoveryPoolOverlay) _discoveryPoolOverlay.remove();
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'modal-overlay';
|
|
overlay.id = 'discovery-pool-overlay';
|
|
overlay.onclick = (e) => { if (e.target === overlay) closeDiscoveryPoolModal(); };
|
|
|
|
const playlistOptions = (_discoveryPoolData.playlists || [])
|
|
.map(p => `<option value="${p.id}" ${playlistId == p.id ? 'selected' : ''}>${_esc(p.name)}</option>`)
|
|
.join('');
|
|
|
|
const failedCount = _discoveryPoolData.stats.failed || 0;
|
|
const matchedCount = _discoveryPoolData.stats.matched || 0;
|
|
|
|
overlay.innerHTML = `
|
|
<div class="modal-container playlist-modal">
|
|
<div class="playlist-modal-header">
|
|
<div class="playlist-header-content">
|
|
<h2>Discovery Pool</h2>
|
|
<div class="playlist-quick-info">
|
|
<span class="playlist-track-count" id="pool-header-matched">${matchedCount} Matched</span>
|
|
<span class="playlist-owner ${failedCount > 0 ? 'pool-header-failed-highlight' : ''}" id="pool-header-failed">${failedCount} Failed</span>
|
|
<select class="pool-playlist-filter" onchange="filterDiscoveryPool(this.value)">
|
|
<option value="">All Playlists</option>
|
|
${playlistOptions}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<span class="playlist-modal-close" onclick="closeDiscoveryPoolModal()">×</span>
|
|
</div>
|
|
|
|
<div class="playlist-modal-body">
|
|
<div class="pool-category-grid" id="pool-category-grid">
|
|
<div class="pool-category-card failed" onclick="showPoolList('failed')">
|
|
<div class="pool-category-fallback failed"></div>
|
|
<div class="pool-category-overlay"></div>
|
|
<div class="pool-category-content">
|
|
<div class="pool-category-icon">⚠</div>
|
|
<div class="pool-category-count failed" id="pool-cat-failed-count">${failedCount}</div>
|
|
<div class="pool-category-label">tracks need attention</div>
|
|
</div>
|
|
<div class="pool-category-top-bar failed"></div>
|
|
</div>
|
|
<div class="pool-category-card matched" onclick="showPoolList('matched')">
|
|
<div class="pool-category-fallback matched" id="pool-matched-bg"></div>
|
|
<div class="pool-category-overlay"></div>
|
|
<div class="pool-category-content">
|
|
<div class="pool-category-icon">✓</div>
|
|
<div class="pool-category-count matched" id="pool-cat-matched-count">${matchedCount}</div>
|
|
<div class="pool-category-label">cached matches</div>
|
|
</div>
|
|
<div class="pool-category-top-bar matched"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="pool-list-view" id="pool-list-view" style="display: none;">
|
|
<div class="pool-list-header">
|
|
<button class="pool-back-btn" onclick="showPoolCategories()">← Back</button>
|
|
<span class="pool-list-title" id="pool-list-title"></span>
|
|
<input type="text" class="pool-list-search" id="pool-list-search" placeholder="Filter tracks..." oninput="renderPoolList()">
|
|
</div>
|
|
<div class="pool-list-content" id="pool-list-content"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="playlist-modal-footer">
|
|
<div class="playlist-modal-footer-left"></div>
|
|
<div class="playlist-modal-footer-right">
|
|
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closeDiscoveryPoolModal()">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(overlay);
|
|
overlay.style.display = 'flex';
|
|
_discoveryPoolOverlay = overlay;
|
|
|
|
// Build matched mosaic if images available
|
|
_buildPoolMatchedMosaic();
|
|
}
|
|
|
|
function _buildPoolMatchedMosaic() {
|
|
const entries = _discoveryPoolData.matched || [];
|
|
const images = [];
|
|
for (const e of entries) {
|
|
const md = e.matched_data || {};
|
|
if (md.image_url && images.indexOf(md.image_url) === -1) {
|
|
images.push(md.image_url);
|
|
if (images.length >= 20) break;
|
|
}
|
|
}
|
|
const bgEl = document.getElementById('pool-matched-bg');
|
|
if (!bgEl || images.length < 4) return; // keep fallback gradient
|
|
|
|
// Build mosaic rows similar to wishlist
|
|
bgEl.innerHTML = '';
|
|
bgEl.className = 'wishlist-mosaic-background';
|
|
const rows = 4;
|
|
const imgPerRow = Math.ceil(images.length / rows) * 2; // duplicate for seamless loop
|
|
for (let r = 0; r < rows; r++) {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'wishlist-mosaic-row-wrapper';
|
|
const row = document.createElement('div');
|
|
row.className = 'wishlist-mosaic-row' + (r % 2 === 1 ? ' scroll-right' : '');
|
|
row.style.setProperty('--speed', (25 + r * 5) + 's');
|
|
row.style.animationDelay = (r * 0.15) + 's';
|
|
for (let i = 0; i < imgPerRow; i++) {
|
|
const img = images[(i + r * 3) % images.length];
|
|
const tile = document.createElement('div');
|
|
tile.className = 'wishlist-mosaic-tile';
|
|
tile.innerHTML = `<div class="wishlist-mosaic-image" style="background-image: url('${img}')"></div>`;
|
|
row.appendChild(tile);
|
|
}
|
|
wrapper.appendChild(row);
|
|
bgEl.appendChild(wrapper);
|
|
}
|
|
}
|
|
|
|
function closeDiscoveryPoolModal() {
|
|
if (_discoveryPoolOverlay) {
|
|
_discoveryPoolOverlay.remove();
|
|
_discoveryPoolOverlay = null;
|
|
}
|
|
_discoveryPoolData = null;
|
|
// Refresh dashboard stats
|
|
loadDiscoveryPoolStats();
|
|
}
|
|
|
|
function showPoolCategories() {
|
|
_discoveryPoolView = 'categories';
|
|
const grid = document.getElementById('pool-category-grid');
|
|
const list = document.getElementById('pool-list-view');
|
|
if (grid) grid.style.display = '';
|
|
if (list) list.style.display = 'none';
|
|
}
|
|
|
|
function showPoolList(category) {
|
|
_discoveryPoolView = category;
|
|
const grid = document.getElementById('pool-category-grid');
|
|
const list = document.getElementById('pool-list-view');
|
|
if (grid) grid.style.display = 'none';
|
|
if (list) list.style.display = '';
|
|
|
|
const titleEl = document.getElementById('pool-list-title');
|
|
if (titleEl) titleEl.textContent = category === 'failed' ? 'Failed Tracks' : 'Matched Tracks';
|
|
|
|
// Clear search filter when switching views
|
|
const searchEl = document.getElementById('pool-list-search');
|
|
if (searchEl) searchEl.value = '';
|
|
|
|
renderPoolList();
|
|
}
|
|
|
|
async function filterDiscoveryPool(playlistId) {
|
|
_discoveryPoolPlaylistFilter = playlistId || null;
|
|
let url = '/api/discovery-pool';
|
|
if (playlistId) url += `?playlist_id=${playlistId}`;
|
|
try {
|
|
const res = await fetch(url);
|
|
_discoveryPoolData = await res.json();
|
|
// Update header counts
|
|
_updatePoolHeaderCounts();
|
|
// Update category card counts
|
|
const failedCountEl = document.getElementById('pool-cat-failed-count');
|
|
const matchedCountEl = document.getElementById('pool-cat-matched-count');
|
|
if (failedCountEl) failedCountEl.textContent = _discoveryPoolData.stats.failed || 0;
|
|
if (matchedCountEl) matchedCountEl.textContent = _discoveryPoolData.stats.matched || 0;
|
|
// If viewing a list, refresh it
|
|
if (_discoveryPoolView === 'failed' || _discoveryPoolView === 'matched') {
|
|
renderPoolList();
|
|
}
|
|
} catch (err) {
|
|
showToast('Failed to filter discovery pool', 'error');
|
|
}
|
|
}
|
|
|
|
function _updatePoolHeaderCounts() {
|
|
if (!_discoveryPoolData) return;
|
|
const failedCount = _discoveryPoolData.stats.failed || 0;
|
|
const matchedCount = _discoveryPoolData.stats.matched || 0;
|
|
const matchedEl = document.getElementById('pool-header-matched');
|
|
const failedEl = document.getElementById('pool-header-failed');
|
|
if (matchedEl) matchedEl.textContent = `${matchedCount} Matched`;
|
|
if (failedEl) {
|
|
failedEl.textContent = `${failedCount} Failed`;
|
|
failedEl.classList.toggle('pool-header-failed-highlight', failedCount > 0);
|
|
}
|
|
}
|
|
|
|
function renderPoolList() {
|
|
const container = document.getElementById('pool-list-content');
|
|
if (!container || !_discoveryPoolData) return;
|
|
|
|
// Client-side search filter
|
|
const searchEl = document.getElementById('pool-list-search');
|
|
const query = (searchEl ? searchEl.value : '').toLowerCase().trim();
|
|
|
|
if (_discoveryPoolView === 'failed') {
|
|
let tracks = _discoveryPoolData.failed || [];
|
|
if (query) {
|
|
tracks = tracks.filter(t =>
|
|
(t.track_name || '').toLowerCase().includes(query) ||
|
|
(t.artist_name || '').toLowerCase().includes(query) ||
|
|
(t.playlist_name || '').toLowerCase().includes(query)
|
|
);
|
|
}
|
|
if (tracks.length === 0) {
|
|
container.innerHTML = query
|
|
? '<div class="pool-empty">No failed tracks match your filter.</div>'
|
|
: '<div class="pool-empty">No failed discoveries. All tracks matched successfully.</div>';
|
|
return;
|
|
}
|
|
container.innerHTML = tracks.map(t => `
|
|
<div class="pool-track-row pool-failed">
|
|
<div class="pool-track-info">
|
|
<div class="pool-track-name">${_esc(t.track_name)}</div>
|
|
<div class="pool-track-meta">
|
|
<span class="pool-track-artist">${_esc(t.artist_name)}</span>
|
|
<span class="pool-track-playlist-badge">${_esc(t.playlist_name)}</span>
|
|
</div>
|
|
</div>
|
|
<button class="playlist-modal-btn playlist-modal-btn-primary pool-fix-btn" onclick="openPoolFixModal(${t.id}, '${_escAttr(t.track_name)}', '${_escAttr(t.artist_name)}')">Fix Match</button>
|
|
</div>
|
|
`).join('');
|
|
} else {
|
|
let entries = _discoveryPoolData.matched || [];
|
|
if (query) {
|
|
entries = entries.filter(e => {
|
|
const md = e.matched_data || {};
|
|
const matchedName = md.name || '';
|
|
return (e.original_title || '').toLowerCase().includes(query) ||
|
|
(e.original_artist || '').toLowerCase().includes(query) ||
|
|
matchedName.toLowerCase().includes(query);
|
|
});
|
|
}
|
|
if (entries.length === 0) {
|
|
container.innerHTML = query
|
|
? '<div class="pool-empty">No matched tracks match your filter.</div>'
|
|
: '<div class="pool-empty">No cached discovery matches yet.</div>';
|
|
return;
|
|
}
|
|
container.innerHTML = entries.map(e => {
|
|
const md = e.matched_data || {};
|
|
const matchedArtists = (md.artists || []).map(a => typeof a === 'string' ? a : (a.name || '')).join(', ');
|
|
const conf = Math.round((e.confidence || 0) * 100);
|
|
const confClass = conf >= 80 ? 'high' : (conf >= 70 ? 'mid' : 'low');
|
|
const album = md.album || {};
|
|
const albumImages = (typeof album === 'object' && album.images) ? album.images : [];
|
|
const imgUrl = md.image_url || (albumImages.length > 0 ? albumImages[0].url || '' : '');
|
|
return `
|
|
<div class="pool-track-row pool-matched">
|
|
${imgUrl ? `<img class="pool-match-image" src="${_esc(imgUrl)}" alt="" onerror="this.style.display='none'" />` : '<div class="pool-match-image-placeholder"></div>'}
|
|
<div class="pool-track-info">
|
|
<div class="pool-track-name">${_esc(e.original_title)}</div>
|
|
<div class="pool-track-meta">
|
|
<span class="pool-track-artist">${_esc(e.original_artist)}</span>
|
|
<span class="pool-track-arrow">→</span>
|
|
<span class="pool-match-name">${_esc(md.name || '?')}</span>
|
|
<span class="pool-match-provider">${_esc(e.provider)}</span>
|
|
</div>
|
|
</div>
|
|
<span class="pool-confidence-badge ${confClass}">${conf}%</span>
|
|
<span class="pool-use-count">${e.use_count}×</span>
|
|
<button class="pool-rematch-btn" onclick="rematchPoolCacheEntry(${e.id}, '${_escAttr(e.original_title)}', '${_escAttr(e.original_artist)}')" title="Rematch this track">Rematch</button>
|
|
<button class="pool-remove-btn" onclick="removePoolCacheEntry(${e.id})" title="Remove cached match">×</button>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
}
|
|
|
|
function rematchPoolCacheEntry(cacheId, originalTitle, originalArtist) {
|
|
// Open the fix modal in "rematch" mode — saves to cache instead of mirrored tracks
|
|
openPoolRematchModal(cacheId, originalTitle, originalArtist);
|
|
}
|
|
|
|
function openPoolRematchModal(cacheId, trackName, artistName) {
|
|
// Reuses the fix modal UI but saves via the rematch endpoint
|
|
let fixOverlay = document.getElementById('pool-fix-overlay');
|
|
if (fixOverlay) fixOverlay.remove();
|
|
|
|
fixOverlay = document.createElement('div');
|
|
fixOverlay.className = 'pool-fix-overlay';
|
|
fixOverlay.id = 'pool-fix-overlay';
|
|
fixOverlay.addEventListener('mousedown', (e) => {
|
|
if (e.target === fixOverlay) {
|
|
e.preventDefault();
|
|
closePoolFixModal();
|
|
}
|
|
});
|
|
|
|
fixOverlay.innerHTML = `
|
|
<div class="pool-fix-modal" onmousedown="event.stopPropagation()">
|
|
<div class="pool-fix-header">
|
|
<h2>Rematch Track</h2>
|
|
<button class="pool-fix-close" onclick="closePoolFixModal()" title="Close">✕</button>
|
|
</div>
|
|
<div class="pool-fix-body">
|
|
<div class="pool-fix-source">
|
|
<div class="pool-fix-source-label">Current Match</div>
|
|
<div class="pool-fix-source-row">
|
|
<span class="pool-fix-source-title">${_esc(trackName)}</span>
|
|
<span class="pool-fix-source-sep">—</span>
|
|
<span class="pool-fix-source-artist">${_esc(artistName)}</span>
|
|
</div>
|
|
</div>
|
|
<div class="pool-fix-search">
|
|
<div class="pool-fix-input-row">
|
|
<div class="pool-fix-input-wrap">
|
|
<label for="pool-fix-track-input">Track</label>
|
|
<input type="text" id="pool-fix-track-input" placeholder="Track name" value="${_escAttr(trackName)}">
|
|
</div>
|
|
<div class="pool-fix-input-wrap">
|
|
<label for="pool-fix-artist-input">Artist</label>
|
|
<input type="text" id="pool-fix-artist-input" placeholder="Artist name" value="${_escAttr(artistName)}">
|
|
</div>
|
|
<button class="pool-fix-search-btn" onclick="searchPoolFix()">Search</button>
|
|
</div>
|
|
</div>
|
|
<div class="pool-fix-results-area">
|
|
<div id="pool-fix-results" class="pool-fix-results-list">
|
|
<div class="pool-fix-empty">Searching...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="pool-fix-footer">
|
|
<button class="pool-fix-cancel" onclick="closePoolFixModal()">Cancel</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Store rematch context
|
|
fixOverlay.dataset.mode = 'rematch';
|
|
fixOverlay.dataset.cacheId = cacheId;
|
|
fixOverlay.dataset.originalTitle = trackName;
|
|
fixOverlay.dataset.originalArtist = artistName;
|
|
document.body.appendChild(fixOverlay);
|
|
|
|
const trackInput = fixOverlay.querySelector('#pool-fix-track-input');
|
|
const artistInput = fixOverlay.querySelector('#pool-fix-artist-input');
|
|
const enterHandler = (e) => { if (e.key === 'Enter') searchPoolFix(); };
|
|
trackInput.addEventListener('keypress', enterHandler);
|
|
artistInput.addEventListener('keypress', enterHandler);
|
|
trackInput.focus();
|
|
trackInput.select();
|
|
|
|
setTimeout(() => searchPoolFix(), 500);
|
|
}
|
|
|
|
async function removePoolCacheEntry(entryId) {
|
|
if (!await showConfirmDialog({ title: 'Remove Cache Entry', message: 'Remove this cached match? The track will be re-discovered fresh next time.' })) return;
|
|
try {
|
|
const res = await fetch(`/api/discovery-pool/cache/${entryId}`, { method: 'DELETE' });
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
showToast('Cache entry removed', 'success');
|
|
filterDiscoveryPool(_discoveryPoolPlaylistFilter || '');
|
|
} else {
|
|
showToast(data.error || 'Failed to remove', 'error');
|
|
}
|
|
} catch (err) {
|
|
showToast(`Error: ${err.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
// --- Pool Fix Sub-Modal ---
|
|
|
|
function openPoolFixModal(trackId, trackName, artistName) {
|
|
// Create sub-modal overlay inside the pool modal
|
|
let fixOverlay = document.getElementById('pool-fix-overlay');
|
|
if (fixOverlay) fixOverlay.remove();
|
|
|
|
fixOverlay = document.createElement('div');
|
|
fixOverlay.className = 'pool-fix-overlay';
|
|
fixOverlay.id = 'pool-fix-overlay';
|
|
|
|
// Only close on click to the overlay itself — use a dedicated close zone
|
|
// to prevent accidental dismissal when clicking near inputs
|
|
fixOverlay.addEventListener('mousedown', (e) => {
|
|
if (e.target === fixOverlay) {
|
|
e.preventDefault(); // Prevent stealing focus from inputs
|
|
closePoolFixModal();
|
|
}
|
|
});
|
|
|
|
fixOverlay.innerHTML = `
|
|
<div class="pool-fix-modal" onmousedown="event.stopPropagation()">
|
|
<div class="pool-fix-header">
|
|
<h2>Fix Track Match</h2>
|
|
<button class="pool-fix-close" onclick="closePoolFixModal()" title="Close">✕</button>
|
|
</div>
|
|
<div class="pool-fix-body">
|
|
<div class="pool-fix-source">
|
|
<div class="pool-fix-source-label">Original Track</div>
|
|
<div class="pool-fix-source-row">
|
|
<span class="pool-fix-source-title">${_esc(trackName)}</span>
|
|
<span class="pool-fix-source-sep">—</span>
|
|
<span class="pool-fix-source-artist">${_esc(artistName)}</span>
|
|
</div>
|
|
</div>
|
|
<div class="pool-fix-search">
|
|
<div class="pool-fix-input-row">
|
|
<div class="pool-fix-input-wrap">
|
|
<label for="pool-fix-track-input">Track</label>
|
|
<input type="text" id="pool-fix-track-input" placeholder="Track name" value="${_escAttr(trackName)}">
|
|
</div>
|
|
<div class="pool-fix-input-wrap">
|
|
<label for="pool-fix-artist-input">Artist</label>
|
|
<input type="text" id="pool-fix-artist-input" placeholder="Artist name" value="${_escAttr(artistName)}">
|
|
</div>
|
|
<button class="pool-fix-search-btn" onclick="searchPoolFix()">Search</button>
|
|
</div>
|
|
</div>
|
|
<div class="pool-fix-results-area">
|
|
<div id="pool-fix-results" class="pool-fix-results-list">
|
|
<div class="pool-fix-empty">Searching...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="pool-fix-footer">
|
|
<button class="pool-fix-cancel" onclick="closePoolFixModal()">Cancel</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
fixOverlay.dataset.trackId = trackId;
|
|
document.body.appendChild(fixOverlay);
|
|
|
|
// Add enter key support
|
|
const trackInput = fixOverlay.querySelector('#pool-fix-track-input');
|
|
const artistInput = fixOverlay.querySelector('#pool-fix-artist-input');
|
|
const enterHandler = (e) => { if (e.key === 'Enter') searchPoolFix(); };
|
|
trackInput.addEventListener('keypress', enterHandler);
|
|
artistInput.addEventListener('keypress', enterHandler);
|
|
|
|
// Focus the track input
|
|
trackInput.focus();
|
|
trackInput.select();
|
|
|
|
// Auto-search after a delay
|
|
setTimeout(() => searchPoolFix(), 500);
|
|
}
|
|
|
|
function closePoolFixModal() {
|
|
const fixOverlay = document.getElementById('pool-fix-overlay');
|
|
if (fixOverlay) fixOverlay.remove();
|
|
}
|
|
|
|
async function searchPoolFix() {
|
|
const trackInput = document.getElementById('pool-fix-track-input');
|
|
const artistInput = document.getElementById('pool-fix-artist-input');
|
|
const resultsContainer = document.getElementById('pool-fix-results');
|
|
if (!trackInput || !resultsContainer) return;
|
|
|
|
const trackVal = trackInput.value.trim();
|
|
const artistVal = artistInput.value.trim();
|
|
if (!trackVal && !artistVal) {
|
|
resultsContainer.innerHTML = '<div class="pool-fix-empty">Enter a search term</div>';
|
|
return;
|
|
}
|
|
|
|
resultsContainer.innerHTML = '<div class="pool-fix-empty"><div class="pool-fix-spinner"></div>Searching...</div>';
|
|
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (trackVal) params.set('track', trackVal);
|
|
if (artistVal) params.set('artist', artistVal);
|
|
params.set('limit', '20');
|
|
const res = await fetch(`/api/spotify/search_tracks?${params.toString()}`);
|
|
const data = await res.json();
|
|
const tracks = data.tracks || [];
|
|
|
|
if (tracks.length === 0) {
|
|
resultsContainer.innerHTML = '<div class="pool-fix-empty">No results found</div>';
|
|
return;
|
|
}
|
|
|
|
resultsContainer.innerHTML = tracks.map((track) => {
|
|
const artists = (track.artists || []).join(', ');
|
|
const duration = track.duration_ms ? formatDuration(track.duration_ms) : '';
|
|
const albumText = track.album ? ` · ${_esc(track.album)}` : '';
|
|
return `
|
|
<div class="pool-fix-result" onclick='selectPoolFixTrack(${JSON.stringify(track).replace(/'/g, "'")})'>
|
|
<div class="pool-fix-result-main">
|
|
<div class="pool-fix-result-title">${_esc(track.name || 'Unknown')}</div>
|
|
<div class="pool-fix-result-meta">${_esc(artists)}${albumText}</div>
|
|
</div>
|
|
${duration ? `<div class="pool-fix-result-dur">${duration}</div>` : ''}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
} catch (err) {
|
|
resultsContainer.innerHTML = `<div class="pool-fix-empty">Search failed: ${_esc(err.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
async function selectPoolFixTrack(track) {
|
|
const fixOverlay = document.getElementById('pool-fix-overlay');
|
|
if (!fixOverlay) return;
|
|
|
|
// Confirm selection
|
|
const artists = (track.artists || []).join(', ');
|
|
if (!await showConfirmDialog({ title: 'Confirm Match', message: `Match to "${track.name}" by ${artists}?`, confirmText: 'Confirm' })) return;
|
|
|
|
const isRematch = fixOverlay.dataset.mode === 'rematch';
|
|
|
|
try {
|
|
let res, data;
|
|
if (isRematch) {
|
|
// Rematch mode: save new match to discovery cache
|
|
res = await fetch('/api/discovery-pool/rematch', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
cache_id: parseInt(fixOverlay.dataset.cacheId),
|
|
original_title: fixOverlay.dataset.originalTitle,
|
|
original_artist: fixOverlay.dataset.originalArtist,
|
|
spotify_track: track,
|
|
}),
|
|
});
|
|
data = await res.json();
|
|
} else {
|
|
// Normal fix mode: save to mirrored track
|
|
const trackId = parseInt(fixOverlay.dataset.trackId);
|
|
res = await fetch('/api/discovery-pool/fix', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
track_id: trackId,
|
|
spotify_track: track,
|
|
}),
|
|
});
|
|
data = await res.json();
|
|
}
|
|
|
|
if (data.success) {
|
|
showToast(`Matched: ${track.name}`, 'success');
|
|
closePoolFixModal();
|
|
// Refresh pool data
|
|
filterDiscoveryPool(_discoveryPoolPlaylistFilter || '');
|
|
} else {
|
|
showToast(data.error || 'Failed to fix track', 'error');
|
|
}
|
|
} catch (err) {
|
|
showToast(`Error: ${err.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function deleteMirroredPlaylist(playlistId, name) {
|
|
if (!await showConfirmDialog({ title: 'Delete Playlist', message: `Delete mirrored playlist "${name}"?`, confirmText: 'Delete', destructive: true })) return;
|
|
try {
|
|
const res = await fetch(`/api/mirrored-playlists/${playlistId}`, { method: 'DELETE' });
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
showToast(`Deleted mirror: ${name}`, 'success');
|
|
loadMirroredPlaylists();
|
|
} else {
|
|
showToast(data.error || 'Failed to delete', 'error');
|
|
}
|
|
} catch (err) {
|
|
showToast(`Error: ${err.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Launch the existing discovery modal for a mirrored playlist by creating
|
|
* a temporary entry in youtubePlaylistStates and reusing openYouTubeDiscoveryModal.
|
|
*/
|
|
async function discoverMirroredPlaylist(playlistId) {
|
|
closeMirroredModal();
|
|
const tempHash = `mirrored_${playlistId}`;
|
|
|
|
// If state already exists (discovery in progress or completed), just reopen the modal
|
|
const existingState = youtubePlaylistStates[tempHash];
|
|
const hasActiveDiscovery = activeYouTubePollers[tempHash] || document.getElementById(`youtube-discovery-modal-${tempHash}`);
|
|
if (existingState && (existingState.phase !== 'fresh' || hasActiveDiscovery)) {
|
|
openYouTubeDiscoveryModal(tempHash);
|
|
// Resume polling if discovery is in progress but poller stopped
|
|
if (existingState.phase === 'discovering' && !activeYouTubePollers[tempHash]) {
|
|
startYouTubeDiscoveryPolling(tempHash);
|
|
}
|
|
return;
|
|
}
|
|
|
|
showLoadingOverlay('Preparing discovery...');
|
|
try {
|
|
// Register the mirrored playlist on the backend so the YouTube discovery pipeline can find it
|
|
const prepRes = await fetch(`/api/mirrored-playlists/${playlistId}/prepare-discovery`, { method: 'POST' });
|
|
const prepData = await prepRes.json();
|
|
if (prepData.error) throw new Error(prepData.error);
|
|
|
|
// Also fetch the full data for the frontend state
|
|
const res = await fetch(`/api/mirrored-playlists/${playlistId}`);
|
|
const data = await res.json();
|
|
if (data.error) throw new Error(data.error);
|
|
hideLoadingOverlay();
|
|
|
|
// Build tracks in the format the discovery modal expects
|
|
const tracks = (data.tracks || []).map(t => ({
|
|
id: t.source_track_id || `mirrored_${t.id}`,
|
|
name: t.track_name,
|
|
artists: [t.artist_name],
|
|
album: t.album_name || '',
|
|
duration_ms: t.duration_ms || 0
|
|
}));
|
|
|
|
// Check if backend returned cached results
|
|
if (prepData.from_cache) {
|
|
// Fetch the pre-populated status from the backend
|
|
const statusRes = await fetch(`/api/youtube/discovery/status/${tempHash}`);
|
|
const statusData = await statusRes.json();
|
|
if (statusData.error) throw new Error(statusData.error);
|
|
|
|
youtubePlaylistStates[tempHash] = {
|
|
playlist: {
|
|
name: data.name,
|
|
tracks: tracks,
|
|
track_count: tracks.length
|
|
},
|
|
phase: statusData.phase || 'discovered',
|
|
discovery_results: statusData.results || [],
|
|
discoveryResults: statusData.results || [],
|
|
discovery_progress: statusData.progress || 100,
|
|
spotify_matches: statusData.spotify_matches || 0,
|
|
spotifyMatches: statusData.spotify_matches || 0,
|
|
spotify_total: tracks.length,
|
|
status: statusData.status || 'complete',
|
|
url: `mirrored://${data.source}/${data.source_playlist_id}`,
|
|
sync_playlist_id: null,
|
|
converted_spotify_playlist_id: null,
|
|
download_process_id: null,
|
|
created_at: Date.now() / 1000,
|
|
last_accessed: Date.now() / 1000,
|
|
discovery_future: null,
|
|
sync_progress: {},
|
|
is_mirrored_playlist: true,
|
|
mirrored_source: data.source
|
|
};
|
|
|
|
const cached = prepData.cached_matches || 0;
|
|
const total = prepData.total_tracks || tracks.length;
|
|
showToast(`Loaded ${cached}/${total} cached discovery results`, 'success');
|
|
} else {
|
|
// No cached data — fresh state
|
|
youtubePlaylistStates[tempHash] = {
|
|
playlist: {
|
|
name: data.name,
|
|
tracks: tracks,
|
|
track_count: tracks.length
|
|
},
|
|
phase: 'fresh',
|
|
discovery_results: [],
|
|
discovery_progress: 0,
|
|
spotify_matches: 0,
|
|
spotify_total: tracks.length,
|
|
status: 'parsed',
|
|
url: `mirrored://${data.source}/${data.source_playlist_id}`,
|
|
sync_playlist_id: null,
|
|
converted_spotify_playlist_id: null,
|
|
download_process_id: null,
|
|
created_at: Date.now() / 1000,
|
|
last_accessed: Date.now() / 1000,
|
|
discovery_future: null,
|
|
sync_progress: {},
|
|
is_mirrored_playlist: true,
|
|
mirrored_source: data.source
|
|
};
|
|
}
|
|
|
|
openYouTubeDiscoveryModal(tempHash);
|
|
} catch (err) {
|
|
hideLoadingOverlay();
|
|
showToast(`Error: ${err.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
// ===============================
|
|
// AUTOMATIONS — Visual Builder
|
|
// ===============================
|
|
|
|
async function retryFailedMirroredDiscovery(urlHash) {
|
|
// Extract playlist ID from url_hash (format: "mirrored_<id>")
|
|
const playlistId = urlHash.replace('mirrored_', '');
|
|
try {
|
|
const res = await fetch(`/api/mirrored-playlists/${playlistId}/retry-failed-discovery`, { method: 'POST' });
|
|
const data = await res.json();
|
|
if (data.error) {
|
|
showToast(`Error: ${data.error}`, 'error');
|
|
return;
|
|
}
|
|
if (data.retry_count === 0) {
|
|
showToast('All tracks already found!', 'success');
|
|
return;
|
|
}
|
|
|
|
// Update frontend state to discovering
|
|
const state = youtubePlaylistStates[urlHash];
|
|
if (state) {
|
|
state.phase = 'discovering';
|
|
state.status = 'discovering';
|
|
state.discovery_progress = 0;
|
|
}
|
|
|
|
// Update modal buttons to show discovering state
|
|
updateYouTubeModalButtons(urlHash, 'discovering');
|
|
|
|
// Start polling for progress
|
|
startYouTubeDiscoveryPolling(urlHash);
|
|
|
|
showToast(`Retrying ${data.retry_count} failed tracks...`, 'info');
|
|
} catch (err) {
|
|
showToast(`Error retrying discovery: ${err.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
let _autoBlocks = null; // cached block definitions from /api/automations/blocks
|
|
let _autoBuilder = { editId: null, when: null, do: null, then: [], isSystem: false };
|
|
|
|
let _autoMirroredPlaylists = null; // cached mirrored playlist list
|
|
let _autoSpotifyAuthenticated = false; // whether Spotify is authed (for refresh filtering)
|
|
|
|
const _autoIcons = {
|
|
schedule: '\u23F1\uFE0F', daily_time: '\u{1F570}\uFE0F', weekly_time: '\uD83D\uDCC5', app_started: '\uD83D\uDE80', track_downloaded: '\u2B07\uFE0F', batch_complete: '\u2705',
|
|
watchlist_new_release: '\uD83D\uDD14', playlist_synced: '\uD83D\uDD04',
|
|
playlist_changed: '\u270F\uFE0F',
|
|
process_wishlist: '\uD83D\uDCCB', scan_watchlist: '\uD83D\uDC41\uFE0F',
|
|
scan_library: '\uD83D\uDD04', refresh_mirrored: '\uD83D\uDCC2', sync_playlist: '\uD83D\uDD01',
|
|
discover_playlist: '\uD83D\uDD0D', discovery_completed: '\uD83D\uDD0D',
|
|
notify_only: '\uD83D\uDD14', discord_webhook: '\uD83D\uDCAC', pushbullet: '\uD83D\uDD14', telegram: '\u2709\uFE0F', webhook: '\uD83C\uDF10',
|
|
signal_received: '\u26A1', fire_signal: '\u26A1', run_script: '\uD83D\uDCBB',
|
|
// Phase 3
|
|
wishlist_processing_completed: '\u2705', watchlist_scan_completed: '\u2705',
|
|
database_update_completed: '\uD83D\uDDC4\uFE0F', download_failed: '\u274C',
|
|
download_quarantined: '\u26A0\uFE0F', wishlist_item_added: '\u2795',
|
|
watchlist_artist_added: '\uD83D\uDC64', watchlist_artist_removed: '\uD83D\uDC64',
|
|
import_completed: '\uD83D\uDCE5', mirrored_playlist_created: '\uD83D\uDCC2',
|
|
quality_scan_completed: '\uD83D\uDCCA', duplicate_scan_completed: '\uD83D\uDDC2\uFE0F', library_scan_completed: '\uD83D\uDCE1',
|
|
start_database_update: '\uD83D\uDDC4\uFE0F', run_duplicate_cleaner: '\uD83D\uDDC2\uFE0F',
|
|
clear_quarantine: '\uD83D\uDDD1\uFE0F', cleanup_wishlist: '\uD83E\uDDF9',
|
|
update_discovery_pool: '\uD83E\uDDED', start_quality_scan: '\uD83D\uDCCA',
|
|
backup_database: '\uD83D\uDCBE',
|
|
refresh_beatport_cache: '\uD83C\uDFB5',
|
|
clean_search_history: '\uD83D\uDDD1\uFE0F',
|
|
clean_completed_downloads: '\u2705',
|
|
full_cleanup: '\uD83E\uDDF9',
|
|
playlist_pipeline: '\uD83D\uDE80',
|
|
};
|
|
|
|
// --- Inspiration Templates ---
|
|
// --- Automation Hub Data ---
|
|
|
|
// ── Automation Hub: One-Click Pipeline Groups ──
|
|
const AUTO_HUB_GROUPS = [
|
|
{
|
|
id: 'playlist-pipeline', icon: '🚀', name: 'Playlist Pipeline (All-in-One)',
|
|
desc: 'Single automation that runs the full playlist lifecycle: refresh → discover → sync → download missing. No signal wiring needed.',
|
|
category: 'Sync', badge: '1 automation', color: '#8b5cf6',
|
|
steps: [
|
|
{ label: 'Refresh', icon: '🔄', type: 'action' },
|
|
{ label: 'Discover', icon: '🔍', type: 'action' },
|
|
{ label: 'Sync', icon: '🔗', type: 'action' },
|
|
{ label: 'Download', icon: '📥', type: 'action' },
|
|
],
|
|
automations: [
|
|
{ name: 'Playlist Pipeline', trigger_type: 'schedule', trigger_config: { interval: 6, unit: 'hours' }, action_type: 'playlist_pipeline', action_config: { all: true }, then_actions: [], group_name: 'Playlist Pipeline' },
|
|
]
|
|
},
|
|
{
|
|
id: 'new-music-pipeline', icon: '🚀', name: 'New Music Pipeline',
|
|
desc: 'Full hands-free new music workflow. Scans your watchlist for releases, downloads them, cleans up, and notifies you.',
|
|
category: 'Discovery', badge: '4 automations', color: '#f97316',
|
|
steps: [
|
|
{ label: 'Scan Artists', icon: '🔍', type: 'action' },
|
|
{ label: 'Download', icon: '📥', type: 'action' },
|
|
{ label: 'Cleanup', icon: '🧹', type: 'action' },
|
|
{ label: 'Notify', icon: '🔔', type: 'notify' },
|
|
],
|
|
automations: [
|
|
{ name: 'New Music — Scan Watchlist', trigger_type: 'schedule', trigger_config: { interval: 12, unit: 'hours' }, action_type: 'scan_watchlist', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'nm_scanned' } }], group_name: 'New Music Pipeline' },
|
|
{ name: 'New Music — Download', trigger_type: 'signal_received', trigger_config: { signal_name: 'nm_scanned' }, action_type: 'process_wishlist', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'nm_downloaded' } }], group_name: 'New Music Pipeline' },
|
|
{ name: 'New Music — Cleanup', trigger_type: 'signal_received', trigger_config: { signal_name: 'nm_downloaded' }, action_type: 'full_cleanup', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'nm_cleaned' } }], group_name: 'New Music Pipeline' },
|
|
{ name: 'New Music — Notify', trigger_type: 'signal_received', trigger_config: { signal_name: 'nm_cleaned' }, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'New Music Pipeline', needs_notify: true },
|
|
]
|
|
},
|
|
{
|
|
id: 'nightly-ops', icon: '🌙', name: 'Nightly Operations',
|
|
desc: 'Staggered overnight maintenance: scan, download, cleanup, and backup while you sleep.',
|
|
category: 'Maintenance', badge: '4 automations', color: '#8b5cf6',
|
|
steps: [
|
|
{ label: '1AM Scan', icon: '🔍', type: 'action' },
|
|
{ label: '2AM Download', icon: '📥', type: 'action' },
|
|
{ label: '3AM Cleanup', icon: '🧹', type: 'action' },
|
|
{ label: '4AM Backup', icon: '💾', type: 'action' },
|
|
],
|
|
automations: [
|
|
{ name: 'Nightly — 1AM Scan', trigger_type: 'daily_time', trigger_config: { time: '01:00' }, action_type: 'scan_watchlist', action_config: {}, then_actions: [], group_name: 'Nightly Operations' },
|
|
{ name: 'Nightly — 2AM Download', trigger_type: 'daily_time', trigger_config: { time: '02:00' }, action_type: 'process_wishlist', action_config: {}, then_actions: [], group_name: 'Nightly Operations' },
|
|
{ name: 'Nightly — 3AM Cleanup', trigger_type: 'daily_time', trigger_config: { time: '03:00' }, action_type: 'full_cleanup', action_config: {}, then_actions: [], group_name: 'Nightly Operations' },
|
|
{ name: 'Nightly — 4AM Backup', trigger_type: 'daily_time', trigger_config: { time: '04:00' }, action_type: 'backup_database', action_config: {}, then_actions: [], group_name: 'Nightly Operations' },
|
|
]
|
|
},
|
|
{
|
|
id: 'download-monitor', icon: '📊', name: 'Download Monitor',
|
|
desc: 'Stay informed about your downloads. Get notified on failures, quarantined files, and completed batches.',
|
|
category: 'Alerts', badge: '3 automations', color: '#ef4444',
|
|
steps: [
|
|
{ label: 'Failures', icon: '❌', type: 'notify' },
|
|
{ label: 'Quarantine', icon: '⚠️', type: 'notify' },
|
|
{ label: 'Complete', icon: '✅', type: 'notify' },
|
|
],
|
|
automations: [
|
|
{ name: 'Alert — Download Failed', trigger_type: 'download_failed', trigger_config: {}, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'Download Monitor', needs_notify: true },
|
|
{ name: 'Alert — File Quarantined', trigger_type: 'download_quarantined', trigger_config: {}, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'Download Monitor', needs_notify: true },
|
|
{ name: 'Alert — Batch Complete', trigger_type: 'batch_complete', trigger_config: {}, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'Download Monitor', needs_notify: true },
|
|
]
|
|
},
|
|
{
|
|
id: 'library-guardian', icon: '🛡️', name: 'Library Guardian',
|
|
desc: 'Protect your library quality. After scans, runs quality checks and notifies you of any issues found.',
|
|
category: 'Maintenance', badge: '2 automations', color: '#f59e0b',
|
|
steps: [
|
|
{ label: 'Quality Scan', icon: '✅', type: 'action' },
|
|
{ label: 'Notify', icon: '🔔', type: 'notify' },
|
|
],
|
|
automations: [
|
|
{ name: 'Guardian — Quality Check', trigger_type: 'library_scan_completed', trigger_config: {}, action_type: 'start_quality_scan', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'guardian_quality_done' } }], group_name: 'Library Guardian' },
|
|
{ name: 'Guardian — Notify', trigger_type: 'signal_received', trigger_config: { signal_name: 'guardian_quality_done' }, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'Library Guardian', needs_notify: true },
|
|
]
|
|
},
|
|
{
|
|
id: 'startup-recovery', icon: '⚡', name: 'Startup Recovery',
|
|
desc: 'Self-heal after a restart. Scans your library, processes pending wishlist items, and cleans up automatically.',
|
|
category: 'Maintenance', badge: '3 automations', color: '#14b8a6',
|
|
steps: [
|
|
{ label: 'Scan Library', icon: '📚', type: 'action' },
|
|
{ label: 'Process Wishlist', icon: '📥', type: 'action' },
|
|
{ label: 'Cleanup', icon: '🧹', type: 'action' },
|
|
],
|
|
automations: [
|
|
{ name: 'Startup — Scan Library', trigger_type: 'app_started', trigger_config: {}, action_type: 'scan_library', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'startup_scanned' } }], group_name: 'Startup Recovery' },
|
|
{ name: 'Startup — Process Wishlist', trigger_type: 'signal_received', trigger_config: { signal_name: 'startup_scanned' }, action_type: 'process_wishlist', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'startup_processed' } }], group_name: 'Startup Recovery' },
|
|
{ name: 'Startup — Cleanup', trigger_type: 'signal_received', trigger_config: { signal_name: 'startup_processed' }, action_type: 'full_cleanup', action_config: {}, then_actions: [], group_name: 'Startup Recovery' },
|
|
]
|
|
},
|
|
{
|
|
id: 'import-pipeline', icon: '📦', name: 'Import Pipeline',
|
|
desc: 'After importing files, automatically scans your library, runs a quality check, and notifies you when complete.',
|
|
category: 'Maintenance', badge: '3 automations', color: '#a855f7',
|
|
steps: [
|
|
{ label: 'Scan Library', icon: '📚', type: 'action' },
|
|
{ label: 'Quality Check', icon: '✅', type: 'action' },
|
|
{ label: 'Notify', icon: '🔔', type: 'notify' },
|
|
],
|
|
automations: [
|
|
{ name: 'Import — Scan Library', trigger_type: 'import_completed', trigger_config: {}, action_type: 'scan_library', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'import_scanned' } }], group_name: 'Import Pipeline' },
|
|
{ name: 'Import — Quality Check', trigger_type: 'signal_received', trigger_config: { signal_name: 'import_scanned' }, action_type: 'start_quality_scan', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'import_quality_done' } }], group_name: 'Import Pipeline' },
|
|
{ name: 'Import — Notify', trigger_type: 'signal_received', trigger_config: { signal_name: 'import_quality_done' }, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'Import Pipeline', needs_notify: true },
|
|
]
|
|
},
|
|
{
|
|
id: 'weekly-deep-clean', icon: '✨', name: 'Weekly Deep Clean',
|
|
desc: 'Comprehensive weekly sweep: find duplicates, check quality, clean up, back up, and report results.',
|
|
category: 'Maintenance', badge: '5 automations', color: '#ec4899',
|
|
steps: [
|
|
{ label: 'Duplicates', icon: '📋', type: 'action' },
|
|
{ label: 'Quality', icon: '✅', type: 'action' },
|
|
{ label: 'Cleanup', icon: '🧹', type: 'action' },
|
|
{ label: 'Backup', icon: '💾', type: 'action' },
|
|
{ label: 'Notify', icon: '🔔', type: 'notify' },
|
|
],
|
|
automations: [
|
|
{ name: 'Deep Clean — Duplicates', trigger_type: 'weekly_time', trigger_config: { days: ['sunday'], time: '02:00' }, action_type: 'run_duplicate_cleaner', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'dc_dedup_done' } }], group_name: 'Weekly Deep Clean' },
|
|
{ name: 'Deep Clean — Quality', trigger_type: 'signal_received', trigger_config: { signal_name: 'dc_dedup_done' }, action_type: 'start_quality_scan', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'dc_quality_done' } }], group_name: 'Weekly Deep Clean' },
|
|
{ name: 'Deep Clean — Cleanup', trigger_type: 'signal_received', trigger_config: { signal_name: 'dc_quality_done' }, action_type: 'full_cleanup', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'dc_cleanup_done' } }], group_name: 'Weekly Deep Clean' },
|
|
{ name: 'Deep Clean — Backup', trigger_type: 'signal_received', trigger_config: { signal_name: 'dc_cleanup_done' }, action_type: 'backup_database', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'dc_backup_done' } }], group_name: 'Weekly Deep Clean' },
|
|
{ name: 'Deep Clean — Notify', trigger_type: 'signal_received', trigger_config: { signal_name: 'dc_backup_done' }, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'Weekly Deep Clean', needs_notify: true },
|
|
]
|
|
},
|
|
{
|
|
id: 'beatport-fresh', icon: '🎧', name: 'Beatport Fresh',
|
|
desc: 'Keep your Beatport charts and playlists up to date with a daily cache refresh.',
|
|
category: 'Discovery', badge: '1 automation', color: '#84cc16',
|
|
steps: [
|
|
{ label: 'Refresh Cache', icon: '🔄', type: 'action' },
|
|
],
|
|
automations: [
|
|
{ name: 'Beatport — Daily Refresh', trigger_type: 'daily_time', trigger_config: { time: '05:00' }, action_type: 'refresh_beatport_cache', action_config: {}, then_actions: [], group_name: 'Beatport Fresh' },
|
|
]
|
|
},
|
|
];
|
|
|
|
const AUTO_HUB_RECIPES = [
|
|
// Sync & Playlists
|
|
{
|
|
id: 'spotify-auto-sync', icon: '\uD83D\uDD01', name: 'Spotify Playlist Auto-Sync', desc: 'Refresh all mirrored playlists every 6 hours to keep them in sync with Spotify.',
|
|
category: 'Sync', difficulty: 'beginner', when: { type: 'schedule', config: { interval: 6, unit: 'hours' } }, do: { type: 'refresh_mirrored', config: {} }, then: []
|
|
},
|
|
{
|
|
id: 'release-radar-pipeline', icon: '\uD83D\uDCE1', name: 'Release Radar Pipeline', desc: 'Every Friday, refresh mirrored playlists, discover new tracks, then sync. Chain 3 automations for a full pipeline.',
|
|
category: 'Sync', difficulty: 'intermediate', when: { type: 'weekly_time', config: { days: ['friday'], time: '18:00' } }, do: { type: 'refresh_mirrored', config: {} }, then: [],
|
|
chain: ['Refresh Mirrored', 'Discover Playlist', 'Sync Playlist'], note: 'Create 3 separate automations and chain them with signals for the full pipeline.'
|
|
},
|
|
{
|
|
id: 'discover-weekly-grab', icon: '\uD83C\uDFB5', name: 'Discover Weekly Grab', desc: 'Every Monday, refresh your mirrored Discover Weekly to capture the new playlist before Spotify replaces it.',
|
|
category: 'Sync', difficulty: 'beginner', when: { type: 'weekly_time', config: { days: ['monday'], time: '08:00' } }, do: { type: 'refresh_mirrored', config: {} }, then: []
|
|
},
|
|
{
|
|
id: 'playlist-change-watcher', icon: '\uD83D\uDD14', name: 'Playlist Change Watcher', desc: 'Get a Discord notification whenever any tracked playlist changes.',
|
|
category: 'Sync', difficulty: 'beginner', when: { type: 'playlist_changed', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'discord_webhook', config: {} }]
|
|
},
|
|
{
|
|
id: 'new-mirror-discovery', icon: '\uD83D\uDD0D', name: 'New Mirror Auto-Discovery', desc: 'Automatically discover tracks when you mirror a new playlist.',
|
|
category: 'Sync', difficulty: 'beginner', when: { type: 'mirrored_playlist_created', config: {} }, do: { type: 'discover_playlist', config: {} }, then: []
|
|
},
|
|
// New Music Discovery
|
|
{
|
|
id: 'complete-new-release', icon: '\uD83D\uDE80', name: 'Complete New Release Pipeline', desc: 'Full hands-free chain: scan watchlist \u2192 process wishlist \u2192 quality scan \u2192 notify. Requires 3 automations linked by signals.',
|
|
category: 'Discovery', difficulty: 'advanced', when: { type: 'schedule', config: { interval: 12, unit: 'hours' } }, do: { type: 'scan_watchlist', config: {} }, then: [{ type: 'fire_signal', config: { signal_name: 'watchlist_done' } }],
|
|
chain: ['Scan Watchlist', '\u26A1 watchlist_done', 'Process Wishlist', '\u26A1 wishlist_done', 'Quality Scan', 'Discord'],
|
|
note: 'Create 3 automations: (1) Schedule\u2192Scan Watchlist\u2192fire watchlist_done, (2) Signal watchlist_done\u2192Process Wishlist\u2192fire wishlist_done, (3) Signal wishlist_done\u2192Quality Scan\u2192Discord.'
|
|
},
|
|
{
|
|
id: 'new-release-monitor', icon: '\uD83D\uDD14', name: 'New Release Monitor', desc: 'Scan your watchlist for new releases every 12 hours.',
|
|
category: 'Discovery', difficulty: 'beginner', when: { type: 'schedule', config: { interval: 12, unit: 'hours' } }, do: { type: 'scan_watchlist', config: {} }, then: []
|
|
},
|
|
{
|
|
id: 'artist-watch-alert', icon: '\uD83C\uDFA4', name: 'Artist Watch Alert', desc: 'Get a Telegram notification when you add a new artist to your watchlist.',
|
|
category: 'Discovery', difficulty: 'beginner', when: { type: 'watchlist_artist_added', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'telegram', config: {} }]
|
|
},
|
|
{
|
|
id: 'discovery-pool-refresh', icon: '\uD83C\uDF10', name: 'Discovery Pool Refresh', desc: 'Refresh the discovery pool every night at 2 AM with fresh recommendations.',
|
|
category: 'Discovery', difficulty: 'beginner', when: { type: 'daily_time', config: { time: '02:00' } }, do: { type: 'update_discovery_pool', config: {} }, then: []
|
|
},
|
|
{
|
|
id: 'nightly-wishlist', icon: '\uD83C\uDF19', name: 'Nightly Wishlist Processor', desc: 'Process your wishlist at 3 AM every night while you sleep.',
|
|
category: 'Discovery', difficulty: 'beginner', when: { type: 'daily_time', config: { time: '03:00' } }, do: { type: 'process_wishlist', config: {} }, then: []
|
|
},
|
|
// Library Maintenance
|
|
{
|
|
id: 'full-library-maintenance', icon: '\uD83E\uDDF9', name: 'Full Library Maintenance', desc: 'Run full cleanup every Saturday at 5 AM \u2014 dedup, quarantine, wishlist tidy.',
|
|
category: 'Maintenance', difficulty: 'intermediate', when: { type: 'weekly_time', config: { days: ['saturday'], time: '05:00' } }, do: { type: 'full_cleanup', config: {} }, then: []
|
|
},
|
|
{
|
|
id: 'post-batch-cleanup', icon: '\uD83E\uDDF9', name: 'Post-Batch Cleanup', desc: 'Run a full cleanup after any batch download completes.',
|
|
category: 'Maintenance', difficulty: 'beginner', when: { type: 'batch_complete', config: {} }, do: { type: 'full_cleanup', config: {} }, then: []
|
|
},
|
|
{
|
|
id: 'weekly-db-backup', icon: '\uD83D\uDCBE', name: 'Weekly Database Backup', desc: 'Back up your database every Sunday at 4 AM.',
|
|
category: 'Maintenance', difficulty: 'beginner', when: { type: 'weekly_time', config: { days: ['sunday'], time: '04:00' } }, do: { type: 'backup_database', config: {} }, then: []
|
|
},
|
|
{
|
|
id: 'quality-assurance', icon: '\u2705', name: 'Quality Assurance Pipeline', desc: 'After a library scan completes, run a quality scan and fire a signal when done.',
|
|
category: 'Maintenance', difficulty: 'intermediate', when: { type: 'library_scan_completed', config: {} }, do: { type: 'start_quality_scan', config: {} }, then: [{ type: 'fire_signal', config: { signal_name: 'quality_done' } }]
|
|
},
|
|
{
|
|
id: 'import-cleanup', icon: '\uD83D\uDCE5', name: 'Import Cleanup', desc: 'Automatically scan the library after an import completes to keep things tidy.',
|
|
category: 'Maintenance', difficulty: 'intermediate', when: { type: 'import_completed', config: {} }, do: { type: 'scan_library', config: {} }, then: []
|
|
},
|
|
// Notifications & Alerts
|
|
{
|
|
id: 'download-failure-alert', icon: '\u274C', name: 'Download Failure Alert', desc: 'Get notified via Discord when a download fails.',
|
|
category: 'Alerts', difficulty: 'beginner', when: { type: 'download_failed', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'discord_webhook', config: {} }]
|
|
},
|
|
{
|
|
id: 'quarantine-alert', icon: '\u26A0\uFE0F', name: 'Quarantine Alert', desc: 'Get a Pushbullet alert when a file is quarantined.',
|
|
category: 'Alerts', difficulty: 'beginner', when: { type: 'download_quarantined', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'pushbullet', config: {} }]
|
|
},
|
|
{
|
|
id: 'batch-complete-notify', icon: '\uD83C\uDFC1', name: 'Batch Complete Notification', desc: 'Get a Telegram message when a batch download finishes.',
|
|
category: 'Alerts', difficulty: 'beginner', when: { type: 'batch_complete', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'telegram', config: {} }]
|
|
},
|
|
// Power User Chains
|
|
{
|
|
id: 'full-hands-free', icon: '\uD83E\uDD16', name: 'Full Hands-Free Pipeline', desc: 'The ultimate automation chain: scan \u2192 process \u2192 download \u2192 clean \u2192 notify. Requires 5 automations linked by signals.',
|
|
category: 'Chains', difficulty: 'advanced', when: { type: 'schedule', config: { interval: 12, unit: 'hours' } }, do: { type: 'scan_watchlist', config: {} }, then: [{ type: 'fire_signal', config: { signal_name: 'scan_done' } }],
|
|
chain: ['Scan Watchlist', '\u26A1 scan_done', 'Process Wishlist', '\u26A1 process_done', 'Full Cleanup', '\u26A1 cleanup_done', 'Quality Scan', 'Discord'],
|
|
note: 'Build 4-5 automations, each firing a signal for the next step. Start small and add stages.'
|
|
},
|
|
{
|
|
id: 'staggered-nightly', icon: '\uD83C\uDF03', name: 'Staggered Nightly Pipeline', desc: 'Spread tasks across the night: 1 AM scan, 2 AM process, 3 AM cleanup, 4 AM backup.',
|
|
category: 'Chains', difficulty: 'intermediate', when: { type: 'daily_time', config: { time: '01:00' } }, do: { type: 'scan_watchlist', config: {} }, then: [],
|
|
chain: ['1:00 Scan', '2:00 Process', '3:00 Cleanup', '4:00 Backup'],
|
|
note: 'Create 4 daily_time automations at staggered hours. No signals needed \u2014 just timing.'
|
|
},
|
|
];
|
|
|
|
const AUTO_HUB_GUIDES = [
|
|
{
|
|
id: 'auto-sync-playlists', icon: '\uD83D\uDD01', title: 'Auto-Sync Your Spotify Playlists', subtitle: 'Mirror a Spotify playlist and schedule automatic refreshes.', difficulty: 'beginner',
|
|
steps: [
|
|
'Go to the <strong>Playlists</strong> page and find a Spotify playlist you want to track.',
|
|
'Click <strong>Mirror Playlist</strong> to create a local copy.',
|
|
'Go to <strong>Automations</strong> and click <strong>New Automation</strong>.',
|
|
'Set WHEN to <strong>Schedule \u2192 Every 6 hours</strong>.',
|
|
'Set DO to <strong>Refresh Mirrored Playlists</strong>.',
|
|
'Save and enable \u2014 your playlist will now stay in sync automatically.'
|
|
], relatedRecipes: ['spotify-auto-sync', 'discover-weekly-grab']
|
|
},
|
|
{
|
|
id: 'discord-download-alerts', icon: '\uD83D\uDCE2', title: 'Get Discord Alerts for Downloads', subtitle: 'Set up Discord webhook notifications for download events.', difficulty: 'beginner',
|
|
steps: [
|
|
'In Discord, go to your channel\'s settings \u2192 <strong>Integrations \u2192 Webhooks</strong>.',
|
|
'Create a webhook and copy the URL.',
|
|
'In SoulSync, go to <strong>Settings \u2192 Notifications</strong> and paste the Discord webhook URL.',
|
|
'Go to <strong>Automations \u2192 New Automation</strong>.',
|
|
'Set WHEN to <strong>Download Failed</strong> (or any event), DO to <strong>Notify Only</strong>, THEN to <strong>Discord</strong>.'
|
|
], relatedRecipes: ['download-failure-alert', 'batch-complete-notify']
|
|
},
|
|
{
|
|
id: 'hands-free-pipeline', icon: '\uD83E\uDD16', title: 'Build a Hands-Free Library Pipeline', subtitle: 'Chain watchlist scanning, wishlist processing, and cleanup with signals.', difficulty: 'intermediate',
|
|
steps: [
|
|
'Create Automation 1: <strong>Schedule (12h) \u2192 Scan Watchlist</strong>, THEN fire signal <code>scan_done</code>.',
|
|
'Create Automation 2: <strong>Signal scan_done \u2192 Process Wishlist</strong>, THEN fire signal <code>process_done</code>.',
|
|
'Create Automation 3: <strong>Signal process_done \u2192 Full Cleanup</strong>.',
|
|
'Enable all three automations.',
|
|
'Test by manually running Automation 1 \u2014 watch the chain execute.',
|
|
'Add a THEN notification (Discord/Telegram) to the last automation for completion alerts.',
|
|
'Adjust the schedule interval based on how often you want new music checked.'
|
|
], relatedRecipes: ['complete-new-release', 'full-hands-free']
|
|
},
|
|
{
|
|
id: 'signal-chains', icon: '\u26A1', title: 'Set Up Signal Chains', subtitle: 'Use fire_signal and signal_received to link automations together.', difficulty: 'advanced',
|
|
steps: [
|
|
'Understand the concept: <strong>fire_signal</strong> is a THEN action that emits a named signal. <strong>signal_received</strong> is a WHEN trigger that listens for it.',
|
|
'In your first automation, add a THEN action \u2192 <strong>Fire Signal</strong> and name it (e.g., <code>step1_done</code>).',
|
|
'Create a second automation with WHEN \u2192 <strong>Signal Received</strong> \u2192 signal name <code>step1_done</code>.',
|
|
'The second automation will fire automatically when the first one completes.',
|
|
'Chain up to 5 levels deep (safety limit). SoulSync detects cycles automatically.',
|
|
'Use descriptive signal names like <code>watchlist_scanned</code> or <code>cleanup_finished</code>.'
|
|
], relatedRecipes: ['quality-assurance', 'complete-new-release']
|
|
},
|
|
{
|
|
id: 'nightly-maintenance', icon: '\uD83C\uDF19', title: 'Schedule Nightly Maintenance', subtitle: 'Set up backup, cleanup, and quality scans to run overnight.', difficulty: 'intermediate',
|
|
steps: [
|
|
'Create a <strong>Daily Time (04:00) \u2192 Backup Database</strong> automation.',
|
|
'Create a <strong>Weekly Time (Saturday, 05:00) \u2192 Full Cleanup</strong> automation.',
|
|
'Create a <strong>Daily Time (02:00) \u2192 Update Discovery Pool</strong> automation.',
|
|
'Stagger times by at least 1 hour to avoid resource contention.',
|
|
'Add Discord/Telegram notifications to any you want alerts for.'
|
|
], relatedRecipes: ['weekly-db-backup', 'full-library-maintenance', 'staggered-nightly']
|
|
},
|
|
];
|
|
|
|
const AUTO_HUB_TIPS = [
|
|
{ icon: '\u26A1', title: 'Signal Chaining 101', body: '<strong>fire_signal</strong> (a THEN action) emits a named event. <strong>signal_received</strong> (a WHEN trigger) listens for it. This lets you chain automations: when one finishes, the next starts automatically.', tag: 'Signals' },
|
|
{ icon: '\u23F0', title: 'Stagger Your Schedules', body: 'If you have multiple timed automations, space them at least 1 hour apart. Running scan, process, and cleanup at the same time creates resource contention and can slow everything down.', tag: 'Performance' },
|
|
{ icon: '\uD83C\uDFAF', title: 'Use Conditions to Filter', body: 'Add conditions to event triggers to only fire on specific artists, formats, or quality levels. For example, trigger only when a downloaded track\'s artist matches "Radiohead".', tag: 'Filtering' },
|
|
{ icon: '\uD83D\uDCC1', title: 'Group Related Automations', body: 'Use the Group dropdown when creating automations to organize them. Groups like "Nightly", "Notifications", or "Pipeline" make it easy to find and manage related automations.', tag: 'Organization' },
|
|
{ icon: '\uD83D\uDD04', title: 'Avoid Chain Loops', body: 'SoulSync has built-in cycle detection, but it\'s good practice to design signal names carefully. If A fires signal X and B listens for X and fires Y, make sure nothing fires X again downstream.', tag: 'Safety' },
|
|
{ icon: '\uD83D\uDCDA', title: 'Stack THEN Actions', body: 'Each automation supports up to 3 THEN actions. Combine notification channels (Discord + Telegram) with a fire_signal to both notify yourself and trigger the next automation.', tag: 'Power' },
|
|
{ icon: '\u2699\uFE0F', title: 'System vs Custom', body: 'System automations handle core tasks like Spotify enrichment and are managed automatically. Create custom automations to extend their behavior \u2014 trigger on their completion events.', tag: 'Basics' },
|
|
{ icon: '\uD83E\uDDEA', title: 'Test with Notify Only', body: 'Set DO to <strong>Notify Only</strong> when testing a new trigger. You\'ll see when it fires without any side effects. Once you\'re confident in the timing, switch to the real action.', tag: 'Testing' },
|
|
];
|
|
|
|
const AUTO_HUB_REFERENCE = {
|
|
triggers: [
|
|
{
|
|
group: 'Time-Based', items: [
|
|
{ type: 'schedule', label: 'Schedule', desc: 'Repeating interval (e.g., every 6 hours)' },
|
|
{ type: 'daily_time', label: 'Daily Time', desc: 'Every day at a specific time (e.g., 03:00)' },
|
|
{ type: 'weekly_time', label: 'Weekly Time', desc: 'Specific days + time (e.g., Saturday at 05:00)' },
|
|
]
|
|
},
|
|
{
|
|
group: 'Download Events', items: [
|
|
{ type: 'track_downloaded', label: 'Track Downloaded', desc: 'Fires when a single track download completes' },
|
|
{ type: 'batch_complete', label: 'Batch Complete', desc: 'Fires when a batch download job finishes' },
|
|
{ type: 'download_failed', label: 'Download Failed', desc: 'Fires when a download fails or errors out' },
|
|
{ type: 'download_quarantined', label: 'File Quarantined', desc: 'Fires when a downloaded file is quarantined for quality issues' },
|
|
]
|
|
},
|
|
{
|
|
group: 'Watchlist & Wishlist', items: [
|
|
{ type: 'watchlist_new_release', label: 'New Release Found', desc: 'Fires when a watched artist has a new release' },
|
|
{ type: 'watchlist_scan_completed', label: 'Watchlist Scan Done', desc: 'Fires after a full watchlist scan completes' },
|
|
{ type: 'watchlist_artist_added', label: 'Artist Watched', desc: 'Fires when a new artist is added to the watchlist' },
|
|
{ type: 'watchlist_artist_removed', label: 'Artist Unwatched', desc: 'Fires when an artist is removed from the watchlist' },
|
|
{ type: 'wishlist_item_added', label: 'Wishlist Item Added', desc: 'Fires when a new item is added to the wishlist' },
|
|
{ type: 'wishlist_processing_completed', label: 'Wishlist Processed', desc: 'Fires after the wishlist processor completes a run' },
|
|
]
|
|
},
|
|
{
|
|
group: 'Playlists', items: [
|
|
{ type: 'playlist_synced', label: 'Playlist Synced', desc: 'Fires when a playlist sync operation completes' },
|
|
{ type: 'playlist_changed', label: 'Playlist Changed', desc: 'Fires when a tracked playlist has changes detected' },
|
|
{ type: 'mirrored_playlist_created', label: 'Playlist Mirrored', desc: 'Fires when a new mirrored playlist is created' },
|
|
{ type: 'discovery_completed', label: 'Discovery Complete', desc: 'Fires when playlist discovery finishes' },
|
|
]
|
|
},
|
|
{
|
|
group: 'Library & System', items: [
|
|
{ type: 'app_started', label: 'App Started', desc: 'Fires once when SoulSync starts up' },
|
|
{ type: 'import_completed', label: 'Import Complete', desc: 'Fires when a library import operation finishes' },
|
|
{ type: 'library_scan_completed', label: 'Library Scan Done', desc: 'Fires after a full library scan completes' },
|
|
{ type: 'quality_scan_completed', label: 'Quality Scan Done', desc: 'Fires when a quality scan finishes' },
|
|
{ type: 'duplicate_scan_completed', label: 'Duplicate Scan Done', desc: 'Fires when the duplicate scanner finishes' },
|
|
{ type: 'database_update_completed', label: 'Database Updated', desc: 'Fires after a database update operation' },
|
|
]
|
|
},
|
|
{
|
|
group: 'Signals', items: [
|
|
{ type: 'signal_received', label: 'Signal Received', desc: 'Fires when a named signal is emitted by another automation\'s fire_signal THEN action' },
|
|
]
|
|
},
|
|
],
|
|
actions: [
|
|
{
|
|
group: 'Downloads & Sync', items: [
|
|
{ type: 'playlist_pipeline', label: 'Playlist Pipeline', desc: 'Full lifecycle: refresh → discover → sync → download missing' },
|
|
{ type: 'process_wishlist', label: 'Process Wishlist', desc: 'Download all pending wishlist items' },
|
|
{ type: 'refresh_mirrored', label: 'Refresh Mirrored', desc: 'Refresh all mirrored playlists from their sources' },
|
|
{ type: 'sync_playlist', label: 'Sync Playlist', desc: 'Sync a specific playlist to your library' },
|
|
{ type: 'discover_playlist', label: 'Discover Playlist', desc: 'Run track discovery on mirrored playlists' },
|
|
{ type: 'scan_watchlist', label: 'Scan Watchlist', desc: 'Check watched artists for new releases' },
|
|
{ type: 'update_discovery_pool', label: 'Update Discovery', desc: 'Refresh the discovery pool with new recommendations' },
|
|
]
|
|
},
|
|
{
|
|
group: 'Library Tools', items: [
|
|
{ type: 'scan_library', label: 'Scan Library', desc: 'Full scan of local music library files' },
|
|
{ type: 'start_quality_scan', label: 'Quality Scan', desc: 'Check library tracks for quality issues' },
|
|
{ type: 'start_database_update', label: 'Update Database', desc: 'Run a database update/maintenance operation' },
|
|
{ type: 'backup_database', label: 'Backup Database', desc: 'Create a backup of the music database' },
|
|
]
|
|
},
|
|
{
|
|
group: 'Cleanup', items: [
|
|
{ type: 'full_cleanup', label: 'Full Cleanup', desc: 'Run all cleanup tasks: dedup, quarantine, wishlist tidy' },
|
|
{ type: 'run_duplicate_cleaner', label: 'Duplicate Cleaner', desc: 'Find and handle duplicate tracks' },
|
|
{ type: 'clear_quarantine', label: 'Clear Quarantine', desc: 'Remove all quarantined files' },
|
|
{ type: 'cleanup_wishlist', label: 'Clean Wishlist', desc: 'Remove completed/invalid wishlist items' },
|
|
{ type: 'clean_search_history', label: 'Clean Search History', desc: 'Clear old search history entries' },
|
|
{ type: 'clean_completed_downloads', label: 'Clean Downloads', desc: 'Remove completed download records' },
|
|
]
|
|
},
|
|
{
|
|
group: 'Other', items: [
|
|
{ type: 'notify_only', label: 'Notify Only', desc: 'No action \u2014 just trigger THEN notifications. Great for testing.' },
|
|
]
|
|
},
|
|
],
|
|
thenActions: [
|
|
{
|
|
group: 'Notifications', items: [
|
|
{ type: 'discord_webhook', label: 'Discord Webhook', desc: 'Send a message to a Discord channel via webhook' },
|
|
{ type: 'telegram', label: 'Telegram', desc: 'Send a message to a Telegram chat via bot' },
|
|
{ type: 'pushbullet', label: 'Pushbullet', desc: 'Send a push notification via Pushbullet' },
|
|
]
|
|
},
|
|
{
|
|
group: 'Chaining', items: [
|
|
{ type: 'fire_signal', label: 'Fire Signal', desc: 'Emit a named signal that other automations can listen for with signal_received' },
|
|
]
|
|
},
|
|
],
|
|
};
|
|
|
|
// --- Load & Render List ---
|
|
|
|
// Drag-and-drop state
|
|
let _autoDragState = null;
|
|
let _autoDragEnterCount = 0;
|
|
let _autoDragExpandTimer = null;
|
|
|
|
function _buildAutomationSection(id, label, automations, useGrid, options = {}) {
|
|
const groupName = options.groupName || null;
|
|
const isProtected = options.isProtected || false; // System, Hub sections
|
|
|
|
const section = document.createElement('div');
|
|
section.className = 'automations-section';
|
|
if (isProtected) section.classList.add('section-protected');
|
|
section.id = id;
|
|
if (groupName) section.dataset.groupName = groupName;
|
|
const collapsed = localStorage.getItem('auto_section_' + id) === '1';
|
|
if (collapsed) section.classList.add('collapsed');
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'automations-section-header';
|
|
|
|
// Group header actions (rename, bulk toggle, delete) — only for user groups
|
|
let actionsHtml = '';
|
|
if (groupName && !isProtected) {
|
|
const enabledCount = automations.filter(a => a.enabled).length;
|
|
const allEnabled = enabledCount === automations.length;
|
|
actionsHtml = `
|
|
<div class="section-actions" onclick="event.stopPropagation();">
|
|
<button class="section-action-btn" title="${allEnabled ? 'Disable all' : 'Enable all'}" onclick="_bulkToggleGroup('${_escAttr(groupName)}', ${allEnabled})">
|
|
${allEnabled ? '⏸' : '▶'}
|
|
</button>
|
|
<button class="section-action-btn" title="Rename group" onclick="_startRenameGroup('${_escAttr(groupName)}', this)">
|
|
✏️
|
|
</button>
|
|
<button class="section-action-btn section-action-danger" title="Delete group" onclick="_deleteGroup('${_escAttr(groupName)}')">
|
|
🗑️
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
header.innerHTML = `
|
|
<span class="section-chevron">▼</span>
|
|
<span class="section-label">${label}</span>
|
|
<span class="section-count">${automations.length}</span>
|
|
${actionsHtml}
|
|
<span class="section-line"></span>
|
|
`;
|
|
header.onclick = (e) => {
|
|
if (e.target.closest('.section-actions')) return;
|
|
section.classList.toggle('collapsed');
|
|
localStorage.setItem('auto_section_' + id, section.classList.contains('collapsed') ? '1' : '0');
|
|
};
|
|
|
|
const body = document.createElement('div');
|
|
body.className = 'automations-section-body';
|
|
|
|
// Drop zone setup (not for protected sections)
|
|
if (!isProtected) {
|
|
const dropGroupName = groupName; // null for "My Automations"
|
|
body.addEventListener('dragover', (e) => {
|
|
if (!_autoDragState) return;
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
body.classList.add('drop-target');
|
|
});
|
|
body.addEventListener('dragenter', (e) => {
|
|
if (!_autoDragState) return;
|
|
_autoDragEnterCount++;
|
|
body.classList.add('drop-target');
|
|
// Expand collapsed sections on drag-hover
|
|
if (section.classList.contains('collapsed')) {
|
|
_autoDragExpandTimer = setTimeout(() => {
|
|
section.classList.remove('collapsed');
|
|
}, 500);
|
|
}
|
|
});
|
|
body.addEventListener('dragleave', (e) => {
|
|
if (!_autoDragState) return;
|
|
_autoDragEnterCount--;
|
|
if (_autoDragEnterCount <= 0) {
|
|
_autoDragEnterCount = 0;
|
|
body.classList.remove('drop-target');
|
|
if (_autoDragExpandTimer) { clearTimeout(_autoDragExpandTimer); _autoDragExpandTimer = null; }
|
|
}
|
|
});
|
|
body.addEventListener('drop', async (e) => {
|
|
e.preventDefault();
|
|
body.classList.remove('drop-target');
|
|
_autoDragEnterCount = 0;
|
|
if (!_autoDragState) return;
|
|
const draggedId = _autoDragState.id;
|
|
const fromGroup = _autoDragState.groupName;
|
|
if (fromGroup === dropGroupName) return; // Same group, no-op
|
|
try {
|
|
const res = await fetch('/api/automations/' + draggedId, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ group_name: dropGroupName })
|
|
});
|
|
const data = await res.json();
|
|
if (data.error) throw new Error(data.error);
|
|
showToast(dropGroupName ? `Moved to "${dropGroupName}"` : 'Moved to My Automations', 'success');
|
|
await loadAutomations();
|
|
} catch (err) { showToast('Error: ' + err.message, 'error'); }
|
|
});
|
|
}
|
|
|
|
const container = document.createElement('div');
|
|
container.className = useGrid ? 'automations-grid' : 'automations-user-list';
|
|
automations.forEach(a => container.appendChild(renderAutomationCard(a)));
|
|
body.appendChild(container);
|
|
section.appendChild(header);
|
|
section.appendChild(body);
|
|
return section;
|
|
}
|
|
|
|
/**
|
|
* Delete a group — ungroups all automations (moves to My Automations).
|
|
*/
|
|
async function _deleteGroup(groupName) {
|
|
// Collect automation IDs in this group
|
|
const ids = [];
|
|
document.querySelectorAll(`.automations-section[data-group-name="${groupName}"] .automation-card`).forEach(card => {
|
|
if (card.dataset.id) ids.push(parseInt(card.dataset.id));
|
|
});
|
|
|
|
if (ids.length === 0) { await loadAutomations(); return; }
|
|
|
|
// Show choice dialog — ungroup or delete all
|
|
const choice = await _showDeleteGroupDialog(groupName, ids.length);
|
|
if (!choice) return;
|
|
|
|
try {
|
|
if (choice === 'ungroup') {
|
|
const res = await fetch('/api/automations/group', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ automation_ids: ids, group_name: null })
|
|
});
|
|
const data = await res.json();
|
|
if (data.error) throw new Error(data.error);
|
|
showToast(`Dissolved group "${groupName}" — ${data.updated} automations moved to My Automations`, 'success');
|
|
} else if (choice === 'delete_all') {
|
|
// Delete each automation
|
|
let deleted = 0;
|
|
for (const id of ids) {
|
|
try {
|
|
const res = await fetch('/api/automations/' + id, { method: 'DELETE' });
|
|
const data = await res.json();
|
|
if (data.success) deleted++;
|
|
} catch (e) {}
|
|
}
|
|
showToast(`Deleted group "${groupName}" and ${deleted} automation${deleted !== 1 ? 's' : ''}`, 'success');
|
|
}
|
|
await loadAutomations();
|
|
} catch (err) { showToast('Error: ' + err.message, 'error'); }
|
|
}
|
|
|
|
function _showDeleteGroupDialog(groupName, count) {
|
|
return new Promise((resolve) => {
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'modal-overlay';
|
|
overlay.style.display = 'flex';
|
|
overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } };
|
|
|
|
overlay.innerHTML = `
|
|
<div class="delete-group-dialog">
|
|
<div class="delete-group-icon">🗑️</div>
|
|
<h3 class="delete-group-title">Delete Group "${groupName}"</h3>
|
|
<p class="delete-group-message">This group contains ${count} automation${count !== 1 ? 's' : ''}. What would you like to do?</p>
|
|
<div class="delete-group-actions">
|
|
<button class="delete-group-btn delete-group-keep" id="dg-ungroup">
|
|
Keep Automations — move to My Automations
|
|
</button>
|
|
<button class="delete-group-btn delete-group-remove" id="dg-delete">
|
|
Delete Everything — remove group and all ${count} automation${count !== 1 ? 's' : ''}
|
|
</button>
|
|
<button class="delete-group-btn delete-group-cancel" id="dg-cancel">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
overlay.querySelector('#dg-ungroup').onclick = () => { overlay.remove(); resolve('ungroup'); };
|
|
overlay.querySelector('#dg-delete').onclick = () => { overlay.remove(); resolve('delete_all'); };
|
|
overlay.querySelector('#dg-cancel').onclick = () => { overlay.remove(); resolve(null); };
|
|
|
|
document.addEventListener('keydown', function esc(e) {
|
|
if (e.key === 'Escape') { overlay.remove(); resolve(null); document.removeEventListener('keydown', esc); }
|
|
});
|
|
|
|
document.body.appendChild(overlay);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Rename a group — inline edit on the section header label.
|
|
*/
|
|
function _startRenameGroup(groupName, btnEl) {
|
|
const section = btnEl.closest('.automations-section');
|
|
const labelEl = section?.querySelector('.section-label');
|
|
if (!labelEl) return;
|
|
|
|
const input = document.createElement('input');
|
|
input.className = 'section-rename-input';
|
|
input.value = groupName;
|
|
input.onclick = (e) => e.stopPropagation();
|
|
|
|
const originalText = labelEl.textContent;
|
|
labelEl.textContent = '';
|
|
labelEl.appendChild(input);
|
|
input.focus();
|
|
input.select();
|
|
|
|
const finish = async (save) => {
|
|
const newName = input.value.trim();
|
|
input.removeEventListener('blur', blurHandler);
|
|
if (!save || !newName || newName === groupName) {
|
|
labelEl.textContent = originalText;
|
|
return;
|
|
}
|
|
|
|
const ids = [];
|
|
section.querySelectorAll('.automation-card').forEach(card => {
|
|
if (card.dataset.id) ids.push(parseInt(card.dataset.id));
|
|
});
|
|
|
|
try {
|
|
const res = await fetch('/api/automations/group', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ automation_ids: ids, group_name: newName })
|
|
});
|
|
const data = await res.json();
|
|
if (data.error) throw new Error(data.error);
|
|
showToast(`Renamed to "${newName}"`, 'success');
|
|
await loadAutomations();
|
|
} catch (err) {
|
|
showToast('Error: ' + err.message, 'error');
|
|
labelEl.textContent = originalText;
|
|
}
|
|
};
|
|
|
|
input.addEventListener('keydown', (e) => {
|
|
e.stopPropagation();
|
|
if (e.key === 'Enter') { e.preventDefault(); finish(true); }
|
|
if (e.key === 'Escape') { finish(false); }
|
|
});
|
|
const blurHandler = () => finish(true);
|
|
input.addEventListener('blur', blurHandler);
|
|
}
|
|
|
|
/**
|
|
* Bulk toggle all automations in a group.
|
|
*/
|
|
async function _bulkToggleGroup(groupName, currentlyAllEnabled) {
|
|
const ids = [];
|
|
document.querySelectorAll(`.automations-section[data-group-name="${groupName}"] .automation-card`).forEach(card => {
|
|
if (card.dataset.id) ids.push(parseInt(card.dataset.id));
|
|
});
|
|
if (ids.length === 0) return;
|
|
|
|
const targetEnabled = !currentlyAllEnabled;
|
|
try {
|
|
const res = await fetch('/api/automations/bulk-toggle', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ automation_ids: ids, enabled: targetEnabled })
|
|
});
|
|
const data = await res.json();
|
|
if (data.error) throw new Error(data.error);
|
|
showToast(`${targetEnabled ? 'Enabled' : 'Disabled'} ${data.updated} automations`, 'success');
|
|
await loadAutomations();
|
|
} catch (err) { showToast('Error: ' + err.message, 'error'); }
|
|
}
|
|
|
|
async function loadAutomations() {
|
|
const list = document.getElementById('automations-list');
|
|
const empty = document.getElementById('automations-empty');
|
|
const statsBar = document.getElementById('automations-stats');
|
|
if (!list || !empty) return;
|
|
try {
|
|
const res = await fetch('/api/automations');
|
|
const automations = await res.json();
|
|
if (automations.error) throw new Error(automations.error);
|
|
if (!automations.length) {
|
|
list.innerHTML = ''; empty.style.display = '';
|
|
if (statsBar) statsBar.innerHTML = '';
|
|
return;
|
|
}
|
|
empty.style.display = 'none';
|
|
list.innerHTML = '';
|
|
|
|
const systemAutos = automations.filter(a => a.is_system);
|
|
const userAutos = automations.filter(a => !a.is_system);
|
|
|
|
if (systemAutos.length) {
|
|
list.appendChild(_buildAutomationSection('auto-section-system', 'System', systemAutos, true, { isProtected: true }));
|
|
}
|
|
|
|
// Automation Hub section
|
|
list.appendChild(_buildAutomationHub());
|
|
|
|
// User automations — split by group
|
|
const groups = [...new Set(userAutos.filter(a => a.group_name).map(a => a.group_name))].sort();
|
|
const ungrouped = userAutos.filter(a => !a.group_name);
|
|
groups.forEach(g => {
|
|
const groupAutos = userAutos.filter(a => a.group_name === g);
|
|
if (groupAutos.length) {
|
|
list.appendChild(_buildAutomationSection('auto-section-group-' + g.replace(/\W+/g, '_'), '\uD83D\uDCC1 ' + g, groupAutos, true, { groupName: g }));
|
|
}
|
|
});
|
|
if (ungrouped.length) {
|
|
list.appendChild(_buildAutomationSection('auto-section-custom', 'My Automations', ungrouped, true));
|
|
}
|
|
|
|
// Stats summary bar
|
|
if (statsBar) {
|
|
const total = automations.length;
|
|
const active = automations.filter(a => a.enabled).length;
|
|
const sys = systemAutos.length;
|
|
const custom = userAutos.length;
|
|
statsBar.innerHTML = `
|
|
<span class="auto-stat"><strong>${active}</strong> Active</span>
|
|
<span class="auto-stat"><strong>${sys}</strong> System</span>
|
|
<span class="auto-stat"><strong>${custom}</strong> Custom</span>
|
|
`;
|
|
}
|
|
|
|
// Filter bar — show when 6+ automations
|
|
_initAutoFilterBar(automations);
|
|
// Catch up on current automation progress
|
|
try {
|
|
const progRes = await fetch('/api/automations/progress');
|
|
const progData = await progRes.json();
|
|
if (!progData.error) updateAutomationProgressFromData(progData);
|
|
} catch (e) { }
|
|
} catch (err) {
|
|
list.innerHTML = ''; empty.style.display = '';
|
|
if (statsBar) statsBar.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
// --- Automation Hub ---
|
|
|
|
function _buildAutomationHub() {
|
|
const section = document.createElement('div');
|
|
section.className = 'automations-section';
|
|
section.id = 'auto-section-hub';
|
|
const collapsed = localStorage.getItem('auto_section_auto-section-hub') === '1';
|
|
if (collapsed) section.classList.add('collapsed');
|
|
const header = document.createElement('div');
|
|
header.className = 'automations-section-header';
|
|
header.innerHTML = `
|
|
<span class="section-chevron">▼</span>
|
|
<span class="section-label">Automation Hub</span>
|
|
<span class="section-count">${AUTO_HUB_GROUPS.length} pipelines · ${AUTO_HUB_RECIPES.length} recipes</span>
|
|
<span class="section-line"></span>
|
|
`;
|
|
header.onclick = () => {
|
|
section.classList.toggle('collapsed');
|
|
localStorage.setItem('auto_section_auto-section-hub', section.classList.contains('collapsed') ? '1' : '0');
|
|
};
|
|
const body = document.createElement('div');
|
|
body.className = 'automations-section-body';
|
|
|
|
const activeTab = localStorage.getItem('auto_hub_tab') || 'pipelines';
|
|
const tabs = [
|
|
{ id: 'pipelines', label: 'Pipelines' },
|
|
{ id: 'recipes', label: 'Singles' },
|
|
{ id: 'guides', label: 'Quick Start' },
|
|
{ id: 'tips', label: 'Tips' },
|
|
{ id: 'reference', label: 'Reference' },
|
|
];
|
|
|
|
const tabBar = document.createElement('div');
|
|
tabBar.className = 'auto-hub-tabs';
|
|
tabs.forEach(t => {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'auto-hub-tab' + (t.id === activeTab ? ' active' : '');
|
|
btn.textContent = t.label;
|
|
btn.dataset.tab = t.id;
|
|
btn.onclick = (e) => { e.stopPropagation(); _switchHubTab(t.id, body); };
|
|
tabBar.appendChild(btn);
|
|
});
|
|
body.appendChild(tabBar);
|
|
|
|
// Build all tab contents
|
|
const pipelinesPane = _buildHubPipelines();
|
|
pipelinesPane.id = 'auto-hub-pane-pipelines';
|
|
pipelinesPane.className = 'auto-hub-tab-content' + (activeTab === 'pipelines' ? ' active' : '');
|
|
body.appendChild(pipelinesPane);
|
|
|
|
const recipesPane = _buildHubRecipes();
|
|
recipesPane.id = 'auto-hub-pane-recipes';
|
|
recipesPane.className = 'auto-hub-tab-content' + (activeTab === 'recipes' ? ' active' : '');
|
|
body.appendChild(recipesPane);
|
|
|
|
const guidesPane = _buildHubGuides();
|
|
guidesPane.id = 'auto-hub-pane-guides';
|
|
guidesPane.className = 'auto-hub-tab-content' + (activeTab === 'guides' ? ' active' : '');
|
|
body.appendChild(guidesPane);
|
|
|
|
const tipsPane = _buildHubTips();
|
|
tipsPane.id = 'auto-hub-pane-tips';
|
|
tipsPane.className = 'auto-hub-tab-content' + (activeTab === 'tips' ? ' active' : '');
|
|
body.appendChild(tipsPane);
|
|
|
|
const refPane = _buildHubReference();
|
|
refPane.id = 'auto-hub-pane-reference';
|
|
refPane.className = 'auto-hub-tab-content' + (activeTab === 'reference' ? ' active' : '');
|
|
body.appendChild(refPane);
|
|
|
|
section.appendChild(header);
|
|
section.appendChild(body);
|
|
return section;
|
|
}
|
|
|
|
function _switchHubTab(tabId, bodyEl) {
|
|
const container = bodyEl || document.querySelector('#auto-section-hub .automations-section-body');
|
|
if (!container) return;
|
|
container.querySelectorAll('.auto-hub-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tabId));
|
|
container.querySelectorAll('.auto-hub-tab-content').forEach(p => p.classList.toggle('active', p.id === 'auto-hub-pane-' + tabId));
|
|
localStorage.setItem('auto_hub_tab', tabId);
|
|
}
|
|
|
|
function _buildHubPipelines() {
|
|
const pane = document.createElement('div');
|
|
|
|
const intro = document.createElement('div');
|
|
intro.className = 'auto-hub-pipeline-intro';
|
|
intro.innerHTML = 'One-click deployment — each pipeline creates multiple linked automations that work together.';
|
|
pane.appendChild(intro);
|
|
|
|
const grid = document.createElement('div');
|
|
grid.className = 'auto-hub-pipeline-grid';
|
|
|
|
AUTO_HUB_GROUPS.forEach(group => {
|
|
const card = document.createElement('div');
|
|
card.className = 'auto-hub-pipeline-card';
|
|
card.style.setProperty('--pipeline-color', group.color);
|
|
|
|
// Pipeline flow visualization
|
|
const stepsHtml = group.steps.map((step, i) => {
|
|
const nodeClass = step.type === 'notify' ? 'pipeline-node-notify' : 'pipeline-node-action';
|
|
return (i > 0 ? '<span class="pipeline-connector"></span>' : '') +
|
|
`<div class="pipeline-node ${nodeClass}">
|
|
<span class="pipeline-node-icon">${step.icon}</span>
|
|
<span class="pipeline-node-label">${step.label}</span>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
card.innerHTML = `
|
|
<div class="pipeline-card-top">
|
|
<span class="pipeline-card-icon">${group.icon}</span>
|
|
<div class="pipeline-card-title-row">
|
|
<div class="pipeline-card-name">${group.name}</div>
|
|
<span class="pipeline-card-badge">${group.badge}</span>
|
|
</div>
|
|
</div>
|
|
<div class="pipeline-card-desc">${group.desc}</div>
|
|
<div class="pipeline-flow">${stepsHtml}</div>
|
|
<div class="pipeline-card-footer">
|
|
<button class="pipeline-deploy-btn" onclick="event.stopPropagation(); deployHubGroup('${group.id}')">Deploy Pipeline</button>
|
|
</div>
|
|
`;
|
|
|
|
card.addEventListener('click', (e) => {
|
|
if (e.target.closest('.pipeline-deploy-btn')) return;
|
|
showPipelineDetail(group.id);
|
|
});
|
|
|
|
grid.appendChild(card);
|
|
});
|
|
|
|
pane.appendChild(grid);
|
|
return pane;
|
|
}
|
|
|
|
function showPipelineDetail(groupId) {
|
|
const group = AUTO_HUB_GROUPS.find(g => g.id === groupId);
|
|
if (!group) return;
|
|
|
|
// Build automation detail list
|
|
const autoDetails = group.automations.map((auto, i) => {
|
|
const triggerLabel = _autoFormatTrigger(auto.trigger_type, auto.trigger_config);
|
|
const actionLabel = _autoFormatAction(auto.action_type);
|
|
const thenLabels = auto.then_actions.map(t => {
|
|
if (t.type === 'fire_signal') return `⚡ Signal: ${t.config.signal_name}`;
|
|
return _autoFormatNotify(t.type);
|
|
});
|
|
if (auto.needs_notify) thenLabels.push('🔔 Your notification');
|
|
|
|
return `
|
|
<div class="pipeline-detail-auto" style="--step-color: ${group.color}">
|
|
<div class="pipeline-detail-step-num">${i + 1}</div>
|
|
<div class="pipeline-detail-step-body">
|
|
<div class="pipeline-detail-step-name">${auto.name}</div>
|
|
<div class="pipeline-detail-step-flow">
|
|
<span class="pipeline-detail-tag when">WHEN</span>
|
|
<span class="pipeline-detail-tag-value">${_esc(triggerLabel)}</span>
|
|
<span class="pipeline-detail-tag do">DO</span>
|
|
<span class="pipeline-detail-tag-value">${_esc(actionLabel)}</span>
|
|
${thenLabels.length ? `<span class="pipeline-detail-tag then">THEN</span><span class="pipeline-detail-tag-value">${thenLabels.map(t => _esc(t)).join(', ')}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
// Build flow diagram
|
|
const flowHtml = group.steps.map((step, i) => {
|
|
const nodeClass = step.type === 'notify' ? 'pipeline-node-notify' : 'pipeline-node-action';
|
|
return (i > 0 ? '<span class="pipeline-connector"></span>' : '') +
|
|
`<div class="pipeline-node ${nodeClass}">
|
|
<span class="pipeline-node-icon">${step.icon}</span>
|
|
<span class="pipeline-node-label">${step.label}</span>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'pipeline-detail-overlay';
|
|
overlay.innerHTML = `
|
|
<div class="pipeline-detail-modal" style="--pipeline-color: ${group.color}">
|
|
<button class="pipeline-detail-close" onclick="this.closest('.pipeline-detail-overlay').remove()">×</button>
|
|
<div class="pipeline-detail-header">
|
|
<span class="pipeline-detail-icon">${group.icon}</span>
|
|
<div>
|
|
<div class="pipeline-detail-title">${group.name}</div>
|
|
<div class="pipeline-detail-desc">${group.desc}</div>
|
|
</div>
|
|
</div>
|
|
<div class="pipeline-detail-flow" style="--pipeline-color: ${group.color}">${flowHtml}</div>
|
|
<div class="pipeline-detail-section-title">How It Works</div>
|
|
<div class="pipeline-detail-section-desc">This pipeline deploys ${group.automations.length} automations${group.automations.some(a => a.then_actions.some(t => t.type === 'fire_signal')) ? ' linked by signals — each step triggers the next automatically' : ' running on independent schedules'}.</div>
|
|
<div class="pipeline-detail-autos">${autoDetails}</div>
|
|
<button class="pipeline-deploy-btn" style="--pipeline-color: ${group.color}; margin-top: 8px;" onclick="this.closest('.pipeline-detail-overlay').remove(); deployHubGroup('${group.id}')">Deploy Pipeline</button>
|
|
</div>
|
|
`;
|
|
|
|
overlay.addEventListener('click', (e) => {
|
|
if (e.target === overlay) overlay.remove();
|
|
});
|
|
|
|
document.body.appendChild(overlay);
|
|
}
|
|
|
|
function _buildHubRecipes() {
|
|
const pane = document.createElement('div');
|
|
const categories = ['All', 'Sync', 'Discovery', 'Maintenance', 'Alerts', 'Chains'];
|
|
const difficulties = ['All', 'Beginner', 'Intermediate', 'Advanced'];
|
|
|
|
let activeCat = 'All', activeDiff = 'All';
|
|
|
|
// Category filters
|
|
const catFilters = document.createElement('div');
|
|
catFilters.className = 'auto-hub-filters';
|
|
categories.forEach(c => {
|
|
const pill = document.createElement('button');
|
|
pill.className = 'auto-hub-filter-pill' + (c === 'All' ? ' active' : '');
|
|
pill.textContent = c;
|
|
pill.dataset.filter = c;
|
|
pill.dataset.filterType = 'category';
|
|
pill.onclick = () => {
|
|
activeCat = c;
|
|
catFilters.querySelectorAll('.auto-hub-filter-pill').forEach(p => p.classList.toggle('active', p.dataset.filter === c));
|
|
filterRecipes();
|
|
};
|
|
catFilters.appendChild(pill);
|
|
});
|
|
pane.appendChild(catFilters);
|
|
|
|
// Difficulty filters
|
|
const diffFilters = document.createElement('div');
|
|
diffFilters.className = 'auto-hub-filters';
|
|
difficulties.forEach(d => {
|
|
const pill = document.createElement('button');
|
|
pill.className = 'auto-hub-filter-pill' + (d === 'All' ? ' active' : '');
|
|
pill.textContent = d;
|
|
pill.dataset.filter = d;
|
|
pill.dataset.filterType = 'difficulty';
|
|
pill.onclick = () => {
|
|
activeDiff = d;
|
|
diffFilters.querySelectorAll('.auto-hub-filter-pill').forEach(p => p.classList.toggle('active', p.dataset.filter === d));
|
|
filterRecipes();
|
|
};
|
|
diffFilters.appendChild(pill);
|
|
});
|
|
pane.appendChild(diffFilters);
|
|
|
|
const grid = document.createElement('div');
|
|
grid.className = 'auto-hub-recipes-grid';
|
|
|
|
AUTO_HUB_RECIPES.forEach(r => {
|
|
const card = document.createElement('div');
|
|
card.className = 'auto-hub-recipe-card';
|
|
card.dataset.category = r.category;
|
|
card.dataset.difficulty = r.difficulty;
|
|
|
|
const trigLabel = _autoFormatTrigger(r.when.type, r.when.config);
|
|
const actLabel = _autoFormatAction(r.do.type);
|
|
|
|
let chainHTML = '';
|
|
if (r.chain) {
|
|
chainHTML = '<div class="auto-hub-recipe-chain">' + r.chain.map((step, i) => {
|
|
let cls = 'flow-action';
|
|
if (i === 0) cls = 'flow-trigger';
|
|
else if (step.startsWith('\u26A1')) cls = 'flow-notify';
|
|
return (i > 0 ? '<span class="flow-arrow">→</span>' : '') +
|
|
`<span class="${cls}">${_esc(step)}</span>`;
|
|
}).join('') + '</div>';
|
|
} else {
|
|
chainHTML = `<div class="auto-hub-recipe-chain">
|
|
<span class="flow-trigger">${_esc(trigLabel)}</span>
|
|
<span class="flow-arrow">→</span>
|
|
<span class="flow-action">${_esc(actLabel)}</span>
|
|
${r.then.length ? r.then.map(th => `<span class="flow-arrow">→</span><span class="flow-notify">${_esc(_autoFormatNotify(th.type))}</span>`).join('') : ''}
|
|
</div>`;
|
|
}
|
|
|
|
card.innerHTML = `
|
|
<div class="auto-hub-recipe-header">
|
|
<div class="auto-hub-recipe-icon">${r.icon}</div>
|
|
<div class="auto-hub-recipe-name">${_esc(r.name)}</div>
|
|
<span class="auto-hub-badge ${r.difficulty}">${_esc(r.difficulty)}</span>
|
|
</div>
|
|
<div class="auto-hub-recipe-desc">${_esc(r.desc)}</div>
|
|
${chainHTML}
|
|
${r.note ? `<div class="auto-hub-recipe-note">${_esc(r.note)}</div>` : ''}
|
|
<button class="auto-hub-recipe-use" onclick="event.stopPropagation(); useHubRecipe('${r.id}')">Use This</button>
|
|
`;
|
|
card.onclick = () => useHubRecipe(r.id);
|
|
grid.appendChild(card);
|
|
});
|
|
pane.appendChild(grid);
|
|
|
|
function filterRecipes() {
|
|
grid.querySelectorAll('.auto-hub-recipe-card').forEach(card => {
|
|
const catMatch = activeCat === 'All' || card.dataset.category === activeCat;
|
|
const diffMatch = activeDiff === 'All' || card.dataset.difficulty === activeDiff.toLowerCase();
|
|
card.style.display = (catMatch && diffMatch) ? '' : 'none';
|
|
});
|
|
}
|
|
|
|
return pane;
|
|
}
|
|
|
|
function _buildHubGuides() {
|
|
const pane = document.createElement('div');
|
|
|
|
const callout = document.createElement('div');
|
|
callout.className = 'auto-hub-callout';
|
|
callout.innerHTML = '<span class="auto-hub-callout-icon">\uD83D\uDCA1</span><span>Click any guide to expand step-by-step instructions. Related recipes let you jump straight to a pre-filled template.</span>';
|
|
pane.appendChild(callout);
|
|
|
|
AUTO_HUB_GUIDES.forEach(g => {
|
|
const card = document.createElement('div');
|
|
card.className = 'auto-hub-guide-card';
|
|
|
|
const headerEl = document.createElement('div');
|
|
headerEl.className = 'auto-hub-guide-header';
|
|
headerEl.innerHTML = `
|
|
<span class="auto-hub-guide-icon">${g.icon}</span>
|
|
<span class="auto-hub-guide-title">${_esc(g.title)}</span>
|
|
<span class="auto-hub-badge ${g.difficulty}">${_esc(g.difficulty)}</span>
|
|
<span class="auto-hub-guide-chevron">▼</span>
|
|
`;
|
|
headerEl.onclick = () => card.classList.toggle('expanded');
|
|
card.appendChild(headerEl);
|
|
|
|
const bodyEl = document.createElement('div');
|
|
bodyEl.className = 'auto-hub-guide-body';
|
|
bodyEl.innerHTML = `
|
|
<div class="auto-hub-guide-subtitle">${_esc(g.subtitle)}</div>
|
|
<ol class="auto-hub-steps">${g.steps.map(s => `<li>${s}</li>`).join('')}</ol>
|
|
${g.relatedRecipes.length ? `
|
|
<div class="auto-hub-guide-related">
|
|
<span class="auto-hub-guide-related-label">Related:</span>
|
|
${g.relatedRecipes.map(rId => {
|
|
const recipe = AUTO_HUB_RECIPES.find(r => r.id === rId);
|
|
return recipe ? `<button class="auto-hub-guide-related-link" onclick="event.stopPropagation(); useHubRecipe('${rId}')">${recipe.icon} ${_esc(recipe.name)}</button>` : '';
|
|
}).join('')}
|
|
</div>
|
|
` : ''}
|
|
`;
|
|
card.appendChild(bodyEl);
|
|
pane.appendChild(card);
|
|
});
|
|
|
|
return pane;
|
|
}
|
|
|
|
function _buildHubTips() {
|
|
const pane = document.createElement('div');
|
|
|
|
const callout = document.createElement('div');
|
|
callout.className = 'auto-hub-callout';
|
|
callout.innerHTML = '<span class="auto-hub-callout-icon">\u2728</span><span>Power-user tips to get the most out of your automations.</span>';
|
|
pane.appendChild(callout);
|
|
|
|
const grid = document.createElement('div');
|
|
grid.className = 'auto-hub-tips-grid';
|
|
AUTO_HUB_TIPS.forEach(t => {
|
|
const card = document.createElement('div');
|
|
card.className = 'auto-hub-tip-card';
|
|
card.innerHTML = `
|
|
<div class="auto-hub-tip-header">
|
|
<span class="auto-hub-tip-icon">${t.icon}</span>
|
|
<span class="auto-hub-tip-title">${_esc(t.title)}</span>
|
|
<span class="auto-hub-tip-tag">${_esc(t.tag)}</span>
|
|
</div>
|
|
<div class="auto-hub-tip-body">${t.body}</div>
|
|
`;
|
|
grid.appendChild(card);
|
|
});
|
|
pane.appendChild(grid);
|
|
return pane;
|
|
}
|
|
|
|
function _buildHubReference() {
|
|
const pane = document.createElement('div');
|
|
const sections = [
|
|
{ label: 'Triggers (WHEN)', data: AUTO_HUB_REFERENCE.triggers },
|
|
{ label: 'Actions (DO)', data: AUTO_HUB_REFERENCE.actions },
|
|
{ label: 'Then Actions (THEN)', data: AUTO_HUB_REFERENCE.thenActions },
|
|
];
|
|
|
|
sections.forEach(sec => {
|
|
const totalItems = sec.data.reduce((n, g) => n + g.items.length, 0);
|
|
const group = document.createElement('div');
|
|
group.className = 'auto-hub-ref-group';
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'auto-hub-ref-group-header';
|
|
header.innerHTML = `
|
|
<span class="auto-hub-ref-group-label">${_esc(sec.label)}</span>
|
|
<span class="auto-hub-ref-group-count">${totalItems}</span>
|
|
<span class="auto-hub-ref-chevron">▼</span>
|
|
`;
|
|
header.onclick = () => group.classList.toggle('expanded');
|
|
group.appendChild(header);
|
|
|
|
const body = document.createElement('div');
|
|
body.className = 'auto-hub-ref-body';
|
|
sec.data.forEach(sub => {
|
|
body.innerHTML += `<div class="auto-hub-ref-subheader">${_esc(sub.group)}</div>`;
|
|
let tableHTML = '<table class="auto-hub-table"><thead><tr><th>Type</th><th>Description</th></tr></thead><tbody>';
|
|
sub.items.forEach(item => {
|
|
tableHTML += `<tr><td>${_esc(item.label)}</td><td>${_esc(item.desc)}</td></tr>`;
|
|
});
|
|
tableHTML += '</tbody></table>';
|
|
body.innerHTML += tableHTML;
|
|
});
|
|
group.appendChild(body);
|
|
pane.appendChild(group);
|
|
});
|
|
return pane;
|
|
}
|
|
|
|
async function useHubRecipe(recipeId) {
|
|
const t = AUTO_HUB_RECIPES.find(r => r.id === recipeId);
|
|
if (!t) return;
|
|
await showAutomationBuilder();
|
|
document.getElementById('builder-name').value = t.name;
|
|
_autoBuilder.when = { type: t.when.type, config: JSON.parse(JSON.stringify(t.when.config)) };
|
|
_autoBuilder.do = { type: t.do.type, config: JSON.parse(JSON.stringify(t.do.config)) };
|
|
_autoBuilder.then = t.then.map(th => ({ type: th.type, config: JSON.parse(JSON.stringify(th.config)) }));
|
|
_renderBuilderSidebar();
|
|
_renderBuilderCanvas();
|
|
if (t.note) {
|
|
showToast(t.note, 'info');
|
|
}
|
|
}
|
|
|
|
async function deployHubGroup(groupId) {
|
|
const group = AUTO_HUB_GROUPS.find(g => g.id === groupId);
|
|
if (!group) return;
|
|
|
|
// Check if any automations need notifications — prompt for config
|
|
const needsNotify = group.automations.some(a => a.needs_notify);
|
|
let notifyConfig = null;
|
|
|
|
if (needsNotify) {
|
|
notifyConfig = await _promptNotifyConfig(group.name);
|
|
if (notifyConfig === null) return; // User cancelled
|
|
if (notifyConfig === false) notifyConfig = null; // Skip notifications, still deploy
|
|
}
|
|
|
|
// Deploy all automations in the group
|
|
let created = 0, failed = 0;
|
|
for (const auto of group.automations) {
|
|
try {
|
|
const payload = {
|
|
name: auto.name,
|
|
trigger_type: auto.trigger_type,
|
|
trigger_config: auto.trigger_config,
|
|
action_type: auto.action_type,
|
|
action_config: auto.action_config,
|
|
then_actions: [...auto.then_actions],
|
|
group_name: auto.group_name,
|
|
enabled: true,
|
|
};
|
|
|
|
// Inject notification config for automations that need it
|
|
if (auto.needs_notify && notifyConfig) {
|
|
payload.then_actions.push(notifyConfig);
|
|
}
|
|
|
|
const response = await fetch('/api/automations', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (response.ok) {
|
|
created++;
|
|
} else {
|
|
const err = await response.json();
|
|
console.error(`Failed to create "${auto.name}":`, err);
|
|
failed++;
|
|
}
|
|
} catch (e) {
|
|
console.error(`Error creating "${auto.name}":`, e);
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
if (created > 0) {
|
|
showToast(`Deployed "${group.name}" — ${created} automation${created > 1 ? 's' : ''} created${failed ? `, ${failed} failed` : ''}`, 'success');
|
|
loadAutomations();
|
|
} else {
|
|
showToast(`Failed to deploy "${group.name}"`, 'error');
|
|
}
|
|
}
|
|
|
|
function _promptNotifyConfig(groupName) {
|
|
return new Promise(resolve => {
|
|
const overlay = document.createElement('div');
|
|
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center;';
|
|
|
|
overlay.innerHTML = `
|
|
<div style="background:var(--bg-secondary, #1e1e2e);border:1px solid rgba(255,255,255,0.1);border-radius:14px;padding:28px;max-width:420px;width:90%;color:var(--text-primary, #fff);font-family:inherit;">
|
|
<h3 style="margin:0 0 6px;font-size:1.1em;">Configure Notifications</h3>
|
|
<p style="margin:0 0 18px;font-size:0.85em;opacity:0.5;">${groupName} includes notification steps. Choose how to get notified.</p>
|
|
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:18px;">
|
|
<label style="font-size:0.85em;opacity:0.7;">Notification Type</label>
|
|
<select id="deploy-notify-type" style="padding:9px 12px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);border-radius:8px;color:#fff;font-size:0.9em;">
|
|
<option value="discord_webhook">Discord Webhook</option>
|
|
<option value="telegram">Telegram</option>
|
|
<option value="pushbullet">Pushbullet</option>
|
|
<option value="none">Skip Notifications</option>
|
|
</select>
|
|
<div id="deploy-notify-fields"></div>
|
|
</div>
|
|
<div style="display:flex;gap:10px;justify-content:flex-end;">
|
|
<button id="deploy-notify-cancel" style="padding:8px 20px;border:1px solid rgba(255,255,255,0.1);border-radius:8px;background:transparent;color:rgba(255,255,255,0.7);cursor:pointer;font-size:0.88em;">Cancel</button>
|
|
<button id="deploy-notify-confirm" style="padding:8px 20px;border:none;border-radius:8px;background:var(--accent-color,#1db954);color:#fff;cursor:pointer;font-size:0.88em;font-weight:600;">Deploy</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(overlay);
|
|
|
|
const typeSelect = overlay.querySelector('#deploy-notify-type');
|
|
const fieldsDiv = overlay.querySelector('#deploy-notify-fields');
|
|
|
|
function updateFields() {
|
|
const type = typeSelect.value;
|
|
if (type === 'discord_webhook') {
|
|
fieldsDiv.innerHTML = '<input id="deploy-notify-url" type="text" placeholder="Discord Webhook URL" style="width:100%;padding:9px 12px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);border-radius:8px;color:#fff;font-size:0.88em;margin-top:6px;box-sizing:border-box;">';
|
|
} else if (type === 'telegram') {
|
|
fieldsDiv.innerHTML = '<input id="deploy-notify-token" type="text" placeholder="Bot Token" style="width:100%;padding:9px 12px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);border-radius:8px;color:#fff;font-size:0.88em;margin-top:6px;box-sizing:border-box;"><input id="deploy-notify-chat" type="text" placeholder="Chat ID" style="width:100%;padding:9px 12px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);border-radius:8px;color:#fff;font-size:0.88em;margin-top:6px;box-sizing:border-box;">';
|
|
} else if (type === 'pushbullet') {
|
|
fieldsDiv.innerHTML = '<input id="deploy-notify-token" type="text" placeholder="Access Token" style="width:100%;padding:9px 12px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);border-radius:8px;color:#fff;font-size:0.88em;margin-top:6px;box-sizing:border-box;">';
|
|
} else {
|
|
fieldsDiv.innerHTML = '';
|
|
}
|
|
}
|
|
typeSelect.addEventListener('change', updateFields);
|
|
updateFields();
|
|
|
|
overlay.querySelector('#deploy-notify-cancel').addEventListener('click', () => {
|
|
document.body.removeChild(overlay);
|
|
resolve(null);
|
|
});
|
|
|
|
overlay.querySelector('#deploy-notify-confirm').addEventListener('click', () => {
|
|
const type = typeSelect.value;
|
|
let config = {};
|
|
if (type === 'discord_webhook') {
|
|
config = { webhook_url: (overlay.querySelector('#deploy-notify-url')?.value || '').trim() };
|
|
} else if (type === 'telegram') {
|
|
config = { bot_token: (overlay.querySelector('#deploy-notify-token')?.value || '').trim(), chat_id: (overlay.querySelector('#deploy-notify-chat')?.value || '').trim() };
|
|
} else if (type === 'pushbullet') {
|
|
config = { access_token: (overlay.querySelector('#deploy-notify-token')?.value || '').trim() };
|
|
} else {
|
|
document.body.removeChild(overlay);
|
|
resolve(false); // Skip notifications but still deploy
|
|
return;
|
|
}
|
|
document.body.removeChild(overlay);
|
|
resolve({ type, config });
|
|
});
|
|
|
|
overlay.addEventListener('click', (e) => {
|
|
if (e.target === overlay) { document.body.removeChild(overlay); resolve(null); }
|
|
});
|
|
});
|
|
}
|
|
|
|
// --- Filter Bar ---
|
|
function _initAutoFilterBar(automations) {
|
|
const bar = document.getElementById('auto-filter-bar');
|
|
if (!bar) return;
|
|
if (automations.length < 7) { bar.style.display = 'none'; return; }
|
|
bar.style.display = '';
|
|
|
|
// Populate trigger dropdown
|
|
const trigSel = document.getElementById('auto-filter-trigger');
|
|
const actSel = document.getElementById('auto-filter-action');
|
|
const trigTypes = [...new Set(automations.map(a => a.trigger_type))].sort();
|
|
const actTypes = [...new Set(automations.map(a => a.action_type))].sort();
|
|
const prevTrig = trigSel.value;
|
|
const prevAct = actSel.value;
|
|
trigSel.innerHTML = '<option value="">All Triggers</option>' + trigTypes.map(t =>
|
|
`<option value="${_escAttr(t)}">${_esc(_autoFormatTrigger(t, {}))}</option>`).join('');
|
|
actSel.innerHTML = '<option value="">All Actions</option>' + actTypes.map(t =>
|
|
`<option value="${_escAttr(t)}">${_esc(_autoFormatAction(t))}</option>`).join('');
|
|
trigSel.value = prevTrig;
|
|
actSel.value = prevAct;
|
|
|
|
// Bind events (use a flag to avoid double-binding)
|
|
if (!bar.dataset.bound) {
|
|
bar.dataset.bound = '1';
|
|
document.getElementById('auto-filter-search').addEventListener('input', _filterAutomations);
|
|
trigSel.addEventListener('change', _filterAutomations);
|
|
actSel.addEventListener('change', _filterAutomations);
|
|
}
|
|
_filterAutomations();
|
|
}
|
|
|
|
function _filterAutomations() {
|
|
const q = (document.getElementById('auto-filter-search').value || '').toLowerCase().trim();
|
|
const trigFilter = document.getElementById('auto-filter-trigger').value;
|
|
const actFilter = document.getElementById('auto-filter-action').value;
|
|
const cards = document.querySelectorAll('#automations-list .automation-card');
|
|
let visible = 0;
|
|
cards.forEach(card => {
|
|
const name = (card.querySelector('.automation-name')?.textContent || '').toLowerCase();
|
|
const trig = card.querySelector('.flow-trigger')?.textContent || '';
|
|
const act = card.querySelector('.flow-action')?.textContent || '';
|
|
// Match search text against name, trigger label, action label
|
|
const matchQ = !q || name.includes(q) || trig.toLowerCase().includes(q) || act.toLowerCase().includes(q);
|
|
// Match trigger/action type filters using data attributes
|
|
const matchTrig = !trigFilter || card.dataset.triggerType === trigFilter;
|
|
const matchAct = !actFilter || card.dataset.actionType === actFilter;
|
|
const show = matchQ && matchTrig && matchAct;
|
|
card.style.display = show ? '' : 'none';
|
|
if (show) visible++;
|
|
});
|
|
const countEl = document.getElementById('auto-filter-count');
|
|
if (countEl) {
|
|
countEl.textContent = (q || trigFilter || actFilter) ? `${visible} of ${cards.length}` : '';
|
|
}
|
|
}
|
|
|
|
// --- Group Dropdown ---
|
|
let _activeGroupDropdown = null;
|
|
|
|
function _showGroupDropdown(event, autoId, currentGroup) {
|
|
// Close any existing dropdown
|
|
_closeGroupDropdown();
|
|
|
|
const btn = event.currentTarget;
|
|
const card = btn.closest('.automation-card');
|
|
if (!card) return;
|
|
|
|
// Collect all existing group names from visible cards
|
|
const allGroups = new Set();
|
|
document.querySelectorAll('#automations-list .automation-card .automation-group-btn[data-group]').forEach(b => {
|
|
const g = b.dataset.group;
|
|
if (g) allGroups.add(g);
|
|
});
|
|
|
|
const dropdown = document.createElement('div');
|
|
dropdown.className = 'auto-group-dropdown';
|
|
|
|
let html = '';
|
|
if (currentGroup) {
|
|
html += `<div class="auto-group-option ungroup" onclick="_assignGroup(${autoId}, null)">Remove from group</div>`;
|
|
html += '<div class="auto-group-divider"></div>';
|
|
}
|
|
allGroups.forEach(g => {
|
|
const isActive = g === currentGroup;
|
|
html += `<div class="auto-group-option${isActive ? ' active' : ''}" onclick="_assignGroup(${autoId}, '${_escAttr(g)}')">${_esc(g)}</div>`;
|
|
});
|
|
if (allGroups.size) html += '<div class="auto-group-divider"></div>';
|
|
html += `<input class="auto-group-input" placeholder="New group name..." onkeydown="if(event.key==='Enter'){_assignGroup(${autoId}, this.value.trim()); event.preventDefault();}">`;
|
|
|
|
dropdown.innerHTML = html;
|
|
|
|
// Position dropdown on document.body to avoid overflow:hidden clipping
|
|
const rect = btn.getBoundingClientRect();
|
|
dropdown.style.position = 'fixed';
|
|
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
|
|
dropdown.style.left = 'auto';
|
|
document.body.appendChild(dropdown);
|
|
_activeGroupDropdown = dropdown;
|
|
|
|
// Open upward if not enough room below
|
|
const dropdownHeight = dropdown.offsetHeight;
|
|
if (rect.bottom + 4 + dropdownHeight > window.innerHeight && rect.top - 4 - dropdownHeight > 0) {
|
|
dropdown.style.top = (rect.top - 4 - dropdownHeight) + 'px';
|
|
} else {
|
|
dropdown.style.top = (rect.bottom + 4) + 'px';
|
|
}
|
|
|
|
// Focus the input
|
|
setTimeout(() => dropdown.querySelector('.auto-group-input')?.focus(), 50);
|
|
|
|
// Close on outside click
|
|
const handler = (e) => {
|
|
if (!dropdown.contains(e.target) && e.target !== btn) {
|
|
_closeGroupDropdown();
|
|
document.removeEventListener('click', handler, true);
|
|
}
|
|
};
|
|
setTimeout(() => document.addEventListener('click', handler, true), 10);
|
|
}
|
|
|
|
function _closeGroupDropdown() {
|
|
if (_activeGroupDropdown) {
|
|
_activeGroupDropdown.remove();
|
|
_activeGroupDropdown = null;
|
|
}
|
|
}
|
|
|
|
async function _assignGroup(autoId, groupName) {
|
|
_closeGroupDropdown();
|
|
try {
|
|
const res = await fetch('/api/automations/' + autoId, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ group_name: groupName || null })
|
|
});
|
|
const data = await res.json();
|
|
if (data.error) throw new Error(data.error);
|
|
showToast(groupName ? `Moved to "${groupName}"` : 'Removed from group', 'success');
|
|
await loadAutomations();
|
|
} catch (err) { showToast('Error: ' + err.message, 'error'); }
|
|
}
|
|
|
|
function renderAutomationCard(a) {
|
|
const card = document.createElement('div');
|
|
card.className = 'automation-card' + (a.enabled ? '' : ' disabled') + (a.is_system ? ' system' : '');
|
|
card.dataset.id = a.id;
|
|
card.dataset.triggerType = a.trigger_type || '';
|
|
card.dataset.actionType = a.action_type || '';
|
|
|
|
// Drag-and-drop (non-system only)
|
|
if (!a.is_system) {
|
|
card.draggable = true;
|
|
card.addEventListener('dragstart', (e) => {
|
|
_autoDragState = { id: a.id, groupName: a.group_name || null };
|
|
e.dataTransfer.setData('text/plain', String(a.id));
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
card.classList.add('dragging');
|
|
// Dim protected sections during drag
|
|
document.querySelectorAll('.section-protected').forEach(s => s.classList.add('no-drop'));
|
|
});
|
|
card.addEventListener('dragend', () => {
|
|
card.classList.remove('dragging');
|
|
_autoDragState = null;
|
|
_autoDragEnterCount = 0;
|
|
document.querySelectorAll('.drop-target').forEach(el => el.classList.remove('drop-target'));
|
|
document.querySelectorAll('.no-drop').forEach(el => el.classList.remove('no-drop'));
|
|
if (_autoDragExpandTimer) { clearTimeout(_autoDragExpandTimer); _autoDragExpandTimer = null; }
|
|
});
|
|
}
|
|
const tIcon = _autoIcons[a.trigger_type] || '\u2699\uFE0F';
|
|
const aIcon = _autoIcons[a.action_type] || '\u2699\uFE0F';
|
|
const tl = tIcon + ' ' + _autoFormatTrigger(a.trigger_type, a.trigger_config);
|
|
const al = aIcon + ' ' + _autoFormatAction(a.action_type);
|
|
const thenItems = a.then_actions || [];
|
|
const actionDelay = a.action_config && a.action_config.delay ? a.action_config.delay : 0;
|
|
const metaParts = [];
|
|
if (a.last_run) metaParts.push('Last: ' + _autoTimeAgo(a.last_run));
|
|
const _timerTriggers = ['schedule', 'daily_time', 'weekly_time'];
|
|
if (a.next_run && a.enabled && _timerTriggers.includes(a.trigger_type)) metaParts.push('<span class="auto-next-run" data-next="' + _escAttr(a.next_run) + '">Next: ' + _autoTimeUntil(a.next_run) + '</span>');
|
|
if (!_timerTriggers.includes(a.trigger_type) && a.enabled) metaParts.push('Listening');
|
|
if (a.run_count) metaParts.push('<span class="auto-runs-link" onclick="event.stopPropagation(); showAutomationHistory(' + a.id + ', \'' + _escAttr(a.name) + '\', \'' + _escAttr(a.action_type || '') + '\')" title="View run history">Runs: ' + a.run_count + '</span>');
|
|
if (a.last_error) metaParts.push('Error: ' + _esc(a.last_error));
|
|
|
|
const dupeBtn = a.is_system ? '' :
|
|
`<button class="automation-dupe-btn" title="Duplicate" onclick="event.stopPropagation(); duplicateAutomation(${a.id})">📋</button>`;
|
|
const groupBtn = a.is_system ? '' :
|
|
`<button class="automation-group-btn${a.group_name ? ' grouped' : ''}" data-group="${_escAttr(a.group_name || '')}" title="${a.group_name ? 'Group: ' + _escAttr(a.group_name) : 'Assign group'}" onclick="event.stopPropagation(); _showGroupDropdown(event, ${a.id}, ${a.group_name ? "'" + _escAttr(a.group_name) + "'" : 'null'})">📁</button>`;
|
|
const deleteBtn = a.is_system ? '' :
|
|
`<button class="automation-delete-btn" title="Delete" onclick="event.stopPropagation(); deleteAutomation(${a.id}, '${_escAttr(a.name)}')">🗑</button>`;
|
|
|
|
card.innerHTML = `
|
|
<div class="automation-status ${a.enabled ? 'enabled' : 'disabled'}"></div>
|
|
<div class="automation-info">
|
|
<div class="automation-name">${_esc(a.name)}</div>
|
|
<div class="automation-flow">
|
|
<span class="flow-trigger">${_esc(tl)}</span>
|
|
<span class="flow-arrow">→</span>
|
|
${actionDelay ? `<span class="flow-delay">\u23F3 ${actionDelay}m</span><span class="flow-arrow">→</span>` : ''}
|
|
<span class="flow-action">${_esc(al)}</span>
|
|
${thenItems.length ? thenItems.map(t => `<span class="flow-arrow">→</span><span class="flow-notify">${_esc(_autoFormatNotify(t.type))}</span>`).join('') : ''}
|
|
</div>
|
|
<div class="automation-meta">${metaParts.join(' · ')}</div>
|
|
</div>
|
|
<div class="automation-actions">
|
|
<button class="automation-run-btn" title="Run now" onclick="event.stopPropagation(); runAutomation(${a.id})">▶</button>
|
|
<label class="automation-toggle" onclick="event.stopPropagation();">
|
|
<input type="checkbox" ${a.enabled ? 'checked' : ''} onchange="toggleAutomation(${a.id})">
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
<button class="automation-edit-btn" title="Edit" onclick="event.stopPropagation(); showAutomationBuilder(${a.id})">⚙</button>
|
|
${dupeBtn}
|
|
${groupBtn}
|
|
${deleteBtn}
|
|
</div>
|
|
`;
|
|
return card;
|
|
}
|
|
|
|
function _autoFormatTrigger(type, config) {
|
|
if (type === 'schedule' && config) return 'Every ' + (config.interval || 1) + ' ' + (config.unit || 'hours');
|
|
if (type === 'daily_time' && config) return 'Daily at ' + (config.time || '00:00');
|
|
if (type === 'weekly_time' && config) {
|
|
const days = (config.days || []).map(d => d.charAt(0).toUpperCase() + d.slice(1)).join(', ');
|
|
return (days || 'Every day') + ' at ' + (config.time || '00:00');
|
|
}
|
|
if (type === 'signal_received' && config) {
|
|
const sig = config.signal_name || 'unknown';
|
|
return 'Signal: ' + sig;
|
|
}
|
|
const labels = {
|
|
app_started: 'App Started', track_downloaded: 'Track Downloaded', batch_complete: 'Batch Complete',
|
|
watchlist_new_release: 'New Release Found', playlist_synced: 'Playlist Synced',
|
|
playlist_changed: 'Playlist Changed', discovery_completed: 'Discovery Complete',
|
|
wishlist_processing_completed: 'Wishlist Processed', watchlist_scan_completed: 'Watchlist Scan Done',
|
|
database_update_completed: 'Database Updated', download_failed: 'Download Failed',
|
|
download_quarantined: 'File Quarantined', wishlist_item_added: 'Wishlist Item Added',
|
|
watchlist_artist_added: 'Artist Watched', watchlist_artist_removed: 'Artist Unwatched',
|
|
import_completed: 'Import Complete', mirrored_playlist_created: 'Playlist Mirrored',
|
|
quality_scan_completed: 'Quality Scan Done', duplicate_scan_completed: 'Duplicate Scan Done',
|
|
library_scan_completed: 'Library Scan Done', signal_received: 'Signal Received'
|
|
};
|
|
let label = labels[type] || type || 'Unknown';
|
|
if (config && config.conditions && config.conditions.length) {
|
|
const first = config.conditions[0];
|
|
label += ' (' + first.field + ' ' + first.operator + ' "' + first.value + '"' +
|
|
(config.conditions.length > 1 ? ' +' + (config.conditions.length - 1) + ' more' : '') + ')';
|
|
}
|
|
return label;
|
|
}
|
|
function _autoFormatAction(type) {
|
|
const labels = {
|
|
process_wishlist: 'Process Wishlist', scan_watchlist: 'Scan Watchlist',
|
|
scan_library: 'Scan Library', refresh_mirrored: 'Refresh Mirrored',
|
|
sync_playlist: 'Sync Playlist', discover_playlist: 'Discover Playlist',
|
|
notify_only: 'Notify Only',
|
|
start_database_update: 'Update Database', run_duplicate_cleaner: 'Run Duplicate Cleaner',
|
|
clear_quarantine: 'Clear Quarantine', cleanup_wishlist: 'Clean Up Wishlist',
|
|
update_discovery_pool: 'Update Discovery', start_quality_scan: 'Run Quality Scan',
|
|
backup_database: 'Backup Database',
|
|
refresh_beatport_cache: 'Refresh Beatport Cache', clean_search_history: 'Clean Search History',
|
|
clean_completed_downloads: 'Clean Completed Downloads',
|
|
full_cleanup: 'Full Cleanup',
|
|
playlist_pipeline: 'Playlist Pipeline'
|
|
};
|
|
return labels[type] || type || 'Unknown';
|
|
}
|
|
function _autoFormatNotify(type) {
|
|
if (type === 'discord_webhook') return 'Discord';
|
|
if (type === 'pushbullet') return 'Pushbullet';
|
|
if (type === 'telegram') return 'Telegram';
|
|
if (type === 'fire_signal') return '\u26A1 Signal';
|
|
if (type === 'run_script') return '\uD83D\uDCBB Script';
|
|
return type || '';
|
|
}
|
|
function _autoParseUTC(ts) {
|
|
// If timestamp already has timezone info (+00:00 or Z), parse as-is; otherwise append Z to treat as UTC
|
|
if (/[Zz]$/.test(ts) || /[+-]\d{2}:\d{2}$/.test(ts)) return new Date(ts).getTime();
|
|
return new Date(ts + 'Z').getTime();
|
|
}
|
|
function _autoTimeAgo(ts) {
|
|
if (!ts) return 'Never';
|
|
const d = (Date.now() - _autoParseUTC(ts)) / 1000;
|
|
if (d < 60) return 'just now'; if (d < 3600) return Math.floor(d / 60) + 'm ago';
|
|
if (d < 86400) return Math.floor(d / 3600) + 'h ago'; return Math.floor(d / 86400) + 'd ago';
|
|
}
|
|
function _autoTimeUntil(ts) {
|
|
if (!ts) return '';
|
|
const d = (_autoParseUTC(ts) - Date.now()) / 1000;
|
|
if (d <= 0) return 'soon'; if (d < 60) return 'in ' + Math.ceil(d) + 's';
|
|
if (d < 3600) return 'in ' + Math.ceil(d / 60) + 'm'; if (d < 86400) return 'in ' + Math.round(d / 3600) + 'h';
|
|
return 'in ' + Math.round(d / 86400) + 'd';
|
|
}
|
|
|
|
// --- Live countdown for "Next: in Xs" ---
|
|
setInterval(() => {
|
|
document.querySelectorAll('.auto-next-run[data-next]').forEach(el => {
|
|
el.textContent = 'Next: ' + _autoTimeUntil(el.dataset.next);
|
|
});
|
|
}, 1000);
|
|
|
|
// --- CRUD ---
|
|
|
|
async function deleteAutomation(id, name) {
|
|
if (!await showConfirmDialog({ title: 'Delete Automation', message: `Delete automation "${name}"?`, confirmText: 'Delete', destructive: true })) return;
|
|
try {
|
|
const res = await fetch('/api/automations/' + id, { method: 'DELETE' });
|
|
const data = await res.json();
|
|
if (data.error) throw new Error(data.error);
|
|
showToast('Automation deleted', 'success');
|
|
await loadAutomations();
|
|
} catch (err) { showToast('Error: ' + err.message, 'error'); }
|
|
}
|
|
|
|
async function duplicateAutomation(id) {
|
|
try {
|
|
const res = await fetch('/api/automations/' + id + '/duplicate', { method: 'POST' });
|
|
const data = await res.json();
|
|
if (data.error) throw new Error(data.error);
|
|
showToast('Automation duplicated', 'success');
|
|
await loadAutomations();
|
|
} catch (err) { showToast('Error: ' + err.message, 'error'); }
|
|
}
|
|
|
|
async function toggleAutomation(id) {
|
|
try {
|
|
const res = await fetch('/api/automations/' + id + '/toggle', { method: 'POST' });
|
|
const data = await res.json();
|
|
if (data.error) throw new Error(data.error);
|
|
await loadAutomations();
|
|
} catch (err) { showToast('Error: ' + err.message, 'error'); }
|
|
}
|
|
|
|
// --- Automation Progress Tracking ---
|
|
const _autoProgressLogCounts = {};
|
|
const _autoProgressHideTimers = {};
|
|
|
|
function updateAutomationProgressFromData(data) {
|
|
for (const [aidStr, state] of Object.entries(data)) {
|
|
const aid = parseInt(aidStr);
|
|
const card = document.querySelector(`.automation-card[data-id="${aid}"]`);
|
|
if (!card) continue;
|
|
|
|
let panel = card.querySelector('.automation-output');
|
|
if (!panel) {
|
|
panel = document.createElement('div');
|
|
panel.className = 'automation-output';
|
|
panel.innerHTML = `
|
|
<div class="auto-progress-bar-wrap"><div class="auto-progress-bar" style="width:0%"></div></div>
|
|
<div class="auto-progress-phase"></div>
|
|
<div class="auto-progress-log"></div>
|
|
`;
|
|
card.appendChild(panel);
|
|
_autoProgressLogCounts[aid] = 0;
|
|
}
|
|
|
|
// Update progress bar
|
|
const bar = panel.querySelector('.auto-progress-bar');
|
|
bar.style.width = (state.progress || 0) + '%';
|
|
|
|
// Update phase text
|
|
const phaseEl = panel.querySelector('.auto-progress-phase');
|
|
phaseEl.textContent = state.phase || '';
|
|
|
|
// Status indicator on card
|
|
const statusDot = card.querySelector('.automation-status');
|
|
|
|
if (state.status === 'running') {
|
|
if (statusDot) statusDot.className = 'automation-status running';
|
|
card.classList.add('running');
|
|
panel.classList.add('visible');
|
|
panel.classList.remove('finished', 'error');
|
|
if (_autoProgressHideTimers[aid]) {
|
|
clearTimeout(_autoProgressHideTimers[aid]);
|
|
delete _autoProgressHideTimers[aid];
|
|
}
|
|
// Reset log for new run (handles re-run within hide window)
|
|
if (_autoProgressLogCounts[aid] > 0 && state.log && state.log.length < _autoProgressLogCounts[aid]) {
|
|
const existingLog = panel.querySelector('.auto-progress-log');
|
|
if (existingLog) existingLog.innerHTML = '';
|
|
_autoProgressLogCounts[aid] = 0;
|
|
}
|
|
} else if (state.status === 'finished' || state.status === 'error') {
|
|
if (statusDot) statusDot.className = 'automation-status ' + (card.querySelector('input[type=checkbox]')?.checked ? 'enabled' : 'disabled');
|
|
card.classList.remove('running');
|
|
bar.style.width = '100%';
|
|
panel.classList.add('finished');
|
|
if (state.status === 'error') panel.classList.add('error');
|
|
if (!_autoProgressHideTimers[aid]) {
|
|
_autoProgressHideTimers[aid] = setTimeout(() => {
|
|
panel.classList.remove('visible');
|
|
delete _autoProgressHideTimers[aid];
|
|
_autoProgressLogCounts[aid] = 0;
|
|
}, 30000);
|
|
}
|
|
}
|
|
|
|
// Update log lines
|
|
const logEl = panel.querySelector('.auto-progress-log');
|
|
const rendered = _autoProgressLogCounts[aid] || 0;
|
|
const logLines = state.log || [];
|
|
if (logLines.length > rendered) {
|
|
// Normal append — log is still growing
|
|
for (let i = rendered; i < logLines.length; i++) {
|
|
const line = logLines[i];
|
|
const div = document.createElement('div');
|
|
div.className = 'auto-log-line ' + (line.type || 'info');
|
|
div.textContent = line.text;
|
|
logEl.appendChild(div);
|
|
}
|
|
_autoProgressLogCounts[aid] = logLines.length;
|
|
logEl.scrollTop = logEl.scrollHeight;
|
|
} else if (logLines.length === rendered && logLines.length >= 50) {
|
|
// Log buffer is full and rotating — replace last few lines
|
|
const children = logEl.children;
|
|
if (children.length > 0) {
|
|
const lastServerLine = logLines[logLines.length - 1];
|
|
const lastDomLine = children[children.length - 1];
|
|
if (lastServerLine && lastDomLine.textContent !== lastServerLine.text) {
|
|
// Content changed — full re-render
|
|
logEl.innerHTML = '';
|
|
for (const line of logLines) {
|
|
const div = document.createElement('div');
|
|
div.className = 'auto-log-line ' + (line.type || 'info');
|
|
div.textContent = line.text;
|
|
logEl.appendChild(div);
|
|
}
|
|
_autoProgressLogCounts[aid] = logLines.length;
|
|
logEl.scrollTop = logEl.scrollHeight;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runAutomation(id) {
|
|
try {
|
|
const res = await fetch('/api/automations/' + id + '/run', { method: 'POST' });
|
|
const data = await res.json();
|
|
if (data.error) throw new Error(data.error);
|
|
showToast('Automation triggered', 'success');
|
|
setTimeout(() => loadAutomations(), 1500);
|
|
} catch (err) { showToast('Error: ' + err.message, 'error'); }
|
|
}
|
|
|
|
const _RESULT_DISPLAY_MAP = {
|
|
'start_database_update': [
|
|
{ key: 'artists', label: 'Artists' },
|
|
{ key: 'albums', label: 'Albums' },
|
|
{ key: 'tracks', label: 'Tracks' },
|
|
{ key: 'removed_artists', label: 'Removed Artists', hideZero: true },
|
|
{ key: 'removed_albums', label: 'Removed Albums', hideZero: true },
|
|
{ key: 'removed_tracks', label: 'Removed Tracks', hideZero: true },
|
|
],
|
|
'deep_scan_library': [
|
|
{ key: 'artists', label: 'Artists' },
|
|
{ key: 'albums', label: 'Albums' },
|
|
{ key: 'tracks', label: 'Tracks' },
|
|
{ key: 'removed_artists', label: 'Removed Artists', hideZero: true },
|
|
{ key: 'removed_albums', label: 'Removed Albums', hideZero: true },
|
|
{ key: 'removed_tracks', label: 'Removed Tracks', hideZero: true },
|
|
],
|
|
'scan_watchlist': [
|
|
{ key: 'artists_scanned', label: 'Artists Scanned' },
|
|
{ key: 'successful_scans', label: 'Successful' },
|
|
{ key: 'new_tracks_found', label: 'New Tracks' },
|
|
{ key: 'tracks_added_to_wishlist', label: 'Added to Wishlist' },
|
|
],
|
|
'run_duplicate_cleaner': [
|
|
{ key: 'files_scanned', label: 'Files Scanned' },
|
|
{ key: 'duplicates_found', label: 'Duplicates Found' },
|
|
{ key: 'files_deleted', label: 'Files Deleted' },
|
|
{ key: 'space_freed_mb', label: 'Space Freed (MB)' },
|
|
],
|
|
'start_quality_scan': [
|
|
{ key: 'tracks_scanned', label: 'Tracks Scanned' },
|
|
{ key: 'quality_met', label: 'Quality Met' },
|
|
{ key: 'low_quality', label: 'Low Quality' },
|
|
{ key: 'matched', label: 'Added to Wishlist' },
|
|
],
|
|
'scan_library': [
|
|
{ key: 'scan_duration_seconds', label: 'Duration (s)' },
|
|
],
|
|
'backup_database': [
|
|
{ key: 'size_mb', label: 'Backup Size (MB)' },
|
|
],
|
|
'refresh_mirrored': [
|
|
{ key: 'refreshed', label: 'Playlists Refreshed' },
|
|
{ key: 'errors', label: 'Errors', hideZero: true },
|
|
],
|
|
'clear_quarantine': [
|
|
{ key: 'removed', label: 'Items Removed' },
|
|
],
|
|
'cleanup_wishlist': [
|
|
{ key: 'removed', label: 'Duplicates Removed' },
|
|
],
|
|
'full_cleanup': [
|
|
{ key: 'quarantine_removed', label: 'Quarantine Removed' },
|
|
{ key: 'staging_removed', label: 'Import Dirs Removed' },
|
|
{ key: 'total_removed', label: 'Total Items Removed' },
|
|
],
|
|
'playlist_pipeline': [
|
|
{ key: 'playlists_refreshed', label: 'Refreshed' },
|
|
{ key: 'tracks_discovered', label: 'Discovered' },
|
|
{ key: 'tracks_synced', label: 'Synced' },
|
|
{ key: 'sync_skipped', label: 'Skipped', hideZero: true },
|
|
{ key: 'wishlist_queued', label: 'Wishlist Queued' },
|
|
{ key: 'duration_seconds', label: 'Duration (s)' },
|
|
],
|
|
};
|
|
|
|
function _renderResultStats(resultJson, actionType) {
|
|
if (!resultJson || typeof resultJson !== 'object') return '';
|
|
var fields = _RESULT_DISPLAY_MAP[actionType];
|
|
var items = [];
|
|
if (fields) {
|
|
fields.forEach(function (f) {
|
|
var val = resultJson[f.key];
|
|
if (val == null) return;
|
|
if (f.hideZero && (val === 0 || val === '0')) return;
|
|
items.push({ label: f.label, value: val });
|
|
});
|
|
} else {
|
|
// Generic fallback: show all non-status, non-underscore keys
|
|
Object.keys(resultJson).forEach(function (k) {
|
|
if (k === 'status' || k.startsWith('_')) return;
|
|
var label = k.replace(/_/g, ' ').replace(/\b\w/g, function (c) { return c.toUpperCase(); });
|
|
items.push({ label: label, value: resultJson[k] });
|
|
});
|
|
}
|
|
if (items.length === 0) return '';
|
|
var html = '<div class="history-stats-grid">';
|
|
items.forEach(function (it) {
|
|
html += '<div class="history-stat-item"><div class="history-stat-label">' + _esc(it.label) + '</div><div class="history-stat-value">' + _esc(String(it.value)) + '</div></div>';
|
|
});
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
async function showAutomationHistory(automationId, automationName, actionType) {
|
|
let modal = document.getElementById('automation-history-modal');
|
|
if (!modal) {
|
|
modal = document.createElement('div');
|
|
modal.id = 'automation-history-modal';
|
|
modal.className = 'modal-overlay';
|
|
document.body.appendChild(modal);
|
|
}
|
|
modal.innerHTML = '<div class="modal-container automation-history-modal"><div class="history-modal-header"><h3>Run History: ' + _esc(automationName) + '</h3><button class="history-close-btn" onclick="document.getElementById(\'automation-history-modal\').style.display=\'none\'">×</button></div><div class="history-modal-body"><div class="history-loading">Loading...</div></div></div>';
|
|
modal.style.display = 'flex';
|
|
modal.onclick = function (e) { if (e.target === modal) modal.style.display = 'none'; };
|
|
|
|
try {
|
|
const res = await fetch('/api/automations/' + automationId + '/history?limit=50');
|
|
const data = await res.json();
|
|
if (data.error) throw new Error(data.error);
|
|
const body = modal.querySelector('.history-modal-body');
|
|
if (!data.history || data.history.length === 0) {
|
|
body.innerHTML = '<div class="history-empty">No run history yet. History will be recorded on future runs.</div>';
|
|
return;
|
|
}
|
|
let html = '<div class="history-entries">';
|
|
data.history.forEach(function (entry) {
|
|
const statusClass = 'history-status-' + (entry.status || 'completed');
|
|
const statusLabel = (entry.status || 'completed').charAt(0).toUpperCase() + (entry.status || 'completed').slice(1);
|
|
const timeAgo = _autoTimeAgo(entry.started_at);
|
|
const duration = entry.duration_seconds != null ? _formatDuration(entry.duration_seconds) : '';
|
|
const summary = entry.summary ? _esc(entry.summary) : '';
|
|
const hasLogs = entry.log_lines && entry.log_lines.length > 0;
|
|
const entryId = 'history-entry-' + entry.id;
|
|
|
|
html += '<div class="history-entry">';
|
|
html += '<div class="history-entry-header" onclick="var el=document.getElementById(\'' + entryId + '\'); if(el) el.classList.toggle(\'expanded\')">';
|
|
html += '<span class="history-status-badge ' + statusClass + '">' + statusLabel + '</span>';
|
|
html += '<span class="history-time">' + timeAgo + '</span>';
|
|
if (duration) html += '<span class="history-duration">' + duration + '</span>';
|
|
if (hasLogs) html += '<span class="history-expand-icon">▼</span>';
|
|
html += '</div>';
|
|
if (summary) html += '<div class="history-summary">' + summary + '</div>';
|
|
if (entry.result_json && typeof entry.result_json === 'object') {
|
|
html += _renderResultStats(entry.result_json, actionType);
|
|
}
|
|
if (hasLogs) {
|
|
html += '<div id="' + entryId + '" class="history-log-section">';
|
|
entry.log_lines.forEach(function (log) {
|
|
html += '<div class="history-log-line history-log-' + (log.type || 'info') + '">' + _esc(log.text || '') + '</div>';
|
|
});
|
|
html += '</div>';
|
|
}
|
|
html += '</div>';
|
|
});
|
|
html += '</div>';
|
|
if (data.total > data.history.length) {
|
|
html += '<div class="history-total">Showing ' + data.history.length + ' of ' + data.total + ' runs</div>';
|
|
}
|
|
body.innerHTML = html;
|
|
} catch (err) {
|
|
const body = modal.querySelector('.history-modal-body');
|
|
if (body) body.innerHTML = '<div class="history-empty">Error loading history: ' + _esc(err.message) + '</div>';
|
|
}
|
|
}
|
|
|
|
function _formatDuration(seconds) {
|
|
if (seconds < 1) return '<1s';
|
|
if (seconds < 60) return Math.round(seconds) + 's';
|
|
var m = Math.floor(seconds / 60);
|
|
var s = Math.round(seconds % 60);
|
|
if (m < 60) return m + 'm ' + s + 's';
|
|
var h = Math.floor(m / 60);
|
|
m = m % 60;
|
|
return h + 'h ' + m + 'm';
|
|
}
|
|
|
|
async function saveAutomation() {
|
|
const name = document.getElementById('builder-name').value.trim();
|
|
if (!name) { showToast('Name is required', 'error'); return; }
|
|
if (!_autoBuilder.when) { showToast('Add a trigger (WHEN)', 'error'); return; }
|
|
if (!_autoBuilder.do) { showToast('Add an action (DO)', 'error'); return; }
|
|
|
|
// Read configs from DOM
|
|
const triggerConfig = _readPlacedConfig('when');
|
|
const actionConfig = _readPlacedConfig('do');
|
|
|
|
// Read THEN actions (multi-slot)
|
|
const thenActions = _autoBuilder.then.map((item, i) => ({
|
|
type: item.type,
|
|
config: _readPlacedConfig('then-' + i),
|
|
}));
|
|
|
|
// Read optional delay from DO slot
|
|
const delayEl = document.getElementById('cfg-do-delay');
|
|
const delayVal = delayEl ? parseInt(delayEl.value) : 0;
|
|
if (delayVal > 0) actionConfig.delay = delayVal;
|
|
|
|
const groupInput = document.getElementById('builder-group-name');
|
|
const groupName = groupInput ? groupInput.value.trim() : '';
|
|
|
|
const body = {
|
|
name,
|
|
trigger_type: _autoBuilder.when.type, trigger_config: triggerConfig,
|
|
action_type: _autoBuilder.do.type, action_config: actionConfig,
|
|
then_actions: thenActions,
|
|
group_name: groupName || null,
|
|
};
|
|
|
|
try {
|
|
let res;
|
|
if (_autoBuilder.editId) {
|
|
res = await fetch('/api/automations/' + _autoBuilder.editId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
} else {
|
|
res = await fetch('/api/automations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
}
|
|
const data = await res.json();
|
|
if (data.error) throw new Error(data.error);
|
|
showToast(_autoBuilder.editId ? 'Automation updated' : 'Automation created', 'success');
|
|
hideAutomationBuilder();
|
|
await loadAutomations();
|
|
} catch (err) { showToast('Error: ' + err.message, 'error'); }
|
|
}
|
|
|
|
// --- Builder View ---
|
|
|
|
async function showAutomationBuilder(editId) {
|
|
// Load block definitions (always refresh)
|
|
try {
|
|
const res = await fetch('/api/automations/blocks');
|
|
_autoBlocks = await res.json();
|
|
} catch (e) {
|
|
if (!_autoBlocks) { showToast('Failed to load blocks', 'error'); return; }
|
|
}
|
|
|
|
_autoMirroredPlaylists = null; // invalidate so it re-fetches
|
|
_autoSpotifyAuthenticated = false;
|
|
_autoBuilder = { editId: editId || null, when: null, do: null, then: [], isSystem: false };
|
|
|
|
// Populate group datalist from existing automations
|
|
try {
|
|
const allRes = await fetch('/api/automations');
|
|
const allAutos = await allRes.json();
|
|
const groupSet = new Set();
|
|
if (Array.isArray(allAutos)) allAutos.forEach(a => { if (a.group_name) groupSet.add(a.group_name); });
|
|
const datalist = document.getElementById('builder-group-list');
|
|
if (datalist) datalist.innerHTML = [...groupSet].sort().map(g => `<option value="${_escAttr(g)}">`).join('');
|
|
} catch (e) { }
|
|
|
|
// If editing, load automation data
|
|
if (editId) {
|
|
try {
|
|
const res = await fetch('/api/automations/' + editId);
|
|
const a = await res.json();
|
|
if (a.error) throw new Error(a.error);
|
|
document.getElementById('builder-name').value = a.name || '';
|
|
const groupInput = document.getElementById('builder-group-name');
|
|
if (groupInput) groupInput.value = a.group_name || '';
|
|
_autoBuilder.when = { type: a.trigger_type, config: a.trigger_config || {} };
|
|
_autoBuilder.do = { type: a.action_type, config: a.action_config || {} };
|
|
// Load then_actions array
|
|
_autoBuilder.then = (a.then_actions || []).map(item => ({
|
|
type: item.type, config: item.config || {}
|
|
}));
|
|
// Backward compat: if no then_actions but has notify_type
|
|
if (!_autoBuilder.then.length && a.notify_type) {
|
|
_autoBuilder.then = [{ type: a.notify_type, config: a.notify_config || {} }];
|
|
}
|
|
_autoBuilder.isSystem = !!a.is_system;
|
|
} catch (err) { showToast('Failed to load automation', 'error'); return; }
|
|
} else {
|
|
document.getElementById('builder-name').value = '';
|
|
const groupInput = document.getElementById('builder-group-name');
|
|
if (groupInput) groupInput.value = '';
|
|
}
|
|
|
|
// System automations: lock the name field and hide group
|
|
document.getElementById('builder-name').readOnly = _autoBuilder.isSystem;
|
|
const groupEl = document.getElementById('builder-group-name');
|
|
if (groupEl) groupEl.style.display = _autoBuilder.isSystem ? 'none' : '';
|
|
|
|
_renderBuilderSidebar();
|
|
_renderBuilderCanvas();
|
|
|
|
document.getElementById('automations-list-view').style.display = 'none';
|
|
document.getElementById('automations-builder-view').style.display = '';
|
|
}
|
|
|
|
function hideAutomationBuilder() {
|
|
document.getElementById('automations-builder-view').style.display = 'none';
|
|
document.getElementById('automations-list-view').style.display = '';
|
|
document.getElementById('builder-name').readOnly = false;
|
|
_autoBuilder = { editId: null, when: null, do: null, then: [], isSystem: false };
|
|
}
|
|
|
|
// --- Sidebar ---
|
|
|
|
function _renderBuilderSidebar() {
|
|
const sidebar = document.getElementById('builder-sidebar');
|
|
if (!sidebar || !_autoBlocks) return;
|
|
|
|
let html = '';
|
|
const sections = [
|
|
{ key: 'triggers', title: 'Triggers', slot: 'when' },
|
|
{ key: 'actions', title: 'Actions', slot: 'do' },
|
|
{ key: 'notifications', title: 'Then', slot: 'then' },
|
|
];
|
|
|
|
sections.forEach(sec => {
|
|
html += `<div class="sidebar-section"><div class="sidebar-section-title">${sec.title}</div>`;
|
|
(_autoBlocks[sec.key] || []).forEach(block => {
|
|
const icon = _autoIcons[block.type] || '\u2699\uFE0F';
|
|
const disabled = !block.available;
|
|
const helpKey = 'auto-' + block.type;
|
|
const hasHelp = !!TOOL_HELP_CONTENT[helpKey];
|
|
html += `<div class="block-item${disabled ? ' coming-soon' : ''}" ${!disabled ? `draggable="true" ondragstart="_autoDragStart(event,'${block.type}','${sec.slot}')" onclick="_autoClickBlock('${block.type}','${sec.slot}')"` : ''}>
|
|
<div class="block-item-icon">${icon}</div>
|
|
<div class="block-item-text">
|
|
<div class="block-item-label">${_esc(block.label)}</div>
|
|
<div class="block-item-desc">${_esc(block.description)}</div>
|
|
</div>
|
|
${disabled ? '<span class="coming-soon-badge">Soon</span>' : ''}
|
|
${hasHelp ? `<button class="tool-help-button block-help-btn" onclick="event.stopPropagation(); openToolHelpModal('${helpKey}')" title="Learn more">?</button>` : ''}
|
|
</div>`;
|
|
});
|
|
html += '</div>';
|
|
});
|
|
sidebar.innerHTML = html;
|
|
}
|
|
|
|
// --- Canvas ---
|
|
|
|
function _renderBuilderCanvas() {
|
|
const canvas = document.getElementById('builder-canvas');
|
|
if (!canvas) return;
|
|
|
|
let html = '';
|
|
|
|
// WHEN slot
|
|
const whenData = _autoBuilder.when;
|
|
html += '<span class="flow-slot-label when">WHEN</span>';
|
|
if (whenData) {
|
|
html += `<div class="flow-slot filled" id="slot-when" ondragover="_autoDragOver(event,'when')" ondragleave="_autoDragLeave(event,'when')" ondrop="_autoDrop(event,'when')">
|
|
${_renderPlacedBlock('when', whenData)}
|
|
</div>`;
|
|
} else {
|
|
html += `<div class="flow-slot empty" id="slot-when" ondragover="_autoDragOver(event,'when')" ondragleave="_autoDragLeave(event,'when')" ondrop="_autoDrop(event,'when')">
|
|
<div class="flow-slot-prompt">Drag a trigger here — WHEN does this run?</div>
|
|
</div>`;
|
|
}
|
|
|
|
html += '<div class="flow-connector"></div>';
|
|
|
|
// DO slot
|
|
const doData = _autoBuilder.do;
|
|
html += '<span class="flow-slot-label do">DO</span>';
|
|
if (doData) {
|
|
html += `<div class="flow-slot filled" id="slot-do" ondragover="_autoDragOver(event,'do')" ondragleave="_autoDragLeave(event,'do')" ondrop="_autoDrop(event,'do')">
|
|
${_renderPlacedBlock('do', doData)}
|
|
</div>`;
|
|
} else {
|
|
html += `<div class="flow-slot empty" id="slot-do" ondragover="_autoDragOver(event,'do')" ondragleave="_autoDragLeave(event,'do')" ondrop="_autoDrop(event,'do')">
|
|
<div class="flow-slot-prompt">Drag an action here — WHAT should it do?</div>
|
|
</div>`;
|
|
}
|
|
|
|
html += '<div class="flow-connector"></div>';
|
|
|
|
// THEN section (multi-slot, 1-3 items)
|
|
html += '<span class="flow-slot-label then">THEN</span>';
|
|
if (_autoBuilder.then.length > 0) {
|
|
_autoBuilder.then.forEach((item, i) => {
|
|
if (i > 0) html += '<div class="flow-connector small"></div>';
|
|
html += `<div class="flow-slot filled then-slot" id="slot-then-${i}">
|
|
${_renderPlacedBlock('then-' + i, item)}
|
|
</div>`;
|
|
});
|
|
}
|
|
if (_autoBuilder.then.length < 3) {
|
|
if (_autoBuilder.then.length > 0) html += '<div class="flow-connector small"></div>';
|
|
html += `<div class="flow-slot empty then-add" id="slot-then-add"
|
|
ondragover="_autoDragOver(event,'then')" ondragleave="_autoDragLeave(event,'then')" ondrop="_autoDrop(event,'then')">
|
|
<div class="flow-slot-prompt">${_autoBuilder.then.length === 0
|
|
? 'Drag a then-action here (optional)'
|
|
: '+ Add another (max 3)'}</div>
|
|
</div>`;
|
|
}
|
|
|
|
canvas.innerHTML = html;
|
|
// Load mirrored playlist selects if any are present
|
|
_autoLoadMirroredSelects();
|
|
// Set up checkbox state for refresh_mirrored
|
|
['when', 'do'].forEach(sk => {
|
|
const allCb = document.getElementById('cfg-' + sk + '-all');
|
|
if (allCb) _autoTogglePlaylistSelect(sk);
|
|
});
|
|
// Also check then slots
|
|
_autoBuilder.then.forEach((item, i) => {
|
|
const allCb = document.getElementById('cfg-then-' + i + '-all');
|
|
if (allCb) _autoTogglePlaylistSelect('then-' + i);
|
|
});
|
|
}
|
|
|
|
function _renderPlacedBlock(slotKey, data) {
|
|
const blockDef = _findBlockDef(data.type);
|
|
const icon = _autoIcons[data.type] || '\u2699\uFE0F';
|
|
const label = blockDef ? blockDef.label : data.type;
|
|
const configHtml = _renderBlockConfigFields(slotKey, data.type, data.config || {});
|
|
|
|
// Add optional delay field for action blocks
|
|
let delayHtml = '';
|
|
if (slotKey === 'do') {
|
|
const delayVal = (data.config && data.config.delay) || '';
|
|
delayHtml = `<div class="placed-block-config"><div class="config-row">
|
|
<label>Delay (minutes)</label>
|
|
<input type="number" id="cfg-${slotKey}-delay" value="${delayVal}" min="0" placeholder="0" style="width:80px;" title="Wait before executing action">
|
|
</div></div>`;
|
|
}
|
|
|
|
// System automations: lock trigger and action slots (no remove, no replace)
|
|
const locked = _autoBuilder.isSystem && (slotKey === 'when' || slotKey === 'do');
|
|
const removeBtn = locked ? '' : `<button class="placed-block-remove" onclick="_autoRemoveBlock('${slotKey}')">\u2715</button>`;
|
|
|
|
return `<div class="placed-block${locked ? ' locked' : ''}" data-type="${_escAttr(data.type)}">
|
|
<div class="placed-block-header">
|
|
<span class="placed-block-icon">${icon}</span>
|
|
<span class="placed-block-label">${_esc(label)}</span>
|
|
${removeBtn}
|
|
</div>
|
|
${configHtml ? '<div class="placed-block-config">' + configHtml + '</div>' : ''}
|
|
${delayHtml}
|
|
</div>`;
|
|
}
|
|
|
|
function _renderBlockConfigFields(slotKey, blockType, config) {
|
|
if (blockType === 'schedule') {
|
|
const interval = config.interval || 6;
|
|
const unit = config.unit || 'hours';
|
|
return `<div class="config-row">
|
|
<label>Every</label>
|
|
<input type="number" id="cfg-${slotKey}-interval" value="${interval}" min="1" style="width:70px;">
|
|
<select id="cfg-${slotKey}-unit">
|
|
<option value="minutes"${unit === 'minutes' ? ' selected' : ''}>Minutes</option>
|
|
<option value="hours"${unit === 'hours' ? ' selected' : ''}>Hours</option>
|
|
<option value="days"${unit === 'days' ? ' selected' : ''}>Days</option>
|
|
</select>
|
|
</div>`;
|
|
}
|
|
if (blockType === 'daily_time') {
|
|
const timeVal = config.time || '03:00';
|
|
return `<div class="config-row">
|
|
<label>At</label>
|
|
<input type="time" id="cfg-${slotKey}-time" value="${timeVal}">
|
|
</div>`;
|
|
}
|
|
if (blockType === 'weekly_time') {
|
|
const timeVal = config.time || '03:00';
|
|
const selectedDays = config.days || [];
|
|
const allDays = [['mon', 'Mon'], ['tue', 'Tue'], ['wed', 'Wed'], ['thu', 'Thu'], ['fri', 'Fri'], ['sat', 'Sat'], ['sun', 'Sun']];
|
|
let dayHtml = '<div class="config-row"><label>Days</label><div class="day-picker" id="cfg-' + slotKey + '-days">';
|
|
allDays.forEach(([val, lbl]) => {
|
|
const active = selectedDays.includes(val) ? ' active' : '';
|
|
dayHtml += `<button type="button" class="day-btn${active}" data-day="${val}" onclick="this.classList.toggle('active')">${lbl}</button>`;
|
|
});
|
|
dayHtml += '</div></div>';
|
|
return `<div class="config-row">
|
|
<label>At</label>
|
|
<input type="time" id="cfg-${slotKey}-time" value="${timeVal}">
|
|
</div>${dayHtml}`;
|
|
}
|
|
|
|
// Event triggers with conditions
|
|
const blockDef = _findBlockDef(blockType);
|
|
if (blockDef && blockDef.has_conditions) {
|
|
return _renderConditionBuilder(slotKey, blockDef, config);
|
|
}
|
|
|
|
if (blockType === 'process_wishlist') {
|
|
const cat = config.category || 'all';
|
|
return `<div class="config-row">
|
|
<label>Category</label>
|
|
<select id="cfg-${slotKey}-category">
|
|
<option value="all"${cat === 'all' ? ' selected' : ''}>All</option>
|
|
<option value="albums"${cat === 'albums' ? ' selected' : ''}>Albums</option>
|
|
<option value="singles"${cat === 'singles' ? ' selected' : ''}>Singles</option>
|
|
</select>
|
|
</div>`;
|
|
}
|
|
if (blockType === 'signal_received') {
|
|
const sigName = _escAttr(config.signal_name || '');
|
|
const knownSignals = (_autoBlocks && _autoBlocks.known_signals) || [];
|
|
return `<div class="config-row">
|
|
<label>Signal Name</label>
|
|
<input type="text" id="cfg-${slotKey}-signal_name" value="${sigName}"
|
|
list="known-signals-list-${slotKey}" placeholder="e.g. libraryReady"
|
|
oninput="this.value = this.value.toLowerCase().replace(/[^a-z0-9_\\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')"
|
|
style="font-family:monospace;">
|
|
<datalist id="known-signals-list-${slotKey}">
|
|
${knownSignals.map(s => `<option value="${s}">`).join('')}
|
|
</datalist>
|
|
</div>
|
|
<div class="config-row" style="color:rgba(255,255,255,0.35);font-size:11px;">Triggers when another automation fires this signal</div>`;
|
|
}
|
|
if (blockType === 'fire_signal') {
|
|
const sigName = _escAttr(config.signal_name || '');
|
|
const knownSignals = (_autoBlocks && _autoBlocks.known_signals) || [];
|
|
return `<div class="config-row">
|
|
<label>Signal Name</label>
|
|
<input type="text" id="cfg-${slotKey}-signal_name" value="${sigName}"
|
|
list="known-signals-fire-${slotKey}" placeholder="e.g. libraryReady"
|
|
oninput="this.value = this.value.toLowerCase().replace(/[^a-z0-9_\\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')"
|
|
style="font-family:monospace;">
|
|
<datalist id="known-signals-fire-${slotKey}">
|
|
${knownSignals.map(s => `<option value="${s}">`).join('')}
|
|
</datalist>
|
|
</div>
|
|
<div class="config-row" style="color:rgba(255,255,255,0.35);font-size:11px;">Other automations with "Signal Received" trigger will wake up</div>`;
|
|
}
|
|
if (blockType === 'run_script') {
|
|
const scriptName = _escAttr(config.script_name || '');
|
|
const timeout = config.timeout || 60;
|
|
// Fetch scripts list and populate
|
|
const selectId = `cfg-${slotKey}-script_name`;
|
|
setTimeout(async () => {
|
|
try {
|
|
const resp = await fetch('/api/scripts');
|
|
const data = await resp.json();
|
|
const sel = document.getElementById(selectId);
|
|
if (sel && data.scripts) {
|
|
sel.innerHTML = '<option value="">Select a script...</option>' +
|
|
data.scripts.map(s => `<option value="${_escAttr(s.name)}"${s.name === scriptName ? ' selected' : ''}>${escapeHtml(s.name)} (${s.extension})</option>`).join('');
|
|
}
|
|
} catch (e) { console.warn('Failed to load scripts:', e); }
|
|
}, 100);
|
|
return `<div class="config-row">
|
|
<label>Script</label>
|
|
<select id="${selectId}">
|
|
<option value="${scriptName}">${scriptName || 'Loading...'}</option>
|
|
</select>
|
|
</div>
|
|
<div class="config-row">
|
|
<label>Timeout</label>
|
|
<input type="number" id="cfg-${slotKey}-timeout" value="${timeout}" min="5" max="300" style="width:80px;"> seconds
|
|
</div>
|
|
<div class="config-row" style="color:rgba(255,255,255,0.35);font-size:11px;">Place scripts in the <code>scripts/</code> folder. Supported: .sh, .py, .bat, .ps1</div>`;
|
|
}
|
|
if (blockType === 'scan_watchlist' || blockType === 'scan_library' || blockType === 'notify_only') {
|
|
return '<div class="config-row" style="color:rgba(255,255,255,0.4);font-size:12px;">No configuration needed</div>';
|
|
}
|
|
if (blockType === 'refresh_mirrored') {
|
|
const allChecked = config.all ? ' checked' : '';
|
|
return `<div class="config-row">
|
|
<label>Playlist</label>
|
|
<select id="cfg-${slotKey}-playlist_id" class="mirrored-playlist-select" data-block-type="refresh_mirrored" data-value="${_escAttr(config.playlist_id || '')}">
|
|
<option value="">Loading...</option>
|
|
</select>
|
|
</div>
|
|
<div class="config-row">
|
|
<label><input type="checkbox" id="cfg-${slotKey}-all"${allChecked} onchange="_autoTogglePlaylistSelect('${slotKey}')"> Refresh all mirrored playlists</label>
|
|
</div>`;
|
|
}
|
|
if (blockType === 'sync_playlist') {
|
|
return `<div class="config-row">
|
|
<label>Playlist</label>
|
|
<select id="cfg-${slotKey}-playlist_id" class="mirrored-playlist-select" data-value="${_escAttr(config.playlist_id || '')}">
|
|
<option value="">Loading...</option>
|
|
</select>
|
|
</div>`;
|
|
}
|
|
if (blockType === 'discover_playlist') {
|
|
const allChecked = config.all ? ' checked' : '';
|
|
return `<div class="config-row">
|
|
<label>Playlist</label>
|
|
<select id="cfg-${slotKey}-playlist_id" class="mirrored-playlist-select" data-value="${_escAttr(config.playlist_id || '')}">
|
|
<option value="">Loading...</option>
|
|
</select>
|
|
</div>
|
|
<div class="config-row">
|
|
<label><input type="checkbox" id="cfg-${slotKey}-all"${allChecked} onchange="_autoTogglePlaylistSelect('${slotKey}')"> Discover all mirrored playlists</label>
|
|
</div>`;
|
|
}
|
|
if (blockType === 'playlist_pipeline') {
|
|
const allChecked = config.all ? ' checked' : '';
|
|
const skipWishlistChecked = config.skip_wishlist ? ' checked' : '';
|
|
return `<div class="config-row">
|
|
<label>Playlist</label>
|
|
<select id="cfg-${slotKey}-playlist_id" class="mirrored-playlist-select" data-value="${_escAttr(config.playlist_id || '')}">
|
|
<option value="">Loading...</option>
|
|
</select>
|
|
</div>
|
|
<div class="config-row">
|
|
<label><input type="checkbox" id="cfg-${slotKey}-all"${allChecked} onchange="_autoTogglePlaylistSelect('${slotKey}')"> Process all mirrored playlists</label>
|
|
</div>
|
|
<div class="config-row">
|
|
<label><input type="checkbox" id="cfg-${slotKey}-skip_wishlist"${skipWishlistChecked}> Skip wishlist processing</label>
|
|
</div>
|
|
<div class="config-row" style="color:rgba(255,255,255,0.35);font-size:11px;">Runs 4 phases: Refresh → Discover → Sync → Download Missing</div>`;
|
|
}
|
|
// Shared variable tags builder for notification types
|
|
function _notifyVarHtml(slotKey) {
|
|
let allVars = ['time', 'name', 'run_count', 'status'];
|
|
const triggerDef = _autoBuilder.when ? _findBlockDef(_autoBuilder.when.type) : null;
|
|
if (triggerDef && triggerDef.variables) {
|
|
triggerDef.variables.forEach(v => { if (!allVars.includes(v)) allVars.push(v); });
|
|
}
|
|
let html = '<div class="variable-tags">';
|
|
allVars.forEach(v => { html += `<span class="variable-tag" onclick="_autoInsertVar('cfg-${slotKey}-message','{${v}}')">{${v}}</span>`; });
|
|
return html + '</div>';
|
|
}
|
|
|
|
if (blockType === 'discord_webhook') {
|
|
const url = _escAttr(config.webhook_url || '');
|
|
return `<div class="config-row">
|
|
<label>Webhook URL</label>
|
|
<input type="text" id="cfg-${slotKey}-webhook_url" value="${url}" placeholder="https://discord.com/api/webhooks/...">
|
|
</div>
|
|
<div class="config-row">
|
|
<label>Message</label>
|
|
<textarea id="cfg-${slotKey}-message" placeholder="Message with {variables}...">${config.message || '{name} completed with status: {status}'}</textarea>
|
|
</div>
|
|
${_notifyVarHtml(slotKey)}`;
|
|
}
|
|
if (blockType === 'pushbullet') {
|
|
const token = _escAttr(config.access_token || '');
|
|
return `<div class="config-row">
|
|
<label>Access Token</label>
|
|
<input type="text" id="cfg-${slotKey}-access_token" value="${token}" placeholder="o.xxxxxxxxxxxxxxxxxxxx">
|
|
</div>
|
|
<div class="config-row">
|
|
<label>Title</label>
|
|
<input type="text" id="cfg-${slotKey}-title" value="${_escAttr(config.title || '{name}')}" placeholder="Notification title">
|
|
</div>
|
|
<div class="config-row">
|
|
<label>Message</label>
|
|
<textarea id="cfg-${slotKey}-message" placeholder="Message with {variables}...">${config.message || 'Completed with status: {status}'}</textarea>
|
|
</div>
|
|
${_notifyVarHtml(slotKey)}`;
|
|
}
|
|
if (blockType === 'telegram') {
|
|
const botToken = _escAttr(config.bot_token || '');
|
|
const chatId = _escAttr(config.chat_id || '');
|
|
return `<div class="config-row">
|
|
<label>Bot Token</label>
|
|
<input type="text" id="cfg-${slotKey}-bot_token" value="${botToken}" placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11">
|
|
</div>
|
|
<div class="config-row">
|
|
<label>Chat ID</label>
|
|
<input type="text" id="cfg-${slotKey}-chat_id" value="${chatId}" placeholder="-1001234567890 or @channelname">
|
|
</div>
|
|
<div class="config-row">
|
|
<label>Message</label>
|
|
<textarea id="cfg-${slotKey}-message" placeholder="Message with {variables}...">${config.message || '{name} completed with status: {status}'}</textarea>
|
|
</div>
|
|
${_notifyVarHtml(slotKey)}`;
|
|
}
|
|
if (blockType === 'webhook') {
|
|
const url = _escAttr(config.url || '');
|
|
const hdrs = (config.headers || '').replace(/"/g, '"');
|
|
return `<div class="config-row">
|
|
<label>URL</label>
|
|
<input type="text" id="cfg-${slotKey}-url" value="${url}" placeholder="https://your-server.com/hook">
|
|
</div>
|
|
<div class="config-row">
|
|
<label>Headers <span style="opacity:0.4;font-weight:400">(one per line, Key: Value)</span></label>
|
|
<textarea id="cfg-${slotKey}-headers" placeholder="Authorization: Bearer token123\nX-Custom: value" style="font-family:monospace;font-size:11px;">${hdrs}</textarea>
|
|
</div>
|
|
<div class="config-row">
|
|
<label>Custom Message <span style="opacity:0.4;font-weight:400">(optional)</span></label>
|
|
<textarea id="cfg-${slotKey}-message" placeholder="Message with {variables}...">${config.message || ''}</textarea>
|
|
</div>
|
|
<div class="config-row" style="color:rgba(255,255,255,0.35);font-size:11px;">
|
|
Sends a JSON POST with all event variables. Custom message added as "message" field if set.
|
|
</div>
|
|
${_notifyVarHtml(slotKey)}`;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
// --- Condition Builder ---
|
|
|
|
function _renderConditionBuilder(slotKey, blockDef, config) {
|
|
const conditions = config.conditions || [];
|
|
const match = config.match || 'all';
|
|
const fields = blockDef.condition_fields || [];
|
|
|
|
let html = '<div class="condition-builder" id="conditions-' + slotKey + '">';
|
|
html += `<div class="config-row condition-header">
|
|
<label>Match</label>
|
|
<select id="cfg-${slotKey}-match" class="condition-match-select">
|
|
<option value="all"${match === 'all' ? ' selected' : ''}>All conditions</option>
|
|
<option value="any"${match === 'any' ? ' selected' : ''}>Any condition</option>
|
|
</select>
|
|
</div>`;
|
|
|
|
html += '<div id="condition-rows-' + slotKey + '">';
|
|
if (conditions.length) {
|
|
conditions.forEach((cond, i) => {
|
|
html += _renderConditionRow(slotKey, i, fields, cond);
|
|
});
|
|
}
|
|
html += '</div>';
|
|
|
|
html += `<button class="add-condition-btn" onclick="_autoAddCondition('${slotKey}')">+ Add Condition</button>`;
|
|
html += '</div>';
|
|
|
|
if (!conditions.length) {
|
|
html += '<div class="config-row" style="color:rgba(255,255,255,0.35);font-size:12px;margin-top:4px;">No conditions = triggers on every event</div>';
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
function _renderConditionRow(slotKey, index, fields, cond) {
|
|
const field = cond ? cond.field : (fields[0] || '');
|
|
const operator = cond ? cond.operator : 'equals';
|
|
const value = cond ? _escAttr(cond.value) : '';
|
|
|
|
let fieldOpts = '';
|
|
fields.forEach(f => { fieldOpts += `<option value="${f}"${f === field ? ' selected' : ''}>${f}</option>`; });
|
|
|
|
// For playlist-related triggers, use a mirrored playlist dropdown instead of free text
|
|
const triggerType = _autoBuilder.when ? _autoBuilder.when.type : '';
|
|
const usePlaylistSelect = ((triggerType === 'playlist_changed' || triggerType === 'discovery_completed') && field === 'playlist_name');
|
|
const valueHtml = usePlaylistSelect
|
|
? `<select class="cond-value mirrored-playlist-name-select" data-slot="${slotKey}" data-idx="${index}" data-value="${value}"></select>`
|
|
: `<input type="text" class="cond-value" data-slot="${slotKey}" data-idx="${index}" value="${value}" placeholder="value...">`;
|
|
|
|
return `<div class="condition-row" data-index="${index}">
|
|
<select class="cond-field" data-slot="${slotKey}" data-idx="${index}">${fieldOpts}</select>
|
|
<select class="cond-operator" data-slot="${slotKey}" data-idx="${index}">
|
|
<option value="equals"${operator === 'equals' ? ' selected' : ''}>equals</option>
|
|
<option value="contains"${operator === 'contains' ? ' selected' : ''}>contains</option>
|
|
<option value="starts_with"${operator === 'starts_with' ? ' selected' : ''}>starts with</option>
|
|
<option value="not_contains"${operator === 'not_contains' ? ' selected' : ''}>not contains</option>
|
|
</select>
|
|
${valueHtml}
|
|
<button class="remove-condition-btn" onclick="_autoRemoveCondition('${slotKey}',${index})">\u2715</button>
|
|
</div>`;
|
|
}
|
|
|
|
function _autoAddCondition(slotKey) {
|
|
const data = _autoBuilder[slotKey];
|
|
if (!data) return;
|
|
if (!data.config) data.config = {};
|
|
if (!data.config.conditions) data.config.conditions = [];
|
|
|
|
// Save existing conditions from DOM before re-render
|
|
_autoSaveConditionsFromDOM(slotKey);
|
|
|
|
const blockDef = _findBlockDef(data.type);
|
|
const fields = blockDef ? (blockDef.condition_fields || []) : [];
|
|
data.config.conditions.push({ field: fields[0] || '', operator: 'contains', value: '' });
|
|
_renderBuilderCanvas();
|
|
// Re-populate mirrored playlist selects if needed
|
|
_autoLoadMirroredSelects();
|
|
}
|
|
|
|
function _autoRemoveCondition(slotKey, index) {
|
|
const data = _autoBuilder[slotKey];
|
|
if (!data || !data.config || !data.config.conditions) return;
|
|
_autoSaveConditionsFromDOM(slotKey);
|
|
data.config.conditions.splice(index, 1);
|
|
_renderBuilderCanvas();
|
|
_autoLoadMirroredSelects();
|
|
}
|
|
|
|
function _autoSaveConditionsFromDOM(slotKey) {
|
|
const data = _autoBuilder[slotKey];
|
|
if (!data || !data.config) return;
|
|
const container = document.getElementById('condition-rows-' + slotKey);
|
|
if (!container) return;
|
|
const rows = container.querySelectorAll('.condition-row');
|
|
const conditions = [];
|
|
rows.forEach(row => {
|
|
const field = row.querySelector('.cond-field')?.value || '';
|
|
const operator = row.querySelector('.cond-operator')?.value || 'contains';
|
|
const value = row.querySelector('.cond-value')?.value || '';
|
|
conditions.push({ field, operator, value });
|
|
});
|
|
data.config.conditions = conditions;
|
|
// Also save match mode
|
|
const matchEl = document.getElementById('cfg-' + slotKey + '-match');
|
|
if (matchEl) data.config.match = matchEl.value;
|
|
}
|
|
|
|
// --- Mirrored Playlist Select ---
|
|
|
|
function _autoTogglePlaylistSelect(slotKey) {
|
|
const allCb = document.getElementById('cfg-' + slotKey + '-all');
|
|
const sel = document.getElementById('cfg-' + slotKey + '-playlist_id');
|
|
if (sel) sel.disabled = allCb && allCb.checked;
|
|
}
|
|
|
|
async function _autoLoadMirroredSelects() {
|
|
const selects = document.querySelectorAll('.mirrored-playlist-select');
|
|
const nameSelects = document.querySelectorAll('.mirrored-playlist-name-select');
|
|
if (!selects.length && !nameSelects.length) return;
|
|
|
|
if (!_autoMirroredPlaylists) {
|
|
try {
|
|
const res = await fetch('/api/mirrored-playlists/list');
|
|
const data = await res.json();
|
|
// New format returns { playlists, spotify_authenticated }
|
|
if (Array.isArray(data)) {
|
|
// Backward compat: old format was plain array
|
|
_autoMirroredPlaylists = data;
|
|
_autoSpotifyAuthenticated = false;
|
|
} else {
|
|
_autoMirroredPlaylists = data.playlists || [];
|
|
_autoSpotifyAuthenticated = data.spotify_authenticated || false;
|
|
}
|
|
} catch (e) { _autoMirroredPlaylists = []; _autoSpotifyAuthenticated = false; }
|
|
}
|
|
|
|
selects.forEach(sel => {
|
|
const savedValue = sel.dataset.value || '';
|
|
const isRefresh = sel.dataset.blockType === 'refresh_mirrored';
|
|
sel.innerHTML = '<option value="">-- Select playlist --</option>';
|
|
_autoMirroredPlaylists.forEach(p => {
|
|
// For refresh selects: hide file playlists, hide spotify (library) if not authed
|
|
if (isRefresh) {
|
|
if (p.source === 'file' || p.source === 'beatport') return;
|
|
if (p.source === 'spotify' && !_autoSpotifyAuthenticated) return;
|
|
}
|
|
sel.innerHTML += `<option value="${p.id}"${String(p.id) === savedValue ? ' selected' : ''}>${_esc(p.name)}</option>`;
|
|
});
|
|
});
|
|
|
|
nameSelects.forEach(sel => {
|
|
const savedValue = sel.dataset.value || '';
|
|
sel.innerHTML = '<option value="">-- Select playlist --</option>';
|
|
_autoMirroredPlaylists.forEach(p => {
|
|
sel.innerHTML += `<option value="${_escAttr(p.name)}"${p.name === savedValue ? ' selected' : ''}>${_esc(p.name)}</option>`;
|
|
});
|
|
});
|
|
}
|
|
|
|
function _readPlacedConfig(slotKey) {
|
|
let data;
|
|
if (slotKey.startsWith('then-')) {
|
|
const idx = parseInt(slotKey.split('-')[1]);
|
|
data = _autoBuilder.then[idx];
|
|
} else {
|
|
data = _autoBuilder[slotKey];
|
|
}
|
|
if (!data) return {};
|
|
const type = data.type;
|
|
if (type === 'schedule') {
|
|
return {
|
|
interval: parseInt(document.getElementById('cfg-' + slotKey + '-interval')?.value) || 6,
|
|
unit: document.getElementById('cfg-' + slotKey + '-unit')?.value || 'hours',
|
|
};
|
|
}
|
|
if (type === 'daily_time') {
|
|
return { time: document.getElementById('cfg-' + slotKey + '-time')?.value || '03:00' };
|
|
}
|
|
if (type === 'weekly_time') {
|
|
const daysEl = document.getElementById('cfg-' + slotKey + '-days');
|
|
const days = daysEl ? Array.from(daysEl.querySelectorAll('.day-btn.active')).map(b => b.dataset.day) : [];
|
|
return {
|
|
time: document.getElementById('cfg-' + slotKey + '-time')?.value || '03:00',
|
|
days,
|
|
};
|
|
}
|
|
// Event triggers with conditions
|
|
const blockDef = _findBlockDef(type);
|
|
if (blockDef && blockDef.has_conditions) {
|
|
_autoSaveConditionsFromDOM(slotKey);
|
|
return {
|
|
conditions: (data.config && data.config.conditions) || [],
|
|
match: document.getElementById('cfg-' + slotKey + '-match')?.value || 'all',
|
|
};
|
|
}
|
|
if (type === 'process_wishlist') {
|
|
return { category: document.getElementById('cfg-' + slotKey + '-category')?.value || 'all' };
|
|
}
|
|
if (type === 'refresh_mirrored') {
|
|
const allCb = document.getElementById('cfg-' + slotKey + '-all');
|
|
return {
|
|
playlist_id: document.getElementById('cfg-' + slotKey + '-playlist_id')?.value || '',
|
|
all: allCb ? allCb.checked : false,
|
|
};
|
|
}
|
|
if (type === 'sync_playlist') {
|
|
return { playlist_id: document.getElementById('cfg-' + slotKey + '-playlist_id')?.value || '' };
|
|
}
|
|
if (type === 'discover_playlist') {
|
|
const allCb = document.getElementById('cfg-' + slotKey + '-all');
|
|
return {
|
|
playlist_id: document.getElementById('cfg-' + slotKey + '-playlist_id')?.value || '',
|
|
all: allCb ? allCb.checked : false,
|
|
};
|
|
}
|
|
if (type === 'playlist_pipeline') {
|
|
const allCb = document.getElementById('cfg-' + slotKey + '-all');
|
|
const skipWl = document.getElementById('cfg-' + slotKey + '-skip_wishlist');
|
|
return {
|
|
playlist_id: document.getElementById('cfg-' + slotKey + '-playlist_id')?.value || '',
|
|
all: allCb ? allCb.checked : false,
|
|
skip_wishlist: skipWl ? skipWl.checked : false,
|
|
};
|
|
}
|
|
if (type === 'signal_received' || type === 'fire_signal') {
|
|
return { signal_name: document.getElementById('cfg-' + slotKey + '-signal_name')?.value?.trim() || '' };
|
|
}
|
|
if (type === 'run_script') {
|
|
return {
|
|
script_name: document.getElementById('cfg-' + slotKey + '-script_name')?.value || '',
|
|
timeout: parseInt(document.getElementById('cfg-' + slotKey + '-timeout')?.value || '60') || 60,
|
|
};
|
|
}
|
|
if (type === 'discord_webhook') {
|
|
return {
|
|
webhook_url: document.getElementById('cfg-' + slotKey + '-webhook_url')?.value?.trim() || '',
|
|
message: document.getElementById('cfg-' + slotKey + '-message')?.value || '',
|
|
};
|
|
}
|
|
if (type === 'pushbullet') {
|
|
return {
|
|
access_token: document.getElementById('cfg-' + slotKey + '-access_token')?.value?.trim() || '',
|
|
title: document.getElementById('cfg-' + slotKey + '-title')?.value || '',
|
|
message: document.getElementById('cfg-' + slotKey + '-message')?.value || '',
|
|
};
|
|
}
|
|
if (type === 'telegram') {
|
|
return {
|
|
bot_token: document.getElementById('cfg-' + slotKey + '-bot_token')?.value?.trim() || '',
|
|
chat_id: document.getElementById('cfg-' + slotKey + '-chat_id')?.value?.trim() || '',
|
|
message: document.getElementById('cfg-' + slotKey + '-message')?.value || '',
|
|
};
|
|
}
|
|
if (type === 'webhook') {
|
|
return {
|
|
url: document.getElementById('cfg-' + slotKey + '-url')?.value?.trim() || '',
|
|
headers: document.getElementById('cfg-' + slotKey + '-headers')?.value || '',
|
|
message: document.getElementById('cfg-' + slotKey + '-message')?.value || '',
|
|
};
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function _findBlockDef(type) {
|
|
if (!_autoBlocks) return null;
|
|
for (const cat of ['triggers', 'actions', 'notifications']) {
|
|
const found = (_autoBlocks[cat] || []).find(b => b.type === type);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// --- Drag & Drop ---
|
|
|
|
function _autoDragStart(e, blockType, slotCategory) {
|
|
e.dataTransfer.setData('text/plain', JSON.stringify({ type: blockType, slot: slotCategory }));
|
|
e.dataTransfer.effectAllowed = 'copy';
|
|
}
|
|
|
|
function _autoDragOver(e, slotKey) {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'copy';
|
|
const targetId = slotKey === 'then' ? 'slot-then-add' : 'slot-' + slotKey;
|
|
document.getElementById(targetId)?.classList.add('drag-over');
|
|
}
|
|
|
|
function _autoDragLeave(e, slotKey) {
|
|
const targetId = slotKey === 'then' ? 'slot-then-add' : 'slot-' + slotKey;
|
|
document.getElementById(targetId)?.classList.remove('drag-over');
|
|
}
|
|
|
|
function _autoDrop(e, slotKey) {
|
|
e.preventDefault();
|
|
const dropTargetId = slotKey === 'then' ? 'slot-then-add' : 'slot-' + slotKey;
|
|
document.getElementById(dropTargetId)?.classList.remove('drag-over');
|
|
if (_autoBuilder.isSystem && (slotKey === 'when' || slotKey === 'do')) return;
|
|
try {
|
|
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
|
|
// Handle THEN slot (append to array)
|
|
if (slotKey === 'then') {
|
|
if (data.slot !== 'then') { showToast('Wrong slot — drop ' + data.slot + ' blocks here', 'error'); return; }
|
|
if (_autoBuilder.then.length >= 3) { showToast('Maximum 3 then-actions', 'error'); return; }
|
|
_autoBuilder.then.push({ type: data.type, config: {} });
|
|
} else {
|
|
if (data.slot !== slotKey) { showToast('Wrong slot — drop ' + data.slot + ' blocks here', 'error'); return; }
|
|
_autoBuilder[slotKey] = { type: data.type, config: {} };
|
|
}
|
|
_renderBuilderCanvas();
|
|
} catch (err) { }
|
|
}
|
|
|
|
// Click-to-add (alternative to drag)
|
|
function _autoClickBlock(blockType, slotCategory) {
|
|
if (_autoBuilder.isSystem && (slotCategory === 'when' || slotCategory === 'do')) return;
|
|
if (slotCategory === 'then') {
|
|
if (_autoBuilder.then.length >= 3) { showToast('Maximum 3 then-actions', 'error'); return; }
|
|
_autoBuilder.then.push({ type: blockType, config: {} });
|
|
} else {
|
|
_autoBuilder[slotCategory] = { type: blockType, config: {} };
|
|
}
|
|
_renderBuilderCanvas();
|
|
}
|
|
|
|
function _autoRemoveBlock(slotKey) {
|
|
if (_autoBuilder.isSystem && (slotKey === 'when' || slotKey === 'do')) return;
|
|
// Handle then-N slots
|
|
if (slotKey.startsWith('then-')) {
|
|
const idx = parseInt(slotKey.split('-')[1]);
|
|
if (!isNaN(idx) && idx >= 0 && idx < _autoBuilder.then.length) {
|
|
_autoBuilder.then.splice(idx, 1);
|
|
}
|
|
} else {
|
|
_autoBuilder[slotKey] = null;
|
|
}
|
|
_renderBuilderCanvas();
|
|
}
|
|
|
|
// Variable insertion
|
|
function _autoInsertVar(textareaId, variable) {
|
|
const el = document.getElementById(textareaId);
|
|
if (!el) return;
|
|
const start = el.selectionStart, end = el.selectionEnd;
|
|
el.value = el.value.substring(0, start) + variable + el.value.substring(end);
|
|
el.selectionStart = el.selectionEnd = start + variable.length;
|
|
el.focus();
|
|
}
|
|
|
|
// ===== ISSUES PAGE =====
|
|
|
|
const ISSUE_CATEGORIES = {
|
|
wrong_track: { label: 'Wrong Track', icon: '❌', description: 'This file plays a completely different song than expected', applies: ['track'] },
|
|
wrong_metadata: { label: 'Wrong Metadata', icon: '✎', description: 'Title, artist, year, or other tags are incorrect', applies: ['track', 'album'] },
|
|
wrong_cover: { label: 'Wrong Cover Art', icon: '📷', description: 'The album artwork is wrong or missing', applies: ['album'] },
|
|
wrong_artist: { label: 'Wrong Artist', icon: '👤', description: 'This track is filed under the wrong artist', applies: ['track'] },
|
|
duplicate_tracks: { label: 'Duplicate Tracks', icon: '🔁', description: 'The same track appears more than once in this album', applies: ['album'] },
|
|
missing_tracks: { label: 'Missing Tracks', icon: '❓', description: 'Tracks that should be here are missing from this album', applies: ['album'] },
|
|
audio_quality: { label: 'Audio Quality', icon: '🎵', description: 'Audio has quality issues — clipping, low bitrate, silence, etc.', applies: ['track'] },
|
|
wrong_album: { label: 'Wrong Album', icon: '💿', description: 'This track belongs to a different album', applies: ['track'] },
|
|
incomplete_album: { label: 'Incomplete Album', icon: '⚠', description: 'Album is partially downloaded — some tracks present, others not', applies: ['album'] },
|
|
other: { label: 'Other', icon: '💬', description: 'Any other issue not listed above', applies: ['track', 'album'] },
|
|
};
|
|
|
|
const ISSUE_STATUS_META = {
|
|
open: { label: 'Open', cls: 'issue-status-open' },
|
|
in_progress: { label: 'In Progress', cls: 'issue-status-progress' },
|
|
resolved: { label: 'Resolved', cls: 'issue-status-resolved' },
|
|
dismissed: { label: 'Dismissed', cls: 'issue-status-dismissed' },
|
|
};
|
|
|
|
let _issuesPageState = { loaded: false };
|
|
|
|
function _issueHeaders(extra) {
|
|
const h = { 'X-Profile-Id': String(currentProfile ? currentProfile.id : 1) };
|
|
if (extra) Object.assign(h, extra);
|
|
return h;
|
|
}
|
|
|
|
async function loadIssuesPage() {
|
|
const admin = isEnhancedAdmin();
|
|
const subtitle = document.getElementById('issues-subtitle');
|
|
if (subtitle) {
|
|
subtitle.textContent = admin ? 'Manage and resolve reported library problems' : 'Track and resolve library problems';
|
|
}
|
|
await Promise.all([loadIssuesList(), loadIssuesCounts()]);
|
|
}
|
|
|
|
async function loadIssuesCounts() {
|
|
try {
|
|
const resp = await fetch('/api/issues/counts', { headers: _issueHeaders() });
|
|
const data = await resp.json();
|
|
if (!data.success) return;
|
|
const counts = data.counts;
|
|
const statsEl = document.getElementById('issues-stats');
|
|
if (!statsEl) return;
|
|
const total = (counts.open || 0) + (counts.in_progress || 0) + (counts.resolved || 0) + (counts.dismissed || 0);
|
|
statsEl.innerHTML = `
|
|
<div class="issues-stat-card issues-stat-open">
|
|
<div class="issues-stat-number">${counts.open || 0}</div>
|
|
<div class="issues-stat-label">Open</div>
|
|
</div>
|
|
<div class="issues-stat-card issues-stat-progress">
|
|
<div class="issues-stat-number">${counts.in_progress || 0}</div>
|
|
<div class="issues-stat-label">In Progress</div>
|
|
</div>
|
|
<div class="issues-stat-card issues-stat-resolved">
|
|
<div class="issues-stat-number">${counts.resolved || 0}</div>
|
|
<div class="issues-stat-label">Resolved</div>
|
|
</div>
|
|
<div class="issues-stat-card issues-stat-dismissed">
|
|
<div class="issues-stat-number">${counts.dismissed || 0}</div>
|
|
<div class="issues-stat-label">Dismissed</div>
|
|
</div>
|
|
<div class="issues-stat-card issues-stat-total">
|
|
<div class="issues-stat-number">${total}</div>
|
|
<div class="issues-stat-label">Total</div>
|
|
</div>
|
|
`;
|
|
// Update nav badge
|
|
const badge = document.getElementById('issues-nav-badge');
|
|
if (badge) {
|
|
const openCount = counts.open || 0;
|
|
badge.textContent = openCount;
|
|
badge.classList.toggle('hidden', openCount === 0);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load issue counts:', e);
|
|
}
|
|
}
|
|
|
|
async function loadIssuesList() {
|
|
const listEl = document.getElementById('issues-list');
|
|
if (!listEl) return;
|
|
listEl.innerHTML = '<div class="issues-loading"><div class="issues-spinner"></div>Loading issues...</div>';
|
|
|
|
const statusFilter = document.getElementById('issues-filter-status')?.value || '';
|
|
const categoryFilter = document.getElementById('issues-filter-category')?.value || '';
|
|
|
|
let url = '/api/issues?';
|
|
if (statusFilter) url += `status=${encodeURIComponent(statusFilter)}&`;
|
|
if (categoryFilter) url += `category=${encodeURIComponent(categoryFilter)}&`;
|
|
|
|
try {
|
|
const profileId = currentProfile ? currentProfile.id : 1;
|
|
const resp = await fetch(url, { headers: { 'X-Profile-Id': String(profileId) } });
|
|
const data = await resp.json();
|
|
if (!data.success || !data.issues || data.issues.length === 0) {
|
|
listEl.innerHTML = `
|
|
<div class="issues-empty">
|
|
<div class="issues-empty-icon">🔍</div>
|
|
<div class="issues-empty-title">No issues found</div>
|
|
<div class="issues-empty-text">${statusFilter || categoryFilter ? 'Try adjusting your filters' : 'No issues have been reported yet'}</div>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
listEl.innerHTML = '';
|
|
data.issues.forEach(issue => {
|
|
listEl.appendChild(renderIssueCard(issue));
|
|
});
|
|
} catch (e) {
|
|
console.error('Failed to load issues:', e);
|
|
listEl.innerHTML = '<div class="issues-empty"><div class="issues-empty-title">Failed to load issues</div></div>';
|
|
}
|
|
}
|
|
|
|
function renderIssueCard(issue) {
|
|
const card = document.createElement('div');
|
|
card.className = 'issue-card';
|
|
card.dataset.issueId = issue.id;
|
|
card.onclick = () => showIssueDetailModal(issue.id);
|
|
|
|
const catMeta = ISSUE_CATEGORIES[issue.category] || ISSUE_CATEGORIES.other;
|
|
const statusMeta = ISSUE_STATUS_META[issue.status] || ISSUE_STATUS_META.open;
|
|
const admin = isEnhancedAdmin();
|
|
|
|
let snapshot = {};
|
|
try { snapshot = typeof issue.snapshot_data === 'string' ? JSON.parse(issue.snapshot_data || '{}') : (issue.snapshot_data || {}); } catch (e) { }
|
|
|
|
const entityLabel = issue.entity_type === 'track' ? 'Track' : (issue.entity_type === 'album' ? 'Album' : 'Artist');
|
|
const entityName = snapshot.title || snapshot.name || `${entityLabel} #${issue.entity_id}`;
|
|
const artistName = snapshot.artist_name || '';
|
|
const albumName = snapshot.album_title || '';
|
|
const thumbUrl = snapshot.thumb_url || snapshot.album_thumb || '';
|
|
|
|
const createdDate = issue.created_at ? new Date(issue.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '';
|
|
const createdTime = issue.created_at ? new Date(issue.created_at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) : '';
|
|
|
|
// Priority indicator
|
|
const priorityCls = issue.priority === 'high' ? 'issue-priority-high' : (issue.priority === 'low' ? 'issue-priority-low' : 'issue-priority-normal');
|
|
|
|
let thumbHtml = '';
|
|
if (thumbUrl) {
|
|
thumbHtml = `<img class="issue-card-thumb" src="${_escAttr(thumbUrl)}" alt="" onerror="this.style.display='none'">`;
|
|
} else {
|
|
thumbHtml = `<div class="issue-card-thumb-placeholder">${catMeta.icon}</div>`;
|
|
}
|
|
|
|
let metaLine = '';
|
|
if (issue.entity_type === 'track') {
|
|
metaLine = [artistName, albumName].filter(Boolean).map(s => _esc(s)).join(' — ');
|
|
} else if (issue.entity_type === 'album') {
|
|
metaLine = artistName ? _esc(artistName) : '';
|
|
}
|
|
|
|
let profileBadge = '';
|
|
if (admin && issue.reporter_name) {
|
|
profileBadge = `<span class="issue-card-profile">by ${_esc(issue.reporter_name)}</span>`;
|
|
}
|
|
|
|
let adminResponseIndicator = '';
|
|
if (issue.admin_response) {
|
|
adminResponseIndicator = '<span class="issue-card-responded" title="Admin has responded">💬</span>';
|
|
}
|
|
|
|
card.innerHTML = `
|
|
<div class="issue-card-left">
|
|
${thumbHtml}
|
|
</div>
|
|
<div class="issue-card-center">
|
|
<div class="issue-card-title-row">
|
|
<span class="issue-card-category-icon" title="${_escAttr(catMeta.label)}">${catMeta.icon}</span>
|
|
<span class="issue-card-title">${_esc(issue.title)}</span>
|
|
${adminResponseIndicator}
|
|
</div>
|
|
<div class="issue-card-entity">
|
|
<span class="issue-card-entity-type">${_esc(entityLabel)}</span>
|
|
<span class="issue-card-entity-name">${_esc(entityName)}</span>
|
|
${metaLine ? `<span class="issue-card-meta-line">${metaLine}</span>` : ''}
|
|
</div>
|
|
${issue.description ? `<div class="issue-card-description">${_esc(issue.description)}</div>` : ''}
|
|
<div class="issue-card-footer">
|
|
<span class="issue-card-date">${_esc(createdDate)} ${_esc(createdTime)}</span>
|
|
${profileBadge}
|
|
</div>
|
|
</div>
|
|
<div class="issue-card-right">
|
|
<span class="issue-status-badge ${statusMeta.cls}">${_esc(statusMeta.label)}</span>
|
|
<span class="issue-priority-dot ${priorityCls}" title="${_esc(issue.priority)} priority"></span>
|
|
</div>
|
|
`;
|
|
return card;
|
|
}
|
|
|
|
// --- Report Issue Modal ---
|
|
|
|
let _reportIssueState = {};
|
|
|
|
function showReportIssueModal(entityType, entityId, entityName, artistName, albumTitle) {
|
|
_reportIssueState = { entityType, entityId, entityName, artistName, albumTitle: albumTitle || '' };
|
|
const overlay = document.getElementById('report-issue-overlay');
|
|
const titleEl = document.getElementById('report-issue-title');
|
|
const body = document.getElementById('report-issue-body');
|
|
if (!overlay || !body) return;
|
|
|
|
const entityLabel = entityType === 'track' ? 'Track' : (entityType === 'album' ? 'Album' : 'Artist');
|
|
titleEl.textContent = `Report Issue — ${entityLabel}`;
|
|
|
|
body.innerHTML = `
|
|
<div class="report-issue-entity-info">
|
|
<div class="report-issue-entity-name">${_esc(entityName)}</div>
|
|
${artistName ? `<div class="report-issue-entity-artist">${_esc(artistName)}${albumTitle ? ' — ' + _esc(albumTitle) : ''}</div>` : ''}
|
|
</div>
|
|
<div class="report-issue-section">
|
|
<label class="report-issue-label">What's the problem?</label>
|
|
<div class="report-issue-category-grid" id="report-issue-categories">
|
|
${Object.entries(ISSUE_CATEGORIES)
|
|
.filter(([, cat]) => !cat.applies || cat.applies.includes(entityType))
|
|
.map(([key, cat]) => `
|
|
<div class="report-issue-category-card" data-category="${_escAttr(key)}" onclick="selectIssueCategory(this, '${_escAttr(key)}')">
|
|
<div class="report-issue-category-icon">${cat.icon}</div>
|
|
<div class="report-issue-category-label">${_esc(cat.label)}</div>
|
|
<div class="report-issue-category-desc">${_esc(cat.description)}</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
<div class="report-issue-section" id="report-issue-details-section" style="display:none">
|
|
<label class="report-issue-label">Title</label>
|
|
<input type="text" class="report-issue-input" id="report-issue-input-title" placeholder="Brief summary of the issue..." maxlength="200" oninput="event.target._userEdited = true">
|
|
<label class="report-issue-label" style="margin-top:12px">Details <span class="report-issue-optional">(optional)</span></label>
|
|
<textarea class="report-issue-textarea" id="report-issue-input-desc" placeholder="Provide more details about what's wrong — what you expected vs what you see..." rows="4" maxlength="2000"></textarea>
|
|
<label class="report-issue-label" style="margin-top:12px">Priority</label>
|
|
<div class="report-issue-priority-row">
|
|
<button class="report-issue-priority-btn" data-priority="low" onclick="selectIssuePriority(this, 'low')">Low</button>
|
|
<button class="report-issue-priority-btn selected" data-priority="normal" onclick="selectIssuePriority(this, 'normal')">Normal</button>
|
|
<button class="report-issue-priority-btn" data-priority="high" onclick="selectIssuePriority(this, 'high')">High</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
_reportIssueState.selectedCategory = null;
|
|
_reportIssueState.selectedPriority = 'normal';
|
|
const submitBtn = document.getElementById('report-issue-submit-btn');
|
|
if (submitBtn) submitBtn.disabled = true;
|
|
|
|
overlay.classList.remove('hidden');
|
|
}
|
|
|
|
function selectIssueCategory(el, category) {
|
|
document.querySelectorAll('.report-issue-category-card').forEach(c => c.classList.remove('selected'));
|
|
el.classList.add('selected');
|
|
_reportIssueState.selectedCategory = category;
|
|
|
|
const detailsSection = document.getElementById('report-issue-details-section');
|
|
if (detailsSection) detailsSection.style.display = '';
|
|
|
|
// Auto-generate title based on category
|
|
const titleInput = document.getElementById('report-issue-input-title');
|
|
const catMeta = ISSUE_CATEGORIES[category];
|
|
if (titleInput && !titleInput._userEdited) {
|
|
const entityName = _reportIssueState.entityName || '';
|
|
titleInput.value = `${catMeta.label}: ${entityName}`;
|
|
}
|
|
|
|
const submitBtn = document.getElementById('report-issue-submit-btn');
|
|
if (submitBtn) submitBtn.disabled = false;
|
|
}
|
|
|
|
function selectIssuePriority(el, priority) {
|
|
document.querySelectorAll('.report-issue-priority-btn').forEach(b => b.classList.remove('selected'));
|
|
el.classList.add('selected');
|
|
_reportIssueState.selectedPriority = priority;
|
|
}
|
|
|
|
function closeReportIssueModal() {
|
|
const overlay = document.getElementById('report-issue-overlay');
|
|
if (overlay) overlay.classList.add('hidden');
|
|
_reportIssueState = {};
|
|
}
|
|
|
|
async function submitIssue() {
|
|
if (_reportIssueState._submitting) return;
|
|
const category = _reportIssueState.selectedCategory;
|
|
if (!category) {
|
|
showToast('Please select an issue category', 'error');
|
|
return;
|
|
}
|
|
|
|
const titleInput = document.getElementById('report-issue-input-title');
|
|
const descInput = document.getElementById('report-issue-input-desc');
|
|
const title = (titleInput?.value || '').trim();
|
|
const description = (descInput?.value || '').trim();
|
|
|
|
if (!title) {
|
|
showToast('Please provide a title for the issue', 'error');
|
|
return;
|
|
}
|
|
|
|
_reportIssueState._submitting = true;
|
|
const submitBtn = document.getElementById('report-issue-submit-btn');
|
|
if (submitBtn) {
|
|
submitBtn.disabled = true;
|
|
submitBtn.textContent = 'Submitting...';
|
|
}
|
|
|
|
try {
|
|
const resp = await fetch('/api/issues', {
|
|
method: 'POST',
|
|
headers: _issueHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({
|
|
profile_id: currentProfile ? currentProfile.id : 1,
|
|
entity_type: _reportIssueState.entityType,
|
|
entity_id: String(_reportIssueState.entityId),
|
|
category: category,
|
|
title: title,
|
|
description: description,
|
|
priority: _reportIssueState.selectedPriority || 'normal',
|
|
}),
|
|
});
|
|
const data = await resp.json();
|
|
if (data.success) {
|
|
showToast('Issue reported successfully', 'success');
|
|
closeReportIssueModal();
|
|
// Refresh issues page if visible
|
|
const issuesPage = document.getElementById('issues-page');
|
|
if (issuesPage && issuesPage.classList.contains('active')) {
|
|
loadIssuesPage();
|
|
}
|
|
// Update badge
|
|
loadIssuesBadge();
|
|
} else {
|
|
showToast(data.error || 'Failed to submit issue', 'error');
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to submit issue:', e);
|
|
showToast('Failed to submit issue', 'error');
|
|
} finally {
|
|
_reportIssueState._submitting = false;
|
|
if (submitBtn) {
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = 'Submit Issue';
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Issue Detail Modal ---
|
|
|
|
async function showIssueDetailModal(issueId) {
|
|
const overlay = document.getElementById('issue-detail-overlay');
|
|
const body = document.getElementById('issue-detail-body');
|
|
const footer = document.getElementById('issue-detail-footer');
|
|
const titleEl = document.getElementById('issue-detail-title');
|
|
if (!overlay || !body) return;
|
|
|
|
body.innerHTML = '<div class="issues-loading"><div class="issues-spinner"></div>Loading...</div>';
|
|
footer.innerHTML = '<button class="enhanced-bulk-btn secondary" onclick="closeIssueDetailModal()">Close</button>';
|
|
overlay.classList.remove('hidden');
|
|
|
|
try {
|
|
const resp = await fetch(`/api/issues/${issueId}`, { headers: _issueHeaders() });
|
|
const data = await resp.json();
|
|
if (!data.success || !data.issue) {
|
|
body.innerHTML = '<div class="issues-empty"><div class="issues-empty-title">Issue not found</div></div>';
|
|
return;
|
|
}
|
|
renderIssueDetail(data.issue, body, footer, titleEl);
|
|
} catch (e) {
|
|
console.error('Failed to load issue:', e);
|
|
body.innerHTML = '<div class="issues-empty"><div class="issues-empty-title">Failed to load issue</div></div>';
|
|
}
|
|
}
|
|
|
|
function renderIssueDetail(issue, body, footer, titleEl) {
|
|
const admin = isEnhancedAdmin();
|
|
const catMeta = ISSUE_CATEGORIES[issue.category] || ISSUE_CATEGORIES.other;
|
|
const statusMeta = ISSUE_STATUS_META[issue.status] || ISSUE_STATUS_META.open;
|
|
|
|
let snapshot = {};
|
|
try { snapshot = typeof issue.snapshot_data === 'string' ? JSON.parse(issue.snapshot_data || '{}') : (issue.snapshot_data || {}); } catch (e) { }
|
|
|
|
const entityLabel = issue.entity_type === 'track' ? 'Track' : (issue.entity_type === 'album' ? 'Album' : 'Artist');
|
|
const entityName = snapshot.title || snapshot.name || `${entityLabel} #${issue.entity_id}`;
|
|
const artistName = snapshot.artist_name || (issue.entity_type === 'artist' ? snapshot.name : '') || '';
|
|
const albumTitle = issue.entity_type === 'album' ? (snapshot.title || '') : (snapshot.album_title || '');
|
|
const artistId = issue.entity_type === 'artist' ? snapshot.id : snapshot.artist_id;
|
|
|
|
// Resolve image URLs — album art and artist photo
|
|
let artistThumb = '';
|
|
let albumThumb = '';
|
|
if (issue.entity_type === 'album') {
|
|
albumThumb = snapshot.thumb_url || '';
|
|
artistThumb = snapshot.artist_thumb || '';
|
|
} else if (issue.entity_type === 'track') {
|
|
albumThumb = snapshot.album_thumb || '';
|
|
artistThumb = snapshot.artist_thumb || '';
|
|
} else {
|
|
// Artist issue
|
|
artistThumb = snapshot.thumb_url || '';
|
|
}
|
|
|
|
// Determine the album-level Spotify ID for download/wishlist actions
|
|
const spotifyAlbumId = snapshot.spotify_album_id || '';
|
|
|
|
console.log('Issue detail snapshot:', { entityType: issue.entity_type, albumThumb, artistThumb, spotifyAlbumId, snapshotKeys: Object.keys(snapshot) });
|
|
|
|
const createdDate = issue.created_at ? new Date(issue.created_at).toLocaleString() : 'Unknown';
|
|
const resolvedDate = issue.resolved_at ? new Date(issue.resolved_at).toLocaleString() : '';
|
|
|
|
titleEl.textContent = `Issue #${issue.id}`;
|
|
|
|
// --- Build external links chips ---
|
|
function _extLinks(snap) {
|
|
const links = [];
|
|
if (snap.spotify_artist_id) links.push({ svc: 'Spotify', type: 'Artist', url: `https://open.spotify.com/artist/${snap.spotify_artist_id}`, cls: 'ext-spotify' });
|
|
if (snap.spotify_album_id) links.push({ svc: 'Spotify', type: 'Album', url: `https://open.spotify.com/album/${snap.spotify_album_id}`, cls: 'ext-spotify' });
|
|
if (snap.spotify_track_id) links.push({ svc: 'Spotify', type: 'Track', url: `https://open.spotify.com/track/${snap.spotify_track_id}`, cls: 'ext-spotify' });
|
|
if (snap.artist_musicbrainz_id) links.push({ svc: 'MusicBrainz', type: 'Artist', url: `https://musicbrainz.org/artist/${snap.artist_musicbrainz_id}`, cls: 'ext-mb' });
|
|
if (snap.musicbrainz_release_id) links.push({ svc: 'MusicBrainz', type: 'Release', url: `https://musicbrainz.org/release/${snap.musicbrainz_release_id}`, cls: 'ext-mb' });
|
|
if (snap.musicbrainz_recording_id) links.push({ svc: 'MusicBrainz', type: 'Recording', url: `https://musicbrainz.org/recording/${snap.musicbrainz_recording_id}`, cls: 'ext-mb' });
|
|
if (snap.artist_deezer_id) links.push({ svc: 'Deezer', type: 'Artist', url: `https://www.deezer.com/artist/${snap.artist_deezer_id}`, cls: 'ext-deezer' });
|
|
if (snap.album_deezer_id) links.push({ svc: 'Deezer', type: 'Album', url: `https://www.deezer.com/album/${snap.album_deezer_id}`, cls: 'ext-deezer' });
|
|
if (snap.track_deezer_id) links.push({ svc: 'Deezer', type: 'Track', url: `https://www.deezer.com/track/${snap.track_deezer_id}`, cls: 'ext-deezer' });
|
|
if (snap.artist_tidal_id) links.push({ svc: 'Tidal', type: 'Artist', url: `https://listen.tidal.com/artist/${snap.artist_tidal_id}`, cls: 'ext-tidal' });
|
|
if (snap.album_tidal_id) links.push({ svc: 'Tidal', type: 'Album', url: `https://listen.tidal.com/album/${snap.album_tidal_id}`, cls: 'ext-tidal' });
|
|
if (snap.artist_qobuz_id) links.push({ svc: 'Qobuz', type: 'Artist', cls: 'ext-qobuz', id: snap.artist_qobuz_id });
|
|
if (snap.album_qobuz_id) links.push({ svc: 'Qobuz', type: 'Album', cls: 'ext-qobuz', id: snap.album_qobuz_id });
|
|
return links;
|
|
}
|
|
|
|
const extLinks = _extLinks(snapshot);
|
|
let extLinksHtml = '';
|
|
if (extLinks.length > 0) {
|
|
const chips = extLinks.map(l => {
|
|
if (l.url) {
|
|
return `<a class="issue-ext-chip ${l.cls}" href="${_escAttr(l.url)}" target="_blank" rel="noopener" title="${_escAttr(l.svc + ' ' + l.type)}">${_esc(l.svc)} <span class="issue-ext-chip-type">${_esc(l.type)}</span></a>`;
|
|
}
|
|
return `<span class="issue-ext-chip ${l.cls}" title="${_escAttr(l.svc + ' ' + l.type + ': ' + l.id)}">${_esc(l.svc)} <span class="issue-ext-chip-type">${_esc(l.type)}</span></span>`;
|
|
}).join('');
|
|
extLinksHtml = `<div class="issue-ext-chips" style="margin-top:8px">${chips}</div>`;
|
|
}
|
|
|
|
// --- Build enhanced-library-style album/track widget ---
|
|
// Determine which album data to show (for album issues it's the entity, for track issues it's the parent)
|
|
const showAlbumWidget = (issue.entity_type === 'album' || issue.entity_type === 'track');
|
|
const albumName = issue.entity_type === 'album' ? (snapshot.title || '') : (snapshot.album_title || '');
|
|
const albumYear = snapshot.year || '';
|
|
const albumLabel = snapshot.label || '';
|
|
const albumType = snapshot.record_type || '';
|
|
const albumTrackCount = issue.entity_type === 'album' ? (snapshot.track_count || '') : (snapshot.album_track_count || '');
|
|
const albumGenres = snapshot.genres || [];
|
|
|
|
// --- Build the hero section (artist photo + album art + info) ---
|
|
let heroHtml = '';
|
|
if (showAlbumWidget) {
|
|
// Genre tags
|
|
let genreTagsHtml = '';
|
|
if (Array.isArray(albumGenres) && albumGenres.length > 0) {
|
|
genreTagsHtml = `<div class="issue-hero-genres">${albumGenres.slice(0, 5).map(g => `<span class="issue-hero-genre-tag">${_esc(g)}</span>`).join('')}</div>`;
|
|
}
|
|
|
|
// Album meta line
|
|
const albumMetaParts = [];
|
|
if (albumYear) albumMetaParts.push(String(albumYear));
|
|
if (albumType) albumMetaParts.push(albumType.charAt(0).toUpperCase() + albumType.slice(1));
|
|
if (albumTrackCount) albumMetaParts.push(albumTrackCount + ' tracks');
|
|
if (albumLabel) albumMetaParts.push(albumLabel);
|
|
|
|
// For track issues, show the track title under the album
|
|
const trackNameLine = issue.entity_type === 'track' && entityName
|
|
? `<div class="issue-hero-track-name">♫ ${_esc(entityName)}</div>` : '';
|
|
|
|
heroHtml = `
|
|
<div class="issue-hero">
|
|
<div class="issue-hero-art-group">
|
|
${artistThumb ? `<img class="issue-hero-artist-thumb" src="${_escAttr(artistThumb)}" alt="" onerror="this.style.display='none'">` : ''}
|
|
${albumThumb ? `<img class="issue-hero-album-art" src="${_escAttr(albumThumb)}" alt="" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">` : ''}
|
|
<div class="issue-hero-album-placeholder" style="${albumThumb ? 'display:none' : ''}">${catMeta.icon}</div>
|
|
</div>
|
|
<div class="issue-hero-info">
|
|
${artistName ? `<div class="issue-hero-artist">${_esc(artistName)}</div>` : ''}
|
|
<div class="issue-hero-album">${_esc(albumName)}</div>
|
|
${trackNameLine}
|
|
${albumMetaParts.length > 0 ? `<div class="issue-hero-meta">${_esc(albumMetaParts.join(' \u00B7 '))}</div>` : ''}
|
|
${genreTagsHtml}
|
|
${extLinksHtml}
|
|
</div>
|
|
</div>
|
|
`;
|
|
} else {
|
|
// Artist-level issue — simpler hero
|
|
heroHtml = `
|
|
<div class="issue-hero">
|
|
<div class="issue-hero-art-group">
|
|
${artistThumb ? `<img class="issue-hero-album-art" src="${_escAttr(artistThumb)}" alt="" onerror="this.style.display='none'">` : `<div class="issue-hero-album-placeholder">${catMeta.icon}</div>`}
|
|
</div>
|
|
<div class="issue-hero-info">
|
|
<div class="issue-hero-album">${_esc(entityName)}</div>
|
|
${extLinksHtml}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// --- Issue info bar ---
|
|
let issueInfoHtml = `
|
|
<div class="issue-detail-info-bar">
|
|
<div class="issue-detail-info-left">
|
|
<span class="issue-status-badge ${statusMeta.cls}">${_esc(statusMeta.label)}</span>
|
|
<span class="issue-priority-dot issue-priority-${_escAttr(issue.priority || 'normal')}" title="${_esc((issue.priority || 'normal'))} priority"></span>
|
|
<span class="issue-detail-category">${catMeta.icon} ${_esc(catMeta.label)}</span>
|
|
</div>
|
|
<div class="issue-detail-info-right">
|
|
<span class="issue-detail-date">Reported ${_esc(createdDate)}</span>
|
|
${issue.reporter_name && admin ? `<span class="issue-detail-profile">by ${_esc(issue.reporter_name)}</span>` : ''}
|
|
${resolvedDate ? `<span class="issue-detail-date">Resolved ${_esc(resolvedDate)}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// --- Issue description ---
|
|
let descriptionHtml = `
|
|
<div class="issue-detail-section">
|
|
<div class="issue-detail-section-title">Issue</div>
|
|
<div class="issue-detail-title-text">${_esc(issue.title)}</div>
|
|
${issue.description ? `<div class="issue-detail-description">${_esc(issue.description)}</div>` : '<div class="issue-detail-no-desc">No additional details provided</div>'}
|
|
</div>
|
|
`;
|
|
|
|
// --- Action buttons (Download Album / Add to Wishlist) for admin ---
|
|
let actionButtonsHtml = '';
|
|
if (admin && (issue.entity_type === 'album' || issue.entity_type === 'track')) {
|
|
actionButtonsHtml = `
|
|
<div class="issue-action-buttons">
|
|
<button class="issue-action-btn issue-action-download" id="issue-action-download" title="Download this album">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
Download Album
|
|
</button>
|
|
<button class="issue-action-btn issue-action-wishlist" id="issue-action-wishlist" title="Add all tracks to wishlist">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/></svg>
|
|
Add to Wishlist
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// --- Metadata grid for track-level issues ---
|
|
let metaGridHtml = '';
|
|
if (issue.entity_type === 'track') {
|
|
const metaItems = [];
|
|
if (snapshot.track_number) metaItems.push({ icon: '#', label: 'Track', value: String(snapshot.track_number) });
|
|
if (snapshot.duration) metaItems.push({ icon: '◷', label: 'Duration', value: typeof snapshot.duration === 'number' ? formatDurationMs(snapshot.duration) : String(snapshot.duration) });
|
|
if (snapshot.format) metaItems.push({ icon: '💾', label: 'Format', value: snapshot.format });
|
|
if (snapshot.bitrate) metaItems.push({ icon: '🎶', label: 'Bitrate', value: snapshot.bitrate + ' kbps' });
|
|
if (snapshot.bpm) metaItems.push({ icon: '♫', label: 'BPM', value: String(snapshot.bpm) });
|
|
if (snapshot.quality) metaItems.push({ icon: '★', label: 'Quality', value: snapshot.quality });
|
|
if (metaItems.length > 0) {
|
|
metaGridHtml = `
|
|
<div class="issue-detail-section">
|
|
<div class="issue-detail-section-title">Track Details</div>
|
|
<div class="issue-detail-meta-grid">
|
|
${metaItems.map(m => `
|
|
<div class="issue-meta-item">
|
|
<span class="issue-meta-icon">${m.icon}</span>
|
|
<span class="issue-meta-label">${_esc(m.label)}</span>
|
|
<span class="issue-meta-value">${_esc(m.value)}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
// --- File path display for tracks ---
|
|
let filePathHtml = '';
|
|
if (snapshot.file_path) {
|
|
filePathHtml = `
|
|
<div class="issue-detail-section">
|
|
<div class="issue-detail-section-title">File Path</div>
|
|
<div class="issue-detail-filepath">${_esc(snapshot.file_path)}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// --- Enhanced-library-style track listing ---
|
|
let trackListHtml = '';
|
|
if (snapshot.tracks && Array.isArray(snapshot.tracks) && snapshot.tracks.length > 0) {
|
|
let lastDisc = null;
|
|
let rows = '';
|
|
const hasMultiDisc = snapshot.tracks.some(tr => (tr.disc_number || 1) > 1);
|
|
snapshot.tracks.forEach(t => {
|
|
const disc = t.disc_number || 1;
|
|
if (hasMultiDisc && disc !== lastDisc) {
|
|
rows += `<div class="issue-detail-tracklist-disc">Disc ${disc}</div>`;
|
|
lastDisc = disc;
|
|
}
|
|
const fmt = t.format || (t.file_path ? t.file_path.split('.').pop().toUpperCase() : '');
|
|
const fmtLower = fmt.toLowerCase();
|
|
const fmtClass = fmtLower === 'flac' ? 'flac' : (fmtLower === 'mp3' ? 'mp3' : 'other');
|
|
const br = t.bitrate ? parseInt(t.bitrate) : 0;
|
|
const brClass = br >= 320 || fmtLower === 'flac' ? 'high' : (br >= 192 ? 'medium' : 'low');
|
|
const durStr = t.duration && typeof t.duration === 'number' ? formatDurationMs(t.duration) : '';
|
|
|
|
rows += `
|
|
<div class="issue-detail-tracklist-row">
|
|
<span class="issue-detail-tracklist-num">${_esc(String(t.track_number || '-'))}</span>
|
|
<span class="issue-detail-tracklist-title">${_esc(t.title || 'Unknown')}</span>
|
|
${durStr ? `<span class="issue-detail-tracklist-dur">${durStr}</span>` : ''}
|
|
<span class="issue-detail-tracklist-meta">
|
|
${fmt ? `<span class="enhanced-format-badge ${fmtClass}">${_esc(fmt)}</span>` : ''}
|
|
${br ? `<span class="enhanced-bitrate ${brClass}">${br}k</span>` : ''}
|
|
</span>
|
|
</div>
|
|
`;
|
|
});
|
|
trackListHtml = `
|
|
<div class="issue-detail-section">
|
|
<div class="issue-detail-section-title">Track Listing <span class="issue-detail-section-count">${snapshot.tracks.length} tracks</span></div>
|
|
<div class="issue-detail-tracklist">${rows}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// --- Admin response section ---
|
|
let adminResponseHtml = '';
|
|
if (admin) {
|
|
adminResponseHtml = `
|
|
<div class="issue-detail-section">
|
|
<div class="issue-detail-section-title">Admin Response</div>
|
|
<textarea class="issue-detail-response-textarea" id="issue-detail-response-input" placeholder="Write a response to the reporter..." rows="3">${_esc(issue.admin_response || '')}</textarea>
|
|
</div>
|
|
`;
|
|
} else if (issue.admin_response) {
|
|
adminResponseHtml = `
|
|
<div class="issue-detail-section">
|
|
<div class="issue-detail-section-title">Admin Response</div>
|
|
<div class="issue-detail-admin-response">${_esc(issue.admin_response)}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
body.innerHTML = `
|
|
${heroHtml}
|
|
${issueInfoHtml}
|
|
${actionButtonsHtml}
|
|
${descriptionHtml}
|
|
${metaGridHtml}
|
|
${filePathHtml}
|
|
${trackListHtml}
|
|
${adminResponseHtml}
|
|
`;
|
|
|
|
// --- Footer with status action buttons ---
|
|
const safeId = parseInt(issue.id, 10);
|
|
let footerHtml = '<button class="enhanced-bulk-btn secondary" onclick="closeIssueDetailModal()">Close</button>';
|
|
|
|
if (admin) {
|
|
if (issue.status === 'open' || issue.status === 'in_progress') {
|
|
if (issue.status === 'open') {
|
|
footerHtml += `<button class="enhanced-bulk-btn issue-btn-progress" onclick="updateIssueStatus(${safeId}, 'in_progress')">Mark In Progress</button>`;
|
|
}
|
|
footerHtml += `<button class="enhanced-bulk-btn issue-btn-resolve" onclick="updateIssueStatus(${safeId}, 'resolved')">Resolve</button>`;
|
|
footerHtml += `<button class="enhanced-bulk-btn issue-btn-dismiss" onclick="updateIssueStatus(${safeId}, 'dismissed')">Dismiss</button>`;
|
|
} else {
|
|
footerHtml += `<button class="enhanced-bulk-btn issue-btn-reopen" onclick="updateIssueStatus(${safeId}, 'open')">Reopen</button>`;
|
|
}
|
|
footerHtml += `<button class="enhanced-bulk-btn issue-btn-delete" onclick="deleteIssue(${safeId})">Delete</button>`;
|
|
} else {
|
|
if (issue.status === 'open') {
|
|
footerHtml += `<button class="enhanced-bulk-btn issue-btn-delete" onclick="deleteIssue(${safeId})">Withdraw</button>`;
|
|
}
|
|
}
|
|
|
|
footer.innerHTML = footerHtml;
|
|
|
|
// --- Attach action button handlers ---
|
|
const dlBtn = document.getElementById('issue-action-download');
|
|
if (dlBtn) {
|
|
dlBtn.onclick = () => issueDownloadAlbum(spotifyAlbumId, artistName, albumName);
|
|
}
|
|
const wlBtn = document.getElementById('issue-action-wishlist');
|
|
if (wlBtn) {
|
|
wlBtn.onclick = () => issueAddToWishlist(spotifyAlbumId, artistName, albumName);
|
|
}
|
|
}
|
|
|
|
// --- Issue Action: Download Album ---
|
|
async function issueDownloadAlbum(spotifyAlbumId, artistName, albumName) {
|
|
const btn = document.getElementById('issue-action-download');
|
|
if (!spotifyAlbumId && (!artistName || !albumName)) {
|
|
showToast('No album ID or artist/album info available for download', 'warning');
|
|
return;
|
|
}
|
|
try {
|
|
if (btn) { btn.disabled = true; btn.textContent = 'Loading...'; }
|
|
|
|
let response;
|
|
if (spotifyAlbumId) {
|
|
const albumParams = new URLSearchParams({ name: albumName || '', artist: artistName || '' });
|
|
response = await fetch(`/api/spotify/album/${encodeURIComponent(spotifyAlbumId)}?${albumParams}`);
|
|
} else {
|
|
// No Spotify album ID — search for the album by name
|
|
const query = `${artistName} ${albumName}`;
|
|
const searchResp = await fetch('/api/enhanced-search', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ query })
|
|
});
|
|
if (!searchResp.ok) throw new Error('Album search failed');
|
|
const searchData = await searchResp.json();
|
|
const foundAlbum = searchData.spotify_albums?.[0];
|
|
if (!foundAlbum || !foundAlbum.id) {
|
|
showToast(`Could not find "${albumName}" by ${artistName}`, 'warning');
|
|
return;
|
|
}
|
|
const albumParams = new URLSearchParams({ name: foundAlbum.name || albumName, artist: foundAlbum.artist || artistName });
|
|
response = await fetch(`/api/spotify/album/${encodeURIComponent(foundAlbum.id)}?${albumParams}`);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 401) throw new Error('Spotify not authenticated');
|
|
throw new Error(`Failed to load album: ${response.status}`);
|
|
}
|
|
|
|
const albumData = await response.json();
|
|
if (!albumData || !albumData.tracks || albumData.tracks.length === 0) {
|
|
showToast(`No tracks available for "${albumName}"`, 'warning');
|
|
return;
|
|
}
|
|
|
|
// Close the issue modal first
|
|
closeIssueDetailModal();
|
|
|
|
const resolvedAlbumId = albumData.id || spotifyAlbumId || Date.now();
|
|
const virtualPlaylistId = `issue_download_${resolvedAlbumId}`;
|
|
|
|
// Enrich tracks with album metadata
|
|
const enrichedTracks = albumData.tracks.map(track => ({
|
|
...track,
|
|
album: {
|
|
name: albumData.name,
|
|
id: albumData.id,
|
|
album_type: albumData.album_type || 'album',
|
|
images: albumData.images || [],
|
|
release_date: albumData.release_date,
|
|
total_tracks: albumData.total_tracks
|
|
}
|
|
}));
|
|
|
|
const playlistName = `[${artistName}] ${albumData.name}`;
|
|
const artistObject = { id: `issue_${artistName}`, name: artistName, image_url: '' };
|
|
const fullAlbumObject = {
|
|
name: albumData.name,
|
|
id: albumData.id,
|
|
album_type: albumData.album_type || 'album',
|
|
images: albumData.images || [],
|
|
image_url: albumData.images?.[0]?.url || null,
|
|
release_date: albumData.release_date,
|
|
total_tracks: albumData.total_tracks,
|
|
artists: albumData.artists || [{ name: artistName }]
|
|
};
|
|
|
|
await openDownloadMissingModalForArtistAlbum(
|
|
virtualPlaylistId, playlistName, enrichedTracks, fullAlbumObject, artistObject, true
|
|
);
|
|
|
|
// Register download bubble so it appears on the dashboard
|
|
const albumType = fullAlbumObject.album_type || 'album';
|
|
registerArtistDownload(artistObject, fullAlbumObject, virtualPlaylistId, albumType);
|
|
|
|
} catch (error) {
|
|
console.error('Issue download error:', error);
|
|
showToast(`Error: ${error.message}`, 'error');
|
|
} finally {
|
|
if (btn) { btn.disabled = false; btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Download Album'; }
|
|
}
|
|
}
|
|
|
|
// --- Redownload Library Album (Enhanced View) ---
|
|
async function redownloadLibraryAlbum(album, artistName, btn) {
|
|
const albumName = album.title || '';
|
|
const spotifyAlbumId = album.spotify_album_id || '';
|
|
|
|
if (!spotifyAlbumId && !albumName) {
|
|
showToast('No album ID or name available for redownload', 'warning');
|
|
return;
|
|
}
|
|
|
|
const origText = btn ? btn.innerHTML : '';
|
|
try {
|
|
if (btn) { btn.disabled = true; btn.textContent = 'Loading...'; }
|
|
|
|
let response;
|
|
if (spotifyAlbumId) {
|
|
const params = new URLSearchParams({ name: albumName, artist: artistName || '' });
|
|
response = await fetch(`/api/spotify/album/${encodeURIComponent(spotifyAlbumId)}?${params}`);
|
|
}
|
|
|
|
// Fallback: search by name if no ID or direct fetch failed
|
|
if (!response || !response.ok) {
|
|
const query = `${artistName || ''} ${albumName}`.trim();
|
|
const searchResp = await fetch('/api/enhanced-search', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ query })
|
|
});
|
|
if (!searchResp.ok) throw new Error('Album search failed');
|
|
const searchData = await searchResp.json();
|
|
const found = searchData.spotify_albums?.[0] || searchData.itunes_albums?.[0];
|
|
if (!found || !found.id) {
|
|
showToast(`Could not find "${albumName}" by ${artistName || 'unknown'}`, 'warning');
|
|
return;
|
|
}
|
|
const params = new URLSearchParams({ name: found.name || albumName, artist: found.artist || artistName || '' });
|
|
response = await fetch(`/api/spotify/album/${encodeURIComponent(found.id)}?${params}`);
|
|
}
|
|
|
|
if (!response.ok) throw new Error(`Failed to load album: ${response.status}`);
|
|
|
|
const albumData = await response.json();
|
|
if (!albumData || !albumData.tracks || albumData.tracks.length === 0) {
|
|
showToast(`No tracks found for "${albumName}"`, 'warning');
|
|
return;
|
|
}
|
|
|
|
const resolvedId = albumData.id || spotifyAlbumId || album.id;
|
|
const virtualPlaylistId = `library_redownload_${resolvedId}`;
|
|
const playlistName = `[${artistName || 'Unknown'}] ${albumData.name}`;
|
|
|
|
const enrichedTracks = albumData.tracks.map(track => ({
|
|
...track,
|
|
album: {
|
|
name: albumData.name,
|
|
id: albumData.id,
|
|
album_type: albumData.album_type || 'album',
|
|
images: albumData.images || [],
|
|
release_date: albumData.release_date,
|
|
total_tracks: albumData.total_tracks
|
|
}
|
|
}));
|
|
|
|
const enhancedArtist = artistDetailPageState.enhancedData?.artist;
|
|
const artistObject = {
|
|
id: artistDetailPageState.currentArtistId || `library_${artistName || album.id}`,
|
|
name: artistName || '',
|
|
image_url: enhancedArtist?.thumb_url || ''
|
|
};
|
|
const fullAlbumObject = {
|
|
name: albumData.name,
|
|
id: albumData.id,
|
|
album_type: albumData.album_type || 'album',
|
|
images: albumData.images || [],
|
|
image_url: albumData.images?.[0]?.url || null,
|
|
release_date: albumData.release_date,
|
|
total_tracks: albumData.total_tracks,
|
|
artists: albumData.artists || [{ name: artistName || '' }]
|
|
};
|
|
|
|
await openDownloadMissingModalForArtistAlbum(
|
|
virtualPlaylistId, playlistName, enrichedTracks, fullAlbumObject, artistObject, true
|
|
);
|
|
|
|
// Register download bubble so it appears on the dashboard
|
|
const albumType = fullAlbumObject.album_type || 'album';
|
|
registerArtistDownload(artistObject, fullAlbumObject, virtualPlaylistId, albumType);
|
|
|
|
} catch (error) {
|
|
console.error('Redownload album error:', error);
|
|
showToast(`Error: ${error.message}`, 'error');
|
|
} finally {
|
|
if (btn) { btn.disabled = false; btn.innerHTML = origText; }
|
|
}
|
|
}
|
|
|
|
// --- Issue Action: Add to Wishlist ---
|
|
async function issueAddToWishlist(spotifyAlbumId, artistName, albumName) {
|
|
const btn = document.getElementById('issue-action-wishlist');
|
|
if (!spotifyAlbumId && (!artistName || !albumName)) {
|
|
showToast('No album ID or artist/album info available', 'warning');
|
|
return;
|
|
}
|
|
try {
|
|
if (btn) { btn.disabled = true; btn.textContent = 'Loading...'; }
|
|
|
|
let response;
|
|
if (spotifyAlbumId) {
|
|
const albumParams = new URLSearchParams({ name: albumName || '', artist: artistName || '' });
|
|
response = await fetch(`/api/spotify/album/${encodeURIComponent(spotifyAlbumId)}?${albumParams}`);
|
|
} else {
|
|
// No Spotify album ID — search for the album by name
|
|
const query = `${artistName} ${albumName}`;
|
|
const searchResp = await fetch('/api/enhanced-search', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ query })
|
|
});
|
|
if (!searchResp.ok) throw new Error('Album search failed');
|
|
const searchData = await searchResp.json();
|
|
const foundAlbum = searchData.spotify_albums?.[0];
|
|
if (!foundAlbum || !foundAlbum.id) {
|
|
showToast(`Could not find "${albumName}" by ${artistName}`, 'warning');
|
|
return;
|
|
}
|
|
const albumParams = new URLSearchParams({ name: foundAlbum.name || albumName, artist: foundAlbum.artist || artistName });
|
|
response = await fetch(`/api/spotify/album/${encodeURIComponent(foundAlbum.id)}?${albumParams}`);
|
|
}
|
|
|
|
if (!response.ok) throw new Error(`Failed to load album: ${response.status}`);
|
|
|
|
const albumData = await response.json();
|
|
if (!albumData || !albumData.tracks || albumData.tracks.length === 0) {
|
|
showToast(`No tracks available for "${albumName}"`, 'warning');
|
|
return;
|
|
}
|
|
|
|
// Close issue modal and open wishlist modal
|
|
closeIssueDetailModal();
|
|
|
|
const albumArtists = albumData.artists || [{ name: artistName }];
|
|
const album = {
|
|
name: albumData.name,
|
|
id: albumData.id,
|
|
album_type: albumData.album_type || 'album',
|
|
images: albumData.images || [],
|
|
release_date: albumData.release_date,
|
|
total_tracks: albumData.total_tracks,
|
|
artists: albumArtists
|
|
};
|
|
const artist = { id: null, name: artistName };
|
|
|
|
// Enrich tracks with album metadata — use album artist for wishlist grouping
|
|
// (Spotify returns per-track artists which can differ on compilations/soundtracks)
|
|
const tracks = albumData.tracks.map(t => ({
|
|
...t,
|
|
artists: albumArtists,
|
|
album: album
|
|
}));
|
|
|
|
await openAddToWishlistModal(album, artist, tracks, albumData.album_type || 'album');
|
|
|
|
} catch (error) {
|
|
console.error('Issue wishlist error:', error);
|
|
showToast(`Error: ${error.message}`, 'error');
|
|
} finally {
|
|
if (btn) { btn.disabled = false; btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/></svg> Add to Wishlist'; }
|
|
}
|
|
}
|
|
|
|
async function updateIssueStatus(issueId, newStatus) {
|
|
const payload = { status: newStatus };
|
|
|
|
// Include admin response if present
|
|
const responseInput = document.getElementById('issue-detail-response-input');
|
|
if (responseInput) {
|
|
payload.admin_response = responseInput.value.trim();
|
|
}
|
|
|
|
try {
|
|
const resp = await fetch(`/api/issues/${issueId}`, {
|
|
method: 'PUT',
|
|
headers: _issueHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await resp.json();
|
|
if (data.success) {
|
|
showToast(`Issue ${newStatus === 'resolved' ? 'resolved' : newStatus === 'dismissed' ? 'dismissed' : newStatus === 'in_progress' ? 'marked in progress' : 'reopened'}`, 'success');
|
|
closeIssueDetailModal();
|
|
// Refresh if on issues page
|
|
const issuesPage = document.getElementById('issues-page');
|
|
if (issuesPage && issuesPage.classList.contains('active')) {
|
|
loadIssuesPage();
|
|
}
|
|
loadIssuesBadge();
|
|
} else {
|
|
showToast(data.error || 'Failed to update issue', 'error');
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to update issue:', e);
|
|
showToast('Failed to update issue', 'error');
|
|
}
|
|
}
|
|
|
|
async function deleteIssue(issueId) {
|
|
if (!confirm('Are you sure you want to delete this issue?')) return;
|
|
try {
|
|
const resp = await fetch(`/api/issues/${issueId}`, { method: 'DELETE', headers: _issueHeaders() });
|
|
const data = await resp.json();
|
|
if (data.success) {
|
|
showToast('Issue deleted', 'success');
|
|
closeIssueDetailModal();
|
|
const issuesPage = document.getElementById('issues-page');
|
|
if (issuesPage && issuesPage.classList.contains('active')) {
|
|
loadIssuesPage();
|
|
}
|
|
loadIssuesBadge();
|
|
} else {
|
|
showToast(data.error || 'Failed to delete issue', 'error');
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to delete issue:', e);
|
|
showToast('Failed to delete issue', 'error');
|
|
}
|
|
}
|
|
|
|
function closeIssueDetailModal() {
|
|
const overlay = document.getElementById('issue-detail-overlay');
|
|
if (overlay) overlay.classList.add('hidden');
|
|
}
|
|
|
|
async function loadIssuesBadge() {
|
|
try {
|
|
const resp = await fetch('/api/issues/counts', { headers: _issueHeaders() });
|
|
const data = await resp.json();
|
|
if (!data.success) return;
|
|
const badge = document.getElementById('issues-nav-badge');
|
|
if (badge) {
|
|
const openCount = data.counts.open || 0;
|
|
badge.textContent = openCount;
|
|
badge.classList.toggle('hidden', openCount === 0);
|
|
}
|
|
} catch (e) { }
|
|
}
|
|
|
|
// ===== END ISSUES PAGE =====
|
|
|
|
// --- Helpers ---
|
|
|
|
function _esc(str) {
|
|
if (!str) return '';
|
|
const d = document.createElement('div');
|
|
d.textContent = str;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function _escAttr(str) {
|
|
if (!str) return '';
|
|
return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
// ===== ENHANCE QUALITY MODAL =====
|
|
|
|
let _enhanceQualityData = null;
|
|
let _enhanceArtistId = null;
|
|
|
|
const ENHANCE_TIER_MAP = {
|
|
'lossless': { num: 1, label: 'Lossless', cssClass: 'lossless' },
|
|
'high_lossy': { num: 2, label: 'High Lossy', cssClass: 'high-lossy' },
|
|
'standard_lossy': { num: 3, label: 'Standard Lossy', cssClass: 'standard-lossy' },
|
|
'low_lossy': { num: 4, label: 'Low Lossy', cssClass: 'low-lossy' },
|
|
'unknown': { num: 999, label: 'Unknown', cssClass: 'unknown' },
|
|
};
|
|
|
|
async function checkArtistEnhanceEligibility(artistId) {
|
|
const btn = document.getElementById('library-artist-enhance-btn');
|
|
if (!btn) return;
|
|
btn.classList.add('hidden');
|
|
_enhanceArtistId = artistId;
|
|
|
|
try {
|
|
const resp = await fetch(`/api/library/artist/${artistId}/quality-analysis`);
|
|
if (!resp.ok) return;
|
|
const data = await resp.json();
|
|
if (!data.success || !data.tracks || data.tracks.length === 0) return;
|
|
|
|
_enhanceQualityData = data;
|
|
|
|
// Show button if any tracks are below the user's min acceptable tier
|
|
const minTier = data.min_acceptable_tier || 1;
|
|
const belowCount = data.tracks.filter(t => t.tier_num > minTier).length;
|
|
if (belowCount > 0) {
|
|
btn.classList.remove('hidden');
|
|
btn.querySelector('.enhance-text').textContent = `Enhance Quality (${belowCount})`;
|
|
}
|
|
} catch (e) {
|
|
console.debug('Enhance eligibility check failed:', e);
|
|
}
|
|
}
|
|
|
|
async function playArtistRadio() {
|
|
try {
|
|
const artistId = artistDetailPageState.currentArtistId;
|
|
const artistName = artistDetailPageState.currentArtistName || '';
|
|
if (!artistId) {
|
|
showToast('No artist selected', 'error');
|
|
return;
|
|
}
|
|
|
|
// Get tracks from this artist's library
|
|
const resp = await fetch(`/api/library/artist/${artistId}/enhanced`);
|
|
if (!resp.ok) throw new Error('Failed to load artist data');
|
|
const data = await resp.json();
|
|
if (!data.success) throw new Error(data.error || 'Failed');
|
|
|
|
// Collect all tracks with file paths
|
|
const allTracks = [];
|
|
for (const album of (data.albums || [])) {
|
|
for (const track of (album.tracks || [])) {
|
|
if (track.file_path) {
|
|
allTracks.push({ track, album });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!allTracks.length) {
|
|
showToast('No playable tracks found for this artist', 'error');
|
|
return;
|
|
}
|
|
|
|
// Pick a random track
|
|
const random = allTracks[Math.floor(Math.random() * allTracks.length)];
|
|
const albumArt = random.album.thumb_url || data.artist?.thumb_url || null;
|
|
|
|
// Clear existing queue and disable radio before starting fresh
|
|
npRadioMode = false;
|
|
clearQueue();
|
|
if (audioPlayer && !audioPlayer.paused) {
|
|
audioPlayer.pause();
|
|
}
|
|
|
|
// Play the track first, then enable radio mode after a short delay
|
|
// so currentTrack is set and the radio queue fill triggers
|
|
playLibraryTrack({
|
|
id: random.track.id,
|
|
title: random.track.title,
|
|
file_path: random.track.file_path,
|
|
bitrate: random.track.bitrate,
|
|
artist_id: artistId,
|
|
album_id: random.album.id,
|
|
}, random.album.title || '', artistName);
|
|
|
|
// Enable radio mode after track starts loading
|
|
setTimeout(() => {
|
|
npRadioMode = true;
|
|
const radioBtn = document.querySelector('.np-radio-btn');
|
|
if (radioBtn) radioBtn.classList.add('active');
|
|
}, 1000);
|
|
|
|
showToast(`Playing ${artistName} radio — similar tracks will auto-queue`, 'success');
|
|
} catch (e) {
|
|
showToast(`Failed to start artist radio: ${e.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
function openEnhanceQualityModal() {
|
|
if (!_enhanceQualityData) return;
|
|
const data = _enhanceQualityData;
|
|
|
|
// Remove existing modal if any
|
|
const existing = document.getElementById('enhance-quality-overlay');
|
|
if (existing) existing.remove();
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.id = 'enhance-quality-overlay';
|
|
overlay.className = 'enhance-modal-overlay';
|
|
overlay.onclick = (e) => { if (e.target === overlay) closeEnhanceQualityModal(); };
|
|
|
|
const minTier = data.min_acceptable_tier || 1;
|
|
const summary = data.quality_summary || {};
|
|
|
|
overlay.innerHTML = `
|
|
<div class="enhance-modal" onclick="event.stopPropagation()">
|
|
<div class="enhance-modal-header">
|
|
<h3>⚡ Enhance Quality — ${_esc(data.artist_name)}</h3>
|
|
<button class="enhance-modal-close" onclick="closeEnhanceQualityModal()">×</button>
|
|
</div>
|
|
<div class="enhance-summary-bar">
|
|
${_buildEnhanceSummaryChips(summary)}
|
|
</div>
|
|
<div class="enhance-controls">
|
|
<div class="enhance-tier-selector">
|
|
<label>Upgrade tracks below:</label>
|
|
<select id="enhance-tier-dropdown" onchange="updateEnhanceThreshold(parseInt(this.value))">
|
|
<option value="1" ${minTier <= 1 ? 'selected' : ''}>Lossless (FLAC/WAV)</option>
|
|
<option value="2" ${minTier === 2 ? 'selected' : ''}>High Lossy (OGG/Opus)</option>
|
|
<option value="3" ${minTier === 3 ? 'selected' : ''}>Standard Lossy (M4A/AAC)</option>
|
|
<option value="4" ${minTier >= 4 ? 'selected' : ''}>Low Lossy (MP3/WMA)</option>
|
|
</select>
|
|
</div>
|
|
<div class="enhance-select-controls">
|
|
<button class="enhance-select-btn" onclick="enhanceSelectAll(true)">Select All Below</button>
|
|
<button class="enhance-select-btn" onclick="enhanceSelectAll(false)">Deselect All</button>
|
|
<span class="enhance-selected-count" id="enhance-selected-count">0 selected</span>
|
|
</div>
|
|
</div>
|
|
<div class="enhance-modal-body">
|
|
<table class="enhance-track-table">
|
|
<thead>
|
|
<tr>
|
|
<th></th>
|
|
<th>#</th>
|
|
<th>Title</th>
|
|
<th>Album</th>
|
|
<th>Format</th>
|
|
<th>Bitrate</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="enhance-track-tbody">
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="enhance-modal-footer">
|
|
<div class="enhance-footer-info" id="enhance-footer-info"></div>
|
|
<div class="enhance-footer-actions">
|
|
<button class="enhance-btn secondary" onclick="closeEnhanceQualityModal()">Cancel</button>
|
|
<button class="enhance-btn primary" id="enhance-submit-btn" onclick="submitEnhanceQuality()" disabled>
|
|
⚡ Enhance 0 Tracks
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(overlay);
|
|
renderEnhanceTrackRows(minTier);
|
|
}
|
|
|
|
function _buildEnhanceSummaryChips(summary) {
|
|
const chips = [
|
|
{ key: 'lossless', label: 'FLAC', cssClass: 'lossless' },
|
|
{ key: 'high_lossy', label: 'OGG/Opus', cssClass: 'high-lossy' },
|
|
{ key: 'standard_lossy', label: 'M4A/AAC', cssClass: 'standard-lossy' },
|
|
{ key: 'low_lossy', label: 'MP3/WMA', cssClass: 'low-lossy' },
|
|
];
|
|
return chips
|
|
.filter(c => (summary[c.key] || 0) > 0)
|
|
.map(c => `
|
|
<div class="enhance-summary-chip ${c.cssClass}">
|
|
<span class="chip-count">${summary[c.key]}</span>
|
|
<span class="chip-label">${c.label}</span>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function renderEnhanceTrackRows(thresholdTier) {
|
|
const tbody = document.getElementById('enhance-track-tbody');
|
|
if (!tbody || !_enhanceQualityData) return;
|
|
|
|
const tracks = _enhanceQualityData.tracks;
|
|
// Sort: below-threshold first, then by album + track number
|
|
const sorted = [...tracks].sort((a, b) => {
|
|
const aBt = a.tier_num > thresholdTier ? 0 : 1;
|
|
const bBt = b.tier_num > thresholdTier ? 0 : 1;
|
|
if (aBt !== bBt) return aBt - bBt;
|
|
const albumCmp = (a.album_title || '').localeCompare(b.album_title || '');
|
|
if (albumCmp !== 0) return albumCmp;
|
|
return (a.disc_number || 1) * 1000 + (a.track_number || 0) - ((b.disc_number || 1) * 1000 + (b.track_number || 0));
|
|
});
|
|
|
|
tbody.innerHTML = sorted.map(track => {
|
|
const isBelow = track.tier_num > thresholdTier;
|
|
const tierInfo = ENHANCE_TIER_MAP[track.tier_name] || ENHANCE_TIER_MAP['unknown'];
|
|
const bitrateStr = track.bitrate ? `${track.bitrate} kbps` : '-';
|
|
return `
|
|
<tr class="enhance-track-row ${isBelow ? 'below-threshold' : 'above-threshold'}"
|
|
data-track-id="${_escAttr(track.track_id)}" data-tier="${track.tier_num}">
|
|
<td><input type="checkbox" class="enhance-track-check"
|
|
${isBelow ? 'checked' : ''} onchange="updateEnhanceSelectedCount()"></td>
|
|
<td>${track.track_number || '-'}</td>
|
|
<td>${_esc(track.title)}</td>
|
|
<td>${_esc(track.album_title)}</td>
|
|
<td><span class="enhance-format-badge ${tierInfo.cssClass}">${_esc(track.format)}</span></td>
|
|
<td><span class="enhance-bitrate">${bitrateStr}</span></td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
|
|
updateEnhanceSelectedCount();
|
|
}
|
|
|
|
function updateEnhanceThreshold(tierNum) {
|
|
const rows = document.querySelectorAll('.enhance-track-row');
|
|
rows.forEach(row => {
|
|
const trackTier = parseInt(row.dataset.tier);
|
|
const isBelow = trackTier > tierNum;
|
|
const cb = row.querySelector('.enhance-track-check');
|
|
|
|
row.classList.toggle('below-threshold', isBelow);
|
|
row.classList.toggle('above-threshold', !isBelow);
|
|
if (cb) cb.checked = isBelow;
|
|
});
|
|
updateEnhanceSelectedCount();
|
|
}
|
|
|
|
function enhanceSelectAll(select) {
|
|
const thresholdTier = parseInt(document.getElementById('enhance-tier-dropdown')?.value || '1');
|
|
const checks = document.querySelectorAll('.enhance-track-check');
|
|
checks.forEach(cb => {
|
|
const row = cb.closest('.enhance-track-row');
|
|
const trackTier = parseInt(row?.dataset.tier || '999');
|
|
if (select) {
|
|
cb.checked = trackTier > thresholdTier;
|
|
} else {
|
|
cb.checked = false;
|
|
}
|
|
});
|
|
updateEnhanceSelectedCount();
|
|
}
|
|
|
|
function updateEnhanceSelectedCount() {
|
|
const checks = document.querySelectorAll('.enhance-track-check:checked');
|
|
const count = checks.length;
|
|
const countEl = document.getElementById('enhance-selected-count');
|
|
const submitBtn = document.getElementById('enhance-submit-btn');
|
|
|
|
if (countEl) countEl.textContent = `${count} selected`;
|
|
if (submitBtn) {
|
|
submitBtn.textContent = `⚡ Enhance ${count} Track${count !== 1 ? 's' : ''}`;
|
|
submitBtn.disabled = count === 0;
|
|
}
|
|
}
|
|
|
|
async function submitEnhanceQuality() {
|
|
const checks = document.querySelectorAll('.enhance-track-check:checked');
|
|
const trackIds = [];
|
|
checks.forEach(cb => {
|
|
const row = cb.closest('.enhance-track-row');
|
|
if (row?.dataset.trackId) trackIds.push(row.dataset.trackId);
|
|
});
|
|
|
|
if (trackIds.length === 0) return;
|
|
|
|
const submitBtn = document.getElementById('enhance-submit-btn');
|
|
const footerInfo = document.getElementById('enhance-footer-info');
|
|
if (submitBtn) {
|
|
submitBtn.disabled = true;
|
|
submitBtn.innerHTML = '<span class="enhance-spinner"></span>Processing...';
|
|
}
|
|
if (footerInfo) footerInfo.textContent = 'Matching tracks to Spotify and adding to wishlist...';
|
|
|
|
try {
|
|
const resp = await fetch(`/api/library/artist/${_enhanceArtistId}/enhance`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ track_ids: trackIds })
|
|
});
|
|
|
|
const result = await resp.json();
|
|
|
|
if (result.success) {
|
|
const msg = `${result.enhanced_count} track${result.enhanced_count !== 1 ? 's' : ''} queued for enhancement`;
|
|
if (footerInfo) footerInfo.textContent = msg;
|
|
|
|
showToast(msg + (result.failed_count > 0 ? ` (${result.failed_count} failed)` : ''), 'success');
|
|
|
|
// Update button count
|
|
const enhBtn = document.getElementById('library-artist-enhance-btn');
|
|
if (enhBtn && result.enhanced_count > 0) {
|
|
const remaining = trackIds.length - result.enhanced_count;
|
|
if (remaining <= 0) {
|
|
enhBtn.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
if (submitBtn) {
|
|
submitBtn.textContent = '✅ Done';
|
|
submitBtn.disabled = true;
|
|
}
|
|
|
|
// Auto-close after short delay
|
|
setTimeout(() => closeEnhanceQualityModal(), 1500);
|
|
} else {
|
|
throw new Error(result.error || 'Enhancement failed');
|
|
}
|
|
} catch (e) {
|
|
console.error('Enhance quality error:', e);
|
|
showToast(`Enhancement failed: ${e.message}`, 'error');
|
|
if (submitBtn) {
|
|
submitBtn.textContent = `⚡ Enhance ${trackIds.length} Tracks`;
|
|
submitBtn.disabled = false;
|
|
}
|
|
if (footerInfo) footerInfo.textContent = '';
|
|
}
|
|
}
|
|
|
|
function closeEnhanceQualityModal() {
|
|
const overlay = document.getElementById('enhance-quality-overlay');
|
|
if (overlay) {
|
|
overlay.classList.add('hidden');
|
|
setTimeout(() => overlay.remove(), 300);
|
|
}
|
|
}
|
|
|
|
// Global exports
|
|
window.openEnhanceQualityModal = openEnhanceQualityModal;
|
|
window.closeEnhanceQualityModal = closeEnhanceQualityModal;
|
|
window.updateEnhanceThreshold = updateEnhanceThreshold;
|
|
window.enhanceSelectAll = enhanceSelectAll;
|
|
window.updateEnhanceSelectedCount = updateEnhanceSelectedCount;
|
|
window.submitEnhanceQuality = submitEnhanceQuality;
|
|
|
|
// ===== END ENHANCE QUALITY MODAL =====
|