A tiny Subsonic/Jellyfin/S3 server in Rust
navidrome subsonic s3 emby jellyfin
0

Configure Feed

Select the types of activity you want to include in your feed.

smolsonic / src / watcher.rs
5.5 kB 186 lines
1use crate::db::Db; 2use crate::scanner::{gc_album, gc_artist, has_audio_extension, upsert_file}; 3use crate::typesense::TypesenseClient; 4use anyhow::Result; 5use notify::{EventKind, RecursiveMode}; 6use notify_debouncer_full::new_debouncer; 7use std::path::{Path, PathBuf}; 8use std::sync::Arc; 9use std::time::Duration; 10 11const DEBOUNCE: Duration = Duration::from_secs(2); 12 13pub fn start( 14 pool: Db, 15 music_dir: PathBuf, 16 covers_dir: PathBuf, 17 typesense: Option<Arc<TypesenseClient>>, 18) { 19 let rt = tokio::runtime::Handle::current(); 20 std::thread::Builder::new() 21 .name("library-watcher".into()) 22 .spawn(move || { 23 if let Err(e) = run(pool, music_dir, covers_dir, typesense, rt) { 24 tracing::error!("watcher stopped: {e}"); 25 } 26 }) 27 .expect("spawn watcher thread"); 28} 29 30fn run( 31 pool: Db, 32 music_dir: PathBuf, 33 covers_dir: PathBuf, 34 typesense: Option<Arc<TypesenseClient>>, 35 rt: tokio::runtime::Handle, 36) -> Result<()> { 37 let (tx, rx) = std::sync::mpsc::channel(); 38 let mut debouncer = new_debouncer(DEBOUNCE, None, tx)?; 39 debouncer.watch(&music_dir, RecursiveMode::Recursive)?; 40 tracing::info!("watching {} for changes", music_dir.display()); 41 42 for result in rx { 43 match result { 44 Ok(events) => { 45 for ev in events { 46 if matches!(ev.event.kind, EventKind::Access(_) | EventKind::Other) { 47 continue; 48 } 49 for path in &ev.event.paths { 50 rt.block_on(apply_path(&pool, &covers_dir, typesense.as_deref(), path)); 51 } 52 } 53 } 54 Err(errors) => { 55 for e in errors { 56 tracing::warn!("watcher error: {e}"); 57 } 58 } 59 } 60 } 61 drop(debouncer); 62 Ok(()) 63} 64 65async fn apply_path( 66 pool: &Db, 67 covers_dir: &Path, 68 typesense: Option<&TypesenseClient>, 69 path: &Path, 70) { 71 if path.is_file() { 72 if !has_audio_extension(path) { 73 return; 74 } 75 match upsert_file(pool, path, covers_dir, typesense).await { 76 Ok(_) => tracing::info!("watch upsert {}", path.display()), 77 Err(e) => tracing::warn!("watch upsert {}: {e}", path.display()), 78 } 79 return; 80 } 81 82 if path.exists() { 83 return; 84 } 85 86 let path_str = path.to_string_lossy().to_string(); 87 if has_audio_extension(path) { 88 match delete_song(pool, &path_str, typesense).await { 89 Ok(true) => tracing::info!("watch delete {path_str}"), 90 Ok(false) => {} 91 Err(e) => tracing::warn!("watch delete {path_str}: {e}"), 92 } 93 return; 94 } 95 96 match delete_songs_under(pool, &path_str, typesense).await { 97 Ok(n) if n > 0 => tracing::info!("watch delete {n} songs under {path_str}"), 98 Ok(_) => {} 99 Err(e) => tracing::warn!("watch delete dir {path_str}: {e}"), 100 } 101} 102 103async fn delete_song(pool: &Db, path: &str, typesense: Option<&TypesenseClient>) -> Result<bool> { 104 let row = sqlx::query_as::<_, (String, String, String)>( 105 "SELECT id, album_id, artist_id FROM songs WHERE path = ?1", 106 ) 107 .bind(path) 108 .fetch_optional(pool) 109 .await?; 110 111 let Some((song_id, album_id, artist_id)) = row else { 112 return Ok(false); 113 }; 114 115 sqlx::query("DELETE FROM songs WHERE path = ?1") 116 .bind(path) 117 .execute(pool) 118 .await?; 119 120 if let Some(ts) = typesense { 121 if let Err(e) = ts.delete_song(&song_id).await { 122 tracing::warn!("typesense delete song {song_id}: {e}"); 123 } 124 } 125 126 gc_album(pool, &album_id, typesense).await?; 127 gc_artist(pool, &artist_id, typesense).await?; 128 Ok(true) 129} 130 131async fn delete_songs_under( 132 pool: &Db, 133 dir: &str, 134 typesense: Option<&TypesenseClient>, 135) -> Result<u64> { 136 let mut prefix = dir.trim_end_matches(std::path::MAIN_SEPARATOR).to_string(); 137 prefix.push(std::path::MAIN_SEPARATOR); 138 let pattern = format!("{}%", escape_like(&prefix)); 139 140 let rows: Vec<(String, String, String)> = 141 sqlx::query_as("SELECT id, album_id, artist_id FROM songs WHERE path LIKE ?1 ESCAPE '\\'") 142 .bind(&pattern) 143 .fetch_all(pool) 144 .await?; 145 146 if rows.is_empty() { 147 return Ok(0); 148 } 149 150 let deleted = sqlx::query("DELETE FROM songs WHERE path LIKE ?1 ESCAPE '\\'") 151 .bind(&pattern) 152 .execute(pool) 153 .await? 154 .rows_affected(); 155 156 let mut seen_albums = std::collections::HashSet::new(); 157 let mut seen_artists = std::collections::HashSet::new(); 158 for (song_id, album_id, artist_id) in rows { 159 if let Some(ts) = typesense { 160 if let Err(e) = ts.delete_song(&song_id).await { 161 tracing::warn!("typesense delete song {song_id}: {e}"); 162 } 163 } 164 if seen_albums.insert(album_id.clone()) { 165 gc_album(pool, &album_id, typesense).await?; 166 } 167 if seen_artists.insert(artist_id.clone()) { 168 gc_artist(pool, &artist_id, typesense).await?; 169 } 170 } 171 Ok(deleted) 172} 173 174fn escape_like(s: &str) -> String { 175 let mut out = String::with_capacity(s.len()); 176 for ch in s.chars() { 177 match ch { 178 '\\' | '%' | '_' => { 179 out.push('\\'); 180 out.push(ch); 181 } 182 _ => out.push(ch), 183 } 184 } 185 out 186}