A tiny Subsonic/Jellyfin/S3 server in Rust
navidrome
subsonic
s3
emby
jellyfin
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}