Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e47e95fc4 | ||
|
|
303de2b26c |
@@ -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
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user