Added better artist bio/pics/lyrics
This commit is contained in:
@@ -310,17 +310,17 @@ pub async fn enrich_artist(
|
||||
let local_id = a.id;
|
||||
(a, Some(local_id), mbid)
|
||||
} else {
|
||||
// Look up artist name from MusicBrainz by MBID — don't create a local record
|
||||
let (name, _disambiguation) = state
|
||||
// Look up artist info from MusicBrainz by MBID — don't create a local record
|
||||
let info = state
|
||||
.mb_client
|
||||
.get_artist_by_mbid(&mbid)
|
||||
.get_artist_info(&mbid)
|
||||
.await
|
||||
.map_err(|e| ApiError::NotFound(format!("artist MBID {mbid} not found: {e}")))?;
|
||||
|
||||
// Create a synthetic artist object for display only (not saved to DB)
|
||||
let synthetic = shanty_db::entities::artist::Model {
|
||||
id: 0,
|
||||
name,
|
||||
name: info.name.clone(),
|
||||
musicbrainz_id: Some(mbid.clone()),
|
||||
added_at: chrono::Utc::now().naive_utc(),
|
||||
top_songs: "[]".to_string(),
|
||||
@@ -330,6 +330,27 @@ pub async fn enrich_artist(
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch detailed artist info (country, type, URLs) — best-effort
|
||||
let artist_info = match state.mb_client.get_artist_info(&mbid).await {
|
||||
Ok(info) => {
|
||||
tracing::debug!(
|
||||
mbid = %mbid,
|
||||
urls = info.urls.len(),
|
||||
country = ?info.country,
|
||||
"fetched artist info"
|
||||
);
|
||||
Some(info)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(mbid = %mbid, error = %e, "failed to fetch artist info");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// 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 release groups and filter by allowed secondary types
|
||||
let all_release_groups = state
|
||||
.search
|
||||
@@ -568,6 +589,9 @@ pub async fn enrich_artist(
|
||||
"total_watched_tracks": total_artist_watched,
|
||||
"total_owned_tracks": total_artist_owned,
|
||||
"enriched": !skip_track_fetch,
|
||||
"artist_info": artist_info,
|
||||
"artist_photo": artist_photo,
|
||||
"artist_bio": artist_bio,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -591,6 +615,140 @@ 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(
|
||||
state: &AppState,
|
||||
mbid: &str,
|
||||
artist_info: &Option<shanty_tag::provider::ArtistInfo>,
|
||||
) -> (Option<String>, Option<String>) {
|
||||
let cache_key = format!("artist_wiki:{mbid}");
|
||||
|
||||
// Check cache first
|
||||
if let Ok(Some(json)) = queries::cache::get(state.db.conn(), &cache_key).await {
|
||||
if 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('/').last().unwrap_or("");
|
||||
resolve_wikidata_to_wikipedia(entity_id).await
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} 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);
|
||||
};
|
||||
|
||||
let resp = match client.get(&api_url).send().await {
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
_ => return (None, None),
|
||||
};
|
||||
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(_) => return (None, None),
|
||||
};
|
||||
|
||||
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(' ', "_")
|
||||
))
|
||||
}
|
||||
|
||||
async fn add_artist(
|
||||
state: web::Data<AppState>,
|
||||
session: Session,
|
||||
|
||||
113
src/routes/lyrics.rs
Normal file
113
src/routes/lyrics.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
use actix_session::Session;
|
||||
use actix_web::{HttpResponse, web};
|
||||
use serde::Deserialize;
|
||||
|
||||
use shanty_db::queries;
|
||||
|
||||
use crate::auth;
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LyricsQuery {
|
||||
artist: String,
|
||||
title: String,
|
||||
}
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(web::resource("/lyrics").route(web::get().to(get_lyrics)));
|
||||
}
|
||||
|
||||
async fn get_lyrics(
|
||||
state: web::Data<AppState>,
|
||||
session: Session,
|
||||
query: web::Query<LyricsQuery>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
auth::require_auth(&session)?;
|
||||
|
||||
let artist = &query.artist;
|
||||
let title = &query.title;
|
||||
|
||||
// Normalize cache key
|
||||
let cache_key = format!("lyrics:{}:{}", artist.to_lowercase(), title.to_lowercase());
|
||||
|
||||
// Check cache first
|
||||
if let Ok(Some(json)) = queries::cache::get(state.db.conn(), &cache_key).await {
|
||||
return Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.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()))?;
|
||||
|
||||
let url = format!(
|
||||
"https://lrclib.net/api/search?artist_name={}&track_name={}",
|
||||
urlencoded(artist),
|
||||
urlencoded(title),
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("LRCLIB request 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,
|
||||
})
|
||||
};
|
||||
|
||||
// Cache for 30 days
|
||||
let _ = queries::cache::set(
|
||||
state.db.conn(),
|
||||
&cache_key,
|
||||
"lrclib",
|
||||
&result.to_string(),
|
||||
30 * 86400,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(HttpResponse::Ok().json(result))
|
||||
}
|
||||
|
||||
fn urlencoded(s: &str) -> String {
|
||||
s.replace(' ', "+")
|
||||
.replace('&', "%26")
|
||||
.replace('=', "%3D")
|
||||
.replace('#', "%23")
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod albums;
|
||||
pub mod artists;
|
||||
pub mod auth;
|
||||
pub mod downloads;
|
||||
pub mod lyrics;
|
||||
pub mod search;
|
||||
pub mod system;
|
||||
pub mod tracks;
|
||||
@@ -17,6 +18,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
.configure(tracks::configure)
|
||||
.configure(search::configure)
|
||||
.configure(downloads::configure)
|
||||
.configure(lyrics::configure)
|
||||
.configure(system::configure),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user