diff --git a/README.md b/README.md index 8e781a8..43fd082 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ - **Обход цензуры и региональных блокировок**: - Если трек в каталоге РФ заблокирован или зацензурен («запикан»), движок автоматически переключается на глобальные источники и скачивает полноценную **Explicit (18+)** версию. - **Мультисервисный поиск & Предпрослушивание**: - - Параллельный поиск по **Яндекс Музыке**, **SoundCloud**, **YouTube** и **Apple Music**. + - Параллельный поиск по **Spotify** (приоритетный источник), **Яндекс Музыке**, **SoundCloud**, **YouTube** и **Apple Music**. + - **Приоритет Spotify**: треки и альбомы со Spotify получают бонус релевантности и всегда выводятся первыми в выдаче. - Сквозное ранжирование по точности совпадения: лучшие совпадения сразу со всех платформ показываются вверху. - Определение цензуры: бейджи `🔞 18+ Explicit` (без цензуры) и `⚠️ Clean` (зацензуренная версия). - Встроенный аудиоплеер предпрослушивания с возможностью перемотки на лету (**HTTP Range 206**). diff --git a/app/core/search.py b/app/core/search.py index 9886b01..eada58c 100644 --- a/app/core/search.py +++ b/app/core/search.py @@ -64,6 +64,10 @@ def calculate_track_relevance(query: str, item: Dict[str, Any]) -> float: 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 @@ -302,21 +306,94 @@ async def search_apple(query: str) -> Dict[str, List[Dict[str, Any]]]: 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 with relevance ranking.""" + """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) - y_res, sc_res, yt_res, a_res = await asyncio.gather( - y_task, sc_task, yt_task, a_task, return_exceptions=True + 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 (y_res, sc_res, yt_res, a_res): + 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", [])) @@ -325,8 +402,7 @@ async def search_all(query: str) -> Dict[str, Any]: for t in all_tracks: t["relevance_score"] = round(calculate_track_relevance(query, t), 2) - # Sort tracks descending by relevance score: - # Tracks with the highest match (e.g. found on Yandex, SoundCloud, YouTube) appear at the top! + # 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 diff --git a/app/providers/universal.py b/app/providers/universal.py index df632d8..df5b7ef 100644 --- a/app/providers/universal.py +++ b/app/providers/universal.py @@ -30,9 +30,9 @@ class UniversalProvider(BaseProvider): def _resolve_sync(self, url: str) -> List[TrackMetadata]: lower = url.lower() - # Handle Spotify via open oEmbed API & yt-dlp fallback + # Handle Spotify via spotifyscraper & open oEmbed fallback if "spotify.com" in lower: - tracks = self._resolve_spotify_oembed(url) + tracks = self._resolve_spotify(url) if tracks: return tracks @@ -45,6 +45,86 @@ class UniversalProvider(BaseProvider): # Default: Handle YouTube / YouTube Music and other generic links via yt-dlp return self._resolve_ytdlp(url) + def _resolve_spotify(self, url: str) -> List[TrackMetadata]: + tracks: List[TrackMetadata] = [] + try: + from spotify_scraper import SpotifyClient + m_track = re.search(r'track/([a-zA-Z0-9]+)', url) + m_album = re.search(r'album/([a-zA-Z0-9]+)', url) + m_playlist = re.search(r'playlist/([a-zA-Z0-9]+)', url) + + with SpotifyClient() as client: + if m_track: + t = client.get_track(m_track.group(1)) + if t: + artist = ", ".join(a.name for a in t.artists) if t.artists else "Unknown" + album = t.album.name if (t.album and hasattr(t.album, "name")) else "Singles" + cover = None + if hasattr(t, "images") and t.images: + cover = getattr(t.images[0], "url", None) + elif hasattr(t, "album") and t.album and getattr(t.album, "images", None): + cover = getattr(t.album.images[0], "url", None) + + tracks.append(TrackMetadata( + service="spotify", + service_id=str(t.id), + title=t.name, + artist=artist, + album=album, + duration_ms=getattr(t, "duration_ms", None), + cover_url=cover, + is_explicit=getattr(t, "explicit", False), + source_url=t.url or url + )) + return tracks + + elif m_album: + alb = client.get_album(m_album.group(1)) + if alb and hasattr(alb, "tracks"): + cover = getattr(alb.images[0], "url", None) if hasattr(alb, "images") and alb.images else None + for idx, t in enumerate(alb.tracks, 1): + artist = ", ".join(a.name for a in t.artists) if t.artists else "Unknown" + tracks.append(TrackMetadata( + service="spotify", + service_id=str(t.id), + title=t.name, + artist=artist, + album=alb.name, + track_number=idx, + duration_ms=getattr(t, "duration_ms", None), + cover_url=cover, + is_explicit=getattr(t, "explicit", False), + source_url=t.url or url + )) + return tracks + + elif m_playlist: + pl = client.get_playlist(m_playlist.group(1)) + if pl and hasattr(pl, "tracks"): + cover = getattr(pl.images[0], "url", None) if hasattr(pl, "images") and pl.images else None + for idx, t in enumerate(pl.tracks, 1): + artist = ", ".join(a.name for a in t.artists) if t.artists else "Unknown" + album = getattr(t.album, "name", pl.name) if getattr(t, "album", None) else pl.name + tracks.append(TrackMetadata( + service="spotify", + service_id=str(t.id), + title=t.name, + artist=artist, + album=album, + track_number=idx, + duration_ms=getattr(t, "duration_ms", None), + cover_url=cover, + is_explicit=getattr(t, "explicit", False), + source_url=t.url or url + )) + return tracks + + except Exception as e: + print(f"Spotify scraper resolution failed: {e}") + + # Fallback to open oembed if scraper failed + return self._resolve_spotify_oembed(url) + def _resolve_spotify_oembed(self, url: str) -> List[TrackMetadata]: try: oembed_url = f"https://open.spotify.com/oembed?url={url}" @@ -52,9 +132,7 @@ class UniversalProvider(BaseProvider): resp = client.get(oembed_url) if resp.status_code == 200: data = resp.json() - # data has title ("Track Name" or "Track Name - Single"), thumbnail_url full_title = data.get("title", "Unknown") - # Spotfiy oEmbed titles are often "Title" or "Artist - Title" artist = "Unknown" title = full_title if " - " in full_title: diff --git a/app/templates/index.html b/app/templates/index.html index 76f037a..e724b92 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -58,7 +58,7 @@
diff --git a/app/wave/recommender.py b/app/wave/recommender.py index 1d65f11..e7d250d 100644 --- a/app/wave/recommender.py +++ b/app/wave/recommender.py @@ -127,6 +127,43 @@ class WaveRecommender: except Exception as e: print(f"[WaveRecommender] Error processing anchor {anchor_name}: {e}") - # Randomize candidates order so the playlist feels fresh - random.shuffle(candidates) + # Sourcing C: Spotify top tracks for anchor artists (User preference: Spotify) + try: + from spotify_scraper import SpotifyClient + with SpotifyClient() as sp_client: + for anchor_name in selected_anchors: + try: + sp_search = sp_client.search(anchor_name, types=["artist"], limit=1) + if hasattr(sp_search, "artists") and sp_search.artists: + sp_art = sp_client.get_artist(sp_search.artists[0].id) + if hasattr(sp_art, "top_tracks") and sp_art.top_tracks: + for st in sp_art.top_tracks[:5]: + st_art = ", ".join(a.name for a in st.artists) if st.artists else anchor_name + key = f"{_normalize(st_art)}|{_normalize(st.name)}" + if key in existing_keys or key in wave_keys or key in seen_keys: + continue + cover = None + if hasattr(st, "images") and st.images: + img = st.images[0] + cover = getattr(img, "url", None) or (img.get("url") if isinstance(img, dict) else None) + album_name = getattr(st.album, "name", "Singles") if getattr(st, "album", None) else "Singles" + meta = TrackMetadata( + service="spotify", + service_id=str(st.id), + title=st.name, + artist=st_art, + album=album_name, + duration_ms=getattr(st, "duration_ms", None), + cover_url=cover, + is_explicit=getattr(st, "explicit", False), + source_url=getattr(st, "url", None) or f"https://open.spotify.com/track/{st.id}" + ) + seen_keys.add(key) + # Insert Spotify tracks at the beginning so they are preferred + candidates.insert(0, meta) + except Exception as err: + print(f"[WaveRecommender] Spotify sourcing notice for {anchor_name}: {err}") + except Exception as e: + print(f"[WaveRecommender] Spotify client notice: {e}") + return candidates[:target_count] diff --git a/requirements.txt b/requirements.txt index 08e3f73..6d3ce00 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ aiofiles>=24.1.0 httpx>=0.27.0 jinja2>=3.1.0 python-multipart>=0.0.12 +spotifyscraper>=3.9.0