Files
navidrome-music-sync/app/static/app.js
T

301 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
document.addEventListener("DOMContentLoaded", () => {
const downloadForm = document.getElementById("downloadForm");
const urlInput = document.getElementById("urlInput");
const syncLikesBtn = document.getElementById("syncLikesBtn");
const tasksList = document.getElementById("tasksList");
const historyBody = document.getElementById("historyBody");
const tracksCount = document.getElementById("tracksCount");
// Search elements
const searchCard = document.getElementById("searchCard");
const searchQueryLabel = document.getElementById("searchQueryLabel");
const searchLoading = document.getElementById("searchLoading");
const searchTracksList = document.getElementById("searchTracksList");
const searchAlbumsList = document.getElementById("searchAlbumsList");
const closeSearchBtn = document.getElementById("closeSearchBtn");
// Load history and active tasks
fetchHistory();
fetchTasks();
setInterval(fetchTasks, 2000);
setInterval(fetchHistory, 10000);
// Form submit
downloadForm.addEventListener("submit", async (e) => {
e.preventDefault();
const val = urlInput.value.trim();
if (!val) return;
// Check if it's a direct URL
if (val.startsWith("http://") || val.startsWith("https://")) {
await startDownload(val);
urlInput.value = "";
} else {
// It's a search query!
await performSearch(val);
}
});
// Close search
closeSearchBtn.addEventListener("click", () => {
searchCard.style.display = "none";
});
// Perform search
async function performSearch(query) {
searchCard.style.display = "block";
searchQueryLabel.textContent = `«${query}»`;
searchLoading.style.display = "block";
searchTracksList.innerHTML = "";
searchAlbumsList.innerHTML = "";
try {
const res = await fetch(`api/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
searchLoading.style.display = "none";
renderSearchResults(data);
} catch (err) {
searchLoading.style.display = "none";
searchTracksList.innerHTML = `<p class="empty-state">Ошибка поиска: ${err}</p>`;
}
}
function renderSearchResults(data) {
const tracks = data.tracks || [];
const albums = data.albums || [];
// Tracks
if (tracks.length === 0) {
searchTracksList.innerHTML = `<p class="empty-state">Треков не найдено</p>`;
} else {
searchTracksList.innerHTML = tracks.map(t => {
const explicitBadge = t.explicit_status === 'explicit'
? `<span class="badge badge-explicit" title="Оригинал: текст без цензуры (18+)">🔞 18+ Explicit</span>`
: (t.explicit_status === 'clean'
? `<span class="badge badge-clean" title="Внимание: трек зацензурен (слова запиканы / вырезаны)">⚠️ Clean (Цензура)</span>`
: '');
const versionBadge = t.version ? `<span class="badge badge-version">${escapeHtml(t.version)}</span>` : '';
return `
<div class="search-track-row" id="track-row-${escapeHtml(t.id)}">
<div class="track-left">
<img class="track-thumb" src="${t.cover || 'static/placeholder.png'}" onerror="this.src='data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'44\' height=\'44\' viewBox=\'0 0 24 24\' fill=\'none\' stroke=\'%2364748b\' stroke-width=\'2\'><rect x=\'3\' y=\'3\' width=\'18\' height=\'18\' rx=\'2\'/><circle cx=\'12\' cy=\'12\' r=\'4\'/></svg>'">
<div class="track-details">
<div style="display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap;">
<span class="track-title">${escapeHtml(t.title)}</span>
${explicitBadge}
${versionBadge}
</div>
<span class="track-meta">
<span class="tag-badge tag-${t.service}">${t.service_name}</span> • ${escapeHtml(t.artist)} • ${escapeHtml(t.album || 'Single')} (${t.duration})
</span>
</div>
</div>
<div class="track-actions">
<button class="btn-play-preview" onclick="playPreviewTrack('${escapeAttr(t.url)}', '${escapeAttr(t.title)}', '${escapeAttr(t.artist)}', '${escapeAttr(t.cover || '')}', '${escapeAttr(t.explicit_status || '')}', '${escapeAttr(t.preview_url || '')}')">
▶ Слушать
</button>
<button class="btn-sm btn-import" onclick="handleImport('${escapeAttr(t.url)}')">📥 В Navidrome</button>
<a class="btn-sm btn-direct-dl" href="api/direct-download?url=${encodeURIComponent(t.url)}" download>💾 Скачать</a>
</div>
</div>
`;
}).join("");
}
// Albums
if (albums.length === 0) {
document.getElementById("searchAlbumsSection").style.display = "none";
} else {
document.getElementById("searchAlbumsSection").style.display = "block";
searchAlbumsList.innerHTML = albums.map(a => `
<div class="album-card">
<img class="album-thumb" src="${a.cover || ''}" onerror="this.src='data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'120\' height=\'120\' viewBox=\'0 0 24 24\' fill=\'none\' stroke=\'%2364748b\' stroke-width=\'2\'><circle cx=\'12\' cy=\'12\' r=\'10\'/><circle cx=\'12\' cy=\'12\' r=\'3\'/></svg>'">
<span class="album-title">${escapeHtml(a.title)}</span>
<span class="album-meta">
<span class="tag-badge tag-${a.service}">${a.service_name}</span> • ${a.year || ''} • ${a.track_count ? a.track_count + ' треков' : ''}
</span>
<button class="btn-sm btn-import" style="margin-top: auto;" onclick="handleImport('${escapeAttr(a.url)}')">📥 Импортировать альбом</button>
</div>
`).join("");
}
}
// Audio Preview Player
let currentPlayingUrl = null;
window.playPreviewTrack = function(url, title, artist, cover, explicitStatus, previewUrl) {
const bar = document.getElementById("audioPlayerBar");
const audio = document.getElementById("globalAudioPlayer");
const playerCover = document.getElementById("playerCover");
const playerTitle = document.getElementById("playerTitle");
const playerArtist = document.getElementById("playerArtist");
const playerBadge = document.getElementById("playerExplicitBadge");
const playerImportBtn = document.getElementById("playerImportBtn");
const playerDownloadBtn = document.getElementById("playerDownloadBtn");
if (currentPlayingUrl === url && !audio.paused) {
audio.pause();
return;
}
currentPlayingUrl = url;
playerCover.src = cover || "static/placeholder.png";
playerTitle.textContent = title;
playerArtist.textContent = artist;
if (explicitStatus === "explicit") {
playerBadge.className = "badge badge-explicit";
playerBadge.textContent = "🔞 18+ Explicit (Без цензуры)";
playerBadge.style.display = "inline-flex";
} else if (explicitStatus === "clean") {
playerBadge.className = "badge badge-clean";
playerBadge.textContent = "⚠️ Clean (С цензурой)";
playerBadge.style.display = "inline-flex";
} else {
playerBadge.style.display = "none";
}
playerImportBtn.onclick = () => handleImport(url);
playerDownloadBtn.onclick = () => {
window.location.href = "api/direct-download?url=" + encodeURIComponent(url);
};
bar.style.display = "flex";
// Choose source: Apple 30s direct preview or server stream with HTTP Range
const streamSrc = previewUrl || ("api/stream?url=" + encodeURIComponent(url));
audio.src = streamSrc;
audio.play().catch(e => console.warn("Playback error:", e));
};
const playerCloseBtn = document.getElementById("playerCloseBtn");
if (playerCloseBtn) {
playerCloseBtn.addEventListener("click", () => {
const audio = document.getElementById("globalAudioPlayer");
audio.pause();
audio.src = "";
currentPlayingUrl = null;
document.getElementById("audioPlayerBar").style.display = "none";
});
}
// Global handler for import button
window.handleImport = async function(url) {
await startDownload(url);
};
async function startDownload(url) {
try {
const res = await fetch("api/download", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url })
});
const data = await res.json();
if (data.task_id) {
fetchTasks();
window.scrollTo({ top: tasksList.offsetTop - 100, behavior: "smooth" });
} else {
alert(data.error || "Ошибка запуска загрузки");
}
} catch (err) {
alert("Ошибка сети: " + err);
}
}
// Sync likes button
syncLikesBtn.addEventListener("click", async () => {
if (!confirm("Запустить синхронизацию плейлиста «Мне нравится» из Яндекс Музыки?")) return;
try {
const res = await fetch("api/sync/yandex-likes", { method: "POST" });
const data = await res.json();
if (data.task_id) {
fetchTasks();
}
} catch (err) {
alert("Ошибка запуска синхронизации: " + err);
}
});
async function fetchTasks() {
try {
const res = await fetch("api/tasks");
const tasks = await res.json();
renderTasks(tasks);
} catch (err) {
console.error(err);
}
}
function renderTasks(tasks) {
if (!tasks || tasks.length === 0) {
tasksList.innerHTML = `<p class="empty-state">Нет активных загрузок</p>`;
return;
}
tasksList.innerHTML = tasks.map(t => {
const percent = t.total > 0 ? Math.round((t.progress / t.total) * 100) : 0;
return `
<div class="task-item">
<div class="task-header">
<strong>${escapeHtml(t.target)}</strong>
<span class="badge ${t.status === 'completed' ? 'badge-success' : 'badge-info'}">${t.status}</span>
</div>
<div class="task-progress-bar">
<div class="task-progress-fill" style="width: ${percent}%;"></div>
</div>
<div class="task-current">
${t.progress}/${t.total} (${percent}%) • ${escapeHtml(t.current_item || '')}
</div>
</div>
`;
}).join("");
}
async function fetchHistory() {
try {
const res = await fetch("api/history");
const data = await res.json();
if (tracksCount) tracksCount.textContent = `Загружено: ${data.total || 0}`;
renderHistory(data.tracks || []);
} catch (err) {
console.error(err);
}
}
function renderHistory(tracks) {
if (!tracks || tracks.length === 0) {
historyBody.innerHTML = `<tr><td colspan="5" class="empty-state">Пока нет загрузок</td></tr>`;
return;
}
historyBody.innerHTML = tracks.map(t => `
<tr>
<td><span class="tag-badge tag-${t.service}">${t.service}</span></td>
<td><strong>${escapeHtml(t.artist)}</strong></td>
<td>${escapeHtml(t.title)}</td>
<td>${escapeHtml(t.album || 'Singles')}</td>
<td style="color: var(--text-secondary);">${formatDate(t.downloaded_at)}</td>
</tr>
`).join("");
}
function escapeHtml(str) {
if (!str) return "";
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function escapeAttr(str) {
if (!str) return "";
return str.replace(/'/g, "\\'").replace(/"/g, "&quot;");
}
function formatDate(isoStr) {
if (!isoStr) return "";
const d = new Date(isoStr);
return d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
});