1use crate::endpoints::playlists::{
2 add_items_to_playlist, change_playlist_details, create_playlist, follow_playlist,
3 get_current_user_playlists, get_playlist, get_playlist_cover_image, get_playlist_items,
4 get_users_playlists, remove_items_from_playlist, unfollow_playlist, update_playlist_items,
5};
6use crate::endpoints::user::get_current_user;
7use crate::io::output::{ErrorKind, Response};
8use crate::storage::pins::{PinStore, ResourceType};
9use std::collections::HashSet;
10
11use super::{extract_id, now_playing, with_client};
12
13fn resolve_playlist_id(input: &str) -> Result<String, Response> {
16 if let Ok(store) = PinStore::new()
17 && let Some(pin) = store.find_by_alias(input)
18 && pin.resource_type == ResourceType::Playlist
19 {
20 return Ok(pin.id.clone());
21 }
22 Ok(extract_id(input))
23}
24
25async fn resolve_playlist_id_async(
28 client: &crate::http::api::SpotifyApi,
29 input: &str,
30) -> Result<String, Response> {
31 if let Ok(store) = PinStore::new()
33 && let Some(pin) = store.find_by_alias(input)
34 && pin.resource_type == ResourceType::Playlist
35 {
36 return Ok(pin.id.clone());
37 }
38
39 let extracted = extract_id(input);
41 if !input.contains(' ') && extracted.chars().all(|c| c.is_alphanumeric()) {
42 return Ok(extracted);
43 }
44
45 let mut offset = 0u32;
47 let limit = 50u8;
48 let search_name = input.to_lowercase();
49
50 loop {
51 match get_current_user_playlists::get_current_user_playlists(
52 client,
53 Some(limit),
54 Some(offset),
55 )
56 .await
57 {
58 Ok(Some(page)) => {
59 if let Some(items) = page.get("items").and_then(|i| i.as_array()) {
60 for playlist in items {
62 if let Some(name) = playlist.get("name").and_then(|n| n.as_str())
63 && name.to_lowercase() == search_name
64 && let Some(id) = playlist.get("id").and_then(|i| i.as_str())
65 {
66 return Ok(id.to_string());
67 }
68 }
69
70 let total = page.get("total").and_then(|t| t.as_u64()).unwrap_or(0);
72 offset += limit as u32;
73 if offset >= total as u32 || items.is_empty() {
74 break;
75 }
76 } else {
77 break;
78 }
79 }
80 Ok(None) => break,
81 Err(e) => return Err(Response::from_http_error(&e, "Failed to search playlists")),
82 }
83 }
84
85 Err(Response::err(
86 404,
87 format!(
88 "Playlist '{}' not found. Use playlist ID, URL, or exact name.",
89 input
90 ),
91 ErrorKind::NotFound,
92 ))
93}
94
95pub async fn playlist_list(limit: u8, offset: u32) -> Response {
96 with_client(|client| async move {
97 match get_current_user_playlists::get_current_user_playlists(
98 &client,
99 Some(limit),
100 Some(offset),
101 )
102 .await
103 {
104 Ok(Some(payload)) => Response::success_with_payload(200, "Your playlists", payload),
105 Ok(None) => Response::success_with_payload(
106 200,
107 "No playlists",
108 serde_json::json!({ "items": [] }),
109 ),
110 Err(e) => Response::from_http_error(&e, "Failed to get playlists"),
111 }
112 })
113 .await
114}
115
116pub async fn playlist_get(playlist: &str) -> Response {
117 let playlist_id = match resolve_playlist_id(playlist) {
118 Ok(id) => id,
119 Err(e) => return e,
120 };
121
122 with_client(|client| async move {
123 match get_playlist::get_playlist(&client, &playlist_id).await {
124 Ok(Some(payload)) => Response::success_with_payload(200, "Playlist details", payload),
125 Ok(None) => Response::err(404, "Playlist not found", ErrorKind::NotFound),
126 Err(e) => Response::from_http_error(&e, "Failed to get playlist"),
127 }
128 })
129 .await
130}
131
132pub async fn playlist_create(name: &str, description: Option<&str>, public: bool) -> Response {
133 let name = name.to_string();
134 let description = description.map(|s| s.to_string());
135
136 with_client(|client| async move {
137 let user_id = match get_current_user::get_current_user(&client).await {
138 Ok(Some(user)) => match user.get("id").and_then(|v| v.as_str()) {
139 Some(id) => id.to_string(),
140 None => return Response::err(500, "Could not get user ID", ErrorKind::Api),
141 },
142 Ok(None) => return Response::err(500, "Could not get user info", ErrorKind::Api),
143 Err(e) => return Response::from_http_error(&e, "Failed to get user info"),
144 };
145
146 match create_playlist::create_playlist(
147 &client,
148 &user_id,
149 &name,
150 description.as_deref(),
151 public,
152 )
153 .await
154 {
155 Ok(Some(payload)) => Response::success_with_payload(201, "Playlist created", payload),
156 Ok(None) => Response::err(500, "Failed to create playlist", ErrorKind::Api),
157 Err(e) => Response::from_http_error(&e, "Failed to create playlist"),
158 }
159 })
160 .await
161}
162
163pub async fn playlist_add(
164 playlist: &str,
165 uris: &[String],
166 now_playing_flag: bool,
167 position: Option<u32>,
168 dry_run: bool,
169) -> Response {
170 if uris.is_empty() && !now_playing_flag {
171 return Response::err(
172 400,
173 "Provide track URIs or use --now-playing",
174 ErrorKind::Validation,
175 );
176 }
177
178 let playlist_id = match resolve_playlist_id(playlist) {
179 Ok(id) => id,
180 Err(e) => return e,
181 };
182 let explicit_uris = uris.to_vec();
183
184 with_client(|client| async move {
185 let mut all_uris = explicit_uris;
186
187 if now_playing_flag {
188 match now_playing::get_track_uri(&client).await {
189 Ok(uri) => all_uris.push(uri),
190 Err(e) => return e,
191 }
192 }
193
194 let uri_count = all_uris.len();
195
196 if dry_run {
197 return Response::success_with_payload(
198 200,
199 format!(
200 "[DRY RUN] Would add {} track(s) to playlist {}",
201 uri_count, playlist_id
202 ),
203 serde_json::json!({
204 "dry_run": true,
205 "action": "add",
206 "playlist_id": playlist_id,
207 "uris": all_uris,
208 "position": position
209 }),
210 );
211 }
212
213 match add_items_to_playlist::add_items_to_playlist(
214 &client,
215 &playlist_id,
216 &all_uris,
217 position,
218 )
219 .await
220 {
221 Ok(Some(payload)) => Response::success_with_payload(
222 201,
223 format!("Added {} track(s)", uri_count),
224 payload,
225 ),
226 Ok(None) => Response::success(201, format!("Added {} track(s)", uri_count)),
227 Err(e) => Response::from_http_error(&e, "Failed to add tracks"),
228 }
229 })
230 .await
231}
232
233pub async fn playlist_remove(playlist: &str, uris: &[String], dry_run: bool) -> Response {
234 let playlist_id = match resolve_playlist_id(playlist) {
235 Ok(id) => id,
236 Err(e) => return e,
237 };
238 let uris = uris.to_vec();
239 let uri_count = uris.len();
240
241 if dry_run {
242 return Response::success_with_payload(
243 200,
244 format!(
245 "[DRY RUN] Would remove {} track(s) from playlist {}",
246 uri_count, playlist_id
247 ),
248 serde_json::json!({
249 "dry_run": true,
250 "action": "remove",
251 "playlist_id": playlist_id,
252 "uris": uris
253 }),
254 );
255 }
256
257 with_client(|client| async move {
258 match remove_items_from_playlist::remove_items_from_playlist(&client, &playlist_id, &uris)
259 .await
260 {
261 Ok(Some(payload)) => Response::success_with_payload(
262 200,
263 format!("Removed {} track(s)", uri_count),
264 payload,
265 ),
266 Ok(None) => Response::success(200, format!("Removed {} track(s)", uri_count)),
267 Err(e) => Response::from_http_error(&e, "Failed to remove tracks"),
268 }
269 })
270 .await
271}
272
273pub async fn playlist_edit(
274 playlist: &str,
275 name: Option<&str>,
276 description: Option<&str>,
277 public: Option<bool>,
278) -> Response {
279 if name.is_none() && description.is_none() && public.is_none() {
280 return Response::err(
281 400,
282 "No changes specified. Use --name, --description, --public, or --private",
283 ErrorKind::Validation,
284 );
285 }
286
287 let playlist_id = match resolve_playlist_id(playlist) {
288 Ok(id) => id,
289 Err(e) => return e,
290 };
291 let name = name.map(|s| s.to_string());
292 let description = description.map(|s| s.to_string());
293
294 with_client(|client| async move {
295 match change_playlist_details::change_playlist_details(
296 &client,
297 &playlist_id,
298 name.as_deref(),
299 description.as_deref(),
300 public,
301 )
302 .await
303 {
304 Ok(_) => Response::success(200, "Playlist updated"),
305 Err(e) => Response::from_http_error(&e, "Failed to update playlist"),
306 }
307 })
308 .await
309}
310
311pub async fn playlist_reorder(playlist: &str, from: u32, to: u32, count: u32) -> Response {
312 let playlist_id = match resolve_playlist_id(playlist) {
313 Ok(id) => id,
314 Err(e) => return e,
315 };
316
317 with_client(|client| async move {
318 match update_playlist_items::reorder_playlist_items(
319 &client,
320 &playlist_id,
321 from,
322 to,
323 Some(count),
324 )
325 .await
326 {
327 Ok(_) => Response::success(
328 200,
329 format!("Moved {} track(s) from position {} to {}", count, from, to),
330 ),
331 Err(e) => Response::from_http_error(&e, "Failed to reorder tracks"),
332 }
333 })
334 .await
335}
336
337pub async fn playlist_follow(playlist: &str, public: bool) -> Response {
338 let playlist_id = extract_id(playlist);
339
340 with_client(|client| async move {
341 match follow_playlist::follow_playlist(&client, &playlist_id, Some(public)).await {
342 Ok(_) => Response::success(200, "Following playlist"),
343 Err(e) => Response::from_http_error(&e, "Failed to follow playlist"),
344 }
345 })
346 .await
347}
348
349pub async fn playlist_unfollow(playlist: &str) -> Response {
350 let playlist_id = extract_id(playlist);
351
352 with_client(|client| async move {
353 match unfollow_playlist::unfollow_playlist(&client, &playlist_id).await {
354 Ok(_) => Response::success(200, "Unfollowed playlist"),
355 Err(e) => Response::from_http_error(&e, "Failed to unfollow playlist"),
356 }
357 })
358 .await
359}
360
361pub async fn playlist_duplicate(playlist: &str, new_name: Option<&str>) -> Response {
362 let playlist_id = match resolve_playlist_id(playlist) {
363 Ok(id) => id,
364 Err(e) => return e,
365 };
366 let new_name = new_name.map(|s| s.to_string());
367
368 with_client(|client| async move {
369 let source = match get_playlist::get_playlist(&client, &playlist_id).await {
371 Ok(Some(p)) => p,
372 Ok(None) => return Response::err(404, "Playlist not found", ErrorKind::NotFound),
373 Err(e) => return Response::from_http_error(&e, "Failed to get playlist"),
374 };
375
376 let source_name = source
378 .get("name")
379 .and_then(|v| v.as_str())
380 .unwrap_or("Playlist");
381 let default_name = format!("{} (Copy)", source_name);
382 let name = new_name.as_deref().unwrap_or(&default_name);
383 let description = source.get("description").and_then(|v| v.as_str());
384
385 let user = match get_current_user::get_current_user(&client).await {
387 Ok(Some(u)) => u,
388 Ok(None) => return Response::err(500, "Failed to get user info", ErrorKind::Api),
389 Err(e) => return Response::from_http_error(&e, "Failed to get user info"),
390 };
391 let user_id = match user.get("id").and_then(|v| v.as_str()) {
392 Some(id) => id,
393 None => return Response::err(500, "Failed to get user ID", ErrorKind::Api),
394 };
395
396 let new_playlist = match create_playlist::create_playlist(
398 &client,
399 user_id,
400 name,
401 description,
402 false,
403 )
404 .await
405 {
406 Ok(Some(p)) => p,
407 Ok(None) => return Response::err(500, "Failed to create playlist", ErrorKind::Api),
408 Err(e) => return Response::from_http_error(&e, "Failed to create playlist"),
409 };
410
411 let new_playlist_id = match new_playlist.get("id").and_then(|v| v.as_str()) {
412 Some(id) => id,
413 None => return Response::err(500, "Failed to get new playlist ID", ErrorKind::Api),
414 };
415
416 if let Some(tracks) = source
418 .get("tracks")
419 .and_then(|t| t.get("items"))
420 .and_then(|i| i.as_array())
421 {
422 let uris: Vec<String> = tracks
423 .iter()
424 .filter_map(|item| {
425 item.get("track")
426 .and_then(|t| t.get("uri"))
427 .and_then(|u| u.as_str())
428 .map(|s| s.to_string())
429 })
430 .collect();
431
432 if !uris.is_empty()
433 && let Err(e) = add_items_to_playlist::add_items_to_playlist(
434 &client,
435 new_playlist_id,
436 &uris,
437 None,
438 )
439 .await
440 {
441 return Response::from_http_error(&e, "Created playlist but failed to copy tracks");
442 }
443 }
444
445 Response::success_with_payload(
446 200,
447 format!("Duplicated playlist as '{}'", name),
448 new_playlist,
449 )
450 })
451 .await
452}
453
454pub async fn playlist_cover(playlist: &str) -> Response {
456 let playlist_id = match resolve_playlist_id(playlist) {
457 Ok(id) => id,
458 Err(e) => return e,
459 };
460
461 with_client(|client| async move {
462 match get_playlist_cover_image::get_playlist_cover_image(&client, &playlist_id).await {
463 Ok(Some(payload)) => {
464 Response::success_with_payload(200, "Playlist cover image", payload)
465 }
466 Ok(None) => Response::err(404, "No cover image found", ErrorKind::NotFound),
467 Err(e) => Response::from_http_error(&e, "Failed to get playlist cover"),
468 }
469 })
470 .await
471}
472
473pub async fn playlist_user(user_id: &str) -> Response {
475 let user_id = user_id.to_string();
476
477 with_client(|client| async move {
478 match get_users_playlists::get_users_playlists(&client, &user_id).await {
479 Ok(Some(payload)) => Response::success_with_payload(
480 200,
481 format!("Playlists for user {}", user_id),
482 payload,
483 ),
484 Ok(None) => Response::success_with_payload(
485 200,
486 "No playlists found",
487 serde_json::json!({ "items": [] }),
488 ),
489 Err(e) => Response::from_http_error(&e, "Failed to get user's playlists"),
490 }
491 })
492 .await
493}
494
495pub async fn playlist_deduplicate(playlist: &str, dry_run: bool) -> Response {
497 let playlist_input = playlist.to_string();
498
499 with_client(|client| async move {
500 let playlist_id = match resolve_playlist_id_async(&client, &playlist_input).await {
502 Ok(id) => id,
503 Err(e) => return e,
504 };
505
506 let mut all_tracks: Vec<serde_json::Value> = Vec::new();
508 let mut offset = 0u32;
509 let limit = 50u8;
510
511 loop {
512 match get_playlist_items::get_playlist_items(
513 &client,
514 &playlist_id,
515 Some(limit),
516 Some(offset),
517 )
518 .await
519 {
520 Ok(Some(page)) => {
521 let items = page.get("items").and_then(|i| i.as_array());
522 match items {
523 Some(tracks) if !tracks.is_empty() => {
524 all_tracks.extend(tracks.iter().cloned());
525 let total = page.get("total").and_then(|t| t.as_u64()).unwrap_or(0);
526 offset += limit as u32;
527 if offset >= total as u32 {
528 break;
529 }
530 }
531 _ => break,
532 }
533 }
534 Ok(None) => break,
535 Err(e) => return Response::from_http_error(&e, "Failed to fetch playlist tracks"),
536 }
537 }
538
539 if all_tracks.is_empty() {
540 return Response::success(200, "Playlist is empty, nothing to deduplicate");
541 }
542
543 let mut seen_ids: HashSet<String> = HashSet::new();
545 let mut unique_uris: Vec<String> = Vec::new();
546 let mut duplicate_names: Vec<String> = Vec::new();
547
548 for item in all_tracks.iter() {
549 if let Some(track) = item.get("track") {
550 let id = track.get("id").and_then(|i| i.as_str()).unwrap_or("");
551 let uri = track.get("uri").and_then(|u| u.as_str()).unwrap_or("");
552 let name = track
553 .get("name")
554 .and_then(|n| n.as_str())
555 .unwrap_or("Unknown");
556
557 if !id.is_empty() && !uri.is_empty() {
558 if seen_ids.contains(id) {
559 duplicate_names.push(name.to_string());
560 } else {
561 seen_ids.insert(id.to_string());
562 unique_uris.push(uri.to_string());
563 }
564 }
565 }
566 }
567
568 if duplicate_names.is_empty() {
569 return Response::success(200, "No duplicates found");
570 }
571
572 let duplicate_count = duplicate_names.len();
573
574 if dry_run {
575 return Response::success_with_payload(
576 200,
577 format!(
578 "[DRY RUN] Would remove {} duplicate(s) from playlist",
579 duplicate_count
580 ),
581 serde_json::json!({
582 "dry_run": true,
583 "action": "deduplicate",
584 "playlist_id": playlist_id,
585 "duplicate_count": duplicate_count,
586 "duplicates": duplicate_names,
587 "unique_count": unique_uris.len()
588 }),
589 );
590 }
591
592 let all_uris: Vec<String> = all_tracks
597 .iter()
598 .filter_map(|item| {
599 item.get("track")
600 .and_then(|t| t.get("uri"))
601 .and_then(|u| u.as_str())
602 .map(String::from)
603 })
604 .collect();
605
606 if let Err(e) =
608 remove_items_from_playlist::remove_items_from_playlist(&client, &playlist_id, &all_uris)
609 .await
610 {
611 return Response::from_http_error(&e, "Failed to clear playlist");
612 }
613
614 if !unique_uris.is_empty()
616 && let Err(e) = add_items_to_playlist::add_items_to_playlist(
617 &client,
618 &playlist_id,
619 &unique_uris,
620 None,
621 )
622 .await
623 {
624 return Response::from_http_error(&e, "Failed to restore unique tracks");
625 }
626
627 Response::success_with_payload(
628 200,
629 format!(
630 "Removed {} duplicate(s), {} unique track(s) remain",
631 duplicate_count,
632 unique_uris.len()
633 ),
634 serde_json::json!({
635 "removed_count": duplicate_count,
636 "removed": duplicate_names,
637 "remaining_count": unique_uris.len()
638 }),
639 )
640 })
641 .await
642}