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.
"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)

View file

@ -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))

View file

@ -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())

View file

@ -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:

View file

@ -33,6 +33,7 @@ class Task:
timer: str = ""
template: str = ""
cli: str = ""
auto_start: bool = True
def serialize(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)
break
except Exception as e:
LOG.exception(e)
LOG.error(f"Error getting archive ID: {e}")
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']}'.")
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)

View file

@ -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 = {

View file

@ -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 }

View file

@ -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',

View file

@ -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) {

View file

@ -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 => {

View file

@ -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)

View file

@ -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)

View file

@ -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>

View file

@ -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",

View file

@ -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')

View file

@ -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>()

View file

@ -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)

View file

@ -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)

View file

@ -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>
&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>
</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) {

File diff suppressed because it is too large Load diff

39
uv.lock
View file

@ -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]]