test: Integrate fetchPlaylists into scheduler checkChannel as best-effo…
- "src/services/scheduler.ts" - "src/__tests__/scheduler.test.ts" GSD-Task: S03/T01
This commit is contained in:
parent
ed901c8240
commit
8ebacba3e1
2 changed files with 127 additions and 0 deletions
|
|
@ -33,9 +33,11 @@ import type {
|
||||||
Channel,
|
Channel,
|
||||||
PlatformSourceMetadata,
|
PlatformSourceMetadata,
|
||||||
PlatformContentMetadata,
|
PlatformContentMetadata,
|
||||||
|
PlaylistDiscoveryResult,
|
||||||
} from '../types/index';
|
} from '../types/index';
|
||||||
import type { LibSQLDatabase } from 'drizzle-orm/libsql';
|
import type { LibSQLDatabase } from 'drizzle-orm/libsql';
|
||||||
import type * as schema from '../db/schema/index';
|
import type * as schema from '../db/schema/index';
|
||||||
|
import { getPlaylistsByChannelId } from '../db/repositories/playlist-repository';
|
||||||
|
|
||||||
// ── Rate Limiter Tests ──
|
// ── Rate Limiter Tests ──
|
||||||
|
|
||||||
|
|
@ -891,6 +893,91 @@ describe('SchedulerService', () => {
|
||||||
|
|
||||||
scheduler.stop();
|
scheduler.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('calls fetchPlaylists after successful scan when source supports it', async () => {
|
||||||
|
const channel = await insertTestChannel();
|
||||||
|
|
||||||
|
// Build a registry with fetchPlaylists support
|
||||||
|
const fetchPlaylistsFn = vi.fn<(ch: Channel) => Promise<PlaylistDiscoveryResult[]>>()
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
platformPlaylistId: 'PL_test_1',
|
||||||
|
title: 'Test Playlist',
|
||||||
|
videoIds: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const mockSourceWithPlaylists: PlatformSource = {
|
||||||
|
resolveChannel: mockResolveChannel,
|
||||||
|
fetchRecentContent: mockFetchRecentContent,
|
||||||
|
fetchPlaylists: fetchPlaylistsFn,
|
||||||
|
};
|
||||||
|
const reg = new PlatformRegistry();
|
||||||
|
reg.register(Platform.YouTube, mockSourceWithPlaylists);
|
||||||
|
|
||||||
|
const localLimiter = new RateLimiter({
|
||||||
|
[Platform.YouTube]: { minIntervalMs: 0 },
|
||||||
|
});
|
||||||
|
const scheduler = new SchedulerService(db, reg, localLimiter);
|
||||||
|
|
||||||
|
mockFetchRecentContent.mockResolvedValueOnce(makeCannedContent(1, `plsync_${channel.id}`));
|
||||||
|
const result = await scheduler.checkChannel(channel);
|
||||||
|
|
||||||
|
expect(result.status).toBe('success');
|
||||||
|
// Allow background sidecar to complete
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
expect(fetchPlaylistsFn).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
// Verify playlist was persisted
|
||||||
|
const playlists = await getPlaylistsByChannelId(db, channel.id);
|
||||||
|
expect(playlists.length).toBe(1);
|
||||||
|
expect(playlists[0].title).toBe('Test Playlist');
|
||||||
|
|
||||||
|
scheduler.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scan succeeds even when playlist sync throws', async () => {
|
||||||
|
const channel = await insertTestChannel();
|
||||||
|
|
||||||
|
const fetchPlaylistsFn = vi.fn<(ch: Channel) => Promise<PlaylistDiscoveryResult[]>>()
|
||||||
|
.mockRejectedValueOnce(new Error('Playlist fetch exploded'));
|
||||||
|
const mockSourceWithPlaylists: PlatformSource = {
|
||||||
|
resolveChannel: mockResolveChannel,
|
||||||
|
fetchRecentContent: mockFetchRecentContent,
|
||||||
|
fetchPlaylists: fetchPlaylistsFn,
|
||||||
|
};
|
||||||
|
const reg = new PlatformRegistry();
|
||||||
|
reg.register(Platform.YouTube, mockSourceWithPlaylists);
|
||||||
|
|
||||||
|
const localLimiter = new RateLimiter({
|
||||||
|
[Platform.YouTube]: { minIntervalMs: 0 },
|
||||||
|
});
|
||||||
|
const scheduler = new SchedulerService(db, reg, localLimiter);
|
||||||
|
|
||||||
|
mockFetchRecentContent.mockResolvedValueOnce(makeCannedContent(2, `plfail_${channel.id}`));
|
||||||
|
const result = await scheduler.checkChannel(channel);
|
||||||
|
|
||||||
|
expect(result.status).toBe('success');
|
||||||
|
expect(result.newItems).toBe(2);
|
||||||
|
// Allow background sidecar to attempt and fail
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
expect(fetchPlaylistsFn).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
scheduler.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips playlist sync when source does not support fetchPlaylists', async () => {
|
||||||
|
const channel = await insertTestChannel();
|
||||||
|
const scheduler = new SchedulerService(db, registry, rateLimiter);
|
||||||
|
|
||||||
|
// Default mock source has no fetchPlaylists
|
||||||
|
mockFetchRecentContent.mockResolvedValueOnce(makeCannedContent(1, `noplsync_${channel.id}`));
|
||||||
|
const result = await scheduler.checkChannel(channel);
|
||||||
|
|
||||||
|
expect(result.status).toBe('success');
|
||||||
|
expect(result.newItems).toBe(1);
|
||||||
|
|
||||||
|
scheduler.stop();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── addChannel() / removeChannel() ──
|
// ── addChannel() / removeChannel() ──
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
updateContentItem,
|
updateContentItem,
|
||||||
} from '../db/repositories/content-repository';
|
} from '../db/repositories/content-repository';
|
||||||
import { getPlatformSettings } from '../db/repositories/platform-settings-repository';
|
import { getPlatformSettings } from '../db/repositories/platform-settings-repository';
|
||||||
|
import { upsertPlaylists } from '../db/repositories/playlist-repository';
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
|
|
||||||
|
|
@ -330,6 +331,18 @@ export class SchedulerService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 10. Best-effort playlist sync (K011 sidecar pattern)
|
||||||
|
// Runs after content scan succeeds — failure never affects the scan result.
|
||||||
|
if (source.fetchPlaylists && !effectiveSignal.aborted) {
|
||||||
|
this.syncPlaylists(channel, source)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(
|
||||||
|
`[scheduler] Playlist sync failed for channel ${channel.id}:`,
|
||||||
|
err instanceof Error ? err.message : err
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
channelId: channel.id,
|
channelId: channel.id,
|
||||||
channelName: channel.name,
|
channelName: channel.name,
|
||||||
|
|
@ -517,6 +530,33 @@ export class SchedulerService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort playlist sync for a channel.
|
||||||
|
* Fetches playlists from the platform source and upserts them into the DB.
|
||||||
|
* Failure is logged but never propagated (K011 sidecar pattern).
|
||||||
|
*/
|
||||||
|
private async syncPlaylists(channel: Channel, source: PlatformSource): Promise<void> {
|
||||||
|
if (!source.fetchPlaylists) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const discoveryResults = await source.fetchPlaylists(channel);
|
||||||
|
if (discoveryResults.length === 0) {
|
||||||
|
console.log(`[scheduler] Playlist sync: no playlists found for channel ${channel.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const upserted = await upsertPlaylists(this.db, channel.id, discoveryResults);
|
||||||
|
console.log(
|
||||||
|
`[scheduler] Playlist sync complete for channel ${channel.id}: ${upserted.length} playlists synced`
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`[scheduler] Playlist sync error for channel ${channel.id}:`,
|
||||||
|
err instanceof Error ? err.message : err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Internal ──
|
// ── Internal ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue