Merge pull request #408 from arabcoders/dev
Expose archiver as an API endpoint.
This commit is contained in:
commit
ab8545392e
10 changed files with 481 additions and 109 deletions
78
API.md
78
API.md
|
|
@ -27,6 +27,9 @@ This document describes the available endpoints and their usage. All endpoints r
|
|||
- [GET /api/history](#get-apihistory)
|
||||
- [DELETE /api/history/{id}/archive](#delete-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)
|
||||
- [PUT /api/tasks](#put-apitasks)
|
||||
- [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
|
||||
**Purpose**: Retrieves the scheduled tasks from the internal `Tasks` manager.
|
||||
|
||||
|
|
|
|||
84
FAQ.md
84
FAQ.md
|
|
@ -3,48 +3,48 @@
|
|||
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.
|
||||
|
||||
| Environment Variable | Description | Default |
|
||||
| ------------------------------ | ------------------------------------------------------------------ | ---------------------------------- |
|
||||
| YTP_OUTPUT_TEMPLATE | The template for the filenames of the downloaded videos | `%(title)s.%(ext)s` |
|
||||
| YTP_DEFAULT_PRESET | The default preset to use for the download | `default` |
|
||||
| YTP_INSTANCE_TITLE | The title of the instance | `empty string` |
|
||||
| YTP_FILE_LOGGING | Whether to log to file | `false` |
|
||||
| YTP_DOWNLOAD_PATH | Path to where the downloads will be saved | `/downloads` |
|
||||
| YTP_MAX_WORKERS | How many works to use for downloads | `1` |
|
||||
| YTP_AUTH_USERNAME | Username for basic authentication | `empty string` |
|
||||
| YTP_AUTH_PASSWORD | Password for basic authentication | `empty string` |
|
||||
| YTP_CONSOLE_ENABLED | Whether to enable the console | `false` |
|
||||
| YTP_REMOVE_FILES | Remove the actual file when clicking the remove button | `false` |
|
||||
| YTP_CONFIG_PATH | Path to where the config files will be stored. | `/config` |
|
||||
| YTP_TEMP_PATH | Path to where tmp files are stored. | `/tmp` |
|
||||
| YTP_TEMP_KEEP | Whether to keep the Individual video temp directory or remove it | `false` |
|
||||
| YTP_HOST | Which IP address to bind to | `0.0.0.0` |
|
||||
| YTP_PORT | Which port to bind to | `8081` |
|
||||
| YTP_LOG_LEVEL | Log level | `info` |
|
||||
| YTP_STREAMER_VCODEC | The video codec to use for in-browser streaming | `libx264` |
|
||||
| YTP_STREAMER_ACODEC | The audio codec to use for in-browser streaming | `aac` |
|
||||
| YTP_ACCESS_LOG | Whether to log access to the web server | `true` |
|
||||
| YTP_DEBUG | Whether to turn on debug mode | `false` |
|
||||
| YTP_DEBUGPY_PORT | The port to use for the debugpy debugger | `5678` |
|
||||
| YTP_EXTRACT_INFO_TIMEOUT | The timeout for extracting video information | `70` |
|
||||
| YTP_DB_FILE | The path to the SQLite database file | `{config_path}/ytptube.db` |
|
||||
| YTP_UI_UPDATE_TITLE | Whether to update the title of the page with the current stats | `true` |
|
||||
| YTP_PIP_PACKAGES | A space separated list of pip packages to install | `empty string` |
|
||||
| YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` |
|
||||
| YTP_BASIC_MODE | Whether to run WebUI in basic mode | `false` |
|
||||
| YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use | `empty string` |
|
||||
| YTP_BROWSER_ENABLED | Whether to enable the file browser | `false` |
|
||||
| YTP_BROWSER_CONTROL_ENABLED | Whether to enable the file browser actions | `false` |
|
||||
| YTP_YTDLP_AUTO_UPDATE | Whether to enable the auto update for yt-dlp | `true` |
|
||||
| YTP_YTDLP_DEBUG | Whether to turn debug logging for the internal `yt-dlp` package | `false` |
|
||||
| YTP_YTDLP_VERSION | The version of yt-dlp to use. Defaults to latest version | `empty string` |
|
||||
| YTP_BASE_PATH | Set this if you are serving YTPTube from sub-folder | `/` |
|
||||
| YTP_PREVENT_LIVE_PREMIERE | Prevents the initial youtube premiere stream from being downloaded | `false` |
|
||||
| YTP_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer | `15 */1 * * *` |
|
||||
| YTP_PLAYLIST_ITEMS_CONCURRENCY | The number of playlist items be to processed at same time | `1` |
|
||||
| YTP_TEMP_DISABLED | Disable temp files handling. | `false` |
|
||||
| YTP_DOWNLOAD_PATH_DEPTH | How many subdirectories to show in auto complete. | `1` |
|
||||
|
||||
| Environment Variable | Description | Default |
|
||||
| ------------------------------ | ------------------------------------------------------------------ | -------------------------- |
|
||||
| TZ | The timezone to use for the application | `(not_set)` |
|
||||
| YTP_OUTPUT_TEMPLATE | The template for the filenames of the downloaded videos | `%(title)s.%(ext)s` |
|
||||
| YTP_DEFAULT_PRESET | The default preset to use for the download | `default` |
|
||||
| YTP_INSTANCE_TITLE | The title of the instance | `empty string` |
|
||||
| YTP_FILE_LOGGING | Whether to log to file | `false` |
|
||||
| YTP_DOWNLOAD_PATH | Path to where the downloads will be saved | `/downloads` |
|
||||
| YTP_MAX_WORKERS | How many works to use for downloads | `1` |
|
||||
| YTP_AUTH_USERNAME | Username for basic authentication | `empty string` |
|
||||
| YTP_AUTH_PASSWORD | Password for basic authentication | `empty string` |
|
||||
| YTP_CONSOLE_ENABLED | Whether to enable the console | `false` |
|
||||
| YTP_REMOVE_FILES | Remove the actual file when clicking the remove button | `false` |
|
||||
| YTP_CONFIG_PATH | Path to where the config files will be stored. | `/config` |
|
||||
| YTP_TEMP_PATH | Path to where tmp files are stored. | `/tmp` |
|
||||
| YTP_TEMP_KEEP | Whether to keep the Individual video temp directory or remove it | `false` |
|
||||
| YTP_HOST | Which IP address to bind to | `0.0.0.0` |
|
||||
| YTP_PORT | Which port to bind to | `8081` |
|
||||
| YTP_LOG_LEVEL | Log level | `info` |
|
||||
| YTP_STREAMER_VCODEC | The video codec to use for in-browser streaming | `libx264` |
|
||||
| YTP_STREAMER_ACODEC | The audio codec to use for in-browser streaming | `aac` |
|
||||
| YTP_ACCESS_LOG | Whether to log access to the web server | `true` |
|
||||
| YTP_DEBUG | Whether to turn on debug mode | `false` |
|
||||
| YTP_DEBUGPY_PORT | The port to use for the debugpy debugger | `5678` |
|
||||
| YTP_EXTRACT_INFO_TIMEOUT | The timeout for extracting video information | `70` |
|
||||
| YTP_DB_FILE | The path to the SQLite database file | `{config_path}/ytptube.db` |
|
||||
| YTP_UI_UPDATE_TITLE | Whether to update the title of the page with the current stats | `true` |
|
||||
| YTP_PIP_PACKAGES | A space separated list of pip packages to install | `empty string` |
|
||||
| YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` |
|
||||
| YTP_BASIC_MODE | Whether to run WebUI in basic mode | `false` |
|
||||
| YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use | `empty string` |
|
||||
| YTP_BROWSER_ENABLED | Whether to enable the file browser | `true` |
|
||||
| YTP_BROWSER_CONTROL_ENABLED | Whether to enable the file browser actions | `false` |
|
||||
| YTP_YTDLP_AUTO_UPDATE | Whether to enable the auto update for yt-dlp | `true` |
|
||||
| YTP_YTDLP_DEBUG | Whether to turn debug logging for the internal `yt-dlp` package | `false` |
|
||||
| YTP_YTDLP_VERSION | The version of yt-dlp to use. Defaults to latest version | `empty string` |
|
||||
| YTP_BASE_PATH | Set this if you are serving YTPTube from sub-folder | `/` |
|
||||
| YTP_PREVENT_LIVE_PREMIERE | Prevents the initial youtube premiere stream from being downloaded | `false` |
|
||||
| YTP_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer | `15 */1 * * *` |
|
||||
| YTP_PLAYLIST_ITEMS_CONCURRENCY | The number of playlist items be to processed at same time | `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
|
||||
|
||||
|
|
|
|||
|
|
@ -96,6 +96,32 @@ class DataStore:
|
|||
msg: str = f"{key=} or {url=} not found."
|
||||
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:
|
||||
return self._dict.get(id, None)
|
||||
|
||||
|
|
|
|||
|
|
@ -676,11 +676,43 @@ class DownloadQueue(metaclass=Singleton):
|
|||
item.extras.update({"external_downloader": True})
|
||||
|
||||
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."
|
||||
LOG.error(message)
|
||||
await self._notify.emit(
|
||||
Events.LOG_INFO, data={"preset": item.preset}, title="Already Downloaded", message=message
|
||||
)
|
||||
|
||||
return {"status": "error", "msg": message}
|
||||
|
||||
started: float = time.perf_counter()
|
||||
|
|
@ -908,26 +940,24 @@ class DownloadQueue(metaclass=Singleton):
|
|||
|
||||
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.
|
||||
|
||||
Args:
|
||||
id (str): The ID of the item to retrieve.
|
||||
**kwargs: The key-value pair to search for. Supported keys are 'id', 'url'.
|
||||
|
||||
Returns:
|
||||
Download | None: The requested item if found, otherwise None.
|
||||
(StoreType, Download) | None: The requested item if found, otherwise None.
|
||||
|
||||
"""
|
||||
try:
|
||||
return self.queue.get(key=id)
|
||||
except KeyError:
|
||||
pass
|
||||
if item := self.queue.get_item(**kwargs):
|
||||
return (StoreType.QUEUE, item)
|
||||
|
||||
try:
|
||||
return self.done.get(key=id)
|
||||
except KeyError:
|
||||
return None
|
||||
if item := self.done.get_item(**kwargs):
|
||||
return (StoreType.HISTORY, item)
|
||||
|
||||
return (None, None)
|
||||
|
||||
async def _download_pool(self) -> None:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ class Config:
|
|||
console_enabled: bool = False
|
||||
"Enable direct access to yt-dlp console."
|
||||
|
||||
browser_enabled: bool = False
|
||||
browser_enabled: bool = True
|
||||
"Enable file browser access."
|
||||
|
||||
browser_control_enabled: bool = False
|
||||
|
|
|
|||
210
app/routes/api/archiver.py
Normal file
210
app/routes/api/archiver.py
Normal 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,
|
||||
)
|
||||
|
|
@ -64,7 +64,7 @@
|
|||
</div>
|
||||
<div class="control is-expanded">
|
||||
<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">
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -105,7 +105,7 @@
|
|||
|
||||
<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()">
|
||||
:placeholder="getDefault('template', config.app.output_template || '%(title)s.%(ext)s')">
|
||||
<template #help>
|
||||
<span class="help is-bold is-unselectable">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
|
|
@ -123,7 +123,7 @@
|
|||
<span>Command options for yt-dlp</span>
|
||||
</label>
|
||||
<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="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>
|
||||
|
|
@ -138,7 +138,8 @@
|
|||
|
||||
<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">
|
||||
icon="fa-solid fa-cookie" :disabled="!socket.isConnected || addInProgress"
|
||||
:placeholder="getDefault('cookies', '')">
|
||||
<template #help>
|
||||
<span class="help is-bold">
|
||||
<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 expand_description = (e: Event) => toggleClass(e.target as HTMLElement, ['is-ellipsis', 'is-pre-wrap'])
|
||||
|
||||
const get_output_template = () => {
|
||||
if (form.value.preset && !hasFormatInConfig.value) {
|
||||
const preset = config.presets.find(p => p.name === form.value.preset)
|
||||
if (preset && preset.template) {
|
||||
return preset.template
|
||||
}
|
||||
const getDefault = (type: 'cookies' | 'cli' | 'template' | 'folder', ret: string = '') => {
|
||||
if (false !== hasFormatInConfig.value || !form.value.preset) {
|
||||
return ret
|
||||
}
|
||||
return config.app.output_template || '%(title)s.%(ext)s'
|
||||
}
|
||||
|
||||
const get_download_folder = (): string => {
|
||||
if (form.value.preset && false === hasFormatInConfig.value) {
|
||||
const preset = config.presets.find(p => p.name === form.value.preset)
|
||||
if (preset?.folder) {
|
||||
return preset.folder.replace(config.app.download_path, '')
|
||||
}
|
||||
const preset = config.presets.find(p => p.name === form.value.preset)
|
||||
|
||||
if (!preset) {
|
||||
return ret
|
||||
}
|
||||
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)))
|
||||
|
|
|
|||
|
|
@ -160,13 +160,13 @@
|
|||
Save in
|
||||
</label>
|
||||
<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">
|
||||
</div>
|
||||
<span class="help">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span class="is-bold">Current download folder: <code>{{ get_download_folder() }}</code>. All folders
|
||||
are sub-folders of <code>{{ config.app.download_path }}</code>.</span>
|
||||
<span class="is-bold">All folders are sub-folders of
|
||||
<code>{{ config.app.download_path }}</code>.</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -179,11 +179,12 @@
|
|||
</label>
|
||||
<div class="control">
|
||||
<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>
|
||||
<span class="help">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -237,7 +238,7 @@
|
|||
<span>Command options for yt-dlp</span>
|
||||
</label>
|
||||
<TextareaAutocomplete id="cli_options" v-model="form.cli" :options="ytDlpOpt"
|
||||
:disabled="addInProgress" />
|
||||
:placeholder="getDefault('cli', '')" :disabled="addInProgress" />
|
||||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>
|
||||
|
|
@ -282,10 +283,8 @@
|
|||
<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>
|
||||
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
|
||||
for ytdlp.cli, preset or task. If you don't have <code>--download-archive</code> set but
|
||||
<code>YTP_KEEP_ARCHIVE</code> environment option is set to <code>true</code> which is the default, It will
|
||||
also work.
|
||||
<li>RSS Feed monitoring will only work if you have <code>--download-archive</code> set in <code>Command options
|
||||
for yt-dlp</code> via the task itself, or preset. command options
|
||||
</li>
|
||||
</ul>
|
||||
</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 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 => {
|
||||
if (!url || '' === url) {
|
||||
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 body = await resp.json()
|
||||
const channel_id = ag(body, 'channel_id', null)
|
||||
console.log('convert_url', { url, channel_id, body })
|
||||
|
||||
if (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
|
||||
}
|
||||
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -67,7 +67,8 @@
|
|||
<DeprecatedNotice :version="config.app.app_version" title="Deprecation Notice" tone="warning"
|
||||
icon="fas fa-exclamation-triangle fa-fade fa-spin-10">
|
||||
<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>
|
||||
<ul>
|
||||
<li>
|
||||
|
|
@ -85,6 +86,12 @@
|
|||
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.
|
||||
</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>
|
||||
</ul>
|
||||
<p>
|
||||
|
|
|
|||
|
|
@ -55,7 +55,8 @@
|
|||
</div>
|
||||
<div class="is-hidden-mobile">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -361,9 +362,10 @@
|
|||
<Message title="Tips" class="is-info is-background-info-80" icon="fas fa-info-circle">
|
||||
<span>
|
||||
<ul>
|
||||
<li>If you are adding a big channel or playlist and you want to skip all old videos, please click on
|
||||
<code>Actions > Archive All</code> button to mark all videos as downloaded. otherwise, it will try to
|
||||
download all videos.
|
||||
<li>
|
||||
If you don't wish to download <strong>ALL</strong> content from a channel or playlist, click on
|
||||
<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>
|
||||
</ul>
|
||||
</span>
|
||||
|
|
|
|||
Loading…
Reference in a new issue