Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions docs/features/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ track-canvas: func(artist: string, title: string, album: option<string>, duratio

The host side mirrors the metadata fanout: [`bindings::canvas`](../../src-tauri/crates/core/src/plugin/bindings.rs) binds the world (reusing `source`'s host types via `with:`), [`runtime::canvas_track_canvas`](../../src-tauri/crates/core/src/plugin/runtime.rs) instantiates + calls it, and the Tauri command [`fetch_track_canvas`](../../src-tauri/crates/app/src/commands/canvas.rs) fans out to every enabled `canvas` plugin (per-plugin lock + blocking task + 20 s timeout), returning the **first safe hit** (an SSRF guard rejects a non-https / loopback URL before it reaches the webview). It is **fail-soft**: a plugin error, panic, or timeout is logged and skipped, never surfaced — so a misbehaving Canvas provider can never break playback; the frontend simply falls back down the precedence chain. Frontend: [`useTrackCanvas`](../../src/hooks/useTrackCanvas.ts) tries the manual local Canvas first, then this command, and [`CanvasStage`](../../src/components/player/CanvasStage.tsx) tells a local path from a remote URL (same `http(s)` split as `MotionCoverOverlay`).

By default the plugin returns a remote URL the webview streams; an **opt-in local cache** (`app_setting['canvas.cache_enabled']`, default OFF) downloads the resolved mp4 into an app-wide LRU (`<app-data>/waveflow/canvas_cache/`) and serves the on-disk copy — it reuses the motion-artwork cache's `motion_cache` primitives (download + eviction + SSRF guard) with a separate dir, and its toggle + footprint + clear live in the canvas plugin's ⚙️ panel next to the motion-cache control (both rendered by a shared `LocalCacheOption`).

The core carries only the world surface + a test-only `canvas-fixture` under [`plugins/canvas-fixture/`](../../src-tauri/plugins/canvas-fixture/) (never bundled), exercised by [`tests/plugin_canvas.rs`](../../src-tauri/crates/core/tests/plugin_canvas.rs). The first consumer — a Spotify Canvas plugin — lives in its own separate, **unsigned** repo, never the core (the Spotify path is a grey-area of their Developer Terms, isolated exactly like the excluded YouTube path).

## Official plugins
Expand Down
2 changes: 1 addition & 1 deletion docs/features/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Entry points in the [`PlayerBar`](../../src/components/player/PlayerBar.tsx): cl

**Sourcing — v1 is manual mp4 only** (core-only, no plugin): the user picks a local `.mp4` via the immersive top-bar "⋯" → [`CanvasPickerModal`](../../src/components/common/CanvasPickerModal.tsx) (set / remove). The file is validated by mp4 magic bytes (`ftyp`), size-capped (64 MiB), BLAKE3-hashed and **published atomically** — staged to a unique temp then `hard_link`-ed onto the target (no-replace; a concurrent importer of the same hash loses harmlessly) — into the never-evicted per-profile `canvas/` dir ([`AppPaths::profile_canvas_dir`](../../src-tauri/crates/app/src/paths.rs)), via the shared [`media_file::store_hash_addressed_mp4`](../../src-tauri/crates/app/src/commands/media_file.rs) helper the manual motion cover (#408) also uses. The row in `track_canvas` (per-profile, `ON DELETE CASCADE`) is itself the "has a manual Canvas" signal. Commands: [`commands/canvas.rs`](../../src-tauri/crates/app/src/commands/canvas.rs) (`get_track_canvas` / `set_track_canvas_from_file` / `clear_track_canvas`).

**Sourcing — plugin (issue #473).** When no manual clip is set, [`useTrackCanvas`](../../src/hooks/useTrackCanvas.ts) falls back to enabled [`canvas`-world plugins](plugins.md#the-canvas-world-waveflowcanvasv1) via [`fetch_track_canvas`](../../src-tauri/crates/app/src/commands/canvas.rs), which returns a **remote** mp4 URL — [`CanvasStage`](../../src/components/player/CanvasStage.tsx) loads a local path through `convertFileSrc` and a remote URL directly (the `http(s)` prefix tells them apart). The plugin clip sits one rung below the manual one: **manual Canvas > plugin Canvas > motion cover > slideshow > static cover**. The fanout is **fail-soft** (a plugin error/panic/timeout is logged + skipped, never breaks playback), and the picker's "remove" affordance keys on the manual clip only (a plugin URL doesn't read as "a Canvas to remove"). The first consumer is a separate **unsigned** Spotify Canvas plugin — grey-area of Spotify's Developer Terms, isolated exactly like the excluded YouTube path, never the signed core.
**Sourcing — plugin (issue #473).** When no manual clip is set, [`useTrackCanvas`](../../src/hooks/useTrackCanvas.ts) falls back to enabled [`canvas`-world plugins](plugins.md#the-canvas-world-waveflowcanvasv1) via [`fetch_track_canvas`](../../src-tauri/crates/app/src/commands/canvas.rs), which returns a **remote** mp4 URL — [`CanvasStage`](../../src/components/player/CanvasStage.tsx) loads a local path through `convertFileSrc` and a remote URL directly (the `http(s)` prefix tells them apart). The plugin clip sits one rung below the manual one: **manual Canvas > plugin Canvas > motion cover > slideshow > static cover**. The fanout is **fail-soft** (a plugin error/panic/timeout is logged + skipped, never breaks playback), and the picker's "remove" affordance keys on the manual clip only (a plugin URL doesn't read as "a Canvas to remove"). By default the webview streams the plugin's remote mp4; an **opt-in local cache** (`app_setting['canvas.cache_enabled']`, default OFF, toggle in the canvas plugin's ⚙️ panel) downloads it into an app-wide LRU (`<app-data>/waveflow/canvas_cache/`) and serves the on-disk copy instead — the same mechanism (and shared `motion_cache` primitives) as the [motion-artwork cache](plugins.md#apple-motion-artwork-metadata-world), a separate dir. The first consumer is a separate **unsigned** Spotify Canvas plugin — grey-area of Spotify's Developer Terms, isolated exactly like the excluded YouTube path, never the signed core.

**"Show Canvas" toggle** — a [`CanvasToggleButton`](../../src/components/player/CanvasToggleButton.tsx) (Spotify's control) appears in the immersive top bar **and** the `NowPlayingPanel` header **only when the current track has a Canvas** and motion isn't reduced, so it is never a dead control. It drives the global [`useCanvasEnabled`](../../src/hooks/useCanvasEnabled.ts) preference (localStorage, **default OFF** — the cover shows first, the clip takes over on click, matching "click Show Canvas to reveal"). `prefers-reduced-motion` ([`usePrefersReducedMotion`](../../src/hooks/usePrefersReducedMotion.ts)) suppresses the clip and hides the toggle; radio (negative sentinel id) and Spotify tracks are excluded. Setting a Canvas is immersive-only in v1 (the panel only reflects + toggles).

Expand Down
120 changes: 115 additions & 5 deletions src-tauri/crates/app/src/commands/canvas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,19 @@ use serde::Serialize;
use tauri::State;
use waveflow_core::artwork::motion_cache;

use crate::error::AppResult;
use crate::error::{AppError, AppResult};
use crate::offline;
use crate::state::AppState;

/// Per-plugin call timeout for the Canvas fanout — a hung provider must not
/// stall the now-playing path. Matches the motion-artwork budget.
const CANVAS_PLUGIN_TIMEOUT: Duration = Duration::from_secs(20);

/// `app_setting` key for the opt-in local Canvas cache (issue #473). Default
/// OFF — a plugin Canvas is a remote URL the webview streams unless the user
/// opts into caching the mp4 on disk (same shape as the motion-artwork cache).
const CANVAS_CACHE_ENABLED_KEY: &str = "canvas.cache_enabled";

/// Hard cap on a user-supplied Canvas clip, mirroring
/// [`super::motion_artwork`]'s manual-cover cap: a deliberately-chosen file
/// deserves a generous ceiling, but still needs *a* limit since this
Expand Down Expand Up @@ -152,6 +157,12 @@ pub async fn fetch_track_canvas(
return Ok(None);
}

// Opt-in local cache: when on, the resolved remote mp4 is downloaded into an
// app-wide LRU and the returned "url" is the on-disk path (offline replay,
// no re-stream). Read once up front; default OFF streams the remote url.
let cache_locally = canvas_cache_enabled(&state).await;
let cache_dir = state.paths.canvas_cache_dir.clone();

Comment thread
coderabbitai[bot] marked this conversation as resolved.
let plugin_ids =
super::plugins::enabled_plugin_ids_for_world(&state, "waveflow:canvas").await?;
if plugin_ids.is_empty() {
Expand Down Expand Up @@ -211,10 +222,37 @@ pub async fn fetch_track_canvas(
tracing::warn!(plugin_id, "canvas plugin returned an unsafe url; skipping");
continue;
}
return Ok(Some(PluginCanvas {
url: canvas.url,
plugin_id,
}));
// With the local cache on, download the mp4 and point the stage
// at the on-disk copy.
let url = if cache_locally {
match motion_cache::cache_mp4(
&cache_dir,
&canvas.url,
motion_cache::DEFAULT_MAX_CACHE_BYTES,
)
.await
{
Ok(path) => path.to_string_lossy().into_owned(),
// Security rejection (unsafe initial url or unsafe
// redirect hop): must NOT degrade to streaming the raw
// url — that would hand the webview <video> the very
// target the cache path just refused to follow. Skip
// this plugin and keep looking.
Err(motion_cache::CacheError::UnsafeUrl) => {
tracing::warn!(plugin_id, "canvas cache refused an unsafe url/redirect; skipping");
continue;
}
// Ordinary failure (network / HTTP / disk): degrade to
// streaming the remote url, same posture as cache-off.
Err(e) => {
tracing::warn!(plugin_id, %e, "canvas cache download failed; serving remote url");
canvas.url
}
}
} else {
canvas.url
};
return Ok(Some(PluginCanvas { url, plugin_id }));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Ok(Ok(Ok(None))) => { /* this plugin has no Canvas for the track */ }
Ok(Ok(Err(e))) => tracing::warn!(plugin_id, %e, "canvas plugin failed; skipping"),
Expand All @@ -225,3 +263,75 @@ pub async fn fetch_track_canvas(

Ok(None)
}

// ----- opt-in local Canvas cache (issue #473) -----------------------------
//
// Mirrors the motion-artwork cache: an app-wide, hash-addressed LRU of
// downloaded Canvas mp4s, reusing the same `motion_cache` primitives (download
// + eviction + SSRF guard) with a separate dir so the two caches size/clear
// independently. Default OFF — a plugin Canvas streams from the CDN unless the
// user opts in.

async fn canvas_cache_enabled(state: &AppState) -> bool {
sqlx::query_scalar::<_, String>("SELECT value FROM app_setting WHERE key = ?")
.bind(CANVAS_CACHE_ENABLED_KEY)
.fetch_optional(&state.app_db)
.await
.ok()
.flatten()
.map(|v| v == "true" || v == "1")
.unwrap_or(false)
}

/// The Canvas-cache toggle state + current on-disk footprint, for the plugin
/// options panel. Mirrors `MotionCacheInfo`.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CanvasCacheInfo {
pub enabled: bool,
pub size_bytes: u64,
pub file_count: u64,
}

/// Read the toggle + cache footprint for the settings UI.
#[tauri::command]
pub async fn get_canvas_cache_info(state: State<'_, AppState>) -> AppResult<CanvasCacheInfo> {
let enabled = canvas_cache_enabled(&state).await;
let dir = state.paths.canvas_cache_dir.clone();
let (size_bytes, file_count) = tokio::task::spawn_blocking(move || motion_cache::stats(&dir))
.await
.map_err(|e| AppError::Other(format!("spawn_blocking: {e}")))?;
Ok(CanvasCacheInfo {
enabled,
size_bytes,
file_count,
})
}

/// Toggle the opt-in local Canvas cache. Turning it OFF does not purge the
/// existing files — that's the explicit "Clear cache" action below.
#[tauri::command]
pub async fn set_canvas_cache_enabled(state: State<'_, AppState>, enabled: bool) -> AppResult<()> {
sqlx::query(
"INSERT INTO app_setting (key, value, value_type, updated_at)
VALUES (?, ?, 'bool', ?)
ON CONFLICT(key) DO UPDATE
SET value = excluded.value, updated_at = excluded.updated_at",
)
.bind(CANVAS_CACHE_ENABLED_KEY)
.bind(if enabled { "true" } else { "false" })
.bind(chrono::Utc::now().timestamp_millis())
.execute(&state.app_db)
.await?;
Ok(())
}

/// Delete every cached Canvas mp4 (and any leftover `.part` temporaries).
#[tauri::command]
pub async fn clear_canvas_cache(state: State<'_, AppState>) -> AppResult<()> {
let dir = state.paths.canvas_cache_dir.clone();
tokio::task::spawn_blocking(move || motion_cache::clear(&dir))
.await
.map_err(|e| AppError::Other(format!("spawn_blocking: {e}")))?;
Ok(())
}
12 changes: 10 additions & 2 deletions src-tauri/crates/app/src/commands/motion_artwork.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,7 @@ pub async fn fetch_album_motion_artwork(
continue;
}
// When the local cache is on, download the mp4 and point the
// overlay at the on-disk copy; fall back to the remote URL if
// the download fails so the feature degrades gracefully.
// overlay at the on-disk copy.
let square_url = if cache_locally {
match motion_cache::cache_mp4(
&cache_dir,
Expand All @@ -172,6 +171,15 @@ pub async fn fetch_album_motion_artwork(
.await
{
Ok(path) => path.to_string_lossy().into_owned(),
// Security rejection (unsafe initial url or unsafe
// redirect hop): must NOT degrade to streaming the
// raw url — that would hand the webview <video> the
// target the cache path just refused. Skip.
Err(motion_cache::CacheError::UnsafeUrl) => {
tracing::warn!(plugin_id, "motion cache refused an unsafe url/redirect; skipping");
continue;
}
// Ordinary failure: degrade to the remote URL.
Err(e) => {
tracing::warn!(%e, "motion cache download failed; serving remote url");
remote_square
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/crates/app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,9 @@ pub fn run() {
commands::canvas::set_track_canvas_from_file,
commands::canvas::clear_track_canvas,
commands::canvas::fetch_track_canvas,
commands::canvas::get_canvas_cache_info,
commands::canvas::set_canvas_cache_enabled,
commands::canvas::clear_canvas_cache,
commands::integration::get_lastfm_api_key,
commands::integration::set_lastfm_api_key,
commands::integration::get_bio_source,
Expand Down
6 changes: 6 additions & 0 deletions src-tauri/crates/app/src/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ pub struct AppPaths {
/// App-wide, opt-in LRU cache of downloaded animated-album-artwork
/// (`.mp4`) files — shared across profiles like `metadata_artwork`.
pub motion_cache_dir: PathBuf,
/// App-wide, opt-in LRU cache of downloaded per-track Canvas (`.mp4`)
/// files (issue #473) — same shape as `motion_cache_dir`, separate dir
/// so the two caches size/evict/clear independently.
pub canvas_cache_dir: PathBuf,
pub profiles_dir: PathBuf,
pub bundled_plugins_dir: Option<PathBuf>,
}
Expand Down Expand Up @@ -88,6 +92,7 @@ impl AppPaths {
avatars_dir: root.join("avatars"),
metadata_artwork_dir: root.join("metadata_artwork"),
motion_cache_dir: root.join("motion_cache"),
canvas_cache_dir: root.join("canvas_cache"),
profiles_dir: root.join("profiles"),
bundled_plugins_dir,
root,
Expand All @@ -106,6 +111,7 @@ impl AppPaths {
std::fs::create_dir_all(&self.avatars_dir)?;
std::fs::create_dir_all(&self.metadata_artwork_dir)?;
std::fs::create_dir_all(&self.motion_cache_dir)?;
std::fs::create_dir_all(&self.canvas_cache_dir)?;
std::fs::create_dir_all(&self.profiles_dir)?;
let plugin_paths = self.plugin_paths();
std::fs::create_dir_all(&plugin_paths.plugins_root)?;
Expand Down
4 changes: 3 additions & 1 deletion src-tauri/crates/app/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@
"$APPDATA/waveflow/metadata_artwork/**",
"$APPLOCALDATA/waveflow/metadata_artwork/**",
"$APPDATA/waveflow/motion_cache/**",
"$APPLOCALDATA/waveflow/motion_cache/**"
"$APPLOCALDATA/waveflow/motion_cache/**",
"$APPDATA/waveflow/canvas_cache/**",
"$APPLOCALDATA/waveflow/canvas_cache/**"
]
}
}
Expand Down
Loading