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. - MeTube-специфичные HTTP/Socket.IO вызовы должны быть инкапсулированы в отдельный класс `MeTubeClient` (раздел 36 ТЗ) — не размазывать по backend.
- Секреты только через `.env` (см. `.env.example`), никогда не коммитить `.env`, refresh token, client secret. - Секреты только через `.env` (см. `.env.example`), никогда не коммитить `.env`, refresh token, client secret.
- Backend тесты — `pytest`, лежат в `tests/` в корне. Integration-тесты против Google/MeTube — mocked по умолчанию; реальные вызовы к `http://192.168.8.177:8081` — только opt-in, не в обычном CI. - 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 import Channel
from app.models.channel_category import channel_categories from app.models.channel_category import channel_categories
from app.models.video import Video 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 from app.services.video_presentation import channel_categories_map, serialize_video
router = APIRouter(dependencies=[Depends(require_session)]) router = APIRouter(dependencies=[Depends(require_session)])
@ -76,12 +77,15 @@ def get_feed(
channel_ids = list({v.channel_id for v in rows}) 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()} 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) categories_map = channel_categories_map(db, channel_ids)
jobs_map = latest_jobs_map(db, [v.id for v in rows])
items = [] items = []
for video in rows: for video in rows:
channel = channels.get(video.channel_id) channel = channels.get(video.channel_id)
if channel is None: if channel is None:
continue 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} return {"items": items, "next_cursor": next_cursor}

View file

@ -1,12 +1,11 @@
import logging import logging
import httpx
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import settings
from app.db import get_db from app.db import get_db
from app.services.metube_client import MeTubeClient
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -22,21 +21,10 @@ def _check_database(db: Session) -> str:
return "error" 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") @router.get("/health")
def health(db: Session = Depends(get_db)) -> dict: def health(db: Session = Depends(get_db)) -> dict:
return { return {
"status": "ok", "status": "ok",
"database": _check_database(db), "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 fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.auth_dependency import require_session from app.core.auth_dependency import require_session
from app.db import get_db from app.db import get_db
from app.models.channel import Channel from app.models.channel import Channel
from app.models.download_job import DownloadJob
from app.models.video import Video 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 from app.services.video_presentation import channel_categories_map, serialize_video
logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(require_session)]) router = APIRouter(dependencies=[Depends(require_session)])
@router.get("/videos/{youtube_video_id}") def _get_video_or_404(db: Session, youtube_video_id: str) -> Video:
def get_video(youtube_video_id: str, db: Session = Depends(get_db)) -> dict:
video = db.query(Video).filter(Video.youtube_video_id == youtube_video_id).one_or_none() video = db.query(Video).filter(Video.youtube_video_id == youtube_video_id).one_or_none()
if video is None: if video is None:
raise HTTPException(status_code=404, detail="Video not found") 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) channel = db.get(Channel, video.channel_id)
if channel is None: if channel is None:
raise HTTPException(status_code=404, detail="Channel not found") raise HTTPException(status_code=404, detail="Channel not found")
categories = channel_categories_map(db, [channel.id]).get(channel.id, []) 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 import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path 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.sync import router as sync_router
from app.api.videos import router as videos_router from app.api.videos import router as videos_router
from app.config import settings 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 from app.services.scheduler import create_scheduler
logging.basicConfig(level=settings.log_level) logging.basicConfig(level=settings.log_level)
logger = logging.getLogger(__name__) 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 @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
logger.info("Application startup") logger.info("Application startup")
_reconcile_download_jobs()
scheduler = create_scheduler() scheduler = create_scheduler()
scheduler.start() scheduler.start()
metube_task = asyncio.create_task(MeTubeClient().run_event_listener(_on_metube_event))
yield yield
metube_task.cancel()
scheduler.shutdown(wait=False) scheduler.shutdown(wait=False)
logger.info("Application shutdown") 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.category import Category
from app.models.channel import Channel from app.models.channel import Channel
from app.models.channel_category import channel_categories 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.oauth_credentials import OAuthCredentials
from app.models.video import Video 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.category import Category
from app.models.channel import Channel from app.models.channel import Channel
from app.models.channel_category import channel_categories from app.models.channel_category import channel_categories
from app.models.download_job import DownloadJob
from app.models.video import Video 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 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 { return {
"youtube_video_id": video.youtube_video_id, "youtube_video_id": video.youtube_video_id,
"title": video.title, "title": video.title,
@ -37,10 +53,5 @@ def serialize_video(video: Video, channel: Channel, categories: list[dict]) -> d
"duration_seconds": video.duration_seconds, "duration_seconds": video.duration_seconds,
"youtube_url": video.youtube_url, "youtube_url": video.youtube_url,
"categories": categories, "categories": categories,
"local": { "local": _serialize_local(download_job),
"available": False,
"status": "not_downloaded",
"progress_percent": None,
"media_url": None,
},
} }

View file

@ -1,2 +1,3 @@
-r requirements.txt -r requirements.txt
pytest==8.3.4 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==2.37.0
google-auth-oauthlib==1.2.1 google-auth-oauthlib==1.2.1
apscheduler==3.11.0 apscheduler==3.11.0
python-socketio[asyncio_client]==5.11.4

View file

@ -359,13 +359,49 @@
margin-bottom: 16px; margin-bottom: 16px;
} }
.player-wrapper iframe { .player-wrapper iframe,
.player-wrapper video {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
border: 0; 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 { .video-page-title {

View file

@ -168,6 +168,28 @@ export function getVideo(youtubeVideoId: string) {
return request<FeedVideoDto>(`/api/videos/${youtubeVideoId}`) 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( export function getFeed(
params: { categoryId?: number; uncategorized?: boolean; channelId?: number; cursor?: string } = {}, 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 { Link } from 'react-router-dom'
import type { FeedVideoDto } from '../api/client' import type { FeedVideoDto } from '../api/client'
import { formatDuration, formatRelativeTime } from '../utils/format' import { formatDuration, formatRelativeTime } from '../utils/format'
import DownloadButton from './DownloadButton'
interface Props { interface Props {
video: FeedVideoDto video: FeedVideoDto
@ -31,6 +32,7 @@ function VideoCard({ video }: Props) {
<Link to={href} className="button-link"> <Link to={href} className="button-link">
Смотреть Смотреть
</Link> </Link>
<DownloadButton video={video} />
</div> </div>
</div> </div>
</li> </li>

View file

@ -2,6 +2,8 @@ import { useQuery } from '@tanstack/react-query'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { getVideo } from '../api/client' import { getVideo } from '../api/client'
import { formatDuration, formatRelativeTime } from '../utils/format' import { formatDuration, formatRelativeTime } from '../utils/format'
import DownloadButton from '../components/DownloadButton'
import Player from '../components/Player'
function VideoPage() { function VideoPage() {
const { youtubeVideoId } = useParams<{ youtubeVideoId: string }>() const { youtubeVideoId } = useParams<{ youtubeVideoId: string }>()
@ -40,14 +42,7 @@ function VideoPage() {
← Лента ← Лента
</Link> </Link>
<div className="player-wrapper"> <Player video={video} />
<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>
<h1 className="video-page-title">{video.title}</h1> <h1 className="video-page-title">{video.title}</h1>
@ -56,10 +51,10 @@ function VideoPage() {
{' · '} {' · '}
{formatRelativeTime(video.published_at)} {formatRelativeTime(video.published_at)}
{duration && ` · ${duration}`} {duration && ` · ${duration}`}
{' · '}
<DownloadButton video={video} />
</div> </div>
<div className="video-page-source">Источник: YouTube</div>
{video.categories.length > 0 && ( {video.categories.length > 0 && (
<div className="video-page-categories"> <div className="video-page-categories">
{video.categories.map((c) => ( {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] [tool.pytest.ini_options]
pythonpath = ["backend"] pythonpath = ["backend"]
testpaths = ["tests"] testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"

View file

@ -17,8 +17,9 @@ def client(monkeypatch, db_session):
monkeypatch.setattr( monkeypatch.setattr(
google_oauth, "build_authorization_url", lambda: ("https://accounts.google.com/fake", "fixed-state") google_oauth, "build_authorization_url", lambda: ("https://accounts.google.com/fake", "fixed-state")
) )
with TestClient(app) as test_client: # Deliberately not using `with TestClient(app)`: that runs the app's
yield test_client # 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[get_db]

View file

@ -14,8 +14,9 @@ def client(db_session):
app.dependency_overrides[get_db] = _get_db_override app.dependency_overrides[get_db] = _get_db_override
app.dependency_overrides[require_session] = lambda: None app.dependency_overrides[require_session] = lambda: None
with TestClient(app) as test_client: # Deliberately not using `with TestClient(app)`: that runs the app's
yield test_client # 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[get_db]
del app.dependency_overrides[require_session] 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[get_db] = _get_db_override
app.dependency_overrides[require_session] = lambda: None app.dependency_overrides[require_session] = lambda: None
with TestClient(app) as test_client: # Deliberately not using `with TestClient(app)`: that runs the app's
yield test_client # 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[get_db]
del app.dependency_overrides[require_session] del app.dependency_overrides[require_session]

View file

@ -21,8 +21,7 @@ def override_get_db():
def test_health_ok(): def test_health_ok():
with patch("app.api.health.httpx.get") as mock_get: with patch("app.services.metube_client.MeTubeClient.health", return_value=True):
mock_get.return_value = MagicMock(status_code=200)
client = TestClient(app) client = TestClient(app)
response = client.get("/api/health") response = client.get("/api/health")
@ -34,7 +33,7 @@ def test_health_ok():
def test_health_metube_unreachable(): 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) client = TestClient(app)
response = client.get("/api/health") 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[get_db] = _get_db_override
app.dependency_overrides[require_session] = lambda: None app.dependency_overrides[require_session] = lambda: None
with TestClient(app) as test_client: # Deliberately not using `with TestClient(app)`: that runs the app's
yield test_client # 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[get_db]
del app.dependency_overrides[require_session] 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): def test_get_video_not_found(client):
resp = client.get("/api/videos/missing") resp = client.get("/api/videos/missing")
assert resp.status_code == 404 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"