Merge pull request #366 from arabcoders/dev

Custom Fields Feature
This commit is contained in:
Abdulmohsen 2025-08-08 00:02:45 +03:00 committed by GitHub
commit 3f11262afb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 995 additions and 107 deletions

View file

@ -30,9 +30,11 @@
"dateparser",
"daterange",
"defusedxml",
"dlfields",
"dotenv",
"edgechromium",
"engineio",
"Errno",
"euuo",
"faststart",
"finaldir",

41
FAQ.md Normal file
View file

@ -0,0 +1,41 @@
# I cant download anything
If you are receiving errors like:
- "OSError: [Errno 5] I/O error"
- "OSError: [Errno 18] Cross-device link: '/tmp/random_id/name.webm' -> '/downloads/name.webm'
- "Operation not permitted: '/downloads/name.webm'
This indicates an error with your mounts and how they interact with the container. So, the basic solution is to do the following:
```yaml
services:
ytptube:
user: "${UID:-1000}:${UID:-1000}" # change this to your user id and group id, for example: "1000:1000"
image: ghcr.io/arabcoders/ytptube:latest
container_name: ytptube
restart: unless-stopped
environment:
- YTP_TEMP_PATH=/downloads/tmp
- YTP_DOWNLOAD_PATH=/downloads/files
ports:
- "8081:8081"
volumes:
- ./config:/config:rw
- ./downloads:/downloads:rw
```
Then run the following command to create the necessary directories and start the container:
```bash
mkdir -p ./config && mkdir -p ./downloads/{tmp,files} && docker compose -f compose.yaml up -d
```
Reference: [Issue #363](https://github.com/arabcoders/ytptube/issues/363)
# I want to use link with playlist but only download the video not all the videos in the playlist?
Simply create a preset, and in the `Command options for yt-dlp` field set `--no-playlist`. Then select the preset
whenever the link includes a playlist id.
> [!NOTE]
> You can also do the same via advanced options `Command options for yt-dlp` field, but presets are more convenient.

View file

@ -58,6 +58,9 @@ class Events:
PRESETS_ADD: str = "presets_add"
PRESETS_UPDATE: str = "presets_update"
DLFIELDS_ADD: str = "dlfields_add"
DLFIELDS_UPDATE: str = "dlfields_update"
SCHEDULE_ADD: str = "schedule_add"
CONDITIONS_ADD: str = "conditions_add"
@ -104,6 +107,7 @@ class Events:
Events.CLI_CLOSE,
Events.CLI_OUTPUT,
Events.PRESETS_UPDATE,
Events.DLFIELDS_UPDATE,
]
def only_debug() -> list:

344
app/library/dl_fields.py Normal file
View file

@ -0,0 +1,344 @@
import json
import logging
import re
import uuid
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any
from aiohttp import web
from .config import Config
from .encoder import Encoder
from .Events import EventBus, Events
from .Singleton import Singleton
from .Utils import init_class
LOG = logging.getLogger("DLFields")
class FieldType(str, Enum):
STRING = "string"
TEXT = "text"
BOOL = "bool"
@classmethod
def all(cls) -> list[str]:
return [member.value for member in cls]
@classmethod
def from_value(cls, value: str) -> "FieldType":
"""
Returns the FieldType enum member corresponding to the given value.
Args:
value (str): The value to match against the enum members.
Returns:
StoreType: The enum member that matches the value.
Raises:
ValueError: If the value does not match any member.
"""
for member in cls:
if member.value == value:
return member
msg = f"Invalid StoreType value: {value}"
raise ValueError(msg)
def __str__(self) -> str:
return self.value
@dataclass(kw_only=True)
class DLField:
id: str = field(default_factory=lambda: str(uuid.uuid4()))
"""The id of the field."""
name: str
"""The name of the preset."""
description: str
"""The description of the preset."""
field: str
"""The yt-dlp field to use in long format."""
kind: FieldType = FieldType.TEXT
"""The kind of the field. i.e. string, bool"""
icon: str = ""
"""The icon of the field, it can be a font-awesome icon"""
order: int = 0
"""The order of the field, used to sort the fields in the UI."""
value: str = ""
"""The default value of the field, It's currently unused."""
extras: dict = field(default_factory=dict)
"""Additional options for the field."""
def serialize(self) -> dict:
dct = self.__dict__
dct["kind"] = str(self.kind)
dct["extras"] = {k: v for k, v in self.extras.items() if v is not None}
return dct
def json(self) -> str:
return Encoder().encode(self.serialize())
def get(self, key: str, default: Any = None) -> Any:
return self.serialize().get(key, default)
class DLFields(metaclass=Singleton):
"""
This class is used to manage the DLFields.
"""
_items: list[DLField] = []
"""The list of items."""
_instance = None
"""The instance of the class."""
_config: Config = None
"""The config instance."""
def __init__(self, file: str | Path | None = None, config: Config | None = None):
DLFields._instance = self
self._config = config or Config.get_instance()
self._file: Path = Path(file) if file else Path(self._config.config_path).joinpath("dl_fields.json")
if self._file.exists() and "600" != self._file.stat().st_mode:
try:
self._file.chmod(0o600)
except Exception:
pass
def event_handler(_, __):
msg = "Not implemented"
raise Exception(msg)
EventBus.get_instance().subscribe(Events.DLFIELDS_ADD, event_handler, f"{__class__.__name__}.add")
@staticmethod
def get_instance() -> "DLFields":
"""
Get the instance of the class.
Returns:
DLFields: The instance of the class
"""
if not DLFields._instance:
DLFields._instance = DLFields()
return DLFields._instance
async def on_shutdown(self, _: web.Application):
pass
def attach(self, _: web.Application):
"""
Attach the class to the aiohttp application.
Args:
_ (web.Application): The aiohttp application.
Returns:
None
"""
self.load()
def get_all(self) -> list[DLField]:
"""Return the items."""
return self._items
def load(self) -> "DLFields":
"""
Load the items.
Returns:
Presets: The current instance.
"""
has: int = len(self._items)
self.clear()
if not self._file.exists() or self._file.stat().st_size < 10:
return self
try:
LOG.info(f"{'Reloading' if has else 'Loading'} '{self._file}'.")
items: dict = json.loads(self._file.read_text())
except Exception as e:
LOG.error(f"Failed to parse '{self._file}'. '{e}'.")
return self
if not items or len(items) < 1:
return self
need_save = False
for i, item in enumerate(items):
try:
if "id" not in item:
item["id"] = str(uuid.uuid4())
need_save = True
item: DLField = init_class(DLField, item)
self._items.append(item)
except Exception as e:
LOG.error(f"Failed to parse '{self._file}:{i}'. '{e!s}'.")
continue
if need_save:
LOG.info(f"Saving '{self._file}'.")
self.save(self._items)
return self
def clear(self) -> "DLFields":
"""
Clear all items.
Returns:
DLFields: The current instance.
"""
if len(self._items) < 1:
return self
self._items.clear()
return self
def validate(self, item: DLField | dict) -> bool:
"""
Validate the item.
Args:
item (DLField|dict): The item to validate.
Returns:
bool: True if valid
Raises:
ValueError: If the item is not valid.
"""
if not isinstance(item, dict):
if not isinstance(item, DLField):
msg = f"Unexpected '{type(item).__name__}' type was given."
raise ValueError(msg) # noqa: TRY004
item = item.serialize()
for key in ["id", "name", "description", "field", "kind"]:
if key not in item:
msg = f"Missing required key '{key}'."
raise ValueError(msg)
if item.get("kind") not in FieldType.all():
msg = f"Invalid field type '{item.get('kind')}'."
raise ValueError(msg)
if item.get("extras") and not isinstance(item.get("extras"), dict):
msg = "Extras must be a dictionary."
raise ValueError(msg)
if item.get("value") and not isinstance(item.get("value"), str):
msg = "Value must be a string."
raise ValueError(msg)
if item.get("order") is not None and not isinstance(item.get("order"), int):
msg = "Order must be an integer."
raise ValueError(msg)
if not isinstance(item.get("extras", {}), dict):
msg = "Extras must be a dictionary."
raise ValueError(msg) # noqa: TRY004
if re.match(r"^--[a-zA-Z0-9\-]+$", item.get("field", "").strip()) is None:
msg = "Invalid yt-dlp option field it must starts with '--' and contain only alphanumeric characters."
raise ValueError(msg)
return True
def save(self, items: list[DLField | dict]) -> "DLFields":
"""
Save the items.
Args:
items (list[DLField]): The items to save.
Returns:
Presets: The current instance.
"""
for i, item in enumerate(items):
try:
if not isinstance(item, DLField):
item: DLField = init_class(DLField, item)
items[i] = item
except Exception as e:
LOG.error(f"Failed to save item '{i}' due to parsing error. '{e!s}'.")
continue
try:
self.validate(item)
except ValueError as e:
LOG.error(f"Failed to validate item '{i}: {item.name}'. '{e}'.")
continue
try:
self._file.write_text(json.dumps(obj=[item.serialize() for item in items], indent=4))
LOG.info(f"Saved '{self._file}'.")
except Exception as e:
LOG.error(f"Failed to save '{self._file}'. '{e!s}'.")
return self
def has(self, id_or_name: str) -> bool:
"""
Check if the item exists by id or name.
Args:
id_or_name (str): The id or name of the item.
Returns:
bool: True if exists, False otherwise.
"""
return self.get(id_or_name) is not None
def get(self, id_or_name: str) -> DLField | None:
"""
Get the item by id or name.
Args:
id_or_name (str): The id or name of the item.
Returns:
Preset|None: The item if found, None otherwise.
"""
if not id_or_name:
return None
for item in self.get_all():
if id_or_name not in (item.id, item.name):
continue
return item
return None

View file

@ -19,6 +19,7 @@ from aiohttp import web
from app.library.BackgroundWorker import BackgroundWorker
from app.library.conditions import Conditions
from app.library.config import Config
from app.library.dl_fields import DLFields
from app.library.DownloadQueue import DownloadQueue
from app.library.Events import EventBus, Events
from app.library.HttpAPI import HttpAPI
@ -127,6 +128,7 @@ class Main:
Presets.get_instance().attach(self._app)
Notification.get_instance().attach(self._app)
Conditions.get_instance().attach(self._app)
DLFields.get_instance().attach(self._app)
self._background_worker.attach(self._app)
EventBus.get_instance().sync_emit(

100
app/routes/api/dl_fields.py Normal file
View file

@ -0,0 +1,100 @@
import logging
import uuid
from aiohttp import web
from aiohttp.web import Request, Response
from app.library.dl_fields import DLField, DLFields
from app.library.encoder import Encoder
from app.library.Events import EventBus, Events
from app.library.router import route
from app.library.Utils import init_class, validate_uuid
LOG: logging.Logger = logging.getLogger(__name__)
@route("GET", "api/dl_fields/", "dl_fields")
async def dl_fields(request: Request, encoder: Encoder) -> Response:
"""
Get the dl_fields.
Args:
request (Request): The request object.
encoder (Encoder): The encoder instance.
Returns:
Response: The response object.
"""
data: list[DLField] = DLFields.get_instance().get_all()
filter_fields: str | None = request.query.get("filter", None)
if filter_fields:
fields: list[str] = [field.strip() for field in filter_fields.split(",")]
data = [{key: value for key, value in preset.serialize().items() if key in fields} for preset in data]
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("PUT", "api/dl_fields/", "dl_fields_add")
async def dl_fields_add(request: Request, encoder: Encoder, notify: EventBus) -> Response:
"""
Add presets.
Args:
request (Request): The request object.
encoder (Encoder): The encoder instance.
notify (EventBus): The event bus instance.
Returns:
Response: The response object
"""
data = await request.json()
if not isinstance(data, list):
return web.json_response(
{"error": "Invalid request body expecting list with dicts."},
status=web.HTTPBadRequest.status_code,
)
items: list[DLField] = []
cls = DLFields.get_instance()
for item in data:
if not isinstance(item, dict):
return web.json_response(
{"error": "Invalid request body expecting list with dicts."},
status=web.HTTPBadRequest.status_code,
)
if not item.get("name"):
return web.json_response(
{"error": "name is required.", "data": item}, status=web.HTTPBadRequest.status_code
)
if not item.get("id", None) or not validate_uuid(item.get("id"), version=4):
item["id"] = str(uuid.uuid4())
try:
cls.validate(item)
except ValueError as e:
return web.json_response(
{"error": f"Failed to validate preset '{item.get('name')}'. '{e!s}'"},
status=web.HTTPBadRequest.status_code,
)
items.append(init_class(DLField, item))
try:
items = cls.save(items=items).load().get_all()
except Exception as e:
LOG.exception(e)
return web.json_response(
{"error": "Failed to save download fields.", "message": str(e)},
status=web.HTTPInternalServerError.status_code,
)
await notify.emit(Events.DLFIELDS_UPDATE, data=items)
return web.json_response(data=items, status=web.HTTPOk.status_code, dumps=encoder.encode)

View file

@ -6,6 +6,7 @@ from typing import Any
import socketio
from app.library.config import Config
from app.library.dl_fields import DLFields
from app.library.DownloadQueue import DownloadQueue
from app.library.Events import EventBus, Events
from app.library.Presets import Presets
@ -26,6 +27,7 @@ async def connect(config: Config, queue: DownloadQueue, notify: EventBus, sid: s
**queue.get(),
"config": config.frontend(),
"presets": Presets.get_instance().get_all(),
"dl_fields": DLFields.get_instance().get_all(),
"paused": queue.is_paused(),
}

View file

@ -0,0 +1,276 @@
<template>
<form class="modal is-active" @submit.prevent="updateItems">
<div class="modal-background" @click="cancel" />
<div class="modal-card" style="min-width:calc(100vw - 30vw);">
<header class="modal-card-head">
<p class="modal-card-title">Manage Custom Fields</p>
<button type="button" class="delete" aria-label="close" @click="cancel" />
</header>
<section class="modal-card-body">
<div class="columns is-multiline">
<div class="column is-12 is-clearfix is-unselectable">
<span class="title is-4">
<span class="icon-text">
<span class="icon"><i class="fas fa-plus" /></span>
<span>Custom Fields</span>
</span>
</span>
<div class="is-pulled-right">
<div class="field is-grouped">
<p class="control">
<button type="button" class="button is-primary" @click="addNewField" :disabled="isLoading">
<span class="icon"><i class="fas fa-add" /></span>
</button>
</p>
<p class="control">
<button type="button" class="button is-info" @click="loadContent()"
:class="{ 'is-loading': isLoading }" :disabled="isLoading">
<span class="icon"><i class="fas fa-refresh" /></span>
</button>
</p>
</div>
</div>
<div class="is-hidden-mobile">
<span class="subtitle">The idea behind the custom fields is to allow you to add new fields to the download
form to automate some of the yt-dlp options.</span>
</div>
</div>
<div class="column is-12" v-for="(item, index) in sortedDLFields" :key="item.id || index">
<div class="box">
<div class="columns is-multiline">
<div class="column is-12 is-12-mobile">
<NuxtLink @click="deleteField(index)" :disabled="isLoading" class="has-text-danger">
<span class="icon"><i class="fas fa-trash" /></span>
<span>Delete Field</span>
</NuxtLink>
</div>
<div class="column is-6 is-12-mobile">
<div class="field">
<label class="label">
<span class="icon"><i class="fas fa-cog" /></span>
<span>Field Type</span>
</label>
<div class="select is-fullwidth" :class="{ 'is-loading': isLoading }">
<select v-model="item.kind" :disabled="isLoading" class="is-capitalized">
<option v-for="kind in Object.values(FieldTypes)" :key="`kind-${kind}`" :value="kind"
v-text="kind" />
</select>
</div>
<span class="help is-bold">
Field Type. String is a single line input, Text is a multi-line input, Bool is a checkbox.
</span>
</div>
</div>
<div class="column is-6 is-12-mobile">
<div class="field">
<label class="label">
<span class="icon"><i class="fas fa-font" /></span>
<span>Field Name</span>
</label>
<input type="text" v-model="item.name" class="input" :disabled="isLoading" />
<span class="help is-bold">
The name of the field, it will be shown in the UI.
</span>
</div>
</div>
<div class="column is-6 is-12-mobile">
<div class="field">
<label class="label">
<span class="icon"><i class="fas fa-terminal" /></span>
<span>Associated yt-dlp option</span>
</label>
<input type="text" v-model="item.field" class="input" :disabled="isLoading" />
<span class="help is-bold">
The long form of yt-dlp option name, e.g. <code>--no-overwrites</code> not <code>-w</code>.
</span>
</div>
</div>
<div class="column is-6 is-12-mobile">
<div class="field">
<label class="label">
<span class="icon"><i class="fas fa-info-circle" /></span>
<span>Field Description</span>
</label>
<input type="text" v-model="item.description" class="input" :disabled="isLoading" />
<span class="help is-bold">
A short description of the field, it will be shown in the UI.
</span>
</div>
</div>
<div class="column is-6 is-12-mobile">
<div class="field">
<label class="label">
<span class="icon"><i class="fas fa-sort-numeric-up" /></span>
<span>Field Order</span>
</label>
<input type="number" v-model="item.order" class="input" :disabled="isLoading" />
<span class="help is-bold">
The order of the field, used to sort the fields in the UI. Lower numbers will appear first.
</span>
</div>
</div>
<div class="column is-6 is-12-mobile">
<div class="field">
<label class="label">
<span class="icon"><i class="fas fa-image" /></span>
<span>Field Icon</span>
</label>
<input type="text" v-model="item.icon" class="input" :disabled="isLoading" />
<span class="help is-bold">
The icon of the field, must be from <NuxtLink
href="https://fontawesome.com/search?ic=free&o=r" target="_blank">
font-awesome</NuxtLink> icon. e.g. <code>fa-solid fa-image</code>. Leave empty for no icon.
</span>
</div>
</div>
</div>
</div>
</div>
<div class="column is-12">
<Message message_class="has-background-warning-90 has-text-dark" v-if="items.length === 0">
<span class="icon">
<i class="fas fa-exclamation-circle" />
</span>
<span>
No custom fields found, you can add new fields using the button above.
</span>
</Message>
</div>
</div>
</section>
<footer class="modal-card-foot p-5">
<div class="field is-grouped" style="width:100%">
<div class="control is-expanded">
<button type="submit" class="button is-fullwidth is-primary" :disabled="isLoading">
<span class="icon"><i class="fas fa-save" /></span>
<span>Save</span>
</button>
</div>
<div class="control is-expanded">
<button type="button" class="button is-fullwidth is-danger" @click="cancel" :disabled="isLoading">
<span class="icon"><i class="fas fa-times" /></span>
<span>Cancel</span>
</button>
</div>
</div>
</footer>
</div>
</form>
</template>
<script setup lang="ts">
import { defineEmits, ref } from 'vue'
import type { DLField } from '~/types/dl_fields'
const emitter = defineEmits<{ (e: 'cancel'): void }>()
const toast = useNotification()
const isLoading = ref<boolean>(false)
const items = ref<DLField[]>([])
const FieldTypes = {
STRING: 'string',
TEXT: 'text',
BOOL: 'bool'
}
const cancel = () => emitter('cancel')
const loadContent = async () => {
try {
isLoading.value = true
const response = await request('/api/dl_fields')
const data = await response.json()
if (0 === data.length) {
return
}
items.value = data
} catch (e: any) {
console.error(e)
toast.error('Failed to fetch page content.')
} finally {
isLoading.value = false
}
}
const updateItems = async () => {
for (const item of items.value) {
if (validateItem(item, items.value.indexOf(item) + 1)) {
continue
}
return
}
try {
isLoading.value = true
const resp = await request('/api/dl_fields', {
method: 'PUT',
body: JSON.stringify(items.value)
})
if (!resp.ok) {
const error = await resp.json()
toast.error(`Failed to update fields: ${error.error || 'Unknown error'}`)
return
}
toast.success('Fields updated successfully.')
emitter('cancel')
} finally {
isLoading.value = false
}
}
const addNewField = () => items.value.push({
name: '',
description: '',
kind: 'string',
field: '',
value: '',
icon: '',
order: items.value.reduce((max, item) => Math.max(max, item.order || 1), 0) + 1,
extras: {}
})
const deleteField = (index: number) => items.value.splice(index, 1)
const validateItem = (item: DLField, index: number): boolean => {
const requiredFields = ['name', 'field', 'kind', 'description']
for (const field of requiredFields) {
if (!item[field as keyof DLField]) {
toast.error(`${item.name || index}: Field ${field} is required.`)
return false
}
}
if (!item.order || item.order < 1) {
toast.error(`${item.name || index}: Order must be a positive number.`)
return false
}
if (!Object.values(FieldTypes).includes(item.kind)) {
toast.error(`${item.name || index}: Invalid field type: ${item.kind}`)
return false
}
if (!/^--[a-zA-Z0-9\-]+$/.test(item.field)) {
toast.error(`${item.name || index}: Invalid field format, it must start with '--' and contain no spaces.`)
return false
}
return true
}
onMounted(async () => await loadContent())
const sortedDLFields = computed(() => items.value.sort((a, b) => (a.order || 0) - (b.order || 0)))
</script>

View file

@ -0,0 +1,47 @@
<template>
<div class="field">
<label :for="`dlf-${id}`" class="label is-unselectable">
<span v-if="icon" class="icon"><i :class="icon" /></span>
<span v-tooltip="field ? `yt-dlp option: ${field}` : null" :class="{ 'has-tooltip': field }">{{ label }}</span>
</label>
<div class="control is-expanded" v-if="'string' === type">
<input :id="`dlf-${id}`" :type="type" class="input" v-model="model" :placeholder="placeholder"
:disabled="disabled" />
</div>
<div class="control is-expanded" v-if="'text' === type">
<textarea class="textarea is-pre" :id="`dlf-${id}`" v-model="model" :placeholder="placeholder"
:disabled="disabled"></textarea>
</div>
<div class="control is-expanded" v-if="'bool' === type">
<input type="checkbox" :id="`dlf-${id}`" v-model="model" class="switch is-success" :disabled="disabled" />
<label :for="`dlf-${id}`" class="label is-unselectable">
{{ model ? 'Yes' : 'No' }}
</label>
</div>
<span class="help is-bold is-unselectable">
<template v-if="description">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span v-text="description" />
</template>
<template v-if="$slots.help">
<slot name="help"></slot>
</template>
</span>
</div>
</template>
<script setup lang="ts">
import type { ModelRef } from 'vue';
import type { DLFieldType } from '~/types/dl_fields';
defineProps<{
id: string,
label: string,
field?: string,
type: DLFieldType,
description?: string
icon?: string
placeholder?: string
disabled?: boolean
}>()
const model = defineModel() as ModelRef<string>
</script>

View file

@ -88,96 +88,82 @@
</div>
<div class="columns is-multiline is-mobile" v-if="showAdvanced && !config.app.basic_mode">
<div class="column is-3-tablet is-12-mobile" v-if="!config.app.basic_mode">
<div class="field">
<input id="force_download" type="checkbox" class="switch is-danger" :checked="forceDownload"
@change="forceDownload = !forceDownload" :disabled="addInProgress" />
<label for="force_download" class="is-unselectable">Force download</label>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span class="is-bold">Ignore archive and re-download.</span>
</span>
<div class="column is-3-tablet is-12-mobile">
<DLInput id="force_download" type="bool" label="Force download"
v-model="dlFields['--no-download-archive']" icon="fa-solid fa-download"
:disabled="!socket.isConnected || addInProgress" description="Ignore archive and re-download." />
</div>
<div class="column is-3-tablet is-12-mobile" v-if="!config.app.basic_mode">
<div class="field">
<input id="auto_start" type="checkbox" v-model="auto_start" :disabled="addInProgress"
class="switch is-success" />
<label for="auto_start" class="is-unselectable">Auto start</label>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span class="is-bold">Whether to start the download automatically.</span>
</span>
<div class="column is-3-tablet is-12-mobile">
<DLInput id="auto_start" type="bool" label="Auto start" v-model="auto_start" icon="fa-solid fa-play"
:disabled="!socket.isConnected || addInProgress"
description="Whether to start the download automatically." />
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field has-addons">
<div class="control">
<label for="output_format" class="button is-static is-unselectable">
<span class="icon"><i class="fa-solid fa-file" /></span>
<span>Template</span>
</label>
</div>
<div class="control is-expanded">
<input type="text" class="input" v-model="form.template" id="output_format"
:disabled="!socket.isConnected || addInProgress" :placeholder="get_output_template()">
</div>
</div>
<span class="help is-bold is-unselectable">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>All output template naming options can be found at <NuxtLink target="_blank"
to="https://github.com/yt-dlp/yt-dlp#output-template">this page</NuxtLink>.</span>
</span>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline is-unselectable" for="cli_options">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
Command options for yt-dlp
</label>
<div class="control">
<textarea class="textarea is-pre" v-model="form.cli" id="cli_options"
:disabled="!socket.isConnected || addInProgress"
placeholder="command options to use, e.g. --no-embed-metadata --no-embed-thumbnail" />
</div>
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Check <NuxtLink target="_blank" to="https://github.com/yt-dlp/yt-dlp#general-options">this page
</NuxtLink> for more info. <span class="has-text-danger">Not all options are supported <NuxtLink
target="_blank"
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26-L48">some are
ignored</NuxtLink>. Use with caution these options can break yt-dlp or the frontend.</span>
<DLInput id="output_template" type="string" label="Output template" v-model="form.template"
icon="fa-solid fa-file" :disabled="!socket.isConnected || addInProgress"
:placeholder="get_output_template()">
<template #help>
<span class="help is-bold is-unselectable">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>All output template naming options can be found at <NuxtLink target="_blank"
to="https://github.com/yt-dlp/yt-dlp#output-template">this page</NuxtLink>.</span>
</span>
</span>
</div>
</template>
</DLInput>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline is-unselectable" for="ytdlpCookies">
<span class="icon"><i class="fa-solid fa-cookie" /></span>
Cookies
</label>
<div class="control">
<textarea class="textarea is-pre" id="ytdlpCookies" v-model="form.cookies"
:disabled="!socket.isConnected || addInProgress" />
</div>
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<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> by yt-dlp to export cookies. <span class="has-text-danger">The
cookies MUST be in Netscape HTTP Cookie format.</span>
<DLInput id="cli_options" type="text" label="Command options for yt-dlp" v-model="form.cli"
icon="fa-solid fa-terminal" :disabled="!socket.isConnected || addInProgress">
<template #help>
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Check <NuxtLink target="_blank" to="https://github.com/yt-dlp/yt-dlp#general-options">this
page</NuxtLink> for more info. <span class="has-text-danger">Not all options are supported
<NuxtLink target="_blank"
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26-L48">some are
ignored</NuxtLink>. Use with caution these options can break yt-dlp or the frontend.
</span>
</span>
</span>
</span>
</div>
</template>
</DLInput>
</div>
<div class="column is-6-tablet is-12-mobile">
<DLInput id="ytdlpCookies" type="text" label="Cookies for yt-dlp" v-model="form.cookies"
icon="fa-solid fa-cookie" :disabled="!socket.isConnected || addInProgress">
<template #help>
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<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> by yt-dlp to export cookies. <span class="has-text-danger">The
cookies MUST be in Netscape HTTP Cookie format.</span>
</span>
</span>
</template>
</DLInput>
</div>
<template v-if="config.dl_fields.length > 0">
<div class="column is-6-tablet is-12-mobile" v-for="(fi, index) in sortedDLFields"
:key="fi.id || `dlf-${index}`">
<DLInput :id="fi?.id || `dlf-${index}`" :type="fi.kind" :description="fi.description" :label="fi.name"
:icon="fi.icon" v-model="dlFields[fi.field]" :field="fi.field"
:disabled="!socket.isConnected || addInProgress" />
</div>
</template>
<div class="column is-12 is-hidden-tablet">
<Dropdown icons="fa-solid fa-cogs" label="Actions">
<NuxtLink class="dropdown-item" @click="() => showFields = true">
<span class="icon has-text-purple"><i class="fa-solid fa-plus" /></span>
<span>Custom Fields</span>
</NuxtLink>
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="emitter('getInfo', form.url, form.preset)">
<span class="icon has-text-info"><i class="fa-solid fa-info" /></span>
<span>yt-dlp Information</span>
@ -197,6 +183,13 @@
</div>
<div class="column is-12">
<div class="field is-grouped is-justify-self-end is-hidden-mobile">
<div class="control">
<button type="button" class="button is-purple" @click="() => showFields = true"
:class="{ 'is-loading': !socket.isConnected }" :disabled="!socket.isConnected">
<span class="icon"><i class="fa-solid fa-plus" /></span>
<span>Custom Fields</span>
</button>
</div>
<div class="control">
<button type="button" class="button is-info" @click="emitter('getInfo', form.url, form.preset)"
:class="{ 'is-loading': !socket.isConnected }"
@ -235,6 +228,8 @@
<ConfirmDialog v-if="dialog_confirm.visible" :visible="dialog_confirm.visible" :title="dialog_confirm.title"
:message="dialog_confirm.message" :options="dialog_confirm.options" @confirm="dialog_confirm.confirm"
@cancel="() => dialog_confirm.visible = false" />
<DLFields v-if="showFields" @cancel="() => showFields = false" />
</main>
</template>
@ -259,6 +254,9 @@ const auto_start = useStorage<boolean>('auto_start', true)
const show_description = useStorage<boolean>('show_description', true)
const addInProgress = ref<boolean>(false)
const showFields = ref<boolean>(false)
const dlFields = useStorage<Record<string, any>>('dl_fields', {})
const dlFieldsExtra = ['--no-download-archive']
const form = useStorage<item_request>('local_config_v1', {
id: null,
@ -271,6 +269,7 @@ const form = useStorage<item_request>('local_config_v1', {
extras: {},
}) as Ref<item_request>
const dialog_confirm = ref({
visible: false,
title: 'Confirm Action',
@ -278,13 +277,48 @@ const dialog_confirm = ref({
message: '',
options: [],
})
const FORCE_FLAG = '--no-download-archive'
const addDownload = async () => {
if (form.value?.cli && '' !== form.value.cli) {
const options = await convertOptions(form.value.cli)
if (null === options) {
return
let form_cli = (form.value?.cli || '').trim()
const is_valid = (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;
}
if (false === config.app.basic_mode) {
if (dlFields.value && Object.keys(dlFields.value).length > 0) {
const joined = []
for (const [key, value] of Object.entries(dlFields.value)) {
if (false === is_valid(key)) {
continue
}
if ([undefined, null, '', false].includes(value as any)) {
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
}
}
}
@ -301,7 +335,7 @@ const addDownload = async () => {
folder: config.app.basic_mode ? null : form.value.folder,
template: config.app.basic_mode ? null : form.value.template,
cookies: config.app.basic_mode ? '' : form.value.cookies,
cli: config.app.basic_mode ? null : form.value.cli,
cli: config.app.basic_mode ? null : form_cli,
auto_start: config.app.basic_mode ? true : auto_start.value
} as item_request
@ -326,15 +360,20 @@ const addDownload = async () => {
return
}
let had_errors = false
data.forEach((item: Record<string, any>) => {
if (false !== item.status) {
return
}
toast.error(`Error: ${item.msg || 'Failed to add download.'}`)
had_errors = true
})
form.value.url = ''
emitter('clear_form')
if (false === had_errors) {
form.value.url = ''
emitter('clear_form')
}
}
catch (e: any) {
console.error(e)
@ -360,6 +399,7 @@ const reset_config = () => {
folder: '',
extras: {},
} as item_request
dlFields.value = {}
showAdvanced.value = false
@ -465,26 +505,5 @@ const get_output_template = () => {
return config.app.output_template || '%(title)s.%(ext)s'
}
const forceDownload = computed({
get(): boolean {
return new RegExp(`(^|\\s)${FORCE_FLAG}(\\s|$)`).test(form.value.cli || '')
},
set(val: boolean): void {
const cli = form.value.cli || ''
if (val) {
if (!cli.includes(FORCE_FLAG)) {
form.value.cli = cli.trim() + (cli.trim() ? ` ${FORCE_FLAG}` : FORCE_FLAG)
}
} else {
form.value.cli = cli
.replace(new RegExp(`(\\s*)${FORCE_FLAG}(\\s*)`, 'g'), (match, before, after) => {
return before && after ? ' ' : ''
})
.replace(/[ \t]+/g, ' ')
.replace(/^[ \t]+|[ \t]+$/g, '')
.trim()
}
},
})
const sortedDLFields = computed(() => config.dl_fields.sort((a, b) => (a.order || 0) - (b.order || 0)))
</script>

View file

@ -39,6 +39,7 @@ export const useConfigStore = defineStore('config', () => {
'default': true
}
],
dl_fields: [],
folders: [],
paused: false,
is_loaded: false,

View file

@ -1,6 +1,7 @@
import { io } from "socket.io-client";
import type { Socket as IOSocket, SocketOptions } from "socket.io-client"
import type { ManagerOptions } from "socket.io-client";
import type { ConfigState } from "~/types/config";
import type { StoreItem } from "~/types/store";
export const useSocketStore = defineStore('socket', () => {
@ -55,8 +56,9 @@ export const useSocketStore = defineStore('socket', () => {
tasks: json.data.tasks,
folders: json.data.folders,
presets: json.data.presets,
dl_fields: json.data.dl_fields,
paused: Boolean(json.data.paused)
})
} as Partial<ConfigState>)
stateStore.addAll('queue', json.data.queue || {})
stateStore.addAll('history', json.data.done || {})
@ -177,6 +179,7 @@ export const useSocketStore = defineStore('socket', () => {
}, true);
on('presets_update', (data: string) => config.update('presets', JSON.parse(data).data || []));
on('dlfields_update', (data: string) => config.update('dl_fields', JSON.parse(data).data || []));
}
if (false === isConnected.value) {

View file

@ -1,3 +1,4 @@
import type { DLField } from "./dl_fields"
type AppConfig = {
/** Path where downloaded files will be saved */
@ -72,6 +73,8 @@ type ConfigState = {
app: AppConfig
/** List of presets */
presets: Preset[]
/** List of custom download fields */
dl_fields: DLField[]
/** List of folders where files can be saved */
folders: string[]
/** Indicates if downloads are currently paused */

44
ui/app/types/dl_fields.d.ts vendored Normal file
View file

@ -0,0 +1,44 @@
type DLFieldType = "string" | "text" | "bool";
type DLField = {
/** The id of the field */
id?: string;
/** The name of the preset */
name: string;
/** The description of the preset */
description: string;
/** The yt-dlp field to use in long format */
field: string;
/** The kind of the field. i.e. string, bool */
kind: DLFieldType;
/** The icon of the field, it can be a font-awesome icon */
icon?: string;
/** The order of the field, used to sort the fields in the UI. */
order: number = 1;
/** The default value of the field, It's currently unused */
value: string;
/** Additional options for the field */
extras: Record<string, any>;
}
/**
* Request payload for creating/updating DLField
*/
type DLFieldRequest = {
name: string;
description: string;
field: string;
kind: DLFieldType;
value?: string;
extras?: Record<string, any>;
}
export type { DLField, DLFieldRequest, DLFieldType };