1use crate::config::Config;
4use crate::db::connection::Database;
5use crate::db::queries;
6use crate::db::queries::shares::{ShareKind, Slice};
7
8use super::*;
9
10#[derive(Debug, thiserror::Error)]
13pub enum ShareError {
14 #[error("sharing.public_url is not set, so there is no address to give out")]
15 NoPublicUrl,
16 #[error("none of these tracks are in the library")]
17 NothingToShare,
18 #[error("none of these tracks are on the server, so a link has nothing to point at")]
19 NothingRemote,
20 #[error("the server refused to share these: {0}")]
21 Server(#[from] crate::remote::client::SubsonicError),
22 #[error(transparent)]
23 Database(#[from] crate::db::connection::DbError),
24}
25
26#[derive(Debug, Clone)]
28pub struct ShareOutcome {
29 pub url: String,
30 pub id: String,
32 pub shared: usize,
34 pub skipped: usize,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum ShareTarget {
41 Tracks(Vec<i64>),
43 Album {
45 album_id: i64,
46 start_track_id: Option<i64>,
47 },
48 Artist(i64),
50}
51
52pub fn resolve_share(
58 conn: &rusqlite::Connection,
59 target: &ShareTarget,
60) -> Result<(Slice, Vec<i64>), ShareError> {
61 let album_tracks = |album_id| -> Result<Vec<i64>, ShareError> {
62 Ok(queries::tracks_for_album(conn, album_id)?
63 .into_iter()
64 .map(|t| t.id)
65 .collect())
66 };
67 let (slice, ids) = match target {
68 ShareTarget::Tracks(ids) => {
69 let rows = queries::tracks_by_ids(conn, ids)?;
70 match (ids.as_slice(), rows.first().and_then(|t| t.album_id)) {
71 ([one], Some(album_id)) => {
72 return resolve_share(
73 conn,
74 &ShareTarget::Album {
75 album_id,
76 start_track_id: Some(*one),
77 },
78 );
79 }
80 _ => {
81 let ids = ids
83 .iter()
84 .copied()
85 .filter(|id| rows.iter().any(|t| t.id == *id))
86 .collect();
87 (Slice::TRACKS, ids)
88 }
89 }
90 }
91 ShareTarget::Album {
92 album_id,
93 start_track_id,
94 } => {
95 let ids = album_tracks(*album_id)?;
96 let slice = Slice {
97 kind: ShareKind::Album,
98 subject_id: Some(*album_id),
99 start_track_id: start_track_id.filter(|s| ids.contains(s)),
100 };
101 (slice, ids)
102 }
103 ShareTarget::Artist(artist_id) => {
104 let mut ids = Vec::new();
105 for album in queries::albums_for_artist(conn, *artist_id)? {
106 ids.extend(album_tracks(album.id)?);
107 }
108 let slice = Slice {
109 kind: ShareKind::Artist,
110 subject_id: Some(*artist_id),
111 start_track_id: None,
112 };
113 (slice, ids)
114 }
115 };
116 if ids.is_empty() {
117 return Err(ShareError::NothingToShare);
118 }
119 Ok((slice, ids))
120}
121
122pub fn create_share(
137 db: &Database,
138 user: i64,
139 cfg: &Config,
140 target: &ShareTarget,
141 description: Option<&str>,
142) -> Result<ShareOutcome, ShareError> {
143 let Some(client) = subsonic_client(cfg) else {
144 return create_native_share(db, user, cfg, target, description);
145 };
146 let resolved;
149 let track_ids = match target {
150 ShareTarget::Tracks(ids) => ids.as_slice(),
151 _ => {
152 resolved = resolve_share(&db.conn, target)?.1;
153 resolved.as_slice()
154 }
155 };
156
157 let rows = queries::tracks_by_ids(&db.conn, track_ids)?;
159
160 let shared = rows.iter().filter(|t| t.remote_id.is_some()).count();
161 if shared == 0 {
162 return Err(ShareError::NothingRemote);
163 }
164
165 let one_album = rows
169 .first()
170 .and_then(|f| f.album_id)
171 .filter(|first| rows.iter().all(|t| t.album_id == Some(*first)))
172 .and_then(|album_id| album_remote_id(&db.conn, album_id, rows.len()));
173
174 let remote_ids: Vec<String> = match one_album {
175 Some(rid) => vec![album_share_id(&client, rid)],
176 None => rows.into_iter().filter_map(|t| t.remote_id).collect(),
177 };
178
179 let refs: Vec<&str> = remote_ids.iter().map(String::as_str).collect();
180 let share = client.create_share(&refs, description)?;
181
182 let url = share
185 .url
186 .clone()
187 .unwrap_or_else(|| format!("{}/s/{}", client.base_url(), share.id));
188
189 Ok(ShareOutcome {
190 url,
191 id: share.id,
192 shared,
193 skipped: track_ids.len().saturating_sub(shared),
194 })
195}
196
197pub fn create_native_share(
203 db: &Database,
204 user: i64,
205 cfg: &Config,
206 target: &ShareTarget,
207 description: Option<&str>,
208) -> Result<ShareOutcome, ShareError> {
209 let base = cfg
210 .sharing
211 .public_url
212 .as_deref()
213 .filter(|u| !u.trim().is_empty())
214 .ok_or(ShareError::NoPublicUrl)?;
215 let (slice, ids) = resolve_share(&db.conn, target)?;
216 let now = std::time::SystemTime::now()
217 .duration_since(std::time::UNIX_EPOCH)
218 .map_or(0, |d| d.as_secs() as i64);
219 let share = queries::shares::create_share(&db.conn, user, slice, &ids, description, now, None)?;
220 Ok(ShareOutcome {
221 url: share_url(base, &share.id),
222 id: share.id,
223 shared: ids.len(),
224 skipped: match (target, slice.kind) {
226 (ShareTarget::Tracks(asked), ShareKind::Tracks) => asked.len() - ids.len(),
227 _ => 0,
228 },
229 })
230}
231
232pub fn share_url(public_url: &str, id: &str) -> String {
234 format!("{}/share/{id}", public_url.trim_end_matches('/'))
235}
236
237fn album_share_id(client: &crate::remote::client::SubsonicClient, remote_id: String) -> String {
244 let koan = crate::remote::profile::is_koan(client.auth());
245 album_share_id_for(koan, remote_id)
246}
247
248fn album_share_id_for(koan: bool, remote_id: String) -> String {
249 if koan && remote_id.parse::<i64>().is_ok() {
250 format!("al-{remote_id}")
251 } else {
252 remote_id
253 }
254}
255
256pub(super) fn album_remote_id(
260 conn: &rusqlite::Connection,
261 album_id: i64,
262 selected: usize,
263) -> Option<String> {
264 let (remote_id, total): (Option<String>, i64) = conn
265 .query_row(
266 "SELECT al.remote_id, (SELECT COUNT(*) FROM tracks WHERE album_id = al.id)
267 FROM albums al WHERE al.id = ?1",
268 [album_id],
269 |row| Ok((row.get(0)?, row.get(1)?)),
270 )
271 .ok()?;
272 (total == selected as i64).then_some(remote_id).flatten()
273}
274
275#[cfg(test)]
276mod share_tests {
277 use super::*;
278 use crate::db::queries::sample_meta;
279
280 fn test_db() -> Database {
281 let conn = rusqlite::Connection::open_in_memory().unwrap();
282 conn.pragma_update(None, "foreign_keys", "on").unwrap();
283 crate::db::schema::create_tables(&conn).unwrap();
284 Database { conn }
285 }
286
287 fn album_of_three(db: &Database) -> (i64, Vec<i64>) {
289 let ids: Vec<i64> = ["One", "Two", "Three"]
290 .iter()
291 .enumerate()
292 .map(|(i, title)| {
293 let mut meta = sample_meta(title, "Boards of Canada", "Geogaddi");
294 meta.path = Some(format!("/music/geogaddi/{i}.flac"));
295 meta.track_number = Some(i as i32 + 1);
296 queries::upsert_track(&db.conn, &meta).unwrap()
297 })
298 .collect();
299 let album_id: i64 = db
300 .conn
301 .query_row("SELECT album_id FROM tracks WHERE id = ?1", [ids[0]], |r| {
302 r.get(0)
303 })
304 .unwrap();
305 db.conn
306 .execute(
307 "UPDATE albums SET remote_id = 'al-1' WHERE id = ?1",
308 [album_id],
309 )
310 .unwrap();
311 (album_id, ids)
312 }
313
314 #[test]
315 fn whole_album_collapses_to_the_album_link() {
316 let db = test_db();
317 let (album_id, ids) = album_of_three(&db);
318 assert_eq!(
319 album_remote_id(&db.conn, album_id, ids.len()),
320 Some("al-1".into())
321 );
322 }
323
324 #[test]
325 fn part_of_an_album_does_not() {
326 let db = test_db();
327 let (album_id, _) = album_of_three(&db);
328 assert_eq!(album_remote_id(&db.conn, album_id, 2), None);
331 }
332
333 #[test]
334 fn a_local_only_album_has_no_link_to_collapse_to() {
335 let db = test_db();
336 let (album_id, ids) = album_of_three(&db);
337 db.conn
338 .execute(
339 "UPDATE albums SET remote_id = NULL WHERE id = ?1",
340 [album_id],
341 )
342 .unwrap();
343 assert_eq!(album_remote_id(&db.conn, album_id, ids.len()), None);
344 }
345}
346
347#[cfg(test)]
348mod native_share_tests {
349 use super::*;
350 use crate::db::queries::{sample_meta, upsert_track};
351
352 #[test]
353 fn a_standalone_server_shares_natively_in_the_order_asked() {
354 let conn = rusqlite::Connection::open_in_memory().unwrap();
355 conn.pragma_update(None, "foreign_keys", "on").unwrap();
356 crate::db::schema::create_tables(&conn).unwrap();
357 let db = Database { conn };
358 let a = upsert_track(&db.conn, &sample_meta("A", "X", "Y")).unwrap();
359 let b = upsert_track(&db.conn, &sample_meta("B", "X", "Y")).unwrap();
360 let mut cfg = Config::default();
361 assert!(matches!(
362 create_share(
363 &db,
364 queries::LOCAL_USER,
365 &cfg,
366 &ShareTarget::Tracks(vec![a]),
367 None
368 ),
369 Err(ShareError::NoPublicUrl)
370 ));
371 cfg.sharing.public_url = Some("https://koan.example/".into());
372 let out = create_share(
373 &db,
374 queries::LOCAL_USER,
375 &cfg,
376 &ShareTarget::Tracks(vec![b, 9999, a]),
377 Some("mix"),
378 )
379 .unwrap();
380 assert_eq!(out.url, format!("https://koan.example/share/{}", out.id));
381 assert_eq!((out.shared, out.skipped), (2, 1));
382 let share = queries::shares::get_share(&db.conn, &out.id)
383 .unwrap()
384 .unwrap();
385 assert_eq!(share.track_ids, [b, a]);
386 assert!(matches!(
387 create_share(
388 &db,
389 queries::LOCAL_USER,
390 &cfg,
391 &ShareTarget::Tracks(vec![9999]),
392 None
393 ),
394 Err(ShareError::NothingToShare)
395 ));
396 }
397
398 #[test]
399 fn a_server_with_an_upstream_still_shares_natively() {
400 let conn = rusqlite::Connection::open_in_memory().unwrap();
402 conn.pragma_update(None, "foreign_keys", "on").unwrap();
403 crate::db::schema::create_tables(&conn).unwrap();
404 let db = Database { conn };
405 let a = upsert_track(&db.conn, &sample_meta("A", "X", "Y")).unwrap();
406 let mut cfg = Config::default();
407 cfg.remote.enabled = true;
408 cfg.remote.url = "https://upstream.invalid".into();
409 cfg.remote.username = "someone".into();
410 cfg.remote.password = "secret".into();
411 cfg.sharing.public_url = Some("https://koan.example".into());
412 let out = create_native_share(
413 &db,
414 queries::LOCAL_USER,
415 &cfg,
416 &ShareTarget::Tracks(vec![a]),
417 None,
418 )
419 .unwrap();
420 assert_eq!(out.url, format!("https://koan.example/share/{}", out.id));
421 assert!(
422 queries::shares::get_share(&db.conn, &out.id)
423 .unwrap()
424 .is_some()
425 );
426 }
427
428 fn album_track(db: &Database, title: &str, album: &str, n: i32, date: &str) -> i64 {
429 let mut meta = sample_meta(title, "Rrose", album);
430 meta.track_number = Some(n);
431 meta.date = Some(date.into());
432 upsert_track(&db.conn, &meta).unwrap()
433 }
434
435 #[test]
436 fn shares_are_slices_fixed_when_made() {
437 let conn = rusqlite::Connection::open_in_memory().unwrap();
438 conn.pragma_update(None, "foreign_keys", "on").unwrap();
439 crate::db::schema::create_tables(&conn).unwrap();
440 let db = Database { conn };
441 let later = album_track(&db, "L1", "Later", 1, "2021");
442 let a1 = album_track(&db, "E1", "Earlier", 1, "2015");
443 let a2 = album_track(&db, "E2", "Earlier", 2, "2015");
444 let album_of = |t| {
445 queries::tracks_by_ids(&db.conn, &[t]).unwrap()[0]
446 .album_id
447 .unwrap()
448 };
449 let (earlier, later_album) = (album_of(a1), album_of(later));
450 let artist = queries::tracks_by_ids(&db.conn, &[a1]).unwrap()[0]
451 .artist_id
452 .unwrap();
453
454 let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Tracks(vec![a2])).unwrap();
456 assert_eq!(
457 (slice.kind, slice.subject_id, slice.start_track_id),
458 (ShareKind::Album, Some(earlier), Some(a2))
459 );
460 assert_eq!(ids, [a1, a2]);
461
462 let (slice, ids) = resolve_share(
464 &db.conn,
465 &ShareTarget::Album {
466 album_id: later_album,
467 start_track_id: Some(a1),
468 },
469 )
470 .unwrap();
471 assert_eq!((slice.kind, slice.start_track_id), (ShareKind::Album, None));
472 assert_eq!(ids, [later]);
473
474 let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Artist(artist)).unwrap();
476 assert_eq!(
477 (slice.kind, slice.subject_id),
478 (ShareKind::Artist, Some(artist))
479 );
480 assert_eq!(ids, [a1, a2, later]);
481
482 let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Tracks(vec![later, a1])).unwrap();
484 assert_eq!(slice, Slice::TRACKS);
485 assert_eq!(ids, [later, a1]);
486
487 assert!(matches!(
488 resolve_share(&db.conn, &ShareTarget::Artist(9999)),
489 Err(ShareError::NothingToShare)
490 ));
491 }
492}
493
494#[cfg(test)]
495mod album_share_id_tests {
496 use super::album_share_id_for;
497
498 #[test]
499 fn a_koan_album_is_named_as_an_album() {
500 assert_eq!(album_share_id_for(true, "46215".into()), "al-46215");
501 assert_eq!(album_share_id_for(true, "al-7".into()), "al-7");
503 assert_eq!(album_share_id_for(false, "46215".into()), "46215");
504 assert_eq!(album_share_id_for(false, "3xJ9kQ2pZ".into()), "3xJ9kQ2pZ");
505 }
506}