termusiclib/songtag/
mod.rs

1//! SPDX-License-Identifier: MIT
2
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::thread::{self, sleep};
6use std::time::Duration;
7
8use anyhow::{anyhow, bail, Result};
9use lofty::config::WriteOptions;
10use lofty::id3::v2::{Frame, Id3v2Tag, UnsynchronizedTextFrame};
11use lofty::picture::Picture;
12use lofty::prelude::{Accessor, TagExt};
13use lofty::TextEncoding;
14use service::SongTagService;
15use tokio::sync::mpsc::UnboundedSender;
16use ytd_rs::{Arg, YoutubeDL};
17
18use crate::library_db::const_unknown::{UNKNOWN_ARTIST, UNKNOWN_TITLE};
19use crate::types::{DLMsg, Msg, SongTagRecordingResult, TEMsg};
20use crate::utils::get_parent_folder;
21
22mod kugou;
23pub mod lrc;
24mod migu;
25mod netease_v2;
26mod service;
27
28#[derive(Debug, PartialEq, Eq, Clone)]
29pub struct SongTag {
30    service_provider: ServiceProvider,
31    song_id: String,
32    artist: Option<String>,
33    title: Option<String>,
34    album: Option<String>,
35    lang_ext: Option<String>,
36    lyric_id: Option<String>,
37    url: Option<UrlTypes>,
38    pic_id: Option<String>,
39    album_id: Option<String>,
40    // genre: Option<String>,
41}
42
43/// Indicate in which way the song can be downloaded, if at all.
44#[derive(Debug, PartialEq, Eq, Clone)]
45pub enum UrlTypes {
46    /// Download is protected by DRM or a fee, something which we dont do here
47    Protected,
48    /// Download is freely available, but requires extra fetching (`Api::song_url()`)
49    AvailableRequiresFetching,
50    /// Url is freely available to be downloaded
51    FreeDownloadable(String),
52}
53
54#[derive(Debug, PartialEq, Eq, Clone, Copy)]
55pub enum ServiceProvider {
56    Netease,
57    Kugou,
58    Migu,
59}
60
61impl std::fmt::Display for ServiceProvider {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        let service_provider = match self {
64            Self::Netease => "Netease",
65            Self::Kugou => "Kugou",
66            Self::Migu => "Migu",
67        };
68        write!(f, "{service_provider}")
69    }
70}
71
72// Search function of 3 servers. Run in parallel to get results faster.
73pub async fn search(search_str: &str, tx_done: UnboundedSender<Msg>) {
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    let _ = tx_done.send(Msg::TagEditor(TEMsg::TESearchLyricResult(
110        SongTagRecordingResult::Finish(results),
111    )));
112}
113
114impl SongTag {
115    #[must_use]
116    pub fn artist(&self) -> Option<&str> {
117        self.artist.as_deref()
118    }
119
120    #[must_use]
121    pub fn album(&self) -> Option<&str> {
122        self.album.as_deref()
123    }
124
125    /// Optionally return the title of the song
126    /// If `None` it wasn't able to read the tags
127    #[must_use]
128    pub fn title(&self) -> Option<&str> {
129        self.title.as_deref()
130    }
131
132    #[must_use]
133    pub fn lang_ext(&self) -> Option<&str> {
134        self.lang_ext.as_deref()
135    }
136
137    #[must_use]
138    pub const fn service_provider(&self) -> ServiceProvider {
139        self.service_provider
140    }
141
142    #[must_use]
143    pub const fn url(&self) -> Option<&UrlTypes> {
144        self.url.as_ref()
145    }
146
147    #[must_use]
148    pub fn id(&self) -> &str {
149        &self.song_id
150    }
151
152    // get lyric by lyric_id
153    pub async fn fetch_lyric(&self) -> Result<Option<String>> {
154        let lyric_string = match self.service_provider {
155            ServiceProvider::Kugou => {
156                let kugou_api = kugou::Api::new();
157                kugou_api.get_lyrics(self).await.map_err(|v| anyhow!(v))?
158            }
159            ServiceProvider::Netease => {
160                let neteasev2_api = netease_v2::Api::new();
161                neteasev2_api
162                    .get_lyrics(self)
163                    .await
164                    .map_err(|v| anyhow!(v))?
165            }
166            ServiceProvider::Migu => {
167                let migu_api = migu::Api::new();
168                migu_api.get_lyrics(self).await.map_err(|v| anyhow!(v))?
169            }
170        };
171
172        Ok(Some(lyric_string))
173    }
174
175    /// Fetch a picture for the current song
176    /// For kugou & netease `pic_id()` or for migu `song_id` is used
177    pub async fn fetch_photo(&self) -> Result<Picture> {
178        match self.service_provider {
179            ServiceProvider::Kugou => {
180                let kugou_api = kugou::Api::new();
181                Ok(kugou_api.get_picture(self).await.map_err(|v| anyhow!(v))?)
182            }
183            ServiceProvider::Netease => {
184                let neteasev2_api = netease_v2::Api::new();
185                Ok(neteasev2_api
186                    .get_picture(self)
187                    .await
188                    .map_err(|v| anyhow!(v))?)
189            }
190            ServiceProvider::Migu => {
191                let migu_api = migu::Api::new();
192                Ok(migu_api.get_picture(self).await.map_err(|v| anyhow!(v))?)
193            }
194        }
195    }
196
197    #[allow(clippy::too_many_lines)]
198    pub async fn download(&self, file: &Path, tx: UnboundedSender<Msg>) -> Result<()> {
199        let p_parent = get_parent_folder(file);
200        let artist = self
201            .artist
202            .clone()
203            .unwrap_or_else(|| UNKNOWN_ARTIST.to_string());
204        let title = self
205            .title
206            .clone()
207            .unwrap_or_else(|| UNKNOWN_TITLE.to_string());
208
209        let album = self.album.clone().unwrap_or_else(|| String::from("N/A"));
210        let lyric = self.fetch_lyric().await;
211        let photo = self.fetch_photo().await;
212
213        let filename = format!("{artist}-{title}.%(ext)s");
214
215        let args = vec![
216            Arg::new("--quiet"),
217            Arg::new_with_arg("--output", filename.as_ref()),
218            Arg::new("--extract-audio"),
219            Arg::new_with_arg("--audio-format", "mp3"),
220        ];
221
222        let p_full = p_parent.join(format!("{artist}-{title}.mp3"));
223        match std::fs::remove_file(&p_full) {
224            Err(err) if err.kind() == std::io::ErrorKind::NotFound => (),
225            v => v?,
226        }
227
228        if self.url().is_some_and(|v| *v == UrlTypes::Protected) {
229            bail!("The item is protected by copyright, please, select another one.");
230        }
231
232        let mut url = if let Some(UrlTypes::FreeDownloadable(url)) = &self.url {
233            url.clone()
234        } else {
235            String::new()
236        };
237
238        match self.service_provider {
239            ServiceProvider::Netease => {
240                let neteasev2_api = netease_v2::Api::new();
241                url = neteasev2_api
242                    .download_recording(self)
243                    .await
244                    .map_err(|v| anyhow!(v))?;
245            }
246            ServiceProvider::Migu => {}
247            ServiceProvider::Kugou => {
248                let kugou_api = kugou::Api::new();
249                url = kugou_api
250                    .download_recording(self)
251                    .await
252                    .map_err(|v| anyhow!(v))?;
253            }
254        }
255
256        if url.is_empty() {
257            bail!("failed to fetch url, please, try another item.");
258        }
259
260        // avoid full string clones when sending via a channel
261        let url = Arc::from(url.into_boxed_str());
262
263        let ytd = YoutubeDL::new(&PathBuf::from(p_parent), args, &url)?;
264
265        thread::spawn(move || -> Result<()> {
266            tx.send(Msg::Download(DLMsg::DownloadRunning(
267                url.clone(),
268                title.clone(),
269            )))
270            .ok();
271
272            // start download
273            // check what the result is and print out the path to the download or the error
274            let _download_result = match ytd.download() {
275                Ok(res) => res,
276                Err(err) => {
277                    tx.send(Msg::Download(DLMsg::DownloadErrDownload(
278                        url.clone(),
279                        title.clone(),
280                        err.to_string(),
281                    )))
282                    .ok();
283                    sleep(Duration::from_secs(1));
284                    tx.send(Msg::Download(DLMsg::DownloadCompleted(url.clone(), None)))
285                        .ok();
286
287                    return Ok(());
288                }
289            };
290
291            tx.send(Msg::Download(DLMsg::DownloadSuccess(url.clone())))
292                .ok();
293            let mut tag = Id3v2Tag::default();
294
295            tag.set_title(title.clone());
296            tag.set_artist(artist);
297            tag.set_album(album);
298
299            if let Ok(Some(l)) = lyric {
300                let frame = Frame::UnsynchronizedText(UnsynchronizedTextFrame::new(
301                    TextEncoding::UTF8,
302                    *b"eng",
303                    String::from("saved by termusic"),
304                    l,
305                ));
306                tag.insert(frame);
307            }
308
309            if let Ok(picture) = photo {
310                tag.insert_picture(picture);
311            }
312
313            if tag.save_to_path(&p_full, WriteOptions::new()).is_ok() {
314                sleep(Duration::from_secs(1));
315                tx.send(Msg::Download(DLMsg::DownloadCompleted(
316                    url,
317                    Some(p_full.to_string_lossy().to_string()),
318                )))
319                .ok();
320            } else {
321                tx.send(Msg::Download(DLMsg::DownloadErrEmbedData(
322                    url.clone(),
323                    title,
324                )))
325                .ok();
326                sleep(Duration::from_secs(1));
327                tx.send(Msg::Download(DLMsg::DownloadCompleted(url, None)))
328                    .ok();
329            }
330
331            Ok(())
332        });
333        Ok(())
334    }
335}