1022 lines
31 KiB
Vue
1022 lines
31 KiB
Vue
<template>
|
|
<form id="taskForm" autocomplete="off" class="space-y-4" @submit.prevent="checkInfo">
|
|
<UAlert
|
|
v-if="!isMultiLineInput && form.url && is_yt_handle(form.url)"
|
|
color="warning"
|
|
variant="soft"
|
|
icon="i-lucide-info"
|
|
title="Warning"
|
|
>
|
|
<template #description>
|
|
<div class="space-y-2 text-sm text-default">
|
|
<p>
|
|
You are using a YouTube link with <code>@handle</code> instead of
|
|
<code>channel_id</code>. To activate RSS feed support for URL click on the
|
|
<b>Convert URL</b> link.
|
|
</p>
|
|
|
|
<UButton
|
|
type="button"
|
|
color="neutral"
|
|
variant="outline"
|
|
size="sm"
|
|
:loading="convertInProgress"
|
|
:disabled="addInProgress || convertInProgress"
|
|
@click="() => void convertCurrentUrl()"
|
|
>
|
|
Convert URL
|
|
</UButton>
|
|
</div>
|
|
</template>
|
|
</UAlert>
|
|
|
|
<UAlert
|
|
v-if="form.url && is_generic_rss(form.url) && !isMultiLineInput"
|
|
color="warning"
|
|
variant="soft"
|
|
icon="i-lucide-info"
|
|
title="Information"
|
|
description="You are using a generic RSS/Atom feed URL. The task handler will automatically download new items found in this feed."
|
|
/>
|
|
|
|
<UAlert
|
|
v-if="isMultiLineInput"
|
|
color="info"
|
|
variant="soft"
|
|
icon="i-lucide-files"
|
|
title="Multiple URLs"
|
|
>
|
|
<template #description>
|
|
<ul class="list-disc space-y-1 pl-5 text-sm text-default">
|
|
<li>First URL uses the <b>Name</b> provided above with full settings.</li>
|
|
<li>Other URLs infer names from metadata and inherit settings from the first URL.</li>
|
|
<li v-if="form.timer">Timers are offset by 5-minute increments for each URL.</li>
|
|
</ul>
|
|
</template>
|
|
</UAlert>
|
|
|
|
<div class="space-y-6 border-b border-default pb-5 last:border-b-0 last:pb-0">
|
|
<div v-if="reference" class="flex justify-end">
|
|
<UButton
|
|
type="button"
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="sm"
|
|
:icon="showImport ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
|
@click="showImport = !showImport"
|
|
>
|
|
{{ showImport ? 'Hide' : 'Show' }} import
|
|
</UButton>
|
|
</div>
|
|
|
|
<div v-if="showImport || !reference" class="space-y-3 border-b border-default pb-5">
|
|
<UFormField class="w-full" :ui="fieldUi">
|
|
<template #label>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<UIcon name="i-lucide-import" class="size-4 text-toned" />
|
|
<span class="font-semibold text-default">Import string</span>
|
|
</div>
|
|
</template>
|
|
<template #description>
|
|
<span>You can use this field to populate the data, using shared string.</span>
|
|
</template>
|
|
|
|
<div class="flex flex-col gap-2 sm:flex-row">
|
|
<UInput
|
|
id="import_string"
|
|
v-model="import_string"
|
|
type="text"
|
|
autocomplete="off"
|
|
size="lg"
|
|
class="w-full"
|
|
:ui="inputUi"
|
|
/>
|
|
|
|
<UButton
|
|
type="button"
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-import"
|
|
size="lg"
|
|
:disabled="!import_string"
|
|
class="justify-center sm:min-w-28"
|
|
@click="() => void importItem()"
|
|
>
|
|
Import
|
|
</UButton>
|
|
</div>
|
|
</UFormField>
|
|
</div>
|
|
|
|
<div class="space-y-5">
|
|
<div class="grid gap-4 xl:grid-cols-2">
|
|
<UFormField class="w-full" :ui="fieldUi">
|
|
<template #label>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<UIcon name="i-lucide-type" class="size-4 text-toned" />
|
|
<span class="font-semibold text-default">Name</span>
|
|
</div>
|
|
</template>
|
|
<template #description>
|
|
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-toned">
|
|
<p class="text-sm text-toned">The name is used to identify this specific task.</p>
|
|
</div>
|
|
</template>
|
|
<UInput
|
|
id="name"
|
|
v-model="form.name"
|
|
type="text"
|
|
size="lg"
|
|
:disabled="addInProgress"
|
|
class="w-full"
|
|
:ui="inputUi"
|
|
/>
|
|
</UFormField>
|
|
|
|
<UFormField class="w-full" :ui="fieldUi">
|
|
<template #label>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<UIcon name="i-lucide-link" class="size-4 text-toned" />
|
|
<span class="font-semibold text-default">URL</span>
|
|
<UBadge v-if="urlCount > 1" color="info" variant="soft" size="sm">
|
|
{{ urlCount }} URLs
|
|
</UBadge>
|
|
</div>
|
|
</template>
|
|
|
|
<template #description>
|
|
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-toned">
|
|
<span>The channel or playlist URL. Use Shift+Enter for multiple URLs.</span>
|
|
<button
|
|
v-if="!isMultiLineInput && is_yt_handle(form.url)"
|
|
type="button"
|
|
class="text-primary hover:underline"
|
|
:disabled="addInProgress || convertInProgress"
|
|
@click="() => void convertCurrentUrl()"
|
|
>
|
|
Convert URL
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="w-full">
|
|
<UTextarea
|
|
v-if="isMultiLineInput"
|
|
id="url"
|
|
ref="urlFieldRef"
|
|
v-model="form.url"
|
|
:disabled="addInProgress || convertInProgress"
|
|
:rows="3"
|
|
:maxrows="10"
|
|
autoresize
|
|
size="lg"
|
|
class="w-full"
|
|
:ui="textareaUi"
|
|
placeholder="https://www.youtube.com/channel/UCUi3_cffYenmMTuWEsLHzqg"
|
|
@keydown="handleKeyDown"
|
|
/>
|
|
|
|
<UInput
|
|
v-else
|
|
id="url"
|
|
ref="urlFieldRef"
|
|
v-model="form.url"
|
|
type="url"
|
|
:disabled="addInProgress || convertInProgress"
|
|
size="lg"
|
|
class="w-full"
|
|
:ui="inputUi"
|
|
placeholder="https://www.youtube.com/channel/UCUi3_cffYenmMTuWEsLHzqg"
|
|
@keydown="handleKeyDown"
|
|
@paste="handlePaste"
|
|
/>
|
|
</div>
|
|
</UFormField>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-5">
|
|
<div class="grid gap-4 xl:grid-cols-2">
|
|
<UFormField class="w-full" :ui="fieldUi">
|
|
<template #label>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<UIcon name="i-lucide-clock-3" class="size-4 text-toned" />
|
|
<span class="font-semibold text-default">CRON Timer</span>
|
|
</div>
|
|
</template>
|
|
<template #description>
|
|
<span>
|
|
The CRON timer expression to use for this task. If not set, the task runner will be
|
|
disabled. For more information on CRON expressions, see
|
|
<NuxtLink
|
|
to="https://crontab.guru/"
|
|
target="_blank"
|
|
class="text-primary hover:underline"
|
|
>
|
|
crontab.guru
|
|
</NuxtLink>
|
|
.
|
|
</span>
|
|
</template>
|
|
|
|
<UInput
|
|
id="timer"
|
|
v-model="form.timer"
|
|
type="text"
|
|
:disabled="addInProgress"
|
|
placeholder="0 12 * * 5"
|
|
size="lg"
|
|
class="w-full"
|
|
:ui="inputUi"
|
|
/>
|
|
</UFormField>
|
|
|
|
<UFormField class="w-full" :ui="fieldUi" :description="presetDescription">
|
|
<template #label>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<UIcon name="i-lucide-sliders-horizontal" class="size-4 text-toned" />
|
|
<span class="font-semibold text-default">Preset</span>
|
|
</div>
|
|
</template>
|
|
|
|
<UTooltip
|
|
side="bottom"
|
|
:text="
|
|
hasFormatInConfig
|
|
? 'Presets are disabled. Format key is present in the command options for yt-dlp.'
|
|
: undefined
|
|
"
|
|
>
|
|
<USelectMenu
|
|
id="preset"
|
|
v-model="form.preset"
|
|
:items="presetItems"
|
|
value-key="value"
|
|
label-key="label"
|
|
color="neutral"
|
|
:disabled="addInProgress || hasFormatInConfig"
|
|
placeholder="Select preset"
|
|
size="lg"
|
|
class="w-full"
|
|
:ui="{ content: 'min-w-[13rem]', item: 'pl-6' }"
|
|
:search-input="{ placeholder: 'Search presets' }"
|
|
/>
|
|
</UTooltip>
|
|
</UFormField>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-5">
|
|
<div class="grid gap-4 xl:grid-cols-2">
|
|
<UFormField class="w-full" :ui="fieldUi">
|
|
<template #label>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<UIcon name="i-lucide-folder-output" class="size-4 text-toned" />
|
|
<span class="font-semibold text-default">Download path</span>
|
|
</div>
|
|
</template>
|
|
|
|
<template #description>
|
|
Path relative to the download path, leave empty to use preset or default download
|
|
path.
|
|
</template>
|
|
|
|
<div class="flex flex-col gap-2 sm:flex-row">
|
|
<UTooltip :text="`Full Path: ${config.app.download_path}`">
|
|
<div
|
|
class="inline-flex min-h-11 items-center rounded-md border border-default bg-muted/30 px-3 text-sm text-toned"
|
|
>
|
|
{{ shortPath(config.app.download_path) }}
|
|
</div>
|
|
</UTooltip>
|
|
|
|
<UInput
|
|
id="folder"
|
|
v-model="form.folder"
|
|
type="text"
|
|
list="folders"
|
|
:placeholder="getDefault('folder', '/')"
|
|
:disabled="addInProgress"
|
|
size="lg"
|
|
class="w-full"
|
|
:ui="inputUi"
|
|
/>
|
|
</div>
|
|
</UFormField>
|
|
|
|
<UFormField class="w-full" :ui="fieldUi">
|
|
<template #label>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<UIcon name="i-lucide-file-code-2" class="size-4 text-toned" />
|
|
<span class="font-semibold text-default">Output template</span>
|
|
</div>
|
|
</template>
|
|
|
|
<template #description>
|
|
The template to use. Leave empty to use preset or default template.
|
|
</template>
|
|
|
|
<UInput
|
|
id="output_template"
|
|
v-model="form.template"
|
|
type="text"
|
|
:disabled="addInProgress"
|
|
:placeholder="
|
|
getDefault('template', config.app.output_template || '%(title)s.%(ext)s')
|
|
"
|
|
size="lg"
|
|
class="w-full"
|
|
:ui="inputUi"
|
|
/>
|
|
</UFormField>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-5">
|
|
<div class="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
|
|
<div class="rounded-lg border border-default bg-muted/20 p-3">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div class="space-y-1">
|
|
<div class="flex items-center gap-2">
|
|
<UIcon name="i-lucide-power" class="size-4 text-toned" />
|
|
<p class="text-sm font-semibold text-default">Enabled</p>
|
|
</div>
|
|
<p class="text-xs text-toned">Whether the task is enabled.</p>
|
|
</div>
|
|
<USwitch v-model="form.enabled" :disabled="addInProgress" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rounded-lg border border-default bg-muted/20 p-3">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div class="space-y-1">
|
|
<div class="flex items-center gap-2">
|
|
<UIcon name="i-lucide-play" class="size-4 text-toned" />
|
|
<p class="text-sm font-semibold text-default">Auto Start</p>
|
|
</div>
|
|
<p class="text-xs text-toned">
|
|
Whether to automatically queue and start the download task.
|
|
</p>
|
|
</div>
|
|
<USwitch v-model="form.auto_start" :disabled="addInProgress" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rounded-lg border border-default bg-muted/20 p-3">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div class="space-y-1">
|
|
<div class="flex items-center gap-2">
|
|
<UIcon name="i-lucide-rss" class="size-4 text-toned" />
|
|
<p class="text-sm font-semibold text-default">Enable Handler</p>
|
|
</div>
|
|
<p class="text-xs text-toned">
|
|
Tasks without a supported handler must use a timer.
|
|
</p>
|
|
</div>
|
|
<USwitch v-model="form.handler_enabled" :disabled="addInProgress" />
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="!reference" class="rounded-lg border border-default bg-muted/20 p-3">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div class="space-y-1">
|
|
<div class="flex items-center gap-2">
|
|
<UIcon name="i-lucide-archive" class="size-4 text-toned" />
|
|
<p class="text-sm font-semibold text-default">Archive All</p>
|
|
</div>
|
|
<p class="text-xs text-toned">Mark all existing items as downloaded after add.</p>
|
|
</div>
|
|
<USwitch v-model="archiveAllAfterAdd" :disabled="addInProgress" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-5 border-t border-default pt-5">
|
|
<UFormField class="w-full" :ui="editorFieldUi">
|
|
<template #label>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<UIcon name="i-lucide-terminal" class="size-4 text-toned" />
|
|
<span>Command options for yt-dlp</span>
|
|
</div>
|
|
</template>
|
|
<template #description>
|
|
<NuxtLink class="text-primary hover:underline" @click="showOptions = true">
|
|
View all options
|
|
</NuxtLink>
|
|
. Not all options are supported;
|
|
<a
|
|
target="_blank"
|
|
href="https://github.com/arabcoders/ytptube/blob/master/app/features/ytdlp/utils.py#L29"
|
|
class="text-primary hover:underline"
|
|
>
|
|
some are ignored
|
|
</a>
|
|
</template>
|
|
<TextareaAutocomplete
|
|
id="cli_options"
|
|
v-model="form.cli"
|
|
:options="ytDlpOpt"
|
|
:placeholder="getDefault('cli', '')"
|
|
:disabled="addInProgress"
|
|
/>
|
|
</UFormField>
|
|
</div>
|
|
</div>
|
|
|
|
<UAlert color="info" variant="soft">
|
|
<template #description>
|
|
<ul class="list-disc space-y-2 pl-5 text-sm text-default">
|
|
<li>
|
|
<strong>Tasks:</strong> requires <code>--download-archive</code> in
|
|
<code>Command options for yt-dlp</code> can be set via presets or manually. Default
|
|
presets already include this option.
|
|
</li>
|
|
<li>
|
|
<strong>YouTube RSS:</strong> Use <code>channel_id</code> or
|
|
<code>playlist_id</code> URLs. Other link types (custom names, handles, user profiles)
|
|
are not supported.
|
|
</li>
|
|
<li>
|
|
<strong>Generic RSS/Atom:</strong> URL must end with <code>.rss</code> or
|
|
<code>.atom</code>. If not possible, append <code>&handler=rss</code> to existing query
|
|
parameters, or add <code>#handler=rss</code> as a fragment.
|
|
</li>
|
|
<li>
|
|
<strong>RSS Monitoring Basics:</strong> Runs hourly independently. Timer controls
|
|
scheduled downloads to yt-dlp. Disable <code>Enable Handler</code> to disable RSS
|
|
monitoring.
|
|
</li>
|
|
<li>
|
|
<strong>Timer Requirement:</strong> if no supported handler matches the URL, you must
|
|
set a CRON timer before saving.
|
|
</li>
|
|
</ul>
|
|
</template>
|
|
</UAlert>
|
|
|
|
<div
|
|
class="flex flex-col-reverse gap-2 border-t border-default pt-5 sm:flex-row sm:justify-end"
|
|
>
|
|
<UButton
|
|
type="button"
|
|
color="neutral"
|
|
variant="outline"
|
|
size="lg"
|
|
icon="i-lucide-x"
|
|
:disabled="addInProgress"
|
|
class="justify-center"
|
|
@click="emitter('cancel')"
|
|
>
|
|
Cancel
|
|
</UButton>
|
|
|
|
<UButton
|
|
type="submit"
|
|
color="primary"
|
|
size="lg"
|
|
icon="i-lucide-save"
|
|
:disabled="addInProgress"
|
|
:loading="addInProgress"
|
|
class="justify-center"
|
|
>
|
|
Save
|
|
</UButton>
|
|
</div>
|
|
|
|
<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>
|
|
</form>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import { useStorage } from '@vueuse/core';
|
|
import { CronExpressionParser } from 'cron-parser';
|
|
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue';
|
|
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
|
import type { ExportedTask, Task } from '~/types/tasks';
|
|
|
|
const props = defineProps<{
|
|
reference?: number | null | undefined;
|
|
task: Task;
|
|
addInProgress?: boolean;
|
|
}>();
|
|
|
|
const emitter = defineEmits<{
|
|
(e: 'cancel'): void;
|
|
(e: 'dirty-change', dirty: boolean): void;
|
|
(
|
|
e: 'submit',
|
|
payload: { reference: number | null | undefined; task: Task | Task[]; archive_all?: boolean },
|
|
): void;
|
|
}>();
|
|
|
|
const toast = useNotification();
|
|
const config = useYtpConfig();
|
|
const dialog = useDialog();
|
|
const tasksComposable = useTasks();
|
|
const { findPreset, getPresetDefault, selectItems } = usePresetOptions();
|
|
const showImport = useStorage('showTaskImport', false);
|
|
|
|
const createDefaultTask = (source?: Partial<Task>): Task => ({
|
|
name: '',
|
|
url: '',
|
|
folder: '',
|
|
preset: '',
|
|
timer: '',
|
|
template: '',
|
|
cli: '',
|
|
auto_start: true,
|
|
handler_enabled: true,
|
|
enabled: true,
|
|
...(JSON.parse(JSON.stringify(source || {})) as Partial<Task>),
|
|
});
|
|
|
|
const convertInProgress = ref(false);
|
|
const import_string = ref('');
|
|
const showOptions = ref(false);
|
|
const ytDlpOpt = ref<AutoCompleteOptions>([]);
|
|
const archiveAllAfterAdd = ref(false);
|
|
const urlFieldRef = ref<{
|
|
inputRef?: HTMLInputElement | null;
|
|
textareaRef?: HTMLTextAreaElement | null;
|
|
} | null>(null);
|
|
|
|
const CHANNEL_REGEX =
|
|
/^https?:\/\/(?:www\.)?youtube\.com\/(?:(?:channel\/(?<channelId>UC[0-9A-Za-z_-]{22}))|(?:c\/(?<customName>[A-Za-z0-9_-]+))|(?:user\/(?<userName>[A-Za-z0-9_-]+))|(?:@(?<handle>[A-Za-z0-9_-]+)))(?<suffix>\/.*)?\/?$/;
|
|
const GENERIC_RSS_REGEX = /\.(rss|atom)(\?.*)?$|handler=rss/i;
|
|
|
|
const form = reactive<Task>(createDefaultTask(props.task));
|
|
|
|
const dirtySource = computed(() => ({
|
|
reference: props.reference ?? null,
|
|
form: JSON.parse(JSON.stringify(form)),
|
|
import_string: import_string.value,
|
|
showImport: showImport.value,
|
|
archiveAllAfterAdd: archiveAllAfterAdd.value,
|
|
}));
|
|
const { isDirty, markClean } = useDirtyState(dirtySource);
|
|
|
|
const fieldUi = {
|
|
label: 'font-semibold text-default',
|
|
container: 'space-y-2',
|
|
description: 'text-sm text-toned',
|
|
hint: 'text-sm text-toned',
|
|
};
|
|
|
|
const editorFieldUi = {
|
|
root: 'w-full',
|
|
label: 'font-semibold text-default',
|
|
container: 'space-y-2',
|
|
description: 'text-sm text-toned',
|
|
hint: 'text-sm text-toned',
|
|
};
|
|
|
|
const inputUi = {
|
|
root: 'w-full',
|
|
base: 'w-full bg-elevated/60 ring-default focus-visible:ring-primary',
|
|
};
|
|
|
|
const textareaUi = {
|
|
root: 'w-full',
|
|
base: 'min-h-[7rem] w-full bg-elevated/60 ring-default focus-visible:ring-primary',
|
|
};
|
|
|
|
const isMultiLineInput = computed(() => Boolean(form.url && form.url.includes('\n')));
|
|
const urlCount = computed(() => splitUrls(form.url || '').length);
|
|
const presetItems = computed(() => selectItems.value);
|
|
const presetDescription = computed(() => {
|
|
return hasFormatInConfig.value
|
|
? 'Presets are disabled. Format key is present in the command options for yt-dlp.'
|
|
: "Select the preset to use for this URL. If the -f, --format argument is present in the command line options, the preset and all it's options will be ignored.";
|
|
});
|
|
|
|
const hasFormatInConfig = computed<boolean>(
|
|
() => !!form.cli && /(?<!\S)(-f|--format)(=|\s)(\S+)/.test(form.cli),
|
|
);
|
|
|
|
const splitUrls = (urlString: string): string[] => {
|
|
return urlString
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0);
|
|
};
|
|
|
|
const getUrlElement = (): HTMLInputElement | HTMLTextAreaElement | null => {
|
|
return urlFieldRef.value?.textareaRef || urlFieldRef.value?.inputRef || null;
|
|
};
|
|
|
|
watch(
|
|
() => props.task,
|
|
(value) => {
|
|
Object.assign(form, createDefaultTask(value));
|
|
if (!value?.preset) {
|
|
form.preset = toRaw(config.app.default_preset);
|
|
}
|
|
|
|
import_string.value = '';
|
|
archiveAllAfterAdd.value = false;
|
|
nextTick(() => {
|
|
markClean();
|
|
emitter('dirty-change', false);
|
|
});
|
|
},
|
|
{ immediate: true, deep: true },
|
|
);
|
|
|
|
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(
|
|
() => form.cli,
|
|
() => {
|
|
if (!hasFormatInConfig.value && !form.preset) {
|
|
form.preset = config.app.default_preset;
|
|
}
|
|
},
|
|
);
|
|
|
|
watch(isDirty, (value: boolean) => emitter('dirty-change', value));
|
|
|
|
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) {
|
|
event.preventDefault();
|
|
await checkInfo();
|
|
return;
|
|
}
|
|
|
|
if (event.shiftKey && !isTextarea) {
|
|
event.preventDefault();
|
|
const cursorPos = target.selectionStart || form.url.length;
|
|
form.url =
|
|
form.url.substring(0, cursorPos) +
|
|
'\n' +
|
|
form.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.url || '';
|
|
const start = target.selectionStart || currentValue.length;
|
|
const end = target.selectionEnd || currentValue.length;
|
|
form.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 hasFormContent = computed(() => {
|
|
return Boolean(
|
|
form.name ||
|
|
form.url ||
|
|
form.timer ||
|
|
form.template ||
|
|
form.folder ||
|
|
form.cli ||
|
|
(form.preset && form.preset !== config.app.default_preset) ||
|
|
form.auto_start === false ||
|
|
form.handler_enabled === false ||
|
|
form.enabled === false,
|
|
);
|
|
});
|
|
|
|
const confirmImportOverwrite = async (): Promise<boolean> => {
|
|
if (!hasFormContent.value) {
|
|
return true;
|
|
}
|
|
|
|
const { status } = await dialog.confirmDialog({
|
|
title: 'Overwrite current form?',
|
|
message: 'Importing will overwrite the current task form fields.',
|
|
confirmText: 'Overwrite',
|
|
cancelText: 'Cancel',
|
|
confirmColor: 'warning',
|
|
});
|
|
|
|
return status === true;
|
|
};
|
|
|
|
const checkInfo = async (): Promise<void> => {
|
|
const urls = splitUrls(form.url || '');
|
|
|
|
if (urls.length === 0) {
|
|
toast.error('At least one URL is required.');
|
|
return;
|
|
}
|
|
|
|
if (!form.name) {
|
|
toast.error('The name field is required.');
|
|
return;
|
|
}
|
|
|
|
if (form.folder) {
|
|
form.folder = form.folder.trim();
|
|
await nextTick();
|
|
}
|
|
|
|
if (form.timer) {
|
|
try {
|
|
CronExpressionParser.parse(form.timer);
|
|
} catch (error: any) {
|
|
console.error(error);
|
|
toast.error(`Invalid CRON expression. ${error.message}`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
new URL(urls[0] || '');
|
|
} catch {
|
|
toast.error('Invalid URL');
|
|
return;
|
|
}
|
|
|
|
if (form.cli && '' !== form.cli) {
|
|
const options = await convertOptions(form.cli);
|
|
if (null === options) {
|
|
return;
|
|
}
|
|
form.cli = form.cli.trim();
|
|
}
|
|
|
|
try {
|
|
await requireTimerForTask(form);
|
|
} catch (error: any) {
|
|
toast.error(error.message);
|
|
return;
|
|
}
|
|
|
|
if (urls.length === 1) {
|
|
emitter('submit', {
|
|
reference: toRaw(props.reference),
|
|
task: toRaw({ ...form }),
|
|
archive_all: archiveAllAfterAdd.value,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const tasks: Task[] = urls.map((url, idx) => {
|
|
if (idx === 0) {
|
|
return {
|
|
name: form.name,
|
|
url,
|
|
folder: form.folder,
|
|
preset: form.preset,
|
|
timer: form.timer,
|
|
template: form.template,
|
|
cli: form.cli,
|
|
auto_start: form.auto_start,
|
|
handler_enabled: form.handler_enabled,
|
|
enabled: form.enabled,
|
|
} as Task;
|
|
}
|
|
|
|
return {
|
|
url,
|
|
preset: form.preset,
|
|
timer: form.timer,
|
|
handler_enabled: form.handler_enabled,
|
|
} as Task;
|
|
});
|
|
|
|
try {
|
|
await Promise.all(tasks.map((item) => requireTimerForTask(item)));
|
|
} catch (error: any) {
|
|
toast.error(error.message);
|
|
return;
|
|
}
|
|
|
|
const submitTasks: Task[] = tasks.map((item, idx) => {
|
|
if (idx === 0) {
|
|
return item;
|
|
}
|
|
|
|
return { url: item.url } as Task;
|
|
});
|
|
|
|
emitter('submit', {
|
|
reference: toRaw(props.reference),
|
|
task: submitTasks,
|
|
archive_all: archiveAllAfterAdd.value,
|
|
});
|
|
};
|
|
|
|
const importItem = async (): Promise<void> => {
|
|
const val = import_string.value.trim();
|
|
if (!val) {
|
|
toast.error('The import string is required.');
|
|
return;
|
|
}
|
|
|
|
if (!(await confirmImportOverwrite())) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const item = decode(val) as ExportedTask;
|
|
|
|
if ('task' !== item._type) {
|
|
toast.error(`Invalid import string. Expected type 'task', got '${item._type}'.`);
|
|
import_string.value = '';
|
|
return;
|
|
}
|
|
|
|
form.name = item.name ?? form.name;
|
|
form.url = item.url ?? form.url;
|
|
form.template = item.template ?? form.template;
|
|
form.timer = item.timer ?? form.timer;
|
|
form.folder = item.folder ?? form.folder;
|
|
form.cli = item.cli ?? form.cli;
|
|
form.auto_start = item.auto_start ?? true;
|
|
form.handler_enabled = item.handler_enabled ?? true;
|
|
form.enabled = item.enabled ?? true;
|
|
|
|
if (item.preset) {
|
|
const preset = findPreset(item.preset);
|
|
if (!preset) {
|
|
toast.warning(`Preset '${item.preset}' not found. Preset will be set to default.`);
|
|
form.preset = 'default';
|
|
} else {
|
|
form.preset = item.preset;
|
|
}
|
|
}
|
|
|
|
import_string.value = '';
|
|
showImport.value = false;
|
|
} catch (error: any) {
|
|
console.error(error);
|
|
toast.error(`Failed to import string. ${error.message}`);
|
|
}
|
|
};
|
|
|
|
const convertOptions = async (args: string): Promise<Record<string, any> | null> => {
|
|
try {
|
|
const response = await convertCliOptions(args);
|
|
|
|
if (response.output_template) {
|
|
form.template = response.output_template;
|
|
}
|
|
|
|
if (response.download_path) {
|
|
form.folder = response.download_path;
|
|
}
|
|
|
|
return response.opts as Record<string, any>;
|
|
} catch (error: any) {
|
|
toast.error(error.message);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const is_yt_handle = (url: string): boolean => {
|
|
if (!url || '' === url) {
|
|
return false;
|
|
}
|
|
const m = url.match(CHANNEL_REGEX);
|
|
if (m?.groups) {
|
|
return !m.groups.channelId;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const is_generic_rss = (url: string): boolean => {
|
|
if (!url || '' === url) {
|
|
return false;
|
|
}
|
|
return GENERIC_RSS_REGEX.test(url);
|
|
};
|
|
|
|
const convert_url = async (url: string): Promise<string> => {
|
|
if (!url || '' === url) {
|
|
return url;
|
|
}
|
|
|
|
const m = url.match(CHANNEL_REGEX);
|
|
if (!m?.groups || !m.groups.handle) {
|
|
return url;
|
|
}
|
|
|
|
const params = new URLSearchParams();
|
|
params.append('url', url);
|
|
params.append('args', '-I0');
|
|
|
|
try {
|
|
convertInProgress.value = true;
|
|
const resp = await request('/api/yt-dlp/url/info?' + params.toString());
|
|
const body = await resp.json();
|
|
const channel_id = ag(body, 'channel_id', null);
|
|
|
|
if (channel_id) {
|
|
return url.replace(`/@${m.groups.handle}`, `/channel/${channel_id}`);
|
|
}
|
|
} catch (error: any) {
|
|
console.error(error);
|
|
toast.error(`Error: ${error.message}`);
|
|
} finally {
|
|
convertInProgress.value = false;
|
|
}
|
|
|
|
return url;
|
|
};
|
|
|
|
const convertCurrentUrl = async (): Promise<void> => {
|
|
form.url = await convert_url(form.url);
|
|
};
|
|
|
|
const requireTimerForTask = async (
|
|
item: Pick<Task, 'url' | 'preset' | 'timer' | 'handler_enabled'>,
|
|
): Promise<void> => {
|
|
if (item.timer?.trim()) {
|
|
return;
|
|
}
|
|
|
|
if (item.handler_enabled === false) {
|
|
throw new Error('This task needs a CRON timer because the handler is disabled.');
|
|
}
|
|
|
|
const result = await tasksComposable.inspectTaskHandler({
|
|
url: item.url,
|
|
preset: item.preset,
|
|
static_only: true,
|
|
});
|
|
|
|
if (!result) {
|
|
throw new Error('Failed to verify handler support. Set a CRON timer or try again.');
|
|
}
|
|
|
|
if (result?.matched) {
|
|
return;
|
|
}
|
|
|
|
throw new Error('This task needs a CRON timer because no supported handler matches the URL.');
|
|
};
|
|
|
|
const getDefault = (type: 'cookies' | 'cli' | 'template' | 'folder', ret: string = '') => {
|
|
if (false !== hasFormatInConfig.value || !form.preset) {
|
|
return ret;
|
|
}
|
|
|
|
return getPresetDefault(form.preset, type, ret);
|
|
};
|
|
|
|
onMounted(() => {
|
|
markClean();
|
|
emitter('dirty-change', false);
|
|
});
|
|
</script>
|