├── .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 | 
186 |
187 | 
188 |
189 | 
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 | 
263 |
264 |
265 |
266 | For `fzf` you will need to install
267 | [chafa](https://github.com/hpjansson/chafa/)
268 |
269 |
270 | Showcase
271 |
272 | 
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 | 
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 |
--------------------------------------------------------------------------------