half way to watch status complete

This commit is contained in:
2026-02-16 12:38:51 -06:00
parent c030bbb155
commit 46d50de1a9

View File

@@ -181,10 +181,6 @@ struct App {
autoplay_next_index: Option<usize>, autoplay_next_index: Option<usize>,
autoplay_eof_latched: bool, autoplay_eof_latched: bool,
autoplay_prev_time_pos: Option<f64>, autoplay_prev_time_pos: Option<f64>,
// Cache: container item id -> has resumable episode under it
parent_progress: std::collections::HashMap<String, bool>,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -250,7 +246,6 @@ impl Default for App {
autoplay_next_index: None, autoplay_next_index: None,
autoplay_eof_latched: false, autoplay_eof_latched: false,
autoplay_prev_time_pos: None, autoplay_prev_time_pos: None,
parent_progress: std::collections::HashMap::new(),
} }
} }
} }
@@ -412,7 +407,6 @@ fn handle_key(app: &mut App, code: KeyCode) {
_ => return, _ => return,
} }
} }
// If search mode is active, it captures input first // If search mode is active, it captures input first
if let Some(search) = &mut app.search { if let Some(search) = &mut app.search {
match code { match code {
@@ -453,7 +447,6 @@ fn handle_key(app: &mut App, code: KeyCode) {
match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) { match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) {
Ok(page) => { Ok(page) => {
update_parent_progress_cache(app, &client, user_id, &page.items);
app.view_stack.push(View::Library { app.view_stack.push(View::Library {
title, title,
query, query,
@@ -823,7 +816,6 @@ fn handle_key(app: &mut App, code: KeyCode) {
match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) { match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) {
Ok(page) => { Ok(page) => {
update_parent_progress_cache(app, &client, user_id, &page.items);
app.view_stack.push(View::Library { app.view_stack.push(View::Library {
title: cat, title: cat,
query, query,
@@ -903,7 +895,6 @@ fn handle_key(app: &mut App, code: KeyCode) {
match client.playlist_items(user_id, &it.id, 0, PAGE_SIZE) { match client.playlist_items(user_id, &it.id, 0, PAGE_SIZE) {
Ok(page) => { Ok(page) => {
update_parent_progress_cache(app, &client, user_id, &page.items);
app.view_stack.push(View::Library { app.view_stack.push(View::Library {
title: format!("{} > {}", title, it.name), title: format!("{} > {}", title, it.name),
// store a query placeholder; playlist paging could be done later if you want // store a query placeholder; playlist paging could be done later if you want
@@ -1063,8 +1054,6 @@ fn load_next_library_page(app: &mut App) {
Err(e) => { app.status = format!("Fetch failed: {e}"); finish_loading(app); return; } Err(e) => { app.status = format!("Fetch failed: {e}"); finish_loading(app); return; }
}; };
update_parent_progress_cache(app, &client, user_id, &page.items);
if let Some(View::Library { items, total, .. }) = app.view_stack.last_mut() { if let Some(View::Library { items, total, .. }) = app.view_stack.last_mut() {
items.extend(page.items); items.extend(page.items);
*total = page.total; *total = page.total;
@@ -1079,33 +1068,6 @@ fn finish_loading(app: &mut App) {
} }
} }
fn update_parent_progress_cache(
app: &mut App,
client: &core::jellyfin::JellyfinClient,
user_id: &str,
new_items: &[core::jellyfin::JfItem],
) {
for it in new_items {
let ty = it.item_type.as_deref().unwrap_or("");
if ty != "Series" && ty != "Season" {
continue;
}
if app.parent_progress.contains_key(&it.id) {
continue;
}
match client.has_resumable_under(user_id, &it.id) {
Ok(has) => {
app.parent_progress.insert(it.id.clone(), has);
}
Err(_) => {
// If the server rejects the query, just leave it unknown.
}
}
}
}
fn maybe_prefetch(app: &mut App) { fn maybe_prefetch(app: &mut App) {
const PREFETCH_ROWS: usize = 0; const PREFETCH_ROWS: usize = 0;
@@ -1420,7 +1382,7 @@ fn ui(frame: &mut Frame, app: &App) {
.unwrap_or("-"); .unwrap_or("-");
let right_text = format!( let right_text = format!(
"{connected}\n\np/P: play/autoplay\nq: quit\n/help: more help" "{connected}\n\np/P: play/autoplay\nr/R: resume/autoplay\nq: quit\n/help: more help"
); );
frame.render_widget( frame.render_widget(
@@ -1611,7 +1573,6 @@ fn open_children(app: &mut App, title: String, query: core::jellyfin::LibraryQue
match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) { match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) {
Ok(page) => { Ok(page) => {
update_parent_progress_cache(app, &client, user_id, &page.items);
app.view_stack.push(View::Library { app.view_stack.push(View::Library {
title, title,
query, query,
@@ -1710,7 +1671,6 @@ fn mpv_cmd(socket: &PathBuf, cmd: &str) -> Result<(), String> {
mpv_send(socket, &format!("{cmd}\n")) mpv_send(socket, &format!("{cmd}\n"))
} }
fn ticks_to_seconds(ticks: u64) -> f64 { fn ticks_to_seconds(ticks: u64) -> f64 {
// Jellyfin ticks are 10,000,000 per second // Jellyfin ticks are 10,000,000 per second
(ticks as f64) / 10_000_000.0 (ticks as f64) / 10_000_000.0
@@ -1738,7 +1698,6 @@ fn mpv_seek_absolute(socket: &PathBuf, seconds: f64) -> Result<(), String> {
Err("mpv seek failed (mpv never became ready or rejected seek)".into()) Err("mpv seek failed (mpv never became ready or rejected seek)".into())
} }
fn spawn_mpv(cfg: &core::config::Config, first_item_id: &str) -> Result<MpvSession, String> { fn spawn_mpv(cfg: &core::config::Config, first_item_id: &str) -> Result<MpvSession, String> {
let token = cfg let token = cfg
.access_token .access_token
@@ -2165,10 +2124,6 @@ fn mpv_load_url(cfg: &core::config::Config, mpv: &MpvSession, url: &str) -> Resu
) )
} }
fn track_label(title: Option<&str>, lang: Option<&str>, id: Option<i64>, kind: &str) -> String { fn track_label(title: Option<&str>, lang: Option<&str>, id: Option<i64>, kind: &str) -> String {
let mut parts: Vec<String> = Vec::new(); let mut parts: Vec<String> = Vec::new();
@@ -2394,8 +2349,6 @@ fn unicode_progress_bar(width: usize, ratio: f64) -> String {
s s
} }
// --- watched / in-progress helpers ---
fn is_music_item(it: &core::jellyfin::JfItem) -> bool { 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"))
} }
@@ -2406,20 +2359,21 @@ fn is_downloaded(_it: &core::jellyfin::JfItem) -> bool {
} }
fn is_played(it: &core::jellyfin::JfItem) -> bool { fn is_played(it: &core::jellyfin::JfItem) -> bool {
// direct played flag
if it.user_data.as_ref().and_then(|u| u.played) == Some(true) { if it.user_data.as_ref().and_then(|u| u.played) == Some(true) {
return true; return true;
} }
// If Jellyfin provides played percentage // aggregate containers: Season/Series can have UnplayedItemCount
if let Some(p) = it.user_data.as_ref().and_then(|u| u.played_percentage) { if matches!(it.item_type.as_deref(), Some("Season" | "Series")) {
if p >= 99.9 { if it.user_data.as_ref().and_then(|u| u.unplayed_item_count) == Some(0) {
return true; return true;
} }
} }
// Series/Season often only has UnplayedItemCount // percent-based fallback (works for many types)
if matches!(it.item_type.as_deref(), Some("Series" | "Season")) { if let Some(p) = it.user_data.as_ref().and_then(|u| u.played_percentage) {
if it.user_data.as_ref().and_then(|u| u.unplayed_item_count) == Some(0) { if p >= 99.9 {
return true; return true;
} }
} }
@@ -2427,28 +2381,27 @@ fn is_played(it: &core::jellyfin::JfItem) -> bool {
false false
} }
fn is_in_progress_fast(it: &core::jellyfin::JfItem) -> bool {
fn is_in_progress_with_cache(app: &App, it: &core::jellyfin::JfItem) -> bool {
if is_played(it) { if is_played(it) {
return false; return false;
} }
// movies/episodes match it.item_type.as_deref() {
if playback_position_ticks(it).is_some() { // Movies/Episodes: only if resume ticks exist
return true; Some("Movie" | "Episode") => playback_position_ticks(it).is_some(),
}
// series/seasons: ask cache first (based on resumable episodes) // Seasons: only percent-based
if matches!(it.item_type.as_deref(), Some("Series") | Some("Season")) { Some("Season") => {
if let Some(v) = app.parent_progress.get(&it.id) { if let Some(p) = it.user_data.as_ref().and_then(|u| u.played_percentage) {
return *v; p > 0.1 && p < 99.9
} else {
false
} }
} }
// fallback: percentage-based if server provides it // Series: NO in-progress marker (by your requirement)
match it.user_data.as_ref().and_then(|u| u.played_percentage) { Some("Series") => false,
Some(p) if p > 0.1 && p < 99.9 => true,
_ => false, _ => false,
} }
} }
@@ -2458,19 +2411,25 @@ fn is_in_progress(it: &core::jellyfin::JfItem) -> bool {
return false; return false;
} }
// movies/episodes match it.item_type.as_deref() {
if playback_position_ticks(it).is_some() { // Movies/Episodes: only if resume ticks exist
return true; Some("Movie" | "Episode") => playback_position_ticks(it).is_some(),
}
// Seasons: percent-based
Some("Season") => it.user_data
.as_ref()
.and_then(|u| u.played_percentage)
.is_some_and(|p| p > 0.1 && p < 99.9),
// Series: no in-progress marker (your requirement)
Some("Series") => false,
// series/seasons (percentage-based)
match it.user_data.as_ref().and_then(|u| u.played_percentage) {
Some(p) if p > 0.1 && p < 99.9 => true,
_ => false, _ => false,
} }
} }
fn playback_position_ticks(it: &core::jellyfin::JfItem) -> Option<u64> { fn playback_position_ticks(it: &core::jellyfin::JfItem) -> Option<u64> {
it.user_data it.user_data
.as_ref() .as_ref()
@@ -2478,24 +2437,23 @@ fn playback_position_ticks(it: &core::jellyfin::JfItem) -> Option<u64> {
.and_then(|v| if v > 0 { Some(v as u64) } else { None }) .and_then(|v| if v > 0 { Some(v as u64) } else { None })
} }
fn emoji_prefix_for(app: &App, it: &core::jellyfin::JfItem) -> &'static str { fn emoji_prefix_for(_app: &App, it: &core::jellyfin::JfItem) -> &'static str {
// no emoji for music // no emoji for music
if is_music_item(it) { if is_music_item(it) {
return ""; return "";
} }
let played = is_played(it); if is_played(it) {
let prog = is_in_progress_with_cache(app, it); return "";
match (played, prog) {
(true, _) => "",
(false, true) => "🟠 ", // orange in-progress
(false, false) => "",
} }
if is_in_progress(it) {
return "🟠 ";
}
""
} }
// --- transcoding + resume URL builder (Approach B) --- // --- transcoding + resume URL builder (Approach B) ---
fn clamp_mbps_to_bps(mbps: u32) -> u32 { fn clamp_mbps_to_bps(mbps: u32) -> u32 {
mbps.saturating_mul(1_000_000) // Mb/s -> bits/s mbps.saturating_mul(1_000_000) // Mb/s -> bits/s
@@ -2548,8 +2506,6 @@ fn mpv_cmd_reply(socket: &PathBuf, cmd: &str) -> Result<Value, String> {
struct Panes { struct Panes {
top: Rect, top: Rect,
left_main: Rect, left_main: Rect,