Merge pull request #504 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled

QoL: Support newlines as seperator for URLs.
This commit is contained in:
Abdulmohsen 2025-11-29 19:13:25 +03:00 committed by GitHub
commit af84a03ad1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 289 additions and 55 deletions

2
FAQ.md
View file

@ -479,7 +479,7 @@ This setup should work for at VAAPI encoding in `x86_64` containers.
> Your `video`, `render` group id might be different from mine, you can run the follow command in docker host server to get the group ids for both groups.
```bash
$ cat /etc/group | grep -E 'render|video'
cat /etc/group | grep -E 'render|video'
video:x:44:your_docker_username
render:x:105:your_docker_username

View file

@ -80,7 +80,7 @@ services:
> If you have low RAM, remove the `tmpfs` and mount a disk-based directory to `/tmp` instead. See [FAQ](FAQ.md#getting-no-space-left-on-device-error) for more information.
```bash
$ mkdir -p ./{config,downloads} && docker compose -f compose.yaml up -d
mkdir -p ./{config,downloads} && docker compose -f compose.yaml up -d
```
Then you can access the WebUI at `http://localhost:8081`.

View file

@ -5,18 +5,26 @@
<div class="box">
<div class="columns is-multiline is-mobile">
<div class="column is-12">
<label class="label is-inline is-unselectable" for="url">
<label class=" label is-inline is-unselectable" for="url">
<span class="icon"><i class="fa-solid fa-link" /></span>
URLs separated by <span class="is-bold">{{ getSeparatorsName(separator) }}</span>
<span class="has-tooltip" v-tooltip="'Use Shift+Enter to switch to multiline input mode.'">
URLs separated by newlines or <span class="is-bold is-lowercase">{{ getSeparatorsName(separator)
}}</span>
</span>
</label>
<div class="field is-grouped">
<div class="control is-expanded">
<input type="text" class="input" id="url" placeholder="URLs to download"
:disabled="!socket.isConnected || addInProgress" v-model="form.url">
<textarea v-if="isMultiLineInput" ref="urlTextarea" class="textarea" id="url"
:disabled="!socket.isConnected || addInProgress" v-model="form.url" @keydown="handleKeyDown"
@input="adjustTextareaHeight"
style="resize: none; overflow-y: auto; min-height: 38px; max-height: 300px;" />
<input v-else type="text" class="input" id="url" placeholder="URLs to download"
:disabled="!socket.isConnected || addInProgress" v-model="form.url" @keydown="handleKeyDown"
@paste="handlePaste">
</div>
<div class="control">
<button type="submit" class="button is-primary" :class="{ 'is-loading': addInProgress }"
:disabled="!socket.isConnected || addInProgress || !form?.url">
:disabled="!socket.isConnected || addInProgress || !hasValidUrl">
<span class="icon"><i class="fa-solid fa-plus" /></span>
<span>Add</span>
</button>
@ -209,7 +217,7 @@
<div class="control" v-if="config.app.console_enabled" v-tooltip="'Run directly in console'">
<button type="button" class="button is-warning" @click="runCliCommand"
:disabled="!socket.isConnected || !form?.url">
:disabled="!socket.isConnected || !hasValidUrl">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
</button>
</div>
@ -217,15 +225,15 @@
<div class="control">
<button type="button" class="button is-info"
v-tooltip="'Get yt-dlp information for the provided URL.'"
@click="emitter('getInfo', form.url, form.preset, form.cli)"
:disabled="!socket.isConnected || addInProgress || !form?.url || multiURLs">
@click="emitter('getInfo', splitUrls(form.url || '')[0] || '', form.preset, form.cli)"
:disabled="!socket.isConnected || addInProgress || !hasValidUrl">
<span class="icon"><i class="fa-solid fa-info" /></span>
</button>
</div>
<div class="control" v-if="config.app.console_enabled">
<button type="button" class="button is-success" @click="testDownloadOptions"
:disabled="!socket.isConnected || !form?.url" v-tooltip="'Show compiled yt-dlp options.'">
:disabled="!socket.isConnected || !hasValidUrl" v-tooltip="'Show compiled yt-dlp options.'">
<span class="icon"><i class="fa-solid fa-flask" /></span>
</button>
</div>
@ -291,6 +299,7 @@ const testResultsData = ref<any>(null)
const dlFieldsExtra = ['--no-download-archive', '--no-continue']
const ytDlpOpt = ref<AutoCompleteOptions>([])
const cookiesDropzoneRef = ref<InstanceType<typeof TextDropzone> | null>(null)
const urlTextarea = ref<HTMLTextAreaElement | null>(null)
const form = useStorage<item_request>('local_config_v1', {
id: null,
@ -304,14 +313,6 @@ const form = useStorage<item_request>('local_config_v1', {
}) as Ref<item_request>
const dialog_confirm = ref({
visible: false,
title: 'Confirm Action',
confirm: () => { },
message: '',
options: [],
})
const is_valid_dl_field = (dl_field: string): boolean => {
if (dlFieldsExtra.includes(dl_field)) {
return true
@ -324,6 +325,81 @@ const is_valid_dl_field = (dl_field: string): boolean => {
return false;
}
const adjustTextareaHeight = async (): Promise<void> => {
await nextTick()
if (urlTextarea.value) {
urlTextarea.value.style.height = 'auto'
const newHeight = Math.min(urlTextarea.value.scrollHeight, 300)
urlTextarea.value.style.height = `${newHeight}px`
}
}
const handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
const target = event.target as HTMLInputElement | HTMLTextAreaElement
const isTextarea = target.tagName === 'TEXTAREA'
if (event.key !== 'Enter') {
return
}
if (event.ctrlKey && isTextarea && !hasValidUrl.value) {
event.preventDefault()
addDownload()
return
}
if (event.shiftKey && !isTextarea) {
event.preventDefault()
const cursorPos = target.selectionStart || form.value.url.length
form.value.url = form.value.url.substring(0, cursorPos) + '\n' + form.value.url.substring(target.selectionEnd || cursorPos)
await nextTick()
if (urlTextarea.value) {
await adjustTextareaHeight()
urlTextarea.value.setSelectionRange(cursorPos + 1, cursorPos + 1)
urlTextarea.value.focus()
}
}
}
const handlePaste = async (event: ClipboardEvent): Promise<void> => {
const pastedText = event.clipboardData?.getData('text') || ''
if (!pastedText.includes('\n')) {
return
}
event.preventDefault()
const target = event.target as HTMLInputElement
const currentValue = form.value.url || ''
const start = target.selectionStart || currentValue.length
const end = target.selectionEnd || currentValue.length
form.value.url = currentValue.substring(0, start) + pastedText + currentValue.substring(end)
await nextTick()
if (urlTextarea.value) {
await adjustTextareaHeight()
const newPos = start + pastedText.length
urlTextarea.value.setSelectionRange(newPos, newPos)
urlTextarea.value.focus()
}
}
const splitUrls = (urlString: string): Array<string> => {
const lines = urlString.split('\n')
const urls: string[] = []
lines.forEach(line => line.split(separator.value).forEach(url => {
const trimmed = url.trim()
if (trimmed) {
urls.push(trimmed)
}
}))
return urls
}
const addDownload = async () => {
let form_cli = (form.value?.cli || '').trim()
@ -361,11 +437,7 @@ const addDownload = async () => {
const request_data = [] as Array<item_request>
form.value.url.split(separator.value).forEach(async (url: string) => {
if (!url.trim()) {
return
}
splitUrls(form.value.url).forEach(async (url: string) => {
const data = {
url: url,
preset: form.value.preset || config.app.default_preset,
@ -441,7 +513,6 @@ const resetConfig = async () => {
dlFields.value = {}
showAdvanced.value = false
toast.success('Local configuration has been reset.')
dialog_confirm.value.visible = false
}
const convertOptions = async (args: string) => {
@ -509,7 +580,11 @@ onMounted(async () => {
await nextTick()
if (!separators.some(s => s.value === separator.value)) {
separator.value = separators[0]?.value ?? '.'
separator.value = separators[0]?.value ?? ','
}
if (isMultiLineInput.value && urlTextarea.value) {
await adjustTextareaHeight()
}
})
@ -565,7 +640,7 @@ const runCliCommand = async (): Promise<void> => {
const resp = await request('/api/yt-dlp/command', {
method: 'POST',
body: JSON.stringify({
url: form.value.url,
url: splitUrls(form.value.url).join(' '),
preset: form.value.preset,
folder: form.value.folder,
cookies: form.value.cookies,
@ -657,6 +732,7 @@ const CloseTestResults = () => {
testResultsData.value = null
}
const isMultiLineInput = computed(() => !!form.value.url && form.value.url.includes('\n'))
const hasFormatInConfig = computed((): boolean => !!form.value.cli?.match(/(?<!\S)(-f|--format)(=|\s)(\S+)/))
const filter_presets = (flag: boolean = true) => config.presets.filter(item => item.default === flag)
@ -693,13 +769,17 @@ const getDefault = (type: 'cookies' | 'cli' | 'template' | 'folder', ret: string
return ret
}
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
const sortedDLFields = computed(() => config.dl_fields.sort((a, b) => (a.order || 0) - (b.order || 0)))
const sortedDLFields = computed(() => [...config.dl_fields].sort((a, b) => (a.order || 0) - (b.order || 0)))
const hasValidUrl = computed(() => form.value.url && form.value.url.trim().length > 0)
const multiURLs = computed(() => {
if (!form.value.url) {
return false
watch(isMultiLineInput, async newValue => {
await nextTick()
if (newValue) {
await adjustTextareaHeight()
urlTextarea.value?.focus()
return
}
return form.value.url.split(separator.value).filter((u: string) => u.trim()).length > 1
const inputElement = document.getElementById('url') as HTMLInputElement
inputElement?.focus()
})
</script>

View file

@ -2,6 +2,15 @@
.terminal {
padding-left: 10px;
}
.history-item {
cursor: pointer;
transition: background-color 0.2s;
}
.history-item:hover {
background-color: #f5f5f5;
}
</style>
<template>
@ -39,15 +48,17 @@
<section class="card-content p-1 m-1">
<div class="field is-grouped">
<div class="control is-expanded">
<InputAutocomplete v-model="command" :options="ytDlpOptions" :disabled="isLoading" placeholder="--help"
id="command" @keydown.enter="runCommand" :multiple="true" :allowShortFlags="true" />
<TextareaAutocomplete v-if="isMultiLineInput" ref="commandTextarea" v-model="command"
:options="ytDlpOptions" :disabled="isLoading" placeholder="--help" @keydown="handleKeyDown" />
<InputAutocomplete v-else v-model="command" ref="commandInput" :options="ytDlpOptions"
:disabled="isLoading" placeholder="--help" @keydown="handleKeyDown" @paste="handlePaste"
:multiple="true" :allowShortFlags="true" />
</div>
<p class="control">
<button class="button is-primary" type="button" :disabled="isLoading || '' === command"
<button class="button is-primary" type="button" :disabled="isLoading || !hasValidCommand"
@click="runCommand">
<span class="icon">
<i class="fa-solid fa-spinner fa-spin" v-if="isLoading" />
<i class="fa-solid fa-paper-plane" v-else />
<i class="fa-solid" :class="isLoading ? 'fa-spinner fa-spin' : 'fa-paper-plane'" />
</span>
</button>
</p>
@ -55,6 +66,48 @@
</section>
</div>
</div>
<div class="column is-12">
<div class="card">
<header class="card-header">
<p class="card-header-title is-pointer is-unselectable" @click="isHistoryCollapsed = !isHistoryCollapsed">
<span class="icon"><i class="fa-solid fa-clock-rotate-left" /></span>
<span class="ml-2">Commands History</span>
</p>
<p class="card-header-icon">
<span v-tooltip.top="'Clear command history'" class="icon" @click="clearHistory()">
<i class="fa-solid fa-trash" />
</span>
<span v-tooltip.top="isHistoryCollapsed ? 'Expand' : 'Collapse'" class="icon ml-2"
@click="isHistoryCollapsed = !isHistoryCollapsed">
<i class="fa-solid" :class="isHistoryCollapsed ? 'fa-chevron-down' : 'fa-chevron-up'" />
</span>
</p>
</header>
<div v-show="!isHistoryCollapsed" class="card-content p-2">
<Message :newStyle="true" class="is-info" v-if="commandHistory.length < 1">
Commands history is empty.
</Message>
<div class="table-container" v-if="commandHistory.length > 0">
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
style="min-width: 850px; table-layout: fixed;">
<tbody>
<tr v-for="(cmd, index) in commandHistory" :key="index">
<td>
<code class="is-family-monospace is-text-overflow is-pointer is-block" @click="loadCommand(cmd)">
{{ cmd.replace(/\n/g, ' ') }}
</code>
</td>
<td style="width: 40px; text-align: center;">
<button class="delete" @click="removeFromHistory(index)" />
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</template>
@ -65,27 +118,41 @@ import { FitAddon } from '@xterm/addon-fit'
import { useStorage } from '@vueuse/core'
import { disableOpacity, enableOpacity } from '~/utils'
import InputAutocomplete from '~/components/InputAutocomplete.vue'
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
import { useDialog } from '~/composables/useDialog'
import type { AutoCompleteOptions } from '~/types/autocomplete'
const config = useConfigStore()
const socket = useSocketStore()
const toast = useNotification()
const dialog = useDialog()
const terminal = ref<Terminal>()
const terminalFit = ref<FitAddon>()
const command = ref<string>('')
const terminal_window = useTemplateRef<HTMLDivElement>('terminal_window')
const commandInput = ref<InstanceType<typeof InputAutocomplete> | null>(null)
const commandTextarea = ref<InstanceType<typeof TextareaAutocomplete> | null>(null)
const isLoading = ref<boolean>(false)
const storedCommand = useStorage<string>('console_command', '')
const commandHistory = useStorage<string[]>('console_command_history', [])
const isHistoryCollapsed = useStorage<boolean>('console_history_collapsed', false)
const MAX_HISTORY_ITEMS = 50
const ytDlpOptions = computed<AutoCompleteOptions>(() => config.ytdlp_options.flatMap(opt => opt.flags
.map(flag => ({ value: flag, description: opt.description || '' }))
))
const hasValidCommand = computed(() => command.value && command.value.trim().length > 0)
const isMultiLineInput = computed(() => !!command.value && command.value.includes('\n'))
watch(() => isLoading.value, async value => {
if (value) {
return
}
if (command.value.trim()) {
addToHistory(command.value.trim())
}
command.value = ''
await nextTick();
focusInput()
@ -99,6 +166,61 @@ watch(() => config.app.console_enabled, async () => {
await navigateTo('/')
})
const handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
const target = event.target as HTMLInputElement | HTMLTextAreaElement
const isTextarea = 'TEXTAREA' === target.tagName
if (event.key !== 'Enter') {
return
}
if (((event.ctrlKey && isTextarea) || !isTextarea) && hasValidCommand.value) {
event.preventDefault()
runCommand()
return
}
if (event.shiftKey && !isTextarea) {
event.preventDefault()
const cursorPos = target.selectionStart || command.value.length
command.value = command.value.substring(0, cursorPos) + '\n' + command.value.substring(target.selectionEnd || cursorPos)
await nextTick()
if (commandTextarea.value) {
const textarea = commandTextarea.value.$el?.querySelector('textarea') as HTMLTextAreaElement
if (textarea) {
textarea.setSelectionRange(cursorPos + 1, cursorPos + 1)
textarea.focus()
}
}
}
}
const handlePaste = async (event: ClipboardEvent): Promise<void> => {
const pastedText = event.clipboardData?.getData('text') || ''
if (!pastedText.includes('\n')) {
return
}
event.preventDefault()
const target = event.target as HTMLInputElement
const currentValue = command.value || ''
const start = target.selectionStart || currentValue.length
const end = target.selectionEnd || currentValue.length
command.value = currentValue.substring(0, start) + pastedText + currentValue.substring(end)
await nextTick()
if (!commandTextarea.value) {
return
}
const textarea = commandTextarea.value.$el?.querySelector('textarea') as HTMLTextAreaElement
if (textarea) {
const newPos = start + pastedText.length
textarea.setSelectionRange(newPos, newPos)
textarea.focus()
}
}
const handle_event = () => {
if (!terminal.value) {
return
@ -135,7 +257,7 @@ const ensureTerminal = async () => {
}
const runCommand = async () => {
if ('' === command.value) {
if (!hasValidCommand.value) {
return
}
@ -145,22 +267,24 @@ const runCommand = async () => {
return
}
if (command.value.startsWith('yt-dlp')) {
command.value = command.value.replace(/^yt-dlp/, '').trim()
let cmd = command.value.trim().replace(/\n/g, ' ').trim()
if (cmd.startsWith('yt-dlp')) {
cmd = cmd.replace(/^yt-dlp/, '').trim()
await nextTick()
if ('' === command.value) {
if ('' === cmd) {
return
}
}
await ensureTerminal()
if ('clear' === command.value) {
if ('clear' === cmd) {
clearOutput(true)
return
}
socket.emit('cli_post', command.value)
socket.emit('cli_post', cmd)
isLoading.value = true
terminal.value?.writeln(`user@YTPTube ~`)
terminal.value?.writeln(`$ yt-dlp ${command.value}`)
@ -179,25 +303,56 @@ const clearOutput = async (withCommand: boolean = false) => {
focusInput()
}
const focusInput = () => {
const inputElement = document.getElementById('command') as HTMLInputElement
if (inputElement) {
inputElement.focus()
const focusInput = async () => {
await nextTick()
let elm;
if (isMultiLineInput.value) {
elm = commandTextarea.value?.$el?.querySelector('textarea') as HTMLTextAreaElement
} else {
elm = commandInput.value?.$el?.querySelector('input') as HTMLInputElement
}
elm?.focus()
}
const addToHistory = (cmd: string) => {
commandHistory.value = [cmd, ...commandHistory.value.filter(h => h !== cmd)].slice(0, MAX_HISTORY_ITEMS)
}
const loadCommand = async (cmd: string) => {
command.value = cmd
await nextTick()
focusInput()
}
const clearHistory = async () => {
if (commandHistory.value.length === 0) {
return
}
const { status } = await dialog.confirmDialog({
title: 'Confirm Action',
message: `Clear commands history?`,
confirmColor: 'is-danger',
})
if (!status) {
return
}
commandHistory.value = []
}
const removeFromHistory = (index: number) => commandHistory.value = commandHistory.value.filter((_, i) => i !== index)
const writer = (s: string) => {
if (!terminal.value) {
return
}
const json = JSON.parse(s)
terminal.value.writeln(json.data.line)
terminal.value.writeln(JSON.parse(s).data.line)
}
const loader = () => isLoading.value = false
watch(isMultiLineInput, () => focusInput())
onMounted(async () => {
document.addEventListener('resize', handle_event);
focusInput()
@ -213,7 +368,6 @@ onMounted(async () => {
if (storedCommand.value) {
command.value = storedCommand.value
await nextTick()
runCommand()
}
})

View file

@ -3,8 +3,8 @@ import { useStorage } from '@vueuse/core'
import type { notification, notificationType } from '~/composables/useNotification'
const _map: Record<notificationType, { level: number; color: string; icon: string }> = {
'error': { level: 3, color: 'is-danger', icon: 'fas fa-circle-exclamation' },
'warning': { level: 2, color: 'is-warning', icon: 'fas fa-triangle-exclamation' },
'error': { level: 3, color: 'is-danger', icon: 'fas fa-triangle-exclamation' },
'warning': { level: 2, color: 'is-warning', icon: 'fas fa-circle-exclamation' },
'success': { level: 1, color: 'is-primary', icon: 'fas fa-circle-check' },
'info': { level: 0, color: 'is-info', icon: 'fas fa-circle-info' }
}