Merge pull request #333 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled

Added the ability to add items in paused state
This commit is contained in:
Abdulmohsen 2025-07-08 22:25:42 +03:00 committed by GitHub
commit 4c11e178b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1463 additions and 798 deletions

1
API.md
View file

@ -210,6 +210,7 @@ or an error:
"cookies": "...", // -- optional. If provided, it MUST BE in Netscape HTTP Cookie format. "cookies": "...", // -- optional. If provided, it MUST BE in Netscape HTTP Cookie format.
"template": "%(title)s.%(ext)s", // -- optional. The filename template to use for this item. "template": "%(title)s.%(ext)s", // -- optional. The filename template to use for this item.
"cli": "--write-subs --embed-subs", // -- optional. Additional command options for yt-dlp to apply to this item. "cli": "--write-subs --embed-subs", // -- optional. Additional command options for yt-dlp to apply to this item.
"auto_start": true // -- optional. Whether to auto-start the download after adding it. Defaults to true.
} }
// Or multiple items (array of objects) // Or multiple items (array of objects)

View file

@ -5,9 +5,9 @@ import logging
from collections import OrderedDict from collections import OrderedDict
from datetime import UTC, datetime from datetime import UTC, datetime
from email.utils import formatdate from email.utils import formatdate
from enum import Enum
from sqlite3 import Connection from sqlite3 import Connection
from .config import Config
from .Download import Download from .Download import Download
from .ItemDTO import ItemDTO from .ItemDTO import ItemDTO
from .Utils import init_class from .Utils import init_class
@ -15,60 +15,100 @@ from .Utils import init_class
LOG = logging.getLogger("datastore") LOG = logging.getLogger("datastore")
class StoreType(str, Enum):
DONE = "done"
QUEUE = "queue"
PENDING = "pending"
@classmethod
def all(cls) -> list[str]:
return [member.value for member in cls]
@classmethod
def from_value(cls, value: str) -> "StoreType":
"""
Returns the StoreType enum member corresponding to the given value.
Args:
value (str): The value to match against the enum members.
Returns:
StoreType: The enum member that matches the value.
Raises:
ValueError: If the value does not match any member.
"""
for member in cls:
if member.value == value:
return member
msg = f"Invalid StoreType value: {value}"
raise ValueError(msg)
def __str__(self) -> str:
return self.value
class DataStore: class DataStore:
""" """
Persistent queue. Persistent queue.
""" """
type: str = None _type: StoreType = None
dict: OrderedDict[str, Download] = None """Type of the store, e.g., DONE, QUEUE, PENDING."""
config: Config = None
connection: Connection _dict: OrderedDict[str, Download] = None
"""Ordered dictionary to store Download objects."""
def __init__(self, type: str, connection: Connection): _connection: Connection
self.dict = OrderedDict() """SQLite connection to the database."""
self.type = type
self.config = Config.get_instance() def __init__(self, type: StoreType, connection: Connection):
self.connection = connection self._dict = OrderedDict()
self._type = type
self._connection = connection
def load(self) -> None: def load(self) -> None:
for id, item in self.saved_items(): for id, item in self.saved_items():
self.dict.update({id: Download(info=item)}) self._dict.update({id: Download(info=item)})
def exists(self, key: str | None = None, url: str | None = None) -> bool: def exists(self, key: str | None = None, url: str | None = None) -> bool:
if not key and not url: if not key and not url:
msg = "key or url must be provided." msg = "key or url must be provided."
raise KeyError(msg) raise KeyError(msg)
if key and key in self.dict: if key and key in self._dict:
return True return True
return any((key and self.dict[i].info._id == key) or (url and self.dict[i].info.url == url) for i in self.dict) return any(
(key and self._dict[i].info._id == key) or (url and self._dict[i].info.url == url) for i in self._dict
)
def get(self, key: str | None = None, url: str | None = None) -> Download: def get(self, key: str | None = None, url: str | None = None) -> Download:
if not key and not url: if not key and not url:
msg = "key or url must be provided." msg = "key or url must be provided."
raise KeyError(msg) raise KeyError(msg)
for i in self.dict: for i in self._dict:
if (key and self.dict[i].info._id == key) or (url and self.dict[i].info.url == url): if (key and self._dict[i].info._id == key) or (url and self._dict[i].info.url == url):
return self.dict[i] return self._dict[i]
msg: str = f"{key=} or {url=} not found." msg: str = f"{key=} or {url=} not found."
raise KeyError(msg) raise KeyError(msg)
def get_by_id(self, id: str) -> Download | None: def get_by_id(self, id: str) -> Download | None:
return self.dict.get(id, None) return self._dict.get(id, None)
def items(self) -> list[tuple[str, Download]]: def items(self) -> list[tuple[str, Download]]:
return self.dict.items() return self._dict.items()
def saved_items(self) -> list[tuple[str, ItemDTO]]: def saved_items(self) -> list[tuple[str, ItemDTO]]:
items: list[tuple[str, ItemDTO]] = [] items: list[tuple[str, ItemDTO]] = []
cursor = self.connection.execute( cursor = self._connection.execute(
'SELECT "id", "data", "created_at" FROM "history" WHERE "type" = ? ORDER BY "created_at" ASC', (self.type,) 'SELECT "id", "data", "created_at" FROM "history" WHERE "type" = ? ORDER BY "created_at" ASC',
(str(self._type),),
) )
for row in cursor: for row in cursor:
@ -88,39 +128,40 @@ class DataStore:
asyncio.create_task(EventBus.get_instance().emit(Events.ITEM_ERROR, value.info), name="emit_item_error") asyncio.create_task(EventBus.get_instance().emit(Events.ITEM_ERROR, value.info), name="emit_item_error")
self.dict.update({value.info._id: value}) self._dict.update({value.info._id: value})
self._update_store_item(self.type, value.info) self._update_store_item(self._type, value.info)
return self.dict[value.info._id] return self._dict[value.info._id]
def delete(self, key: str) -> None: def delete(self, key: str) -> None:
self.dict.pop(key, None) self._dict.pop(key, None)
self._delete_store_item(key) self._delete_store_item(key)
def next(self) -> tuple[str, Download]: def next(self) -> tuple[str, Download]:
return next(iter(self.dict.items())) return next(iter(self._dict.items()))
def empty(self): def empty(self):
return not bool(self.dict) return not bool(self._dict)
def has_downloads(self): def has_downloads(self):
if 0 == len(self.dict): if 0 == len(self._dict):
return False return False
return any(self.dict[key].started() is False for key in self.dict) return any(self._dict[key].info.auto_start and self._dict[key].started() is False for key in self._dict)
def get_next_download(self) -> Download: def get_next_download(self) -> Download:
for key in self.dict: for key in self._dict:
if self.dict[key].started() is False and self.dict[key].is_cancelled() is False: ref = self._dict[key]
return self.dict[key] if ref.info.auto_start and ref.started() is False and ref.is_cancelled() is False:
return ref
return None return None
async def test(self) -> bool: async def test(self) -> bool:
self.connection.execute('SELECT "id" FROM "history" LIMIT 1').fetchone() self._connection.execute('SELECT "id" FROM "history" LIMIT 1').fetchone()
return True return True
def _update_store_item(self, type: str, item: ItemDTO) -> None: def _update_store_item(self, type: StoreType, item: ItemDTO) -> None:
sqlStatement = """ sqlStatement = """
INSERT INTO "history" ("id", "type", "url", "data") INSERT INTO "history" ("id", "type", "url", "data")
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
@ -141,14 +182,14 @@ class DataStore:
except AttributeError: except AttributeError:
pass pass
self.connection.execute( self._connection.execute(
sqlStatement.strip(), sqlStatement.strip(),
( (
stored._id, stored._id,
type, str(type),
stored.url, stored.url,
stored.json(), stored.json(),
type, str(type),
stored.url, stored.url,
stored.json(), stored.json(),
datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"), datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
@ -156,4 +197,4 @@ class DataStore:
) )
def _delete_store_item(self, key: str) -> None: def _delete_store_item(self, key: str) -> None:
self.connection.execute('DELETE FROM "history" WHERE "type" = ? AND "id" = ?', (self.type, key)) self._connection.execute('DELETE FROM "history" WHERE "type" = ? AND "id" = ?', (str(self._type), key))

View file

@ -13,11 +13,10 @@ from typing import TYPE_CHECKING
import yt_dlp import yt_dlp
from aiohttp import web from aiohttp import web
from app.library.ag_utils import ag from .ag_utils import ag
from .conditions import Conditions from .conditions import Conditions
from .config import Config from .config import Config
from .DataStore import DataStore from .DataStore import DataStore, StoreType
from .Download import Download from .Download import Download
from .Events import EventBus, Events from .Events import EventBus, Events
from .Events import info as event_info from .Events import info as event_info
@ -50,12 +49,6 @@ class DownloadQueue(metaclass=Singleton):
DownloadQueue class is a singleton class that manages the download queue and the download history. DownloadQueue class is a singleton class that manages the download queue and the download history.
""" """
TYPE_DONE: str = "done"
"""Queue type for completed downloads."""
TYPE_QUEUE: str = "queue"
"""Queue type for pending downloads."""
paused: asyncio.Event paused: asyncio.Event
"""Event to pause the download queue.""" """Event to pause the download queue."""
@ -74,6 +67,9 @@ class DownloadQueue(metaclass=Singleton):
done: DataStore done: DataStore
"""DataStore for the completed downloads.""" """DataStore for the completed downloads."""
pending: DataStore
"""DataStore for the pending downloads."""
workers: asyncio.Semaphore workers: asyncio.Semaphore
"""Semaphore to limit the number of concurrent downloads.""" """Semaphore to limit the number of concurrent downloads."""
@ -85,8 +81,8 @@ class DownloadQueue(metaclass=Singleton):
self.config = config or Config.get_instance() self.config = config or Config.get_instance()
self._notify = EventBus.get_instance() self._notify = EventBus.get_instance()
self.done = DataStore(type=DownloadQueue.TYPE_DONE, connection=connection) self.done = DataStore(type=StoreType.DONE, connection=connection)
self.queue = DataStore(type=DownloadQueue.TYPE_QUEUE, connection=connection) self.queue = DataStore(type=StoreType.QUEUE, connection=connection)
self.done.load() self.done.load()
self.queue.load() self.queue.load()
self.paused = asyncio.Event() self.paused = asyncio.Event()
@ -154,6 +150,94 @@ class DownloadQueue(metaclass=Singleton):
) )
asyncio.create_task(self._download_pool(), name="download_pool") asyncio.create_task(self._download_pool(), name="download_pool")
async def start_items(self, ids: list[str]) -> dict[str, str]:
"""
Start one or more queued downloads that were added with auto_started=False.
Args:
ids (list[str]): List of item IDs to start.
Returns:
dict[str, str]: Dictionary of per-ID results and overall status.
"""
status: dict[str, str] = {"status": "ok"}
started = False
tasks = []
for item_id in ids:
try:
item = self.queue.get(key=item_id)
except KeyError as e:
status[item_id] = f"not found: {e!s}"
status["status"] = "error"
LOG.warning(f"Start requested for non-existent item {item_id=}.")
continue
if item.info.auto_start:
status[item_id] = "already started"
LOG.debug(f"Item {item.info.name()} already started.")
continue
item.info.auto_start = True
updated = self.queue.put(item)
tasks.append(self._notify.emit(Events.UPDATED, data=updated.info))
status[item_id] = "started"
started = True
LOG.debug(f"Item {item.info.name()} marked as started.")
if started:
self.event.set()
if len(tasks) > 0:
await asyncio.gather(*tasks)
return status
async def pause_items(self, ids: list[str]) -> dict[str, str]:
"""
Pause one or more queued downloads that were added with auto_started=True.
Args:
ids (list[str]): List of item IDs to pause.
Returns:
dict[str, str]: Dictionary of per-ID results and overall status.
"""
status: dict[str, str] = {"status": "ok"}
tasks = []
for item_id in ids:
try:
item = self.queue.get(key=item_id)
except KeyError as e:
status[item_id] = f"not found: {e!s}"
status["status"] = "error"
LOG.warning(f"Start requested for non-existent item {item_id=}.")
continue
if item.started() or item.is_cancelled():
status[item_id] = "already started"
LOG.debug(f"Item {item.info.name()} already started.")
continue
if item.info.auto_start is False:
status[item_id] = "not started"
LOG.debug(f"Item {item.info.name()} is not set to auto-start.")
continue
item.info.auto_start = False
updated = self.queue.put(item)
tasks.append(self._notify.emit(Events.UPDATED, data=updated.info))
status[item_id] = "paused"
LOG.debug(f"Item {item.info.name()} marked as paused.")
if len(tasks) > 0:
await asyncio.gather(*tasks)
return status
def pause(self, shutdown: bool = False) -> bool: def pause(self, shutdown: bool = False) -> bool:
""" """
Pause the download queue. Pause the download queue.
@ -362,11 +446,12 @@ class DownloadQueue(metaclass=Singleton):
live_in=live_in if live_in else item.extras.get("live_in", None), live_in=live_in if live_in else item.extras.get("live_in", None),
options=options, options=options,
cli=item.cli, cli=item.cli,
auto_start=item.auto_start,
extras=item.extras, extras=item.extras,
) )
try: try:
dlInfo: Download = Download(info=dl, info_dict=entry, logs=logs) dlInfo: Download = Download(info=dl, info_dict=entry if item.auto_start else None, logs=logs)
text_logs: str = "" text_logs: str = ""
if filtered_logs := extract_ytdlp_logs(logs): if filtered_logs := extract_ytdlp_logs(logs):
@ -411,7 +496,10 @@ class DownloadQueue(metaclass=Singleton):
if _requeue: if _requeue:
NotifyEvent = Events.ADDED NotifyEvent = Events.ADDED
itemDownload = self.queue.put(dlInfo) itemDownload = self.queue.put(dlInfo)
self.event.set() if item.auto_start:
self.event.set()
else:
LOG.debug(f"Item {itemDownload.info.name()} is not set to auto-start.")
else: else:
dlInfo.info.status = "not_live" dlInfo.info.status = "not_live"
itemDownload = self.done.put(dlInfo) itemDownload = self.done.put(dlInfo)
@ -423,7 +511,10 @@ class DownloadQueue(metaclass=Singleton):
else: else:
NotifyEvent = Events.ADDED NotifyEvent = Events.ADDED
itemDownload = self.queue.put(dlInfo) itemDownload = self.queue.put(dlInfo)
self.event.set() if item.auto_start:
self.event.set()
else:
LOG.debug(f"Item {itemDownload.info.name()} is not set to auto-start.")
await self._notify.emit(NotifyEvent, data=itemDownload.info.serialize()) await self._notify.emit(NotifyEvent, data=itemDownload.info.serialize())
@ -632,7 +723,7 @@ class DownloadQueue(metaclass=Singleton):
self.queue.delete(id) self.queue.delete(id)
await self._notify.emit(Events.CANCELLED, data=item.info.serialize()) await self._notify.emit(Events.CANCELLED, data=item.info.serialize())
item.info.status = "cancelled" item.info.status = "cancelled"
item.info.error = "Cancelled by user." # item.info.error = "Cancelled by user."
self.done.put(item) self.done.put(item)
await self._notify.emit(Events.COMPLETED, data=item.info.serialize()) await self._notify.emit(Events.COMPLETED, data=item.info.serialize())
LOG.info(f"Deleted from queue {item_ref}") LOG.info(f"Deleted from queue {item_ref}")
@ -755,7 +846,7 @@ class DownloadQueue(metaclass=Singleton):
LOG.info("Download pool resumed downloading.") LOG.info("Download pool resumed downloading.")
for _id, entry in list(self.queue.items()): for _id, entry in list(self.queue.items()):
if entry.started() or entry.is_cancelled(): if entry.started() or entry.is_cancelled() or entry.info.auto_start is False:
continue continue
if entry.is_live: if entry.is_live:
@ -816,7 +907,7 @@ class DownloadQueue(metaclass=Singleton):
if entry.is_cancelled() is True: if entry.is_cancelled() is True:
await self._notify.emit(Events.CANCELLED, data=entry.info.serialize()) await self._notify.emit(Events.CANCELLED, data=entry.info.serialize())
entry.info.status = "cancelled" entry.info.status = "cancelled"
entry.info.error = "Cancelled by user." # entry.info.error = "Cancelled by user."
self.done.put(value=entry) self.done.put(value=entry)
await self._notify.emit(Events.COMPLETED, data=entry.info.serialize()) await self._notify.emit(Events.COMPLETED, data=entry.info.serialize())

View file

@ -41,6 +41,9 @@ class Item:
requeued: bool = False requeued: bool = False
"""If the item has been retried already via conditions.""" """If the item has been retried already via conditions."""
auto_start: bool = True
"""If the item should be started automatically."""
def serialize(self) -> dict: def serialize(self) -> dict:
return self.__dict__.copy() return self.__dict__.copy()
@ -95,7 +98,7 @@ class Item:
data = {} data = {}
for k, v in self.serialize().items(): for k, v in self.serialize().items():
if not v: if not v and k not in ("auto_start"):
continue continue
if k == "cli": if k == "cli":
@ -157,6 +160,10 @@ class Item:
if item.get("template") and isinstance(item.get("template"), str): if item.get("template") and isinstance(item.get("template"), str):
data["template"] = item.get("template") data["template"] = item.get("template")
if "auto_start" in item and isinstance(item.get("auto_start"), bool):
LOG.info("Item '%s' auto_start is set to %s.", url, item.get("auto_start"))
data["auto_start"] = bool(item.get("auto_start"))
extras = item.get("extras") extras = item.get("extras")
if extras and isinstance(extras, dict) and len(extras) > 0: if extras and isinstance(extras, dict) and len(extras) > 0:
data["extras"] = extras data["extras"] = extras
@ -213,6 +220,7 @@ class ItemDTO:
options: dict = field(default_factory=dict) options: dict = field(default_factory=dict)
extras: dict = field(default_factory=dict) extras: dict = field(default_factory=dict)
cli: str = "" cli: str = ""
auto_start: bool = True
# yt-dlp injected fields. # yt-dlp injected fields.
tmpfilename: str | None = None tmpfilename: str | None = None
@ -239,7 +247,7 @@ class ItemDTO:
return self._id return self._id
def name(self) -> str: def name(self) -> str:
return f'id="{self.id}", title="{self.title}"' return f'id="{self.id}", title="{self.title}"'
@staticmethod @staticmethod
def removed_fields() -> tuple: def removed_fields() -> tuple:

View file

@ -33,6 +33,7 @@ class Task:
timer: str = "" timer: str = ""
template: str = "" template: str = ""
cli: str = "" cli: str = ""
auto_start: bool = True
def serialize(self) -> dict: def serialize(self) -> dict:
return self.__dict__ return self.__dict__

View file

@ -1131,6 +1131,7 @@ def get_archive_id(url: str) -> tuple[bool, dict[str | None, str | None, str | N
idDict["archive_id"] = YTDLP_INFO_CLS._make_archive_id(idDict) idDict["archive_id"] = YTDLP_INFO_CLS._make_archive_id(idDict)
break break
except Exception as e: except Exception as e:
LOG.exception(e)
LOG.error(f"Error getting archive ID: {e}") LOG.error(f"Error getting archive ID: {e}")
return idDict return idDict

View file

@ -128,3 +128,27 @@ async def archive_item(config: Config, data: dict):
LOG.info(f"Archiving url '{data['url']}' with id '{idDict['archive_id']}'.") LOG.info(f"Archiving url '{data['url']}' with id '{idDict['archive_id']}'.")
else: else:
LOG.info(f"URL '{data['url']}' with id '{idDict['archive_id']}' already archived.") LOG.info(f"URL '{data['url']}' with id '{idDict['archive_id']}' already archived.")
@route(RouteType.SOCKET, "item_start", "item_start")
async def item_start(queue: DownloadQueue, notify: EventBus, sid: str, data: list | str) -> None:
if not data:
await notify.emit(Events.ERROR, data=error("Invalid request."), to=sid)
return
if isinstance(data, str):
data = [data]
await queue.start_items(data)
@route(RouteType.SOCKET, "item_pause", "item_pause")
async def item_pause(queue: DownloadQueue, notify: EventBus, sid: str, data: list | str) -> None:
if not data:
await notify.emit(Events.ERROR, data=error("Invalid request."), to=sid)
return
if isinstance(data, str):
data = [data]
await queue.pause_items(data)

View file

@ -42,6 +42,8 @@ type AppConfig = {
app_build_date: string app_build_date: string
/** App branch name, e.g. "main" or "develop" */ /** App branch name, e.g. "main" or "develop" */
app_branch: string app_branch: string
/** When the app started */
started: number
} }
type Preset = { type Preset = {

View file

@ -8,6 +8,7 @@ type task_item = {
cli?: string, cli?: string,
timer?: string, timer?: string,
in_progress?: boolean, in_progress?: boolean,
auto_start?: boolean,
} }
type exported_task = task_item & { _type: string, _version: string } type exported_task = task_item & { _type: string, _version: string }

View file

@ -489,7 +489,7 @@ const hideThumbnail = useStorage('hideThumbnailHistory', false)
const direction = useStorage('sortCompleted', 'desc') const direction = useStorage('sortCompleted', 'desc')
const display_style = useStorage('display_style', 'cards') const display_style = useStorage('display_style', 'cards')
const bg_enable = useStorage('random_bg', true) const bg_enable = useStorage('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.85) const bg_opacity = useStorage('random_bg_opacity', 0.95)
const selectedElms = ref([]) const selectedElms = ref([])
const masterSelectAll = ref(false) const masterSelectAll = ref(false)
@ -601,9 +601,9 @@ const deleteSelectedItems = () => {
return return
} }
let msg = `Are you sure you want to delete '${selectedElms.value.length}' items?` let msg = `${config.app.remove_files ? 'Remove' : 'Clear'} '${selectedElms.value.length}' items?`
if (true === config.app.remove_files) { if (true === config.app.remove_files) {
msg += '\nThis will delete the files from the server if they exist.' msg += ' This will remove any associated files if they exists.'
} }
if (false === box.confirm(msg, config.app.remove_files)) { if (false === box.confirm(msg, config.app.remove_files)) {
@ -620,10 +620,11 @@ const deleteSelectedItems = () => {
remove_file: config.app.remove_files, remove_file: config.app.remove_files,
}) })
} }
selectedElms.value = []
} }
const clearCompleted = () => { const clearCompleted = () => {
let msg = 'Are you sure you want to clear all completed downloads?' let msg = 'Clear all completed downloads?'
if (false === box.confirm(msg)) { if (false === box.confirm(msg)) {
return return
} }
@ -636,7 +637,7 @@ const clearCompleted = () => {
} }
const clearIncomplete = () => { const clearIncomplete = () => {
if (false === box.confirm('Are you sure you want to clear all in-complete downloads?')) { if (false === box.confirm('Clear all in-complete downloads?')) {
return return
} }
@ -731,7 +732,7 @@ const setStatus = item => {
} }
const retryIncomplete = () => { const retryIncomplete = () => {
if (false === box.confirm('Are you sure you want to retry all incomplete downloads?')) { if (false === box.confirm('Retry all incomplete downloads?')) {
return false return false
} }
@ -781,11 +782,12 @@ const archiveItem = async (item, opts = {}) => {
} }
const removeItem = item => { const removeItem = item => {
let msg = `Remove '${item.title ?? item.id ?? item.url ?? '??'}'?` let msg = `${config.app.remove_files ? 'Remove' : 'Clear'} '${item.title ?? item.id ?? item.url ?? '??'}'?`
if (item.status === 'finished' && item.filename && config.app.remove_files) { if (item.status === 'finished' && config.app.remove_files) {
msg += '\nThis will delete the file from the server if it exists.' msg += ' This will remove any associated files if they exists.'
} }
if (false === box.confirm(msg, config.app.remove_files)) {
if (false === box.confirm(msg, item.filename && config.app.remove_files)) {
return false return false
} }
@ -809,7 +811,7 @@ const retryItem = (item, re_add = false) => {
socket.emit('item_delete', { id: item._id, remove_file: false }) socket.emit('item_delete', { id: item._id, remove_file: false })
if (true === re_add) { if (true === re_add) {
toast.info('Removed the item from history, and added it to the new download form.') toast.info('Cleared the item from history, and added it to the new download form.')
emitter('add_new', item_req) emitter('add_new', item_req)
return return
} }
@ -851,6 +853,8 @@ const downloadSelected = async () => {
files_list.push(item.folder ? item.folder + '/' + item.filename : item.filename) files_list.push(item.folder ? item.folder + '/' + item.filename : item.filename)
} }
selectedElms.value = []
try { try {
const response = await request('/api/file/download', { const response = await request('/api/file/download', {
method: 'POST', method: 'POST',

View file

@ -9,11 +9,19 @@
<span class="icon"><i class="fa-solid fa-link" /></span> <span class="icon"><i class="fa-solid fa-link" /></span>
URLs URLs
</label> </label>
<div class="control"> <div class="field is-grouped">
<input type="text" class="input" id="url" placeholder="Video or playlist link" <div class="control is-expanded">
:disabled="!socket.isConnected || addInProgress" v-model="form.url"> <input type="text" class="input" id="url" placeholder="Video or playlist link"
:disabled="!socket.isConnected || addInProgress" v-model="form.url">
</div>
<div class="control is-align-content-space-around" v-if="!config.app.basic_mode"
v-tooltip="'Auto start the download after adding it.'">
<input id="auto_start" type="checkbox" v-model="auto_start" :disabled="addInProgress"
class="switch is-success" />
<label for="auto_start" class="is-unselectable">Start</label>
</div>
</div> </div>
<span class="help is-bold"> <span class="help is-bold is-unselectable">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>You can add multiple URLs separated by <code <span>You can add multiple URLs separated by <code
class="is-bold">{{ getSeparatorsName(separator) }}</code>.</span> class="is-bold">{{ getSeparatorsName(separator) }}</code>.</span>
@ -233,6 +241,7 @@
</template> </template>
<script setup> <script setup>
import 'assets/css/bulma-switch.css'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
const props = defineProps({ const props = defineProps({
@ -264,6 +273,7 @@ const getSeparatorsName = (value) => {
const showAdvanced = useStorage('show_advanced', false) const showAdvanced = useStorage('show_advanced', false)
const separator = useStorage('url_separator', separators[0].value) const separator = useStorage('url_separator', separators[0].value)
const auto_start = useStorage('auto_start', true)
const addInProgress = ref(false) const addInProgress = ref(false)
@ -300,6 +310,7 @@ const addDownload = async () => {
template: config.app.basic_mode ? null : form.value.template, template: config.app.basic_mode ? null : form.value.template,
cookies: config.app.basic_mode ? '' : form.value.cookies, cookies: config.app.basic_mode ? '' : form.value.cookies,
cli: config.app.basic_mode ? null : form.value.cli, cli: config.app.basic_mode ? null : form.value.cli,
auto_start: config.app.basic_mode ? true : auto_start.value
} }
if (form.value?.extras && Object.keys(form.value.extras).length > 0) { if (form.value?.extras && Object.keys(form.value.extras).length > 0) {

View file

@ -23,6 +23,19 @@
</span> </span>
</button> </button>
</div> </div>
<div class="column is-half-mobile" v-if="hasManualStart">
<button type="button" class="button is-fullwidth is-success" :disabled="!hasSelected" @click="startItems">
<span class="icon"><i class="fa-solid fa-circle-play" /></span>
<span>Start</span>
</button>
</div>
<div class="column is-half-mobile" v-if="hasPausable">
<button type="button" class="button is-fullwidth is-background-warning-85" :disabled="!hasSelected"
@click="pauseSelected">
<span class="icon"><i class="fa-solid fa-pause" /></span>
<span>Pause</span>
</button>
</div>
<div class="column is-half-mobile" v-if="('cards' === display_style || hasSelected)"> <div class="column is-half-mobile" v-if="('cards' === display_style || hasSelected)">
<button type="button" class="button is-fullwidth is-warning" :disabled="!hasSelected" @click="cancelSelected"> <button type="button" class="button is-fullwidth is-warning" :disabled="!hasSelected" @click="cancelSelected">
<span class="icon"><i class="fa-solid fa-eject" /></span> <span class="icon"><i class="fa-solid fa-eject" /></span>
@ -105,6 +118,14 @@
<td class="is-vcentered is-items-center"> <td class="is-vcentered is-items-center">
<Dropdown icons="fa-solid fa-cogs" @open_state="s => table_container = !s" <Dropdown icons="fa-solid fa-cogs" @open_state="s => table_container = !s"
:button_classes="'is-small'" label="Actions"> :button_classes="'is-small'" label="Actions">
<template v-if="!item.auto_start">
<NuxtLink class="dropdown-item has-text-success" @click="startItem(item)">
<span class="icon"><i class="fa-solid fa-circle-play" /></span>
<span>Start Download</span>
</NuxtLink>
<hr class="dropdown-divider" />
</template>
<template v-if="isEmbedable(item.url)"> <template v-if="isEmbedable(item.url)">
<NuxtLink class="dropdown-item has-text-danger" @click="embed_url = getEmbedable(item.url)"> <NuxtLink class="dropdown-item has-text-danger" @click="embed_url = getEmbedable(item.url)">
<span class="icon"><i class="fa-solid fa-play" /></span> <span class="icon"><i class="fa-solid fa-play" /></span>
@ -219,6 +240,18 @@
<span>Cancel</span> <span>Cancel</span>
</button> </button>
</div> </div>
<div class="column is-half-mobile" v-if="!item.auto_start && !item.status">
<button class="button is-success is-fullwidth" @click="startItem(item)">
<span class="icon"><i class="fa-solid fa-circle-play" /></span>
<span>Start</span>
</button>
</div>
<div class="column is-half-mobile" v-if="item.auto_start && !item.status">
<button class="button is-background-warning-85 is-fullwidth" @click="pauseItem(item)">
<span class="icon"><i class="fa-solid fa-pause" /></span>
<span>Pause</span>
</button>
</div>
<div class="column is-half-mobile"> <div class="column is-half-mobile">
<Dropdown icons="fa-solid fa-cogs" @open_state="s => table_container = !s" label="Actions"> <Dropdown icons="fa-solid fa-cogs" @open_state="s => table_container = !s" label="Actions">
<template v-if="isEmbedable(item.url)"> <template v-if="isEmbedable(item.url)">
@ -294,7 +327,7 @@ const showQueue = useStorage('showQueue', true)
const hideThumbnail = useStorage('hideThumbnailQueue', false) const hideThumbnail = useStorage('hideThumbnailQueue', false)
const display_style = useStorage('display_style', 'cards') const display_style = useStorage('display_style', 'cards')
const bg_enable = useStorage('random_bg', true) const bg_enable = useStorage('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.85) const bg_opacity = useStorage('random_bg_opacity', 0.95)
const selectedElms = ref([]) const selectedElms = ref([])
const masterSelectAll = ref(false) const masterSelectAll = ref(false)
@ -324,8 +357,42 @@ const filteredItems = computed(() => {
const hasSelected = computed(() => selectedElms.value.length > 0) const hasSelected = computed(() => selectedElms.value.length > 0)
const hasQueuedItems = computed(() => stateStore.count('queue') > 0) const hasQueuedItems = computed(() => stateStore.count('queue') > 0)
const hasManualStart = computed(() => {
if (stateStore.count('queue') < 0) {
return false
}
for (const key in stateStore.queue) {
const item = stateStore.queue[key]
if (!item.status && item.auto_start === false) {
return true
}
}
return false
})
const hasPausable = computed(() => {
if (stateStore.count('queue') < 0) {
return false
}
for (const key in stateStore.queue) {
const item = stateStore.queue[key]
if (!item.status && item.auto_start === true) {
return true
}
}
return false
})
const setIcon = item => { const setIcon = item => {
if (!item.auto_start) {
return 'fa-hourglass-half'
}
if ('downloading' === item.status && item.is_live) { if ('downloading' === item.status && item.is_live) {
return 'fa-globe fa-spin' return 'fa-globe fa-spin'
} }
@ -350,6 +417,10 @@ const setIcon = item => {
} }
const setStatus = item => { const setStatus = item => {
if (!item.auto_start) {
return 'Pending'
}
if (null === item.status && true === config.paused) { if (null === item.status && true === config.paused) {
return 'Paused' return 'Paused'
} }
@ -378,7 +449,7 @@ const setIconColor = item => {
return 'has-text-info' return 'has-text-info'
} }
if (null === item.status && true === config.paused) { if (!item.auto_start || (null === item.status && true === config.paused)) {
return 'has-text-warning' return 'has-text-warning'
} }
@ -422,8 +493,12 @@ const percentPipe = value => {
const updateProgress = (item) => { const updateProgress = (item) => {
let string = '' let string = ''
if (!item.auto_start) {
return 'Manual start'
}
if (null === item.status && true === config.paused) { if (null === item.status && true === config.paused) {
return 'Paused' return 'Global Pause'
} }
if ('postprocessing' === item.status) { if ('postprocessing' === item.status) {
@ -482,6 +557,65 @@ const cancelItems = item => {
items.forEach(id => socket.emit('item_cancel', id)) items.forEach(id => socket.emit('item_cancel', id))
} }
const startItem = item => socket.emit('item_start', item._id)
const pauseItem = item => socket.emit('item_pause', item._id)
const startItems = () => {
if (selectedElms.value.length < 1) {
return
}
let filtered = []
selectedElms.value.forEach(id => {
const item = stateStore.get('queue', id)
if (item && !item.auto_start && !item.status) {
filtered.push(id)
}
})
selectedElms.value = []
if (filtered.length < 1) {
toast.error('No eligible items to start.')
return
}
if (true !== box.confirm(`Start '${filtered.length}' selected items?`)) {
return false
}
filtered.forEach(id => socket.emit('item_start', id))
}
const pauseSelected = () => {
if (selectedElms.value.length < 1) {
return
}
let filtered = []
selectedElms.value.forEach(id => {
const item = stateStore.get('queue', id)
if (item && item.auto_start && !item.status) {
filtered.push(id)
}
})
selectedElms.value = []
if (filtered.length < 1) {
toast.error('No eligible items to pause.')
return
}
if (true !== box.confirm(`Pause '${filtered.length}' selected items?`)) {
return false
}
filtered.forEach(id => socket.emit('item_pause', id))
}
const pImg = e => e.target.naturalHeight > e.target.naturalWidth ? e.target.classList.add('image-portrait') : null const pImg = e => e.target.naturalHeight > e.target.naturalWidth ? e.target.classList.add('image-portrait') : null
watch(embed_url, v => { watch(embed_url, v => {

View file

@ -138,7 +138,7 @@ defineProps({
}) })
const bg_enable = useStorage('random_bg', true) const bg_enable = useStorage('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.85) const bg_opacity = useStorage('random_bg_opacity', 0.95)
const selectedTheme = useStorage('theme', 'auto') const selectedTheme = useStorage('theme', 'auto')
const allow_toasts = useStorage('allow_toasts', true) const allow_toasts = useStorage('allow_toasts', true)
const reduce_confirm = useStorage('reduce_confirm', false) const reduce_confirm = useStorage('reduce_confirm', false)

View file

@ -195,6 +195,26 @@
</div> </div>
</div> </div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="output_template">
<span class="icon"><i class="fa-solid fa-circle-play" /></span>
Auto Start
</label>
<div class="control is-unselectable">
<input id="auto_start" type="checkbox" v-model="form.auto_start" :disabled="addInProgress"
class="switch is-success" />
<label for="auto_start" class="is-unselectable">
{{ form.auto_start ? 'Yes' : 'No' }}
</label>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Whether to automatically start downloading or just queue them in paused state.</span>
</span>
</div>
</div>
<div class="column is-12"> <div class="column is-12">
<div class="field"> <div class="field">
<label class="label is-inline" for="cli_options"> <label class="label is-inline" for="cli_options">
@ -269,6 +289,7 @@
</template> </template>
<script setup> <script setup>
import 'assets/css/bulma-switch.css'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { CronExpressionParser } from 'cron-parser' import { CronExpressionParser } from 'cron-parser'
import { decode } from '~/utils/importer' import { decode } from '~/utils/importer'
@ -392,6 +413,8 @@ const importItem = async () => {
form.cli = item.cli form.cli = item.cli
} }
form.auto_start = item?.auto_start ?? true
if (item.preset) { if (item.preset) {
// -- check if the preset exists in config.presets // -- check if the preset exists in config.presets
const preset = config.presets.find(p => p.name === item.preset) const preset = config.presets.find(p => p.name === item.preset)

View file

@ -3,7 +3,7 @@
<nav class="navbar is-mobile is-dark"> <nav class="navbar is-mobile is-dark">
<div class="navbar-brand pl-5"> <div class="navbar-brand pl-5">
<NuxtLink class="navbar-item is-text-overflow" to="/" @click.native="e => changeRoute(e)" <NuxtLink class="navbar-item is-text-overflow" to="/" @click.native="(e: MouseEvent) => changeRoute(e)"
v-tooltip="socket.isConnected ? 'Connected' : 'Connecting'"> v-tooltip="socket.isConnected ? 'Connected' : 'Connecting'">
<span class="is-text-overflow"> <span class="is-text-overflow">
<span class="icon"><i class="fas fa-home" /></span> <span class="icon"><i class="fas fa-home" /></span>
@ -23,30 +23,30 @@
<div class="navbar-menu is-unselectable" :class="{ 'is-active': showMenu }"> <div class="navbar-menu is-unselectable" :class="{ 'is-active': showMenu }">
<div class="navbar-start" v-if="!config.app.basic_mode"> <div class="navbar-start" v-if="!config.app.basic_mode">
<NuxtLink class="navbar-item" to="/browser" @click.native="e => changeRoute(e)" <NuxtLink class="navbar-item" to="/browser" @click.native="(e: MouseEvent) => changeRoute(e)"
v-if="config.app.browser_enabled"> v-if="config.app.browser_enabled">
<span class="icon"><i class="fa-solid fa-folder-tree" /></span> <span class="icon"><i class="fa-solid fa-folder-tree" /></span>
<span>Files</span> <span>Files</span>
</NuxtLink> </NuxtLink>
<NuxtLink class="navbar-item" to="/presets" @click.native="e => changeRoute(e)"> <NuxtLink class="navbar-item" to="/presets" @click.native="(e: MouseEvent) => changeRoute(e)">
<span class="icon"><i class="fa-solid fa-sliders" /></span> <span class="icon"><i class="fa-solid fa-sliders" /></span>
<span>Presets</span> <span>Presets</span>
</NuxtLink> </NuxtLink>
<NuxtLink class="navbar-item" to="/tasks" @click.native="e => changeRoute(e)"> <NuxtLink class="navbar-item" to="/tasks" @click.native="(e: MouseEvent) => changeRoute(e)">
<span class="icon"><i class="fa-solid fa-tasks" /></span> <span class="icon"><i class="fa-solid fa-tasks" /></span>
<span>Tasks</span> <span>Tasks</span>
</NuxtLink> </NuxtLink>
<NuxtLink class="navbar-item" to="/notifications" @click.native="e => changeRoute(e)"> <NuxtLink class="navbar-item" to="/notifications" @click.native="(e: MouseEvent) => changeRoute(e)">
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i class="fa-solid fa-paper-plane" /></span> <span class="icon"><i class="fa-solid fa-paper-plane" /></span>
<span>Notifications</span> <span>Notifications</span>
</span> </span>
</NuxtLink> </NuxtLink>
<NuxtLink class="navbar-item" to="/conditions" @click.native="e => changeRoute(e)"> <NuxtLink class="navbar-item" to="/conditions" @click.native="(e: MouseEvent) => changeRoute(e)">
<span class="icon"><i class="fa-solid fa-filter" /></span> <span class="icon"><i class="fa-solid fa-filter" /></span>
<span>Conditions</span> <span>Conditions</span>
</NuxtLink> </NuxtLink>
@ -60,13 +60,13 @@
</a> </a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<NuxtLink class="navbar-item" to="/logs" @click.native="e => changeRoute(e)" <NuxtLink class="navbar-item" to="/logs" @click.native="(e: MouseEvent) => changeRoute(e)"
v-if="config.app.file_logging"> v-if="config.app.file_logging">
<span class="icon"><i class="fa-solid fa-file-lines" /></span> <span class="icon"><i class="fa-solid fa-file-lines" /></span>
<span>Logs</span> <span>Logs</span>
</NuxtLink> </NuxtLink>
<NuxtLink class="navbar-item" to="/console" @click.native="e => changeRoute(e)" <NuxtLink class="navbar-item" to="/console" @click.native="(e: MouseEvent) => changeRoute(e)"
v-if="config.app.console_enabled"> v-if="config.app.console_enabled">
<span class="icon"><i class="fa-solid fa-terminal" /></span> <span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>Console</span> <span>Console</span>
@ -135,7 +135,7 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import 'assets/css/bulma.css' import 'assets/css/bulma.css'
import 'assets/css/style.css' import 'assets/css/style.css'
import 'assets/css/all.css' import 'assets/css/all.css'
@ -151,49 +151,73 @@ const loadedImage = ref()
const show_settings = ref(false) const show_settings = ref(false)
const loadingImage = ref(false) const loadingImage = ref(false)
const bg_enable = useStorage('random_bg', true) const bg_enable = useStorage('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.85) const bg_opacity = useStorage('random_bg_opacity', 0.95)
const showMenu = ref(false) const showMenu = ref(false)
const applyPreferredColorScheme = scheme => { const applyPreferredColorScheme = (scheme: string) => {
if (!scheme || 'auto' === scheme) { if (!scheme || scheme === 'auto') {
return return
} }
for (let s = 0; s < document.styleSheets.length; s++) { for (let s = 0; s < document.styleSheets.length; s++) {
for (let i = 0; i < document.styleSheets[s].cssRules.length; i++) { const styleSheet = document.styleSheets[s]
try {
const rule = document.styleSheets[s].cssRules[i] let rules: CSSRuleList
if (rule && rule.media && rule.media.mediaText.includes("prefers-color-scheme")) { try {
switch (scheme) { rules = styleSheet.cssRules
case "light": } catch (e) {
rule.media.appendMedium("original-prefers-color-scheme") // Cross-origin stylesheet
if (rule.media.mediaText.includes("light")) { console.debug("Unable to access stylesheet rules:", e)
rule.media.deleteMedium("(prefers-color-scheme: light)") continue
} }
if (rule.media.mediaText.includes("dark")) {
rule.media.deleteMedium("(prefers-color-scheme: dark)") for (let i = 0; i < rules.length; i++) {
} const rule = rules[i]
break
case "dark": if (rule instanceof CSSMediaRule && rule.media.mediaText.includes("prefers-color-scheme")) {
rule.media.appendMedium("(prefers-color-scheme: light)") const media = rule.media
rule.media.appendMedium("(prefers-color-scheme: dark)")
if (rule.media.mediaText.includes("original")) { const safeDelete = (medium: string) => {
rule.media.deleteMedium("original-prefers-color-scheme") if (media.mediaText.includes(medium)) {
} try {
break media.deleteMedium(medium)
default: } catch (e) {
rule.media.appendMedium("(prefers-color-scheme: dark)") console.debug(`Failed to delete medium "${medium}"`, e)
if (rule.media.mediaText.includes("light")) { }
rule.media.deleteMedium("(prefers-color-scheme: light)")
}
if (rule.media.mediaText.includes("original")) {
rule.media.deleteMedium("original-prefers-color-scheme")
}
break
} }
} }
} catch (e) {
console.debug(e) try {
switch (scheme) {
case "light":
if (!media.mediaText.includes("original-prefers-color-scheme")) {
media.appendMedium("original-prefers-color-scheme")
}
safeDelete("(prefers-color-scheme: light)")
safeDelete("(prefers-color-scheme: dark)")
break
case "dark":
if (!media.mediaText.includes("(prefers-color-scheme: light)")) {
media.appendMedium("(prefers-color-scheme: light)")
}
if (!media.mediaText.includes("(prefers-color-scheme: dark)")) {
media.appendMedium("(prefers-color-scheme: dark)")
}
safeDelete("original-prefers-color-scheme")
break
default:
if (!media.mediaText.includes("(prefers-color-scheme: dark)")) {
media.appendMedium("(prefers-color-scheme: dark)")
}
safeDelete("(prefers-color-scheme: light)")
safeDelete("original-prefers-color-scheme")
break
}
} catch (e) {
console.debug("Error modifying media rule:", e)
}
} }
} }
} }
@ -236,10 +260,10 @@ watch(bg_opacity, v => {
if (false === bg_enable.value) { if (false === bg_enable.value) {
return return
} }
document.querySelector('body').setAttribute("style", `opacity: ${v}`) document.querySelector('body')?.setAttribute("style", `opacity: ${v}`)
}) })
watch(loadedImage, v => { watch(loadedImage, _ => {
if (false === bg_enable.value) { if (false === bg_enable.value) {
return return
} }
@ -253,14 +277,14 @@ watch(loadedImage, v => {
"min-height": '100%', "min-height": '100%',
"min-width": '100%', "min-width": '100%',
"background-image": `url(${loadedImage.value})`, "background-image": `url(${loadedImage.value})`,
} } as any
html.setAttribute("style", Object.keys(style).map(k => `${k}: ${style[k]}`).join('; ').trim()) html.setAttribute("style", Object.keys(style).map(k => `${k}: ${style[k]}`).join('; ').trim())
html.classList.add('bg-fanart') html.classList.add('bg-fanart')
body.setAttribute("style", `opacity: ${bg_opacity.value}`) body?.setAttribute("style", `opacity: ${bg_opacity.value}`)
}) })
const handleImage = async enabled => { const handleImage = async (enabled: boolean) => {
if (false === enabled) { if (false === enabled) {
if (!loadedImage.value) { if (!loadedImage.value) {
return return
@ -272,9 +296,11 @@ const handleImage = async enabled => {
if (html.getAttribute("style")) { if (html.getAttribute("style")) {
html.removeAttribute("style") html.removeAttribute("style")
} }
if (body.getAttribute("style")) {
if (body?.getAttribute("style")) {
body.removeAttribute("style") body.removeAttribute("style")
} }
loadedImage.value = '' loadedImage.value = ''
return return
} }
@ -312,7 +338,7 @@ const loadImage = async (force = false) => {
} }
} }
const changeRoute = async (_, callback) => { const changeRoute = async (_: MouseEvent, callback: Function | null = null) => {
showMenu.value = false showMenu.value = false
document.querySelectorAll('div.has-dropdown').forEach(el => el.classList.remove('is-active')) document.querySelectorAll('div.has-dropdown').forEach(el => el.classList.remove('is-active'))
if (callback) { if (callback) {
@ -320,15 +346,16 @@ const changeRoute = async (_, callback) => {
} }
} }
const openMenu = e => { const openMenu = (e: MouseEvent) => {
const elm = e.target.closest('div.has-dropdown') const elm = (e.target as HTMLElement)?.closest('div.has-dropdown') as HTMLElement | null
document.querySelectorAll('div.has-dropdown').forEach(el => { document.querySelectorAll<HTMLElement>('div.has-dropdown').forEach(el => {
if (el !== elm) { if (el !== elm) {
el.classList.remove('is-active') el.classList.remove('is-active')
} }
}) })
e.target.closest('div.has-dropdown').classList.toggle('is-active') elm?.classList.toggle('is-active')
} }
</script> </script>

View file

@ -12,17 +12,17 @@
"web-types": "./web-types.json", "web-types": "./web-types.json",
"dependencies": { "dependencies": {
"@pinia/nuxt": "^0.11.1", "@pinia/nuxt": "^0.11.1",
"@sentry/nuxt": "^9.34.0", "@sentry/nuxt": "^9.35.0",
"@vueuse/core": "^13.4.0", "@vueuse/core": "^13.5.0",
"@vueuse/nuxt": "^13.4.0", "@vueuse/nuxt": "^13.5.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"cron-parser": "^5.3.0", "cron-parser": "^5.3.0",
"cronstrue": "^3.0.0", "cronstrue": "^3.0.0",
"floating-vue": "^5.2.2", "floating-vue": "^5.2.2",
"hls.js": "^1.6.5", "hls.js": "^1.6.7",
"moment": "^2.30.1", "moment": "^2.30.1",
"nuxt": "^3.17.5", "nuxt": "^3.17.6",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"vue": "^3.5.17", "vue": "^3.5.17",

View file

@ -203,7 +203,7 @@ const config = useConfigStore()
const socket = useSocketStore() const socket = useSocketStore()
const bg_enable = useStorage('random_bg', true) const bg_enable = useStorage('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.85) const bg_opacity = useStorage('random_bg_opacity', 0.95)
const sort_by = useStorage('sort_by', 'name') const sort_by = useStorage('sort_by', 'name')
const sort_order = useStorage('sort_order', 'asc') const sort_order = useStorage('sort_order', 'asc')

View file

@ -65,7 +65,7 @@ const config = useConfigStore()
const socket = useSocketStore() const socket = useSocketStore()
const bg_enable = useStorage<boolean>('random_bg', true) const bg_enable = useStorage<boolean>('random_bg', true)
const bg_opacity = useStorage<number>('random_bg_opacity', 0.85) const bg_opacity = useStorage<number>('random_bg_opacity', 0.95)
const terminal = ref<Terminal>() const terminal = ref<Terminal>()
const terminalFit = ref<FitAddon>() const terminalFit = ref<FitAddon>()

View file

@ -81,7 +81,7 @@ const stateStore = useStateStore()
const socket = useSocketStore() const socket = useSocketStore()
const box = useConfirm() const box = useConfirm()
const bg_enable = useStorage<boolean>('random_bg', true) const bg_enable = useStorage<boolean>('random_bg', true)
const bg_opacity = useStorage<number>('random_bg_opacity', 0.85) const bg_opacity = useStorage<number>('random_bg_opacity', 0.95)
const display_style = useStorage<string>('display_style', 'cards') const display_style = useStorage<string>('display_style', 'cards')
const show_thumbnail = useStorage<boolean>('show_thumbnail', true) const show_thumbnail = useStorage<boolean>('show_thumbnail', true)

View file

@ -127,7 +127,7 @@ const logContainer = useTemplateRef<HTMLDivElement>('logContainer')
const bottomMarker = useTemplateRef<HTMLDivElement>('bottomMarker') const bottomMarker = useTemplateRef<HTMLDivElement>('bottomMarker')
const textWrap = useStorage<boolean>('logs_wrap', true) const textWrap = useStorage<boolean>('logs_wrap', true)
const bg_enable = useStorage<boolean>('random_bg', true) const bg_enable = useStorage<boolean>('random_bg', true)
const bg_opacity = useStorage<number>('random_bg_opacity', 0.85) const bg_opacity = useStorage<number>('random_bg_opacity', 0.95)
const logs = ref<Array<log_line>>([]) const logs = ref<Array<log_line>>([])
const offset = ref<number>(0) const offset = ref<number>(0)

View file

@ -71,8 +71,7 @@ div.is-centered {
</div> </div>
</div> </div>
<div class="columns is-multiline is-mobile" v-if="!toggleForm && filteredTasks && filteredTasks.length > 0">
<div class="columns is-multiline is-mobile" v-if="filteredTasks && filteredTasks.length > 0">
<div class="column" v-if="'list' !== display_style"> <div class="column" v-if="'list' !== display_style">
<button type="button" class="button is-fullwidth is-ghost is-inverted" <button type="button" class="button is-fullwidth is-ghost is-inverted"
@click="masterSelectAll = !masterSelectAll"> @click="masterSelectAll = !masterSelectAll">
@ -103,10 +102,8 @@ div.is-centered {
</span> </span>
</button> </button>
</div> </div>
</div> </div>
<div class="columns is-multiline" v-if="!isLoading && !toggleForm && filteredTasks && filteredTasks.length > 0"> <div class="columns is-multiline" v-if="!isLoading && !toggleForm && filteredTasks && filteredTasks.length > 0">
<template v-if="'list' === display_style"> <template v-if="'list' === display_style">
<div class="column is-12"> <div class="column is-12">
@ -152,9 +149,21 @@ div.is-centered {
{{ remove_tags(item.name) }} {{ remove_tags(item.name) }}
</NuxtLink> </NuxtLink>
</div> </div>
<div v-if="item.preset"> <div>
<span class="icon"><i class="fa-solid fa-tv" /></span> <span class="icon-text">
<span>{{ item.preset ?? config.app.default_preset }}</span> <span class="icon">
<i class="fa-solid"
:class="{ 'fa-circle-pause': item.auto_start, 'fa-circle-play': !item.auto_start }" />
</span>
<span>
{{ item.auto_start ? 'Auto' : 'Manual' }}
</span>
</span>
&nbsp;
<span class="icon-text" v-if="item.preset">
<span class="icon"><i class="fa-solid fa-tv" /></span>
<span>{{ item.preset ?? config.app.default_preset }}</span>
</span>
</div> </div>
</td> </td>
<td class="is-vcentered has-text-centered"> <td class="is-vcentered has-text-centered">
@ -218,7 +227,12 @@ div.is-centered {
{{ tag }} {{ tag }}
</span> </span>
</div> </div>
<div class="control">
<span class="icon" v-tooltip="`${item.auto_start ? 'Auto' : 'Manual'} start`">
<i class="fa-solid"
:class="{ 'fa-circle-pause': item.auto_start, 'fa-circle-play': !item.auto_start }" />
</span>
</div>
<div class="control"> <div class="control">
<a class="has-text-primary" v-tooltip="'Export task.'" @click.prevent="exportItem(item)"> <a class="has-text-primary" v-tooltip="'Export task.'" @click.prevent="exportItem(item)">
<span class="icon"><i class="fa-solid fa-file-export" /></span> <span class="icon"><i class="fa-solid fa-file-export" /></span>
@ -653,6 +667,10 @@ const runNow = async (item: task_item, mass: boolean = false) => {
data.cli = item.cli data.cli = item.cli
} }
if (item?.auto_start !== undefined) {
data.auto_start = item.auto_start
}
socket.emit('add_url', data) socket.emit('add_url', data)
if (true === mass) { if (true === mass) {
@ -685,6 +703,7 @@ const exportItem = async (item: task_item) => {
preset: info.preset, preset: info.preset,
timer: info.timer, timer: info.timer,
folder: info.folder, folder: info.folder,
auto_start: info?.auto_start ?? true,
} as exported_task } as exported_task
if (info.template) { if (info.template) {

File diff suppressed because it is too large Load diff

39
uv.lock
View file

@ -95,14 +95,15 @@ wheels = [
[[package]] [[package]]
name = "aiosignal" name = "aiosignal"
version = "1.3.2" version = "1.4.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "frozenlist" }, { name = "frozenlist" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424 } sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597 }, { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490 },
] ]
[[package]] [[package]]
@ -773,7 +774,7 @@ wheels = [
[[package]] [[package]]
name = "pyinstaller" name = "pyinstaller"
version = "6.14.1" version = "6.14.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "altgraph" }, { name = "altgraph" },
@ -784,19 +785,19 @@ dependencies = [
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "setuptools" }, { name = "setuptools" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/d66d3a9c34349d73eb099401060e2591da8ccc5ed427e54fff3961302513/pyinstaller-6.14.1.tar.gz", hash = "sha256:35d5c06a668e21f0122178dbf20e40fd21012dc8f6170042af6050c4e7b3edca", size = 4284317 } sdist = { url = "https://files.pythonhosted.org/packages/f8/25/41d6be08d65bdc5126e86d854f5767397483acf360f2c95c890e3fa96a31/pyinstaller-6.14.2.tar.gz", hash = "sha256:142cce0719e79315f0cc26400c2e5c45d9b6b17e7e0491fee444a9f8f16f4917", size = 4284885 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/43/f6/fa56e547fe849db4b8da0acaad6101a6382c18370c7e0f378a1cf0ea89f0/pyinstaller-6.14.1-py3-none-macosx_10_13_universal2.whl", hash = "sha256:da559cfe4f7a20a7ebdafdf12ea2a03ea94d3caa49736ef53ee2c155d78422c9", size = 999937 }, { url = "https://files.pythonhosted.org/packages/a0/dd/e5f4a4be80e291d2443ac7e73fa78f17003e4f2e3ec15a2ffdea0583a5c6/pyinstaller-6.14.2-py3-none-macosx_10_13_universal2.whl", hash = "sha256:d77d18bf5343a1afef2772393d7a489d4ec2282dee5bca549803fc0d74b78330", size = 1000610 },
{ url = "https://files.pythonhosted.org/packages/af/a6/a2814978f47ae038b1ce112717adbdcfd8dfb9504e5c52437902331cde1a/pyinstaller-6.14.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:f040d1e3d42af3730104078d10d4a8ca3350bd1c78de48f12e1b26f761e0cbc3", size = 719569 }, { url = "https://files.pythonhosted.org/packages/8f/a5/0780ce0f9916012cafd65673a4cc3d59aee65af84c773f49b36aa98d0ce9/pyinstaller-6.14.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:3fa0c391e1300a9fd7752eb1ffe2950112b88fba9d2743eee2ef218a15f4705f", size = 720241 },
{ url = "https://files.pythonhosted.org/packages/35/f0/86391a4c0f558aef43a7dac8f678d46f4e5b84bd133308e3ea81f7384ab9/pyinstaller-6.14.1-py3-none-manylinux2014_i686.whl", hash = "sha256:7b8813fb2d5a82ef4ceffc342ed9a11a6fc1ef21e68e833dbd8fedb8a188d3f5", size = 729824 }, { url = "https://files.pythonhosted.org/packages/c8/d6/bf9e385cc20ee5dba5248716eda4d1271599c9ff2e173a0e7577d57866f0/pyinstaller-6.14.2-py3-none-manylinux2014_i686.whl", hash = "sha256:077efb2d01d16d9c8fdda3ad52788f0fead2791c5cec9ed6ce058af7e26eb74b", size = 730496 },
{ url = "https://files.pythonhosted.org/packages/e5/88/446814e335d937406e6e1ae4a77ed922b8eea8b90f3aaf69427a16b58ed2/pyinstaller-6.14.1-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e2cfdbc6dd41d19872054fc233da18856ec422a7fdea899b6985ae04f980376a", size = 727937 }, { url = "https://files.pythonhosted.org/packages/97/6f/358d23398cf210ba5a588e1311b6611762e353670d11838633cbb4c5ff79/pyinstaller-6.14.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:fdd2bd020a18736806a6bd5d3c4352f1209b427a96ad6c459d88aec1d90c4f21", size = 728609 },
{ url = "https://files.pythonhosted.org/packages/c6/0f/5aa891c61d303ad4a794b7e2f864aacf64fe0f6f5559e2aec0f742595fad/pyinstaller-6.14.1-py3-none-manylinux2014_s390x.whl", hash = "sha256:a4d53b3ecb5786b097b79bda88c4089186fc1498ef7eaa6cee57599ae459241e", size = 724762 }, { url = "https://files.pythonhosted.org/packages/9f/08/379af897977d77a4cf7d8c50dbe0135950be6d97be24c3ca4b45ccccd33b/pyinstaller-6.14.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:03862c6b3cf7b16843d24b529f89cd4077cbe467883cd54ce7a81940d6da09d3", size = 725434 },
{ url = "https://files.pythonhosted.org/packages/c5/92/e32ec0a1754852a8ed5a60f6746c6483e3da68aee97d314f3a3a99e0ed9e/pyinstaller-6.14.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c48dd257f77f61ebea2d1fdbaf11243730f2271873c88d3b5ecb7869525d3bcb", size = 724957 }, { url = "https://files.pythonhosted.org/packages/b8/98/460a32d2e325ad0ea81e4df478a8d84b5ebe0ceaca0cd3088f16afcaba5f/pyinstaller-6.14.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:78827a21ada2a848e98671852d20d74b2955b6e2aaf2359ed13a462e1a603d84", size = 725629 },
{ url = "https://files.pythonhosted.org/packages/c3/66/1260f384e47bf939f6238f791d4cda7edb94771d2fa0a451e0edb21ac9c7/pyinstaller-6.14.1-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:5b05cbb2ffc033b4681268159b82bac94b875475c339603c7e605f00a73c8746", size = 724132 }, { url = "https://files.pythonhosted.org/packages/6f/bc/16eef174580bf4ca386479e48d5be8a977bf36cb6a9006814d754834c773/pyinstaller-6.14.2-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:185710ab1503dfdfa14c43237d394d96ac183422d588294be42531480dfa6c38", size = 724803 },
{ url = "https://files.pythonhosted.org/packages/d2/8b/8570ab94ec07e0b2b1203f45840353ee76aa067a2540c97da43d43477b26/pyinstaller-6.14.1-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d5fd73757c8ea9adb2f9c1f81656335ba9890029ede3031835d768fde36e89f0", size = 723847 }, { url = "https://files.pythonhosted.org/packages/3c/6b/7162d59ee37e6883a5c4830cfe7dfb06c4997cc6aeb5f170d30ae76d9a39/pyinstaller-6.14.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:6c673a7e761bd4a2560cfd5dbe1ccdcfe2dff304b774e6e5242fc5afed953661", size = 724519 },
{ url = "https://files.pythonhosted.org/packages/d5/43/6c68dc9e53b09ff948d6e46477932b387832bbb920c48061d734ef089368/pyinstaller-6.14.1-py3-none-win32.whl", hash = "sha256:547f7a93592e408cbfd093ce9fd9631215387dab0dbf3130351d3b0b1186a534", size = 1299744 }, { url = "https://files.pythonhosted.org/packages/2a/26/d9559ac0851b1e3427a6b3ab0cd9edc8082b114f2499f78af532fdd5e14d/pyinstaller-6.14.2-py3-none-win32.whl", hash = "sha256:1697601aa788e3a52f0b5e620b4741a34b82e6f222ec6e1318b3a1349f566bb2", size = 1300415 },
{ url = "https://files.pythonhosted.org/packages/7c/dd/bb8d5bcb0592f7f5d454ad308051d00ed34f8b08d5003400b825cfe35513/pyinstaller-6.14.1-py3-none-win_amd64.whl", hash = "sha256:0794290b4b56ef9d35858334deb29f36ec1e1f193b0f825212a0aa5a1bec5a2f", size = 1357625 }, { url = "https://files.pythonhosted.org/packages/79/69/111c85292ff99567a2408a6c6e9bf0b31910239f82b97d106321762d222c/pyinstaller-6.14.2-py3-none-win_amd64.whl", hash = "sha256:e10e0e67288d6dcb5898a917dd1d4272aa0ff33f197ad49a0e39618009d63ed9", size = 1358298 },
{ url = "https://files.pythonhosted.org/packages/89/57/8a8979737980e50aa5031b77318ce783759bf25be2956317f2e1d7a65a09/pyinstaller-6.14.1-py3-none-win_arm64.whl", hash = "sha256:d9d99695827f892cb19644106da30681363e8ff27b8326ac8416d62890ab9c74", size = 1298607 }, { url = "https://files.pythonhosted.org/packages/3d/e2/c267cadb3307a4979757b086674f592669c04bd960a8d2746dd2d18ad57d/pyinstaller-6.14.2-py3-none-win_arm64.whl", hash = "sha256:69fd11ca57e572387826afaa4a1b3d4cb74927d76f231f0308c0bd7872ca5ac1", size = 1299280 },
] ]
[[package]] [[package]]
@ -1246,11 +1247,11 @@ wheels = [
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.14.0" version = "4.14.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423 } sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839 }, { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906 },
] ]
[[package]] [[package]]