diff --git a/app/library/DownloadQueue.py b/app/library/DownloadQueue.py index 721b02ae..ff5cb171 100644 --- a/app/library/DownloadQueue.py +++ b/app/library/DownloadQueue.py @@ -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 diff --git a/app/library/Utils.py b/app/library/Utils.py index 11376d44..a5182c78 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 502e460e..0d9e1cd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/ui/components/History.vue b/ui/components/History.vue index 42c97606..822650f9 100644 --- a/ui/components/History.vue +++ b/ui/components/History.vue @@ -107,7 +107,7 @@ -
+
{{ formatTime(item.extras.duration) }} @@ -134,7 +134,8 @@ - + {{ setStatus(item) }} @@ -292,7 +293,7 @@
- + {{ setStatus(item) }}
@@ -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' : '' +} diff --git a/ui/composables/useNotification.ts b/ui/composables/useNotification.ts index 62d2a79b..eb252a74 100644 --- a/ui/composables/useNotification.ts +++ b/ui/composables/useNotification.ts @@ -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('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 } } diff --git a/ui/stores/SocketStore.js b/ui/stores/SocketStore.js index 8aafdd51..3b9b628e 100644 --- a/ui/stores/SocketStore.js +++ b/ui/stores/SocketStore.js @@ -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 => { diff --git a/uv.lock b/uv.lock index bcb35940..29e646ff 100644 --- a/uv.lock +++ b/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" },