diff --git a/AGENTS.md b/AGENTS.md index b1c87dd..625f309 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) — так и сделано во всех текущих тестах. ## Запуск/проверка diff --git a/backend/app/api/feed.py b/backend/app/api/feed.py index ae2e08f..73600b7 100644 --- a/backend/app/api/feed.py +++ b/backend/app/api/feed.py @@ -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} diff --git a/backend/app/api/health.py b/backend/app/api/health.py index 3c06094..ac2b785 100644 --- a/backend/app/api/health.py +++ b/backend/app/api/health.py @@ -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", } diff --git a/backend/app/api/videos.py b/backend/app/api/videos.py index e5a4306..742d1e0 100644 --- a/backend/app/api/videos.py +++ b/backend/app/api/videos.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index 61b04bd..6105630 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index c7285e3..4a49d1e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/download_job.py b/backend/app/models/download_job.py new file mode 100644 index 0000000..2728437 --- /dev/null +++ b/backend/app/models/download_job.py @@ -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 + ) diff --git a/backend/app/services/download_jobs.py b/backend/app/services/download_jobs.py new file mode 100644 index 0000000..ea3ba42 --- /dev/null +++ b/backend/app/services/download_jobs.py @@ -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)) diff --git a/backend/app/services/metube_client.py b/backend/app/services/metube_client.py new file mode 100644 index 0000000..e430897 --- /dev/null +++ b/backend/app/services/metube_client.py @@ -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/, 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) diff --git a/backend/app/services/video_presentation.py b/backend/app/services/video_presentation.py index 2bd7c74..a4c1f88 100644 --- a/backend/app/services/video_presentation.py +++ b/backend/app/services/video_presentation.py @@ -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), } diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt index 152b054..135ac86 100644 --- a/backend/requirements-dev.txt +++ b/backend/requirements-dev.txt @@ -1,2 +1,3 @@ -r requirements.txt pytest==8.3.4 +pytest-asyncio==0.25.0 diff --git a/backend/requirements.txt b/backend/requirements.txt index 28d64eb..80ef05a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/frontend/src/App.css b/frontend/src/App.css index 726d9e8..8bc322a 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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 { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index dafff5c..0ea0c84 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -168,6 +168,28 @@ export function getVideo(youtubeVideoId: string) { return request(`/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(`/api/videos/${youtubeVideoId}/download`, { method: 'POST' }) +} + +export function getDownloadStatus(youtubeVideoId: string) { + return request(`/api/videos/${youtubeVideoId}/download-status`) +} + +export function recheckLocal(youtubeVideoId: string) { + return request(`/api/videos/${youtubeVideoId}/recheck-local`, { method: 'POST' }) +} + export function getFeed( params: { categoryId?: number; uncategorized?: boolean; channelId?: number; cursor?: string } = {}, ) { diff --git a/frontend/src/components/DownloadButton.tsx b/frontend/src/components/DownloadButton.tsx new file mode 100644 index 0000000..7505738 --- /dev/null +++ b/frontend/src/components/DownloadButton.tsx @@ -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 = { + 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 ✓ На сервере + } + + if (status === 'downloading') { + return {percent ?? 0}% + } + + if (status === 'queued' || status === 'postprocessing') { + return {STATUS_LABELS[status]} + } + + if (status === 'failed') { + return ( + + Ошибка + + + ) + } + + return ( + + ) +} + +export default DownloadButton diff --git a/frontend/src/components/Player.tsx b/frontend/src/components/Player.tsx new file mode 100644 index 0000000..688b462 --- /dev/null +++ b/frontend/src/components/Player.tsx @@ -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 ( + <> +
+ {/* eslint-disable-next-line jsx-a11y/media-has-caption */} +
+
● Локальная копия · mediaVM
+ + ) + } + + return ( + <> +
+