myYouTube/tests/test_download_jobs.py
vrubelroman e333296170 Fix download status flapping: only the 'completed' event is terminal
Production logs showed a job oscillating downloading -> completed ->
downloading -> completed -> postprocessing -> completed. Cause: yt-dlp
reports status='finished' via progress-hook ticks once per stream when
downloading separate video+audio for muxing (video lands, audio is still
in flight), and MeTube forwards that through 'updated' Socket.IO events
too -- we were mapping any status='finished' to our "completed", regardless
of which event carried it.

Only the dedicated 'completed' event (and the 'done' bucket of MeTube's
/history, for startup reconciliation) is now treated as authoritative for
terminal status; a transient 'finished' arriving via 'added'/'updated'
(or history's queue/pending) is a no-op for status, matching the
progress_percent update it also carries.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 19:09:18 +00:00

296 lines
10 KiB
Python

import json
import pytest
from app.models.channel import Channel
from app.models.download_job import DownloadJob
from app.models.video import Video
from app.services import download_jobs
def _seed_video(db_session, youtube_video_id="vid1", youtube_channel_id="chanA"):
channel = Channel(youtube_channel_id=youtube_channel_id, title="Channel", subscribed=True)
db_session.add(channel)
db_session.commit()
from datetime import datetime, timezone
video = Video(
youtube_video_id=youtube_video_id,
channel_id=channel.id,
title="Video",
published_at=datetime(2026, 9, 10, tzinfo=timezone.utc),
youtube_url=f"https://www.youtube.com/watch?v={youtube_video_id}",
)
db_session.add(video)
db_session.commit()
db_session.refresh(video)
return video
def test_request_download_enqueues_and_creates_job(monkeypatch, db_session):
video = _seed_video(db_session)
calls = {}
def fake_enqueue(self, youtube_url, custom_name_prefix):
calls["youtube_url"] = youtube_url
calls["custom_name_prefix"] = custom_name_prefix
return {"status": "ok"}
monkeypatch.setattr("app.services.metube_client.MeTubeClient.enqueue_video", fake_enqueue)
job = download_jobs.request_download(db_session, video)
assert job.status == "queued"
assert calls["youtube_url"] == video.youtube_url
assert calls["custom_name_prefix"] == video.youtube_video_id
def test_request_download_is_idempotent_while_active(monkeypatch, db_session):
video = _seed_video(db_session)
monkeypatch.setattr(
"app.services.metube_client.MeTubeClient.enqueue_video", lambda self, u, p: {"status": "ok"}
)
job1 = download_jobs.request_download(db_session, video)
job2 = download_jobs.request_download(db_session, video)
assert job1.id == job2.id
assert db_session.query(DownloadJob).count() == 1
def test_request_download_raises_on_metube_error(monkeypatch, db_session):
video = _seed_video(db_session)
monkeypatch.setattr(
"app.services.metube_client.MeTubeClient.enqueue_video",
lambda self, u, p: {"status": "error", "msg": "boom"},
)
with pytest.raises(download_jobs.MeTubeRejected):
download_jobs.request_download(db_session, video)
assert db_session.query(DownloadJob).count() == 0
def test_request_download_allows_retry_after_failure(monkeypatch, db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="failed", error_message="oops")
db_session.add(job)
db_session.commit()
monkeypatch.setattr(
"app.services.metube_client.MeTubeClient.enqueue_video", lambda self, u, p: {"status": "ok"}
)
new_job = download_jobs.request_download(db_session, video)
assert new_job.id != job.id
assert db_session.query(DownloadJob).count() == 2
@pytest.mark.asyncio
async def test_handle_metube_event_added_and_updated(db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="queued")
db_session.add(job)
db_session.commit()
added_payload = json.dumps({"id": "vid1.vid1", "url": video.youtube_url, "status": "pending"})
await download_jobs.handle_metube_event(db_session, "added", added_payload)
db_session.refresh(job)
assert job.metube_job_id == "vid1.vid1"
assert job.status == "queued"
updating_payload = json.dumps(
{"id": "vid1.vid1", "url": video.youtube_url, "status": "downloading", "percent": 42.5}
)
await download_jobs.handle_metube_event(db_session, "updated", updating_payload)
db_session.refresh(job)
assert job.status == "downloading"
assert job.progress_percent == 42
assert job.started_at is not None
@pytest.mark.asyncio
async def test_handle_metube_event_completed_builds_media_url(monkeypatch, db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="downloading", metube_job_id="vid1.vid1")
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}",
)
payload = json.dumps(
{"id": "vid1.vid1", "url": video.youtube_url, "status": "finished", "filename": "vid1.vid1.mp4"}
)
await download_jobs.handle_metube_event(db_session, "completed", payload)
db_session.refresh(job)
assert job.status == "completed"
assert job.progress_percent == 100
assert job.metube_filename == "vid1.vid1.mp4"
assert job.media_url == "http://metube.local/download/vid1.vid1.mp4"
assert job.completed_at is not None
@pytest.mark.asyncio
async def test_transient_finished_in_updated_event_does_not_complete_job(db_session):
"""Regression test for a real bug seen in production logs: yt-dlp reports
status='finished' via 'updated' events once per stream when merging
separate video+audio (finished for video, then still downloading audio),
which used to flip our job to 'completed' prematurely and then back to
'downloading', flapping the UI. Only the dedicated 'completed' event may
mark a job completed."""
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="downloading", metube_job_id="vid1.vid1", progress_percent=80)
db_session.add(job)
db_session.commit()
# video stream finished (transient) -- must NOT become "completed"
await download_jobs.handle_metube_event(
db_session,
"updated",
json.dumps({"id": "vid1.vid1", "url": video.youtube_url, "status": "finished"}),
)
db_session.refresh(job)
assert job.status == "downloading"
# audio stream still downloading
await download_jobs.handle_metube_event(
db_session,
"updated",
json.dumps({"id": "vid1.vid1", "url": video.youtube_url, "status": "downloading", "percent": 10}),
)
db_session.refresh(job)
assert job.status == "downloading"
assert job.progress_percent == 10
# ffmpeg merge
await download_jobs.handle_metube_event(
db_session,
"updated",
json.dumps({"id": "vid1.vid1", "url": video.youtube_url, "status": "postprocessing"}),
)
db_session.refresh(job)
assert job.status == "postprocessing"
# only the dedicated 'completed' event finalizes it
await download_jobs.handle_metube_event(
db_session,
"completed",
json.dumps(
{"id": "vid1.vid1", "url": video.youtube_url, "status": "finished", "filename": "vid1.vid1.mp4"}
),
)
db_session.refresh(job)
assert job.status == "completed"
@pytest.mark.asyncio
async def test_handle_metube_event_error_marks_failed(db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="downloading", metube_job_id="vid1.vid1")
db_session.add(job)
db_session.commit()
payload = json.dumps({"id": "vid1.vid1", "url": video.youtube_url, "status": "error", "msg": "network blip"})
await download_jobs.handle_metube_event(db_session, "updated", payload)
db_session.refresh(job)
assert job.status == "failed"
assert job.error_message == "network blip"
@pytest.mark.asyncio
async def test_handle_metube_event_ignores_unrelated_download(db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="queued")
db_session.add(job)
db_session.commit()
payload = json.dumps(
{"id": "other.other", "url": "https://www.youtube.com/watch?v=other", "status": "finished"}
)
await download_jobs.handle_metube_event(db_session, "completed", payload)
db_session.refresh(job)
assert job.status == "queued"
@pytest.mark.asyncio
async def test_handle_metube_event_canceled_marks_failed(db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="downloading", metube_job_id="vid1.vid1")
db_session.add(job)
db_session.commit()
await download_jobs.handle_metube_event(db_session, "canceled", json.dumps("vid1.vid1"))
db_session.refresh(job)
assert job.status == "failed"
def test_reconcile_marks_unknown_when_history_unavailable(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_on_startup(db_session)
db_session.refresh(job)
assert job.status == "unknown"
def test_reconcile_restores_completed_from_history(monkeypatch, db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="downloading")
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_on_startup(db_session)
db_session.refresh(job)
assert job.status == "completed"
assert job.media_url == "http://metube.local/download/f.mp4"
def test_reconcile_leaves_completed_jobs_untouched(monkeypatch, db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="completed", media_url="http://x/f.mp4")
db_session.add(job)
db_session.commit()
def fail_if_called(self):
raise AssertionError("should not fetch history for completed jobs")
monkeypatch.setattr("app.services.metube_client.MeTubeClient.fetch_history", fail_if_called)
download_jobs.reconcile_on_startup(db_session)
db_session.refresh(job)
assert job.status == "completed"
assert job.media_url == "http://x/f.mp4"