# SoundCloud Playlist Support

This document covers the implementation of SoundCloud playlist support in Droppy.

## Overview

SoundCloud playlists (sets) can now be added to the queue by pasting a playlist URL. All tracks are queued with proper metadata, and audio is extracted on-demand when each track plays.

## Architecture

```
┌─────────────────┐     ┌──────────────────┐     ┌─────────────────┐
│  Paste Playlist │────►│  Extract Metadata │────►│  Queue All      │
│  URL            │     │  (oEmbed API)     │     │  Tracks         │
└─────────────────┘     └──────────────────┘     └─────────────────┘
                                                          │
                                                          ▼
┌─────────────────┐     ┌──────────────────┐     ┌─────────────────┐
│  Play Audio     │◄────│  Download Audio   │◄────│  Click Track    │
│  from Cache     │     │  (yt-dlp)         │     │  to Play        │
└─────────────────┘     └──────────────────┘     └─────────────────┘
```

## Files Modified

### Server-Side

#### `server/services/soundcloudExtractor.ts`

**New Functions:**
- `isSoundCloudPlaylist(url)` - Detects if URL is a SoundCloud set/playlist
- `extractSoundCloudPlaylist(url)` - Extracts metadata for all tracks in a playlist

**Modified Functions:**
- `fetchSoundCloudOEmbed(url)` - Added retry logic and URL normalization for API URLs
- `extractSoundCloud(url)` - Changed from URL fetching to yt-dlp download for HLS support

**Key Implementation Details:**

1. **Playlist Detection**
   ```typescript
   export function isSoundCloudPlaylist(url: string): boolean {
     const urlObj = new URL(url);
     return urlObj.hostname.includes('soundcloud.com') &&
            urlObj.pathname.includes('/sets/');
   }
   ```

2. **URL Normalization for oEmbed**
   - yt-dlp returns `api-v2.soundcloud.com` URLs for some tracks
   - oEmbed API only works with `api.soundcloud.com` URLs
   - Solution: Convert `api-v2` to `api` before oEmbed requests

3. **Rate Limiting Protection**
   - Concurrency: 3 requests at a time
   - Delay: 500ms between batches
   - Retry: Up to 2 retries with exponential backoff for 429 responses

4. **Audio Download (HLS Support)**
   - SoundCloud now serves HLS streams (`m3u8_native`) instead of direct MP3s
   - Using `fetch()` on HLS URLs only downloads the playlist file (~12KB)
   - Solution: Use yt-dlp with `extractAudio: true` and `audioFormat: 'mp3'`
   - yt-dlp handles HLS download and conversion internally

#### `server/services/contentExtractor.ts`

- Added imports for `isSoundCloudPlaylist` and `extractSoundCloudPlaylist`
- Added `isPlaylistUrl(url)` - Checks if URL is any playlist type (YouTube or SoundCloud)
- Added `extractPlaylist(url)` - Routes to appropriate playlist extractor

#### `server/services/socketManager.ts`

**New Functions:**
- `needsSoundCloudExtraction(item)` - Checks if queue item needs audio extraction
- `extractSoundCloudAudio(item)` - Extracts audio on-demand for a queue item

**Modified Handlers (all now async with on-demand extraction):**
- `queue-add` - Extracts audio if autoplay triggers
- `queue-next` - Extracts audio before playing next track
- `queue-prev` - Extracts audio before playing previous track
- `queue-play` - Extracts audio before playing selected track
- `track-ended` - Extracts audio before auto-advancing
- `queue-remove-playlist` - New handler to remove all tracks from a playlist

**Interface Changes:**
- Added `originalUrl` and `duration` to `ContentPayload` interface
- Added playlist fields: `playlistId`, `playlistTitle`, `playlistIndex`, `playlistLength`

#### `server/routes/extract.ts`

- Modified POST `/api/extract` to detect playlists and return array response
- Response format for playlists: `{ success: true, contents: [...], isPlaylist: true }`

### Client-Side

#### `src/lib/socket.ts`

- Added `originalUrl` to `ContentPayload` interface
- Added `queueRemovePlaylist(playlistId)` function

#### `src/lib/components/DropZone.svelte`

- Modified `handleUrl()` to handle array responses for playlists
- Iterates through `data.contents` and calls `queueAdd()` for each track

#### `src/routes/+page.svelte`

- Added playlist badge display in queue items (shows "Playlist Name • 3/15")
- Added remove-playlist button (grid icon) to remove all tracks from a playlist

## Data Flow

### 1. Playlist Submission
```
User pastes: https://soundcloud.com/artist/sets/playlist-name
     │
     ▼
DropZone.handleUrl()
     │
     ▼
POST /api/extract
     │
     ▼
isPlaylistUrl() returns true
     │
     ▼
extractSoundCloudPlaylist()
     │
     ├─► yt-dlp --flat-playlist (get track URLs)
     │
     ├─► fetchSoundCloudOEmbed() for each track (batched, rate-limited)
     │
     └─► Return array of ExtractedContent with metadata
```

### 2. Track Playback
```
User clicks track in queue
     │
     ▼
socket.emit('queue-play', itemId)
     │
     ▼
Server: queue-play handler
     │
     ▼
needsSoundCloudExtraction(item)?
     │
     ├─► Yes: extractSoundCloudAudio(item)
     │        │
     │        ├─► yt-dlp download with extractAudio
     │        │
     │        ├─► Save to /uploads/soundcloud-cache/{hash}.mp3
     │        │
     │        └─► Update queue item URL to cached path
     │
     └─► No: Use existing URL
     │
     ▼
io.emit('new-content', item)
     │
     ▼
Display: SoundCloudPlayer renders with cached audio URL
```

## Cache Strategy

- **Location**: `/uploads/soundcloud-cache/`
- **Filename**: MD5 hash of original SoundCloud URL + `.mp3`
- **Validation**: Files < 50KB are considered invalid (HLS playlist files) and re-downloaded
- **Persistence**: Cache survives container restarts (Docker volume)

## Known Limitations

1. **Initial Load Time**: First play of each track takes 5-10 seconds for download
2. **Large Playlists**: Metadata fetching for 100+ tracks can take 30+ seconds
3. **Private Playlists**: May fail if yt-dlp cannot access them
4. **Rate Limits**: Heavy usage may trigger SoundCloud rate limiting

## Error Handling

- oEmbed failures fall back to "Track N" title
- Download failures return error type to display
- Invalid cache files are automatically cleaned up
- Retry logic handles transient failures

## Testing

Test with these example playlists:
- `https://soundcloud.com/ygravy/sets/marvelous` (15 tracks)
- `https://soundcloud.com/4kcarpet/sets/passengerprincess` (8 tracks)

## Future Improvements

- [ ] Background pre-fetching of next track while current plays
- [ ] Progress indicator during audio download
- [ ] Playlist shuffle mode
- [ ] Import liked tracks
