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 c8c2d8ac..69a7e3d7 100644 --- a/app/library/DataStore.py +++ b/app/library/DataStore.py @@ -147,12 +147,13 @@ class DataStore: 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] + ref = self._dict[key] + if ref.info.auto_start and ref.started() is False and ref.is_cancelled() is False: + return ref return None diff --git a/app/library/DownloadQueue.py b/app/library/DownloadQueue.py index 6e071fea..fb1bf668 100644 --- a/app/library/DownloadQueue.py +++ b/app/library/DownloadQueue.py @@ -150,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. @@ -358,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): @@ -407,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) @@ -419,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()) @@ -628,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}") @@ -751,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: @@ -812,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/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/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 @@