Fixing Multi-Device Compatibility
This commit is contained in:
@@ -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
@@ -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
@@ -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:?}"))?;
|
||||||
|
|||||||
Reference in New Issue
Block a user