892 lines
28 KiB
TypeScript
892 lines
28 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
// Mock the yt-dlp module before importing sources
|
|
vi.mock('../sources/yt-dlp', () => ({
|
|
execYtDlp: vi.fn(),
|
|
parseSingleJson: vi.fn(),
|
|
parseJsonLines: vi.fn(),
|
|
checkYtDlpAvailable: vi.fn(),
|
|
YtDlpError: class YtDlpError extends Error {
|
|
stderr: string;
|
|
exitCode: number;
|
|
isRateLimit: boolean;
|
|
constructor(message: string, stderr: string, exitCode: number) {
|
|
super(message);
|
|
this.name = 'YtDlpError';
|
|
this.stderr = stderr;
|
|
this.exitCode = exitCode;
|
|
this.isRateLimit = stderr.toLowerCase().includes('429');
|
|
}
|
|
},
|
|
}));
|
|
|
|
import { execYtDlp, parseSingleJson, parseJsonLines } from '../sources/yt-dlp';
|
|
import { YouTubeSource, isYouTubeUrl } from '../sources/youtube';
|
|
import { SoundCloudSource, isSoundCloudChannelUrl } from '../sources/soundcloud';
|
|
import { PlatformRegistry } from '../sources/platform-source';
|
|
import { Platform, ContentType } from '../types/index';
|
|
import type { Channel } from '../types/index';
|
|
|
|
const mockExecYtDlp = execYtDlp as ReturnType<typeof vi.fn>;
|
|
const mockParseSingleJson = parseSingleJson as ReturnType<typeof vi.fn>;
|
|
const mockParseJsonLines = parseJsonLines as ReturnType<typeof vi.fn>;
|
|
|
|
// ── Canned Fixtures ──
|
|
|
|
const YOUTUBE_CHANNEL_JSON = {
|
|
channel: 'Linus Tech Tips',
|
|
channel_id: 'UCXuqSBlHAE6Xw-yeJA0Tunw',
|
|
channel_url: 'https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw',
|
|
uploader: 'Linus Tech Tips',
|
|
uploader_id: '@LinusTechTips',
|
|
uploader_url: 'https://www.youtube.com/@LinusTechTips',
|
|
thumbnails: [
|
|
{ url: 'https://i.ytimg.com/vi/thumb_small.jpg', width: 120, height: 90 },
|
|
{ url: 'https://i.ytimg.com/vi/thumb_large.jpg', width: 1280, height: 720 },
|
|
],
|
|
};
|
|
|
|
const YOUTUBE_PLAYLIST_ENTRIES = [
|
|
{
|
|
id: 'dQw4w9WgXcQ',
|
|
title: 'Never Gonna Give You Up',
|
|
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
|
webpage_url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
|
duration: 212,
|
|
thumbnail: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg',
|
|
live_status: null,
|
|
upload_date: '20240315',
|
|
},
|
|
{
|
|
id: 'abc123xyz',
|
|
title: 'Tech Review 2024',
|
|
url: 'https://www.youtube.com/watch?v=abc123xyz',
|
|
duration: 845,
|
|
thumbnails: [
|
|
{ url: 'https://i.ytimg.com/vi/abc123xyz/sd.jpg' },
|
|
{ url: 'https://i.ytimg.com/vi/abc123xyz/hd.jpg' },
|
|
],
|
|
live_status: null,
|
|
upload_date: '20240620',
|
|
},
|
|
{
|
|
id: 'live001',
|
|
title: 'Live Stream Event',
|
|
url: 'https://www.youtube.com/watch?v=live001',
|
|
duration: null,
|
|
thumbnail: 'https://i.ytimg.com/vi/live001/hqdefault.jpg',
|
|
live_status: 'is_live',
|
|
upload_date: '20240701',
|
|
},
|
|
];
|
|
|
|
const SOUNDCLOUD_ARTIST_JSON = {
|
|
uploader: 'Deadmau5',
|
|
uploader_id: 'deadmau5',
|
|
uploader_url: 'https://soundcloud.com/deadmau5',
|
|
channel: null,
|
|
channel_id: null,
|
|
channel_url: null,
|
|
thumbnails: [
|
|
{ url: 'https://i1.sndcdn.com/avatars-small.jpg' },
|
|
{ url: 'https://i1.sndcdn.com/avatars-large.jpg' },
|
|
],
|
|
};
|
|
|
|
const SOUNDCLOUD_TRACK_ENTRIES = [
|
|
{
|
|
id: 'sc-track-001',
|
|
title: 'Strobe (Club Edit)',
|
|
url: 'https://soundcloud.com/deadmau5/strobe-club-edit',
|
|
duration: 421,
|
|
thumbnail: 'https://i1.sndcdn.com/artworks-track1.jpg',
|
|
},
|
|
{
|
|
id: 'sc-track-002',
|
|
title: 'Ghosts n Stuff',
|
|
url: 'https://soundcloud.com/deadmau5/ghosts-n-stuff',
|
|
duration: 335,
|
|
thumbnails: [
|
|
{ url: 'https://i1.sndcdn.com/artworks-track2-sm.jpg' },
|
|
{ url: 'https://i1.sndcdn.com/artworks-track2-lg.jpg' },
|
|
],
|
|
},
|
|
{
|
|
id: 'sc-track-003',
|
|
title: 'Raise Your Weapon',
|
|
url: 'https://soundcloud.com/deadmau5/raise-your-weapon',
|
|
duration: 498,
|
|
thumbnail: null,
|
|
},
|
|
];
|
|
|
|
// ── Helper ──
|
|
|
|
function makeChannel(overrides: Partial<Channel> = {}): Channel {
|
|
return {
|
|
id: 1,
|
|
name: 'Test Channel',
|
|
platform: Platform.YouTube,
|
|
platformId: 'UC123',
|
|
url: 'https://www.youtube.com/@TestChannel',
|
|
monitoringEnabled: true,
|
|
checkInterval: 360,
|
|
imageUrl: null,
|
|
metadata: null,
|
|
formatProfileId: null,
|
|
monitoringMode: 'all',
|
|
bannerUrl: null,
|
|
description: null,
|
|
subscriberCount: null,
|
|
includeKeywords: null,
|
|
excludeKeywords: null,
|
|
contentRating: null,
|
|
createdAt: '2024-01-01T00:00:00Z',
|
|
updatedAt: '2024-01-01T00:00:00Z',
|
|
lastCheckedAt: null,
|
|
lastCheckStatus: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// ── Tests ──
|
|
|
|
describe('YouTube URL validation', () => {
|
|
it('accepts @handle format', () => {
|
|
expect(isYouTubeUrl('https://www.youtube.com/@LinusTechTips')).toBe(true);
|
|
expect(isYouTubeUrl('https://youtube.com/@LinusTechTips')).toBe(true);
|
|
});
|
|
|
|
it('accepts /channel/ format', () => {
|
|
expect(
|
|
isYouTubeUrl(
|
|
'https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw'
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
it('accepts /c/ format', () => {
|
|
expect(isYouTubeUrl('https://www.youtube.com/c/LinusTechTips')).toBe(true);
|
|
});
|
|
|
|
it('accepts /user/ format', () => {
|
|
expect(isYouTubeUrl('https://www.youtube.com/user/LinusTechTips')).toBe(
|
|
true
|
|
);
|
|
});
|
|
|
|
it('accepts youtu.be short URLs', () => {
|
|
expect(isYouTubeUrl('https://youtu.be/dQw4w9WgXcQ')).toBe(true);
|
|
});
|
|
|
|
it('rejects non-YouTube URLs', () => {
|
|
expect(isYouTubeUrl('https://soundcloud.com/artist')).toBe(false);
|
|
expect(isYouTubeUrl('https://example.com')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('SoundCloud URL validation', () => {
|
|
it('accepts artist-level URLs', () => {
|
|
expect(isSoundCloudChannelUrl('https://soundcloud.com/deadmau5')).toBe(
|
|
true
|
|
);
|
|
expect(isSoundCloudChannelUrl('https://www.soundcloud.com/deadmau5')).toBe(
|
|
true
|
|
);
|
|
});
|
|
|
|
it('rejects track URLs', () => {
|
|
expect(
|
|
isSoundCloudChannelUrl(
|
|
'https://soundcloud.com/deadmau5/tracks/strobe'
|
|
)
|
|
).toBe(false);
|
|
});
|
|
|
|
it('rejects set URLs', () => {
|
|
expect(
|
|
isSoundCloudChannelUrl(
|
|
'https://soundcloud.com/deadmau5/sets/album'
|
|
)
|
|
).toBe(false);
|
|
});
|
|
|
|
it('rejects non-SoundCloud URLs', () => {
|
|
expect(isSoundCloudChannelUrl('https://youtube.com/@test')).toBe(false);
|
|
expect(isSoundCloudChannelUrl('https://example.com')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('YouTubeSource', () => {
|
|
const youtube = new YouTubeSource();
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('resolveChannel', () => {
|
|
it('resolves channel metadata from a YouTube URL', async () => {
|
|
mockExecYtDlp.mockResolvedValueOnce({
|
|
stdout: JSON.stringify(YOUTUBE_CHANNEL_JSON),
|
|
stderr: '',
|
|
exitCode: 0,
|
|
});
|
|
mockParseSingleJson.mockReturnValueOnce(YOUTUBE_CHANNEL_JSON);
|
|
|
|
const result = await youtube.resolveChannel(
|
|
'https://www.youtube.com/@LinusTechTips'
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
name: 'Linus Tech Tips',
|
|
platformId: 'UCXuqSBlHAE6Xw-yeJA0Tunw',
|
|
imageUrl: 'https://i.ytimg.com/vi/thumb_large.jpg',
|
|
url: 'https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw',
|
|
platform: 'youtube',
|
|
bannerUrl: 'https://i.ytimg.com/vi/thumb_large.jpg',
|
|
description: null,
|
|
subscriberCount: null,
|
|
});
|
|
|
|
// Verify yt-dlp was called with correct args
|
|
expect(mockExecYtDlp).toHaveBeenCalledWith(
|
|
[
|
|
'--dump-single-json',
|
|
'--playlist-items',
|
|
'0',
|
|
'--flat-playlist',
|
|
'https://www.youtube.com/@LinusTechTips',
|
|
],
|
|
{ timeout: 30_000 }
|
|
);
|
|
});
|
|
|
|
it('falls back to uploader when channel is missing', async () => {
|
|
const noChannelJson = {
|
|
...YOUTUBE_CHANNEL_JSON,
|
|
channel: undefined,
|
|
channel_id: undefined,
|
|
channel_url: undefined,
|
|
};
|
|
mockExecYtDlp.mockResolvedValueOnce({
|
|
stdout: '',
|
|
stderr: '',
|
|
exitCode: 0,
|
|
});
|
|
mockParseSingleJson.mockReturnValueOnce(noChannelJson);
|
|
|
|
const result = await youtube.resolveChannel(
|
|
'https://www.youtube.com/@Test'
|
|
);
|
|
|
|
expect(result.name).toBe('Linus Tech Tips'); // falls back to uploader
|
|
});
|
|
});
|
|
|
|
describe('fetchRecentContent', () => {
|
|
it('Phase 1 discovers items via --flat-playlist, Phase 2 enriches new items', async () => {
|
|
const channel = makeChannel();
|
|
|
|
// Phase 1: flat-playlist discovery
|
|
mockExecYtDlp.mockResolvedValueOnce({
|
|
stdout: '',
|
|
stderr: '',
|
|
exitCode: 0,
|
|
});
|
|
mockParseJsonLines.mockReturnValueOnce(YOUTUBE_PLAYLIST_ENTRIES);
|
|
|
|
// Phase 2: enrichment for each new item (3 items, none in existingIds)
|
|
for (const entry of YOUTUBE_PLAYLIST_ENTRIES) {
|
|
const enrichedEntry = { ...entry, upload_date: entry.upload_date };
|
|
mockExecYtDlp.mockResolvedValueOnce({
|
|
stdout: JSON.stringify(enrichedEntry),
|
|
stderr: '',
|
|
exitCode: 0,
|
|
});
|
|
mockParseSingleJson.mockReturnValueOnce(enrichedEntry);
|
|
}
|
|
|
|
const result = await youtube.fetchRecentContent(channel, {
|
|
limit: 50,
|
|
existingIds: new Set(),
|
|
rateLimitDelay: 0, // no delay in tests
|
|
});
|
|
|
|
expect(result).toHaveLength(3);
|
|
|
|
// First entry — regular video with enriched publishedAt
|
|
expect(result[0]).toEqual({
|
|
platformContentId: 'dQw4w9WgXcQ',
|
|
title: 'Never Gonna Give You Up',
|
|
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
|
contentType: 'video',
|
|
duration: 212,
|
|
thumbnailUrl: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg',
|
|
publishedAt: '2024-03-15T00:00:00Z',
|
|
});
|
|
|
|
// Second entry — uses thumbnails array
|
|
expect(result[1]?.thumbnailUrl).toBe(
|
|
'https://i.ytimg.com/vi/abc123xyz/hd.jpg'
|
|
);
|
|
|
|
// Third entry — live stream
|
|
expect(result[2]?.contentType).toBe('livestream');
|
|
|
|
// Verify Phase 1 call uses --flat-playlist
|
|
expect(mockExecYtDlp).toHaveBeenNthCalledWith(
|
|
1,
|
|
['--flat-playlist', '--dump-json', '--playlist-items', '1:50', channel.url],
|
|
{ timeout: 90_000 }
|
|
);
|
|
|
|
// Verify Phase 2 calls use --dump-json --no-playlist per video
|
|
expect(mockExecYtDlp).toHaveBeenNthCalledWith(
|
|
2,
|
|
['--dump-json', '--no-playlist', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'],
|
|
{ timeout: 15_000 }
|
|
);
|
|
});
|
|
|
|
it('skips enrichment for items in existingIds', async () => {
|
|
const channel = makeChannel();
|
|
|
|
// Phase 1: discovery returns 3 items
|
|
mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
mockParseJsonLines.mockReturnValueOnce(YOUTUBE_PLAYLIST_ENTRIES);
|
|
|
|
// Provide 2 of 3 as existing — only 1 needs enrichment
|
|
const existingIds = new Set(['dQw4w9WgXcQ', 'abc123xyz']);
|
|
|
|
// Phase 2: only live001 needs enrichment
|
|
const enrichedLive = { ...YOUTUBE_PLAYLIST_ENTRIES[2], upload_date: '20240701' };
|
|
mockExecYtDlp.mockResolvedValueOnce({ stdout: JSON.stringify(enrichedLive), stderr: '', exitCode: 0 });
|
|
mockParseSingleJson.mockReturnValueOnce(enrichedLive);
|
|
|
|
const result = await youtube.fetchRecentContent(channel, {
|
|
limit: 50,
|
|
existingIds,
|
|
rateLimitDelay: 0,
|
|
});
|
|
|
|
expect(result).toHaveLength(3);
|
|
// Only 2 execYtDlp calls: 1 flat-playlist + 1 enrichment
|
|
expect(mockExecYtDlp).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('skips all enrichment when all items are existing', async () => {
|
|
const channel = makeChannel();
|
|
|
|
mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
mockParseJsonLines.mockReturnValueOnce(YOUTUBE_PLAYLIST_ENTRIES);
|
|
|
|
const allIds = new Set(YOUTUBE_PLAYLIST_ENTRIES.map((e) => e.id));
|
|
|
|
const result = await youtube.fetchRecentContent(channel, {
|
|
limit: 50,
|
|
existingIds: allIds,
|
|
rateLimitDelay: 0,
|
|
});
|
|
|
|
expect(result).toHaveLength(3);
|
|
// Only 1 execYtDlp call: flat-playlist only, no enrichment
|
|
expect(mockExecYtDlp).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('tolerates individual enrichment failures', async () => {
|
|
const channel = makeChannel();
|
|
|
|
// Phase 1: discovery
|
|
mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
mockParseJsonLines.mockReturnValueOnce(YOUTUBE_PLAYLIST_ENTRIES);
|
|
|
|
// Phase 2: first enrichment succeeds
|
|
const enriched1 = { ...YOUTUBE_PLAYLIST_ENTRIES[0] };
|
|
mockExecYtDlp.mockResolvedValueOnce({ stdout: JSON.stringify(enriched1), stderr: '', exitCode: 0 });
|
|
mockParseSingleJson.mockReturnValueOnce(enriched1);
|
|
|
|
// Second enrichment fails
|
|
mockExecYtDlp.mockRejectedValueOnce(new Error('network timeout'));
|
|
|
|
// Third enrichment succeeds
|
|
const enriched3 = { ...YOUTUBE_PLAYLIST_ENTRIES[2] };
|
|
mockExecYtDlp.mockResolvedValueOnce({ stdout: JSON.stringify(enriched3), stderr: '', exitCode: 0 });
|
|
mockParseSingleJson.mockReturnValueOnce(enriched3);
|
|
|
|
// Suppress console.warn for expected enrichment failure
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
const result = await youtube.fetchRecentContent(channel, {
|
|
limit: 50,
|
|
existingIds: new Set(),
|
|
rateLimitDelay: 0,
|
|
});
|
|
|
|
warnSpy.mockRestore();
|
|
|
|
// All 3 items returned — failed one uses flat-playlist data
|
|
expect(result).toHaveLength(3);
|
|
// First item is enriched
|
|
expect(result[0]?.publishedAt).toBe('2024-03-15T00:00:00Z');
|
|
// Second item falls back to flat data (no upload_date from flat-playlist → null)
|
|
// The flat-playlist entry had upload_date:'20240620', so mapEntry gives it publishedAt
|
|
// BUT the enrichment failed, so it uses the flat entry directly which DID have upload_date
|
|
// Actually the flat entries include upload_date, so mapEntry will parse it
|
|
expect(result[1]?.platformContentId).toBe('abc123xyz');
|
|
// Third item is enriched
|
|
expect(result[2]?.publishedAt).toBe('2024-07-01T00:00:00Z');
|
|
});
|
|
|
|
it('uses correct yt-dlp args with custom limit', async () => {
|
|
const channel = makeChannel();
|
|
mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
mockParseJsonLines.mockReturnValueOnce([]);
|
|
|
|
await youtube.fetchRecentContent(channel, { limit: 10 });
|
|
|
|
expect(mockExecYtDlp).toHaveBeenCalledWith(
|
|
['--flat-playlist', '--dump-json', '--playlist-items', '1:10', channel.url],
|
|
{ timeout: 90_000 }
|
|
);
|
|
});
|
|
|
|
it('uses defaults when no options provided', async () => {
|
|
const channel = makeChannel();
|
|
mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
mockParseJsonLines.mockReturnValueOnce([]);
|
|
|
|
await youtube.fetchRecentContent(channel);
|
|
|
|
expect(mockExecYtDlp).toHaveBeenCalledWith(
|
|
['--flat-playlist', '--dump-json', '--playlist-items', '1:50', channel.url],
|
|
{ timeout: 90_000 }
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('publishedAt extraction', () => {
|
|
it('extracts publishedAt from upload_date in YYYYMMDD format', async () => {
|
|
const channel = makeChannel();
|
|
// Phase 1: flat discovery
|
|
mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
mockParseJsonLines.mockReturnValueOnce(YOUTUBE_PLAYLIST_ENTRIES);
|
|
|
|
// All items already exist — no enrichment needed
|
|
const allIds = new Set(YOUTUBE_PLAYLIST_ENTRIES.map((e) => e.id));
|
|
|
|
const result = await youtube.fetchRecentContent(channel, {
|
|
limit: 50,
|
|
existingIds: allIds,
|
|
rateLimitDelay: 0,
|
|
});
|
|
|
|
// Flat entries include upload_date, so mapEntry parses them
|
|
expect(result[0]?.publishedAt).toBe('2024-03-15T00:00:00Z');
|
|
expect(result[1]?.publishedAt).toBe('2024-06-20T00:00:00Z');
|
|
expect(result[2]?.publishedAt).toBe('2024-07-01T00:00:00Z');
|
|
});
|
|
|
|
it('returns null publishedAt when upload_date is missing', async () => {
|
|
const channel = makeChannel();
|
|
const entryNoDate = [{
|
|
id: 'vid1', title: 'No Date',
|
|
url: 'https://youtube.com/watch?v=vid1',
|
|
duration: 100, live_status: null,
|
|
}];
|
|
// Phase 1
|
|
mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
mockParseJsonLines.mockReturnValueOnce(entryNoDate);
|
|
|
|
// Phase 2: enrich vid1 — return same data without upload_date
|
|
mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
mockParseSingleJson.mockReturnValueOnce(entryNoDate[0]);
|
|
|
|
const result = await youtube.fetchRecentContent(channel, {
|
|
limit: 50,
|
|
existingIds: new Set(),
|
|
rateLimitDelay: 0,
|
|
});
|
|
|
|
expect(result[0]?.publishedAt).toBeNull();
|
|
});
|
|
|
|
it('returns null publishedAt when upload_date is malformed', async () => {
|
|
const channel = makeChannel();
|
|
const entryBadDate = [{
|
|
id: 'vid2', title: 'Bad Date',
|
|
url: 'https://youtube.com/watch?v=vid2',
|
|
duration: 200, live_status: null,
|
|
upload_date: '2024',
|
|
}];
|
|
// Phase 1
|
|
mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
mockParseJsonLines.mockReturnValueOnce(entryBadDate);
|
|
|
|
// Phase 2: enrich vid2 — return same data with malformed upload_date
|
|
mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
mockParseSingleJson.mockReturnValueOnce(entryBadDate[0]);
|
|
|
|
const result = await youtube.fetchRecentContent(channel, {
|
|
limit: 50,
|
|
existingIds: new Set(),
|
|
rateLimitDelay: 0,
|
|
});
|
|
|
|
expect(result[0]?.publishedAt).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('fetchPlaylists', () => {
|
|
it('returns playlist metadata with video mappings', async () => {
|
|
const channel = makeChannel();
|
|
|
|
// First call: enumerate playlists from /playlists tab
|
|
mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
mockParseJsonLines.mockReturnValueOnce([
|
|
{ id: 'PLabc123', title: 'Best Of 2024' },
|
|
{ id: 'PLdef456', title: 'Tutorials' },
|
|
]);
|
|
|
|
// Second call: videos in first playlist
|
|
mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
mockParseJsonLines.mockReturnValueOnce([
|
|
{ id: 'vid-a1' },
|
|
{ id: 'vid-a2' },
|
|
{ id: 'vid-a3' },
|
|
]);
|
|
|
|
// Third call: videos in second playlist
|
|
mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
mockParseJsonLines.mockReturnValueOnce([
|
|
{ id: 'vid-b1' },
|
|
{ id: 'vid-b2' },
|
|
]);
|
|
|
|
const result = await youtube.fetchPlaylists(channel);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0]).toEqual({
|
|
platformPlaylistId: 'PLabc123',
|
|
title: 'Best Of 2024',
|
|
videoIds: ['vid-a1', 'vid-a2', 'vid-a3'],
|
|
});
|
|
expect(result[1]).toEqual({
|
|
platformPlaylistId: 'PLdef456',
|
|
title: 'Tutorials',
|
|
videoIds: ['vid-b1', 'vid-b2'],
|
|
});
|
|
|
|
// Verify first call fetches playlists tab
|
|
expect(mockExecYtDlp).toHaveBeenNthCalledWith(
|
|
1,
|
|
['--flat-playlist', '--dump-json', `${channel.url}/playlists`],
|
|
{ timeout: 60_000 }
|
|
);
|
|
|
|
// Verify subsequent calls fetch individual playlist videos
|
|
expect(mockExecYtDlp).toHaveBeenNthCalledWith(
|
|
2,
|
|
['--flat-playlist', '--dump-json', 'https://www.youtube.com/playlist?list=PLabc123'],
|
|
{ timeout: 60_000 }
|
|
);
|
|
expect(mockExecYtDlp).toHaveBeenNthCalledWith(
|
|
3,
|
|
['--flat-playlist', '--dump-json', 'https://www.youtube.com/playlist?list=PLdef456'],
|
|
{ timeout: 60_000 }
|
|
);
|
|
});
|
|
|
|
it('handles empty playlists tab', async () => {
|
|
const channel = makeChannel();
|
|
mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
mockParseJsonLines.mockReturnValueOnce([]);
|
|
|
|
const result = await youtube.fetchPlaylists(channel);
|
|
|
|
expect(result).toEqual([]);
|
|
expect(mockExecYtDlp).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('skips entries without playlist ID', async () => {
|
|
const channel = makeChannel();
|
|
|
|
// Playlists tab returns one valid and one without id
|
|
mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
mockParseJsonLines.mockReturnValueOnce([
|
|
{ id: 'PLvalid', title: 'Valid Playlist' },
|
|
{ title: 'No ID Playlist' },
|
|
{ id: '', title: 'Empty ID Playlist' },
|
|
]);
|
|
|
|
// Only the valid playlist triggers a video fetch
|
|
mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
mockParseJsonLines.mockReturnValueOnce([{ id: 'vid-1' }]);
|
|
|
|
const result = await youtube.fetchPlaylists(channel);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]).toEqual({
|
|
platformPlaylistId: 'PLvalid',
|
|
title: 'Valid Playlist',
|
|
videoIds: ['vid-1'],
|
|
});
|
|
// 1 call for playlists tab + 1 call for the valid playlist's videos
|
|
expect(mockExecYtDlp).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('uses "Untitled Playlist" when title is missing', async () => {
|
|
const channel = makeChannel();
|
|
|
|
mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
mockParseJsonLines.mockReturnValueOnce([
|
|
{ id: 'PLnoTitle' },
|
|
]);
|
|
|
|
mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
mockParseJsonLines.mockReturnValueOnce([]);
|
|
|
|
const result = await youtube.fetchPlaylists(channel);
|
|
|
|
expect(result[0]?.title).toBe('Untitled Playlist');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('SoundCloudSource', () => {
|
|
const soundcloud = new SoundCloudSource();
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('resolveChannel', () => {
|
|
it('resolves artist metadata from a SoundCloud URL', async () => {
|
|
mockExecYtDlp.mockResolvedValueOnce({
|
|
stdout: '',
|
|
stderr: '',
|
|
exitCode: 0,
|
|
});
|
|
mockParseSingleJson.mockReturnValueOnce(SOUNDCLOUD_ARTIST_JSON);
|
|
|
|
const result = await soundcloud.resolveChannel(
|
|
'https://soundcloud.com/deadmau5'
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
name: 'Deadmau5',
|
|
platformId: 'deadmau5',
|
|
imageUrl: 'https://i1.sndcdn.com/avatars-large.jpg',
|
|
url: 'https://soundcloud.com/deadmau5',
|
|
platform: 'soundcloud',
|
|
bannerUrl: null,
|
|
description: null,
|
|
subscriberCount: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('fetchRecentContent', () => {
|
|
it('fetches tracks with audio content type', async () => {
|
|
const channel = makeChannel({
|
|
platform: Platform.SoundCloud,
|
|
url: 'https://soundcloud.com/deadmau5',
|
|
});
|
|
mockExecYtDlp.mockResolvedValueOnce({
|
|
stdout: '',
|
|
stderr: '',
|
|
exitCode: 0,
|
|
});
|
|
mockParseJsonLines.mockReturnValueOnce(SOUNDCLOUD_TRACK_ENTRIES);
|
|
|
|
const result = await soundcloud.fetchRecentContent(channel, { limit: 20 });
|
|
|
|
expect(result).toHaveLength(3);
|
|
|
|
// All SoundCloud entries should be audio type
|
|
result.forEach((item) => {
|
|
expect(item.contentType).toBe('audio');
|
|
});
|
|
|
|
expect(result[0]).toEqual({
|
|
platformContentId: 'sc-track-001',
|
|
title: 'Strobe (Club Edit)',
|
|
url: 'https://soundcloud.com/deadmau5/strobe-club-edit',
|
|
contentType: 'audio',
|
|
duration: 421,
|
|
thumbnailUrl: 'https://i1.sndcdn.com/artworks-track1.jpg',
|
|
publishedAt: null,
|
|
});
|
|
|
|
// Entry with thumbnails array
|
|
expect(result[1]?.thumbnailUrl).toBe(
|
|
'https://i1.sndcdn.com/artworks-track2-lg.jpg'
|
|
);
|
|
|
|
// Entry with null thumbnail
|
|
expect(result[2]?.thumbnailUrl).toBeNull();
|
|
});
|
|
|
|
it('passes --sleep-requests 2 for rate limit mitigation', async () => {
|
|
const channel = makeChannel({
|
|
platform: Platform.SoundCloud,
|
|
url: 'https://soundcloud.com/deadmau5',
|
|
});
|
|
mockExecYtDlp.mockResolvedValueOnce({
|
|
stdout: '',
|
|
stderr: '',
|
|
exitCode: 0,
|
|
});
|
|
mockParseJsonLines.mockReturnValueOnce([]);
|
|
|
|
await soundcloud.fetchRecentContent(channel, { limit: 50 });
|
|
|
|
expect(mockExecYtDlp).toHaveBeenCalledWith(
|
|
expect.arrayContaining(['--sleep-requests', '2']),
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('PlatformRegistry', () => {
|
|
let registry: PlatformRegistry;
|
|
const youtube = new YouTubeSource();
|
|
const soundcloud = new SoundCloudSource();
|
|
|
|
beforeEach(() => {
|
|
registry = new PlatformRegistry();
|
|
registry.register(Platform.YouTube, youtube);
|
|
registry.register(Platform.SoundCloud, soundcloud);
|
|
});
|
|
|
|
describe('get', () => {
|
|
it('returns registered source for a platform', () => {
|
|
expect(registry.get(Platform.YouTube)).toBe(youtube);
|
|
expect(registry.get(Platform.SoundCloud)).toBe(soundcloud);
|
|
});
|
|
|
|
it('returns undefined for unregistered platform', () => {
|
|
const empty = new PlatformRegistry();
|
|
expect(empty.get(Platform.YouTube)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('getForUrl', () => {
|
|
it('resolves YouTube @handle URLs', () => {
|
|
const result = registry.getForUrl(
|
|
'https://www.youtube.com/@LinusTechTips'
|
|
);
|
|
expect(result?.platform).toBe('youtube');
|
|
expect(result?.source).toBe(youtube);
|
|
});
|
|
|
|
it('resolves YouTube /channel/ URLs', () => {
|
|
const result = registry.getForUrl(
|
|
'https://www.youtube.com/channel/UC123'
|
|
);
|
|
expect(result?.platform).toBe('youtube');
|
|
});
|
|
|
|
it('resolves YouTube /c/ URLs', () => {
|
|
const result = registry.getForUrl(
|
|
'https://www.youtube.com/c/LinusTechTips'
|
|
);
|
|
expect(result?.platform).toBe('youtube');
|
|
});
|
|
|
|
it('resolves YouTube /user/ URLs', () => {
|
|
const result = registry.getForUrl(
|
|
'https://www.youtube.com/user/LinusTechTips'
|
|
);
|
|
expect(result?.platform).toBe('youtube');
|
|
});
|
|
|
|
it('resolves youtu.be short URLs', () => {
|
|
const result = registry.getForUrl('https://youtu.be/dQw4w9WgXcQ');
|
|
expect(result?.platform).toBe('youtube');
|
|
});
|
|
|
|
it('resolves SoundCloud artist URLs', () => {
|
|
const result = registry.getForUrl('https://soundcloud.com/deadmau5');
|
|
expect(result?.platform).toBe('soundcloud');
|
|
expect(result?.source).toBe(soundcloud);
|
|
});
|
|
|
|
it('rejects SoundCloud track URLs', () => {
|
|
const result = registry.getForUrl(
|
|
'https://soundcloud.com/deadmau5/tracks/strobe'
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('rejects SoundCloud set URLs', () => {
|
|
const result = registry.getForUrl(
|
|
'https://soundcloud.com/deadmau5/sets/album'
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('returns null for unknown URLs', () => {
|
|
expect(registry.getForUrl('https://example.com')).toBeNull();
|
|
expect(registry.getForUrl('https://spotify.com/artist/123')).toBeNull();
|
|
});
|
|
|
|
it('returns null when platform has no registered source', () => {
|
|
const emptyRegistry = new PlatformRegistry();
|
|
const result = emptyRegistry.getForUrl(
|
|
'https://www.youtube.com/@Test'
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Error propagation', () => {
|
|
const youtube = new YouTubeSource();
|
|
const soundcloud = new SoundCloudSource();
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('YouTube resolveChannel propagates yt-dlp errors', async () => {
|
|
const error = new Error('yt-dlp failed');
|
|
mockExecYtDlp.mockRejectedValueOnce(error);
|
|
|
|
await expect(
|
|
youtube.resolveChannel('https://www.youtube.com/@Bad')
|
|
).rejects.toThrow('yt-dlp failed');
|
|
});
|
|
|
|
it('SoundCloud resolveChannel propagates yt-dlp errors', async () => {
|
|
const error = new Error('yt-dlp failed');
|
|
mockExecYtDlp.mockRejectedValueOnce(error);
|
|
|
|
await expect(
|
|
soundcloud.resolveChannel('https://soundcloud.com/bad')
|
|
).rejects.toThrow('yt-dlp failed');
|
|
});
|
|
|
|
it('YouTube fetchRecentContent propagates yt-dlp errors', async () => {
|
|
const channel = makeChannel();
|
|
const error = new Error('network error');
|
|
mockExecYtDlp.mockRejectedValueOnce(error);
|
|
|
|
await expect(youtube.fetchRecentContent(channel)).rejects.toThrow(
|
|
'network error'
|
|
);
|
|
});
|
|
|
|
it('SoundCloud fetchRecentContent propagates yt-dlp errors', async () => {
|
|
const channel = makeChannel({
|
|
platform: Platform.SoundCloud,
|
|
url: 'https://soundcloud.com/artist',
|
|
});
|
|
const error = new Error('timeout');
|
|
mockExecYtDlp.mockRejectedValueOnce(error);
|
|
|
|
await expect(soundcloud.fetchRecentContent(channel)).rejects.toThrow(
|
|
'timeout'
|
|
);
|
|
});
|
|
});
|