use crate::{Credit, Format, Media, Quality, ReleaseType, Torrent};
use serde::Deserialize;
use serde::de::{self, Deserializer, Visitor};
use std::fmt::{Formatter, Result as FmtResult};
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowseResponse {
pub current_page: u32,
pub pages: u32,
pub results: Vec<BrowseGroup>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowseGroup {
#[serde(deserialize_with = "string_or_u32")]
pub group_id: u32,
pub group_name: String,
pub artist: String,
pub cover: String,
pub tags: Vec<String>,
pub bookmarked: bool,
pub vanity_house: bool,
pub group_year: u32,
#[serde(deserialize_with = "release_type_from_display")]
pub release_type: ReleaseType,
pub group_time: String,
pub max_size: u64,
pub total_snatched: u32,
pub total_seeders: u32,
pub total_leechers: u32,
pub torrents: Vec<BrowseTorrent>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
#[expect(
clippy::struct_excessive_bools,
reason = "mirrors Gazelle API JSON shape"
)]
pub struct BrowseTorrent {
pub torrent_id: u32,
pub edition_id: u32,
pub artists: Vec<Credit>,
pub media: Media,
pub format: Format,
pub encoding: Quality,
pub remastered: Option<bool>,
pub remaster_year: Option<u16>,
pub remaster_record_label: Option<String>,
pub remaster_catalogue_number: String,
pub remaster_title: String,
pub has_log: bool,
pub log_score: i32,
pub has_cue: bool,
pub scene: bool,
pub vanity_house: bool,
pub file_count: u32,
pub time: String,
pub size: u64,
pub snatches: u32,
pub seeders: u32,
pub leechers: u32,
pub is_freeleech: bool,
pub is_neutral_leech: bool,
pub is_personal_freeleech: bool,
pub can_use_token: bool,
pub has_snatched: bool,
pub leech_status: Option<u32>,
pub is_freeload: Option<bool>,
pub trumpable: Option<bool>,
}
impl BrowseTorrent {
#[must_use]
pub fn to_torrent(&self) -> Torrent {
Torrent {
id: self.torrent_id,
media: self.media.clone(),
format: self.format.clone(),
encoding: self.encoding.clone(),
remastered: self.remastered,
remaster_year: self.remaster_year,
remaster_title: self.remaster_title.clone(),
remaster_record_label: self.remaster_record_label.clone().unwrap_or_default(),
remaster_catalogue_number: self.remaster_catalogue_number.clone(),
scene: self.scene,
has_log: self.has_log,
has_cue: self.has_cue,
log_score: self.log_score,
file_count: self.file_count,
size: self.size,
seeders: self.seeders,
leechers: self.leechers,
snatched: self.snatches,
has_snatched: Some(self.has_snatched),
trumpable: self.trumpable,
is_freeload: self.is_freeload,
time: self.time.clone(),
..Torrent::default()
}
}
}
fn release_type_from_display<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<ReleaseType, D::Error> {
let s = String::deserialize(deserializer)?;
ReleaseType::from_display(&s)
.ok_or_else(|| de::Error::custom(format!("unrecognized release type: {s}")))
}
fn string_or_u32<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u32, D::Error> {
struct StringOrU32Visitor;
impl Visitor<'_> for StringOrU32Visitor {
type Value = u32;
fn expecting(&self, f: &mut Formatter) -> FmtResult {
f.write_str("a u32 or a string containing a u32")
}
fn visit_u64<E: de::Error>(self, value: u64) -> Result<Self::Value, E> {
u32::try_from(value).map_err(de::Error::custom)
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
value.parse().map_err(de::Error::custom)
}
}
deserializer.deserialize_any(StringOrU32Visitor)
}
#[cfg(test)]
#[expect(
clippy::indexing_slicing,
reason = "test assertions on known fixture data"
)]
mod tests {
use super::*;
const OPS_RESPONSE: &str = include_str!("../tests/fixtures/browse_response_ops.json");
const RED_RESPONSE: &str = include_str!("../tests/fixtures/browse_response_red.json");
#[test]
fn deserialize_ops() {
let response: BrowseResponse =
serde_json::from_str(OPS_RESPONSE).expect("fixture should deserialize");
assert_eq!(response.current_page, 1);
assert_eq!(response.pages, 20);
assert_eq!(response.results.len(), 1);
let group = &response.results[0];
assert_eq!(group.group_id, 100_200);
assert_eq!(group.group_name, "Mock Album");
assert_eq!(group.torrents.len(), 2);
assert_eq!(group.torrents[0].torrent_id, 3_000_001);
assert_eq!(group.torrents[0].remastered, None);
assert_eq!(group.torrents[0].media, Media::CD);
assert_eq!(group.torrents[0].encoding, Quality::Lossless);
assert_eq!(group.torrents[1].torrent_id, 3_000_002);
assert_eq!(group.torrents[1].encoding, Quality::Lossless24);
}
#[test]
fn deserialize_red() {
let response: BrowseResponse =
serde_json::from_str(RED_RESPONSE).expect("fixture should deserialize");
assert_eq!(response.current_page, 1);
assert_eq!(response.pages, 50);
assert_eq!(response.results.len(), 1);
let group = &response.results[0];
assert_eq!(group.group_id, 200_300);
assert_eq!(group.group_name, "Mock Album");
assert_eq!(group.torrents.len(), 2);
assert_eq!(group.torrents[0].torrent_id, 6_000_001);
assert_eq!(group.torrents[0].remastered, Some(true));
assert_eq!(group.torrents[0].media, Media::WEB);
assert_eq!(group.torrents[0].encoding, Quality::Lossless);
assert_eq!(group.torrents[1].torrent_id, 6_000_002);
assert_eq!(group.torrents[1].remastered, Some(false));
}
#[test]
fn browse_torrent_to_torrent_round_trip() {
let response: BrowseResponse =
serde_json::from_str(RED_RESPONSE).expect("fixture should deserialize");
let browse_torrent = &response.results[0].torrents[0];
let torrent = browse_torrent.to_torrent();
assert_eq!(torrent.id, 6_000_001);
assert_eq!(torrent.media, Media::WEB);
assert_eq!(torrent.format, Format::FLAC);
assert_eq!(torrent.encoding, Quality::Lossless);
assert_eq!(torrent.remastered, Some(true));
assert_eq!(torrent.remaster_catalogue_number, "MOCK-100");
}
}