Merge pull request #408 from arabcoders/dev

Expose archiver as an API endpoint.
This commit is contained in:
Abdulmohsen 2025-09-03 19:37:59 +03:00 committed by GitHub
commit ab8545392e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 481 additions and 109 deletions

78
API.md
View file

@ -27,6 +27,9 @@ This document describes the available endpoints and their usage. All endpoints r
- [GET /api/history](#get-apihistory) - [GET /api/history](#get-apihistory)
- [DELETE /api/history/{id}/archive](#delete-apihistoryidarchive) - [DELETE /api/history/{id}/archive](#delete-apihistoryidarchive)
- [POST /api/history/{id}/archive](#post-apihistoryidarchive) - [POST /api/history/{id}/archive](#post-apihistoryidarchive)
- [GET /api/archiver](#get-apiarchiver)
- [POST /api/archiver](#post-apiarchiver)
- [DELETE /api/archiver](#delete-apiarchiver)
- [GET /api/tasks](#get-apitasks) - [GET /api/tasks](#get-apitasks)
- [PUT /api/tasks](#put-apitasks) - [PUT /api/tasks](#put-apitasks)
- [POST /api/tasks/{id}/mark](#post-apitasksidmark) - [POST /api/tasks/{id}/mark](#post-apitasksidmark)
@ -401,6 +404,81 @@ or an error:
--- ---
### GET /api/archiver
**Purpose**: Read entries from the download archive associated with a preset.
**Query Parameters**:
- `preset` (required): Preset name.
- `ids` (optional): Comma-separated list of archive IDs to filter. If omitted, returns all entries.
**Response**:
```json
{
"file": "/path/to/archive.log",
"items": ["youtube ABC123", "vimeo XYZ789"],
"count": 2
}
```
or an error:
```json
{ "error": "Preset '<name>' does not provide a download_archive." }
```
- `400 Bad Request` if `preset` is missing/invalid or the preset has no `download_archive`.
---
### POST /api/archiver
**Purpose**: Append archive IDs to the download archive resolved by a preset.
**Body**:
```json
{
"preset": "<preset-name>",
"items": ["youtube ABC123", "vimeo XYZ789"],
"skip_check": false
}
```
**Response**:
```json
{
"file": "/path/to/archive.log",
"status": true,
"items": ["youtube ABC123", "vimeo XYZ789"]
}
```
Notes:
- Returns `200 OK` if any new entries were added, `304 Not Modified` if no-op (all already present or invalid).
- `400 Bad Request` if `preset` is missing/invalid, the preset has no `download_archive`, or `items` is empty.
---
### DELETE /api/archiver
**Purpose**: Remove archive IDs from the download archive resolved by a preset.
**Body**:
```json
{
"preset": "<preset-name>",
"items": ["youtube ABC123", "vimeo XYZ789"]
}
```
**Response**:
```json
{
"file": "/path/to/archive.log",
"status": true,
"items": ["youtube ABC123", "vimeo XYZ789"]
}
```
Notes:
- Returns `200 OK` if the archive file changed, `304 Not Modified` if it was unchanged.
- `400 Bad Request` if `preset` is missing/invalid, the preset has no `download_archive`, or `items` is empty.
---
### GET /api/tasks ### GET /api/tasks
**Purpose**: Retrieves the scheduled tasks from the internal `Tasks` manager. **Purpose**: Retrieves the scheduled tasks from the internal `Tasks` manager.

84
FAQ.md
View file

@ -3,48 +3,48 @@
Certain configuration values can be set via environment variables, using the `-e` parameter on the docker command line, Certain configuration values can be set via environment variables, using the `-e` parameter on the docker command line,
or the `environment:` section in `compose.yaml` file. or the `environment:` section in `compose.yaml` file.
| Environment Variable | Description | Default | | Environment Variable | Description | Default |
| ------------------------------ | ------------------------------------------------------------------ | ---------------------------------- | | ------------------------------ | ------------------------------------------------------------------ | -------------------------- |
| YTP_OUTPUT_TEMPLATE | The template for the filenames of the downloaded videos | `%(title)s.%(ext)s` | | TZ | The timezone to use for the application | `(not_set)` |
| YTP_DEFAULT_PRESET | The default preset to use for the download | `default` | | YTP_OUTPUT_TEMPLATE | The template for the filenames of the downloaded videos | `%(title)s.%(ext)s` |
| YTP_INSTANCE_TITLE | The title of the instance | `empty string` | | YTP_DEFAULT_PRESET | The default preset to use for the download | `default` |
| YTP_FILE_LOGGING | Whether to log to file | `false` | | YTP_INSTANCE_TITLE | The title of the instance | `empty string` |
| YTP_DOWNLOAD_PATH | Path to where the downloads will be saved | `/downloads` | | YTP_FILE_LOGGING | Whether to log to file | `false` |
| YTP_MAX_WORKERS | How many works to use for downloads | `1` | | YTP_DOWNLOAD_PATH | Path to where the downloads will be saved | `/downloads` |
| YTP_AUTH_USERNAME | Username for basic authentication | `empty string` | | YTP_MAX_WORKERS | How many works to use for downloads | `1` |
| YTP_AUTH_PASSWORD | Password for basic authentication | `empty string` | | YTP_AUTH_USERNAME | Username for basic authentication | `empty string` |
| YTP_CONSOLE_ENABLED | Whether to enable the console | `false` | | YTP_AUTH_PASSWORD | Password for basic authentication | `empty string` |
| YTP_REMOVE_FILES | Remove the actual file when clicking the remove button | `false` | | YTP_CONSOLE_ENABLED | Whether to enable the console | `false` |
| YTP_CONFIG_PATH | Path to where the config files will be stored. | `/config` | | YTP_REMOVE_FILES | Remove the actual file when clicking the remove button | `false` |
| YTP_TEMP_PATH | Path to where tmp files are stored. | `/tmp` | | YTP_CONFIG_PATH | Path to where the config files will be stored. | `/config` |
| YTP_TEMP_KEEP | Whether to keep the Individual video temp directory or remove it | `false` | | YTP_TEMP_PATH | Path to where tmp files are stored. | `/tmp` |
| YTP_HOST | Which IP address to bind to | `0.0.0.0` | | YTP_TEMP_KEEP | Whether to keep the Individual video temp directory or remove it | `false` |
| YTP_PORT | Which port to bind to | `8081` | | YTP_HOST | Which IP address to bind to | `0.0.0.0` |
| YTP_LOG_LEVEL | Log level | `info` | | YTP_PORT | Which port to bind to | `8081` |
| YTP_STREAMER_VCODEC | The video codec to use for in-browser streaming | `libx264` | | YTP_LOG_LEVEL | Log level | `info` |
| YTP_STREAMER_ACODEC | The audio codec to use for in-browser streaming | `aac` | | YTP_STREAMER_VCODEC | The video codec to use for in-browser streaming | `libx264` |
| YTP_ACCESS_LOG | Whether to log access to the web server | `true` | | YTP_STREAMER_ACODEC | The audio codec to use for in-browser streaming | `aac` |
| YTP_DEBUG | Whether to turn on debug mode | `false` | | YTP_ACCESS_LOG | Whether to log access to the web server | `true` |
| YTP_DEBUGPY_PORT | The port to use for the debugpy debugger | `5678` | | YTP_DEBUG | Whether to turn on debug mode | `false` |
| YTP_EXTRACT_INFO_TIMEOUT | The timeout for extracting video information | `70` | | YTP_DEBUGPY_PORT | The port to use for the debugpy debugger | `5678` |
| YTP_DB_FILE | The path to the SQLite database file | `{config_path}/ytptube.db` | | YTP_EXTRACT_INFO_TIMEOUT | The timeout for extracting video information | `70` |
| YTP_UI_UPDATE_TITLE | Whether to update the title of the page with the current stats | `true` | | YTP_DB_FILE | The path to the SQLite database file | `{config_path}/ytptube.db` |
| YTP_PIP_PACKAGES | A space separated list of pip packages to install | `empty string` | | YTP_UI_UPDATE_TITLE | Whether to update the title of the page with the current stats | `true` |
| YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` | | YTP_PIP_PACKAGES | A space separated list of pip packages to install | `empty string` |
| YTP_BASIC_MODE | Whether to run WebUI in basic mode | `false` | | YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` |
| YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use | `empty string` | | YTP_BASIC_MODE | Whether to run WebUI in basic mode | `false` |
| YTP_BROWSER_ENABLED | Whether to enable the file browser | `false` | | YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use | `empty string` |
| YTP_BROWSER_CONTROL_ENABLED | Whether to enable the file browser actions | `false` | | YTP_BROWSER_ENABLED | Whether to enable the file browser | `true` |
| YTP_YTDLP_AUTO_UPDATE | Whether to enable the auto update for yt-dlp | `true` | | YTP_BROWSER_CONTROL_ENABLED | Whether to enable the file browser actions | `false` |
| YTP_YTDLP_DEBUG | Whether to turn debug logging for the internal `yt-dlp` package | `false` | | YTP_YTDLP_AUTO_UPDATE | Whether to enable the auto update for yt-dlp | `true` |
| YTP_YTDLP_VERSION | The version of yt-dlp to use. Defaults to latest version | `empty string` | | YTP_YTDLP_DEBUG | Whether to turn debug logging for the internal `yt-dlp` package | `false` |
| YTP_BASE_PATH | Set this if you are serving YTPTube from sub-folder | `/` | | YTP_YTDLP_VERSION | The version of yt-dlp to use. Defaults to latest version | `empty string` |
| YTP_PREVENT_LIVE_PREMIERE | Prevents the initial youtube premiere stream from being downloaded | `false` | | YTP_BASE_PATH | Set this if you are serving YTPTube from sub-folder | `/` |
| YTP_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer | `15 */1 * * *` | | YTP_PREVENT_LIVE_PREMIERE | Prevents the initial youtube premiere stream from being downloaded | `false` |
| YTP_PLAYLIST_ITEMS_CONCURRENCY | The number of playlist items be to processed at same time | `1` | | YTP_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer | `15 */1 * * *` |
| YTP_TEMP_DISABLED | Disable temp files handling. | `false` | | YTP_PLAYLIST_ITEMS_CONCURRENCY | The number of playlist items be to processed at same time | `1` |
| YTP_DOWNLOAD_PATH_DEPTH | How many subdirectories to show in auto complete. | `1` | | YTP_TEMP_DISABLED | Disable temp files handling. | `false` |
| YTP_DOWNLOAD_PATH_DEPTH | How many subdirectories to show in auto complete. | `1` |
# Browser extensions & bookmarklets # Browser extensions & bookmarklets

View file

@ -96,6 +96,32 @@ class DataStore:
msg: str = f"{key=} or {url=} not found." msg: str = f"{key=} or {url=} not found."
raise KeyError(msg) raise KeyError(msg)
def get_item(self, **kwargs) -> Download | None:
"""
Get a specific item from the datastore based on provided attributes.
Args:
**kwargs: Arbitrary keyword arguments representing attributes of the ItemDTO.
If no attributes are provided, the method returns None.
If any attribute matches, the corresponding Download object is returned.
Returns:
Download | None: The requested item if found, otherwise None.
"""
if not kwargs:
return None
for i in self._dict:
if not self._dict[i].info:
continue
info = self._dict[i].info.__dict__
if any((key in info and info == value) for key, value in kwargs.items()):
return self._dict[i]
return None
def get_by_id(self, id: str) -> Download | None: def get_by_id(self, id: str) -> Download | None:
return self._dict.get(id, None) return self._dict.get(id, None)

View file

@ -676,11 +676,43 @@ class DownloadQueue(metaclass=Singleton):
item.extras.update({"external_downloader": True}) item.extras.update({"external_downloader": True})
if item.is_archived(): if item.is_archived():
if archive_id := item.get_archive_id():
store_type, _ = self.get_item(archive_id=archive_id)
if not store_type:
dlInfo = Download(
info=ItemDTO(
id=archive_id.split()[1],
title=archive_id,
url=item.url,
preset=item.preset,
folder=item.folder,
status="skip",
cookies=item.cookies,
template=item.template,
msg="URL is already downloaded.",
extras=item.extras,
)
)
if archive_file := dlInfo.info.get_ytdlp_opts().get_all().get("download_archive"):
dlInfo.info.msg += f" Found in archive '{archive_file}'."
self.done.put(dlInfo)
await self._notify.emit(
Events.ITEM_MOVED,
data={"to": "history", "preset": dlInfo.info.preset, "item": dlInfo.info},
title="Download History Update",
message=f"Download history updated with '{item.url}'.",
)
return {"status": "ok"}
message: str = f"The URL '{item.url}' is already downloaded and recorded in archive." message: str = f"The URL '{item.url}' is already downloaded and recorded in archive."
LOG.error(message) LOG.error(message)
await self._notify.emit( await self._notify.emit(
Events.LOG_INFO, data={"preset": item.preset}, title="Already Downloaded", message=message Events.LOG_INFO, data={"preset": item.preset}, title="Already Downloaded", message=message
) )
return {"status": "error", "msg": message} return {"status": "error", "msg": message}
started: float = time.perf_counter() started: float = time.perf_counter()
@ -908,26 +940,24 @@ class DownloadQueue(metaclass=Singleton):
return items return items
def get_item(self, id: str) -> Download | None: def get_item(self, **kwargs) -> tuple[StoreType, Download] | tuple[None, None]:
""" """
Get a specific item from the download queue or history. Get a specific item from the download queue or history.
Args: Args:
id (str): The ID of the item to retrieve. **kwargs: The key-value pair to search for. Supported keys are 'id', 'url'.
Returns: Returns:
Download | None: The requested item if found, otherwise None. (StoreType, Download) | None: The requested item if found, otherwise None.
""" """
try: if item := self.queue.get_item(**kwargs):
return self.queue.get(key=id) return (StoreType.QUEUE, item)
except KeyError:
pass
try: if item := self.done.get_item(**kwargs):
return self.done.get(key=id) return (StoreType.HISTORY, item)
except KeyError:
return None return (None, None)
async def _download_pool(self) -> None: async def _download_pool(self) -> None:
""" """

View file

@ -164,7 +164,7 @@ class Config:
console_enabled: bool = False console_enabled: bool = False
"Enable direct access to yt-dlp console." "Enable direct access to yt-dlp console."
browser_enabled: bool = False browser_enabled: bool = True
"Enable file browser access." "Enable file browser access."
browser_control_enabled: bool = False browser_control_enabled: bool = False

210
app/routes/api/archiver.py Normal file
View file

@ -0,0 +1,210 @@
import logging
from collections.abc import Iterable
from aiohttp import web
from aiohttp.web import Request, Response
from app.library.Archiver import Archiver
from app.library.Presets import Presets
from app.library.router import route
from app.library.YTDLPOpts import YTDLPOpts
LOG: logging.Logger = logging.getLogger(__name__)
def _get_archive_file_from_preset(preset: str) -> str | None:
"""
Resolve the archive file path for a given preset.
Validates that the preset exists and that applying the preset results
in yt-dlp options that contain a 'download_archive' path.
"""
if not preset or not Presets.get_instance().has(preset):
return None
try:
opts: dict = YTDLPOpts.get_instance().preset(preset).get_all()
except Exception as e:
LOG.error(f"Failed to build yt-dlp opts for preset '{preset}'. {e!s}")
return None
if not (archive_file := opts.get("download_archive")):
return None
if not isinstance(archive_file, str) or len(archive_file.strip()) < 1:
return None
return archive_file.strip()
def _normalize_ids(items: Iterable[str] | None) -> tuple[list[str], list[str]]:
"""
Validate and normalize archive IDs.
- Trims whitespace
- Enforces that each ID has at least two whitespace-separated tokens
(e.g., "youtube ABC123") as required by yt-dlp's archive format
- De-duplicates while preserving order
Returns a tuple: (valid_ids, invalid_inputs)
"""
if not items:
return ([], [])
seen: set[str] = set()
valid: list[str] = []
invalid: list[str] = []
for raw in items:
if raw is None:
continue
s = str(raw).strip()
if not s:
continue
if len(s.split()) < 2:
invalid.append(s)
continue
if s in seen:
continue
seen.add(s)
valid.append(s)
return (valid, invalid)
@route("GET", "api/archiver/", "archiver")
async def archiver_get(request: Request) -> Response:
"""
Read IDs from the download archive for a given preset.
Query params:
- preset: required preset name/id
- ids: optional comma-separated list to filter; when omitted, returns all
"""
preset: str | None = request.query.get("preset")
if not preset:
return web.json_response(data={"error": "preset is required."}, status=web.HTTPBadRequest.status_code)
archive_file: str | None = _get_archive_file_from_preset(preset)
if not archive_file:
return web.json_response(
data={"error": f"Preset '{preset}' does not provide a download_archive."},
status=web.HTTPBadRequest.status_code,
)
ids_param: str | None = request.query.get("ids")
ids: list[str] = []
if ids_param:
ids_list = [s.strip() for s in ids_param.split(",") if s and s.strip()]
ids, invalid = _normalize_ids(ids_list)
if invalid:
return web.json_response(
data={"error": "invalid ids provided.", "invalid_items": invalid},
status=web.HTTPBadRequest.status_code,
)
try:
data: list[str] = Archiver.get_instance().read(archive_file, ids or None)
return web.json_response(
data={"file": archive_file, "items": data, "count": len(data)}, status=web.HTTPOk.status_code
)
except Exception as e:
LOG.exception(e)
return web.json_response(
data={"error": f"Failed to read archive file for preset '{preset}'.", "message": str(e)},
status=web.HTTPInternalServerError.status_code,
)
@route("POST", "api/archiver/", "archiver_add")
async def archiver_add(request: Request) -> Response:
"""
Append IDs to the download archive for a given preset.
Body: { "preset": string, "items": [string, ...], "skip_check": bool? }
"""
post = await request.json()
preset: str | None = post.get("preset") if isinstance(post, dict) else None
if not preset:
return web.json_response(data={"error": "preset is required."}, status=web.HTTPBadRequest.status_code)
archive_file: str | None = _get_archive_file_from_preset(preset)
if not archive_file:
return web.json_response(
data={"error": f"Preset '{preset}' does not provide a download_archive."},
status=web.HTTPBadRequest.status_code,
)
items, invalid = _normalize_ids((post or {}).get("items", [])) if isinstance(post, dict) else ([], [])
if invalid:
return web.json_response(
data={"error": "invalid ids provided.", "invalid_items": invalid},
status=web.HTTPBadRequest.status_code,
)
if len(items) < 1:
return web.json_response(
data={"error": "items is required and must be a non-empty list."}, status=web.HTTPBadRequest.status_code
)
skip_check: bool = bool(post.get("skip_check", False)) if isinstance(post, dict) else False
try:
status: bool = Archiver.get_instance().add(archive_file, items, skip_check=skip_check)
return web.json_response(
data={"file": archive_file, "status": status, "items": items},
status=web.HTTPOk.status_code if status else web.HTTPNotModified.status_code,
)
except Exception as e:
LOG.exception(e)
return web.json_response(
data={"error": f"Failed to add items to archive for preset '{preset}'.", "message": str(e)},
status=web.HTTPInternalServerError.status_code,
)
@route("DELETE", "api/archiver/", "archiver_delete")
async def archiver_delete(request: Request) -> Response:
"""
Remove IDs from the download archive for a given preset.
Body: { "preset": string, "items": [string, ...] }
"""
post = await request.json()
preset: str | None = post.get("preset") if isinstance(post, dict) else None
if not preset:
return web.json_response(data={"error": "preset is required."}, status=web.HTTPBadRequest.status_code)
archive_file: str | None = _get_archive_file_from_preset(preset)
if not archive_file:
return web.json_response(
data={"error": f"Preset '{preset}' does not provide a download_archive."},
status=web.HTTPBadRequest.status_code,
)
items, invalid = _normalize_ids((post or {}).get("items", [])) if isinstance(post, dict) else ([], [])
if invalid:
return web.json_response(
data={"error": "invalid ids provided.", "invalid_items": invalid},
status=web.HTTPBadRequest.status_code,
)
if len(items) < 1:
return web.json_response(
data={"error": "items is required and must be a non-empty list."}, status=web.HTTPBadRequest.status_code
)
try:
status: bool = Archiver.get_instance().delete(archive_file, items)
return web.json_response(
data={"file": archive_file, "status": status, "items": items},
status=web.HTTPOk.status_code if status else web.HTTPNotModified.status_code,
)
except Exception as e:
LOG.exception(e)
return web.json_response(
data={"error": f"Failed to delete items from archive for preset '{preset}'.", "message": str(e)},
status=web.HTTPInternalServerError.status_code,
)

View file

@ -64,7 +64,7 @@
</div> </div>
<div class="control is-expanded"> <div class="control is-expanded">
<input type="text" class="input is-fullwidth" id="path" v-model="form.folder" <input type="text" class="input is-fullwidth" id="path" v-model="form.folder"
:placeholder="get_download_folder()" :disabled="!socket.isConnected || addInProgress" :placeholder="getDefault('folder', '/')" :disabled="!socket.isConnected || addInProgress"
list="folders"> list="folders">
</div> </div>
</div> </div>
@ -105,7 +105,7 @@
<DLInput id="output_template" type="string" label="Output template" v-model="form.template" <DLInput id="output_template" type="string" label="Output template" v-model="form.template"
icon="fa-solid fa-file" :disabled="!socket.isConnected || addInProgress" icon="fa-solid fa-file" :disabled="!socket.isConnected || addInProgress"
:placeholder="get_output_template()"> :placeholder="getDefault('template', config.app.output_template || '%(title)s.%(ext)s')">
<template #help> <template #help>
<span class="help is-bold is-unselectable"> <span class="help is-bold is-unselectable">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
@ -123,7 +123,7 @@
<span>Command options for yt-dlp</span> <span>Command options for yt-dlp</span>
</label> </label>
<TextareaAutocomplete id="cli_options" v-model="form.cli" :options="ytDlpOpt" <TextareaAutocomplete id="cli_options" v-model="form.cli" :options="ytDlpOpt"
:disabled="!socket.isConnected || addInProgress" /> :placeholder="getDefault('cli', '')" :disabled="!socket.isConnected || addInProgress" />
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span> <span>
@ -138,7 +138,8 @@
<div class="column is-6-tablet is-12-mobile"> <div class="column is-6-tablet is-12-mobile">
<DLInput id="ytdlpCookies" type="text" label="Cookies for yt-dlp" v-model="form.cookies" <DLInput id="ytdlpCookies" type="text" label="Cookies for yt-dlp" v-model="form.cookies"
icon="fa-solid fa-cookie" :disabled="!socket.isConnected || addInProgress"> icon="fa-solid fa-cookie" :disabled="!socket.isConnected || addInProgress"
:placeholder="getDefault('cookies', '')">
<template #help> <template #help>
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
@ -483,24 +484,34 @@ const filter_presets = (flag: boolean = true) => config.presets.filter(item => i
const get_preset = (name: string | undefined) => config.presets.find(item => item.name === name) const get_preset = (name: string | undefined) => config.presets.find(item => item.name === name)
const expand_description = (e: Event) => toggleClass(e.target as HTMLElement, ['is-ellipsis', 'is-pre-wrap']) const expand_description = (e: Event) => toggleClass(e.target as HTMLElement, ['is-ellipsis', 'is-pre-wrap'])
const get_output_template = () => { const getDefault = (type: 'cookies' | 'cli' | 'template' | 'folder', ret: string = '') => {
if (form.value.preset && !hasFormatInConfig.value) { if (false !== hasFormatInConfig.value || !form.value.preset) {
const preset = config.presets.find(p => p.name === form.value.preset) return ret
if (preset && preset.template) {
return preset.template
}
} }
return config.app.output_template || '%(title)s.%(ext)s'
}
const get_download_folder = (): string => { const preset = config.presets.find(p => p.name === form.value.preset)
if (form.value.preset && false === hasFormatInConfig.value) {
const preset = config.presets.find(p => p.name === form.value.preset) if (!preset) {
if (preset?.folder) { return ret
return preset.folder.replace(config.app.download_path, '')
}
} }
return '/'
if (type === 'cookies' && preset.cookies) {
return preset.cookies
}
if (type === 'cli' && preset.cli) {
return preset.cli
}
if (type === 'template' && preset.template) {
return preset.template
}
if (type === 'folder' && preset.folder) {
return preset.folder.replace(config.app.download_path, '') || ret
}
return ret
} }
const sortedDLFields = computed(() => config.dl_fields.sort((a, b) => (a.order || 0) - (b.order || 0))) const sortedDLFields = computed(() => config.dl_fields.sort((a, b) => (a.order || 0) - (b.order || 0)))

View file

@ -160,13 +160,13 @@
Save in Save in
</label> </label>
<div class="control"> <div class="control">
<input type="text" class="input" id="folder" placeholder="Leave empty to use default download path" <input type="text" class="input" id="folder" :placeholder="getDefault('folder', '/')"
v-model="form.folder" :disabled="addInProgress" list="folders"> v-model="form.folder" :disabled="addInProgress" list="folders">
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span class="is-bold">Current download folder: <code>{{ get_download_folder() }}</code>. All folders <span class="is-bold">All folders are sub-folders of
are sub-folders of <code>{{ config.app.download_path }}</code>.</span> <code>{{ config.app.download_path }}</code>.</span>
</span> </span>
</div> </div>
</div> </div>
@ -179,11 +179,12 @@
</label> </label>
<div class="control"> <div class="control">
<input type="text" class="input" id="output_template" :disabled="addInProgress" <input type="text" class="input" id="output_template" :disabled="addInProgress"
placeholder="Leave empty to use default template." v-model="form.template"> :placeholder="getDefault('template', config.app.output_template || '%(title)s.%(ext)s')"
v-model="form.template">
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span class="is-bold">Current output format: <code>{{ get_output_template() }}</code>.</span> <span class="is-bold">The template to use for the output file name.</span>
</span> </span>
</div> </div>
</div> </div>
@ -237,7 +238,7 @@
<span>Command options for yt-dlp</span> <span>Command options for yt-dlp</span>
</label> </label>
<TextareaAutocomplete id="cli_options" v-model="form.cli" :options="ytDlpOpt" <TextareaAutocomplete id="cli_options" v-model="form.cli" :options="ytDlpOpt"
:disabled="addInProgress" /> :placeholder="getDefault('cli', '')" :disabled="addInProgress" />
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span> <span>
@ -282,10 +283,8 @@
<li>RSS monitoring runs every hour alongside the actual task execution. Regardless if the task has timer set <li>RSS monitoring runs every hour alongside the actual task execution. Regardless if the task has timer set
or not. To opt out of RSS monitoring for a specific task, simply disable the <code>Enable Handler</code> or not. To opt out of RSS monitoring for a specific task, simply disable the <code>Enable Handler</code>
option. To have the task only monitor RSS feed, <b>do not set timer</b>.</li> option. To have the task only monitor RSS feed, <b>do not set timer</b>.</li>
<li>RSS Feed monitoring will only work if you have <code>--download-archive</code> set in command options <li>RSS Feed monitoring will only work if you have <code>--download-archive</code> set in <code>Command options
for ytdlp.cli, preset or task. If you don't have <code>--download-archive</code> set but for yt-dlp</code> via the task itself, or preset. command options
<code>YTP_KEEP_ARCHIVE</code> environment option is set to <code>true</code> which is the default, It will
also work.
</li> </li>
</ul> </ul>
</span> </span>
@ -463,26 +462,6 @@ const hasFormatInConfig = computed<boolean>(() => !!form.cli && /(?<!\S)(-f|--fo
const filter_presets = (flag = true) => config.presets.filter(item => item.default === flag) const filter_presets = (flag = true) => config.presets.filter(item => item.default === flag)
const get_download_folder = (): string => {
if (form.preset && false === hasFormatInConfig.value) {
const preset = config.presets.find(p => p.name === form.preset)
if (preset?.folder) {
return preset.folder.replace(config.app.download_path, '')
}
}
return '/'
}
const get_output_template = (): string => {
if (form.preset && false === hasFormatInConfig.value) {
const preset = config.presets.find(p => p.name === form.preset)
if (preset?.template) {
return preset.template
}
}
return config.app.output_template || '%(title)s.%(ext)s'
}
const is_yt_handle = (url: string): boolean => { const is_yt_handle = (url: string): boolean => {
if (!url || '' === url) { if (!url || '' === url) {
return false return false
@ -513,7 +492,6 @@ const convert_url = async (url: string): Promise<string> => {
const resp = await request('/api/yt-dlp/url/info?' + params.toString()) const resp = await request('/api/yt-dlp/url/info?' + params.toString())
const body = await resp.json() const body = await resp.json()
const channel_id = ag(body, 'channel_id', null) const channel_id = ag(body, 'channel_id', null)
console.log('convert_url', { url, channel_id, body })
if (channel_id) { if (channel_id) {
return url.replace(`/@${m.groups.handle}`, `/channel/${channel_id}`) return url.replace(`/@${m.groups.handle}`, `/channel/${channel_id}`)
@ -528,4 +506,34 @@ const convert_url = async (url: string): Promise<string> => {
return url return url
} }
const getDefault = (type: 'cookies' | 'cli' | 'template' | 'folder', ret: string = '') => {
if (false !== hasFormatInConfig.value || !form.preset) {
return ret
}
const preset = config.presets.find(p => p.name === form.preset)
if (!preset) {
return ret
}
if (type === 'cookies' && preset.cookies) {
return preset.cookies
}
if (type === 'cli' && preset.cli) {
return preset.cli
}
if (type === 'template' && preset.template) {
return preset.template
}
if (type === 'folder' && preset.folder) {
return preset.folder.replace(config.app.download_path, '') || ret
}
return ret
}
</script> </script>

View file

@ -67,7 +67,8 @@
<DeprecatedNotice :version="config.app.app_version" title="Deprecation Notice" tone="warning" <DeprecatedNotice :version="config.app.app_version" title="Deprecation Notice" tone="warning"
icon="fas fa-exclamation-triangle fa-fade fa-spin-10"> icon="fas fa-exclamation-triangle fa-fade fa-spin-10">
<p> <p>
The following environment variables and features are deprecated and will be removed in future releases: The following environment variables and features are deprecated and will be removed in
<strong class="has-text-danger">v0.10.x</strong>
</p> </p>
<ul> <ul>
<li> <li>
@ -85,6 +86,12 @@
The <strong>Basic mode</strong> (which limited the interface to the new download form) is being removed. The <strong>Basic mode</strong> (which limited the interface to the new download form) is being removed.
Everything except what is available behind configurable flag will become part of the standard interface. Everything except what is available behind configurable flag will become part of the standard interface.
</li> </li>
<li>
The file browser feature will be enabled by <strong>default</strong>, and the associated environment
variable <code>YTP_BROWSER_ENABLED</code> will be removed, We will keep the
<code>YTP_BROWSER_CONTROL_ENABLED</code> to control whether you want to enable the file management
features or not. it will default to <code>false</code>.
</li>
<li>The <strong>archive.manual.log</strong> feature has been removed.</li> <li>The <strong>archive.manual.log</strong> feature has been removed.</li>
</ul> </ul>
<p> <p>

View file

@ -55,7 +55,8 @@
</div> </div>
<div class="is-hidden-mobile"> <div class="is-hidden-mobile">
<span class="subtitle"> <span class="subtitle">
The task runner is simple queue system that allows you to schedule downloads to run at the specific time. The task runner is simple queue system that allows you to poll channels or playlists for new content at
specified intervals.
</span> </span>
</div> </div>
</div> </div>
@ -361,9 +362,10 @@
<Message title="Tips" class="is-info is-background-info-80" icon="fas fa-info-circle"> <Message title="Tips" class="is-info is-background-info-80" icon="fas fa-info-circle">
<span> <span>
<ul> <ul>
<li>If you are adding a big channel or playlist and you want to skip all old videos, please click on <li>
<code>Actions > Archive All</code> button to mark all videos as downloaded. otherwise, it will try to If you don't wish to download <strong>ALL</strong> content from a channel or playlist, click on
download all videos. <code> <span class="icon"><i class="fa-solid fa-cogs" /></span> Actions > <span class="icon"><i
class="fa-solid fa-box-archive" /></span> Archive All</code> to archive all existing content.
</li> </li>
</ul> </ul>
</span> </span>