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,
|
createContentItem,
|
||||||
getContentItemById,
|
getContentItemById,
|
||||||
updateContentItem,
|
updateContentItem,
|
||||||
getContentItemsByStatus,
|
|
||||||
} from '../db/repositories/content-repository';
|
} from '../db/repositories/content-repository';
|
||||||
import type { Platform } from '../types/index';
|
import type { Platform } from '../types/index';
|
||||||
|
|
||||||
|
|
@ -440,95 +439,6 @@ describe('Content Item Update & Query Functions', () => {
|
||||||
const result = await updateContentItem(db, 999, { status: 'failed' });
|
const result = await updateContentItem(db, 999, { status: 'failed' });
|
||||||
expect(result).toBeNull();
|
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 ──
|
// ── Config ──
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import {
|
||||||
getPendingQueueItems,
|
getPendingQueueItems,
|
||||||
updateQueueItemStatus,
|
updateQueueItemStatus,
|
||||||
countQueueItemsByStatus,
|
countQueueItemsByStatus,
|
||||||
deleteQueueItem,
|
|
||||||
getQueueItemByContentItemId,
|
getQueueItemByContentItemId,
|
||||||
} from '../db/repositories/queue-repository';
|
} from '../db/repositories/queue-repository';
|
||||||
import type { Channel, ContentItem } from '../types/index';
|
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', () => {
|
describe('getQueueItemByContentItemId', () => {
|
||||||
it('returns queue item for a given content item ID', async () => {
|
it('returns queue item for a given content item ID', async () => {
|
||||||
const item = await createQueueItem(db, {
|
const item = await createQueueItem(db, {
|
||||||
|
|
|
||||||
|
|
@ -288,24 +288,6 @@ export async function bulkSetMonitored(
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get content items by status, ordered by creation date (oldest first). */
|
/** 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 ──
|
// ── Paginated Listing ──
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -150,12 +150,6 @@ export async function getContentPlaylistMappings(
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Delete all playlists for a channel. Cascade handles junction rows. */
|
/** 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 ──
|
// ── Row Mapping ──
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -202,17 +202,6 @@ export async function countQueueItemsByStatus(
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Delete a queue item by ID. Returns true if a row was deleted. */
|
/** 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).
|
* 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). */
|
/** Set the monitoring mode for a channel (cascades to content items). */
|
||||||
export function useSetMonitoringMode(channelId: number) {
|
export function useSetMonitoringMode(channelId: number) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
|
||||||
|
|
@ -35,20 +35,6 @@ export const contentKeys = {
|
||||||
|
|
||||||
// ── Queries ──
|
// ── 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. */
|
/** Fetch paginated content items for a channel with search/filter/sort. */
|
||||||
export function useChannelContentPaginated(channelId: number, filters: ChannelContentFilters) {
|
export function useChannelContentPaginated(channelId: number, filters: ChannelContentFilters) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|
|
||||||
|
|
@ -2,36 +2,9 @@ import { Bookmark, BookmarkPlus, Download, ExternalLink, Film, Music } from 'luc
|
||||||
import { StatusBadge } from './StatusBadge';
|
import { StatusBadge } from './StatusBadge';
|
||||||
import { DownloadProgressBar } from './DownloadProgressBar';
|
import { DownloadProgressBar } from './DownloadProgressBar';
|
||||||
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
|
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
|
||||||
|
import { formatDuration, formatRelativeTime } from '../utils/format';
|
||||||
import type { ContentItem } from '@shared/types/index';
|
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 ──
|
// ── Component ──
|
||||||
|
|
||||||
interface ContentCardProps {
|
interface ContentCardProps {
|
||||||
|
|
|
||||||
|
|
@ -2,36 +2,9 @@ import { Bookmark, BookmarkPlus, Download, ExternalLink, Film, Music } from 'luc
|
||||||
import { StatusBadge } from './StatusBadge';
|
import { StatusBadge } from './StatusBadge';
|
||||||
import { DownloadProgressBar } from './DownloadProgressBar';
|
import { DownloadProgressBar } from './DownloadProgressBar';
|
||||||
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
|
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
|
||||||
|
import { formatDuration, formatRelativeTime } from '../utils/format';
|
||||||
import type { ContentItem } from '@shared/types/index';
|
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 ──
|
// ── Component ──
|
||||||
|
|
||||||
interface ContentListItemProps {
|
interface ContentListItemProps {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Pagination } from '../components/Pagination';
|
||||||
import { FilterBar, type FilterDefinition } from '../components/FilterBar';
|
import { FilterBar, type FilterDefinition } from '../components/FilterBar';
|
||||||
import { SkeletonActivityList } from '../components/Skeleton';
|
import { SkeletonActivityList } from '../components/Skeleton';
|
||||||
import { useHistory, useActivity, type HistoryFilters } from '../api/hooks/useActivity';
|
import { useHistory, useActivity, type HistoryFilters } from '../api/hooks/useActivity';
|
||||||
|
import { formatRelativeTime } from '../utils/format';
|
||||||
import type { DownloadHistoryRecord } from '@shared/types/index';
|
import type { DownloadHistoryRecord } from '@shared/types/index';
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── 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 {
|
function formatEventType(type: string): string {
|
||||||
return type
|
return type
|
||||||
.split('_')
|
.split('_')
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import { SortGroupBar, type GroupByKey } from '../components/SortGroupBar';
|
||||||
import { Modal } from '../components/Modal';
|
import { Modal } from '../components/Modal';
|
||||||
import { useToast } from '../components/Toast';
|
import { useToast } from '../components/Toast';
|
||||||
import { useDownloadProgress, useScanProgress } from '../contexts/DownloadProgressContext';
|
import { useDownloadProgress, useScanProgress } from '../contexts/DownloadProgressContext';
|
||||||
|
import { formatDuration, formatFileSize, formatRelativeTime, formatSubscriberCount } from '../utils/format';
|
||||||
import type { ContentItem, MonitoringMode } from '@shared/types/index';
|
import type { ContentItem, MonitoringMode } from '@shared/types/index';
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
@ -55,49 +56,6 @@ function ContentStatusCell({ item }: { item: ContentItem }) {
|
||||||
return <StatusBadge status={item.status} />;
|
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 }[] = [
|
const MONITORING_MODE_OPTIONS: { value: MonitoringMode; label: string }[] = [
|
||||||
{ value: 'all', label: 'All Content' },
|
{ value: 'all', label: 'All Content' },
|
||||||
{ value: 'future', label: 'Future Only' },
|
{ value: 'future', label: 'Future Only' },
|
||||||
|
|
|
||||||
|
|
@ -10,23 +10,9 @@ import { ProgressBar } from '../components/ProgressBar';
|
||||||
import { AddChannelModal } from '../components/AddChannelModal';
|
import { AddChannelModal } from '../components/AddChannelModal';
|
||||||
import { SkeletonChannelsList } from '../components/Skeleton';
|
import { SkeletonChannelsList } from '../components/Skeleton';
|
||||||
import { useToast } from '../components/Toast';
|
import { useToast } from '../components/Toast';
|
||||||
|
import { formatRelativeTime } from '../utils/format';
|
||||||
import type { ChannelWithCounts } from '@shared/types/api';
|
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 ──
|
// ── Component ──
|
||||||
|
|
||||||
export function Channels() {
|
export function Channels() {
|
||||||
|
|
|
||||||
|
|
@ -11,27 +11,9 @@ import { FilterBar, type FilterDefinition } from '../components/FilterBar';
|
||||||
import { SkeletonLibrary } from '../components/Skeleton';
|
import { SkeletonLibrary } from '../components/Skeleton';
|
||||||
import { useLibraryContent, type LibraryFilters } from '../api/hooks/useLibrary';
|
import { useLibraryContent, type LibraryFilters } from '../api/hooks/useLibrary';
|
||||||
import { useChannels } from '../api/hooks/useChannels';
|
import { useChannels } from '../api/hooks/useChannels';
|
||||||
|
import { formatDuration, formatFileSize } from '../utils/format';
|
||||||
import type { ContentItem, ContentStatus, ContentType } from '@shared/types/index';
|
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 ──
|
// ── Component ──
|
||||||
|
|
||||||
export function Library() {
|
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 {
|
export function formatBytes(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
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`;
|
if (bytes < 1024 ** 4) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
||||||
return `${(bytes / 1024 ** 4).toFixed(1)} TB`;
|
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 { type FastifyInstance } from 'fastify';
|
||||||
|
import { parseIdParam } from './helpers';
|
||||||
import { PlatformRegistry } from '../../sources/platform-source';
|
import { PlatformRegistry } from '../../sources/platform-source';
|
||||||
import { YouTubeSource } from '../../sources/youtube';
|
import { YouTubeSource } from '../../sources/youtube';
|
||||||
import { SoundCloudSource } from '../../sources/soundcloud';
|
import { SoundCloudSource } from '../../sources/soundcloud';
|
||||||
|
|
@ -230,14 +231,8 @@ export async function channelRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.get<{ Params: { id: string } }>(
|
fastify.get<{ Params: { id: string } }>(
|
||||||
'/api/v1/channel/:id',
|
'/api/v1/channel/:id',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseInt(request.params.id, 10);
|
const id = parseIdParam(request.params.id, reply, 'Channel ID');
|
||||||
if (isNaN(id)) {
|
if (id === null) return;
|
||||||
return reply.status(400).send({
|
|
||||||
statusCode: 400,
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Channel ID must be a number',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const channel = await getChannelById(fastify.db, id);
|
const channel = await getChannelById(fastify.db, id);
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
|
|
@ -263,14 +258,8 @@ export async function channelRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
schema: { body: updateChannelBodySchema },
|
schema: { body: updateChannelBodySchema },
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseInt(request.params.id, 10);
|
const id = parseIdParam(request.params.id, reply, 'Channel ID');
|
||||||
if (isNaN(id)) {
|
if (id === null) return;
|
||||||
return reply.status(400).send({
|
|
||||||
statusCode: 400,
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Channel ID must be a number',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await updateChannel(fastify.db, id, request.body);
|
const updated = await updateChannel(fastify.db, id, request.body);
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
|
|
@ -297,14 +286,8 @@ export async function channelRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
'/api/v1/channel/:id/monitoring-mode',
|
'/api/v1/channel/:id/monitoring-mode',
|
||||||
{ schema: { body: monitoringModeBodySchema } },
|
{ schema: { body: monitoringModeBodySchema } },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseInt(request.params.id, 10);
|
const id = parseIdParam(request.params.id, reply, 'Channel ID');
|
||||||
if (isNaN(id)) {
|
if (id === null) return;
|
||||||
return reply.status(400).send({
|
|
||||||
statusCode: 400,
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Channel ID must be a number',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await setMonitoringMode(
|
const result = await setMonitoringMode(
|
||||||
|
|
@ -341,14 +324,8 @@ export async function channelRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.delete<{ Params: { id: string } }>(
|
fastify.delete<{ Params: { id: string } }>(
|
||||||
'/api/v1/channel/:id',
|
'/api/v1/channel/:id',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseInt(request.params.id, 10);
|
const id = parseIdParam(request.params.id, reply, 'Channel ID');
|
||||||
if (isNaN(id)) {
|
if (id === null) return;
|
||||||
return reply.status(400).send({
|
|
||||||
statusCode: 400,
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Channel ID must be a number',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify channel exists before deleting
|
// Verify channel exists before deleting
|
||||||
const existing = await getChannelById(fastify.db, id);
|
const existing = await getChannelById(fastify.db, id);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { type FastifyInstance } from 'fastify';
|
import { type FastifyInstance } from 'fastify';
|
||||||
|
import { parseIdParam } from './helpers';
|
||||||
import { getCollectibleItems } from '../../db/repositories/content-repository';
|
import { getCollectibleItems } from '../../db/repositories/content-repository';
|
||||||
import { getChannelById } from '../../db/repositories/channel-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 } }>(
|
fastify.post<{ Params: { id: string } }>(
|
||||||
'/api/v1/channel/:id/collect',
|
'/api/v1/channel/:id/collect',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseInt(request.params.id, 10);
|
const id = parseIdParam(request.params.id, reply, 'Channel ID');
|
||||||
if (isNaN(id)) {
|
if (id === null) return;
|
||||||
return reply.status(400).send({
|
|
||||||
statusCode: 400,
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Channel ID must be a number',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const channel = await getChannelById(fastify.db, id);
|
const channel = await getChannelById(fastify.db, id);
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { type FastifyInstance } from 'fastify';
|
import { type FastifyInstance } from 'fastify';
|
||||||
|
import { parseIdParam } from './helpers';
|
||||||
import {
|
import {
|
||||||
getAllContentItems,
|
getAllContentItems,
|
||||||
getContentByChannelId,
|
getContentByChannelId,
|
||||||
|
|
@ -155,14 +156,8 @@ export async function contentRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
'/api/v1/content/:id/monitored',
|
'/api/v1/content/:id/monitored',
|
||||||
{ schema: { body: toggleMonitoredBodySchema } },
|
{ schema: { body: toggleMonitoredBodySchema } },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseInt(request.params.id, 10);
|
const id = parseIdParam(request.params.id, reply, 'Content item ID');
|
||||||
if (isNaN(id)) {
|
if (id === null) return;
|
||||||
return reply.status(400).send({
|
|
||||||
statusCode: 400,
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Content item ID must be a number',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await setMonitored(
|
const result = await setMonitored(
|
||||||
|
|
@ -213,15 +208,8 @@ export async function contentRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
sortDirection?: string;
|
sortDirection?: string;
|
||||||
};
|
};
|
||||||
}>('/api/v1/channel/:id/content', async (request, reply) => {
|
}>('/api/v1/channel/:id/content', async (request, reply) => {
|
||||||
const channelId = parseInt(request.params.id, 10);
|
const channelId = parseIdParam(request.params.id, reply, 'Channel ID');
|
||||||
|
if (channelId === null) return;
|
||||||
if (isNaN(channelId)) {
|
|
||||||
return reply.status(400).send({
|
|
||||||
statusCode: 400,
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Invalid channel ID',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = Math.max(1, parseInt(request.query.page ?? '1', 10) || 1);
|
const page = Math.max(1, parseInt(request.query.page ?? '1', 10) || 1);
|
||||||
const pageSize = Math.min(
|
const pageSize = Math.min(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { type FastifyInstance } from 'fastify';
|
import { type FastifyInstance } from 'fastify';
|
||||||
|
import { parseIdParam } from './helpers';
|
||||||
import {
|
import {
|
||||||
createFormatProfile,
|
createFormatProfile,
|
||||||
getAllFormatProfiles,
|
getAllFormatProfiles,
|
||||||
|
|
@ -88,14 +89,8 @@ export async function formatProfileRoutes(fastify: FastifyInstance): Promise<voi
|
||||||
fastify.get<{ Params: { id: string } }>(
|
fastify.get<{ Params: { id: string } }>(
|
||||||
'/api/v1/format-profile/:id',
|
'/api/v1/format-profile/:id',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseInt(request.params.id, 10);
|
const id = parseIdParam(request.params.id, reply, 'Format profile ID');
|
||||||
if (isNaN(id)) {
|
if (id === null) return;
|
||||||
return reply.status(400).send({
|
|
||||||
statusCode: 400,
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Format profile ID must be a number',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = await getFormatProfileById(fastify.db, id);
|
const profile = await getFormatProfileById(fastify.db, id);
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
|
|
@ -130,14 +125,8 @@ export async function formatProfileRoutes(fastify: FastifyInstance): Promise<voi
|
||||||
schema: { body: updateFormatProfileBodySchema },
|
schema: { body: updateFormatProfileBodySchema },
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseInt(request.params.id, 10);
|
const id = parseIdParam(request.params.id, reply, 'Format profile ID');
|
||||||
if (isNaN(id)) {
|
if (id === null) return;
|
||||||
return reply.status(400).send({
|
|
||||||
statusCode: 400,
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Format profile ID must be a number',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guard: prevent unsetting isDefault on the default profile
|
// Guard: prevent unsetting isDefault on the default profile
|
||||||
const existing = await getFormatProfileById(fastify.db, id);
|
const existing = await getFormatProfileById(fastify.db, id);
|
||||||
|
|
@ -174,14 +163,8 @@ export async function formatProfileRoutes(fastify: FastifyInstance): Promise<voi
|
||||||
fastify.delete<{ Params: { id: string } }>(
|
fastify.delete<{ Params: { id: string } }>(
|
||||||
'/api/v1/format-profile/:id',
|
'/api/v1/format-profile/:id',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseInt(request.params.id, 10);
|
const id = parseIdParam(request.params.id, reply, 'Format profile ID');
|
||||||
if (isNaN(id)) {
|
if (id === null) return;
|
||||||
return reply.status(400).send({
|
|
||||||
statusCode: 400,
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Format profile ID must be a number',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guard: prevent deleting the default profile
|
// Guard: prevent deleting the default profile
|
||||||
const profile = await getFormatProfileById(fastify.db, id);
|
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 { type FastifyInstance } from 'fastify';
|
||||||
|
import { parseIdParam } from './helpers';
|
||||||
import {
|
import {
|
||||||
createNotificationSetting,
|
createNotificationSetting,
|
||||||
getAllNotificationSettings,
|
getAllNotificationSettings,
|
||||||
|
|
@ -122,14 +123,8 @@ export async function notificationRoutes(fastify: FastifyInstance): Promise<void
|
||||||
fastify.get<{ Params: { id: string } }>(
|
fastify.get<{ Params: { id: string } }>(
|
||||||
'/api/v1/notification/:id',
|
'/api/v1/notification/:id',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseInt(request.params.id, 10);
|
const id = parseIdParam(request.params.id, reply, 'Notification setting ID');
|
||||||
if (isNaN(id)) {
|
if (id === null) return;
|
||||||
return reply.status(400).send({
|
|
||||||
statusCode: 400,
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Notification setting ID must be a number',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const setting = await getNotificationSettingById(fastify.db, id);
|
const setting = await getNotificationSettingById(fastify.db, id);
|
||||||
if (!setting) {
|
if (!setting) {
|
||||||
|
|
@ -163,14 +158,8 @@ export async function notificationRoutes(fastify: FastifyInstance): Promise<void
|
||||||
schema: { body: updateNotificationBodySchema },
|
schema: { body: updateNotificationBodySchema },
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseInt(request.params.id, 10);
|
const id = parseIdParam(request.params.id, reply, 'Notification setting ID');
|
||||||
if (isNaN(id)) {
|
if (id === null) return;
|
||||||
return reply.status(400).send({
|
|
||||||
statusCode: 400,
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Notification setting ID must be a number',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await updateNotificationSetting(fastify.db, id, request.body);
|
const updated = await updateNotificationSetting(fastify.db, id, request.body);
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
|
|
@ -190,14 +179,8 @@ export async function notificationRoutes(fastify: FastifyInstance): Promise<void
|
||||||
fastify.delete<{ Params: { id: string } }>(
|
fastify.delete<{ Params: { id: string } }>(
|
||||||
'/api/v1/notification/:id',
|
'/api/v1/notification/:id',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseInt(request.params.id, 10);
|
const id = parseIdParam(request.params.id, reply, 'Notification setting ID');
|
||||||
if (isNaN(id)) {
|
if (id === null) return;
|
||||||
return reply.status(400).send({
|
|
||||||
statusCode: 400,
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Notification setting ID must be a number',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleted = await deleteNotificationSetting(fastify.db, id);
|
const deleted = await deleteNotificationSetting(fastify.db, id);
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
|
|
@ -217,14 +200,8 @@ export async function notificationRoutes(fastify: FastifyInstance): Promise<void
|
||||||
fastify.post<{ Params: { id: string } }>(
|
fastify.post<{ Params: { id: string } }>(
|
||||||
'/api/v1/notification/:id/test',
|
'/api/v1/notification/:id/test',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseInt(request.params.id, 10);
|
const id = parseIdParam(request.params.id, reply, 'Notification setting ID');
|
||||||
if (isNaN(id)) {
|
if (id === null) return;
|
||||||
return reply.status(400).send({
|
|
||||||
statusCode: 400,
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Notification setting ID must be a number',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const setting = await getNotificationSettingById(fastify.db, id);
|
const setting = await getNotificationSettingById(fastify.db, id);
|
||||||
if (!setting) {
|
if (!setting) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { type FastifyInstance } from 'fastify';
|
import { type FastifyInstance } from 'fastify';
|
||||||
|
import { parseIdParam } from './helpers';
|
||||||
import { PlatformRegistry } from '../../sources/platform-source';
|
import { PlatformRegistry } from '../../sources/platform-source';
|
||||||
import { YouTubeSource } from '../../sources/youtube';
|
import { YouTubeSource } from '../../sources/youtube';
|
||||||
import { SoundCloudSource } from '../../sources/soundcloud';
|
import { SoundCloudSource } from '../../sources/soundcloud';
|
||||||
|
|
@ -36,14 +37,8 @@ export async function playlistRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.get<{ Params: { id: string } }>(
|
fastify.get<{ Params: { id: string } }>(
|
||||||
'/api/v1/channel/:id/playlists',
|
'/api/v1/channel/:id/playlists',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseInt(request.params.id, 10);
|
const id = parseIdParam(request.params.id, reply, 'Channel ID');
|
||||||
if (isNaN(id)) {
|
if (id === null) return;
|
||||||
return reply.status(400).send({
|
|
||||||
statusCode: 400,
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Channel ID must be a number',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const channel = await getChannelById(fastify.db, id);
|
const channel = await getChannelById(fastify.db, id);
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
|
|
@ -66,14 +61,8 @@ export async function playlistRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.post<{ Params: { id: string } }>(
|
fastify.post<{ Params: { id: string } }>(
|
||||||
'/api/v1/channel/:id/playlists/refresh',
|
'/api/v1/channel/:id/playlists/refresh',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseInt(request.params.id, 10);
|
const id = parseIdParam(request.params.id, reply, 'Channel ID');
|
||||||
if (isNaN(id)) {
|
if (id === null) return;
|
||||||
return reply.status(400).send({
|
|
||||||
statusCode: 400,
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Channel ID must be a number',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const channel = await getChannelById(fastify.db, id);
|
const channel = await getChannelById(fastify.db, id);
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { type FastifyInstance } from 'fastify';
|
import { type FastifyInstance } from 'fastify';
|
||||||
|
import { parseIdParam } from './helpers';
|
||||||
import {
|
import {
|
||||||
getQueueItemsByStatus,
|
getQueueItemsByStatus,
|
||||||
getQueueItemById,
|
getQueueItemById,
|
||||||
|
|
@ -57,14 +58,8 @@ export async function queueRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.get<{ Params: { id: string } }>(
|
fastify.get<{ Params: { id: string } }>(
|
||||||
'/api/v1/queue/:id',
|
'/api/v1/queue/:id',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseInt(request.params.id, 10);
|
const id = parseIdParam(request.params.id, reply, 'Queue item ID');
|
||||||
if (isNaN(id)) {
|
if (id === null) return;
|
||||||
return reply.status(400).send({
|
|
||||||
statusCode: 400,
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Queue item ID must be a number',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = await getQueueItemById(fastify.db, id);
|
const item = await getQueueItemById(fastify.db, id);
|
||||||
if (!item) {
|
if (!item) {
|
||||||
|
|
@ -141,14 +136,8 @@ export async function queueRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.delete<{ Params: { id: string } }>(
|
fastify.delete<{ Params: { id: string } }>(
|
||||||
'/api/v1/queue/:id',
|
'/api/v1/queue/:id',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseInt(request.params.id, 10);
|
const id = parseIdParam(request.params.id, reply, 'Queue item ID');
|
||||||
if (isNaN(id)) {
|
if (id === null) return;
|
||||||
return reply.status(400).send({
|
|
||||||
statusCode: 400,
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Queue item ID must be a number',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fastify.queueService) {
|
if (!fastify.queueService) {
|
||||||
return reply.status(503).send({
|
return reply.status(503).send({
|
||||||
|
|
@ -190,14 +179,8 @@ export async function queueRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.post<{ Params: { id: string } }>(
|
fastify.post<{ Params: { id: string } }>(
|
||||||
'/api/v1/queue/:id/retry',
|
'/api/v1/queue/:id/retry',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseInt(request.params.id, 10);
|
const id = parseIdParam(request.params.id, reply, 'Queue item ID');
|
||||||
if (isNaN(id)) {
|
if (id === null) return;
|
||||||
return reply.status(400).send({
|
|
||||||
statusCode: 400,
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Queue item ID must be a number',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fastify.queueService) {
|
if (!fastify.queueService) {
|
||||||
return reply.status(503).send({
|
return reply.status(503).send({
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { type FastifyInstance } from 'fastify';
|
import { type FastifyInstance } from 'fastify';
|
||||||
|
import { parseIdParam } from './helpers';
|
||||||
import type { CheckChannelResult } from '../../services/scheduler';
|
import type { CheckChannelResult } from '../../services/scheduler';
|
||||||
import {
|
import {
|
||||||
getChannelById,
|
getChannelById,
|
||||||
|
|
@ -75,14 +76,8 @@ export async function scanRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.post<{ Params: { id: string } }>(
|
fastify.post<{ Params: { id: string } }>(
|
||||||
'/api/v1/channel/:id/scan',
|
'/api/v1/channel/:id/scan',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseInt(request.params.id, 10);
|
const id = parseIdParam(request.params.id, reply, 'Channel ID');
|
||||||
if (isNaN(id)) {
|
if (id === null) return;
|
||||||
return reply.status(400).send({
|
|
||||||
statusCode: 400,
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Channel ID must be a number',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const channel = await getChannelById(fastify.db, id);
|
const channel = await getChannelById(fastify.db, id);
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
|
|
@ -137,14 +132,8 @@ export async function scanRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.post<{ Params: { id: string } }>(
|
fastify.post<{ Params: { id: string } }>(
|
||||||
'/api/v1/channel/:id/scan-cancel',
|
'/api/v1/channel/:id/scan-cancel',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseInt(request.params.id, 10);
|
const id = parseIdParam(request.params.id, reply, 'Channel ID');
|
||||||
if (isNaN(id)) {
|
if (id === null) return;
|
||||||
return reply.status(400).send({
|
|
||||||
statusCode: 400,
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Channel ID must be a number',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fastify.scheduler) {
|
if (!fastify.scheduler) {
|
||||||
return reply.status(503).send({
|
return reply.status(503).send({
|
||||||
|
|
@ -164,14 +153,8 @@ export async function scanRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.get<{ Params: { id: string } }>(
|
fastify.get<{ Params: { id: string } }>(
|
||||||
'/api/v1/channel/:id/scan-status',
|
'/api/v1/channel/:id/scan-status',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseInt(request.params.id, 10);
|
const id = parseIdParam(request.params.id, reply, 'Channel ID');
|
||||||
if (isNaN(id)) {
|
if (id === null) return;
|
||||||
return reply.status(400).send({
|
|
||||||
statusCode: 400,
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Channel ID must be a number',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const scanning = fastify.scheduler?.isScanning(id) ?? false;
|
const scanning = fastify.scheduler?.isScanning(id) ?? false;
|
||||||
return { scanning };
|
return { scanning };
|
||||||
|
|
|
||||||
|
|
@ -168,8 +168,8 @@ export async function systemRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
await setAppSetting(db, APP_CONCURRENT_DOWNLOADS, body.concurrentDownloads.toString());
|
await setAppSetting(db, APP_CONCURRENT_DOWNLOADS, body.concurrentDownloads.toString());
|
||||||
|
|
||||||
// Update queue concurrency at runtime
|
// Update queue concurrency at runtime
|
||||||
if ((fastify as any).queueService?.setConcurrency) {
|
if (fastify.queueService?.setConcurrency) {
|
||||||
(fastify as any).queueService.setConcurrency(body.concurrentDownloads);
|
fastify.queueService.setConcurrency(body.concurrentDownloads);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue