improved menubar

This commit is contained in:
arabcoders 2025-05-07 19:56:28 +03:00
parent b5a3ed1ab4
commit 815bd3c0cf
10 changed files with 1328 additions and 775 deletions

View file

@ -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

View file

@ -78,6 +78,7 @@ class HttpAPI(Common):
"/notifications",
"/changelog",
"/logs",
"/conditions",
"/browser",
"/browser/{path:.*}",
]

View file

@ -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():

View 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>

View file

@ -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>

View file

@ -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
View 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>

View file

@ -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>

View file

@ -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
}

File diff suppressed because it is too large Load diff