termusiclib/
utils.rs

1use std::borrow::Cow;
2use std::ffi::OsStr;
3use std::iter::FusedIterator;
4use std::path::{Path, PathBuf};
5use std::process::Stdio;
6
7use anyhow::{Context, Result, anyhow};
8use pinyin::ToPinyin;
9use rand::Rng;
10use tokio::process::{Child, Command};
11use unicode_segmentation::UnicodeSegmentation;
12
13use crate::config::ServerOverlay;
14
15#[must_use]
16pub fn get_pin_yin(input: &str) -> String {
17    let mut b = String::new();
18    for (index, f) in input.to_pinyin().enumerate() {
19        match f {
20            Some(p) => {
21                b.push_str(p.plain());
22            }
23            None => {
24                if let Some(c) = input.to_uppercase().chars().nth(index) {
25                    b.push(c);
26                }
27            }
28        }
29    }
30    b
31}
32
33// TODO: decide filetype supported by backend instead of in library
34#[must_use]
35pub fn filetype_supported(path: &Path) -> bool {
36    if path.starts_with("http") {
37        return true;
38    }
39
40    let Some(ext) = path.extension().and_then(OsStr::to_str) else {
41        return false;
42    };
43
44    matches!(
45        ext,
46        "mkv"
47            | "mka"
48            | "mp3"
49            | "aiff"
50            | "aif"
51            | "aifc"
52            | "flac"
53            | "m4a"
54            | "aac"
55            | "opus"
56            | "ogg"
57            | "wav"
58            | "webm"
59    )
60}
61
62/// Check if the given path has a extension that matches well-known playlists that are supported by us.
63#[must_use]
64pub fn is_playlist(path: &Path) -> bool {
65    let Some(ext) = path.extension().and_then(OsStr::to_str) else {
66        return false;
67    };
68
69    matches!(ext, "m3u" | "m3u8" | "pls" | "asx" | "xspf")
70}
71
72/// Get the parent path of the given `path`, if there is none use the tempdir
73#[must_use]
74pub fn get_parent_folder(path: &Path) -> Cow<'_, Path> {
75    if path.is_dir() {
76        return path.into();
77    }
78    match path.parent() {
79        Some(p) => p.into(),
80        None => std::env::temp_dir().into(),
81    }
82}
83
84pub fn get_app_config_path() -> Result<PathBuf> {
85    let mut path = dirs::config_dir().ok_or_else(|| anyhow!("failed to find os config dir."))?;
86    path.push("termusic");
87
88    if !path.exists() {
89        std::fs::create_dir_all(&path)?;
90    }
91    Ok(path)
92}
93
94/// Get the termusic database path for [`new_database`](crate::new_database).
95pub fn get_app_new_database_path() -> Result<PathBuf> {
96    let mut db_path = get_app_config_path().context("failed to get app configuration path")?;
97    // for the lack of a better name, just adding a "2" compared to the old database
98    db_path.push("library2.db");
99
100    Ok(db_path)
101}
102
103/// Get the podcast directoy resolved and created
104fn get_podcast_save_path(config: &ServerOverlay) -> Result<PathBuf> {
105    let full_path = shellexpand::path::tilde(&config.settings.podcast.download_dir);
106    if !full_path.exists() {
107        std::fs::create_dir_all(&full_path)?;
108    }
109    Ok(full_path.into_owned())
110}
111
112/// Get the download directory for the provided `pod_title` and create it if not existing
113pub fn create_podcast_dir(config: &ServerOverlay, pod_title: String) -> Result<PathBuf> {
114    let mut download_path = get_podcast_save_path(config).context("get podcast directory")?;
115    download_path.push(pod_title);
116    std::fs::create_dir_all(&download_path).context("creating podcast download directory")?;
117
118    Ok(download_path)
119}
120
121/// Parse the playlist at `current_node`(from the tui tree) and return the media paths
122pub fn playlist_get_vec(playlist_path: &Path) -> Result<Vec<String>> {
123    // get the directory the playlist is in
124    let playlist_directory = absolute_path(
125        playlist_path
126            .parent()
127            .ok_or_else(|| anyhow!("cannot get directory from playlist path"))?,
128    )?;
129    let playlist_str = std::fs::read_to_string(playlist_path)?;
130    let items = crate::playlist::decode(&playlist_str)
131        .with_context(|| playlist_path.display().to_string())?;
132    let mut vec = Vec::with_capacity(items.len());
133    for mut item in items {
134        item.absoluteize(&playlist_directory);
135
136        // TODO: refactor to return better values
137        vec.push(item.to_string());
138    }
139    Ok(vec)
140}
141
142/// Some helper functions for dealing with Unicode strings.
143#[allow(clippy::module_name_repetitions)]
144pub trait StringUtils {
145    /// Creates a string slice from `start` and taking `length`, counted by grapheme clusters.
146    fn substr(&self, start: usize, length: usize) -> &str;
147    /// Counts the total number of Unicode graphemes in the String.
148    fn grapheme_len(&self) -> usize;
149}
150
151impl StringUtils for str {
152    fn substr(&self, start: usize, length: usize) -> &str {
153        // the logic below assumes "length > 0", so this is a fallback
154        if length == 0 {
155            return "";
156        }
157
158        let mut iter = self.grapheme_indices(true).skip(start);
159        // get the start idx
160        let Some((start_idx, _)) = iter.next() else {
161            return "";
162        };
163        // skip all remaining wanted length, minus the one we already have
164        match iter.nth(length - 1) {
165            Some((end_idx, _)) => {
166                // a grapheme index here is the beginning idx of the provided `grapheme`
167                // as the grapheme we got here is the next *unwanted* character, use a exclusive range
168                &self[start_idx..end_idx]
169            }
170            None => {
171                // there was no character after the skip, so just take everything since the start
172                &self[start_idx..]
173            }
174        }
175    }
176
177    fn grapheme_len(&self) -> usize {
178        self.graphemes(true).count()
179    }
180}
181
182// passthrough impl for "String", otherwise you would always have to cast it manually
183impl StringUtils for String {
184    #[inline]
185    fn substr(&self, start: usize, length: usize) -> &str {
186        (**self).substr(start, length)
187    }
188
189    #[inline]
190    fn grapheme_len(&self) -> usize {
191        self.as_str().grapheme_len()
192    }
193}
194
195/// Spawn a detached process
196/// # Panics
197/// panics when spawn server failed
198pub fn spawn_process<A: IntoIterator<Item = S> + Clone, S: AsRef<OsStr>>(
199    prog: &Path,
200    superuser: bool,
201    shout_output: bool,
202    args: A,
203) -> std::io::Result<Child> {
204    let mut cmd = if superuser {
205        let mut cmd_t = Command::new("sudo");
206        cmd_t.arg(prog);
207        cmd_t
208    } else {
209        Command::new(prog)
210    };
211    cmd.stdin(Stdio::null());
212    if shout_output {
213        cmd.stdout(Stdio::piped());
214        cmd.stderr(Stdio::piped());
215    } else {
216        cmd.stdout(Stdio::null());
217        cmd.stderr(Stdio::null());
218    }
219
220    cmd.args(args);
221    cmd.spawn()
222}
223
224/// Absolutize a given path with the current working directory.
225///
226/// This function, unlike [`std::fs::canonicalize`] does *not* hit the filesystem and so does not require the input path to exist yet.
227///
228/// Examples:
229/// `./somewhere` -> `/absolute/./somewhere`
230/// `.\somewhere` -> `C:\somewhere`
231///
232/// in the future consider replacing with [`std::path::absolute`] once stable
233pub fn absolute_path(path: &Path) -> std::io::Result<Cow<'_, Path>> {
234    if path.is_absolute() {
235        Ok(Cow::Borrowed(path))
236    } else {
237        Ok(Cow::Owned(std::env::current_dir()?.join(path)))
238    }
239}
240
241/// Absolutize a given path with the given base.
242///
243/// `base` is expected to be absoulte!
244///
245/// This function, unlike [`std::fs::canonicalize`] does *not* hit the filesystem and so does not require the input path to exist yet.
246///
247/// Examples:
248/// `./somewhere` -> `/absolute/./somewhere`
249/// `.\somewhere` -> `C:\somewhere`
250///
251/// in the future consider replacing with [`std::path::absolute`] once stable
252#[must_use]
253pub fn absolute_path_base<'a>(path: &'a Path, base: &Path) -> Cow<'a, Path> {
254    if path.is_absolute() {
255        Cow::Borrowed(path)
256    } else {
257        Cow::Owned(base.join(path))
258    }
259}
260
261/// Generate `len` random ascii character (a-z0-9)
262#[must_use]
263pub fn random_ascii(len: usize) -> String {
264    rand::rng()
265        .sample_iter(&rand::distr::Alphanumeric)
266        .take(len)
267        .map(|v| char::from(v).to_ascii_lowercase())
268        .collect()
269}
270
271/// Helper function to defer formatting to later, without having to allocate a intermediate [`String`]
272///
273/// similar to [`format_args!`], but it can be returned by `move`d values
274///
275/// Source: <https://internals.rust-lang.org/t/suggestion-for-helper-for-writing-fmt-debug-impls-with-nested-structure/19477/2>
276///
277/// Example:
278/// ```
279/// # use std::fmt::Display;
280/// # use termusiclib::utils::display_with;
281/// // instead of `fn nested() -> String`
282/// fn nested() -> impl Display {
283///   let new_string = String::from("Hello allocated string");
284///   // instead of `format!("Formatted! {}", new_string)`
285///   display_with(move |f| write!(f, "Formatted! {}", new_string))
286/// }
287///
288/// println!("No Extra allocation:\n{}", nested());
289/// ```
290pub fn display_with(
291    f: impl Fn(&mut std::fmt::Formatter<'_>) -> std::fmt::Result,
292) -> impl std::fmt::Display {
293    struct DisplayWith<F>(F);
294
295    impl<F> std::fmt::Display for DisplayWith<F>
296    where
297        F: Fn(&mut std::fmt::Formatter<'_>) -> std::fmt::Result,
298    {
299        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
300            self.0(f)
301        }
302    }
303
304    DisplayWith(f)
305}
306
307/// A extra [`str::split`] iterator that splits at any of the given `array`.
308///
309/// This is currently not possible with std rust as [`str::split`] accepts a Pattern, but no pattern is implemented for `&[&str]`
310/// and custom Patterns can currently not be implemented without nightly.
311///
312/// Note that this iterator does nothing if the value is empty.
313/// If the pattern is empty, behaves as if no pattern was found and yields the entire value once.
314#[derive(Debug, Clone)]
315pub struct SplitArrayIter<'a> {
316    val: &'a str,
317    array: &'a [&'a str],
318}
319
320impl<'a> SplitArrayIter<'a> {
321    #[must_use]
322    pub fn new(val: &'a str, array: &'a [&'a str]) -> Self {
323        Self { val, array }
324    }
325}
326
327impl<'a> Iterator for SplitArrayIter<'a> {
328    type Item = &'a str;
329
330    fn next(&mut self) -> Option<Self::Item> {
331        if self.val.is_empty() {
332            return None;
333        }
334
335        let mut found: Option<(&str, &str)> = None;
336
337        // Find the first pattern that occurs, resetting the values if there is pattern before it.
338        // It is using a "found" option because lets say input value is "a+b-c+t", and the pattern is "-" and "+" (in that order),
339        // we would want to split it to "a", "b", "c" and "t", not "a+b", "c" and "t".
340        //
341        // Or said differently, if we have "ArtistA, ArtistB feat. ArtistC" and pattern ["feat.", ","] (in that order),
342        // then we would want to split it into "ArtistA", "ArtistB" and "ArtistC", not "ArtistA, ArtistB" and "ArtistC".
343        for pat in self.array {
344            // "split_once" only returns "Some" if pattern is found
345            // the returned values do not include the pattern
346            if let Some((val, remainder)) = self.val.split_once(pat) {
347                // only assign a new "found" if there is none or if the current pattern's value is shorter
348                // meaning it is found before any other, as explained above
349                if found.is_none_or(|v| v.0.len() > val.len()) {
350                    found = Some((val, remainder));
351                }
352            }
353            // try the next pattern
354        }
355
356        let (found, remainder) = found.unwrap_or((self.val, ""));
357
358        self.val = remainder;
359
360        Some(found)
361    }
362}
363
364impl FusedIterator for SplitArrayIter<'_> {}
365
366#[cfg(test)]
367mod tests {
368    use std::fmt::{Display, Write};
369
370    use super::*;
371    use pretty_assertions::assert_eq;
372
373    #[test]
374    fn test_pin_yin() {
375        assert_eq!(get_pin_yin("陈一发儿"), "chenyifaer".to_string());
376        assert_eq!(get_pin_yin("Gala乐队"), "GALAledui".to_string());
377        assert_eq!(get_pin_yin("乐队Gala乐队"), "leduiGALAledui".to_string());
378        assert_eq!(get_pin_yin("Annett Louisan"), "ANNETT LOUISAN".to_string());
379    }
380
381    #[test]
382    fn test_substr() {
383        // 0 length fallback
384        assert_eq!("abcde".substr(0, 0), "");
385
386        assert_eq!("abcde".substr(0, 1), "a");
387        assert_eq!("abcde".substr(4, 1), "e");
388
389        // something starting beyond the current string
390        assert_eq!("abcde".substr(100, 1), "");
391        // requesting more length that is available
392        assert_eq!("abcde".substr(3, 3), "de");
393
394        assert_eq!("陈一发儿".substr(0, 1), "陈");
395        assert_eq!("陈一发儿".substr(3, 1), "儿");
396    }
397
398    #[test]
399    fn display_with_to_string() {
400        fn nested() -> impl Display {
401            let new_owned = String::from("Owned");
402
403            display_with(move |f| write!(f, "Nested! {new_owned}"))
404        }
405
406        let mut str = String::new();
407
408        let _ = write!(&mut str, "Formatted! {}", nested());
409
410        assert_eq!(str, "Formatted! Nested! Owned");
411    }
412
413    #[test]
414    fn split_array_single_pattern() {
415        let value = "something++another++test";
416        let pattern = &["++"];
417        let mut iter = SplitArrayIter::new(value, pattern);
418
419        assert_eq!(iter.next(), Some("something"));
420        assert_eq!(iter.next(), Some("another"));
421        assert_eq!(iter.next(), Some("test"));
422        assert_eq!(iter.next(), None);
423    }
424
425    #[test]
426    fn split_array_multi_pattern() {
427        let value = "something++another--test";
428        let pattern = &["++", "--"];
429        let mut iter = SplitArrayIter::new(value, pattern);
430
431        assert_eq!(iter.next(), Some("something"));
432        assert_eq!(iter.next(), Some("another"));
433        assert_eq!(iter.next(), Some("test"));
434        assert_eq!(iter.next(), None);
435    }
436
437    #[test]
438    fn split_array_multi_pattern_interspersed() {
439        let value = "something--test++another--test";
440        let pattern = &["++", "--"];
441        let mut iter = SplitArrayIter::new(value, pattern);
442
443        assert_eq!(iter.next(), Some("something"));
444        assert_eq!(iter.next(), Some("test"));
445        assert_eq!(iter.next(), Some("another"));
446        assert_eq!(iter.next(), Some("test"));
447        assert_eq!(iter.next(), None);
448    }
449
450    #[test]
451    fn split_array_multi_pattern_interspersed2() {
452        let value = "ArtistA, ArtistB feat. ArtistC";
453        // the pattern has a specific order
454        let pattern = &["feat.", ","];
455        let mut iter = SplitArrayIter::new(value, pattern).map(str::trim);
456
457        assert_eq!(iter.next(), Some("ArtistA"));
458        assert_eq!(iter.next(), Some("ArtistB"));
459        assert_eq!(iter.next(), Some("ArtistC"));
460        assert_eq!(iter.next(), None);
461
462        let value = "ArtistA, ArtistB feat. ArtistC";
463        // the pattern has a specific order
464        let pattern = &[",", "feat."];
465        let mut iter = SplitArrayIter::new(value, pattern).map(str::trim);
466
467        assert_eq!(iter.next(), Some("ArtistA"));
468        assert_eq!(iter.next(), Some("ArtistB"));
469        assert_eq!(iter.next(), Some("ArtistC"));
470        assert_eq!(iter.next(), None);
471    }
472
473    #[test]
474    fn split_array_empty_val() {
475        let mut iter = SplitArrayIter::new("", &["test"]);
476
477        assert_eq!(iter.next(), None);
478        assert_eq!(iter.next(), None);
479        assert_eq!(iter.next(), None);
480    }
481
482    #[test]
483    fn split_array_empty_pat() {
484        let mut iter = SplitArrayIter::new("hello there", &[]);
485
486        assert_eq!(iter.next(), Some("hello there"));
487        assert_eq!(iter.next(), None);
488        assert_eq!(iter.next(), None);
489    }
490}