termusiclib/
utils.rs

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