Merge pull request #569 from arabcoders/dev
feat: improve task creation to support multiple URLs.
This commit is contained in:
commit
d9c9a85939
10 changed files with 1662 additions and 1424 deletions
51
API.md
51
API.md
|
|
@ -938,43 +938,68 @@ Notes:
|
|||
---
|
||||
|
||||
### 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
|
||||
{
|
||||
"name": "My Task",
|
||||
"url": "https://youtube.com/...",
|
||||
"timer": "5 */2 * * *",
|
||||
"cookies": "",
|
||||
"config": {},
|
||||
"template": "...",
|
||||
"folder": "...",
|
||||
"preset": "...",
|
||||
"preset": "default",
|
||||
"folder": "",
|
||||
"template": "",
|
||||
"cli": "",
|
||||
"auto_start": true,
|
||||
"handler_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
|
||||
{
|
||||
"id": 1,
|
||||
"name": "My Task",
|
||||
"url": "https://youtube.com/...",
|
||||
"timer": "5 */2 * * *",
|
||||
"cookies": "",
|
||||
"config": {},
|
||||
"template": "...",
|
||||
"folder": "...",
|
||||
"preset": "...",
|
||||
"preset": "default",
|
||||
"folder": "",
|
||||
"template": "",
|
||||
"cli": "",
|
||||
"auto_start": true,
|
||||
"handler_enabled": true,
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response (multiple tasks):**
|
||||
```json
|
||||
[
|
||||
{"id": 1, "name": "First Task", "url": "https://...", ...},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
**Error Responses**:
|
||||
- `400 Bad Request` - Invalid request body or validation error
|
||||
- `409 Conflict` - Task with the same name already exists
|
||||
|
|
|
|||
|
|
@ -26,6 +26,122 @@ if TYPE_CHECKING:
|
|||
|
||||
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:
|
||||
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:
|
||||
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(
|
||||
{"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,
|
||||
)
|
||||
|
||||
try:
|
||||
item: Task = Task.model_validate(data)
|
||||
first_task: Task = Task.model_validate(first_item)
|
||||
except ValidationError as exc:
|
||||
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,
|
||||
)
|
||||
|
||||
if await repo.get_by_name(item.name):
|
||||
if await repo.get_by_name(first_task.name):
|
||||
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,
|
||||
)
|
||||
|
||||
try:
|
||||
created = await repo.create(item.model_dump())
|
||||
saved = _serialize(created)
|
||||
except ValueError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||
created_tasks: list[dict[str, Any]] = []
|
||||
base_settings = first_task.model_dump(exclude={"id", "name", "url", "created_at", "updated_at"})
|
||||
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -220,10 +220,11 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, toRef, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useStorage, useIntersectionObserver } from '@vueuse/core'
|
||||
import type { item_request } from '~/types/item'
|
||||
import type { Preset } from '~/types/presets'
|
||||
import type { ItemStatus, StoreItem } from '~/types/store'
|
||||
import { useNotification } from '~/composables/useNotification'
|
||||
import { useConfigStore } from '~/stores/ConfigStore'
|
||||
|
|
@ -250,7 +251,9 @@ const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
|
|||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
||||
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 embedUrl = ref<string>('')
|
||||
|
|
@ -662,7 +665,7 @@ const deleteHistoryItem = async (item: StoreItem): Promise<void> => {
|
|||
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) => {
|
||||
if (!item?.msg || item.msg === item?.error) {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,26 @@
|
|||
<template>
|
||||
<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">
|
||||
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.
|
||||
</Message>
|
||||
</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">
|
||||
You are using a generic RSS/Atom feed URL. The task handler will automatically download new items found
|
||||
in this feed.
|
||||
</Message>
|
||||
</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">
|
||||
<form autocomplete="off" id="taskForm" @submit.prevent="checkInfo()">
|
||||
<div class="card">
|
||||
|
|
@ -83,21 +92,30 @@
|
|||
<div class="field">
|
||||
<label class="label is-inline" for="url">
|
||||
<span class="icon"><i class="fa-solid fa-link" /></span>
|
||||
URL
|
||||
<template v-if="is_yt_handle(form.url)">
|
||||
<span>URL</span>
|
||||
<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>
|
||||
</template>
|
||||
</label>
|
||||
<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"
|
||||
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"
|
||||
:class="{ 'fa-spin': convertInProgress }" /></span>
|
||||
</div>
|
||||
<span class="help">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -366,6 +384,7 @@ import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
|
|||
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
||||
import type { ExportedTask, Task } from '~/types/tasks'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
import { shortPath } from "~/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
reference?: number | null | undefined
|
||||
|
|
@ -375,7 +394,7 @@ const props = defineProps<{
|
|||
|
||||
const emitter = defineEmits<{
|
||||
(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()
|
||||
|
|
@ -388,11 +407,86 @@ const import_string = ref<string>('')
|
|||
const showOptions = ref<boolean>(false)
|
||||
const ytDlpOpt = ref<AutoCompleteOptions>([])
|
||||
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 GENERIC_RSS_REGEX = /\.(rss|atom)(\?.*)?$|handler=rss/i
|
||||
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
|
||||
.filter(opt => !opt.ignored)
|
||||
.flatMap(opt => opt.flags
|
||||
|
|
@ -421,12 +515,16 @@ onMounted(() => {
|
|||
})
|
||||
|
||||
const checkInfo = async (): Promise<void> => {
|
||||
const required = ['name', 'url'] as const
|
||||
for (const key of required) {
|
||||
if (!form[key]) {
|
||||
toast.error(`The ${key} field is required.`)
|
||||
return
|
||||
}
|
||||
const urls = splitUrls(form.url || '')
|
||||
|
||||
if (urls.length === 0) {
|
||||
toast.error('At least one URL is required.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.name) {
|
||||
toast.error('The name field is required.')
|
||||
return
|
||||
}
|
||||
|
||||
if (form.folder) {
|
||||
|
|
@ -445,7 +543,7 @@ const checkInfo = async (): Promise<void> => {
|
|||
}
|
||||
|
||||
try {
|
||||
new URL(form.url)
|
||||
new URL(urls[0] || '')
|
||||
} catch {
|
||||
toast.error('Invalid URL')
|
||||
return
|
||||
|
|
@ -457,7 +555,30 @@ const checkInfo = async (): Promise<void> => {
|
|||
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> => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { ref, readonly } from 'vue'
|
||||
|
||||
import { useNotification } from '~/composables/useNotification'
|
||||
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils'
|
||||
import type {
|
||||
|
|
@ -34,6 +33,10 @@ const isLoading = ref<boolean>(false)
|
|||
* Indicates if an add/update operation is in progress.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
|
|
@ -181,14 +184,14 @@ const getTask = async (id: number): Promise<Task | null> => {
|
|||
|
||||
/**
|
||||
* 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
|
||||
* @returns Created task or null on error
|
||||
* @returns Created task(s) or null on error
|
||||
*/
|
||||
const createTask = async (
|
||||
task: Omit<Task, 'id' | 'created_at' | 'updated_at'>,
|
||||
callback?: (response: APIResponse<Task>) => void,
|
||||
): Promise<Task | null> => {
|
||||
task: Omit<Task, 'id' | 'created_at' | 'updated_at'> | Omit<Task, 'id' | 'created_at' | 'updated_at'>[],
|
||||
callback?: (response: APIResponse<Task | Task[]>) => void,
|
||||
): Promise<Task | Task[] | null> => {
|
||||
addInProgress.value = true
|
||||
try {
|
||||
const response = await request('/api/tasks/', {
|
||||
|
|
@ -198,7 +201,19 @@ const createTask = async (
|
|||
await ensureSuccess(response)
|
||||
|
||||
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)
|
||||
notify.success('Task created.')
|
||||
|
|
@ -242,7 +257,7 @@ const updateTask = async (
|
|||
try {
|
||||
// Explicitly remove id, created_at, updated_at fields if present
|
||||
const { id: _, created_at: __, updated_at: ___, ...taskData } = task as Task
|
||||
|
||||
|
||||
const response = await request(`/api/tasks/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(taskData),
|
||||
|
|
@ -465,6 +480,29 @@ const generateTaskMetadata = async (id: number): Promise<TaskMetadataResponse |
|
|||
*/
|
||||
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).
|
||||
*/
|
||||
|
|
@ -482,6 +520,7 @@ const __resetForTesting = () => {
|
|||
addInProgress.value = false
|
||||
lastError.value = null
|
||||
throwInstead.value = false
|
||||
inProgressIds.value = new Set()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -496,6 +535,10 @@ export const useTasks = () => ({
|
|||
isLoading: readonly(isLoading),
|
||||
addInProgress: readonly(addInProgress),
|
||||
lastError: readonly(lastError),
|
||||
inProgressIds: readonly(inProgressIds),
|
||||
isTaskInProgress,
|
||||
setTaskInProgress,
|
||||
clearTaskInProgress,
|
||||
loadTasks,
|
||||
getTask,
|
||||
createTask,
|
||||
|
|
|
|||
|
|
@ -268,7 +268,7 @@
|
|||
<NuxtLink target="_blank" :href="item.url">
|
||||
{{ remove_tags(item.name) }}
|
||||
</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" />
|
||||
</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 type { item_request } from '~/types/item'
|
||||
|
||||
type TaskWithUI = Task & { in_progress?: boolean }
|
||||
|
||||
const box = useConfirm()
|
||||
const toast = useNotification()
|
||||
const config = useConfigStore()
|
||||
|
|
@ -481,9 +479,9 @@ const display_style = useStorage<string>("tasks_display_style", "cards")
|
|||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
||||
|
||||
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 toggleForm = ref<boolean>(false)
|
||||
const selectedElms = ref<Array<number>>([])
|
||||
|
|
@ -491,7 +489,7 @@ const masterSelectAll = ref(false)
|
|||
const massRun = ref<boolean>(false)
|
||||
const massDelete = ref<boolean>(false)
|
||||
const table_container = ref(false)
|
||||
const inspectTask = ref<TaskWithUI | null>(null)
|
||||
const inspectTask = ref<Task | null>(null)
|
||||
const query = ref()
|
||||
const toggleFilter = ref(false)
|
||||
const CACHE_KEY = 'tasks:handler_support'
|
||||
|
|
@ -591,7 +589,7 @@ const filteredTasks = computed(() => {
|
|||
if (!q) return tasks.value;
|
||||
|
||||
return tasks.value.filter(task => deepIncludes(task, q, new WeakSet()));
|
||||
}) as ComputedRef<Array<TaskWithUI>>;
|
||||
}) as ComputedRef<Array<Task>>;
|
||||
|
||||
const reloadContent = async (fromMounted: boolean = false) => {
|
||||
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 }) => {
|
||||
let createdOrUpdated: Task | null = null
|
||||
const updateItem = async ({ reference, task, archive_all }: { reference?: number | null | undefined, task: Task | Task[], archive_all?: boolean }) => {
|
||||
let createdOrUpdated: Task | Task[] | null = null
|
||||
|
||||
if (reference) {
|
||||
createdOrUpdated = await tasksComposable.updateTask(reference, task)
|
||||
createdOrUpdated = await tasksComposable.updateTask(reference, task as Task)
|
||||
} else {
|
||||
createdOrUpdated = await tasksComposable.createTask(task)
|
||||
}
|
||||
|
|
@ -720,15 +718,23 @@ const updateItem = async ({ reference, task, archive_all }: { reference?: number
|
|||
return
|
||||
}
|
||||
|
||||
await checkHandlerSupport(createdOrUpdated)
|
||||
|
||||
if (!reference && true === archive_all && createdOrUpdated.id) {
|
||||
await sleep(1)
|
||||
await nextTick()
|
||||
await archiveAll(createdOrUpdated, true)
|
||||
}
|
||||
const tasksList = Array.isArray(createdOrUpdated) ? createdOrUpdated : [createdOrUpdated]
|
||||
|
||||
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) => {
|
||||
|
|
@ -798,13 +804,15 @@ const runSelected = async () => {
|
|||
}, 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.`))) {
|
||||
return
|
||||
}
|
||||
|
||||
if (false === mass) {
|
||||
item.in_progress = true
|
||||
setTaskInProgress(item.id)
|
||||
}
|
||||
|
||||
const data: item_request = {
|
||||
|
|
@ -841,7 +849,7 @@ const runNow = async (item: TaskWithUI, mass: boolean = false) => {
|
|||
|
||||
setTimeout(async () => {
|
||||
await nextTick()
|
||||
item.in_progress = false
|
||||
if (item.id) clearTaskInProgress(item.id)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
|
|
@ -892,7 +900,7 @@ const get_tags = (name: string): Array<string> => {
|
|||
|
||||
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) {
|
||||
toast.error('Task ID is missing')
|
||||
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)
|
||||
} catch (e: any) {
|
||||
toast.error(`Failed to archive items. ${e.message || 'Unknown error.'}`)
|
||||
return
|
||||
} finally {
|
||||
item.in_progress = false
|
||||
clearTaskInProgress(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
const unarchiveAll = async (item: TaskWithUI) => {
|
||||
const unarchiveAll = async (item: Task) => {
|
||||
if (!item.id) {
|
||||
toast.error('Task ID is missing')
|
||||
return
|
||||
|
|
@ -934,17 +942,17 @@ const unarchiveAll = async (item: TaskWithUI) => {
|
|||
return;
|
||||
}
|
||||
|
||||
item.in_progress = true
|
||||
setTaskInProgress(item.id)
|
||||
await tasksComposable.unmarkTaskItems(item.id)
|
||||
} catch (e: any) {
|
||||
toast.error(`Failed to remove items from archive. ${e.message || 'Unknown error.'}`)
|
||||
return
|
||||
} finally {
|
||||
item.in_progress = false
|
||||
if (item.id) clearTaskInProgress(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
const generateMeta = async (item: TaskWithUI) => {
|
||||
const generateMeta = async (item: Task) => {
|
||||
if (!item.id) {
|
||||
toast.error('Task ID is missing')
|
||||
return
|
||||
|
|
@ -977,13 +985,13 @@ const generateMeta = async (item: TaskWithUI) => {
|
|||
return;
|
||||
}
|
||||
|
||||
item.in_progress = true
|
||||
setTaskInProgress(item.id)
|
||||
await tasksComposable.generateTaskMetadata(item.id)
|
||||
} catch (e: any) {
|
||||
toast.error(`Failed to generate metadata. ${e.message || 'Unknown error.'}`)
|
||||
return
|
||||
} finally {
|
||||
item.in_progress = false
|
||||
if (item.id) clearTaskInProgress(item.id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import path from "path";
|
||||
import { defineNuxtConfig } from 'nuxt/config'
|
||||
|
||||
let extraNitro = {}
|
||||
try {
|
||||
|
|
@ -63,17 +63,15 @@ export default defineNuxtConfig({
|
|||
linkActiveClass: "is-selected",
|
||||
}
|
||||
},
|
||||
|
||||
modules: [
|
||||
'@pinia/nuxt',
|
||||
'@vueuse/nuxt',
|
||||
'floating-vue/nuxt',
|
||||
'development' === process.env.NODE_ENV ? '@nuxt/eslint' : '',
|
||||
].filter(Boolean),
|
||||
|
||||
nitro: {
|
||||
output: {
|
||||
publicDir: path.join(__dirname, 'production' === process.env.NODE_ENV ? 'exported' : 'dist')
|
||||
publicDir: 'production' === process.env.NODE_ENV ? __dirname + '/exported' : __dirname + '/dist',
|
||||
},
|
||||
...extraNitro,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -21,23 +21,23 @@
|
|||
"dependencies": {
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"@vueuse/core": "^14.2.0",
|
||||
"@vueuse/nuxt": "^14.2.0",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"@vueuse/nuxt": "^14.2.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"cron-parser": "^5.5.0",
|
||||
"cronstrue": "^3.11.0",
|
||||
"cronstrue": "^3.12.0",
|
||||
"floating-vue": "^5.2.2",
|
||||
"hls.js": "^1.6.15",
|
||||
"marked": "^17.0.1",
|
||||
"marked": "^17.0.2",
|
||||
"marked-alert": "^2.1.2",
|
||||
"marked-base-url": "^1.1.8",
|
||||
"marked-gfm-heading-id": "^4.1.3",
|
||||
"moment": "^2.30.1",
|
||||
"nuxt": "^4.3.0",
|
||||
"nuxt": "^4.3.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.27",
|
||||
"vue-router": "^5.0.1",
|
||||
"vue": "^3.5.28",
|
||||
"vue-router": "^5.0.2",
|
||||
"vue-toastification": "2.0.0-rc.5"
|
||||
},
|
||||
"pnpm": {
|
||||
|
|
@ -53,13 +53,13 @@
|
|||
"ansi-regex": "6.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/eslint": "^1.13.0",
|
||||
"@nuxt/eslint-config": "^1.13.0",
|
||||
"@typescript-eslint/parser": "^8.54.0",
|
||||
"eslint": "^9.39.2",
|
||||
"@nuxt/eslint": "^1.15.1",
|
||||
"@nuxt/eslint-config": "^1.15.1",
|
||||
"@typescript-eslint/parser": "^8.55.0",
|
||||
"eslint": "^10.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18",
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-eslint-parser": "^10.4.0",
|
||||
"vue-tsc": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2466
ui/pnpm-lock.yaml
2466
ui/pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
38
uv.lock
38
uv.lock
|
|
@ -939,11 +939,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.7.0"
|
||||
version = "4.9.2"
|
||||
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 = [
|
||||
{ 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]]
|
||||
|
|
@ -1172,7 +1172,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pyinstaller"
|
||||
version = "6.18.0"
|
||||
version = "6.19.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "altgraph" },
|
||||
|
|
@ -1183,19 +1183,19 @@ dependencies = [
|
|||
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
|
||||
{ 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 = [
|
||||
{ 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/4c/9c/a3634c0ec8e1ed31b373b548848b5c0b39b56edc191cf737e697d484ec23/pyinstaller-6.18.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:07785459b3bf8a48889eac0b4d0667ade84aef8930ce030bc7cbb32f41283b33", size = 734971 },
|
||||
{ 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/54/39/fbc56519000cdbf450f472692a7b9b55d42077ce8529f1be631db7b75a36/pyinstaller-6.18.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:779817a0cf69604cddcdb5be1fd4959dc2ce048d6355c73e5da97884df2f3387", size = 744343 },
|
||||
{ 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/1c/08/3a1419183e4713ef77d912ecbdd6ef858689ed9deb34d547133f724ca745/pyinstaller-6.18.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:4328c9837f1aef4fe1a127d4ff1b09a12ce53c827ce87c94117628b0e1fd098b", size = 740943 },
|
||||
{ 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/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/9a/09/e7a870e7205cdbd2f8785010a5d3fe48a9df2591156ee34a8b29b774fa14/pyinstaller-6.18.0-py3-none-win32.whl", hash = "sha256:496205e4fa92ec944f9696eb597962a83aef4d4c3479abfab83d730e1edf016b", size = 1323811 },
|
||||
{ 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/1b/8d/1a88e6e94107de3ea1c842fd59c3aa132d344ad8e52ea458ffa9a748726e/pyinstaller-6.18.0-py3-none-win_arm64.whl", hash = "sha256:dba4b70e3c9ba09aab51152c72a08e58a751851548f77ad35944d32a300c8381", size = 1324869 },
|
||||
{ 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/cd/7e/dfd724b0b533f5aaec0ee5df406fe2319987ed6964480a706f85478b12ea/pyinstaller-6.19.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8bd68abd812d8a6ba33b9f1810e91fee0f325969733721b78151f0065319ca11", size = 735477 },
|
||||
{ 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/da/0a/fc77e9f861be8cf300ac37155f59cc92aff99b29f2ddd78546f563a5b5a6/pyinstaller-6.19.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4ab2bb52e58448e14ddf9450601bdedd66800465043501c1d8f1cab87b60b122", size = 744849 },
|
||||
{ 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/53/60/b8db5f1a4b0fb228175f2ea0aa33f949adcc097fbe981cc524f9faf85777/pyinstaller-6.19.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a0fc5f6b3c55aa54353f0c74ffa59b1115433c1850c6f655d62b461a2ed6cbbe", size = 741448 },
|
||||
{ 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/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/52/03/b2c2ee41fb8e10fd2a45d21f5ec2ef25852cfb978dbf762972eed59e3d63/pyinstaller-6.19.0-py3-none-win32.whl", hash = "sha256:3c5c251054fe4cfaa04c34a363dcfbf811545438cb7198304cd444756bc2edd2", size = 1324317 },
|
||||
{ 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/81/65/458cd523308a101a22fd2742893405030cc24994cc74b1b767cecf137160/pyinstaller-6.19.0-py3-none-win_arm64.whl", hash = "sha256:c2d5a539b0bfe6159d5522c8c70e1c0e487f22c2badae0f97d45246223b798ea", size = 1325374 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1733,7 +1733,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "trio"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
|
|
@ -1743,9 +1743,9 @@ dependencies = [
|
|||
{ name = "sniffio" },
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
|
|
|
|||
Loading…
Reference in a new issue