use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use db::ReadDb;
use dioxus::prelude::*;
use reader::ArtistImageRef;
use server::cover::FetchedArtistImages;
use server::source::{ActiveSource, ArtistView};
use tracing::Instrument;
use utils::artist::{joined_credit_primary, normalize_artist_key};
use crate::use_db_queries::use_active_source;
const ARTIST_PHOTO_MISS_KIND: &str = "artist_photo_miss";
const ARTIST_PHOTO_MISS_TTL_SECS: i64 = 86_400;
pub fn use_artist_photo_fetch(
albums: Resource<Vec<reader::Album>>,
sample_tracks: Resource<Vec<reader::Track>>,
artist_images: Resource<db::ArtistImages>,
) {
let source = use_active_source();
let active_source = use_context::<Signal<ActiveSource>>();
let read_db = use_context::<ReadDb>();
let caps = use_memo(move || active_source.read().capabilities());
let mut fetched_artist_images = use_context::<Signal<FetchedArtistImages>>();
let mut is_fetching = use_signal(|| false);
let mut fetch_done = use_signal(|| None::<config::Source>);
use_effect(move || {
if !caps().sync || caps().artist_view != ArtistView::Library {
return;
}
if *is_fetching.read() || *fetch_done.read() == Some(source()) {
return;
}
is_fetching.set(true);
let src = active_source.peek().clone();
let this_source = source.peek().clone();
spawn(
async move {
let images = src.fetch_artist_images().await.unwrap_or_default();
fetched_artist_images.write().replace_all(images);
fetch_done.set(Some(this_source));
is_fetching.set(false);
}
.instrument(tracing::info_span!("artist.fetch_images")),
);
});
use_effect(move || {
if caps().artist_view != ArtistView::Remote {
return;
}
if *is_fetching.read() || *fetch_done.read() == Some(source()) {
return;
}
let db_imgs = artist_images.read();
let Some((_, db_photos)) = db_imgs.clone() else {
return;
};
drop(db_imgs);
let albums = albums.read().clone().unwrap_or_default();
let sample = sample_tracks.read().clone().unwrap_or_default();
if albums.is_empty() && sample.is_empty() {
return;
}
let names = {
let already = fetched_artist_images.read();
fetch_queue(&albums, &sample, &db_photos, &already)
};
fetch_done.set(Some(source.peek().clone()));
if names.is_empty() {
return;
}
is_fetching.set(true);
fetched_artist_images
.write()
.mark_pending(names.iter().cloned());
let src = active_source.peek().clone();
let db = read_db.clone();
spawn(
async move {
let to_fetch = settle_fresh_misses(&db, fetched_artist_images, names).await;
drain_queue(src, fetched_artist_images, to_fetch).await;
}
.instrument(tracing::info_span!("artist.fetch_yt_images")),
);
is_fetching.set(false);
});
}
fn fetch_queue(
albums: &[reader::Album],
sample: &[reader::Track],
db_photos: &HashMap<String, ArtistImageRef>,
already: &FetchedArtistImages,
) -> Vec<String> {
let mut names: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for album in albums {
if !album.artist.trim().is_empty() {
names.insert(album.artist.clone());
}
}
for track in sample {
for artist in &track.artists {
if !artist.trim().is_empty() {
names.insert(artist.clone());
}
}
}
let norms: std::collections::HashSet<String> =
names.iter().map(|n| normalize_artist_key(n)).collect();
let mut names: Vec<String> = names
.into_iter()
.filter(|n| {
let norm = normalize_artist_key(n);
!joined_credit_primary(&norm).is_some_and(|p| norms.contains(p))
&& !already.contains(n)
&& !db_photos.contains_key(&norm)
})
.collect();
names.sort_by_key(|n| n.to_lowercase());
names
}
async fn settle_fresh_misses(
db: &ReadDb,
mut fetched: Signal<FetchedArtistImages>,
names: Vec<String>,
) -> Vec<String> {
let fresh_misses: std::collections::HashSet<String> = db
.meta_keys_since(ARTIST_PHOTO_MISS_KIND, ARTIST_PHOTO_MISS_TTL_SECS)
.await
.unwrap_or_default()
.into_iter()
.collect();
let (missed, to_fetch): (Vec<String>, Vec<String>) = names
.into_iter()
.partition(|n| fresh_misses.contains(&normalize_artist_key(n)));
if !missed.is_empty() {
let mut map = fetched.write();
for name in missed {
map.insert_miss(name);
}
}
to_fetch
}
async fn drain_queue(src: ActiveSource, fetched: Signal<FetchedArtistImages>, names: Vec<String>) {
let shared = Arc::new(Mutex::new(names.into_iter()));
let worker = || {
let src = src.clone();
let shared = shared.clone();
async move {
while let Some(name) = shared.lock().ok().and_then(|mut it| it.next()) {
resolve_one(&src, fetched, name).await;
}
}
};
tokio::join!(worker(), worker(), worker(), worker(), worker(), worker());
}
async fn resolve_one(src: &ActiveSource, mut fetched: Signal<FetchedArtistImages>, name: String) {
match src.fetch_artist_image(&name).await {
Ok(Some(url)) => {
let _ = src
.set_artist_image(&normalize_artist_key(&name), "server", Some(&url))
.await;
fetched.write().insert_hit(name, url);
}
Ok(None) => {
let _ = src
.set_meta(&normalize_artist_key(&name), ARTIST_PHOTO_MISS_KIND, "")
.await;
fetched.write().insert_miss(name);
}
Err(_) => fetched.write().insert_miss(name),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn album(artist: &str) -> reader::Album {
reader::Album {
id: format!("al-{artist}"),
title: "A".into(),
artist: artist.into(),
genre: String::new(),
year: 0,
cover_path: None,
manual_cover: false,
}
}
fn track(artists: &[&str]) -> reader::Track {
reader::Track {
id: reader::TrackId::Local("/music/x.flac".into()),
cover: None,
album_id: "al".into(),
title: String::new(),
artist: artists.first().unwrap_or(&"").to_string(),
album: String::new(),
duration: 0,
khz: 0,
bitrate: 0,
track_number: None,
disc_number: None,
musicbrainz_release_id: None,
musicbrainz_recording_id: None,
musicbrainz_track_id: None,
playlist_item_id: None,
artists: artists.iter().map(|a| a.to_string()).collect(),
}
}
#[test]
fn fetch_queue_filters_and_orders() {
let albums = [album("Zebra"), album("apple")];
let sample = [
track(&["Beta", "COOL&CREATE, beatMARIO"]), track(&["COOL&CREATE"]), track(&[" "]), ];
let mut db_photos: HashMap<String, ArtistImageRef> = HashMap::new();
db_photos.insert("zebra".into(), ArtistImageRef::Remote("u".into()));
let mut already = FetchedArtistImages::default();
already.insert_hit("Beta".into(), "u".into());
let queue = fetch_queue(&albums, &sample, &db_photos, &already);
assert_eq!(queue, vec!["apple".to_string(), "COOL&CREATE".to_string()]);
}
}