feat(search): add multi-source search across Yandex, Apple, YouTube with direct download & import

This commit is contained in:
Antigravity committed 2026-10-12 13:53:14 +03:00
1 parent c098376aa4
commit 49bd123a03
5 files changed
+496 -18

No files matched your search

+103 -4
View File
@@ -6,6 +6,14 @@ document.addEventListener("DOMContentLoaded", () => {
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();
@@ -15,9 +23,95 @@ document.addEventListener("DOMContentLoaded", () => {
// Form submit
downloadForm.addEventListener("submit", async (e) => {
e.preventDefault();
const url = urlInput.value.trim();
if (!url) return;
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 => `
<div class="search-track-row">
<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">
<span class="track-title">${escapeHtml(t.title)}</span>
<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-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("");
}
}
// 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",
@@ -26,15 +120,15 @@ document.addEventListener("DOMContentLoaded", () => {
});
const data = await res.json();
if (data.task_id) {
urlInput.value = "";
fetchTasks();
window.scrollTo({ top: tasksList.offsetTop - 100, behavior: "smooth" });
} else {
alert(data.error || "Ошибка запуска загрузки");
}
} catch (err) {
alert("Ошибка сети: " + err);
}
});
}
// Sync likes button
syncLikesBtn.addEventListener("click", async () => {
@@ -118,6 +212,11 @@ document.addEventListener("DOMContentLoaded", () => {
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);
+161 -5
View File
@@ -101,6 +101,14 @@ h1 {
margin-bottom: 0.5rem;
}
.card h3 {
font-size: 1rem;
color: var(--text-secondary);
margin-bottom: 0.75rem;
border-bottom: 1px solid #334155;
padding-bottom: 0.3rem;
}
.card-desc {
color: var(--text-secondary);
font-size: 0.9rem;
@@ -161,6 +169,41 @@ input[type="text"]:focus {
background-color: #475569;
}
.btn-sm {
padding: 0.35rem 0.65rem;
font-size: 0.78rem;
border-radius: 6px;
border: none;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.3rem;
text-decoration: none;
}
.btn-import {
background-color: #0284c7;
color: #fff;
}
.btn-import:hover { background-color: #0369a1; }
.btn-direct-dl {
background-color: #334155;
color: #e2e8f0;
}
.btn-direct-dl:hover { background-color: #475569; }
.btn-close {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.5rem;
cursor: pointer;
line-height: 1;
}
.btn-close:hover { color: #fff; }
.quick-actions {
display: flex;
align-items: center;
@@ -171,6 +214,121 @@ input[type="text"]:focus {
flex-wrap: wrap;
}
/* Search Results */
.search-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.search-loading {
padding: 1.5rem;
text-align: center;
color: var(--accent-color);
font-size: 0.9rem;
}
.search-tracks-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.search-track-row {
display: flex;
align-items: center;
justify-content: space-between;
background-color: #0f172a;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.6rem 0.8rem;
gap: 0.75rem;
}
.track-left {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.track-thumb {
width: 44px;
height: 44px;
border-radius: 6px;
object-fit: cover;
background-color: #334155;
flex-shrink: 0;
}
.track-details {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.track-title {
font-weight: 600;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-meta {
font-size: 0.78rem;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-actions {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
/* Albums Grid */
.search-albums-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.75rem;
}
.album-card {
background-color: #0f172a;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.album-thumb {
width: 100%;
aspect-ratio: 1;
border-radius: 6px;
object-fit: cover;
background-color: #334155;
}
.album-title {
font-weight: 600;
font-size: 0.85rem;
line-height: 1.2;
}
.album-meta {
font-size: 0.75rem;
color: var(--text-secondary);
}
/* Tasks */
.tasks-list {
display: flex;
flex-direction: column;
@@ -238,14 +396,12 @@ input[type="text"]:focus {
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
text-transform: uppercase;
font-weight: 600;
}
.tag-yandex { background-color: #ef4444; color: #fff; }
.tag-spotify { background-color: #10b981; color: #fff; }
.tag-apple { background-color: #ec4899; color: #fff; }
.tag-youtube { background-color: #e11d48; color: #fff; }
.tag-yandex { background-color: rgba(239, 68, 68, 0.2); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.4); }
.tag-apple { background-color: rgba(236, 72, 153, 0.2); color: #f472b6; border: 1px solid rgba(236, 72, 153, 0.4); }
.tag-youtube { background-color: rgba(225, 29, 72, 0.2); color: #fb7185; border: 1px solid rgba(225, 29, 72, 0.4); }
.empty-state {
text-align: center;