mirror of
https://github.com/arabianq/lyrics_fetcher.git
synced 2026-04-28 16:11:22 +00:00
Compare commits
8 Commits
development
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c4b48b8d33 | |||
| d9de6b3800 | |||
| 02205f1cea | |||
| 45dde40008 | |||
| 3f65e5d83e | |||
| 27a2a30ae8 | |||
| 325ce4452b | |||
| e878fb7b6c |
@@ -66,6 +66,14 @@ jobs:
|
|||||||
- name: Build binary
|
- name: Build binary
|
||||||
run: cargo build --release --target ${{ matrix.target }}
|
run: cargo build --release --target ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Compress binary with UPX
|
||||||
|
if: matrix.os != 'macos-latest' && matrix.target != 'aarch64-pc-windows-msvc'
|
||||||
|
uses: crazy-max/ghaction-upx@v3
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
files: target/${{ matrix.target }}/release/${{ matrix.binary_name }}
|
||||||
|
args: -9
|
||||||
|
|
||||||
- name: Rename artifact
|
- name: Rename artifact
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -105,8 +113,6 @@ jobs:
|
|||||||
target_commitish: ${{ github.sha }}
|
target_commitish: ${{ github.sha }}
|
||||||
name: "Development Build"
|
name: "Development Build"
|
||||||
body: |
|
body: |
|
||||||
Автоматическая сборка последней версии для тестирования.
|
|
||||||
|
|
||||||
**Commit:** [${{ steps.release_info.outputs.commit }}](https://github.com/${{ github.repository }}/commit/${{ github.sha }})
|
**Commit:** [${{ steps.release_info.outputs.commit }}](https://github.com/${{ github.repository }}/commit/${{ github.sha }})
|
||||||
**Date:** ${{ steps.release_info.outputs.date }}
|
**Date:** ${{ steps.release_info.outputs.date }}
|
||||||
prerelease: true
|
prerelease: true
|
||||||
|
|||||||
Generated
+49
-2
@@ -355,6 +355,12 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -836,7 +842,6 @@ dependencies = [
|
|||||||
"bitflags",
|
"bitflags",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"flate2",
|
"flate2",
|
||||||
"tokio",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -984,6 +989,32 @@ dependencies = [
|
|||||||
"scopeguard",
|
"scopeguard",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lofty"
|
||||||
|
version = "0.23.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6d0c107dba5af049cf1c36b646fc1ba0cd2705f40d766d2c4c64f1b797c5fbed"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"data-encoding",
|
||||||
|
"flate2",
|
||||||
|
"lofty_attr",
|
||||||
|
"log",
|
||||||
|
"ogg_pager",
|
||||||
|
"paste",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lofty_attr"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "458ace39169e4b83c4f77ae3d42d5d1d11c422feef590219a97c973d3b524557"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.29"
|
version = "0.4.29"
|
||||||
@@ -998,7 +1029,7 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lyrics_fetcher"
|
name = "lyrics_fetcher"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1008,6 +1039,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"id3",
|
"id3",
|
||||||
"itertools",
|
"itertools",
|
||||||
|
"lofty",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"scraper",
|
"scraper",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1058,6 +1090,15 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ogg_pager"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6d6d1ca8364b84e0cf725eed06b1460c44671e6c0fb28765f5262de3ece07fdc"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -1105,6 +1146,12 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "paste"
|
||||||
|
version = "1.0.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
|
|||||||
+4
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lyrics_fetcher"
|
name = "lyrics_fetcher"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["arabianq"]
|
authors = ["arabianq"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
@@ -8,6 +8,7 @@ license = "MIT"
|
|||||||
repository = "https://github.com/arabianq/lyrics_fetcher"
|
repository = "https://github.com/arabianq/lyrics_fetcher"
|
||||||
homepage = "https://github.com/arabianq/lyrics_fetcher"
|
homepage = "https://github.com/arabianq/lyrics_fetcher"
|
||||||
keywords = ["lyrics", "music", "audio", "lrclib", "genius"]
|
keywords = ["lyrics", "music", "audio", "lrclib", "genius"]
|
||||||
|
description = "A Rust for fetching and embedding lyrics into audio files using multiple sources."
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1.51.0", features = ["full"] }
|
tokio = { version = "1.51.0", features = ["full"] }
|
||||||
@@ -20,7 +21,8 @@ anyhow = "1.0.102"
|
|||||||
itertools = "0.14.0"
|
itertools = "0.14.0"
|
||||||
dotenv = "0.15.0"
|
dotenv = "0.15.0"
|
||||||
|
|
||||||
id3 = { version = "1.16.4", default-features = false, features = ["tokio"] }
|
lofty = "0.23.3"
|
||||||
|
id3 = "1.16.4"
|
||||||
reqwest = { version = "0.13.2", default-features = false, features = [
|
reqwest = { version = "0.13.2", default-features = false, features = [
|
||||||
"rustls",
|
"rustls",
|
||||||
"json",
|
"json",
|
||||||
|
|||||||
+77
-19
@@ -1,5 +1,9 @@
|
|||||||
mod sources;
|
mod sources;
|
||||||
|
|
||||||
|
use lofty::{
|
||||||
|
file::{AudioFile, TaggedFileExt},
|
||||||
|
tag::TagExt,
|
||||||
|
};
|
||||||
use sources::*;
|
use sources::*;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
@@ -16,7 +20,8 @@ use tokio::sync::Semaphore;
|
|||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
use id3::{Tag, TagLike, Version, frame::Lyrics};
|
use id3::{TagLike, Version};
|
||||||
|
use lofty::{config::WriteOptions, tag::ItemKey};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
struct Args {
|
struct Args {
|
||||||
@@ -51,7 +56,7 @@ struct Args {
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
dotenv::dotenv()?;
|
dotenv::dotenv().ok();
|
||||||
|
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|
||||||
@@ -175,28 +180,44 @@ async fn process_file(
|
|||||||
overwrite: bool,
|
overwrite: bool,
|
||||||
allow_inaccurate: bool,
|
allow_inaccurate: bool,
|
||||||
) {
|
) {
|
||||||
let mut tag = match Tag::async_read_from_path(file_path).await {
|
let mut tagged_file = match lofty::read_from_path(&file_path) {
|
||||||
Ok(tag) => tag,
|
Ok(file) => file,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
eprintln!("[ERROR] Failed to read '{}': {}", file_path.display(), e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let search_tag = match tagged_file.primary_tag().or(tagged_file.first_tag()) {
|
||||||
|
Some(t) => t.clone(),
|
||||||
|
None => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[ERROR] Failed to read ID3 tag for file '{}': {}",
|
"[ERROR] No tags in '{}', don't know what to search for. Skipping",
|
||||||
file_path.display(),
|
file_path.display()
|
||||||
e
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if !overwrite && tag.lyrics().next().is_some() {
|
let properties = tagged_file.properties();
|
||||||
|
|
||||||
|
let has_lyrics = search_tag.items().any(|item| {
|
||||||
|
matches!(item.key(), ItemKey::Lyrics) || format!("{:?}", item.key()).contains("Lyric")
|
||||||
|
});
|
||||||
|
|
||||||
|
if !overwrite && has_lyrics {
|
||||||
println!(
|
println!(
|
||||||
"[INFO] File '{}' already has lyrics, skipping (use --overwrite to force)",
|
"[INFO] Lyrics already exist in '{}', skipping",
|
||||||
file_path.display()
|
file_path.display()
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for source in sources.iter() {
|
for source in sources.iter() {
|
||||||
match source.fetch_lyrics(&tag, allow_inaccurate).await {
|
match source
|
||||||
|
.fetch_lyrics(&search_tag, properties, allow_inaccurate)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(lyrics) => {
|
Ok(lyrics) => {
|
||||||
let lyrics = lyrics.trim();
|
let lyrics = lyrics.trim();
|
||||||
if lyrics.is_empty() {
|
if lyrics.is_empty() {
|
||||||
@@ -209,34 +230,71 @@ async fn process_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"[INFO] Successfully fetched lyrics for file '{}' from source '{}'",
|
"[INFO] Found lyrics for '{}' via '{}'",
|
||||||
file_path.display(),
|
file_path.display(),
|
||||||
source.name()
|
source.name()
|
||||||
);
|
);
|
||||||
|
|
||||||
tag.remove_all_lyrics();
|
let ext = file_path
|
||||||
tag.remove_all_synchronised_lyrics();
|
.extension()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_lowercase();
|
||||||
|
|
||||||
tag.add_frame(Lyrics {
|
if ext == "mp3" {
|
||||||
|
let mut mp3_tag =
|
||||||
|
id3::Tag::read_from_path(&file_path).unwrap_or_else(|_| id3::Tag::new());
|
||||||
|
|
||||||
|
mp3_tag.add_frame(id3::frame::Lyrics {
|
||||||
lang: "XXX".to_string(),
|
lang: "XXX".to_string(),
|
||||||
description: format!("Fetched from {}", source.name()),
|
description: String::new(),
|
||||||
text: lyrics.to_string(),
|
text: lyrics.to_string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Err(e) = tag.write_to_path(file_path, Version::Id3v24) {
|
if let Err(e) = mp3_tag.write_to_path(&file_path, Version::Id3v24) {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[ERROR] Failed to write tags into {}: {}",
|
"[ERROR] Failed to save MP3 tag in {}: {}",
|
||||||
file_path.display(),
|
file_path.display(),
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"[INFO] Successfully wrote lyrics for '{}' via id3 (MP3)",
|
||||||
|
file_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if tagged_file.primary_tag().is_none() {
|
||||||
|
let tag_type = tagged_file.primary_tag_type();
|
||||||
|
tagged_file.insert_tag(lofty::tag::Tag::new(tag_type));
|
||||||
|
}
|
||||||
|
|
||||||
|
let tag_to_save = tagged_file.primary_tag_mut().unwrap();
|
||||||
|
|
||||||
|
tag_to_save.remove_key(ItemKey::Lyrics);
|
||||||
|
tag_to_save.insert_text(ItemKey::Lyrics, lyrics.to_string());
|
||||||
|
|
||||||
|
if let Err(e) = tag_to_save.save_to_path(&file_path, WriteOptions::default()) {
|
||||||
|
eprintln!(
|
||||||
|
"[ERROR] Failed to save tags in {}: {}",
|
||||||
|
file_path.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"[INFO] Successfully wrote lyrics for '{}' via lofty ({})",
|
||||||
|
file_path.display(),
|
||||||
|
ext.to_uppercase()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[ERROR] Failed to fetch lyrics for file '{}' from source '{}': {}",
|
"[ERROR] Source '{}' error for '{}': {}",
|
||||||
file_path.display(),
|
|
||||||
source.name(),
|
source.name(),
|
||||||
|
file_path.display(),
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-4
@@ -2,7 +2,10 @@ use crate::sources::LyricsSource;
|
|||||||
|
|
||||||
use anyhow::{Context, Ok, Result, anyhow};
|
use anyhow::{Context, Ok, Result, anyhow};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use id3::{Tag, TagLike};
|
use lofty::{
|
||||||
|
properties::FileProperties,
|
||||||
|
tag::{Accessor, Tag},
|
||||||
|
};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use scraper::{ElementRef, Html, Node, Selector};
|
use scraper::{ElementRef, Html, Node, Selector};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -66,13 +69,20 @@ impl LyricsSource for GeniusSource {
|
|||||||
"Genius"
|
"Genius"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_lyrics(&self, tag: &Tag, allow_inaccurate: bool) -> Result<String> {
|
async fn fetch_lyrics(
|
||||||
|
&self,
|
||||||
|
tag: &Tag,
|
||||||
|
_properties: &FileProperties,
|
||||||
|
allow_inaccurate: bool,
|
||||||
|
) -> Result<String> {
|
||||||
let title = tag
|
let title = tag
|
||||||
.title()
|
.title()
|
||||||
.ok_or_else(|| anyhow!("Missing track title in ID3 tag"))?;
|
.ok_or_else(|| anyhow!("Missing track title in ID3 tag"))?
|
||||||
|
.to_string();
|
||||||
let artist = tag
|
let artist = tag
|
||||||
.artist()
|
.artist()
|
||||||
.ok_or_else(|| anyhow!("Missing artist name in ID3 tag"))?;
|
.ok_or_else(|| anyhow!("Missing artist name in ID3 tag"))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
let query = format!("{} {}", artist, title);
|
let query = format!("{} {}", artist, title);
|
||||||
|
|
||||||
|
|||||||
+20
-10
@@ -2,7 +2,10 @@ use crate::sources::LyricsSource;
|
|||||||
|
|
||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{Context, Result, anyhow};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use id3::{Tag, TagLike};
|
use lofty::{
|
||||||
|
properties::FileProperties,
|
||||||
|
tag::{Accessor, Tag},
|
||||||
|
};
|
||||||
use reqwest::{Client, StatusCode};
|
use reqwest::{Client, StatusCode};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
@@ -63,25 +66,32 @@ impl LyricsSource for LrcLibSource {
|
|||||||
"LrcLib"
|
"LrcLib"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_lyrics(&self, tag: &Tag, allow_inaccurate: bool) -> Result<String> {
|
async fn fetch_lyrics(
|
||||||
|
&self,
|
||||||
|
tag: &Tag,
|
||||||
|
properties: &FileProperties,
|
||||||
|
allow_inaccurate: bool,
|
||||||
|
) -> Result<String> {
|
||||||
let base_url = "https://lrclib.net/api";
|
let base_url = "https://lrclib.net/api";
|
||||||
|
|
||||||
let track_name = tag
|
let track_name = tag
|
||||||
.title()
|
.title()
|
||||||
.ok_or_else(|| anyhow!("Missing track title in ID3 tag"))?;
|
.ok_or_else(|| anyhow!("Missing track title in ID3 tag"))?
|
||||||
|
.to_string();
|
||||||
let artist_name = tag
|
let artist_name = tag
|
||||||
.artist()
|
.artist()
|
||||||
.ok_or_else(|| anyhow!("Missing artist name in ID3 tag"))?;
|
.ok_or_else(|| anyhow!("Missing artist name in ID3 tag"))?
|
||||||
let album_name = tag.album().unwrap_or_default();
|
.to_string();
|
||||||
let target_duration = tag.duration().unwrap_or_default() / 1000;
|
let album_name = tag.album().unwrap_or_default().to_string();
|
||||||
|
let target_duration = properties.duration().as_secs() as u32;
|
||||||
|
|
||||||
let mut get_req = self
|
let mut get_req = self
|
||||||
.client
|
.client
|
||||||
.get(format!("{base_url}/get"))
|
.get(format!("{base_url}/get"))
|
||||||
.query(&[("track_name", track_name), ("artist_name", artist_name)]);
|
.query(&[("track_name", &track_name), ("artist_name", &artist_name)]);
|
||||||
|
|
||||||
if !album_name.is_empty() {
|
if !album_name.is_empty() {
|
||||||
get_req = get_req.query(&[("album_name", album_name)]);
|
get_req = get_req.query(&[("album_name", &album_name)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if target_duration > 0 {
|
if target_duration > 0 {
|
||||||
@@ -100,10 +110,10 @@ impl LyricsSource for LrcLibSource {
|
|||||||
let mut search_req = self
|
let mut search_req = self
|
||||||
.client
|
.client
|
||||||
.get(format!("{base_url}/search"))
|
.get(format!("{base_url}/search"))
|
||||||
.query(&[("track_name", track_name), ("artist_name", artist_name)]);
|
.query(&[("track_name", &track_name), ("artist_name", &artist_name)]);
|
||||||
|
|
||||||
if !album_name.is_empty() {
|
if !album_name.is_empty() {
|
||||||
search_req = search_req.query(&[("album_name", album_name)]);
|
search_req = search_req.query(&[("album_name", &album_name)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = search_req.send().await?;
|
let response = search_req.send().await?;
|
||||||
|
|||||||
+7
-2
@@ -6,13 +6,18 @@ pub use lrclib::LrcLibSource;
|
|||||||
|
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use id3::Tag;
|
use lofty::{properties::FileProperties, tag::Tag};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait LyricsSource: Send + Sync {
|
pub trait LyricsSource: Send + Sync {
|
||||||
fn name(&self) -> &'static str;
|
fn name(&self) -> &'static str;
|
||||||
async fn fetch_lyrics(&self, tag: &Tag, allow_inaccurate: bool) -> Result<String>;
|
async fn fetch_lyrics(
|
||||||
|
&self,
|
||||||
|
tag: &Tag,
|
||||||
|
properties: &FileProperties,
|
||||||
|
allow_inaccurate: bool,
|
||||||
|
) -> Result<String>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_source(name: &str) -> Result<Arc<dyn LyricsSource>> {
|
pub async fn create_source(name: &str) -> Result<Arc<dyn LyricsSource>> {
|
||||||
|
|||||||
Reference in New Issue
Block a user