offline mode almost there
This commit is contained in:
@@ -71,6 +71,98 @@ pub struct DownloadManager {
|
||||
pub state: Arc<Mutex<DownloadState>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LocalEntry {
|
||||
pub item_id: String,
|
||||
pub title: String,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// Scan downloads root for marker files and return local playable entries.
|
||||
/// Title is taken from marker JSON if present, else from the media filename stem.
|
||||
pub fn scan_local_entries() -> Vec<LocalEntry> {
|
||||
fn walk(dir: &Path, out: &mut Vec<LocalEntry>) {
|
||||
let rd = match std::fs::read_dir(dir) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for ent in rd.flatten() {
|
||||
let p = ent.path();
|
||||
|
||||
if p.is_dir() {
|
||||
walk(&p, out);
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = match p.file_name().and_then(|s| s.to_str()) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if !name.ends_with(DOWNLOAD_MARKER_EXT) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let raw = match std::fs::read_to_string(&p) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let v: serde_json::Value = match serde_json::from_str(raw.trim()) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let item_id = match v.get("item_id").and_then(|x| x.as_str()) {
|
||||
Some(s) if !s.is_empty() => s.to_string(),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// derive media path: "<media>.<DOWNLOAD_MARKER_EXT>" -> "<media>"
|
||||
let suffix = format!(".{DOWNLOAD_MARKER_EXT}");
|
||||
if !name.ends_with(&suffix) {
|
||||
continue;
|
||||
}
|
||||
let media_name = &name[..name.len() - suffix.len()];
|
||||
let media_path = p.with_file_name(media_name);
|
||||
if !media_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// title: prefer json field if you ever add it, else from filename
|
||||
let title = v
|
||||
.get("title")
|
||||
.and_then(|x| x.as_str())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
media_path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(media_name)
|
||||
.to_string()
|
||||
});
|
||||
|
||||
out.push(LocalEntry {
|
||||
item_id,
|
||||
title,
|
||||
path: media_path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
if let Ok(root) = downloads_root() {
|
||||
walk(&root, &mut out);
|
||||
}
|
||||
|
||||
// Stable sort: title
|
||||
out.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
impl DownloadManager {
|
||||
pub fn start(cfg: Config) -> Self {
|
||||
let state = Arc::new(Mutex::new(DownloadState::default()));
|
||||
|
||||
@@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::config::Config;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct JellyfinClient {
|
||||
base_url: String,
|
||||
@@ -112,6 +114,35 @@ impl JellyfinClient {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ping_base_url(base_url: &str) -> bool {
|
||||
// Fast, unauthenticated check. Latch result at startup.
|
||||
// We try a couple endpoints because Jellyfin setups vary.
|
||||
let base = base_url.trim_end_matches('/');
|
||||
|
||||
let client = match reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_millis(900))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let candidates = [
|
||||
format!("{base}/System/Ping"),
|
||||
format!("{base}/System/Info/Public"),
|
||||
];
|
||||
|
||||
for url in candidates {
|
||||
if let Ok(resp) = client.get(url).send() {
|
||||
if resp.status().is_success() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub fn list_views(&self, user_id: &str) -> Result<Vec<JfItem>, String> {
|
||||
let url = format!("{}/Users/{}/Views", self.base_url, user_id);
|
||||
|
||||
|
||||
138
src/main.rs
138
src/main.rs
@@ -48,6 +48,11 @@ enum View {
|
||||
page_size: usize,
|
||||
loading: bool,
|
||||
},
|
||||
OfflineLibrary {
|
||||
title: String,
|
||||
items: Vec<core::downloads::LocalEntry>,
|
||||
selected: usize,
|
||||
},
|
||||
Login,
|
||||
}
|
||||
|
||||
@@ -172,6 +177,7 @@ struct App {
|
||||
login_pass: String,
|
||||
login_focus: LoginFocus,
|
||||
login_error: Option<String>,
|
||||
offline: bool,
|
||||
|
||||
search: Option<SearchState>,
|
||||
mpv: Option<MpvSession>,
|
||||
@@ -203,19 +209,25 @@ impl Default for App {
|
||||
fn default() -> Self {
|
||||
let loaded = core::config::load_config().ok();
|
||||
|
||||
let (view_stack, config_status) = match &loaded {
|
||||
Some(cfg) if cfg.access_token.is_some() => (
|
||||
vec![View::Categories],
|
||||
format!("Connected: ({})", cfg.base_url),
|
||||
),
|
||||
Some(cfg) => (
|
||||
vec![View::Login],
|
||||
format!("Not Connected: ({})", cfg.base_url),
|
||||
),
|
||||
None => (
|
||||
vec![View::Login],
|
||||
"Missing Configuration".to_string(),
|
||||
),
|
||||
let (view_stack, config_status, offline) = match &loaded {
|
||||
Some(cfg) if cfg.access_token.is_some() => {
|
||||
let online = core::jellyfin::JellyfinClient::ping_base_url(&cfg.base_url);
|
||||
if online {
|
||||
(vec![View::Categories], format!("✅ Online: ({})", cfg.base_url), false)
|
||||
} else {
|
||||
(vec![View::Categories], format!("📴 Offline: ({})", cfg.base_url), true)
|
||||
}
|
||||
}
|
||||
Some(cfg) => {
|
||||
// no token: treat as "not connected", but still allow offline browsing
|
||||
let online = core::jellyfin::JellyfinClient::ping_base_url(&cfg.base_url);
|
||||
if online {
|
||||
(vec![View::Login], format!("Not Connected: ({})", cfg.base_url), false)
|
||||
} else {
|
||||
(vec![View::Categories], format!("📴 Offline (no token): ({})", cfg.base_url), true)
|
||||
}
|
||||
}
|
||||
None => (vec![View::Login], "Missing Configuration".to_string(), false),
|
||||
};
|
||||
|
||||
Self {
|
||||
@@ -225,8 +237,15 @@ impl Default for App {
|
||||
config: loaded,
|
||||
|
||||
view_stack,
|
||||
offline,
|
||||
|
||||
items: vec![
|
||||
items: if offline {
|
||||
vec![
|
||||
"Downloaded".into(),
|
||||
"Settings".into(),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
"Shows".into(),
|
||||
"Movies".into(),
|
||||
"Music".into(),
|
||||
@@ -235,9 +254,10 @@ impl Default for App {
|
||||
"Libraries".into(),
|
||||
"Continue Watching".into(),
|
||||
"Recently Added".into(),
|
||||
"Downloads".into(),
|
||||
"Downloaded".into(),
|
||||
"Settings".into(),
|
||||
],
|
||||
]
|
||||
},
|
||||
selected: 0,
|
||||
|
||||
login_user: String::new(),
|
||||
@@ -261,6 +281,7 @@ impl Default for App {
|
||||
downloads_selected: 0,
|
||||
downloaded_ids: downloads::scan_downloaded_ids().unwrap_or_default(),
|
||||
last_download_message: None,
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -688,6 +709,9 @@ fn handle_key(app: &mut App, code: KeyCode) {
|
||||
View::Library { items, selected, .. } if !items.is_empty() => {
|
||||
(items[*selected].id.clone(), items[*selected].name.clone())
|
||||
}
|
||||
View::OfflineLibrary { items, selected, .. } if !items.is_empty() => {
|
||||
(items[*selected].item_id.clone(), items[*selected].title.clone())
|
||||
}
|
||||
_ => { app.status = "Nothing to play here".into(); return; }
|
||||
};
|
||||
|
||||
@@ -719,6 +743,10 @@ fn handle_key(app: &mut App, code: KeyCode) {
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('P') => {
|
||||
if matches!(app.current_view(), View::OfflineLibrary { .. }) {
|
||||
app.status = "Offline: autoplay disabled (use 'p')".into();
|
||||
return;
|
||||
}
|
||||
let cfg = match &app.config { Some(c) => c.clone(), None => { app.status="No config".into(); return; } };
|
||||
|
||||
let (item_id, item_name, selected, len) = match app.current_view() {
|
||||
@@ -968,6 +996,23 @@ fn handle_key(app: &mut App, code: KeyCode) {
|
||||
View::Categories => {
|
||||
let cat = app.items[app.selected].clone();
|
||||
|
||||
// OFFLINE: local-only library
|
||||
if app.offline {
|
||||
if cat == "Downloaded" {
|
||||
let items = crate::core::downloads::scan_local_entries();
|
||||
|
||||
app.view_stack.push(View::OfflineLibrary {
|
||||
title: "Downloaded".into(),
|
||||
items,
|
||||
selected: 0,
|
||||
});
|
||||
app.status = "$".into();
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything else offline can remain as-is (Downloads/Settings etc)
|
||||
}
|
||||
|
||||
let cfg = match &app.config {
|
||||
Some(c) => c.clone(),
|
||||
None => { app.status = "No config loaded".into(); return; }
|
||||
@@ -1182,6 +1227,11 @@ fn handle_key(app: &mut App, code: KeyCode) {
|
||||
|
||||
maybe_prefetch(app);
|
||||
}
|
||||
View::OfflineLibrary { items, selected, .. } => {
|
||||
if items.is_empty() { return; }
|
||||
if *selected == 0 { *selected = items.len() - 1; }
|
||||
else { *selected -= 1; }
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1217,6 +1267,11 @@ fn handle_key(app: &mut App, code: KeyCode) {
|
||||
|
||||
maybe_prefetch(app);
|
||||
}
|
||||
View::OfflineLibrary { items, selected, .. } => {
|
||||
if items.is_empty() { return; }
|
||||
*selected = (*selected + 1) % items.len();
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1528,6 +1583,9 @@ fn ui(frame: &mut Frame, app: &App) {
|
||||
// Main
|
||||
|
||||
match app.current_view() {
|
||||
View::OfflineLibrary { title, items, selected } => {
|
||||
render_offline_library(frame, title, items, *selected, panes.left_main, panes.right_main);
|
||||
}
|
||||
View::Categories => {
|
||||
render_categories(frame, app, panes.left_main, panes.right_main);
|
||||
}
|
||||
@@ -2986,3 +3044,51 @@ fn render_login(frame: &mut Frame, app: &App, left: Rect, right: Rect) {
|
||||
right,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_offline_library(
|
||||
frame: &mut Frame,
|
||||
title: &str,
|
||||
items: &[core::downloads::LocalEntry],
|
||||
selected: usize,
|
||||
left: Rect,
|
||||
right: Rect,
|
||||
) {
|
||||
let viewport = left.height.saturating_sub(2) as usize;
|
||||
let len = items.len();
|
||||
let selected = selected.min(len.saturating_sub(1));
|
||||
|
||||
let (start, end) = compute_window(len, selected, viewport);
|
||||
|
||||
let list_items: Vec<ListItem> = items[start..end]
|
||||
.iter()
|
||||
.map(|it| ListItem::new(format!("💾 {}", it.title)))
|
||||
.collect();
|
||||
|
||||
let list = List::new(list_items)
|
||||
.block(
|
||||
Block::bordered()
|
||||
.title(format!("{title} [{}/{}]", items.len(), items.len()))
|
||||
.merge_borders(MergeStrategy::Exact),
|
||||
)
|
||||
.highlight_symbol("> ")
|
||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
|
||||
let mut state = ListState::default();
|
||||
state.select(if len > 0 { Some(selected - start) } else { None });
|
||||
|
||||
frame.render_stateful_widget(list, left, &mut state);
|
||||
|
||||
let detail = if len > 0 {
|
||||
let it = &items[selected];
|
||||
format!("Title: {}\nItemId: {}\nPath:\n{}", it.title, it.item_id, it.path.display())
|
||||
} else {
|
||||
"No downloaded items found".to_string()
|
||||
};
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(detail)
|
||||
.wrap(Wrap { trim: false })
|
||||
.block(Block::bordered().title("Details").merge_borders(MergeStrategy::Exact)),
|
||||
right,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user