Merge pull request #289 from arabcoders/dev

Major WebUI improvements + minor bug fixes
This commit is contained in:
Abdulmohsen 2025-06-04 00:45:25 +03:00 committed by GitHub
commit cf2fb5fac8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1085 additions and 379 deletions

View file

@ -74,4 +74,13 @@
"spellright.documentTypes": [ "spellright.documentTypes": [
"latex" "latex"
], ],
"search.exclude": {
"ui/.nuxt": true,
"ui/.output": true,
"ui/dist": true,
"ui/exported": true,
"ui/node_modules": true,
"ui/.out": true,
"**/.venv": true,
}
} }

View file

@ -871,6 +871,83 @@ class HttpAPI(Common):
await self._notify.emit(Events.CONDITIONS_UPDATE, data=items) await self._notify.emit(Events.CONDITIONS_UPDATE, data=items)
return web.json_response(data=items, status=web.HTTPOk.status_code, dumps=self.encoder.encode) return web.json_response(data=items, status=web.HTTPOk.status_code, dumps=self.encoder.encode)
@route("POST", "api/conditions/test")
async def conditions_test(self, request: Request) -> Response:
"""
Test condition against URL.
Args:
request (Request): The request object.
Returns:
Response: The response object
"""
params = await request.json()
if not isinstance(params, dict):
return web.json_response(
{"error": "Invalid request body expecting dict."},
status=web.HTTPBadRequest.status_code,
)
url = params.get("url")
if not url:
return web.json_response({"error": "url is required."}, status=web.HTTPBadRequest.status_code)
cond = params.get("condition")
if not cond:
return web.json_response({"error": "condition is required."}, status=web.HTTPBadRequest.status_code)
try:
preset = params.get("preset", self.config.default_preset)
key = self.cache.hash(url + str(preset))
if not self.cache.has(key):
opts = {}
if ytdlp_proxy := self.config.get_ytdlp_args().get("proxy", None):
opts["proxy"] = ytdlp_proxy
ytdlp_opts = YTDLPOpts.get_instance().preset(name=preset, with_cookies=True).add(opts).get_all()
data = extract_info(
config=ytdlp_opts,
url=url,
debug=False,
no_archive=True,
follow_redirect=True,
sanitize_info=True,
)
if not data:
return web.json_response(
data={"error": f"Failed to extract info from '{url!s}'."},
status=web.HTTPBadRequest.status_code,
)
self.cache.set(key=key, value=data, ttl=600)
else:
data = self.cache.get(key)
except Exception as e:
LOG.exception(e)
return web.json_response(
data={"error": f"Failed to extract video info. '{e!s}'"},
status=web.HTTPInternalServerError.status_code,
)
try:
from yt_dlp.utils import match_str
status = match_str(cond, data)
except Exception as e:
LOG.exception(e)
return web.json_response(
data={"error": str(e)},
status=web.HTTPBadRequest.status_code,
)
return web.json_response(
data={"status": status, "condition": cond, "data": data},
status=web.HTTPOk.status_code,
dumps=self.encoder.encode,
)
@route("GET", "api/presets") @route("GET", "api/presets")
async def presets(self, request: Request) -> Response: async def presets(self, request: Request) -> Response:
""" """
@ -1007,9 +1084,6 @@ class HttpAPI(Common):
if not item.get("id", None) or not validate_uuid(item.get("id"), version=4): if not item.get("id", None) or not validate_uuid(item.get("id"), version=4):
item["id"] = str(uuid.uuid4()) item["id"] = str(uuid.uuid4())
if not item.get("timer", None) or str(item.get("timer")).strip() == "":
item["timer"] = f"{random.randint(1,59)} */1 * * *" # noqa: S311
if not item.get("template", None): if not item.get("template", None):
item["template"] = "" item["template"] = ""

View file

@ -108,7 +108,7 @@ class Tasks(metaclass=Singleton):
self.load() self.load()
self._notify.subscribe( self._notify.subscribe(
Events.TASKS_ADD, Events.TASKS_ADD,
lambda data, _, **kwargs: self.add(**data.data), # noqa: ARG005 lambda data, _, **kwargs: self.save(**data.data), # noqa: ARG005
f"{__class__.__name__}.add", f"{__class__.__name__}.add",
) )
@ -145,6 +145,7 @@ class Tasks(metaclass=Singleton):
for i, task in enumerate(tasks): for i, task in enumerate(tasks):
try: try:
task, task_status = clean_item(task, keys=("cookies", "config")) task, task_status = clean_item(task, keys=("cookies", "config"))
self.validate(task)
task = Task(**task) task = Task(**task)
if task_status: if task_status:
need_save = True need_save = True
@ -152,9 +153,13 @@ class Tasks(metaclass=Singleton):
LOG.error(f"Failed to parse task at list position '{i}'. '{e!s}'.") LOG.error(f"Failed to parse task at list position '{i}'. '{e!s}'.")
continue continue
self._tasks.append(task)
if not task.timer:
continue
try: try:
self._scheduler.add(timer=task.timer, func=self._runner, args=(task,), id=task.id) self._scheduler.add(timer=task.timer, func=self._runner, args=(task,), id=task.id)
self._tasks.append(task)
try: try:
from cronsim import CronSim from cronsim import CronSim
@ -221,14 +226,19 @@ class Tasks(metaclass=Singleton):
msg = "No name found." msg = "No name found."
raise ValueError(msg) raise ValueError(msg)
if not task.get("timer"):
msg = "No timer found."
raise ValueError(msg)
if not task.get("url"): if not task.get("url"):
msg = "No URL found." msg = "No URL found."
raise ValueError(msg) raise ValueError(msg)
if task.get("timer"):
try:
from cronsim import CronSim
CronSim(task.get("timer"), datetime.now(UTC))
except Exception as e:
msg = f"Invalid timer format. '{e!s}'."
raise ValueError(msg) from e
if task.get("cli"): if task.get("cli"):
try: try:
from .Utils import arg_converter from .Utils import arg_converter

View file

@ -453,7 +453,7 @@ def arg_converter(
if isinstance(matchFilter, set): if isinstance(matchFilter, set):
diff["match_filter"] = {"filters": list(matchFilter)} diff["match_filter"] = {"filters": list(matchFilter)}
return json.loads(json.dumps(diff, cls=Encoder)) return json.loads(json.dumps(diff, cls=Encoder, default=str))
return diff return diff
@ -760,15 +760,40 @@ def get_files(base_path: str, dir: str | None = None):
else: else:
dir_path = base_path dir_path = base_path
if not os.path.isdir(dir_path): dir_path = pathlib.Path(dir_path)
if dir_path.is_symlink():
try:
dir_path = dir_path.resolve()
except OSError as e:
LOG.warning(f"Skipping broken symlink: {dir_path} - {e}")
return []
if not str(dir_path).startswith(base_path):
msg = f"Invalid path: '{dir_path}' - must be inside '{base_path}'."
LOG.warning(msg)
return []
if not dir_path.is_dir():
msg = f"Invalid path: '{dir_path}' - must be a directory." msg = f"Invalid path: '{dir_path}' - must be a directory."
raise OSError(msg) LOG.warning(msg)
return []
contents: list = [] contents: list = []
for file in pathlib.Path(dir_path).iterdir(): for file in dir_path.iterdir():
if file.name.startswith(".") or file.name.startswith("_"): if file.name.startswith(".") or file.name.startswith("_"):
continue continue
if file.is_symlink():
try:
test: pathlib.Path = file.resolve()
if not str(test).startswith(base_path):
msg = f"Invalid symlink: '{file}' - must resolve inside '{base_path}'."
LOG.warning(msg)
continue
except OSError:
LOG.warning(f"Skipping broken symlink: {file}")
continue
content_type = None content_type = None
for pattern in FILES_TYPE: for pattern in FILES_TYPE:

View file

@ -317,3 +317,26 @@ class Conditions(metaclass=Singleton):
continue continue
return None return None
def single_match(self, name: str, info: dict) -> Condition | None:
"""
Check if condition matches the info dict.
Args:
name (str): The condition name to check.
info (dict): The info dict to check.
Returns:
Condition|None: The condition if found, None otherwise.
"""
if len(self._items) < 1 or not info or not isinstance(info, dict) or len(info) < 1:
return None
item = self.get(name)
if not item or not item.filter:
return None
from yt_dlp.utils import match_str
return item if match_str(item.filter, info) else None

View file

@ -325,7 +325,10 @@ hr {
white-space: nowrap; white-space: nowrap;
} }
.is-max-width { .is-max-width {
max-width: calc(100% - 10px); max-width: calc(100% - 10px);
} }
.is-bold {
font-weight: bold;
}

View file

@ -57,7 +57,7 @@
</label> </label>
<div class="control"> <div class="control">
<input type="text" class="input" id="name" v-model="form.name" :disabled="addInProgress" <input type="text" class="input" id="name" v-model="form.name" :disabled="addInProgress"
placeholder="For the problematic channel or video name."> placeholder="For the problematic channel or video name.">
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
@ -70,15 +70,20 @@
<div class="field"> <div class="field">
<label class="label is-inline" for="filter"> <label class="label is-inline" for="filter">
<span class="icon"><i class="fa-solid fa-filter" /></span> <span class="icon"><i class="fa-solid fa-filter" /></span>
Filter Condition Filter
</label> </label>
<div class="control"> <div class="control">
<input type="text" class="input" id="filter" v-model="form.filter" :disabled="addInProgress" <input type="text" class="input" id="filter" v-model="form.filter" :disabled="addInProgress"
placeholder="availability = 'needs_auth' & channel_id = 'channel_id'"> placeholder="availability = 'needs_auth' & channel_id = 'channel_id'">
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>The yt-dlp <code>[--match-filters]</code> filter logic.</span> <span>
The yt-dlp <code>[--match-filters]</code> filter logic.
<NuxtLink @click="test_data.show = true" :disabled="addInProgress || !form.filter" class="is-bold">
Test filter logic
</NuxtLink>
</span>
</span> </span>
</div> </div>
</div> </div>
@ -108,29 +113,102 @@
<div class="card-footer mt-auto"> <div class="card-footer mt-auto">
<div class="card-footer-item"> <div class="card-footer-item">
<button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit" <div class="card-footer-item">
:class="{ 'is-loading': addInProgress }" form="addForm"> <button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit"
<span class="icon"><i class="fa-solid fa-save" /></span> :class="{ 'is-loading': addInProgress }" form="addForm">
<span>Save</span> <span class="icon"><i class="fa-solid fa-save" /></span>
</button> <span>Save</span>
</div> </button>
<div class="card-footer-item"> </div>
<button class="button is-fullwidth is-danger" @click="emitter('cancel')" :disabled="addInProgress" <div class="card-footer-item">
type="button"> <button class="button is-fullwidth is-danger" @click="emitter('cancel')" :disabled="addInProgress"
<span class="icon"><i class="fa-solid fa-times" /></span> type="button">
<span>Cancel</span> <span class="icon"><i class="fa-solid fa-times" /></span>
</button> <span>Cancel</span>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
</form> </form>
</div> </div>
<div v-if="test_data.show" class="column is-12">
<Modal @close="test_data.show = false" title="Test condition">
<form autocomplete="off" id="testCondition" @submit.prevent="run_test()">
<div class="card">
<div class="card-content">
<div class="field">
<label class="label is-inline" for="url">
<span class="icon"><i class="fa-solid fa-link" /></span>
URL
</label>
<div class="field-body">
<div class="field is-grouped">
<div class="control is-expanded">
<input type="url" class="input " id="url" v-model="test_data.url"
:disabled="test_data.in_progress" placeholder="https://..." required>
</div>
<div class="control">
<button class="button is-primary" type="submit" :disabled="test_data.in_progress"
:class="{ 'is-loading': test_data.in_progress }">
<span class="icon"><i class="fa-solid fa-play" /></span>
<span>Test</span>
</button>
</div>
</div>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The url to test the filter against.</span>
</span>
</div>
<div class="field">
<label class="label is-inline" for="filter">
<span class="icon"><i class="fa-solid fa-filter" /></span>
Condition Filter
</label>
<div class="control">
<input type="text" class="input" id="filter" v-model="form.filter" :disabled="test_data.in_progress"
placeholder="availability = 'needs_auth' & channel_id = 'channel_id'" required>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The yt-dlp <code>[--match-filters]</code> filter logic.</span><br>
</span>
</div>
<div class="field">
<span class="is-bold" :class="{
'has-text-success': true === test_data?.data?.status,
'has-text-danger': false === test_data?.data?.status,
}">
<span class="icon"><i class="fa-solid" :class="{
'fa-check': true === test_data.data.status,
'fa-xmark': false === test_data.data.status,
'fa-question': null === (test_data.data.status || null),
}" /></span>
Filter Status: {{ test_data?.data?.status === null ? 'Not tested' : test_data?.data?.status ?
'Matched' : 'Not matched' }}
</span>
</div>
<div class="field">
<pre style="height:60vh;"><code>{{ show_data() }}</code></pre>
</div>
</div>
</div>
</form>
</Modal>
</div>
</main> </main>
</template> </template>
<script setup> <script setup>
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
const emitter = defineEmits(['cancel', 'submit']); const emitter = defineEmits(['cancel', 'submit'])
const props = defineProps({ const props = defineProps({
reference: { reference: {
@ -149,30 +227,72 @@ const props = defineProps({
}, },
}) })
const toast = useToast() const test_data = ref({ show: false, url: '', data: { status: null, data: {} }, in_progress: false })
const toast = useNotification()
const box = useConfirm()
const form = reactive(JSON.parse(JSON.stringify(props.item))) const form = reactive(JSON.parse(JSON.stringify(props.item)))
const import_string = ref('') const import_string = ref('')
const showImport = useStorage('showImport', false); const showImport = useStorage('showImport', false)
const run_test = async () => {
if (!test_data.value.url) {
toast.error('The URL is required for testing.', { force: true })
return
}
try {
new URL(test_data.value.url)
} catch (e) {
toast.error('The URL is invalid.', { force: true })
return
}
test_data.value.in_progress = true
test_data.value.data.status = null
try {
const response = await request('/api/conditions/test', {
method: 'POST',
body: JSON.stringify({
url: test_data.value.url,
condition: form.filter,
}),
})
const json = await response.json()
if (!response.ok) {
toast.error(json.message || json.error || 'Unknown error', { force: true })
return
}
test_data.value.data = json
} catch (e) {
toast.error(`Failed to test condition. ${error.message}`)
} finally {
test_data.value.in_progress = false
}
}
const checkInfo = async () => { const checkInfo = async () => {
const required = ['name', 'filter', 'cli']; const required = ['name', 'filter', 'cli']
for (const key of required) { for (const key of required) {
if (!form[key]) { if (!form[key]) {
toast.error(`The ${key} field is required.`); toast.error(`The ${key} field is required.`)
return return
} }
} }
if (form?.cli && '' !== form.cli) { if (form?.cli && '' !== form.cli) {
const options = await convertOptions(form.cli); const options = await convertOptions(form.cli)
if (null === options) { if (null === options) {
return return
} }
form.cli = form.cli.trim() form.cli = form.cli.trim()
} }
let copy = JSON.parse(JSON.stringify(form)); let copy = JSON.parse(JSON.stringify(form))
for (const key in copy) { for (const key in copy) {
if (typeof copy[key] !== 'string') { if (typeof copy[key] !== 'string') {
@ -181,7 +301,7 @@ const checkInfo = async () => {
copy[key] = copy[key].trim() copy[key] = copy[key].trim()
} }
emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) }); emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) })
} }
const convertOptions = async args => { const convertOptions = async args => {
@ -191,7 +311,7 @@ const convertOptions = async args => {
} catch (e) { } catch (e) {
toast.error(e.message) toast.error(e.message)
} }
return null; return null
} }
const importItem = async () => { const importItem = async () => {
@ -219,7 +339,7 @@ const importItem = async () => {
return return
} }
if ((form.filter || form.cli) && false === confirm('This will overwrite the current data. Are you sure?')) { if ((form.filter || form.cli) && false === box.confirm('This will overwrite the current data. Are you sure?', true)) {
return return
} }
@ -242,4 +362,12 @@ const importItem = async () => {
toast.error(`Failed to parse import string. ${e.message}`) toast.error(`Failed to parse import string. ${e.message}`)
} }
} }
const show_data = () => {
if (!test_data.value.data?.data || !Object.keys(test_data.value.data?.data).length) {
return 'No data to show.'
}
return JSON.stringify(test_data.value.data.data, null, 2)
}
</script> </script>

View file

@ -43,7 +43,7 @@ const props = defineProps({
}); });
const cache = useSessionCache() const cache = useSessionCache()
const toast = useToast() const toast = useNotification()
const url = ref() const url = ref()
const error = ref(false) const error = ref(false)
const isPreloading = ref(false) const isPreloading = ref(false)

View file

@ -40,7 +40,7 @@ import { request } from '~/utils/index'
const emitter = defineEmits(['closeModel']) const emitter = defineEmits(['closeModel'])
const isLoading = ref(false) const isLoading = ref(false)
const data = ref({}) const data = ref({})
const toast = useToast() const toast = useNotification()
const props = defineProps({ const props = defineProps({
link: { link: {

View file

@ -385,7 +385,8 @@ const emitter = defineEmits(['getInfo', 'add_new'])
const config = useConfigStore() const config = useConfigStore()
const stateStore = useStateStore() const stateStore = useStateStore()
const socket = useSocketStore() const socket = useSocketStore()
const toast = useToast() const toast = useNotification()
const box = useConfirm()
const selectedElms = ref([]) const selectedElms = ref([])
const masterSelectAll = ref(false) const masterSelectAll = ref(false)
@ -488,7 +489,7 @@ const deleteSelectedItems = () => {
msg += '\nThis will delete the files from the server if they exist.' msg += '\nThis will delete the files from the server if they exist.'
} }
if (false === confirm(msg)) { if (false === box.confirm(msg, config.app.remove_files)) {
return return
} }
@ -506,7 +507,7 @@ const deleteSelectedItems = () => {
const clearCompleted = () => { const clearCompleted = () => {
let msg = 'Are you sure you want to clear all completed downloads?' let msg = 'Are you sure you want to clear all completed downloads?'
if (false === confirm(msg)) { if (false === box.confirm(msg)) {
return return
} }
@ -518,7 +519,7 @@ const clearCompleted = () => {
} }
const clearIncomplete = () => { const clearIncomplete = () => {
if (false === confirm('Are you sure you want to clear all in-complete downloads?')) { if (false === box.confirm('Are you sure you want to clear all in-complete downloads?')) {
return return
} }
@ -598,7 +599,7 @@ const setStatus = item => {
} }
const requeueIncomplete = () => { const requeueIncomplete = () => {
if (false === confirm('Are you sure you want to re-queue all incomplete downloads?')) { if (false === box.confirm('Are you sure you want to re-queue all incomplete downloads?')) {
return false return false
} }
@ -612,7 +613,7 @@ const requeueIncomplete = () => {
} }
const archiveItem = item => { const archiveItem = item => {
if (!confirm(`Archive '${item.title ?? item.id ?? item.url ?? '??'}'?`)) { if (!box.confirm(`Archive '${item.title ?? item.id ?? item.url ?? '??'}'?`)) {
return return
} }
socket.emit('archive_item', item) socket.emit('archive_item', item)
@ -621,7 +622,7 @@ const archiveItem = item => {
const removeItem = item => { const removeItem = item => {
const msg = `Remove '${item.title ?? item.id ?? item.url ?? '??'}'?\n this will delete the file from the server.` const msg = `Remove '${item.title ?? item.id ?? item.url ?? '??'}'?\n this will delete the file from the server.`
if (config.app.remove_files && !confirm(msg)) { if (false === box.confirm(msg, config.app.remove_files)) {
return false return false
} }

View file

@ -23,7 +23,7 @@ import { request } from '~/utils/index'
const emitter = defineEmits(['closeModel']) const emitter = defineEmits(['closeModel'])
const isLoading = ref(false) const isLoading = ref(false)
const data = ref({}) const data = ref({})
const toast = useToast() const toast = useNotification()
const image = ref('') const image = ref('')
const props = defineProps({ const props = defineProps({

35
ui/components/Modal.vue Normal file
View file

@ -0,0 +1,35 @@
<template>
<div>
<div class="modal is-active">
<div class="model-title" v-if="title" />
<div class="modal-background" @click="closeModal"></div>
<div class="modal-content" style="width:70vw;">
<slot />
</div>
<button class="modal-close is-large" aria-label="close" @click="closeModal"></button>
</div>
</div>
</template>
<script setup>
const emitter = defineEmits(['close'])
const props = defineProps({
title: {
type: String,
default: '',
required: false,
},
})
const closeModal = () => emitter('close')
const eventFunc = e => {
if (e.key === 'Escape') {
emitter('close')
}
}
onMounted(() => window.addEventListener('keydown', eventFunc))
onUnmounted(() => window.removeEventListener('keydown', eventFunc))
</script>

View file

@ -189,7 +189,8 @@ const props = defineProps({
const emitter = defineEmits(['getInfo', 'clear_form']) const emitter = defineEmits(['getInfo', 'clear_form'])
const config = useConfigStore() const config = useConfigStore()
const socket = useSocketStore() const socket = useSocketStore()
const toast = useToast() const toast = useNotification()
const box = useConfirm()
const showAdvanced = useStorage('show_advanced', false) const showAdvanced = useStorage('show_advanced', false)
const addInProgress = ref(false) const addInProgress = ref(false)
@ -261,7 +262,7 @@ const addDownload = async () => {
} }
const resetConfig = () => { const resetConfig = () => {
if (true !== confirm('Reset your local configuration?')) { if (true !== box.confirm('Reset your local configuration?')) {
return return
} }

View file

@ -268,7 +268,7 @@
<script setup> <script setup>
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
const emitter = defineEmits(['cancel', 'submit']); const emitter = defineEmits(['cancel', 'submit']);
const toast = useToast(); const toast = useNotification();
const props = defineProps({ const props = defineProps({
reference: { reference: {
type: String, type: String,
@ -295,6 +295,7 @@ const requestMethods = ['POST', 'PUT'];
const requestType = ['json', 'form']; const requestType = ['json', 'form'];
const showImport = useStorage('showImport', false); const showImport = useStorage('showImport', false);
const import_string = ref(''); const import_string = ref('');
const box = useConfirm()
const checkInfo = async () => { const checkInfo = async () => {
const required = ['name', 'request.url', 'request.method', 'request.type', 'request.data_key']; const required = ['name', 'request.url', 'request.method', 'request.type', 'request.data_key'];
@ -331,7 +332,6 @@ const checkInfo = async () => {
emitter('submit', { reference: toRaw(props.reference), item: toRaw(form) }); emitter('submit', { reference: toRaw(props.reference), item: toRaw(form) });
} }
const importItem = async () => { const importItem = async () => {
let val = import_string.value.trim() let val = import_string.value.trim()
if (!val) { if (!val) {
@ -359,7 +359,7 @@ const importItem = async () => {
} }
if (form.target) { if (form.target) {
if (false === confirm('This will overwrite the current form fields. Are you sure?')) { if (false === box.confirm('This will overwrite the current form fields. Are you sure?', true)) {
return return
} }
} }

View file

@ -218,10 +218,11 @@ const props = defineProps({
}) })
const config = useConfigStore() const config = useConfigStore()
const toast = useToast() const toast = useNotification()
const form = reactive(JSON.parse(JSON.stringify(props.preset))) const form = reactive(JSON.parse(JSON.stringify(props.preset)))
const import_string = ref('') const import_string = ref('')
const showImport = useStorage('showImport', false); const showImport = useStorage('showImport', false)
const box = useConfirm()
onMounted(() => { onMounted(() => {
if (props.preset?.cli && '' !== props.preset?.cli) { if (props.preset?.cli && '' !== props.preset?.cli) {
@ -328,7 +329,7 @@ const importItem = async () => {
return return
} }
if (form.cli && false === confirm('This will overwrite the current data. Are you sure?')) { if (form.cli && false === box.confirm('This will overwrite the current data. Are you sure?', true)) {
return return
} }

View file

@ -235,12 +235,13 @@ import { ucFirst } from '~/utils/index'
import { isEmbedable, getEmbedable } from '~/utils/embedable' import { isEmbedable, getEmbedable } from '~/utils/embedable'
const emitter = defineEmits(['getInfo']) const emitter = defineEmits(['getInfo'])
const config = useConfigStore(); const config = useConfigStore()
const stateStore = useStateStore(); const stateStore = useStateStore()
const socket = useSocketStore(); const socket = useSocketStore()
const box = useConfirm()
const selectedElms = ref([]); const selectedElms = ref([])
const masterSelectAll = ref(false); const masterSelectAll = ref(false)
const showQueue = useStorage('showQueue', true) const showQueue = useStorage('showQueue', true)
const hideThumbnail = useStorage('hideThumbnailQueue', false) const hideThumbnail = useStorage('hideThumbnailQueue', false)
const display_style = useStorage('display_style', 'cards') const display_style = useStorage('display_style', 'cards')
@ -252,11 +253,11 @@ const bg_opacity = useStorage('random_bg_opacity', 0.85)
watch(masterSelectAll, (value) => { watch(masterSelectAll, (value) => {
for (const key in stateStore.queue) { for (const key in stateStore.queue) {
const element = stateStore.queue[key]; const element = stateStore.queue[key]
if (value) { if (value) {
selectedElms.value.push(element._id); selectedElms.value.push(element._id)
} else { } else {
selectedElms.value = []; selectedElms.value = []
} }
} }
}) })
@ -266,35 +267,35 @@ const hasQueuedItems = computed(() => stateStore.count('queue') > 0)
const setIcon = item => { const setIcon = item => {
if ('downloading' === item.status && item.is_live) { if ('downloading' === item.status && item.is_live) {
return 'fa-globe fa-spin'; return 'fa-globe fa-spin'
} }
if ('downloading' === item.status) { if ('downloading' === item.status) {
return 'fa-download'; return 'fa-download'
} }
if ('postprocessing' === item.status) { if ('postprocessing' === item.status) {
return 'fa-cog fa-spin'; return 'fa-cog fa-spin'
} }
if (null === item.status && true === config.paused) { if (null === item.status && true === config.paused) {
return 'fa-pause-circle'; return 'fa-pause-circle'
} }
if (!item.status) { if (!item.status) {
return 'fa-question'; return 'fa-question'
} }
return 'fa-spinner fa-spin'; return 'fa-spinner fa-spin'
} }
const setStatus = item => { const setStatus = item => {
if (null === item.status && true === config.paused) { if (null === item.status && true === config.paused) {
return 'Paused'; return 'Paused'
} }
if ('downloading' === item.status && item.is_live) { if ('downloading' === item.status && item.is_live) {
return 'Live Streaming'; return 'Live Streaming'
} }
if ('preparing' === item.status) { if ('preparing' === item.status) {
@ -302,7 +303,7 @@ const setStatus = item => {
} }
if (!item.status) { if (!item.status) {
return 'Unknown..'; return 'Unknown..'
} }
return ucFirst(item.status) return ucFirst(item.status)
@ -324,84 +325,82 @@ const setIconColor = item => {
return '' return ''
} }
const ETAPipe = value => { const ETAPipe = value => {
if (value === null || 0 === value) { if (value === null || 0 === value) {
return 'Live'; return 'Live'
} }
if (value < 60) { if (value < 60) {
return `${Math.round(value)}s`; return `${Math.round(value)}s`
} }
if (value < 3600) { if (value < 3600) {
return `${Math.floor(value / 60)}m ${Math.round(value % 60)}s`; return `${Math.floor(value / 60)}m ${Math.round(value % 60)}s`
} }
const hours = Math.floor(value / 3600) const hours = Math.floor(value / 3600)
const minutes = value % 3600 const minutes = value % 3600
return `${hours}h ${Math.floor(minutes / 60)}m ${Math.round(minutes % 60)}s`; return `${hours}h ${Math.floor(minutes / 60)}m ${Math.round(minutes % 60)}s`
} }
const speedPipe = value => { const speedPipe = value => {
if (null === value || 0 === value) { if (null === value || 0 === value) {
return '0KB/s'; return '0KB/s'
} }
const k = 1024; const k = 1024
const dm = 2; const dm = 2
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s']; const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s']
const i = Math.floor(Math.log(value) / Math.log(k)); const i = Math.floor(Math.log(value) / Math.log(k))
return parseFloat((value / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; return parseFloat((value / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
} }
const percentPipe = value => { const percentPipe = value => {
if (value === null || 0 === value) { if (value === null || 0 === value) {
return '00.00'; return '00.00'
} }
return parseFloat(value).toFixed(2); return parseFloat(value).toFixed(2)
} }
const updateProgress = (item) => { const updateProgress = (item) => {
let string = ''; let string = ''
if (null === item.status && true === config.paused) { if (null === item.status && true === config.paused) {
return 'Paused'; return 'Paused'
} }
if ('postprocessing' === item.status) { if ('postprocessing' === item.status) {
return 'Post-processors are running.'; return 'Post-processors are running.'
} }
if ('preparing' === item.status) { if ('preparing' === item.status) {
return ag(item, 'extras.external_downloader') ? 'External downloader.' : 'Preparing'; return ag(item, 'extras.external_downloader') ? 'External downloader.' : 'Preparing'
} }
if (null != item.status) { if (null != item.status) {
string += item.percent && !item.is_live ? percentPipe(item.percent) + '%' : 'Live'; string += item.percent && !item.is_live ? percentPipe(item.percent) + '%' : 'Live'
} }
string += item.speed ? ' - ' + speedPipe(item.speed) : ' - Waiting..'; string += item.speed ? ' - ' + speedPipe(item.speed) : ' - Waiting..'
if (null != item.status && item.eta) { if (null != item.status && item.eta) {
string += ' - ' + ETAPipe(item.eta); string += ' - ' + ETAPipe(item.eta)
} }
return string; return string;
} }
const confirmCancel = item => { const confirmCancel = item => {
if (true !== confirm(`Are you sure you want to cancel (${item.title})?`)) { if (true !== box.confirm(`Are you sure you want to cancel (${item.title})?`)) {
return false; return false
} }
cancelItems(item._id); cancelItems(item._id)
return true; return true
} }
const cancelSelected = () => { const cancelSelected = () => {
if (true !== confirm('Are you sure you want to cancel selected items?')) { if (true !== box.confirm('Are you sure you want to cancel selected items?')) {
return false; return false;
} }
cancelItems(selectedElms.value); cancelItems(selectedElms.value)
selectedElms.value = []; selectedElms.value = []
return true; return true;
} }
@ -410,20 +409,21 @@ const cancelItems = item => {
if (typeof item === 'object') { if (typeof item === 'object') {
for (const key in item) { for (const key in item) {
items.push(item[key]); items.push(item[key])
} }
} else { } else {
items.push(item); items.push(item)
} }
if (items.length < 0) { if (items.length < 0) {
return; return
} }
items.forEach(id => socket.emit('item_cancel', id)); items.forEach(id => socket.emit('item_cancel', id))
} }
const pImg = e => e.target.naturalHeight > e.target.naturalWidth ? e.target.classList.add('image-portrait') : null const pImg = e => e.target.naturalHeight > e.target.naturalWidth ? e.target.classList.add('image-portrait') : null
watch(embed_url, v => { watch(embed_url, v => {
if (!bg_enable.value) { if (!bg_enable.value) {
return return

View file

@ -9,71 +9,121 @@
</span> </span>
</header> </header>
<div class="card-content"> <div class="card-content">
<div class="field"> <div class="columns is-multiline">
<label class="label" for="random_bg">Color scheme</label> <div class="column is-6">
<div class="control"> <div class="field">
<label for="auto" class="radio"> <label class="label">Color scheme</label>
<input id="auto" type="radio" v-model="selectedTheme" value="auto"> <div class="control">
System Default <label for="auto" class="radio">
</label> <input id="auto" type="radio" v-model="selectedTheme" value="auto">
<label for="light" class="radio"> System Default
<input id="light" type="radio" v-model="selectedTheme" value="light"> </label>
Light <label for="light" class="radio">
</label> <input id="light" type="radio" v-model="selectedTheme" value="light">
<label for="dark" class="radio"> Light
<input id="dark" type="radio" v-model="selectedTheme" value="dark"> </label>
Dark <label for="dark" class="radio">
</label> <input id="dark" type="radio" v-model="selectedTheme" value="dark">
</div> Dark
<p class="help"> </label>
<span class="icon"><i class="fa-solid fa-info" /></span> </div>
<span>Select the color scheme for the WebUI.</span> <p class="help">
</p> <span class="icon"><i class="fa-solid fa-info" /></span>
</div> <span>Select the color scheme for the WebUI.</span>
</p>
</div>
<div class="field"> <div class="field">
<label class="label" for="random_bg">Backgrounds</label> <label class="label" for="random_bg">Backgrounds</label>
<div class="control"> <div class="control">
<input id="random_bg" type="checkbox" class="switch is-success" v-model="bg_enable"> <input id="random_bg" type="checkbox" class="switch is-success" v-model="bg_enable">
<label for="random_bg" class="is-unselectable">&nbsp;Enable</label> <label for="random_bg" class="is-unselectable">&nbsp;Enable</label>
</div> </div>
<p class="help"> <p class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>Use random background image.</span> <span>
</p> Use random background image.
</div> <NuxtLink @click="$emit('reload_bg')" class="is-bold" v-if="bg_enable">
Reload
</NuxtLink>
<span class="icon" v-if="isLoading"><i class="fa fa-spin fa-spinner" /></span>
</span>
</p>
</div>
<div class="field"> <div class="field">
<label class="label" for="random_bg_opacity"> <label class="label" for="random_bg_opacity">
Background Visibility: (<code>{{ bg_opacity }}</code>) Background Visibility: (<code>{{ bg_opacity }}</code>)
</label> </label>
<div class="control"> <div class="control">
<input id="random_bg_opacity" style="width: 100%" type="range" v-model="bg_opacity" min="0.50" max="1.00" <input id="random_bg_opacity" style="width: 100%" type="range" v-model="bg_opacity" min="0.50"
step="0.05"> max="1.00" step="0.05">
</div>
<p class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>How visible the background image should be.</span>
</p>
</div>
</div> </div>
<p class="help"> <div class="column is-6">
<span class="icon"><i class="fa-solid fa-info" /></span> <div class="field">
<span>How visible the background image should be.</span> <label class="label" for="reduce_confirm">Reduce confirm box usage</label>
</p> <div class="control">
</div> <input id="reduce_confirm" type="checkbox" class="switch is-success" v-model="reduce_confirm">
<label for="reduce_confirm" class="is-unselectable">
&nbsp;{{ reduce_confirm ? 'Enabled' : 'Disabled' }}
</label>
</div>
<p class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Reduce the usage of confirm boxes in the WebUI.</span>
</p>
</div>
<div class="field">
<label class="label" for="allow_toasts">Show toasts</label>
<div class="control">
<input id="allow_toasts" type="checkbox" class="switch is-success" v-model="allow_toasts">
<label for="allow_toasts" class="is-unselectable">
&nbsp;{{ allow_toasts ? 'Enabled' : 'Disabled' }}
</label>
</div>
<p class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span class="has-text-danger is-bold">
Show notification toasts. If disabled, you will not see errors reported or anything else.
</span>
</p>
</div>
<div class="field">
<label class="label">Toasts position</label>
<div class="control">
<div class="select is-fullwidth">
<select v-model="toast_position">
<option :value="POSITION.TOP_RIGHT">{{ POSITION.TOP_RIGHT }}</option>
<option :value="POSITION.TOP_CENTER">{{ POSITION.TOP_CENTER }}</option>
<option :value="POSITION.TOP_LEFT">{{ POSITION.TOP_LEFT }}</option>
<option :value="POSITION.BOTTOM_RIGHT">{{ POSITION.BOTTOM_RIGHT }}</option>
<option :value="POSITION.BOTTOM_CENTER">{{ POSITION.BOTTOM_CENTER }}</option>
<option :value="POSITION.BOTTOM_LEFT">{{ POSITION.BOTTOM_LEFT }}</option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="label" for="dismiss_on_click">Dismiss toasts on click</label>
<div class="control">
<input id="dismiss_on_click" type="checkbox" class="switch is-success"
v-model="toast_dismiss_on_click">
<label for="dismiss_on_click" class="is-unselectable">
&nbsp;{{ toast_dismiss_on_click ? 'Enabled' : 'Disabled' }}
</label>
</div>
</div>
<div class="field" v-if="bg_enable">
<label class="label" for="random_bg_opacity">
Reload the currently displayed background image.
</label>
<div class="control">
<button class="button is-info" @click="$emit('reload_bg')" :class="{ 'is-loading': isLoading }"
:disabled="isLoading">
<span class="icon-text">
<span class="icon"><i class="fas fa-sync-alt" /></span>
<span>Reload</span>
</span>
</button>
</div> </div>
<p class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Change the displayed picture.</span>
</p>
</div> </div>
</div> </div>
</div> </div>
@ -81,10 +131,9 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { POSITION } from 'vue-toastification'
defineProps({ defineProps({
isLoading: { isLoading: {
@ -96,5 +145,8 @@ defineProps({
const bg_enable = useStorage('random_bg', true) const bg_enable = useStorage('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.85) const bg_opacity = useStorage('random_bg_opacity', 0.85)
const selectedTheme = useStorage('theme', 'auto') const selectedTheme = useStorage('theme', 'auto')
const allow_toasts = useStorage('allow_toasts', true)
const reduce_confirm = useStorage('reduce_confirm', false)
const toast_position = useStorage('toast_position', POSITION.TOP_RIGHT)
const toast_dismiss_on_click = useStorage('toast_dismiss_on_click', true)
</script> </script>

View file

@ -111,8 +111,9 @@
<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>Select the preset to use for this URL. <span class="text-has-danger">If the <span>Select the preset to use for this URL. <span class="text-has-danger">If the
<code>-f, --format</code> argument is present in the command line options, the preset and all <code>-f, --format</code> <span class="has-text-danger">
it's options will be ignored.</span> argument is present in the command line options, the preset and all
it's options will be ignored.</span></span>
</span> </span>
</span> </span>
</div> </div>
@ -125,16 +126,15 @@
CRON expression timer. CRON expression timer.
</label> </label>
<div class="control"> <div class="control">
<input type="text" class="input" id="timer" placeholder="leave empty to run once every hour." <input type="text" class="input" id="timer" v-model="form.timer" :disabled="addInProgress"
v-model="form.timer" :disabled="addInProgress"> placeholder="0 12 * * 5">
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span> <span>
The CRON timer expression to use for this task. If not set, the task will run once an hour in a The CRON timer expression to use for this task. If not set, the task will be disabled. For more
random information on CRON expressions, see <NuxtLink to="https://crontab.guru/" target="_blank">
minute. For more information on CRON expressions, see <NuxtLink to="https://crontab.guru/" crontab.guru</NuxtLink>.
target="_blank">crontab.guru</NuxtLink>.
</span> </span>
</span> </span>
</div> </div>
@ -152,8 +152,8 @@
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>Paths are relative to global download path, defaults to preset download path if set otherwise, <span>Current download folder: <code>{{ get_download_folder() }}</code>. All folders are
fallback root path if not set.</span> sub-folders of <code>{{ config.app.download_path }}</code>.</span>
</span> </span>
</div> </div>
</div> </div>
@ -170,11 +170,7 @@
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>Use this output template if non are given with URL. if not set, it will defaults to <span>Current output format: <code>{{ get_output_template() }}</code>.</span>
<code>{{ config.app.output_template }}</code>.
For more information <NuxtLink href="https://github.com/yt-dlp/yt-dlp#output-template"
target="_blank">visit this url</NuxtLink>.
</span>
</span> </span>
</div> </div>
</div> </div>
@ -232,11 +228,12 @@
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { CronExpressionParser } from 'cron-parser' import { CronExpressionParser } from 'cron-parser'
const emitter = defineEmits(['cancel', 'submit']); const emitter = defineEmits(['cancel', 'submit'])
const toast = useToast(); const toast = useNotification()
const config = useConfigStore(); const config = useConfigStore()
const showImport = useStorage('showImport', false); const showImport = useStorage('showImport', false)
const import_string = ref(''); const import_string = ref('')
const box = useConfirm()
const props = defineProps({ const props = defineProps({
reference: { reference: {
@ -255,49 +252,49 @@ const props = defineProps({
}, },
}) })
const form = reactive(props.task); const form = reactive(props.task)
onMounted(() => { onMounted(() => {
if (!props.task?.preset || '' === props.task.preset) { if (!props.task?.preset || '' === props.task.preset) {
form.preset = toRaw(config.app.default_preset); form.preset = toRaw(config.app.default_preset)
} }
}) })
const checkInfo = async () => { const checkInfo = async () => {
const required = ['name', 'url']; const required = ['name', 'url']
for (const key of required) { for (const key of required) {
if (!form[key]) { if (!form[key]) {
toast.error(`The ${key} field is required.`); toast.error(`The ${key} field is required.`)
return; return
} }
} }
if (form.timer) { if (form.timer) {
try { try {
CronExpressionParser.parse(form.timer); CronExpressionParser.parse(form.timer)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.error(`Invalid CRON expression. ${e.message}`); toast.error(`Invalid CRON expression. ${e.message}`)
return; return
} }
} }
try { try {
new URL(form.url); new URL(form.url)
} catch (e) { } catch (e) {
toast.error('Invalid URL'); toast.error('Invalid URL')
return; return
} }
if (form?.cli && '' !== form.cli) { if (form?.cli && '' !== form.cli) {
const options = await convertOptions(form.cli); const options = await convertOptions(form.cli)
if (null === options) { if (null === options) {
return return
} }
form.cli = form.cli.trim(" ") form.cli = form.cli.trim(" ")
} }
emitter('submit', { reference: toRaw(props.reference), task: toRaw(form) }); emitter('submit', { reference: toRaw(props.reference), task: toRaw(form) })
} }
const importItem = async () => { const importItem = async () => {
@ -327,7 +324,7 @@ const importItem = async () => {
} }
if (form.url || form.timer) { if (form.url || form.timer) {
if (false === confirm('This will overwrite the current form fields. Are you sure?')) { if (false === box.confirm('This will overwrite the current form fields. Are you sure?', true)) {
return return
} }
} }
@ -391,7 +388,7 @@ const convertOptions = async args => {
toast.error(e.message) toast.error(e.message)
} }
return null; return null
} }
const hasFormatInConfig = computed(() => { const hasFormatInConfig = computed(() => {
@ -404,4 +401,23 @@ const hasFormatInConfig = computed(() => {
const filter_presets = (flag = true) => config.presets.filter(item => item.default === flag) const filter_presets = (flag = true) => config.presets.filter(item => item.default === flag)
const get_download_folder = () => {
if (form.preset && !hasFormatInConfig.value) {
const preset = config.presets.find(p => p.name === form.preset)
if (preset && preset.folder) {
return preset.folder.replace(config.app.download_path, '')
}
}
return '/'
}
const get_output_template = () => {
if (form.preset && !hasFormatInConfig.value) {
const preset = config.presets.find(p => p.name === form.preset)
if (preset && preset.template) {
return preset.template
}
}
return config.app.output_template || '%(title)s.%(ext)s'
}
</script> </script>

View file

@ -31,7 +31,7 @@ import { onMounted, onUpdated, ref, onUnmounted } from 'vue'
import Hls from 'hls.js' import Hls from 'hls.js'
import { makeDownload } from '~/utils/index' import { makeDownload } from '~/utils/index'
const config = useConfigStore() const config = useConfigStore()
const toast = useToast() const toast = useNotification()
const props = defineProps({ const props = defineProps({
item: { item: {

View file

@ -0,0 +1,23 @@
import { useStorage } from '@vueuse/core'
const reduceConfirm = useStorage<boolean>('reduce_confirm', false)
function confirm(msg: string, force: boolean = false): boolean {
if (false === force && true === reduceConfirm.value) {
return true
}
return window.confirm(msg)
}
function alert(msg: string): boolean {
return window.confirm(msg)
}
function prompt(msg: string, defaultValue: string = ''): string | null {
return window.prompt(msg, defaultValue)
}
export default function useConfirm() {
return { confirm, alert, prompt, reduceConfirm }
}

View file

@ -0,0 +1,60 @@
import { useStorage } from '@vueuse/core'
import { POSITION, useToast } from "vue-toastification"
type notificationType = 'info' | 'success' | 'warning' | 'error'
type notificationOptions = {
timeout?: number,
force?: boolean,
closeOnClick?: boolean,
position?: POSITION
}
const allowToast = useStorage<boolean>('allow_toasts', true)
const toastPosition = useStorage<POSITION>('toast_position', POSITION.TOP_RIGHT)
const toastDismissOnClick = useStorage<boolean>('toast_dismiss_on_click', true)
const toast = useToast()
function notify(type: notificationType, message: string, opts?: notificationOptions): void {
let force = opts?.force || false;
if (false === allowToast.value && false === force) {
return;
}
if (!opts) {
opts = {}
}
if (opts?.force) {
delete opts.force
}
opts.closeOnClick = toastDismissOnClick.value
opts.position = toastPosition.value ?? POSITION.TOP_RIGHT
switch (type) {
case 'info':
toast.info(message, opts)
break;
case 'success':
toast.success(message, opts)
break;
case 'warning':
toast.warning(message, opts)
break;
case 'error':
toast.error(message, opts)
break;
default:
toast.error(`Unknown notification type: ${type}. ${message}`, opts)
break;
}
}
export default function useNotification() {
return {
info: (message: string, opts?: notificationOptions) => notify('info', message, opts),
success: (message: string, opts?: notificationOptions) => notify('success', message, opts),
warning: (message: string, opts?: notificationOptions) => notify('warning', message, opts),
error: (message: string, opts?: notificationOptions) => notify('error', message, opts),
}
}

View file

@ -1,2 +0,0 @@
import { useToast } from "vue-toastification";
export default () => useToast()

View file

@ -87,7 +87,7 @@
</div> </div>
<div class="navbar-item is-hidden-mobile"> <div class="navbar-item is-hidden-mobile">
<button class="button is-dark has-tooltip-bottom" v-tooltip.bottom="'WebUI Settings'" <button class="button is-dark has-tooltip-bottom mr-4" v-tooltip.bottom="'WebUI Settings'"
@click="show_settings = !show_settings"> @click="show_settings = !show_settings">
<span class="icon"><i class="fas fa-cog" /></span> <span class="icon"><i class="fas fa-cog" /></span>
</button> </button>

View file

@ -155,7 +155,7 @@ import moment from 'moment'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
const route = useRoute() const route = useRoute()
const toast = useToast() const toast = useNotification()
const config = useConfigStore() const config = useConfigStore()
const socket = useSocketStore() const socket = useSocketStore()

View file

@ -1,59 +1,88 @@
<template> <template>
<div> <main>
<div class="mt-1 columns is-multiline"> <div class="mt-1 columns is-multiline">
<div class="column is-12 is-clearfix is-unselectable"> <div class="column is-12 is-clearfix is-unselectable">
<span class="title is-4"> <span class="title is-4">
<span class="icon"><i class="fas fa-cogs" /></span> <span class="icon"><i class="fas fa-cogs" /></span>
CHANGELOG CHANGELOG
</span> </span>
<div class="is-hidden-mobile"> <div class="is-hidden-mobile">
<span class="subtitle">This page display the latest changes and updates from the project.</span> <span class="subtitle">This page display the latest changes and updates from the project.</span>
</div> </div>
</div> </div>
</div> </div>
<div class="column is-12" v-for="(log, index) in logs" :key="log.tag">
<div class="content"> <div class="columns is-multiline" v-if="isLoading">
<h1 class="is-4"> <div class="column is-12">
<span class="icon-text"> <Message message_class="has-background-info-90 has-text-dark" title="Loading" icon="fas fa-spinner fa-spin"
<span class="icon"><i class="fas fa-code-branch"></i></span> message="Loading data. Please wait..." />
<span>
{{ log.tag }} <span class="tag has-text-success" v-if="isInstalled(log.tag)">Installed</span>
</span>
</span>
</h1>
<hr>
<ul>
<li v-for="commit in log.commits" :key="commit.sha">
<strong>{{ ucFirst(commit.message).replace(/\.$/, "") }}.</strong> -
<small>
<NuxtLink :to="`https://github.com/arabcoders/ytptube/commit/${commit.sha}`" target="_blank">
<span class="has-tooltip" v-tooltip="`SHA: ${commit.sha} - Date: ${commit.date}`">
{{ moment(commit.date).fromNow() }}
</span>
</NuxtLink>
</small>
</li>
</ul>
<hr v-if="index < logs.length - 1">
</div> </div>
</div> </div>
</div>
<template v-if="!isLoading">
<div class="logs-container">
<div class="columns is-multiline">
<div class="column is-12" v-for="(log, index) in logs" :key="log.tag">
<div class="content">
<h1 class="is-4">
<span class="icon"><i class="fas fa-code-branch" /></span>
{{ formatTag(log.tag) }} <span class="tag has-text-success" v-if="isInstalled(log.tag)">Installed</span>
</h1>
<hr>
<ul>
<li v-for="commit in log.commits" :key="commit.sha">
<strong>{{ ucFirst(commit.message).replace(/\.$/, "") }}.</strong> -
<small>
<NuxtLink :to="`https://github.com/arabcoders/ytptube/commit/${commit.sha}`" target="_blank">
<span class="has-tooltip" v-tooltip="`SHA: ${commit.sha} - Date: ${commit.date}`">
{{ moment(commit.date).fromNow() }}
</span>
</NuxtLink>
</small>
</li>
</ul>
<hr v-if="index < logs.length - 1">
</div>
</div>
</div>
</div>
</template>
</main>
</template> </template>
<script setup> <script setup>
import moment from 'moment' import moment from 'moment'
useHead({ title: 'CHANGELOG' }) useHead({ title: 'CHANGELOG' })
const REPO_URL = "https://arabcoders.github.io/ytptube/CHANGELOG-{branch}.json?version={version}"; const REPO_URL = "https://arabcoders.github.io/ytptube/CHANGELOG-{branch}.json?version={version}";
const logs = ref([]); const toast = useNotification();
const config = useConfigStore() const config = useConfigStore()
const logs = ref([]);
const api_version = ref('') const api_version = ref('')
const isLoading = ref(false);
const branch = computed(() => { const branch = computed(() => {
const branch = String(api_version.value).split('-')[0] ?? 'master'; const branch = String(api_version.value).split('-')[0] ?? 'master';
return ['master', 'dev'].includes(branch) ? branch : 'master'; return ['master', 'dev'].includes(branch) ? branch : 'master';
}); });
const formatTag = tag => {
const parts = tag.split('-');
if (parts.length < 3) {
return tag;
}
const branch = parts[0];
const date = parts[1];
const shortSha = parts[2].substring(0, 7);
return `${ucFirst(branch)}: ${moment(date, 'YYYYMMDD').format('YYYY-MM-DD')} - ${shortSha}`;
}
watch(() => config.app.version, async value => { watch(() => config.app.version, async value => {
if (!value) { if (!value) {
return return
@ -70,19 +99,37 @@ const loadContent = async () => {
return return
} }
isLoading.value = true;
try { try {
const changes = await fetch(r(REPO_URL, { branch: branch.value, version: api_version.value })); const changes = await fetch(r(REPO_URL, { branch: branch.value, version: api_version.value }));
logs.value = await changes.json(); logs.value = await changes.json();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
notification('error', 'Error', `Failed to fetch changelog. ${e.message}`); toast.error('error', 'Error', `Failed to fetch changelog. ${e.message}`);
} finally {
isLoading.value = false;
} }
} }
const isInstalled = tag => { const isInstalled = tag => {
const installed = String(api_version.value).split('-').pop(); const installed = String(api_version.value).split('-').pop();
return tag.endsWith(installed); const tagHash = tag.split('-').pop();
return tagHash.startsWith(installed);
} }
onMounted(() => loadContent()); onMounted(() => loadContent());
</script> </script>
<style scoped>
.logs-container {
padding: 1rem;
min-width: 100%;
max-height: 73vh;
overflow-y: auto;
overflow-x: auto;
}
hr {
border-bottom: 1px solid #b2d1ff;
}
</style>

View file

@ -66,7 +66,7 @@
</div> </div>
<div class="card-content" v-if="cond?.raw"> <div class="card-content" v-if="cond?.raw">
<div class="content"> <div class="content">
<pre><code>{{ filterItem(cond) }}</code></pre> <pre><code>{{ JSON.stringify(cleanObject(cond, remove_keys), null, 2) }}</code></pre>
</div> </div>
</div> </div>
<div class="card-footer mt-auto"> <div class="card-footer mt-auto">
@ -104,6 +104,10 @@
filter i am able to bypass it <code>availability = 'needs_auth' & channel_id = 'channel_id'</code>. filter i am able to bypass it <code>availability = 'needs_auth' & channel_id = 'channel_id'</code>.
and set proxy for that specific video, while leaving the rest of the videos to be downloaded normally. and set proxy for that specific video, while leaving the rest of the videos to be downloaded normally.
</li> </li>
<li>
The data which the filter is applied on is the same data that yt-dlp returns, simply, click on the
information button, and check the data to craft your filter.
</li>
</ul> </ul>
</Message> </Message>
</div> </div>
@ -113,9 +117,10 @@
<script setup> <script setup>
import { request } from '~/utils/index' import { request } from '~/utils/index'
const toast = useToast() const toast = useNotification()
const config = useConfigStore() const config = useConfigStore()
const socket = useSocketStore() const socket = useSocketStore()
const box = useConfirm()
const items = ref([]) const items = ref([])
const item = ref({}) const item = ref({})
@ -124,6 +129,7 @@ const toggleForm = ref(false)
const isLoading = ref(false) const isLoading = ref(false)
const initialLoad = ref(true) const initialLoad = ref(true)
const addInProgress = ref(false) const addInProgress = ref(false)
const remove_keys = ['in_progress', 'raw']
watch(() => config.app.basic_mode, async () => { watch(() => config.app.basic_mode, async () => {
if (!config.app.basic_mode) { if (!config.app.basic_mode) {
@ -213,7 +219,7 @@ const updateItems = async items => {
} }
const deleteItem = async (item) => { const deleteItem = async (item) => {
if (true !== confirm(`Delete '${item.name}'?`)) { if (true !== box.confirm(`Delete '${item.name}'?`, true)) {
return return
} }
@ -235,6 +241,7 @@ const deleteItem = async (item) => {
} }
const updateItem = async ({ reference, item }) => { const updateItem = async ({ reference, item }) => {
item = cleanObject(item, remove_keys)
if (reference) { if (reference) {
const index = items.value.findIndex(t => t?.id === reference) const index = items.value.findIndex(t => t?.id === reference)
if (index > -1) { if (index > -1) {
@ -253,11 +260,6 @@ const updateItem = async ({ reference, item }) => {
resetForm(true) resetForm(true)
} }
const filterItem = item => {
const { raw, ...rest } = item
return JSON.stringify(rest, null, 2)
}
const editItem = _item => { const editItem = _item => {
item.value = _item item.value = _item
itemRef.value = _item.id itemRef.value = _item.id

View file

@ -62,6 +62,8 @@ const emitter = defineEmits(['getInfo'])
const config = useConfigStore() const config = useConfigStore()
const stateStore = useStateStore() const stateStore = useStateStore()
const socket = useSocketStore() const socket = useSocketStore()
const box = useConfirm()
const get_info = ref('') const get_info = ref('')
const bg_enable = useStorage('random_bg', true) const bg_enable = useStorage('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.85) const bg_opacity = useStorage('random_bg_opacity', 0.85)
@ -91,7 +93,7 @@ watch(() => stateStore.queue, () => {
}, { deep: true }) }, { deep: true })
const pauseDownload = () => { const pauseDownload = () => {
if (false === confirm('Are you sure you want to pause all non-active downloads?')) { if (false === box.confirm('Are you sure you want to pause all non-active downloads?')) {
return false return false
} }

View file

@ -117,7 +117,7 @@ import { useStorage } from '@vueuse/core'
let scrollTimeout = null let scrollTimeout = null
const toast = useToast() const toast = useNotification()
const socket = useSocketStore() const socket = useSocketStore()
const config = useConfigStore() const config = useConfigStore()

View file

@ -140,9 +140,10 @@ div.is-centered {
<script setup> <script setup>
import { request } from '~/utils/index' import { request } from '~/utils/index'
const toast = useToast() const toast = useNotification()
const config = useConfigStore() const config = useConfigStore()
const socket = useSocketStore() const socket = useSocketStore()
const box = useConfirm()
const allowedEvents = ref([]) const allowedEvents = ref([])
const notifications = ref([]) const notifications = ref([])
@ -234,7 +235,7 @@ const updateData = async notifications => {
} }
const deleteItem = async item => { const deleteItem = async item => {
if (true !== confirm(`Are you sure you want to delete notification target (${item.name})?`)) { if (true !== box.confirm(`Are you sure you want to delete notification target (${item.name})?`)) {
return return
} }
@ -294,7 +295,7 @@ const editItem = item => {
const join_events = events => (!events || events.length < 1) ? 'ALL' : events.map(e => ucFirst(e)).join(', ') const join_events = events => (!events || events.length < 1) ? 'ALL' : events.map(e => ucFirst(e)).join(', ')
const sendTest = async () => { const sendTest = async () => {
if (true !== confirm('Are you sure you want to send a test notification?')) { if (true !== box.confirm('Are you sure you want to send a test notification?')) {
return return
} }

View file

@ -3,17 +3,13 @@ table.is-fixed {
table-layout: fixed; table-layout: fixed;
} }
div.table-container {
overflow: hidden;
}
div.is-centered { div.is-centered {
justify-content: center; justify-content: center;
} }
</style> </style>
<template> <template>
<div> <main>
<div class="mt-1 columns is-multiline"> <div class="mt-1 columns is-multiline">
<div class="column is-12 is-clearfix is-unselectable"> <div class="column is-12 is-clearfix is-unselectable">
<span class="title is-4"> <span class="title is-4">
@ -25,17 +21,23 @@ div.is-centered {
<div class="is-pulled-right"> <div class="is-pulled-right">
<div class="field is-grouped"> <div class="field is-grouped">
<p class="control"> <p class="control">
<button class="button is-primary" @click=" <button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm;"
resetForm(false); v-tooltip.bottom="'Toggle add form'">
toggleForm = !toggleForm; <span class="icon"><i class="fas fa-add" /></span>
"> </button>
<span class="icon"><i class="fas fa-add"></i></span> </p>
<p class="control">
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom"
@click="() => display_style = display_style === 'cards' ? 'list' : 'cards'">
<span class="icon"><i class="fa-solid"
:class="{ 'fa-table': display_style === 'cards', 'fa-table-list': display_style === 'list' }" /></span>
</button> </button>
</p> </p>
<p class="control"> <p class="control">
<button class="button is-info" @click="reloadContent" :class="{ 'is-loading': isLoading }" <button class="button is-info" @click="reloadContent" :class="{ 'is-loading': isLoading }"
:disabled="!socket.isConnected || isLoading" v-if="presets && presets.length > 0"> :disabled="!socket.isConnected || isLoading" v-if="presets && presets.length > 0">
<span class="icon"><i class="fas fa-refresh"></i></span> <span class="icon"><i class="fas fa-refresh" /></span>
</button> </button>
</p> </p>
</div> </div>
@ -45,20 +47,71 @@ div.is-centered {
that you want to apply to given download.</span> that you want to apply to given download.</span>
</div> </div>
</div> </div>
</div>
<div class="column is-12" v-if="toggleForm"> <div class="columns" v-if="toggleForm">
<div class="column is-12">
<PresetForm :addInProgress="addInProgress" :reference="presetRef" :preset="preset" @cancel="resetForm(true)" <PresetForm :addInProgress="addInProgress" :reference="presetRef" :preset="preset" @cancel="resetForm(true)"
@submit="updateItem" :presets="presets" /> @submit="updateItem" :presets="presets" />
</div> </div>
</div>
<div class="column is-12" v-if="!toggleForm"> <template v-if="!toggleForm">
<div class="columns is-multiline" v-if="presetsNoDefault && presetsNoDefault.length > 0"> <div class="columns is-multiline" v-if="presetsNoDefault && presetsNoDefault.length > 0">
<template v-if="'list' === display_style">
<div class="column is-12">
<div class="table-container">
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
style="min-width: 650px; table-layout: fixed;">
<thead>
<tr class="has-text-centered is-unselectable">
<th width="80%">Preset</th>
<th width="20%">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in presetsNoDefault" :key="item.id">
<td class="is-vcentered">
<div class="is-text-overflow">
{{ item.name }}
</div>
</td>
<td class="is-vcentered is-items-center">
<div class="field is-grouped is-grouped-centered">
<div class="control">
<button class="button is-primary is-small is-fullwidth" v-tooltip="'Export'"
@click="exportItem(item)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
</button>
</div>
<div class="control">
<button class="button is-warning is-small is-fullwidth" v-tooltip="'Edit'"
@click="editItem(item)">
<span class="icon"><i class="fa-solid fa-cog" /></span>
</button>
</div>
<div class="control">
<button class="button is-danger is-small is-fullwidth" v-tooltip="'Delete'"
@click="deleteItem(item)">
<span class="icon"><i class="fa-solid fa-trash" /></span>
</button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<template v-else>
<div class="column is-6" v-for="item in presetsNoDefault" :key="item.id"> <div class="column is-6" v-for="item in presetsNoDefault" :key="item.id">
<div class="card is-flex is-full-height is-flex-direction-column"> <div class="card is-flex is-full-height is-flex-direction-column">
<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">
<button class="has-text-primary" v-tooltip="'Export preset.'" @click="exportItem(item)"> <button class="has-text-primary" v-tooltip="'Export'" @click="exportItem(item)">
<span class="icon"><i class="fa-solid fa-file-export" /></span> <span class="icon"><i class="fa-solid fa-file-export" /></span>
</button> </button>
<button @click="item.raw = !item.raw"> <button @click="item.raw = !item.raw">
@ -123,47 +176,61 @@ div.is-centered {
</div> </div>
</div> </div>
</div> </div>
</div> </template>
<Message title="No presets" message="There are no custom defined presets."
class="is-background-warning-80 has-text-dark" icon="fas fa-exclamation-circle"
v-if="!presets || presets.length < 1" />
</div> </div>
</div>
<div class="column is-12" v-if="presets && presets.length > 0 && !toggleForm"> <div class="columns is-multiline" v-if="!presets || presets.length < 1">
<Message message_class="has-background-info-90 has-text-dark" title="Tips" icon="fas fa-info-circle"> <div class="column is-12">
<ul> <Message message_class="has-background-info-90 has-text-dark" title="Loading" icon="fas fa-spinner fa-spin"
<li> message="Loading data. Please wait..." v-if="isLoading" />
When you export preset, it doesn't include <code>Cookies</code> field for security reasons. <Message title="No presets" message="There are no custom defined presets."
</li> class="is-background-warning-80 has-text-dark" icon="fas fa-exclamation-circle" v-else />
<li> </div>
If you have created a global <code>config/ytdlp.cli</code> file, it will be appended to your exported preset </div>
<code><i class="fa-solid fa-terminal" /> Command arguments for yt-dlp</code> field for better compatibility
and completeness. <div class="columns is-multiline" v-if="presets && presets.length > 0">
</li> <div class="column is-12">
</ul> <Message message_class="has-background-info-90 has-text-dark" title="Tips" icon="fas fa-info-circle">
</Message> <ul>
</div> <li>
</div> When you export preset, it doesn't include <code>Cookies</code> field for security reasons.
</li>
<li>
If you have created a global <code>config/ytdlp.cli</code> file, it will be appended to your exported
preset
<code><i class="fa-solid fa-terminal" /> Command options for yt-dlp</code> field for better
compatibility
and completeness.
</li>
</ul>
</Message>
</div>
</div>
</template>
</main>
</template> </template>
<script setup> <script setup>
import { useStorage } from '@vueuse/core'
import { request } from '~/utils/index' import { request } from '~/utils/index'
const toast = useToast() const toast = useNotification()
const config = useConfigStore() const config = useConfigStore()
const socket = useSocketStore() const socket = useSocketStore()
const box = useConfirm()
const display_style = useStorage("preset_display_style", "cards")
const presets = ref([]) const presets = ref([])
const preset = ref({}) const preset = ref({})
const presetRef = ref("") const presetRef = ref("")
const toggleForm = ref(false) const toggleForm = ref(false)
const isLoading = ref(false) const isLoading = ref(true)
const initialLoad = ref(true) const initialLoad = ref(true)
const addInProgress = ref(false) const addInProgress = ref(false)
const remove_keys = ["raw", "toggle_description"]
const presetsNoDefault = computed(() => const presetsNoDefault = computed(() => presets.value.filter((t) => !t.default))
presets.value.filter((t) => !t.default),
)
watch( watch(
() => config.app.basic_mode, () => config.app.basic_mode,
@ -175,15 +242,12 @@ watch(
}, },
) )
watch( watch(() => socket.isConnected, async () => {
() => socket.isConnected, if (socket.isConnected && initialLoad.value) {
async () => { await reloadContent(true)
if (socket.isConnected && initialLoad.value) { initialLoad.value = false
await reloadContent(true) }
initialLoad.value = false })
}
},
)
const reloadContent = async (fromMounted = false) => { const reloadContent = async (fromMounted = false) => {
try { try {
@ -249,7 +313,7 @@ const updatePresets = async (items) => {
} }
const deleteItem = async (item) => { const deleteItem = async (item) => {
if (true !== confirm(`Delete preset '${item.name}'?`)) { if (true !== box.confirm(`Delete preset '${item.name}'?`, true)) {
return return
} }
@ -271,6 +335,7 @@ const deleteItem = async (item) => {
} }
const updateItem = async ({ reference, preset }) => { const updateItem = async ({ reference, preset }) => {
preset = cleanObject(preset, remove_keys)
if (reference) { if (reference) {
const index = presets.value.findIndex((t) => t?.id === reference) const index = presets.value.findIndex((t) => t?.id === reference)
if (index > -1) { if (index > -1) {
@ -290,7 +355,7 @@ const updateItem = async ({ reference, preset }) => {
} }
const filterItem = item => { const filterItem = item => {
const { raw, toggle_description, ...rest } = item const rest = cleanObject(item, remove_keys)
if ("default" in rest) { if ("default" in rest) {
delete rest.default delete rest.default
} }

View file

@ -3,17 +3,13 @@ table.is-fixed {
table-layout: fixed; table-layout: fixed;
} }
div.table-container {
overflow: hidden;
}
div.is-centered { div.is-centered {
justify-content: center; justify-content: center;
} }
</style> </style>
<template> <template>
<div> <main>
<div class="mt-1 columns is-multiline"> <div class="mt-1 columns is-multiline">
<div class="column is-12 is-clearfix is-unselectable"> <div class="column is-12 is-clearfix is-unselectable">
<span class="title is-4"> <span class="title is-4">
@ -25,14 +21,24 @@ div.is-centered {
<div class="is-pulled-right"> <div class="is-pulled-right">
<div class="field is-grouped"> <div class="field is-grouped">
<p class="control"> <p class="control">
<button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm"> <button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm"
<span class="icon"><i class="fas fa-add"></i></span> v-tooltip.bottom="'Toggle Add form'">
<span class="icon"><i class="fas fa-add" /></span>
</button> </button>
</p> </p>
<p class="control">
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom"
@click="() => display_style = display_style === 'cards' ? 'list' : 'cards'">
<span class="icon"><i class="fa-solid"
:class="{ 'fa-table': display_style === 'cards', 'fa-table-list': display_style === 'list' }" /></span>
</button>
</p>
<p class="control"> <p class="control">
<button class="button is-info" @click="reloadContent" :class="{ 'is-loading': isLoading }" <button class="button is-info" @click="reloadContent" :class="{ 'is-loading': isLoading }"
:disabled="!socket.isConnected || isLoading" v-if="tasks && tasks.length > 0"> :disabled="!socket.isConnected || isLoading" v-if="tasks && tasks.length > 0">
<span class="icon"><i class="fas fa-refresh"></i></span> <span class="icon"><i class="fas fa-refresh" /></span>
</button> </button>
</p> </p>
</div> </div>
@ -43,96 +49,190 @@ div.is-centered {
</span> </span>
</div> </div>
</div> </div>
</div>
<div class="column is-12" v-if="toggleForm"> <div class="columns is-multiline" v-if="toggleForm">
<div class="column is-12">
<TaskForm :addInProgress="addInProgress" :reference="taskRef" :task="task" @cancel="resetForm(true);" <TaskForm :addInProgress="addInProgress" :reference="taskRef" :task="task" @cancel="resetForm(true);"
@submit="updateItem" /> @submit="updateItem" />
</div> </div>
</div>
<div class="column is-12" v-if="!toggleForm"> <div class="columns is-multiline" v-if="!isLoading && !toggleForm && tasks && tasks.length > 0">
<div class="columns is-multiline" v-if="tasks && tasks.length > 0"> <template v-if="'list' === display_style">
<div class="column is-6" v-for="item in tasks" :key="item.id"> <div class="column is-12">
<div class="card is-flex is-full-height is-flex-direction-column"> <div class="table-container">
<header class="card-header"> <table class="table is-striped is-hoverable is-fullwidth is-bordered"
<div class="card-header-title is-text-overflow is-block"> style="min-width: 850px; table-layout: fixed;">
<NuxtLink target="_blank" :href="item.url">{{ item.name }}</NuxtLink> <thead>
</div> <tr class="has-text-centered is-unselectable">
<div class="card-header-icon"> <th width="50%">
<a class="has-text-primary" v-tooltip="'Export task.'" @click.prevent="exportItem(item)"> <span class="icon"><i class="fa-solid fa-tasks" /></span>
<span class="icon"><i class="fa-solid fa-file-export" /></span> <span>Task</span>
</a> </th>
</div> <th width="30%">
</header>
<div class="card-content is-flex-grow-1">
<div class="content">
<p class="is-text-overflow">
<span class="icon"><i class="fa-solid fa-clock" /></span> <span class="icon"><i class="fa-solid fa-clock" /></span>
<span>{{ tryParse(item.timer) }} - {{ item.timer }}</span> <span>Timer</span>
</p> </th>
<p class="is-text-overflow" v-if="item.folder"> <th width="20%">
<span class="icon"><i class="fa-solid fa-folder" /></span> <span class="icon"><i class="fa-solid fa-gear" /></span>
<span>{{ calcPath(item.folder) }}</span> <span>Actions</span>
</p> </th>
<p class="is-text-overflow" v-if="item.template"> </tr>
<span class="icon"><i class="fa-solid fa-file" /></span> </thead>
<span>{{ item.template }}</span> <tbody>
</p> <tr v-for="item in tasks" :key="item.id">
<p class="is-text-overflow"> <td class="is-vcentered">
<span class="icon"><i class="fa-solid fa-tv" /></span> <div class="is-text-overflow">
<span>{{ item.preset ?? config.app.default_preset }}</span> <NuxtLink target="_blank" :href="item.url" class="is-bold">
</p> {{ item.name }}
<p class="is-text-overflow" v-if="item.cli"> </NuxtLink>
<span class="icon"><i class="fa-solid fa-terminal" /></span> </div>
<span>{{ item.cli }}</span> <div v-if="item.preset">
</p> <span class="icon"><i class="fa-solid fa-tv" /></span>
</div> <span>{{ item.preset ?? config.app.default_preset }}</span>
</div>
</td>
<td class="is-vcentered has-text-centered">
<span v-if="item.timer" class="has-tooltip" v-tooltip="item.timer">
{{ tryParse(item.timer) }}
</span>
<span v-else class="has-text-danger">
<span class="icon"><i class="fa-solid fa-exclamation-triangle" /></span>
<span>No timer is set</span>
</span>
</td>
<td class="is-vcentered is-items-center">
<div class="field is-grouped is-grouped-centered">
<div class="control">
<button class="button is-purple is-small is-fullwidth" v-tooltip="'Run now'"
@click="runNow(item)" :class="{ 'is-loading': item?.in_progress }">
<span class="icon"><i class="fa-solid fa-up-right-from-square" /></span>
</button>
</div>
<div class="control">
<button class="button is-primary is-small is-fullwidth" v-tooltip="'Export'"
@click="exportItem(item)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
</button>
</div>
<div class="control">
<button class="button is-warning is-small is-fullwidth" v-tooltip="'Edit'"
@click="editItem(item)">
<span class="icon"><i class="fa-solid fa-cog" /></span>
</button>
</div>
<div class="control">
<button class="button is-danger is-small is-fullwidth" v-tooltip="'Delete'"
@click="deleteItem(item)">
<span class="icon"><i class="fa-solid fa-trash" /></span>
</button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<template v-else>
<div class="column is-6" v-for="item in tasks" :key="item.id">
<div class="card is-flex is-full-height is-flex-direction-column">
<header class="card-header">
<div class="card-header-title is-text-overflow is-block">
<NuxtLink target="_blank" :href="item.url">{{ item.name }}</NuxtLink>
</div> </div>
<div class="card-footer mt-auto"> <div class="card-header-icon">
<div class="card-footer-item"> <a class="has-text-primary" v-tooltip="'Export task.'" @click.prevent="exportItem(item)">
<button class="button is-warning is-fullwidth" @click="editItem(item);"> <span class="icon"><i class="fa-solid fa-file-export" /></span>
<span class="icon"><i class="fa-solid fa-cog" /></span> </a>
<span>Edit</span> </div>
</button> </header>
</div> <div class="card-content is-flex-grow-1">
<div class="card-footer-item"> <div class="content">
<button class="button is-danger is-fullwidth" @click="deleteItem(item)"> <p class="is-text-overflow">
<span class="icon"><i class="fa-solid fa-trash" /></span> <span class="icon"><i class="fa-solid fa-clock" /></span>
<span>Delete</span> <span v-if="item.timer">{{ tryParse(item.timer) }} - {{ item.timer }}</span>
</button> <span v-else>No timer is set</span>
</div> </p>
<div class="card-footer-item"> <p class="is-text-overflow" v-if="item.folder">
<button class="button is-purple is-fullwidth" @click="runNow(item)"> <span class="icon"><i class="fa-solid fa-folder" /></span>
<span class="icon"><i class="fa-solid fa-microchip" /></span> <span>{{ calcPath(item.folder) }}</span>
<span>Run now</span> </p>
</button> <p class="is-text-overflow" v-if="item.template">
</div> <span class="icon"><i class="fa-solid fa-file" /></span>
<span>{{ item.template }}</span>
</p>
<p class="is-text-overflow">
<span class="icon"><i class="fa-solid fa-tv" /></span>
<span>{{ item.preset ?? config.app.default_preset }}</span>
</p>
<p class="is-text-overflow" v-if="item.cli">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>{{ item.cli }}</span>
</p>
</div>
</div>
<div class="card-footer mt-auto">
<div class="card-footer-item">
<button class="button is-warning is-fullwidth" @click="editItem(item);">
<span class="icon"><i class="fa-solid fa-cog" /></span>
<span>Edit</span>
</button>
</div>
<div class="card-footer-item">
<button class="button is-danger is-fullwidth" @click="deleteItem(item)">
<span class="icon"><i class="fa-solid fa-trash" /></span>
<span>Delete</span>
</button>
</div>
<div class="card-footer-item">
<button class="button is-purple is-fullwidth" @click="runNow(item)"
:class="{ 'is-loading': item?.in_progress }">
<span class="icon"><i class="fa-solid fa-up-right-from-square" /></span>
<span>Run now</span>
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template>
</div>
<div class="columns is-multiline" v-if="!tasks || tasks.length < 1">
<div class="column is-12">
<Message message_class="has-background-info-90 has-text-dark" title="Loading" icon="fas fa-spinner fa-spin"
message="Loading data. Please wait..." v-if="isLoading" />
<Message title="No tasks" message="No tasks are defined." class="is-background-warning-80 has-text-dark" <Message title="No tasks" message="No tasks are defined." class="is-background-warning-80 has-text-dark"
icon="fas fa-exclamation-circle" v-if="!tasks || tasks.length < 1" /> icon="fas fa-exclamation-circle" v-else />
</div> </div>
</div> </div>
</div> </main>
</template> </template>
<script setup> <script setup>
import moment from 'moment' import moment from 'moment'
import { useStorage } from '@vueuse/core'
import { CronExpressionParser } from 'cron-parser' import { CronExpressionParser } from 'cron-parser'
import { request } from '~/utils/index' import { request } from '~/utils/index'
const toast = useToast() const toast = useNotification()
const config = useConfigStore() const config = useConfigStore()
const socket = useSocketStore() const socket = useSocketStore()
const box = useConfirm()
const tasks = ref([]) const tasks = ref([])
const task = ref({}) const task = ref({})
const taskRef = ref('') const taskRef = ref('')
const toggleForm = ref(false) const toggleForm = ref(false)
const isLoading = ref(false) const isLoading = ref(true)
const initialLoad = ref(true) const initialLoad = ref(true)
const addInProgress = ref(false) const addInProgress = ref(false)
const display_style = useStorage("tasks_display_style", "cards")
watch(() => config.app.basic_mode, async () => { watch(() => config.app.basic_mode, async () => {
if (!config.app.basic_mode) { if (!config.app.basic_mode) {
@ -216,7 +316,7 @@ const updateTasks = async items => {
} }
const deleteItem = async item => { const deleteItem = async item => {
if (true !== confirm(`Are you sure you want to delete (${item.name})?`)) { if (true !== box.confirm(`Delete '${item.name}' task?`, true)) {
return return
} }
@ -238,6 +338,7 @@ const deleteItem = async item => {
} }
const updateItem = async ({ reference, task }) => { const updateItem = async ({ reference, task }) => {
task = cleanObject(task, ['in_progress'])
if (reference) { if (reference) {
// -- find the task index. // -- find the task index.
const index = tasks.value.findIndex((t) => t?.id === reference) const index = tasks.value.findIndex((t) => t?.id === reference)
@ -289,11 +390,13 @@ const tryParse = expression => {
} }
} }
const runNow = item => { const runNow = async item => {
if (true !== confirm(`Run '${item.name}' now? it will also run at the scheduled time.`)) { if (true !== box.confirm(`Run '${item.name}' now? it will also run at the scheduled time.`)) {
return return
} }
item.in_progress = true
let data = { let data = {
url: item.url, url: item.url,
preset: item.preset, preset: item.preset,
@ -312,6 +415,11 @@ const runNow = item => {
} }
socket.emit('add_url', data) socket.emit('add_url', data)
setTimeout(async () => {
await nextTick()
item.in_progress = false
}, 500)
} }
onUnmounted(() => socket.off('status', statusHandler)) onUnmounted(() => socket.off('status', statusHandler))
@ -349,4 +457,6 @@ const exportItem = async item => {
return copyText(base64UrlEncode(JSON.stringify(data))); return copyText(base64UrlEncode(JSON.stringify(data)));
} }
</script> </script>

View file

@ -5,7 +5,7 @@ export const useSocketStore = defineStore('socket', () => {
const runtimeConfig = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const config = useConfigStore() const config = useConfigStore()
const stateStore = useStateStore() const stateStore = useStateStore()
const toast = useToast() const toast = useNotification()
const socket = ref(null); const socket = ref(null);
const isConnected = ref(false); const isConnected = ref(false);

View file

@ -1,6 +1,4 @@
import { useStorage } from '@vueuse/core' const toast = useNotification()
const toast = useToast()
const AG_SEPARATOR = '.' const AG_SEPARATOR = '.'
/** /**
@ -221,7 +219,12 @@ const encodePath = item => {
} }
item = item.replace(/#/g, '%23') item = item.replace(/#/g, '%23')
return item.split('/').map(decodeURIComponent).map(encodeURIComponent).join('/') try {
return item.split('/').map(decodeURIComponent).map(encodeURIComponent).join('/')
} catch (e) {
console.error('Error encoding path:', e, item)
return item
}
} }
/** /**
@ -473,6 +476,22 @@ const toggleClass = (target, className) => {
target.classList.add(className) target.classList.add(className)
} }
const cleanObject = (item, fields = []) => {
if (!item || typeof item !== 'object' || fields.length < 1) {
return item
}
const cleaned = {}
for (const key of Object.keys(item)) {
if (false === fields.includes(key)) {
cleaned[key] = item[key]
}
}
return cleaned
}
export { export {
ag_set, ag_set,
ag, ag,
@ -499,4 +518,5 @@ export {
base64UrlDecode, base64UrlDecode,
has_data, has_data,
toggleClass, toggleClass,
cleanObject,
} }