Compare commits
1 Commits
fed86c9e85
...
eaaff5f98f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaaff5f98f |
@@ -8,6 +8,7 @@ repository = "ssh://connor@git.rcjohnstone.com:2222/Shanty/web.git"
|
||||
|
||||
[dependencies]
|
||||
shanty-config = { path = "../shanty-config" }
|
||||
shanty-data = { path = "../shanty-data" }
|
||||
shanty-db = { path = "../shanty-db" }
|
||||
shanty-index = { path = "../shanty-index" }
|
||||
shanty-tag = { path = "../shanty-tag" }
|
||||
|
||||
@@ -147,6 +147,10 @@ pub fn artist_page(props: &Props) -> Html {
|
||||
|
||||
html! {
|
||||
<div>
|
||||
if let Some(ref banner) = d.artist_banner {
|
||||
<div class="artist-banner" style={format!("background-image: url('{banner}')")}>
|
||||
</div>
|
||||
}
|
||||
<div class="page-header">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex gap-2 items-center">
|
||||
|
||||
@@ -265,6 +265,56 @@ pub fn settings_page() -> Html {
|
||||
html! {}
|
||||
};
|
||||
|
||||
let lastfm_key_html = {
|
||||
let key_set = ytauth.as_ref().map(|s| s.lastfm_api_key_set).unwrap_or(false);
|
||||
if key_set {
|
||||
html! {
|
||||
<p class="text-sm" style="margin: 0.25rem 0 0 0;">
|
||||
<span style="color: var(--success);">{ "\u{2713}" }</span>
|
||||
{ " API key configured via " }
|
||||
<code>{ "SHANTY_LASTFM_API_KEY" }</code>
|
||||
</p>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<p class="text-sm" style="margin: 0.25rem 0 0 0; color: var(--warning);">
|
||||
{ "Set " }
|
||||
<code>{ "SHANTY_LASTFM_API_KEY" }</code>
|
||||
{ " environment variable. Get a key at " }
|
||||
<a href="https://www.last.fm/api/account/create" target="_blank">{ "last.fm/api/account/create" }</a>
|
||||
{ " (use any name, leave callback URL blank)." }
|
||||
</p>
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let fanart_key_html = {
|
||||
let key_set = ytauth
|
||||
.as_ref()
|
||||
.map(|s| s.fanart_api_key_set)
|
||||
.unwrap_or(false);
|
||||
if key_set {
|
||||
html! {
|
||||
<p class="text-sm" style="margin: 0.25rem 0 0 0;">
|
||||
<span style="color: var(--success);">{ "\u{2713}" }</span>
|
||||
{ " API key configured via " }
|
||||
<code>{ "SHANTY_FANART_API_KEY" }</code>
|
||||
{ ". Provides artist thumbnails and HD banners." }
|
||||
</p>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<p class="text-sm" style="margin: 0.25rem 0 0 0; color: var(--warning);">
|
||||
{ "Set " }
|
||||
<code>{ "SHANTY_FANART_API_KEY" }</code>
|
||||
{ " environment variable. Get a key at " }
|
||||
<a href="https://fanart.tv/get-an-api-key/" target="_blank">{ "fanart.tv" }</a>
|
||||
{ "." }
|
||||
</p>
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
html! {
|
||||
<div>
|
||||
<div class="page-header">
|
||||
@@ -441,6 +491,82 @@ pub fn settings_page() -> Html {
|
||||
{ ytauth_html }
|
||||
</div>
|
||||
|
||||
// Metadata Providers
|
||||
<div class="card">
|
||||
<h3>{ "Metadata Providers" }</h3>
|
||||
<div class="form-group">
|
||||
<label>{ "Music Database" }</label>
|
||||
<select onchange={let config = config.clone(); Callback::from(move |e: Event| {
|
||||
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||
let mut cfg = (*config).clone().unwrap();
|
||||
cfg.metadata.metadata_source = select.value();
|
||||
config.set(Some(cfg));
|
||||
})}>
|
||||
{ for [("musicbrainz", "MusicBrainz")].iter().map(|(v, label)| html! {
|
||||
<option value={*v} selected={c.metadata.metadata_source == *v}>{ label }</option>
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{ "Artist Images" }</label>
|
||||
<select onchange={let config = config.clone(); Callback::from(move |e: Event| {
|
||||
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||
let mut cfg = (*config).clone().unwrap();
|
||||
cfg.metadata.artist_image_source = select.value();
|
||||
config.set(Some(cfg));
|
||||
})}>
|
||||
{ for [("wikipedia", "Wikipedia"), ("fanarttv", "fanart.tv")].iter().map(|(v, label)| html! {
|
||||
<option value={*v} selected={c.metadata.artist_image_source == *v}>{ label }</option>
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
if c.metadata.artist_image_source == "fanarttv" {
|
||||
{ fanart_key_html.clone() }
|
||||
}
|
||||
<div class="form-group">
|
||||
<label>{ "Artist Bios" }</label>
|
||||
<select onchange={let config = config.clone(); Callback::from(move |e: Event| {
|
||||
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||
let mut cfg = (*config).clone().unwrap();
|
||||
cfg.metadata.artist_bio_source = select.value();
|
||||
config.set(Some(cfg));
|
||||
})}>
|
||||
{ for [("wikipedia", "Wikipedia"), ("lastfm", "Last.fm")].iter().map(|(v, label)| html! {
|
||||
<option value={*v} selected={c.metadata.artist_bio_source == *v}>{ label }</option>
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
if c.metadata.artist_bio_source == "lastfm" {
|
||||
{ lastfm_key_html.clone() }
|
||||
}
|
||||
<div class="form-group">
|
||||
<label>{ "Lyrics" }</label>
|
||||
<select onchange={let config = config.clone(); Callback::from(move |e: Event| {
|
||||
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||
let mut cfg = (*config).clone().unwrap();
|
||||
cfg.metadata.lyrics_source = select.value();
|
||||
config.set(Some(cfg));
|
||||
})}>
|
||||
{ for [("lrclib", "LRCLIB")].iter().map(|(v, label)| html! {
|
||||
<option value={*v} selected={c.metadata.lyrics_source == *v}>{ label }</option>
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{ "Cover Art" }</label>
|
||||
<select onchange={let config = config.clone(); Callback::from(move |e: Event| {
|
||||
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||
let mut cfg = (*config).clone().unwrap();
|
||||
cfg.metadata.cover_art_source = select.value();
|
||||
config.set(Some(cfg));
|
||||
})}>
|
||||
{ for [("coverartarchive", "Cover Art Archive")].iter().map(|(v, label)| html! {
|
||||
<option value={*v} selected={c.metadata.cover_art_source == *v}>{ label }</option>
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Indexing
|
||||
<div class="card">
|
||||
<h3>{ "Indexing" }</h3>
|
||||
|
||||
@@ -67,6 +67,8 @@ pub struct FullArtistDetail {
|
||||
pub artist_photo: Option<String>,
|
||||
#[serde(default)]
|
||||
pub artist_bio: Option<String>,
|
||||
#[serde(default)]
|
||||
pub artist_banner: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
@@ -176,6 +178,10 @@ pub struct YtAuthStatus {
|
||||
pub ytdlp_latest: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ytdlp_update_available: bool,
|
||||
#[serde(default)]
|
||||
pub lastfm_api_key_set: bool,
|
||||
#[serde(default)]
|
||||
pub fanart_api_key_set: bool,
|
||||
}
|
||||
|
||||
// --- Downloads ---
|
||||
@@ -289,6 +295,8 @@ pub struct AppConfig {
|
||||
pub download: DownloadConfigFe,
|
||||
#[serde(default)]
|
||||
pub indexing: IndexingConfigFe,
|
||||
#[serde(default)]
|
||||
pub metadata: MetadataConfigFe,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
@@ -341,3 +349,45 @@ pub struct IndexingConfigFe {
|
||||
#[serde(default)]
|
||||
pub concurrency: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MetadataConfigFe {
|
||||
#[serde(default = "default_metadata_source")]
|
||||
pub metadata_source: String,
|
||||
#[serde(default = "default_artist_image_source")]
|
||||
pub artist_image_source: String,
|
||||
#[serde(default = "default_artist_bio_source")]
|
||||
pub artist_bio_source: String,
|
||||
#[serde(default = "default_lyrics_source")]
|
||||
pub lyrics_source: String,
|
||||
#[serde(default = "default_cover_art_source")]
|
||||
pub cover_art_source: String,
|
||||
}
|
||||
|
||||
impl Default for MetadataConfigFe {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
metadata_source: default_metadata_source(),
|
||||
artist_image_source: default_artist_image_source(),
|
||||
artist_bio_source: default_artist_bio_source(),
|
||||
lyrics_source: default_lyrics_source(),
|
||||
cover_art_source: default_cover_art_source(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_metadata_source() -> String {
|
||||
"musicbrainz".into()
|
||||
}
|
||||
fn default_artist_image_source() -> String {
|
||||
"wikipedia".into()
|
||||
}
|
||||
fn default_artist_bio_source() -> String {
|
||||
"wikipedia".into()
|
||||
}
|
||||
fn default_lyrics_source() -> String {
|
||||
"lrclib".into()
|
||||
}
|
||||
fn default_cover_art_source() -> String {
|
||||
"coverartarchive".into()
|
||||
}
|
||||
|
||||
@@ -148,6 +148,14 @@ a:hover { color: var(--accent-hover); }
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.artist-links a:hover { color: var(--accent); }
|
||||
.artist-banner {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.artist-photo {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
|
||||
@@ -97,6 +97,12 @@ impl From<shanty_tag::TagError> for ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<shanty_data::DataError> for ApiError {
|
||||
fn from(e: shanty_data::DataError) -> Self {
|
||||
ApiError::Internal(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<shanty_org::OrgError> for ApiError {
|
||||
fn from(e: shanty_org::OrgError) -> Self {
|
||||
ApiError::Internal(e.to_string())
|
||||
|
||||
@@ -5,9 +5,10 @@ use clap::Parser;
|
||||
use tracing_actix_web::TracingLogger;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use shanty_data::MusicBrainzFetcher;
|
||||
use shanty_data::WikipediaFetcher;
|
||||
use shanty_db::Database;
|
||||
use shanty_search::MusicBrainzSearch;
|
||||
use shanty_tag::MusicBrainzClient;
|
||||
|
||||
use shanty_web::config::AppConfig;
|
||||
use shanty_web::routes;
|
||||
@@ -53,8 +54,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
tracing::info!(url = %config.database_url, "connecting to database");
|
||||
let db = Database::new(&config.database_url).await?;
|
||||
|
||||
let mb_client = MusicBrainzClient::new()?;
|
||||
let mb_client = MusicBrainzFetcher::new()?;
|
||||
let search = MusicBrainzSearch::new()?;
|
||||
let wiki_fetcher = WikipediaFetcher::new()?;
|
||||
|
||||
let bind = format!("{}:{}", config.web.bind, config.web.port);
|
||||
tracing::info!(bind = %bind, "starting server");
|
||||
@@ -64,6 +66,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
db,
|
||||
mb_client,
|
||||
search,
|
||||
wiki_fetcher,
|
||||
config: std::sync::Arc::new(tokio::sync::RwLock::new(config)),
|
||||
config_path,
|
||||
tasks: TaskManager::new(),
|
||||
|
||||
@@ -2,9 +2,9 @@ use actix_session::Session;
|
||||
use actix_web::{HttpResponse, web};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use shanty_data::MetadataFetcher;
|
||||
use shanty_db::entities::wanted_item::WantedStatus;
|
||||
use shanty_db::queries;
|
||||
use shanty_tag::provider::MetadataProvider;
|
||||
|
||||
use crate::auth;
|
||||
use crate::error::ApiError;
|
||||
@@ -127,7 +127,7 @@ async fn resolve_release_from_group(
|
||||
// Since we can't call get_json directly, use the artist_releases approach
|
||||
// to find a release that matches this group.
|
||||
//
|
||||
// Actually, the simplest: the MetadataProvider trait has get_artist_releases
|
||||
// Actually, the simplest: the MetadataFetcher trait has get_artist_releases
|
||||
// which returns releases, but we need releases for a release GROUP.
|
||||
// Let's add a direct HTTP call here via reqwest.
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ use actix_session::Session;
|
||||
use actix_web::{HttpResponse, web};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use shanty_data::{ArtistBioFetcher, ArtistImageFetcher, MetadataFetcher};
|
||||
use shanty_db::entities::wanted_item::WantedStatus;
|
||||
use shanty_db::queries;
|
||||
use shanty_search::SearchProvider;
|
||||
use shanty_tag::provider::MetadataProvider;
|
||||
|
||||
use crate::auth;
|
||||
use crate::error::ApiError;
|
||||
@@ -346,9 +346,24 @@ pub async fn enrich_artist(
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch Wikipedia photo + bio (cached)
|
||||
let (artist_photo, artist_bio) = fetch_wikipedia_data(state, &mbid, &artist_info).await;
|
||||
tracing::debug!(mbid = %mbid, has_photo = artist_photo.is_some(), has_bio = artist_bio.is_some(), "wikipedia data");
|
||||
// Fetch artist photo + bio + banner (cached, provider-aware)
|
||||
let config = state.config.read().await;
|
||||
let image_source = config.metadata.artist_image_source.clone();
|
||||
let bio_source = config.metadata.artist_bio_source.clone();
|
||||
let lastfm_api_key = config.metadata.lastfm_api_key.clone();
|
||||
let fanart_api_key = config.metadata.fanart_api_key.clone();
|
||||
drop(config);
|
||||
let (artist_photo, artist_bio, artist_banner) = fetch_artist_enrichment(
|
||||
state,
|
||||
&mbid,
|
||||
&artist_info,
|
||||
&image_source,
|
||||
&bio_source,
|
||||
lastfm_api_key.as_deref(),
|
||||
fanart_api_key.as_deref(),
|
||||
)
|
||||
.await;
|
||||
tracing::debug!(mbid = %mbid, has_photo = artist_photo.is_some(), has_bio = artist_bio.is_some(), has_banner = artist_banner.is_some(), "artist enrichment data");
|
||||
|
||||
// Fetch release groups and filter by allowed secondary types
|
||||
let all_release_groups = state
|
||||
@@ -591,6 +606,7 @@ pub async fn enrich_artist(
|
||||
"artist_info": artist_info,
|
||||
"artist_photo": artist_photo,
|
||||
"artist_bio": artist_bio,
|
||||
"artist_banner": artist_banner,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -614,137 +630,130 @@ pub async fn enrich_all_watched_artists(state: &AppState) -> Result<u32, ApiErro
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Fetch artist photo and bio from Wikipedia, with caching.
|
||||
async fn fetch_wikipedia_data(
|
||||
/// Fetch artist photo, bio, and banner using configured providers, with per-source caching.
|
||||
async fn fetch_artist_enrichment(
|
||||
state: &AppState,
|
||||
mbid: &str,
|
||||
artist_info: &Option<shanty_tag::provider::ArtistInfo>,
|
||||
) -> (Option<String>, Option<String>) {
|
||||
let cache_key = format!("artist_wiki:{mbid}");
|
||||
artist_info: &Option<shanty_data::ArtistInfo>,
|
||||
image_source: &str,
|
||||
bio_source: &str,
|
||||
lastfm_api_key: Option<&str>,
|
||||
fanart_api_key: Option<&str>,
|
||||
) -> (Option<String>, Option<String>, Option<String>) {
|
||||
let Some(info) = artist_info.as_ref() else {
|
||||
tracing::debug!(mbid = mbid, "no artist info for enrichment");
|
||||
return (None, None, None);
|
||||
};
|
||||
|
||||
// Check cache first
|
||||
if let Ok(Some(json)) = queries::cache::get(state.db.conn(), &cache_key).await
|
||||
&& let Ok(cached) = serde_json::from_str::<serde_json::Value>(&json)
|
||||
{
|
||||
return (
|
||||
cached
|
||||
.get("photo_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
cached.get("bio").and_then(|v| v.as_str()).map(String::from),
|
||||
);
|
||||
}
|
||||
|
||||
// Find Wikipedia URL from artist info — try direct link first, then resolve via Wikidata
|
||||
let wiki_url = if let Some(info) = artist_info.as_ref() {
|
||||
if let Some(u) = info.urls.iter().find(|u| u.link_type == "wikipedia") {
|
||||
Some(u.url.clone())
|
||||
} else if let Some(wd) = info.urls.iter().find(|u| u.link_type == "wikidata") {
|
||||
// Extract Wikidata entity ID and resolve to Wikipedia URL
|
||||
let entity_id = wd.url.split('/').next_back().unwrap_or("");
|
||||
resolve_wikidata_to_wikipedia(entity_id).await
|
||||
} else {
|
||||
None
|
||||
}
|
||||
// Build fanart.tv fetcher once if needed (used for both image and banner)
|
||||
let fanart_fetcher = if image_source == "fanarttv" {
|
||||
fanart_api_key.and_then(|key| shanty_data::FanartTvFetcher::new(key.to_string()).ok())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let Some(wiki_url) = wiki_url else {
|
||||
tracing::debug!(mbid = mbid, "no wikipedia URL found");
|
||||
return (None, None);
|
||||
};
|
||||
tracing::debug!(mbid = mbid, wiki_url = %wiki_url, "found wikipedia URL");
|
||||
|
||||
// Parse article title from URL (e.g., https://en.wikipedia.org/wiki/Pink_Floyd → Pink_Floyd)
|
||||
let title = wiki_url.split("/wiki/").nth(1).unwrap_or("").to_string();
|
||||
|
||||
if title.is_empty() {
|
||||
return (None, None);
|
||||
}
|
||||
|
||||
// Detect language from URL (e.g., en.wikipedia.org → en)
|
||||
let lang = wiki_url
|
||||
.split("://")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('.').next())
|
||||
.unwrap_or("en");
|
||||
|
||||
// Call Wikipedia REST API
|
||||
let api_url = format!("https://{lang}.wikipedia.org/api/rest_v1/page/summary/{title}");
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Shanty/0.1.0 (shanty-music-app)")
|
||||
.build()
|
||||
.ok();
|
||||
|
||||
let Some(client) = client else {
|
||||
return (None, None);
|
||||
// Fetch image (cached per source — only cache hits, not misses)
|
||||
let image_cache_key = format!("artist_image:{image_source}:{mbid}");
|
||||
let photo_url = if let Ok(Some(cached)) =
|
||||
queries::cache::get(state.db.conn(), &image_cache_key).await
|
||||
&& !cached.is_empty()
|
||||
{
|
||||
Some(cached)
|
||||
} else {
|
||||
let url = match image_source {
|
||||
"wikipedia" => state
|
||||
.wiki_fetcher
|
||||
.get_artist_image(info)
|
||||
.await
|
||||
.unwrap_or(None),
|
||||
"fanarttv" => match &fanart_fetcher {
|
||||
Some(f) => f.get_artist_image(info).await.unwrap_or(None),
|
||||
None => {
|
||||
tracing::warn!("fanart.tv selected but SHANTY_FANART_API_KEY not set");
|
||||
None
|
||||
}
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
if let Some(ref val) = url {
|
||||
let _ = queries::cache::set(
|
||||
state.db.conn(),
|
||||
&image_cache_key,
|
||||
image_source,
|
||||
val,
|
||||
30 * 86400,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
url
|
||||
};
|
||||
|
||||
let resp = match client.get(&api_url).send().await {
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
_ => return (None, None),
|
||||
// Fetch banner (cached per source — only for providers that support banners)
|
||||
let banner_cache_key = format!("artist_banner:{image_source}:{mbid}");
|
||||
let banner = if let Ok(Some(cached)) =
|
||||
queries::cache::get(state.db.conn(), &banner_cache_key).await
|
||||
&& !cached.is_empty()
|
||||
{
|
||||
Some(cached)
|
||||
} else {
|
||||
let url = match image_source {
|
||||
"fanarttv" => match &fanart_fetcher {
|
||||
Some(f) => f.get_artist_banner(info).await.unwrap_or(None),
|
||||
None => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
if let Some(ref val) = url {
|
||||
let _ = queries::cache::set(
|
||||
state.db.conn(),
|
||||
&banner_cache_key,
|
||||
image_source,
|
||||
val,
|
||||
30 * 86400,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
url
|
||||
};
|
||||
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(_) => return (None, None),
|
||||
// Fetch bio (cached per source — only cache hits, not misses)
|
||||
let bio_cache_key = format!("artist_bio:{bio_source}:{mbid}");
|
||||
let bio = if let Ok(Some(cached)) = queries::cache::get(state.db.conn(), &bio_cache_key).await
|
||||
&& !cached.is_empty()
|
||||
{
|
||||
Some(cached)
|
||||
} else {
|
||||
let text = match bio_source {
|
||||
"wikipedia" => state
|
||||
.wiki_fetcher
|
||||
.get_artist_bio(info)
|
||||
.await
|
||||
.unwrap_or(None),
|
||||
"lastfm" => {
|
||||
if let Some(key) = lastfm_api_key {
|
||||
match shanty_data::LastFmBioFetcher::new(key.to_string()) {
|
||||
Ok(fetcher) => fetcher.get_artist_bio(info).await.unwrap_or(None),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to create Last.fm fetcher");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("Last.fm bio source selected but SHANTY_LASTFM_API_KEY not set");
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(ref val) = text {
|
||||
let _ =
|
||||
queries::cache::set(state.db.conn(), &bio_cache_key, bio_source, val, 30 * 86400)
|
||||
.await;
|
||||
}
|
||||
text
|
||||
};
|
||||
|
||||
let photo_url = body
|
||||
.get("thumbnail")
|
||||
.and_then(|t| t.get("source"))
|
||||
.and_then(|s| s.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let bio = body
|
||||
.get("extract")
|
||||
.and_then(|e| e.as_str())
|
||||
.map(String::from);
|
||||
|
||||
// Cache for 30 days
|
||||
let cache_val = serde_json::json!({ "photo_url": photo_url, "bio": bio });
|
||||
let _ = queries::cache::set(
|
||||
state.db.conn(),
|
||||
&cache_key,
|
||||
"wikipedia",
|
||||
&cache_val.to_string(),
|
||||
30 * 86400,
|
||||
)
|
||||
.await;
|
||||
|
||||
(photo_url, bio)
|
||||
}
|
||||
|
||||
/// Resolve a Wikidata entity ID to an English Wikipedia URL.
|
||||
async fn resolve_wikidata_to_wikipedia(entity_id: &str) -> Option<String> {
|
||||
if entity_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let url = format!(
|
||||
"https://www.wikidata.org/w/api.php?action=wbgetentities&ids={entity_id}&props=sitelinks&sitefilter=enwiki&format=json"
|
||||
);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Shanty/0.1.0 (shanty-music-app)")
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
let resp: serde_json::Value = client.get(&url).send().await.ok()?.json().await.ok()?;
|
||||
|
||||
let title = resp
|
||||
.get("entities")
|
||||
.and_then(|e| e.get(entity_id))
|
||||
.and_then(|e| e.get("sitelinks"))
|
||||
.and_then(|s| s.get("enwiki"))
|
||||
.and_then(|w| w.get("title"))
|
||||
.and_then(|t| t.as_str())?;
|
||||
|
||||
Some(format!(
|
||||
"https://en.wikipedia.org/wiki/{}",
|
||||
title.replace(' ', "_")
|
||||
))
|
||||
(photo_url, bio, banner)
|
||||
}
|
||||
|
||||
async fn add_artist(
|
||||
|
||||
@@ -2,6 +2,7 @@ use actix_session::Session;
|
||||
use actix_web::{HttpResponse, web};
|
||||
use serde::Deserialize;
|
||||
|
||||
use shanty_data::LyricsFetcher;
|
||||
use shanty_db::queries;
|
||||
|
||||
use crate::auth;
|
||||
@@ -38,59 +39,20 @@ async fn get_lyrics(
|
||||
.body(json));
|
||||
}
|
||||
|
||||
// Call LRCLIB API
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Shanty/0.1.0 (shanty-music-app)")
|
||||
.build()
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
// Use LrclibFetcher from shanty-data
|
||||
let fetcher = shanty_data::LrclibFetcher::new()
|
||||
.map_err(|e| ApiError::Internal(format!("failed to create lyrics fetcher: {e}")))?;
|
||||
|
||||
let url = format!(
|
||||
"https://lrclib.net/api/search?artist_name={}&track_name={}",
|
||||
urlencoded(artist),
|
||||
urlencoded(title),
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
let lyrics_result = fetcher
|
||||
.get_lyrics(artist, title)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("LRCLIB request failed: {e}")))?;
|
||||
.map_err(|e| ApiError::Internal(format!("lyrics fetch failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||
"found": false,
|
||||
"lyrics": null,
|
||||
"synced_lyrics": null,
|
||||
})));
|
||||
}
|
||||
|
||||
let results: Vec<serde_json::Value> = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("LRCLIB parse failed: {e}")))?;
|
||||
|
||||
let result = if let Some(entry) = results.first() {
|
||||
let plain = entry
|
||||
.get("plainLyrics")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
let synced = entry
|
||||
.get("syncedLyrics")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
serde_json::json!({
|
||||
"found": plain.is_some() || synced.is_some(),
|
||||
"lyrics": plain,
|
||||
"synced_lyrics": synced,
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"found": false,
|
||||
"lyrics": null,
|
||||
"synced_lyrics": null,
|
||||
})
|
||||
};
|
||||
let result = serde_json::json!({
|
||||
"found": lyrics_result.found,
|
||||
"lyrics": lyrics_result.lyrics,
|
||||
"synced_lyrics": lyrics_result.synced_lyrics,
|
||||
});
|
||||
|
||||
// Cache for 30 days
|
||||
let _ = queries::cache::set(
|
||||
@@ -104,10 +66,3 @@ async fn get_lyrics(
|
||||
|
||||
Ok(HttpResponse::Ok().json(result))
|
||||
}
|
||||
|
||||
fn urlencoded(s: &str) -> String {
|
||||
s.replace(' ', "+")
|
||||
.replace('&', "%26")
|
||||
.replace('=', "%3D")
|
||||
.replace('#', "%23")
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ struct AuthStatus {
|
||||
ytdlp_version: Option<String>,
|
||||
ytdlp_latest: Option<String>,
|
||||
ytdlp_update_available: bool,
|
||||
lastfm_api_key_set: bool,
|
||||
fanart_api_key_set: bool,
|
||||
}
|
||||
|
||||
/// GET /api/ytauth/status — check YouTube auth state.
|
||||
@@ -78,6 +80,8 @@ async fn status(state: web::Data<AppState>, session: Session) -> Result<HttpResp
|
||||
ytdlp_version,
|
||||
ytdlp_latest,
|
||||
ytdlp_update_available,
|
||||
lastfm_api_key_set: config.metadata.lastfm_api_key.is_some(),
|
||||
fanart_api_key_set: config.metadata.fanart_api_key.is_some(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
use shanty_data::MusicBrainzFetcher;
|
||||
use shanty_data::WikipediaFetcher;
|
||||
use shanty_db::Database;
|
||||
use shanty_search::MusicBrainzSearch;
|
||||
use shanty_tag::MusicBrainzClient;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::tasks::TaskManager;
|
||||
@@ -15,8 +16,9 @@ pub struct FirefoxLoginSession {
|
||||
|
||||
pub struct AppState {
|
||||
pub db: Database,
|
||||
pub mb_client: MusicBrainzClient,
|
||||
pub mb_client: MusicBrainzFetcher,
|
||||
pub search: MusicBrainzSearch,
|
||||
pub wiki_fetcher: WikipediaFetcher,
|
||||
pub config: Arc<RwLock<AppConfig>>,
|
||||
pub config_path: Option<String>,
|
||||
pub tasks: TaskManager,
|
||||
|
||||
Reference in New Issue
Block a user