Fixing Multi-Device Compatibility

This commit is contained in:
2026-07-25 10:56:11 -05:00
parent 04c98cacfd
commit 4ce716be9f
12 changed files with 9223 additions and 9143 deletions
+3 -3
View File
@@ -1,3 +1,3 @@
/target /target
/*.zip /*.zip
*.zip *.zip
Generated
+2764 -2764
View File
File diff suppressed because it is too large Load Diff
+12 -12
View File
@@ -1,13 +1,13 @@
[package] [package]
name = "cjtui" name = "cjtui"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
authors = ["Christopher Warren Johnson III"] authors = ["Christopher Warren Johnson III"]
[dependencies] [dependencies]
ratatui = "0.30.0" ratatui = "0.30.0"
color-eyre = "0.6.3" color-eyre = "0.6.3"
crossterm = "0.29.0" crossterm = "0.29.0"
serde_json ="1" serde_json ="1"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
+122 -122
View File
@@ -1,123 +1,123 @@
# CjTUI # CjTUI
*Chris Jellyfin Terminal User Interface* *Chris Jellyfin Terminal User Interface*
This is the first actually useful programming project I have completed. This is the first actually useful programming project I have completed.
I still can't write in rust because of generative AI. I still can't write in rust because of generative AI.
Maybe one day when I am better at programming I will refactor the codebase and figure out how to hand code a transcoding option that actually is compatible with Jellyfin API. Maybe one day when I am better at programming I will refactor the codebase and figure out how to hand code a transcoding option that actually is compatible with Jellyfin API.
Linux Only Curreently. Linux Only Curreently.
## Features ## Features
* **Blazingly Fast Speed** * **Blazingly Fast Speed**
* Online and Offline Playback * Online and Offline Playback
* Robust Download Connectivity * Robust Download Connectivity
* Individual Download and Batch Download Support * Individual Download and Batch Download Support
* Download Queue Management * Download Queue Management
* Local Media Management * Local Media Management
* Autoplay and Resume functionality * Autoplay and Resume functionality
* Sends Current Watch Position to the Server * Sends Current Watch Position to the Server
* Playlist and Collection Support * Playlist and Collection Support
* Persistent Auth * Persistent Auth
## System Dependencies on ## System Dependencies on
**MPV** and **ARIA2C** **MPV** and **ARIA2C**
## Persistent Storage ## Persistent Storage
All application data is stored in: All application data is stored in:
~/.config/cjtui/ ~/.config/cjtui/
* config.toml: Server URL and API key is stored. * config.toml: Server URL and API key is stored.
* downloads/: Local download data, used for local playback and offline navigation. * downloads/: Local download data, used for local playback and offline navigation.
* temp/: Temporary download data during ARIA2C functionality. * temp/: Temporary download data during ARIA2C functionality.
## Controls ## Controls
##### __General Interface Controls__ ##### __General Interface Controls__
* **Up**/K: Up * **Up**/K: Up
* **Right**/L/**Enter**: Drilldown/Enter * **Right**/L/**Enter**: Drilldown/Enter
* **Down**/J: Down * **Down**/J: Down
* **Left**/H/**Backspace**/Esc: Back * **Left**/H/**Backspace**/Esc: Back
* q: Return to the home screen or quit. * q: Return to the home screen or quit.
* /: Search: Click once to search and clicking / multiple times cycles through search categories. * /: Search: Click once to search and clicking / multiple times cycles through search categories.
##### __How to Watch Media__ ##### __How to Watch Media__
* p: Play: Starts a show playing at the beginning and will use offline playback if available. * p: Play: Starts a show playing at the beginning and will use offline playback if available.
* P: AutoPlay: Starts a show playing at the beginning and will use offline playback if available. Turns on auto play mode which will play the next episode in series or song in the playlist. * P: AutoPlay: Starts a show playing at the beginning and will use offline playback if available. Turns on auto play mode which will play the next episode in series or song in the playlist.
* r: Resume: If there is a pause position online, it will stream beginning at the paused position. * r: Resume: If there is a pause position online, it will stream beginning at the paused position.
* R: AutoResume: Resume + Autoplay flag is on. * R: AutoResume: Resume + Autoplay flag is on.
* w: Watched: Marks as watched. * w: Watched: Marks as watched.
* W: Not Watched: Marks as unwatched. * W: Not Watched: Marks as unwatched.
##### __Media Player Control__ ##### __Media Player Control__
* Space: Pause * Space: Pause
* x: close mpv * x: close mpv
* f: full screen media player * f: full screen media player
* a: cycle audio track * a: cycle audio track
* s: cycle subtitles * s: cycle subtitles
* v: change subtitle visibility. * v: change subtitle visibility.
##### __Local File Management__ ##### __Local File Management__
* d: Queue file for download * d: Queue file for download
* D: Enter the download visual queue. * D: Enter the download visual queue.
* (d on an item in the queue): Remove item from queue. * (d on an item in the queue): Remove item from queue.
* X: Deletes the local file if the file exists. * X: Deletes the local file if the file exists.
* o: Enter offline playback/Attempt to ping the server to join online mode. (Local downloaded database is available during this mode) * o: Enter offline playback/Attempt to ping the server to join online mode. (Local downloaded database is available during this mode)
##### __Admin Controls__ ##### __Admin Controls__
* /\reset"Enter": Deletes the config file and exits the program. Reloading will prompt URL, Username, and Password Input. * /\reset"Enter": Deletes the config file and exits the program. Reloading will prompt URL, Username, and Password Input.
## Installation Instructions ## Installation Instructions
### __Nix/NixOS__ ### __Nix/NixOS__
#### __Run without installing:__ #### __Run without installing:__
```bash ```bash
nix run git+https://git.cwj3rcb.com/siege/CjTui nix run git+https://git.cwj3rcb.com/siege/CjTui
``` ```
#### __Build manually:__ #### __Build manually:__
```bash ```bash
nix build git+https://git.cwj3rcb.com/siege/CjTui nix build git+https://git.cwj3rcb.com/siege/CjTui
./result/bin/cjtui ./result/bin/cjtui
``` ```
#### __Install on NixOS with flakes:__ #### __Install on NixOS with flakes:__
Add CJTUI to your `flake.nix` inputs Add CJTUI to your `flake.nix` inputs
```nix ```nix
inputs.cjtui.url = "git+https://git.cwj3rcb.com/siege/CjTui"; inputs.cjtui.url = "git+https://git.cwj3rcb.com/siege/CjTui";
``` ```
Then install the package Then install the package
```nix ```nix
environment.systemPackages = [ environment.systemPackages = [
cjtui.packages.${pkgs.system}.default cjtui.packages.${pkgs.system}.default
]; ];
``` ```
If your packages are in `configuration.nix`, pass `cjtui` through `specialArgs` from your system flake If your packages are in `configuration.nix`, pass `cjtui` through `specialArgs` from your system flake
```nix ```nix
specialArgs = { specialArgs = {
inherit cjtui; inherit cjtui;
}; };
``` ```
Then your `configuration.nix` can accept it Then your `configuration.nix` can accept it
```nix ```nix
{ pkgs, cjtui, ... }: { pkgs, cjtui, ... }:
``` ```
CJTUI pins its own `nixpkgs` input so it uses tested versions of `mpv` and `aria2c`. CJTUI pins its own `nixpkgs` input so it uses tested versions of `mpv` and `aria2c`.
Do **not** add Do **not** add
```nix ```nix
inputs.cjtui.inputs.nixpkgs.follows = "nixpkgs"; inputs.cjtui.inputs.nixpkgs.follows = "nixpkgs";
``` ```
unless you intentionally want CJTUI to use your systems versions of `mpv` and `aria2c`. unless you intentionally want CJTUI to use your systems versions of `mpv` and `aria2c`.
### __General Install__ ### __General Install__
Install mpv and aria2c with your package manager of choice then -> Install mpv and aria2c with your package manager of choice then ->
```bash ```bash
cargo install --git https://git.cwj3rcb.com/siege/CjTui cargo install --git https://git.cwj3rcb.com/siege/CjTui
``` ```
Generated
+27 -27
View File
@@ -1,27 +1,27 @@
{ {
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1781577229, "lastModified": 1781577229,
"narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=", "narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "567a49d1913ce81ac6e9582e3553dd90a955875f", "rev": "567a49d1913ce81ac6e9582e3553dd90a955875f",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "owner": "NixOS",
"ref": "nixos-unstable", "ref": "nixos-unstable",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
}, },
"root": { "root": {
"inputs": { "inputs": {
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
} }
} }
}, },
"root": "root", "root": "root",
"version": 7 "version": 7
} }
+52 -52
View File
@@ -1,53 +1,53 @@
{ {
description = "CJTUI - Chris Jellyfin Terminal User Interface"; description = "CJTUI - Chris Jellyfin Terminal User Interface";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
}; };
outputs = { self, nixpkgs }: outputs = { self, nixpkgs }:
let let
systems = [ "x86_64-linux" "aarch64-linux" ]; systems = [ "x86_64-linux" "aarch64-linux" ];
forAllSystems = nixpkgs.lib.genAttrs systems; forAllSystems = nixpkgs.lib.genAttrs systems;
pkgsFor = system: import nixpkgs { pkgsFor = system: import nixpkgs {
inherit system; inherit system;
}; };
in { in {
packages = forAllSystems (system: packages = forAllSystems (system:
let let
pkgs = pkgsFor system; pkgs = pkgsFor system;
in { in {
default = pkgs.rustPlatform.buildRustPackage { default = pkgs.rustPlatform.buildRustPackage {
pname = "cjtui"; pname = "cjtui";
version = "0.1.0"; version = "0.1.0";
src = ./.; src = ./.;
cargoLock = { cargoLock = {
lockFile = ./Cargo.lock; lockFile = ./Cargo.lock;
}; };
nativeBuildInputs = [ nativeBuildInputs = [
pkgs.makeWrapper pkgs.makeWrapper
]; ];
postInstall = '' postInstall = ''
wrapProgram $out/bin/cjtui \ wrapProgram $out/bin/cjtui \
--prefix PATH : ${pkgs.lib.makeBinPath [ --prefix PATH : ${pkgs.lib.makeBinPath [
pkgs.mpv pkgs.mpv
pkgs.aria2 pkgs.aria2
]} ]}
''; '';
}; };
}); });
apps = forAllSystems (system: { apps = forAllSystems (system: {
default = { default = {
type = "app"; type = "app";
program = "${self.packages.${system}.default}/bin/cjtui"; program = "${self.packages.${system}.default}/bin/cjtui";
}; };
}); });
}; };
} }
+156 -105
View File
@@ -1,105 +1,156 @@
use std::{fs, io, path::PathBuf}; use std::{fs, io, path::PathBuf};
use super::paths; use super::paths;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Config { pub struct Config {
pub base_url: String, pub base_url: String,
pub access_token: Option<String>, pub access_token: Option<String>,
pub user_id: Option<String>, pub user_id: Option<String>,
} /// Stable, per-installation identifier sent to Jellyfin as DeviceId.
/// Must be unique per machine: Jellyfin keys sessions on (DeviceId, Client),
/// so two machines sharing one DeviceId invalidate each other's tokens.
#[derive(Debug)] pub device_id: Option<String>,
pub enum ConfigError { }
NotFound(PathBuf),
Io(io::Error),
Parse(String), #[derive(Debug)]
} pub enum ConfigError {
NotFound(PathBuf),
impl From<io::Error> for ConfigError { Io(io::Error),
fn from(e: io::Error) -> Self { Parse(String),
Self::Io(e) }
}
} impl From<io::Error> for ConfigError {
fn from(e: io::Error) -> Self {
pub fn config_path() -> Option<PathBuf> { Self::Io(e)
paths::app_config_dir().map(|d| d.join("config.toml")) }
} }
// Very small TOML-ish parser for now (no deps). pub fn config_path() -> Option<PathBuf> {
// Format: paths::app_config_dir().map(|d| d.join("config.toml"))
// base_url = "http://..." }
// access_token = "..."
pub fn load_config() -> Result<Config, ConfigError> { // Very small TOML-ish parser for now (no deps).
let path = config_path().ok_or_else(|| ConfigError::Parse("No HOME/XDG dirs".into()))?; // Format:
if !path.exists() { // base_url = "http://..."
return Err(ConfigError::NotFound(path)); // access_token = "..."
} pub fn load_config() -> Result<Config, ConfigError> {
let path = config_path().ok_or_else(|| ConfigError::Parse("No HOME/XDG dirs".into()))?;
let s = fs::read_to_string(&path)?; if !path.exists() {
parse_config(&s).map_err(ConfigError::Parse) return Err(ConfigError::NotFound(path));
} }
fn parse_config(s: &str) -> Result<Config, String> { let s = fs::read_to_string(&path)?;
let mut base_url: Option<String> = None; parse_config(&s).map_err(ConfigError::Parse)
let mut access_token: Option<String> = None; }
let mut user_id: Option<String> = None;
fn parse_config(s: &str) -> Result<Config, String> {
for (i, line) in s.lines().enumerate() { let mut base_url: Option<String> = None;
let line = line.trim(); let mut access_token: Option<String> = None;
if line.is_empty() || line.starts_with('#') { let mut user_id: Option<String> = None;
continue; let mut device_id: Option<String> = None;
}
for (i, line) in s.lines().enumerate() {
let (k, v) = line let line = line.trim();
.split_once('=') if line.is_empty() || line.starts_with('#') {
.ok_or_else(|| format!("Line {}: expected key = value", i + 1))?; continue;
}
let key = k.trim();
let mut val = v.trim().to_string(); let (k, v) = line
.split_once('=')
// strip quotes if present .ok_or_else(|| format!("Line {}: expected key = value", i + 1))?;
if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 {
val = val[1..val.len() - 1].to_string(); let key = k.trim();
} let mut val = v.trim().to_string();
match key { // strip quotes if present
"base_url" => base_url = Some(val), if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 {
"access_token" => access_token = Some(val), val = val[1..val.len() - 1].to_string();
"user_id" => user_id = Some(val), }
_ => {}
} match key {
} "base_url" => base_url = Some(val),
"access_token" => access_token = Some(val),
let base_url = base_url.ok_or_else(|| "Missing base_url".to_string())?; "user_id" => user_id = Some(val),
"device_id" => device_id = Some(val),
Ok(Config { _ => {}
base_url, }
access_token, }
user_id,
}) let base_url = base_url.ok_or_else(|| "Missing base_url".to_string())?;
}
Ok(Config {
pub fn save_config(cfg: &Config) -> Result<(), ConfigError> { base_url,
let dir = paths::app_config_dir() access_token,
.ok_or_else(|| ConfigError::Parse("No HOME/XDG dirs".into()))?; user_id,
std::fs::create_dir_all(&dir)?; device_id,
})
let path = dir.join("config.toml"); }
let mut out = String::new(); pub fn save_config(cfg: &Config) -> Result<(), ConfigError> {
let dir = paths::app_config_dir()
out.push_str(&format!("base_url = \"{}\"\n", cfg.base_url)); .ok_or_else(|| ConfigError::Parse("No HOME/XDG dirs".into()))?;
std::fs::create_dir_all(&dir)?;
if let Some(t) = &cfg.access_token {
out.push_str(&format!("access_token = \"{}\"\n", t)); let path = dir.join("config.toml");
}
let mut out = String::new();
if let Some(u) = &cfg.user_id {
out.push_str(&format!("user_id = \"{}\"\n", u)); out.push_str(&format!("base_url = \"{}\"\n", cfg.base_url));
}
if let Some(t) = &cfg.access_token {
std::fs::write(path, out)?; out.push_str(&format!("access_token = \"{}\"\n", t));
Ok(()) }
}
if let Some(u) = &cfg.user_id {
out.push_str(&format!("user_id = \"{}\"\n", u));
}
if let Some(d) = &cfg.device_id {
out.push_str(&format!("device_id = \"{}\"\n", d));
}
std::fs::write(path, out)?;
Ok(())
}
/// Returns this installation's DeviceId, generating and persisting one on first use.
///
/// Jellyfin treats a DeviceId as a physical device. Logging in from a second
/// machine with the same DeviceId makes the server rebind that device's session
/// and revoke the first machine's access token, so this value must never be
/// shared between installs (and must survive restarts, or every launch would
/// register a fresh device on the server).
pub fn ensure_device_id(cfg: &mut Config) -> String {
if let Some(d) = cfg.device_id.as_deref() {
if !d.trim().is_empty() {
return d.to_string();
}
}
let id = generate_device_id();
cfg.device_id = Some(id.clone());
// Best effort: if the write fails we still use the id for this run, and try
// again next launch rather than failing the login outright.
let _ = save_config(cfg);
id
}
fn generate_device_id() -> String {
// The kernel hands us a real random UUID, no crate needed.
if let Ok(s) = fs::read_to_string("/proc/sys/kernel/random/uuid") {
let s = s.trim();
if !s.is_empty() {
return s.to_string();
}
}
// Fallback for anything without procfs: mix clock and pid.
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("cjtui-{:x}-{:x}", nanos, std::process::id())
}
+1364 -1364
View File
File diff suppressed because it is too large Load Diff
+695 -674
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,4 +1,4 @@
pub mod config; pub mod config;
pub mod jellyfin; pub mod jellyfin;
pub mod paths; pub mod paths;
pub mod downloads; pub mod downloads;
+44 -44
View File
@@ -1,44 +1,44 @@
use std::{env, path::PathBuf}; use std::{env, path::PathBuf};
pub fn home_dir() -> Option<PathBuf> { pub fn home_dir() -> Option<PathBuf> {
env::var_os("HOME").map(PathBuf::from) env::var_os("HOME").map(PathBuf::from)
} }
pub fn config_dir() -> Option<PathBuf> { pub fn config_dir() -> Option<PathBuf> {
// XDG first, fallback to ~/.config // XDG first, fallback to ~/.config
if let Some(p) = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from) { if let Some(p) = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from) {
Some(p) Some(p)
} else { } else {
home_dir().map(|h| h.join(".config")) home_dir().map(|h| h.join(".config"))
} }
} }
pub fn cache_dir() -> Option<PathBuf> { pub fn cache_dir() -> Option<PathBuf> {
// XDG first, fallback to ~/.cache // XDG first, fallback to ~/.cache
if let Some(p) = env::var_os("XDG_CACHE_HOME").map(PathBuf::from) { if let Some(p) = env::var_os("XDG_CACHE_HOME").map(PathBuf::from) {
Some(p) Some(p)
} else { } else {
home_dir().map(|h| h.join(".cache")) home_dir().map(|h| h.join(".cache"))
} }
} }
pub fn app_config_dir() -> Option<PathBuf> { pub fn app_config_dir() -> Option<PathBuf> {
config_dir().map(|d| d.join("cjtui")) config_dir().map(|d| d.join("cjtui"))
} }
pub fn app_cache_dir() -> Option<PathBuf> { pub fn app_cache_dir() -> Option<PathBuf> {
cache_dir().map(|d| d.join("cjtui")) cache_dir().map(|d| d.join("cjtui"))
} }
pub fn posters_cache_dir() -> Option<PathBuf> { pub fn posters_cache_dir() -> Option<PathBuf> {
app_cache_dir().map(|d| d.join("posters")) app_cache_dir().map(|d| d.join("posters"))
} }
pub fn downloads_dir() -> Option<PathBuf> { pub fn downloads_dir() -> Option<PathBuf> {
app_config_dir().map(|d| d.join("downloads")) app_config_dir().map(|d| d.join("downloads"))
} }
pub fn temp_downloads_dir() -> Option<PathBuf> { pub fn temp_downloads_dir() -> Option<PathBuf> {
app_config_dir().map(|d| d.join("temp")) app_config_dir().map(|d| d.join("temp"))
} }
+3981 -3973
View File
File diff suppressed because it is too large Load Diff