Fixing Multi-Device Compatibility
This commit is contained in:
@@ -7,6 +7,10 @@ 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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +49,7 @@ 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();
|
||||
@@ -68,6 +73,7 @@ fn parse_config(s: &str) -> Result<Config, String> {
|
||||
"base_url" => base_url = Some(val),
|
||||
"access_token" => access_token = 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,
|
||||
access_token,
|
||||
user_id,
|
||||
device_id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -100,6 +107,50 @@ pub fn save_config(cfg: &Config) -> Result<(), ConfigError> {
|
||||
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())
|
||||
}
|
||||
|
||||
+25
-4
@@ -9,6 +9,20 @@ pub struct JellyfinClient {
|
||||
base_url: String,
|
||||
client: reqwest::blocking::Client,
|
||||
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 {
|
||||
@@ -123,14 +137,16 @@ impl JellyfinClient {
|
||||
base_url: cfg.base_url.trim_end_matches('/').to_string(),
|
||||
client: reqwest::blocking::Client::new(),
|
||||
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 {
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
client: reqwest::blocking::Client::new(),
|
||||
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 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"),
|
||||
);
|
||||
|
||||
@@ -345,9 +363,12 @@ impl JellyfinClient {
|
||||
}
|
||||
|
||||
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!(
|
||||
"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"),
|
||||
self.token
|
||||
)
|
||||
|
||||
+11
-3
@@ -220,7 +220,12 @@ struct MpvSession {
|
||||
|
||||
impl Default for App {
|
||||
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 {
|
||||
Some(cfg) if cfg.access_token.is_some() => {
|
||||
@@ -432,7 +437,7 @@ fn handle_key(app: &mut App, code: KeyCode) {
|
||||
}
|
||||
|
||||
KeyCode::Enter => {
|
||||
let cfg = match &app.config {
|
||||
let mut cfg = match &app.config {
|
||||
Some(c) => c.clone(),
|
||||
None => {
|
||||
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) {
|
||||
Ok(res) => {
|
||||
let mut new_cfg = cfg;
|
||||
@@ -3929,6 +3936,7 @@ fn ensure_config_before_tui() -> Result<(), String> {
|
||||
base_url: u,
|
||||
access_token: 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:?}"))?;
|
||||
|
||||
Reference in New Issue
Block a user