Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src-tauri/crates/app/src/commands/browse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1262,7 +1262,17 @@ struct ArtistAlbumRawRow {
artwork_format: Option<String>,
}

/// Return full artist detail: header, discography, and track count.
/// Loads an artist's metadata, artwork, available-track counts, and albums.
///
/// # Examples
///
/// ```no_run
/// let detail = get_artist_detail(state, artist_id).await?;
/// assert_eq!(detail.id, artist_id);
/// # Ok::<(), crate::error::AppError>(())
/// ```
///
/// Returns an error when the artist does not exist or the profile data cannot be loaded.
#[tauri::command]
pub async fn get_artist_detail(
state: tauri::State<'_, AppState>,
Expand Down
22 changes: 22 additions & 0 deletions src-tauri/crates/app/src/commands/deezer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,16 @@ pub struct DeezerArtistEnrichment {
}

impl DeezerArtistEnrichment {
/// Creates an artist enrichment value with no Deezer metadata or artwork.
///
/// # Examples
///
/// ```
/// let enrichment = DeezerArtistEnrichment::empty();
/// assert!(enrichment.deezer_id.is_none());
/// assert!(enrichment.picture_url.is_none());
/// assert!(enrichment.background_path.is_none());
/// ```
fn empty() -> Self {
Self {
deezer_id: None,
Expand Down Expand Up @@ -340,6 +350,18 @@ async fn enrich_artist_deezer_with_pool(
Ok(enrichment)
}

/// Enriches a local artist with cached or remotely fetched Deezer, biography, and background artwork metadata.
///
/// Uses the configured biography provider and language, respects offline mode, and persists newly
/// resolved metadata for subsequent requests. Returns an empty enrichment when the artist or a
/// remote match is unavailable.
///
/// # Examples
///
/// ```no_run
/// let enrichment = enrich_artist_deezer_inner(state, pool, artist_id).await?;
/// # Ok::<(), AppError>(())
/// ```
async fn enrich_artist_deezer_inner(
state: tauri::State<'_, AppState>,
pool: sqlx::SqlitePool,
Expand Down
100 changes: 80 additions & 20 deletions src-tauri/crates/core/src/artwork/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,35 @@ pub fn existing_path(dir: &Path, hash: &str) -> Option<String> {
}
}

/// Resolve an artist/album picture path preferring a local profile-artwork
/// sidecar (`<local_dir>/<local_hash>.<local_format>`, e.g. `artist.jpg`
/// imported into the profile) over the shared Deezer metadata cache
/// ([`existing_path`]). Each candidate is returned only when the file
/// actually exists on disk, so a stale DB reference to a wiped file falls
/// through to the next source instead of yielding a broken path (#350).
///
/// The local sidecar carries its own `format` column (jpg/png/webp) so we
/// take it explicitly; the metadata cache is always `.jpg`, so `cache_hash`
/// goes through [`existing_path`] which knows the extension.
/// Resolves an artwork path, preferring an existing local sidecar over the shared cache.
///
/// Local artwork uses the supplied hash and format to form `<local_dir>/<hash>.<format>`.
/// If that file is unavailable, the function checks the shared cache. Missing or stale
/// references yield `None`.
///
/// # Examples
///
/// ```
/// use std::path::Path;
///
/// let path = resolve_local_or_cached_path(
/// Path::new("/tmp/profile"),
/// None,
/// None,
/// Path::new("/tmp/cache"),
/// None,
/// );
///
/// assert_eq!(path, None);
/// ```
///
/// # Parameters
///
/// * `local_format` - The file extension of the local sidecar, such as `jpg`, `png`, or `webp`.
///
/// # Returns
///
/// The path to the first existing artwork file, or `None` when no referenced file exists.
pub fn resolve_local_or_cached_path(
local_dir: &Path,
local_hash: Option<&str>,
Expand All @@ -64,26 +83,67 @@ pub fn resolve_local_or_cached_path(
cache_hash.and_then(|h| existing_path(cache_dir, h))
}

/// Download `url`, blake3-hash the bytes and write the file to
/// `<dir>/<hash>.jpg` if missing, then queue the `_1x` / `_2x` thumbnail
/// job. Returns the hex hash on success.
/// Downloads an image, caches it under its BLAKE3 hash, and queues thumbnail generation.
///
/// The cached file is written as `<dir>/<hash>.jpg` when it does not already exist.
/// Returns the hexadecimal hash when the download and caching succeed; failures are
/// logged and produce `None`.
///
/// All failures (network, http != 2xx, oversize body, write error) are logged
/// at WARN level and surfaced as `None`. Enrichment is best-effort: the
/// caller should fall back to the remote URL.
/// # Examples
///
/// ```no_run
/// # async fn example() {
/// let hash = download_and_cache("https://example.com/artwork.jpg", std::path::Path::new("/tmp/artwork")).await;
/// assert!(hash.is_some());
/// # }
/// ```
///
/// # Returns
///
/// The hexadecimal BLAKE3 hash of the cached image, or `None` if downloading,
/// reading, validation, or writing fails.
pub async fn download_and_cache(url: &str, dir: &Path) -> Option<String>
pub async fn download_and_cache(url: &str, dir: &Path) -> Option<String> {
download_and_cache_inner(url, dir, true).await
}

/// Same as [`download_and_cache`] but **skips thumbnail generation**.
/// Downloads an image and caches it without generating thumbnails.
///
/// # Returns
///
/// The BLAKE3 hash of the cached image, or `None` if the download or caching fails.
///
/// For images only ever consumed at full size — the artist hero fanart
/// (#482) is painted full-bleed, so a downscaled tier would only soften
/// it — the `_1x` / `_2x` job is pure CPU + disk for files nothing reads.
/// # Examples
///
/// ```no_run
/// # async fn example() {
/// let hash = download_and_cache_full_res(
/// "https://example.com/artist-fanart.jpg",
/// std::path::Path::new("/tmp/artwork"),
/// ).await;
/// # }
/// ```
pub async fn download_and_cache_full_res(url: &str, dir: &Path) -> Option<String> {
download_and_cache_inner(url, dir, false).await
}

/// Downloads artwork, caches it under its content hash, and optionally queues thumbnail generation.
///
/// Returns `None` when the download fails, the response is unsuccessful, the response body is empty or too large, or the cached file cannot be written.
///
/// # Examples
///
/// ```no_run
/// # async fn example() {
/// let hash = download_and_cache_inner(
/// "https://example.com/artwork.jpg",
/// std::path::Path::new("/tmp/artwork"),
/// true,
/// )
/// .await;
/// assert!(hash.is_some());
/// # }
/// ```
async fn download_and_cache_inner(
url: &str,
dir: &Path,
Expand Down
72 changes: 60 additions & 12 deletions src-tauri/crates/core/src/metadata/theaudiodb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,20 @@ struct ArtistPayload {
}

impl ArtistPayload {
/// Pick the biography for `lang`, falling back to English when the
/// requested language is missing or blank. `lang` is a short code
/// (`"fr"`, `"de"`, … `"zh"`); anything unmapped resolves to English.
/// Selects the biography for a supported language and falls back to English when the selected biography is unavailable or blank.
///
/// Supported language codes are `fr`, `de`, `es`, `it`, `pt`, `nl`, `ru`, `ja`, and `zh`; other codes select English.
///
/// # Examples
///
/// ```
/// let payload = ArtistPayload {
/// bio_en: Some("English biography".into()),
/// ..Default::default()
/// };
///
/// assert_eq!(payload.bio_for_lang("fr"), Some("English biography".into()));
/// ```
fn bio_for_lang(&self, lang: &str) -> Option<String> {
let primary = match lang {
"fr" => &self.bio_fr,
Expand All @@ -108,9 +119,24 @@ impl ArtistPayload {
non_blank(primary).or_else(|| non_blank(&self.bio_en))
}

/// First non-blank wide image, widest-and-cleanest first: real
/// fanart, then its community alternates, then the wide thumb, and
/// the logo banner only as a last resort.
/// Selects the first available wide artist image in priority order.
///
/// Blank image URLs are skipped. Fanart fields take precedence over the wide thumbnail,
/// which takes precedence over the banner.
///
/// # Examples
///
/// ```
/// let payload = ArtistPayload {
/// fanart: Some("https://example.com/fanart.jpg".into()),
/// ..Default::default()
/// };
///
/// assert_eq!(
/// payload.fanart_url(),
/// Some("https://example.com/fanart.jpg".into())
/// );
/// ```
fn fanart_url(&self) -> Option<String> {
non_blank(&self.fanart)
.or_else(|| non_blank(&self.fanart2))
Expand Down Expand Up @@ -146,6 +172,13 @@ impl Default for TheAudioDbClient {
}

impl TheAudioDbClient {
/// Creates a client for communicating with TheAudioDB.
///
/// # Examples
///
/// ```
/// let _client = TheAudioDbClient::new();
/// ```
pub fn new() -> Self {
let http = reqwest::Client::builder()
.user_agent(USER_AGENT)
Expand All @@ -155,12 +188,27 @@ impl TheAudioDbClient {
Self { http }
}

/// Look up an artist by name, returning its bio in `lang` (English
/// fallback) and its wide fanart URL. Returns `Ok(None)` only when
/// nothing matches the name — a match with neither bio nor fanart
/// still comes back as `Some` with both fields empty, so the caller
/// can cache the "looked it up, nothing there" outcome instead of
/// re-querying a rate-limited API on every visit.
/// Looks up an artist by name and returns localized biography and fanart information.
///
/// The biography uses the requested language with an English fallback. A matching
/// artist is returned even when no biography or fanart is available; `None` means
/// that no artist matched the requested name.
///
/// # Examples
///
/// ```no_run
/// async fn lookup_artist(
/// client: &TheAudioDbClient,
/// ) -> reqwest::Result<()> {
/// let artist = client.artist_info("Daft Punk", "en").await?;
///
/// if let Some(artist) = artist {
/// println!("{}", artist.name);
/// }
///
/// Ok(())
/// }
/// ```
pub async fn artist_info(
&self,
name: &str,
Expand Down
16 changes: 11 additions & 5 deletions src/components/common/ArtistHeroBackdrop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,10 @@
* hero dissolves into whatever the current theme/skin paints behind it.
*/
/**
* Escape a URL for interpolation inside a double-quoted CSS `url("…")`.
* Unlike the local artwork paths other backdrops paint, a hero source can
* be a **remote URL straight out of TheAudioDB** — third-party data that
* must not be able to close the string and inject CSS. Inside a quoted
* string only the backslash, the closing quote and raw newlines matter.
* Escapes a source for use inside a double-quoted CSS `url(...)` value.
*
* @param src - The source to escape
* @returns The source with backslashes and double quotes escaped and raw line breaks removed
*/
function cssUrl(src: string): string {
return src.replace(/[\\"]/g, "\\$&").replace(/[\n\r\f]/g, "");
Expand All @@ -38,6 +37,13 @@ interface ArtistHeroBackdropProps {
isFanart: boolean;
}

/**
* Renders a decorative artist backdrop with styling based on the image type.
*
* @param src - The image source used for the backdrop
* @param isFanart - Whether the source is fanart rather than a square artist photo
* @returns The backdrop element, or `null` when no source is provided
*/
export function ArtistHeroBackdrop({ src, isFanart }: ArtistHeroBackdropProps) {
if (!src) return null;

Expand Down
7 changes: 7 additions & 0 deletions src/components/views/ArtistDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ interface ArtistDetailViewProps {
onNavigateToArtist: (artistId: number) => void;
}

/**
* Renders an artist detail page with artwork, metadata, related artists, albums, and tracks.
*
* @param artistId - The library artist identifier to display
* @param onNavigateToAlbum - Handles navigation to an album
* @param onNavigateToArtist - Handles navigation to a library artist
*/
export function ArtistDetailView({
artistId,
onNavigateToAlbum,
Expand Down
6 changes: 6 additions & 0 deletions src/components/views/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,12 @@ function LanguageDropdown({ currentCode, onSelect }: LanguageDropdownProps) {
);
}

/**
* Renders the categorized application settings interface.
*
* @param onNavigate - Callback used to navigate to another application view.
* @returns The settings view.
*/
export function SettingsView({ onNavigate }: SettingsViewProps) {
const { t, i18n } = useTranslation();
const { theme, setThemeId } = useTheme();
Expand Down
6 changes: 3 additions & 3 deletions src/components/views/settings/ArtistHeroCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { PanelTop } from "lucide-react";
import { useArtistHero } from "../../../hooks/useArtistHero";

/**
* Settings → Appearance row toggling the full-bleed hero backdrop behind
* the artist detail header (issue #482). Default ON — turning it off
* restores the flat header with just the circular photo.
* Renders an appearance setting for enabling or disabling the artist detail hero backdrop.
*
* @returns A settings row containing a controlled checkbox for the hero backdrop.
*/
export function ArtistHeroCard() {
const { t } = useTranslation();
Expand Down
20 changes: 14 additions & 6 deletions src/hooks/useArtistHero.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export const ARTIST_HERO_EVENT = "waveflow:artist-hero";
* toggle is there for users who prefer the flat header. */
const DEFAULT_ENABLED = true;

/**
* Parses a stored preference value into an enabled state.
*
* @param raw - The stored preference value
* @returns `true` for `"true"` or `"1"`, the default enabled state for a missing value, and `false` for other values
*/
function parseEnabled(raw: string | null): boolean {
if (raw == null) return DEFAULT_ENABLED;
return raw === "true" || raw === "1";
Expand All @@ -32,12 +38,14 @@ export interface ArtistHero {
}

/**
* Per-profile preference: paint a full-bleed hero backdrop behind the
* artist detail header (issue #482) — the wide TheAudioDB fanart when the
* artist has one, a blurred version of the square photo otherwise. Default
* ON. The write machinery mirrors [`useCoverSlideshow`](./useCoverSlideshow.ts)
* — serialized writes, profile-switch guards, and rollback to the last
* backend-confirmed value.
* Manages the per-profile artist hero backdrop preference.
*
* The preference is enabled by default and synchronized with the active
* profile. Updates are applied optimistically and rolled back to the last
* confirmed value if the latest write fails.
*
* @returns The current enabled state, whether the active profile's preference
* has been resolved, and a function for updating the preference.
*/
export function useArtistHero(): ArtistHero {
const { activeProfile } = useProfile();
Expand Down
6 changes: 6 additions & 0 deletions src/lib/tauri/detail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ export interface DeezerArtistEnrichment {
background_path: string | null;
}

/**
* Enriches an album with metadata from Deezer.
*
* @param albumId - The identifier of the album to enrich
* @returns Deezer metadata for the album
*/
export function enrichAlbumDeezer(
albumId: number,
): Promise<DeezerAlbumEnrichment> {
Expand Down
Loading