QoL: Support newlines as seperator for URLs.

This commit is contained in:
arabcoders 2025-11-28 17:15:46 +03:00
parent 13ac81b56e
commit d99875eb57
3 changed files with 201 additions and 53 deletions

View file

@ -5,18 +5,26 @@
<div class="box"> <div class="box">
<div class="columns is-multiline is-mobile"> <div class="columns is-multiline is-mobile">
<div class="column is-12"> <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> <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> </label>
<div class="field is-grouped"> <div class="field is-grouped">
<div class="control is-expanded"> <div class="control is-expanded">
<input type="text" class="input" id="url" placeholder="URLs to download" <textarea v-if="isMultiLineInput" ref="urlTextarea" class="textarea" id="url"
:disabled="!socket.isConnected || addInProgress" v-model="form.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>
<div class="control"> <div class="control">
<button type="submit" class="button is-primary" :class="{ 'is-loading': addInProgress }" <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 class="icon"><i class="fa-solid fa-plus" /></span>
<span>Add</span> <span>Add</span>
</button> </button>
@ -209,7 +217,7 @@
<div class="control" v-if="config.app.console_enabled" v-tooltip="'Run directly in console'"> <div class="control" v-if="config.app.console_enabled" v-tooltip="'Run directly in console'">
<button type="button" class="button is-warning" @click="runCliCommand" <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> <span class="icon"><i class="fa-solid fa-terminal" /></span>
</button> </button>
</div> </div>
@ -217,15 +225,15 @@
<div class="control"> <div class="control">
<button type="button" class="button is-info" <button type="button" class="button is-info"
v-tooltip="'Get yt-dlp information for the provided URL.'" v-tooltip="'Get yt-dlp information for the provided URL.'"
@click="emitter('getInfo', form.url, form.preset, form.cli)" @click="emitter('getInfo', splitUrls(form.url || '')[0] || '', form.preset, form.cli)"
:disabled="!socket.isConnected || addInProgress || !form?.url || multiURLs"> :disabled="!socket.isConnected || addInProgress || !hasValidUrl">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
</button> </button>
</div> </div>
<div class="control" v-if="config.app.console_enabled"> <div class="control" v-if="config.app.console_enabled">
<button type="button" class="button is-success" @click="testDownloadOptions" <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> <span class="icon"><i class="fa-solid fa-flask" /></span>
</button> </button>
</div> </div>
@ -291,6 +299,7 @@ const testResultsData = ref<any>(null)
const dlFieldsExtra = ['--no-download-archive', '--no-continue'] const dlFieldsExtra = ['--no-download-archive', '--no-continue']
const ytDlpOpt = ref<AutoCompleteOptions>([]) const ytDlpOpt = ref<AutoCompleteOptions>([])
const cookiesDropzoneRef = ref<InstanceType<typeof TextDropzone> | null>(null) const cookiesDropzoneRef = ref<InstanceType<typeof TextDropzone> | null>(null)
const urlTextarea = ref<HTMLTextAreaElement | null>(null)
const form = useStorage<item_request>('local_config_v1', { const form = useStorage<item_request>('local_config_v1', {
id: null, id: null,
@ -304,14 +313,6 @@ const form = useStorage<item_request>('local_config_v1', {
}) as Ref<item_request> }) 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 => { const is_valid_dl_field = (dl_field: string): boolean => {
if (dlFieldsExtra.includes(dl_field)) { if (dlFieldsExtra.includes(dl_field)) {
return true return true
@ -324,6 +325,81 @@ const is_valid_dl_field = (dl_field: string): boolean => {
return false; 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 () => { const addDownload = async () => {
let form_cli = (form.value?.cli || '').trim() let form_cli = (form.value?.cli || '').trim()
@ -361,11 +437,7 @@ const addDownload = async () => {
const request_data = [] as Array<item_request> const request_data = [] as Array<item_request>
form.value.url.split(separator.value).forEach(async (url: string) => { splitUrls(form.value.url).forEach(async (url: string) => {
if (!url.trim()) {
return
}
const data = { const data = {
url: url, url: url,
preset: form.value.preset || config.app.default_preset, preset: form.value.preset || config.app.default_preset,
@ -441,7 +513,6 @@ const resetConfig = async () => {
dlFields.value = {} dlFields.value = {}
showAdvanced.value = false showAdvanced.value = false
toast.success('Local configuration has been reset.') toast.success('Local configuration has been reset.')
dialog_confirm.value.visible = false
} }
const convertOptions = async (args: string) => { const convertOptions = async (args: string) => {
@ -509,7 +580,11 @@ onMounted(async () => {
await nextTick() await nextTick()
if (!separators.some(s => s.value === separator.value)) { 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', { const resp = await request('/api/yt-dlp/command', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
url: form.value.url, url: splitUrls(form.value.url).join(' '),
preset: form.value.preset, preset: form.value.preset,
folder: form.value.folder, folder: form.value.folder,
cookies: form.value.cookies, cookies: form.value.cookies,
@ -657,6 +732,7 @@ const CloseTestResults = () => {
testResultsData.value = null 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 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) 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 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(() => { watch(isMultiLineInput, async newValue => {
if (!form.value.url) { await nextTick()
return false 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> </script>

View file

@ -39,15 +39,17 @@
<section class="card-content p-1 m-1"> <section class="card-content p-1 m-1">
<div class="field is-grouped"> <div class="field is-grouped">
<div class="control is-expanded"> <div class="control is-expanded">
<InputAutocomplete v-model="command" :options="ytDlpOptions" :disabled="isLoading" placeholder="--help" <TextareaAutocomplete v-if="isMultiLineInput" ref="commandTextarea" v-model="command"
id="command" @keydown.enter="runCommand" :multiple="true" :allowShortFlags="true" /> :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> </div>
<p class="control"> <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"> @click="runCommand">
<span class="icon"> <span class="icon">
<i class="fa-solid fa-spinner fa-spin" v-if="isLoading" /> <i class="fa-solid" :class="isLoading ? 'fa-spinner fa-spin' : 'fa-paper-plane'"/>
<i class="fa-solid fa-paper-plane" v-else />
</span> </span>
</button> </button>
</p> </p>
@ -65,6 +67,7 @@ import { FitAddon } from '@xterm/addon-fit'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { disableOpacity, enableOpacity } from '~/utils' import { disableOpacity, enableOpacity } from '~/utils'
import InputAutocomplete from '~/components/InputAutocomplete.vue' import InputAutocomplete from '~/components/InputAutocomplete.vue'
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
import type { AutoCompleteOptions } from '~/types/autocomplete' import type { AutoCompleteOptions } from '~/types/autocomplete'
const config = useConfigStore() const config = useConfigStore()
@ -75,6 +78,8 @@ const terminal = ref<Terminal>()
const terminalFit = ref<FitAddon>() const terminalFit = ref<FitAddon>()
const command = ref<string>('') const command = ref<string>('')
const terminal_window = useTemplateRef<HTMLDivElement>('terminal_window') 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 isLoading = ref<boolean>(false)
const storedCommand = useStorage<string>('console_command', '') const storedCommand = useStorage<string>('console_command', '')
@ -82,6 +87,9 @@ const ytDlpOptions = computed<AutoCompleteOptions>(() => config.ytdlp_options.fl
.map(flag => ({ value: flag, description: opt.description || '' })) .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 => { watch(() => isLoading.value, async value => {
if (value) { if (value) {
return return
@ -99,6 +107,61 @@ watch(() => config.app.console_enabled, async () => {
await navigateTo('/') 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 = () => { const handle_event = () => {
if (!terminal.value) { if (!terminal.value) {
return return
@ -135,7 +198,7 @@ const ensureTerminal = async () => {
} }
const runCommand = async () => { const runCommand = async () => {
if ('' === command.value) { if (!hasValidCommand.value) {
return return
} }
@ -145,22 +208,24 @@ const runCommand = async () => {
return return
} }
if (command.value.startsWith('yt-dlp')) { let cmd = command.value.trim().replace(/\n/g, ' ').trim()
command.value = command.value.replace(/^yt-dlp/, '').trim()
if (cmd.startsWith('yt-dlp')) {
cmd = cmd.replace(/^yt-dlp/, '').trim()
await nextTick() await nextTick()
if ('' === command.value) { if ('' === cmd) {
return return
} }
} }
await ensureTerminal() await ensureTerminal()
if ('clear' === command.value) { if ('clear' === cmd) {
clearOutput(true) clearOutput(true)
return return
} }
socket.emit('cli_post', command.value) socket.emit('cli_post', cmd)
isLoading.value = true isLoading.value = true
terminal.value?.writeln(`user@YTPTube ~`) terminal.value?.writeln(`user@YTPTube ~`)
terminal.value?.writeln(`$ yt-dlp ${command.value}`) terminal.value?.writeln(`$ yt-dlp ${command.value}`)
@ -179,25 +244,29 @@ const clearOutput = async (withCommand: boolean = false) => {
focusInput() focusInput()
} }
const focusInput = () => { const focusInput = async () => {
const inputElement = document.getElementById('command') as HTMLInputElement await nextTick()
if (inputElement) { let elm;
inputElement.focus() if (isMultiLineInput.value) {
elm = commandTextarea.value?.$el?.querySelector('textarea') as HTMLTextAreaElement
} else {
elm = commandInput.value?.$el?.querySelector('input') as HTMLInputElement
} }
elm?.focus()
} }
const writer = (s: string) => { const writer = (s: string) => {
if (!terminal.value) { if (!terminal.value) {
return return
} }
terminal.value.writeln(JSON.parse(s).data.line)
const json = JSON.parse(s)
terminal.value.writeln(json.data.line)
} }
const loader = () => isLoading.value = false const loader = () => isLoading.value = false
watch(isMultiLineInput, () => focusInput())
onMounted(async () => { onMounted(async () => {
document.addEventListener('resize', handle_event); document.addEventListener('resize', handle_event);
focusInput() focusInput()
@ -213,7 +282,6 @@ onMounted(async () => {
if (storedCommand.value) { if (storedCommand.value) {
command.value = storedCommand.value command.value = storedCommand.value
await nextTick() await nextTick()
runCommand()
} }
}) })

View file

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