Merge github/master — resolve Queue.tsx import conflict (keep both Skeleton + DownloadProgressBar)
This commit is contained in:
commit
c016916706
4 changed files with 109 additions and 8 deletions
|
|
@ -0,0 +1,49 @@
|
||||||
|
# Phase 01: Wire Up WebSocket Download Progress End-to-End
|
||||||
|
|
||||||
|
The backend event bus, WebSocket route, progress parser, and frontend context/hook/component all exist but are not connected in the UI. This phase wires everything together so users see real-time download progress in the Queue page and Channel Detail page. By the end, downloading items will show a live progress bar with percentage, speed, and ETA — completing the WIP feature from commit 0541a5f.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] Wrap the app in DownloadProgressProvider:
|
||||||
|
- Read `src/frontend/src/App.tsx` and `src/frontend/src/contexts/DownloadProgressContext.tsx` to understand current structure
|
||||||
|
- In `App.tsx`, import `DownloadProgressProvider` from `../contexts/DownloadProgressContext`
|
||||||
|
- Wrap the `<AuthenticatedLayout />` route (or the `<Routes>` in `App()`) with `<DownloadProgressProvider>` so all pages can access download progress
|
||||||
|
- Ensure the provider is inside the existing `QueryClientProvider` (check `main.tsx` for provider ordering)
|
||||||
|
- **Note:** Already wired in `main.tsx` (lines 25-29) from commit 0541a5f. Provider wraps entire app inside QueryClientProvider. No changes needed.
|
||||||
|
|
||||||
|
- [x] Integrate DownloadProgressBar into the Queue page for actively downloading items:
|
||||||
|
- Read `src/frontend/src/pages/Queue.tsx` and `src/frontend/src/components/DownloadProgressBar.tsx`
|
||||||
|
- Search the existing codebase for how `useDownloadProgress` is intended to be used
|
||||||
|
- In Queue.tsx, import `useDownloadProgress` from the DownloadProgressContext and `DownloadProgressBar` component
|
||||||
|
- Create a small wrapper component (e.g., `QueueItemProgress`) that calls `useDownloadProgress(contentItemId)` and renders `<DownloadProgressBar>` when progress exists, or falls back to the existing `<StatusBadge>` when no active progress
|
||||||
|
- Update the `status` column render in the Queue table to use this wrapper for items with status `downloading`
|
||||||
|
- **Note:** Already wired from prior commits. `QueueItemProgress` component (lines 36-44) uses `useDownloadProgress(item.contentItemId)` and renders `<DownloadProgressBar>` for active downloads, falling back to `<StatusBadge>`. Status column at line 94 uses this wrapper. Frontend builds clean, all 606 tests pass.
|
||||||
|
|
||||||
|
- [x] Integrate download progress into the Channel Detail page:
|
||||||
|
- Read `src/frontend/src/pages/ChannelDetail.tsx` to understand how content items are displayed
|
||||||
|
- Search for how content items render their status in this page
|
||||||
|
- For content items with status `downloading`, show the `DownloadProgressBar` alongside or instead of the static status badge
|
||||||
|
- Use the same `useDownloadProgress` hook pattern established in the Queue page
|
||||||
|
- **Note:** Already wired in commit 0541a5f. `ContentStatusCell` component (lines 38-46) uses `useDownloadProgress(item.id)` and renders `<DownloadProgressBar>` for active downloads, falling back to `<StatusBadge>`. Used in status column at line 560.
|
||||||
|
|
||||||
|
- [x] Add a WebSocket connection status indicator to the Sidebar or app header:
|
||||||
|
- Read `src/frontend/src/components/Sidebar.tsx`
|
||||||
|
- Import `useDownloadProgressConnection` from the DownloadProgressContext
|
||||||
|
- Add a small visual indicator (e.g., a colored dot) near the bottom of the sidebar that shows green when WebSocket is connected and grey/red when disconnected
|
||||||
|
- Use existing CSS variables (`--success` for connected, `--text-muted` for disconnected)
|
||||||
|
- **Done:** Added connection status indicator at bottom of sidebar with green/grey dot and "Connected"/"Disconnected" label. Collapses to just the dot when sidebar is collapsed. Uses `useDownloadProgressConnection` hook.
|
||||||
|
|
||||||
|
- [x] Verify the backend emits progress events during streaming downloads:
|
||||||
|
- Read `src/services/download.ts` to confirm the `spawnDownload` method emits `download:progress` events via the event bus
|
||||||
|
- Read `src/server/routes/websocket.ts` to confirm the WebSocket route subscribes to the event bus and broadcasts to clients
|
||||||
|
- Read `src/server/index.ts` to confirm the event bus is passed to both the WebSocket route plugin and the server builder
|
||||||
|
- If any wiring is missing between `buildServer()` and the WebSocket route registration, fix it
|
||||||
|
- Verify the `--newline` and `--progress` flags are added to yt-dlp args in `spawnDownload` (they should already be there)
|
||||||
|
- **Verified:** All wiring is correct. Single `DownloadEventBus` instance created in `src/index.ts:61` is shared between `buildServer` (→ WebSocket route) and `DownloadService`. `spawnDownload` adds `--newline`/`--progress` flags, parses progress lines, and emits all three event types. WebSocket route subscribes and broadcasts to clients. All 13 related tests pass.
|
||||||
|
|
||||||
|
- [x] Invalidate relevant queries on WebSocket events for immediate UI freshness:
|
||||||
|
- Read the `DownloadProgressContext.tsx` — it already invalidates `content` and `queue` query keys on `download:complete` and `download:failed`
|
||||||
|
- Read `src/frontend/src/api/hooks/useQueue.ts` and `src/frontend/src/api/hooks/useContent.ts` to verify they use matching query keys
|
||||||
|
- Also invalidate `activity` and `channels` query keys on complete/failed events so the Activity page and channel content counts update without manual refresh
|
||||||
|
- Add `library` query key invalidation on complete events if the library hook uses a separate query key
|
||||||
|
- **Done:** Added `activity`, `channels`, and `library` query key invalidations to both `download:complete` and `download:failed` handlers in `DownloadProgressContext.tsx`. Verified query keys match: `useActivity` uses `['activity']`, `useChannels` uses `['channels']`, `useLibraryContent` uses `['library']`. Frontend builds clean, all 40 backend tests pass.
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { TubearrLogo } from './TubearrLogo';
|
import { TubearrLogo } from './TubearrLogo';
|
||||||
|
import { useDownloadProgressConnection } from '../contexts/DownloadProgressContext';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ to: '/', icon: Radio, label: 'Channels' },
|
{ to: '/', icon: Radio, label: 'Channels' },
|
||||||
|
|
@ -22,6 +23,7 @@ const NAV_ITEMS = [
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
|
const wsConnected = useDownloadProgressConnection();
|
||||||
const [collapsed, setCollapsed] = useState(() => {
|
const [collapsed, setCollapsed] = useState(() => {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem('tubearr-sidebar-collapsed') === 'true';
|
return localStorage.getItem('tubearr-sidebar-collapsed') === 'true';
|
||||||
|
|
@ -126,6 +128,41 @@ export function Sidebar() {
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* WebSocket connection status */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: collapsed ? 'var(--space-3)' : 'var(--space-3) var(--space-4)',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||||
|
}}
|
||||||
|
title={wsConnected ? 'WebSocket connected' : 'WebSocket disconnected'}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: wsConnected ? 'var(--success)' : 'var(--text-muted)',
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'background-color var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!collapsed && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{wsConnected ? 'Connected' : 'Disconnected'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -193,9 +193,12 @@ export function DownloadProgressProvider({ children }: { children: ReactNode })
|
||||||
|
|
||||||
case 'download:complete':
|
case 'download:complete':
|
||||||
store.delete(event.contentItemId);
|
store.delete(event.contentItemId);
|
||||||
// Invalidate content queries so the UI refreshes with updated status
|
// Invalidate queries so the UI refreshes with updated status
|
||||||
queryClient.invalidateQueries({ queryKey: ['content'] });
|
queryClient.invalidateQueries({ queryKey: ['content'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['activity'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['channels'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['library'] });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'download:failed':
|
case 'download:failed':
|
||||||
|
|
@ -203,6 +206,9 @@ export function DownloadProgressProvider({ children }: { children: ReactNode })
|
||||||
// Invalidate to show updated status (failed)
|
// Invalidate to show updated status (failed)
|
||||||
queryClient.invalidateQueries({ queryKey: ['content'] });
|
queryClient.invalidateQueries({ queryKey: ['content'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['activity'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['channels'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['library'] });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'scan:started':
|
case 'scan:started':
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { ListOrdered, RotateCcw, X, RefreshCw } from 'lucide-react';
|
||||||
import { Table, type Column } from '../components/Table';
|
import { Table, type Column } from '../components/Table';
|
||||||
import { StatusBadge } from '../components/StatusBadge';
|
import { StatusBadge } from '../components/StatusBadge';
|
||||||
import { SkeletonQueueList } from '../components/Skeleton';
|
import { SkeletonQueueList } from '../components/Skeleton';
|
||||||
|
import { DownloadProgressBar } from '../components/DownloadProgressBar';
|
||||||
|
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
|
||||||
import { useQueue, useRetryQueueItem, useCancelQueueItem } from '../api/hooks/useQueue';
|
import { useQueue, useRetryQueueItem, useCancelQueueItem } from '../api/hooks/useQueue';
|
||||||
import type { QueueItem, QueueStatus } from '@shared/types/index';
|
import type { QueueItem, QueueStatus } from '@shared/types/index';
|
||||||
|
|
||||||
|
|
@ -30,6 +32,18 @@ const STATUS_TABS: { value: QueueStatus | ''; label: string }[] = [
|
||||||
{ value: 'failed', label: 'Failed' },
|
{ value: 'failed', label: 'Failed' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ── Queue item progress wrapper ──
|
||||||
|
|
||||||
|
function QueueItemProgress({ item }: { item: QueueItem }) {
|
||||||
|
const progress = useDownloadProgress(item.contentItemId);
|
||||||
|
|
||||||
|
if (item.status === 'downloading' && progress) {
|
||||||
|
return <DownloadProgressBar progress={progress} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <StatusBadge status={item.status} pulse={item.status === 'downloading'} />;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Component ──
|
// ── Component ──
|
||||||
|
|
||||||
export function Queue() {
|
export function Queue() {
|
||||||
|
|
@ -76,14 +90,9 @@ export function Queue() {
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
width: '130px',
|
width: '160px',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (item) => (
|
render: (item) => <QueueItemProgress item={item} />,
|
||||||
<StatusBadge
|
|
||||||
status={item.status}
|
|
||||||
pulse={item.status === 'downloading'}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'priority',
|
key: 'priority',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue