Compare commits

..
2 Commits
16 changed files with 366 additions and 99 deletions
+18 -3
View File
@@ -7,6 +7,8 @@
- **Прямые файлы** (`.zip`, `.pdf`, `.mp4`, …) качаются через `httpx` по chunk'ам.
- **Видео с сайтов** (YouTube и сотни других) — через `yt-dlp`.
- Прогресс течёт в UI по **SSE** в реальном времени.
- Две вкладки: **Активные** (что качается сейчас и завершилось за последний час)
и **История** (все завершённые загрузки).
## Стек
@@ -31,7 +33,22 @@ app/
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:
@@ -44,8 +61,6 @@ cd frontend && npm install && npm run build && cd ..
uv run pvideodl
```
Открыть <http://127.0.0.1:8000>. Файлы складываются в `downloads/`.
## Запуск (дев) — два процесса
```bash
+24 -3
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
import json
from abc import ABC, abstractmethod
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -17,6 +17,10 @@ import aiosqlite
from app.models import Download, DownloadStatus
def _now() -> datetime:
return datetime.now(timezone.utc)
class Storage(ABC):
@abstractmethod
async def init(self) -> None: ...
@@ -52,6 +56,7 @@ _COLUMNS = (
"eta",
"error",
"created_at",
"finished_at",
)
@@ -59,6 +64,8 @@ def _row_to_download(row: aiosqlite.Row) -> Download:
data = dict(row)
data["status"] = DownloadStatus(data["status"])
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)
@@ -98,22 +105,36 @@ class SqliteStorage(Storage):
speed REAL,
eta REAL,
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(
"UPDATE downloads SET status = ?, error = ? WHERE status = ?",
"UPDATE downloads SET status = ?, error = ?, finished_at = ? "
"WHERE status = ?",
(
DownloadStatus.FAILED.value,
"Прервано при перезапуске приложения",
_now().isoformat(),
DownloadStatus.DOWNLOADING.value,
),
)
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:
if self._db is not None:
await self._db.close()
+1
View File
@@ -37,6 +37,7 @@ class Download(BaseModel):
eta: float | None = None # секунд до конца, оценка
error: str | None = None
created_at: datetime = Field(default_factory=_now)
finished_at: datetime | None = None # момент перехода в done/failed
class CreateDownloads(BaseModel):
+8 -2
View File
@@ -13,7 +13,7 @@ import logging
from app.core.events import EventBus
from app.core.queue import DownloadQueue
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
logger = logging.getLogger("pvideodl.worker")
@@ -89,7 +89,12 @@ class WorkerPool:
except Exception as exc: # noqa: BLE001
logger.warning("Ошибка скачивания %s: %s", download.url, exc)
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)
return
@@ -104,6 +109,7 @@ class WorkerPool:
speed=None,
eta=0,
error=None,
finished_at=_now(),
)
await self._publish("done", done)
+21
View File
@@ -0,0 +1,21 @@
@echo off
rem Dev mode: backend (:8000) in a new window + Vite dev server (:5173).
rem Open http://localhost:5173 - Vite proxies /api to the backend.
setlocal
cd /d "%~dp0"
echo [1/2] uv sync
call uv sync
if errorlevel 1 exit /b 1
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"
cd frontend
npm run dev
Executable
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Дев-режим: бэкенд (:8000) + Vite dev-сервер (:5173) с hot-reload фронта.
# Откройте http://localhost:5173 — Vite проксирует /api на бэкенд.
set -euo pipefail
cd "$(dirname "$0")"
uv sync
[ -d frontend/node_modules ] || (cd frontend && npm install)
echo "==> Бэкенд → http://127.0.0.1:8000 (в фоне)"
uv run python -m app.main &
backend_pid=$!
trap 'kill "$backend_pid" 2>/dev/null' EXIT INT TERM
echo "==> Vite dev → http://localhost:5173"
cd frontend
npm run dev
+29
View File
@@ -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]);
}
+40 -3
View File
@@ -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])));
}
+1
View File
@@ -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';
+80 -1
View File
@@ -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>
+19 -87
View File
@@ -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>
+25
View File
@@ -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>
+23
View File
@@ -0,0 +1,23 @@
@echo off
rem Production launch: install deps, build frontend if needed, run the server.
rem Downloaded files go to downloads\ - UI at http://127.0.0.1:8000
setlocal
cd /d "%~dp0"
echo [1/3] uv sync
call uv sync
if errorlevel 1 exit /b 1
if exist "frontend\build" goto serve
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
Executable
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Прод-запуск одной командой: зависимости -> сборка фронта (если нужна) -> сервер.
# Файлы складываются в downloads/, UI на http://127.0.0.1:8000
set -euo pipefail
cd "$(dirname "$0")"
echo "==> uv sync"
uv sync
if [ ! -d frontend/build ]; then
echo "==> Сборка фронтенда (frontend/build отсутствует)"
(cd frontend && npm install && npm run build)
else
echo "==> Фронтенд уже собран — пропускаю сборку (удалите frontend/build для пересборки)"
fi
echo "==> Запуск PVideoDl на http://127.0.0.1:8000"
exec uv run pvideodl
+42
View File
@@ -1,6 +1,8 @@
import tempfile
from datetime import datetime, timezone
from pathlib import Path
import aiosqlite
import pytest
from app.core.storage import SqliteStorage
@@ -57,6 +59,45 @@ async def test_delete(storage: SqliteStorage):
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():
path = Path(tempfile.mkdtemp()) / "test.db"
st = SqliteStorage(path)
@@ -69,4 +110,5 @@ async def test_downloading_marked_failed_on_init():
await st2.init() # должен пометить "качающиеся" записи как FAILED
got = await st2.get(d.id)
assert got is not None and got.status == DownloadStatus.FAILED
assert got.finished_at is not None # прерванная загрузка тоже «завершилась»
await st2.close()
View File