diff --git a/backend/main.py b/backend/main.py index fac4330..88b799b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -35,12 +35,17 @@ class DownloadRequest(BaseModel): # ── Music search ────────────────────────────────────────────────────────────── @app.get("/api/search/music") -async def search_music(q: str = Query(..., min_length=1)) -> list[dict[str, Any]]: - """Search iTunes for song metadata.""" +async def search_music( + q: str = Query(..., min_length=1), + source: str = Query("itunes"), +) -> list[dict[str, Any]]: + """Search for song metadata from the selected source (iTunes or Deezer).""" + if source not in music_search.SOURCES: + raise HTTPException(status_code=400, detail=f"Unknown source: {source}") try: - return await music_search.search_music(q) + return await music_search.search_music(q, source=source) except Exception as exc: - raise HTTPException(status_code=502, detail=f"iTunes search failed: {exc}") + raise HTTPException(status_code=502, detail=f"{source} search failed: {exc}") # ── YouTube search ──────────────────────────────────────────────────────────── diff --git a/backend/search.py b/backend/search.py index 9d89907..21ae278 100644 --- a/backend/search.py +++ b/backend/search.py @@ -1,15 +1,29 @@ """ -Music metadata search via the iTunes Search API. -Free, no auth, returns track info + album art URLs. +Music metadata search across multiple sources (iTunes, Deezer). +Both are free and require no API key. """ -import httpx +import asyncio from typing import Any +import httpx + ITUNES_URL = "https://itunes.apple.com/search" +DEEZER_SEARCH_URL = "https://api.deezer.com/search" +DEEZER_ALBUM_URL = "https://api.deezer.com/album" + +SOURCES = ("itunes", "deezer") -async def search_music(query: str, limit: int = 10) -> list[dict[str, Any]]: +async def search_music(query: str, source: str = "itunes", limit: int = 10) -> list[dict[str, Any]]: + if source == "deezer": + return await _search_deezer(query, limit) + return await _search_itunes(query, limit) + + +# ── iTunes ─────────────────────────────────────────────────────────────────── + +async def _search_itunes(query: str, limit: int) -> list[dict[str, Any]]: params = { "term": query, "media": "music", @@ -43,3 +57,57 @@ async def search_music(query: str, limit: int = 10) -> list[dict[str, Any]]: }) return results + + +# ── Deezer ─────────────────────────────────────────────────────────────────── + +async def _search_deezer(query: str, limit: int) -> list[dict[str, Any]]: + async with httpx.AsyncClient(timeout=10) as client: + response = await client.get(DEEZER_SEARCH_URL, params={"q": query, "limit": limit}) + response.raise_for_status() + tracks = response.json().get("data", []) + + # Deezer's search results don't include genre/release date/track number — + # those live on the album, so fetch each unique album in parallel. + album_ids = {t["album"]["id"] for t in tracks if t.get("album")} + albums = await asyncio.gather( + *(_fetch_deezer_album(client, aid) for aid in album_ids), + return_exceptions=True, + ) + album_map = {a["id"]: a for a in albums if isinstance(a, dict) and "id" in a} + + results = [] + for item in tracks: + album = item.get("album") or {} + album_info = album_map.get(album.get("id"), {}) + + genres = album_info.get("genres", {}).get("data", []) + release_date = album_info.get("release_date", "") + + results.append({ + "trackId": item.get("id"), + "trackName": item.get("title", ""), + "artistName": (item.get("artist") or {}).get("name", ""), + "collectionName": album.get("title", ""), + "trackNumber": _deezer_track_position(album_info, item.get("id")), + "discNumber": None, + "releaseDate": release_date[:4] if release_date else "", + "genre": genres[0]["name"] if genres else "", + "artworkUrl": album.get("cover_xl") or album.get("cover_big") or "", + "previewUrl": item.get("preview", ""), + }) + + return results + + +async def _fetch_deezer_album(client: httpx.AsyncClient, album_id: int) -> dict[str, Any]: + response = await client.get(f"{DEEZER_ALBUM_URL}/{album_id}") + response.raise_for_status() + return response.json() + + +def _deezer_track_position(album_info: dict[str, Any], track_id: Any) -> int | None: + for track in album_info.get("tracks", {}).get("data", []): + if track.get("id") == track_id: + return track.get("track_position") + return None diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 2615cf7..4727ce5 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,12 +1,12 @@ import type { - MusicResult, YouTubeResult, DownloadRequest, + MusicResult, MusicSource, YouTubeResult, DownloadRequest, DownloadStatus, Artist, } from './types'; const BASE = '/api'; -export async function searchMusic(query: string): Promise { - const res = await fetch(`${BASE}/search/music?q=${encodeURIComponent(query)}`); +export async function searchMusic(query: string, source: MusicSource = 'itunes'): Promise { + const res = await fetch(`${BASE}/search/music?q=${encodeURIComponent(query)}&source=${source}`); if (!res.ok) throw new Error(await res.text()); return res.json(); } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index bd3e6c8..9de4b74 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -1,3 +1,5 @@ +export type MusicSource = 'itunes' | 'deezer'; + export interface MusicResult { trackId: number; trackName: string; diff --git a/frontend/src/components/MusicSearch.tsx b/frontend/src/components/MusicSearch.tsx index 5febad6..3f0c65c 100644 --- a/frontend/src/components/MusicSearch.tsx +++ b/frontend/src/components/MusicSearch.tsx @@ -1,26 +1,31 @@ import { useState, useRef } from 'react'; import { Search, Loader2, Music } from 'lucide-react'; import { searchMusic } from '../api/client'; -import type { MusicResult } from '../api/types'; +import type { MusicResult, MusicSource } from '../api/types'; interface Props { onSelect: (track: MusicResult) => void; } +const SOURCES: { value: MusicSource; label: string }[] = [ + { value: 'itunes', label: 'iTunes' }, + { value: 'deezer', label: 'Deezer' }, +]; + export default function MusicSearch({ onSelect }: Props) { const [query, setQuery] = useState(''); + const [source, setSource] = useState('itunes'); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const inputRef = useRef(null); - async function handleSearch(e: React.FormEvent) { - e.preventDefault(); - if (!query.trim()) return; + async function runSearch(q: string, src: MusicSource) { + if (!q.trim()) return; setLoading(true); setError(''); try { - const data = await searchMusic(query.trim()); + const data = await searchMusic(q.trim(), src); setResults(data); if (data.length === 0) setError('No results found. Try a different search.'); } catch { @@ -30,9 +35,30 @@ export default function MusicSearch({ onSelect }: Props) { } } + function handleSearch(e: React.FormEvent) { + e.preventDefault(); + runSearch(query, source); + } + + function handleSourceChange(e: React.ChangeEvent) { + const next = e.target.value as MusicSource; + setSource(next); + if (query.trim() && results.length > 0) runSearch(query, next); + } + return (
+