1use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::{Arc, Mutex};
9
10use crate::config::Config;
11use crate::db::connection::Database;
12use crate::db::queries;
13use crate::db::queries::shares::{ShareKind, Slice};
14use crate::player::commands::PlayerCommand;
15use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
16use crate::remote::client::{SubsonicAuth, SubsonicClient};
17
18pub fn get_remote_password(cfg: &Config) -> Option<String> {
25 (!cfg.remote.password.is_empty()).then(|| cfg.remote.password.clone())
26}
27
28pub fn spawn_library_watch(
41 db_path: std::path::PathBuf,
42 on_state: impl Fn(bool) + Send + Sync + 'static,
43) -> Option<std::thread::JoinHandle<()>> {
44 use notify::{RecursiveMode, Watcher};
45
46 std::thread::Builder::new()
47 .name("koan-library-watch".into())
48 .spawn(move || {
49 let scan_now = |reason: &str| {
50 let cfg = Config::load().unwrap_or_default();
51 if cfg.library.folders.is_empty() {
52 return;
53 }
54 let Ok(db) = Database::open(&db_path) else {
55 return;
56 };
57 on_state(true);
58 let result = crate::index::scanner::full_scan(
59 &db,
60 &cfg.library.folders,
61 crate::index::scanner::ScanOptions::default(),
62 None,
63 );
64 on_state(false);
65 log::info!(
66 "{reason} scan: {} added, {} updated, {} removed, {} unchanged",
67 result.added,
68 result.updated,
69 result.removed,
70 result.skipped
71 );
72 };
73
74 std::thread::sleep(std::time::Duration::from_secs(3));
76 scan_now("startup");
77
78 let (tx, rx) = std::sync::mpsc::channel();
79 let Ok(mut watcher) = notify::recommended_watcher(move |event| {
80 let _ = tx.send(event);
81 }) else {
82 log::warn!("could not watch the library folders");
83 return;
84 };
85
86 let cfg = Config::load().unwrap_or_default();
87 for folder in &cfg.library.folders {
88 if let Err(e) = watcher.watch(folder, RecursiveMode::Recursive) {
89 log::warn!("could not watch {}: {e}", folder.display());
90 }
91 }
92
93 const SETTLE: std::time::Duration = std::time::Duration::from_secs(5);
96 while let Ok(first) = rx.recv() {
97 if first.is_err() {
98 continue;
99 }
100 while rx.recv_timeout(SETTLE).is_ok() {}
101 scan_now("watched change");
102 }
103 })
104 .ok()
105}
106
107pub fn spawn_auto_sync(
121 db_path: std::path::PathBuf,
122 on_state: impl Fn(bool) + Send + 'static,
123 on_progress: impl Fn(crate::remote::sync::SyncProgress) + Send + Sync + 'static,
124) -> Option<std::thread::JoinHandle<()>> {
125 std::thread::Builder::new()
126 .name("koan-auto-sync".into())
127 .spawn(move || {
128 std::thread::sleep(std::time::Duration::from_secs(5));
129 loop {
130 let cfg = Config::load().unwrap_or_default();
131 if !cfg.remote.enabled || !cfg.remote.auto_sync {
132 std::thread::sleep(std::time::Duration::from_secs(60));
135 continue;
136 }
137
138 if let Some(client) = subsonic_client(&cfg)
139 && let Ok(db) = Database::open(&db_path)
140 {
141 on_state(true);
142 match sync_remote(
143 &db,
144 &client,
145 false,
146 &cfg.remote.url,
147 &cfg.remote.username,
148 &on_progress,
149 ) {
150 Ok(s) => log::info!(
151 "auto sync: {} artists, {} albums, {} tracks ({} albums failed); \
152 favourites {}↑ {}↓; playlists {}↓ {}↑",
153 s.library.artists_synced,
154 s.library.albums_synced,
155 s.library.tracks_synced,
156 s.library.albums_failed,
157 s.favourites.pushed,
158 s.favourites.imported,
159 s.playlists.pulled,
160 s.playlists.pushed,
161 ),
162 Err(e) => log::warn!("auto sync failed: {e}"),
163 }
164 on_state(false);
165 }
166
167 match cfg.remote.auto_sync_interval_mins {
168 0 => return,
170 mins => std::thread::sleep(std::time::Duration::from_secs(mins * 60)),
171 }
172 }
173 })
174 .ok()
175}
176
177#[derive(Debug, Clone, Copy, Default)]
179pub struct RebuildSummary {
180 pub tracks: u64,
181 pub albums: u64,
182 pub artists: u64,
183}
184
185pub fn rebuild_index(db: &Database) -> Result<RebuildSummary, crate::db::connection::DbError> {
196 let count = |sql: &str| -> u64 {
197 db.conn
198 .query_row(sql, [], |r| r.get::<_, i64>(0))
199 .unwrap_or(0) as u64
200 };
201 let summary = RebuildSummary {
202 tracks: count("SELECT COUNT(*) FROM tracks"),
203 albums: count("SELECT COUNT(*) FROM albums"),
204 artists: count("SELECT COUNT(*) FROM artists"),
205 };
206
207 db.conn.execute_batch(
210 "BEGIN;
211 DELETE FROM track_vectors;
212 DELETE FROM lyrics_cache;
213 DELETE FROM play_history;
214 DELETE FROM scan_cache;
215 DELETE FROM tracks_fts;
216 DELETE FROM tracks;
217 DELETE FROM similar_artists;
218 DELETE FROM albums;
219 DELETE FROM artists;
220 COMMIT;",
221 )?;
222 let _ = db.conn.execute_batch("VACUUM");
223 Ok(summary)
224}
225
226pub fn cache_size_bytes(cfg: &Config) -> u64 {
228 walkdir::WalkDir::new(cfg.cache_dir())
229 .into_iter()
230 .filter_map(Result::ok)
231 .filter(|e| e.file_type().is_file())
232 .filter_map(|e| e.metadata().ok())
233 .map(|m| m.len())
234 .sum()
235}
236
237pub fn tracks_under(db: &Database, folder: &Path) -> u64 {
242 let (lower, upper) = queries::folder_prefix_range(folder);
243 db.conn
244 .query_row(
245 "SELECT COUNT(*) FROM tracks WHERE path >= ?1 AND path < ?2",
246 [&lower, &upper],
247 |r| r.get::<_, i64>(0),
248 )
249 .unwrap_or(0) as u64
250}
251
252pub fn tracks_from_server(db: &Database) -> u64 {
254 db.conn
255 .query_row(
256 "SELECT COUNT(*) FROM tracks WHERE remote_id IS NOT NULL",
257 [],
258 |r| r.get::<_, i64>(0),
259 )
260 .unwrap_or(0) as u64
261}
262
263pub fn forget_folder(db: &Database, folder: &Path) -> Result<u64, crate::db::connection::DbError> {
276 let folder = &crate::index::spelling::on_disk(folder);
278 let (lower, upper) = queries::folder_prefix_range(folder);
279
280 let tx = db.conn.unchecked_transaction()?;
281 tx.execute(
283 "UPDATE tracks SET path = NULL, source = 'remote'
284 WHERE path >= ?1 AND path < ?2 AND remote_id IS NOT NULL",
285 [&lower, &upper],
286 )?;
287
288 let ids: Vec<i64> = {
289 let mut stmt = tx.prepare("SELECT id FROM tracks WHERE path >= ?1 AND path < ?2")?;
290 let rows = stmt.query_map([&lower, &upper], |r| r.get(0))?;
291 rows.filter_map(Result::ok).collect()
292 };
293 for id in &ids {
294 tx.execute("DELETE FROM track_vectors WHERE track_id = ?1", [id])?;
295 tx.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", [id])?;
296 tx.execute("DELETE FROM play_history WHERE track_id = ?1", [id])?;
297 tx.execute("DELETE FROM scan_cache WHERE track_id = ?1", [id])?;
298 tx.execute("DELETE FROM tracks_fts WHERE rowid = ?1", [id])?;
299 tx.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
300 }
301 prune_empty_albums_and_artists(&tx)?;
302 tx.commit()?;
303 Ok(ids.len() as u64)
304}
305
306pub fn forget_remote(db: &Database) -> Result<u64, crate::db::connection::DbError> {
312 let tx = db.conn.unchecked_transaction()?;
313
314 let ids: Vec<i64> = {
315 let mut stmt =
316 tx.prepare("SELECT id FROM tracks WHERE remote_id IS NOT NULL AND path IS NULL")?;
317 let rows = stmt.query_map([], |r| r.get(0))?;
318 rows.filter_map(Result::ok).collect()
319 };
320 for id in &ids {
321 tx.execute("DELETE FROM track_vectors WHERE track_id = ?1", [id])?;
322 tx.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", [id])?;
323 tx.execute("DELETE FROM play_history WHERE track_id = ?1", [id])?;
324 tx.execute("DELETE FROM scan_cache WHERE track_id = ?1", [id])?;
325 tx.execute("DELETE FROM tracks_fts WHERE rowid = ?1", [id])?;
326 tx.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
327 }
328 tx.execute(
330 "UPDATE tracks SET remote_id = NULL, remote_url = NULL, source = 'local'
331 WHERE remote_id IS NOT NULL",
332 [],
333 )?;
334 tx.execute("DELETE FROM similar_artists", [])?;
335 prune_empty_albums_and_artists(&tx)?;
336 tx.commit()?;
337 Ok(ids.len() as u64)
338}
339
340fn prune_empty_albums_and_artists(
342 tx: &rusqlite::Transaction<'_>,
343) -> Result<(), crate::db::connection::DbError> {
344 tx.execute(
345 "DELETE FROM albums WHERE NOT EXISTS
346 (SELECT 1 FROM tracks WHERE tracks.album_id = albums.id)",
347 [],
348 )?;
349 tx.execute(
350 "DELETE FROM similar_artists WHERE NOT EXISTS
351 (SELECT 1 FROM albums WHERE albums.artist_id = similar_artists.artist_id)",
352 [],
353 )?;
354 tx.execute(
355 "DELETE FROM artists WHERE NOT EXISTS
356 (SELECT 1 FROM albums WHERE albums.artist_id = artists.id)
357 AND NOT EXISTS
358 (SELECT 1 FROM tracks WHERE tracks.artist_id = artists.id)",
359 [],
360 )?;
361 Ok(())
362}
363
364#[derive(Debug, Clone, Copy, Default)]
366pub struct CacheCleared {
367 pub files: u64,
368 pub bytes: u64,
369}
370
371pub fn clear_download_cache(db: &Database, cfg: &Config) -> CacheCleared {
376 let dir = cfg.cache_dir();
377 let mut cleared = CacheCleared::default();
378 for entry in walkdir::WalkDir::new(&dir)
379 .into_iter()
380 .filter_map(Result::ok)
381 .filter(|e| e.file_type().is_file())
382 {
383 if let Ok(meta) = entry.metadata() {
384 cleared.bytes += meta.len();
385 cleared.files += 1;
386 }
387 }
388 let _ = std::fs::remove_dir_all(&dir);
389 let _ = std::fs::create_dir_all(&dir);
390 let _ = queries::clear_cached_paths(&db.conn);
391 cleared
392}
393
394pub fn clear_downloads_for(db: &Database, track_ids: &[i64]) -> CacheCleared {
401 let mut cleared = CacheCleared::default();
402 let paths = match queries::cached_paths_for(&db.conn, track_ids) {
403 Ok(paths) => paths,
404 Err(e) => {
405 log::warn!("could not read cached paths: {e}");
406 return cleared;
407 }
408 };
409 for path in &paths {
410 let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
411 match std::fs::remove_file(path) {
412 Ok(()) => {
413 cleared.files += 1;
414 cleared.bytes += size;
415 }
416 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
418 Err(e) => log::warn!("could not remove {path}: {e}"),
419 }
420 }
421 if let Err(e) = queries::clear_cached_paths_for(&db.conn, track_ids) {
422 log::warn!("removed downloads but failed to forget them ({e})");
423 }
424 cleared
425}
426
427pub fn sweep_partial_downloads(cfg: &Config) -> CacheCleared {
440 let mut swept = CacheCleared::default();
441 for entry in walkdir::WalkDir::new(cfg.cache_dir())
442 .into_iter()
443 .filter_map(Result::ok)
444 .filter(|e| e.file_type().is_file())
445 .filter(|e| e.path().extension().is_some_and(|ext| ext == "part"))
446 {
447 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
448 match std::fs::remove_file(entry.path()) {
449 Ok(()) => {
450 swept.files += 1;
451 swept.bytes += size;
452 }
453 Err(e) => log::warn!("could not remove {}: {e}", entry.path().display()),
454 }
455 }
456 if swept.files > 0 {
457 log::info!(
458 "swept {} unfinished download(s), {} bytes",
459 swept.files,
460 swept.bytes
461 );
462 }
463 swept
464}
465
466pub fn requeue_cleared_downloads(
473 state: &Arc<SharedPlayerState>,
474 tx: &crossbeam_channel::Sender<PlayerCommand>,
475) {
476 let stale = state.reset_items_with_missing_files();
477 if stale.is_empty() {
478 return;
479 }
480 log::info!(
481 "{} queued tracks lost their copy — fetching again",
482 stale.len()
483 );
484 spawn_downloads(stale, tx.clone(), state.clone());
485}
486
487pub fn sync_favourite_to_remote(db: &Database, path: &Path, star: bool) {
499 let cfg = Config::load().unwrap_or_default();
500 if !cfg.remote.enabled {
501 return;
502 }
503 let Ok(Some(remote_id)) = queries::remote_id_for_path(&db.conn, path) else {
504 log::warn!("not syncing favourite: {} has no remote id", path.display());
505 return;
506 };
507 let Some(client) = subsonic_client(&cfg) else {
508 log::warn!("not syncing favourite: no usable server credentials");
509 return;
510 };
511 std::thread::Builder::new()
512 .name("koan-fav-sync".into())
513 .spawn(move || {
514 let result = if star {
515 client.star(&remote_id)
516 } else {
517 client.unstar(&remote_id)
518 };
519 match result {
520 Ok(()) => log::info!("synced favourite to remote: {remote_id} = {star}"),
521 Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
522 }
523 })
524 .ok();
525}
526
527#[derive(Debug, Default)]
529pub struct FullSync {
530 pub library: crate::remote::sync::SyncResult,
531 pub favourites: FavouriteSync,
532 pub playlists: crate::playlists::PlaylistSync,
533}
534
535pub fn sync_remote(
546 db: &Database,
547 client: &SubsonicClient,
548 full: bool,
549 url: &str,
550 username: &str,
551 progress: &(dyn Fn(crate::remote::sync::SyncProgress) + Sync),
552) -> Result<FullSync, crate::remote::sync::SyncError> {
553 let library = crate::remote::sync::sync_library(db, client, full, url, username, progress)?;
554 Ok(FullSync {
555 library,
556 favourites: reconcile_favourites(db, client),
557 playlists: crate::playlists::reconcile_playlists(db, client, username),
558 })
559}
560
561#[derive(Debug, Default, Clone, Copy)]
563pub struct FavouriteSync {
564 pub pushed: usize,
565 pub imported: usize,
566}
567
568pub fn reconcile_favourites(db: &Database, client: &SubsonicClient) -> FavouriteSync {
579 let mut out = FavouriteSync::default();
580
581 let tracks = queries::favourites_with_remote_id(&db.conn).unwrap_or_default();
582 for (_path, remote_id) in &tracks {
583 if client.star(remote_id).is_ok() {
584 out.pushed += 1;
585 }
586 }
587 for (_id, remote_id) in queries::favourite_albums_with_remote_id(&db.conn).unwrap_or_default() {
588 if client.star_album(&remote_id).is_ok() {
589 out.pushed += 1;
590 }
591 }
592 for (_id, remote_id) in queries::favourite_artists_with_remote_id(&db.conn).unwrap_or_default()
593 {
594 if client.star_artist(&remote_id).is_ok() {
595 out.pushed += 1;
596 }
597 }
598
599 let starred = match client.get_starred_all() {
600 Ok(s) => s,
601 Err(e) => {
602 log::warn!("could not fetch starred items from the server: {e}");
603 return out;
604 }
605 };
606
607 let songs: Vec<String> = starred.song.into_iter().map(|s| s.id).collect();
608 let albums: Vec<String> = starred.album.into_iter().map(|a| a.id).collect();
609 let artists: Vec<String> = starred.artist.into_iter().map(|a| a.id).collect();
610 out.imported += queries::import_remote_favourites(&db.conn, &songs).unwrap_or(0);
611 out.imported += queries::import_remote_favourite_albums(&db.conn, &albums).unwrap_or(0);
612 out.imported += queries::import_remote_favourite_artists(&db.conn, &artists).unwrap_or(0);
613 out
614}
615
616#[derive(Debug, Clone, Copy, PartialEq, Eq)]
619pub enum FavouriteKind {
620 Track,
621 Album,
622 Artist,
623}
624
625pub fn sync_collection_favourite_to_remote(
630 db: &Database,
631 kind: FavouriteKind,
632 id: i64,
633 star: bool,
634) {
635 let cfg = Config::load().unwrap_or_default();
636 if !cfg.remote.enabled {
637 return;
638 }
639 let remote_id = match kind {
640 FavouriteKind::Album => queries::album_remote_id(&db.conn, id),
641 FavouriteKind::Artist => queries::artist_remote_id(&db.conn, id),
642 FavouriteKind::Track => return,
643 };
644 let Ok(Some(remote_id)) = remote_id else {
645 log::warn!("not syncing favourite: {kind:?} {id} has no remote id");
646 return;
647 };
648 let Some(client) = subsonic_client(&cfg) else {
649 log::warn!("not syncing favourite: no usable server credentials");
650 return;
651 };
652 std::thread::Builder::new()
653 .name("koan-fav-sync".into())
654 .spawn(move || {
655 let result = match (kind, star) {
656 (FavouriteKind::Album, true) => client.star_album(&remote_id),
657 (FavouriteKind::Album, false) => client.unstar_album(&remote_id),
658 (FavouriteKind::Artist, true) => client.star_artist(&remote_id),
659 (FavouriteKind::Artist, false) => client.unstar_artist(&remote_id),
660 (FavouriteKind::Track, _) => Ok(()),
661 };
662 match result {
663 Ok(()) => log::info!("synced favourite to remote: {kind:?} {remote_id} = {star}"),
664 Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
665 }
666 })
667 .ok();
668}
669
670#[derive(Debug, thiserror::Error)]
672pub enum SignInError {
673 #[error("the server did not accept those credentials: {0}")]
674 Rejected(#[from] crate::remote::client::SubsonicError),
675 #[error("could not write the configuration: {0}")]
676 Config(#[from] crate::config::ConfigError),
677}
678
679pub fn set_remote_credentials(
692 url: &str,
693 username: &str,
694 password: &str,
695) -> Result<(), SignInError> {
696 let url = url.trim_end_matches('/');
697 SubsonicClient::new(url, username, password).ping()?;
698
699 Config::persist(|cfg| {
700 cfg.remote.enabled = true;
701 cfg.remote.url = url.to_string();
702 cfg.remote.username = username.to_string();
703 cfg.remote.password = password.to_string();
704 })?;
705 Ok(())
706}
707
708pub fn get_subsonic_password(cfg: &Config) -> Option<String> {
712 (!cfg.subsonic.password.is_empty()).then(|| cfg.subsonic.password.clone())
713}
714
715pub fn subsonic_auth(cfg: &Config) -> Option<SubsonicAuth> {
722 if !cfg.remote.enabled || cfg.remote.url.is_empty() {
723 return None;
724 }
725 let password = get_remote_password(cfg)?;
726 Some(SubsonicAuth::new(
727 &cfg.remote.url,
728 &cfg.remote.username,
729 &password,
730 ))
731}
732
733pub fn subsonic_client(cfg: &Config) -> Option<Arc<SubsonicClient>> {
745 let auth = subsonic_auth(cfg)?;
746
747 let mut slot = SUBSONIC_CLIENT.lock();
748 if let Some((cached, client)) = slot.as_ref()
749 && *cached == auth
750 {
751 return Some(client.clone());
752 }
753
754 let client = Arc::new(SubsonicClient::from_auth(auth.clone()));
755 *slot = Some((auth, client.clone()));
756 Some(client)
757}
758
759type CachedClient = Option<(SubsonicAuth, Arc<SubsonicClient>)>;
760
761static SUBSONIC_CLIENT: std::sync::LazyLock<parking_lot::Mutex<CachedClient>> =
762 std::sync::LazyLock::new(|| parking_lot::Mutex::new(None));
763
764#[derive(Debug, thiserror::Error)]
773pub enum ShareError {
774 #[error("no remote server is configured")]
775 NoRemote,
776 #[error("sharing.public_url is not set, so there is no address to give out")]
777 NoPublicUrl,
778 #[error("none of these tracks are in the library")]
779 NothingToShare,
780 #[error("none of these tracks are on the server, so a link has nothing to point at")]
781 NothingRemote,
782 #[error("the server refused to share these: {0}")]
783 Server(#[from] crate::remote::client::SubsonicError),
784 #[error(transparent)]
785 Database(#[from] crate::db::connection::DbError),
786}
787
788#[derive(Debug, Clone)]
790pub struct ShareOutcome {
791 pub url: String,
792 pub id: String,
794 pub shared: usize,
796 pub skipped: usize,
798}
799
800#[derive(Debug, Clone, PartialEq, Eq)]
802pub enum ShareTarget {
803 Tracks(Vec<i64>),
805 Album {
807 album_id: i64,
808 start_track_id: Option<i64>,
809 },
810 Artist(i64),
812}
813
814pub fn resolve_share(
820 conn: &rusqlite::Connection,
821 target: &ShareTarget,
822) -> Result<(Slice, Vec<i64>), ShareError> {
823 let album_tracks = |album_id| -> Result<Vec<i64>, ShareError> {
824 Ok(queries::tracks_for_album(conn, album_id)?
825 .into_iter()
826 .map(|t| t.id)
827 .collect())
828 };
829 let (slice, ids) = match target {
830 ShareTarget::Tracks(ids) => {
831 let rows = queries::tracks_by_ids(conn, ids)?;
832 match (ids.as_slice(), rows.first().and_then(|t| t.album_id)) {
833 ([one], Some(album_id)) => {
834 return resolve_share(
835 conn,
836 &ShareTarget::Album {
837 album_id,
838 start_track_id: Some(*one),
839 },
840 );
841 }
842 _ => {
843 let ids = ids
845 .iter()
846 .copied()
847 .filter(|id| rows.iter().any(|t| t.id == *id))
848 .collect();
849 (Slice::TRACKS, ids)
850 }
851 }
852 }
853 ShareTarget::Album {
854 album_id,
855 start_track_id,
856 } => {
857 let ids = album_tracks(*album_id)?;
858 let slice = Slice {
859 kind: ShareKind::Album,
860 subject_id: Some(*album_id),
861 start_track_id: start_track_id.filter(|s| ids.contains(s)),
862 };
863 (slice, ids)
864 }
865 ShareTarget::Artist(artist_id) => {
866 let mut ids = Vec::new();
867 for album in queries::albums_for_artist(conn, *artist_id)? {
868 ids.extend(album_tracks(album.id)?);
869 }
870 let slice = Slice {
871 kind: ShareKind::Artist,
872 subject_id: Some(*artist_id),
873 start_track_id: None,
874 };
875 (slice, ids)
876 }
877 };
878 if ids.is_empty() {
879 return Err(ShareError::NothingToShare);
880 }
881 Ok((slice, ids))
882}
883
884pub fn create_share(
897 db: &Database,
898 cfg: &Config,
899 target: &ShareTarget,
900 description: Option<&str>,
901) -> Result<ShareOutcome, ShareError> {
902 let Some(client) = subsonic_client(cfg) else {
903 return create_native_share(db, cfg, target, description);
904 };
905 let resolved;
908 let track_ids = match target {
909 ShareTarget::Tracks(ids) => ids.as_slice(),
910 _ => {
911 resolved = resolve_share(&db.conn, target)?.1;
912 resolved.as_slice()
913 }
914 };
915
916 let rows = queries::tracks_by_ids(&db.conn, track_ids)?;
918
919 let shared = rows.iter().filter(|t| t.remote_id.is_some()).count();
920 if shared == 0 {
921 return Err(ShareError::NothingRemote);
922 }
923
924 let one_album = rows
928 .first()
929 .and_then(|f| f.album_id)
930 .filter(|first| rows.iter().all(|t| t.album_id == Some(*first)))
931 .and_then(|album_id| album_remote_id(&db.conn, album_id, rows.len()));
932
933 let remote_ids: Vec<String> = match one_album {
934 Some(rid) => vec![rid],
935 None => rows.into_iter().filter_map(|t| t.remote_id).collect(),
936 };
937
938 let refs: Vec<&str> = remote_ids.iter().map(String::as_str).collect();
939 let share = client.create_share(&refs, description)?;
940
941 let url = share
944 .url
945 .clone()
946 .unwrap_or_else(|| format!("{}/s/{}", client.base_url(), share.id));
947
948 Ok(ShareOutcome {
949 url,
950 id: share.id,
951 shared,
952 skipped: track_ids.len().saturating_sub(shared),
953 })
954}
955
956fn create_native_share(
958 db: &Database,
959 cfg: &Config,
960 target: &ShareTarget,
961 description: Option<&str>,
962) -> Result<ShareOutcome, ShareError> {
963 let base = cfg
964 .sharing
965 .public_url
966 .as_deref()
967 .filter(|u| !u.trim().is_empty())
968 .ok_or(ShareError::NoPublicUrl)?;
969 let (slice, ids) = resolve_share(&db.conn, target)?;
970 let now = std::time::SystemTime::now()
971 .duration_since(std::time::UNIX_EPOCH)
972 .map_or(0, |d| d.as_secs() as i64);
973 let share = queries::shares::create_share(&db.conn, slice, &ids, description, now, None)?;
974 Ok(ShareOutcome {
975 url: share_url(base, &share.id),
976 id: share.id,
977 shared: ids.len(),
978 skipped: match (target, slice.kind) {
980 (ShareTarget::Tracks(asked), ShareKind::Tracks) => asked.len() - ids.len(),
981 _ => 0,
982 },
983 })
984}
985
986pub fn share_url(public_url: &str, id: &str) -> String {
988 format!("{}/share/{id}", public_url.trim_end_matches('/'))
989}
990
991fn album_remote_id(conn: &rusqlite::Connection, album_id: i64, selected: usize) -> Option<String> {
995 let (remote_id, total): (Option<String>, i64) = conn
996 .query_row(
997 "SELECT al.remote_id, (SELECT COUNT(*) FROM tracks WHERE album_id = al.id)
998 FROM albums al WHERE al.id = ?1",
999 [album_id],
1000 |row| Ok((row.get(0)?, row.get(1)?)),
1001 )
1002 .ok()?;
1003 (total == selected as i64).then_some(remote_id).flatten()
1004}
1005
1006pub fn shuffle<T>(items: &mut [T]) {
1015 let mut seed = [0u8; 8];
1016 if getrandom::fill(&mut seed).is_err() {
1017 return; }
1019 let mut state = u64::from_le_bytes(seed) | 1;
1020 for i in (1..items.len()).rev() {
1021 state ^= state << 13;
1023 state ^= state >> 7;
1024 state ^= state << 17;
1025 items.swap(i, (state % (i as u64 + 1)) as usize);
1026 }
1027}
1028
1029pub fn truncate_bytes(s: &str, max: usize) -> &str {
1031 if s.len() <= max {
1032 return s;
1033 }
1034 let mut end = max;
1035 while end > 0 && !s.is_char_boundary(end) {
1036 end -= 1;
1037 }
1038 &s[..end]
1039}
1040
1041pub fn sanitise_filename(s: &str) -> String {
1044 let cleaned: String = s
1045 .chars()
1046 .map(|c| match c {
1047 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
1048 _ => c,
1049 })
1050 .collect::<String>()
1051 .trim()
1052 .to_string();
1053
1054 truncate_bytes(&cleaned, 240).trim_end().to_string()
1055}
1056
1057pub fn year_of(date: &str) -> Option<&str> {
1060 date.get(..4)
1061}
1062
1063pub fn cache_path_for_track(
1066 cache_dir: &Path,
1067 track: &queries::TrackRow,
1068 album_date: Option<&str>,
1069) -> PathBuf {
1070 let artist_dir = sanitise_filename(&track.artist_name);
1071
1072 let year = album_date
1073 .and_then(year_of)
1074 .map(|y| format!("({}) ", y))
1075 .unwrap_or_default();
1076 let codec = track
1077 .codec
1078 .as_deref()
1079 .map(|c| format!(" [{}]", c))
1080 .unwrap_or_default();
1081 let album_dir = sanitise_filename(&format!("{}{}{}", year, track.album_title, codec));
1082
1083 let disc_prefix = match track.disc {
1084 Some(d) if d > 1 => format!("{}-", d),
1085 _ => String::new(),
1086 };
1087 let track_num = track
1088 .track_number
1089 .map(|n| format!("{:02}. ", n))
1090 .unwrap_or_default();
1091
1092 let ext = track
1093 .codec
1094 .as_deref()
1095 .map(|c| c.to_lowercase())
1096 .unwrap_or_else(|| "flac".into());
1097
1098 let filename = sanitise_filename(&format!(
1099 "{}{}{} - {}",
1100 disc_prefix, track_num, track.artist_name, track.title
1101 ));
1102
1103 cache_dir
1104 .join(artist_dir)
1105 .join(album_dir)
1106 .join(format!("{}.{}", filename, ext))
1107}
1108
1109pub fn resolve_item_path(
1117 db: &Database,
1118 cfg: &Config,
1119 id: i64,
1120 track: &queries::TrackRow,
1121 album_date: Option<&str>,
1122) -> (PathBuf, ItemState) {
1123 match queries::resolve_playback_path(&db.conn, id) {
1124 Ok(Some(queries::PlaybackSource::Local(p))) => (p, ItemState::Ready),
1125 Ok(Some(queries::PlaybackSource::Cached(p))) => {
1130 let state = if is_cached_audio(&p) {
1131 ItemState::Ready
1132 } else {
1133 ItemState::Pending
1134 };
1135 (p, state)
1136 }
1137 Ok(Some(queries::PlaybackSource::Remote(_))) => {
1138 let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
1139 if dest.exists() && is_cached_audio(&dest) {
1140 (dest, ItemState::Ready)
1141 } else {
1142 (dest, ItemState::Pending)
1143 }
1144 }
1145 _ => {
1146 let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
1148 (dest, ItemState::Pending)
1149 }
1150 }
1151}
1152
1153pub fn playlist_item_from_track(
1155 track: &queries::TrackRow,
1156 album_date: Option<&str>,
1157 dest: PathBuf,
1158 state: ItemState,
1159) -> PlaylistItem {
1160 let year = album_date.and_then(year_of).map(str::to_string);
1161 PlaylistItem {
1162 playlist_entry_id: None,
1163 id: QueueItemId::new(),
1164 db_id: Some(track.id),
1165 path: dest,
1166 title: track.title.clone(),
1167 artist: track.artist_name.clone(),
1168 album_artist: track.album_artist_name.clone(),
1169 album: track.album_title.clone(),
1170 year,
1171 codec: track.codec.clone(),
1172 track_number: track.track_number.map(|n| n as i64),
1173 disc: track.disc.map(|n| n as i64),
1174 duration_ms: track.duration_ms.map(|d| d as u64),
1175 state,
1176 }
1177}
1178
1179pub fn playlist_items_for_tracks(db: &Database, tracks: &[queries::TrackRow]) -> Vec<PlaylistItem> {
1186 use std::collections::HashMap;
1187
1188 let cfg = Config::load().unwrap_or_default();
1189 let mut album_dates: HashMap<i64, Option<String>> = HashMap::new();
1190
1191 tracks
1192 .iter()
1193 .map(|track| {
1194 let album_date = match track.album_id {
1195 Some(aid) => album_dates
1196 .entry(aid)
1197 .or_insert_with(|| queries::album_date(&db.conn, aid).ok().flatten())
1198 .clone(),
1199 None => None,
1200 };
1201 let (path, state) = resolve_item_path(db, &cfg, track.id, track, album_date.as_deref());
1202 playlist_item_from_track(track, album_date.as_deref(), path, state)
1203 })
1204 .collect()
1205}
1206
1207pub fn track_to_playlist_item(track: &queries::TrackRow, db: &Database) -> PlaylistItem {
1209 let album_date = track
1210 .album_id
1211 .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
1212
1213 let cfg = Config::load().unwrap_or_default();
1214 let (path, state) = resolve_item_path(db, &cfg, track.id, track, album_date.as_deref());
1215
1216 let year = album_date.as_deref().and_then(year_of).map(str::to_string);
1217
1218 PlaylistItem {
1219 playlist_entry_id: None,
1220 id: QueueItemId::new(),
1221 db_id: Some(track.id),
1222 path,
1223 title: track.title.clone(),
1224 artist: track.artist_name.clone(),
1225 album_artist: track.album_artist_name.clone(),
1226 album: track.album_title.clone(),
1227 year,
1228 codec: track.codec.clone(),
1229 track_number: track.track_number.map(|n| n as i64),
1230 disc: track.disc.map(|n| n as i64),
1231 duration_ms: track.duration_ms.map(|d| d as u64),
1232 state,
1233 }
1234}
1235
1236fn is_cached_audio(path: &std::path::Path) -> bool {
1246 const MIN_PLAUSIBLE_BYTES: u64 = 4096;
1247 match std::fs::metadata(path) {
1248 Ok(meta) if meta.len() >= MIN_PLAUSIBLE_BYTES => true,
1249 Ok(_) => {
1250 let mut first = [0u8; 1];
1251 match std::fs::File::open(path)
1252 .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut first).map(|_| first[0]))
1253 {
1254 Ok(b) => b != b'{' && b != b'<',
1255 Err(_) => false,
1256 }
1257 }
1258 Err(_) => false,
1259 }
1260}
1261
1262pub fn download_track(
1269 db_id: i64,
1270 queue_id: QueueItemId,
1271 tx: &crossbeam_channel::Sender<PlayerCommand>,
1272 log_buf: &Arc<Mutex<Vec<String>>>,
1273 state: &Arc<SharedPlayerState>,
1274 cfg: &Config,
1275 client: &SubsonicClient,
1276) {
1277 let db = match crate::db::pool::shared().get() {
1282 Ok(db) => db,
1283 Err(e) => {
1284 fail_track(state, tx, queue_id, format!("db error: {}", e));
1285 return;
1286 }
1287 };
1288 let track = match queries::get_track_row(&db.conn, db_id) {
1289 Ok(Some(t)) => t,
1290 _ => {
1291 fail_track(state, tx, queue_id, "track not found".into());
1292 return;
1293 }
1294 };
1295
1296 let remote_id = match &track.remote_id {
1297 Some(rid) => rid.clone(),
1298 None => {
1299 if let Some(ref path) = track.path {
1301 let p = std::path::PathBuf::from(path);
1302 if p.exists() {
1303 state.update_paths(&[(queue_id, p)]);
1304 state.update_item_state(queue_id, ItemState::Ready);
1305 if state.is_cursor(queue_id) {
1306 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1307 }
1308 return;
1309 }
1310 }
1311 fail_track(
1312 state,
1313 tx,
1314 queue_id,
1315 "not in the library folder, and no remote copy to fetch".into(),
1316 );
1317 return;
1318 }
1319 };
1320
1321 if let Some(ref local_path) = track.path {
1323 let p = std::path::PathBuf::from(local_path);
1324 if p.exists() {
1325 log::info!("download_track: local file exists, using {}", p.display());
1326 state.update_paths(&[(queue_id, p)]);
1327 state.update_item_state(queue_id, ItemState::Ready);
1328 if state.is_cursor(queue_id) {
1329 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1330 }
1331 return;
1332 }
1333 }
1334
1335 let album_date: Option<String> = track
1336 .album_id
1337 .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
1338
1339 let dest = cache_path_for_track(&cfg.cache_dir(), &track, album_date.as_deref());
1340
1341 if dest.exists() && !is_cached_audio(&dest) {
1347 log::warn!(
1348 "discarding non-audio cache entry {} (likely a stored server error)",
1349 dest.display()
1350 );
1351 let _ = std::fs::remove_file(&dest);
1352 }
1353 if dest.exists() {
1354 state.update_paths(&[(queue_id, dest)]);
1355 state.update_item_state(queue_id, ItemState::Ready);
1356 if state.is_cursor(queue_id) {
1357 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1358 }
1359 return;
1360 }
1361
1362 state.update_paths(&[(queue_id, crate::remote::download::part_path(&dest))]);
1365
1366 let bytes_written = crate::remote::downloads::ByteFeed::new();
1367
1368 let store = crate::remote::downloads::store();
1371 store.queued(crate::remote::downloads::Download {
1372 id: queue_id,
1373 track_id: db_id,
1374 title: track.title.clone(),
1375 artist: track.artist_name.clone(),
1376 source: crate::remote::download::part_path(&dest),
1377 dest: dest.clone(),
1378 total: 0,
1379 written: bytes_written.clone(),
1380 state: crate::remote::downloads::DownloadState::Queued,
1381 bytes_per_second: 0,
1382 });
1383
1384 let progress_qid = queue_id;
1385 let bytes_written_progress = bytes_written.clone();
1386 let progress_tx = tx.clone();
1387 let stream_ready_sent = Arc::new(std::sync::atomic::AtomicBool::new(false));
1388 let stream_ready_flag = stream_ready_sent.clone();
1389 let announced_total = AtomicU64::new(u64::MAX);
1391 let result = client.download_with_progress(&remote_id, &dest, move |downloaded, total| {
1392 bytes_written_progress.set(downloaded);
1393 store.progressed();
1396 if announced_total.swap(total, Ordering::Relaxed) != total {
1397 store.started(progress_qid, total, bytes_written_progress.clone());
1400 }
1401 if !stream_ready_flag.load(Ordering::Relaxed)
1402 && downloaded >= crate::player::state::STREAM_THRESHOLD
1403 {
1404 stream_ready_flag.store(true, Ordering::Relaxed);
1405 progress_tx
1406 .send(PlayerCommand::TrackStreamReady(progress_qid))
1407 .ok();
1408 }
1409 });
1410
1411 if let Err(e) = result {
1416 store.failed(queue_id, e.to_string());
1417 fail_track(state, tx, queue_id, e.to_string());
1418 bytes_written.done();
1419 push_log(log_buf, format!("x {} — {}", track.title, e));
1420 return;
1421 }
1422 store.finished(queue_id);
1423
1424 state.update_paths(&[(queue_id, dest.clone())]);
1425 state.update_item_state(queue_id, ItemState::Ready);
1426 bytes_written.done();
1427 if let Err(e) = queries::set_cached_path(&db.conn, db_id, &dest.to_string_lossy()) {
1429 log::warn!(
1430 "cached {} but failed to record it ({}) — it will not be evicted",
1431 dest.display(),
1432 e
1433 );
1434 }
1435
1436 push_log(
1437 log_buf,
1438 format!("+ {} — {}", track.title, track.artist_name),
1439 );
1440
1441 if state.is_cursor(queue_id) {
1442 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1443 }
1444}
1445
1446pub(crate) fn fail_track(
1452 state: &Arc<SharedPlayerState>,
1453 tx: &crossbeam_channel::Sender<PlayerCommand>,
1454 queue_id: QueueItemId,
1455 reason: String,
1456) {
1457 state.update_item_state(queue_id, ItemState::Failed(reason));
1458 if state.is_cursor(queue_id) {
1459 tx.send(PlayerCommand::TrackFailed(queue_id)).ok();
1460 }
1461}
1462
1463fn push_log(log_buf: &Arc<Mutex<Vec<String>>>, msg: String) {
1466 match log_buf.lock() {
1467 Ok(mut buf) => buf.push(msg),
1468 Err(_) => log::info!("{}", msg),
1469 }
1470}
1471
1472pub fn remote_unavailable(cfg: &Config) -> String {
1478 if !cfg.remote.enabled {
1479 return "no remote server is configured".into();
1480 }
1481 if cfg.remote.url.is_empty() {
1482 return "the remote server has no address".into();
1483 }
1484 if get_remote_password(cfg).is_none() {
1485 return "no password is stored for the remote server".into();
1486 }
1487 "the remote server could not be reached".into()
1490}
1491
1492pub fn spawn_downloads(
1502 pending: Vec<(i64, QueueItemId)>,
1503 tx: crossbeam_channel::Sender<PlayerCommand>,
1504 state: Arc<SharedPlayerState>,
1505) {
1506 if pending.is_empty() {
1507 return;
1508 }
1509 crate::remote::queue::shared(&tx, &state, None).enqueue(pending);
1510}
1511
1512#[cfg(test)]
1513mod year_tests {
1514 use super::year_of;
1515
1516 #[test]
1517 fn a_year_is_the_first_four_characters_when_they_are_bytes_too() {
1518 assert_eq!(year_of("1997-05-21"), Some("1997"));
1519 assert_eq!(year_of("199"), None);
1520 assert_eq!(year_of("1997"), None);
1522 }
1523}
1524
1525#[cfg(test)]
1526mod rebuild_tests {
1527 use super::*;
1528 use crate::db::queries::sample_meta;
1529
1530 fn test_db() -> Database {
1531 let conn = rusqlite::Connection::open_in_memory().unwrap();
1532 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1533 crate::db::schema::create_tables(&conn).unwrap();
1534 Database { conn }
1535 }
1536
1537 #[test]
1538 fn clearing_one_download_leaves_the_others_and_the_library_alone() {
1539 let dir = tempfile::tempdir().unwrap();
1540 let db = test_db();
1541
1542 let mut cached = Vec::new();
1543 for name in ["one", "two"] {
1544 let mut meta = sample_meta(name, "Artist", "Album");
1545 meta.source = "remote".into();
1546 meta.path = None;
1547 meta.remote_id = Some(name.into());
1548 let id = queries::upsert_track(&db.conn, &meta).unwrap();
1549 let file = dir.path().join(format!("{name}.opus"));
1550 std::fs::write(&file, vec![0u8; 2048]).unwrap();
1551 queries::set_cached_path(&db.conn, id, &file.to_string_lossy()).unwrap();
1552 cached.push((id, file));
1553 }
1554
1555 let cleared = clear_downloads_for(&db, &[cached[0].0]);
1556 assert_eq!(cleared.files, 1);
1557 assert_eq!(cleared.bytes, 2048);
1558 assert!(!cached[0].1.exists(), "the copy asked for is gone");
1559 assert!(cached[1].1.exists(), "the other one is untouched");
1560
1561 assert_eq!(queries::library_stats(&db.conn).unwrap().remote_tracks, 2);
1564 assert_eq!(queries::library_stats(&db.conn).unwrap().cached_tracks, 1);
1565 assert!(
1566 queries::cached_paths_for(&db.conn, &[cached[0].0])
1567 .unwrap()
1568 .is_empty()
1569 );
1570 }
1571
1572 #[test]
1573 fn clearing_a_download_that_is_already_gone_is_not_a_failure() {
1574 let db = test_db();
1575 let mut meta = sample_meta("ghost", "Artist", "Album");
1576 meta.source = "remote".into();
1577 meta.path = None;
1578 meta.remote_id = Some("ghost".into());
1579 let id = queries::upsert_track(&db.conn, &meta).unwrap();
1580 queries::set_cached_path(&db.conn, id, "/nowhere/at/all.opus").unwrap();
1581
1582 let cleared = clear_downloads_for(&db, &[id]);
1583 assert_eq!(cleared.files, 0, "nothing was there to remove");
1584 assert!(
1586 queries::cached_paths_for(&db.conn, &[id])
1587 .unwrap()
1588 .is_empty()
1589 );
1590 }
1591
1592 #[test]
1593 fn sweeping_removes_half_finished_downloads_and_nothing_else() {
1594 let dir = tempfile::tempdir().unwrap();
1595 let cache = dir.path().join("cache");
1596 std::fs::create_dir_all(cache.join("Artist")).unwrap();
1597
1598 let finished = cache.join("Artist/whole.opus");
1599 let half = cache.join("Artist/half.opus.part");
1600 std::fs::write(&finished, vec![0u8; 1024]).unwrap();
1601 std::fs::write(&half, vec![0u8; 4096]).unwrap();
1602
1603 let cfg = Config {
1604 remote: crate::config::RemoteConfig {
1605 cache_dir: Some(cache.clone()),
1606 ..Default::default()
1607 },
1608 ..Default::default()
1609 };
1610
1611 let swept = sweep_partial_downloads(&cfg);
1612 assert_eq!(swept.files, 1);
1613 assert_eq!(swept.bytes, 4096);
1614 assert!(!half.exists(), "the unfinished one is gone");
1615 assert!(finished.exists(), "a downloaded track is not touched");
1616 }
1617
1618 #[test]
1619 fn sweeping_an_empty_cache_is_not_an_error() {
1620 let dir = tempfile::tempdir().unwrap();
1621 let cfg = Config {
1622 remote: crate::config::RemoteConfig {
1623 cache_dir: Some(dir.path().join("nothing-here")),
1624 ..Default::default()
1625 },
1626 ..Default::default()
1627 };
1628 assert_eq!(sweep_partial_downloads(&cfg).files, 0);
1629 }
1630
1631 #[test]
1632 fn clearing_no_tracks_does_nothing() {
1633 let db = test_db();
1634 assert_eq!(clear_downloads_for(&db, &[]).files, 0);
1635 }
1636
1637 #[test]
1638 fn rebuild_drops_the_index_and_keeps_favourites() {
1639 let db = test_db();
1640 let mut meta = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
1641 meta.path = Some("/music/windowlicker.flac".into());
1642 let track_id = queries::upsert_track(&db.conn, &meta).unwrap();
1643
1644 queries::toggle_favourite(&db.conn, Path::new("/music/windowlicker.flac")).unwrap();
1646 db.conn
1647 .execute(
1648 "INSERT INTO lyrics_cache (track_id, source, content, fetched_at)
1649 VALUES (?1, 'test', 'la la la', 0)",
1650 [track_id],
1651 )
1652 .unwrap();
1653
1654 let summary = rebuild_index(&db).unwrap();
1655 assert_eq!(summary.tracks, 1);
1656 assert_eq!(summary.albums, 1);
1657
1658 let tracks: i64 = db
1659 .conn
1660 .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
1661 .unwrap();
1662 assert_eq!(tracks, 0, "the index is gone");
1663
1664 let favourites: i64 = db
1665 .conn
1666 .query_row("SELECT COUNT(*) FROM favourites", [], |r| r.get(0))
1667 .unwrap();
1668 assert_eq!(favourites, 1, "favourites survive — they key on the path");
1669
1670 let lyrics: i64 = db
1671 .conn
1672 .query_row("SELECT COUNT(*) FROM lyrics_cache", [], |r| r.get(0))
1673 .unwrap();
1674 assert_eq!(lyrics, 0, "anything keyed on a track id cannot survive");
1675 }
1676
1677 #[test]
1678 fn rebuilding_an_empty_library_is_not_an_error() {
1679 let db = test_db();
1680 let summary = rebuild_index(&db).unwrap();
1681 assert_eq!(summary.tracks, 0);
1682 }
1683}
1684
1685#[cfg(test)]
1686mod share_tests {
1687 use super::*;
1688 use crate::db::queries::sample_meta;
1689
1690 fn test_db() -> Database {
1691 let conn = rusqlite::Connection::open_in_memory().unwrap();
1692 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1693 crate::db::schema::create_tables(&conn).unwrap();
1694 Database { conn }
1695 }
1696
1697 fn album_of_three(db: &Database) -> (i64, Vec<i64>) {
1699 let ids: Vec<i64> = ["One", "Two", "Three"]
1700 .iter()
1701 .enumerate()
1702 .map(|(i, title)| {
1703 let mut meta = sample_meta(title, "Boards of Canada", "Geogaddi");
1704 meta.path = Some(format!("/music/geogaddi/{i}.flac"));
1705 meta.track_number = Some(i as i32 + 1);
1706 queries::upsert_track(&db.conn, &meta).unwrap()
1707 })
1708 .collect();
1709 let album_id: i64 = db
1710 .conn
1711 .query_row("SELECT album_id FROM tracks WHERE id = ?1", [ids[0]], |r| {
1712 r.get(0)
1713 })
1714 .unwrap();
1715 db.conn
1716 .execute(
1717 "UPDATE albums SET remote_id = 'al-1' WHERE id = ?1",
1718 [album_id],
1719 )
1720 .unwrap();
1721 (album_id, ids)
1722 }
1723
1724 #[test]
1725 fn whole_album_collapses_to_the_album_link() {
1726 let db = test_db();
1727 let (album_id, ids) = album_of_three(&db);
1728 assert_eq!(
1729 album_remote_id(&db.conn, album_id, ids.len()),
1730 Some("al-1".into())
1731 );
1732 }
1733
1734 #[test]
1735 fn part_of_an_album_does_not() {
1736 let db = test_db();
1737 let (album_id, _) = album_of_three(&db);
1738 assert_eq!(album_remote_id(&db.conn, album_id, 2), None);
1741 }
1742
1743 #[test]
1744 fn a_local_only_album_has_no_link_to_collapse_to() {
1745 let db = test_db();
1746 let (album_id, ids) = album_of_three(&db);
1747 db.conn
1748 .execute(
1749 "UPDATE albums SET remote_id = NULL WHERE id = ?1",
1750 [album_id],
1751 )
1752 .unwrap();
1753 assert_eq!(album_remote_id(&db.conn, album_id, ids.len()), None);
1754 }
1755}
1756
1757#[cfg(test)]
1758mod client_cache_tests {
1759 use super::*;
1760
1761 #[test]
1762 fn one_subsonic_client_is_shared_per_credentials() {
1763 crate::config::isolate_config_for_tests();
1764 let mut cfg = Config::default();
1765 cfg.remote.enabled = true;
1766 cfg.remote.url = "https://shared-client.invalid".into();
1767 cfg.remote.username = "koan".into();
1768 cfg.remote.password = "first".into();
1769
1770 let first = subsonic_client(&cfg).expect("a configured remote yields a client");
1771 let again = subsonic_client(&cfg).expect("a configured remote yields a client");
1772 assert!(
1773 Arc::ptr_eq(&first, &again),
1774 "rebuilding drops the connection pool and re-handshakes TLS per request"
1775 );
1776
1777 cfg.remote.password = "second".into();
1778 let relogged = subsonic_client(&cfg).expect("a configured remote yields a client");
1779 assert!(
1780 !Arc::ptr_eq(&first, &relogged),
1781 "new credentials must not keep serving the client signed with the old ones"
1782 );
1783 }
1784}
1785
1786#[cfg(test)]
1787mod native_share_tests {
1788 use super::*;
1789 use crate::db::queries::{sample_meta, upsert_track};
1790
1791 #[test]
1792 fn a_standalone_server_shares_natively_in_the_order_asked() {
1793 let conn = rusqlite::Connection::open_in_memory().unwrap();
1794 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1795 crate::db::schema::create_tables(&conn).unwrap();
1796 let db = Database { conn };
1797 let a = upsert_track(&db.conn, &sample_meta("A", "X", "Y")).unwrap();
1798 let b = upsert_track(&db.conn, &sample_meta("B", "X", "Y")).unwrap();
1799 let mut cfg = Config::default();
1800 assert!(matches!(
1801 create_share(&db, &cfg, &ShareTarget::Tracks(vec![a]), None),
1802 Err(ShareError::NoPublicUrl)
1803 ));
1804 cfg.sharing.public_url = Some("https://koan.example/".into());
1805 let out = create_share(
1806 &db,
1807 &cfg,
1808 &ShareTarget::Tracks(vec![b, 9999, a]),
1809 Some("mix"),
1810 )
1811 .unwrap();
1812 assert_eq!(out.url, format!("https://koan.example/share/{}", out.id));
1813 assert_eq!((out.shared, out.skipped), (2, 1));
1814 let share = queries::shares::get_share(&db.conn, &out.id)
1815 .unwrap()
1816 .unwrap();
1817 assert_eq!(share.track_ids, [b, a]);
1818 assert!(matches!(
1819 create_share(&db, &cfg, &ShareTarget::Tracks(vec![9999]), None),
1820 Err(ShareError::NothingToShare)
1821 ));
1822 }
1823
1824 fn album_track(db: &Database, title: &str, album: &str, n: i32, date: &str) -> i64 {
1825 let mut meta = sample_meta(title, "Rrose", album);
1826 meta.track_number = Some(n);
1827 meta.date = Some(date.into());
1828 upsert_track(&db.conn, &meta).unwrap()
1829 }
1830
1831 #[test]
1832 fn shares_are_slices_fixed_when_made() {
1833 let conn = rusqlite::Connection::open_in_memory().unwrap();
1834 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1835 crate::db::schema::create_tables(&conn).unwrap();
1836 let db = Database { conn };
1837 let later = album_track(&db, "L1", "Later", 1, "2021");
1838 let a1 = album_track(&db, "E1", "Earlier", 1, "2015");
1839 let a2 = album_track(&db, "E2", "Earlier", 2, "2015");
1840 let album_of = |t| {
1841 queries::tracks_by_ids(&db.conn, &[t]).unwrap()[0]
1842 .album_id
1843 .unwrap()
1844 };
1845 let (earlier, later_album) = (album_of(a1), album_of(later));
1846 let artist = queries::tracks_by_ids(&db.conn, &[a1]).unwrap()[0]
1847 .artist_id
1848 .unwrap();
1849
1850 let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Tracks(vec![a2])).unwrap();
1852 assert_eq!(
1853 (slice.kind, slice.subject_id, slice.start_track_id),
1854 (ShareKind::Album, Some(earlier), Some(a2))
1855 );
1856 assert_eq!(ids, [a1, a2]);
1857
1858 let (slice, ids) = resolve_share(
1860 &db.conn,
1861 &ShareTarget::Album {
1862 album_id: later_album,
1863 start_track_id: Some(a1),
1864 },
1865 )
1866 .unwrap();
1867 assert_eq!((slice.kind, slice.start_track_id), (ShareKind::Album, None));
1868 assert_eq!(ids, [later]);
1869
1870 let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Artist(artist)).unwrap();
1872 assert_eq!(
1873 (slice.kind, slice.subject_id),
1874 (ShareKind::Artist, Some(artist))
1875 );
1876 assert_eq!(ids, [a1, a2, later]);
1877
1878 let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Tracks(vec![later, a1])).unwrap();
1880 assert_eq!(slice, Slice::TRACKS);
1881 assert_eq!(ids, [later, a1]);
1882
1883 assert!(matches!(
1884 resolve_share(&db.conn, &ShareTarget::Artist(9999)),
1885 Err(ShareError::NothingToShare)
1886 ));
1887 }
1888}