1use super::*;
4use crate::json::Json;
5use crate::store::StoreError;
6
7pub const PLAYLIST_LIMIT: usize = 512;
9pub const PLAYLIST_OWNER_LIMIT: usize = 128;
11pub const PLAYLIST_TRACK_LIMIT: usize = 2000;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Playlist {
20 pub id: String,
22 pub owner: UserRef,
24 pub name: String,
26 pub comment: Option<String>,
28 pub tracks: Vec<EntityRef>,
30 pub created_at: u64,
32 pub updated_at: u64,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Scrobble {
44 pub owner: UserRef,
46 pub track: EntityRef,
48 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 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 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 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 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 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 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;