diff --git a/app/core/search.py b/app/core/search.py
new file mode 100644
index 0000000..903cca8
--- /dev/null
+++ b/app/core/search.py
@@ -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
+ }
diff --git a/app/main.py b/app/main.py
index 3f369a8..0922b03 100644
--- a/app/main.py
+++ b/app/main.py
@@ -1,13 +1,17 @@
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.templating import Jinja2Templates
+from fastapi.responses import FileResponse
from pydantic import BaseModel
-from typing import List, Dict, Any
+from typing import List, Dict, Any, Optional
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.core.search import search_all
+from app.core.tagger import get_track_dest_path
app = FastAPI(title="Navidrome Music Sync", version="1.0.0")
@@ -27,6 +31,12 @@ def on_startup():
def read_root(request: Request):
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")
async def start_download(req: DownloadRequest):
url = req.url.strip()
@@ -36,6 +46,39 @@ async def start_download(req: DownloadRequest):
task = await sync_manager.start_sync_task(url)
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")
async def sync_yandex_likes():
if not settings.YANDEX_MUSIC_TOKEN:
diff --git a/app/static/app.js b/app/static/app.js
index 4a276dc..ed7a1c9 100644
--- a/app/static/app.js
+++ b/app/static/app.js
@@ -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 = `
Ошибка поиска: ${err}
`;
+ }
+ }
+
+ function renderSearchResults(data) {
+ const tracks = data.tracks || [];
+ const albums = data.albums || [];
+
+ // Tracks
+ if (tracks.length === 0) {
+ searchTracksList.innerHTML = `Треков не найдено
`;
+ } else {
+ searchTracksList.innerHTML = tracks.map(t => `
+
+
+

+
+ ${escapeHtml(t.title)}
+
+ ${t.service_name} • ${escapeHtml(t.artist)} • ${escapeHtml(t.album || 'Single')} (${t.duration})
+
+
+
+
+
+ `).join("");
+ }
+
+ // Albums
+ if (albums.length === 0) {
+ document.getElementById("searchAlbumsSection").style.display = "none";
+ } else {
+ document.getElementById("searchAlbumsSection").style.display = "block";
+ searchAlbumsList.innerHTML = albums.map(a => `
+
+

+
${escapeHtml(a.title)}
+
+ ${a.service_name} • ${a.year || ''} • ${a.track_count ? a.track_count + ' треков' : ''}
+
+
+
+ `).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, ">");
}
+ function escapeAttr(str) {
+ if (!str) return "";
+ return str.replace(/'/g, "\\'").replace(/"/g, """);
+ }
+
function formatDate(isoStr) {
if (!isoStr) return "";
const d = new Date(isoStr);
diff --git a/app/static/style.css b/app/static/style.css
index c899df2..910f740 100644
--- a/app/static/style.css
+++ b/app/static/style.css
@@ -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;
diff --git a/app/templates/index.html b/app/templates/index.html
index dcd9657..5d199d2 100644
--- a/app/templates/index.html
+++ b/app/templates/index.html
@@ -16,7 +16,7 @@
🎵
Navidrome Music Sync
-
Импорт и синхронизация музыки в Navidrome
+
Поиск, импорт и синхронизация музыки в Navidrome
+
+
+
+
+
+
Ищем на Яндекс Музыке, Apple Music и YouTube...
+
+
+
+
+
+
+
+
+