diff --git a/API.md b/API.md index 45e76ab3..b761dd36 100644 --- a/API.md +++ b/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) diff --git a/app/library/DataStore.py b/app/library/DataStore.py index 691f034f..69a7e3d7 100644 --- a/app/library/DataStore.py +++ b/app/library/DataStore.py @@ -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)) diff --git a/app/library/DownloadQueue.py b/app/library/DownloadQueue.py index e946be2a..fb1bf668 100644 --- a/app/library/DownloadQueue.py +++ b/app/library/DownloadQueue.py @@ -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()) diff --git a/app/library/ItemDTO.py b/app/library/ItemDTO.py index ea8f7c8e..55adef71 100644 --- a/app/library/ItemDTO.py +++ b/app/library/ItemDTO.py @@ -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: diff --git a/app/library/Tasks.py b/app/library/Tasks.py index 6b063916..25e7dedd 100644 --- a/app/library/Tasks.py +++ b/app/library/Tasks.py @@ -33,6 +33,7 @@ class Task: timer: str = "" template: str = "" cli: str = "" + auto_start: bool = True def serialize(self) -> dict: return self.__dict__ diff --git a/app/library/Utils.py b/app/library/Utils.py index f2be102f..18e90ee3 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -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 diff --git a/app/routes/socket/history.py b/app/routes/socket/history.py index 2a16be2f..1868a0f8 100644 --- a/app/routes/socket/history.py +++ b/app/routes/socket/history.py @@ -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) diff --git a/ui/@types/config.d.ts b/ui/@types/config.d.ts index ae018cbb..fda67c28 100644 --- a/ui/@types/config.d.ts +++ b/ui/@types/config.d.ts @@ -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 = { diff --git a/ui/@types/tasks.ts b/ui/@types/tasks.ts index c1a25d40..c337c211 100644 --- a/ui/@types/tasks.ts +++ b/ui/@types/tasks.ts @@ -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 } diff --git a/ui/components/History.vue b/ui/components/History.vue index 855b06f1..6c6a276e 100644 --- a/ui/components/History.vue +++ b/ui/components/History.vue @@ -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', diff --git a/ui/components/NewDownload.vue b/ui/components/NewDownload.vue index 1bb75a0b..6a2ae274 100644 --- a/ui/components/NewDownload.vue +++ b/ui/components/NewDownload.vue @@ -9,11 +9,19 @@ URLs -
- +
+
+ +
+
+ + +
- + You can add multiple URLs separated by {{ getSeparatorsName(separator) }}. @@ -233,6 +241,7 @@ diff --git a/ui/package.json b/ui/package.json index 75fb9823..bbd1e7ce 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/pages/browser/[...slug].vue b/ui/pages/browser/[...slug].vue index 4f7477d9..01b45e99 100644 --- a/ui/pages/browser/[...slug].vue +++ b/ui/pages/browser/[...slug].vue @@ -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') diff --git a/ui/pages/console.vue b/ui/pages/console.vue index 2bfc89d3..696306f0 100644 --- a/ui/pages/console.vue +++ b/ui/pages/console.vue @@ -65,7 +65,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 terminal = ref() const terminalFit = ref() diff --git a/ui/pages/index.vue b/ui/pages/index.vue index b1b8c27b..72f005fd 100644 --- a/ui/pages/index.vue +++ b/ui/pages/index.vue @@ -81,7 +81,7 @@ const stateStore = useStateStore() const socket = useSocketStore() const box = useConfirm() 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 display_style = useStorage('display_style', 'cards') const show_thumbnail = useStorage('show_thumbnail', true) diff --git a/ui/pages/logs.vue b/ui/pages/logs.vue index b1da8883..0bae88a0 100644 --- a/ui/pages/logs.vue +++ b/ui/pages/logs.vue @@ -127,7 +127,7 @@ const logContainer = useTemplateRef('logContainer') const bottomMarker = useTemplateRef('bottomMarker') const textWrap = useStorage('logs_wrap', true) const bg_enable = useStorage('random_bg', true) -const bg_opacity = useStorage('random_bg_opacity', 0.85) +const bg_opacity = useStorage('random_bg_opacity', 0.95) const logs = ref>([]) const offset = ref(0) diff --git a/ui/pages/tasks.vue b/ui/pages/tasks.vue index 8ac122b4..e6de61d3 100644 --- a/ui/pages/tasks.vue +++ b/ui/pages/tasks.vue @@ -71,8 +71,7 @@ div.is-centered {
- -
+
-
-