commit
3bbb1509e2
24 changed files with 451 additions and 101 deletions
11
.github/workflows/main.yml
vendored
11
.github/workflows/main.yml
vendored
|
|
@ -45,7 +45,6 @@ on:
|
||||||
env:
|
env:
|
||||||
DOCKERHUB_SLUG: arabcoders/ytptube
|
DOCKERHUB_SLUG: arabcoders/ytptube
|
||||||
GHCR_SLUG: ghcr.io/arabcoders/ytptube
|
GHCR_SLUG: ghcr.io/arabcoders/ytptube
|
||||||
PLATFORMS: linux/amd64
|
|
||||||
PNPM_VERSION: 10
|
PNPM_VERSION: 10
|
||||||
NODE_VERSION: 20
|
NODE_VERSION: 20
|
||||||
|
|
||||||
|
|
@ -67,7 +66,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: 'ui/pnpm-lock.yaml'
|
cache-dependency-path: "ui/pnpm-lock.yaml"
|
||||||
|
|
||||||
- name: Install frontend dependencies & Build
|
- name: Install frontend dependencies & Build
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
|
|
@ -87,8 +86,8 @@ jobs:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
push: false
|
push: false
|
||||||
cache-from: type=gha, scope=pr_${{ github.workflow }}
|
cache-from: type=gha, scope=${{ github.workflow }}
|
||||||
cache-to: type=gha, scope=pr_${{ github.workflow }}
|
cache-to: type=gha, mode=max, scope=${{ github.workflow }}
|
||||||
|
|
||||||
push-build:
|
push-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -110,7 +109,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: 'ui/pnpm-lock.yaml'
|
cache-dependency-path: "ui/pnpm-lock.yaml"
|
||||||
|
|
||||||
- name: Install frontend dependencies & Build
|
- name: Install frontend dependencies & Build
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
|
|
@ -178,7 +177,7 @@ jobs:
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha, scope=${{ github.workflow }}
|
cache-from: type=gha, scope=${{ github.workflow }}
|
||||||
cache-to: type=gha, scope=${{ github.workflow }}
|
cache-to: type=gha, mode=max, scope=${{ github.workflow }}
|
||||||
|
|
||||||
- name: Version tag
|
- name: Version tag
|
||||||
uses: arabcoders/action-python-autotagger@master
|
uses: arabcoders/action-python-autotagger@master
|
||||||
|
|
|
||||||
8
API.md
8
API.md
|
|
@ -108,7 +108,7 @@ If you fail to provide valid credentials, a `401 Unauthorized` response is retur
|
||||||
---
|
---
|
||||||
|
|
||||||
### POST /api/yt-dlp/convert
|
### POST /api/yt-dlp/convert
|
||||||
**Purpose**: Convert a string of yt-dlp or youtube-dl CLI arguments into a JSON-friendly structure.
|
**Purpose**: Convert a string of yt-dlp options into a JSON-friendly structure.
|
||||||
|
|
||||||
**Body**:
|
**Body**:
|
||||||
```json
|
```json
|
||||||
|
|
@ -134,7 +134,7 @@ If you fail to provide valid credentials, a `401 Unauthorized` response is retur
|
||||||
or an error:
|
or an error:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"error": "Failed to parse command arguments for yt-dlp. '<reason>'."
|
"error": "Failed to parse command options for yt-dlp. '<reason>'."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -212,7 +212,7 @@ or an error:
|
||||||
"folder": "my_channel/foo", // -- optional. The folder to save the item in, relative to the `download_path`.
|
"folder": "my_channel/foo", // -- optional. The folder to save the item in, relative to the `download_path`.
|
||||||
"cookies": "...", // -- optional. If provided, it MUST BE in Netscape HTTP Cookie format.
|
"cookies": "...", // -- optional. If provided, it MUST BE in Netscape HTTP Cookie format.
|
||||||
"template": "%(title)s.%(ext)s", // -- optional. The filename template to use for this item.
|
"template": "%(title)s.%(ext)s", // -- optional. The filename template to use for this item.
|
||||||
"cli": "--write-subs --embed-subs", // -- optional. Additional yt-dlp CLI arguments to apply to this item.
|
"cli": "--write-subs --embed-subs", // -- optional. Additional command options for yt-dlp to apply to this item.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Or multiple items (array of objects)
|
// Or multiple items (array of objects)
|
||||||
|
|
@ -697,7 +697,7 @@ Binary image data with appropriate `Content-Type` header.
|
||||||
"folder": "my_channel/foo", // optional, relative to download_path
|
"folder": "my_channel/foo", // optional, relative to download_path
|
||||||
"template": "%(title)s.%(ext)s", // optional, filename template
|
"template": "%(title)s.%(ext)s", // optional, filename template
|
||||||
"cookies": "...", // optional, Netscape HTTP Cookie format
|
"cookies": "...", // optional, Netscape HTTP Cookie format
|
||||||
"cli": "--write-subs --embed-subs", // optional, additional yt-dlp CLI arguments
|
"cli": "--write-subs --embed-subs", // optional, additional command options for yt-dlp
|
||||||
},
|
},
|
||||||
...
|
...
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
|
# syntax=docker/dockerfile:1.4
|
||||||
FROM node:lts-alpine AS node_builder
|
FROM node:lts-alpine AS node_builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY ui ./
|
COPY ui ./
|
||||||
RUN if [ ! -f "/app/exported/index.html" ]; then pnpm install --production --prefer-offline --frozen-lockfile && pnpm run generate; else echo "Skipping UI build, already built."; fi
|
RUN if [ ! -f "/app/exported/index.html" ]; then npm install --production --prefer-offline --frozen-lockfile && npm run generate; else echo "Skipping UI build, already built."; fi
|
||||||
|
|
||||||
FROM python:3.13-alpine AS python_builder
|
FROM python:3.13-alpine AS python_builder
|
||||||
|
|
||||||
|
|
@ -10,6 +11,8 @@ ENV LANG=C.UTF-8
|
||||||
ENV LC_ALL=C.UTF-8
|
ENV LC_ALL=C.UTF-8
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
ENV PYTHONFAULTHANDLER=1
|
ENV PYTHONFAULTHANDLER=1
|
||||||
|
ENV PIP_NO_CACHE_DIR=off
|
||||||
|
ENV PIP_CACHE_DIR=/root/.cache/pip
|
||||||
|
|
||||||
# Use sed to strip carriage-return characters from the entrypoint script (in case building on Windows)
|
# Use sed to strip carriage-return characters from the entrypoint script (in case building on Windows)
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
|
|
@ -19,7 +22,7 @@ WORKDIR /app
|
||||||
|
|
||||||
ARG PIPENV_FLAGS="--deploy"
|
ARG PIPENV_FLAGS="--deploy"
|
||||||
COPY ./Pipfile* .
|
COPY ./Pipfile* .
|
||||||
RUN PIPENV_VENV_IN_PROJECT=1 pipenv install ${PIPENV_FLAGS}
|
RUN --mount=type=cache,target=/root/.cache/pip PIPENV_VENV_IN_PROJECT=1 pipenv install ${PIPENV_FLAGS}
|
||||||
|
|
||||||
FROM python:3.13-alpine
|
FROM python:3.13-alpine
|
||||||
|
|
||||||
|
|
|
||||||
30
README.md
30
README.md
|
|
@ -2,9 +2,11 @@
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Web GUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp) with playlist & channel support.
|
**YTPTube** is a web-based GUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp), designed to make downloading videos from
|
||||||
|
YouTube and other video platforms easier and more user-friendly. It supports downloading playlists, channels, and
|
||||||
|
live streams, and includes features like scheduling downloads, sending notifications, and a built-in video player.
|
||||||
|
|
||||||
[](https://raw.githubusercontent.com/ArabCoders/ytptube/master/sc_full.png)
|

|
||||||
|
|
||||||
# YTPTube Features.
|
# YTPTube Features.
|
||||||
|
|
||||||
|
|
@ -30,7 +32,9 @@ Web GUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp) with playlist & channel s
|
||||||
* Apply `yt-dlp` options per custom defined conditions.
|
* Apply `yt-dlp` options per custom defined conditions.
|
||||||
* Custom browser extensions, bookmarklets and iOS shortcuts to send links to YTPTube instance.
|
* Custom browser extensions, bookmarklets and iOS shortcuts to send links to YTPTube instance.
|
||||||
|
|
||||||
# Run using docker command
|
# Installation
|
||||||
|
|
||||||
|
## Run using docker command
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p ./{config,downloads} && docker run -d --rm --user "$UID:${GID-$UID}" --name ytptube \
|
mkdir -p ./{config,downloads} && docker run -d --rm --user "$UID:${GID-$UID}" --name ytptube \
|
||||||
|
|
@ -38,7 +42,13 @@ mkdir -p ./{config,downloads} && docker run -d --rm --user "$UID:${GID-$UID}" --
|
||||||
ghcr.io/arabcoders/ytptube:latest
|
ghcr.io/arabcoders/ytptube:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
# Using compose file
|
Then you can access the WebUI at `http://localhost:8081`.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> If you are using `podman` instead of `docker`, you can use the same command, but you need to change the user to `0:0`
|
||||||
|
> it will appears to be running as root, but it will run as the user who started the container.
|
||||||
|
|
||||||
|
## Using compose file
|
||||||
|
|
||||||
The following is an example of a `compose.yaml` file that can be used to run YTPTube.
|
The following is an example of a `compose.yaml` file that can be used to run YTPTube.
|
||||||
|
|
||||||
|
|
@ -58,12 +68,24 @@ services:
|
||||||
- /tmp
|
- /tmp
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Make sure to change the `user` line to match your user id and group id
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ mkdir -p ./{config,downloads} && docker compose -f compose.yaml up -d
|
$ mkdir -p ./{config,downloads} && docker compose -f compose.yaml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Then you can access the WebUI at `http://localhost:8081`.
|
Then you can access the WebUI at `http://localhost:8081`.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> you can use podman-compose instead of docker-compose, as it supports the same syntax. However, you should change the
|
||||||
|
> user to `0:0` it will appears to be running as root, but it will run as the user who started the container.
|
||||||
|
|
||||||
|
## Unraid
|
||||||
|
|
||||||
|
For `Unraid` users You can install the `Community Applications` plugin, and search for **ytptube** it comes
|
||||||
|
pre-configured.
|
||||||
|
|
||||||
# Browser extensions & bookmarklets
|
# Browser extensions & bookmarklets
|
||||||
|
|
||||||
## Simple bookmarklet
|
## Simple bookmarklet
|
||||||
|
|
|
||||||
|
|
@ -388,8 +388,8 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
try:
|
try:
|
||||||
arg_converter(args=item.cli, level=True)
|
arg_converter(args=item.cli, level=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.error(f"Invalid cli options '{item.cli}'. {e!s}")
|
LOG.error(f"Invalid command options for yt-dlp '{item.cli}'. {e!s}")
|
||||||
return {"status": "error", "msg": f"Invalid cli options '{item.cli}'. {e!s}"}
|
return {"status": "error", "msg": f"Invalid command options for yt-dlp '{item.cli}'. {e!s}"}
|
||||||
|
|
||||||
if _preset:
|
if _preset:
|
||||||
if _preset.folder and not item.folder:
|
if _preset.folder and not item.folder:
|
||||||
|
|
|
||||||
|
|
@ -491,7 +491,7 @@ class HttpAPI(Common):
|
||||||
err = err.split("\n")[-1] if "\n" in err else err
|
err = err.split("\n")[-1] if "\n" in err else err
|
||||||
err = err.replace("main.py: error: ", "").strip().capitalize()
|
err = err.replace("main.py: error: ", "").strip().capitalize()
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
data={"error": f"Failed to parse command arguments for yt-dlp. '{err}'."},
|
data={"error": f"Failed to parse command options for yt-dlp. '{err}'."},
|
||||||
status=web.HTTPBadRequest.status_code,
|
status=web.HTTPBadRequest.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -844,7 +844,8 @@ class HttpAPI(Common):
|
||||||
|
|
||||||
if not item.get("cli"):
|
if not item.get("cli"):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"error": "CLI arguments is required.", "data": item}, status=web.HTTPBadRequest.status_code
|
{"error": "command options for yt-dlp is required.", "data": item},
|
||||||
|
status=web.HTTPBadRequest.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not item.get("id", None) or not validate_uuid(item.get("id"), version=4):
|
if not item.get("id", None) or not validate_uuid(item.get("id"), version=4):
|
||||||
|
|
@ -1687,7 +1688,6 @@ class HttpAPI(Common):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.exception(e)
|
|
||||||
LOG.error(f"Failed to request random background image from '{backend!s}'.'. '{e!s}'.")
|
LOG.error(f"Failed to request random background image from '{backend!s}'.'. '{e!s}'.")
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
data={"error": "failed to retrieve the random background image."},
|
data={"error": "failed to retrieve the random background image."},
|
||||||
|
|
|
||||||
|
|
@ -19,19 +19,19 @@ class Item:
|
||||||
"""The URL of the item to be downloaded."""
|
"""The URL of the item to be downloaded."""
|
||||||
|
|
||||||
preset: str = field(default_factory=lambda: Item._default_preset())
|
preset: str = field(default_factory=lambda: Item._default_preset())
|
||||||
"""The preset to be used for the download."""
|
"""The preset to be used for this download."""
|
||||||
|
|
||||||
folder: str = ""
|
folder: str = ""
|
||||||
"""The folder to save the download to."""
|
"""The folder to save the download to."""
|
||||||
|
|
||||||
cookies: str = ""
|
cookies: str = ""
|
||||||
"""The cookies to be used for the download."""
|
"""The cookies to be used for this download."""
|
||||||
|
|
||||||
template: str = ""
|
template: str = ""
|
||||||
"""The template to be used for the download."""
|
"""The template to be used for this download."""
|
||||||
|
|
||||||
cli: str = ""
|
cli: str = ""
|
||||||
"""The yt-dlp cli options to be used for the download."""
|
"""The command options for yt-dlp to be used for this download."""
|
||||||
|
|
||||||
extras: dict = field(default_factory=dict)
|
extras: dict = field(default_factory=dict)
|
||||||
"""Extra data to be added to the download."""
|
"""Extra data to be added to the download."""
|
||||||
|
|
@ -60,10 +60,10 @@ class Item:
|
||||||
|
|
||||||
def has_cli(self) -> bool:
|
def has_cli(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if the item has any yt-dlp cli options associated with it.
|
Check if the item has any command options for yt-dlp associated with it.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if the item has yt-dlp cli options, False otherwise.
|
bool: True if the item has command options for yt, False otherwise.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self.cli and len(self.cli) > 2
|
return self.cli and len(self.cli) > 2
|
||||||
|
|
@ -120,7 +120,7 @@ class Item:
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If the url is not provided.
|
ValueError: If the url is not provided.
|
||||||
ValueError: If the yt-cli command line arguments are not valid.
|
ValueError: If the command options for yt-cli are invalid.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Item: The formatted item.
|
Item: The formatted item.
|
||||||
|
|
@ -174,7 +174,7 @@ class Item:
|
||||||
|
|
||||||
data["cli"] = cli
|
data["cli"] = cli
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
msg = f"Failed to parse yt-dlp cli options. {e!s}"
|
msg = f"Failed to parse command options for yt-dlp. {e!s}"
|
||||||
raise ValueError(msg) from e
|
raise ValueError(msg) from e
|
||||||
|
|
||||||
return Item(**data)
|
return Item(**data)
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ class Preset:
|
||||||
"""The default cookies to use if non is given."""
|
"""The default cookies to use if non is given."""
|
||||||
|
|
||||||
cli: str = ""
|
cli: str = ""
|
||||||
"""yt-dlp cli command line arguments."""
|
"""command options for yt-dlp."""
|
||||||
|
|
||||||
default: bool = False
|
default: bool = False
|
||||||
"""If True, the preset is a default preset."""
|
"""If True, the preset is a default preset."""
|
||||||
|
|
@ -236,7 +236,7 @@ class Presets(metaclass=Singleton):
|
||||||
try:
|
try:
|
||||||
arg_converter(args=item.get("cli"))
|
arg_converter(args=item.get("cli"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
msg = f"Invalid cli options. '{e!s}'."
|
msg = f"Invalid command options for yt-dlp. '{e!s}'."
|
||||||
raise ValueError(msg) from e
|
raise ValueError(msg) from e
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,7 @@ class Tasks(metaclass=Singleton):
|
||||||
|
|
||||||
arg_converter(args=task.get("cli"))
|
arg_converter(args=task.get("cli"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
msg = f"Invalid cli options. '{e!s}'."
|
msg = f"Invalid command options for yt-dlp. '{e!s}'."
|
||||||
raise ValueError(msg) from e
|
raise ValueError(msg) from e
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,10 @@ class YTDLPOpts(metaclass=Singleton):
|
||||||
"""The preset options."""
|
"""The preset options."""
|
||||||
|
|
||||||
_item_cli: list = []
|
_item_cli: list = []
|
||||||
"""The item cli options."""
|
"""The command options for yt-dlp from item."""
|
||||||
|
|
||||||
_preset_cli: str = ""
|
_preset_cli: str = ""
|
||||||
"""The preset cli options."""
|
"""The command options for yt-dlp from preset."""
|
||||||
|
|
||||||
_instance = None
|
_instance = None
|
||||||
"""The instance of the class."""
|
"""The instance of the class."""
|
||||||
|
|
@ -44,10 +44,10 @@ class YTDLPOpts(metaclass=Singleton):
|
||||||
|
|
||||||
def add_cli(self, args: str, from_user: int | bool = False) -> "YTDLPOpts":
|
def add_cli(self, args: str, from_user: int | bool = False) -> "YTDLPOpts":
|
||||||
"""
|
"""
|
||||||
Parse and add yt-dlp cli options to the item options.
|
Parse and add command options for yt-dlp to the item options.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
args (str): The cli options to add
|
args (str): The command options for yt-dlp to add
|
||||||
from_user (bool): If the options are from the user
|
from_user (bool): If the options are from the user
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -60,7 +60,7 @@ class YTDLPOpts(metaclass=Singleton):
|
||||||
try:
|
try:
|
||||||
arg_converter(args=args, level=from_user)
|
arg_converter(args=args, level=from_user)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
msg = f"Invalid cli options for were given. '{e!s}'."
|
msg = f"Invalid command options for yt-dlp were given. '{e!s}'."
|
||||||
raise ValueError(msg) from e
|
raise ValueError(msg) from e
|
||||||
|
|
||||||
self._item_cli.append(args)
|
self._item_cli.append(args)
|
||||||
|
|
@ -119,7 +119,7 @@ class YTDLPOpts(metaclass=Singleton):
|
||||||
self._preset_opts = {}
|
self._preset_opts = {}
|
||||||
self._preset_cli = preset.cli
|
self._preset_cli = preset.cli
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
msg = f"Invalid cli options for preset '{preset.name}'. '{e!s}'."
|
msg = f"Invalid preset '{preset.name}' command options for yt-dlp. '{e!s}'."
|
||||||
raise ValueError(msg) from e
|
raise ValueError(msg) from e
|
||||||
|
|
||||||
if preset.cookies and with_cookies:
|
if preset.cookies and with_cookies:
|
||||||
|
|
@ -175,7 +175,7 @@ class YTDLPOpts(metaclass=Singleton):
|
||||||
merge.append(self._preset_cli)
|
merge.append(self._preset_cli)
|
||||||
|
|
||||||
if len(merge) > 0:
|
if len(merge) > 0:
|
||||||
# prepend the cli options to the list
|
# prepend the yt-dlp command options to the list
|
||||||
self._item_cli = merge + self._item_cli
|
self._item_cli = merge + self._item_cli
|
||||||
|
|
||||||
user_cli = {}
|
user_cli = {}
|
||||||
|
|
@ -188,7 +188,7 @@ class YTDLPOpts(metaclass=Singleton):
|
||||||
if len(removed_options) > 0:
|
if len(removed_options) > 0:
|
||||||
LOG.warning("Removed the following options: '%s'.", ", ".join(removed_options))
|
LOG.warning("Removed the following options: '%s'.", ", ".join(removed_options))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
msg = f"Invalid cli options were given. '{e!s}'."
|
msg = f"Invalid command options for yt-dlp were given. '{e!s}'."
|
||||||
raise ValueError(msg) from e
|
raise ValueError(msg) from e
|
||||||
|
|
||||||
data = merge_dict(user_cli, merge_dict(self._item_opts, merge_dict(self._preset_opts, default_opts)))
|
data = merge_dict(user_cli, merge_dict(self._item_opts, merge_dict(self._preset_opts, default_opts)))
|
||||||
|
|
|
||||||
|
|
@ -204,13 +204,13 @@ class Conditions(metaclass=Singleton):
|
||||||
raise ValueError(msg) from e
|
raise ValueError(msg) from e
|
||||||
|
|
||||||
if not item.get("cli"):
|
if not item.get("cli"):
|
||||||
msg = "No CLI arguments were found."
|
msg = "No command options for yt-dlp were found."
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
arg_converter(args=item.get("cli"))
|
arg_converter(args=item.get("cli"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
msg = f"Invalid cli options. '{e!s}'."
|
msg = f"Invalid command options for yt-dlp. '{e!s}'."
|
||||||
raise ValueError(msg) from e
|
raise ValueError(msg) from e
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
|
||||||
BIN
sc_full.png
BIN
sc_full.png
Binary file not shown.
|
Before Width: | Height: | Size: 2.9 MiB |
BIN
sc_short.png
BIN
sc_short.png
Binary file not shown.
|
Before Width: | Height: | Size: 638 KiB After Width: | Height: | Size: 675 KiB |
|
|
@ -80,7 +80,8 @@
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>
|
<span>
|
||||||
The yt-dlp <code>[--match-filters]</code> filter logic.
|
The yt-dlp <code>[--match-filters]</code> filter logic.
|
||||||
<NuxtLink @click="test_data.show = true" :disabled="addInProgress || !form.filter" class="is-bold">
|
<NuxtLink @click="test_data.show = true" :disabled="addInProgress || !form.filter"
|
||||||
|
class="is-bold">
|
||||||
Test filter logic
|
Test filter logic
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -92,7 +93,7 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline" for="cli_options">
|
<label class="label is-inline" for="cli_options">
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||||
Command arguments for yt-dlp
|
Command options for yt-dlp
|
||||||
</label>
|
</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<textarea class="textarea is-pre" v-model="form.cli" id="cli_options" :disabled="addInProgress"
|
<textarea class="textarea is-pre" v-model="form.cli" id="cli_options" :disabled="addInProgress"
|
||||||
|
|
@ -101,8 +102,8 @@
|
||||||
<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>If the filter is matched, these options will be used.
|
<span>If the filter is matched, these options will be used.
|
||||||
<span class="has-text-danger">This will override the cli arguments given with the URL. it's
|
<span class="has-text-danger">This will override the command options for yt-dlp given with the
|
||||||
recommended to use presets and keep the cli with url empty if you plan to use this
|
URL. it's recommended to use presets and keep that field with url empty if you plan to use this
|
||||||
feature.</span>
|
feature.</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -621,7 +621,10 @@ const archiveItem = item => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeItem = item => {
|
const removeItem = item => {
|
||||||
const msg = `Remove '${item.title ?? item.id ?? item.url ?? '??'}'?\n this will delete the file from the server.`
|
let msg = `Remove '${item.title ?? item.id ?? item.url ?? '??'}'?`
|
||||||
|
if (item.status === 'finished' && item.filename && config.app.remove_files) {
|
||||||
|
msg += '\nThis will delete the file from the server if it exists.'
|
||||||
|
}
|
||||||
if (false === box.confirm(msg, config.app.remove_files)) {
|
if (false === box.confirm(msg, config.app.remove_files)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,10 @@
|
||||||
<input type="text" class="input" id="url" placeholder="Video or playlist link"
|
<input type="text" class="input" id="url" placeholder="Video or playlist link"
|
||||||
:disabled="!socket.isConnected || addInProgress" v-model="form.url">
|
:disabled="!socket.isConnected || addInProgress" v-model="form.url">
|
||||||
</div>
|
</div>
|
||||||
<span class="help">
|
<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>You can add multiple URLs separated by a comma <code>,</code>.</span>
|
<span>You can add multiple URLs separated by <code
|
||||||
|
class="is-bold">{{ getSeparatorsName(separator) }}</code>.</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-4-tablet is-12-mobile" v-if="!config.app.basic_mode">
|
<div class="column is-4-tablet is-12-mobile" v-if="!config.app.basic_mode">
|
||||||
|
|
@ -30,7 +31,7 @@
|
||||||
<div class="select is-fullwidth">
|
<div class="select is-fullwidth">
|
||||||
<select id="preset" class="is-fullwidth"
|
<select id="preset" class="is-fullwidth"
|
||||||
:disabled="!socket.isConnected || addInProgress || hasFormatInConfig" v-model="form.preset"
|
:disabled="!socket.isConnected || addInProgress || hasFormatInConfig" v-model="form.preset"
|
||||||
v-tooltip.bottom="hasFormatInConfig ? 'Presets are disabled. Format key is present in the Command arguments for yt-dlp.' : ''">
|
v-tooltip.bottom="hasFormatInConfig ? 'Presets are disabled. Format key is present in the command options for yt-dlp.' : ''">
|
||||||
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0">
|
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0">
|
||||||
<option v-for="item in filter_presets(false)" :key="item.name" :value="item.name">
|
<option v-for="item in filter_presets(false)" :key="item.name" :value="item.name">
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
|
|
@ -84,7 +85,30 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="columns is-multiline is-mobile" v-if="showAdvanced && !config.app.basic_mode">
|
<div class="columns is-multiline is-mobile" v-if="showAdvanced && !config.app.basic_mode">
|
||||||
<div class="column is-12">
|
|
||||||
|
<div class="column is-4-tablet is-12-mobile" v-if="!config.app.basic_mode">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-inline is-unselectable">
|
||||||
|
<span class="icon"><i class="fa-solid fa-comment" /></span>
|
||||||
|
<span>URLs Separator</span>
|
||||||
|
</label>
|
||||||
|
<div class="control">
|
||||||
|
<div class="select is-fullwidth">
|
||||||
|
<select class="is-fullwidth" :disabled="!socket.isConnected || addInProgress" v-model="separator">
|
||||||
|
<option v-for="(sep, index) in separators" :key="`sep-${index}`" :value="sep.value">
|
||||||
|
{{ sep.name }} ({{ sep.value }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="help is-bold">
|
||||||
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
|
<span>Use this separate multiple URLs in the input field.</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-8-tablet is-12-mobile">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline is-unselectable" for="output_format"
|
<label class="label is-inline is-unselectable" for="output_format"
|
||||||
v-tooltip="'Default: ' + config.app.output_template">
|
v-tooltip="'Default: ' + config.app.output_template">
|
||||||
|
|
@ -96,7 +120,7 @@
|
||||||
:disabled="!socket.isConnected || addInProgress"
|
:disabled="!socket.isConnected || addInProgress"
|
||||||
placeholder="Uses default output template naming if empty.">
|
placeholder="Uses default output template naming if empty.">
|
||||||
</div>
|
</div>
|
||||||
<span class="help">
|
<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>All output template naming options can be found at <NuxtLink target="_blank"
|
<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>
|
to="https://github.com/yt-dlp/yt-dlp#output-template">this page</NuxtLink>.</span>
|
||||||
|
|
@ -108,7 +132,7 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline is-unselectable" for="cli_options">
|
<label class="label is-inline is-unselectable" for="cli_options">
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||||
Command arguments for yt-dlp
|
Command options for yt-dlp
|
||||||
</label>
|
</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<textarea class="textarea is-pre" v-model="form.cli" id="cli_options"
|
<textarea class="textarea is-pre" v-model="form.cli" id="cli_options"
|
||||||
|
|
@ -116,12 +140,13 @@
|
||||||
placeholder="command options to use, e.g. --no-embed-metadata --no-embed-thumbnail" />
|
placeholder="command options to use, e.g. --no-embed-metadata --no-embed-thumbnail" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="help">
|
<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>yt-dlp cli arguments. Check <NuxtLink target="_blank"
|
<span>Check <NuxtLink target="_blank" to="https://github.com/yt-dlp/yt-dlp#general-options">this page
|
||||||
to="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#general-options">this page</NuxtLink>.
|
</NuxtLink> for more info. <span class="has-text-danger">Not all options are supported <NuxtLink
|
||||||
For more info. <span class="has-text-danger">Not all options are supported some are ignored. Use
|
target="_blank"
|
||||||
with caution those arguments can break yt-dlp or the frontend.</span>
|
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L24-L46">some are
|
||||||
|
ignored</NuxtLink>. Use with caution these options can break yt-dlp or the frontend.</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -137,12 +162,13 @@
|
||||||
<textarea class="textarea is-pre" id="ytdlpCookies" v-model="form.cookies"
|
<textarea class="textarea is-pre" id="ytdlpCookies" v-model="form.cookies"
|
||||||
:disabled="!socket.isConnected || addInProgress" />
|
:disabled="!socket.isConnected || addInProgress" />
|
||||||
</div>
|
</div>
|
||||||
<span class="help">
|
<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>Use the <NuxtLink target="_blank"
|
<span>Use the <NuxtLink target="_blank"
|
||||||
to="https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp">
|
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. The cookies MUST be in Netscape HTTP
|
Recommended addon</NuxtLink> by yt-dlp to export cookies. <span class="has-text-danger">The
|
||||||
Cookie format.</span>
|
cookies MUST be in Netscape HTTP Cookie format.</span>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -192,7 +218,22 @@ const socket = useSocketStore()
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
const box = useConfirm()
|
const box = useConfirm()
|
||||||
|
|
||||||
|
const separators = [
|
||||||
|
{ name: 'Comma', value: ',', },
|
||||||
|
{ name: 'Semicolon', value: ';', },
|
||||||
|
{ name: 'Colon', value: ':', },
|
||||||
|
{ name: 'Pipe', value: '|', },
|
||||||
|
{ name: 'Space', value: ' ', }
|
||||||
|
]
|
||||||
|
|
||||||
|
const getSeparatorsName = (value) => {
|
||||||
|
const sep = separators.find(s => s.value === value)
|
||||||
|
return sep ? `${sep.name} (${value})` : 'Unknown'
|
||||||
|
}
|
||||||
|
|
||||||
const showAdvanced = useStorage('show_advanced', false)
|
const showAdvanced = useStorage('show_advanced', false)
|
||||||
|
const separator = useStorage('url_separator', separators[0])
|
||||||
|
|
||||||
const addInProgress = ref(false)
|
const addInProgress = ref(false)
|
||||||
|
|
||||||
const form = useStorage('local_config_v1', {
|
const form = useStorage('local_config_v1', {
|
||||||
|
|
@ -216,7 +257,7 @@ const addDownload = async () => {
|
||||||
|
|
||||||
const request_data = []
|
const request_data = []
|
||||||
|
|
||||||
form.value.url.split(',').forEach(async (url) => {
|
form.value.url.split(separator.value).forEach(async (url) => {
|
||||||
if (!url.trim()) {
|
if (!url.trim()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -245,11 +286,19 @@ const addDownload = async () => {
|
||||||
body: JSON.stringify(request_data),
|
body: JSON.stringify(request_data),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json()
|
toast.error(`Error: ${data.error || 'Failed to add download.'}`)
|
||||||
throw new Error(data.error)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data.forEach(item => {
|
||||||
|
if (false !== item.status) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.error(`Error: ${item.msg || 'Failed to add download.'}`)
|
||||||
|
})
|
||||||
|
|
||||||
form.value.url = ''
|
form.value.url = ''
|
||||||
emitter('clear_form')
|
emitter('clear_form')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
134
ui/components/NotifyDropdown.vue
Normal file
134
ui/components/NotifyDropdown.vue
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
<template>
|
||||||
|
<div class="navbar-item has-dropdown is-hoverable">
|
||||||
|
<a class="navbar-link">
|
||||||
|
<span class="icon"><i class="fas fa-bell" /></span>
|
||||||
|
<span class="tag is-underlined ml-2">{{ store.unreadCount }}</span>
|
||||||
|
</a>
|
||||||
|
<div class="navbar-dropdown is-right" style="width: 400px;">
|
||||||
|
<template v-if="store.notifications.length > 0">
|
||||||
|
<div class="px-3 py-2 is-flex is-justify-content-space-between is-align-items-center">
|
||||||
|
<span class="has-text-grey">
|
||||||
|
<span class="is-underlined">{{ store.notifications.length }}</span> Total
|
||||||
|
</span>
|
||||||
|
<div class="field is-grouped">
|
||||||
|
|
||||||
|
<div class="control" v-if="store.unreadCount > 0">
|
||||||
|
<button class="button is-small is-light mr-1" @click="store.markAllRead">
|
||||||
|
<span class="icon"><i class="fas fa-check" /></span>
|
||||||
|
<span>Mark all read</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-small is-danger is-light" @click="store.clear">
|
||||||
|
<span class="icon"><i class="fas fa-trash" /></span>
|
||||||
|
<span>Clear all</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="navbar-divider">
|
||||||
|
</template>
|
||||||
|
<div class="notification-list">
|
||||||
|
<div v-for="n in store.notifications" :key="n.id" class="navbar-item is-flex is-align-items-start"
|
||||||
|
:class="['notification-item', 'notification-' + n.level]">
|
||||||
|
<div class="is-flex-grow-1">
|
||||||
|
<p class="is-size-7 mb-1 notification-message" :class="{ expanded: expandedId === n.id }"
|
||||||
|
@click="toggleExpand(n.id)">
|
||||||
|
{{ n.message }}
|
||||||
|
</p>
|
||||||
|
<p class="is-size-7 has-text-grey">
|
||||||
|
<span :date-datetime="n.created" v-tooltip="moment(n.created).format('YYYY-M-DD H:mm Z')"
|
||||||
|
v-rtime="n.created" />
|
||||||
|
- <NuxtLink @click="copy_text(n.id, n.message)">
|
||||||
|
<span v-if="copiedId === n.id" class="has-text-success">Copied!</span>
|
||||||
|
<span v-else>Copy</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 is-flex is-flex-direction-column is-justify-content-center">
|
||||||
|
<div class="field is-grouped">
|
||||||
|
<div class="control" v-if="!n.seen">
|
||||||
|
<button class="button is-small is-light" @click="store.markRead(n.id)">
|
||||||
|
<span class="icon"><i class="fas fa-check" /></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-danger is-small" @click="store.remove(n.id)">
|
||||||
|
<span class="icon"><i class="fas fa-trash" /></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="store.notifications.length < 1" class="navbar-item is-flex is-align-items-start">
|
||||||
|
<div class="is-flex-grow-1 has-text-centered has-text-grey">
|
||||||
|
<p class="is-size-7">No notifications</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import moment from 'moment'
|
||||||
|
const store = useNotificationStore()
|
||||||
|
const copiedId = ref(null)
|
||||||
|
const expandedId = ref(null)
|
||||||
|
|
||||||
|
const toggleExpand = id => expandedId.value = expandedId.value === id ? null : id
|
||||||
|
|
||||||
|
const copy_text = (id, text) => {
|
||||||
|
copiedId.value = id
|
||||||
|
copyText(text, false, false)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (copiedId.value === id) {
|
||||||
|
copiedId.value = null
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notification-item {
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-info {
|
||||||
|
border-color: var(--bulma-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-success {
|
||||||
|
border-color: var(--bulma-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-warning {
|
||||||
|
border-color: var(--bulma-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-error {
|
||||||
|
border-color: var(--bulma-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-list {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-message {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-message.expanded {
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -22,32 +22,70 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
|
|
||||||
<div class="columns is-multiline is-mobile">
|
<div class="columns is-multiline is-mobile">
|
||||||
|
<template v-if="showImport || !reference">
|
||||||
<div class="column is-12" v-if="showImport || !reference">
|
<div class="column is-6-tablet is-12-mobile">
|
||||||
<label class="label is-inline" for="import_string">
|
<div class="field">
|
||||||
<span class="icon"><i class="fa-solid fa-file-import" /></span>
|
<label class="label is-inline" for="import_string">
|
||||||
Import string
|
<span class="icon"><i class="fa-solid fa-file-import" /></span>
|
||||||
</label>
|
Import form pre-existing preset
|
||||||
|
</label>
|
||||||
<div class="field has-addons">
|
<div class="control is-expanded">
|
||||||
<div class="control is-expanded">
|
<div class="select is-fullwidth">
|
||||||
<input type="text" class="input" id="import_string" v-model="import_string" autocomplete="off">
|
<select class="is-fullwidth" v-model="selected_preset" @change="import_existing_preset">
|
||||||
</div>
|
<option value="" disabled>Select a preset</option>
|
||||||
|
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0">
|
||||||
<div class="control">
|
<option v-for="item in filter_presets(false)" :key="item.name" :value="item.name">
|
||||||
<button class="button is-primary" :disabled="!import_string" type="button" @click="importItem">
|
{{ item.name }}
|
||||||
<span class="icon"><i class="fa-solid fa-add" /></span>
|
</option>
|
||||||
<span>Import</span>
|
</optgroup>
|
||||||
</button>
|
<optgroup label="Default presets">
|
||||||
|
<option v-for="item in filter_presets(true)" :key="item.name" :value="item.name">
|
||||||
|
{{ item.name }}
|
||||||
|
</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="help is-bold">
|
||||||
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
|
<span>Select a preset to import its data. <span class="has-text-danger">Warning: This will
|
||||||
|
overwrite the current form data.</span></span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="help">
|
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<div class="column is-6-tablet is-12-mobile">
|
||||||
<span>You can use this field to populate the data, using shared string.</span>
|
<div class="field">
|
||||||
</span>
|
<label class="label is-inline" for="import_string">
|
||||||
</div>
|
<span class="icon"><i class="fa-solid fa-file-import" /></span>
|
||||||
|
Import string
|
||||||
|
</label>
|
||||||
|
<div class="field-body">
|
||||||
|
<div class="field has-addons">
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<input type="text" class="input" id="import_string" v-model="import_string"
|
||||||
|
autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-primary" :disabled="!import_string" type="button"
|
||||||
|
@click="importItem">
|
||||||
|
<span class="icon"><i class="fa-solid fa-add" /></span>
|
||||||
|
<span>Import</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="help is-bold">
|
||||||
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
|
<span>Paste shared preset string here to import it. <span class="has-text-danger">Warning:
|
||||||
|
This will overwrite the current form data.</span></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|
@ -98,7 +136,8 @@
|
||||||
<span>Use this output template if non are given with URL. if not set, it will defaults to
|
<span>Use this output template if non are given with URL. if not set, it will defaults to
|
||||||
<code>{{ config.app.output_template }}</code>.
|
<code>{{ config.app.output_template }}</code>.
|
||||||
For more information visit <NuxtLink href="https://github.com/yt-dlp/yt-dlp#output-template"
|
For more information visit <NuxtLink href="https://github.com/yt-dlp/yt-dlp#output-template"
|
||||||
target="_blank">this page</NuxtLink>.
|
target="_blank">
|
||||||
|
this page</NuxtLink>.
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -223,6 +262,7 @@ const form = reactive(JSON.parse(JSON.stringify(props.preset)))
|
||||||
const import_string = ref('')
|
const import_string = ref('')
|
||||||
const showImport = useStorage('showImport', false)
|
const showImport = useStorage('showImport', false)
|
||||||
const box = useConfirm()
|
const box = useConfirm()
|
||||||
|
const selected_preset = ref('')
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (props.preset?.cli && '' !== props.preset?.cli) {
|
if (props.preset?.cli && '' !== props.preset?.cli) {
|
||||||
|
|
@ -329,10 +369,6 @@ const importItem = async () => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.cli && false === box.confirm('This will overwrite the current data. Are you sure?', true)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.name) {
|
if (item.name) {
|
||||||
form.name = item.name
|
form.name = item.name
|
||||||
}
|
}
|
||||||
|
|
@ -371,4 +407,26 @@ const importItem = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filter_presets = (flag = true) => config.presets.filter(item => item.default === flag)
|
||||||
|
|
||||||
|
const import_existing_preset = async () => {
|
||||||
|
if (!selected_preset.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const preset = config.presets.find(p => p.name === selected_preset.value)
|
||||||
|
if (!preset) {
|
||||||
|
toast.error('Preset not found.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
form.cli = preset.cli || ''
|
||||||
|
form.folder = preset.folder || ''
|
||||||
|
form.template = preset.template || ''
|
||||||
|
form.cookies = preset.cookies || ''
|
||||||
|
form.description = preset.description || ''
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
selected_preset.value = ''
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@
|
||||||
<div class="select is-fullwidth">
|
<div class="select is-fullwidth">
|
||||||
<select id="preset" class="is-fullwidth" v-model="form.preset"
|
<select id="preset" class="is-fullwidth" v-model="form.preset"
|
||||||
:disabled="addInProgress || hasFormatInConfig"
|
:disabled="addInProgress || hasFormatInConfig"
|
||||||
v-tooltip.bottom="hasFormatInConfig ? 'Presets are disabled. Format key is present in the command arguments for yt-dlp.' : ''">
|
v-tooltip.bottom="hasFormatInConfig ? 'Presets are disabled. Format key is present in the command options for yt-dlp.' : ''">
|
||||||
<optgroup label="Default presets">
|
<optgroup label="Default presets">
|
||||||
<option v-for="item in filter_presets(true)" :key="item.name" :value="item.name">
|
<option v-for="item in filter_presets(true)" :key="item.name" :value="item.name">
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
|
|
@ -179,7 +179,7 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline" for="cli_options">
|
<label class="label is-inline" for="cli_options">
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||||
Command arguments for yt-dlp
|
Command options for yt-dlp
|
||||||
</label>
|
</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<textarea type="text" class="textarea is-pre" v-model="form.cli" id="cli_options"
|
<textarea type="text" class="textarea is-pre" v-model="form.cli" id="cli_options"
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,23 @@
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import { POSITION, useToast } from "vue-toastification"
|
import { POSITION, useToast } from "vue-toastification"
|
||||||
|
|
||||||
type notificationType = 'info' | 'success' | 'warning' | 'error'
|
export type notificationType = 'info' | 'success' | 'warning' | 'error'
|
||||||
type notificationOptions = {
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
message: string
|
||||||
|
level: notificationType
|
||||||
|
seen: boolean
|
||||||
|
created: Date
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface notificationOptions {
|
||||||
timeout?: number,
|
timeout?: number,
|
||||||
force?: boolean,
|
force?: boolean,
|
||||||
|
store?: boolean,
|
||||||
closeOnClick?: boolean,
|
closeOnClick?: boolean,
|
||||||
position?: POSITION
|
position?: POSITION
|
||||||
|
onClick?: (closeToast: Function) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowToast = useStorage<boolean>('allow_toasts', true)
|
const allowToast = useStorage<boolean>('allow_toasts', true)
|
||||||
|
|
@ -15,11 +26,24 @@ const toastDismissOnClick = useStorage<boolean>('toast_dismiss_on_click', true)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
function notify(type: notificationType, message: string, opts?: notificationOptions): void {
|
function notify(type: notificationType, message: string, opts?: notificationOptions): void {
|
||||||
let force = opts?.force || false;
|
const notificationStore = useNotificationStore()
|
||||||
|
|
||||||
|
let id: string = ''
|
||||||
|
const force = opts?.force || false;
|
||||||
|
const store = opts?.store || true;
|
||||||
|
|
||||||
|
if (store && notificationStore) {
|
||||||
|
id = notificationStore.add(type, message, false)
|
||||||
|
}
|
||||||
|
|
||||||
if (false === allowToast.value && false === force) {
|
if (false === allowToast.value && false === force) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (false === document.hasFocus()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!opts) {
|
if (!opts) {
|
||||||
opts = {}
|
opts = {}
|
||||||
}
|
}
|
||||||
|
|
@ -30,6 +54,14 @@ function notify(type: notificationType, message: string, opts?: notificationOpti
|
||||||
|
|
||||||
opts.closeOnClick = toastDismissOnClick.value
|
opts.closeOnClick = toastDismissOnClick.value
|
||||||
opts.position = toastPosition.value ?? POSITION.TOP_RIGHT
|
opts.position = toastPosition.value ?? POSITION.TOP_RIGHT
|
||||||
|
opts.onClick = (closeToast: Function) => {
|
||||||
|
if (opts?.closeOnClick !== false) {
|
||||||
|
closeToast()
|
||||||
|
}
|
||||||
|
if (notificationStore) {
|
||||||
|
notificationStore.markRead(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'info':
|
case 'info':
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,8 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NotifyDropdown />
|
||||||
|
|
||||||
<div class="navbar-item is-hidden-mobile">
|
<div class="navbar-item is-hidden-mobile">
|
||||||
<button class="button is-dark has-tooltip-bottom mr-4" v-tooltip.bottom="'WebUI Settings'"
|
<button class="button is-dark has-tooltip-bottom mr-4" v-tooltip.bottom="'WebUI Settings'"
|
||||||
@click="show_settings = !show_settings">
|
@click="show_settings = !show_settings">
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,7 @@ const isLoading = ref(true)
|
||||||
const initialLoad = ref(true)
|
const initialLoad = ref(true)
|
||||||
const addInProgress = ref(false)
|
const addInProgress = ref(false)
|
||||||
const display_style = useStorage("tasks_display_style", "cards")
|
const display_style = useStorage("tasks_display_style", "cards")
|
||||||
|
const remove_keys = ['in_progress']
|
||||||
|
|
||||||
watch(() => config.app.basic_mode, async () => {
|
watch(() => config.app.basic_mode, async () => {
|
||||||
if (!config.app.basic_mode) {
|
if (!config.app.basic_mode) {
|
||||||
|
|
@ -295,7 +296,7 @@ const updateTasks = async items => {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(items),
|
body: JSON.stringify(items.map(item => cleanObject(toRaw(item), remove_keys))),
|
||||||
})
|
})
|
||||||
|
|
||||||
data = await response.json()
|
data = await response.json()
|
||||||
|
|
@ -338,7 +339,6 @@ const deleteItem = async item => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateItem = async ({ reference, task }) => {
|
const updateItem = async ({ reference, task }) => {
|
||||||
task = cleanObject(task, ['in_progress'])
|
|
||||||
if (reference) {
|
if (reference) {
|
||||||
// -- find the task index.
|
// -- find the task index.
|
||||||
const index = tasks.value.findIndex((t) => t?.id === reference)
|
const index = tasks.value.findIndex((t) => t?.id === reference)
|
||||||
|
|
|
||||||
47
ui/stores/NotificationStore.ts
Normal file
47
ui/stores/NotificationStore.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useStorage } from '@vueuse/core'
|
||||||
|
import type { Notification, notificationType } from '~/composables/useNotification'
|
||||||
|
|
||||||
|
export const useNotificationStore = defineStore('notifications', () => {
|
||||||
|
const notifications = useStorage<Notification[]>('notifications', [])
|
||||||
|
const unreadCount = computed<number>(() => notifications.value.filter(n => !n.seen).length)
|
||||||
|
|
||||||
|
const add = (level: notificationType, message: string, seen: boolean = false): string => {
|
||||||
|
const id = Array.from(
|
||||||
|
window.crypto.getRandomValues(new Uint8Array(14 / 2)),
|
||||||
|
(dec: any) => dec.toString(16).padStart(2, "0")
|
||||||
|
).join('')
|
||||||
|
|
||||||
|
notifications.value.unshift({
|
||||||
|
id: id,
|
||||||
|
message,
|
||||||
|
level,
|
||||||
|
seen: seen,
|
||||||
|
created: new Date()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (notifications.value.length > 99) {
|
||||||
|
notifications.value.length = 99
|
||||||
|
}
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
const clear = () => notifications.value = []
|
||||||
|
const markAllRead = () => notifications.value.forEach(n => n.seen = true)
|
||||||
|
|
||||||
|
const markRead = (id: string) => {
|
||||||
|
console.log(`Marking notification ${id} as read`)
|
||||||
|
const n = notifications.value.find(n => n.id === id)
|
||||||
|
if (!n) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n.seen = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const get = (id: string): Notification | undefined => notifications.value.find(n => n.id === id)
|
||||||
|
|
||||||
|
const remove = (id: string) => notifications.value = notifications.value.filter(n => n.id !== id)
|
||||||
|
|
||||||
|
return { notifications, unreadCount, add, get, markAllRead, clear, markRead, remove }
|
||||||
|
})
|
||||||
|
|
@ -125,7 +125,7 @@ const r = (text, context = {}) => {
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyText = (str, notify = true) => {
|
const copyText = (str, notify = true, store = false) => {
|
||||||
if (navigator.clipboard) {
|
if (navigator.clipboard) {
|
||||||
navigator.clipboard.writeText(str).then(() => {
|
navigator.clipboard.writeText(str).then(() => {
|
||||||
if (notify) {
|
if (notify) {
|
||||||
|
|
@ -148,7 +148,7 @@ const copyText = (str, notify = true) => {
|
||||||
document.body.removeChild(el)
|
document.body.removeChild(el)
|
||||||
|
|
||||||
if (notify) {
|
if (notify) {
|
||||||
toast.success('Text copied to clipboard.')
|
toast.success('Text copied to clipboard.', { store: store })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue