Skip to main content

utils/
lib.rs

1//! Shared utility crate for Kopuz: color helpers, image processing (artwork,
2//! thumbnails, subsonic images), lyrics fetching, and terminal logging.
3
4pub mod color;
5pub mod db_cache;
6pub mod hls_source;
7pub mod jellyfin_image;
8pub mod logs;
9pub mod lyrics;
10pub mod musicbrainz;
11pub mod range_source;
12pub mod stream_buffer;
13pub mod subsonic_image;
14pub mod themes;
15use std::path::Path;
16use std::sync::Arc;
17
18pub type CoverUrl = Arc<str>;
19
20pub fn cover_url_from_string(url: String) -> CoverUrl {
21    Arc::from(url)
22}
23
24pub fn map_cover_url(url: Option<String>) -> Option<CoverUrl> {
25    url.map(cover_url_from_string)
26}
27
28/// Cross-platform async sleep backed by tokio.
29pub async fn sleep(duration: std::time::Duration) {
30    tokio::time::sleep(duration).await;
31}
32
33/// Run a future on tokio's worker pool instead of the calling thread.
34///
35/// Dioxus polls its tasks (`use_resource`, `spawn`) on the UI thread, so any
36/// CPU spent inside them — sqlx row decoding, response JSON parsing — stalls
37/// rendering for that long. Wrapping the future here moves the work to a
38/// worker thread; the UI-side task only awaits the join handle. The `Send`
39/// bound is the guardrail: a future that touches a `Signal` won't compile.
40///
41/// Dropping the returned future aborts the spawned task, so cancellation
42/// passes through: when dioxus drops a superseded `use_resource` rerun, the
43/// offloaded query stops instead of running to completion in the background —
44/// the same semantics the un-offloaded future had.
45///
46/// Panics inside the future propagate to the caller unchanged.
47pub async fn offload<F>(fut: F) -> F::Output
48where
49    F: std::future::Future + Send + 'static,
50    F::Output: Send + 'static,
51{
52    struct AbortOnDrop(tokio::task::AbortHandle);
53    impl Drop for AbortOnDrop {
54        fn drop(&mut self) {
55            self.0.abort();
56        }
57    }
58
59    let handle = tokio::spawn(fut);
60    // Aborting an already-finished task is a no-op, so the guard can simply
61    // live for the whole function — it only bites on mid-await drop.
62    let _guard = AbortOnDrop(handle.abort_handle());
63    match handle.await {
64        Ok(out) => out,
65        Err(err) => match err.try_into_panic() {
66            Ok(panic) => std::panic::resume_unwind(panic),
67            // Unreachable via our own abort (the awaiter was dropped with the
68            // guard); a cancellation seen here means runtime shutdown, where
69            // the app is exiting anyway.
70            Err(err) => panic!("offloaded task cancelled: {err}"),
71        },
72    }
73}
74
75fn format_artwork_url_impl(path: Option<&impl AsRef<Path>>, size: Option<u32>) -> Option<CoverUrl> {
76    let p = path?;
77    let p = p.as_ref();
78    let p_str = p.to_string_lossy();
79
80    let abs_path = if let Some(stripped) = p_str.strip_prefix("./") {
81        std::env::current_dir().unwrap_or_default().join(stripped)
82    } else {
83        p.to_path_buf()
84    };
85
86    let abs_str = abs_path.to_string_lossy();
87    let abs_str = if abs_str.starts_with('~') {
88        if let Ok(home) = std::env::var("HOME") {
89            std::borrow::Cow::Owned(abs_str.replacen('~', &home, 1))
90        } else {
91            abs_str
92        }
93    } else {
94        abs_str
95    };
96
97    // Android WebView is unreliable with custom URL schemes (artwork://) and the
98    // http localhost shim, so inline the cover as a base64 data URL instead.
99    #[cfg(target_os = "android")]
100    {
101        use base64::{Engine as _, engine::general_purpose};
102        return match std::fs::read(&*abs_str) {
103            Ok(bytes) => {
104                let mime = if abs_str.ends_with(".png") {
105                    "image/png"
106                } else if abs_str.ends_with(".gif") {
107                    "image/gif"
108                } else if abs_str.ends_with(".webp") {
109                    "image/webp"
110                } else {
111                    "image/jpeg"
112                };
113                let b64 = general_purpose::STANDARD.encode(&bytes);
114                Some(cover_url_from_string(format!("data:{mime};base64,{b64}")))
115            }
116            Err(_) => None,
117        };
118    }
119
120    const QUERY_VAL: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS
121        .add(b' ')
122        .add(b'"')
123        .add(b'#')
124        .add(b'%')
125        .add(b'&')
126        .add(b'+')
127        .add(b'=')
128        .add(b'?')
129        .add(b'<')
130        .add(b'>')
131        .add(b'`')
132        .add(b'\\')
133        .add(b':');
134
135    if cfg!(target_os = "windows") {
136        let mut url = format!(
137            "http://artwork.dioxus.localhost/local?p={}",
138            percent_encoding::utf8_percent_encode(&abs_str, QUERY_VAL)
139        );
140        if let Some(size) = size {
141            url.push_str(&format!("&s={size}"));
142        }
143        Some(cover_url_from_string(url))
144    } else {
145        let mut url = format!(
146            "artwork://local?p={}",
147            percent_encoding::utf8_percent_encode(&abs_str, QUERY_VAL)
148        );
149        if let Some(size) = size {
150            url.push_str(&format!("&s={size}"));
151        }
152        Some(cover_url_from_string(url))
153    }
154}
155
156pub fn format_artwork_url(path: Option<&impl AsRef<Path>>) -> Option<CoverUrl> {
157    format_artwork_url_impl(path, None)
158}
159
160pub fn format_artwork_thumb_url(path: Option<&impl AsRef<Path>>, size: u32) -> Option<CoverUrl> {
161    format_artwork_url_impl(path, Some(size))
162}
163
164pub fn default_cover_url() -> CoverUrl {
165    cover_url_from_string(
166        "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'%3E%3Crect width='400' height='400' fill='%231e1b2e'/%3E%3Ccircle cx='200' cy='180' r='70' fill='none' stroke='%233d3466' stroke-width='6'/%3E%3Cpath d='M155 280 Q200 240 245 280' fill='none' stroke='%233d3466' stroke-width='6' stroke-linecap='round'/%3E%3C/svg%3E".to_string()
167    )
168}
169
170#[cfg(test)]
171mod offload_tests {
172    use std::sync::Arc;
173    use std::sync::atomic::{AtomicBool, Ordering};
174
175    /// Dropping the `offload` future must abort the spawned task — a
176    /// superseded `use_resource` rerun may not leave its query running.
177    #[tokio::test]
178    async fn dropping_offload_aborts_the_task() {
179        struct SetOnDrop(Arc<AtomicBool>);
180        impl Drop for SetOnDrop {
181            fn drop(&mut self) {
182                self.0.store(true, Ordering::SeqCst);
183            }
184        }
185        let dropped = Arc::new(AtomicBool::new(false));
186        let guard = SetOnDrop(dropped.clone());
187        let fut = super::offload(async move {
188            let _guard = guard;
189            tokio::time::sleep(std::time::Duration::from_secs(300)).await;
190        });
191        // Poll the offload future long enough to spawn, then drop it (select
192        // drops the loser when the timer wins).
193        tokio::select! {
194            _ = fut => panic!("offloaded sleep cannot have completed"),
195            _ = tokio::time::sleep(std::time::Duration::from_millis(50)) => {}
196        }
197        for _ in 0..100 {
198            if dropped.load(Ordering::SeqCst) {
199                return;
200            }
201            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
202        }
203        panic!("offloaded task kept running after its caller was dropped");
204    }
205}