Automatically re-queue upcoming live streams in history.

This commit is contained in:
arabcoders 2025-04-22 16:52:52 +03:00
parent cdcb5d61be
commit 0b79b6f6aa
4 changed files with 101 additions and 13 deletions

View file

@ -52,6 +52,7 @@
"tmpfilename", "tmpfilename",
"upgrader", "upgrader",
"urandom", "urandom",
"usegmt",
"vcodec", "vcodec",
"vconvert", "vconvert",
"writedescription", "writedescription",

View file

@ -5,8 +5,8 @@ import logging
import os import os
import time import time
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
from email.utils import formatdate from email.utils import formatdate, parsedate_to_datetime
from pathlib import Path from pathlib import Path
from sqlite3 import Connection from sqlite3 import Connection
@ -24,7 +24,7 @@ from .ItemDTO import Item, ItemDTO
from .Presets import Presets from .Presets import Presets
from .Scheduler import Scheduler from .Scheduler import Scheduler
from .Singleton import Singleton from .Singleton import Singleton
from .Utils import arg_converter, calc_download_path, extract_info, is_downloaded, load_cookies from .Utils import arg_converter, calc_download_path, dt_delta, extract_info, is_downloaded, load_cookies
from .YTDLPOpts import YTDLPOpts from .YTDLPOpts import YTDLPOpts
LOG = logging.getLogger("DownloadQueue") LOG = logging.getLogger("DownloadQueue")
@ -95,9 +95,14 @@ class DownloadQueue(metaclass=Singleton):
Scheduler.get_instance().add( Scheduler.get_instance().add(
timer="* * * * *", timer="* * * * *",
func=self.monitor_queue_for_stale_items, func=self.monitor_queue_for_stale_items,
id=f"{__class__.__name__}.monitor_queue_for_stale_items", id=f"{__class__.__name__}.{__class__.monitor_queue_for_stale_items.__name__}",
) )
Scheduler.get_instance().add(
timer="* * * * *",
func=self.monitor_queue_live,
id=f"{__class__.__name__}.{__class__.monitor_queue_live.__name__}",
)
# app.on_shutdown.append(self.on_shutdown) # app.on_shutdown.append(self.on_shutdown)
# async def close_pool(_: web.Application): # async def close_pool(_: web.Application):
@ -243,8 +248,8 @@ class DownloadQueue(metaclass=Singleton):
if ("video" == eventType or eventType.startswith("url")) and "id" in entry and "title" in entry: if ("video" == eventType or eventType.startswith("url")) and "id" in entry and "title" in entry:
# check if the video is live stream. # check if the video is live stream.
if "live_status" in entry and "is_upcoming" == entry.get("live_status"): if "live_status" in entry and "is_upcoming" == entry.get("live_status"):
if "release_timestamp" in entry and entry.get("release_timestamp"): if entry.get("release_timestamp"):
live_in = formatdate(entry.get("release_timestamp")) live_in = formatdate(entry.get("release_timestamp"), usegmt=True)
else: else:
error = "Live stream not yet started. And no date is set." error = "Live stream not yet started. And no date is set."
else: else:
@ -752,7 +757,7 @@ class DownloadQueue(metaclass=Singleton):
""" """
Monitor the queue for stale items and cancel them if needed. Monitor the queue for stale items and cancel them if needed.
""" """
if self.is_paused() or not self.queue.has_downloads(): if self.is_paused() or self.queue.empty():
return return
LOG.debug("Checking for stale items in the download queue.") LOG.debug("Checking for stale items in the download queue.")
@ -767,3 +772,59 @@ class DownloadQueue(metaclass=Singleton):
await self.cancel([id]) await self.cancel([id])
except Exception as e: except Exception as e:
LOG.error(f"Failed to cancel staled item '{item_ref}'. {e!s}") LOG.error(f"Failed to cancel staled item '{item_ref}'. {e!s}")
async def monitor_queue_live(self):
"""
Monitor the queue for items marked as live events and queue them when time is reached.
"""
if self.is_paused() or self.done.empty():
return
LOG.debug("Checking for live stream items in the history queue.")
time_now = datetime.now(tz=UTC)
status = ["not_live", "is_upcoming", "is_live"]
for id, item in list(self.done.items()):
if item.info.status not in status:
continue
item_ref = f"{id=} {item.info.id=} {item.info.title=}"
if not item.is_live:
LOG.debug(f"Item '{item_ref}' is not a live stream.")
continue
if not item.info.live_in:
LOG.debug(f"Item '{item_ref}' marked as live stream, but no date is set.")
continue
starts_in = parsedate_to_datetime(item.info.live_in)
if time_now < (starts_in + timedelta(minutes=1)):
LOG.debug(f"Item '{item_ref}' is not yet live. will start at '{dt_delta(starts_in-time_now)}'.")
continue
LOG.info(f"Re-queuing item '{item_ref}' for download.")
try:
await self.clear([item.info._id], remove_file=False)
except Exception as e:
LOG.error(f"Failed to clear item '{item_ref}'. {e!s}")
continue
try:
info = item.info
new_queue = Item(
url=info.url,
preset=info.preset,
folder=info.folder,
cookies=info.cookies,
template=info.template,
cli=item.info.cli,
extras=item.info.extras,
)
await self.add(item=new_queue)
except Exception as e:
LOG.error(f"Failed to re-queue item '{item_ref}'. {e!s}")

View file

@ -1,6 +1,5 @@
import base64 import base64
import copy import copy
import datetime
import glob import glob
import ipaddress import ipaddress
import json import json
@ -11,6 +10,7 @@ import re
import shlex import shlex
import socket import socket
import uuid import uuid
from datetime import UTC, datetime, timedelta
from functools import lru_cache from functools import lru_cache
from http.cookiejar import MozillaCookieJar from http.cookiejar import MozillaCookieJar
from typing import Any from typing import Any
@ -68,7 +68,7 @@ class StreamingError(Exception):
class FileLogFormatter(logging.Formatter): class FileLogFormatter(logging.Formatter):
def formatTime(self, record, datefmt=None): # noqa: ARG002, N802 def formatTime(self, record, datefmt=None): # noqa: ARG002, N802
return datetime.datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds") return datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds")
def calc_download_path(base_path: str, folder: str | None = None, create_path: bool = True) -> str: def calc_download_path(base_path: str, folder: str | None = None, create_path: bool = True) -> str:
@ -844,8 +844,8 @@ def get_files(base_path: str, dir: str | None = None):
"path": str(file).replace(base_path, "").strip("/"), "path": str(file).replace(base_path, "").strip("/"),
"size": stat.st_size, "size": stat.st_size,
"mime": get_mime_type({}, file) if file.is_file() else "directory", "mime": get_mime_type({}, file) if file.is_file() else "directory",
"mtime": datetime.datetime.fromtimestamp(stat.st_mtime, tz=datetime.UTC).isoformat(), "mtime": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(),
"ctime": datetime.datetime.fromtimestamp(stat.st_ctime, tz=datetime.UTC).isoformat(), "ctime": datetime.fromtimestamp(stat.st_ctime, tz=UTC).isoformat(),
"is_dir": file.is_dir(), "is_dir": file.is_dir(),
"is_file": file.is_file(), "is_file": file.is_file(),
} }
@ -1089,3 +1089,27 @@ def get_archive_id(url: str) -> tuple[bool, dict[str | None, str | None, str | N
break break
return idDict return idDict
def dt_delta(delta: timedelta) -> str:
"""
Turn a timedelta into 1d 20h 5min style output.
"""
total_secs = int(delta.total_seconds())
days, rem = divmod(total_secs, 86400)
hours, rem = divmod(rem, 3600)
minutes = rem // 60
parts = []
if days:
parts.append(f"{days}d")
if hours:
parts.append(f"{hours}h")
if minutes:
parts.append(f"{minutes}min")
# if it's under one minute:
if not parts:
parts.append("0min")
return " ".join(parts)

View file

@ -131,8 +131,9 @@
</td> </td>
<td class="is-vcentered has-text-centered is-unselectable" <td class="is-vcentered has-text-centered is-unselectable"
v-if="item.live_in && 'not_live' === item.status"> v-if="item.live_in && 'not_live' === item.status">
<span class="icon"><i class="fa-solid fa-spin fa-rotate-right" /></span>
<span :date-datetime="item.live_in" class="user-hint" <span :date-datetime="item.live_in" class="user-hint"
v-tooltip="'Starts at: ' + moment(item.live_in).format('YYYY-M-DD H:mm Z')"> v-tooltip="'Will automatically be requeued at: ' + moment(item.live_in).format('YYYY-M-DD H:mm Z')">
{{ moment(item.live_in).fromNow() }} {{ moment(item.live_in).fromNow() }}
</span> </span>
</td> </td>
@ -258,8 +259,9 @@
</div> </div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable" <div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable"
v-if="item.live_in && 'not_live' === item.status"> v-if="item.live_in && 'not_live' === item.status">
<span class="icon"><i class="fa-solid fa-spin fa-rotate-right" /></span>
<span :date-datetime="item.live_in" class="user-hint" <span :date-datetime="item.live_in" class="user-hint"
v-tooltip="'Starts at: ' + moment(item.live_in).format('YYYY-M-DD H:mm Z')"> v-tooltip="'Will automatically be requeued at: ' + moment(item.live_in).format('YYYY-M-DD H:mm Z')">
{{ moment(item.live_in).fromNow() }} {{ moment(item.live_in).fromNow() }}
</span> </span>
</div> </div>