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

+156
View File
@@ -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
View File
@@ -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
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); 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) { function formatDate(isoStr) {
if (!isoStr) return ""; if (!isoStr) return "";
const d = new Date(isoStr); const d = new Date(isoStr);
+161 -5
View File
@@ -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;
+30 -6
View File
@@ -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="Закрыть поиск">&times;</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>