# 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