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