Fix download status handling and the blank page after download

- Never let a non-authoritative MeTube 'updated' event downgrade a
  terminal job (it refilled the progress bar after completion).
- Self-heal stale active jobs against MeTube history on status polls,
  so a missed event no longer leaves a job stuck in 'queued'.
- Build YT.Player on an imperatively created child div: React keeps
  owning the container, so switching to the local copy after a
  download no longer throws removeChild and blanks the page.
- Autoplay on open and seek controls; subtitle experiment reverted.
This commit is contained in:
vrubelroman 2026-09-27 23:36:09 +00:00
parent b1faeb3729
commit 43adec5224
9 changed files with 758 additions and 5 deletions

View file

@ -1,4 +1,4 @@
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
@ -128,6 +128,149 @@ def test_download_status_not_downloaded(client, db_session):
assert resp.json()["status"] == "not_downloaded"
def _seed_job_with_age(db_session, video, status="queued", age_minutes=10):
"""Seeds a job whose requested_at/updated_at are both `age_minutes` in the
past (aware datetimes -- see AGENTS.md: SQLite stores them naive, the
production code normalizes them before comparing)."""
from app.models.download_job import DownloadJob
old = datetime.now(timezone.utc) - timedelta(minutes=age_minutes)
job = DownloadJob(video_id=video.id, status=status, requested_at=old, updated_at=old)
db_session.add(job)
db_session.commit()
return job
def test_download_status_self_heals_stale_job_to_completed(client, db_session, monkeypatch):
"""Bug 1: a job stuck in 'queued' because the terminal Socket.IO event was
missed is reconciled against MeTube history on poll."""
video = _seed_single_video(db_session)
_seed_job_with_age(db_session, video, status="queued", age_minutes=10)
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}",
)
resp = client.get(f"/api/videos/{video.youtube_video_id}/download-status")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "completed"
assert body["media_url"] == "http://metube.local/download/f.mp4"
def test_download_status_self_heals_stale_job_to_failed(client, db_session, monkeypatch):
video = _seed_single_video(db_session)
_seed_job_with_age(db_session, video, status="queued", age_minutes=10)
monkeypatch.setattr(
"app.services.metube_client.MeTubeClient.fetch_history",
lambda self: {
"queue": [],
"pending": [],
"done": [{"id": "vid1.vid1", "url": video.youtube_url, "status": "error", "msg": "HTTP 429 bot check"}],
},
)
resp = client.get(f"/api/videos/{video.youtube_video_id}/download-status")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "failed"
assert body["error_message"] == "HTTP 429 bot check"
assert body["media_url"] is None
def test_download_status_stale_job_still_in_queue_stays_active(client, db_session, monkeypatch):
video = _seed_single_video(db_session)
_seed_job_with_age(db_session, video, status="queued", age_minutes=10)
monkeypatch.setattr(
"app.services.metube_client.MeTubeClient.fetch_history",
lambda self: {
"queue": [{"id": "vid1.vid1", "url": video.youtube_url, "status": "pending"}],
"pending": [],
"done": [],
},
)
resp = client.get(f"/api/videos/{video.youtube_video_id}/download-status")
assert resp.status_code == 200
assert resp.json()["status"] == "queued"
def test_download_status_stale_job_not_in_history_is_unknown(client, db_session, monkeypatch):
video = _seed_single_video(db_session)
_seed_job_with_age(db_session, video, status="queued", age_minutes=10)
monkeypatch.setattr(
"app.services.metube_client.MeTubeClient.fetch_history",
lambda self: {"queue": [], "pending": [], "done": []},
)
resp = client.get(f"/api/videos/{video.youtube_video_id}/download-status")
assert resp.status_code == 200
assert resp.json()["status"] == "unknown"
def test_download_status_fresh_job_does_not_fetch_history(client, db_session, monkeypatch):
video = _seed_single_video(db_session)
_seed_job_with_age(db_session, video, status="queued", age_minutes=0)
def fail_if_called(self):
raise AssertionError("fetch_history must not be called for a fresh job")
monkeypatch.setattr("app.services.metube_client.MeTubeClient.fetch_history", fail_if_called)
resp = client.get(f"/api/videos/{video.youtube_video_id}/download-status")
assert resp.status_code == 200
assert resp.json()["status"] == "queued"
def test_download_status_terminal_job_does_not_fetch_history(client, db_session, monkeypatch):
video = _seed_single_video(db_session)
_seed_job_with_age(db_session, video, status="completed", age_minutes=10)
def fail_if_called(self):
raise AssertionError("fetch_history must not be called for a terminal job")
monkeypatch.setattr("app.services.metube_client.MeTubeClient.fetch_history", fail_if_called)
resp = client.get(f"/api/videos/{video.youtube_video_id}/download-status")
assert resp.status_code == 200
assert resp.json()["status"] == "completed"
def test_download_status_fetch_error_keeps_status(client, db_session, monkeypatch):
"""A transient history-fetch failure must not bubble up nor change the
job's status -- the poll response keeps the last known state."""
video = _seed_single_video(db_session)
_seed_job_with_age(db_session, video, status="queued", age_minutes=10)
def raise_error(self):
raise RuntimeError("connection refused")
monkeypatch.setattr("app.services.metube_client.MeTubeClient.fetch_history", raise_error)
resp = client.get(f"/api/videos/{video.youtube_video_id}/download-status")
assert resp.status_code == 200
assert resp.json()["status"] == "queued"
def test_recheck_local_downgrades_to_unknown_when_media_missing(client, db_session, monkeypatch):
from app.models.download_job import DownloadJob