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
This commit is contained in:
jlightner 2026-04-03 22:55:43 +00:00
parent 1078b6dcd7
commit b1e90ea8d6
24 changed files with 139 additions and 524 deletions

View file

@ -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 ──

View file

@ -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, {

View file

@ -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<ContentItem[]> {
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 ──

View file

@ -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<void> {
await db.delete(playlists).where(eq(playlists.channelId, channelId));
}
// ── Row Mapping ──

View file

@ -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<boolean> {
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).

View file

@ -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<ScanStatusResponse>(
`/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();

View file

@ -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<ApiResponse<ContentItem[]>>(
`/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({

View file

@ -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 {

View file

@ -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 {

View file

@ -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('_')

View file

@ -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 <StatusBadge status={item.status} />;
}
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' },

View file

@ -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() {

View file

@ -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() {

View file

@ -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`;
}

View file

@ -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<void> {
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<void> {
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<void> {
'/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<void> {
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);

View file

@ -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<void> {
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) {

View file

@ -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<void> {
'/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<void> {
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(

View file

@ -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<voi
fastify.get<{ Params: { id: string } }>(
'/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<voi
schema: { body: updateFormatProfileBodySchema },
},
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 unsetting isDefault on the default profile
const existing = await getFormatProfileById(fastify.db, id);
@ -174,14 +163,8 @@ export async function formatProfileRoutes(fastify: FastifyInstance): Promise<voi
fastify.delete<{ Params: { id: string } }>(
'/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);

View file

@ -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;
}

View file

@ -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<void
fastify.get<{ Params: { id: string } }>(
'/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<void
schema: { body: updateNotificationBodySchema },
},
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 updated = await updateNotificationSetting(fastify.db, id, request.body);
if (!updated) {
@ -190,14 +179,8 @@ export async function notificationRoutes(fastify: FastifyInstance): Promise<void
fastify.delete<{ Params: { id: string } }>(
'/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<void
fastify.post<{ Params: { id: string } }>(
'/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) {

View file

@ -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<void> {
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<void> {
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) {

View file

@ -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<void> {
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<void> {
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<void> {
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({

View file

@ -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<void> {
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<void> {
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<void> {
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 };

View file

@ -168,8 +168,8 @@ export async function systemRoutes(fastify: FastifyInstance): Promise<void> {
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);
}
}