refactor(providers): remove the unused Deezer catalogue search

DeezerClient::search_all was reachable only through
Backend::search_deezer, which had no production caller: no UniFFI
export, platform channel or extension API wraps it, and no built-in
Deezer catalogue search is planned. Remove the chain together with the
items that existed only for it: the core search result types, the Deezer
search page types and their API error wrappers, the cached Search value,
and the latency test that exercised the search fan-out.

The Search cache bucket stays because the coalesced Deezer lookups use
it. The shared error context helper moves into the deezer module now
that search.rs is gone. searchDeezerByISRC is a separate, live method
and is untouched.
This commit is contained in:
zarzet committed 2026-10-18 22:22:57 +07:00
1 parent d60baee4a7
commit 2debaa5363
8 files changed
+12 -432

No files matched your search

@@ -1,4 +1,4 @@
use super::{TrackMetadata, zero};
use super::zero;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -121,39 +121,6 @@ pub struct ArtistResponsePayload {
pub albums: Vec<ArtistAlbumMetadata>,
}
pub type SearchArtistResult = ArtistInfoMetadata;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SearchAlbumResult {
pub id: String,
pub name: String,
pub artists: String,
pub images: String,
pub release_date: String,
pub total_tracks: isize,
pub album_type: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SearchPlaylistResult {
pub id: String,
pub name: String,
pub owner: String,
pub images: String,
pub total_tracks: isize,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SearchAllResult {
pub tracks: Vec<TrackMetadata>,
pub artists: Vec<SearchArtistResult>,
pub albums: Vec<SearchAlbumResult>,
pub playlists: Vec<SearchPlaylistResult>,
}
// Go's internal extended-metadata response has exported, untagged fields.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "PascalCase")]
@@ -1,48 +1,5 @@
use super::{Artist, Track, Tracks, best_image};
use crate::lyrics::json::{Update, go_deserialize};
use serde::Deserialize;
#[derive(Clone, Debug, Default)]
pub struct ApiError {
pub error_type: String,
pub message: String,
pub code: isize,
}
go_deserialize!(ApiError { "type" => error_type, "message" => message, "code" => code, });
/// A repeated non-null Go pointer field merges into the existing object.
#[derive(Clone, Debug, Default)]
pub struct OptionalApiError(pub Option<ApiError>);
impl<'de> Update<'de> for OptionalApiError {
fn update<D: serde::Deserializer<'de>>(&mut self, decoder: D) -> Result<(), D::Error> {
match Option::<&serde_json::value::RawValue>::deserialize(decoder)? {
None => self.0 = None,
Some(raw) => {
self.0
.get_or_insert_default()
.update(&mut serde_json::Deserializer::from_str(raw.get()))
.map_err(serde::de::Error::custom)?;
}
}
Ok(())
}
}
macro_rules! search_page {
($name:ident, $item:ty) => {
#[derive(Clone, Debug, Default)]
pub struct $name {
pub data: Option<Vec<$item>>,
pub error: OptionalApiError,
}
go_deserialize!($name { "data" => data, "error" => error, });
};
}
search_page!(TrackSearch, Track);
search_page!(ArtistSearch, Artist);
search_page!(AlbumSearch, SearchAlbum);
search_page!(PlaylistSearch, SearchPlaylist);
use crate::lyrics::json::go_deserialize;
#[derive(Clone, Debug, Default)]
pub struct TrackPage {
@@ -142,25 +99,6 @@ go_deserialize!(FullPlaylist {
"creator" => creator, "tracks" => tracks,
});
#[derive(Clone, Debug, Default)]
pub struct SearchAlbum {
pub id: i64,
pub title: String,
pub cover: String,
pub cover_medium: String,
pub cover_big: String,
pub cover_xl: String,
pub nb_tracks: isize,
pub release_date: String,
pub record_type: String,
pub artist: Artist,
}
go_deserialize!(SearchAlbum {
"id" => id, "title" => title, "cover" => cover, "cover_medium" => cover_medium,
"cover_big" => cover_big, "cover_xl" => cover_xl, "nb_tracks" => nb_tracks,
"release_date" => release_date, "record_type" => record_type, "artist" => artist,
});
#[derive(Clone, Debug, Default)]
pub struct ArtistAlbum {
pub id: i64,
@@ -191,22 +129,6 @@ pub struct AlbumTrackCount {
}
go_deserialize!(AlbumTrackCount { "nb_tracks" => nb_tracks, });
#[derive(Clone, Debug, Default)]
pub struct SearchPlaylist {
pub id: i64,
pub title: String,
pub picture: String,
pub picture_medium: String,
pub picture_big: String,
pub picture_xl: String,
pub nb_tracks: isize,
pub user: Name,
}
go_deserialize!(SearchPlaylist {
"id" => id, "title" => title, "picture" => picture, "picture_medium" => picture_medium,
"picture_big" => picture_big, "picture_xl" => picture_xl, "nb_tracks" => nb_tracks, "user" => user,
});
macro_rules! cover_image {
($($name:ty),*) => {$(
impl $name {
@@ -216,7 +138,7 @@ macro_rules! cover_image {
}
)*};
}
cover_image!(FullAlbum, SearchAlbum, ArtistAlbum);
cover_image!(FullAlbum, ArtistAlbum);
impl FullArtist {
pub fn image(&self) -> String {
@@ -228,14 +150,3 @@ impl FullArtist {
])
}
}
impl SearchPlaylist {
pub fn image(&self) -> String {
best_image([
self.picture_xl.as_str(),
&self.picture_big,
&self.picture_medium,
&self.picture,
])
}
}
@@ -391,110 +391,6 @@ impl Drop for GatedServer {
}
}
#[test]
fn catalog_search_gates_required_tracks_then_overlaps_optional_categories() {
for mode in ["all", "artist", "track_failure"] {
let (network, config) = network();
let track = if mode == "track_failure" {
r#"{"error":{"code":100,"message":"required track failure"}}"#
} else {
r#"{"data":[{"id":1,"title":"Track"}]}"#
};
let artist = if mode == "artist" {
r#"{"data":[{"id":2,"name":"Artist"}]}"#
} else {
r#"{"error":{"code":100,"message":"optional artist failure"}}"#
};
let mut server = GatedServer::new(
config,
&[
("/2.0/search/track", 200, track),
("/2.0/search/artist", 200, artist),
(
"/2.0/search/album",
200,
r#"{"data":[{"id":3,"title":"Album"}]}"#,
),
(
"/2.0/search/playlist",
200,
r#"{"data":[{"id":4,"title":"Playlist"}]}"#,
),
],
);
let directory = tempfile::tempdir().unwrap();
let backend = backend(
directory.path(),
server.url.clone(),
server.url.clone(),
network,
);
let active = backend.clone();
let worker = thread::spawn(move || {
active.search_deezer(
"needle",
2,
2,
if mode == "artist" { "artist" } else { "" },
&|| Ok(()),
)
});
let (first, release) = server.next();
assert_eq!(
first,
if mode == "artist" {
"/2.0/search/artist"
} else {
"/2.0/search/track"
}
);
assert!(matches!(
server.started.recv_timeout(Duration::from_millis(100)),
Err(mpsc::RecvTimeoutError::Timeout)
));
release.send(()).unwrap();
if mode == "all" {
let mut pending = Vec::new();
for _ in 0..3 {
pending.push(server.next());
}
let mut categories: Vec<_> = pending.iter().map(|(path, _)| path.as_str()).collect();
categories.sort();
assert_eq!(
categories,
[
"/2.0/search/album",
"/2.0/search/artist",
"/2.0/search/playlist"
]
);
for (_, release) in pending {
release.send(()).unwrap();
}
}
let result = worker.join().unwrap();
if mode == "track_failure" {
assert!(result.unwrap_err().contains("required track failure"));
} else {
let value: Value = serde_json::from_str(&result.unwrap()).unwrap();
if mode == "all" {
assert_eq!(value["tracks"][0]["spotify_id"], "deezer:1");
assert_eq!(value["artists"], json!([]));
assert_eq!(value["albums"][0]["id"], "deezer:3");
assert_eq!(value["playlists"][0]["id"], "deezer:4");
} else {
assert_eq!(value["artists"][0]["id"], "deezer:2");
assert_eq!(value["tracks"], json!([]));
assert_eq!(value["albums"], json!([]));
assert_eq!(value["playlists"], json!([]));
}
}
let paths = server.finish();
assert_eq!(paths.len(), if mode == "all" { 4 } else { 1 });
backend.shutdown();
}
}
#[test]
fn reenrich_cover_and_lyrics_overlap_preserve_best_effort_results_and_cancel() {
for mode in [
@@ -108,23 +108,6 @@ impl Backend {
Ok(())
}
pub fn search_deezer(
&self,
query: &str,
track_limit: isize,
artist_limit: isize,
filter: &str,
check: &Check<'_>,
) -> Result<String, String> {
self.metadata_operation(30, check, |check| {
encode(
self.deezer
.search_all(query, track_limit, artist_limit, filter, check)?
.as_ref(),
)
})
}
pub(super) fn deezer_metadata(
&self,
kind: &str,
+7 -1
View File
@@ -3,7 +3,6 @@
mod browse;
mod cache;
mod extended;
mod search;
use crate::resolver::{Check, ResolverError, http::ResolverHttp};
use serde::de::DeserializeOwned;
@@ -179,6 +178,13 @@ pub fn retry_delay(value: &str, now: SystemTime) -> Duration {
.min(Duration::from_secs(120))
}
fn context(error: ResolverError, message: &str) -> ResolverError {
match error {
ResolverError::Cancelled(_) | ResolverError::Closed | ResolverError::Busy => error,
_ => ResolverError::Failed(format!("{message}: {error}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1,6 +1,6 @@
use super::{Check, DeezerClient, ResolverError};
use spotiflac_core::metadata::{
AlbumExtendedMetadata, AlbumResponsePayload, ArtistResponsePayload, SearchAllResult,
AlbumExtendedMetadata, AlbumResponsePayload, ArtistResponsePayload,
};
use std::collections::BTreeMap;
use std::panic::{AssertUnwindSafe, catch_unwind};
@@ -9,7 +9,6 @@ use std::time::{Duration, Instant};
#[derive(Clone)]
pub(super) enum Value {
Search(Arc<SearchAllResult>),
Album(Arc<AlbumResponsePayload>),
Artist(Arc<ArtistResponsePayload>),
Extended(Arc<AlbumExtendedMetadata>),
@@ -1,4 +1,4 @@
use super::{Check, DeezerClient, MetadataLookup, ResolverError, cache::Value, search::context};
use super::{Check, DeezerClient, MetadataLookup, ResolverError, cache::Value, context};
use spotiflac_core::metadata::{
AlbumExtendedMetadata, TrackMetadata,
deezer::{FullAlbum, Track},
@@ -1,182 +0,0 @@
use super::{
Check, DeezerClient, ResolverError,
cache::{Bucket, Value},
};
use spotiflac_core::metadata::{
SearchAlbumResult, SearchAllResult, SearchArtistResult, SearchPlaylistResult,
deezer::{self, AlbumSearch, ArtistSearch, PlaylistSearch, TrackSearch},
};
use spotiflac_network::query;
use std::sync::Arc;
impl DeezerClient {
pub fn search_all(
&self,
query: &str,
track_limit: isize,
artist_limit: isize,
filter: &str,
check: &Check<'_>,
) -> Result<Arc<SearchAllResult>, ResolverError> {
check().map_err(ResolverError::Cancelled)?;
let (tracks, artists, albums, playlists) = match filter {
"track" => (50, 0, 0, 0),
"artist" => (0, 20, 0, 0),
"album" => (0, 0, 20, 0),
"playlist" => (0, 0, 0, 20),
_ => (track_limit, artist_limit, 5, 5),
};
if tracks < 0 || artists < 0 {
return Err(ResolverError::Failed("negative search limit".into()));
}
let key = format!("deezer:all:{query}:{tracks}:{artists}:{albums}:{playlists}:{filter}");
if let Some(Value::Search(result)) = self.cached(Bucket::Search, &key) {
return Ok(result);
}
let mut parameters = query::Query::new();
query::set(&mut parameters, "q", query);
let encoded = query::encode(&parameters);
let endpoint = |kind: &str, limit: isize| {
format!("https://api.deezer.com/2.0/search/{kind}?{encoded}&limit={limit}")
};
let mut result = SearchAllResult::default();
if tracks > 0 {
let response: TrackSearch = self
.get_json(&endpoint("track", tracks), check)
.map_err(|error| context(error, "deezer track search failed"))?;
if let Some(error) = response.error.0 {
return Err(ResolverError::Failed(format!(
"deezer API error: {} (code {})",
error.message, error.code
)));
}
result.tracks = response
.data
.unwrap_or_default()
.iter()
.map(|track| track.metadata())
.collect();
}
let fetch_artists = || {
(artists > 0)
.then(|| self.get_json::<ArtistSearch>(&endpoint("artist", artists), check))
};
let fetch_albums = || {
(albums > 0).then(|| self.get_json::<AlbumSearch>(&endpoint("album", albums), check))
};
let fetch_playlists = || {
(playlists > 0)
.then(|| self.get_json::<PlaylistSearch>(&endpoint("playlist", playlists), check))
};
let parallel = [artists, albums, playlists]
.into_iter()
.filter(|n| *n > 0)
.count()
> 1;
let (artist_response, album_response, playlist_response) = std::thread::scope(|scope| {
let artist = (parallel && artists > 0).then(|| {
std::thread::Builder::new()
.name("search-artists".into())
.spawn_scoped(scope, fetch_artists)
});
let album = (parallel && albums > 0).then(|| {
std::thread::Builder::new()
.name("search-albums".into())
.spawn_scoped(scope, fetch_albums)
});
let playlist = fetch_playlists();
let artist = match artist {
Some(Ok(worker)) => worker
.join()
.map_err(|_| ResolverError::Failed("artist search panicked".into()))?,
_ => fetch_artists(),
};
let album = match album {
Some(Ok(worker)) => worker
.join()
.map_err(|_| ResolverError::Failed("album search panicked".into()))?,
_ => fetch_albums(),
};
Ok::<_, ResolverError>((artist, album, playlist))
})?;
if let Some(response) = artist_response {
match response {
Ok(response) if response.error.0.is_none() => {
result.artists = response
.data
.unwrap_or_default()
.iter()
.map(|artist| SearchArtistResult {
id: format!("deezer:{}", artist.id),
name: artist.name.clone(),
images: artist.image(),
followers: artist.nb_fan,
popularity: 0,
})
.collect();
}
Err(error @ (ResolverError::Cancelled(_) | ResolverError::Closed)) => {
return Err(error);
}
_ => {}
}
}
if let Some(response) = album_response {
match response {
Ok(response) if response.error.0.is_none() => {
result.albums = response
.data
.unwrap_or_default()
.iter()
.map(|album| SearchAlbumResult {
id: format!("deezer:{}", album.id),
name: album.title.clone(),
artists: album.artist.name.clone(),
images: album.image(),
release_date: album.release_date.clone(),
total_tracks: album.nb_tracks,
album_type: deezer::album_type(&album.record_type),
})
.collect();
}
Err(error @ (ResolverError::Cancelled(_) | ResolverError::Closed)) => {
return Err(error);
}
_ => {}
}
}
if let Some(response) = playlist_response {
match response {
Ok(response) if response.error.0.is_none() => {
result.playlists = response
.data
.unwrap_or_default()
.iter()
.map(|playlist| SearchPlaylistResult {
id: format!("deezer:{}", playlist.id),
name: playlist.title.clone(),
owner: playlist.user.name.clone(),
images: playlist.image(),
total_tracks: playlist.nb_tracks,
})
.collect();
}
Err(error @ (ResolverError::Cancelled(_) | ResolverError::Closed)) => {
return Err(error);
}
_ => {}
}
}
check().map_err(ResolverError::Cancelled)?;
let result = Arc::new(result);
self.store(Bucket::Search, key, Value::Search(result.clone()));
Ok(result)
}
}
pub(super) fn context(error: ResolverError, message: &str) -> ResolverError {
match error {
ResolverError::Cancelled(_) | ResolverError::Closed | ResolverError::Busy => error,
_ => ResolverError::Failed(format!("{message}: {error}")),
}
}