feat: add reading_status_push job — differential push to AniList

Push reading statuses (PLANNING/CURRENT/COMPLETED) to AniList for all
linked series that changed since last sync, or have new books/no sync yet.

- Migration 0057: adds reading_status_push to index_jobs type constraint
- Migration 0058: creates reading_status_push_results table (pushed/skipped/no_books/error)
- API: new reading_status_push module with start_push, get_push_report, get_push_results
- Differential detection: synced_at IS NULL OR reading progress updated OR new books added
- Same 429 retry logic as reading_status_match (wait 10s, retry once, abort on 2nd 429)
- Notifications: ReadingStatusPushCompleted/Failed events
- Backoffice: push button in reading status group, job detail report with per-series list
- Replay support, badge label, i18n (FR + EN)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 10:30:04 +01:00
parent d977b6b27a
commit 10cc69e53f
13 changed files with 939 additions and 7 deletions

View File

@@ -47,6 +47,10 @@ pub struct EventToggles {
pub reading_status_match_completed: bool,
#[serde(default = "default_true")]
pub reading_status_match_failed: bool,
#[serde(default = "default_true")]
pub reading_status_push_completed: bool,
#[serde(default = "default_true")]
pub reading_status_push_failed: bool,
}
fn default_true() -> bool {
@@ -69,6 +73,8 @@ fn default_events() -> EventToggles {
metadata_refresh_failed: true,
reading_status_match_completed: true,
reading_status_match_failed: true,
reading_status_push_completed: true,
reading_status_push_failed: true,
}
}
@@ -265,6 +271,16 @@ pub enum NotificationEvent {
library_name: Option<String>,
error: String,
},
// Reading status push (differential push to AniList)
ReadingStatusPushCompleted {
library_name: Option<String>,
total_series: i32,
pushed: i32,
},
ReadingStatusPushFailed {
library_name: Option<String>,
error: String,
},
}
/// Classify an indexer job_type string into the right event constructor category.
@@ -511,6 +527,37 @@ fn format_event(event: &NotificationEvent) -> String {
]
.join("\n")
}
NotificationEvent::ReadingStatusPushCompleted {
library_name,
total_series,
pushed,
} => {
let lib = library_name.as_deref().unwrap_or("All libraries");
[
format!("✅ <b>Reading status push completed</b>"),
format!("━━━━━━━━━━━━━━━━━━━━"),
format!("📂 <b>Library:</b> {lib}"),
String::new(),
format!("📊 <b>Results</b>"),
format!(" ⬆️ Pushed: <b>{pushed}</b> / <b>{total_series}</b> series"),
]
.join("\n")
}
NotificationEvent::ReadingStatusPushFailed {
library_name,
error,
} => {
let lib = library_name.as_deref().unwrap_or("All libraries");
let err = truncate(error, 200);
[
format!("🚨 <b>Reading status push failed</b>"),
format!("━━━━━━━━━━━━━━━━━━━━"),
format!("📂 <b>Library:</b> {lib}"),
String::new(),
format!("💬 <code>{err}</code>"),
]
.join("\n")
}
}
}
@@ -553,6 +600,8 @@ fn is_event_enabled(config: &TelegramConfig, event: &NotificationEvent) -> bool
NotificationEvent::MetadataRefreshFailed { .. } => config.events.metadata_refresh_failed,
NotificationEvent::ReadingStatusMatchCompleted { .. } => config.events.reading_status_match_completed,
NotificationEvent::ReadingStatusMatchFailed { .. } => config.events.reading_status_match_failed,
NotificationEvent::ReadingStatusPushCompleted { .. } => config.events.reading_status_push_completed,
NotificationEvent::ReadingStatusPushFailed { .. } => config.events.reading_status_push_failed,
}
}