├── .envrc ├── .gitattributes ├── src ├── flixhq │ ├── mod.rs │ ├── flixhq.rs │ └── html.rs ├── utils │ ├── players │ │ ├── mod.rs │ │ ├── celluloid.rs │ │ ├── vlc.rs │ │ ├── iina.rs │ │ └── mpv.rs │ ├── mod.rs │ ├── image_preview.rs │ ├── presence.rs │ ├── ffmpeg.rs │ ├── fzf.rs │ ├── config.rs │ ├── rofi.rs │ └── history.rs ├── providers │ ├── mod.rs │ └── vidcloud.rs ├── cli.rs └── main.rs ├── flake.lock ├── default.nix ├── flake.nix ├── .gitignore ├── Cargo.toml ├── DISCLAIMER.md ├── install ├── .github └── workflows │ └── build.yaml ├── README.md └── LICENSE /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.sh text eol=lf 2 | install text eol=lf 3 | 4 | -------------------------------------------------------------------------------- /src/flixhq/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod flixhq; 2 | pub(super) mod html; 3 | -------------------------------------------------------------------------------- /src/utils/players/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod mpv; 2 | pub mod vlc; 3 | pub mod iina; 4 | pub mod celluloid; 5 | -------------------------------------------------------------------------------- /src/providers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod vidcloud; 2 | 3 | pub trait VideoExtractor { 4 | async fn extract(&mut self, video_url: &str) -> anyhow::Result<()>; 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | pub mod ffmpeg; 3 | pub mod fzf; 4 | pub mod history; 5 | pub mod image_preview; 6 | pub mod players; 7 | pub mod rofi; 8 | pub mod presence; 9 | 10 | #[derive(thiserror::Error, Debug)] 11 | pub enum SpawnError { 12 | #[error("Failed to spawn process: {0}")] 13 | IOError(std::io::Error), 14 | } 15 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1737885589, 6 | "narHash": "sha256-Zf0hSrtzaM1DEz8//+Xs51k/wdSajticVrATqDrfQjg=", 7 | "owner": "nixos", 8 | "repo": "nixpkgs", 9 | "rev": "852ff1d9e153d8875a83602e03fdef8a63f0ecf8", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "nixos", 14 | "ref": "nixos-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs" 22 | } 23 | } 24 | }, 25 | "root": "root", 26 | "version": 7 27 | } 28 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { rustPlatform 2 | , lib 3 | , openssl 4 | , pkg-config 5 | , 6 | }: 7 | let manifest = (lib.importTOML ./Cargo.toml).package; 8 | in 9 | rustPlatform.buildRustPackage { 10 | pname = manifest.name; 11 | version = manifest.version; 12 | cargoLock.lockFile = ./Cargo.lock; 13 | src = lib.cleanSource ./.; 14 | 15 | nativeBuildInputs = [ pkg-config ]; 16 | buildInputs = [ openssl ]; 17 | 18 | doCheck = false; 19 | 20 | meta = { 21 | description = "A CLI tool to watch movies and TV shows"; 22 | homepage = "https://github.com/eatmynerds/lobster-rs"; 23 | license = lib.licenses.mit; 24 | mainProgram = "lobster-rs"; 25 | platforms = lib.platforms.unix; 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "A CLI tool to watch movies and TV shows"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 6 | }; 7 | 8 | outputs = { self, nixpkgs }: 9 | let 10 | supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; 11 | eachSystem = nixpkgs.lib.genAttrs supportedSystems; 12 | pkgsFor = eachSystem (system: 13 | import nixpkgs { 14 | config = { }; 15 | localSystem = system; 16 | overlays = [ ]; 17 | }); 18 | in 19 | { 20 | packages = eachSystem (system: { 21 | lobster-rs = pkgsFor.${system}.callPackage ./default.nix { }; 22 | default = self.packages.${system}.lobster-rs; 23 | }); 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | Cargo.lock 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # MSVC Windows builds of rustc generate these, which store debugging information 14 | *.pdb 15 | *.mkv 16 | 17 | # RustRover 18 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 19 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 20 | # and can be added to the global gitignore or merged into this file. For a more nuclear 21 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 22 | #.idea/ 23 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lobster-rs" 3 | version = "0.1.5" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | anyhow = "1.0.95" 8 | clap = { version = "4.5.23", features = ["derive"] } 9 | crossterm = "0.28.1" 10 | ctrlc = "3.4.5" 11 | dirs = "5.0.1" 12 | discord-rich-presence = "0.2.5" 13 | futures = "0.3.31" 14 | image = "0.25.5" 15 | lazy_static = "1.5.0" 16 | log = "0.4.22" 17 | regex = "1.11.1" 18 | reqwest = "0.12.9" 19 | rich-logger = { version = "0.1.16", features = [ "pretty_json"] } 20 | self_update = { version = "0.41.0", features = ["archive-tar", "archive-zip", "compression-flate2", "compression-zip-deflate"] } 21 | serde = { version = "1.0.216", features = ["derive"] } 22 | serde_json = "1.0.134" 23 | thiserror = "2.0.9" 24 | tokio = { version = "1.42.0", features = ["full"] } 25 | toml = "0.8.19" 26 | visdom = "1.0.2" 27 | 28 | [package.metadata.cross.build] 29 | pre-build = [ 30 | "dpkg --add-architecture $CROSS_DEB_ARCH", 31 | "apt-get update && apt-get --assume-yes install libssl-dev:$CROSS_DEB_ARCH" 32 | ] 33 | -------------------------------------------------------------------------------- /DISCLAIMER.md: -------------------------------------------------------------------------------- 1 |

Disclaimer

2 | 3 |
4 | 5 |

This project: lobster

6 | 7 |
8 | 9 | The core aim of this project is to co-relate automation and efficiency to extract what is provided to an user on the internet. Every content available in the project is hosted by external non-affiliated sources. 10 | 11 |
12 | 13 | Any content served through this project is publicly accessible. If your site is listed in this project, the code is pretty much public. Take necessary measures to counter the exploits used to extract content in your site. 14 | 15 | Think of this project as your normal browser, but a bit more straight-forward and specific. While an average browser makes hundreds of requests to get everything from a site, this project goes on to make requests associated with only getting the content served by the sites. 16 | 17 | 18 | 19 | This project is to be used at the user's own risk, based on their government and laws. 20 | 21 | This project has no control on the content it is serving, using copyrighted content from the providers is not going to be accounted for by the developer. It is the user's own risk. 22 | 23 | 24 | 25 |
26 | 27 |

DMCA and Copyright Infrigements

28 | 29 |
30 | 31 | 32 | A browser is a tool, and the maliciousness of the tool is directly based on the user. 33 | 34 | 35 | This project uses client-side content access mechanisms. Hence, the copyright infrigements or DMCA in this project's regards are to be forwarded to the associated site by the associated notifier of any such claims. As of writing this is flixhq.to 36 | -------------------------------------------------------------------------------- /src/utils/players/celluloid.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::SpawnError; 2 | use log::{debug, error}; 3 | use std::sync::atomic::{AtomicBool, Ordering}; 4 | use std::sync::Arc; 5 | 6 | pub struct Celluloid { 7 | pub executable: String, 8 | pub args: Vec, 9 | } 10 | 11 | impl Celluloid { 12 | pub fn new() -> Self { 13 | debug!("Initializing new celluloid instance."); 14 | Self { 15 | executable: "celluloid".to_string(), 16 | args: vec![], 17 | } 18 | } 19 | } 20 | 21 | #[derive(Default, Debug)] 22 | pub struct CelluloidArgs { 23 | pub url: String, 24 | pub mpv_sub_files: Option>, 25 | pub mpv_force_media_title: Option, 26 | } 27 | 28 | pub trait CelluloidPlay { 29 | fn play(&self, args: CelluloidArgs) -> Result<(), SpawnError>; 30 | } 31 | 32 | impl CelluloidPlay for Celluloid { 33 | fn play(&self, args: CelluloidArgs) -> Result<(), SpawnError> { 34 | debug!("Preparing to play video with URL: {:?}", args.url); 35 | 36 | let mut temp_args = self.args.clone(); 37 | temp_args.push(args.url.clone()); 38 | 39 | if let Some(mpv_sub_files) = args.mpv_sub_files { 40 | let temp_sub_files = mpv_sub_files 41 | .iter() 42 | .map(|sub_file| sub_file.replace(":", r#"\:"#)) 43 | .collect::>() 44 | .join(":"); 45 | 46 | temp_args.push(format!("--mpv-sub-files={}", temp_sub_files)); 47 | } 48 | 49 | if let Some(mpv_force_media_title) = args.mpv_force_media_title { 50 | temp_args.push(format!("--mpv-force-media-title={}", mpv_force_media_title)); 51 | } 52 | 53 | let running = Arc::new(AtomicBool::new(true)); 54 | let r = running.clone(); 55 | 56 | match ctrlc::set_handler(move || { 57 | r.store(false, Ordering::SeqCst); 58 | }) { 59 | Ok(_) => {} 60 | Err(_) => {} 61 | } 62 | 63 | std::process::Command::new(&self.executable) 64 | .args(temp_args) 65 | .status() 66 | .map_err(|e| { 67 | error!("Failed to spawn iina process: {}", e); 68 | SpawnError::IOError(e) 69 | })?; 70 | 71 | Ok(()) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/providers/vidcloud.rs: -------------------------------------------------------------------------------- 1 | use crate::{providers::VideoExtractor, CLIENT}; 2 | use log::{debug, error}; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Debug, Serialize, Deserialize)] 6 | pub struct Source { 7 | pub file: String, 8 | } 9 | 10 | #[derive(Debug, Serialize, Deserialize)] 11 | pub struct Track { 12 | pub file: String, 13 | pub label: String, 14 | pub kind: String, 15 | #[serde(skip_serializing_if = "Option::is_none")] 16 | pub default: Option, 17 | } 18 | 19 | #[derive(Debug, Serialize, Deserialize)] 20 | pub struct VidCloud { 21 | pub sources: Vec, 22 | pub tracks: Vec, 23 | } 24 | 25 | impl VidCloud { 26 | pub fn new() -> Self { 27 | debug!("Initializing VidCloud instance."); 28 | Self { 29 | sources: vec![], 30 | tracks: vec![], 31 | } 32 | } 33 | } 34 | 35 | impl VideoExtractor for VidCloud { 36 | async fn extract(&mut self, server_url: &str) -> anyhow::Result<()> { 37 | let request_url = format!("https://dec.eatmynerds.live?url={}", server_url); 38 | 39 | debug!("Starting extraction process for URL: {}", server_url); 40 | debug!("Constructed request URL: {}", request_url); 41 | 42 | let response = match CLIENT.get(&request_url).send().await { 43 | Ok(resp) => { 44 | debug!("Received response from server."); 45 | match resp.text().await { 46 | Ok(text) => text, 47 | Err(e) => { 48 | error!("Failed to read response text: {}", e); 49 | return Err(e.into()); 50 | } 51 | } 52 | } 53 | Err(e) => { 54 | error!("HTTP request failed: {}", e); 55 | return Err(e.into()); 56 | } 57 | }; 58 | 59 | match serde_json::from_str::(&response) { 60 | Ok(sources) => { 61 | self.sources = sources.sources; 62 | self.tracks = sources.tracks; 63 | debug!("Successfully deserialized response into VidCloud."); 64 | } 65 | Err(e) => { 66 | error!("Failed to deserialize response: {}", e); 67 | return Err(e.into()); 68 | } 69 | } 70 | 71 | Ok(()) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /install: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | 4 | DEPS="curl jq unzip" 5 | 6 | for dep in $DEPS; do 7 | if ! command -v "$dep" >/dev/null 2>&1; then 8 | echo "Error: '$dep' is required but not installed." >&2 9 | exit 1 10 | fi 11 | done 12 | 13 | if [ "${1:-}" = "--android" ]; then 14 | if ! command -v adb >/dev/null 2>&1; then 15 | echo "Warning: '--android' specified but 'adb' not found. Android install may fail." >&2 16 | fi 17 | fi 18 | 19 | USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" 20 | API_URL="https://api.github.com/repos/eatmynerds/lobster-rs/releases/latest" 21 | TEMP_DIR="./temp-lobster" 22 | ARCH=$(uname -m) 23 | OS=$(uname -s | tr '[:upper:]' '[:lower:]') 24 | 25 | case "$ARCH" in 26 | i386|i486|i586|i686) 27 | echo "Error: 32-bit x86 architectures are not supported. Compile from source instead." 28 | exit 1 29 | ;; 30 | esac 31 | 32 | rm -rf "$TEMP_DIR" 33 | mkdir -p "$TEMP_DIR" 34 | 35 | DOWNLOAD_URL=$(curl -sSL -H "User-Agent: $USER_AGENT" "$API_URL" | jq -r '.assets[0].browser_download_url') 36 | if [ -z "$DOWNLOAD_URL" ] || [ "$DOWNLOAD_URL" = "null" ]; then 37 | echo "Error: No download URL found." 38 | exit 1 39 | fi 40 | 41 | curl -sSL "$DOWNLOAD_URL" -o "$TEMP_DIR/asset.zip" 42 | unzip -q "$TEMP_DIR/asset.zip" -d "$TEMP_DIR" 43 | 44 | if command -v getprop >/dev/null 2>&1; then 45 | OS="android" 46 | fi 47 | if [ "${1:-}" = "--android" ] && command -v adb >/dev/null 2>&1; then 48 | OS="android" 49 | ARCH=$(adb shell uname -m) 50 | fi 51 | 52 | case "$OS" in 53 | *linux*) BUILD_TARGET="${ARCH}-unknown-linux-gnu_lobster-rs" ;; 54 | *darwin*) BUILD_TARGET="${ARCH}-apple-darwin_lobster-rs" ;; 55 | *android*) BUILD_TARGET="${ARCH}-linux-android_lobster-rs" ;; 56 | *msys*|*cygwin*|*mingw*|*windows*) BUILD_TARGET="${ARCH}-pc-windows-msvc_lobster-rs.exe" ;; 57 | *) 58 | echo "Error: Unsupported platform $OS-$ARCH" 59 | exit 1 60 | ;; 61 | esac 62 | 63 | FOUND_FILE=$(find "$TEMP_DIR" -type f -name "$BUILD_TARGET" 2>/dev/null || true) 64 | if [ -z "$FOUND_FILE" ]; then 65 | echo "Error: Build target not found." 66 | exit 1 67 | fi 68 | 69 | case "$OS" in 70 | *msys*|*cygwin*|*mingw*|*windows*) DEST_PATH="./lobster-rs.exe" ;; 71 | *) DEST_PATH="./lobster-rs" ;; 72 | esac 73 | 74 | mv "$FOUND_FILE" "$DEST_PATH" 75 | chmod +x "$DEST_PATH" 76 | 77 | rm -rf "$TEMP_DIR" 78 | echo "Done! Binary saved to $DEST_PATH." 79 | -------------------------------------------------------------------------------- /src/utils/players/vlc.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::SpawnError; 2 | use ctrlc; 3 | use log::{debug, error}; 4 | use std::sync::{ 5 | atomic::{AtomicBool, Ordering}, 6 | Arc, 7 | }; 8 | 9 | pub struct Vlc { 10 | pub executable: String, 11 | pub args: Vec, 12 | } 13 | 14 | impl Vlc { 15 | pub fn new() -> Self { 16 | debug!("Initializing new vlc instance."); 17 | Self { 18 | executable: "vlc".to_string(), 19 | args: vec![], 20 | } 21 | } 22 | } 23 | 24 | #[derive(Default, Debug)] 25 | pub struct VlcArgs { 26 | pub url: String, 27 | pub input_slave: Option>, 28 | pub meta_title: Option, 29 | } 30 | 31 | pub trait VlcPlay { 32 | fn play(&self, args: VlcArgs) -> Result<(), SpawnError>; 33 | } 34 | 35 | impl VlcPlay for Vlc { 36 | fn play(&self, args: VlcArgs) -> Result<(), SpawnError> { 37 | debug!("Preparing to play video with URL: {:?}", args.url); 38 | 39 | let mut temp_args = self.args.clone(); 40 | temp_args.push(args.url.clone()); 41 | 42 | if let Some(input_slave) = &args.input_slave { 43 | let input_slave_arg = format!(r#"--input-slave="{}""#, input_slave.join("#")); 44 | temp_args.push(input_slave_arg.clone()); 45 | debug!("Added input-slave argument: {}", input_slave_arg); 46 | } 47 | 48 | if let Some(meta_title) = &args.meta_title { 49 | let meta_title_arg = format!("--meta-title={}", meta_title); 50 | temp_args.push(meta_title_arg.clone()); 51 | debug!("Added meta-title argument: {}", meta_title_arg); 52 | } 53 | 54 | debug!( 55 | "Executing VLC command: {} with args: {:?}", 56 | self.executable, temp_args 57 | ); 58 | 59 | debug!("Executing mpv command: {} {:?}", self.executable, temp_args); 60 | 61 | let running = Arc::new(AtomicBool::new(true)); 62 | let r = running.clone(); 63 | 64 | match ctrlc::set_handler(move || { 65 | r.store(false, Ordering::SeqCst); 66 | }) { 67 | Ok(_) => {} 68 | Err(_) => {} 69 | } 70 | 71 | std::process::Command::new(&self.executable) 72 | .args(temp_args) 73 | .status() 74 | .map_err(|e| { 75 | error!("Failed to spawn VLC process: {}", e); 76 | SpawnError::IOError(e) 77 | })?; 78 | 79 | Ok(()) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/utils/players/iina.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::SpawnError; 2 | use std::sync::atomic::{AtomicBool, Ordering}; 3 | use std::sync::Arc; 4 | use log::{debug, error}; 5 | 6 | pub struct Iina { 7 | pub executable: String, 8 | pub args: Vec, 9 | } 10 | 11 | impl Iina { 12 | pub fn new() -> Self { 13 | debug!("Initializing new iina instance."); 14 | Self { 15 | executable: "iina".to_string(), 16 | args: vec![], 17 | } 18 | } 19 | } 20 | 21 | #[derive(Default, Debug)] 22 | pub struct IinaArgs { 23 | pub url: String, 24 | pub no_stdin: bool, 25 | pub keep_running: bool, 26 | pub mpv_sub_files: Option>, 27 | pub mpv_force_media_title: Option, 28 | } 29 | 30 | pub trait IinaPlay { 31 | fn play(&self, args: IinaArgs) -> Result<(), SpawnError>; 32 | } 33 | 34 | impl IinaPlay for Iina { 35 | fn play(&self, args: IinaArgs) -> Result<(), SpawnError> { 36 | debug!("Preparing to play video with URL: {:?}", args.url); 37 | 38 | let mut temp_args = self.args.clone(); 39 | temp_args.push(args.url.clone()); 40 | 41 | if args.no_stdin { 42 | temp_args.push("--no-stdin".to_string()); 43 | } 44 | 45 | if args.keep_running { 46 | temp_args.push("--keep-running".to_string()); 47 | } 48 | 49 | if let Some(mpv_sub_files) = args.mpv_sub_files { 50 | let temp_sub_files = mpv_sub_files 51 | .iter() 52 | .map(|sub_file| sub_file.replace(":", r#"\:"#)) 53 | .collect::>() 54 | .join(":"); 55 | 56 | temp_args.push(format!("--mpv-sub-files={}", temp_sub_files)); 57 | } 58 | 59 | if let Some(mpv_force_media_title) = args.mpv_force_media_title { 60 | temp_args.push(format!("--mpv-force-media-title={}", mpv_force_media_title)); 61 | } 62 | 63 | let running = Arc::new(AtomicBool::new(true)); 64 | let r = running.clone(); 65 | 66 | match ctrlc::set_handler(move || { 67 | r.store(false, Ordering::SeqCst); 68 | }) { 69 | Ok(_) => {} 70 | Err(_) => {} 71 | } 72 | 73 | std::process::Command::new(&self.executable) 74 | .args(temp_args) 75 | .status() 76 | .map_err(|e| { 77 | error!("Failed to spawn iina process: {}", e); 78 | SpawnError::IOError(e) 79 | })?; 80 | 81 | Ok(()) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/utils/image_preview.rs: -------------------------------------------------------------------------------- 1 | use crate::CLIENT; 2 | use log::{debug, error}; 3 | 4 | pub fn generate_desktop( 5 | media_title: String, 6 | media_id: String, 7 | image_path: String, 8 | ) -> anyhow::Result<()> { 9 | debug!("Generating desktop entry for media_id: {}", media_id); 10 | 11 | let desktop_entry = String::from(format!( 12 | r#"[Desktop Entry] 13 | Name={} 14 | Exec=echo %c 15 | Icon={} 16 | Type=Application 17 | Categories=imagepreview;"#, 18 | media_title, image_path 19 | )); 20 | 21 | let image_preview_dir = dirs::home_dir() 22 | .expect("Failed to get home directory") 23 | .join(".local/share/applications/imagepreview"); 24 | 25 | if !image_preview_dir.exists() { 26 | debug!("Creating directory: {:?}", image_preview_dir); 27 | std::fs::create_dir(&image_preview_dir)?; 28 | } 29 | 30 | let desktop_file = image_preview_dir.join(format!("{}.desktop", media_id.replace("/", "-"))); 31 | 32 | debug!("Writing desktop entry to file: {:?}", desktop_file); 33 | std::fs::write(&desktop_file, desktop_entry)?; 34 | 35 | debug!( 36 | "Desktop entry generated successfully for media_id: {}", 37 | media_id 38 | ); 39 | 40 | Ok(()) 41 | } 42 | 43 | pub fn remove_desktop_and_tmp(media_id: String) -> anyhow::Result<()> { 44 | debug!( 45 | "Removing desktop entry and temporary files for media_id: {}", 46 | media_id 47 | ); 48 | 49 | let image_preview_dir = dirs::home_dir() 50 | .expect("Failed to get home directory") 51 | .join(".local/share/applications/imagepreview"); 52 | 53 | let desktop_file = image_preview_dir.join(format!("{}.desktop", media_id.replace("/", "-"))); 54 | 55 | if desktop_file.exists() { 56 | debug!("Removing desktop file: {:?}", desktop_file); 57 | std::fs::remove_file(&desktop_file)?; 58 | } else { 59 | debug!("Desktop file does not exist: {:?}", desktop_file); 60 | } 61 | 62 | if std::fs::metadata("/tmp/images").is_ok() { 63 | debug!("Removing temporary images directory: /tmp/images"); 64 | std::fs::remove_dir_all("/tmp/images")?; 65 | } else { 66 | debug!("Temporary images directory does not exist: /tmp/images"); 67 | } 68 | 69 | debug!( 70 | "Desktop entry and temporary files removed successfully for media_id: {}", 71 | media_id 72 | ); 73 | 74 | Ok(()) 75 | } 76 | 77 | pub async fn image_preview( 78 | images: &Vec<(String, String, String)>, 79 | ) -> anyhow::Result> { 80 | debug!( 81 | "Starting image preview generation for {} images.", 82 | images.len() 83 | ); 84 | 85 | if std::fs::metadata("/tmp/images").is_ok() { 86 | debug!("Removing existing temporary images directory: /tmp/images"); 87 | std::fs::remove_dir_all("/tmp/images")?; 88 | } 89 | 90 | debug!("Creating temporary images directory: /tmp/images"); 91 | std::fs::create_dir_all("/tmp/images").expect("Failed to create image cache directory"); 92 | 93 | let mut temp_images: Vec<(String, String, String)> = vec![]; 94 | 95 | for (media_name, image_url, media_id) in images.iter() { 96 | debug!( 97 | "Downloading image for media_id: {} from URL: {}", 98 | media_id, image_url 99 | ); 100 | 101 | let image_bytes = CLIENT 102 | .get(image_url.to_string()) 103 | .send() 104 | .await? 105 | .bytes() 106 | .await?; 107 | 108 | let output_path = format!("/tmp/images/{}.jpg", media_id.replace("/", "-")); 109 | debug!("Saving image to: {}", output_path); 110 | 111 | match image::load_from_memory(&image_bytes) { 112 | Ok(image) => { 113 | image.save(&output_path)?; 114 | temp_images.push((media_name.to_string(), media_id.to_string(), output_path)); 115 | debug!("Image saved successfully for media_id: {}", media_id); 116 | } 117 | Err(e) => { 118 | error!( 119 | "Failed to process image for media_id: {}. Error: {}", 120 | media_id, e 121 | ); 122 | return Err(anyhow::anyhow!(e)); 123 | } 124 | } 125 | } 126 | 127 | debug!("Image preview generation completed successfully."); 128 | 129 | Ok(temp_images) 130 | } 131 | -------------------------------------------------------------------------------- /src/utils/players/mpv.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::SpawnError; 2 | use crossterm::style::Stylize; 3 | use log::{debug, error}; 4 | use std::process::{Child, Stdio}; 5 | use std::sync::atomic::{AtomicBool, Ordering}; 6 | use std::sync::Arc; 7 | 8 | pub struct Mpv { 9 | pub executable: String, 10 | pub args: Vec, 11 | } 12 | 13 | impl Mpv { 14 | pub fn new() -> Self { 15 | debug!("Initializing new mpv instance."); 16 | Self { 17 | executable: "mpv".to_string(), 18 | args: vec![], 19 | } 20 | } 21 | } 22 | 23 | #[derive(Default, Debug)] 24 | pub struct MpvArgs { 25 | pub url: String, 26 | pub sub_file: Option, 27 | pub sub_files: Option>, 28 | pub force_media_title: Option, 29 | pub quiet: bool, 30 | pub really_quiet: bool, 31 | pub save_position_on_quit: bool, 32 | pub write_filename_in_watch_later_config: bool, 33 | pub watch_later_dir: Option, 34 | pub input_ipc_server: Option, 35 | } 36 | 37 | pub trait MpvPlay { 38 | fn play(&self, args: MpvArgs) -> Result; 39 | } 40 | 41 | impl MpvPlay for Mpv { 42 | fn play(&self, args: MpvArgs) -> Result { 43 | debug!("Preparing to play video with URL: {:?}", args.url); 44 | 45 | let mut temp_args = self.args.clone(); 46 | temp_args.push(args.url.clone()); 47 | 48 | if args.quiet { 49 | debug!("Adding quiet flag"); 50 | temp_args.push(String::from("--quiet")); 51 | } 52 | 53 | if args.really_quiet { 54 | debug!("Adding really quiet flag"); 55 | temp_args.push(String::from("--really-quiet")); 56 | } 57 | 58 | if let Some(sub_files) = args.sub_files { 59 | let temp_sub_files = sub_files 60 | .iter() 61 | .map(|sub_file| sub_file.replace(":", r#"\:"#)) 62 | .collect::>() 63 | .join(":"); 64 | 65 | debug!("Adding subtitle files: {}", temp_sub_files); 66 | temp_args.push(format!("--sub-files={}", temp_sub_files)); 67 | } 68 | 69 | if args.save_position_on_quit { 70 | debug!("Adding save position on quit flag"); 71 | temp_args.push(String::from("--save-position-on-quit")); 72 | } 73 | 74 | if args.write_filename_in_watch_later_config { 75 | debug!("Adding write filename in watch later config flag"); 76 | temp_args.push(String::from("--write-filename-in-watch-later-config")); 77 | } 78 | 79 | if let Some(watch_later_dir) = args.watch_later_dir { 80 | debug!("Setting watch later directory: {}", watch_later_dir); 81 | if cfg!(not(target_os = "windows")) { 82 | temp_args.push(format!("--watch-later-dir={}", watch_later_dir)); 83 | } else { 84 | temp_args.push(format!("--watch-later-directory={}", watch_later_dir)); 85 | } 86 | } 87 | 88 | if let Some(input_ipc_server) = args.input_ipc_server { 89 | debug!("Setting input IPC server: {}", input_ipc_server); 90 | temp_args.push(format!("--input-ipc-server={}", input_ipc_server)); 91 | } 92 | 93 | if let Some(sub_file) = args.sub_file { 94 | debug!("Adding subtitle file: {}", sub_file); 95 | temp_args.push(format!("--sub-file={sub_file}")); 96 | } 97 | 98 | if let Some(force_media_title) = args.force_media_title { 99 | debug!("Forcing media title: {}", force_media_title); 100 | println!( 101 | "{}", 102 | format!(r#"Now playing "{}""#, force_media_title).blue() 103 | ); 104 | temp_args.push(format!("--force-media-title={}", force_media_title)); 105 | } 106 | 107 | debug!("Executing mpv command: {} {:?}", self.executable, temp_args); 108 | 109 | let running = Arc::new(AtomicBool::new(true)); 110 | let r = running.clone(); 111 | 112 | match ctrlc::set_handler(move || { 113 | r.store(false, Ordering::SeqCst); 114 | }) { 115 | Ok(_) => {} 116 | Err(_) => {} 117 | } 118 | 119 | std::process::Command::new(&self.executable) 120 | .stdout(Stdio::piped()) 121 | .args(temp_args) 122 | .spawn() 123 | .map_err(|e| { 124 | error!("Failed to spawn MPV process: {}", e); 125 | SpawnError::IOError(e) 126 | }) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/utils/presence.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient}; 3 | use lazy_static::lazy_static; 4 | use regex::Regex; 5 | use std::{ 6 | io::{Cursor, Read}, 7 | process::Child, 8 | }; 9 | use log::{info, error, warn}; 10 | 11 | lazy_static! { 12 | static ref FILE_PATH: String = if cfg!(windows) { 13 | std::env::var("LocalAppData").unwrap() + "\\temp\\discord_presence" 14 | } else { 15 | String::from("/tmp/discord_presence") 16 | }; 17 | } 18 | 19 | const PATTERN: &str = r#"(\(Paused\)\s)?AV:\s([0-9:]*) / ([0-9:]*) \(([0-9]*)%\)"#; 20 | 21 | pub async fn discord_presence( 22 | title: &str, 23 | season_and_episode_num: Option<(usize, usize)>, 24 | mut mpv_child: Child, 25 | large_image: &str, 26 | ) -> anyhow::Result<()> { 27 | let client_id = "1340948447305535592"; 28 | let mut client = DiscordIpcClient::new(client_id) 29 | .map_err(|_| anyhow!("Failed to create discord IPC client!"))?; 30 | 31 | match client.connect() { 32 | Ok(_) => info!("Client connected to Discord successfully."), 33 | Err(_) => warn!("Client failed to connect to Discord, will retry automatically."), 34 | }; 35 | 36 | let details = match season_and_episode_num { 37 | Some((season_num, episode_num)) => format!( 38 | "{} - Season {} Episode {}", 39 | title, 40 | season_num, 41 | episode_num + 1 42 | ), 43 | None => title.to_string(), 44 | }; 45 | 46 | let re: regex::Regex = Regex::new(PATTERN).unwrap(); 47 | let mut output = mpv_child.stdout.take().unwrap(); 48 | let buffer = vec![0; 256]; 49 | let mut cursor = Cursor::new(buffer); 50 | 51 | // Track connection status 52 | let mut connected = true; 53 | 54 | while mpv_child.try_wait()?.is_none() { 55 | cursor.set_position(0); 56 | let offset = cursor.position(); 57 | let bread = output.read(&mut cursor.get_mut()[offset as usize..])?; 58 | cursor.set_position(offset + bread as u64); 59 | let read_data = &cursor.get_ref()[..cursor.position() as usize]; 60 | let content = String::from_utf8_lossy(&read_data); 61 | let captures = re 62 | .captures_iter(&content) 63 | .last() 64 | .ok_or("Could not match the regex pattern."); 65 | let position = match captures { 66 | Ok(captures) => { 67 | let (_paused, av_first, av_second, _percentage) = ( 68 | captures.get(1).map_or("", |m| m.as_str()), 69 | captures.get(2).map_or("", |m| m.as_str()), 70 | captures.get(3).map_or("", |m| m.as_str()), 71 | captures.get(4).map_or("", |m| m.as_str()), 72 | ); 73 | format!("{}/{}", av_first, av_second) 74 | } 75 | Err(_) => String::from(""), 76 | }; 77 | 78 | let activity = activity::Activity::new() 79 | .details(details.as_str()) 80 | .state(position.as_str()) 81 | .assets( 82 | activity::Assets::new() 83 | .large_image(large_image) 84 | .large_text(&title), 85 | ) 86 | .buttons(vec![ 87 | activity::Button::new("Github", "https://github.com/eatmynerds/lobster-rs"), 88 | activity::Button::new("Discord", "https://discord.gg/4P2DaJFxbm"), 89 | ]); 90 | 91 | let result = client.set_activity(activity.clone()); 92 | 93 | match result { 94 | Ok(_) => { 95 | if !connected { 96 | info!("Reconnected to Discord successfully."); 97 | connected = true; 98 | } 99 | } 100 | Err(_) => { 101 | if connected { 102 | warn!("Discord connection lost, attempting to reconnect..."); 103 | connected = false; 104 | } 105 | 106 | match client.connect() { 107 | Ok(_) => { 108 | info!("Reconnected to Discord successfully."); 109 | connected = true; 110 | 111 | if let Err(_) = client.set_activity(activity) { 112 | warn!("Failed to set activity after reconnection."); 113 | } 114 | } 115 | Err(_) => { 116 | warn!("Failed to reconnect to Discord, will retry on next update."); 117 | } 118 | } 119 | } 120 | } 121 | } 122 | 123 | // Try to close connection gracefully 124 | if let Err(_) = client.close() { 125 | error!("Failed to close Discord connection gracefully."); 126 | } 127 | 128 | Ok(()) 129 | } 130 | 131 | 132 | 133 | -------------------------------------------------------------------------------- /src/utils/ffmpeg.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{atomic::AtomicBool, Arc}; 2 | 3 | use crate::utils::SpawnError; 4 | use log::{debug, error}; 5 | 6 | pub struct Ffmpeg { 7 | pub executable: String, 8 | pub args: Vec, 9 | } 10 | 11 | impl Ffmpeg { 12 | pub fn new() -> Self { 13 | debug!("Initializing new ffmpeg instance."); 14 | Self { 15 | executable: "ffmpeg".to_string(), 16 | args: vec![], 17 | } 18 | } 19 | } 20 | 21 | #[derive(Default)] 22 | pub struct FfmpegArgs<'a> { 23 | pub input_file: String, 24 | pub stats: bool, 25 | pub log_level: Option, 26 | pub output_file: String, 27 | pub subtitle_files: Option<&'a Vec>, 28 | pub subtitle_language: Option, 29 | pub codec: Option, 30 | } 31 | 32 | pub trait FfmpegSpawn { 33 | fn embed_video(&self, args: FfmpegArgs) -> Result<(), SpawnError>; 34 | } 35 | 36 | impl FfmpegSpawn for Ffmpeg { 37 | fn embed_video(&self, args: FfmpegArgs) -> Result<(), SpawnError> { 38 | debug!("Starting embed_video with input file: {}", args.input_file); 39 | 40 | let mut temp_args = self.args.clone(); 41 | temp_args.push("-i".to_string()); 42 | temp_args.push(args.input_file.to_owned()); 43 | 44 | if args.stats { 45 | debug!("Adding stats flag."); 46 | temp_args.push("-stats".to_string()); 47 | } 48 | 49 | if let Some(log_level) = &args.log_level { 50 | debug!("Setting log level to: {}", log_level); 51 | temp_args.push("-loglevel".to_string()); 52 | temp_args.push(log_level.to_owned()); 53 | } 54 | 55 | if let Some(subtitle_files) = args.subtitle_files { 56 | let subtitle_count = subtitle_files.len(); 57 | debug!("Embedding {} subtitle files.", subtitle_count); 58 | 59 | if subtitle_count > 1 { 60 | for subtitle_file in subtitle_files { 61 | debug!("Adding subtitle file: {}", subtitle_file); 62 | temp_args.push("-i".to_string()); 63 | temp_args.push(subtitle_file.to_string()); 64 | } 65 | 66 | temp_args.extend("-map 0:v -map 0:a".split(" ").map(String::from)); 67 | 68 | for i in 1..=subtitle_count { 69 | temp_args.push("-map".to_string()); 70 | temp_args.push(i.to_string()); 71 | } 72 | 73 | temp_args.extend("-c:v copy -c:a copy -c:s srt".split(" ").map(String::from)); 74 | 75 | for i in 1..=subtitle_count { 76 | let metadata = format!( 77 | "-metadata:s:s:{} language={}_{}", 78 | i - 1, 79 | args.subtitle_language.as_deref().unwrap_or("English"), 80 | i 81 | ); 82 | debug!("Adding metadata: {}", metadata); 83 | temp_args.push(metadata); 84 | } 85 | } else { 86 | temp_args.push("-i".to_string()); 87 | temp_args.push(subtitle_files.join("\n")); 88 | temp_args.extend("-map 0:v -map 0:a -map 1".split(" ").map(String::from)); 89 | temp_args.push("-metadata:s:s:0".to_string()); 90 | let language = format!( 91 | "language={}", 92 | args.subtitle_language.as_deref().unwrap_or("English") 93 | ); 94 | debug!("Adding single subtitle metadata: {}", language); 95 | temp_args.push(language); 96 | } 97 | } 98 | 99 | if let Some(codec) = &args.codec { 100 | debug!("Setting codec to: {}", codec); 101 | temp_args.push("-c".to_string()); 102 | temp_args.push(codec.to_string()); 103 | } 104 | 105 | temp_args.push(args.output_file.to_owned()); 106 | debug!("Output file set to: {}", args.output_file); 107 | 108 | debug!( 109 | "Executing ffmpeg command: {} {:?}", 110 | self.executable, temp_args 111 | ); 112 | 113 | let running = Arc::new(AtomicBool::new(true)); 114 | 115 | let r = running.clone(); 116 | 117 | match ctrlc::set_handler(move || { 118 | r.store(false, std::sync::atomic::Ordering::SeqCst); 119 | }) { 120 | Ok(_) => {} 121 | Err(_) => {} 122 | } 123 | 124 | let exit_status = std::process::Command::new(&self.executable) 125 | .args(temp_args) 126 | .status() 127 | .map_err(|e| { 128 | error!("Error executing ffmpeg command: {}", e); 129 | std::process::exit(1); 130 | })?; 131 | 132 | if exit_status.code() != Some(0) { 133 | error!("Failed to download {:?}", args.output_file); 134 | std::process::exit(1); 135 | } 136 | 137 | Ok(()) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/utils/fzf.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::SpawnError; 2 | use log::{debug, error}; 3 | use std::{io::Write, process::Stdio}; 4 | 5 | pub struct Fzf { 6 | pub executable: String, 7 | pub args: Vec, 8 | } 9 | 10 | impl Fzf { 11 | pub fn new() -> Self { 12 | debug!("Initializing new Fzf instance."); 13 | Self { 14 | executable: "fzf".to_string(), 15 | args: vec![], 16 | } 17 | } 18 | } 19 | 20 | #[derive(Default, Debug)] 21 | pub struct FzfArgs { 22 | pub process_stdin: Option, 23 | pub header: Option, 24 | pub reverse: bool, 25 | pub preview: Option, 26 | pub with_nth: Option, 27 | pub ignore_case: bool, 28 | pub query: Option, 29 | pub cycle: bool, 30 | pub prompt: Option, 31 | pub delimiter: Option, 32 | pub preview_window: Option, 33 | } 34 | 35 | pub trait FzfSpawn { 36 | fn spawn(&mut self, args: &mut FzfArgs) -> Result; 37 | } 38 | 39 | impl FzfSpawn for Fzf { 40 | fn spawn(&mut self, args: &mut FzfArgs) -> Result { 41 | let mut temp_args = self.args.clone(); 42 | 43 | if let Some(header) = &args.header { 44 | debug!("Setting header: {}", header); 45 | temp_args.push(format!("--header={}", header)); 46 | } 47 | 48 | if let Some(prompt) = &args.prompt { 49 | temp_args.push("--prompt".to_string()); 50 | temp_args.push(prompt.to_string()); 51 | } 52 | 53 | if args.reverse { 54 | debug!("Adding reverse flag."); 55 | temp_args.push("--reverse".to_string()); 56 | } 57 | 58 | if let Some(preview) = &args.preview { 59 | debug!("Setting preview: {}", preview); 60 | temp_args.push(format!("--preview={}", preview)); 61 | } 62 | 63 | if let Some(with_nth) = &args.with_nth { 64 | debug!("Setting with-nth: {}", with_nth); 65 | temp_args.push(format!("--with-nth={}", with_nth)); 66 | } 67 | 68 | if args.ignore_case { 69 | debug!("Adding ignore-case flag."); 70 | temp_args.push("--ignore-case".to_string()); 71 | } 72 | 73 | if let Some(query) = &args.query { 74 | debug!("Setting query: {}", query); 75 | temp_args.push(format!("--query={}", query)); 76 | } 77 | 78 | if args.cycle { 79 | debug!("Adding cycle flag."); 80 | temp_args.push("--cycle".to_string()); 81 | } 82 | 83 | if let Some(delimiter) = &args.delimiter { 84 | debug!("Setting delimiter: {}", delimiter); 85 | temp_args.push(format!("--delimiter={}", delimiter)); 86 | } 87 | 88 | if let Some(preview_window) = &args.preview_window { 89 | debug!("Setting preview-window: {}", preview_window); 90 | temp_args.push(format!("--preview-window={}", preview_window)); 91 | } 92 | 93 | let mut command = std::process::Command::new(&self.executable); 94 | command.args(&temp_args); 95 | 96 | debug!("Executing fzf command: {} {:?}", self.executable, temp_args); 97 | 98 | if let Some(process_stdin) = &args.process_stdin { 99 | debug!("Process stdin provided, writing to stdin."); 100 | 101 | command 102 | .stdin(Stdio::piped()) 103 | .stdout(Stdio::piped()) 104 | .stderr(Stdio::piped()); 105 | 106 | let mut child = command.spawn().map_err(|e| { 107 | error!("Failed to spawn process: {}", e); 108 | SpawnError::IOError(e) 109 | })?; 110 | 111 | if let Some(mut stdin) = child.stdin.take() { 112 | writeln!(stdin, "{}", process_stdin).map_err(|e| { 113 | error!("Failed to write to stdin: {}", e); 114 | SpawnError::IOError(e) 115 | })?; 116 | } 117 | 118 | let output = child.wait_with_output().map_err(|e| { 119 | error!("Failed to wait for process output: {}", e); 120 | SpawnError::IOError(e) 121 | })?; 122 | 123 | debug!("Process completed successfully."); 124 | Ok(output) 125 | } else { 126 | debug!("No process stdin provided."); 127 | 128 | command 129 | .stdin(Stdio::piped()) 130 | .stdout(Stdio::piped()) 131 | .stderr(Stdio::piped()); 132 | 133 | let child = command.spawn().map_err(|e| { 134 | error!("Failed to spawn process: {}", e); 135 | SpawnError::IOError(e) 136 | })?; 137 | 138 | let output = child.wait_with_output().map_err(|e| { 139 | error!("Failed to wait for process output: {}", e); 140 | SpawnError::IOError(e) 141 | })?; 142 | 143 | debug!("Process completed successfully."); 144 | Ok(output) 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/utils/config.rs: -------------------------------------------------------------------------------- 1 | use crate::{Args, Languages, Provider}; 2 | use anyhow::Context; 3 | use log::{debug, warn}; 4 | use serde::{Deserialize, Serialize}; 5 | use std::{ 6 | fs::{self, File}, 7 | io::Write, 8 | path::Path, 9 | }; 10 | 11 | #[derive(Deserialize, Serialize, Debug, Clone)] 12 | pub struct Config { 13 | pub use_external_menu: bool, 14 | pub download: String, 15 | pub provider: Provider, 16 | pub subs_language: Languages, 17 | pub player: String, 18 | pub history: bool, 19 | pub image_preview: bool, 20 | pub no_subs: bool, 21 | pub debug: bool, 22 | } 23 | 24 | impl Config { 25 | pub fn new() -> Self { 26 | debug!("Creating a new default configuration."); 27 | let download_dir = std::env::current_dir() 28 | .expect("Failed to get current directory") 29 | .to_str() 30 | .expect("Failed to convert path to str") 31 | .to_string(); 32 | 33 | Self { 34 | player: String::from("mpv"), 35 | download: download_dir, 36 | provider: Provider::Vidcloud, 37 | history: false, 38 | subs_language: Languages::English, 39 | use_external_menu: false, 40 | image_preview: false, 41 | no_subs: false, 42 | debug: false, 43 | } 44 | } 45 | 46 | pub fn load_config() -> anyhow::Result { 47 | debug!("Loading configuration..."); 48 | let config_dir = dirs::config_dir().context("Failed to retrieve the config directory")?; 49 | 50 | let config_path = format!("{}/lobster-rs/config.toml", config_dir.display()); 51 | debug!("Looking for config file at path: {:?}", config_path); 52 | 53 | let config = Config::load_from_file(Path::new(&config_path))?; 54 | debug!("Configuration loaded successfully."); 55 | Ok(config) 56 | } 57 | 58 | pub fn load_from_file(file_path: &Path) -> anyhow::Result { 59 | if !file_path.exists() { 60 | warn!( 61 | "Config file not found at {:?}. Creating a default configuration.", 62 | file_path 63 | ); 64 | 65 | let default_config = Config::new(); 66 | let content = toml::to_string(&default_config) 67 | .with_context(|| "Failed to serialize the default configuration")?; 68 | 69 | if let Some(parent) = file_path.parent() { 70 | debug!("Creating config directory: {:?}", parent); 71 | fs::create_dir_all(parent) 72 | .with_context(|| format!("Failed to create config directory: {:?}", parent))?; 73 | } 74 | 75 | let mut file = File::create(&file_path) 76 | .with_context(|| format!("Failed to create config file: {:?}", file_path))?; 77 | 78 | debug!("Writing default configuration to file."); 79 | file.write_all(content.as_bytes()) 80 | .with_context(|| format!("Failed to write to config file: {:?}", file_path))?; 81 | 82 | debug!("Default configuration created successfully."); 83 | return Ok(default_config); 84 | } 85 | 86 | debug!("Reading config file from {:?}", file_path); 87 | let content = fs::read_to_string(&file_path) 88 | .with_context(|| format!("Failed to read config file: {:?}", file_path))?; 89 | 90 | debug!("Parsing config file content."); 91 | toml::from_str(&content).context("Failed to parse config.toml") 92 | } 93 | 94 | pub fn program_configuration(mut args: Args, config: &Self) -> Args { 95 | debug!("Applying configuration to program arguments."); 96 | 97 | if cfg!(target_os = "linux") { 98 | args.rofi = if !args.rofi { 99 | debug!("Setting `rofi` to {}", config.use_external_menu); 100 | config.use_external_menu 101 | } else { 102 | args.rofi 103 | }; 104 | 105 | match std::process::Command::new("rofi").arg("-v").output() { 106 | Ok(_) => {} 107 | Err(_) => { 108 | args.rofi = false; 109 | } 110 | } 111 | } else { 112 | debug!("Disabling `rofi` as it is not supported on this OS."); 113 | args.rofi = false; 114 | } 115 | 116 | args.image_preview = if !args.image_preview { 117 | debug!("Setting `image_preview` to {}", config.image_preview); 118 | config.image_preview 119 | } else { 120 | args.image_preview 121 | }; 122 | 123 | args.no_subs = if !args.no_subs { 124 | debug!("Setting `no_subs` to {}", config.no_subs); 125 | config.no_subs 126 | } else { 127 | args.no_subs 128 | }; 129 | 130 | args.download = args.download.as_ref().map(|download| { 131 | if download.is_some() { 132 | debug!("Using provided download directory: {:?}", download); 133 | } else { 134 | warn!( 135 | "Provided download directory is empty. Using default download directory: {:?}", 136 | config.download 137 | ); 138 | } 139 | Some(download.clone().unwrap_or_else(|| config.download.clone())) 140 | }); 141 | 142 | args.provider = Some(match &args.provider { 143 | Some(provider) => { 144 | debug!("Using provided provider: {:?}", provider); 145 | *provider 146 | } 147 | None => { 148 | debug!("Using default provider: {:?}", config.provider); 149 | config.provider 150 | } 151 | }); 152 | 153 | args.language = Some(match &args.language { 154 | Some(language) => { 155 | debug!("Using provided language: {:?}", language); 156 | *language 157 | } 158 | None => { 159 | debug!("Using default language: {:?}", config.subs_language); 160 | config.subs_language 161 | } 162 | }); 163 | 164 | args.debug = if !args.debug { 165 | debug!("Setting `debug` to {}", config.debug); 166 | config.debug 167 | } else { 168 | args.debug 169 | }; 170 | 171 | args 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/utils/rofi.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::SpawnError; 2 | use log::{debug, error}; 3 | use std::io::Write; 4 | 5 | pub struct Rofi { 6 | executable: String, 7 | pub args: Vec, 8 | } 9 | 10 | impl Rofi { 11 | pub fn new() -> Self { 12 | debug!("Initializing new Rofi instance."); 13 | Self { 14 | executable: "rofi".to_string(), 15 | args: vec![], 16 | } 17 | } 18 | } 19 | 20 | #[derive(Default, Debug)] 21 | pub struct RofiArgs { 22 | pub process_stdin: Option, 23 | pub mesg: Option, 24 | pub filter: Option, 25 | pub sort: bool, 26 | pub show_icons: bool, 27 | pub show: Option, 28 | pub drun_categories: Option, 29 | pub theme: Option, 30 | pub dmenu: bool, 31 | pub case_sensitive: bool, 32 | pub width: Option, 33 | pub left_display_prompt: Option, 34 | pub entry_prompt: Option, 35 | pub display_columns: Option, 36 | } 37 | 38 | pub trait RofiSpawn { 39 | fn spawn(&mut self, args: &mut RofiArgs) -> Result; 40 | } 41 | 42 | impl RofiSpawn for Rofi { 43 | fn spawn(&mut self, args: &mut RofiArgs) -> Result { 44 | let mut temp_args = self.args.clone(); 45 | 46 | debug!("Preparing arguments for Rofi execution."); 47 | if let Some(filter) = &args.filter { 48 | temp_args.push("-filter".to_string()); 49 | temp_args.push(filter.to_string()); 50 | debug!("Added filter argument: {}", filter); 51 | } 52 | 53 | if args.show_icons { 54 | temp_args.push("-show-icons".to_string()); 55 | debug!("Enabled show-icons."); 56 | } 57 | 58 | if let Some(drun_categories) = &args.drun_categories { 59 | temp_args.push("-drun-categories".to_string()); 60 | temp_args.push(drun_categories.to_string()); 61 | debug!("Added drun-categories: {}", drun_categories); 62 | } 63 | 64 | if let Some(theme) = &args.theme { 65 | temp_args.push("-theme".to_string()); 66 | temp_args.push(theme.to_string()); 67 | debug!("Added theme: {}", theme); 68 | } 69 | 70 | if args.sort { 71 | temp_args.push("-sort".to_string()); 72 | debug!("Enabled sorting."); 73 | } 74 | 75 | if args.dmenu { 76 | temp_args.push("-dmenu".to_string()); 77 | debug!("Enabled dmenu mode."); 78 | } 79 | 80 | if args.case_sensitive { 81 | temp_args.push("-i".to_string()); 82 | debug!("Enabled case sensitivity."); 83 | } 84 | 85 | if let Some(width) = &args.width { 86 | temp_args.push("-width".to_string()); 87 | temp_args.push(width.to_string()); 88 | debug!("Set width to {}", width); 89 | } 90 | 91 | if let Some(show) = &args.show { 92 | temp_args.push("-show".to_string()); 93 | temp_args.push(show.to_string()); 94 | debug!("Set show mode to {}", show); 95 | } 96 | 97 | if let Some(left_display_prompt) = &args.left_display_prompt { 98 | temp_args.push("-left-display-prompt".to_string()); 99 | temp_args.push(left_display_prompt.to_string()); 100 | debug!("Set left display prompt: {}", left_display_prompt); 101 | } 102 | 103 | if let Some(entry_prompt) = &args.entry_prompt { 104 | temp_args.push("-p".to_string()); 105 | temp_args.push(entry_prompt.to_string()); 106 | debug!("Set entry prompt: {}", entry_prompt); 107 | } 108 | 109 | if let Some(display_columns) = &args.display_columns { 110 | temp_args.push("-display-columns".to_string()); 111 | temp_args.push(display_columns.to_string()); 112 | debug!("Set display columns to {}", display_columns); 113 | } 114 | 115 | if let Some(mesg) = &args.mesg { 116 | temp_args.push("-mesg".to_string()); 117 | temp_args.push(mesg.to_string()); 118 | debug!("Set message: {}", mesg); 119 | } 120 | 121 | let mut command = std::process::Command::new(&self.executable); 122 | command.args(&temp_args); 123 | 124 | debug!("Constructed command: {:?}", command); 125 | 126 | if let Some(process_stdin) = &args.process_stdin { 127 | debug!("Spawning Rofi process with stdin."); 128 | command 129 | .stdin(std::process::Stdio::piped()) 130 | .stdout(std::process::Stdio::piped()) 131 | .stderr(std::process::Stdio::piped()); 132 | 133 | let mut child = command.spawn().map_err(|e| { 134 | error!("Failed to spawn Rofi process: {}", e); 135 | SpawnError::IOError(e) 136 | })?; 137 | 138 | if let Some(mut stdin) = child.stdin.take() { 139 | debug!("Writing to stdin: {}", process_stdin); 140 | writeln!(stdin, "{}", process_stdin).map_err(|e| { 141 | error!("Failed to write to stdin: {}", e); 142 | SpawnError::IOError(e) 143 | })?; 144 | } 145 | 146 | let output = child.wait_with_output().map_err(|e| { 147 | error!("Failed to wait for Rofi process: {}", e); 148 | SpawnError::IOError(e) 149 | })?; 150 | 151 | debug!("Rofi process completed successfully."); 152 | Ok(output) 153 | } else { 154 | debug!("Spawning Rofi process without stdin."); 155 | command 156 | .stdin(std::process::Stdio::piped()) 157 | .stdout(std::process::Stdio::piped()) 158 | .stderr(std::process::Stdio::piped()); 159 | 160 | let child = command.spawn().map_err(|e| { 161 | error!("Failed to spawn Rofi process: {}", e); 162 | SpawnError::IOError(e) 163 | })?; 164 | 165 | let output = child.wait_with_output().map_err(|e| { 166 | error!("Failed to wait for Rofi process: {}", e); 167 | SpawnError::IOError(e) 168 | })?; 169 | 170 | debug!("Rofi process completed successfully."); 171 | Ok(output) 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/utils/history.rs: -------------------------------------------------------------------------------- 1 | use crate::flixhq::flixhq::FlixHQEpisode; 2 | use anyhow::anyhow; 3 | use reqwest::Client; 4 | use std::fs::OpenOptions; 5 | use std::io::prelude::*; 6 | 7 | pub async fn save_progress(url: String) -> anyhow::Result<(String, f32)> { 8 | let watchlater_dir = std::path::PathBuf::new().join(format!( 9 | "{}/lobster-rs/watchlater", 10 | std::env::temp_dir().display() 11 | )); 12 | 13 | let mut durations: Vec = vec![]; 14 | 15 | let re = regex::Regex::new(r#"#EXTINF:([0-9]*\.?[0-9]+),"#).unwrap(); 16 | 17 | let client = Client::builder() 18 | .danger_accept_invalid_certs(true) 19 | .build()?; 20 | 21 | let response = client.get(url).send().await?.text().await?; 22 | 23 | for capture in re.captures_iter(&response) { 24 | if let Some(duration) = capture.get(1) { 25 | durations.push(duration.as_str().parse::().unwrap()); 26 | } 27 | } 28 | 29 | let entries: Vec<_> = std::fs::read_dir(watchlater_dir)? 30 | .filter_map(|entry| entry.ok()) 31 | .filter(|entry| entry.path().is_file()) 32 | .collect(); 33 | 34 | let file_path = entries[0].path(); 35 | 36 | let watchlater_contents = std::fs::read_to_string(&file_path)?; 37 | 38 | let start_pos = watchlater_contents.split("start=").collect::>()[1].trim(); 39 | 40 | let position = start_pos 41 | .chars() 42 | .position(|i| i == '\n') 43 | .map(|n| &start_pos[..n]) 44 | .unwrap_or_else(|| start_pos); 45 | 46 | let position = position.parse::().unwrap(); 47 | 48 | let total_duration: f32 = durations.iter().sum(); 49 | 50 | let progress = (position * 100.0) / total_duration; 51 | 52 | let new_position = format!( 53 | "{:.2}:{:.2}:{:.2}", 54 | (position / 3600.0), 55 | (position / 60.0 % 60.0), 56 | (position % 60.0) 57 | ); 58 | 59 | Ok((new_position, progress)) 60 | } 61 | 62 | fn write_to_history(info: String) -> anyhow::Result<()> { 63 | let history_file_dir = dirs::data_local_dir() 64 | .expect("Failed to find local dir") 65 | .join("lobster-rs"); 66 | 67 | if !history_file_dir.exists() { 68 | std::fs::create_dir_all(&history_file_dir)?; 69 | } 70 | 71 | let history_file = history_file_dir.join("lobster_history.txt"); 72 | 73 | if !history_file.exists() { 74 | std::fs::File::create(&history_file)?; 75 | } 76 | 77 | let mut file = OpenOptions::new().append(true).open(history_file).unwrap(); 78 | if let Err(e) = writeln!(file, "{}", info) { 79 | eprintln!("Couldn't write to file: {}", e); 80 | } 81 | 82 | Ok(()) 83 | } 84 | 85 | fn remove_from_history(media_id: String) -> anyhow::Result<()> { 86 | let history_file_dir = dirs::data_local_dir() 87 | .expect("Failed to find local dir") 88 | .join("lobster-rs"); 89 | 90 | if !history_file_dir.exists() { 91 | std::fs::create_dir_all(&history_file_dir)?; 92 | } 93 | 94 | let history_file = history_file_dir.join("lobster_history.txt"); 95 | 96 | if !history_file.exists() { 97 | return Err(anyhow!("History file does not exist!")); 98 | } 99 | 100 | let mut history_file_temp = std::fs::read_to_string(&history_file)? 101 | .lines() 102 | .map(String::from) 103 | .collect::>(); 104 | 105 | if let Some(pos) = history_file_temp.iter().position(|x| x.contains(&media_id)) { 106 | let _ = history_file_temp.remove(pos); 107 | } else { 108 | return Err(anyhow!("Episode does not exist in history file yet!")); 109 | } 110 | 111 | std::fs::write(history_file, history_file_temp.join("\n"))?; 112 | 113 | Ok(()) 114 | } 115 | 116 | pub async fn save_history( 117 | media_info: (Option, String, String, String, String), 118 | episode_info: Option<(usize, usize, Vec>)>, 119 | position: String, 120 | progress: f32, 121 | ) -> anyhow::Result<()> { 122 | let media_type = media_info.2.split('/').collect::>()[0]; 123 | 124 | match media_type { 125 | "movie" => { 126 | if progress > 90.0 { 127 | if remove_from_history(media_info.2.clone()).is_ok() { 128 | } else { 129 | write_to_history(format!( 130 | "{}\t{}\t{}\t{}", 131 | media_info.3, position, media_info.2, media_info.4 132 | ))?; 133 | } 134 | 135 | return Ok(()); 136 | } 137 | 138 | write_to_history(format!( 139 | "{}\t{}\t{}\t{}", 140 | media_info.3, position, media_info.2, media_info.4 141 | ))?; 142 | } 143 | "tv" => { 144 | if let Some((mut season_number, mut episode_number, episodes)) = episode_info { 145 | if progress > 90.0 { 146 | episode_number += 1; 147 | 148 | if episode_number >= episodes[season_number - 1].len() { 149 | if season_number < episodes.len() { 150 | season_number += 1; 151 | episode_number = 0; 152 | } 153 | } 154 | 155 | if remove_from_history(media_info.2.clone()).is_ok() { 156 | } else { 157 | write_to_history(format!( 158 | "{}\t{}\t{}\t{}\t{}\t{}\t{}", 159 | media_info.3, 160 | position, 161 | media_info.2, 162 | media_info.1, 163 | season_number, 164 | episodes[season_number - 1][episode_number].title, 165 | media_info.4 166 | ))?; 167 | } 168 | 169 | return Ok(()); 170 | } 171 | 172 | write_to_history(format!( 173 | "{}\t{}\t{}\t{}\t{}\t{}\t{}", 174 | media_info.3, 175 | position, 176 | media_info.2, 177 | media_info.1, 178 | season_number, 179 | episodes[season_number - 1][episode_number].title, 180 | media_info.4 181 | ))?; 182 | } 183 | } 184 | _ => return Err(anyhow!("Unknown media type!")), 185 | } 186 | 187 | Ok(()) 188 | } 189 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | 11 | jobs: 12 | build-linux: 13 | name: Build Linux (${{ matrix.arch }}) 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | arch: [x86_64, aarch64] 19 | include: 20 | - arch: x86_64 21 | target: x86_64-unknown-linux-gnu 22 | - arch: aarch64 23 | target: aarch64-unknown-linux-gnu 24 | 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v3 28 | 29 | - name: Install Rust 30 | uses: actions-rs/toolchain@v1 31 | with: 32 | toolchain: nightly 33 | target: ${{ matrix.target }} 34 | override: true 35 | profile: minimal 36 | 37 | - name: Install dependencies for x86_64 38 | if: matrix.arch == 'x86_64' 39 | run: | 40 | sudo apt-get update 41 | sudo apt-get install -y build-essential 42 | 43 | - name: Install dependencies for aarch64 44 | if: matrix.arch == 'aarch64' 45 | run: | 46 | sudo apt-get update 47 | sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu qemu-user 48 | cargo install cross 49 | 50 | - name: Build x86_64 target 51 | if: matrix.arch == 'x86_64' 52 | run: cargo build --release --target ${{ matrix.target }} 53 | 54 | - name: Build aarch64 target 55 | if: matrix.arch == 'aarch64' 56 | run: cross build --release --target ${{ matrix.target }} 57 | 58 | - name: Upload build artifacts 59 | uses: actions/upload-artifact@v4 60 | with: 61 | name: ${{ matrix.target }} 62 | path: target/${{ matrix.target }}/release/lobster-rs* 63 | 64 | build-macos: 65 | name: Build macOS (${{ matrix.arch }}) 66 | runs-on: macos-latest 67 | strategy: 68 | fail-fast: false 69 | matrix: 70 | arch: [x86_64, aarch64] 71 | include: 72 | - arch: x86_64 73 | target: x86_64-apple-darwin 74 | - arch: aarch64 75 | target: aarch64-apple-darwin 76 | 77 | steps: 78 | - name: Checkout code 79 | uses: actions/checkout@v3 80 | 81 | - name: Install Rust 82 | uses: actions-rs/toolchain@v1 83 | with: 84 | toolchain: nightly 85 | target: ${{ matrix.target }} 86 | override: true 87 | profile: minimal 88 | 89 | - name: Build 90 | run: cargo build --release --target ${{ matrix.target }} 91 | 92 | - name: Upload build artifacts 93 | uses: actions/upload-artifact@v4 94 | with: 95 | name: ${{ matrix.target }} 96 | path: target/${{ matrix.target }}/release/lobster-rs* 97 | 98 | build-windows: 99 | name: Build Windows (${{ matrix.arch }}) 100 | runs-on: windows-latest 101 | strategy: 102 | fail-fast: false 103 | matrix: 104 | arch: [x86_64, aarch64] 105 | include: 106 | - arch: x86_64 107 | target: x86_64-pc-windows-msvc 108 | - arch: aarch64 109 | target: aarch64-pc-windows-msvc 110 | 111 | steps: 112 | - name: Checkout code 113 | uses: actions/checkout@v3 114 | 115 | - name: Install Rust 116 | uses: actions-rs/toolchain@v1 117 | with: 118 | toolchain: nightly 119 | target: ${{ matrix.target }} 120 | override: true 121 | profile: minimal 122 | 123 | - name: Build 124 | run: cargo build --release --target ${{ matrix.target }} 125 | 126 | - name: Upload build artifacts 127 | uses: actions/upload-artifact@v4 128 | with: 129 | name: ${{ matrix.target }} 130 | path: target/${{ matrix.target }}/release/lobster-rs* 131 | 132 | build-android: 133 | name: Build Android (${{ matrix.arch }}) 134 | runs-on: ubuntu-latest 135 | strategy: 136 | fail-fast: false 137 | matrix: 138 | arch: [x86_64, aarch64] 139 | include: 140 | - arch: x86_64 141 | target: x86_64-linux-android 142 | - arch: aarch64 143 | target: aarch64-linux-android 144 | 145 | steps: 146 | - name: Checkout code 147 | uses: actions/checkout@v3 148 | 149 | - name: Install Rust 150 | uses: actions-rs/toolchain@v1 151 | with: 152 | toolchain: nightly 153 | override: true 154 | profile: minimal 155 | 156 | - name: Install Android targets 157 | run: | 158 | rustup target add ${{ matrix.target }} 159 | 160 | - name: Install Android NDK and configure OpenSSL 161 | run: | 162 | sudo apt-get update 163 | sudo apt-get install -y unzip wget libssl-dev 164 | wget -q https://dl.google.com/android/repository/android-ndk-r25b-linux.zip 165 | unzip -q android-ndk-r25b-linux.zip 166 | echo "ANDROID_NDK_HOME=$PWD/android-ndk-r25b" >> $GITHUB_ENV 167 | echo "ANDROID_NDK_ROOT=$PWD/android-ndk-r25b" >> $GITHUB_ENV 168 | echo "$PWD/android-ndk-r25b/toolchains/llvm/prebuilt/linux-x86_64/bin" >> $GITHUB_PATH 169 | cargo install cargo-ndk 170 | 171 | # Install OpenSSL for Android 172 | git clone https://github.com/openssl/openssl.git -b OpenSSL_1_1_1-stable openssl-src 173 | cd openssl-src 174 | 175 | # Configure and build OpenSSL for Android 176 | export ANDROID_NDK_HOME=$PWD/../android-ndk-r25b 177 | export PATH=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH 178 | 179 | if [[ "${{ matrix.arch }}" == "aarch64" ]]; then 180 | # Configure for ARM64 181 | ./Configure android-arm64 -D__ANDROID_API__=21 --prefix=$PWD/../openssl-android-arm64 182 | make -j4 183 | make install_sw 184 | echo "OPENSSL_DIR=$PWD/../openssl-android-arm64" >> $GITHUB_ENV 185 | else 186 | # Configure for x86_64 187 | ./Configure android-x86_64 -D__ANDROID_API__=21 --prefix=$PWD/../openssl-android-x86_64 188 | make -j4 189 | make install_sw 190 | echo "OPENSSL_DIR=$PWD/../openssl-android-x86_64" >> $GITHUB_ENV 191 | fi 192 | 193 | cd .. 194 | 195 | - name: Build for Android with OpenSSL 196 | run: | 197 | export OPENSSL_STATIC=1 198 | cargo ndk -t ${{ matrix.target }} build --release 199 | 200 | - name: Upload build artifacts 201 | uses: actions/upload-artifact@v4 202 | with: 203 | name: ${{ matrix.target }} 204 | path: target/${{ matrix.target }}/release/lobster-rs* 205 | 206 | release: 207 | name: Create Release 208 | runs-on: ubuntu-latest 209 | needs: [build-linux, build-macos, build-windows, build-android] 210 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') 211 | 212 | steps: 213 | - name: Checkout code 214 | uses: actions/checkout@v3 215 | 216 | - name: Download artifacts 217 | uses: actions/download-artifact@v4 218 | with: 219 | path: release 220 | 221 | - name: Create GitHub release 222 | id: create_release 223 | uses: actions/create-release@v1 224 | env: 225 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 226 | with: 227 | tag_name: ${{ github.ref_name }} 228 | release_name: Release ${{ github.ref_name }} 229 | body: | 230 | This is the release for version ${{ github.ref_name }}. 231 | draft: false 232 | prerelease: false 233 | 234 | - name: Zip all binaries 235 | run: | 236 | find release -type f ! -name "*.d" -exec sh -c 'cp "$1" "$(dirname "$1" | xargs basename)_$(basename "$1")"' _ {} \; 237 | chmod +x *apple-darwin* 238 | chmod +x *linux-gnu* 239 | chmod +x *linux-android* 240 | zip -r lobster-rs.zip *lobster-rs* 241 | 242 | - name: Upload zip to release 243 | uses: actions/upload-release-asset@v1 244 | env: 245 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 246 | with: 247 | upload_url: ${{ steps.create_release.outputs.upload_url }} 248 | asset_path: lobster-rs.zip 249 | asset_name: lobster-rs.zip 250 | asset_content_type: application/zip 251 | -------------------------------------------------------------------------------- /src/flixhq/flixhq.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | flixhq::html::FlixHQHTML, 3 | providers::{ 4 | vidcloud::{Source, Track, VidCloud}, 5 | VideoExtractor, 6 | }, 7 | MediaType, Provider, BASE_URL, CLIENT, 8 | }; 9 | use anyhow::anyhow; 10 | use log::{debug, error}; 11 | use serde::{Deserialize, Serialize}; 12 | 13 | #[derive(Debug)] 14 | pub enum FlixHQInfo { 15 | Tv(FlixHQShow), 16 | Movie(FlixHQMovie), 17 | } 18 | 19 | #[derive(Debug)] 20 | pub struct FlixHQMovie { 21 | pub title: String, 22 | pub year: String, 23 | pub media_type: MediaType, 24 | pub duration: String, 25 | pub image: String, 26 | pub id: String, 27 | } 28 | 29 | #[derive(Debug)] 30 | pub struct FlixHQShow { 31 | pub title: String, 32 | pub media_type: MediaType, 33 | pub image: String, 34 | pub id: String, 35 | pub seasons: FlixHQSeason, 36 | pub episodes: usize, 37 | } 38 | 39 | #[derive(Debug)] 40 | pub struct FlixHQSeason { 41 | pub total_seasons: usize, 42 | pub episodes: Vec>, 43 | } 44 | 45 | #[derive(Debug)] 46 | pub struct FlixHQResult { 47 | pub id: String, 48 | pub title: String, 49 | pub year: String, 50 | pub image: String, 51 | pub duration: String, 52 | pub media_type: Option, 53 | } 54 | 55 | #[derive(Debug, Clone)] 56 | pub struct FlixHQEpisode { 57 | pub id: String, 58 | pub title: String, 59 | } 60 | 61 | #[derive(Debug)] 62 | pub struct FlixHQServers { 63 | pub servers: Vec, 64 | } 65 | 66 | #[derive(Debug)] 67 | pub struct FlixHQServer { 68 | pub name: String, 69 | pub url: String, 70 | } 71 | 72 | #[derive(Debug, Deserialize)] 73 | pub struct FlixHQServerInfo { 74 | link: String, 75 | } 76 | 77 | #[derive(Debug, Serialize)] 78 | pub struct FlixHQSources { 79 | pub subtitles: FlixHQSubtitles, 80 | pub sources: FlixHQSourceType, 81 | } 82 | 83 | #[derive(Debug, Serialize)] 84 | pub enum FlixHQSourceType { 85 | VidCloud(Vec), 86 | } 87 | 88 | #[derive(Debug, Serialize)] 89 | pub enum FlixHQSubtitles { 90 | VidCloud(Vec), 91 | } 92 | 93 | pub struct FlixHQ; 94 | 95 | impl FlixHQ { 96 | pub async fn search(&self, query: &str) -> anyhow::Result> { 97 | debug!("Starting search for query: {}", query); 98 | let parsed_query = query.replace(" ", "-"); 99 | 100 | debug!("Formatted query: {}", parsed_query); 101 | 102 | let page_html = CLIENT 103 | .get(&format!("{}/search/{}", BASE_URL, parsed_query)) 104 | .send() 105 | .await? 106 | .text() 107 | .await?; 108 | 109 | debug!("Received HTML for search results"); 110 | let results = self.parse_search(&page_html); 111 | 112 | debug!("Search completed with {} results", results.len()); 113 | Ok(results) 114 | } 115 | 116 | pub async fn info(&self, media_id: &str) -> anyhow::Result { 117 | debug!("Fetching info for media_id: {}", media_id); 118 | let info_html = CLIENT 119 | .get(&format!("{}/{}", BASE_URL, media_id)) 120 | .send() 121 | .await? 122 | .text() 123 | .await?; 124 | 125 | debug!("Received HTML for media info"); 126 | let search_result = self.single_page(&info_html, media_id); 127 | 128 | match &search_result.media_type { 129 | Some(MediaType::Tv) => { 130 | debug!("Media type is Tv. Processing seasons and episodes"); 131 | let id = search_result 132 | .id 133 | .split('-') 134 | .last() 135 | .unwrap_or_default() 136 | .to_owned(); 137 | 138 | let season_html = CLIENT 139 | .get(format!("{}/ajax/v2/tv/seasons/{}", BASE_URL, id)) 140 | .send() 141 | .await? 142 | .text() 143 | .await?; 144 | 145 | let season_ids = self.season_info(&season_html); 146 | 147 | let mut seasons_and_episodes = vec![]; 148 | for season in &season_ids { 149 | let episode_html = CLIENT 150 | .get(format!("{}/ajax/v2/season/episodes/{}", BASE_URL, &season)) 151 | .send() 152 | .await? 153 | .text() 154 | .await?; 155 | 156 | let episodes = self.episode_info(&episode_html); 157 | seasons_and_episodes.push(episodes); 158 | } 159 | 160 | debug!( 161 | "Fetched {} seasons with {} episodes", 162 | season_ids.len(), 163 | seasons_and_episodes.last().map(|x| x.len()).unwrap_or(0) 164 | ); 165 | 166 | return Ok(FlixHQInfo::Tv(FlixHQShow { 167 | episodes: seasons_and_episodes.last().map(|x| x.len()).unwrap_or(0), 168 | seasons: FlixHQSeason { 169 | total_seasons: season_ids.len(), 170 | episodes: seasons_and_episodes, 171 | }, 172 | id: search_result 173 | .id 174 | .split('-') 175 | .last() 176 | .unwrap_or_default() 177 | .to_owned(), 178 | title: search_result.title, 179 | image: search_result.image, 180 | media_type: MediaType::Tv, 181 | })); 182 | } 183 | 184 | Some(MediaType::Movie) => { 185 | debug!("Media type is Movie"); 186 | return Ok(FlixHQInfo::Movie(FlixHQMovie { 187 | id: search_result 188 | .id 189 | .split('-') 190 | .last() 191 | .unwrap_or_default() 192 | .to_owned(), 193 | title: search_result.title, 194 | image: search_result.image, 195 | year: search_result 196 | .year 197 | .split('-') 198 | .nth(0) 199 | .unwrap_or_default() 200 | .to_owned(), 201 | duration: search_result.duration, 202 | media_type: MediaType::Movie, 203 | })); 204 | } 205 | None => { 206 | error!("No results found for media_id: {}", media_id); 207 | return Err(anyhow!("No results found")); 208 | } 209 | } 210 | } 211 | 212 | pub async fn servers(&self, episode_id: &str, media_id: &str) -> anyhow::Result { 213 | debug!( 214 | "Fetching servers for episode_id: {} and media_id: {}", 215 | episode_id, media_id 216 | ); 217 | let episode_id = format!( 218 | "{}/ajax/{}", 219 | BASE_URL, 220 | if !episode_id.starts_with(&format!("{}/ajax", BASE_URL)) && !media_id.contains("movie") 221 | { 222 | format!("v2/episode/servers/{}", episode_id) 223 | } else { 224 | format!("movie/episodes/{}", episode_id) 225 | } 226 | ); 227 | 228 | let server_html = CLIENT.get(episode_id).send().await?.text().await?; 229 | 230 | debug!("Received HTML for servers"); 231 | let servers = self.info_server(server_html, media_id); 232 | 233 | debug!("Found {} servers", servers.len()); 234 | Ok(FlixHQServers { servers }) 235 | } 236 | 237 | pub async fn sources( 238 | &self, 239 | episode_id: &str, 240 | media_id: &str, 241 | server: Provider, 242 | ) -> anyhow::Result { 243 | debug!( 244 | "Fetching sources for episode_id: {}, media_id: {}, server: {}", 245 | episode_id, media_id, server 246 | ); 247 | let servers = self.servers(episode_id, media_id).await?; 248 | 249 | let i = match servers 250 | .servers 251 | .iter() 252 | .position(|s| s.name == server.to_string()) 253 | { 254 | Some(index) => index, 255 | None => { 256 | error!("Server {} not found!", server); 257 | std::process::exit(1); 258 | } 259 | }; 260 | 261 | let parts = &servers.servers[i].url; 262 | 263 | debug!("Selected server URL: {}", parts); 264 | let server_id: &str = parts 265 | .split('.') 266 | .collect::>() 267 | .last() 268 | .copied() 269 | .unwrap_or_default(); 270 | 271 | let server_json = CLIENT 272 | .get(format!("{}/ajax/episode/sources/{}", BASE_URL, server_id)) 273 | .send() 274 | .await? 275 | .text() 276 | .await?; 277 | 278 | let server_info: FlixHQServerInfo = serde_json::from_str(&server_json)?; 279 | 280 | match server { 281 | Provider::Vidcloud | Provider::Upcloud => { 282 | debug!("Processing VidCloud or UpCloud sources"); 283 | let mut vidcloud = VidCloud::new(); 284 | vidcloud.extract(&server_info.link).await?; 285 | 286 | debug!("Sources and subtitles extracted successfully"); 287 | return Ok(FlixHQSources { 288 | sources: FlixHQSourceType::VidCloud(vidcloud.sources), 289 | subtitles: FlixHQSubtitles::VidCloud(vidcloud.tracks), 290 | }); 291 | } 292 | } 293 | } 294 | 295 | pub async fn recent_movies(&self) -> anyhow::Result> { 296 | let recent_html = CLIENT 297 | .get(format!("{}/home", BASE_URL)) 298 | .send() 299 | .await? 300 | .text() 301 | .await?; 302 | 303 | let results = self.parse_recent_movies(&recent_html); 304 | 305 | Ok(results) 306 | } 307 | 308 | pub async fn recent_shows(&self) -> anyhow::Result> { 309 | let recent_html = CLIENT 310 | .get(format!("{}/home", BASE_URL)) 311 | .send() 312 | .await? 313 | .text() 314 | .await?; 315 | 316 | let results = self.parse_recent_shows(&recent_html); 317 | 318 | Ok(results) 319 | } 320 | 321 | pub async fn trending_movies(&self) -> anyhow::Result> { 322 | let trending_html = CLIENT 323 | .get(format!("{}/home", BASE_URL)) 324 | .send() 325 | .await? 326 | .text() 327 | .await?; 328 | 329 | let results = self.parse_trending_movies(&trending_html); 330 | 331 | Ok(results) 332 | } 333 | 334 | pub async fn trending_shows(&self) -> anyhow::Result> { 335 | let trending_html = CLIENT 336 | .get(format!("{}/home", BASE_URL)) 337 | .send() 338 | .await? 339 | .text() 340 | .await?; 341 | 342 | let results = self.parse_trending_shows(&trending_html); 343 | 344 | Ok(results) 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LOBSTER-RS 2 | 3 | A [`lobster`](https://github.com/justchokingaround/lobster) rewrite in Rust. With a few improvements. 4 | 5 | ## Overview 6 | 7 | - [Installation](#installation) 8 | - [Prerequisites](#prerequisites) 9 | - [Linux](#linux) 10 | - [Android](#android) 11 | - [NixOS (Flake)](#nixos-flake) 12 | - [Mac](#mac) 13 | - [Windows (Git Bash)](#windows-git-bash) 14 | - [Usage](#usage) 15 | - [Clear History](#--clear-history-argument) 16 | - [Download](#-d----download-path-argument) 17 | - [Discord Presence](#-r----rpc-argument) 18 | - [Edit Configuration](#-e----edit-argument) 19 | - [Image Preview](#-i----image-preview-argument) 20 | - [JSON Output](#-j----json-argument) 21 | - [Language Selection](#-l----language-language-argument) 22 | - [Rofi Menu](#--rofi-argument) 23 | - [Provider Selection](#-p----provider-provider-argument) 24 | - [Quality Selection](#-q----quality-quality-argument) 25 | - [No Subtitles](#-n----no-subtitles-argument) 26 | - [Recent Content](#--recent-tvmovie-argument) 27 | - [Syncplay](#-s----syncplay-argument) 28 | - [Trending Content](#-t----trending-tvmovie-argument) 29 | - [Continue Watching](#-c----continue-argument) 30 | - [Update](#-u----update-argument) 31 | - [Version](#-v----version-argument) 32 | - [Debug Mode](#--debug-argument) 33 | - [Configuration](#configuration) 34 | - [Dependencies](#dependencies) 35 | - [Contributing](#contributing) 36 | - [Uninstall](#uninstall) 37 | 38 | ## Installation 39 | 40 | ### Prerequisites 41 | Before you run the installer you'll need the following for it to work: 42 | - [`jq`](https://jqlang.github.io/jq/) 43 | - `unzip` - As most linux distributions don't come with it by default 44 | 45 | #### Linux 46 | 47 | ```sh 48 | curl -sL "https://raw.githubusercontent.com/eatmynerds/lobster-rs/master/install" -o install && \ 49 | chmod +x install && \ 50 | ./install && \ 51 | sudo mv lobster-rs /usr/local/bin/lobster-rs && \ 52 | rm install && \ 53 | echo 'lobster-rs installed successfully! :) \nRun `lobster-rs --help` to get started.' 54 | ``` 55 | 56 | #### Android 57 | 58 | ```sh 59 | curl -sL https://github.com/eatmynerds/lobster-rs/raw/master/install -o install && \ 60 | chmod +x install && \ 61 | ./install && \ 62 | mv lobster-rs /data/data/com.termux/files/usr/bin/lobster-rs 63 | ``` 64 | 65 | If you're using Android 14 or newer make sure to run this before: 66 | ```sh 67 | pkg install termux-am 68 | ``` 69 | 70 | #### Nixos (Flake) 71 | 72 | Add this to your flake.nix 73 | 74 | ```nix 75 | inputs.lobster.url = "github:eatmynerds/lobster-rs"; 76 | ``` 77 | 78 | Add this to your configuration.nix 79 | 80 | ```nix 81 | environment.systemPackages = [ 82 | inputs.lobster.packages..lobster 83 | ]; 84 | ``` 85 | 86 | ##### Or to run the script once use 87 | 88 | ```sh 89 | nix run github:eatmynerds/lobster-rs 90 | ``` 91 | 92 | ##### Nixos (Flake) update 93 | 94 | When encoutering errors first run the nix flake update command in the cloned 95 | project and second add new/missing [dependencies](#dependencies) to the 96 | default.nix file. Use the 97 | [nixos package search](https://search.nixos.org/packages) to find the correct 98 | name. 99 | 100 | ```nix 101 | nix flake update 102 | ``` 103 | 104 | #### Mac 105 | 106 | ```sh 107 | curl -sL https://github.com/eatmynerds/lobster-rs/raw/master/install -o install && \ 108 | chmod +x install && \ 109 | ./install && \ 110 | sudo mv lobster-rs "$(brew --prefix)"/bin/lobster-rs && \ 111 | rm install && \ 112 | echo 'lobster-rs installed successfully! :) \nRun `lobster-rs --help` to get started.' 113 | ``` 114 | 115 | #### Windows (Git Bash) 116 | 117 |
118 | Windows installation instructions 119 | 120 | - This guide covers how to install and use lobster with the windows terminal, 121 | you could also use a different terminal emulator, that supports fzf, like for 122 | example wezterm 123 | - Note that the git bash terminal does _not_ have proper fzf support 124 | 125 | 1. Install scoop 126 | 127 | Open a PowerShell terminal 128 | https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows?view=powershell-7.2#msi 129 | (version 5.1 or later) and run: 130 | 131 | ```ps 132 | Set-ExecutionPolicy RemoteSigned -Scope CurrentUser 133 | irm get.scoop.sh | iex 134 | ``` 135 | 136 | 2. Install git,mpv and fzf 137 | 138 | ```ps 139 | scoop bucket add extras 140 | scoop install git mpv fzf 141 | ``` 142 | 143 | 3. Install windows terminal (you don't need to have a microsoft account for 144 | that) https://learn.microsoft.com/en-us/windows/terminal/install 145 | 146 | 4. Install git bash (select the option to add it to the windows terminal during 147 | installation) https://git-scm.com/download/win 148 | 149 | (The next steps are to be done in the windows terminal, in a bash shell) 150 | 151 | 5. Download the script file to the current directory 152 | 153 | ```sh 154 | curl -sL https://github.com/eatmynerds/lobster-rs/raw/master/install -o install && \ 155 | chmod +x install && \ 156 | ./install && \ 157 | sudo mv lobster-rs /usr/bin/lobster-rs && \ 158 | rm install && \ 159 | echo 'lobster-rs installed successfully! :) \nRun `lobster-rs --help` to get started.' 160 | 161 | ``` 162 | 163 |
164 | 165 | ## Usage 166 | 167 | ```sh 168 | lobster-rs --help 169 | ``` 170 | 171 | Note: 172 | All arguments can be specified in the config file as well. 173 | If an argument is specified in both the config file and the command line, the command line argument will be used. 174 | 175 | Some example usages: 176 | ```sh 177 | lobster-rs -i "a silent voice" --rofi 178 | lobster-rs -l Spanish "fight club" -i -d 179 | lobster-rs -l Spanish "blade runner" --json 180 | ``` 181 | 182 |
183 | Showcase 184 | 185 | ![image](https://github.com/justchokingaround/lobster/assets/44473782/5ed98fb9-008d-4068-a854-577245cfe1ee) 186 | 187 | ![image](https://github.com/justchokingaround/lobster/assets/44473782/cd59329e-a1c8-408a-be48-690db2d52642) 188 | 189 | ![image](https://github.com/justchokingaround/lobster/assets/44473782/fae5ea52-4dc4-41ee-b7a2-cbb2476f5819) 190 | 191 |
192 | 193 | ### `--clear-history` argument 194 | 195 | This argument allows you to delete the history file 196 | 197 | ```sh 198 | lobster-rs --clear-history 199 | ``` 200 | 201 | ### `-d` / `--download` `` argument 202 | 203 | This option lets you use lobster as you normally would, with the exception that 204 | instead of playing the video in your player of choice, it will instead download 205 | the video. If no path is specified when passing this argument, then it will 206 | download to the current working directory, as an example, it would look like 207 | this: 208 | 209 | ```sh 210 | lobster-rs -d . "rick and morty" 211 | ``` 212 | 213 | or 214 | 215 | ```sh 216 | lobster-rs "rick and morty" -d 217 | ``` 218 | 219 | If you want to specify a path to which you would like to download the video, you 220 | can do so by passing an additional parameter to the `-d` or `--download` 221 | argument, for instance: using a full path: 222 | 223 | ```sh 224 | lobster-rs -d "/home/nerds/tv_shows/rick_and_morty/" "rick and morty" 225 | ``` 226 | 227 | or using a relative path: 228 | 229 | ```sh 230 | lobster-rs -d "../rick_and_morty/" "rick and morty" 231 | ``` 232 | 233 | ### `-r` / `--rpc` argument 234 | 235 | By passing this argument you make use of discord rich presence so you can let 236 | your friends know what you are watching. 237 | 238 | ```sh 239 | lobster-rs --rpc 240 | ``` 241 | 242 | ### `-e` / `--edit` argument 243 | 244 | By passing this argument you can edit the config file using an editor of your 245 | choice. By default it will use the editor defined in the `~/.config/lobster-rs/config.toml` 246 | file, but if you don't have one defined, it will use the `$EDITOR` environment 247 | variable (if it's not set, it will default to `vim`). 248 | 249 | ### `-i` / `--image-preview` argument 250 | 251 | By passing this argument you can see image previews when selecting an entry. 252 | 253 | For `rofi` it will work out of the box, if you have icons enabled in your 254 | default configuration. 255 | 256 | Example using my custom rofi configuration (to customize how your rofi image 257 | preview looks, please check the [configuration](#configuration) section) 258 | 259 |
260 | Showcase 261 | 262 | ![image](https://github.com/justchokingaround/lobster/assets/44473782/a8850f00-9491-4f86-939d-2f63bcb36e96) 263 | 264 |
265 | 266 | For `fzf` you will need to install 267 | [chafa](https://github.com/hpjansson/chafa/) 268 | 269 |
270 | Showcase 271 | 272 | ![image](https://github.com/justchokingaround/lobster/assets/44473782/8d8057d8-4d85-4f0e-b6c0-3b7dd5dce557) 273 | 274 |
275 | 276 | Installation instructions for chafa 277 | 278 | On Arch Linux you can install it using your aur helper of choice with: 279 | 280 | ```sh 281 | paru -S chafa 282 | ``` 283 | 284 | ### `-j` / `--json` argument 285 | 286 | By passing this argument, you can output the json for the currently selected 287 | media to stdout, with the decrypted video link. 288 | 289 | ### `-l` / `--language` `` argument 290 | 291 | By passing this argument, you can specify your preferred language for the 292 | subtitles of a video. 293 | Example use case: 294 | 295 | ```sh 296 | lobster-rs "seven" -l Spanish 297 | ``` 298 | 299 | NOTE: The default language is `english`. 300 | 301 | ### `--rofi` argument 302 | 303 | By passing this argument, you can use rofi instead of fzf to interact with the 304 | lobster script. 305 | 306 | This is the recommended way to use lobster, and is a core philosophy of this 307 | script. My use case is that I have a keybind in my WM configuration that calls 308 | lobster, that way I can watch Movies and TV Shows without ever even opening the 309 | terminal. 310 | 311 | Here is an example of that looks like (without image preview): 312 | 313 |
314 | Showcase 315 | 316 | ![image](https://github.com/justchokingaround/lobster/assets/44473782/d1243c17-0ef1-44b3-99a8-f2c4a4ab5da9) 317 | 318 |
319 | 320 | ### `-p` / `--provider` `` argument 321 | 322 | By passing this argument, you can specify a preferred provider. The script 323 | currently supports the following providers: `Upcloud`, `Vidcloud`. 324 | Example use case: 325 | 326 | ```sh 327 | lobster-rs -p Vidcloud "shawshank redemption" 328 | ``` 329 | 330 | ### `-q` / `--quality` `` argument 331 | 332 | By passing this argument, you can specify a preferred quality for the video (if 333 | those are present in the source). If it is not provided as an argument the quality 334 | will default to the highest available one. 335 | 336 | Example use case: 337 | 338 | ```sh 339 | lobster-rs -q 720 "the godfather" 340 | ``` 341 | 342 | ### `-n` / `--no-subtitles` argument 343 | 344 | By passing this argument, you can watch a movie or TV show without subtitles. 345 | 346 | Example use case: 347 | 348 | ```sh 349 | lobster-rs -n "rick and morty" 350 | ``` 351 | 352 | ### `--recent` `` argument 353 | 354 | By passing this argument, you can see watch most recently released movies and TV 355 | shows. You can specify if you want to see movies or TV shows by passing the `tv` 356 | or `movie` parameter. 357 | 358 | Example use case: 359 | 360 | ```sh 361 | lobster-rs --recent tv 362 | ``` 363 | 364 | ### `-s` / `--syncplay` argument 365 | 366 | By passing this argument, you can use [syncplay](https://syncplay.pl/) to watch 367 | videos with your friends. This will only work if you have syncplay installed and 368 | configured. 369 | 370 | ```sh 371 | lobster-rs --syncplay 372 | ``` 373 | 374 | ### `-t` / `--trending` `` argument 375 | 376 | By passing this argument, you can see the most trending movies and TV shows. 377 | 378 | Example use case: 379 | 380 | ```sh 381 | lobster-rs -t movie 382 | ``` 383 | 384 | ### `-c` / `--continue` argument 385 | 386 | This feature is disabled by default because it relies on history, to enable it, 387 | you need to change the following line in your configuration file: 388 | 389 | ```sh 390 | history=true 391 | ``` 392 | 393 | In a similar fashion to how saving your position when you watch videos on 394 | YouTube or Netflix works, lobster has history support and saves the last minute 395 | you watched for a Movie or TV Show episode. To use this feature, simply watch a 396 | Movie or an Episode from a TV Show, and after you quit mpv the history will be 397 | automatically updated. The next time you want to resume from the last position 398 | watched, you can just run 399 | 400 | ```sh 401 | lobster-rs --continue 402 | ``` 403 | 404 | which will prompt you to chose which of the saved Movies/TV Shows you'd like to 405 | resume from. Upon the completion of a movie or an episode, the corresponding 406 | entry is either deleted (in case of a movie, or the last episode of a show), or 407 | it is updated to the next available episode (if it's the last episode of a 408 | season, it will update to the first episode of the next season). 409 | 410 | ### `-u` / `--update` argument 411 | 412 | By passing this argument, you can update the script to the latest version. 413 | 414 | Example use case: 415 | 416 | ```sh 417 | lobster-rs -u 418 | ``` 419 | 420 | ### `-V` / `--version` argument 421 | 422 | By passing this argument, you can see the current version of the script. This is 423 | useful if you want to check if you have the latest version installed. 424 | 425 | ### `--debug` argument 426 | 427 | By passing this argument, you can see the debug output of the script. 428 | 429 | ## Configuration 430 | 431 | Please refer to the 432 | [wiki](https://github.com/eatmynerds/lobster-rs/wiki/Configuration) for 433 | information on how to configure the script using the configuration file. 434 | 435 | ## Dependencies 436 | 437 | - fzf 438 | - mpv 439 | - rofi (external menu) 440 | - vlc (optional) 441 | - chafa (optional) 442 | - ffmpeg (optional) 443 | 444 | ### In case you don't have fzf installed, you can install it like this: 445 | 446 | ```sh 447 | git clone --depth 1 https://github.com/junegunn/fzf.git ~/.fzf 448 | ~/.fzf/install 449 | ``` 450 | ## Contributing 451 | 452 | All contributions are welcome, and I will to review them as soon as possible. If 453 | you want to contribute, please follow the following recommendations: 454 | 455 | - All help is appreciated, even if it's just a typo fix, or a small improvement 456 | - You do not need to be a programmer to contribute, you can also help by opening 457 | issues, or by testing the script and reporting bugs 458 | - If you are unsure about something, please open an issue first, start a 459 | discussion or message me personally 460 | - Please use `cargo fmt` to format your code 461 | - If you are adding a new feature, please make sure that it is configurable 462 | (either through the config file and/or through command line arguments) 463 | 464 | ## Uninstall 465 | ### Linux 466 | 467 | ```sh 468 | sudo rm $(which lobster-rs) 469 | ``` 470 | 471 | ### Mac 472 | 473 | ```sh 474 | rm "$(brew --prefix)"/bin/lobster-rs 475 | ``` 476 | 477 | ### Windows 478 | 479 | ```sh 480 | rm /usr/bin/lobster-rs 481 | ``` 482 | 483 | 484 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use crate::flixhq::flixhq::{FlixHQ, FlixHQInfo}; 2 | use crate::utils::image_preview::remove_desktop_and_tmp; 3 | use crate::utils::{ 4 | config::Config, 5 | { 6 | fzf::FzfArgs, 7 | rofi::{Rofi, RofiArgs, RofiSpawn}, 8 | }, 9 | }; 10 | use crate::{handle_servers, launcher}; 11 | use crate::{Args, MediaType}; 12 | use anyhow::anyhow; 13 | use log::{debug, error, info}; 14 | use std::{io, io::Write, sync::Arc}; 15 | 16 | pub fn get_input(rofi: bool) -> anyhow::Result { 17 | if rofi { 18 | debug!("Using Rofi interface for input."); 19 | 20 | let mut rofi = Rofi::new(); 21 | debug!("Initializing Rofi with arguments."); 22 | 23 | let rofi_output = match rofi.spawn(&mut RofiArgs { 24 | sort: true, 25 | dmenu: true, 26 | case_sensitive: true, 27 | width: Some(1500), 28 | entry_prompt: Some("".to_string()), 29 | mesg: Some("Search Movie/TV Show".to_string()), 30 | ..Default::default() 31 | }) { 32 | Ok(output) => { 33 | debug!("Rofi command executed successfully."); 34 | output 35 | } 36 | Err(e) => { 37 | error!("Failed to execute Rofi command: {}", e); 38 | return Err(e.into()); 39 | } 40 | }; 41 | 42 | let result = String::from_utf8_lossy(&rofi_output.stdout) 43 | .trim() 44 | .to_string(); 45 | 46 | debug!("Rofi returned input: {}", result); 47 | Ok(result) 48 | } else { 49 | debug!("Using terminal input for input."); 50 | 51 | print!("Search Movie/TV Show: "); 52 | if let Err(e) = io::stdout().flush() { 53 | error!("Failed to flush stdout: {}", e); 54 | return Err(e.into()); 55 | } 56 | 57 | let mut input = String::new(); 58 | match io::stdin().read_line(&mut input) { 59 | Ok(_) => { 60 | let result = input.trim().to_string(); 61 | if result.is_empty() { 62 | error!("User input is empty."); 63 | return Err(anyhow::anyhow!("User input is empty.")); 64 | } 65 | debug!("User entered input: {}", result); 66 | Ok(result) 67 | } 68 | Err(e) => { 69 | error!("Failed to read input from stdin: {}", e); 70 | Err(e.into()) 71 | } 72 | } 73 | } 74 | } 75 | 76 | pub async fn run(settings: Arc, config: Arc) -> anyhow::Result<()> { 77 | if settings.clear_history { 78 | let history_file = dirs::data_local_dir() 79 | .expect("Failed to find local dir") 80 | .join("lobster-rs/lobster_history.txt"); 81 | 82 | if history_file.exists() { 83 | std::fs::remove_file(history_file)?; 84 | } 85 | 86 | info!("History file deleted! Exiting..."); 87 | 88 | std::process::exit(0); 89 | } 90 | 91 | if settings.r#continue { 92 | let history_file = dirs::data_local_dir() 93 | .expect("Failed to find local dir") 94 | .join("lobster-rs/lobster_history.txt"); 95 | 96 | if !history_file.exists() { 97 | error!("History file not found!"); 98 | std::process::exit(1) 99 | } 100 | 101 | let history_text = std::fs::read_to_string(history_file).unwrap(); 102 | 103 | let mut history_choices: Vec = vec![]; 104 | let mut history_image_files: Vec<(String, String, String)> = vec![]; 105 | let history_entries = history_text.split("\n").collect::>(); 106 | for (i, history_entry) in history_entries.iter().enumerate() { 107 | if i == history_entries.len() - 1 { 108 | break; 109 | } 110 | 111 | let entries = history_entry.split("\t").collect::>(); 112 | let title = entries[0]; 113 | let media_type = entries[2].split('/').collect::>()[0]; 114 | match media_type { 115 | "tv" => { 116 | let temp_episode = entries[5].replace(":", ""); 117 | 118 | let episode_number = temp_episode 119 | .split_whitespace() 120 | .nth(1) 121 | .expect("Failed to parse episode number from history!"); 122 | 123 | if settings.image_preview { 124 | history_image_files.push(( 125 | format!("{} {} {}", title, entries[4], entries[5]), 126 | entries[6].to_string(), 127 | entries[3].to_string(), 128 | )) 129 | } 130 | 131 | history_choices.push(format!( 132 | "{} (tv) Season {} {}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", 133 | title, 134 | entries[4], 135 | entries[5], 136 | entries[3], 137 | entries[2], 138 | entries[6], 139 | entries[4], 140 | episode_number, 141 | title, 142 | entries[5], 143 | )) 144 | } 145 | "movie" => { 146 | let episode_id = entries[2].rsplit("-").collect::>()[0]; 147 | 148 | if settings.image_preview { 149 | history_image_files.push(( 150 | title.to_string(), 151 | entries[3].to_string(), 152 | entries[2].to_string(), 153 | )) 154 | } 155 | 156 | history_choices.push(format!( 157 | "{} (movie)\t{}\t{}\t{}", 158 | title, episode_id, entries[2], entries[3] 159 | )) 160 | } 161 | _ => {} 162 | } 163 | } 164 | 165 | let history_choice = launcher( 166 | &history_image_files, 167 | settings.rofi, 168 | &mut RofiArgs { 169 | mesg: Some("Choose an entry: ".to_string()), 170 | process_stdin: Some(history_choices.join("\n")), 171 | dmenu: true, 172 | case_sensitive: true, 173 | entry_prompt: Some("".to_string()), 174 | display_columns: Some(1), 175 | ..Default::default() 176 | }, 177 | &mut FzfArgs { 178 | prompt: Some("Choose an entry: ".to_string()), 179 | process_stdin: Some(history_choices.join("\n")), 180 | reverse: true, 181 | with_nth: Some("1".to_string()), 182 | delimiter: Some("\t".to_string()), 183 | ..Default::default() 184 | }, 185 | ) 186 | .await; 187 | 188 | let entry = history_choice.split("\t").collect::>(); 189 | let media_type = entry[2].split('/').collect::>()[0]; 190 | match media_type { 191 | "tv" => { 192 | let show_info = FlixHQ.info(entry[2]).await?; 193 | if let FlixHQInfo::Tv(tv) = show_info { 194 | let season_number = entry[4] 195 | .parse::() 196 | .expect("Failed to parse season number!"); 197 | let episode_number = entry[5] 198 | .parse::() 199 | .expect("Failed to parse episode number!"); 200 | handle_servers( 201 | config.clone(), 202 | settings.clone(), 203 | Some(false), 204 | (Some(entry[7].to_string()), entry[1], entry[2], entry[6], entry[3]), 205 | Some((season_number, episode_number, tv.seasons.episodes)), 206 | ) 207 | .await?; 208 | } 209 | } 210 | "movie" => { 211 | handle_servers( 212 | config.clone(), 213 | settings.clone(), 214 | Some(false), 215 | (None, entry[1], entry[2], entry[0], entry[3]), 216 | None, 217 | ) 218 | .await? 219 | } 220 | _ => {} 221 | } 222 | } 223 | 224 | let results = if let Some(recent) = &settings.recent { 225 | match recent { 226 | MediaType::Movie => FlixHQ.recent_movies().await?, 227 | MediaType::Tv => FlixHQ.recent_shows().await?, 228 | } 229 | } else if let Some(trending) = &settings.trending { 230 | match trending { 231 | MediaType::Movie => FlixHQ.trending_movies().await?, 232 | MediaType::Tv => FlixHQ.trending_shows().await?, 233 | } 234 | } else { 235 | let query = match &settings.query { 236 | Some(query) => query.to_string(), 237 | None => get_input(settings.rofi)?, 238 | }; 239 | 240 | FlixHQ.search(&query).await? 241 | }; 242 | 243 | if results.is_empty() { 244 | return Err(anyhow!("No results found")); 245 | } 246 | 247 | let mut search_results: Vec = vec![]; 248 | let mut image_preview_files: Vec<(String, String, String)> = vec![]; 249 | 250 | for result in results { 251 | match result { 252 | FlixHQInfo::Movie(movie) => { 253 | if settings.image_preview { 254 | image_preview_files.push(( 255 | movie.title.to_string(), 256 | movie.image.to_string(), 257 | movie.id.to_string(), 258 | )); 259 | } 260 | 261 | let formatted_duration = if movie.duration == "N/A" { 262 | "N/A".to_string() 263 | } else { 264 | let movie_duration = movie.duration.replace("m", "").parse::()?; 265 | 266 | if movie_duration >= 60 { 267 | let hours = movie_duration / 60; 268 | let minutes = movie_duration % 60; 269 | format!("{}h{}min", hours, minutes) 270 | } else { 271 | format!("{}m", movie_duration) 272 | } 273 | }; 274 | 275 | search_results.push(format!( 276 | "{}\t{}\t{}\t{} [{}] [{}]", 277 | movie.image, 278 | movie.id, 279 | movie.media_type, 280 | movie.title, 281 | movie.year, 282 | formatted_duration 283 | )); 284 | } 285 | FlixHQInfo::Tv(tv) => { 286 | if settings.image_preview { 287 | image_preview_files.push(( 288 | tv.title.to_string(), 289 | tv.image.to_string(), 290 | tv.id.to_string(), 291 | )); 292 | } 293 | 294 | search_results.push(format!( 295 | "{}\t{}\t{}\t{} [SZNS {}] [EPS {}]", 296 | tv.image, tv.id, tv.media_type, tv.title, tv.seasons.total_seasons, tv.episodes 297 | )); 298 | } 299 | } 300 | } 301 | 302 | let mut media_choice = launcher( 303 | &image_preview_files, 304 | settings.rofi, 305 | &mut RofiArgs { 306 | process_stdin: Some(search_results.join("\n")), 307 | mesg: Some("Choose a movie or TV show".to_string()), 308 | dmenu: true, 309 | case_sensitive: true, 310 | entry_prompt: Some("".to_string()), 311 | display_columns: Some(4), 312 | ..Default::default() 313 | }, 314 | &mut FzfArgs { 315 | process_stdin: Some(search_results.join("\n")), 316 | reverse: true, 317 | with_nth: Some("4,5,6,7".to_string()), 318 | delimiter: Some("\t".to_string()), 319 | header: Some("Choose a movie or TV show".to_string()), 320 | ..Default::default() 321 | }, 322 | ) 323 | .await; 324 | 325 | if settings.image_preview { 326 | for (_, _, media_id) in &image_preview_files { 327 | remove_desktop_and_tmp(media_id.to_string()) 328 | .expect("Failed to remove old .desktop files & tmp images"); 329 | } 330 | } 331 | 332 | if settings.rofi { 333 | for result in search_results { 334 | if result.contains(&media_choice) { 335 | media_choice = result; 336 | break; 337 | } 338 | } 339 | } 340 | 341 | let media_info = media_choice.split("\t").collect::>(); 342 | let media_image = media_info[0]; 343 | let media_id = media_info[1]; 344 | let media_type = media_info[2]; 345 | let media_title = media_info[3].split('[').next().unwrap_or("").trim(); 346 | 347 | if media_type == "tv" { 348 | let show_info = FlixHQ.info(&media_id).await?; 349 | 350 | if let FlixHQInfo::Tv(tv) = show_info { 351 | let mut seasons: Vec = vec![]; 352 | 353 | for season in 0..tv.seasons.total_seasons { 354 | seasons.push(format!("Season {}", season + 1)); 355 | } 356 | 357 | let season_choice = launcher( 358 | &vec![], 359 | settings.rofi, 360 | &mut RofiArgs { 361 | process_stdin: Some(seasons.join("\n")), 362 | mesg: Some("Choose a season".to_string()), 363 | dmenu: true, 364 | case_sensitive: true, 365 | entry_prompt: Some("".to_string()), 366 | ..Default::default() 367 | }, 368 | &mut FzfArgs { 369 | process_stdin: Some(seasons.join("\n")), 370 | reverse: true, 371 | delimiter: Some("\t".to_string()), 372 | header: Some("Choose a season".to_string()), 373 | ..Default::default() 374 | }, 375 | ) 376 | .await; 377 | 378 | let season_number = season_choice.replace("Season ", "").parse::()?; 379 | 380 | let mut episodes: Vec = vec![]; 381 | 382 | for episode in &tv.seasons.episodes[season_number - 1] { 383 | episodes.push(episode.title.to_string()); 384 | } 385 | 386 | let episode_choice = launcher( 387 | &vec![], 388 | settings.rofi, 389 | &mut RofiArgs { 390 | process_stdin: Some(episodes.join("\n")), 391 | mesg: Some("Select an episode:".to_string()), 392 | dmenu: true, 393 | case_sensitive: true, 394 | entry_prompt: Some("".to_string()), 395 | ..Default::default() 396 | }, 397 | &mut FzfArgs { 398 | process_stdin: Some(episodes.join("\n")), 399 | reverse: true, 400 | delimiter: Some("\t".to_string()), 401 | header: Some("Select an episode:".to_string()), 402 | ..Default::default() 403 | }, 404 | ) 405 | .await; 406 | 407 | let episode_choices = &tv.seasons.episodes[season_number - 1]; 408 | 409 | let episode_number = episode_choices 410 | .iter() 411 | .position(|episode| episode.title == episode_choice) 412 | .unwrap_or_else(|| { 413 | error!("Invalid episode choice: '{}'", episode_choice); 414 | std::process::exit(1); 415 | }); 416 | 417 | let episode_info = &tv.seasons.episodes[season_number - 1][episode_number]; 418 | 419 | handle_servers( 420 | config, 421 | settings, 422 | None, 423 | (Some(episode_info.title.clone()), &episode_info.id, media_id, media_title, media_image), 424 | Some((season_number, episode_number, tv.seasons.episodes.clone())), 425 | ) 426 | .await?; 427 | } 428 | } else { 429 | let episode_id = &media_id.rsplit('-').collect::>()[0]; 430 | 431 | handle_servers( 432 | config, 433 | settings, 434 | None, 435 | (None, episode_id, media_id, media_title, media_image), 436 | None, 437 | ) 438 | .await?; 439 | } 440 | 441 | Ok(()) 442 | } 443 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /src/flixhq/html.rs: -------------------------------------------------------------------------------- 1 | use super::flixhq::{ 2 | FlixHQ, FlixHQEpisode, FlixHQInfo, FlixHQMovie, FlixHQResult, FlixHQSeason, FlixHQServer, 3 | FlixHQShow, 4 | }; 5 | use crate::{MediaType, BASE_URL}; 6 | use log::{debug, warn}; 7 | use visdom::types::Elements; 8 | use visdom::Vis; 9 | 10 | fn create_html_fragment(html: &str) -> Elements<'_> { 11 | Vis::load(html).expect("Failed to load HTML") 12 | } 13 | 14 | pub(super) trait FlixHQHTML { 15 | fn parse_recent_shows(&self, html: &str) -> Vec; 16 | fn parse_recent_movies(&self, html: &str) -> Vec; 17 | fn parse_trending_movies(&self, html: &str) -> Vec; 18 | fn parse_trending_shows(&self, html: &str) -> Vec; 19 | fn parse_search(&self, html: &str) -> Vec; 20 | fn single_page(&self, html: &str, id: &str) -> FlixHQResult; 21 | fn season_info(&self, html: &str) -> Vec; 22 | fn episode_info(&self, html: &str) -> Vec; 23 | fn info_server(&self, html: String, media_id: &str) -> Vec; 24 | } 25 | 26 | struct PageElement { 27 | id: String, 28 | image: String, 29 | title: String, 30 | release_date: String, 31 | episode: String, 32 | } 33 | 34 | fn page_elements<'a>(page_parser: &'a Page) -> impl Iterator + use<'a> { 35 | let ids = page_parser.page_ids(); 36 | let images = page_parser.page_images(); 37 | let titles = page_parser.page_titles(); 38 | let release_dates = page_parser.page_release_dates(); 39 | let episodes = page_parser.page_episodes(); 40 | 41 | ids.zip(images) 42 | .zip(titles) 43 | .zip(release_dates) 44 | .zip(episodes) 45 | .map( 46 | |((((id, image), title), release_date), episode)| PageElement { 47 | id, 48 | image, 49 | title, 50 | release_date, 51 | episode, 52 | }, 53 | ) 54 | } 55 | 56 | struct TrendingMovieElement { 57 | id: String, 58 | image: String, 59 | title: String, 60 | release_date: String, 61 | duration: String, 62 | } 63 | 64 | fn trending_movies<'a>( 65 | trending_parser: &'a Trending, 66 | ) -> impl Iterator + use<'a> { 67 | let ids = trending_parser.trending_movie_ids(); 68 | let images = trending_parser.trending_movie_images(); 69 | let titles = trending_parser.trending_movie_titles(); 70 | let release_dates = trending_parser.trending_movie_release_dates(); 71 | let durations = trending_parser.trending_movie_duration(); 72 | 73 | ids.zip(images) 74 | .zip(titles) 75 | .zip(release_dates) 76 | .zip(durations) 77 | .map( 78 | |((((id, image), title), release_date), duration)| TrendingMovieElement { 79 | id, 80 | image, 81 | title, 82 | release_date, 83 | duration, 84 | }, 85 | ) 86 | } 87 | 88 | struct TrendingShowElement { 89 | id: String, 90 | image: String, 91 | title: String, 92 | season: String, 93 | episode: String, 94 | } 95 | 96 | fn trending_shows<'a>( 97 | trending_parser: &'a Trending, 98 | ) -> impl Iterator + use<'a> { 99 | let ids = trending_parser.trending_show_ids(); 100 | let images = trending_parser.trending_show_images(); 101 | let titles = trending_parser.trending_show_titles(); 102 | let seasons = trending_parser.trending_show_seasons(); 103 | let episodes = trending_parser.trending_show_episodes(); 104 | 105 | ids.zip(images).zip(titles).zip(seasons).zip(episodes).map( 106 | |((((id, image), title), season), episode)| TrendingShowElement { 107 | id, 108 | image, 109 | title, 110 | season, 111 | episode, 112 | }, 113 | ) 114 | } 115 | 116 | struct RecentMovieElement { 117 | id: String, 118 | image: String, 119 | title: String, 120 | release_date: String, 121 | duration: String, 122 | } 123 | 124 | fn recent_movies<'a>( 125 | recent_parser: &'a Recent, 126 | ) -> impl Iterator + use<'a> { 127 | let ids = recent_parser.recent_movie_ids(); 128 | let images = recent_parser.recent_movie_images(); 129 | let titles = recent_parser.recent_movie_titles(); 130 | let release_dates = recent_parser.recent_movie_release_dates(); 131 | let durations = recent_parser.recent_movie_durations(); 132 | 133 | ids.zip(images) 134 | .zip(titles) 135 | .zip(release_dates) 136 | .zip(durations) 137 | .map( 138 | |((((id, image), title), release_date), duration)| RecentMovieElement { 139 | id, 140 | image, 141 | title, 142 | release_date, 143 | duration, 144 | }, 145 | ) 146 | } 147 | 148 | struct RecentShowElement { 149 | id: String, 150 | image: String, 151 | title: String, 152 | season: String, 153 | episode: String, 154 | } 155 | 156 | fn recent_shows<'a>( 157 | recent_parser: &'a Recent, 158 | ) -> impl Iterator + use<'a> { 159 | let ids = recent_parser.recent_show_ids(); 160 | let titles = recent_parser.recent_show_titles(); 161 | let images = recent_parser.recent_show_images(); 162 | let seasons = recent_parser.recent_show_seasons(); 163 | let episodes = recent_parser.recent_show_episodes(); 164 | 165 | ids.zip(images).zip(titles).zip(seasons).zip(episodes).map( 166 | |((((id, image), title), season), episode)| RecentShowElement { 167 | id, 168 | image, 169 | title, 170 | season, 171 | episode, 172 | }, 173 | ) 174 | } 175 | 176 | impl FlixHQHTML for FlixHQ { 177 | fn parse_recent_shows(&self, html: &str) -> Vec { 178 | let recent_parser = Recent::new(html); 179 | 180 | let mut results: Vec = vec![]; 181 | for RecentShowElement { 182 | id, 183 | image, 184 | title, 185 | season, 186 | episode, 187 | } in recent_shows(&recent_parser) 188 | { 189 | results.push(FlixHQInfo::Tv(FlixHQShow { 190 | id, 191 | title, 192 | image, 193 | seasons: FlixHQSeason { 194 | total_seasons: season.replace("SS ", "").parse().unwrap_or(0), 195 | episodes: vec![], 196 | }, 197 | episodes: episode.replace("EPS ", "").parse().unwrap_or(0), 198 | media_type: MediaType::Tv, 199 | })); 200 | } 201 | 202 | results 203 | } 204 | 205 | fn parse_recent_movies(&self, html: &str) -> Vec { 206 | let recent_parser = Recent::new(html); 207 | 208 | let mut results: Vec = vec![]; 209 | for RecentMovieElement { 210 | id, 211 | image, 212 | title, 213 | release_date, 214 | duration, 215 | } in recent_movies(&recent_parser) 216 | { 217 | results.push(FlixHQInfo::Movie(FlixHQMovie { 218 | id, 219 | title, 220 | year: release_date, 221 | image, 222 | duration, 223 | media_type: MediaType::Movie, 224 | })); 225 | } 226 | 227 | results 228 | } 229 | 230 | fn parse_trending_movies(&self, html: &str) -> Vec { 231 | let trending_parser = Trending::new(html); 232 | 233 | let mut results: Vec = vec![]; 234 | for TrendingMovieElement { 235 | id, 236 | image, 237 | title, 238 | release_date, 239 | duration, 240 | } in trending_movies(&trending_parser) 241 | { 242 | results.push(FlixHQInfo::Movie(FlixHQMovie { 243 | id, 244 | title, 245 | year: release_date, 246 | image, 247 | duration, 248 | media_type: MediaType::Movie, 249 | })); 250 | } 251 | 252 | results 253 | } 254 | 255 | fn parse_trending_shows(&self, html: &str) -> Vec { 256 | let trending_parser = Trending::new(html); 257 | 258 | let mut results: Vec = vec![]; 259 | for TrendingShowElement { 260 | id, 261 | image, 262 | title, 263 | season, 264 | episode, 265 | } in trending_shows(&trending_parser) 266 | { 267 | results.push(FlixHQInfo::Tv(FlixHQShow { 268 | id, 269 | title, 270 | image, 271 | seasons: FlixHQSeason { 272 | total_seasons: season.replace("SS ", "").parse().unwrap_or(0), 273 | episodes: vec![], 274 | }, 275 | episodes: episode.replace("EPS ", "").parse().unwrap_or(0), 276 | media_type: MediaType::Tv, 277 | })); 278 | } 279 | 280 | results 281 | } 282 | 283 | fn parse_search(&self, html: &str) -> Vec { 284 | debug!("Parsing search results from HTML."); 285 | let page_parser = Page::new(html); 286 | 287 | let mut results: Vec = vec![]; 288 | for PageElement { 289 | id, 290 | image, 291 | title, 292 | release_date, 293 | episode, 294 | } in page_elements(&page_parser) 295 | { 296 | debug!("Processing media item: ID = {}, Title = {}", id, title); 297 | let media_type = page_parser.media_type(&id); 298 | 299 | match media_type { 300 | Some(MediaType::Tv) => { 301 | debug!("Identified as TV show."); 302 | results.push(FlixHQInfo::Tv(FlixHQShow { 303 | id, 304 | title, 305 | image, 306 | seasons: FlixHQSeason { 307 | total_seasons: release_date.replace("SS ", "").parse().unwrap_or(0), 308 | episodes: vec![], 309 | }, 310 | episodes: episode.replace("EPS ", "").parse().unwrap_or(0), 311 | media_type: MediaType::Tv, 312 | })); 313 | } 314 | Some(MediaType::Movie) => { 315 | debug!("Identified as Movie."); 316 | results.push(FlixHQInfo::Movie(FlixHQMovie { 317 | id, 318 | title, 319 | year: release_date, 320 | image, 321 | duration: episode, 322 | media_type: MediaType::Movie, 323 | })); 324 | } 325 | None => warn!("Unknown media type for ID = {}", id), 326 | } 327 | } 328 | 329 | debug!("Parsed {} results.", results.len()); 330 | results 331 | } 332 | 333 | fn single_page(&self, html: &str, id: &str) -> FlixHQResult { 334 | debug!("Parsing single page for ID = {}", id); 335 | let elements = create_html_fragment(html); 336 | let search_parser = Search::new(&elements); 337 | let info_parser = Info::new(&elements); 338 | 339 | let result = FlixHQResult { 340 | title: search_parser.title(), 341 | image: search_parser.image(), 342 | year: info_parser.label(3, "Released:").join(""), 343 | duration: info_parser.duration(), 344 | media_type: Some(MediaType::Tv), 345 | id: id.to_string(), 346 | }; 347 | 348 | debug!("Parsed single page result: {:?}", result); 349 | result 350 | } 351 | 352 | fn season_info(&self, html: &str) -> Vec { 353 | debug!("Extracting season information."); 354 | let season_parser = Season::new(html); 355 | 356 | let seasons: Vec = season_parser 357 | .season_results() 358 | .into_iter() 359 | .flatten() 360 | .collect(); 361 | 362 | debug!("Extracted {} seasons.", seasons.len()); 363 | seasons 364 | } 365 | 366 | fn episode_info(&self, html: &str) -> Vec { 367 | debug!("Extracting episode information."); 368 | let episode_parser = Episode::new(html); 369 | 370 | let episodes = episode_parser.episode_results(); 371 | debug!("Extracted {} episodes.", episodes.len()); 372 | episodes 373 | } 374 | 375 | fn info_server(&self, html: String, media_id: &str) -> Vec { 376 | debug!("Extracting server information for media ID = {}", media_id); 377 | let server_parser = Server::new(&html); 378 | let servers = server_parser.parse_server_html(media_id); 379 | 380 | debug!("Extracted {} servers.", servers.len()); 381 | servers 382 | } 383 | } 384 | 385 | struct Page<'a> { 386 | elements: Elements<'a>, 387 | } 388 | 389 | impl<'a> Page<'a> { 390 | fn new(html: &'a str) -> Self { 391 | let elements = create_html_fragment(html); 392 | Self { elements } 393 | } 394 | 395 | fn page_ids(&self) -> impl Iterator + use<'a> { 396 | self.elements 397 | .find("div.film-poster > a") 398 | .into_iter() 399 | .filter_map(|element| { 400 | element 401 | .get_attribute("href") 402 | .and_then(|href| href.to_string().strip_prefix('/').map(String::from)) 403 | }) 404 | } 405 | 406 | fn page_images(&self) -> impl Iterator + use<'a> { 407 | self.elements 408 | .find("div.film-poster > img") 409 | .into_iter() 410 | .filter_map(|element| { 411 | element 412 | .get_attribute("data-src") 413 | .map(|value| value.to_string()) 414 | }) 415 | } 416 | 417 | fn page_titles(&self) -> impl Iterator + use<'a> { 418 | self.elements 419 | .find("div.film-detail > h2.film-name > a") 420 | .into_iter() 421 | .filter_map(|element| { 422 | element 423 | .get_attribute("title") 424 | .map(|value| value.to_string()) 425 | }) 426 | } 427 | 428 | fn page_release_dates(&self) -> impl Iterator + use<'a> { 429 | self.elements 430 | .find("div.fd-infor > span:nth-child(1)") 431 | .into_iter() 432 | .map(|element| element.text()) 433 | } 434 | 435 | fn page_episodes(&self) -> impl Iterator + use<'a> { 436 | self.elements 437 | .find("div.fd-infor > span:nth-child(3)") 438 | .into_iter() 439 | .map(|element| element.text()) 440 | } 441 | 442 | fn media_type(&self, id: &str) -> Option { 443 | match id.split('/').next() { 444 | Some("tv") => Some(MediaType::Tv), 445 | Some("movie") => Some(MediaType::Movie), 446 | _ => None, 447 | } 448 | } 449 | } 450 | 451 | #[derive(Clone, Copy)] 452 | struct Search<'b> { 453 | elements: &'b Elements<'b>, 454 | } 455 | 456 | impl<'b> Search<'b> { 457 | fn new(elements: &'b Elements<'b>) -> Self { 458 | Self { elements } 459 | } 460 | 461 | fn image(&self) -> String { 462 | let image_attr = self 463 | .elements 464 | .find("div.m_i-d-poster > div > img") 465 | .attr("src"); 466 | 467 | if let Some(image) = image_attr { 468 | return image.to_string(); 469 | }; 470 | 471 | String::new() 472 | } 473 | 474 | fn title(&self) -> String { 475 | self.elements 476 | .find( 477 | "#main-wrapper > div.movie_information > div > div.m_i-detail > div.m_i-d-content > h2", 478 | ) 479 | .text() 480 | .trim() 481 | .to_owned() 482 | } 483 | } 484 | 485 | /// Remy clarke was here & some red guy 486 | #[derive(Clone, Copy)] 487 | struct Info<'b> { 488 | elements: &'b Elements<'b>, 489 | } 490 | 491 | impl<'b> Info<'b> { 492 | fn new(elements: &'b Elements<'b>) -> Self { 493 | Self { elements } 494 | } 495 | 496 | fn label(&self, index: usize, label: &str) -> Vec { 497 | self.elements 498 | .find(&format!( 499 | "div.m_i-d-content > div.elements > div:nth-child({})", 500 | index 501 | )) 502 | .text() 503 | .replace(label, "") 504 | .split(',') 505 | .map(|s| s.trim().to_owned()) 506 | .filter(|x| !x.is_empty()) 507 | .collect() 508 | } 509 | 510 | pub fn duration(&self) -> String { 511 | self.elements 512 | .find("span.item:nth-child(3)") 513 | .text() 514 | .trim() 515 | .to_owned() 516 | } 517 | } 518 | 519 | struct Season<'a> { 520 | elements: Elements<'a>, 521 | } 522 | 523 | impl<'a> Season<'a> { 524 | fn new(html: &'a str) -> Self { 525 | let elements = create_html_fragment(html); 526 | Self { elements } 527 | } 528 | 529 | fn season_results(&self) -> Vec> { 530 | self.elements.find(".dropdown-menu > a").map(|_, element| { 531 | element 532 | .get_attribute("data-id") 533 | .map(|value| value.to_string()) 534 | }) 535 | } 536 | } 537 | 538 | struct Episode<'a> { 539 | elements: Elements<'a>, 540 | } 541 | 542 | impl<'a> Episode<'a> { 543 | fn new(html: &'a str) -> Self { 544 | let elements = create_html_fragment(html); 545 | Self { elements } 546 | } 547 | 548 | fn episode_title(&self) -> Vec> { 549 | self.elements.find("ul > li > a").map(|_, element| { 550 | element 551 | .get_attribute("title") 552 | .map(|value| value.to_string()) 553 | }) 554 | } 555 | 556 | fn episode_id(&self) -> Vec> { 557 | self.elements.find("ul > li > a").map(|_, element| { 558 | element 559 | .get_attribute("data-id") 560 | .map(|value| value.to_string()) 561 | }) 562 | } 563 | 564 | fn episode_results(&self) -> Vec { 565 | let episode_titles = self.episode_title(); 566 | let episode_ids = self.episode_id(); 567 | 568 | let mut episodes: Vec = vec![]; 569 | 570 | for (id, title) in episode_ids.iter().zip(episode_titles.iter()) { 571 | if let Some(id) = id { 572 | episodes.push(FlixHQEpisode { 573 | id: id.to_string(), 574 | title: title.as_deref().unwrap_or("").to_string(), 575 | }); 576 | } 577 | } 578 | 579 | episodes 580 | } 581 | } 582 | 583 | struct Server<'a> { 584 | elements: Elements<'a>, 585 | } 586 | 587 | impl<'a> Server<'a> { 588 | fn new(html: &'a str) -> Self { 589 | let elements = create_html_fragment(html); 590 | Self { elements } 591 | } 592 | 593 | fn parse_server_html(&self, media_id: &str) -> Vec { 594 | self.elements.find("ul > li > a").map(|_, element| { 595 | let id = element 596 | .get_attribute("id") 597 | .map(|value| value.to_string().replace("watch-", "")) 598 | .unwrap_or(String::from("")); 599 | 600 | let name = element 601 | .get_attribute("title") 602 | .map(|value| value.to_string().trim_start_matches("Server ").to_owned()); 603 | 604 | let url = format!("{}/watch-{}.{}", BASE_URL, media_id, id); 605 | let name = name.unwrap_or(String::from("")); 606 | 607 | FlixHQServer { name, url } 608 | }) 609 | } 610 | } 611 | 612 | struct Recent<'a> { 613 | elements: Elements<'a>, 614 | } 615 | 616 | impl<'a> Recent<'a> { 617 | fn new(html: &'a str) -> Self { 618 | let elements = create_html_fragment(html); 619 | Self { elements } 620 | } 621 | fn recent_movie_ids(&self) -> impl Iterator + use<'a> { 622 | self.elements 623 | .find("#main-wrapper > div > section:nth-child(6) > div.block_area-content.block_area-list.film_list.film_list-grid > div > div.flw-item > div.film-poster > a") 624 | .into_iter() 625 | .filter_map(|element| { 626 | element 627 | .get_attribute("href") 628 | .and_then(|href| href.to_string().strip_prefix('/').map(String::from)) 629 | }) 630 | } 631 | 632 | fn recent_movie_images(&self) -> impl Iterator + use<'a> { 633 | self.elements 634 | .find("#main-wrapper > div > section:nth-child(6) > div.block_area-content.block_area-list.film_list.film_list-grid > div > div.flw-item > div.film-poster > img") 635 | .into_iter() 636 | .filter_map(|element| { 637 | element 638 | .get_attribute("data-src") 639 | .map(|value| value.to_string()) 640 | }) 641 | } 642 | 643 | fn recent_movie_titles(&self) -> impl Iterator + use<'a> { 644 | self.elements 645 | .find("#main-wrapper > div > section:nth-child(6) > div.block_area-content.block_area-list.film_list.film_list-grid > div > div.flw-item > div.film-detail > h3.film-name > a") 646 | .into_iter() 647 | .filter_map(|element| { 648 | element 649 | .get_attribute("title") 650 | .map(|value| value.to_string()) 651 | }) 652 | } 653 | 654 | fn recent_movie_release_dates(&self) -> impl Iterator + use<'a> { 655 | self.elements 656 | .find("#main-wrapper > div > section:nth-child(6) > div.block_area-content.block_area-list.film_list.film_list-grid > div > div.flw-item > div.film-detail > div.fd-infor > span:nth-child(1)") 657 | .into_iter() 658 | .map(|value| value.text()) 659 | } 660 | 661 | fn recent_movie_durations(&self) -> impl Iterator + use<'a> { 662 | self.elements 663 | .find("#main-wrapper > div > section:nth-child(6) > div.block_area-content.block_area-list.film_list.film_list-grid > div > div.flw-item > div.film-detail > div.fd-infor > span:nth-child(3)") 664 | .into_iter() 665 | .map(|value| value.text()) 666 | } 667 | 668 | fn recent_show_ids(&self) -> impl Iterator + use<'a> { 669 | self.elements 670 | .find("#main-wrapper > div > section:nth-child(7) > div.block_area-content.block_area-list.film_list.film_list-grid > div > div.flw-item > div.film-poster > a") 671 | .into_iter() 672 | .filter_map(|element| { 673 | element 674 | .get_attribute("href") 675 | .and_then(|href| href.to_string().strip_prefix('/').map(String::from)) 676 | }) 677 | } 678 | 679 | fn recent_show_titles(&self) -> impl Iterator + use<'a> { 680 | self.elements 681 | .find("#main-wrapper > div > section:nth-child(7) > div.block_area-content.block_area-list.film_list.film_list-grid > div > div.flw-item > div.film-detail > h3.film-name > a") 682 | .into_iter() 683 | .filter_map(|element| { 684 | element 685 | .get_attribute("title") 686 | .map(|value| value.to_string()) 687 | }) 688 | } 689 | 690 | fn recent_show_images(&self) -> impl Iterator + use<'a> { 691 | self.elements 692 | .find("#main-wrapper > div > section:nth-child(7) > div.block_area-content.block_area-list.film_list.film_list-grid > div > div.flw-item > div.film-poster > img") 693 | .into_iter() 694 | .filter_map(|element| { 695 | element 696 | .get_attribute("data-src") 697 | .map(|value| value.to_string()) 698 | }) 699 | } 700 | 701 | fn recent_show_episodes(&self) -> impl Iterator + use<'a> { 702 | self.elements 703 | .find("#main-wrapper > div > section:nth-child(7) > div.block_area-content.block_area-list.film_list.film_list-grid > div > div.flw-item > div.film-detail > div.fd-infor > span:nth-child(3)") 704 | .into_iter() 705 | .map(|value| value.text()) 706 | } 707 | 708 | fn recent_show_seasons(&self) -> impl Iterator + use<'a> { 709 | self.elements 710 | .find("#main-wrapper > div > section:nth-child(7) > div.block_area-content.block_area-list.film_list.film_list-grid > div > div.flw-item > div.film-detail > div.fd-infor > span:nth-child(1)") 711 | .into_iter() 712 | .map(|value| value.text()) 713 | } 714 | } 715 | 716 | struct Trending<'a> { 717 | elements: Elements<'a>, 718 | } 719 | 720 | impl<'a> Trending<'a> { 721 | fn new(html: &'a str) -> Self { 722 | let elements = create_html_fragment(html); 723 | Self { elements } 724 | } 725 | fn trending_movie_ids(&self) -> impl Iterator + use<'a> { 726 | self.elements 727 | .find("div#trending-movies div.film_list-wrap div.flw-item div.film-poster a") 728 | .into_iter() 729 | .filter_map(|element| { 730 | element 731 | .get_attribute("href") 732 | .and_then(|href| href.to_string().strip_prefix('/').map(String::from)) 733 | }) 734 | } 735 | 736 | fn trending_movie_images(&self) -> impl Iterator + use<'a> { 737 | self.elements 738 | .find("div#trending-movies div.film_list-wrap div.flw-item div.film-poster > img") 739 | .into_iter() 740 | .filter_map(|element| { 741 | element 742 | .get_attribute("data-src") 743 | .map(|value| value.to_string()) 744 | }) 745 | } 746 | 747 | fn trending_movie_release_dates(&self) -> impl Iterator + use<'a> { 748 | self.elements 749 | .find("div#trending-movies div.film_list-wrap div.flw-item > div.film-detail > div.fd-infor > span:nth-child(1)") 750 | .into_iter() 751 | .map(|value| value.text()) 752 | } 753 | 754 | fn trending_movie_titles(&self) -> impl Iterator + use<'a> { 755 | self.elements 756 | .find("div#trending-movies div.film_list-wrap div.flw-item > div.film-detail > h3.film-name > a") 757 | .into_iter() 758 | .filter_map(|element| { 759 | element 760 | .get_attribute("title") 761 | .map(|value| value.to_string()) 762 | }) 763 | } 764 | 765 | fn trending_movie_duration(&self) -> impl Iterator + use<'a> { 766 | self.elements 767 | .find("div#trending-movies div.film_list-wrap div.flw-item > div.film-detail > div.fd-infor > span:nth-child(3)") 768 | .into_iter() 769 | .map(|value| value.text()) 770 | } 771 | 772 | fn trending_show_ids(&self) -> impl Iterator + use<'a> { 773 | self.elements 774 | .find("div#trending-tv div.film_list-wrap div.flw-item div.film-poster a") 775 | .into_iter() 776 | .filter_map(|element| { 777 | element 778 | .get_attribute("href") 779 | .and_then(|href| href.to_string().strip_prefix('/').map(String::from)) 780 | }) 781 | } 782 | 783 | fn trending_show_images(&self) -> impl Iterator + use<'a> { 784 | self.elements 785 | .find("div#trending-tv div.film_list-wrap div.flw-item div.film-poster > img") 786 | .into_iter() 787 | .filter_map(|element| { 788 | element 789 | .get_attribute("data-src") 790 | .map(|value| value.to_string()) 791 | }) 792 | } 793 | 794 | fn trending_show_seasons(&self) -> impl Iterator + use<'a> { 795 | self.elements 796 | .find("div#trending-tv div.film_list-wrap div.flw-item > div.film-detail > div.fd-infor > span:nth-child(1)") 797 | .into_iter() 798 | .map(|value| value.text()) 799 | } 800 | 801 | fn trending_show_titles(&self) -> impl Iterator + use<'a> { 802 | self.elements 803 | .find("div#trending-tv div.film_list-wrap div.flw-item > div.film-detail > h3.film-name > a") 804 | .into_iter() 805 | .filter_map(|element| { 806 | element 807 | .get_attribute("title") 808 | .map(|value| value.to_string()) 809 | }) 810 | } 811 | 812 | fn trending_show_episodes(&self) -> impl Iterator + use<'a> { 813 | self.elements 814 | .find("div#trending-tv div.film_list-wrap div.flw-item > div.film-detail > div.fd-infor > span:nth-child(3)") 815 | .into_iter() 816 | .map(|value| value.text()) 817 | } 818 | } 819 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use clap::{Parser, ValueEnum}; 3 | use futures::future::{BoxFuture, FutureExt}; 4 | use futures::StreamExt; 5 | use lazy_static::lazy_static; 6 | use log::{debug, error, info, warn, LevelFilter}; 7 | use regex::Regex; 8 | use reqwest::Client; 9 | use self_update::cargo_crate_version; 10 | use serde::{Deserialize, Serialize}; 11 | use std::{ 12 | fmt::{self, Debug, Display, Formatter}, 13 | num::ParseIntError, 14 | process::Command, 15 | str::FromStr, 16 | sync::Arc, 17 | }; 18 | use utils::history::{save_history, save_progress}; 19 | use utils::image_preview::remove_desktop_and_tmp; 20 | use utils::presence::discord_presence; 21 | use utils::SpawnError; 22 | use serde_json::json; 23 | 24 | mod cli; 25 | use cli::run; 26 | mod flixhq; 27 | use flixhq::flixhq::{FlixHQ, FlixHQEpisode, FlixHQSourceType, FlixHQSubtitles}; 28 | mod providers; 29 | mod utils; 30 | use utils::{ 31 | config::Config, 32 | ffmpeg::{Ffmpeg, FfmpegArgs, FfmpegSpawn}, 33 | fzf::{Fzf, FzfArgs, FzfSpawn}, 34 | image_preview::{generate_desktop, image_preview}, 35 | players::{ 36 | celluloid::{Celluloid, CelluloidArgs, CelluloidPlay}, 37 | iina::{Iina, IinaArgs, IinaPlay}, 38 | mpv::{Mpv, MpvArgs, MpvPlay}, 39 | vlc::{Vlc, VlcArgs, VlcPlay}, 40 | }, 41 | rofi::{Rofi, RofiArgs, RofiSpawn}, 42 | }; 43 | 44 | pub static BASE_URL: &'static str = "https://flixhq.to"; 45 | 46 | lazy_static! { 47 | static ref CLIENT: Client = Client::new(); 48 | } 49 | 50 | #[derive(ValueEnum, Debug, Clone, Serialize, Deserialize)] 51 | #[clap(rename_all = "kebab-case")] 52 | pub enum MediaType { 53 | Tv, 54 | Movie, 55 | } 56 | 57 | impl Display for MediaType { 58 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 59 | match self { 60 | MediaType::Tv => write!(f, "tv"), 61 | MediaType::Movie => write!(f, "movie"), 62 | } 63 | } 64 | } 65 | 66 | #[derive(Debug)] 67 | pub enum Player { 68 | Vlc, 69 | Mpv, 70 | Iina, 71 | Celluloid, 72 | MpvAndroid, 73 | SyncPlay, 74 | } 75 | 76 | #[derive(ValueEnum, Clone, Debug, Serialize, Deserialize, Copy, PartialEq)] 77 | #[clap(rename_all = "PascalCase")] 78 | pub enum Provider { 79 | Vidcloud, 80 | Upcloud, 81 | } 82 | 83 | impl Display for Provider { 84 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 85 | match self { 86 | Provider::Vidcloud => write!(f, "Vidcloud"), 87 | Provider::Upcloud => write!(f, "Upcloud"), 88 | } 89 | } 90 | } 91 | 92 | #[derive(ValueEnum, Debug, Clone, Copy)] 93 | pub enum Quality { 94 | #[clap(name = "360")] 95 | Q360 = 360, 96 | #[clap(name = "720")] 97 | Q720 = 720, 98 | #[clap(name = "1080")] 99 | Q1080 = 1080, 100 | } 101 | 102 | #[derive(thiserror::Error, Debug)] 103 | pub enum StreamError { 104 | #[error("Failed to parse quality from string: {0}")] 105 | QualityParseError(#[from] ParseIntError), 106 | } 107 | 108 | impl FromStr for Quality { 109 | type Err = StreamError; 110 | 111 | fn from_str(s: &str) -> Result { 112 | let quality = s.parse::()?; 113 | Ok(match quality { 114 | 0..=600 => Quality::Q360, 115 | 601..=840 => Quality::Q720, 116 | 841..=1200 => Quality::Q1080, 117 | _ => Quality::Q1080, 118 | }) 119 | } 120 | } 121 | 122 | impl Quality { 123 | fn to_u32(self) -> u32 { 124 | self as u32 125 | } 126 | } 127 | 128 | impl Display for Quality { 129 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 130 | write!(f, "{}", self.to_u32()) 131 | } 132 | } 133 | 134 | #[derive(ValueEnum, Debug, Clone, Serialize, Deserialize, Copy)] 135 | #[clap(rename_all = "PascalCase")] 136 | pub enum Languages { 137 | Arabic, 138 | Turkish, 139 | Danish, 140 | Dutch, 141 | English, 142 | Finnish, 143 | German, 144 | Italian, 145 | Russian, 146 | Spanish, 147 | } 148 | 149 | impl Display for Languages { 150 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 151 | match self { 152 | Languages::Arabic => write!(f, "Arabic"), 153 | Languages::Turkish => write!(f, "Turkish"), 154 | Languages::Danish => write!(f, "Danish"), 155 | Languages::Dutch => write!(f, "Dutch"), 156 | Languages::English => write!(f, "English"), 157 | Languages::Finnish => write!(f, "Finnish"), 158 | Languages::German => write!(f, "German"), 159 | Languages::Italian => write!(f, "Italian"), 160 | Languages::Russian => write!(f, "Russian"), 161 | Languages::Spanish => write!(f, "Spanish"), 162 | } 163 | } 164 | } 165 | 166 | #[derive(Parser, Debug, Clone, Default)] 167 | #[clap(author, version, about = "A media streaming CLI tool", long_about = None)] 168 | pub struct Args { 169 | /// The search query or title to look for 170 | #[clap(value_parser)] 171 | pub query: Option, 172 | 173 | /// Deletes the history file 174 | #[clap(long)] 175 | pub clear_history: bool, 176 | 177 | /// Continue watching from current history 178 | #[clap(short, long)] 179 | pub r#continue: bool, 180 | 181 | /// Downloads movie or episode that is selected (defaults to current directory) 182 | #[clap(short, long)] 183 | pub download: Option>, 184 | 185 | /// Enables discord rich presence (beta feature, works fine on Linux) 186 | #[clap(short, long)] 187 | pub rpc: bool, 188 | 189 | /// Edit config file using an editor defined with lobster_editor in the config ($EDITOR by default) 190 | #[clap(short, long)] 191 | pub edit: bool, 192 | 193 | /// Shows image previews during media selection 194 | #[clap(short, long)] 195 | pub image_preview: bool, 196 | 197 | /// Outputs JSON containing video links, subtitle links, etc. 198 | #[clap(short, long)] 199 | pub json: bool, 200 | 201 | /// Specify the subtitle language 202 | #[clap(short, long, value_enum)] 203 | pub language: Option, 204 | 205 | /// Use rofi instead of fzf 206 | #[clap(long)] 207 | pub rofi: bool, 208 | 209 | /// Specify the provider to watch from 210 | #[clap(short, long, value_enum)] 211 | pub provider: Option, 212 | 213 | /// Specify the video quality (defaults to the highest possible quality) 214 | #[clap(short, long, value_enum)] 215 | pub quality: Option, 216 | 217 | /// Lets you select from the most recent movies or TV shows 218 | #[clap(long, value_enum)] 219 | pub recent: Option, 220 | 221 | /// Use Syncplay to watch with friends 222 | #[clap(short, long)] 223 | pub syncplay: bool, 224 | 225 | /// Lets you select from the most popular movies or TV shows 226 | #[clap(short, long, value_enum)] 227 | pub trending: Option, 228 | 229 | /// Update the script 230 | #[clap(short, long)] 231 | pub update: bool, 232 | 233 | /// Enable debug mode (prints debug info to stdout and saves it to $TEMPDIR/lobster.log) 234 | #[clap(long)] 235 | pub debug: bool, 236 | 237 | /// Disable subtitles 238 | #[clap(short, long)] 239 | pub no_subs: bool, 240 | } 241 | 242 | fn fzf_launcher<'a>(args: &'a mut FzfArgs) -> anyhow::Result { 243 | debug!("Launching fzf with arguments: {:?}", args); 244 | 245 | let mut fzf = Fzf::new(); 246 | 247 | let output = fzf 248 | .spawn(args) 249 | .map(|output| { 250 | let result = String::from_utf8_lossy(&output.stdout).trim().to_string(); 251 | debug!("fzf completed with result: {}", result); 252 | result 253 | }) 254 | .unwrap_or_else(|e| { 255 | error!("Failed to launch fzf: {}", e.to_string()); 256 | std::process::exit(1) 257 | }); 258 | 259 | if output.is_empty() { 260 | return Err(anyhow!("No selection made. Exiting...")); 261 | } 262 | 263 | Ok(output) 264 | } 265 | 266 | fn rofi_launcher<'a>(args: &'a mut RofiArgs) -> anyhow::Result { 267 | debug!("Launching rofi with arguments: {:?}", args); 268 | 269 | let mut rofi = Rofi::new(); 270 | 271 | let output = rofi 272 | .spawn(args) 273 | .map(|output| { 274 | let result = String::from_utf8_lossy(&output.stdout).trim().to_string(); 275 | debug!("rofi completed with result: {}", result); 276 | result 277 | }) 278 | .unwrap_or_else(|e| { 279 | error!("Failed to launch rofi: {}", e.to_string()); 280 | std::process::exit(1) 281 | }); 282 | 283 | if output.is_empty() { 284 | return Err(anyhow!("No selection made. Exiting...")); 285 | } 286 | 287 | Ok(output) 288 | } 289 | 290 | async fn launcher( 291 | image_preview_files: &Vec<(String, String, String)>, 292 | rofi: bool, 293 | rofi_args: &mut RofiArgs, 294 | fzf_args: &mut FzfArgs, 295 | ) -> String { 296 | if image_preview_files.is_empty() { 297 | debug!("No image preview files provided."); 298 | } else { 299 | debug!( 300 | "Generating image previews for {} files.", 301 | image_preview_files.len() 302 | ); 303 | let temp_images_dirs = image_preview(image_preview_files) 304 | .await 305 | .expect("Failed to generate image previews"); 306 | 307 | if rofi { 308 | for (media_name, media_id, image_path) in temp_images_dirs { 309 | debug!( 310 | "Generating desktop entry for: {} (ID: {})", 311 | media_name, media_id 312 | ); 313 | generate_desktop(media_name, media_id, image_path) 314 | .expect("Failed to generate desktop entry for image preview"); 315 | } 316 | 317 | rofi_args.show = Some("drun".to_string()); 318 | rofi_args.drun_categories = Some("imagepreview".to_string()); 319 | rofi_args.show_icons = true; 320 | rofi_args.dmenu = false; 321 | } else { 322 | match std::process::Command::new("chafa").arg("-v").output() { 323 | Ok(_) => { 324 | debug!("Setting up fzf preview script."); 325 | 326 | fzf_args.preview = Some( 327 | r#" 328 | set -l selected (echo {} | cut -f2 | sed 's/\//-/g') 329 | chafa -f sixels -s 80x40 "/tmp/images/$selected.jpg" 330 | "# 331 | .to_string(), 332 | ); 333 | } 334 | Err(_) => { 335 | warn!("Chafa isn't installed. Cannot preview images with fzf."); 336 | } 337 | } 338 | } 339 | } 340 | 341 | if rofi { 342 | debug!("Using rofi launcher."); 343 | match rofi_launcher(rofi_args) { 344 | Ok(output) => output, 345 | Err(_) => { 346 | if !image_preview_files.is_empty() { 347 | for (_, _, media_id) in image_preview_files { 348 | remove_desktop_and_tmp(media_id.to_string()) 349 | .expect("Failed to remove old .desktop files & tmp images"); 350 | } 351 | } 352 | 353 | std::process::exit(1) 354 | } 355 | } 356 | } else { 357 | debug!("Using fzf launcher."); 358 | match fzf_launcher(fzf_args) { 359 | Ok(output) => output, 360 | Err(_) => { 361 | if !image_preview_files.is_empty() { 362 | for (_, _, media_id) in image_preview_files { 363 | remove_desktop_and_tmp(media_id.to_string()) 364 | .expect("Failed to remove old .desktop files & tmp images"); 365 | } 366 | } 367 | 368 | std::process::exit(1) 369 | } 370 | } 371 | } 372 | } 373 | 374 | async fn download( 375 | download_dir: String, 376 | media_title: String, 377 | url: String, 378 | subtitles: Option>, 379 | subtitle_language: Option, 380 | ) -> anyhow::Result<()> { 381 | info!("{}", format!(r#"Starting download for "{}""#, media_title)); 382 | 383 | let ffmpeg = Ffmpeg::new(); 384 | 385 | ffmpeg.embed_video(FfmpegArgs { 386 | input_file: url, 387 | log_level: Some("error".to_string()), 388 | stats: true, 389 | output_file: format!("{}/{}.mkv", download_dir, media_title), 390 | subtitle_files: subtitles.as_ref(), 391 | subtitle_language: Some(subtitle_language.unwrap_or(Languages::English).to_string()), 392 | codec: Some("copy".to_string()), 393 | })?; 394 | 395 | Ok(()) 396 | } 397 | 398 | fn update() -> anyhow::Result<()> { 399 | let target = self_update::get_target(); 400 | 401 | let target_arch = match target { 402 | "x86_64-unknown-linux-gnu" => "x86_64-unknown-linux-gnu_lobster-rs", 403 | "aarch64-unknown-linux-gnu" => "aarch64-unknown-linux-gnu_lobster-rs", 404 | "x86_64-apple-darwin" => "x86_64-apple-darwin_lobster-rs", 405 | "aarch64-apple-darwin" => "aarch64-apple-darwin_lobster-rs", 406 | "x86_64-pc-windows-msvc" => "x86_64-pc-windows-msvc_lobster-rs.exe", 407 | "aarch64-pc-windows-msvc" => "aarch64-pc-windows-msvc_lobster-rs.exe", 408 | _ => return Err(anyhow::anyhow!("Unsupported target: {}", target)), 409 | }; 410 | 411 | let status = self_update::backends::github::Update::configure() 412 | .repo_owner("eatmynerds") 413 | .repo_name("lobster-rs") 414 | .bin_name(target_arch) 415 | .target("lobster-rs") 416 | .current_version(cargo_crate_version!()) 417 | .show_download_progress(true) 418 | .build()? 419 | .update()?; 420 | 421 | println!("Update status: Updated to version `{}`!", status.version()); 422 | 423 | Ok(()) 424 | } 425 | 426 | async fn url_quality(url: String, quality: Option) -> anyhow::Result { 427 | let client = Client::builder() 428 | .danger_accept_invalid_certs(true) 429 | .build()?; 430 | 431 | let input = client.get(url).send().await?.text().await?; 432 | 433 | let url_re = Regex::new(r"https://[^\s]+m3u8").unwrap(); 434 | let res_re = Regex::new(r"RESOLUTION=(\d+)x(\d+)").unwrap(); 435 | 436 | let mut resolutions = Vec::new(); 437 | for cap in res_re.captures_iter(&input) { 438 | resolutions.push(cap[2].to_string()); // Collect only height (e.g., "1080", "720", "360") 439 | } 440 | 441 | let url = if let Some(chosen_quality) = quality { 442 | url_re 443 | .captures_iter(&input) 444 | .zip(res_re.captures_iter(&input)) 445 | .find_map(|(url_captures, res_captures)| { 446 | let resolution = &res_captures[2]; 447 | let url = &url_captures[0]; 448 | 449 | if resolution == chosen_quality.to_string() { 450 | Some(url.to_string()) 451 | } else { 452 | None 453 | } 454 | }) 455 | .unwrap_or_else(|| { 456 | info!("Quality {} not found, falling back to auto", chosen_quality); 457 | input 458 | .lines() 459 | .find(|line| line.starts_with("https://")) 460 | .unwrap_or("") 461 | .to_string() 462 | }) 463 | } else { 464 | let mut urls_and_resolutions: Vec<(u32, String)> = url_re 465 | .captures_iter(&input) 466 | .zip(res_re.captures_iter(&input)) 467 | .filter_map(|(url_captures, res_captures)| { 468 | let resolution: u32 = res_captures[2].parse().ok()?; 469 | let url = url_captures[0].to_string(); 470 | Some((resolution, url)) 471 | }) 472 | .collect(); 473 | 474 | urls_and_resolutions.sort_by_key(|&(resolution, _)| std::cmp::Reverse(resolution)); 475 | 476 | let (_, url) = urls_and_resolutions 477 | .first() 478 | .expect("Failed to find best url quality!"); 479 | 480 | url.to_string() 481 | }; 482 | 483 | Ok(url) 484 | } 485 | 486 | async fn player_run_choice( 487 | media_info: (Option, String, String, String, String), 488 | episode_info: Option<(usize, usize, Vec>)>, 489 | config: Arc, 490 | settings: Arc, 491 | player: Player, 492 | download_dir: Option, 493 | player_url: String, 494 | subtitles: Vec, 495 | subtitle_language: Option, 496 | ) -> anyhow::Result<()> { 497 | let process_stdin = if media_info.2.starts_with("tv/") { 498 | Some("Next Episode\nPrevious Episode\nReplay\nExit\nSearch".to_string()) 499 | } else { 500 | Some("Replay\nExit\nSearch".to_string()) 501 | }; 502 | 503 | let run_choice = launcher( 504 | &vec![], 505 | settings.rofi, 506 | &mut RofiArgs { 507 | mesg: Some("Select: ".to_string()), 508 | process_stdin: process_stdin.clone(), 509 | dmenu: true, 510 | case_sensitive: true, 511 | ..Default::default() 512 | }, 513 | &mut FzfArgs { 514 | prompt: Some("Select: ".to_string()), 515 | process_stdin, 516 | reverse: true, 517 | ..Default::default() 518 | }, 519 | ) 520 | .await; 521 | 522 | match run_choice.as_str() { 523 | "Next Episode" => { 524 | handle_servers( 525 | config.clone(), 526 | settings.clone(), 527 | Some(true), 528 | ( 529 | media_info.0, 530 | media_info.1.as_str(), 531 | media_info.2.as_str(), 532 | media_info.3.as_str(), 533 | media_info.4.as_str(), 534 | ), 535 | episode_info, 536 | ) 537 | .await?; 538 | } 539 | "Previous Episode" => { 540 | handle_servers( 541 | config.clone(), 542 | settings.clone(), 543 | Some(false), 544 | ( 545 | media_info.0, 546 | media_info.1.as_str(), 547 | media_info.2.as_str(), 548 | media_info.3.as_str(), 549 | media_info.4.as_str(), 550 | ), 551 | episode_info, 552 | ) 553 | .await?; 554 | } 555 | "Search" => { 556 | run(Arc::new(Args::default()), Arc::clone(&config)).await?; 557 | } 558 | "Replay" => { 559 | handle_stream( 560 | settings.clone(), 561 | config.clone(), 562 | player, 563 | download_dir, 564 | player_url, 565 | media_info, 566 | episode_info, 567 | subtitles, 568 | subtitle_language, 569 | ) 570 | .await?; 571 | } 572 | "Exit" => { 573 | std::process::exit(0); 574 | } 575 | _ => { 576 | unreachable!("You shouldn't be here...") 577 | } 578 | } 579 | 580 | Ok(()) 581 | } 582 | 583 | fn handle_stream( 584 | settings: Arc, 585 | config: Arc, 586 | player: Player, 587 | download_dir: Option, 588 | url: String, 589 | media_info: (Option, String, String, String, String), 590 | episode_info: Option<(usize, usize, Vec>)>, 591 | subtitles: Vec, 592 | subtitle_language: Option, 593 | ) -> BoxFuture<'static, anyhow::Result<()>> { 594 | let subtitles_choice = settings.no_subs; 595 | let player_url = url.clone(); 596 | 597 | let subtitles_for_player = if subtitles_choice { 598 | info!("Continuing without subtitles"); 599 | None 600 | } else { 601 | if subtitles.len() > 0 { 602 | Some(subtitles.clone()) 603 | } else { 604 | info!("No subtitles available!"); 605 | None 606 | } 607 | }; 608 | 609 | let subtitle_language = if subtitles_choice { 610 | subtitle_language 611 | } else { 612 | None 613 | }; 614 | 615 | async move { 616 | match player { 617 | Player::Celluloid => { 618 | if let Some(download_dir) = download_dir { 619 | download( 620 | download_dir, 621 | media_info.3, 622 | url, 623 | subtitles_for_player, 624 | subtitle_language, 625 | ) 626 | .await?; 627 | 628 | info!("Download completed. Exiting..."); 629 | return Ok(()); 630 | } 631 | 632 | let title = if let Some(title) = media_info.0 { 633 | format!("{} - {}", media_info.3, title) 634 | } else { 635 | media_info.3 636 | }; 637 | 638 | let celluloid = Celluloid::new(); 639 | 640 | celluloid.play(CelluloidArgs { 641 | url, 642 | mpv_sub_files: subtitles_for_player, 643 | mpv_force_media_title: Some(title), 644 | ..Default::default() 645 | })?; 646 | } 647 | Player::Iina => { 648 | if let Some(download_dir) = download_dir { 649 | download( 650 | download_dir, 651 | media_info.3, 652 | url, 653 | subtitles_for_player, 654 | subtitle_language, 655 | ) 656 | .await?; 657 | 658 | info!("Download completed. Exiting..."); 659 | return Ok(()); 660 | } 661 | 662 | let title = if let Some(title) = media_info.0 { 663 | format!("{} - {}", media_info.3, title) 664 | } else { 665 | media_info.3 666 | }; 667 | 668 | let iina = Iina::new(); 669 | 670 | iina.play(IinaArgs { 671 | url, 672 | no_stdin: true, 673 | keep_running: true, 674 | mpv_sub_files: subtitles_for_player, 675 | mpv_force_media_title: Some(title), 676 | ..Default::default() 677 | })?; 678 | } 679 | Player::Vlc => { 680 | if let Some(download_dir) = download_dir { 681 | download( 682 | download_dir, 683 | media_info.3, 684 | url, 685 | subtitles_for_player, 686 | subtitle_language, 687 | ) 688 | .await?; 689 | 690 | info!("Download completed. Exiting..."); 691 | return Ok(()); 692 | } 693 | 694 | let url = url_quality(url, settings.quality).await?; 695 | 696 | let title: String = if let Some(title_part) = &media_info.0 { 697 | format!("{} - {}", media_info.3, title_part) 698 | } else { 699 | media_info.3.to_string() 700 | }; 701 | 702 | let vlc = Vlc::new(); 703 | 704 | vlc.play(VlcArgs { 705 | url, 706 | input_slave: subtitles_for_player, 707 | meta_title: Some(title), 708 | ..Default::default() 709 | })?; 710 | 711 | player_run_choice( 712 | media_info, 713 | episode_info, 714 | config, 715 | settings, 716 | player, 717 | download_dir, 718 | player_url, 719 | subtitles, 720 | subtitle_language, 721 | ) 722 | .await?; 723 | } 724 | Player::Mpv => { 725 | if let Some(download_dir) = download_dir { 726 | download( 727 | download_dir, 728 | media_info.3, 729 | url, 730 | subtitles_for_player.clone(), 731 | subtitle_language, 732 | ) 733 | .await?; 734 | 735 | info!("Download completed. Exiting..."); 736 | return Ok(()); 737 | } 738 | 739 | let watchlater_path = 740 | format!("{}/lobster-rs/watchlater", std::env::temp_dir().display()); 741 | 742 | let watchlater_dir = std::path::PathBuf::new().join(&watchlater_path); 743 | 744 | if watchlater_dir.exists() { 745 | std::fs::remove_dir_all(&watchlater_dir) 746 | .expect("Failed to remove watchlater directory!"); 747 | } 748 | 749 | std::fs::create_dir_all(&watchlater_dir) 750 | .expect("Failed to create watchlater directory!"); 751 | 752 | let url = url_quality(url, settings.quality).await?; 753 | 754 | let title: String = if let Some(title_part) = &media_info.0 { 755 | format!("{} - {}", media_info.3, title_part) 756 | } else { 757 | media_info.3.to_string() 758 | }; 759 | 760 | let mpv = Mpv::new(); 761 | 762 | let mut child = mpv.play(MpvArgs { 763 | url: url.clone(), 764 | sub_files: subtitles_for_player.clone(), 765 | force_media_title: Some(title), 766 | watch_later_dir: Some(watchlater_path), 767 | write_filename_in_watch_later_config: true, 768 | save_position_on_quit: true, 769 | ..Default::default() 770 | })?; 771 | 772 | if settings.rpc { 773 | let season_and_episode_num = episode_info.as_ref().map(|(a, b, _)| (*a, *b)); 774 | 775 | discord_presence( 776 | &media_info.2.clone(), 777 | season_and_episode_num, 778 | child, 779 | &media_info.3, 780 | ) 781 | .await?; 782 | } else { 783 | child.wait()?; 784 | } 785 | 786 | if config.history { 787 | let (position, progress) = save_progress(url).await?; 788 | 789 | save_history(media_info.clone(), episode_info.clone(), position, progress) 790 | .await?; 791 | } 792 | 793 | player_run_choice( 794 | media_info, 795 | episode_info, 796 | config, 797 | settings, 798 | player, 799 | download_dir, 800 | player_url, 801 | subtitles, 802 | subtitle_language, 803 | ) 804 | .await?; 805 | } 806 | Player::MpvAndroid => { 807 | if let Some(download_dir) = download_dir { 808 | download( 809 | download_dir, 810 | media_info.2, 811 | url, 812 | subtitles_for_player, 813 | subtitle_language, 814 | ) 815 | .await?; 816 | 817 | info!("Download completed. Exiting..."); 818 | return Ok(()); 819 | } 820 | 821 | let title: String = if let Some(title_part) = media_info.0 { 822 | format!("{} - {}", media_info.3, title_part) 823 | } else { 824 | media_info.3.to_string() 825 | }; 826 | 827 | Command::new("am") 828 | .args([ 829 | "start", 830 | "--user", 831 | "0", 832 | "-a", 833 | "android.intent.action.VIEW", 834 | "-d", 835 | &url, 836 | "-n", 837 | "is.xyz.mpv/.MPVActivity", 838 | "-e", 839 | "title", 840 | &title, 841 | ]) 842 | .spawn() 843 | .map_err(|e| { 844 | error!("Failed to start MPV for Android: {}", e); 845 | SpawnError::IOError(e) 846 | })?; 847 | } 848 | Player::SyncPlay => { 849 | let url = url_quality(url, settings.quality).await?; 850 | 851 | let title: String = if let Some(title_part) = media_info.0 { 852 | format!("{} - {}", media_info.3, title_part) 853 | } else { 854 | media_info.3.to_string() 855 | }; 856 | 857 | Command::new("syncplay") 858 | .args([&url, "--", &format!("--force-media-title={}", title)]) 859 | .spawn() 860 | .map_err(|e| { 861 | error!("Failed to start Syncplay: {}", e); 862 | SpawnError::IOError(e) 863 | })?; 864 | } 865 | } 866 | 867 | Ok(()) 868 | } 869 | .boxed() 870 | } 871 | 872 | pub async fn handle_servers( 873 | config: Arc, 874 | settings: Arc, 875 | next_episode: Option, 876 | media_info: (Option, &str, &str, &str, &str), 877 | show_info: Option<(usize, usize, Vec>)>, 878 | ) -> anyhow::Result<()> { 879 | debug!( 880 | "Fetching servers for episode_id: {}, media_id: {}", 881 | media_info.1, media_info.2 882 | ); 883 | 884 | let (episode_id, episode_title, new_show_info, server_results) = 885 | if let Some(next_episode) = next_episode { 886 | let show_info = show_info.clone().expect("Failed to get episode info"); 887 | let mut episode_number = show_info.1; 888 | let mut season_number = show_info.0; 889 | 890 | let total_seasons = show_info.2.len(); 891 | 892 | if next_episode { 893 | let total_episodes = show_info.2[season_number - 1].len(); 894 | 895 | if episode_number + 1 < total_episodes { 896 | // Move to next episode 897 | episode_number += 1; 898 | } else if season_number < total_seasons { 899 | // Move to the first episode of the next season 900 | season_number += 1; 901 | episode_number = 0; 902 | } else { 903 | // No next episode or season available, staying at the last episode 904 | error!("No next episode or season available."); 905 | std::process::exit(1); 906 | } 907 | } else { 908 | // Move to the previous episode 909 | if episode_number > 0 { 910 | episode_number -= 1; 911 | } else if season_number > 1 { 912 | // Move to the last episode of the previous season 913 | season_number -= 1; 914 | episode_number = show_info.2[season_number - 1].len() - 1; 915 | } else { 916 | // No previous episode available, staying at the first episode 917 | error!("No previous episode available."); 918 | std::process::exit(1); 919 | } 920 | } 921 | 922 | let episode_info= show_info.2[season_number - 1][episode_number].clone(); 923 | 924 | ( 925 | episode_info.id.clone(), 926 | Some(episode_info.title), 927 | Some((season_number, episode_number, show_info.2)), 928 | FlixHQ 929 | .servers(&episode_info.id, media_info.2) 930 | .await 931 | .map_err(|_| anyhow::anyhow!("Timeout while fetching servers"))?, 932 | ) 933 | } else { 934 | ( 935 | media_info.1.to_string(), 936 | media_info.0, 937 | show_info, 938 | FlixHQ 939 | .servers(media_info.1, media_info.2) 940 | .await 941 | .map_err(|_| anyhow::anyhow!("Timeout while fetching servers"))?, 942 | ) 943 | }; 944 | 945 | if server_results.servers.is_empty() { 946 | return Err(anyhow::anyhow!("No servers found")); 947 | } 948 | 949 | let servers: Vec = server_results 950 | .servers 951 | .into_iter() 952 | .filter_map(|server_result| match server_result.name.as_str() { 953 | "Vidcloud" => Some(Provider::Vidcloud), 954 | "Upcloud" => Some(Provider::Upcloud), 955 | _ => None, 956 | }) 957 | .collect(); 958 | 959 | let server_choice = settings.provider.unwrap_or(Provider::Vidcloud); 960 | 961 | let server = servers 962 | .iter() 963 | .find(|&&x| x == server_choice) 964 | .unwrap_or(&Provider::Vidcloud); 965 | 966 | debug!("Fetching sources for selected server: {:?}", server); 967 | 968 | let sources = FlixHQ 969 | .sources(episode_id.as_str(), media_info.2, *server) 970 | .await 971 | .map_err(|_| anyhow::anyhow!("Timeout while fetching sources"))?; 972 | 973 | debug!("{}", json!(sources)); 974 | 975 | if settings.json { 976 | info!("{}", serde_json::to_value(&sources).unwrap()); 977 | } 978 | 979 | match (sources.sources, sources.subtitles) { 980 | ( 981 | FlixHQSourceType::VidCloud(vidcloud_sources), 982 | FlixHQSubtitles::VidCloud(vidcloud_subtitles), 983 | ) => { 984 | if vidcloud_sources.is_empty() { 985 | return Err(anyhow::anyhow!("No sources available from VidCloud")); 986 | } 987 | 988 | debug!("{}", json!(vidcloud_subtitles)); 989 | 990 | let selected_subtitles: Vec = futures::stream::iter(vidcloud_subtitles) 991 | .filter(|subtitle| { 992 | let settings = Arc::clone(&settings); 993 | let subtitle_label = subtitle.label.clone(); 994 | async move { 995 | let language = settings.language.unwrap_or(Languages::English).to_string(); 996 | subtitle_label.contains(&language) 997 | } 998 | }) 999 | .map(|subtitle| subtitle.file.clone()) 1000 | .collect() 1001 | .await; 1002 | 1003 | debug!("Selected subtitles: {:?}", selected_subtitles); 1004 | 1005 | let mut player = match config.player.to_lowercase().as_str() { 1006 | "vlc" => Player::Vlc, 1007 | "mpv" => Player::Mpv, 1008 | "syncplay" => Player::SyncPlay, 1009 | "iina" => Player::Iina, 1010 | "celluloid" => Player::Celluloid, 1011 | _ => { 1012 | error!("Player not supported"); 1013 | std::process::exit(1); 1014 | } 1015 | }; 1016 | 1017 | if cfg!(target_os = "android") { 1018 | player = Player::MpvAndroid; 1019 | } 1020 | 1021 | if settings.syncplay { 1022 | player = Player::SyncPlay; 1023 | } 1024 | 1025 | debug!("Starting stream with player: {:?}", player); 1026 | 1027 | handle_stream( 1028 | Arc::clone(&settings), 1029 | Arc::clone(&config), 1030 | player, 1031 | settings 1032 | .download 1033 | .as_ref() 1034 | .and_then(|inner| inner.as_ref()) 1035 | .cloned(), 1036 | vidcloud_sources[0].file.to_string(), 1037 | ( 1038 | episode_title, 1039 | episode_id, 1040 | media_info.2.to_string(), 1041 | media_info.3.to_string(), 1042 | media_info.4.to_string(), 1043 | ), 1044 | new_show_info.map(|(a, b, c)| (a, b, c)), 1045 | selected_subtitles, 1046 | Some(settings.language.unwrap_or(Languages::English)), 1047 | ) 1048 | .await?; 1049 | } 1050 | } 1051 | 1052 | Ok(()) 1053 | } 1054 | 1055 | fn is_command_available(command: &str) -> bool { 1056 | let version_arg = if command == "rofi" || command == "ffmpeg" { 1057 | String::from("-version") 1058 | } else { 1059 | String::from("--version") 1060 | }; 1061 | 1062 | match Command::new(command).arg(version_arg).output() { 1063 | Ok(output) => output.status.success(), 1064 | Err(_) => false, 1065 | } 1066 | } 1067 | 1068 | fn check_dependencies() { 1069 | let dependencies = if cfg!(target_os = "windows") { 1070 | vec!["mpv", "chafa", "ffmpeg", "fzf"] 1071 | } else if cfg!(target_os = "android") { 1072 | vec!["chafa", "ffmpeg", "fzf"] 1073 | } else { 1074 | vec!["mpv", "fzf", "rofi", "ffmpeg", "chafa"] 1075 | }; 1076 | 1077 | for dep in dependencies { 1078 | if !is_command_available(dep) { 1079 | match dep { 1080 | "chafa" => { 1081 | warn!( 1082 | "Chafa isn't installed. You won't be able to do image previews with fzf." 1083 | ); 1084 | continue; 1085 | } 1086 | "rofi" => { 1087 | warn!("Rofi isn't installed. You won't be able to use rofi to search."); 1088 | continue; 1089 | } 1090 | "ffmpeg" => { 1091 | warn!("Ffmpeg isn't installed. You won't be able to download."); 1092 | continue; 1093 | } 1094 | _ => { 1095 | error!("{} is missing. Please install it.", dep); 1096 | std::process::exit(1); 1097 | } 1098 | } 1099 | } 1100 | } 1101 | } 1102 | 1103 | #[tokio::main] 1104 | async fn main() -> anyhow::Result<()> { 1105 | let args = Args::parse(); 1106 | 1107 | let log_level = if args.debug { 1108 | LevelFilter::Debug 1109 | } else { 1110 | LevelFilter::Info 1111 | }; 1112 | 1113 | rich_logger::init(log_level).unwrap(); 1114 | 1115 | check_dependencies(); 1116 | 1117 | if args.update { 1118 | let update_result = tokio::task::spawn_blocking(move || update()).await?; 1119 | 1120 | match update_result { 1121 | Ok(_) => { 1122 | std::process::exit(0); 1123 | } 1124 | Err(e) => { 1125 | error!("Failed to update: {}", e); 1126 | std::process::exit(1); 1127 | } 1128 | } 1129 | } 1130 | 1131 | if args.edit { 1132 | if cfg!(not(target_os = "windows")) { 1133 | let editor = std::env::var("EDITOR").map_err(|_| { 1134 | error!("EDITOR environment variable not set!"); 1135 | std::process::exit(1); 1136 | }).unwrap(); 1137 | std::process::Command::new(editor) 1138 | .arg( 1139 | dirs::config_dir() 1140 | .expect("Failed to get config directory") 1141 | .join("lobster-rs/config.toml"), 1142 | ) 1143 | .status() 1144 | .expect("Failed to open config file with editor"); 1145 | 1146 | info!("Done editing config file."); 1147 | std::process::exit(0); 1148 | } else { 1149 | error!("The `edit` flag is not supported on Windows."); 1150 | std::process::exit(1); 1151 | } 1152 | } 1153 | 1154 | let config = Arc::new(Config::load_config().expect("Failed to load config file")); 1155 | 1156 | let settings = Arc::new(Config::program_configuration(args, &config)); 1157 | 1158 | run(settings, config).await?; 1159 | 1160 | Ok(()) 1161 | } 1162 | --------------------------------------------------------------------------------