more changes to support custom fields in download form

This commit is contained in:
arabcoders 2025-08-07 21:01:24 +03:00
parent 0ea1d80243
commit 355adfade4
9 changed files with 270 additions and 4 deletions

View file

@ -1,5 +1,6 @@
import json import json
import logging import logging
import re
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
@ -153,7 +154,7 @@ class DLFields(metaclass=Singleton):
def get_all(self) -> list[DLField]: def get_all(self) -> list[DLField]:
"""Return the items.""" """Return the items."""
return self._default + self._items return self._items
def load(self) -> "DLFields": def load(self) -> "DLFields":
""" """
@ -257,6 +258,10 @@ class DLFields(metaclass=Singleton):
msg = "Extras must be a dictionary." msg = "Extras must be a dictionary."
raise ValueError(msg) # noqa: TRY004 raise ValueError(msg) # noqa: TRY004
if re.match(r"^--[a-zA-Z0-9\-]+$", item.get("field", "").strip()) is None:
msg = "Invalid yt-dlp option field it must starts with '--' and contain only alphanumeric characters."
raise ValueError(msg)
return True return True
def save(self, items: list[DLField | dict]) -> "DLFields": def save(self, items: list[DLField | dict]) -> "DLFields":

View file

@ -85,7 +85,7 @@ async def dl_fields_add(request: Request, encoder: Encoder, notify: EventBus) ->
status=web.HTTPBadRequest.status_code, status=web.HTTPBadRequest.status_code,
) )
items.append(init_class(DLFields, item)) items.append(init_class(DLField, item))
try: try:
items = cls.save(items=items).load().get_all() items = cls.save(items=items).load().get_all()

View file

@ -6,6 +6,7 @@ from typing import Any
import socketio import socketio
from app.library.config import Config from app.library.config import Config
from app.library.dl_fields import DLFields
from app.library.DownloadQueue import DownloadQueue from app.library.DownloadQueue import DownloadQueue
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.Presets import Presets from app.library.Presets import Presets
@ -26,6 +27,7 @@ async def connect(config: Config, queue: DownloadQueue, notify: EventBus, sid: s
**queue.get(), **queue.get(),
"config": config.frontend(), "config": config.frontend(),
"presets": Presets.get_instance().get_all(), "presets": Presets.get_instance().get_all(),
"dl_fields": DLFields.get_instance().get_all(),
"paused": queue.is_paused(), "paused": queue.is_paused(),
} }

View file

@ -0,0 +1,245 @@
<template>
<form class="modal is-active" @submit.prevent="updateItems">
<div class="modal-background" @click="cancel" />
<div class="modal-card" style="min-width:calc(100vw - 30vw);">
<header class="modal-card-head">
<p class="modal-card-title">Manage Custom Fields</p>
<button type="button" class="delete" aria-label="close" @click="cancel" />
</header>
<section class="modal-card-body">
<div class="columns is-multiline">
<div class="column is-12 is-clearfix is-unselectable">
<div class="is-pulled-right">
<div class="field is-grouped">
<p class="control">
<button type="button" class="button is-primary" @click="addNewField" :disabled="isLoading">
<span class="icon"><i class="fas fa-add" /></span>
</button>
</p>
<p class="control">
<button type="button" class="button is-info" @click="loadContent()"
:class="{ 'is-loading': isLoading }" :disabled="isLoading">
<span class="icon"><i class="fas fa-refresh" /></span>
</button>
</p>
</div>
</div>
</div>
<div class="column is-12" v-for="(item, index) in items" :key="item.id || index">
<div class="box">
<div class="columns is-multiline">
<div class="column is-12 is-12-mobile">
<NuxtLink @click="deleteField(index)" :disabled="isLoading" class="has-text-danger">
<span class="icon"><i class="fas fa-trash" /></span>
<span>Delete Field</span>
</NuxtLink>
</div>
<div class="column is-6 is-12-mobile">
<div class="field">
<label class="label">Field Type</label>
<div class="select is-fullwidth" :class="{ 'is-loading': isLoading }">
<select v-model="item.kind" :disabled="isLoading" class="is-capitalized">
<option v-for="kind in Object.values(FieldTypes)" :key="`kind-${kind}`" :value="kind"
v-text="kind" />
</select>
</div>
<span class="help is-bold">
Field Type.
</span>
</div>
</div>
<div class="column is-6 is-12-mobile">
<div class="field">
<label class="label">Field Name</label>
<input type="text" v-model="item.name" class="input" :disabled="isLoading" />
<span class="help is-bold">
The name of the field, it will be shown in the UI.
</span>
</div>
</div>
<div class="column is-6 is-12-mobile">
<div class="field">
<label class="label">Associated yt-dlp option</label>
<input type="text" v-model="item.field" class="input" :disabled="isLoading" />
<span class="help is-bold">
The long form of yt-dlp option name, e.g. <code>--no-overwrites</code> not <code>-w</code>.
</span>
</div>
</div>
<div class="column is-6 is-12-mobile">
<div class="field">
<label class="label">Description</label>
<input type="text" v-model="item.description" class="input" :disabled="isLoading" />
<span class="help is-bold">
A short description of the field, it will be shown in the UI.
</span>
</div>
</div>
<!--
<div class="column is-6 is-12-mobile">
<div class="field">
<label class="label is-unselectable" :for="`p-${index}`">Persist the value?</label>
<input :id="`p-${index}`" type="checkbox" v-model="item.extras.persist" :disabled="isLoading"
class="switch is-success" />
<label class="label is-unselectable" :for="`p-${index}`">
{{ item.extras?.persist ? 'Yes' : 'No' }}
</label>
<span class="help is-bold">If enabled the value will persist.</span>
</div>
</div>
<div class="column is-6 is-12-mobile">
<div class="field">
<label class="label">Value</label>
<input type="text" v-model="item.value" class="input" :disabled="true" />
<span class="help is-bold">
This field will be used in the future to store values for select type field.
</span>
</div>
</div> -->
</div>
</div>
</div>
<div class="column is-12">
<Message message_class="has-background-warning-90 has-text-dark" v-if="items.length === 0">
<span class="icon">
<i class="fas fa-exclamation-circle" />
</span>
<span>
No custom fields found, you can add new fields using the button above.
</span>
</Message>
</div>
</div>
</section>
<footer class="modal-card-foot p-5">
<div class="field is-grouped" style="width:100%">
<div class="control is-expanded">
<button type="submit" class="button is-fullwidth is-primary" :disabled="isLoading">
<span class="icon"><i class="fas fa-save" /></span>
<span>Save</span>
</button>
</div>
<div class="control is-expanded">
<button type="button" class="button is-fullwidth is-danger" @click="cancel" :disabled="isLoading">
<span class="icon"><i class="fas fa-times" /></span>
<span>Cancel</span>
</button>
</div>
</div>
</footer>
</div>
</form>
</template>
<script setup lang="ts">
import { defineEmits, ref } from 'vue'
import type { DLField } from '~/types/dl_fields'
const emitter = defineEmits<{ (e: 'cancel'): void }>()
const toast = useNotification()
const isLoading = ref<boolean>(false)
const items = ref<DLField[]>([])
const FieldTypes = {
STRING: 'string',
TEXT: 'text',
BOOL: 'bool'
}
const cancel = () => emitter('cancel')
const loadContent = async () => {
try {
isLoading.value = true
const response = await request('/api/dl_fields')
const data = await response.json()
if (0 === data.length) {
return
}
items.value = data
} catch (e: any) {
console.error(e)
toast.error('Failed to fetch page content.')
} finally {
isLoading.value = false
}
}
const updateItems = async () => {
for (const item of items.value) {
if (validateItem(item, items.value.indexOf(item) + 1)) {
continue
}
return
}
try {
isLoading.value = true
const resp = await request('/api/dl_fields', {
method: 'PUT',
body: JSON.stringify(items.value)
})
if (!resp.ok) {
const error = await resp.json()
toast.error(`Failed to update fields: ${error.error || 'Unknown error'}`)
return
}
toast.success('Fields updated successfully.')
emitter('cancel')
} finally {
isLoading.value = false
}
}
const addNewField = () => items.value.push({
name: '',
description: '',
kind: 'string',
field: '',
value: '',
extras: {}
})
const deleteField = (index: number) => items.value.splice(index, 1)
const validateItem = (item: DLField, index: number): boolean => {
const requiredFields = ['name', 'field', 'kind', 'description']
for (const field of requiredFields) {
if (!item[field as keyof DLField] || item[field as keyof DLField]?.trim() === '') {
toast.error(`${item.name || index}: Field ${field} is required.`)
return false
}
}
if (!Object.values(FieldTypes).includes(item.kind)) {
toast.error(`${item.name || index}: Invalid field type: ${item.kind}`)
return false
}
if (!/^--[a-zA-Z0-9\-]+$/.test(item.field)) {
toast.error(`${item.name || index}: Invalid field format, it must start with '--' and contain no spaces.`)
return false
}
return true
}
onMounted(async () => await loadContent())
</script>

View file

@ -198,6 +198,13 @@
<div class="column is-12"> <div class="column is-12">
<div class="field is-grouped is-justify-self-end is-hidden-mobile"> <div class="field is-grouped is-justify-self-end is-hidden-mobile">
<div class="control"> <div class="control">
<button type="button" class="button is-purple" @click="() => showFields = true"
:class="{ 'is-loading': !socket.isConnected }" :disabled="!socket.isConnected">
<span class="icon"><i class="fa-solid fa-plus" /></span>
<span>Custom Fields</span>
</button>
</div>
<div class="control">
<button type="button" class="button is-info" @click="emitter('getInfo', form.url, form.preset)" <button type="button" class="button is-info" @click="emitter('getInfo', form.url, form.preset)"
:class="{ 'is-loading': !socket.isConnected }" :class="{ 'is-loading': !socket.isConnected }"
:disabled="!socket.isConnected || addInProgress || !form?.url"> :disabled="!socket.isConnected || addInProgress || !form?.url">
@ -235,6 +242,8 @@
<ConfirmDialog v-if="dialog_confirm.visible" :visible="dialog_confirm.visible" :title="dialog_confirm.title" <ConfirmDialog v-if="dialog_confirm.visible" :visible="dialog_confirm.visible" :title="dialog_confirm.title"
:message="dialog_confirm.message" :options="dialog_confirm.options" @confirm="dialog_confirm.confirm" :message="dialog_confirm.message" :options="dialog_confirm.options" @confirm="dialog_confirm.confirm"
@cancel="() => dialog_confirm.visible = false" /> @cancel="() => dialog_confirm.visible = false" />
<DLFields v-if="showFields" @cancel="() => showFields = false" />
</main> </main>
</template> </template>
@ -259,6 +268,7 @@ const auto_start = useStorage<boolean>('auto_start', true)
const show_description = useStorage<boolean>('show_description', true) const show_description = useStorage<boolean>('show_description', true)
const addInProgress = ref<boolean>(false) const addInProgress = ref<boolean>(false)
const showFields = ref<boolean>(true)
const form = useStorage<item_request>('local_config_v1', { const form = useStorage<item_request>('local_config_v1', {
id: null, id: null,

View file

@ -39,6 +39,7 @@ export const useConfigStore = defineStore('config', () => {
'default': true 'default': true
} }
], ],
dl_fields: [],
folders: [], folders: [],
paused: false, paused: false,
is_loaded: false, is_loaded: false,

View file

@ -177,6 +177,7 @@ export const useSocketStore = defineStore('socket', () => {
}, true); }, true);
on('presets_update', (data: string) => config.update('presets', JSON.parse(data).data || [])); on('presets_update', (data: string) => config.update('presets', JSON.parse(data).data || []));
on('dlfields_update', (data: string) => config.update('dl_fields', JSON.parse(data).data || []));
} }
if (false === isConnected.value) { if (false === isConnected.value) {

View file

@ -72,6 +72,8 @@ type ConfigState = {
app: AppConfig app: AppConfig
/** List of presets */ /** List of presets */
presets: Preset[] presets: Preset[]
/** List of custom download fields */
dl_fields: DLField[]
/** List of folders where files can be saved */ /** List of folders where files can be saved */
folders: string[] folders: string[]
/** Indicates if downloads are currently paused */ /** Indicates if downloads are currently paused */

View file

@ -1,8 +1,8 @@
enum DLFieldType { STRING = "string", TEXT = "text", BOOL = "bool" } type DLFieldType = "string" | "text" | "bool";
type DLField = { type DLField = {
/** The id of the field */ /** The id of the field */
id: string; id?: string;
/** The name of the preset */ /** The name of the preset */
name: string; name: string;