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
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:
+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;
+30 -6
View File
@@ -16,7 +16,7 @@
<span class="logo-icon">🎵</span>
<div>
<h1>Navidrome Music Sync</h1>
<p class="subtitle">Импорт и синхронизация музыки в Navidrome</p>
<p class="subtitle">Поиск, импорт и синхронизация музыки в Navidrome</p>
</div>
</div>
<div class="status-badges">
@@ -26,15 +26,15 @@
</header>
<main class="main-content">
<!-- Input Card -->
<!-- Search & Input Card -->
<section class="card input-card">
<h2>📥 Импорт музыки</h2>
<p class="card-desc">Вставьте ссылку на трек, альбом или плейлист из <b>Яндекс Музыки</b>, <b>Spotify</b>, <b>Apple Music</b> или <b>YouTube</b>:</p>
<h2>🔍 Поиск и импорт музыки</h2>
<p class="card-desc">Введите название трека/артиста (например: <b>Booker - Время колокольчиков</b>) или вставьте прямую ссылку (Яндекс, Spotify, Apple, YouTube):</p>
<form id="downloadForm" class="download-form">
<div class="input-wrapper">
<input type="text" id="urlInput" placeholder="https://music.yandex.ru/... или https://open.spotify.com/..." required autocomplete="off">
<button type="submit" id="submitBtn" class="btn btn-primary">Загрузить</button>
<input type="text" id="urlInput" placeholder="Название трека или https://..." required autocomplete="off">
<button type="submit" id="submitBtn" class="btn btn-primary">Найти / Загрузить</button>
</div>
</form>
@@ -46,6 +46,30 @@
</div>
</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 -->
<section class="card tasks-card">
<h2>⚡ Очередь задач</h2>