tubearr/src/sources/soundcloud.ts
jlightner 6a5402ce8d feat: Added banner_url, description, subscriber_count columns with Driz…
- "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
2026-04-03 07:23:39 +00:00

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;
}