download marking in place
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::VecDeque,
|
collections::{VecDeque, HashSet},
|
||||||
fs,
|
fs,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
process::{Child, Command, Stdio},
|
process::{Child, Command, Stdio},
|
||||||
@@ -92,6 +92,89 @@ impl DownloadManager {
|
|||||||
|
|
||||||
// ---------- directory helpers ----------
|
// ---------- directory helpers ----------
|
||||||
|
|
||||||
|
const DOWNLOAD_MARKER_EXT: &str = "cj-jftui.json";
|
||||||
|
|
||||||
|
fn marker_path_for_media(media_path: &Path) -> PathBuf {
|
||||||
|
// Example:
|
||||||
|
// Movie.mkv -> Movie.mkv.cj-jftui.json
|
||||||
|
let file_name = media_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("download");
|
||||||
|
|
||||||
|
media_path
|
||||||
|
.parent()
|
||||||
|
.unwrap_or_else(|| Path::new("."))
|
||||||
|
.join(format!("{file_name}.{DOWNLOAD_MARKER_EXT}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_download_marker(media_path: &Path, item_id: &str) -> Result<(), String> {
|
||||||
|
let marker = marker_path_for_media(media_path);
|
||||||
|
|
||||||
|
let payload = json!({
|
||||||
|
"item_id": item_id,
|
||||||
|
"saved_at_unix": std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.ok()
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0),
|
||||||
|
"media_file": media_path.file_name().and_then(|s| s.to_str()).unwrap_or(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
fs::write(&marker, payload.to_string())
|
||||||
|
.map_err(|e| format!("write marker failed: {e}"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_dir_for_markers(dir: &Path, out: &mut HashSet<String>) -> Result<(), String> {
|
||||||
|
if !dir.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for ent in fs::read_dir(dir).map_err(|e| format!("read_dir failed: {e}"))? {
|
||||||
|
let p = ent.map_err(|e| format!("dir entry failed: {e}"))?.path();
|
||||||
|
|
||||||
|
if p.is_dir() {
|
||||||
|
scan_dir_for_markers(&p, out)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for *.cj-jftui.json
|
||||||
|
let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
|
||||||
|
if !name.ends_with(DOWNLOAD_MARKER_EXT) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = match fs::read_to_string(&p) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let v: Value = match serde_json::from_str(raw.trim()) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(id) = v.get("item_id").and_then(|x| x.as_str()) {
|
||||||
|
if !id.trim().is_empty() {
|
||||||
|
out.insert(id.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public: scan the downloads root and return set of downloaded Jellyfin item ids.
|
||||||
|
pub fn scan_downloaded_ids() -> Result<HashSet<String>, String> {
|
||||||
|
let root = downloads_root()?;
|
||||||
|
let mut out = HashSet::new();
|
||||||
|
scan_dir_for_markers(&root, &mut out)?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn downloads_root() -> Result<PathBuf, String> {
|
pub fn downloads_root() -> Result<PathBuf, String> {
|
||||||
let dir = paths::app_config_dir().ok_or_else(|| "no config dir".to_string())?;
|
let dir = paths::app_config_dir().ok_or_else(|| "no config dir".to_string())?;
|
||||||
Ok(dir.join("downloads"))
|
Ok(dir.join("downloads"))
|
||||||
@@ -719,8 +802,6 @@ fn poll_progress_for_front(state: &Arc<Mutex<DownloadState>>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fn finalize_front(state: &Arc<Mutex<DownloadState>>) -> Result<(), String> {
|
fn finalize_front(state: &Arc<Mutex<DownloadState>>) -> Result<(), String> {
|
||||||
let (it, tmp_dir, final_abs_watch) = {
|
let (it, tmp_dir, final_abs_watch) = {
|
||||||
let st = state.lock().map_err(|_| "lock poisoned".to_string())?;
|
let st = state.lock().map_err(|_| "lock poisoned".to_string())?;
|
||||||
@@ -741,8 +822,8 @@ fn finalize_front(state: &Arc<Mutex<DownloadState>>) -> Result<(), String> {
|
|||||||
let real_file = find_real_downloaded_file(&tmp_dir)?;
|
let real_file = find_real_downloaded_file(&tmp_dir)?;
|
||||||
|
|
||||||
// Build final filename:
|
// Build final filename:
|
||||||
// Your final_rel_path ends in "...something.watch" or "...something.listen".
|
// final_rel_path ends in "...something.watch/.listen"
|
||||||
// We keep the same STEM and replace extension with the actual downloaded extension.
|
// Keep stem, replace extension with actual media extension
|
||||||
let real_ext = real_file
|
let real_ext = real_file
|
||||||
.extension()
|
.extension()
|
||||||
.and_then(|s| s.to_str())
|
.and_then(|s| s.to_str())
|
||||||
@@ -756,7 +837,6 @@ fn finalize_front(state: &Arc<Mutex<DownloadState>>) -> Result<(), String> {
|
|||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let final_abs = if real_ext.is_empty() {
|
let final_abs = if real_ext.is_empty() {
|
||||||
// fallback: no extension detected; keep .watch path as-is
|
|
||||||
final_abs_watch.clone()
|
final_abs_watch.clone()
|
||||||
} else {
|
} else {
|
||||||
final_parent.join(format!("{stem}.{real_ext}"))
|
final_parent.join(format!("{stem}.{real_ext}"))
|
||||||
@@ -765,6 +845,10 @@ fn finalize_front(state: &Arc<Mutex<DownloadState>>) -> Result<(), String> {
|
|||||||
// Move file into place
|
// Move file into place
|
||||||
fs::rename(&real_file, &final_abs).map_err(|e| format!("move to final failed: {e}"))?;
|
fs::rename(&real_file, &final_abs).map_err(|e| format!("move to final failed: {e}"))?;
|
||||||
|
|
||||||
|
// ✅ NEW: write marker so we can detect "downloaded" later
|
||||||
|
// (If marker fails, we still consider download successful.)
|
||||||
|
let _ = write_download_marker(&final_abs, &it.item_id);
|
||||||
|
|
||||||
// Cleanup temp folder fully
|
// Cleanup temp folder fully
|
||||||
let _ = fs::remove_dir_all(&tmp_dir);
|
let _ = fs::remove_dir_all(&tmp_dir);
|
||||||
|
|
||||||
|
|||||||
52
src/main.rs
52
src/main.rs
@@ -7,6 +7,7 @@ use std::{
|
|||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
process::{Child, Command, Stdio},
|
process::{Child, Command, Stdio},
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
|
collections::HashSet,
|
||||||
thread,
|
thread,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -182,6 +183,8 @@ struct App {
|
|||||||
downloads: Option<downloads::DownloadManager>,
|
downloads: Option<downloads::DownloadManager>,
|
||||||
downloads_focus: bool,
|
downloads_focus: bool,
|
||||||
downloads_selected: usize,
|
downloads_selected: usize,
|
||||||
|
downloaded_ids: HashSet<String>,
|
||||||
|
last_download_message: Option<String>,
|
||||||
|
|
||||||
autoplay: bool,
|
autoplay: bool,
|
||||||
autoplay_view_depth: usize,
|
autoplay_view_depth: usize,
|
||||||
@@ -256,6 +259,8 @@ impl Default for App {
|
|||||||
downloads: None,
|
downloads: None,
|
||||||
downloads_focus: false,
|
downloads_focus: false,
|
||||||
downloads_selected: 0,
|
downloads_selected: 0,
|
||||||
|
downloaded_ids: downloads::scan_downloaded_ids().unwrap_or_default(),
|
||||||
|
last_download_message: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -336,6 +341,21 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<
|
|||||||
app.mpv_state = MpvState::default();
|
app.mpv_state = MpvState::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh local-download index when downloads complete
|
||||||
|
if let Some(dm) = &app.downloads {
|
||||||
|
if let Ok(st) = dm.state.lock() {
|
||||||
|
let msg = st.last_message.clone();
|
||||||
|
|
||||||
|
if msg != app.last_download_message {
|
||||||
|
app.last_download_message = msg.clone();
|
||||||
|
|
||||||
|
if matches!(msg.as_deref(), Some("download complete")) {
|
||||||
|
app.downloaded_ids = downloads::scan_downloaded_ids().unwrap_or_default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Option A: always tick autoplay by polling eof-reached (window stays open)
|
// Option A: always tick autoplay by polling eof-reached (window stays open)
|
||||||
autoplay_tick(&mut app);
|
autoplay_tick(&mut app);
|
||||||
|
|
||||||
@@ -2699,9 +2719,10 @@ fn is_music_item(it: &core::jellyfin::JfItem) -> bool {
|
|||||||
matches!(it.item_type.as_deref(), Some("Audio" | "MusicAlbum" | "MusicArtist"))
|
matches!(it.item_type.as_deref(), Some("Audio" | "MusicAlbum" | "MusicArtist"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Placeholder for later
|
fn is_downloaded(app: &App, it: &core::jellyfin::JfItem) -> bool {
|
||||||
fn is_downloaded(_it: &core::jellyfin::JfItem) -> bool {
|
// Basic: only exact item-id matches.
|
||||||
false
|
// (No container inference for Series/Season/Album right now.)
|
||||||
|
app.downloaded_ids.contains(&it.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_played(it: &core::jellyfin::JfItem) -> bool {
|
fn is_played(it: &core::jellyfin::JfItem) -> bool {
|
||||||
@@ -2777,15 +2798,30 @@ fn is_in_progress_fast(it: &core::jellyfin::JfItem) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn emoji_prefix_for(app: &App, it: &core::jellyfin::JfItem) -> String {
|
||||||
|
// Keep your existing behavior: no emojis for music items (optional).
|
||||||
|
// If you DO want 💾 for music too, remove this early return.
|
||||||
|
if is_music_item(it) {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let downloaded = is_downloaded(app, it);
|
||||||
|
let played = is_played(it);
|
||||||
|
let inprog = is_in_progress_fast(it);
|
||||||
|
|
||||||
|
let mut s = String::new();
|
||||||
|
|
||||||
fn emoji_prefix_for(_app: &App, it: &core::jellyfin::JfItem) -> &'static str {
|
if downloaded {
|
||||||
if is_music_item(it) { return ""; }
|
s.push_str("💾 ");
|
||||||
|
}
|
||||||
|
|
||||||
if is_played(it) { return "✅ "; }
|
if played {
|
||||||
if is_in_progress_fast(it) { return "🟠 "; }
|
s.push_str("✅ ");
|
||||||
""
|
} else if inprog {
|
||||||
|
s.push_str("🟠 ");
|
||||||
|
}
|
||||||
|
|
||||||
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
Reference in New Issue
Block a user