offline mode almost there

This commit is contained in:
2026-02-16 18:38:23 -06:00
parent 81d75446fd
commit d805e666e9
3 changed files with 255 additions and 26 deletions

View File

@@ -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()));

View File

@@ -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);

View File

@@ -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,19 +237,27 @@ impl Default for App {
config: loaded,
view_stack,
offline,
items: vec![
"Shows".into(),
"Movies".into(),
"Music".into(),
"Playlists".into(),
"Collections".into(),
"Libraries".into(),
"Continue Watching".into(),
"Recently Added".into(),
"Downloads".into(),
"Settings".into(),
],
items: if offline {
vec![
"Downloaded".into(),
"Settings".into(),
]
} else {
vec![
"Shows".into(),
"Movies".into(),
"Music".into(),
"Playlists".into(),
"Collections".into(),
"Libraries".into(),
"Continue Watching".into(),
"Recently Added".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,
);
}