ytptube/ui/app/components/ConditionForm.vue
2026-02-20 22:46:30 +03:00

786 lines
26 KiB
Vue

<template>
<main class="columns mt-2 is-multiline">
<div class="column is-12">
<form autocomplete="off" id="addForm" @submit.prevent="checkInfo()">
<div class="card">
<div class="card-header">
<div class="card-header-title is-text-overflow is-block">
<span class="icon-text">
<span class="icon"
><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'"
/></span>
<span>{{ reference ? 'Edit' : 'Add' }}</span>
</span>
</div>
<div class="card-header-icon" v-if="reference">
<button type="button" @click="showImport = !showImport">
<span class="icon"
><i
class="fa-solid"
:class="{
'fa-arrow-down': !showImport,
'fa-arrow-up': showImport,
}"
/></span>
<span>{{ showImport ? 'Hide' : 'Show' }} import</span>
</button>
</div>
</div>
<div class="card-content">
<div class="columns is-multiline is-mobile">
<div class="column is-12" v-if="showImport || !reference">
<label class="label is-inline" for="import_string">
<span class="icon"><i class="fa-solid fa-file-import" /></span>
Import string
</label>
<div class="field has-addons">
<div class="control is-expanded">
<input
type="text"
class="input"
id="import_string"
v-model="import_string"
autocomplete="off"
/>
</div>
<div class="control">
<button
class="button is-primary"
:disabled="!import_string"
type="button"
@click="importItem"
>
<span class="icon"><i class="fa-solid fa-add" /></span>
<span>Import</span>
</button>
</div>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>You can use this field to populate the data, using shared string.</span>
</span>
</div>
<div class="column is-12">
<div class="field">
<label class="label is-inline" for="name">
<span class="icon"><i class="fa-solid fa-tag" /></span>
Name
</label>
<div class="control">
<input
type="text"
class="input"
id="name"
v-model="form.name"
:disabled="addInProgress"
placeholder="For the problematic channel or video name."
/>
</div>
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The name that refers to this condition.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="enabled">
<span class="icon"><i class="fa-solid fa-power-off" /></span>
Enabled
</label>
<div class="control is-unselectable">
<input
id="enabled"
type="checkbox"
v-model="form.enabled"
:disabled="addInProgress"
class="switch is-success"
/>
<label for="enabled" class="is-unselectable">
{{ form.enabled ? 'Yes' : 'No' }}
</label>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span class="is-bold">Whether the condition is enabled.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="priority">
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
Priority
</label>
<div class="control">
<input
type="number"
class="input"
id="priority"
v-model.number="form.priority"
:disabled="addInProgress"
min="0"
placeholder="0"
/>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span class="is-bold">Higher priority conditions are checked first.</span>
</span>
</div>
</div>
<div class="column is-12">
<div class="field">
<label class="label is-inline" for="filter">
<span class="icon"><i class="fa-solid fa-filter" /></span>
Condition Filter
<template v-if="!addInProgress || form.filter">
-
<NuxtLink @click="test_data.show = true" class="is-bold">
Test filter logic
</NuxtLink>
</template>
</label>
<div class="control">
<input
type="text"
class="input"
id="filter"
v-model="form.filter"
:disabled="addInProgress"
placeholder="availability = 'needs_auth' & channel_id = 'channel_id'"
/>
</div>
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span
>yt-dlp <code>--match-filters</code> logic with <code>OR</code>,
<code>||</code> support.</span
>
</span>
</div>
</div>
<div class="column is-12">
<div class="field">
<label class="label is-unselectable" for="cli_options">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>Command options for yt-dlp</span>
</label>
<TextareaAutocomplete
id="cli_options"
v-model="form.cli"
:options="ytDlpOpt"
:disabled="addInProgress"
/>
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>
<NuxtLink @click="showOptions = true">View all options</NuxtLink>. Not all
options are supported
<NuxtLink
target="_blank"
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26"
>some are ignored</NuxtLink
>. Use with caution.
</span>
</span>
</div>
</div>
<div class="column is-12">
<div class="field">
<label class="label is-inline">
<span class="icon"><i class="fa-solid fa-plus-square" /></span>
Extra/Custom Options
</label>
<div class="content">
<div v-if="form.extras && Object.keys(form.extras).length > 0" class="mb-3">
<div
v-for="(value, key) in form.extras"
:key="key"
class="field is-grouped mb-2"
>
<div class="control">
<input
type="text"
class="input"
:value="key"
@input="updateExtraKey($event, key)"
:disabled="addInProgress"
placeholder="key_name"
/>
</div>
<div class="control is-expanded">
<input
type="text"
class="input"
:value="value"
@input="updateExtraValue(key, $event)"
:disabled="addInProgress"
placeholder="value"
/>
</div>
<div class="control">
<button
type="button"
class="button is-danger"
@click="removeExtra(key)"
:disabled="addInProgress"
>
<span class="icon"><i class="fa-solid fa-times" /></span>
</button>
</div>
</div>
</div>
<div class="field is-grouped">
<div class="control">
<input
type="text"
class="input"
v-model="newExtraKey"
:disabled="addInProgress"
placeholder="new_key"
@keyup.enter="addExtra"
/>
</div>
<div class="control is-expanded">
<input
type="text"
class="input"
v-model="newExtraValue"
:disabled="addInProgress"
placeholder="new_value"
@keyup.enter="addExtra"
/>
</div>
<div class="control">
<button
type="button"
class="button is-primary"
@click="addExtra"
:disabled="addInProgress || !newExtraKey || !newExtraValue"
>
<span class="icon"><i class="fa-solid fa-plus" /></span>
<span>Add</span>
</button>
</div>
</div>
</div>
<span class="help">
<div class="message is-info">
<div class="message-body content pl-0 is-small pt-1">
<ul>
<li>
For advanced users only. This feature is meant to be expanded later.
</li>
<li>Keys must be lowercase with underscores (e.g., custom_field).</li>
<li class="has-text-danger is-bold">
You must click on Add to actually add the option.
</li>
<li>
The key <code>ignore_download</code> with value of
<code>true</code> will instruct <b>YTPTube</b> to ignore the download
and directly mark the item as archived. this is useful to skip certain
kind of downloads.
</li>
<li>
The key <code>set_preset</code> with the name of an existing preset will
instruct <b>YTPTube</b> to switch the download to use the specified
preset. This is useful to apply different download settings based on
content type or source.
</li>
</ul>
</div>
</div>
</span>
</div>
</div>
<div class="column is-12">
<div class="field">
<label class="label is-inline" for="description">
<span class="icon"><i class="fa-solid fa-comment" /></span>
Description
</label>
<div class="control">
<textarea
class="textarea"
id="description"
v-model="form.description"
:disabled="addInProgress"
placeholder="Describe what this condition does"
/>
</div>
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Use this field to help understand the purpose of this condition.</span>
</span>
</div>
</div>
</div>
</div>
<div class="card-footer mt-auto">
<div class="card-footer-item">
<div class="card-footer-item">
<button
class="button is-fullwidth is-primary"
:disabled="addInProgress"
type="submit"
:class="{ 'is-loading': addInProgress }"
form="addForm"
>
<span class="icon"><i class="fa-solid fa-save" /></span>
<span>Save</span>
</button>
</div>
<div class="card-footer-item">
<button
class="button is-fullwidth is-danger"
@click="emitter('cancel')"
:disabled="addInProgress"
type="button"
>
<span class="icon"><i class="fa-solid fa-times" /></span>
<span>Cancel</span>
</button>
</div>
</div>
</div>
</div>
</form>
</div>
<div v-if="test_data.show" class="column is-12">
<Modal @close="test_data.show = false" title="Test condition">
<form autocomplete="off" id="testCondition" @submit.prevent="run_test()">
<div class="card">
<div class="card-content">
<div class="field">
<label class="label is-inline" for="url">
<span class="icon"><i class="fa-solid fa-link" /></span>
URL
</label>
<div class="field-body">
<div class="field is-grouped">
<div class="control is-expanded">
<input
type="url"
class="input"
id="url"
v-model="test_data.url"
:disabled="test_data.in_progress"
placeholder="https://..."
required
/>
</div>
<div class="control">
<button
class="button is-primary"
type="submit"
:disabled="test_data.in_progress"
:class="{ 'is-loading': test_data.in_progress }"
>
<span class="icon"><i class="fa-solid fa-play" /></span>
<span>Test</span>
</button>
</div>
</div>
</div>
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The url to test the filter against.</span>
</span>
</div>
<div class="field">
<label class="label is-inline" for="filter">
<span class="icon"><i class="fa-solid fa-filter" /></span>
Condition Filter
</label>
<div class="control">
<input
type="text"
class="input"
id="filter"
v-model="form.filter"
:disabled="test_data.in_progress"
placeholder="availability = 'needs_auth' & channel_id = 'channel_id'"
required
/>
</div>
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span
>yt-dlp <code>--match-filters</code> logic with <code>OR</code>,
<code>||</code> support.</span
>
</span>
</div>
<div class="field">
<span
class="is-bold"
:class="{
'has-text-success': true === logic_test,
'has-text-danger': false === logic_test,
}"
>
<span class="icon"
><i
class="fa-solid"
:class="{
'fa-check': true === logic_test,
'fa-xmark': false === logic_test,
'fa-question': null === logic_test,
}"
/></span>
Filter Status:
<template v-if="null === test_data?.data?.status">Not tested</template>
<template v-else>{{ logic_test ? 'Matched' : 'Not matched' }}</template>
</span>
</div>
<div class="field">
<pre style="height: 60vh"><code>{{ show_data() }}</code></pre>
</div>
</div>
</div>
</form>
</Modal>
</div>
<Modal v-if="showOptions" @close="showOptions = false" :contentClass="'modal-content-max'">
<YTDLPOptions />
</Modal>
</main>
</template>
<script setup lang="ts">
import 'assets/css/bulma-switch.css';
import { useStorage } from '@vueuse/core';
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue';
import type { AutoCompleteOptions } from '~/types/autocomplete';
import type { Condition } from '~/types/conditions';
import { useConfirm } from '~/composables/useConfirm';
import type { ImportedItem } from '~/types';
const emitter = defineEmits<{
(e: 'cancel'): void;
(e: 'submit', payload: { reference: number | null | undefined; item: Condition }): void;
}>();
const props = defineProps<{
reference?: number | null;
item: Condition;
addInProgress?: boolean;
}>();
const toast = useNotification();
const showImport = useStorage('showImport', false);
const box = useConfirm();
const config = useConfigStore();
const form = reactive<Condition>(JSON.parse(JSON.stringify(props.item)));
const import_string = ref('');
const newExtraKey = ref('');
const newExtraValue = ref('');
const test_data = ref<{
show: boolean;
url: string;
in_progress: boolean;
changed: boolean;
data: { status: boolean | null; data: Record<string, any> };
}>({ show: false, url: '', in_progress: false, changed: false, data: { status: null, data: {} } });
const showOptions = ref<boolean>(false);
const ytDlpOpt = ref<AutoCompleteOptions>([]);
if (!form.extras) {
form.extras = {};
}
if (form.enabled === undefined) {
form.enabled = true;
}
if (form.priority === undefined) {
form.priority = 0;
}
if (form.description === undefined) {
form.description = '';
}
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.filter,
() => (test_data.value.changed = true),
);
const checkInfo = async (): Promise<void> => {
const required: (keyof Condition)[] = ['name', 'filter'];
for (const key of required) {
if (!form[key]) {
toast.error(`The ${key} field is required.`);
return;
}
}
if ((!form.cli || '' === form.cli.trim()) && Object.keys(form.extras).length < 1) {
toast.error('Command options for yt-dlp or at least one extra option is required.');
return;
}
if (form.cli && '' !== form.cli.trim()) {
const options = await convertOptions(form.cli);
if (options === null) {
return;
}
form.cli = form.cli.trim();
}
const copy: Condition = JSON.parse(JSON.stringify(form));
for (const key in copy) {
if (typeof (copy as any)[key] !== 'string') {
continue;
}
(copy as any)[key] = (copy as any)[key].trim();
}
emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) });
};
const convertOptions = async (args: string): Promise<any | null> => {
try {
const response = await convertCliOptions(args);
return response.opts;
} catch (e: any) {
toast.error(e.message);
}
return null;
};
const run_test = async (): Promise<void> => {
if (!test_data.value.url) {
toast.error('The URL is required for testing.', { force: true });
return;
}
try {
new URL(test_data.value.url);
} catch {
toast.error('The URL is invalid.', { force: true });
return;
}
test_data.value.in_progress = true;
test_data.value.data.status = false;
try {
const response = await request('/api/conditions/test', {
method: 'POST',
body: JSON.stringify({ url: test_data.value.url, condition: form.filter }),
});
const json = await response.json();
if (!response.ok) {
toast.error(json.message || json.error || 'Unknown error', { force: true });
return;
}
test_data.value.data = json;
test_data.value.changed = false;
} catch (error: any) {
toast.error(`Failed to test condition. ${error.message}`);
} finally {
test_data.value.in_progress = false;
}
};
const importItem = async (): Promise<void> => {
const val = import_string.value.trim();
if (!val) {
toast.error('The import string is required.');
return;
}
try {
const item = decode(val) as Condition & ImportedItem;
if (!item._type || item._type !== 'condition') {
toast.error(
`Invalid import string. Expected type 'condition', got '${item._type ?? 'unknown'}'.`,
);
return;
}
if (
(form.filter || form.cli || Object.keys(form.extras).length > 0) &&
!(await box.confirm('Overwrite the current form fields?'))
) {
return;
}
if (item.name) {
form.name = item.name;
}
if (item.filter) {
form.filter = item.filter;
}
if (item.cli) {
form.cli = item.cli;
}
if (item.extras) {
form.extras = { ...item.extras };
}
if (item.enabled !== undefined) {
form.enabled = item.enabled;
}
if (item.priority !== undefined) {
form.priority = item.priority;
}
if (item.description !== undefined) {
form.description = item.description;
}
import_string.value = '';
showImport.value = false;
} catch (e: any) {
console.error(e);
toast.error(`Failed to parse import string. ${e.message}`);
}
};
const show_data = (): string => {
if (!test_data.value.data?.data || Object.keys(test_data.value.data.data).length === 0) {
return 'No data to show.';
}
return JSON.stringify(test_data.value.data.data, null, 2);
};
const logic_test = computed(() => {
if (Object.keys(test_data.value.data?.data ?? {}).length < 1) {
return null;
}
if (!test_data.value.changed) {
return test_data.value.data.status;
}
try {
const st = match_str(form.filter, test_data.value.data.data);
console.log('Logic test:', st, form.filter, test_data.value.data.data);
return st;
} catch (e: any) {
console.error(e);
return false;
}
});
const validateKey = (key: string): boolean => /^[a-z][a-z0-9_]*$/.test(key);
const parseValue = (value: string): string | number | boolean => {
if (!isNaN(Number(value)) && !isNaN(parseFloat(value))) {
return Number(value);
}
if ('true' === value.toLowerCase()) {
return true;
}
if ('false' === value.toLowerCase()) {
return false;
}
return value;
};
const addExtra = (): void => {
const key = newExtraKey.value.trim();
const value = newExtraValue.value.trim();
if (!key || !value) {
toast.error('Both key and value are required.');
return;
}
if (!validateKey(key)) {
toast.error('Key must be lower_case.');
return;
}
if (form.extras[key]) {
toast.error(`Key '${key}' already exists.`);
return;
}
form.extras[key] = parseValue(value);
newExtraKey.value = '';
newExtraValue.value = '';
};
const removeExtra = (key: string): void => {
const { [key]: _, ...rest } = form.extras;
form.extras = rest;
};
const updateExtraKey = (event: Event, oldKey: string): void => {
const target = event.target as HTMLInputElement;
const newKey = target.value.trim();
if (!newKey) {
return;
}
if (!validateKey(newKey)) {
toast.error('Key must be lowercase and contain only letters, numbers, and underscores.');
target.value = oldKey;
return;
}
if (newKey !== oldKey) {
if (form.extras[newKey]) {
toast.error(`Key '${newKey}' already exists.`);
target.value = oldKey;
return;
}
const value = form.extras[oldKey];
const { [oldKey]: _, ...rest } = form.extras;
form.extras = { ...rest, [newKey]: value };
}
};
const updateExtraValue = (key: string, event: Event): void => {
const target = event.target as HTMLInputElement;
const value = target.value.trim();
form.extras[key] = value ? parseValue(value) : '';
};
</script>