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 / scanner.rs
17 kB 604 lines
1use crate::db::Db; 2use crate::models::{Album, Artist, Song}; 3use crate::typesense::TypesenseClient; 4use anyhow::{Context, Result}; 5use lofty::file::{AudioFile, TaggedFileExt}; 6use lofty::picture::{MimeType, Picture}; 7use lofty::probe::Probe; 8use lofty::tag::Accessor; 9use md5::{Digest, Md5}; 10use std::path::{Path, PathBuf}; 11use std::sync::atomic::{AtomicUsize, Ordering}; 12use std::sync::Arc; 13use std::time::SystemTime; 14use walkdir::WalkDir; 15 16pub const AUDIO_EXTS: &[&str] = &[ 17 "mp3", "ogg", "flac", "m4a", "aac", "mp4", "alac", "wav", "wv", "mpc", "aiff", "aif", "opus", 18 "ape", "wma", 19]; 20 21#[derive(Debug, Clone, Default)] 22pub struct ScanStats { 23 pub scanned: usize, 24 pub inserted: usize, 25 pub updated: usize, 26 pub skipped: usize, 27 pub removed: usize, 28} 29 30#[derive(Debug, Default)] 31pub struct ScanProgress { 32 pub running: std::sync::atomic::AtomicBool, 33 pub count: AtomicUsize, 34} 35 36pub async fn scan( 37 pool: Db, 38 music_dir: PathBuf, 39 covers_dir: PathBuf, 40 progress: Arc<ScanProgress>, 41 typesense: Option<Arc<TypesenseClient>>, 42) -> Result<ScanStats> { 43 progress.running.store(true, Ordering::SeqCst); 44 progress.count.store(0, Ordering::SeqCst); 45 std::fs::create_dir_all(&covers_dir) 46 .with_context(|| format!("creating covers dir {}", covers_dir.display()))?; 47 48 let mut stats = ScanStats::default(); 49 let walker = WalkDir::new(&music_dir).follow_links(true).into_iter(); 50 for entry in walker.filter_map(|e| e.ok()) { 51 if !entry.file_type().is_file() { 52 continue; 53 } 54 let path = entry.path(); 55 if !has_audio_extension(path) { 56 continue; 57 } 58 stats.scanned += 1; 59 progress.count.store(stats.scanned, Ordering::SeqCst); 60 match process_file(&pool, path, &covers_dir, typesense.as_deref()).await { 61 Ok(ProcessResult::Inserted) => stats.inserted += 1, 62 Ok(ProcessResult::Updated) => stats.updated += 1, 63 Ok(ProcessResult::Skipped) => stats.skipped += 1, 64 Err(e) => { 65 tracing::warn!("scan {}: {e}", path.display()); 66 stats.skipped += 1; 67 } 68 } 69 } 70 71 match reconcile_deletions(&pool, typesense.as_deref()).await { 72 Ok(removed) => stats.removed = removed as usize, 73 Err(e) => tracing::warn!("reconcile deletions: {e}"), 74 } 75 76 progress.running.store(false, Ordering::SeqCst); 77 tracing::info!( 78 "scan complete: {} scanned, {} inserted, {} updated, {} skipped, {} removed", 79 stats.scanned, 80 stats.inserted, 81 stats.updated, 82 stats.skipped, 83 stats.removed 84 ); 85 Ok(stats) 86} 87 88pub async fn reconcile_deletions(pool: &Db, typesense: Option<&TypesenseClient>) -> Result<u64> { 89 let rows: Vec<(String, String, String, String)> = 90 sqlx::query_as("SELECT id, path, album_id, artist_id FROM songs") 91 .fetch_all(pool) 92 .await?; 93 94 let mut missing_paths = Vec::new(); 95 let mut missing_song_ids = Vec::new(); 96 let mut album_ids = std::collections::HashSet::new(); 97 let mut artist_ids = std::collections::HashSet::new(); 98 for (id, path, album_id, artist_id) in rows { 99 if !Path::new(&path).exists() { 100 missing_paths.push(path); 101 missing_song_ids.push(id); 102 album_ids.insert(album_id); 103 artist_ids.insert(artist_id); 104 } 105 } 106 107 if missing_paths.is_empty() { 108 return Ok(0); 109 } 110 111 let mut tx = pool.begin().await?; 112 let mut deleted = 0u64; 113 for path in &missing_paths { 114 deleted += sqlx::query("DELETE FROM songs WHERE path = ?1") 115 .bind(path) 116 .execute(&mut *tx) 117 .await? 118 .rows_affected(); 119 } 120 tx.commit().await?; 121 122 if let Some(ts) = typesense { 123 for id in &missing_song_ids { 124 if let Err(e) = ts.delete_song(id).await { 125 tracing::warn!("typesense delete song {id}: {e}"); 126 } 127 } 128 } 129 130 for album_id in &album_ids { 131 gc_album(pool, album_id, typesense).await?; 132 } 133 for artist_id in &artist_ids { 134 gc_artist(pool, artist_id, typesense).await?; 135 } 136 137 Ok(deleted) 138} 139 140pub async fn gc_album( 141 pool: &Db, 142 album_id: &str, 143 typesense: Option<&TypesenseClient>, 144) -> Result<()> { 145 let remaining: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM songs WHERE album_id = ?1") 146 .bind(album_id) 147 .fetch_one(pool) 148 .await?; 149 if remaining == 0 { 150 sqlx::query("DELETE FROM albums WHERE id = ?1") 151 .bind(album_id) 152 .execute(pool) 153 .await?; 154 if let Some(ts) = typesense { 155 if let Err(e) = ts.delete_album(album_id).await { 156 tracing::warn!("typesense delete album {album_id}: {e}"); 157 } 158 } 159 } 160 Ok(()) 161} 162 163pub async fn gc_artist( 164 pool: &Db, 165 artist_id: &str, 166 typesense: Option<&TypesenseClient>, 167) -> Result<()> { 168 let remaining: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM songs WHERE artist_id = ?1") 169 .bind(artist_id) 170 .fetch_one(pool) 171 .await?; 172 if remaining == 0 { 173 sqlx::query("DELETE FROM artists WHERE id = ?1") 174 .bind(artist_id) 175 .execute(pool) 176 .await?; 177 if let Some(ts) = typesense { 178 if let Err(e) = ts.delete_artist(artist_id).await { 179 tracing::warn!("typesense delete artist {artist_id}: {e}"); 180 } 181 } 182 } 183 Ok(()) 184} 185 186enum ProcessResult { 187 Inserted, 188 Updated, 189 Skipped, 190} 191 192pub fn has_audio_extension(path: &Path) -> bool { 193 match path.extension().and_then(|s| s.to_str()) { 194 Some(ext) => AUDIO_EXTS.iter().any(|e| e.eq_ignore_ascii_case(ext)), 195 None => false, 196 } 197} 198 199pub async fn upsert_file( 200 pool: &Db, 201 path: &Path, 202 covers_dir: &Path, 203 typesense: Option<&TypesenseClient>, 204) -> Result<()> { 205 process_file(pool, path, covers_dir, typesense) 206 .await 207 .map(|_| ()) 208} 209 210async fn process_file( 211 pool: &Db, 212 path: &Path, 213 covers_dir: &Path, 214 typesense: Option<&TypesenseClient>, 215) -> Result<ProcessResult> { 216 let path_str = path.to_string_lossy().to_string(); 217 let meta = std::fs::metadata(path)?; 218 let mtime = meta 219 .modified() 220 .ok() 221 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()) 222 .map(|d| d.as_secs() as i64) 223 .unwrap_or(0); 224 let filesize = meta.len() as i64; 225 226 if let Some(existing_mtime) = 227 sqlx::query_scalar::<_, i64>("SELECT mtime FROM songs WHERE path = ?1") 228 .bind(&path_str) 229 .fetch_optional(pool) 230 .await? 231 { 232 if existing_mtime == mtime { 233 return Ok(ProcessResult::Skipped); 234 } 235 } 236 237 let extracted = tokio::task::spawn_blocking({ 238 let path = path.to_path_buf(); 239 let covers_dir = covers_dir.to_path_buf(); 240 move || extract_metadata(&path, filesize, &covers_dir) 241 }) 242 .await??; 243 244 let Extracted { 245 title, 246 artist, 247 album_artist, 248 album, 249 genre, 250 year, 251 track, 252 disc, 253 duration_ms, 254 bitrate, 255 suffix, 256 content_type, 257 cover_filename, 258 } = extracted; 259 260 let display_artist = if !album_artist.is_empty() { 261 album_artist 262 } else { 263 artist.clone() 264 }; 265 266 let artist_id = artist_id_for(&display_artist); 267 let album_id = album_id_for(&display_artist, &album, year); 268 let song_id = song_id_for(&path_str); 269 270 let mut tx = pool.begin().await?; 271 272 sqlx::query( 273 r#"INSERT INTO artists (id, name, name_lower) VALUES (?1, ?2, ?3) 274 ON CONFLICT(name_lower) DO UPDATE SET name = excluded.name"#, 275 ) 276 .bind(&artist_id) 277 .bind(&display_artist) 278 .bind(display_artist.to_lowercase()) 279 .execute(&mut *tx) 280 .await?; 281 282 sqlx::query( 283 r#"INSERT INTO albums (id, title, artist, artist_id, year, cover_art) 284 VALUES (?1, ?2, ?3, ?4, ?5, ?6) 285 ON CONFLICT(id) DO UPDATE SET 286 title = excluded.title, 287 artist = excluded.artist, 288 artist_id = excluded.artist_id, 289 year = excluded.year, 290 cover_art = COALESCE(excluded.cover_art, albums.cover_art)"#, 291 ) 292 .bind(&album_id) 293 .bind(&album) 294 .bind(&display_artist) 295 .bind(&artist_id) 296 .bind(year) 297 .bind(cover_filename.as_deref()) 298 .execute(&mut *tx) 299 .await?; 300 301 let existed = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM songs WHERE id = ?1") 302 .bind(&song_id) 303 .fetch_one(&mut *tx) 304 .await? 305 > 0; 306 307 sqlx::query( 308 r#"INSERT INTO songs 309 (id, path, title, artist, artist_id, album, album_id, genre, track_number, 310 disc_number, year, duration_ms, bitrate, filesize, suffix, content_type, cover_art, mtime) 311 VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18) 312 ON CONFLICT(id) DO UPDATE SET 313 path = excluded.path, 314 title = excluded.title, 315 artist = excluded.artist, 316 artist_id = excluded.artist_id, 317 album = excluded.album, 318 album_id = excluded.album_id, 319 genre = excluded.genre, 320 track_number = excluded.track_number, 321 disc_number = excluded.disc_number, 322 year = excluded.year, 323 duration_ms = excluded.duration_ms, 324 bitrate = excluded.bitrate, 325 filesize = excluded.filesize, 326 suffix = excluded.suffix, 327 content_type = excluded.content_type, 328 cover_art = COALESCE(excluded.cover_art, songs.cover_art), 329 mtime = excluded.mtime"#, 330 ) 331 .bind(&song_id) 332 .bind(&path_str) 333 .bind(&title) 334 .bind(&artist) 335 .bind(&artist_id) 336 .bind(&album) 337 .bind(&album_id) 338 .bind(genre.as_deref()) 339 .bind(track) 340 .bind(disc) 341 .bind(year_to_opt(year)) 342 .bind(duration_ms) 343 .bind(bitrate) 344 .bind(filesize) 345 .bind(&suffix) 346 .bind(&content_type) 347 .bind(cover_filename.as_deref()) 348 .bind(mtime) 349 .execute(&mut *tx) 350 .await?; 351 352 tx.commit().await?; 353 354 if let Some(ts) = typesense { 355 let artist_row = Artist { 356 id: artist_id.clone(), 357 name: display_artist.clone(), 358 }; 359 let album_row = Album { 360 id: album_id.clone(), 361 title: album.clone(), 362 artist: display_artist.clone(), 363 artist_id: artist_id.clone(), 364 year, 365 cover_art: cover_filename.clone(), 366 }; 367 let song_row = Song { 368 id: song_id.clone(), 369 path: path_str.clone(), 370 title: title.clone(), 371 artist: artist.clone(), 372 artist_id: artist_id.clone(), 373 album: album.clone(), 374 album_id: album_id.clone(), 375 genre: genre.clone(), 376 track_number: track, 377 disc_number: disc, 378 year: year_to_opt(year), 379 duration_ms, 380 bitrate, 381 filesize, 382 suffix: suffix.clone(), 383 content_type: content_type.clone(), 384 cover_art: cover_filename.clone(), 385 }; 386 if let Err(e) = ts.upsert_artist(&artist_row).await { 387 tracing::warn!("typesense upsert artist {}: {e}", artist_row.id); 388 } 389 if let Err(e) = ts.upsert_album(&album_row).await { 390 tracing::warn!("typesense upsert album {}: {e}", album_row.id); 391 } 392 if let Err(e) = ts.upsert_song(&song_row).await { 393 tracing::warn!("typesense upsert song {}: {e}", song_row.id); 394 } 395 } 396 397 Ok(if existed { 398 ProcessResult::Updated 399 } else { 400 ProcessResult::Inserted 401 }) 402} 403 404struct Extracted { 405 title: String, 406 artist: String, 407 album_artist: String, 408 album: String, 409 genre: Option<String>, 410 year: i64, 411 track: Option<i64>, 412 disc: Option<i64>, 413 duration_ms: i64, 414 bitrate: i64, 415 suffix: String, 416 content_type: String, 417 cover_filename: Option<String>, 418} 419 420fn extract_metadata(path: &Path, filesize: i64, covers_dir: &Path) -> Result<Extracted> { 421 let tagged = Probe::open(path)?.read()?; 422 let props = tagged.properties(); 423 let duration_ms = props.duration().as_millis() as i64; 424 let bitrate = props.audio_bitrate().unwrap_or(0) as i64; 425 426 let primary_tag = tagged.primary_tag().or_else(|| tagged.first_tag()); 427 428 let title = primary_tag 429 .and_then(|t| t.title().map(|s| s.into_owned())) 430 .unwrap_or_else(|| file_stem(path)); 431 let artist = primary_tag 432 .and_then(|t| t.artist().map(|s| s.into_owned())) 433 .unwrap_or_else(|| "Unknown Artist".to_string()); 434 let album_artist = primary_tag 435 .and_then(|t| { 436 t.get_string(&lofty::tag::ItemKey::AlbumArtist) 437 .map(|s| s.to_string()) 438 }) 439 .unwrap_or_default(); 440 let album = primary_tag 441 .and_then(|t| t.album().map(|s| s.into_owned())) 442 .unwrap_or_else(|| "Unknown Album".to_string()); 443 let genre = primary_tag.and_then(|t| t.genre().map(|s| s.into_owned())); 444 let year = primary_tag.and_then(|t| t.year()).unwrap_or(0) as i64; 445 let track = primary_tag.and_then(|t| t.track()).map(|n| n as i64); 446 let disc = primary_tag.and_then(|t| t.disk()).map(|n| n as i64); 447 448 let suffix = path 449 .extension() 450 .and_then(|s| s.to_str()) 451 .unwrap_or("") 452 .to_lowercase(); 453 let content_type = mime_for_suffix(&suffix).to_string(); 454 455 let album_key = album_id_for( 456 if album_artist.is_empty() { 457 &artist 458 } else { 459 &album_artist 460 }, 461 &album, 462 year, 463 ); 464 465 let cover_filename = if let Some(pic) = primary_tag.and_then(|t| t.pictures().first().cloned()) 466 { 467 save_picture(covers_dir, &album_key, &pic).ok() 468 } else { 469 find_dir_cover(path, covers_dir, &album_key).ok().flatten() 470 }; 471 472 let _ = filesize; 473 Ok(Extracted { 474 title, 475 artist, 476 album_artist, 477 album, 478 genre, 479 year, 480 track, 481 disc, 482 duration_ms, 483 bitrate, 484 suffix, 485 content_type, 486 cover_filename, 487 }) 488} 489 490fn save_picture(covers_dir: &Path, album_key: &str, pic: &Picture) -> Result<String> { 491 let ext = match pic.mime_type() { 492 Some(MimeType::Jpeg) => "jpg", 493 Some(MimeType::Png) => "png", 494 Some(MimeType::Gif) => "gif", 495 Some(MimeType::Bmp) => "bmp", 496 Some(MimeType::Tiff) => "tiff", 497 _ => "jpg", 498 }; 499 let filename = format!("{album_key}.{ext}"); 500 let full = covers_dir.join(&filename); 501 if !full.exists() { 502 std::fs::write(&full, pic.data())?; 503 } 504 Ok(filename) 505} 506 507fn find_dir_cover(audio_path: &Path, covers_dir: &Path, album_key: &str) -> Result<Option<String>> { 508 let Some(dir) = audio_path.parent() else { 509 return Ok(None); 510 }; 511 const CANDIDATES: &[&str] = &[ 512 "cover.jpg", 513 "cover.jpeg", 514 "cover.png", 515 "folder.jpg", 516 "folder.jpeg", 517 "folder.png", 518 "front.jpg", 519 "front.png", 520 "album.jpg", 521 "album.png", 522 ]; 523 for cand in CANDIDATES { 524 let p = dir.join(cand); 525 if p.is_file() { 526 let ext = p 527 .extension() 528 .and_then(|s| s.to_str()) 529 .unwrap_or("jpg") 530 .to_lowercase(); 531 let filename = format!("{album_key}.{ext}"); 532 let dest = covers_dir.join(&filename); 533 if !dest.exists() { 534 std::fs::copy(&p, &dest)?; 535 } 536 return Ok(Some(filename)); 537 } 538 } 539 Ok(None) 540} 541 542fn file_stem(path: &Path) -> String { 543 path.file_stem() 544 .and_then(|s| s.to_str()) 545 .unwrap_or("Unknown Title") 546 .to_string() 547} 548 549fn year_to_opt(year: i64) -> Option<i64> { 550 if year > 0 { 551 Some(year) 552 } else { 553 None 554 } 555} 556 557fn mime_for_suffix(suffix: &str) -> &'static str { 558 match suffix { 559 "mp3" => "audio/mpeg", 560 "flac" => "audio/flac", 561 "ogg" => "audio/ogg", 562 "m4a" | "aac" | "mp4" | "alac" => "audio/mp4", 563 "wav" => "audio/wav", 564 "wma" => "audio/x-ms-wma", 565 "opus" => "audio/opus", 566 "aiff" | "aif" => "audio/aiff", 567 "wv" => "audio/x-wavpack", 568 "mpc" => "audio/x-musepack", 569 "ape" => "audio/x-ape", 570 _ => "application/octet-stream", 571 } 572} 573 574fn short_md5(input: &str) -> String { 575 let mut hasher = Md5::new(); 576 hasher.update(input.as_bytes()); 577 let digest = hasher.finalize(); 578 let mut s = String::with_capacity(32); 579 for b in digest.iter() { 580 s.push_str(&format!("{b:02x}")); 581 } 582 s.truncate(16); 583 s 584} 585 586pub fn artist_id_for(name: &str) -> String { 587 format!("ar-{}", short_md5(&name.to_lowercase())) 588} 589 590pub fn album_id_for(artist: &str, album: &str, year: i64) -> String { 591 format!( 592 "al-{}", 593 short_md5(&format!( 594 "{}|{}|{}", 595 artist.to_lowercase(), 596 album.to_lowercase(), 597 year 598 )) 599 ) 600} 601 602pub fn song_id_for(path: &str) -> String { 603 format!("so-{}", short_md5(path)) 604}