feat(search): add multi-source search across Yandex, Apple, YouTube with direct download & import
This commit is contained in:
1 parent
c098376aa4
commit
49bd123a03
5 files changed
+496
-18
No files matched your search
+103
-4
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function escapeAttr(str) {
|
||||
if (!str) return "";
|
||||
return str.replace(/'/g, "\\'").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function formatDate(isoStr) {
|
||||
if (!isoStr) return "";
|
||||
const d = new Date(isoStr);
|
||||
|
||||
+161
-5
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user