Add 7Digital and ProStudioMasters basic search
This commit is contained in:
parent
9e4e7196ae
commit
3f4fd9be5e
16 changed files with 3763 additions and 5 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1 +1 @@
|
||||||
/target
|
**/target
|
||||||
|
|
1585
Cargo.lock
generated
Normal file
1585
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -2,7 +2,10 @@
|
||||||
name = "munite"
|
name = "munite"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
description = "United media searching"
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
munite-core = { version = "0.1.0", path = "./munite-core" }
|
||||||
|
clap = { version = "4.0", features = [ "derive" ] }
|
||||||
|
tokio = { version = "1.24", features = [ "rt", "macros", "rt-multi-thread" ] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
|
1410
munite-core/Cargo.lock
generated
Normal file
1410
munite-core/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
16
munite-core/Cargo.toml
Normal file
16
munite-core/Cargo.toml
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
[package]
|
||||||
|
name = "munite-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
async-trait = "0.1.61"
|
||||||
|
reqwest = { version = "0.11.13", features = [ "json" ] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
serde = { version = "1.0", features = [ "derive" ] }
|
||||||
|
soup = "0.5"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1.24", features = [ "rt", "macros", "rt-multi-thread" ] }
|
44
munite-core/src/audio/interface.rs
Normal file
44
munite-core/src/audio/interface.rs
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
use crate::{MuniteResult, StoreEntry, StoreSearch};
|
||||||
|
|
||||||
|
/// A search result from an audio store
|
||||||
|
pub enum AudioEntry {
|
||||||
|
/// A search result from a music store
|
||||||
|
Music(super::music::MusicEntry),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::convert::Into<StoreEntry> for AudioEntry {
|
||||||
|
fn into(self) -> StoreEntry {
|
||||||
|
StoreEntry::Audio(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for AudioEntry {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Music(x) => write!(f, "[MUSIC]{}", x),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store with audio-specific functionality
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait AudioSearchStore {
|
||||||
|
/// Search the store for the given string `s`, returning the results of the search
|
||||||
|
async fn search(&self, s: String) -> MuniteResult<Vec<AudioEntry>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl<X: AudioSearchStore + Sync> StoreSearch for X {
|
||||||
|
async fn search(&self, s: String) -> MuniteResult<Vec<StoreEntry>> {
|
||||||
|
(self as &(dyn AudioSearchStore + Sync))
|
||||||
|
.search(s)
|
||||||
|
.await
|
||||||
|
.map(
|
||||||
|
|entries|
|
||||||
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| e.into())
|
||||||
|
.collect()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
18
munite-core/src/audio/mod.rs
Normal file
18
munite-core/src/audio/mod.rs
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
//! Audio store functionality
|
||||||
|
mod interface;
|
||||||
|
pub mod music;
|
||||||
|
|
||||||
|
pub use interface::{AudioSearchStore, AudioEntry};
|
||||||
|
|
||||||
|
/// All available AudioSearchStores
|
||||||
|
pub fn all() -> Vec<Box<dyn AudioSearchStore>> {
|
||||||
|
vec![
|
||||||
|
Box::new(music::SevenDigital::default()),
|
||||||
|
Box::new(music::ProStudioMasters::default()),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All available audio SearchStores
|
||||||
|
pub fn stores() -> Vec<Box<dyn crate::StoreSearch>> {
|
||||||
|
music::stores()
|
||||||
|
}
|
147
munite-core/src/audio/music/interface.rs
Normal file
147
munite-core/src/audio/music/interface.rs
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
use crate::MuniteResult;
|
||||||
|
use crate::audio::{AudioEntry, AudioSearchStore};
|
||||||
|
|
||||||
|
/// A search result from a music store
|
||||||
|
pub enum MusicEntry {
|
||||||
|
/// A search result for an artist
|
||||||
|
Artist(ArtistEntry),
|
||||||
|
/// A search result for an album or single
|
||||||
|
Release(ReleaseEntry),
|
||||||
|
/// A search result for a track
|
||||||
|
Track(TrackEntry),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for MusicEntry {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Artist(x) => write!(f, "[ARTIST]{}", x),
|
||||||
|
Self::Release(x) => write!(f, "[RELEASE]{}", x),
|
||||||
|
Self::Track(x) => write!(f, "[TRACK]{}", x),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A search result for an artist
|
||||||
|
pub struct ArtistEntry {
|
||||||
|
/// Artist's ID used by the store
|
||||||
|
pub id: Option<u64>,
|
||||||
|
/// Artist's name
|
||||||
|
pub name: String,
|
||||||
|
/// Artist's cover image
|
||||||
|
pub image: Option<String>,
|
||||||
|
/// Store name
|
||||||
|
pub store: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::convert::Into<MusicEntry> for ArtistEntry {
|
||||||
|
fn into(self) -> MusicEntry {
|
||||||
|
MusicEntry::Artist(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ArtistEntry {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
if let Some(id) = self.id {
|
||||||
|
write!(f, "[{}]({}) `{}`", self.store, id, self.name)
|
||||||
|
} else {
|
||||||
|
write!(f, "[{}] `{}`", self.store, self.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A search result for an album or single
|
||||||
|
pub struct ReleaseEntry {
|
||||||
|
/// Release's ID used by the store
|
||||||
|
pub id: Option<u64>,
|
||||||
|
/// Release's title
|
||||||
|
pub title: String,
|
||||||
|
/// Release's artist
|
||||||
|
pub artist: ArtistEntry,
|
||||||
|
/// Store name
|
||||||
|
pub store: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::convert::Into<MusicEntry> for ReleaseEntry {
|
||||||
|
fn into(self) -> MusicEntry {
|
||||||
|
MusicEntry::Release(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ReleaseEntry {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
if let Some(id) = self.id {
|
||||||
|
write!(f, "[{}]({}) `{}` by {}", self.store, id, self.title, self.artist)
|
||||||
|
} else {
|
||||||
|
write!(f, "[{}] `{}` by {}", self.store, self.title, self.artist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A search result for a track
|
||||||
|
pub struct TrackEntry {
|
||||||
|
/// Track's ID used by the store
|
||||||
|
pub id: Option<u64>,
|
||||||
|
/// Track's title
|
||||||
|
pub title: String,
|
||||||
|
/// Track's artist
|
||||||
|
pub artist: ArtistEntry,
|
||||||
|
/// Track's release
|
||||||
|
pub release: Option<ReleaseEntry>,
|
||||||
|
/// Store name
|
||||||
|
pub store: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::convert::Into<MusicEntry> for TrackEntry {
|
||||||
|
fn into(self) -> MusicEntry {
|
||||||
|
MusicEntry::Track(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for TrackEntry {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
if let Some(id) = self.id {
|
||||||
|
|
||||||
|
if let Some(release) = &self.release {
|
||||||
|
write!(f, "[{}]({}) `{}` from {}", self.store, id, self.title, release)
|
||||||
|
} else {
|
||||||
|
write!(f, "[{}]({}) `{}` by {}", self.store, id, self.title, self.artist)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let Some(release) = &self.release {
|
||||||
|
write!(f, "[{}] `{}` from {}", self.store, self.title, release)
|
||||||
|
} else {
|
||||||
|
write!(f, "[{}] `{}` by {}", self.store, self.title, self.artist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::convert::Into<AudioEntry> for MusicEntry {
|
||||||
|
fn into(self) -> AudioEntry {
|
||||||
|
AudioEntry::Music(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store with music-specific functionality
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait MusicSearchStore {
|
||||||
|
/// Search the store for the given string `s`, returning the results of the search
|
||||||
|
async fn search(&self, s: String) -> MuniteResult<Vec<MusicEntry>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl<X: MusicSearchStore + Sync> AudioSearchStore for X {
|
||||||
|
async fn search(&self, s: String) -> MuniteResult<Vec<AudioEntry>> {
|
||||||
|
(self as &(dyn MusicSearchStore + Sync))
|
||||||
|
.search(s)
|
||||||
|
.await
|
||||||
|
.map(
|
||||||
|
|entries|
|
||||||
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| e.into())
|
||||||
|
.collect()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
27
munite-core/src/audio/music/mod.rs
Normal file
27
munite-core/src/audio/music/mod.rs
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
//! Music store functionality
|
||||||
|
|
||||||
|
mod seven_digital;
|
||||||
|
mod pro_studio_masters;
|
||||||
|
|
||||||
|
pub use seven_digital::SevenDigital;
|
||||||
|
pub use pro_studio_masters::ProStudioMasters;
|
||||||
|
|
||||||
|
mod interface;
|
||||||
|
|
||||||
|
pub use interface::{MusicEntry, MusicSearchStore, TrackEntry, ArtistEntry, ReleaseEntry};
|
||||||
|
|
||||||
|
/// All available MusicSearchStores
|
||||||
|
pub fn all() -> Vec<Box<dyn MusicSearchStore>> {
|
||||||
|
vec![
|
||||||
|
Box::new(SevenDigital::default()),
|
||||||
|
Box::new(ProStudioMasters::default()),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All available music SearchStores
|
||||||
|
pub fn stores() -> Vec<Box<dyn crate::StoreSearch>> {
|
||||||
|
vec![
|
||||||
|
Box::new(SevenDigital::default()),
|
||||||
|
Box::new(ProStudioMasters::default()),
|
||||||
|
]
|
||||||
|
}
|
93
munite-core/src/audio/music/pro_studio_masters.rs
Normal file
93
munite-core/src/audio/music/pro_studio_masters.rs
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
use reqwest::{Client, RequestBuilder};
|
||||||
|
use soup::{Soup, QueryBuilderExt, NodeExt};
|
||||||
|
|
||||||
|
use super::{MusicEntry, MusicSearchStore, ReleaseEntry, ArtistEntry};
|
||||||
|
use crate::MuniteError;
|
||||||
|
|
||||||
|
/// ProStudioMasters store client
|
||||||
|
pub struct ProStudioMasters {
|
||||||
|
client: Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProStudioMasters {
|
||||||
|
/// Create a new client for the ProStudioMasters store
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
client: Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_search_query(&self, r: RequestBuilder, query: &str) -> RequestBuilder {
|
||||||
|
r
|
||||||
|
.query(&[
|
||||||
|
("cs", "1"), // page?
|
||||||
|
("q", query),
|
||||||
|
])
|
||||||
|
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
|
||||||
|
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; rv:108.0) Gecko/20100101 Firefox/108.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_releases(&self, query: &str) -> RequestBuilder {
|
||||||
|
self.apply_search_query(
|
||||||
|
self.client.get("https://www.prostudiomasters.com/search"),
|
||||||
|
query
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::default::Default for ProStudioMasters {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MusicSearchStore for ProStudioMasters {
|
||||||
|
async fn search(&self, s: String) -> crate::MuniteResult<Vec<MusicEntry>> {
|
||||||
|
let releases = self.search_releases(&s).send().await.map_err(|e| MuniteError::Http(e))?;
|
||||||
|
let page = releases.text().await.map_err(|e| MuniteError::Http(e))?;
|
||||||
|
let mut output = Vec::new();
|
||||||
|
for album in Soup::new(&page)
|
||||||
|
.tag("a")
|
||||||
|
.class("album")
|
||||||
|
.find_all() {
|
||||||
|
//dbg!(album.display());
|
||||||
|
if let Some(id) = album.get("href") {
|
||||||
|
if let Some(artist) = album.tag("span").class("artist").find() {
|
||||||
|
if let Some(title) = album.tag("span").class("title").find() {
|
||||||
|
output.push(ReleaseEntry {
|
||||||
|
id: id.split('/')
|
||||||
|
.last()
|
||||||
|
.map(|x| x.parse().ok())
|
||||||
|
.flatten(),
|
||||||
|
title: title.text().trim().to_owned(),
|
||||||
|
artist: ArtistEntry {
|
||||||
|
id: None,
|
||||||
|
name: artist.text().trim().to_owned(),
|
||||||
|
image: None,
|
||||||
|
store: "ProStudioMasters".to_owned(),
|
||||||
|
},
|
||||||
|
store: "ProStudioMasters".to_owned(),
|
||||||
|
}.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn interface_test_prostudiomasters() {
|
||||||
|
let store = ProStudioMasters::new();
|
||||||
|
let results = store.search("test".to_owned()).await;
|
||||||
|
let results = results.expect("Search results query failed");
|
||||||
|
assert_eq!(results.len(), 144);
|
||||||
|
}
|
||||||
|
}
|
255
munite-core/src/audio/music/seven_digital.rs
Normal file
255
munite-core/src/audio/music/seven_digital.rs
Normal file
|
@ -0,0 +1,255 @@
|
||||||
|
use reqwest::{Client, RequestBuilder};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use super::{MusicEntry, MusicSearchStore, TrackEntry, ReleaseEntry, ArtistEntry};
|
||||||
|
use crate::MuniteError;
|
||||||
|
|
||||||
|
/// 7Digital store client
|
||||||
|
pub struct SevenDigital {
|
||||||
|
client: Client,
|
||||||
|
id: usize,
|
||||||
|
key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SevenDigital {
|
||||||
|
/// Create a new client for a 7Digital store region
|
||||||
|
pub fn new(shop_id: usize, oath_key: String) -> Self {
|
||||||
|
Self {
|
||||||
|
client: Client::new(),
|
||||||
|
id: shop_id,
|
||||||
|
key: oath_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_search_query(&self, r: RequestBuilder, query: &str) -> RequestBuilder {
|
||||||
|
r
|
||||||
|
.query(&[
|
||||||
|
("oauth_consumer_key", &self.key as &str),
|
||||||
|
("usageTypes", "download"),
|
||||||
|
("pageSize", "11"),
|
||||||
|
("q", query),
|
||||||
|
("shopId", &self.id.to_string() as &str)
|
||||||
|
])
|
||||||
|
.header("Accept", "application/json, text/javascript, */*; q=0.01")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_tracks(&self, query: &str) -> RequestBuilder {
|
||||||
|
self.apply_search_query(
|
||||||
|
self.client.get("https://api.7digital.com/1.2/track/search"),
|
||||||
|
query
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_releases(&self, query: &str) -> RequestBuilder {
|
||||||
|
self.apply_search_query(
|
||||||
|
self.client.get("https://api.7digital.com/1.2/release/search"),
|
||||||
|
query
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_artists(&self, query: &str) -> RequestBuilder {
|
||||||
|
self.apply_search_query(
|
||||||
|
self.client.get("https://api.7digital.com/1.2/artist/search"),
|
||||||
|
query
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::default::Default for SevenDigital {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(
|
||||||
|
1069, // Canada
|
||||||
|
"7drfpc993qp5".to_owned(), // Website's default?
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MusicSearchStore for SevenDigital {
|
||||||
|
async fn search(&self, s: String) -> crate::MuniteResult<Vec<MusicEntry>> {
|
||||||
|
let tracks = self.search_tracks(&s).send();
|
||||||
|
let releases = self.search_releases(&s).send();
|
||||||
|
let artists = self.search_artists(&s).send();
|
||||||
|
let results = futures_util::future::join_all(vec![tracks, releases, artists]).await;
|
||||||
|
let mut output = Vec::new();
|
||||||
|
for response in results {
|
||||||
|
let response = response.map_err(|e| MuniteError::Http(e))?;
|
||||||
|
let result: SearchResultsResponse = response.json().await.map_err(|e| MuniteError::Http(e))?;
|
||||||
|
for entry in result.searchResults.searchResult {
|
||||||
|
output.push(entry.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API datatypes
|
||||||
|
|
||||||
|
#[allow(non_snake_case, dead_code)]
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SearchResultsResponse {
|
||||||
|
status: String,
|
||||||
|
version: String,
|
||||||
|
searchResults: SearchResults,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(non_snake_case, dead_code)]
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SearchResults {
|
||||||
|
page: u64,
|
||||||
|
pageSize: u64,
|
||||||
|
totalItems: u64,
|
||||||
|
searchResult: Vec<AnySearchResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(non_snake_case, non_camel_case_types, dead_code)]
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
enum AnySearchResult {
|
||||||
|
artist {
|
||||||
|
score: f64,
|
||||||
|
artist: ArtistSearchResult,
|
||||||
|
},
|
||||||
|
release {
|
||||||
|
score: f64,
|
||||||
|
release: ReleaseSearchResult,
|
||||||
|
},
|
||||||
|
track{
|
||||||
|
score: f64,
|
||||||
|
track: TrackSearchResult,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::convert::Into<MusicEntry> for AnySearchResult {
|
||||||
|
fn into(self) -> MusicEntry {
|
||||||
|
match self {
|
||||||
|
Self::artist{artist: x, ..} => MusicEntry::Artist(x.into()),
|
||||||
|
Self::release{release: x, ..} => MusicEntry::Release(x.into()),
|
||||||
|
Self::track{track: x, ..} => MusicEntry::Track(x.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(non_snake_case, dead_code)]
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ArtistSearchResult {
|
||||||
|
id: u64,
|
||||||
|
name: String,
|
||||||
|
appearsAs: Option<String>,
|
||||||
|
sortName: Option<String>,
|
||||||
|
slug: String,
|
||||||
|
image: String,
|
||||||
|
isPlaceholderImage: Option<String>, // This string is actually a bool WTF
|
||||||
|
popularity: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::convert::Into<ArtistEntry> for ArtistSearchResult {
|
||||||
|
fn into(self) -> ArtistEntry {
|
||||||
|
ArtistEntry {
|
||||||
|
id: Some(self.id),
|
||||||
|
name: self.name,
|
||||||
|
image: Some(self.image),
|
||||||
|
store: "7digital".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(non_snake_case, dead_code)]
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ReleaseSearchResult {
|
||||||
|
id: u64,
|
||||||
|
title: String,
|
||||||
|
version: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
type_: String,
|
||||||
|
barcode: Option<String>,
|
||||||
|
year: Option<u64>,
|
||||||
|
explicitContent: Option<bool>,
|
||||||
|
artist: ArtistSearchResult,
|
||||||
|
slug: String,
|
||||||
|
image: String,
|
||||||
|
label: LabelInfo,
|
||||||
|
licensor: LicensorInfo,
|
||||||
|
popularity: Option<f64>,
|
||||||
|
duration: Option<u64>,
|
||||||
|
trackCount: Option<u64>,
|
||||||
|
download: Option<DownloadInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::convert::Into<ReleaseEntry> for ReleaseSearchResult {
|
||||||
|
fn into(self) -> ReleaseEntry {
|
||||||
|
ReleaseEntry {
|
||||||
|
id: Some(self.id),
|
||||||
|
title: self.title,
|
||||||
|
artist: self.artist.into(),
|
||||||
|
store: "7digital".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(non_snake_case, dead_code)]
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LabelInfo {
|
||||||
|
// I don't care
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(non_snake_case, dead_code)]
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LicensorInfo {
|
||||||
|
// Ewww
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(non_snake_case, dead_code)]
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TrackSearchResult {
|
||||||
|
id: u64,
|
||||||
|
title: String,
|
||||||
|
version: String,
|
||||||
|
artist: ArtistSearchResult,
|
||||||
|
trackNumber: u64,
|
||||||
|
duration: u64,
|
||||||
|
explicitContent: bool,
|
||||||
|
isrc: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
type_: String,
|
||||||
|
release: ReleaseSearchResult,
|
||||||
|
discNumber: u64,
|
||||||
|
popularity: Option<f64>,
|
||||||
|
number: u64,
|
||||||
|
download: Option<DownloadInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::convert::Into<TrackEntry> for TrackSearchResult {
|
||||||
|
fn into(self) -> TrackEntry {
|
||||||
|
TrackEntry {
|
||||||
|
id: Some(self.id),
|
||||||
|
title: self.title,
|
||||||
|
release: Some(self.release.into()),
|
||||||
|
artist: self.artist.into(),
|
||||||
|
store: "7digital".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(non_snake_case, dead_code)]
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DownloadInfo {
|
||||||
|
// I don't care
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn interface_test_7digital() {
|
||||||
|
let key = std::env::var("SEVENDIGITAL_OATH_KEY").expect("missing 7Digital oath key in environment");
|
||||||
|
let shop_id = 1069; // Canada
|
||||||
|
let store = SevenDigital::new(shop_id, key);
|
||||||
|
let results = store.search("test".to_owned()).await;
|
||||||
|
let results = results.expect("Search results query failed");
|
||||||
|
assert_eq!(results.len(), 33);
|
||||||
|
}
|
||||||
|
}
|
25
munite-core/src/errors.rs
Normal file
25
munite-core/src/errors.rs
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
/// Error information for munite functionality
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum MuniteError {
|
||||||
|
/// HTTP failure
|
||||||
|
Http(reqwest::Error),
|
||||||
|
/// Unexpected failure
|
||||||
|
Unexpected(String),
|
||||||
|
/// Store does not support that operation
|
||||||
|
NotSupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for MuniteError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Http(err) => write!(f, "HTTP error: {}", err),
|
||||||
|
Self::Unexpected(msg) => write!(f, "Unexpected error: {}", msg),
|
||||||
|
Self::NotSupported => write!(f, "Not Supported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for MuniteError {}
|
||||||
|
|
||||||
|
/// Result with MuniteError Err type
|
||||||
|
pub type MuniteResult<T> = Result<T, MuniteError>;
|
26
munite-core/src/lib.rs
Normal file
26
munite-core/src/lib.rs
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
//! Core functionality for Munite, a united media search client
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
pub mod audio;
|
||||||
|
mod errors;
|
||||||
|
mod search;
|
||||||
|
|
||||||
|
pub use errors::{MuniteError, MuniteResult};
|
||||||
|
|
||||||
|
pub use search::{StoreSearch, StoreEntry};
|
||||||
|
|
||||||
|
/// All available SearchStores
|
||||||
|
pub fn stores() -> Vec<Box<dyn StoreSearch>> {
|
||||||
|
audio::stores()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
//use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn it_works() {
|
||||||
|
let result = 2 + 2;
|
||||||
|
assert_eq!(result, 4);
|
||||||
|
}
|
||||||
|
}
|
22
munite-core/src/search.rs
Normal file
22
munite-core/src/search.rs
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
use super::MuniteResult;
|
||||||
|
|
||||||
|
/// A search result from a store
|
||||||
|
pub enum StoreEntry {
|
||||||
|
/// A search result from an audio store
|
||||||
|
Audio(crate::audio::AudioEntry),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for StoreEntry {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Audio(x) => write!(f, "[AUDIO]{}", x),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store search client for a specific store
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait StoreSearch {
|
||||||
|
/// Search the store for the given string `s`, returning the results of the search
|
||||||
|
async fn search(&self, s: String) -> MuniteResult<Vec<StoreEntry>>;
|
||||||
|
}
|
34
src/cli.rs
Normal file
34
src/cli.rs
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
use clap::{Args, Parser, Subcommand};
|
||||||
|
|
||||||
|
/// Unified media search tool
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(author, version, about, long_about = None)]
|
||||||
|
pub struct CliArgs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub category: StoreCategory,
|
||||||
|
/// Max results per store
|
||||||
|
#[arg(long, short)]
|
||||||
|
pub max: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CliArgs {
|
||||||
|
pub fn get() -> Self {
|
||||||
|
Self::parse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
pub enum StoreCategory {
|
||||||
|
/// Search all available stores
|
||||||
|
All(StandardSearch),
|
||||||
|
/// Search all audio stores
|
||||||
|
Audio(StandardSearch),
|
||||||
|
/// Search all music stores
|
||||||
|
Music(StandardSearch),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
pub struct StandardSearch {
|
||||||
|
/// Search query
|
||||||
|
pub query: String,
|
||||||
|
}
|
57
src/main.rs
57
src/main.rs
|
@ -1,3 +1,56 @@
|
||||||
fn main() {
|
mod cli;
|
||||||
println!("Hello, world!");
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let args = cli::CliArgs::get();
|
||||||
|
|
||||||
|
let results = match args.category {
|
||||||
|
cli::StoreCategory::All(s) => {
|
||||||
|
let stores = munite_core::stores();
|
||||||
|
search_query(stores, s, args.max).await
|
||||||
|
},
|
||||||
|
cli::StoreCategory::Audio(s) => {
|
||||||
|
let stores = munite_core::audio::stores();
|
||||||
|
search_query(stores, s, args.max).await
|
||||||
|
},
|
||||||
|
cli::StoreCategory::Music(s) => {
|
||||||
|
let stores = munite_core::audio::music::stores();
|
||||||
|
search_query(stores, s, args.max).await
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for entry in results {
|
||||||
|
match entry {
|
||||||
|
Ok(entry) => println!("{}", entry),
|
||||||
|
Err(e) => eprintln!("[ERROR]{}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search_query(
|
||||||
|
stores: Vec<Box<dyn munite_core::StoreSearch>>,
|
||||||
|
query: cli::StandardSearch,
|
||||||
|
max_results: Option<usize>,
|
||||||
|
) -> Vec<munite_core::MuniteResult<munite_core::StoreEntry>> {
|
||||||
|
let mut awaitables = Vec::with_capacity(stores.len());
|
||||||
|
for store in stores.iter() {
|
||||||
|
awaitables.push(store.search(query.query.clone()));
|
||||||
|
}
|
||||||
|
let responses = futures_util::future::join_all(awaitables).await;
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for resp in responses {
|
||||||
|
match resp {
|
||||||
|
Ok(results) => {
|
||||||
|
if let Some(max) = max_results {
|
||||||
|
result.extend(results.into_iter().take(max).map(|x| Ok(x)));
|
||||||
|
} else {
|
||||||
|
result.extend(results.into_iter().map(|x| Ok(x)));
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
result.push(Err(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue