[FEAT] add option to see the compiled yt-dlp options

This commit is contained in:
arabcoders 2025-11-08 02:07:37 +03:00
parent b939aad1db
commit 058bf567d1
5 changed files with 225 additions and 32 deletions

View file

@ -1,6 +1,7 @@
import logging
import shlex
from pathlib import Path
from typing import Any
from .config import Config
from .Presets import Preset, Presets
@ -28,7 +29,7 @@ class ARGSMerger:
if not args or not isinstance(args, str) or len(args) < 2:
return self
_args = shlex.split(args)
_args: list[str] = shlex.split(args)
if len(_args) > 0:
self.args.extend(_args)
@ -124,8 +125,8 @@ class YTDLPCli:
raise ValueError(msg)
self.item = item
self.preset = item.get_preset()
self._config = config or Config.get_instance()
self.preset: Preset = item.get_preset()
self._config: Config = config or Config.get_instance()
def build(self) -> tuple[str, dict]:
"""
@ -138,7 +139,7 @@ class YTDLPCli:
template: str | None = None
save_path: str | None = None
cookie_file: Path | None = None
cli_args = ARGSMerger.get_instance()
cli_args: ARGSMerger = ARGSMerger.get_instance()
if self.item.cookies:
cookie_file = create_cookies_file(self.item.cookies)
@ -160,10 +161,10 @@ class YTDLPCli:
template = self.preset.template
if self.preset.cli:
cli_args.add(self.preset.cli)
cli_args.add(self._replace_vars(self.preset.cli))
if self.item.cli:
cli_args.add(self.item.cli)
cli_args.add(self._replace_vars(self.item.cli))
if not save_path:
save_path = self._config.download_path
@ -172,24 +173,22 @@ class YTDLPCli:
template = self._config.output_template
if cookie_file:
cli_args.add(f'--cookies "{cookie_file!s}"')
cli_args.add(self._replace_vars(f'--cookies "{cookie_file!s}"'))
if template:
cli_args.add(f'--output "{template}"')
cli_args.add(self._replace_vars(f'--output "{template}"'))
if save_path:
cli_args.add(f'--paths "home:{save_path}"')
cli_args.add(self._replace_vars(f'--paths "home:{save_path}"'))
cli_args.add(f'--paths "temp:{self._config.temp_path}"')
cli_args.add(self._replace_vars(f'--paths "temp:{self._config.temp_path}"'))
if self.item.url:
cli_args.add(self.item.url)
command = str(cli_args)
for k, v in self._config.get_replacers().items():
command = command.replace(f"%({k})s", v if isinstance(v, str) else str(v))
command: str = self._replace_vars(str(cli_args))
info = {
info: dict[str, Any] = {
"command": command,
"dict": cli_args.as_dict(),
"ytdlp": cli_args.as_ytdlp(),
@ -202,6 +201,22 @@ class YTDLPCli:
return command, info
def _replace_vars(self, text: str) -> str:
"""
Replace variables in the given text.
Args:
text (str): The text to replace variables in
Returns:
str: The text with variables replaced
"""
for k, v in self._config.get_replacers().items():
text: str = text.replace(f"%({k})s", v if isinstance(v, str) else str(v))
return text
class YTDLPOpts:
def __init__(self):

View file

@ -9,6 +9,7 @@ from aiohttp.web import Request, Response
from app.library.cache import Cache
from app.library.config import Config
from app.library.encoder import Encoder
from app.library.ItemDTO import Item
from app.library.Presets import Presets
from app.library.router import route
@ -276,13 +277,14 @@ async def get_archive_ids(request: Request, config: Config) -> Response:
@route("POST", "api/yt-dlp/command/", "make_command")
async def make_command(request: Request, config: Config) -> Response:
async def make_command(request: Request, config: Config, encoder: Encoder) -> Response:
"""
Build yt-dlp CLI command.
Args:
request (Request): The request object.
config (Config): The config instance.
encoder (Encoder): The encoder instance.
Returns:
Response: The response object with the merged fields and final yt-dlp CLI command string.
@ -304,7 +306,7 @@ async def make_command(request: Request, config: Config) -> Response:
return web.json_response(data={"error": str(e), "data": data}, status=web.HTTPBadRequest.status_code)
try:
command, _ = YTDLPCli(item=it, config=config).build()
command, info = YTDLPCli(item=it, config=config).build()
except Exception as e:
LOG.exception(e)
return web.json_response(
@ -312,4 +314,7 @@ async def make_command(request: Request, config: Config) -> Response:
status=web.HTTPBadRequest.status_code,
)
if request.query.get("full", False):
return web.json_response(data=info, status=web.HTTPOk.status_code, dumps=encoder.encode)
return web.json_response(data={"command": command}, status=web.HTTPOk.status_code)

View file

@ -289,6 +289,10 @@ hr {
white-space: pre-wrap;
}
.is-pre-wrap-force {
white-space: pre-wrap !important;
}
.has-text-bold {
font-weight: bold;
}
@ -370,4 +374,3 @@ div.is-centered {
padding: 0;
margin: 0 auto;
}

View file

@ -0,0 +1,85 @@
<style scoped>
code {
color: var(--bulma-code) !important
}
</style>
<template>
<div>
<div class="modal is-active" v-if="false === externalModel">
<div class="modal-background" @click="emitter('closeModel')"></div>
<div class="modal-content modal-content-max">
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading">
<i class="fas fa-circle-notch fa-spin" />
</div>
<div v-else>
<div class="content p-0 m-0" style="position: relative">
<pre :class="[code_classes, custom_classes]"><code class="p-4 is-block" v-text="data" />
<div class="m-4 is-flex" style="position: absolute; top:0; right:0;">
<button class="button is-small is-purple mr-3" @click="() => toggleClass('is-pre-wrap-force')">
<span class="icon"><i class="fas fa-text-width" /></span>
</button>
<button class="button is-info is-small" @click="() => copyText(JSON.stringify(data, null, 2))" >
<span class="icon"><i class="fas fa-copy" /></span>
</button>
</div>
</pre>
</div>
</div>
</div>
<button class="modal-close is-large" aria-label="close" @click="emitter('closeModel')"></button>
</div>
<div class="modal-content-max" style="height: 80vh;" v-else>
<div class="content p-0 m-0" style="position: relative">
<pre :class="[code_classes, custom_classes]"><code class="p-4 is-block" v-text="data" /></pre>
<div class="m-4 is-flex" style="position: absolute; top:0; right:0;">
<button class="button is-small is-purple mr-3" @click="() => toggleClass('is-pre-wrap-force')">
<span class="icon"><i class="fas fa-text-width" /></span>
</button>
<button class="button is-info is-small" @click="() => copyText(JSON.stringify(data, null, 2))">
<span class="icon"><i class="fas fa-copy" /></span>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useStorage } from '@vueuse/core'
import { disableOpacity, enableOpacity } from '~/utils';
const emitter = defineEmits<{ (e: 'closeModel'): void }>()
withDefaults(defineProps<{ externalModel?: boolean, data: any, code_classes?: string, isLoading?: boolean }>(), {
code_classes: '',
isLoading: false,
externalModel: false,
})
const custom_classes = useStorage<string>('modal_text_classes', '')
const handle_event = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
emitter('closeModel')
}
}
onMounted(async (): Promise<void> => {
disableOpacity()
document.addEventListener('keydown', handle_event)
})
onBeforeUnmount(() => {
enableOpacity()
document.removeEventListener('keydown', handle_event)
})
const toggleClass = (className: string) => {
if (custom_classes.value.includes(className)) {
custom_classes.value = custom_classes.value.replace(className, '').trim()
} else {
custom_classes.value += ` ${className}`
}
}
</script>

View file

@ -15,8 +15,7 @@
:disabled="!socket.isConnected || addInProgress" v-model="form.url">
</div>
<div class="control">
<button type="submit" class="button is-primary"
:class="{ 'is-loading': !socket.isConnected || addInProgress }"
<button type="submit" class="button is-primary" :class="{ 'is-loading': addInProgress }"
:disabled="!socket.isConnected || addInProgress || !form?.url">
<span class="icon"><i class="fa-solid fa-plus" /></span>
<span>Add</span>
@ -72,7 +71,7 @@
<div class="column">
<button type="button" class="button is-info" @click="showAdvanced = !showAdvanced"
:class="{ 'is-loading': !socket.isConnected }" :disabled="!socket.isConnected">
:disabled="!socket.isConnected">
<span class="icon"><i class="fa-solid fa-cog" /></span>
<span>Advanced Options</span>
</button>
@ -178,6 +177,11 @@
<span>Run CLI</span>
</NuxtLink>
<NuxtLink class="dropdown-item" @click="testDownloadOptions" v-if="config.app.console_enabled">
<span class="icon has-text-success"><i class="fa-solid fa-flask" /></span>
<span>Show compiled yt-dlp options</span>
</NuxtLink>
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="resetConfig">
<span class="icon has-text-danger"><i class="fa-solid fa-rotate-left" /></span>
@ -189,26 +193,31 @@
<div class="field is-grouped is-justify-self-end is-hidden-mobile">
<div class="control">
<button type="button" class="button is-purple" @click="() => showFields = true"
:class="{ 'is-loading': !socket.isConnected }" :disabled="!socket.isConnected">
:disabled="!socket.isConnected" v-tooltip="'Manage custom fields'">
<span class="icon"><i class="fa-solid fa-plus" /></span>
<span>Custom Fields</span>
</button>
</div>
<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">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
</button>
</div>
<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)"
:class="{ 'is-loading': !socket.isConnected }"
:disabled="!socket.isConnected || addInProgress || !form?.url">
:disabled="!socket.isConnected || addInProgress || !form?.url || multiURLs">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Information</span>
</button>
</div>
<div class="control" v-if="config.app.console_enabled">
<button type="button" class="button is-warning" @click="runCliCommand"
:disabled="!socket.isConnected || !form?.url">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>Run CLI</span>
<button type="button" class="button is-success" @click="testDownloadOptions"
:disabled="!socket.isConnected || !form?.url" v-tooltip="'Show compiled yt-dlp options.'">
<span class="icon"><i class="fa-solid fa-flask" /></span>
</button>
</div>
@ -216,7 +225,6 @@
<button type="button" class="button is-danger" @click="resetConfig"
:disabled="!!(!socket.isConnected || form?.id)" v-tooltip="'Reset local settings'">
<span class="icon"><i class="fa-solid fa-rotate-left" /></span>
<span>Reset</span>
</button>
</div>
@ -234,6 +242,8 @@
<Modal v-if="showOptions" @close="showOptions = false" :contentClass="'modal-content-max'">
<YTDLPOptions />
</Modal>
<ModalText v-if="showTestResults" @closeModel="CloseTestResults" :data="testResultsData" />
</main>
</template>
@ -266,6 +276,8 @@ const storedCommand = useStorage<string>('console_command', '')
const addInProgress = ref<boolean>(false)
const showFields = ref<boolean>(false)
const showOptions = ref<boolean>(false)
const showTestResults = ref<boolean>(false)
const testResultsData = ref<any>(null)
const dlFieldsExtra = ['--no-download-archive']
const ytDlpOpt = ref<AutoCompleteOptions>([])
@ -496,10 +508,9 @@ const runCliCommand = async (): Promise<void> => {
return
}
const {status} = await dialog.confirmDialog({
const { status } = await dialog.confirmDialog({
title: 'Run CLI Command',
message: `This will generate a yt-dlp command and run it in the console. Continue?`,
confirmColor: 'is-warning',
})
if (!status) {
@ -568,6 +579,73 @@ const runCliCommand = async (): Promise<void> => {
}
}
const testDownloadOptions = async (): Promise<void> => {
if (!form.value.url) {
toast.warning('Please enter a URL first')
return
}
let form_cli = (form.value?.cli || '').trim()
if (dlFields.value && Object.keys(dlFields.value).length > 0) {
const joined = []
for (const [key, value] of Object.entries(dlFields.value)) {
if (false === is_valid_dl_field(key)) {
continue
}
if ([undefined, null, '', false].includes(value as any)) {
continue
}
const keyRegex = new RegExp(`(^|\\s)${key}(\\s|$)`);
if (form_cli && keyRegex.test(form_cli)) {
continue;
}
joined.push(true === value ? `${key}` : `${key} ${value}`)
}
if (joined.length > 0) {
form_cli = form_cli ? `${form_cli} ${joined.join(' ')}` : joined.join(' ')
}
}
try {
const resp = await request('/api/yt-dlp/command?full=true', {
method: 'POST',
body: JSON.stringify({
url: form.value.url,
preset: form.value.preset,
folder: form.value.folder,
cookies: form.value.cookies,
template: form.value.template,
cli: form_cli,
})
})
const json = await resp.json()
if (!resp.ok) {
toast.error(`Error: ${json.error || 'Failed to generate command.'}`)
return
}
testResultsData.value = {
command: json.command,
yt_dlp: json.ytdlp,
}
showTestResults.value = true
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to test download options')
}
}
const CloseTestResults = () => {
showTestResults.value = false
testResultsData.value = null
}
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)
@ -606,4 +684,11 @@ const getDefault = (type: 'cookies' | 'cli' | 'template' | 'folder', ret: string
// 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 multiURLs = computed(() => {
if (!form.value.url) {
return false
}
return form.value.url.split(separator.value).filter((u: string) => u.trim()).length > 1
})
</script>