about summary refs log tree commit diff stats
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--crates/fmt/Cargo.toml30
-rw-r--r--crates/fmt/LICENSE18
-rw-r--r--crates/fmt/LICENSE.license10
-rw-r--r--crates/fmt/src/fmt.rs137
-rw-r--r--crates/fmt/src/linebreak.rs520
-rw-r--r--crates/fmt/src/parasplit.rs629
-rw-r--r--crates/libmpv2/Cargo.toml1
-rw-r--r--crates/libmpv2/examples/events.rs34
-rw-r--r--crates/libmpv2/examples/opengl.rs19
-rw-r--r--crates/libmpv2/src/lib.rs2
-rw-r--r--crates/libmpv2/src/mpv.rs41
-rw-r--r--crates/libmpv2/src/mpv/errors.rs95
-rw-r--r--crates/libmpv2/src/mpv/events.rs4
-rw-r--r--crates/libmpv2/src/mpv/protocol.rs12
-rw-r--r--crates/libmpv2/src/mpv/raw_error_warning.txt5
-rw-r--r--crates/libmpv2/src/mpv/raw_error_warning.txt.license (renamed from package/blake3/add_cargo_lock.patch.license)2
-rw-r--r--crates/libmpv2/src/mpv/render.rs4
-rw-r--r--crates/libmpv2/src/tests.rs24
-rw-r--r--crates/termsize/.gitignore (renamed from .env)8
-rw-r--r--crates/termsize/Cargo.toml36
-rw-r--r--crates/termsize/LICENSE20
-rw-r--r--crates/termsize/LICENSE.license9
-rw-r--r--crates/termsize/README.md51
-rw-r--r--crates/termsize/src/lib.rs52
-rw-r--r--crates/termsize/src/nix.rs100
-rw-r--r--crates/termsize/src/other.rs14
-rw-r--r--crates/termsize/src/win.rs52
-rw-r--r--crates/yt_dlp/Cargo.toml2
-rw-r--r--crates/yt_dlp/src/error.rs68
-rw-r--r--crates/yt_dlp/src/lib.rs151
-rw-r--r--crates/yt_dlp/src/logging.rs5
-rw-r--r--crates/yt_dlp/src/python_json_decode_failed.error_msg5
-rw-r--r--crates/yt_dlp/src/python_json_decode_failed.error_msg.license9
-rw-r--r--crates/yt_dlp/src/tests.rs6
-rw-r--r--crates/yt_dlp/src/wrapper/info_json.rs278
-rw-r--r--crates/yt_dlp/src/wrapper/yt_dlp_options.rs2
36 files changed, 2309 insertions, 146 deletions
diff --git a/crates/fmt/Cargo.toml b/crates/fmt/Cargo.toml
new file mode 100644
index 0000000..7f82a09
--- /dev/null
+++ b/crates/fmt/Cargo.toml
@@ -0,0 +1,30 @@
+# yt - A fully featured command line YouTube client
+#
+# Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+# Copyright (C) 2025 uutils developers
+# SPDX-License-Identifier: MIT
+#
+# 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>.
+
+[package]
+name = "uu_fmt"
+authors = ["uutils developers", "Benedikt Peetz <benedikt.peetz@b-peetz.de>"]
+license = "MIT"
+description = "A fork of the uutils fmt tool. This fork is a library instead of a binary."
+version.workspace = true
+edition.workspace = true
+repository.workspace = true
+rust-version.workspace = true
+publish = false
+
+[lib]
+path = "src/fmt.rs"
+
+[dependencies]
+unicode-width = "0.2.0"
+
+[lints]
+workspace = true
diff --git a/crates/fmt/LICENSE b/crates/fmt/LICENSE
new file mode 100644
index 0000000..21bd444
--- /dev/null
+++ b/crates/fmt/LICENSE
@@ -0,0 +1,18 @@
+Copyright (c) uutils developers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/crates/fmt/LICENSE.license b/crates/fmt/LICENSE.license
new file mode 100644
index 0000000..6cee99d
--- /dev/null
+++ b/crates/fmt/LICENSE.license
@@ -0,0 +1,10 @@
+yt - A fully featured command line YouTube client
+
+Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+Copyright (C) 2025 uutils developers
+SPDX-License-Identifier: MIT
+
+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>.
diff --git a/crates/fmt/src/fmt.rs b/crates/fmt/src/fmt.rs
new file mode 100644
index 0000000..3067bea
--- /dev/null
+++ b/crates/fmt/src/fmt.rs
@@ -0,0 +1,137 @@
+// yt - A fully featured command line YouTube client
+//
+// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// Copyright (C) 2025 uutils developers
+// SPDX-License-Identifier: MIT
+//
+// 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 part of the uutils coreutils package.
+//
+// For the full copyright and license information, please view the LICENSE
+// file that was distributed with this source code.
+
+use std::fmt::Write;
+
+use linebreak::break_lines;
+use parasplit::ParagraphStream;
+
+mod linebreak;
+mod parasplit;
+
+#[derive(Debug)]
+#[allow(clippy::struct_excessive_bools)]
+pub struct FmtOptions {
+    /// First and second line of paragraph
+    /// may have different indentations, in which
+    /// case the first line's indentation is preserved,
+    /// and each subsequent line's indentation matches the second line.
+    pub crown_margin: bool,
+
+    /// Like the [`crown_margin`], except that the first and second line of a paragraph *must*
+    /// have different indentation or they are treated as separate paragraphs.
+    pub tagged_paragraph: bool,
+
+    /// Attempt to detect and preserve mail headers in the input.
+    /// Be careful when combining this with [`prefix`].
+    pub mail: bool,
+
+    /// Split lines only, do not reflow.
+    pub split_only: bool,
+
+    /// Insert exactly one space between words, and two between sentences.
+    /// Sentence breaks in the input are detected as [?!.] followed by two spaces or a newline;
+    /// other punctuation is not interpreted as a sentence break.
+    pub uniform: bool,
+
+    /// Reformat only lines beginning with PREFIX, reattaching PREFIX to reformatted lines.
+    /// Unless [`exact_prefix`] is specified, leading whitespace will be ignored when matching PREFIX.
+    pub prefix: Option<String>,
+
+    /// Do not reformat lines beginning with ``ANTI_PREFIX``.
+    /// Unless [`exact_anti_prefix`] is specified, leading whitespace will be ignored when matching ``ANTI_PREFIX``.
+    pub anti_prefix: Option<String>,
+
+    /// [`prefix`] must match at the beginning of the line with no preceding whitespace.
+    pub exact_prefix: bool,
+
+    /// [`anti_prefix`] must match at the beginning of the line with no preceding whitespace.
+    pub exact_anti_prefix: bool,
+
+    /// Fill output lines up to a maximum of WIDTH columns, default 75.
+    pub width: usize,
+
+    /// Goal width, default of 93% of WIDTH.
+    /// Must be less than or equal to WIDTH.
+    pub goal: usize,
+
+    /// Break lines more quickly at the expense of a potentially more ragged appearance.
+    pub quick: bool,
+
+    /// Treat tabs as TABWIDTH spaces for determining line length, default 8.
+    /// Note that this is used only for calculating line lengths; tabs are preserved in the output.
+    pub tabwidth: usize,
+}
+
+impl FmtOptions {
+    #[must_use]
+    #[allow(clippy::cast_sign_loss)]
+    #[allow(clippy::cast_possible_truncation)]
+    #[allow(clippy::cast_precision_loss)]
+    pub fn new(width: Option<usize>, goal: Option<usize>, tabwidth: Option<usize>) -> Self {
+        // by default, goal is 93% of width
+        const DEFAULT_GOAL_TO_WIDTH_RATIO: f64 = 0.93;
+        const DEFAULT_WIDTH: usize = 75;
+
+        FmtOptions {
+            crown_margin: false,
+            tagged_paragraph: false,
+            mail: false,
+            split_only: false,
+            uniform: false,
+            prefix: None,
+            anti_prefix: None,
+            exact_prefix: false,
+            exact_anti_prefix: false,
+            width: width.unwrap_or(DEFAULT_WIDTH),
+            goal: goal.unwrap_or(
+                ((width.unwrap_or(DEFAULT_WIDTH) as f64) * DEFAULT_GOAL_TO_WIDTH_RATIO).floor()
+                    as usize,
+            ),
+            quick: false,
+            tabwidth: tabwidth.unwrap_or(8),
+        }
+    }
+}
+
+/// Process text and format it according to the provided options.
+///
+/// # Arguments
+///
+/// * `text` - The text to process.
+/// * `fmt_opts` - A reference to a [`FmtOptions`] structure containing the formatting options.
+///
+/// # Returns
+///
+/// The formatted [`String`].
+#[must_use]
+pub fn process_text(text: &str, fmt_opts: &FmtOptions) -> String {
+    let mut output = String::new();
+
+    let p_stream = ParagraphStream::new(fmt_opts, text);
+    for para_result in p_stream {
+        match para_result {
+            Err(s) => {
+                output.push_str(&s);
+                output.push('\n');
+            }
+            Ok(para) => write!(output, "{}", break_lines(&para, fmt_opts))
+                .expect("This is in-memory. It should not fail"),
+        }
+    }
+
+    output
+}
diff --git a/crates/fmt/src/linebreak.rs b/crates/fmt/src/linebreak.rs
new file mode 100644
index 0000000..b1dc6fa
--- /dev/null
+++ b/crates/fmt/src/linebreak.rs
@@ -0,0 +1,520 @@
+// yt - A fully featured command line YouTube client
+//
+// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// Copyright (C) 2025 uutils developers
+// SPDX-License-Identifier: MIT
+//
+// 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 part of the uutils coreutils package.
+//
+// For the full copyright and license information, please view the LICENSE
+// file that was distributed with this source code.
+
+use std::fmt::Write;
+use std::{cmp, mem};
+
+use crate::FmtOptions;
+use crate::parasplit::{ParaWords, Paragraph, WordInfo};
+
+struct BreakArgs<'a> {
+    opts: &'a FmtOptions,
+    init_len: usize,
+    indent_str: &'a str,
+    indent_len: usize,
+    uniform: bool,
+    output: String,
+}
+
+impl BreakArgs<'_> {
+    fn compute_width(&self, winfo: &WordInfo<'_>, position_n: usize, fresh: bool) -> usize {
+        if fresh {
+            0
+        } else {
+            let post = winfo.after_tab;
+            match winfo.before_tab {
+                None => post,
+                Some(pre) => {
+                    post + ((pre + position_n) / self.opts.tabwidth + 1) * self.opts.tabwidth
+                        - position_n
+                }
+            }
+        }
+    }
+}
+
+pub(super) fn break_lines(para: &Paragraph, opts: &FmtOptions) -> String {
+    let mut output = String::new();
+
+    // indent
+    let p_indent = &para.indent_str;
+    let p_indent_len = para.indent_len;
+
+    // words
+    let p_words = ParaWords::new(opts, para);
+    let mut p_words_words = p_words.words();
+
+    // the first word will *always* appear on the first line
+    // make sure of this here
+    let Some(winfo) = p_words_words.next() else {
+        return "\n".to_owned();
+    };
+
+    // print the init, if it exists, and get its length
+    let p_init_len = winfo.word_nchars
+        + if opts.crown_margin || opts.tagged_paragraph {
+            // handle "init" portion
+            output.push_str(&para.init_str);
+            para.init_len
+        } else if !para.mail_header {
+            // for non-(crown, tagged) that's the same as a normal indent
+            output.push_str(p_indent);
+            p_indent_len
+        } else {
+            // except that mail headers get no indent at all
+            0
+        };
+
+    // write first word after writing init
+    write!(output, "{}", winfo.word).expect("Works");
+
+    // does this paragraph require uniform spacing?
+    let uniform = para.mail_header || opts.uniform;
+
+    let mut break_args = BreakArgs {
+        opts,
+        init_len: p_init_len,
+        indent_str: p_indent,
+        indent_len: p_indent_len,
+        uniform,
+        output,
+    };
+
+    if opts.quick || para.mail_header {
+        break_simple(p_words_words, &mut break_args);
+    } else {
+        break_knuth_plass(p_words_words, &mut break_args);
+    };
+
+    break_args.output
+}
+
+// break_simple implements a "greedy" breaking algorithm: print words until
+// maxlength would be exceeded, then print a linebreak and indent and continue.
+fn break_simple<'a, T: Iterator<Item = &'a WordInfo<'a>>>(iter: T, args: &mut BreakArgs<'a>) {
+    iter.fold((args.init_len, false), |(l, prev_punct), winfo| {
+        accum_words_simple(args, l, prev_punct, winfo)
+    });
+    args.output.push('\n');
+}
+
+fn accum_words_simple<'a>(
+    args: &mut BreakArgs<'a>,
+    l: usize,
+    prev_punct: bool,
+    winfo: &'a WordInfo<'a>,
+) -> (usize, bool) {
+    // compute the length of this word, considering how tabs will expand at this position on the line
+    let wlen = winfo.word_nchars + args.compute_width(winfo, l, false);
+
+    let slen = compute_slen(
+        args.uniform,
+        winfo.new_line,
+        winfo.sentence_start,
+        prev_punct,
+    );
+
+    if l + wlen + slen > args.opts.width {
+        write_newline(args.indent_str, &mut args.output);
+        write_with_spaces(&winfo.word[winfo.word_start..], 0, &mut args.output);
+        (args.indent_len + winfo.word_nchars, winfo.ends_punct)
+    } else {
+        write_with_spaces(winfo.word, slen, &mut args.output);
+        (l + wlen + slen, winfo.ends_punct)
+    }
+}
+
+// break_knuth_plass implements an "optimal" breaking algorithm in the style of
+//    Knuth, D.E., and Plass, M.F. "Breaking Paragraphs into Lines." in Software,
+//    Practice and Experience. Vol. 11, No. 11, November 1981.
+//    http://onlinelibrary.wiley.com/doi/10.1002/spe.4380111102/pdf
+#[allow(trivial_casts)]
+fn break_knuth_plass<'a, T: Clone + Iterator<Item = &'a WordInfo<'a>>>(
+    mut iter: T,
+    args: &mut BreakArgs<'a>,
+) {
+    // run the algorithm to get the breakpoints
+    let breakpoints = find_kp_breakpoints(iter.clone(), args);
+
+    // iterate through the breakpoints (note that breakpoints is in reverse break order, so we .rev() it
+    let result: (bool, bool) = breakpoints.iter().rev().fold(
+        (false, false),
+        |(mut prev_punct, mut fresh), &(next_break, break_before)| {
+            if fresh {
+                write_newline(args.indent_str, &mut args.output);
+            }
+            // at each breakpoint, keep emitting words until we find the word matching this breakpoint
+            for winfo in &mut iter {
+                let (slen, word) = slice_if_fresh(
+                    fresh,
+                    winfo.word,
+                    winfo.word_start,
+                    args.uniform,
+                    winfo.new_line,
+                    winfo.sentence_start,
+                    prev_punct,
+                );
+                fresh = false;
+                prev_punct = winfo.ends_punct;
+
+                // We find identical breakpoints here by comparing addresses of the references.
+                // This is OK because the backing vector is not mutating once we are linebreaking.
+                let winfo_ptr = winfo as *const _;
+                let next_break_ptr = next_break as *const _;
+                if winfo_ptr == next_break_ptr {
+                    // OK, we found the matching word
+                    if break_before {
+                        write_newline(args.indent_str, &mut args.output);
+                        write_with_spaces(&winfo.word[winfo.word_start..], 0, &mut args.output);
+                    } else {
+                        // breaking after this word, so that means "fresh" is true for the next iteration
+                        write_with_spaces(word, slen, &mut args.output);
+                        fresh = true;
+                    }
+                    break;
+                }
+                write_with_spaces(word, slen, &mut args.output);
+            }
+            (prev_punct, fresh)
+        },
+    );
+    let (mut prev_punct, mut fresh) = result;
+
+    // after the last linebreak, write out the rest of the final line.
+    for winfo in iter {
+        if fresh {
+            write_newline(args.indent_str, &mut args.output);
+        }
+        let (slen, word) = slice_if_fresh(
+            fresh,
+            winfo.word,
+            winfo.word_start,
+            args.uniform,
+            winfo.new_line,
+            winfo.sentence_start,
+            prev_punct,
+        );
+        prev_punct = winfo.ends_punct;
+        fresh = false;
+        write_with_spaces(word, slen, &mut args.output);
+    }
+
+    args.output.push('\n');
+}
+
+struct LineBreak<'a> {
+    prev: usize,
+    linebreak: Option<&'a WordInfo<'a>>,
+    break_before: bool,
+    demerits: i64,
+    prev_rat: f32,
+    length: usize,
+    fresh: bool,
+}
+
+#[allow(clippy::cognitive_complexity)]
+#[allow(clippy::cast_possible_wrap)]
+fn find_kp_breakpoints<'a, T: Iterator<Item = &'a WordInfo<'a>>>(
+    iter: T,
+    args: &BreakArgs<'a>,
+) -> Vec<(&'a WordInfo<'a>, bool)> {
+    let mut iter = iter.peekable();
+    // set up the initial null linebreak
+    let mut linebreaks = vec![LineBreak {
+        prev: 0,
+        linebreak: None,
+        break_before: false,
+        demerits: 0,
+        prev_rat: 0.0,
+        length: args.init_len,
+        fresh: false,
+    }];
+    // this vec holds the current active linebreaks; next_ holds the breaks that will be active for
+    // the next word
+    let mut active_breaks = vec![0];
+    let mut next_active_breaks = vec![];
+
+    let stretch = args.opts.width - args.opts.goal;
+    let minlength = args.opts.goal.max(stretch + 1) - stretch;
+    let mut new_linebreaks = vec![];
+    let mut is_sentence_start = false;
+    let mut least_demerits = 0;
+    loop {
+        let Some(w) = iter.next() else { break };
+
+        // if this is the last word, we don't add additional demerits for this break
+        let (is_last_word, is_sentence_end) = match iter.peek() {
+            None => (true, true),
+            Some(&&WordInfo {
+                sentence_start: st,
+                new_line: nl,
+                ..
+            }) => (false, st || (nl && w.ends_punct)),
+        };
+
+        // should we be adding extra space at the beginning of the next sentence?
+        let slen = compute_slen(args.uniform, w.new_line, is_sentence_start, false);
+
+        let mut ld_new = i64::MAX;
+        let mut ld_next = i64::MAX;
+        let mut ld_idx = 0;
+        new_linebreaks.clear();
+        next_active_breaks.clear();
+        // go through each active break, extending it and possibly adding a new active
+        // break if we are above the minimum required length
+        #[allow(clippy::explicit_iter_loop)]
+        for &i in active_breaks.iter() {
+            let active = &mut linebreaks[i];
+            // normalize demerits to avoid overflow, and record if this is the least
+            active.demerits -= least_demerits;
+            if active.demerits < ld_next {
+                ld_next = active.demerits;
+                ld_idx = i;
+            }
+
+            // get the new length
+            let tlen = w.word_nchars
+                + args.compute_width(w, active.length, active.fresh)
+                + slen
+                + active.length;
+
+            // if tlen is longer than args.opts.width, we drop this break from the active list
+            // otherwise, we extend the break, and possibly add a new break at this point
+            if tlen <= args.opts.width {
+                // this break will still be active next time
+                next_active_breaks.push(i);
+                // we can put this word on this line
+                active.fresh = false;
+                active.length = tlen;
+
+                // if we're above the minlength, we can also consider breaking here
+                if tlen >= minlength {
+                    let (new_demerits, new_ratio) = if is_last_word {
+                        // there is no penalty for the final line's length
+                        (0, 0.0)
+                    } else {
+                        compute_demerits(
+                            args.opts.goal as isize - tlen as isize,
+                            stretch,
+                            w.word_nchars,
+                            active.prev_rat,
+                        )
+                    };
+
+                    // do not even consider adding a line that has too many demerits
+                    // also, try to detect overflow by checking signum
+                    let total_demerits = new_demerits + active.demerits;
+                    if new_demerits < BAD_INFTY_SQ
+                        && total_demerits < ld_new
+                        && active.demerits.signum() <= new_demerits.signum()
+                    {
+                        ld_new = total_demerits;
+                        new_linebreaks.push(LineBreak {
+                            prev: i,
+                            linebreak: Some(w),
+                            break_before: false,
+                            demerits: total_demerits,
+                            prev_rat: new_ratio,
+                            length: args.indent_len,
+                            fresh: true,
+                        });
+                    }
+                }
+            }
+        }
+
+        // if we generated any new linebreaks, add the last one to the list
+        // the last one is always the best because we don't add to new_linebreaks unless
+        // it's better than the best one so far
+        match new_linebreaks.pop() {
+            None => (),
+            Some(lb) => {
+                next_active_breaks.push(linebreaks.len());
+                linebreaks.push(lb);
+            }
+        }
+
+        if next_active_breaks.is_empty() {
+            // every potential linebreak is too long! choose the linebreak with the least demerits, ld_idx
+            let new_break =
+                restart_active_breaks(args, &linebreaks[ld_idx], ld_idx, w, slen, minlength);
+            next_active_breaks.push(linebreaks.len());
+            linebreaks.push(new_break);
+            least_demerits = 0;
+        } else {
+            // next time around, normalize out the demerits fields
+            // on active linebreaks to make overflow less likely
+            least_demerits = cmp::max(ld_next, 0);
+        }
+        // swap in new list of active breaks
+        mem::swap(&mut active_breaks, &mut next_active_breaks);
+        // If this was the last word in a sentence, the next one must be the first in the next.
+        is_sentence_start = is_sentence_end;
+    }
+
+    // return the best path
+    build_best_path(&linebreaks, &active_breaks)
+}
+
+fn build_best_path<'a>(paths: &[LineBreak<'a>], active: &[usize]) -> Vec<(&'a WordInfo<'a>, bool)> {
+    // of the active paths, we select the one with the fewest demerits
+    active
+        .iter()
+        .min_by_key(|&&a| paths[a].demerits)
+        .map(|&(mut best_idx)| {
+            let mut breakwords = vec![];
+            // now, chase the pointers back through the break list, recording
+            // the words at which we should break
+            loop {
+                let next_best = &paths[best_idx];
+                match next_best.linebreak {
+                    None => return breakwords,
+                    Some(prev) => {
+                        breakwords.push((prev, next_best.break_before));
+                        best_idx = next_best.prev;
+                    }
+                }
+            }
+        })
+        .unwrap_or_default()
+}
+
+// "infinite" badness is more like (1+BAD_INFTY)^2 because of how demerits are computed
+const BAD_INFTY: i64 = 10_000_000;
+const BAD_INFTY_SQ: i64 = BAD_INFTY * BAD_INFTY;
+// badness = BAD_MULT * abs(r) ^ 3
+const BAD_MULT: f32 = 100.0;
+// DR_MULT is multiplier for delta-R between lines
+const DR_MULT: f32 = 600.0;
+// DL_MULT is penalty multiplier for short words at end of line
+const DL_MULT: f32 = 300.0;
+
+#[allow(clippy::cast_precision_loss)]
+#[allow(clippy::cast_possible_truncation)]
+fn compute_demerits(delta_len: isize, stretch: usize, wlen: usize, prev_rat: f32) -> (i64, f32) {
+    // how much stretch are we using?
+    let ratio = if delta_len == 0 {
+        0.0f32
+    } else {
+        delta_len as f32 / stretch as f32
+    };
+
+    // compute badness given the stretch ratio
+    let bad_linelen = if ratio.abs() > 1.0f32 {
+        BAD_INFTY
+    } else {
+        (BAD_MULT * ratio.powi(3).abs()) as i64
+    };
+
+    // we penalize lines ending in really short words
+    let bad_wordlen = if wlen >= stretch {
+        0
+    } else {
+        (DL_MULT
+            * ((stretch - wlen) as f32 / (stretch - 1) as f32)
+                .powi(3)
+                .abs()) as i64
+    };
+
+    // we penalize lines that have very different ratios from previous lines
+    let bad_delta_r = (DR_MULT * ((ratio - prev_rat) / 2.0).powi(3).abs()) as i64;
+
+    let demerits = i64::pow(1 + bad_linelen + bad_wordlen + bad_delta_r, 2);
+
+    (demerits, ratio)
+}
+
+#[allow(clippy::cast_possible_wrap)]
+fn restart_active_breaks<'a>(
+    args: &BreakArgs<'a>,
+    active: &LineBreak<'a>,
+    act_idx: usize,
+    w: &'a WordInfo<'a>,
+    slen: usize,
+    min: usize,
+) -> LineBreak<'a> {
+    let (break_before, line_length) = if active.fresh {
+        // never break before a word if that word would be the first on a line
+        (false, args.indent_len)
+    } else {
+        // choose the lesser evil: breaking too early, or breaking too late
+        let wlen = w.word_nchars + args.compute_width(w, active.length, active.fresh);
+        let underlen = min as isize - active.length as isize;
+        let overlen = (wlen + slen + active.length) as isize - args.opts.width as isize;
+        if overlen > underlen {
+            // break early, put this word on the next line
+            (true, args.indent_len + w.word_nchars)
+        } else {
+            (false, args.indent_len)
+        }
+    };
+
+    // restart the linebreak. This will be our only active path.
+    LineBreak {
+        prev: act_idx,
+        linebreak: Some(w),
+        break_before,
+        demerits: 0, // this is the only active break, so we can reset the demerit count
+        prev_rat: if break_before { 1.0 } else { -1.0 },
+        length: line_length,
+        fresh: !break_before,
+    }
+}
+
+// Number of spaces to add before a word, based on mode, newline, sentence start.
+#[allow(clippy::fn_params_excessive_bools)]
+fn compute_slen(uniform: bool, newline: bool, start: bool, punct: bool) -> usize {
+    if uniform || newline {
+        if start || (newline && punct) { 2 } else { 1 }
+    } else {
+        0
+    }
+}
+
+// If we're on a fresh line, slen=0 and we slice off leading whitespace.
+// Otherwise, compute slen and leave whitespace alone.
+#[allow(clippy::fn_params_excessive_bools)]
+fn slice_if_fresh(
+    fresh: bool,
+    word: &str,
+    start: usize,
+    uniform: bool,
+    newline: bool,
+    second_start: bool,
+    punct: bool,
+) -> (usize, &str) {
+    if fresh {
+        (0, &word[start..])
+    } else {
+        (compute_slen(uniform, newline, second_start, punct), word)
+    }
+}
+
+// Write a newline and add the indent.
+fn write_newline(indent: &str, output: &mut String) {
+    output.push('\n');
+    output.push_str(indent);
+}
+
+// Write the word, along with slen spaces.
+fn write_with_spaces(word: &str, slen: usize, output: &mut String) {
+    if slen == 2 {
+        output.push_str("  ");
+    } else if slen == 1 {
+        output.push(' ');
+    }
+    output.push_str(word);
+}
diff --git a/crates/fmt/src/parasplit.rs b/crates/fmt/src/parasplit.rs
new file mode 100644
index 0000000..d4723cb
--- /dev/null
+++ b/crates/fmt/src/parasplit.rs
@@ -0,0 +1,629 @@
+// yt - A fully featured command line YouTube client
+//
+// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// Copyright (C) 2025 uutils developers
+// SPDX-License-Identifier: MIT
+//
+// 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 part of the uutils coreutils package.
+//
+// For the full copyright and license information, please view the LICENSE
+// file that was distributed with this source code.
+
+use std::iter::Peekable;
+use std::slice::Iter;
+use unicode_width::UnicodeWidthChar;
+
+use crate::FmtOptions;
+
+fn char_width(c: char) -> usize {
+    if (c as usize) < 0xA0 {
+        // if it is ASCII, call it exactly 1 wide (including control chars)
+        // calling control chars' widths 1 is consistent with OpenBSD fmt
+        1
+    } else {
+        // otherwise, get the unicode width
+        // note that we shouldn't actually get None here because only c < 0xA0
+        // can return None, but for safety and future-proofing we do it this way
+        UnicodeWidthChar::width(c).unwrap_or(1)
+    }
+}
+
+// lines with PSKIP, lacking PREFIX, or which are entirely blank are
+// NoFormatLines; otherwise, they are FormatLines
+#[derive(Debug)]
+pub(super) enum Line {
+    FormatLine(FileLine),
+    NoFormatLine(String, bool),
+}
+
+impl Line {
+    // when we know that it's a FormatLine, as in the ParagraphStream iterator
+    fn get_formatline(self) -> FileLine {
+        match self {
+            Self::FormatLine(fl) => fl,
+            Self::NoFormatLine(..) => panic!("Found NoFormatLine when expecting FormatLine"),
+        }
+    }
+
+    // when we know that it's a NoFormatLine, as in the ParagraphStream iterator
+    fn get_noformatline(self) -> (String, bool) {
+        match self {
+            Self::NoFormatLine(s, b) => (s, b),
+            Self::FormatLine(..) => panic!("Found FormatLine when expecting NoFormatLine"),
+        }
+    }
+}
+
+/// Each line's prefix has to be considered to know whether to merge it with
+/// the next line or not
+#[derive(Debug)]
+pub(super) struct FileLine {
+    line: String,
+    /// The end of the indent, always the start of the text
+    indent_end: usize,
+
+    /// The end of the PREFIX's indent, that is, the spaces before the prefix
+    prefix_indent_end: usize,
+
+    /// Display length of indent taking into account tabs
+    indent_len: usize,
+
+    /// PREFIX indent length taking into account tabs
+    prefix_len: usize,
+}
+
+/// Iterator that produces a stream of Lines from a file
+pub(super) struct FileLines<'a> {
+    opts: &'a FmtOptions,
+    lines: std::str::Lines<'a>,
+}
+
+impl FileLines<'_> {
+    fn new<'b>(opts: &'b FmtOptions, lines: std::str::Lines<'b>) -> FileLines<'b> {
+        FileLines { opts, lines }
+    }
+
+    /// returns true if this line should be formatted
+    fn match_prefix(&self, line: &str) -> (bool, usize) {
+        let Some(prefix) = &self.opts.prefix else {
+            return (true, 0);
+        };
+
+        FileLines::match_prefix_generic(prefix, line, self.opts.exact_prefix)
+    }
+
+    /// returns true if this line should be formatted
+    fn match_anti_prefix(&self, line: &str) -> bool {
+        let Some(anti_prefix) = &self.opts.anti_prefix else {
+            return true;
+        };
+
+        match FileLines::match_prefix_generic(anti_prefix, line, self.opts.exact_anti_prefix) {
+            (true, _) => false,
+            (_, _) => true,
+        }
+    }
+
+    fn match_prefix_generic(pfx: &str, line: &str, exact: bool) -> (bool, usize) {
+        if line.starts_with(pfx) {
+            return (true, 0);
+        }
+
+        if !exact {
+            // we do it this way rather than byte indexing to support unicode whitespace chars
+            for (i, char) in line.char_indices() {
+                if line[i..].starts_with(pfx) {
+                    return (true, i);
+                } else if !char.is_whitespace() {
+                    break;
+                }
+            }
+        }
+
+        (false, 0)
+    }
+
+    fn compute_indent(&self, string: &str, prefix_end: usize) -> (usize, usize, usize) {
+        let mut prefix_len = 0;
+        let mut indent_len = 0;
+        let mut indent_end = 0;
+        for (os, c) in string.char_indices() {
+            if os == prefix_end {
+                // we found the end of the prefix, so this is the printed length of the prefix here
+                prefix_len = indent_len;
+            }
+
+            if (os >= prefix_end) && !c.is_whitespace() {
+                // found first non-whitespace after prefix, this is indent_end
+                indent_end = os;
+                break;
+            } else if c == '\t' {
+                // compute tab length
+                indent_len = (indent_len / self.opts.tabwidth + 1) * self.opts.tabwidth;
+            } else {
+                // non-tab character
+                indent_len += char_width(c);
+            }
+        }
+        (indent_end, prefix_len, indent_len)
+    }
+}
+
+impl Iterator for FileLines<'_> {
+    type Item = Line;
+
+    fn next(&mut self) -> Option<Line> {
+        let n = self.lines.next()?;
+
+        // if this line is entirely whitespace,
+        // emit a blank line
+        // Err(true) indicates that this was a linebreak,
+        // which is important to know when detecting mail headers
+        if n.chars().all(char::is_whitespace) {
+            return Some(Line::NoFormatLine(String::new(), true));
+        }
+
+        let (pmatch, poffset) = self.match_prefix(n);
+
+        // if this line does not match the prefix,
+        // emit the line unprocessed and iterate again
+        if !pmatch {
+            return Some(Line::NoFormatLine(n.to_owned(), false));
+        }
+
+        // if the line matches the prefix, but is blank after,
+        // don't allow lines to be combined through it (that is,
+        // treat it like a blank line, except that since it's
+        // not truly blank we will not allow mail headers on the
+        // following line)
+        if pmatch
+            && n[poffset + self.opts.prefix.as_ref().map_or(0, String::len)..]
+                .chars()
+                .all(char::is_whitespace)
+        {
+            return Some(Line::NoFormatLine(n.to_owned(), false));
+        }
+
+        // skip if this line matches the anti_prefix
+        // (NOTE definition of match_anti_prefix is TRUE if we should process)
+        if !self.match_anti_prefix(n) {
+            return Some(Line::NoFormatLine(n.to_owned(), false));
+        }
+
+        // figure out the indent, prefix, and prefixindent ending points
+        let prefix_end = poffset + self.opts.prefix.as_ref().map_or(0, String::len);
+        let (indent_end, prefix_len, indent_len) = self.compute_indent(n, prefix_end);
+
+        Some(Line::FormatLine(FileLine {
+            line: n.to_owned(),
+            indent_end,
+            prefix_indent_end: poffset,
+            indent_len,
+            prefix_len,
+        }))
+    }
+}
+
+/// A paragraph : a collection of [`FileLines`] that are to be formatted
+/// plus info about the paragraph's indentation
+///
+/// We only retain the String from the [`FileLine`]; the other info
+/// is only there to help us in deciding how to merge lines into Paragraphs
+#[derive(Debug)]
+pub(super) struct Paragraph {
+    /// the lines of the file
+    lines: Vec<String>,
+    /// string representing the init, that is, the first line's indent
+    pub init_str: String,
+    /// printable length of the init string considering TABWIDTH
+    pub init_len: usize,
+    /// byte location of end of init in first line String
+    init_end: usize,
+    /// string representing indent
+    pub indent_str: String,
+    /// length of above
+    pub indent_len: usize,
+    /// byte location of end of indent (in crown and tagged mode, only applies to 2nd line and onward)
+    indent_end: usize,
+    /// we need to know if this is a mail header because we do word splitting differently in that case
+    pub mail_header: bool,
+}
+
+/// An iterator producing a stream of paragraphs from a stream of lines
+/// given a set of options.
+pub(super) struct ParagraphStream<'a> {
+    lines: Peekable<FileLines<'a>>,
+    next_mail: bool,
+    opts: &'a FmtOptions,
+}
+
+impl ParagraphStream<'_> {
+    pub(super) fn new<'b>(opts: &'b FmtOptions, text: &'b str) -> ParagraphStream<'b> {
+        let lines = FileLines::new(opts, text.lines()).peekable();
+        // at the beginning of the file, we might find mail headers
+        ParagraphStream {
+            lines,
+            next_mail: true,
+            opts,
+        }
+    }
+
+    /// Detect RFC822 mail header
+    fn is_mail_header(line: &FileLine) -> bool {
+        // a mail header begins with either "From " (envelope sender line)
+        // or with a sequence of printable ASCII chars (33 to 126, inclusive,
+        // except colon) followed by a colon.
+        if line.indent_end > 0 {
+            false
+        } else {
+            let l_slice = &line.line[..];
+            if l_slice.starts_with("From ") {
+                true
+            } else {
+                let Some(colon_posn) = l_slice.find(':') else {
+                    return false;
+                };
+
+                // header field must be nonzero length
+                if colon_posn == 0 {
+                    return false;
+                }
+
+                l_slice[..colon_posn]
+                    .chars()
+                    .all(|x| !matches!(x as usize, y if !(33..=126).contains(&y)))
+            }
+        }
+    }
+}
+
+impl Iterator for ParagraphStream<'_> {
+    type Item = Result<Paragraph, String>;
+
+    #[allow(clippy::cognitive_complexity)]
+    fn next(&mut self) -> Option<Result<Paragraph, String>> {
+        // return a NoFormatLine in an Err; it should immediately be output
+        let noformat = match self.lines.peek()? {
+            Line::FormatLine(_) => false,
+            Line::NoFormatLine(_, _) => true,
+        };
+
+        // found a NoFormatLine, immediately dump it out
+        if noformat {
+            let (s, nm) = self.lines.next().unwrap().get_noformatline();
+            self.next_mail = nm;
+            return Some(Err(s));
+        }
+
+        // found a FormatLine, now build a paragraph
+        let mut init_str = String::new();
+        let mut init_end = 0;
+        let mut init_len = 0;
+        let mut indent_str = String::new();
+        let mut indent_end = 0;
+        let mut indent_len = 0;
+        let mut prefix_len = 0;
+        let mut prefix_indent_end = 0;
+        let mut p_lines = Vec::new();
+
+        let mut in_mail = false;
+        let mut second_done = false; // for when we use crown or tagged mode
+        loop {
+            // peek ahead
+            // need to explicitly force fl out of scope before we can call self.lines.next()
+            let Some(Line::FormatLine(fl)) = self.lines.peek() else {
+                break;
+            };
+
+            if p_lines.is_empty() {
+                // first time through the loop, get things set up
+                // detect mail header
+                if self.opts.mail && self.next_mail && ParagraphStream::is_mail_header(fl) {
+                    in_mail = true;
+                    // there can't be any indent or prefixindent because otherwise is_mail_header
+                    // would fail since there cannot be any whitespace before the colon in a
+                    // valid header field
+                    indent_str.push_str("  ");
+                    indent_len = 2;
+                } else {
+                    if self.opts.crown_margin || self.opts.tagged_paragraph {
+                        init_str.push_str(&fl.line[..fl.indent_end]);
+                        init_len = fl.indent_len;
+                        init_end = fl.indent_end;
+                    } else {
+                        second_done = true;
+                    }
+
+                    // these will be overwritten in the 2nd line of crown or tagged mode, but
+                    // we are not guaranteed to get to the 2nd line, e.g., if the next line
+                    // is a NoFormatLine or None. Thus, we set sane defaults the 1st time around
+                    indent_str.push_str(&fl.line[..fl.indent_end]);
+                    indent_len = fl.indent_len;
+                    indent_end = fl.indent_end;
+
+                    // save these to check for matching lines
+                    prefix_len = fl.prefix_len;
+                    prefix_indent_end = fl.prefix_indent_end;
+
+                    // in tagged mode, add 4 spaces of additional indenting by default
+                    // (gnu fmt's behavior is different: it seems to find the closest column to
+                    // indent_end that is divisible by 3. But honestly that behavior seems
+                    // pretty arbitrary.
+                    // Perhaps a better default would be 1 TABWIDTH? But ugh that's so big.
+                    if self.opts.tagged_paragraph {
+                        indent_str.push_str("    ");
+                        indent_len += 4;
+                    }
+                }
+            } else if in_mail {
+                // lines following mail headers must begin with spaces
+                if fl.indent_end == 0 || (self.opts.prefix.is_some() && fl.prefix_indent_end == 0) {
+                    break; // this line does not begin with spaces
+                }
+            } else if !second_done {
+                // now we have enough info to handle crown margin and tagged mode
+
+                // in both crown and tagged modes we require that prefix_len is the same
+                if prefix_len != fl.prefix_len || prefix_indent_end != fl.prefix_indent_end {
+                    break;
+                }
+
+                // in tagged mode, indent has to be *different* on following lines
+                if self.opts.tagged_paragraph
+                    && indent_len - 4 == fl.indent_len
+                    && indent_end == fl.indent_end
+                {
+                    break;
+                }
+
+                // this is part of the same paragraph, get the indent info from this line
+                indent_str.clear();
+                indent_str.push_str(&fl.line[..fl.indent_end]);
+                indent_len = fl.indent_len;
+                indent_end = fl.indent_end;
+
+                second_done = true;
+            } else {
+                // detect mismatch
+                if indent_end != fl.indent_end
+                    || prefix_indent_end != fl.prefix_indent_end
+                    || indent_len != fl.indent_len
+                    || prefix_len != fl.prefix_len
+                {
+                    break;
+                }
+            }
+
+            p_lines.push(self.lines.next().unwrap().get_formatline().line);
+
+            // when we're in split-only mode, we never join lines, so stop here
+            if self.opts.split_only {
+                break;
+            }
+        }
+
+        // if this was a mail header, then the next line can be detected as one. Otherwise, it cannot.
+        // NOTE next_mail is true at ParagraphStream instantiation, and is set to true after a blank
+        // NoFormatLine.
+        self.next_mail = in_mail;
+
+        Some(Ok(Paragraph {
+            lines: p_lines,
+            init_str,
+            init_len,
+            init_end,
+            indent_str,
+            indent_len,
+            indent_end,
+            mail_header: in_mail,
+        }))
+    }
+}
+
+pub(super) struct ParaWords<'a> {
+    opts: &'a FmtOptions,
+    para: &'a Paragraph,
+    words: Vec<WordInfo<'a>>,
+}
+
+impl<'a> ParaWords<'a> {
+    pub(super) fn new(opts: &'a FmtOptions, para: &'a Paragraph) -> Self {
+        let mut pw = ParaWords {
+            opts,
+            para,
+            words: Vec::new(),
+        };
+        pw.create_words();
+        pw
+    }
+
+    fn create_words(&mut self) {
+        if self.para.mail_header {
+            // no extra spacing for mail headers; always exactly 1 space
+            // safe to trim_start on every line of a mail header, since the
+            // first line is guaranteed not to have any spaces
+            self.words.extend(
+                self.para
+                    .lines
+                    .iter()
+                    .flat_map(|x| x.split_whitespace())
+                    .map(|x| WordInfo {
+                        word: x,
+                        word_start: 0,
+                        word_nchars: x.len(), // OK for mail headers; only ASCII allowed (unicode is escaped)
+                        before_tab: None,
+                        after_tab: 0,
+                        sentence_start: false,
+                        ends_punct: false,
+                        new_line: false,
+                    }),
+            );
+        } else {
+            // first line
+            self.words
+                .extend(if self.opts.crown_margin || self.opts.tagged_paragraph {
+                    // crown and tagged mode has the "init" in the first line, so slice from there
+                    WordSplit::new(self.opts, &self.para.lines[0][self.para.init_end..])
+                } else {
+                    // otherwise we slice from the indent
+                    WordSplit::new(self.opts, &self.para.lines[0][self.para.indent_end..])
+                });
+
+            if self.para.lines.len() > 1 {
+                let indent_end = self.para.indent_end;
+                let opts = self.opts;
+                self.words.extend(
+                    self.para
+                        .lines
+                        .iter()
+                        .skip(1)
+                        .flat_map(|x| WordSplit::new(opts, &x[indent_end..])),
+                );
+            }
+        }
+    }
+
+    pub(super) fn words(&'a self) -> Iter<'a, WordInfo<'a>> {
+        self.words.iter()
+    }
+}
+
+struct WordSplit<'a> {
+    opts: &'a FmtOptions,
+    string: &'a str,
+    length: usize,
+    position: usize,
+    prev_punct: bool,
+}
+
+impl WordSplit<'_> {
+    fn analyze_tabs(&self, string: &str) -> (Option<usize>, usize, Option<usize>) {
+        // given a string, determine (length before tab) and (printed length after first tab)
+        // if there are no tabs, beforetab = -1 and aftertab is the printed length
+        let mut beforetab = None;
+        let mut aftertab = 0;
+        let mut word_start = None;
+        for (os, c) in string.char_indices() {
+            if !c.is_whitespace() {
+                word_start = Some(os);
+                break;
+            } else if c == '\t' {
+                if beforetab.is_none() {
+                    beforetab = Some(aftertab);
+                    aftertab = 0;
+                } else {
+                    aftertab = (aftertab / self.opts.tabwidth + 1) * self.opts.tabwidth;
+                }
+            } else {
+                aftertab += 1;
+            }
+        }
+        (beforetab, aftertab, word_start)
+    }
+}
+
+impl WordSplit<'_> {
+    fn new<'b>(opts: &'b FmtOptions, string: &'b str) -> WordSplit<'b> {
+        // wordsplits *must* start at a non-whitespace character
+        let trim_string = string.trim_start();
+        WordSplit {
+            opts,
+            string: trim_string,
+            length: string.len(),
+            position: 0,
+            prev_punct: false,
+        }
+    }
+
+    fn is_punctuation(c: char) -> bool {
+        matches!(c, '!' | '.' | '?')
+    }
+}
+
+pub(super) struct WordInfo<'a> {
+    pub word: &'a str,
+    pub word_start: usize,
+    pub word_nchars: usize,
+    pub before_tab: Option<usize>,
+    pub after_tab: usize,
+    pub sentence_start: bool,
+    pub ends_punct: bool,
+    pub new_line: bool,
+}
+
+// returns (&str, is_start_of_sentence)
+impl<'a> Iterator for WordSplit<'a> {
+    type Item = WordInfo<'a>;
+
+    fn next(&mut self) -> Option<WordInfo<'a>> {
+        if self.position >= self.length {
+            return None;
+        }
+
+        let old_position = self.position;
+        let new_line = old_position == 0;
+
+        // find the start of the next word, and record if we find a tab character
+        let (before_tab, after_tab, word_start) =
+            if let (b, a, Some(s)) = self.analyze_tabs(&self.string[old_position..]) {
+                (b, a, s + old_position)
+            } else {
+                self.position = self.length;
+                return None;
+            };
+
+        // find the beginning of the next whitespace
+        // note that this preserves the invariant that self.position
+        // points to whitespace character OR end of string
+        let mut word_nchars = 0;
+        self.position = match self.string[word_start..].find(|x: char| {
+            if x.is_whitespace() {
+                true
+            } else {
+                word_nchars += char_width(x);
+                false
+            }
+        }) {
+            None => self.length,
+            Some(s) => s + word_start,
+        };
+
+        let word_start_relative = word_start - old_position;
+        // if the previous sentence was punctuation and this sentence has >2 whitespace or one tab, is a new sentence.
+        let is_start_of_sentence =
+            self.prev_punct && (before_tab.is_some() || word_start_relative > 1);
+
+        // now record whether this word ends in punctuation
+        self.prev_punct = match self.string[..self.position].chars().next_back() {
+            Some(ch) => WordSplit::is_punctuation(ch),
+            _ => panic!("fatal: expected word not to be empty"),
+        };
+
+        let (word, word_start_relative, before_tab, after_tab) = if self.opts.uniform {
+            (&self.string[word_start..self.position], 0, None, 0)
+        } else {
+            (
+                &self.string[old_position..self.position],
+                word_start_relative,
+                before_tab,
+                after_tab,
+            )
+        };
+
+        Some(WordInfo {
+            word,
+            word_start: word_start_relative,
+            word_nchars,
+            before_tab,
+            after_tab,
+            sentence_start: is_start_of_sentence,
+            ends_punct: self.prev_punct,
+            new_line,
+        })
+    }
+}
diff --git a/crates/libmpv2/Cargo.toml b/crates/libmpv2/Cargo.toml
index a8a4ed6..fb2f5bf 100644
--- a/crates/libmpv2/Cargo.toml
+++ b/crates/libmpv2/Cargo.toml
@@ -24,7 +24,6 @@ publish = false
 
 [dependencies]
 libmpv2-sys = { path = "libmpv2-sys" }
-thiserror = "2.0.7"
 log.workspace = true
 
 [dev-dependencies]
diff --git a/crates/libmpv2/examples/events.rs b/crates/libmpv2/examples/events.rs
index 8f7c79f..e502d5c 100644
--- a/crates/libmpv2/examples/events.rs
+++ b/crates/libmpv2/examples/events.rs
@@ -45,25 +45,27 @@ fn main() -> Result<()> {
             // Trigger `Event::EndFile`.
             mpv.command("playlist-next", &["force"]).unwrap();
         });
-        scope.spawn(move |_| loop {
-            let ev = ev_ctx.wait_event(600.).unwrap_or(Err(Error::Null));
+        scope.spawn(move |_| {
+            loop {
+                let ev = ev_ctx.wait_event(600.).unwrap_or(Err(Error::Null));
 
-            match ev {
-                Ok(Event::EndFile(r)) => {
-                    println!("Exiting! Reason: {:?}", r);
-                    break;
-                }
+                match ev {
+                    Ok(Event::EndFile(r)) => {
+                        println!("Exiting! Reason: {:?}", r);
+                        break;
+                    }
 
-                Ok(Event::PropertyChange {
-                    name: "demuxer-cache-state",
-                    change: PropertyData::Node(mpv_node),
-                    ..
-                }) => {
-                    let ranges = seekable_ranges(mpv_node);
-                    println!("Seekable ranges updated: {:?}", ranges);
+                    Ok(Event::PropertyChange {
+                        name: "demuxer-cache-state",
+                        change: PropertyData::Node(mpv_node),
+                        ..
+                    }) => {
+                        let ranges = seekable_ranges(mpv_node);
+                        println!("Seekable ranges updated: {:?}", ranges);
+                    }
+                    Ok(e) => println!("Event triggered: {:?}", e),
+                    Err(e) => println!("Event errored: {:?}", e),
                 }
-                Ok(e) => println!("Event triggered: {:?}", e),
-                Err(e) => println!("Event errored: {:?}", e),
             }
         });
     })
diff --git a/crates/libmpv2/examples/opengl.rs b/crates/libmpv2/examples/opengl.rs
index 1de307f..8eb9647 100644
--- a/crates/libmpv2/examples/opengl.rs
+++ b/crates/libmpv2/examples/opengl.rs
@@ -9,8 +9,8 @@
 // If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
 
 use libmpv2::{
-    render::{OpenGLInitParams, RenderContext, RenderParam, RenderParamApiType},
     Mpv,
+    render::{OpenGLInitParams, RenderContext, RenderParam, RenderParamApiType},
 };
 use std::{env, ffi::c_void};
 
@@ -38,16 +38,13 @@ fn main() {
         Ok(())
     })
     .unwrap();
-    let mut render_context = RenderContext::new(
-        unsafe { mpv.ctx.as_mut() },
-        vec![
-            RenderParam::ApiType(RenderParamApiType::OpenGl),
-            RenderParam::InitParams(OpenGLInitParams {
-                get_proc_address,
-                ctx: video,
-            }),
-        ],
-    )
+    let mut render_context = RenderContext::new(unsafe { mpv.ctx.as_mut() }, vec![
+        RenderParam::ApiType(RenderParamApiType::OpenGl),
+        RenderParam::InitParams(OpenGLInitParams {
+            get_proc_address,
+            ctx: video,
+        }),
+    ])
     .expect("Failed creating render context");
 
     event_subsystem
diff --git a/crates/libmpv2/src/lib.rs b/crates/libmpv2/src/lib.rs
index 4d8d18a..d47e620 100644
--- a/crates/libmpv2/src/lib.rs
+++ b/crates/libmpv2/src/lib.rs
@@ -67,8 +67,8 @@ pub mod mpv_error {
     pub use libmpv2_sys::mpv_error_MPV_ERROR_INVALID_PARAMETER as InvalidParameter;
     pub use libmpv2_sys::mpv_error_MPV_ERROR_LOADING_FAILED as LoadingFailed;
     pub use libmpv2_sys::mpv_error_MPV_ERROR_NOMEM as NoMem;
-    pub use libmpv2_sys::mpv_error_MPV_ERROR_NOTHING_TO_PLAY as NothingToPlay;
     pub use libmpv2_sys::mpv_error_MPV_ERROR_NOT_IMPLEMENTED as NotImplemented;
+    pub use libmpv2_sys::mpv_error_MPV_ERROR_NOTHING_TO_PLAY as NothingToPlay;
     pub use libmpv2_sys::mpv_error_MPV_ERROR_OPTION_ERROR as OptionError;
     pub use libmpv2_sys::mpv_error_MPV_ERROR_OPTION_FORMAT as OptionFormat;
     pub use libmpv2_sys::mpv_error_MPV_ERROR_OPTION_NOT_FOUND as OptionNotFound;
diff --git a/crates/libmpv2/src/mpv.rs b/crates/libmpv2/src/mpv.rs
index 07d0976..29dac8d 100644
--- a/crates/libmpv2/src/mpv.rs
+++ b/crates/libmpv2/src/mpv.rs
@@ -184,7 +184,7 @@ pub mod mpv_node {
 
     pub mod sys_node {
         use super::{DropWrapper, MpvNode, MpvNodeArrayIter, MpvNodeMapIter};
-        use crate::{mpv_error, mpv_format, Error, Result};
+        use crate::{Error, Result, mpv_error, mpv_format};
         use std::rc::Rc;
 
         #[derive(Debug, Clone)]
@@ -375,14 +375,14 @@ unsafe impl SetData for String {
 /// Wrapper around an `&str` returned by mpv, that properly deallocates it with mpv's allocator.
 #[derive(Debug, Hash, Eq, PartialEq)]
 pub struct MpvStr<'a>(&'a str);
-impl<'a> Deref for MpvStr<'a> {
+impl Deref for MpvStr<'_> {
     type Target = str;
 
     fn deref(&self) -> &str {
         self.0
     }
 }
-impl<'a> Drop for MpvStr<'a> {
+impl Drop for MpvStr<'_> {
     fn drop(&mut self) {
         unsafe { libmpv2_sys::mpv_free(self.0.as_ptr() as *mut u8 as _) };
     }
@@ -403,7 +403,7 @@ unsafe impl<'a> GetData for MpvStr<'a> {
     }
 }
 
-unsafe impl<'a> SetData for &'a str {
+unsafe impl SetData for &str {
     fn call_as_c_void<T, F: FnMut(*mut ctype::c_void) -> Result<T>>(self, mut fun: F) -> Result<T> {
         let string = CString::new(self)?;
         fun((&mut string.as_ptr()) as *mut *const ctype::c_char as *mut _)
@@ -511,9 +511,8 @@ impl Mpv {
         }
 
         initializer(MpvInitializer { ctx })?;
-        mpv_err((), unsafe { libmpv2_sys::mpv_initialize(ctx) }).map_err(|err| {
+        mpv_err((), unsafe { libmpv2_sys::mpv_initialize(ctx) }).inspect_err(|_| {
             unsafe { libmpv2_sys::mpv_terminate_destroy(ctx) };
-            err
         })?;
 
         let ctx = unsafe { NonNull::new_unchecked(ctx) };
@@ -526,19 +525,6 @@ impl Mpv {
         })
     }
 
-    /// Execute a command
-    pub fn execute(&self, name: &str, args: &[&str]) -> Result<()> {
-        if args.is_empty() {
-            debug!("Running mpv command: '{}'", name);
-        } else {
-            debug!("Running mpv command: '{} {}'", name, args.join(" "));
-        }
-
-        self.command(name, args)?;
-
-        Ok(())
-    }
-
     /// Load a configuration file. The path has to be absolute, and a file.
     pub fn load_config(&self, path: &str) -> Result<()> {
         let file = CString::new(path)?.into_raw();
@@ -562,7 +548,7 @@ impl Mpv {
     /// Send a command to the `Mpv` instance. This uses `mpv_command_string` internally,
     /// so that the syntax is the same as described in the [manual for the input.conf](https://mpv.io/manual/master/#list-of-input-commands).
     ///
-    /// Note that you may have to escape strings with `""` when they contain spaces.
+    /// Note that this function escapes the arguments for you.
     ///
     /// # Examples
     ///
@@ -583,12 +569,19 @@ impl Mpv {
     /// # }
     /// ```
     pub fn command(&self, name: &str, args: &[&str]) -> Result<()> {
-        let mut cmd = name.to_owned();
+        fn escape(input: &str) -> String {
+            input.replace('"', "\\\"")
+        }
+
+        let mut cmd = escape(name);
 
         for elem in args {
             cmd.push(' ');
-            cmd.push_str(elem);
+            cmd.push('"');
+            cmd.push_str(&escape(elem));
+            cmd.push('"');
         }
+        debug!("Running mpv command: '{}'", cmd);
 
         let raw = CString::new(cmd)?;
         mpv_err((), unsafe {
@@ -597,7 +590,9 @@ impl Mpv {
     }
 
     /// Set the value of a property.
-    pub fn set_property<T: SetData>(&self, name: &str, data: T) -> Result<()> {
+    pub fn set_property<T: SetData + std::fmt::Display>(&self, name: &str, data: T) -> Result<()> {
+        debug!("Setting mpv property: '{name}' = '{data}'");
+
         let name = CString::new(name)?;
         let format = T::get_format().as_mpv_format() as _;
         data.call_as_c_void(|ptr| {
diff --git a/crates/libmpv2/src/mpv/errors.rs b/crates/libmpv2/src/mpv/errors.rs
index a2baee5..a2d3dd8 100644
--- a/crates/libmpv2/src/mpv/errors.rs
+++ b/crates/libmpv2/src/mpv/errors.rs
@@ -8,36 +8,52 @@
 // 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::{ffi::NulError, os::raw as ctype, str::Utf8Error};
-
-use thiserror::Error;
+use std::{ffi::NulError, fmt::Display, os::raw as ctype, str::Utf8Error};
 
 use super::mpv_error;
 
 #[allow(missing_docs)]
 pub type Result<T> = ::std::result::Result<T, Error>;
 
-#[derive(Error, Debug)]
+#[derive(Debug)]
 pub enum Error {
-    #[error("loading file failed: {error}")]
-    Loadfile { error: String },
+    Loadfile {
+        error: String,
+    },
 
-    #[error("version mismatch detected! Linked version ({linked}) is unequal to the loaded version ({loaded})")]
     VersionMismatch {
         linked: ctype::c_ulong,
         loaded: ctype::c_ulong,
     },
 
-    #[error("invalid utf8 returned")]
     InvalidUtf8,
 
-    #[error("null pointer returned")]
     Null,
 
-    #[error("raw error returned: {}", to_string_mpv_error(*(.0)))]
     Raw(crate::MpvError),
 }
 
+impl std::error::Error for Error {}
+
+impl Display for Error {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Error::Loadfile { error } => write!(f, "loading file failed: {error}"),
+            Error::VersionMismatch { linked, loaded } => write!(
+                f,
+                "version mismatch detected! Linked version ({linked}) is unequal to the loaded version ({loaded})"
+            ),
+            Error::InvalidUtf8 => f.write_str("invalid utf8 returned"),
+            Error::Null => f.write_str("null pointer returned"),
+            Error::Raw(raw) => write!(
+                f,
+                include_str!("./raw_error_warning.txt"),
+                to_string_mpv_error(*(raw))
+            ),
+        }
+    }
+}
+
 impl From<NulError> for Error {
     fn from(_other: NulError) -> Error {
         Error::Null
@@ -76,35 +92,70 @@ fn to_string_mpv_error_raw(num: crate::MpvError) -> (&'static str, &'static str)
 
         mpv_error::NoMem => ("Memory allocation failed.", ""),
 
-        mpv_error::Uninitialized => ("The mpv core wasn't configured and initialized yet", " See the notes in mpv_create()."),
+        mpv_error::Uninitialized => (
+            "The mpv core wasn't configured and initialized yet",
+            " See the notes in mpv_create().",
+        ),
 
-        mpv_error::InvalidParameter => ("Generic catch-all error if a parameter is set to an invalid or unsupported value.", "This is used if there is no better error code."),
+        mpv_error::InvalidParameter => (
+            "Generic catch-all error if a parameter is set to an invalid or unsupported value.",
+            "This is used if there is no better error code.",
+        ),
 
         mpv_error::OptionNotFound => ("Trying to set an option that doesn't exist.", ""),
-        mpv_error::OptionFormat => ("Trying to set an option using an unsupported MPV_FORMAT.", ""),
-        mpv_error::OptionError => ("Setting the option failed", " Typically this happens if the provided option value could not be parsed."),
+        mpv_error::OptionFormat => (
+            "Trying to set an option using an unsupported MPV_FORMAT.",
+            "",
+        ),
+        mpv_error::OptionError => (
+            "Setting the option failed",
+            " Typically this happens if the provided option value could not be parsed.",
+        ),
 
         mpv_error::PropertyNotFound => ("The accessed property doesn't exist.", ""),
-        mpv_error::PropertyFormat => ("Trying to set or get a property using an unsupported MPV_FORMAT.", ""),
-        mpv_error::PropertyUnavailable => ("The property exists, but is not available", "This usually happens when the associated subsystem is not active, e.g. querying audio parameters while audio is disabled."),
+        mpv_error::PropertyFormat => (
+            "Trying to set or get a property using an unsupported MPV_FORMAT.",
+            "",
+        ),
+        mpv_error::PropertyUnavailable => (
+            "The property exists, but is not available",
+            "This usually happens when the associated subsystem is not active, e.g. querying audio parameters while audio is disabled.",
+        ),
         mpv_error::PropertyError => ("Error setting or getting a property.", ""),
 
-        mpv_error::Command => ("General error when running a command with mpv_command and similar.", ""),
+        mpv_error::Command => (
+            "General error when running a command with mpv_command and similar.",
+            "",
+        ),
 
-        mpv_error::LoadingFailed => ("Generic error on loading (usually used with mpv_event_end_file.error).", ""),
+        mpv_error::LoadingFailed => (
+            "Generic error on loading (usually used with mpv_event_end_file.error).",
+            "",
+        ),
 
         mpv_error::AoInitFailed => ("Initializing the audio output failed.", ""),
         mpv_error::VoInitFailed => ("Initializing the video output failed.", ""),
 
-        mpv_error::NothingToPlay => ("There was no audio or video data to play", "This also happens if the file was recognized, but did not contain any audio or video streams, or no streams were selected."),
+        mpv_error::NothingToPlay => (
+            "There was no audio or video data to play",
+            "This also happens if the file was recognized, but did not contain any audio or video streams, or no streams were selected.",
+        ),
 
-        mpv_error::UnknownFormat => ("     * When trying to load the file, the file format could not be determined, or the file was too broken to open it.", ""),
+        mpv_error::UnknownFormat => (
+            "     * When trying to load the file, the file format could not be determined, or the file was too broken to open it.",
+            "",
+        ),
 
-        mpv_error::Generic => ("Generic error for signaling that certain system requirements are not fulfilled.", ""),
+        mpv_error::Generic => (
+            "Generic error for signaling that certain system requirements are not fulfilled.",
+            "",
+        ),
         mpv_error::NotImplemented => ("The API function which was called is a stub only", ""),
         mpv_error::Unsupported => ("Unspecified error.", ""),
 
-        mpv_error::Success => unreachable!("This is not an error. It's just here, to ensure that the 0 case marks an success'"),
+        mpv_error::Success => unreachable!(
+            "This is not an error. It's just here, to ensure that the 0 case marks an success'"
+        ),
         _ => unreachable!("Mpv seems to have changed it's constants."),
     }
 }
diff --git a/crates/libmpv2/src/mpv/events.rs b/crates/libmpv2/src/mpv/events.rs
index 6fb4683..e27da2c 100644
--- a/crates/libmpv2/src/mpv/events.rs
+++ b/crates/libmpv2/src/mpv/events.rs
@@ -11,7 +11,7 @@
 use crate::mpv_node::sys_node::SysMpvNode;
 use crate::{mpv::mpv_err, *};
 
-use std::ffi::{c_void, CString};
+use std::ffi::{CString, c_void};
 use std::os::raw as ctype;
 use std::ptr::NonNull;
 use std::slice;
@@ -86,7 +86,7 @@ impl<'a> PropertyData<'a> {
             mpv_format::Node => {
                 let sys_node = *(ptr as *mut libmpv2_sys::mpv_node);
                 let node = SysMpvNode::new(sys_node, false);
-                return Ok(PropertyData::Node(node.value().unwrap()));
+                Ok(PropertyData::Node(node.value().unwrap()))
             }
             mpv_format::None => unreachable!(),
             _ => unimplemented!(),
diff --git a/crates/libmpv2/src/mpv/protocol.rs b/crates/libmpv2/src/mpv/protocol.rs
index 31a5933..ec840d8 100644
--- a/crates/libmpv2/src/mpv/protocol.rs
+++ b/crates/libmpv2/src/mpv/protocol.rs
@@ -17,7 +17,7 @@ use std::os::raw as ctype;
 use std::panic;
 use std::panic::RefUnwindSafe;
 use std::slice;
-use std::sync::{atomic::Ordering, Mutex};
+use std::sync::{Mutex, atomic::Ordering};
 
 impl Mpv {
     /// Create a context with which custom protocols can be registered.
@@ -101,11 +101,7 @@ where
         let slice = slice::from_raw_parts_mut(buf, nbytes as _);
         ((*data).read_fn)(&mut *(*data).cookie, slice)
     });
-    if let Ok(ret) = ret {
-        ret
-    } else {
-        -1
-    }
+    ret.unwrap_or(-1)
 }
 
 unsafe extern "C" fn seek_wrapper<T, U>(cookie: *mut ctype::c_void, offset: i64) -> i64
@@ -177,8 +173,8 @@ pub struct ProtocolContext<'parent, T: RefUnwindSafe, U: RefUnwindSafe> {
     _does_not_outlive: PhantomData<&'parent Mpv>,
 }
 
-unsafe impl<'parent, T: RefUnwindSafe, U: RefUnwindSafe> Send for ProtocolContext<'parent, T, U> {}
-unsafe impl<'parent, T: RefUnwindSafe, U: RefUnwindSafe> Sync for ProtocolContext<'parent, T, U> {}
+unsafe impl<T: RefUnwindSafe, U: RefUnwindSafe> Send for ProtocolContext<'_, T, U> {}
+unsafe impl<T: RefUnwindSafe, U: RefUnwindSafe> Sync for ProtocolContext<'_, T, U> {}
 
 impl<'parent, T: RefUnwindSafe, U: RefUnwindSafe> ProtocolContext<'parent, T, U> {
     fn new(
diff --git a/crates/libmpv2/src/mpv/raw_error_warning.txt b/crates/libmpv2/src/mpv/raw_error_warning.txt
new file mode 100644
index 0000000..277500a
--- /dev/null
+++ b/crates/libmpv2/src/mpv/raw_error_warning.txt
@@ -0,0 +1,5 @@
+Raw mpv error: {}
+
+This error is directly returned from `mpv`.
+This is probably caused by a bug in `yt`, please open an issue about
+this and try to replicate it with the `-vvvv` verbosity setting.
diff --git a/package/blake3/add_cargo_lock.patch.license b/crates/libmpv2/src/mpv/raw_error_warning.txt.license
index d4d410f..7813eb6 100644
--- a/package/blake3/add_cargo_lock.patch.license
+++ b/crates/libmpv2/src/mpv/raw_error_warning.txt.license
@@ -1,6 +1,6 @@
 yt - A fully featured command line YouTube client
 
-Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
 SPDX-License-Identifier: GPL-3.0-or-later
 
 This file is part of Yt.
diff --git a/crates/libmpv2/src/mpv/render.rs b/crates/libmpv2/src/mpv/render.rs
index c3f2dc9..6457048 100644
--- a/crates/libmpv2/src/mpv/render.rs
+++ b/crates/libmpv2/src/mpv/render.rs
@@ -8,9 +8,9 @@
 // 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 crate::{mpv::mpv_err, Error, Result};
+use crate::{Error, Result, mpv::mpv_err};
 use std::collections::HashMap;
-use std::ffi::{c_void, CStr};
+use std::ffi::{CStr, c_void};
 use std::os::raw::c_int;
 use std::ptr;
 
diff --git a/crates/libmpv2/src/tests.rs b/crates/libmpv2/src/tests.rs
index 68753fc..6106eb2 100644
--- a/crates/libmpv2/src/tests.rs
+++ b/crates/libmpv2/src/tests.rs
@@ -54,10 +54,10 @@ fn properties() {
         0.6,
         f64::round(subg * f64::powi(10.0, 4)) / f64::powi(10.0, 4)
     );
-    mpv.command(
-        "loadfile",
-        &["test-data/speech_12kbps_mb.wav", "append-play"],
-    )
+    mpv.command("loadfile", &[
+        "test-data/speech_12kbps_mb.wav",
+        "append-play",
+    ])
     .unwrap();
     thread::sleep(Duration::from_millis(250));
 
@@ -185,10 +185,10 @@ fn events() {
 fn node_map() {
     let mpv = Mpv::new().unwrap();
 
-    mpv.command(
-        "loadfile",
-        &["test-data/speech_12kbps_mb.wav", "append-play"],
-    )
+    mpv.command("loadfile", &[
+        "test-data/speech_12kbps_mb.wav",
+        "append-play",
+    ])
     .unwrap();
 
     thread::sleep(Duration::from_millis(250));
@@ -217,10 +217,10 @@ fn node_map() {
 fn node_array() -> Result<()> {
     let mpv = Mpv::new()?;
 
-    mpv.command(
-        "loadfile",
-        &["test-data/speech_12kbps_mb.wav", "append-play"],
-    )
+    mpv.command("loadfile", &[
+        "test-data/speech_12kbps_mb.wav",
+        "append-play",
+    ])
     .unwrap();
 
     thread::sleep(Duration::from_millis(250));
diff --git a/.env b/crates/termsize/.gitignore
index f4ffbe7..5bc2870 100644
--- a/.env
+++ b/crates/termsize/.gitignore
@@ -1,10 +1,12 @@
 # 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
+# Copyright (C) 2025 softprops <d.tangren@gmail.com>
+# SPDX-License-Identifier: MIT
 #
 # 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>.
-DATABASE_URL=sqlite://target/database.sqlite
+
+target
+Cargo.lock
diff --git a/crates/termsize/Cargo.toml b/crates/termsize/Cargo.toml
new file mode 100644
index 0000000..10ab7ed
--- /dev/null
+++ b/crates/termsize/Cargo.toml
@@ -0,0 +1,36 @@
+# yt - A fully featured command line YouTube client
+#
+# Copyright (C) 2025 softprops <d.tangren@gmail.com>
+# SPDX-License-Identifier: MIT
+#
+# 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>.
+
+[package]
+name = "termsize"
+authors = [
+  "softprops <d.tangren@gmail.com>",
+  "Benedikt Peetz <benedikt.peetz@b-peetz.de>",
+]
+description = "Retrieves terminal size"
+repository = "https://github.com/softprops/termsize"
+homepage = "https://github.com/softprops/termsize"
+documentation = "http://softprops.github.io/termsize"
+keywords = ["tty", "terminal", "term", "size", "dimensions"]
+license = "MIT"
+readme = "README.md"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+publish = false
+
+[target.'cfg(unix)'.dependencies]
+libc = "0.2"
+
+[target.'cfg(windows)'.dependencies]
+winapi = { version = "0.3", features = ["handleapi", "fileapi", "wincon"] }
+
+[lints]
+workspace = true
diff --git a/crates/termsize/LICENSE b/crates/termsize/LICENSE
new file mode 100644
index 0000000..78c7d8a
--- /dev/null
+++ b/crates/termsize/LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2015-2024 Doug Tangren
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
diff --git a/crates/termsize/LICENSE.license b/crates/termsize/LICENSE.license
new file mode 100644
index 0000000..3562ab9
--- /dev/null
+++ b/crates/termsize/LICENSE.license
@@ -0,0 +1,9 @@
+yt - A fully featured command line YouTube client
+
+Copyright (C) 2025 softprops <d.tangren@gmail.com>
+SPDX-License-Identifier: MIT
+
+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>.
diff --git a/crates/termsize/README.md b/crates/termsize/README.md
new file mode 100644
index 0000000..305669b
--- /dev/null
+++ b/crates/termsize/README.md
@@ -0,0 +1,51 @@
+<!--
+yt - A fully featured command line YouTube client
+
+Copyright (C) 2025 softprops <d.tangren@gmail.com>
+SPDX-License-Identifier: MIT
+
+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>.
+-->
+
+# termsize
+
+[![CI](https://github.com/softprops/termsize/actions/workflows/ci.yml/badge.svg)](https://github.com/softprops/termsize/actions/workflows/ci.yml)
+[![Crates.io](https://img.shields.io/crates/v/termsize.svg)](https://crates.io/crates/termsize)
+
+> because terminal size matters
+
+Termsize is a rust crate providing a multi-platform interface for resolving your
+terminal's current size in rows and columns. On most unix systems, this is
+similar invoking the [stty(1)](http://man7.org/linux/man-pages/man1/stty.1.html)
+program, requesting the terminal size.
+
+## [Documentation](https://softprops.github.com/termsize)
+
+## install
+
+run `cargo add termsize` in your terminal or add the following to your
+`Cargo.toml` file
+
+```toml
+[dependencies]
+termsize = "0.1"
+```
+
+## usage
+
+Termize provides one function, `get`, which returns a `termsize::Size` struct
+exposing two fields: `rows` and `cols` representing the number of rows and
+columns a a terminal's stdout supports.
+
+```rust
+pub fn main() {
+  termsize::get().map(|{ rows, cols }| {
+    println!("rows {} cols {}", size.rows, size.cols)
+  });
+}
+```
+
+Doug Tangren (softprops) 2015-2024
diff --git a/crates/termsize/src/lib.rs b/crates/termsize/src/lib.rs
new file mode 100644
index 0000000..69e7b78
--- /dev/null
+++ b/crates/termsize/src/lib.rs
@@ -0,0 +1,52 @@
+#![deny(missing_docs)]
+// yt - A fully featured command line YouTube client
+//
+// Copyright (C) 2025 softprops <d.tangren@gmail.com>
+// SPDX-License-Identifier: MIT
+//
+// 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>.
+
+//! Termsize is a tiny crate that provides a simple
+//! interface for retrieving the current
+//! [terminal interface](http://www.manpagez.com/man/4/tty/) size
+//!
+//! ```rust
+//! extern crate termsize;
+//!
+//! termsize::get().map(|size| println!("rows {} cols {}", size.rows, size.cols));
+//! ```
+
+/// Container for number of rows and columns
+#[derive(Debug, Clone, Copy)]
+pub struct Size {
+    /// number of rows
+    pub rows: u16,
+    /// number of columns
+    pub cols: u16,
+}
+
+#[cfg(unix)]
+#[path = "nix.rs"]
+mod imp;
+
+#[cfg(windows)]
+#[path = "win.rs"]
+mod imp;
+
+#[cfg(not(any(unix, windows)))]
+#[path = "other.rs"]
+mod imp;
+
+pub use imp::get;
+
+#[cfg(test)]
+mod tests {
+    use super::get;
+    #[test]
+    fn test_get() {
+        assert!(get().is_some());
+    }
+}
diff --git a/crates/termsize/src/nix.rs b/crates/termsize/src/nix.rs
new file mode 100644
index 0000000..d672f54
--- /dev/null
+++ b/crates/termsize/src/nix.rs
@@ -0,0 +1,100 @@
+// yt - A fully featured command line YouTube client
+//
+// Copyright (C) 2025 softprops <d.tangren@gmail.com>
+// SPDX-License-Identifier: MIT
+//
+// 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::io::IsTerminal;
+
+use self::super::Size;
+use libc::{STDOUT_FILENO, TIOCGWINSZ, c_ushort, ioctl};
+
+/// A representation of the size of the current terminal
+#[repr(C)]
+#[derive(Debug)]
+struct UnixSize {
+    /// number of rows
+    pub rows: c_ushort,
+    /// number of columns
+    pub cols: c_ushort,
+    x: c_ushort,
+    y: c_ushort,
+}
+
+/// Gets the current terminal size
+#[must_use]
+pub fn get() -> Option<Size> {
+    // http://rosettacode.org/wiki/Terminal_control/Dimensions#Library:_BSD_libc
+    if !std::io::stdout().is_terminal() {
+        return None;
+    }
+    let mut us = UnixSize {
+        rows: 0,
+        cols: 0,
+        x: 0,
+        y: 0,
+    };
+    let r = unsafe { ioctl(STDOUT_FILENO, TIOCGWINSZ, &mut us) };
+    if r == 0 {
+        Some(Size {
+            rows: us.rows,
+            cols: us.cols,
+        })
+    } else {
+        None
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{super::Size, get};
+    use std::process::{Command, Output, Stdio};
+
+    #[cfg(target_os = "macos")]
+    fn stty_size() -> Output {
+        Command::new("stty")
+            .arg("-f")
+            .arg("/dev/stderr")
+            .arg("size")
+            .stderr(Stdio::inherit())
+            .output()
+            .expect("expected stty output")
+    }
+
+    #[cfg(not(target_os = "macos"))]
+    fn stty_size() -> Output {
+        Command::new("stty")
+            .arg("-F")
+            .arg("/dev/stderr")
+            .arg("size")
+            .stderr(Stdio::inherit())
+            .output()
+            .expect("expected stty output")
+    }
+
+    #[test]
+    fn test_shell() {
+        let output = stty_size();
+        assert!(output.status.success());
+        let stdout = String::from_utf8(output.stdout).expect("expected utf8");
+        let mut data = stdout.split_whitespace();
+        let rs = data
+            .next()
+            .expect("expected row")
+            .parse::<u16>()
+            .expect("expected u16 col");
+        let cs = data
+            .next()
+            .expect("expected col")
+            .parse::<u16>()
+            .expect("expected u16 col");
+        if let Some(Size { rows, cols }) = get() {
+            assert_eq!(rows, rs);
+            assert_eq!(cols, cs);
+        }
+    }
+}
diff --git a/crates/termsize/src/other.rs b/crates/termsize/src/other.rs
new file mode 100644
index 0000000..8a02f22
--- /dev/null
+++ b/crates/termsize/src/other.rs
@@ -0,0 +1,14 @@
+// yt - A fully featured command line YouTube client
+//
+// Copyright (C) 2025 softprops <d.tangren@gmail.com>
+// SPDX-License-Identifier: MIT
+//
+// 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>.
+
+/// Gets the current terminal size
+pub fn get() -> Option<super::Size> {
+    None
+}
diff --git a/crates/termsize/src/win.rs b/crates/termsize/src/win.rs
new file mode 100644
index 0000000..72d8433
--- /dev/null
+++ b/crates/termsize/src/win.rs
@@ -0,0 +1,52 @@
+// yt - A fully featured command line YouTube client
+//
+// Copyright (C) 2025 softprops <d.tangren@gmail.com>
+// SPDX-License-Identifier: MIT
+//
+// 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::ptr;
+
+use winapi::um::{
+    fileapi::{CreateFileA, OPEN_EXISTING},
+    handleapi::INVALID_HANDLE_VALUE,
+    wincon::{CONSOLE_SCREEN_BUFFER_INFO, GetConsoleScreenBufferInfo},
+    winnt::{FILE_SHARE_WRITE, GENERIC_READ, GENERIC_WRITE},
+};
+
+use self::super::Size;
+
+/// Gets the current terminal size
+pub fn get() -> Option<Size> {
+    // http://rosettacode.org/wiki/Terminal_control/Dimensions#Windows
+    let handle = unsafe {
+        CreateFileA(
+            b"CONOUT$\0".as_ptr() as *const i8,
+            GENERIC_READ | GENERIC_WRITE,
+            FILE_SHARE_WRITE,
+            ptr::null_mut(),
+            OPEN_EXISTING,
+            0,
+            ptr::null_mut(),
+        )
+    };
+    if handle == INVALID_HANDLE_VALUE {
+        return None;
+    }
+    let info = unsafe {
+        // https://msdn.microsoft.com/en-us/library/windows/desktop/ms683171(v=vs.85).aspx
+        let mut info = ::std::mem::MaybeUninit::<CONSOLE_SCREEN_BUFFER_INFO>::uninit();
+        if GetConsoleScreenBufferInfo(handle, info.as_mut_ptr()) == 0 {
+            None
+        } else {
+            Some(info.assume_init())
+        }
+    };
+    info.map(|inf| Size {
+        rows: (inf.srWindow.Bottom - inf.srWindow.Top + 1) as u16,
+        cols: (inf.srWindow.Right - inf.srWindow.Left + 1) as u16,
+    })
+}
diff --git a/crates/yt_dlp/Cargo.toml b/crates/yt_dlp/Cargo.toml
index 1d34371..a948a34 100644
--- a/crates/yt_dlp/Cargo.toml
+++ b/crates/yt_dlp/Cargo.toml
@@ -22,7 +22,7 @@ rust-version.workspace = true
 publish = false
 
 [dependencies]
-pyo3 = { version = "0.23.3", features = ["auto-initialize"] }
+pyo3 = { version = "0.23.4", features = ["auto-initialize"] }
 bytes.workspace = true
 log.workspace = true
 serde.workspace = true
diff --git a/crates/yt_dlp/src/error.rs b/crates/yt_dlp/src/error.rs
new file mode 100644
index 0000000..3881f0b
--- /dev/null
+++ b/crates/yt_dlp/src/error.rs
@@ -0,0 +1,68 @@
+// 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::Display, io};
+
+use pyo3::Python;
+
+#[derive(Debug)]
+#[allow(clippy::module_name_repetitions)]
+pub enum YtDlpError {
+    ResponseParseError {
+        error: serde_json::error::Error,
+    },
+    PythonError {
+        error: Box<pyo3::PyErr>,
+        kind: String,
+    },
+    IoError {
+        error: io::Error,
+    },
+}
+
+impl std::error::Error for YtDlpError {}
+
+impl Display for YtDlpError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            YtDlpError::ResponseParseError { error } => write!(
+                f,
+                include_str!("./python_json_decode_failed.error_msg"),
+                error
+            ),
+            YtDlpError::PythonError { error, kind: _ } => write!(f, "Python error: {error}"),
+            YtDlpError::IoError { error } => write!(f, "Io error: {error}"),
+        }
+    }
+}
+
+impl From<serde_json::error::Error> for YtDlpError {
+    fn from(value: serde_json::error::Error) -> Self {
+        Self::ResponseParseError { error: value }
+    }
+}
+
+impl From<pyo3::PyErr> for YtDlpError {
+    fn from(value: pyo3::PyErr) -> Self {
+        Python::with_gil(|py| {
+            let kind = value.get_type(py).to_string();
+            Self::PythonError {
+                error: Box::new(value),
+                kind,
+            }
+        })
+    }
+}
+
+impl From<io::Error> for YtDlpError {
+    fn from(value: io::Error) -> Self {
+        Self::IoError { error: value }
+    }
+}
diff --git a/crates/yt_dlp/src/lib.rs b/crates/yt_dlp/src/lib.rs
index 970bfe2..40610c2 100644
--- a/crates/yt_dlp/src/lib.rs
+++ b/crates/yt_dlp/src/lib.rs
@@ -12,8 +12,8 @@
 #![allow(unsafe_op_in_unsafe_fn)]
 #![allow(clippy::missing_errors_doc)]
 
-use std::env;
-use std::io::stdout;
+use std::io::stderr;
+use std::{env, process};
 use std::{fs::File, io::Write};
 
 use std::{path::PathBuf, sync::Once};
@@ -21,18 +21,20 @@ use std::{path::PathBuf, sync::Once};
 use crate::{duration::Duration, logging::setup_logging, wrapper::info_json::InfoJson};
 
 use bytes::Bytes;
-use log::{info, log_enabled, Level};
+use error::YtDlpError;
+use log::{Level, debug, info, log_enabled};
 use pyo3::types::{PyString, PyTuple, PyTupleMethods};
 use pyo3::{
-    pyfunction,
+    Bound, PyAny, PyResult, Python, pyfunction,
     types::{PyAnyMethods, PyDict, PyDictMethods, PyList, PyListMethods, PyModule},
-    wrap_pyfunction, Bound, PyAny, PyResult, Python,
+    wrap_pyfunction,
 };
 use serde::Serialize;
 use serde_json::{Map, Value};
 use url::Url;
 
 pub mod duration;
+pub mod error;
 pub mod logging;
 pub mod wrapper;
 
@@ -51,6 +53,33 @@ pub fn add_logger_and_sig_handler<'a>(
     opts: Bound<'a, PyDict>,
     py: Python<'_>,
 ) -> PyResult<Bound<'a, PyDict>> {
+    /// 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.
+    #[pyfunction]
+    fn filter_error_log(_py: Python<'_>, record: &Bound<'_, PyAny>) -> bool {
+        // Filter out all error logs (they are propagated as rust errors)
+        let levelname: String = record
+            .getattr("levelname")
+            .expect("This should exist")
+            .extract()
+            .expect("This should be a String");
+
+        let return_value = levelname.as_str() != "ERROR";
+
+        if log_enabled!(Level::Debug) && !return_value {
+            let message: String = record
+                .call_method0("getMessage")
+                .expect("This method exists")
+                .extract()
+                .expect("The message is a string");
+
+            debug!("Swollowed error message: '{message}'");
+        }
+        return_value
+    }
+
     setup_logging(py, "yt_dlp")?;
 
     let logging = PyModule::import(py, "logging")?;
@@ -81,6 +110,11 @@ signal.signal(signal.SIGINT, signal.SIG_DFL)",
             .expect("This method exists");
     });
 
+    ytdl_logger.call_method1(
+        "addFilter",
+        (wrap_pyfunction!(filter_error_log, py).expect("This function can be wrapped"),),
+    )?;
+
     // This was taken from `ytcc`, I don't think it is still applicable
     // ytdl_logger.setattr("propagate", false)?;
     // let logging_null_handler = logging.call_method0("NullHandler")?;
@@ -111,10 +145,10 @@ pub fn progress_hook(py: Python<'_>, input: &Bound<'_, PyDict>) -> PyResult<()>
     // see: https://en.wikipedia.org/wiki/ANSI_escape_code#Control_Sequence_Introducer_commands
     const CSI: &str = "\x1b[";
     fn clear_whole_line() {
-        print!("{CSI}2K");
+        eprint!("{CSI}2K");
     }
     fn move_to_col(x: usize) {
-        print!("{CSI}{x}G");
+        eprint!("{CSI}{x}G");
     }
     // }}}
 
@@ -125,7 +159,7 @@ pub fn progress_hook(py: Python<'_>, input: &Bound<'_, PyDict>) -> PyResult<()>
             .expect("Will always work")
             .to_owned(),
     )?)
-    .expect("Python should always produce valid json");
+    .expect("python's json is valid");
 
     macro_rules! get {
         (@interrogate $item:ident, $type_fun:ident, $get_fun:ident, $name:expr) => {{
@@ -198,7 +232,7 @@ pub fn progress_hook(py: Python<'_>, input: &Bound<'_, PyDict>) -> PyResult<()>
         format!("{bytes}/s")
     }
 
-    let get_title = |add_extension: bool| -> String {
+    let get_title = || -> String {
         match get! {is_string, as_str, "info_dict", "ext"} {
             "vtt" => {
                 format!(
@@ -206,16 +240,8 @@ pub fn progress_hook(py: Python<'_>, input: &Bound<'_, PyDict>) -> PyResult<()>
                     default_get! {as_str, "<No Subtitle Language>", "info_dict", "name"}
                 )
             }
-            title_extension @ ("webm" | "mp4" | "m4a") => {
-                if add_extension {
-                    format!(
-                        "{} ({})",
-                        default_get! { as_str, "<No title>", "info_dict", "title"},
-                        title_extension
-                    )
-                } else {
-                    default_get! { as_str, "<No title>", "info_dict", "title"}.to_owned()
-                }
+            "webm" | "mp4" | "mp3" | "m4a" => {
+                default_get! { as_str, "<No title>", "info_dict", "title"}.to_owned()
             }
             other => panic!("The extension '{other}' is not yet implemented"),
         }
@@ -257,9 +283,9 @@ pub fn progress_hook(py: Python<'_>, input: &Bound<'_, PyDict>) -> PyResult<()>
             clear_whole_line();
             move_to_col(1);
 
-            print!(
+            eprint!(
                 "'{}' [{}/{} at {}] -> [{} of {}{} {}] ",
-                c!("34;1", get_title(true)),
+                c!("34;1", get_title()),
                 c!("33;1", Duration::from(Some(elapsed))),
                 c!("33;1", Duration::from(Some(eta))),
                 c!("32;1", format_speed(speed)),
@@ -268,13 +294,16 @@ pub fn progress_hook(py: Python<'_>, input: &Bound<'_, PyDict>) -> PyResult<()>
                 c!("31;1", format_bytes(total_bytes)),
                 c!("36;1", format!("{:.02}%", percent))
             );
-            stdout().flush()?;
+            stderr().flush()?;
         }
         "finished" => {
-            println!("-> Finished downloading.");
+            eprintln!("-> Finished downloading.");
         }
         "error" => {
-            panic!("-> Error while downloading: {}", get_title(true))
+            // TODO: This should probably return an Err. But I'm not so sure where the error would
+            // bubble up to (i.e., who would catch it) <2025-01-21>
+            eprintln!("-> Error while downloading: {}", get_title());
+            process::exit(1);
         }
         other => unreachable!("'{other}' should not be a valid state!"),
     };
@@ -298,6 +327,42 @@ pub fn add_hooks<'a>(opts: Bound<'a, PyDict>, py: Python<'_>) -> PyResult<Bound<
     Ok(opts)
 }
 
+/// Take the result of the ie (may be modified) and resolve all unresolved
+/// references (URLs, playlist items).
+///
+/// It will also download the videos if 'download'.
+/// Returns the resolved `ie_result`.
+#[allow(clippy::unused_async)]
+#[allow(clippy::missing_panics_doc)]
+pub async fn process_ie_result(
+    yt_dlp_opts: &Map<String, Value>,
+    ie_result: InfoJson,
+    download: bool,
+) -> Result<InfoJson, YtDlpError> {
+    Python::with_gil(|py| -> Result<InfoJson, YtDlpError> {
+        let opts = json_map_to_py_dict(yt_dlp_opts, py)?;
+
+        let instance = get_yt_dlp(py, opts)?;
+
+        let args = {
+            let ie_result = json_loads_str(py, ie_result)?;
+            (ie_result,)
+        };
+
+        let kwargs = PyDict::new(py);
+        kwargs.set_item("download", download)?;
+
+        let result = instance
+            .call_method("process_ie_result", args, Some(&kwargs))?
+            .downcast_into::<PyDict>()
+            .expect("This is a dict");
+
+        let result_str = json_dumps(py, result.into_any())?;
+
+        serde_json::from_str(&result_str).map_err(Into::into)
+    })
+}
+
 /// `extract_info(self, url, download=True, ie_key=None, extra_info=None, process=True, force_generic_extractor=False)`
 ///
 /// Extract and return the information dictionary of the URL
@@ -320,8 +385,8 @@ pub async fn extract_info(
     url: &Url,
     download: bool,
     process: bool,
-) -> PyResult<InfoJson> {
-    Python::with_gil(|py| {
+) -> Result<InfoJson, YtDlpError> {
+    Python::with_gil(|py| -> Result<InfoJson, YtDlpError> {
         let opts = json_map_to_py_dict(yt_dlp_opts, py)?;
 
         let instance = get_yt_dlp(py, opts)?;
@@ -331,14 +396,33 @@ pub async fn extract_info(
         kwargs.set_item("download", download)?;
         kwargs.set_item("process", process)?;
 
-        let result = instance.call_method("extract_info", args, Some(&kwargs))?;
+        let result = instance
+            .call_method("extract_info", args, Some(&kwargs))?
+            .downcast_into::<PyDict>()
+            .expect("This is a dict");
+
+        // Resolve the generator object
+        if let Some(generator) = result.get_item("entries")? {
+            if generator.is_instance_of::<PyList>() {
+                // already resolved. Do nothing
+            } else {
+                let max_backlog = yt_dlp_opts.get("playlistend").map_or(10000, |value| {
+                    usize::try_from(value.as_u64().expect("Works")).expect("Should work")
+                });
+
+                let mut out = vec![];
+                while let Ok(output) = generator.call_method0("__next__") {
+                    out.push(output);
 
-        // Remove the `<generator at 0xsome_hex>`, by setting it to null
-        if !process {
-            result.set_item("entries", ())?;
+                    if out.len() == max_backlog {
+                        break;
+                    }
+                }
+                result.set_item("entries", out)?;
+            }
         }
 
-        let result_str = json_dumps(py, result)?;
+        let result_str = json_dumps(py, result.into_any())?;
 
         if let Ok(confirm) = env::var("YT_STORE_INFO_JSON") {
             if confirm == "yes" {
@@ -347,8 +431,7 @@ pub async fn extract_info(
             }
         }
 
-        Ok(serde_json::from_str(&result_str)
-            .expect("Python should be able to produce correct json"))
+        serde_json::from_str(&result_str).map_err(Into::into)
     })
 }
 
@@ -380,7 +463,7 @@ pub fn unsmuggle_url(smug_url: &Url) -> PyResult<Url> {
 pub async fn download(
     urls: &[Url],
     download_options: &Map<String, Value>,
-) -> PyResult<Vec<PathBuf>> {
+) -> Result<Vec<PathBuf>, YtDlpError> {
     let mut out_paths = Vec::with_capacity(urls.len());
 
     for url in urls {
diff --git a/crates/yt_dlp/src/logging.rs b/crates/yt_dlp/src/logging.rs
index 670fc1c..e731502 100644
--- a/crates/yt_dlp/src/logging.rs
+++ b/crates/yt_dlp/src/logging.rs
@@ -17,10 +17,11 @@
 
 use std::ffi::CString;
 
-use log::{logger, Level, MetadataBuilder, Record};
+use log::{Level, MetadataBuilder, Record, logger};
 use pyo3::{
+    Bound, PyAny, PyResult, Python,
     prelude::{PyAnyMethods, PyListMethods, PyModuleMethods},
-    pyfunction, wrap_pyfunction, Bound, PyAny, PyResult, Python,
+    pyfunction, wrap_pyfunction,
 };
 
 /// Consume a Python `logging.LogRecord` and emit a Rust `Log` instead.
diff --git a/crates/yt_dlp/src/python_json_decode_failed.error_msg b/crates/yt_dlp/src/python_json_decode_failed.error_msg
new file mode 100644
index 0000000..d10688e
--- /dev/null
+++ b/crates/yt_dlp/src/python_json_decode_failed.error_msg
@@ -0,0 +1,5 @@
+Failed to decode yt-dlp's response: {}
+
+This is probably a bug.
+Try running the command again with the `YT_STORE_INFO_JSON=yes` environment variable set
+and maybe debug it further via `yt check info-json output.info.json`.
diff --git a/crates/yt_dlp/src/python_json_decode_failed.error_msg.license b/crates/yt_dlp/src/python_json_decode_failed.error_msg.license
new file mode 100644
index 0000000..7813eb6
--- /dev/null
+++ b/crates/yt_dlp/src/python_json_decode_failed.error_msg.license
@@ -0,0 +1,9 @@
+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>.
diff --git a/crates/yt_dlp/src/tests.rs b/crates/yt_dlp/src/tests.rs
index b48deb4..91b6626 100644
--- a/crates/yt_dlp/src/tests.rs
+++ b/crates/yt_dlp/src/tests.rs
@@ -10,7 +10,7 @@
 
 use std::sync::LazyLock;
 
-use serde_json::{json, Value};
+use serde_json::{Value, json};
 use url::Url;
 
 static YT_OPTS: LazyLock<serde_json::Map<String, Value>> = LazyLock::new(|| {
@@ -26,6 +26,7 @@ static YT_OPTS: LazyLock<serde_json::Map<String, Value>> = LazyLock::new(|| {
 });
 
 #[tokio::test]
+#[ignore = "This test hangs forever"]
 async fn test_extract_info_video() {
     let info = crate::extract_info(
         &YT_OPTS,
@@ -41,6 +42,7 @@ async fn test_extract_info_video() {
 }
 
 #[tokio::test]
+#[ignore = "This test hangs forever"]
 async fn test_extract_info_url() {
     let err = crate::extract_info(
         &YT_OPTS,
@@ -56,6 +58,7 @@ async fn test_extract_info_url() {
 }
 
 #[tokio::test]
+#[ignore = "This test hangs forever"]
 async fn test_extract_info_playlist() {
     let err = crate::extract_info(
         &YT_OPTS,
@@ -70,6 +73,7 @@ async fn test_extract_info_playlist() {
     println!("{err:#?}");
 }
 #[tokio::test]
+#[ignore = "This test hangs forever"]
 async fn test_extract_info_playlist_full() {
     let err = crate::extract_info(
         &YT_OPTS,
diff --git a/crates/yt_dlp/src/wrapper/info_json.rs b/crates/yt_dlp/src/wrapper/info_json.rs
index 35d155e..a2c00df 100644
--- a/crates/yt_dlp/src/wrapper/info_json.rs
+++ b/crates/yt_dlp/src/wrapper/info_json.rs
@@ -13,7 +13,7 @@
 
 use std::{collections::HashMap, path::PathBuf};
 
-use pyo3::{types::PyDict, Bound, PyResult, Python};
+use pyo3::{Bound, PyResult, Python, types::PyDict};
 use serde::{Deserialize, Deserializer, Serialize};
 use serde_json::Value;
 use url::Url;
@@ -29,123 +29,385 @@ type ExtractorKey = String;
 #[derive(Debug, Deserialize, Serialize, PartialEq)]
 #[serde(deny_unknown_fields)]
 pub struct InfoJson {
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub __files_to_move: Option<FilesToMove>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub __last_playlist_index: Option<u32>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub __post_extractor: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub __x_forwarded_for_ip: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub _filename: Option<PathBuf>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub _format_sort_fields: Option<Vec<String>>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub _has_drm: Option<Todo>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub _type: Option<InfoType>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub _version: Option<Version>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub abr: Option<f64>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub acodec: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub age_limit: Option<u32>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub artists: Option<Todo>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub aspect_ratio: Option<f64>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub asr: Option<u32>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub audio_channels: Option<u32>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub audio_ext: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub automatic_captions: Option<HashMap<String, Vec<Caption>>>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub availability: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub average_rating: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub categories: Option<Vec<String>>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub channel: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub channel_follower_count: Option<u32>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub channel_id: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub channel_is_verified: Option<bool>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub channel_url: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub chapters: Option<Vec<Chapter>>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub comment_count: Option<u32>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub comments: Option<Vec<Comment>>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub concurrent_view_count: Option<u32>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub container: Option<Todo>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub description: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub direct: Option<bool>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub display_id: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub downloader_options: Option<DownloaderOptions>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub duration: Option<f64>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub duration_string: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub dynamic_range: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub entries: Option<Vec<InfoJson>>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub episode: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub episode_number: Option<u32>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub epoch: Option<u32>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub ext: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub extractor: Option<Extractor>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub extractor_key: Option<ExtractorKey>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub filename: Option<PathBuf>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub filesize: Option<u64>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub filesize_approx: Option<u64>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub format: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub format_id: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub format_index: Option<u32>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub format_note: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub formats: Option<Vec<Format>>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub fps: Option<f64>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub fulltitle: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub genre: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub genres: Option<Vec<String>>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub has_drm: Option<bool>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub heatmap: Option<Vec<HeatMapEntry>>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub height: Option<u32>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub hls_aes: Option<Todo>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub http_headers: Option<HttpHeader>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub id: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub ie_key: Option<ExtractorKey>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub is_live: Option<bool>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub language: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub language_preference: Option<i32>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub license: Option<Todo>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub like_count: Option<u32>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub live_status: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub location: Option<Todo>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub manifest_url: Option<Url>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub media_type: Option<Todo>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub modified_date: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub n_entries: Option<u32>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub original_url: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub playable_in_embed: Option<bool>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub playlist: Option<Todo>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub playlist_autonumber: Option<u32>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub playlist_channel: Option<Todo>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub playlist_channel_id: Option<Todo>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub playlist_count: Option<u32>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub playlist_id: Option<Todo>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub playlist_index: Option<u64>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub playlist_title: Option<Todo>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub playlist_uploader: Option<Todo>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub playlist_uploader_id: Option<Todo>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub playlist_webpage_url: Option<Url>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub preference: Option<Todo>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub protocol: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub quality: Option<f64>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub release_date: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub release_timestamp: Option<u64>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub release_year: Option<u32>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub repost_count: Option<u32>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub requested_downloads: Option<Vec<RequestedDownloads>>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub requested_entries: Option<Vec<u32>>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub requested_formats: Option<Vec<Format>>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub requested_subtitles: Option<HashMap<String, Subtitle>>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub resolution: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub season: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub season_number: Option<u32>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub series: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub source_preference: Option<i32>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub sponsorblock_chapters: Option<Vec<SponsorblockChapter>>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub stretched_ratio: Option<Todo>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub subtitles: Option<HashMap<String, Vec<Caption>>>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub tags: Option<Vec<String>>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub tbr: Option<f64>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub thumbnail: Option<Url>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub thumbnails: Option<Vec<ThumbNail>>,
-    pub timestamp: Option<u64>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub timestamp: Option<f64>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub title: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub upload_date: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub uploader: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub uploader_id: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub uploader_url: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub url: Option<Url>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub vbr: Option<f64>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub vcodec: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub video_ext: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub view_count: Option<u32>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub was_live: Option<bool>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub webpage_url: Option<Url>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub webpage_url_basename: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub webpage_url_domain: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub width: Option<u32>,
 }
 
@@ -181,7 +443,7 @@ pub struct RequestedDownloads {
     pub filesize_approx: Option<u64>,
     pub format: String,
     pub format_id: String,
-    pub format_note: String,
+    pub format_note: Option<String>,
     pub fps: Option<f64>,
     pub has_drm: Option<bool>,
     pub height: Option<u32>,
@@ -190,6 +452,7 @@ pub struct RequestedDownloads {
     pub language: Option<String>,
     pub manifest_url: Option<Url>,
     pub protocol: String,
+    pub quality: Option<i64>,
     pub requested_formats: Option<Vec<Format>>,
     pub resolution: String,
     pub tbr: f64,
@@ -350,12 +613,15 @@ pub struct HeatMapEntry {
 #[serde(deny_unknown_fields)]
 pub enum InfoType {
     #[serde(alias = "playlist")]
+    #[serde(rename(serialize = "playlist"))]
     Playlist,
 
     #[serde(alias = "url")]
+    #[serde(rename(serialize = "url"))]
     Url,
 
     #[serde(alias = "video")]
+    #[serde(rename(serialize = "video"))]
     Video,
 }
 
@@ -432,7 +698,7 @@ pub struct Comment {
     // Can't also be deserialized, as it's already used in 'edited'
     // _time_text: String,
     pub timestamp: i64,
-    pub author_url: Url,
+    pub author_url: Option<Url>,
     pub author_is_uploader: bool,
     pub is_favorited: bool,
 }
@@ -496,6 +762,7 @@ pub struct Format {
     pub height: Option<u32>,
     pub http_headers: Option<HttpHeader>,
     pub is_dash_periods: Option<bool>,
+    pub is_live: Option<bool>,
     pub language: Option<String>,
     pub language_preference: Option<i32>,
     pub manifest_stream_number: Option<u32>,
@@ -543,9 +810,10 @@ pub struct HttpHeader {
 #[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd)]
 #[serde(deny_unknown_fields)]
 pub struct Fragment {
-    pub url: Option<Url>,
     pub duration: Option<f64>,
+    pub fragment_count: Option<usize>,
     pub path: Option<PathBuf>,
+    pub url: Option<Url>,
 }
 
 impl InfoJson {
diff --git a/crates/yt_dlp/src/wrapper/yt_dlp_options.rs b/crates/yt_dlp/src/wrapper/yt_dlp_options.rs
index c2a86df..25595b5 100644
--- a/crates/yt_dlp/src/wrapper/yt_dlp_options.rs
+++ b/crates/yt_dlp/src/wrapper/yt_dlp_options.rs
@@ -8,7 +8,7 @@
 // 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::{types::PyDict, Bound, PyResult, Python};
+use pyo3::{Bound, PyResult, Python, types::PyDict};
 use serde::Serialize;
 
 use crate::json_loads;