use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use url::Url;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum StreamingProvider {
#[serde(rename = "spotify")]
Spotify,
#[serde(rename = "apple_music")]
AppleMusic,
#[serde(rename = "deezer")]
Deezer,
#[serde(rename = "napster")]
Napster,
#[serde(rename = "youtube")]
YouTube,
}
impl StreamingProvider {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Spotify => "spotify",
Self::AppleMusic => "apple_music",
Self::Deezer => "deezer",
Self::Napster => "napster",
Self::YouTube => "youtube",
}
}
}
impl std::fmt::Display for StreamingProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
const ALL_STREAMING_PROVIDERS: [StreamingProvider; 5] = [
StreamingProvider::Spotify,
StreamingProvider::AppleMusic,
StreamingProvider::Deezer,
StreamingProvider::Napster,
StreamingProvider::YouTube,
];
fn lis_tn_streaming_url(song_link: Option<&str>, provider: &str) -> Option<String> {
let link = song_link?;
let parsed = Url::parse(link).ok()?;
if parsed.host_str() != Some("lis.tn") {
return None;
}
let sep = if parsed.query().is_some() { '&' } else { '?' };
Some(format!("{link}{sep}{provider}"))
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct AppleMusicMetadata {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(
default,
rename = "artistName",
skip_serializing_if = "Option::is_none"
)]
pub artist_name: Option<String>,
#[serde(default, rename = "albumName", skip_serializing_if = "Option::is_none")]
pub album_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(
default,
rename = "durationInMillis",
skip_serializing_if = "Option::is_none"
)]
pub duration_in_millis: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub isrc: Option<String>,
#[serde(
default,
rename = "trackNumber",
skip_serializing_if = "Option::is_none"
)]
pub track_number: Option<i32>,
#[serde(
default,
rename = "composerName",
skip_serializing_if = "Option::is_none"
)]
pub composer_name: Option<String>,
#[serde(
default,
rename = "discNumber",
skip_serializing_if = "Option::is_none"
)]
pub disc_number: Option<i32>,
#[serde(
default,
rename = "releaseDate",
skip_serializing_if = "Option::is_none"
)]
pub release_date: Option<String>,
#[serde(flatten)]
pub extras: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct SpotifyMetadata {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub explicit: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub popularity: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub track_number: Option<i32>,
#[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
pub object_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uri: Option<String>,
#[serde(flatten)]
pub extras: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct DeezerMetadata {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub link: Option<String>,
#[serde(flatten)]
pub extras: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct NapsterMetadata {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub isrc: Option<String>,
#[serde(
default,
rename = "artistName",
skip_serializing_if = "Option::is_none"
)]
pub artist_name: Option<String>,
#[serde(default, rename = "albumName", skip_serializing_if = "Option::is_none")]
pub album_name: Option<String>,
#[serde(flatten)]
pub extras: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct MusicBrainzEntry {
#[serde(default)]
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub score: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub length: Option<i64>,
#[serde(flatten)]
pub extras: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct RecognitionResult {
pub timecode: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audio_id: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artist: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub album: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub release_date: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub song_link: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub isrc: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upc: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub apple_music: Option<AppleMusicMetadata>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spotify: Option<SpotifyMetadata>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deezer: Option<DeezerMetadata>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub napster: Option<NapsterMetadata>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub musicbrainz: Option<Vec<MusicBrainzEntry>>,
#[serde(flatten)]
pub extras: HashMap<String, Value>,
}
impl RecognitionResult {
#[must_use]
pub fn is_custom_match(&self) -> bool {
self.audio_id.is_some()
}
#[must_use]
pub fn is_public_match(&self) -> bool {
self.audio_id.is_none() && (self.artist.is_some() || self.title.is_some())
}
#[must_use]
pub fn thumbnail_url(&self) -> Option<String> {
lis_tn_streaming_url(self.song_link.as_deref(), "thumb")
}
#[must_use]
pub fn streaming_url(&self, provider: StreamingProvider) -> Option<String> {
if let Some(direct) = self.direct_streaming_url(provider) {
return Some(direct);
}
lis_tn_streaming_url(self.song_link.as_deref(), provider.as_str())
}
fn direct_streaming_url(&self, provider: StreamingProvider) -> Option<String> {
match provider {
StreamingProvider::AppleMusic => self
.apple_music
.as_ref()
.and_then(|am| non_empty(am.url.as_deref())),
StreamingProvider::Spotify => {
if let Some(sp) = self.spotify.as_ref() {
if let Some(u) = sp
.extras
.get("external_urls")
.and_then(|v| v.get("spotify"))
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
{
return Some(u.to_string());
}
if let Some(u) = non_empty(sp.uri.as_deref()) {
return Some(u);
}
}
None
}
StreamingProvider::Deezer => self
.deezer
.as_ref()
.and_then(|d| non_empty(d.link.as_deref())),
StreamingProvider::Napster => self.napster.as_ref().and_then(|n| {
n.extras
.get("href")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string)
}),
StreamingProvider::YouTube => None,
}
}
#[must_use]
pub fn streaming_urls(&self) -> HashMap<StreamingProvider, String> {
let mut out = HashMap::new();
for p in ALL_STREAMING_PROVIDERS {
if let Some(u) = self.streaming_url(p) {
out.insert(p, u);
}
}
out
}
#[must_use]
pub fn preview_url(&self) -> Option<String> {
if let Some(am) = self.apple_music.as_ref() {
if let Some(u) = am
.extras
.get("previews")
.and_then(Value::as_array)
.and_then(|arr| arr.first())
.and_then(|first| first.get("url"))
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
{
return Some(u.to_string());
}
}
if let Some(sp) = self.spotify.as_ref() {
if let Some(u) = sp
.extras
.get("preview_url")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
{
return Some(u.to_string());
}
}
if let Some(dz) = self.deezer.as_ref() {
if let Some(u) = dz
.extras
.get("preview")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
{
return Some(u.to_string());
}
}
None
}
}
fn non_empty(s: Option<&str>) -> Option<String> {
s.filter(|v| !v.is_empty()).map(String::from)
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "result", rename_all = "snake_case")]
pub enum RecognitionMatch {
Public(RecognitionResult),
Custom(RecognitionResult),
}
impl From<RecognitionResult> for RecognitionMatch {
fn from(r: RecognitionResult) -> Self {
if r.is_custom_match() {
Self::Custom(r)
} else {
Self::Public(r)
}
}
}
impl RecognitionMatch {
#[must_use]
pub fn result(&self) -> &RecognitionResult {
match self {
Self::Public(r) | Self::Custom(r) => r,
}
}
#[must_use]
pub fn into_result(self) -> RecognitionResult {
match self {
Self::Public(r) | Self::Custom(r) => r,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct EnterpriseMatch {
#[serde(default)]
pub score: i32,
pub timecode: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artist: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub album: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub release_date: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub isrc: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upc: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub song_link: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_offset: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_offset: Option<i64>,
#[serde(flatten)]
pub extras: HashMap<String, Value>,
}
impl EnterpriseMatch {
#[must_use]
pub fn thumbnail_url(&self) -> Option<String> {
lis_tn_streaming_url(self.song_link.as_deref(), "thumb")
}
#[must_use]
pub fn streaming_url(&self, provider: StreamingProvider) -> Option<String> {
lis_tn_streaming_url(self.song_link.as_deref(), provider.as_str())
}
#[must_use]
pub fn streaming_urls(&self) -> HashMap<StreamingProvider, String> {
let mut out = HashMap::new();
for p in ALL_STREAMING_PROVIDERS {
if let Some(u) = self.streaming_url(p) {
out.insert(p, u);
}
}
out
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct EnterpriseChunkResult {
#[serde(default)]
pub songs: Vec<EnterpriseMatch>,
#[serde(default)]
pub offset: String,
#[serde(flatten)]
pub extras: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct Stream {
pub radio_id: i64,
pub url: String,
#[serde(default)]
pub stream_running: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub longpoll_category: Option<String>,
#[serde(flatten)]
pub extras: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct StreamCallbackSong {
pub artist: String,
pub title: String,
pub score: i32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub album: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub release_date: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub song_link: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub isrc: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upc: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub apple_music: Option<AppleMusicMetadata>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spotify: Option<SpotifyMetadata>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deezer: Option<DeezerMetadata>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub napster: Option<NapsterMetadata>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub musicbrainz: Option<Vec<MusicBrainzEntry>>,
#[serde(flatten)]
pub extras: HashMap<String, Value>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct StreamCallbackMatch {
pub radio_id: i64,
pub timestamp: Option<String>,
pub play_length: Option<i64>,
pub song: StreamCallbackSong,
pub alternatives: Vec<StreamCallbackSong>,
pub extras: HashMap<String, Value>,
pub raw_response: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct StreamCallbackNotification {
pub radio_id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stream_running: Option<bool>,
pub notification_code: i32,
pub notification_message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub time: Option<i64>,
#[serde(flatten)]
pub extras: HashMap<String, Value>,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub raw_response: Value,
}
impl<'de> Deserialize<'de> for StreamCallbackMatch {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let mut map: serde_json::Map<String, Value> = Deserialize::deserialize(deserializer)?;
let radio_id = map
.remove("radio_id")
.ok_or_else(|| serde::de::Error::missing_field("radio_id"))
.and_then(|v| serde_json::from_value(v).map_err(serde::de::Error::custom))?;
let timestamp = match map.remove("timestamp") {
Some(Value::Null) | None => None,
Some(v) => Some(serde_json::from_value(v).map_err(serde::de::Error::custom)?),
};
let play_length = match map.remove("play_length") {
Some(Value::Null) | None => None,
Some(v) => Some(serde_json::from_value(v).map_err(serde::de::Error::custom)?),
};
let results: Vec<StreamCallbackSong> = match map.remove("results") {
Some(v) => serde_json::from_value(v).map_err(serde::de::Error::custom)?,
None => Vec::new(),
};
let mut iter = results.into_iter();
let song = iter
.next()
.ok_or_else(|| serde::de::Error::custom("callback result.results is empty"))?;
let alternatives = iter.collect();
let extras: HashMap<String, Value> = map.into_iter().collect();
Ok(Self {
radio_id,
timestamp,
play_length,
song,
alternatives,
extras,
raw_response: Value::Null,
})
}
}
impl Serialize for StreamCallbackMatch {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeMap;
let mut len = 1; if self.timestamp.is_some() {
len += 1;
}
if self.play_length.is_some() {
len += 1;
}
len += 1; len += self.extras.len();
let mut map = serializer.serialize_map(Some(len))?;
map.serialize_entry("radio_id", &self.radio_id)?;
if let Some(t) = &self.timestamp {
map.serialize_entry("timestamp", t)?;
}
if let Some(p) = &self.play_length {
map.serialize_entry("play_length", p)?;
}
let mut results: Vec<&StreamCallbackSong> =
Vec::with_capacity(1 + self.alternatives.len());
results.push(&self.song);
for a in &self.alternatives {
results.push(a);
}
map.serialize_entry("results", &results)?;
for (k, v) in &self.extras {
map.serialize_entry(k, v)?;
}
map.end()
}
}
#[derive(Debug, Clone, PartialEq)]
#[allow(clippy::large_enum_variant)]
pub enum CallbackEvent {
Match(StreamCallbackMatch),
Notification(StreamCallbackNotification),
}
impl CallbackEvent {
#[must_use]
pub fn as_match(&self) -> Option<&StreamCallbackMatch> {
if let Self::Match(m) = self {
Some(m)
} else {
None
}
}
#[must_use]
pub fn as_notification(&self) -> Option<&StreamCallbackNotification> {
if let Self::Notification(n) = self {
Some(n)
} else {
None
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct LyricsResult {
pub artist: String,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lyrics: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub song_id: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub media: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub full_title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artist_id: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub song_link: Option<String>,
#[serde(flatten)]
pub extras: HashMap<String, Value>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn recognition_basic() {
let v = json!({
"artist": "Tears For Fears",
"title": "Everybody Wants To Rule The World",
"timecode": "00:56",
"song_link": "https://lis.tn/NbkVb"
});
let r: RecognitionResult = serde_json::from_value(v).unwrap();
assert!(r.is_public_match());
assert!(!r.is_custom_match());
assert_eq!(
r.thumbnail_url().as_deref(),
Some("https://lis.tn/NbkVb?thumb")
);
}
#[test]
fn custom_match() {
let v = json!({"timecode": "01:45", "audio_id": 146});
let r: RecognitionResult = serde_json::from_value(v).unwrap();
assert!(r.is_custom_match());
assert!(!r.is_public_match());
assert_eq!(r.thumbnail_url(), None);
}
#[test]
fn match_enum() {
let r = RecognitionResult {
timecode: "x".into(),
audio_id: Some(1),
..Default::default()
};
match RecognitionMatch::from(r) {
RecognitionMatch::Custom(_) => {}
RecognitionMatch::Public(_) => panic!("expected Custom"),
}
}
#[test]
fn extras_round_trip() {
let v = json!({
"timecode": "00:01",
"artist": "X",
"tidal": {"id": "abc"}
});
let r: RecognitionResult = serde_json::from_value(v).unwrap();
assert_eq!(r.extras.get("tidal").unwrap().get("id").unwrap(), "abc");
}
#[test]
fn youtube_link_no_thumb() {
let r = RecognitionResult {
timecode: "00:01".into(),
song_link: Some("https://www.youtube.com/watch?v=abc".into()),
..Default::default()
};
assert_eq!(r.thumbnail_url(), None);
}
#[test]
fn thumb_with_existing_query() {
let r = RecognitionResult {
timecode: "00:01".into(),
song_link: Some("https://lis.tn/abc?utm=x".into()),
..Default::default()
};
assert_eq!(
r.thumbnail_url().as_deref(),
Some("https://lis.tn/abc?utm=x&thumb")
);
}
#[test]
fn stream_callback_match_deserialize_splits_song_and_alternatives() {
let v = json!({
"radio_id": 7,
"timestamp": "2020-04-13 10:31:43",
"play_length": 111,
"results": [
{"artist": "X", "title": "Y", "score": 100},
{"artist": "X", "title": "Y (Remix)", "score": 95}
]
});
let m: StreamCallbackMatch = serde_json::from_value(v).unwrap();
assert_eq!(m.radio_id, 7);
assert_eq!(m.timestamp.as_deref(), Some("2020-04-13 10:31:43"));
assert_eq!(m.play_length, Some(111));
assert_eq!(m.song.title, "Y");
assert_eq!(m.alternatives.len(), 1);
assert_eq!(m.alternatives[0].title, "Y (Remix)");
}
#[test]
fn stream_callback_match_extras_capture_unknown_keys() {
let v = json!({
"radio_id": 1,
"results": [{"artist": "X", "title": "Y", "score": 100}],
"futuristic_field": {"a": 1}
});
let m: StreamCallbackMatch = serde_json::from_value(v).unwrap();
assert_eq!(
m.extras
.get("futuristic_field")
.and_then(|v| v.get("a"))
.and_then(Value::as_i64),
Some(1)
);
}
#[test]
fn stream_callback_match_empty_results_errors() {
let v = json!({"radio_id": 1, "results": []});
let err = serde_json::from_value::<StreamCallbackMatch>(v).unwrap_err();
assert!(format!("{err}").contains("empty"));
}
#[test]
fn stream_callback_match_round_trip_serialize() {
let v = json!({
"radio_id": 9,
"timestamp": "2026-05-04 10:31:43",
"play_length": 60,
"results": [
{"artist": "X", "title": "Y", "score": 100, "song_link": "https://lis.tn/abc"}
]
});
let original: StreamCallbackMatch = serde_json::from_value(v).unwrap();
let bytes = serde_json::to_vec(&original).unwrap();
let back: StreamCallbackMatch = serde_json::from_slice(&bytes).unwrap();
assert_eq!(back.radio_id, 9);
assert_eq!(back.song.title, "Y");
assert_eq!(back.song.song_link.as_deref(), Some("https://lis.tn/abc"));
}
#[test]
fn stream_callback_notification_round_trip() {
let n = StreamCallbackNotification {
radio_id: 3,
stream_running: Some(false),
notification_code: 650,
notification_message: "x".into(),
time: Some(1),
extras: HashMap::new(),
raw_response: Value::Null,
};
let bytes = serde_json::to_vec(&n).unwrap();
let back: StreamCallbackNotification = serde_json::from_slice(&bytes).unwrap();
assert_eq!(back.radio_id, 3);
assert_eq!(back.notification_code, 650);
assert_eq!(back.time, Some(1));
}
#[test]
fn enterprise_match_thumb() {
let m = EnterpriseMatch {
score: 80,
timecode: "00:01".into(),
song_link: Some("https://lis.tn/abc".into()),
..Default::default()
};
assert_eq!(
m.thumbnail_url().as_deref(),
Some("https://lis.tn/abc?thumb")
);
}
#[test]
fn streaming_url_prefers_direct_apple_music() {
let v = json!({
"timecode": "00:01",
"song_link": "https://lis.tn/abc",
"apple_music": {"url": "https://music.apple.com/track/123"}
});
let r: RecognitionResult = serde_json::from_value(v).unwrap();
assert_eq!(
r.streaming_url(StreamingProvider::AppleMusic).as_deref(),
Some("https://music.apple.com/track/123"),
);
}
#[test]
fn streaming_url_falls_back_to_lis_tn_redirect() {
let v = json!({
"timecode": "00:01",
"song_link": "https://lis.tn/abc"
});
let r: RecognitionResult = serde_json::from_value(v).unwrap();
assert_eq!(
r.streaming_url(StreamingProvider::Spotify).as_deref(),
Some("https://lis.tn/abc?spotify"),
);
assert_eq!(
r.streaming_url(StreamingProvider::YouTube).as_deref(),
Some("https://lis.tn/abc?youtube"),
);
}
#[test]
fn streaming_url_returns_none_for_youtube_song_link() {
let v = json!({
"timecode": "00:01",
"song_link": "https://www.youtube.com/watch?v=abc"
});
let r: RecognitionResult = serde_json::from_value(v).unwrap();
assert_eq!(r.streaming_url(StreamingProvider::Spotify), None);
assert_eq!(r.streaming_url(StreamingProvider::YouTube), None);
}
#[test]
fn streaming_urls_lists_all_resolvable() {
let v = json!({
"timecode": "00:01",
"song_link": "https://lis.tn/abc",
"deezer": {"link": "https://deezer.com/track/9"}
});
let r: RecognitionResult = serde_json::from_value(v).unwrap();
let urls = r.streaming_urls();
assert_eq!(
urls.get(&StreamingProvider::Deezer).map(String::as_str),
Some("https://deezer.com/track/9"),
);
assert_eq!(
urls.get(&StreamingProvider::Spotify).map(String::as_str),
Some("https://lis.tn/abc?spotify"),
);
assert_eq!(urls.len(), 5);
}
#[test]
fn streaming_url_spotify_external_urls_in_extras() {
let v = json!({
"timecode": "00:01",
"spotify": {
"id": "abc",
"external_urls": {"spotify": "https://open.spotify.com/track/abc"}
}
});
let r: RecognitionResult = serde_json::from_value(v).unwrap();
assert_eq!(
r.streaming_url(StreamingProvider::Spotify).as_deref(),
Some("https://open.spotify.com/track/abc"),
);
}
#[test]
fn preview_url_apple_music_first() {
let v = json!({
"timecode": "00:01",
"apple_music": {
"previews": [{"url": "https://itunes/preview.m4a"}]
},
"spotify": {"preview_url": "https://spotify/preview.mp3"}
});
let r: RecognitionResult = serde_json::from_value(v).unwrap();
assert_eq!(
r.preview_url().as_deref(),
Some("https://itunes/preview.m4a")
);
}
#[test]
fn preview_url_falls_through_to_deezer() {
let v = json!({
"timecode": "00:01",
"deezer": {"preview": "https://deezer/preview.mp3"}
});
let r: RecognitionResult = serde_json::from_value(v).unwrap();
assert_eq!(
r.preview_url().as_deref(),
Some("https://deezer/preview.mp3")
);
}
#[test]
fn preview_url_none_when_absent() {
let v = json!({"timecode": "00:01"});
let r: RecognitionResult = serde_json::from_value(v).unwrap();
assert_eq!(r.preview_url(), None);
}
#[test]
fn enterprise_match_streaming_urls_lis_tn_only() {
let m = EnterpriseMatch {
score: 90,
timecode: "00:01".into(),
song_link: Some("https://lis.tn/abc".into()),
..Default::default()
};
let urls = m.streaming_urls();
assert_eq!(urls.len(), 5);
assert_eq!(
urls.get(&StreamingProvider::Spotify).map(String::as_str),
Some("https://lis.tn/abc?spotify"),
);
}
#[test]
fn enterprise_match_streaming_urls_empty_for_youtube() {
let m = EnterpriseMatch {
score: 90,
timecode: "00:01".into(),
song_link: Some("https://www.youtube.com/watch?v=x".into()),
..Default::default()
};
assert!(m.streaming_urls().is_empty());
}
#[test]
fn serialize_round_trip_recognition_result_typed_fields() {
let v = json!({
"artist": "Tears For Fears",
"title": "Everybody Wants To Rule The World",
"album": "Songs From The Big Chair",
"release_date": "1985-03-25",
"label": "Mercury Records",
"timecode": "00:56",
"song_link": "https://lis.tn/NbkVb",
"apple_music": {
"name": "Everybody Wants To Rule The World",
"artistName": "Tears For Fears",
"url": "https://music.apple.com/track/123",
"isrc": "GBF088400024"
},
"spotify": {
"id": "abc",
"name": "Everybody Wants To Rule The World",
"external_urls": {"spotify": "https://open.spotify.com/track/abc"}
},
"deezer": {"id": 9, "title": "Everybody Wants To Rule The World", "link": "https://deezer.com/track/9"},
"napster": {"id": "n1", "name": "X", "isrc": "GBF088400024"},
"musicbrainz": [{"id": "mb1", "title": "X"}],
"tidal": {"id": "t1"}
});
let original: RecognitionResult = serde_json::from_value(v).unwrap();
let bytes = serde_json::to_vec(&original).unwrap();
let round_tripped: RecognitionResult = serde_json::from_slice(&bytes).unwrap();
assert_eq!(round_tripped.artist, original.artist);
assert_eq!(round_tripped.title, original.title);
assert_eq!(round_tripped.album, original.album);
assert_eq!(round_tripped.release_date, original.release_date);
assert_eq!(round_tripped.label, original.label);
assert_eq!(round_tripped.timecode, original.timecode);
assert_eq!(round_tripped.song_link, original.song_link);
assert_eq!(round_tripped.apple_music, original.apple_music);
assert_eq!(round_tripped.spotify, original.spotify);
assert_eq!(round_tripped.deezer, original.deezer);
assert_eq!(round_tripped.napster, original.napster);
assert_eq!(round_tripped.musicbrainz, original.musicbrainz);
assert_eq!(
round_tripped.extras.get("tidal").and_then(|v| v.get("id")),
original.extras.get("tidal").and_then(|v| v.get("id"))
);
}
#[test]
fn serialize_round_trip_enterprise_chunk() {
let v = json!({
"songs": [{
"score": 95,
"timecode": "00:00",
"artist": "X",
"title": "Y",
"song_link": "https://lis.tn/abc",
"isrc": "AAA",
"upc": "BBB",
"start_offset": 0,
"end_offset": 30
}],
"offset": "00:00"
});
let original: EnterpriseChunkResult = serde_json::from_value(v).unwrap();
let bytes = serde_json::to_vec(&original).unwrap();
let round_tripped: EnterpriseChunkResult = serde_json::from_slice(&bytes).unwrap();
assert_eq!(round_tripped.offset, original.offset);
assert_eq!(round_tripped.songs.len(), 1);
assert_eq!(round_tripped.songs[0], original.songs[0]);
}
#[test]
fn serialize_round_trip_stream() {
let s = Stream {
radio_id: 42,
url: "https://stream.example/live".into(),
stream_running: true,
longpoll_category: Some("abc123".into()),
extras: HashMap::new(),
};
let bytes = serde_json::to_vec(&s).unwrap();
let round_tripped: Stream = serde_json::from_slice(&bytes).unwrap();
assert_eq!(round_tripped, s);
}
#[test]
fn serialize_round_trip_lyrics_result() {
let l = LyricsResult {
artist: "Tears For Fears".into(),
title: "Everybody Wants To Rule The World".into(),
lyrics: Some("Welcome to your life…".into()),
song_id: Some(1),
media: Some("https://media.example/x".into()),
full_title: Some("Tears For Fears – Everybody Wants To Rule The World".into()),
artist_id: Some(99),
song_link: Some("https://lis.tn/abc".into()),
extras: HashMap::new(),
};
let bytes = serde_json::to_vec(&l).unwrap();
let round_tripped: LyricsResult = serde_json::from_slice(&bytes).unwrap();
assert_eq!(round_tripped, l);
}
#[test]
fn serialize_round_trip_streaming_provider() {
for (provider, wire) in [
(StreamingProvider::Spotify, "\"spotify\""),
(StreamingProvider::AppleMusic, "\"apple_music\""),
(StreamingProvider::Deezer, "\"deezer\""),
(StreamingProvider::Napster, "\"napster\""),
(StreamingProvider::YouTube, "\"youtube\""),
] {
let s = serde_json::to_string(&provider).unwrap();
assert_eq!(s, wire, "{provider:?} should serialize as {wire}");
let back: StreamingProvider = serde_json::from_str(&s).unwrap();
assert_eq!(back, provider);
}
}
#[test]
fn serialize_round_trip_recognition_match_tagged() {
let r = RecognitionResult {
timecode: "00:01".into(),
audio_id: Some(7),
..Default::default()
};
let m = RecognitionMatch::from(r);
let s = serde_json::to_value(&m).unwrap();
assert_eq!(s.get("kind").and_then(|v| v.as_str()), Some("custom"));
let back: RecognitionMatch = serde_json::from_value(s).unwrap();
assert_eq!(back, m);
}
}