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:
parent
b1faeb3729
commit
43adec5224
9 changed files with 758 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue