Merge pull request #155 from arabcoders/dev

Added the ability to pause download pool
This commit is contained in:
Abdulmohsen 2024-12-31 18:26:28 +03:00 committed by GitHub
commit e415edd761
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 161 additions and 90 deletions

View file

@ -1,4 +1,5 @@
import asyncio import asyncio
import datetime
import json import json
import logging import logging
import os import os
@ -22,6 +23,7 @@ TYPE_QUEUE: str = "queue"
class DownloadQueue: class DownloadQueue:
paused: asyncio.Event
event: asyncio.Event | None = None event: asyncio.Event | None = None
pool: AsyncPool | None = None pool: AsyncPool | None = None
@ -32,6 +34,8 @@ class DownloadQueue:
self.queue = DataStore(type=TYPE_QUEUE, connection=connection) self.queue = DataStore(type=TYPE_QUEUE, connection=connection)
self.done.load() self.done.load()
self.queue.load() self.queue.load()
self.paused = asyncio.Event()
self.paused.set()
async def test(self) -> bool: async def test(self) -> bool:
await self.done.test() await self.done.test()
@ -39,12 +43,23 @@ class DownloadQueue:
async def initialize(self): async def initialize(self):
self.event = asyncio.Event() self.event = asyncio.Event()
LOG.info(f"Using {self.config.max_workers} workers for downloading.") LOG.info(
f"Using '{self.config.max_workers}' worker/s for downloading. Can be configured via `YTP_MAX_WORKERS` environment variable."
asyncio.create_task(
self.__download_pool() if self.config.max_workers > 1 else self.__download(),
name="download_pool" if self.config.max_workers > 1 else "download_worker",
) )
asyncio.create_task(self.__download_pool(), name="download_pool")
def pause(self):
if self.paused.is_set():
self.paused.clear()
LOG.warning(f"Download paused at. {datetime.datetime.now().isoformat()}")
def resume(self):
if not self.paused.is_set():
self.paused.set()
LOG.warning(f"Downloading resumed at. {datetime.datetime.now().isoformat()}")
def isPaused(self) -> bool:
return False if self.paused.is_set() else True
async def __add_entry( async def __add_entry(
self, self,
@ -61,8 +76,8 @@ class DownloadQueue:
options: dict = {} options: dict = {}
error: str = None error: str | None = None
live_in: str = None live_in: str | None = None
eventType = entry.get("_type") or "video" eventType = entry.get("_type") or "video"
if eventType == "playlist": if eventType == "playlist":
@ -109,13 +124,13 @@ class DownloadQueue:
f"Entry id '{entry.get('id', None)}' url '{entry.get('webpage_url', None)} - {entry.get('url', None)}'." f"Entry id '{entry.get('id', None)}' url '{entry.get('webpage_url', None)} - {entry.get('url', None)}'."
) )
if self.done.exists(key=entry["id"], url=entry.get("webpage_url") or entry.get("url")): if self.done.exists(key=entry["id"], url=str(entry.get("webpage_url") or entry.get("url"))):
item = self.done.get(key=entry["id"], url=entry.get("webpage_url") or entry["url"]) item = self.done.get(key=entry["id"], url=entry.get("webpage_url") or entry["url"])
LOG.warning(f"Item '{item.info.id}' - '{item.info.title}' already downloaded. Removing from history.") LOG.warning(f"Item '{item.info.id}' - '{item.info.title}' already downloaded. Removing from history.")
await self.clear([item.info._id], remove_file=False) await self.clear([item.info._id], remove_file=False)
try: try:
item = self.queue.get(key=entry.get("id"), url=entry.get("webpage_url") or entry.get("url")) item = self.queue.get(key=str(entry.get("id")), url=str(entry.get("webpage_url") or entry.get("url")))
if item is not None: if item is not None:
err_message = f"Item ID '{item.info.id}' - '{item.info.title}' already in download queue." err_message = f"Item ID '{item.info.id}' - '{item.info.title}' already in download queue."
LOG.info(err_message) LOG.info(err_message)
@ -127,7 +142,7 @@ class DownloadQueue:
options.update({"is_manifestless": is_manifestless}) options.update({"is_manifestless": is_manifestless})
live_status: list = ["is_live", "is_upcoming"] live_status: list = ["is_live", "is_upcoming"]
is_live = entry.get("is_live", None) or live_in or entry.get("live_status", None) in live_status is_live = bool(entry.get("is_live", None) or live_in or entry.get("live_status", None) in live_status)
try: try:
download_dir = calcDownloadPath(basePath=self.config.download_path, folder=folder) download_dir = calcDownloadPath(basePath=self.config.download_path, folder=folder)
@ -142,9 +157,9 @@ class DownloadQueue:
extras[field] = entry.get(field) extras[field] = entry.get(field)
dl = ItemDTO( dl = ItemDTO(
id=entry.get("id"), id=str(entry.get("id")),
title=entry.get("title"), title=str(entry.get("title")),
url=entry.get("webpage_url") or entry.get("url"), url=str(entry.get("webpage_url") or entry.get("url")),
preset=preset, preset=preset,
thumbnail=entry.get("thumbnail", None), thumbnail=entry.get("thumbnail", None),
folder=folder, folder=folder,
@ -164,7 +179,7 @@ class DownloadQueue:
for property, value in entry.items(): for property, value in entry.items():
if property.startswith("playlist"): if property.startswith("playlist"):
dl.output_template = dl.output_template.replace(f"%({property})s", str(value)) dl.output_template = str(dl.output_template).replace(f"%({property})s", str(value))
dlInfo: Download = Download(info=dl, info_dict=entry, debug=bool(self.config.ytdl_debug)) dlInfo: Download = Download(info=dl, info_dict=entry, debug=bool(self.config.ytdl_debug))
@ -180,7 +195,8 @@ class DownloadQueue:
else: else:
NotifyEvent = "added" NotifyEvent = "added"
itemDownload = self.queue.put(dlInfo) itemDownload = self.queue.put(dlInfo)
self.event.set() if self.event:
self.event.set()
asyncio.create_task( asyncio.create_task(
self.emitter.emit(NotifyEvent, itemDownload.info), name=f"notifier-{NotifyEvent}-{itemDownload.info.id}" self.emitter.emit(NotifyEvent, itemDownload.info), name=f"notifier-{NotifyEvent}-{itemDownload.info.id}"
@ -189,7 +205,7 @@ class DownloadQueue:
return {"status": "ok"} return {"status": "ok"}
elif eventType.startswith("url"): elif eventType.startswith("url"):
return await self.add( return await self.add(
url=entry.get("url"), url=str(entry.get("url")),
preset=preset, preset=preset,
folder=folder, folder=folder,
ytdlp_config=ytdlp_config, ytdlp_config=ytdlp_config,
@ -233,7 +249,7 @@ class DownloadQueue:
try: try:
downloaded, id_dict = self.isDownloaded(url) downloaded, id_dict = self.isDownloaded(url)
if downloaded is True: if downloaded is True and id_dict:
message = f"This url with ID '{id_dict.get('id')}' has been downloaded already and recorded in archive." message = f"This url with ID '{id_dict.get('id')}' has been downloaded already and recorded in archive."
LOG.info(message) LOG.info(message)
return {"status": "error", "msg": message} return {"status": "error", "msg": message}
@ -304,9 +320,11 @@ class DownloadQueue:
await item.close() await item.close()
LOG.debug(f"Deleting from queue {itemMessage}") LOG.debug(f"Deleting from queue {itemMessage}")
self.queue.delete(id) self.queue.delete(id)
asyncio.create_task(self.emitter.canceled(id=id, dl=item), name=f"notifier-c-{id}") asyncio.create_task(self.emitter.canceled(id=id, dl=item.info.serialize()), name=f"notifier-c-{id}")
item.info.status = "canceled"
item.info.error = "Canceled by user."
self.done.put(item) self.done.put(item)
asyncio.create_task(self.emitter.completed(dl=item), name=f"notifier-d-{id}") asyncio.create_task(self.emitter.completed(dl=item.info.serialize()), name=f"notifier-d-{id}")
LOG.info(f"Deleted from queue {itemMessage}") LOG.info(f"Deleted from queue {itemMessage}")
status[id] = "ok" status[id] = "ok"
@ -330,7 +348,7 @@ class DownloadQueue:
filename: str = "" filename: str = ""
if remove_file and self.config.remove_files and "finished" == item.info.status: if remove_file and self.config.remove_files and "finished" == item.info.status:
filename = item.info.filename filename = str(item.info.filename)
if item.info.folder: if item.info.folder:
filename = f"{item.info.folder}/{item.info.filename}" filename = f"{item.info.folder}/{item.info.filename}"
@ -349,7 +367,7 @@ class DownloadQueue:
LOG.error(f"Unable to remove '{itemRef}' local file '{filename}'. {str(e)}") LOG.error(f"Unable to remove '{itemRef}' local file '{filename}'. {str(e)}")
self.done.delete(id) self.done.delete(id)
asyncio.create_task(self.emitter.cleared(id, dl=item), name=f"notifier-c-{id}") asyncio.create_task(self.emitter.cleared(id, dl=item.info.serialize()), name=f"notifier-c-{id}")
msg = f"Deleted completed download '{itemRef}'." msg = f"Deleted completed download '{itemRef}'."
if fileDeleted and filename: if fileDeleted and filename:
msg += f" and removed local file '{filename}'." msg += f" and removed local file '{filename}'."
@ -395,16 +413,22 @@ class DownloadQueue:
while True: while True:
if self.pool.has_open_workers() is True: if self.pool.has_open_workers() is True:
break break
if time.time() - lastLog > 120: if self.config.max_workers > 1 and time.time() - lastLog > 120:
lastLog = time.time() lastLog = time.time()
LOG.info(f"Waiting for worker to be free. {self.pool.get_workers_status()}") LOG.info(f"Waiting for worker to be free. {self.pool.get_workers_status()}")
await asyncio.sleep(1) await asyncio.sleep(1)
while not self.queue.hasDownloads(): while not self.queue.hasDownloads():
LOG.info(f"Waiting for item to download. '{self.pool.get_available_workers()}' free workers.") LOG.info(f"Waiting for item to download. '{self.pool.get_available_workers()}' free workers.")
await self.event.wait() if self.event:
self.event.clear() await self.event.wait()
LOG.debug("Cleared wait event.") self.event.clear()
LOG.debug("Cleared wait event.")
if self.paused and isinstance(self.paused, asyncio.Event):
LOG.info("Download pool is paused.")
await self.paused.wait()
LOG.info("Download pool resumed downloading.")
entry = self.queue.getNextDownload() entry = self.queue.getNextDownload()
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
@ -419,16 +443,6 @@ class DownloadQueue:
LOG.debug(f"Pushed {entry=} to executor.") LOG.debug(f"Pushed {entry=} to executor.")
await asyncio.sleep(1) await asyncio.sleep(1)
async def __download(self):
while True:
while self.queue.empty():
LOG.info("Waiting for item to download.")
await self.event.wait()
self.event.clear()
id, entry = self.queue.next()
await self.__downloadFile(id, entry)
async def __downloadFile(self, id: str, entry: Download): async def __downloadFile(self, id: str, entry: Download):
LOG.info( LOG.info(
f"Downloading 'id: {id}', 'Title: {entry.info.title}', 'URL: {entry.info.url}' to 'folder: {entry.info.folder}'." f"Downloading 'id: {id}', 'Title: {entry.info.title}', 'URL: {entry.info.url}' to 'folder: {entry.info.folder}'."
@ -453,16 +467,17 @@ class DownloadQueue:
self.queue.delete(key=id) self.queue.delete(key=id)
if entry.is_canceled() is True: if entry.is_canceled() is True:
asyncio.create_task(self.emitter.canceled(id, dl=entry.info), name=f"notifier-c-{id}") asyncio.create_task(self.emitter.canceled(id, dl=entry.info.serialize()), name=f"notifier-c-{id}")
entry.info.status = "canceled" entry.info.status = "canceled"
entry.info.error = "Canceled by user." entry.info.error = "Canceled by user."
self.done.put(value=entry) self.done.put(value=entry)
asyncio.create_task(self.emitter.completed(dl=entry.info), name=f"notifier-d-{id}") asyncio.create_task(self.emitter.completed(dl=entry.info.serialize()), name=f"notifier-d-{id}")
self.event.set() if self.event:
self.event.set()
def isDownloaded(self, url: str) -> tuple[bool, dict[str | None, str | None, str | None]]: def isDownloaded(self, url: str) -> tuple[bool, dict | None]:
if not url or not self.config.keep_archive: if not url or not self.config.keep_archive:
return False, None return False, None

View file

@ -9,9 +9,9 @@ class Emitter:
This class is used to emit events to the registered emitters. This class is used to emit events to the registered emitters.
""" """
emitters: list[callable] = [] emitters: list[callable] = [] # type: ignore
def add_emitter(self, emitter: callable): def add_emitter(self, emitter: callable): # type: ignore
""" """
Add an emitter to the list of emitters. Add an emitter to the list of emitters.
@ -29,10 +29,10 @@ class Emitter:
async def completed(self, dl: dict, **kwargs): async def completed(self, dl: dict, **kwargs):
await self.emit("completed", dl, **kwargs) await self.emit("completed", dl, **kwargs)
async def canceled(self, id: str, dl: dict = None, **kwargs): async def canceled(self, id: str, dl: dict | None = None, **kwargs):
await self.emit("canceled", id, **kwargs) await self.emit("canceled", id, **kwargs)
async def cleared(self, id: str, dl: dict = None, **kwargs): async def cleared(self, id: str, dl: dict | None = None, **kwargs):
await self.emit("cleared", id, **kwargs) await self.emit("cleared", id, **kwargs)
async def error(self, message: str, data: dict = {}, **kwargs): async def error(self, message: str, data: dict = {}, **kwargs):

View file

@ -6,6 +6,7 @@ import os
import pty import pty
import shlex import shlex
from datetime import datetime from datetime import datetime
import time
import socketio import socketio
from aiohttp import web from aiohttp import web
@ -25,8 +26,8 @@ class HttpSocket(common):
This class is used to handle WebSocket events. This class is used to handle WebSocket events.
""" """
config: Config = None config: Config
sio: socketio.AsyncServer = None sio: socketio.AsyncServer
def __init__(self, queue: DownloadQueue, emitter: Emitter, encoder: Encoder): def __init__(self, queue: DownloadQueue, emitter: Emitter, encoder: Encoder):
super().__init__(queue=queue, encoder=encoder) super().__init__(queue=queue, encoder=encoder)
@ -39,16 +40,16 @@ class HttpSocket(common):
self.queue = queue self.queue = queue
self.emitter = emitter self.emitter = emitter
def ws_event(func): def ws_event(func): # type: ignore
""" """
Decorator to mark a method as a socket event. Decorator to mark a method as a socket event.
""" """
@functools.wraps(func) @functools.wraps(func) # type: ignore
async def wrapper(*args, **kwargs): async def wrapper(*args, **kwargs):
return await func(*args, **kwargs) return await func(*args, **kwargs) # type: ignore
wrapper._ws_event = func.__name__ wrapper._ws_event = func.__name__ # type: ignore
return wrapper return wrapper
def attach(self, app: web.Application): def attach(self, app: web.Application):
@ -56,11 +57,10 @@ class HttpSocket(common):
for attr_name in dir(self): for attr_name in dir(self):
method = getattr(self, attr_name) method = getattr(self, attr_name)
if hasattr(method, "_ws_event"): if hasattr(method, "_ws_event") and self.sio:
event = method._ws_event self.sio.on(method._ws_event)(method) # type: ignore
self.sio.on(event)(method)
@ws_event @ws_event # type: ignore
async def cli_post(self, sid: str, data): async def cli_post(self, sid: str, data):
if not data: if not data:
await self.emitter.emit("cli_close", {"exitcode": 0}, to=sid) await self.emitter.emit("cli_close", {"exitcode": 0}, to=sid)
@ -153,12 +153,12 @@ class HttpSocket(common):
) )
await self.emitter.emit("cli_close", {"exitcode": -1}, to=sid) await self.emitter.emit("cli_close", {"exitcode": -1}, to=sid)
@ws_event @ws_event # type: ignore
async def add_url(self, sid: str, data: dict): async def add_url(self, sid: str, data: dict):
url: str = data.get("url") url: str | None = data.get("url")
if not url: if not url:
self.emitter.warning("No URL provided.", to=sid) await self.emitter.warning("No URL provided.", to=sid)
return return
preset: str = data.get("preset", "default") preset: str = data.get("preset", "default")
@ -180,7 +180,7 @@ class HttpSocket(common):
await self.emitter.emit("status", status, to=sid) await self.emitter.emit("status", status, to=sid)
@ws_event @ws_event # type: ignore
async def item_cancel(self, sid: str, id: str): async def item_cancel(self, sid: str, id: str):
if not id: if not id:
await self.emitter.warning("Invalid request.", to=sid) await self.emitter.warning("Invalid request.", to=sid)
@ -192,13 +192,13 @@ class HttpSocket(common):
await self.emitter.emit("item_cancel", status) await self.emitter.emit("item_cancel", status)
@ws_event @ws_event # type: ignore
async def item_delete(self, sid: str, data: dict): async def item_delete(self, sid: str, data: dict):
if not data: if not data:
await self.emitter.warning("Invalid request.", to=sid) await self.emitter.warning("Invalid request.", to=sid)
return return
id: str = data.get("id") id: str | None = data.get("id")
if not id: if not id:
await self.emitter.warning("Invalid request.", to=sid) await self.emitter.warning("Invalid request.", to=sid)
return return
@ -209,11 +209,14 @@ class HttpSocket(common):
await self.emitter.emit("item_delete", status) await self.emitter.emit("item_delete", status)
@ws_event @ws_event # type: ignore
async def archive_item(self, sid: str, data: dict): async def archive_item(self, sid: str, data: dict):
if not isinstance(data, dict) or "url" not in data or not self.config.keep_archive: if not isinstance(data, dict) or "url" not in data or not self.config.keep_archive:
return return
if not isinstance(self.config.ytdl_options, dict):
self.config.ytdl_options = {}
file: str = self.config.ytdl_options.get("download_archive", None) file: str = self.config.ytdl_options.get("download_archive", None)
if not file: if not file:
@ -242,13 +245,14 @@ class HttpSocket(common):
LOG.info(f"Archiving url '{data['url']}' with id '{idDict['archive_id']}'.") LOG.info(f"Archiving url '{data['url']}' with id '{idDict['archive_id']}'.")
@ws_event @ws_event # type: ignore
async def connect(self, sid: str, _=None): async def connect(self, sid: str, _=None):
data = { data = {
**self.queue.get(), **self.queue.get(),
"config": self.config.frontend(), "config": self.config.frontend(),
"tasks": self.config.tasks, "tasks": self.config.tasks,
"presets": self.config.presets, "presets": self.config.presets,
"paused": self.queue.isPaused(),
} }
# get download folder listing # get download folder listing
@ -256,3 +260,13 @@ class HttpSocket(common):
data["folders"] = [name for name in os.listdir(downloadPath) if os.path.isdir(os.path.join(downloadPath, name))] data["folders"] = [name for name in os.listdir(downloadPath) if os.path.isdir(os.path.join(downloadPath, name))]
await self.emitter.emit("initial_data", data, to=sid) await self.emitter.emit("initial_data", data, to=sid)
@ws_event # type: ignore
async def pause(self, sid: str, _=None):
self.queue.pause()
await self.emitter.emit("paused", {"paused": True, "at": time.time()})
@ws_event # type: ignore
async def resume(self, sid: str, _=None):
self.queue.resume()
await self.emitter.emit("paused", {"paused": False, "at": time.time()})

View file

@ -14,7 +14,7 @@ class ItemDTO:
_id: str = field(default_factory=lambda: str(uuid.uuid4()), init=False) _id: str = field(default_factory=lambda: str(uuid.uuid4()), init=False)
error: str = None error: str|None = None
id: str id: str
title: str title: str
url: str url: str

View file

@ -11,8 +11,8 @@ class common:
This class is used to share common methods between the socket and the API gateways. This class is used to share common methods between the socket and the API gateways.
""" """
queue: DownloadQueue = None queue: DownloadQueue
encoder: Encoder = None encoder: Encoder
def __init__(self, queue: DownloadQueue, encoder: Encoder): def __init__(self, queue: DownloadQueue, encoder: Encoder):
super().__init__() super().__init__()

View file

@ -60,7 +60,7 @@ class Config:
# immutable config vars. # immutable config vars.
version: str = APP_VERSION version: str = APP_VERSION
__instance = None __instance = None
ytdl_options: dict | str = {} ytdl_options: dict = {}
tasks: list = [] tasks: list = []
new_version_available: bool = False new_version_available: bool = False
ytdlp_version: str = YTDLP_VERSION ytdlp_version: str = YTDLP_VERSION
@ -159,6 +159,7 @@ class Config:
"url_prefix", "url_prefix",
"remove_files", "remove_files",
"ui_update_title", "ui_update_title",
"max_workers",
) )
@staticmethod @staticmethod

View file

@ -52,7 +52,8 @@
<div v-if="false === hideThumbnail" class="card-image"> <div v-if="false === hideThumbnail" class="card-image">
<figure class="image is-3by1" v-if="item.extras?.thumbnail"> <figure class="image is-3by1" v-if="item.extras?.thumbnail">
<NuxtLink v-tooltip="item.title" :href="item.url" target="_blank"> <NuxtLink v-tooltip="item.title" :href="item.url" target="_blank">
<img :src="config.app.url_host + config.app.url_prefix + 'thumbnail?url=' + encodePath(item.extras.thumbnail)" <img
:src="config.app.url_host + config.app.url_prefix + 'thumbnail?url=' + encodePath(item.extras.thumbnail)"
:alt="item.title" /> :alt="item.title" />
</NuxtLink> </NuxtLink>
</figure> </figure>
@ -171,14 +172,18 @@ const hasSelected = computed(() => selectedElms.value.length > 0)
const hasQueuedItems = computed(() => stateStore.count('queue') > 0) const hasQueuedItems = computed(() => stateStore.count('queue') > 0)
const setIcon = item => { const setIcon = item => {
if (item.status === 'downloading' && item.is_live) { if ('downloading' === item.status && item.is_live) {
return 'fa-solid fa-globe'; return 'fa-solid fa-globe';
} }
if (item.status === 'downloading') { if ('downloading' === item.status) {
return 'fa-solid fa-circle-check'; return 'fa-solid fa-circle-check';
} }
if (null === item.status && true === config.paused) {
return 'fa-solid fa-pause-circle';
}
return 'fa-solid fa-spinner fa-spin'; return 'fa-solid fa-spinner fa-spin';
} }
@ -219,7 +224,11 @@ const percentPipe = value => {
const updateProgress = (item) => { const updateProgress = (item) => {
let string = ''; let string = '';
if (item.status == 'preparing') { if (null === item.status && true === config.paused) {
return 'Paused';
}
if ('preparing' === item.status) {
return 'Preparing'; return 'Preparing';
} }

View file

@ -11,29 +11,33 @@
</NuxtLink> </NuxtLink>
</div> </div>
<div class="navbar-end is-flex"> <div class="navbar-end is-flex">
<div class="navbar-item" v-if="socket.isConnected">
<button class="button is-dark" @click="socket.emit('pause', {})" v-if="false === config.paused"
v-tooltip="'Pause non-active downloads.'">
<span class="icon has-text-warning"><i class="fas fa-pause"></i></span>
</button>
<button class="button is-dark" @click="socket.emit('resume', {})" v-else v-tooltip="'Resume downloading.'">
<span class="icon has-text-success"><i class="fas fa-play"></i></span>
</button>
</div>
<div class="navbar-item"> <div class="navbar-item">
<NuxtLink class="button is-dark has-tooltip-bottom" to="/console"> <NuxtLink class="button is-dark has-tooltip-bottom" to="/console" v-tooltip.bottom="'Terminal'">
<span class="icon-text"> <span class="icon"><i class="fa-solid fa-terminal" /></span>
<span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>Terminal</span>
</span>
</NuxtLink> </NuxtLink>
</div> </div>
<div class="navbar-item"> <div class="navbar-item">
<button v-tooltip.bottom="'Toggle Add Form'" class="button is-dark has-tooltip-bottom" <button v-tooltip.bottom="'Toggle Add Form'" class="button is-dark has-tooltip-bottom"
@click="config.showForm = !config.showForm"> @click="config.showForm = !config.showForm">
<span class="icon-text"> <span class="icon"><i class="fa-solid fa-plus" /></span>
<span class="icon"><i class="fa-solid fa-plus" /></span>
<span>Add</span>
</span>
</button> </button>
</div> </div>
<div class="navbar-item" v-if="config.tasks.length > 0"> <div class="navbar-item" v-if="config.tasks.length > 0" v-tooltip.bottom="'Tasks'">
<NuxtLink class="button is-dark has-tooltip-bottom" to="/tasks"> <NuxtLink class="button is-dark has-tooltip-bottom" to="/tasks">
<span class="icon"><i class="fa-solid fa-tasks" /></span> <span class="icon"><i class="fa-solid fa-tasks" /></span>
<span class="is-hidden-mobile">Tasks</span>
</NuxtLink> </NuxtLink>
</div> </div>

View file

@ -7,19 +7,28 @@
<script setup> <script setup>
const config = useConfigStore() const config = useConfigStore()
useHead({ title: 'YTPTube' }) const stateStore = useStateStore()
watch(() => config.app.ui_update_title, value => { onMounted(() => {
if (true !== value) { if (!config.app.ui_update_title) {
useHead({ title: 'YTPTube' })
return return
} }
useHead({ title: `YTPTube: ( ${Object.keys(stateStore.queue).length || 0}/${config.app.max_workers} | ${Object.keys(stateStore.history).length || 0} )` })
})
const s = useStateStore() watch(() => stateStore.history, () => {
useHead({ title: `YTPTube: ( ${Object.keys(s.queue).length || 0} | ${Object.keys(s.history).length || 0} )` }) if (!config.app.ui_update_title) {
watch([s.queue, s.history], () => { return
const title = `YTPTube: ( ${Object.keys(s.queue).length || 0} | ${Object.keys(s.history).length || 0} )` }
useHead({ title }) useHead({ title: `YTPTube: ( ${Object.keys(stateStore.queue).length || 0}/${config.app.max_workers} | ${Object.keys(stateStore.history).length || 0} )` })
}) }, { deep: true })
watch(() => stateStore.queue, () => {
if (!config.app.ui_update_title) {
return
}
useHead({ title: `YTPTube: ( ${Object.keys(stateStore.queue).length || 0}/${config.app.max_workers} | ${Object.keys(stateStore.history).length || 0} )` })
}, { deep: true })
}, { immediate: true })
</script> </script>

View file

@ -7,6 +7,7 @@ const CONFIG_KEYS = {
ui_update_title: true, ui_update_title: true,
output_template: '', output_template: '',
ytdlp_version: '', ytdlp_version: '',
max_workers: 1,
version: '', version: '',
url_host: '', url_host: '',
url_prefix: '', url_prefix: '',
@ -21,6 +22,7 @@ const CONFIG_KEYS = {
], ],
folders: [], folders: [],
tasks: [], tasks: [],
paused: false,
}; };
export const useConfigStore = defineStore('config', () => { export const useConfigStore = defineStore('config', () => {

View file

@ -24,6 +24,7 @@ export const useSocketStore = defineStore('socket', () => {
tasks: initialData['tasks'], tasks: initialData['tasks'],
folders: initialData['folders'], folders: initialData['folders'],
presets: initialData['presets'], presets: initialData['presets'],
paused: Boolean(initialData['paused'])
}) })
stateStore.addAll('queue', initialData['queue'] ?? {}) stateStore.addAll('queue', initialData['queue'] ?? {})
@ -89,6 +90,22 @@ export const useSocketStore = defineStore('socket', () => {
return; return;
} }
}); });
socket.value.on('paused', data => {
const json = JSON.parse(data);
const pausedState = Boolean(json.paused);
config.update('paused', pausedState);
if (false === pausedState) {
toast.success('Download queue resumed.');
return;
}
toast.warning('Download queue paused.', {
timeout: 10000,
});
});
} }
const on = (event, callback) => socket.value.on(event, callback); const on = (event, callback) => socket.value.on(event, callback);