lastfm_client/file_handler.rs
1use chrono::Local;
2use serde::Serialize;
3use std::collections::HashMap;
4use std::fs::{self, File, OpenOptions};
5use std::io::{BufRead, BufReader, Result, Write as _};
6
7#[cfg(feature = "sqlite")]
8use rusqlite::{Connection as SqliteConnection, OpenFlags};
9
10use crate::types::TrackPlayInfo;
11
12/// File format options for saving track data
13#[derive(Debug)]
14#[non_exhaustive]
15pub enum FileFormat {
16 /// Save as JSON format with pretty printing
17 Json,
18 /// Save as CSV format with headers
19 Csv,
20 /// Save as NDJSON (Newline Delimited JSON) - one compact JSON object per line
21 Ndjson,
22}
23
24/// Handler for file I/O operations (JSON and CSV)
25#[derive(Debug)]
26#[non_exhaustive]
27pub struct FileHandler;
28
29impl FileHandler {
30 /// Save data to a file in the data directory.
31 ///
32 /// Files are saved to the `data/` directory (created if it doesn't exist) with a timestamp in the filename.
33 ///
34 /// # Arguments
35 /// * `data` - Data to save (must implement Serialize)
36 /// * `format` - File format to save as (`FileFormat::Json` for JSON or `FileFormat::Csv` for CSV)
37 /// * `filename_prefix` - Prefix for the filename. The final filename will be `{prefix}_{timestamp}.{extension}`
38 ///
39 /// # Errors
40 /// * `std::io::Error` - If the file cannot be opened or written to, or if the data directory cannot be created
41 /// * `serde_json::Error` - If the JSON cannot be serialized
42 ///
43 /// # Returns
44 /// * `Result<String>` - Full path to the saved file (e.g., `data/recent_tracks_20240101_120000.json`)
45 pub fn save<T: Serialize>(
46 data: &[T],
47 format: &FileFormat,
48 filename_prefix: &str,
49 ) -> Result<String> {
50 // Create data directory if it doesn't exist
51 fs::create_dir_all("data")?;
52
53 // Generate timestamp
54 let timestamp = Local::now().format("%Y%m%d_%H%M%S");
55
56 // Create filename with timestamp
57 let filename = format!(
58 "data/{}_{}.{}",
59 filename_prefix,
60 timestamp,
61 match format {
62 FileFormat::Json => "json",
63 FileFormat::Csv => "csv",
64 FileFormat::Ndjson => "ndjson",
65 }
66 );
67
68 match format {
69 FileFormat::Json => {
70 // Special case: if T is a HashMap with track info
71 if std::any::type_name::<T>()
72 == std::any::type_name::<HashMap<String, TrackPlayInfo>>()
73 && let Some(single_item) = data.first()
74 {
75 Self::save_single(single_item, &filename)?;
76 return Ok(filename);
77 }
78 Self::save_as_json(data, &filename)
79 }
80 FileFormat::Csv => crate::csv_export::save_as_csv_dispatch(data, &filename),
81 FileFormat::Ndjson => Self::save_as_ndjson(data, &filename),
82 }?;
83
84 Ok(filename)
85 }
86
87 /// Save data to a JSON file.
88 ///
89 /// # Arguments
90 /// * `data` - Data to save
91 /// * `filename` - Filename to save as
92 fn save_as_json<T: Serialize>(data: &[T], filename: &str) -> Result<()> {
93 let json = serde_json::to_string_pretty(data)?;
94 let mut file = File::create(filename)?;
95
96 file.write_all(json.as_bytes())?;
97
98 Ok(())
99 }
100
101 /// Save data to an NDJSON file - one compact JSON object per line.
102 ///
103 /// # Arguments
104 /// * `data` - Data to save
105 /// * `filename` - Filename to save as
106 fn save_as_ndjson<T: Serialize>(data: &[T], filename: &str) -> Result<()> {
107 let mut file = File::create(filename)?;
108 for item in data {
109 let line = serde_json::to_string(item)?;
110 file.write_all(line.as_bytes())?;
111 file.write_all(b"\n")?;
112 }
113 Ok(())
114 }
115
116 /// Append items to an existing NDJSON file as new lines.
117 ///
118 /// # Arguments
119 /// * `data` - Data to append
120 /// * `file_path` - Path to the target file
121 fn append_ndjson_lines<T: Serialize>(data: &[T], file_path: &str) -> Result<()> {
122 let mut file = OpenOptions::new().append(true).open(file_path)?;
123 for item in data {
124 let line = serde_json::to_string(item)?;
125 file.write_all(line.as_bytes())?;
126 file.write_all(b"\n")?;
127 }
128 Ok(())
129 }
130
131 /// Load existing NDJSON data from a file - one item per line.
132 ///
133 /// # Arguments
134 /// * `file_path` - Path to the NDJSON file to read
135 ///
136 /// # Errors
137 /// * `std::io::Error` - If the file cannot be opened
138 /// * `serde_json::Error` - If a line cannot be deserialized into `T`
139 pub fn load_ndjson<T: serde::de::DeserializeOwned>(file_path: &str) -> Result<Vec<T>> {
140 let file = File::open(file_path)?;
141 let reader = BufReader::new(file);
142 let mut items = Vec::new();
143 for line in reader.lines() {
144 let line = line?;
145 if line.is_empty() {
146 continue;
147 }
148 let item: T = serde_json::from_str(&line)?;
149 items.push(item);
150 }
151 Ok(items)
152 }
153
154 /// Append new items to an existing NDJSON file, or create it if it does not exist.
155 ///
156 /// # Arguments
157 /// * `new_data` - New items to append
158 /// * `file_path` - Path to the target NDJSON file
159 ///
160 /// # Errors
161 /// * `std::io::Error` - If the file cannot be opened or written
162 pub fn append_or_create_ndjson<T: Serialize>(new_data: &[T], file_path: &str) -> Result<()> {
163 if std::path::Path::new(file_path).exists() {
164 Self::append_ndjson_lines(new_data, file_path)
165 } else {
166 if let Some(parent) = std::path::Path::new(file_path).parent() {
167 fs::create_dir_all(parent)?;
168 }
169 Self::save_as_ndjson(new_data, file_path)
170 }
171 }
172
173 /// Save a single item to a JSON file
174 ///
175 /// # Errors
176 /// * `std::io::Error` - If there was an error reading or writing the file
177 /// * `serde_json::Error` - If there was an error serializing the data
178 ///
179 /// # Arguments
180 /// * `data` - Data to save
181 /// * `filename` - Filename to save as
182 pub fn save_single<T: Serialize>(data: &T, filename: &str) -> Result<()> {
183 let json = serde_json::to_string_pretty(data)?;
184 let mut file = File::create(filename)?;
185 file.write_all(json.as_bytes())?;
186 Ok(())
187 }
188
189 /// Load existing JSON data from a file.
190 ///
191 /// # Arguments
192 /// * `file_path` - Path to the JSON file to read
193 ///
194 /// # Errors
195 /// * `std::io::Error` - If the file cannot be opened
196 /// * `serde_json::Error` - If the JSON cannot be deserialized into `Vec<T>`
197 pub fn load<T: serde::de::DeserializeOwned>(file_path: &str) -> Result<Vec<T>> {
198 let file = File::open(file_path)?;
199 let data: Vec<T> = serde_json::from_reader(file)?;
200
201 Ok(data)
202 }
203
204 /// Return the path of the sidecar metadata file for `file_path`.
205 ///
206 /// The sidecar stores the latest known Unix timestamp so subsequent update calls do not
207 /// need to deserialize the full data file.
208 #[must_use]
209 pub fn sidecar_path(file_path: &str) -> String {
210 format!("{file_path}.meta")
211 }
212
213 /// Read the latest timestamp from a sidecar metadata file.
214 ///
215 /// Returns `None` if the sidecar does not exist or cannot be parsed.
216 #[must_use]
217 pub fn read_sidecar_timestamp(file_path: &str) -> Option<u32> {
218 fs::read_to_string(Self::sidecar_path(file_path))
219 .ok()
220 .and_then(|s| s.trim().parse().ok())
221 }
222
223 /// Write a timestamp to the sidecar metadata file associated with `file_path`.
224 ///
225 /// # Errors
226 /// * `std::io::Error` - If the sidecar file cannot be written
227 pub fn write_sidecar_timestamp(file_path: &str, timestamp: u32) -> Result<()> {
228 fs::write(Self::sidecar_path(file_path), timestamp.to_string())
229 }
230
231 /// Append new items to an existing CSV file, or create it with headers if it does not exist.
232 ///
233 /// When appending to an existing file the header row is omitted so it is not duplicated.
234 ///
235 /// # Arguments
236 /// * `new_data` - New items to append
237 /// * `file_path` - Path to the target CSV file
238 ///
239 /// # Errors
240 /// * `std::io::Error` - If the file cannot be opened or written
241 /// * `csv::Error` - If serialization fails
242 pub fn append_or_create_csv<T: Serialize>(new_data: &[T], file_path: &str) -> Result<()> {
243 if std::path::Path::new(file_path).exists() {
244 crate::csv_export::append_csv_rows_dispatch(new_data, file_path)?;
245 } else {
246 if let Some(parent) = std::path::Path::new(file_path).parent() {
247 fs::create_dir_all(parent)?;
248 }
249 crate::csv_export::save_as_csv_dispatch(new_data, file_path)?;
250 }
251 Ok(())
252 }
253
254 /// Save data to a new `SQLite` database file.
255 ///
256 /// Creates a timestamped `.db` file under `data/`. All rows are inserted in a single
257 /// transaction for performance.
258 ///
259 /// # Arguments
260 /// * `data` - Data to save (must implement `SqliteExportable`)
261 /// * `filename_prefix` - Prefix for the generated filename
262 ///
263 /// # Errors
264 /// * `std::io::Error` - If the data directory cannot be created or the database cannot be opened or written
265 ///
266 /// # Returns
267 /// * `Result<String>` - Full path to the saved database file (e.g., `data/recent_tracks_20240101_120000.db`)
268 #[cfg(feature = "sqlite")]
269 pub fn save_sqlite<T: crate::sqlite::SqliteExportable>(
270 data: &[T],
271 filename_prefix: &str,
272 ) -> Result<String> {
273 fs::create_dir_all("data")?;
274 let timestamp = Local::now().format("%Y%m%d_%H%M%S");
275 let filename = format!("data/{filename_prefix}_{timestamp}.db");
276
277 let mut conn =
278 SqliteConnection::open(&filename).map_err(|e| std::io::Error::other(e.to_string()))?;
279
280 conn.execute_batch(T::create_table_sql())
281 .map_err(|e| std::io::Error::other(e.to_string()))?;
282
283 let tx = conn
284 .transaction()
285 .map_err(|e| std::io::Error::other(e.to_string()))?;
286
287 {
288 let mut stmt = tx
289 .prepare(T::insert_sql())
290 .map_err(|e| std::io::Error::other(e.to_string()))?;
291
292 for item in data {
293 item.bind_and_execute(&mut stmt)
294 .map_err(|e| std::io::Error::other(e.to_string()))?;
295 }
296 }
297
298 tx.commit()
299 .map_err(|e| std::io::Error::other(e.to_string()))?;
300
301 Ok(filename)
302 }
303
304 /// Append new items to an existing `SQLite` database, or create it if it does not exist.
305 ///
306 /// Opens the database at `file_path`, creates the table if it does not already exist,
307 /// and inserts all rows in a single transaction.
308 ///
309 /// # Arguments
310 /// * `data` - Data to insert
311 /// * `file_path` - Path to the target `.db` file
312 ///
313 /// # Errors
314 /// * `std::io::Error` - If the file cannot be opened or the data cannot be written
315 #[cfg(feature = "sqlite")]
316 pub fn append_or_create_sqlite<T: crate::sqlite::SqliteExportable>(
317 data: &[T],
318 file_path: &str,
319 ) -> Result<()> {
320 if let Some(parent) = std::path::Path::new(file_path).parent()
321 && !parent.as_os_str().is_empty()
322 {
323 fs::create_dir_all(parent)?;
324 }
325
326 let mut conn =
327 SqliteConnection::open(file_path).map_err(|e| std::io::Error::other(e.to_string()))?;
328
329 conn.execute_batch(T::create_table_sql())
330 .map_err(|e| std::io::Error::other(e.to_string()))?;
331
332 let tx = conn
333 .transaction()
334 .map_err(|e| std::io::Error::other(e.to_string()))?;
335
336 {
337 let mut stmt = tx
338 .prepare(T::insert_sql())
339 .map_err(|e| std::io::Error::other(e.to_string()))?;
340
341 for item in data {
342 item.bind_and_execute(&mut stmt)
343 .map_err(|e| std::io::Error::other(e.to_string()))?;
344 }
345 }
346
347 tx.commit()
348 .map_err(|e| std::io::Error::other(e.to_string()))?;
349
350 Ok(())
351 }
352
353 /// Load all rows from a `SQLite` database into a [`crate::types::TrackList`].
354 ///
355 /// Opens the database at `file_path` and runs `T::select_sql()`, mapping
356 /// each row with `T::from_row`. The returned `TrackList<T>` supports all
357 /// analysis methods (`to_set()`, `top_artists()`, `by_date()`, etc.).
358 ///
359 /// Fields that are not persisted in the database schema (such as `image`,
360 /// `streamable`, and human-readable date strings) are reconstructed with
361 /// empty/default values. See [`crate::sqlite::SqliteLoadable`] for details.
362 ///
363 /// # Arguments
364 /// * `file_path` - Path to the `.db` file produced by `fetch_and_save_sqlite`
365 /// or `fetch_and_update_sqlite`. Relative paths are resolved from the **process
366 /// current working directory** (for `cargo run`, that is normally the package
367 /// root where `Cargo.toml` lives, not `target/release/`).
368 ///
369 /// # Errors
370 /// * `std::io::Error` - If the database cannot be opened or the query fails
371 ///
372 /// # Example
373 ///
374 /// ```ignore
375 /// use lastfm_client::{file_handler::FileHandler, RecentTrack};
376 ///
377 /// let tracks = FileHandler::load_sqlite::<RecentTrack>("data/recent_tracks.db")?;
378 /// let top = tracks.to_set(); // TrackList<ScoredTrack>
379 /// let artists = tracks.top_artists(); // TrackList<ScoredArtist>
380 /// println!("Streak: {} day(s)", tracks.streak());
381 /// ```
382 #[cfg(feature = "sqlite")]
383 pub fn load_sqlite<T: crate::sqlite::SqliteLoadable>(
384 file_path: &str,
385 ) -> std::io::Result<crate::types::TrackList<T>> {
386 let path = std::path::Path::new(file_path);
387 if !path.is_file() {
388 let cwd = std::env::current_dir()
389 .map_or_else(|_| "<unavailable>".to_string(), |p| p.display().to_string());
390 return Err(std::io::Error::new(
391 std::io::ErrorKind::NotFound,
392 format!("SQLite database not found at {file_path:?} (resolved from cwd {cwd:?})"),
393 ));
394 }
395
396 let conn = SqliteConnection::open_with_flags(
397 path,
398 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
399 )
400 .map_err(|e| std::io::Error::other(e.to_string()))?;
401
402 let mut stmt = conn
403 .prepare(T::select_sql())
404 .map_err(|e| std::io::Error::other(e.to_string()))?;
405
406 let rows = stmt
407 .query_map([], |row| T::from_row(row))
408 .map_err(|e| std::io::Error::other(e.to_string()))?;
409
410 let items: rusqlite::Result<Vec<T>> = rows.collect();
411 let items = items.map_err(|e| std::io::Error::other(e.to_string()))?;
412
413 Ok(crate::types::TrackList::from(items))
414 }
415
416 /// Query the maximum `date_uts` value stored in a `SQLite` table.
417 ///
418 /// Used by the update flow to determine the latest timestamp already present in the
419 /// database, so only newer records need to be fetched from the API.
420 ///
421 /// Returns `None` if the file does not exist, the table is empty, or the query fails.
422 ///
423 /// # Arguments
424 /// * `file_path` - Path to the `.db` file
425 /// * `table_name` - Name of the table to query
426 #[cfg(feature = "sqlite")]
427 #[must_use]
428 pub fn read_sqlite_max_timestamp(file_path: &str, table_name: &str) -> Option<u32> {
429 if !std::path::Path::new(file_path).exists() {
430 return None;
431 }
432 let conn = SqliteConnection::open(file_path).ok()?;
433 conn.query_row(
434 &format!("SELECT MAX(date_uts) FROM {table_name}"),
435 [],
436 |row| row.get::<_, Option<u32>>(0),
437 )
438 .ok()
439 .flatten()
440 }
441
442 /// Prepend new items to an existing JSON file, or create the file if it does not exist.
443 ///
444 /// New items are placed before existing items so the result remains sorted newest-first,
445 /// which matches the order returned by the Last.fm API.
446 ///
447 /// # Arguments
448 /// * `new_data` - New items to prepend
449 /// * `file_path` - Path to the target JSON file
450 ///
451 /// # Errors
452 /// * `std::io::Error` - If the file cannot be read or written
453 /// * `serde_json::Error` - If serialization or deserialization fails
454 pub fn prepend_json<T: Serialize + serde::de::DeserializeOwned + Clone>(
455 new_data: &[T],
456 file_path: &str,
457 ) -> Result<()> {
458 let existing: Vec<T> = if std::path::Path::new(file_path).exists() {
459 Self::load(file_path)?
460 } else {
461 // Ensure the parent directory exists before creating the file
462 if let Some(parent) = std::path::Path::new(file_path).parent() {
463 fs::create_dir_all(parent)?;
464 }
465 vec![]
466 };
467
468 let mut combined = new_data.to_vec();
469 combined.extend(existing);
470 Self::save_as_json(&combined, file_path)
471 }
472}