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
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:
commit
4c11e178b2
23 changed files with 1463 additions and 798 deletions
1
API.md
1
API.md
|
|
@ -210,6 +210,7 @@ or an error:
|
|||
"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.
|
||||
"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)
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import logging
|
|||
from collections import OrderedDict
|
||||
from datetime import UTC, datetime
|
||||
from email.utils import formatdate
|
||||
from enum import Enum
|
||||
from sqlite3 import Connection
|
||||
|
||||
from .config import Config
|
||||
from .Download import Download
|
||||
from .ItemDTO import ItemDTO
|
||||
from .Utils import init_class
|
||||
|
|
@ -15,60 +15,100 @@ from .Utils import init_class
|
|||
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:
|
||||
"""
|
||||
Persistent queue.
|
||||
"""
|
||||
|
||||
type: str = None
|
||||
dict: OrderedDict[str, Download] = None
|
||||
config: Config = None
|
||||
_type: StoreType = None
|
||||
"""Type of the store, e.g., DONE, QUEUE, PENDING."""
|
||||
|
||||
connection: Connection
|
||||
_dict: OrderedDict[str, Download] = None
|
||||
"""Ordered dictionary to store Download objects."""
|
||||
|
||||
def __init__(self, type: str, connection: Connection):
|
||||
self.dict = OrderedDict()
|
||||
self.type = type
|
||||
self.config = Config.get_instance()
|
||||
self.connection = connection
|
||||
_connection: Connection
|
||||
"""SQLite connection to the database."""
|
||||
|
||||
def __init__(self, type: StoreType, connection: Connection):
|
||||
self._dict = OrderedDict()
|
||||
self._type = type
|
||||
self._connection = connection
|
||||
|
||||
def load(self) -> None:
|
||||
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:
|
||||
if not key and not url:
|
||||
msg = "key or url must be provided."
|
||||
raise KeyError(msg)
|
||||
|
||||
if key and key in self.dict:
|
||||
if key and key in self._dict:
|
||||
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:
|
||||
if not key and not url:
|
||||
msg = "key or url must be provided."
|
||||
raise KeyError(msg)
|
||||
|
||||
for i in self.dict:
|
||||
if (key and self.dict[i].info._id == key) or (url and self.dict[i].info.url == url):
|
||||
return self.dict[i]
|
||||
for i in self._dict:
|
||||
if (key and self._dict[i].info._id == key) or (url and self._dict[i].info.url == url):
|
||||
return self._dict[i]
|
||||
|
||||
msg: str = f"{key=} or {url=} not found."
|
||||
raise KeyError(msg)
|
||||
|
||||
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]]:
|
||||
return self.dict.items()
|
||||
return self._dict.items()
|
||||
|
||||
def saved_items(self) -> list[tuple[str, ItemDTO]]:
|
||||
items: list[tuple[str, ItemDTO]] = []
|
||||
|
||||
cursor = self.connection.execute(
|
||||
'SELECT "id", "data", "created_at" FROM "history" WHERE "type" = ? ORDER BY "created_at" ASC', (self.type,)
|
||||
cursor = self._connection.execute(
|
||||
'SELECT "id", "data", "created_at" FROM "history" WHERE "type" = ? ORDER BY "created_at" ASC',
|
||||
(str(self._type),),
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
self.dict.update({value.info._id: value})
|
||||
self._update_store_item(self.type, value.info)
|
||||
self._dict.update({value.info._id: value})
|
||||
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:
|
||||
self.dict.pop(key, None)
|
||||
self._dict.pop(key, None)
|
||||
self._delete_store_item(key)
|
||||
|
||||
def next(self) -> tuple[str, Download]:
|
||||
return next(iter(self.dict.items()))
|
||||
return next(iter(self._dict.items()))
|
||||
|
||||
def empty(self):
|
||||
return not bool(self.dict)
|
||||
return not bool(self._dict)
|
||||
|
||||
def has_downloads(self):
|
||||
if 0 == len(self.dict):
|
||||
if 0 == len(self._dict):
|
||||
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:
|
||||
for key in self.dict:
|
||||
if self.dict[key].started() is False and self.dict[key].is_cancelled() is False:
|
||||
return self.dict[key]
|
||||
for key in self._dict:
|
||||
ref = self._dict[key]
|
||||
if ref.info.auto_start and ref.started() is False and ref.is_cancelled() is False:
|
||||
return ref
|
||||
|
||||
return None
|
||||
|
||||
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
|
||||
|
||||
def _update_store_item(self, type: str, item: ItemDTO) -> None:
|
||||
def _update_store_item(self, type: StoreType, item: ItemDTO) -> None:
|
||||
sqlStatement = """
|
||||
INSERT INTO "history" ("id", "type", "url", "data")
|
||||
VALUES (?, ?, ?, ?)
|
||||
|
|
@ -141,14 +182,14 @@ class DataStore:
|
|||
except AttributeError:
|
||||
pass
|
||||
|
||||
self.connection.execute(
|
||||
self._connection.execute(
|
||||
sqlStatement.strip(),
|
||||
(
|
||||
stored._id,
|
||||
type,
|
||||
str(type),
|
||||
stored.url,
|
||||
stored.json(),
|
||||
type,
|
||||
str(type),
|
||||
stored.url,
|
||||
stored.json(),
|
||||
datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
|
|
@ -156,4 +197,4 @@ class DataStore:
|
|||
)
|
||||
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -13,11 +13,10 @@ from typing import TYPE_CHECKING
|
|||
import yt_dlp
|
||||
from aiohttp import web
|
||||
|
||||
from app.library.ag_utils import ag
|
||||
|
||||
from .ag_utils import ag
|
||||
from .conditions import Conditions
|
||||
from .config import Config
|
||||
from .DataStore import DataStore
|
||||
from .DataStore import DataStore, StoreType
|
||||
from .Download import Download
|
||||
from .Events import EventBus, Events
|
||||
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.
|
||||
"""
|
||||
|
||||
TYPE_DONE: str = "done"
|
||||
"""Queue type for completed downloads."""
|
||||
|
||||
TYPE_QUEUE: str = "queue"
|
||||
"""Queue type for pending downloads."""
|
||||
|
||||
paused: asyncio.Event
|
||||
"""Event to pause the download queue."""
|
||||
|
||||
|
|
@ -74,6 +67,9 @@ class DownloadQueue(metaclass=Singleton):
|
|||
done: DataStore
|
||||
"""DataStore for the completed downloads."""
|
||||
|
||||
pending: DataStore
|
||||
"""DataStore for the pending downloads."""
|
||||
|
||||
workers: asyncio.Semaphore
|
||||
"""Semaphore to limit the number of concurrent downloads."""
|
||||
|
||||
|
|
@ -85,8 +81,8 @@ class DownloadQueue(metaclass=Singleton):
|
|||
|
||||
self.config = config or Config.get_instance()
|
||||
self._notify = EventBus.get_instance()
|
||||
self.done = DataStore(type=DownloadQueue.TYPE_DONE, connection=connection)
|
||||
self.queue = DataStore(type=DownloadQueue.TYPE_QUEUE, connection=connection)
|
||||
self.done = DataStore(type=StoreType.DONE, connection=connection)
|
||||
self.queue = DataStore(type=StoreType.QUEUE, connection=connection)
|
||||
self.done.load()
|
||||
self.queue.load()
|
||||
self.paused = asyncio.Event()
|
||||
|
|
@ -154,6 +150,94 @@ class DownloadQueue(metaclass=Singleton):
|
|||
)
|
||||
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:
|
||||
"""
|
||||
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),
|
||||
options=options,
|
||||
cli=item.cli,
|
||||
auto_start=item.auto_start,
|
||||
extras=item.extras,
|
||||
)
|
||||
|
||||
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 = ""
|
||||
if filtered_logs := extract_ytdlp_logs(logs):
|
||||
|
|
@ -411,7 +496,10 @@ class DownloadQueue(metaclass=Singleton):
|
|||
if _requeue:
|
||||
NotifyEvent = Events.ADDED
|
||||
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:
|
||||
dlInfo.info.status = "not_live"
|
||||
itemDownload = self.done.put(dlInfo)
|
||||
|
|
@ -423,7 +511,10 @@ class DownloadQueue(metaclass=Singleton):
|
|||
else:
|
||||
NotifyEvent = Events.ADDED
|
||||
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())
|
||||
|
||||
|
|
@ -632,7 +723,7 @@ class DownloadQueue(metaclass=Singleton):
|
|||
self.queue.delete(id)
|
||||
await self._notify.emit(Events.CANCELLED, data=item.info.serialize())
|
||||
item.info.status = "cancelled"
|
||||
item.info.error = "Cancelled by user."
|
||||
# item.info.error = "Cancelled by user."
|
||||
self.done.put(item)
|
||||
await self._notify.emit(Events.COMPLETED, data=item.info.serialize())
|
||||
LOG.info(f"Deleted from queue {item_ref}")
|
||||
|
|
@ -755,7 +846,7 @@ class DownloadQueue(metaclass=Singleton):
|
|||
LOG.info("Download pool resumed downloading.")
|
||||
|
||||
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
|
||||
|
||||
if entry.is_live:
|
||||
|
|
@ -816,7 +907,7 @@ class DownloadQueue(metaclass=Singleton):
|
|||
if entry.is_cancelled() is True:
|
||||
await self._notify.emit(Events.CANCELLED, data=entry.info.serialize())
|
||||
entry.info.status = "cancelled"
|
||||
entry.info.error = "Cancelled by user."
|
||||
# entry.info.error = "Cancelled by user."
|
||||
|
||||
self.done.put(value=entry)
|
||||
await self._notify.emit(Events.COMPLETED, data=entry.info.serialize())
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ class Item:
|
|||
requeued: bool = False
|
||||
"""If the item has been retried already via conditions."""
|
||||
|
||||
auto_start: bool = True
|
||||
"""If the item should be started automatically."""
|
||||
|
||||
def serialize(self) -> dict:
|
||||
return self.__dict__.copy()
|
||||
|
||||
|
|
@ -95,7 +98,7 @@ class Item:
|
|||
|
||||
data = {}
|
||||
for k, v in self.serialize().items():
|
||||
if not v:
|
||||
if not v and k not in ("auto_start"):
|
||||
continue
|
||||
|
||||
if k == "cli":
|
||||
|
|
@ -157,6 +160,10 @@ class Item:
|
|||
if item.get("template") and isinstance(item.get("template"), str):
|
||||
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")
|
||||
if extras and isinstance(extras, dict) and len(extras) > 0:
|
||||
data["extras"] = extras
|
||||
|
|
@ -213,6 +220,7 @@ class ItemDTO:
|
|||
options: dict = field(default_factory=dict)
|
||||
extras: dict = field(default_factory=dict)
|
||||
cli: str = ""
|
||||
auto_start: bool = True
|
||||
|
||||
# yt-dlp injected fields.
|
||||
tmpfilename: str | None = None
|
||||
|
|
@ -239,7 +247,7 @@ class ItemDTO:
|
|||
return self._id
|
||||
|
||||
def name(self) -> str:
|
||||
return f'id="{self.id}", title="{self.title}"'
|
||||
return f'id="{self.id}", title="{self.title}"'
|
||||
|
||||
@staticmethod
|
||||
def removed_fields() -> tuple:
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ class Task:
|
|||
timer: str = ""
|
||||
template: str = ""
|
||||
cli: str = ""
|
||||
auto_start: bool = True
|
||||
|
||||
def serialize(self) -> dict:
|
||||
return self.__dict__
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
break
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Error getting archive ID: {e}")
|
||||
|
||||
return idDict
|
||||
|
|
|
|||
|
|
@ -128,3 +128,27 @@ async def archive_item(config: Config, data: dict):
|
|||
LOG.info(f"Archiving url '{data['url']}' with id '{idDict['archive_id']}'.")
|
||||
else:
|
||||
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)
|
||||
|
|
|
|||
2
ui/@types/config.d.ts
vendored
2
ui/@types/config.d.ts
vendored
|
|
@ -42,6 +42,8 @@ type AppConfig = {
|
|||
app_build_date: string
|
||||
/** App branch name, e.g. "main" or "develop" */
|
||||
app_branch: string
|
||||
/** When the app started */
|
||||
started: number
|
||||
}
|
||||
|
||||
type Preset = {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ type task_item = {
|
|||
cli?: string,
|
||||
timer?: string,
|
||||
in_progress?: boolean,
|
||||
auto_start?: boolean,
|
||||
}
|
||||
|
||||
type exported_task = task_item & { _type: string, _version: string }
|
||||
|
|
|
|||
|
|
@ -489,7 +489,7 @@ const hideThumbnail = useStorage('hideThumbnailHistory', false)
|
|||
const direction = useStorage('sortCompleted', 'desc')
|
||||
const display_style = useStorage('display_style', 'cards')
|
||||
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 masterSelectAll = ref(false)
|
||||
|
|
@ -601,9 +601,9 @@ const deleteSelectedItems = () => {
|
|||
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) {
|
||||
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)) {
|
||||
|
|
@ -620,10 +620,11 @@ const deleteSelectedItems = () => {
|
|||
remove_file: config.app.remove_files,
|
||||
})
|
||||
}
|
||||
selectedElms.value = []
|
||||
}
|
||||
|
||||
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)) {
|
||||
return
|
||||
}
|
||||
|
|
@ -636,7 +637,7 @@ const clearCompleted = () => {
|
|||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -731,7 +732,7 @@ const setStatus = item => {
|
|||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -781,11 +782,12 @@ const archiveItem = async (item, opts = {}) => {
|
|||
}
|
||||
|
||||
const removeItem = item => {
|
||||
let msg = `Remove '${item.title ?? item.id ?? item.url ?? '??'}'?`
|
||||
if (item.status === 'finished' && item.filename && config.app.remove_files) {
|
||||
msg += '\nThis will delete the file from the server if it exists.'
|
||||
let msg = `${config.app.remove_files ? 'Remove' : 'Clear'} '${item.title ?? item.id ?? item.url ?? '??'}'?`
|
||||
if (item.status === 'finished' && config.app.remove_files) {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -809,7 +811,7 @@ const retryItem = (item, re_add = false) => {
|
|||
socket.emit('item_delete', { id: item._id, remove_file: false })
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
|
@ -851,6 +853,8 @@ const downloadSelected = async () => {
|
|||
files_list.push(item.folder ? item.folder + '/' + item.filename : item.filename)
|
||||
}
|
||||
|
||||
selectedElms.value = []
|
||||
|
||||
try {
|
||||
const response = await request('/api/file/download', {
|
||||
method: 'POST',
|
||||
|
|
|
|||
|
|
@ -9,11 +9,19 @@
|
|||
<span class="icon"><i class="fa-solid fa-link" /></span>
|
||||
URLs
|
||||
</label>
|
||||
<div class="control">
|
||||
<input type="text" class="input" id="url" placeholder="Video or playlist link"
|
||||
:disabled="!socket.isConnected || addInProgress" v-model="form.url">
|
||||
<div class="field is-grouped">
|
||||
<div class="control is-expanded">
|
||||
<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>
|
||||
<span class="help is-bold">
|
||||
<span class="help is-bold is-unselectable">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>You can add multiple URLs separated by <code
|
||||
class="is-bold">{{ getSeparatorsName(separator) }}</code>.</span>
|
||||
|
|
@ -233,6 +241,7 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import 'assets/css/bulma-switch.css'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
const props = defineProps({
|
||||
|
|
@ -264,6 +273,7 @@ const getSeparatorsName = (value) => {
|
|||
|
||||
const showAdvanced = useStorage('show_advanced', false)
|
||||
const separator = useStorage('url_separator', separators[0].value)
|
||||
const auto_start = useStorage('auto_start', true)
|
||||
|
||||
const addInProgress = ref(false)
|
||||
|
||||
|
|
@ -300,6 +310,7 @@ const addDownload = async () => {
|
|||
template: config.app.basic_mode ? null : form.value.template,
|
||||
cookies: config.app.basic_mode ? '' : form.value.cookies,
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,19 @@
|
|||
</span>
|
||||
</button>
|
||||
</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)">
|
||||
<button type="button" class="button is-fullwidth is-warning" :disabled="!hasSelected" @click="cancelSelected">
|
||||
<span class="icon"><i class="fa-solid fa-eject" /></span>
|
||||
|
|
@ -105,6 +118,14 @@
|
|||
<td class="is-vcentered is-items-center">
|
||||
<Dropdown icons="fa-solid fa-cogs" @open_state="s => table_container = !s"
|
||||
: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)">
|
||||
<NuxtLink class="dropdown-item has-text-danger" @click="embed_url = getEmbedable(item.url)">
|
||||
<span class="icon"><i class="fa-solid fa-play" /></span>
|
||||
|
|
@ -219,6 +240,18 @@
|
|||
<span>Cancel</span>
|
||||
</button>
|
||||
</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">
|
||||
<Dropdown icons="fa-solid fa-cogs" @open_state="s => table_container = !s" label="Actions">
|
||||
<template v-if="isEmbedable(item.url)">
|
||||
|
|
@ -294,7 +327,7 @@ const showQueue = useStorage('showQueue', true)
|
|||
const hideThumbnail = useStorage('hideThumbnailQueue', false)
|
||||
const display_style = useStorage('display_style', 'cards')
|
||||
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 masterSelectAll = ref(false)
|
||||
|
|
@ -324,8 +357,42 @@ const filteredItems = computed(() => {
|
|||
|
||||
const hasSelected = computed(() => selectedElms.value.length > 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 => {
|
||||
if (!item.auto_start) {
|
||||
return 'fa-hourglass-half'
|
||||
}
|
||||
|
||||
if ('downloading' === item.status && item.is_live) {
|
||||
return 'fa-globe fa-spin'
|
||||
}
|
||||
|
|
@ -350,6 +417,10 @@ const setIcon = item => {
|
|||
}
|
||||
|
||||
const setStatus = item => {
|
||||
if (!item.auto_start) {
|
||||
return 'Pending'
|
||||
}
|
||||
|
||||
if (null === item.status && true === config.paused) {
|
||||
return 'Paused'
|
||||
}
|
||||
|
|
@ -378,7 +449,7 @@ const setIconColor = item => {
|
|||
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'
|
||||
}
|
||||
|
||||
|
|
@ -422,8 +493,12 @@ const percentPipe = value => {
|
|||
const updateProgress = (item) => {
|
||||
let string = ''
|
||||
|
||||
if (!item.auto_start) {
|
||||
return 'Manual start'
|
||||
}
|
||||
|
||||
if (null === item.status && true === config.paused) {
|
||||
return 'Paused'
|
||||
return 'Global Pause'
|
||||
}
|
||||
|
||||
if ('postprocessing' === item.status) {
|
||||
|
|
@ -482,6 +557,65 @@ const cancelItems = item => {
|
|||
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
|
||||
|
||||
watch(embed_url, v => {
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ defineProps({
|
|||
})
|
||||
|
||||
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 allow_toasts = useStorage('allow_toasts', true)
|
||||
const reduce_confirm = useStorage('reduce_confirm', false)
|
||||
|
|
|
|||
|
|
@ -195,6 +195,26 @@
|
|||
</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="field">
|
||||
<label class="label is-inline" for="cli_options">
|
||||
|
|
@ -269,6 +289,7 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import 'assets/css/bulma-switch.css'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { CronExpressionParser } from 'cron-parser'
|
||||
import { decode } from '~/utils/importer'
|
||||
|
|
@ -392,6 +413,8 @@ const importItem = async () => {
|
|||
form.cli = item.cli
|
||||
}
|
||||
|
||||
form.auto_start = item?.auto_start ?? true
|
||||
|
||||
if (item.preset) {
|
||||
// -- check if the preset exists in config.presets
|
||||
const preset = config.presets.find(p => p.name === item.preset)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<nav class="navbar is-mobile is-dark">
|
||||
|
||||
<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'">
|
||||
<span class="is-text-overflow">
|
||||
<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-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">
|
||||
<span class="icon"><i class="fa-solid fa-folder-tree" /></span>
|
||||
<span>Files</span>
|
||||
</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>Presets</span>
|
||||
</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>Tasks</span>
|
||||
</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"><i class="fa-solid fa-paper-plane" /></span>
|
||||
<span>Notifications</span>
|
||||
</span>
|
||||
</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>Conditions</span>
|
||||
</NuxtLink>
|
||||
|
|
@ -60,13 +60,13 @@
|
|||
</a>
|
||||
|
||||
<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">
|
||||
<span class="icon"><i class="fa-solid fa-file-lines" /></span>
|
||||
<span>Logs</span>
|
||||
</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">
|
||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||
<span>Console</span>
|
||||
|
|
@ -135,7 +135,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import 'assets/css/bulma.css'
|
||||
import 'assets/css/style.css'
|
||||
import 'assets/css/all.css'
|
||||
|
|
@ -151,49 +151,73 @@ const loadedImage = ref()
|
|||
const show_settings = ref(false)
|
||||
const loadingImage = ref(false)
|
||||
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 applyPreferredColorScheme = scheme => {
|
||||
if (!scheme || 'auto' === scheme) {
|
||||
const applyPreferredColorScheme = (scheme: string) => {
|
||||
if (!scheme || scheme === 'auto') {
|
||||
return
|
||||
}
|
||||
|
||||
for (let s = 0; s < document.styleSheets.length; s++) {
|
||||
for (let i = 0; i < document.styleSheets[s].cssRules.length; i++) {
|
||||
try {
|
||||
const rule = document.styleSheets[s].cssRules[i]
|
||||
if (rule && rule.media && rule.media.mediaText.includes("prefers-color-scheme")) {
|
||||
switch (scheme) {
|
||||
case "light":
|
||||
rule.media.appendMedium("original-prefers-color-scheme")
|
||||
if (rule.media.mediaText.includes("light")) {
|
||||
rule.media.deleteMedium("(prefers-color-scheme: light)")
|
||||
}
|
||||
if (rule.media.mediaText.includes("dark")) {
|
||||
rule.media.deleteMedium("(prefers-color-scheme: dark)")
|
||||
}
|
||||
break
|
||||
case "dark":
|
||||
rule.media.appendMedium("(prefers-color-scheme: light)")
|
||||
rule.media.appendMedium("(prefers-color-scheme: dark)")
|
||||
if (rule.media.mediaText.includes("original")) {
|
||||
rule.media.deleteMedium("original-prefers-color-scheme")
|
||||
}
|
||||
break
|
||||
default:
|
||||
rule.media.appendMedium("(prefers-color-scheme: dark)")
|
||||
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
|
||||
const styleSheet = document.styleSheets[s]
|
||||
|
||||
let rules: CSSRuleList
|
||||
try {
|
||||
rules = styleSheet.cssRules
|
||||
} catch (e) {
|
||||
// Cross-origin stylesheet
|
||||
console.debug("Unable to access stylesheet rules:", e)
|
||||
continue
|
||||
}
|
||||
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const rule = rules[i]
|
||||
|
||||
if (rule instanceof CSSMediaRule && rule.media.mediaText.includes("prefers-color-scheme")) {
|
||||
const media = rule.media
|
||||
|
||||
const safeDelete = (medium: string) => {
|
||||
if (media.mediaText.includes(medium)) {
|
||||
try {
|
||||
media.deleteMedium(medium)
|
||||
} catch (e) {
|
||||
console.debug(`Failed to delete medium "${medium}"`, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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) {
|
||||
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) {
|
||||
return
|
||||
}
|
||||
|
|
@ -253,14 +277,14 @@ watch(loadedImage, v => {
|
|||
"min-height": '100%',
|
||||
"min-width": '100%',
|
||||
"background-image": `url(${loadedImage.value})`,
|
||||
}
|
||||
} as any
|
||||
|
||||
html.setAttribute("style", Object.keys(style).map(k => `${k}: ${style[k]}`).join('; ').trim())
|
||||
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 (!loadedImage.value) {
|
||||
return
|
||||
|
|
@ -272,9 +296,11 @@ const handleImage = async enabled => {
|
|||
if (html.getAttribute("style")) {
|
||||
html.removeAttribute("style")
|
||||
}
|
||||
if (body.getAttribute("style")) {
|
||||
|
||||
if (body?.getAttribute("style")) {
|
||||
body.removeAttribute("style")
|
||||
}
|
||||
|
||||
loadedImage.value = ''
|
||||
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
|
||||
document.querySelectorAll('div.has-dropdown').forEach(el => el.classList.remove('is-active'))
|
||||
if (callback) {
|
||||
|
|
@ -320,15 +346,16 @@ const changeRoute = async (_, callback) => {
|
|||
}
|
||||
}
|
||||
|
||||
const openMenu = e => {
|
||||
const elm = e.target.closest('div.has-dropdown')
|
||||
const openMenu = (e: MouseEvent) => {
|
||||
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) {
|
||||
el.classList.remove('is-active')
|
||||
}
|
||||
})
|
||||
|
||||
e.target.closest('div.has-dropdown').classList.toggle('is-active')
|
||||
elm?.classList.toggle('is-active')
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -12,17 +12,17 @@
|
|||
"web-types": "./web-types.json",
|
||||
"dependencies": {
|
||||
"@pinia/nuxt": "^0.11.1",
|
||||
"@sentry/nuxt": "^9.34.0",
|
||||
"@vueuse/core": "^13.4.0",
|
||||
"@vueuse/nuxt": "^13.4.0",
|
||||
"@sentry/nuxt": "^9.35.0",
|
||||
"@vueuse/core": "^13.5.0",
|
||||
"@vueuse/nuxt": "^13.5.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"cron-parser": "^5.3.0",
|
||||
"cronstrue": "^3.0.0",
|
||||
"floating-vue": "^5.2.2",
|
||||
"hls.js": "^1.6.5",
|
||||
"hls.js": "^1.6.7",
|
||||
"moment": "^2.30.1",
|
||||
"nuxt": "^3.17.5",
|
||||
"nuxt": "^3.17.6",
|
||||
"pinia": "^3.0.3",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"vue": "^3.5.17",
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ const config = useConfigStore()
|
|||
const socket = useSocketStore()
|
||||
|
||||
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_order = useStorage('sort_order', 'asc')
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ const config = useConfigStore()
|
|||
const socket = useSocketStore()
|
||||
|
||||
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 terminalFit = ref<FitAddon>()
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ const stateStore = useStateStore()
|
|||
const socket = useSocketStore()
|
||||
const box = useConfirm()
|
||||
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 show_thumbnail = useStorage<boolean>('show_thumbnail', true)
|
||||
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ const logContainer = useTemplateRef<HTMLDivElement>('logContainer')
|
|||
const bottomMarker = useTemplateRef<HTMLDivElement>('bottomMarker')
|
||||
const textWrap = useStorage<boolean>('logs_wrap', 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 offset = ref<number>(0)
|
||||
|
|
|
|||
|
|
@ -71,8 +71,7 @@ div.is-centered {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="columns is-multiline is-mobile" v-if="filteredTasks && filteredTasks.length > 0">
|
||||
<div class="columns is-multiline is-mobile" v-if="!toggleForm && filteredTasks && filteredTasks.length > 0">
|
||||
<div class="column" v-if="'list' !== display_style">
|
||||
<button type="button" class="button is-fullwidth is-ghost is-inverted"
|
||||
@click="masterSelectAll = !masterSelectAll">
|
||||
|
|
@ -103,10 +102,8 @@ div.is-centered {
|
|||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="columns is-multiline" v-if="!isLoading && !toggleForm && filteredTasks && filteredTasks.length > 0">
|
||||
<template v-if="'list' === display_style">
|
||||
<div class="column is-12">
|
||||
|
|
@ -152,9 +149,21 @@ div.is-centered {
|
|||
{{ remove_tags(item.name) }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div v-if="item.preset">
|
||||
<span class="icon"><i class="fa-solid fa-tv" /></span>
|
||||
<span>{{ item.preset ?? config.app.default_preset }}</span>
|
||||
<div>
|
||||
<span class="icon-text">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</td>
|
||||
<td class="is-vcentered has-text-centered">
|
||||
|
|
@ -218,7 +227,12 @@ div.is-centered {
|
|||
{{ tag }}
|
||||
</span>
|
||||
</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">
|
||||
<a class="has-text-primary" v-tooltip="'Export task.'" @click.prevent="exportItem(item)">
|
||||
<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
|
||||
}
|
||||
|
||||
if (item?.auto_start !== undefined) {
|
||||
data.auto_start = item.auto_start
|
||||
}
|
||||
|
||||
socket.emit('add_url', data)
|
||||
|
||||
if (true === mass) {
|
||||
|
|
@ -685,6 +703,7 @@ const exportItem = async (item: task_item) => {
|
|||
preset: info.preset,
|
||||
timer: info.timer,
|
||||
folder: info.folder,
|
||||
auto_start: info?.auto_start ?? true,
|
||||
} as exported_task
|
||||
|
||||
if (info.template) {
|
||||
|
|
|
|||
1540
ui/pnpm-lock.yaml
1540
ui/pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
39
uv.lock
39
uv.lock
|
|
@ -95,14 +95,15 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "aiosignal"
|
||||
version = "1.3.2"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
|
|
@ -773,7 +774,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pyinstaller"
|
||||
version = "6.14.1"
|
||||
version = "6.14.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "altgraph" },
|
||||
|
|
@ -784,19 +785,19 @@ dependencies = [
|
|||
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
|
||||
{ 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 = [
|
||||
{ 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/af/a6/a2814978f47ae038b1ce112717adbdcfd8dfb9504e5c52437902331cde1a/pyinstaller-6.14.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:f040d1e3d42af3730104078d10d4a8ca3350bd1c78de48f12e1b26f761e0cbc3", size = 719569 },
|
||||
{ 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/e5/88/446814e335d937406e6e1ae4a77ed922b8eea8b90f3aaf69427a16b58ed2/pyinstaller-6.14.1-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e2cfdbc6dd41d19872054fc233da18856ec422a7fdea899b6985ae04f980376a", size = 727937 },
|
||||
{ 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/c5/92/e32ec0a1754852a8ed5a60f6746c6483e3da68aee97d314f3a3a99e0ed9e/pyinstaller-6.14.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c48dd257f77f61ebea2d1fdbaf11243730f2271873c88d3b5ecb7869525d3bcb", size = 724957 },
|
||||
{ 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/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/d5/43/6c68dc9e53b09ff948d6e46477932b387832bbb920c48061d734ef089368/pyinstaller-6.14.1-py3-none-win32.whl", hash = "sha256:547f7a93592e408cbfd093ce9fd9631215387dab0dbf3130351d3b0b1186a534", size = 1299744 },
|
||||
{ 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/89/57/8a8979737980e50aa5031b77318ce783759bf25be2956317f2e1d7a65a09/pyinstaller-6.14.1-py3-none-win_arm64.whl", hash = "sha256:d9d99695827f892cb19644106da30681363e8ff27b8326ac8416d62890ab9c74", size = 1298607 },
|
||||
{ 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/8f/a5/0780ce0f9916012cafd65673a4cc3d59aee65af84c773f49b36aa98d0ce9/pyinstaller-6.14.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:3fa0c391e1300a9fd7752eb1ffe2950112b88fba9d2743eee2ef218a15f4705f", size = 720241 },
|
||||
{ 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/97/6f/358d23398cf210ba5a588e1311b6611762e353670d11838633cbb4c5ff79/pyinstaller-6.14.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:fdd2bd020a18736806a6bd5d3c4352f1209b427a96ad6c459d88aec1d90c4f21", size = 728609 },
|
||||
{ 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/b8/98/460a32d2e325ad0ea81e4df478a8d84b5ebe0ceaca0cd3088f16afcaba5f/pyinstaller-6.14.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:78827a21ada2a848e98671852d20d74b2955b6e2aaf2359ed13a462e1a603d84", size = 725629 },
|
||||
{ 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/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/2a/26/d9559ac0851b1e3427a6b3ab0cd9edc8082b114f2499f78af532fdd5e14d/pyinstaller-6.14.2-py3-none-win32.whl", hash = "sha256:1697601aa788e3a52f0b5e620b4741a34b82e6f222ec6e1318b3a1349f566bb2", size = 1300415 },
|
||||
{ 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/3d/e2/c267cadb3307a4979757b086674f592669c04bd960a8d2746dd2d18ad57d/pyinstaller-6.14.2-py3-none-win_arm64.whl", hash = "sha256:69fd11ca57e572387826afaa4a1b3d4cb74927d76f231f0308c0bd7872ca5ac1", size = 1299280 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1246,11 +1247,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.14.0"
|
||||
version = "4.14.1"
|
||||
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 = [
|
||||
{ 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]]
|
||||
|
|
|
|||
Loading…
Reference in a new issue