feat(S02/T02): Wired monitoringMode through channel creation (route sch…
- src/server/routes/channel.ts - src/frontend/src/api/hooks/useChannels.ts - src/frontend/src/components/AddChannelModal.tsx - src/__tests__/channel.test.ts
This commit is contained in:
parent
6715c9b6fe
commit
0ef34b1d21
4 changed files with 150 additions and 3 deletions
|
|
@ -260,6 +260,92 @@ describe('Channel API', () => {
|
||||||
|
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('creates channel with monitoringMode=future and monitoringEnabled=true', async () => {
|
||||||
|
execYtDlpMock.mockResolvedValueOnce({
|
||||||
|
stdout: JSON.stringify({
|
||||||
|
channel: 'Future Only Channel',
|
||||||
|
channel_id: 'UC_FUTURE_001',
|
||||||
|
channel_url: 'https://www.youtube.com/@FutureOnly',
|
||||||
|
uploader: 'Future Only Channel',
|
||||||
|
thumbnails: [{ url: 'https://yt.com/future_thumb.jpg' }],
|
||||||
|
}),
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/channel',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: {
|
||||||
|
url: 'https://www.youtube.com/@FutureOnly',
|
||||||
|
monitoringMode: 'future',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.monitoringMode).toBe('future');
|
||||||
|
expect(body.monitoringEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates channel with monitoringMode=none and monitoringEnabled=false', async () => {
|
||||||
|
execYtDlpMock.mockResolvedValueOnce({
|
||||||
|
stdout: JSON.stringify({
|
||||||
|
channel: 'No Monitor Channel',
|
||||||
|
channel_id: 'UC_NOMONITOR_001',
|
||||||
|
channel_url: 'https://www.youtube.com/@NoMonitor',
|
||||||
|
uploader: 'No Monitor Channel',
|
||||||
|
thumbnails: [{ url: 'https://yt.com/nomonitor_thumb.jpg' }],
|
||||||
|
}),
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/channel',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: {
|
||||||
|
url: 'https://www.youtube.com/@NoMonitor',
|
||||||
|
monitoringMode: 'none',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.monitoringMode).toBe('none');
|
||||||
|
expect(body.monitoringEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults monitoringMode to all when not specified', async () => {
|
||||||
|
execYtDlpMock.mockResolvedValueOnce({
|
||||||
|
stdout: JSON.stringify({
|
||||||
|
channel: 'Default Mode Channel',
|
||||||
|
channel_id: 'UC_DEFAULTMODE_001',
|
||||||
|
channel_url: 'https://www.youtube.com/@DefaultMode',
|
||||||
|
uploader: 'Default Mode Channel',
|
||||||
|
thumbnails: [{ url: 'https://yt.com/defaultmode_thumb.jpg' }],
|
||||||
|
}),
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/channel',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: {
|
||||||
|
url: 'https://www.youtube.com/@DefaultMode',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.monitoringMode).toBe('all');
|
||||||
|
expect(body.monitoringEnabled).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── GET /api/v1/channel ──
|
// ── GET /api/v1/channel ──
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ interface CreateChannelInput {
|
||||||
url: string;
|
url: string;
|
||||||
checkInterval?: number;
|
checkInterval?: number;
|
||||||
monitoringEnabled?: boolean;
|
monitoringEnabled?: boolean;
|
||||||
|
monitoringMode?: string;
|
||||||
formatProfileId?: number;
|
formatProfileId?: number;
|
||||||
grabAll?: boolean;
|
grabAll?: boolean;
|
||||||
grabAllOrder?: 'newest' | 'oldest';
|
grabAllOrder?: 'newest' | 'oldest';
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Modal } from './Modal';
|
||||||
import { useCreateChannel } from '../api/hooks/useChannels';
|
import { useCreateChannel } from '../api/hooks/useChannels';
|
||||||
import { usePlatformSettings } from '../api/hooks/usePlatformSettings';
|
import { usePlatformSettings } from '../api/hooks/usePlatformSettings';
|
||||||
import { useFormatProfiles } from '../api/hooks/useFormatProfiles';
|
import { useFormatProfiles } from '../api/hooks/useFormatProfiles';
|
||||||
|
import { apiClient } from '../api/client';
|
||||||
import { Loader, CheckCircle } from 'lucide-react';
|
import { Loader, CheckCircle } from 'lucide-react';
|
||||||
import type { Platform } from '@shared/types/index';
|
import type { Platform } from '@shared/types/index';
|
||||||
|
|
||||||
|
|
@ -49,6 +50,7 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
const [checkInterval, setCheckInterval] = useState('');
|
const [checkInterval, setCheckInterval] = useState('');
|
||||||
const [formatProfileId, setFormatProfileId] = useState<number | undefined>(undefined);
|
const [formatProfileId, setFormatProfileId] = useState<number | undefined>(undefined);
|
||||||
|
const [monitoringMode, setMonitoringMode] = useState<string>('all');
|
||||||
const [grabAll, setGrabAll] = useState(false);
|
const [grabAll, setGrabAll] = useState(false);
|
||||||
const [grabAllOrder, setGrabAllOrder] = useState<'newest' | 'oldest'>('newest');
|
const [grabAllOrder, setGrabAllOrder] = useState<'newest' | 'oldest'>('newest');
|
||||||
|
|
||||||
|
|
@ -76,6 +78,11 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
|
||||||
setFormatProfileId(settings.defaultFormatProfileId);
|
setFormatProfileId(settings.defaultFormatProfileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-fill monitoring mode from platform defaults
|
||||||
|
if (settings.defaultMonitoringMode) {
|
||||||
|
setMonitoringMode(settings.defaultMonitoringMode);
|
||||||
|
}
|
||||||
|
|
||||||
// Pre-fill grab-all defaults for YouTube
|
// Pre-fill grab-all defaults for YouTube
|
||||||
if (detectedPlatform === 'youtube') {
|
if (detectedPlatform === 'youtube') {
|
||||||
if (settings.grabAllEnabled) {
|
if (settings.grabAllEnabled) {
|
||||||
|
|
@ -95,14 +102,22 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
|
||||||
{
|
{
|
||||||
url: url.trim(),
|
url: url.trim(),
|
||||||
checkInterval: checkInterval ? parseInt(checkInterval, 10) : undefined,
|
checkInterval: checkInterval ? parseInt(checkInterval, 10) : undefined,
|
||||||
|
monitoringMode,
|
||||||
formatProfileId: formatProfileId ?? undefined,
|
formatProfileId: formatProfileId ?? undefined,
|
||||||
grabAll: detectedPlatform === 'youtube' ? grabAll : undefined,
|
grabAll: detectedPlatform === 'youtube' ? grabAll : undefined,
|
||||||
grabAllOrder: detectedPlatform === 'youtube' && grabAll ? grabAllOrder : undefined,
|
grabAllOrder: detectedPlatform === 'youtube' && grabAll ? grabAllOrder : undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: (newChannel) => {
|
||||||
resetForm();
|
resetForm();
|
||||||
onClose();
|
onClose();
|
||||||
|
// Fire-and-forget initial scan so content appears immediately
|
||||||
|
apiClient
|
||||||
|
.post(`/api/v1/channel/${newChannel.id}/scan`)
|
||||||
|
.catch(() => {
|
||||||
|
// Auto-scan failure is non-critical — channel was created successfully
|
||||||
|
console.warn(`[AddChannel] Auto-scan failed for channel ${newChannel.id}`);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -112,6 +127,7 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
|
||||||
setUrl('');
|
setUrl('');
|
||||||
setCheckInterval('');
|
setCheckInterval('');
|
||||||
setFormatProfileId(undefined);
|
setFormatProfileId(undefined);
|
||||||
|
setMonitoringMode('all');
|
||||||
setGrabAll(false);
|
setGrabAll(false);
|
||||||
setGrabAllOrder('newest');
|
setGrabAllOrder('newest');
|
||||||
createChannel.reset();
|
createChannel.reset();
|
||||||
|
|
@ -232,6 +248,35 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Monitoring Mode — shown when platform detected */}
|
||||||
|
{detectedPlatform && (
|
||||||
|
<div style={{ marginBottom: 'var(--space-4)' }}>
|
||||||
|
<label
|
||||||
|
htmlFor="monitoring-mode"
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: 'var(--space-1)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Monitoring Mode
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="monitoring-mode"
|
||||||
|
value={monitoringMode}
|
||||||
|
onChange={(e) => setMonitoringMode(e.target.value)}
|
||||||
|
disabled={createChannel.isPending}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
<option value="all">Monitor All</option>
|
||||||
|
<option value="future">Future Only</option>
|
||||||
|
<option value="none">None</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Grab All — YouTube only */}
|
{/* Grab All — YouTube only */}
|
||||||
{detectedPlatform === 'youtube' && (
|
{detectedPlatform === 'youtube' && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ const createChannelBodySchema = {
|
||||||
url: { type: 'string' as const },
|
url: { type: 'string' as const },
|
||||||
checkInterval: { type: 'number' as const, minimum: 1 },
|
checkInterval: { type: 'number' as const, minimum: 1 },
|
||||||
monitoringEnabled: { type: 'boolean' as const },
|
monitoringEnabled: { type: 'boolean' as const },
|
||||||
|
monitoringMode: { type: 'string' as const, enum: ['all', 'future', 'existing', 'none'] },
|
||||||
formatProfileId: { type: 'number' as const },
|
formatProfileId: { type: 'number' as const },
|
||||||
grabAll: { type: 'boolean' as const },
|
grabAll: { type: 'boolean' as const },
|
||||||
grabAllOrder: { type: 'string' as const, enum: ['newest', 'oldest'] },
|
grabAllOrder: { type: 'string' as const, enum: ['newest', 'oldest'] },
|
||||||
|
|
@ -89,6 +90,7 @@ export async function channelRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
url: string;
|
url: string;
|
||||||
checkInterval?: number;
|
checkInterval?: number;
|
||||||
monitoringEnabled?: boolean;
|
monitoringEnabled?: boolean;
|
||||||
|
monitoringMode?: MonitoringMode;
|
||||||
formatProfileId?: number;
|
formatProfileId?: number;
|
||||||
grabAll?: boolean;
|
grabAll?: boolean;
|
||||||
grabAllOrder?: 'newest' | 'oldest';
|
grabAllOrder?: 'newest' | 'oldest';
|
||||||
|
|
@ -99,7 +101,7 @@ export async function channelRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
schema: { body: createChannelBodySchema },
|
schema: { body: createChannelBodySchema },
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const { url, checkInterval, monitoringEnabled, formatProfileId, grabAll, grabAllOrder } = request.body;
|
const { url, checkInterval, monitoringEnabled, monitoringMode, formatProfileId, grabAll, grabAllOrder } = request.body;
|
||||||
|
|
||||||
// Validate URL against registered platforms
|
// Validate URL against registered platforms
|
||||||
const match = registry.getForUrl(url);
|
const match = registry.getForUrl(url);
|
||||||
|
|
@ -150,13 +152,26 @@ export async function channelRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
10
|
10
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Derive monitoringEnabled from monitoringMode when provided;
|
||||||
|
// preserve backward compat when only monitoringEnabled is sent
|
||||||
|
const resolvedMode = monitoringMode ?? 'all';
|
||||||
|
const resolvedEnabled = monitoringMode
|
||||||
|
? monitoringMode !== 'none'
|
||||||
|
: (monitoringEnabled ?? true);
|
||||||
|
|
||||||
|
request.log.info(
|
||||||
|
{ resolvedMode, resolvedEnabled, platform: metadata.platform },
|
||||||
|
'[channel] Creating channel with monitoring mode'
|
||||||
|
);
|
||||||
|
|
||||||
// Insert channel
|
// Insert channel
|
||||||
const channel = await createChannel(fastify.db, {
|
const channel = await createChannel(fastify.db, {
|
||||||
name: metadata.name,
|
name: metadata.name,
|
||||||
platform: metadata.platform,
|
platform: metadata.platform,
|
||||||
platformId: metadata.platformId,
|
platformId: metadata.platformId,
|
||||||
url: metadata.url,
|
url: metadata.url,
|
||||||
monitoringEnabled: monitoringEnabled ?? true,
|
monitoringEnabled: resolvedEnabled,
|
||||||
|
monitoringMode: resolvedMode,
|
||||||
checkInterval: checkInterval ?? defaultInterval,
|
checkInterval: checkInterval ?? defaultInterval,
|
||||||
imageUrl: metadata.imageUrl,
|
imageUrl: metadata.imageUrl,
|
||||||
metadata: null,
|
metadata: null,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue