Added Deezer sounce for searching

This commit is contained in:
2026-07-30 08:31:47 -04:00
parent e5643590ee
commit ed34321168
6 changed files with 129 additions and 16 deletions

View File

@@ -35,12 +35,17 @@ class DownloadRequest(BaseModel):
# ── Music search ────────────────────────────────────────────────────────────── # ── Music search ──────────────────────────────────────────────────────────────
@app.get("/api/search/music") @app.get("/api/search/music")
async def search_music(q: str = Query(..., min_length=1)) -> list[dict[str, Any]]: async def search_music(
"""Search iTunes for song metadata.""" 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: try:
return await music_search.search_music(q) return await music_search.search_music(q, source=source)
except Exception as exc: 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 ──────────────────────────────────────────────────────────── # ── YouTube search ────────────────────────────────────────────────────────────

View File

@@ -1,15 +1,29 @@
""" """
Music metadata search via the iTunes Search API. Music metadata search across multiple sources (iTunes, Deezer).
Free, no auth, returns track info + album art URLs. Both are free and require no API key.
""" """
import httpx import asyncio
from typing import Any from typing import Any
import httpx
ITUNES_URL = "https://itunes.apple.com/search" 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 = { params = {
"term": query, "term": query,
"media": "music", "media": "music",
@@ -43,3 +57,57 @@ async def search_music(query: str, limit: int = 10) -> list[dict[str, Any]]:
}) })
return results 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

View File

@@ -1,12 +1,12 @@
import type { import type {
MusicResult, YouTubeResult, DownloadRequest, MusicResult, MusicSource, YouTubeResult, DownloadRequest,
DownloadStatus, Artist, DownloadStatus, Artist,
} from './types'; } from './types';
const BASE = '/api'; const BASE = '/api';
export async function searchMusic(query: string): Promise<MusicResult[]> { export async function searchMusic(query: string, source: MusicSource = 'itunes'): Promise<MusicResult[]> {
const res = await fetch(`${BASE}/search/music?q=${encodeURIComponent(query)}`); const res = await fetch(`${BASE}/search/music?q=${encodeURIComponent(query)}&source=${source}`);
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
return res.json(); return res.json();
} }

View File

@@ -1,3 +1,5 @@
export type MusicSource = 'itunes' | 'deezer';
export interface MusicResult { export interface MusicResult {
trackId: number; trackId: number;
trackName: string; trackName: string;

View File

@@ -1,26 +1,31 @@
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { Search, Loader2, Music } from 'lucide-react'; import { Search, Loader2, Music } from 'lucide-react';
import { searchMusic } from '../api/client'; import { searchMusic } from '../api/client';
import type { MusicResult } from '../api/types'; import type { MusicResult, MusicSource } from '../api/types';
interface Props { interface Props {
onSelect: (track: MusicResult) => void; onSelect: (track: MusicResult) => void;
} }
const SOURCES: { value: MusicSource; label: string }[] = [
{ value: 'itunes', label: 'iTunes' },
{ value: 'deezer', label: 'Deezer' },
];
export default function MusicSearch({ onSelect }: Props) { export default function MusicSearch({ onSelect }: Props) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [source, setSource] = useState<MusicSource>('itunes');
const [results, setResults] = useState<MusicResult[]>([]); const [results, setResults] = useState<MusicResult[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
async function handleSearch(e: React.FormEvent) { async function runSearch(q: string, src: MusicSource) {
e.preventDefault(); if (!q.trim()) return;
if (!query.trim()) return;
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const data = await searchMusic(query.trim()); const data = await searchMusic(q.trim(), src);
setResults(data); setResults(data);
if (data.length === 0) setError('No results found. Try a different search.'); if (data.length === 0) setError('No results found. Try a different search.');
} catch { } 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<HTMLSelectElement>) {
const next = e.target.value as MusicSource;
setSource(next);
if (query.trim() && results.length > 0) runSearch(query, next);
}
return ( return (
<div> <div>
<form onSubmit={handleSearch} className="search-form"> <form onSubmit={handleSearch} className="search-form">
<select
className="source-select"
value={source}
onChange={handleSourceChange}
aria-label="Metadata source"
>
{SOURCES.map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
<div className="search-input-wrap"> <div className="search-input-wrap">
<Search size={18} className="search-icon" /> <Search size={18} className="search-icon" />
<input <input

View File

@@ -145,6 +145,18 @@ button { font-family: inherit; cursor: pointer; }
} }
.search-input:focus { border-color: var(--accent); } .search-input:focus { border-color: var(--accent); }
.source-select {
padding: 9px 10px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 13px;
outline: none;
transition: border-color 0.15s;
}
.source-select:focus { border-color: var(--accent); }
/* ── Buttons ──────────────────────────────────────────────────── */ /* ── Buttons ──────────────────────────────────────────────────── */
.btn-primary { .btn-primary {
display: inline-flex; display: inline-flex;