Merge pull request #210 from arabcoders/dev

Standardize the form design & add import/export to all functions
This commit is contained in:
Abdulmohsen 2025-03-14 23:18:44 +03:00 committed by GitHub
commit 9adb6efbc5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1221 additions and 1195 deletions

13
.vscode/launch.json vendored
View file

@ -43,6 +43,19 @@
"YTP_ACCESS_LOG": "true", "YTP_ACCESS_LOG": "true",
} }
}, },
{
"name": "Node: Generate UI",
"request": "launch",
"runtimeArgs": [
"run",
"generate"
],
"runtimeExecutable": "yarn",
"type": "node",
"cwd": "${workspaceFolder}/ui",
"console": "internalConsole",
"outputCapture": "std",
},
{ {
"name": "Python: main.py (Deep)", "name": "Python: main.py (Deep)",
"type": "debugpy", "type": "debugpy",

36
Pipfile.lock generated
View file

@ -27,11 +27,11 @@
}, },
"aiohappyeyeballs": { "aiohappyeyeballs": {
"hashes": [ "hashes": [
"sha256:0850b580748c7071db98bffff6d4c94028d0d3035acc20fd721a0ce7e8cac35d", "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558",
"sha256:18fde6204a76deeabc97c48bdd01d5801cfda5d6b9c8bbeb1aaaee9d648ca191" "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"
], ],
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.9'",
"version": "==2.5.0" "version": "==2.6.1"
}, },
"aiohttp": { "aiohttp": {
"hashes": [ "hashes": [
@ -149,11 +149,11 @@
}, },
"attrs": { "attrs": {
"hashes": [ "hashes": [
"sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e", "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3",
"sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a" "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==25.1.0" "version": "==25.3.0"
}, },
"bidict": { "bidict": {
"hashes": [ "hashes": [
@ -436,20 +436,20 @@
}, },
"curl-cffi": { "curl-cffi": {
"hashes": [ "hashes": [
"sha256:202145686c5ebc00635b9b15cc8afa82523f14197a640b618bb09d59e74889ba", "sha256:04b1d23f0f54f94b8298ed417e6bece85a635d674723cde2b155da686efbf78f",
"sha256:26d61c5b5c59e9c4a006cba35feccd73c75ce981db0da89a3da88dd280ead7a0", "sha256:15053d01c6a3e3c4c5331ce9e07e1dc31ca5aa063babca05d18b1b5aad369fac",
"sha256:429a14e724898b7001be2776caa87c294e8062f0ac652619df5854eeae6e156c", "sha256:1e60b8ecc80bfb0da4ff73ac9d194e80482b50ecbb8aefec1b0edaf45fafd80e",
"sha256:4818e074b61cb209bd8d4d0d03783313d4773e6b51f8b815e25aad9cc146a7b7", "sha256:3969e4260ad4dab638fb6dbe349623f9f5f022435c7fd21daf760231380367fa",
"sha256:752a4686c062a8bd6e25961845ea812704ba089a0e6863957deb2a30590105bb", "sha256:3e37b35268ca58492f54ed020ae4b50c33ee0debad4145db9f746f04ed466eb0",
"sha256:7968c3ea37bec96bbe4b623c0fa59c09d3cdaf173da2e0a516b19db52f05caa4", "sha256:458f53c41bd76d90d8974d60c3a8a0dd902a1af1f9056215cf24f454bcedc6fd",
"sha256:7b1177203e47d3248ccb250e63983eaddf39fce5332f91cc2993ea54cae5aa21", "sha256:59389773a1556e087120e91eac1e33f84f1599d853e1bc168b153e4cdf360002",
"sha256:7f41bd0a5dafe3fe19319da1c31302a40634ec4779769ca3d954d8673eb2b4f2", "sha256:cfc74f09e44d2d8d61b8e8fda3a7004b5bc0217a703fbbe9e16ef8caa1f3d4e4",
"sha256:81ba09622e68f8392236212dd0afd75dcd18cbe1be16467deb4d441d62cfc8a3", "sha256:f03f4b17dc679c82bd3c946feb1ad38749b2ad731d7c26daefaac857d1c72fd9",
"sha256:ea8f0511bc6882d30a985ddc53fa26d109f2977715d4851aef162bfa308cd2a8" "sha256:f1b0c7b7b81afca15a0e56c593d3c2bdcd4fd4c9ca49b9ded5b9d8076ba78ff9"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.9'",
"version": "==0.9.0" "version": "==0.10.0"
}, },
"debugpy": { "debugpy": {
"hashes": [ "hashes": [

View file

@ -360,7 +360,10 @@ class Notification(metaclass=Singleton):
method=target.get("request", {}).get("method", "POST"), method=target.get("request", {}).get("method", "POST"),
url=target.get("request", {}).get("url"), url=target.get("request", {}).get("url"),
headers=[ headers=[
TargetRequestHeader(key=h.get("key"), value=h.get("value")) TargetRequestHeader(
key=str(h.get("key", "")).strip(),
value=str(h.get("value", "")).strip(),
)
for h in target.get("request", {}).get("headers", []) for h in target.get("request", {}).get("headers", [])
], ],
), ),

View file

@ -129,7 +129,7 @@ class YTDLPOpts(metaclass=Singleton):
self.presets_opts = {} self.presets_opts = {}
self._item_opts = {} self._item_opts = {}
if "format" in data and "not_set" == data["format"]: if "format" in data and data["format"] in ["not_set", "default"]:
data["format"] = None data["format"] = None
return data return data

View file

@ -1,16 +1,57 @@
<template> <template>
<main class="columns mt-2"> <main class="columns mt-2 is-multiline">
<div class="column">
<form id="taskForm" @submit.prevent="checkInfo()">
<div class="box">
<div class="columns is-multiline is-mobile">
<div class="column is-12"> <div class="column is-12">
<h1 class="title is-6" style="border-bottom: 1px solid #dbdbdb;"> <form autocomplete="off" id="taskForm" @submit.prevent="checkInfo()">
<div class="card">
<div class="card-header">
<div class="card-header-title is-text-overflow is-block">
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span> <span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span>
<span>{{ reference ? 'Edit' : 'Add' }}</span> <span>{{ reference ? 'Edit' : 'Add' }}</span>
</span> </span>
</h1> </div>
<div class="card-header-icon" v-if="reference">
<button type="button" @click="showImport = !showImport">
<span class="icon"><i class="fa-solid" :class="{
'fa-arrow-down': !showImport,
'fa-arrow-up': showImport,
}" /></span>
<span>
<span v-if="showImport">Hide</span>
<span v-else>Show</span>
import
</span>
</button>
</div>
</div>
<div class="card-content">
<div class="columns is-multiline is-mobile">
<div class="column is-12" v-if="showImport || !reference">
<label class="label is-inline" for="import_string">
Import string
</label>
<div class="field has-addons">
<div class="control has-icons-left is-expanded">
<input type="text" class="input" id="import_string" v-model="import_string" autocomplete="off">
<span class="icon is-small is-left"><i class="fa-solid fa-t" /></span>
</div>
<div class="control">
<button class="button is-primary" :disabled="!import_string" type="button" @click="importItem">
<span class="icon"><i class="fa-solid fa-add" /></span>
<span>Import</span>
</button>
</div>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>You can use this field to populate the data, using shared string.</span>
</span>
</div> </div>
<div class="column is-6-tablet is-12-mobile"> <div class="column is-6-tablet is-12-mobile">
@ -119,7 +160,8 @@
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span> <span>
Subscribe to the events you want to listen for. When the event is triggered, the notification will Subscribe to the events you want to listen for. When the event is triggered, the notification will
be sent to the target URL. If no events are selected, the notification will be sent for all events. be sent to the target URL. If no events are selected, the notification will be sent for all
events.
</span> </span>
</span> </span>
</div> </div>
@ -178,26 +220,25 @@
</span> </span>
</div> </div>
</div> </div>
</div>
<div class="column is-12"> <div class="card-footer">
<div class="field is-grouped is-grouped-right"> <p class="card-footer-item">
<p class="control"> <button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit"
<button class="button is-primary" :disabled="addInProgress" type="submit"
:class="{ 'is-loading': addInProgress }" form="taskForm"> :class="{ 'is-loading': addInProgress }" form="taskForm">
<span class="icon"><i class="fa-solid fa-save" /></span> <span class="icon"><i class="fa-solid fa-save" /></span>
<span>Save</span> <span>Save</span>
</button> </button>
</p> </p>
<p class="control"> <p class="card-footer-item">
<button class="button is-danger" @click="emitter('cancel')" :disabled="addInProgress" type="button"> <button class="button is-fullwidth is-danger" @click="emitter('cancel')" :disabled="addInProgress"
type="button">
<span class="icon"><i class="fa-solid fa-times" /></span> <span class="icon"><i class="fa-solid fa-times" /></span>
<span>Cancel</span> <span>Cancel</span>
</button> </button>
</p> </p>
</div> </div>
</div> </div>
</div>
</div> </div>
</form> </form>
</div> </div>
@ -205,6 +246,7 @@
</template> </template>
<script setup> <script setup>
import { useStorage } from '@vueuse/core'
const emitter = defineEmits(['cancel', 'submit']); const emitter = defineEmits(['cancel', 'submit']);
const toast = useToast(); const toast = useToast();
const props = defineProps({ const props = defineProps({
@ -231,6 +273,8 @@ const props = defineProps({
const form = reactive(props.item); const form = reactive(props.item);
const requestMethods = ['POST', 'PUT']; const requestMethods = ['POST', 'PUT'];
const requestType = ['json', 'form']; const requestType = ['json', 'form'];
const showImport = useStorage('showImport', false);
const import_string = ref('');
const checkInfo = async () => { const checkInfo = async () => {
const required = ['name', 'request.url', 'request.method', 'request.type']; const required = ['name', 'request.url', 'request.method', 'request.type'];
@ -254,13 +298,73 @@ const checkInfo = async () => {
return; return;
} }
let headers = []
for (const header of form.request.headers) { for (const header of form.request.headers) {
if (!header.key || !header.value) { if (!header.key || !header.value) {
form.request.headers.splice(form.request.headers.indexOf(header), 1); continue
} }
headers.push({ key: String(header.key).trim(), value: String(header.value).trim() })
} }
form.request.headers = headers;
emitter('submit', { reference: toRaw(props.reference), item: toRaw(form) }); emitter('submit', { reference: toRaw(props.reference), item: toRaw(form) });
} }
const importItem = async () => {
let val = import_string.value.trim()
if (!val) {
toast.error('The import string is required.')
return
}
if (false === val.startsWith('{')) {
try {
val = base64UrlDecode(val)
} catch (e) {
console.error(e)
toast.error(`Failed to decode string. ${e.message}`)
return
}
}
try {
const item = JSON.parse(val)
if ('notification' !== item._type) {
toast.error(`Invalid import string. Expected type 'notification', got '${item._type}'.`)
import_string.value = ''
return
}
if (form.target) {
if (false === confirm('This will overwrite the current form fields. Are you sure?')) {
return
}
}
if (item.name) {
form.name = item.name
}
if (item.url) {
form.url = item.url
}
if (item.request) {
form.request = item.request
}
if (item.on) {
form.on = item.on
}
import_string.value = ''
} catch (e) {
console.error(e)
toast.error(`Failed to import task. ${e.message}`)
}
}
</script> </script>

View file

@ -1,38 +1,5 @@
<template> <template>
<main class="columns mt-2 is-multiline"> <main class="columns mt-2 is-multiline">
<div class="column is-12">
<h1 class="is-pointer is-unselectable title is-5" @click="importExpanded = !importExpanded">
<span class="icon-text">
<span class="icon"><i class="fa-solid" :class="importExpanded ? 'fa-arrow-up' : 'fa-arrow-down'" /></span>
<span>Import preset.</span>
</span>
</h1>
<form id="importForm" @submit.prevent="importPreset()" v-if="importExpanded">
<div class="box">
<label class="label" for="json_preset">
Base64 encoded JSON string.
</label>
<div class="field has-addons">
<div class="control has-icons-left is-expanded">
<input type="text" class="input" id="json_preset" v-model="json_preset" autocomplete="off">
<span class="icon is-small is-left"><i class="fa-solid fa-t" /></span>
</div>
<div class="control">
<button form="importForm" class="button is-primary" :disabled="!json_preset" type="submit">
<span class="icon"><i class="fa-solid fa-add" /></span>
<span>Import</span>
</button>
</div>
</div>
</div>
</form>
</div>
<div class="column is-12"> <div class="column is-12">
<h1 class="is-unselectable is-pointer title is-5" @click="convertExpanded = !convertExpanded"> <h1 class="is-unselectable is-pointer title is-5" @click="convertExpanded = !convertExpanded">
<span class="icon-text"> <span class="icon-text">
@ -40,10 +7,8 @@
<span>Convert yt-dlp cli options.</span> <span>Convert yt-dlp cli options.</span>
</span> </span>
</h1> </h1>
<form autocomplete="off" id="convertOpts" @submit.prevent="convertOptions()" v-if="convertExpanded"> <form autocomplete="off" id="convertOpts" @submit.prevent="convertOptions()" v-if="convertExpanded">
<div class="box"> <div class="box">
<label class="label" for="opts"> <label class="label" for="opts">
yt-dlp CLI options yt-dlp CLI options
</label> </label>
@ -74,18 +39,54 @@
</div> </div>
<div class="column is-12"> <div class="column is-12">
<form id="presetForm" @submit.prevent="checkInfo()"> <form autocomplete="off" id="presetForm" @submit.prevent="checkInfo()">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<div class="card-header-title"> <div class="card-header-title is-text-overflow is-block">
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span> <span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span>
<span>{{ reference ? 'Edit' : 'Add' }}</span> <span>{{ reference ? 'Edit' : 'Add' }}</span>
</span> </span>
</div> </div>
<div class="card-header-icon" v-if="reference">
<button type="button" @click="showImport = !showImport">
<span class="icon"><i class="fa-solid" :class="{
'fa-arrow-down': !showImport,
'fa-arrow-up': showImport,
}" /></span>
<span>{{ showImport ? 'Hide' : 'Show' }} import</span>
</button>
</div> </div>
</div>
<div class="card-content"> <div class="card-content">
<div class="columns is-multiline is-mobile"> <div class="columns is-multiline is-mobile">
<div class="column is-12" v-if="showImport || !reference">
<label class="label is-inline" for="import_string">
Import string
</label>
<div class="field has-addons">
<div class="control has-icons-left is-expanded">
<input type="text" class="input" id="import_string" v-model="import_string" autocomplete="off">
<span class="icon is-small is-left"><i class="fa-solid fa-t" /></span>
</div>
<div class="control">
<button class="button is-primary" :disabled="!import_string" type="button" @click="importItem">
<span class="icon"><i class="fa-solid fa-add" /></span>
<span>Import</span>
</button>
</div>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>You can use this field to populate the data, using shared string.</span>
</span>
</div>
<div class="column is-6-tablet is-12-mobile"> <div class="column is-6-tablet is-12-mobile">
<div class="field"> <div class="field">
<label class="label is-inline" for="name" v-text="'Name'" /> <label class="label is-inline" for="name" v-text="'Name'" />
@ -112,8 +113,7 @@
<span>The yt-dlp <code>[--format, -f]</code> video format code. see <NuxtLink <span>The yt-dlp <code>[--format, -f]</code> video format code. see <NuxtLink
href="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#format-selection" target="blank">this href="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#format-selection" target="blank">this
url</NuxtLink> for more info.</span>. Note, as this key is required, you can set the value to url</NuxtLink> for more info.</span>. Note, as this key is required, you can set the value to
<code>not_set</code> <code>default</code> to let <code>yt-dlp</code> choose the best format.
to use the default yt-dlp format.
</span> </span>
</div> </div>
</div> </div>
@ -218,6 +218,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="card-footer"> <div class="card-footer">
<div class="card-footer-item"> <div class="card-footer-item">
@ -235,8 +236,6 @@
</button> </button>
</div> </div>
</div> </div>
</div>
</div> </div>
</form> </form>
</div> </div>
@ -247,6 +246,7 @@
</template> </template>
<script setup> <script setup>
import { useStorage } from '@vueuse/core'
const emitter = defineEmits(['cancel', 'submit']); const emitter = defineEmits(['cancel', 'submit']);
const props = defineProps({ const props = defineProps({
@ -276,8 +276,8 @@ const toast = useToast()
const convertInProgress = ref(false) const convertInProgress = ref(false)
const form = reactive(JSON.parse(JSON.stringify(props.preset))) const form = reactive(JSON.parse(JSON.stringify(props.preset)))
const opts = ref('') const opts = ref('')
const json_preset = ref('') const import_string = ref('')
const importExpanded = ref(false) const showImport = useStorage('showImport', false);
const convertExpanded = ref(false) const convertExpanded = ref(false)
onMounted(() => { onMounted(() => {
@ -395,10 +395,10 @@ const convertOptions = async () => {
} }
} }
const importPreset = async () => { const importItem = async () => {
let val = json_preset.value.trim() let val = import_string.value.trim()
if (!val) { if (!val) {
toast.error('The preset import string is required.') toast.error('The import string is required.')
return return
} }
@ -406,13 +406,20 @@ const importPreset = async () => {
try { try {
val = base64UrlDecode(val) val = base64UrlDecode(val)
} catch (e) { } catch (e) {
toast.error('Invalid base64 string.', e) console.error(e)
toast.error(`Failed to decode string. ${e.message}`)
return return
} }
} }
try { try {
const preset = JSON.parse(val) const item = JSON.parse(val)
if ('preset' !== item._type) {
toast.error(`Invalid import string. Expected type 'preset', got '${item._type}'.`)
import_string.value = ''
return
}
if (form.format || form.args || form.postprocessors) { if (form.format || form.args || form.postprocessors) {
if (false === confirm('This will overwrite the current form fields. Are you sure?')) { if (false === confirm('This will overwrite the current form fields. Are you sure?')) {
@ -420,35 +427,35 @@ const importPreset = async () => {
} }
} }
if (preset.name) { if (item.name) {
form.name = preset.name form.name = item.name
} }
if (preset.format) { if (item.format) {
form.format = preset.format form.format = item.format
} }
if (preset.args) { if (item.args) {
form.args = JSON.stringify(preset.args, null, 2) form.args = JSON.stringify(item.args, null, 2)
} }
if (preset.postprocessors) { if (item.postprocessors) {
form.postprocessors = JSON.stringify(preset.postprocessors, null, 2) form.postprocessors = JSON.stringify(item.postprocessors, null, 2)
} }
if (preset.output_template) { if (item.output_template) {
form.template = preset.output_template form.template = item.output_template
} }
if (preset.folder) { if (item.folder) {
form.folder = preset.folder form.folder = item.folder
} }
json_preset.value = '' import_string.value = ''
importExpanded.value = false showImport.value = false
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.error(`Failed to import preset. ${e.message}`) toast.error(`Failed to string. ${e.message}`)
} }
} }
</script> </script>

View file

@ -1,17 +1,56 @@
<template> <template>
<main class="columns mt-2"> <main class="columns mt-2 is-multiline">
<div class="column">
<form id="taskForm" @submit.prevent="checkInfo()" class="box">
<div class="columns is-multiline is-mobile">
<div class="column is-12"> <div class="column is-12">
<h1 class="title is-6" style="border-bottom: 1px solid #dbdbdb;"> <form autocomplete="off" id="taskForm" @submit.prevent="checkInfo()">
<div class="card">
<div class="card-header">
<div class="card-header-title is-text-overflow is-block">
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span> <span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span>
<span>{{ reference ? 'Edit' : 'Add' }}</span> <span>{{ reference ? 'Edit' : 'Add' }}</span>
</span> </span>
</h1>
</div> </div>
<div class="card-header-icon" v-if="reference">
<button type="button" @click="showImport = !showImport">
<span class="icon"><i class="fa-solid" :class="{
'fa-arrow-down': !showImport,
'fa-arrow-up': showImport,
}" /></span>
<span>{{ showImport ? 'Hide' : 'Show' }} import</span>
</button>
</div>
</div>
<div class="card-content">
<div class="columns is-multiline is-mobile">
<div class="column is-12" v-if="showImport || !reference">
<label class="label is-inline" for="import_string">
Import string
</label>
<div class="field has-addons">
<div class="control has-icons-left is-expanded">
<input type="text" class="input" id="import_string" v-model="import_string" autocomplete="off">
<span class="icon is-small is-left"><i class="fa-solid fa-t" /></span>
</div>
<div class="control">
<button class="button is-primary" :disabled="!import_string" type="button" @click="importItem">
<span class="icon"><i class="fa-solid fa-add" /></span>
<span>Import</span>
</button>
</div>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>You can use this field to populate the data, using shared string.</span>
</span>
</div>
<div class="column is-6-tablet is-12-mobile"> <div class="column is-6-tablet is-12-mobile">
<div class="field"> <div class="field">
<label class="label is-inline" for="name" v-text="'Name'" /> <label class="label is-inline" for="name" v-text="'Name'" />
@ -40,23 +79,6 @@
</div> </div>
</div> </div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="folder">
Download path
</label>
<div class="control has-icons-left">
<input type="text" class="input" id="folder" placeholder="Leave empty to use default download path"
v-model="form.folder" :disabled="addInProgress" list="folders">
<span class="icon is-small is-left"><i class="fa-solid fa-folder" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Downloads are relative to download path, defaults to root path if not set.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile"> <div class="column is-6-tablet is-12-mobile">
<div class="field"> <div class="field">
<label class="label is-inline" for="folder"> <label class="label is-inline" for="folder">
@ -104,6 +126,23 @@
</div> </div>
</div> </div>
<div class="column is-6-tablet is-12-mobile" v-if="showAdvanced">
<div class="field">
<label class="label is-inline" for="folder">
Download path
</label>
<div class="control has-icons-left">
<input type="text" class="input" id="folder" placeholder="Leave empty to use default download path"
v-model="form.folder" :disabled="addInProgress" list="folders">
<span class="icon is-small is-left"><i class="fa-solid fa-folder" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Downloads are relative to download path, defaults to root path if not set.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile" v-if="showAdvanced"> <div class="column is-6-tablet is-12-mobile" v-if="showAdvanced">
<div class="field"> <div class="field">
<label class="label is-inline" for="output_template"> <label class="label is-inline" for="output_template">
@ -127,7 +166,8 @@
<div class="column is-6-tablet is-12-mobile" v-if="showAdvanced"> <div class="column is-6-tablet is-12-mobile" v-if="showAdvanced">
<div class="field"> <div class="field">
<label class="label is-inline" for="config" v-tooltip="'Extends current global yt-dlp config. (JSON)'"> <label class="label is-inline" for="config"
v-tooltip="'Extends current global yt-dlp config. (JSON)'">
JSON yt-dlp config or CLI options. <NuxtLink JSON yt-dlp config or CLI options. <NuxtLink
v-if="form.config && (typeof form.config === 'string') && !form.config.trim().startsWith('{')" v-if="form.config && (typeof form.config === 'string') && !form.config.trim().startsWith('{')"
@click="convertOptions()">Convert to JSON</NuxtLink> @click="convertOptions()">Convert to JSON</NuxtLink>
@ -139,7 +179,8 @@
<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> Extends current global yt-dlp config with given options. Some fields are ignored like <span> Extends current global yt-dlp config with given options. Some fields are ignored like
<code>cookiefile</code>, <code>paths</code>, and <code>outtmpl</code> etc. Warning: Use with caution <code>cookiefile</code>, <code>paths</code>, and <code>outtmpl</code> etc. Warning: Use with
caution
some of those options can break yt-dlp or the frontend.</span> some of those options can break yt-dlp or the frontend.</span>
</span> </span>
</div> </div>
@ -147,7 +188,8 @@
<div class="column is-6-tablet is-12-mobile" v-if="showAdvanced"> <div class="column is-6-tablet is-12-mobile" v-if="showAdvanced">
<div class="field"> <div class="field">
<label class="label is-inline" for="cookies" v-tooltip="'Netscape HTTP Cookie format.'">Cookies</label> <label class="label is-inline" for="cookies"
v-tooltip="'Netscape HTTP Cookie format.'">Cookies</label>
<div class="control"> <div class="control">
<textarea class="textarea is-pre" id="cookies" v-model="form.cookies" :disabled="addInProgress" /> <textarea class="textarea is-pre" id="cookies" v-model="form.cookies" :disabled="addInProgress" />
</div> </div>
@ -161,30 +203,32 @@
</div> </div>
</div> </div>
<div class="column is-12"> </div>
<div class="field is-grouped is-grouped-right"> </div>
<p class="control">
<button class="button is-primary" :disabled="addInProgress" type="submit" <div class="card-footer">
<p class="card-footer-item">
<button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit"
:class="{ 'is-loading': addInProgress }" form="taskForm"> :class="{ 'is-loading': addInProgress }" form="taskForm">
<span class="icon"><i class="fa-solid fa-save" /></span> <span class="icon"><i class="fa-solid fa-save" /></span>
<span>Save</span> <span>Save</span>
</button> </button>
</p> </p>
<p class="control"> <p class="card-footer-item">
<button class="button is-danger" @click="emitter('cancel')" :disabled="addInProgress" type="button"> <button class="button is-fullwidth is-danger" @click="emitter('cancel')" :disabled="addInProgress"
type="button">
<span class="icon"><i class="fa-solid fa-times" /></span> <span class="icon"><i class="fa-solid fa-times" /></span>
<span>Cancel</span> <span>Cancel</span>
</button> </button>
</p> </p>
<p class="control"> <p class="card-footer-item">
<button class="button is-info" type="button" @click="showAdvanced = !showAdvanced"> <button class="button is-fullwidth is-info" type="button" @click="showAdvanced = !showAdvanced">
<span class="icon"><i class="fa-solid fa-cog" /></span> <span class="icon"><i class="fa-solid fa-cog" /></span>
<span>Opts</span> <span>Advanced</span>
</button> </button>
</p> </p>
</div> </div>
</div> </div>
</div>
</form> </form>
</div> </div>
<datalist id="folders" v-if="config?.folders"> <datalist id="folders" v-if="config?.folders">
@ -195,14 +239,15 @@
<script setup> <script setup>
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { parseExpression } from 'cron-parser' import { CronExpressionParser } from 'cron-parser'
import { request } from '~/utils/index'
const emitter = defineEmits(['cancel', 'submit']); const emitter = defineEmits(['cancel', 'submit']);
const toast = useToast(); const toast = useToast();
const config = useConfigStore(); const config = useConfigStore();
const convertInProgress = ref(false); const convertInProgress = ref(false);
const showAdvanced = useStorage('task.showAdvanced', false); const showAdvanced = useStorage('task.showAdvanced', false);
const showImport = useStorage('showImport', false);
const import_string = ref('');
const props = defineProps({ const props = defineProps({
reference: { reference: {
@ -244,9 +289,10 @@ const checkInfo = async () => {
if (form.timer) { if (form.timer) {
try { try {
parseExpression(form.timer); CronExpressionParser.parse(form.timer);
} catch (e) { } catch (e) {
toast.error('Invalid CRON expression.'); console.log(e)
toast.error(`Invalid CRON expression. ${e.message}`);
return; return;
} }
} }
@ -311,4 +357,71 @@ const hasFormatInConfig = computed(() => {
return false return false
} }
}) })
const importItem = async () => {
let val = import_string.value.trim()
if (!val) {
toast.error('The import string is required.')
return
}
if (false === val.startsWith('{')) {
try {
val = base64UrlDecode(val)
} catch (e) {
console.error(e)
toast.error(`Failed to decode string. ${e.message}`)
return
}
}
try {
const item = JSON.parse(val)
if ('task' !== item._type) {
toast.error(`Invalid import string. Expected type 'task', got '${item._type}'.`)
import_string.value = ''
return
}
if (form.config || form.url || form.timer) {
if (false === confirm('This will overwrite the current form fields. Are you sure?')) {
return
}
}
if (item.name) {
form.name = item.name
}
if (item.url) {
form.url = item.url
}
if (item.preset) {
form.preset = item.preset
}
if (item.timer) {
form.timer = item.timer
}
if (item.folder) {
form.folder = item.folder
}
if (item.template) {
form.template = item.template
}
if (item.config) {
form.config = JSON.stringify(item.config, null, 2)
}
import_string.value = ''
} catch (e) {
console.error(e)
toast.error(`Failed to import string. ${e.message}`)
}
}
</script> </script>

View file

@ -11,19 +11,19 @@
}, },
"web-types": "./web-types.json", "web-types": "./web-types.json",
"dependencies": { "dependencies": {
"@pinia/nuxt": "^0.5.1", "@pinia/nuxt": "^0.10.1",
"@sentry/nuxt": "^9.2.0", "@sentry/nuxt": "^9.5.0",
"@vueuse/core": "^10.9.0", "@vueuse/core": "^13.0.0",
"@vueuse/nuxt": "^10.9.0", "@vueuse/nuxt": "^13.0.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"cron-parser": "^4.9.0", "cron-parser": "^5.0.4",
"cronstrue": "^2.49.0", "cronstrue": "^2.49.0",
"floating-vue": "^5.2.2", "floating-vue": "^5.2.2",
"hls.js": "^1.4.12", "hls.js": "^1.4.12",
"moment": "^2.30.1", "moment": "^2.30.1",
"nuxt": "^3.16", "nuxt": "^3.16",
"pinia": "^2.1.7", "pinia": "^3.0.1",
"socket.io-client": "^4.7.2", "socket.io-client": "^4.7.2",
"vue": "^3.4", "vue": "^3.4",
"vue-router": "^4.3.0", "vue-router": "^4.3.0",

View file

@ -66,9 +66,8 @@ div.is-centered {
<NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink> <NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink>
</div> </div>
<div class="card-header-icon"> <div class="card-header-icon">
<a :href="item.url" class="has-text-primary" v-tooltip="'Copy url.'" <a class="has-text-primary" v-tooltip="'Export target.'" @click.prevent="exportItem(item)">
@click.prevent="copyText(item.request.url)"> <span class="icon"><i class="fa-solid fa-file-export" /></span>
<span class="icon"><i class="fa-solid fa-copy" /></span>
</a> </a>
<button @click="item.raw = !item.raw"> <button @click="item.raw = !item.raw">
<span class="icon"><i class="fa-solid" <span class="icon"><i class="fa-solid"
@ -116,6 +115,19 @@ div.is-centered {
</Message> </Message>
</div> </div>
</div> </div>
<div class="column is-12" v-if="notifications && notifications.length > 0 && !toggleForm">
<Message message_class="has-background-info-90 has-text-dark" title="Tips" icon="fas fa-info-circle">
<ul>
<li>
When you export notification target, We remove <code>Authorization</code> header key by default,
However this might not be enough to remove credentials from the exported data. it's your responsibility
to ensure that the exported data does not contain any sensitive information for sharing.
</li>
</ul>
</Message>
</div>
</div> </div>
</template> </template>
@ -301,4 +313,23 @@ const sendTest = async () => {
onMounted(async () => socket.isConnected ? await reloadContent(true) : '') onMounted(async () => socket.isConnected ? await reloadContent(true) : '')
const exportItem = async item => {
let data = JSON.parse(JSON.stringify(item))
const keys = ['id', 'raw']
keys.forEach(k => {
if (data.hasOwnProperty(k)) {
delete data[k]
}
})
// -- Remove authorization headers
if (data.request?.headers && data.request.headers.length > 0) {
data.request.headers = data.request.headers.filter(h => h.key.toLowerCase() !== 'authorization')
}
data['_type'] = 'notification'
data['_version'] = '1.0'
return copyText(base64UrlEncode(JSON.stringify(data)));
}
</script> </script>

View file

@ -58,8 +58,8 @@ div.is-centered {
<header class="card-header"> <header class="card-header">
<div class="card-header-title is-text-overflow is-block" v-text="item.name" /> <div class="card-header-title is-text-overflow is-block" v-text="item.name" />
<div class="card-header-icon"> <div class="card-header-icon">
<a class="has-text-primary" v-tooltip="'Export preset.'" @click.prevent="copyItem(item)"> <a class="has-text-primary" v-tooltip="'Export preset.'" @click.prevent="exportItem(item)">
<span class="icon"><i class="fa-solid fa-copy" /></span> <span class="icon"><i class="fa-solid fa-file-export" /></span>
</a> </a>
<button @click="item.raw = !item.raw"> <button @click="item.raw = !item.raw">
<span class="icon"><i class="fa-solid" :class="{ <span class="icon"><i class="fa-solid" :class="{
@ -116,12 +116,11 @@ div.is-centered {
v-if="!presets || presets.length < 1" /> v-if="!presets || presets.length < 1" />
</div> </div>
</div> </div>
<div class="column is-12"> <div class="column is-12" v-if="presets && presets.length > 0 && !toggleForm">
<Message message_class="has-background-info-90 has-text-dark" title="Tips" icon="fas fa-info-circle"> <Message message_class="has-background-info-90 has-text-dark" title="Tips" icon="fas fa-info-circle">
<ul> <ul>
<li> <li>
When you export the preset, it doesn't include <code>Cookies</code> field for security reasons. The exported When you export preset, it doesn't include <code>Cookies</code> field for security reasons.
string is URL safe base64 encoded JSON string.
</li> </li>
</ul> </ul>
</Message> </Message>
@ -285,10 +284,10 @@ const editItem = (item) => {
onMounted(async () => (socket.isConnected ? await reloadContent(true) : "")); onMounted(async () => (socket.isConnected ? await reloadContent(true) : ""));
const copyItem = (item) => { const exportItem = item => {
let data = JSON.parse(JSON.stringify(item)); let data = JSON.parse(JSON.stringify(item));
const keys = ["id", "default", "raw", "cookies"]; const keys = ["id", "default", "raw", "cookies"];
keys.forEach((key) => { keys.forEach(key => {
if (key in data) { if (key in data) {
delete data[key]; delete data[key];
} }
@ -311,6 +310,9 @@ const copyItem = (item) => {
userData[key] = data[key]; userData[key] = data[key];
} }
userData['_type'] = 'preset';
userData['_version'] = '1.0'
return copyText(base64UrlEncode(JSON.stringify(userData))); return copyText(base64UrlEncode(JSON.stringify(userData)));
}; };

View file

@ -39,8 +39,8 @@ div.is-centered {
</div> </div>
<div class="is-hidden-mobile"> <div class="is-hidden-mobile">
<span class="subtitle"> <span class="subtitle">
The task runner is simply queuing given urls at the specified time. It's basically doing what you are doing The task runner is simple queue system that allows you to schedule downloads. The tasks are run at the
when you click the add button on the WebGUI, this just fancy way to automate that. scheduled time.
</span> </span>
</div> </div>
</div> </div>
@ -59,9 +59,8 @@ div.is-centered {
<NuxtLink target="_blank" :href="item.url">{{ item.name }}</NuxtLink> <NuxtLink target="_blank" :href="item.url">{{ item.name }}</NuxtLink>
</div> </div>
<div class="card-header-icon"> <div class="card-header-icon">
<a :href="item.url" class="has-text-primary" v-tooltip="'Copy url.'" <a class="has-text-primary" v-tooltip="'Export task.'" @click.prevent="exportItem(item)">
@click.prevent="copyText(item.url)"> <span class="icon"><i class="fa-solid fa-file-export" /></span>
<span class="icon"><i class="fa-solid fa-copy" /></span>
</a> </a>
<button @click="item.raw = !item.raw"> <button @click="item.raw = !item.raw">
<span class="icon"><i class="fa-solid" <span class="icon"><i class="fa-solid"
@ -121,12 +120,23 @@ div.is-centered {
icon="fas fa-exclamation-circle" v-if="!tasks || tasks.length < 1" /> icon="fas fa-exclamation-circle" v-if="!tasks || tasks.length < 1" />
</div> </div>
</div> </div>
<div class="column is-12" v-if="tasks && tasks.length > 0 && !toggleForm">
<Message message_class="has-background-info-90 has-text-dark" title="Tips" icon="fas fa-info-circle">
<ul>
<li>
When you export task, the preset settings is automatically merged into the task and the preset set to
<code>default</code> to be more portable. The exporter doesn't include <code>Cookies</code> field for
security reasons.
</li>
</ul>
</Message>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import moment from 'moment' import moment from 'moment'
import { parseExpression } from 'cron-parser' import { CronExpressionParser } from 'cron-parser'
import { request } from '~/utils/index' import { request } from '~/utils/index'
const toast = useToast() const toast = useToast()
@ -293,7 +303,7 @@ onMounted(async () => {
const tryParse = expression => { const tryParse = expression => {
try { try {
return moment(parseExpression(expression).next().toISOString()).fromNow() return moment(CronExpressionParser.parse(expression).next().toISOString()).fromNow()
} catch (e) { } catch (e) {
return "Invalid" return "Invalid"
} }
@ -347,4 +357,72 @@ const statusHandler = async stream => {
return return
} }
} }
const exportItem = async item => {
let preset = config.presets.find(p => p.name === item.preset)
if (!preset) {
toast.error('Preset not found.')
return
}
const info = JSON.parse(JSON.stringify(item))
preset = JSON.parse(JSON.stringify(preset))
let data = {
name: info.name,
url: info.url,
preset: 'default',
timer: info.timer,
folder: info.folder,
template: info.template,
config: info.config,
}
// -- merge preset options with task args.
let args = {}
if (preset.args && Object.keys(preset.args).length > 0) {
for (const key of Object.keys(preset.args)) {
args[key] = preset.args[key]
}
}
const defaults = ['default', 'not_set']
if (preset.format && !defaults.includes(preset.format)) {
args.format = preset.format
}
if (preset.postprocessors && preset.postprocessors.length > 0) {
args.postprocessors = preset.postprocessors
}
if (preset.folder && !info.folder) {
data.folder = preset.folder
}
if (preset.template && !info.template) {
data.template = preset.template
}
if (!data.config || Object.keys(data.config).length < 1) {
data.config = {}
}
for (const key of Object.keys(args)) {
if (key in data.config && Array.isArray(args[key]) && Array.isArray(data.config[key])) {
data.config[key] = data.config[key].concat(args[key])
continue
}
if (data?.config[key]) {
continue
}
data.config[key] = args[key]
}
data['_type'] = 'task'
data['_version'] = '1.1'
return copyText(base64UrlEncode(JSON.stringify(data)));
}
</script> </script>

File diff suppressed because it is too large Load diff