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))