use std::collections::BTreeSet;
use futures_util::stream::{self, StreamExt};
use suno_core::{
AreaKind, AreaListing, Clip, LIKED_PLAYLIST_ID, PlaylistDesired, PlaylistInput, SourceMode,
SunoClient, build_playlist_desired, is_downloadable,
};
use crate::cli::args::SyncArgs;
use crate::cli::desired::{
ExitCode, PlaylistPolicy, ResolvedSelection, is_narrowed, resolve_playlist,
};
use crate::cli::failure;
use crate::cli::task_output::eprint_t;
use crate::clock::TokioClock;
use crate::http::ReqwestHttp;
pub(crate) async fn enumerate_areas(
selection: &ResolvedSelection,
client: &SunoClient<TokioClock>,
http: &ReqwestHttp,
label: &str,
args: &SyncArgs,
verbosity: i8,
concurrency: u32,
) -> std::result::Result<Vec<AreaListing>, ExitCode> {
let mut areas: Vec<AreaListing> = Vec::new();
let narrowed = is_narrowed(args.limit, args.since.as_deref());
if let Some(lib) = selection.library {
if lib.unfiltered {
match client.list_clips(http, false, None).await {
Ok((clips, complete, any_filtered)) => areas.push(AreaListing::listed(
AreaKind::Library,
lib.mode,
clips,
complete,
any_filtered,
false,
)),
Err(err) => {
if verbosity >= -1 {
eprint_t!(
"warning: library listing failed ({err}); suppressing deletion this run"
);
}
areas.push(AreaListing::failed(AreaKind::Library, lib.mode));
}
}
} else {
match client.list_clips(http, false, args.limit).await {
Ok((clips, complete, any_filtered)) => areas.push(AreaListing::listed(
AreaKind::Library,
lib.mode,
clips,
complete,
any_filtered,
narrowed,
)),
Err(err) => return Err(failure::report_listing_failure(label, &err)),
}
}
}
if let Some(mode) = selection.liked {
match client.list_clips(http, true, None).await {
Ok((clips, complete, any_filtered)) => areas.push(AreaListing::listed(
AreaKind::Liked,
mode,
clips,
complete,
any_filtered,
narrowed,
)),
Err(err) => {
if verbosity >= -1 {
eprint_t!(
"warning: liked feed failed to list ({err}); suppressing deletion this run"
);
}
areas.push(AreaListing::failed(AreaKind::Liked, mode));
}
}
}
if !matches!(selection.playlists, PlaylistPolicy::None) {
let playlists = match client.get_playlists(http).await {
Ok(playlists) => Some(playlists),
Err(err) => {
if selection.cli_scoped {
return Err(failure::report_listing_failure(label, &err));
}
if verbosity >= -1 {
eprint_t!(
"warning: playlist listing failed ({err}); suppressing deletion this run"
);
}
None
}
};
match (&selection.playlists, playlists) {
(PlaylistPolicy::Explicit(list), Some(pls)) => {
let mut to_fetch: Vec<(String, String, SourceMode)> = Vec::new();
for (value, mode) in list {
let playlist = match resolve_playlist(value, &pls) {
Ok(playlist) => playlist,
Err(err) => {
if selection.cli_scoped {
eprint_t!("error: {err}.");
print_visible_playlists(&pls, verbosity);
return Err(ExitCode::Config);
}
if verbosity >= -1 {
eprint_t!(
"warning: a configured playlist could not be resolved ({err}); leaving its .m3u8 untouched"
);
}
areas.push(AreaListing::unresolved_playlist(*mode));
continue;
}
};
to_fetch.push((playlist.id.clone(), playlist.name.clone(), *mode));
}
let fetched = stream::iter(to_fetch)
.map(|(id, name, mode)| async move {
list_playlist_area(client, http, &id, &name, mode, narrowed, verbosity)
.await
})
.buffered(concurrency.max(1) as usize)
.collect::<Vec<_>>()
.await;
areas.extend(fetched);
}
(PlaylistPolicy::All { default, overrides }, Some(pls)) => {
let to_fetch: Vec<(String, String, SourceMode)> = pls
.iter()
.map(|playlist| {
(
playlist.id.clone(),
playlist.name.clone(),
overrides.get(&playlist.id).copied().unwrap_or(*default),
)
})
.collect();
let fetched = stream::iter(to_fetch)
.map(|(id, name, mode)| async move {
list_playlist_area(client, http, &id, &name, mode, narrowed, verbosity)
.await
})
.buffered(concurrency.max(1) as usize)
.collect::<Vec<_>>()
.await;
areas.extend(fetched);
}
(PlaylistPolicy::Explicit(list), None) => {
for (_, mode) in list {
areas.push(AreaListing::unresolved_playlist(*mode));
}
}
(PlaylistPolicy::All { default, .. }, None) => {
areas.push(AreaListing::unresolved_playlist(*default));
}
(PlaylistPolicy::None, _) => {}
}
}
Ok(areas)
}
async fn list_playlist_area(
client: &SunoClient<TokioClock>,
http: &ReqwestHttp,
id: &str,
name: &str,
mode: SourceMode,
narrowed: bool,
verbosity: i8,
) -> AreaListing {
match client.get_playlist_clips(http, id).await {
Ok((raw, complete)) => {
let raw_len = raw.len();
let clips: Vec<Clip> = raw.into_iter().filter(is_downloadable).collect();
let any_filtered = clips.len() < raw_len;
AreaListing::listed(
AreaKind::Playlist {
id: id.to_owned(),
name: name.to_owned(),
},
mode,
clips,
complete,
any_filtered,
narrowed,
)
}
Err(err) => {
if verbosity >= -1 {
eprint_t!(
"warning: playlist '{name}' members failed to list ({err}); suppressing deletion this run"
);
}
AreaListing::failed(
AreaKind::Playlist {
id: id.to_owned(),
name: name.to_owned(),
},
mode,
)
}
}
}
fn print_visible_playlists(playlists: &[suno_core::Playlist], verbosity: i8) {
if verbosity < -1 {
return;
}
if playlists.is_empty() {
eprint_t!("no playlists are visible for this account.");
return;
}
eprint_t!("visible playlists:");
for playlist in playlists {
eprint_t!(" {} ({})", playlist.name, playlist.id);
}
}
pub(crate) async fn fetch_playlist_desired(
client: &SunoClient<TokioClock>,
http: &ReqwestHttp,
desired: &[suno_core::Desired],
protected: &mut BTreeSet<String>,
verbosity: i8,
concurrency: u32,
) -> (Vec<PlaylistDesired>, bool) {
let playlists = match client.get_playlists(http).await {
Ok(playlists) => playlists,
Err(err) => {
if verbosity >= -1 {
eprint_t!(
"warning: playlist listing failed ({err}); leaving existing .m3u8 files untouched"
);
}
return (Vec::new(), false);
}
};
let mut fetched: Vec<(String, String, Vec<Clip>)> = Vec::new();
let member_results = stream::iter(playlists.iter())
.map(|playlist| async move {
(
playlist.id.clone(),
playlist.name.clone(),
client.get_playlist_clips(http, &playlist.id).await,
)
})
.buffered(concurrency.max(1) as usize)
.collect::<Vec<_>>()
.await;
for (id, name, result) in member_results {
match result {
Ok((members, true)) => fetched.push((id, name, members)),
Ok((_, false)) => {
if verbosity >= -1 {
eprint_t!(
"warning: playlist '{}' returned an incomplete member page; keeping its .m3u8 unchanged",
name
);
}
protected.insert(id);
}
Err(err) => {
if verbosity >= -1 {
eprint_t!(
"warning: playlist '{}' members failed to list ({err}); keeping its .m3u8 unchanged",
name
);
}
protected.insert(id);
}
}
}
match client.list_clips(http, true, None).await {
Ok((liked, true, _)) => {
fetched.push((
LIKED_PLAYLIST_ID.to_owned(),
"Liked Songs".to_owned(),
liked,
));
}
Ok((_, false, _)) => {
if verbosity >= -1 {
eprint_t!("warning: liked feed was truncated; keeping Liked Songs.m3u8 unchanged");
}
protected.insert(LIKED_PLAYLIST_ID.to_owned());
}
Err(err) => {
if verbosity >= -1 {
eprint_t!(
"warning: liked feed failed to list ({err}); keeping Liked Songs.m3u8 unchanged"
);
}
protected.insert(LIKED_PLAYLIST_ID.to_owned());
}
}
let inputs: Vec<PlaylistInput<'_>> = fetched
.iter()
.map(|(id, name, members)| PlaylistInput {
id: id.as_str(),
name: name.as_str(),
members: members.as_slice(),
})
.collect();
(build_playlist_desired(&inputs, desired), true)
}