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,5 @@
import json
from datetime import datetime, timedelta, timezone
import pytest
@ -389,3 +390,267 @@ def test_reconcile_leaves_completed_jobs_untouched(monkeypatch, db_session):
db_session.refresh(job)
assert job.status == "completed"
assert job.media_url == "http://x/f.mp4"
# --- Bug 2: non-authoritative 'updated' events must never roll terminal
# statuses back (double progress bar bug) ---------------------------------
@pytest.mark.asyncio
async def test_updated_event_does_not_roll_back_completed_job(db_session):
"""A late 'updated' event arriving after the authoritative 'completed' must
not flip the job back to downloading/postprocessing/failed -- that used to
re-trigger the progress bar after completion. Progress ticks are still
taken; status and its payload (media_url/completed_at/error_message) are
left alone."""
video = _seed_video(db_session)
completed_at = datetime(2026, 9, 20, tzinfo=timezone.utc)
job = DownloadJob(
video_id=video.id,
status="completed",
metube_job_id="vid1.vid1",
media_url="http://metube.local/download/f.mp4",
progress_percent=100,
completed_at=completed_at,
)
db_session.add(job)
db_session.commit()
await download_jobs.handle_metube_event(
db_session,
"updated",
json.dumps({"id": "vid1.vid1", "url": video.youtube_url, "status": "downloading", "percent": 12}),
)
db_session.refresh(job)
assert job.status == "completed"
assert job.progress_percent == 12
assert job.media_url == "http://metube.local/download/f.mp4"
assert job.completed_at is not None
assert job.error_message is None
await download_jobs.handle_metube_event(
db_session,
"updated",
json.dumps({"id": "vid1.vid1", "url": video.youtube_url, "status": "postprocessing", "percent": 55}),
)
db_session.refresh(job)
assert job.status == "completed"
assert job.progress_percent == 55
assert job.media_url == "http://metube.local/download/f.mp4"
# a late 'error' tick must not turn it into failed either
await download_jobs.handle_metube_event(
db_session,
"updated",
json.dumps({"id": "vid1.vid1", "url": video.youtube_url, "status": "error", "msg": "late blip"}),
)
db_session.refresh(job)
assert job.status == "completed"
assert job.media_url == "http://metube.local/download/f.mp4"
assert job.completed_at is not None
assert job.error_message is None
@pytest.mark.asyncio
async def test_updated_event_does_not_roll_back_failed_job(db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="failed", metube_job_id="vid1.vid1", error_message="boom")
db_session.add(job)
db_session.commit()
await download_jobs.handle_metube_event(
db_session,
"updated",
json.dumps({"id": "vid1.vid1", "url": video.youtube_url, "status": "downloading", "percent": 30}),
)
db_session.refresh(job)
assert job.status == "failed"
assert job.error_message == "boom"
@pytest.mark.asyncio
async def test_updated_event_does_not_touch_deleted_job(db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="deleted")
db_session.add(job)
db_session.commit()
await download_jobs.handle_metube_event(
db_session,
"updated",
json.dumps({"id": "vid1.vid1", "url": video.youtube_url, "status": "postprocessing", "percent": 10}),
)
db_session.refresh(job)
assert job.status == "deleted"
assert job.media_url is None
@pytest.mark.asyncio
async def test_authoritative_completed_still_finalizes_queued_job(monkeypatch, db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="queued")
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}",
)
await download_jobs.handle_metube_event(
db_session,
"completed",
json.dumps({"id": "vid1.vid1", "url": video.youtube_url, "status": "finished", "filename": "f.mp4"}),
)
db_session.refresh(job)
assert job.status == "completed"
assert job.media_url == "http://metube.local/download/f.mp4"
# --- Bug 1: stale-job detection and lazy self-heal --------------------------
def test_job_is_stale_requires_both_timestamps_old(db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="queued")
db_session.add(job)
db_session.commit()
now = datetime.now(timezone.utc)
old = now - timedelta(minutes=10)
job.requested_at = old
job.updated_at = old
assert download_jobs.job_is_stale(job) is True
# events still flowing: fresh updated_at means no reconcile needed
job.updated_at = now
assert download_jobs.job_is_stale(job) is False
# terminal jobs are never reconciled
job.updated_at = old
job.status = "completed"
assert download_jobs.job_is_stale(job) is False
def test_reconcile_stale_job_completed_from_done(monkeypatch, db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="queued")
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_stale_job(db_session, job)
db_session.refresh(job)
assert job.status == "completed"
assert job.media_url == "http://metube.local/download/f.mp4"
assert job.progress_percent == 100
def test_reconcile_stale_job_failed_from_done_error(monkeypatch, db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="queued")
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": "error", "msg": "HTTP 429 bot check"}],
},
)
download_jobs.reconcile_stale_job(db_session, job)
db_session.refresh(job)
assert job.status == "failed"
assert job.error_message == "HTTP 429 bot check"
def test_reconcile_stale_job_keeps_active_when_still_in_queue(monkeypatch, db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="queued")
db_session.add(job)
db_session.commit()
monkeypatch.setattr(
"app.services.metube_client.MeTubeClient.fetch_history",
lambda self: {
"queue": [{"id": "vid1.vid1", "url": video.youtube_url, "status": "pending"}],
"pending": [],
"done": [],
},
)
download_jobs.reconcile_stale_job(db_session, job)
db_session.refresh(job)
assert job.status == "queued"
def test_reconcile_stale_job_unknown_when_not_in_history(monkeypatch, db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="queued")
db_session.add(job)
db_session.commit()
monkeypatch.setattr(
"app.services.metube_client.MeTubeClient.fetch_history",
lambda self: {"queue": [], "pending": [], "done": []},
)
download_jobs.reconcile_stale_job(db_session, job)
db_session.refresh(job)
assert job.status == "unknown"
def test_reconcile_stale_job_fetch_error_leaves_status(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_stale_job(db_session, job)
db_session.refresh(job)
assert job.status == "downloading"
def test_reconcile_stale_job_touches_updated_at_for_backoff(monkeypatch, db_session):
"""updated_at is bumped on every reconcile attempt (success or failure),
so the 2s polling loop triggers at most one history fetch per window."""
video = _seed_video(db_session)
old = datetime.now(timezone.utc) - timedelta(minutes=10)
job = DownloadJob(video_id=video.id, status="queued", requested_at=old, updated_at=old)
db_session.add(job)
db_session.commit()
monkeypatch.setattr(
"app.services.metube_client.MeTubeClient.fetch_history",
lambda self: {"queue": [], "pending": [], "done": []},
)
assert download_jobs.job_is_stale(job) is True
download_jobs.reconcile_stale_job(db_session, job)
assert download_jobs.job_is_stale(job) is False