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
+51
View File
@@ -7,6 +7,10 @@ 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.
pub device_id: Option<String>,
} }
@@ -45,6 +49,7 @@ fn parse_config(s: &str) -> Result<Config, String> {
let mut base_url: Option<String> = None; let mut base_url: Option<String> = None;
let mut access_token: Option<String> = None; let mut access_token: Option<String> = None;
let mut user_id: Option<String> = None; let mut user_id: Option<String> = None;
let mut device_id: Option<String> = None;
for (i, line) in s.lines().enumerate() { for (i, line) in s.lines().enumerate() {
let line = line.trim(); let line = line.trim();
@@ -68,6 +73,7 @@ fn parse_config(s: &str) -> Result<Config, String> {
"base_url" => base_url = Some(val), "base_url" => base_url = Some(val),
"access_token" => access_token = Some(val), "access_token" => access_token = Some(val),
"user_id" => user_id = Some(val), "user_id" => user_id = Some(val),
"device_id" => device_id = Some(val),
_ => {} _ => {}
} }
} }
@@ -78,6 +84,7 @@ fn parse_config(s: &str) -> Result<Config, String> {
base_url, base_url,
access_token, access_token,
user_id, user_id,
device_id,
}) })
} }
@@ -100,6 +107,50 @@ pub fn save_config(cfg: &Config) -> Result<(), ConfigError> {
out.push_str(&format!("user_id = \"{}\"\n", u)); 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)?; std::fs::write(path, out)?;
Ok(()) 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())
}
+25 -4
View File
@@ -9,6 +9,20 @@ pub struct JellyfinClient {
base_url: String, base_url: String,
client: reqwest::blocking::Client, client: reqwest::blocking::Client,
token: String, token: String,
device_id: String,
}
/// Human-readable device label shown in Jellyfin's dashboard. Cosmetic only —
/// DeviceId is what the server keys sessions on.
fn device_name() -> String {
if let Ok(h) = std::fs::read_to_string("/etc/hostname") {
let h = h.trim();
if !h.is_empty() {
return h.to_string();
}
}
std::env::var("HOSTNAME").unwrap_or_else(|_| "cjtui".to_string())
} }
pub struct LoginResult { pub struct LoginResult {
@@ -123,14 +137,16 @@ impl JellyfinClient {
base_url: cfg.base_url.trim_end_matches('/').to_string(), base_url: cfg.base_url.trim_end_matches('/').to_string(),
client: reqwest::blocking::Client::new(), client: reqwest::blocking::Client::new(),
token, token,
device_id: cfg.device_id.clone().unwrap_or_default(),
}) })
} }
pub fn unauthenticated(base_url: &str) -> Self { pub fn unauthenticated(base_url: &str, device_id: &str) -> Self {
Self { Self {
base_url: base_url.trim_end_matches('/').to_string(), base_url: base_url.trim_end_matches('/').to_string(),
client: reqwest::blocking::Client::new(), client: reqwest::blocking::Client::new(),
token: String::new(), // unused for login token: String::new(), // unused for login
device_id: device_id.to_string(),
} }
} }
@@ -138,7 +154,9 @@ impl JellyfinClient {
let url = format!("{}/Users/AuthenticateByName", self.base_url); let url = format!("{}/Users/AuthenticateByName", self.base_url);
let auth_header = format!( let auth_header = format!(
"MediaBrowser Client=\"cjtui\", Device=\"kitty\", DeviceId=\"cjtui\", Version=\"{}\"", "MediaBrowser Client=\"cjtui\", Device=\"{}\", DeviceId=\"{}\", Version=\"{}\"",
device_name(),
self.device_id,
env!("CARGO_PKG_VERSION"), env!("CARGO_PKG_VERSION"),
); );
@@ -345,9 +363,12 @@ impl JellyfinClient {
} }
fn auth_header_value(&self) -> String { fn auth_header_value(&self) -> String {
// Identify as a client and include the token. // Identify as a client and include the token. DeviceId is per-install so
// that two machines on the same account hold independent sessions.
format!( format!(
"MediaBrowser Client=\"cjtui\", Device=\"kitty\", DeviceId=\"cjtui\", Version=\"{}\", Token=\"{}\"", "MediaBrowser Client=\"cjtui\", Device=\"{}\", DeviceId=\"{}\", Version=\"{}\", Token=\"{}\"",
device_name(),
self.device_id,
env!("CARGO_PKG_VERSION"), env!("CARGO_PKG_VERSION"),
self.token self.token
) )
+11 -3
View File
@@ -220,7 +220,12 @@ struct MpvSession {
impl Default for App { impl Default for App {
fn default() -> Self { fn default() -> Self {
let loaded = core::config::load_config().ok(); let loaded = core::config::load_config().ok().map(|mut cfg| {
// Migrates configs written before device_id existed, so an upgraded
// install stops sharing one DeviceId with every other install.
core::config::ensure_device_id(&mut cfg);
cfg
});
let (view_stack, config_status, offline) = match &loaded { let (view_stack, config_status, offline) = match &loaded {
Some(cfg) if cfg.access_token.is_some() => { Some(cfg) if cfg.access_token.is_some() => {
@@ -432,7 +437,7 @@ fn handle_key(app: &mut App, code: KeyCode) {
} }
KeyCode::Enter => { KeyCode::Enter => {
let cfg = match &app.config { let mut cfg = match &app.config {
Some(c) => c.clone(), Some(c) => c.clone(),
None => { None => {
app.login_error = Some("No config loaded. Create config.toml with base_url.".to_string()); app.login_error = Some("No config loaded. Create config.toml with base_url.".to_string());
@@ -440,7 +445,9 @@ fn handle_key(app: &mut App, code: KeyCode) {
} }
}; };
let client = core::jellyfin::JellyfinClient::unauthenticated(&cfg.base_url); let device_id = core::config::ensure_device_id(&mut cfg);
let client = core::jellyfin::JellyfinClient::unauthenticated(&cfg.base_url, &device_id);
match client.login_by_name(&app.login_user, &app.login_pass) { match client.login_by_name(&app.login_user, &app.login_pass) {
Ok(res) => { Ok(res) => {
let mut new_cfg = cfg; let mut new_cfg = cfg;
@@ -3929,6 +3936,7 @@ fn ensure_config_before_tui() -> Result<(), String> {
base_url: u, base_url: u,
access_token: None, access_token: None,
user_id: None, user_id: None,
device_id: None, // generated and persisted on first login
}; };
config::save_config(&cfg).map_err(|e| format!("save_config failed: {e:?}"))?; config::save_config(&cfg).map_err(|e| format!("save_config failed: {e:?}"))?;