use std::collections::HashMap;
use std::path::{Path, PathBuf};
use config::{AppConfig, MusicService};
use reader::{ArtistImageRef, CoverRef, Track};
use utils::CoverUrl;
use crate::source::ArtistView;
pub(crate) fn jellyfin_item_url(
server_url: &str,
item_id: &str,
image_tag: Option<&str>,
access_token: Option<&str>,
max_width: u32,
quality: u32,
) -> String {
let mut params = vec![
format!("maxWidth={max_width}"),
format!("quality={quality}"),
];
if let Some(tag) = image_tag {
params.push(format!("tag={tag}"));
}
if let Some(token) = access_token {
params.push(format!("api_key={token}"));
}
format!(
"{server_url}/Items/{item_id}/Images/Primary?{}",
params.join("&")
)
}
fn subsonic_item_url(
server_url: &str,
item_id: &str,
access_token: Option<&str>,
max_width: u32,
quality: u32,
) -> Option<String> {
if server_url.is_empty() || item_id.is_empty() {
return None;
}
let mut url = reqwest::Url::parse(&format!(
"{}/rest/getCoverArt.view",
server_url.trim_end_matches('/')
))
.ok()?;
{
let mut pairs = url.query_pairs_mut();
pairs.append_pair("id", item_id);
pairs.append_pair("size", &max_width.to_string());
pairs.append_pair("quality", &quality.to_string());
if let Some(token) = access_token {
pairs.append_pair("access_token", token);
}
}
Some(url.to_string())
}
pub fn resolve(config: &AppConfig, cover: CoverRef, max_width: u32) -> Option<CoverUrl> {
let server = config.server.as_ref();
let url = match cover {
CoverRef::Local(path) => return utils::format_artwork_url(Some(&path)),
CoverRef::EmbeddedUrl(url) => url,
CoverRef::JellyfinItem { item_id, tag } => {
let server = server.filter(|server| server.service == MusicService::Jellyfin)?;
jellyfin_item_url(
&server.url,
&item_id,
tag.as_deref(),
server.access_token.as_deref(),
max_width,
80,
)
}
CoverRef::SubsonicItem { item_id, signed } => {
let server = server.filter(|server| {
matches!(
server.service,
MusicService::Subsonic | MusicService::Custom
)
})?;
if signed {
let (Some(password), Some(username)) =
(server.access_token.as_deref(), server.user_id.as_deref())
else {
return None;
};
crate::subsonic::cover_art_url(
&server.url,
username,
password,
&item_id,
Some(max_width),
)
.ok()?
} else {
subsonic_item_url(
&server.url,
&item_id,
server.access_token.as_deref(),
max_width,
80,
)?
}
}
CoverRef::None => return None,
};
Some(utils::cover_url_from_string(url))
}
pub fn from_path(
config: &AppConfig,
cover_path: Option<&Path>,
max_width: u32,
) -> Option<CoverUrl> {
let stored = cover_path?.to_string_lossy();
resolve(config, CoverRef::parse(&stored), max_width)
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct FetchedArtistImages(HashMap<String, FetchEntry>);
#[derive(Debug, Clone, PartialEq)]
enum FetchEntry {
Pending,
Hit(String),
Miss,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArtistFetchState<'a> {
NotFetching,
Pending,
Resolved(Option<&'a str>),
}
impl FetchedArtistImages {
pub fn state(&self, display: &str) -> ArtistFetchState<'_> {
match self.0.get(display) {
None => ArtistFetchState::NotFetching,
Some(FetchEntry::Pending) => ArtistFetchState::Pending,
Some(FetchEntry::Hit(url)) => ArtistFetchState::Resolved(Some(url)),
Some(FetchEntry::Miss) => ArtistFetchState::Resolved(None),
}
}
pub fn contains(&self, display: &str) -> bool {
self.0.contains_key(display)
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn mark_pending(&mut self, names: impl IntoIterator<Item = String>) {
for name in names {
self.0.entry(name).or_insert(FetchEntry::Pending);
}
}
pub fn insert_hit(&mut self, display: String, url: String) {
self.0.insert(display, FetchEntry::Hit(url));
}
pub fn insert_miss(&mut self, display: String) {
self.0.insert(display, FetchEntry::Miss);
}
pub fn replace_all(&mut self, found: impl IntoIterator<Item = (String, String)>) {
self.0 = found
.into_iter()
.map(|(k, v)| (k, FetchEntry::Hit(v)))
.collect();
}
}
pub struct ArtistArt<'a> {
pub override_path: Option<&'a Path>,
pub photo: Option<&'a ArtistImageRef>,
pub fetched: ArtistFetchState<'a>,
pub album_cover: Option<&'a Path>,
pub view: ArtistView,
}
impl<'a> ArtistArt<'a> {
pub fn from_caches(
images: &'a db::ArtistImages,
fetched: &'a FetchedArtistImages,
norm: &str,
display: &str,
album_cover: Option<&'a Path>,
view: ArtistView,
) -> Self {
let (overrides, photos) = images;
Self {
override_path: overrides.get(norm).map(PathBuf::as_path),
photo: photos.get(norm),
fetched: fetched.state(display),
album_cover,
view,
}
}
}
pub fn artist(config: &AppConfig, art: ArtistArt<'_>, max_width: u32) -> Option<CoverUrl> {
let override_owned = art.override_path.map(Path::to_path_buf);
if let Some(cover) = utils::format_artwork_url(override_owned.as_deref()) {
return Some(cover);
}
if let Some(ArtistImageRef::Remote(url)) = art.photo {
return Some(utils::cover_url_from_string(url.clone()));
}
if let ArtistFetchState::Resolved(Some(url)) = art.fetched {
return Some(utils::cover_url_from_string(url.to_string()));
}
if let Some(ArtistImageRef::Local(path)) = art.photo
&& let Some(cover) = utils::format_artwork_url(Some(path))
{
return Some(cover);
}
match art.view {
ArtistView::Library if art.fetched != ArtistFetchState::Pending => {
from_path(config, art.album_cover, max_width)
}
_ => None,
}
}
pub fn track(config: &AppConfig, track: &Track, max_width: u32) -> Option<CoverUrl> {
resolve(config, CoverRef::for_track(track), max_width)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn local_active() -> AppConfig {
AppConfig {
active_source: config::Source::Local,
server: None,
..Default::default()
}
}
fn subsonic_track(item_id: &str, cover: Option<&str>) -> Track {
Track {
id: reader::TrackId::Server {
service: MusicService::Subsonic,
item_id: item_id.to_string(),
},
cover: cover.map(str::to_string),
album_id: String::new(),
title: String::new(),
artist: String::new(),
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: Vec::new(),
}
}
fn subsonic_config(with_creds: bool) -> AppConfig {
AppConfig {
active_source: config::Source::Local,
server: Some(config::MusicServer {
url: "https://sub.example.com".into(),
service: MusicService::Subsonic,
access_token: with_creds.then(|| "pw".to_string()),
user_id: with_creds.then(|| "alice".to_string()),
..Default::default()
}),
..Default::default()
}
}
fn jellyfin_config() -> AppConfig {
AppConfig {
active_source: config::Source::Local,
server: Some(config::MusicServer {
url: "https://jelly.example.com".into(),
service: MusicService::Jellyfin,
access_token: Some("token".to_string()),
..Default::default()
}),
..Default::default()
}
}
#[test]
fn subsonic_track_without_cover_path_falls_back_to_getcoverart() {
let track = subsonic_track("TR-42", Some("none"));
let got = super::track(&subsonic_config(true), &track, 800).expect("fallback cover url");
let s: &str = &got;
assert!(s.contains("getCoverArt"), "got: {s}");
assert!(s.contains("TR-42"), "keyed by the track id: {s}");
assert!(s.contains("alice"), "signed with the username: {s}");
assert!(super::track(&subsonic_config(false), &track, 800).is_none());
}
#[test]
fn subsonic_item_without_the_sentinel_uses_the_token_lookup() {
let got = resolve(
&subsonic_config(true),
CoverRef::SubsonicItem {
item_id: "AL-7".to_string(),
signed: false,
},
512,
)
.expect("cover url");
assert!(got.contains("getCoverArt"), "got: {got}");
assert!(got.contains("id=AL-7"), "keyed by the item: {got}");
assert!(got.contains("size=512"), "sized by the view: {got}");
assert!(
got.contains("access_token=pw"),
"token-authenticated: {got}"
);
assert!(
resolve(
&subsonic_config(true),
CoverRef::JellyfinItem {
item_id: "AL-7".to_string(),
tag: None
},
512
)
.is_none()
);
}
#[test]
fn soundcloud_track_resolves_without_a_server() {
let url = "https://i1.sndcdn.com/artworks-1:2-large.jpg";
let mut track = subsonic_track("SC-1", Some(url));
track.id = reader::TrackId::Server {
service: MusicService::SoundCloud,
item_id: "SC-1".to_string(),
};
let got = super::track(&local_active(), &track, 500).expect("artwork url");
assert_eq!(&*got, url);
}
#[test]
fn from_path_resolves_a_remote_ref_while_local_is_active() {
let url = "https://example.com/cover.jpg";
let reff = format!("ytmusic:_:{}", CoverRef::encode_url(url));
let got = from_path(&local_active(), Some(Path::new(&reff)), 200).expect("resolves");
assert_eq!(
&*got, url,
"self-contained remote ref → its URL, not artwork://"
);
}
#[test]
fn typed_jellyfin_ref_resolves_the_referenced_item_and_tag() {
let got = resolve(
&jellyfin_config(),
CoverRef::JellyfinItem {
item_id: "album-42".to_string(),
tag: Some("primary-tag".to_string()),
},
640,
)
.expect("jellyfin cover");
assert!(got.contains("/Items/album-42/Images/Primary"));
assert!(got.contains("tag=primary-tag"));
assert!(got.contains("maxWidth=640"));
}
#[test]
fn embedded_url_resolves_without_an_active_server() {
let url = "https://images.example/cover.jpg";
let got = resolve(&local_active(), CoverRef::EmbeddedUrl(url.to_string()), 320)
.expect("embedded cover");
assert_eq!(&*got, url);
}
#[test]
fn local_artwork_uses_the_shared_cached_protocol() {
let path = Path::new("/music/album/cover.png");
for max_width in [80, 1400] {
let cover = from_path(&local_active(), Some(path), max_width).expect("local cover");
assert!(
cover.starts_with("artwork://")
|| cover.starts_with("http://artwork.dioxus.localhost/"),
"local cover must use the artwork protocol: {cover}"
);
}
}
#[test]
fn artist_chain_resolves_in_priority_order() {
let cfg = local_active();
let album = Path::new("/music/band/album/cover.jpg");
let over = Path::new("/pics/custom.png");
let remote = ArtistImageRef::Remote("https://yt/photo.jpg".into());
let local = ArtistImageRef::Local(PathBuf::from("/music/band/artist.jpg"));
let art = |override_path, photo, fetched, album_cover, view| ArtistArt {
override_path,
photo,
fetched,
album_cover,
view,
};
let hit = ArtistFetchState::Resolved(Some("https://fetched/p.jpg"));
let miss = ArtistFetchState::Resolved(None);
let none = ArtistFetchState::NotFetching;
let lib = ArtistView::Library;
let rem = ArtistView::Remote;
for view in [lib, rem] {
let got = artist(
&cfg,
art(Some(over), Some(&remote), hit, Some(album), view),
320,
)
.unwrap();
assert!(got.contains("custom.png"));
}
let got = artist(&cfg, art(None, Some(&remote), hit, Some(album), lib), 320).unwrap();
assert_eq!(&*got, "https://yt/photo.jpg");
let got = artist(&cfg, art(None, Some(&local), hit, Some(album), lib), 320).unwrap();
assert_eq!(&*got, "https://fetched/p.jpg");
let got = artist(
&cfg,
art(
None,
Some(&local),
ArtistFetchState::Pending,
Some(album),
lib,
),
320,
)
.unwrap();
assert!(got.contains("artist.jpg"));
assert_eq!(
artist(
&cfg,
art(None, None, ArtistFetchState::Pending, Some(album), lib),
320
),
None
);
for state in [miss, none] {
let got = artist(&cfg, art(None, None, state, Some(album), lib), 320).unwrap();
assert!(got.contains("cover.jpg"));
}
assert_eq!(artist(&cfg, art(None, None, miss, None, lib), 320), None);
for state in [miss, none, ArtistFetchState::Pending] {
assert_eq!(
artist(&cfg, art(None, None, state, Some(album), rem), 320),
None
);
}
}
#[test]
fn artist_last_resort_resolves_remote_album_refs() {
let url = "https://example.com/album.jpg";
let reff = format!("ytmusic:_:{}", CoverRef::encode_url(url));
let got = artist(
&local_active(),
ArtistArt {
override_path: None,
photo: None,
fetched: ArtistFetchState::Resolved(None),
album_cover: Some(Path::new(&reff)),
view: ArtistView::Library,
},
320,
)
.unwrap();
assert_eq!(&*got, url);
}
#[test]
fn from_caches_bridges_norm_and_display_keys() {
let mut overrides = std::collections::HashMap::new();
overrides.insert("cool&create".to_string(), PathBuf::from("/pics/cc.png"));
let mut photos = std::collections::HashMap::new();
photos.insert(
"cool&create".to_string(),
ArtistImageRef::Remote("https://p/cc.jpg".into()),
);
let images: db::ArtistImages = (overrides, photos);
let mut fetched = FetchedArtistImages::default();
fetched.insert_hit("COOL&CREATE".into(), "https://f/cc.jpg".into());
let art = ArtistArt::from_caches(
&images,
&fetched,
"cool&create",
"COOL&CREATE",
None,
ArtistView::Library,
);
assert!(art.override_path.is_some());
assert!(matches!(art.photo, Some(ArtistImageRef::Remote(_))));
assert_eq!(
art.fetched,
ArtistFetchState::Resolved(Some("https://f/cc.jpg"))
);
let empty: db::ArtistImages = Default::default();
let no_fetch = FetchedArtistImages::default();
let art = ArtistArt::from_caches(&empty, &no_fetch, "x", "X", None, ArtistView::Library);
assert!(art.override_path.is_none() && art.photo.is_none());
assert_eq!(art.fetched, ArtistFetchState::NotFetching);
}
#[test]
fn fetched_map_states() {
let mut m = FetchedArtistImages::default();
assert_eq!(m.state("A"), ArtistFetchState::NotFetching);
m.mark_pending(["A".to_string()]);
assert_eq!(m.state("A"), ArtistFetchState::Pending);
assert!(m.contains("A"));
m.insert_hit("A".into(), "https://u".into());
assert_eq!(m.state("A"), ArtistFetchState::Resolved(Some("https://u")));
m.insert_miss("A".into());
assert_eq!(m.state("A"), ArtistFetchState::Resolved(None));
m.mark_pending(["A".to_string()]);
assert_eq!(m.state("A"), ArtistFetchState::Resolved(None));
m.replace_all([("B".to_string(), "https://b".to_string())]);
assert_eq!(m.state("B"), ArtistFetchState::Resolved(Some("https://b")));
assert_eq!(
m.state("A"),
ArtistFetchState::NotFetching,
"replace_all resets"
);
}
}