448 lines
13 KiB
Vue
448 lines
13 KiB
Vue
<style>
|
|
.terminal {
|
|
padding-left: 10px;
|
|
}
|
|
|
|
.history-item {
|
|
cursor: pointer;
|
|
transition: background-color 0.2s;
|
|
}
|
|
|
|
.history-item:hover {
|
|
background-color: #f5f5f5;
|
|
}
|
|
</style>
|
|
|
|
<template>
|
|
<div class="mt-1 columns is-multiline">
|
|
<div class="column is-12 is-clearfix">
|
|
<h1 class="title is-4">
|
|
<span class="icon-text">
|
|
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
|
<span>Console</span>
|
|
</span>
|
|
</h1>
|
|
<div class="subtitle is-6 is-unselectable">
|
|
You can use this page to run yt-dlp commands directly in a non-interactive way, bypassing the web interface and
|
|
it's settings.
|
|
</div>
|
|
</div>
|
|
<div class="column is-12">
|
|
<div class="card">
|
|
<header class="card-header">
|
|
<p class="card-header-title">
|
|
<span class="icon">
|
|
<i class="fa-solid fa-desktop" />
|
|
</span>
|
|
<span class="ml-2">Output</span>
|
|
</p>
|
|
<p class="card-header-icon">
|
|
<span v-tooltip.top="'Clear console window'" class="icon" @click="clearOutput()">
|
|
<i class="fa-solid fa-broom" />
|
|
</span>
|
|
</p>
|
|
</header>
|
|
<section class="card-content p-0 m-0">
|
|
<div ref="terminal_window" style="min-height: 60vh;max-height:70vh;" />
|
|
</section>
|
|
<section class="card-content p-1 m-1">
|
|
<div class="field is-grouped">
|
|
<div class="control is-expanded">
|
|
<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 || !hasValidCommand"
|
|
@click="runCommand">
|
|
<span class="icon">
|
|
<i class="fa-solid" :class="isLoading ? 'fa-spinner fa-spin' : 'fa-paper-plane'" />
|
|
</span>
|
|
</button>
|
|
</p>
|
|
</div>
|
|
</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 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>
|
|
|
|
<script setup lang="ts">
|
|
import { fetchEventSource } from '@microsoft/fetch-event-source'
|
|
import type { EventSourceMessage } from '@microsoft/fetch-event-source'
|
|
import '@xterm/xterm/css/xterm.css'
|
|
import { Terminal } from '@xterm/xterm'
|
|
import { FitAddon } from '@xterm/addon-fit'
|
|
import { useStorage } from '@vueuse/core'
|
|
import { disableOpacity, enableOpacity, parse_api_error, uri } 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 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 sseController = ref<AbortController | null>(null)
|
|
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()
|
|
}, { immediate: true })
|
|
|
|
watch(() => config.app.console_enabled, async () => {
|
|
if (config.app.console_enabled) {
|
|
return
|
|
}
|
|
toast.error('Console is disabled in the configuration. Please enable it to use this feature.')
|
|
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
|
|
}
|
|
terminalFit.value?.fit()
|
|
}
|
|
|
|
const handleStreamMessage = (event: EventSourceMessage) => {
|
|
if (!terminal.value) {
|
|
return
|
|
}
|
|
|
|
let payload: { type?: string; line?: string; exitcode?: number } | null = null
|
|
if (event.data) {
|
|
try {
|
|
payload = JSON.parse(event.data) as { type?: string; line?: string; exitcode?: number }
|
|
} catch {
|
|
payload = null
|
|
}
|
|
}
|
|
|
|
if ('output' === event.event) {
|
|
terminal.value.writeln(payload?.line ?? '')
|
|
return
|
|
}
|
|
|
|
if ('close' === event.event) {
|
|
isLoading.value = false
|
|
sseController.value?.abort()
|
|
}
|
|
}
|
|
|
|
const startStream = async (cmd: string) => {
|
|
sseController.value?.abort()
|
|
const controller = new AbortController()
|
|
sseController.value = controller
|
|
isLoading.value = true
|
|
|
|
try {
|
|
await fetchEventSource(uri('/api/system/terminal'), {
|
|
method: 'POST',
|
|
body: JSON.stringify({ command: cmd }),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'text/event-stream',
|
|
},
|
|
credentials: 'same-origin',
|
|
signal: controller.signal,
|
|
onopen: async (response) => {
|
|
if (response.ok) {
|
|
return
|
|
}
|
|
let message = response.statusText || 'Failed to start command stream.'
|
|
try {
|
|
message = await parse_api_error(response.clone().json())
|
|
} catch {
|
|
try {
|
|
const text = await response.text()
|
|
if (text) {
|
|
message = text
|
|
}
|
|
} catch {
|
|
message = response.statusText || 'Failed to start command stream.'
|
|
}
|
|
}
|
|
throw new Error(message)
|
|
},
|
|
onmessage: handleStreamMessage,
|
|
onerror: (error) => {
|
|
if (controller.signal.aborted) {
|
|
return
|
|
}
|
|
terminal.value?.writeln(`Error: ${error}`)
|
|
isLoading.value = false
|
|
},
|
|
})
|
|
} catch (error) {
|
|
if (!controller.signal.aborted) {
|
|
terminal.value?.writeln(`Error: ${error}`)
|
|
isLoading.value = false
|
|
}
|
|
} finally {
|
|
if (controller === sseController.value) {
|
|
sseController.value = null
|
|
}
|
|
}
|
|
}
|
|
|
|
const ensureTerminal = async () => {
|
|
if (terminal.value) {
|
|
return
|
|
}
|
|
|
|
terminal.value = new Terminal({
|
|
fontSize: 14,
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
cursorBlink: false,
|
|
cursorStyle: 'underline',
|
|
cols: 108,
|
|
rows: 10,
|
|
disableStdin: true,
|
|
scrollback: 1000,
|
|
})
|
|
|
|
terminalFit.value = new FitAddon()
|
|
terminal.value.loadAddon(terminalFit.value)
|
|
|
|
await nextTick()
|
|
|
|
if (terminal_window.value) {
|
|
terminal.value.open(terminal_window.value)
|
|
}
|
|
|
|
terminalFit.value.fit();
|
|
}
|
|
|
|
const runCommand = async () => {
|
|
if (!hasValidCommand.value) {
|
|
return
|
|
}
|
|
|
|
if (true !== config.app.console_enabled) {
|
|
await navigateTo('/')
|
|
toast.error('Console is disabled in the configuration. Please enable it to use this feature.')
|
|
return
|
|
}
|
|
|
|
let cmd = command.value.trim().replace(/\n/g, ' ').trim()
|
|
|
|
if (cmd.startsWith('yt-dlp')) {
|
|
cmd = cmd.replace(/^yt-dlp/, '').trim()
|
|
await nextTick()
|
|
if ('' === cmd) {
|
|
return
|
|
}
|
|
}
|
|
|
|
await ensureTerminal()
|
|
|
|
if ('clear' === cmd) {
|
|
clearOutput(true)
|
|
return
|
|
}
|
|
|
|
await startStream(cmd)
|
|
terminal.value?.writeln(`user@YTPTube ~`)
|
|
terminal.value?.writeln(`$ yt-dlp ${command.value}`)
|
|
storedCommand.value = ''
|
|
}
|
|
|
|
const clearOutput = async (withCommand: boolean = false) => {
|
|
if (terminal.value) {
|
|
terminal.value.clear()
|
|
}
|
|
|
|
if (true === withCommand) {
|
|
command.value = ''
|
|
}
|
|
|
|
focusInput()
|
|
}
|
|
|
|
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)
|
|
|
|
watch(isMultiLineInput, () => focusInput())
|
|
|
|
onMounted(async () => {
|
|
document.addEventListener('resize', handle_event);
|
|
focusInput()
|
|
|
|
disableOpacity()
|
|
|
|
await ensureTerminal()
|
|
|
|
if (storedCommand.value) {
|
|
command.value = storedCommand.value
|
|
await nextTick()
|
|
}
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
sseController.value?.abort()
|
|
document.removeEventListener('resize', handle_event)
|
|
enableOpacity()
|
|
});
|
|
</script>
|