feat: Add Generic platform, per-platform NFO toggle, and default view s…
- "src/db/schema/platform-settings.ts" - "drizzle/0018_platform_settings_nfo_view.sql" - "src/types/index.ts" - "src/db/repositories/platform-settings-repository.ts" - "src/server/routes/platform-settings.ts" - "src/frontend/src/components/PlatformSettingsForm.tsx" - "src/frontend/src/pages/Settings.tsx" - "src/__tests__/platform-settings-api.test.ts" GSD-Task: S04/T01
This commit is contained in:
parent
4cabcfbb4c
commit
0f42a4b269
11 changed files with 1402 additions and 99 deletions
2
drizzle/0018_platform_settings_nfo_view.sql
Normal file
2
drizzle/0018_platform_settings_nfo_view.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE `platform_settings` ADD `nfo_enabled` integer NOT NULL DEFAULT false;--> statement-breakpoint
|
||||||
|
ALTER TABLE `platform_settings` ADD `default_view` text NOT NULL DEFAULT 'list';
|
||||||
1147
drizzle/meta/0018_snapshot.json
Normal file
1147
drizzle/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -127,6 +127,13 @@
|
||||||
"when": 1775282773898,
|
"when": 1775282773898,
|
||||||
"tag": "0017_wild_havok",
|
"tag": "0017_wild_havok",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1775520100000,
|
||||||
|
"tag": "0018_platform_settings_nfo_view",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -344,6 +344,137 @@ describe('Platform Settings API', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Generic platform ──
|
||||||
|
|
||||||
|
describe('Generic platform', () => {
|
||||||
|
it('accepts generic as a valid platform', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/platform-settings/generic',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: {
|
||||||
|
checkInterval: 480,
|
||||||
|
concurrencyLimit: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.platform).toBe('generic');
|
||||||
|
expect(body.checkInterval).toBe(480);
|
||||||
|
expect(body.nfoEnabled).toBe(false);
|
||||||
|
expect(body.defaultView).toBe('list');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows generic in list of all platform settings', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/platform-settings',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const platforms = res.json().map((s: { platform: string }) => s.platform);
|
||||||
|
expect(platforms).toContain('generic');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── nfoEnabled ──
|
||||||
|
|
||||||
|
describe('nfoEnabled', () => {
|
||||||
|
it('persists nfoEnabled through PUT → GET round-trip', async () => {
|
||||||
|
const putRes = await server.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/platform-settings/youtube',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: {
|
||||||
|
nfoEnabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(putRes.statusCode).toBe(200);
|
||||||
|
expect(putRes.json().nfoEnabled).toBe(true);
|
||||||
|
|
||||||
|
const getRes = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/platform-settings/youtube',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
});
|
||||||
|
expect(getRes.statusCode).toBe(200);
|
||||||
|
expect(getRes.json().nfoEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults nfoEnabled to false when not specified', async () => {
|
||||||
|
await server.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/platform-settings/generic',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
const putRes = await server.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/platform-settings/generic',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { checkInterval: 360 },
|
||||||
|
});
|
||||||
|
expect(putRes.statusCode).toBe(200);
|
||||||
|
expect(putRes.json().nfoEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── defaultView ──
|
||||||
|
|
||||||
|
describe('defaultView', () => {
|
||||||
|
it('persists defaultView through PUT → GET round-trip', async () => {
|
||||||
|
const putRes = await server.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/platform-settings/youtube',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: {
|
||||||
|
defaultView: 'poster',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(putRes.statusCode).toBe(200);
|
||||||
|
expect(putRes.json().defaultView).toBe('poster');
|
||||||
|
|
||||||
|
const getRes = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/platform-settings/youtube',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
});
|
||||||
|
expect(getRes.statusCode).toBe(200);
|
||||||
|
expect(getRes.json().defaultView).toBe('poster');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid defaultView value', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/platform-settings/youtube',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: {
|
||||||
|
defaultView: 'grid',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults defaultView to list when not specified', async () => {
|
||||||
|
await server.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/platform-settings/generic',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
const putRes = await server.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/platform-settings/generic',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { checkInterval: 360 },
|
||||||
|
});
|
||||||
|
expect(putRes.statusCode).toBe(200);
|
||||||
|
expect(putRes.json().defaultView).toBe('list');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── Auth ──
|
// ── Auth ──
|
||||||
|
|
||||||
describe('Authentication', () => {
|
describe('Authentication', () => {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ export interface UpsertPlatformSettingsData {
|
||||||
scanLimit?: number;
|
scanLimit?: number;
|
||||||
rateLimitDelay?: number;
|
rateLimitDelay?: number;
|
||||||
defaultMonitoringMode?: MonitoringMode;
|
defaultMonitoringMode?: MonitoringMode;
|
||||||
|
nfoEnabled?: boolean;
|
||||||
|
defaultView?: 'list' | 'poster' | 'table';
|
||||||
}
|
}
|
||||||
|
|
||||||
type Db = LibSQLDatabase<typeof schema>;
|
type Db = LibSQLDatabase<typeof schema>;
|
||||||
|
|
@ -67,6 +69,8 @@ export async function upsertPlatformSettings(
|
||||||
scanLimit: data.scanLimit ?? 500,
|
scanLimit: data.scanLimit ?? 500,
|
||||||
rateLimitDelay: data.rateLimitDelay ?? 1000,
|
rateLimitDelay: data.rateLimitDelay ?? 1000,
|
||||||
defaultMonitoringMode: data.defaultMonitoringMode ?? 'all',
|
defaultMonitoringMode: data.defaultMonitoringMode ?? 'all',
|
||||||
|
nfoEnabled: data.nfoEnabled ?? false,
|
||||||
|
defaultView: data.defaultView ?? 'list',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
@ -82,6 +86,8 @@ export async function upsertPlatformSettings(
|
||||||
scanLimit: data.scanLimit ?? 500,
|
scanLimit: data.scanLimit ?? 500,
|
||||||
rateLimitDelay: data.rateLimitDelay ?? 1000,
|
rateLimitDelay: data.rateLimitDelay ?? 1000,
|
||||||
defaultMonitoringMode: data.defaultMonitoringMode ?? 'all',
|
defaultMonitoringMode: data.defaultMonitoringMode ?? 'all',
|
||||||
|
nfoEnabled: data.nfoEnabled ?? false,
|
||||||
|
defaultView: data.defaultView ?? 'list',
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -117,6 +123,8 @@ function mapRow(row: typeof platformSettings.$inferSelect): PlatformSettings {
|
||||||
scanLimit: row.scanLimit ?? 500,
|
scanLimit: row.scanLimit ?? 500,
|
||||||
rateLimitDelay: row.rateLimitDelay ?? 1000,
|
rateLimitDelay: row.rateLimitDelay ?? 1000,
|
||||||
defaultMonitoringMode: (row.defaultMonitoringMode ?? 'all') as MonitoringMode,
|
defaultMonitoringMode: (row.defaultMonitoringMode ?? 'all') as MonitoringMode,
|
||||||
|
nfoEnabled: row.nfoEnabled,
|
||||||
|
defaultView: (row.defaultView ?? 'list') as 'list' | 'poster' | 'table',
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ export const platformSettings = sqliteTable('platform_settings', {
|
||||||
scanLimit: integer('scan_limit').default(100),
|
scanLimit: integer('scan_limit').default(100),
|
||||||
rateLimitDelay: integer('rate_limit_delay').default(1000),
|
rateLimitDelay: integer('rate_limit_delay').default(1000),
|
||||||
defaultMonitoringMode: text('default_monitoring_mode').notNull().default('all'),
|
defaultMonitoringMode: text('default_monitoring_mode').notNull().default('all'),
|
||||||
|
nfoEnabled: integer('nfo_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
defaultView: text('default_view').notNull().default('list'),
|
||||||
createdAt: text('created_at')
|
createdAt: text('created_at')
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(datetime('now'))`),
|
.default(sql`(datetime('now'))`),
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ export interface UpdatePlatformSettingsInput {
|
||||||
scanLimit?: number;
|
scanLimit?: number;
|
||||||
rateLimitDelay?: number;
|
rateLimitDelay?: number;
|
||||||
defaultMonitoringMode?: 'all' | 'future' | 'existing' | 'none';
|
defaultMonitoringMode?: 'all' | 'future' | 'existing' | 'none';
|
||||||
|
nfoEnabled?: boolean;
|
||||||
|
defaultView?: 'list' | 'poster' | 'table';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mutations ──
|
// ── Mutations ──
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ export interface PlatformSettingsFormValues {
|
||||||
scanLimit: number;
|
scanLimit: number;
|
||||||
rateLimitDelay: number;
|
rateLimitDelay: number;
|
||||||
defaultMonitoringMode: string;
|
defaultMonitoringMode: string;
|
||||||
|
nfoEnabled: boolean;
|
||||||
|
defaultView: 'list' | 'poster' | 'table';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlatformSettingsFormProps {
|
interface PlatformSettingsFormProps {
|
||||||
|
|
@ -89,6 +91,8 @@ export function PlatformSettingsForm({
|
||||||
const [scanLimit, setScanLimit] = useState(settings?.scanLimit ?? 100);
|
const [scanLimit, setScanLimit] = useState(settings?.scanLimit ?? 100);
|
||||||
const [rateLimitDelay, setRateLimitDelay] = useState(settings?.rateLimitDelay ?? 1000);
|
const [rateLimitDelay, setRateLimitDelay] = useState(settings?.rateLimitDelay ?? 1000);
|
||||||
const [defaultMonitoringMode, setDefaultMonitoringMode] = useState(settings?.defaultMonitoringMode ?? 'all');
|
const [defaultMonitoringMode, setDefaultMonitoringMode] = useState(settings?.defaultMonitoringMode ?? 'all');
|
||||||
|
const [nfoEnabled, setNfoEnabled] = useState(settings?.nfoEnabled ?? false);
|
||||||
|
const [defaultView, setDefaultView] = useState<'list' | 'poster' | 'table'>(settings?.defaultView ?? 'list');
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(e: FormEvent) => {
|
(e: FormEvent) => {
|
||||||
|
|
@ -103,12 +107,14 @@ export function PlatformSettingsForm({
|
||||||
scanLimit,
|
scanLimit,
|
||||||
rateLimitDelay,
|
rateLimitDelay,
|
||||||
defaultMonitoringMode,
|
defaultMonitoringMode,
|
||||||
|
nfoEnabled,
|
||||||
|
defaultView,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[defaultFormatProfileId, checkInterval, concurrencyLimit, subtitleLanguages, grabAllEnabled, grabAllOrder, scanLimit, rateLimitDelay, defaultMonitoringMode, onSubmit],
|
[defaultFormatProfileId, checkInterval, concurrencyLimit, subtitleLanguages, grabAllEnabled, grabAllOrder, scanLimit, rateLimitDelay, defaultMonitoringMode, nfoEnabled, defaultView, onSubmit],
|
||||||
);
|
);
|
||||||
|
|
||||||
const platformLabel = platform === 'youtube' ? 'YouTube' : platform === 'soundcloud' ? 'SoundCloud' : platform;
|
const platformLabel = platform === 'youtube' ? 'YouTube' : platform === 'soundcloud' ? 'SoundCloud' : platform === 'generic' ? 'Generic' : platform;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
|
|
@ -280,6 +286,41 @@ export function PlatformSettingsForm({
|
||||||
<span style={hintStyle}>Default monitoring mode for new channels from this platform.</span>
|
<span style={hintStyle}>Default monitoring mode for new channels from this platform.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Default View */}
|
||||||
|
<div style={fieldGroupStyle}>
|
||||||
|
<label htmlFor="ps-default-view" style={labelStyle}>Default View</label>
|
||||||
|
<select
|
||||||
|
id="ps-default-view"
|
||||||
|
value={defaultView}
|
||||||
|
onChange={(e) => setDefaultView(e.target.value as 'list' | 'poster' | 'table')}
|
||||||
|
style={selectStyle}
|
||||||
|
>
|
||||||
|
<option value="list">List</option>
|
||||||
|
<option value="poster">Poster</option>
|
||||||
|
<option value="table">Table</option>
|
||||||
|
</select>
|
||||||
|
<span style={hintStyle}>Default content view when browsing channels on this platform.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* NFO Enabled */}
|
||||||
|
<div style={{ ...fieldGroupStyle, display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||||
|
<input
|
||||||
|
id="ps-nfo-enabled"
|
||||||
|
type="checkbox"
|
||||||
|
checked={nfoEnabled}
|
||||||
|
onChange={(e) => setNfoEnabled(e.target.checked)}
|
||||||
|
style={{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
accentColor: 'var(--accent)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label htmlFor="ps-nfo-enabled" style={{ fontSize: 'var(--font-size-sm)', color: 'var(--text-secondary)', cursor: 'pointer' }}>
|
||||||
|
Generate NFO sidecar files
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)', marginTop: 'var(--space-5)' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)', marginTop: 'var(--space-5)' }}>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,6 @@ export function SettingsPage() {
|
||||||
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 [outputTemplate, setOutputTemplate] = useState('');
|
||||||
const [nfoEnabled, setNfoEnabled] = useState(false);
|
|
||||||
const [timezone, setTimezone] = useState('UTC');
|
const [timezone, setTimezone] = useState('UTC');
|
||||||
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
|
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
|
||||||
const [settingsSaveFlash, setSettingsSaveFlash] = useState(false);
|
const [settingsSaveFlash, setSettingsSaveFlash] = useState(false);
|
||||||
|
|
@ -134,7 +133,6 @@ export function SettingsPage() {
|
||||||
setCheckInterval(appSettings.checkInterval);
|
setCheckInterval(appSettings.checkInterval);
|
||||||
setConcurrentDownloads(appSettings.concurrentDownloads);
|
setConcurrentDownloads(appSettings.concurrentDownloads);
|
||||||
setOutputTemplate(appSettings.outputTemplate);
|
setOutputTemplate(appSettings.outputTemplate);
|
||||||
setNfoEnabled(appSettings.nfoEnabled);
|
|
||||||
setTimezone(appSettings.timezone);
|
setTimezone(appSettings.timezone);
|
||||||
setTheme(appSettings.theme);
|
setTheme(appSettings.theme);
|
||||||
}
|
}
|
||||||
|
|
@ -154,7 +152,6 @@ export function SettingsPage() {
|
||||||
(Number(checkInterval) !== appSettings.checkInterval ||
|
(Number(checkInterval) !== appSettings.checkInterval ||
|
||||||
Number(concurrentDownloads) !== appSettings.concurrentDownloads ||
|
Number(concurrentDownloads) !== appSettings.concurrentDownloads ||
|
||||||
outputTemplate !== appSettings.outputTemplate ||
|
outputTemplate !== appSettings.outputTemplate ||
|
||||||
nfoEnabled !== appSettings.nfoEnabled ||
|
|
||||||
timezone !== appSettings.timezone ||
|
timezone !== appSettings.timezone ||
|
||||||
theme !== appSettings.theme);
|
theme !== appSettings.theme);
|
||||||
|
|
||||||
|
|
@ -207,7 +204,6 @@ export function SettingsPage() {
|
||||||
checkInterval: Number(checkInterval),
|
checkInterval: Number(checkInterval),
|
||||||
concurrentDownloads: Number(concurrentDownloads),
|
concurrentDownloads: Number(concurrentDownloads),
|
||||||
outputTemplate,
|
outputTemplate,
|
||||||
nfoEnabled,
|
|
||||||
timezone,
|
timezone,
|
||||||
theme,
|
theme,
|
||||||
},
|
},
|
||||||
|
|
@ -312,7 +308,7 @@ export function SettingsPage() {
|
||||||
|
|
||||||
// ── Platform Settings handlers ──
|
// ── Platform Settings handlers ──
|
||||||
|
|
||||||
const KNOWN_PLATFORMS = ['youtube', 'soundcloud'] as const;
|
const KNOWN_PLATFORMS = ['youtube', 'soundcloud', 'generic'] as const;
|
||||||
|
|
||||||
const platformSettingsMap = useMemo(() => {
|
const platformSettingsMap = useMemo(() => {
|
||||||
const map = new Map<string, PlatformSettings>();
|
const map = new Map<string, PlatformSettings>();
|
||||||
|
|
@ -337,6 +333,8 @@ export function SettingsPage() {
|
||||||
subtitleLanguages: values.subtitleLanguages || null,
|
subtitleLanguages: values.subtitleLanguages || null,
|
||||||
grabAllEnabled: values.grabAllEnabled,
|
grabAllEnabled: values.grabAllEnabled,
|
||||||
grabAllOrder: values.grabAllOrder,
|
grabAllOrder: values.grabAllOrder,
|
||||||
|
nfoEnabled: values.nfoEnabled,
|
||||||
|
defaultView: values.defaultView,
|
||||||
};
|
};
|
||||||
updatePlatformSettingsMutation.mutate(input, {
|
updatePlatformSettingsMutation.mutate(input, {
|
||||||
onSuccess: () => setEditingPlatform(null),
|
onSuccess: () => setEditingPlatform(null),
|
||||||
|
|
@ -357,7 +355,7 @@ export function SettingsPage() {
|
||||||
() =>
|
() =>
|
||||||
KNOWN_PLATFORMS.map((platform) => ({
|
KNOWN_PLATFORMS.map((platform) => ({
|
||||||
platform,
|
platform,
|
||||||
label: platform === 'youtube' ? 'YouTube' : 'SoundCloud',
|
label: platform === 'youtube' ? 'YouTube' : platform === 'soundcloud' ? 'SoundCloud' : 'Generic',
|
||||||
settings: platformSettingsMap.get(platform) ?? null,
|
settings: platformSettingsMap.get(platform) ?? null,
|
||||||
})),
|
})),
|
||||||
[platformSettingsMap],
|
[platformSettingsMap],
|
||||||
|
|
@ -369,17 +367,25 @@ export function SettingsPage() {
|
||||||
key: 'platform',
|
key: 'platform',
|
||||||
label: 'Platform',
|
label: 'Platform',
|
||||||
width: '130px',
|
width: '130px',
|
||||||
render: (row) => (
|
render: (row) => {
|
||||||
<span
|
const colorMap: Record<string, { color: string; bg: string }> = {
|
||||||
style={{
|
youtube: { color: '#ff4444', bg: 'rgba(255, 68, 68, 0.1)' },
|
||||||
...badgeBase,
|
soundcloud: { color: '#ff7700', bg: 'rgba(255, 119, 0, 0.1)' },
|
||||||
color: row.platform === 'youtube' ? '#ff4444' : '#ff7700',
|
generic: { color: '#8b8d97', bg: 'rgba(139, 141, 151, 0.1)' },
|
||||||
backgroundColor: row.platform === 'youtube' ? 'rgba(255, 68, 68, 0.1)' : 'rgba(255, 119, 0, 0.1)',
|
};
|
||||||
}}
|
const c = colorMap[row.platform] ?? colorMap.generic;
|
||||||
>
|
return (
|
||||||
{row.label}
|
<span
|
||||||
</span>
|
style={{
|
||||||
),
|
...badgeBase,
|
||||||
|
color: c.color,
|
||||||
|
backgroundColor: c.bg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'formatProfile',
|
key: 'formatProfile',
|
||||||
|
|
@ -433,6 +439,35 @@ export function SettingsPage() {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'nfo',
|
||||||
|
label: 'NFO',
|
||||||
|
width: '70px',
|
||||||
|
render: (row) => {
|
||||||
|
const enabled = row.settings?.nfoEnabled ?? false;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
...badgeBase,
|
||||||
|
color: enabled ? 'var(--success)' : 'var(--text-muted)',
|
||||||
|
backgroundColor: enabled ? 'var(--success-bg)' : 'var(--bg-hover)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{enabled ? 'On' : 'Off'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'defaultView',
|
||||||
|
label: 'View',
|
||||||
|
width: '80px',
|
||||||
|
render: (row) => (
|
||||||
|
<span style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-sm)', textTransform: 'capitalize' }}>
|
||||||
|
{row.settings?.defaultView ?? 'list'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
label: '',
|
label: '',
|
||||||
|
|
@ -1339,84 +1374,6 @@ export function SettingsPage() {
|
||||||
{templatePreview || '—'}
|
{templatePreview || '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* NFO Sidecar toggle */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 'var(--space-4)',
|
|
||||||
paddingTop: 'var(--space-4)',
|
|
||||||
borderTop: '1px solid var(--border)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="nfo-enabled"
|
|
||||||
style={{
|
|
||||||
display: 'block',
|
|
||||||
fontSize: 'var(--font-size-sm)',
|
|
||||||
fontWeight: 500,
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Generate NFO Files
|
|
||||||
</label>
|
|
||||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)' }}>
|
|
||||||
Write Kodi-compatible .nfo sidecar files alongside downloaded media
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
position: 'relative',
|
|
||||||
display: 'inline-block',
|
|
||||||
width: 44,
|
|
||||||
height: 24,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="nfo-enabled"
|
|
||||||
type="checkbox"
|
|
||||||
checked={nfoEnabled}
|
|
||||||
onChange={(e) => setNfoEnabled(e.target.checked)}
|
|
||||||
style={{
|
|
||||||
opacity: 0,
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
position: 'absolute',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
cursor: 'pointer',
|
|
||||||
inset: 0,
|
|
||||||
backgroundColor: nfoEnabled ? 'var(--accent)' : 'var(--bg-hover)',
|
|
||||||
borderRadius: 12,
|
|
||||||
border: `1px solid ${nfoEnabled ? 'var(--accent)' : 'var(--border)'}`,
|
|
||||||
transition: 'background-color 0.2s, border-color 0.2s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
content: '""',
|
|
||||||
height: 18,
|
|
||||||
width: 18,
|
|
||||||
left: nfoEnabled ? 22 : 2,
|
|
||||||
top: 2,
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderRadius: '50%',
|
|
||||||
transition: 'left 0.2s',
|
|
||||||
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -1664,7 +1621,7 @@ export function SettingsPage() {
|
||||||
|
|
||||||
{/* ── Platform Settings: Edit modal ── */}
|
{/* ── Platform Settings: Edit modal ── */}
|
||||||
<Modal
|
<Modal
|
||||||
title={`${editingPlatform === 'youtube' ? 'YouTube' : editingPlatform === 'soundcloud' ? 'SoundCloud' : editingPlatform ?? ''} Settings`}
|
title={`${editingPlatform === 'youtube' ? 'YouTube' : editingPlatform === 'soundcloud' ? 'SoundCloud' : editingPlatform === 'generic' ? 'Generic' : editingPlatform ?? ''} Settings`}
|
||||||
open={!!editingPlatform}
|
open={!!editingPlatform}
|
||||||
onClose={() => setEditingPlatform(null)}
|
onClose={() => setEditingPlatform(null)}
|
||||||
width={520}
|
width={520}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { Platform } from '../../types/index';
|
||||||
|
|
||||||
// ── JSON Schemas for Fastify Validation ──
|
// ── JSON Schemas for Fastify Validation ──
|
||||||
|
|
||||||
const VALID_PLATFORMS = [Platform.YouTube, Platform.SoundCloud] as const;
|
const VALID_PLATFORMS = [Platform.YouTube, Platform.SoundCloud, Platform.Generic] as const;
|
||||||
|
|
||||||
const upsertPlatformSettingsBodySchema = {
|
const upsertPlatformSettingsBodySchema = {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
|
|
@ -23,6 +23,8 @@ const upsertPlatformSettingsBodySchema = {
|
||||||
scanLimit: { type: 'integer' as const, minimum: 10, maximum: 1000 },
|
scanLimit: { type: 'integer' as const, minimum: 10, maximum: 1000 },
|
||||||
rateLimitDelay: { type: 'integer' as const, minimum: 0, maximum: 10000 },
|
rateLimitDelay: { type: 'integer' as const, minimum: 0, maximum: 10000 },
|
||||||
defaultMonitoringMode: { type: 'string' as const, enum: ['all', 'future', 'existing', 'none'] },
|
defaultMonitoringMode: { type: 'string' as const, enum: ['all', 'future', 'existing', 'none'] },
|
||||||
|
nfoEnabled: { type: 'boolean' as const },
|
||||||
|
defaultView: { type: 'string' as const, enum: ['list', 'poster', 'table'] },
|
||||||
},
|
},
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
};
|
};
|
||||||
|
|
@ -79,6 +81,8 @@ export async function platformSettingsRoutes(fastify: FastifyInstance): Promise<
|
||||||
scanLimit?: number;
|
scanLimit?: number;
|
||||||
rateLimitDelay?: number;
|
rateLimitDelay?: number;
|
||||||
defaultMonitoringMode?: 'all' | 'future' | 'existing' | 'none';
|
defaultMonitoringMode?: 'all' | 'future' | 'existing' | 'none';
|
||||||
|
nfoEnabled?: boolean;
|
||||||
|
defaultView?: 'list' | 'poster' | 'table';
|
||||||
};
|
};
|
||||||
}>(
|
}>(
|
||||||
'/api/v1/platform-settings/:platform',
|
'/api/v1/platform-settings/:platform',
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,8 @@ export interface PlatformSettings {
|
||||||
scanLimit: number;
|
scanLimit: number;
|
||||||
rateLimitDelay: number;
|
rateLimitDelay: number;
|
||||||
defaultMonitoringMode: MonitoringMode;
|
defaultMonitoringMode: MonitoringMode;
|
||||||
|
nfoEnabled: boolean;
|
||||||
|
defaultView: 'list' | 'poster' | 'table';
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue