Implement Phase 6: MeTube download integration

- MeTubeClient encapsulates all MeTube HTTP/Socket.IO calls (verified against
  the real MeTube source: /add returns no job id, GET /history gives a queue
  snapshot for reconciliation, filenames arrive already relative, percent is
  a 0-100 float)
- download_jobs table + service: request/dedup active downloads, apply live
  Socket.IO events (added/updated/completed/canceled/cleared) matched by
  canonical YouTube URL, safe relative-path -> public media URL construction
- Reconciliation on startup against MeTube's live queue/done state (section 19):
  non-terminal jobs recovered where possible, else marked "unknown"; already
  completed jobs are left untouched
- POST/GET /api/videos/{id}/download(-status), recheck-local; feed/video
  detail now report real local availability instead of a stub
- Frontend: download button with live status polling (queued/downloading %/
  postprocessing/completed/failed+retry), local <video> playback with
  YouTube fallback on playback error
- health.py now delegates to MeTubeClient (single place for MeTube calls)

26 new backend tests (63 total). Verified live: Socket.IO connects
successfully to the real MeTube instance on deploy.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
vrubelroman 2026-09-16 18:56:17 +00:00
parent 0ed20bb838
commit fe16c08daa
27 changed files with 1125 additions and 48 deletions

View file

@ -37,6 +37,7 @@
- MeTube-специфичные HTTP/Socket.IO вызовы должны быть инкапсулированы в отдельный класс `MeTubeClient` (раздел 36 ТЗ) — не размазывать по backend.
- Секреты только через `.env` (см. `.env.example`), никогда не коммитить `.env`, refresh token, client secret.
- Backend тесты — `pytest`, лежат в `tests/` в корне. Integration-тесты против Google/MeTube — mocked по умолчанию; реальные вызовы к `http://192.168.8.177:8081` — только opt-in, не в обычном CI.
- **В unit-тестах не использовать `with TestClient(app) as client:`** — это запускает lifespan приложения, который с Phase 6 реально стучится в MeTube (Socket.IO) и в Postgres (`reconcile_on_startup`). Используй `TestClient(app)` без `with` (lifespan не запускается, дефолтное поведение starlette) — так и сделано во всех текущих тестах.
## Запуск/проверка

View file

@ -10,6 +10,7 @@ from app.db import get_db
from app.models.channel import Channel
from app.models.channel_category import channel_categories
from app.models.video import Video
from app.services.download_jobs import latest_jobs_map
from app.services.video_presentation import channel_categories_map, serialize_video
router = APIRouter(dependencies=[Depends(require_session)])
@ -76,12 +77,15 @@ def get_feed(
channel_ids = list({v.channel_id for v in rows})
channels = {c.id: c for c in db.query(Channel).filter(Channel.id.in_(channel_ids)).all()}
categories_map = channel_categories_map(db, channel_ids)
jobs_map = latest_jobs_map(db, [v.id for v in rows])
items = []
for video in rows:
channel = channels.get(video.channel_id)
if channel is None:
continue
items.append(serialize_video(video, channel, categories_map.get(channel.id, [])))
items.append(
serialize_video(video, channel, categories_map.get(channel.id, []), jobs_map.get(video.id))
)
return {"items": items, "next_cursor": next_cursor}

View file

@ -1,12 +1,11 @@
import logging
import httpx
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.config import settings
from app.db import get_db
from app.services.metube_client import MeTubeClient
logger = logging.getLogger(__name__)
@ -22,21 +21,10 @@ def _check_database(db: Session) -> str:
return "error"
def _check_metube() -> str:
try:
response = httpx.get(settings.metube_api_base_url, timeout=5)
if response.status_code < 500:
return "ok"
return "error"
except Exception:
logger.warning("MeTube healthcheck failed", exc_info=True)
return "error"
@router.get("/health")
def health(db: Session = Depends(get_db)) -> dict:
return {
"status": "ok",
"database": _check_database(db),
"metube": _check_metube(),
"metube": "ok" if MeTubeClient().health() else "error",
}

View file

@ -1,24 +1,89 @@
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.auth_dependency import require_session
from app.db import get_db
from app.models.channel import Channel
from app.models.download_job import DownloadJob
from app.models.video import Video
from app.services.download_jobs import MeTubeRejected, get_latest_job, request_download
from app.services.metube_client import MeTubeClient
from app.services.video_presentation import channel_categories_map, serialize_video
logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(require_session)])
@router.get("/videos/{youtube_video_id}")
def get_video(youtube_video_id: str, db: Session = Depends(get_db)) -> dict:
def _get_video_or_404(db: Session, youtube_video_id: str) -> Video:
video = db.query(Video).filter(Video.youtube_video_id == youtube_video_id).one_or_none()
if video is None:
raise HTTPException(status_code=404, detail="Video not found")
return video
def _serialize_job(job: DownloadJob) -> dict:
return {
"status": job.status,
"progress_percent": job.progress_percent,
"media_url": job.media_url if job.status == "completed" else None,
"error_message": job.error_message,
"requested_at": job.requested_at,
"started_at": job.started_at,
"completed_at": job.completed_at,
}
@router.get("/videos/{youtube_video_id}")
def get_video(youtube_video_id: str, db: Session = Depends(get_db)) -> dict:
video = _get_video_or_404(db, youtube_video_id)
channel = db.get(Channel, video.channel_id)
if channel is None:
raise HTTPException(status_code=404, detail="Channel not found")
categories = channel_categories_map(db, [channel.id]).get(channel.id, [])
return serialize_video(video, channel, categories)
job = get_latest_job(db, video.id)
return serialize_video(video, channel, categories, job)
@router.post("/videos/{youtube_video_id}/download")
def download_video(youtube_video_id: str, db: Session = Depends(get_db)) -> dict:
video = _get_video_or_404(db, youtube_video_id)
try:
job = request_download(db, video)
except MeTubeRejected as exc:
raise HTTPException(status_code=502, detail=f"MeTube rejected the download: {exc}")
except Exception:
logger.exception("Failed to enqueue download for %s", youtube_video_id)
raise HTTPException(status_code=502, detail="MeTube is unavailable")
return _serialize_job(job)
@router.get("/videos/{youtube_video_id}/download-status")
def download_status(youtube_video_id: str, db: Session = Depends(get_db)) -> dict:
video = _get_video_or_404(db, youtube_video_id)
job = get_latest_job(db, video.id)
if job is None:
return {"status": "not_downloaded", "progress_percent": None, "media_url": None, "error_message": None}
return _serialize_job(job)
@router.post("/videos/{youtube_video_id}/recheck-local")
def recheck_local(youtube_video_id: str, db: Session = Depends(get_db)) -> dict:
video = _get_video_or_404(db, youtube_video_id)
job = get_latest_job(db, video.id)
if job is None or job.status != "completed":
raise HTTPException(status_code=400, detail="No completed download to recheck")
if job.media_url and MeTubeClient().check_media(job.media_url):
return _serialize_job(job)
job.status = "unknown"
job.media_url = None
db.commit()
return _serialize_job(job)

View file

@ -1,3 +1,4 @@
import asyncio
import logging
from contextlib import asynccontextmanager
from pathlib import Path
@ -29,18 +30,48 @@ from app.api.health import router as health_router
from app.api.sync import router as sync_router
from app.api.videos import router as videos_router
from app.config import settings
from app.db import SessionLocal
from app.services import download_jobs
from app.services.metube_client import MeTubeClient
from app.services.scheduler import create_scheduler
logging.basicConfig(level=settings.log_level)
logger = logging.getLogger(__name__)
async def _on_metube_event(event_name: str, payload) -> None:
db = SessionLocal()
try:
await download_jobs.handle_metube_event(db, event_name, payload)
except Exception:
logger.exception("Failed handling MeTube '%s' event", event_name)
finally:
db.close()
def _reconcile_download_jobs() -> None:
db = SessionLocal()
try:
download_jobs.reconcile_on_startup(db)
except Exception:
logger.exception("Failed to reconcile download jobs on startup")
finally:
db.close()
@asynccontextmanager
async def lifespan(_: FastAPI):
logger.info("Application startup")
_reconcile_download_jobs()
scheduler = create_scheduler()
scheduler.start()
metube_task = asyncio.create_task(MeTubeClient().run_event_listener(_on_metube_event))
yield
metube_task.cancel()
scheduler.shutdown(wait=False)
logger.info("Application shutdown")

View file

@ -2,7 +2,16 @@ from app.models.app_settings import AppSetting
from app.models.category import Category
from app.models.channel import Channel
from app.models.channel_category import channel_categories
from app.models.download_job import DownloadJob
from app.models.oauth_credentials import OAuthCredentials
from app.models.video import Video
__all__ = ["AppSetting", "Category", "Channel", "channel_categories", "OAuthCredentials", "Video"]
__all__ = [
"AppSetting",
"Category",
"Channel",
"channel_categories",
"DownloadJob",
"OAuthCredentials",
"Video",
]

View file

@ -0,0 +1,31 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
# queued -> downloading -> postprocessing -> completed
# -> failed
# unknown: state could not be reconciled after a restart
ACTIVE_STATUSES = ("queued", "downloading", "postprocessing")
TERMINAL_STATUSES = ("completed", "failed")
class DownloadJob(Base):
__tablename__ = "download_jobs"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
video_id: Mapped[int] = mapped_column(ForeignKey("videos.id"), nullable=False, index=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="queued")
metube_job_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
metube_filename: Mapped[str | None] = mapped_column(String, nullable=True)
media_url: Mapped[str | None] = mapped_column(String, nullable=True)
progress_percent: Mapped[int | None] = mapped_column(Integer, nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
requested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)

View file

@ -0,0 +1,179 @@
import json
import logging
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.models.download_job import ACTIVE_STATUSES, DownloadJob
from app.models.video import Video
from app.services.metube_client import METUBE_STATUS_MAP, MeTubeClient
logger = logging.getLogger(__name__)
class MeTubeRejected(Exception):
pass
def get_latest_job(db: Session, video_id: int) -> DownloadJob | None:
return (
db.query(DownloadJob)
.filter(DownloadJob.video_id == video_id)
.order_by(DownloadJob.requested_at.desc(), DownloadJob.id.desc())
.first()
)
def latest_jobs_map(db: Session, video_ids: list[int]) -> dict[int, DownloadJob]:
if not video_ids:
return {}
rows = (
db.query(DownloadJob)
.filter(DownloadJob.video_id.in_(video_ids))
.order_by(DownloadJob.requested_at.desc(), DownloadJob.id.desc())
.all()
)
result: dict[int, DownloadJob] = {}
for row in rows:
result.setdefault(row.video_id, row)
return result
def request_download(db: Session, video: Video) -> DownloadJob:
existing = get_latest_job(db, video.id)
if existing is not None and existing.status in ACTIVE_STATUSES:
return existing
result = MeTubeClient().enqueue_video(video.youtube_url, video.youtube_video_id)
if result.get("status") == "error":
raise MeTubeRejected(result.get("msg") or "MeTube rejected the download")
job = DownloadJob(video_id=video.id, status="queued")
db.add(job)
db.commit()
db.refresh(job)
return job
def _find_job_by_payload(db: Session, payload: dict) -> DownloadJob | None:
metube_id = payload.get("id")
if metube_id:
job = (
db.query(DownloadJob)
.filter(DownloadJob.metube_job_id == metube_id)
.order_by(DownloadJob.id.desc())
.first()
)
if job is not None:
return job
url = payload.get("url")
if url:
video = db.query(Video).filter(Video.youtube_url == url).one_or_none()
if video is not None:
job = get_latest_job(db, video.id)
if job is not None:
return job
return None
async def handle_metube_event(db: Session, event_name: str, raw_payload) -> None:
try:
payload = json.loads(raw_payload) if isinstance(raw_payload, str) else raw_payload
except (TypeError, ValueError):
logger.warning("Could not parse MeTube event payload for %s: %r", event_name, raw_payload)
return
if event_name in ("canceled", "cleared"):
if not isinstance(payload, str):
return
job = (
db.query(DownloadJob)
.filter(DownloadJob.metube_job_id == payload)
.order_by(DownloadJob.id.desc())
.first()
)
if job is not None and event_name == "canceled" and job.status in ACTIVE_STATUSES:
job.status = "failed"
job.error_message = "Отменено в MeTube"
db.commit()
return
if not isinstance(payload, dict):
return
job = _find_job_by_payload(db, payload)
if job is None:
return
_apply_metube_info(job, payload)
db.commit()
logger.info("download_job %s updated to status=%s via '%s' event", job.id, job.status, event_name)
def _apply_metube_info(job: DownloadJob, info: dict) -> None:
if info.get("id"):
job.metube_job_id = info["id"]
our_status = METUBE_STATUS_MAP.get(info.get("status"), "unknown")
if job.started_at is None and our_status in ("downloading", "postprocessing", "completed"):
job.started_at = datetime.now(timezone.utc)
percent = info.get("percent")
if isinstance(percent, (int, float)):
job.progress_percent = int(percent)
if our_status == "failed":
job.status = "failed"
job.error_message = info.get("msg") or info.get("error") or "Ошибка загрузки"
elif our_status == "completed":
filename = info.get("filename")
if filename:
job.metube_filename = filename
job.media_url = MeTubeClient().build_media_url(filename)
job.status = "completed"
job.progress_percent = 100
job.completed_at = job.completed_at or datetime.now(timezone.utc)
else:
job.status = our_status
def reconcile_on_startup(db: Session) -> None:
"""Section 19: after a restart we don't trust in-flight jobs until we've
checked MeTube's current state. Completed jobs with a media_url are left
alone — they don't need MeTube's history to remain trustworthy."""
stale_statuses = list(ACTIVE_STATUSES) + ["unknown"]
jobs = db.query(DownloadJob).filter(DownloadJob.status.in_(stale_statuses)).all()
if not jobs:
return
client = MeTubeClient()
try:
history = client.fetch_history()
except Exception:
logger.warning("Could not fetch MeTube history for reconciliation", exc_info=True)
for job in jobs:
job.status = "unknown"
db.commit()
return
by_url: dict[str, dict] = {}
for bucket in ("queue", "pending", "done"):
for item in history.get(bucket, []) or []:
url = item.get("url")
if url:
by_url[url] = item
videos = {v.id: v for v in db.query(Video).filter(Video.id.in_([j.video_id for j in jobs])).all()}
for job in jobs:
video = videos.get(job.video_id)
info = by_url.get(video.youtube_url) if video else None
if info is None:
job.status = "unknown"
continue
_apply_metube_info(job, info)
db.commit()
logger.info("Reconciled %d download job(s) against MeTube history", len(jobs))

View file

@ -0,0 +1,136 @@
"""
Encapsulates all HTTP/Socket.IO calls to the external MeTube service.
Verified against the real MeTube source (alexta69/metube, app/main.py + app/ytdl.py):
- POST /add only accepts/returns {"status": "ok"|"error", "msg": ...} — no job id.
The job id/url/status are only observable via Socket.IO events or GET /history.
- MeTube itself dedups by URL (a second /add for a queued URL is a no-op "ok").
- Socket.IO 'added'/'updated'/'completed' events carry DownloadInfo.to_public_dict(),
JSON-*string*-encoded (json.JSONEncoder().encode(...)), not a raw object — must
json.loads() the payload. Relevant keys: id, title, url, status, msg, percent
(float 0-100 or None), filename (already relative to DOWNLOAD_DIR), error.
'canceled'/'cleared' carry just an id (also JSON-string-encoded).
- MeTube status vocabulary: pending/preparing/scheduled/downloading/postprocessing/
finished/error — mapped to our own vocabulary in sync with download_jobs.
- GET /history returns {"queue": [...], "pending": [...], "done": [...]} of the
same to_public_dict() shape — used for reconciliation after our own restart.
- Downloaded files are served by aiohttp's static route at /download/<relative
path>, matching METUBE_CONTAINER_DOWNLOAD_DIR.
"""
import logging
from pathlib import PurePosixPath
from typing import Awaitable, Callable
from urllib.parse import quote
import httpx
import socketio
from app.config import settings
logger = logging.getLogger(__name__)
METUBE_STATUS_MAP: dict[str, str] = {
"pending": "queued",
"preparing": "queued",
"scheduled": "queued",
"downloading": "downloading",
"postprocessing": "postprocessing",
"finished": "completed",
"error": "failed",
}
EventHandler = Callable[[str, dict | str], Awaitable[None]]
class MeTubeClient:
def __init__(self) -> None:
self.api_base_url = settings.metube_api_base_url.rstrip("/")
self.public_base_url = settings.metube_public_base_url.rstrip("/")
self.download_dir = settings.metube_container_download_dir
self.timeout = settings.metube_request_timeout_seconds
def enqueue_video(self, youtube_url: str, custom_name_prefix: str) -> dict:
payload = {
"url": youtube_url,
"download_type": "video",
"codec": "auto",
"format": "mp4",
"quality": "best",
"auto_start": True,
"custom_name_prefix": custom_name_prefix,
}
response = httpx.post(f"{self.api_base_url}/add", json=payload, timeout=self.timeout)
response.raise_for_status()
return response.json()
def health(self) -> bool:
try:
response = httpx.get(self.api_base_url, timeout=5)
return response.status_code < 500
except Exception:
return False
def fetch_history(self) -> dict:
response = httpx.get(f"{self.api_base_url}/history", timeout=self.timeout)
response.raise_for_status()
return response.json()
def build_media_url(self, filename: str) -> str | None:
"""Safely turn a MeTube-reported filename into a public /download/... URL.
`filename` is expected relative to METUBE_CONTAINER_DOWNLOAD_DIR (that's
what MeTube itself stores), but we defensively strip an accidental
absolute prefix and reject any path that escapes the download dir.
"""
if not filename:
return None
normalized = filename.replace("\\", "/")
download_dir = self.download_dir.rstrip("/")
if download_dir and normalized.startswith(download_dir + "/"):
normalized = normalized[len(download_dir) + 1 :]
normalized = normalized.lstrip("/")
path = PurePosixPath(normalized)
if ".." in path.parts or path.is_absolute():
logger.warning("Rejected unsafe MeTube filename: %r", filename)
return None
encoded = "/".join(quote(part) for part in path.parts)
return f"{self.public_base_url}/download/{encoded}"
def check_media(self, media_url: str) -> bool:
try:
response = httpx.head(media_url, timeout=self.timeout, follow_redirects=True)
return response.status_code == 200
except Exception:
return False
async def run_event_listener(self, on_event: EventHandler) -> None:
"""Runs forever, reconnecting automatically, until cancelled."""
sio = socketio.AsyncClient(reconnection=True, reconnection_delay=5, reconnection_delay_max=30)
for event_name in ("added", "updated", "completed", "canceled", "cleared"):
async def _handler(data, _event_name=event_name):
await on_event(_event_name, data)
sio.on(event_name, _handler)
@sio.event
async def connect():
logger.info("Connected to MeTube Socket.IO at %s", self.api_base_url)
@sio.event
async def disconnect():
logger.warning("Disconnected from MeTube Socket.IO")
while True:
try:
await sio.connect(self.api_base_url, wait_timeout=10)
await sio.wait()
except Exception:
logger.warning("MeTube Socket.IO connection failed, retrying", exc_info=True)
await sio.sleep(10)

View file

@ -4,6 +4,7 @@ from sqlalchemy.orm import Session
from app.models.category import Category
from app.models.channel import Channel
from app.models.channel_category import channel_categories
from app.models.download_job import DownloadJob
from app.models.video import Video
@ -21,7 +22,22 @@ def channel_categories_map(db: Session, channel_ids: list[int]) -> dict[int, lis
return result
def serialize_video(video: Video, channel: Channel, categories: list[dict]) -> dict:
def _serialize_local(job: DownloadJob | None) -> dict:
if job is None:
return {"available": False, "status": "not_downloaded", "progress_percent": None, "media_url": None}
if job.status == "completed":
return {"available": True, "status": "completed", "progress_percent": 100, "media_url": job.media_url}
return {
"available": False,
"status": job.status,
"progress_percent": job.progress_percent,
"media_url": None,
}
def serialize_video(
video: Video, channel: Channel, categories: list[dict], download_job: DownloadJob | None = None
) -> dict:
return {
"youtube_video_id": video.youtube_video_id,
"title": video.title,
@ -37,10 +53,5 @@ def serialize_video(video: Video, channel: Channel, categories: list[dict]) -> d
"duration_seconds": video.duration_seconds,
"youtube_url": video.youtube_url,
"categories": categories,
"local": {
"available": False,
"status": "not_downloaded",
"progress_percent": None,
"media_url": None,
},
"local": _serialize_local(download_job),
}

View file

@ -1,2 +1,3 @@
-r requirements.txt
pytest==8.3.4
pytest-asyncio==0.25.0

View file

@ -10,3 +10,4 @@ cryptography==44.0.0
google-auth==2.37.0
google-auth-oauthlib==1.2.1
apscheduler==3.11.0
python-socketio[asyncio_client]==5.11.4

View file

@ -359,13 +359,49 @@
margin-bottom: 16px;
}
.player-wrapper iframe {
.player-wrapper iframe,
.player-wrapper video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
background: #000;
}
.download-badge {
display: inline-block;
font-size: 12px;
padding: 3px 8px;
border-radius: 10px;
background: #eee;
}
.download-badge.completed {
background: #dcf5df;
color: #1a7f2e;
}
.download-badge.error {
background: #fbdcdc;
color: #a3221f;
}
.download-error {
display: inline-flex;
align-items: center;
gap: 6px;
}
.video-actions button,
.video-page-meta button {
font-size: 13px;
padding: 4px 10px;
border: 1px solid #ccc;
border-radius: 6px;
background: transparent;
cursor: pointer;
}
.video-page-title {

View file

@ -168,6 +168,28 @@ export function getVideo(youtubeVideoId: string) {
return request<FeedVideoDto>(`/api/videos/${youtubeVideoId}`)
}
export interface DownloadJobDto {
status: string
progress_percent: number | null
media_url: string | null
error_message: string | null
requested_at?: string
started_at?: string | null
completed_at?: string | null
}
export function downloadVideo(youtubeVideoId: string) {
return request<DownloadJobDto>(`/api/videos/${youtubeVideoId}/download`, { method: 'POST' })
}
export function getDownloadStatus(youtubeVideoId: string) {
return request<DownloadJobDto>(`/api/videos/${youtubeVideoId}/download-status`)
}
export function recheckLocal(youtubeVideoId: string) {
return request<DownloadJobDto>(`/api/videos/${youtubeVideoId}/recheck-local`, { method: 'POST' })
}
export function getFeed(
params: { categoryId?: number; uncategorized?: boolean; channelId?: number; cursor?: string } = {},
) {

View file

@ -0,0 +1,87 @@
import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { FeedVideoDto } from '../api/client'
import { downloadVideo, getDownloadStatus } from '../api/client'
const ACTIVE_STATUSES = ['queued', 'downloading', 'postprocessing']
const STATUS_LABELS: Record<string, string> = {
queued: 'В очереди...',
postprocessing: 'Обработка...',
}
interface Props {
video: FeedVideoDto
}
function DownloadButton({ video }: Props) {
const queryClient = useQueryClient()
const [polling, setPolling] = useState(ACTIVE_STATUSES.includes(video.local.status))
const statusQuery = useQuery({
queryKey: ['download-status', video.youtube_video_id],
queryFn: () => getDownloadStatus(video.youtube_video_id),
enabled: polling,
refetchInterval: (query) => (query.state.data && ACTIVE_STATUSES.includes(query.state.data.status) ? 2000 : false),
initialData: polling
? {
status: video.local.status,
progress_percent: video.local.progress_percent,
media_url: video.local.media_url,
error_message: null,
}
: undefined,
})
const downloadMutation = useMutation({
mutationFn: () => downloadVideo(video.youtube_video_id),
onSuccess: (data) => {
queryClient.setQueryData(['download-status', video.youtube_video_id], data)
setPolling(true)
},
})
const current = polling ? statusQuery.data : undefined
const status = current?.status ?? video.local.status
const percent = current?.progress_percent ?? video.local.progress_percent
useEffect(() => {
if (current && !ACTIVE_STATUSES.includes(current.status)) {
setPolling(false)
if (current.status === 'completed' || current.status === 'failed') {
queryClient.invalidateQueries({ queryKey: ['feed'] })
}
}
}, [current?.status, queryClient])
if (status === 'completed') {
return <span className="download-badge completed">✓ На сервере</span>
}
if (status === 'downloading') {
return <span className="download-badge">{percent ?? 0}%</span>
}
if (status === 'queued' || status === 'postprocessing') {
return <span className="download-badge">{STATUS_LABELS[status]}</span>
}
if (status === 'failed') {
return (
<span className="download-error">
<span className="download-badge error">Ошибка</span>
<button onClick={() => downloadMutation.mutate()} disabled={downloadMutation.isPending}>
Повторить
</button>
</span>
)
}
return (
<button onClick={() => downloadMutation.mutate()} disabled={downloadMutation.isPending}>
⬇ Скачать
</button>
)
}
export default DownloadButton

View file

@ -0,0 +1,60 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { FeedVideoDto } from '../api/client'
import { recheckLocal } from '../api/client'
interface Props {
video: FeedVideoDto
}
function Player({ video }: Props) {
const [localFailed, setLocalFailed] = useState(false)
const queryClient = useQueryClient()
const recheckMutation = useMutation({
mutationFn: () => recheckLocal(video.youtube_video_id),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['video', video.youtube_video_id] })
queryClient.invalidateQueries({ queryKey: ['download-status', video.youtube_video_id] })
},
})
const useLocal = video.local.available && !!video.local.media_url && !localFailed
if (useLocal) {
return (
<>
<div className="player-wrapper">
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
controls
src={video.local.media_url!}
onError={() => {
setLocalFailed(true)
recheckMutation.mutate()
}}
/>
</div>
<div className="video-page-source">● Локальная копия · mediaVM</div>
</>
)
}
return (
<>
<div className="player-wrapper">
<iframe
src={`https://www.youtube-nocookie.com/embed/${video.youtube_video_id}`}
title={video.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
<div className="video-page-source">
{localFailed ? 'Локальная копия недоступна. Переключено на YouTube.' : 'Источник: YouTube'}
</div>
</>
)
}
export default Player

View file

@ -1,6 +1,7 @@
import { Link } from 'react-router-dom'
import type { FeedVideoDto } from '../api/client'
import { formatDuration, formatRelativeTime } from '../utils/format'
import DownloadButton from './DownloadButton'
interface Props {
video: FeedVideoDto
@ -31,6 +32,7 @@ function VideoCard({ video }: Props) {
<Link to={href} className="button-link">
Смотреть
</Link>
<DownloadButton video={video} />
</div>
</div>
</li>

View file

@ -2,6 +2,8 @@ import { useQuery } from '@tanstack/react-query'
import { Link, useParams } from 'react-router-dom'
import { getVideo } from '../api/client'
import { formatDuration, formatRelativeTime } from '../utils/format'
import DownloadButton from '../components/DownloadButton'
import Player from '../components/Player'
function VideoPage() {
const { youtubeVideoId } = useParams<{ youtubeVideoId: string }>()
@ -40,14 +42,7 @@ function VideoPage() {
← Лента
</Link>
<div className="player-wrapper">
<iframe
src={`https://www.youtube-nocookie.com/embed/${video.youtube_video_id}`}
title={video.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
<Player video={video} />
<h1 className="video-page-title">{video.title}</h1>
@ -56,10 +51,10 @@ function VideoPage() {
{' · '}
{formatRelativeTime(video.published_at)}
{duration && ` · ${duration}`}
{' · '}
<DownloadButton video={video} />
</div>
<div className="video-page-source">Источник: YouTube</div>
{video.categories.length > 0 && (
<div className="video-page-categories">
{video.categories.map((c) => (

View file

@ -0,0 +1,40 @@
"""download_jobs
Revision ID: 0005_download_jobs
Revises: 0004_videos
Create Date: 2026-09-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0005_download_jobs"
down_revision: Union[str, None] = "0004_videos"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"download_jobs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("video_id", sa.Integer(), sa.ForeignKey("videos.id"), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False, server_default="queued"),
sa.Column("metube_job_id", sa.String(length=255), nullable=True),
sa.Column("metube_filename", sa.String(), nullable=True),
sa.Column("media_url", sa.String(), nullable=True),
sa.Column("progress_percent", sa.Integer(), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("requested_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_download_jobs_video_id", "download_jobs", ["video_id"])
def downgrade() -> None:
op.drop_index("ix_download_jobs_video_id", table_name="download_jobs")
op.drop_table("download_jobs")

View file

@ -1,3 +1,5 @@
[tool.pytest.ini_options]
pythonpath = ["backend"]
testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"

View file

@ -17,8 +17,9 @@ def client(monkeypatch, db_session):
monkeypatch.setattr(
google_oauth, "build_authorization_url", lambda: ("https://accounts.google.com/fake", "fixed-state")
)
with TestClient(app) as test_client:
yield test_client
# Deliberately not using `with TestClient(app)`: that runs the app's
# lifespan, which would try to reach the real MeTube instance and DB.
yield TestClient(app)
del app.dependency_overrides[get_db]

View file

@ -14,8 +14,9 @@ def client(db_session):
app.dependency_overrides[get_db] = _get_db_override
app.dependency_overrides[require_session] = lambda: None
with TestClient(app) as test_client:
yield test_client
# Deliberately not using `with TestClient(app)`: that runs the app's
# lifespan, which would try to reach the real MeTube instance and DB.
yield TestClient(app)
del app.dependency_overrides[get_db]
del app.dependency_overrides[require_session]

243
tests/test_download_jobs.py Normal file
View file

@ -0,0 +1,243 @@
import json
import pytest
from app.models.channel import Channel
from app.models.download_job import DownloadJob
from app.models.video import Video
from app.services import download_jobs
def _seed_video(db_session, youtube_video_id="vid1", youtube_channel_id="chanA"):
channel = Channel(youtube_channel_id=youtube_channel_id, title="Channel", subscribed=True)
db_session.add(channel)
db_session.commit()
from datetime import datetime, timezone
video = Video(
youtube_video_id=youtube_video_id,
channel_id=channel.id,
title="Video",
published_at=datetime(2026, 9, 10, tzinfo=timezone.utc),
youtube_url=f"https://www.youtube.com/watch?v={youtube_video_id}",
)
db_session.add(video)
db_session.commit()
db_session.refresh(video)
return video
def test_request_download_enqueues_and_creates_job(monkeypatch, db_session):
video = _seed_video(db_session)
calls = {}
def fake_enqueue(self, youtube_url, custom_name_prefix):
calls["youtube_url"] = youtube_url
calls["custom_name_prefix"] = custom_name_prefix
return {"status": "ok"}
monkeypatch.setattr("app.services.metube_client.MeTubeClient.enqueue_video", fake_enqueue)
job = download_jobs.request_download(db_session, video)
assert job.status == "queued"
assert calls["youtube_url"] == video.youtube_url
assert calls["custom_name_prefix"] == video.youtube_video_id
def test_request_download_is_idempotent_while_active(monkeypatch, db_session):
video = _seed_video(db_session)
monkeypatch.setattr(
"app.services.metube_client.MeTubeClient.enqueue_video", lambda self, u, p: {"status": "ok"}
)
job1 = download_jobs.request_download(db_session, video)
job2 = download_jobs.request_download(db_session, video)
assert job1.id == job2.id
assert db_session.query(DownloadJob).count() == 1
def test_request_download_raises_on_metube_error(monkeypatch, db_session):
video = _seed_video(db_session)
monkeypatch.setattr(
"app.services.metube_client.MeTubeClient.enqueue_video",
lambda self, u, p: {"status": "error", "msg": "boom"},
)
with pytest.raises(download_jobs.MeTubeRejected):
download_jobs.request_download(db_session, video)
assert db_session.query(DownloadJob).count() == 0
def test_request_download_allows_retry_after_failure(monkeypatch, db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="failed", error_message="oops")
db_session.add(job)
db_session.commit()
monkeypatch.setattr(
"app.services.metube_client.MeTubeClient.enqueue_video", lambda self, u, p: {"status": "ok"}
)
new_job = download_jobs.request_download(db_session, video)
assert new_job.id != job.id
assert db_session.query(DownloadJob).count() == 2
@pytest.mark.asyncio
async def test_handle_metube_event_added_and_updated(db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="queued")
db_session.add(job)
db_session.commit()
added_payload = json.dumps({"id": "vid1.vid1", "url": video.youtube_url, "status": "pending"})
await download_jobs.handle_metube_event(db_session, "added", added_payload)
db_session.refresh(job)
assert job.metube_job_id == "vid1.vid1"
assert job.status == "queued"
updating_payload = json.dumps(
{"id": "vid1.vid1", "url": video.youtube_url, "status": "downloading", "percent": 42.5}
)
await download_jobs.handle_metube_event(db_session, "updated", updating_payload)
db_session.refresh(job)
assert job.status == "downloading"
assert job.progress_percent == 42
assert job.started_at is not None
@pytest.mark.asyncio
async def test_handle_metube_event_completed_builds_media_url(monkeypatch, db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="downloading", metube_job_id="vid1.vid1")
db_session.add(job)
db_session.commit()
monkeypatch.setattr(
"app.services.metube_client.MeTubeClient.build_media_url",
lambda self, filename: f"http://metube.local/download/{filename}",
)
payload = json.dumps(
{"id": "vid1.vid1", "url": video.youtube_url, "status": "finished", "filename": "vid1.vid1.mp4"}
)
await download_jobs.handle_metube_event(db_session, "completed", payload)
db_session.refresh(job)
assert job.status == "completed"
assert job.progress_percent == 100
assert job.metube_filename == "vid1.vid1.mp4"
assert job.media_url == "http://metube.local/download/vid1.vid1.mp4"
assert job.completed_at is not None
@pytest.mark.asyncio
async def test_handle_metube_event_error_marks_failed(db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="downloading", metube_job_id="vid1.vid1")
db_session.add(job)
db_session.commit()
payload = json.dumps({"id": "vid1.vid1", "url": video.youtube_url, "status": "error", "msg": "network blip"})
await download_jobs.handle_metube_event(db_session, "updated", payload)
db_session.refresh(job)
assert job.status == "failed"
assert job.error_message == "network blip"
@pytest.mark.asyncio
async def test_handle_metube_event_ignores_unrelated_download(db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="queued")
db_session.add(job)
db_session.commit()
payload = json.dumps(
{"id": "other.other", "url": "https://www.youtube.com/watch?v=other", "status": "finished"}
)
await download_jobs.handle_metube_event(db_session, "completed", payload)
db_session.refresh(job)
assert job.status == "queued"
@pytest.mark.asyncio
async def test_handle_metube_event_canceled_marks_failed(db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="downloading", metube_job_id="vid1.vid1")
db_session.add(job)
db_session.commit()
await download_jobs.handle_metube_event(db_session, "canceled", json.dumps("vid1.vid1"))
db_session.refresh(job)
assert job.status == "failed"
def test_reconcile_marks_unknown_when_history_unavailable(monkeypatch, db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="downloading")
db_session.add(job)
db_session.commit()
def raise_error(self):
raise RuntimeError("connection refused")
monkeypatch.setattr("app.services.metube_client.MeTubeClient.fetch_history", raise_error)
download_jobs.reconcile_on_startup(db_session)
db_session.refresh(job)
assert job.status == "unknown"
def test_reconcile_restores_completed_from_history(monkeypatch, db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="downloading")
db_session.add(job)
db_session.commit()
monkeypatch.setattr(
"app.services.metube_client.MeTubeClient.fetch_history",
lambda self: {
"queue": [],
"pending": [],
"done": [{"id": "vid1.vid1", "url": video.youtube_url, "status": "finished", "filename": "f.mp4"}],
},
)
monkeypatch.setattr(
"app.services.metube_client.MeTubeClient.build_media_url",
lambda self, filename: f"http://metube.local/download/{filename}",
)
download_jobs.reconcile_on_startup(db_session)
db_session.refresh(job)
assert job.status == "completed"
assert job.media_url == "http://metube.local/download/f.mp4"
def test_reconcile_leaves_completed_jobs_untouched(monkeypatch, db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="completed", media_url="http://x/f.mp4")
db_session.add(job)
db_session.commit()
def fail_if_called(self):
raise AssertionError("should not fetch history for completed jobs")
monkeypatch.setattr("app.services.metube_client.MeTubeClient.fetch_history", fail_if_called)
download_jobs.reconcile_on_startup(db_session)
db_session.refresh(job)
assert job.status == "completed"
assert job.media_url == "http://x/f.mp4"

View file

@ -19,8 +19,9 @@ def client(db_session):
app.dependency_overrides[get_db] = _get_db_override
app.dependency_overrides[require_session] = lambda: None
with TestClient(app) as test_client:
yield test_client
# Deliberately not using `with TestClient(app)`: that runs the app's
# lifespan, which would try to reach the real MeTube instance and DB.
yield TestClient(app)
del app.dependency_overrides[get_db]
del app.dependency_overrides[require_session]

View file

@ -21,8 +21,7 @@ def override_get_db():
def test_health_ok():
with patch("app.api.health.httpx.get") as mock_get:
mock_get.return_value = MagicMock(status_code=200)
with patch("app.services.metube_client.MeTubeClient.health", return_value=True):
client = TestClient(app)
response = client.get("/api/health")
@ -34,7 +33,7 @@ def test_health_ok():
def test_health_metube_unreachable():
with patch("app.api.health.httpx.get", side_effect=ConnectionError):
with patch("app.services.metube_client.MeTubeClient.health", return_value=False):
client = TestClient(app)
response = client.get("/api/health")

View file

@ -0,0 +1,33 @@
import pytest
from app.services.metube_client import MeTubeClient
@pytest.fixture
def client():
return MeTubeClient()
def test_build_media_url_encodes_spaces(client):
url = client.build_media_url("some file.mp4")
assert url == f"{client.public_base_url}/download/some%20file.mp4"
def test_build_media_url_strips_absolute_download_dir_prefix(client):
filename = f"{client.download_dir}/nested/video.mp4"
url = client.build_media_url(filename)
assert url == f"{client.public_base_url}/download/nested/video.mp4"
def test_build_media_url_rejects_path_traversal(client):
assert client.build_media_url("../../etc/passwd") is None
def test_build_media_url_none_for_empty(client):
assert client.build_media_url("") is None
assert client.build_media_url(None) is None
def test_build_media_url_encodes_each_segment(client):
url = client.build_media_url("dir with space/file#1.mp4")
assert url == f"{client.public_base_url}/download/dir%20with%20space/file%231.mp4"

View file

@ -19,8 +19,10 @@ def client(db_session):
app.dependency_overrides[get_db] = _get_db_override
app.dependency_overrides[require_session] = lambda: None
with TestClient(app) as test_client:
yield test_client
# Deliberately not using `with TestClient(app)`: that runs the app's
# lifespan, which would try to reach the real MeTube instance and DB
# (see AGENTS.md: MeTube network calls in tests must be opt-in only).
yield TestClient(app)
del app.dependency_overrides[get_db]
del app.dependency_overrides[require_session]
@ -60,3 +62,99 @@ def test_get_video_by_youtube_id(client, db_session):
def test_get_video_not_found(client):
resp = client.get("/api/videos/missing")
assert resp.status_code == 404
def _seed_single_video(db_session, youtube_video_id="vid1"):
channel = Channel(youtube_channel_id="chanA", title="Channel A", subscribed=True)
db_session.add(channel)
db_session.commit()
video = Video(
youtube_video_id=youtube_video_id,
channel_id=channel.id,
title="Video One",
published_at=datetime(2026, 9, 10, tzinfo=timezone.utc),
youtube_url=f"https://www.youtube.com/watch?v={youtube_video_id}",
)
db_session.add(video)
db_session.commit()
db_session.refresh(video)
return video
def test_download_video_enqueues(client, db_session, monkeypatch):
video = _seed_single_video(db_session)
monkeypatch.setattr(
"app.services.metube_client.MeTubeClient.enqueue_video", lambda self, u, p: {"status": "ok"}
)
resp = client.post(f"/api/videos/{video.youtube_video_id}/download")
assert resp.status_code == 200
assert resp.json()["status"] == "queued"
def test_download_video_metube_rejects(client, db_session, monkeypatch):
video = _seed_single_video(db_session)
monkeypatch.setattr(
"app.services.metube_client.MeTubeClient.enqueue_video",
lambda self, u, p: {"status": "error", "msg": "bad url"},
)
resp = client.post(f"/api/videos/{video.youtube_video_id}/download")
assert resp.status_code == 502
def test_download_video_second_click_returns_same_job(client, db_session, monkeypatch):
video = _seed_single_video(db_session)
monkeypatch.setattr(
"app.services.metube_client.MeTubeClient.enqueue_video", lambda self, u, p: {"status": "ok"}
)
r1 = client.post(f"/api/videos/{video.youtube_video_id}/download").json()
r2 = client.post(f"/api/videos/{video.youtube_video_id}/download").json()
assert r1["requested_at"] == r2["requested_at"]
from app.models.download_job import DownloadJob
assert db_session.query(DownloadJob).count() == 1
def test_download_status_not_downloaded(client, db_session):
video = _seed_single_video(db_session)
resp = client.get(f"/api/videos/{video.youtube_video_id}/download-status")
assert resp.json()["status"] == "not_downloaded"
def test_recheck_local_downgrades_to_unknown_when_media_missing(client, db_session, monkeypatch):
from app.models.download_job import DownloadJob
video = _seed_single_video(db_session)
job = DownloadJob(video_id=video.id, status="completed", media_url="http://metube.local/download/f.mp4")
db_session.add(job)
db_session.commit()
monkeypatch.setattr("app.services.metube_client.MeTubeClient.check_media", lambda self, url: False)
resp = client.post(f"/api/videos/{video.youtube_video_id}/recheck-local")
assert resp.status_code == 200
assert resp.json()["status"] == "unknown"
def test_recheck_local_keeps_completed_when_media_reachable(client, db_session, monkeypatch):
from app.models.download_job import DownloadJob
video = _seed_single_video(db_session)
job = DownloadJob(video_id=video.id, status="completed", media_url="http://metube.local/download/f.mp4")
db_session.add(job)
db_session.commit()
monkeypatch.setattr("app.services.metube_client.MeTubeClient.check_media", lambda self, url: True)
resp = client.post(f"/api/videos/{video.youtube_video_id}/recheck-local")
assert resp.status_code == 200
assert resp.json()["status"] == "completed"