- "src/db/schema/channels.ts" - "drizzle/0010_special_ghost_rider.sql" - "src/types/index.ts" - "src/sources/youtube.ts" - "src/sources/soundcloud.ts" - "src/db/repositories/channel-repository.ts" - "src/server/routes/channel.ts" - "src/__tests__/sources.test.ts" GSD-Task: S01/T01
114 lines
3.7 KiB
TypeScript
114 lines
3.7 KiB
TypeScript
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<PlatformSourceMetadata> {
|
|
const result = await execYtDlp(
|
|
['--dump-single-json', '--playlist-items', '0', '--flat-playlist', url],
|
|
{ timeout: 30_000 }
|
|
);
|
|
|
|
const data = parseSingleJson(result.stdout) as Record<string, unknown>;
|
|
|
|
// 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<PlatformContentMetadata[]> {
|
|
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<string, unknown>;
|
|
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, unknown>): 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;
|
|
}
|