From c057b6a286e342ae12ddaf1cd758b81452f05121 Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 02:29:49 +0000 Subject: [PATCH 01/21] feat(S01+S04): server-side pagination, search/filter, download engine hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S01 — Server-Side Pagination: - Added getChannelContentPaginated() to content repository with search, filter, sort - Channel content API now supports ?page, ?pageSize, ?search, ?status, ?contentType, ?sortBy, ?sortDirection - Backwards-compatible: no params returns all items (legacy mode) - Frontend useChannelContentPaginated hook with keepPreviousData - ChannelDetail page: search bar, status/type filter dropdowns, pagination controls - Sorting delegated to server (removed client-side sortedContent) - Item count shown in Content header (e.g. '121 items') S04 — Download Engine Hardening: - yt-dlp auto-update on production startup (native -U with pip fallback) - Error classification: rate_limit, format_unavailable, geo_blocked, age_restricted, private, network - Format fallback chains: preferred res → best under res → single best → any - Improved parseFinalPath: explicit non-path prefix detection, extension validation - Error category included in download:failed events - classifyYtDlpError() exported from yt-dlp module for downstream use --- docker-compose.yml | 5 +- src/db/repositories/content-repository.ts | 75 ++++++++++ src/frontend/src/api/hooks/useContent.ts | 44 +++++- src/frontend/src/pages/ChannelDetail.tsx | 159 ++++++++++++++-------- src/index.ts | 20 +++ src/server/routes/content.ts | 56 +++++++- src/services/download.ts | 59 +++++--- src/sources/yt-dlp.ts | 85 ++++++++++++ 8 files changed, 420 insertions(+), 83 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 4fd08fe..fa093b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: ports: - "8989:8989" volumes: - - ./config:/config + - tubearr-config:/config - ./media:/media environment: - NODE_ENV=production @@ -18,3 +18,6 @@ services: retries: 3 start_period: 15s restart: unless-stopped + +volumes: + tubearr-config: diff --git a/src/db/repositories/content-repository.ts b/src/db/repositories/content-repository.ts index 260946e..346fa69 100644 --- a/src/db/repositories/content-repository.ts +++ b/src/db/repositories/content-repository.ts @@ -92,6 +92,81 @@ export async function getContentByChannelId( return rows.map(mapRow); } +/** Optional filters for channel content queries. */ +export interface ChannelContentFilters { + search?: string; + status?: ContentStatus; + contentType?: ContentType; + sortBy?: 'title' | 'publishedAt' | 'status' | 'duration' | 'fileSize' | 'downloadedAt'; + sortDirection?: 'asc' | 'desc'; +} + +/** + * Get paginated content items for a channel with optional search, filter, and sort. + * Returns items and total count for pagination. + */ +export async function getChannelContentPaginated( + db: Db, + channelId: number, + filters?: ChannelContentFilters, + page = 1, + pageSize = 50 +): Promise { + const conditions = [eq(contentItems.channelId, channelId)]; + + if (filters?.search) { + conditions.push(like(contentItems.title, `%${filters.search}%`)); + } + if (filters?.status) { + conditions.push(eq(contentItems.status, filters.status)); + } + if (filters?.contentType) { + conditions.push(eq(contentItems.contentType, filters.contentType)); + } + + const whereClause = and(...conditions); + const offset = (page - 1) * pageSize; + + // Count total matching records + const countResult = await db + .select({ count: sql`count(*)` }) + .from(contentItems) + .where(whereClause); + + const total = Number(countResult[0].count); + + // Build sort order + const sortCol = resolveSortColumn(filters?.sortBy); + const sortDir = filters?.sortDirection === 'asc' ? sortCol : desc(sortCol); + + // Fetch paginated results + const rows = await db + .select() + .from(contentItems) + .where(whereClause) + .orderBy(sortDir, desc(contentItems.id)) + .limit(pageSize) + .offset(offset); + + return { + items: rows.map(mapRow), + total, + }; +} + +/** Resolve sort column name to Drizzle column reference. */ +function resolveSortColumn(sortBy?: string) { + switch (sortBy) { + case 'title': return contentItems.title; + case 'publishedAt': return contentItems.publishedAt; + case 'status': return contentItems.status; + case 'duration': return contentItems.duration; + case 'fileSize': return contentItems.fileSize; + case 'downloadedAt': return contentItems.downloadedAt; + default: return contentItems.createdAt; + } +} + /** Check if a specific content item exists for a channel. Returns the item or null. */ export async function getContentByPlatformContentId( db: Db, diff --git a/src/frontend/src/api/hooks/useContent.ts b/src/frontend/src/api/hooks/useContent.ts index d9f0a4e..e11739b 100644 --- a/src/frontend/src/api/hooks/useContent.ts +++ b/src/frontend/src/api/hooks/useContent.ts @@ -1,8 +1,8 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'; import { apiClient } from '../client'; import { queueKeys } from './useQueue'; import type { ContentItem } from '@shared/types/index'; -import type { ApiResponse } from '@shared/types/api'; +import type { ApiResponse, PaginatedResponse } from '@shared/types/api'; // ── Collect Types ── @@ -13,15 +13,29 @@ export interface CollectResult { items: Array<{ contentItemId: number; status: string }>; } +// ── Channel Content Filter Types ── + +export interface ChannelContentFilters { + page?: number; + pageSize?: number; + search?: string; + status?: string; + contentType?: string; + sortBy?: string; + sortDirection?: 'asc' | 'desc'; +} + // ── Query Keys ── export const contentKeys = { byChannel: (channelId: number) => ['content', 'channel', channelId] as const, + byChannelPaginated: (channelId: number, filters: ChannelContentFilters) => + ['content', 'channel', channelId, 'paginated', filters] as const, }; // ── Queries ── -/** Fetch content items for a specific channel. */ +/** Fetch content items for a specific channel (legacy — all items). */ export function useChannelContent(channelId: number) { return useQuery({ queryKey: contentKeys.byChannel(channelId), @@ -35,6 +49,30 @@ export function useChannelContent(channelId: number) { }); } +/** Fetch paginated content items for a channel with search/filter/sort. */ +export function useChannelContentPaginated(channelId: number, filters: ChannelContentFilters) { + return useQuery({ + queryKey: contentKeys.byChannelPaginated(channelId, filters), + queryFn: async () => { + const params = new URLSearchParams(); + if (filters.page) params.set('page', String(filters.page)); + if (filters.pageSize) params.set('pageSize', String(filters.pageSize)); + if (filters.search) params.set('search', filters.search); + if (filters.status) params.set('status', filters.status); + if (filters.contentType) params.set('contentType', filters.contentType); + if (filters.sortBy) params.set('sortBy', filters.sortBy); + if (filters.sortDirection) params.set('sortDirection', filters.sortDirection); + + const response = await apiClient.get>( + `/api/v1/channel/${channelId}/content?${params.toString()}`, + ); + return response; + }, + enabled: channelId > 0, + placeholderData: keepPreviousData, + }); +} + // ── Mutations ── /** Enqueue a content item for download. Returns 202 with queue item. */ diff --git a/src/frontend/src/pages/ChannelDetail.tsx b/src/frontend/src/pages/ChannelDetail.tsx index 6c85291..406e3e1 100644 --- a/src/frontend/src/pages/ChannelDetail.tsx +++ b/src/frontend/src/pages/ChannelDetail.tsx @@ -19,7 +19,7 @@ import { Trash2, } from 'lucide-react'; import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useSetMonitoringMode, useScanStatus } from '../api/hooks/useChannels'; -import { useChannelContent, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored } from '../api/hooks/useContent'; +import { useChannelContentPaginated, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored, type ChannelContentFilters } from '../api/hooks/useContent'; import { apiClient } from '../api/client'; import { useChannelPlaylists, useRefreshPlaylists } from '../api/hooks/usePlaylists'; import { useFormatProfiles } from '../api/hooks/useFormatProfiles'; @@ -28,6 +28,7 @@ import { PlatformBadge } from '../components/PlatformBadge'; import { StatusBadge } from '../components/StatusBadge'; import { QualityLabel } from '../components/QualityLabel'; import { DownloadProgressBar } from '../components/DownloadProgressBar'; +import { Pagination } from '../components/Pagination'; import { Modal } from '../components/Modal'; import { useDownloadProgress } from '../contexts/DownloadProgressContext'; import type { ContentItem, MonitoringMode } from '@shared/types/index'; @@ -96,10 +97,31 @@ export function ChannelDetail() { // ── Data hooks ── const { data: channel, isLoading: channelLoading, error: channelError } = useChannel(channelId); - const { data: content, isLoading: contentLoading, error: contentError, refetch: refetchContent } = useChannelContent(channelId); const { data: formatProfiles } = useFormatProfiles(); const { data: playlistData } = useChannelPlaylists(channelId); + // ── Content pagination state ── + const [contentPage, setContentPage] = useState(1); + const [contentSearch, setContentSearch] = useState(''); + const [contentStatusFilter, setContentStatusFilter] = useState(''); + const [contentTypeFilter, setContentTypeFilter] = useState(''); + const [sortKey, setSortKey] = useState(null); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + + const contentFilters: ChannelContentFilters = useMemo(() => ({ + page: contentPage, + pageSize: 50, + search: contentSearch || undefined, + status: contentStatusFilter || undefined, + contentType: contentTypeFilter || undefined, + sortBy: sortKey ?? undefined, + sortDirection: sortDirection, + }), [contentPage, contentSearch, contentStatusFilter, contentTypeFilter, sortKey, sortDirection]); + + const { data: contentResponse, isLoading: contentLoading, error: contentError, refetch: refetchContent } = useChannelContentPaginated(channelId, contentFilters); + const content = contentResponse?.data ?? []; + const contentPagination = contentResponse?.pagination; + // ── Mutation hooks ── const updateChannel = useUpdateChannel(channelId); const deleteChannel = useDeleteChannel(); @@ -115,8 +137,6 @@ export function ChannelDetail() { const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [scanResult, setScanResult] = useState<{ message: string; isError: boolean } | null>(null); const [scanInProgress, setScanInProgress] = useState(false); - const [sortKey, setSortKey] = useState(null); - const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const [expandedPlaylists, setExpandedPlaylists] = useState>(new Set()); const [selectedIds, setSelectedIds] = useState>(new Set()); const [localCheckInterval, setLocalCheckInterval] = useState(''); @@ -256,6 +276,7 @@ export function ChannelDetail() { const handleSort = useCallback((key: string, direction: 'asc' | 'desc') => { setSortKey(key); setSortDirection(direction); + setContentPage(1); }, []); const togglePlaylist = useCallback((id: number | 'uncategorized') => { @@ -316,55 +337,10 @@ export function ChannelDetail() { clearSelection(); }, [selectedIds, downloadContent, clearSelection]); - // ── Sorted content ── + // ── Sorted content (server-side — just use content directly) ── - const sortedContent = useMemo(() => { - const items = content ?? []; - if (!sortKey) return items; - const sorted = [...items]; - sorted.sort((a, b) => { - let cmp = 0; - switch (sortKey) { - case 'title': - cmp = a.title.localeCompare(b.title); - break; - case 'publishedAt': { - const aDate = a.publishedAt ? Date.parse(a.publishedAt) : -Infinity; - const bDate = b.publishedAt ? Date.parse(b.publishedAt) : -Infinity; - cmp = aDate - bDate; - break; - } - case 'status': - cmp = a.status.localeCompare(b.status); - break; - case 'duration': { - const aDur = a.duration ?? -Infinity; - const bDur = b.duration ?? -Infinity; - cmp = aDur - bDur; - break; - } - case 'fileSize': - cmp = (a.fileSize ?? -Infinity) - (b.fileSize ?? -Infinity); - break; - case 'downloadedAt': { - const aDate2 = a.downloadedAt ? Date.parse(a.downloadedAt) : -Infinity; - const bDate2 = b.downloadedAt ? Date.parse(b.downloadedAt) : -Infinity; - cmp = aDate2 - bDate2; - break; - } - case 'quality': { - const aQ = a.qualityMetadata?.actualResolution ?? ''; - const bQ = b.qualityMetadata?.actualResolution ?? ''; - cmp = aQ.localeCompare(bQ); - break; - } - default: - return 0; - } - return sortDirection === 'desc' ? -cmp : cmp; - }); - return sorted; - }, [content, sortKey, sortDirection]); + // Sort is handled server-side via contentFilters.sortBy/sortDirection. + // playlistGroups still needs client-side grouping for YouTube channels. // ── Playlist grouping (YouTube only) ── @@ -379,7 +355,7 @@ export function ChannelDetail() { // Build a Map from content ID to content item for O(1) lookups (js-index-maps) const contentById = new Map(); - for (const item of sortedContent) { + for (const item of content) { contentById.set(item.id, item); } @@ -399,13 +375,13 @@ export function ChannelDetail() { } // Uncategorized: items not in any playlist - const uncategorized = sortedContent.filter((item) => !categorizedIds.has(item.id)); + const uncategorized = content.filter((item) => !categorizedIds.has(item.id)); if (uncategorized.length > 0) { groups.push({ id: 'uncategorized', title: 'Uncategorized', items: uncategorized }); } return groups.length > 0 ? groups : null; - }, [channel, playlistData, sortedContent]); + }, [channel, playlistData, content]); // ── Content table columns ── @@ -1066,6 +1042,7 @@ export function ChannelDetail() { display: 'flex', alignItems: 'center', gap: 'var(--space-3)', + flexWrap: 'wrap', }} >

Content

+ {contentPagination ? ( + + {contentPagination.totalItems} items + + ) : null} +
+ {/* Search */} + { setContentSearch(e.target.value); setContentPage(1); }} + style={{ + padding: 'var(--space-2) var(--space-3)', + borderRadius: 'var(--radius-md)', + border: '1px solid var(--border)', + backgroundColor: 'var(--bg-input)', + color: 'var(--text-primary)', + fontSize: 'var(--font-size-sm)', + width: 200, + }} + /> + {/* Status filter */} + + {/* Type filter */} +
{contentError ? (
1 ? ( +
+ +
+ ) : null}
{/* Floating bulk action bar */} diff --git a/src/index.ts b/src/index.ts index db4d5af..854899a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { PlatformRegistry } from './sources/platform-source'; import { YouTubeSource } from './sources/youtube'; import { SoundCloudSource } from './sources/soundcloud'; import { Platform } from './types/index'; +import { getYtDlpVersion, updateYtDlp } from './sources/yt-dlp'; import type { ViteDevServer } from 'vite'; const APP_NAME = 'Tubearr'; @@ -44,6 +45,25 @@ async function main(): Promise { await seedAppDefaults(db); console.log(`[${APP_NAME}] App settings seeded`); + // 2d. Check yt-dlp version and auto-update if configured + try { + const version = await getYtDlpVersion(); + if (version) { + console.log(`[${APP_NAME}] yt-dlp version: ${version}`); + // Auto-update on startup (non-blocking — continue if it fails) + if (appConfig.nodeEnv === 'production') { + const result = await updateYtDlp(); + if (result.updated) { + console.log(`[${APP_NAME}] yt-dlp updated: ${result.previousVersion} → ${result.version}`); + } + } + } else { + console.warn(`[${APP_NAME}] yt-dlp not found on PATH — downloads will fail`); + } + } catch (err) { + console.warn(`[${APP_NAME}] yt-dlp check failed: ${err instanceof Error ? err.message : String(err)}`); + } + // 3. Build and configure Fastify server // In dev mode, embed Vite for HMR — single port, no separate frontend process let vite: ViteDevServer | undefined; diff --git a/src/server/routes/content.ts b/src/server/routes/content.ts index b2bc480..8edd4d7 100644 --- a/src/server/routes/content.ts +++ b/src/server/routes/content.ts @@ -2,6 +2,7 @@ import { type FastifyInstance } from 'fastify'; import { getAllContentItems, getContentByChannelId, + getChannelContentPaginated, setMonitored, bulkSetMonitored, } from '../../db/repositories/content-repository'; @@ -202,6 +203,15 @@ export async function contentRoutes(fastify: FastifyInstance): Promise { fastify.get<{ Params: { id: string }; + Querystring: { + page?: string; + pageSize?: string; + search?: string; + status?: string; + contentType?: string; + sortBy?: string; + sortDirection?: string; + }; }>('/api/v1/channel/:id/content', async (request, reply) => { const channelId = parseInt(request.params.id, 10); @@ -213,12 +223,50 @@ export async function contentRoutes(fastify: FastifyInstance): Promise { }); } - try { - const items = await getContentByChannelId(fastify.db, channelId); + const page = Math.max(1, parseInt(request.query.page ?? '1', 10) || 1); + const pageSize = Math.min( + 200, + Math.max(1, parseInt(request.query.pageSize ?? '50', 10) || 50) + ); - const response: ApiResponse = { + // If no pagination params provided, return all items (backwards-compatible) + const hasPaginationParams = request.query.page || request.query.pageSize || request.query.search || request.query.status || request.query.contentType || request.query.sortBy; + + try { + if (!hasPaginationParams) { + // Legacy mode: return all items as flat array (backwards-compatible) + const items = await getContentByChannelId(fastify.db, channelId); + const response: ApiResponse = { + success: true, + data: items, + }; + return response; + } + + // Paginated mode with filters + const result = await getChannelContentPaginated( + fastify.db, + channelId, + { + search: request.query.search || undefined, + status: (request.query.status as ContentStatus) || undefined, + contentType: (request.query.contentType as ContentType) || undefined, + sortBy: request.query.sortBy as 'title' | 'publishedAt' | 'status' | 'duration' | 'fileSize' | 'downloadedAt' | undefined, + sortDirection: (request.query.sortDirection as 'asc' | 'desc') || undefined, + }, + page, + pageSize + ); + + const response: PaginatedResponse = { success: true, - data: items, + data: result.items, + pagination: { + page, + pageSize, + totalItems: result.total, + totalPages: Math.ceil(result.total / pageSize), + }, }; return response; diff --git a/src/services/download.ts b/src/services/download.ts index e0642f4..0b7c0f6 100644 --- a/src/services/download.ts +++ b/src/services/download.ts @@ -3,7 +3,7 @@ import { extname } from 'node:path'; import { createInterface } from 'node:readline'; import type { LibSQLDatabase } from 'drizzle-orm/libsql'; import type * as schema from '../db/schema/index'; -import { execYtDlp, spawnYtDlp, YtDlpError } from '../sources/yt-dlp'; +import { execYtDlp, spawnYtDlp, YtDlpError, classifyYtDlpError } from '../sources/yt-dlp'; import { updateContentItem } from '../db/repositories/content-repository'; import { parseProgressLine } from './progress-parser'; import type { DownloadEventBus } from './event-bus'; @@ -137,16 +137,22 @@ export class DownloadService { // Report error to rate limiter this.rateLimiter.reportError(channel.platform as Platform); + // Classify the error for better retry decisions + const errorMsg = err instanceof Error ? err.message : String(err); + const stderr = err instanceof YtDlpError ? err.stderr : ''; + const errorCategory = classifyYtDlpError(stderr || errorMsg); + // Update status to failed await updateContentItem(this.db, contentItem.id, { status: 'failed' }); - const errorMsg = err instanceof Error ? err.message : String(err); - console.log(`${logPrefix} status=failed error="${errorMsg.slice(0, 200)}"`); + console.log( + `${logPrefix} status=failed category=${errorCategory} error="${errorMsg.slice(0, 200)}"` + ); - // Emit download:failed event + // Emit download:failed event with error category this.eventBus?.emitDownload('download:failed', { contentItemId: contentItem.id, - error: errorMsg.slice(0, 200), + error: `[${errorCategory}] ${errorMsg.slice(0, 200)}`, }); throw err; @@ -288,32 +294,32 @@ export class DownloadService { /** * Build format args for video content. + * Uses a fallback chain: preferred resolution → best available → any. + * yt-dlp supports `/` as a fallback separator: `format1/format2/format3`. */ private buildVideoArgs(formatProfile?: FormatProfile): string[] { const args: string[] = []; + const container = formatProfile?.containerFormat ?? 'mp4'; if (formatProfile?.videoResolution === 'Best') { - // "Best" selects separate best-quality video + audio streams, merged together. - // This is higher quality than `-f best` which picks a single combined format. - args.push('-f', 'bestvideo+bestaudio/best'); - const container = formatProfile.containerFormat ?? 'mp4'; + // Best quality: separate streams merged + args.push('-f', 'bestvideo+bestaudio/bestvideo*+bestaudio/best'); args.push('--merge-output-format', container); } else if (formatProfile?.videoResolution) { const height = parseResolutionHeight(formatProfile.videoResolution); if (height) { + // Fallback chain: exact res → best under res → single best stream → any args.push( '-f', - `bestvideo[height<=${height}]+bestaudio/best[height<=${height}]` + `bestvideo[height<=${height}]+bestaudio/bestvideo[height<=${height}]*+bestaudio/best[height<=${height}]/bestvideo+bestaudio/best` ); } else { - args.push('-f', 'best'); + args.push('-f', 'bestvideo+bestaudio/best'); } - - // Container format for merge - const container = formatProfile.containerFormat ?? 'mp4'; args.push('--merge-output-format', container); } else { - args.push('-f', 'best'); + args.push('-f', 'bestvideo+bestaudio/best'); + args.push('--merge-output-format', container); } return args; @@ -367,18 +373,33 @@ export class DownloadService { /** * Parse the final file path from yt-dlp stdout. - * The `--print after_move:filepath` flag makes yt-dlp output the final path - * as the last line of stdout. + * The `--print after_move:filepath` flag makes yt-dlp output the final path. + * + * Strategy: walk backwards through lines, skipping known yt-dlp output prefixes + * (e.g. [download], [Merger], [ExtractAudio], Deleting). + * A valid path line should be an absolute path or at least contain a file extension. */ private parseFinalPath(stdout: string, fallbackPath: string): string { const lines = stdout.trim().split('\n'); - // The filepath from --print is typically the last non-empty line + + // Known non-path prefixes from yt-dlp output + const NON_PATH_PREFIXES = ['[', 'Deleting', 'WARNING:', 'ERROR:', 'Merging', 'Post-process']; + for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i].trim(); - if (line && !line.startsWith('[') && !line.startsWith('Deleting')) { + if (!line) continue; + + // Skip known yt-dlp output lines + const isNonPath = NON_PATH_PREFIXES.some((prefix) => line.startsWith(prefix)); + if (isNonPath) continue; + + // A valid path should have a file extension or start with / + if (line.startsWith('/') || /\.\w{2,5}$/.test(line)) { return line; } } + + console.warn('[download] Could not parse final path from yt-dlp output, using fallback'); return fallbackPath; } diff --git a/src/sources/yt-dlp.ts b/src/sources/yt-dlp.ts index 23aeb2a..2977828 100644 --- a/src/sources/yt-dlp.ts +++ b/src/sources/yt-dlp.ts @@ -212,3 +212,88 @@ export async function getYtDlpVersion(): Promise { return null; } } + +// ── Auto-Update ── + +/** + * Update yt-dlp to the latest version. + * Uses `yt-dlp -U` which handles self-update on most installations. + * For pip-based installs (Alpine/Docker), falls back to `pip install -U yt-dlp`. + * + * Returns { updated, version, previousVersion } on success. + */ +export async function updateYtDlp(): Promise<{ + updated: boolean; + version: string | null; + previousVersion: string | null; +}> { + const previousVersion = await getYtDlpVersion(); + + try { + // Try native self-update first + const { stderr } = await execFileAsync('yt-dlp', ['-U'], { + timeout: 120_000, + windowsHide: true, + }); + + // Check if it actually updated + const newVersion = await getYtDlpVersion(); + const didUpdate = newVersion !== previousVersion; + + if (didUpdate) { + console.log(`[yt-dlp] Updated from ${previousVersion} to ${newVersion}`); + } else if (stderr.toLowerCase().includes('up to date')) { + console.log(`[yt-dlp] Already up to date (${newVersion})`); + } + + return { updated: didUpdate, version: newVersion, previousVersion }; + } catch (err) { + // Self-update may not work in pip-based installs — try pip + try { + await execFileAsync('pip', ['install', '-U', 'yt-dlp'], { + timeout: 120_000, + windowsHide: true, + }); + + const newVersion = await getYtDlpVersion(); + const didUpdate = newVersion !== previousVersion; + console.log( + `[yt-dlp] pip update: ${previousVersion} → ${newVersion}${didUpdate ? '' : ' (no change)'}` + ); + + return { updated: didUpdate, version: newVersion, previousVersion }; + } catch (pipErr) { + console.warn( + `[yt-dlp] Auto-update failed: ${err instanceof Error ? err.message : String(err)}` + ); + return { updated: false, version: previousVersion, previousVersion }; + } + } +} + +// ── Error Classification ── + +/** + * Classify a yt-dlp error into a category for better retry/fallback decisions. + */ +export type YtDlpErrorCategory = + | 'rate_limit' // 429, too many requests + | 'format_unavailable' // requested format not available + | 'geo_blocked' // geo-restriction + | 'age_restricted' // age-gated content + | 'private' // private or removed video + | 'network' // DNS, connection, timeout + | 'unknown'; + +export function classifyYtDlpError(stderr: string): YtDlpErrorCategory { + const lower = stderr.toLowerCase(); + + if (lower.includes('429') || lower.includes('too many requests')) return 'rate_limit'; + if (lower.includes('requested format') || lower.includes('format is not available')) return 'format_unavailable'; + if (lower.includes('not available in your country') || lower.includes('geo')) return 'geo_blocked'; + if (lower.includes('age') && (lower.includes('restricted') || lower.includes('verify'))) return 'age_restricted'; + if (lower.includes('private video') || lower.includes('video unavailable') || lower.includes('been removed')) return 'private'; + if (lower.includes('unable to download') || lower.includes('connection') || lower.includes('timed out') || lower.includes('urlopen error')) return 'network'; + + return 'unknown'; +} From ac8905ca388c3fba8fc62daff08fffe4a6952b25 Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 02:33:49 +0000 Subject: [PATCH 02/21] =?UTF-8?q?feat(S02):=20modern=20UI=20design=20syste?= =?UTF-8?q?m=20=E2=80=94=20theme=20overhaul,=20skeletons,=20animations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Redesigned theme.css: deeper darks (#0f1117), glassmorphism tokens, accent glow, softer borders (rgba), spring/slow transitions, new radii - Added Inter + JetBrains Mono web fonts for premium typography - Enhanced global.css: focus rings with box-shadow, skeleton shimmer animation, glass-card class, fade-in page transitions, slide-up animations, badge-glow pulse - Created Skeleton component library: Skeleton, SkeletonRow, SkeletonTable, SkeletonChannelHeader, SkeletonChannelsList - Replaced spinner-text loading states with skeleton placeholders (Channels page, ChannelDetail header and content table) - Updated all card border-radius from radius-lg to radius-xl across all pages - Added responsive breakpoint stub for mobile (768px) --- src/frontend/src/components/Skeleton.tsx | 111 +++++++++++++++++++++++ src/frontend/src/pages/ChannelDetail.tsx | 20 ++-- src/frontend/src/pages/Channels.tsx | 10 +- src/frontend/src/pages/Settings.tsx | 8 +- src/frontend/src/styles/global.css | 96 ++++++++++++++++++-- src/frontend/src/styles/theme.css | 94 +++++++++++-------- 6 files changed, 268 insertions(+), 71 deletions(-) create mode 100644 src/frontend/src/components/Skeleton.tsx diff --git a/src/frontend/src/components/Skeleton.tsx b/src/frontend/src/components/Skeleton.tsx new file mode 100644 index 0000000..2e2eefa --- /dev/null +++ b/src/frontend/src/components/Skeleton.tsx @@ -0,0 +1,111 @@ +/** + * Skeleton loading placeholder components. + * Uses the .skeleton CSS class for shimmer animation. + */ + +interface SkeletonProps { + width?: string | number; + height?: string | number; + borderRadius?: string; + style?: React.CSSProperties; +} + +/** Generic skeleton block. */ +export function Skeleton({ width = '100%', height = 16, borderRadius, style }: SkeletonProps) { + return ( +
+ ); +} + +/** Skeleton for a table row. */ +export function SkeletonRow({ columns = 6 }: { columns?: number }) { + return ( + + {Array.from({ length: columns }).map((_, i) => ( + + + + ))} + + ); +} + +/** Skeleton for the full content table. */ +export function SkeletonTable({ rows = 8, columns = 6 }: { rows?: number; columns?: number }) { + return ( +
+ + + {Array.from({ length: rows }).map((_, i) => ( + + ))} + +
+
+ ); +} + +/** Skeleton for the channel detail header. */ +export function SkeletonChannelHeader() { + return ( +
+ +
+ + +
+ + + +
+
+
+ ); +} + +/** Skeleton for the channels list page. */ +export function SkeletonChannelsList({ rows = 4 }: { rows?: number }) { + return ( +
+
+ +
+ + +
+
+
+ +
+
+ ); +} diff --git a/src/frontend/src/pages/ChannelDetail.tsx b/src/frontend/src/pages/ChannelDetail.tsx index 406e3e1..9d50cd9 100644 --- a/src/frontend/src/pages/ChannelDetail.tsx +++ b/src/frontend/src/pages/ChannelDetail.tsx @@ -28,6 +28,7 @@ import { PlatformBadge } from '../components/PlatformBadge'; import { StatusBadge } from '../components/StatusBadge'; import { QualityLabel } from '../components/QualityLabel'; import { DownloadProgressBar } from '../components/DownloadProgressBar'; +import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton'; import { Pagination } from '../components/Pagination'; import { Modal } from '../components/Modal'; import { useDownloadProgress } from '../contexts/DownloadProgressContext'; @@ -667,9 +668,11 @@ export function ChannelDetail() { if (channelLoading) { return ( -
- - Loading channel... +
+ +
+ +
); } @@ -735,7 +738,7 @@ export function ChannelDetail() { gap: 'var(--space-5)', padding: 'var(--space-5)', backgroundColor: 'var(--bg-card)', - borderRadius: 'var(--radius-lg)', + borderRadius: 'var(--radius-xl)', border: '1px solid var(--border)', marginBottom: 'var(--space-6)', alignItems: 'flex-start', @@ -1030,7 +1033,7 @@ export function ChannelDetail() {
) : null} {contentLoading ? ( -
- - Loading content... -
+ ) : hasPlaylistGroups ? ( renderPlaylistGroups(playlistGroups!) ) : ( @@ -1196,7 +1196,7 @@ export function ChannelDetail() { padding: 'var(--space-3) var(--space-5)', backgroundColor: 'var(--bg-card)', border: '1px solid var(--border)', - borderRadius: 'var(--radius-lg)', + borderRadius: 'var(--radius-xl)', boxShadow: 'var(--shadow-lg)', }} > diff --git a/src/frontend/src/pages/Channels.tsx b/src/frontend/src/pages/Channels.tsx index 585f91c..db1bab8 100644 --- a/src/frontend/src/pages/Channels.tsx +++ b/src/frontend/src/pages/Channels.tsx @@ -8,6 +8,7 @@ import { PlatformBadge } from '../components/PlatformBadge'; import { StatusBadge } from '../components/StatusBadge'; import { ProgressBar } from '../components/ProgressBar'; import { AddChannelModal } from '../components/AddChannelModal'; +import { SkeletonChannelsList } from '../components/Skeleton'; import type { ChannelWithCounts } from '@shared/types/api'; // ── Helpers ── @@ -184,12 +185,7 @@ export function Channels() { ); if (isLoading) { - return ( -
- - Loading channels... -
- ); + return ; } if (error) { @@ -339,7 +335,7 @@ export function Channels() {
Date: Fri, 3 Apr 2026 02:35:37 +0000 Subject: [PATCH 03/21] fix: update download test expectations for format fallback chains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests expected old single-format strings, updated to match the new fallback chain format: preferred → next best → any. --- src/__tests__/download.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/__tests__/download.test.ts b/src/__tests__/download.test.ts index a961bd9..3f83acb 100644 --- a/src/__tests__/download.test.ts +++ b/src/__tests__/download.test.ts @@ -344,7 +344,7 @@ describe('DownloadService', () => { const args = execYtDlpMock.mock.calls[0][0] as string[]; expect(args).toContain('-f'); const fIdx = args.indexOf('-f'); - expect(args[fIdx + 1]).toBe('bestvideo[height<=1080]+bestaudio/best[height<=1080]'); + expect(args[fIdx + 1]).toBe('bestvideo[height<=1080]+bestaudio/bestvideo[height<=1080]*+bestaudio/best[height<=1080]/bestvideo+bestaudio/best'); expect(args).toContain('--merge-output-format'); const moIdx = args.indexOf('--merge-output-format'); expect(args[moIdx + 1]).toBe('mkv'); @@ -431,7 +431,7 @@ describe('DownloadService', () => { const args = execYtDlpMock.mock.calls[0][0] as string[]; expect(args).toContain('-f'); const fIdx = args.indexOf('-f'); - expect(args[fIdx + 1]).toBe('best'); + expect(args[fIdx + 1]).toBe('bestvideo+bestaudio/best'); }); it('falls back to -f "bestaudio" for audio when no format profile', async () => { @@ -652,7 +652,7 @@ describe('DownloadService', () => { const args = execYtDlpMock.mock.calls[0][0] as string[]; const fIdx = args.indexOf('-f'); expect(fIdx).toBeGreaterThanOrEqual(0); - expect(args[fIdx + 1]).toBe('bestvideo+bestaudio/best'); + expect(args[fIdx + 1]).toBe('bestvideo+bestaudio/bestvideo*+bestaudio/best'); // Should default to mp4 merge format when containerFormat is null expect(args).toContain('--merge-output-format'); const moIdx = args.indexOf('--merge-output-format'); @@ -695,7 +695,7 @@ describe('DownloadService', () => { const args = execYtDlpMock.mock.calls[0][0] as string[]; const fIdx = args.indexOf('-f'); - expect(args[fIdx + 1]).toBe('bestvideo+bestaudio/best'); + expect(args[fIdx + 1]).toBe('bestvideo+bestaudio/bestvideo*+bestaudio/best'); const moIdx = args.indexOf('--merge-output-format'); expect(args[moIdx + 1]).toBe('mkv'); }); From 91b0b74dcbade059554bbbdc4e8f7bcf735bb619 Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 02:39:19 +0000 Subject: [PATCH 04/21] chore: add media/ to .gitignore and remove from tracking --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e834e5c..57c9dc4 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ venv/ target/ vendor/ config/ +media/ From a0906f3cdb3ca15abd193bb592ea385e902e4cab Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 03:08:56 +0000 Subject: [PATCH 05/21] feat(S03/T01): content card view with grid layout and view toggle - Created ContentCard component: 16:9 thumbnail with duration badge, title, status badge, relative time, monitor/download/external actions, overlay checkbox for selection (visible on hover + when selected) - Added card/table view toggle button in Content header (Grid3X3/List icons) - View preference persisted to localStorage (tubearr-content-view) - Card grid uses CSS grid with auto-fill minmax(240px, 1fr) - Card hover shows border-light highlight, selected shows accent border - Download progress overlay on card thumbnail for active downloads - Added .card-checkbox CSS rule for hover-reveal behavior - Both views share pagination, search, filter, bulk selection --- src/frontend/src/components/ContentCard.tsx | 273 ++++++++++++++++++++ src/frontend/src/pages/ChannelDetail.tsx | 69 +++++ src/frontend/src/styles/global.css | 6 + 3 files changed, 348 insertions(+) create mode 100644 src/frontend/src/components/ContentCard.tsx diff --git a/src/frontend/src/components/ContentCard.tsx b/src/frontend/src/components/ContentCard.tsx new file mode 100644 index 0000000..82c9e10 --- /dev/null +++ b/src/frontend/src/components/ContentCard.tsx @@ -0,0 +1,273 @@ +import { Bookmark, BookmarkPlus, Download, ExternalLink, Film, Music } from 'lucide-react'; +import { StatusBadge } from './StatusBadge'; +import { DownloadProgressBar } from './DownloadProgressBar'; +import { useDownloadProgress } from '../contexts/DownloadProgressContext'; +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 { + item: ContentItem; + selected: boolean; + onSelect: (id: number) => void; + onToggleMonitored: (id: number, monitored: boolean) => void; + onDownload: (id: number) => void; +} + +export function ContentCard({ item, selected, onSelect, onToggleMonitored, onDownload }: ContentCardProps) { + const progress = useDownloadProgress(item.id); + const duration = formatDuration(item.duration); + const published = formatRelativeTime(item.publishedAt); + + return ( +
onSelect(item.id)} + onMouseEnter={(e) => { + if (!selected) e.currentTarget.style.borderColor = 'var(--border-light)'; + }} + onMouseLeave={(e) => { + if (!selected) e.currentTarget.style.borderColor = 'var(--border)'; + }} + > + {/* Thumbnail */} +
+ {item.thumbnailUrl ? ( + + ) : ( +
+ {item.contentType === 'audio' ? : } +
+ )} + + {/* Duration badge */} + {duration && ( + + {duration} + + )} + + {/* Selection checkbox */} +
+ { + e.stopPropagation(); + onSelect(item.id); + }} + onClick={(e) => e.stopPropagation()} + aria-label={`Select ${item.title}`} + style={{ + width: 18, + height: 18, + cursor: 'pointer', + accentColor: 'var(--accent)', + }} + /> +
+ + {/* Download progress overlay */} + {item.status === 'downloading' && progress && ( +
+ +
+ )} +
+ + {/* Card body */} +
+ {/* Title */} + e.stopPropagation()} + style={{ + display: 'block', + fontWeight: 500, + fontSize: 'var(--font-size-sm)', + color: 'var(--text-primary)', + lineHeight: 1.4, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + textDecoration: 'none', + }} + title={item.title} + > + {item.title} + + + {/* Meta row */} +
+ + + {published} + +
+ + {/* Action row */} +
+ + + {item.status !== 'downloaded' && item.status !== 'downloading' && item.status !== 'queued' && ( + + )} + + e.stopPropagation()} + title="Open on YouTube" + aria-label={`Open ${item.title} on YouTube`} + style={{ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 28, + height: 28, + borderRadius: 'var(--radius-sm)', + color: 'var(--text-muted)', + transition: 'color var(--transition-fast)', + }} + > + + +
+
+
+ ); +} diff --git a/src/frontend/src/pages/ChannelDetail.tsx b/src/frontend/src/pages/ChannelDetail.tsx index 9d50cd9..478459a 100644 --- a/src/frontend/src/pages/ChannelDetail.tsx +++ b/src/frontend/src/pages/ChannelDetail.tsx @@ -10,6 +10,8 @@ import { Download, ExternalLink, Film, + Grid3X3, + List, ListMusic, Loader, Music, @@ -29,6 +31,7 @@ import { StatusBadge } from '../components/StatusBadge'; import { QualityLabel } from '../components/QualityLabel'; import { DownloadProgressBar } from '../components/DownloadProgressBar'; import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton'; +import { ContentCard } from '../components/ContentCard'; import { Pagination } from '../components/Pagination'; import { Modal } from '../components/Modal'; import { useDownloadProgress } from '../contexts/DownloadProgressContext'; @@ -108,6 +111,10 @@ export function ChannelDetail() { const [contentTypeFilter, setContentTypeFilter] = useState(''); const [sortKey, setSortKey] = useState(null); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + const [viewMode, setViewMode] = useState<'table' | 'card'>(() => { + try { return (localStorage.getItem('tubearr-content-view') as 'table' | 'card') || 'table'; } + catch { return 'table'; } + }); const contentFilters: ChannelContentFilters = useMemo(() => ({ page: contentPage, @@ -280,6 +287,14 @@ export function ChannelDetail() { setContentPage(1); }, []); + const handleViewToggle = useCallback(() => { + setViewMode((prev) => { + const next = prev === 'table' ? 'card' : 'table'; + try { localStorage.setItem('tubearr-content-view', next); } catch { /* ignore */ } + return next; + }); + }, []); + const togglePlaylist = useCallback((id: number | 'uncategorized') => { setExpandedPlaylists((prev) => { const next = new Set(prev); @@ -614,6 +629,37 @@ export function ChannelDetail() { [contentColumns, sortKey, sortDirection, handleSort], ); + const renderCardGrid = useCallback( + (items: ContentItem[]) => ( +
+ {items.length === 0 ? ( +
+ No content found for this channel. +
+ ) : ( + items.map((item) => ( + toggleMonitored.mutate({ contentId: id, monitored })} + onDownload={(id) => downloadContent.mutate(id)} + /> + )) + )} +
+ ), + [selectedIds, toggleSelect, toggleMonitored, downloadContent], + ); + const renderPlaylistGroups = useCallback( (groups: { id: number | 'uncategorized'; title: string; items: ContentItem[] }[]) => (
@@ -1117,6 +1163,27 @@ export function ChannelDetail() { + {/* View toggle */} +
{contentError ? (
) : hasPlaylistGroups ? ( renderPlaylistGroups(playlistGroups!) + ) : viewMode === 'card' ? ( + renderCardGrid(content) ) : ( renderTable(content) )} diff --git a/src/frontend/src/styles/global.css b/src/frontend/src/styles/global.css index 1763619..f028da8 100644 --- a/src/frontend/src/styles/global.css +++ b/src/frontend/src/styles/global.css @@ -216,6 +216,12 @@ tbody tr:hover { background-color: var(--bg-hover); } +/* ── Card checkbox visibility on hover ── */ +div:hover > .card-checkbox, +.card-checkbox:has(input:checked) { + opacity: 1 !important; +} + /* ── Responsive ── */ @media (max-width: 768px) { :root { From 3355326526025b5423ef3920074a5a3913c0203e Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 04:15:46 +0000 Subject: [PATCH 06/21] =?UTF-8?q?feat:=20Added=20CSS=20button=20utilities?= =?UTF-8?q?=20(.btn,=20.btn-icon,=20.btn-ghost,=20.btn-dan=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "src/frontend/src/styles/global.css" - "src/frontend/src/components/Skeleton.tsx" - "src/frontend/src/components/Modal.tsx" - "src/frontend/src/components/Sidebar.tsx" - "src/frontend/src/pages/Queue.tsx" - "src/frontend/src/pages/Library.tsx" - "src/frontend/src/pages/Activity.tsx" - "src/frontend/src/pages/System.tsx" GSD-Task: S02/T02 --- src/frontend/src/components/Modal.tsx | 20 +----- src/frontend/src/components/Sidebar.tsx | 17 +---- src/frontend/src/components/Skeleton.tsx | 89 ++++++++++++++++++++++++ src/frontend/src/pages/Activity.tsx | 43 ++---------- src/frontend/src/pages/Library.tsx | 23 ++---- src/frontend/src/pages/Queue.tsx | 55 +++------------ src/frontend/src/pages/System.tsx | 74 ++------------------ src/frontend/src/styles/global.css | 88 +++++++++++++++++++++++ 8 files changed, 205 insertions(+), 204 deletions(-) diff --git a/src/frontend/src/components/Modal.tsx b/src/frontend/src/components/Modal.tsx index 2e2317b..b8782af 100644 --- a/src/frontend/src/components/Modal.tsx +++ b/src/frontend/src/components/Modal.tsx @@ -112,6 +112,7 @@ export function Modal({ title, open, onClose, children, width = 480 }: ModalProp border: '1px solid var(--border-light)', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-lg)', + animation: 'modal-enter 200ms ease-out', }} > {/* Header */} @@ -137,24 +138,7 @@ export function Modal({ title, open, onClose, children, width = 480 }: ModalProp diff --git a/src/frontend/src/components/Sidebar.tsx b/src/frontend/src/components/Sidebar.tsx index 532dc0c..3c7b112 100644 --- a/src/frontend/src/components/Sidebar.tsx +++ b/src/frontend/src/components/Sidebar.tsx @@ -78,26 +78,11 @@ export function Sidebar() { @@ -190,20 +179,8 @@ export function Queue() { disabled={cancelMutation.isPending} title="Cancel" aria-label="Cancel pending item" - style={{ - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - width: 28, - height: 28, - padding: 0, - border: '1px solid var(--border)', - borderRadius: 'var(--radius-md)', - backgroundColor: 'var(--bg-input)', - color: 'var(--danger)', - cursor: cancelMutation.isPending ? 'wait' : 'pointer', - transition: 'background-color var(--transition-fast)', - }} + className="btn-icon" + style={{ color: 'var(--danger)' }} > @@ -294,18 +271,7 @@ export function Queue() { @@ -479,15 +461,7 @@ export function SettingsPage() { onClick={(e) => { e.stopPropagation(); setEditingProfile(p); }} title="Edit profile" aria-label={`Edit ${p.name}`} - style={iconButtonBase} - onMouseEnter={(e) => { - e.currentTarget.style.color = 'var(--accent)'; - e.currentTarget.style.backgroundColor = 'var(--accent-subtle)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.color = 'var(--text-muted)'; - e.currentTarget.style.backgroundColor = 'transparent'; - }} + className="btn-icon btn-icon-edit" > @@ -496,15 +470,7 @@ export function SettingsPage() { onClick={(e) => { e.stopPropagation(); setDeletingProfile(p); }} title="Delete profile" aria-label={`Delete ${p.name}`} - style={iconButtonBase} - onMouseEnter={(e) => { - e.currentTarget.style.color = 'var(--danger)'; - e.currentTarget.style.backgroundColor = 'var(--danger-bg)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.color = 'var(--text-muted)'; - e.currentTarget.style.backgroundColor = 'transparent'; - }} + className="btn-icon btn-icon-delete" > @@ -587,18 +553,8 @@ export function SettingsPage() { title="Send test notification" aria-label={`Test ${n.name}`} disabled={result === 'loading'} - style={{ - ...iconButtonBase, - opacity: result === 'loading' ? 0.5 : 1, - }} - onMouseEnter={(e) => { - e.currentTarget.style.color = 'var(--success)'; - e.currentTarget.style.backgroundColor = 'var(--success-bg)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.color = 'var(--text-muted)'; - e.currentTarget.style.backgroundColor = 'transparent'; - }} + className="btn-icon btn-icon-test" + style={{ opacity: result === 'loading' ? 0.5 : 1 }} > {result === 'loading' ? @@ -611,15 +567,7 @@ export function SettingsPage() { onClick={(e) => { e.stopPropagation(); setEditingNotification(n); }} title="Edit channel" aria-label={`Edit ${n.name}`} - style={iconButtonBase} - onMouseEnter={(e) => { - e.currentTarget.style.color = 'var(--accent)'; - e.currentTarget.style.backgroundColor = 'var(--accent-subtle)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.color = 'var(--text-muted)'; - e.currentTarget.style.backgroundColor = 'transparent'; - }} + className="btn-icon btn-icon-edit" > @@ -629,15 +577,7 @@ export function SettingsPage() { onClick={(e) => { e.stopPropagation(); setDeletingNotification(n); }} title="Delete channel" aria-label={`Delete ${n.name}`} - style={iconButtonBase} - onMouseEnter={(e) => { - e.currentTarget.style.color = 'var(--danger)'; - e.currentTarget.style.backgroundColor = 'var(--danger-bg)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.color = 'var(--text-muted)'; - e.currentTarget.style.backgroundColor = 'transparent'; - }} + className="btn-icon btn-icon-delete" > @@ -652,12 +592,7 @@ export function SettingsPage() { // ── Loading state ── if (profilesLoading) { - return ( -
- - Loading settings... -
- ); + return ; } // ── Error state ── @@ -683,17 +618,7 @@ export function SettingsPage() { @@ -792,22 +709,8 @@ export function SettingsPage() { onClick={handleCopyApiKey} title={copySuccess ? 'Copied!' : 'Copy to clipboard'} aria-label="Copy API key to clipboard" - style={{ - ...iconButtonBase, - color: copySuccess ? 'var(--success)' : 'var(--text-muted)', - }} - onMouseEnter={(e) => { - if (!copySuccess) { - e.currentTarget.style.color = 'var(--accent)'; - e.currentTarget.style.backgroundColor = 'var(--accent-subtle)'; - } - }} - onMouseLeave={(e) => { - if (!copySuccess) { - e.currentTarget.style.color = 'var(--text-muted)'; - e.currentTarget.style.backgroundColor = 'transparent'; - } - }} + className="btn-icon btn-icon-edit" + style={copySuccess ? { color: 'var(--success)' } : undefined} > {copySuccess ? : } @@ -817,15 +720,7 @@ export function SettingsPage() { onClick={() => setShowRegenerateConfirm(true)} title="Regenerate API key" aria-label="Regenerate API key" - style={iconButtonBase} - onMouseEnter={(e) => { - e.currentTarget.style.color = 'var(--warning)'; - e.currentTarget.style.backgroundColor = 'var(--warning-bg)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.color = 'var(--text-muted)'; - e.currentTarget.style.backgroundColor = 'transparent'; - }} + className="btn-icon btn-icon-warning" > @@ -911,17 +806,10 @@ export function SettingsPage() {
) : null} - {/* Download error toast */} - {downloadContent.isError ? ( -
- {downloadContent.error instanceof Error - ? downloadContent.error.message - : 'Failed to enqueue download'} -
- ) : null} - - {/* Scan result toast */} - {scanResult ? ( -
- {scanResult.message} -
- ) : null} - {/* Delete confirmation modal */} setShowDeleteConfirm(false)} disabled={deleteChannel.isPending} - style={{ - padding: 'var(--space-2) var(--space-4)', - borderRadius: 'var(--radius-md)', - backgroundColor: 'var(--bg-hover)', - color: 'var(--text-secondary)', - fontSize: 'var(--font-size-sm)', - fontWeight: 500, - }} + className="btn btn-ghost" > Cancel
); } diff --git a/src/frontend/src/styles/global.css b/src/frontend/src/styles/global.css index 437b000..eb1bc1f 100644 --- a/src/frontend/src/styles/global.css +++ b/src/frontend/src/styles/global.css @@ -341,6 +341,17 @@ div:hover > .card-checkbox, } /* ── Responsive ── */ +@keyframes toast-slide-in { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + @media (max-width: 768px) { :root { --sidebar-width: 0px; From cca396a7e8d3081e9342ac37b9acc2f35e0fa96f Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 04:40:36 +0000 Subject: [PATCH 09/21] =?UTF-8?q?feat:=20Refactored=20channel=20header=20i?= =?UTF-8?q?nto=20grouped=20control=20sections=20(Monitori=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "src/frontend/src/pages/ChannelDetail.tsx" GSD-Task: S03/T02 --- src/frontend/src/pages/ChannelDetail.tsx | 513 ++++++++++++++--------- 1 file changed, 315 insertions(+), 198 deletions(-) diff --git a/src/frontend/src/pages/ChannelDetail.tsx b/src/frontend/src/pages/ChannelDetail.tsx index 215945c..d189bf5 100644 --- a/src/frontend/src/pages/ChannelDetail.tsx +++ b/src/frontend/src/pages/ChannelDetail.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { ArrowLeft, @@ -7,6 +7,7 @@ import { CheckCircle, ChevronDown, ChevronRight, + ChevronUp, Download, ExternalLink, Film, @@ -17,7 +18,6 @@ import { Music, RefreshCw, Save, - Search, Trash2, } from 'lucide-react'; import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useSetMonitoringMode, useScanStatus } from '../api/hooks/useChannels'; @@ -151,6 +151,24 @@ export function ChannelDetail() { const [checkIntervalSaved, setCheckIntervalSaved] = useState(false); const { toast } = useToast(); + // ── Collapsible header ── + const headerRef = useRef(null); + const [isHeaderCollapsed, setIsHeaderCollapsed] = useState(false); + + useEffect(() => { + const el = headerRef.current; + if (!el) return; + const observer = new IntersectionObserver( + ([entry]) => { + // Collapse when the header is mostly out of view + setIsHeaderCollapsed(!entry.isIntersecting); + }, + { threshold: 0, rootMargin: '-60px 0px 0px 0px' }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, []); + // Sync local check interval from channel data useEffect(() => { if (channel?.checkInterval != null) { @@ -774,248 +792,347 @@ export function ChannelDetail() { Back to Channels - {/* Channel header */} + {/* Compact sticky bar — visible when full header scrolls out of view */}
- {/* Avatar */} + {/* Identity — compact */} {`${channel.name} + + {channel.name} + + - {/* Info */} -
-
-

+ + {/* Key actions — compact */} + + + + + + +
+ + {/* Scroll-to-top to reveal full header */} + +
+ + {/* Full channel header — observed for collapse trigger */} +
+ {/* Identity row */} +
+ {`${channel.name} +
+
+

+ {channel.name} +

+ +
+ - {channel.name} -

- + {channel.url} + + +
+
+ + {/* Control groups */} +
+ {/* Monitoring group */} +
+ + Monitoring + +
+ +
+ setLocalCheckInterval(e.target.value === '' ? '' : Number(e.target.value))} + aria-label="Check interval in minutes" + title="Check interval (minutes)" + style={{ + width: 56, + padding: 'var(--space-2)', + borderRadius: 'var(--radius-md)', + border: '1px solid var(--border)', + backgroundColor: 'var(--bg-main)', + color: 'var(--text-primary)', + fontSize: 'var(--font-size-sm)', + }} + /> + min + +
+
- - - {channel.url} + {/* Format group */} +
+ + Format - - - - {/* Actions row */} -
- {/* Monitoring mode dropdown */} - - - {/* Format profile selector */} +
- {/* Per-channel check interval */} -
- setLocalCheckInterval(e.target.value === '' ? '' : Number(e.target.value))} - aria-label="Check interval in minutes" - title="Check interval (minutes)" - style={{ - width: 64, - padding: 'var(--space-2) var(--space-2)', - borderRadius: 'var(--radius-md)', - border: '1px solid var(--border)', - backgroundColor: 'var(--bg-main)', - color: 'var(--text-primary)', - fontSize: 'var(--font-size-sm)', - }} - /> - min + {/* Actions group */} +
+ + Actions + +
-
- - {/* Refresh & Scan button */} - - - {/* Collect Monitored button */} - - - {/* Refresh Playlists button (YouTube only) */} - {isYouTube ? ( - - ) : null} + + {isYouTube ? ( + + ) : null} +
+
- {/* Delete button */} + {/* Spacer + Delete */} +
- {/* Floating bulk action bar */} + {/* Floating bulk action bar — glassmorphism */} {selectedIds.size > 0 ? (
Date: Fri, 3 Apr 2026 06:06:20 +0000 Subject: [PATCH 11/21] ci: add Forgejo Actions workflow for test automation Runs TypeScript type checking and vitest on push/PR to master. --- .forgejo/workflows/ci.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .forgejo/workflows/ci.yml diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..3f21e7e --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npx tsc --noEmit + + - name: Run tests + run: npm test From 6a5402ce8d9970bca945c4137c83bcdd7f30d353 Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 06:04:49 +0000 Subject: [PATCH 12/21] =?UTF-8?q?feat:=20Added=20banner=5Furl,=20descripti?= =?UTF-8?q?on,=20subscriber=5Fcount=20columns=20with=20Driz=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "src/db/schema/channels.ts" - "drizzle/0010_special_ghost_rider.sql" - "src/types/index.ts" - "src/sources/youtube.ts" - "src/sources/soundcloud.ts" - "src/db/repositories/channel-repository.ts" - "src/server/routes/channel.ts" - "src/__tests__/sources.test.ts" GSD-Task: S01/T01 --- drizzle/0009_many_carlie_cooper.sql | 1 + drizzle/0010_special_ghost_rider.sql | 3 + drizzle/meta/0009_snapshot.json | 976 +++++++++++++++++++ drizzle/meta/0010_snapshot.json | 997 ++++++++++++++++++++ drizzle/meta/_journal.json | 14 + src/__tests__/sources.test.ts | 9 + src/__tests__/yt-dlp-classification.test.ts | 161 ++++ src/db/repositories/channel-repository.ts | 17 +- src/db/repositories/queue-repository.ts | 7 + src/db/schema/channels.ts | 3 + src/db/schema/queue.ts | 1 + src/server/routes/channel.ts | 3 + src/sources/soundcloud.ts | 11 + src/sources/youtube.ts | 20 +- src/sources/yt-dlp.ts | 6 + src/types/index.ts | 7 + 16 files changed, 2232 insertions(+), 4 deletions(-) create mode 100644 drizzle/0009_many_carlie_cooper.sql create mode 100644 drizzle/0010_special_ghost_rider.sql create mode 100644 drizzle/meta/0009_snapshot.json create mode 100644 drizzle/meta/0010_snapshot.json create mode 100644 src/__tests__/yt-dlp-classification.test.ts diff --git a/drizzle/0009_many_carlie_cooper.sql b/drizzle/0009_many_carlie_cooper.sql new file mode 100644 index 0000000..9ffe95f --- /dev/null +++ b/drizzle/0009_many_carlie_cooper.sql @@ -0,0 +1 @@ +ALTER TABLE `queue_items` ADD `error_category` text; \ No newline at end of file diff --git a/drizzle/0010_special_ghost_rider.sql b/drizzle/0010_special_ghost_rider.sql new file mode 100644 index 0000000..adcd59e --- /dev/null +++ b/drizzle/0010_special_ghost_rider.sql @@ -0,0 +1,3 @@ +ALTER TABLE `channels` ADD `banner_url` text;--> statement-breakpoint +ALTER TABLE `channels` ADD `description` text;--> statement-breakpoint +ALTER TABLE `channels` ADD `subscriber_count` integer; \ No newline at end of file diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..755adda --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,976 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "eaac3184-0b4a-45d4-b2a9-da09dbd4bd56", + "prevId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", + "tables": { + "channels": { + "name": "channels", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform_id": { + "name": "platform_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitoring_enabled": { + "name": "monitoring_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "check_interval": { + "name": "check_interval", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 360 + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "format_profile_id": { + "name": "format_profile_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_check_status": { + "name": "last_check_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monitoring_mode": { + "name": "monitoring_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'all'" + } + }, + "indexes": {}, + "foreignKeys": { + "channels_format_profile_id_format_profiles_id_fk": { + "name": "channels_format_profile_id_format_profiles_id_fk", + "tableFrom": "channels", + "tableTo": "format_profiles", + "columnsFrom": [ + "format_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "content_items": { + "name": "content_items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform_content_id": { + "name": "platform_content_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quality_metadata": { + "name": "quality_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'monitored'" + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "downloaded_at": { + "name": "downloaded_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monitored": { + "name": "monitored", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "content_items_channel_id_channels_id_fk": { + "name": "content_items_channel_id_channels_id_fk", + "tableFrom": "content_items", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "format_profiles": { + "name": "format_profiles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "video_resolution": { + "name": "video_resolution", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audio_codec": { + "name": "audio_codec", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audio_bitrate": { + "name": "audio_bitrate", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "container_format": { + "name": "container_format", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "subtitle_languages": { + "name": "subtitle_languages", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "embed_subtitles": { + "name": "embed_subtitles", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "download_history": { + "name": "download_history", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "content_item_id": { + "name": "content_item_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "download_history_content_item_id_content_items_id_fk": { + "name": "download_history_content_item_id_content_items_id_fk", + "tableFrom": "download_history", + "tableTo": "content_items", + "columnsFrom": [ + "content_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "download_history_channel_id_channels_id_fk": { + "name": "download_history_channel_id_channels_id_fk", + "tableFrom": "download_history", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "content_playlist": { + "name": "content_playlist", + "columns": { + "content_item_id": { + "name": "content_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "playlist_id": { + "name": "playlist_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "content_playlist_content_item_id_content_items_id_fk": { + "name": "content_playlist_content_item_id_content_items_id_fk", + "tableFrom": "content_playlist", + "tableTo": "content_items", + "columnsFrom": [ + "content_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "content_playlist_playlist_id_playlists_id_fk": { + "name": "content_playlist_playlist_id_playlists_id_fk", + "tableFrom": "content_playlist", + "tableTo": "playlists", + "columnsFrom": [ + "playlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "content_playlist_content_item_id_playlist_id_pk": { + "columns": [ + "content_item_id", + "playlist_id" + ], + "name": "content_playlist_content_item_id_playlist_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_settings": { + "name": "notification_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "on_grab": { + "name": "on_grab", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "on_download": { + "name": "on_download", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "on_failure": { + "name": "on_failure", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "platform_settings": { + "name": "platform_settings", + "columns": { + "platform": { + "name": "platform", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "default_format_profile_id": { + "name": "default_format_profile_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "check_interval": { + "name": "check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 360 + }, + "concurrency_limit": { + "name": "concurrency_limit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 2 + }, + "subtitle_languages": { + "name": "subtitle_languages", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grab_all_enabled": { + "name": "grab_all_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "grab_all_order": { + "name": "grab_all_order", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'newest'" + }, + "scan_limit": { + "name": "scan_limit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 100 + }, + "rate_limit_delay": { + "name": "rate_limit_delay", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1000 + }, + "default_monitoring_mode": { + "name": "default_monitoring_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'all'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "platform_settings_default_format_profile_id_format_profiles_id_fk": { + "name": "platform_settings_default_format_profile_id_format_profiles_id_fk", + "tableFrom": "platform_settings", + "tableTo": "format_profiles", + "columnsFrom": [ + "default_format_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "playlists": { + "name": "playlists", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform_playlist_id": { + "name": "platform_playlist_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "playlists_channel_id_channels_id_fk": { + "name": "playlists_channel_id_channels_id_fk", + "tableFrom": "playlists", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "queue_items": { + "name": "queue_items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "content_item_id": { + "name": "content_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_category": { + "name": "error_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "queue_items_content_item_id_content_items_id_fk": { + "name": "queue_items_content_item_id_content_items_id_fk", + "tableFrom": "queue_items", + "tableTo": "content_items", + "columnsFrom": [ + "content_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_config": { + "name": "system_config", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..ffe76c8 --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,997 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "2032ed4f-0e7d-4a3c-9e00-96716084f3f6", + "prevId": "eaac3184-0b4a-45d4-b2a9-da09dbd4bd56", + "tables": { + "channels": { + "name": "channels", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform_id": { + "name": "platform_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitoring_enabled": { + "name": "monitoring_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "check_interval": { + "name": "check_interval", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 360 + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "format_profile_id": { + "name": "format_profile_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_check_status": { + "name": "last_check_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monitoring_mode": { + "name": "monitoring_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'all'" + }, + "banner_url": { + "name": "banner_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscriber_count": { + "name": "subscriber_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "channels_format_profile_id_format_profiles_id_fk": { + "name": "channels_format_profile_id_format_profiles_id_fk", + "tableFrom": "channels", + "tableTo": "format_profiles", + "columnsFrom": [ + "format_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "content_items": { + "name": "content_items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform_content_id": { + "name": "platform_content_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quality_metadata": { + "name": "quality_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'monitored'" + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "downloaded_at": { + "name": "downloaded_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monitored": { + "name": "monitored", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "content_items_channel_id_channels_id_fk": { + "name": "content_items_channel_id_channels_id_fk", + "tableFrom": "content_items", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "format_profiles": { + "name": "format_profiles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "video_resolution": { + "name": "video_resolution", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audio_codec": { + "name": "audio_codec", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audio_bitrate": { + "name": "audio_bitrate", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "container_format": { + "name": "container_format", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "subtitle_languages": { + "name": "subtitle_languages", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "embed_subtitles": { + "name": "embed_subtitles", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "download_history": { + "name": "download_history", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "content_item_id": { + "name": "content_item_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "download_history_content_item_id_content_items_id_fk": { + "name": "download_history_content_item_id_content_items_id_fk", + "tableFrom": "download_history", + "tableTo": "content_items", + "columnsFrom": [ + "content_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "download_history_channel_id_channels_id_fk": { + "name": "download_history_channel_id_channels_id_fk", + "tableFrom": "download_history", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "content_playlist": { + "name": "content_playlist", + "columns": { + "content_item_id": { + "name": "content_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "playlist_id": { + "name": "playlist_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "content_playlist_content_item_id_content_items_id_fk": { + "name": "content_playlist_content_item_id_content_items_id_fk", + "tableFrom": "content_playlist", + "tableTo": "content_items", + "columnsFrom": [ + "content_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "content_playlist_playlist_id_playlists_id_fk": { + "name": "content_playlist_playlist_id_playlists_id_fk", + "tableFrom": "content_playlist", + "tableTo": "playlists", + "columnsFrom": [ + "playlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "content_playlist_content_item_id_playlist_id_pk": { + "columns": [ + "content_item_id", + "playlist_id" + ], + "name": "content_playlist_content_item_id_playlist_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_settings": { + "name": "notification_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "on_grab": { + "name": "on_grab", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "on_download": { + "name": "on_download", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "on_failure": { + "name": "on_failure", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "platform_settings": { + "name": "platform_settings", + "columns": { + "platform": { + "name": "platform", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "default_format_profile_id": { + "name": "default_format_profile_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "check_interval": { + "name": "check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 360 + }, + "concurrency_limit": { + "name": "concurrency_limit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 2 + }, + "subtitle_languages": { + "name": "subtitle_languages", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grab_all_enabled": { + "name": "grab_all_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "grab_all_order": { + "name": "grab_all_order", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'newest'" + }, + "scan_limit": { + "name": "scan_limit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 100 + }, + "rate_limit_delay": { + "name": "rate_limit_delay", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1000 + }, + "default_monitoring_mode": { + "name": "default_monitoring_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'all'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "platform_settings_default_format_profile_id_format_profiles_id_fk": { + "name": "platform_settings_default_format_profile_id_format_profiles_id_fk", + "tableFrom": "platform_settings", + "tableTo": "format_profiles", + "columnsFrom": [ + "default_format_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "playlists": { + "name": "playlists", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform_playlist_id": { + "name": "platform_playlist_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "playlists_channel_id_channels_id_fk": { + "name": "playlists_channel_id_channels_id_fk", + "tableFrom": "playlists", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "queue_items": { + "name": "queue_items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "content_item_id": { + "name": "content_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_category": { + "name": "error_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "queue_items_content_item_id_content_items_id_fk": { + "name": "queue_items_content_item_id_content_items_id_fk", + "tableFrom": "queue_items", + "tableTo": "content_items", + "columnsFrom": [ + "content_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_config": { + "name": "system_config", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 063a6a8..445549e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,20 @@ "when": 1774839000000, "tag": "0008_add_default_monitoring_mode", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1775192114394, + "tag": "0009_many_carlie_cooper", + "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1775196046744, + "tag": "0010_special_ghost_rider", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/__tests__/sources.test.ts b/src/__tests__/sources.test.ts index 60dc603..4174ab5 100644 --- a/src/__tests__/sources.test.ts +++ b/src/__tests__/sources.test.ts @@ -135,6 +135,9 @@ function makeChannel(overrides: Partial = {}): Channel { metadata: null, formatProfileId: null, monitoringMode: 'all', + bannerUrl: null, + description: null, + subscriberCount: null, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', lastCheckedAt: null, @@ -237,6 +240,9 @@ describe('YouTubeSource', () => { imageUrl: 'https://i.ytimg.com/vi/thumb_large.jpg', url: 'https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw', platform: 'youtube', + bannerUrl: 'https://i.ytimg.com/vi/thumb_large.jpg', + description: null, + subscriberCount: null, }); // Verify yt-dlp was called with correct args @@ -669,6 +675,9 @@ describe('SoundCloudSource', () => { imageUrl: 'https://i1.sndcdn.com/avatars-large.jpg', url: 'https://soundcloud.com/deadmau5', platform: 'soundcloud', + bannerUrl: null, + description: null, + subscriberCount: null, }); }); }); diff --git a/src/__tests__/yt-dlp-classification.test.ts b/src/__tests__/yt-dlp-classification.test.ts new file mode 100644 index 0000000..94713a1 --- /dev/null +++ b/src/__tests__/yt-dlp-classification.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from 'vitest'; +import { + classifyYtDlpError, + YtDlpError, + type YtDlpErrorCategory, +} from '../sources/yt-dlp'; + +describe('classifyYtDlpError', () => { + // ── rate_limit ── + + it('classifies HTTP 429 as rate_limit', () => { + expect(classifyYtDlpError('ERROR: HTTP Error 429: Too Many Requests')).toBe('rate_limit'); + }); + + it('classifies "too many requests" as rate_limit', () => { + expect(classifyYtDlpError('ERROR: too many requests, please retry later')).toBe('rate_limit'); + }); + + // ── format_unavailable ── + + it('classifies "requested format" as format_unavailable', () => { + expect(classifyYtDlpError('ERROR: requested format not available')).toBe('format_unavailable'); + }); + + it('classifies "format is not available" as format_unavailable', () => { + expect(classifyYtDlpError('ERROR: format is not available')).toBe('format_unavailable'); + }); + + // ── geo_blocked ── + + it('classifies geo-restriction as geo_blocked', () => { + expect( + classifyYtDlpError('ERROR: Video not available in your country') + ).toBe('geo_blocked'); + }); + + it('classifies "geo" keyword as geo_blocked', () => { + expect(classifyYtDlpError('ERROR: geo-restricted content')).toBe('geo_blocked'); + }); + + // ── age_restricted ── + + it('classifies age-restricted content', () => { + expect(classifyYtDlpError('ERROR: age restricted video')).toBe('age_restricted'); + }); + + it('classifies age verify as age_restricted', () => { + expect(classifyYtDlpError('Sign in to confirm your age. This video may be inappropriate for some users. Verify your age')).toBe('age_restricted'); + }); + + // ── private ── + + it('classifies private video', () => { + expect(classifyYtDlpError('ERROR: Private video. Sign in if you\'ve been granted access')).toBe('private'); + }); + + it('classifies "video unavailable"', () => { + expect(classifyYtDlpError('ERROR: Video unavailable')).toBe('private'); + }); + + it('classifies "been removed"', () => { + expect(classifyYtDlpError('ERROR: This video has been removed by the uploader')).toBe('private'); + }); + + // ── sign_in_required ── + + it('classifies "sign in" as sign_in_required', () => { + expect(classifyYtDlpError('ERROR: Sign in to confirm you are not a bot')).toBe('sign_in_required'); + }); + + it('classifies "login required" as sign_in_required', () => { + expect(classifyYtDlpError('ERROR: This video requires login required authentication')).toBe('sign_in_required'); + }); + + // ── copyright ── + + it('classifies "copyright" keyword', () => { + expect(classifyYtDlpError('ERROR: This video contains content from UMG, who has blocked it on copyright grounds')).toBe('copyright'); + }); + + it('classifies "blocked...claim" pattern', () => { + expect(classifyYtDlpError('ERROR: Video blocked due to a claim by Sony Music')).toBe('copyright'); + }); + + // ── network ── + + it('classifies connection error as network', () => { + expect(classifyYtDlpError('ERROR: unable to download webpage: connection refused')).toBe('network'); + }); + + it('classifies timeout as network', () => { + expect(classifyYtDlpError('ERROR: timed out')).toBe('network'); + }); + + it('classifies urlopen error as network', () => { + expect(classifyYtDlpError('ERROR: ')).toBe('network'); + }); + + // ── unknown ── + + it('returns unknown for empty string', () => { + expect(classifyYtDlpError('')).toBe('unknown'); + }); + + it('returns unknown for unrecognized error', () => { + expect(classifyYtDlpError('ERROR: Something completely unexpected happened')).toBe('unknown'); + }); + + // ── Priority / first-match-wins ── + + it('first match wins when multiple signals present', () => { + // Contains both '429' (rate_limit) and 'connection' (network) — rate_limit is checked first + const result = classifyYtDlpError('ERROR: 429 connection refused'); + expect(result).toBe('rate_limit'); + }); +}); + +describe('YtDlpError.category', () => { + it('auto-populates category from stderr in constructor', () => { + const err = new YtDlpError( + 'yt-dlp failed', + 'ERROR: HTTP Error 429: Too Many Requests', + 1 + ); + expect(err.category).toBe('rate_limit'); + expect(err.isRateLimit).toBe(true); + }); + + it('sets category to unknown for unrecognized errors', () => { + const err = new YtDlpError('yt-dlp failed', 'some weird error', 1); + expect(err.category).toBe('unknown'); + expect(err.isRateLimit).toBe(false); + }); + + it('sets sign_in_required category', () => { + const err = new YtDlpError( + 'yt-dlp failed', + 'ERROR: Sign in to confirm you are not a bot', + 1 + ); + expect(err.category).toBe('sign_in_required'); + }); + + it('sets copyright category', () => { + const err = new YtDlpError( + 'yt-dlp failed', + 'ERROR: blocked on copyright grounds', + 1 + ); + expect(err.category).toBe('copyright'); + }); + + it('preserves all existing YtDlpError properties', () => { + const err = new YtDlpError('msg', 'stderr text', 42); + expect(err.name).toBe('YtDlpError'); + expect(err.message).toBe('msg'); + expect(err.stderr).toBe('stderr text'); + expect(err.exitCode).toBe(42); + expect(err.category).toBe('unknown'); + }); +}); diff --git a/src/db/repositories/channel-repository.ts b/src/db/repositories/channel-repository.ts index ecc955b..0c9872f 100644 --- a/src/db/repositories/channel-repository.ts +++ b/src/db/repositories/channel-repository.ts @@ -9,12 +9,17 @@ import type { Channel, Platform, MonitoringMode } from '../../types/index'; /** Fields needed to create a new channel (auto-generated fields excluded). */ export type CreateChannelData = Omit< Channel, - 'id' | 'createdAt' | 'updatedAt' | 'lastCheckedAt' | 'lastCheckStatus' | 'monitoringMode' -> & { monitoringMode?: Channel['monitoringMode'] }; + 'id' | 'createdAt' | 'updatedAt' | 'lastCheckedAt' | 'lastCheckStatus' | 'monitoringMode' | 'bannerUrl' | 'description' | 'subscriberCount' +> & { + monitoringMode?: Channel['monitoringMode']; + bannerUrl?: string | null; + description?: string | null; + subscriberCount?: number | null; +}; /** Fields that can be updated on an existing channel. */ export type UpdateChannelData = Partial< - Pick + Pick >; type Db = LibSQLDatabase; @@ -39,6 +44,9 @@ export async function createChannel( metadata: data.metadata, formatProfileId: data.formatProfileId, monitoringMode: data.monitoringMode ?? 'all', + bannerUrl: data.bannerUrl ?? null, + description: data.description ?? null, + subscriberCount: data.subscriberCount ?? null, }) .returning(); @@ -185,6 +193,9 @@ function mapRow(row: typeof channels.$inferSelect): Channel { metadata: row.metadata as Record | null, formatProfileId: row.formatProfileId, monitoringMode: (row.monitoringMode ?? 'all') as Channel['monitoringMode'], + bannerUrl: row.bannerUrl ?? null, + description: row.description ?? null, + subscriberCount: row.subscriberCount ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, lastCheckedAt: row.lastCheckedAt, diff --git a/src/db/repositories/queue-repository.ts b/src/db/repositories/queue-repository.ts index 68b28be..a589a25 100644 --- a/src/db/repositories/queue-repository.ts +++ b/src/db/repositories/queue-repository.ts @@ -16,6 +16,7 @@ export interface CreateQueueItemData { /** Optional fields when updating queue item status. */ export interface UpdateQueueItemFields { error?: string | null; + errorCategory?: string | null; startedAt?: string | null; completedAt?: string | null; attempts?: number; @@ -72,6 +73,7 @@ export async function getQueueItemsByStatus( attempts: queueItems.attempts, maxAttempts: queueItems.maxAttempts, error: queueItems.error, + errorCategory: queueItems.errorCategory, startedAt: queueItems.startedAt, completedAt: queueItems.completedAt, createdAt: queueItems.createdAt, @@ -101,6 +103,7 @@ export async function getAllQueueItems( attempts: queueItems.attempts, maxAttempts: queueItems.maxAttempts, error: queueItems.error, + errorCategory: queueItems.errorCategory, startedAt: queueItems.startedAt, completedAt: queueItems.completedAt, createdAt: queueItems.createdAt, @@ -154,6 +157,7 @@ export async function updateQueueItemStatus( }; if (updates?.error !== undefined) setData.error = updates.error; + if (updates?.errorCategory !== undefined) setData.errorCategory = updates.errorCategory; if (updates?.startedAt !== undefined) setData.startedAt = updates.startedAt; if (updates?.completedAt !== undefined) setData.completedAt = updates.completedAt; if (updates?.attempts !== undefined) setData.attempts = updates.attempts; @@ -239,6 +243,7 @@ function mapRow(row: typeof queueItems.$inferSelect): QueueItem { attempts: row.attempts, maxAttempts: row.maxAttempts, error: row.error, + errorCategory: row.errorCategory, startedAt: row.startedAt, completedAt: row.completedAt, createdAt: row.createdAt, @@ -255,6 +260,7 @@ interface JoinedQueueRow { attempts: number; maxAttempts: number; error: string | null; + errorCategory: string | null; startedAt: string | null; completedAt: string | null; createdAt: string; @@ -273,6 +279,7 @@ function mapJoinedRow(row: JoinedQueueRow): QueueItem { attempts: row.attempts, maxAttempts: row.maxAttempts, error: row.error, + errorCategory: row.errorCategory, startedAt: row.startedAt, completedAt: row.completedAt, createdAt: row.createdAt, diff --git a/src/db/schema/channels.ts b/src/db/schema/channels.ts index dfc9620..58f187b 100644 --- a/src/db/schema/channels.ts +++ b/src/db/schema/channels.ts @@ -28,4 +28,7 @@ export const channels = sqliteTable('channels', { lastCheckedAt: text('last_checked_at'), // null until first monitoring check lastCheckStatus: text('last_check_status'), // 'success' | 'error' | 'rate_limited' monitoringMode: text('monitoring_mode').notNull().default('all'), // 'all' | 'future' | 'existing' | 'none' + bannerUrl: text('banner_url'), + description: text('description'), + subscriberCount: integer('subscriber_count'), }); diff --git a/src/db/schema/queue.ts b/src/db/schema/queue.ts index 7820384..5babed0 100644 --- a/src/db/schema/queue.ts +++ b/src/db/schema/queue.ts @@ -13,6 +13,7 @@ export const queueItems = sqliteTable('queue_items', { attempts: integer('attempts').notNull().default(0), maxAttempts: integer('max_attempts').notNull().default(3), error: text('error'), + errorCategory: text('error_category'), // rate_limit|format_unavailable|geo_blocked|age_restricted|private|network|sign_in_required|copyright|unknown startedAt: text('started_at'), completedAt: text('completed_at'), createdAt: text('created_at') diff --git a/src/server/routes/channel.ts b/src/server/routes/channel.ts index 9e19c25..4a55165 100644 --- a/src/server/routes/channel.ts +++ b/src/server/routes/channel.ts @@ -176,6 +176,9 @@ export async function channelRoutes(fastify: FastifyInstance): Promise { imageUrl: metadata.imageUrl, metadata: null, formatProfileId: formatProfileId ?? null, + bannerUrl: metadata.bannerUrl ?? null, + description: metadata.description ?? null, + subscriberCount: metadata.subscriberCount ?? null, }); // Notify scheduler of new channel diff --git a/src/sources/soundcloud.ts b/src/sources/soundcloud.ts index 7338781..280ca7b 100644 --- a/src/sources/soundcloud.ts +++ b/src/sources/soundcloud.ts @@ -47,12 +47,23 @@ export class SoundCloudSource implements PlatformSource { ? (thumbnails[thumbnails.length - 1]?.url ?? null) : null; + // Extract enrichment metadata (limited availability on SoundCloud) + const description = typeof data.description === 'string' ? data.description : null; + const subscriberCount = typeof data.channel_follower_count === 'number' + ? data.channel_follower_count + : typeof data.uploader_follower_count === 'number' + ? data.uploader_follower_count + : null; + return { name: channelName, platformId: uploaderId, imageUrl, url: uploaderUrl, platform: Platform.SoundCloud, + bannerUrl: null, // SoundCloud doesn't provide banner URLs via yt-dlp + description, + subscriberCount, }; } diff --git a/src/sources/youtube.ts b/src/sources/youtube.ts index e3ed739..11c31e3 100644 --- a/src/sources/youtube.ts +++ b/src/sources/youtube.ts @@ -44,17 +44,35 @@ export class YouTubeSource implements PlatformSource { url; // Pick the best thumbnail — yt-dlp returns an array sorted by quality - const thumbnails = data.thumbnails as Array<{ url?: string }> | undefined; + const thumbnails = data.thumbnails as Array<{ url?: string; width?: number }> | undefined; const imageUrl = thumbnails?.length ? (thumbnails[thumbnails.length - 1]?.url ?? null) : null; + // Extract enrichment metadata + const description = typeof data.description === 'string' ? data.description : null; + const subscriberCount = typeof data.channel_follower_count === 'number' + ? data.channel_follower_count + : null; + + // Banner: try channel_banner_url first, then look for wide thumbnails (>=1024px) + let bannerUrl: string | null = null; + if (typeof data.channel_banner_url === 'string') { + bannerUrl = data.channel_banner_url; + } else if (thumbnails?.length) { + const wideThumbnail = thumbnails.find((t) => (t.width ?? 0) >= 1024); + if (wideThumbnail?.url) bannerUrl = wideThumbnail.url; + } + return { name: channelName, platformId: channelId, imageUrl, url: channelUrl, platform: Platform.YouTube, + bannerUrl, + description, + subscriberCount, }; } diff --git a/src/sources/yt-dlp.ts b/src/sources/yt-dlp.ts index 2977828..a8f42ec 100644 --- a/src/sources/yt-dlp.ts +++ b/src/sources/yt-dlp.ts @@ -26,6 +26,7 @@ export class YtDlpError extends Error { readonly stderr: string; readonly exitCode: number; readonly isRateLimit: boolean; + readonly category: YtDlpErrorCategory; constructor(message: string, stderr: string, exitCode: number) { super(message); @@ -33,6 +34,7 @@ export class YtDlpError extends Error { this.stderr = stderr; this.exitCode = exitCode; this.isRateLimit = detectRateLimit(stderr); + this.category = classifyYtDlpError(stderr); } } @@ -283,6 +285,8 @@ export type YtDlpErrorCategory = | 'age_restricted' // age-gated content | 'private' // private or removed video | 'network' // DNS, connection, timeout + | 'sign_in_required' // sign-in or login required + | 'copyright' // copyright claim or block | 'unknown'; export function classifyYtDlpError(stderr: string): YtDlpErrorCategory { @@ -293,6 +297,8 @@ export function classifyYtDlpError(stderr: string): YtDlpErrorCategory { if (lower.includes('not available in your country') || lower.includes('geo')) return 'geo_blocked'; if (lower.includes('age') && (lower.includes('restricted') || lower.includes('verify'))) return 'age_restricted'; if (lower.includes('private video') || lower.includes('video unavailable') || lower.includes('been removed')) return 'private'; + if (lower.includes('sign in') || lower.includes('login required')) return 'sign_in_required'; + if (lower.includes('copyright') || /blocked.*claim/.test(lower)) return 'copyright'; if (lower.includes('unable to download') || lower.includes('connection') || lower.includes('timed out') || lower.includes('urlopen error')) return 'network'; return 'unknown'; diff --git a/src/types/index.ts b/src/types/index.ts index ecc7532..0faa198 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -43,6 +43,9 @@ export interface PlatformSourceMetadata { imageUrl: string | null; url: string; platform: Platform; + bannerUrl?: string | null; + description?: string | null; + subscriberCount?: number | null; } /** Metadata for a single piece of content from a platform. */ @@ -70,6 +73,9 @@ export interface Channel { metadata: Record | null; formatProfileId: number | null; monitoringMode: MonitoringMode; + bannerUrl: string | null; + description: string | null; + subscriberCount: number | null; createdAt: string; updatedAt: string; lastCheckedAt: string | null; @@ -113,6 +119,7 @@ export interface QueueItem { attempts: number; maxAttempts: number; error: string | null; + errorCategory: string | null; startedAt: string | null; completedAt: string | null; createdAt: string; From ab7ab3634bd12a95c7789778f8d17c3af97f6b46 Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 06:34:20 +0000 Subject: [PATCH 13/21] =?UTF-8?q?feat:=20Reordered=20Add=20Channel=20modal?= =?UTF-8?q?=20fields=20to=20URL=20=E2=86=92=20Monitoring=20Mode=20?= =?UTF-8?q?=E2=86=92=20For=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "src/frontend/src/components/AddChannelModal.tsx" - "src/frontend/src/api/hooks/useChannels.ts" GSD-Task: S02/T01 --- src/frontend/src/api/hooks/useChannels.ts | 2 - .../src/components/AddChannelModal.tsx | 226 +++++++----------- src/frontend/src/components/Skeleton.tsx | 23 +- src/frontend/src/pages/ChannelDetail.tsx | 185 ++++++++++---- 4 files changed, 237 insertions(+), 199 deletions(-) diff --git a/src/frontend/src/api/hooks/useChannels.ts b/src/frontend/src/api/hooks/useChannels.ts index 51de309..4d8582c 100644 --- a/src/frontend/src/api/hooks/useChannels.ts +++ b/src/frontend/src/api/hooks/useChannels.ts @@ -39,8 +39,6 @@ interface CreateChannelInput { monitoringEnabled?: boolean; monitoringMode?: string; formatProfileId?: number; - grabAll?: boolean; - grabAllOrder?: 'newest' | 'oldest'; } /** Create a new channel by URL (resolves metadata via backend). */ diff --git a/src/frontend/src/components/AddChannelModal.tsx b/src/frontend/src/components/AddChannelModal.tsx index 7ffc163..61324be 100644 --- a/src/frontend/src/components/AddChannelModal.tsx +++ b/src/frontend/src/components/AddChannelModal.tsx @@ -51,8 +51,6 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) { const [checkInterval, setCheckInterval] = useState(''); const [formatProfileId, setFormatProfileId] = useState(undefined); const [monitoringMode, setMonitoringMode] = useState('all'); - const [grabAll, setGrabAll] = useState(false); - const [grabAllOrder, setGrabAllOrder] = useState<'newest' | 'oldest'>('newest'); const createChannel = useCreateChannel(); const { data: platformSettingsList } = usePlatformSettings(); @@ -82,16 +80,6 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) { if (settings.defaultMonitoringMode) { setMonitoringMode(settings.defaultMonitoringMode); } - - // Pre-fill grab-all defaults for YouTube - if (detectedPlatform === 'youtube') { - if (settings.grabAllEnabled) { - setGrabAll(true); - } - if (settings.grabAllOrder) { - setGrabAllOrder(settings.grabAllOrder); - } - } }, [detectedPlatform, platformSettingsList]); // eslint-disable-line react-hooks/exhaustive-deps const handleSubmit = (e: React.FormEvent) => { @@ -104,8 +92,6 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) { checkInterval: checkInterval ? parseInt(checkInterval, 10) : undefined, monitoringMode, formatProfileId: formatProfileId ?? undefined, - grabAll: detectedPlatform === 'youtube' ? grabAll : undefined, - grabAllOrder: detectedPlatform === 'youtube' && grabAll ? grabAllOrder : undefined, }, { onSuccess: (newChannel) => { @@ -128,8 +114,6 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) { setCheckInterval(''); setFormatProfileId(undefined); setMonitoringMode('all'); - setGrabAll(false); - setGrabAllOrder('newest'); createChannel.reset(); }; @@ -142,7 +126,29 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) { return ( -
+ + {/* Loading overlay */} + {createChannel.isPending && ( +
+ + + Resolving channel… + +
+ )} {/* URL input */}
)} - {/* Monitoring Mode — shown when platform detected */} - {detectedPlatform && ( -
- - -
- )} - - {/* Grab All — YouTube only */} - {detectedPlatform === 'youtube' && ( - <> -
- setGrabAll(e.target.checked)} - disabled={createChannel.isPending} - style={{ width: 'auto' }} - /> - -
- - {/* Download order — shown when grab-all enabled */} - {grabAll && ( -
- - -

- Back-catalog items will be enqueued at low priority. -

-
- )} - - )} + {/* Check interval (optional) */} +
+ + setCheckInterval(e.target.value)} + placeholder="360 (default: 6 hours)" + disabled={createChannel.isPending} + style={{ width: '100%' }} + /> +
{/* Error display */} {createChannel.isError && ( @@ -362,9 +297,12 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) { color: 'var(--danger)', }} > - {createChannel.error instanceof Error - ? createChannel.error.message - : 'Failed to add channel'} + {createChannel.error instanceof Error && + createChannel.error.message.toLowerCase().includes('already exists') + ? 'This channel has already been added.' + : createChannel.error instanceof Error + ? createChannel.error.message + : 'Failed to add channel'}
)} diff --git a/src/frontend/src/components/Skeleton.tsx b/src/frontend/src/components/Skeleton.tsx index d1cad8a..4ac565d 100644 --- a/src/frontend/src/components/Skeleton.tsx +++ b/src/frontend/src/components/Skeleton.tsx @@ -62,20 +62,27 @@ export function SkeletonChannelHeader() { return (
- -
- - -
+ {/* Banner placeholder */} + + {/* Identity + controls */} +
+
+ +
+ + +
+
+ + +
diff --git a/src/frontend/src/pages/ChannelDetail.tsx b/src/frontend/src/pages/ChannelDetail.tsx index b177535..e601e74 100644 --- a/src/frontend/src/pages/ChannelDetail.tsx +++ b/src/frontend/src/pages/ChannelDetail.tsx @@ -19,6 +19,7 @@ import { RefreshCw, Save, Trash2, + Users, } from 'lucide-react'; import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useSetMonitoringMode, useScanStatus } from '../api/hooks/useChannels'; import { useChannelContentPaginated, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored, type ChannelContentFilters } from '../api/hooks/useContent'; @@ -86,6 +87,14 @@ function formatRelativeTime(isoString: string | null): string { 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' }, @@ -144,6 +153,7 @@ export function ChannelDetail() { // ── Local state ── const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [showFullDescription, setShowFullDescription] = useState(false); const [scanInProgress, setScanInProgress] = useState(false); const [expandedPlaylists, setExpandedPlaylists] = useState>(new Set()); const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -833,6 +843,11 @@ export function ChannelDetail() { {channel.name} + {channel.subscriberCount != null && ( + + {formatSubscriberCount(channel.subscriberCount)} subscribers + + )}
@@ -907,62 +922,141 @@ export function ChannelDetail() {
- {/* Identity row */} -
- {`${channel.name} -
-
-

- {channel.name} -

- -
- - {channel.url} - - -
-
+ ) : ( +
+ )} - {/* Control groups */} +
+ {/* Identity row — avatar overlaps banner */} +
+ {`${channel.name} +
+
+

+ {channel.name} +

+ + {channel.subscriberCount != null && ( + + + {formatSubscriberCount(channel.subscriberCount)} subscribers + + )} +
+ + {channel.url} + + +
+
+ + {/* Description — collapsible */} + {channel.description && ( +
+

150 + ? { overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' as const } + : {} + ), + }} + > + {channel.description} +

+ {channel.description.length > 150 && ( + + )} +
+ )}
+
{/* Content table / playlist groups */} From 592c8b131718558e3864a038dbeff49194326f10 Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 06:40:02 +0000 Subject: [PATCH 14/21] =?UTF-8?q?feat:=20Created=20ContentListItem=20compo?= =?UTF-8?q?nent=20with=20horizontal=20flexbox=20layout:=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "src/frontend/src/components/ContentListItem.tsx" GSD-Task: S03/T01 --- .../src/components/ContentListItem.tsx | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 src/frontend/src/components/ContentListItem.tsx diff --git a/src/frontend/src/components/ContentListItem.tsx b/src/frontend/src/components/ContentListItem.tsx new file mode 100644 index 0000000..e99e64d --- /dev/null +++ b/src/frontend/src/components/ContentListItem.tsx @@ -0,0 +1,295 @@ +import { Bookmark, BookmarkPlus, Download, ExternalLink, Film, Music } from 'lucide-react'; +import { StatusBadge } from './StatusBadge'; +import { DownloadProgressBar } from './DownloadProgressBar'; +import { useDownloadProgress } from '../contexts/DownloadProgressContext'; +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 { + item: ContentItem; + selected: boolean; + onSelect: (id: number) => void; + onToggleMonitored: (id: number, monitored: boolean) => void; + onDownload: (id: number) => void; +} + +export function ContentListItem({ item, selected, onSelect, onToggleMonitored, onDownload }: ContentListItemProps) { + const progress = useDownloadProgress(item.id); + const duration = formatDuration(item.duration); + const published = formatRelativeTime(item.publishedAt); + + return ( +
onSelect(item.id)} + onMouseEnter={(e) => { + if (!selected) e.currentTarget.style.borderColor = 'var(--border-light)'; + // Reveal checkbox on hover + const cb = e.currentTarget.querySelector('.list-checkbox') as HTMLElement | null; + if (cb) cb.style.opacity = '1'; + }} + onMouseLeave={(e) => { + if (!selected) e.currentTarget.style.borderColor = 'var(--border)'; + // Hide checkbox if not selected + const cb = e.currentTarget.querySelector('.list-checkbox') as HTMLElement | null; + if (cb && !selected) cb.style.opacity = '0'; + }} + > + {/* Selection checkbox */} +
+ { + e.stopPropagation(); + onSelect(item.id); + }} + onClick={(e) => e.stopPropagation()} + aria-label={`Select ${item.title}`} + style={{ + width: 16, + height: 16, + cursor: 'pointer', + accentColor: 'var(--accent)', + }} + /> +
+ + {/* Thumbnail */} +
+ {item.thumbnailUrl ? ( + + ) : ( +
+ {item.contentType === 'audio' ? : } +
+ )} + + {/* Duration badge on thumbnail */} + {duration && ( + + {duration} + + )} + + {/* Download progress overlay */} + {item.status === 'downloading' && progress && ( +
+ +
+ )} +
+ + {/* Info section */} +
+ {/* Title */} + e.stopPropagation()} + style={{ + fontWeight: 500, + fontSize: 'var(--font-size-sm)', + color: 'var(--text-primary)', + lineHeight: 1.3, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + textDecoration: 'none', + }} + title={item.title} + > + {item.title} + + + {/* Meta row: published · duration · content type */} +
+ {published && {published}} + {published && duration && ·} + {duration && {duration}} + {(published || duration) && ·} + {item.contentType} +
+
+ + {/* Right section: status badge + action buttons */} +
+ + +
+ + + {item.status !== 'downloaded' && item.status !== 'downloading' && item.status !== 'queued' && ( + + )} + + e.stopPropagation()} + title="Open on YouTube" + aria-label={`Open ${item.title} on YouTube`} + style={{ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 28, + height: 28, + borderRadius: 'var(--radius-sm)', + color: 'var(--text-muted)', + transition: 'color var(--transition-fast)', + }} + > + + +
+
+
+ ); +} From 9fc15a3ed0f874d482f38bf9b7c0c94398922b21 Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 06:42:34 +0000 Subject: [PATCH 15/21] =?UTF-8?q?feat:=20Wired=20ContentListItem=20into=20?= =?UTF-8?q?ChannelDetail=20with=20three-button=20segmen=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "src/frontend/src/pages/ChannelDetail.tsx" GSD-Task: S03/T02 --- src/frontend/src/pages/ChannelDetail.tsx | 137 +++++++++++++++++++---- 1 file changed, 113 insertions(+), 24 deletions(-) diff --git a/src/frontend/src/pages/ChannelDetail.tsx b/src/frontend/src/pages/ChannelDetail.tsx index e601e74..ccb0c2e 100644 --- a/src/frontend/src/pages/ChannelDetail.tsx +++ b/src/frontend/src/pages/ChannelDetail.tsx @@ -12,6 +12,7 @@ import { ExternalLink, Film, Grid3X3, + LayoutList, List, ListMusic, Loader, @@ -33,6 +34,7 @@ import { QualityLabel } from '../components/QualityLabel'; import { DownloadProgressBar } from '../components/DownloadProgressBar'; import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton'; import { ContentCard } from '../components/ContentCard'; +import { ContentListItem } from '../components/ContentListItem'; import { Pagination } from '../components/Pagination'; import { Modal } from '../components/Modal'; import { useToast } from '../components/Toast'; @@ -121,8 +123,12 @@ export function ChannelDetail() { const [contentTypeFilter, setContentTypeFilter] = useState(''); const [sortKey, setSortKey] = useState(null); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); - const [viewMode, setViewMode] = useState<'table' | 'card'>(() => { - try { return (localStorage.getItem('tubearr-content-view') as 'table' | 'card') || 'table'; } + const [viewMode, setViewMode] = useState<'table' | 'card' | 'list'>(() => { + try { + const stored = localStorage.getItem('tubearr-content-view'); + if (stored === 'table' || stored === 'card' || stored === 'list') return stored; + return 'table'; + } catch { return 'table'; } }); @@ -312,12 +318,9 @@ export function ChannelDetail() { setContentPage(1); }, []); - const handleViewToggle = useCallback(() => { - setViewMode((prev) => { - const next = prev === 'table' ? 'card' : 'table'; - try { localStorage.setItem('tubearr-content-view', next); } catch { /* ignore */ } - return next; - }); + const handleSetViewMode = useCallback((mode: 'table' | 'card' | 'list') => { + setViewMode(mode); + try { localStorage.setItem('tubearr-content-view', mode); } catch { /* ignore */ } }, []); const togglePlaylist = useCallback((id: number | 'uncategorized') => { @@ -685,6 +688,37 @@ export function ChannelDetail() { [selectedIds, toggleSelect, toggleMonitored, downloadContent], ); + const renderListView = useCallback( + (items: ContentItem[]) => ( +
+ {items.length === 0 ? ( +
+ No content found for this channel. +
+ ) : ( + items.map((item) => ( + toggleMonitored.mutate({ contentId: id, monitored })} + onDownload={(id) => downloadContent.mutate(id)} + /> + )) + )} +
+ ), + [selectedIds, toggleSelect, toggleMonitored, downloadContent], + ); + const renderPlaylistGroups = useCallback( (groups: { id: number | 'uncategorized'; title: string; items: ContentItem[] }[]) => (
@@ -726,13 +760,17 @@ export function ChannelDetail() { {group.items.length} - {isExpanded ? renderTable(group.items) : null} + {isExpanded ? ( + viewMode === 'card' ? renderCardGrid(group.items) : + viewMode === 'list' ? renderListView(group.items) : + renderTable(group.items) + ) : null}
); })}
), - [expandedPlaylists, togglePlaylist, renderTable], + [expandedPlaylists, togglePlaylist, renderTable, renderCardGrid, renderListView, viewMode], ); // ── Loading / Error states ── @@ -1324,27 +1362,76 @@ export function ChannelDetail() { - {/* View toggle */} - + + + +
{contentError ? (
Date: Fri, 3 Apr 2026 06:49:38 +0000 Subject: [PATCH 16/21] =?UTF-8?q?feat:=20Add=20SortGroupBar=20with=20Date/?= =?UTF-8?q?Title/Duration/Size/Status=20sort=20button=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "src/frontend/src/components/SortGroupBar.tsx" - "src/frontend/src/pages/ChannelDetail.tsx" GSD-Task: S04/T01 --- src/frontend/src/components/SortGroupBar.tsx | 155 +++++++++++++++++++ src/frontend/src/pages/ChannelDetail.tsx | 36 ++++- 2 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 src/frontend/src/components/SortGroupBar.tsx diff --git a/src/frontend/src/components/SortGroupBar.tsx b/src/frontend/src/components/SortGroupBar.tsx new file mode 100644 index 0000000..e452ee8 --- /dev/null +++ b/src/frontend/src/components/SortGroupBar.tsx @@ -0,0 +1,155 @@ +import { ArrowDown, ArrowUp } from 'lucide-react'; + +export type SortKey = 'publishedAt' | 'title' | 'duration' | 'fileSize' | 'status'; +export type GroupByKey = 'none' | 'playlist' | 'year' | 'type'; + +interface SortButton { + key: SortKey; + label: string; +} + +const SORT_BUTTONS: SortButton[] = [ + { key: 'publishedAt', label: 'Date' }, + { key: 'title', label: 'Title' }, + { key: 'duration', label: 'Duration' }, + { key: 'fileSize', label: 'Size' }, + { key: 'status', label: 'Status' }, +]; + +const GROUP_BY_OPTIONS: { value: GroupByKey; label: string; youtubeOnly?: boolean }[] = [ + { value: 'none', label: 'No Grouping' }, + { value: 'playlist', label: 'Playlist', youtubeOnly: true }, + { value: 'year', label: 'Year' }, + { value: 'type', label: 'Type' }, +]; + +interface SortGroupBarProps { + sortKey: string | null; + sortDirection: 'asc' | 'desc'; + onSort: (key: string, direction: 'asc' | 'desc') => void; + groupBy: GroupByKey; + onGroupByChange: (groupBy: GroupByKey) => void; + isYouTube: boolean; +} + +export function SortGroupBar({ + sortKey, + sortDirection, + onSort, + groupBy, + onGroupByChange, + isYouTube, +}: SortGroupBarProps) { + const handleSortClick = (key: SortKey) => { + if (sortKey === key) { + // Toggle direction + onSort(key, sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + // New sort key — default to descending + onSort(key, 'desc'); + } + }; + + return ( +
+ {/* Sort label */} + + Sort + + + {/* Sort buttons */} + {SORT_BUTTONS.map((btn) => { + const isActive = sortKey === btn.key; + return ( + + ); + })} + + {/* Spacer */} +
+ + {/* Group by */} + + Group + + +
+ ); +} diff --git a/src/frontend/src/pages/ChannelDetail.tsx b/src/frontend/src/pages/ChannelDetail.tsx index ccb0c2e..13d66e6 100644 --- a/src/frontend/src/pages/ChannelDetail.tsx +++ b/src/frontend/src/pages/ChannelDetail.tsx @@ -36,6 +36,7 @@ import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton'; import { ContentCard } from '../components/ContentCard'; import { ContentListItem } from '../components/ContentListItem'; import { Pagination } from '../components/Pagination'; +import { SortGroupBar, type GroupByKey } from '../components/SortGroupBar'; import { Modal } from '../components/Modal'; import { useToast } from '../components/Toast'; import { useDownloadProgress } from '../contexts/DownloadProgressContext'; @@ -121,8 +122,23 @@ export function ChannelDetail() { const [contentSearch, setContentSearch] = useState(''); const [contentStatusFilter, setContentStatusFilter] = useState(''); const [contentTypeFilter, setContentTypeFilter] = useState(''); - const [sortKey, setSortKey] = useState(null); - const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + const [sortKey, setSortKey] = useState(() => { + try { return localStorage.getItem('tubearr-sort-key') || null; } catch { return null; } + }); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(() => { + try { + const stored = localStorage.getItem('tubearr-sort-dir'); + if (stored === 'asc' || stored === 'desc') return stored; + return 'asc'; + } catch { return 'asc'; } + }); + const [groupBy, setGroupBy] = useState(() => { + try { + const stored = localStorage.getItem('tubearr-group-by'); + if (stored === 'none' || stored === 'playlist' || stored === 'year' || stored === 'type') return stored; + return 'none'; + } catch { return 'none'; } + }); const [viewMode, setViewMode] = useState<'table' | 'card' | 'list'>(() => { try { const stored = localStorage.getItem('tubearr-content-view'); @@ -316,6 +332,13 @@ export function ChannelDetail() { setSortKey(key); setSortDirection(direction); setContentPage(1); + try { localStorage.setItem('tubearr-sort-key', key); } catch { /* ignore */ } + try { localStorage.setItem('tubearr-sort-dir', direction); } catch { /* ignore */ } + }, []); + + const handleGroupByChange = useCallback((value: GroupByKey) => { + setGroupBy(value); + try { localStorage.setItem('tubearr-group-by', value); } catch { /* ignore */ } }, []); const handleSetViewMode = useCallback((mode: 'table' | 'card' | 'list') => { @@ -1433,6 +1456,15 @@ export function ChannelDetail() {
+ {/* Sort & Group controls */} + {contentError ? (
Date: Fri, 3 Apr 2026 06:51:51 +0000 Subject: [PATCH 17/21] =?UTF-8?q?feat:=20Added=20groupedContent=20useMemo?= =?UTF-8?q?=20and=20renderGroupedContent=20to=20unify=20pl=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "src/frontend/src/pages/ChannelDetail.tsx" GSD-Task: S04/T02 --- src/frontend/src/pages/ChannelDetail.tsx | 88 +++++++++++++++++++++--- 1 file changed, 77 insertions(+), 11 deletions(-) diff --git a/src/frontend/src/pages/ChannelDetail.tsx b/src/frontend/src/pages/ChannelDetail.tsx index 13d66e6..13af27f 100644 --- a/src/frontend/src/pages/ChannelDetail.tsx +++ b/src/frontend/src/pages/ChannelDetail.tsx @@ -177,7 +177,7 @@ export function ChannelDetail() { const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showFullDescription, setShowFullDescription] = useState(false); const [scanInProgress, setScanInProgress] = useState(false); - const [expandedPlaylists, setExpandedPlaylists] = useState>(new Set()); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); const [selectedIds, setSelectedIds] = useState>(new Set()); const [localCheckInterval, setLocalCheckInterval] = useState(''); const [checkIntervalSaved, setCheckIntervalSaved] = useState(false); @@ -341,13 +341,18 @@ export function ChannelDetail() { try { localStorage.setItem('tubearr-group-by', value); } catch { /* ignore */ } }, []); + // Reset expanded groups when groupBy changes + useEffect(() => { + setExpandedGroups(new Set()); + }, [groupBy]); + const handleSetViewMode = useCallback((mode: 'table' | 'card' | 'list') => { setViewMode(mode); try { localStorage.setItem('tubearr-content-view', mode); } catch { /* ignore */ } }, []); - const togglePlaylist = useCallback((id: number | 'uncategorized') => { - setExpandedPlaylists((prev) => { + const toggleGroup = useCallback((id: string | number) => { + setExpandedGroups((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); @@ -450,6 +455,68 @@ export function ChannelDetail() { return groups.length > 0 ? groups : null; }, [channel, playlistData, content]); + // ── Unified group-by logic ── + + const groupedContent = useMemo<{ id: string | number; title: string; items: ContentItem[] }[] | null>(() => { + if (groupBy === 'none') return null; + + if (groupBy === 'playlist') { + // Delegate to existing playlist grouping for YouTube; null for others + return playlistGroups; + } + + if (groupBy === 'year') { + const yearMap = new Map(); + for (const item of content) { + const year = item.publishedAt ? new Date(item.publishedAt).getFullYear().toString() : 'Unknown'; + const arr = yearMap.get(year); + if (arr) { + arr.push(item); + } else { + yearMap.set(year, [item]); + } + } + // Sort groups by year descending, 'Unknown' last + const groups = Array.from(yearMap.entries()) + .sort((a, b) => { + if (a[0] === 'Unknown') return 1; + if (b[0] === 'Unknown') return -1; + return Number(b[0]) - Number(a[0]); + }) + .map(([year, items]) => ({ id: year, title: year, items })); + return groups.length > 0 ? groups : null; + } + + if (groupBy === 'type') { + const shorts: ContentItem[] = []; + const longform: ContentItem[] = []; + const livestream: ContentItem[] = []; + const audio: ContentItem[] = []; + + for (const item of content) { + if (item.contentType === 'livestream') { + livestream.push(item); + } else if (item.contentType === 'audio') { + audio.push(item); + } else if (item.contentType === 'video' && item.duration != null && item.duration <= 60) { + shorts.push(item); + } else { + // video with duration > 60, null duration, or any other video + longform.push(item); + } + } + + const groups: { id: string; title: string; items: ContentItem[] }[] = []; + if (shorts.length > 0) groups.push({ id: 'short', title: 'Short', items: shorts }); + if (longform.length > 0) groups.push({ id: 'longform', title: 'Longform', items: longform }); + if (livestream.length > 0) groups.push({ id: 'livestream', title: 'Livestream', items: livestream }); + if (audio.length > 0) groups.push({ id: 'audio', title: 'Audio', items: audio }); + return groups.length > 0 ? groups : null; + } + + return null; + }, [groupBy, content, playlistGroups]); + // ── Content table columns ── const contentColumns = useMemo[]>( @@ -742,15 +809,15 @@ export function ChannelDetail() { [selectedIds, toggleSelect, toggleMonitored, downloadContent], ); - const renderPlaylistGroups = useCallback( - (groups: { id: number | 'uncategorized'; title: string; items: ContentItem[] }[]) => ( + const renderGroupedContent = useCallback( + (groups: { id: string | number; title: string; items: ContentItem[] }[]) => (
{groups.map((group) => { - const isExpanded = expandedPlaylists.has(group.id); + const isExpanded = expandedGroups.has(group.id); return (
), - [expandedPlaylists, togglePlaylist, renderTable, renderCardGrid, renderListView, viewMode], + [expandedGroups, toggleGroup, renderTable, renderCardGrid, renderListView, viewMode], ); // ── Loading / Error states ── @@ -844,7 +911,6 @@ export function ChannelDetail() { } const isYouTube = channel.platform === 'youtube'; - const hasPlaylistGroups = isYouTube && playlistGroups !== null; return (
@@ -1498,8 +1564,8 @@ export function ChannelDetail() { ) : null} {contentLoading ? ( - ) : hasPlaylistGroups ? ( - renderPlaylistGroups(playlistGroups!) + ) : groupedContent ? ( + renderGroupedContent(groupedContent) ) : viewMode === 'card' ? ( renderCardGrid(content) ) : viewMode === 'list' ? ( From cdd1128632b39657a675bd8dfe63959e405a0b06 Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 07:03:12 +0000 Subject: [PATCH 18/21] =?UTF-8?q?feat:=20Added=20typed=20scan=20event=20pi?= =?UTF-8?q?peline=20=E2=80=94=20EventBus=20emits=20scan:started/ite?= =?UTF-8?q?=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "src/services/event-bus.ts" - "src/services/scheduler.ts" - "src/index.ts" - "src/server/routes/websocket.ts" GSD-Task: S05/T01 --- src/index.ts | 1 + src/server/routes/websocket.ts | 40 +++++++++++++++- src/services/event-bus.ts | 83 ++++++++++++++++++++++++++++------ src/services/scheduler.ts | 30 ++++++++++++ 4 files changed, 138 insertions(+), 16 deletions(-) diff --git a/src/index.ts b/src/index.ts index 854899a..598ac22 100644 --- a/src/index.ts +++ b/src/index.ts @@ -148,6 +148,7 @@ async function main(): Promise { ); }); }, + eventBus, }); // Attach scheduler to server so routes can notify it diff --git a/src/server/routes/websocket.ts b/src/server/routes/websocket.ts index d3fc0a9..77412d3 100644 --- a/src/server/routes/websocket.ts +++ b/src/server/routes/websocket.ts @@ -1,7 +1,16 @@ import websocket from '@fastify/websocket'; import type { FastifyInstance } from 'fastify'; import type { WebSocket } from 'ws'; -import type { DownloadEventBus, DownloadProgressPayload, DownloadCompletePayload, DownloadFailedPayload } from '../../services/event-bus'; +import type { + DownloadEventBus, + DownloadProgressPayload, + DownloadCompletePayload, + DownloadFailedPayload, + ScanStartedPayload, + ScanItemDiscoveredPayload, + ScanCompletePayload, + ScanErrorPayload, +} from '../../services/event-bus'; /** * WebSocket route plugin. @@ -39,16 +48,43 @@ export async function websocketRoutes( sendJson(socket, { type: 'download:failed', ...data }); }; - // Subscribe to event bus + // Subscribe to download events eventBus.onDownload('download:progress', onProgress); eventBus.onDownload('download:complete', onComplete); eventBus.onDownload('download:failed', onFailed); + // Create listeners for scan event types + const onScanStarted = (data: ScanStartedPayload) => { + sendJson(socket, { type: 'scan:started', ...data }); + }; + + const onScanItemDiscovered = (data: ScanItemDiscoveredPayload) => { + sendJson(socket, { type: 'scan:item-discovered', ...data }); + }; + + const onScanComplete = (data: ScanCompletePayload) => { + sendJson(socket, { type: 'scan:complete', ...data }); + }; + + const onScanError = (data: ScanErrorPayload) => { + sendJson(socket, { type: 'scan:error', ...data }); + }; + + // Subscribe to scan events + eventBus.onScan('scan:started', onScanStarted); + eventBus.onScan('scan:item-discovered', onScanItemDiscovered); + eventBus.onScan('scan:complete', onScanComplete); + eventBus.onScan('scan:error', onScanError); + // Cleanup on disconnect const cleanup = () => { eventBus.offDownload('download:progress', onProgress); eventBus.offDownload('download:complete', onComplete); eventBus.offDownload('download:failed', onFailed); + eventBus.offScan('scan:started', onScanStarted); + eventBus.offScan('scan:item-discovered', onScanItemDiscovered); + eventBus.offScan('scan:complete', onScanComplete); + eventBus.offScan('scan:error', onScanError); console.log('[websocket] client disconnected'); }; diff --git a/src/services/event-bus.ts b/src/services/event-bus.ts index e529738..ad77c55 100644 --- a/src/services/event-bus.ts +++ b/src/services/event-bus.ts @@ -1,6 +1,7 @@ import { EventEmitter } from 'node:events'; +import type { ContentItem } from '../types/index'; -// ── Event Payload Types ── +// ── Download Event Payload Types ── export interface DownloadProgressPayload { contentItemId: number; @@ -18,7 +19,33 @@ export interface DownloadFailedPayload { error: string; } -// ── Event Map ── +// ── Scan Event Payload Types ── + +export interface ScanStartedPayload { + channelId: number; + channelName: string; +} + +export interface ScanItemDiscoveredPayload { + channelId: number; + channelName: string; + item: ContentItem; +} + +export interface ScanCompletePayload { + channelId: number; + channelName: string; + newItems: number; + totalFetched: number; +} + +export interface ScanErrorPayload { + channelId: number; + channelName: string; + error: string; +} + +// ── Event Maps ── export interface DownloadEventMap { 'download:progress': [DownloadProgressPayload]; @@ -26,17 +53,23 @@ export interface DownloadEventMap { 'download:failed': [DownloadFailedPayload]; } +export interface ScanEventMap { + 'scan:started': [ScanStartedPayload]; + 'scan:item-discovered': [ScanItemDiscoveredPayload]; + 'scan:complete': [ScanCompletePayload]; + 'scan:error': [ScanErrorPayload]; +} + // ── Typed Event Bus ── /** - * Typed EventEmitter for download events. - * Decouples download progress producers (DownloadService) from + * Typed EventEmitter for download and scan events. + * Decouples event producers (DownloadService, SchedulerService) from * consumers (WebSocket route, logging, etc). */ -export class DownloadEventBus extends EventEmitter { - /** - * Emit a typed download event. - */ +export class EventBus extends EventEmitter { + // ── Download events ── + emitDownload( event: K, ...args: DownloadEventMap[K] @@ -44,9 +77,6 @@ export class DownloadEventBus extends EventEmitter { return this.emit(event, ...args); } - /** - * Subscribe to a typed download event. - */ onDownload( event: K, listener: (...args: DownloadEventMap[K]) => void @@ -54,13 +84,38 @@ export class DownloadEventBus extends EventEmitter { return this.on(event, listener as (...args: unknown[]) => void); } - /** - * Unsubscribe from a typed download event. - */ offDownload( event: K, listener: (...args: DownloadEventMap[K]) => void ): this { return this.off(event, listener as (...args: unknown[]) => void); } + + // ── Scan events ── + + emitScan( + event: K, + ...args: ScanEventMap[K] + ): boolean { + return this.emit(event, ...args); + } + + onScan( + event: K, + listener: (...args: ScanEventMap[K]) => void + ): this { + return this.on(event, listener as (...args: unknown[]) => void); + } + + offScan( + event: K, + listener: (...args: ScanEventMap[K]) => void + ): this { + return this.off(event, listener as (...args: unknown[]) => void); + } } + +/** @deprecated Use EventBus instead. */ +export const DownloadEventBus = EventBus; +/** @deprecated Use EventBus instead. */ +export type DownloadEventBus = EventBus; diff --git a/src/services/scheduler.ts b/src/services/scheduler.ts index 5949ec7..d117d6d 100644 --- a/src/services/scheduler.ts +++ b/src/services/scheduler.ts @@ -5,6 +5,7 @@ import type { Channel, Platform, PlatformContentMetadata } from '../types/index' import type { PlatformRegistry, FetchRecentContentOptions } from '../sources/platform-source'; import type { RateLimiter } from './rate-limiter'; import { YtDlpError } from '../sources/yt-dlp'; +import type { EventBus } from './event-bus'; import { getEnabledChannels, updateChannel, @@ -45,6 +46,8 @@ export interface CheckChannelResult { export interface SchedulerOptions { /** Called when a new content item is inserted — used to auto-enqueue for download. */ onNewContent?: (contentItemId: number) => void; + /** Event bus for broadcasting scan lifecycle events to WebSocket clients. */ + eventBus?: EventBus; } // ── Scheduler Service ── @@ -61,6 +64,7 @@ export class SchedulerService { private readonly platformRegistry: PlatformRegistry; private readonly rateLimiter: RateLimiter; private readonly onNewContent?: (contentItemId: number) => void; + private readonly eventBus?: EventBus; private readonly jobs = new Map(); private readonly channelCache = new Map(); private readonly activeChecks = new Set(); @@ -76,6 +80,7 @@ export class SchedulerService { this.platformRegistry = platformRegistry; this.rateLimiter = rateLimiter; this.onNewContent = options?.onNewContent; + this.eventBus = options?.eventBus; } /** @@ -166,6 +171,12 @@ export class SchedulerService { this.activeChecks.add(channel.id); + // Emit scan:started before any async work + this.eventBus?.emitScan('scan:started', { + channelId: channel.id, + channelName: channel.name, + }); + console.log( `[scheduler] Checking channel ${channel.id} ("${channel.name}") on ${channel.platform}` ); @@ -225,6 +236,12 @@ export class SchedulerService { }); if (created) { insertedCount++; + // Broadcast the new item to WebSocket clients + this.eventBus?.emitScan('scan:item-discovered', { + channelId: channel.id, + channelName: channel.name, + item: created, + }); // Only auto-enqueue monitored items if (this.onNewContent && created.monitored) { this.onNewContent(created.id); @@ -244,6 +261,13 @@ export class SchedulerService { `[scheduler] Check complete for channel ${channel.id}: ${insertedCount} new items (${items.length} fetched, ${existingIds.size} existing)` ); + this.eventBus?.emitScan('scan:complete', { + channelId: channel.id, + channelName: channel.name, + newItems: insertedCount, + totalFetched: items.length, + }); + return { channelId: channel.id, channelName: channel.name, @@ -272,6 +296,12 @@ export class SchedulerService { this.rateLimiter.reportError(channel.platform); + this.eventBus?.emitScan('scan:error', { + channelId: channel.id, + channelName: channel.name, + error: err instanceof Error ? err.message : String(err), + }); + console.error( `[scheduler] Check failed for channel ${channel.id} ("${channel.name}"): ${status}`, err instanceof Error ? err.message : err From cc50ed25e9b32e413b72e80ab28a3999010ecc34 Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 07:08:21 +0000 Subject: [PATCH 19/21] =?UTF-8?q?perf:=20Expanded=20DownloadProgressContex?= =?UTF-8?q?t=20with=20ScanStore=20to=20handle=20scan=20We=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "src/frontend/src/contexts/DownloadProgressContext.tsx" - "src/frontend/src/pages/ChannelDetail.tsx" GSD-Task: S05/T02 --- .../src/contexts/DownloadProgressContext.tsx | 182 +++++++++++++++++- src/frontend/src/pages/ChannelDetail.tsx | 31 +-- 2 files changed, 183 insertions(+), 30 deletions(-) diff --git a/src/frontend/src/contexts/DownloadProgressContext.tsx b/src/frontend/src/contexts/DownloadProgressContext.tsx index 5683b66..23aac8d 100644 --- a/src/frontend/src/contexts/DownloadProgressContext.tsx +++ b/src/frontend/src/contexts/DownloadProgressContext.tsx @@ -1,7 +1,10 @@ import { createContext, useContext, useCallback, useRef, type ReactNode } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; +import { useQueryClient, type QueryClient } from '@tanstack/react-query'; import { useSyncExternalStore } from 'react'; import { useWebSocket } from '../hooks/useWebSocket'; +import { contentKeys } from '../api/hooks/useContent'; +import type { ContentItem } from '@shared/types/index'; +import type { PaginatedResponse } from '@shared/types/api'; // ── Types ── @@ -32,7 +35,39 @@ interface DownloadFailedEvent { type DownloadEvent = DownloadProgressEvent | DownloadCompleteEvent | DownloadFailedEvent; -// ── Store (external to React for zero unnecessary re-renders) ── +// ── Scan Event Types ── + +interface ScanStartedEvent { + type: 'scan:started'; + channelId: number; + channelName: string; +} + +interface ScanItemDiscoveredEvent { + type: 'scan:item-discovered'; + channelId: number; + channelName: string; + item: ContentItem; +} + +interface ScanCompleteEvent { + type: 'scan:complete'; + channelId: number; + channelName: string; + newItems: number; + totalFetched: number; +} + +interface ScanErrorEvent { + type: 'scan:error'; + channelId: number; + channelName: string; + error: string; +} + +type ScanEvent = ScanStartedEvent | ScanItemDiscoveredEvent | ScanCompleteEvent | ScanErrorEvent; + +// ── Download Progress Store (external to React for zero unnecessary re-renders) ── class ProgressStore { private _map = new Map(); @@ -63,6 +98,58 @@ class ProgressStore { } } +// ── Scan Progress Store ── + +export interface ScanProgress { + scanning: boolean; + newItemCount: number; +} + +class ScanStore { + private _map = new Map(); + private _listeners = new Set<() => void>(); + + subscribe = (listener: () => void) => { + this._listeners.add(listener); + return () => this._listeners.delete(listener); + }; + + getSnapshot = () => this._map; + + startScan(channelId: number) { + this._map = new Map(this._map); + this._map.set(channelId, { scanning: true, newItemCount: 0 }); + this._notify(); + } + + incrementItems(channelId: number) { + this._map = new Map(this._map); + const current = this._map.get(channelId) ?? { scanning: true, newItemCount: 0 }; + this._map.set(channelId, { ...current, newItemCount: current.newItemCount + 1 }); + this._notify(); + } + + completeScan(channelId: number) { + this._map = new Map(this._map); + const current = this._map.get(channelId); + if (current) { + this._map.set(channelId, { scanning: false, newItemCount: current.newItemCount }); + } + this._notify(); + } + + clearScan(channelId: number) { + if (!this._map.has(channelId)) return; + this._map = new Map(this._map); + this._map.delete(channelId); + this._notify(); + } + + private _notify() { + for (const listener of this._listeners) listener(); + } +} + // ── Context ── interface DownloadProgressContextValue { @@ -70,6 +157,10 @@ interface DownloadProgressContextValue { getProgress: (contentItemId: number) => ProgressInfo | undefined; /** Whether the WebSocket is connected */ isConnected: boolean; + /** Subscribe to scan store changes */ + scanStoreSubscribe: (listener: () => void) => () => void; + /** Get scan store snapshot */ + scanStoreGetSnapshot: () => Map; } const DownloadProgressContext = createContext(null); @@ -80,13 +171,15 @@ export function DownloadProgressProvider({ children }: { children: ReactNode }) const queryClient = useQueryClient(); const storeRef = useRef(new ProgressStore()); const store = storeRef.current; + const scanStoreRef = useRef(new ScanStore()); + const scanStore = scanStoreRef.current; // Subscribe to the store with useSyncExternalStore for optimal re-renders const progressMap = useSyncExternalStore(store.subscribe, store.getSnapshot); const handleMessage = useCallback( (data: unknown) => { - const event = data as DownloadEvent; + const event = data as DownloadEvent | ScanEvent; if (!event?.type) return; switch (event.type) { @@ -111,9 +204,30 @@ export function DownloadProgressProvider({ children }: { children: ReactNode }) queryClient.invalidateQueries({ queryKey: ['content'] }); queryClient.invalidateQueries({ queryKey: ['queue'] }); break; + + case 'scan:started': + scanStore.startScan(event.channelId); + break; + + case 'scan:item-discovered': + scanStore.incrementItems(event.channelId); + injectContentItemIntoCache(queryClient, event.channelId, event.item); + break; + + case 'scan:complete': + scanStore.completeScan(event.channelId); + // Safety net: reconcile any missed items + queryClient.invalidateQueries({ + queryKey: contentKeys.byChannel(event.channelId), + }); + break; + + case 'scan:error': + scanStore.completeScan(event.channelId); + break; } }, - [store, queryClient], + [store, scanStore, queryClient], ); const { isConnected } = useWebSocket({ onMessage: handleMessage }); @@ -126,7 +240,14 @@ export function DownloadProgressProvider({ children }: { children: ReactNode }) ); return ( - + {children} ); @@ -153,3 +274,54 @@ export function useDownloadProgressConnection(): boolean { const context = useContext(DownloadProgressContext); return context?.isConnected ?? false; } + +// ── Scan Progress Hook ── + +/** + * Get scan progress for a specific channel. + * Returns `{ scanning, newItemCount }` from the scan store via useSyncExternalStore. + * Only re-renders components that use this hook when the scan store changes. + */ +export function useScanProgress(channelId: number): ScanProgress { + const context = useContext(DownloadProgressContext); + if (!context) { + throw new Error('useScanProgress must be used within a DownloadProgressProvider'); + } + const scanMap = useSyncExternalStore( + context.scanStoreSubscribe, + context.scanStoreGetSnapshot, + ); + return scanMap.get(channelId) ?? { scanning: false, newItemCount: 0 }; +} + +// ── Cache Injection Helper ── + +/** + * Inject a newly discovered content item into all matching TanStack Query caches + * for the given channel. Prepends the item to page 1 queries and increments pagination counts. + */ +function injectContentItemIntoCache( + queryClient: QueryClient, + channelId: number, + item: ContentItem, +) { + queryClient.setQueriesData>( + { queryKey: contentKeys.byChannel(channelId) }, + (oldData) => { + if (!oldData?.data) return oldData; + // Avoid duplicates + if (oldData.data.some((existing) => existing.id === item.id)) return oldData; + return { + ...oldData, + data: [item, ...oldData.data], + pagination: { + ...oldData.pagination, + totalItems: oldData.pagination.totalItems + 1, + totalPages: Math.ceil( + (oldData.pagination.totalItems + 1) / oldData.pagination.pageSize, + ), + }, + }; + }, + ); +} diff --git a/src/frontend/src/pages/ChannelDetail.tsx b/src/frontend/src/pages/ChannelDetail.tsx index 13af27f..3a47737 100644 --- a/src/frontend/src/pages/ChannelDetail.tsx +++ b/src/frontend/src/pages/ChannelDetail.tsx @@ -22,9 +22,8 @@ import { Trash2, Users, } from 'lucide-react'; -import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useSetMonitoringMode, useScanStatus } from '../api/hooks/useChannels'; +import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useSetMonitoringMode } from '../api/hooks/useChannels'; import { useChannelContentPaginated, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored, type ChannelContentFilters } from '../api/hooks/useContent'; -import { apiClient } from '../api/client'; import { useChannelPlaylists, useRefreshPlaylists } from '../api/hooks/usePlaylists'; import { useFormatProfiles } from '../api/hooks/useFormatProfiles'; import { Table, type Column } from '../components/Table'; @@ -39,7 +38,7 @@ import { Pagination } from '../components/Pagination'; import { SortGroupBar, type GroupByKey } from '../components/SortGroupBar'; import { Modal } from '../components/Modal'; import { useToast } from '../components/Toast'; -import { useDownloadProgress } from '../contexts/DownloadProgressContext'; +import { useDownloadProgress, useScanProgress } from '../contexts/DownloadProgressContext'; import type { ContentItem, MonitoringMode } from '@shared/types/index'; // ── Helpers ── @@ -173,10 +172,12 @@ export function ChannelDetail() { const refreshPlaylists = useRefreshPlaylists(channelId); const bulkMonitored = useBulkMonitored(channelId); + // ── Scan state (WebSocket-driven) ── + const { scanning: scanInProgress, newItemCount: _scanNewItemCount } = useScanProgress(channelId); + // ── Local state ── const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showFullDescription, setShowFullDescription] = useState(false); - const [scanInProgress, setScanInProgress] = useState(false); const [expandedGroups, setExpandedGroups] = useState>(new Set()); const [selectedIds, setSelectedIds] = useState>(new Set()); const [localCheckInterval, setLocalCheckInterval] = useState(''); @@ -208,26 +209,6 @@ export function ChannelDetail() { } }, [channel?.checkInterval]); - // Poll scan status when a scan is known to be in progress - useScanStatus(channelId, scanInProgress, () => { - setScanInProgress(false); - toast('Scan complete — content refreshed', 'success'); - }); - - // On mount, check if a scan is already running (e.g. auto-scan after channel creation) - useEffect(() => { - if (!channelId || channelId <= 0) return; - let cancelled = false; - apiClient.get<{ scanning: boolean }>(`/api/v1/channel/${channelId}/scan-status`) - .then((result) => { - if (!cancelled && result.scanning) { - setScanInProgress(true); - } - }) - .catch(() => { /* ignore — non-critical */ }); - return () => { cancelled = true; }; - }, [channelId]); - // Surface download errors via toast useEffect(() => { if (downloadContent.isError) { @@ -274,7 +255,7 @@ export function ChannelDetail() { scanChannel.mutate(undefined, { onSuccess: (result) => { if (result.status === 'already_running') { - setScanInProgress(true); + toast('Scan already in progress', 'info'); } else if (result.status === 'rate_limited') { toast('Rate limited — try again later', 'info'); } else if (result.status === 'error') { From c5820fe9574eb60cf56f301d238f7ef51ff6dce7 Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 07:14:45 +0000 Subject: [PATCH 20/21] =?UTF-8?q?feat:=20Added=20YtDlpStatusResponse/YtDlp?= =?UTF-8?q?UpdateResponse=20types,=20YTDLP=5FLAST=5FU=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "src/types/api.ts" - "src/db/repositories/system-config-repository.ts" - "src/server/routes/system.ts" GSD-Task: S06/T01 --- .../repositories/system-config-repository.ts | 1 + src/server/routes/system.ts | 45 ++++++++++++++++++- src/types/api.ts | 16 +++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/db/repositories/system-config-repository.ts b/src/db/repositories/system-config-repository.ts index d9276cc..4be9d8f 100644 --- a/src/db/repositories/system-config-repository.ts +++ b/src/db/repositories/system-config-repository.ts @@ -10,6 +10,7 @@ type Db = LibSQLDatabase; export const APP_CHECK_INTERVAL = 'app.check_interval'; export const APP_CONCURRENT_DOWNLOADS = 'app.concurrent_downloads'; +export const YTDLP_LAST_UPDATED = 'ytdlp.last_updated'; // ── Read / Write ── diff --git a/src/server/routes/system.ts b/src/server/routes/system.ts index 80ecbb9..7090e2d 100644 --- a/src/server/routes/system.ts +++ b/src/server/routes/system.ts @@ -5,15 +5,18 @@ import { fileURLToPath } from 'node:url'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; import { appConfig } from '../../config/index'; -import type { SystemStatusResponse, ApiKeyResponse, AppSettingsResponse } from '../../types/api'; +import type { SystemStatusResponse, ApiKeyResponse, AppSettingsResponse, YtDlpStatusResponse, YtDlpUpdateResponse } from '../../types/api'; import { systemConfig } from '../../db/schema/index'; import { API_KEY_DB_KEY } from '../middleware/auth'; import { getAppSettings, + getAppSetting, setAppSetting, APP_CHECK_INTERVAL, APP_CONCURRENT_DOWNLOADS, + YTDLP_LAST_UPDATED, } from '../../db/repositories/system-config-repository'; +import { getYtDlpVersion, updateYtDlp } from '../../sources/yt-dlp'; import os from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -179,4 +182,44 @@ export async function systemRoutes(fastify: FastifyInstance): Promise { return response; }); + + // ── yt-dlp Status & Update ── + + /** + * GET /api/v1/system/ytdlp/status — Current yt-dlp version and last-updated timestamp. + */ + fastify.get('/api/v1/system/ytdlp/status', async (_request, _reply) => { + const db = fastify.db; + const [version, lastUpdated] = await Promise.all([ + getYtDlpVersion(), + getAppSetting(db, YTDLP_LAST_UPDATED), + ]); + + const response: YtDlpStatusResponse = { version, lastUpdated }; + return response; + }); + + /** + * POST /api/v1/system/ytdlp/update — Trigger a yt-dlp update and persist the timestamp. + */ + fastify.post('/api/v1/system/ytdlp/update', async (request, _reply) => { + const db = fastify.db; + const result = await updateYtDlp(); + + const lastUpdated = new Date().toISOString(); + await setAppSetting(db, YTDLP_LAST_UPDATED, lastUpdated); + + request.log.info( + { updated: result.updated, version: result.version, previousVersion: result.previousVersion }, + '[system] yt-dlp update check completed' + ); + + const response: YtDlpUpdateResponse = { + updated: result.updated, + version: result.version, + previousVersion: result.previousVersion, + lastUpdated, + }; + return response; + }); } diff --git a/src/types/api.ts b/src/types/api.ts index 247f233..0ba6f94 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -80,3 +80,19 @@ export interface AppSettingsResponse { export type ChannelWithCounts = import('./index').Channel & { contentCounts: ContentCounts; }; + +// ── yt-dlp Status ── + +/** Response shape for GET /api/v1/system/ytdlp/status. */ +export interface YtDlpStatusResponse { + version: string | null; + lastUpdated: string | null; +} + +/** Response shape for POST /api/v1/system/ytdlp/update. */ +export interface YtDlpUpdateResponse { + updated: boolean; + version: string | null; + previousVersion: string | null; + lastUpdated: string; +} From 9f35d06e8840b851eb20bfdabd8a311fee8e83e9 Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 07:16:56 +0000 Subject: [PATCH 21/21] =?UTF-8?q?feat:=20Added=20useYtDlpStatus/useUpdateY?= =?UTF-8?q?tDlp=20hooks=20and=20yt-dlp=20card=20to=20Syst=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "src/frontend/src/api/hooks/useSystem.ts" - "src/frontend/src/pages/System.tsx" GSD-Task: S06/T02 --- src/frontend/src/api/hooks/useSystem.ts | 23 ++++- src/frontend/src/pages/System.tsx | 108 +++++++++++++++++++++++- 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/src/frontend/src/api/hooks/useSystem.ts b/src/frontend/src/api/hooks/useSystem.ts index eb1b01b..eb0ede2 100644 --- a/src/frontend/src/api/hooks/useSystem.ts +++ b/src/frontend/src/api/hooks/useSystem.ts @@ -1,6 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { apiClient } from '../client'; -import type { HealthResponse, SystemStatusResponse, ApiKeyResponse, AppSettingsResponse } from '@shared/types/api'; +import type { HealthResponse, SystemStatusResponse, ApiKeyResponse, AppSettingsResponse, YtDlpStatusResponse, YtDlpUpdateResponse } from '@shared/types/api'; // ── Query Keys ── @@ -9,6 +9,7 @@ export const systemKeys = { health: ['system', 'health'] as const, apiKey: ['system', 'apikey'] as const, appSettings: ['system', 'appSettings'] as const, + ytdlpStatus: ['system', 'ytdlpStatus'] as const, }; // ── Queries ── @@ -70,3 +71,23 @@ export function useUpdateAppSettings() { }, }); } + +/** Fetch yt-dlp version and last-updated timestamp. Auto-refreshes every 60s. */ +export function useYtDlpStatus() { + return useQuery({ + queryKey: systemKeys.ytdlpStatus, + queryFn: () => apiClient.get('/api/v1/system/ytdlp/status'), + refetchInterval: 60_000, + }); +} + +/** Trigger a yt-dlp update check. Invalidates the status query on success. */ +export function useUpdateYtDlp() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => apiClient.post('/api/v1/system/ytdlp/update'), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: systemKeys.ytdlpStatus }); + }, + }); +} diff --git a/src/frontend/src/pages/System.tsx b/src/frontend/src/pages/System.tsx index 4ae8e27..9a3f695 100644 --- a/src/frontend/src/pages/System.tsx +++ b/src/frontend/src/pages/System.tsx @@ -1,9 +1,11 @@ -import { RefreshCw, Server, Activity, Cpu, HardDrive } from 'lucide-react'; -import { useSystemStatus, useHealth } from '../api/hooks/useSystem'; +import { RefreshCw, Server, Activity, Cpu, HardDrive, Download, CheckCircle, AlertCircle, Loader2 } from 'lucide-react'; +import { useSystemStatus, useHealth, useYtDlpStatus, useUpdateYtDlp } from '../api/hooks/useSystem'; import { HealthStatus } from '../components/HealthStatus'; import { SkeletonSystem } from '../components/Skeleton'; import { formatBytes } from '../utils/format'; +import { useState } from 'react'; + // ── Helpers ── function formatUptime(seconds: number): string { @@ -23,6 +25,9 @@ function formatUptime(seconds: number): string { export function SystemPage() { const { data: health, isLoading: healthLoading, error: healthError, refetch: refetchHealth } = useHealth(); const { data: status, isLoading: statusLoading, error: statusError, refetch: refetchStatus } = useSystemStatus(); + const { data: ytdlpStatus, isLoading: ytdlpLoading, error: ytdlpError } = useYtDlpStatus(); + const updateYtDlp = useUpdateYtDlp(); + const [updateMessage, setUpdateMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const isLoading = healthLoading || statusLoading; @@ -84,6 +89,105 @@ export function SystemPage() { ) : null} + {/* ── yt-dlp section ── */} +
+
+

+ + yt-dlp +

+
+ +
+ {ytdlpLoading ? ( +

Loading yt-dlp status…

+ ) : ytdlpError ? ( +

+ Failed to load yt-dlp status: {ytdlpError instanceof Error ? ytdlpError.message : 'Unknown error'} +

+ ) : ( + <> +
+
+ Version +

+ {ytdlpStatus?.version ?? 'Unknown'} +

+
+
+ Last Updated +

+ {ytdlpStatus?.lastUpdated ? new Date(ytdlpStatus.lastUpdated).toLocaleString() : 'Never'} +

+
+
+ +
+ + + {updateMessage && ( + + {updateMessage.type === 'success' ? : } + {updateMessage.text} + + )} +
+ + )} +
+ +

+ Auto-refreshes every 60 seconds. +

+
+ {/* ── System Status section ── */}