update to the playlists. testing
This commit is contained in:
@@ -44,6 +44,12 @@ pub fn playlists_page() -> Html {
|
|||||||
let count = use_state(|| 50usize);
|
let count = use_state(|| 50usize);
|
||||||
let popularity_bias = use_state(|| 5u8);
|
let popularity_bias = use_state(|| 5u8);
|
||||||
let ordering = use_state(|| "interleave".to_string());
|
let ordering = use_state(|| "interleave".to_string());
|
||||||
|
let discovery_range = use_state(|| 5u8);
|
||||||
|
let global_popularity = use_state(|| 0u8);
|
||||||
|
let country_filter = use_state(|| false);
|
||||||
|
let seed_weight = use_state(|| 5u8);
|
||||||
|
let max_tracks_per_artist = use_state(|| 0u8); // 0 = auto
|
||||||
|
let max_artists = use_state(|| 0u8); // 0 = unlimited
|
||||||
|
|
||||||
// Results
|
// Results
|
||||||
let generated = use_state(|| None::<GeneratedPlaylist>);
|
let generated = use_state(|| None::<GeneratedPlaylist>);
|
||||||
@@ -85,6 +91,12 @@ pub fn playlists_page() -> Html {
|
|||||||
let count = count.clone();
|
let count = count.clone();
|
||||||
let popularity_bias = popularity_bias.clone();
|
let popularity_bias = popularity_bias.clone();
|
||||||
let ordering = ordering.clone();
|
let ordering = ordering.clone();
|
||||||
|
let discovery_range = discovery_range.clone();
|
||||||
|
let global_popularity = global_popularity.clone();
|
||||||
|
let country_filter = country_filter.clone();
|
||||||
|
let seed_weight = seed_weight.clone();
|
||||||
|
let max_tracks_per_artist = max_tracks_per_artist.clone();
|
||||||
|
let max_artists = max_artists.clone();
|
||||||
let generated = generated.clone();
|
let generated = generated.clone();
|
||||||
let error = error.clone();
|
let error = error.clone();
|
||||||
let loading = loading.clone();
|
let loading = loading.clone();
|
||||||
@@ -94,6 +106,12 @@ pub fn playlists_page() -> Html {
|
|||||||
let count = *count;
|
let count = *count;
|
||||||
let popularity_bias = *popularity_bias;
|
let popularity_bias = *popularity_bias;
|
||||||
let ordering_val = (*ordering).clone();
|
let ordering_val = (*ordering).clone();
|
||||||
|
let discovery_range_val = *discovery_range;
|
||||||
|
let global_popularity_val = *global_popularity;
|
||||||
|
let country_filter_val = *country_filter;
|
||||||
|
let seed_weight_val = *seed_weight;
|
||||||
|
let max_tpa_val = *max_tracks_per_artist;
|
||||||
|
let max_artists_val = *max_artists;
|
||||||
let generated = generated.clone();
|
let generated = generated.clone();
|
||||||
let error = error.clone();
|
let error = error.clone();
|
||||||
let loading = loading.clone();
|
let loading = loading.clone();
|
||||||
@@ -110,6 +128,12 @@ pub fn playlists_page() -> Html {
|
|||||||
popularity_bias,
|
popularity_bias,
|
||||||
ordering: ordering_val,
|
ordering: ordering_val,
|
||||||
rules: None,
|
rules: None,
|
||||||
|
discovery_range: Some(discovery_range_val),
|
||||||
|
global_popularity: if global_popularity_val > 0 { Some(global_popularity_val) } else { None },
|
||||||
|
country_filter: if country_filter_val { Some(true) } else { None },
|
||||||
|
seed_weight: Some(seed_weight_val),
|
||||||
|
max_tracks_per_artist: if max_tpa_val > 0 { Some(max_tpa_val) } else { None },
|
||||||
|
max_artists: if max_artists_val > 0 { Some(max_artists_val) } else { None },
|
||||||
};
|
};
|
||||||
|
|
||||||
match api::generate_playlist(&req).await {
|
match api::generate_playlist(&req).await {
|
||||||
@@ -428,6 +452,112 @@ pub fn playlists_page() -> Html {
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<label>{ format!("Discovery Range: {}", *discovery_range) }
|
||||||
|
<span class="text-sm text-muted">{ if *discovery_range <= 3 { " (focused)" } else if *discovery_range >= 7 { " (wide)" } else { "" } }</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0" max="10"
|
||||||
|
value={discovery_range.to_string()}
|
||||||
|
oninput={{
|
||||||
|
let dr = discovery_range.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
if let Ok(v) = input.value().parse() {
|
||||||
|
dr.set(v);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label>{ format!("Seed Weight: {}", *seed_weight) }
|
||||||
|
<span class="text-sm text-muted">{ if *seed_weight == 0 { " (exclude seeds)" } else if *seed_weight >= 8 { " (heavy)" } else { "" } }</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0" max="10"
|
||||||
|
value={seed_weight.to_string()}
|
||||||
|
oninput={{
|
||||||
|
let sw = seed_weight.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
if let Ok(v) = input.value().parse() {
|
||||||
|
sw.set(v);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label>{ format!("Global Popularity: {}", *global_popularity) }
|
||||||
|
<span class="text-sm text-muted">{ if *global_popularity == 0 { " (off)" } else { "" } }</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0" max="10"
|
||||||
|
value={global_popularity.to_string()}
|
||||||
|
oninput={{
|
||||||
|
let gp = global_popularity.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
if let Ok(v) = input.value().parse() {
|
||||||
|
gp.set(v);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox"
|
||||||
|
checked={*country_filter}
|
||||||
|
onchange={{
|
||||||
|
let cf = country_filter.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
cf.set(input.checked());
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{ " Same countries as seed artists" }
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 1rem;">
|
||||||
|
<div class="form-group" style="flex: 1;">
|
||||||
|
<label>{ "Max Per Artist" }</label>
|
||||||
|
<select onchange={{
|
||||||
|
let mtpa = max_tracks_per_artist.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||||
|
if let Ok(v) = select.value().parse() {
|
||||||
|
mtpa.set(v);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
<option value="0" selected={*max_tracks_per_artist == 0}>{ "Auto" }</option>
|
||||||
|
{ for [1u8, 2, 3, 5, 10, 15, 20, 25, 30, 50].iter().map(|n| html! {
|
||||||
|
<option value={n.to_string()} selected={*max_tracks_per_artist == *n}>
|
||||||
|
{ n.to_string() }
|
||||||
|
</option>
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex: 1;">
|
||||||
|
<label>{ "Max Artists" }</label>
|
||||||
|
<select onchange={{
|
||||||
|
let ma = max_artists.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||||
|
if let Ok(v) = select.value().parse() {
|
||||||
|
ma.set(v);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
<option value="0" selected={*max_artists == 0}>{ "Unlimited" }</option>
|
||||||
|
{ for [5u8, 10, 15, 20, 25, 30, 50, 75, 100].iter().map(|n| html! {
|
||||||
|
<option value={n.to_string()} selected={*max_artists == *n}>
|
||||||
|
{ n.to_string() }
|
||||||
|
</option>
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{ "Track Order" }</label>
|
<label>{ "Track Order" }</label>
|
||||||
<select onchange={{
|
<select onchange={{
|
||||||
|
|||||||
@@ -362,6 +362,18 @@ pub struct GenerateRequest {
|
|||||||
pub ordering: String,
|
pub ordering: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub rules: Option<SmartRulesInput>,
|
pub rules: Option<SmartRulesInput>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub discovery_range: Option<u8>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub global_popularity: Option<u8>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub country_filter: Option<bool>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub seed_weight: Option<u8>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_tracks_per_artist: Option<u8>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_artists: Option<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_playlist_count() -> usize {
|
fn default_playlist_count() -> usize {
|
||||||
|
|||||||
+60
-4
@@ -1,14 +1,62 @@
|
|||||||
|
use std::future::Future;
|
||||||
|
use std::pin::Pin;
|
||||||
|
|
||||||
use actix_session::Session;
|
use actix_session::Session;
|
||||||
use actix_web::{HttpResponse, web};
|
use actix_web::{HttpResponse, web};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use sea_orm::DatabaseConnection;
|
||||||
|
use shanty_data::HybridMusicBrainzFetcher;
|
||||||
use shanty_db::queries;
|
use shanty_db::queries;
|
||||||
use shanty_playlist::{self, PlaylistRequest};
|
use shanty_playlist::{self, CountryLookup, PlaylistRequest, SimilarConfig};
|
||||||
|
|
||||||
use crate::auth;
|
use crate::auth;
|
||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Country lookup backed by search_cache + local MusicBrainz DB.
|
||||||
|
/// Never hits the remote MB API — unknown artists pass through the filter.
|
||||||
|
struct CachedCountryLookup<'a> {
|
||||||
|
conn: &'a DatabaseConnection,
|
||||||
|
mb: &'a HybridMusicBrainzFetcher,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CountryLookup for CachedCountryLookup<'_> {
|
||||||
|
fn get_country<'a>(
|
||||||
|
&'a self,
|
||||||
|
mbid: &'a str,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Option<String>> + Send + 'a>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
// 1. Check search_cache
|
||||||
|
let cache_key = format!("mb_artist_country:{mbid}");
|
||||||
|
if let Ok(Some(cached)) = queries::cache::get(self.conn, &cache_key).await {
|
||||||
|
return if cached == "__none__" {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(cached)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try local MB DB only (no remote API)
|
||||||
|
let info = self.mb.get_artist_info_local(mbid)?;
|
||||||
|
let country = info.country;
|
||||||
|
|
||||||
|
// Cache the result (30 days)
|
||||||
|
let cache_val = country.as_deref().unwrap_or("__none__");
|
||||||
|
let _ = queries::cache::set(
|
||||||
|
self.conn,
|
||||||
|
&cache_key,
|
||||||
|
"musicbrainz",
|
||||||
|
cache_val,
|
||||||
|
30 * 24 * 3600,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
country
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||||
cfg.service(web::resource("/playlists/generate").route(web::post().to(generate_playlist)))
|
cfg.service(web::resource("/playlists/generate").route(web::post().to(generate_playlist)))
|
||||||
.service(
|
.service(
|
||||||
@@ -58,13 +106,21 @@ async fn generate_playlist(
|
|||||||
}
|
}
|
||||||
let fetcher = shanty_data::LastFmSimilarFetcher::new(api_key)
|
let fetcher = shanty_data::LastFmSimilarFetcher::new(api_key)
|
||||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||||
|
let similar_config = SimilarConfig::from_request(&req);
|
||||||
|
let country_lookup = if similar_config.country_filter {
|
||||||
|
Some(CachedCountryLookup {
|
||||||
|
conn,
|
||||||
|
mb: &state.mb_client,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
shanty_playlist::similar_artists(
|
shanty_playlist::similar_artists(
|
||||||
conn,
|
conn,
|
||||||
&fetcher,
|
&fetcher,
|
||||||
req.seed_artists,
|
req.seed_artists,
|
||||||
req.count,
|
&similar_config,
|
||||||
req.popularity_bias,
|
country_lookup.as_ref().map(|c| c as &dyn CountryLookup),
|
||||||
&req.ordering,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||||
|
|||||||
Reference in New Issue
Block a user