Merge pull request #270 from arabcoders/dev
Design Update & Fold preset --format into the cli args
This commit is contained in:
commit
853a443b88
25 changed files with 1943 additions and 1010 deletions
149
README.md
149
README.md
|
|
@ -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` |
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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}'.")
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
319
app/library/conditions.py
Normal 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
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -313,3 +313,7 @@ hr {
|
|||
.is-justify-self-end {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.is-full-height {
|
||||
height: 100%;
|
||||
}
|
||||
|
|
|
|||
245
ui/components/ConditionForm.vue
Normal file
245
ui/components/ConditionForm.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)">
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
289
ui/pages/conditions.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
1381
ui/yarn.lock
1381
ui/yarn.lock
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue