Added Deezer sounce for searching
This commit is contained in:
@@ -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 ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export type MusicSource = 'itunes' | 'deezer';
|
||||||
|
|
||||||
export interface MusicResult {
|
export interface MusicResult {
|
||||||
trackId: number;
|
trackId: number;
|
||||||
trackName: string;
|
trackName: string;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user