Files
navidrome-music-sync/app/core/search.py
T

419 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import re
import difflib
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 = int(ms) // 1000
m = total_sec // 60
s = total_sec % 60
return f"{m:02d}:{s:02d}"
def normalize_text(s: str) -> str:
if not s:
return ""
s = s.lower()
s = re.sub(r"[^\w\s]", " ", s)
return " ".join(s.split())
def calculate_track_relevance(query: str, item: Dict[str, Any]) -> float:
"""Calculate match score between search query and track metadata."""
q_norm = normalize_text(query)
q_words = set(q_norm.split())
if not q_words:
return 0.0
art_norm = normalize_text(item.get("artist", ""))
tit_norm = normalize_text(item.get("title", ""))
full_norm = f"{art_norm} {tit_norm}"
full_words = set(full_norm.split())
# 1. Overlap of query words (0..100)
overlap = len(q_words & full_words) / len(q_words)
score = overlap * 100.0
# 2. Sequence similarity ratio (0..50)
seq_ratio = difflib.SequenceMatcher(None, q_norm, full_norm).ratio()
score += seq_ratio * 50.0
# 3. Exact query substring in title or combined string
if q_norm in full_norm or full_norm in q_norm:
score += 30.0
# 4. Handle "Artist - Title" query pattern
if " - " in query or " — " in query:
parts = re.split(r"\s+[-—]\s+", query, maxsplit=1)
if len(parts) == 2:
q_art = normalize_text(parts[0])
q_tit = normalize_text(parts[1])
if q_art and (q_art in art_norm or art_norm in q_art):
score += 35.0
if q_tit and (q_tit in tit_norm or tit_norm in q_tit):
score += 45.0
# 5. Exact title match bonus
if tit_norm and (tit_norm == q_norm or (len(q_words) > 1 and q_norm.endswith(tit_norm))):
score += 25.0
# 6. Priority bonus for user's preferred music service: Spotify
if item.get("service") == "spotify":
score += 25.0
# Slight penalty for overly long titles (e.g. video descriptions)
if len(full_norm) > len(q_norm) * 3:
score -= 10.0
return max(0.0, score)
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[:8]:
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
content_warning = getattr(t, "content_warning", None)
version = getattr(t, "version", None)
title_lower = (t.title or "").lower()
version_lower = (version or "").lower()
if content_warning == "explicit" or any(w in title_lower or w in version_lower for w in ["explicit", "18+", "uncensored"]):
explicit_status = "explicit"
elif content_warning == "clean" or any(w in title_lower or w in version_lower for w in ["clean", "censored", "радио", "radio edit"]):
explicit_status = "clean"
else:
explicit_status = "neutral"
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}",
"explicit_status": explicit_status,
"version": version,
"preview_url": None
})
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_soundcloud(query: str) -> Dict[str, List[Dict[str, Any]]]:
"""Search tracks via SoundCloud."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, _search_soundcloud_sync, query)
def _search_soundcloud_sync(query: str) -> Dict[str, List[Dict[str, Any]]]:
tracks = []
encoded_query = urllib.parse.quote(query)
ydl_opts = {
"extract_flat": True,
"quiet": True,
"no_warnings": True,
"ignoreerrors": True,
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(f"scsearch5:{encoded_query}", download=False)
if info and "entries" in info:
for entry in info["entries"]:
if not entry:
continue
sec = entry.get("duration", 0)
title_raw = urllib.parse.unquote(entry.get("title") or "")
uploader_raw = urllib.parse.unquote(entry.get("uploader") or entry.get("channel") or "Unknown")
title_lower = title_raw.lower()
if any(w in title_lower for w in ["explicit", "uncensored", "18+"]):
explicit_status = "explicit"
elif any(w in title_lower for w in ["clean", "censored", "radio edit", "радиоверсия"]):
explicit_status = "clean"
else:
explicit_status = "neutral"
artist = uploader_raw
title = title_raw
if " - " in title_raw:
parts = title_raw.split(" - ", 1)
artist = parts[0].strip()
title = parts[1].strip()
url = entry.get("webpage_url") or entry.get("url") or ""
tracks.append({
"service": "soundcloud",
"service_name": "SoundCloud",
"id": str(entry.get("id")),
"title": title,
"artist": artist,
"album": "SoundCloud",
"year": None,
"duration": f"{int(sec) // 60:02d}:{int(sec) % 60:02d}" if sec else "00:00",
"cover": entry.get("thumbnail"),
"url": url,
"explicit_status": explicit_status,
"version": None,
"preview_url": None
})
except Exception as e:
print(f"SoundCloud search error: {e}")
return {"tracks": tracks, "albums": []}
async def search_youtube(query: str) -> Dict[str, List[Dict[str, Any]]]:
"""Search tracks via YouTube Music / YouTube."""
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,
"ignoreerrors": True,
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(f"ytsearch5:{query}", download=False)
if info and "entries" in info:
for entry in info["entries"]:
if not entry:
continue
sec = entry.get("duration", 0)
title_raw = entry.get("title") or ""
uploader_raw = entry.get("uploader") or entry.get("channel") or "Unknown"
title_lower = title_raw.lower()
if any(w in title_lower for w in ["explicit", "uncensored", "18+"]):
explicit_status = "explicit"
elif any(w in title_lower for w in ["clean", "censored", "radio edit", "радиоверсия"]):
explicit_status = "clean"
else:
explicit_status = "neutral"
artist = uploader_raw
title = title_raw
if " - " in title_raw:
parts = title_raw.split(" - ", 1)
artist = parts[0].strip()
title = parts[1].strip()
tracks.append({
"service": "youtube",
"service_name": "YouTube",
"id": str(entry.get("id")),
"title": title,
"artist": artist,
"album": "YouTube Audio",
"year": None,
"duration": f"{int(sec) // 60:02d}:{int(sec) % 60:02d}" if sec else "00:00",
"cover": entry.get("thumbnail"),
"url": f"https://www.youtube.com/watch?v={entry.get('id')}",
"explicit_status": explicit_status,
"version": None,
"preview_url": None
})
except Exception as e:
print(f"YouTube search error: {e}")
return {"tracks": tracks, "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
track_explicitness = (r.get("trackExplicitness") or "").lower()
if track_explicitness == "explicit":
explicit_status = "explicit"
elif track_explicitness == "cleaned":
explicit_status = "clean"
else:
explicit_status = "neutral"
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"),
"explicit_status": explicit_status,
"version": "30с превью",
"preview_url": r.get("previewUrl")
})
except Exception as e:
print(f"Apple search error: {e}")
return {"tracks": tracks, "albums": albums}
async def search_spotify(query: str) -> Dict[str, List[Dict[str, Any]]]:
"""Search tracks and albums via Spotify."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, _search_spotify_sync, query)
def _search_spotify_sync(query: str) -> Dict[str, List[Dict[str, Any]]]:
tracks = []
albums = []
try:
from spotify_scraper import SpotifyClient
with SpotifyClient() as client:
res = client.search(query, limit=8)
if hasattr(res, "tracks") and res.tracks:
for t in res.tracks:
if not t:
continue
artists = ", ".join(a.name for a in t.artists) if t.artists else "Unknown"
cover = None
if hasattr(t, "images") and t.images:
img = t.images[0]
cover = getattr(img, "url", None) or (img.get("url") if isinstance(img, dict) else None)
elif hasattr(t, "album") and t.album and getattr(t.album, "images", None):
img = t.album.images[0]
cover = getattr(img, "url", None) or (img.get("url") if isinstance(img, dict) else None)
album_title = getattr(t.album, "name", "Spotify Single") if getattr(t, "album", None) else "Spotify Single"
explicit_status = "explicit" if getattr(t, "explicit", False) else "neutral"
track_url = getattr(t, "url", None) or f"https://open.spotify.com/track/{t.id}"
tracks.append({
"service": "spotify",
"service_name": "Spotify",
"id": str(t.id),
"title": t.name,
"artist": artists,
"album": album_title,
"year": None,
"duration": format_duration(getattr(t, "duration_ms", None)),
"cover": cover,
"url": track_url,
"explicit_status": explicit_status,
"version": None,
"preview_url": getattr(t, "preview_url", None)
})
if hasattr(res, "albums") and res.albums:
for a in res.albums[:4]:
if not a:
continue
cover = None
if hasattr(a, "images") and a.images:
img = a.images[0]
cover = getattr(img, "url", None) or (img.get("url") if isinstance(img, dict) else None)
album_url = getattr(a, "url", None) or f"https://open.spotify.com/album/{a.id}"
artist_name = "Spotify"
if hasattr(a, "artists") and a.artists:
artist_name = ", ".join(art.name for art in a.artists)
albums.append({
"service": "spotify",
"service_name": "Spotify",
"id": str(a.id),
"title": a.name,
"artist": artist_name,
"year": None,
"track_count": getattr(a, "total_tracks", None),
"cover": cover,
"url": album_url
})
except Exception as e:
print(f"Spotify search error: {e}")
return {"tracks": tracks, "albums": albums}
async def search_all(query: str) -> Dict[str, Any]:
"""Run concurrent search across all platforms (Spotify preferred) with relevance ranking."""
sp_task = search_spotify(query)
y_task = search_yandex(query)
sc_task = search_soundcloud(query)
yt_task = search_youtube(query)
a_task = search_apple(query)
sp_res, y_res, sc_res, yt_res, a_res = await asyncio.gather(
sp_task, y_task, sc_task, yt_task, a_task, return_exceptions=True
)
all_tracks = []
all_albums = []
for res in (sp_res, y_res, sc_res, yt_res, a_res):
if isinstance(res, dict):
all_tracks.extend(res.get("tracks", []))
all_albums.extend(res.get("albums", []))
# Calculate relevance score for each track
for t in all_tracks:
t["relevance_score"] = round(calculate_track_relevance(query, t), 2)
# Sort tracks descending by relevance score (Spotify prioritized at top)
all_tracks.sort(key=lambda t: t.get("relevance_score", 0), reverse=True)
# Sort albums by relevance as well
if all_albums:
for al in all_albums:
al["relevance_score"] = round(calculate_track_relevance(query, al), 2)
all_albums.sort(key=lambda a: a.get("relevance_score", 0), reverse=True)
return {
"query": query,
"tracks": all_tracks,
"albums": all_albums
}