a
File size: 4,778 Bytes
1ad3b12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
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<String> = 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<String> = 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"),
    }
}