Add general store functionality and BestBuy Canada
This commit is contained in:
parent
450ee41971
commit
ea1e748399
13 changed files with 515 additions and 38 deletions
|
@ -7,9 +7,33 @@ pub enum AudioExtra {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store audio type
|
/// Store audio type
|
||||||
|
#[derive(Eq)]
|
||||||
pub enum AudioType {
|
pub enum AudioType {
|
||||||
/// Music store
|
/// Music store
|
||||||
Music
|
Music,
|
||||||
|
/// Matches any audio type (not to be used by stores)
|
||||||
|
Any
|
||||||
|
}
|
||||||
|
|
||||||
|
// nighytly-only
|
||||||
|
//const ANY_DISCRIMINANT: std::mem::Discriminant<AudioType> = std::mem::discriminant(&AudioType::Any);
|
||||||
|
|
||||||
|
impl std::cmp::PartialEq for AudioType {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
let self_d = std::mem::discriminant(self);
|
||||||
|
let other_d = std::mem::discriminant(other);
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
let ANY_DISCRIMINANT = std::mem::discriminant(&Self::Any);
|
||||||
|
self_d == other_d || self_d == ANY_DISCRIMINANT || other_d == ANY_DISCRIMINANT
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ne(&self, other: &Self) -> bool {
|
||||||
|
let self_d = std::mem::discriminant(self);
|
||||||
|
let other_d = std::mem::discriminant(other);
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
let ANY_DISCRIMINANT = std::mem::discriminant(&Self::Any);
|
||||||
|
self_d != other_d && self_d != ANY_DISCRIMINANT && other_d != ANY_DISCRIMINANT
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::convert::Into<StoreExtra> for AudioExtra {
|
impl std::convert::Into<StoreExtra> for AudioExtra {
|
||||||
|
@ -73,3 +97,16 @@ impl<X: AudioSearchStore + Sync> StoreSearch for X {
|
||||||
|
|
||||||
audio_store_impl!{super::music::SevenDigital}
|
audio_store_impl!{super::music::SevenDigital}
|
||||||
audio_store_impl!{super::music::ProStudioMasters}
|
audio_store_impl!{super::music::ProStudioMasters}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audiotype_eq_test() {
|
||||||
|
assert!(AudioType::Music == AudioType::Any);
|
||||||
|
assert!(AudioType::Any == AudioType::Music);
|
||||||
|
assert!(AudioType::Any == AudioType::Any);
|
||||||
|
assert!(AudioType::Music == AudioType::Music);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -10,6 +10,8 @@ pub enum MusicExtra {
|
||||||
Release(ReleaseExtra),
|
Release(ReleaseExtra),
|
||||||
/// A search result for a track
|
/// A search result for a track
|
||||||
Track(TrackExtra),
|
Track(TrackExtra),
|
||||||
|
/// A search result for a physical album with limited information
|
||||||
|
Physical(MusicMediumType),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for MusicExtra {
|
impl std::fmt::Display for MusicExtra {
|
||||||
|
@ -18,6 +20,27 @@ impl std::fmt::Display for MusicExtra {
|
||||||
Self::Artist(x) => write!(f, "[ARTIST]{}", x),
|
Self::Artist(x) => write!(f, "[ARTIST]{}", x),
|
||||||
Self::Release(x) => write!(f, "[RELEASE]{}", x),
|
Self::Release(x) => write!(f, "[RELEASE]{}", x),
|
||||||
Self::Track(x) => write!(f, "[TRACK]{}", x),
|
Self::Track(x) => write!(f, "[TRACK]{}", x),
|
||||||
|
Self::Physical(x) => write!(f, "[{}]", x),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Physical music media
|
||||||
|
pub enum MusicMediumType {
|
||||||
|
/// Compact Disc
|
||||||
|
CD,
|
||||||
|
/// Vinyl LP
|
||||||
|
Vinyl,
|
||||||
|
/// Any cassette form factor
|
||||||
|
Cassette,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for MusicMediumType {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::CD => write!(f, "CD"),
|
||||||
|
Self::Vinyl => write!(f, "Vinyl"),
|
||||||
|
Self::Cassette => write!(f, "Cassette"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ pub use pro_studio_masters::ProStudioMasters;
|
||||||
|
|
||||||
mod interface;
|
mod interface;
|
||||||
|
|
||||||
pub use interface::{MusicExtra, MusicSearchStore, TrackExtra, ArtistExtra, ReleaseExtra};
|
pub use interface::{MusicExtra, MusicSearchStore, TrackExtra, ArtistExtra, ReleaseExtra, MusicMediumType};
|
||||||
|
|
||||||
/// All available MusicSearchStores
|
/// All available MusicSearchStores
|
||||||
pub fn all() -> Vec<Box<dyn MusicSearchStore>> {
|
pub fn all() -> Vec<Box<dyn MusicSearchStore>> {
|
||||||
|
|
|
@ -2,7 +2,7 @@ use reqwest::{Client, RequestBuilder};
|
||||||
use soup::{Soup, QueryBuilderExt, NodeExt};
|
use soup::{Soup, QueryBuilderExt, NodeExt};
|
||||||
|
|
||||||
use super::{MusicExtra, MusicSearchStore, ReleaseExtra, ArtistExtra};
|
use super::{MusicExtra, MusicSearchStore, ReleaseExtra, ArtistExtra};
|
||||||
use crate::{MuniteError, StoreEntry, StorePrice, StoreCurrency};
|
use crate::{MuniteError, StoreEntry, StorePrice, StoreCurrency, StoreId};
|
||||||
|
|
||||||
/// ProStudioMasters store client
|
/// ProStudioMasters store client
|
||||||
pub struct ProStudioMasters {
|
pub struct ProStudioMasters {
|
||||||
|
@ -59,7 +59,8 @@ impl MusicSearchStore for ProStudioMasters {
|
||||||
id: id.split('/')
|
id: id.split('/')
|
||||||
.last()
|
.last()
|
||||||
.map(|x| x.parse().ok())
|
.map(|x| x.parse().ok())
|
||||||
.flatten(),
|
.flatten()
|
||||||
|
.map(|x| StoreId::Id(x)),
|
||||||
title: title.text().trim().to_owned(),
|
title: title.text().trim().to_owned(),
|
||||||
store: "ProStudioMasters".to_owned(),
|
store: "ProStudioMasters".to_owned(),
|
||||||
extra: MusicExtra::Release(ReleaseExtra {
|
extra: MusicExtra::Release(ReleaseExtra {
|
||||||
|
@ -89,6 +90,6 @@ mod tests {
|
||||||
let store = ProStudioMasters::new();
|
let store = ProStudioMasters::new();
|
||||||
let results = store.search("test".to_owned()).await;
|
let results = store.search("test".to_owned()).await;
|
||||||
let results = results.expect("Search results query failed");
|
let results = results.expect("Search results query failed");
|
||||||
assert_eq!(results.len(), 144);
|
assert_ne!(results.len(), 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@ use reqwest::{Client, RequestBuilder};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use super::{MusicExtra, MusicSearchStore, TrackExtra, ReleaseExtra, ArtistExtra};
|
use super::{MusicExtra, MusicSearchStore, TrackExtra, ReleaseExtra, ArtistExtra};
|
||||||
use crate::{MuniteError, StoreEntry, StorePrice, StoreCurrency};
|
use crate::{MuniteError, StoreEntry, StorePrice, StoreCurrency, StoreId};
|
||||||
|
|
||||||
/// 7Digital store client
|
/// 7Digital store client
|
||||||
pub struct SevenDigital {
|
pub struct SevenDigital {
|
||||||
|
@ -146,7 +146,7 @@ struct ArtistSearchResult {
|
||||||
impl std::convert::Into<StoreEntry> for ArtistSearchResult {
|
impl std::convert::Into<StoreEntry> for ArtistSearchResult {
|
||||||
fn into(self) -> StoreEntry {
|
fn into(self) -> StoreEntry {
|
||||||
StoreEntry {
|
StoreEntry {
|
||||||
id: Some(self.id),
|
id: Some(StoreId::Id(self.id)),
|
||||||
title: self.name,
|
title: self.name,
|
||||||
extra: MusicExtra::Artist(ArtistExtra {
|
extra: MusicExtra::Artist(ArtistExtra {
|
||||||
image: Some(self.image)
|
image: Some(self.image)
|
||||||
|
@ -182,7 +182,7 @@ struct ReleaseSearchResult {
|
||||||
impl std::convert::Into<StoreEntry> for ReleaseSearchResult {
|
impl std::convert::Into<StoreEntry> for ReleaseSearchResult {
|
||||||
fn into(self) -> StoreEntry {
|
fn into(self) -> StoreEntry {
|
||||||
StoreEntry {
|
StoreEntry {
|
||||||
id: Some(self.id),
|
id: Some(StoreId::Id(self.id)),
|
||||||
title: self.title,
|
title: self.title,
|
||||||
extra: MusicExtra::Release(ReleaseExtra {
|
extra: MusicExtra::Release(ReleaseExtra {
|
||||||
artist_name: self.artist.name,
|
artist_name: self.artist.name,
|
||||||
|
@ -231,7 +231,7 @@ struct TrackSearchResult {
|
||||||
impl std::convert::Into<StoreEntry> for TrackSearchResult {
|
impl std::convert::Into<StoreEntry> for TrackSearchResult {
|
||||||
fn into(self) -> StoreEntry {
|
fn into(self) -> StoreEntry {
|
||||||
StoreEntry {
|
StoreEntry {
|
||||||
id: Some(self.id),
|
id: Some(StoreId::Id(self.id)),
|
||||||
title: self.title,
|
title: self.title,
|
||||||
extra: MusicExtra::Track(TrackExtra {
|
extra: MusicExtra::Track(TrackExtra {
|
||||||
artist_name: self.artist.name.to_owned(),
|
artist_name: self.artist.name.to_owned(),
|
||||||
|
@ -265,11 +265,11 @@ mod tests {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn interface_test_7digital() {
|
async fn interface_test_7digital() {
|
||||||
let key = std::env::var("SEVENDIGITAL_OATH_KEY").expect("missing 7Digital oath key in environment");
|
let key = std::env::var("SEVENDIGITAL_OATH_KEY").unwrap_or_else(|_| String::from("7drfpc993qp5"));
|
||||||
let shop_id = 1069; // Canada
|
let shop_id = 1069; // Canada
|
||||||
let store = SevenDigital::new(shop_id, key);
|
let store = SevenDigital::new(shop_id, key);
|
||||||
let results = store.search("test".to_owned()).await;
|
let results = store.search("test".to_owned()).await;
|
||||||
let results = results.expect("Search results query failed");
|
let results = results.expect("Search results query failed");
|
||||||
assert_eq!(results.len(), 33);
|
assert_ne!(results.len(), 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,20 +4,25 @@ pub enum StoreExtra {
|
||||||
Audio(crate::audio::AudioExtra),
|
Audio(crate::audio::AudioExtra),
|
||||||
/// Extra search information from a video store
|
/// Extra search information from a video store
|
||||||
Video(crate::video::VideoExtra),
|
Video(crate::video::VideoExtra),
|
||||||
|
/// No extra search information available
|
||||||
|
Nothing,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store type
|
/// Store type
|
||||||
|
#[derive(PartialEq, Eq)]
|
||||||
pub enum StoreType {
|
pub enum StoreType {
|
||||||
/// Audio store
|
/// Audio store
|
||||||
Audio(crate::audio::AudioType),
|
Audio(crate::audio::AudioType),
|
||||||
/// Video store
|
/// Video store
|
||||||
Video(crate::video::VideoType),
|
Video(crate::video::VideoType),
|
||||||
|
/// General store
|
||||||
|
General,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A search result from a store
|
/// A search result from a store
|
||||||
pub struct StoreEntry {
|
pub struct StoreEntry {
|
||||||
/// Entry ID used by the store
|
/// Entry ID used by the store
|
||||||
pub id: Option<u64>,
|
pub id: Option<StoreId>,
|
||||||
/// Entry title
|
/// Entry title
|
||||||
pub title: String,
|
pub title: String,
|
||||||
/// Store name
|
/// Store name
|
||||||
|
@ -28,6 +33,34 @@ pub struct StoreEntry {
|
||||||
pub price: StorePrice,
|
pub price: StorePrice,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A store identifier
|
||||||
|
pub enum StoreId {
|
||||||
|
/// SKU string
|
||||||
|
Sku(String),
|
||||||
|
/// Numerical ID
|
||||||
|
Id(u64),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoreId {
|
||||||
|
/// Parse a raw store id into number or sku variant
|
||||||
|
pub fn from_parse(input: String) -> Self {
|
||||||
|
if let Ok(id) = input.parse() {
|
||||||
|
Self::Id(id)
|
||||||
|
} else {
|
||||||
|
Self::Sku(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for StoreId {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Sku(s) => write!(f, "{}", s),
|
||||||
|
Self::Id(id) => write!(f, "{}", id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Store price information
|
/// Store price information
|
||||||
pub struct StorePrice {
|
pub struct StorePrice {
|
||||||
/// Price of store result, in 100ths of a dollar
|
/// Price of store result, in 100ths of a dollar
|
||||||
|
@ -49,13 +82,14 @@ impl std::fmt::Display for StoreExtra {
|
||||||
match self {
|
match self {
|
||||||
Self::Audio(x) => write!(f, "[AUDIO]{}", x),
|
Self::Audio(x) => write!(f, "[AUDIO]{}", x),
|
||||||
Self::Video(x) => write!(f, "[VIDEO]{}", x),
|
Self::Video(x) => write!(f, "[VIDEO]{}", x),
|
||||||
|
Self::Nothing => write!(f, ""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for StoreEntry {
|
impl std::fmt::Display for StoreEntry {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
if let Some(id) = self.id {
|
if let Some(id) = &self.id {
|
||||||
write!(f, "{} ({}:{}) {} ({})", self.title, self.store, id, self.extra, self.price)
|
write!(f, "{} ({}:{}) {} ({})", self.title, self.store, id, self.extra, self.price)
|
||||||
} else {
|
} else {
|
||||||
write!(f, "{} ({}) {} ({})", self.title, self.store, self.extra, self.price)
|
write!(f, "{} ({}) {} ({})", self.title, self.store, self.extra, self.price)
|
||||||
|
@ -66,8 +100,8 @@ impl std::fmt::Display for StoreEntry {
|
||||||
impl std::fmt::Display for StorePrice {
|
impl std::fmt::Display for StorePrice {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self.currency {
|
match self.currency {
|
||||||
StoreCurrency::Cad => write!(f, "${}.{} CAD", self.cents / 100, self.cents % 100),
|
StoreCurrency::Cad => write!(f, "${}.{:02} CAD", self.cents / 100, self.cents % 100),
|
||||||
StoreCurrency::Unknown => write!(f, "?{}.{}?", self.cents / 100, self.cents % 100),
|
StoreCurrency::Unknown => write!(f, "?{}.{:02}?", self.cents / 100, self.cents % 100),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
207
munite-core/src/general/best_buy_ca.rs
Normal file
207
munite-core/src/general/best_buy_ca.rs
Normal file
|
@ -0,0 +1,207 @@
|
||||||
|
use reqwest::{Client, RequestBuilder};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use super::GeneralSearchStore;
|
||||||
|
use crate::audio::{AudioExtra, music::{MusicExtra, MusicMediumType}};
|
||||||
|
use crate::video::{VideoExtra, VideoInfoExtra, MovieExtra, VideoFormat, TvSeriesExtra};
|
||||||
|
use crate::{MuniteError, StoreEntry, StorePrice, StoreCurrency, StoreType, StoreId, StoreExtra};
|
||||||
|
|
||||||
|
/// BestBuy Canada store client
|
||||||
|
pub struct BestBuyCa {
|
||||||
|
client: Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BestBuyCa {
|
||||||
|
/// Create a new client for a BestBuy store
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
client: Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_search_query(&self, r: RequestBuilder, query: &str, page: u64, page_size: u64, path: &str, sort_by: &str, sort_direction: &str) -> RequestBuilder {
|
||||||
|
r
|
||||||
|
.query(&[
|
||||||
|
("page", &page.to_string() as &str),
|
||||||
|
("path", path),
|
||||||
|
("pageSize", &page_size.to_string() as &str),
|
||||||
|
("query", query),
|
||||||
|
("sortBy", sort_by),
|
||||||
|
("sortDir", sort_direction),
|
||||||
|
("exp", "search_abtesting_5050_conversion:b")
|
||||||
|
])
|
||||||
|
//.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")
|
||||||
|
//.header("Accept", "application/json, text/javascript, */*; q=0.01")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_store(&self, query: &str, page: u64, page_size: u64, path: &str, sort_by: &str, sort_direction: &str) -> RequestBuilder {
|
||||||
|
self.apply_search_query(
|
||||||
|
self.client.get("https://www.bestbuy.ca/api/v2/json/search"),
|
||||||
|
query,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
path,
|
||||||
|
sort_by,
|
||||||
|
sort_direction,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::default::Default for BestBuyCa {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_PAGE_SIZE: u64 = 100; // Anything larger is clamped back down to this value
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl GeneralSearchStore for BestBuyCa {
|
||||||
|
async fn search(&self, s: String, category: StoreType) -> crate::MuniteResult<Vec<StoreEntry>> {
|
||||||
|
let result = match category {
|
||||||
|
StoreType::General => self.search_store(&s, 1, MAX_PAGE_SIZE, "", "relevance", "desc").send().await,
|
||||||
|
StoreType::Video(_) => self.search_store(&s, 1, MAX_PAGE_SIZE, "category:Movies & Music;category:Movies & TV Shows", "relevance", "desc").send().await,
|
||||||
|
StoreType::Audio(_) => self.search_store(&s, 1, MAX_PAGE_SIZE, "category:Movies & Music;category:Music", "relevance", "desc").send().await,
|
||||||
|
}.map_err(|e| MuniteError::Http(e))?;
|
||||||
|
let json_data: SearchResultsResponse = result.json().await.map_err(|e| MuniteError::Http(e))?;
|
||||||
|
let mut output = Vec::with_capacity(json_data.products.len());
|
||||||
|
for entry in json_data.products {
|
||||||
|
output.push(entry.into());
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store(&self) -> Vec<StoreType> {
|
||||||
|
vec![
|
||||||
|
StoreType::General,
|
||||||
|
StoreType::Audio(crate::audio::AudioType::Music),
|
||||||
|
StoreType::Video(crate::video::VideoType::TvSeries),
|
||||||
|
StoreType::Video(crate::video::VideoType::Movie),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API datatypes
|
||||||
|
|
||||||
|
#[allow(non_snake_case, dead_code)]
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SearchResultsResponse {
|
||||||
|
currentPage: u64,
|
||||||
|
total: u64,
|
||||||
|
totalPages: u64,
|
||||||
|
pageSize: u64,
|
||||||
|
products: Vec<ProductInfo>
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(non_snake_case, dead_code)]
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ProductInfo {
|
||||||
|
sku: String,
|
||||||
|
name: String,
|
||||||
|
shortDescription: String,
|
||||||
|
productUrl: String,
|
||||||
|
regularPrice: f64,
|
||||||
|
salePrice: f64,
|
||||||
|
categoryName: String,
|
||||||
|
seoText: String,
|
||||||
|
categoryIds: Vec<String>, // numbers as strings
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seo_contains(seo: &str, word: &str) -> bool {
|
||||||
|
seo.contains(&format!("-{}-", word))
|
||||||
|
|| seo.starts_with(&format!("{}-", word))
|
||||||
|
|| seo.ends_with(&format!("-{}", word))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn product_extras(prod: &ProductInfo) -> StoreExtra {
|
||||||
|
// some category ids:
|
||||||
|
// 20404 is Movies & TV Shows
|
||||||
|
// 20404a is Blu-ray movies
|
||||||
|
// 20404b is DVD movies
|
||||||
|
// 20527 is DVD TV Shows
|
||||||
|
// 27659 is Blu-ray TV shows
|
||||||
|
// 27645 is Blu-ray action movies
|
||||||
|
// 20002 is Movies & Music
|
||||||
|
// 20554 is Music
|
||||||
|
// 20986 is Rock Music
|
||||||
|
if prod.categoryIds.contains(&"20002".to_owned()) {
|
||||||
|
// is audio or video
|
||||||
|
if prod.categoryIds.contains(&"20554".to_owned()) {
|
||||||
|
// is music
|
||||||
|
if seo_contains(&prod.seoText, "vinyl") {
|
||||||
|
StoreExtra::Audio(AudioExtra::Music(MusicExtra::Physical(MusicMediumType::Vinyl)))
|
||||||
|
} else if seo_contains(&prod.seoText, "compact-discs") {
|
||||||
|
StoreExtra::Audio(AudioExtra::Music(MusicExtra::Physical(MusicMediumType::CD)))
|
||||||
|
} else {
|
||||||
|
StoreExtra::Nothing
|
||||||
|
}
|
||||||
|
} else if prod.categoryIds.contains(&"20404".to_owned()) {
|
||||||
|
// is video
|
||||||
|
// NOTE: 4K Blu-rays are NOT a different category, they're grouped in with regular blu-rays
|
||||||
|
if prod.categoryIds.contains(&"20404a".to_owned()) {
|
||||||
|
// Blu-ray movie
|
||||||
|
StoreExtra::Video(VideoExtra {
|
||||||
|
format: if seo_contains(&prod.seoText, "4k") { VideoFormat::BlurayUhd } else { VideoFormat::Bluray },
|
||||||
|
info: Some(VideoInfoExtra::Movie(MovieExtra { })),
|
||||||
|
})
|
||||||
|
} else if prod.categoryIds.contains(&"20404b".to_owned()) {
|
||||||
|
// DVD movie
|
||||||
|
StoreExtra::Video(VideoExtra {
|
||||||
|
format: VideoFormat::Dvd,
|
||||||
|
info: Some(VideoInfoExtra::Movie(MovieExtra { })),
|
||||||
|
})
|
||||||
|
} else if prod.categoryIds.contains(&"27659".to_owned()) {
|
||||||
|
// Blu-ray TV show
|
||||||
|
StoreExtra::Video(VideoExtra {
|
||||||
|
format: if seo_contains(&prod.seoText, "4k") { VideoFormat::BlurayUhd } else { VideoFormat::Bluray },
|
||||||
|
info: Some(VideoInfoExtra::TvSeries(TvSeriesExtra { })),
|
||||||
|
})
|
||||||
|
} else if prod.categoryIds.contains(&"20527".to_owned()) {
|
||||||
|
// DVD TV show
|
||||||
|
StoreExtra::Video(VideoExtra {
|
||||||
|
format: VideoFormat::Dvd,
|
||||||
|
info: Some(VideoInfoExtra::TvSeries(TvSeriesExtra { })),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
StoreExtra::Nothing
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
StoreExtra::Nothing
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
StoreExtra::Nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::convert::Into<StoreEntry> for ProductInfo {
|
||||||
|
fn into(self) -> StoreEntry {
|
||||||
|
let extra_info = product_extras(&self);
|
||||||
|
StoreEntry {
|
||||||
|
id: Some(StoreId::Sku(self.sku)),
|
||||||
|
title: self.name,
|
||||||
|
store: "BestBuy.ca".to_owned(),
|
||||||
|
extra: extra_info,
|
||||||
|
price: StorePrice {
|
||||||
|
cents: (self.salePrice * 100.0) as _,
|
||||||
|
currency: StoreCurrency::Cad,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::StoreSearch;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn interface_test_bestbuyca() {
|
||||||
|
let store = BestBuyCa::new();
|
||||||
|
let results = (&store as &dyn StoreSearch).search("test".to_owned()).await;
|
||||||
|
let results = results.expect("Search results query failed");
|
||||||
|
assert_ne!(results.len(), 0);
|
||||||
|
}
|
||||||
|
}
|
64
munite-core/src/general/interface.rs
Normal file
64
munite-core/src/general/interface.rs
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
use crate::{MuniteResult, StoreEntry, StoreSearch, StoreType};
|
||||||
|
use crate::audio::{AudioType, AudioSearchStore};
|
||||||
|
use crate::video::{VideoType, VideoSearchStore};
|
||||||
|
|
||||||
|
/// Store with no specific goods functionality
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait GeneralSearchStore {
|
||||||
|
/// Search the store for the given string `s`, returning the results of the search
|
||||||
|
async fn search(&self, s: String, category: StoreType) -> MuniteResult<Vec<StoreEntry>>;
|
||||||
|
|
||||||
|
/// Store type
|
||||||
|
fn store(&self) -> Vec<StoreType>;
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! general_store_impl {
|
||||||
|
($name:ty) => {
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl StoreSearch for $name {
|
||||||
|
async fn search(&self, s: String) -> MuniteResult<Vec<StoreEntry>> {
|
||||||
|
(self as &(dyn GeneralSearchStore + Sync))
|
||||||
|
.search(s, StoreType::General)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store(&self) -> Vec<crate::StoreType> {
|
||||||
|
(self as &(dyn GeneralSearchStore)).store()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl VideoSearchStore for $name {
|
||||||
|
async fn search(&self, s: String) -> MuniteResult<Vec<StoreEntry>> {
|
||||||
|
(self as &(dyn GeneralSearchStore + Sync))
|
||||||
|
.search(s, StoreType::Video(VideoType::Any))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store(&self) -> Vec<VideoType> {
|
||||||
|
(self as &(dyn GeneralSearchStore)).store()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|x| if let StoreType::Video(v) = x { Some(v) } else {None})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl AudioSearchStore for $name {
|
||||||
|
async fn search(&self, s: String) -> MuniteResult<Vec<StoreEntry>> {
|
||||||
|
(self as &(dyn GeneralSearchStore + Sync))
|
||||||
|
.search(s, StoreType::Audio(AudioType::Any))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store(&self) -> Vec<AudioType> {
|
||||||
|
(self as &(dyn GeneralSearchStore)).store()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|x| if let StoreType::Audio(a) = x { Some(a) } else {None})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
general_store_impl!{super::BestBuyCa}
|
34
munite-core/src/general/mod.rs
Normal file
34
munite-core/src/general/mod.rs
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
//! General store functionality
|
||||||
|
mod interface;
|
||||||
|
mod best_buy_ca;
|
||||||
|
|
||||||
|
pub use interface::GeneralSearchStore;
|
||||||
|
pub use best_buy_ca::BestBuyCa;
|
||||||
|
|
||||||
|
/// All available GeneralSearchStores
|
||||||
|
pub fn all() -> Vec<Box<dyn GeneralSearchStore>> {
|
||||||
|
vec![
|
||||||
|
Box::new(BestBuyCa::default())
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All available GeneralSearchStores
|
||||||
|
pub fn all_audio() -> Vec<Box<dyn crate::audio::AudioSearchStore>> {
|
||||||
|
vec![
|
||||||
|
Box::new(BestBuyCa::default())
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All available GeneralSearchStores
|
||||||
|
pub fn all_video() -> Vec<Box<dyn crate::video::VideoSearchStore>> {
|
||||||
|
vec![
|
||||||
|
Box::new(BestBuyCa::default())
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All available general SearchStores
|
||||||
|
pub fn stores() -> Vec<Box<dyn crate::StoreSearch>> {
|
||||||
|
vec![
|
||||||
|
Box::new(BestBuyCa::default())
|
||||||
|
]
|
||||||
|
}
|
|
@ -1,20 +1,25 @@
|
||||||
//! Core functionality for Munite, a united media search client
|
//! Core functionality for Munite, a united media search client
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
|
// nightly-only
|
||||||
|
//#![feature(const_discriminant)]
|
||||||
|
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
|
pub mod general;
|
||||||
pub mod video;
|
pub mod video;
|
||||||
mod entry;
|
mod entry;
|
||||||
mod errors;
|
mod errors;
|
||||||
mod search;
|
mod search;
|
||||||
|
|
||||||
pub use entry::{StoreEntry, StoreExtra, StoreType, StorePrice, StoreCurrency};
|
pub use entry::{StoreEntry, StoreExtra, StoreType, StorePrice, StoreCurrency, StoreId};
|
||||||
pub use errors::{MuniteError, MuniteResult};
|
pub use errors::{MuniteError, MuniteResult};
|
||||||
|
|
||||||
pub use search::StoreSearch;
|
pub use search::StoreSearch;
|
||||||
|
|
||||||
/// All available SearchStores
|
/// All available SearchStores
|
||||||
pub fn stores() -> Vec<Box<dyn StoreSearch>> {
|
pub fn stores() -> Vec<Box<dyn StoreSearch>> {
|
||||||
audio::stores()
|
let mut all = audio::stores();
|
||||||
|
all.append(&mut video::stores());
|
||||||
|
all
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
@ -2,7 +2,7 @@ use reqwest::{Client, RequestBuilder};
|
||||||
use soup::{Soup, QueryBuilderExt, NodeExt};
|
use soup::{Soup, QueryBuilderExt, NodeExt};
|
||||||
|
|
||||||
use super::{VideoExtra, /*TvSeriesExtra, MovieExtra, VideoBoxSetExtra, VideoInfoExtra,*/ VideoType, VideoFormat};
|
use super::{VideoExtra, /*TvSeriesExtra, MovieExtra, VideoBoxSetExtra, VideoInfoExtra,*/ VideoType, VideoFormat};
|
||||||
use crate::{MuniteError, StoreEntry, StorePrice, StoreCurrency};
|
use crate::{MuniteError, StoreEntry, StorePrice, StoreCurrency, StoreId};
|
||||||
|
|
||||||
/// Cinema1 store client
|
/// Cinema1 store client
|
||||||
pub struct CinemaOne {
|
pub struct CinemaOne {
|
||||||
|
@ -65,7 +65,8 @@ impl CinemaOne {
|
||||||
.split('-')
|
.split('-')
|
||||||
.last()
|
.last()
|
||||||
.map(|x| x.replace(".html", "").parse().ok())
|
.map(|x| x.replace(".html", "").parse().ok())
|
||||||
.flatten(),
|
.flatten()
|
||||||
|
.map(|x| StoreId::Id(x)),
|
||||||
title: title.text().trim().to_owned(),
|
title: title.text().trim().to_owned(),
|
||||||
store: "Cinema1".to_owned(),
|
store: "Cinema1".to_owned(),
|
||||||
extra: VideoExtra {
|
extra: VideoExtra {
|
||||||
|
@ -82,6 +83,7 @@ impl CinemaOne {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
fn next_page_exists(soup: &Soup) -> bool {
|
fn next_page_exists(soup: &Soup) -> bool {
|
||||||
soup.class("pages-item-next").find().is_some()
|
soup.class("pages-item-next").find().is_some()
|
||||||
}
|
}
|
||||||
|
@ -96,11 +98,14 @@ impl std::default::Default for CinemaOne {
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl super::VideoSearchStore for CinemaOne {
|
impl super::VideoSearchStore for CinemaOne {
|
||||||
async fn search(&self, s: String) -> crate::MuniteResult<Vec<StoreEntry>> {
|
async fn search(&self, s: String) -> crate::MuniteResult<Vec<StoreEntry>> {
|
||||||
let mut page = 1;
|
let /*mut*/ page = 1;
|
||||||
let mut output = Vec::new();
|
let mut output = Vec::new();
|
||||||
let releases = self.search_releases(&s, page).send().await.map_err(|e| MuniteError::Http(e))?;
|
let releases = self.search_releases(&s, page).send().await.map_err(|e| MuniteError::Http(e))?;
|
||||||
let page_html = releases.text().await.map_err(|e| MuniteError::Http(e))?;
|
let page_html = releases.text().await.map_err(|e| MuniteError::Http(e))?;
|
||||||
let mut next_page_ok = {
|
let soup_data = Soup::new(&page_html);
|
||||||
|
Self::process_page(&mut output, &soup_data);
|
||||||
|
// Disabled since it's too slow
|
||||||
|
/*let mut next_page_ok = {
|
||||||
let soup_data = Soup::new(&page_html);
|
let soup_data = Soup::new(&page_html);
|
||||||
let next_page_ok = Self::next_page_exists(&soup_data);
|
let next_page_ok = Self::next_page_exists(&soup_data);
|
||||||
Self::process_page(&mut output, &soup_data);
|
Self::process_page(&mut output, &soup_data);
|
||||||
|
@ -113,7 +118,7 @@ impl super::VideoSearchStore for CinemaOne {
|
||||||
let soup_data = Soup::new(&page_html);
|
let soup_data = Soup::new(&page_html);
|
||||||
next_page_ok = Self::next_page_exists(&soup_data);
|
next_page_ok = Self::next_page_exists(&soup_data);
|
||||||
Self::process_page(&mut output, &soup_data);
|
Self::process_page(&mut output, &soup_data);
|
||||||
}
|
}*/
|
||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -133,10 +138,10 @@ mod tests {
|
||||||
use crate::video::VideoSearchStore;
|
use crate::video::VideoSearchStore;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn interface_test_prostudiomasters() {
|
async fn interface_test_cinema1() {
|
||||||
let store = CinemaOne::new();
|
let store = CinemaOne::new();
|
||||||
let results = store.search("test".to_owned()).await;
|
let results = store.search("test".to_owned()).await;
|
||||||
let results = results.expect("Search results query failed");
|
let results = results.expect("Search results query failed");
|
||||||
assert_eq!(results.len(), 144);
|
assert_ne!(results.len(), 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -107,11 +107,32 @@ impl std::fmt::Display for VideoBoxSetExtra {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store video type
|
/// Store video type
|
||||||
|
#[derive(Eq)]
|
||||||
pub enum VideoType {
|
pub enum VideoType {
|
||||||
/// TV store
|
/// TV store
|
||||||
TvSeries,
|
TvSeries,
|
||||||
/// Movie store
|
/// Movie store
|
||||||
Movie,
|
Movie,
|
||||||
|
/// Matches any video type (not to be used by stores)
|
||||||
|
Any
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::cmp::PartialEq for VideoType {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
let self_d = std::mem::discriminant(self);
|
||||||
|
let other_d = std::mem::discriminant(other);
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
let ANY_DISCRIMINANT = std::mem::discriminant(&Self::Any);
|
||||||
|
self_d == other_d || self_d == ANY_DISCRIMINANT || other_d == ANY_DISCRIMINANT
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ne(&self, other: &Self) -> bool {
|
||||||
|
let self_d = std::mem::discriminant(self);
|
||||||
|
let other_d = std::mem::discriminant(other);
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
let ANY_DISCRIMINANT = std::mem::discriminant(&Self::Any);
|
||||||
|
self_d != other_d && self_d != ANY_DISCRIMINANT && other_d != ANY_DISCRIMINANT
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::convert::Into<StoreExtra> for VideoExtra {
|
impl std::convert::Into<StoreExtra> for VideoExtra {
|
||||||
|
@ -176,3 +197,19 @@ impl<X: VideoSearchStore + Sync> StoreSearch for X {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
video_store_impl!{super::CinemaOne}
|
video_store_impl!{super::CinemaOne}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn videotype_eq_test() {
|
||||||
|
assert!(VideoType::TvSeries == VideoType::Any);
|
||||||
|
assert!(VideoType::Any == VideoType::TvSeries);
|
||||||
|
assert!(VideoType::Movie == VideoType::Any);
|
||||||
|
assert!(VideoType::Any == VideoType::Movie);
|
||||||
|
assert!(VideoType::Any == VideoType::Any);
|
||||||
|
assert!(VideoType::TvSeries == VideoType::TvSeries);
|
||||||
|
assert!(VideoType::Movie == VideoType::Movie);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
58
src/main.rs
58
src/main.rs
|
@ -1,25 +1,52 @@
|
||||||
mod cli;
|
mod cli;
|
||||||
|
|
||||||
|
use core::future::Future;
|
||||||
|
use core::pin::Pin;
|
||||||
|
use std::iter::Iterator;
|
||||||
|
|
||||||
|
use munite_core::{StoreEntry, MuniteResult};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let args = cli::CliArgs::get();
|
let args = cli::CliArgs::get();
|
||||||
|
|
||||||
|
//let general_stores = munite_core::general::stores();
|
||||||
let results = match args.category {
|
let results = match args.category {
|
||||||
cli::StoreCategory::All(s) => {
|
cli::StoreCategory::All(s) => {
|
||||||
let stores = munite_core::stores();
|
let stores = munite_core::stores();
|
||||||
search_query(stores, s, args.max).await
|
search_query(stores.iter(), args.max, |store| store.search(s.query.clone())).await
|
||||||
},
|
},
|
||||||
cli::StoreCategory::Audio(s) => {
|
cli::StoreCategory::Audio(s) => {
|
||||||
let stores = munite_core::audio::stores();
|
let stores = munite_core::audio::all();
|
||||||
search_query(stores, s, args.max).await
|
let general_stores = munite_core::general::all_audio();
|
||||||
|
search_query(stores.iter().chain(general_stores.iter()),
|
||||||
|
args.max,
|
||||||
|
|store| store.search(s.query.clone()),
|
||||||
|
).await
|
||||||
},
|
},
|
||||||
cli::StoreCategory::Music(s) => {
|
cli::StoreCategory::Music(s) => {
|
||||||
let stores = munite_core::audio::music::stores();
|
let stores = munite_core::audio::all();
|
||||||
search_query(stores, s, args.max).await
|
let general_stores = munite_core::general::all_audio();
|
||||||
|
search_query(stores.iter()
|
||||||
|
.chain(general_stores.iter())
|
||||||
|
.filter(|x|
|
||||||
|
x.store()
|
||||||
|
.iter()
|
||||||
|
.any(|x| x == &munite_core::audio::AudioType::Music)
|
||||||
|
),
|
||||||
|
args.max,
|
||||||
|
|store| store.search(s.query.clone()),
|
||||||
|
).await
|
||||||
},
|
},
|
||||||
cli::StoreCategory::Video(s) => {
|
cli::StoreCategory::Video(s) => {
|
||||||
let stores = munite_core::video::stores();
|
let stores = munite_core::video::all();
|
||||||
search_query(stores, s, args.max).await
|
let general_stores = munite_core::general::all_video();
|
||||||
|
search_query(
|
||||||
|
stores.iter()
|
||||||
|
.chain(general_stores.iter()),
|
||||||
|
args.max,
|
||||||
|
|store| store.search(s.query.clone()),
|
||||||
|
).await
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
for entry in results {
|
for entry in results {
|
||||||
|
@ -30,14 +57,17 @@ async fn main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn search_query(
|
async fn search_query<'a, I: 'a>(
|
||||||
stores: Vec<Box<dyn munite_core::StoreSearch>>,
|
stores: impl Iterator<Item=&'a I> + 'a,
|
||||||
query: cli::StandardSearch,
|
//query: cli::StandardSearch,
|
||||||
max_results: Option<usize>,
|
max_results: Option<usize>,
|
||||||
) -> Vec<munite_core::MuniteResult<munite_core::StoreEntry>> {
|
gen: impl Fn(&'a I) -> Pin<Box<dyn Future<Output = MuniteResult<Vec<StoreEntry>>> + Send + 'a>>,
|
||||||
let mut awaitables = Vec::with_capacity(stores.len());
|
) -> Vec<MuniteResult<StoreEntry>> {
|
||||||
for store in stores.iter() {
|
let size_hint = stores.size_hint();
|
||||||
awaitables.push(store.search(query.query.clone()));
|
let mut awaitables = Vec::with_capacity(size_hint.1.unwrap_or(size_hint.0));
|
||||||
|
for store in stores {
|
||||||
|
//awaitables.push(store.search(query.query.clone()));
|
||||||
|
awaitables.push(gen(store));
|
||||||
}
|
}
|
||||||
let responses = futures_util::future::join_all(awaitables).await;
|
let responses = futures_util::future::join_all(awaitables).await;
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
|
|
Loading…
Reference in a new issue