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:
@@ -7,6 +7,8 @@
|
|||||||
- **Прямые файлы** (`.zip`, `.pdf`, `.mp4`, …) качаются через `httpx` по chunk'ам.
|
- **Прямые файлы** (`.zip`, `.pdf`, `.mp4`, …) качаются через `httpx` по chunk'ам.
|
||||||
- **Видео с сайтов** (YouTube и сотни других) — через `yt-dlp`.
|
- **Видео с сайтов** (YouTube и сотни других) — через `yt-dlp`.
|
||||||
- Прогресс течёт в UI по **SSE** в реальном времени.
|
- Прогресс течёт в UI по **SSE** в реальном времени.
|
||||||
|
- Две вкладки: **Активные** (что качается сейчас и завершилось за последний час)
|
||||||
|
и **История** (все завершённые загрузки).
|
||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
||||||
@@ -31,7 +33,22 @@ app/
|
|||||||
frontend/ SvelteKit SPA → собирается в frontend/build
|
frontend/ SvelteKit SPA → собирается в frontend/build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Запуск (прод, «для себя») — одна команда
|
## Запуск — готовые скрипты
|
||||||
|
|
||||||
|
В корне лежат скрипты-обёртки (Linux/macOS — `.sh`, Windows — `.bat`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Прод: зависимости -> сборка фронта (если её нет) -> сервер
|
||||||
|
./run.sh # Windows: run.bat
|
||||||
|
|
||||||
|
# Дев: бэкенд (:8000) + Vite dev-сервер (:5173) с hot-reload
|
||||||
|
./dev.sh # Windows: dev.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
`run` открывает <http://127.0.0.1:8000>, `dev` — <http://localhost:5173>.
|
||||||
|
Файлы складываются в `downloads/`.
|
||||||
|
|
||||||
|
## Запуск (прод, «для себя») — вручную
|
||||||
|
|
||||||
Фронт собирается в статику, FastAPI отдаёт её с того же origin:
|
Фронт собирается в статику, FastAPI отдаёт её с того же origin:
|
||||||
|
|
||||||
@@ -44,8 +61,6 @@ cd frontend && npm install && npm run build && cd ..
|
|||||||
uv run pvideodl
|
uv run pvideodl
|
||||||
```
|
```
|
||||||
|
|
||||||
Открыть <http://127.0.0.1:8000>. Файлы складываются в `downloads/`.
|
|
||||||
|
|
||||||
## Запуск (дев) — два процесса
|
## Запуск (дев) — два процесса
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+24
-3
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -17,6 +17,10 @@ import aiosqlite
|
|||||||
from app.models import Download, DownloadStatus
|
from app.models import Download, DownloadStatus
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
class Storage(ABC):
|
class Storage(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def init(self) -> None: ...
|
async def init(self) -> None: ...
|
||||||
@@ -52,6 +56,7 @@ _COLUMNS = (
|
|||||||
"eta",
|
"eta",
|
||||||
"error",
|
"error",
|
||||||
"created_at",
|
"created_at",
|
||||||
|
"finished_at",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -59,6 +64,8 @@ def _row_to_download(row: aiosqlite.Row) -> Download:
|
|||||||
data = dict(row)
|
data = dict(row)
|
||||||
data["status"] = DownloadStatus(data["status"])
|
data["status"] = DownloadStatus(data["status"])
|
||||||
data["created_at"] = datetime.fromisoformat(data["created_at"])
|
data["created_at"] = datetime.fromisoformat(data["created_at"])
|
||||||
|
if data.get("finished_at"):
|
||||||
|
data["finished_at"] = datetime.fromisoformat(data["finished_at"])
|
||||||
return Download.model_validate(data)
|
return Download.model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
@@ -98,22 +105,36 @@ class SqliteStorage(Storage):
|
|||||||
speed REAL,
|
speed REAL,
|
||||||
eta REAL,
|
eta REAL,
|
||||||
error TEXT,
|
error TEXT,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL,
|
||||||
|
finished_at TEXT
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
# Миграция старых БД, созданных до появления finished_at.
|
||||||
|
await self._migrate_add_column("finished_at", "TEXT")
|
||||||
# На старте всё, что осталось "качающимся" после прошлого запуска,
|
# На старте всё, что осталось "качающимся" после прошлого запуска,
|
||||||
# помечаем упавшим — воркеры этого процесса о них не знают.
|
# помечаем упавшим — воркеры этого процесса о них не знают.
|
||||||
await self._db.execute(
|
await self._db.execute(
|
||||||
"UPDATE downloads SET status = ?, error = ? WHERE status = ?",
|
"UPDATE downloads SET status = ?, error = ?, finished_at = ? "
|
||||||
|
"WHERE status = ?",
|
||||||
(
|
(
|
||||||
DownloadStatus.FAILED.value,
|
DownloadStatus.FAILED.value,
|
||||||
"Прервано при перезапуске приложения",
|
"Прервано при перезапуске приложения",
|
||||||
|
_now().isoformat(),
|
||||||
DownloadStatus.DOWNLOADING.value,
|
DownloadStatus.DOWNLOADING.value,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
await self._db.commit()
|
await self._db.commit()
|
||||||
|
|
||||||
|
async def _migrate_add_column(self, name: str, decl: str) -> None:
|
||||||
|
"""Добавить колонку, если её ещё нет (idempotent-миграция старых БД)."""
|
||||||
|
async with self._conn.execute("PRAGMA table_info(downloads)") as cursor:
|
||||||
|
cols = {row["name"] for row in await cursor.fetchall()}
|
||||||
|
if name not in cols:
|
||||||
|
await self._conn.execute(
|
||||||
|
f"ALTER TABLE downloads ADD COLUMN {name} {decl}"
|
||||||
|
)
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
if self._db is not None:
|
if self._db is not None:
|
||||||
await self._db.close()
|
await self._db.close()
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class Download(BaseModel):
|
|||||||
eta: float | None = None # секунд до конца, оценка
|
eta: float | None = None # секунд до конца, оценка
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
created_at: datetime = Field(default_factory=_now)
|
created_at: datetime = Field(default_factory=_now)
|
||||||
|
finished_at: datetime | None = None # момент перехода в done/failed
|
||||||
|
|
||||||
|
|
||||||
class CreateDownloads(BaseModel):
|
class CreateDownloads(BaseModel):
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import logging
|
|||||||
from app.core.events import EventBus
|
from app.core.events import EventBus
|
||||||
from app.core.queue import DownloadQueue
|
from app.core.queue import DownloadQueue
|
||||||
from app.core.storage import Storage
|
from app.core.storage import Storage
|
||||||
from app.models import Download, DownloadEvent, DownloadStatus
|
from app.models import Download, DownloadEvent, DownloadStatus, _now
|
||||||
from app.services.downloader import Progress, pick_downloader
|
from app.services.downloader import Progress, pick_downloader
|
||||||
|
|
||||||
logger = logging.getLogger("pvideodl.worker")
|
logger = logging.getLogger("pvideodl.worker")
|
||||||
@@ -89,7 +89,12 @@ class WorkerPool:
|
|||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.warning("Ошибка скачивания %s: %s", download.url, exc)
|
logger.warning("Ошибка скачивания %s: %s", download.url, exc)
|
||||||
failed = await self._storage.update(
|
failed = await self._storage.update(
|
||||||
download_id, status=DownloadStatus.FAILED, error=str(exc), speed=None, eta=None
|
download_id,
|
||||||
|
status=DownloadStatus.FAILED,
|
||||||
|
error=str(exc),
|
||||||
|
speed=None,
|
||||||
|
eta=None,
|
||||||
|
finished_at=_now(),
|
||||||
)
|
)
|
||||||
await self._publish("failed", failed)
|
await self._publish("failed", failed)
|
||||||
return
|
return
|
||||||
@@ -104,6 +109,7 @@ class WorkerPool:
|
|||||||
speed=None,
|
speed=None,
|
||||||
eta=0,
|
eta=0,
|
||||||
error=None,
|
error=None,
|
||||||
|
finished_at=_now(),
|
||||||
)
|
)
|
||||||
await self._publish("done", done)
|
await self._publish("done", done)
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
@echo off
|
@echo off
|
||||||
rem Дев-режим: бэкенд (:8000) в отдельном окне + Vite dev-сервер (:5173).
|
rem Dev mode: backend (:8000) in a new window + Vite dev server (:5173).
|
||||||
rem Откройте http://localhost:5173 — Vite проксирует /api на бэкенд.
|
rem Open http://localhost:5173 - Vite proxies /api to the backend.
|
||||||
chcp 65001 >nul
|
|
||||||
setlocal
|
setlocal
|
||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
|
|
||||||
call uv sync || exit /b 1
|
echo [1/2] uv sync
|
||||||
if not exist "frontend\node_modules" (
|
call uv sync
|
||||||
pushd frontend
|
if errorlevel 1 exit /b 1
|
||||||
call npm install || exit /b 1
|
|
||||||
popd
|
|
||||||
)
|
|
||||||
|
|
||||||
echo ==^> Бэкенд ^(:8000^) — отдельное окно
|
if exist "frontend\node_modules" goto dev
|
||||||
|
pushd frontend
|
||||||
|
call npm install
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
popd
|
||||||
|
|
||||||
|
:dev
|
||||||
|
echo [2/2] starting backend (:8000) in a new window, then Vite dev (:5173)
|
||||||
start "PVideoDl backend" cmd /k "uv run python -m app.main"
|
start "PVideoDl backend" cmd /k "uv run python -m app.main"
|
||||||
|
|
||||||
echo ==^> Vite dev ^(:5173^) — http://localhost:5173
|
|
||||||
cd frontend
|
cd frontend
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|||||||
@@ -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.
|
// Карта загрузок id -> Download. Карта, а не массив — точечные апдейты по id из SSE.
|
||||||
const downloadsMap = writable<Map<string, Download>>(new Map());
|
const downloadsMap = writable<Map<string, Download>>(new Map());
|
||||||
|
|
||||||
// Отсортированный список для рендера (новые сверху).
|
// Завершённую (done/failed) загрузку держим на главной как «недавнюю» столько времени.
|
||||||
export const downloads = derived(downloadsMap, ($m) =>
|
const RECENT_FINISHED_MS = 60 * 60 * 1000; // 1 час
|
||||||
[...$m.values()].sort((a, b) => b.created_at.localeCompare(a.created_at))
|
|
||||||
|
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 {
|
export function setDownloads(items: Download[]): void {
|
||||||
downloadsMap.set(new Map(items.map((d) => [d.id, d])));
|
downloadsMap.set(new Map(items.map((d) => [d.id, d])));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface Download {
|
|||||||
eta: number | null; // секунд
|
eta: number | null; // секунд
|
||||||
error: string | null;
|
error: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
finished_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DownloadEventType = 'created' | 'progress' | 'done' | 'failed' | 'deleted';
|
export type DownloadEventType = 'created' | 'progress' | 'done' | 'failed' | 'deleted';
|
||||||
|
|||||||
@@ -1,6 +1,85 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
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();
|
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>
|
</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">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { Inbox } from 'lucide-svelte';
|
||||||
import { Inbox, Wifi, WifiOff } from 'lucide-svelte';
|
import { activeDownloads, hydrated } from '$lib/stores';
|
||||||
import * as api from '$lib/api';
|
import { addDownloads, deleteDownload, retryDownload } from '$lib/actions';
|
||||||
import {
|
|
||||||
downloads,
|
|
||||||
connected,
|
|
||||||
setDownloads,
|
|
||||||
removeLocal,
|
|
||||||
pushToast
|
|
||||||
} from '$lib/stores';
|
|
||||||
import { connectSSE, disconnectSSE } from '$lib/sse';
|
|
||||||
import type { Download } from '$lib/types';
|
|
||||||
import AddLinksForm from '$lib/components/AddLinksForm.svelte';
|
import AddLinksForm from '$lib/components/AddLinksForm.svelte';
|
||||||
import DownloadCard from '$lib/components/DownloadCard.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>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>PVideoDl — скачивалка</title></svelte:head>
|
<svelte:head><title>PVideoDl — скачивалка</title></svelte:head>
|
||||||
|
|
||||||
<Toaster />
|
<AddLinksForm onSubmit={addDownloads} />
|
||||||
|
|
||||||
<main class="mx-auto max-w-3xl px-4 py-10">
|
<section class="mt-8">
|
||||||
<header class="mb-8 flex items-end justify-between">
|
{#if !$hydrated}
|
||||||
<div>
|
<p class="py-12 text-center text-sm text-zinc-500">Загрузка…</p>
|
||||||
<h1 class="text-2xl font-semibold tracking-tight text-zinc-50">PVideoDl</h1>
|
{:else if $activeDownloads.length === 0}
|
||||||
<p class="mt-1 text-sm text-zinc-500">Локальная скачивалка файлов и видео</p>
|
<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>
|
</div>
|
||||||
<span
|
{:else}
|
||||||
class="inline-flex items-center gap-1.5 text-xs {$connected
|
<div class="flex flex-col gap-3">
|
||||||
? 'text-emerald-400'
|
{#each $activeDownloads as download (download.id)}
|
||||||
: 'text-zinc-500'}"
|
<DownloadCard {download} onDelete={deleteDownload} onRetry={retryDownload} />
|
||||||
title={$connected ? 'Поток обновлений активен' : 'Нет соединения с сервером'}
|
{/each}
|
||||||
>
|
</div>
|
||||||
{#if $connected}<Wifi class="size-3.5" />онлайн{:else}<WifiOff
|
{/if}
|
||||||
class="size-3.5"
|
</section>
|
||||||
/>оффлайн{/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>
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,22 +1,23 @@
|
|||||||
@echo off
|
@echo off
|
||||||
rem Прод-запуск одной командой: зависимости -> сборка фронта (если нужна) -> сервер.
|
rem Production launch: install deps, build frontend if needed, run the server.
|
||||||
rem Файлы складываются в downloads\, UI на http://127.0.0.1:8000
|
rem Downloaded files go to downloads\ - UI at http://127.0.0.1:8000
|
||||||
chcp 65001 >nul
|
|
||||||
setlocal
|
setlocal
|
||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
|
|
||||||
echo ==^> uv sync
|
echo [1/3] uv sync
|
||||||
call uv sync || exit /b 1
|
call uv sync
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
if not exist "frontend\build" (
|
if exist "frontend\build" goto serve
|
||||||
echo ==^> Сборка фронтенда ^(frontend\build отсутствует^)
|
|
||||||
pushd frontend
|
|
||||||
call npm install || exit /b 1
|
|
||||||
call npm run build || exit /b 1
|
|
||||||
popd
|
|
||||||
) else (
|
|
||||||
echo ==^> Фронтенд уже собран — пропускаю сборку ^(удалите frontend\build для пересборки^)
|
|
||||||
)
|
|
||||||
|
|
||||||
echo ==^> Запуск PVideoDl на http://127.0.0.1:8000
|
echo [2/3] building frontend (frontend\build missing)
|
||||||
|
pushd frontend
|
||||||
|
call npm install
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
call npm run build
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
popd
|
||||||
|
|
||||||
|
:serve
|
||||||
|
echo [3/3] starting PVideoDl at http://127.0.0.1:8000
|
||||||
uv run pvideodl
|
uv run pvideodl
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import tempfile
|
import tempfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.core.storage import SqliteStorage
|
from app.core.storage import SqliteStorage
|
||||||
@@ -57,6 +59,45 @@ async def test_delete(storage: SqliteStorage):
|
|||||||
assert await storage.get(d.id) is None
|
assert await storage.get(d.id) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_finished_at_roundtrip(storage: SqliteStorage):
|
||||||
|
d = Download(url="https://example.com/a.zip")
|
||||||
|
await storage.create(d)
|
||||||
|
assert (await storage.get(d.id)).finished_at is None
|
||||||
|
|
||||||
|
ts = datetime.now(timezone.utc)
|
||||||
|
snap = await storage.update(d.id, status=DownloadStatus.DONE, finished_at=ts)
|
||||||
|
assert snap is not None and snap.finished_at == ts
|
||||||
|
|
||||||
|
|
||||||
|
async def test_migrates_legacy_db_without_finished_at():
|
||||||
|
# БД, созданная до появления колонки finished_at.
|
||||||
|
path = Path(tempfile.mkdtemp()) / "legacy.db"
|
||||||
|
db = await aiosqlite.connect(path)
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE downloads (
|
||||||
|
id TEXT PRIMARY KEY, url TEXT NOT NULL, filename TEXT,
|
||||||
|
status TEXT NOT NULL, progress REAL NOT NULL DEFAULT 0,
|
||||||
|
size_bytes INTEGER, downloaded_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
speed REAL, eta REAL, error TEXT, created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO downloads (id, url, status, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
("x1", "https://e.com/a.zip", "done", datetime.now(timezone.utc).isoformat()),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
st = SqliteStorage(path)
|
||||||
|
await st.init() # должен добавить колонку, не уронив существующие данные
|
||||||
|
got = await st.get("x1")
|
||||||
|
assert got is not None and got.status == DownloadStatus.DONE
|
||||||
|
assert got.finished_at is None
|
||||||
|
await st.close()
|
||||||
|
|
||||||
|
|
||||||
async def test_downloading_marked_failed_on_init():
|
async def test_downloading_marked_failed_on_init():
|
||||||
path = Path(tempfile.mkdtemp()) / "test.db"
|
path = Path(tempfile.mkdtemp()) / "test.db"
|
||||||
st = SqliteStorage(path)
|
st = SqliteStorage(path)
|
||||||
@@ -69,4 +110,5 @@ async def test_downloading_marked_failed_on_init():
|
|||||||
await st2.init() # должен пометить "качающиеся" записи как FAILED
|
await st2.init() # должен пометить "качающиеся" записи как FAILED
|
||||||
got = await st2.get(d.id)
|
got = await st2.get(d.id)
|
||||||
assert got is not None and got.status == DownloadStatus.FAILED
|
assert got is not None and got.status == DownloadStatus.FAILED
|
||||||
|
assert got.finished_at is not None # прерванная загрузка тоже «завершилась»
|
||||||
await st2.close()
|
await st2.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user