use std::collections::HashMap;
use std::path::{Path, PathBuf};
use config::{AppConfig, MusicService};
use reader::{ArtistImageRef, Track};
use utils::CoverUrl;
use crate::source::ArtistView;
fn local_artwork(config: &AppConfig, path: Option<&Path>) -> Option<CoverUrl> {
match config
.image_optimization_enabled
.then_some(config.image_optimization_max_size)
.filter(|size| *size > 0)
{
Some(size) => utils::format_artwork_thumb_url(path, size),
None => utils::format_artwork_url(path),
}
}
pub fn from_path(
config: &AppConfig,
cover_path: Option<&Path>,
max_width: u32,
) -> Option<CoverUrl> {
let path = cover_path?;
if path.is_absolute() {
return local_artwork(config, Some(path));
}
let (server_url, token) = config
.server
.as_ref()
.map(|s| (s.url.as_str(), s.access_token.as_deref()))
.unwrap_or(("", None));
utils::map_cover_url(utils::jellyfin_image::jellyfin_image_url_from_path(
&path.to_string_lossy(),
server_url,
token,
max_width,
80,
))
}
#[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) = local_artwork(config, 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) = local_artwork(config, 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> {
let Some(service) = track.id.service() else {
let owned = track.cover.as_deref().map(PathBuf::from);
return local_artwork(config, owned.as_deref());
};
let server = config.server.as_ref()?;
let url = match service {
MusicService::Jellyfin => utils::jellyfin_image::resolve_track_cover(
track.cover.as_deref(),
&track.id.key(),
&track.album_id,
&server.url,
server.access_token.as_deref(),
max_width,
80,
),
MusicService::Subsonic | MusicService::Custom => {
let subsonic_path = match track.cover.as_deref() {
Some(c) => format!("{}:{}", track.id.uid(), c),
None => track.id.uid(),
};
utils::subsonic_image::subsonic_image_url_from_path(
&subsonic_path,
&server.url,
server.access_token.as_deref(),
max_width,
80,
)
.or_else(|| {
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,
&track.id.key(),
Some(max_width),
)
.ok()
})
}
MusicService::YtMusic => utils::jellyfin_image::resolve_track_cover(
track.cover.as_deref(),
&track.id.key(),
&track.album_id,
"",
None,
max_width,
80,
),
MusicService::SoundCloud => track.cover.clone(),
};
utils::map_cover_url(url)
}
#[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()
}
}
#[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 from_path_resolves_a_remote_ref_while_local_is_active() {
let url = "https://example.com/cover.jpg";
let reff = format!("ytmusic:_:{}", utils::jellyfin_image::encode_cover_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 local_artwork_is_original_unless_optimization_is_enabled() {
let path = Path::new("/music/album/cover.png");
for max_width in [80, 1400] {
let original = from_path(&local_active(), Some(path), max_width).expect("local cover");
assert!(
!original.contains("&s="),
"default must serve original at {max_width}: {original}"
);
}
let optimized = AppConfig {
image_optimization_enabled: true,
image_optimization_max_size: 512,
..local_active()
};
let resized = from_path(&optimized, Some(path), 80).expect("optimized local cover");
assert!(
resized.ends_with("&s=512"),
"selected size must be used: {resized}"
);
}
#[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:_:{}", utils::jellyfin_image::encode_cover_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"
);
}
}