Added Jellyfin Auth

This commit is contained in:
2026-02-13 22:18:05 -06:00
parent 5f07f6e611
commit e3f312ac52
5 changed files with 1292 additions and 98 deletions

1085
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,4 +7,6 @@ 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"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }

View File

@@ -1,33 +1,82 @@
use serde::{Deserialize, Serialize};
use super::config::Config; use super::config::Config;
#[derive(Clone)] #[derive(Clone)]
pub struct JellyfinClient { pub struct JellyfinClient {
pub cfg: Config, base_url: String,
client: reqwest::blocking::Client,
} }
#[derive(Debug, Clone)] pub struct LoginResult {
pub struct JfItem { pub access_token: String,
pub id: String, pub user_id: Option<String>,
pub name: String,
pub item_type: String, // "Movie", "Series", etc.
pub image_tag: Option<String>,
} }
impl JellyfinClient { impl JellyfinClient {
pub fn new(cfg: Config) -> Self { pub fn from_config(cfg: &Config) -> Self {
Self { cfg } Self {
base_url: cfg.base_url.trim_end_matches('/').to_string(),
client: reqwest::blocking::Client::new(),
}
} }
// Placeholder for now — next step will use reqwest and actually call Jellyfin. pub fn login_by_name(&self, username: &str, password: &str) -> Result<LoginResult, String> {
pub fn test_connection_stub(&self) -> Result<String, String> { let url = format!("{}/Users/AuthenticateByName", self.base_url);
Ok(format!("Configured for {}", self.cfg.base_url))
}
// Placeholder list so we can wire UI without network yet // Jellyfin expects this format. :contentReference[oaicite:0]{index=0}
pub fn list_movies_stub(&self) -> Vec<JfItem> { let body = AuthenticateUserByName {
vec![ Username: username,
JfItem { id: "1".into(), name: "Movie A".into(), item_type: "Movie".into(), image_tag: None }, Pw: password,
JfItem { id: "2".into(), name: "Movie B".into(), item_type: "Movie".into(), image_tag: None }, };
]
// This header identifies the client/device. :contentReference[oaicite:1]{index=1}
let auth_header = r#"MediaBrowser Client="cj-jftui", Device="kitty", DeviceId="cj-jftui", Version="0.1.0""#;
let resp = self
.client
.post(url)
.header("X-Emby-Authorization", auth_header)
.json(&body)
.send()
.map_err(|e| format!("request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().unwrap_or_default();
return Err(format!("login failed: {status} {text}"));
}
let parsed: AuthenticationResult = resp
.json()
.map_err(|e| format!("bad response json: {e}"))?;
let token = parsed
.AccessToken
.ok_or_else(|| "missing AccessToken in response".to_string())?;
let user_id = parsed.User.and_then(|u| u.Id);
Ok(LoginResult {
access_token: token,
user_id,
})
} }
} }
#[derive(Serialize)]
struct AuthenticateUserByName<'a> {
Username: &'a str,
Pw: &'a str,
}
#[derive(Deserialize)]
struct AuthenticationResult {
AccessToken: Option<String>,
User: Option<UserDto>,
}
#[derive(Deserialize)]
struct UserDto {
Id: Option<String>,
}

View File

@@ -23,6 +23,13 @@ use ratatui::backend::CrosstermBackend;
enum View { enum View {
Categories, Categories,
Placeholder(String), // temporary for testing navigation Placeholder(String), // temporary for testing navigation
Login,
}
#[derive(Clone, Copy)]
enum LoginFocus {
User,
Pass,
} }
struct App { struct App {
@@ -34,20 +41,41 @@ struct App {
items: Vec<String>, items: Vec<String>,
selected: usize, selected: usize,
config: Option<core::config::Config>,
login_user: String,
login_pass: String,
login_focus: LoginFocus,
login_error: Option<String>,
} }
impl Default for App { impl Default for App {
fn default() -> Self { fn default() -> Self {
let config_status = match core::config::load_config() { let loaded = core::config::load_config().ok();
Ok(cfg) => format!("config: ok ({})", cfg.base_url),
Err(e) => format!("config: missing/invalid ({:?})", e), let (view_stack, config_status) = match &loaded {
Some(cfg) if cfg.access_token.is_some() => (
vec![View::Categories],
format!("auth: token present ({})", cfg.base_url),
),
Some(cfg) => (
vec![View::Login],
format!("auth: needs login ({})", cfg.base_url),
),
None => (
vec![View::Login],
"config: missing/invalid (need base_url)".to_string(),
),
}; };
Self { Self {
should_exit: false, should_exit: false,
status: "Press q to quit".to_string(), status: "Press q to quit".to_string(),
config_status, config_status,
view_stack: vec![View::Categories], config: loaded,
view_stack,
items: vec![ items: vec![
"Shows".into(), "Shows".into(),
"Movies".into(), "Movies".into(),
@@ -60,10 +88,16 @@ impl Default for App {
"Settings".into(), "Settings".into(),
], ],
selected: 0, selected: 0,
login_user: String::new(),
login_pass: String::new(),
login_focus: LoginFocus::User,
login_error: None,
} }
} }
} }
impl App { impl App {
fn current_view(&self) -> &View { fn current_view(&self) -> &View {
self.view_stack.last().unwrap() self.view_stack.last().unwrap()
@@ -115,6 +149,75 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<
} }
fn handle_key(app: &mut App, code: KeyCode) { fn handle_key(app: &mut App, code: KeyCode) {
// Special input handling in Login view
if matches!(app.current_view(), View::Login) {
match code {
KeyCode::Char('q') => { app.should_exit = true; return; }
KeyCode::Esc => { app.should_exit = true; return; }
KeyCode::Tab => {
app.login_focus = match app.login_focus {
LoginFocus::User => LoginFocus::Pass,
LoginFocus::Pass => LoginFocus::User,
};
return;
}
KeyCode::Backspace => {
match app.login_focus {
LoginFocus::User => { app.login_user.pop(); }
LoginFocus::Pass => { app.login_pass.pop(); }
}
return;
}
KeyCode::Enter => {
let cfg = match &app.config {
Some(c) => c.clone(),
None => {
app.login_error = Some("No config loaded. Create config.toml with base_url.".to_string());
return;
}
};
let client = core::jellyfin::JellyfinClient::from_config(&cfg);
match client.login_by_name(&app.login_user, &app.login_pass) {
Ok(res) => {
let mut new_cfg = cfg;
new_cfg.access_token = Some(res.access_token);
new_cfg.user_id = res.user_id;
if let Err(e) = core::config::save_config(&new_cfg) {
app.login_error = Some(format!("login ok but failed to write config: {e:?}"));
return;
}
app.config_status = format!("auth: token present ({})", new_cfg.base_url);
app.config = Some(new_cfg);
app.login_error = None;
app.login_pass.clear(); // don't keep password around
app.view_stack = vec![View::Categories];
app.status = "Logged in".to_string();
}
Err(e) => {
app.login_error = Some(e);
}
}
return;
}
KeyCode::Char(c) => {
match app.login_focus {
LoginFocus::User => app.login_user.push(c),
LoginFocus::Pass => app.login_pass.push(c),
}
return;
}
_ => return,
}
}
match code { match code {
KeyCode::Char('q') => app.should_exit = true, KeyCode::Char('q') => app.should_exit = true,
@@ -177,6 +280,9 @@ fn ui(frame: &mut Frame, app: &App) {
View::Placeholder(name) => { View::Placeholder(name) => {
render_placeholder(frame, name, panes.left_main, panes.right_main); render_placeholder(frame, name, panes.left_main, panes.right_main);
} }
View::Login => {
render_login(frame, app, panes.left_main, panes.right_main);
}
} }
// Bottom // Bottom
@@ -267,4 +373,36 @@ fn layout(area: Rect) -> Panes {
left_output, left_output,
right_output, right_output,
} }
} }
fn render_login(frame: &mut Frame, app: &App, left: Rect, right: Rect) {
let focus = match app.login_focus {
LoginFocus::User => "username",
LoginFocus::Pass => "password",
};
let masked = "*".repeat(app.login_pass.len());
let left_text = format!(
"Login (Tab switch, Enter submit)\n\nusername: {}\npassword: {}\n\nfocus: {}\n",
app.login_user, masked, focus
);
frame.render_widget(
Paragraph::new(left_text)
.block(Block::bordered().title("Login").merge_borders(MergeStrategy::Exact)),
left,
);
let right_text = match &app.login_error {
Some(e) => format!("Error:\n{e}"),
None => "Enter Jellyfin credentials.\nToken will be stored in config.toml.".to_string(),
};
frame.render_widget(
Paragraph::new(right_text)
.block(Block::bordered().title("Info").merge_borders(MergeStrategy::Exact)),
right,
);
}

View File

@@ -1,66 +0,0 @@
use std::io;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use ratatui::{
buffer::Buffer,
layout::{Rect, Constraint, Layout, Spacing},
style::Stylize,
symbols::{border, merge::MergeStrategy},
text::{Line, Text},
widgets::{Block, Paragraph, Widget},
DefaultTerminal, Frame,
};
fn main() -> io::Result<()> {
ratatui::run(|terminal| {
loop {
terminal.draw(render)?;
let should_exit: bool = exit_app()?;
if should_exit { break; }
}
Ok(())
})
}
fn render(frame: &mut Frame) {
use Constraint::{Fill, Length, Min};
let [top_area, rest_area] =
Layout::vertical([Fill(1), Fill(12)]).areas(frame.area());
let [main_area, output_area] =
Layout::vertical([Fill(9), Fill(3)])
.spacing(Spacing::Overlap(1))
.areas(rest_area);
let [left_main_area, right_main_area] =
Layout::horizontal([Fill(2), Fill(5)])
.spacing(Spacing::Overlap(1))
.areas(main_area);
let [left_output_area, right_output_area] =
Layout::horizontal([Fill(5), Fill(1)])
.spacing(Spacing::Overlap(1))
.areas(output_area);
frame.render_widget(Block::bordered(), top_area);
frame.render_widget(Block::bordered().merge_borders(MergeStrategy::Exact), left_output_area);
frame.render_widget(Block::bordered().merge_borders(MergeStrategy::Exact), right_output_area);
frame.render_widget(Block::bordered().merge_borders(MergeStrategy::Exact), left_main_area);
frame.render_widget(Block::bordered().merge_borders(MergeStrategy::Exact), right_main_area);
}
fn exit_app() -> io::Result<bool> {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
KeyCode::Char('q') => return Ok(true),
_ => {}
},
_ => {}
}
Ok(false)
}