From c0ac8cadd58a4e6feb7acdf22db2f5d83d7e0f38 Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 06:24:14 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Added=20RatingBadge/RatingPicker=20comp?= =?UTF-8?q?onents,=20channel=20and=20content=20it=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "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 --- src/db/repositories/content-repository.ts | 1 + src/frontend/src/api/hooks/useChannels.ts | 2 +- src/frontend/src/api/hooks/useContent.ts | 13 +++ src/frontend/src/components/RatingBadge.tsx | 113 ++++++++++++++++++++ src/frontend/src/pages/ChannelDetail.tsx | 31 +++++- src/frontend/src/pages/Settings.tsx | 84 ++++++++++++++- src/server/routes/channel.ts | 3 +- src/server/routes/content.ts | 60 +++++++++++ src/server/routes/system.ts | 12 ++- src/types/api.ts | 1 + 10 files changed, 312 insertions(+), 8 deletions(-) create mode 100644 src/frontend/src/components/RatingBadge.tsx diff --git a/src/db/repositories/content-repository.ts b/src/db/repositories/content-repository.ts index 455a73b..f3220ae 100644 --- a/src/db/repositories/content-repository.ts +++ b/src/db/repositories/content-repository.ts @@ -29,6 +29,7 @@ export interface UpdateContentItemData { qualityMetadata?: QualityInfo | null; status?: ContentStatus; downloadedAt?: string | null; + contentRating?: string | null; } type Db = LibSQLDatabase; diff --git a/src/frontend/src/api/hooks/useChannels.ts b/src/frontend/src/api/hooks/useChannels.ts index abedf90..3cdb404 100644 --- a/src/frontend/src/api/hooks/useChannels.ts +++ b/src/frontend/src/api/hooks/useChannels.ts @@ -59,7 +59,7 @@ export function useUpdateChannel(id: number) { const queryClient = useQueryClient(); 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(`/api/v1/channel/${id}`, data), onSuccess: (updated) => { queryClient.setQueryData(channelKeys.detail(id), updated); diff --git a/src/frontend/src/api/hooks/useContent.ts b/src/frontend/src/api/hooks/useContent.ts index 6f4833e..d6a692a 100644 --- a/src/frontend/src/api/hooks/useContent.ts +++ b/src/frontend/src/api/hooks/useContent.ts @@ -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>(`/api/v1/content/${contentId}/rating`, { contentRating }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['content', 'channel', channelId] }); + }, + }); +} diff --git a/src/frontend/src/components/RatingBadge.tsx b/src/frontend/src/components/RatingBadge.tsx new file mode 100644 index 0000000..634158b --- /dev/null +++ b/src/frontend/src/components/RatingBadge.tsx @@ -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 = { + '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 ( + + {rating} + + ); +} + +// ── 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 ( + + ); +} diff --git a/src/frontend/src/pages/ChannelDetail.tsx b/src/frontend/src/pages/ChannelDetail.tsx index 171c9ac..65222c2 100644 --- a/src/frontend/src/pages/ChannelDetail.tsx +++ b/src/frontend/src/pages/ChannelDetail.tsx @@ -27,12 +27,13 @@ import { Users, } from 'lucide-react'; 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 { useFormatProfiles } from '../api/hooks/useFormatProfiles'; import { Table, type Column } from '../components/Table'; import { PlatformBadge } from '../components/PlatformBadge'; import { StatusBadge } from '../components/StatusBadge'; +import { RatingBadge, RatingPicker } from '../components/RatingBadge'; import { QualityLabel } from '../components/QualityLabel'; import { DownloadProgressBar } from '../components/DownloadProgressBar'; import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton'; @@ -117,6 +118,7 @@ export function ChannelDetail() { const toggleMonitored = useToggleMonitored(channelId); const refreshPlaylists = useRefreshPlaylists(channelId); const bulkMonitored = useBulkMonitored(channelId); + const updateContentRating = useUpdateContentRating(channelId); // ── Scan state (WebSocket-driven) ── const { scanning: scanInProgress, newItemCount: scanNewItemCount } = useScanProgress(channelId); @@ -611,6 +613,19 @@ export function ChannelDetail() { sortable: true, render: (item) => , }, + { + key: 'contentRating', + label: 'Rating', + width: '90px', + render: (item) => ( + updateContentRating.mutate({ contentId: item.id, contentRating: rating })} + disabled={updateContentRating.isPending} + compact + /> + ), + }, { key: 'quality', label: 'Quality', @@ -669,7 +684,7 @@ export function ChannelDetail() { ), }, ], - [toggleMonitored, selectedIds, toggleSelect, isAllSelected, toggleSelectAll], + [toggleMonitored, selectedIds, toggleSelect, isAllSelected, toggleSelectAll, updateContentRating], ); // ── Render helpers ── @@ -1240,6 +1255,18 @@ export function ChannelDetail() { + {/* Content Rating group */} +
+ + Rating + + updateChannel.mutate({ contentRating: rating })} + disabled={updateChannel.isPending} + /> +
+ {/* Actions group */}
diff --git a/src/frontend/src/pages/Settings.tsx b/src/frontend/src/pages/Settings.tsx index 1da1005..d9c34fc 100644 --- a/src/frontend/src/pages/Settings.tsx +++ b/src/frontend/src/pages/Settings.tsx @@ -105,6 +105,7 @@ export function SettingsPage() { const [checkInterval, setCheckInterval] = useState(''); const [concurrentDownloads, setConcurrentDownloads] = useState(''); const [outputTemplate, setOutputTemplate] = useState(''); + const [nfoEnabled, setNfoEnabled] = useState(false); const [settingsSaveFlash, setSettingsSaveFlash] = useState(false); // Initialize local state from fetched app settings @@ -113,6 +114,7 @@ export function SettingsPage() { setCheckInterval(appSettings.checkInterval); setConcurrentDownloads(appSettings.concurrentDownloads); setOutputTemplate(appSettings.outputTemplate); + setNfoEnabled(appSettings.nfoEnabled); } }, [appSettings]); @@ -122,7 +124,8 @@ export function SettingsPage() { appSettings != null && (Number(checkInterval) !== appSettings.checkInterval || Number(concurrentDownloads) !== appSettings.concurrentDownloads || - outputTemplate !== appSettings.outputTemplate); + outputTemplate !== appSettings.outputTemplate || + nfoEnabled !== appSettings.nfoEnabled); const settingsValid = checkInterval !== '' && @@ -173,6 +176,7 @@ export function SettingsPage() { checkInterval: Number(checkInterval), concurrentDownloads: Number(concurrentDownloads), outputTemplate, + nfoEnabled, }, { onSuccess: () => { @@ -1192,6 +1196,84 @@ export function SettingsPage() { {templatePreview || '—'}
+ + {/* NFO Sidecar toggle */} +
+
+ + + Write Kodi-compatible .nfo sidecar files alongside downloaded media + +
+ +
diff --git a/src/server/routes/channel.ts b/src/server/routes/channel.ts index 6519488..85c4c89 100644 --- a/src/server/routes/channel.ts +++ b/src/server/routes/channel.ts @@ -56,6 +56,7 @@ const updateChannelBodySchema = { formatProfileId: { type: 'number' as const, nullable: true }, includeKeywords: { type: 'string' as const, nullable: true }, excludeKeywords: { type: 'string' as const, nullable: true }, + contentRating: { type: 'string' as const, nullable: true }, }, additionalProperties: false, }; @@ -255,7 +256,7 @@ export async function channelRoutes(fastify: FastifyInstance): Promise { fastify.put<{ 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', { diff --git a/src/server/routes/content.ts b/src/server/routes/content.ts index 3dcebf3..9689e9b 100644 --- a/src/server/routes/content.ts +++ b/src/server/routes/content.ts @@ -6,6 +6,7 @@ import { getChannelContentPaginated, setMonitored, bulkSetMonitored, + updateContentItem, } from '../../db/repositories/content-repository'; import type { PaginatedResponse, ApiResponse } from '../../types/api'; import type { ContentItem, ContentStatus, ContentType } from '../../types/index'; @@ -31,6 +32,15 @@ const toggleMonitoredBodySchema = { additionalProperties: false, }; +const updateRatingBodySchema = { + type: 'object' as const, + required: ['contentRating'], + properties: { + contentRating: { type: 'string' as const, nullable: true }, + }, + additionalProperties: false, +}; + // ── Route Plugin ── /** @@ -40,6 +50,7 @@ const toggleMonitoredBodySchema = { * GET /api/v1/content — paginated content listing with optional filters * 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/rating — update content item rating * GET /api/v1/channel/:id/content — content items for a specific channel */ export async function contentRoutes(fastify: FastifyInstance): Promise { @@ -196,6 +207,55 @@ export async function contentRoutes(fastify: FastifyInstance): Promise { // ── 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 = { + 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<{ Params: { id: string }; Querystring: { diff --git a/src/server/routes/system.ts b/src/server/routes/system.ts index 3a8d8f7..e5a67be 100644 --- a/src/server/routes/system.ts +++ b/src/server/routes/system.ts @@ -15,6 +15,7 @@ import { APP_CHECK_INTERVAL, APP_CONCURRENT_DOWNLOADS, APP_OUTPUT_TEMPLATE, + APP_NFO_ENABLED, YTDLP_LAST_UPDATED, } from '../../db/repositories/system-config-repository'; import { getYtDlpVersion, updateYtDlp } from '../../sources/yt-dlp'; @@ -115,12 +116,13 @@ 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, APP_OUTPUT_TEMPLATE]); + const settings = await getAppSettings(db, [APP_CHECK_INTERVAL, APP_CONCURRENT_DOWNLOADS, APP_OUTPUT_TEMPLATE, APP_NFO_ENABLED]); 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}', + nfoEnabled: settings[APP_NFO_ENABLED] === 'true', }; return response; @@ -133,7 +135,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; outputTemplate?: string }; + const body = request.body as { checkInterval?: number; concurrentDownloads?: number; outputTemplate?: string; nfoEnabled?: boolean }; // Validate if (body.checkInterval !== undefined) { @@ -191,13 +193,17 @@ export async function systemRoutes(fastify: FastifyInstance): Promise { } 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 - 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 = { 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}', + nfoEnabled: settings[APP_NFO_ENABLED] === 'true', }; return response; diff --git a/src/types/api.ts b/src/types/api.ts index 7999ed8..afb373d 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -75,6 +75,7 @@ export interface AppSettingsResponse { checkInterval: number; concurrentDownloads: number; outputTemplate: string; + nfoEnabled: boolean; } /** Channel with aggregated content counts — returned by GET /api/v1/channel. */