Merge pull request #569 from arabcoders/dev

feat: improve task creation to support multiple URLs.
This commit is contained in:
Abdulmohsen 2026-02-17 21:02:09 +03:00 committed by GitHub
commit d9c9a85939
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1662 additions and 1424 deletions

51
API.md
View file

@ -938,43 +938,68 @@ Notes:
--- ---
### POST /api/tasks ### POST /api/tasks
**Purpose**: Create a new scheduled task. **Purpose**: Create one or more scheduled tasks.
**Body**: Task object. Example: **Body**: Single task object or array of task objects.
**Single task:**
```json ```json
{ {
"name": "My Task", "name": "My Task",
"url": "https://youtube.com/...", "url": "https://youtube.com/...",
"timer": "5 */2 * * *", "timer": "5 */2 * * *",
"cookies": "", "preset": "default",
"config": {}, "folder": "",
"template": "...", "template": "",
"folder": "...", "cli": "",
"preset": "...",
"auto_start": true, "auto_start": true,
"handler_enabled": true, "handler_enabled": true,
"enabled": true "enabled": true
} }
``` ```
**Response**: **Multiple tasks:**
```json
[
{"name": "First Task", "url": "https://...", "timer": "0 12 * * *", "preset": "default"},
{"url": "https://...", "folder": "custom/folder"},
{"url": "https://...", "preset": "different-preset", "template": "%(title)s.%(ext)s"}
]
```
**Multi-URL Behavior:**
When providing an array, the first task must include all required fields. Subsequent tasks can omit all fields except `url`, which is required for each task. Which will be backfilled using the first task values. Except for two fields that are backfilled with special logic:
- `name`: Inferred from URL metadata, fallback: `task-{index}` if metadata is unavailable.
- `timer`:
- If no timer is provided in first task, timer will be disabled for all tasks.
- If timer is provided in first task, be extended by adding 5min interval for each subsequent task (e.g., `5 */2 * * *`, `10 */2 * * *`, `15 */2 * * *`, etc.) to prevent simultaneous execution this extends to hours.
**Response (single task):**
```json ```json
{ {
"id": 1, "id": 1,
"name": "My Task", "name": "My Task",
"url": "https://youtube.com/...", "url": "https://youtube.com/...",
"timer": "5 */2 * * *", "timer": "5 */2 * * *",
"cookies": "", "preset": "default",
"config": {}, "folder": "",
"template": "...", "template": "",
"folder": "...", "cli": "",
"preset": "...",
"auto_start": true, "auto_start": true,
"handler_enabled": true, "handler_enabled": true,
"enabled": true "enabled": true
} }
``` ```
**Response (multiple tasks):**
```json
[
{"id": 1, "name": "First Task", "url": "https://...", ...},
...
]
```
**Error Responses**: **Error Responses**:
- `400 Bad Request` - Invalid request body or validation error - `400 Bad Request` - Invalid request body or validation error
- `409 Conflict` - Task with the same name already exists - `409 Conflict` - Task with the same name already exists

View file

@ -26,6 +26,122 @@ if TYPE_CHECKING:
LOG: logging.Logger = logging.getLogger(__name__) LOG: logging.Logger = logging.getLogger(__name__)
TIMER_SLOTS_PER_HOUR: int = 12
def _offset_timer(timer: str, index: int) -> str:
"""
Add deterministic offset to CRON timer based on index.
Uses 5-minute increments with 12 slots per hour (0-55 minutes).
After 12 items, expands to hours.
Formula:
hours_offset = index // 12
minutes_offset = (index % 12) * 5
Examples:
index 0: +0min, index 5: +25min, index 11: +55min
index 12: +1hr 0min, index 24: +2hr 0min
Args:
timer: CRON expression string (5 fields)
index: Task index for offset calculation
Returns:
Modified CRON expression with offset applied, or original on error
"""
if not timer or index <= 0:
return timer
try:
parts = timer.strip().split()
if len(parts) != 5:
return timer
minute, hour, dom, month, dow = parts
hours_offset = index // TIMER_SLOTS_PER_HOUR
minutes_offset = (index % TIMER_SLOTS_PER_HOUR) * 5
original_minute = int(minute) if minute.isdigit() else 0
if minute.isdigit():
new_minute = (original_minute + minutes_offset) % 60
minute = str(new_minute)
elif "/" in minute:
base, step = minute.split("/", 1)
if base.isdigit():
new_base = (int(base) + minutes_offset) % 60
minute = f"{new_base}/{step}"
elif minute == "*":
minute = str(minutes_offset)
carry_hour = (original_minute + minutes_offset) // 60
if hour.isdigit():
new_hour = (int(hour) + hours_offset + carry_hour) % 24
hour = str(new_hour)
elif "/" in hour:
base, step = hour.split("/", 1)
if base.isdigit():
new_base = (int(base) + hours_offset + carry_hour) % 24
hour = f"{new_base}/{step}"
elif hour == "*" and (hours_offset > 0 or carry_hour > 0):
hour = str((hours_offset + carry_hour) % 24)
return f"{minute} {hour} {dom} {month} {dow}"
except Exception as e:
LOG.warning(f"Failed to offset timer '{timer}': {e}")
return timer
async def _get_info(url: str, preset: str) -> tuple[str | None, str | None]:
"""
Fetch metadata from URL and extract title for task name.
Also converts YouTube @handle URLs to channel IDs when possible.
Args:
url: URL to fetch metadata from
preset: Preset name to use for extraction options
Returns:
Tuple of (title or None, converted_url or None)
"""
try:
from app.features.ytdlp.extractor import fetch_info
from app.features.ytdlp.ytdlp_opts import YTDLPOpts
params = YTDLPOpts.get_instance().add_cli("-I0", from_user=False)
if preset:
params.preset(name=preset)
(metadata, _) = await fetch_info(config=params.get_all(), url=url)
if not metadata or not isinstance(metadata, dict):
return (None, None)
title = ag(metadata, ["title", "fulltitle"])
name = str(title)[:255] if title else None
converted_url: str | None = None
channel_id = metadata.get("channel_id")
if channel_id and "/@" in url:
import re
converted_url = re.sub(r"/@[^/]+", f"/channel/{channel_id}", url)
return (name, converted_url)
except TimeoutError:
LOG.debug(f"Timeout while inferring name from '{url}'")
return (None, None)
except Exception as e:
LOG.debug(f"Failed to infer name from '{url}': {e}")
return (None, None)
def _model(model: Any) -> Task: def _model(model: Any) -> Task:
return Task.model_validate(model) return Task.model_validate(model)
@ -53,35 +169,107 @@ async def tasks_list(request: Request, repo: TasksRepository, encoder: Encoder)
async def tasks_add(request: Request, repo: TasksRepository, encoder: Encoder, notify: EventBus) -> Response: async def tasks_add(request: Request, repo: TasksRepository, encoder: Encoder, notify: EventBus) -> Response:
data = await request.json() data = await request.json()
if not isinstance(data, dict): if isinstance(data, dict):
data = [data]
if not isinstance(data, list) or len(data) == 0:
return web.json_response( return web.json_response(
{"error": "Invalid request body expecting dict."}, {"error": "Invalid request body. Expecting dict or list of dicts."},
status=web.HTTPBadRequest.status_code,
)
first_item = data[0]
if not isinstance(first_item, dict) or not first_item.get("url"):
return web.json_response(
{"error": "First item requires 'url' field."},
status=web.HTTPBadRequest.status_code,
)
if not first_item.get("name"):
return web.json_response(
{"error": "First item requires 'name' field."},
status=web.HTTPBadRequest.status_code, status=web.HTTPBadRequest.status_code,
) )
try: try:
item: Task = Task.model_validate(data) first_task: Task = Task.model_validate(first_item)
except ValidationError as exc: except ValidationError as exc:
return web.json_response( return web.json_response(
data={"error": "Failed to validate task.", "detail": format_validation_errors(exc)}, data={"error": "Failed to validate first task.", "detail": format_validation_errors(exc)},
status=web.HTTPBadRequest.status_code, status=web.HTTPBadRequest.status_code,
) )
if await repo.get_by_name(item.name): if await repo.get_by_name(first_task.name):
return web.json_response( return web.json_response(
data={"error": f"Task with name '{item.name}' already exists."}, data={"error": f"Task with name '{first_task.name}' already exists."},
status=web.HTTPConflict.status_code, status=web.HTTPConflict.status_code,
) )
try: created_tasks: list[dict[str, Any]] = []
created = await repo.create(item.model_dump()) base_settings = first_task.model_dump(exclude={"id", "name", "url", "created_at", "updated_at"})
saved = _serialize(created)
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=web.HTTPBadRequest.status_code)
notify.emit(Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.TASKS, action=CEAction.CREATE, data=saved)) for idx, item in enumerate(data):
if not isinstance(item, dict):
LOG.warning(f"Skipping item {idx}: not a dict")
continue
return web.json_response(data=saved, status=web.HTTPOk.status_code, dumps=encoder.encode) url = str(item.get("url", "")).strip()
if not url:
LOG.debug(f"Skipping item {idx}: empty URL")
continue
inferred_name, converted_url = await _get_info(url, item.get("preset", first_task.preset))
name = item.get("name", first_task.name if 0 == idx else (inferred_name or f"task-{idx}"))
final_name = str(name)
counter = 1
while await repo.get_by_name(final_name):
final_name = f"{name}-{counter}"
counter += 1
item_settings = base_settings.copy()
for key in ["timer", "preset", "folder", "template", "cli", "auto_start", "handler_enabled", "enabled"]:
if key in item and item[key] is not None:
item_settings[key] = item[key]
task_dict: dict[str, Any] = {
"name": final_name,
"url": converted_url or url,
**item_settings,
}
if not task_dict.get("timer") and first_task.timer and idx > 0:
task_dict["timer"] = _offset_timer(first_task.timer, idx)
try:
validated = Task.model_validate(task_dict)
task_data = validated.model_dump()
except ValidationError as exc:
LOG.warning(f"Skipping task {idx}: validation failed - {exc}")
continue
try:
created = await repo.create(task_data)
saved = _serialize(created)
created_tasks.append(saved)
notify.emit(
Events.CONFIG_UPDATE,
data=ConfigEvent(feature=CEFeature.TASKS, action=CEAction.CREATE, data=saved),
)
except ValueError as exc:
LOG.warning(f"Failed to create task {idx}: {exc}")
continue
if len(created_tasks) == 0:
return web.json_response(
{"error": "Failed to create any tasks."},
status=web.HTTPBadRequest.status_code,
)
if len(created_tasks) == 1:
return web.json_response(data=created_tasks[0], status=web.HTTPOk.status_code, dumps=encoder.encode)
return web.json_response(data=created_tasks, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("GET", r"api/tasks/{id:\d+}", "tasks_get") @route("GET", r"api/tasks/{id:\d+}", "tasks_get")

View file

@ -220,10 +220,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, toRef, watch } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useStorage, useIntersectionObserver } from '@vueuse/core' import { useStorage, useIntersectionObserver } from '@vueuse/core'
import type { item_request } from '~/types/item' import type { item_request } from '~/types/item'
import type { Preset } from '~/types/presets'
import type { ItemStatus, StoreItem } from '~/types/store' import type { ItemStatus, StoreItem } from '~/types/store'
import { useNotification } from '~/composables/useNotification' import { useNotification } from '~/composables/useNotification'
import { useConfigStore } from '~/stores/ConfigStore' import { useConfigStore } from '~/stores/ConfigStore'
@ -250,7 +251,9 @@ const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
const isMobile = useMediaQuery({ maxWidth: 1024 }) const isMobile = useMediaQuery({ maxWidth: 1024 })
const box = useConfirm() const box = useConfirm()
const { app, paused, presets } = storeToRefs(configStore) const app = toRef(configStore, 'app')
const paused = toRef(configStore, 'paused')
const presets = toRef(configStore, 'presets')
const { queue, history } = storeToRefs(stateStore) const { queue, history } = storeToRefs(stateStore)
const embedUrl = ref<string>('') const embedUrl = ref<string>('')
@ -662,7 +665,7 @@ const deleteHistoryItem = async (item: StoreItem): Promise<void> => {
toast.info('Removed from history queue.') toast.info('Removed from history queue.')
} }
const filter_presets = (flag: boolean = true) => presets.value.filter(item => item.default === flag) const filter_presets = (flag: boolean = true) => presets.value.filter((item: Preset) => item.default === flag)
const showMessage = (item: StoreItem) => { const showMessage = (item: StoreItem) => {
if (!item?.msg || item.msg === item?.error) { if (!item?.msg || item.msg === item?.error) {

View file

@ -1,17 +1,26 @@
<template> <template>
<main class="columns mt-2 is-multiline"> <main class="columns mt-2 is-multiline">
<div class="column is-12" v-if="form?.url && is_yt_handle(form.url)"> <div class="column is-12" v-if="!isMultiLineInput && form?.url && is_yt_handle(form.url)">
<Message title="Information" class="is-info" icon="fas fa-info-circle"> <Message title="Information" class="is-info" icon="fas fa-info-circle">
You are using a YouTube link with <code>@handle</code> instead of <code>channel_id</code>. To activate RSS You are using a YouTube link with <code>@handle</code> instead of <code>channel_id</code>. To activate RSS
feed support for URL click on the <b>Convert URL</b> link. feed support for URL click on the <b>Convert URL</b> link.
</Message> </Message>
</div> </div>
<div class="column is-12" v-if="form?.url && is_generic_rss(form.url)"> <div class="column is-12" v-if="form?.url && is_generic_rss(form.url) && !isMultiLineInput">
<Message title="Information" class="is-warning" icon="fas fa-info-circle"> <Message title="Information" class="is-warning" icon="fas fa-info-circle">
You are using a generic RSS/Atom feed URL. The task handler will automatically download new items found You are using a generic RSS/Atom feed URL. The task handler will automatically download new items found
in this feed. in this feed.
</Message> </Message>
</div> </div>
<div class="column is-12" v-if="isMultiLineInput">
<Message title="Multiple URLs" class="is-info" icon="fas fa-info-circle">
<ul>
<li>First URL uses the <b>Name</b> provided above with full settings.</li>
<li>Other URLs infer names from metadata and inherit settings from the first URL.</li>
<li v-if="form.timer">Timers are offset by 5-minute increments for each URL.</li>
</ul>
</Message>
</div>
<div class="column is-12"> <div class="column is-12">
<form autocomplete="off" id="taskForm" @submit.prevent="checkInfo()"> <form autocomplete="off" id="taskForm" @submit.prevent="checkInfo()">
<div class="card"> <div class="card">
@ -83,21 +92,30 @@
<div class="field"> <div class="field">
<label class="label is-inline" for="url"> <label class="label is-inline" for="url">
<span class="icon"><i class="fa-solid fa-link" /></span> <span class="icon"><i class="fa-solid fa-link" /></span>
URL <span>URL</span>
<template v-if="is_yt_handle(form.url)"> <span class="tag is-info is-light is-small ml-2" v-if="urlCount > 1">{{ urlCount }} URLs</span>
<template v-if="!isMultiLineInput && is_yt_handle(form.url)">
- <NuxtLink @click="async () => form.url = await convert_url(form.url)">Convert URL</NuxtLink> - <NuxtLink @click="async () => form.url = await convert_url(form.url)">Convert URL</NuxtLink>
</template> </template>
</label> </label>
<div class="control has-icons-left"> <div class="control has-icons-left">
<input type="url" class="input" id="url" v-model="form.url" <textarea v-if="isMultiLineInput" ref="urlTextarea" class="input" id="url"
:disabled="addInProgress || convertInProgress" v-model="form.url" @keydown="handleKeyDown"
@input="adjustTextareaHeight"
style="resize: none; overflow-y: auto; min-height: 38px; max-height: 300px;"
placeholder="https://www.youtube.com/channel/UCUi3_cffYenmMTuWEsLHzqg" />
<input v-else type="url" class="input" id="url" v-model="form.url"
:disabled="addInProgress || convertInProgress" :disabled="addInProgress || convertInProgress"
placeholder="https://www.youtube.com/channel/UCUi3_cffYenmMTuWEsLHzqg"> placeholder="https://www.youtube.com/channel/UCUi3_cffYenmMTuWEsLHzqg" @keydown="handleKeyDown"
@paste="handlePaste">
<span class="icon is-small is-left"><i class="fa-solid fa-link" <span class="icon is-small is-left"><i class="fa-solid fa-link"
:class="{ 'fa-spin': convertInProgress }" /></span> :class="{ 'fa-spin': convertInProgress }" /></span>
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span class="is-bold">The channel or playlist URL.</span> <span class="is-bold">
The channel or playlist URL. Use Shift+Enter for multiple URLs.
</span>
</span> </span>
</div> </div>
</div> </div>
@ -366,6 +384,7 @@ import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
import type { AutoCompleteOptions } from '~/types/autocomplete' import type { AutoCompleteOptions } from '~/types/autocomplete'
import type { ExportedTask, Task } from '~/types/tasks' import type { ExportedTask, Task } from '~/types/tasks'
import { useConfirm } from '~/composables/useConfirm' import { useConfirm } from '~/composables/useConfirm'
import { shortPath } from "~/utils"
const props = defineProps<{ const props = defineProps<{
reference?: number | null | undefined reference?: number | null | undefined
@ -375,7 +394,7 @@ const props = defineProps<{
const emitter = defineEmits<{ const emitter = defineEmits<{
(e: 'cancel'): void (e: 'cancel'): void
(e: 'submit', payload: { reference: number | null | undefined, task: Task, archive_all?: boolean }): void (e: 'submit', payload: { reference: number | null | undefined, task: Task | Task[], archive_all?: boolean }): void
}>() }>()
const toast = useNotification() const toast = useNotification()
@ -388,11 +407,86 @@ const import_string = ref<string>('')
const showOptions = ref<boolean>(false) const showOptions = ref<boolean>(false)
const ytDlpOpt = ref<AutoCompleteOptions>([]) const ytDlpOpt = ref<AutoCompleteOptions>([])
const archiveAllAfterAdd = ref<boolean>(false) const archiveAllAfterAdd = ref<boolean>(false)
const urlTextarea = ref<HTMLTextAreaElement | null>(null)
const CHANNEL_REGEX = /^https?:\/\/(?:www\.)?youtube\.com\/(?:(?:channel\/(?<channelId>UC[0-9A-Za-z_-]{22}))|(?:c\/(?<customName>[A-Za-z0-9_-]+))|(?:user\/(?<userName>[A-Za-z0-9_-]+))|(?:@(?<handle>[A-Za-z0-9_-]+)))(?<suffix>\/.*)?\/?$/ const CHANNEL_REGEX = /^https?:\/\/(?:www\.)?youtube\.com\/(?:(?:channel\/(?<channelId>UC[0-9A-Za-z_-]{22}))|(?:c\/(?<customName>[A-Za-z0-9_-]+))|(?:user\/(?<userName>[A-Za-z0-9_-]+))|(?:@(?<handle>[A-Za-z0-9_-]+)))(?<suffix>\/.*)?\/?$/
const GENERIC_RSS_REGEX = /\.(rss|atom)(\?.*)?$|handler=rss/i const GENERIC_RSS_REGEX = /\.(rss|atom)(\?.*)?$|handler=rss/i
const form = reactive<Task>({ ...props.task }) const form = reactive<Task>({ ...props.task })
const isMultiLineInput = computed(() => !!form.url && form.url.includes('\n'))
const urlCount = computed(() => splitUrls(form.url || '').length)
const splitUrls = (urlString: string): string[] => {
return urlString
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
}
const adjustTextareaHeight = async (): Promise<void> => {
await nextTick()
if (urlTextarea.value) {
urlTextarea.value.style.height = 'auto'
const newHeight = Math.min(urlTextarea.value.scrollHeight, 300)
urlTextarea.value.style.height = `${newHeight}px`
}
}
const handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
const target = event.target as HTMLInputElement | HTMLTextAreaElement
const isTextarea = target.tagName === 'TEXTAREA'
if (event.key !== 'Enter') return
if (event.ctrlKey && isTextarea) {
event.preventDefault()
checkInfo()
return
}
if (event.shiftKey && !isTextarea) {
event.preventDefault()
const cursorPos = target.selectionStart || form.url.length
form.url = form.url.substring(0, cursorPos) + '\n' + form.url.substring(target.selectionEnd || cursorPos)
await nextTick()
if (urlTextarea.value) {
await adjustTextareaHeight()
urlTextarea.value.setSelectionRange(cursorPos + 1, cursorPos + 1)
urlTextarea.value.focus()
}
}
}
const handlePaste = async (event: ClipboardEvent): Promise<void> => {
const pastedText = event.clipboardData?.getData('text') || ''
if (!pastedText.includes('\n')) return
event.preventDefault()
const target = event.target as HTMLInputElement
const currentValue = form.url || ''
const start = target.selectionStart || currentValue.length
const end = target.selectionEnd || currentValue.length
form.url = currentValue.substring(0, start) + pastedText + currentValue.substring(end)
await nextTick()
if (urlTextarea.value) {
await adjustTextareaHeight()
const newPos = start + pastedText.length
urlTextarea.value.setSelectionRange(newPos, newPos)
urlTextarea.value.focus()
}
}
watch(isMultiLineInput, async (newValue) => {
await nextTick()
if (newValue) {
await adjustTextareaHeight()
urlTextarea.value?.focus()
}
})
watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions
.filter(opt => !opt.ignored) .filter(opt => !opt.ignored)
.flatMap(opt => opt.flags .flatMap(opt => opt.flags
@ -421,12 +515,16 @@ onMounted(() => {
}) })
const checkInfo = async (): Promise<void> => { const checkInfo = async (): Promise<void> => {
const required = ['name', 'url'] as const const urls = splitUrls(form.url || '')
for (const key of required) {
if (!form[key]) { if (urls.length === 0) {
toast.error(`The ${key} field is required.`) toast.error('At least one URL is required.')
return return
} }
if (!form.name) {
toast.error('The name field is required.')
return
} }
if (form.folder) { if (form.folder) {
@ -445,7 +543,7 @@ const checkInfo = async (): Promise<void> => {
} }
try { try {
new URL(form.url) new URL(urls[0] || '')
} catch { } catch {
toast.error('Invalid URL') toast.error('Invalid URL')
return return
@ -457,7 +555,30 @@ const checkInfo = async (): Promise<void> => {
form.cli = form.cli.trim() form.cli = form.cli.trim()
} }
emitter('submit', { reference: toRaw(props.reference), task: toRaw(form), archive_all: archiveAllAfterAdd.value }) if (urls.length === 1) {
emitter('submit', { reference: toRaw(props.reference), task: toRaw(form), archive_all: archiveAllAfterAdd.value })
return
}
const tasks: Task[] = urls.map((url, idx) => {
if (idx === 0) {
return {
name: form.name,
url: url,
folder: form.folder,
preset: form.preset,
timer: form.timer,
template: form.template,
cli: form.cli,
auto_start: form.auto_start,
handler_enabled: form.handler_enabled,
enabled: form.enabled,
} as Task
}
return { url } as Task
})
emitter('submit', { reference: toRaw(props.reference), task: tasks, archive_all: archiveAllAfterAdd.value })
} }
const importItem = async (): Promise<void> => { const importItem = async (): Promise<void> => {

View file

@ -1,5 +1,4 @@
import { ref, readonly } from 'vue' import { ref, readonly } from 'vue'
import { useNotification } from '~/composables/useNotification' import { useNotification } from '~/composables/useNotification'
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils' import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils'
import type { import type {
@ -34,6 +33,10 @@ const isLoading = ref<boolean>(false)
* Indicates if an add/update operation is in progress. * Indicates if an add/update operation is in progress.
*/ */
const addInProgress = ref<boolean>(false) const addInProgress = ref<boolean>(false)
/**
* Set of task IDs that are currently in progress.
*/
const inProgressIds = ref<Set<number>>(new Set())
/** /**
* Stores the last error message, if any. * Stores the last error message, if any.
*/ */
@ -181,14 +184,14 @@ const getTask = async (id: number): Promise<Task | null> => {
/** /**
* Creates a new task via API. * Creates a new task via API.
* @param task Task to create * @param task Task to create (single task or array for batch)
* @param callback Optional callback with APIResponse result * @param callback Optional callback with APIResponse result
* @returns Created task or null on error * @returns Created task(s) or null on error
*/ */
const createTask = async ( const createTask = async (
task: Omit<Task, 'id' | 'created_at' | 'updated_at'>, task: Omit<Task, 'id' | 'created_at' | 'updated_at'> | Omit<Task, 'id' | 'created_at' | 'updated_at'>[],
callback?: (response: APIResponse<Task>) => void, callback?: (response: APIResponse<Task | Task[]>) => void,
): Promise<Task | null> => { ): Promise<Task | Task[] | null> => {
addInProgress.value = true addInProgress.value = true
try { try {
const response = await request('/api/tasks/', { const response = await request('/api/tasks/', {
@ -198,7 +201,19 @@ const createTask = async (
await ensureSuccess(response) await ensureSuccess(response)
const json = await response.json() const json = await response.json()
const created = await parse_api_response<Task>(json) const created = await parse_api_response<Task | Array<Task>>(json)
if (Array.isArray(created)) {
notify.success(`${created.length} tasks created.`)
created.forEach(t => updateTasksList(t))
lastError.value = null
if (callback) {
callback({ success: true, error: null, detail: null, data: created })
}
return created
}
updateTasksList(created) updateTasksList(created)
notify.success('Task created.') notify.success('Task created.')
@ -242,7 +257,7 @@ const updateTask = async (
try { try {
// Explicitly remove id, created_at, updated_at fields if present // Explicitly remove id, created_at, updated_at fields if present
const { id: _, created_at: __, updated_at: ___, ...taskData } = task as Task const { id: _, created_at: __, updated_at: ___, ...taskData } = task as Task
const response = await request(`/api/tasks/${id}`, { const response = await request(`/api/tasks/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(taskData), body: JSON.stringify(taskData),
@ -465,6 +480,29 @@ const generateTaskMetadata = async (id: number): Promise<TaskMetadataResponse |
*/ */
const clearError = () => lastError.value = null const clearError = () => lastError.value = null
/**
* Checks if a task is currently in progress.
* @param id Task ID
* @returns true if the task is in progress
*/
const isTaskInProgress = (id: number): boolean => inProgressIds.value.has(id)
/**
* Sets a task as in progress.
* @param id Task ID
*/
const setTaskInProgress = (id: number): void => {
inProgressIds.value.add(id)
}
/**
* Clears the in progress state for a task.
* @param id Task ID
*/
const clearTaskInProgress = (id: number): void => {
inProgressIds.value.delete(id)
}
/** /**
* Resets all state to initial values (for testing only). * Resets all state to initial values (for testing only).
*/ */
@ -482,6 +520,7 @@ const __resetForTesting = () => {
addInProgress.value = false addInProgress.value = false
lastError.value = null lastError.value = null
throwInstead.value = false throwInstead.value = false
inProgressIds.value = new Set()
} }
/** /**
@ -496,6 +535,10 @@ export const useTasks = () => ({
isLoading: readonly(isLoading), isLoading: readonly(isLoading),
addInProgress: readonly(addInProgress), addInProgress: readonly(addInProgress),
lastError: readonly(lastError), lastError: readonly(lastError),
inProgressIds: readonly(inProgressIds),
isTaskInProgress,
setTaskInProgress,
clearTaskInProgress,
loadTasks, loadTasks,
getTask, getTask,
createTask, createTask,

View file

@ -268,7 +268,7 @@
<NuxtLink target="_blank" :href="item.url"> <NuxtLink target="_blank" :href="item.url">
{{ remove_tags(item.name) }} {{ remove_tags(item.name) }}
</NuxtLink> </NuxtLink>
<span class="icon" v-if="item.in_progress"> <span class="icon" v-if="isTaskInProgress(item.id!)">
<i class="fa-solid fa-spinner fa-spin has-text-info" /> <i class="fa-solid fa-spinner fa-spin has-text-info" />
</span> </span>
<span class="tag is-danger is-small ml-1" v-if="!item.enabled">Disabled</span> <span class="tag is-danger is-small ml-1" v-if="!item.enabled">Disabled</span>
@ -468,8 +468,6 @@ import { sleep } from '~/utils'
import { useSessionCache } from '~/utils/cache' import { useSessionCache } from '~/utils/cache'
import type { item_request } from '~/types/item' import type { item_request } from '~/types/item'
type TaskWithUI = Task & { in_progress?: boolean }
const box = useConfirm() const box = useConfirm()
const toast = useNotification() const toast = useNotification()
const config = useConfigStore() const config = useConfigStore()
@ -481,9 +479,9 @@ const display_style = useStorage<string>("tasks_display_style", "cards")
const isMobile = useMediaQuery({ maxWidth: 1024 }) const isMobile = useMediaQuery({ maxWidth: 1024 })
const tasksComposable = useTasks() const tasksComposable = useTasks()
const { tasks, isLoading, addInProgress } = tasksComposable const { tasks, isLoading, addInProgress, isTaskInProgress, setTaskInProgress, clearTaskInProgress } = tasksComposable
const task = ref<Partial<TaskWithUI>>({}) const task = ref<Partial<Task>>({})
const taskRef = ref<number | null>(null) const taskRef = ref<number | null>(null)
const toggleForm = ref<boolean>(false) const toggleForm = ref<boolean>(false)
const selectedElms = ref<Array<number>>([]) const selectedElms = ref<Array<number>>([])
@ -491,7 +489,7 @@ const masterSelectAll = ref(false)
const massRun = ref<boolean>(false) const massRun = ref<boolean>(false)
const massDelete = ref<boolean>(false) const massDelete = ref<boolean>(false)
const table_container = ref(false) const table_container = ref(false)
const inspectTask = ref<TaskWithUI | null>(null) const inspectTask = ref<Task | null>(null)
const query = ref() const query = ref()
const toggleFilter = ref(false) const toggleFilter = ref(false)
const CACHE_KEY = 'tasks:handler_support' const CACHE_KEY = 'tasks:handler_support'
@ -591,7 +589,7 @@ const filteredTasks = computed(() => {
if (!q) return tasks.value; if (!q) return tasks.value;
return tasks.value.filter(task => deepIncludes(task, q, new WeakSet())); return tasks.value.filter(task => deepIncludes(task, q, new WeakSet()));
}) as ComputedRef<Array<TaskWithUI>>; }) as ComputedRef<Array<Task>>;
const reloadContent = async (fromMounted: boolean = false) => { const reloadContent = async (fromMounted: boolean = false) => {
try { try {
@ -707,11 +705,11 @@ const toggleHandlerEnabled = async (item: Task) => {
} }
} }
const updateItem = async ({ reference, task, archive_all }: { reference?: number | null | undefined, task: Task, archive_all?: boolean }) => { const updateItem = async ({ reference, task, archive_all }: { reference?: number | null | undefined, task: Task | Task[], archive_all?: boolean }) => {
let createdOrUpdated: Task | null = null let createdOrUpdated: Task | Task[] | null = null
if (reference) { if (reference) {
createdOrUpdated = await tasksComposable.updateTask(reference, task) createdOrUpdated = await tasksComposable.updateTask(reference, task as Task)
} else { } else {
createdOrUpdated = await tasksComposable.createTask(task) createdOrUpdated = await tasksComposable.createTask(task)
} }
@ -720,15 +718,23 @@ const updateItem = async ({ reference, task, archive_all }: { reference?: number
return return
} }
await checkHandlerSupport(createdOrUpdated) const tasksList = Array.isArray(createdOrUpdated) ? createdOrUpdated : [createdOrUpdated]
if (!reference && true === archive_all && createdOrUpdated.id) {
await sleep(1)
await nextTick()
await archiveAll(createdOrUpdated, true)
}
resetForm(true) resetForm(true)
if (!reference && true === archive_all) {
await nextTick()
await sleep(1)
toast.info(`Archiving existing items for '${tasksList.length}' tasks. This will take a while...`)
for (const t of tasksList) {
if (t.id) await archiveAll(t, true)
}
}
for (const t of tasksList) {
await checkHandlerSupport(t)
}
} }
const editItem = (item: Task) => { const editItem = (item: Task) => {
@ -798,13 +804,15 @@ const runSelected = async () => {
}, 500) }, 500)
} }
const runNow = async (item: TaskWithUI, mass: boolean = false) => { const runNow = async (item: Task, mass: boolean = false) => {
if (!item.id) return
if (!mass && true !== (await box.confirm(`Run '${item.name}' now? it will also run at the scheduled time.`))) { if (!mass && true !== (await box.confirm(`Run '${item.name}' now? it will also run at the scheduled time.`))) {
return return
} }
if (false === mass) { if (false === mass) {
item.in_progress = true setTaskInProgress(item.id)
} }
const data: item_request = { const data: item_request = {
@ -841,7 +849,7 @@ const runNow = async (item: TaskWithUI, mass: boolean = false) => {
setTimeout(async () => { setTimeout(async () => {
await nextTick() await nextTick()
item.in_progress = false if (item.id) clearTaskInProgress(item.id)
}, 500) }, 500)
} }
@ -892,7 +900,7 @@ const get_tags = (name: string): Array<string> => {
const remove_tags = (name: string): string => name.replace(/\[(.*?)\]/g, '').trim(); const remove_tags = (name: string): string => name.replace(/\[(.*?)\]/g, '').trim();
const archiveAll = async (item: TaskWithUI, by_pass: boolean = false) => { const archiveAll = async (item: Task, by_pass: boolean = false) => {
if (!item.id) { if (!item.id) {
toast.error('Task ID is missing') toast.error('Task ID is missing')
return return
@ -909,17 +917,17 @@ const archiveAll = async (item: TaskWithUI, by_pass: boolean = false) => {
} }
} }
item.in_progress = true setTaskInProgress(item.id)
await tasksComposable.markTaskItems(item.id) await tasksComposable.markTaskItems(item.id)
} catch (e: any) { } catch (e: any) {
toast.error(`Failed to archive items. ${e.message || 'Unknown error.'}`) toast.error(`Failed to archive items. ${e.message || 'Unknown error.'}`)
return return
} finally { } finally {
item.in_progress = false clearTaskInProgress(item.id)
} }
} }
const unarchiveAll = async (item: TaskWithUI) => { const unarchiveAll = async (item: Task) => {
if (!item.id) { if (!item.id) {
toast.error('Task ID is missing') toast.error('Task ID is missing')
return return
@ -934,17 +942,17 @@ const unarchiveAll = async (item: TaskWithUI) => {
return; return;
} }
item.in_progress = true setTaskInProgress(item.id)
await tasksComposable.unmarkTaskItems(item.id) await tasksComposable.unmarkTaskItems(item.id)
} catch (e: any) { } catch (e: any) {
toast.error(`Failed to remove items from archive. ${e.message || 'Unknown error.'}`) toast.error(`Failed to remove items from archive. ${e.message || 'Unknown error.'}`)
return return
} finally { } finally {
item.in_progress = false if (item.id) clearTaskInProgress(item.id)
} }
} }
const generateMeta = async (item: TaskWithUI) => { const generateMeta = async (item: Task) => {
if (!item.id) { if (!item.id) {
toast.error('Task ID is missing') toast.error('Task ID is missing')
return return
@ -977,13 +985,13 @@ const generateMeta = async (item: TaskWithUI) => {
return; return;
} }
item.in_progress = true setTaskInProgress(item.id)
await tasksComposable.generateTaskMetadata(item.id) await tasksComposable.generateTaskMetadata(item.id)
} catch (e: any) { } catch (e: any) {
toast.error(`Failed to generate metadata. ${e.message || 'Unknown error.'}`) toast.error(`Failed to generate metadata. ${e.message || 'Unknown error.'}`)
return return
} finally { } finally {
item.in_progress = false if (item.id) clearTaskInProgress(item.id)
} }
} }
</script> </script>

View file

@ -1,4 +1,4 @@
import path from "path"; import { defineNuxtConfig } from 'nuxt/config'
let extraNitro = {} let extraNitro = {}
try { try {
@ -63,17 +63,15 @@ export default defineNuxtConfig({
linkActiveClass: "is-selected", linkActiveClass: "is-selected",
} }
}, },
modules: [ modules: [
'@pinia/nuxt', '@pinia/nuxt',
'@vueuse/nuxt', '@vueuse/nuxt',
'floating-vue/nuxt', 'floating-vue/nuxt',
'development' === process.env.NODE_ENV ? '@nuxt/eslint' : '', 'development' === process.env.NODE_ENV ? '@nuxt/eslint' : '',
].filter(Boolean), ].filter(Boolean),
nitro: { nitro: {
output: { output: {
publicDir: path.join(__dirname, 'production' === process.env.NODE_ENV ? 'exported' : 'dist') publicDir: 'production' === process.env.NODE_ENV ? __dirname + '/exported' : __dirname + '/dist',
}, },
...extraNitro, ...extraNitro,
}, },

View file

@ -21,23 +21,23 @@
"dependencies": { "dependencies": {
"@microsoft/fetch-event-source": "^2.0.1", "@microsoft/fetch-event-source": "^2.0.1",
"@pinia/nuxt": "^0.11.3", "@pinia/nuxt": "^0.11.3",
"@vueuse/core": "^14.2.0", "@vueuse/core": "^14.2.1",
"@vueuse/nuxt": "^14.2.0", "@vueuse/nuxt": "^14.2.1",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"cron-parser": "^5.5.0", "cron-parser": "^5.5.0",
"cronstrue": "^3.11.0", "cronstrue": "^3.12.0",
"floating-vue": "^5.2.2", "floating-vue": "^5.2.2",
"hls.js": "^1.6.15", "hls.js": "^1.6.15",
"marked": "^17.0.1", "marked": "^17.0.2",
"marked-alert": "^2.1.2", "marked-alert": "^2.1.2",
"marked-base-url": "^1.1.8", "marked-base-url": "^1.1.8",
"marked-gfm-heading-id": "^4.1.3", "marked-gfm-heading-id": "^4.1.3",
"moment": "^2.30.1", "moment": "^2.30.1",
"nuxt": "^4.3.0", "nuxt": "^4.3.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.27", "vue": "^3.5.28",
"vue-router": "^5.0.1", "vue-router": "^5.0.2",
"vue-toastification": "2.0.0-rc.5" "vue-toastification": "2.0.0-rc.5"
}, },
"pnpm": { "pnpm": {
@ -53,13 +53,13 @@
"ansi-regex": "6.2.0" "ansi-regex": "6.2.0"
}, },
"devDependencies": { "devDependencies": {
"@nuxt/eslint": "^1.13.0", "@nuxt/eslint": "^1.15.1",
"@nuxt/eslint-config": "^1.13.0", "@nuxt/eslint-config": "^1.15.1",
"@typescript-eslint/parser": "^8.54.0", "@typescript-eslint/parser": "^8.55.0",
"eslint": "^9.39.2", "eslint": "^10.0.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^4.0.18", "vitest": "^4.0.18",
"vue-eslint-parser": "^10.2.0", "vue-eslint-parser": "^10.4.0",
"vue-tsc": "^3.2.4" "vue-tsc": "^3.2.4"
} }
} }

File diff suppressed because it is too large Load diff

38
uv.lock
View file

@ -939,11 +939,11 @@ wheels = [
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.7.0" version = "4.9.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/71/25/ccd8e88fcd16a4eb6343a8b4b9635e6f3928a7ebcd82822a14d20e3ca29f/platformdirs-4.7.0.tar.gz", hash = "sha256:fd1a5f8599c85d49b9ac7d6e450bc2f1aaf4a23f1fe86d09952fe20ad365cf36", size = 23118 } sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/e3/1eddccb2c39ecfbe09b3add42a04abcc3fa5b468aa4224998ffb8a7e9c8f/platformdirs-4.7.0-py3-none-any.whl", hash = "sha256:1ed8db354e344c5bb6039cd727f096af975194b508e37177719d562b2b540ee6", size = 18983 }, { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168 },
] ]
[[package]] [[package]]
@ -1172,7 +1172,7 @@ wheels = [
[[package]] [[package]]
name = "pyinstaller" name = "pyinstaller"
version = "6.18.0" version = "6.19.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "altgraph" }, { name = "altgraph" },
@ -1183,19 +1183,19 @@ dependencies = [
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "setuptools" }, { name = "setuptools" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/9f/b8/0fe3359920b0a4e7008e0e93ff383003763e3eee3eb31a07c52868722960/pyinstaller-6.18.0.tar.gz", hash = "sha256:cdc507542783511cad4856fce582fdc37e9f29665ca596889c663c83ec8c6ec9", size = 4034976 } sdist = { url = "https://files.pythonhosted.org/packages/c8/63/fd62472b6371d89dc138d40c36d87a50dc2de18a035803bbdc376b4ffac4/pyinstaller-6.19.0.tar.gz", hash = "sha256:ec73aeb8bd9b7f2f1240d328a4542e90b3c6e6fbc106014778431c616592a865", size = 4036072 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/73/e6/51b0146a1a3eec619e58f5d69fb4e3d0f65a31cbddbeef557c9bb83eeed9/pyinstaller-6.18.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:cb7aa5a71bfa7c0af17a4a4e21855663c89e4bd7c40f1d337c8370636d8847c3", size = 1040056 }, { url = "https://files.pythonhosted.org/packages/e3/eb/23374721fecfa72677e79800921cb6aceefa6ba48574dc404f3f6c6c3be7/pyinstaller-6.19.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:4190e76b74f0c4b5c5f11ac360928cd2e36ec8e3194d437bf6b8648c7bc0c134", size = 1040563 },
{ url = "https://files.pythonhosted.org/packages/4c/9c/a3634c0ec8e1ed31b373b548848b5c0b39b56edc191cf737e697d484ec23/pyinstaller-6.18.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:07785459b3bf8a48889eac0b4d0667ade84aef8930ce030bc7cbb32f41283b33", size = 734971 }, { url = "https://files.pythonhosted.org/packages/cd/7e/dfd724b0b533f5aaec0ee5df406fe2319987ed6964480a706f85478b12ea/pyinstaller-6.19.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8bd68abd812d8a6ba33b9f1810e91fee0f325969733721b78151f0065319ca11", size = 735477 },
{ url = "https://files.pythonhosted.org/packages/2c/04/6756442078ccfcd552ccce636be1574035e62f827ffa1f5d8a0382682546/pyinstaller-6.18.0-py3-none-manylinux2014_i686.whl", hash = "sha256:f998675b7ccb2dabbb1dc2d6f18af61d55428ad6d38e6c4d700417411b697d37", size = 746637 }, { url = "https://files.pythonhosted.org/packages/88/c9/ee3a4101c31f26344e66896c73c1fd6ed8282bf871473365b7f8674af406/pyinstaller-6.19.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1ec54ef967996ca61dacba676227e2b23219878ccce5ee9d6f3aada7b8ed8abf", size = 747143 },
{ url = "https://files.pythonhosted.org/packages/54/39/fbc56519000cdbf450f472692a7b9b55d42077ce8529f1be631db7b75a36/pyinstaller-6.18.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:779817a0cf69604cddcdb5be1fd4959dc2ce048d6355c73e5da97884df2f3387", size = 744343 }, { url = "https://files.pythonhosted.org/packages/da/0a/fc77e9f861be8cf300ac37155f59cc92aff99b29f2ddd78546f563a5b5a6/pyinstaller-6.19.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4ab2bb52e58448e14ddf9450601bdedd66800465043501c1d8f1cab87b60b122", size = 744849 },
{ url = "https://files.pythonhosted.org/packages/36/f2/50887badf282fee776e83d1e4feab74c026f50a1ea16e109ed939e32aa28/pyinstaller-6.18.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:31b5d109f8405be0b7cddcede43e7b074792bc9a5bbd54ec000a3e779183c2af", size = 741084 }, { url = "https://files.pythonhosted.org/packages/6d/e3/6872e020ee758afe0b821663858492c10745608b07150e5e2c824a5b3e1c/pyinstaller-6.19.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:da6d5c6391ccefe73554b9fa29b86001c8e378e0f20c2a4004f836ba537eff63", size = 741590 },
{ url = "https://files.pythonhosted.org/packages/1c/08/3a1419183e4713ef77d912ecbdd6ef858689ed9deb34d547133f724ca745/pyinstaller-6.18.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:4328c9837f1aef4fe1a127d4ff1b09a12ce53c827ce87c94117628b0e1fd098b", size = 740943 }, { url = "https://files.pythonhosted.org/packages/53/60/b8db5f1a4b0fb228175f2ea0aa33f949adcc097fbe981cc524f9faf85777/pyinstaller-6.19.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a0fc5f6b3c55aa54353f0c74ffa59b1115433c1850c6f655d62b461a2ed6cbbe", size = 741448 },
{ url = "https://files.pythonhosted.org/packages/c2/47/309305e36d116f1434b42d91c420ff951fa79b2c398bbd59930c830450be/pyinstaller-6.18.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:3638fc81eb948e5e5eab1d4ad8f216e3fec6d4a350648304f0adb227b746ee5e", size = 740107 }, { url = "https://files.pythonhosted.org/packages/6f/4d/63b0600f2694e9141b83129fbc1c488ec84d5a0770b1448ec154dcd0fee9/pyinstaller-6.19.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:e649ba6bd1b0b89b210ad92adb5fbdc8a42dd2c5ca4f72ef3a0bfec83a424b83", size = 740613 },
{ url = "https://files.pythonhosted.org/packages/83/0f/a59a95cd1df59ddbc9e74d5a663387551333bcf19a5dd3086f5c81a2e83c/pyinstaller-6.18.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe59da34269e637f97fd3c43024f764586fc319141d245ff1a2e9af1036aa3", size = 739843 }, { url = "https://files.pythonhosted.org/packages/01/d4/e812ad36178093a0e9fd4b8127577748dd85b0cb71de912229dca21fd741/pyinstaller-6.19.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:481a909c8e60c8692fc60fcb1344d984b44b943f8bc9682f2fcdae305ad297e6", size = 740350 },
{ url = "https://files.pythonhosted.org/packages/9a/09/e7a870e7205cdbd2f8785010a5d3fe48a9df2591156ee34a8b29b774fa14/pyinstaller-6.18.0-py3-none-win32.whl", hash = "sha256:496205e4fa92ec944f9696eb597962a83aef4d4c3479abfab83d730e1edf016b", size = 1323811 }, { url = "https://files.pythonhosted.org/packages/52/03/b2c2ee41fb8e10fd2a45d21f5ec2ef25852cfb978dbf762972eed59e3d63/pyinstaller-6.19.0-py3-none-win32.whl", hash = "sha256:3c5c251054fe4cfaa04c34a363dcfbf811545438cb7198304cd444756bc2edd2", size = 1324317 },
{ url = "https://files.pythonhosted.org/packages/fb/d5/48eef2002b6d3937ceac2717fe17e9ca3a43a4c9826bafee367dfc75ba85/pyinstaller-6.18.0-py3-none-win_amd64.whl", hash = "sha256:976fabd90ecfbda47571c87055ad73413ec615ff7dea35e12a4304174de78de9", size = 1384389 }, { url = "https://files.pythonhosted.org/packages/9c/d3/6d5e62b8270e2b53a6065e281b3a7785079b00e9019c8019952828dd1669/pyinstaller-6.19.0-py3-none-win_amd64.whl", hash = "sha256:b5bb6536c6560330d364d91522250f254b107cf69129d9cbcd0e6727c570be33", size = 1384894 },
{ url = "https://files.pythonhosted.org/packages/1b/8d/1a88e6e94107de3ea1c842fd59c3aa132d344ad8e52ea458ffa9a748726e/pyinstaller-6.18.0-py3-none-win_arm64.whl", hash = "sha256:dba4b70e3c9ba09aab51152c72a08e58a751851548f77ad35944d32a300c8381", size = 1324869 }, { url = "https://files.pythonhosted.org/packages/81/65/458cd523308a101a22fd2742893405030cc24994cc74b1b767cecf137160/pyinstaller-6.19.0-py3-none-win_arm64.whl", hash = "sha256:c2d5a539b0bfe6159d5522c8c70e1c0e487f22c2badae0f97d45246223b798ea", size = 1325374 },
] ]
[[package]] [[package]]
@ -1733,7 +1733,7 @@ wheels = [
[[package]] [[package]]
name = "trio" name = "trio"
version = "0.32.0" version = "0.33.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "attrs" }, { name = "attrs" },
@ -1743,9 +1743,9 @@ dependencies = [
{ name = "sniffio" }, { name = "sniffio" },
{ name = "sortedcontainers" }, { name = "sortedcontainers" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/d8/ce/0041ddd9160aac0031bcf5ab786c7640d795c797e67c438e15cfedf815c8/trio-0.32.0.tar.gz", hash = "sha256:150f29ec923bcd51231e1d4c71c7006e65247d68759dd1c19af4ea815a25806b", size = 605323 } sdist = { url = "https://files.pythonhosted.org/packages/52/b6/c744031c6f89b18b3f5f4f7338603ab381d740a7f45938c4607b2302481f/trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970", size = 605109 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/41/bf/945d527ff706233636c73880b22c7c953f3faeb9d6c7e2e85bfbfd0134a0/trio-0.32.0-py3-none-any.whl", hash = "sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5", size = 512030 }, { url = "https://files.pythonhosted.org/packages/1c/93/dab25dc87ac48da0fe0f6419e07d0bfd98799bed4e05e7b9e0f85a1a4b4b/trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b", size = 510294 },
] ]
[[package]] [[package]]