1323 lines
39 KiB
Vue
1323 lines
39 KiB
Vue
<template>
|
|
<main class="space-y-4">
|
|
<form autocomplete="off" class="space-y-4" @submit.prevent="addDownload">
|
|
<UPageCard variant="outline" :ui="downloadCardUi">
|
|
<template #body>
|
|
<div class="space-y-4">
|
|
<UFormField class="w-full" :ui="downloadFieldUi">
|
|
<template #label>
|
|
<span class="inline-flex items-center gap-2 font-semibold">
|
|
<UTooltip text="Use Shift+Enter to switch to multiline input mode.">
|
|
<UIcon name="i-lucide-link" class="size-4 text-toned" />
|
|
</UTooltip>
|
|
<span>
|
|
URLs separated by newlines or
|
|
<span class="font-semibold lowercase">{{ getSeparatorsName(separator) }}</span>
|
|
</span>
|
|
</span>
|
|
</template>
|
|
|
|
<div class="flex flex-row gap-2 items-start">
|
|
<div class="min-w-0 flex-1">
|
|
<UTextarea
|
|
v-if="isMultiLineInput"
|
|
ref="urlTextarea"
|
|
id="url"
|
|
v-model="form.url"
|
|
:disabled="addInProgress"
|
|
size="lg"
|
|
variant="outline"
|
|
color="neutral"
|
|
class="w-full"
|
|
:rows="3"
|
|
:maxrows="12"
|
|
autoresize
|
|
:ui="{
|
|
root: 'w-full',
|
|
base: 'min-h-[7.25rem] bg-elevated/60 ring-default focus-visible:ring-primary',
|
|
}"
|
|
@keydown="handleKeyDown"
|
|
autofocus
|
|
/>
|
|
<UInput
|
|
v-else
|
|
id="url"
|
|
v-model="form.url"
|
|
type="text"
|
|
placeholder="URLs to download"
|
|
:disabled="addInProgress"
|
|
size="lg"
|
|
variant="outline"
|
|
color="neutral"
|
|
class="w-full"
|
|
:ui="{
|
|
root: 'w-full',
|
|
base: 'bg-elevated/60 ring-default focus-visible:ring-primary',
|
|
}"
|
|
@keydown="handleKeyDown"
|
|
@paste="handlePaste"
|
|
autofocus
|
|
/>
|
|
</div>
|
|
|
|
<UButton
|
|
type="submit"
|
|
color="primary"
|
|
icon="i-lucide-plus"
|
|
:loading="addInProgress"
|
|
:disabled="addInProgress || !hasValidUrl"
|
|
size="lg"
|
|
class="justify-center sm:min-w-28"
|
|
>
|
|
Add
|
|
</UButton>
|
|
</div>
|
|
</UFormField>
|
|
|
|
<div
|
|
class="grid gap-4 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.95fr)_11rem] xl:items-end"
|
|
>
|
|
<UFormField
|
|
class="min-w-0 w-full"
|
|
label="Preset"
|
|
:ui="downloadFieldUi"
|
|
:description="
|
|
hasFormatInConfig
|
|
? 'Presets are disabled. Format key is present in the command options for yt-dlp.'
|
|
: 'Prefill saved yt-dlp command options.'
|
|
"
|
|
>
|
|
<template #label>
|
|
<span class="inline-flex items-center gap-2 font-semibold">
|
|
<UIcon name="i-lucide-sliders-horizontal" class="size-4 text-toned" />
|
|
<span>Preset</span>
|
|
</span>
|
|
</template>
|
|
|
|
<div class="flex w-full gap-2">
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-info"
|
|
square
|
|
class="shrink-0"
|
|
@click="show_description = !show_description"
|
|
/>
|
|
|
|
<div class="min-w-0 flex-1">
|
|
<USelectMenu
|
|
id="preset"
|
|
v-model="form.preset"
|
|
:items="presetItems"
|
|
value-key="value"
|
|
label-key="label"
|
|
color="neutral"
|
|
class="w-full"
|
|
size="lg"
|
|
:ui="{ content: 'min-w-[13rem]', item: 'pl-6' }"
|
|
:search-input="{ placeholder: 'Search presets' }"
|
|
:disabled="addInProgress || hasFormatInConfig"
|
|
placeholder="Select preset"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</UFormField>
|
|
|
|
<UFormField
|
|
class="min-w-0 w-full"
|
|
label="Download path"
|
|
:ui="downloadFieldUi"
|
|
:description="`Relative to ${config.app.download_path}`"
|
|
>
|
|
<template #label>
|
|
<span class="inline-flex items-center gap-2 font-semibold">
|
|
<UIcon name="i-lucide-folder-output" class="size-4 text-toned" />
|
|
<span>Download path</span>
|
|
</span>
|
|
</template>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<span
|
|
class="max-w-44 shrink-0 truncate rounded-md border border-default bg-muted/40 px-3 py-2 text-xs text-toned"
|
|
:title="config.app.download_path"
|
|
>
|
|
{{ shortPath(config.app.download_path) }}
|
|
</span>
|
|
|
|
<UInput
|
|
id="folder"
|
|
v-model="form.folder"
|
|
:placeholder="getDefault('folder', '/')"
|
|
:disabled="addInProgress"
|
|
list="folders"
|
|
class="w-full"
|
|
size="lg"
|
|
:ui="{ root: 'w-full', base: 'bg-default/90' }"
|
|
/>
|
|
</div>
|
|
</UFormField>
|
|
|
|
<div class="flex w-full items-end xl:col-span-1">
|
|
<UButton
|
|
type="button"
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-settings-2"
|
|
size="lg"
|
|
class="w-full justify-center"
|
|
@click="showAdvanced = !showAdvanced"
|
|
>
|
|
{{ showAdvanced ? 'Hide Options' : 'Show Options' }}
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="show_description && !hasFormatInConfig && findPreset(form.preset)?.description"
|
|
class="max-h-36 overflow-auto rounded-md border border-default bg-muted/30 px-3 py-2 text-sm text-toned"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="block w-full cursor-pointer text-left"
|
|
@click="expand_description"
|
|
>
|
|
<span class="inline-flex items-start gap-2">
|
|
<UIcon name="i-lucide-info" class="mt-0.5 size-4 shrink-0 text-info" />
|
|
<span class="is-ellipsis">{{ findPreset(form.preset)?.description }}</span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</UPageCard>
|
|
|
|
<UPageCard v-if="showAdvanced" variant="outline" :ui="downloadCardUi">
|
|
<template #body>
|
|
<div class="space-y-4">
|
|
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-12">
|
|
<div class="xl:col-span-2">
|
|
<DLInput
|
|
id="force_download"
|
|
v-model="dlFields['--no-download-archive']"
|
|
type="bool"
|
|
label="Force download"
|
|
icon="i-lucide-download"
|
|
:disabled="addInProgress"
|
|
description="Ignore archive."
|
|
compact
|
|
/>
|
|
</div>
|
|
|
|
<div class="xl:col-span-2">
|
|
<DLInput
|
|
id="auto_start"
|
|
v-model="auto_start"
|
|
type="bool"
|
|
label="Auto start"
|
|
icon="i-lucide-play"
|
|
:disabled="addInProgress"
|
|
description="Download automatically."
|
|
compact
|
|
/>
|
|
</div>
|
|
|
|
<div class="xl:col-span-2">
|
|
<DLInput
|
|
id="no_cache"
|
|
v-model="dlFields['--no-continue']"
|
|
type="bool"
|
|
label="Bypass cache"
|
|
icon="i-lucide-eraser"
|
|
:disabled="addInProgress"
|
|
description="Remove temporary files."
|
|
compact
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
:class="
|
|
hasIgnoreConditionOptions
|
|
? 'md:col-span-2 xl:col-span-3'
|
|
: 'md:col-span-2 xl:col-span-6'
|
|
"
|
|
>
|
|
<DLInput
|
|
id="output_template"
|
|
v-model="form.template"
|
|
type="string"
|
|
label="Output template"
|
|
icon="i-lucide-file-code-2"
|
|
:disabled="addInProgress"
|
|
:placeholder="
|
|
getDefault('template', config.app.output_template || '%(title)s.%(ext)s')
|
|
"
|
|
>
|
|
<template #description>
|
|
<span>
|
|
See
|
|
<NuxtLink
|
|
target="_blank"
|
|
class="font-medium text-primary hover:underline"
|
|
to="https://github.com/yt-dlp/yt-dlp#output-template"
|
|
>
|
|
the yt-dlp output template page
|
|
</NuxtLink>
|
|
for info.
|
|
</span>
|
|
</template>
|
|
</DLInput>
|
|
</div>
|
|
|
|
<UFormField
|
|
v-if="hasIgnoreConditionOptions"
|
|
class="md:col-span-2 xl:col-span-3 w-full"
|
|
:ui="advancedEditorFieldUi"
|
|
>
|
|
<template #label>
|
|
<span class="inline-flex items-center gap-2 font-semibold">
|
|
<UIcon name="i-lucide-list-minus" class="size-4 text-toned" />
|
|
<span>Ignore conditions</span>
|
|
</span>
|
|
</template>
|
|
|
|
<template #description>
|
|
<span> Skip selected condition rules. </span>
|
|
</template>
|
|
|
|
<template #hint>
|
|
<button
|
|
v-if="selectedIgnoreConditions.length > 0"
|
|
type="button"
|
|
class="font-medium text-primary hover:underline"
|
|
@click="selectedIgnoreConditions = []"
|
|
>
|
|
Clear selection
|
|
</button>
|
|
</template>
|
|
|
|
<USelectMenu
|
|
v-model="selectedIgnoreConditions"
|
|
:items="ignoreConditionOptions"
|
|
value-key="value"
|
|
label-key="label"
|
|
multiple
|
|
placeholder="Select conditions to ignore"
|
|
class="w-full"
|
|
size="lg"
|
|
:disabled="addInProgress || conditions.isLoading.value"
|
|
:search-input="{ placeholder: 'Search conditions' }"
|
|
:ui="{ base: 'w-full', content: 'min-w-[18rem]' }"
|
|
/>
|
|
</UFormField>
|
|
</div>
|
|
|
|
<div class="grid gap-4 xl:grid-cols-2">
|
|
<UFormField
|
|
label="Command options for yt-dlp"
|
|
class="w-full"
|
|
:ui="advancedEditorFieldUi"
|
|
>
|
|
<template #label>
|
|
<span class="inline-flex items-center gap-2 font-semibold">
|
|
<UIcon name="i-lucide-terminal" class="size-4 text-toned" />
|
|
<span>Command options for yt-dlp</span>
|
|
</span>
|
|
</template>
|
|
|
|
<template #description>
|
|
<span>
|
|
<button
|
|
type="button"
|
|
class="font-medium text-primary hover:underline"
|
|
@click="showOptions = true"
|
|
>
|
|
View all options
|
|
</button>
|
|
. Not all options are supported;
|
|
<a
|
|
target="_blank"
|
|
href="https://github.com/arabcoders/ytptube/blob/master/app/features/ytdlp/utils.py#L29"
|
|
>
|
|
some are ignored
|
|
</a>
|
|
</span>
|
|
</template>
|
|
|
|
<TextareaAutocomplete
|
|
id="cli_options"
|
|
v-model="form.cli"
|
|
:options="ytDlpOpt"
|
|
:placeholder="getDefault('cli', '')"
|
|
:disabled="addInProgress"
|
|
:rows="5"
|
|
class="w-full"
|
|
/>
|
|
</UFormField>
|
|
|
|
<UFormField label="Cookies" class="w-full" :ui="advancedEditorFieldUi">
|
|
<template #label>
|
|
<span class="inline-flex items-center gap-2 font-semibold">
|
|
<UIcon name="i-lucide-cookie" class="size-4 text-toned" />
|
|
<span>Cookies</span>
|
|
</span>
|
|
</template>
|
|
|
|
<template #hint>
|
|
<button
|
|
type="button"
|
|
class="font-medium text-primary hover:underline"
|
|
@click="cookiesDropzoneRef?.triggerFileSelect()"
|
|
>
|
|
Upload file
|
|
</button>
|
|
</template>
|
|
<template #description>
|
|
<span>
|
|
Use the
|
|
<NuxtLink
|
|
target="_blank"
|
|
to="https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp"
|
|
>
|
|
recommended addon
|
|
</NuxtLink>
|
|
to export cookies.
|
|
<span class="text-error"
|
|
>The cookies MUST be in Netscape HTTP Cookie format.</span
|
|
>
|
|
</span>
|
|
</template>
|
|
|
|
<TextDropzone
|
|
ref="cookiesDropzoneRef"
|
|
id="ytdlpCookies"
|
|
v-model="form.cookies"
|
|
:disabled="addInProgress"
|
|
:rows="5"
|
|
:placeholder="
|
|
getDefault(
|
|
'cookies',
|
|
'Leave empty to use default cookies. Or drag & drop a cookie file here.',
|
|
)
|
|
"
|
|
class="w-full"
|
|
@error="(msg: string) => toast.error(msg)"
|
|
/>
|
|
</UFormField>
|
|
</div>
|
|
|
|
<div
|
|
v-if="config.dl_fields.length > 0"
|
|
class="grid gap-4 md:grid-cols-2 xl:grid-cols-2"
|
|
>
|
|
<DLInput
|
|
v-for="(fi, index) in sortedDLFields"
|
|
:id="fi?.id || `dlf-${index}`"
|
|
:key="fi.id || `dlf-${index}`"
|
|
v-model="dlFields[fi.field]"
|
|
:type="fi.kind"
|
|
:description="fi.description"
|
|
:label="fi.name"
|
|
:icon="fi.icon"
|
|
:field="fi.field"
|
|
:disabled="addInProgress"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="flex flex-wrap items-center justify-between gap-2 border-t border-default pt-4"
|
|
>
|
|
<UDropdownMenu class="sm:hidden" :items="mobileActionGroups" :modal="false">
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-ellipsis"
|
|
trailing-icon="i-lucide-chevron-down"
|
|
>
|
|
Actions
|
|
</UButton>
|
|
</UDropdownMenu>
|
|
|
|
<div class="hidden flex-wrap items-center gap-2 sm:flex">
|
|
<UButton
|
|
type="button"
|
|
color="info"
|
|
variant="outline"
|
|
icon="i-lucide-info"
|
|
:disabled="addInProgress || !hasValidUrl"
|
|
@click="
|
|
emitter('getInfo', splitUrls(form.url || '')[0] || '', form.preset, form.cli)
|
|
"
|
|
>
|
|
yt-dlp Information
|
|
</UButton>
|
|
|
|
<UButton
|
|
v-if="config.app.console_enabled"
|
|
type="button"
|
|
color="warning"
|
|
variant="outline"
|
|
icon="i-lucide-terminal"
|
|
:disabled="!hasValidUrl"
|
|
@click="runCliCommand"
|
|
>
|
|
Run CLI
|
|
</UButton>
|
|
|
|
<UButton
|
|
v-if="config.app.console_enabled"
|
|
type="button"
|
|
color="success"
|
|
variant="outline"
|
|
icon="i-lucide-flask-conical"
|
|
:disabled="!hasValidUrl"
|
|
@click="testDownloadOptions"
|
|
>
|
|
Show compiled yt-dlp options
|
|
</UButton>
|
|
</div>
|
|
|
|
<UButton
|
|
type="button"
|
|
color="error"
|
|
variant="outline"
|
|
icon="i-lucide-rotate-ccw"
|
|
:disabled="!!form?.id"
|
|
@click="resetConfig"
|
|
>
|
|
Reset local settings
|
|
</UButton>
|
|
</div>
|
|
</template>
|
|
</UPageCard>
|
|
</form>
|
|
|
|
<datalist v-if="config?.folders" id="folders">
|
|
<option v-for="dir in config.folders" :key="dir" :value="dir" />
|
|
</datalist>
|
|
|
|
<UModal
|
|
v-if="showOptions"
|
|
v-model:open="showOptions"
|
|
title="yt-dlp options"
|
|
:dismissible="true"
|
|
:ui="{ content: 'sm:max-w-6xl', body: 'p-0' }"
|
|
>
|
|
<template #description>
|
|
<span class="sr-only">Browse available yt-dlp flags and descriptions.</span>
|
|
</template>
|
|
|
|
<template #body>
|
|
<YTDLPOptions />
|
|
</template>
|
|
</UModal>
|
|
|
|
<UModal
|
|
v-model:open="showTestResults"
|
|
title="Test results"
|
|
:dismissible="true"
|
|
:ui="{ content: 'sm:max-w-5xl', body: 'p-0' }"
|
|
@update:open="(open) => !open && closeTestResults()"
|
|
>
|
|
<template #body>
|
|
<div class="relative min-h-[40vh] p-4 sm:p-5">
|
|
<div v-if="!testResultsData" class="flex min-h-[40vh] items-center justify-center">
|
|
<UIcon
|
|
name="i-lucide-loader-circle"
|
|
class="size-16 animate-spin text-toned sm:size-20"
|
|
/>
|
|
</div>
|
|
|
|
<div v-else class="relative">
|
|
<div class="pointer-events-none absolute right-3 top-3 z-10 flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
class="pointer-events-auto inline-flex size-9 items-center justify-center rounded-md border border-default bg-default/90 text-toned shadow-sm transition-colors hover:text-default"
|
|
aria-label="Toggle wrapped text"
|
|
@click="toggleTestResultsWrap"
|
|
>
|
|
<UIcon name="i-lucide-wrap-text" class="size-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="pointer-events-auto inline-flex size-9 items-center justify-center rounded-md border border-default bg-default/90 text-toned shadow-sm transition-colors hover:text-default"
|
|
aria-label="Copy text"
|
|
@click="copyTestResults"
|
|
>
|
|
<UIcon name="i-lucide-copy" class="size-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<pre
|
|
:class="testResultsPreClasses"
|
|
><code class="block p-4" v-text="testResultsText" /></pre>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</UModal>
|
|
</main>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useStorage } from '@vueuse/core';
|
|
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue';
|
|
import TextDropzone from '~/components/TextDropzone.vue';
|
|
import { useConditions } from '~/composables/useConditions';
|
|
import type { Condition } from '~/types/conditions';
|
|
import type { item_request } from '~/types/item';
|
|
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
|
import { navigateTo } from '#app';
|
|
import { useDialog } from '~/composables/useDialog';
|
|
|
|
const props = defineProps<{ item?: Partial<item_request> }>();
|
|
const emitter = defineEmits<{
|
|
(e: 'getInfo', url: string, preset: string | undefined, cli: string | undefined): void;
|
|
(e: 'clear_form'): void;
|
|
}>();
|
|
const config = useYtpConfig();
|
|
const toast = useNotification();
|
|
const dialog = useDialog();
|
|
const conditions = useConditions();
|
|
const { findPreset, hasPreset, selectItems, getPresetDefault } = usePresetOptions();
|
|
|
|
const showAdvanced = useStorage<boolean>('show_advanced', false);
|
|
const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',');
|
|
const auto_start = useStorage<boolean>('auto_start', true);
|
|
const show_description = useStorage<boolean>('show_description', true);
|
|
const dlFields = useStorage<Record<string, any>>('dl_fields', {});
|
|
const storedCommand = useStorage<string>('console_command', '');
|
|
const testResultsClasses = useStorage<string>('modal_text_classes', '');
|
|
|
|
const addInProgress = ref<boolean>(false);
|
|
const showOptions = ref<boolean>(false);
|
|
const showTestResults = ref<boolean>(false);
|
|
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<{ textareaRef?: HTMLTextAreaElement | null } | null>(null);
|
|
|
|
type IgnoreConditionOption = {
|
|
value: string;
|
|
label: string;
|
|
};
|
|
|
|
const normalizeIgnoreConditionValues = (value: unknown): string[] => {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
|
|
return value
|
|
.map((entry) => {
|
|
if ('boolean' === typeof entry) {
|
|
return '';
|
|
}
|
|
|
|
if ('number' === typeof entry || 'string' === typeof entry) {
|
|
return String(entry).trim();
|
|
}
|
|
|
|
return '';
|
|
})
|
|
.filter((entry, index, items) => !!entry && items.indexOf(entry) === index);
|
|
};
|
|
|
|
const buildIgnoreConditionOptions = (
|
|
items: readonly Condition[],
|
|
selectedValues: readonly string[] = [],
|
|
): IgnoreConditionOption[] => {
|
|
const options = items
|
|
.filter((condition) => condition.enabled)
|
|
.map((condition) => ({
|
|
value:
|
|
condition.id !== undefined && condition.id !== null ? String(condition.id) : condition.name,
|
|
label: condition.name,
|
|
}));
|
|
|
|
const missingSelectedItems = selectedValues
|
|
.filter((value) => value !== '*' && !options.some((item) => item.value === value))
|
|
.map((value) => ({
|
|
value,
|
|
label: value,
|
|
}));
|
|
|
|
return [
|
|
{
|
|
value: '*',
|
|
label: 'All conditions',
|
|
},
|
|
...options,
|
|
...missingSelectedItems,
|
|
];
|
|
};
|
|
|
|
const mergeIgnoreConditionsIntoExtras = (
|
|
extras: Record<string, unknown> | undefined,
|
|
ignoreConditions: string[],
|
|
): Record<string, unknown> => {
|
|
const nextExtras = { ...(extras || {}) };
|
|
|
|
if (ignoreConditions.length > 0) {
|
|
nextExtras.ignore_conditions = [...ignoreConditions];
|
|
return nextExtras;
|
|
}
|
|
|
|
delete nextExtras.ignore_conditions;
|
|
return nextExtras;
|
|
};
|
|
|
|
const hasAllConditionsLoaded = computed(() => {
|
|
const loaded = conditions.conditions.value.length;
|
|
const total = conditions.pagination.value.total;
|
|
|
|
return loaded > 0 && (total <= 0 || loaded >= total);
|
|
});
|
|
|
|
const ensureIgnoreConditionsLoaded = async (): Promise<void> => {
|
|
if (conditions.isLoading.value || hasAllConditionsLoaded.value) {
|
|
return;
|
|
}
|
|
|
|
await conditions.loadConditions(1, 1000);
|
|
};
|
|
|
|
const selectedIgnoreConditions = computed<string[]>({
|
|
get: () => normalizeIgnoreConditionValues(form.value?.extras?.ignore_conditions),
|
|
set: (values) => {
|
|
const extras = mergeIgnoreConditionsIntoExtras(
|
|
form.value?.extras,
|
|
normalizeIgnoreConditionValues(values),
|
|
);
|
|
form.value.extras = Object.keys(extras).length > 0 ? extras : {};
|
|
},
|
|
});
|
|
|
|
const testResultsText = computed(() => {
|
|
if (typeof testResultsData.value === 'string') {
|
|
return testResultsData.value;
|
|
}
|
|
|
|
if (testResultsData.value === undefined || testResultsData.value === null) {
|
|
return '';
|
|
}
|
|
|
|
try {
|
|
return JSON.stringify(testResultsData.value, null, 2) ?? '';
|
|
} catch {
|
|
return String(testResultsData.value ?? '');
|
|
}
|
|
});
|
|
|
|
const testResultsPreClasses = computed(() => [
|
|
'max-h-[calc(100vh-10rem)] overflow-auto rounded-xl border border-default bg-elevated/40 text-sm text-default',
|
|
'font-mono leading-6 whitespace-pre',
|
|
testResultsClasses.value,
|
|
]);
|
|
|
|
const downloadCardUi = {
|
|
root: 'w-full',
|
|
container: 'w-full p-4 sm:p-6',
|
|
wrapper: 'w-full items-stretch',
|
|
body: 'w-full space-y-4',
|
|
};
|
|
|
|
const downloadFieldUi = {
|
|
label: 'font-semibold text-default',
|
|
container: 'space-y-2',
|
|
};
|
|
|
|
const advancedEditorFieldUi = {
|
|
root: 'w-full',
|
|
label: 'font-semibold text-default',
|
|
container: 'space-y-2',
|
|
description: 'text-sm text-toned',
|
|
help: 'mt-2 text-sm text-toned',
|
|
};
|
|
|
|
const form = useStorage<item_request>('local_config_v1', {
|
|
id: null,
|
|
url: '',
|
|
preset: config.app.default_preset,
|
|
cookies: '',
|
|
cli: '',
|
|
template: '',
|
|
folder: '',
|
|
extras: {},
|
|
}) as Ref<item_request>;
|
|
|
|
const presetItems = computed(() => selectItems.value);
|
|
const ignoreConditionOptions = computed(() =>
|
|
buildIgnoreConditionOptions(
|
|
conditions.conditions.value,
|
|
normalizeIgnoreConditionValues(form.value?.extras?.ignore_conditions),
|
|
),
|
|
);
|
|
const hasIgnoreConditionOptions = computed(() =>
|
|
ignoreConditionOptions.value.some((option) => option.value !== '*'),
|
|
);
|
|
|
|
const mobileActionGroups = computed(() => {
|
|
const groups = [
|
|
[
|
|
{
|
|
label: 'Custom Fields',
|
|
icon: 'i-lucide-plus',
|
|
onSelect: () => navigateTo('/dl_fields'),
|
|
},
|
|
{
|
|
label: 'yt-dlp Information',
|
|
icon: 'i-lucide-info',
|
|
disabled: addInProgress.value || !hasValidUrl.value,
|
|
onSelect: () =>
|
|
emitter(
|
|
'getInfo',
|
|
splitUrls(form.value.url || '')[0] || '',
|
|
form.value.preset,
|
|
form.value.cli,
|
|
),
|
|
},
|
|
],
|
|
] as Array<Array<Record<string, unknown>>>;
|
|
|
|
if (config.app.console_enabled) {
|
|
groups[0]?.push(
|
|
{
|
|
label: 'Run CLI',
|
|
icon: 'i-lucide-terminal',
|
|
disabled: !hasValidUrl.value,
|
|
onSelect: () => void runCliCommand(),
|
|
},
|
|
{
|
|
label: 'Show compiled yt-dlp options',
|
|
icon: 'i-lucide-flask-conical',
|
|
disabled: !hasValidUrl.value,
|
|
onSelect: () => void testDownloadOptions(),
|
|
},
|
|
);
|
|
}
|
|
|
|
groups.push([
|
|
{
|
|
label: 'Reset local settings',
|
|
icon: 'i-lucide-rotate-ccw',
|
|
color: 'error',
|
|
disabled: Boolean(form.value?.id),
|
|
onSelect: () => void resetConfig(),
|
|
},
|
|
]);
|
|
|
|
return groups;
|
|
});
|
|
|
|
const is_valid_dl_field = (dl_field: string): boolean => {
|
|
if (dlFieldsExtra.includes(dl_field)) {
|
|
return true;
|
|
}
|
|
|
|
if (config.dl_fields && config.dl_fields.length > 0) {
|
|
return config.dl_fields.some((f) => f.field === dl_field);
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const getUrlElement = (): HTMLInputElement | HTMLTextAreaElement | null => {
|
|
return (
|
|
urlTextarea.value?.textareaRef || (document.getElementById('url') as HTMLInputElement | null)
|
|
);
|
|
};
|
|
|
|
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();
|
|
|
|
const field = getUrlElement();
|
|
if (field instanceof HTMLTextAreaElement) {
|
|
field.setSelectionRange(cursorPos + 1, cursorPos + 1);
|
|
field.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();
|
|
|
|
const field = getUrlElement();
|
|
if (field instanceof HTMLTextAreaElement) {
|
|
const newPos = start + pastedText.length;
|
|
field.setSelectionRange(newPos, newPos);
|
|
field.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 () => {
|
|
if (form.value.folder) {
|
|
form.value.folder = form.value.folder.trim();
|
|
}
|
|
|
|
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(' ');
|
|
}
|
|
}
|
|
|
|
if (form_cli && form_cli.trim()) {
|
|
const options = await convertOptions(form_cli);
|
|
if (null === options) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const request_data = [] as Array<item_request>;
|
|
|
|
splitUrls(form.value.url).forEach(async (url: string) => {
|
|
const data = {
|
|
url: url,
|
|
preset: form.value.preset || config.app.default_preset,
|
|
folder: form.value.folder,
|
|
template: form.value.template,
|
|
cookies: form.value.cookies,
|
|
cli: form_cli,
|
|
auto_start: auto_start.value,
|
|
} as item_request;
|
|
|
|
if (form.value?.extras && Object.keys(form.value.extras).length > 0) {
|
|
data.extras = form.value.extras;
|
|
}
|
|
|
|
request_data.push(data);
|
|
});
|
|
|
|
try {
|
|
addInProgress.value = true;
|
|
const response = await request('/api/history', {
|
|
method: 'POST',
|
|
body: JSON.stringify(request_data),
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (!response.ok) {
|
|
toast.error(`Error: ${data.error || 'Failed to add download.'}`);
|
|
return;
|
|
}
|
|
|
|
let had_errors = false;
|
|
|
|
if (200 === response.status) {
|
|
data.forEach((item: Record<string, any>) => {
|
|
if (false !== item.status) {
|
|
return;
|
|
}
|
|
|
|
had_errors = true;
|
|
|
|
if (item?.hidden) {
|
|
return;
|
|
}
|
|
|
|
toast.error(`Error: ${item.msg || 'Failed to add download.'}`);
|
|
});
|
|
}
|
|
|
|
if (202 === response.status) {
|
|
toast.success(data.message, { timeout: 2000 });
|
|
}
|
|
|
|
if (false === had_errors) {
|
|
form.value.url = '';
|
|
emitter('clear_form');
|
|
}
|
|
} catch (e: any) {
|
|
console.error(e);
|
|
toast.error(`Error: ${e.message}`);
|
|
} finally {
|
|
addInProgress.value = false;
|
|
}
|
|
};
|
|
|
|
const resetConfig = async () => {
|
|
const { status } = await dialog.confirmDialog({
|
|
title: 'Confirm Action',
|
|
message: `Reset local configuration?`,
|
|
confirmColor: 'error',
|
|
});
|
|
if (!status) {
|
|
return;
|
|
}
|
|
|
|
form.value = {
|
|
url: '',
|
|
preset: config.app.default_preset,
|
|
cookies: '',
|
|
cli: '',
|
|
template: '',
|
|
folder: '',
|
|
extras: {},
|
|
} as item_request;
|
|
dlFields.value = {};
|
|
showAdvanced.value = false;
|
|
toast.success('Local configuration has been reset.');
|
|
};
|
|
|
|
const convertOptions = async (args: string) => {
|
|
try {
|
|
const response = await convertCliOptions(args);
|
|
|
|
if (response.output_template) {
|
|
form.value.template = response.output_template;
|
|
}
|
|
|
|
if (response.download_path) {
|
|
form.value.folder = response.download_path;
|
|
}
|
|
|
|
return response.opts;
|
|
} catch (e: any) {
|
|
toast.error(e.message);
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
onUpdated(async () => {
|
|
await nextTick();
|
|
|
|
if ('' === form.value?.preset) {
|
|
form.value.preset = config.app.default_preset;
|
|
}
|
|
|
|
if (config.isLoaded() && form.value?.preset && !hasPreset(form.value.preset)) {
|
|
form.value.preset = config.app.default_preset;
|
|
}
|
|
});
|
|
|
|
watch(
|
|
() => config.ytdlp_options,
|
|
(newOptions) =>
|
|
(ytDlpOpt.value = newOptions
|
|
.filter((opt) => !opt.ignored)
|
|
.flatMap((opt) =>
|
|
opt.flags
|
|
.filter((flag) => flag.startsWith('--'))
|
|
.map((flag) => ({ value: flag, description: opt.description || '' })),
|
|
)),
|
|
{ immediate: true },
|
|
);
|
|
|
|
watch(
|
|
showAdvanced,
|
|
(open) => {
|
|
if (!open) {
|
|
return;
|
|
}
|
|
|
|
void ensureIgnoreConditionsLoaded();
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
onMounted(async () => {
|
|
await nextTick();
|
|
|
|
if ('' === form.value?.preset) {
|
|
form.value.preset = config.app.default_preset;
|
|
}
|
|
|
|
if (form.value?.folder) {
|
|
form.value.folder = form.value.folder.trim();
|
|
await nextTick();
|
|
}
|
|
|
|
if (config.isLoaded() && form.value?.preset && !hasPreset(form.value.preset)) {
|
|
form.value.preset = config.app.default_preset;
|
|
}
|
|
|
|
if (props?.item) {
|
|
const updates: Partial<item_request> = {};
|
|
const keys = Object.keys(props.item) as (keyof item_request)[];
|
|
for (const key of keys) {
|
|
const value = props.item[key];
|
|
updates[key] = key === 'extras' ? JSON.parse(JSON.stringify(value)) : value!;
|
|
}
|
|
form.value = { ...form.value, ...updates };
|
|
emitter('clear_form');
|
|
}
|
|
|
|
await nextTick();
|
|
|
|
if (!separators.some((s) => s.value === separator.value)) {
|
|
separator.value = separators[0]?.value ?? ',';
|
|
}
|
|
});
|
|
|
|
const runCliCommand = async (): Promise<void> => {
|
|
if (!form.value.url) {
|
|
toast.warning('Please enter a URL first');
|
|
return;
|
|
}
|
|
|
|
if (form.value?.folder) {
|
|
form.value.folder = form.value.folder.trim();
|
|
await nextTick();
|
|
}
|
|
|
|
const { status } = await dialog.confirmDialog({
|
|
title: 'Run CLI Command',
|
|
message: `This will generate a yt-dlp command and run it in the console. Continue?`,
|
|
});
|
|
|
|
if (!status) {
|
|
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(' ');
|
|
}
|
|
}
|
|
|
|
if (form_cli && form_cli.trim()) {
|
|
const options = await convertOptions(form_cli);
|
|
if (null === options) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const resp = await request('/api/yt-dlp/command', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
url: splitUrls(form.value.url).join(' '),
|
|
preset: form.value.preset,
|
|
folder: form.value.folder,
|
|
cookies: form.value.cookies,
|
|
template: form.value.template,
|
|
cli: form_cli,
|
|
}),
|
|
});
|
|
|
|
const json = (await resp.json()) as { command?: string; error?: string };
|
|
|
|
if (!resp.ok) {
|
|
toast.error(`Error: ${json.error || 'Failed to generate command.'}`);
|
|
return;
|
|
}
|
|
|
|
storedCommand.value = json.command;
|
|
|
|
await nextTick();
|
|
await navigateTo('/console');
|
|
} catch (error) {
|
|
toast.error(
|
|
error instanceof Error ? error.message : 'Failed to create and navigate to command',
|
|
);
|
|
}
|
|
};
|
|
|
|
const testDownloadOptions = async (): Promise<void> => {
|
|
if (!form.value.url) {
|
|
toast.warning('Please enter a URL first');
|
|
return;
|
|
}
|
|
|
|
if (form.value?.folder) {
|
|
form.value.folder = form.value.folder.trim();
|
|
await nextTick();
|
|
}
|
|
|
|
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 toggleTestResultsWrap = () => {
|
|
if (testResultsClasses.value.includes('is-pre-wrap-force')) {
|
|
testResultsClasses.value = testResultsClasses.value.replace('is-pre-wrap-force', '').trim();
|
|
return;
|
|
}
|
|
|
|
testResultsClasses.value = `${testResultsClasses.value} is-pre-wrap-force`.trim();
|
|
};
|
|
|
|
const copyTestResults = () => {
|
|
try {
|
|
copyText(JSON.stringify(testResultsData.value, null, 2));
|
|
} catch {
|
|
copyText(testResultsText.value);
|
|
}
|
|
};
|
|
|
|
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 expand_description = (e: Event) =>
|
|
toggleClass(e.target as HTMLElement, ['is-ellipsis', 'is-pre-wrap']);
|
|
|
|
const getDefault = (type: 'cookies' | 'cli' | 'template' | 'folder', ret: string = '') => {
|
|
if (false !== hasFormatInConfig.value || !form.value.preset) {
|
|
return ret;
|
|
}
|
|
|
|
return getPresetDefault(form.value.preset, type, ret);
|
|
};
|
|
|
|
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);
|
|
|
|
watch(isMultiLineInput, async (newValue) => {
|
|
await nextTick();
|
|
if (newValue) {
|
|
urlTextarea.value?.textareaRef?.focus();
|
|
return;
|
|
}
|
|
getUrlElement()?.focus();
|
|
});
|
|
</script>
|