Merge pull request #270 from arabcoders/dev

Design Update & Fold preset --format into the cli args
This commit is contained in:
Abdulmohsen 2025-05-10 19:54:07 +03:00 committed by GitHub
commit 853a443b88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1943 additions and 1010 deletions

149
README.md
View file

@ -10,7 +10,7 @@ Web GUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp) with playlist & channel s
* Multi-downloads support.
* Random beautiful background. `can be disabled`.
* Can Handle live streams.
* Can handle live streams.
* Scheduler to queue channels or playlists to be downloaded automatically at a specified time.
* Send notification to targets based on selected events.
* Support per link `cli options` & `cookies`
@ -26,6 +26,7 @@ Web GUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp) with playlist & channel s
* Support for both advanced and basic mode for WebUI.
* Bundled tools in container: curl-cffi, ffmpeg, ffprobe, aria2, rtmpdump, mkvtoolsnix, mp4box.
* Automatic upcoming live stream re-queue.
* Apply `yt-dlp` options per custom defined conditions.
# Run using docker command
@ -54,53 +55,11 @@ services:
```
```bash
$ mkdir {config,downloads} && docker-compose -f compose.yaml up -d
$ mkdir {config,downloads} && docker compose -f compose.yaml up -d
```
Then you can access the WebUI at `http://localhost:8081`.
# Environment variables
Certain configuration values can be set via environment variables, using the `-e` parameter on the docker command line, or the `environment:` section in `compose.yaml` file.
| Environment Variable | Description | Default |
| ------------------------ | ---------------------------------------------------------------- | ---------------------------------- |
| YTP_OUTPUT_TEMPLATE | The template for the filenames of the downloaded videos | `%(title)s.%(ext)s` |
| YTP_DEFAULT_PRESET | The default preset to use for the download | `default` |
| YTP_INSTANCE_TITLE | The title of the instance | `empty string` |
| YTP_FILE_LOGGING | Whether to log to file | `false` |
| YTP_DOWNLOAD_PATH | Path to where the downloads will be saved | `/downloads` |
| YTP_MAX_WORKERS | How many works to use for downloads | `1` |
| YTP_AUTH_USERNAME | Username for basic authentication | `empty string` |
| YTP_AUTH_PASSWORD | Password for basic authentication | `empty string` |
| YTP_CONSOLE_ENABLED | Whether to enable the console | `false` |
| YTP_REMOVE_FILES | Remove the actual file when clicking the remove button | `false` |
| YTP_CONFIG_PATH | Path to where the queue persistence files will be saved | `/config` |
| YTP_TEMP_PATH | Path where intermediary download files will be saved | `/tmp` |
| YTP_TEMP_KEEP | Whether to keep the Individual video temp directory or remove it | `false` |
| YTP_KEEP_ARCHIVE | Keep history of downloaded videos | `true` |
| YTP_YTDL_DEBUG | Whether to turn debug logging for the internal `yt-dlp` package | `false` |
| YTP_ALLOW_MANIFESTLESS | Allow `yt-dlp` to download unprocessed streams | `false` |
| YTP_HOST | Which IP address to bind to | `0.0.0.0` |
| YTP_PORT | Which port to bind to | `8081` |
| YTP_LOG_LEVEL | Log level | `info` |
| YTP_STREAMER_VCODEC | The video codec to use for in-browser streaming | `libx264` |
| YTP_STREAMER_ACODEC | The audio codec to use for in-browser streaming | `aac` |
| YTP_ACCESS_LOG | Whether to log access to the web server | `true` |
| YTP_DEBUG | Whether to turn on debug mode | `false` |
| YTP_DEBUGPY_PORT | The port to use for the debugpy debugger | `5678` |
| YTP_SOCKET_TIMEOUT | The timeout for the yt-dlp socket connection variable | `30` |
| YTP_EXTRACT_INFO_TIMEOUT | The timeout for extracting video information | `70` |
| YTP_DB_FILE | The path to the SQLite database file | `{config_path}/ytptube.db` |
| YTP_MANUAL_ARCHIVE | The path to the manual archive file | `{config_path}/manual_archive.log` |
| YTP_UI_UPDATE_TITLE | Whether to update the title of the page with the current stats | `true` |
| YTP_PIP_PACKAGES | A space separated list of pip packages to install | `empty string` |
| YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` |
| YTP_BASIC_MODE | Whether to run WebUI in basic mode | `false` |
| YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use. | `empty string` |
| YTP_BROWSER_ENABLED | Whether to enable the file browser | `false` |
| YTP_YTDLP_AUTO_UPDATE | Whether to enable the auto update for yt-dlp | `false` |
# Browser extensions & bookmarklets
## Simple bookmarklet
@ -169,21 +128,28 @@ example.com {
# Updating yt-dlp
The engine which powers the actual video downloads in YTPTube is [yt-dlp](https://github.com/yt-dlp/yt-dlp). Since video sites regularly change their layouts, frequent updates of yt-dlp are required to keep up.
The engine which powers the actual video downloads in YTPTube is [yt-dlp](https://github.com/yt-dlp/yt-dlp). Since video
sites regularly change their layouts, frequent updates of yt-dlp are required to keep up.
There's an automatic nightly task YTPTube which looks for a new version of yt-dlp, and if one exists, it will be open PR on the repository. The PR will be reviewed and merged by the maintainer.
We have added `YTP_YTDLP_AUTO_UPDATE` environment, which allows you to automatically update yt-dlp to the latest version.
To get latest version, simply set the environment variable to `true` and restart the container. Now, whenever you restart
the container, it will check for the latest version of yt-dlp and update it if a new version is available.
We will no longer release new versions of YTPTube for every new version of yt-dlp.
# Troubleshooting and submitting issues
Before asking a question or submitting an issue for YTPTube, Please remember that YTPTube is only a UI for [yt-dlp](https://github.com/yt-dlp/yt-dlp). Any issues you might be experiencing with authentication to video websites, postprocessing, permissions, other `yt-dlp options` configurations which seem not to work, or anything else that concerns the workings of the underlying yt-dlp library, need not be opened on the YTPTube project. In order to debug and troubleshoot them, it's advised to try using the yt-dlp binary directly first, bypassing the UI, and once that is working, importing the options that worked for you into `yt-dlp options` file.
Before asking a question or submitting an issue for YTPTube, Please remember that YTPTube is only a thin WebUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp).
Any issues you might be experiencing with authentication to video websites, postprocessing, permissions, other `yt-dlp options`
configurations which seem not to work, or anything else that concerns the workings of the underlying yt-dlp library, need not be opened on the YTPTube project.
In order to test with the yt-dlp command directly, you can either download it and run it locally, or for a better simulation of its actual conditions, you can run it within the YTPTube container itself.
In order to debug and troubleshoot them, it's advised to try using the yt-dlp binary directly first,
bypassing the UI, and once that is working, importing the options that worked for you into a new `preset` or `ytdlp.cli` file.
## Via HTTP
If you have enabled the web terminal via `YTP_CONSOLE_ENABLED` environment variable, simply go to `Terminal` button in
your navbar and directly use the yt-dlp command, the interface is jailed to the `yt-dlp` binary you can't access the
anything else.
your navbar and directly use the yt-dlp command, the interface is jailed to the `yt-dlp` binary you can't access anything else.
## Via CLI
@ -197,35 +163,6 @@ yt-dlp ....
Once there, you can use the yt-dlp command freely.
# Building and running locally
> [!IMPORTANT]
> Make sure you have `nodejs` and `Python 3.11+` installed.
Follow these steps to build and run YTPTube locally:
```bash
cd ytptube/ui
# install Vue and build the UI
npm install
npm run build
# install python dependencies
cd ..
# you might not have venv module installed, if so, install it via `pip3 install venv` or your package manager.
python -m venv .venv
source .venv/bin/activate
pip3 install pipenv
pipenv install
# run
python app/main.py
```
A Docker image can be built locally (it will build the UI too):
```bash
docker build . -t ytptube
```
# ytdlp.cli file
The `config/ytdlp.cli`, is a command line options file for `yt-dlp` it will be globally applied to all downloads.
@ -248,7 +185,6 @@ As this is a simple basic authentication, if your browser doesn't show the promp
`http://username:password@your_ytptube_url:port`
# Basic mode
What does the basic mode do? it hides the the following features from the WebUI.
@ -303,17 +239,6 @@ project and what i wanted to achieve with it.
This project is not affiliated with YouTube, yt-dlp, or any other service. It's a personal project that was created to
make downloading videos from the internet easier. It's not intended to be used for piracy or any other illegal activities.
# Project plan
Let me start by saying, i am primarily PHP developer, and i am not familiar with the best practices of python or frontend design.
There are no project plan, or design document for this project. I started this project as a hobby project, and i am learning
as i go. What you see is the result of me learning while creating this tool. It might not be the best but i like it.
While i value your feedback, remember this project is a hobby project, and i may not have the time to implement all the
features you might want. I am open to PRs, and i will do my best to review them and merge them if they fit my vision for the
project.
# Social contact
If you have short or quick questions, you are free to join my [discord server](https://discord.gg/G3GpVR8xpb) and ask
@ -323,3 +248,45 @@ the question. keep in mind it's solo project, as such it might take me a bit of
If you feel like donating and appreciate my work, you can do so by donating to children charity. For example [Make-A-Wish](https://worldwish.org).
# Environment variables
Certain configuration values can be set via environment variables, using the `-e` parameter on the docker command line, or the `environment:` section in `compose.yaml` file.
| Environment Variable | Description | Default |
| ------------------------ | ---------------------------------------------------------------- | ---------------------------------- |
| YTP_OUTPUT_TEMPLATE | The template for the filenames of the downloaded videos | `%(title)s.%(ext)s` |
| YTP_DEFAULT_PRESET | The default preset to use for the download | `default` |
| YTP_INSTANCE_TITLE | The title of the instance | `empty string` |
| YTP_FILE_LOGGING | Whether to log to file | `false` |
| YTP_DOWNLOAD_PATH | Path to where the downloads will be saved | `/downloads` |
| YTP_MAX_WORKERS | How many works to use for downloads | `1` |
| YTP_AUTH_USERNAME | Username for basic authentication | `empty string` |
| YTP_AUTH_PASSWORD | Password for basic authentication | `empty string` |
| YTP_CONSOLE_ENABLED | Whether to enable the console | `false` |
| YTP_REMOVE_FILES | Remove the actual file when clicking the remove button | `false` |
| YTP_CONFIG_PATH | Path to where the queue persistence files will be saved | `/config` |
| YTP_TEMP_PATH | Path where intermediary download files will be saved | `/tmp` |
| YTP_TEMP_KEEP | Whether to keep the Individual video temp directory or remove it | `false` |
| YTP_KEEP_ARCHIVE | Keep history of downloaded videos | `true` |
| YTP_YTDL_DEBUG | Whether to turn debug logging for the internal `yt-dlp` package | `false` |
| YTP_ALLOW_MANIFESTLESS | Allow `yt-dlp` to download unprocessed streams | `false` |
| YTP_HOST | Which IP address to bind to | `0.0.0.0` |
| YTP_PORT | Which port to bind to | `8081` |
| YTP_LOG_LEVEL | Log level | `info` |
| YTP_STREAMER_VCODEC | The video codec to use for in-browser streaming | `libx264` |
| YTP_STREAMER_ACODEC | The audio codec to use for in-browser streaming | `aac` |
| YTP_ACCESS_LOG | Whether to log access to the web server | `true` |
| YTP_DEBUG | Whether to turn on debug mode | `false` |
| YTP_DEBUGPY_PORT | The port to use for the debugpy debugger | `5678` |
| YTP_SOCKET_TIMEOUT | The timeout for the yt-dlp socket connection variable | `30` |
| YTP_EXTRACT_INFO_TIMEOUT | The timeout for extracting video information | `70` |
| YTP_DB_FILE | The path to the SQLite database file | `{config_path}/ytptube.db` |
| YTP_MANUAL_ARCHIVE | The path to the manual archive file | `{config_path}/manual_archive.log` |
| YTP_UI_UPDATE_TITLE | Whether to update the title of the page with the current stats | `true` |
| YTP_PIP_PACKAGES | A space separated list of pip packages to install | `empty string` |
| YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` |
| YTP_BASIC_MODE | Whether to run WebUI in basic mode | `false` |
| YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use. | `empty string` |
| YTP_BROWSER_ENABLED | Whether to enable the file browser | `false` |
| YTP_YTDLP_AUTO_UPDATE | Whether to enable the auto update for yt-dlp | `false` |

View file

@ -15,6 +15,7 @@ import yt_dlp
from aiohttp import web
from .AsyncPool import AsyncPool
from .conditions import Conditions
from .config import Config
from .DataStore import DataStore
from .Download import Download
@ -227,7 +228,7 @@ class DownloadQueue(metaclass=Singleton):
eventType = entry.get("_type") or "video"
if "playlist" == eventType:
LOG.info("Processing playlist")
LOG.info(f"Processing playlist '{entry.get('id')}: {entry.get('title')}'.")
entries = entry.get("entries", [])
playlistCount = int(entry.get("playlist_count", len(entries)))
results = []
@ -479,6 +480,13 @@ class DownloadQueue(metaclass=Singleton):
if not entry:
return {"status": "error", "msg": "Unable to extract info." + "\n".join(logs)}
if not item.requeued:
condition = Conditions.get_instance().match(info=entry)
if condition is not None:
already.pop()
LOG.info(f"Condition '{condition}' matched for '{item.url}'.")
return await self.add(item=item.new_with(requeued=True, cli=condition.cli),already=already)
end_time = time.perf_counter() - started
LOG.debug(f"extract_info: for 'URL: {item.url}' is done in '{end_time:.3f}'. Length: '{len(entry)}/keys'.")
except yt_dlp.utils.ExistingVideoReached as exc:

View file

@ -128,6 +128,9 @@ class Events:
PRESETS_UPDATE = "presets_update"
SCHEDULE_ADD = "schedule_add"
CONDITIONS_ADD = "conditions_add"
CONDITIONS_UPDATE = "conditions_update"
SUBSCRIBED = "subscribed"
UNSUBSCRIBED = "unsubscribed"

View file

@ -22,6 +22,7 @@ from yt_dlp.cookies import LenientSimpleCookie
from .cache import Cache
from .common import Common
from .conditions import Condition, Conditions
from .config import Config
from .DownloadQueue import DownloadQueue
from .encoder import Encoder
@ -77,6 +78,7 @@ class HttpAPI(Common):
"/notifications",
"/changelog",
"/logs",
"/conditions",
"/browser",
"/browser/{path:.*}",
]
@ -771,6 +773,94 @@ class HttpAPI(Common):
dumps=self.encoder.encode,
)
@route("GET", "api/conditions")
async def conditions(self, _: Request) -> Response:
"""
Get the conditions
Args:
_ (Request): The request object.
Returns:
Response: The response object.
"""
return web.json_response(
data=Conditions.get_instance().get_all(),
status=web.HTTPOk.status_code,
dumps=self.encoder.encode,
)
@route("PUT", "api/conditions")
async def conditions_add(self, request: Request) -> Response:
"""
Save Conditions.
Args:
request (Request): The request object.
Returns:
Response: The response object
"""
data = await request.json()
if not isinstance(data, list):
return web.json_response(
{"error": "Invalid request body expecting list with dicts."},
status=web.HTTPBadRequest.status_code,
)
items: list = []
cls = Conditions.get_instance()
for item in data:
if not isinstance(item, dict):
return web.json_response(
{"error": "Invalid request body expecting list with dicts."},
status=web.HTTPBadRequest.status_code,
)
if not item.get("name"):
return web.json_response(
{"error": "name is required.", "data": item}, status=web.HTTPBadRequest.status_code
)
if not item.get("filter"):
return web.json_response(
{"error": "filter is required.", "data": item}, status=web.HTTPBadRequest.status_code
)
if not item.get("cli"):
return web.json_response(
{"error": "CLI arguments is required.", "data": item}, status=web.HTTPBadRequest.status_code
)
if not item.get("id", None) or not validate_uuid(item.get("id"), version=4):
item["id"] = str(uuid.uuid4())
try:
cls.validate(item)
except ValueError as e:
return web.json_response(
{"error": f"Failed to validate condition '{item.get('name')}'. '{e!s}'"},
status=web.HTTPBadRequest.status_code,
)
items.append(Condition(**item))
try:
items = cls.save(items=items).load().get_all()
except Exception as e:
LOG.exception(e)
return web.json_response(
{"error": "Failed to save conditions.", "message": str(e)},
status=web.HTTPInternalServerError.status_code,
)
await self._notify.emit(Events.CONDITIONS_UPDATE, data=items)
return web.json_response(data=items, status=web.HTTPOk.status_code, dumps=self.encoder.encode)
@route("GET", "api/presets")
async def presets(self, request: Request) -> Response:
"""
@ -828,11 +918,6 @@ class HttpAPI(Common):
{"error": "name is required.", "data": item}, status=web.HTTPBadRequest.status_code
)
if not item.get("format"):
return web.json_response(
{"error": "format is required.", "data": item}, status=web.HTTPBadRequest.status_code
)
if not item.get("id", None) or not validate_uuid(item.get("id"), version=4):
item["id"] = str(uuid.uuid4())
@ -846,7 +931,7 @@ class HttpAPI(Common):
presets.append(Preset(**item))
try:
presets = cls.save(presets=presets).load().get_all()
presets = cls.save(items=presets).load().get_all()
except Exception as e:
LOG.exception(e)
return web.json_response(
@ -1638,7 +1723,7 @@ class HttpAPI(Common):
LOG.error(str(e))
return web.json_response(data={"message": str(e)}, status=web.HTTPInternalServerError.status_code)
url = "https://www.youtube.com/account"
url = "https://www.youtube.com/paid_memberships"
try:
opts = {
@ -1655,8 +1740,8 @@ class HttpAPI(Common):
LOG.debug(f"Checking '{url}' redirection.")
response = await client.request(method="GET", url=url, follow_redirects=False)
return web.json_response(
data={"message": "Authenticated." if response.status_code == 200 else "Not authenticated."},
status=200 if response.status_code == 200 else 401,
data={"message": "Authenticated." if 200 == response.status_code else "Not authenticated."},
status=200 if 200 == response.status_code else 401,
)
except Exception as e:
LOG.error(f"Failed to request '{url}'. '{e}'.")

View file

@ -373,15 +373,16 @@ class HttpSocket(Common):
pass
@ws_event
async def disconnect(self, sid: str):
async def disconnect(self, sid: str, reason: str):
"""
Handle client disconnection.
Args:
sid (str): The session ID of the client.
reason (str): The reason for disconnection.
"""
LOG.debug(f"Client '{sid}' disconnected.")
LOG.debug(f"Client '{sid}' disconnected. {reason}")
for event in self.subscribers:
if sid in self.subscribers[event]:

View file

@ -36,6 +36,9 @@ class Item:
extras: dict = field(default_factory=dict)
"""Extra data to be added to the download."""
requeued: bool = False
"""If the item has been re-queued already via conditions."""
def serialize(self) -> dict:
return self.__dict__.copy()
@ -155,6 +158,9 @@ class Item:
if extras and isinstance(extras, dict) and len(extras) > 0:
data["extras"] = extras
if item.get("requeued") and isinstance(item.get("requeued"), bool):
data["requeued"] = item.get("requeued")
cli = item.get("cli")
if cli and len(cli) > 2:
from .Utils import arg_converter

View file

@ -24,9 +24,6 @@ class Preset:
name: str
"""The name of the preset."""
format: str
"""The format of the preset."""
folder: str = ""
"""The default download folder to use if non is given."""
@ -56,13 +53,13 @@ class Presets(metaclass=Singleton):
This class is used to manage the presets.
"""
_presets: list[Preset] = []
_items: list[Preset] = []
"""The list of presets."""
_instance = None
"""The instance of the class."""
_default_presets: list[Preset] = []
_default: list[Preset] = []
def __init__(self, file: str | None = None, config: Config | None = None):
Presets._instance = self
@ -77,13 +74,14 @@ class Presets(metaclass=Singleton):
except Exception:
pass
with open(os.path.join(os.path.dirname(__file__), "presets.json")) as f:
default_file = os.path.join(os.path.dirname(__file__), "presets.json")
with open(default_file) as f:
for i, preset in enumerate(json.load(f)):
try:
self.validate(preset)
self._default_presets.append(Preset(**preset))
self._default.append(Preset(**preset))
except Exception as e:
LOG.error(f"Failed to parse default preset '{i}'. '{e!s}'.")
LOG.error(f"Failed to parse '{default_file}:{i}'. '{e!s}'.")
continue
def event_handler(_, __):
@ -111,7 +109,7 @@ class Presets(metaclass=Singleton):
def attach(self, _: web.Application):
"""
Attach the work to the aiohttp application.
Attach the class to the aiohttp application.
Args:
_ (web.Application): The aiohttp application.
@ -123,12 +121,12 @@ class Presets(metaclass=Singleton):
self.load()
def get_all(self) -> list[Preset]:
"""Return the presets."""
return self._default_presets + self._presets
"""Return the items."""
return self._default + self._items
def load(self) -> "Presets":
"""
Load the Presets.
Load the items.
Returns:
Presets: The current instance.
@ -139,16 +137,15 @@ class Presets(metaclass=Singleton):
if not os.path.exists(self._file) or os.path.getsize(self._file) < 10:
return self
LOG.info(f"Loading presets from '{self._file}'.")
LOG.info(f"Loading '{self._file}'.")
try:
with open(self._file) as f:
presets = json.load(f)
except Exception as e:
LOG.error(f"Failed to parse presets from '{self._file}'. '{e}'.")
LOG.error(f"Failed to parse '{self._file}'. '{e}'.")
return self
if not presets or len(presets) < 1:
LOG.info(f"No presets were defined in '{self._file}'.")
return self
need_save = False
@ -160,134 +157,144 @@ class Presets(metaclass=Singleton):
need_save = True
preset, preset_status = clean_item(preset, keys=("args", "postprocessors"))
if preset.get("format"):
if not preset.get("cli"):
preset.update({"cli": f"--format {preset['format']}"})
else:
preset["cli"] = f"--format '{preset['format']}'\n" + preset["cli"]
preset["cli"] = str(preset["cli"]).strip()
preset.pop("format")
need_save = True
preset = Preset(**preset)
if preset_status:
need_save = True
self._presets.append(preset)
self._items.append(preset)
except Exception as e:
LOG.error(f"Failed to parse preset at list position '{i}'. '{e!s}'.")
LOG.error(f"Failed to parse '{self._file}:{i}'. '{e!s}'.")
continue
if need_save:
LOG.info("Saving presets due to format, or id change.")
self.save(self._presets)
LOG.info(f"Saving '{self._file}' due to changes.")
self.save(self._items)
return self
def clear(self) -> "Presets":
"""
Clear all presets
Clear all items.
Returns:
Presets: The current instance.
"""
if len(self._presets) < 1:
if len(self._items) < 1:
return self
self._presets.clear()
self._items.clear()
return self
def validate(self, preset: Preset | dict) -> bool:
def validate(self, item: Preset | dict) -> bool:
"""
Validate the preset.
Validate the item.
Args:
preset (Preset|dict): The preset to validate.
item (Preset|dict): The item to validate.
Returns:
bool: True if the preset is valid, False otherwise.
bool: True if valid
Raises:
ValueError: If the item is not valid.
"""
if not isinstance(preset, dict):
if not isinstance(preset, Preset):
msg = f"Invalid preset type. Was expecting a (Preset|dict), but got '{type(preset).__name__}'."
if not isinstance(item, dict):
if not isinstance(item, Preset):
msg = f"Unexpected '{type(item).__name__}' type was given."
raise ValueError(msg) # noqa: TRY004
preset = preset.serialize()
item = item.serialize()
if not preset.get("id"):
if not item.get("id"):
msg = "No id found."
raise ValueError(msg)
if not preset.get("name"):
if not item.get("name"):
msg = "No name found."
raise ValueError(msg)
if not preset.get("format"):
msg = "No format found."
raise ValueError(msg)
if preset.get("cli"):
if item.get("cli"):
try:
arg_converter(args=preset.get("cli"))
arg_converter(args=item.get("cli"))
except Exception as e:
msg = f"Invalid cli options. '{e!s}'."
raise ValueError(msg) from e
return True
def save(self, presets: list[Preset | dict]) -> "Presets":
def save(self, items: list[Preset | dict]) -> "Presets":
"""
Save the presets.
Save the items.
Args:
presets (list[Preset]): The presets to save.
items (list[Preset]): The items to save.
Returns:
Presets: The current instance.
"""
for i, preset in enumerate(presets):
for i, preset in enumerate(items):
try:
if not isinstance(preset, Preset):
preset = Preset(**preset)
presets[i] = preset
items[i] = preset
except Exception as e:
LOG.error(f"Failed to save preset '{i}' due to parsing error. '{e!s}'.")
LOG.error(f"Failed to save item '{i}' due to parsing error. '{e!s}'.")
continue
try:
self.validate(preset)
except ValueError as e:
LOG.error(f"Failed to validate preset '{i}: {preset.name}'. '{e}'.")
LOG.error(f"Failed to validate item '{i}: {preset.name}'. '{e}'.")
continue
try:
with open(self._file, "w") as f:
json.dump(obj=[preset.serialize() for preset in presets if preset.default is False], fp=f, indent=4)
json.dump(obj=[preset.serialize() for preset in items if preset.default is False], fp=f, indent=4)
LOG.info(f"Presets saved to '{self._file}'.")
LOG.info(f"Saved '{self._file}'.")
except Exception as e:
LOG.error(f"Failed to save presets to '{self._file}'. '{e!s}'.")
LOG.error(f"Failed to save '{self._file}'. '{e!s}'.")
return self
def has(self, id_or_name: str) -> bool:
"""
Check if the preset exists by id or name.
Check if the item exists by id or name.
Args:
id_or_name (str): The id or name of the preset.
id_or_name (str): The id or name of the item.
Returns:
bool: True if the preset exists, False otherwise.
bool: True if exists, False otherwise.
"""
return self.get(id_or_name) is not None
def get(self, id_or_name: str) -> Preset | None:
"""
Get the preset by id or name.
Get the item by id or name.
Args:
id_or_name (str): The id or name of the preset.
id_or_name (str): The id or name of the item.
Returns:
Preset|None: The preset if found, None otherwise.
Preset|None: The item if found, None otherwise.
"""
if not id_or_name:

View file

@ -135,9 +135,6 @@ class YTDLPOpts(metaclass=Singleton):
self._preset_opts["cookiefile"] = str(file)
if preset.format:
self._preset_opts["format"] = preset.format
if preset.template:
self._preset_opts["outtmpl"] = {"default": preset.template, "chapter": self._config.output_template_chapter}

319
app/library/conditions.py Normal file
View file

@ -0,0 +1,319 @@
import json
import logging
import os
import uuid
from dataclasses import dataclass, field
from typing import Any
from aiohttp import web
from .config import Config
from .encoder import Encoder
from .Events import EventBus, Events
from .Singleton import Singleton
from .Utils import arg_converter
LOG = logging.getLogger("presets")
@dataclass(kw_only=True)
class Condition:
id: str = field(default_factory=lambda: str(uuid.uuid4()))
"""The id of the condition."""
name: str
"""The name of the condition."""
filter: str
"""The filter to run on info dict."""
cli: str
"""If matched append this to the download request and re-queue."""
def serialize(self) -> dict:
return self.__dict__
def json(self) -> str:
return Encoder().encode(self.serialize())
def get(self, key: str, default: Any = None) -> Any:
return self.serialize().get(key, default)
class Conditions(metaclass=Singleton):
"""
This class is used to manage the download conditions.
"""
_items: list[Condition] = []
"""The list of items."""
_instance = None
"""The instance of the class."""
def __init__(self, file: str | None = None, config: Config | None = None):
Conditions._instance = self
config = config or Config.get_instance()
self._file: str = file or os.path.join(config.config_path, "conditions.json")
if os.path.exists(self._file) and "600" != oct(os.stat(self._file).st_mode)[-3:]:
try:
os.chmod(self._file, 0o600)
except Exception:
pass
def event_handler(_, __):
msg = "Not implemented"
raise Exception(msg)
EventBus.get_instance().subscribe(Events.CONDITIONS_ADD, event_handler, f"{__class__.__name__}.add")
@staticmethod
def get_instance() -> "Conditions":
"""
Get the instance of the class.
Returns:
Conditions: The instance of the class
"""
if not Conditions._instance:
Conditions._instance = Conditions()
return Conditions._instance
async def on_shutdown(self, _: web.Application):
pass
def attach(self, _: web.Application):
"""
Attach the work to the aiohttp application.
Args:
_ (web.Application): The aiohttp application.
Returns:
None
"""
self.load()
def get_all(self) -> list[Condition]:
"""Return the items."""
return self._items
def load(self) -> "Conditions":
"""
Load the items.
Returns:
Conditions: The current instance.
"""
self.clear()
if not os.path.exists(self._file) or os.path.getsize(self._file) < 10:
return self
LOG.info(f"Loading '{self._file}'.")
try:
with open(self._file) as f:
items = json.load(f)
except Exception as e:
LOG.error(f"Failed to parse '{self._file}'. '{e}'.")
return self
if not items or len(items) < 1:
LOG.debug(f"No items were found in '{self._file}'.")
return self
need_save = False
for i, item in enumerate(items):
try:
if "id" not in item:
item["id"] = str(uuid.uuid4())
need_save = True
item = Condition(**item)
self._items.append(item)
except Exception as e:
LOG.error(f"Failed to parse failure condition at list position '{i}'. '{e!s}'.")
continue
if need_save:
LOG.info("Saving failure conditions due to format, or id change.")
self.save(self._items)
return self
def clear(self) -> "Conditions":
"""
Clear all items
Returns:
conditions: The current instance.
"""
if len(self._items) < 1:
return self
self._items.clear()
return self
def validate(self, item: Condition | dict) -> bool:
"""
Validate item.
Args:
item (Condition|dict): The item to validate.
Returns:
bool: True if valid, False otherwise.
"""
from yt_dlp.utils import match_str
if not isinstance(item, dict):
if not isinstance(item, Condition):
msg = f"Unexpected '{type(item).__name__}' item type."
raise ValueError(msg) # noqa: TRY004
item = item.serialize()
if not item.get("id"):
msg = "No id found."
raise ValueError(msg)
if not item.get("name"):
msg = "No name found."
raise ValueError(msg)
if not item.get("filter"):
msg = "No filter found."
raise ValueError(msg)
try:
match_str(item.get("filter"), {})
except Exception as e:
msg = f"Invalid filter. '{e!s}'."
raise ValueError(msg) from e
if not item.get("cli"):
msg = "No CLI arguments were found."
raise ValueError(msg)
try:
arg_converter(args=item.get("cli"))
except Exception as e:
msg = f"Invalid cli options. '{e!s}'."
raise ValueError(msg) from e
return True
def save(self, items: list[Condition | dict]) -> "Conditions":
"""
Save the items.
Args:
items (list[Condition]): The items to save.
Returns:
Conditions: The current instance.
"""
for i, item in enumerate(items):
try:
if not isinstance(item, Condition):
item = Condition(**item)
items[i] = item
except Exception as e:
LOG.error(f"Failed to save '{i}' due to parsing error. '{e!s}'.")
continue
try:
self.validate(item)
except ValueError as e:
LOG.error(f"Failed to validate '{i}: {item.name}'. '{e!s}'.")
continue
try:
with open(self._file, "w") as f:
json.dump(obj=[item.serialize() for item in items], fp=f, indent=4)
LOG.info(f"Updated '{self._file}'.")
except Exception as e:
LOG.error(f"Failed to save '{self._file}'. '{e!s}'.")
return self
def has(self, id_or_name: str) -> bool:
"""
Check if the item exists by id or name.
Args:
id_or_name (str): The id or name of the preset.
Returns:
bool: True if the item exists, False otherwise.
"""
return self.get(id_or_name) is not None
def get(self, id_or_name: str) -> Condition | None:
"""
Get the item by id or name.
Args:
id_or_name (str): The id or name of the preset.
Returns:
Condition|None: The item if found, None otherwise.
"""
if not id_or_name:
return None
for condition in self.get_all():
if id_or_name not in (condition.id, condition.name):
continue
return condition
return None
def match(self, info: dict) -> Condition | None:
"""
Check if any condition matches the info dict.
Args:
info (dict): The info dict to check.
Returns:
Condition|None: The condition if found, None otherwise.
"""
if len(self._items) < 1 or not info or not isinstance(info, dict) or len(info) < 1:
return None
from yt_dlp.utils import match_str
for item in self.get_all():
if not item.filter:
LOG.error(f"Filter is empty for '{item.name}'.")
continue
try:
if match_str(item.filter, info):
LOG.debug(f"Matched '{item.name}' with filter '{item.filter}'.")
return item
except Exception as e:
LOG.error(f"Failed to evaluate '{item.name}'. '{e!s}'.")
continue
return None

View file

@ -2,7 +2,6 @@
{
"id": "3e163c6c-64eb-4448-924f-814b629b3810",
"name": "default",
"format": "default",
"folder": "",
"template": "",
"cookies": "",
@ -12,41 +11,37 @@
{
"id": "5bf9c42b-8852-468a-99f5-915622dfba25",
"name": "Best video and audio",
"format": "bv+ba/b",
"folder": "",
"template": "",
"cookies": "",
"cli": "",
"cli": "--format 'bv+ba/b'",
"default": true
},
{
"id": "441675ed-b739-40f0-a0b0-1ecfcb9dc48b",
"name": "1080p H264/m4a or best available",
"format": "bv[height<=1080][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]",
"folder": "",
"template": "",
"cookies": "",
"cli": "-S vcodec:h264",
"cli": "-S vcodec:h264 --format 'bv[height<=1080][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'",
"default": true
},
{
"id": "9719fcc3-4cf2-4d88-b1e4-74dff3dba00e",
"name": "720p h264/m4a or best available",
"format": "bv[height<=720][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]",
"folder": "",
"template": "",
"cookies": "",
"cli": "-S vcodec:h264",
"cli": "-S vcodec:h264 --format 'bv[height<=720][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'",
"default": true
},
{
"id": "a6fd4b25-2b3e-458d-bb57-b75e41cc4330",
"name": "Audio only",
"format": "bestaudio/best",
"folder": "",
"template": "",
"cookies": "",
"cli": "--extract-audio --add-chapters --embed-metadata --embed-thumbnail",
"cli": "--extract-audio --add-chapters --embed-metadata --embed-thumbnail --format 'bestaudio/best'",
"default": true
}
]

View file

@ -9,6 +9,7 @@ from pathlib import Path
import caribou
import magic
from aiohttp import web
from library.conditions import Conditions
from library.config import Config
from library.DownloadQueue import DownloadQueue
from library.Events import EventBus, Events
@ -167,6 +168,7 @@ class Main:
Tasks.get_instance().attach(self._app)
Presets.get_instance().attach(self._app)
Notification.get_instance().attach(self._app)
Conditions.get_instance().attach(self._app)
EventBus.get_instance().sync_emit(Events.LOADED, data={"app": self._app})

View file

@ -313,3 +313,7 @@ hr {
.is-justify-self-end {
justify-self: end;
}
.is-full-height {
height: 100%;
}

View file

@ -0,0 +1,245 @@
<template>
<main class="columns mt-2 is-multiline">
<div class="column is-12">
<form autocomplete="off" id="addForm" @submit.prevent="checkInfo()">
<div class="card is-flex is-full-height is-flex-direction-column">
<div class="card-header">
<div class="card-header-title is-text-overflow is-block">
<span class="icon-text">
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span>
<span>{{ reference ? 'Edit' : 'Add' }}</span>
</span>
</div>
<div class="card-header-icon" v-if="reference">
<button type="button" @click="showImport = !showImport">
<span class="icon"><i class="fa-solid" :class="{
'fa-arrow-down': !showImport,
'fa-arrow-up': showImport,
}" /></span>
<span>{{ showImport ? 'Hide' : 'Show' }} import</span>
</button>
</div>
</div>
<div class="card-content is-flex-grow-1">
<div class="columns is-multiline is-mobile">
<div class="column is-12" v-if="showImport || !reference">
<label class="label is-inline" for="import_string">
<span class="icon"><i class="fa-solid fa-file-import" /></span>
Import string
</label>
<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>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>You can use this field to populate the data, using shared string.</span>
</span>
</div>
<div class="column is-12">
<div class="field">
<label class="label is-inline" for="name">
<span class="icon"><i class="fa-solid fa-tag" /></span>
Name
</label>
<div class="control">
<input type="text" class="input" id="name" v-model="form.name" :disabled="addInProgress"
placeholder="For the problematic channel or video name.">
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The name that refers to this condition.</span>
</span>
</div>
</div>
<div class="column is-12">
<div class="field">
<label class="label is-inline" for="filter">
<span class="icon"><i class="fa-solid fa-filter" /></span>
Filter
</label>
<div class="control">
<input type="text" class="input" id="filter" v-model="form.filter" :disabled="addInProgress"
placeholder="availability = 'needs_auth' & channel_id = 'channel_id'">
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The yt-dlp <code>[--match-filters]</code> filter logic.</span>
</span>
</div>
</div>
<div class="column is-12">
<div class="field">
<label class="label is-inline" for="cli_options">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
Command arguments for yt-dlp
</label>
<div class="control">
<textarea class="textarea is-pre" v-model="form.cli" id="cli_options" :disabled="addInProgress"
placeholder="command options to use, e.g. --proxy 1.2.3.4:3128" />
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<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
recommended to use presets and keep the cli with url empty if you plan to use this
feature.</span>
</span>
</span>
</div>
</div>
</div>
</div>
<div class="card-footer mt-auto">
<div class="card-footer-item">
<button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit"
:class="{ 'is-loading': addInProgress }" form="addForm">
<span class="icon"><i class="fa-solid fa-save" /></span>
<span>Save</span>
</button>
</div>
<div class="card-footer-item">
<button class="button is-fullwidth is-danger" @click="emitter('cancel')" :disabled="addInProgress"
type="button">
<span class="icon"><i class="fa-solid fa-times" /></span>
<span>Cancel</span>
</button>
</div>
</div>
</div>
</form>
</div>
</main>
</template>
<script setup>
import { useStorage } from '@vueuse/core'
const emitter = defineEmits(['cancel', 'submit']);
const props = defineProps({
reference: {
type: String,
required: false,
default: null,
},
item: {
type: Object,
required: true,
},
addInProgress: {
type: Boolean,
required: false,
default: false,
},
})
const toast = useToast()
const form = reactive(JSON.parse(JSON.stringify(props.item)))
const import_string = ref('')
const showImport = useStorage('showImport', false);
const checkInfo = async () => {
const required = ['name', 'filter', 'cli'];
for (const key of required) {
if (!form[key]) {
toast.error(`The ${key} field is required.`);
return
}
}
if (form?.cli && '' !== form.cli) {
const options = await convertOptions(form.cli);
if (null === options) {
return
}
form.cli = form.cli.trim()
}
let copy = JSON.parse(JSON.stringify(form));
for (const key in copy) {
if (typeof copy[key] !== 'string') {
continue
}
copy[key] = copy[key].trim()
}
emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) });
}
const convertOptions = async args => {
try {
const response = await convertCliOptions(args)
return response.opts
} catch (e) {
toast.error(e.message)
}
return null;
}
const importItem = async () => {
let val = import_string.value.trim()
if (!val) {
toast.error('The import string is required.')
return
}
if (false === val.startsWith('{')) {
try {
val = base64UrlDecode(val)
} catch (e) {
console.error(e)
toast.error(`Failed to decode string. ${e.message}`)
return
}
}
try {
const item = JSON.parse(val)
if (!item?._type || 'condition' !== item._type) {
toast.error(`Invalid import string. Expected type 'condition', got '${item._type ?? 'unknown'}'.`)
return
}
if ((form.filter || form.cli) && false === confirm('This will overwrite the current data. Are you sure?')) {
return
}
if (item.name) {
form.name = item.name
}
if (item.filter) {
form.filter = item.filter
}
if (item.cli) {
form.cli = item.cli
}
import_string.value = ''
showImport.value = false
} catch (e) {
console.error(e)
toast.error(`Failed to parse import string. ${e.message}`)
}
}
</script>

View file

@ -1,14 +1,18 @@
<template>
<vTooltip @show="loadContent" @hide="stopTimer">
<slot />
<template #popper>
<span class="icon" v-if="!url"><i class="fas fa-circle-notch fa-spin"></i></span>
<template #popper class="p-0 m-0">
<span class="icon" v-if="!url"><i class="fas fa-circle-notch fa-spin" /></span>
<template v-else>
<div>
<h1 v-if="props.title" class="is-4">{{ props.title }}</h1>
<img :src="url" style="width:720px; height: auto" class="card-image" :alt="props.title" @error="clearCache"
:crossorigin="props.privacy ? 'anonymous' : 'use-credentials'"
:referrerpolicy="props.privacy ? 'no-referrer' : 'origin'" />
<div style="width: 25vw; height: auto;" class="m-1">
<div class="is-block" style="word-break: all;" v-if="props.title">
<span style="font-size: 120%;">{{ props.title }}</span>
</div>
<figure class="image is-3by1">
<img @load="e => pImg(e)" :src="url" :alt="props.title" @error="clearCache"
:crossorigin="props.privacy ? 'anonymous' : 'use-credentials'"
:referrerpolicy="props.privacy ? 'no-referrer' : 'origin'" />
</figure>
</div>
</template>
</template>
@ -54,9 +58,7 @@ const defaultLoader = async () => {
return
}
const response = await request('/api/thumbnail?url=' + encodePath(props.image), {
signal: cancelRequest.signal,
})
const response = await request(props.image, { signal: cancelRequest.signal })
if (200 !== response.status) {
toast.error(`ImageView Request error. ${response.status}: ${response.statusText}`)
@ -110,4 +112,6 @@ const clearCache = async () => {
url.value = '';
return loadContent()
}
const pImg = e => e.target.naturalHeight > e.target.naturalWidth ? e.target.classList.add('image-portrait') : null
</script>

View file

@ -107,9 +107,19 @@
</label>
</td>
<td class="is-vcentered">
<div class="is-text-overflow" v-tooltip="item.title">
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
<div v-if="item.extras.thumbnail">
<FloatingImage :image="'/api/thumbnail?url=' + encodePath(item.extras.thumbnail)"
:title="item.title">
<div class="is-text-overflow">
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
</div>
</FloatingImage>
</div>
<template v-else>
<div class="is-text-overflow" v-tooltip="item.title">
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
</div>
</template>
<div v-if="item.error" class="is-text-overflow is-pointer" @click="toggle_class($event)">
<span class="has-text-danger">{{ item.error }}</span>
</div>
@ -190,7 +200,7 @@
<div class="columns is-multiline" v-else>
<LateLoader :unrender="true" :min-height="hideThumbnail ? 210 : 410" class="column is-6"
v-for="item in sortCompleted" :key="item._id">
<div class="card"
<div class="card is-flex is-full-height is-flex-direction-column"
:class="{ 'is-bordered-danger': item.status === 'error', 'is-bordered-info': item.live_in || item.is_live }">
<header class="card-header has-tooltip">
@ -239,7 +249,7 @@
</template>
</figure>
</div>
<div class="card-content">
<div class="card-content is-flex-grow-1">
<div class="columns is-mobile is-multiline">
<div class="column is-12" v-if="item.error">
<div class="is-text-overflow is-pointer" @click="toggle_class($event)">

View file

@ -49,7 +49,7 @@
</span>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="column is-12">
<div class="field">
<label class="label is-inline" for="name">
<span class="icon"><i class="fa-solid fa-tag" /></span>
@ -65,26 +65,7 @@
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="format">
<span class="icon"><i class="fa-solid fa-f" /></span>
Format
</label>
<div class="control">
<input type="text" class="input" id="format" v-model="form.format" :disabled="addInProgress">
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The yt-dlp <code>[--format, -f]</code> video format code. see <NuxtLink
href="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#format-selection" target="blank">this
page</NuxtLink> for more info. Note, as this key is required, you can set the value to
<code>default</code> to let <code>yt-dlp</code> choose the best format.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="column is-12">
<div class="field">
<label class="label is-inline" for="folder">
<span class="icon"><i class="fa-solid fa-folder" /></span>
@ -102,7 +83,7 @@
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="column is-12">
<div class="field">
<label class="label is-inline" for="output_template">
<span class="icon"><i class="fa-solid fa-file" /></span>
@ -127,7 +108,7 @@
<div class="field">
<label class="label is-inline" for="cli_options">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
Command arguments for yt-dlp
Command options for yt-dlp
</label>
<div class="control">
<textarea class="textarea is-pre" v-model="form.cli" id="cli_options" :disabled="addInProgress"
@ -241,8 +222,7 @@ onMounted(() => {
})
const checkInfo = async () => {
const required = ['name', 'format'];
for (const key of required) {
for (const key of ['name']) {
if (!form[key]) {
toast.error(`The ${key} field is required.`);
return
@ -299,10 +279,6 @@ const convertOptions = async args => {
form.folder = response.download_path
}
if (response.format) {
form.format = response.format
}
return response.opts
} catch (e) {
toast.error(e.message)
@ -336,7 +312,7 @@ const importItem = async () => {
return
}
if ((form.format || form.cli) && false === confirm('This will overwrite the current data. Are you sure?')) {
if (form.cli && false === confirm('This will overwrite the current data. Are you sure?')) {
return
}
@ -344,14 +320,20 @@ const importItem = async () => {
form.name = item.name
}
if (item.format) {
form.format = item.format
}
if (item.cli) {
form.cli = item.cli
}
// -- backwards compatibility for old presets.
if (item.format) {
if (!item?.cli) {
form.cli = `--format '${item.format}'`
} else {
form.cli = `--format '${item.format}'\n${form.cli}`
}
form.cli = form.cli.trim()
}
if (item.template) {
form.template = item.template
}
@ -364,7 +346,7 @@ const importItem = async () => {
showImport.value = false
} catch (e) {
console.error(e)
toast.error(`Failed to string. ${e.message}`)
toast.error(`Failed to parse string. ${e.message}`)
}
}

View file

@ -118,7 +118,7 @@
<div class="columns is-multiline" v-else>
<LateLoader :unrender="true" :min-height="hideThumbnail ? 265 : 475" class="column is-6"
v-for="item in stateStore.queue" :key="item._id">
<div class="card">
<div class="card is-flex is-full-height is-flex-direction-column">
<header class="card-header">
<div class="card-header-title is-text-overflow is-block" v-tooltip="item.title">
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
@ -148,7 +148,7 @@
</template>
</figure>
</div>
<div class="card-content">
<div class="card-content is-flex-grow-1">
<div class="columns is-multiline is-mobile">
<div class="column is-12">
<div class="progress-bar is-unselectable">

View file

@ -1,95 +1,103 @@
<template>
<div id="main_container" class="container">
<nav class="navbar is-mobile is-dark">
<div class="navbar-brand pl-5 is-hidden-mobile">
<NuxtLink class="navbar-item has-tooltip-bottom" to="/"
<div class="navbar-brand pl-5">
<NuxtLink class="navbar-item is-text-overflow" to="/" @click.native="e => changeRoute(e)"
v-tooltip="socket.isConnected ? 'Connected' : 'Connecting'">
<span>
<span class="is-text-overflow">
<span class="icon"><i class="fas fa-home" /></span>
<span class="has-text-bold" :class="`has-text-${socket.isConnected ? 'success' : 'danger'}`">
YTPTube
</span>
<span v-if="config?.app?.instance_title">: {{ config.app.instance_title }}</span>
<span class="has-text-bold" v-if="config?.app?.instance_title">: {{ config.app.instance_title }}</span>
</span>
</NuxtLink>
<button class="navbar-burger burger" @click="showMenu = !showMenu">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</button>
</div>
<div class="navbar-end is-flex" style="flex-flow:wrap">
<div class="navbar-item is-hidden-tablet">
<NuxtLink class="button is-dark has-tooltip-bottom" to="/">
<span :class="socket.isConnected ? 'has-text-success' : 'has-text-danger'" class="icon">
<img src="/favicon.ico" /></span>
<div class="navbar-menu is-unselectable" :class="{ 'is-active': showMenu }">
<div class="navbar-start" v-if="!config.app.basic_mode">
<NuxtLink class="navbar-item" to="/browser" @click.native="e => changeRoute(e)"
v-if="config.app.browser_enabled">
<span class="icon"><i class="fa-solid fa-folder-tree" /></span>
<span>Files</span>
</NuxtLink>
</div>
<div class="navbar-item" v-if="!config.app.basic_mode && config.app.browser_enabled">
<NuxtLink class="button is-dark has-tooltip-bottom" to="/browser">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-folder-tree" /></span>
<span class="is-hidden-mobile">Files</span>
</span>
<NuxtLink class="navbar-item" to="/presets" @click.native="e => changeRoute(e)">
<span class="icon"><i class="fa-solid fa-sliders" /></span>
<span>Presets</span>
</NuxtLink>
</div>
<div class="navbar-item" v-if="!config.app.basic_mode">
<NuxtLink class="button is-dark has-tooltip-bottom" to="/presets">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-sliders" /></span>
<span class="is-hidden-mobile">Presets</span>
</span>
<NuxtLink class="navbar-item" to="/tasks" @click.native="e => changeRoute(e)">
<span class="icon"><i class="fa-solid fa-tasks" /></span>
<span>Tasks</span>
</NuxtLink>
</div>
<div class="navbar-item" v-if="!config.app.basic_mode">
<NuxtLink class="button is-dark has-tooltip-bottom" to="/tasks">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-tasks" /></span>
<span class="is-hidden-mobile">Tasks</span>
</span>
</NuxtLink>
</div>
<div class="navbar-item" v-if="!config.app.basic_mode">
<NuxtLink class="button is-dark has-tooltip-bottom" to="/notifications">
<NuxtLink class="navbar-item" to="/notifications" @click.native="e => changeRoute(e)">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-paper-plane" /></span>
<span class="is-hidden-mobile">Notifications</span>
<span>Notifications</span>
</span>
</NuxtLink>
</div>
<div class="navbar-item" v-if="!config.app.basic_mode && config.app.console_enabled">
<NuxtLink class="button is-dark has-tooltip-bottom" to="/console">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
<span class="is-hidden-mobile">Terminal</span>
</span>
<NuxtLink class="navbar-item" to="/conditions" @click.native="e => changeRoute(e)">
<span class="icon"><i class="fa-solid fa-filter" /></span>
<span>Conditions</span>
</NuxtLink>
</div>
<div class="navbar-item" v-if="!config.app.basic_mode && config.app.file_logging">
<NuxtLink class="button is-dark has-tooltip-bottom" to="/logs">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-file-lines" /></span>
<span class="is-hidden-mobile">Logs</span>
</span>
</NuxtLink>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown" v-if="!config.app.basic_mode">
<a class="navbar-link" @click="e => openMenu(e)">
<span class="icon"><i class="fas fa-tools" /></span>
<span>Other</span>
</a>
<div class="navbar-item is-hidden-mobile">
<button class="button is-dark" @click="reloadPage">
<span class="icon"><i class="fas fa-refresh"></i></span>
</button>
<div class="navbar-dropdown">
<NuxtLink class="navbar-item" to="/console" @click.native="e => changeRoute(e)"
v-if="config.app.console_enabled">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>Terminal</span>
</NuxtLink>
<NuxtLink class="navbar-item" to="/logs" @click.native="e => changeRoute(e)"
v-if="config.app.file_logging">
<span class="icon"><i class="fa-solid fa-file-lines" /></span>
<span>Logs</span>
</NuxtLink>
</div>
</div>
<div class="navbar-item is-hidden-mobile">
<button class="button is-dark" @click="reloadPage">
<span class="icon"><i class="fas fa-refresh" /></span>
</button>
</div>
<div class="navbar-item is-hidden-tablet">
<button class="button is-dark" @click="reloadPage">
<span class="icon"><i class="fas fa-refresh" /></span>
<span>Reload</span>
</button>
</div>
<div class="navbar-item is-hidden-mobile">
<button class="button is-dark has-tooltip-bottom" v-tooltip.bottom="'WebUI Settings'"
@click="show_settings = !show_settings">
<span class="icon"><i class="fas fa-cog" /></span>
</button>
</div>
<div class="navbar-item is-hidden-tablet">
<button class="button is-dark" @click="show_settings = !show_settings">
<span class="icon"><i class="fas fa-cog" /></span>
<span>WebUI Settings</span>
</button>
</div>
</div>
<div class="navbar-item">
<button class="button is-dark has-tooltip-bottom" v-tooltip.bottom="'WebUI Settings'"
@click="show_settings = !show_settings">
<span class="icon"><i class="fas fa-cog" /></span>
</button>
</div>
</div>
</nav>
@ -137,6 +145,7 @@ const show_settings = ref(false)
const loadingImage = ref(false)
const bg_enable = useStorage('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.85)
const showMenu = ref(false)
const applyPreferredColorScheme = scheme => {
if (!scheme || 'auto' === scheme) {
@ -296,4 +305,23 @@ const loadImage = async (force = false) => {
}
}
const changeRoute = async (_, callback) => {
showMenu.value = false
document.querySelectorAll('div.has-dropdown').forEach(el => el.classList.remove('is-active'))
if (callback) {
callback()
}
}
const openMenu = e => {
const elm = e.target.closest('div.has-dropdown')
document.querySelectorAll('div.has-dropdown').forEach(el => {
if (el !== elm) {
el.classList.remove('is-active')
}
})
e.target.closest('div.has-dropdown').classList.toggle('is-active')
}
</script>

View file

@ -12,7 +12,7 @@
"web-types": "./web-types.json",
"dependencies": {
"@pinia/nuxt": "^0.11.0",
"@sentry/nuxt": "^9.15.0",
"@sentry/nuxt": "^9.16.0",
"@vueuse/core": "^13.1.0",
"@vueuse/nuxt": "^13.1.0",
"@xterm/addon-fit": "^0.10.0",
@ -22,7 +22,7 @@
"floating-vue": "^5.2.2",
"hls.js": "^1.6.2",
"moment": "^2.30.1",
"nuxt": "^3.17.1",
"nuxt": "^3.17.2",
"pinia": "^3.0.2",
"socket.io-client": "^4.7.2",
"vue": "^3.4",

289
ui/pages/conditions.vue Normal file
View file

@ -0,0 +1,289 @@
<template>
<div>
<div class="mt-1 columns is-multiline">
<div class="column is-12 is-clearfix is-unselectable">
<span class="title is-4">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-filter" /></span>
<span>Conditions</span>
</span>
</span>
<div class="is-pulled-right">
<div class="field is-grouped">
<p class="control">
<button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm;">
<span class="icon"><i class="fas fa-add" /></span>
</button>
</p>
<p class="control">
<button class="button is-info" @click="reloadContent" :class="{ 'is-loading': isLoading }"
:disabled="!socket.isConnected || isLoading" v-if="items && items.length > 0">
<span class="icon"><i class="fas fa-refresh" /></span>
</button>
</p>
</div>
</div>
<div class="is-hidden-mobile">
<span class="subtitle">Run yt-dlp custom match filter on returned info. and apply cli arguments if matched.
This is an advanced feature and should be used with caution.</span>
</div>
</div>
<div class="column is-12" v-if="toggleForm">
<ConditionForm :addInProgress="addInProgress" :reference="itemRef" :item="item" @cancel="resetForm(true)"
@submit="updateItem" />
</div>
<div class="column is-12" v-if="!toggleForm">
<div class="columns is-multiline" v-if="items.length > 0">
<div class="column is-6" v-for="cond in items" :key="cond.id">
<div class="card is-flex is-full-height is-flex-direction-column">
<header class="card-header">
<div class="card-header-title is-text-overflow is-block" v-text="cond.name" />
<div class="card-header-icon">
<a class="has-text-primary" v-tooltip="'Export item.'" @click.prevent="exportItem(cond)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
</a>
<button @click="cond.raw = !cond.raw">
<span class="icon"><i class="fa-solid" :class="{
'fa-arrow-down': !cond?.raw,
'fa-arrow-up': cond?.raw,
}" /></span>
</button>
</div>
</header>
<div class="card-content is-flex-grow-1">
<div class="content">
<p class="is-text-overflow">
<span class="icon"><i class="fa-solid fa-filter" /></span>
<span v-text="cond.filter" />
</p>
<p class="is-text-overflow" v-if="cond.cli">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>{{ cond.cli }}</span>
</p>
</div>
</div>
<div class="card-content" v-if="cond?.raw">
<div class="content">
<pre><code>{{ filterItem(cond) }}</code></pre>
</div>
</div>
<div class="card-footer mt-auto">
<div class="card-footer-item">
<button class="button is-warning is-fullwidth" @click="editItem(cond)">
<span class="icon"><i class="fa-solid fa-cog" /></span>
<span>Edit</span>
</button>
</div>
<div class="card-footer-item">
<button class="button is-danger is-fullwidth" @click="deleteItem(cond)">
<span class="icon"><i class="fa-solid fa-trash" /></span>
<span>Delete</span>
</button>
</div>
</div>
</div>
</div>
</div>
<Message title="No items" message="There are no custom conditions defined."
class="is-background-warning-80 has-text-dark" icon="fas fa-exclamation-circle"
v-if="!items || items.length < 1" />
</div>
</div>
<div class="column is-12" v-if="items && items.length > 0 && !toggleForm">
<Message message_class="has-background-info-90 has-text-dark" title="Tips" icon="fas fa-info-circle">
<ul>
<li>The filtering rely on yt-dlp <code>--match-filter</code> logic, whatever works there works here as well
and uses the same logic boolean and operators.</li>
<li>
The primary use case for this feature is to apply custom cli arguments to specific returned info.
</li>
<li>
For example, i follow specific channel that sometimes region lock some videos, by using the following
filter i am able to bypass it <code>availability = 'needs_auth' & channel_id = 'channel_id'</code>.
and set proxy for that specific video, while leaving the rest of the videos to be downloaded normally.
</li>
</ul>
</Message>
</div>
</div>
</template>
<script setup>
import { request } from '~/utils/index'
const toast = useToast()
const config = useConfigStore()
const socket = useSocketStore()
const items = ref([])
const item = ref({})
const itemRef = ref("")
const toggleForm = ref(false)
const isLoading = ref(false)
const initialLoad = ref(true)
const addInProgress = ref(false)
watch(() => config.app.basic_mode, async () => {
if (!config.app.basic_mode) {
return
}
await navigateTo("/")
})
watch(() => socket.isConnected, async () => {
if (socket.isConnected && initialLoad.value) {
await reloadContent(true)
initialLoad.value = false
}
})
const reloadContent = async (fromMounted = false) => {
try {
isLoading.value = true
const response = await request("/api/conditions")
if (fromMounted && !response.ok) {
return
}
const data = await response.json()
if (data.length < 1) {
return
}
items.value = data
} catch (e) {
if (fromMounted) {
return
}
console.error(e)
toast.error("Failed to fetch page content.")
} finally {
isLoading.value = false
}
}
const resetForm = (closeForm = false) => {
item.value = {}
itemRef.value = null
addInProgress.value = false
if (closeForm) {
toggleForm.value = false
}
}
const updateItems = async items => {
let data
try {
addInProgress.value = true
const local_items = []
for (const item of items) {
const { id, name, filter, cli } = item
if (!name || !filter || !cli) {
toast.error("Name, filter and cli are required.")
return false
}
local_items.push({ id, name, filter, cli })
}
const response = await request("/api/conditions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(local_items),
})
data = await response.json()
if (200 !== response.status) {
toast.error(`Failed to update items. ${data.error}`)
return false
}
items.value = data
resetForm(true)
return true
} catch (e) {
toast.error(`Failed to update items. ${data?.error}. ${e.message}`)
} finally {
addInProgress.value = false
}
}
const deleteItem = async (item) => {
if (true !== confirm(`Delete '${item.name}'?`)) {
return
}
const index = items.value.findIndex((t) => t?.id === item.id)
if (index > -1) {
items.value.splice(index, 1)
} else {
toast.error("Item not found.")
return
}
const status = await updateItems(items.value)
if (!status) {
return
}
toast.success("Item deleted.")
}
const updateItem = async ({ reference, item }) => {
if (reference) {
const index = items.value.findIndex(t => t?.id === reference)
if (index > -1) {
items.value[index] = item
}
} else {
items.value.push(item)
}
const status = await updateItems(items.value)
if (!status) {
return
}
toast.success(`Item ${reference ? "updated" : "added"}.`)
resetForm(true)
}
const filterItem = item => {
const { raw, ...rest } = item
return JSON.stringify(rest, null, 2)
}
const editItem = _item => {
item.value = _item
itemRef.value = _item.id
toggleForm.value = true
}
onMounted(async () => (socket.isConnected ? await reloadContent(true) : ""))
const exportItem = item => {
let data = JSON.parse(JSON.stringify(item))
if ("id" in data) {
delete data['id']
}
let userData = {}
for (const key of Object.keys(data)) {
if (!data[key]) {
continue
}
userData[key] = data[key]
}
userData['_type'] = 'condition'
userData['_version'] = '1.0'
return copyText(base64UrlEncode(JSON.stringify(userData)))
}
</script>

View file

@ -1,19 +1,3 @@
<style>
.navbar-dropdown {
display: none;
}
.navbar-item.is-active .navbar-dropdown,
.navbar-item.is-hoverable:focus .navbar-dropdown,
.navbar-item.is-hoverable:focus-within .navbar-dropdown,
.navbar-item.is-hoverable:hover .navbar-dropdown {
display: block;
}
.navbar-item.has-dropdown {
padding: 0.5rem 0.75rem;
}
</style>
<template>
<div>
<div class="mt-1 columns is-multiline">
@ -21,10 +5,7 @@
<span class="title is-4">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-download" /></span>
<span class="is-hidden-mobile">Downloads</span>
<span class="is-hidden-tablet">
{{ config?.app?.instance_title ? config.app.instance_title : 'Downloads' }}
</span>
<span>Downloads</span>
</span>
</span>

View file

@ -59,7 +59,7 @@ div.is-centered {
<div class="column is-12" v-if="!toggleForm">
<div class="columns is-multiline" v-if="notifications && notifications.length > 0">
<div class="column is-6" v-for="item in notifications" :key="item.id">
<div class="card">
<div class="card is-flex is-full-height is-flex-direction-column">
<header class="card-header">
<div class="card-header-title is-text-overflow is-block">
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
@ -75,7 +75,7 @@ div.is-centered {
</button>
</div>
</header>
<div class="card-content">
<div class="card-content is-flex-grow-1">
<div class="content">
<p>
<span class="icon"><i class="fa-solid fa-list-ul" /></span>
@ -92,7 +92,7 @@ div.is-centered {
<pre><code>{{ filterItem(item) }}</code></pre>
</div>
</div>
<div class="card-footer">
<div class="card-footer mt-auto">
<div class="card-footer-item">
<button class="button is-warning is-fullwidth" @click="editItem(item);">
<span class="icon"><i class="fa-solid fa-trash-can" /></span>

View file

@ -54,7 +54,7 @@ div.is-centered {
<div class="column is-12" v-if="!toggleForm">
<div class="columns is-multiline" v-if="presetsNoDefault && presetsNoDefault.length > 0">
<div class="column is-6" v-for="item in presetsNoDefault" :key="item.id">
<div class="card">
<div class="card is-flex is-full-height is-flex-direction-column">
<header class="card-header">
<div class="card-header-title is-text-overflow is-block" v-text="item.name" />
<div class="card-header-icon">
@ -69,13 +69,8 @@ div.is-centered {
</button>
</div>
</header>
<div class="card-content">
<div class="card-content is-flex-grow-1">
<div class="content">
<p class="is-text-overflow"
v-if="item?.format && false === ['default', 'not_set'].includes(item.format)">
<span class="icon"><i class="fa-solid fa-f" /></span>
<span v-text="item.format" />
</p>
<p class="is-text-overflow" v-if="item.folder">
<span class="icon"><i class="fa-solid fa-folder" /></span>
<span>{{ calcPath(item.folder) }}</span>
@ -99,7 +94,7 @@ div.is-centered {
<pre><code>{{ filterItem(item) }}</code></pre>
</div>
</div>
<div class="card-footer">
<div class="card-footer mt-auto">
<div class="card-footer-item">
<button class="button is-warning is-fullwidth" @click="editItem(item)">
<span class="icon"><i class="fa-solid fa-cog" /></span>
@ -197,7 +192,7 @@ const reloadContent = async (fromMounted = false) => {
return
}
console.error(e)
toast.error("Failed to fetch tasks.")
toast.error("Failed to fetch page content.")
} finally {
isLoading.value = false
}
@ -322,7 +317,7 @@ const exportItem = item => {
}
userData['_type'] = 'preset'
userData['_version'] = '2.0'
userData['_version'] = '2.5'
return copyText(base64UrlEncode(JSON.stringify(userData)))
}

View file

@ -52,7 +52,7 @@ div.is-centered {
<div class="column is-12" v-if="!toggleForm">
<div class="columns is-multiline" v-if="tasks && tasks.length > 0">
<div class="column is-6" v-for="item in tasks" :key="item.id">
<div class="card">
<div class="card is-flex is-full-height is-flex-direction-column">
<header class="card-header">
<div class="card-header-title is-text-overflow is-block">
<NuxtLink target="_blank" :href="item.url">{{ item.name }}</NuxtLink>
@ -63,7 +63,7 @@ div.is-centered {
</a>
</div>
</header>
<div class="card-content">
<div class="card-content is-flex-grow-1">
<div class="content">
<p class="is-text-overflow">
<span class="icon"><i class="fa-solid fa-clock" /></span>
@ -87,7 +87,7 @@ div.is-centered {
</p>
</div>
</div>
<div class="card-footer">
<div class="card-footer mt-auto">
<div class="card-footer-item">
<button class="button is-warning is-fullwidth" @click="editItem(item);">
<span class="icon"><i class="fa-solid fa-cog" /></span>

File diff suppressed because it is too large Load diff