import type { PlatformSource, FetchRecentContentOptions } from './platform-source'; import type { Channel, PlatformSourceMetadata, PlatformContentMetadata, } from '../types/index'; import { Platform, ContentType } from '../types/index'; import { execYtDlp, parseSingleJson, parseJsonLines } from './yt-dlp'; // ── URL Validation ── /** * Accept SoundCloud artist-level URLs. * Reject track and set URLs (which contain /tracks/ or /sets/). */ export function isSoundCloudChannelUrl(url: string): boolean { if (!/^https?:\/\/(www\.)?soundcloud\.com\//.test(url)) return false; if (/\/(tracks|sets)\//.test(url)) return false; return true; } // ── Implementation ── export class SoundCloudSource implements PlatformSource { async resolveChannel(url: string): Promise { const result = await execYtDlp( ['--dump-single-json', '--playlist-items', '0', '--flat-playlist', url], { timeout: 30_000 } ); const data = parseSingleJson(result.stdout) as Record; // SoundCloud uses uploader/uploader_id/uploader_url instead of channel fields const channelName = (data.uploader as string) ?? (data.channel as string) ?? 'Unknown Artist'; const uploaderId = (data.uploader_id as string) ?? ''; const uploaderUrl = (data.uploader_url as string) ?? (data.channel_url as string) ?? url; // Pick best available thumbnail const thumbnails = data.thumbnails as Array<{ url?: string }> | undefined; const imageUrl = thumbnails?.length ? (thumbnails[thumbnails.length - 1]?.url ?? null) : null; // Extract enrichment metadata (limited availability on SoundCloud) const description = typeof data.description === 'string' ? data.description : null; const subscriberCount = typeof data.channel_follower_count === 'number' ? data.channel_follower_count : typeof data.uploader_follower_count === 'number' ? data.uploader_follower_count : null; return { name: channelName, platformId: uploaderId, imageUrl, url: uploaderUrl, platform: Platform.SoundCloud, bannerUrl: null, // SoundCloud doesn't provide banner URLs via yt-dlp description, subscriberCount, }; } async fetchRecentContent( channel: Channel, options?: FetchRecentContentOptions ): Promise { const limit = options?.limit ?? 50; const result = await execYtDlp( [ '--flat-playlist', '--dump-json', '--playlist-items', `1:${limit}`, '--sleep-requests', '2', // SoundCloud rate limit mitigation channel.url, ], { timeout: 120_000 } // Longer timeout due to sleep-requests ); const entries = parseJsonLines(result.stdout); return entries.map((entry) => { const e = entry as Record; return { platformContentId: (e.id as string) ?? '', title: (e.title as string) ?? 'Untitled', url: (e.url as string) ?? (e.webpage_url as string) ?? '', contentType: ContentType.Audio, // SoundCloud defaults to audio duration: typeof e.duration === 'number' ? e.duration : null, thumbnailUrl: extractThumbnailUrl(e), publishedAt: null, // populated in T02 from upload_date }; }); } } // ── Helpers ── function extractThumbnailUrl(entry: Record): string | null { if (typeof entry.thumbnail === 'string') return entry.thumbnail; const thumbnails = entry.thumbnails as Array<{ url?: string }> | undefined; if (thumbnails?.length) { return thumbnails[thumbnails.length - 1]?.url ?? null; } return null; }