feat: Added RatingBadge/RatingPicker components, channel and content it…
- "src/frontend/src/components/RatingBadge.tsx" - "src/frontend/src/pages/ChannelDetail.tsx" - "src/frontend/src/pages/Settings.tsx" - "src/server/routes/content.ts" - "src/server/routes/channel.ts" - "src/server/routes/system.ts" - "src/types/api.ts" GSD-Task: S05/T04
This commit is contained in:
parent
b4d730d42f
commit
c0ac8cadd5
10 changed files with 312 additions and 8 deletions
|
|
@ -29,6 +29,7 @@ export interface UpdateContentItemData {
|
||||||
qualityMetadata?: QualityInfo | null;
|
qualityMetadata?: QualityInfo | null;
|
||||||
status?: ContentStatus;
|
status?: ContentStatus;
|
||||||
downloadedAt?: string | null;
|
downloadedAt?: string | null;
|
||||||
|
contentRating?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Db = LibSQLDatabase<typeof schema>;
|
type Db = LibSQLDatabase<typeof schema>;
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export function useUpdateChannel(id: number) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: { name?: string; checkInterval?: number; monitoringEnabled?: boolean; formatProfileId?: number | null; includeKeywords?: string | null; excludeKeywords?: string | null }) =>
|
mutationFn: (data: { name?: string; checkInterval?: number; monitoringEnabled?: boolean; formatProfileId?: number | null; includeKeywords?: string | null; excludeKeywords?: string | null; contentRating?: string | null }) =>
|
||||||
apiClient.put<Channel>(`/api/v1/channel/${id}`, data),
|
apiClient.put<Channel>(`/api/v1/channel/${id}`, data),
|
||||||
onSuccess: (updated) => {
|
onSuccess: (updated) => {
|
||||||
queryClient.setQueryData(channelKeys.detail(id), updated);
|
queryClient.setQueryData(channelKeys.detail(id), updated);
|
||||||
|
|
|
||||||
|
|
@ -138,3 +138,16 @@ export function useCollectAllMonitored() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Update content item rating. */
|
||||||
|
export function useUpdateContentRating(channelId: number) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ contentId, contentRating }: { contentId: number; contentRating: string | null }) =>
|
||||||
|
apiClient.patch<ApiResponse<ContentItem>>(`/api/v1/content/${contentId}/rating`, { contentRating }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['content', 'channel', channelId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
113
src/frontend/src/components/RatingBadge.tsx
Normal file
113
src/frontend/src/components/RatingBadge.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
// ── Content Rating Badge ──
|
||||||
|
|
||||||
|
export const CONTENT_RATINGS = [
|
||||||
|
'G', 'PG', 'PG-13', 'R', 'NC-17',
|
||||||
|
'TV-Y', 'TV-G', 'TV-PG', 'TV-14', 'TV-MA',
|
||||||
|
'NR',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ContentRating = (typeof CONTENT_RATINGS)[number];
|
||||||
|
|
||||||
|
interface BadgeStyle {
|
||||||
|
color: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Color map: green for family-friendly, yellow for teen, red for mature */
|
||||||
|
const RATING_STYLES: Record<string, BadgeStyle> = {
|
||||||
|
'G': { color: '#22c55e', backgroundColor: 'rgba(34, 197, 94, 0.12)' },
|
||||||
|
'TV-Y': { color: '#22c55e', backgroundColor: 'rgba(34, 197, 94, 0.12)' },
|
||||||
|
'TV-G': { color: '#22c55e', backgroundColor: 'rgba(34, 197, 94, 0.12)' },
|
||||||
|
'PG': { color: '#eab308', backgroundColor: 'rgba(234, 179, 8, 0.12)' },
|
||||||
|
'TV-PG': { color: '#eab308', backgroundColor: 'rgba(234, 179, 8, 0.12)' },
|
||||||
|
'PG-13': { color: '#f97316', backgroundColor: 'rgba(249, 115, 22, 0.12)' },
|
||||||
|
'TV-14': { color: '#f97316', backgroundColor: 'rgba(249, 115, 22, 0.12)' },
|
||||||
|
'R': { color: '#ef4444', backgroundColor: 'rgba(239, 68, 68, 0.12)' },
|
||||||
|
'TV-MA': { color: '#ef4444', backgroundColor: 'rgba(239, 68, 68, 0.12)' },
|
||||||
|
'NC-17': { color: '#dc2626', backgroundColor: 'rgba(220, 38, 38, 0.12)' },
|
||||||
|
'NR': { color: 'var(--text-muted)', backgroundColor: 'var(--bg-hover)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_STYLE: BadgeStyle = {
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
backgroundColor: 'var(--bg-hover)',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Badge Component ──
|
||||||
|
|
||||||
|
interface RatingBadgeProps {
|
||||||
|
rating: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RatingBadge({ rating }: RatingBadgeProps) {
|
||||||
|
if (!rating) return null;
|
||||||
|
|
||||||
|
const style = RATING_STYLES[rating] ?? DEFAULT_STYLE;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '2px var(--space-2)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rating}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Picker Component ──
|
||||||
|
|
||||||
|
interface RatingPickerProps {
|
||||||
|
value: string | null;
|
||||||
|
onChange: (rating: string | null) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Compact mode for inline use in tables */
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RatingPicker({ value, onChange, disabled, compact }: RatingPickerProps) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => onChange(e.target.value || null)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label="Content rating"
|
||||||
|
style={{
|
||||||
|
padding: compact ? '2px 6px' : 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
backgroundColor: 'var(--bg-main)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontSize: compact ? 'var(--font-size-xs)' : 'var(--font-size-sm)',
|
||||||
|
minWidth: compact ? 70 : 100,
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: disabled ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">No Rating</option>
|
||||||
|
<optgroup label="Movie Ratings">
|
||||||
|
<option value="G">G</option>
|
||||||
|
<option value="PG">PG</option>
|
||||||
|
<option value="PG-13">PG-13</option>
|
||||||
|
<option value="R">R</option>
|
||||||
|
<option value="NC-17">NC-17</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="TV Ratings">
|
||||||
|
<option value="TV-Y">TV-Y</option>
|
||||||
|
<option value="TV-G">TV-G</option>
|
||||||
|
<option value="TV-PG">TV-PG</option>
|
||||||
|
<option value="TV-14">TV-14</option>
|
||||||
|
<option value="TV-MA">TV-MA</option>
|
||||||
|
</optgroup>
|
||||||
|
<option value="NR">NR (Not Rated)</option>
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -27,12 +27,13 @@ import {
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useCancelScan, useSetMonitoringMode } from '../api/hooks/useChannels';
|
import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useCancelScan, useSetMonitoringMode } from '../api/hooks/useChannels';
|
||||||
import { useChannelContentPaginated, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored, type ChannelContentFilters } from '../api/hooks/useContent';
|
import { useChannelContentPaginated, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored, useUpdateContentRating, type ChannelContentFilters } from '../api/hooks/useContent';
|
||||||
import { useChannelPlaylists, useRefreshPlaylists } from '../api/hooks/usePlaylists';
|
import { useChannelPlaylists, useRefreshPlaylists } from '../api/hooks/usePlaylists';
|
||||||
import { useFormatProfiles } from '../api/hooks/useFormatProfiles';
|
import { useFormatProfiles } from '../api/hooks/useFormatProfiles';
|
||||||
import { Table, type Column } from '../components/Table';
|
import { Table, type Column } from '../components/Table';
|
||||||
import { PlatformBadge } from '../components/PlatformBadge';
|
import { PlatformBadge } from '../components/PlatformBadge';
|
||||||
import { StatusBadge } from '../components/StatusBadge';
|
import { StatusBadge } from '../components/StatusBadge';
|
||||||
|
import { RatingBadge, RatingPicker } from '../components/RatingBadge';
|
||||||
import { QualityLabel } from '../components/QualityLabel';
|
import { QualityLabel } from '../components/QualityLabel';
|
||||||
import { DownloadProgressBar } from '../components/DownloadProgressBar';
|
import { DownloadProgressBar } from '../components/DownloadProgressBar';
|
||||||
import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton';
|
import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton';
|
||||||
|
|
@ -117,6 +118,7 @@ export function ChannelDetail() {
|
||||||
const toggleMonitored = useToggleMonitored(channelId);
|
const toggleMonitored = useToggleMonitored(channelId);
|
||||||
const refreshPlaylists = useRefreshPlaylists(channelId);
|
const refreshPlaylists = useRefreshPlaylists(channelId);
|
||||||
const bulkMonitored = useBulkMonitored(channelId);
|
const bulkMonitored = useBulkMonitored(channelId);
|
||||||
|
const updateContentRating = useUpdateContentRating(channelId);
|
||||||
|
|
||||||
// ── Scan state (WebSocket-driven) ──
|
// ── Scan state (WebSocket-driven) ──
|
||||||
const { scanning: scanInProgress, newItemCount: scanNewItemCount } = useScanProgress(channelId);
|
const { scanning: scanInProgress, newItemCount: scanNewItemCount } = useScanProgress(channelId);
|
||||||
|
|
@ -611,6 +613,19 @@ export function ChannelDetail() {
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (item) => <ContentStatusCell item={item} />,
|
render: (item) => <ContentStatusCell item={item} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'contentRating',
|
||||||
|
label: 'Rating',
|
||||||
|
width: '90px',
|
||||||
|
render: (item) => (
|
||||||
|
<RatingPicker
|
||||||
|
value={item.contentRating}
|
||||||
|
onChange={(rating) => updateContentRating.mutate({ contentId: item.id, contentRating: rating })}
|
||||||
|
disabled={updateContentRating.isPending}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'quality',
|
key: 'quality',
|
||||||
label: 'Quality',
|
label: 'Quality',
|
||||||
|
|
@ -669,7 +684,7 @@ export function ChannelDetail() {
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[toggleMonitored, selectedIds, toggleSelect, isAllSelected, toggleSelectAll],
|
[toggleMonitored, selectedIds, toggleSelect, isAllSelected, toggleSelectAll, updateContentRating],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Render helpers ──
|
// ── Render helpers ──
|
||||||
|
|
@ -1240,6 +1255,18 @@ export function ChannelDetail() {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Content Rating group */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
|
||||||
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||||
|
Rating
|
||||||
|
</span>
|
||||||
|
<RatingPicker
|
||||||
|
value={channel.contentRating}
|
||||||
|
onChange={(rating) => updateChannel.mutate({ contentRating: rating })}
|
||||||
|
disabled={updateChannel.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions group */}
|
{/* Actions group */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
|
||||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ 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 [settingsSaveFlash, setSettingsSaveFlash] = useState(false);
|
const [settingsSaveFlash, setSettingsSaveFlash] = useState(false);
|
||||||
|
|
||||||
// Initialize local state from fetched app settings
|
// Initialize local state from fetched app settings
|
||||||
|
|
@ -113,6 +114,7 @@ export function SettingsPage() {
|
||||||
setCheckInterval(appSettings.checkInterval);
|
setCheckInterval(appSettings.checkInterval);
|
||||||
setConcurrentDownloads(appSettings.concurrentDownloads);
|
setConcurrentDownloads(appSettings.concurrentDownloads);
|
||||||
setOutputTemplate(appSettings.outputTemplate);
|
setOutputTemplate(appSettings.outputTemplate);
|
||||||
|
setNfoEnabled(appSettings.nfoEnabled);
|
||||||
}
|
}
|
||||||
}, [appSettings]);
|
}, [appSettings]);
|
||||||
|
|
||||||
|
|
@ -122,7 +124,8 @@ export function SettingsPage() {
|
||||||
appSettings != null &&
|
appSettings != null &&
|
||||||
(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);
|
||||||
|
|
||||||
const settingsValid =
|
const settingsValid =
|
||||||
checkInterval !== '' &&
|
checkInterval !== '' &&
|
||||||
|
|
@ -173,6 +176,7 @@ export function SettingsPage() {
|
||||||
checkInterval: Number(checkInterval),
|
checkInterval: Number(checkInterval),
|
||||||
concurrentDownloads: Number(concurrentDownloads),
|
concurrentDownloads: Number(concurrentDownloads),
|
||||||
outputTemplate,
|
outputTemplate,
|
||||||
|
nfoEnabled,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|
@ -1192,6 +1196,84 @@ 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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ const updateChannelBodySchema = {
|
||||||
formatProfileId: { type: 'number' as const, nullable: true },
|
formatProfileId: { type: 'number' as const, nullable: true },
|
||||||
includeKeywords: { type: 'string' as const, nullable: true },
|
includeKeywords: { type: 'string' as const, nullable: true },
|
||||||
excludeKeywords: { type: 'string' as const, nullable: true },
|
excludeKeywords: { type: 'string' as const, nullable: true },
|
||||||
|
contentRating: { type: 'string' as const, nullable: true },
|
||||||
},
|
},
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
};
|
};
|
||||||
|
|
@ -255,7 +256,7 @@ export async function channelRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
|
|
||||||
fastify.put<{
|
fastify.put<{
|
||||||
Params: { id: string };
|
Params: { id: string };
|
||||||
Body: { name?: string; checkInterval?: number; monitoringEnabled?: boolean; formatProfileId?: number | null; includeKeywords?: string | null; excludeKeywords?: string | null };
|
Body: { name?: string; checkInterval?: number; monitoringEnabled?: boolean; formatProfileId?: number | null; includeKeywords?: string | null; excludeKeywords?: string | null; contentRating?: string | null };
|
||||||
}>(
|
}>(
|
||||||
'/api/v1/channel/:id',
|
'/api/v1/channel/:id',
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
getChannelContentPaginated,
|
getChannelContentPaginated,
|
||||||
setMonitored,
|
setMonitored,
|
||||||
bulkSetMonitored,
|
bulkSetMonitored,
|
||||||
|
updateContentItem,
|
||||||
} from '../../db/repositories/content-repository';
|
} from '../../db/repositories/content-repository';
|
||||||
import type { PaginatedResponse, ApiResponse } from '../../types/api';
|
import type { PaginatedResponse, ApiResponse } from '../../types/api';
|
||||||
import type { ContentItem, ContentStatus, ContentType } from '../../types/index';
|
import type { ContentItem, ContentStatus, ContentType } from '../../types/index';
|
||||||
|
|
@ -31,6 +32,15 @@ const toggleMonitoredBodySchema = {
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateRatingBodySchema = {
|
||||||
|
type: 'object' as const,
|
||||||
|
required: ['contentRating'],
|
||||||
|
properties: {
|
||||||
|
contentRating: { type: 'string' as const, nullable: true },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
// ── Route Plugin ──
|
// ── Route Plugin ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -40,6 +50,7 @@ const toggleMonitoredBodySchema = {
|
||||||
* GET /api/v1/content — paginated content listing with optional filters
|
* GET /api/v1/content — paginated content listing with optional filters
|
||||||
* PATCH /api/v1/content/bulk/monitored — bulk toggle monitored state
|
* PATCH /api/v1/content/bulk/monitored — bulk toggle monitored state
|
||||||
* PATCH /api/v1/content/:id/monitored — toggle single item monitored state
|
* PATCH /api/v1/content/:id/monitored — toggle single item monitored state
|
||||||
|
* PATCH /api/v1/content/:id/rating — update content item rating
|
||||||
* GET /api/v1/channel/:id/content — content items for a specific channel
|
* GET /api/v1/channel/:id/content — content items for a specific channel
|
||||||
*/
|
*/
|
||||||
export async function contentRoutes(fastify: FastifyInstance): Promise<void> {
|
export async function contentRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
|
|
@ -196,6 +207,55 @@ export async function contentRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
|
|
||||||
// ── GET /api/v1/channel/:id/content ──
|
// ── GET /api/v1/channel/:id/content ──
|
||||||
|
|
||||||
|
// ── PATCH /api/v1/content/:id/rating ──
|
||||||
|
|
||||||
|
fastify.patch<{
|
||||||
|
Params: { id: string };
|
||||||
|
Body: { contentRating: string | null };
|
||||||
|
}>(
|
||||||
|
'/api/v1/content/:id/rating',
|
||||||
|
{ schema: { body: updateRatingBodySchema } },
|
||||||
|
async (request, reply) => {
|
||||||
|
const id = parseIdParam(request.params.id, reply, 'Content item ID');
|
||||||
|
if (id === null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await updateContentItem(
|
||||||
|
fastify.db,
|
||||||
|
id,
|
||||||
|
{ contentRating: request.body.contentRating },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
statusCode: 404,
|
||||||
|
error: 'Not Found',
|
||||||
|
message: 'Content item not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiResponse<ContentItem> = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error(
|
||||||
|
{ err, id },
|
||||||
|
'[content] Failed to update content rating'
|
||||||
|
);
|
||||||
|
return reply.status(500).send({
|
||||||
|
statusCode: 500,
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: 'Failed to update content rating',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── GET /api/v1/channel/:id/content (paginated) ──
|
||||||
|
|
||||||
fastify.get<{
|
fastify.get<{
|
||||||
Params: { id: string };
|
Params: { id: string };
|
||||||
Querystring: {
|
Querystring: {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
APP_CHECK_INTERVAL,
|
APP_CHECK_INTERVAL,
|
||||||
APP_CONCURRENT_DOWNLOADS,
|
APP_CONCURRENT_DOWNLOADS,
|
||||||
APP_OUTPUT_TEMPLATE,
|
APP_OUTPUT_TEMPLATE,
|
||||||
|
APP_NFO_ENABLED,
|
||||||
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';
|
||||||
|
|
@ -115,12 +116,13 @@ 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, APP_OUTPUT_TEMPLATE]);
|
const settings = await getAppSettings(db, [APP_CHECK_INTERVAL, APP_CONCURRENT_DOWNLOADS, APP_OUTPUT_TEMPLATE, APP_NFO_ENABLED]);
|
||||||
|
|
||||||
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}',
|
outputTemplate: settings[APP_OUTPUT_TEMPLATE] ?? '{platform}/{channel}/{title}.{ext}',
|
||||||
|
nfoEnabled: settings[APP_NFO_ENABLED] === 'true',
|
||||||
};
|
};
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|
@ -133,7 +135,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; outputTemplate?: string };
|
const body = request.body as { checkInterval?: number; concurrentDownloads?: number; outputTemplate?: string; nfoEnabled?: boolean };
|
||||||
|
|
||||||
// Validate
|
// Validate
|
||||||
if (body.checkInterval !== undefined) {
|
if (body.checkInterval !== undefined) {
|
||||||
|
|
@ -191,13 +193,17 @@ export async function systemRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
}
|
}
|
||||||
await setAppSetting(db, APP_OUTPUT_TEMPLATE, body.outputTemplate);
|
await setAppSetting(db, APP_OUTPUT_TEMPLATE, body.outputTemplate);
|
||||||
}
|
}
|
||||||
|
if (body.nfoEnabled !== undefined) {
|
||||||
|
await setAppSetting(db, APP_NFO_ENABLED, body.nfoEnabled ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
// Return updated values
|
// Return updated values
|
||||||
const settings = await getAppSettings(db, [APP_CHECK_INTERVAL, APP_CONCURRENT_DOWNLOADS, APP_OUTPUT_TEMPLATE]);
|
const settings = await getAppSettings(db, [APP_CHECK_INTERVAL, APP_CONCURRENT_DOWNLOADS, APP_OUTPUT_TEMPLATE, APP_NFO_ENABLED]);
|
||||||
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}',
|
outputTemplate: settings[APP_OUTPUT_TEMPLATE] ?? '{platform}/{channel}/{title}.{ext}',
|
||||||
|
nfoEnabled: settings[APP_NFO_ENABLED] === 'true',
|
||||||
};
|
};
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ export interface AppSettingsResponse {
|
||||||
checkInterval: number;
|
checkInterval: number;
|
||||||
concurrentDownloads: number;
|
concurrentDownloads: number;
|
||||||
outputTemplate: string;
|
outputTemplate: string;
|
||||||
|
nfoEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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