termusiclib/songtag/
mod.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use anyhow::{Result, anyhow, bail};
5use lofty::TextEncoding;
6use lofty::config::WriteOptions;
7use lofty::id3::v2::{Frame, Id3v2Tag, UnsynchronizedTextFrame};
8use lofty::picture::Picture;
9use lofty::prelude::{Accessor, TagExt};
10use service::SongTagService;
11use ytd_rs::{Arg, YoutubeDL};
12
13use crate::common::const_unknown::{UNKNOWN_ARTIST, UNKNOWN_TITLE};
14use crate::utils::get_parent_folder;
15
16mod kugou;
17pub mod lrc;
18mod migu;
19mod netease_v2;
20mod service;
21
22#[derive(Debug, PartialEq, Eq, Clone)]
23pub struct SongTag {
24    service_provider: ServiceProvider,
25    song_id: String,
26    artist: Option<String>,
27    title: Option<String>,
28    album: Option<String>,
29    lang_ext: Option<String>,
30    lyric_id: Option<String>,
31    url: Option<UrlTypes>,
32    pic_id: Option<String>,
33    album_id: Option<String>,
34    // genre: Option<String>,
35}
36
37/// Indicate in which way the song can be downloaded, if at all.
38#[derive(Debug, PartialEq, Eq, Clone)]
39pub enum UrlTypes {
40    /// Download is protected by DRM or a fee, something which we dont do here
41    Protected,
42    /// Download is freely available, but requires extra fetching (`Api::song_url()`)
43    AvailableRequiresFetching,
44    /// Url is freely available to be downloaded
45    FreeDownloadable(String),
46}
47
48#[derive(Debug, PartialEq, Eq, Clone, Copy)]
49pub enum ServiceProvider {
50    Netease,
51    Kugou,
52    Migu,
53}
54
55impl std::fmt::Display for ServiceProvider {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        let service_provider = match self {
58            Self::Netease => "Netease",
59            Self::Kugou => "Kugou",
60            Self::Migu => "Migu",
61        };
62        write!(f, "{service_provider}")
63    }
64}
65
66/// All events that can happen in [`search`].
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum SongtagSearchResult {
69    Finish(Vec<SongTag>),
70}
71
72// Search function of 3 servers. Run in parallel to get results faster.
73pub async fn search(search_str: &str, tx_done: impl Fn(SongtagSearchResult) + Send + 'static) {
74    let mut results: Vec<SongTag> = Vec::new();
75
76    let handle_netease = async {
77        let neteasev2_api = netease_v2::Api::new();
78        neteasev2_api.search_recording(search_str, 0, 30).await
79    };
80
81    let handle_migu = async {
82        let migu_api = migu::Api::new();
83        migu_api.search_recording(search_str, 0, 30).await
84    };
85
86    let handle_kugou = async {
87        let kugou_api = kugou::Api::new();
88        kugou_api.search_recording(search_str, 0, 30).await
89    };
90
91    let (netease_res, migu_res, kugou_res) =
92        futures_util::join!(handle_netease, handle_migu, handle_kugou);
93
94    match netease_res {
95        Ok(vec) => results.extend(vec),
96        Err(err) => error!("Netease Error: {err:#}"),
97    }
98
99    match migu_res {
100        Ok(vec) => results.extend(vec),
101        Err(err) => error!("Migu Error: {err:#}"),
102    }
103
104    match kugou_res {
105        Ok(vec) => results.extend(vec),
106        Err(err) => error!("Kogou Error: {err:#}"),
107    }
108
109    tx_done(SongtagSearchResult::Finish(results));
110}
111
112pub type TrackDLMsgURL = Arc<str>;
113
114#[derive(Clone, PartialEq, Eq, Debug)]
115pub enum TrackDLMsg {
116    /// Indicates a Start of a download.
117    ///
118    /// `(Url, Title)`
119    Start(TrackDLMsgURL, String),
120    /// Indicates the Download was a Success, though termusic post-processing is not done yet.
121    ///
122    /// `(Url)`
123    Success(TrackDLMsgURL),
124    /// Indicates the Download thread finished in both Success or Error.
125    ///
126    /// `(Url, Filename)`
127    Completed(TrackDLMsgURL, Option<String>),
128    /// Indicates that the Download has Errored and has been aborted.
129    ///
130    /// `(Url, Title, ErrorAsString)`
131    Err(TrackDLMsgURL, String, String),
132    /// Indicates that the Download was a Success, but termusic post-processing failed.
133    /// Like re-saving tags after editing.
134    ///
135    /// `(Url, Title)`
136    ErrEmbedData(TrackDLMsgURL, String),
137}
138
139impl SongTag {
140    #[must_use]
141    pub fn artist(&self) -> Option<&str> {
142        self.artist.as_deref()
143    }
144
145    #[must_use]
146    pub fn album(&self) -> Option<&str> {
147        self.album.as_deref()
148    }
149
150    /// Optionally return the title of the song
151    /// If `None` it wasn't able to read the tags
152    #[must_use]
153    pub fn title(&self) -> Option<&str> {
154        self.title.as_deref()
155    }
156
157    #[must_use]
158    pub fn lang_ext(&self) -> Option<&str> {
159        self.lang_ext.as_deref()
160    }
161
162    #[must_use]
163    pub const fn service_provider(&self) -> ServiceProvider {
164        self.service_provider
165    }
166
167    #[must_use]
168    pub const fn url(&self) -> Option<&UrlTypes> {
169        self.url.as_ref()
170    }
171
172    #[must_use]
173    pub fn id(&self) -> &str {
174        &self.song_id
175    }
176
177    // get lyric by lyric_id
178    pub async fn fetch_lyric(&self) -> Result<Option<String>> {
179        let lyric_string = match self.service_provider {
180            ServiceProvider::Kugou => {
181                let kugou_api = kugou::Api::new();
182                kugou_api.get_lyrics(self).await.map_err(|v| anyhow!(v))?
183            }
184            ServiceProvider::Netease => {
185                let neteasev2_api = netease_v2::Api::new();
186                neteasev2_api
187                    .get_lyrics(self)
188                    .await
189                    .map_err(|v| anyhow!(v))?
190            }
191            ServiceProvider::Migu => {
192                let migu_api = migu::Api::new();
193                migu_api.get_lyrics(self).await.map_err(|v| anyhow!(v))?
194            }
195        };
196
197        Ok(Some(lyric_string))
198    }
199
200    /// Fetch a picture for the current song
201    /// For kugou & netease `pic_id()` or for migu `song_id` is used
202    pub async fn fetch_photo(&self) -> Result<Picture> {
203        match self.service_provider {
204            ServiceProvider::Kugou => {
205                let kugou_api = kugou::Api::new();
206                Ok(kugou_api.get_picture(self).await.map_err(|v| anyhow!(v))?)
207            }
208            ServiceProvider::Netease => {
209                let neteasev2_api = netease_v2::Api::new();
210                Ok(neteasev2_api
211                    .get_picture(self)
212                    .await
213                    .map_err(|v| anyhow!(v))?)
214            }
215            ServiceProvider::Migu => {
216                let migu_api = migu::Api::new();
217                Ok(migu_api.get_picture(self).await.map_err(|v| anyhow!(v))?)
218            }
219        }
220    }
221
222    /// Try to download the currently selected item in the tag editor list.
223    ///
224    /// Async for fetching lyrics, coverart and url. The actual track download is blocking and in another thread.
225    pub async fn download(
226        &self,
227        file: &Path,
228        tx: impl Fn(TrackDLMsg) + Send + 'static,
229    ) -> Result<()> {
230        if self.url().is_some_and(|v| *v == UrlTypes::Protected) {
231            bail!("The item is protected by copyright, please select another one.");
232        }
233
234        let artist = self
235            .artist
236            .clone()
237            .unwrap_or_else(|| UNKNOWN_ARTIST.to_string());
238        let title = self
239            .title
240            .clone()
241            .unwrap_or_else(|| UNKNOWN_TITLE.to_string());
242
243        let album = self.album.clone().unwrap_or_else(|| String::from("N/A"));
244        let lyric = self
245            .fetch_lyric()
246            .await
247            .map_err(|err| {
248                warn!("Fetching Lyric failed: {err:#?}");
249            })
250            .ok()
251            .flatten();
252        let photo = self
253            .fetch_photo()
254            .await
255            .map_err(|err| {
256                warn!("Fetching Photo failed: {err:#?}");
257            })
258            .ok();
259
260        let p_parent = get_parent_folder(file);
261        let out_path = p_parent.join(format!("{artist}-{title}.mp3"));
262        match std::fs::remove_file(&out_path) {
263            Err(err) if err.kind() == std::io::ErrorKind::NotFound => (),
264            v => v?,
265        }
266
267        let mut url = if let Some(UrlTypes::FreeDownloadable(url)) = &self.url {
268            url.clone()
269        } else {
270            String::new()
271        };
272
273        match self.service_provider {
274            ServiceProvider::Netease => {
275                let neteasev2_api = netease_v2::Api::new();
276                url = neteasev2_api
277                    .download_recording(self)
278                    .await
279                    .map_err(|v| anyhow!(v))?;
280            }
281            ServiceProvider::Migu => {}
282            ServiceProvider::Kugou => {
283                let kugou_api = kugou::Api::new();
284                url = kugou_api
285                    .download_recording(self)
286                    .await
287                    .map_err(|v| anyhow!(v))?;
288            }
289        }
290
291        if url.is_empty() {
292            bail!("failed to fetch url, please, try another item.");
293        }
294
295        let filename = format!("{artist}-{title}.%(ext)s");
296
297        let mut tag = Id3v2Tag::default();
298
299        tag.set_title(title.clone());
300        tag.set_artist(artist);
301        tag.set_album(album);
302
303        if let Some(l) = lyric {
304            let frame = Frame::UnsynchronizedText(UnsynchronizedTextFrame::new(
305                TextEncoding::UTF8,
306                *b"eng",
307                String::from("saved by termusic"),
308                l,
309            ));
310            tag.insert(frame);
311        }
312
313        if let Some(picture) = photo {
314            tag.insert_picture(picture);
315        }
316
317        let args = vec![
318            Arg::new("--quiet"),
319            Arg::new_with_arg("--output", filename.as_ref()),
320            Arg::new("--extract-audio"),
321            Arg::new_with_arg("--audio-format", "mp3"),
322        ];
323
324        // avoid full string clones when sending via a channel
325        let url = Arc::from(url.into_boxed_str());
326
327        let ytd = YoutubeDL::new(&PathBuf::from(p_parent), args, &url)?;
328
329        let jh = tokio::task::spawn_blocking(move || {
330            Self::download_ytd(url, &out_path, title, &tag, &ytd, tx);
331        });
332        drop(jh);
333
334        Ok(())
335    }
336
337    /// Run the download and emit Err / Success.
338    ///
339    /// This function is blocking until the download finishes.
340    fn download_ytd(
341        url: Arc<str>,
342        out_path: &Path,
343        title: String,
344        tag: &Id3v2Tag,
345        ytd: &YoutubeDL,
346        tx: impl Fn(TrackDLMsg),
347    ) {
348        tx(TrackDLMsg::Start(url.clone(), title.clone()));
349
350        // do the actual download
351        if let Err(err) = ytd.download() {
352            tx(TrackDLMsg::Err(url.clone(), title.clone(), err.to_string()));
353
354            return;
355        }
356
357        tx(TrackDLMsg::Success(url.clone()));
358
359        if tag.save_to_path(out_path, WriteOptions::new()).is_ok() {
360            tx(TrackDLMsg::Completed(
361                url,
362                Some(out_path.to_string_lossy().to_string()),
363            ));
364        } else {
365            tx(TrackDLMsg::ErrEmbedData(url.clone(), title));
366            tx(TrackDLMsg::Completed(url, None));
367        }
368    }
369}