termusiclib/
utils.rs

1use anyhow::{anyhow, Context, Result};
2use pinyin::ToPinyin;
3use rand::Rng;
4use std::borrow::Cow;
5use std::path::{Path, PathBuf};
6use std::process::Stdio;
7use std::{
8    ffi::OsStr,
9    process::{Child, Command},
10};
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(current_node: &str) -> bool {
36    let p = Path::new(current_node);
37
38    if p.starts_with("http") {
39        return true;
40    }
41
42    match p.extension() {
43        Some(ext) if ext == "mkv" || ext == "mka" => true,
44        Some(ext) if ext == "mp3" => true,
45        Some(ext) if ext == "aiff" => true,
46        Some(ext) if ext == "flac" => true,
47        Some(ext) if ext == "m4a" => true,
48        Some(ext) if ext == "aac" => true,
49        Some(ext) if ext == "opus" => true,
50        Some(ext) if ext == "ogg" => true,
51        Some(ext) if ext == "wav" => true,
52        Some(ext) if ext == "webm" => true,
53        Some(_) | None => false,
54    }
55}
56
57#[must_use]
58pub fn is_playlist(current_node: &str) -> bool {
59    let p = Path::new(current_node);
60
61    match p.extension() {
62        Some(ext) if ext == "m3u" => true,
63        Some(ext) if ext == "m3u8" => true,
64        Some(ext) if ext == "pls" => true,
65        Some(ext) if ext == "asx" => true,
66        Some(ext) if ext == "xspf" => true,
67        Some(_) | None => false,
68    }
69}
70
71/// Get the parent path of the given `path`, if there is none use the tempdir
72#[must_use]
73pub fn get_parent_folder(path: &Path) -> Cow<'_, Path> {
74    if path.is_dir() {
75        return path.into();
76    }
77    match path.parent() {
78        Some(p) => p.into(),
79        None => std::env::temp_dir().into(),
80    }
81}
82
83pub fn get_app_config_path() -> Result<PathBuf> {
84    let mut path = dirs::config_dir().ok_or_else(|| anyhow!("failed to find os config dir."))?;
85    path.push("termusic");
86
87    if !path.exists() {
88        std::fs::create_dir_all(&path)?;
89    }
90    Ok(path)
91}
92
93/// Get the podcast directoy resolved and created
94fn get_podcast_save_path(config: &ServerOverlay) -> Result<PathBuf> {
95    let full_path = shellexpand::path::tilde(&config.settings.podcast.download_dir);
96    if !full_path.exists() {
97        std::fs::create_dir_all(&full_path)?;
98    }
99    Ok(full_path.into_owned())
100}
101
102/// Get the download directory for the provided `pod_title` and create it if not existing
103pub fn create_podcast_dir(config: &ServerOverlay, pod_title: String) -> Result<PathBuf> {
104    let mut download_path = get_podcast_save_path(config).context("get podcast directory")?;
105    download_path.push(pod_title);
106    std::fs::create_dir_all(&download_path).context("creating podcast download directory")?;
107
108    Ok(download_path)
109}
110
111/// Parse the playlist at `current_node`(from the tui tree) and return the media paths
112pub fn playlist_get_vec(current_node: &str) -> Result<Vec<String>> {
113    let playlist_path = Path::new(current_node);
114    // get the directory the playlist is in
115    let playlist_directory = absolute_path(
116        playlist_path
117            .parent()
118            .ok_or_else(|| anyhow!("cannot get directory from playlist path"))?,
119    )?;
120    let playlist_str = std::fs::read_to_string(playlist_path)?;
121    let items = crate::playlist::decode(&playlist_str)
122        .with_context(|| playlist_path.display().to_string())?;
123    let mut vec = Vec::with_capacity(items.len());
124    for mut item in items {
125        item.absoluteize(&playlist_directory);
126
127        // TODO: refactor to return better values
128        vec.push(item.to_string());
129    }
130    Ok(vec)
131}
132
133/// Some helper functions for dealing with Unicode strings.
134#[allow(clippy::module_name_repetitions)]
135pub trait StringUtils {
136    /// Creates a string slice from `start` and taking `length`, counted by grapheme clusters.
137    fn substr(&self, start: usize, length: usize) -> &str;
138    /// Counts the total number of Unicode graphemes in the String.
139    fn grapheme_len(&self) -> usize;
140}
141
142impl StringUtils for str {
143    fn substr(&self, start: usize, length: usize) -> &str {
144        // the logic below assumes "length > 0", so this is a fallback
145        if length == 0 {
146            return "";
147        }
148
149        let mut iter = self.grapheme_indices(true).skip(start);
150        // get the start idx
151        let Some((start_idx, _)) = iter.next() else {
152            return "";
153        };
154        // skip all remaining wanted length, minus the one we already have
155        match iter.nth(length - 1) {
156            Some((end_idx, _)) => {
157                // a grapheme index here is the beginning idx of the provided `grapheme`
158                // as the grapheme we got here is the next *unwanted* character, use a exclusive range
159                &self[start_idx..end_idx]
160            }
161            None => {
162                // there was no character after the skip, so just take everything since the start
163                &self[start_idx..]
164            }
165        }
166    }
167
168    fn grapheme_len(&self) -> usize {
169        self.graphemes(true).count()
170    }
171}
172
173// passthrough impl for "String", otherwise you would always have to cast it manually
174impl StringUtils for String {
175    #[inline]
176    fn substr(&self, start: usize, length: usize) -> &str {
177        (**self).substr(start, length)
178    }
179
180    #[inline]
181    fn grapheme_len(&self) -> usize {
182        self.as_str().grapheme_len()
183    }
184}
185
186/// Spawn a detached process
187/// # Panics
188/// panics when spawn server failed
189pub fn spawn_process<A: IntoIterator<Item = S> + Clone, S: AsRef<OsStr>>(
190    prog: &Path,
191    superuser: bool,
192    shout_output: bool,
193    args: A,
194) -> std::io::Result<Child> {
195    let mut cmd = if superuser {
196        let mut cmd_t = Command::new("sudo");
197        cmd_t.arg(prog);
198        cmd_t
199    } else {
200        Command::new(prog)
201    };
202    cmd.stdin(Stdio::null());
203    if !shout_output {
204        cmd.stdout(Stdio::null());
205        cmd.stderr(Stdio::null());
206    }
207
208    cmd.args(args);
209    cmd.spawn()
210}
211
212/// Absolutize a given path with the current working directory.
213///
214/// This function, unlike [`std::fs::canonicalize`] does *not* hit the filesystem and so does not require the input path to exist yet.
215///
216/// Examples:
217/// `./somewhere` -> `/absolute/./somewhere`
218/// `.\somewhere` -> `C:\somewhere`
219///
220/// in the future consider replacing with [`std::path::absolute`] once stable
221pub fn absolute_path(path: &Path) -> std::io::Result<Cow<'_, Path>> {
222    if path.is_absolute() {
223        Ok(Cow::Borrowed(path))
224    } else {
225        Ok(Cow::Owned(std::env::current_dir()?.join(path)))
226    }
227}
228
229/// Absolutize a given path with the given base.
230///
231/// `base` is expected to be absoulte!
232///
233/// This function, unlike [`std::fs::canonicalize`] does *not* hit the filesystem and so does not require the input path to exist yet.
234///
235/// Examples:
236/// `./somewhere` -> `/absolute/./somewhere`
237/// `.\somewhere` -> `C:\somewhere`
238///
239/// in the future consider replacing with [`std::path::absolute`] once stable
240#[must_use]
241pub fn absolute_path_base<'a>(path: &'a Path, base: &Path) -> Cow<'a, Path> {
242    if path.is_absolute() {
243        Cow::Borrowed(path)
244    } else {
245        Cow::Owned(base.join(path))
246    }
247}
248
249/// Generate `len` random ascii character (a-z0-9)
250#[must_use]
251pub fn random_ascii(len: usize) -> String {
252    rand::thread_rng()
253        .sample_iter(&rand::distributions::Alphanumeric)
254        .take(len)
255        .map(|v| char::from(v).to_ascii_lowercase())
256        .collect()
257}
258
259/// Helper function to defer formatting to later, without having to allocate a intermediate [`String`]
260///
261/// similar to [`format_args!`], but it can be returned by `move`d values
262///
263/// Source: <https://internals.rust-lang.org/t/suggestion-for-helper-for-writing-fmt-debug-impls-with-nested-structure/19477/2>
264///
265/// Example:
266/// ```
267/// # use std::fmt::Display;
268/// # use termusiclib::utils::display_with;
269/// // instead of `fn nested() -> String`
270/// fn nested() -> impl Display {
271///   let new_string = String::from("Hello allocated string");
272///   // instead of `format!("Formatted! {}", new_string)`
273///   display_with(move |f| write!(f, "Formatted! {}", new_string))
274/// }
275///
276/// println!("No Extra allocation:\n{}", nested());
277/// ```
278pub fn display_with(
279    f: impl Fn(&mut std::fmt::Formatter<'_>) -> std::fmt::Result,
280) -> impl std::fmt::Display {
281    struct DisplayWith<F>(F);
282
283    impl<F> std::fmt::Display for DisplayWith<F>
284    where
285        F: Fn(&mut std::fmt::Formatter<'_>) -> std::fmt::Result,
286    {
287        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288            self.0(f)
289        }
290    }
291
292    DisplayWith(f)
293}
294
295#[cfg(test)]
296mod tests {
297    use std::fmt::{Display, Write};
298
299    use super::*;
300    use pretty_assertions::assert_eq;
301
302    #[test]
303    fn test_pin_yin() {
304        assert_eq!(get_pin_yin("陈一发儿"), "chenyifaer".to_string());
305        assert_eq!(get_pin_yin("Gala乐队"), "GALAledui".to_string());
306        assert_eq!(get_pin_yin("乐队Gala乐队"), "leduiGALAledui".to_string());
307        assert_eq!(get_pin_yin("Annett Louisan"), "ANNETT LOUISAN".to_string());
308    }
309
310    #[test]
311    fn test_substr() {
312        // 0 length fallback
313        assert_eq!("abcde".substr(0, 0), "");
314
315        assert_eq!("abcde".substr(0, 1), "a");
316        assert_eq!("abcde".substr(4, 1), "e");
317
318        // something starting beyond the current string
319        assert_eq!("abcde".substr(100, 1), "");
320        // requesting more length that is available
321        assert_eq!("abcde".substr(3, 3), "de");
322
323        assert_eq!("陈一发儿".substr(0, 1), "陈");
324        assert_eq!("陈一发儿".substr(3, 1), "儿");
325    }
326
327    #[test]
328    fn display_with_to_string() {
329        fn nested() -> impl Display {
330            let new_owned = String::from("Owned");
331
332            display_with(move |f| write!(f, "Nested! {new_owned}"))
333        }
334
335        let mut str = String::new();
336
337        let _ = write!(&mut str, "Formatted! {}", nested());
338
339        assert_eq!(str, "Formatted! Nested! Owned");
340    }
341}