From b1e90ea8d6d0b5749931b4fcf324900d27938f51 Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 22:55:43 +0000 Subject: [PATCH] refactor: consolidate format utils, extract route helpers, remove dead code - Consolidate 5 duplicate format functions (formatDuration, formatRelativeTime, formatFileSize, formatSubscriberCount) into shared utils/format.ts - Extract parseIdParam() route helper, replacing 22 copy-paste blocks across 9 route files - Remove dead exports: useScanStatus, useChannelContent (non-paginated), getContentItemsByStatus, deleteQueueItem, deletePlaylistsByChannelId - Fix as-any type assertion in system.ts (queueService already typed on FastifyInstance) - Net: -411 lines, 23 files touched --- src/__tests__/format-profile.test.ts | 90 ------------------- src/__tests__/queue-repository.test.ts | 20 ----- src/db/repositories/content-repository.ts | 18 ---- src/db/repositories/playlist-repository.ts | 6 -- src/db/repositories/queue-repository.ts | 11 --- src/frontend/src/api/hooks/useChannels.ts | 40 --------- src/frontend/src/api/hooks/useContent.ts | 14 --- src/frontend/src/components/ContentCard.tsx | 29 +----- .../src/components/ContentListItem.tsx | 29 +----- src/frontend/src/pages/Activity.tsx | 13 +-- src/frontend/src/pages/ChannelDetail.tsx | 44 +-------- src/frontend/src/pages/Channels.tsx | 16 +--- src/frontend/src/pages/Library.tsx | 20 +---- src/frontend/src/utils/format.ts | 54 ++++++++++- src/server/routes/channel.ts | 41 ++------- src/server/routes/collect.ts | 11 +-- src/server/routes/content.ts | 22 ++--- src/server/routes/format-profile.ts | 31 ++----- src/server/routes/helpers.ts | 26 ++++++ src/server/routes/notification.ts | 41 ++------- src/server/routes/playlist.ts | 21 ++--- src/server/routes/queue.ts | 31 ++----- src/server/routes/scan.ts | 31 ++----- src/server/routes/system.ts | 4 +- 24 files changed, 139 insertions(+), 524 deletions(-) create mode 100644 src/server/routes/helpers.ts diff --git a/src/__tests__/format-profile.test.ts b/src/__tests__/format-profile.test.ts index e127057..8900370 100644 --- a/src/__tests__/format-profile.test.ts +++ b/src/__tests__/format-profile.test.ts @@ -21,7 +21,6 @@ import { createContentItem, getContentItemById, updateContentItem, - getContentItemsByStatus, } from '../db/repositories/content-repository'; import type { Platform } from '../types/index'; @@ -440,95 +439,6 @@ describe('Content Item Update & Query Functions', () => { const result = await updateContentItem(db, 999, { status: 'failed' }); expect(result).toBeNull(); }); - - it('gets content items by status', async () => { - const dbPath = freshDbPath(); - const db = await initDatabaseAsync(dbPath); - await runMigrations(dbPath); - - const channel = await createChannel(db, { - name: 'Multi Channel', - platform: 'youtube' as Platform, - platformId: 'UC_MULTI', - url: 'https://www.youtube.com/@Multi', - monitoringEnabled: true, - checkInterval: 360, - imageUrl: null, - metadata: null, - formatProfileId: null, - }); - - // Create items with different statuses - await createContentItem(db, { - channelId: channel.id, - title: 'Item 1', - platformContentId: 'v1', - url: 'https://youtube.com/watch?v=v1', - contentType: 'video' as const, - duration: null, - status: 'monitored', - }); - - const item2 = await createContentItem(db, { - channelId: channel.id, - title: 'Item 2', - platformContentId: 'v2', - url: 'https://youtube.com/watch?v=v2', - contentType: 'video' as const, - duration: null, - status: 'monitored', - }); - - await createContentItem(db, { - channelId: channel.id, - title: 'Item 3', - platformContentId: 'v3', - url: 'https://youtube.com/watch?v=v3', - contentType: 'audio' as const, - duration: null, - status: 'downloaded', - }); - - const monitored = await getContentItemsByStatus(db, 'monitored'); - expect(monitored).toHaveLength(2); - - const downloaded = await getContentItemsByStatus(db, 'downloaded'); - expect(downloaded).toHaveLength(1); - expect(downloaded[0].title).toBe('Item 3'); - }); - - it('respects limit parameter on getContentItemsByStatus', async () => { - const dbPath = freshDbPath(); - const db = await initDatabaseAsync(dbPath); - await runMigrations(dbPath); - - const channel = await createChannel(db, { - name: 'Limit Channel', - platform: 'youtube' as Platform, - platformId: 'UC_LIMIT', - url: 'https://www.youtube.com/@Limit', - monitoringEnabled: true, - checkInterval: 360, - imageUrl: null, - metadata: null, - formatProfileId: null, - }); - - for (let i = 0; i < 5; i++) { - await createContentItem(db, { - channelId: channel.id, - title: `Item ${i}`, - platformContentId: `vid_${i}`, - url: `https://youtube.com/watch?v=vid_${i}`, - contentType: 'video' as const, - duration: null, - status: 'monitored', - }); - } - - const limited = await getContentItemsByStatus(db, 'monitored', 2); - expect(limited).toHaveLength(2); - }); }); // ── Config ── diff --git a/src/__tests__/queue-repository.test.ts b/src/__tests__/queue-repository.test.ts index f6eee3f..dc00330 100644 --- a/src/__tests__/queue-repository.test.ts +++ b/src/__tests__/queue-repository.test.ts @@ -14,7 +14,6 @@ import { getPendingQueueItems, updateQueueItemStatus, countQueueItemsByStatus, - deleteQueueItem, getQueueItemByContentItemId, } from '../db/repositories/queue-repository'; import type { Channel, ContentItem } from '../types/index'; @@ -344,25 +343,6 @@ describe('Queue Repository', () => { }); }); - describe('deleteQueueItem', () => { - it('deletes an existing item and returns true', async () => { - const item = await createQueueItem(db, { - contentItemId: testContentItem.id, - }); - - const deleted = await deleteQueueItem(db, item.id); - expect(deleted).toBe(true); - - const found = await getQueueItemById(db, item.id); - expect(found).toBeNull(); - }); - - it('returns false for non-existent ID', async () => { - const deleted = await deleteQueueItem(db, 99999); - expect(deleted).toBe(false); - }); - }); - describe('getQueueItemByContentItemId', () => { it('returns queue item for a given content item ID', async () => { const item = await createQueueItem(db, { diff --git a/src/db/repositories/content-repository.ts b/src/db/repositories/content-repository.ts index 346fa69..101dcb2 100644 --- a/src/db/repositories/content-repository.ts +++ b/src/db/repositories/content-repository.ts @@ -288,24 +288,6 @@ export async function bulkSetMonitored( } /** Get content items by status, ordered by creation date (oldest first). */ -export async function getContentItemsByStatus( - db: Db, - status: ContentStatus, - limit?: number -): Promise { - let query = db - .select() - .from(contentItems) - .where(eq(contentItems.status, status)) - .orderBy(contentItems.createdAt); - - if (limit !== undefined) { - query = query.limit(limit) as typeof query; - } - - const rows = await query; - return rows.map(mapRow); -} // ── Paginated Listing ── diff --git a/src/db/repositories/playlist-repository.ts b/src/db/repositories/playlist-repository.ts index 1474abf..b6fd17b 100644 --- a/src/db/repositories/playlist-repository.ts +++ b/src/db/repositories/playlist-repository.ts @@ -150,12 +150,6 @@ export async function getContentPlaylistMappings( } /** Delete all playlists for a channel. Cascade handles junction rows. */ -export async function deletePlaylistsByChannelId( - db: Db, - channelId: number -): Promise { - await db.delete(playlists).where(eq(playlists.channelId, channelId)); -} // ── Row Mapping ── diff --git a/src/db/repositories/queue-repository.ts b/src/db/repositories/queue-repository.ts index a589a25..0eab072 100644 --- a/src/db/repositories/queue-repository.ts +++ b/src/db/repositories/queue-repository.ts @@ -202,17 +202,6 @@ export async function countQueueItemsByStatus( } /** Delete a queue item by ID. Returns true if a row was deleted. */ -export async function deleteQueueItem( - db: Db, - id: number -): Promise { - const result = await db - .delete(queueItems) - .where(eq(queueItems.id, id)) - .returning({ id: queueItems.id }); - - return result.length > 0; -} /** * Get a queue item by content item ID (for dedup checking before enqueue). diff --git a/src/frontend/src/api/hooks/useChannels.ts b/src/frontend/src/api/hooks/useChannels.ts index a3560dc..82d260a 100644 --- a/src/frontend/src/api/hooks/useChannels.ts +++ b/src/frontend/src/api/hooks/useChannels.ts @@ -130,46 +130,6 @@ export function useScanAllChannels() { }); } -// ── Scan Status Polling ── - -interface ScanStatusResponse { - scanning: boolean; -} - -/** - * Poll the scan-status endpoint while `enabled` is true. - * When the scan completes (scanning flips false), calls `onComplete`. - * Polls every 2s. - */ -export function useScanStatus( - channelId: number, - enabled: boolean, - onComplete?: () => void, -) { - const queryClient = useQueryClient(); - const onCompleteRef = { current: onComplete }; - onCompleteRef.current = onComplete; - - return useQuery({ - queryKey: ['scan-status', channelId] as const, - queryFn: async () => { - const result = await apiClient.get( - `/api/v1/channel/${channelId}/scan-status`, - ); - // When scan just finished, refetch content and notify caller - if (!result.scanning) { - queryClient.invalidateQueries({ queryKey: channelKeys.all }); - queryClient.invalidateQueries({ queryKey: channelKeys.detail(channelId) }); - queryClient.invalidateQueries({ queryKey: contentKeys.byChannel(channelId) }); - onCompleteRef.current?.(); - } - return result; - }, - enabled: enabled && channelId > 0, - refetchInterval: enabled ? 2000 : false, - }); -} - /** Set the monitoring mode for a channel (cascades to content items). */ export function useSetMonitoringMode(channelId: number) { const queryClient = useQueryClient(); diff --git a/src/frontend/src/api/hooks/useContent.ts b/src/frontend/src/api/hooks/useContent.ts index e11739b..6f4833e 100644 --- a/src/frontend/src/api/hooks/useContent.ts +++ b/src/frontend/src/api/hooks/useContent.ts @@ -35,20 +35,6 @@ export const contentKeys = { // ── Queries ── -/** Fetch content items for a specific channel (legacy — all items). */ -export function useChannelContent(channelId: number) { - return useQuery({ - queryKey: contentKeys.byChannel(channelId), - queryFn: async () => { - const response = await apiClient.get>( - `/api/v1/channel/${channelId}/content`, - ); - return response.data; - }, - enabled: channelId > 0, - }); -} - /** Fetch paginated content items for a channel with search/filter/sort. */ export function useChannelContentPaginated(channelId: number, filters: ChannelContentFilters) { return useQuery({ diff --git a/src/frontend/src/components/ContentCard.tsx b/src/frontend/src/components/ContentCard.tsx index 82c9e10..a6424a9 100644 --- a/src/frontend/src/components/ContentCard.tsx +++ b/src/frontend/src/components/ContentCard.tsx @@ -2,36 +2,9 @@ import { Bookmark, BookmarkPlus, Download, ExternalLink, Film, Music } from 'luc import { StatusBadge } from './StatusBadge'; import { DownloadProgressBar } from './DownloadProgressBar'; import { useDownloadProgress } from '../contexts/DownloadProgressContext'; +import { formatDuration, formatRelativeTime } from '../utils/format'; import type { ContentItem } from '@shared/types/index'; -// ── Helpers ── - -function formatDuration(seconds: number | null): string { - if (seconds == null) return ''; - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = seconds % 60; - if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; - return `${m}:${String(s).padStart(2, '0')}`; -} - -function formatRelativeTime(isoString: string | null): string { - if (!isoString) return ''; - const delta = Date.now() - Date.parse(isoString); - if (delta < 0) return 'just now'; - const seconds = Math.floor(delta / 1000); - if (seconds < 60) return 'just now'; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - if (days < 30) return `${days}d ago`; - const months = Math.floor(days / 30); - if (months < 12) return `${months}mo ago`; - return `${Math.floor(months / 12)}y ago`; -} - // ── Component ── interface ContentCardProps { diff --git a/src/frontend/src/components/ContentListItem.tsx b/src/frontend/src/components/ContentListItem.tsx index e99e64d..1eec403 100644 --- a/src/frontend/src/components/ContentListItem.tsx +++ b/src/frontend/src/components/ContentListItem.tsx @@ -2,36 +2,9 @@ import { Bookmark, BookmarkPlus, Download, ExternalLink, Film, Music } from 'luc import { StatusBadge } from './StatusBadge'; import { DownloadProgressBar } from './DownloadProgressBar'; import { useDownloadProgress } from '../contexts/DownloadProgressContext'; +import { formatDuration, formatRelativeTime } from '../utils/format'; import type { ContentItem } from '@shared/types/index'; -// ── Helpers (shared pattern with ContentCard) ── - -function formatDuration(seconds: number | null): string { - if (seconds == null) return ''; - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = seconds % 60; - if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; - return `${m}:${String(s).padStart(2, '0')}`; -} - -function formatRelativeTime(isoString: string | null): string { - if (!isoString) return ''; - const delta = Date.now() - Date.parse(isoString); - if (delta < 0) return 'just now'; - const seconds = Math.floor(delta / 1000); - if (seconds < 60) return 'just now'; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - if (days < 30) return `${days}d ago`; - const months = Math.floor(days / 30); - if (months < 12) return `${months}mo ago`; - return `${Math.floor(months / 12)}y ago`; -} - // ── Component ── interface ContentListItemProps { diff --git a/src/frontend/src/pages/Activity.tsx b/src/frontend/src/pages/Activity.tsx index e90d7d3..a7e1410 100644 --- a/src/frontend/src/pages/Activity.tsx +++ b/src/frontend/src/pages/Activity.tsx @@ -6,6 +6,7 @@ import { Pagination } from '../components/Pagination'; import { FilterBar, type FilterDefinition } from '../components/FilterBar'; import { SkeletonActivityList } from '../components/Skeleton'; import { useHistory, useActivity, type HistoryFilters } from '../api/hooks/useActivity'; +import { formatRelativeTime } from '../utils/format'; import type { DownloadHistoryRecord } from '@shared/types/index'; // ── Helpers ── @@ -21,18 +22,6 @@ function formatTimestamp(iso: string): string { }); } -function formatRelativeTime(iso: string): string { - const d = new Date(iso); - const now = new Date(); - const diffMs = now.getTime() - d.getTime(); - - if (diffMs < 60_000) return 'just now'; - if (diffMs < 3600_000) return `${Math.floor(diffMs / 60_000)}m ago`; - if (diffMs < 86400_000) return `${Math.floor(diffMs / 3600_000)}h ago`; - if (diffMs < 604800_000) return `${Math.floor(diffMs / 86400_000)}d ago`; - return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); -} - function formatEventType(type: string): string { return type .split('_') diff --git a/src/frontend/src/pages/ChannelDetail.tsx b/src/frontend/src/pages/ChannelDetail.tsx index 3e33ee1..d0bf812 100644 --- a/src/frontend/src/pages/ChannelDetail.tsx +++ b/src/frontend/src/pages/ChannelDetail.tsx @@ -40,6 +40,7 @@ import { SortGroupBar, type GroupByKey } from '../components/SortGroupBar'; import { Modal } from '../components/Modal'; import { useToast } from '../components/Toast'; import { useDownloadProgress, useScanProgress } from '../contexts/DownloadProgressContext'; +import { formatDuration, formatFileSize, formatRelativeTime, formatSubscriberCount } from '../utils/format'; import type { ContentItem, MonitoringMode } from '@shared/types/index'; // ── Helpers ── @@ -55,49 +56,6 @@ function ContentStatusCell({ item }: { item: ContentItem }) { return ; } -function formatDuration(seconds: number | null): string { - if (seconds == null) return '—'; - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = seconds % 60; - if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; - return `${m}:${String(s).padStart(2, '0')}`; -} - -function formatFileSize(bytes: number | null): string { - if (bytes == null) return '—'; - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; -} - -function formatRelativeTime(isoString: string | null): string { - if (!isoString) return '—'; - const delta = Date.now() - Date.parse(isoString); - if (delta < 0) return 'just now'; - const seconds = Math.floor(delta / 1000); - if (seconds < 60) return 'just now'; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - if (days < 30) return `${days}d ago`; - const months = Math.floor(days / 30); - if (months < 12) return `${months}mo ago`; - const years = Math.floor(months / 12); - return `${years}y ago`; -} - -function formatSubscriberCount(count: number | null): string | null { - if (count == null) return null; - if (count < 1000) return `${count}`; - if (count < 1_000_000) return `${(count / 1000).toFixed(1).replace(/\.0$/, '')}K`; - if (count < 1_000_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; - return `${(count / 1_000_000_000).toFixed(1).replace(/\.0$/, '')}B`; -} - const MONITORING_MODE_OPTIONS: { value: MonitoringMode; label: string }[] = [ { value: 'all', label: 'All Content' }, { value: 'future', label: 'Future Only' }, diff --git a/src/frontend/src/pages/Channels.tsx b/src/frontend/src/pages/Channels.tsx index 98293a8..b4eb2bc 100644 --- a/src/frontend/src/pages/Channels.tsx +++ b/src/frontend/src/pages/Channels.tsx @@ -10,23 +10,9 @@ import { ProgressBar } from '../components/ProgressBar'; import { AddChannelModal } from '../components/AddChannelModal'; import { SkeletonChannelsList } from '../components/Skeleton'; import { useToast } from '../components/Toast'; +import { formatRelativeTime } from '../utils/format'; import type { ChannelWithCounts } from '@shared/types/api'; -// ── Helpers ── - -function formatRelativeTime(dateStr: string | null): string { - if (!dateStr) return '—'; - const diff = Date.now() - new Date(dateStr).getTime(); - const seconds = Math.floor(diff / 1000); - if (seconds < 60) return 'just now'; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - return `${days}d ago`; -} - // ── Component ── export function Channels() { diff --git a/src/frontend/src/pages/Library.tsx b/src/frontend/src/pages/Library.tsx index c9cc79a..4c90e6b 100644 --- a/src/frontend/src/pages/Library.tsx +++ b/src/frontend/src/pages/Library.tsx @@ -11,27 +11,9 @@ import { FilterBar, type FilterDefinition } from '../components/FilterBar'; import { SkeletonLibrary } from '../components/Skeleton'; import { useLibraryContent, type LibraryFilters } from '../api/hooks/useLibrary'; import { useChannels } from '../api/hooks/useChannels'; +import { formatDuration, formatFileSize } from '../utils/format'; import type { ContentItem, ContentStatus, ContentType } from '@shared/types/index'; -// ── Helpers ── - -function formatDuration(seconds: number | null): string { - if (seconds == null) return '—'; - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = seconds % 60; - if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; - return `${m}:${String(s).padStart(2, '0')}`; -} - -function formatFileSize(bytes: number | null): string { - if (bytes == null) return '—'; - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; -} - // ── Component ── export function Library() { diff --git a/src/frontend/src/utils/format.ts b/src/frontend/src/utils/format.ts index bfc8c45..6488ae2 100644 --- a/src/frontend/src/utils/format.ts +++ b/src/frontend/src/utils/format.ts @@ -1,6 +1,11 @@ /** - * Format a byte count into a human-readable string (B, KB, MB, GB, TB). + * Shared formatting utilities for the Tubearr frontend. + * + * Consolidates format helpers that were previously duplicated across + * ChannelDetail, Library, ContentCard, ContentListItem, Channels, Activity. */ + +/** Format a byte count into a human-readable string (B, KB, MB, GB, TB). */ export function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; @@ -8,3 +13,50 @@ export function formatBytes(bytes: number): string { if (bytes < 1024 ** 4) return `${(bytes / 1024 ** 3).toFixed(1)} GB`; return `${(bytes / 1024 ** 4).toFixed(1)} TB`; } + +/** Format a file size in bytes to a human-readable string with appropriate precision. */ +export function formatFileSize(bytes: number | null): string { + if (bytes == null) return '—'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + +/** Format seconds into h:mm:ss or m:ss. */ +export function formatDuration(seconds: number | null): string { + if (seconds == null) return '—'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; + return `${m}:${String(s).padStart(2, '0')}`; +} + +/** Format an ISO date string as a relative time (e.g. "2h ago", "3d ago"). */ +export function formatRelativeTime(isoString: string | null): string { + if (!isoString) return '—'; + const delta = Date.now() - Date.parse(isoString); + if (delta < 0) return 'just now'; + const seconds = Math.floor(delta / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo ago`; + const years = Math.floor(months / 12); + return `${years}y ago`; +} + +/** Format a subscriber/follower count to a compact string (e.g. "6.7M"). */ +export function formatSubscriberCount(count: number | null): string | null { + if (count == null) return null; + if (count < 1000) return `${count}`; + if (count < 1_000_000) return `${(count / 1000).toFixed(1).replace(/\.0$/, '')}K`; + if (count < 1_000_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; + return `${(count / 1_000_000_000).toFixed(1).replace(/\.0$/, '')}B`; +} diff --git a/src/server/routes/channel.ts b/src/server/routes/channel.ts index 4b4a0fc..e59cfa2 100644 --- a/src/server/routes/channel.ts +++ b/src/server/routes/channel.ts @@ -1,4 +1,5 @@ import { type FastifyInstance } from 'fastify'; +import { parseIdParam } from './helpers'; import { PlatformRegistry } from '../../sources/platform-source'; import { YouTubeSource } from '../../sources/youtube'; import { SoundCloudSource } from '../../sources/soundcloud'; @@ -230,14 +231,8 @@ export async function channelRoutes(fastify: FastifyInstance): Promise { fastify.get<{ Params: { id: string } }>( '/api/v1/channel/:id', async (request, reply) => { - const id = parseInt(request.params.id, 10); - if (isNaN(id)) { - return reply.status(400).send({ - statusCode: 400, - error: 'Bad Request', - message: 'Channel ID must be a number', - }); - } + const id = parseIdParam(request.params.id, reply, 'Channel ID'); + if (id === null) return; const channel = await getChannelById(fastify.db, id); if (!channel) { @@ -263,14 +258,8 @@ export async function channelRoutes(fastify: FastifyInstance): Promise { schema: { body: updateChannelBodySchema }, }, async (request, reply) => { - const id = parseInt(request.params.id, 10); - if (isNaN(id)) { - return reply.status(400).send({ - statusCode: 400, - error: 'Bad Request', - message: 'Channel ID must be a number', - }); - } + const id = parseIdParam(request.params.id, reply, 'Channel ID'); + if (id === null) return; const updated = await updateChannel(fastify.db, id, request.body); if (!updated) { @@ -297,14 +286,8 @@ export async function channelRoutes(fastify: FastifyInstance): Promise { '/api/v1/channel/:id/monitoring-mode', { schema: { body: monitoringModeBodySchema } }, async (request, reply) => { - const id = parseInt(request.params.id, 10); - if (isNaN(id)) { - return reply.status(400).send({ - statusCode: 400, - error: 'Bad Request', - message: 'Channel ID must be a number', - }); - } + const id = parseIdParam(request.params.id, reply, 'Channel ID'); + if (id === null) return; try { const result = await setMonitoringMode( @@ -341,14 +324,8 @@ export async function channelRoutes(fastify: FastifyInstance): Promise { fastify.delete<{ Params: { id: string } }>( '/api/v1/channel/:id', async (request, reply) => { - const id = parseInt(request.params.id, 10); - if (isNaN(id)) { - return reply.status(400).send({ - statusCode: 400, - error: 'Bad Request', - message: 'Channel ID must be a number', - }); - } + const id = parseIdParam(request.params.id, reply, 'Channel ID'); + if (id === null) return; // Verify channel exists before deleting const existing = await getChannelById(fastify.db, id); diff --git a/src/server/routes/collect.ts b/src/server/routes/collect.ts index 829ebad..6e4f6c8 100644 --- a/src/server/routes/collect.ts +++ b/src/server/routes/collect.ts @@ -1,4 +1,5 @@ import { type FastifyInstance } from 'fastify'; +import { parseIdParam } from './helpers'; import { getCollectibleItems } from '../../db/repositories/content-repository'; import { getChannelById } from '../../db/repositories/channel-repository'; @@ -57,14 +58,8 @@ export async function collectRoutes(fastify: FastifyInstance): Promise { fastify.post<{ Params: { id: string } }>( '/api/v1/channel/:id/collect', async (request, reply) => { - const id = parseInt(request.params.id, 10); - if (isNaN(id)) { - return reply.status(400).send({ - statusCode: 400, - error: 'Bad Request', - message: 'Channel ID must be a number', - }); - } + const id = parseIdParam(request.params.id, reply, 'Channel ID'); + if (id === null) return; const channel = await getChannelById(fastify.db, id); if (!channel) { diff --git a/src/server/routes/content.ts b/src/server/routes/content.ts index 8edd4d7..3dcebf3 100644 --- a/src/server/routes/content.ts +++ b/src/server/routes/content.ts @@ -1,4 +1,5 @@ import { type FastifyInstance } from 'fastify'; +import { parseIdParam } from './helpers'; import { getAllContentItems, getContentByChannelId, @@ -155,14 +156,8 @@ export async function contentRoutes(fastify: FastifyInstance): Promise { '/api/v1/content/:id/monitored', { schema: { body: toggleMonitoredBodySchema } }, async (request, reply) => { - const id = parseInt(request.params.id, 10); - if (isNaN(id)) { - return reply.status(400).send({ - statusCode: 400, - error: 'Bad Request', - message: 'Content item ID must be a number', - }); - } + const id = parseIdParam(request.params.id, reply, 'Content item ID'); + if (id === null) return; try { const result = await setMonitored( @@ -213,15 +208,8 @@ export async function contentRoutes(fastify: FastifyInstance): Promise { sortDirection?: string; }; }>('/api/v1/channel/:id/content', async (request, reply) => { - const channelId = parseInt(request.params.id, 10); - - if (isNaN(channelId)) { - return reply.status(400).send({ - statusCode: 400, - error: 'Bad Request', - message: 'Invalid channel ID', - }); - } + const channelId = parseIdParam(request.params.id, reply, 'Channel ID'); + if (channelId === null) return; const page = Math.max(1, parseInt(request.query.page ?? '1', 10) || 1); const pageSize = Math.min( diff --git a/src/server/routes/format-profile.ts b/src/server/routes/format-profile.ts index 74e7bc7..79d0652 100644 --- a/src/server/routes/format-profile.ts +++ b/src/server/routes/format-profile.ts @@ -1,4 +1,5 @@ import { type FastifyInstance } from 'fastify'; +import { parseIdParam } from './helpers'; import { createFormatProfile, getAllFormatProfiles, @@ -88,14 +89,8 @@ export async function formatProfileRoutes(fastify: FastifyInstance): Promise( '/api/v1/format-profile/:id', async (request, reply) => { - const id = parseInt(request.params.id, 10); - if (isNaN(id)) { - return reply.status(400).send({ - statusCode: 400, - error: 'Bad Request', - message: 'Format profile ID must be a number', - }); - } + const id = parseIdParam(request.params.id, reply, 'Format profile ID'); + if (id === null) return; const profile = await getFormatProfileById(fastify.db, id); if (!profile) { @@ -130,14 +125,8 @@ export async function formatProfileRoutes(fastify: FastifyInstance): Promise { - const id = parseInt(request.params.id, 10); - if (isNaN(id)) { - return reply.status(400).send({ - statusCode: 400, - error: 'Bad Request', - message: 'Format profile ID must be a number', - }); - } + const id = parseIdParam(request.params.id, reply, 'Format profile ID'); + if (id === null) return; // Guard: prevent unsetting isDefault on the default profile const existing = await getFormatProfileById(fastify.db, id); @@ -174,14 +163,8 @@ export async function formatProfileRoutes(fastify: FastifyInstance): Promise( '/api/v1/format-profile/:id', async (request, reply) => { - const id = parseInt(request.params.id, 10); - if (isNaN(id)) { - return reply.status(400).send({ - statusCode: 400, - error: 'Bad Request', - message: 'Format profile ID must be a number', - }); - } + const id = parseIdParam(request.params.id, reply, 'Format profile ID'); + if (id === null) return; // Guard: prevent deleting the default profile const profile = await getFormatProfileById(fastify.db, id); diff --git a/src/server/routes/helpers.ts b/src/server/routes/helpers.ts new file mode 100644 index 0000000..d5e5209 --- /dev/null +++ b/src/server/routes/helpers.ts @@ -0,0 +1,26 @@ +import type { FastifyReply } from 'fastify'; + +/** + * Parse a numeric ID from route params. + * Returns the parsed number, or sends a 400 response and returns null. + * + * Usage: + * const id = parseIdParam(request.params.id, reply); + * if (id === null) return; + */ +export function parseIdParam( + raw: string, + reply: FastifyReply, + label = 'ID', +): number | null { + const id = parseInt(raw, 10); + if (isNaN(id)) { + reply.status(400).send({ + statusCode: 400, + error: 'Bad Request', + message: `${label} must be a number`, + }); + return null; + } + return id; +} diff --git a/src/server/routes/notification.ts b/src/server/routes/notification.ts index 429251e..fdb112f 100644 --- a/src/server/routes/notification.ts +++ b/src/server/routes/notification.ts @@ -1,4 +1,5 @@ import { type FastifyInstance } from 'fastify'; +import { parseIdParam } from './helpers'; import { createNotificationSetting, getAllNotificationSettings, @@ -122,14 +123,8 @@ export async function notificationRoutes(fastify: FastifyInstance): Promise( '/api/v1/notification/:id', async (request, reply) => { - const id = parseInt(request.params.id, 10); - if (isNaN(id)) { - return reply.status(400).send({ - statusCode: 400, - error: 'Bad Request', - message: 'Notification setting ID must be a number', - }); - } + const id = parseIdParam(request.params.id, reply, 'Notification setting ID'); + if (id === null) return; const setting = await getNotificationSettingById(fastify.db, id); if (!setting) { @@ -163,14 +158,8 @@ export async function notificationRoutes(fastify: FastifyInstance): Promise { - const id = parseInt(request.params.id, 10); - if (isNaN(id)) { - return reply.status(400).send({ - statusCode: 400, - error: 'Bad Request', - message: 'Notification setting ID must be a number', - }); - } + const id = parseIdParam(request.params.id, reply, 'Notification setting ID'); + if (id === null) return; const updated = await updateNotificationSetting(fastify.db, id, request.body); if (!updated) { @@ -190,14 +179,8 @@ export async function notificationRoutes(fastify: FastifyInstance): Promise( '/api/v1/notification/:id', async (request, reply) => { - const id = parseInt(request.params.id, 10); - if (isNaN(id)) { - return reply.status(400).send({ - statusCode: 400, - error: 'Bad Request', - message: 'Notification setting ID must be a number', - }); - } + const id = parseIdParam(request.params.id, reply, 'Notification setting ID'); + if (id === null) return; const deleted = await deleteNotificationSetting(fastify.db, id); if (!deleted) { @@ -217,14 +200,8 @@ export async function notificationRoutes(fastify: FastifyInstance): Promise( '/api/v1/notification/:id/test', async (request, reply) => { - const id = parseInt(request.params.id, 10); - if (isNaN(id)) { - return reply.status(400).send({ - statusCode: 400, - error: 'Bad Request', - message: 'Notification setting ID must be a number', - }); - } + const id = parseIdParam(request.params.id, reply, 'Notification setting ID'); + if (id === null) return; const setting = await getNotificationSettingById(fastify.db, id); if (!setting) { diff --git a/src/server/routes/playlist.ts b/src/server/routes/playlist.ts index 03bfefb..b174b1d 100644 --- a/src/server/routes/playlist.ts +++ b/src/server/routes/playlist.ts @@ -1,4 +1,5 @@ import { type FastifyInstance } from 'fastify'; +import { parseIdParam } from './helpers'; import { PlatformRegistry } from '../../sources/platform-source'; import { YouTubeSource } from '../../sources/youtube'; import { SoundCloudSource } from '../../sources/soundcloud'; @@ -36,14 +37,8 @@ export async function playlistRoutes(fastify: FastifyInstance): Promise { fastify.get<{ Params: { id: string } }>( '/api/v1/channel/:id/playlists', async (request, reply) => { - const id = parseInt(request.params.id, 10); - if (isNaN(id)) { - return reply.status(400).send({ - statusCode: 400, - error: 'Bad Request', - message: 'Channel ID must be a number', - }); - } + const id = parseIdParam(request.params.id, reply, 'Channel ID'); + if (id === null) return; const channel = await getChannelById(fastify.db, id); if (!channel) { @@ -66,14 +61,8 @@ export async function playlistRoutes(fastify: FastifyInstance): Promise { fastify.post<{ Params: { id: string } }>( '/api/v1/channel/:id/playlists/refresh', async (request, reply) => { - const id = parseInt(request.params.id, 10); - if (isNaN(id)) { - return reply.status(400).send({ - statusCode: 400, - error: 'Bad Request', - message: 'Channel ID must be a number', - }); - } + const id = parseIdParam(request.params.id, reply, 'Channel ID'); + if (id === null) return; const channel = await getChannelById(fastify.db, id); if (!channel) { diff --git a/src/server/routes/queue.ts b/src/server/routes/queue.ts index 4faf99f..ab952cc 100644 --- a/src/server/routes/queue.ts +++ b/src/server/routes/queue.ts @@ -1,4 +1,5 @@ import { type FastifyInstance } from 'fastify'; +import { parseIdParam } from './helpers'; import { getQueueItemsByStatus, getQueueItemById, @@ -57,14 +58,8 @@ export async function queueRoutes(fastify: FastifyInstance): Promise { fastify.get<{ Params: { id: string } }>( '/api/v1/queue/:id', async (request, reply) => { - const id = parseInt(request.params.id, 10); - if (isNaN(id)) { - return reply.status(400).send({ - statusCode: 400, - error: 'Bad Request', - message: 'Queue item ID must be a number', - }); - } + const id = parseIdParam(request.params.id, reply, 'Queue item ID'); + if (id === null) return; const item = await getQueueItemById(fastify.db, id); if (!item) { @@ -141,14 +136,8 @@ export async function queueRoutes(fastify: FastifyInstance): Promise { fastify.delete<{ Params: { id: string } }>( '/api/v1/queue/:id', async (request, reply) => { - const id = parseInt(request.params.id, 10); - if (isNaN(id)) { - return reply.status(400).send({ - statusCode: 400, - error: 'Bad Request', - message: 'Queue item ID must be a number', - }); - } + const id = parseIdParam(request.params.id, reply, 'Queue item ID'); + if (id === null) return; if (!fastify.queueService) { return reply.status(503).send({ @@ -190,14 +179,8 @@ export async function queueRoutes(fastify: FastifyInstance): Promise { fastify.post<{ Params: { id: string } }>( '/api/v1/queue/:id/retry', async (request, reply) => { - const id = parseInt(request.params.id, 10); - if (isNaN(id)) { - return reply.status(400).send({ - statusCode: 400, - error: 'Bad Request', - message: 'Queue item ID must be a number', - }); - } + const id = parseIdParam(request.params.id, reply, 'Queue item ID'); + if (id === null) return; if (!fastify.queueService) { return reply.status(503).send({ diff --git a/src/server/routes/scan.ts b/src/server/routes/scan.ts index 183d45c..cad8500 100644 --- a/src/server/routes/scan.ts +++ b/src/server/routes/scan.ts @@ -1,4 +1,5 @@ import { type FastifyInstance } from 'fastify'; +import { parseIdParam } from './helpers'; import type { CheckChannelResult } from '../../services/scheduler'; import { getChannelById, @@ -75,14 +76,8 @@ export async function scanRoutes(fastify: FastifyInstance): Promise { fastify.post<{ Params: { id: string } }>( '/api/v1/channel/:id/scan', async (request, reply) => { - const id = parseInt(request.params.id, 10); - if (isNaN(id)) { - return reply.status(400).send({ - statusCode: 400, - error: 'Bad Request', - message: 'Channel ID must be a number', - }); - } + const id = parseIdParam(request.params.id, reply, 'Channel ID'); + if (id === null) return; const channel = await getChannelById(fastify.db, id); if (!channel) { @@ -137,14 +132,8 @@ export async function scanRoutes(fastify: FastifyInstance): Promise { fastify.post<{ Params: { id: string } }>( '/api/v1/channel/:id/scan-cancel', async (request, reply) => { - const id = parseInt(request.params.id, 10); - if (isNaN(id)) { - return reply.status(400).send({ - statusCode: 400, - error: 'Bad Request', - message: 'Channel ID must be a number', - }); - } + const id = parseIdParam(request.params.id, reply, 'Channel ID'); + if (id === null) return; if (!fastify.scheduler) { return reply.status(503).send({ @@ -164,14 +153,8 @@ export async function scanRoutes(fastify: FastifyInstance): Promise { fastify.get<{ Params: { id: string } }>( '/api/v1/channel/:id/scan-status', async (request, reply) => { - const id = parseInt(request.params.id, 10); - if (isNaN(id)) { - return reply.status(400).send({ - statusCode: 400, - error: 'Bad Request', - message: 'Channel ID must be a number', - }); - } + const id = parseIdParam(request.params.id, reply, 'Channel ID'); + if (id === null) return; const scanning = fastify.scheduler?.isScanning(id) ?? false; return { scanning }; diff --git a/src/server/routes/system.ts b/src/server/routes/system.ts index 7090e2d..36287cd 100644 --- a/src/server/routes/system.ts +++ b/src/server/routes/system.ts @@ -168,8 +168,8 @@ export async function systemRoutes(fastify: FastifyInstance): Promise { await setAppSetting(db, APP_CONCURRENT_DOWNLOADS, body.concurrentDownloads.toString()); // Update queue concurrency at runtime - if ((fastify as any).queueService?.setConcurrency) { - (fastify as any).queueService.setConcurrency(body.concurrentDownloads); + if (fastify.queueService?.setConcurrency) { + fastify.queueService.setConcurrency(body.concurrentDownloads); } }