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:
parent
1078b6dcd7
commit
b1e90ea8d6
24 changed files with 139 additions and 524 deletions
|
|
@ -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 ──
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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('_')
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
26
src/server/routes/helpers.ts
Normal file
26
src/server/routes/helpers.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue