Enhance PVideoDl with finished_at tracking for downloads and UI updates. Added finished_at field to Download model, updated storage to handle legacy databases, and improved frontend to display recent downloads. Refactored scripts for better clarity and error handling.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
// Действия над загрузками, общие для всех вкладок: добавление, удаление, ретрай.
|
||||
// Обёртки над api + сторами + тостами — чтобы страницы не дублировали логику.
|
||||
import * as api from './api';
|
||||
import { removeLocal, pushToast } from './stores';
|
||||
import type { Download } from './types';
|
||||
|
||||
export async function addDownloads(urls: string[]): Promise<void> {
|
||||
try {
|
||||
const created = await api.addDownloads(urls);
|
||||
pushToast('info', `Добавлено в очередь: ${created.length}`);
|
||||
} catch (e) {
|
||||
pushToast('error', (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteDownload(id: string): Promise<void> {
|
||||
removeLocal(id); // оптимистично убираем из UI
|
||||
try {
|
||||
await api.deleteDownload(id);
|
||||
} catch (e) {
|
||||
pushToast('error', `Не удалось удалить: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function retryDownload(d: Download): Promise<void> {
|
||||
// Ретрая на бэкенде пока нет — пересоздаём задачу с тем же URL.
|
||||
await deleteDownload(d.id);
|
||||
await addDownloads([d.url]);
|
||||
}
|
||||
@@ -5,11 +5,48 @@ import type { Download, DownloadEvent } from './types';
|
||||
// Карта загрузок id -> Download. Карта, а не массив — точечные апдейты по id из SSE.
|
||||
const downloadsMap = writable<Map<string, Download>>(new Map());
|
||||
|
||||
// Отсортированный список для рендера (новые сверху).
|
||||
export const downloads = derived(downloadsMap, ($m) =>
|
||||
[...$m.values()].sort((a, b) => b.created_at.localeCompare(a.created_at))
|
||||
// Завершённую (done/failed) загрузку держим на главной как «недавнюю» столько времени.
|
||||
const RECENT_FINISHED_MS = 60 * 60 * 1000; // 1 час
|
||||
|
||||
const FINISHED = new Set(['done', 'failed']);
|
||||
|
||||
// «Часы» для derived-сторов: тикают, чтобы недавно завершённые сами уходили
|
||||
// с главной по истечении окна, даже без новых SSE-событий.
|
||||
export const now = writable(Date.now());
|
||||
|
||||
/** Запустить тиканье часов. Возвращает функцию остановки (для onMount cleanup). */
|
||||
export function startClock(): () => void {
|
||||
const id = setInterval(() => now.set(Date.now()), 30_000);
|
||||
return () => clearInterval(id);
|
||||
}
|
||||
|
||||
function isRecentlyFinished(d: Download, nowMs: number): boolean {
|
||||
if (!d.finished_at) return false;
|
||||
return nowMs - Date.parse(d.finished_at) < RECENT_FINISHED_MS;
|
||||
}
|
||||
|
||||
// Главная: то, что качается сейчас (или в очереди), плюс недавно завершённое.
|
||||
export const activeDownloads = derived([downloadsMap, now], ([$m, $now]) =>
|
||||
[...$m.values()]
|
||||
.filter((d) => !FINISHED.has(d.status) || isRecentlyFinished(d, $now))
|
||||
.sort((a, b) => b.created_at.localeCompare(a.created_at))
|
||||
);
|
||||
|
||||
// История: всё завершённое (done/failed), свежее — сверху.
|
||||
export const historyDownloads = derived(downloadsMap, ($m) =>
|
||||
[...$m.values()]
|
||||
.filter((d) => FINISHED.has(d.status))
|
||||
.sort((a, b) =>
|
||||
(b.finished_at ?? b.created_at).localeCompare(a.finished_at ?? a.created_at)
|
||||
)
|
||||
);
|
||||
|
||||
export const activeCount = derived(activeDownloads, ($d) => $d.length);
|
||||
export const historyCount = derived(historyDownloads, ($d) => $d.length);
|
||||
|
||||
// Первичная гидрация завершена — чтобы отличать «грузим список» от «список пуст».
|
||||
export const hydrated = writable(false);
|
||||
|
||||
export function setDownloads(items: Download[]): void {
|
||||
downloadsMap.set(new Map(items.map((d) => [d.id, d])));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface Download {
|
||||
eta: number | null; // секунд
|
||||
error: string | null;
|
||||
created_at: string;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
export type DownloadEventType = 'created' | 'progress' | 'done' | 'failed' | 'deleted';
|
||||
|
||||
@@ -1,6 +1,85 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { DownloadCloud, History, Wifi, WifiOff } from 'lucide-svelte';
|
||||
import * as api from '$lib/api';
|
||||
import {
|
||||
setDownloads,
|
||||
pushToast,
|
||||
connected,
|
||||
activeCount,
|
||||
historyCount,
|
||||
hydrated,
|
||||
startClock
|
||||
} from '$lib/stores';
|
||||
import { connectSSE, disconnectSSE } from '$lib/sse';
|
||||
import Toaster from '$lib/components/Toaster.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(() => {
|
||||
// Гидрация: текущий список + подписка на живые обновления + часы.
|
||||
api
|
||||
.listDownloads()
|
||||
.then(setDownloads)
|
||||
.catch((e) => pushToast('error', `Не удалось загрузить список: ${e.message}`))
|
||||
.finally(() => hydrated.set(true));
|
||||
connectSSE();
|
||||
const stopClock = startClock();
|
||||
return () => {
|
||||
disconnectSSE();
|
||||
stopClock();
|
||||
};
|
||||
});
|
||||
|
||||
const tabs = [
|
||||
{ href: '/', label: 'Активные', icon: DownloadCloud },
|
||||
{ href: '/history', label: 'История', icon: History }
|
||||
];
|
||||
const path = $derived($page.url.pathname);
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
<Toaster />
|
||||
|
||||
<main class="mx-auto max-w-3xl px-4 py-10">
|
||||
<header class="mb-6 flex items-end justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-zinc-50">PVideoDl</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500">Локальная скачивалка файлов и видео</p>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-xs {$connected
|
||||
? 'text-emerald-400'
|
||||
: 'text-zinc-500'}"
|
||||
title={$connected ? 'Поток обновлений активен' : 'Нет соединения с сервером'}
|
||||
>
|
||||
{#if $connected}<Wifi class="size-3.5" />онлайн{:else}<WifiOff
|
||||
class="size-3.5"
|
||||
/>оффлайн{/if}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<nav class="mb-8 flex gap-1 border-b border-zinc-800">
|
||||
{#each tabs as tab (tab.href)}
|
||||
{@const active = path === tab.href}
|
||||
{@const count = tab.href === '/' ? $activeCount : $historyCount}
|
||||
<a
|
||||
href={tab.href}
|
||||
class="-mb-px inline-flex items-center gap-2 border-b-2 px-3 py-2.5 text-sm font-medium transition-colors {active
|
||||
? 'border-indigo-500 text-zinc-100'
|
||||
: 'border-transparent text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
<tab.icon class="size-4" />
|
||||
{tab.label}
|
||||
{#if $hydrated && count > 0}
|
||||
<span class="rounded-full bg-zinc-800 px-1.5 py-0.5 text-xs font-normal text-zinc-400"
|
||||
>{count}</span
|
||||
>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
@@ -1,96 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Inbox, Wifi, WifiOff } from 'lucide-svelte';
|
||||
import * as api from '$lib/api';
|
||||
import {
|
||||
downloads,
|
||||
connected,
|
||||
setDownloads,
|
||||
removeLocal,
|
||||
pushToast
|
||||
} from '$lib/stores';
|
||||
import { connectSSE, disconnectSSE } from '$lib/sse';
|
||||
import type { Download } from '$lib/types';
|
||||
import { Inbox } from 'lucide-svelte';
|
||||
import { activeDownloads, hydrated } from '$lib/stores';
|
||||
import { addDownloads, deleteDownload, retryDownload } from '$lib/actions';
|
||||
import AddLinksForm from '$lib/components/AddLinksForm.svelte';
|
||||
import DownloadCard from '$lib/components/DownloadCard.svelte';
|
||||
import Toaster from '$lib/components/Toaster.svelte';
|
||||
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(() => {
|
||||
// Гидрация: текущий список + подписка на живые обновления.
|
||||
api
|
||||
.listDownloads()
|
||||
.then(setDownloads)
|
||||
.catch((e) => pushToast('error', `Не удалось загрузить список: ${e.message}`))
|
||||
.finally(() => (loading = false));
|
||||
connectSSE();
|
||||
return disconnectSSE;
|
||||
});
|
||||
|
||||
async function handleAdd(urls: string[]) {
|
||||
try {
|
||||
const created = await api.addDownloads(urls);
|
||||
pushToast('info', `Добавлено в очередь: ${created.length}`);
|
||||
} catch (e) {
|
||||
pushToast('error', (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
removeLocal(id); // оптимистично убираем из UI
|
||||
try {
|
||||
await api.deleteDownload(id);
|
||||
} catch (e) {
|
||||
pushToast('error', `Не удалось удалить: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRetry(d: Download) {
|
||||
// Ретрая на бэкенде пока нет — пересоздаём задачу с тем же URL.
|
||||
await handleDelete(d.id);
|
||||
await handleAdd([d.url]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>PVideoDl — скачивалка</title></svelte:head>
|
||||
|
||||
<Toaster />
|
||||
<AddLinksForm onSubmit={addDownloads} />
|
||||
|
||||
<main class="mx-auto max-w-3xl px-4 py-10">
|
||||
<header class="mb-8 flex items-end justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-zinc-50">PVideoDl</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500">Локальная скачивалка файлов и видео</p>
|
||||
<section class="mt-8">
|
||||
{#if !$hydrated}
|
||||
<p class="py-12 text-center text-sm text-zinc-500">Загрузка…</p>
|
||||
{:else if $activeDownloads.length === 0}
|
||||
<div class="flex flex-col items-center gap-3 py-16 text-center text-zinc-500">
|
||||
<Inbox class="size-10 opacity-40" />
|
||||
<p class="text-sm">Сейчас ничего не качается. Вставьте ссылки выше и нажмите «Скачать».</p>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-xs {$connected
|
||||
? 'text-emerald-400'
|
||||
: 'text-zinc-500'}"
|
||||
title={$connected ? 'Поток обновлений активен' : 'Нет соединения с сервером'}
|
||||
>
|
||||
{#if $connected}<Wifi class="size-3.5" />онлайн{:else}<WifiOff
|
||||
class="size-3.5"
|
||||
/>оффлайн{/if}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<AddLinksForm onSubmit={handleAdd} />
|
||||
|
||||
<section class="mt-8">
|
||||
{#if loading}
|
||||
<p class="py-12 text-center text-sm text-zinc-500">Загрузка…</p>
|
||||
{:else if $downloads.length === 0}
|
||||
<div class="flex flex-col items-center gap-3 py-16 text-center text-zinc-500">
|
||||
<Inbox class="size-10 opacity-40" />
|
||||
<p class="text-sm">Пока пусто. Вставьте ссылки выше и нажмите «Скачать».</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each $downloads as download (download.id)}
|
||||
<DownloadCard {download} onDelete={handleDelete} onRetry={handleRetry} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</main>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each $activeDownloads as download (download.id)}
|
||||
<DownloadCard {download} onDelete={deleteDownload} onRetry={retryDownload} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { History } from 'lucide-svelte';
|
||||
import { historyDownloads, hydrated } from '$lib/stores';
|
||||
import { deleteDownload, retryDownload } from '$lib/actions';
|
||||
import DownloadCard from '$lib/components/DownloadCard.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>PVideoDl — история</title></svelte:head>
|
||||
|
||||
<section>
|
||||
{#if !$hydrated}
|
||||
<p class="py-12 text-center text-sm text-zinc-500">Загрузка…</p>
|
||||
{:else if $historyDownloads.length === 0}
|
||||
<div class="flex flex-col items-center gap-3 py-16 text-center text-zinc-500">
|
||||
<History class="size-10 opacity-40" />
|
||||
<p class="text-sm">История пуста — здесь появятся завершённые загрузки.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each $historyDownloads as download (download.id)}
|
||||
<DownloadCard {download} onDelete={deleteDownload} onRetry={retryDownload} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
Reference in New Issue
Block a user