Merge pull request #292 from arabcoders/dev

Add notifications center
This commit is contained in:
Abdulmohsen 2025-06-04 22:23:56 +03:00 committed by GitHub
commit 3bbb1509e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 451 additions and 101 deletions

View file

@ -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
View file

@ -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
}, },
... ...
] ]

View file

@ -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

View file

@ -2,9 +2,11 @@
![Build Status](https://github.com/ArabCoders/ytptube/actions/workflows/main.yml/badge.svg) ![Build Status](https://github.com/ArabCoders/ytptube/actions/workflows/main.yml/badge.svg)
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.
[![Short screenshot](https://raw.githubusercontent.com/ArabCoders/ytptube/master/sc_short.png)](https://raw.githubusercontent.com/ArabCoders/ytptube/master/sc_full.png) ![Short screenshot](https://raw.githubusercontent.com/ArabCoders/ytptube/master/sc_short.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

View file

@ -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:

View file

@ -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."},

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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)))

View file

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 638 KiB

After

Width:  |  Height:  |  Size: 675 KiB

View file

@ -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>

View file

@ -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
} }

View file

@ -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')
} }

View 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>

View file

@ -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>

View file

@ -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"

View file

@ -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':

View file

@ -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">

View file

@ -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)

View 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 }
})

View file

@ -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 })
} }
} }