[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 logging
import shlex import shlex
from pathlib import Path from pathlib import Path
from typing import Any
from .config import Config from .config import Config
from .Presets import Preset, Presets from .Presets import Preset, Presets
@ -28,7 +29,7 @@ class ARGSMerger:
if not args or not isinstance(args, str) or len(args) < 2: if not args or not isinstance(args, str) or len(args) < 2:
return self return self
_args = shlex.split(args) _args: list[str] = shlex.split(args)
if len(_args) > 0: if len(_args) > 0:
self.args.extend(_args) self.args.extend(_args)
@ -124,8 +125,8 @@ class YTDLPCli:
raise ValueError(msg) raise ValueError(msg)
self.item = item self.item = item
self.preset = item.get_preset() self.preset: Preset = item.get_preset()
self._config = config or Config.get_instance() self._config: Config = config or Config.get_instance()
def build(self) -> tuple[str, dict]: def build(self) -> tuple[str, dict]:
""" """
@ -138,7 +139,7 @@ class YTDLPCli:
template: str | None = None template: str | None = None
save_path: str | None = None save_path: str | None = None
cookie_file: Path | None = None cookie_file: Path | None = None
cli_args = ARGSMerger.get_instance() cli_args: ARGSMerger = ARGSMerger.get_instance()
if self.item.cookies: if self.item.cookies:
cookie_file = create_cookies_file(self.item.cookies) cookie_file = create_cookies_file(self.item.cookies)
@ -160,10 +161,10 @@ class YTDLPCli:
template = self.preset.template template = self.preset.template
if self.preset.cli: if self.preset.cli:
cli_args.add(self.preset.cli) cli_args.add(self._replace_vars(self.preset.cli))
if self.item.cli: if self.item.cli:
cli_args.add(self.item.cli) cli_args.add(self._replace_vars(self.item.cli))
if not save_path: if not save_path:
save_path = self._config.download_path save_path = self._config.download_path
@ -172,24 +173,22 @@ class YTDLPCli:
template = self._config.output_template template = self._config.output_template
if cookie_file: if cookie_file:
cli_args.add(f'--cookies "{cookie_file!s}"') cli_args.add(self._replace_vars(f'--cookies "{cookie_file!s}"'))
if template: if template:
cli_args.add(f'--output "{template}"') cli_args.add(self._replace_vars(f'--output "{template}"'))
if save_path: 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: if self.item.url:
cli_args.add(self.item.url) cli_args.add(self.item.url)
command = str(cli_args) command: str = self._replace_vars(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))
info = { info: dict[str, Any] = {
"command": command, "command": command,
"dict": cli_args.as_dict(), "dict": cli_args.as_dict(),
"ytdlp": cli_args.as_ytdlp(), "ytdlp": cli_args.as_ytdlp(),
@ -202,6 +201,22 @@ class YTDLPCli:
return command, info 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: class YTDLPOpts:
def __init__(self): def __init__(self):

View file

@ -9,6 +9,7 @@ from aiohttp.web import Request, Response
from app.library.cache import Cache from app.library.cache import Cache
from app.library.config import Config from app.library.config import Config
from app.library.encoder import Encoder
from app.library.ItemDTO import Item from app.library.ItemDTO import Item
from app.library.Presets import Presets from app.library.Presets import Presets
from app.library.router import route 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") @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. Build yt-dlp CLI command.
Args: Args:
request (Request): The request object. request (Request): The request object.
config (Config): The config instance. config (Config): The config instance.
encoder (Encoder): The encoder instance.
Returns: Returns:
Response: The response object with the merged fields and final yt-dlp CLI command string. 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) return web.json_response(data={"error": str(e), "data": data}, status=web.HTTPBadRequest.status_code)
try: try:
command, _ = YTDLPCli(item=it, config=config).build() command, info = YTDLPCli(item=it, config=config).build()
except Exception as e: except Exception as e:
LOG.exception(e) LOG.exception(e)
return web.json_response( return web.json_response(
@ -312,4 +314,7 @@ async def make_command(request: Request, config: Config) -> Response:
status=web.HTTPBadRequest.status_code, 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) return web.json_response(data={"command": command}, status=web.HTTPOk.status_code)

View file

@ -289,6 +289,10 @@ hr {
white-space: pre-wrap; white-space: pre-wrap;
} }
.is-pre-wrap-force {
white-space: pre-wrap !important;
}
.has-text-bold { .has-text-bold {
font-weight: bold; font-weight: bold;
} }
@ -370,4 +374,3 @@ div.is-centered {
padding: 0; padding: 0;
margin: 0 auto; 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"> :disabled="!socket.isConnected || addInProgress" v-model="form.url">
</div> </div>
<div class="control"> <div class="control">
<button type="submit" class="button is-primary" <button type="submit" class="button is-primary" :class="{ 'is-loading': addInProgress }"
:class="{ 'is-loading': !socket.isConnected || addInProgress }"
:disabled="!socket.isConnected || addInProgress || !form?.url"> :disabled="!socket.isConnected || addInProgress || !form?.url">
<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>
@ -72,7 +71,7 @@
<div class="column"> <div class="column">
<button type="button" class="button is-info" @click="showAdvanced = !showAdvanced" <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 class="icon"><i class="fa-solid fa-cog" /></span>
<span>Advanced Options</span> <span>Advanced Options</span>
</button> </button>
@ -178,6 +177,11 @@
<span>Run CLI</span> <span>Run CLI</span>
</NuxtLink> </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" /> <hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="resetConfig"> <NuxtLink class="dropdown-item" @click="resetConfig">
<span class="icon has-text-danger"><i class="fa-solid fa-rotate-left" /></span> <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="field is-grouped is-justify-self-end is-hidden-mobile">
<div class="control"> <div class="control">
<button type="button" class="button is-purple" @click="() => showFields = true" <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 class="icon"><i class="fa-solid fa-plus" /></span>
<span>Custom Fields</span>
</button> </button>
</div> </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"> <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.'"
@click="emitter('getInfo', form.url, form.preset, form.cli)" @click="emitter('getInfo', form.url, form.preset, form.cli)"
:class="{ 'is-loading': !socket.isConnected }" :disabled="!socket.isConnected || addInProgress || !form?.url || multiURLs">
:disabled="!socket.isConnected || addInProgress || !form?.url">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>Information</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-warning" @click="runCliCommand" <button type="button" class="button is-success" @click="testDownloadOptions"
:disabled="!socket.isConnected || !form?.url"> :disabled="!socket.isConnected || !form?.url" v-tooltip="'Show compiled yt-dlp options.'">
<span class="icon"><i class="fa-solid fa-terminal" /></span> <span class="icon"><i class="fa-solid fa-flask" /></span>
<span>Run CLI</span>
</button> </button>
</div> </div>
@ -216,7 +225,6 @@
<button type="button" class="button is-danger" @click="resetConfig" <button type="button" class="button is-danger" @click="resetConfig"
:disabled="!!(!socket.isConnected || form?.id)" v-tooltip="'Reset local settings'"> :disabled="!!(!socket.isConnected || form?.id)" v-tooltip="'Reset local settings'">
<span class="icon"><i class="fa-solid fa-rotate-left" /></span> <span class="icon"><i class="fa-solid fa-rotate-left" /></span>
<span>Reset</span>
</button> </button>
</div> </div>
@ -234,6 +242,8 @@
<Modal v-if="showOptions" @close="showOptions = false" :contentClass="'modal-content-max'"> <Modal v-if="showOptions" @close="showOptions = false" :contentClass="'modal-content-max'">
<YTDLPOptions /> <YTDLPOptions />
</Modal> </Modal>
<ModalText v-if="showTestResults" @closeModel="CloseTestResults" :data="testResultsData" />
</main> </main>
</template> </template>
@ -266,6 +276,8 @@ const storedCommand = useStorage<string>('console_command', '')
const addInProgress = ref<boolean>(false) const addInProgress = ref<boolean>(false)
const showFields = ref<boolean>(false) const showFields = ref<boolean>(false)
const showOptions = ref<boolean>(false) const showOptions = ref<boolean>(false)
const showTestResults = ref<boolean>(false)
const testResultsData = ref<any>(null)
const dlFieldsExtra = ['--no-download-archive'] const dlFieldsExtra = ['--no-download-archive']
const ytDlpOpt = ref<AutoCompleteOptions>([]) const ytDlpOpt = ref<AutoCompleteOptions>([])
@ -496,10 +508,9 @@ const runCliCommand = async (): Promise<void> => {
return return
} }
const {status} = await dialog.confirmDialog({ const { status } = await dialog.confirmDialog({
title: 'Run CLI Command', title: 'Run CLI Command',
message: `This will generate a yt-dlp command and run it in the console. Continue?`, message: `This will generate a yt-dlp command and run it in the console. Continue?`,
confirmColor: 'is-warning',
}) })
if (!status) { 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 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)
@ -606,4 +684,11 @@ const getDefault = (type: 'cookies' | 'cli' | 'template' | 'folder', ret: string
// eslint-disable-next-line vue/no-side-effects-in-computed-properties // 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 multiURLs = computed(() => {
if (!form.value.url) {
return false
}
return form.value.url.split(separator.value).filter((u: string) => u.trim()).length > 1
})
</script> </script>