aede_core/
user_playlists.rs

1//! Ordered private playlists and client-declared listens in the shared user store.
2
3use super::*;
4use crate::json::Json;
5use crate::store::StoreError;
6
7/// Maximum static playlists in one personal store.
8pub const PLAYLIST_LIMIT: usize = 512;
9/// Maximum static playlists belonging to one owner.
10pub const PLAYLIST_OWNER_LIMIT: usize = 128;
11/// Maximum ordered entries in one static playlist, including repetitions.
12pub const PLAYLIST_TRACK_LIMIT: usize = 2000;
13
14/// One owner's ordered, private selection, independent of catalog identifiers.
15///
16/// Repeated tracks and references to temporarily unavailable files are retained.
17/// There is no sharing flag: selecting another owner never grants access.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Playlist {
20    /// Opaque persistent identifier, generated independently of its contents.
21    pub id: String,
22    /// Immutable personal owner, not a mutable login name.
23    pub owner: UserRef,
24    /// Human-readable name, 1–256 UTF-8 bytes containing non-whitespace.
25    pub name: String,
26    /// Optional description, at most 2048 UTF-8 bytes.
27    pub comment: Option<String>,
28    /// Stable track references in playback order; repetitions are meaningful.
29    pub tracks: Vec<EntityRef>,
30    /// Unix seconds when the playlist was created.
31    pub created_at: u64,
32    /// Unix seconds when its content or description last changed.
33    pub updated_at: u64,
34}
35
36/// A client declares that a track was listened to, without playback evidence.
37///
38/// Unlike [`Play`], this does not claim a duration, completion, decoded audio,
39/// or acknowledgement. Subsonic scrobble submissions supply only the track and
40/// a timestamp. Each accepted submission increments the all-time counter;
41/// repeating a request can therefore double-count a listen.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Scrobble {
44    /// Immutable owner whose client submitted the declaration.
45    pub owner: UserRef,
46    /// Stable reference to the declared track.
47    pub track: EntityRef,
48    /// Unix milliseconds supplied by the client, or receipt time when omitted.
49    pub at_ms: u64,
50}
51
52const MAX_DATE: u64 = 253_402_300_799;
53
54fn text_valid(value: &str, limit: usize, nonblank: bool) -> bool {
55    value.len() <= limit && !value.contains('\0') && (!nonblank || !value.trim().is_empty())
56}
57
58fn track_valid(track: &EntityRef) -> bool {
59    track.kind == EntityKind::Track && text_valid(&track.key, 16384, true)
60}
61
62fn playlist_valid(playlist: &Playlist) -> bool {
63    playlist.id.strip_prefix("playlist-").is_some_and(|id| {
64        id.len() == 64
65            && id
66                .bytes()
67                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
68    }) && text_valid(&playlist.owner, 512, true)
69        && text_valid(&playlist.name, 256, true)
70        && playlist
71            .comment
72            .as_deref()
73            .is_none_or(|text| text_valid(text, 2048, false))
74        && playlist.tracks.len() <= PLAYLIST_TRACK_LIMIT
75        && playlist.tracks.iter().all(track_valid)
76        && playlist.created_at <= playlist.updated_at
77        && playlist.updated_at <= MAX_DATE
78}
79
80pub(super) fn validate(data: &UserData) -> Result<(), StoreError> {
81    let mut owners = BTreeMap::new();
82    let mut ids = BTreeSet::new();
83    if data.playlists.len() > PLAYLIST_LIMIT || data.scrobbles.len() > HISTORY_STORE_LIMIT {
84        return Err(StoreError::Invalid(
85            "personal playlist or scrobble limit exceeded",
86        ));
87    }
88    history::validate(data)?;
89    for playlist in &data.playlists {
90        let count = owners.entry(&playlist.owner).or_insert(0usize);
91        *count += 1;
92        if *count > PLAYLIST_OWNER_LIMIT || !ids.insert(&playlist.id) || !playlist_valid(playlist) {
93            return Err(StoreError::Invalid(
94                "invalid, duplicate or oversized personal playlist",
95            ));
96        }
97    }
98    if data.scrobbles.iter().any(|event| {
99        !text_valid(&event.owner, 512, true)
100            || !track_valid(&event.track)
101            || event.at_ms > MAX_DATE * 1000 + 999
102    }) {
103        return Err(StoreError::Invalid("invalid personal scrobble"));
104    }
105    Ok(())
106}
107
108impl UserData {
109    /// Finds a playlist only within the supplied owner's private namespace.
110    pub fn playlist(&self, owner: &str, id: &str) -> Option<&Playlist> {
111        self.playlists
112            .iter()
113            .find(|playlist| playlist.owner == owner && playlist.id == id)
114    }
115
116    /// Creates a bounded static playlist and returns its persistent random ID.
117    ///
118    /// Invalid data or a reached store/owner limit fails before any mutation.
119    pub fn create_playlist(
120        &mut self,
121        owner: &str,
122        name: &str,
123        tracks: Vec<EntityRef>,
124        now: u64,
125    ) -> Result<String, String> {
126        if self.playlists.len() >= PLAYLIST_LIMIT
127            || self
128                .playlists
129                .iter()
130                .filter(|playlist| playlist.owner == owner)
131                .count()
132                >= PLAYLIST_OWNER_LIMIT
133        {
134            return Err("playlist limit reached".into());
135        }
136        let id = format!("playlist-{}", crate::accounts::random_token()?);
137        let playlist = Playlist {
138            id: id.clone(),
139            owner: owner.into(),
140            name: name.into(),
141            comment: None,
142            tracks,
143            created_at: now,
144            updated_at: now,
145        };
146        if !playlist_valid(&playlist) || self.playlists.iter().any(|known| known.id == id) {
147            return Err("invalid playlist or identifier collision".into());
148        }
149        self.playlists.push(playlist);
150        Ok(id)
151    }
152
153    /// Updates only the supplied fields of an owner's playlist atomically.
154    ///
155    /// `Some("")` clears the comment; `None` preserves it. A missing owner/ID
156    /// pair and invalid replacement data fail without changing the playlist.
157    pub fn update_playlist(
158        &mut self,
159        owner: &str,
160        id: &str,
161        name: Option<&str>,
162        comment: Option<&str>,
163        tracks: Option<Vec<EntityRef>>,
164        now: u64,
165    ) -> Result<(), String> {
166        let existing = self
167            .playlists
168            .iter_mut()
169            .find(|playlist| playlist.owner == owner && playlist.id == id)
170            .ok_or_else(|| "playlist not found".to_string())?;
171        let mut replacement = existing.clone();
172        if let Some(name) = name {
173            replacement.name = name.into();
174        }
175        if let Some(comment) = comment {
176            replacement.comment = (!comment.is_empty()).then(|| comment.into());
177        }
178        if let Some(tracks) = tracks {
179            replacement.tracks = tracks;
180        }
181        replacement.updated_at = now;
182        if !playlist_valid(&replacement) {
183            return Err("invalid playlist replacement".into());
184        }
185        *existing = replacement;
186        Ok(())
187    }
188
189    /// Deliberately removes one owner's playlist, leaving all music intact.
190    pub fn delete_playlist(&mut self, owner: &str, id: &str) -> bool {
191        let before = self.playlists.len();
192        self.playlists
193            .retain(|playlist| playlist.owner != owner || playlist.id != id);
194        self.playlists.len() != before
195    }
196
197    /// Records a declared listen and updates its count without fabricating a [`Play`].
198    ///
199    /// Recent declarations retain their multiplicity, ordered by client time,
200    /// and are bounded by [`HISTORY_LIMIT`]. Counts outlive that recent log.
201    pub fn record_scrobble(&mut self, event: Scrobble) -> Result<(), String> {
202        if !text_valid(&event.owner, 512, true)
203            || !track_valid(&event.track)
204            || event.at_ms > MAX_DATE * 1000 + 999
205        {
206            return Err("invalid scrobble declaration".into());
207        }
208        let at = event.at_ms / 1000;
209        match self
210            .counts
211            .iter_mut()
212            .find(|count| count.owner == event.owner && count.track == event.track)
213        {
214            Some(count) => {
215                count.count = count.count.saturating_add(1);
216                count.last_played = count.last_played.max(at);
217            }
218            None => self.counts.push(PlayCount {
219                owner: event.owner.clone(),
220                track: event.track.clone(),
221                count: 1,
222                completed_count: 0,
223                last_played: at,
224            }),
225        }
226        self.scrobbles.push(event);
227        self.scrobbles.sort_by_key(|event| event.at_ms);
228        history::retain(&mut self.scrobbles, |event| &event.owner);
229        Ok(())
230    }
231}
232
233pub(super) fn write_tables(data: &UserData, root: &mut Json) {
234    if !data.playlists.is_empty() {
235        root.set(
236            "playlists",
237            Json::Arr(
238                data.playlists
239                    .iter()
240                    .map(|playlist| {
241                        let mut row = Json::obj();
242                        row.set("id", playlist.id.as_str().into());
243                        row.set("owner", playlist.owner.as_str().into());
244                        row.set("name", playlist.name.as_str().into());
245                        if let Some(comment) = &playlist.comment {
246                            row.set("comment", comment.as_str().into());
247                        }
248                        row.set(
249                            "tracks",
250                            Json::Arr(
251                                playlist
252                                    .tracks
253                                    .iter()
254                                    .map(|track| track.to_token().into())
255                                    .collect(),
256                            ),
257                        );
258                        row.set("created_at", playlist.created_at.into());
259                        row.set("updated_at", playlist.updated_at.into());
260                        row
261                    })
262                    .collect(),
263            ),
264        );
265    }
266    if !data.scrobbles.is_empty() {
267        root.set(
268            "scrobbles",
269            Json::Arr(
270                data.scrobbles
271                    .iter()
272                    .map(|event| {
273                        let mut row = Json::obj();
274                        row.set("owner", event.owner.as_str().into());
275                        row.set("track", event.track.to_token().into());
276                        row.set("at_ms", event.at_ms.into());
277                        row
278                    })
279                    .collect(),
280            ),
281        );
282    }
283}
284
285fn required_string(row: &Json, name: &str) -> Result<String, StoreError> {
286    row.field_str(name).ok_or(StoreError::Invalid(
287        "missing or malformed private playlist/scrobble field",
288    ))
289}
290
291pub(super) fn read_playlists(root: &Json) -> Result<Vec<Playlist>, StoreError> {
292    let rows = root.get("playlists").and_then(Json::as_arr).unwrap_or(&[]);
293    if rows.len() > PLAYLIST_LIMIT {
294        return Err(StoreError::Invalid("personal playlist limit exceeded"));
295    }
296    let mut data = UserData::default();
297    for row in rows {
298        let tracks = row
299            .get("tracks")
300            .and_then(Json::as_arr)
301            .filter(|tracks| tracks.len() <= PLAYLIST_TRACK_LIMIT)
302            .ok_or(StoreError::Invalid("invalid personal playlist entries"))?
303            .iter()
304            .map(|track| {
305                track
306                    .as_str()
307                    .and_then(EntityRef::parse_token)
308                    .ok_or(StoreError::Invalid("invalid personal playlist track"))
309            })
310            .collect::<Result<Vec<_>, _>>()?;
311        let comment = match row.get("comment") {
312            None => None,
313            Some(Json::Str(text)) => Some(text.clone()),
314            _ => return Err(StoreError::Invalid("invalid personal playlist comment")),
315        };
316        data.playlists.push(Playlist {
317            id: required_string(row, "id")?,
318            owner: required_string(row, "owner")?,
319            name: required_string(row, "name")?,
320            comment,
321            tracks,
322            created_at: row
323                .field_u64("created_at")
324                .ok_or(StoreError::Invalid("invalid playlist creation time"))?,
325            updated_at: row
326                .field_u64("updated_at")
327                .ok_or(StoreError::Invalid("invalid playlist update time"))?,
328        });
329    }
330    validate(&data)?;
331    Ok(data.playlists)
332}
333
334pub(super) fn read_scrobbles(root: &Json) -> Result<Vec<Scrobble>, StoreError> {
335    let rows = root.get("scrobbles").and_then(Json::as_arr).unwrap_or(&[]);
336    if rows.len() > HISTORY_STORE_LIMIT {
337        return Err(StoreError::Invalid("personal scrobble limit exceeded"));
338    }
339    let mut data = UserData::default();
340    for row in rows {
341        data.scrobbles.push(Scrobble {
342            owner: required_string(row, "owner")?,
343            track: EntityRef::parse_token(&required_string(row, "track")?)
344                .ok_or(StoreError::Invalid("invalid scrobble track"))?,
345            at_ms: row
346                .field_u64("at_ms")
347                .ok_or(StoreError::Invalid("invalid scrobble timestamp"))?,
348        });
349    }
350    validate(&data)?;
351    data.scrobbles.sort_by_key(|event| event.at_ms);
352    Ok(data.scrobbles)
353}
354
355pub(super) fn merge(
356    into: &mut UserData,
357    playlists: Vec<Playlist>,
358    scrobbles: Vec<Scrobble>,
359    report: &mut Merge,
360) {
361    for playlist in playlists {
362        match into
363            .playlists
364            .iter_mut()
365            .find(|known| known.owner == playlist.owner && known.id == playlist.id)
366        {
367            Some(known) if known.updated_at >= playlist.updated_at => report.kept += 1,
368            Some(known) => {
369                *known = playlist;
370                report.updated += 1;
371            }
372            None => {
373                into.playlists.push(playlist);
374                report.added += 1;
375            }
376        }
377    }
378    let mut available = BTreeMap::new();
379    for event in &into.scrobbles {
380        *available
381            .entry((&event.owner, &event.track, event.at_ms))
382            .or_insert(0usize) += 1;
383    }
384    // Own the keys before appending, since each submitted copy may be distinct.
385    let mut available: BTreeMap<_, _> = available
386        .into_iter()
387        .map(|((owner, track, at), count)| ((owner.clone(), track.clone(), at), count))
388        .collect();
389    for event in scrobbles {
390        let copies = available
391            .entry((event.owner.clone(), event.track.clone(), event.at_ms))
392            .or_default();
393        if *copies > 0 {
394            *copies -= 1;
395        } else {
396            into.scrobbles.push(event);
397            report.plays += 1;
398        }
399    }
400    into.scrobbles.sort_by_key(|event| event.at_ms);
401    history::retain(&mut into.scrobbles, |event| &event.owner);
402}
403
404#[cfg(test)]
405#[path = "user_playlists_tests.rs"]
406mod tests;