diff --git a/src/frontend/src/api/hooks/useFormatProfiles.ts b/src/frontend/src/api/hooks/useFormatProfiles.ts index 90fedef..bd5d17e 100644 --- a/src/frontend/src/api/hooks/useFormatProfiles.ts +++ b/src/frontend/src/api/hooks/useFormatProfiles.ts @@ -29,6 +29,7 @@ interface CreateFormatProfileInput { isDefault?: boolean; subtitleLanguages?: string | null; embedSubtitles?: boolean; + outputTemplate?: string | null; } interface UpdateFormatProfileInput { @@ -40,6 +41,7 @@ interface UpdateFormatProfileInput { isDefault?: boolean; subtitleLanguages?: string | null; embedSubtitles?: boolean; + outputTemplate?: string | null; } // ── Mutations ── diff --git a/src/frontend/src/components/FormatProfileForm.tsx b/src/frontend/src/components/FormatProfileForm.tsx index 5fc6048..2333669 100644 --- a/src/frontend/src/components/FormatProfileForm.tsx +++ b/src/frontend/src/components/FormatProfileForm.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, type FormEvent } from 'react'; +import { useState, useCallback, useMemo, type FormEvent } from 'react'; import { Loader } from 'lucide-react'; import type { FormatProfile } from '@shared/types/index'; @@ -23,6 +23,7 @@ export interface FormatProfileFormValues { embedChapters: boolean; embedThumbnail: boolean; sponsorBlockRemove: string | null; + outputTemplate: string | null; } interface FormatProfileFormProps { @@ -98,6 +99,38 @@ export function FormatProfileForm({ const [embedChapters, setEmbedChapters] = useState(profile?.embedChapters ?? false); const [embedThumbnail, setEmbedThumbnail] = useState(profile?.embedThumbnail ?? false); const [sponsorBlockRemove, setSponsorBlockRemove] = useState(profile?.sponsorBlockRemove ?? ''); + const [outputTemplate, setOutputTemplate] = useState(profile?.outputTemplate ?? ''); + + const TEMPLATE_VARIABLES = ['platform', 'channel', 'title', 'date', 'year', 'month', 'contentType', 'id', 'ext'] as const; + + const templatePreview = useMemo(() => { + if (!outputTemplate.trim()) return ''; + const exampleVars: Record = { + platform: 'youtube', + channel: 'TechChannel', + title: 'How to Build a Server', + date: '2026-04-04', + year: '2026', + month: '04', + contentType: 'video', + id: 'dQw4w9WgXcQ', + ext: 'mp4', + }; + return outputTemplate.replace(/\{([a-zA-Z]+)\}/g, (_m, v: string) => exampleVars[v] ?? `{${v}}`); + }, [outputTemplate]); + + const templateErrors = useMemo(() => { + if (!outputTemplate.trim()) return []; // empty = use system default + const errors: string[] = []; + if (!outputTemplate.includes('{ext}')) errors.push('Must contain {ext}'); + const matches = [...outputTemplate.matchAll(/\{([a-zA-Z]+)\}/g)]; + for (const m of matches) { + if (!(TEMPLATE_VARIABLES as readonly string[]).includes(m[1])) { + errors.push(`Unknown variable: {${m[1]}}`); + } + } + return errors; + }, [outputTemplate]); const handleSubmit = useCallback( (e: FormEvent) => { @@ -115,9 +148,10 @@ export function FormatProfileForm({ embedChapters, embedThumbnail, sponsorBlockRemove: sponsorBlockRemove.trim() || null, + outputTemplate: outputTemplate.trim() || null, }); }, - [name, videoResolution, audioCodec, audioBitrate, containerFormat, isDefault, subtitleLanguages, embedSubtitles, embedChapters, embedThumbnail, sponsorBlockRemove, onSubmit], + [name, videoResolution, audioCodec, audioBitrate, containerFormat, isDefault, subtitleLanguages, embedSubtitles, embedChapters, embedThumbnail, sponsorBlockRemove, outputTemplate, onSubmit], ); return ( @@ -312,6 +346,39 @@ export function FormatProfileForm({ + {/* Output Template (per-profile override) */} +
+ + setOutputTemplate(e.target.value)} + placeholder="Leave blank to use system default" + style={{ + ...inputStyle, + fontFamily: 'var(--font-mono, "SF Mono", "Fira Code", monospace)', + fontSize: 'var(--font-size-sm)', + borderColor: templateErrors.length > 0 ? 'var(--danger)' : undefined, + }} + /> + {templateErrors.length > 0 && ( + + {templateErrors.join('. ')} + + )} + {templatePreview && templateErrors.length === 0 && ( + + Preview: {templatePreview} + + )} + + Variables: {'{platform}'}, {'{channel}'}, {'{title}'}, {'{date}'}, {'{year}'}, {'{month}'}, {'{contentType}'}, {'{id}'}, {'{ext}'} + +
+ {/* Is Default checkbox */}
(''); const [concurrentDownloads, setConcurrentDownloads] = useState(''); + const [outputTemplate, setOutputTemplate] = useState(''); const [settingsSaveFlash, setSettingsSaveFlash] = useState(false); // Initialize local state from fetched app settings @@ -90,6 +91,7 @@ export function SettingsPage() { if (appSettings) { setCheckInterval(appSettings.checkInterval); setConcurrentDownloads(appSettings.concurrentDownloads); + setOutputTemplate(appSettings.outputTemplate); } }, [appSettings]); @@ -98,14 +100,48 @@ export function SettingsPage() { concurrentDownloads !== '' && appSettings != null && (Number(checkInterval) !== appSettings.checkInterval || - Number(concurrentDownloads) !== appSettings.concurrentDownloads); + Number(concurrentDownloads) !== appSettings.concurrentDownloads || + outputTemplate !== appSettings.outputTemplate); const settingsValid = checkInterval !== '' && concurrentDownloads !== '' && Number(checkInterval) >= 1 && Number(concurrentDownloads) >= 1 && - Number(concurrentDownloads) <= 10; + Number(concurrentDownloads) <= 10 && + outputTemplate.trim().length > 0 && + outputTemplate.includes('{ext}'); + + // ── Template preview ── + const TEMPLATE_VARIABLES = ['platform', 'channel', 'title', 'date', 'year', 'month', 'contentType', 'id', 'ext'] as const; + + const templatePreview = useMemo(() => { + const exampleVars: Record = { + platform: 'youtube', + channel: 'TechChannel', + title: 'How to Build a Server', + date: '2026-04-04', + year: '2026', + month: '04', + contentType: 'video', + id: 'dQw4w9WgXcQ', + ext: 'mp4', + }; + return outputTemplate.replace(/\{([a-zA-Z]+)\}/g, (_m, v: string) => exampleVars[v] ?? `{${v}}`); + }, [outputTemplate]); + + const templateErrors = useMemo(() => { + const errors: string[] = []; + if (outputTemplate.trim().length === 0) return ['Template must not be empty']; + if (!outputTemplate.includes('{ext}')) errors.push('Must contain {ext}'); + const matches = [...outputTemplate.matchAll(/\{([a-zA-Z]+)\}/g)]; + for (const m of matches) { + if (!(TEMPLATE_VARIABLES as readonly string[]).includes(m[1])) { + errors.push(`Unknown variable: {${m[1]}}`); + } + } + return errors; + }, [outputTemplate]); // ── App Settings handlers ── @@ -115,6 +151,7 @@ export function SettingsPage() { { checkInterval: Number(checkInterval), concurrentDownloads: Number(concurrentDownloads), + outputTemplate, }, { onSuccess: () => { @@ -836,6 +873,134 @@ export function SettingsPage() {

+ {/* ── File Organization section ── */} +
+
+

+ + File Organization +

+

+ Configure the default output path template for downloaded files. Individual format profiles can override this. +

+
+ +
+ {/* Template input */} +
+ + setOutputTemplate(e.target.value)} + placeholder="{platform}/{channel}/{title}.{ext}" + aria-label="Output path template" + style={{ + width: '100%', + padding: 'var(--space-2) var(--space-3)', + borderRadius: 'var(--radius-md)', + border: `1px solid ${templateErrors.length > 0 ? 'var(--danger)' : 'var(--border)'}`, + backgroundColor: 'var(--bg-main)', + color: 'var(--text-primary)', + fontSize: 'var(--font-size-sm)', + fontFamily: 'var(--font-mono, "SF Mono", "Fira Code", monospace)', + }} + /> + {templateErrors.length > 0 && ( +
+ {templateErrors.join('. ')} +
+ )} +
+ + {/* Available variables */} +
+ + Available variables + +
+ {TEMPLATE_VARIABLES.map((v) => ( + + ))} +
+
+ + {/* Live preview */} +
+ + Preview + +
+ {templatePreview || '—'} +
+
+
+
+ {/* ── Platform Settings section ── */}
diff --git a/src/server/routes/system.ts b/src/server/routes/system.ts index 36287cd..3a8d8f7 100644 --- a/src/server/routes/system.ts +++ b/src/server/routes/system.ts @@ -14,6 +14,7 @@ import { setAppSetting, APP_CHECK_INTERVAL, APP_CONCURRENT_DOWNLOADS, + APP_OUTPUT_TEMPLATE, YTDLP_LAST_UPDATED, } from '../../db/repositories/system-config-repository'; import { getYtDlpVersion, updateYtDlp } from '../../sources/yt-dlp'; @@ -114,11 +115,12 @@ export async function systemRoutes(fastify: FastifyInstance): Promise { */ fastify.get('/api/v1/system/settings', async (_request, _reply) => { const db = fastify.db; - const settings = await getAppSettings(db, [APP_CHECK_INTERVAL, APP_CONCURRENT_DOWNLOADS]); + const settings = await getAppSettings(db, [APP_CHECK_INTERVAL, APP_CONCURRENT_DOWNLOADS, APP_OUTPUT_TEMPLATE]); const response: AppSettingsResponse = { checkInterval: parseInt(settings[APP_CHECK_INTERVAL] ?? '360', 10), concurrentDownloads: parseInt(settings[APP_CONCURRENT_DOWNLOADS] ?? '2', 10), + outputTemplate: settings[APP_OUTPUT_TEMPLATE] ?? '{platform}/{channel}/{title}.{ext}', }; return response; @@ -131,7 +133,7 @@ export async function systemRoutes(fastify: FastifyInstance): Promise { */ fastify.put('/api/v1/system/settings', async (request, reply) => { const db = fastify.db; - const body = request.body as { checkInterval?: number; concurrentDownloads?: number }; + const body = request.body as { checkInterval?: number; concurrentDownloads?: number; outputTemplate?: string }; // Validate if (body.checkInterval !== undefined) { @@ -172,12 +174,30 @@ export async function systemRoutes(fastify: FastifyInstance): Promise { fastify.queueService.setConcurrency(body.concurrentDownloads); } } + if (body.outputTemplate !== undefined) { + if (typeof body.outputTemplate !== 'string' || body.outputTemplate.trim().length === 0) { + return reply.status(400).send({ + statusCode: 400, + error: 'Bad Request', + message: 'outputTemplate must be a non-empty string', + }); + } + if (!body.outputTemplate.includes('{ext}')) { + return reply.status(400).send({ + statusCode: 400, + error: 'Bad Request', + message: 'outputTemplate must contain {ext} for the file extension', + }); + } + await setAppSetting(db, APP_OUTPUT_TEMPLATE, body.outputTemplate); + } // Return updated values - const settings = await getAppSettings(db, [APP_CHECK_INTERVAL, APP_CONCURRENT_DOWNLOADS]); + const settings = await getAppSettings(db, [APP_CHECK_INTERVAL, APP_CONCURRENT_DOWNLOADS, APP_OUTPUT_TEMPLATE]); const response: AppSettingsResponse = { checkInterval: parseInt(settings[APP_CHECK_INTERVAL] ?? '360', 10), concurrentDownloads: parseInt(settings[APP_CONCURRENT_DOWNLOADS] ?? '2', 10), + outputTemplate: settings[APP_OUTPUT_TEMPLATE] ?? '{platform}/{channel}/{title}.{ext}', }; return response; diff --git a/src/types/api.ts b/src/types/api.ts index 0ba6f94..7999ed8 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -74,6 +74,7 @@ export interface ContentCounts { export interface AppSettingsResponse { checkInterval: number; concurrentDownloads: number; + outputTemplate: string; } /** Channel with aggregated content counts — returned by GET /api/v1/channel. */