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
@@ -0,0 +1,156 @@
|
|||||||
|
import asyncio
|
||||||
|
import urllib.parse
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
import httpx
|
||||||
|
import yt_dlp
|
||||||
|
from app.config import settings
|
||||||
|
from app.providers.yandex import YandexMusicProvider
|
||||||
|
|
||||||
|
yandex_provider = YandexMusicProvider()
|
||||||
|
|
||||||
|
def format_duration(ms: Optional[int]) -> str:
|
||||||
|
if not ms:
|
||||||
|
return "00:00"
|
||||||
|
total_sec = ms // 1000
|
||||||
|
m = total_sec // 60
|
||||||
|
s = total_sec % 60
|
||||||
|
return f"{m:02d}:{s:02d}"
|
||||||
|
|
||||||
|
async def search_yandex(query: str) -> Dict[str, List[Dict[str, Any]]]:
|
||||||
|
"""Search tracks and albums via Yandex Music."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(None, _search_yandex_sync, query)
|
||||||
|
|
||||||
|
def _search_yandex_sync(query: str) -> Dict[str, List[Dict[str, Any]]]:
|
||||||
|
tracks = []
|
||||||
|
albums = []
|
||||||
|
try:
|
||||||
|
client = yandex_provider.get_client()
|
||||||
|
res = client.search(query, type_="all")
|
||||||
|
|
||||||
|
if res.tracks and res.tracks.results:
|
||||||
|
for t in res.tracks.results[:6]:
|
||||||
|
if not t:
|
||||||
|
continue
|
||||||
|
artists = ", ".join(a.name for a in t.artists) if t.artists else "Unknown"
|
||||||
|
album_title = t.albums[0].title if t.albums else "Single"
|
||||||
|
year = t.albums[0].year if t.albums and t.albums[0].year else None
|
||||||
|
cover = "https://" + t.cover_uri.replace("%%", "400x400") if t.cover_uri else None
|
||||||
|
tracks.append({
|
||||||
|
"service": "yandex",
|
||||||
|
"service_name": "Яндекс Музыка",
|
||||||
|
"id": str(t.id),
|
||||||
|
"title": t.title,
|
||||||
|
"artist": artists,
|
||||||
|
"album": album_title,
|
||||||
|
"year": year,
|
||||||
|
"duration": format_duration(t.duration_ms),
|
||||||
|
"cover": cover,
|
||||||
|
"url": f"https://music.yandex.ru/track/{t.id}"
|
||||||
|
})
|
||||||
|
|
||||||
|
if res.albums and res.albums.results:
|
||||||
|
for a in res.albums.results[:4]:
|
||||||
|
if not a:
|
||||||
|
continue
|
||||||
|
artists = ", ".join(art.name for art in a.artists) if a.artists else "Unknown"
|
||||||
|
cover = "https://" + a.cover_uri.replace("%%", "400x400") if a.cover_uri else None
|
||||||
|
albums.append({
|
||||||
|
"service": "yandex",
|
||||||
|
"service_name": "Яндекс Музыка",
|
||||||
|
"id": str(a.id),
|
||||||
|
"title": a.title,
|
||||||
|
"artist": artists,
|
||||||
|
"year": a.year,
|
||||||
|
"track_count": a.track_count,
|
||||||
|
"cover": cover,
|
||||||
|
"url": f"https://music.yandex.ru/album/{a.id}"
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Yandex search error: {e}")
|
||||||
|
return {"tracks": tracks, "albums": albums}
|
||||||
|
|
||||||
|
async def search_apple(query: str) -> Dict[str, List[Dict[str, Any]]]:
|
||||||
|
"""Search tracks and albums via Apple Music / iTunes public API."""
|
||||||
|
tracks = []
|
||||||
|
albums = []
|
||||||
|
try:
|
||||||
|
encoded = urllib.parse.quote(query)
|
||||||
|
url = f"https://itunes.apple.com/search?term={encoded}&entity=song&limit=5"
|
||||||
|
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
for r in data.get("results", []):
|
||||||
|
cover = r.get("artworkUrl100", "").replace("100x100bb", "400x400bb")
|
||||||
|
duration_ms = r.get("trackTimeMillis", 0)
|
||||||
|
release_date = r.get("releaseDate", "")
|
||||||
|
year = int(release_date[:4]) if release_date else None
|
||||||
|
tracks.append({
|
||||||
|
"service": "apple",
|
||||||
|
"service_name": "Apple Music",
|
||||||
|
"id": str(r.get("trackId")),
|
||||||
|
"title": r.get("trackName"),
|
||||||
|
"artist": r.get("artistName"),
|
||||||
|
"album": r.get("collectionName"),
|
||||||
|
"year": year,
|
||||||
|
"duration": format_duration(duration_ms),
|
||||||
|
"cover": cover,
|
||||||
|
"url": r.get("trackViewUrl")
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Apple search error: {e}")
|
||||||
|
return {"tracks": tracks, "albums": albums}
|
||||||
|
|
||||||
|
async def search_youtube(query: str) -> Dict[str, List[Dict[str, Any]]]:
|
||||||
|
"""Search tracks via YouTube Music."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(None, _search_youtube_sync, query)
|
||||||
|
|
||||||
|
def _search_youtube_sync(query: str) -> Dict[str, List[Dict[str, Any]]]:
|
||||||
|
tracks = []
|
||||||
|
ydl_opts = {
|
||||||
|
"extract_flat": True,
|
||||||
|
"quiet": True,
|
||||||
|
"no_warnings": True,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
info = ydl.extract_info(f"ytsearch3:{query}", download=False)
|
||||||
|
if info and "entries" in info:
|
||||||
|
for entry in info["entries"]:
|
||||||
|
if not entry:
|
||||||
|
continue
|
||||||
|
sec = entry.get("duration", 0)
|
||||||
|
tracks.append({
|
||||||
|
"service": "youtube",
|
||||||
|
"service_name": "YouTube",
|
||||||
|
"id": entry.get("id"),
|
||||||
|
"title": entry.get("title"),
|
||||||
|
"artist": entry.get("uploader") or entry.get("channel") or "Unknown",
|
||||||
|
"album": "YouTube Audio",
|
||||||
|
"year": None,
|
||||||
|
"duration": f"{sec // 60:02d}:{sec % 60:02d}" if sec else "00:00",
|
||||||
|
"cover": entry.get("thumbnail"),
|
||||||
|
"url": f"https://www.youtube.com/watch?v={entry.get('id')}"
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"YouTube search error: {e}")
|
||||||
|
return {"tracks": tracks, "albums": []}
|
||||||
|
|
||||||
|
async def search_all(query: str) -> Dict[str, Any]:
|
||||||
|
"""Run concurrent search across all platforms."""
|
||||||
|
yandex_task = search_yandex(query)
|
||||||
|
apple_task = search_apple(query)
|
||||||
|
youtube_task = search_youtube(query)
|
||||||
|
|
||||||
|
y_res, a_res, yt_res = await asyncio.gather(yandex_task, apple_task, youtube_task)
|
||||||
|
|
||||||
|
all_tracks = y_res["tracks"] + a_res["tracks"] + yt_res["tracks"]
|
||||||
|
all_albums = y_res["albums"] + a_res["albums"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"query": query,
|
||||||
|
"tracks": all_tracks,
|
||||||
|
"albums": all_albums
|
||||||
|
}
|
||||||
+46
-3
@@ -1,13 +1,17 @@
|
|||||||
import os
|
import os
|
||||||
from fastapi import FastAPI, Request, HTTPException
|
import urllib.parse
|
||||||
|
from fastapi import FastAPI, Request, HTTPException, Query
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import init_db, get_recent_tracks, get_tracks_count
|
from app.db import init_db, get_recent_tracks, get_tracks_count, get_db
|
||||||
from app.queue import sync_manager
|
from app.queue import sync_manager
|
||||||
|
from app.core.search import search_all
|
||||||
|
from app.core.tagger import get_track_dest_path
|
||||||
|
|
||||||
app = FastAPI(title="Navidrome Music Sync", version="1.0.0")
|
app = FastAPI(title="Navidrome Music Sync", version="1.0.0")
|
||||||
|
|
||||||
@@ -27,6 +31,12 @@ def on_startup():
|
|||||||
def read_root(request: Request):
|
def read_root(request: Request):
|
||||||
return templates.TemplateResponse(request=request, name="index.html")
|
return templates.TemplateResponse(request=request, name="index.html")
|
||||||
|
|
||||||
|
@app.get("/api/search")
|
||||||
|
async def search_music(q: str = Query(..., min_length=1)):
|
||||||
|
"""Search tracks and albums across Yandex, Apple Music and YouTube."""
|
||||||
|
results = await search_all(q.strip())
|
||||||
|
return results
|
||||||
|
|
||||||
@app.post("/api/download")
|
@app.post("/api/download")
|
||||||
async def start_download(req: DownloadRequest):
|
async def start_download(req: DownloadRequest):
|
||||||
url = req.url.strip()
|
url = req.url.strip()
|
||||||
@@ -36,6 +46,39 @@ async def start_download(req: DownloadRequest):
|
|||||||
task = await sync_manager.start_sync_task(url)
|
task = await sync_manager.start_sync_task(url)
|
||||||
return {"task_id": task.id, "status": task.status}
|
return {"task_id": task.id, "status": task.status}
|
||||||
|
|
||||||
|
@app.get("/api/direct-download")
|
||||||
|
async def direct_download(url: str = Query(...)):
|
||||||
|
"""Download track directly to user's browser/device."""
|
||||||
|
provider = sync_manager.get_provider(url)
|
||||||
|
tracks = await provider.resolve(url)
|
||||||
|
if not tracks:
|
||||||
|
raise HTTPException(status_code=404, detail="Track not found")
|
||||||
|
|
||||||
|
track = tracks[0]
|
||||||
|
dest_path = get_track_dest_path(
|
||||||
|
settings.MUSIC_DIR,
|
||||||
|
artist=track.artist,
|
||||||
|
album=track.album or "Singles",
|
||||||
|
track_num=track.track_number,
|
||||||
|
title=track.title,
|
||||||
|
ext="mp3"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not os.path.exists(dest_path):
|
||||||
|
success = await provider.download_track(track, dest_path)
|
||||||
|
if not success and track.service == "yandex":
|
||||||
|
success = await sync_manager.universal_provider.download_track(track, dest_path)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to download track")
|
||||||
|
|
||||||
|
filename = f"{track.artist} - {track.title}.mp3"
|
||||||
|
encoded_filename = urllib.parse.quote(filename)
|
||||||
|
return FileResponse(
|
||||||
|
dest_path,
|
||||||
|
media_type="audio/mpeg",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"}
|
||||||
|
)
|
||||||
|
|
||||||
@app.post("/api/sync/yandex-likes")
|
@app.post("/api/sync/yandex-likes")
|
||||||
async def sync_yandex_likes():
|
async def sync_yandex_likes():
|
||||||
if not settings.YANDEX_MUSIC_TOKEN:
|
if not settings.YANDEX_MUSIC_TOKEN:
|
||||||
|
|||||||
+103
-4
@@ -6,6 +6,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const historyBody = document.getElementById("historyBody");
|
const historyBody = document.getElementById("historyBody");
|
||||||
const tracksCount = document.getElementById("tracksCount");
|
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
|
// Load history and active tasks
|
||||||
fetchHistory();
|
fetchHistory();
|
||||||
fetchTasks();
|
fetchTasks();
|
||||||
@@ -15,9 +23,95 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
// Form submit
|
// Form submit
|
||||||
downloadForm.addEventListener("submit", async (e) => {
|
downloadForm.addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const url = urlInput.value.trim();
|
const val = urlInput.value.trim();
|
||||||
if (!url) return;
|
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 {
|
try {
|
||||||
const res = await fetch("api/download", {
|
const res = await fetch("api/download", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -26,15 +120,15 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.task_id) {
|
if (data.task_id) {
|
||||||
urlInput.value = "";
|
|
||||||
fetchTasks();
|
fetchTasks();
|
||||||
|
window.scrollTo({ top: tasksList.offsetTop - 100, behavior: "smooth" });
|
||||||
} else {
|
} else {
|
||||||
alert(data.error || "Ошибка запуска загрузки");
|
alert(data.error || "Ошибка запуска загрузки");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("Ошибка сети: " + err);
|
alert("Ошибка сети: " + err);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
// Sync likes button
|
// Sync likes button
|
||||||
syncLikesBtn.addEventListener("click", async () => {
|
syncLikesBtn.addEventListener("click", async () => {
|
||||||
@@ -118,6 +212,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeAttr(str) {
|
||||||
|
if (!str) return "";
|
||||||
|
return str.replace(/'/g, "\\'").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(isoStr) {
|
function formatDate(isoStr) {
|
||||||
if (!isoStr) return "";
|
if (!isoStr) return "";
|
||||||
const d = new Date(isoStr);
|
const d = new Date(isoStr);
|
||||||
|
|||||||
+161
-5
@@ -101,6 +101,14 @@ h1 {
|
|||||||
margin-bottom: 0.5rem;
|
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 {
|
.card-desc {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@@ -161,6 +169,41 @@ input[type="text"]:focus {
|
|||||||
background-color: #475569;
|
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 {
|
.quick-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -171,6 +214,121 @@ input[type="text"]:focus {
|
|||||||
flex-wrap: wrap;
|
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 {
|
.tasks-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -238,14 +396,12 @@ input[type="text"]:focus {
|
|||||||
padding: 0.2rem 0.5rem;
|
padding: 0.2rem 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-yandex { background-color: #ef4444; color: #fff; }
|
.tag-yandex { background-color: rgba(239, 68, 68, 0.2); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.4); }
|
||||||
.tag-spotify { background-color: #10b981; color: #fff; }
|
.tag-apple { background-color: rgba(236, 72, 153, 0.2); color: #f472b6; border: 1px solid rgba(236, 72, 153, 0.4); }
|
||||||
.tag-apple { background-color: #ec4899; color: #fff; }
|
.tag-youtube { background-color: rgba(225, 29, 72, 0.2); color: #fb7185; border: 1px solid rgba(225, 29, 72, 0.4); }
|
||||||
.tag-youtube { background-color: #e11d48; color: #fff; }
|
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<span class="logo-icon">🎵</span>
|
<span class="logo-icon">🎵</span>
|
||||||
<div>
|
<div>
|
||||||
<h1>Navidrome Music Sync</h1>
|
<h1>Navidrome Music Sync</h1>
|
||||||
<p class="subtitle">Импорт и синхронизация музыки в Navidrome</p>
|
<p class="subtitle">Поиск, импорт и синхронизация музыки в Navidrome</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-badges">
|
<div class="status-badges">
|
||||||
@@ -26,15 +26,15 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<!-- Input Card -->
|
<!-- Search & Input Card -->
|
||||||
<section class="card input-card">
|
<section class="card input-card">
|
||||||
<h2>📥 Импорт музыки</h2>
|
<h2>🔍 Поиск и импорт музыки</h2>
|
||||||
<p class="card-desc">Вставьте ссылку на трек, альбом или плейлист из <b>Яндекс Музыки</b>, <b>Spotify</b>, <b>Apple Music</b> или <b>YouTube</b>:</p>
|
<p class="card-desc">Введите название трека/артиста (например: <b>Booker - Время колокольчиков</b>) или вставьте прямую ссылку (Яндекс, Spotify, Apple, YouTube):</p>
|
||||||
|
|
||||||
<form id="downloadForm" class="download-form">
|
<form id="downloadForm" class="download-form">
|
||||||
<div class="input-wrapper">
|
<div class="input-wrapper">
|
||||||
<input type="text" id="urlInput" placeholder="https://music.yandex.ru/... или https://open.spotify.com/..." required autocomplete="off">
|
<input type="text" id="urlInput" placeholder="Название трека или https://..." required autocomplete="off">
|
||||||
<button type="submit" id="submitBtn" class="btn btn-primary">Загрузить</button>
|
<button type="submit" id="submitBtn" class="btn btn-primary">Найти / Загрузить</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -46,6 +46,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Search Results Card (Hidden by default) -->
|
||||||
|
<section class="card search-card" id="searchCard" style="display: none;">
|
||||||
|
<div class="search-header">
|
||||||
|
<h2>🎯 Результаты поиска: <span id="searchQueryLabel"></span></h2>
|
||||||
|
<button id="closeSearchBtn" class="btn-close" title="Закрыть поиск">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="searchLoading" class="search-loading" style="display: none;">
|
||||||
|
<div class="spinner"></div> Ищем на Яндекс Музыке, Apple Music и YouTube...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Tracks -->
|
||||||
|
<div class="search-section" id="searchTracksSection">
|
||||||
|
<h3>🎵 Найденные треки</h3>
|
||||||
|
<div class="search-tracks-list" id="searchTracksList"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Albums -->
|
||||||
|
<div class="search-section" id="searchAlbumsSection" style="margin-top: 1.5rem;">
|
||||||
|
<h3>💿 Альбомы</h3>
|
||||||
|
<div class="search-albums-grid" id="searchAlbumsList"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Active Tasks -->
|
<!-- Active Tasks -->
|
||||||
<section class="card tasks-card">
|
<section class="card tasks-card">
|
||||||
<h2>⚡ Очередь задач</h2>
|
<h2>⚡ Очередь задач</h2>
|
||||||
|
|||||||
Reference in new issue
Block a user