├── .envrc ├── .gitignore ├── src ├── ro_cell.rs ├── message.rs ├── config │ ├── mod.rs │ ├── options.rs │ ├── theme.rs │ └── keys.rs ├── cli.rs ├── search.rs ├── import.rs ├── player.rs ├── channel.rs ├── stream_formats.rs ├── protobuf.rs ├── commands.rs ├── help.rs ├── api │ ├── invidious.rs │ └── mod.rs ├── utils.rs ├── main.rs ├── ui │ └── utils.rs ├── input.rs ├── client.rs └── database.rs ├── flake.nix ├── Cargo.toml ├── .github └── workflows │ ├── ci.yml │ └── cd.yml ├── flake.lock ├── CHANGELOG.md └── README.md /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /result 3 | /.direnv 4 | -------------------------------------------------------------------------------- /src/ro_cell.rs: -------------------------------------------------------------------------------- 1 | use std::{cell::UnsafeCell, mem::MaybeUninit, ops::Deref}; 2 | 3 | pub struct RoCell { 4 | content: UnsafeCell>, 5 | initialized: UnsafeCell, 6 | } 7 | 8 | unsafe impl Sync for RoCell {} 9 | 10 | impl RoCell { 11 | pub const fn new() -> Self { 12 | Self { 13 | content: UnsafeCell::new(MaybeUninit::uninit()), 14 | initialized: UnsafeCell::new(false), 15 | } 16 | } 17 | 18 | pub fn init(&self, value: T) { 19 | unsafe { 20 | self.initialized.get().replace(true); 21 | *self.content.get() = MaybeUninit::new(value); 22 | } 23 | } 24 | } 25 | 26 | impl Deref for RoCell { 27 | type Target = T; 28 | 29 | fn deref(&self) -> &Self::Target { 30 | unsafe { (*self.content.get()).assume_init_ref() } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/message.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | use tokio_util::sync::CancellationToken; 3 | 4 | pub enum MessageType { 5 | Normal, 6 | Error, 7 | Warning, 8 | } 9 | 10 | pub struct Message { 11 | message: String, 12 | pub message_type: MessageType, 13 | token: CancellationToken, 14 | } 15 | 16 | impl Message { 17 | pub fn new() -> Self { 18 | Message { 19 | message: String::new(), 20 | message_type: MessageType::Normal, 21 | token: CancellationToken::new(), 22 | } 23 | } 24 | 25 | pub fn set_message(&mut self, message: &str) { 26 | message.clone_into(&mut self.message); 27 | self.message_type = MessageType::Normal; 28 | self.token.cancel(); 29 | self.token = CancellationToken::new(); 30 | } 31 | 32 | pub fn set_error_message(&mut self, message: &str) { 33 | self.set_message(message); 34 | self.message_type = MessageType::Error; 35 | } 36 | 37 | pub fn set_warning_message(&mut self, message: &str) { 38 | self.set_message(message); 39 | self.message_type = MessageType::Warning; 40 | } 41 | 42 | pub fn clear_message(&mut self) { 43 | self.message.clear(); 44 | } 45 | 46 | pub fn clone_token(&self) -> CancellationToken { 47 | self.token.clone() 48 | } 49 | } 50 | 51 | impl Deref for Message { 52 | type Target = String; 53 | 54 | fn deref(&self) -> &Self::Target { 55 | &self.message 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 4 | 5 | crane.url = "github:ipetkov/crane"; 6 | 7 | flake-utils.url = "github:numtide/flake-utils"; 8 | 9 | rust-overlay = { 10 | url = "github:oxalica/rust-overlay"; 11 | inputs.nixpkgs.follows = "nixpkgs"; 12 | }; 13 | }; 14 | 15 | outputs = 16 | { 17 | nixpkgs, 18 | crane, 19 | flake-utils, 20 | rust-overlay, 21 | ... 22 | }: 23 | flake-utils.lib.eachDefaultSystem ( 24 | system: 25 | let 26 | pkgs = import nixpkgs { 27 | inherit system; 28 | overlays = [ (import rust-overlay) ]; 29 | }; 30 | 31 | rust-toolchain = pkgs.rust-bin.stable.latest.default.override { 32 | extensions = [ 33 | "rust-src" 34 | "rust-analyzer" 35 | ]; 36 | }; 37 | 38 | craneLib = (crane.mkLib pkgs).overrideToolchain rust-toolchain; 39 | 40 | ytsub = craneLib.buildPackage { 41 | CARGO_PROFILE = "release-lto"; 42 | 43 | src = craneLib.cleanCargoSource ./.; 44 | 45 | buildInputs = [ 46 | pkgs.sqlite.dev 47 | ]; 48 | }; 49 | in 50 | { 51 | packages.default = ytsub; 52 | 53 | devShells.default = craneLib.devShell { 54 | inputsFrom = [ ytsub ]; 55 | 56 | packages = with pkgs; [ 57 | cargo-edit 58 | ]; 59 | }; 60 | } 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ytsub" 3 | version = "0.7.0" 4 | edition = "2024" 5 | description = "A subscriptions only TUI Youtube client" 6 | authors = ["Berke Enercan "] 7 | repository = "https://github.com/sarowish/ytsub" 8 | license = "GPL-3.0" 9 | readme = "README.md" 10 | keywords = ["youtube", "cli", "tui", "terminal"] 11 | categories = ["command-line-utilities"] 12 | 13 | [dependencies] 14 | anyhow = "1.0.100" 15 | clap = "4.5.50" 16 | crossterm = { version = "0.29.0", features = ["event-stream"] } 17 | dirs = "6.0.0" 18 | futures-util = "0.3.31" 19 | num_cpus = "1.17.0" 20 | rand = "0.9.2" 21 | rusqlite = "0.37.0" 22 | serde = { version = "1.0.228", features = ["derive"] } 23 | serde_json = "1.0.145" 24 | tokio = { version = "1.48.0", features = ["time", "macros", "rt-multi-thread", "process"] } 25 | tokio-util = "0.7.16" 26 | unicode-width = "0.2.0" 27 | unicode-segmentation = "1.12.0" 28 | toml = "0.9.8" 29 | csv = "1.4.0" 30 | quick-xml = { version = "0.38.3", features = ["serialize"] } 31 | chrono = "0.4.42" 32 | dyn-clone = "1.0.20" 33 | ratatui = "0.29.0" 34 | regex-lite = "0.1.8" 35 | reqwest = { version = "0.12.24", default-features = false, features = ["json", "rustls-tls", "gzip"] } 36 | async-trait = "0.1.89" 37 | open = "5.3.2" 38 | bitflags = "2.10.0" 39 | base64 = "0.22.1" 40 | itertools = "0.14.0" 41 | url = "2.5.7" 42 | 43 | [target.'cfg(unix)'.dependencies] 44 | libc = "0.2.177" 45 | 46 | [target.'cfg(windows)'.dependencies] 47 | rusqlite = { version = "0.37.0", features = ["bundled"]} 48 | 49 | [features] 50 | bundled_sqlite = ["rusqlite/bundled"] 51 | 52 | 53 | [profile.release-lto] 54 | inherits = "release" 55 | lto = true 56 | codegen-units = 1 57 | panic = "abort" 58 | strip = "symbols" 59 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | 8 | jobs: 9 | test: 10 | name: Test 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | include: 15 | - os: ubuntu-latest 16 | target: x86_64-unknown-linux-gnu 17 | - os: ubuntu-24.04-arm 18 | target: aarch64-unknown-linux-gnu 19 | - os: macos-latest 20 | target: aarch64-apple-darwin 21 | - os: macos-13 22 | target: x86_64-apple-darwin 23 | - os: windows-latest 24 | target: x86_64-pc-windows-msvc 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v4 28 | 29 | - name: Install Rust 30 | uses: dtolnay/rust-toolchain@stable 31 | with: 32 | targets: ${{ matrix.target }} 33 | 34 | - uses: Swatinem/rust-cache@v2 35 | 36 | - name: Test 37 | run: cargo test --target ${{ matrix.target }} 38 | 39 | clippy: 40 | name: Clippy 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Checkout repository 44 | uses: actions/checkout@v4 45 | 46 | - name: Install Rust 47 | uses: dtolnay/rust-toolchain@stable 48 | with: 49 | components: clippy 50 | 51 | - uses: Swatinem/rust-cache@v2 52 | 53 | - name: Check Lints 54 | run: cargo clippy -- -D warnings 55 | 56 | rustfmt: 57 | name: Rustfmt 58 | runs-on: ubuntu-latest 59 | steps: 60 | - name: Checkout repository 61 | uses: actions/checkout@v4 62 | 63 | - name: Install Rust 64 | uses: dtolnay/rust-toolchain@stable 65 | with: 66 | components: rustfmt 67 | 68 | - name: Check formatting 69 | run: cargo fmt --all --check 70 | -------------------------------------------------------------------------------- /src/config/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod keys; 2 | pub mod options; 3 | pub mod theme; 4 | 5 | use self::{ 6 | keys::{KeyBindings, UserKeyBindings}, 7 | options::{Options, UserOptions}, 8 | theme::{Theme, UserTheme}, 9 | }; 10 | use crate::{CLAP_ARGS, utils}; 11 | use anyhow::Result; 12 | use serde::Deserialize; 13 | use std::{fs, path::PathBuf}; 14 | 15 | const CONFIG_FILE: &str = "config.toml"; 16 | 17 | #[derive(Deserialize)] 18 | struct UserConfig { 19 | #[serde(flatten)] 20 | options: Option, 21 | #[serde(flatten)] 22 | theme: Option, 23 | key_bindings: Option, 24 | } 25 | 26 | #[derive(Default)] 27 | pub struct Config { 28 | pub options: Options, 29 | pub theme: Theme, 30 | pub key_bindings: KeyBindings, 31 | } 32 | 33 | impl Config { 34 | pub fn new() -> Result { 35 | let config_file = match CLAP_ARGS.get_one::("config") { 36 | Some(path) => path.to_owned(), 37 | None => utils::get_config_dir()?.join(CONFIG_FILE), 38 | }; 39 | 40 | let mut config = match fs::read_to_string(config_file) { 41 | Ok(config_str) if !CLAP_ARGS.get_flag("no_config") => { 42 | Self::try_from(toml::from_str::(&config_str)?)? 43 | } 44 | _ => Self::default(), 45 | }; 46 | 47 | config.options.override_with_clap_args(); 48 | 49 | if config.options.database.as_os_str().is_empty() { 50 | config.options.database = utils::get_default_database_file()?; 51 | } 52 | 53 | if config.options.instances.as_os_str().is_empty() { 54 | config.options.instances = utils::get_default_instances_file()?; 55 | } 56 | 57 | Ok(config) 58 | } 59 | } 60 | 61 | impl TryFrom for Config { 62 | type Error = anyhow::Error; 63 | 64 | fn try_from(user_config: UserConfig) -> Result { 65 | let mut config = Self::default(); 66 | 67 | if let Some(options) = user_config.options { 68 | config.options = options.into(); 69 | } 70 | 71 | if let Some(theme) = user_config.theme { 72 | config.theme = theme.try_into()?; 73 | } 74 | 75 | if let Some(key_bindings) = user_config.key_bindings { 76 | config.key_bindings = key_bindings.try_into()?; 77 | } 78 | 79 | Ok(config) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Deployment 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v[0-9]+.[0-9]+.[0-9]+" 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | include: 15 | - os: ubuntu-latest 16 | target: x86_64-unknown-linux-gnu 17 | - os: ubuntu-24.04-arm 18 | target: aarch64-unknown-linux-gnu 19 | - os: macos-latest 20 | target: aarch64-apple-darwin 21 | - os: macos-13 22 | target: x86_64-apple-darwin 23 | - os: windows-latest 24 | target: x86_64-pc-windows-msvc 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | 30 | - name: Set the release version 31 | run: echo "RELEASE_VERSION=${GITHUB_REF:11}" >> $GITHUB_ENV 32 | 33 | - name: Install Rust 34 | uses: dtolnay/rust-toolchain@stable 35 | with: 36 | targets: ${{ matrix.target }} 37 | 38 | - name: Build 39 | run: cargo build --profile release-lto --target ${{ matrix.target }} 40 | 41 | - name: Prepare assets 42 | shell: bash 43 | run: | 44 | mkdir ytsub-${{ env.RELEASE_VERSION }} 45 | cp LICENSE README.md target/${{ matrix.target }}/release-lto/ytsub \ 46 | ytsub-${{ env.RELEASE_VERSION }} 47 | 48 | - name: Package assets 49 | shell: bash 50 | run: | 51 | if [ "${{ matrix.config.OS }}" = "windows-latest" ]; then 52 | 7z a -tzip ytsub-${{ env.RELEASE_VERSION }}-${{ matrix.target }}.zip \ 53 | ytsub-${{ env.RELEASE_VERSION }} 54 | else 55 | tar czvf ytsub-${{ env.RELEASE_VERSION }}-${{ matrix.target }}.tar.gz \ 56 | ytsub-${{ env.RELEASE_VERSION }} 57 | fi 58 | 59 | - name: Upload 60 | uses: softprops/action-gh-release@v1 61 | with: 62 | files: ytsub-${{ env.RELEASE_VERSION }}-${{ matrix.target }}.* 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | 66 | publish-cargo: 67 | name: Publish on crates.io 68 | runs-on: ubuntu-latest 69 | steps: 70 | - name: Checkout repository 71 | uses: actions/checkout@v4 72 | 73 | - name: Install Rust 74 | uses: dtolnay/rust-toolchain@stable 75 | 76 | - name: Publish 77 | run: cargo publish 78 | env: 79 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 80 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "crane": { 4 | "locked": { 5 | "lastModified": 1762538466, 6 | "narHash": "sha256-8zrIPl6J+wLm9MH5ksHcW7BUHo7jSNOu0/hA0ohOOaM=", 7 | "owner": "ipetkov", 8 | "repo": "crane", 9 | "rev": "0cea393fffb39575c46b7a0318386467272182fe", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "ipetkov", 14 | "repo": "crane", 15 | "type": "github" 16 | } 17 | }, 18 | "flake-utils": { 19 | "inputs": { 20 | "systems": "systems" 21 | }, 22 | "locked": { 23 | "lastModified": 1731533236, 24 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 25 | "owner": "numtide", 26 | "repo": "flake-utils", 27 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "numtide", 32 | "repo": "flake-utils", 33 | "type": "github" 34 | } 35 | }, 36 | "nixpkgs": { 37 | "locked": { 38 | "lastModified": 1762482733, 39 | "narHash": "sha256-g/da4FzvckvbiZT075Sb1/YDNDr+tGQgh4N8i5ceYMg=", 40 | "owner": "NixOS", 41 | "repo": "nixpkgs", 42 | "rev": "e1ebeec86b771e9d387dd02d82ffdc77ac753abc", 43 | "type": "github" 44 | }, 45 | "original": { 46 | "owner": "NixOS", 47 | "ref": "nixpkgs-unstable", 48 | "repo": "nixpkgs", 49 | "type": "github" 50 | } 51 | }, 52 | "root": { 53 | "inputs": { 54 | "crane": "crane", 55 | "flake-utils": "flake-utils", 56 | "nixpkgs": "nixpkgs", 57 | "rust-overlay": "rust-overlay" 58 | } 59 | }, 60 | "rust-overlay": { 61 | "inputs": { 62 | "nixpkgs": [ 63 | "nixpkgs" 64 | ] 65 | }, 66 | "locked": { 67 | "lastModified": 1762655942, 68 | "narHash": "sha256-hOM12KcQNQALrhB9w6KJmV5hPpm3GA763HRe9o7JUiI=", 69 | "owner": "oxalica", 70 | "repo": "rust-overlay", 71 | "rev": "6ac961b02d4235572692241e333d0470637f5492", 72 | "type": "github" 73 | }, 74 | "original": { 75 | "owner": "oxalica", 76 | "repo": "rust-overlay", 77 | "type": "github" 78 | } 79 | }, 80 | "systems": { 81 | "locked": { 82 | "lastModified": 1681028828, 83 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 84 | "owner": "nix-systems", 85 | "repo": "default", 86 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 87 | "type": "github" 88 | }, 89 | "original": { 90 | "owner": "nix-systems", 91 | "repo": "default", 92 | "type": "github" 93 | } 94 | } 95 | }, 96 | "root": "root", 97 | "version": 7 98 | } 99 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser}; 2 | 3 | pub fn get_matches() -> ArgMatches { 4 | Command::new(env!("CARGO_PKG_NAME")) 5 | .version(env!("CARGO_PKG_VERSION")) 6 | .about(env!("CARGO_PKG_DESCRIPTION")) 7 | .arg( 8 | Arg::new("config") 9 | .short('c') 10 | .long("config") 11 | .help("Path to configuration file") 12 | .value_parser(ValueParser::path_buf()) 13 | .value_name("FILE"), 14 | ) 15 | .arg( 16 | Arg::new("no_config") 17 | .short('n') 18 | .long("no-config") 19 | .help("Ignore configuration file") 20 | .conflicts_with("config") 21 | .action(ArgAction::SetTrue), 22 | ) 23 | .arg( 24 | Arg::new("database") 25 | .short('d') 26 | .long("database") 27 | .help("Path to database file") 28 | .value_parser(ValueParser::path_buf()) 29 | .value_name("FILE"), 30 | ) 31 | .arg( 32 | Arg::new("instances") 33 | .short('s') 34 | .long("instances") 35 | .help("Path to instances file") 36 | .value_parser(ValueParser::path_buf()) 37 | .value_name("FILE"), 38 | ) 39 | .arg( 40 | Arg::new("gen_instances_list") 41 | .short('g') 42 | .long("gen-instances") 43 | .help("Generate Invidious instances file") 44 | .action(ArgAction::SetTrue), 45 | ) 46 | .arg( 47 | Arg::new("tick_rate") 48 | .hide(true) 49 | .short('t') 50 | .long("tick-rate") 51 | .help("Tick rate in milliseconds") 52 | .value_name("TICK RATE") 53 | .value_parser(clap::value_parser!(u64)), 54 | ) 55 | .arg( 56 | Arg::new("request_timeout") 57 | .hide(true) 58 | .short('r') 59 | .long("request-timeout") 60 | .help("Timeout in seconds") 61 | .value_name("TIMEOUT") 62 | .value_parser(clap::value_parser!(u64)), 63 | ) 64 | .arg( 65 | Arg::new("highlight_symbol") 66 | .hide(true) 67 | .long("highlight-symbol") 68 | .help("Symbol to highlight selected items") 69 | .value_name("SYMBOL"), 70 | ) 71 | .subcommand(create_import_subcommand()) 72 | .subcommand(create_export_subcommand()) 73 | .get_matches() 74 | } 75 | 76 | fn create_import_subcommand() -> Command { 77 | Command::new("import") 78 | .about("Import subscriptions") 79 | .arg( 80 | Arg::new("format") 81 | .short('f') 82 | .long("format") 83 | .help("Format of the import file") 84 | .value_name("FORMAT") 85 | .default_value("youtube_csv") 86 | .value_parser(["youtube_csv", "newpipe"]), 87 | ) 88 | .arg( 89 | Arg::new("source") 90 | .help("Path to the import file") 91 | .value_parser(ValueParser::path_buf()) 92 | .value_name("FILE") 93 | .required(true), 94 | ) 95 | } 96 | 97 | fn create_export_subcommand() -> Command { 98 | Command::new("export") 99 | .about("Export subscriptions") 100 | .arg( 101 | Arg::new("format") 102 | .short('f') 103 | .long("format") 104 | .help("Format of the export file") 105 | .value_name("FORMAT") 106 | .default_value("youtube_csv") 107 | .value_parser(["youtube_csv", "newpipe"]), 108 | ) 109 | .arg( 110 | Arg::new("target") 111 | .help("Path to the export file") 112 | .value_parser(ValueParser::path_buf()) 113 | .value_name("FILE") 114 | .required(true), 115 | ) 116 | } 117 | -------------------------------------------------------------------------------- /src/search.rs: -------------------------------------------------------------------------------- 1 | use crate::app::{State, StatefulList}; 2 | use std::fmt::Display; 3 | 4 | #[derive(Default)] 5 | pub enum SearchState { 6 | #[default] 7 | NotSearching, 8 | PoppedKey, 9 | PushedKey, 10 | } 11 | 12 | #[derive(Default, PartialEq, Eq, Debug, Clone)] 13 | pub enum SearchDirection { 14 | #[default] 15 | Forward, 16 | Backward, 17 | } 18 | 19 | impl SearchDirection { 20 | fn reverse(&self) -> SearchDirection { 21 | match self { 22 | SearchDirection::Forward => SearchDirection::Backward, 23 | SearchDirection::Backward => SearchDirection::Forward, 24 | } 25 | } 26 | } 27 | 28 | type Match = (usize, String); 29 | type LastSearch = (String, SearchDirection); 30 | 31 | #[derive(Default)] 32 | pub struct Search { 33 | matches: Vec, 34 | pub pattern: String, 35 | pub state: SearchState, 36 | pub direction: SearchDirection, 37 | pub recovery_index: Option, 38 | last_search: Option, 39 | } 40 | 41 | impl Search { 42 | pub fn search(&mut self, list: &mut StatefulList, pattern: &str) { 43 | if pattern.is_empty() { 44 | self.recover_item(list); 45 | return; 46 | } 47 | self.pattern = pattern.to_lowercase(); 48 | match self.state { 49 | SearchState::NotSearching | SearchState::PoppedKey => { 50 | if let SearchState::NotSearching = self.state { 51 | self.recovery_index = list.state.selected(); 52 | } 53 | self.matches = list 54 | .items 55 | .iter() 56 | .enumerate() 57 | .map(|(i, item)| (i, item.to_string().to_lowercase())) 58 | .filter(|(_, item)| item.contains(&self.pattern)) 59 | .collect(); 60 | } 61 | SearchState::PushedKey => { 62 | self.matches = self 63 | .matches 64 | .drain(..) 65 | .filter(|(_, text)| text.contains(&self.pattern)) 66 | .collect(); 67 | } 68 | } 69 | if self.any_matches() { 70 | match self.direction { 71 | SearchDirection::Forward => self.next_match(list), 72 | SearchDirection::Backward => self.prev_match(list), 73 | } 74 | } else { 75 | self.recover_item(list); 76 | } 77 | } 78 | 79 | fn indices(&self) -> Vec { 80 | self.matches.iter().map(|m| m.0).collect() 81 | } 82 | 83 | pub fn any_matches(&self) -> bool { 84 | !self.matches.is_empty() 85 | } 86 | 87 | pub fn complete_search(&mut self, abort: bool) { 88 | self.state = SearchState::NotSearching; 89 | self.recovery_index = None; 90 | self.matches.clear(); 91 | 92 | let pattern = std::mem::take(&mut self.pattern); 93 | 94 | if !abort { 95 | self.last_search = Some((pattern, self.direction.clone())); 96 | } 97 | } 98 | 99 | pub fn recover_item(&mut self, list: &mut StatefulList) { 100 | if self.recovery_index.is_some() { 101 | list.state.select(self.recovery_index); 102 | } 103 | } 104 | 105 | fn jump_to_match( 106 | &self, 107 | list: &mut StatefulList, 108 | match_index: Option, 109 | ) { 110 | if match_index.is_some() { 111 | list.state.select(match_index); 112 | } 113 | } 114 | 115 | pub fn next_match(&mut self, list: &mut StatefulList) { 116 | let indices = self.indices(); 117 | let match_index = if let Some(recovery_index) = self.recovery_index { 118 | indices 119 | .iter() 120 | .find(|index| **index > recovery_index) 121 | .or_else(|| indices.first()) 122 | } else { 123 | indices.first() 124 | } 125 | .copied(); 126 | self.jump_to_match(list, match_index); 127 | } 128 | 129 | pub fn prev_match(&mut self, list: &mut StatefulList) { 130 | let indices = self.indices(); 131 | let match_index = if let Some(recovery_index) = self.recovery_index { 132 | indices 133 | .iter() 134 | .rev() 135 | .find(|index| **index < recovery_index) 136 | .or_else(|| indices.last()) 137 | } else { 138 | indices.last() 139 | } 140 | .copied(); 141 | self.jump_to_match(list, match_index); 142 | } 143 | 144 | pub fn repeat_last( 145 | &mut self, 146 | list: &mut StatefulList, 147 | opposite_dir: bool, 148 | ) { 149 | if let Some((pattern, direction)) = &self.last_search { 150 | let pattern = pattern.clone(); 151 | self.direction = if opposite_dir { 152 | direction.reverse() 153 | } else { 154 | direction.clone() 155 | }; 156 | self.search(list, &pattern); 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/import.rs: -------------------------------------------------------------------------------- 1 | use crate::channel::{Channel, ListItem, RefreshState}; 2 | use anyhow::Result; 3 | use serde::{Deserialize, Serialize}; 4 | use std::{fmt::Display, fs::File, io::BufReader, path::Path}; 5 | 6 | #[derive(Clone, Copy)] 7 | pub enum Format { 8 | YoutubeCsv, 9 | NewPipe, 10 | } 11 | 12 | impl From<&str> for Format { 13 | fn from(format: &str) -> Self { 14 | match format { 15 | "newpipe" => Format::NewPipe, 16 | _ => Format::YoutubeCsv, 17 | } 18 | } 19 | } 20 | 21 | pub trait Import { 22 | fn channel_id(&self) -> String; 23 | fn channel_title(&self) -> String; 24 | } 25 | 26 | #[derive(Deserialize, Serialize)] 27 | pub struct YoutubeCsv { 28 | #[serde(rename = "Channel Id")] 29 | pub channel_id: String, 30 | #[serde(rename = "Channel Url")] 31 | channel_url: String, 32 | #[serde(rename = "Channel Title")] 33 | pub channel_title: String, 34 | } 35 | 36 | impl YoutubeCsv { 37 | pub fn read_subscriptions(path: &Path) -> Result> { 38 | let file = File::open(path)?; 39 | let mut rdr = csv::Reader::from_reader(file); 40 | 41 | let mut subscriptions: Vec = Vec::new(); 42 | 43 | for record in rdr.deserialize() { 44 | subscriptions.push(record?); 45 | } 46 | 47 | Ok(subscriptions.into_iter().map(ImportItem::from).collect()) 48 | } 49 | 50 | pub fn export(channels: &[Channel], path: &Path) -> Result<()> { 51 | let file = File::create(path)?; 52 | let mut wtr = csv::Writer::from_writer(file); 53 | 54 | for channel in channels { 55 | wtr.serialize(YoutubeCsv { 56 | channel_id: channel.channel_id.clone(), 57 | channel_url: format!("http://www.youtube.com/channel/{}", channel.channel_id), 58 | channel_title: channel.channel_name.clone(), 59 | })?; 60 | } 61 | 62 | wtr.flush()?; 63 | 64 | Ok(()) 65 | } 66 | } 67 | 68 | impl Import for YoutubeCsv { 69 | fn channel_id(&self) -> String { 70 | self.channel_id.clone() 71 | } 72 | 73 | fn channel_title(&self) -> String { 74 | self.channel_title.clone() 75 | } 76 | } 77 | 78 | #[derive(Deserialize, Serialize, Clone)] 79 | pub struct NewPipeInner { 80 | service_id: u64, 81 | pub url: String, 82 | pub name: String, 83 | } 84 | 85 | impl NewPipeInner { 86 | fn new(channel: &Channel) -> Self { 87 | Self { 88 | service_id: 0, 89 | url: format!("http://www.youtube.com/channel/{}", channel.channel_id), 90 | name: channel.channel_name.clone(), 91 | } 92 | } 93 | } 94 | 95 | #[derive(Deserialize, Serialize)] 96 | pub struct NewPipe { 97 | app_version: String, 98 | app_version_int: u64, 99 | pub subscriptions: Vec, 100 | } 101 | 102 | impl NewPipe { 103 | fn new(subscriptions: Vec) -> Self { 104 | Self { 105 | app_version: "0.23.0".to_string(), 106 | app_version_int: 986, 107 | subscriptions, 108 | } 109 | } 110 | 111 | pub fn read_subscriptions(path: &Path) -> Result> { 112 | let file = File::open(path)?; 113 | let rdr = BufReader::new(file); 114 | 115 | let newpipe: NewPipe = serde_json::from_reader(rdr)?; 116 | 117 | Ok(newpipe 118 | .subscriptions 119 | .into_iter() 120 | .map(ImportItem::from) 121 | .collect()) 122 | } 123 | 124 | pub fn export(channels: &[Channel], path: &Path) -> Result<()> { 125 | let file = File::create(path)?; 126 | 127 | let subs = channels.iter().map(NewPipeInner::new).collect(); 128 | 129 | let newpipe = NewPipe::new(subs); 130 | 131 | Ok(serde_json::to_writer(file, &newpipe)?) 132 | } 133 | } 134 | 135 | impl Import for NewPipeInner { 136 | fn channel_id(&self) -> String { 137 | self.url 138 | .rsplit_once('/') 139 | .map(|(_, id)| id) 140 | .unwrap() 141 | .to_string() 142 | } 143 | 144 | fn channel_title(&self) -> String { 145 | self.name.clone() 146 | } 147 | } 148 | 149 | pub struct ImportItem { 150 | pub sub_state: RefreshState, 151 | pub channel_title: String, 152 | pub channel_id: String, 153 | } 154 | 155 | impl From for ImportItem { 156 | fn from(item: T) -> Self { 157 | Self { 158 | sub_state: RefreshState::Completed, 159 | channel_title: item.channel_title(), 160 | channel_id: item.channel_id(), 161 | } 162 | } 163 | } 164 | 165 | impl ListItem for ImportItem { 166 | fn id(&self) -> &str { 167 | &self.channel_id 168 | } 169 | } 170 | 171 | impl Display for ImportItem { 172 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 173 | write!( 174 | f, 175 | "{} {}", 176 | match self.sub_state { 177 | RefreshState::ToBeRefreshed => "□", 178 | RefreshState::Refreshing => "■", 179 | _ => "", 180 | }, 181 | self.channel_title 182 | ) 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/player.rs: -------------------------------------------------------------------------------- 1 | use crate::TX; 2 | use crate::client::{Client, ClientRequest}; 3 | use crate::{OPTIONS, api::Api, app::VideoPlayer, emit_msg, stream_formats::Formats}; 4 | use anyhow::Result; 5 | use std::path::Path; 6 | use std::process::Stdio; 7 | use tokio::process::Command; 8 | 9 | pub async fn run_detached(mut command: Command) -> Result<()> { 10 | #[cfg(unix)] 11 | unsafe { 12 | command.pre_exec(|| { 13 | if libc::setsid() < 0 { 14 | return Err(std::io::Error::last_os_error()); 15 | } 16 | 17 | Ok(()) 18 | }) 19 | }; 20 | 21 | let mut child = command 22 | .stdin(Stdio::null()) 23 | .stdout(Stdio::null()) 24 | .stderr(Stdio::null()) 25 | .spawn()?; 26 | 27 | let exit_status = child.wait().await?; 28 | 29 | if let Some(code) = exit_status.code() 30 | && code != 0 31 | { 32 | Err(anyhow::anyhow!("Process exited with status code {code}")) 33 | } else { 34 | Ok(()) 35 | } 36 | } 37 | 38 | pub async fn play_from_formats(instance: Box, formats: Formats) -> Result<()> { 39 | let (video_url, audio_url) = if formats.use_adaptive_streams { 40 | ( 41 | formats.video_formats.get_selected_item().get_url(), 42 | Some(formats.audio_formats.get_selected_item().get_url()), 43 | ) 44 | } else { 45 | (formats.formats.get_selected_item().get_url(), None) 46 | }; 47 | 48 | let captions = instance.get_caption_paths(&formats).await; 49 | 50 | let chapters = formats 51 | .chapters 52 | .and_then(|chapters| chapters.write_to_file(&formats.id).ok()); 53 | 54 | let player_command = gen_video_player_command( 55 | video_url, 56 | audio_url, 57 | &captions, 58 | chapters.as_deref(), 59 | &formats.title, 60 | ); 61 | 62 | play_video(player_command, &formats.id).await 63 | } 64 | 65 | pub async fn play_using_ytdlp(video_id: &str) -> Result<()> { 66 | let url = format!("{}/watch?v={}", "https://www.youtube.com", video_id); 67 | 68 | let mut player_command = Command::new(&OPTIONS.mpv_path); 69 | player_command.arg(url); 70 | 71 | play_video(player_command, video_id).await 72 | } 73 | 74 | async fn play_video(player_command: Command, video_id: &str) -> Result<()> { 75 | emit_msg!("Launching video player"); 76 | TX.send(ClientRequest::SetWatched(video_id.to_owned(), true))?; 77 | 78 | if let Err(e) = run_detached(player_command).await { 79 | emit_msg!(error, e.to_string()); 80 | TX.send(ClientRequest::SetWatched(video_id.to_owned(), false))?; 81 | } 82 | 83 | Ok(()) 84 | } 85 | 86 | fn gen_video_player_command( 87 | video_url: &str, 88 | audio_url: Option<&str>, 89 | captions: &[String], 90 | chapters: Option<&Path>, 91 | title: &str, 92 | ) -> Command { 93 | let mut command; 94 | match OPTIONS.video_player_for_stream_formats { 95 | VideoPlayer::Mpv => { 96 | command = Command::new(&OPTIONS.mpv_path); 97 | command 98 | .arg(format!("--force-media-title={title}")) 99 | .arg("--no-ytdl") 100 | .arg(video_url); 101 | 102 | if let Some(audio_url) = audio_url { 103 | command.arg(format!("--audio-file={audio_url}")); 104 | } 105 | 106 | for caption in captions { 107 | command.arg(format!("--sub-file={caption}")); 108 | } 109 | 110 | if let Some(path) = chapters { 111 | command.arg(format!("--chapters-file={}", path.display())); 112 | } 113 | } 114 | VideoPlayer::Vlc => { 115 | command = Command::new(&OPTIONS.vlc_path); 116 | command 117 | .arg("--no-video-title-show") 118 | .arg(format!("--input-title-format={title}")) 119 | .arg("--play-and-exit") 120 | .arg(video_url); 121 | 122 | if let Some(audio_url) = audio_url { 123 | command.arg(format!("--input-slave={audio_url}")); 124 | } 125 | 126 | if !captions.is_empty() { 127 | command.arg(format!("--sub-file={}", captions.join(" "))); 128 | } 129 | } 130 | } 131 | 132 | command 133 | } 134 | 135 | pub async fn open_in_invidious(client: &mut Client, url_component: &str) -> Result<()> { 136 | if client.invidious_instance.is_none() 137 | && let Err(e) = client.set_instance().await 138 | { 139 | emit_msg!(error, e.to_string()); 140 | return Ok(()); 141 | } 142 | 143 | let instance = client 144 | .invidious_instance 145 | .as_ref() 146 | .expect("The function should return before if an instance couldn't be set"); 147 | 148 | let url = format!("{}/{}", instance.domain, url_component); 149 | 150 | open_in_browser(&url); 151 | 152 | Ok(()) 153 | } 154 | 155 | pub fn open_in_youtube(url_component: &str) { 156 | open_in_browser(&format!("https://www.youtube.com/{url_component}")); 157 | } 158 | 159 | pub fn open_in_browser(url: &str) { 160 | let commands = open::commands(url); 161 | let mut last_error = None; 162 | 163 | tokio::spawn(async move { 164 | for cmd in commands { 165 | let command = Command::from(cmd); 166 | 167 | match run_detached(command).await { 168 | Ok(()) => return Ok(()), 169 | Err(err) => last_error = Some(err), 170 | } 171 | } 172 | 173 | emit_msg!(error, &last_error.unwrap().to_string()); 174 | anyhow::Ok(()) 175 | }); 176 | } 177 | -------------------------------------------------------------------------------- /src/channel.rs: -------------------------------------------------------------------------------- 1 | use crate::{OPTIONS, THEME, config::options::EnabledTabs}; 2 | use bitflags::bitflags; 3 | use chrono::DateTime; 4 | use ratatui::text::{Line, Span}; 5 | use serde::{Deserialize, de}; 6 | use serde_json::Value; 7 | use std::fmt::Display; 8 | 9 | #[derive(Deserialize, PartialEq, Debug, Clone, Copy)] 10 | #[serde(rename_all(deserialize = "lowercase"))] 11 | pub enum ChannelTab { 12 | Videos, 13 | Shorts, 14 | Streams, 15 | } 16 | 17 | impl From for ChannelTab { 18 | fn from(value: u8) -> Self { 19 | match value { 20 | 0b001 => ChannelTab::Videos, 21 | 0b010 => ChannelTab::Shorts, 22 | 0b100 => ChannelTab::Streams, 23 | _ => unreachable!("The function should only be used for `EnabledTabs` names."), 24 | } 25 | } 26 | } 27 | 28 | impl Display for ChannelTab { 29 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 30 | write!( 31 | f, 32 | "{}", 33 | match self { 34 | ChannelTab::Videos => "videos", 35 | ChannelTab::Shorts => "shorts", 36 | ChannelTab::Streams => "streams", 37 | } 38 | ) 39 | } 40 | } 41 | 42 | pub fn tabs_to_be_loaded() -> impl Iterator { 43 | if OPTIONS.hide_disabled_tabs { 44 | OPTIONS.tabs.iter() 45 | } else { 46 | EnabledTabs::all().iter() 47 | } 48 | .map(|tab| tab.bits().into()) 49 | } 50 | 51 | #[derive(Clone, Copy)] 52 | pub enum RefreshState { 53 | ToBeRefreshed, 54 | Refreshing, 55 | Completed, 56 | Failed, 57 | } 58 | 59 | pub trait ListItem { 60 | fn id(&self) -> &str; 61 | } 62 | 63 | pub struct Channel { 64 | pub channel_id: String, 65 | pub channel_name: String, 66 | pub refresh_state: RefreshState, 67 | pub new_video: bool, 68 | pub last_refreshed: Option, 69 | } 70 | 71 | impl Channel { 72 | pub fn new(channel_id: String, channel_name: String, last_refreshed: Option) -> Self { 73 | Self { 74 | channel_id, 75 | channel_name, 76 | refresh_state: RefreshState::Completed, 77 | new_video: false, 78 | last_refreshed, 79 | } 80 | } 81 | 82 | pub fn set_to_be_refreshed(&mut self) { 83 | self.refresh_state = RefreshState::ToBeRefreshed; 84 | } 85 | } 86 | 87 | impl ListItem for Channel { 88 | fn id(&self) -> &str { 89 | &self.channel_id 90 | } 91 | } 92 | 93 | impl Display for Channel { 94 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 95 | write!(f, "{}", self.channel_name) 96 | } 97 | } 98 | 99 | impl From<&Channel> for Line<'_> { 100 | fn from(value: &Channel) -> Self { 101 | let refresh_indicator = match value.refresh_state { 102 | RefreshState::ToBeRefreshed => "□ ", 103 | RefreshState::Refreshing => "■ ", 104 | RefreshState::Completed => "", 105 | RefreshState::Failed => "✗ ", 106 | }; 107 | 108 | Line::from(vec![ 109 | Span::raw(format!("{}{}", refresh_indicator, value.channel_name)), 110 | Span::styled( 111 | if value.new_video { " [N]" } else { "" }, 112 | THEME.new_video_indicator, 113 | ), 114 | ]) 115 | } 116 | } 117 | 118 | fn deserialize_published_date<'de, D>(deserializer: D) -> Result 119 | where 120 | D: de::Deserializer<'de>, 121 | { 122 | let date_str: &str = de::Deserialize::deserialize(deserializer)?; 123 | let date = DateTime::parse_from_rfc3339(date_str).unwrap(); 124 | 125 | Ok(date.timestamp() as u64) 126 | } 127 | 128 | bitflags! { 129 | pub struct HideVideos: u8 { 130 | const WATCHED = 0b0001; 131 | const MEMBERS_ONLY = 0b0010; 132 | } 133 | } 134 | 135 | #[derive(Deserialize)] 136 | pub struct Video { 137 | #[serde(skip_deserializing)] 138 | pub channel_name: Option, 139 | #[serde(rename = "videoId")] 140 | pub video_id: String, 141 | pub title: String, 142 | #[serde(deserialize_with = "deserialize_published_date")] 143 | pub published: u64, 144 | #[serde(skip_deserializing)] 145 | pub published_text: String, 146 | pub length: Option, 147 | #[serde(skip_deserializing)] 148 | pub watched: bool, 149 | #[serde(skip_deserializing)] 150 | pub members_only: bool, 151 | #[serde(skip_deserializing)] 152 | pub new: bool, 153 | } 154 | 155 | impl Video { 156 | pub fn needs_update(&self, other: &Video) -> bool { 157 | self.title != other.title 158 | || self.length != other.length 159 | || self.members_only != other.members_only 160 | } 161 | } 162 | 163 | impl From<&Value> for Video { 164 | fn from(video_json: &Value) -> Self { 165 | let is_upcoming = video_json["isUpcoming"].as_bool().unwrap(); 166 | let mut published = video_json["published"].as_u64().unwrap(); 167 | let published_text = video_json 168 | .get("publishedText") 169 | .and_then(Value::as_str) 170 | .map(ToString::to_string) 171 | .unwrap_or_default(); 172 | let mut length = video_json["lengthSeconds"].as_u64().unwrap(); 173 | 174 | if is_upcoming { 175 | let premiere_timestamp = video_json["premiereTimestamp"].as_u64().unwrap(); 176 | 177 | // In Invidious API, all shorts are marked as upcoming but the published key needs to be 178 | // used for the release time. If the premiere timestamp is 0, assume it is a shorts. 179 | if premiere_timestamp != 0 { 180 | published = premiere_timestamp; 181 | } 182 | } 183 | 184 | if length == 0 { 185 | length = 60; 186 | } 187 | 188 | Video { 189 | channel_name: None, 190 | video_id: video_json["videoId"].as_str().unwrap().to_string(), 191 | title: video_json["title"].as_str().unwrap().to_string(), 192 | published, 193 | published_text, 194 | length: Some(length as u32), 195 | watched: false, 196 | members_only: false, 197 | new: true, 198 | } 199 | } 200 | } 201 | 202 | impl ListItem for Video { 203 | fn id(&self) -> &str { 204 | &self.video_id 205 | } 206 | } 207 | 208 | impl Display for Video { 209 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 210 | write!(f, "{}", self.title) 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/stream_formats.rs: -------------------------------------------------------------------------------- 1 | use crate::OPTIONS; 2 | use crate::api::Chapters; 3 | use crate::channel::ListItem; 4 | use crate::{ 5 | api::{Format, VideoInfo}, 6 | app::SelectionList, 7 | }; 8 | use std::fmt::Display; 9 | 10 | #[derive(Default)] 11 | pub struct Formats { 12 | pub title: String, 13 | pub id: String, 14 | pub video_formats: SelectionList, 15 | pub audio_formats: SelectionList, 16 | pub formats: SelectionList, 17 | pub captions: SelectionList, 18 | pub chapters: Option, 19 | pub selected_tab: usize, 20 | pub use_adaptive_streams: bool, 21 | } 22 | 23 | impl Formats { 24 | pub fn new(title: String, id: String, video_info: VideoInfo) -> Self { 25 | let mut formats = Formats { 26 | title, 27 | id, 28 | video_formats: SelectionList::new(video_info.video_formats), 29 | audio_formats: SelectionList::new(video_info.audio_formats), 30 | formats: SelectionList::new(video_info.format_streams), 31 | captions: SelectionList::new(video_info.captions), 32 | chapters: video_info.chapters, 33 | selected_tab: 0, 34 | use_adaptive_streams: OPTIONS.prefer_dash_formats, 35 | }; 36 | 37 | formats.set_preferred(); 38 | 39 | formats 40 | } 41 | 42 | fn set_preferred(&mut self) { 43 | let mut video_idx = None; 44 | 45 | for (idx, format) in self.video_formats.items.iter().enumerate() { 46 | if let Some(preferred_codec) = &OPTIONS.preferred_video_codec { 47 | if OPTIONS.video_quality == format.get_quality() { 48 | video_idx = Some(idx); 49 | } 50 | 51 | if *preferred_codec == format.get_codec() { 52 | match video_idx { 53 | Some(video_idx) if idx == video_idx => break, 54 | None => video_idx = Some(idx), 55 | _ => (), 56 | } 57 | } 58 | } else if OPTIONS.video_quality == format.get_quality() { 59 | video_idx = Some(idx); 60 | break; 61 | } 62 | } 63 | 64 | self.video_formats.items[video_idx.unwrap_or_default()].selected = true; 65 | 66 | let mut audio_idx = None; 67 | 68 | for (idx, format) in self.audio_formats.items.iter().enumerate() { 69 | if matches!(&format.item, Format::Audio { language,.. } if language.as_ref().is_some_and(|(_, is_default)| *is_default)) 70 | { 71 | audio_idx = Some(idx); 72 | 73 | if OPTIONS.preferred_audio_codec.is_none() { 74 | break; 75 | } 76 | } 77 | 78 | if OPTIONS 79 | .preferred_audio_codec 80 | .as_ref() 81 | .is_some_and(|preferred| *preferred == format.get_codec()) 82 | { 83 | match audio_idx { 84 | Some(audio_idx) if idx == audio_idx => break, 85 | None => audio_idx = Some(idx), 86 | _ => (), 87 | } 88 | } 89 | } 90 | 91 | self.audio_formats.items[audio_idx.unwrap_or_default()].selected = true; 92 | 93 | if let Some(item) = self.formats.items.first_mut() { 94 | item.selected = true; 95 | } 96 | 97 | for language in &OPTIONS.subtitle_languages { 98 | if let Some(caption) = self 99 | .captions 100 | .items 101 | .iter_mut() 102 | .find(|caption| caption.item.id() == language) 103 | { 104 | caption.selected = true; 105 | } 106 | } 107 | 108 | for caption in &mut self.captions.items { 109 | if OPTIONS 110 | .subtitle_languages 111 | .iter() 112 | .any(|language| *language == caption.item.id() || matches!(caption.item.id().split_once('-'), Some((lang, _)) if lang == *language)) 113 | { 114 | caption.selected = true; 115 | } 116 | } 117 | } 118 | 119 | pub fn switch_format_type(&mut self) { 120 | self.use_adaptive_streams = !self.use_adaptive_streams; 121 | self.selected_tab = 0; 122 | } 123 | 124 | pub fn get_mut_selected_tab(&mut self) -> &mut SelectionList { 125 | match self.selected_tab { 126 | 0 if self.use_adaptive_streams => &mut self.video_formats, 127 | 0 => &mut self.formats, 128 | 1 => &mut self.audio_formats, 129 | 2 => &mut self.captions, 130 | _ => panic!(), 131 | } 132 | } 133 | 134 | pub fn next_tab(&mut self) { 135 | self.selected_tab = (self.selected_tab + 1) % 3; 136 | 137 | if !self.use_adaptive_streams && self.selected_tab == 1 { 138 | self.next_tab(); 139 | } 140 | 141 | if self.get_mut_selected_tab().items.is_empty() { 142 | self.next_tab(); 143 | } 144 | } 145 | 146 | pub fn previous_tab(&mut self) { 147 | self.selected_tab = if self.selected_tab == 0 { 148 | 2 149 | } else { 150 | self.selected_tab - 1 151 | }; 152 | 153 | if !self.use_adaptive_streams && self.selected_tab == 1 { 154 | self.previous_tab(); 155 | } 156 | 157 | if self.get_mut_selected_tab().items.is_empty() { 158 | self.previous_tab(); 159 | } 160 | } 161 | } 162 | 163 | impl Display for Format { 164 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 165 | match self { 166 | Format::Video { 167 | quality, 168 | fps, 169 | r#type, 170 | .. 171 | } => write!(f, "{quality} @ {fps} fps, {type}"), 172 | Format::Audio { 173 | language, 174 | bitrate, 175 | r#type, 176 | .. 177 | } => write!( 178 | f, 179 | "{}{}, {}", 180 | language 181 | .as_ref() 182 | .map_or(String::new(), |(language, _)| format!("{language}, ")), 183 | bitrate, 184 | r#type 185 | ), 186 | Format::Stream { 187 | quality, 188 | fps, 189 | bitrate, 190 | r#type, 191 | .. 192 | } => write!( 193 | f, 194 | "{} @ {} fps, {}, {}", 195 | quality, 196 | fps, 197 | bitrate.clone().unwrap_or_default(), 198 | r#type 199 | ), 200 | Format::Caption { label, .. } => write!(f, "{label}"), 201 | } 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/protobuf.rs: -------------------------------------------------------------------------------- 1 | use base64::{Engine, prelude::BASE64_URL_SAFE_NO_PAD}; 2 | use itertools::Itertools; 3 | use serde_json::Value; 4 | 5 | #[derive(Debug, PartialEq)] 6 | enum WireType { 7 | /// int32, int64, uint32, uint64, sint32, sint64, bool, enum 8 | Varint, 9 | /// fixed64, sfixed64, double 10 | I64, 11 | /// string, bytes, embedded messages, packed repeated fields 12 | Len, 13 | /// group start (deprecated) 14 | SGroup, 15 | /// group end (deprecated) 16 | EGroup, 17 | /// fixed32, sfixed32, float 18 | I32, 19 | } 20 | 21 | impl TryFrom for WireType { 22 | type Error = anyhow::Error; 23 | 24 | fn try_from(value: u64) -> Result { 25 | let id = match value { 26 | 0 => WireType::Varint, 27 | 1 => WireType::I64, 28 | 2 => WireType::Len, 29 | 3 => WireType::SGroup, 30 | 4 => WireType::EGroup, 31 | 5 => WireType::I32, 32 | _ => return Err(anyhow::anyhow!("invalid wire type id")), 33 | }; 34 | 35 | Ok(id) 36 | } 37 | } 38 | 39 | #[derive(Debug, PartialEq)] 40 | struct Tag { 41 | field_number: u64, 42 | wire_type: WireType, 43 | } 44 | 45 | fn parse_varint>(message: &mut T) -> Option { 46 | const MSB_MASK: u8 = 1 << 7; 47 | 48 | message 49 | .take_while_inclusive(|byte| byte & MSB_MASK != 0) 50 | .enumerate() 51 | .map(|(idx, byte)| u64::from(byte & !MSB_MASK) << (7 * idx)) 52 | .reduce(|acc, byte| acc | byte) 53 | } 54 | 55 | fn parse_tag>(message: &mut T) -> Option { 56 | let varint = parse_varint(message)?; 57 | 58 | Some(Tag { 59 | field_number: varint >> 3, 60 | wire_type: WireType::try_from(varint & 0b111).ok()?, 61 | }) 62 | } 63 | 64 | fn parse_message>(message: &mut T) -> Option { 65 | let mut res = Value::default(); 66 | 67 | while let Some(tag) = parse_tag(message) { 68 | let index = tag.field_number.to_string(); 69 | 70 | match tag.wire_type { 71 | WireType::Varint => res[index] = parse_varint(message)?.into(), 72 | WireType::I64 => { 73 | let bytes = message.take(8).collect::>(); 74 | res[index] = f64::from_le_bytes(bytes.try_into().ok()?).into(); 75 | } 76 | WireType::Len => { 77 | let len = parse_varint(message)?; 78 | let submessage = message.take(len as usize).collect::>(); 79 | 80 | res[index] = if let Some(s) = String::from_utf8(submessage.clone()) 81 | .ok() 82 | .filter(|s| !s.chars().any(char::is_control)) 83 | { 84 | s.into() 85 | } else { 86 | let submessage = parse_message(&mut submessage.into_iter()).unwrap_or_default(); 87 | let mut value = res.get_mut(&index); 88 | 89 | if let Some(value) = &mut value 90 | && value.is_object() 91 | { 92 | Value::Array(vec![value.take(), submessage]) 93 | } else if let Some(value) = value.and_then(Value::as_array_mut) { 94 | value.push(submessage); 95 | continue; 96 | } else { 97 | submessage 98 | } 99 | } 100 | } 101 | WireType::I32 => { 102 | let bytes = message.take(4).collect::>(); 103 | res[index] = f32::from_le_bytes(bytes.try_into().ok()?).into(); 104 | } 105 | _ => return None, 106 | }; 107 | } 108 | 109 | Some(res) 110 | } 111 | 112 | pub fn decode_protobuf_from_binary(message: Vec) -> Option { 113 | parse_message(&mut message.into_iter()) 114 | } 115 | 116 | pub fn decode_protobuf(message: &str) -> Option { 117 | let bytes = BASE64_URL_SAFE_NO_PAD.decode(message).ok()?; 118 | 119 | decode_protobuf_from_binary(bytes) 120 | } 121 | 122 | #[cfg(test)] 123 | mod tests { 124 | use crate::protobuf::{Tag, WireType, decode_protobuf, decode_protobuf_from_binary, parse_tag}; 125 | 126 | #[test] 127 | fn tag() { 128 | let bytes: [u8; 2] = [0xf2, 0x6]; 129 | 130 | assert_eq!( 131 | parse_tag(bytes.into_iter().by_ref()), 132 | Some(Tag { 133 | field_number: 110, 134 | wire_type: WireType::Len 135 | }) 136 | ); 137 | 138 | let bytes = [0x8_u8]; 139 | 140 | assert_eq!( 141 | parse_tag(bytes.into_iter().by_ref()), 142 | Some(Tag { 143 | field_number: 1, 144 | wire_type: WireType::Varint 145 | }) 146 | ); 147 | } 148 | 149 | #[test] 150 | fn a_simple_message() { 151 | let bytes = vec![0x08, 0x96, 0x01]; 152 | 153 | assert_eq!( 154 | decode_protobuf_from_binary(bytes).unwrap()["1"].as_u64(), 155 | Some(150) 156 | ); 157 | } 158 | 159 | #[test] 160 | fn string() { 161 | let bytes = vec![0x12, 0x07, 0x74, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x67]; 162 | 163 | assert_eq!( 164 | decode_protobuf_from_binary(bytes).unwrap()["2"].as_str(), 165 | Some("testing") 166 | ); 167 | } 168 | 169 | #[test] 170 | fn non_varint32() { 171 | let bytes = vec![0x5d, 0x00, 0x00, 0xde, 0x42]; 172 | 173 | assert_eq!( 174 | decode_protobuf_from_binary(bytes).unwrap()["11"].as_f64(), 175 | Some(111.0) 176 | ); 177 | } 178 | 179 | #[test] 180 | fn non_varint64() { 181 | let bytes = vec![0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5c, 0x40]; 182 | 183 | assert_eq!( 184 | decode_protobuf_from_binary(bytes).unwrap()["12"].as_f64(), 185 | Some(112.0) 186 | ); 187 | } 188 | 189 | #[test] 190 | fn submessage() { 191 | let bytes = vec![0x1a, 0x03, 0x08, 0x96, 0x01]; 192 | 193 | assert_eq!( 194 | decode_protobuf_from_binary(bytes), 195 | Some(serde_json::json!({ 196 | "3": { "1": 150 } 197 | })) 198 | ); 199 | } 200 | 201 | #[test] 202 | fn yt_tab() { 203 | let message = "EgZ2aWRlb3PyBgQKAjoA"; 204 | 205 | assert_eq!( 206 | decode_protobuf(message), 207 | Some(serde_json::json!({ 208 | "2": "videos", 209 | "110": { "1": { "7": "" } } 210 | })) 211 | ); 212 | } 213 | 214 | #[test] 215 | fn yt_xtags() { 216 | let message = "ChQKBWFjb250EgtkdWJiZWQtYXV0bwoNCgRsYW5nEgVlbi1VUw"; 217 | 218 | assert_eq!( 219 | decode_protobuf(message), 220 | Some(serde_json::json!({ 221 | "1": [ 222 | {"1": "acont", "2": "dubbed-auto"}, 223 | {"1": "lang", "2": "en-US"}, 224 | ] 225 | })) 226 | ); 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/commands.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, PartialEq, Eq, Clone, Copy)] 2 | pub enum Command { 3 | SetModeSubs, 4 | SetModeLatestVideos, 5 | OnDown, 6 | OnUp, 7 | OnLeft, 8 | OnRight, 9 | SelectFirst, 10 | SelectLast, 11 | NextTab, 12 | PreviousTab, 13 | JumpToChannel, 14 | ToggleHide, 15 | Subscribe, 16 | Unsubscribe, 17 | DeleteVideo, 18 | SearchForward, 19 | SearchBackward, 20 | RepeatLastSearch, 21 | RepeatLastSearchOpposite, 22 | SwitchApi, 23 | RefreshChannel, 24 | RefreshChannels, 25 | RefreshFailedChannels, 26 | LoadMoreVideos, 27 | OpenInInvidious, 28 | OpenInYoutube, 29 | PlayFromFormats, 30 | PlayUsingYtdlp, 31 | SelectFormats, 32 | ToggleWatched, 33 | ToggleHelp, 34 | ToggleTag, 35 | Quit, 36 | } 37 | 38 | impl TryFrom<&str> for Command { 39 | type Error = anyhow::Error; 40 | 41 | fn try_from(command: &str) -> Result { 42 | let command = match command { 43 | "set_mode_subs" => Command::SetModeSubs, 44 | "set_mode_latest_videos" => Command::SetModeLatestVideos, 45 | "on_down" => Command::OnDown, 46 | "on_up" => Command::OnUp, 47 | "on_left" => Command::OnLeft, 48 | "on_right" => Command::OnRight, 49 | "select_first" => Command::SelectFirst, 50 | "select_last" => Command::SelectLast, 51 | "next_tab" => Command::NextTab, 52 | "previous_tab" => Command::PreviousTab, 53 | "jump_to_channel" => Command::JumpToChannel, 54 | "toggle_hide" => Command::ToggleHide, 55 | "subscribe" => Command::Subscribe, 56 | "unsubscribe" => Command::Unsubscribe, 57 | "delete_video" => Command::DeleteVideo, 58 | "search_forward" => Command::SearchForward, 59 | "search_backward" => Command::SearchBackward, 60 | "repeat_last_search" => Command::RepeatLastSearch, 61 | "repeat_last_search_opposite" => Command::RepeatLastSearchOpposite, 62 | "switch_api" => Command::SwitchApi, 63 | "refresh_channel" => Command::RefreshChannel, 64 | "refresh_channels" => Command::RefreshChannels, 65 | "refresh_failed_channels" => Command::RefreshFailedChannels, 66 | "load_more_videos" => Command::LoadMoreVideos, 67 | "open_in_invidious" => Command::OpenInInvidious, 68 | "open_in_youtube" => Command::OpenInYoutube, 69 | "play_from_formats" => Command::PlayFromFormats, 70 | "play_using_ytdlp" => Command::PlayUsingYtdlp, 71 | "select_formats" => Command::SelectFormats, 72 | "toggle_watched" => Command::ToggleWatched, 73 | "toggle_help" => Command::ToggleHelp, 74 | "toggle_tag" => Command::ToggleTag, 75 | "quit" => Command::Quit, 76 | _ => anyhow::bail!("\"{}\" is an invalid command", command), 77 | }; 78 | 79 | Ok(command) 80 | } 81 | } 82 | 83 | #[derive(Debug, PartialEq, Eq, Clone, Copy)] 84 | pub enum ImportCommand { 85 | Import, 86 | ToggleSelection, 87 | SelectAll, 88 | DeselectAll, 89 | } 90 | 91 | impl TryFrom<&str> for ImportCommand { 92 | type Error = anyhow::Error; 93 | 94 | fn try_from(command: &str) -> Result { 95 | let command = match command { 96 | "toggle_selection" => ImportCommand::ToggleSelection, 97 | "select_all" => ImportCommand::SelectAll, 98 | "deselect_all" => ImportCommand::DeselectAll, 99 | "import" => ImportCommand::Import, 100 | _ => anyhow::bail!("\"{}\" is an invalid command", command), 101 | }; 102 | 103 | Ok(command) 104 | } 105 | } 106 | 107 | #[derive(Debug, PartialEq, Eq, Clone, Copy)] 108 | pub enum TagCommand { 109 | CreateTag, 110 | DeleteTag, 111 | RenameTag, 112 | SelectChannels, 113 | ToggleSelection, 114 | SelectAll, 115 | DeselectAll, 116 | Abort, 117 | } 118 | 119 | impl TryFrom<&str> for TagCommand { 120 | type Error = anyhow::Error; 121 | 122 | fn try_from(command: &str) -> Result { 123 | let command = match command { 124 | "toggle_selection" => TagCommand::ToggleSelection, 125 | "select_all" => TagCommand::SelectAll, 126 | "deselect_all" => TagCommand::DeselectAll, 127 | "select_channels" => TagCommand::SelectChannels, 128 | "create_tag" => TagCommand::CreateTag, 129 | "delete_tag" => TagCommand::DeleteTag, 130 | "rename_tag" => TagCommand::RenameTag, 131 | "abort" => TagCommand::Abort, 132 | _ => anyhow::bail!("\"{}\" is an invalid command", command), 133 | }; 134 | 135 | Ok(command) 136 | } 137 | } 138 | 139 | #[derive(Debug, PartialEq, Eq, Clone, Copy)] 140 | pub enum ChannelSelectionCommand { 141 | Confirm, 142 | Abort, 143 | ToggleSelection, 144 | SelectAll, 145 | DeselectAll, 146 | } 147 | 148 | impl TryFrom<&str> for ChannelSelectionCommand { 149 | type Error = anyhow::Error; 150 | 151 | fn try_from(command: &str) -> Result { 152 | let command = match command { 153 | "confirm" => ChannelSelectionCommand::Confirm, 154 | "abort" => ChannelSelectionCommand::Abort, 155 | "toggle_selection" => ChannelSelectionCommand::ToggleSelection, 156 | "select_all" => ChannelSelectionCommand::SelectAll, 157 | "deselect_all" => ChannelSelectionCommand::DeselectAll, 158 | _ => anyhow::bail!("\"{}\" is an invalid command", command), 159 | }; 160 | 161 | Ok(command) 162 | } 163 | } 164 | 165 | #[derive(Debug, PartialEq, Eq, Clone, Copy)] 166 | pub enum FormatSelectionCommand { 167 | PreviousTab, 168 | NextTab, 169 | SwitchFormatType, 170 | Select, 171 | PlayVideo, 172 | Abort, 173 | } 174 | 175 | impl TryFrom<&str> for FormatSelectionCommand { 176 | type Error = anyhow::Error; 177 | 178 | fn try_from(command: &str) -> Result { 179 | let command = match command { 180 | "previous_tab" => FormatSelectionCommand::PreviousTab, 181 | "next_tab" => FormatSelectionCommand::NextTab, 182 | "switch_format_type" => FormatSelectionCommand::SwitchFormatType, 183 | "select" => FormatSelectionCommand::Select, 184 | "play_video" => FormatSelectionCommand::PlayVideo, 185 | "abort" => FormatSelectionCommand::Abort, 186 | _ => anyhow::bail!("\"{}\" is an invalid command", command), 187 | }; 188 | 189 | Ok(command) 190 | } 191 | } 192 | 193 | #[derive(Debug, PartialEq, Eq, Clone, Copy)] 194 | pub enum HelpCommand { 195 | ScrollUp, 196 | ScrollDown, 197 | GoToTop, 198 | GoToBottom, 199 | Abort, 200 | } 201 | 202 | impl TryFrom<&str> for HelpCommand { 203 | type Error = anyhow::Error; 204 | 205 | fn try_from(command: &str) -> Result { 206 | let command = match command { 207 | "scroll_up" => HelpCommand::ScrollUp, 208 | "scroll_down" => HelpCommand::ScrollDown, 209 | "go_to_top" => HelpCommand::GoToTop, 210 | "go_to_bottom" => HelpCommand::GoToBottom, 211 | "abort" => HelpCommand::Abort, 212 | _ => anyhow::bail!("\"{}\" is an invalid command", command), 213 | }; 214 | 215 | Ok(command) 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/config/options.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | CLAP_ARGS, 3 | api::{ApiBackend, VideoFormat}, 4 | app::VideoPlayer, 5 | channel::ChannelTab, 6 | }; 7 | use bitflags::bitflags; 8 | use serde::{Deserialize, de}; 9 | use std::path::PathBuf; 10 | 11 | bitflags! { 12 | pub struct EnabledTabs: u8 { 13 | const VIDEOS = 0b0001; 14 | const SHORTS = 0b0010; 15 | const STREAMS = 0b0100; 16 | } 17 | } 18 | 19 | #[derive(Deserialize)] 20 | pub struct UserOptions { 21 | database: Option, 22 | instances: Option, 23 | #[serde(default, deserialize_with = "deserialize_tabs")] 24 | tabs: Option, 25 | hide_disabled_tabs: Option, 26 | api: Option, 27 | refresh_threshold: Option, 28 | rss_threshold: Option, 29 | tick_rate: Option, 30 | request_timeout: Option, 31 | highlight_symbol: Option, 32 | video_player_for_stream_formats: Option, 33 | #[serde(alias = "video_player")] 34 | mpv_path: Option, 35 | vlc_path: Option, 36 | hide_watched: Option, 37 | hide_members_only: Option, 38 | subtitle_languages: Option>, 39 | prefer_dash_formats: Option, 40 | prefer_original_audio: Option, 41 | #[serde(default, deserialize_with = "deserialize_video_quality")] 42 | video_quality: Option, 43 | preferred_video_codec: Option, 44 | preferred_audio_codec: Option, 45 | chapters: Option, 46 | } 47 | 48 | pub struct Options { 49 | pub database: PathBuf, 50 | pub instances: PathBuf, 51 | pub tabs: EnabledTabs, 52 | pub hide_disabled_tabs: bool, 53 | pub api: ApiBackend, 54 | pub refresh_threshold: u64, 55 | pub rss_threshold: usize, 56 | pub tick_rate: u64, 57 | pub request_timeout: u64, 58 | pub highlight_symbol: String, 59 | pub video_player_for_stream_formats: VideoPlayer, 60 | pub mpv_path: PathBuf, 61 | pub vlc_path: PathBuf, 62 | pub hide_watched: bool, 63 | pub hide_members_only: bool, 64 | pub subtitle_languages: Vec, 65 | pub prefer_dash_formats: bool, 66 | pub prefer_original_audio: bool, 67 | pub video_quality: u16, 68 | pub preferred_video_codec: Option, 69 | pub preferred_audio_codec: Option, 70 | pub chapters: bool, 71 | } 72 | 73 | impl Options { 74 | pub fn override_with_clap_args(&mut self) { 75 | if let Some(database) = CLAP_ARGS.get_one::("database") { 76 | database.clone_into(&mut self.database); 77 | } 78 | 79 | if let Some(instances) = CLAP_ARGS.get_one::("instances") { 80 | instances.clone_into(&mut self.instances); 81 | } 82 | 83 | if let Some(tick_rate) = CLAP_ARGS.get_one::("tick_rate") { 84 | self.tick_rate = *tick_rate; 85 | } 86 | 87 | if let Some(request_timeout) = CLAP_ARGS.get_one::("request_timeout") { 88 | self.request_timeout = *request_timeout; 89 | } 90 | 91 | if let Some(highlight_symbol) = CLAP_ARGS.get_one::("highlight_symbol") { 92 | highlight_symbol.clone_into(&mut self.highlight_symbol); 93 | } 94 | } 95 | } 96 | 97 | impl Default for Options { 98 | fn default() -> Self { 99 | Options { 100 | database: PathBuf::default(), 101 | instances: PathBuf::default(), 102 | tabs: EnabledTabs::VIDEOS, 103 | hide_disabled_tabs: true, 104 | api: ApiBackend::Local, 105 | refresh_threshold: 600, 106 | rss_threshold: 9999, 107 | tick_rate: 10, 108 | request_timeout: 5, 109 | highlight_symbol: String::new(), 110 | video_player_for_stream_formats: VideoPlayer::Mpv, 111 | mpv_path: PathBuf::from("mpv"), 112 | vlc_path: PathBuf::from("vlc"), 113 | hide_watched: false, 114 | hide_members_only: false, 115 | subtitle_languages: Vec::new(), 116 | prefer_dash_formats: true, 117 | prefer_original_audio: true, 118 | video_quality: u16::MAX, 119 | preferred_video_codec: None, 120 | preferred_audio_codec: None, 121 | chapters: true, 122 | } 123 | } 124 | } 125 | 126 | impl From for Options { 127 | fn from(user_options: UserOptions) -> Self { 128 | let mut options = Options::default(); 129 | 130 | macro_rules! set_options_field { 131 | ($name: ident) => { 132 | if let Some(option) = user_options.$name { 133 | options.$name = option; 134 | } 135 | }; 136 | ($name: ident |) => { 137 | options.$name = user_options.$name; 138 | }; 139 | } 140 | 141 | set_options_field!(database); 142 | set_options_field!(instances); 143 | set_options_field!(tabs); 144 | set_options_field!(hide_disabled_tabs); 145 | set_options_field!(api); 146 | set_options_field!(refresh_threshold); 147 | set_options_field!(rss_threshold); 148 | set_options_field!(tick_rate); 149 | set_options_field!(request_timeout); 150 | set_options_field!(highlight_symbol); 151 | set_options_field!(hide_watched); 152 | set_options_field!(hide_members_only); 153 | set_options_field!(video_player_for_stream_formats); 154 | set_options_field!(mpv_path); 155 | set_options_field!(vlc_path); 156 | set_options_field!(subtitle_languages); 157 | set_options_field!(prefer_dash_formats); 158 | set_options_field!(prefer_original_audio); 159 | set_options_field!(video_quality); 160 | set_options_field!(preferred_video_codec |); 161 | set_options_field!(preferred_audio_codec |); 162 | set_options_field!(chapters); 163 | 164 | options 165 | } 166 | } 167 | 168 | fn deserialize_tabs<'de, D>(deserializer: D) -> Result, D::Error> 169 | where 170 | D: de::Deserializer<'de>, 171 | { 172 | let Some(tabs): Option> = de::Deserialize::deserialize(deserializer)? else { 173 | return Ok(None); 174 | }; 175 | 176 | let mut enabled = EnabledTabs::empty(); 177 | 178 | if tabs.contains(&ChannelTab::Videos) { 179 | enabled.insert(EnabledTabs::VIDEOS); 180 | } 181 | 182 | if tabs.contains(&ChannelTab::Shorts) { 183 | enabled.insert(EnabledTabs::SHORTS); 184 | } 185 | 186 | if tabs.contains(&ChannelTab::Streams) { 187 | enabled.insert(EnabledTabs::STREAMS); 188 | } 189 | 190 | Ok(Some(enabled)) 191 | } 192 | 193 | fn deserialize_video_quality<'de, D>(deserializer: D) -> Result, D::Error> 194 | where 195 | D: de::Deserializer<'de>, 196 | { 197 | use serde::de::Error; 198 | 199 | let Some(quality_str): Option = de::Deserialize::deserialize(deserializer)? else { 200 | return Ok(None); 201 | }; 202 | 203 | Ok(Some(if quality_str.to_lowercase() == "best" { 204 | u16::MAX 205 | } else if let Some(Ok(quality)) = quality_str.strip_suffix('p').map(str::parse::) { 206 | quality 207 | } else if let Ok(quality) = quality_str.parse::() { 208 | quality 209 | } else { 210 | return Err(Error::custom(format!( 211 | "\"{quality_str}\" is not a valid quality" 212 | ))); 213 | })) 214 | } 215 | -------------------------------------------------------------------------------- /src/help.rs: -------------------------------------------------------------------------------- 1 | use crate::KEY_BINDINGS; 2 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 3 | use std::ops::{Deref, DerefMut}; 4 | 5 | const DESCRIPTIONS_LEN: usize = 33; 6 | const DESCRIPTIONS: [&str; DESCRIPTIONS_LEN] = [ 7 | "Switch to subscriptions mode", 8 | "Switch to latest videos mode", 9 | "Go one line downward", 10 | "Go one line upward", 11 | "Switch to channels block", 12 | "Switch to videos block", 13 | "Jump to the first line", 14 | "Jump to the last line", 15 | "Select next tab", 16 | "Select previous tab", 17 | "Jump to the channel of the selected video from latest videos mode", 18 | "Hide/unhide watched videos", 19 | "Subscribe", 20 | "Unsubscribe", 21 | "Delete the selected video from database", 22 | "Search Forward", 23 | "Search backward", 24 | "Repeat last search", 25 | "Repeat last search in the opposite direction", 26 | "Switch API", 27 | "Refresh videos of the selected channel", 28 | "Refresh videos of every channel", 29 | "Refresh videos of channels which their latest refresh was a failure", 30 | "Load more videos", 31 | "Open channel or video Invidious page in browser", 32 | "Open channel or video Youtube page in browser", 33 | "Play video in video player using stream formats", 34 | "Play video in mpv using yt-dlp", 35 | "Toggle format selection window", 36 | "Mark/unmark video as watched", 37 | "Toggle help window", 38 | "Toggle tag selection window", 39 | "Quit application", 40 | ]; 41 | 42 | const IMPORT_DESCRIPTIONS_LEN: usize = 4; 43 | const IMPORT_DESCRIPTIONS: [&str; IMPORT_DESCRIPTIONS_LEN] = [ 44 | " - Import, ", 45 | " - Toggle, ", 46 | " - Select all, ", 47 | " - Deselect all", 48 | ]; 49 | 50 | const TAG_DESCRIPTIONS_LEN: usize = 8; 51 | const TAG_DESCRIPTIONS: [&str; TAG_DESCRIPTIONS_LEN] = [ 52 | " - Create tag, ", 53 | " - Delete tag, ", 54 | " - Rename tag, ", 55 | " - Pick channels, ", 56 | " - Toggle, ", 57 | " - Select all, ", 58 | " - Deselect all, ", 59 | " - Abort", 60 | ]; 61 | 62 | const CHANNEL_SELECTION_DESCRIPTIONS_LEN: usize = 5; 63 | const CHANNEL_SELECTION_DESCRIPTIONS: [&str; CHANNEL_SELECTION_DESCRIPTIONS_LEN] = [ 64 | " - Confirm, ", 65 | " - Abort, ", 66 | " - Toggle, ", 67 | " - Select all, ", 68 | " - Deselect all", 69 | ]; 70 | 71 | const FORMAT_SELECTION_DESCRIPTIONS_LEN: usize = 6; 72 | const FORMAT_SELECTION_DESCRIPTIONS: [&str; FORMAT_SELECTION_DESCRIPTIONS_LEN] = [ 73 | " - Previous tab, ", 74 | " - Next tab, ", 75 | " - Switch format, ", 76 | " - Select, ", 77 | " - Play video, ", 78 | " - Abort", 79 | ]; 80 | 81 | pub struct HelpWindowState { 82 | pub show: bool, 83 | pub scroll: u16, 84 | pub max_scroll: u16, 85 | } 86 | 87 | impl HelpWindowState { 88 | pub fn new() -> Self { 89 | Self { 90 | show: false, 91 | scroll: 0, 92 | max_scroll: 0, 93 | } 94 | } 95 | 96 | pub fn toggle(&mut self) { 97 | self.show = !self.show; 98 | } 99 | 100 | pub fn scroll_up(&mut self) { 101 | self.scroll = self.scroll.saturating_sub(1); 102 | } 103 | 104 | pub fn scroll_down(&mut self) { 105 | self.scroll = std::cmp::min(self.scroll + 1, self.max_scroll); 106 | } 107 | 108 | pub fn scroll_top(&mut self) { 109 | self.scroll = 0; 110 | } 111 | 112 | pub fn scroll_bottom(&mut self) { 113 | self.scroll = self.max_scroll; 114 | } 115 | } 116 | 117 | const HELP_ENTRY: (String, &str) = (String::new(), ""); 118 | 119 | pub struct Help<'a> { 120 | pub general: [(String, &'a str); DESCRIPTIONS_LEN], 121 | pub import: [(String, &'a str); IMPORT_DESCRIPTIONS_LEN], 122 | pub tag: [(String, &'a str); TAG_DESCRIPTIONS_LEN], 123 | pub channel_selection: [(String, &'a str); CHANNEL_SELECTION_DESCRIPTIONS_LEN], 124 | pub format_selection: [(String, &'a str); FORMAT_SELECTION_DESCRIPTIONS_LEN], 125 | } 126 | 127 | impl Default for Help<'_> { 128 | fn default() -> Self { 129 | Self::new() 130 | } 131 | } 132 | 133 | impl Help<'_> { 134 | pub fn new() -> Self { 135 | let mut help = Self { 136 | general: [HELP_ENTRY; DESCRIPTIONS_LEN], 137 | import: [HELP_ENTRY; IMPORT_DESCRIPTIONS_LEN], 138 | tag: [HELP_ENTRY; TAG_DESCRIPTIONS_LEN], 139 | channel_selection: [HELP_ENTRY; CHANNEL_SELECTION_DESCRIPTIONS_LEN], 140 | format_selection: [HELP_ENTRY; FORMAT_SELECTION_DESCRIPTIONS_LEN], 141 | }; 142 | 143 | macro_rules! generate_entries { 144 | ($entries: expr, $bindings: expr, $descriptions: ident) => { 145 | for (key, command) in &$bindings { 146 | let idx = *command as usize; 147 | 148 | if !$entries[idx].0.is_empty() { 149 | $entries[idx].0.push_str(", "); 150 | } 151 | $entries[idx].0.push_str(&key_event_to_string(key)); 152 | } 153 | 154 | for (idx, (_, desc)) in $entries.iter_mut().enumerate() { 155 | *desc = $descriptions[idx]; 156 | } 157 | }; 158 | } 159 | 160 | generate_entries!(help.general, KEY_BINDINGS.general, DESCRIPTIONS); 161 | generate_entries!(help.import, KEY_BINDINGS.import, IMPORT_DESCRIPTIONS); 162 | generate_entries!(help.tag, KEY_BINDINGS.tag, TAG_DESCRIPTIONS); 163 | generate_entries!( 164 | help.channel_selection, 165 | KEY_BINDINGS.channel_selection, 166 | CHANNEL_SELECTION_DESCRIPTIONS 167 | ); 168 | generate_entries!( 169 | help.format_selection, 170 | KEY_BINDINGS.format_selection, 171 | FORMAT_SELECTION_DESCRIPTIONS 172 | ); 173 | 174 | for (keys, _) in &mut help.general { 175 | *keys = format!("{keys:10} "); 176 | } 177 | 178 | help 179 | } 180 | } 181 | 182 | impl<'a> Deref for Help<'a> { 183 | type Target = [(String, &'a str); DESCRIPTIONS_LEN]; 184 | 185 | fn deref(&self) -> &Self::Target { 186 | &self.general 187 | } 188 | } 189 | 190 | impl DerefMut for Help<'_> { 191 | fn deref_mut(&mut self) -> &mut Self::Target { 192 | &mut self.general 193 | } 194 | } 195 | 196 | fn key_event_to_string(key_event: &KeyEvent) -> String { 197 | let char; 198 | let key_code = match key_event.code { 199 | KeyCode::Backspace => "backspace", 200 | KeyCode::Enter => "enter", 201 | KeyCode::Left => "left", 202 | KeyCode::Right => "right", 203 | KeyCode::Up => "up", 204 | KeyCode::Down => "down", 205 | KeyCode::Home => "home", 206 | KeyCode::End => "end", 207 | KeyCode::PageUp => "pageup", 208 | KeyCode::PageDown => "pagedown", 209 | KeyCode::Tab => "tab", 210 | KeyCode::BackTab => "backtab", 211 | KeyCode::Delete => "delete", 212 | KeyCode::Insert => "insert", 213 | KeyCode::Char(' ') => "space", 214 | KeyCode::Char(c) => { 215 | char = c.to_string(); 216 | &char 217 | } 218 | KeyCode::Esc => "esc", 219 | _ => "", 220 | }; 221 | 222 | let mut modifiers = Vec::with_capacity(3); 223 | 224 | if key_event.modifiers.intersects(KeyModifiers::CONTROL) { 225 | modifiers.push("ctrl"); 226 | } 227 | 228 | if key_event.modifiers.intersects(KeyModifiers::SHIFT) { 229 | modifiers.push("shift"); 230 | } 231 | 232 | if key_event.modifiers.intersects(KeyModifiers::ALT) { 233 | modifiers.push("alt"); 234 | } 235 | 236 | let mut key = modifiers.join("-"); 237 | 238 | if !key.is_empty() { 239 | key.push('-'); 240 | } 241 | key.push_str(key_code); 242 | 243 | key 244 | } 245 | -------------------------------------------------------------------------------- /src/api/invidious.rs: -------------------------------------------------------------------------------- 1 | use super::{Api, ApiBackend, Chapters, Format, VideoInfo}; 2 | use crate::OPTIONS; 3 | use crate::api::{ChannelFeed, ChannelTab}; 4 | use crate::channel::Video; 5 | use crate::stream_formats::Formats; 6 | use anyhow::Result; 7 | use async_trait::async_trait; 8 | use rand::prelude::*; 9 | use reqwest::Client; 10 | use serde_json::Value; 11 | use std::collections::HashSet; 12 | use std::time::Duration; 13 | 14 | const API_BACKEND: ApiBackend = ApiBackend::Invidious; 15 | 16 | fn extract_tab(videos_array: &Value) -> Option> { 17 | videos_array 18 | .as_array() 19 | .map(|array| array.iter().map(Video::from).collect()) 20 | } 21 | 22 | #[derive(Clone)] 23 | pub struct Instance { 24 | pub domain: String, 25 | client: Client, 26 | continuation: Option, 27 | } 28 | 29 | impl Instance { 30 | pub fn new(invidious_instances: &[String]) -> Self { 31 | let mut rng = rand::rng(); 32 | let domain = 33 | invidious_instances[rng.random_range(0..invidious_instances.len())].to_string(); 34 | let client = Client::builder() 35 | .timeout(Duration::from_secs(OPTIONS.request_timeout)) 36 | .build() 37 | .unwrap(); 38 | 39 | Self { 40 | domain, 41 | client, 42 | continuation: None, 43 | } 44 | } 45 | 46 | async fn get_tab_of_channel(&self, channel_id: &str, tab: ChannelTab) -> Result { 47 | let url = format!("{}/api/v1/channels/{}/{}", self.domain, channel_id, tab); 48 | 49 | let response = self.client.get(&url).send().await?; 50 | let mut value = response.error_for_status()?.json::().await?; 51 | 52 | Ok(value["videos"].take()) 53 | } 54 | 55 | async fn get_more_videos_helper( 56 | &mut self, 57 | channel_id: &str, 58 | tab: ChannelTab, 59 | ) -> Result> { 60 | let url = format!("{}/api/v1/channels/{}/{}", self.domain, channel_id, tab); 61 | let mut request = self.client.get(&url); 62 | 63 | if let Some(token) = &self.continuation { 64 | request = request.query(&[("continuation", token)]); 65 | } 66 | 67 | let response = request.send().await?; 68 | let value = response.error_for_status()?.json::().await?; 69 | 70 | self.continuation = value 71 | .get("continuation") 72 | .and_then(Value::as_str) 73 | .map(ToString::to_string); 74 | 75 | Ok(extract_tab(&value["videos"]).unwrap_or_default()) 76 | } 77 | } 78 | 79 | #[async_trait] 80 | impl Api for Instance { 81 | async fn resolve_url(&self, channel_url: &str) -> Result { 82 | let url = format!("{}/api/v1/resolveurl", self.domain); 83 | let response = self 84 | .client 85 | .get(&url) 86 | .query(&[("url", channel_url)]) 87 | .send() 88 | .await?; 89 | 90 | let value: Value = response.error_for_status()?.json().await?; 91 | 92 | Ok(value["ucid"].as_str().unwrap().to_string()) 93 | } 94 | 95 | async fn get_videos_of_channel(&mut self, channel_id: &str) -> Result { 96 | let mut channel_feed = ChannelFeed::new(channel_id); 97 | 98 | for tab in OPTIONS.tabs.iter().map(|tab| tab.bits().into()) { 99 | let videos_array = self.get_tab_of_channel(channel_id, tab).await?; 100 | 101 | if let Some(videos) = extract_tab(&videos_array) { 102 | *channel_feed.get_mut_videos(tab) = videos; 103 | } 104 | } 105 | 106 | Ok(channel_feed) 107 | } 108 | 109 | async fn get_videos_for_the_first_time(&mut self, channel_id: &str) -> Result { 110 | let mut channel_feed = ChannelFeed::new(channel_id); 111 | 112 | for tab in OPTIONS.tabs.iter().map(|tab| tab.bits().into()) { 113 | let videos_array = self.get_tab_of_channel(channel_id, tab).await?; 114 | 115 | if channel_feed.channel_title.is_none() 116 | && let Some(video) = videos_array.get(0) 117 | { 118 | channel_feed.channel_title = video["author"].as_str().map(ToString::to_string); 119 | } 120 | 121 | if let Some(videos) = extract_tab(&videos_array) { 122 | *channel_feed.get_mut_videos(tab) = videos; 123 | } 124 | } 125 | 126 | Ok(channel_feed) 127 | } 128 | 129 | async fn get_rss_feed_of_channel(&self, channel_id: &str) -> Result { 130 | let url = format!("{}/feed/channel/{}", self.domain, channel_id); 131 | let response = self.client.get(&url).send().await?.error_for_status()?; 132 | 133 | Ok(quick_xml::de::from_str(&response.text().await?)?) 134 | } 135 | 136 | async fn get_more_videos( 137 | &mut self, 138 | channel_id: &str, 139 | tab: ChannelTab, 140 | present_videos: HashSet, 141 | ) -> Result { 142 | let mut feed = ChannelFeed::new(channel_id); 143 | let videos = self.get_more_videos_helper(channel_id, tab).await?; 144 | 145 | match tab { 146 | ChannelTab::Videos => feed.videos = videos, 147 | ChannelTab::Shorts => feed.shorts = videos, 148 | ChannelTab::Streams => feed.live_streams = videos, 149 | } 150 | 151 | let new_video_present = |videos: &[Video]| { 152 | !videos 153 | .iter() 154 | .all(|video| present_videos.contains(&video.video_id)) 155 | }; 156 | 157 | if new_video_present(&feed.videos) { 158 | return Ok(feed); 159 | } 160 | 161 | while self.continuation.is_some() 162 | && let Ok(videos) = self.get_more_videos_helper(channel_id, tab).await 163 | { 164 | let new = new_video_present(&videos); 165 | feed.extend_videos(videos, tab); 166 | 167 | if new { 168 | return Ok(feed); 169 | } 170 | } 171 | 172 | Ok(ChannelFeed::default()) 173 | } 174 | 175 | async fn get_video_formats(&self, video_id: &str) -> Result { 176 | let url = format!("{}/api/v1/videos/{}", self.domain, video_id); 177 | let response = self.client.get(&url).send().await?; 178 | let value = match response.error_for_status() { 179 | Ok(response) => response.json::().await?, 180 | Err(_e) => { 181 | anyhow::bail!(format!("Stream formats are not available: ",)); 182 | } 183 | }; 184 | 185 | let mut format_streams: Vec = value["formatStreams"] 186 | .as_array() 187 | .unwrap() 188 | .iter() 189 | .map(|format| Format::from_stream(format, API_BACKEND)) 190 | .collect(); 191 | 192 | let adaptive_formats = value["adaptiveFormats"].as_array().unwrap(); 193 | 194 | let mut video_formats = Vec::new(); 195 | let mut audio_formats = Vec::new(); 196 | 197 | for format in adaptive_formats { 198 | if format.get("qualityLabel").is_some() { 199 | video_formats.push(Format::from_video(format, API_BACKEND)); 200 | } else if format.get("audioQuality").is_some() { 201 | audio_formats.push(Format::from_audio(format, API_BACKEND)); 202 | } 203 | } 204 | 205 | format_streams.reverse(); 206 | video_formats.reverse(); 207 | 208 | let captions = value["captions"] 209 | .as_array() 210 | .unwrap() 211 | .iter() 212 | .filter_map(|caption| Format::from_caption(caption, API_BACKEND)) 213 | .collect(); 214 | 215 | let chapters = OPTIONS 216 | .chapters 217 | .then(|| Chapters::try_from(value["description"].as_str()).ok()) 218 | .flatten(); 219 | 220 | Ok(VideoInfo::new( 221 | video_formats, 222 | audio_formats, 223 | format_streams, 224 | captions, 225 | chapters, 226 | )) 227 | } 228 | 229 | async fn get_caption_paths(&self, formats: &Formats) -> Vec { 230 | formats 231 | .captions 232 | .get_selected_items() 233 | .iter() 234 | .map(|caption| format!("{}{}", self.domain, caption.get_url())) 235 | .collect() 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Result, bail}; 2 | use serde_json::Value; 3 | use std::collections::HashMap; 4 | use std::fs::File; 5 | use std::io::{BufRead, BufReader, Write}; 6 | use std::path::PathBuf; 7 | use std::time::{SystemTime, UNIX_EPOCH}; 8 | use url::Url; 9 | 10 | use crate::CONFIG; 11 | 12 | const PACKAGE_NAME: &str = env!("CARGO_PKG_NAME"); 13 | const INSTANCES_FILE: &str = "instances"; 14 | const DATABASE_FILE: &str = "videos.db"; 15 | 16 | pub fn get_config_dir() -> Result { 17 | let path = match dirs::config_dir() { 18 | Some(path) => path.join(PACKAGE_NAME), 19 | None => bail!("Couldn't find config directory"), 20 | }; 21 | 22 | Ok(path) 23 | } 24 | 25 | pub fn get_data_dir() -> Result { 26 | let path = match dirs::data_local_dir() { 27 | Some(path) => path.join(PACKAGE_NAME), 28 | None => bail!("Couldn't find local data directory"), 29 | }; 30 | 31 | if !path.exists() { 32 | std::fs::create_dir_all(&path)?; 33 | } 34 | 35 | Ok(path) 36 | } 37 | 38 | pub fn get_cache_dir() -> Result { 39 | let path = match dirs::cache_dir() { 40 | Some(path) => path.join(PACKAGE_NAME), 41 | None => bail!("Couldn't find cache directory"), 42 | }; 43 | 44 | if !path.exists() { 45 | std::fs::create_dir_all(&path)?; 46 | } 47 | 48 | Ok(path) 49 | } 50 | 51 | fn hyperlink(text: &str, link: &str) -> String { 52 | format!("\x1b]8;;{link}\x1b\\{text}\x1b]8;;\x1b\\") 53 | } 54 | 55 | pub async fn fetch_invidious_instances() -> Result> { 56 | const REQUEST_URL: &str = "https://api.invidious.io/instances.json"; 57 | const ONION: &str = "onion"; 58 | let instances: Value = reqwest::get(REQUEST_URL).await?.json().await?; 59 | Ok(instances 60 | .as_array() 61 | .unwrap() 62 | .iter() 63 | .map(|arr| arr.as_array().unwrap()) 64 | .filter(|instance| { 65 | let instance = &instance[1]; 66 | instance["type"].as_str().unwrap() != ONION 67 | && instance["api"].as_bool().unwrap_or(false) 68 | }) 69 | .map(|instance| instance[1]["uri"].as_str().unwrap().to_string()) 70 | .collect()) 71 | } 72 | 73 | pub fn get_default_instances_file() -> Result { 74 | Ok(get_config_dir()?.join(INSTANCES_FILE)) 75 | } 76 | 77 | pub async fn generate_instances_file() -> Result<()> { 78 | let instances = fetch_invidious_instances().await?; 79 | let instances_file_path = &CONFIG.options.instances; 80 | let instances_dir = instances_file_path.parent().unwrap(); 81 | 82 | if !instances_dir.exists() { 83 | std::fs::create_dir_all(instances_dir)?; 84 | } 85 | 86 | anyhow::ensure!( 87 | !instances.is_empty(), 88 | format!( 89 | "No suitable instance available on {}", 90 | hyperlink("api.invidious.io", "https://api.invidious.io/") 91 | ) 92 | ); 93 | 94 | let mut file = File::create(instances_file_path.as_path())?; 95 | println!( 96 | "Generated \"{}\" with the following instances:", 97 | instances_file_path.display() 98 | ); 99 | for instance in instances { 100 | writeln!(file, "{instance}")?; 101 | println!("{instance}"); 102 | } 103 | Ok(()) 104 | } 105 | 106 | pub fn read_instances() -> Result> { 107 | let file = File::open(&CONFIG.options.instances)?; 108 | let mut instances = Vec::new(); 109 | for instance in BufReader::new(file).lines() { 110 | instances.push(instance?); 111 | } 112 | Ok(instances) 113 | } 114 | 115 | pub fn get_default_database_file() -> Result { 116 | Ok(get_data_dir()?.join(DATABASE_FILE)) 117 | } 118 | 119 | pub fn length_as_seconds(length: &str) -> u32 { 120 | let mut total = 0; 121 | 122 | for t in length.split(':') { 123 | total *= 60; 124 | total += t.parse::().unwrap(); 125 | } 126 | 127 | total 128 | } 129 | 130 | pub fn params_from_url(url: &str) -> Result> { 131 | let parsed_url = Url::parse(url)?; 132 | 133 | Ok(parsed_url 134 | .query_pairs() 135 | .map(|(k, v)| (k.to_string(), v.to_string())) 136 | .collect()) 137 | } 138 | 139 | pub fn length_as_hhmmss(length: u32) -> String { 140 | let seconds = length % 60; 141 | let minutes = (length / 60) % 60; 142 | let hours = (length / 60) / 60; 143 | match (hours, minutes, seconds) { 144 | (0, 0, _) => format!("0:{seconds:02}"), 145 | (0, _, _) => format!("{minutes}:{seconds:02}"), 146 | _ => format!("{hours}:{minutes:02}:{seconds:02}"), 147 | } 148 | } 149 | 150 | const MINUTE: u64 = 60; 151 | const HOUR: u64 = 3600; 152 | const DAY: u64 = 86400; 153 | const WEEK: u64 = 604800; 154 | const MONTH: u64 = 2592000; 155 | const YEAR: u64 = 31536000; 156 | 157 | pub fn published(published_text: &str) -> Result { 158 | let (num, time_frame) = { 159 | let v: Vec<&str> = published_text.splitn(2, ' ').collect(); 160 | 161 | match v[0].parse::() { 162 | Ok(num) => (num, v[1]), 163 | _ => ( 164 | v[0].trim_end_matches(char::is_alphabetic).parse()?, 165 | v[0].trim_start_matches(char::is_numeric), 166 | ), 167 | } 168 | }; 169 | 170 | let from_now = if time_frame.starts_with('s') { 171 | num 172 | } else if time_frame.starts_with("mi") { 173 | num * MINUTE 174 | } else if time_frame.starts_with('h') { 175 | num * HOUR 176 | } else if time_frame.starts_with('d') { 177 | num * DAY 178 | } else if time_frame.starts_with('w') { 179 | num * WEEK 180 | } else if time_frame.starts_with("mo") { 181 | num * MONTH 182 | } else if time_frame.starts_with('y') { 183 | num * YEAR 184 | } else { 185 | return Err(anyhow::anyhow!("Not a valid published text")); 186 | }; 187 | 188 | Ok(now()?.saturating_sub(from_now)) 189 | } 190 | 191 | pub fn published_text(published: u64) -> Result { 192 | let now = now()?; 193 | let time_diff = now.abs_diff(published); 194 | let (num, mut time_frame) = if time_diff < MINUTE { 195 | (time_diff, "second".to_string()) 196 | } else if time_diff < HOUR { 197 | (time_diff / MINUTE, "minute".to_string()) 198 | } else if time_diff < DAY { 199 | (time_diff / HOUR, "hour".to_string()) 200 | } else if time_diff < WEEK * 2 { 201 | (time_diff / DAY, "day".to_string()) 202 | } else if time_diff < MONTH { 203 | (time_diff / WEEK, "week".to_string()) 204 | } else if time_diff < YEAR { 205 | (time_diff / MONTH, "month".to_string()) 206 | } else { 207 | (time_diff / YEAR, "year".to_string()) 208 | }; 209 | if num > 1 { 210 | time_frame.push('s'); 211 | } 212 | Ok(if published > now { 213 | format!("Premieres in {num} {time_frame}") 214 | } else { 215 | format!("Shared {num} {time_frame} ago") 216 | }) 217 | } 218 | 219 | pub fn now() -> Result { 220 | Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs()) 221 | } 222 | 223 | pub fn time_passed(time: u64) -> Result { 224 | Ok(now()?.saturating_sub(time)) 225 | } 226 | 227 | #[cfg(test)] 228 | mod tests { 229 | 230 | use super::{ 231 | length_as_hhmmss, length_as_seconds, now, params_from_url, published, published_text, 232 | }; 233 | 234 | #[test] 235 | fn extract_params() { 236 | let url = "https://example.com/products?page=2&sort=desc"; 237 | let params = params_from_url(url).ok(); 238 | 239 | assert_eq!( 240 | params 241 | .as_ref() 242 | .and_then(|hm| hm.get("page").map(ToOwned::to_owned)), 243 | Some(String::from("2")) 244 | ); 245 | assert_eq!( 246 | params.and_then(|hm| hm.get("sort").map(ToOwned::to_owned)), 247 | Some(String::from("desc")) 248 | ); 249 | } 250 | 251 | #[test] 252 | fn length_conversion() { 253 | const SECONDS: u32 = 5409; 254 | const TEXT: &str = "1:30:09"; 255 | 256 | assert_eq!(length_as_hhmmss(SECONDS), TEXT); 257 | assert_eq!(length_as_seconds(TEXT), SECONDS); 258 | } 259 | 260 | #[test] 261 | fn published_conversion() { 262 | const TEXT: &str = "5 days ago"; 263 | let time = now().unwrap().saturating_sub(432000); 264 | 265 | assert_eq!(published(TEXT).unwrap(), time); 266 | assert_eq!(published_text(time).unwrap(), "Shared ".to_owned() + TEXT); 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /src/config/theme.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use ratatui::style::{Color, Modifier, Style}; 3 | use serde::Deserialize; 4 | 5 | #[derive(Deserialize)] 6 | struct UserStyle { 7 | fg: Option, 8 | bg: Option, 9 | modifiers: Option, 10 | } 11 | 12 | impl UserStyle { 13 | fn to_style(&self) -> Result