use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, ActiveValue::Set}; use crate::entity::{available_ratings, cleanup_state, image_meta}; use crate::AppState; const CLEANUP_INTERVAL_SECS: i64 = 7 * 24 * 3600; fn now_secs() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs() as i64 } /// Checks whether a cleanup run is overdue (last run more than 7 days ago, /// or never run) and if so, runs it. Called periodically from a background /// task. Reading a persisted timestamp (rather than an in-memory one) means /// a run that was missed while the container was asleep/restarted still /// happens promptly on the next check after waking, instead of being /// silently skipped. pub async fn run_if_due(state: &AppState) { let now = now_secs(); let last_run = match cleanup_state::Entity::find_by_id(true).one(&state.db).await { Ok(Some(row)) => row.last_run, Ok(None) => None, Err(e) => { tracing::warn!(error = %e, "failed to read cleanup_state, skipping this check"); return; } }; let due = match last_run { Some(t) => now - t >= CLEANUP_INTERVAL_SECS, None => true, }; if !due { return; } run_cleanup(state, false).await; let model = cleanup_state::ActiveModel { id: Set(true), last_run: Set(Some(now)), }; if let Err(e) = cleanup_state::Entity::insert(model) .on_conflict( sea_orm::sea_query::OnConflict::column(cleanup_state::Column::Id) .update_columns([cleanup_state::Column::LastRun]) .to_owned(), ) .exec(&state.db) .await { tracing::warn!(error = %e, "failed to update cleanup_state.last_run"); } } /// Deletes image_meta and available_ratings rows older than 7 days, and /// purges the corresponding Cloudflare Cache-Tags. Lazy: does not /// re-render anything, the next real request for a purged title is simply /// treated as a cache miss. In dry_run mode, only logs what would be /// deleted. pub async fn run_cleanup(state: &AppState, dry_run: bool) { let cutoff = now_secs() - CLEANUP_INTERVAL_SECS; let expired_images = match image_meta::Entity::find() .filter(image_meta::Column::UpdatedAt.lt(cutoff)) .all(&state.db) .await { Ok(rows) => rows, Err(e) => { tracing::warn!(error = %e, "cleanup: failed to query expired image_meta rows"); return; } }; if expired_images.is_empty() { tracing::info!("cleanup: no expired image_meta rows found"); return; } tracing::info!(count = expired_images.len(), dry_run, "cleanup: expired image_meta rows found"); if dry_run { for row in &expired_images { tracing::info!(cache_key = %row.cache_key, "cleanup (dry run): would delete"); } return; } // Cloudflare tags are keyed "id:{id_type}:{id_value}" derived from the // cache_key prefix (before the settings suffix), matching the format // purge_cloudflare_tags() already expects. let prefixes: Vec = expired_images .iter() .filter_map(|row| { let parts: Vec<&str> = row.cache_key.splitn(2, '/').collect(); if parts.len() == 2 { let id_part = parts[1].split(|c| c == '@' || c == '.').next().unwrap_or(parts[1]); Some(format!("{}/{}", parts[0], id_part)) } else { None } }) .collect(); match crate::services::stremio_account::purge_cloudflare_tags(&state.http, &prefixes).await { Ok(n) => tracing::info!(purged = n, "cleanup: Cloudflare tags purged"), Err(e) => tracing::warn!(error = %e, "cleanup: Cloudflare purge failed, continuing with Postgres delete anyway"), } let cache_keys: Vec = expired_images.iter().map(|r| r.cache_key.clone()).collect(); match image_meta::Entity::delete_many() .filter(image_meta::Column::CacheKey.is_in(cache_keys)) .exec(&state.db) .await { Ok(res) => tracing::info!(deleted = res.rows_affected, "cleanup: image_meta rows deleted"), Err(e) => tracing::warn!(error = %e, "cleanup: failed to delete expired image_meta rows"), } match available_ratings::Entity::delete_many() .filter(available_ratings::Column::UpdatedAt.lt(cutoff)) .exec(&state.db) .await { Ok(res) => tracing::info!(deleted = res.rows_affected, "cleanup: available_ratings rows deleted"), Err(e) => tracing::warn!(error = %e, "cleanup: failed to delete expired available_ratings rows"), } }