Compare commits
2
Commits
3281fa2a5d
...
3e47e95fc4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e47e95fc4 | ||
|
|
303de2b26c |
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
// Карта загрузок 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>
|
||||||
@@ -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
|
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