Merge pull request #301 from arabcoders/dev
Fix premiere video re-queue.
This commit is contained in:
commit
4c76aa7123
7 changed files with 155 additions and 68 deletions
|
|
@ -5,13 +5,15 @@ import logging
|
|||
import time
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from email.utils import formatdate, parsedate_to_datetime
|
||||
from email.utils import formatdate
|
||||
from pathlib import Path
|
||||
from sqlite3 import Connection
|
||||
|
||||
import yt_dlp
|
||||
from aiohttp import web
|
||||
|
||||
from app.library.ag_utils import ag
|
||||
|
||||
from .AsyncPool import AsyncPool
|
||||
from .conditions import Conditions
|
||||
from .config import Config
|
||||
|
|
@ -32,6 +34,7 @@ from .Utils import (
|
|||
extract_ytdlp_logs,
|
||||
is_downloaded,
|
||||
load_cookies,
|
||||
str_to_dt,
|
||||
)
|
||||
from .YTDLPOpts import YTDLPOpts
|
||||
|
||||
|
|
@ -109,14 +112,14 @@ class DownloadQueue(metaclass=Singleton):
|
|||
|
||||
Scheduler.get_instance().add(
|
||||
timer="* * * * *",
|
||||
func=self.monitor_stale,
|
||||
id=f"{__class__.__name__}.{__class__.monitor_stale.__name__}",
|
||||
func=self._check_for_stale,
|
||||
id=f"{__class__.__name__}.{__class__._check_for_stale.__name__}",
|
||||
)
|
||||
|
||||
Scheduler.get_instance().add(
|
||||
timer="* * * * *",
|
||||
func=self.monitor_queue_live,
|
||||
id=f"{__class__.__name__}.{__class__.monitor_queue_live.__name__}",
|
||||
func=self._check_live,
|
||||
id=f"{__class__.__name__}.{__class__._check_live.__name__}",
|
||||
)
|
||||
# app.on_shutdown.append(self.on_shutdown)
|
||||
|
||||
|
|
@ -242,11 +245,16 @@ class DownloadQueue(metaclass=Singleton):
|
|||
live_in: str | None = None
|
||||
is_premiere: bool = bool(entry.get("is_premiere", False))
|
||||
|
||||
release_in: str | None = None
|
||||
if entry.get("release_timestamp"):
|
||||
release_in = formatdate(entry.get("release_timestamp"), usegmt=True)
|
||||
item.extras["release_in"] = release_in
|
||||
|
||||
# check if the video is live stream.
|
||||
if "live_status" in entry and "is_upcoming" == entry.get("live_status"):
|
||||
if entry.get("release_timestamp"):
|
||||
live_in = formatdate(entry.get("release_timestamp"), usegmt=True)
|
||||
item.extras.update({"live_in": live_in})
|
||||
if "is_upcoming" == entry.get("live_status"):
|
||||
if release_in:
|
||||
live_in = release_in
|
||||
item.extras["live_in"] = live_in
|
||||
else:
|
||||
error = f"No start time is set for {'premiere' if is_premiere else 'live stream'}."
|
||||
else:
|
||||
|
|
@ -324,12 +332,11 @@ class DownloadQueue(metaclass=Singleton):
|
|||
if filtered_logs := extract_ytdlp_logs(logs):
|
||||
text_logs = f" Logs: {', '.join(filtered_logs)}"
|
||||
|
||||
if live_in or "is_upcoming" == entry.get("live_status"):
|
||||
if "is_upcoming" == entry.get("live_status"):
|
||||
NotifyEvent = Events.COMPLETED
|
||||
dlInfo.info.status = "not_live"
|
||||
dlInfo.info.msg = (
|
||||
f"{'Premiere video' if is_premiere else 'Live Stream' } is not available yet." + text_logs
|
||||
)
|
||||
dlInfo.info.msg = f"{'Premiere video' if is_premiere else 'Stream' } is not available yet." + text_logs
|
||||
await self._notify.emit(Events.LOG_INFO, data=event_info(dlInfo.info.msg, {"lowPriority": True}))
|
||||
itemDownload: Download = self.done.put(dlInfo)
|
||||
elif len(entry.get("formats", [])) < 1:
|
||||
availability: str = entry.get("availability", "public")
|
||||
|
|
@ -343,25 +350,36 @@ class DownloadQueue(metaclass=Singleton):
|
|||
NotifyEvent = Events.COMPLETED
|
||||
await self._notify.emit(Events.LOG_WARNING, data=event_warning(f"No formats found for '{dl.title}'."))
|
||||
elif is_premiere and self.config.prevent_live_premiere:
|
||||
dlInfo.info.error = (
|
||||
f"Premiering right now. Delaying download by '{300+dl.extras.get('duration',0)}' seconds."
|
||||
)
|
||||
_live_in = live_in or item.extras.get("live_in", None)
|
||||
if _live_in:
|
||||
starts_in = parsedate_to_datetime(live_in)
|
||||
starts_in = starts_in.replace(tzinfo=UTC) if starts_in.tzinfo is None else starts_in.astimezone(UTC)
|
||||
starts_in = starts_in + timedelta(minutes=5, seconds=dl.extras.get("duration", 0))
|
||||
dlInfo.info.error += f" Starts in {starts_in.isoformat()}."
|
||||
dlInfo.info.error = "Premiering right now."
|
||||
|
||||
dlInfo.info.status = "not_live"
|
||||
itemDownload = self.done.put(dlInfo)
|
||||
NotifyEvent = Events.COMPLETED
|
||||
await self._notify.emit(
|
||||
Events.LOG_INFO,
|
||||
data=event_info(
|
||||
f"'{dl.title}' is premiering. Download delayed by '{300+dl.extras.get('duration')}'s."
|
||||
),
|
||||
)
|
||||
_requeue = True
|
||||
if release_in:
|
||||
try:
|
||||
starts_in = str_to_dt(release_in)
|
||||
starts_in = (
|
||||
starts_in.replace(tzinfo=UTC) if starts_in.tzinfo is None else starts_in.astimezone(UTC)
|
||||
)
|
||||
starts_in = starts_in + timedelta(minutes=5, seconds=dl.extras.get("duration", 0))
|
||||
dlInfo.info.error += f" Download will start at {starts_in.isoformat()}."
|
||||
_requeue = False
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to parse live_in date '{release_in}'. {e!s}")
|
||||
dlInfo.info.error += f" Failed to parse live_in date '{release_in}'."
|
||||
else:
|
||||
dlInfo.info.error += f" Delaying download by '{300+dl.extras.get('duration',0)}' seconds."
|
||||
|
||||
if _requeue:
|
||||
NotifyEvent = Events.ADDED
|
||||
itemDownload = self.queue.put(dlInfo)
|
||||
self.event.set()
|
||||
else:
|
||||
dlInfo.info.status = "not_live"
|
||||
itemDownload = self.done.put(dlInfo)
|
||||
NotifyEvent = Events.COMPLETED
|
||||
await self._notify.emit(
|
||||
Events.LOG_INFO,
|
||||
data=event_info(f"'{dl.title}' is {dlInfo.info.error}.", {"lowPriority": True}),
|
||||
)
|
||||
else:
|
||||
NotifyEvent = Events.ADDED
|
||||
itemDownload = self.queue.put(dlInfo)
|
||||
|
|
@ -788,9 +806,9 @@ class DownloadQueue(metaclass=Singleton):
|
|||
|
||||
return is_downloaded(file, url)
|
||||
|
||||
async def monitor_stale(self):
|
||||
async def _check_for_stale(self):
|
||||
"""
|
||||
Monitor the queue and pool for stale downloads and cancel them if needed.
|
||||
Monitor pool for stale downloads and cancel them if needed.
|
||||
"""
|
||||
if self.is_paused():
|
||||
return
|
||||
|
|
@ -856,7 +874,7 @@ class DownloadQueue(metaclass=Singleton):
|
|||
LOG.error(f"Failed to cancel staled item '{item_ref}' from worker pool. {e!s}")
|
||||
LOG.exception(e)
|
||||
|
||||
async def monitor_queue_live(self):
|
||||
async def _check_live(self):
|
||||
"""
|
||||
Monitor the queue for items marked as live events and queue them when time is reached.
|
||||
"""
|
||||
|
|
@ -878,26 +896,28 @@ class DownloadQueue(metaclass=Singleton):
|
|||
LOG.debug(f"Item '{item_ref}' is not a live stream.")
|
||||
continue
|
||||
|
||||
live_in: str | None = item.info.live_in or item.info.extras.get("live_in", None)
|
||||
if not live_in:
|
||||
LOG.debug(f"Item '{item_ref}' marked as live stream, but no date is set.")
|
||||
continue
|
||||
|
||||
starts_in = parsedate_to_datetime(live_in)
|
||||
starts_in = starts_in.replace(tzinfo=UTC) if starts_in.tzinfo is None else starts_in.astimezone(UTC)
|
||||
|
||||
if time_now < (starts_in + timedelta(minutes=1)):
|
||||
LOG.debug(f"Item '{item_ref}' is not yet live. will start in '{dt_delta(starts_in-time_now)}'.")
|
||||
continue
|
||||
|
||||
duration: int | None = item.info.extras.get("duration", None)
|
||||
is_premiere: bool = item.info.extras.get("is_premiere", False)
|
||||
|
||||
if is_premiere and duration and self.config.prevent_live_premiere:
|
||||
live_in: str | None = item.info.live_in or ag(item.info.extras, ["live_in", "release_in"], None)
|
||||
if not live_in:
|
||||
LOG.debug(
|
||||
f"Item '{item_ref}' marked as {'premiere video' if is_premiere else 'live stream'}, but no date is set."
|
||||
)
|
||||
continue
|
||||
|
||||
starts_in = str_to_dt(live_in)
|
||||
starts_in = starts_in.replace(tzinfo=UTC) if starts_in.tzinfo is None else starts_in.astimezone(UTC)
|
||||
|
||||
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
|
||||
|
||||
if self.config.prevent_live_premiere and is_premiere and duration:
|
||||
premiere_ends: datetime = starts_in + timedelta(minutes=5, seconds=duration)
|
||||
if time_now < premiere_ends:
|
||||
LOG.debug(
|
||||
f"Item '{item_ref}' is premiering and download is delayed by '{300+duration}' seconds. Will start at '{premiere_ends.isoformat()}'"
|
||||
f"Item '{item_ref}' is premiering, download will start in '{(starts_in.astimezone() + timedelta(minutes=5, seconds=duration)).isoformat()}'"
|
||||
)
|
||||
continue
|
||||
|
||||
|
|
|
|||
|
|
@ -1291,3 +1291,36 @@ def parse_tags(text: str) -> tuple[str, dict[str, str | bool]]:
|
|||
return ""
|
||||
|
||||
return TAG_REGEX.sub(replacer, text).strip(), tags
|
||||
|
||||
|
||||
def str_to_dt(time_str: str, now=None) -> datetime:
|
||||
"""
|
||||
Convert a string representation of time into a datetime object.
|
||||
|
||||
Args:
|
||||
time_str (str): The string representation of time.
|
||||
now (datetime, optional): The base datetime to use for relative times. Defaults to None, which uses the current UTC time.
|
||||
|
||||
Returns:
|
||||
datetime: A datetime object representing the parsed time.
|
||||
|
||||
Raises:
|
||||
ValueError: If the time string cannot be parsed.
|
||||
|
||||
"""
|
||||
from dateparser import parse as _parse
|
||||
|
||||
dt = _parse(
|
||||
time_str,
|
||||
settings={
|
||||
"RELATIVE_BASE": now or datetime.now(tz=UTC),
|
||||
"RETURN_AS_TIMEZONE_AWARE": True,
|
||||
"TO_TIMEZONE": "UTC",
|
||||
},
|
||||
)
|
||||
|
||||
if dt is None:
|
||||
msg = f"Couldn't parse date: {time_str!r}"
|
||||
raise ValueError(msg)
|
||||
|
||||
return dt
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ dependencies = [
|
|||
"python-magic-bin; sys_platform == 'win32'",
|
||||
# Non‐Windows
|
||||
"python-magic>=0.4.27; sys_platform != 'win32'",
|
||||
|
||||
# Cross‐platform
|
||||
"python-socketio>=5.11.1",
|
||||
"aiohttp>=3.9.3",
|
||||
|
|
@ -34,6 +33,7 @@ dependencies = [
|
|||
"pycryptodome",
|
||||
"yt-dlp",
|
||||
"platformdirs",
|
||||
"dateparser>=1.2.1",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@
|
|||
</label>
|
||||
</td>
|
||||
<td class="is-text-overflow is-vcentered">
|
||||
<div class="is-inline is-pulled-right" v-if="item.extras?.duration">
|
||||
<div class="is-inline is-pulled-right" v-if="item.extras?.duration">
|
||||
<span class="tag is-info" v-if="item.extras?.duration">
|
||||
{{ formatTime(item.extras.duration) }}
|
||||
</span>
|
||||
|
|
@ -134,7 +134,8 @@
|
|||
</td>
|
||||
<td class="is-vcentered has-text-centered is-unselectable">
|
||||
<span class="icon-text">
|
||||
<span class="icon" :class="setIconColor(item)"><i :class="setIcon(item)" /></span>
|
||||
<span class="icon" :class="setIconColor(item)"><i
|
||||
:class="[setIcon(item), is_queued(item)]" /></span>
|
||||
<span>{{ setStatus(item) }}</span>
|
||||
</span>
|
||||
</td>
|
||||
|
|
@ -292,7 +293,7 @@
|
|||
</div>
|
||||
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
|
||||
<span class="icon-text">
|
||||
<span class="icon" :class="setIconColor(item)"><i :class="setIcon(item)" /></span>
|
||||
<span class="icon" :class="setIconColor(item)"><i :class="[setIcon(item), is_queued(item)]" /></span>
|
||||
<span>{{ setStatus(item) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -842,4 +843,12 @@ const removeFromArchive = async (item, opts) => {
|
|||
socket.emit('item_delete', { id: item._id, remove_file: false })
|
||||
}
|
||||
}
|
||||
|
||||
const is_queued = item => {
|
||||
if (!item?.status || 'not_live' !== item.status) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return item.live_in || item.extras?.live_in || item.extras?.release_in ? 'fa-spin' : ''
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -14,10 +14,11 @@ export interface Notification {
|
|||
export interface notificationOptions {
|
||||
timeout?: number,
|
||||
force?: boolean,
|
||||
store?: boolean,
|
||||
closeOnClick?: boolean,
|
||||
position?: POSITION
|
||||
onClick?: (closeToast: Function) => void
|
||||
store?: boolean,
|
||||
lowPriority?: boolean
|
||||
}
|
||||
|
||||
const allowToast = useStorage<boolean>('allow_toasts', true)
|
||||
|
|
@ -28,15 +29,20 @@ const toast = useToast()
|
|||
function notify(type: notificationType, message: string, opts?: notificationOptions): void {
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
if (!opts) {
|
||||
opts = {}
|
||||
}
|
||||
|
||||
let id: string = ''
|
||||
const force = opts?.force || false;
|
||||
const store = opts?.store || true;
|
||||
const lowPriority = opts?.lowPriority || false;
|
||||
|
||||
if (store && notificationStore) {
|
||||
if (notificationStore && (store || true === lowPriority)) {
|
||||
id = notificationStore.add(type, message, false)
|
||||
}
|
||||
|
||||
if (false === allowToast.value && false === force) {
|
||||
if (true === lowPriority || (false === allowToast.value && false === force)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -44,14 +50,6 @@ function notify(type: notificationType, message: string, opts?: notificationOpti
|
|||
return;
|
||||
}
|
||||
|
||||
if (!opts) {
|
||||
opts = {}
|
||||
}
|
||||
|
||||
if (opts?.force) {
|
||||
delete opts.force
|
||||
}
|
||||
|
||||
opts.closeOnClick = toastDismissOnClick.value
|
||||
opts.position = toastPosition.value ?? POSITION.TOP_RIGHT
|
||||
opts.onClick = (closeToast: Function) => {
|
||||
|
|
@ -88,5 +86,6 @@ export default function useNotification() {
|
|||
success: (message: string, opts?: notificationOptions) => notify('success', message, opts),
|
||||
warning: (message: string, opts?: notificationOptions) => notify('warning', message, opts),
|
||||
error: (message: string, opts?: notificationOptions) => notify('error', message, opts),
|
||||
notify
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,27 +51,27 @@ export const useSocketStore = defineStore('socket', () => {
|
|||
|
||||
socket.value.on('error', stream => {
|
||||
const json = JSON.parse(stream);
|
||||
toast.error(`${json.data?.id ?? json?.type}: ${json?.message}`);
|
||||
toast.error(`${json.data?.id ?? json?.type}: ${json?.message}`, json.data || {});
|
||||
});
|
||||
|
||||
socket.value.on('log_info', stream => {
|
||||
const json = JSON.parse(stream);
|
||||
toast.info(json?.message);
|
||||
toast.info(json?.message, json.data || {});
|
||||
});
|
||||
|
||||
socket.value.on('log_success', stream => {
|
||||
const json = JSON.parse(stream);
|
||||
toast.success(json?.message);
|
||||
toast.success(json?.message, json.data || {});
|
||||
});
|
||||
|
||||
socket.value.on('log_warning', stream => {
|
||||
const json = JSON.parse(stream);
|
||||
toast.warning(json?.message);
|
||||
toast.warning(json?.message, json.data || {});
|
||||
});
|
||||
|
||||
socket.value.on('log_error', stream => {
|
||||
const json = JSON.parse(stream);
|
||||
toast.error(json?.message);
|
||||
toast.error(json?.message, json.data || {});
|
||||
});
|
||||
|
||||
socket.value.on('completed', stream => {
|
||||
|
|
|
|||
26
uv.lock
26
uv.lock
|
|
@ -352,6 +352,21 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/cb/16/773159bbac84807f0be78d19b832f109bf8bd46f0bc2d79a21849d0c385f/curl_cffi-0.7.1-cp38-abi3-win_amd64.whl", hash = "sha256:0eb5b08f562749639529e6990ff1b10a40e53ed45115e15f00b239230eabb927", size = 3990082 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dateparser"
|
||||
version = "1.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "pytz" },
|
||||
{ name = "regex" },
|
||||
{ name = "tzlocal" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/3f/d3207a05f5b6a78c66d86631e60bfba5af163738a599a5b9aa2c2737a09e/dateparser-1.2.1.tar.gz", hash = "sha256:7e4919aeb48481dbfc01ac9683c8e20bfe95bb715a38c1e9f6af889f4f30ccc3", size = 309924 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/0a/981c438c4cd84147c781e4e96c1d72df03775deb1bc76c5a6ee8afa89c62/dateparser-1.2.1-py3-none-any.whl", hash = "sha256:bdcac262a467e6260030040748ad7c10d6bacd4f3b9cdb4cfd2251939174508c", size = 295658 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "debugpy"
|
||||
version = "1.8.14"
|
||||
|
|
@ -1062,6 +1077,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/cd/f1/bfb6811df4745f92f14c47a29e50e89a36b1533130fcc56452d4660bd2d6/pythonnet-3.0.5-py3-none-any.whl", hash = "sha256:f6702d694d5d5b163c9f3f5cc34e0bed8d6857150237fae411fefb883a656d20", size = 297506 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytz"
|
||||
version = "2025.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywebview"
|
||||
version = "5.4"
|
||||
|
|
@ -1352,6 +1376,7 @@ dependencies = [
|
|||
{ name = "caribou" },
|
||||
{ name = "coloredlogs" },
|
||||
{ name = "curl-cffi" },
|
||||
{ name = "dateparser" },
|
||||
{ name = "debugpy" },
|
||||
{ name = "httpx" },
|
||||
{ name = "mutagen" },
|
||||
|
|
@ -1387,6 +1412,7 @@ requires-dist = [
|
|||
{ name = "caribou", specifier = ">=0.3.0" },
|
||||
{ name = "coloredlogs", specifier = ">=15.0.1" },
|
||||
{ name = "curl-cffi", specifier = "==0.7.1" },
|
||||
{ name = "dateparser", specifier = ">=1.2.1" },
|
||||
{ name = "debugpy", specifier = ">=1.8.1" },
|
||||
{ name = "httpx" },
|
||||
{ name = "mutagen" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue