improved menubar
This commit is contained in:
parent
b5a3ed1ab4
commit
815bd3c0cf
10 changed files with 1328 additions and 775 deletions
|
|
@ -26,6 +26,7 @@ Web GUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp) with playlist & channel s
|
|||
* Support for both advanced and basic mode for WebUI.
|
||||
* Bundled tools in container: curl-cffi, ffmpeg, ffprobe, aria2, rtmpdump, mkvtoolsnix, mp4box.
|
||||
* Automatic upcoming live stream re-queue.
|
||||
* Apply `yt-dlp` options per custom defined conditions.
|
||||
|
||||
# Run using docker command
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ class HttpAPI(Common):
|
|||
"/notifications",
|
||||
"/changelog",
|
||||
"/logs",
|
||||
"/conditions",
|
||||
"/browser",
|
||||
"/browser/{path:.*}",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -297,6 +297,9 @@ class Conditions(metaclass=Singleton):
|
|||
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
|
||||
|
||||
from yt_dlp.utils import match_str
|
||||
|
||||
for item in self.get_all():
|
||||
|
|
|
|||
245
ui/components/ConditionForm.vue
Normal file
245
ui/components/ConditionForm.vue
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
<template>
|
||||
<main class="columns mt-2 is-multiline">
|
||||
<div class="column is-12">
|
||||
<form autocomplete="off" id="addForm" @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"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span>
|
||||
<span>{{ reference ? 'Edit' : 'Add' }}</span>
|
||||
</span>
|
||||
</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">
|
||||
<span class="icon"><i class="fa-solid fa-file-import" /></span>
|
||||
Import string
|
||||
</label>
|
||||
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded">
|
||||
<input type="text" class="input" id="import_string" v-model="import_string" autocomplete="off">
|
||||
</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-12">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="name">
|
||||
<span class="icon"><i class="fa-solid fa-tag" /></span>
|
||||
Name
|
||||
</label>
|
||||
<div class="control">
|
||||
<input type="text" class="input" id="name" v-model="form.name" :disabled="addInProgress"
|
||||
placeholder="For the problematic channel or video name.">
|
||||
</div>
|
||||
<span class="help">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>The name that refers to this condition.</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="filter">
|
||||
<span class="icon"><i class="fa-solid fa-filter" /></span>
|
||||
Filter
|
||||
</label>
|
||||
<div class="control">
|
||||
<input type="text" class="input" id="filter" v-model="form.filter" :disabled="addInProgress"
|
||||
placeholder="availability = 'needs_auth' & channel_id = 'channel_id'">
|
||||
</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>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="cli_options">
|
||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||
Command arguments for yt-dlp
|
||||
</label>
|
||||
<div class="control">
|
||||
<textarea class="textarea is-pre" v-model="form.cli" id="cli_options" :disabled="addInProgress"
|
||||
placeholder="command options to use, e.g. --proxy 1.2.3.4:3128" />
|
||||
</div>
|
||||
<span class="help">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>If the filter is matched, these options will be used.
|
||||
<span class="has-text-danger">This will override the cli arguments given with the URL. it's
|
||||
recommended to use presets and keep the cli with url empty if you plan to use this
|
||||
feature.</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="card-footer-item">
|
||||
<button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit"
|
||||
:class="{ 'is-loading': addInProgress }" form="addForm">
|
||||
<span class="icon"><i class="fa-solid fa-save" /></span>
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-footer-item">
|
||||
<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>Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useStorage } from '@vueuse/core'
|
||||
const emitter = defineEmits(['cancel', 'submit']);
|
||||
|
||||
const props = defineProps({
|
||||
reference: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
addInProgress: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
const form = reactive(JSON.parse(JSON.stringify(props.item)))
|
||||
const import_string = ref('')
|
||||
const showImport = useStorage('showImport', false);
|
||||
|
||||
const checkInfo = async () => {
|
||||
const required = ['name', 'filter', 'cli'];
|
||||
|
||||
for (const key of required) {
|
||||
if (!form[key]) {
|
||||
toast.error(`The ${key} field is required.`);
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (form?.cli && '' !== form.cli) {
|
||||
const options = await convertOptions(form.cli);
|
||||
if (null === options) {
|
||||
return
|
||||
}
|
||||
form.cli = form.cli.trim()
|
||||
}
|
||||
|
||||
let copy = JSON.parse(JSON.stringify(form));
|
||||
|
||||
for (const key in copy) {
|
||||
if (typeof copy[key] !== 'string') {
|
||||
continue
|
||||
}
|
||||
copy[key] = copy[key].trim()
|
||||
}
|
||||
|
||||
emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) });
|
||||
}
|
||||
|
||||
const convertOptions = async args => {
|
||||
try {
|
||||
const response = await convertCliOptions(args)
|
||||
return response.opts
|
||||
} catch (e) {
|
||||
toast.error(e.message)
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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 (!item?._type || 'condition' !== item._type) {
|
||||
toast.error(`Invalid import string. Expected type 'condition', got '${item._type ?? 'unknown'}'.`)
|
||||
return
|
||||
}
|
||||
|
||||
if ((form.filter || form.cli) && false === confirm('This will overwrite the current data. Are you sure?')) {
|
||||
return
|
||||
}
|
||||
|
||||
if (item.name) {
|
||||
form.name = item.name
|
||||
}
|
||||
|
||||
if (item.filter) {
|
||||
form.filter = item.filter
|
||||
}
|
||||
|
||||
if (item.cli) {
|
||||
form.cli = item.cli
|
||||
}
|
||||
|
||||
import_string.value = ''
|
||||
showImport.value = false
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error(`Failed to parse import string. ${e.message}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,95 +1,103 @@
|
|||
<template>
|
||||
<div id="main_container" class="container">
|
||||
<nav class="navbar is-mobile is-dark">
|
||||
<div class="navbar-brand pl-5 is-hidden-mobile">
|
||||
<NuxtLink class="navbar-item has-tooltip-bottom" to="/"
|
||||
|
||||
<div class="navbar-brand pl-5">
|
||||
<NuxtLink class="navbar-item is-text-overflow" to="/" @click.native="e => changeRoute(e)"
|
||||
v-tooltip="socket.isConnected ? 'Connected' : 'Connecting'">
|
||||
<span>
|
||||
<span class="is-text-overflow">
|
||||
<span class="icon"><i class="fas fa-home" /></span>
|
||||
<span class="has-text-bold" :class="`has-text-${socket.isConnected ? 'success' : 'danger'}`">
|
||||
YTPTube
|
||||
</span>
|
||||
<span v-if="config?.app?.instance_title">: {{ config.app.instance_title }}</span>
|
||||
<span class="has-text-bold" v-if="config?.app?.instance_title">: {{ config.app.instance_title }}</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
|
||||
<button class="navbar-burger burger" @click="showMenu = !showMenu">
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="navbar-end is-flex" style="flex-flow:wrap">
|
||||
|
||||
<div class="navbar-item is-hidden-tablet">
|
||||
<NuxtLink class="button is-dark has-tooltip-bottom" to="/">
|
||||
<span :class="socket.isConnected ? 'has-text-success' : 'has-text-danger'" class="icon">
|
||||
<img src="/favicon.ico" /></span>
|
||||
<div class="navbar-menu is-unselectable" :class="{ 'is-active': showMenu }">
|
||||
<div class="navbar-start" v-if="!config.app.basic_mode">
|
||||
<NuxtLink class="navbar-item" to="/browser" @click.native="e => changeRoute(e)"
|
||||
v-if="config.app.browser_enabled">
|
||||
<span class="icon"><i class="fa-solid fa-folder-tree" /></span>
|
||||
<span>Files</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="navbar-item" v-if="!config.app.basic_mode && config.app.browser_enabled">
|
||||
<NuxtLink class="button is-dark has-tooltip-bottom" to="/browser">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-folder-tree" /></span>
|
||||
<span class="is-hidden-mobile">Files</span>
|
||||
</span>
|
||||
<NuxtLink class="navbar-item" to="/presets" @click.native="e => changeRoute(e)">
|
||||
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
||||
<span>Presets</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="navbar-item" v-if="!config.app.basic_mode">
|
||||
<NuxtLink class="button is-dark has-tooltip-bottom" to="/presets">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
||||
<span class="is-hidden-mobile">Presets</span>
|
||||
</span>
|
||||
<NuxtLink class="navbar-item" to="/tasks" @click.native="e => changeRoute(e)">
|
||||
<span class="icon"><i class="fa-solid fa-tasks" /></span>
|
||||
<span>Tasks</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="navbar-item" v-if="!config.app.basic_mode">
|
||||
<NuxtLink class="button is-dark has-tooltip-bottom" to="/tasks">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-tasks" /></span>
|
||||
<span class="is-hidden-mobile">Tasks</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="navbar-item" v-if="!config.app.basic_mode">
|
||||
<NuxtLink class="button is-dark has-tooltip-bottom" to="/notifications">
|
||||
<NuxtLink class="navbar-item" to="/notifications" @click.native="e => changeRoute(e)">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-paper-plane" /></span>
|
||||
<span class="is-hidden-mobile">Notifications</span>
|
||||
<span>Notifications</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="navbar-item" v-if="!config.app.basic_mode && config.app.console_enabled">
|
||||
<NuxtLink class="button is-dark has-tooltip-bottom" to="/console">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||
<span class="is-hidden-mobile">Terminal</span>
|
||||
</span>
|
||||
<NuxtLink class="navbar-item" to="/conditions" @click.native="e => changeRoute(e)">
|
||||
<span class="icon"><i class="fa-solid fa-filter" /></span>
|
||||
<span>Conditions</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="navbar-item" v-if="!config.app.basic_mode && config.app.file_logging">
|
||||
<NuxtLink class="button is-dark has-tooltip-bottom" to="/logs">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-file-lines" /></span>
|
||||
<span class="is-hidden-mobile">Logs</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown" v-if="!config.app.basic_mode">
|
||||
<a class="navbar-link" @click="e => openMenu(e)">
|
||||
<span class="icon"><i class="fas fa-tools" /></span>
|
||||
<span>Other</span>
|
||||
</a>
|
||||
|
||||
<div class="navbar-item is-hidden-mobile">
|
||||
<button class="button is-dark" @click="reloadPage">
|
||||
<span class="icon"><i class="fas fa-refresh"></i></span>
|
||||
</button>
|
||||
<div class="navbar-dropdown">
|
||||
<NuxtLink class="navbar-item" to="/console" @click.native="e => changeRoute(e)"
|
||||
v-if="config.app.console_enabled">
|
||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||
<span>Terminal</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="navbar-item" to="/logs" @click.native="e => changeRoute(e)"
|
||||
v-if="config.app.file_logging">
|
||||
<span class="icon"><i class="fa-solid fa-file-lines" /></span>
|
||||
<span>Logs</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="navbar-item is-hidden-mobile">
|
||||
<button class="button is-dark" @click="reloadPage">
|
||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="navbar-item is-hidden-tablet">
|
||||
<button class="button is-dark" @click="reloadPage">
|
||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
||||
<span>Reload</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="navbar-item is-hidden-mobile">
|
||||
<button class="button is-dark has-tooltip-bottom" v-tooltip.bottom="'WebUI Settings'"
|
||||
@click="show_settings = !show_settings">
|
||||
<span class="icon"><i class="fas fa-cog" /></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="navbar-item is-hidden-tablet">
|
||||
<button class="button is-dark" @click="show_settings = !show_settings">
|
||||
<span class="icon"><i class="fas fa-cog" /></span>
|
||||
<span>WebUI Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="navbar-item">
|
||||
<button class="button is-dark has-tooltip-bottom" v-tooltip.bottom="'WebUI Settings'"
|
||||
@click="show_settings = !show_settings">
|
||||
<span class="icon"><i class="fas fa-cog" /></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
|
@ -137,6 +145,7 @@ const show_settings = ref(false)
|
|||
const loadingImage = ref(false)
|
||||
const bg_enable = useStorage('random_bg', true)
|
||||
const bg_opacity = useStorage('random_bg_opacity', 0.85)
|
||||
const showMenu = ref(false)
|
||||
|
||||
const applyPreferredColorScheme = scheme => {
|
||||
if (!scheme || 'auto' === scheme) {
|
||||
|
|
@ -296,4 +305,23 @@ const loadImage = async (force = false) => {
|
|||
}
|
||||
}
|
||||
|
||||
const changeRoute = async (_, callback) => {
|
||||
showMenu.value = false
|
||||
document.querySelectorAll('div.has-dropdown').forEach(el => el.classList.remove('is-active'))
|
||||
if (callback) {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const openMenu = e => {
|
||||
const elm = e.target.closest('div.has-dropdown')
|
||||
|
||||
document.querySelectorAll('div.has-dropdown').forEach(el => {
|
||||
if (el !== elm) {
|
||||
el.classList.remove('is-active')
|
||||
}
|
||||
})
|
||||
|
||||
e.target.closest('div.has-dropdown').classList.toggle('is-active')
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"web-types": "./web-types.json",
|
||||
"dependencies": {
|
||||
"@pinia/nuxt": "^0.11.0",
|
||||
"@sentry/nuxt": "^9.15.0",
|
||||
"@sentry/nuxt": "^9.16.0",
|
||||
"@vueuse/core": "^13.1.0",
|
||||
"@vueuse/nuxt": "^13.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
"floating-vue": "^5.2.2",
|
||||
"hls.js": "^1.6.2",
|
||||
"moment": "^2.30.1",
|
||||
"nuxt": "^3.17.1",
|
||||
"nuxt": "^3.17.2",
|
||||
"pinia": "^3.0.2",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"vue": "^3.4",
|
||||
|
|
|
|||
289
ui/pages/conditions.vue
Normal file
289
ui/pages/conditions.vue
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="mt-1 columns is-multiline">
|
||||
<div class="column is-12 is-clearfix is-unselectable">
|
||||
<span class="title is-4">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-filter" /></span>
|
||||
<span>Conditions</span>
|
||||
</span>
|
||||
</span>
|
||||
<div class="is-pulled-right">
|
||||
<div class="field is-grouped">
|
||||
<p class="control">
|
||||
<button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm;">
|
||||
<span class="icon"><i class="fas fa-add" /></span>
|
||||
</button>
|
||||
</p>
|
||||
<p class="control">
|
||||
<button class="button is-info" @click="reloadContent" :class="{ 'is-loading': isLoading }"
|
||||
:disabled="!socket.isConnected || isLoading" v-if="items && items.length > 0">
|
||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="is-hidden-mobile">
|
||||
<span class="subtitle">Run yt-dlp custom match filter on returned info. and apply cli arguments if matched.
|
||||
This is an advanced feature and should be used with caution.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12" v-if="toggleForm">
|
||||
<ConditionForm :addInProgress="addInProgress" :reference="itemRef" :item="item" @cancel="resetForm(true)"
|
||||
@submit="updateItem" />
|
||||
</div>
|
||||
|
||||
<div class="column is-12" v-if="!toggleForm">
|
||||
<div class="columns is-multiline" v-if="items.length > 0">
|
||||
<div class="column is-6" v-for="cond in items" :key="cond.id">
|
||||
<div class="card">
|
||||
<header class="card-header">
|
||||
<div class="card-header-title is-text-overflow is-block" v-text="cond.name" />
|
||||
<div class="card-header-icon">
|
||||
<a class="has-text-primary" v-tooltip="'Export item.'" @click.prevent="exportItem(cond)">
|
||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||
</a>
|
||||
<button @click="cond.raw = !cond.raw">
|
||||
<span class="icon"><i class="fa-solid" :class="{
|
||||
'fa-arrow-down': !cond?.raw,
|
||||
'fa-arrow-up': cond?.raw,
|
||||
}" /></span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="card-content">
|
||||
<div class="content">
|
||||
<p class="is-text-overflow">
|
||||
<span class="icon"><i class="fa-solid fa-filter" /></span>
|
||||
<span v-text="cond.filter" />
|
||||
</p>
|
||||
<p class="is-text-overflow" v-if="cond.cli">
|
||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||
<span>{{ cond.cli }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content" v-if="cond?.raw">
|
||||
<div class="content">
|
||||
<pre><code>{{ filterItem(cond) }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="card-footer-item">
|
||||
<button class="button is-warning is-fullwidth" @click="editItem(cond)">
|
||||
<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(cond)">
|
||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Message title="No items" message="There are no custom conditions defined."
|
||||
class="is-background-warning-80 has-text-dark" icon="fas fa-exclamation-circle"
|
||||
v-if="!items || items.length < 1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-12" v-if="items && items.length > 0 && !toggleForm">
|
||||
<Message message_class="has-background-info-90 has-text-dark" title="Tips" icon="fas fa-info-circle">
|
||||
<ul>
|
||||
<li>The filtering rely on yt-dlp <code>--match-filter</code> logic, whatever works there works here as well
|
||||
and uses the same logic boolean and operators.</li>
|
||||
<li>
|
||||
The primary use case for this feature is to apply custom cli arguments to specific returned info.
|
||||
</li>
|
||||
<li>
|
||||
For example, i follow specific channel that sometimes region lock some videos, by using the following
|
||||
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.
|
||||
</li>
|
||||
</ul>
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { request } from '~/utils/index'
|
||||
|
||||
const toast = useToast()
|
||||
const config = useConfigStore()
|
||||
const socket = useSocketStore()
|
||||
|
||||
const items = ref([])
|
||||
const item = ref({})
|
||||
const itemRef = ref("")
|
||||
const toggleForm = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const initialLoad = ref(true)
|
||||
const addInProgress = ref(false)
|
||||
|
||||
watch(() => config.app.basic_mode, async () => {
|
||||
if (!config.app.basic_mode) {
|
||||
return
|
||||
}
|
||||
await navigateTo("/")
|
||||
})
|
||||
|
||||
watch(() => socket.isConnected, async () => {
|
||||
if (socket.isConnected && initialLoad.value) {
|
||||
await reloadContent(true)
|
||||
initialLoad.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const reloadContent = async (fromMounted = false) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const response = await request("/api/conditions")
|
||||
|
||||
if (fromMounted && !response.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data.length < 1) {
|
||||
return
|
||||
}
|
||||
|
||||
items.value = data
|
||||
} catch (e) {
|
||||
if (fromMounted) {
|
||||
return
|
||||
}
|
||||
console.error(e)
|
||||
toast.error("Failed to fetch page content.")
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = (closeForm = false) => {
|
||||
item.value = {}
|
||||
itemRef.value = null
|
||||
addInProgress.value = false
|
||||
if (closeForm) {
|
||||
toggleForm.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateItems = async items => {
|
||||
let data
|
||||
try {
|
||||
addInProgress.value = true
|
||||
|
||||
const local_items = []
|
||||
for (const item of items) {
|
||||
const { id, name, filter, cli } = item
|
||||
if (!name || !filter || !cli) {
|
||||
toast.error("Name, filter and cli are required.")
|
||||
return false
|
||||
}
|
||||
local_items.push({ id, name, filter, cli })
|
||||
}
|
||||
|
||||
const response = await request("/api/conditions", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(local_items),
|
||||
})
|
||||
|
||||
data = await response.json()
|
||||
|
||||
if (200 !== response.status) {
|
||||
toast.error(`Failed to update items. ${data.error}`)
|
||||
return false
|
||||
}
|
||||
|
||||
items.value = data
|
||||
resetForm(true)
|
||||
return true
|
||||
} catch (e) {
|
||||
toast.error(`Failed to update items. ${data?.error}. ${e.message}`)
|
||||
} finally {
|
||||
addInProgress.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteItem = async (item) => {
|
||||
if (true !== confirm(`Delete '${item.name}'?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
const index = items.value.findIndex((t) => t?.id === item.id)
|
||||
if (index > -1) {
|
||||
items.value.splice(index, 1)
|
||||
} else {
|
||||
toast.error("Item not found.")
|
||||
return
|
||||
}
|
||||
|
||||
const status = await updateItems(items.value)
|
||||
|
||||
if (!status) {
|
||||
return
|
||||
}
|
||||
|
||||
toast.success("Item deleted.")
|
||||
}
|
||||
|
||||
const updateItem = async ({ reference, item }) => {
|
||||
if (reference) {
|
||||
const index = items.value.findIndex(t => t?.id === reference)
|
||||
if (index > -1) {
|
||||
items.value[index] = item
|
||||
}
|
||||
} else {
|
||||
items.value.push(item)
|
||||
}
|
||||
|
||||
const status = await updateItems(items.value)
|
||||
if (!status) {
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(`Item ${reference ? "updated" : "added"}.`)
|
||||
resetForm(true)
|
||||
}
|
||||
|
||||
const filterItem = item => {
|
||||
const { raw, ...rest } = item
|
||||
return JSON.stringify(rest, null, 2)
|
||||
}
|
||||
|
||||
const editItem = _item => {
|
||||
item.value = _item
|
||||
itemRef.value = _item.id
|
||||
toggleForm.value = true
|
||||
}
|
||||
|
||||
onMounted(async () => (socket.isConnected ? await reloadContent(true) : ""))
|
||||
|
||||
const exportItem = item => {
|
||||
let data = JSON.parse(JSON.stringify(item))
|
||||
if ("id" in data) {
|
||||
delete data['id']
|
||||
}
|
||||
|
||||
let userData = {}
|
||||
|
||||
for (const key of Object.keys(data)) {
|
||||
if (!data[key]) {
|
||||
continue
|
||||
}
|
||||
userData[key] = data[key]
|
||||
}
|
||||
|
||||
userData['_type'] = 'condition'
|
||||
userData['_version'] = '1.0'
|
||||
|
||||
return copyText(base64UrlEncode(JSON.stringify(userData)))
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,19 +1,3 @@
|
|||
<style>
|
||||
.navbar-dropdown {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-item.is-active .navbar-dropdown,
|
||||
.navbar-item.is-hoverable:focus .navbar-dropdown,
|
||||
.navbar-item.is-hoverable:focus-within .navbar-dropdown,
|
||||
.navbar-item.is-hoverable:hover .navbar-dropdown {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.navbar-item.has-dropdown {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div>
|
||||
<div class="mt-1 columns is-multiline">
|
||||
|
|
@ -21,10 +5,7 @@
|
|||
<span class="title is-4">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-download" /></span>
|
||||
<span class="is-hidden-mobile">Downloads</span>
|
||||
<span class="is-hidden-tablet">
|
||||
{{ config?.app?.instance_title ? config.app.instance_title : 'Downloads' }}
|
||||
</span>
|
||||
<span>Downloads</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ const reloadContent = async (fromMounted = false) => {
|
|||
return
|
||||
}
|
||||
console.error(e)
|
||||
toast.error("Failed to fetch tasks.")
|
||||
toast.error("Failed to fetch page content.")
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
|
|
|||
1381
ui/yarn.lock
1381
ui/yarn.lock
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue