Add 7Digital and ProStudioMasters basic search

This commit is contained in:
NGnius (Graham) 2023-01-08 12:24:58 -05:00
parent 9e4e7196ae
commit 3f4fd9be5e
16 changed files with 3763 additions and 5 deletions

2
.gitignore vendored
View file

@ -1 +1 @@
/target
**/target

1585
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,10 @@
name = "munite"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
description = "United media searching"
[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

File diff suppressed because it is too large Load diff

16
munite-core/Cargo.toml Normal file
View 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" ] }

View 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()
)
}
}

View 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()
}

View 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()
)
}
}

View 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()),
]
}

View 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);
}
}

View 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
View 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
View 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
View 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
View 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,
}

View file

@ -1,3 +1,56 @@
fn main() {
println!("Hello, world!");
mod cli;
#[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
}