termusiclib/new_database/
mod.rs

1#![allow(clippy::unnecessary_debug_formatting)] // for logging we want all paths's characters to be escaped
2
3use std::{fmt::Debug, path::Path, sync::Arc};
4
5use anyhow::{Context, Result};
6use parking_lot::{Mutex, MutexGuard};
7use rusqlite::{Connection, OptionalExtension};
8use tokio::{runtime::Handle, sync::Semaphore};
9use track_insert::TrackInsertable;
10use walkdir::DirEntry;
11
12use crate::{
13    config::{ServerOverlay, v2::server::ScanDepth},
14    new_database::{
15        album_ops::delete_all_unreferenced_albums, artist_ops::delete_all_unreferenced_artists,
16    },
17    track::{MetadataOptions, parse_metadata_from_file},
18    utils::{filetype_supported, get_app_new_database_path},
19};
20
21/// Sqlite / rusqlite integer type alias.
22///
23/// This alias exists to keep it in one place and because rusqlite does not export such a type.
24pub type Integer = i64;
25
26mod album_insert;
27pub mod album_ops;
28mod artist_insert;
29pub mod artist_ops;
30mod migrate;
31mod track_insert;
32pub mod track_ops;
33
34#[allow(clippy::doc_markdown)]
35/// The SQLite Database interface.
36///
37/// This *can* be shared between threads via `clone`, **but** only one operation may occur at a time.
38#[derive(Clone)]
39pub struct Database {
40    conn: Arc<Mutex<Connection>>,
41    /// Limit how many scanners are active at a time
42    semaphore: Arc<Semaphore>,
43}
44
45impl Debug for Database {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        f.debug_struct("DataBase")
48            .field("conn", &"<unavailable>")
49            .finish()
50    }
51}
52
53impl Database {
54    /// Create a new database at the given `path`, with all migrations applied
55    ///
56    /// # Panics
57    ///
58    /// - if database creation fails
59    /// - if database migration fails
60    pub fn new(path: &Path) -> Result<Self> {
61        let conn = Connection::open(path).context("open/create database")?;
62
63        Self::new_from_connection(conn)
64    }
65
66    /// Create a new database at the default app config path.
67    ///
68    /// # Panics
69    ///
70    /// see [`new`](Self::new).
71    pub fn new_default_path() -> Result<Self> {
72        Self::new(&get_app_new_database_path()?)
73    }
74
75    /// Get a lock to the underlying connection to start operations.
76    pub fn get_connection(&self) -> MutexGuard<'_, Connection> {
77        self.conn.lock()
78    }
79
80    /// Prepare the given Connection for usage.
81    fn new_from_connection(conn: Connection) -> Result<Self> {
82        migrate::migrate(&conn).context("Database migration")?;
83
84        let conn = Arc::new(Mutex::new(conn));
85        // for now limit to one worker at a time
86        let semaphore = Arc::new(Semaphore::new(1));
87        Ok(Self { conn, semaphore })
88    }
89
90    /// Scan the given path recursively, limited to [`ServerOverlay::get_library_scan_depth`].
91    ///
92    /// If `replace_metadata` is `false` then paths that already exist in the database will not be updated.
93    ///
94    /// Waits for a permit before starting another worker.
95    pub fn scan_path(
96        &self,
97        path: &Path,
98        config: &ServerOverlay,
99        replace_metadata: bool,
100    ) -> Result<()> {
101        let path = path
102            .canonicalize()
103            .with_context(|| path.display().to_string())?;
104
105        let walker = {
106            let mut walker = walkdir::WalkDir::new(&path).follow_links(true);
107
108            if let ScanDepth::Limited(limit) = config.get_metadata_scan_depth() {
109                walker = walker.max_depth(usize::try_from(limit).unwrap_or(usize::MAX));
110            }
111
112            walker
113                .into_iter()
114                .filter_map(Result::ok)
115                // only process files which we support
116                .filter(|v| v.file_type().is_file())
117                .filter(|v| filetype_supported(v.path()))
118        };
119
120        let separators = config.settings.metadata.artist_separators.clone();
121
122        self.spawn_worker(move |db| {
123            let separators: Vec<&str> = separators.iter().map(String::as_str).collect();
124            Self::process_iter(walker, &db, &path, replace_metadata, &separators);
125        });
126
127        Ok(())
128    }
129
130    /// Spawn a database worker, for work in the background.
131    ///
132    /// Will first spawn a task to await for a permit, then spawn a blocking task with the actual function.
133    fn spawn_worker<F>(&self, fun: F)
134    where
135        F: FnOnce(Self) + Send + 'static,
136    {
137        let handle = Handle::current();
138        let handle_1 = handle.clone();
139
140        let db = self.clone();
141
142        // first spawn a task to acquire a permit, then spawn a blocking task as WalkDir and rusqlite are sync-only.
143        handle.spawn(async move {
144            let Ok(permit) = db.semaphore.clone().acquire_owned().await else {
145                error!("Failed to acquite permit for scanner!");
146                return;
147            };
148
149            handle_1.spawn_blocking(move || {
150                // this keeps the permit for the duration of this block / function
151                let _permit = permit;
152                fun(db);
153            });
154        });
155    }
156
157    /// The actual function to walk the iterator of files for [`Self::scan_path`].
158    ///
159    /// Expects `path` to be absolute.
160    fn process_iter(
161        walker: impl Iterator<Item = DirEntry>,
162        db: &Self,
163        path: &Path,
164        replace_metadata: bool,
165        separators: &[&str],
166    ) {
167        // keep the permit for the entirety of this function
168        info!("Scanning {path:#?}");
169
170        let mut created_updated: usize = 0;
171
172        // assumptions in this function:
173        // - "walker" iterator is already filtered to only contain files
174        // - "walker" iterator is already filtered to only our supported file types
175        for record in walker {
176            let path = record.path();
177
178            // skip existing paths, if no full scan is requested
179            if !replace_metadata {
180                match track_ops::track_exists(&db.conn.lock(), path) {
181                    Ok(true) => continue,
182                    Err(err) => {
183                        warn!("Error checking if {path:#?} exists: {err:#?}");
184                        continue;
185                    }
186                    Ok(false) => (),
187                }
188            }
189
190            let track_metadata = match parse_metadata_from_file(
191                path,
192                MetadataOptions {
193                    album: true,
194                    album_artist: true,
195                    album_artists: true,
196                    artist: true,
197                    artists: true,
198                    artist_separators: separators,
199                    title: true,
200                    duration: true,
201                    genre: true,
202                    ..Default::default()
203                },
204            ) {
205                Ok(v) => v,
206                Err(err) => {
207                    warn!("Error scanning path {path:#?}: {err:#?}");
208                    continue;
209                }
210            };
211
212            let db_track = match TrackInsertable::try_from_track(path, &track_metadata) {
213                Ok(v) => v,
214                Err(err) => {
215                    warn!("Error converting to database track {path:#?}: {err:#?}");
216                    continue;
217                }
218            };
219
220            let _id = match db_track.try_insert_or_update(&db.conn.lock()) {
221                Ok(v) => v,
222                Err(err) => {
223                    warn!("Error inserting or updating {path:#?}: {err:#?}");
224                    continue;
225                }
226            };
227
228            created_updated += 1;
229        }
230
231        info!("Finished Scanning {path:#?} with {created_updated} created or updated");
232    }
233
234    /// Spawn a worker to cleanup the database.
235    ///
236    /// This includes removing unreferenced albums and artists.
237    // TODO: also add option to check for all tracks to actually exist on disk
238    pub fn run_cleanup(&self) {
239        self.spawn_worker(move |db| {
240            if let Err(err) = Self::process_cleanup(&db) {
241                warn!("Error processing database cleanup: {err:#?}");
242            }
243        });
244    }
245
246    /// The actual function for work from [`run_cleanup`](Self::run_cleanup).
247    fn process_cleanup(db: &Self) -> Result<()> {
248        let conn = db.get_connection();
249
250        info!("Starting Database cleanup");
251
252        // note that albums have to be deleted first, as otherwise artists would not count
253        // as unreferenced if there is still a album, even if that is unreferenced itself.
254        let affected_albums = delete_all_unreferenced_albums(&conn)?;
255
256        info!("Deleted {affected_albums} Albums");
257
258        let affected_artists = delete_all_unreferenced_artists(&conn)?;
259
260        info!("Deleted {affected_artists} Artists");
261
262        // finally run optimize to reclaim freed space
263        exec_optimize(&conn)?;
264
265        info!("Finished Database cleanup");
266
267        Ok(())
268    }
269}
270
271/// Run SQLite operation `PRAGMA optimize`.
272fn exec_optimize(conn: &Connection) -> Result<()> {
273    let mut stmt = conn.prepare("PRAGMA optimize;")?;
274
275    // "PRAGMA optimize;" does not return any rows
276    let _ = stmt.execute([]).optional()?.unwrap_or_default();
277
278    Ok(())
279}
280
281#[cfg(test)]
282mod test_utils {
283    use std::path::{Path, PathBuf};
284
285    use rusqlite::Connection;
286
287    use super::Database;
288
289    /// Open a new In-Memory sqlite database
290    pub fn gen_database_raw() -> Connection {
291        Connection::open_in_memory().expect("open db failed")
292    }
293
294    /// Open a new In-Memory database that is fully prepared
295    pub fn gen_database() -> Database {
296        Database::new_from_connection(gen_database_raw()).expect("db creation failed")
297    }
298
299    /// Unix / DOS path handling, because depending on the system paths would otherwise not be absolute
300    pub fn test_path(path: &Path) -> PathBuf {
301        if cfg!(windows) {
302            let mut pathbuf = PathBuf::from("C:\\");
303            pathbuf.push(path);
304
305            pathbuf
306        } else {
307            path.to_path_buf()
308        }
309    }
310
311    #[test]
312    #[cfg(unix)]
313    fn test_path_absolute_unix() {
314        let path = test_path(Path::new("/somewhere/else"));
315        assert!(path.is_absolute());
316
317        assert_eq!(path, Path::new("/somewhere/else"));
318    }
319
320    #[test]
321    #[cfg(windows)]
322    fn test_path_absolute_windows() {
323        let path = test_path(Path::new("/somewhere/else"));
324        assert!(path.is_absolute());
325
326        assert_eq!(path, Path::new("C:\\somewhere\\else"));
327    }
328}