feat: Add File Organization settings section with output template input…
- "src/frontend/src/pages/Settings.tsx" - "src/frontend/src/components/FormatProfileForm.tsx" - "src/frontend/src/api/hooks/useFormatProfiles.ts" - "src/types/api.ts" - "src/server/routes/system.ts" GSD-Task: S02/T04
This commit is contained in:
parent
fb731377bd
commit
3bfdb7b634
5 changed files with 263 additions and 8 deletions
|
|
@ -29,6 +29,7 @@ interface CreateFormatProfileInput {
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
subtitleLanguages?: string | null;
|
subtitleLanguages?: string | null;
|
||||||
embedSubtitles?: boolean;
|
embedSubtitles?: boolean;
|
||||||
|
outputTemplate?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateFormatProfileInput {
|
interface UpdateFormatProfileInput {
|
||||||
|
|
@ -40,6 +41,7 @@ interface UpdateFormatProfileInput {
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
subtitleLanguages?: string | null;
|
subtitleLanguages?: string | null;
|
||||||
embedSubtitles?: boolean;
|
embedSubtitles?: boolean;
|
||||||
|
outputTemplate?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mutations ──
|
// ── Mutations ──
|
||||||
|
|
|
||||||
|
|
@ -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 { Loader } from 'lucide-react';
|
||||||
import type { FormatProfile } from '@shared/types/index';
|
import type { FormatProfile } from '@shared/types/index';
|
||||||
|
|
||||||
|
|
@ -23,6 +23,7 @@ export interface FormatProfileFormValues {
|
||||||
embedChapters: boolean;
|
embedChapters: boolean;
|
||||||
embedThumbnail: boolean;
|
embedThumbnail: boolean;
|
||||||
sponsorBlockRemove: string | null;
|
sponsorBlockRemove: string | null;
|
||||||
|
outputTemplate: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormatProfileFormProps {
|
interface FormatProfileFormProps {
|
||||||
|
|
@ -98,6 +99,38 @@ export function FormatProfileForm({
|
||||||
const [embedChapters, setEmbedChapters] = useState(profile?.embedChapters ?? false);
|
const [embedChapters, setEmbedChapters] = useState(profile?.embedChapters ?? false);
|
||||||
const [embedThumbnail, setEmbedThumbnail] = useState(profile?.embedThumbnail ?? false);
|
const [embedThumbnail, setEmbedThumbnail] = useState(profile?.embedThumbnail ?? false);
|
||||||
const [sponsorBlockRemove, setSponsorBlockRemove] = useState(profile?.sponsorBlockRemove ?? '');
|
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<string, string> = {
|
||||||
|
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(
|
const handleSubmit = useCallback(
|
||||||
(e: FormEvent) => {
|
(e: FormEvent) => {
|
||||||
|
|
@ -115,9 +148,10 @@ export function FormatProfileForm({
|
||||||
embedChapters,
|
embedChapters,
|
||||||
embedThumbnail,
|
embedThumbnail,
|
||||||
sponsorBlockRemove: sponsorBlockRemove.trim() || null,
|
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 (
|
return (
|
||||||
|
|
@ -312,6 +346,39 @@ export function FormatProfileForm({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Output Template (per-profile override) */}
|
||||||
|
<div style={fieldGroupStyle}>
|
||||||
|
<label htmlFor="fp-output-template" style={labelStyle}>
|
||||||
|
Output Template Override
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="fp-output-template"
|
||||||
|
type="text"
|
||||||
|
value={outputTemplate}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--danger)', marginTop: 'var(--space-1)', display: 'block' }}>
|
||||||
|
{templateErrors.join('. ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{templatePreview && templateErrors.length === 0 && (
|
||||||
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginTop: 'var(--space-1)', display: 'block', fontFamily: 'var(--font-mono, "SF Mono", "Fira Code", monospace)' }}>
|
||||||
|
Preview: {templatePreview}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginTop: 'var(--space-1)', display: 'block' }}>
|
||||||
|
Variables: {'{platform}'}, {'{channel}'}, {'{title}'}, {'{date}'}, {'{year}'}, {'{month}'}, {'{contentType}'}, {'{id}'}, {'{ext}'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Is Default checkbox */}
|
{/* Is Default checkbox */}
|
||||||
<div style={{ ...fieldGroupStyle, display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
<div style={{ ...fieldGroupStyle, display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||||
import { Plus, Pencil, Trash2, Loader, RefreshCw, Star, Bell, Send, CheckCircle, XCircle, Eye, EyeOff, Copy, RotateCw, Key, Globe, Save } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Loader, RefreshCw, Star, Bell, Send, CheckCircle, XCircle, Eye, EyeOff, Copy, RotateCw, Key, Globe, Save, FolderTree } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useFormatProfiles,
|
useFormatProfiles,
|
||||||
useCreateFormatProfile,
|
useCreateFormatProfile,
|
||||||
|
|
@ -83,6 +83,7 @@ export function SettingsPage() {
|
||||||
const updateAppSettingsMutation = useUpdateAppSettings();
|
const updateAppSettingsMutation = useUpdateAppSettings();
|
||||||
const [checkInterval, setCheckInterval] = useState<number | ''>('');
|
const [checkInterval, setCheckInterval] = useState<number | ''>('');
|
||||||
const [concurrentDownloads, setConcurrentDownloads] = useState<number | ''>('');
|
const [concurrentDownloads, setConcurrentDownloads] = useState<number | ''>('');
|
||||||
|
const [outputTemplate, setOutputTemplate] = useState('');
|
||||||
const [settingsSaveFlash, setSettingsSaveFlash] = useState(false);
|
const [settingsSaveFlash, setSettingsSaveFlash] = useState(false);
|
||||||
|
|
||||||
// Initialize local state from fetched app settings
|
// Initialize local state from fetched app settings
|
||||||
|
|
@ -90,6 +91,7 @@ export function SettingsPage() {
|
||||||
if (appSettings) {
|
if (appSettings) {
|
||||||
setCheckInterval(appSettings.checkInterval);
|
setCheckInterval(appSettings.checkInterval);
|
||||||
setConcurrentDownloads(appSettings.concurrentDownloads);
|
setConcurrentDownloads(appSettings.concurrentDownloads);
|
||||||
|
setOutputTemplate(appSettings.outputTemplate);
|
||||||
}
|
}
|
||||||
}, [appSettings]);
|
}, [appSettings]);
|
||||||
|
|
||||||
|
|
@ -98,14 +100,48 @@ export function SettingsPage() {
|
||||||
concurrentDownloads !== '' &&
|
concurrentDownloads !== '' &&
|
||||||
appSettings != null &&
|
appSettings != null &&
|
||||||
(Number(checkInterval) !== appSettings.checkInterval ||
|
(Number(checkInterval) !== appSettings.checkInterval ||
|
||||||
Number(concurrentDownloads) !== appSettings.concurrentDownloads);
|
Number(concurrentDownloads) !== appSettings.concurrentDownloads ||
|
||||||
|
outputTemplate !== appSettings.outputTemplate);
|
||||||
|
|
||||||
const settingsValid =
|
const settingsValid =
|
||||||
checkInterval !== '' &&
|
checkInterval !== '' &&
|
||||||
concurrentDownloads !== '' &&
|
concurrentDownloads !== '' &&
|
||||||
Number(checkInterval) >= 1 &&
|
Number(checkInterval) >= 1 &&
|
||||||
Number(concurrentDownloads) >= 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<string, string> = {
|
||||||
|
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 ──
|
// ── App Settings handlers ──
|
||||||
|
|
||||||
|
|
@ -115,6 +151,7 @@ export function SettingsPage() {
|
||||||
{
|
{
|
||||||
checkInterval: Number(checkInterval),
|
checkInterval: Number(checkInterval),
|
||||||
concurrentDownloads: Number(concurrentDownloads),
|
concurrentDownloads: Number(concurrentDownloads),
|
||||||
|
outputTemplate,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|
@ -836,6 +873,134 @@ export function SettingsPage() {
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* ── File Organization section ── */}
|
||||||
|
<section style={{ marginBottom: 'var(--space-8)' }}>
|
||||||
|
<div style={{ marginBottom: 'var(--space-4)' }}>
|
||||||
|
<h2 style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', fontSize: 'var(--font-size-lg)', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||||
|
<FolderTree size={20} />
|
||||||
|
File Organization
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: 'var(--text-muted)', fontSize: 'var(--font-size-sm)', marginTop: 'var(--space-1)' }}>
|
||||||
|
Configure the default output path template for downloaded files. Individual format profiles can override this.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-card)',
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
padding: 'var(--space-4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Template input */}
|
||||||
|
<div style={{ marginBottom: 'var(--space-4)' }}>
|
||||||
|
<label
|
||||||
|
htmlFor="output-template"
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
marginBottom: 'var(--space-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Output Template
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="output-template"
|
||||||
|
type="text"
|
||||||
|
value={outputTemplate}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<div style={{ color: 'var(--danger)', fontSize: 'var(--font-size-xs)', marginTop: 'var(--space-1)' }}>
|
||||||
|
{templateErrors.join('. ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Available variables */}
|
||||||
|
<div style={{ marginBottom: 'var(--space-4)' }}>
|
||||||
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginBottom: 'var(--space-2)', display: 'block' }}>
|
||||||
|
Available variables
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-1)' }}>
|
||||||
|
{TEMPLATE_VARIABLES.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const input = document.getElementById('output-template') as HTMLInputElement | null;
|
||||||
|
if (input) {
|
||||||
|
const start = input.selectionStart ?? outputTemplate.length;
|
||||||
|
const end = input.selectionEnd ?? outputTemplate.length;
|
||||||
|
const newVal = outputTemplate.slice(0, start) + `{${v}}` + outputTemplate.slice(end);
|
||||||
|
setOutputTemplate(newVal);
|
||||||
|
// Restore cursor position after React re-render
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
input.focus();
|
||||||
|
const newPos = start + v.length + 2;
|
||||||
|
input.setSelectionRange(newPos, newPos);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setOutputTemplate((prev) => prev + `{${v}}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
backgroundColor: 'var(--bg-hover)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
fontFamily: 'var(--font-mono, "SF Mono", "Fira Code", monospace)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
title={`Insert {${v}} at cursor`}
|
||||||
|
>
|
||||||
|
{`{${v}}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live preview */}
|
||||||
|
<div>
|
||||||
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginBottom: 'var(--space-1)', display: 'block' }}>
|
||||||
|
Preview
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--bg-main)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
fontFamily: 'var(--font-mono, "SF Mono", "Fira Code", monospace)',
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{templatePreview || '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* ── Platform Settings section ── */}
|
{/* ── Platform Settings section ── */}
|
||||||
<section style={{ marginBottom: 'var(--space-8)' }}>
|
<section style={{ marginBottom: 'var(--space-8)' }}>
|
||||||
<div style={{ marginBottom: 'var(--space-4)' }}>
|
<div style={{ marginBottom: 'var(--space-4)' }}>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
setAppSetting,
|
setAppSetting,
|
||||||
APP_CHECK_INTERVAL,
|
APP_CHECK_INTERVAL,
|
||||||
APP_CONCURRENT_DOWNLOADS,
|
APP_CONCURRENT_DOWNLOADS,
|
||||||
|
APP_OUTPUT_TEMPLATE,
|
||||||
YTDLP_LAST_UPDATED,
|
YTDLP_LAST_UPDATED,
|
||||||
} from '../../db/repositories/system-config-repository';
|
} from '../../db/repositories/system-config-repository';
|
||||||
import { getYtDlpVersion, updateYtDlp } from '../../sources/yt-dlp';
|
import { getYtDlpVersion, updateYtDlp } from '../../sources/yt-dlp';
|
||||||
|
|
@ -114,11 +115,12 @@ export async function systemRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
*/
|
*/
|
||||||
fastify.get('/api/v1/system/settings', async (_request, _reply) => {
|
fastify.get('/api/v1/system/settings', async (_request, _reply) => {
|
||||||
const db = fastify.db;
|
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 = {
|
const response: AppSettingsResponse = {
|
||||||
checkInterval: parseInt(settings[APP_CHECK_INTERVAL] ?? '360', 10),
|
checkInterval: parseInt(settings[APP_CHECK_INTERVAL] ?? '360', 10),
|
||||||
concurrentDownloads: parseInt(settings[APP_CONCURRENT_DOWNLOADS] ?? '2', 10),
|
concurrentDownloads: parseInt(settings[APP_CONCURRENT_DOWNLOADS] ?? '2', 10),
|
||||||
|
outputTemplate: settings[APP_OUTPUT_TEMPLATE] ?? '{platform}/{channel}/{title}.{ext}',
|
||||||
};
|
};
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|
@ -131,7 +133,7 @@ export async function systemRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
*/
|
*/
|
||||||
fastify.put('/api/v1/system/settings', async (request, reply) => {
|
fastify.put('/api/v1/system/settings', async (request, reply) => {
|
||||||
const db = fastify.db;
|
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
|
// Validate
|
||||||
if (body.checkInterval !== undefined) {
|
if (body.checkInterval !== undefined) {
|
||||||
|
|
@ -172,12 +174,30 @@ export async function systemRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.queueService.setConcurrency(body.concurrentDownloads);
|
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
|
// 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 = {
|
const response: AppSettingsResponse = {
|
||||||
checkInterval: parseInt(settings[APP_CHECK_INTERVAL] ?? '360', 10),
|
checkInterval: parseInt(settings[APP_CHECK_INTERVAL] ?? '360', 10),
|
||||||
concurrentDownloads: parseInt(settings[APP_CONCURRENT_DOWNLOADS] ?? '2', 10),
|
concurrentDownloads: parseInt(settings[APP_CONCURRENT_DOWNLOADS] ?? '2', 10),
|
||||||
|
outputTemplate: settings[APP_OUTPUT_TEMPLATE] ?? '{platform}/{channel}/{title}.{ext}',
|
||||||
};
|
};
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ export interface ContentCounts {
|
||||||
export interface AppSettingsResponse {
|
export interface AppSettingsResponse {
|
||||||
checkInterval: number;
|
checkInterval: number;
|
||||||
concurrentDownloads: number;
|
concurrentDownloads: number;
|
||||||
|
outputTemplate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Channel with aggregated content counts — returned by GET /api/v1/channel. */
|
/** Channel with aggregated content counts — returned by GET /api/v1/channel. */
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue