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,10 +1,10 @@
|
|||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.download_job import ACTIVE_STATUSES, DownloadJob
|
||||
from app.models.download_job import ACTIVE_STATUSES, TERMINAL_STATUSES, DownloadJob
|
||||
from app.models.video import Video
|
||||
from app.services.metube_client import METUBE_STATUS_MAP, MeTubeClient
|
||||
|
||||
|
|
@ -206,6 +206,14 @@ def _apply_metube_info(job: DownloadJob, info: dict, *, authoritative: bool) ->
|
|||
if isinstance(percent, (int, float)):
|
||||
job.progress_percent = int(percent)
|
||||
|
||||
# Late, delayed 'updated' events must never roll a terminal job back (or
|
||||
# forward into 'failed'): Socket.IO delivers events out of order, so a
|
||||
# lagging progress tick often arrives after the authoritative 'completed'.
|
||||
# Progress/metube_job_id above are still taken; the status (and its
|
||||
# error_message/completed_at/media_url) is left alone.
|
||||
if not authoritative and job.status in TERMINAL_STATUSES:
|
||||
return
|
||||
|
||||
if our_status == "failed":
|
||||
job.status = "failed"
|
||||
job.error_message = info.get("msg") or info.get("error") or "Ошибка загрузки"
|
||||
|
|
@ -266,3 +274,74 @@ def reconcile_on_startup(db: Session) -> None:
|
|||
|
||||
db.commit()
|
||||
logger.info("Reconciled %d download job(s) against MeTube history", len(jobs))
|
||||
|
||||
|
||||
def _as_utc(value: datetime) -> datetime | None:
|
||||
"""SQLite stores/returns naive datetimes regardless of the column's
|
||||
timezone=True, while Postgres returns aware ones -- normalize before any
|
||||
comparison with datetime.now(timezone.utc)."""
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def job_is_stale(job: DownloadJob, threshold_minutes: int = 5) -> bool:
|
||||
"""True when an active job had no events for a while: both requested_at and
|
||||
updated_at are older than the threshold. The updated_at leg implements the
|
||||
'reconcile once per window' backoff -- while events keep flowing, updated_at
|
||||
stays fresh and no MeTube history fetch is needed."""
|
||||
if job.status not in ACTIVE_STATUSES:
|
||||
return False
|
||||
now = datetime.now(timezone.utc)
|
||||
threshold = timedelta(minutes=threshold_minutes)
|
||||
requested_at = _as_utc(job.requested_at)
|
||||
updated_at = _as_utc(job.updated_at)
|
||||
if requested_at is None or updated_at is None:
|
||||
return False
|
||||
return now - requested_at > threshold and now - updated_at > threshold
|
||||
|
||||
|
||||
def reconcile_stale_job(db: Session, job: DownloadJob) -> None:
|
||||
"""One-shot lazy self-heal for an active job whose Socket.IO events stopped
|
||||
(the backend may have missed the terminal event entirely). Mirrors
|
||||
reconcile_on_startup's matching, but is called from the download-status
|
||||
endpoint while the service is running:
|
||||
- 'done' entry is authoritative (finished -> completed, error -> failed);
|
||||
- 'queue'/'pending' entries keep the job active (still in flight);
|
||||
- no entry at all -> 'unknown' (UI shows a retry action);
|
||||
- history fetch failure is transient here (not a restart) -> status is left
|
||||
untouched, so the polling client keeps its last known state.
|
||||
Always touches updated_at and commits: that is the backoff that prevents
|
||||
the 2s polling loop from hammering /history (next reconcile only after
|
||||
the stale window has passed again)."""
|
||||
client = MeTubeClient()
|
||||
try:
|
||||
history = client.fetch_history()
|
||||
except Exception:
|
||||
logger.warning("Could not fetch MeTube history to self-heal stale job %s", job.id, exc_info=True)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return
|
||||
|
||||
# (item, authoritative) -- only the 'done' bucket is a terminal, trustworthy
|
||||
# outcome; 'queue'/'pending' are still in flight (see handle_metube_event).
|
||||
by_url: dict[str, tuple[dict, bool]] = {}
|
||||
for bucket, authoritative in (("queue", False), ("pending", False), ("done", True)):
|
||||
for item in history.get(bucket, []) or []:
|
||||
url = item.get("url")
|
||||
if url:
|
||||
by_url[url] = (item, authoritative)
|
||||
|
||||
video = db.get(Video, job.video_id)
|
||||
match = by_url.get(video.youtube_url) if video else None
|
||||
if match is None:
|
||||
job.status = "unknown"
|
||||
else:
|
||||
info, authoritative = match
|
||||
_apply_metube_info(job, info, authoritative=authoritative)
|
||||
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
logger.info("Self-healed stale download job %s to status=%s", job.id, job.status)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue