Skip to main content

koan_core/helpers/
shares.rs

1//! Share links to tracks, albums and playlists.
2
3use crate::config::Config;
4use crate::db::connection::Database;
5use crate::db::queries;
6use crate::db::queries::shares::{ShareKind, Slice};
7
8use super::*;
9
10/// Why a share link could not be made. Each variant is something the user can
11/// act on.
12#[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/// A created share link, and how much of the request it covers.
27#[derive(Debug, Clone)]
28pub struct ShareOutcome {
29    pub url: String,
30    /// The server's own ID for the share, for callers that manage them.
31    pub id: String,
32    /// Tracks the server knows about, which went into the link.
33    pub shared: usize,
34    /// Tracks with no copy on the server, left out of it.
35    pub skipped: usize,
36}
37
38/// What a share link is asked to cover.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum ShareTarget {
41    /// Loose tracks. A single track shares its album, cued to that track.
42    Tracks(Vec<i64>),
43    /// An album, optionally cued to one of its tracks.
44    Album {
45        album_id: i64,
46        start_track_id: Option<i64>,
47    },
48    /// An artist's albums, in release order.
49    Artist(i64),
50}
51
52/// The slice a target makes and the tracks it covers, in play order. Only
53/// tracks in the library are included; the list is fixed from here on.
54///
55/// A single track becomes its album cued to it: a song is heard in the
56/// record it belongs to, the way the app shows it.
57pub 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                    // The order asked for, which is the order the page plays them in.
82                    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
122/// Create a public share link for a slice of the library.
123///
124/// With a remote Subsonic server configured, the link is made there: a laptop
125/// or phone shares through the server it plays from, which may be another
126/// koan. Without one this koan is the server, and makes the link itself.
127///
128/// A link points at the server, so only tracks the server knows about can go in
129/// it. A mixed selection shares the part that can be shared and reports the
130/// rest rather than failing whole — half a link beats none, as long as the
131/// caller says which half.
132///
133/// `user` is who is sharing, recorded on a link this koan makes itself.
134///
135/// May be network-bound. Callers keep it off whatever thread draws.
136pub 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    // A remote server makes its own kind of link from what it is given, so it
147    // is given exactly what was picked.
148    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    // One query, not one per track: sharing an artist is thousands of tracks.
158    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    // A whole record shares as one album rather than as N tracks — the server
166    // renders it as the album it is, and the link survives the user adding to
167    // it. Only when the selection is the whole album.
168    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    // Navidrome does not always hand back a URL, and a share with no link is
183    // useless to the caller — the ID is enough to build it.
184    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
197/// A share this koan serves at `{sharing.public_url}/share/{id}`.
198///
199/// What a server's own surfaces make whatever `[remote]` says: a link made
200/// upstream would belong to the upstream's account, not the koan user who
201/// asked, and could not be listed or revoked here.
202pub 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        // Only loose tracks are named one by one, so only they can be missing.
225        skipped: match (target, slice.kind) {
226            (ShareTarget::Tracks(asked), ShareKind::Tracks) => asked.len() - ids.len(),
227            _ => 0,
228        },
229    })
230}
231
232/// A native share's public address.
233pub fn share_url(public_url: &str, id: &str) -> String {
234    format!("{}/share/{id}", public_url.trim_end_matches('/'))
235}
236
237/// An album's id as `createShare` should be given it.
238///
239/// koan numbers albums and songs separately, publishes album ids bare, and
240/// reads a bare id in `createShare` as a song, so album 5 would share song 5.
241/// Its `al-` prefix says which is meant. Other servers get the id as they
242/// issued it: some also number albums, and would not know the prefix.
243fn 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
256/// The album's own remote ID, but only when `selected` covers every track on
257/// it. Sharing an album link for half an album would hand out more than the
258/// user picked.
259pub(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    /// Three tracks on one album; the album carries a remote ID.
288    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        // Sharing an album link for two of three tracks would hand out a track
329        // the user did not pick.
330        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        // Local-only tracks, which the upstream path refuses as NothingRemote.
401        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        // One track: its album, cued to it.
455        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        // An album, with a cue that is not on it dropped.
463        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        // An artist: every album, in release order.
475        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        // Several tracks stay a list, in the order given.
483        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        // Already prefixed, or not koan's numbering: left as issued.
502        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}