about summary refs log tree commit diff stats
path: root/crates/yt_dlp/src
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--crates/yt_dlp/src/info_json.rs56
-rw-r--r--crates/yt_dlp/src/lib.rs682
-rw-r--r--crates/yt_dlp/src/logging.rs197
-rw-r--r--crates/yt_dlp/src/options.rs207
-rw-r--r--crates/yt_dlp/src/post_processors/dearrow.rs247
-rw-r--r--crates/yt_dlp/src/post_processors/mod.rs48
-rw-r--r--crates/yt_dlp/src/progress_hook.rs96
-rw-r--r--crates/yt_dlp/src/python_error.rs55
8 files changed, 901 insertions, 687 deletions
diff --git a/crates/yt_dlp/src/info_json.rs b/crates/yt_dlp/src/info_json.rs
new file mode 100644
index 0000000..402acb4
--- /dev/null
+++ b/crates/yt_dlp/src/info_json.rs
@@ -0,0 +1,56 @@
+// yt - A fully featured command line YouTube client
+//
+// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Yt.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
+use pyo3::{
+    Bound, Python, intern,
+    types::{PyAnyMethods, PyDict},
+};
+
+pub type InfoJson = serde_json::Map<String, serde_json::Value>;
+
+/// # Panics
+/// If expectation about python operations fail.
+#[must_use]
+pub fn json_loads(
+    input: serde_json::Map<String, serde_json::Value>,
+    py: Python<'_>,
+) -> Bound<'_, PyDict> {
+    let json = py.import(intern!(py, "json")).expect("Module exists");
+    let loads = json.getattr(intern!(py, "loads")).expect("Method exists");
+    let self_str = serde_json::to_string(&serde_json::Value::Object(input)).expect("Vaild json");
+    let dict = loads
+        .call((self_str,), None)
+        .expect("Vaild json is always a valid dict");
+
+    dict.cast_into().expect("Should always be a dict")
+}
+
+/// # Panics
+/// If expectation about python operations fail.
+#[must_use]
+pub fn json_dumps(input: &Bound<'_, PyDict>) -> serde_json::Map<String, serde_json::Value> {
+    let py = input.py();
+
+    let json = py.import(intern!(py, "json")).expect("Module exists");
+    let dumps = json.getattr(intern!(py, "dumps")).expect("Method exists");
+    let dict = dumps
+        .call((input,), None)
+        .map_err(|err| err.print(py))
+        .expect("Might not always work, but for our dicts it works");
+
+    let string: String = dict.extract().expect("Should always be a string");
+
+    let value: serde_json::Value = serde_json::from_str(&string).expect("Should be valid json");
+
+    match value {
+        serde_json::Value::Object(map) => map,
+        _ => unreachable!("These should not be json.dumps output"),
+    }
+}
diff --git a/crates/yt_dlp/src/lib.rs b/crates/yt_dlp/src/lib.rs
index dd42fc6..4b252de 100644
--- a/crates/yt_dlp/src/lib.rs
+++ b/crates/yt_dlp/src/lib.rs
@@ -1,216 +1,137 @@
+// yt - A fully featured command line YouTube client
+//
+// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Yt.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
 //! The `yt_dlp` interface is completely contained in the [`YoutubeDL`] structure.
 
-use std::{self, env, mem, path::PathBuf};
-
-use indexmap::IndexMap;
-use log::{Level, debug, error, info, log_enabled};
-use logging::setup_logging;
-use rustpython::{
-    InterpreterConfig,
-    vm::{
-        self, AsObject, Interpreter, PyObjectRef, PyPayload, PyRef, VirtualMachine,
-        builtins::{PyBaseException, PyBaseExceptionRef, PyDict, PyList, PyStr},
-        function::{FuncArgs, KwArgs, PosArgs},
-        py_io::Write,
-        suggestion::offer_suggestions,
-    },
+use std::path::PathBuf;
+
+use log::{debug, info};
+use pyo3::{
+    Bound, Py, PyAny, Python, intern,
+    types::{PyAnyMethods, PyDict, PyIterator, PyList},
 };
 use url::Url;
 
-mod logging;
+use crate::{
+    info_json::{InfoJson, json_dumps, json_loads},
+    python_error::{IntoPythonError, PythonError},
+};
+
+pub mod info_json;
+pub mod options;
+pub mod post_processors;
 pub mod progress_hook;
+pub mod python_error;
 
 #[macro_export]
 macro_rules! json_get {
-    ($value:expr, $name:literal, $into:ident) => {
-        $crate::json_cast!($value.get($name).expect("Should exist"), $into)
-    };
+    ($value:expr, $name:literal, $into:ident) => {{
+        match $value.get($name) {
+            Some(val) => $crate::json_cast!(@log_key $name, val, $into),
+            None => panic!(
+                concat!(
+                    "Expected '",
+                    $name,
+                    "' to be a key for the '",
+                    stringify!($value),
+                    "' object: {:#?}"
+                ),
+                $value
+            ),
+        }
+    }};
+}
+
+#[macro_export]
+macro_rules! json_try_get {
+    ($value:expr, $name:literal, $into:ident) => {{
+        if let Some(val) = $value.get($name) {
+            if val.is_null() {
+                None
+            } else {
+                Some(json_cast!(@log_key $name, val, $into))
+            }
+        } else {
+            None
+        }
+    }};
 }
 
 #[macro_export]
 macro_rules! json_cast {
-    ($value:expr, $into:ident) => {
-        $value.$into().expect(concat!(
-            "Should be able to cast value into ",
-            stringify!($into)
-        ))
-    };
+    ($value:expr, $into:ident) => {{
+        let value_name = stringify!($value);
+        json_cast!(@log_key value_name, $value, $into)
+    }};
+
+    (@log_key $name:expr, $value:expr, $into:ident) => {{
+        match $value.$into() {
+            Some(result) => result,
+            None => panic!(
+                concat!(
+                    "Expected to be able to cast '{}' value (which is '{:?}') ",
+                    stringify!($into)
+                ),
+                $name,
+                $value
+            ),
+        }
+    }};
 }
 
+macro_rules! py_kw_args {
+    ($py:expr => $($kw_arg_name:ident = $kw_arg_val:expr),*) => {{
+        use $crate::python_error::IntoPythonError;
+
+        let dict = PyDict::new($py);
+
+        $(
+            dict.set_item(stringify!($kw_arg_name), $kw_arg_val).wrap_exc($py)?;
+        )*
+
+        Some(dict)
+    }
+    .as_ref()};
+}
+pub(crate) use py_kw_args;
+
 /// The core of the `yt_dlp` interface.
+#[derive(Debug)]
 pub struct YoutubeDL {
-    interpreter: Interpreter,
-    youtube_dl_class: PyObjectRef,
-    yt_dlp_module: PyObjectRef,
+    inner: Py<PyAny>,
     options: serde_json::Map<String, serde_json::Value>,
 }
 
-impl std::fmt::Debug for YoutubeDL {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        // TODO(@bpeetz): Use something useful here. <2025-06-13>
-        f.write_str("YoutubeDL")
-    }
-}
-
 impl YoutubeDL {
-    /// Construct this instance from options.
-    ///
-    /// # Panics
-    /// If `yt_dlp` changed their interface.
+    /// Fetch the underlying `yt_dlp` and `python` version.
     ///
     /// # Errors
-    /// If a python call fails.
-    pub fn from_options(mut options: YoutubeDLOptions) -> Result<Self, build::Error> {
-        let mut settings = vm::Settings::default();
-        if let Ok(python_path) = env::var("PYTHONPATH") {
-            for path in python_path.split(':') {
-                settings.path_list.push(path.to_owned());
-            }
-        } else {
-            error!(
-                "No PYTHONPATH found or invalid utf8. \
-                This means, that you probably did not \
-                supply the yt_dlp!"
-            );
-        }
-
-        settings.install_signal_handlers = false;
-
-        // NOTE(@bpeetz): Another value leads to an internal codegen error. <2025-06-13>
-        settings.optimize = 0;
-
-        settings.isolated = true;
-
-        let interpreter = InterpreterConfig::new()
-            .init_stdlib()
-            .settings(settings)
-            .interpreter();
-
-        let output_options = options.options.clone();
-
-        let (yt_dlp_module, youtube_dl_class) = match interpreter.enter(|vm| {
-            let yt_dlp_module = vm.import("yt_dlp", 0)?;
-            let class = yt_dlp_module.get_attr("YoutubeDL", vm)?;
-
-            let maybe_hook = mem::take(&mut options.progress_hook);
-            let opts = options.into_py_dict(vm);
-            if let Some(function) = maybe_hook {
-                opts.get_or_insert(vm, vm.new_pyobj("progress_hooks"), || {
-                    let hook: PyObjectRef = vm.new_function("progress_hook", function).into();
-                    vm.new_pyobj(vec![hook])
-                })
-                .expect("Should work?");
-            }
-
-            {
-                // Unconditionally set a logger.
-                // Otherwise, yt_dlp will log to stderr.
-
-                /// Is the specified record to be logged? Returns false for no,
-                /// true for yes. Filters can either modify log records in-place or
-                /// return a completely different record instance which will replace
-                /// the original log record in any future processing of the event.
-                fn filter_error_log(mut input: FuncArgs, vm: &VirtualMachine) -> bool {
-                    let record = input.args.remove(0);
-
-                    // Filter out all error logs (they are propagated as rust errors)
-                    let levelname: PyRef<PyStr> = record
-                        .get_attr("levelname", vm)
-                        .expect("This should exist")
-                        .downcast()
-                        .expect("This should be a String");
-
-                    let return_value = levelname.as_str() != "ERROR";
-
-                    if log_enabled!(Level::Debug) && !return_value {
-                        let message: String = {
-                            let get_message = record.get_attr("getMessage", vm).expect("Is set");
-                            let message: PyRef<PyStr> = get_message
-                                .call((), vm)
-                                .expect("Can be called")
-                                .downcast()
-                                .expect("Downcasting works");
-
-                            message.as_str().to_owned()
-                        };
-
-                        debug!("Swollowed error message: '{message}'");
-                    }
-                    return_value
-                }
-
-                let logging = setup_logging(vm, "yt_dlp")?;
-                let ytdl_logger = {
-                    let get_logger = logging.get_item("getLogger", vm)?;
-                    get_logger.call(("yt_dlp",), vm)?
-                };
-
-                {
-                    let args = FuncArgs::new(
-                        PosArgs::new(vec![]),
-                        KwArgs::new({
-                            let mut map = IndexMap::new();
-                            // Ensure that all events are logged by setting
-                            // the log level to NOTSET (we filter on rust's side)
-                            map.insert("level".to_owned(), vm.new_pyobj(0));
-                            map
-                        }),
-                    );
-
-                    let basic_config = logging.get_item("basicConfig", vm)?;
-                    basic_config.call(args, vm)?;
-                }
-
-                {
-                    let add_filter = ytdl_logger.get_attr("addFilter", vm)?;
-                    add_filter.call(
-                        (vm.new_function("yt_dlp_error_filter", filter_error_log),),
-                        vm,
-                    )?;
-                }
-
-                opts.set_item("logger", ytdl_logger, vm)?;
-            }
-
-            let youtube_dl_class = class.call((opts,), vm)?;
-
-            Ok::<_, PyRef<PyBaseException>>((yt_dlp_module, youtube_dl_class))
-        }) {
-            Ok(ok) => Ok(ok),
-            Err(err) => {
-                // TODO(@bpeetz): Do we want to run `interpreter.finalize` here? <2025-06-14>
-                // interpreter.finalize(Some(err));
-                interpreter.enter(|vm| {
-                    let buffer = process_exception(vm, &err);
-                    Err(build::Error::Python(buffer))
-                })
-            }
-        }?;
-
-        Ok(Self {
-            interpreter,
-            youtube_dl_class,
-            yt_dlp_module,
-            options: output_options,
+    /// If python attribute access fails.
+    pub fn version(&self) -> Result<(String, String), PythonError> {
+        Python::attach(|py| {
+            let yt_dlp = py
+                .import(intern!(py, "yt_dlp"))
+                .wrap_exc(py)?
+                .getattr(intern!(py, "version"))
+                .wrap_exc(py)?
+                .getattr(intern!(py, "__version__"))
+                .wrap_exc(py)?
+                .extract()
+                .wrap_exc(py)?;
+
+            let python = py.version();
+
+            Ok((yt_dlp, python.to_owned()))
         })
     }
 
-    /// # Panics
-    ///
-    /// If `yt_dlp` changed their location or type of `__version__`.
-    pub fn version(&self) -> String {
-        let str_ref: PyRef<PyStr> = self.interpreter.enter_and_expect(
-            |vm| {
-                let version_module = self.yt_dlp_module.get_attr("version", vm)?;
-                let version = version_module.get_attr("__version__", vm)?;
-                let version = version.downcast().expect("This should always be a string");
-                Ok(version)
-            },
-            "yt_dlp version location has changed",
-        );
-        str_ref.to_string()
-    }
-
     /// Download a given list of URLs.
     /// Returns the paths they were downloaded to.
     ///
@@ -224,8 +145,9 @@ impl YoutubeDL {
             let info_json = self.extract_info(url, true, true)?;
 
             // Try to work around yt-dlp type weirdness
-            let result_string = if let Some(filename) = info_json.get("filename") {
-                PathBuf::from(json_cast!(filename, as_str))
+            let result_string = if let Some(filename) = json_try_get!(info_json, "filename", as_str)
+            {
+                PathBuf::from(filename)
             } else {
                 PathBuf::from(json_get!(
                     json_cast!(
@@ -267,63 +189,66 @@ impl YoutubeDL {
         download: bool,
         process: bool,
     ) -> Result<InfoJson, extract_info::Error> {
-        match self.interpreter.enter(|vm| {
-            let pos_args = PosArgs::new(vec![vm.new_pyobj(url.to_string())]);
-
-            let kw_args = KwArgs::new({
-                let mut map = IndexMap::new();
-                map.insert("download".to_owned(), vm.new_pyobj(download));
-                map.insert("process".to_owned(), vm.new_pyobj(process));
-                map
-            });
+        Python::attach(|py| {
+            let inner = self
+                .inner
+                .bind(py)
+                .getattr(intern!(py, "extract_info"))
+                .wrap_exc(py)?;
 
-            let fun_args = FuncArgs::new(pos_args, kw_args);
-
-            let inner = self.youtube_dl_class.get_attr("extract_info", vm)?;
             let result = inner
-                .call_with_args(fun_args, vm)?
-                .downcast::<PyDict>()
+                .call(
+                    (url.to_string(),),
+                    py_kw_args!(py => download = download, process = process),
+                )
+                .wrap_exc(py)?
+                .cast_into::<PyDict>()
                 .expect("This is a dict");
 
             // Resolve the generator object
-            if let Ok(generator) = result.get_item("entries", vm) {
-                if generator.payload_is::<PyList>() {
+            if let Ok(generator) = result.get_item(intern!(py, "entries")) {
+                if generator.is_instance_of::<PyList>() {
                     // already resolved. Do nothing
-                } else {
-                    let max_backlog = self.options.get("playlistend").map_or(10000, |value| {
-                        usize::try_from(value.as_u64().expect("Works")).expect("Should work")
-                    });
+                } else if let Ok(generator) = generator.cast::<PyIterator>() {
+                    // A python generator object.
+                    let max_backlog = json_try_get!(self.options, "playlistend", as_u64)
+                        .map_or(10000, |playlistend| {
+                            usize::try_from(playlistend).expect("Should work")
+                        });
 
                     let mut out = vec![];
-                    let next = generator.get_attr("__next__", vm)?;
-                    while let Ok(output) = next.call((), vm) {
-                        out.push(output);
+                    for output in generator {
+                        out.push(output.wrap_exc(py)?);
 
                         if out.len() == max_backlog {
                             break;
                         }
                     }
-                    result.set_item("entries", vm.new_pyobj(out), vm)?;
-                }
-            }
 
-            let result = {
-                let sanitize = self.youtube_dl_class.get_attr("sanitize_info", vm)?;
-                let value = sanitize.call((result,), vm)?;
+                    result.set_item(intern!(py, "entries"), out).wrap_exc(py)?;
+                } else {
+                    // Probably some sort of paged list (`OnDemand` or otherwise)
+                    let max_backlog = json_try_get!(self.options, "playlistend", as_u64)
+                        .map_or(10000, |playlistend| {
+                            usize::try_from(playlistend).expect("Should work")
+                        });
 
-                value.downcast::<PyDict>().expect("This should stay a dict")
-            };
+                    let next = generator.getattr(intern!(py, "getslice")).wrap_exc(py)?;
 
-            let result_json = json_dumps(result, vm);
+                    let output = next
+                        .call((), py_kw_args!(py => start = 0, end = max_backlog))
+                        .wrap_exc(py)?;
 
-            Ok::<_, PyRef<PyBaseException>>(result_json)
-        }) {
-            Ok(ok) => Ok(ok),
-            Err(err) => self.interpreter.enter(|vm| {
-                let buffer = process_exception(vm, &err);
-                Err(extract_info::Error::Python(buffer))
-            }),
-        }
+                    result
+                        .set_item(intern!(py, "entries"), output)
+                        .wrap_exc(py)?;
+                }
+            }
+
+            let result = self.prepare_info_json(&result, py)?;
+
+            Ok(result)
+        })
     }
 
     /// Take the (potentially modified) result of the information extractor (i.e.,
@@ -344,263 +269,110 @@ impl YoutubeDL {
         ie_result: InfoJson,
         download: bool,
     ) -> Result<InfoJson, process_ie_result::Error> {
-        match self.interpreter.enter(|vm| {
-            let pos_args = PosArgs::new(vec![vm.new_pyobj(json_loads(ie_result, vm))]);
+        Python::attach(|py| {
+            let inner = self
+                .inner
+                .bind(py)
+                .getattr(intern!(py, "process_ie_result"))
+                .wrap_exc(py)?;
 
-            let kw_args = KwArgs::new({
-                let mut map = IndexMap::new();
-                map.insert("download".to_owned(), vm.new_pyobj(download));
-                map
-            });
-
-            let fun_args = FuncArgs::new(pos_args, kw_args);
-
-            let inner = self.youtube_dl_class.get_attr("process_ie_result", vm)?;
             let result = inner
-                .call_with_args(fun_args, vm)?
-                .downcast::<PyDict>()
+                .call(
+                    (json_loads(ie_result, py),),
+                    py_kw_args!(py => download = download),
+                )
+                .wrap_exc(py)?
+                .cast_into::<PyDict>()
                 .expect("This is a dict");
 
-            let result = {
-                let sanitize = self.youtube_dl_class.get_attr("sanitize_info", vm)?;
-                let value = sanitize.call((result,), vm)?;
-
-                value.downcast::<PyDict>().expect("This should stay a dict")
-            };
-
-            let result_json = json_dumps(result, vm);
-
-            Ok::<_, PyRef<PyBaseException>>(result_json)
-        }) {
-            Ok(ok) => Ok(ok),
-            Err(err) => self.interpreter.enter(|vm| {
-                let buffer = process_exception(vm, &err);
-                Err(process_ie_result::Error::Python(buffer))
-            }),
-        }
-    }
-}
+            let result = self.prepare_info_json(&result, py)?;
 
-#[allow(missing_docs)]
-pub mod process_ie_result {
-    #[derive(Debug, thiserror::Error)]
-    pub enum Error {
-        #[error("Python threw an exception: {0}")]
-        Python(String),
-    }
-}
-#[allow(missing_docs)]
-pub mod extract_info {
-    #[derive(Debug, thiserror::Error)]
-    pub enum Error {
-        #[error("Python threw an exception: {0}")]
-        Python(String),
+            Ok(result)
+        })
     }
-}
-
-pub type InfoJson = serde_json::Map<String, serde_json::Value>;
-pub type ProgressHookFunction = fn(input: FuncArgs, vm: &VirtualMachine);
-
-/// Options, that are used to customize the download behaviour.
-///
-/// In the future, this might get a Builder api.
-///
-/// See `help(yt_dlp.YoutubeDL())` from python for a full list of available options.
-#[derive(Default, Debug)]
-pub struct YoutubeDLOptions {
-    options: serde_json::Map<String, serde_json::Value>,
-    progress_hook: Option<ProgressHookFunction>,
-}
 
-impl YoutubeDLOptions {
-    #[must_use]
-    pub fn new() -> Self {
-        Self {
-            options: serde_json::Map::new(),
-            progress_hook: None,
-        }
-    }
+    /// Close this [`YoutubeDL`] instance, and stop all currently running downloads.
+    ///
+    /// # Errors
+    /// If python operations fail.
+    pub fn close(&self) -> Result<(), close::Error> {
+        Python::attach(|py| {
+            debug!("Closing YoutubeDL.");
 
-    #[must_use]
-    pub fn set(self, key: impl Into<String>, value: impl Into<serde_json::Value>) -> Self {
-        let mut options = self.options;
-        options.insert(key.into(), value.into());
+            let inner = self
+                .inner
+                .bind(py)
+                .getattr(intern!(py, "close"))
+                .wrap_exc(py)?;
 
-        Self {
-            options,
-            progress_hook: self.progress_hook,
-        }
-    }
+            inner.call0().wrap_exc(py)?;
 
-    #[must_use]
-    pub fn with_progress_hook(self, progress_hook: ProgressHookFunction) -> Self {
-        if let Some(_previous_hook) = self.progress_hook {
-            todo!()
-        } else {
-            Self {
-                options: self.options,
-                progress_hook: Some(progress_hook),
-            }
-        }
+            Ok(())
+        })
     }
 
-    /// # Errors
-    /// If the underlying [`YoutubeDL::from_options`] errors.
-    pub fn build(self) -> Result<YoutubeDL, build::Error> {
-        YoutubeDL::from_options(self)
-    }
+    fn prepare_info_json<'py>(
+        &self,
+        info: &Bound<'py, PyDict>,
+        py: Python<'py>,
+    ) -> Result<InfoJson, prepare::Error> {
+        let sanitize = self
+            .inner
+            .bind(py)
+            .getattr(intern!(py, "sanitize_info"))
+            .wrap_exc(py)?;
 
-    #[must_use]
-    pub fn from_json_options(options: serde_json::Map<String, serde_json::Value>) -> Self {
-        Self {
-            options,
-            progress_hook: None,
-        }
-    }
+        let value = sanitize.call((info,), None).wrap_exc(py)?;
 
-    #[must_use]
-    pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
-        self.options.get(key)
-    }
+        let result = value.cast::<PyDict>().expect("This should stay a dict");
 
-    fn into_py_dict(self, vm: &VirtualMachine) -> PyRef<PyDict> {
-        json_loads(self.options, vm)
+        Ok(json_dumps(result))
     }
 }
 
 #[allow(missing_docs)]
-pub mod build {
+pub mod close {
+    use crate::python_error::PythonError;
+
     #[derive(Debug, thiserror::Error)]
     pub enum Error {
-        #[error("Python threw an exception: {0}")]
-        Python(String),
-
-        #[error("Io error: {0}")]
-        Io(#[from] std::io::Error),
+        #[error(transparent)]
+        Python(#[from] PythonError),
     }
 }
+#[allow(missing_docs)]
+pub mod process_ie_result {
+    use crate::{prepare, python_error::PythonError};
 
-fn json_loads(
-    input: serde_json::Map<String, serde_json::Value>,
-    vm: &VirtualMachine,
-) -> PyRef<PyDict> {
-    let json = vm.import("json", 0).expect("Module exists");
-    let loads = json.get_attr("loads", vm).expect("Method exists");
-    let self_str = serde_json::to_string(&serde_json::Value::Object(input)).expect("Vaild json");
-    let dict = loads
-        .call((self_str,), vm)
-        .expect("Vaild json is always a valid dict");
-
-    dict.downcast().expect("Should always be a dict")
-}
+    #[derive(Debug, thiserror::Error)]
+    pub enum Error {
+        #[error(transparent)]
+        Python(#[from] PythonError),
 
-/// # Panics
-/// If expectation about python operations fail.
-pub fn json_dumps(
-    input: PyRef<PyDict>,
-    vm: &VirtualMachine,
-) -> serde_json::Map<String, serde_json::Value> {
-    let json = vm.import("json", 0).expect("Module exists");
-    let dumps = json.get_attr("dumps", vm).expect("Method exists");
-    let dict = dumps
-        .call((input,), vm)
-        .map_err(|err| vm.print_exception(err))
-        .expect("Might not always work, but for our dicts it works");
-
-    let string: PyRef<PyStr> = dict.downcast().expect("Should always be a string");
-
-    let real_string = string.to_str().expect("Should be valid utf8");
-
-    // {
-    //     let mut file = File::create("debug.dump.json").unwrap();
-    //     write!(file, "{}", real_string).unwrap();
-    // }
-
-    let value: serde_json::Value = serde_json::from_str(real_string).expect("Should be valid json");
-
-    match value {
-        serde_json::Value::Object(map) => map,
-        _ => unreachable!("These should not be json.dumps output"),
+        #[error("Failed to prepare the info json")]
+        InfoJsonPrepare(#[from] prepare::Error),
     }
 }
+#[allow(missing_docs)]
+pub mod extract_info {
+    use crate::{prepare, python_error::PythonError};
 
-// Inlined and changed from `vm.write_exception_inner`
-fn write_exception<W: Write>(
-    vm: &VirtualMachine,
-    output: &mut W,
-    exc: &PyBaseExceptionRef,
-) -> Result<(), W::Error> {
-    let varargs = exc.args();
-    let args_repr = {
-        match varargs.len() {
-            0 => vec![],
-            1 => {
-                let args0_repr = if true {
-                    varargs[0]
-                        .str(vm)
-                        .unwrap_or_else(|_| PyStr::from("<element str() failed>").into_ref(&vm.ctx))
-                } else {
-                    varargs[0].repr(vm).unwrap_or_else(|_| {
-                        PyStr::from("<element repr() failed>").into_ref(&vm.ctx)
-                    })
-                };
-                vec![args0_repr]
-            }
-            _ => varargs
-                .iter()
-                .map(|vararg| {
-                    vararg.repr(vm).unwrap_or_else(|_| {
-                        PyStr::from("<element repr() failed>").into_ref(&vm.ctx)
-                    })
-                })
-                .collect(),
-        }
-    };
-
-    let exc_class = exc.class();
-
-    if exc_class.fast_issubclass(vm.ctx.exceptions.syntax_error) {
-        unreachable!(
-            "A syntax error should never be raised, \
-                                as yt_dlp should not have them and neither our embedded code"
-        );
-    }
+    #[derive(Debug, thiserror::Error)]
+    pub enum Error {
+        #[error(transparent)]
+        Python(#[from] PythonError),
 
-    let exc_name = exc_class.name();
-    match args_repr.len() {
-        0 => write!(output, "{exc_name}"),
-        1 => write!(output, "{}: {}", exc_name, args_repr[0]),
-        _ => write!(
-            output,
-            "{}: ({})",
-            exc_name,
-            args_repr
-                .iter()
-                .map(|val| val.as_str())
-                .collect::<Vec<_>>()
-                .join(", "),
-        ),
-    }?;
-
-    match offer_suggestions(exc, vm) {
-        Some(suggestions) => {
-            write!(output, ". Did you mean: '{suggestions}'?")
-        }
-        None => Ok(()),
+        #[error("Failed to prepare the info json")]
+        InfoJsonPrepare(#[from] prepare::Error),
     }
 }
+#[allow(missing_docs)]
+pub mod prepare {
+    use crate::python_error::PythonError;
 
-fn process_exception(vm: &VirtualMachine, err: &PyBaseExceptionRef) -> String {
-    let mut buffer = String::new();
-    write_exception(vm, &mut buffer, err)
-        .expect("We are writing into an *in-memory* string, it will always work");
-
-    if log_enabled!(Level::Debug) {
-        let mut output = String::new();
-        vm.write_exception(&mut output, err)
-            .expect("We are writing into an *in-memory* string, it will always work");
-        debug!("Python threw an exception: {output}");
+    #[derive(Debug, thiserror::Error)]
+    pub enum Error {
+        #[error(transparent)]
+        Python(#[from] PythonError),
     }
-
-    buffer
 }
diff --git a/crates/yt_dlp/src/logging.rs b/crates/yt_dlp/src/logging.rs
deleted file mode 100644
index 5cb4c1d..0000000
--- a/crates/yt_dlp/src/logging.rs
+++ /dev/null
@@ -1,197 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-// This file is taken from: https://github.com/dylanbstorey/pyo3-pylogger/blob/d89e0d6820ebc4f067647e3b74af59dbc4941dd5/src/lib.rs
-// It is licensed under the Apache 2.0 License, copyright up to 2024 by Dylan Storey
-// It was modified by Benedikt Peetz 2024, 2025
-
-use log::{Level, MetadataBuilder, Record, logger};
-use rustpython::vm::{
-    PyObjectRef, PyRef, PyResult, VirtualMachine,
-    builtins::{PyInt, PyList, PyStr},
-    convert::ToPyObject,
-    function::FuncArgs,
-};
-
-/// Consume a Python `logging.LogRecord` and emit a Rust `Log` instead.
-fn host_log(mut input: FuncArgs, vm: &VirtualMachine) -> PyResult<()> {
-    let record = input.args.remove(0);
-    let rust_target = {
-        let base: PyRef<PyStr> = input.args.remove(0).downcast().expect("Should be a string");
-        base.as_str().to_owned()
-    };
-
-    let level = {
-        let level: PyRef<PyInt> = record
-            .get_attr("levelno", vm)?
-            .downcast()
-            .expect("Should always be an int");
-        level.as_u32_mask()
-    };
-    let message = {
-        let get_message = record.get_attr("getMessage", vm)?;
-        let message: PyRef<PyStr> = get_message
-            .call((), vm)?
-            .downcast()
-            .expect("Downcasting works");
-
-        message.as_str().to_owned()
-    };
-
-    let pathname = {
-        let pathname: PyRef<PyStr> = record
-            .get_attr("pathname", vm)?
-            .downcast()
-            .expect("Is a string");
-
-        pathname.as_str().to_owned()
-    };
-
-    let lineno = {
-        let lineno: PyRef<PyInt> = record
-            .get_attr("lineno", vm)?
-            .downcast()
-            .expect("Is a number");
-
-        lineno.as_u32_mask()
-    };
-
-    let logger_name = {
-        let name: PyRef<PyStr> = record
-            .get_attr("name", vm)?
-            .downcast()
-            .expect("Should be a string");
-        name.as_str().to_owned()
-    };
-
-    let full_target: Option<String> = if logger_name.trim().is_empty() || logger_name == "root" {
-        None
-    } else {
-        // Libraries (ex: tracing_subscriber::filter::Directive) expect rust-style targets like foo::bar,
-        // and may not deal well with "." as a module separator:
-        let logger_name = logger_name.replace('.', "::");
-        Some(format!("{rust_target}::{logger_name}"))
-    };
-
-    let target = full_target.as_deref().unwrap_or(&rust_target);
-
-    // error
-    let error_metadata = if level >= 40 {
-        MetadataBuilder::new()
-            .target(target)
-            .level(Level::Error)
-            .build()
-    } else if level >= 30 {
-        MetadataBuilder::new()
-            .target(target)
-            .level(Level::Warn)
-            .build()
-    } else if level >= 20 {
-        MetadataBuilder::new()
-            .target(target)
-            .level(Level::Info)
-            .build()
-    } else if level >= 10 {
-        MetadataBuilder::new()
-            .target(target)
-            .level(Level::Debug)
-            .build()
-    } else {
-        MetadataBuilder::new()
-            .target(target)
-            .level(Level::Trace)
-            .build()
-    };
-
-    logger().log(
-        &Record::builder()
-            .metadata(error_metadata)
-            .args(format_args!("{}", &message))
-            .line(Some(lineno))
-            .file(None)
-            .module_path(Some(&pathname))
-            .build(),
-    );
-
-    Ok(())
-}
-
-/// Registers the `host_log` function in rust as the event handler for Python's logging logger
-/// This function needs to be called from within a pyo3 context as early as possible to ensure logging messages
-/// arrive to the rust consumer.
-///
-/// # Panics
-/// Only if internal assertions fail.
-#[allow(clippy::module_name_repetitions)]
-pub(super) fn setup_logging(vm: &VirtualMachine, target: &str) -> PyResult<PyObjectRef> {
-    let logging = vm.import("logging", 0)?;
-
-    let scope = vm.new_scope_with_builtins();
-
-    for (key, value) in logging.dict().expect("Should be a dict") {
-        let key: PyRef<PyStr> = key.downcast().expect("Is a string");
-
-        scope.globals.set_item(key.as_str(), value, vm)?;
-    }
-    scope
-        .globals
-        .set_item("host_log", vm.new_function("host_log", host_log).into(), vm)?;
-
-    let local_scope = scope.clone();
-    vm.run_code_string(
-        local_scope,
-        format!(
-            r#"
-class HostHandler(Handler):
-    def __init__(self, level=0):
-        super().__init__(level=level)
-
-    def emit(self, record):
-        host_log(record,"{target}")
-
-oldBasicConfig = basicConfig
-def basicConfig(*pargs, **kwargs):
-    if "handlers" not in kwargs:
-        kwargs["handlers"] = [HostHandler()]
-    return oldBasicConfig(*pargs, **kwargs)
-"#
-        )
-        .as_str(),
-        "<embedded logging inintializing code>".to_owned(),
-    )?;
-
-    let all: PyRef<PyList> = logging
-        .get_attr("__all__", vm)?
-        .downcast()
-        .expect("Is a list");
-    all.borrow_vec_mut().push(vm.new_pyobj("HostHandler"));
-
-    // {
-    //     let logging_dict = logging.dict().expect("Exists");
-    //
-    //     for (key, val) in scope.globals {
-    //         let key: PyRef<PyStr> = key.downcast().expect("Is a string");
-    //
-    //         if !logging_dict.contains_key(key.as_str(), vm) {
-    //             logging_dict.set_item(key.as_str(), val, vm)?;
-    //         }
-    //     }
-    //
-    //     for (key, val) in scope.locals {
-    //         let key: PyRef<PyStr> = key.downcast().expect("Is a string");
-    //
-    //         if !logging_dict.contains_key(key.as_str(), vm) {
-    //             logging_dict.set_item(key.as_str(), val, vm)?;
-    //         }
-    //     }
-    // }
-
-    Ok(scope.globals.to_pyobject(vm))
-}
diff --git a/crates/yt_dlp/src/options.rs b/crates/yt_dlp/src/options.rs
new file mode 100644
index 0000000..4b8906e
--- /dev/null
+++ b/crates/yt_dlp/src/options.rs
@@ -0,0 +1,207 @@
+// yt - A fully featured command line YouTube client
+//
+// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Yt.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
+use std::sync;
+
+use pyo3::{
+    Bound, IntoPyObjectExt, PyAny, PyResult, Python, intern,
+    types::{PyAnyMethods, PyCFunction, PyDict, PyTuple},
+};
+use pyo3_pylogger::setup_logging;
+
+use crate::{
+    YoutubeDL, json_loads, post_processors, py_kw_args,
+    python_error::{IntoPythonError, PythonError},
+};
+
+pub type ProgressHookFunction = fn(py: Python<'_>) -> PyResult<Bound<'_, PyCFunction>>;
+pub type PostProcessorFunction = fn(py: Python<'_>) -> PyResult<Bound<'_, PyAny>>;
+
+/// Options, that are used to customize the download behaviour.
+///
+/// In the future, this might get a Builder api.
+///
+/// See `help(yt_dlp.YoutubeDL())` from python for a full list of available options.
+#[derive(Default, Debug)]
+pub struct YoutubeDLOptions {
+    options: serde_json::Map<String, serde_json::Value>,
+    progress_hook: Option<ProgressHookFunction>,
+    post_processors: Vec<PostProcessorFunction>,
+}
+
+impl YoutubeDLOptions {
+    #[must_use]
+    pub fn new() -> Self {
+        let me = Self {
+            options: serde_json::Map::new(),
+            progress_hook: None,
+            post_processors: vec![],
+        };
+
+        me.with_post_processor(post_processors::dearrow::process)
+    }
+
+    #[must_use]
+    pub fn set(self, key: impl Into<String>, value: impl Into<serde_json::Value>) -> Self {
+        let mut options = self.options;
+        options.insert(key.into(), value.into());
+
+        Self { options, ..self }
+    }
+
+    #[must_use]
+    pub fn with_progress_hook(self, progress_hook: ProgressHookFunction) -> Self {
+        if let Some(_previous_hook) = self.progress_hook {
+            todo!()
+        } else {
+            Self {
+                progress_hook: Some(progress_hook),
+                ..self
+            }
+        }
+    }
+
+    #[must_use]
+    pub fn with_post_processor(mut self, pp: PostProcessorFunction) -> Self {
+        self.post_processors.push(pp);
+        self
+    }
+
+    /// # Errors
+    /// If the underlying [`YoutubeDL::from_options`] errors.
+    pub fn build(self) -> Result<YoutubeDL, build::Error> {
+        YoutubeDL::from_options(self)
+    }
+
+    #[must_use]
+    pub fn from_json_options(options: serde_json::Map<String, serde_json::Value>) -> Self {
+        Self {
+            options,
+            ..Self::new()
+        }
+    }
+
+    #[must_use]
+    pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
+        self.options.get(key)
+    }
+}
+
+impl YoutubeDL {
+    /// Construct this instance from options.
+    ///
+    /// # Panics
+    /// If `yt_dlp` changed their interface.
+    ///
+    /// # Errors
+    /// If a python call fails.
+    #[allow(clippy::too_many_lines)]
+    pub fn from_options(options: YoutubeDLOptions) -> Result<Self, build::Error> {
+        Python::initialize();
+
+        let output_options = options.options.clone();
+
+        let yt_dlp_module = Python::attach(|py| {
+            let opts = json_loads(options.options, py);
+
+            {
+                static CALL_ONCE: sync::Once = sync::Once::new();
+
+                CALL_ONCE.call_once(|| {
+                    py.run(
+                        c"
+import signal
+signal.signal(signal.SIGINT, signal.SIG_DFL)
+              ",
+                        None,
+                        None,
+                    )
+                    .unwrap_or_else(|err| {
+                        panic!("Failed to disable python signal handling: {err}")
+                    });
+                });
+            }
+
+            {
+                // Setup the progress hook
+                if let Some(ph) = options.progress_hook {
+                    opts.set_item(intern!(py, "progress_hooks"), vec![ph(py).wrap_exc(py)?])
+                        .wrap_exc(py)?;
+                }
+            }
+
+            {
+                // Unconditionally set a logger.
+                // Otherwise, yt_dlp will log to stderr.
+
+                let ytdl_logger = setup_logging(py, "yt_dlp").wrap_exc(py)?;
+
+                opts.set_item(intern!(py, "logger"), ytdl_logger)
+                    .wrap_exc(py)?;
+            }
+
+            let inner = {
+                let p_params = opts.into_bound_py_any(py).wrap_exc(py)?;
+                let p_auto_init = true.into_bound_py_any(py).wrap_exc(py)?;
+
+                py.import(intern!(py, "yt_dlp.YoutubeDL"))
+                    .wrap_exc(py)?
+                    .getattr(intern!(py, "YoutubeDL"))
+                    .wrap_exc(py)?
+                    .call1(
+                        PyTuple::new(
+                            py,
+                            [
+                                p_params.into_bound_py_any(py).wrap_exc(py)?,
+                                p_auto_init.into_bound_py_any(py).wrap_exc(py)?,
+                            ],
+                        )
+                        .wrap_exc(py)?,
+                    )
+                    .wrap_exc(py)?
+            };
+
+            {
+                // Setup the post processors
+                let add_post_processor_fun = inner
+                    .getattr(intern!(py, "add_post_processor"))
+                    .wrap_exc(py)?;
+
+                for pp in options.post_processors {
+                    add_post_processor_fun
+                        .call(
+                            (pp(py).wrap_exc(py)?.into_bound_py_any(py).wrap_exc(py)?,),
+                            // "when" can take any value in yt_dlp.utils.POSTPROCESS_WHEN
+                            py_kw_args!(py => when = "pre_process"),
+                        )
+                        .wrap_exc(py)?;
+                }
+            }
+
+            Ok::<_, PythonError>(inner.unbind())
+        })?;
+
+        Ok(Self {
+            inner: yt_dlp_module,
+            options: output_options,
+        })
+    }
+}
+
+#[allow(missing_docs)]
+pub mod build {
+    use crate::python_error::PythonError;
+
+    #[derive(Debug, thiserror::Error)]
+    pub enum Error {
+        #[error(transparent)]
+        Python(#[from] PythonError),
+    }
+}
diff --git a/crates/yt_dlp/src/post_processors/dearrow.rs b/crates/yt_dlp/src/post_processors/dearrow.rs
new file mode 100644
index 0000000..7787d68
--- /dev/null
+++ b/crates/yt_dlp/src/post_processors/dearrow.rs
@@ -0,0 +1,247 @@
+// yt - A fully featured command line YouTube client
+//
+// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Yt.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
+use curl::easy::Easy;
+use log::{error, info, trace, warn};
+use pyo3::{
+    Bound, PyAny, PyErr, PyResult, Python, exceptions, intern, pyfunction,
+    types::{PyAnyMethods, PyDict, PyModule},
+    wrap_pyfunction,
+};
+use serde::{Deserialize, Serialize};
+
+use crate::{
+    pydict_cast, pydict_get,
+    python_error::{IntoPythonError, PythonError},
+};
+
+/// # Errors
+/// - If the underlying function returns an error.
+/// - If python operations fail.
+pub fn process(py: Python<'_>) -> PyResult<Bound<'_, PyAny>> {
+    #[pyfunction]
+    fn actual_processor(info_json: Bound<'_, PyDict>) -> PyResult<Bound<'_, PyDict>> {
+        let output = match unwrapped_process(info_json) {
+            Ok(ok) => ok,
+            Err(err) => {
+                return Err(PyErr::new::<exceptions::PyRuntimeError, _>(err.to_string()));
+            }
+        };
+        Ok(output)
+    }
+
+    let module = PyModule::new(py, "rust_post_processors")?;
+    let scope = PyDict::new(py);
+    scope.set_item(
+        intern!(py, "actual_processor"),
+        wrap_pyfunction!(actual_processor, module)?,
+    )?;
+    py.run(
+        c"
+import yt_dlp
+
+class DeArrow(yt_dlp.postprocessor.PostProcessor):
+    def run(self, info):
+        info = actual_processor(info)
+        return [], info
+
+inst = DeArrow()
+",
+        Some(&scope),
+        None,
+    )?;
+
+    Ok(scope.get_item(intern!(py, "inst"))?.cast_into()?)
+}
+
+/// # Errors
+/// If the API access fails.
+pub fn unwrapped_process(info: Bound<'_, PyDict>) -> Result<Bound<'_, PyDict>, Error> {
+    if pydict_get!(info, "extractor_key", String).as_str() != "Youtube" {
+        return Ok(info);
+    }
+
+    let mut retry_num = 3;
+    let mut output: DeArrowApi = {
+        loop {
+            let output_bytes = {
+                let mut dst = Vec::new();
+
+                let mut easy = Easy::new();
+                easy.url(
+                    format!(
+                        "https://sponsor.ajay.app/api/branding?videoID={}",
+                        pydict_get!(info, "id", String)
+                    )
+                    .as_str(),
+                )?;
+
+                let mut transfer = easy.transfer();
+                transfer.write_function(|data| {
+                    dst.extend_from_slice(data);
+                    Ok(data.len())
+                })?;
+                transfer.perform()?;
+                drop(transfer);
+
+                dst
+            };
+
+            match serde_json::from_slice(&output_bytes) {
+                Ok(ok) => break ok,
+                Err(err) => {
+                    if retry_num > 0 {
+                        trace!(
+                            "DeArrow: Api access failed, trying again ({retry_num} retries left)"
+                        );
+                        retry_num -= 1;
+                    } else {
+                        let err: serde_json::Error = err;
+                        return Err(err.into());
+                    }
+                }
+            }
+        }
+    };
+
+    // We pop the titles, so we need this vector reversed.
+    output.titles.reverse();
+
+    let title_len = output.titles.len();
+    let mut iterator = output.titles.clone();
+    let selected = loop {
+        let Some(title) = iterator.pop() else {
+            break false;
+        };
+
+        if (title.locked || title.votes < 1) && title_len > 1 {
+            info!(
+                "DeArrow: Skipping title {:#?}, as it is not good enough",
+                title.value
+            );
+            // Skip titles that are not “good” enough.
+            continue;
+        }
+
+        update_title(&info, &title.value).wrap_exc(info.py())?;
+
+        break true;
+    };
+
+    if !selected && title_len != 0 {
+        // No title was selected, even though we had some titles.
+        // Just pick the first one in this case.
+        update_title(&info, &output.titles[0].value).wrap_exc(info.py())?;
+    }
+
+    Ok(info)
+}
+
+#[derive(thiserror::Error, Debug)]
+pub enum Error {
+    #[error(transparent)]
+    Python(#[from] PythonError),
+
+    #[error("Failed to access the DeArrow api: {0}")]
+    Get(#[from] curl::Error),
+
+    #[error("Failed to deserialize a api json return object: {0}")]
+    Deserialize(#[from] serde_json::Error),
+}
+
+fn update_title(info: &Bound<'_, PyDict>, new_title: &str) -> PyResult<()> {
+    let py = info.py();
+
+    assert!(!info.contains(intern!(py, "original_title"))?);
+
+    if let Ok(old_title) = info.get_item(intern!(py, "title")) {
+        warn!(
+            "DeArrow: Updating title from {:#?} to {:#?}",
+            pydict_cast!(old_title, &str),
+            new_title
+        );
+
+        info.set_item(intern!(py, "original_title"), old_title)
+            .expect("We checked, it is a new key");
+    } else {
+        warn!("DeArrow: Setting title to {new_title:#?}");
+    }
+
+    let cleaned_title = {
+        // NOTE(@bpeetz): DeArrow uses `>` as a “Don't format the next word” mark.
+        // They should be removed, if one does not use a auto-formatter. <2025-06-16>
+        new_title.replace('>', "")
+    };
+
+    info.set_item(intern!(py, "title"), cleaned_title)
+        .expect("This should work?");
+
+    Ok(())
+}
+
+#[derive(Serialize, Deserialize)]
+/// See: <https://wiki.sponsor.ajay.app/w/API_Docs/DeArrow>
+struct DeArrowApi {
+    titles: Vec<Title>,
+    thumbnails: Vec<Thumbnail>,
+
+    #[serde(alias = "randomTime")]
+    random_time: Option<f64>,
+
+    #[serde(alias = "videoDuration")]
+    video_duration: Option<f64>,
+
+    #[serde(alias = "casualVotes")]
+    casual_votes: Vec<CasualVote>,
+}
+
+#[derive(Serialize, Deserialize)]
+struct CasualVote {
+    id: String,
+    count: u32,
+    title: String,
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+struct Title {
+    /// Note: Titles will sometimes contain > before a word.
+    /// This tells the auto-formatter to not format a word.
+    /// If you have no auto-formatter, you can ignore this and replace it with an empty string
+    #[serde(alias = "title")]
+    value: String,
+
+    original: bool,
+    votes: u64,
+    locked: bool,
+
+    #[serde(alias = "UUID")]
+    uuid: String,
+
+    /// only present if requested
+    #[serde(alias = "userID")]
+    user_id: Option<String>,
+}
+
+#[derive(Serialize, Deserialize)]
+struct Thumbnail {
+    // null if original is true
+    timestamp: Option<f64>,
+
+    original: bool,
+    votes: u64,
+    locked: bool,
+
+    #[serde(alias = "UUID")]
+    uuid: String,
+
+    /// only present if requested
+    #[serde(alias = "userID")]
+    user_id: Option<String>,
+}
diff --git a/crates/yt_dlp/src/post_processors/mod.rs b/crates/yt_dlp/src/post_processors/mod.rs
new file mode 100644
index 0000000..d9be3f5
--- /dev/null
+++ b/crates/yt_dlp/src/post_processors/mod.rs
@@ -0,0 +1,48 @@
+// yt - A fully featured command line YouTube client
+//
+// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Yt.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
+pub mod dearrow;
+
+#[macro_export]
+macro_rules! pydict_get {
+    ($value:expr, $name:literal, $into:ty) => {{
+        let item = $value.get_item(pyo3::intern!($value.py(), $name));
+        match &item {
+            Ok(val) => $crate::pydict_cast!(val, $into),
+            Err(_) => panic!(
+                concat!(
+                    "Expected '",
+                    $name,
+                    "' to be a key for the'",
+                    stringify!($value),
+                    "' py dictionary: {:#?}"
+                ),
+                $value
+            ),
+        }
+    }};
+}
+
+#[macro_export]
+macro_rules! pydict_cast {
+    ($value:expr, $into:ty) => {{
+        match $value.extract::<$into>() {
+            Ok(result) => result,
+            Err(val) => panic!(
+                concat!(
+                    "Expected to be able to extract ",
+                    stringify!($into),
+                    " from value ({:#?})."
+                ),
+                val
+            ),
+        }
+    }};
+}
diff --git a/crates/yt_dlp/src/progress_hook.rs b/crates/yt_dlp/src/progress_hook.rs
index 7a7628a..7e5f8a5 100644
--- a/crates/yt_dlp/src/progress_hook.rs
+++ b/crates/yt_dlp/src/progress_hook.rs
@@ -1,41 +1,67 @@
+// yt - A fully featured command line YouTube client
+//
+// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Yt.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
 #[macro_export]
-macro_rules! mk_python_function {
+macro_rules! wrap_progress_hook {
     ($name:ident, $new_name:ident) => {
-        pub fn $new_name(
-            mut args: $crate::progress_hook::rustpython::vm::function::FuncArgs,
-            vm: &$crate::progress_hook::rustpython::vm::VirtualMachine,
-        ) {
-            use $crate::progress_hook::rustpython;
-
-            let input = {
-                let dict: rustpython::vm::PyRef<rustpython::vm::builtins::PyDict> = args
-                    .args
-                    .remove(0)
-                    .downcast()
-                    .expect("The progress hook is always called with these args");
-                let new_dict = rustpython::vm::builtins::PyDict::new_ref(&vm.ctx);
-                dict.into_iter()
-                    .filter_map(|(name, value)| {
-                        let real_name: rustpython::vm::PyRefExact<rustpython::vm::builtins::PyStr> =
-                            name.downcast_exact(vm).expect("Is a string");
-                        let name_str = real_name.to_str().expect("Is a string");
-                        if name_str.starts_with('_') {
-                            None
-                        } else {
-                            Some((name_str.to_owned(), value))
-                        }
-                    })
-                    .for_each(|(key, value)| {
-                        new_dict
-                            .set_item(&key, value, vm)
-                            .expect("This is a transpositions, should always be valid");
-                    });
-
-                $crate::json_dumps(new_dict, vm)
-            };
-            $name(input).expect("Shall not fail!");
+        pub(crate) fn $new_name(
+            py: yt_dlp::progress_hook::__priv::pyo3::Python<'_>,
+        ) -> yt_dlp::progress_hook::__priv::pyo3::PyResult<
+            yt_dlp::progress_hook::__priv::pyo3::Bound<
+                '_,
+                yt_dlp::progress_hook::__priv::pyo3::types::PyCFunction,
+            >,
+        > {
+            #[yt_dlp::progress_hook::__priv::pyo3::pyfunction]
+            #[pyo3(crate = "yt_dlp::progress_hook::__priv::pyo3")]
+            fn inner(
+                input: yt_dlp::progress_hook::__priv::pyo3::Bound<
+                    '_,
+                    yt_dlp::progress_hook::__priv::pyo3::types::PyDict,
+                >,
+            ) -> yt_dlp::progress_hook::__priv::pyo3::PyResult<()> {
+                let processed_input = {
+                    let new_dict = yt_dlp::progress_hook::__priv::pyo3::types::PyDict::new(input.py());
+
+                    input
+                        .into_iter()
+                        .filter_map(|(name, value)| {
+                            let real_name = yt_dlp::progress_hook::__priv::pyo3::types::PyAnyMethods::extract::<String>(&name).expect("Should always be a string");
+
+                            if real_name.starts_with('_') {
+                                None
+                            } else {
+                                Some((real_name, value))
+                            }
+                        })
+                        .for_each(|(key, value)| {
+                            yt_dlp::progress_hook::__priv::pyo3::types::PyDictMethods::set_item(&new_dict, &key, value)
+                                .expect("This is a transpositions, should always be valid");
+                        });
+                    yt_dlp::progress_hook::__priv::json_dumps(&new_dict)
+                };
+
+                $name(processed_input)?;
+
+                Ok(())
+            }
+
+            let module = yt_dlp::progress_hook::__priv::pyo3::types::PyModule::new(py, "progress_hook")?;
+            let fun = yt_dlp::progress_hook::__priv::pyo3::wrap_pyfunction!(inner, module)?;
+
+            Ok(fun)
         }
     };
 }
 
-pub use rustpython;
+pub mod __priv {
+    pub use crate::info_json::{json_dumps, json_loads};
+    pub use pyo3;
+}
diff --git a/crates/yt_dlp/src/python_error.rs b/crates/yt_dlp/src/python_error.rs
new file mode 100644
index 0000000..0c442b3
--- /dev/null
+++ b/crates/yt_dlp/src/python_error.rs
@@ -0,0 +1,55 @@
+// yt - A fully featured command line YouTube client
+//
+// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Yt.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
+use std::fmt::{self, Display};
+
+use log::{Level, debug, log_enabled};
+use pyo3::{PyErr, Python, types::PyTracebackMethods};
+
+#[derive(thiserror::Error, Debug)]
+pub struct PythonError(pub String);
+
+pub(crate) trait IntoPythonError<T>: Sized {
+    fn wrap_exc(self, py: Python<'_>) -> Result<T, PythonError>;
+}
+
+impl<T> IntoPythonError<T> for Result<T, PyErr> {
+    fn wrap_exc(self, py: Python<'_>) -> Result<T, PythonError> {
+        self.map_err(|exc| PythonError::from_exception(py, &exc))
+    }
+}
+
+impl Display for PythonError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "Python threw an exception: {}", self.0)
+    }
+}
+
+impl PythonError {
+    pub(super) fn from_exception(py: Python<'_>, exc: &PyErr) -> Self {
+        let buffer = process_exception(py, exc);
+        Self(buffer)
+    }
+}
+
+pub(super) fn process_exception(py: Python<'_>, err: &PyErr) -> String {
+    if log_enabled!(Level::Debug) {
+        let mut output = err.to_string();
+
+        if let Some(tb) = err.traceback(py) {
+            output.push('\n');
+            output.push_str(&tb.format().unwrap());
+        }
+
+        debug!("Python threw an exception: {output}");
+    }
+
+    err.to_string()
+}