pub mod error;
mod browse;
mod explore;
mod library;
mod playlists;
mod podcasts;
mod search;
mod uploads;
pub use error::YtMusicError;
use pyo3::prelude::*;
use pyo3::types::PyDict;
use serde_json::Value;
pub type Result<T> = std::result::Result<T, YtMusicError>;
const VENV_SITE_PACKAGES: &str = env!("YTMUSICAPI_SITE_PACKAGES");
static PYTHON_SETUP: std::sync::OnceLock<()> = std::sync::OnceLock::new();
fn ensure_venv() {
PYTHON_SETUP.get_or_init(|| {
let _ = with_gil(|py| -> PyResult<()> {
let sys = PyModule::import(py, "sys")?;
let path = sys.getattr("path")?;
let already: bool = path
.call_method1("__contains__", (VENV_SITE_PACKAGES,))?
.extract()?;
if !already {
path.call_method1("insert", (0i32, VENV_SITE_PACKAGES))?;
}
Ok(())
});
});
}
pub struct YTMusic {
pub(crate) inner: Py<PyAny>,
}
unsafe impl Send for YTMusic {}
unsafe impl Sync for YTMusic {}
impl YTMusic {
pub fn new() -> Result<Self> {
Self::with_options(None, "en", "")
}
pub fn authenticated(auth: &str) -> Result<Self> {
Self::with_options(Some(auth), "en", "")
}
pub fn with_options(auth: Option<&str>, language: &str, location: &str) -> Result<Self> {
ensure_venv();
with_gil(|py| {
let module = PyModule::import(py, "ytmusicapi")?;
let cls = module.getattr("YTMusic")?;
let kw = PyDict::new(py);
if let Some(a) = auth {
kw.set_item("auth", a)?;
}
kw.set_item("language", language)?;
if !location.is_empty() {
kw.set_item("location", location)?;
}
let instance = cls.call((), Some(&kw))?;
Ok(YTMusic {
inner: instance.unbind(),
})
})
.map_err(|e: PyErr| YtMusicError::Python(format!("{e}")))
}
pub fn setup(filepath: Option<&str>) -> Result<String> {
ensure_venv();
with_gil(|py| {
let module = PyModule::import(py, "ytmusicapi")?;
let kw = PyDict::new(py);
if let Some(p) = filepath {
kw.set_item("filepath", p)?;
}
let result = module.call_method("setup", (), Some(&kw))?;
result.extract::<String>()
})
.map_err(|e: PyErr| YtMusicError::Python(format!("{e}")))
}
pub fn setup_oauth(
client_id: &str,
client_secret: &str,
filepath: Option<&str>,
open_browser: bool,
) -> Result<()> {
ensure_venv();
with_gil(|py| {
patch_refreshing_token(py)?;
let module = PyModule::import(py, "ytmusicapi")?;
let kw = PyDict::new(py);
kw.set_item("client_id", client_id)?;
kw.set_item("client_secret", client_secret)?;
kw.set_item("filepath", filepath.unwrap_or("oauth.json"))?;
kw.set_item("open_browser", open_browser)?;
module.call_method("setup_oauth", (), Some(&kw))?;
Ok(())
})
.map_err(|e: PyErr| YtMusicError::Python(format!("{e}")))
}
pub fn with_oauth(oauth_file: &str, client_id: &str, client_secret: &str) -> Result<Self> {
ensure_venv();
with_gil(|py| {
let module = PyModule::import(py, "ytmusicapi")?;
let cls = module.getattr("YTMusic")?;
let creds_cls = module.getattr("OAuthCredentials")?;
let creds_kw = PyDict::new(py);
creds_kw.set_item("client_id", client_id)?;
creds_kw.set_item("client_secret", client_secret)?;
let credentials = creds_cls.call((), Some(&creds_kw))?;
let kw = PyDict::new(py);
kw.set_item("auth", oauth_file)?;
kw.set_item("oauth_credentials", credentials)?;
let instance = cls.call((), Some(&kw))?;
Ok(YTMusic {
inner: instance.unbind(),
})
})
.map_err(|e: PyErr| YtMusicError::Python(format!("{e}")))
}
}
pub(crate) fn py_to_json(py: Python<'_>, obj: &Bound<'_, PyAny>) -> PyResult<Value> {
let json_mod = PyModule::import(py, "json")?;
let s: String = json_mod.call_method1("dumps", (obj,))?.extract()?;
serde_json::from_str(&s)
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))
}
pub(crate) fn json_to_py<'py>(
py: Python<'py>,
value: &Value,
) -> PyResult<Bound<'py, PyAny>> {
let s = serde_json::to_string(value)
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
PyModule::import(py, "json")?.call_method1("loads", (s,))
}
pub(crate) fn py_err(e: PyErr) -> YtMusicError {
YtMusicError::Python(format!("{e}"))
}
pub(crate) fn with_gil<F, R>(f: F) -> R
where
F: for<'py> FnOnce(Python<'py>) -> R,
{
Python::attach(f)
}
fn patch_refreshing_token(py: Python<'_>) -> PyResult<()> {
use std::ffi::CString;
let code = CString::new(
"import dataclasses\n\
from ytmusicapi.auth.oauth.token import RefreshingToken\n\
if not getattr(RefreshingToken, '_patched', False):\n\
\x20 _orig = RefreshingToken.__init__\n\
\x20 _known = {f.name for f in dataclasses.fields(RefreshingToken)}\n\
\x20 def _init(self, **kw): _orig(self, **{k: v for k, v in kw.items() if k in _known})\n\
\x20 RefreshingToken.__init__ = _init\n\
\x20 RefreshingToken._patched = True\n",
)
.unwrap();
PyModule::from_code(py, code.as_c_str(), c"_rt_patch.py", c"_rt_patch")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constructs_unauthenticated() {
let yt = YTMusic::new().expect("should construct without auth");
drop(yt);
}
#[test]
fn search_returns_list() {
let yt = YTMusic::new().unwrap();
let results = yt.search("Radiohead", Some("artists"), None, Some(5), None).unwrap();
assert!(results.is_array());
}
}