Merge pull request #301 from arabcoders/dev

Fix premiere video re-queue.
This commit is contained in:
Abdulmohsen 2025-06-15 20:00:15 +03:00 committed by GitHub
commit 4c76aa7123
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 155 additions and 68 deletions

View file

@ -5,13 +5,15 @@ import logging
import time import time
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from email.utils import formatdate, parsedate_to_datetime from email.utils import formatdate
from pathlib import Path from pathlib import Path
from sqlite3 import Connection from sqlite3 import Connection
import yt_dlp import yt_dlp
from aiohttp import web from aiohttp import web
from app.library.ag_utils import ag
from .AsyncPool import AsyncPool from .AsyncPool import AsyncPool
from .conditions import Conditions from .conditions import Conditions
from .config import Config from .config import Config
@ -32,6 +34,7 @@ from .Utils import (
extract_ytdlp_logs, extract_ytdlp_logs,
is_downloaded, is_downloaded,
load_cookies, load_cookies,
str_to_dt,
) )
from .YTDLPOpts import YTDLPOpts from .YTDLPOpts import YTDLPOpts
@ -109,14 +112,14 @@ class DownloadQueue(metaclass=Singleton):
Scheduler.get_instance().add( Scheduler.get_instance().add(
timer="* * * * *", timer="* * * * *",
func=self.monitor_stale, func=self._check_for_stale,
id=f"{__class__.__name__}.{__class__.monitor_stale.__name__}", id=f"{__class__.__name__}.{__class__._check_for_stale.__name__}",
) )
Scheduler.get_instance().add( Scheduler.get_instance().add(
timer="* * * * *", timer="* * * * *",
func=self.monitor_queue_live, func=self._check_live,
id=f"{__class__.__name__}.{__class__.monitor_queue_live.__name__}", id=f"{__class__.__name__}.{__class__._check_live.__name__}",
) )
# app.on_shutdown.append(self.on_shutdown) # app.on_shutdown.append(self.on_shutdown)
@ -242,11 +245,16 @@ class DownloadQueue(metaclass=Singleton):
live_in: str | None = None live_in: str | None = None
is_premiere: bool = bool(entry.get("is_premiere", False)) 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. # check if the video is live stream.
if "live_status" in entry and "is_upcoming" == entry.get("live_status"): if "is_upcoming" == entry.get("live_status"):
if entry.get("release_timestamp"): if release_in:
live_in = formatdate(entry.get("release_timestamp"), usegmt=True) live_in = release_in
item.extras.update({"live_in": live_in}) item.extras["live_in"] = live_in
else: else:
error = f"No start time is set for {'premiere' if is_premiere else 'live stream'}." error = f"No start time is set for {'premiere' if is_premiere else 'live stream'}."
else: else:
@ -324,12 +332,11 @@ class DownloadQueue(metaclass=Singleton):
if filtered_logs := extract_ytdlp_logs(logs): if filtered_logs := extract_ytdlp_logs(logs):
text_logs = f" Logs: {', '.join(filtered_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 NotifyEvent = Events.COMPLETED
dlInfo.info.status = "not_live" dlInfo.info.status = "not_live"
dlInfo.info.msg = ( dlInfo.info.msg = f"{'Premiere video' if is_premiere else 'Stream' } is not available yet." + text_logs
f"{'Premiere video' if is_premiere else 'Live 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) itemDownload: Download = self.done.put(dlInfo)
elif len(entry.get("formats", [])) < 1: elif len(entry.get("formats", [])) < 1:
availability: str = entry.get("availability", "public") availability: str = entry.get("availability", "public")
@ -343,25 +350,36 @@ class DownloadQueue(metaclass=Singleton):
NotifyEvent = Events.COMPLETED NotifyEvent = Events.COMPLETED
await self._notify.emit(Events.LOG_WARNING, data=event_warning(f"No formats found for '{dl.title}'.")) 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: elif is_premiere and self.config.prevent_live_premiere:
dlInfo.info.error = ( dlInfo.info.error = "Premiering right now."
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.status = "not_live" _requeue = True
itemDownload = self.done.put(dlInfo) if release_in:
NotifyEvent = Events.COMPLETED try:
await self._notify.emit( starts_in = str_to_dt(release_in)
Events.LOG_INFO, starts_in = (
data=event_info( starts_in.replace(tzinfo=UTC) if starts_in.tzinfo is None else starts_in.astimezone(UTC)
f"'{dl.title}' is premiering. Download delayed by '{300+dl.extras.get('duration')}'s." )
), 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: else:
NotifyEvent = Events.ADDED NotifyEvent = Events.ADDED
itemDownload = self.queue.put(dlInfo) itemDownload = self.queue.put(dlInfo)
@ -788,9 +806,9 @@ class DownloadQueue(metaclass=Singleton):
return is_downloaded(file, url) 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(): if self.is_paused():
return 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.error(f"Failed to cancel staled item '{item_ref}' from worker pool. {e!s}")
LOG.exception(e) 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. 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.") LOG.debug(f"Item '{item_ref}' is not a live stream.")
continue 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) duration: int | None = item.info.extras.get("duration", None)
is_premiere: bool = item.info.extras.get("is_premiere", False) 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) premiere_ends: datetime = starts_in + timedelta(minutes=5, seconds=duration)
if time_now < premiere_ends: if time_now < premiere_ends:
LOG.debug( 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 continue

View file

@ -1291,3 +1291,36 @@ def parse_tags(text: str) -> tuple[str, dict[str, str | bool]]:
return "" return ""
return TAG_REGEX.sub(replacer, text).strip(), tags 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

View file

@ -12,7 +12,6 @@ dependencies = [
"python-magic-bin; sys_platform == 'win32'", "python-magic-bin; sys_platform == 'win32'",
# NonWindows # NonWindows
"python-magic>=0.4.27; sys_platform != 'win32'", "python-magic>=0.4.27; sys_platform != 'win32'",
# Crossplatform # Crossplatform
"python-socketio>=5.11.1", "python-socketio>=5.11.1",
"aiohttp>=3.9.3", "aiohttp>=3.9.3",
@ -34,6 +33,7 @@ dependencies = [
"pycryptodome", "pycryptodome",
"yt-dlp", "yt-dlp",
"platformdirs", "platformdirs",
"dateparser>=1.2.1",
] ]
[tool.ruff] [tool.ruff]

View file

@ -107,7 +107,7 @@
</label> </label>
</td> </td>
<td class="is-text-overflow is-vcentered"> <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"> <span class="tag is-info" v-if="item.extras?.duration">
{{ formatTime(item.extras.duration) }} {{ formatTime(item.extras.duration) }}
</span> </span>
@ -134,7 +134,8 @@
</td> </td>
<td class="is-vcentered has-text-centered is-unselectable"> <td class="is-vcentered has-text-centered is-unselectable">
<span class="icon-text"> <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>{{ setStatus(item) }}</span>
</span> </span>
</td> </td>
@ -292,7 +293,7 @@
</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">
<span class="icon-text"> <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>{{ setStatus(item) }}</span>
</span> </span>
</div> </div>
@ -842,4 +843,12 @@ const removeFromArchive = async (item, opts) => {
socket.emit('item_delete', { id: item._id, remove_file: false }) 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> </script>

View file

@ -14,10 +14,11 @@ export interface Notification {
export interface notificationOptions { export interface notificationOptions {
timeout?: number, timeout?: number,
force?: boolean, force?: boolean,
store?: boolean,
closeOnClick?: boolean, closeOnClick?: boolean,
position?: POSITION position?: POSITION
onClick?: (closeToast: Function) => void onClick?: (closeToast: Function) => void
store?: boolean,
lowPriority?: boolean
} }
const allowToast = useStorage<boolean>('allow_toasts', true) const allowToast = useStorage<boolean>('allow_toasts', true)
@ -28,15 +29,20 @@ const toast = useToast()
function notify(type: notificationType, message: string, opts?: notificationOptions): void { function notify(type: notificationType, message: string, opts?: notificationOptions): void {
const notificationStore = useNotificationStore() const notificationStore = useNotificationStore()
if (!opts) {
opts = {}
}
let id: string = '' let id: string = ''
const force = opts?.force || false; const force = opts?.force || false;
const store = opts?.store || true; const store = opts?.store || true;
const lowPriority = opts?.lowPriority || false;
if (store && notificationStore) { if (notificationStore && (store || true === lowPriority)) {
id = notificationStore.add(type, message, false) id = notificationStore.add(type, message, false)
} }
if (false === allowToast.value && false === force) { if (true === lowPriority || (false === allowToast.value && false === force)) {
return; return;
} }
@ -44,14 +50,6 @@ function notify(type: notificationType, message: string, opts?: notificationOpti
return; return;
} }
if (!opts) {
opts = {}
}
if (opts?.force) {
delete opts.force
}
opts.closeOnClick = toastDismissOnClick.value opts.closeOnClick = toastDismissOnClick.value
opts.position = toastPosition.value ?? POSITION.TOP_RIGHT opts.position = toastPosition.value ?? POSITION.TOP_RIGHT
opts.onClick = (closeToast: Function) => { opts.onClick = (closeToast: Function) => {
@ -88,5 +86,6 @@ export default function useNotification() {
success: (message: string, opts?: notificationOptions) => notify('success', message, opts), success: (message: string, opts?: notificationOptions) => notify('success', message, opts),
warning: (message: string, opts?: notificationOptions) => notify('warning', message, opts), warning: (message: string, opts?: notificationOptions) => notify('warning', message, opts),
error: (message: string, opts?: notificationOptions) => notify('error', message, opts), error: (message: string, opts?: notificationOptions) => notify('error', message, opts),
notify
} }
} }

View file

@ -51,27 +51,27 @@ export const useSocketStore = defineStore('socket', () => {
socket.value.on('error', stream => { socket.value.on('error', stream => {
const json = JSON.parse(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 => { socket.value.on('log_info', stream => {
const json = JSON.parse(stream); const json = JSON.parse(stream);
toast.info(json?.message); toast.info(json?.message, json.data || {});
}); });
socket.value.on('log_success', stream => { socket.value.on('log_success', stream => {
const json = JSON.parse(stream); const json = JSON.parse(stream);
toast.success(json?.message); toast.success(json?.message, json.data || {});
}); });
socket.value.on('log_warning', stream => { socket.value.on('log_warning', stream => {
const json = JSON.parse(stream); const json = JSON.parse(stream);
toast.warning(json?.message); toast.warning(json?.message, json.data || {});
}); });
socket.value.on('log_error', stream => { socket.value.on('log_error', stream => {
const json = JSON.parse(stream); const json = JSON.parse(stream);
toast.error(json?.message); toast.error(json?.message, json.data || {});
}); });
socket.value.on('completed', stream => { socket.value.on('completed', stream => {

26
uv.lock
View file

@ -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 }, { 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]] [[package]]
name = "debugpy" name = "debugpy"
version = "1.8.14" 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 }, { 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]] [[package]]
name = "pywebview" name = "pywebview"
version = "5.4" version = "5.4"
@ -1352,6 +1376,7 @@ dependencies = [
{ name = "caribou" }, { name = "caribou" },
{ name = "coloredlogs" }, { name = "coloredlogs" },
{ name = "curl-cffi" }, { name = "curl-cffi" },
{ name = "dateparser" },
{ name = "debugpy" }, { name = "debugpy" },
{ name = "httpx" }, { name = "httpx" },
{ name = "mutagen" }, { name = "mutagen" },
@ -1387,6 +1412,7 @@ requires-dist = [
{ name = "caribou", specifier = ">=0.3.0" }, { name = "caribou", specifier = ">=0.3.0" },
{ name = "coloredlogs", specifier = ">=15.0.1" }, { name = "coloredlogs", specifier = ">=15.0.1" },
{ name = "curl-cffi", specifier = "==0.7.1" }, { name = "curl-cffi", specifier = "==0.7.1" },
{ name = "dateparser", specifier = ">=1.2.1" },
{ name = "debugpy", specifier = ">=1.8.1" }, { name = "debugpy", specifier = ">=1.8.1" },
{ name = "httpx" }, { name = "httpx" },
{ name = "mutagen" }, { name = "mutagen" },