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
/*.zip
*.zip
/target
/*.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]
name = "cjtui"
version = "0.1.0"
edition = "2024"
authors = ["Christopher Warren Johnson III"]
[dependencies]
ratatui = "0.30.0"
color-eyre = "0.6.3"
crossterm = "0.29.0"
serde_json ="1"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
[package]
name = "cjtui"
version = "0.1.0"
edition = "2024"
authors = ["Christopher Warren Johnson III"]
[dependencies]
ratatui = "0.30.0"
color-eyre = "0.6.3"
crossterm = "0.29.0"
serde_json ="1"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
+122 -122
View File
@@ -1,123 +1,123 @@
# CjTUI
*Chris Jellyfin Terminal User Interface*
This is the first actually useful programming project I have completed.
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.
Linux Only Curreently.
## Features
* **Blazingly Fast Speed**
* Online and Offline Playback
* Robust Download Connectivity
* Individual Download and Batch Download Support
* Download Queue Management
* Local Media Management
* Autoplay and Resume functionality
* Sends Current Watch Position to the Server
* Playlist and Collection Support
* Persistent Auth
## System Dependencies on
**MPV** and **ARIA2C**
## Persistent Storage
All application data is stored in:
~/.config/cjtui/
* config.toml: Server URL and API key is stored.
* downloads/: Local download data, used for local playback and offline navigation.
* temp/: Temporary download data during ARIA2C functionality.
## Controls
##### __General Interface Controls__
* **Up**/K: Up
* **Right**/L/**Enter**: Drilldown/Enter
* **Down**/J: Down
* **Left**/H/**Backspace**/Esc: Back
* q: Return to the home screen or quit.
* /: Search: Click once to search and clicking / multiple times cycles through search categories.
##### __How to Watch Media__
* 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.
* r: Resume: If there is a pause position online, it will stream beginning at the paused position.
* R: AutoResume: Resume + Autoplay flag is on.
* w: Watched: Marks as watched.
* W: Not Watched: Marks as unwatched.
##### __Media Player Control__
* Space: Pause
* x: close mpv
* f: full screen media player
* a: cycle audio track
* s: cycle subtitles
* v: change subtitle visibility.
##### __Local File Management__
* d: Queue file for download
* D: Enter the download visual queue.
* (d on an item in the queue): Remove item from queue.
* 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)
##### __Admin Controls__
* /\reset"Enter": Deletes the config file and exits the program. Reloading will prompt URL, Username, and Password Input.
## Installation Instructions
### __Nix/NixOS__
#### __Run without installing:__
```bash
nix run git+https://git.cwj3rcb.com/siege/CjTui
```
#### __Build manually:__
```bash
nix build git+https://git.cwj3rcb.com/siege/CjTui
./result/bin/cjtui
```
#### __Install on NixOS with flakes:__
Add CJTUI to your `flake.nix` inputs
```nix
inputs.cjtui.url = "git+https://git.cwj3rcb.com/siege/CjTui";
```
Then install the package
```nix
environment.systemPackages = [
cjtui.packages.${pkgs.system}.default
];
```
If your packages are in `configuration.nix`, pass `cjtui` through `specialArgs` from your system flake
```nix
specialArgs = {
inherit cjtui;
};
```
Then your `configuration.nix` can accept it
```nix
{ pkgs, cjtui, ... }:
```
CJTUI pins its own `nixpkgs` input so it uses tested versions of `mpv` and `aria2c`.
Do **not** add
```nix
inputs.cjtui.inputs.nixpkgs.follows = "nixpkgs";
```
unless you intentionally want CJTUI to use your systems versions of `mpv` and `aria2c`.
### __General Install__
Install mpv and aria2c with your package manager of choice then ->
```bash
cargo install --git https://git.cwj3rcb.com/siege/CjTui
# CjTUI
*Chris Jellyfin Terminal User Interface*
This is the first actually useful programming project I have completed.
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.
Linux Only Curreently.
## Features
* **Blazingly Fast Speed**
* Online and Offline Playback
* Robust Download Connectivity
* Individual Download and Batch Download Support
* Download Queue Management
* Local Media Management
* Autoplay and Resume functionality
* Sends Current Watch Position to the Server
* Playlist and Collection Support
* Persistent Auth
## System Dependencies on
**MPV** and **ARIA2C**
## Persistent Storage
All application data is stored in:
~/.config/cjtui/
* config.toml: Server URL and API key is stored.
* downloads/: Local download data, used for local playback and offline navigation.
* temp/: Temporary download data during ARIA2C functionality.
## Controls
##### __General Interface Controls__
* **Up**/K: Up
* **Right**/L/**Enter**: Drilldown/Enter
* **Down**/J: Down
* **Left**/H/**Backspace**/Esc: Back
* q: Return to the home screen or quit.
* /: Search: Click once to search and clicking / multiple times cycles through search categories.
##### __How to Watch Media__
* 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.
* r: Resume: If there is a pause position online, it will stream beginning at the paused position.
* R: AutoResume: Resume + Autoplay flag is on.
* w: Watched: Marks as watched.
* W: Not Watched: Marks as unwatched.
##### __Media Player Control__
* Space: Pause
* x: close mpv
* f: full screen media player
* a: cycle audio track
* s: cycle subtitles
* v: change subtitle visibility.
##### __Local File Management__
* d: Queue file for download
* D: Enter the download visual queue.
* (d on an item in the queue): Remove item from queue.
* 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)
##### __Admin Controls__
* /\reset"Enter": Deletes the config file and exits the program. Reloading will prompt URL, Username, and Password Input.
## Installation Instructions
### __Nix/NixOS__
#### __Run without installing:__
```bash
nix run git+https://git.cwj3rcb.com/siege/CjTui
```
#### __Build manually:__
```bash
nix build git+https://git.cwj3rcb.com/siege/CjTui
./result/bin/cjtui
```
#### __Install on NixOS with flakes:__
Add CJTUI to your `flake.nix` inputs
```nix
inputs.cjtui.url = "git+https://git.cwj3rcb.com/siege/CjTui";
```
Then install the package
```nix
environment.systemPackages = [
cjtui.packages.${pkgs.system}.default
];
```
If your packages are in `configuration.nix`, pass `cjtui` through `specialArgs` from your system flake
```nix
specialArgs = {
inherit cjtui;
};
```
Then your `configuration.nix` can accept it
```nix
{ pkgs, cjtui, ... }:
```
CJTUI pins its own `nixpkgs` input so it uses tested versions of `mpv` and `aria2c`.
Do **not** add
```nix
inputs.cjtui.inputs.nixpkgs.follows = "nixpkgs";
```
unless you intentionally want CJTUI to use your systems versions of `mpv` and `aria2c`.
### __General Install__
Install mpv and aria2c with your package manager of choice then ->
```bash
cargo install --git https://git.cwj3rcb.com/siege/CjTui
```
Generated
+27 -27
View File
@@ -1,27 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1781577229,
"narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "567a49d1913ce81ac6e9582e3553dd90a955875f",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1781577229,
"narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "567a49d1913ce81ac6e9582e3553dd90a955875f",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+52 -52
View File
@@ -1,53 +1,53 @@
{
description = "CJTUI - Chris Jellyfin Terminal User Interface";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" ];
forAllSystems = nixpkgs.lib.genAttrs systems;
pkgsFor = system: import nixpkgs {
inherit system;
};
in {
packages = forAllSystems (system:
let
pkgs = pkgsFor system;
in {
default = pkgs.rustPlatform.buildRustPackage {
pname = "cjtui";
version = "0.1.0";
src = ./.;
cargoLock = {
lockFile = ./Cargo.lock;
};
nativeBuildInputs = [
pkgs.makeWrapper
];
postInstall = ''
wrapProgram $out/bin/cjtui \
--prefix PATH : ${pkgs.lib.makeBinPath [
pkgs.mpv
pkgs.aria2
]}
'';
};
});
apps = forAllSystems (system: {
default = {
type = "app";
program = "${self.packages.${system}.default}/bin/cjtui";
};
});
};
{
description = "CJTUI - Chris Jellyfin Terminal User Interface";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" ];
forAllSystems = nixpkgs.lib.genAttrs systems;
pkgsFor = system: import nixpkgs {
inherit system;
};
in {
packages = forAllSystems (system:
let
pkgs = pkgsFor system;
in {
default = pkgs.rustPlatform.buildRustPackage {
pname = "cjtui";
version = "0.1.0";
src = ./.;
cargoLock = {
lockFile = ./Cargo.lock;
};
nativeBuildInputs = [
pkgs.makeWrapper
];
postInstall = ''
wrapProgram $out/bin/cjtui \
--prefix PATH : ${pkgs.lib.makeBinPath [
pkgs.mpv
pkgs.aria2
]}
'';
};
});
apps = forAllSystems (system: {
default = {
type = "app";
program = "${self.packages.${system}.default}/bin/cjtui";
};
});
};
}
+156 -105
View File
@@ -1,105 +1,156 @@
use std::{fs, io, path::PathBuf};
use super::paths;
#[derive(Debug, Clone)]
pub struct Config {
pub base_url: String,
pub access_token: Option<String>,
pub user_id: Option<String>,
}
#[derive(Debug)]
pub enum ConfigError {
NotFound(PathBuf),
Io(io::Error),
Parse(String),
}
impl From<io::Error> for ConfigError {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
pub fn config_path() -> Option<PathBuf> {
paths::app_config_dir().map(|d| d.join("config.toml"))
}
// Very small TOML-ish parser for now (no deps).
// Format:
// base_url = "http://..."
// access_token = "..."
pub fn load_config() -> Result<Config, ConfigError> {
let path = config_path().ok_or_else(|| ConfigError::Parse("No HOME/XDG dirs".into()))?;
if !path.exists() {
return Err(ConfigError::NotFound(path));
}
let s = fs::read_to_string(&path)?;
parse_config(&s).map_err(ConfigError::Parse)
}
fn parse_config(s: &str) -> Result<Config, String> {
let mut base_url: Option<String> = None;
let mut access_token: Option<String> = None;
let mut user_id: Option<String> = None;
for (i, line) in s.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (k, v) = line
.split_once('=')
.ok_or_else(|| format!("Line {}: expected key = value", i + 1))?;
let key = k.trim();
let mut val = v.trim().to_string();
// strip quotes if present
if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 {
val = val[1..val.len() - 1].to_string();
}
match key {
"base_url" => base_url = Some(val),
"access_token" => access_token = Some(val),
"user_id" => user_id = Some(val),
_ => {}
}
}
let base_url = base_url.ok_or_else(|| "Missing base_url".to_string())?;
Ok(Config {
base_url,
access_token,
user_id,
})
}
pub fn save_config(cfg: &Config) -> Result<(), ConfigError> {
let dir = paths::app_config_dir()
.ok_or_else(|| ConfigError::Parse("No HOME/XDG dirs".into()))?;
std::fs::create_dir_all(&dir)?;
let path = dir.join("config.toml");
let mut out = String::new();
out.push_str(&format!("base_url = \"{}\"\n", cfg.base_url));
if let Some(t) = &cfg.access_token {
out.push_str(&format!("access_token = \"{}\"\n", t));
}
if let Some(u) = &cfg.user_id {
out.push_str(&format!("user_id = \"{}\"\n", u));
}
std::fs::write(path, out)?;
Ok(())
}
use std::{fs, io, path::PathBuf};
use super::paths;
#[derive(Debug, Clone)]
pub struct Config {
pub base_url: String,
pub access_token: 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.
pub device_id: Option<String>,
}
#[derive(Debug)]
pub enum ConfigError {
NotFound(PathBuf),
Io(io::Error),
Parse(String),
}
impl From<io::Error> for ConfigError {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
pub fn config_path() -> Option<PathBuf> {
paths::app_config_dir().map(|d| d.join("config.toml"))
}
// Very small TOML-ish parser for now (no deps).
// Format:
// base_url = "http://..."
// access_token = "..."
pub fn load_config() -> Result<Config, ConfigError> {
let path = config_path().ok_or_else(|| ConfigError::Parse("No HOME/XDG dirs".into()))?;
if !path.exists() {
return Err(ConfigError::NotFound(path));
}
let s = fs::read_to_string(&path)?;
parse_config(&s).map_err(ConfigError::Parse)
}
fn parse_config(s: &str) -> Result<Config, String> {
let mut base_url: Option<String> = None;
let mut access_token: Option<String> = None;
let mut user_id: Option<String> = None;
let mut device_id: Option<String> = None;
for (i, line) in s.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (k, v) = line
.split_once('=')
.ok_or_else(|| format!("Line {}: expected key = value", i + 1))?;
let key = k.trim();
let mut val = v.trim().to_string();
// strip quotes if present
if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 {
val = val[1..val.len() - 1].to_string();
}
match key {
"base_url" => base_url = Some(val),
"access_token" => access_token = Some(val),
"user_id" => user_id = Some(val),
"device_id" => device_id = Some(val),
_ => {}
}
}
let base_url = base_url.ok_or_else(|| "Missing base_url".to_string())?;
Ok(Config {
base_url,
access_token,
user_id,
device_id,
})
}
pub fn save_config(cfg: &Config) -> Result<(), ConfigError> {
let dir = paths::app_config_dir()
.ok_or_else(|| ConfigError::Parse("No HOME/XDG dirs".into()))?;
std::fs::create_dir_all(&dir)?;
let path = dir.join("config.toml");
let mut out = String::new();
out.push_str(&format!("base_url = \"{}\"\n", cfg.base_url));
if let Some(t) = &cfg.access_token {
out.push_str(&format!("access_token = \"{}\"\n", t));
}
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 jellyfin;
pub mod paths;
pub mod config;
pub mod jellyfin;
pub mod paths;
pub mod downloads;
+44 -44
View File
@@ -1,44 +1,44 @@
use std::{env, path::PathBuf};
pub fn home_dir() -> Option<PathBuf> {
env::var_os("HOME").map(PathBuf::from)
}
pub fn config_dir() -> Option<PathBuf> {
// XDG first, fallback to ~/.config
if let Some(p) = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from) {
Some(p)
} else {
home_dir().map(|h| h.join(".config"))
}
}
pub fn cache_dir() -> Option<PathBuf> {
// XDG first, fallback to ~/.cache
if let Some(p) = env::var_os("XDG_CACHE_HOME").map(PathBuf::from) {
Some(p)
} else {
home_dir().map(|h| h.join(".cache"))
}
}
pub fn app_config_dir() -> Option<PathBuf> {
config_dir().map(|d| d.join("cjtui"))
}
pub fn app_cache_dir() -> Option<PathBuf> {
cache_dir().map(|d| d.join("cjtui"))
}
pub fn posters_cache_dir() -> Option<PathBuf> {
app_cache_dir().map(|d| d.join("posters"))
}
pub fn downloads_dir() -> Option<PathBuf> {
app_config_dir().map(|d| d.join("downloads"))
}
pub fn temp_downloads_dir() -> Option<PathBuf> {
app_config_dir().map(|d| d.join("temp"))
}
use std::{env, path::PathBuf};
pub fn home_dir() -> Option<PathBuf> {
env::var_os("HOME").map(PathBuf::from)
}
pub fn config_dir() -> Option<PathBuf> {
// XDG first, fallback to ~/.config
if let Some(p) = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from) {
Some(p)
} else {
home_dir().map(|h| h.join(".config"))
}
}
pub fn cache_dir() -> Option<PathBuf> {
// XDG first, fallback to ~/.cache
if let Some(p) = env::var_os("XDG_CACHE_HOME").map(PathBuf::from) {
Some(p)
} else {
home_dir().map(|h| h.join(".cache"))
}
}
pub fn app_config_dir() -> Option<PathBuf> {
config_dir().map(|d| d.join("cjtui"))
}
pub fn app_cache_dir() -> Option<PathBuf> {
cache_dir().map(|d| d.join("cjtui"))
}
pub fn posters_cache_dir() -> Option<PathBuf> {
app_cache_dir().map(|d| d.join("posters"))
}
pub fn downloads_dir() -> Option<PathBuf> {
app_config_dir().map(|d| d.join("downloads"))
}
pub fn temp_downloads_dir() -> Option<PathBuf> {
app_config_dir().map(|d| d.join("temp"))
}
+3981 -3973
View File
File diff suppressed because it is too large Load Diff