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 / main.rs
13 kB 355 lines
1mod cli; 2mod config; 3mod db; 4mod jellyfin; 5mod mdns; 6mod models; 7mod s3; 8mod scanner; 9mod scrobble; 10mod server; 11mod typesense; 12mod upnp; 13mod video_scanner; 14mod watcher; 15 16use anyhow::Result; 17use clap::Parser; 18use std::sync::atomic::Ordering; 19use std::sync::Arc; 20use std::time::Duration; 21 22#[actix_web::main] 23async fn main() -> Result<()> { 24 tracing_subscriber::fmt() 25 .with_env_filter( 26 tracing_subscriber::EnvFilter::try_from_default_env() 27 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), 28 ) 29 .init(); 30 31 let args = cli::Cli::parse(); 32 let cfg = config::Config::load(&args.config)?; 33 34 let s3_endpoint = cfg 35 .s3 36 .as_ref() 37 .filter(|s| s.enabled) 38 .map(|s| (s.host.as_str(), s.port)); 39 let jellyfin_endpoint = cfg.jellyfin.as_ref().map(|j| (j.host.as_str(), j.port)); 40 let upnp_endpoint = cfg 41 .upnp 42 .as_ref() 43 .filter(|u| u.enabled) 44 .map(|u| (u.host.as_str(), u.port)); 45 cli::print_banner( 46 &cfg.host, 47 cfg.port, 48 &cfg.music_dir, 49 s3_endpoint, 50 jellyfin_endpoint, 51 upnp_endpoint, 52 ); 53 54 let pool = db::init(&cfg.database_path).await?; 55 let scan_progress = Arc::new(scanner::ScanProgress::default()); 56 57 // Optional Typesense client — construct once, share via `Arc`. On startup 58 // we call `bootstrap()` to create collections, then reindex from SQLite 59 // if the songs collection is empty (fresh Typesense node self-heals). 60 let typesense: Option<Arc<typesense::TypesenseClient>> = 61 if let Some(ts_cfg) = cfg.typesense.as_ref() { 62 let client = Arc::new(typesense::TypesenseClient::new(ts_cfg)); 63 match client.bootstrap().await { 64 Ok(()) => { 65 let client_c = client.clone(); 66 let pool_c = pool.clone(); 67 tokio::spawn(async move { 68 match client_c.songs_empty().await { 69 Ok(true) => { 70 tracing::info!("typesense: songs collection empty, reindexing"); 71 if let Err(e) = client_c.reindex_from_db(&pool_c).await { 72 tracing::error!("typesense reindex failed: {e}"); 73 } 74 } 75 Ok(false) => { 76 tracing::info!( 77 "typesense: songs collection non-empty, skipping reindex" 78 ); 79 } 80 Err(e) => { 81 tracing::error!("typesense describe songs: {e}"); 82 } 83 } 84 }); 85 Some(client) 86 } 87 Err(e) => { 88 tracing::error!("typesense: bootstrap failed ({e}) — falling back to FTS5"); 89 None 90 } 91 } 92 } else { 93 None 94 }; 95 96 // Optional ListenBrainz scrobble client. Same opt-in shape as the other 97 // plugins — `None` when `[listenbrainz]` is absent from the config. 98 let scrobble: Option<Arc<scrobble::ListenBrainzClient>> = cfg 99 .listenbrainz 100 .as_ref() 101 .map(|lb| Arc::new(scrobble::ListenBrainzClient::new(lb))); 102 tracing::info!( 103 "listenbrainz scrobble: {}", 104 if scrobble.is_some() { 105 "enabled" 106 } else { 107 "disabled" 108 } 109 ); 110 111 if !args.no_scan { 112 let pool_c = pool.clone(); 113 let music_dir = cfg.music_dir.clone(); 114 let covers_dir = cfg.covers_dir.clone(); 115 let progress = scan_progress.clone(); 116 let ts_c = typesense.clone(); 117 tokio::spawn(async move { 118 tracing::info!("starting library scan of {}", music_dir.display()); 119 if let Err(e) = scanner::scan( 120 pool_c.clone(), 121 music_dir.clone(), 122 covers_dir.clone(), 123 progress, 124 ts_c.clone(), 125 ) 126 .await 127 { 128 tracing::error!("scan failed: {e}"); 129 } 130 watcher::start(pool_c, music_dir, covers_dir, ts_c); 131 }); 132 } else { 133 watcher::start( 134 pool.clone(), 135 cfg.music_dir.clone(), 136 cfg.covers_dir.clone(), 137 typesense.clone(), 138 ); 139 } 140 141 if cfg.scan_interval_secs > 0 { 142 let pool_c = pool.clone(); 143 let music_dir = cfg.music_dir.clone(); 144 let covers_dir = cfg.covers_dir.clone(); 145 let progress = scan_progress.clone(); 146 let ts_c = typesense.clone(); 147 let interval = Duration::from_secs(cfg.scan_interval_secs); 148 tokio::spawn(async move { 149 let mut ticker = tokio::time::interval(interval); 150 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); 151 ticker.tick().await; 152 loop { 153 ticker.tick().await; 154 if progress.running.load(Ordering::SeqCst) { 155 tracing::debug!("rescan: previous scan still running, skipping tick"); 156 continue; 157 } 158 tracing::info!("periodic rescan of {}", music_dir.display()); 159 if let Err(e) = scanner::scan( 160 pool_c.clone(), 161 music_dir.clone(), 162 covers_dir.clone(), 163 progress.clone(), 164 ts_c.clone(), 165 ) 166 .await 167 { 168 tracing::error!("periodic rescan failed: {e}"); 169 } 170 } 171 }); 172 } 173 174 if let Some(s3_cfg) = cfg.s3.clone() { 175 if s3_cfg.enabled { 176 let music_dir = cfg.music_dir.clone(); 177 let video_dir = cfg.video.as_ref().map(|v| v.video_dir.clone()); 178 actix_web::rt::spawn(async move { 179 if let Err(e) = s3::start(s3_cfg, music_dir, video_dir).await { 180 tracing::error!("s3 server stopped: {e}"); 181 } 182 }); 183 } 184 } 185 186 // Video library — enabled only when [video] block is present. 187 // Always create the progress handle (even when [video] is absent) so we 188 // can share a single placeholder Arc with the Jellyfin scan-trigger. 189 let video_scan_progress = Arc::new(video_scanner::VideoScanProgress::default()); 190 if let Some(video_cfg) = cfg.video.clone() { 191 let pool_c = pool.clone(); 192 let video_dir = video_cfg.video_dir.clone(); 193 let covers_dir = cfg.covers_dir.clone(); 194 let progress_c = video_scan_progress.clone(); 195 if !args.no_scan { 196 tokio::spawn(async move { 197 tracing::info!("starting video scan of {}", video_dir.display()); 198 if let Err(e) = video_scanner::scan(pool_c, video_dir, covers_dir, progress_c).await 199 { 200 tracing::error!("video scan failed: {e}"); 201 } 202 }); 203 } 204 if video_cfg.scan_interval_secs > 0 { 205 let pool_c = pool.clone(); 206 let video_dir = video_cfg.video_dir.clone(); 207 let covers_dir = cfg.covers_dir.clone(); 208 let interval = Duration::from_secs(video_cfg.scan_interval_secs); 209 let progress = video_scan_progress.clone(); 210 tokio::spawn(async move { 211 let mut ticker = tokio::time::interval(interval); 212 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); 213 ticker.tick().await; 214 loop { 215 ticker.tick().await; 216 if progress.running.load(Ordering::SeqCst) { 217 tracing::debug!("video rescan: previous scan still running, skipping tick"); 218 continue; 219 } 220 tracing::info!("periodic video rescan of {}", video_dir.display()); 221 if let Err(e) = video_scanner::scan( 222 pool_c.clone(), 223 video_dir.clone(), 224 covers_dir.clone(), 225 progress.clone(), 226 ) 227 .await 228 { 229 tracing::error!("periodic video rescan failed: {e}"); 230 } 231 } 232 }); 233 } 234 } 235 236 // Jellyfin sidecar — enabled only when [jellyfin] block is present. 237 let jellyfin_server_id = if let Some(jf_cfg) = cfg.jellyfin.clone() { 238 match jellyfin::auth::ensure_server_id(&pool).await { 239 Ok(id) => { 240 let pool_c = pool.clone(); 241 let username = cfg.username.clone(); 242 let password = cfg.password.clone(); 243 let music_dir = cfg.music_dir.clone(); 244 let covers_dir = cfg.covers_dir.clone(); 245 let jf_cfg_c = jf_cfg.clone(); 246 let video_library_name = cfg.video.as_ref().map(|v| v.library_name.clone()); 247 let video_dir = cfg.video.as_ref().map(|v| v.video_dir.clone()); 248 let lastfm = cfg.lastfm.clone(); 249 let musicbrainz = cfg.musicbrainz.clone(); 250 let music_progress = scan_progress.clone(); 251 let video_progress = video_scan_progress.clone(); 252 let ts_c = typesense.clone(); 253 let lb_c = scrobble.clone(); 254 actix_web::rt::spawn(async move { 255 if let Err(e) = jellyfin::start( 256 jf_cfg_c, 257 pool_c, 258 username, 259 password, 260 music_dir, 261 covers_dir, 262 video_library_name, 263 video_dir, 264 lastfm, 265 musicbrainz, 266 music_progress, 267 video_progress, 268 ts_c, 269 lb_c, 270 ) 271 .await 272 { 273 tracing::error!("jellyfin server stopped: {e}"); 274 } 275 }); 276 277 let server_name = jf_cfg.server_name.clone(); 278 let server_id_c = id.clone(); 279 let http_port = jf_cfg.port; 280 actix_web::rt::spawn(async move { 281 if let Err(e) = 282 jellyfin::discovery::run(server_name, server_id_c, http_port).await 283 { 284 tracing::error!("jellyfin discovery stopped: {e}"); 285 } 286 }); 287 288 Some(id) 289 } 290 Err(e) => { 291 tracing::error!("jellyfin: failed to initialize server id: {e}"); 292 None 293 } 294 } 295 } else { 296 None 297 }; 298 299 // UPnP/DLNA media server — enabled only when [upnp] block is present. 300 if let Some(upnp_cfg) = cfg.upnp.clone().filter(|u| u.enabled) { 301 match upnp::ensure_device_uuid(&pool).await { 302 Ok(uuid) => { 303 let pool_c = pool.clone(); 304 let covers_dir = cfg.covers_dir.clone(); 305 let subsonic_port = cfg.port; 306 let http_port = upnp_cfg.port; 307 let uuid_c = uuid.clone(); 308 actix_web::rt::spawn(async move { 309 if let Err(e) = 310 upnp::start(upnp_cfg, pool_c, covers_dir, uuid_c, subsonic_port).await 311 { 312 tracing::error!("upnp server stopped: {e}"); 313 } 314 }); 315 tokio::spawn(async move { 316 if let Err(e) = upnp::ssdp::run(uuid, http_port).await { 317 tracing::error!("upnp ssdp stopped: {e}"); 318 } 319 }); 320 } 321 Err(e) => { 322 tracing::error!("upnp: failed to initialize device uuid: {e}"); 323 } 324 } 325 } 326 327 let _mdns_handle = if cfg.mdns.enabled { 328 let s3_endpoint = cfg 329 .s3 330 .as_ref() 331 .filter(|s| s.enabled) 332 .map(|s| (s.host.clone(), s.port)); 333 let jellyfin_mdns = cfg 334 .jellyfin 335 .as_ref() 336 .zip(jellyfin_server_id.as_ref()) 337 .map(|(j, id)| (j.host.clone(), j.port, id.clone())); 338 match mdns::start( 339 &cfg.mdns.instance_name, 340 cfg.port, 341 s3_endpoint, 342 jellyfin_mdns, 343 ) { 344 Ok(handle) => Some(handle), 345 Err(e) => { 346 tracing::warn!("mdns: disabled — {e}"); 347 None 348 } 349 } 350 } else { 351 None 352 }; 353 354 server::start(cfg, pool, scan_progress, typesense, scrobble).await 355}