use std::cmp::Ordering;
use std::time::Duration;
use chrono::{DateTime, Local};
use serde_json::Value;
use crate::http::get_text_with_retries;
pub(crate) fn parse_title_and_total_eps(title: &str) -> (String, Option<u32>) {
let trimmed = title.trim();
let Some(open_idx) = trimmed.rfind('(') else {
return (trimmed.to_string(), None);
};
if !trimmed.ends_with(')') {
return (trimmed.to_string(), None);
}
let inner = trimmed[open_idx + 1..trimmed.len() - 1].trim();
let Some(num_str) = inner.strip_suffix(" episodes") else {
return (trimmed.to_string(), None);
};
let Ok(num) = num_str.trim().parse::<u32>() else {
return (trimmed.to_string(), None);
};
(trimmed[..open_idx].trim().to_string(), Some(num))
}
pub(crate) fn parse_episode_f64(ep: &str) -> Option<f64> {
ep.trim().parse::<f64>().ok()
}
pub(crate) fn episode_labels_match(a: &str, b: &str) -> bool {
let left = a.trim();
let right = b.trim();
if left == right {
return true;
}
match (parse_episode_f64(left), parse_episode_f64(right)) {
(Some(x), Some(y)) => (x - y).abs() < 0.000_001,
_ => false,
}
}
pub(crate) fn compare_episode_labels(a: &str, b: &str) -> Ordering {
match (parse_episode_f64(a), parse_episode_f64(b)) {
(Some(left), Some(right)) => left.partial_cmp(&right).unwrap_or(Ordering::Equal),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => a.cmp(b),
}
}
#[cfg(test)]
pub(crate) fn parse_mode_episode_labels(raw: &str, mode: &str) -> Option<Vec<String>> {
let value: Value = serde_json::from_str(raw).ok()?;
parse_mode_episode_labels_from_value(&value, mode)
}
fn parse_mode_episode_labels_from_value(value: &Value, mode: &str) -> Option<Vec<String>> {
let items = value
.pointer("/data/show/availableEpisodesDetail")?
.get(mode)?
.as_array()?;
let mut episodes = Vec::new();
for item in items {
if item.is_null() {
continue;
}
let value = match item {
Value::String(text) => text.trim().to_string(),
Value::Number(number) => number.to_string(),
_ => continue,
};
if !value.is_empty() && value != "null" {
episodes.push(value);
}
}
if episodes.is_empty() {
None
} else {
Some(episodes)
}
}
pub(crate) fn choose_episode_labels_candidate(
candidates: Vec<Vec<String>>,
total_hint: Option<u32>,
) -> Option<Vec<String>> {
if candidates.is_empty() {
return None;
}
if let Some(total) = total_hint {
for candidate in &candidates {
if candidate.len() as u32 == total {
return Some(candidate.clone());
}
}
}
candidates.into_iter().max_by_key(|episodes| episodes.len())
}
#[derive(Debug, Clone, Default)]
pub(crate) struct EpisodeLabelFetchOutcome {
pub(crate) episode_list: Option<Vec<String>>,
pub(crate) warnings: Vec<String>,
}
pub(crate) fn fetch_episode_labels_with_diagnostics(
ani_id: &str,
total_hint: Option<u32>,
) -> EpisodeLabelFetchOutcome {
let query = "query ($showId: String!) { show( _id: $showId ) { _id availableEpisodesDetail }}";
let variables = format!("{{\"showId\":\"{ani_id}\"}}");
let query_params = vec![
("variables".to_string(), variables),
("query".to_string(), query.to_string()),
];
let raw = match get_text_with_retries(
"https://api.allanime.day/api",
"https://allanime.to",
&query_params,
Duration::from_secs(3),
Duration::from_secs(5),
3,
Duration::from_secs(1),
) {
Ok(raw) => raw,
Err(err) => {
let warning = format!("episode metadata request failed for {ani_id}: {err}");
return EpisodeLabelFetchOutcome {
episode_list: None,
warnings: vec![warning],
};
}
};
let parsed: Value = match serde_json::from_str(&raw) {
Ok(parsed) => parsed,
Err(err) => {
return EpisodeLabelFetchOutcome {
episode_list: None,
warnings: vec![format!(
"episode metadata response parse failed for {ani_id}: {err}"
)],
};
}
};
let mut candidates = Vec::new();
if let Some(sub) = parse_mode_episode_labels_from_value(&parsed, "sub") {
candidates.push(sub);
}
if let Some(dub) = parse_mode_episode_labels_from_value(&parsed, "dub") {
candidates.push(dub);
}
let Some(mut episodes) = choose_episode_labels_candidate(candidates, total_hint) else {
return EpisodeLabelFetchOutcome {
episode_list: None,
warnings: vec![format!(
"episode metadata response for {ani_id} did not contain usable sub/dub episode labels"
)],
};
};
episodes.sort_by(|left, right| compare_episode_labels(left, right));
EpisodeLabelFetchOutcome {
episode_list: Some(episodes),
warnings: Vec::new(),
}
}
pub(crate) fn replay_seed_episode(
last_episode: &str,
episode_list: Option<&[String]>,
) -> Option<String> {
if let Some(episodes) = episode_list
&& let Some(idx) = episodes
.iter()
.position(|episode| episode_labels_match(episode, last_episode))
{
if idx > 0 {
return episodes.get(idx - 1).cloned();
}
return None;
}
let current = parse_episode_u32(last_episode)?;
if current > 1 {
Some((current - 1).to_string())
} else {
None
}
}
pub(crate) fn previous_target_episode(
last_episode: &str,
episode_list: Option<&[String]>,
) -> Option<String> {
if let Some(episodes) = episode_list
&& let Some(idx) = episodes
.iter()
.position(|episode| episode_labels_match(episode, last_episode))
{
if idx > 0 {
return episodes.get(idx - 1).cloned();
}
return None;
}
let current = parse_episode_f64(last_episode)?;
if current <= 0.0 {
return None;
}
if is_effective_integer(current) {
return integer_episode_label(current - 1.0);
}
integer_episode_label(current.floor())
}
pub(crate) fn previous_seed_episode(
last_episode: &str,
episode_list: Option<&[String]>,
) -> Option<String> {
if let Some(episodes) = episode_list
&& let Some(idx) = episodes
.iter()
.position(|episode| episode_labels_match(episode, last_episode))
{
if idx > 1 {
return episodes.get(idx - 2).cloned();
}
return None;
}
let target = previous_target_episode(last_episode, None)?;
let target_value = parse_episode_f64(&target)?;
if target_value > 1.0 {
integer_episode_label(target_value - 1.0)
} else {
None
}
}
pub(crate) fn has_next_episode(
last_episode: &str,
total_episodes: Option<u32>,
episode_list: Option<&[String]>,
) -> bool {
if let Some(episodes) = episode_list
&& let Some(idx) = episodes
.iter()
.position(|episode| episode_labels_match(episode, last_episode))
{
return idx + 1 < episodes.len();
}
if let (Some(total), Some(current)) = (total_episodes, parse_episode_u32(last_episode)) {
return current < total;
}
true
}
pub(crate) fn has_previous_episode(last_episode: &str, episode_list: Option<&[String]>) -> bool {
previous_target_episode(last_episode, episode_list).is_some()
}
pub(crate) fn integer_episode_label(value: f64) -> Option<String> {
if !value.is_finite() || value < 0.0 {
return None;
}
let rounded = value.round();
if !is_effective_integer(rounded) {
return None;
}
Some(format!("{}", rounded as i64))
}
pub(crate) fn is_effective_integer(value: f64) -> bool {
(value - value.round()).abs() < 0.000_001
}
pub(crate) fn episode_ordinal_from_list(last_episode: &str, episodes: &[String]) -> Option<u32> {
episodes
.iter()
.position(|episode| episode_labels_match(episode, last_episode))
.map(|idx| (idx + 1) as u32)
}
pub(crate) fn episode_progress_position(
last_episode: &str,
total_episodes: u32,
episode_list: Option<&[String]>,
) -> Option<u32> {
if total_episodes == 0 {
return None;
}
if let Some(episodes) = episode_list
&& let Some(ordinal) = episode_ordinal_from_list(last_episode, episodes)
{
return Some(ordinal.min(total_episodes));
}
parse_episode_u32(last_episode).map(|current| current.min(total_episodes))
}
pub(crate) fn format_episode_progress_text(
last_episode: &str,
total_episodes: u32,
episode_list: Option<&[String]>,
) -> String {
match episode_progress_position(last_episode, total_episodes, episode_list) {
Some(position) => {
if parse_episode_u32(last_episode) == Some(position) {
format!("{position} of {total_episodes}")
} else {
format!("{position} of {total_episodes} (episode {last_episode})")
}
}
None => format!("{last_episode} of {total_episodes}"),
}
}
pub(crate) fn build_progress_gauge(
last_episode: &str,
total_episodes: u32,
episode_list: Option<&[String]>,
) -> Option<(f64, String)> {
let shown = episode_progress_position(last_episode, total_episodes, episode_list)?;
let ratio = (shown as f64 / total_episodes as f64).clamp(0.0, 1.0);
Some((ratio, format!("{shown}/{total_episodes}")))
}
pub(crate) fn truncate(s: &str, max: usize) -> String {
let mut out = s.to_string();
if out.chars().count() > max {
out = out.chars().take(max.saturating_sub(3)).collect::<String>() + "...";
}
out
}
pub(crate) fn sanitize_title_for_search(title: &str) -> String {
let trimmed = title.trim();
if let Some(open_idx) = trimmed.rfind('(')
&& trimmed.ends_with(')')
&& trimmed[open_idx..].contains("episodes")
{
return trimmed[..open_idx].trim().to_string();
}
trimmed.to_string()
}
pub(crate) fn parse_episode_u32(ep: &str) -> Option<u32> {
ep.trim().parse::<u32>().ok()
}
pub(crate) fn format_last_seen_display(raw: &str) -> String {
format_last_seen_display_with_pattern(raw, "%Y-%m-%d %H:%M %:z")
}
pub(crate) fn format_last_seen_display_tui(raw: &str) -> String {
format_last_seen_display_with_pattern(raw, "%Y-%m-%d %H:%M")
}
fn format_last_seen_display_with_pattern(raw: &str, pattern: &str) -> String {
DateTime::parse_from_rfc3339(raw)
.map(|dt| dt.with_timezone(&Local).format(pattern).to_string())
.unwrap_or_else(|_| raw.to_string())
}