diff --git a/README.md b/README.md index c71c9c7..b7a127a 100644 --- a/README.md +++ b/README.md @@ -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` открывает , `dev` — . +Файлы складываются в `downloads/`. + +## Запуск (прод, «для себя») — вручную Фронт собирается в статику, FastAPI отдаёт её с того же origin: @@ -44,8 +61,6 @@ cd frontend && npm install && npm run build && cd .. uv run pvideodl ``` -Открыть . Файлы складываются в `downloads/`. - ## Запуск (дев) — два процесса ```bash diff --git a/app/core/storage.py b/app/core/storage.py index 599da3c..94f994c 100644 --- a/app/core/storage.py +++ b/app/core/storage.py @@ -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() diff --git a/app/models.py b/app/models.py index a1bf148..09f3d28 100644 --- a/app/models.py +++ b/app/models.py @@ -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): diff --git a/app/services/worker.py b/app/services/worker.py index 3672c5d..07c8aff 100644 --- a/app/services/worker.py +++ b/app/services/worker.py @@ -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) diff --git a/dev.bat b/dev.bat index 6065bd6..6e5d101 100644 --- a/dev.bat +++ b/dev.bat @@ -1,20 +1,21 @@ @echo off -rem Дев-режим: бэкенд (:8000) в отдельном окне + Vite dev-сервер (:5173). -rem Откройте http://localhost:5173 — Vite проксирует /api на бэкенд. -chcp 65001 >nul +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" -call uv sync || exit /b 1 -if not exist "frontend\node_modules" ( - pushd frontend - call npm install || exit /b 1 - popd -) +echo [1/2] uv sync +call uv sync +if errorlevel 1 exit /b 1 -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" - -echo ==^> Vite dev ^(:5173^) — http://localhost:5173 cd frontend npm run dev diff --git a/frontend/src/lib/actions.ts b/frontend/src/lib/actions.ts new file mode 100644 index 0000000..611b25c --- /dev/null +++ b/frontend/src/lib/actions.ts @@ -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 { + 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 { + removeLocal(id); // оптимистично убираем из UI + try { + await api.deleteDownload(id); + } catch (e) { + pushToast('error', `Не удалось удалить: ${(e as Error).message}`); + } +} + +export async function retryDownload(d: Download): Promise { + // Ретрая на бэкенде пока нет — пересоздаём задачу с тем же URL. + await deleteDownload(d.id); + await addDownloads([d.url]); +} diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index 2dfc429..f18aebd 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -5,11 +5,48 @@ import type { Download, DownloadEvent } from './types'; // Карта загрузок id -> Download. Карта, а не массив — точечные апдейты по id из SSE. const downloadsMap = writable>(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]))); } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 7ea1f1a..e2d3dbb 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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'; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 9b776b7..1e41336 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,6 +1,85 @@ -{@render children()} + + +
+
+
+

PVideoDl

+

Локальная скачивалка файлов и видео

+
+ + {#if $connected}онлайн{:else}оффлайн{/if} + +
+ + + + {@render children()} +
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index e41058e..d769bd0 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,96 +1,28 @@ PVideoDl — скачивалка - + -
-
-
-

PVideoDl

-

Локальная скачивалка файлов и видео

+
+ {#if !$hydrated} +

Загрузка…

+ {:else if $activeDownloads.length === 0} +
+ +

Сейчас ничего не качается. Вставьте ссылки выше и нажмите «Скачать».

- - {#if $connected}онлайн{:else}оффлайн{/if} - -
- - - -
- {#if loading} -

Загрузка…

- {:else if $downloads.length === 0} -
- -

Пока пусто. Вставьте ссылки выше и нажмите «Скачать».

-
- {:else} -
- {#each $downloads as download (download.id)} - - {/each} -
- {/if} -
-
+ {:else} +
+ {#each $activeDownloads as download (download.id)} + + {/each} +
+ {/if} + diff --git a/frontend/src/routes/history/+page.svelte b/frontend/src/routes/history/+page.svelte new file mode 100644 index 0000000..5025d8d --- /dev/null +++ b/frontend/src/routes/history/+page.svelte @@ -0,0 +1,25 @@ + + +PVideoDl — история + +
+ {#if !$hydrated} +

Загрузка…

+ {:else if $historyDownloads.length === 0} +
+ +

История пуста — здесь появятся завершённые загрузки.

+
+ {:else} +
+ {#each $historyDownloads as download (download.id)} + + {/each} +
+ {/if} +
diff --git a/run.bat b/run.bat index 8539035..850a8ac 100644 --- a/run.bat +++ b/run.bat @@ -1,22 +1,23 @@ @echo off -rem Прод-запуск одной командой: зависимости -> сборка фронта (если нужна) -> сервер. -rem Файлы складываются в downloads\, UI на http://127.0.0.1:8000 -chcp 65001 >nul +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 ==^> uv sync -call uv sync || exit /b 1 +echo [1/3] uv sync +call uv sync +if errorlevel 1 exit /b 1 -if not exist "frontend\build" ( - echo ==^> Сборка фронтенда ^(frontend\build отсутствует^) - pushd frontend - call npm install || exit /b 1 - call npm run build || exit /b 1 - popd -) else ( - echo ==^> Фронтенд уже собран — пропускаю сборку ^(удалите frontend\build для пересборки^) -) +if exist "frontend\build" goto serve -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 diff --git a/tests/test_storage.py b/tests/test_storage.py index 4720c4f..bcd359d 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -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() diff --git a/Запуск b/Запуск new file mode 100644 index 0000000..e69de29