feat: music search suggestions
This commit is contained in:
parent
ef86181627
commit
bd936a8c42
10 changed files with 474 additions and 3 deletions
|
|
@ -23,7 +23,7 @@ inspired by [NewPipe](https://github.com/TeamNewPipe/NewPipeExtractor).
|
|||
- [X] **Album**
|
||||
- [X] **Artist**
|
||||
- [X] **Search**
|
||||
- [ ] **Search suggestions**
|
||||
- [X] **Search suggestions**
|
||||
- [X] **Radio**
|
||||
- [X] **Track details** (lyrics, recommendations)
|
||||
- [ ] **Moods**
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ pub async fn download_testfiles(project_root: &Path) {
|
|||
music_search_artists(&testfiles).await;
|
||||
music_search_playlists(&testfiles).await;
|
||||
music_search_cont(&testfiles).await;
|
||||
music_search_suggestion(&testfiles).await;
|
||||
music_artist(&testfiles).await;
|
||||
music_details(&testfiles).await;
|
||||
music_lyrics(&testfiles).await;
|
||||
|
|
@ -585,10 +586,14 @@ async fn music_album(testfiles: &Path) {
|
|||
}
|
||||
|
||||
async fn music_search(testfiles: &Path) {
|
||||
for (name, query) in [("default", "black mamba"), ("typo", "liblingsmensch")] {
|
||||
for (name, query) in [
|
||||
("default", "black mamba"),
|
||||
("typo", "liblingsmensch"),
|
||||
("radio", "pop radio"),
|
||||
] {
|
||||
let mut json_path = testfiles.to_path_buf();
|
||||
json_path.push("music_search");
|
||||
json_path.push(format!("{}.json", name));
|
||||
json_path.push(format!("main_{}.json", name));
|
||||
if json_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -684,6 +689,20 @@ async fn music_search_cont(testfiles: &Path) {
|
|||
res.items.next(&rp.query()).await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
async fn music_search_suggestion(testfiles: &Path) {
|
||||
for (name, query) in [("default", "t"), ("empty", "reujbhevmfndxnjrze")] {
|
||||
let mut json_path = testfiles.to_path_buf();
|
||||
json_path.push("music_search");
|
||||
json_path.push(format!("suggestion_{}.json", name));
|
||||
if json_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let rp = rp_testfile(&json_path);
|
||||
rp.query().music_search_suggestion(query).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
async fn music_artist(testfiles: &Path) {
|
||||
for (name, id) in [
|
||||
("default", "UClmXPfaYhXOYsNn_QUyheWQ"),
|
||||
|
|
|
|||
|
|
@ -24,6 +24,13 @@ struct QSearch<'a> {
|
|||
params: Option<Params>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct QSearchSuggestion<'a> {
|
||||
context: YTContext<'a>,
|
||||
input: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
enum Params {
|
||||
#[serde(rename = "EgWKAQIIAWoMEAMQBBAJEA4QChAF")]
|
||||
|
|
@ -182,6 +189,23 @@ impl RustyPipeQuery {
|
|||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn music_search_suggestion(&self, query: &str) -> Result<Vec<String>, Error> {
|
||||
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
|
||||
let request_body = QSearchSuggestion {
|
||||
context,
|
||||
input: query,
|
||||
};
|
||||
|
||||
self.execute_request::<response::MusicSearchSuggestion, _, _>(
|
||||
ClientType::DesktopMusic,
|
||||
"music_search_suggestion",
|
||||
query,
|
||||
"music/get_search_suggestions",
|
||||
&request_body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl MapResponse<MusicSearchResult> for response::MusicSearch {
|
||||
|
|
@ -293,6 +317,41 @@ impl<T: FromYtItem> MapResponse<MusicSearchFiltered<T>> for response::MusicSearc
|
|||
}
|
||||
}
|
||||
|
||||
impl MapResponse<Vec<String>> for response::MusicSearchSuggestion {
|
||||
fn map_response(
|
||||
self,
|
||||
_id: &str,
|
||||
_lang: crate::param::Language,
|
||||
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
||||
) -> Result<MapResult<Vec<String>>, ExtractionError> {
|
||||
let items = self
|
||||
.contents
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|content| {
|
||||
content
|
||||
.search_suggestions_section_renderer
|
||||
.contents
|
||||
.into_iter()
|
||||
.filter_map(|itm| {
|
||||
match itm {
|
||||
response::music_search::SearchSuggestionItem::SearchSuggestionRenderer {
|
||||
suggestion,
|
||||
} => Some(suggestion),
|
||||
response::music_search::SearchSuggestionItem::None => None,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(MapResult {
|
||||
c: items,
|
||||
warnings: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{fs::File, io::BufReader, path::Path};
|
||||
|
|
@ -417,4 +476,26 @@ mod tests {
|
|||
|
||||
insta::assert_ron_snapshot!(format!("map_music_search_playlists_{}", name), map_res.c);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::default("default")]
|
||||
#[case::empty("empty")]
|
||||
fn map_music_search_suggestion(#[case] name: &str) {
|
||||
let filename = format!("testfiles/music_search/suggestion_{}.json", name);
|
||||
let json_path = Path::new(&filename);
|
||||
let json_file = File::open(json_path).unwrap();
|
||||
|
||||
let suggestion: response::MusicSearchSuggestion =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res: MapResult<Vec<String>> =
|
||||
suggestion.map_response("", Language::En, None).unwrap();
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
"deserialization/mapping warnings: {:?}",
|
||||
map_res.warnings
|
||||
);
|
||||
|
||||
insta::assert_ron_snapshot!(format!("map_music_search_suggestion_{}", name), map_res.c);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ pub(crate) use music_details::MusicRelated;
|
|||
pub(crate) use music_item::MusicContinuation;
|
||||
pub(crate) use music_playlist::MusicPlaylist;
|
||||
pub(crate) use music_search::MusicSearch;
|
||||
pub(crate) use music_search::MusicSearchSuggestion;
|
||||
pub(crate) use player::Player;
|
||||
pub(crate) use playlist::Playlist;
|
||||
pub(crate) use playlist::PlaylistCont;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,16 @@ pub(crate) struct MusicSearch {
|
|||
pub contents: Contents,
|
||||
}
|
||||
|
||||
/// Response model for YouTube Music suggestion
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MusicSearchSuggestion {
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub contents: Vec<SearchSuggestionsSection>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Contents {
|
||||
|
|
@ -45,3 +55,21 @@ pub(crate) struct ShowingResultsForRenderer {
|
|||
#[serde_as(as = "Text")]
|
||||
pub corrected_query: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SearchSuggestionsSection {
|
||||
pub search_suggestions_section_renderer: ContentsRenderer<SearchSuggestionItem>,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) enum SearchSuggestionItem {
|
||||
SearchSuggestionRenderer {
|
||||
#[serde_as(as = "Text")]
|
||||
suggestion: String,
|
||||
},
|
||||
#[serde(other, deserialize_with = "deserialize_ignore_any")]
|
||||
None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
source: src/client/music_search.rs
|
||||
expression: map_res.c
|
||||
---
|
||||
[
|
||||
"taylor swift",
|
||||
"tkkg",
|
||||
"techno",
|
||||
"t low",
|
||||
"the weeknd",
|
||||
"tiktok songs",
|
||||
"toten hosen",
|
||||
]
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
source: src/client/music_search.rs
|
||||
expression: map_res.c
|
||||
---
|
||||
[]
|
||||
242
testfiles/music_search/suggestion_default.json
Normal file
242
testfiles/music_search/suggestion_default.json
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
{
|
||||
"contents": [
|
||||
{
|
||||
"searchSuggestionsSectionRenderer": {
|
||||
"contents": [
|
||||
{
|
||||
"searchSuggestionRenderer": {
|
||||
"icon": {
|
||||
"iconType": "SEARCH"
|
||||
},
|
||||
"navigationEndpoint": {
|
||||
"clickTrackingParams": "CAcQpWEYASITCKbj7Pb3yPsCFcfUEQgdRe0MBA==",
|
||||
"searchEndpoint": {
|
||||
"query": "taylor swift"
|
||||
}
|
||||
},
|
||||
"suggestion": {
|
||||
"runs": [
|
||||
{
|
||||
"bold": true,
|
||||
"text": "t"
|
||||
},
|
||||
{
|
||||
"text": "aylor swift"
|
||||
}
|
||||
]
|
||||
},
|
||||
"trackingParams": "CAcQpWEYASITCKbj7Pb3yPsCFcfUEQgdRe0MBA=="
|
||||
}
|
||||
},
|
||||
{
|
||||
"searchSuggestionRenderer": {
|
||||
"icon": {
|
||||
"iconType": "SEARCH"
|
||||
},
|
||||
"navigationEndpoint": {
|
||||
"clickTrackingParams": "CAYQpWEYAiITCKbj7Pb3yPsCFcfUEQgdRe0MBA==",
|
||||
"searchEndpoint": {
|
||||
"query": "tkkg"
|
||||
}
|
||||
},
|
||||
"suggestion": {
|
||||
"runs": [
|
||||
{
|
||||
"bold": true,
|
||||
"text": "t"
|
||||
},
|
||||
{
|
||||
"text": "kkg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"trackingParams": "CAYQpWEYAiITCKbj7Pb3yPsCFcfUEQgdRe0MBA=="
|
||||
}
|
||||
},
|
||||
{
|
||||
"searchSuggestionRenderer": {
|
||||
"icon": {
|
||||
"iconType": "SEARCH"
|
||||
},
|
||||
"navigationEndpoint": {
|
||||
"clickTrackingParams": "CAUQpWEYAyITCKbj7Pb3yPsCFcfUEQgdRe0MBA==",
|
||||
"searchEndpoint": {
|
||||
"query": "techno"
|
||||
}
|
||||
},
|
||||
"suggestion": {
|
||||
"runs": [
|
||||
{
|
||||
"bold": true,
|
||||
"text": "t"
|
||||
},
|
||||
{
|
||||
"text": "echno"
|
||||
}
|
||||
]
|
||||
},
|
||||
"trackingParams": "CAUQpWEYAyITCKbj7Pb3yPsCFcfUEQgdRe0MBA=="
|
||||
}
|
||||
},
|
||||
{
|
||||
"searchSuggestionRenderer": {
|
||||
"icon": {
|
||||
"iconType": "SEARCH"
|
||||
},
|
||||
"navigationEndpoint": {
|
||||
"clickTrackingParams": "CAQQpWEYBCITCKbj7Pb3yPsCFcfUEQgdRe0MBA==",
|
||||
"searchEndpoint": {
|
||||
"query": "t low"
|
||||
}
|
||||
},
|
||||
"suggestion": {
|
||||
"runs": [
|
||||
{
|
||||
"bold": true,
|
||||
"text": "t"
|
||||
},
|
||||
{
|
||||
"text": " low"
|
||||
}
|
||||
]
|
||||
},
|
||||
"trackingParams": "CAQQpWEYBCITCKbj7Pb3yPsCFcfUEQgdRe0MBA=="
|
||||
}
|
||||
},
|
||||
{
|
||||
"searchSuggestionRenderer": {
|
||||
"icon": {
|
||||
"iconType": "SEARCH"
|
||||
},
|
||||
"navigationEndpoint": {
|
||||
"clickTrackingParams": "CAMQpWEYBSITCKbj7Pb3yPsCFcfUEQgdRe0MBA==",
|
||||
"searchEndpoint": {
|
||||
"query": "the weeknd"
|
||||
}
|
||||
},
|
||||
"suggestion": {
|
||||
"runs": [
|
||||
{
|
||||
"bold": true,
|
||||
"text": "t"
|
||||
},
|
||||
{
|
||||
"text": "he weeknd"
|
||||
}
|
||||
]
|
||||
},
|
||||
"trackingParams": "CAMQpWEYBSITCKbj7Pb3yPsCFcfUEQgdRe0MBA=="
|
||||
}
|
||||
},
|
||||
{
|
||||
"searchSuggestionRenderer": {
|
||||
"icon": {
|
||||
"iconType": "SEARCH"
|
||||
},
|
||||
"navigationEndpoint": {
|
||||
"clickTrackingParams": "CAIQpWEYBiITCKbj7Pb3yPsCFcfUEQgdRe0MBA==",
|
||||
"searchEndpoint": {
|
||||
"query": "tiktok songs"
|
||||
}
|
||||
},
|
||||
"suggestion": {
|
||||
"runs": [
|
||||
{
|
||||
"bold": true,
|
||||
"text": "t"
|
||||
},
|
||||
{
|
||||
"text": "iktok songs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"trackingParams": "CAIQpWEYBiITCKbj7Pb3yPsCFcfUEQgdRe0MBA=="
|
||||
}
|
||||
},
|
||||
{
|
||||
"searchSuggestionRenderer": {
|
||||
"icon": {
|
||||
"iconType": "SEARCH"
|
||||
},
|
||||
"navigationEndpoint": {
|
||||
"clickTrackingParams": "CAEQpWEYByITCKbj7Pb3yPsCFcfUEQgdRe0MBA==",
|
||||
"searchEndpoint": {
|
||||
"query": "toten hosen"
|
||||
}
|
||||
},
|
||||
"suggestion": {
|
||||
"runs": [
|
||||
{
|
||||
"bold": true,
|
||||
"text": "t"
|
||||
},
|
||||
{
|
||||
"text": "oten hosen"
|
||||
}
|
||||
]
|
||||
},
|
||||
"trackingParams": "CAEQpWEYByITCKbj7Pb3yPsCFcfUEQgdRe0MBA=="
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"responseContext": {
|
||||
"serviceTrackingParams": [
|
||||
{
|
||||
"params": [
|
||||
{
|
||||
"key": "c",
|
||||
"value": "WEB_REMIX"
|
||||
},
|
||||
{
|
||||
"key": "cver",
|
||||
"value": "1.20221121.01.00"
|
||||
},
|
||||
{
|
||||
"key": "yt_li",
|
||||
"value": "0"
|
||||
},
|
||||
{
|
||||
"key": "GetMusicSearchSuggestions_rid",
|
||||
"value": "0xe343e5421f9bc4f5"
|
||||
}
|
||||
],
|
||||
"service": "CSI"
|
||||
},
|
||||
{
|
||||
"params": [
|
||||
{
|
||||
"key": "logged_in",
|
||||
"value": "0"
|
||||
},
|
||||
{
|
||||
"key": "e",
|
||||
"value": "1714247,9407157,23804281,23882685,23885487,23918597,23934970,23946420,23966208,23983296,23998056,24001373,24002022,24002025,24004644,24007246,24034168,24036948,24077241,24080738,24108448,24120820,24135310,24140247,24161116,24162920,24164186,24169501,24181174,24187043,24187377,24191629,24197450,24199724,24200839,24211178,24217535,24219713,24237297,24241378,24255165,24255543,24255545,24257695,24262346,24263796,24265426,24267564,24268142,24271464,24279196,24282724,24287327,24287604,24288043,24288442,24290971,24292955,24293803,24296354,24298084,24299747,24299875,24390374,24390675,24391018,24391541,24391579,24392268,24392403,24392452,24398048,24401557,24402891,24405024,24406605,24407200,24407452,24407665,24410273,24410853,24412682,24412897,24413820,24414162,24414917,24415579,24415866,24416290,24419371,24420756,24421162,24421894,24422904,24424806,24590921,39322504,39322574"
|
||||
}
|
||||
],
|
||||
"service": "GFEEDBACK"
|
||||
},
|
||||
{
|
||||
"params": [
|
||||
{
|
||||
"key": "client.version",
|
||||
"value": "1.20000101"
|
||||
},
|
||||
{
|
||||
"key": "client.name",
|
||||
"value": "WEB_REMIX"
|
||||
},
|
||||
{
|
||||
"key": "client.fexp",
|
||||
"value": "24257695,24288043,24415579,23882685,24412682,39322574,24287604,24410273,23885487,24217535,24392452,1714247,9407157,24390675,24299747,24401557,39322504,24410853,24191629,24108448,24279196,24255545,24292955,24001373,24161116,24290971,24413820,23934970,24007246,24187377,23998056,24391018,24415866,24293803,24262346,24282724,24398048,24407452,24187043,24407200,24419371,24199724,24421162,24211178,24287327,24265426,24392268,24164186,24241378,24391541,24422904,24255165,24402891,24140247,24416290,23983296,24002025,24414917,24414162,24391579,24237297,24406605,24424806,24255543,24299875,24263796,24298084,24135310,24267564,24004644,24077241,24036948,24405024,24420756,24296354,24590921,24268142,23918597,24390374,24271464,23946420,24197450,24412897,24120820,24421894,23804281,24392403,24181174,24002022,24200839,24288442,24219713,23966208,24034168,24080738,24162920,24169501,24407665"
|
||||
}
|
||||
],
|
||||
"service": "ECATCHER"
|
||||
}
|
||||
],
|
||||
"visitorData": "CgtHV2dSNHFYMDhLSSjZ_4GcBg%3D%3D"
|
||||
},
|
||||
"trackingParams": "CAAQi24iEwim4-z298j7AhXH1BEIHUXtDAQ="
|
||||
}
|
||||
59
testfiles/music_search/suggestion_empty.json
Normal file
59
testfiles/music_search/suggestion_empty.json
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
{
|
||||
"responseContext": {
|
||||
"serviceTrackingParams": [
|
||||
{
|
||||
"params": [
|
||||
{
|
||||
"key": "c",
|
||||
"value": "WEB_REMIX"
|
||||
},
|
||||
{
|
||||
"key": "cver",
|
||||
"value": "1.20221121.01.00"
|
||||
},
|
||||
{
|
||||
"key": "yt_li",
|
||||
"value": "0"
|
||||
},
|
||||
{
|
||||
"key": "GetMusicSearchSuggestions_rid",
|
||||
"value": "0x0a90fd4a664adea1"
|
||||
}
|
||||
],
|
||||
"service": "CSI"
|
||||
},
|
||||
{
|
||||
"params": [
|
||||
{
|
||||
"key": "logged_in",
|
||||
"value": "0"
|
||||
},
|
||||
{
|
||||
"key": "e",
|
||||
"value": "1714245,23804281,23882685,23918597,23934970,23946420,23966208,23983296,23998056,24001373,24002022,24002025,24004644,24007246,24034168,24036948,24077241,24080738,24120819,24135310,24140247,24161116,24162920,24164186,24169501,24181174,24184893,24187043,24187377,24191629,24197450,24199724,24200839,24211178,24217535,24219713,24241378,24255163,24255543,24255545,24262346,24263796,24265426,24267564,24268142,24279196,24280999,24281671,24282722,24287327,24288043,24290971,24292955,24293803,24299747,24299875,24390374,24390675,24391018,24391539,24392399,24400178,24400185,24401291,24401557,24402891,24404641,24406605,24407200,24407665,24412154,24413415,24414074,24414162,24415866,24416291,24416439,24417792,24418790,24420756,24421162,24423785,24426598,24426910,24590921,24591020,39322504,39322574"
|
||||
}
|
||||
],
|
||||
"service": "GFEEDBACK"
|
||||
},
|
||||
{
|
||||
"params": [
|
||||
{
|
||||
"key": "client.version",
|
||||
"value": "1.20000101"
|
||||
},
|
||||
{
|
||||
"key": "client.name",
|
||||
"value": "WEB_REMIX"
|
||||
},
|
||||
{
|
||||
"key": "client.fexp",
|
||||
"value": "24292955,24413415,24077241,24001373,24263796,24267564,24161116,24290971,24412154,24191629,24420756,23882685,24299747,24288043,24591020,24184893,24414162,24255543,24406605,24200839,24287327,24280999,24407665,23946420,23804281,24390374,24140247,24414074,23966208,24080738,24418790,23983296,24219713,24164186,24187043,24400178,24400185,24404641,24426910,23998056,24391539,24007246,24401291,24293803,24135310,24255163,24268142,23918597,24187377,24004644,24390675,24255545,24036948,24423785,24392399,24417792,24299875,1714245,24217535,39322574,24281671,24002025,24426598,24199724,24401557,39322504,24421162,24120819,24211178,24265426,24402891,24391018,24181174,24002022,24416439,24407200,24034168,24415866,24282722,24241378,24162920,24416291,23934970,24169501,24590921,24279196,24262346,24197450"
|
||||
}
|
||||
],
|
||||
"service": "ECATCHER"
|
||||
}
|
||||
],
|
||||
"visitorData": "Cgtyb1QtdzRqZXZGayisj4KcBg%3D%3D"
|
||||
},
|
||||
"trackingParams": "CAAQi24iEwi6jvCx_8j7AhWbyxEIHYe1Bno="
|
||||
}
|
||||
|
|
@ -1798,6 +1798,29 @@ async fn music_search_genre_radio() {
|
|||
rp.query().music_search("pop radio").await.unwrap();
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::default("ed sheer", Some("ed sheeran"))]
|
||||
#[case::empty("reujbhevmfndxnjrze", None)]
|
||||
#[tokio::test]
|
||||
async fn music_search_suggestion(#[case] query: &str, #[case] expect: Option<&str>) {
|
||||
let rp = RustyPipe::builder().strict().build();
|
||||
let suggestion = rp.query().music_search_suggestion(query).await.unwrap();
|
||||
|
||||
match expect {
|
||||
Some(expect) => assert!(
|
||||
suggestion.iter().any(|s| s == expect),
|
||||
"suggestion: {:?}, expected: {}",
|
||||
suggestion,
|
||||
expect
|
||||
),
|
||||
None => assert!(
|
||||
suggestion.is_empty(),
|
||||
"suggestion: {:?}, expected to be empty",
|
||||
suggestion
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::mv("mv", "ZeerrnuLi5E")]
|
||||
#[case::track("track", "7nigXQS1Xb0")]
|
||||
|
|
|
|||
Reference in a new issue