feat(store): add database storage with redb and bson support
This commit introduces a new `store` crate that provides database functionality using redb for storage and bson for serialization. It includes tables for users, servers, and settings, along with async operations for getting, inserting, modifying, and removing data. The store supports UUID keys and integrates with the existing Jellyfin client authentication flow. The changes also include: - Adding new dependencies to Cargo.lock for bitvec, bson, deranged, funty, num-conv, powerfmt, radium, serde_bytes, simdutf8, time, and wyz - Updating Cargo.toml to include the new store crate in workspace members - Modifying ui-iced to use the new database initialization flow with config loading from TOML - Adding a settings module to ui-iced with UI components for managing server and user configuration - Implementing secret string handling for sensitive data like passwords - Updating API client to support pre-authenticated clients with cached tokens
This commit is contained in:
@@ -12,6 +12,7 @@ iced = { workspace = true }
|
||||
iced_video_player = { workspace = true }
|
||||
reqwest = "0.12.24"
|
||||
tap = "1.0.1"
|
||||
toml = "0.9.8"
|
||||
tracing = "0.1.41"
|
||||
url = "2.5.7"
|
||||
uuid = "1.18.1"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// mod settings;
|
||||
mod shared_string;
|
||||
use iced_video_player::{Video, VideoPlayer};
|
||||
use shared_string::SharedString;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
mod blur_hash;
|
||||
@@ -157,6 +159,7 @@ pub enum Message {
|
||||
PasswordChanged(String),
|
||||
Login,
|
||||
LoginSuccess(String),
|
||||
LoadedClient(api::JellyfinClient, bool),
|
||||
Logout,
|
||||
Video(VideoMessage),
|
||||
}
|
||||
@@ -193,25 +196,12 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
||||
Message::Login => {
|
||||
let username = state.username_input.clone();
|
||||
let password = state.password_input.clone();
|
||||
|
||||
// Update the client config with the new credentials
|
||||
let mut config = (*state.jellyfin_client.config).clone();
|
||||
config.username = username;
|
||||
config.password = password;
|
||||
let config = (*state.jellyfin_client.config).clone();
|
||||
|
||||
Task::perform(
|
||||
async move {
|
||||
let mut client = api::JellyfinClient::new_with_config(config);
|
||||
client.authenticate().await
|
||||
},
|
||||
async move { api::JellyfinClient::authenticate(username, password, config).await },
|
||||
|result| match result {
|
||||
Ok(auth_result) => {
|
||||
if let Some(token) = auth_result.access_token {
|
||||
Message::LoginSuccess(token)
|
||||
} else {
|
||||
Message::Error("Authentication failed: No token received".to_string())
|
||||
}
|
||||
}
|
||||
Ok(client) => Message::LoadedClient(client, true),
|
||||
Err(e) => Message::Error(format!("Login failed: {}", e)),
|
||||
},
|
||||
)
|
||||
@@ -232,6 +222,15 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
||||
|_| Message::Refresh,
|
||||
)
|
||||
}
|
||||
Message::LoadedClient(client, is_authenticated) => {
|
||||
state.jellyfin_client = client;
|
||||
state.is_authenticated = is_authenticated;
|
||||
if is_authenticated {
|
||||
Task::done(Message::Refresh)
|
||||
} else {
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
Message::Logout => {
|
||||
state.is_authenticated = false;
|
||||
state.jellyfin_client.set_token("");
|
||||
@@ -372,7 +371,7 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
||||
.unwrap();
|
||||
state.video = Video::new(&url)
|
||||
.inspect_err(|err| {
|
||||
dbg!(err);
|
||||
tracing::error!("{err:?}");
|
||||
})
|
||||
.ok()
|
||||
.map(Arc::new);
|
||||
@@ -384,7 +383,7 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
||||
|
||||
fn view(state: &State) -> Element<'_, Message> {
|
||||
match state.screen {
|
||||
Screen::Settings => settings(state),
|
||||
// Screen::Settings => settings::settings(state),
|
||||
Screen::Home | _ => home(state),
|
||||
}
|
||||
}
|
||||
@@ -508,123 +507,6 @@ fn footer(state: &State) -> Element<'_, Message> {
|
||||
.into()
|
||||
}
|
||||
|
||||
fn settings(state: &State) -> Element<'_, Message> {
|
||||
let content = if state.is_authenticated {
|
||||
// Authenticated view - show user info and logout
|
||||
column([
|
||||
Text::new("Settings").size(32).into(),
|
||||
container(
|
||||
column([
|
||||
Text::new("Account").size(24).into(),
|
||||
Text::new("Server URL").size(14).into(),
|
||||
Text::new(state.jellyfin_client.config.server_url.as_str())
|
||||
.size(12)
|
||||
.into(),
|
||||
container(Text::new("Status: Logged In").size(14))
|
||||
.padding(10)
|
||||
.width(Length::Fill)
|
||||
.into(),
|
||||
container(
|
||||
row([
|
||||
Button::new(Text::new("Logout"))
|
||||
.padding(10)
|
||||
.on_press(Message::Logout)
|
||||
.into(),
|
||||
Button::new(Text::new("Close"))
|
||||
.padding(10)
|
||||
.on_press(Message::CloseSettings)
|
||||
.into(),
|
||||
])
|
||||
.spacing(10),
|
||||
)
|
||||
.padding(10)
|
||||
.width(Length::Fill)
|
||||
.into(),
|
||||
])
|
||||
.spacing(10)
|
||||
.max_width(400)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.padding(20)
|
||||
.width(Length::Fill)
|
||||
.align_x(Alignment::Center)
|
||||
.style(container::rounded_box)
|
||||
.into(),
|
||||
])
|
||||
.spacing(20)
|
||||
.padding(50)
|
||||
.align_x(Alignment::Center)
|
||||
} else {
|
||||
// Not authenticated view - show login form
|
||||
column([
|
||||
Text::new("Settings").size(32).into(),
|
||||
container(
|
||||
column([
|
||||
Text::new("Login to Jellyfin").size(24).into(),
|
||||
Text::new("Server URL").size(14).into(),
|
||||
Text::new(state.jellyfin_client.config.server_url.as_str())
|
||||
.size(12)
|
||||
.into(),
|
||||
container(
|
||||
TextInput::new("Username", &state.username_input)
|
||||
.padding(10)
|
||||
.size(16)
|
||||
.on_input(Message::UsernameChanged),
|
||||
)
|
||||
.padding(10)
|
||||
.width(Length::Fill)
|
||||
.into(),
|
||||
container(
|
||||
TextInput::new("Password", &state.password_input)
|
||||
.padding(10)
|
||||
.size(16)
|
||||
.secure(true)
|
||||
.on_input(Message::PasswordChanged)
|
||||
.on_submit(Message::Login),
|
||||
)
|
||||
.padding(10)
|
||||
.width(Length::Fill)
|
||||
.into(),
|
||||
container(
|
||||
row([
|
||||
Button::new(Text::new("Login"))
|
||||
.padding(10)
|
||||
.on_press(Message::Login)
|
||||
.into(),
|
||||
Button::new(Text::new("Cancel"))
|
||||
.padding(10)
|
||||
.on_press(Message::CloseSettings)
|
||||
.into(),
|
||||
])
|
||||
.spacing(10),
|
||||
)
|
||||
.padding(10)
|
||||
.width(Length::Fill)
|
||||
.into(),
|
||||
])
|
||||
.spacing(10)
|
||||
.max_width(400)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.padding(20)
|
||||
.width(Length::Fill)
|
||||
.align_x(Alignment::Center)
|
||||
.style(container::rounded_box)
|
||||
.into(),
|
||||
])
|
||||
.spacing(20)
|
||||
.padding(50)
|
||||
.align_x(Alignment::Center)
|
||||
};
|
||||
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn card(item: &Item) -> Element<'_, Message> {
|
||||
let name = item
|
||||
.name
|
||||
@@ -660,17 +542,47 @@ fn card(item: &Item) -> Element<'_, Message> {
|
||||
.into()
|
||||
}
|
||||
|
||||
// fn video(url: &str
|
||||
|
||||
fn init() -> (State, Task<Message>) {
|
||||
let mut jellyfin = api::JellyfinClient::new_with_config();
|
||||
// Create a default config for initial state
|
||||
let default_config = api::JellyfinConfig {
|
||||
server_url: "http://localhost:8096".parse().expect("Valid URL"),
|
||||
device_id: "jello-iced".to_string(),
|
||||
device_name: "Jello Iced".to_string(),
|
||||
client_name: "Jello".to_string(),
|
||||
version: "0.1.0".to_string(),
|
||||
};
|
||||
let default_client = api::JellyfinClient::new_with_config(default_config);
|
||||
|
||||
(
|
||||
State::new(jellyfin.clone()),
|
||||
State::new(default_client),
|
||||
Task::perform(
|
||||
async move { jellyfin.authenticate_with_cached_token(".session").await },
|
||||
|token| match token {
|
||||
Ok(token) => Message::SetToken(token),
|
||||
Err(e) => Message::Error(format!("Authentication failed: {}", e)),
|
||||
async move {
|
||||
// Load config from file
|
||||
let config_str = std::fs::read_to_string("config.toml")
|
||||
.map_err(|e| api::JellyfinApiError::IoError(e))?;
|
||||
let config: api::JellyfinConfig = toml::from_str(&config_str).map_err(|e| {
|
||||
api::JellyfinApiError::IoError(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
e,
|
||||
))
|
||||
})?;
|
||||
|
||||
// Try to load cached token and authenticate
|
||||
match std::fs::read_to_string(".session") {
|
||||
Ok(token) => {
|
||||
let client = api::JellyfinClient::pre_authenticated(token.trim(), config)?;
|
||||
Ok((client, true))
|
||||
}
|
||||
Err(_) => {
|
||||
// No cached token, create unauthenticated client
|
||||
let client = api::JellyfinClient::new_with_config(config);
|
||||
Ok((client, false))
|
||||
}
|
||||
}
|
||||
},
|
||||
|result: Result<_, api::JellyfinApiError>| match result {
|
||||
Ok((client, is_authenticated)) => Message::LoadedClient(client, is_authenticated),
|
||||
Err(e) => Message::Error(format!("Initialization failed: {}", e)),
|
||||
},
|
||||
)
|
||||
.chain(Task::done(Message::Refresh)),
|
||||
|
||||
56
ui-iced/src/settings.rs
Normal file
56
ui-iced/src/settings.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use crate::*;
|
||||
|
||||
pub fn settings(state: &State) -> Element<'_, Message> {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SettingsState {
|
||||
login_form: LoginForm,
|
||||
server_form: ServerForm,
|
||||
screen: SettingsScreen,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SettingsMessage {
|
||||
Open,
|
||||
Close,
|
||||
Select(SettingsScreen),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SettingsScreen {
|
||||
Main,
|
||||
Users,
|
||||
Servers,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerItem {
|
||||
pub id: uuid::Uuid,
|
||||
pub name: SharedString,
|
||||
pub url: SharedString,
|
||||
pub users: Vec<uuid::Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserItem {
|
||||
pub id: uuid::Uuid,
|
||||
pub name: SharedString,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoginForm {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerForm {
|
||||
name: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
mod screens {
|
||||
pub fn main(state: &State) -> Element<'_, Message> {}
|
||||
pub fn server(state: &State) -> Element<'_, Message> {}
|
||||
pub fn user(state: &State) -> Element<'_, Message> {}
|
||||
}
|
||||
@@ -49,6 +49,21 @@ impl std::ops::Deref for SharedString {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
pub struct SecretSharedString(ArcCow<'static, str>);
|
||||
|
||||
impl core::fmt::Debug for SecretSharedString {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("(..secret..)")
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for SecretSharedString {
|
||||
fn from(s: String) -> Self {
|
||||
Self(ArcCow::Owned(Arc::from(s)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Hash)]
|
||||
pub enum ArcCow<'a, T: ?Sized> {
|
||||
Borrowed(&'a T),
|
||||
@@ -66,3 +81,9 @@ where
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T> From<&'a T> for ArcCow<'a, T> {
|
||||
fn from(value: &'a T) -> Self {
|
||||
ArcCow::Borrowed(value)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user