Merge pull request #299 from arabcoders/dev
Tag premiere video correctly.
This commit is contained in:
commit
2eab819e87
57 changed files with 4542 additions and 3624 deletions
75
README.md
75
README.md
|
|
@ -274,41 +274,42 @@ If you feel like donating and appreciate my work, you can do so by donating to c
|
|||
|
||||
Certain configuration values can be set via environment variables, using the `-e` parameter on the docker command line, or the `environment:` section in `compose.yaml` file.
|
||||
|
||||
| Environment Variable | Description | Default |
|
||||
| ------------------------ | ---------------------------------------------------------------- | ---------------------------------- |
|
||||
| YTP_OUTPUT_TEMPLATE | The template for the filenames of the downloaded videos | `%(title)s.%(ext)s` |
|
||||
| YTP_DEFAULT_PRESET | The default preset to use for the download | `default` |
|
||||
| YTP_INSTANCE_TITLE | The title of the instance | `empty string` |
|
||||
| YTP_FILE_LOGGING | Whether to log to file | `false` |
|
||||
| YTP_DOWNLOAD_PATH | Path to where the downloads will be saved | `/downloads` |
|
||||
| YTP_MAX_WORKERS | How many works to use for downloads | `1` |
|
||||
| YTP_AUTH_USERNAME | Username for basic authentication | `empty string` |
|
||||
| YTP_AUTH_PASSWORD | Password for basic authentication | `empty string` |
|
||||
| YTP_CONSOLE_ENABLED | Whether to enable the console | `false` |
|
||||
| YTP_REMOVE_FILES | Remove the actual file when clicking the remove button | `false` |
|
||||
| YTP_CONFIG_PATH | Path to where the config files will be stored. | `/config` |
|
||||
| YTP_TEMP_PATH | Path to where tmp files are stored. | `/tmp` |
|
||||
| YTP_TEMP_KEEP | Whether to keep the Individual video temp directory or remove it | `false` |
|
||||
| YTP_KEEP_ARCHIVE | Keep history of downloaded videos | `true` |
|
||||
| YTP_YTDL_DEBUG | Whether to turn debug logging for the internal `yt-dlp` package | `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 | `true` |
|
||||
| YTP_BASE_PATH | Set this if you are serving YTPTube from sub-folder | `/` |
|
||||
| Environment Variable | Description | Default |
|
||||
| ------------------------- | ------------------------------------------------------------------ | ---------------------------------- |
|
||||
| YTP_OUTPUT_TEMPLATE | The template for the filenames of the downloaded videos | `%(title)s.%(ext)s` |
|
||||
| YTP_DEFAULT_PRESET | The default preset to use for the download | `default` |
|
||||
| YTP_INSTANCE_TITLE | The title of the instance | `empty string` |
|
||||
| YTP_FILE_LOGGING | Whether to log to file | `false` |
|
||||
| YTP_DOWNLOAD_PATH | Path to where the downloads will be saved | `/downloads` |
|
||||
| YTP_MAX_WORKERS | How many works to use for downloads | `1` |
|
||||
| YTP_AUTH_USERNAME | Username for basic authentication | `empty string` |
|
||||
| YTP_AUTH_PASSWORD | Password for basic authentication | `empty string` |
|
||||
| YTP_CONSOLE_ENABLED | Whether to enable the console | `false` |
|
||||
| YTP_REMOVE_FILES | Remove the actual file when clicking the remove button | `false` |
|
||||
| YTP_CONFIG_PATH | Path to where the config files will be stored. | `/config` |
|
||||
| YTP_TEMP_PATH | Path to where tmp files are stored. | `/tmp` |
|
||||
| YTP_TEMP_KEEP | Whether to keep the Individual video temp directory or remove it | `false` |
|
||||
| YTP_KEEP_ARCHIVE | Keep history of downloaded videos | `true` |
|
||||
| YTP_YTDL_DEBUG | Whether to turn debug logging for the internal `yt-dlp` package | `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 | `true` |
|
||||
| YTP_BASE_PATH | Set this if you are serving YTPTube from sub-folder | `/` |
|
||||
| YTP_PREVENT_LIVE_PREMIERE | Prevents the initial youtube premiere stream from being downloaded | `false` |
|
||||
|
||||
|
|
|
|||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
|
|
@ -18,6 +18,7 @@ from .config import Config
|
|||
from .DataStore import DataStore
|
||||
from .Download import Download
|
||||
from .Events import EventBus, Events
|
||||
from .Events import info as event_info
|
||||
from .Events import warning as event_warning
|
||||
from .ItemDTO import Item, ItemDTO
|
||||
from .Presets import Presets
|
||||
|
|
@ -63,6 +64,12 @@ class DownloadQueue(metaclass=Singleton):
|
|||
_instance = None
|
||||
"""Instance of the DownloadQueue."""
|
||||
|
||||
queue: DataStore
|
||||
"""DataStore for the download queue."""
|
||||
|
||||
done: DataStore
|
||||
"""DataStore for the completed downloads."""
|
||||
|
||||
def __init__(self, connection: Connection, config: Config | None = None):
|
||||
DownloadQueue._instance = self
|
||||
|
||||
|
|
@ -233,6 +240,7 @@ class DownloadQueue(metaclass=Singleton):
|
|||
options: dict = {}
|
||||
error: str | None = None
|
||||
live_in: str | None = None
|
||||
is_premiere: bool = bool(entry.get("is_premiere", False))
|
||||
|
||||
# check if the video is live stream.
|
||||
if "live_status" in entry and "is_upcoming" == entry.get("live_status"):
|
||||
|
|
@ -240,7 +248,7 @@ class DownloadQueue(metaclass=Singleton):
|
|||
live_in = formatdate(entry.get("release_timestamp"), usegmt=True)
|
||||
item.extras.update({"live_in": live_in})
|
||||
else:
|
||||
error = "Live stream not yet started. And no date is set."
|
||||
error = f"No start time is set for {'premiere' if is_premiere else 'live stream'}."
|
||||
else:
|
||||
error = entry.get("msg")
|
||||
|
||||
|
|
@ -268,7 +276,7 @@ class DownloadQueue(metaclass=Singleton):
|
|||
is_live = bool(entry.get("is_live") or live_in or entry.get("live_status") in live_status)
|
||||
|
||||
try:
|
||||
download_dir = calc_download_path(base_path=self.config.download_path, folder=item.folder)
|
||||
download_dir: str = calc_download_path(base_path=self.config.download_path, folder=item.folder)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return {"status": "error", "msg": str(e)}
|
||||
|
|
@ -281,6 +289,14 @@ class DownloadQueue(metaclass=Singleton):
|
|||
if isinstance(key, str) and key.startswith("playlist") and entry.get(key):
|
||||
item.extras[key] = entry.get(key)
|
||||
|
||||
item.extras["duration"] = entry.get("duration", item.extras.get("duration"))
|
||||
|
||||
if not item.extras.get("live_in") and live_in:
|
||||
item.extras["live_in"] = live_in
|
||||
|
||||
if not item.extras.get("is_premiere") and is_premiere:
|
||||
item.extras["is_premiere"] = is_premiere
|
||||
|
||||
dl = ItemDTO(
|
||||
id=str(entry.get("id")),
|
||||
title=str(entry.get("title")),
|
||||
|
|
@ -304,18 +320,20 @@ class DownloadQueue(metaclass=Singleton):
|
|||
try:
|
||||
dlInfo: Download = Download(info=dl, info_dict=entry, logs=logs)
|
||||
|
||||
text_logs = ""
|
||||
text_logs: str = ""
|
||||
if filtered_logs := extract_ytdlp_logs(logs):
|
||||
text_logs = f" Logs: {', '.join(filtered_logs)}"
|
||||
|
||||
if live_in or "is_upcoming" == entry.get("live_status"):
|
||||
NotifyEvent = Events.COMPLETED
|
||||
dlInfo.info.status = "not_live"
|
||||
dlInfo.info.msg = "Stream is not live yet." + text_logs
|
||||
itemDownload = self.done.put(dlInfo)
|
||||
dlInfo.info.msg = (
|
||||
f"{'Premiere video' if is_premiere else 'Live Stream' } is not available yet." + text_logs
|
||||
)
|
||||
itemDownload: Download = self.done.put(dlInfo)
|
||||
elif len(entry.get("formats", [])) < 1:
|
||||
availability = entry.get("availability", "public")
|
||||
msg = "No formats found."
|
||||
availability: str = entry.get("availability", "public")
|
||||
msg: str = "No formats found."
|
||||
if availability and availability not in ("public",):
|
||||
msg += f" Availability is set for '{availability}'."
|
||||
|
||||
|
|
@ -323,7 +341,27 @@ class DownloadQueue(metaclass=Singleton):
|
|||
dlInfo.info.status = "error"
|
||||
itemDownload = self.done.put(dlInfo)
|
||||
NotifyEvent = Events.COMPLETED
|
||||
await self._notify.emit(Events.LOG_WARNING, data=event_warning(msg))
|
||||
await self._notify.emit(Events.LOG_WARNING, data=event_warning(f"No formats found for '{dl.title}'."))
|
||||
elif is_premiere and self.config.prevent_live_premiere:
|
||||
dlInfo.info.error = (
|
||||
f"Premiering right now. Delaying download by '{300+dl.extras.get('duration',0)}' seconds."
|
||||
)
|
||||
_live_in = live_in or item.extras.get("live_in", None)
|
||||
if _live_in:
|
||||
starts_in = parsedate_to_datetime(live_in)
|
||||
starts_in = starts_in.replace(tzinfo=UTC) if starts_in.tzinfo is None else starts_in.astimezone(UTC)
|
||||
starts_in = starts_in + timedelta(minutes=5, seconds=dl.extras.get("duration", 0))
|
||||
dlInfo.info.error += f" Starts in {starts_in.isoformat()}."
|
||||
|
||||
dlInfo.info.status = "not_live"
|
||||
itemDownload = self.done.put(dlInfo)
|
||||
NotifyEvent = Events.COMPLETED
|
||||
await self._notify.emit(
|
||||
Events.LOG_INFO,
|
||||
data=event_info(
|
||||
f"'{dl.title}' is premiering. Download delayed by '{300+dl.extras.get('duration')}'s."
|
||||
),
|
||||
)
|
||||
else:
|
||||
NotifyEvent = Events.ADDED
|
||||
itemDownload = self.queue.put(dlInfo)
|
||||
|
|
@ -464,12 +502,10 @@ 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)
|
||||
if not item.requeued and (condition := Conditions.get_instance().match(info=entry)):
|
||||
already.pop()
|
||||
LOG.info(f"Condition '{condition.name}' 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'.")
|
||||
|
|
@ -566,7 +602,7 @@ class DownloadQueue(metaclass=Singleton):
|
|||
removed_files = 0
|
||||
filename: str = ""
|
||||
|
||||
LOG.info(
|
||||
LOG.debug(
|
||||
f"{remove_file=} {itemRef} - Removing local files: {self.config.remove_files}, {item.info.status=}"
|
||||
)
|
||||
|
||||
|
|
@ -827,32 +863,44 @@ class DownloadQueue(metaclass=Singleton):
|
|||
if self.is_paused() or self.done.empty():
|
||||
return
|
||||
|
||||
LOG.debug("Checking for live stream items in the history queue.")
|
||||
LOG.debug("Checking history queue for queued live stream links.")
|
||||
|
||||
time_now = datetime.now(tz=UTC)
|
||||
|
||||
status = ["not_live", "is_upcoming", "is_live"]
|
||||
status: list[str] = ["not_live", "is_upcoming", "is_live"]
|
||||
|
||||
for id, item in list(self.done.items()):
|
||||
if item.info.status not in status:
|
||||
continue
|
||||
|
||||
item_ref = f"{id=} {item.info.id=} {item.info.title=}"
|
||||
item_ref: str = f"{id=} {item.info.id=} {item.info.title=}"
|
||||
if not item.is_live:
|
||||
LOG.debug(f"Item '{item_ref}' is not a live stream.")
|
||||
continue
|
||||
|
||||
if not item.info.live_in:
|
||||
live_in: str | None = item.info.live_in or item.info.extras.get("live_in", None)
|
||||
if not live_in:
|
||||
LOG.debug(f"Item '{item_ref}' marked as live stream, but no date is set.")
|
||||
continue
|
||||
|
||||
starts_in = parsedate_to_datetime(item.info.live_in)
|
||||
starts_in = parsedate_to_datetime(live_in)
|
||||
starts_in = starts_in.replace(tzinfo=UTC) if starts_in.tzinfo is None else starts_in.astimezone(UTC)
|
||||
|
||||
if time_now < (starts_in + timedelta(minutes=1)):
|
||||
LOG.debug(f"Item '{item_ref}' is not yet live. will start in '{dt_delta(starts_in-time_now)}'.")
|
||||
continue
|
||||
|
||||
duration: int | None = item.info.extras.get("duration", None)
|
||||
is_premiere: bool = item.info.extras.get("is_premiere", False)
|
||||
|
||||
if is_premiere and duration and self.config.prevent_live_premiere:
|
||||
premiere_ends: datetime = starts_in + timedelta(minutes=5, seconds=duration)
|
||||
if time_now < premiere_ends:
|
||||
LOG.debug(
|
||||
f"Item '{item_ref}' is premiering and download is delayed by '{300+duration}' seconds. Will start at '{premiere_ends.isoformat()}'"
|
||||
)
|
||||
continue
|
||||
|
||||
LOG.info(f"Re-queuing item '{item_ref} {item.info.extras=}' for download.")
|
||||
|
||||
try:
|
||||
|
|
@ -862,17 +910,18 @@ class DownloadQueue(metaclass=Singleton):
|
|||
continue
|
||||
|
||||
try:
|
||||
info = item.info
|
||||
new_queue = Item(
|
||||
url=info.url,
|
||||
preset=info.preset,
|
||||
folder=info.folder,
|
||||
cookies=info.cookies,
|
||||
template=info.template,
|
||||
cli=item.info.cli,
|
||||
extras=item.info.extras,
|
||||
await self.add(
|
||||
item=Item(
|
||||
url=item.info.url,
|
||||
preset=item.info.preset,
|
||||
folder=item.info.folder,
|
||||
cookies=item.info.cookies,
|
||||
template=item.info.template,
|
||||
cli=item.info.cli,
|
||||
extras=item.info.extras,
|
||||
)
|
||||
)
|
||||
await self.add(item=new_queue)
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to re-queue item '{item_ref}'. {e!s}")
|
||||
self.done.put(item)
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Failed to re-queue item '{item_ref}'. {e!s}")
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,30 +1,24 @@
|
|||
import asyncio
|
||||
import errno
|
||||
import functools
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
import socketio
|
||||
from aiohttp import web
|
||||
|
||||
from .common import Common
|
||||
from app.library.router import RouteType, get_routes
|
||||
from app.library.Utils import load_modules
|
||||
|
||||
from .config import Config
|
||||
from .DownloadQueue import DownloadQueue
|
||||
from .encoder import Encoder
|
||||
from .Events import Event, EventBus, Events, error
|
||||
from .Events import Event, EventBus, Events
|
||||
from .ItemDTO import Item
|
||||
from .Presets import Presets
|
||||
from .Utils import is_downloaded, tail_log
|
||||
|
||||
LOG = logging.getLogger("socket_api")
|
||||
LOG: logging.Logger = logging.getLogger("socket_api")
|
||||
|
||||
|
||||
class HttpSocket(Common):
|
||||
class HttpSocket:
|
||||
"""
|
||||
This class is used to handle WebSocket events.
|
||||
"""
|
||||
|
|
@ -32,15 +26,11 @@ class HttpSocket(Common):
|
|||
config: Config
|
||||
sio: socketio.AsyncServer
|
||||
queue: DownloadQueue
|
||||
|
||||
subscribers: dict[str, list[str]] = {}
|
||||
"""Event subscriber list."""
|
||||
|
||||
log_task = None
|
||||
"""Task to tail the log file."""
|
||||
di_context: dict[str, object] = {}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root_path: Path,
|
||||
queue: DownloadQueue | None = None,
|
||||
encoder: Encoder | None = None,
|
||||
config: Config | None = None,
|
||||
|
|
@ -55,20 +45,28 @@ class HttpSocket(Common):
|
|||
async_handlers=True,
|
||||
async_mode="aiohttp",
|
||||
cors_allowed_origins=[],
|
||||
transports=["websocket"],
|
||||
transports=["websocket", "polling"],
|
||||
logger=self.config.debug,
|
||||
engineio_logger=self.config.debug,
|
||||
ping_interval=10,
|
||||
ping_timeout=5,
|
||||
)
|
||||
encoder = encoder or Encoder()
|
||||
self.rootPath = root_path
|
||||
|
||||
def emit(e: Event, _, **kwargs):
|
||||
return self.sio.emit(event=e.event, data=encoder.encode(e.data), **kwargs)
|
||||
|
||||
self._notify.subscribe("frontend", emit, f"{__class__.__name__}.emit")
|
||||
self.di_context = {
|
||||
"config": self.config,
|
||||
"queue": self.queue,
|
||||
"sio": self.sio,
|
||||
"encoder": encoder,
|
||||
"notify": self._notify,
|
||||
"root_path": self.rootPath,
|
||||
}
|
||||
|
||||
super().__init__(queue=queue, encoder=encoder, config=config)
|
||||
self._notify.subscribe("frontend", emit, f"{__class__.__name__}.emit")
|
||||
|
||||
@staticmethod
|
||||
def ws_event(func): # type: ignore
|
||||
|
|
@ -93,347 +91,38 @@ class HttpSocket(Common):
|
|||
LOG.debug("Socket server shutdown complete.")
|
||||
|
||||
def attach(self, app: web.Application):
|
||||
app.on_shutdown.append(self.on_shutdown)
|
||||
|
||||
self.sio.attach(app, socketio_path=f"{self.config.base_path.rstrip('/')}/socket.io")
|
||||
|
||||
for attr_name in dir(self):
|
||||
method = getattr(self, attr_name)
|
||||
if hasattr(method, "_ws_event") and self.sio:
|
||||
self.sio.on(method._ws_event)(method) # type: ignore
|
||||
|
||||
self._notify.subscribe(
|
||||
Events.ADD_URL,
|
||||
lambda data, _, **kwargs: self.add(item=Item.format(data.data)), # noqa: ARG005
|
||||
lambda data, _, **kwargs: self.queue.add(item=Item.format(data.data)), # noqa: ARG005
|
||||
f"{__class__.__name__}.add",
|
||||
)
|
||||
|
||||
# register the shutdown event.
|
||||
app.on_shutdown.append(self.on_shutdown)
|
||||
load_modules(self.rootPath, self.rootPath / "routes" / "socket")
|
||||
|
||||
@ws_event
|
||||
async def cli_post(self, sid: str, data):
|
||||
if not self.config.console_enabled:
|
||||
await self._notify.emit(Events.ERROR, data=error("Console is disabled."), to=sid)
|
||||
return
|
||||
|
||||
if not data:
|
||||
await self._notify.emit(Events.CLI_CLOSE, data={"exitcode": 0}, to=sid)
|
||||
return
|
||||
|
||||
try:
|
||||
LOG.info(f"Cli command from client '{sid}'. '{data}'")
|
||||
|
||||
args = ["yt-dlp", *shlex.split(data)]
|
||||
_env = os.environ.copy()
|
||||
_env.update(
|
||||
{
|
||||
"TERM": "xterm-256color",
|
||||
"LANG": "en_US.UTF-8",
|
||||
"SHELL": "/bin/bash",
|
||||
"LC_ALL": "en_US.UTF-8",
|
||||
"PWD": self.config.download_path,
|
||||
"FORCE_COLOR": "1",
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
}
|
||||
for route in get_routes(RouteType.SOCKET).values():
|
||||
LOG.debug(
|
||||
f"Add ({route.name}) {route.method.value if isinstance(route.method,RouteType) else route.method}: {route.path}."
|
||||
)
|
||||
self.sio.on(route.path)(HttpSocket._injector(route.handler, route.path, self.di_context))
|
||||
|
||||
try:
|
||||
import pty
|
||||
@staticmethod
|
||||
def _injector(func, event: str, container: dict):
|
||||
sig: inspect.Signature = inspect.signature(func)
|
||||
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
stdin_arg = asyncio.subprocess.DEVNULL
|
||||
stdout_arg = stderr_arg = slave_fd
|
||||
use_pty = True
|
||||
except ImportError:
|
||||
use_pty = False
|
||||
master_fd = slave_fd = None
|
||||
stdin_arg = asyncio.subprocess.DEVNULL
|
||||
stdout_arg = asyncio.subprocess.PIPE
|
||||
stderr_arg = asyncio.subprocess.STDOUT
|
||||
async def wrapper(sid, data, **kwargs):
|
||||
args = {}
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
cwd=self.config.download_path,
|
||||
stdin=stdin_arg,
|
||||
stdout=stdout_arg,
|
||||
stderr=stderr_arg,
|
||||
env=_env,
|
||||
)
|
||||
merged = {**container, "sid": sid, "data": data, "event_name": event}
|
||||
if isinstance(kwargs, dict):
|
||||
merged.update(kwargs)
|
||||
|
||||
if use_pty:
|
||||
try:
|
||||
os.close(slave_fd)
|
||||
except Exception as e:
|
||||
LOG.error(f"Error closing PTY. '{e!s}'.")
|
||||
for name in sig.parameters:
|
||||
if name in merged:
|
||||
args[name] = merged[name]
|
||||
|
||||
async def reader(sid: str):
|
||||
if use_pty is False:
|
||||
assert proc.stdout is not None
|
||||
async for raw_line in proc.stdout:
|
||||
line = raw_line.rstrip(b"\n")
|
||||
await self._notify.emit(
|
||||
Events.CLI_OUTPUT,
|
||||
data={"type": "stdout", "line": line.decode("utf-8", errors="replace")},
|
||||
to=sid,
|
||||
)
|
||||
return
|
||||
return await func(**args)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
buffer = b""
|
||||
while True:
|
||||
try:
|
||||
chunk = await loop.run_in_executor(None, lambda: os.read(master_fd, 1024))
|
||||
except OSError as e:
|
||||
if e.errno == errno.EIO:
|
||||
break
|
||||
raise
|
||||
|
||||
if not chunk:
|
||||
# No more output
|
||||
if buffer:
|
||||
# Emit any remaining partial line
|
||||
await self._notify.emit(
|
||||
Events.CLI_OUTPUT,
|
||||
data={"type": "stdout", "line": buffer.decode("utf-8", errors="replace")},
|
||||
to=sid,
|
||||
)
|
||||
break
|
||||
buffer += chunk
|
||||
*lines, buffer = buffer.split(b"\n")
|
||||
for line in lines:
|
||||
await self._notify.emit(
|
||||
Events.CLI_OUTPUT,
|
||||
data={"type": "stdout", "line": line.decode("utf-8", errors="replace")},
|
||||
to=sid,
|
||||
)
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except Exception as e:
|
||||
LOG.error(f"Error closing PTY. '{e!s}'.")
|
||||
|
||||
# Start reading output from PTY
|
||||
read_task = asyncio.create_task(reader(sid=sid))
|
||||
|
||||
# Wait until process finishes
|
||||
returncode = await proc.wait()
|
||||
|
||||
# Ensure reading is done
|
||||
await read_task
|
||||
|
||||
await self._notify.emit(Events.CLI_CLOSE, data={"exitcode": returncode}, to=sid)
|
||||
except Exception as e:
|
||||
LOG.error(f"CLI execute exception was thrown for client '{sid}'.")
|
||||
LOG.exception(e)
|
||||
await self._notify.emit(Events.CLI_OUTPUT, data={"type": "stderr", "line": str(e)}, to=sid)
|
||||
await self._notify.emit(Events.CLI_CLOSE, data={"exitcode": -1}, to=sid)
|
||||
|
||||
@ws_event
|
||||
async def add_url(self, sid: str, data: dict):
|
||||
url: str | None = data.get("url")
|
||||
|
||||
if not url:
|
||||
await self._notify.emit(Events.ERROR, data=error("No URL provided.", data={"unlock": True}), to=sid)
|
||||
return
|
||||
|
||||
try:
|
||||
await self._notify.emit(
|
||||
event=Events.STATUS,
|
||||
data=await self.add(item=Item.format(data)),
|
||||
to=sid,
|
||||
)
|
||||
except ValueError as e:
|
||||
LOG.exception(e)
|
||||
await self._notify.emit(Events.ERROR, data=error(str(e)), to=sid)
|
||||
return
|
||||
|
||||
@ws_event
|
||||
async def item_cancel(self, sid: str, id: str):
|
||||
if not id:
|
||||
await self._notify.emit(Events.ERROR, data=error("Invalid request."), to=sid)
|
||||
return
|
||||
|
||||
status: dict[str, str] = {}
|
||||
status = await self.queue.cancel([id])
|
||||
status.update({"identifier": id})
|
||||
|
||||
await self._notify.emit(Events.ITEM_CANCEL, data=status)
|
||||
|
||||
@ws_event
|
||||
async def item_delete(self, sid: str, data: dict):
|
||||
if not data:
|
||||
await self._notify.emit(Events.ERROR, data=error("Invalid request."), to=sid)
|
||||
return
|
||||
|
||||
id: str | None = data.get("id")
|
||||
if not id:
|
||||
await self._notify.emit(Events.ERROR, data=error("Invalid request."), to=sid)
|
||||
return
|
||||
|
||||
status: dict[str, str] = {}
|
||||
status = await self.queue.clear([id], remove_file=bool(data.get("remove_file", False)))
|
||||
status.update({"identifier": id})
|
||||
|
||||
await self._notify.emit(Events.ITEM_DELETE, data=status)
|
||||
|
||||
@ws_event
|
||||
async def archive_item(self, _: str, data: dict):
|
||||
if not isinstance(data, dict) or "url" not in data:
|
||||
return
|
||||
|
||||
from .YTDLPOpts import YTDLPOpts
|
||||
|
||||
params = YTDLPOpts.get_instance()
|
||||
|
||||
if "preset" in data and isinstance(data["preset"], str):
|
||||
params.preset(name=data["preset"])
|
||||
|
||||
if "cli" in data and isinstance(data["cli"], str) and len(data["cli"]) > 1:
|
||||
params.add_cli(data["cli"], from_user=True)
|
||||
|
||||
params = params.get_all()
|
||||
|
||||
file: str = params.get("download_archive", None)
|
||||
|
||||
if not file:
|
||||
return
|
||||
|
||||
exists, idDict = is_downloaded(file, data["url"])
|
||||
if exists or "archive_id" not in idDict or idDict["archive_id"] is None:
|
||||
return
|
||||
|
||||
async with await anyio.open_file(file, "a") as f:
|
||||
await f.write(f"{idDict['archive_id']}\n")
|
||||
|
||||
manual_archive = self.config.manual_archive
|
||||
if manual_archive:
|
||||
manual_archive = Path(manual_archive)
|
||||
|
||||
if not manual_archive.exists():
|
||||
manual_archive.touch(exist_ok=True)
|
||||
|
||||
previouslyArchived = False
|
||||
async with await anyio.open_file(manual_archive) as f:
|
||||
async for line in f:
|
||||
if idDict["archive_id"] in line:
|
||||
previouslyArchived = True
|
||||
break
|
||||
|
||||
if not previouslyArchived:
|
||||
async with await anyio.open_file(manual_archive, "a") as f:
|
||||
await f.write(f"{idDict['archive_id']} - at: {datetime.now(UTC).isoformat()}\n")
|
||||
LOG.info(f"Archiving url '{data['url']}' with id '{idDict['archive_id']}'.")
|
||||
else:
|
||||
LOG.info(f"URL '{data['url']}' with id '{idDict['archive_id']}' already archived.")
|
||||
|
||||
@ws_event
|
||||
async def connect(self, sid: str, _=None):
|
||||
data = {
|
||||
**self.queue.get(),
|
||||
"config": self.config.frontend(),
|
||||
"presets": Presets.get_instance().get_all(),
|
||||
"paused": self.queue.is_paused(),
|
||||
}
|
||||
|
||||
data["folders"] = [folder.name for folder in Path(self.config.download_path).iterdir() if folder.is_dir()]
|
||||
|
||||
await self._notify.emit(Events.INITIAL_DATA, data=data, to=sid)
|
||||
|
||||
@ws_event
|
||||
async def pause(self, *_, **__):
|
||||
self.queue.pause()
|
||||
await self._notify.emit(Events.PAUSED, data={"paused": True, "at": time.time()})
|
||||
|
||||
@ws_event
|
||||
async def resume(self, *_, **__):
|
||||
self.queue.resume()
|
||||
await self._notify.emit(Events.PAUSED, data={"paused": False, "at": time.time()})
|
||||
|
||||
@ws_event
|
||||
async def subscribe(self, sid: str, event: str):
|
||||
"""
|
||||
Subscribe to a specific event.
|
||||
|
||||
Args:
|
||||
sid (str): The session ID of the client.
|
||||
event (str): The event to subscribe to.
|
||||
|
||||
"""
|
||||
if not isinstance(event, str):
|
||||
await self._notify.emit(Events.ERROR, data=error("Invalid event."), to=sid)
|
||||
return
|
||||
|
||||
if event not in self.subscribers:
|
||||
self.subscribers[event] = []
|
||||
|
||||
if sid not in self.subscribers[event]:
|
||||
self.subscribers[event].append(sid)
|
||||
LOG.debug(f"Client '{sid}' subscribed to event '{event}'.")
|
||||
await self.sio.emit(Events.SUBSCRIBED, data={"event": event}, to=sid)
|
||||
|
||||
async def emit_logs(data: dict):
|
||||
await self.subscribe_emit(event=event, data=data)
|
||||
|
||||
if "log_lines" == event and self.log_task is None:
|
||||
log_file = Path(self.config.config_path) / "logs" / "app.log"
|
||||
LOG.debug(f"Starting tailing '{log_file!s}'.")
|
||||
self.log_task = asyncio.create_task(
|
||||
tail_log(
|
||||
file=log_file,
|
||||
emitter=emit_logs,
|
||||
),
|
||||
name="tail_log",
|
||||
)
|
||||
|
||||
@ws_event
|
||||
async def unsubscribe(self, sid: str, event: str):
|
||||
"""
|
||||
Unsubscribe from a specific event.
|
||||
|
||||
Args:
|
||||
sid (str): The session ID of the client.
|
||||
event (str): The event to unsubscribe from.
|
||||
|
||||
"""
|
||||
if event not in self.subscribers:
|
||||
return
|
||||
|
||||
if sid not in self.subscribers[event]:
|
||||
return
|
||||
|
||||
self.subscribers[event].remove(sid)
|
||||
await self.sio.emit(Events.UNSUBSCRIBED, data={"event": event}, to=sid)
|
||||
|
||||
LOG.debug(f"Client '{sid}' unsubscribed from event '{event}'.")
|
||||
|
||||
if "log_lines" != event or not self.log_task or "log_lines" not in self.subscribers:
|
||||
return
|
||||
|
||||
if len(self.subscribers["log_lines"]) < 1:
|
||||
try:
|
||||
LOG.debug("Stopping log tailing task.")
|
||||
self.log_task.cancel()
|
||||
self.log_task = None
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
@ws_event
|
||||
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. {reason}")
|
||||
|
||||
for event in self.subscribers:
|
||||
if sid in self.subscribers[event]:
|
||||
await self.unsubscribe(sid=sid, event=event)
|
||||
|
||||
async def subscribe_emit(self, event: str, data: dict):
|
||||
if event not in self.subscribers or len(self.subscribers[event]) < 1:
|
||||
return
|
||||
|
||||
for sid in self.subscribers[event]:
|
||||
await self.sio.emit(event=event, data=data, to=sid)
|
||||
return wrapper
|
||||
|
|
|
|||
|
|
@ -175,13 +175,14 @@ class Presets(metaclass=Singleton):
|
|||
Presets: The current instance.
|
||||
|
||||
"""
|
||||
has: int = len(self._items)
|
||||
self.clear()
|
||||
|
||||
if not self._file.exists() or self._file.stat().st_size < 10:
|
||||
return self
|
||||
|
||||
try:
|
||||
LOG.info(f"Loading '{self._file}'.")
|
||||
LOG.info(f"{'Reloading' if has else 'Loading'} '{self._file}'.")
|
||||
presets: dict = json.loads(self._file.read_text())
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to parse '{self._file}'. '{e}'.")
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ class Scheduler(metaclass=Singleton):
|
|||
|
||||
self._jobs[job_id] = job
|
||||
|
||||
LOG.debug(f"Added job '{job_id}' to the schedule.")
|
||||
LOG.debug(f"Added '{job_id}' to the scheduler.")
|
||||
|
||||
return job_id
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from .Scheduler import Scheduler
|
|||
from .Singleton import Singleton
|
||||
from .Utils import init_class
|
||||
|
||||
LOG = logging.getLogger("tasks")
|
||||
LOG: logging.Logger = logging.getLogger("tasks")
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
|
|
@ -124,13 +124,14 @@ class Tasks(metaclass=Singleton):
|
|||
Tasks: The current instance.
|
||||
|
||||
"""
|
||||
has_tasks: bool = len(self._tasks) > 0
|
||||
self.clear()
|
||||
|
||||
if not self._file.exists() or self._file.stat().st_size < 1:
|
||||
return self
|
||||
|
||||
try:
|
||||
LOG.info(f"Loading '{self._file}'.")
|
||||
LOG.info(f"{'Reloading' if has_tasks else 'Loading'} '{self._file}'.")
|
||||
tasks = json.loads(self._file.read_text())
|
||||
except Exception as e:
|
||||
LOG.error(f"Error loading '{self._file}'. '{e!s}'.")
|
||||
|
|
|
|||
|
|
@ -17,10 +17,11 @@ from typing import TypeVar
|
|||
|
||||
import yt_dlp
|
||||
from Crypto.Cipher import AES
|
||||
from yt_dlp.utils import match_str
|
||||
|
||||
from .LogWrapper import LogWrapper
|
||||
|
||||
LOG = logging.getLogger("Utils")
|
||||
LOG: logging.Logger = logging.getLogger("Utils")
|
||||
|
||||
REMOVE_KEYS: list = [
|
||||
{
|
||||
|
|
@ -59,7 +60,8 @@ FILES_TYPE: list = [
|
|||
{"rx": re.compile(r"\.(nfo|json|jpg|torrent|\.info\.json)$", re.IGNORECASE), "type": "metadata"},
|
||||
]
|
||||
|
||||
DATETIME_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?")
|
||||
TAG_REGEX: re.Pattern[str] = re.compile(r"%{([^:}]+)(?::([^}]*))?}c")
|
||||
DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?")
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
|
@ -204,6 +206,10 @@ def extract_info(
|
|||
if not data:
|
||||
return data
|
||||
|
||||
data["is_premiere"] = match_str("media_type=video & duration & is_live", data)
|
||||
if not data["is_premiere"]:
|
||||
data["is_premiere"] = "video" == data.get("media_type") and "is_upcoming" == data.get("live_status")
|
||||
|
||||
return yt_dlp.YoutubeDL.sanitize_info(data) if sanitize_info else data
|
||||
|
||||
|
||||
|
|
@ -277,6 +283,41 @@ def is_downloaded(archive_file: str, url: str) -> tuple[bool, dict[str | None, s
|
|||
return (False, idDict)
|
||||
|
||||
|
||||
def remove_from_archive(archive_file: str | Path, url: str) -> bool:
|
||||
"""
|
||||
Remove the downloaded video record from the archive file.
|
||||
|
||||
Args:
|
||||
archive_file (str): Archive file path.
|
||||
url (str): URL to check and remove.
|
||||
|
||||
Returns:
|
||||
bool: True if the record removed, False otherwise.
|
||||
|
||||
"""
|
||||
if not url or not archive_file:
|
||||
return False
|
||||
|
||||
archive_path: Path = Path(archive_file) if not isinstance(archive_file, Path) else archive_file
|
||||
if not archive_path.exists():
|
||||
return False
|
||||
|
||||
idDict = get_archive_id(url=url)
|
||||
archive_id: str | None = idDict.get("archive_id")
|
||||
|
||||
if not archive_id:
|
||||
return False
|
||||
|
||||
lines: list[str] = archive_path.read_text(encoding="utf-8").splitlines()
|
||||
new_lines: list[str] = [line for line in lines if archive_id not in line]
|
||||
|
||||
if len(lines) == len(new_lines):
|
||||
return False
|
||||
|
||||
archive_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
|
||||
return True
|
||||
|
||||
|
||||
def load_file(file: str, check_type=None) -> tuple[dict | list, bool, str]:
|
||||
"""
|
||||
Load a JSON or JSON5 file and return the contents as a dictionary
|
||||
|
|
@ -803,7 +844,8 @@ def get_files(base_path: Path | str, dir: str | None = None):
|
|||
if file.name.startswith(".") or file.name.startswith("_"):
|
||||
continue
|
||||
|
||||
if file.is_symlink():
|
||||
is_symlink: bool = file.is_symlink()
|
||||
if is_symlink:
|
||||
try:
|
||||
test: Path = file.resolve()
|
||||
test.relative_to(base_path)
|
||||
|
|
@ -831,9 +873,10 @@ def get_files(base_path: Path | str, dir: str | None = None):
|
|||
content_type = "download"
|
||||
|
||||
stat = file.stat()
|
||||
|
||||
contents.append(
|
||||
{
|
||||
"type": "file" if file.is_file() else "dir",
|
||||
"type": "file" if file.is_file() else "link" if is_symlink else "dir",
|
||||
"content_type": content_type,
|
||||
"name": file.name,
|
||||
"path": str(file.relative_to(base_path)).strip("/"),
|
||||
|
|
@ -843,6 +886,7 @@ def get_files(base_path: Path | str, dir: str | None = None):
|
|||
"ctime": datetime.fromtimestamp(stat.st_ctime, tz=UTC).isoformat(),
|
||||
"is_dir": file.is_dir(),
|
||||
"is_file": file.is_file(),
|
||||
"is_symlink": is_symlink,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -960,7 +1004,7 @@ async def read_logfile(file: Path, offset: int = 0, limit: int = 50) -> dict:
|
|||
for line in lines[-(offset + limit) : -offset] if offset else lines[-limit:]:
|
||||
line_bytes = line if isinstance(line, bytes) else line.encode()
|
||||
msg = line.decode(errors="replace")
|
||||
dt_match = DATETIME_PATTERN.match(msg)
|
||||
dt_match = DT_PATTERN.match(msg)
|
||||
result.append(
|
||||
{
|
||||
"id": sha256(line_bytes).hexdigest(),
|
||||
|
|
@ -1002,7 +1046,7 @@ async def tail_log(file: Path, emitter: callable, sleep_time: float = 0.5):
|
|||
continue
|
||||
|
||||
msg = line.decode(errors="replace")
|
||||
dt_match = DATETIME_PATTERN.match(msg)
|
||||
dt_match = DT_PATTERN.match(msg)
|
||||
|
||||
await emitter(
|
||||
{
|
||||
|
|
@ -1198,3 +1242,52 @@ def init_class(cls: type[T], data: dict) -> T:
|
|||
from dataclasses import fields
|
||||
|
||||
return cls(**{k: v for k, v in data.items() if k in {f.name for f in fields(cls)}})
|
||||
|
||||
|
||||
def load_modules(root_path: Path, directory: Path):
|
||||
"""
|
||||
Load all modules from a given directory relative to the root path.
|
||||
|
||||
Args:
|
||||
root_path (Path): The root path of the application.
|
||||
directory (Path): The directory from which to load modules.
|
||||
|
||||
"""
|
||||
import importlib
|
||||
import pkgutil
|
||||
|
||||
package_name: str = str(directory.relative_to(root_path).as_posix()).replace("/", ".")
|
||||
|
||||
LOG.debug(f"Loading routes from '{directory}' with package name '{package_name}'.")
|
||||
|
||||
for _, name, _ in pkgutil.iter_modules([directory]):
|
||||
full_name: str = f"{package_name}.{name}"
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
try:
|
||||
LOG.debug(f"Loading module '{full_name}'.")
|
||||
importlib.import_module(full_name)
|
||||
except ImportError as e:
|
||||
LOG.error(f"Failed to import module '{full_name}': {e}")
|
||||
|
||||
|
||||
def parse_tags(text: str) -> tuple[str, dict[str, str | bool]]:
|
||||
"""
|
||||
Parse tags from a string formatted with %{tag_name[:value]}c.
|
||||
|
||||
Args:
|
||||
text (str): The input string containing tags.
|
||||
|
||||
Returns:
|
||||
tuple[str, dict[str, str | bool]]: A tuple containing the string with tags removed and a dictionary of tags.
|
||||
|
||||
"""
|
||||
tags: dict[str, str | bool] = {}
|
||||
|
||||
def replacer(match: re.Match) -> str:
|
||||
name = match.group(1)
|
||||
value = match.group(2)
|
||||
tags[name] = value if value is not None else True
|
||||
return ""
|
||||
|
||||
return TAG_REGEX.sub(replacer, text).strip(), tags
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import logging
|
|||
from pathlib import Path
|
||||
|
||||
from .config import Config
|
||||
from .Presets import Presets, Preset
|
||||
from .Presets import Preset, Presets
|
||||
from .Singleton import Singleton
|
||||
from .Utils import REMOVE_KEYS, arg_converter, calc_download_path, load_cookies, merge_dict
|
||||
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
import logging
|
||||
|
||||
from .config import Config
|
||||
from .DownloadQueue import DownloadQueue
|
||||
from .encoder import Encoder
|
||||
from .ItemDTO import Item
|
||||
|
||||
LOG = logging.getLogger("common")
|
||||
|
||||
|
||||
class Common:
|
||||
"""
|
||||
This class is used to share common methods between the socket and the API gateways.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
queue: DownloadQueue | None = None,
|
||||
encoder: Encoder | None = None,
|
||||
config: Config | None = None,
|
||||
):
|
||||
super().__init__()
|
||||
self.queue = queue or DownloadQueue.get_instance()
|
||||
self.encoder = encoder or Encoder()
|
||||
|
||||
config = config or Config.get_instance()
|
||||
self.default_preset = config.default_preset
|
||||
|
||||
async def add(self, item: Item) -> dict[str, str]:
|
||||
"""
|
||||
Add an item to the download queue.
|
||||
|
||||
Args:
|
||||
item (Item): The item to be added to the queue.
|
||||
|
||||
Returns:
|
||||
dict[str, str]: The status of the download.
|
||||
{ "status": "text" }
|
||||
|
||||
"""
|
||||
return await self.queue.add(item=item)
|
||||
|
|
@ -94,6 +94,9 @@ class Config:
|
|||
db_file: str = "{config_path}/ytptube.db"
|
||||
"""The path to the database file."""
|
||||
|
||||
archive_file: str = "{config_path}/archive.log"
|
||||
"""The path to the download archive file."""
|
||||
|
||||
manual_archive: str = "{config_path}/archive.manual.log"
|
||||
"""The path to the manual archive file."""
|
||||
|
||||
|
|
@ -158,6 +161,9 @@ class Config:
|
|||
is_native: bool = False
|
||||
"Is the application running in webview."
|
||||
|
||||
prevent_live_premiere: bool = False
|
||||
"""Prevent downloading of the initial premiere live broadcast."""
|
||||
|
||||
pictures_backends: list[str] = [
|
||||
"https://unsplash.it/1920/1080?random",
|
||||
"https://picsum.photos/1920/1080",
|
||||
|
|
@ -209,6 +215,7 @@ class Config:
|
|||
"console_enabled",
|
||||
"browser_enabled",
|
||||
"ytdlp_auto_update",
|
||||
"prevent_premiere_live",
|
||||
)
|
||||
"The variables that are booleans."
|
||||
|
||||
|
|
@ -367,7 +374,7 @@ class Config:
|
|||
self._ytdlp_cli_mutable += f"\n--socket-timeout {self.socket_timeout}"
|
||||
|
||||
if self.keep_archive:
|
||||
archive_file: Path = Path(self.config_path) / "archive.log"
|
||||
archive_file: Path = Path(self.archive_file)
|
||||
if not archive_file.exists():
|
||||
LOG.info(f"Creating archive file '{archive_file}'.")
|
||||
archive_file.touch(exist_ok=True)
|
||||
|
|
|
|||
149
app/library/router.py
Normal file
149
app/library/router.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import logging
|
||||
import re
|
||||
from collections.abc import Awaitable
|
||||
from enum import Enum
|
||||
from functools import wraps
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# make a enum for route types
|
||||
class RouteType(str, Enum):
|
||||
HTTP = "http"
|
||||
SOCKET = "socket"
|
||||
|
||||
@classmethod
|
||||
def all(cls) -> list[str]:
|
||||
return [member.value for member in cls]
|
||||
|
||||
|
||||
class Route:
|
||||
"""
|
||||
A class to represent an route.
|
||||
|
||||
Attributes:
|
||||
method (str): The HTTP method (GET, POST, etc.).
|
||||
path (str): The path for the route.
|
||||
name (str): The name of the route.
|
||||
handler (Awaitable): The function that handles the route.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, method: str, path: str, name: str, handler: Awaitable):
|
||||
self.method: str = method.upper()
|
||||
self.path: str = path
|
||||
self.name: str = name
|
||||
self.handler: Awaitable = handler
|
||||
|
||||
|
||||
ROUTES: dict[str, dict[str, Route]] = {}
|
||||
|
||||
|
||||
def make_route_name(method: str, path: str) -> str:
|
||||
method = method.lower()
|
||||
path = path.strip("/")
|
||||
|
||||
segments: list = []
|
||||
for part in path.split("/"):
|
||||
part = re.sub(r"[^\w]", "_", part) # remove invalid chars
|
||||
if not part:
|
||||
part = "part"
|
||||
elif part[0].isdigit():
|
||||
part = f"p_{part}"
|
||||
segments.append(part)
|
||||
|
||||
return f"{method}:" + ".".join(segments or ["root"])
|
||||
|
||||
|
||||
def route(method: RouteType | str, path: str, name: str | None = None, **kwargs) -> Awaitable:
|
||||
"""
|
||||
Decorator to mark a method as an HTTP route handler.
|
||||
|
||||
Args:
|
||||
method (RouteType|str): The HTTP method.
|
||||
path (str): The path to the route.
|
||||
name (str): The name of the route.
|
||||
kwargs: Additional keyword arguments.
|
||||
|
||||
Returns:
|
||||
Awaitable: The decorated function.
|
||||
|
||||
"""
|
||||
if not name:
|
||||
name = make_route_name(method, path)
|
||||
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
route_type: str = RouteType.SOCKET if RouteType.SOCKET == method else RouteType.HTTP
|
||||
if route_type not in ROUTES:
|
||||
ROUTES[route_type] = {}
|
||||
|
||||
ROUTES[route_type][name] = Route(method=method.upper(), path=path, name=name, handler=wrapper)
|
||||
if "http" == route_type and path.endswith("/") and "/" != path and not kwargs.get("no_slash", False):
|
||||
ROUTES[route_type][f"{name}_no_slash"] = Route(
|
||||
method=method.upper(), path=path[:-1], name=f"{name}_no_slash", handler=wrapper
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def add_route(method: RouteType | str, path: str, handler: Awaitable, name: str | None = None, **kwargs):
|
||||
"""
|
||||
Decorator to mark a method as an HTTP route handler.
|
||||
|
||||
Args:
|
||||
method (RouteType|str): The HTTP method.
|
||||
path (str): The path to the route.
|
||||
name (str): The name of the route.
|
||||
handler (Awaitable): The function that handles the route.
|
||||
kwargs: Additional keyword arguments.
|
||||
|
||||
"""
|
||||
if not name:
|
||||
name = make_route_name(method, path)
|
||||
|
||||
route_type: str = RouteType.SOCKET if RouteType.SOCKET == method else RouteType.HTTP
|
||||
|
||||
if route_type not in ROUTES:
|
||||
ROUTES[route_type] = {}
|
||||
|
||||
ROUTES[route_type][name] = Route(method=method.upper(), path=path, name=name, handler=handler)
|
||||
|
||||
if "http" == route_type and path.endswith("/") and "/" != path and not kwargs.get("no_slash", False):
|
||||
ROUTES[route_type][f"{name}_no_slash"] = Route(
|
||||
method=method.upper(), path=path[:-1], name=f"{name}_no_slash", handler=handler
|
||||
)
|
||||
|
||||
|
||||
def get_route(route_type: RouteType, name: str) -> dict[str, Route] | None:
|
||||
"""
|
||||
Get the route information by name.
|
||||
|
||||
Args:
|
||||
route_type (RouteType): The type of the route (e.g., RouteType.HTTP, RouteType.SOCKET).
|
||||
name (str): The name of the route.
|
||||
|
||||
Returns:
|
||||
dict: The route information, or None if not found.
|
||||
|
||||
"""
|
||||
return ROUTES.get(route_type, {}).get(name, None)
|
||||
|
||||
|
||||
def get_routes(route_type: RouteType) -> dict[str, Route]:
|
||||
"""
|
||||
Get all registered routes.
|
||||
|
||||
Args:
|
||||
route_type (RouteType): The type of the route (e.g., RouteType.HTTP, RouteType.SOCKET).
|
||||
|
||||
Returns:
|
||||
dict[str, dict]: A dictionary of all registered routes.
|
||||
|
||||
"""
|
||||
return ROUTES.get(route_type, {})
|
||||
33
app/main.py
33
app/main.py
|
|
@ -1,24 +1,31 @@
|
|||
#!/usr/bin/env python3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
APP_ROOT = str((Path(__file__).parent / "..").resolve())
|
||||
if APP_ROOT not in sys.path:
|
||||
sys.path.insert(0, APP_ROOT)
|
||||
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sqlite3
|
||||
import sys
|
||||
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
|
||||
from library.HttpAPI import HttpAPI
|
||||
from library.HttpSocket import HttpSocket
|
||||
from library.Notifications import Notification
|
||||
from library.Presets import Presets
|
||||
from library.Scheduler import Scheduler
|
||||
from library.Tasks import Tasks
|
||||
|
||||
from app.library.conditions import Conditions
|
||||
from app.library.config import Config
|
||||
from app.library.DownloadQueue import DownloadQueue
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.HttpAPI import HttpAPI
|
||||
from app.library.HttpSocket import HttpSocket
|
||||
from app.library.Notifications import Notification
|
||||
from app.library.Presets import Presets
|
||||
from app.library.Scheduler import Scheduler
|
||||
from app.library.Tasks import Tasks
|
||||
|
||||
LOG = logging.getLogger("app")
|
||||
MIME = magic.Magic(mime=True)
|
||||
|
|
@ -58,7 +65,7 @@ class Main:
|
|||
|
||||
self._queue = DownloadQueue(connection=connection)
|
||||
self._http = HttpAPI(root_path=ROOT_PATH, queue=self._queue)
|
||||
self._socket = HttpSocket(queue=self._queue)
|
||||
self._socket = HttpSocket(root_path=ROOT_PATH, queue=self._queue)
|
||||
|
||||
self._app.on_cleanup.append(_close_connection)
|
||||
|
||||
|
|
@ -117,7 +124,7 @@ class Main:
|
|||
|
||||
HTTP_LOGGER = None
|
||||
if self._config.access_log:
|
||||
from library.HttpAPI import LOG as HTTP_LOGGER
|
||||
from app.library.HttpAPI import LOG as HTTP_LOGGER
|
||||
|
||||
HTTP_LOGGER.addFilter(lambda record: f"GET {self._app.router['ping'].url_for()}" not in record.getMessage())
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
#!/usr/bin/env python3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
APP_ROOT = str((Path(__file__).parent / "..").resolve())
|
||||
if APP_ROOT not in sys.path:
|
||||
sys.path.insert(0, APP_ROOT)
|
||||
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import socket
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
ready = threading.Event()
|
||||
exception_holder = queue.Queue()
|
||||
|
|
|
|||
135
app/routes/api/_static.py
Normal file
135
app/routes/api/_static.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import magic
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.router import add_route
|
||||
|
||||
MIME = magic.Magic(mime=True)
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
STATIC_FILES: dict[str, dict] = {}
|
||||
|
||||
EXT_TO_MIME: dict = {
|
||||
".html": "text/html",
|
||||
".css": "text/css",
|
||||
".js": "application/javascript",
|
||||
".json": "application/json",
|
||||
".ico": "image/x-icon",
|
||||
}
|
||||
|
||||
FRONTEND_ROUTES: list[str] = [
|
||||
"/console/",
|
||||
"/presets/",
|
||||
"/tasks/",
|
||||
"/notifications/",
|
||||
"/changelog/",
|
||||
"/logs/",
|
||||
"/conditions/",
|
||||
"/browser/",
|
||||
"/browser/{path:.*}",
|
||||
]
|
||||
|
||||
|
||||
async def serve_static_file(request: Request, config: Config) -> Response:
|
||||
"""
|
||||
Preload static files from the ui/exported folder.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
path: str = request.path
|
||||
|
||||
if "/" != config.base_path and path.startswith(config.base_path):
|
||||
path = path.replace(config.base_path[:-1], "", 1)
|
||||
|
||||
if path not in STATIC_FILES:
|
||||
for k in STATIC_FILES:
|
||||
if path.startswith(k):
|
||||
path = k
|
||||
break
|
||||
else:
|
||||
return web.json_response({"error": "File not found.", "file": path}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
item: dict = STATIC_FILES[path]
|
||||
|
||||
return web.FileResponse(
|
||||
path=item["file"],
|
||||
headers={
|
||||
"Pragma": "public",
|
||||
"Cache-Control": "public, max-age=31536000",
|
||||
"Content-Type": item.get("content_type"),
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
)
|
||||
|
||||
|
||||
def preload_static(root_path: Path, config: Config) -> None:
|
||||
"""
|
||||
Preload static files from the ui/exported folder.
|
||||
|
||||
Args:
|
||||
root_path (Path): The root path of the application.
|
||||
config (Config): The configuration instance.
|
||||
|
||||
"""
|
||||
global STATIC_FILES # noqa: PLW0602
|
||||
|
||||
static_dir: Path = (root_path / "ui" / "exported").absolute()
|
||||
if not static_dir.exists():
|
||||
static_dir = (root_path.parent / "ui" / "exported").absolute()
|
||||
if not static_dir.exists():
|
||||
msg: str = f"Could not find the frontend UI static assets. '{static_dir}'."
|
||||
raise ValueError(msg)
|
||||
|
||||
preloaded = 0
|
||||
|
||||
for file in static_dir.rglob("*.*"):
|
||||
if ".map" == file.suffix:
|
||||
continue
|
||||
|
||||
uri_path: str = f"/{file.relative_to(static_dir).as_posix()!s}"
|
||||
# uri_path: str = f"/{str(file.as_posix()).replace(f'{static_dir.as_posix()!s}/', '')}"
|
||||
contentType = EXT_TO_MIME.get(file.suffix)
|
||||
if not contentType:
|
||||
contentType = MIME.from_file(file)
|
||||
|
||||
STATIC_FILES[uri_path] = {
|
||||
"uri": uri_path,
|
||||
"content_type": contentType,
|
||||
"file": file,
|
||||
}
|
||||
|
||||
add_route(method="GET", path=uri_path, handler=serve_static_file)
|
||||
preloaded += 1
|
||||
|
||||
if "/index.html" in STATIC_FILES:
|
||||
for path in FRONTEND_ROUTES:
|
||||
STATIC_FILES[path] = STATIC_FILES["/index.html"]
|
||||
STATIC_FILES[path.lstrip("/")] = STATIC_FILES["/index.html"]
|
||||
add_route(method="GET", path=path, handler=serve_static_file)
|
||||
LOG.debug(f"Preloading frontend static route '{path}'.")
|
||||
preloaded += 1
|
||||
|
||||
# Add main app route
|
||||
STATIC_FILES["/"] = STATIC_FILES["/index.html"]
|
||||
STATIC_FILES[config.base_path] = STATIC_FILES["/index.html"]
|
||||
add_route(method="GET", path=config.base_path, handler=serve_static_file, name="index")
|
||||
|
||||
if preloaded < 1:
|
||||
message = f"Failed to find any static files in '{static_dir}'."
|
||||
if config.ignore_ui:
|
||||
LOG.warning(message)
|
||||
return
|
||||
|
||||
raise ValueError(message)
|
||||
|
||||
LOG.info(f"Loaded '{preloaded}' static files.")
|
||||
168
app/routes/api/archive.py
Normal file
168
app/routes/api/archive.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import anyio
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.Download import Download
|
||||
from app.library.DownloadQueue import DownloadQueue
|
||||
from app.library.router import route
|
||||
from app.library.Utils import is_downloaded, remove_from_archive
|
||||
from app.library.YTDLPOpts import YTDLPOpts
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from library.Download import Download
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route("DELETE", r"api/archive/{id}", "archive.remove")
|
||||
async def archive_remove(request: Request, queue: DownloadQueue, config: Config) -> Response:
|
||||
"""
|
||||
Remove an item from the archive.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
queue (DownloadQueue): The download queue instance.
|
||||
config (Config): The configuration instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
item = None
|
||||
try:
|
||||
data: dict | None = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
|
||||
title: str = ""
|
||||
|
||||
url: str | None = data.get("url", None) if data else None
|
||||
|
||||
if not url:
|
||||
id: str = request.match_info.get("id")
|
||||
if not id:
|
||||
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
item: Download | None = queue.done.get_by_id(id)
|
||||
if not item:
|
||||
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
url = item.info.url
|
||||
title = f" '{item.info.title}'"
|
||||
except KeyError:
|
||||
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
if config.manual_archive:
|
||||
remove_from_archive(archive_file=Path(config.manual_archive), url=url)
|
||||
|
||||
archive_file: Path | None = Path(config.archive_file) if config.keep_archive else None
|
||||
if item:
|
||||
params: YTDLPOpts = YTDLPOpts.get_instance().preset(name=item.info.preset)
|
||||
if item.info.cli:
|
||||
params.add_cli(item.info.cli, from_user=True)
|
||||
|
||||
params = params.get_all()
|
||||
if user_file := params.get("download_archive", None):
|
||||
archive_file = Path(user_file)
|
||||
|
||||
if not archive_file:
|
||||
return web.json_response(
|
||||
data={
|
||||
"error": "Archive file is not configured." if not config.keep_archive else "Archive file is not set."
|
||||
},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if not archive_file.exists():
|
||||
return web.json_response(
|
||||
data={"error": f"Archive file '{archive_file}' does not exist."},
|
||||
status=web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
if not remove_from_archive(archive_file=archive_file, url=url):
|
||||
return web.json_response(
|
||||
data={"error": f"item{title} not found in '{archive_file}' archive."},
|
||||
status=web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
data={"message": f"item{title} removed from '{archive_file}' archive."},
|
||||
status=web.HTTPOk.status_code,
|
||||
)
|
||||
|
||||
|
||||
@route("POST", r"api/archive/{id}", "archive.item")
|
||||
async def archive_item(request: Request, queue: DownloadQueue, config: Config):
|
||||
"""
|
||||
Manually mark an item as archived.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
queue (DownloadQueue): The download queue instance.
|
||||
config (Config): The configuration instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
id: str = request.match_info.get("id")
|
||||
|
||||
if not id:
|
||||
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
item: Download | None = queue.done.get_by_id(id)
|
||||
if not item:
|
||||
return web.json_response(data={"error": "item not found."}, status=web.HTTPNotFound.status_code)
|
||||
except KeyError:
|
||||
return web.json_response(data={"error": "item not found."}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
if config.manual_archive:
|
||||
manual_archive = Path(config.manual_archive)
|
||||
if manual_archive.exists():
|
||||
exists, idDict = is_downloaded(manual_archive, item.info.url)
|
||||
if exists is False and idDict.get("archive_id"):
|
||||
async with await anyio.open_file(manual_archive, "a") as f:
|
||||
await f.write(f"{idDict['archive_id']} - at: {datetime.now(UTC).isoformat()}\n")
|
||||
|
||||
params: YTDLPOpts = YTDLPOpts.get_instance().preset(name=item.info.preset)
|
||||
if item.info.cli:
|
||||
params.add_cli(item.info.cli, from_user=True)
|
||||
|
||||
params = params.get_all()
|
||||
|
||||
user_file: str | None = params.get("download_archive", None)
|
||||
archive_file: Path = Path(user_file) if user_file else Path(config.archive_file)
|
||||
if not archive_file.exists():
|
||||
return web.json_response(
|
||||
data={"error": f"Archive file '{archive_file}' does not exist."},
|
||||
status=web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
exists, idDict = is_downloaded(archive_file, item.info.url)
|
||||
item_id: str | None = idDict.get("archive_id")
|
||||
if not item_id:
|
||||
return web.json_response(
|
||||
data={"error": "item does not have an archive ID."}, status=web.HTTPBadRequest.status_code
|
||||
)
|
||||
|
||||
if exists is True:
|
||||
return web.json_response(
|
||||
data={"error": f"item '{item_id}' already archived in file '{archive_file}'."},
|
||||
status=web.HTTPConflict.status_code,
|
||||
)
|
||||
|
||||
async with await anyio.open_file(archive_file, "a") as f:
|
||||
await f.write(f"{item_id}\n")
|
||||
|
||||
return web.json_response(
|
||||
data={"message": f"item '{item_id}' archived in file '{archive_file}'."},
|
||||
status=web.HTTPOk.status_code,
|
||||
)
|
||||
157
app/routes/api/browser.py
Normal file
157
app/routes/api/browser.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import logging
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote_plus
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.ffprobe import ffprobe
|
||||
from app.library.router import route
|
||||
from app.library.Utils import get_file, get_file_sidecar, get_files, get_mime_type
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route("GET", "api/file/ffprobe/{file:.*}", "ffprobe")
|
||||
async def get_ffprobe(request: Request, config: Config, encoder: Encoder, app: web.Application) -> Response:
|
||||
"""
|
||||
Get the ffprobe data.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration object.
|
||||
encoder (Encoder): The encoder object.
|
||||
app (web.Application): The aiohttp application object.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
file: str = request.match_info.get("file")
|
||||
if not file:
|
||||
return web.json_response(data={"error": "file is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
realFile, status = get_file(download_path=config.download_path, file=file)
|
||||
if web.HTTPFound.status_code == status:
|
||||
return Response(
|
||||
status=web.HTTPFound.status_code,
|
||||
headers={
|
||||
"Location": str(
|
||||
app.router["ffprobe"].url_for(
|
||||
file=str(realFile.relative_to(config.download_path).as_posix()).strip("/")
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if web.HTTPNotFound.status_code == status:
|
||||
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||
|
||||
return web.json_response(data=await ffprobe(realFile), status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
except Exception as e:
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
|
||||
|
||||
|
||||
@route("GET", "api/file/info/{file:.*}", "file_info")
|
||||
async def get_file_info(request: Request, config: Config, encoder: Encoder, app: web.Application) -> Response:
|
||||
"""
|
||||
Get file info
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration object.
|
||||
encoder (Encoder): The encoder object.
|
||||
app (web.Application): The aiohttp application object.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
file: str = request.match_info.get("file")
|
||||
if not file:
|
||||
return web.json_response(data={"error": "file is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
realFile, status = get_file(download_path=config.download_path, file=file)
|
||||
if web.HTTPFound.status_code == status:
|
||||
return Response(
|
||||
status=web.HTTPFound.status_code,
|
||||
headers={
|
||||
"Location": str(
|
||||
app.router["file_info"].url_for(
|
||||
file=str(realFile.relative_to(config.download_path).as_posix()).strip("/")
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if web.HTTPNotFound.status_code == status:
|
||||
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||
|
||||
ff_info = await ffprobe(realFile)
|
||||
|
||||
response = {
|
||||
"title": realFile.stem,
|
||||
"ffprobe": ff_info,
|
||||
"mimetype": get_mime_type(ff_info.get("metadata", {}), realFile),
|
||||
"sidecar": get_file_sidecar(realFile),
|
||||
}
|
||||
|
||||
for key in response["sidecar"]:
|
||||
for i, f in enumerate(response["sidecar"][key]):
|
||||
response["sidecar"][key][i]["file"] = str(
|
||||
realFile.with_name(f["file"].name).relative_to(config.download_path)
|
||||
).strip("/")
|
||||
|
||||
return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
|
||||
|
||||
|
||||
@route("GET", "api/file/browser/{path:.*}", "file_browser")
|
||||
async def file_browser(request: Request, config: Config, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Get the file browser.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration object.
|
||||
encoder (Encoder): The encoder object.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
if not config.browser_enabled:
|
||||
return web.json_response(data={"error": "File browser is disabled."}, status=web.HTTPForbidden.status_code)
|
||||
|
||||
req_path: str = request.match_info.get("path")
|
||||
req_path: str = "/" if not req_path else unquote_plus(req_path)
|
||||
|
||||
test: Path = Path(config.download_path).joinpath(req_path)
|
||||
if not test.exists():
|
||||
return web.json_response(
|
||||
data={"error": f"path '{req_path}' does not exist."}, status=web.HTTPNotFound.status_code
|
||||
)
|
||||
|
||||
if not test.is_dir() and not test.is_symlink():
|
||||
return web.json_response(
|
||||
data={"error": f"path '{req_path}' is not a directory."}, status=web.HTTPBadRequest.status_code
|
||||
)
|
||||
|
||||
try:
|
||||
return web.json_response(
|
||||
data={
|
||||
"path": req_path,
|
||||
"contents": get_files(base_path=Path(config.download_path), dir=req_path),
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
except OSError as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
|
||||
191
app/routes/api/conditions.py
Normal file
191
app/routes/api/conditions.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import logging
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.cache import Cache
|
||||
from app.library.conditions import Condition, Conditions
|
||||
from app.library.config import Config
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.router import route
|
||||
from app.library.Utils import extract_info, init_class, validate_uuid
|
||||
from app.library.YTDLPOpts import YTDLPOpts
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route("GET", "api/conditions/", "conditions_list")
|
||||
async def conditions_list(encoder: Encoder) -> Response:
|
||||
"""
|
||||
Get the conditions
|
||||
|
||||
Args:
|
||||
encoder (Encoder): The encoder instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
return web.json_response(
|
||||
data=Conditions.get_instance().get_all(),
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("PUT", "api/conditions/", "conditions_add")
|
||||
async def conditions_add(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
"""
|
||||
Save Conditions.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
notify (EventBus): The event bus instance.
|
||||
|
||||
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": "command options for yt-dlp is required.", "data": item},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if not item.get("id", None) or not validate_uuid(item.get("id"), version=4):
|
||||
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(init_class(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 notify.emit(Events.CONDITIONS_UPDATE, data=items)
|
||||
return web.json_response(data=items, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("POST", "api/conditions/test/", "conditions_test")
|
||||
async def conditions_test(request: Request, encoder: Encoder, cache: Cache, config: Config) -> Response:
|
||||
"""
|
||||
Test condition against URL.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
cache (Cache): The cache instance.
|
||||
config (Config): The configuration instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object
|
||||
|
||||
"""
|
||||
params = await request.json()
|
||||
|
||||
if not isinstance(params, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting dict."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
url: str | None = params.get("url")
|
||||
if not url:
|
||||
return web.json_response({"error": "url is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
cond: str | None = params.get("condition")
|
||||
if not cond:
|
||||
return web.json_response({"error": "condition is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
preset: str = params.get("preset", config.default_preset)
|
||||
key: str = cache.hash(url + str(preset))
|
||||
if not cache.has(key):
|
||||
opts = {}
|
||||
if ytdlp_proxy := config.get_ytdlp_args().get("proxy", None):
|
||||
opts["proxy"] = ytdlp_proxy
|
||||
ytdlp_opts = YTDLPOpts.get_instance().preset(name=preset).add(opts).get_all()
|
||||
|
||||
data = extract_info(
|
||||
config=ytdlp_opts,
|
||||
url=url,
|
||||
debug=False,
|
||||
no_archive=True,
|
||||
follow_redirect=True,
|
||||
sanitize_info=True,
|
||||
)
|
||||
if not data:
|
||||
return web.json_response(
|
||||
data={"error": f"Failed to extract info from '{url!s}'."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
cache.set(key=key, value=data, ttl=600)
|
||||
else:
|
||||
data = cache.get(key)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(
|
||||
data={"error": f"Failed to extract video info. '{e!s}'"},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
from yt_dlp.utils import match_str
|
||||
|
||||
status = match_str(cond, data)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(
|
||||
data={"error": str(e)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
data={"status": status, "condition": cond, "data": data},
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
180
app/routes/api/dev.py
Normal file
180
app/routes/api/dev.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library import DownloadQueue
|
||||
from app.library.config import Config
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.router import route
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route("GET", "api/dev/loop/", "debug_loop")
|
||||
async def debug_asyncio(config: Config, encoder: Encoder) -> Response:
|
||||
if not config.is_dev():
|
||||
return web.json_response(
|
||||
data={"error": "This endpoint is only available in development mode."},
|
||||
status=web.HTTPForbidden.status_code,
|
||||
)
|
||||
|
||||
import traceback
|
||||
|
||||
tasks = []
|
||||
for task in asyncio.all_tasks():
|
||||
task_info = {"task": str(task), "stack": []}
|
||||
for frame in task.get_stack():
|
||||
formatted = traceback.format_stack(f=frame)
|
||||
task_info["stack"].extend(formatted)
|
||||
|
||||
tasks.append(task_info)
|
||||
|
||||
return web.json_response(
|
||||
data={
|
||||
"total_tasks": len(tasks),
|
||||
"loop": str(asyncio.get_event_loop()),
|
||||
"tasks": tasks,
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("GET", "api/dev/workers/", "pool_list")
|
||||
async def pool_list(config: Config, queue: DownloadQueue) -> Response:
|
||||
"""
|
||||
Get the workers status.
|
||||
|
||||
Args:
|
||||
config (Config): The configuration object.
|
||||
queue (DownloadQueue): The download queue object.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
if not config.is_dev():
|
||||
return web.json_response(
|
||||
{"error": "This endpoint is only available in development mode."},
|
||||
status=web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
if queue.pool is None:
|
||||
return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
status = queue.pool.get_workers_status()
|
||||
|
||||
data = []
|
||||
|
||||
for worker in status:
|
||||
worker_status = status.get(worker)
|
||||
data.append(
|
||||
{
|
||||
"id": worker,
|
||||
"data": {"status": "Waiting for download."} if worker_status is None else worker_status,
|
||||
}
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
data={
|
||||
"open": queue.pool.has_open_workers(),
|
||||
"count": queue.pool.get_available_workers(),
|
||||
"workers": data,
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=lambda obj: json.dumps(obj, default=lambda o: f"<<non-serializable: {type(o).__qualname__}>>"),
|
||||
)
|
||||
|
||||
|
||||
@route("POST", "api/dev/workers/", "pool_start")
|
||||
async def pool_restart(config: Config, queue: DownloadQueue) -> Response:
|
||||
"""
|
||||
Restart the workers pool.
|
||||
|
||||
Args:
|
||||
config (Config): The configuration object.
|
||||
queue (DownloadQueue): The download queue object.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
if not config.is_dev():
|
||||
return web.json_response(
|
||||
{"error": "This endpoint is only available in development mode."},
|
||||
status=web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
if queue.pool is None:
|
||||
return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
queue.pool.start()
|
||||
|
||||
return web.json_response({"message": "Workers pool being restarted."}, status=web.HTTPOk.status_code)
|
||||
|
||||
|
||||
@route("PATCH", "api/dev/workers/{id}", "worker_restart")
|
||||
async def worker_restart(request: Request, config: Config, queue: DownloadQueue) -> Response:
|
||||
"""
|
||||
Restart a worker.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration object.
|
||||
queue (DownloadQueue): The download queue object.
|
||||
|
||||
Returns:
|
||||
Response: The response object
|
||||
|
||||
"""
|
||||
if not config.is_dev():
|
||||
return web.json_response(
|
||||
{"error": "This endpoint is only available in development mode."},
|
||||
status=web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
worker_id: str = request.match_info.get("id")
|
||||
if not worker_id:
|
||||
return web.json_response({"error": "worker id is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if queue.pool is None:
|
||||
return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
status = await queue.pool.restart(worker_id, "requested by user.")
|
||||
|
||||
return web.json_response({"status": "restarted" if status else "in_error_state"}, status=web.HTTPOk.status_code)
|
||||
|
||||
|
||||
@route("DELETE", "api/dev/workers/{id}", "worker_stop")
|
||||
async def worker_stop(request: Request, config: Config, queue: DownloadQueue) -> Response:
|
||||
"""
|
||||
Stop a worker.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration object.
|
||||
queue (DownloadQueue): The download queue object.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
if not config.is_dev():
|
||||
return web.json_response(
|
||||
{"error": "This endpoint is only available in development mode."},
|
||||
status=web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
worker_id: str = request.match_info.get("id")
|
||||
if not worker_id:
|
||||
raise web.HTTPBadRequest(text="worker id is required.")
|
||||
|
||||
if queue.pool is None:
|
||||
return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
status = await queue.pool.stop(worker_id, "requested by user.")
|
||||
|
||||
return web.json_response({"status": "stopped" if status else "in_error_state"}, status=web.HTTPOk.status_code)
|
||||
201
app/routes/api/history.py
Normal file
201
app/routes/api/history.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.DownloadQueue import DownloadQueue
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.ItemDTO import Item
|
||||
from app.library.Presets import Preset, Presets
|
||||
from app.library.router import route
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from library.Download import Download
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route("GET", r"api/history/", "items_list")
|
||||
async def items_list(queue: DownloadQueue, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Get the history.
|
||||
|
||||
Args:
|
||||
queue (DownloadQueue): The download queue instance.
|
||||
encoder (Encoder): The encoder instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
data: dict = {"queue": [], "history": []}
|
||||
q = queue.get()
|
||||
|
||||
data["queue"].extend([q.get("queue", {}).get(k) for k in q.get("queue", {})])
|
||||
data["history"].extend([q.get("done", {}).get(k) for k in q.get("done", {})])
|
||||
|
||||
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("DELETE", "api/history/", "item_delete")
|
||||
async def item_delete(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Delete an item from the queue.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
queue (DownloadQueue): The download queue instance.
|
||||
encoder (Encoder): The encoder instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
data = await request.json()
|
||||
ids = data.get("ids")
|
||||
where = data.get("where")
|
||||
if not ids or where not in ["queue", "done"]:
|
||||
return web.json_response(data={"error": "ids and where are required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
remove_file: bool = bool(data.get("remove_file", True))
|
||||
|
||||
return web.json_response(
|
||||
data=await (queue.cancel(ids) if where == "queue" else queue.clear(ids, remove_file=remove_file)),
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("POST", "api/history/{id}", "item_update")
|
||||
async def item_update(request: Request, queue: DownloadQueue, encoder: Encoder, notify: EventBus) -> Response:
|
||||
"""
|
||||
Update an item in the history.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
queue (DownloadQueue): The download queue instance.
|
||||
encoder (Encoder): The encoder instance.
|
||||
notify (EventBus): The event bus instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
id: str = request.match_info.get("id")
|
||||
if not id:
|
||||
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
item: Download | None = queue.done.get_by_id(id)
|
||||
if not item:
|
||||
return web.json_response(data={"error": "item not found."}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
post = await request.json()
|
||||
if not post:
|
||||
return web.json_response(data={"error": "no data provided."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
updated = False
|
||||
|
||||
for k, v in post.items():
|
||||
if not hasattr(item.info, k):
|
||||
continue
|
||||
|
||||
if getattr(item.info, k) == v:
|
||||
continue
|
||||
|
||||
updated = True
|
||||
setattr(item.info, k, v)
|
||||
LOG.debug(f"Updated '{k}' to '{v}' for '{item.info.id}'")
|
||||
|
||||
if updated:
|
||||
queue.done.put(item)
|
||||
await notify.emit(Events.UPDATE, data=item.info)
|
||||
|
||||
return web.json_response(
|
||||
data=item.info,
|
||||
status=web.HTTPOk.status_code if updated else web.HTTPNotModified.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("GET", "api/history/add/", "item_add")
|
||||
async def item_add(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Add a URL to the download queue.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
queue (DownloadQueue): The download queue instance.
|
||||
encoder (Encoder): The encoder instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object
|
||||
|
||||
"""
|
||||
url: str | None = request.query.get("url")
|
||||
if not url:
|
||||
return web.json_response(data={"error": "url param is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
data: dict[str, str] = {"url": url}
|
||||
|
||||
preset: str | None = request.query.get("preset")
|
||||
if preset:
|
||||
exists: Preset | None = Presets.get_instance().get(preset)
|
||||
if not exists:
|
||||
return web.json_response(
|
||||
data={"status": False, "message": f"Preset '{preset}' does not exist."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
data["preset"] = preset
|
||||
|
||||
try:
|
||||
status: dict[str, str] = await queue.add(item=Item.format(data))
|
||||
except ValueError as e:
|
||||
return web.json_response(data={"status": False, "message": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
return web.json_response(
|
||||
data={"status": status.get("status") == "ok", "message": status.get("msg", "URL added")},
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("POST", "api/history/", "items_add")
|
||||
async def items_add(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Add a URL to the download queue.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
queue (DownloadQueue): The download queue instance.
|
||||
encoder (Encoder): The encoder instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
data = await request.json()
|
||||
|
||||
if isinstance(data, dict):
|
||||
data = [data]
|
||||
|
||||
items = []
|
||||
for item in data:
|
||||
try:
|
||||
items.append(Item.format(item))
|
||||
except ValueError as e:
|
||||
return web.json_response(data={"error": str(e), "data": item}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
status: list = await asyncio.wait_for(
|
||||
fut=asyncio.gather(*[queue.add(item=item) for item in items]),
|
||||
timeout=None,
|
||||
)
|
||||
|
||||
response: list = []
|
||||
|
||||
for i, item in enumerate(items):
|
||||
response.append({"item": item, "status": "ok" == status[i].get("status"), "msg": status[i].get("msg")})
|
||||
|
||||
return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
186
app/routes/api/images.py
Normal file
186
app/routes/api/images.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import logging
|
||||
import random
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.ag_utils import ag
|
||||
from app.library.cache import Cache
|
||||
from app.library.config import Config
|
||||
from app.library.router import route
|
||||
from app.library.Utils import validate_url
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
IS_REQUESTING_BACKGROUND: bool = False
|
||||
|
||||
|
||||
@route("GET", "api/thumbnail/", "get_thumbnail")
|
||||
async def get_thumbnail(request: Request, config: Config) -> Response:
|
||||
"""
|
||||
Get the thumbnail.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration object.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
url = request.query.get("url")
|
||||
if not url:
|
||||
return web.json_response(data={"error": "URL is required."}, status=web.HTTPForbidden.status_code)
|
||||
|
||||
try:
|
||||
validate_url(url)
|
||||
except ValueError as e:
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPForbidden.status_code)
|
||||
|
||||
try:
|
||||
ytdlp_args = config.get_ytdlp_args()
|
||||
opts = {
|
||||
"proxy": ytdlp_args.get("proxy", None),
|
||||
"headers": {
|
||||
"User-Agent": ytdlp_args.get(
|
||||
"user_agent", request.headers.get("User-Agent", f"YTPTube/{config.version}")
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(**opts) as client:
|
||||
LOG.debug(f"Fetching thumbnail from '{url}'.")
|
||||
response = await client.request(method="GET", url=url)
|
||||
return web.Response(
|
||||
body=response.content,
|
||||
headers={
|
||||
"Content-Type": response.headers.get("Content-Type"),
|
||||
"Pragma": "public",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Cache-Control": f"public, max-age={time.time() + 31536000}",
|
||||
"Expires": time.strftime(
|
||||
"%a, %d %b %Y %H:%M:%S GMT",
|
||||
datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple(),
|
||||
),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.error(f"Error fetching thumbnail from '{url}'. '{e}'.")
|
||||
return web.json_response(
|
||||
data={"error": "failed to retrieve the thumbnail."}, status=web.HTTPInternalServerError.status_code
|
||||
)
|
||||
|
||||
|
||||
@route("GET", "api/random/background/", "get_background")
|
||||
async def get_background(request: Request, config: Config, cache: Cache) -> Response:
|
||||
"""
|
||||
Get random background.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration object.
|
||||
cache (Cache): The cache object.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
global IS_REQUESTING_BACKGROUND # noqa: PLW0603
|
||||
|
||||
backend = None
|
||||
|
||||
if IS_REQUESTING_BACKGROUND:
|
||||
return web.Response(status=web.HTTPTooManyRequests.status_code)
|
||||
|
||||
try:
|
||||
IS_REQUESTING_BACKGROUND = True
|
||||
backend = random.choice(config.pictures_backends) # noqa: S311
|
||||
CACHE_KEY_BING = "random_background_bing"
|
||||
CACHE_KEY = "random_background"
|
||||
|
||||
if cache.has(CACHE_KEY) and not request.query.get("force", False):
|
||||
data = await cache.aget(CACHE_KEY)
|
||||
return web.Response(
|
||||
body=data.get("content"),
|
||||
headers={
|
||||
"X-Cache": "HIT",
|
||||
"X-Cache-TTL": str(await cache.attl(CACHE_KEY)),
|
||||
"X-Image-Via": data.get("backend"),
|
||||
**data.get("headers"),
|
||||
},
|
||||
)
|
||||
|
||||
ytdlp_args = config.get_ytdlp_args()
|
||||
opts = {
|
||||
"proxy": ytdlp_args.get("proxy", None),
|
||||
"headers": {
|
||||
"User-Agent": ytdlp_args.get("user_agent", f"YTPTube/{config.version}"),
|
||||
},
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(**opts) as client:
|
||||
if backend.startswith("https://www.bing.com/HPImageArchive.aspx"):
|
||||
if not cache.has(CACHE_KEY_BING):
|
||||
response = await client.request(method="GET", url=backend)
|
||||
if response.status_code != web.HTTPOk.status_code:
|
||||
return web.json_response(
|
||||
data={"error": "failed to retrieve the random background image."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
img_url: str | None = ag(response.json(), "images.0.url")
|
||||
if not img_url:
|
||||
return web.json_response(
|
||||
data={"error": "failed to retrieve the random background image."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
backend = f"https://www.bing.com{img_url}"
|
||||
await cache.aset(key=CACHE_KEY_BING, value=backend, ttl=3600 * 24)
|
||||
else:
|
||||
backend: str = await cache.aget(CACHE_KEY_BING)
|
||||
|
||||
LOG.debug(f"Requesting random picture from '{backend!s}'.")
|
||||
|
||||
response = await client.request(method="GET", url=backend, follow_redirects=True)
|
||||
|
||||
if response.status_code != web.HTTPOk.status_code:
|
||||
return web.json_response(
|
||||
data={"error": "failed to retrieve the random background image."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
data = {
|
||||
"content": response.content,
|
||||
"backend": urlparse(backend).netloc,
|
||||
"headers": {
|
||||
"Content-Type": response.headers.get("Content-Type", "image/jpeg"),
|
||||
"Content-Length": str(len(response.content)),
|
||||
},
|
||||
}
|
||||
|
||||
await cache.aset(key=CACHE_KEY, value=data, ttl=3600)
|
||||
|
||||
LOG.debug(f"Random background image from '{backend!s}' cached.")
|
||||
|
||||
return web.Response(
|
||||
body=data.get("content"),
|
||||
headers={
|
||||
"X-Cache": "MISS",
|
||||
"X-Cache-TTL": "3600",
|
||||
"X-Image-Via": data.get("backend"),
|
||||
**data.get("headers"),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to request random background image from '{backend!s}'.'. '{e!s}'.")
|
||||
return web.json_response(
|
||||
data={"error": "failed to retrieve the random background image."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
finally:
|
||||
IS_REQUESTING_BACKGROUND = False
|
||||
50
app/routes/api/logs.py
Normal file
50
app/routes/api/logs.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.router import route
|
||||
from app.library.Utils import read_logfile
|
||||
|
||||
|
||||
@route("GET", "api/logs/", "logs")
|
||||
async def logs(request: Request, config: Config, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Get recent logs
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration instance.
|
||||
encoder (Encoder): The encoder instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
if not config.file_logging:
|
||||
return web.json_response(data={"error": "File logging is not enabled."}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
offset = int(request.query.get("offset", 0))
|
||||
limit = int(request.query.get("limit", 100))
|
||||
if limit < 1 or limit > 150:
|
||||
limit = 50
|
||||
|
||||
logs_data = await read_logfile(
|
||||
file=Path(config.config_path) / "logs" / "app.log",
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
data={
|
||||
"logs": logs_data["logs"],
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"next_offset": logs_data["next_offset"],
|
||||
"end_is_reached": logs_data["end_is_reached"],
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
111
app/routes/api/notifications.py
Normal file
111
app/routes/api/notifications.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import logging
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events, message
|
||||
from app.library.Notifications import Notification, NotificationEvents
|
||||
from app.library.router import route
|
||||
from app.library.Utils import validate_uuid
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route("GET", "api/notifications/", "notifications_list")
|
||||
async def notifications_list(encoder: Encoder) -> Response:
|
||||
"""
|
||||
Get the notification targets.
|
||||
|
||||
Args:
|
||||
encoder (Encoder): The encoder instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
return web.json_response(
|
||||
data={
|
||||
"notifications": Notification.get_instance().get_targets(),
|
||||
"allowedTypes": list(NotificationEvents.get_events().values()),
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("PUT", "api/notifications/", "notification_add")
|
||||
async def notification_add(request: Request, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Add notification targets.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
notify (EventBus): The event bus instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
post = await request.json()
|
||||
if not isinstance(post, list):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting list with dicts."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
targets: list = []
|
||||
|
||||
ins: Notification = Notification.get_instance()
|
||||
for item in post:
|
||||
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("id", None) or validate_uuid(item.get("id"), version=4):
|
||||
item["id"] = str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
Notification.validate(item)
|
||||
except ValueError as e:
|
||||
return web.json_response(
|
||||
{"error": f"Invalid notification target settings. {e!s}", "data": item},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
targets.append(ins.make_target(item))
|
||||
|
||||
try:
|
||||
ins.save(targets=targets)
|
||||
ins.load()
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response({"error": "Failed to save tasks."}, status=web.HTTPInternalServerError.status_code)
|
||||
|
||||
data = {"notifications": targets, "allowedTypes": list(NotificationEvents.get_events().values())}
|
||||
|
||||
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("POST", "api/notifications/test/", "notification_test")
|
||||
async def notification_test(encoder: Encoder, notify: EventBus) -> Response:
|
||||
"""
|
||||
Test the notification.
|
||||
|
||||
Args:
|
||||
encoder (Encoder): The encoder instance.
|
||||
notify (EventBus): The event bus instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
data = message("test", "This is a test notification.")
|
||||
|
||||
await notify.emit(Events.TEST, data=data)
|
||||
|
||||
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
19
app/routes/api/ping.py
Normal file
19
app/routes/api/ping.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from aiohttp import web
|
||||
from aiohttp.web import Response
|
||||
|
||||
from app.library.DownloadQueue import DownloadQueue
|
||||
from app.library.router import route
|
||||
|
||||
|
||||
@route("GET", "api/ping/", "ping")
|
||||
async def ping(queue: DownloadQueue) -> Response:
|
||||
"""
|
||||
Ping the server.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
await queue.queue.test()
|
||||
|
||||
return web.json_response(data={"status": "pong"}, status=web.HTTPOk.status_code)
|
||||
278
app/routes/api/player.py
Normal file
278
app/routes/api/player.py
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.M3u8 import M3u8
|
||||
from app.library.Playlist import Playlist
|
||||
from app.library.router import route
|
||||
from app.library.Segments import Segments
|
||||
from app.library.Subtitle import Subtitle
|
||||
from app.library.Utils import StreamingError, get_file
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route("GET", "api/player/playlist/{file:.*}.m3u8", "playlist_create")
|
||||
async def playlist_create(request: Request, config: Config, app: web.Application) -> Response:
|
||||
"""
|
||||
Get the playlist.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration instance.
|
||||
app (web.Application): The aiohttp application instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
file: str = request.match_info.get("file")
|
||||
|
||||
if not file:
|
||||
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
base_path: str = config.base_path.rstrip("/")
|
||||
|
||||
try:
|
||||
realFile, status = get_file(download_path=config.download_path, file=file)
|
||||
if web.HTTPFound.status_code == status:
|
||||
return Response(
|
||||
status=web.HTTPFound.status_code,
|
||||
headers={
|
||||
"Location": str(
|
||||
app.router["playlist_create"].url_for(
|
||||
file=str(realFile).replace(config.download_path, "").strip("/")
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if web.HTTPNotFound.status_code == status:
|
||||
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||
|
||||
return web.Response(
|
||||
text=await Playlist(download_path=Path(config.download_path), url=f"{base_path}/").make(file=realFile),
|
||||
headers={
|
||||
"Content-Type": "application/x-mpegURL",
|
||||
"Cache-Control": "no-cache",
|
||||
"Access-Control-Max-Age": "300",
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
)
|
||||
except StreamingError as e:
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
|
||||
@route("GET", "api/player/m3u8/{mode}/{file:.*}.m3u8", "m3u8_create")
|
||||
async def m3u8_create(request: Request, config: Config, app: web.Application) -> Response:
|
||||
"""
|
||||
Get the m3u8 file.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration instance.
|
||||
app (web.Application): The aiohttp application instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
file: str = request.match_info.get("file")
|
||||
mode: str = request.match_info.get("mode")
|
||||
|
||||
if mode not in ["video", "subtitle"]:
|
||||
return web.json_response(
|
||||
data={"error": "Only video and subtitle modes are supported."}, status=web.HTTPBadRequest.status_code
|
||||
)
|
||||
|
||||
if not file:
|
||||
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
duration = request.query.get("duration", None)
|
||||
|
||||
if "subtitle" in mode:
|
||||
if not duration:
|
||||
return web.json_response(data={"error": "duration is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
duration = float(duration)
|
||||
|
||||
base_path: str = config.base_path.rstrip("/")
|
||||
|
||||
try:
|
||||
cls = M3u8(download_path=Path(config.download_path), url=f"{base_path}/")
|
||||
|
||||
realFile, status = get_file(download_path=config.download_path, file=file)
|
||||
if web.HTTPFound.status_code == status:
|
||||
return Response(
|
||||
status=status,
|
||||
headers={
|
||||
"Location": str(
|
||||
app.router["m3u8_create"].url_for(
|
||||
mode=mode, file=str(realFile).replace(config.download_path, "").strip("/")
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if web.HTTPNotFound.status_code == status:
|
||||
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||
|
||||
if "subtitle" in mode:
|
||||
text = await cls.make_subtitle(file=realFile, duration=duration)
|
||||
else:
|
||||
text = await cls.make_stream(file=realFile)
|
||||
except StreamingError as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
return web.Response(
|
||||
text=text,
|
||||
headers={
|
||||
"Content-Type": "application/x-mpegURL",
|
||||
"Cache-Control": "no-cache",
|
||||
"Access-Control-Max-Age": "300",
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
)
|
||||
|
||||
|
||||
@route("GET", r"api/player/segments/{segment:\d+}/{file:.*}.ts", "segments_stream")
|
||||
async def segments_stream(request: Request, config: Config, app: web.Application) -> Response:
|
||||
"""
|
||||
Get the segments.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration instance.
|
||||
app (web.Application): The aiohttp application instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
file: str = request.match_info.get("file")
|
||||
segment: int = request.match_info.get("segment")
|
||||
sd: int = request.query.get("sd")
|
||||
vc: int = int(request.query.get("vc", 0))
|
||||
ac: int = int(request.query.get("ac", 0))
|
||||
|
||||
if not file:
|
||||
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not segment:
|
||||
return web.json_response(data={"error": "segment id is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
realFile, status = get_file(download_path=config.download_path, file=file)
|
||||
if web.HTTPFound.status_code == status:
|
||||
return Response(
|
||||
status=status,
|
||||
headers={
|
||||
"Location": str(
|
||||
app.router["segments"].url_for(
|
||||
segment=segment,
|
||||
file=str(realFile).replace(config.download_path, "").strip("/"),
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if web.HTTPNotFound.status_code == status:
|
||||
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||
|
||||
mtime = realFile.stat().st_mtime
|
||||
|
||||
if request.if_modified_since and request.if_modified_since.timestamp() == mtime:
|
||||
lastMod = time.strftime("%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple())
|
||||
return web.Response(status=web.HTTPNotModified.status_code, headers={"Last-Modified": lastMod})
|
||||
|
||||
resp = web.StreamResponse(
|
||||
status=web.HTTPOk.status_code,
|
||||
headers={
|
||||
"Content-Type": "video/mpegts",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Pragma": "public",
|
||||
"Cache-Control": f"public, max-age={time.time() + 31536000}",
|
||||
"Last-Modified": time.strftime(
|
||||
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple()
|
||||
),
|
||||
"Expires": time.strftime(
|
||||
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple()
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
await resp.prepare(request)
|
||||
|
||||
await Segments(
|
||||
download_path=config.download_path,
|
||||
index=int(segment),
|
||||
duration=float(f"{float(sd if sd else M3u8.duration):.6f}"),
|
||||
vconvert=vc == 1,
|
||||
aconvert=ac == 1,
|
||||
).stream(realFile, resp)
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
@route("GET", "api/player/subtitle/{file:.*}.vtt", "subtitles_get")
|
||||
async def subtitles_get(request: Request, config: Config, app: web.Application) -> Response:
|
||||
"""
|
||||
Get the subtitles.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration instance.
|
||||
app (web.Application): The aiohttp application instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
file: str = request.match_info.get("file")
|
||||
|
||||
if not file:
|
||||
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
realFile, status = get_file(download_path=config.download_path, file=file)
|
||||
if web.HTTPFound.status_code == status:
|
||||
return Response(
|
||||
status=status,
|
||||
headers={
|
||||
"Location": str(
|
||||
app.router["subtitles_get"].url_for(file=str(realFile).replace(config.download_path, "").strip("/"))
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if web.HTTPNotFound.status_code == status:
|
||||
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||
|
||||
mtime = realFile.stat().st_mtime
|
||||
|
||||
if request.if_modified_since and request.if_modified_since.timestamp() == mtime:
|
||||
lastMod = time.strftime("%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple())
|
||||
return web.Response(status=web.HTTPNotModified.status_code, headers={"Last-Modified": lastMod})
|
||||
|
||||
return web.Response(
|
||||
body=await Subtitle().make(file=realFile),
|
||||
headers={
|
||||
"Content-Type": "text/vtt; charset=UTF-8",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Pragma": "public",
|
||||
"Cache-Control": f"public, max-age={time.time() + 31536000}",
|
||||
"Last-Modified": time.strftime(
|
||||
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple()
|
||||
),
|
||||
"Expires": time.strftime(
|
||||
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple()
|
||||
),
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
)
|
||||
100
app/routes/api/presets.py
Normal file
100
app/routes/api/presets.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import logging
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.Presets import Preset, Presets
|
||||
from app.library.router import route
|
||||
from app.library.Utils import init_class, validate_uuid
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route("GET", "api/presets/", "presets")
|
||||
async def presets(request: Request, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Get the presets.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
data: list[Preset] = Presets.get_instance().get_all()
|
||||
filter_fields: str | None = request.query.get("filter", None)
|
||||
|
||||
if filter_fields:
|
||||
fields: list[str] = [field.strip() for field in filter_fields.split(",")]
|
||||
data = [{key: value for key, value in preset.serialize().items() if key in fields} for preset in data]
|
||||
|
||||
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("PUT", "api/presets/", "presets_add")
|
||||
async def presets_add(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
"""
|
||||
Add presets.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
notify (EventBus): The event bus instance.
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
presets: list = []
|
||||
|
||||
cls = Presets.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("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 preset '{item.get('name')}'. '{e!s}'"},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
presets.append(init_class(Preset, item))
|
||||
|
||||
try:
|
||||
presets = cls.save(items=presets).load().get_all()
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(
|
||||
{"error": "Failed to save presets.", "message": str(e)},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
await notify.emit(Events.PRESETS_UPDATE, data=presets)
|
||||
return web.json_response(data=presets, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
93
app/routes/api/tasks.py
Normal file
93
app/routes/api/tasks.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import logging
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.router import route
|
||||
from app.library.Tasks import Task, Tasks
|
||||
from app.library.Utils import init_class, validate_uuid
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route("GET", "api/tasks/", "tasks")
|
||||
async def tasks(encoder: Encoder) -> Response:
|
||||
"""
|
||||
Get the tasks.
|
||||
|
||||
Args:
|
||||
encoder (Encoder): The encoder instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
return web.json_response(data=Tasks.get_instance().get_all(), status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("PUT", "api/tasks/", "tasks_add")
|
||||
async def tasks_add(request: Request, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Add tasks to the queue.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
tasks: list = []
|
||||
|
||||
ins = Tasks.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("url"):
|
||||
return web.json_response({"error": "url 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())
|
||||
|
||||
if not item.get("template", None):
|
||||
item["template"] = ""
|
||||
|
||||
if not item.get("cli", None):
|
||||
item["cli"] = ""
|
||||
|
||||
try:
|
||||
Tasks.validate(item)
|
||||
except ValueError as e:
|
||||
return web.json_response(
|
||||
{"error": f"Failed to validate task '{item.get('name')}'. '{e!s}'"},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
tasks.append(init_class(Task, item))
|
||||
|
||||
try:
|
||||
tasks = ins.save(tasks=tasks).load().get_all()
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(
|
||||
{"error": "Failed to save tasks.", "message": str(e)},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
return web.json_response(data=tasks, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
258
app/routes/api/yt_dlp.py
Normal file
258
app/routes/api/yt_dlp.py
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.cache import Cache
|
||||
from app.library.config import Config
|
||||
from app.library.Presets import Preset, Presets
|
||||
from app.library.router import route
|
||||
from app.library.Utils import REMOVE_KEYS, arg_converter, extract_info, validate_url
|
||||
from app.library.YTDLPOpts import YTDLPOpts
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route("POST", "api/yt-dlp/convert/", "convert")
|
||||
async def convert(request: Request) -> Response:
|
||||
"""
|
||||
Convert the yt-dlp args to a dict.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
post = await request.json()
|
||||
args: str | None = post.get("args")
|
||||
|
||||
if not args:
|
||||
return web.json_response(data={"error": "args param is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
response = {"opts": {}, "output_template": None, "download_path": None}
|
||||
|
||||
data = arg_converter(args, dumps=True)
|
||||
|
||||
if "outtmpl" in data and "default" in data["outtmpl"]:
|
||||
response["output_template"] = data["outtmpl"]["default"]
|
||||
|
||||
if "paths" in data and "home" in data["paths"]:
|
||||
response["download_path"] = data["paths"]["home"]
|
||||
|
||||
if "format" in data:
|
||||
response["format"] = data["format"]
|
||||
|
||||
bad_options = {k: v for d in REMOVE_KEYS for k, v in d.items()}
|
||||
removed_options = []
|
||||
|
||||
for key in data:
|
||||
if key in bad_options.items():
|
||||
removed_options.append(bad_options[key])
|
||||
continue
|
||||
if not key.startswith("_"):
|
||||
response["opts"][key] = data[key]
|
||||
|
||||
if len(removed_options) > 0:
|
||||
response["removed_options"] = removed_options
|
||||
|
||||
return web.json_response(data=response, status=web.HTTPOk.status_code)
|
||||
except Exception as e:
|
||||
err = str(e).strip()
|
||||
err = err.split("\n")[-1] if "\n" in err else err
|
||||
err = err.replace("main.py: error: ", "").strip().capitalize()
|
||||
return web.json_response(
|
||||
data={"error": f"Failed to parse command options for yt-dlp. '{err}'."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
|
||||
@route("GET", "api/yt-dlp/url/info/", "get_info")
|
||||
async def get_info(request: Request, cache: Cache) -> Response:
|
||||
"""
|
||||
Get the video info.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
cache (Cache): The cache instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object
|
||||
|
||||
"""
|
||||
url: str | None = request.query.get("url")
|
||||
if not url:
|
||||
return web.json_response(data={"error": "URL is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
validate_url(url)
|
||||
except ValueError as e:
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
config = Config.get_instance()
|
||||
|
||||
preset = request.query.get("preset")
|
||||
if preset:
|
||||
exists: Preset | None = Presets.get_instance().get(preset)
|
||||
if not exists:
|
||||
return web.json_response(
|
||||
data={"status": False, "message": f"Preset '{preset}' does not exist."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
else:
|
||||
preset: str = config.default_preset
|
||||
|
||||
try:
|
||||
key: str = cache.hash(f"{preset}:{url}")
|
||||
|
||||
if cache.has(key) and not request.query.get("force", False):
|
||||
data: Any | None = cache.get(key)
|
||||
data["_cached"] = {
|
||||
"status": "hit",
|
||||
"key": key,
|
||||
"ttl": data.get("_cached", {}).get("ttl", 300),
|
||||
"ttl_left": data.get("_cached", {}).get("expires", time.time() + 300) - time.time(),
|
||||
"expires": data.get("_cached", {}).get("expires", time.time() + 300),
|
||||
}
|
||||
return web.Response(body=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code)
|
||||
|
||||
opts: dict = {}
|
||||
|
||||
if ytdlp_proxy := config.get_ytdlp_args().get("proxy", None):
|
||||
opts["proxy"] = ytdlp_proxy
|
||||
|
||||
ytdlp_opts = YTDLPOpts.get_instance().preset(name=preset).add(opts).get_all()
|
||||
|
||||
data = extract_info(
|
||||
config=ytdlp_opts,
|
||||
url=url,
|
||||
debug=False,
|
||||
no_archive=True,
|
||||
follow_redirect=True,
|
||||
sanitize_info=True,
|
||||
)
|
||||
|
||||
if "formats" in data:
|
||||
from yt_dlp.cookies import LenientSimpleCookie
|
||||
|
||||
for index, item in enumerate(data["formats"]):
|
||||
if "cookies" in item and len(item["cookies"]) > 0:
|
||||
cookies: list[str] = [f"{c.key}={c.value}" for c in LenientSimpleCookie(item["cookies"]).values()]
|
||||
if len(cookies) > 0:
|
||||
data["formats"][index]["h_cookies"] = "; ".join(cookies)
|
||||
data["formats"][index]["h_cookies"] = data["formats"][index]["h_cookies"].strip()
|
||||
|
||||
data["_cached"] = {
|
||||
"status": "miss",
|
||||
"key": key,
|
||||
"ttl": 300,
|
||||
"ttl_left": 300,
|
||||
"expires": time.time() + 300,
|
||||
}
|
||||
|
||||
data = OrderedDict(sorted(data.items(), key=lambda item: len(str(item[1]))))
|
||||
|
||||
cache.set(key=key, value=data, ttl=300)
|
||||
|
||||
return web.Response(body=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Error encountered while getting video info for '{url}'. '{e!s}'.")
|
||||
return web.json_response(
|
||||
data={
|
||||
"error": "failed to get video info.",
|
||||
"message": str(e),
|
||||
"formats": [],
|
||||
},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
|
||||
@route("GET", "api/yt-dlp/archive/recheck/", "archive_recheck")
|
||||
async def archive_recheck(cache: Cache) -> Response:
|
||||
"""
|
||||
Recheck the manual archive entries.
|
||||
|
||||
Args:
|
||||
cache (Cache): The cache instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object
|
||||
|
||||
"""
|
||||
config: Config = Config.get_instance()
|
||||
if not config.manual_archive:
|
||||
return web.json_response(data={"error": "Manual archive is not enabled."}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
manual_archive = Path(config.manual_archive)
|
||||
if not manual_archive.exists():
|
||||
return web.json_response(
|
||||
data={"error": "Manual archive file not found.", "file": manual_archive},
|
||||
status=web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
tasks: list = []
|
||||
response: list = []
|
||||
|
||||
def info_wrapper(id: str, url: str) -> tuple[str, dict]:
|
||||
try:
|
||||
return (
|
||||
id,
|
||||
extract_info(
|
||||
config={
|
||||
"proxy": config.get_ytdlp_args().get("proxy", None),
|
||||
"simulate": True,
|
||||
"dump_single_json": True,
|
||||
},
|
||||
url=url,
|
||||
no_archive=True,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
return (id, {"error": str(e)})
|
||||
|
||||
async with await anyio.open_file(manual_archive) as f:
|
||||
# line format is "youtube ID - at: ISO8601"
|
||||
async for line in f:
|
||||
line = line.strip()
|
||||
|
||||
if not line or not line.startswith("youtube"):
|
||||
continue
|
||||
|
||||
id = line.split(" ")[1].strip()
|
||||
|
||||
if not id:
|
||||
continue
|
||||
|
||||
url = f"https://www.youtube.com/watch?v={id}"
|
||||
key = cache.hash(id)
|
||||
|
||||
if cache.has(key):
|
||||
data = cache.get(key)
|
||||
response.append({id: bool(data.get("id", None)) if isinstance(data, dict) else False})
|
||||
continue
|
||||
|
||||
tasks.append(
|
||||
asyncio.get_event_loop().run_in_executor(None, lambda i=id, url=url: info_wrapper(id=i, url=url))
|
||||
)
|
||||
|
||||
if len(tasks) > 0:
|
||||
results = await asyncio.gather(*tasks)
|
||||
for data in results:
|
||||
if not data:
|
||||
continue
|
||||
|
||||
id, info = data
|
||||
cache.set(key=cache.hash(id), value=info, ttl=3600 * 6)
|
||||
response.append({id: bool(data.get("id", None)) if isinstance(data, dict) else False})
|
||||
|
||||
return web.json_response(data=response, status=web.HTTPOk.status_code)
|
||||
126
app/routes/socket/connection.py
Normal file
126
app/routes/socket/connection.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import socketio
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.DownloadQueue import DownloadQueue
|
||||
from app.library.Events import EventBus, Events, error
|
||||
from app.library.Presets import Presets
|
||||
from app.library.router import RouteType, route
|
||||
from app.library.Utils import tail_log
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _Data:
|
||||
subscribers: dict[str, list[str]] = {}
|
||||
log_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
@route(RouteType.SOCKET, "connect", "socket_connect")
|
||||
async def connect(config: Config, queue: DownloadQueue, notify: EventBus, sid: str):
|
||||
data = {
|
||||
**queue.get(),
|
||||
"config": config.frontend(),
|
||||
"presets": Presets.get_instance().get_all(),
|
||||
"paused": queue.is_paused(),
|
||||
}
|
||||
|
||||
data["folders"] = [folder.name for folder in Path(config.download_path).iterdir() if folder.is_dir()]
|
||||
|
||||
await notify.emit(Events.INITIAL_DATA, data=data, to=sid)
|
||||
|
||||
|
||||
@route(RouteType.SOCKET, "disconnect", "socket_disconnect")
|
||||
async def disconnect(sid: str, data: str = None):
|
||||
"""
|
||||
Handle client disconnection.
|
||||
|
||||
Args:
|
||||
sid (str): The session ID of the client.
|
||||
data (str): The reason for disconnection.
|
||||
|
||||
"""
|
||||
LOG.debug(f"Client '{sid}' disconnected. {data}")
|
||||
|
||||
for event in _Data.subscribers:
|
||||
if sid in _Data.subscribers[event]:
|
||||
await unsubscribe(sid=sid, data=event)
|
||||
|
||||
|
||||
@route(RouteType.SOCKET, "subscribe", "socket_subscribe")
|
||||
async def subscribe(config: Config, notify: EventBus, sio: socketio.AsyncServer, sid: str, data: str):
|
||||
"""
|
||||
Subscribe to a specific event.
|
||||
|
||||
Args:
|
||||
config (Config): The configuration instance.
|
||||
notify (EventBus): The event bus to use for notifications.
|
||||
sio (socketio.AsyncServer): The Socket.IO server instance.
|
||||
sid (str): The session ID of the client.
|
||||
data (str): The event to subscribe to.
|
||||
|
||||
"""
|
||||
if not isinstance(data, str):
|
||||
await notify.emit(Events.ERROR, data=error("Invalid event."), to=sid)
|
||||
return
|
||||
|
||||
if data not in _Data.subscribers:
|
||||
_Data.subscribers[data] = []
|
||||
|
||||
if sid not in _Data.subscribers[data]:
|
||||
_Data.subscribers[data].append(sid)
|
||||
LOG.debug(f"Client '{sid}' subscribed to event '{data}'.")
|
||||
await sio.emit(Events.SUBSCRIBED, data={"event": data}, to=sid)
|
||||
|
||||
async def emit_logs(data: dict):
|
||||
await subscribe_emit(sio=sio, event=data, data=data)
|
||||
|
||||
if "log_lines" == data and _Data.log_task is None:
|
||||
log_file = Path(config.config_path) / "logs" / "app.log"
|
||||
LOG.debug(f"Starting tailing '{log_file!s}'.")
|
||||
_Data.log_task = asyncio.create_task(tail_log(file=log_file, emitter=emit_logs), name="tail_log")
|
||||
|
||||
|
||||
@route(RouteType.SOCKET, "unsubscribe", "socket_unsubscribe")
|
||||
async def unsubscribe(sio: socketio.AsyncServer, sid: str, data: str):
|
||||
"""
|
||||
Unsubscribe from a specific event.
|
||||
|
||||
Args:
|
||||
sio (socketio.AsyncServer): The Socket.IO server instance.
|
||||
sid (str): The session ID of the client.
|
||||
data (str): The event to unsubscribe from.
|
||||
|
||||
"""
|
||||
if data not in _Data.subscribers:
|
||||
return
|
||||
|
||||
if sid not in _Data.subscribers[data]:
|
||||
return
|
||||
|
||||
_Data.subscribers[data].remove(sid)
|
||||
await sio.emit(Events.UNSUBSCRIBED, data={"event": data}, to=sid)
|
||||
|
||||
LOG.debug(f"Client '{sid}' unsubscribed from event '{data}'.")
|
||||
|
||||
if "log_lines" != data or not _Data.log_task or "log_lines" not in _Data.subscribers:
|
||||
return
|
||||
|
||||
if len(_Data.subscribers["log_lines"]) < 1:
|
||||
try:
|
||||
LOG.debug("Stopping log tailing task.")
|
||||
_Data.log_task.cancel()
|
||||
_Data.log_task = None
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
async def subscribe_emit(sio: socketio.AsyncServer, event: str, data: dict):
|
||||
if event not in _Data.subscribers or len(_Data.subscribers[event]) < 1:
|
||||
return
|
||||
|
||||
for sid in _Data.subscribers[event]:
|
||||
await sio.emit(event=event, data=data, to=sid)
|
||||
130
app/routes/socket/history.py
Normal file
130
app/routes/socket/history.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.DownloadQueue import DownloadQueue
|
||||
from app.library.Events import EventBus, Events, error
|
||||
from app.library.ItemDTO import Item
|
||||
from app.library.router import RouteType, route
|
||||
from app.library.Utils import is_downloaded
|
||||
from app.library.YTDLPOpts import YTDLPOpts
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route(RouteType.SOCKET, "pause", "pause_downloads")
|
||||
async def pause(notify: EventBus, queue: DownloadQueue):
|
||||
queue.pause()
|
||||
await notify.emit(Events.PAUSED, data={"paused": True, "at": time.time()})
|
||||
|
||||
|
||||
@route(RouteType.SOCKET, "resume", "resume_downloads")
|
||||
async def resume(notify: EventBus, queue: DownloadQueue):
|
||||
queue.resume()
|
||||
await notify.emit(Events.PAUSED, data={"paused": False, "at": time.time()})
|
||||
|
||||
|
||||
@route(RouteType.SOCKET, "add_url", "add_url")
|
||||
async def add_url(queue: DownloadQueue, notify: EventBus, sid: str, data: dict):
|
||||
url: str | None = data.get("url")
|
||||
|
||||
if not url:
|
||||
await notify.emit(Events.ERROR, data=error("No URL provided.", data={"unlock": True}), to=sid)
|
||||
return
|
||||
|
||||
try:
|
||||
await notify.emit(
|
||||
event=Events.STATUS,
|
||||
data=await queue.add(item=Item.format(data)),
|
||||
to=sid,
|
||||
)
|
||||
except ValueError as e:
|
||||
LOG.exception(e)
|
||||
await notify.emit(Events.ERROR, data=error(str(e)), to=sid)
|
||||
return
|
||||
|
||||
|
||||
@route(RouteType.SOCKET, "item_cancel", "item_cancel")
|
||||
async def item_cancel(queue: DownloadQueue, notify: EventBus, sid: str, data: str):
|
||||
if not data:
|
||||
await notify.emit(Events.ERROR, data=error("Invalid request."), to=sid)
|
||||
return
|
||||
|
||||
status: dict[str, str] = {}
|
||||
status = await queue.cancel([data])
|
||||
status.update({"identifier": data})
|
||||
|
||||
await notify.emit(Events.ITEM_CANCEL, data=status)
|
||||
|
||||
|
||||
@route(RouteType.SOCKET, "item_delete", "item_delete")
|
||||
async def item_delete(queue: DownloadQueue, notify: EventBus, sid: str, data: dict):
|
||||
if not data:
|
||||
await notify.emit(Events.ERROR, data=error("Invalid request."), to=sid)
|
||||
return
|
||||
|
||||
id: str | None = data.get("id")
|
||||
if not id:
|
||||
await notify.emit(Events.ERROR, data=error("Invalid request."), to=sid)
|
||||
return
|
||||
|
||||
status: dict[str, str] = {}
|
||||
status = await queue.clear([id], remove_file=bool(data.get("remove_file", False)))
|
||||
status.update({"identifier": id})
|
||||
|
||||
await notify.emit(Events.ITEM_DELETE, data=status)
|
||||
|
||||
|
||||
@route(RouteType.SOCKET, "archive_item", "archive_item")
|
||||
async def archive_item(config: Config, data: dict):
|
||||
if not isinstance(data, dict) or "url" not in data:
|
||||
return
|
||||
|
||||
params: YTDLPOpts = YTDLPOpts.get_instance()
|
||||
|
||||
if "preset" in data and isinstance(data["preset"], str):
|
||||
params.preset(name=data["preset"])
|
||||
|
||||
if "cli" in data and isinstance(data["cli"], str) and len(data["cli"]) > 1:
|
||||
params.add_cli(data["cli"], from_user=True)
|
||||
|
||||
params = params.get_all()
|
||||
|
||||
file: str = params.get("download_archive", None)
|
||||
|
||||
if not file:
|
||||
return
|
||||
|
||||
exists, idDict = is_downloaded(file, data["url"])
|
||||
if exists or "archive_id" not in idDict or idDict["archive_id"] is None:
|
||||
return
|
||||
|
||||
async with await anyio.open_file(file, "a") as f:
|
||||
await f.write(f"{idDict['archive_id']}\n")
|
||||
|
||||
manual_archive: str = config.manual_archive
|
||||
if not manual_archive:
|
||||
return
|
||||
|
||||
manual_archive = Path(manual_archive)
|
||||
|
||||
if not manual_archive.exists():
|
||||
manual_archive.touch(exist_ok=True)
|
||||
|
||||
previouslyArchived = False
|
||||
async with await anyio.open_file(manual_archive) as f:
|
||||
async for line in f:
|
||||
if idDict["archive_id"] in line:
|
||||
previouslyArchived = True
|
||||
break
|
||||
|
||||
if not previouslyArchived:
|
||||
async with await anyio.open_file(manual_archive, "a") as f:
|
||||
await f.write(f"{idDict['archive_id']} - at: {datetime.now(UTC).isoformat()}\n")
|
||||
LOG.info(f"Archiving url '{data['url']}' with id '{idDict['archive_id']}'.")
|
||||
else:
|
||||
LOG.info(f"URL '{data['url']}' with id '{idDict['archive_id']}' already archived.")
|
||||
133
app/routes/socket/terminal.py
Normal file
133
app/routes/socket/terminal.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.Events import EventBus, Events, error
|
||||
from app.library.router import RouteType, route
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from asyncio import Task
|
||||
from asyncio.events import AbstractEventLoop
|
||||
from asyncio.subprocess import Process
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route(RouteType.SOCKET, "cli_post", "socket_cli_post")
|
||||
async def cli_post(config: Config, notify: EventBus, sid: str, data: str):
|
||||
if not config.console_enabled:
|
||||
await notify.emit(Events.ERROR, data=error("Console is disabled."), to=sid)
|
||||
return
|
||||
|
||||
if not data:
|
||||
await notify.emit(Events.CLI_CLOSE, data={"exitcode": 0}, to=sid)
|
||||
return
|
||||
|
||||
import asyncio
|
||||
import errno
|
||||
import os
|
||||
import shlex
|
||||
|
||||
try:
|
||||
LOG.info(f"Cli command from client '{sid}'. '{data}'")
|
||||
|
||||
args: list[str] = ["yt-dlp", *shlex.split(data)]
|
||||
_env: dict[str, str] = os.environ.copy()
|
||||
_env.update(
|
||||
{
|
||||
"TERM": "xterm-256color",
|
||||
"LANG": "en_US.UTF-8",
|
||||
"SHELL": "/bin/bash",
|
||||
"LC_ALL": "en_US.UTF-8",
|
||||
"PWD": config.download_path,
|
||||
"FORCE_COLOR": "1",
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
import pty
|
||||
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
stdin_arg = asyncio.subprocess.DEVNULL
|
||||
stdout_arg = stderr_arg = slave_fd
|
||||
use_pty = True
|
||||
except ImportError:
|
||||
use_pty = False
|
||||
master_fd = slave_fd = None
|
||||
stdin_arg = asyncio.subprocess.DEVNULL
|
||||
stdout_arg = asyncio.subprocess.PIPE
|
||||
stderr_arg = asyncio.subprocess.STDOUT
|
||||
|
||||
proc: Process = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
cwd=config.download_path,
|
||||
stdin=stdin_arg,
|
||||
stdout=stdout_arg,
|
||||
stderr=stderr_arg,
|
||||
env=_env,
|
||||
)
|
||||
|
||||
if use_pty:
|
||||
try:
|
||||
os.close(slave_fd)
|
||||
except Exception as e:
|
||||
LOG.error(f"Error closing PTY. '{e!s}'.")
|
||||
|
||||
async def reader(sid: str):
|
||||
if use_pty is False:
|
||||
assert proc.stdout is not None
|
||||
async for raw_line in proc.stdout:
|
||||
line = raw_line.rstrip(b"\n")
|
||||
await notify.emit(
|
||||
Events.CLI_OUTPUT,
|
||||
data={"type": "stdout", "line": line.decode("utf-8", errors="replace")},
|
||||
to=sid,
|
||||
)
|
||||
return
|
||||
|
||||
loop: AbstractEventLoop = asyncio.get_running_loop()
|
||||
buffer: bytes = b""
|
||||
while True:
|
||||
try:
|
||||
chunk: bytes = await loop.run_in_executor(None, lambda: os.read(master_fd, 1024))
|
||||
except OSError as e:
|
||||
if e.errno == errno.EIO:
|
||||
break
|
||||
raise
|
||||
|
||||
if not chunk:
|
||||
if buffer:
|
||||
await notify.emit(
|
||||
Events.CLI_OUTPUT,
|
||||
data={"type": "stdout", "line": buffer.decode("utf-8", errors="replace")},
|
||||
to=sid,
|
||||
)
|
||||
break
|
||||
|
||||
buffer += chunk
|
||||
*lines, buffer = buffer.split(b"\n")
|
||||
|
||||
for line in lines:
|
||||
await notify.emit(
|
||||
Events.CLI_OUTPUT,
|
||||
data={"type": "stdout", "line": line.decode("utf-8", errors="replace")},
|
||||
to=sid,
|
||||
)
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except Exception as e:
|
||||
LOG.error(f"Error closing PTY. '{e!s}'.")
|
||||
|
||||
read_task: Task = asyncio.create_task(reader(sid=sid))
|
||||
|
||||
returncode: int = await proc.wait()
|
||||
|
||||
await read_task
|
||||
|
||||
await notify.emit(Events.CLI_CLOSE, data={"exitcode": returncode}, to=sid)
|
||||
except Exception as e:
|
||||
LOG.error(f"CLI execute exception was thrown for client '{sid}'.")
|
||||
LOG.exception(e)
|
||||
await notify.emit(Events.CLI_OUTPUT, data={"type": "stderr", "line": str(e)}, to=sid)
|
||||
await notify.emit(Events.CLI_CLOSE, data={"exitcode": -1}, to=sid)
|
||||
|
|
@ -1,4 +1,10 @@
|
|||
#!/usr/bin/env python3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
APP_ROOT = str((Path(__file__).parent / "..").resolve())
|
||||
if APP_ROOT not in sys.path:
|
||||
sys.path.insert(0, APP_ROOT)
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
|
|
|||
8
ui/@types/logs.ts
Normal file
8
ui/@types/logs.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
type log_line = {
|
||||
id: number,
|
||||
line: string,
|
||||
datetime?: string,
|
||||
}
|
||||
|
||||
|
||||
export type { log_line };
|
||||
17
ui/@types/tasks.ts
Normal file
17
ui/@types/tasks.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
type task_item = {
|
||||
id: string,
|
||||
name: string,
|
||||
url: string,
|
||||
preset?: string,
|
||||
folder?: string,
|
||||
template?: string,
|
||||
cli?: string,
|
||||
timer?: string,
|
||||
in_progress?: boolean,
|
||||
}
|
||||
|
||||
type exported_task = task_item & { _type: string, _version: string }
|
||||
|
||||
type error_response = { error: string }
|
||||
|
||||
export type { task_item, exported_task, error_response };
|
||||
98
ui/components/ConfirmDialog.vue
Normal file
98
ui/components/ConfirmDialog.vue
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<template>
|
||||
<div v-if="visible" class="modal is-active">
|
||||
<div class="modal-background" @click="cancel" />
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">{{ title }}</p>
|
||||
<button class="delete" aria-label="close" @click="cancel" />
|
||||
</header>
|
||||
|
||||
<section class="modal-card-body">
|
||||
<p class="mb-3 title is-5">{{ message }}</p>
|
||||
|
||||
<div v-if="options?.length">
|
||||
<hr class="">
|
||||
<label v-for="opt in options" :key="opt.key" class="checkbox is-block mb-2 is-unselectable">
|
||||
<input type="checkbox" v-model="selected[opt.key]" class="mr-2" />
|
||||
{{ opt.label }}
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="modal-card-foot p-5">
|
||||
<div class="field is-grouped" style="width:100%">
|
||||
<div class="control is-expanded">
|
||||
<button class="button is-fullwidth" :class="confirm_button_color" @click="handleConfirm">
|
||||
{{ confirm_button_label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<button class="button is-success is-fullwidth" :class="cancel_button_color" @click="cancel">
|
||||
{{ cancel_button_label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Confirm'
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: 'Are you sure?'
|
||||
},
|
||||
options: {
|
||||
type: Array as () => Array<{ key: string; label: string, checked?: boolean }>,
|
||||
default: () => []
|
||||
},
|
||||
confirm_button_label: {
|
||||
type: String,
|
||||
default: 'Confirm'
|
||||
},
|
||||
cancel_button_label: {
|
||||
type: String,
|
||||
default: 'Cancel'
|
||||
},
|
||||
confirm_button_color: {
|
||||
type: String,
|
||||
default: 'is-danger'
|
||||
},
|
||||
cancel_button_color: {
|
||||
type: String,
|
||||
default: 'is-success'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'confirm', options: Record<string, boolean>): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const selected = reactive<Record<string, boolean>>({})
|
||||
|
||||
watch(() => props.visible, visible => {
|
||||
if (visible && props.options) {
|
||||
for (const opt of props.options) {
|
||||
selected[opt.key] ??= false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function handleConfirm() {
|
||||
emit('confirm', { ...selected })
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
109
ui/components/Dropdown.vue
Normal file
109
ui/components/Dropdown.vue
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<template>
|
||||
<div class="dropdown" :class="{ 'is-active': isOpen, 'drop-up': dropUp }" ref="dropdown">
|
||||
<div class="dropdown-trigger">
|
||||
<button class="button is-fullwidth is-justify-content-space-between" aria-haspopup="true"
|
||||
aria-controls="dropdown-menu" @click="toggle" :class="button_classes">
|
||||
<span class="icon" v-if="icons"><i :class="icons" /></span>
|
||||
<span>{{ label }}</span>
|
||||
<div class="is-pulled-right">
|
||||
<span class="icon"><i class="fas fa-angle-down" aria-hidden="true" /></span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-menu" role="menu" id="dropdown-menu">
|
||||
<div class="dropdown-content" @click="handle_slot_click">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const emitter = defineEmits(['open_state'])
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: 'Select'
|
||||
},
|
||||
icons: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
button_classes: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const isOpen = ref(false)
|
||||
const dropUp = ref(false)
|
||||
const dropdown = useTemplateRef<HTMLDivElement>('dropdown')
|
||||
|
||||
const toggle = async () => {
|
||||
isOpen.value = !isOpen.value
|
||||
|
||||
if (isOpen.value && dropdown.value) {
|
||||
await nextTick()
|
||||
const rect = dropdown.value.getBoundingClientRect()
|
||||
const menu = dropdown.value.querySelector('.dropdown-menu') as HTMLElement
|
||||
const menuHeight = menu?.offsetHeight || 0
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
dropUp.value = spaceBelow < menuHeight + 24
|
||||
}
|
||||
}
|
||||
|
||||
const handle_slot_click = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('.dropdown-item')) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handle_event = (event: MouseEvent) => {
|
||||
if (!dropdown.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = event.target as HTMLElement
|
||||
|
||||
if (!dropdown.value.contains(target)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect(() => emitter('open_state', isOpen.value))
|
||||
onMounted(() => document.addEventListener('click', handle_event))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', handle_event))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dropdown {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
z-index: 99;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dropdown.drop-up .dropdown-menu {
|
||||
bottom: 100%;
|
||||
top: auto;
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
width: 80vw;
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="content">
|
||||
<h1 class="has-text-white">Not downloaded yet.</h1>
|
||||
|
|
@ -14,10 +15,8 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
url: {
|
||||
type: String,
|
||||
required: true,
|
||||
|
|
@ -26,12 +25,13 @@ const props = defineProps({
|
|||
|
||||
const emitter = defineEmits(['closeModel'])
|
||||
|
||||
const eventFunc = e => {
|
||||
if (e.key === 'Escape') {
|
||||
emitter('closeModel')
|
||||
const handle_event = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') {
|
||||
return
|
||||
}
|
||||
emitter('closeModel')
|
||||
}
|
||||
|
||||
onMounted(async () => window.addEventListener('keydown', eventFunc))
|
||||
onUnmounted(() => window.removeEventListener('keydown', eventFunc))
|
||||
onMounted(() => document.addEventListener('keydown', handle_event))
|
||||
onBeforeUnmount(() => document.removeEventListener('keydown', handle_event))
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,47 +1,50 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="modal is-active" v-if="false === externalModel">
|
||||
<div class="modal-background" @click="closeModal"></div>
|
||||
<div class="modal-background" @click="emitter('closeModel')"></div>
|
||||
<div class="modal-content" style="width:60vw;">
|
||||
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="p-0 m-0" style="position: relative">
|
||||
<div class="content" style="white-space: pre;">
|
||||
<code class="p-4 is-block" v-text="data" />
|
||||
<button class="button is-small m-4" @click="() => copyText(JSON.stringify(data, null, 4))"
|
||||
<div class="content p-0 m-0" style="position: relative">
|
||||
<pre><code class="p-4 is-block" v-text="data" />
|
||||
<button class="button m-4" @click="() => copyText(JSON.stringify(data, null, 4))"
|
||||
style="position: absolute; top:0; right:0;">
|
||||
<span class="icon"><i class="fas fa-copy"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="modal-close is-large" aria-label="close" @click="closeModal"></button>
|
||||
<button class="modal-close is-large" aria-label="close" @click="emitter('closeModel')"></button>
|
||||
</div>
|
||||
<div style="width:70vw; height: 80vh;" v-else>
|
||||
<div class="p-0 m-0" style="position: relative">
|
||||
<div class="content" style="white-space: pre;">
|
||||
<code class="p-4 is-block" v-text="data" />
|
||||
<button class="button is-small m-4" @click="() => copyText(JSON.stringify(data, null, 4))"
|
||||
style="position: absolute; top:0; right:0;">
|
||||
<span class="icon"><i class="fas fa-copy"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="content p-0 m-0" style="position: relative">
|
||||
<pre><code class="p-4 is-block" v-text="data" /></pre>
|
||||
<button class="button m-4" @click="() => copyText(JSON.stringify(data, null, 4))"
|
||||
style="position: absolute; top:0; right:0;">
|
||||
<span class="icon"><i class="fas fa-copy"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<style scoped>
|
||||
code {
|
||||
color: var(--bulma-code) !important
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { request } from '~/utils/index'
|
||||
|
||||
const emitter = defineEmits(['closeModel'])
|
||||
const isLoading = ref(false)
|
||||
const data = ref({})
|
||||
const toast = useNotification()
|
||||
|
||||
const emitter = defineEmits(['closeModel'])
|
||||
const isLoading = ref<Boolean>(false)
|
||||
const data = ref<any>({})
|
||||
|
||||
const props = defineProps({
|
||||
link: {
|
||||
type: String,
|
||||
|
|
@ -60,16 +63,15 @@ const props = defineProps({
|
|||
},
|
||||
})
|
||||
|
||||
const closeModal = () => emitter('closeModel')
|
||||
|
||||
const eventFunc = e => {
|
||||
if (e.key === 'Escape') {
|
||||
emitter('closeModel')
|
||||
const handle_event = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') {
|
||||
return
|
||||
}
|
||||
emitter('closeModel')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('keydown', eventFunc)
|
||||
document.addEventListener('keydown', handle_event)
|
||||
|
||||
const url = props.useUrl ? props.link : '/api/yt-dlp/url/info?url=' + encodePath(props.link)
|
||||
|
||||
|
|
@ -84,7 +86,7 @@ onMounted(async () => {
|
|||
data.value = body
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
toast.error(`Error: ${e.message}`)
|
||||
} finally {
|
||||
|
|
@ -92,5 +94,5 @@ onMounted(async () => {
|
|||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => window.removeEventListener('keydown', eventFunc))
|
||||
onBeforeUnmount(() => document.removeEventListener('keydown', handle_event))
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@
|
|||
|
||||
<div class="columns is-multiline" v-if="'list' === display_style">
|
||||
<div class="column is-12" v-if="hasItems">
|
||||
<div class="table-container">
|
||||
<div :class="{ 'table-container': table_container }">
|
||||
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||
style="min-width: 1300px; table-layout: fixed;">
|
||||
<thead>
|
||||
|
|
@ -106,7 +106,12 @@
|
|||
:id="'checkbox-' + item._id" :value="item._id">
|
||||
</label>
|
||||
</td>
|
||||
<td class="is-vcentered">
|
||||
<td class="is-text-overflow is-vcentered">
|
||||
<div class="is-inline is-pulled-right" v-if="item.extras?.duration">
|
||||
<span class="tag is-info" v-if="item.extras?.duration">
|
||||
{{ formatTime(item.extras.duration) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="showThumbnails && item.extras.thumbnail">
|
||||
<FloatingImage :image="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
|
||||
:title="item.title">
|
||||
|
|
@ -164,30 +169,51 @@
|
|||
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="control" v-if="config.app?.keep_archive && item.status != 'finished'">
|
||||
<button class="button is-danger is-light is-fullwidth is-small" v-tooltip="'Add link to archive'"
|
||||
@click="archiveItem(item)">
|
||||
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="control" v-if="item.filename && item.status === 'finished'">
|
||||
<a class="button is-link is-fullwidth is-small" :href="makeDownload(config, item)"
|
||||
v-tooltip="'Download video'" :download="item.filename?.split('/').reverse()[0]">
|
||||
<span class="icon"><i class="fa-solid fa-download" /></span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="control" v-if="item.url && !config.app.basic_mode">
|
||||
<button class="button is-info is-fullwidth is-small" @click="emitter('getInfo', item.url)"
|
||||
v-tooltip="'Show video information'">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button is-danger is-fullwidth is-small" @click="removeItem(item)"
|
||||
v-tooltip="'Remove video'">
|
||||
<span class="icon"><i class="fa-solid fa-trash-can" /></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="control" v-if="item.url && !config.app.basic_mode">
|
||||
<Dropdown icons="fa-solid fa-cogs" label="" @open_state="s => table_container = !s"
|
||||
:button_classes="'is-small'">
|
||||
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url)">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Information</span>
|
||||
</NuxtLink>
|
||||
|
||||
<template v-if="item.status != 'finished' || !item.filename">
|
||||
<hr class="dropdown-divider" />
|
||||
<NuxtLink class="dropdown-item" @click="reQueueItem(item, true)">
|
||||
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
|
||||
<span>Add to download form</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<template v-if="'finished' !== item.status && config.app?.keep_archive">
|
||||
<hr class="dropdown-divider" />
|
||||
<NuxtLink class="dropdown-item has-text-danger" @click="addArchiveDialog(item)">
|
||||
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
|
||||
<span>Archive Item</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<template v-if="'finished' === item.status && item.filename && config.app?.keep_archive">
|
||||
<hr class="dropdown-divider" />
|
||||
<NuxtLink class="dropdown-item" @click="removeFromArchiveDialog(item)">
|
||||
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
|
||||
<span>Remove from archive</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -202,12 +228,16 @@
|
|||
v-for="item in sortCompleted" :key="item._id">
|
||||
<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 ">
|
||||
<header class="card-header">
|
||||
<div class="card-header-title is-text-overflow is-block" v-tooltip="item.title">
|
||||
<NuxtLink target="_blank" :href="item.url" class="has-tooltip">{{ item.title }}</NuxtLink>
|
||||
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="card-header-icon">
|
||||
<span class="tag is-info" v-if="item.extras?.duration">
|
||||
{{ formatTime(item.extras.duration) }}
|
||||
</span>
|
||||
|
||||
<span v-if="!showThumbnails">
|
||||
<a v-if="'finished' === item.status && item.filename" href="#" @click.prevent="playVideo(item)"
|
||||
v-tooltip="'Play video.'">
|
||||
|
|
@ -305,14 +335,7 @@
|
|||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="column is-half-mobile" v-if="config.app?.keep_archive && item.status != 'finished'">
|
||||
<a class="button is-danger is-light is-fullwidth" @click="archiveItem(item)">
|
||||
<span class="icon-text is-block">
|
||||
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
|
||||
<span>Archive</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="column is-half-mobile" v-if="item.filename && item.status === 'finished'">
|
||||
<a class="button is-link is-fullwidth" :href="makeDownload(config, item)"
|
||||
:download="item.filename?.split('/').reverse()[0]">
|
||||
|
|
@ -323,12 +346,36 @@
|
|||
</a>
|
||||
</div>
|
||||
<div class="column is-half-mobile" v-if="item.url && !config.app.basic_mode">
|
||||
<button class="button is-info is-fullwidth" @click="emitter('getInfo', item.url)">
|
||||
<span class="icon-text is-block">
|
||||
<Dropdown icons="fa-solid fa-cogs" label="Actions">
|
||||
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url)">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Information</span>
|
||||
</span>
|
||||
</button>
|
||||
</NuxtLink>
|
||||
|
||||
<template v-if="item.status != 'finished' || !item.filename">
|
||||
<hr class="dropdown-divider" />
|
||||
<NuxtLink class="dropdown-item" @click="reQueueItem(item, true)">
|
||||
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
|
||||
<span>Add to download form</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<template v-if="'finished' !== item.status && config.app?.keep_archive">
|
||||
<hr class="dropdown-divider" />
|
||||
<NuxtLink class="dropdown-item has-text-danger" @click="addArchiveDialog(item)">
|
||||
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
|
||||
<span>Archive Item</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<template v-if="'finished' === item.status && item.filename && config.app?.keep_archive">
|
||||
<hr class="dropdown-divider" />
|
||||
<NuxtLink class="dropdown-item" @click="removeFromArchiveDialog(item)">
|
||||
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
|
||||
<span>Remove from archive</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -336,23 +383,13 @@
|
|||
</LateLoader>
|
||||
</div>
|
||||
|
||||
<div class="content has-text-centered" v-if="!hasItems">
|
||||
<p v-if="socket.isConnected">
|
||||
<span class="icon-text">
|
||||
<span class="icon has-text-success">
|
||||
<i class="fa-solid fa-circle-check" />
|
||||
</span>
|
||||
<span>No records.</span>
|
||||
</span>
|
||||
</p>
|
||||
<p v-else>
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-spinner fa-spin" />
|
||||
</span>
|
||||
<span>Connecting...</span>
|
||||
</span>
|
||||
</p>
|
||||
<div class="columns is-multiline" v-if="!hasItems">
|
||||
<div class="column is-12">
|
||||
<Message message_class="has-background-success-90 has-text-dark" title="No records in history."
|
||||
icon="fas fa-circle-check" v-if="socket.isConnected" />
|
||||
<Message message_class="has-background-info-90 has-text-dark" title="Connecting.." icon="fas fa-spinner fa-spin"
|
||||
v-else />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal is-active" v-if="video_item">
|
||||
|
|
@ -371,6 +408,10 @@
|
|||
</div>
|
||||
<button class="modal-close is-large" aria-label="close" @click="embed_url = ''"></button>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog v-if="dialog_confirm.visible" :visible="dialog_confirm.visible" :title="dialog_confirm.title"
|
||||
:message="dialog_confirm.message" :options="dialog_confirm.options" @confirm="dialog_confirm.confirm"
|
||||
@cancel="() => dialog_confirm.visible = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -379,6 +420,8 @@ import moment from 'moment'
|
|||
import { useStorage } from '@vueuse/core'
|
||||
import { makeDownload, formatBytes, uri } from '~/utils/index'
|
||||
import { isEmbedable, getEmbedable } from '~/utils/embedable'
|
||||
import Dropdown from './Dropdown.vue'
|
||||
import { NuxtLink } from '#components'
|
||||
|
||||
const emitter = defineEmits(['getInfo', 'add_new'])
|
||||
|
||||
|
|
@ -401,10 +444,21 @@ const showCompleted = useStorage('showCompleted', true)
|
|||
const hideThumbnail = useStorage('hideThumbnailHistory', false)
|
||||
const direction = useStorage('sortCompleted', 'desc')
|
||||
const display_style = useStorage('display_style', 'cards')
|
||||
const table_container = ref(false)
|
||||
|
||||
const embed_url = ref('')
|
||||
const video_item = ref(null)
|
||||
|
||||
const dialog_confirm = ref({
|
||||
visible: false,
|
||||
title: 'Confirm Action',
|
||||
confirm: (opts) => { },
|
||||
message: '',
|
||||
options: [
|
||||
{ key: 'remove_history', label: 'Also, Remove from history.' },
|
||||
],
|
||||
})
|
||||
|
||||
const playVideo = item => video_item.value = item
|
||||
const closeVideo = () => video_item.value = null
|
||||
|
||||
|
|
@ -547,6 +601,9 @@ const setIcon = item => {
|
|||
if (!item.filename) {
|
||||
return 'fa-solid fa-exclamation'
|
||||
}
|
||||
if (item.extras?.is_premiere) {
|
||||
return 'fa-solid fa-star'
|
||||
}
|
||||
return item.is_live ? 'fa-solid fa-globe' : 'fa-solid fa-circle-check'
|
||||
}
|
||||
|
||||
|
|
@ -559,7 +616,7 @@ const setIcon = item => {
|
|||
}
|
||||
|
||||
if ('not_live' === item.status) {
|
||||
return 'fa-solid fa-headset'
|
||||
return item.extras?.is_premiere ? 'fa-solid fa-star' : 'fa-solid fa-headset'
|
||||
}
|
||||
|
||||
return 'fa-solid fa-circle'
|
||||
|
|
@ -589,6 +646,11 @@ const setStatus = item => {
|
|||
if (!item.filename) {
|
||||
return 'Skipped?'
|
||||
}
|
||||
|
||||
if (item.extras?.is_premiere) {
|
||||
return 'Premiere'
|
||||
}
|
||||
|
||||
return item.is_live ? 'Live Ended' : 'Completed'
|
||||
}
|
||||
|
||||
|
|
@ -601,6 +663,9 @@ const setStatus = item => {
|
|||
}
|
||||
|
||||
if ('not_live' === item.status) {
|
||||
if (item.extras?.is_premiere) {
|
||||
return 'Premiere'
|
||||
}
|
||||
return display_style.value === 'cards' ? 'Live Stream' : 'Live'
|
||||
}
|
||||
|
||||
|
|
@ -621,11 +686,39 @@ const requeueIncomplete = () => {
|
|||
}
|
||||
}
|
||||
|
||||
const archiveItem = item => {
|
||||
if (!box.confirm(`Archive '${item.title ?? item.id ?? item.url ?? '??'}'?`)) {
|
||||
const addArchiveDialog = (item) => {
|
||||
dialog_confirm.value.visible = true
|
||||
dialog_confirm.value.title = 'Archive Item'
|
||||
dialog_confirm.value.message = `Archive '${item.title ?? item.id ?? item.url ?? '??'}'?`
|
||||
dialog_confirm.value.confirm = opts => archiveItem(item, opts)
|
||||
}
|
||||
|
||||
const archiveItem = async (item, opts = {}) => {
|
||||
try {
|
||||
|
||||
const req = await request(`/api/archive/${item._id}`, {
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
const data = await req.json()
|
||||
|
||||
dialog_confirm.value.visible = false
|
||||
|
||||
if (!req.ok) {
|
||||
toast.error(data.error)
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(data.message ?? `Archived '${item.title ?? item.id ?? item.url ?? '??'}'.`)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
if (!opts?.remove_history) {
|
||||
return
|
||||
}
|
||||
socket.emit('archive_item', item)
|
||||
|
||||
socket.emit('item_delete', { id: item._id, remove_file: false })
|
||||
}
|
||||
|
||||
|
|
@ -644,7 +737,7 @@ const removeItem = item => {
|
|||
})
|
||||
}
|
||||
|
||||
const reQueueItem = (item, event = null) => {
|
||||
const reQueueItem = (item, re_add = false) => {
|
||||
const item_req = {
|
||||
url: item.url,
|
||||
preset: item.preset,
|
||||
|
|
@ -657,7 +750,7 @@ const reQueueItem = (item, event = null) => {
|
|||
|
||||
socket.emit('item_delete', { id: item._id, remove_file: false })
|
||||
|
||||
if (event && (event?.altKey && true === event?.altKey)) {
|
||||
if (true === re_add) {
|
||||
toast.info('Removed the item from history, and added it to the new download form.')
|
||||
emitter('add_new', item_req)
|
||||
return
|
||||
|
|
@ -715,4 +808,38 @@ const downloadSelected = () => {
|
|||
}
|
||||
|
||||
const toggle_class = e => e.currentTarget.classList.toggle('is-text-overflow')
|
||||
|
||||
const removeFromArchiveDialog = (item) => {
|
||||
dialog_confirm.value.visible = true
|
||||
dialog_confirm.value.title = 'Remove from Archive'
|
||||
dialog_confirm.value.message = `Remove '${item.title ?? item.id ?? item.url ?? '??'}' from archive?`
|
||||
dialog_confirm.value.confirm = () => removeFromArchive(item)
|
||||
}
|
||||
|
||||
const removeFromArchive = async (item, opts) => {
|
||||
try {
|
||||
const req = await request(`/api/archive/${item._id}`, {
|
||||
credentials: 'include',
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const data = await req.json()
|
||||
|
||||
if (!req.ok) {
|
||||
toast.error(data.error)
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(data.message ?? `Removed '${item.title ?? item.id ?? item.url ?? '??'}' from archive.`)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error(`Error: ${e.message}`)
|
||||
} finally {
|
||||
dialog_confirm.value.visible = false
|
||||
}
|
||||
|
||||
if (opts?.remove_history) {
|
||||
socket.emit('item_delete', { id: item._id, remove_file: false })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,30 +1,31 @@
|
|||
<style>
|
||||
.is-image {
|
||||
<style scoped>
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
margin: auto;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</div>
|
||||
<div v-else>
|
||||
<img :src="image" class="is-image">
|
||||
<img :src="image">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { request } from '~/utils/index'
|
||||
|
||||
const emitter = defineEmits(['closeModel'])
|
||||
const isLoading = ref(false)
|
||||
const data = ref({})
|
||||
const toast = useNotification()
|
||||
const image = ref('')
|
||||
const emitter = defineEmits(['closeModel'])
|
||||
|
||||
const isLoading = ref<boolean>(false)
|
||||
const image = ref<string>('')
|
||||
|
||||
const props = defineProps({
|
||||
link: {
|
||||
|
|
@ -33,15 +34,15 @@ const props = defineProps({
|
|||
},
|
||||
})
|
||||
|
||||
const eventFunc = e => {
|
||||
if ('Escape' === e.key) {
|
||||
emitter('closeModel')
|
||||
const handle_event = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') {
|
||||
return
|
||||
}
|
||||
emitter('closeModel')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('keydown', eventFunc)
|
||||
|
||||
document.addEventListener('keydown', handle_event)
|
||||
|
||||
const url = props.link.startsWith('/') ? props.link : '/api/thumbnail?url=' + encodePath(props.link)
|
||||
|
||||
|
|
@ -54,7 +55,7 @@ onMounted(async () => {
|
|||
}
|
||||
|
||||
image.value = URL.createObjectURL(await imgRequest.blob())
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
toast.error(`Error: ${e.message}`)
|
||||
} finally {
|
||||
|
|
@ -62,5 +63,5 @@ onMounted(async () => {
|
|||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => window.removeEventListener('keydown', eventFunc))
|
||||
onBeforeUnmount(() => document.removeEventListener('keydown', handle_event))
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -2,19 +2,19 @@
|
|||
<div>
|
||||
<div class="modal is-active">
|
||||
<div class="model-title" v-if="title" />
|
||||
<div class="modal-background" @click="closeModal"></div>
|
||||
<div class="modal-background" @click="emitter('close')"></div>
|
||||
<div class="modal-content" style="width:70vw;">
|
||||
<slot />
|
||||
</div>
|
||||
<button class="modal-close is-large" aria-label="close" @click="closeModal"></button>
|
||||
<button class="modal-close is-large" aria-label="close" @click="emitter('close')"></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
const emitter = defineEmits(['close'])
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
|
|
@ -22,14 +22,13 @@ const props = defineProps({
|
|||
},
|
||||
})
|
||||
|
||||
const closeModal = () => emitter('close')
|
||||
|
||||
const eventFunc = e => {
|
||||
if (e.key === 'Escape') {
|
||||
emitter('close')
|
||||
const handle_event = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') {
|
||||
return
|
||||
}
|
||||
emitter('close')
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', eventFunc))
|
||||
onUnmounted(() => window.removeEventListener('keydown', eventFunc))
|
||||
onMounted(() => document.addEventListener('keydown', handle_event))
|
||||
onBeforeUnmount(() => document.removeEventListener('keydown', handle_event))
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -172,16 +172,27 @@
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6-tablet is-4-mobile has-text-left">
|
||||
<button type="button" class="button is-info" @click="emitter('getInfo', form.url)"
|
||||
:class="{ 'is-loading': !socket.isConnected }"
|
||||
:disabled="!socket.isConnected || addInProgress || !form?.url">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Information</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="column is-6-tablet is-6-mobile has-text-right">
|
||||
<div class="column is-12">
|
||||
<div class="field is-grouped is-justify-self-end">
|
||||
|
||||
<div class="control">
|
||||
<button type="button" class="button is-info" @click="emitter('getInfo', form.url)"
|
||||
:class="{ 'is-loading': !socket.isConnected }"
|
||||
:disabled="!socket.isConnected || addInProgress || !form?.url">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Information</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="control">
|
||||
<button type="button" class="button is-warning" @click="removeFromArchive(form.url)"
|
||||
:class="{ 'is-loading': !socket.isConnected }"
|
||||
:disabled="!socket.isConnected || addInProgress || !form?.url">
|
||||
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
|
||||
<span>Remove from archive</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="control">
|
||||
<button type="button" class="button is-danger" @click="resetConfig"
|
||||
:disabled="!socket.isConnected || form?.id" v-tooltip="'Reset local settings'">
|
||||
|
|
@ -212,7 +223,7 @@ const props = defineProps({
|
|||
},
|
||||
})
|
||||
|
||||
const emitter = defineEmits(['getInfo', 'clear_form'])
|
||||
const emitter = defineEmits(['getInfo', 'clear_form', 'remove_archive'])
|
||||
const config = useConfigStore()
|
||||
const socket = useSocketStore()
|
||||
const toast = useNotification()
|
||||
|
|
@ -327,6 +338,7 @@ const resetConfig = () => {
|
|||
}
|
||||
|
||||
showAdvanced.value = false
|
||||
separator.value = separators[0].value
|
||||
|
||||
toast.success('Local configuration has been reset.')
|
||||
}
|
||||
|
|
@ -370,13 +382,17 @@ onMounted(async () => {
|
|||
})
|
||||
emitter('clear_form');
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
if (!separators.some(s => s.value === separator.value)) {
|
||||
separator.value = separators[0].value
|
||||
}
|
||||
})
|
||||
|
||||
const hasFormatInConfig = computed(() => {
|
||||
if (!form?.value?.value) {
|
||||
if (!form.value?.cli) {
|
||||
return false
|
||||
}
|
||||
|
||||
return /(?<!\S)(-f|--format)(=|\s)(\S+)/.test(form.value.cli)
|
||||
})
|
||||
|
||||
|
|
@ -384,4 +400,25 @@ const filter_presets = (flag = true) => config.presets.filter(item => item.defau
|
|||
const get_preset = name => config.presets.find(item => item.name === name)
|
||||
const expand_description = e => toggleClass(e.target, ['is-ellipsis', 'is-pre-wrap'])
|
||||
|
||||
|
||||
const removeFromArchive = async url => {
|
||||
try {
|
||||
const req = await request(`/api/archive/0`, {
|
||||
credentials: 'include',
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ url }),
|
||||
})
|
||||
|
||||
const data = await req.json()
|
||||
|
||||
if (!req.ok) {
|
||||
toast.error(data.error)
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(data.message ?? `Removed item from archive.`)
|
||||
} catch (e) {
|
||||
toast.error(`Error: ${e.message}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
<hr class="navbar-divider">
|
||||
</template>
|
||||
<div class="notification-list">
|
||||
<div v-for="n in store.notifications" :key="n.id" class="navbar-item is-flex is-align-items-start"
|
||||
<div v-for="n in store.notifications" :key="n.id" class="pr-1 pl-1 navbar-item is-flex is-align-items-start"
|
||||
:class="['notification-item', 'notification-' + n.level]">
|
||||
<div class="is-flex-grow-1">
|
||||
<p class="is-size-7 mb-1 notification-message" :class="{ expanded: expandedId === n.id }"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<h1 class="mt-3 is-size-3 is-clickable is-unselectable" @click="showQueue = !showQueue">
|
||||
<h1 class="mt-3 is-size-3 is-clickable is-unselectable" @click="showQueue = !showQueue" v-if="hasQueuedItems">
|
||||
<span class="icon-text title is-4">
|
||||
<span class="icon">
|
||||
<i class="fas" :class="showQueue ? 'fa-solid fa-arrow-up' : 'fa-solid fa-arrow-down'" />
|
||||
|
|
@ -66,8 +66,11 @@
|
|||
</label>
|
||||
</td>
|
||||
<td class="is-text-overflow is-vcentered">
|
||||
<div class="is-inline is-pulled-right" v-if="item.downloaded_bytes">
|
||||
<span class="tag">{{ formatBytes(item.downloaded_bytes) }}</span>
|
||||
<div class="is-inline is-pulled-right" v-if="item.downloaded_bytes || item.extras?.duration">
|
||||
<span class="tag" v-if="item.downloaded_bytes">{{ formatBytes(item.downloaded_bytes) }}</span>
|
||||
<span class="tag is-info" v-if="item.extras?.duration">
|
||||
{{ formatTime(item.extras.duration) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="showThumbnails && item.extras?.thumbnail">
|
||||
<FloatingImage :image="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
|
||||
|
|
@ -139,6 +142,10 @@
|
|||
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
|
||||
</div>
|
||||
<div class="card-header-icon">
|
||||
<span class="tag is-info" v-if="item.extras?.duration">
|
||||
{{ formatTime(item.extras.duration) }}
|
||||
</span>
|
||||
|
||||
<a :href="item.url" class="has-text-primary" v-tooltip="'Copy url.'" @click.prevent="copyText(item.url)">
|
||||
<span class="icon"><i class="fa-solid fa-copy" /></span>
|
||||
</a>
|
||||
|
|
@ -218,24 +225,6 @@
|
|||
</LateLoader>
|
||||
</div>
|
||||
|
||||
<div class="content has-text-centered" v-if="!hasQueuedItems">
|
||||
<p v-if="socket.isConnected">
|
||||
<span class="icon-text">
|
||||
<span class="icon has-text-success">
|
||||
<i class="fa-solid fa-circle-check" />
|
||||
</span>
|
||||
<span>No queued items.</span>
|
||||
</span>
|
||||
</p>
|
||||
<p v-else>
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-spinner fa-spin" />
|
||||
</span>
|
||||
<span>Connecting...</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="modal is-active" v-if="embed_url">
|
||||
<div class="modal-background" @click="embed_url = ''"></div>
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@
|
|||
</style>
|
||||
<template>
|
||||
<div v-if="infoLoaded">
|
||||
<video class="player" ref="video" :poster="uri(thumbnail)" :title="title" playsinline controls crossorigin="anonymous"
|
||||
preload="auto" autoplay>
|
||||
<video class="player" ref="video" :poster="uri(thumbnail)" :title="title" playsinline controls
|
||||
crossorigin="anonymous" preload="auto" autoplay>
|
||||
<source v-for="source in sources" :key="source.src" :src="source.src" @error="source.onerror"
|
||||
:type="source.type" />
|
||||
<track v-for="(track, i) in tracks" :key="track.file" :kind="track.kind" :label="track.label"
|
||||
|
|
@ -25,11 +25,35 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { onMounted, onUpdated, ref, onUnmounted } from 'vue'
|
||||
import Hls from 'hls.js'
|
||||
import { makeDownload } from '~/utils/index'
|
||||
|
||||
type video_track_element = {
|
||||
file: string,
|
||||
kind: string,
|
||||
label: string,
|
||||
lang: string,
|
||||
}
|
||||
|
||||
type video_source_element = {
|
||||
src: string,
|
||||
type: string,
|
||||
onerror: (e: Event) => void,
|
||||
}
|
||||
|
||||
type file_info = {
|
||||
title: string,
|
||||
mimetype: string,
|
||||
sidecar?: {
|
||||
image?: Array<{ file: string }>,
|
||||
text?: Array<{ file: string }>,
|
||||
subtitle?: Array<{ name: string, lang: string, file: string }>,
|
||||
}
|
||||
error?: string,
|
||||
}
|
||||
|
||||
const config = useConfigStore()
|
||||
const toast = useNotification()
|
||||
|
||||
|
|
@ -42,9 +66,9 @@ const props = defineProps({
|
|||
|
||||
const emitter = defineEmits(['closeModel'])
|
||||
|
||||
const video = ref(null)
|
||||
const tracks = ref([])
|
||||
const sources = ref([])
|
||||
const video = useTemplateRef<HTMLMediaElement>('video')
|
||||
const tracks = ref<Array<video_track_element>>([])
|
||||
const sources = ref<Array<video_source_element>>([])
|
||||
|
||||
const thumbnail = ref('/images/placeholder.png')
|
||||
const artist = ref('')
|
||||
|
|
@ -54,19 +78,39 @@ const isApple = /(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent)
|
|||
const volume = useStorage('player_volume', 1)
|
||||
const notFirefox = !navigator.userAgent.toLowerCase().includes('firefox')
|
||||
const infoLoaded = ref(false)
|
||||
const destroyed = ref(false)
|
||||
|
||||
let hls = null
|
||||
let hls: Hls | null = null
|
||||
|
||||
const eventFunc = e => {
|
||||
if ('Escape' === e.key) {
|
||||
emitter('closeModel')
|
||||
const handle_event = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') {
|
||||
return
|
||||
}
|
||||
emitter('closeModel')
|
||||
}
|
||||
|
||||
const volume_change_handler = () => {
|
||||
if (!video.value) {
|
||||
return
|
||||
}
|
||||
|
||||
volume.value = video.value.volume
|
||||
|
||||
if (false === ("mediaSession" in navigator)) {
|
||||
return
|
||||
}
|
||||
|
||||
navigator.mediaSession.setPositionState({
|
||||
duration: video.value.duration,
|
||||
playbackRate: video.value.playbackRate,
|
||||
position: video.value.currentTime,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const req = await request(makeDownload(config, props.item, 'api/file/info'))
|
||||
|
||||
const response = await req.json()
|
||||
const response: file_info = await req.json()
|
||||
|
||||
if (!req.ok) {
|
||||
toast.error(`Failed to fetch video info. ${response?.error}`)
|
||||
|
|
@ -91,13 +135,13 @@ onMounted(async () => {
|
|||
sources.value.push({
|
||||
src: makeDownload(config, props.item, allowedCodec ? 'api/download' : 'm3u8'),
|
||||
type: allowedCodec ? response.mimetype : 'application/x-mpegURL',
|
||||
onerror: e => src_error(e),
|
||||
onerror: (e: Event) => src_error(),
|
||||
})
|
||||
} else {
|
||||
sources.value.push({
|
||||
src: makeDownload(config, props.item, 'api/download'),
|
||||
type: response.mimetype,
|
||||
onerror: e => src_error(e),
|
||||
onerror: e => src_error(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -122,7 +166,7 @@ onMounted(async () => {
|
|||
isAudio.value = true
|
||||
}
|
||||
|
||||
response?.sidecar?.subtitle?.forEach((cap, id) => {
|
||||
response.sidecar?.subtitle?.forEach((cap, _) => {
|
||||
tracks.value.push({
|
||||
kind: "captions",
|
||||
label: cap.name,
|
||||
|
|
@ -139,30 +183,35 @@ onMounted(async () => {
|
|||
await nextTick()
|
||||
|
||||
prepareVideoPlayer()
|
||||
window.addEventListener('keydown', eventFunc)
|
||||
document.addEventListener('keydown', handle_event)
|
||||
})
|
||||
|
||||
onUpdated(() => prepareVideoPlayer())
|
||||
|
||||
onUnmounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
if (hls) {
|
||||
hls.destroy()
|
||||
}
|
||||
|
||||
window.removeEventListener('keydown', eventFunc)
|
||||
document.removeEventListener('keydown', handle_event)
|
||||
|
||||
if (title.value) {
|
||||
window.document.title = 'YTPTube'
|
||||
}
|
||||
if (video.value) {
|
||||
try {
|
||||
video.value.pause()
|
||||
video.value.querySelectorAll("source").forEach(source => source.removeAttribute("src"))
|
||||
video.value.load()
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
if (!video.value) {
|
||||
return
|
||||
}
|
||||
destroyed.value = true
|
||||
|
||||
try {
|
||||
video.value.pause()
|
||||
video.value.querySelectorAll("source").forEach(source => source.removeAttribute("src"))
|
||||
video.value.removeEventListener('volumechange', volume_change_handler)
|
||||
video.value.load()
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -175,9 +224,7 @@ const prepareVideoPlayer = () => {
|
|||
return
|
||||
}
|
||||
|
||||
let mediaMetadata = {
|
||||
title: title.value,
|
||||
}
|
||||
let mediaMetadata: MediaMetadataInit = { title: title.value }
|
||||
|
||||
if (thumbnail.value) {
|
||||
mediaMetadata.artwork = [{ src: thumbnail.value, sizes: '1920x1080', type: 'image/jpeg' }]
|
||||
|
|
@ -192,22 +239,31 @@ const prepareVideoPlayer = () => {
|
|||
window.document.title = `YTPTube - Playing: ${title.value}`
|
||||
}
|
||||
|
||||
video.value.volume = volume.value
|
||||
if (!video.value) {
|
||||
return
|
||||
}
|
||||
|
||||
video.value.addEventListener('volumechange', () => {
|
||||
volume.value = video.value.volume
|
||||
})
|
||||
video.value.volume = volume.value
|
||||
video.value.addEventListener('volumechange', volume_change_handler)
|
||||
}
|
||||
|
||||
const src_error = () => {
|
||||
const src_error = async () => {
|
||||
if (hls) {
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
if (destroyed.value) {
|
||||
return
|
||||
}
|
||||
console.warn('Direct play failed, trying HLS.')
|
||||
attach_hls(makeDownload(config, props.item, 'm3u8'))
|
||||
}
|
||||
|
||||
const attach_hls = link => {
|
||||
const attach_hls = (link: string) => {
|
||||
if (!video.value) {
|
||||
return
|
||||
}
|
||||
|
||||
hls = new Hls({
|
||||
debug: false,
|
||||
enableWorker: true,
|
||||
|
|
|
|||
|
|
@ -60,17 +60,17 @@
|
|||
</a>
|
||||
|
||||
<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>
|
||||
|
||||
<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>Console</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import path from "path";
|
|||
|
||||
let extraNitro = {}
|
||||
try {
|
||||
const API_URL = import.meta.env.NUXT_API_URL;
|
||||
const API_URL = process.env.NUXT_API_URL;
|
||||
if (API_URL) {
|
||||
extraNitro = {
|
||||
devProxy: {
|
||||
|
|
@ -20,7 +20,6 @@ catch (e) {
|
|||
export default defineNuxtConfig({
|
||||
ssr: false,
|
||||
devtools: { enabled: false },
|
||||
|
||||
devServer: {
|
||||
port: 8082,
|
||||
host: "0.0.0.0",
|
||||
|
|
@ -30,8 +29,8 @@ export default defineNuxtConfig({
|
|||
],
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
APP_ENV: process.env.APP_ENV || "production",
|
||||
wss: process.env.VUE_APP_BASE_URL ?? '',
|
||||
APP_ENV: process.env.NODE_ENV,
|
||||
wss: process.env.NUXT_PUBLIC_WSS ?? '',
|
||||
sentry: process.env.NUXT_PUBLIC_SENTRY_DSN ?? '',
|
||||
}
|
||||
},
|
||||
|
|
@ -39,7 +38,7 @@ export default defineNuxtConfig({
|
|||
transpile: ['vue-toastification'],
|
||||
},
|
||||
app: {
|
||||
baseURL: 'dev' == process.env.APP_ENV ? '/' : '',
|
||||
baseURL: 'production' == process.env.NODE_ENV ? '' : '/',
|
||||
buildAssetsDir: "assets",
|
||||
head: {
|
||||
"meta": [
|
||||
|
|
@ -67,7 +66,7 @@ export default defineNuxtConfig({
|
|||
|
||||
nitro: {
|
||||
output: {
|
||||
publicDir: path.join(__dirname, 'dev' == process.env.APP_ENV ? 'dist' : 'exported')
|
||||
publicDir: path.join(__dirname, 'production' === process.env.NODE_ENV ? 'exported' : 'dist')
|
||||
},
|
||||
...extraNitro,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,19 +11,19 @@
|
|||
},
|
||||
"web-types": "./web-types.json",
|
||||
"dependencies": {
|
||||
"@pinia/nuxt": "^0.11.0",
|
||||
"@sentry/nuxt": "^9.24.0",
|
||||
"@pinia/nuxt": "^0.11.1",
|
||||
"@sentry/nuxt": "^9.29.0",
|
||||
"@vueuse/core": "^13.3.0",
|
||||
"@vueuse/nuxt": "^13.3.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"cron-parser": "^5.2.0",
|
||||
"cron-parser": "^5.3.0",
|
||||
"cronstrue": "^2.61.0",
|
||||
"floating-vue": "^5.2.2",
|
||||
"hls.js": "^1.6.5",
|
||||
"moment": "^2.30.1",
|
||||
"nuxt": "^3.17.4",
|
||||
"pinia": "^3.0.2",
|
||||
"nuxt": "^3.17.5",
|
||||
"pinia": "^3.0.3",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"vue": "^3.5.16",
|
||||
"vue-router": "^4.5.1",
|
||||
|
|
|
|||
|
|
@ -27,8 +27,7 @@
|
|||
</div>
|
||||
|
||||
<div class="control">
|
||||
<button class="button is-danger is-light" @click="toggleFilter"
|
||||
v-tooltip.bottom="'Filter content'">
|
||||
<button class="button is-danger is-light" @click="toggleFilter" v-tooltip.bottom="'Filter content'">
|
||||
<span class="icon"><i class="fas fa-filter" /></span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -94,7 +93,8 @@
|
|||
<td class="is-text-overflow is-vcentered">
|
||||
<div class="field is-grouped">
|
||||
<div class="control is-text-overflow is-expanded">
|
||||
<a :href="uri(`/browser/${item.path}`)" v-if="'dir' === item.type" @click.prevent="handleClick(item)">
|
||||
<a :href="uri(`/browser/${item.path}`)" v-if="'dir' === item.content_type"
|
||||
@click.prevent="handleClick(item)">
|
||||
{{ item.name }}
|
||||
</a>
|
||||
<a :href="makeDownload({}, { filename: item.path, folder: '' })"
|
||||
|
|
@ -113,7 +113,7 @@
|
|||
</div>
|
||||
</td>
|
||||
<td class="has-text-centered is-text-overflow is-unselectable">
|
||||
{{ 'file' === item.type ? formatBytes(item.size) : 'Dir' }}
|
||||
{{ 'file' === item.type ? formatBytes(item.size) : ucFirst(item.type) }}
|
||||
</td>
|
||||
<td class="has-text-centered is-text-overflow is-unselectable">
|
||||
<span :data-datetime="item.mtime" v-tooltip="moment(item.mtime).format('MMMM Do YYYY, h:mm:ss a')">
|
||||
|
|
@ -126,7 +126,8 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="column is-12" v-else>
|
||||
<Message title="Loading content" class="is-background-info-80" icon="fas fa-refresh fa-spin" v-if="isLoading">
|
||||
<Message title="Loading content" class="has-background-info-90 has-text-dark" icon="fas fa-refresh fa-spin"
|
||||
v-if="isLoading">
|
||||
Loading file browser contents...
|
||||
</Message>
|
||||
<Message v-else title="No Content" class="is-background-warning-80 has-text-dark"
|
||||
|
|
@ -339,7 +340,7 @@ const reloadContent = async (dir = '/', fromMounted = false) => {
|
|||
}
|
||||
}
|
||||
|
||||
const popstateHandler = e => {
|
||||
const event_handler = e => {
|
||||
|
||||
if (!e.state) {
|
||||
return
|
||||
|
|
@ -357,12 +358,10 @@ onMounted(async () => {
|
|||
await reloadContent(path.value, true)
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', popstateHandler)
|
||||
document.addEventListener('popstate', event_handler)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('popstate', popstateHandler)
|
||||
})
|
||||
onBeforeUnmount(() => document.removeEventListener('popstate', event_handler))
|
||||
|
||||
const makeBreadCrumb = path => {
|
||||
const baseLink = '/'
|
||||
|
|
@ -400,6 +399,9 @@ watch(model_item, v => {
|
|||
})
|
||||
|
||||
const setIcon = item => {
|
||||
if ('link' === item.type) {
|
||||
return 'fa-link'
|
||||
}
|
||||
if ('dir' === item.content_type) {
|
||||
return 'fa-folder'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,20 @@
|
|||
<style>
|
||||
.terminal {
|
||||
padding-left: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="mt-1 columns is-multiline">
|
||||
<div class="column is-12 is-clearfix">
|
||||
<h1 class="title is-4">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||
<span>Terminal</span>
|
||||
<span>Console</span>
|
||||
</span>
|
||||
</h1>
|
||||
<div class="subtitle is-6 is-unselectable">
|
||||
You can use this terminal window to execute non-interactive commands. The interface is jailed to the
|
||||
You can use this console window to execute non-interactive commands. The interface is jailed to the
|
||||
<code>yt-dlp</code>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -19,8 +25,9 @@
|
|||
<span class="icon"><i class="fa-solid fa-terminal" /></span> Console Output
|
||||
</p>
|
||||
<p class="card-header-icon">
|
||||
<span v-tooltip.top="'Clear console window'" class="icon" @click="clearOutput">
|
||||
<i class="fa-solid fa-broom" /></span>
|
||||
<span v-tooltip.top="'Clear console window'" class="icon" @click="clearOutput()">
|
||||
<i class="fa-solid fa-broom" />
|
||||
</span>
|
||||
</p>
|
||||
</header>
|
||||
<section class="card-content p-0 m-0">
|
||||
|
|
@ -28,12 +35,6 @@
|
|||
</section>
|
||||
<section class="card-content p-1 m-1">
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<span class="icon-text input is-unselectable">
|
||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||
<span>yt-dlp</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<input type="text" class="input" v-model="command" placeholder="--help" autocomplete="off"
|
||||
ref="command_input" @keydown.enter="runCommand" :disabled="isLoading" id="command">
|
||||
|
|
@ -54,22 +55,24 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import "@xterm/xterm/css/xterm.css"
|
||||
import { Terminal } from "@xterm/xterm"
|
||||
import { FitAddon } from "@xterm/addon-fit"
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
const terminal = ref()
|
||||
const terminalFit = ref()
|
||||
const command = ref('')
|
||||
const terminal_window = ref()
|
||||
const command_input = ref()
|
||||
const isLoading = ref(false)
|
||||
const config = useConfigStore()
|
||||
const socket = useSocketStore()
|
||||
const bg_enable = useStorage('random_bg', true)
|
||||
const bg_opacity = useStorage('random_bg_opacity', 0.85)
|
||||
|
||||
const bg_enable = useStorage<boolean>('random_bg', true)
|
||||
const bg_opacity = useStorage<number>('random_bg_opacity', 0.85)
|
||||
|
||||
const terminal = ref<Terminal>()
|
||||
const terminalFit = ref<FitAddon>()
|
||||
const command = ref<string>('')
|
||||
const terminal_window = useTemplateRef<HTMLDivElement>('terminal_window')
|
||||
const command_input = useTemplateRef<HTMLInputElement>('command_input')
|
||||
const isLoading = ref<boolean>(false)
|
||||
|
||||
watch(() => isLoading.value, async value => {
|
||||
if (value) {
|
||||
|
|
@ -94,11 +97,11 @@ watch(() => config.app.console_enabled, async () => {
|
|||
await navigateTo('/')
|
||||
})
|
||||
|
||||
const reSizeTerminal = () => {
|
||||
const handle_event = () => {
|
||||
if (!terminal.value) {
|
||||
return
|
||||
}
|
||||
terminalFit.value.fit()
|
||||
terminalFit.value?.fit()
|
||||
}
|
||||
|
||||
const runCommand = async () => {
|
||||
|
|
@ -122,13 +125,14 @@ const runCommand = async () => {
|
|||
cursorStyle: 'underline',
|
||||
cols: 108,
|
||||
rows: 10,
|
||||
buffer: 1000,
|
||||
disableStdin: true,
|
||||
scrollback: 1000,
|
||||
})
|
||||
terminalFit.value = new FitAddon()
|
||||
terminal.value.loadAddon(terminalFit.value)
|
||||
terminal.value.open(terminal_window.value)
|
||||
if (terminal_window.value) {
|
||||
terminal.value.open(terminal_window.value)
|
||||
}
|
||||
terminalFit.value.fit();
|
||||
}
|
||||
|
||||
|
|
@ -143,7 +147,7 @@ const runCommand = async () => {
|
|||
terminal.value.writeln(`$ yt-dlp ${command.value}`)
|
||||
}
|
||||
|
||||
const clearOutput = async (withCommand = false) => {
|
||||
const clearOutput = async (withCommand: boolean = false) => {
|
||||
if (terminal.value) {
|
||||
terminal.value.clear()
|
||||
}
|
||||
|
|
@ -162,7 +166,7 @@ const focusInput = () => {
|
|||
command_input.value.focus()
|
||||
}
|
||||
|
||||
const writer = s => {
|
||||
const writer = (s: string) => {
|
||||
if (!terminal.value) {
|
||||
return
|
||||
}
|
||||
|
|
@ -182,23 +186,23 @@ watch(() => config.app.basic_mode, async () => {
|
|||
})
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', reSizeTerminal);
|
||||
document.addEventListener('resize', handle_event);
|
||||
focusInput()
|
||||
socket.off('cli_close', loader)
|
||||
socket.off('cli_output', writer)
|
||||
socket.on('cli_close', loader)
|
||||
socket.on('cli_output', writer)
|
||||
if (bg_enable.value) {
|
||||
document.querySelector('body').setAttribute("style", `opacity: 1.0`)
|
||||
document.querySelector('body')?.setAttribute("style", `opacity: 1.0`)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
socket.off('cli_close', loader)
|
||||
socket.off('cli_output', writer)
|
||||
window.removeEventListener('resize', reSizeTerminal)
|
||||
document.removeEventListener('resize', handle_event)
|
||||
if (bg_enable.value) {
|
||||
document.querySelector('body').setAttribute("style", `opacity: ${bg_opacity.value}`)
|
||||
document.querySelector('body')?.setAttribute("style", `opacity: ${bg_opacity.value}`)
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
</div>
|
||||
|
||||
<NewDownload v-if="config.showForm || config.app.basic_mode" @getInfo="url => get_info = url" :item="item_form"
|
||||
@clear_form="item_form = {}" />
|
||||
@clear_form="item_form = {}" @remove_archive="" />
|
||||
<Queue @getInfo="url => get_info = url" :thumbnails="show_thumbnail" />
|
||||
<History @getInfo="url => get_info = url" @add_new="item => toNewDownload(item)" :thumbnails="show_thumbnail" />
|
||||
<GetInfo v-if="get_info" :link="get_info" @closeModel="get_info = ''" />
|
||||
|
|
@ -58,7 +58,6 @@
|
|||
<script setup>
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
const emitter = defineEmits(['getInfo'])
|
||||
const config = useConfigStore()
|
||||
const stateStore = useStateStore()
|
||||
const socket = useSocketStore()
|
||||
|
|
|
|||
|
|
@ -89,19 +89,19 @@ code {
|
|||
</span>
|
||||
<span v-for="log in filteredItems" :key="log.id" class="is-block">
|
||||
<template v-if="log?.datetime">[<span class="has-tooltip" :title="log.datetime">{{ moment(log.datetime).format('HH:mm:ss') }}</span>]</template>
|
||||
{{ log.line }}
|
||||
</span>
|
||||
<span class="is-block" v-if="filteredItems.length < 1">
|
||||
<span class="is-block m-0 notification is-warning is-dark has-text-centered" v-if="query">
|
||||
<span class="notification-title is-danger">
|
||||
<span class="icon"><i class="fas fa-filter"/></span>
|
||||
No logs match this query: <u>{{ query }}</u>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
<span class="has-text-danger">No logs available</span></span>
|
||||
</span>
|
||||
</code>
|
||||
{{ log.line }}
|
||||
</span>
|
||||
<span class="is-block" v-if="filteredItems.length < 1">
|
||||
<span class="is-block m-0 notification is-warning is-dark has-text-centered" v-if="query">
|
||||
<span class="notification-title is-danger">
|
||||
<span class="icon"><i class="fas fa-filter" /></span>
|
||||
No logs match this query: <u>{{ query }}</u>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
<span class="has-text-danger">No logs available</span></span>
|
||||
</span>
|
||||
</code>
|
||||
<div ref="bottomMarker"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -109,30 +109,45 @@ code {
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import moment from 'moment'
|
||||
import { request } from '~/utils/index'
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import type { log_line } from '~/@types/logs'
|
||||
|
||||
let scrollTimeout = null
|
||||
let scrollTimeout: NodeJS.Timeout | null = null
|
||||
|
||||
const toast = useNotification()
|
||||
const socket = useSocketStore()
|
||||
const config = useConfigStore()
|
||||
const route = useRoute()
|
||||
|
||||
const logs = ref([])
|
||||
const offset = ref(0)
|
||||
const loading = ref(false)
|
||||
const logContainer = ref(null)
|
||||
const bottomMarker = ref(null)
|
||||
const autoScroll = ref(true)
|
||||
const textWrap = ref(true)
|
||||
const reachedEnd = ref(false)
|
||||
const bg_enable = useStorage('random_bg', true)
|
||||
const bg_opacity = useStorage('random_bg_opacity', 0.85)
|
||||
const logs = ref<Array<log_line>>([])
|
||||
const offset = ref<number>(0)
|
||||
const loading = ref<boolean>(false)
|
||||
const logContainer = useTemplateRef<HTMLDivElement>('logContainer')
|
||||
const bottomMarker = useTemplateRef<HTMLDivElement>('bottomMarker')
|
||||
const autoScroll = ref<boolean>(true)
|
||||
const textWrap = ref<boolean>(true)
|
||||
const reachedEnd = ref<boolean>(false)
|
||||
const bg_enable = useStorage<boolean>('random_bg', true)
|
||||
const bg_opacity = useStorage<number>('random_bg_opacity', 0.85)
|
||||
|
||||
const query = ref<string>((() => {
|
||||
const filter = route.query.filter ?? ''
|
||||
if (!filter) {
|
||||
return ''
|
||||
}
|
||||
if (typeof filter === 'string') {
|
||||
return filter.trim()
|
||||
}
|
||||
if (Array.isArray(filter) && filter.length > 0) {
|
||||
return filter[0]?.trim() ?? ''
|
||||
}
|
||||
return ''
|
||||
})())
|
||||
|
||||
const query = ref(useRoute().query.filter ?? '')
|
||||
const toggleFilter = ref(false)
|
||||
watch(toggleFilter, () => {
|
||||
if (!toggleFilter.value) {
|
||||
|
|
@ -161,9 +176,11 @@ const filteredItems = computed(() => {
|
|||
const context = contextMatch ? parseInt(contextMatch[1], 10) : 0
|
||||
const searchTerm = raw.replace(/context:\d+/, '').trim()
|
||||
|
||||
if (!searchTerm) return logs.value
|
||||
if (!searchTerm) {
|
||||
return logs.value
|
||||
}
|
||||
|
||||
const result = []
|
||||
const result: Array<log_line> = []
|
||||
const matchedIndexes = new Set()
|
||||
|
||||
logs.value.forEach((log, i) => {
|
||||
|
|
@ -174,7 +191,7 @@ const filteredItems = computed(() => {
|
|||
}
|
||||
})
|
||||
|
||||
Array.from(matchedIndexes).sort((a, b) => a - b).forEach(index => {
|
||||
Array.from(matchedIndexes).sort((a: any, b: any) => a - b).forEach((index: any) => {
|
||||
result.push(logs.value[index])
|
||||
})
|
||||
|
||||
|
|
@ -251,11 +268,11 @@ const handleScroll = () => {
|
|||
}
|
||||
}
|
||||
|
||||
const scrollToBottom = (fast=false) => {
|
||||
const scrollToBottom = (fast = false) => {
|
||||
autoScroll.value = true
|
||||
nextTick(() => {
|
||||
if (bottomMarker.value) {
|
||||
bottomMarker.value.scrollIntoView({ behavior: fast ? 'auto': 'smooth' })
|
||||
bottomMarker.value.scrollIntoView({ behavior: fast ? 'auto' : 'smooth' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -263,7 +280,8 @@ const scrollToBottom = (fast=false) => {
|
|||
onMounted(async () => {
|
||||
await fetchLogs()
|
||||
socket.emit('subscribe', 'log_lines')
|
||||
socket.on('log_lines', data => {
|
||||
|
||||
socket.on('log_lines', (data: any) => {
|
||||
logs.value.push(data)
|
||||
|
||||
nextTick(() => {
|
||||
|
|
@ -271,17 +289,19 @@ onMounted(async () => {
|
|||
bottomMarker.value.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
if (bg_enable.value) {
|
||||
document.querySelector('body').setAttribute("style", `opacity: 1.0`)
|
||||
document.querySelector('body')?.setAttribute("style", `opacity: 1.0`)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
socket.emit('unsubscribe', 'log_lines')
|
||||
socket.off('log_lines')
|
||||
if (bg_enable.value) {
|
||||
document.querySelector('body').setAttribute("style", `opacity: ${bg_opacity.value}`)
|
||||
document.querySelector('body')?.setAttribute("style", `opacity: ${bg_opacity.value}`)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ div.is-centered {
|
|||
</p>
|
||||
|
||||
<p class="control">
|
||||
<button class="button is-info" @click="reloadContent" :class="{ 'is-loading': isLoading }"
|
||||
<button class="button is-info" @click="reloadContent()" :class="{ 'is-loading': isLoading }"
|
||||
:disabled="!socket.isConnected || isLoading" v-if="tasks && tasks.length > 0">
|
||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
||||
</button>
|
||||
|
|
@ -97,9 +97,8 @@ div.is-centered {
|
|||
<span v-if="item.timer" class="has-tooltip" v-tooltip="item.timer">
|
||||
{{ tryParse(item.timer) }}
|
||||
</span>
|
||||
<span v-else class="has-text-danger">
|
||||
<span class="icon"><i class="fa-solid fa-exclamation-triangle" /></span>
|
||||
<span>No timer is set</span>
|
||||
<span v-else class="has-text-warning">
|
||||
No timer is set
|
||||
</span>
|
||||
</td>
|
||||
<td class="is-vcentered is-items-center">
|
||||
|
|
@ -155,9 +154,16 @@ div.is-centered {
|
|||
<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>
|
||||
<span v-if="item.timer">{{ tryParse(item.timer) }} - {{ item.timer }}</span>
|
||||
<span v-else>No timer is set</span>
|
||||
<span class="icon">
|
||||
<i class="fa-solid"
|
||||
:class="{ 'fa-clock': item.timer, 'has-text-danger fa-exclamation-triangle': !item.timer }" />
|
||||
</span>
|
||||
<span v-if="item.timer">
|
||||
<NuxtLink target="_blank" :to="`https://crontab.guru/#${item.timer.replace(/ /g, '_')}`">
|
||||
{{ item.timer }} - {{ tryParse(item.timer) }}
|
||||
</NuxtLink>
|
||||
</span>
|
||||
<span class="has-text-warning" v-else>No timer is set</span>
|
||||
</p>
|
||||
<p class="is-text-overflow" v-if="item.folder">
|
||||
<span class="icon"><i class="fa-solid fa-folder" /></span>
|
||||
|
|
@ -214,25 +220,26 @@ div.is-centered {
|
|||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import moment from 'moment'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { CronExpressionParser } from 'cron-parser'
|
||||
import { request } from '~/utils/index'
|
||||
import type { task_item, exported_task, error_response } from '~/@types/tasks'
|
||||
|
||||
const box = useConfirm()
|
||||
const toast = useNotification()
|
||||
const config = useConfigStore()
|
||||
const socket = useSocketStore()
|
||||
const box = useConfirm()
|
||||
|
||||
const tasks = ref([])
|
||||
const task = ref({})
|
||||
const taskRef = ref('')
|
||||
const toggleForm = ref(false)
|
||||
const isLoading = ref(true)
|
||||
const initialLoad = ref(true)
|
||||
const addInProgress = ref(false)
|
||||
const display_style = useStorage("tasks_display_style", "cards")
|
||||
const tasks = ref<Array<task_item>>([])
|
||||
const task = ref<task_item | Object>({})
|
||||
const taskRef = ref<string>('')
|
||||
const toggleForm = ref<boolean>(false)
|
||||
const isLoading = ref<boolean>(true)
|
||||
const initialLoad = ref<boolean>(true)
|
||||
const addInProgress = ref<boolean>(false)
|
||||
const display_style = useStorage<string>("tasks_display_style", "cards")
|
||||
const remove_keys = ['in_progress']
|
||||
|
||||
watch(() => config.app.basic_mode, async () => {
|
||||
|
|
@ -250,7 +257,7 @@ watch(() => socket.isConnected, async () => {
|
|||
}
|
||||
})
|
||||
|
||||
const reloadContent = async (fromMounted = false) => {
|
||||
const reloadContent = async (fromMounted: boolean = false) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const response = await request('/api/tasks')
|
||||
|
|
@ -276,18 +283,16 @@ const reloadContent = async (fromMounted = false) => {
|
|||
}
|
||||
}
|
||||
|
||||
const resetForm = (closeForm = false) => {
|
||||
const resetForm = (closeForm: boolean = false) => {
|
||||
task.value = {}
|
||||
taskRef.value = null
|
||||
taskRef.value = ''
|
||||
addInProgress.value = false
|
||||
if (closeForm) {
|
||||
toggleForm.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateTasks = async items => {
|
||||
let data = {}
|
||||
|
||||
const updateTasks = async (items: Array<task_item>) => {
|
||||
try {
|
||||
addInProgress.value = true
|
||||
|
||||
|
|
@ -299,24 +304,24 @@ const updateTasks = async items => {
|
|||
body: JSON.stringify(items.map(item => cleanObject(toRaw(item), remove_keys))),
|
||||
})
|
||||
|
||||
data = await response.json()
|
||||
const data: Array<task_item> | error_response = await response.json()
|
||||
|
||||
if (200 !== response.status) {
|
||||
toast.error(`Failed to update task. ${data.error}`);
|
||||
if ("error" in data) {
|
||||
toast.error(`Failed to update tasks. ${data.error}`);
|
||||
return false
|
||||
}
|
||||
|
||||
tasks.value = data
|
||||
resetForm(true)
|
||||
return true
|
||||
} catch (e) {
|
||||
toast.error(`Failed to update task. ${data?.error ?? e.message}`);
|
||||
} catch (e: any) {
|
||||
toast.error(`Failed to update tasks. ${e.message}`);
|
||||
} finally {
|
||||
addInProgress.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteItem = async item => {
|
||||
const deleteItem = async (item: task_item) => {
|
||||
if (true !== box.confirm(`Delete '${item.name}' task?`, true)) {
|
||||
return
|
||||
}
|
||||
|
|
@ -338,7 +343,7 @@ const deleteItem = async item => {
|
|||
toast.success('Task deleted.')
|
||||
}
|
||||
|
||||
const updateItem = async ({ reference, task }) => {
|
||||
const updateItem = async ({ reference, task }: { reference?: string, task: task_item }) => {
|
||||
if (reference) {
|
||||
// -- find the task index.
|
||||
const index = tasks.value.findIndex((t) => t?.id === reference)
|
||||
|
|
@ -354,17 +359,17 @@ const updateItem = async ({ reference, task }) => {
|
|||
return
|
||||
}
|
||||
|
||||
toast.success('Task updated')
|
||||
toast.success('Task updated.')
|
||||
resetForm(true)
|
||||
}
|
||||
|
||||
const editItem = item => {
|
||||
const editItem = (item: task_item) => {
|
||||
task.value = item
|
||||
taskRef.value = item.id
|
||||
toggleForm.value = true
|
||||
}
|
||||
|
||||
const calcPath = path => {
|
||||
const calcPath = (path: string) => {
|
||||
const loc = config.app.download_path || '/downloads'
|
||||
|
||||
if (path) {
|
||||
|
|
@ -382,7 +387,7 @@ onMounted(async () => {
|
|||
await reloadContent(true)
|
||||
});
|
||||
|
||||
const tryParse = expression => {
|
||||
const tryParse = (expression: string) => {
|
||||
try {
|
||||
return moment(CronExpressionParser.parse(expression).next().toISOString()).fromNow()
|
||||
} catch (e) {
|
||||
|
|
@ -390,7 +395,7 @@ const tryParse = expression => {
|
|||
}
|
||||
}
|
||||
|
||||
const runNow = async item => {
|
||||
const runNow = async (item: task_item) => {
|
||||
if (true !== box.confirm(`Run '${item.name}' now? it will also run at the scheduled time.`)) {
|
||||
return
|
||||
}
|
||||
|
|
@ -400,7 +405,7 @@ const runNow = async item => {
|
|||
let data = {
|
||||
url: item.url,
|
||||
preset: item.preset,
|
||||
}
|
||||
} as task_item
|
||||
|
||||
if (item.folder) {
|
||||
data.folder = item.folder
|
||||
|
|
@ -422,9 +427,9 @@ const runNow = async item => {
|
|||
}, 500)
|
||||
}
|
||||
|
||||
onUnmounted(() => socket.off('status', statusHandler))
|
||||
onBeforeUnmount(() => socket.off('status', statusHandler))
|
||||
|
||||
const statusHandler = async stream => {
|
||||
const statusHandler = async (stream: string) => {
|
||||
const { status, msg } = JSON.parse(stream)
|
||||
|
||||
if ('error' === status) {
|
||||
|
|
@ -433,7 +438,7 @@ const statusHandler = async stream => {
|
|||
}
|
||||
}
|
||||
|
||||
const exportItem = async item => {
|
||||
const exportItem = async (item: task_item) => {
|
||||
const info = JSON.parse(JSON.stringify(item))
|
||||
|
||||
let data = {
|
||||
|
|
@ -442,7 +447,7 @@ const exportItem = async item => {
|
|||
preset: info.preset,
|
||||
timer: info.timer,
|
||||
folder: info.folder,
|
||||
}
|
||||
} as exported_task
|
||||
|
||||
if (info.template) {
|
||||
data.template = info.template
|
||||
|
|
@ -452,8 +457,8 @@ const exportItem = async item => {
|
|||
data.cli = info.cli
|
||||
}
|
||||
|
||||
data['_type'] = 'task'
|
||||
data['_version'] = '2.0'
|
||||
data._type = 'task'
|
||||
data._version = '2.0'
|
||||
|
||||
return copyText(base64UrlEncode(JSON.stringify(data)));
|
||||
}
|
||||
|
|
|
|||
1645
ui/pnpm-lock.yaml
1645
ui/pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -12,13 +12,13 @@ export const useSocketStore = defineStore('socket', () => {
|
|||
|
||||
const connect = () => {
|
||||
let opts = {
|
||||
transports: ['websocket'],
|
||||
transports: ['websocket', 'polling'],
|
||||
withCredentials: true,
|
||||
}
|
||||
|
||||
let url = runtimeConfig.public.wss
|
||||
|
||||
if ('dev' !== runtimeConfig.public.APP_ENV) {
|
||||
if ('development' !== runtimeConfig.public?.APP_ENV) {
|
||||
url = window.origin;
|
||||
opts.path = `${runtimeConfig.app.baseURL.replace(/\/$/, '')}/socket.io`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -505,6 +505,18 @@ const uri = u => {
|
|||
return eTrim(runtimeConfig.app.baseURL, '/') + '/' + sTrim(u, '/');
|
||||
}
|
||||
|
||||
const formatTime = seconds => {
|
||||
const hrs = Math.floor(seconds / 3600)
|
||||
const mins = Math.floor((seconds % 3600) / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
|
||||
const pad = (n) => n.toString().padStart(2, '0')
|
||||
|
||||
if (hrs > 0) return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`
|
||||
if (mins > 0) return `${pad(mins)}:${pad(secs)}`
|
||||
return `${secs}`
|
||||
}
|
||||
|
||||
export {
|
||||
ag_set,
|
||||
ag,
|
||||
|
|
@ -533,4 +545,5 @@ export {
|
|||
toggleClass,
|
||||
cleanObject,
|
||||
uri,
|
||||
formatTime,
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue