use std::ffi::OsStr;
use std::process::Command;
use std::time::{Duration, Instant};
use super::error::{BackendError, Result};
use super::types::*;
use crate::proc;
const STREAM_FORMAT: AudioFormat = AudioFormat::Mp3(Some(128));
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Cookies {
#[default]
None,
Browser(String),
File(String),
Conflict,
}
impl Cookies {
pub fn from_config(browser: &str, file: &str) -> Self {
match (browser.trim(), file.trim()) {
("", "") => Self::None,
(b, "") => Self::Browser(b.to_string()),
("", f) => Self::File(
crate::paths::expand_tilde(f)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| f.to_string()),
),
_ => Self::Conflict,
}
}
fn args(&self) -> Vec<&str> {
match self {
Self::Browser(b) => vec!["--cookies-from-browser", b],
Self::File(f) => vec!["--cookies", f],
Self::None | Self::Conflict => Vec::new(),
}
}
fn is_configured(&self) -> bool {
matches!(self, Self::Browser(_) | Self::File(_))
}
}
fn looks_like_header_string(raw: &str) -> bool {
let raw = raw.trim();
!raw.is_empty()
&& !raw.contains('\t')
&& !raw.starts_with('#')
&& raw.lines().count() == 1
&& raw.contains('=')
}
pub struct SoundCloud {
yt_dlp: String,
cookies: Cookies,
file_error: Option<String>,
extra_args: Vec<String>,
budget: Duration,
}
impl SoundCloud {
pub fn new(
yt_dlp: impl Into<String>,
cookies: Cookies,
extra_args: Vec<String>,
budget: Duration,
) -> Self {
let file_error = match &cookies {
Cookies::File(path) => Self::check_jar(path),
_ => None,
};
Self {
yt_dlp: yt_dlp.into(),
cookies,
file_error,
extra_args,
budget,
}
}
fn check_jar(path: &str) -> Option<String> {
match std::fs::read_to_string(path) {
Err(e) => Some(format!("cannot read {path}: {e}")),
Ok(raw) if raw.trim().is_empty() => Some(format!("{path} is empty")),
Ok(raw) if looks_like_header_string(&raw) => Some(format!(
"{path} is a `document.cookie` string, not a Netscape cookie jar"
)),
Ok(_) => None,
}
}
fn ytdlp<I, S>(&self, args: I) -> Result<Command>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
if self.cookies == Cookies::Conflict {
return Err(BackendError::NoCredentials {
backend: BackendId::SoundCloud,
how_to_fix: "cookies_from_browser and cookies_file are both set; \
clear one of them"
.into(),
});
}
if let Some(detail) = &self.file_error {
return Err(BackendError::CredentialsUnusable {
backend: BackendId::SoundCloud,
detail: detail.clone(),
how_to_fix: "export a Netscape cookie jar with a cookies.txt \
browser extension — a `document.cookie` dump cannot \
include HttpOnly cookies and may be missing the \
session entirely"
.into(),
});
}
let mut cmd = proc::capture(&self.yt_dlp);
cmd.args(args)
.args(self.cookies.args())
.args(&self.extra_args);
Ok(cmd)
}
fn audit_cookies(&self, stderr: &[u8]) -> Result<()> {
if !self.cookies.is_configured() {
return Ok(());
}
let text = String::from_utf8_lossy(stderr);
let unusable = |detail: &str, how_to_fix: &str| BackendError::CredentialsUnusable {
backend: BackendId::SoundCloud,
detail: detail.trim().to_string(),
how_to_fix: how_to_fix.to_string(),
};
for line in text.lines() {
let lower = line.to_ascii_lowercase();
if lower.contains("extracted 0 cookies") {
return Err(unusable(
line,
"nothing decrypted — check the profile path, and that the \
browser has been signed in at least once",
));
}
if missed_original(line) {
return Err(unusable(
line,
"cookies loaded but SoundCloud does not treat this session \
as signed in — the session cookies are probably among the \
ones that failed to decrypt; export a cookie jar and use \
cookies_file instead",
));
}
if lower.contains("could not be decrypted") || lower.contains("failed to decrypt") {
return Err(unusable(
line,
"the cookie encryption key does not match this browser — a \
Chromium fork read via `chromium:<path>` keeps its key under \
its own keychain name, and the names survive while the values \
do not; export a cookie jar and use cookies_file instead",
));
}
}
Ok(())
}
fn run_json(&self, args: &[&str]) -> Result<(String, String)> {
let cmd = self.ytdlp(args)?;
let out = proc::run_with_deadline(cmd, Instant::now() + self.budget).map_err(|e| {
let msg = e.to_string();
if msg.contains("not found") {
BackendError::ToolMissing {
tool: self.yt_dlp.clone(),
}
} else if msg.contains("timed out") {
BackendError::Timeout {
backend: BackendId::SoundCloud,
op: "yt-dlp",
elapsed: self.budget,
}
} else {
BackendError::ToolFailed {
tool: self.yt_dlp.clone(),
detail: msg,
}
}
})?;
if !out.status.success() {
return Err(classify_ytdlp_failure(&self.yt_dlp, &out.stderr));
}
self.audit_cookies(&out.stderr)?;
Ok((
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
))
}
}
fn missed_original(stderr: &str) -> bool {
stderr.contains("only available for registered users")
}
fn classify_ytdlp_failure(tool: &str, stderr: &[u8]) -> BackendError {
let tail = proc::stderr_tail(stderr);
let lower = tail.to_ascii_lowercase();
if lower.contains("unsupported browser") {
return BackendError::CredentialsUnusable {
backend: BackendId::SoundCloud,
detail: tail,
how_to_fix: "cookies_from_browser must name a browser yt-dlp knows; \
for a Chromium fork use `chromium:/absolute/path/to/profile`"
.into(),
};
}
if lower.contains("drm") {
return BackendError::Unsupported {
backend: BackendId::SoundCloud,
op: "fetch (DRM-protected track)",
};
}
if lower.contains("404") || lower.contains("not found") || lower.contains("unavailable") {
return BackendError::NotFound {
backend: BackendId::SoundCloud,
item: tail,
};
}
if lower.contains("429") || lower.contains("too many requests") {
return BackendError::RateLimited {
backend: BackendId::SoundCloud,
retry_after: None,
};
}
BackendError::ToolFailed {
tool: tool.to_string(),
detail: tail,
}
}
fn parse_search(json: &str, limit: usize) -> Result<Vec<Offer>> {
let v: serde_json::Value = serde_json::from_str(json.trim()).map_err(|e| {
BackendError::parse(BackendId::SoundCloud, "yt-dlp search json", e.to_string())
})?;
let entries = v["entries"].as_array().cloned().unwrap_or_default();
let mut offers = Vec::new();
for e in entries {
if offers.len() >= limit {
break;
}
let (Some(id), Some(title)) = (
e["id"].as_str().map(str::to_string),
e["title"].as_str().map(str::to_string),
) else {
continue;
};
let url = e["webpage_url"]
.as_str()
.map(str::to_string)
.unwrap_or_else(|| format!("https://api.soundcloud.com/tracks/{id}"));
let mut offer = Offer::new(
ItemRef::new(BackendId::SoundCloud, format!("track/{id}")),
ItemKind::Track,
e["uploader"].as_str().unwrap_or_default().to_string(),
title,
url,
);
offer.duration_secs = e["duration"].as_f64().map(|d| d.round() as i64);
offer.pricing = Pricing::Free;
offer.ownership = Ownership::NotApplicable;
offers.push(offer);
}
Ok(offers)
}
fn parse_formats(json: &str) -> Result<Vec<AudioFormat>> {
let v: serde_json::Value = serde_json::from_str(json.trim()).map_err(|e| {
BackendError::parse(BackendId::SoundCloud, "yt-dlp format json", e.to_string())
})?;
let formats = v["formats"].as_array().cloned().unwrap_or_default();
let mut out = Vec::new();
for f in &formats {
let id = f["format_id"].as_str().unwrap_or_default();
let ext = f["ext"].as_str().unwrap_or_default();
if id == "download" {
if let Ok(fmt) = ext.parse::<AudioFormat>() {
out.push(fmt);
continue;
}
}
let abr = f["abr"].as_f64().map(|b| b.round() as u16);
match ext {
"mp3" => out.push(AudioFormat::Mp3(abr)),
"opus" => out.push(AudioFormat::Opus),
"m4a" | "aac" => out.push(AudioFormat::Aac(abr)),
"ogg" => out.push(AudioFormat::Ogg),
_ => {}
}
}
out.dedup();
if out.is_empty() {
out.push(STREAM_FORMAT);
}
Ok(out)
}
pub fn track_id(s: &str) -> Option<String> {
let digits =
|v: &str| (!v.is_empty() && v.bytes().all(|b| b.is_ascii_digit())).then(|| v.to_string());
if let Some(rest) = s.strip_prefix("soundcloud:tracks:") {
return digits(rest);
}
if let Some(rest) = s.strip_prefix("track/") {
return digits(rest);
}
if let Some(rest) = s.rsplit("/tracks/").next().filter(|r| *r != s) {
let rest = rest.rsplit("%3A").next().unwrap_or(rest);
return digits(rest);
}
None
}
pub fn api_url(id: &str) -> String {
format!("https://api.soundcloud.com/tracks/{id}")
}
impl super::AcquisitionBackend for SoundCloud {
fn id(&self) -> BackendId {
BackendId::SoundCloud
}
fn capabilities(&self) -> Capabilities {
Capabilities {
search: true,
price_quotes: false,
ownership_check: false,
requires_purchase: false,
fetch: true,
lossless_capable: self.cookies.is_configured(),
}
}
fn credentials(&self) -> CredentialState {
match &self.cookies {
Cookies::None => CredentialState::NotRequired,
Cookies::Browser(b) => CredentialState::Present {
hint: format!("cookies from {b}"),
},
Cookies::File(f) => CredentialState::Present {
hint: format!("cookie jar {f}"),
},
Cookies::Conflict => CredentialState::Malformed {
detail: "cookies_from_browser and cookies_file are both set".into(),
},
}
}
fn claim_url(&self, url: &str) -> Option<ItemRef> {
if let Some(id) = track_id(url) {
return Some(ItemRef::new(BackendId::SoundCloud, format!("track/{id}")));
}
(url.contains("soundcloud.com/") && !url.contains("/sets/"))
.then(|| ItemRef::new(BackendId::SoundCloud, format!("url/{url}")))
}
fn search(&self, query: &SearchQuery) -> Result<Vec<Offer>> {
let text = query.search_text();
if text.is_empty() || query.limit == 0 {
return Ok(Vec::new());
}
let target = format!("scsearch{}:{text}", query.limit);
let (json, _) = self.run_json(&["--flat-playlist", "--dump-single-json", &target])?;
parse_search(&json, query.limit)
}
fn enrich(&self, offers: &mut [Offer]) -> Result<()> {
for offer in offers.iter_mut() {
let Some(id) = track_id(&offer.item_ref.key) else {
offer.formats = Some(vec![STREAM_FORMAT]);
continue;
};
match self.run_json(&["--dump-single-json", &api_url(&id)]) {
Ok((json, stderr)) => match parse_formats(&json) {
Ok(formats) => {
offer.formats = Some(formats);
if missed_original(&stderr) {
offer.enrich_error = Some(
"this track has an artist-enabled original, which \
needs a signed-in session — set \
soundcloud.cookies_from_browser"
.into(),
);
}
}
Err(e) => offer.enrich_error = Some(e.to_string()),
},
Err(e) => offer.enrich_error = Some(e.to_string()),
}
}
Ok(())
}
fn purchase(&self, _item: &ItemRef) -> Result<PurchaseFlow> {
Ok(PurchaseFlow::NotRequired)
}
fn fetch(&self, item: &ItemRef, opts: &FetchOpts) -> Result<Vec<AcquiredFile>> {
let target = fetch_target(item).ok_or_else(|| BackendError::NotFound {
backend: BackendId::SoundCloud,
item: item.key.clone(),
})?;
std::fs::create_dir_all(&opts.dest_dir)?;
let staging = opts.dest_dir.join(format!(".rr-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&staging)?;
let staging = StagingDir(staging);
let out_template = staging.0.join("%(uploader)s - %(title)s.%(ext)s");
let mut args: Vec<String> = vec![
"--no-playlist".into(),
"--no-progress".into(),
"-f".into(),
"download/bestaudio/best".into(),
"--print-json".into(),
"-o".into(),
out_template.to_string_lossy().into_owned(),
target.clone(),
];
let remaining = opts
.deadline
.saturating_duration_since(Instant::now())
.max(Duration::from_secs(1));
let cmd = self.ytdlp(args.drain(..))?;
let out = proc::run_with_deadline(cmd, Instant::now() + remaining).map_err(|e| {
BackendError::ToolFailed {
tool: self.yt_dlp.clone(),
detail: e.to_string(),
}
})?;
if !out.status.success() {
return Err(classify_ytdlp_failure(&self.yt_dlp, &out.stderr));
}
self.audit_cookies(&out.stderr)?;
let produced = newest_audio_file(&staging.0)?;
let format = produced
.extension()
.and_then(|e| e.to_str())
.and_then(|e| e.parse::<AudioFormat>().ok())
.unwrap_or(STREAM_FORMAT);
let bytes = std::fs::metadata(&produced)?.len();
if bytes == 0 {
return Err(BackendError::ToolFailed {
tool: self.yt_dlp.clone(),
detail: "produced an empty file".into(),
});
}
let (artist, title) = parse_download_metadata(&String::from_utf8_lossy(&out.stdout));
let final_path = super::fs::place(&produced, &opts.dest_dir, opts.overwrite)?;
Ok(vec![AcquiredFile {
path: final_path,
format,
bytes,
retention: opts.retention,
source: item.clone(),
source_url: target,
artist,
title,
album: None,
track_number: None,
}])
}
}
struct StagingDir(std::path::PathBuf);
impl Drop for StagingDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn parse_download_metadata(stdout: &str) -> (Option<String>, Option<String>) {
for line in stdout.lines().rev() {
let line = line.trim();
if !line.starts_with('{') {
continue;
}
if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
let artist = ["artist", "uploader", "creator"]
.iter()
.find_map(|k| v[*k].as_str())
.map(str::to_string);
let title = ["track", "title"]
.iter()
.find_map(|k| v[*k].as_str())
.map(str::to_string);
return (artist, title);
}
}
(None, None)
}
fn fetch_target(item: &ItemRef) -> Option<String> {
if let Some(url) = item.key.strip_prefix("url/") {
return Some(url.to_string());
}
track_id(&item.key).map(|id| api_url(&id))
}
fn newest_audio_file(dir: &std::path::Path) -> Result<std::path::PathBuf> {
let mut best: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
if ext.is_empty() || matches!(ext.as_str(), "part" | "ytdl" | "temp" | "json") {
continue;
}
let modified = entry.metadata().and_then(|m| m.modified())?;
if best.as_ref().map(|(t, _)| modified > *t).unwrap_or(true) {
best = Some((modified, path));
}
}
best.map(|(_, p)| p)
.ok_or_else(|| BackendError::ToolFailed {
tool: "yt-dlp".into(),
detail: "finished without writing an audio file".into(),
})
}
#[cfg(test)]
mod tests {
use super::super::AcquisitionBackend;
use super::*;
const SEARCH_FIXTURE: &str = r#"{"entries":[
{"id":"46289525","title":"Neneh Cherry - Dream Baby Dream (Four Tet remix)",
"uploader":"Four Tet","duration":530.125,
"webpage_url":"https://soundcloud.com/four-tet/neneh-cherry-the-thing-dream"},
{"id":"749301292","title":"Four Tet - Baby","uploader":"sound selector","duration":265.102,
"webpage_url":"https://soundcloud.com/vybeworld/four-tet-baby-1"}
]}"#;
fn sc() -> SoundCloud {
SoundCloud::new("yt-dlp", Cookies::None, vec![], Duration::from_secs(5))
}
fn missing_tool(cookies: Cookies) -> SoundCloud {
SoundCloud::new(
"definitely-not-yt-dlp-xyzzy",
cookies,
vec![],
Duration::from_secs(5),
)
}
#[test]
fn parses_a_flat_playlist_search() {
let offers = parse_search(SEARCH_FIXTURE, 10).unwrap();
assert_eq!(offers.len(), 2);
assert_eq!(offers[0].artist, "Four Tet");
assert_eq!(offers[0].duration_secs, Some(530));
assert_eq!(offers[0].item_ref.to_string(), "soundcloud:track/46289525");
}
#[test]
fn soundcloud_offers_are_free_and_ownership_does_not_apply() {
let o = &parse_search(SEARCH_FIXTURE, 1).unwrap()[0];
assert_eq!(o.pricing, Pricing::Free);
assert_eq!(o.ownership, Ownership::NotApplicable);
assert!(!o.requires_purchase());
assert_eq!(o.cost_class(), CostClass::Free);
}
#[test]
fn search_leaves_formats_unprobed() {
assert_eq!(parse_search(SEARCH_FIXTURE, 1).unwrap()[0].formats, None);
}
#[test]
fn search_respects_the_limit() {
assert_eq!(parse_search(SEARCH_FIXTURE, 1).unwrap().len(), 1);
assert_eq!(parse_search(SEARCH_FIXTURE, 0).unwrap().len(), 0);
}
#[test]
fn entries_missing_an_id_or_title_are_skipped() {
let json = r#"{"entries":[{"id":"1"},{"id":"2","title":"ok","uploader":"u"}]}"#;
let offers = parse_search(json, 10).unwrap();
assert_eq!(offers.len(), 1);
assert_eq!(offers[0].title, "ok");
}
#[test]
fn an_empty_search_is_success_not_failure() {
assert!(parse_search(r#"{"entries":[]}"#, 5).unwrap().is_empty());
assert!(parse_search(r#"{}"#, 5).unwrap().is_empty());
}
#[test]
fn malformed_json_reports_a_shape_change() {
let err = parse_search("not json", 5).unwrap_err();
assert!(matches!(err, BackendError::Parse { .. }));
assert!(!err.is_retryable());
}
#[test]
fn a_transcode_only_track_is_reported_as_lossy() {
let json = r#"{"formats":[
{"format_id":"hls_mp3_0_0","ext":"mp3","abr":128.0},
{"format_id":"http_mp3_0_0","ext":"mp3","abr":128.0},
{"format_id":"hls_opus_0_0","ext":"opus","abr":64.0}]}"#;
let f = parse_formats(json).unwrap();
assert!(f.contains(&AudioFormat::Mp3(Some(128))));
assert!(
!f.iter().any(|x| x.is_lossless()),
"a transcode is never lossless"
);
}
#[test]
fn an_artist_enabled_original_is_detected_as_lossless() {
let json = r#"{"formats":[
{"format_id":"hls_mp3_0_0","ext":"mp3","abr":128.0},
{"format_id":"download","ext":"flac"}]}"#;
let f = parse_formats(json).unwrap();
assert!(f.contains(&AudioFormat::Flac));
assert!(f.iter().any(|x| x.is_lossless()));
}
#[test]
fn unreadable_formats_fall_back_to_the_streaming_baseline() {
assert_eq!(
parse_formats(r#"{"formats":[]}"#).unwrap(),
vec![STREAM_FORMAT]
);
assert_eq!(parse_formats(r#"{}"#).unwrap(), vec![STREAM_FORMAT]);
}
#[test]
fn drm_protected_tracks_are_unsupported_not_broken() {
let err = classify_ytdlp_failure(
"yt-dlp",
b"ERROR: [soundcloud] 271226416: This video is DRM protected",
);
assert!(
matches!(err, BackendError::Unsupported { .. }),
"got {err:?}"
);
assert!(err.is_silently_skippable());
}
#[test]
fn missing_tracks_and_rate_limits_are_classified_separately() {
assert!(matches!(
classify_ytdlp_failure("yt-dlp", b"ERROR: HTTP Error 404: Not Found"),
BackendError::NotFound { .. }
));
let rl = classify_ytdlp_failure("yt-dlp", b"ERROR: HTTP Error 429: Too Many Requests");
assert!(matches!(rl, BackendError::RateLimited { .. }));
assert!(
rl.is_retryable(),
"a rate limit should back off, not give up"
);
}
#[test]
fn an_unrecognised_failure_keeps_the_tools_own_message() {
match classify_ytdlp_failure("yt-dlp", b"ERROR: something entirely new") {
BackendError::ToolFailed { detail, .. } => {
assert!(detail.contains("something entirely new"), "got: {detail}")
}
other => panic!("got {other:?}"),
}
}
#[test]
fn extracts_track_ids_from_every_form_we_encounter() {
assert_eq!(
track_id("soundcloud:tracks:1803453465").as_deref(),
Some("1803453465")
);
assert_eq!(track_id("track/46289525").as_deref(), Some("46289525"));
assert_eq!(
track_id("https://api.soundcloud.com/tracks/46289525").as_deref(),
Some("46289525")
);
assert_eq!(
track_id("https://api.soundcloud.com/tracks/soundcloud%3Atracks%3A46289525").as_deref(),
Some("46289525")
);
}
#[test]
fn rejects_things_that_are_not_track_ids() {
assert_eq!(track_id("apple-music:tracks:123"), None);
assert_eq!(track_id("soundcloud:tracks:notanumber"), None);
assert_eq!(track_id("soundcloud:tracks:"), None);
assert_eq!(track_id("https://soundcloud.com/artist/song"), None);
}
#[test]
fn api_url_is_the_form_yt_dlp_accepts() {
assert_eq!(
api_url("1803453465"),
"https://api.soundcloud.com/tracks/1803453465"
);
}
#[test]
fn claims_track_urls_and_ids_but_not_playlists() {
let sc = sc();
assert_eq!(
sc.claim_url("https://api.soundcloud.com/tracks/46289525")
.unwrap()
.to_string(),
"soundcloud:track/46289525"
);
assert!(
sc.claim_url("https://soundcloud.com/four-tet/song")
.is_some()
);
assert!(
sc.claim_url("https://soundcloud.com/four-tet/sets/mix")
.is_none()
);
assert!(
sc.claim_url("https://burial.bandcamp.com/album/untrue")
.is_none()
);
}
#[test]
fn capabilities_advertise_that_an_anonymous_rip_is_never_lossless() {
let c = sc().capabilities();
assert!(c.search && c.fetch);
assert!(!c.lossless_capable);
assert!(!c.requires_purchase);
assert!(!c.price_quotes, "everything is free, so there is no price");
}
#[test]
fn cookies_make_the_backend_worth_searching_for_lossless() {
assert!(
missing_tool(Cookies::Browser("firefox".into()))
.capabilities()
.lossless_capable
);
assert!(
missing_tool(Cookies::File("/tmp/cookies.txt".into()))
.capabilities()
.lossless_capable
);
}
#[test]
fn cookie_config_maps_to_the_right_yt_dlp_flags() {
assert_eq!(Cookies::from_config("", ""), Cookies::None);
assert_eq!(Cookies::from_config(" ", ""), Cookies::None);
assert_eq!(
Cookies::from_config("firefox", "").args(),
["--cookies-from-browser", "firefox"]
);
assert_eq!(
Cookies::from_config("", " /tmp/c.txt ").args(),
["--cookies", "/tmp/c.txt"]
);
assert!(Cookies::None.args().is_empty());
}
#[test]
fn a_cookie_jar_path_expands_a_leading_tilde() {
let home = std::env::var("HOME").unwrap();
assert_eq!(
Cookies::from_config("", "~/sc_cookies.txt"),
Cookies::File(format!("{home}/sc_cookies.txt"))
);
}
#[test]
fn setting_both_cookie_sources_is_refused_not_silently_resolved() {
let both = Cookies::from_config("firefox", "/tmp/c.txt");
assert_eq!(both, Cookies::Conflict);
let sc = missing_tool(both);
let err = sc
.search(&SearchQuery::from_text("anything", 1))
.unwrap_err();
assert!(
matches!(err, BackendError::NoCredentials { .. }),
"got {err:?}"
);
assert!(err.needs_user_action());
assert!(matches!(
sc.credentials(),
CredentialState::Malformed { .. }
));
assert!(!sc.credentials().is_usable());
}
#[test]
fn every_invocation_carries_the_same_auth() {
let sc = SoundCloud::new(
"yt-dlp",
Cookies::Browser("firefox".into()),
vec!["--sleep-requests".into(), "1".into()],
Duration::from_secs(5),
);
let args = |c: Command| {
c.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect::<Vec<_>>()
};
let probe = args(sc.ytdlp(["--dump-single-json", "url"]).unwrap());
assert_eq!(
probe,
[
"--dump-single-json",
"url",
"--cookies-from-browser",
"firefox",
"--sleep-requests",
"1"
]
);
assert_eq!(
&args(sc.ytdlp(["-f", "download"]).unwrap())[2..],
&probe[2..]
);
}
#[test]
fn a_partly_decrypted_jar_is_fatal_because_the_names_outlive_the_values() {
let sc = missing_tool(Cookies::Browser("chromium:/tmp/helium".into()));
let err = sc
.audit_cookies(
b"WARNING: failed to decrypt cookie (AES-CBC) because UTF-8 decoding failed\n\
Extracted 2192 cookies from chromium (1537 could not be decrypted)",
)
.unwrap_err();
match &err {
BackendError::CredentialsUnusable { how_to_fix, .. } => {
assert!(how_to_fix.contains("cookies_file"), "got {how_to_fix}")
}
other => panic!("got {other:?}"),
}
assert!(err.needs_user_action());
assert!(!err.is_retryable(), "a wrong key is wrong on the retry too");
assert!(!err.is_silently_skippable(), "this must not be swallowed");
}
#[test]
fn a_session_soundcloud_does_not_accept_is_an_error_not_a_quiet_downgrade() {
let sc = missing_tool(Cookies::Browser("chromium:/tmp/helium".into()));
let err = sc
.audit_cookies(
b"WARNING: Original download format is only available for registered users.",
)
.unwrap_err();
match &err {
BackendError::CredentialsUnusable { how_to_fix, .. } => {
assert!(how_to_fix.contains("cookies_file"), "got {how_to_fix}")
}
other => panic!("got {other:?}"),
}
assert!(err.needs_user_action());
assert!(!err.is_retryable(), "a wrong key is wrong on the retry too");
assert!(!err.is_silently_skippable(), "this must not be swallowed");
assert_eq!(err.backend(), Some(BackendId::SoundCloud));
}
#[test]
fn a_document_cookie_dump_is_rejected_with_the_real_instructions() {
let dir = std::env::temp_dir().join(format!("rr-sc-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("header.txt");
std::fs::write(
&path,
"sc_theme=dark; oauth_token=2-314159; sc_session=abc\n",
)
.unwrap();
let sc = SoundCloud::new(
"yt-dlp",
Cookies::File(path.to_string_lossy().into_owned()),
vec![],
Duration::from_secs(5),
);
let err = sc.ytdlp(["--version"]).unwrap_err();
match &err {
BackendError::CredentialsUnusable {
detail, how_to_fix, ..
} => {
assert!(detail.contains("document.cookie"), "got {detail}");
assert!(how_to_fix.contains("HttpOnly"), "got {how_to_fix}");
}
other => panic!("got {other:?}"),
}
assert!(err.needs_user_action());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_real_netscape_jar_is_accepted_and_an_unreadable_one_is_not() {
let dir = std::env::temp_dir().join(format!("rr-sc-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("jar.txt");
std::fs::write(
&path,
"# Netscape HTTP Cookie File\n\
.soundcloud.com\tTRUE\t/\tTRUE\t2000000000\toauth_token\t2-314159\n",
)
.unwrap();
let jar = |p: String| SoundCloud::new("yt-dlp", Cookies::File(p), vec![], Duration::ZERO);
assert!(
jar(path.to_string_lossy().into_owned())
.ytdlp(["--version"])
.is_ok()
);
assert!(!looks_like_header_string(
".soundcloud.com\tTRUE\t/\tTRUE\t1\ta\tb"
));
assert!(looks_like_header_string("a=b; c=d"));
assert!(!looks_like_header_string(" "));
let missing = dir.join("nope.txt").to_string_lossy().into_owned();
assert!(matches!(
jar(missing).ytdlp(["--version"]),
Err(BackendError::CredentialsUnusable { .. })
));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_empty_jar_is_caught() {
let sc = missing_tool(Cookies::File("/tmp/c.txt".into()));
assert!(
sc.audit_cookies(b"Extracted 0 cookies from chromium")
.is_err()
);
}
#[test]
fn anonymous_runs_are_never_audited_for_cookie_problems() {
let sc = sc();
assert!(
sc.audit_cookies(b"Extracted 0 cookies from chromium")
.is_ok()
);
assert!(
sc.audit_cookies(b"Original download format is only available for registered users")
.is_ok()
);
}
#[test]
fn a_clean_signed_in_run_passes_the_audit() {
let sc = missing_tool(Cookies::Browser("firefox".into()));
assert!(
sc.audit_cookies(b"Extracted 2192 cookies from firefox")
.is_ok()
);
assert!(sc.audit_cookies(b"").is_ok());
}
#[test]
fn an_unsupported_browser_names_the_fix_rather_than_leaking_stderr() {
let err = classify_ytdlp_failure(
"yt-dlp",
b"yt-dlp: error: unsupported browser specified for cookies: \"helium\". \
Supported browsers are: brave, chrome, chromium, edge, firefox, opera, \
safari, vivaldi, whale",
);
match &err {
BackendError::CredentialsUnusable { how_to_fix, .. } => {
assert!(how_to_fix.contains("chromium:"), "got {how_to_fix}")
}
other => panic!("got {other:?}"),
}
assert!(err.needs_user_action());
}
#[test]
fn a_refused_original_is_reported_on_the_offer() {
assert!(missed_original(
"WARNING: Original download format is only available for registered users. \
Use --cookies-from-browser"
));
assert!(!missed_original("Extracted 12 cookies from firefox"));
}
#[test]
fn configured_cookies_are_reported_as_present() {
assert!(matches!(sc().credentials(), CredentialState::NotRequired));
match missing_tool(Cookies::Browser("firefox".into())).credentials() {
CredentialState::Present { hint } => assert!(hint.contains("firefox"), "got {hint}"),
other => panic!("got {other:?}"),
}
}
#[test]
fn nothing_needs_buying() {
assert!(matches!(
sc().purchase(&ItemRef::new(BackendId::SoundCloud, "track/1"))
.unwrap(),
PurchaseFlow::NotRequired
));
assert!(matches!(sc().credentials(), CredentialState::NotRequired));
}
#[test]
fn a_missing_yt_dlp_is_reported_by_name() {
let sc = missing_tool(Cookies::None);
let err = sc
.search(&SearchQuery::from_text("anything", 1))
.unwrap_err();
assert!(
matches!(err, BackendError::ToolMissing { .. }),
"got {err:?}"
);
assert!(err.needs_user_action());
}
#[test]
fn an_empty_query_makes_no_subprocess_call() {
let sc = missing_tool(Cookies::None);
assert!(
sc.search(&SearchQuery::from_text(" ", 5))
.unwrap()
.is_empty()
);
assert!(
sc.search(&SearchQuery::from_text("x", 0))
.unwrap()
.is_empty()
);
}
}