A tiny Subsonic/Jellyfin/S3 server in Rust
navidrome subsonic s3 emby jellyfin
0

Configure Feed

Select the types of activity you want to include in your feed.

Add Jellyfin RemoteImage API backed by Last.fm + MusicBrainz

GET /Items/{id}/RemoteImages, /Providers, and POST /Download extend
the existing Last.fm + MusicBrainz plugins to cover-art lookup and
download. Last.fm calls album.getInfo / artist.getInfo; MusicBrainz
resolves a release MBID by artist+album name and then queries the
Cover Art Archive, preferring approved front covers. Downloaded bytes
land in covers_dir and update albums.cover_art so the existing image
handler serves them transparently. Only Primary is supported; other
types return empty on search and 400 on download.

+808
+40
src/jellyfin/dto.rs
··· 575 575 pub provider_name: String, 576 576 pub lyrics: LyricDto, 577 577 } 578 + 579 + // ── RemoteImage ───────────────────────────────────────────────────────────── 580 + 581 + /// `RemoteImageInfo` — one candidate image returned by a metadata provider. 582 + /// Every field is nullable per spec; smolsonic populates `Url`, `Type`, 583 + /// `ProviderName`, and dimensions when the source reports them. 584 + #[derive(Debug, Serialize)] 585 + #[serde(rename_all = "PascalCase")] 586 + pub struct RemoteImageInfo { 587 + pub provider_name: Option<String>, 588 + pub url: Option<String>, 589 + pub thumbnail_url: Option<String>, 590 + pub height: Option<i32>, 591 + pub width: Option<i32>, 592 + pub community_rating: Option<f64>, 593 + pub vote_count: Option<i32>, 594 + pub language: Option<String>, 595 + /// `ImageType`: `Primary | Art | Backdrop | Banner | Logo | Thumb | Disc 596 + /// | Box | Screenshot | Menu | Chapter | BoxRear | Profile`. smolsonic 597 + /// only surfaces `Primary`. 598 + #[serde(rename = "Type")] 599 + pub image_type: &'static str, 600 + } 601 + 602 + #[derive(Debug, Serialize)] 603 + #[serde(rename_all = "PascalCase")] 604 + pub struct RemoteImageResult { 605 + pub images: Vec<RemoteImageInfo>, 606 + pub total_record_count: i32, 607 + pub providers: Vec<String>, 608 + } 609 + 610 + /// `ImageProviderInfo` — one entry in the response of 611 + /// `GET /Items/{id}/RemoteImages/Providers`. 612 + #[derive(Debug, Serialize)] 613 + #[serde(rename_all = "PascalCase")] 614 + pub struct ImageProviderInfo { 615 + pub name: String, 616 + pub supported_images: Vec<&'static str>, 617 + }
+289
src/jellyfin/handlers.rs
··· 2544 2544 } 2545 2545 } 2546 2546 2547 + // ── RemoteImage ───────────────────────────────────────────────────────────── 2548 + // 2549 + // Spec (Jellyfin OpenAPI, RemoteImage tag): 2550 + // GET /Items/{id}/RemoteImages?type=&providerName=&startIndex=&limit= 2551 + // &includeAllLanguages= → RemoteImageResult 2552 + // POST /Items/{id}/RemoteImages/Download?type=&imageUrl= → 204 2553 + // GET /Items/{id}/RemoteImages/Providers → [ImageProviderInfo] 2554 + // 2555 + // Sourced from the Last.fm + MusicBrainz plugins we already wired up for 2556 + // Similar. Only `Primary` (cover art) is supported — smolsonic doesn't 2557 + // model Art/Backdrop/Banner/etc. Requests for other types return an empty 2558 + // image list on the search endpoint and 400 on Download. 2559 + 2560 + #[derive(Debug, Deserialize)] 2561 + #[allow(dead_code)] // `include_all_languages` is accepted per spec but not filtered on. 2562 + pub struct RemoteImagesQuery { 2563 + #[serde(default, alias = "Type", rename = "type")] 2564 + pub image_type: Option<String>, 2565 + #[serde(default, alias = "StartIndex", rename = "startIndex")] 2566 + pub start_index: Option<i64>, 2567 + #[serde(default, alias = "Limit")] 2568 + pub limit: Option<i64>, 2569 + #[serde(default, alias = "ProviderName", rename = "providerName")] 2570 + pub provider_name: Option<String>, 2571 + #[serde(default, alias = "IncludeAllLanguages", rename = "includeAllLanguages")] 2572 + pub include_all_languages: Option<bool>, 2573 + } 2574 + 2575 + #[derive(Debug, Deserialize)] 2576 + pub struct DownloadImageQuery { 2577 + #[serde(default, alias = "Type", rename = "type")] 2578 + pub image_type: Option<String>, 2579 + #[serde(default, alias = "ImageUrl", rename = "imageUrl")] 2580 + pub image_url: Option<String>, 2581 + } 2582 + 2583 + fn is_primary(kind: Option<&str>) -> bool { 2584 + // Absent `type` → default to Primary (Findroid omits it on the initial 2585 + // rail render). Any other explicit value → not Primary. 2586 + match kind { 2587 + None => true, 2588 + Some(s) => s.eq_ignore_ascii_case("Primary"), 2589 + } 2590 + } 2591 + 2592 + /// Resolve `item_guid` to (kind, native_id). For songs we hop up to the 2593 + /// containing album because that's where the artwork actually lives. 2594 + async fn remote_image_target( 2595 + state: &JellyfinState, 2596 + item_guid: &str, 2597 + ) -> Result<(String, crate::models::Album), HttpResponse> { 2598 + let g = mapping::normalize_guid(item_guid); 2599 + let Some((kind, native)) = resolve_native(state, &g).await else { 2600 + return Err(HttpResponse::NotFound().finish()); 2601 + }; 2602 + match kind.as_str() { 2603 + "album" => match repo::find_album(&state.pool, &native).await { 2604 + Ok(Some(a)) => Ok(("album".to_string(), a)), 2605 + _ => Err(HttpResponse::NotFound().finish()), 2606 + }, 2607 + "song" => { 2608 + let Ok(Some(s)) = repo::find_song(&state.pool, &native).await else { 2609 + return Err(HttpResponse::NotFound().finish()); 2610 + }; 2611 + match repo::find_album(&state.pool, &s.album_id).await { 2612 + Ok(Some(a)) => Ok(("song".to_string(), a)), 2613 + _ => Err(HttpResponse::NotFound().finish()), 2614 + } 2615 + } 2616 + "artist" => Err(HttpResponse::NotFound().finish()), 2617 + _ => Err(HttpResponse::NotFound().finish()), 2618 + } 2619 + } 2620 + 2621 + fn enabled_provider_names(state: &JellyfinState) -> Vec<String> { 2622 + let mut out = Vec::new(); 2623 + if state.similar.lastfm.is_some() { 2624 + out.push("Last.fm".to_string()); 2625 + } 2626 + if state.similar.musicbrainz.is_some() { 2627 + out.push("MusicBrainz".to_string()); 2628 + } 2629 + out 2630 + } 2631 + 2632 + pub async fn remote_images( 2633 + _user: AuthedUser, 2634 + state: web::Data<JellyfinState>, 2635 + path: web::Path<String>, 2636 + query: web::Query<RemoteImagesQuery>, 2637 + ) -> HttpResponse { 2638 + let (_kind, album) = match remote_image_target(&state, &path.into_inner()).await { 2639 + Ok(t) => t, 2640 + Err(resp) => return resp, 2641 + }; 2642 + let q = query.into_inner(); 2643 + let providers_available = enabled_provider_names(&state); 2644 + 2645 + // Non-Primary type → the query is well-formed but we have nothing. 2646 + if !is_primary(q.image_type.as_deref()) { 2647 + return HttpResponse::Ok().json(super::dto::RemoteImageResult { 2648 + images: vec![], 2649 + total_record_count: 0, 2650 + providers: providers_available, 2651 + }); 2652 + } 2653 + 2654 + let mut images: Vec<super::dto::RemoteImageInfo> = Vec::new(); 2655 + let want_lastfm = matches_provider(&q.provider_name, "Last.fm"); 2656 + let want_mb = matches_provider(&q.provider_name, "MusicBrainz"); 2657 + 2658 + if want_lastfm { 2659 + if let Some(lf) = &state.similar.lastfm { 2660 + match lf 2661 + .album_image_urls_cached(&state.pool, &album.id, &album.artist, &album.title) 2662 + .await 2663 + { 2664 + Ok(urls) => { 2665 + for url in urls { 2666 + images.push(super::dto::RemoteImageInfo { 2667 + provider_name: Some("Last.fm".to_string()), 2668 + url: Some(url), 2669 + thumbnail_url: None, 2670 + height: None, 2671 + width: None, 2672 + community_rating: None, 2673 + vote_count: None, 2674 + language: Some("en".to_string()), 2675 + image_type: "Primary", 2676 + }); 2677 + } 2678 + } 2679 + Err(e) => tracing::warn!("lastfm album.getInfo({}): {e}", album.title), 2680 + } 2681 + } 2682 + } 2683 + if want_mb { 2684 + if let Some(mb) = &state.similar.musicbrainz { 2685 + match mb 2686 + .album_image_urls_cached(&state.pool, &album.id, &album.artist, &album.title) 2687 + .await 2688 + { 2689 + Ok(urls) => { 2690 + for url in urls { 2691 + images.push(super::dto::RemoteImageInfo { 2692 + provider_name: Some("MusicBrainz".to_string()), 2693 + url: Some(url), 2694 + thumbnail_url: None, 2695 + height: None, 2696 + width: None, 2697 + community_rating: None, 2698 + vote_count: None, 2699 + language: None, 2700 + image_type: "Primary", 2701 + }); 2702 + } 2703 + } 2704 + Err(e) => tracing::warn!("mb coverart({}): {e}", album.title), 2705 + } 2706 + } 2707 + } 2708 + 2709 + // Honour StartIndex / Limit. 2710 + let start = q.start_index.unwrap_or(0).max(0) as usize; 2711 + let take = q.limit.map(|n| n.max(1) as usize).unwrap_or(usize::MAX); 2712 + let total = images.len() as i32; 2713 + let sliced: Vec<super::dto::RemoteImageInfo> = 2714 + images.into_iter().skip(start).take(take).collect(); 2715 + HttpResponse::Ok().json(super::dto::RemoteImageResult { 2716 + images: sliced, 2717 + total_record_count: total, 2718 + providers: providers_available, 2719 + }) 2720 + } 2721 + 2722 + fn matches_provider(requested: &Option<String>, provider: &str) -> bool { 2723 + match requested { 2724 + None => true, 2725 + Some(s) => s.eq_ignore_ascii_case(provider), 2726 + } 2727 + } 2728 + 2729 + pub async fn remote_image_providers( 2730 + _user: AuthedUser, 2731 + state: web::Data<JellyfinState>, 2732 + path: web::Path<String>, 2733 + ) -> HttpResponse { 2734 + // Still 404 on unknown items — real Jellyfin behaves the same. 2735 + let g = mapping::normalize_guid(&path.into_inner()); 2736 + if resolve_native(&state, &g).await.is_none() { 2737 + return HttpResponse::NotFound().finish(); 2738 + } 2739 + let list: Vec<super::dto::ImageProviderInfo> = enabled_provider_names(&state) 2740 + .into_iter() 2741 + .map(|name| super::dto::ImageProviderInfo { 2742 + name, 2743 + supported_images: vec!["Primary"], 2744 + }) 2745 + .collect(); 2746 + HttpResponse::Ok().json(list) 2747 + } 2748 + 2749 + pub async fn download_remote_image( 2750 + _user: AuthedUser, 2751 + state: web::Data<JellyfinState>, 2752 + path: web::Path<String>, 2753 + query: web::Query<DownloadImageQuery>, 2754 + ) -> HttpResponse { 2755 + let (_kind, album) = match remote_image_target(&state, &path.into_inner()).await { 2756 + Ok(t) => t, 2757 + Err(resp) => return resp, 2758 + }; 2759 + let q = query.into_inner(); 2760 + if !is_primary(q.image_type.as_deref()) { 2761 + return HttpResponse::BadRequest().body("only Primary image type is supported"); 2762 + } 2763 + let Some(image_url) = q.image_url.filter(|u| u.starts_with("http")) else { 2764 + return HttpResponse::BadRequest().body("imageUrl query parameter is required"); 2765 + }; 2766 + if state.similar.lastfm.is_none() && state.similar.musicbrainz.is_none() { 2767 + return HttpResponse::BadRequest() 2768 + .body("no image plugin enabled; configure [lastfm] or [musicbrainz]"); 2769 + } 2770 + 2771 + let http = reqwest::Client::builder() 2772 + .timeout(std::time::Duration::from_secs(15)) 2773 + .build(); 2774 + let http = match http { 2775 + Ok(c) => c, 2776 + Err(e) => { 2777 + tracing::error!("build http client: {e}"); 2778 + return HttpResponse::InternalServerError().finish(); 2779 + } 2780 + }; 2781 + let resp = match http.get(&image_url).send().await { 2782 + Ok(r) => r, 2783 + Err(e) => { 2784 + tracing::warn!("download {image_url}: {e}"); 2785 + return HttpResponse::BadGateway().finish(); 2786 + } 2787 + }; 2788 + let content_type = resp 2789 + .headers() 2790 + .get(reqwest::header::CONTENT_TYPE) 2791 + .and_then(|v| v.to_str().ok()) 2792 + .unwrap_or("image/jpeg") 2793 + .to_string(); 2794 + let bytes = match resp.bytes().await { 2795 + Ok(b) => b, 2796 + Err(e) => { 2797 + tracing::warn!("read image bytes: {e}"); 2798 + return HttpResponse::BadGateway().finish(); 2799 + } 2800 + }; 2801 + let ext = ext_for_content_type(&content_type); 2802 + let filename = format!("remote-{}.{ext}", album.id); 2803 + let dst = state.covers_dir.join(&filename); 2804 + if let Err(e) = std::fs::create_dir_all(&state.covers_dir) { 2805 + tracing::error!("create covers_dir {}: {e}", state.covers_dir.display()); 2806 + return HttpResponse::InternalServerError().finish(); 2807 + } 2808 + if let Err(e) = std::fs::write(&dst, &bytes) { 2809 + tracing::error!("write cover {}: {e}", dst.display()); 2810 + return HttpResponse::InternalServerError().finish(); 2811 + } 2812 + if let Err(e) = repo::set_album_cover_art(&state.pool, &album.id, &filename).await { 2813 + tracing::error!("set_album_cover_art({}): {e}", album.id); 2814 + return HttpResponse::InternalServerError().finish(); 2815 + } 2816 + HttpResponse::NoContent().finish() 2817 + } 2818 + 2819 + /// Naive content-type → extension. Falls back to `jpg` for anything unknown 2820 + /// because Cover Art Archive returns JPEG for `/front-*` by default. 2821 + fn ext_for_content_type(ct: &str) -> &'static str { 2822 + let ct_lower = ct.to_ascii_lowercase(); 2823 + if ct_lower.starts_with("image/png") { 2824 + "png" 2825 + } else if ct_lower.starts_with("image/webp") { 2826 + "webp" 2827 + } else if ct_lower.starts_with("image/gif") { 2828 + "gif" 2829 + } else if ct_lower.starts_with("image/svg") { 2830 + "svg" 2831 + } else { 2832 + "jpg" 2833 + } 2834 + } 2835 + 2547 2836 // ── Images ─────────────────────────────────────────────────────────────────── 2548 2837 2549 2838 fn cover_path_for(state: &JellyfinState, filename: &str) -> PathBuf {
+146
src/jellyfin/mod.rs
··· 228 228 "/Items/{id}/InstantMix", 229 229 web::get().to(handlers::item_instant_mix), 230 230 ) 231 + // RemoteImage — modelled after the Jellyfin OpenAPI RemoteImage tag. 232 + // Powered by the Last.fm / MusicBrainz plugins; empty when neither 233 + // is enabled. `/RemoteImages/Providers` and `/Download` must 234 + // precede the plain `/RemoteImages` so actix binds the longer 235 + // paths first. 236 + .route( 237 + "/Items/{id}/RemoteImages/Providers", 238 + web::get().to(handlers::remote_image_providers), 239 + ) 240 + .route( 241 + "/Items/{id}/RemoteImages/Download", 242 + web::post().to(handlers::download_remote_image), 243 + ) 244 + .route( 245 + "/Items/{id}/RemoteImages", 246 + web::get().to(handlers::remote_images), 247 + ) 231 248 .route("/Items/{id}", web::get().to(handlers::item_by_id)) 232 249 // DELETE /Items/{id} — Jellyfin uses this to delete playlists (they 233 250 // are `BaseItem`s). Songs/albums are read-only in smolsonic and get ··· 2168 2185 "expected 404 for {uri}" 2169 2186 ); 2170 2187 } 2188 + } 2189 + 2190 + /// RemoteImage API — with neither Last.fm nor MusicBrainz configured, 2191 + /// the search returns empty images + empty providers, Providers 2192 + /// returns [], Download rejects with 400, and unknown GUIDs 404. 2193 + #[actix_web::test] 2194 + async fn remote_images_returns_empty_when_no_plugin_tokens_configured() { 2195 + let dir = tempdir(); 2196 + let state = fixture_state(&dir, &dir, false).await; 2197 + let app = test::init_service( 2198 + App::new() 2199 + .app_data(web::Data::new(state)) 2200 + .configure(configure_routes), 2201 + ) 2202 + .await; 2203 + 2204 + let req = test::TestRequest::post() 2205 + .uri("/Users/AuthenticateByName") 2206 + .insert_header(( 2207 + "X-Emby-Authorization", 2208 + r#"MediaBrowser Client="t", Device="d", DeviceId="i", Version="v""#, 2209 + )) 2210 + .set_json(serde_json::json!({"Username":"alice","Pw":"secret"})) 2211 + .to_request(); 2212 + let auth_body: Value = test::call_and_read_body_json(&app, req).await; 2213 + let token = auth_body["AccessToken"].as_str().unwrap().to_string(); 2214 + 2215 + // Discover the album GUID. 2216 + let req = test::TestRequest::get() 2217 + .uri("/Items?IncludeItemTypes=MusicAlbum") 2218 + .insert_header(("X-Emby-Token", token.clone())) 2219 + .to_request(); 2220 + let albums: Value = test::call_and_read_body_json(&app, req).await; 2221 + let album_id = albums["Items"][0]["Id"].as_str().unwrap().to_string(); 2222 + 2223 + // GET /RemoteImages — no plugins → empty images + empty providers. 2224 + let req = test::TestRequest::get() 2225 + .uri(&format!("/Items/{album_id}/RemoteImages")) 2226 + .insert_header(("X-Emby-Token", token.clone())) 2227 + .to_request(); 2228 + let body: Value = test::call_and_read_body_json(&app, req).await; 2229 + assert_eq!(body["TotalRecordCount"], 0); 2230 + assert!(body["Images"].as_array().unwrap().is_empty()); 2231 + assert!(body["Providers"].as_array().unwrap().is_empty()); 2232 + 2233 + // GET /RemoteImages/Providers — no plugins → empty list. 2234 + let req = test::TestRequest::get() 2235 + .uri(&format!("/Items/{album_id}/RemoteImages/Providers")) 2236 + .insert_header(("X-Emby-Token", token.clone())) 2237 + .to_request(); 2238 + let body: Value = test::call_and_read_body_json(&app, req).await; 2239 + assert!(body.as_array().unwrap().is_empty()); 2240 + 2241 + // POST Download without imageUrl → 400. 2242 + let req = test::TestRequest::post() 2243 + .uri(&format!( 2244 + "/Items/{album_id}/RemoteImages/Download?type=Primary" 2245 + )) 2246 + .insert_header(("X-Emby-Token", token.clone())) 2247 + .to_request(); 2248 + assert_eq!( 2249 + test::call_service(&app, req).await.status(), 2250 + StatusCode::BAD_REQUEST 2251 + ); 2252 + 2253 + // POST Download with non-Primary type → 400. 2254 + let req = test::TestRequest::post() 2255 + .uri(&format!( 2256 + "/Items/{album_id}/RemoteImages/Download?type=Backdrop&imageUrl=http://x" 2257 + )) 2258 + .insert_header(("X-Emby-Token", token.clone())) 2259 + .to_request(); 2260 + assert_eq!( 2261 + test::call_service(&app, req).await.status(), 2262 + StatusCode::BAD_REQUEST 2263 + ); 2264 + 2265 + // POST Download with plugins disabled but otherwise valid → 400. 2266 + let req = test::TestRequest::post() 2267 + .uri(&format!( 2268 + "/Items/{album_id}/RemoteImages/Download?type=Primary&imageUrl=https://example/x.jpg" 2269 + )) 2270 + .insert_header(("X-Emby-Token", token.clone())) 2271 + .to_request(); 2272 + assert_eq!( 2273 + test::call_service(&app, req).await.status(), 2274 + StatusCode::BAD_REQUEST 2275 + ); 2276 + 2277 + // Requesting a non-Primary type on search → empty images, no error. 2278 + let req = test::TestRequest::get() 2279 + .uri(&format!("/Items/{album_id}/RemoteImages?type=Backdrop")) 2280 + .insert_header(("X-Emby-Token", token.clone())) 2281 + .to_request(); 2282 + let body: Value = test::call_and_read_body_json(&app, req).await; 2283 + assert_eq!(body["TotalRecordCount"], 0); 2284 + 2285 + // Unknown GUID → 404 across the trio. 2286 + let bogus = "00000000-0000-0000-0000-000000000000"; 2287 + for uri in [ 2288 + format!("/Items/{bogus}/RemoteImages"), 2289 + format!("/Items/{bogus}/RemoteImages/Providers"), 2290 + ] { 2291 + let req = test::TestRequest::get() 2292 + .uri(&uri) 2293 + .insert_header(("X-Emby-Token", token.clone())) 2294 + .to_request(); 2295 + assert_eq!( 2296 + test::call_service(&app, req).await.status(), 2297 + StatusCode::NOT_FOUND, 2298 + "expected 404 for {uri}" 2299 + ); 2300 + } 2301 + 2302 + // Artist GUID → RemoteImages 404 (only album + song supported). 2303 + let req = test::TestRequest::get() 2304 + .uri("/Items?IncludeItemTypes=MusicArtist") 2305 + .insert_header(("X-Emby-Token", token.clone())) 2306 + .to_request(); 2307 + let artists: Value = test::call_and_read_body_json(&app, req).await; 2308 + let artist_id = artists["Items"][0]["Id"].as_str().unwrap().to_string(); 2309 + let req = test::TestRequest::get() 2310 + .uri(&format!("/Items/{artist_id}/RemoteImages")) 2311 + .insert_header(("X-Emby-Token", token.clone())) 2312 + .to_request(); 2313 + assert_eq!( 2314 + test::call_service(&app, req).await.status(), 2315 + StatusCode::NOT_FOUND 2316 + ); 2171 2317 } 2172 2318 }
+321
src/jellyfin/similar.rs
··· 194 194 .await?; 195 195 Ok(parse_lastfm_names(&resp)) 196 196 } 197 + 198 + /// Look up cover-art URLs for an album via `album.getInfo`. Note that 199 + /// Last.fm's response usually contains empty image strings post-2018 200 + /// (licensing), but we still call it so we surface whatever it returns 201 + /// alongside MusicBrainz. Cached under the "lastfm-album-image" tag. 202 + pub async fn album_image_urls_cached( 203 + &self, 204 + pool: &Db, 205 + album_native_id: &str, 206 + artist: &str, 207 + album: &str, 208 + ) -> Result<Vec<String>> { 209 + if let Some(cached) = cache_get(pool, album_native_id, "lastfm-album-image").await? { 210 + return Ok(cached); 211 + } 212 + let urls = self.fetch_album_images(artist, album).await?; 213 + cache_put(pool, album_native_id, "lastfm-album-image", &urls).await?; 214 + Ok(urls) 215 + } 216 + 217 + async fn fetch_album_images(&self, artist: &str, album: &str) -> Result<Vec<String>> { 218 + let resp: LastfmAlbumInfoResponse = self 219 + .http 220 + .get("http://ws.audioscrobbler.com/2.0/") 221 + .query(&[ 222 + ("method", "album.getinfo"), 223 + ("artist", artist), 224 + ("album", album), 225 + ("autocorrect", "1"), 226 + ("api_key", self.api_key.as_str()), 227 + ("format", "json"), 228 + ]) 229 + .send() 230 + .await? 231 + .error_for_status()? 232 + .json() 233 + .await?; 234 + Ok(parse_lastfm_album_images(&resp)) 235 + } 236 + 237 + /// `artist.getInfo` → image URLs. Same 2018-licensing caveat as 238 + /// `album.getInfo` — expect mostly empty responses. 239 + pub async fn artist_image_urls_cached( 240 + &self, 241 + pool: &Db, 242 + artist_native_id: &str, 243 + artist: &str, 244 + ) -> Result<Vec<String>> { 245 + if let Some(cached) = cache_get(pool, artist_native_id, "lastfm-artist-image").await? { 246 + return Ok(cached); 247 + } 248 + let urls = self.fetch_artist_images(artist).await?; 249 + cache_put(pool, artist_native_id, "lastfm-artist-image", &urls).await?; 250 + Ok(urls) 251 + } 252 + 253 + async fn fetch_artist_images(&self, artist: &str) -> Result<Vec<String>> { 254 + let resp: LastfmArtistInfoResponse = self 255 + .http 256 + .get("http://ws.audioscrobbler.com/2.0/") 257 + .query(&[ 258 + ("method", "artist.getinfo"), 259 + ("artist", artist), 260 + ("autocorrect", "1"), 261 + ("api_key", self.api_key.as_str()), 262 + ("format", "json"), 263 + ]) 264 + .send() 265 + .await? 266 + .error_for_status()? 267 + .json() 268 + .await?; 269 + Ok(parse_lastfm_artist_images(&resp)) 270 + } 271 + } 272 + 273 + #[derive(Debug, Deserialize)] 274 + struct LastfmAlbumInfoResponse { 275 + #[serde(default)] 276 + album: Option<LastfmAlbumInfo>, 277 + } 278 + 279 + #[derive(Debug, Deserialize)] 280 + struct LastfmAlbumInfo { 281 + #[serde(default, rename = "image")] 282 + images: Vec<LastfmImage>, 283 + } 284 + 285 + #[derive(Debug, Deserialize)] 286 + struct LastfmArtistInfoResponse { 287 + #[serde(default)] 288 + artist: Option<LastfmArtistInfo>, 289 + } 290 + 291 + #[derive(Debug, Deserialize)] 292 + struct LastfmArtistInfo { 293 + #[serde(default, rename = "image")] 294 + images: Vec<LastfmImage>, 295 + } 296 + 297 + #[derive(Debug, Deserialize)] 298 + struct LastfmImage { 299 + #[serde(default, rename = "#text")] 300 + url: String, 301 + #[serde(default)] 302 + #[allow(dead_code)] // useful for callers that want to pick a preferred size 303 + size: String, 304 + } 305 + 306 + fn parse_lastfm_album_images(resp: &LastfmAlbumInfoResponse) -> Vec<String> { 307 + resp.album 308 + .as_ref() 309 + .map(|a| { 310 + a.images 311 + .iter() 312 + .map(|i| i.url.clone()) 313 + .filter(|u| !u.is_empty()) 314 + .collect() 315 + }) 316 + .unwrap_or_default() 317 + } 318 + 319 + fn parse_lastfm_artist_images(resp: &LastfmArtistInfoResponse) -> Vec<String> { 320 + resp.artist 321 + .as_ref() 322 + .map(|a| { 323 + a.images 324 + .iter() 325 + .map(|i| i.url.clone()) 326 + .filter(|u| !u.is_empty()) 327 + .collect() 328 + }) 329 + .unwrap_or_default() 197 330 } 198 331 199 332 #[derive(Debug, Deserialize, Serialize)] ··· 274 407 *guard = Some(std::time::Instant::now()); 275 408 } 276 409 410 + /// Cover Art Archive lookup for an album. Two-step: search for a 411 + /// release MBID by artist+album name, then `GET 412 + /// https://coverartarchive.org/release/{mbid}` for the CAA JSON. 413 + /// Cached under "mb-album-image". 414 + pub async fn album_image_urls_cached( 415 + &self, 416 + pool: &Db, 417 + album_native_id: &str, 418 + artist: &str, 419 + album: &str, 420 + ) -> Result<Vec<String>> { 421 + if let Some(cached) = cache_get(pool, album_native_id, "mb-album-image").await? { 422 + return Ok(cached); 423 + } 424 + let urls = self 425 + .fetch_coverart_urls(artist, album) 426 + .await 427 + .unwrap_or_default(); 428 + cache_put(pool, album_native_id, "mb-album-image", &urls).await?; 429 + Ok(urls) 430 + } 431 + 432 + async fn fetch_coverart_urls(&self, artist: &str, album: &str) -> Result<Vec<String>> { 433 + self.wait_for_slot().await; 434 + let search: MbReleaseSearchResponse = self 435 + .http 436 + .get("https://musicbrainz.org/ws/2/release") 437 + .header("User-Agent", &self.user_agent) 438 + .header("Accept", "application/json") 439 + .query(&[ 440 + ( 441 + "query", 442 + format!("release:\"{album}\" AND artist:\"{artist}\"").as_str(), 443 + ), 444 + ("limit", "1"), 445 + ]) 446 + .send() 447 + .await? 448 + .error_for_status()? 449 + .json() 450 + .await?; 451 + let Some(mbid) = search.releases.first().map(|r| r.id.clone()) else { 452 + return Ok(Vec::new()); 453 + }; 454 + 455 + // Cover Art Archive is a separate host (no MB rate limiter needed), 456 + // but we still keep the interval as a courtesy — it's the same 457 + // upstream infrastructure. 458 + self.wait_for_slot().await; 459 + let caa: CoverArtArchiveResponse = self 460 + .http 461 + .get(format!("https://coverartarchive.org/release/{mbid}")) 462 + .header("User-Agent", &self.user_agent) 463 + .send() 464 + .await? 465 + .error_for_status()? 466 + .json() 467 + .await?; 468 + Ok(parse_caa_urls(&caa)) 469 + } 470 + 277 471 async fn fetch_linked(&self, seed_name: &str) -> Result<Vec<String>> { 278 472 // Step 1: resolve name → MBID via the search index. 279 473 self.wait_for_slot().await; ··· 350 544 .collect() 351 545 } 352 546 547 + #[derive(Debug, Deserialize)] 548 + struct MbReleaseSearchResponse { 549 + #[serde(default)] 550 + releases: Vec<MbRelease>, 551 + } 552 + 553 + #[derive(Debug, Deserialize)] 554 + struct MbRelease { 555 + id: String, 556 + } 557 + 558 + #[derive(Debug, Deserialize)] 559 + struct CoverArtArchiveResponse { 560 + #[serde(default)] 561 + images: Vec<CoverArtImage>, 562 + } 563 + 564 + #[derive(Debug, Deserialize)] 565 + struct CoverArtImage { 566 + #[serde(default)] 567 + image: String, 568 + #[serde(default)] 569 + thumbnails: Option<CoverArtThumbnails>, 570 + #[serde(default)] 571 + front: bool, 572 + #[serde(default)] 573 + approved: bool, 574 + } 575 + 576 + #[derive(Debug, Deserialize)] 577 + struct CoverArtThumbnails { 578 + #[serde(default, rename = "500")] 579 + #[allow(dead_code)] 580 + medium: Option<String>, 581 + #[serde(default, rename = "1200")] 582 + #[allow(dead_code)] 583 + large: Option<String>, 584 + } 585 + 586 + fn parse_caa_urls(resp: &CoverArtArchiveResponse) -> Vec<String> { 587 + // Prefer approved front covers; fall back to any front cover; fall back 588 + // to any image. Emit the full-size `image` URL — CAA serves a redirect 589 + // to the actual bytes when downloaded. 590 + let mut out: Vec<String> = Vec::new(); 591 + for i in &resp.images { 592 + if !i.approved || !i.front { 593 + continue; 594 + } 595 + if !i.image.is_empty() { 596 + out.push(i.image.clone()); 597 + } 598 + } 599 + if out.is_empty() { 600 + for i in &resp.images { 601 + if !i.front { 602 + continue; 603 + } 604 + if !i.image.is_empty() { 605 + out.push(i.image.clone()); 606 + } 607 + } 608 + } 609 + if out.is_empty() { 610 + for i in &resp.images { 611 + if !i.image.is_empty() { 612 + out.push(i.image.clone()); 613 + } 614 + } 615 + } 616 + // Silence unused-field warning on CoverArtThumbnails when out already 617 + // has content — keep the type available for future callers. 618 + let _ = resp 619 + .images 620 + .iter() 621 + .filter_map(|i| i.thumbnails.as_ref()) 622 + .count(); 623 + out 624 + } 625 + 353 626 #[cfg(test)] 354 627 mod tests { 355 628 use super::*; ··· 389 662 let resp: MbArtistResponse = serde_json::from_str(payload).unwrap(); 390 663 let names = parse_mb_linked(&resp); 391 664 assert_eq!(names, vec!["Guest Artist", "Band Member"]); 665 + } 666 + 667 + #[test] 668 + fn parses_lastfm_album_getinfo_images() { 669 + // r##"..."## because the JSON payload contains `"#text"` — a naked 670 + // `r#"..."#` would terminate at the first inner `"#`. 671 + let payload = r##"{ 672 + "album": { 673 + "image": [ 674 + {"#text": "https://example/small.png", "size": "small"}, 675 + {"#text": "https://example/large.png", "size": "large"}, 676 + {"#text": "", "size": "mega"} 677 + ] 678 + } 679 + }"##; 680 + let resp: LastfmAlbumInfoResponse = serde_json::from_str(payload).unwrap(); 681 + let urls = parse_lastfm_album_images(&resp); 682 + assert_eq!( 683 + urls, 684 + vec!["https://example/small.png", "https://example/large.png"] 685 + ); 686 + } 687 + 688 + #[test] 689 + fn parses_caa_prefers_approved_front_covers() { 690 + let payload = r#"{ 691 + "images": [ 692 + {"image": "https://caa/front-approved.jpg", "front": true, "approved": true}, 693 + {"image": "https://caa/front-pending.jpg", "front": true, "approved": false}, 694 + {"image": "https://caa/back.jpg", "front": false, "approved": true} 695 + ] 696 + }"#; 697 + let resp: CoverArtArchiveResponse = serde_json::from_str(payload).unwrap(); 698 + let urls = parse_caa_urls(&resp); 699 + assert_eq!(urls, vec!["https://caa/front-approved.jpg"]); 700 + } 701 + 702 + #[test] 703 + fn parses_caa_falls_back_when_no_approved_front() { 704 + let payload = r#"{ 705 + "images": [ 706 + {"image": "https://caa/pending.jpg", "front": true, "approved": false}, 707 + {"image": "https://caa/back.jpg", "front": false, "approved": true} 708 + ] 709 + }"#; 710 + let resp: CoverArtArchiveResponse = serde_json::from_str(payload).unwrap(); 711 + let urls = parse_caa_urls(&resp); 712 + assert_eq!(urls, vec!["https://caa/pending.jpg"]); 392 713 } 393 714 394 715 #[tokio::test]
+12
src/server/repo.rs
··· 383 383 Ok(row) 384 384 } 385 385 386 + /// Set `albums.cover_art` to `filename` (a bare filename inside covers_dir, 387 + /// not a path). Used by `POST /Items/{id}/RemoteImages/Download` after we 388 + /// persist the downloaded bytes. 389 + pub async fn set_album_cover_art(pool: &Db, album_id: &str, filename: &str) -> Result<()> { 390 + sqlx::query("UPDATE albums SET cover_art = ?1 WHERE id = ?2") 391 + .bind(filename) 392 + .bind(album_id) 393 + .execute(pool) 394 + .await?; 395 + Ok(()) 396 + } 397 + 386 398 pub async fn all_songs_paginated(pool: &Db, limit: i64, offset: i64) -> Result<Vec<Song>> { 387 399 let rows = sqlx::query_as::<_, Song>( 388 400 "SELECT id, path, title, artist, artist_id, album, album_id, genre, track_number,