Added the ability to add item in paused state

This commit is contained in:
arabcoders 2025-07-08 22:13:45 +03:00
parent 1239643f97
commit 9ff9735a2f
12 changed files with 344 additions and 25 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

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

View file

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

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

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

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

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

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