Skip to main content

usenet_dl/db/
state.rs

1//! Runtime state tracking: shutdown detection, NZB processing, RSS seen items.
2
3use crate::error::DatabaseError;
4use crate::{Error, Result};
5
6use super::Database;
7
8impl Database {
9    /// Check if the last shutdown was unclean
10    ///
11    /// Returns true if the previous session did not call set_clean_shutdown(),
12    /// indicating a crash or forced termination.
13    ///
14    /// This method is called on startup to determine if state recovery is needed.
15    pub async fn was_unclean_shutdown(&self) -> Result<bool> {
16        let value: Option<String> = sqlx::query_scalar(
17            r#"
18            SELECT value FROM runtime_state WHERE key = 'clean_shutdown'
19            "#,
20        )
21        .fetch_optional(&self.pool)
22        .await
23        .map_err(|e| {
24            Error::Database(DatabaseError::QueryFailed(format!(
25                "Failed to check shutdown state: {}",
26                e
27            )))
28        })?;
29
30        // If the value is missing or "false", it was an unclean shutdown
31        Ok(value.is_none_or(|v| v != "true"))
32    }
33
34    /// Mark that the application has started cleanly
35    ///
36    /// This should be called during UsenetDownloader::new() to indicate that
37    /// the application is running. If shutdown() is not called before the next
38    /// startup, was_unclean_shutdown() will return true.
39    pub async fn set_clean_start(&self) -> Result<()> {
40        let now = chrono::Utc::now().timestamp();
41        sqlx::query(
42            r#"
43            INSERT INTO runtime_state (key, value, updated_at)
44            VALUES ('clean_shutdown', 'false', ?)
45            ON CONFLICT(key) DO UPDATE SET value = 'false', updated_at = ?
46            "#,
47        )
48        .bind(now)
49        .bind(now)
50        .execute(&self.pool)
51        .await
52        .map_err(|e| {
53            Error::Database(DatabaseError::QueryFailed(format!(
54                "Failed to set clean start: {}",
55                e
56            )))
57        })?;
58
59        Ok(())
60    }
61
62    /// Mark that the application is shutting down cleanly
63    ///
64    /// This should be called during UsenetDownloader::shutdown() to indicate
65    /// a graceful shutdown. If this is not called before the process exits,
66    /// the next startup will detect an unclean shutdown.
67    pub async fn set_clean_shutdown(&self) -> Result<()> {
68        let now = chrono::Utc::now().timestamp();
69        sqlx::query(
70            r#"
71            INSERT INTO runtime_state (key, value, updated_at)
72            VALUES ('clean_shutdown', 'true', ?)
73            ON CONFLICT(key) DO UPDATE SET value = 'true', updated_at = ?
74            "#,
75        )
76        .bind(now)
77        .bind(now)
78        .execute(&self.pool)
79        .await
80        .map_err(|e| {
81            Error::Database(DatabaseError::QueryFailed(format!(
82                "Failed to set clean shutdown: {}",
83                e
84            )))
85        })?;
86
87        Ok(())
88    }
89
90    /// Mark an NZB file as processed
91    ///
92    /// This is used by the folder watcher with WatchFolderAction::Keep to track
93    /// which NZB files have already been processed to avoid re-adding them.
94    pub async fn mark_nzb_processed(&self, path: &std::path::Path) -> Result<()> {
95        let path_str = path.to_string_lossy().into_owned();
96        let now = chrono::Utc::now().timestamp();
97
98        sqlx::query(
99            r#"
100            INSERT INTO processed_nzbs (path, processed_at)
101            VALUES (?, ?)
102            ON CONFLICT(path) DO UPDATE SET processed_at = ?
103            "#,
104        )
105        .bind(&path_str)
106        .bind(now)
107        .bind(now)
108        .execute(&self.pool)
109        .await
110        .map_err(|e| {
111            Error::Database(DatabaseError::QueryFailed(format!(
112                "Failed to mark NZB as processed: {}",
113                e
114            )))
115        })?;
116
117        Ok(())
118    }
119
120    /// Check if an NZB file has been processed
121    pub async fn is_nzb_processed(&self, path: &std::path::Path) -> Result<bool> {
122        let path_str = path.to_string_lossy().into_owned();
123
124        let count: i64 = sqlx::query_scalar(
125            r#"
126            SELECT COUNT(*) FROM processed_nzbs WHERE path = ?
127            "#,
128        )
129        .bind(&path_str)
130        .fetch_one(&self.pool)
131        .await
132        .map_err(|e| {
133            Error::Database(DatabaseError::QueryFailed(format!(
134                "Failed to check if NZB is processed: {}",
135                e
136            )))
137        })?;
138
139        Ok(count > 0)
140    }
141
142    /// Check if an RSS feed item has been seen before
143    pub async fn is_rss_item_seen(&self, feed_id: i64, guid: &str) -> Result<bool> {
144        let count: i64 = sqlx::query_scalar(
145            r#"
146            SELECT COUNT(*) FROM rss_seen WHERE feed_id = ? AND guid = ?
147            "#,
148        )
149        .bind(feed_id)
150        .bind(guid)
151        .fetch_one(&self.pool)
152        .await
153        .map_err(|e| {
154            Error::Database(DatabaseError::QueryFailed(format!(
155                "Failed to check if RSS item is seen: {}",
156                e
157            )))
158        })?;
159
160        Ok(count > 0)
161    }
162
163    /// Mark an RSS feed item as seen
164    pub async fn mark_rss_item_seen(&self, feed_id: i64, guid: &str) -> Result<()> {
165        let now = chrono::Utc::now().timestamp();
166
167        sqlx::query(
168            r#"
169            INSERT INTO rss_seen (feed_id, guid, seen_at)
170            VALUES (?, ?, ?)
171            ON CONFLICT(feed_id, guid) DO UPDATE SET seen_at = ?
172            "#,
173        )
174        .bind(feed_id)
175        .bind(guid)
176        .bind(now)
177        .bind(now)
178        .execute(&self.pool)
179        .await
180        .map_err(|e| {
181            Error::Database(DatabaseError::QueryFailed(format!(
182                "Failed to mark RSS item as seen: {}",
183                e
184            )))
185        })?;
186
187        Ok(())
188    }
189}