Merge pull request #299 from arabcoders/dev

Tag premiere video correctly.
This commit is contained in:
Abdulmohsen 2025-06-14 22:14:04 +03:00 committed by GitHub
commit 2eab819e87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 4542 additions and 3624 deletions

View file

@ -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. 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 | | Environment Variable | Description | Default |
| ------------------------ | ---------------------------------------------------------------- | ---------------------------------- | | ------------------------- | ------------------------------------------------------------------ | ---------------------------------- |
| YTP_OUTPUT_TEMPLATE | The template for the filenames of the downloaded videos | `%(title)s.%(ext)s` | | 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_DEFAULT_PRESET | The default preset to use for the download | `default` |
| YTP_INSTANCE_TITLE | The title of the instance | `empty string` | | YTP_INSTANCE_TITLE | The title of the instance | `empty string` |
| YTP_FILE_LOGGING | Whether to log to file | `false` | | YTP_FILE_LOGGING | Whether to log to file | `false` |
| YTP_DOWNLOAD_PATH | Path to where the downloads will be saved | `/downloads` | | YTP_DOWNLOAD_PATH | Path to where the downloads will be saved | `/downloads` |
| YTP_MAX_WORKERS | How many works to use for downloads | `1` | | YTP_MAX_WORKERS | How many works to use for downloads | `1` |
| YTP_AUTH_USERNAME | Username for basic authentication | `empty string` | | YTP_AUTH_USERNAME | Username for basic authentication | `empty string` |
| YTP_AUTH_PASSWORD | Password for basic authentication | `empty string` | | YTP_AUTH_PASSWORD | Password for basic authentication | `empty string` |
| YTP_CONSOLE_ENABLED | Whether to enable the console | `false` | | YTP_CONSOLE_ENABLED | Whether to enable the console | `false` |
| YTP_REMOVE_FILES | Remove the actual file when clicking the remove button | `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_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_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_TEMP_KEEP | Whether to keep the Individual video temp directory or remove it | `false` |
| YTP_KEEP_ARCHIVE | Keep history of downloaded videos | `true` | | 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_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_HOST | Which IP address to bind to | `0.0.0.0` |
| YTP_PORT | Which port to bind to | `8081` | | YTP_PORT | Which port to bind to | `8081` |
| YTP_LOG_LEVEL | Log level | `info` | | YTP_LOG_LEVEL | Log level | `info` |
| YTP_STREAMER_VCODEC | The video codec to use for in-browser streaming | `libx264` | | 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_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_ACCESS_LOG | Whether to log access to the web server | `true` |
| YTP_DEBUG | Whether to turn on debug mode | `false` | | YTP_DEBUG | Whether to turn on debug mode | `false` |
| YTP_DEBUGPY_PORT | The port to use for the debugpy debugger | `5678` | | 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_SOCKET_TIMEOUT | The timeout for the yt-dlp socket connection variable | `30` |
| YTP_EXTRACT_INFO_TIMEOUT | The timeout for extracting video information | `70` | | 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_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_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_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_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_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` |
| YTP_BASIC_MODE | Whether to run WebUI in basic mode | `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_PICTURES_BACKENDS | A comma separated list of pictures urls to use. | `empty string` |
| YTP_BROWSER_ENABLED | Whether to enable the file browser | `false` | | 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_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_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
View file

View file

@ -18,6 +18,7 @@ from .config import Config
from .DataStore import DataStore from .DataStore import DataStore
from .Download import Download from .Download import Download
from .Events import EventBus, Events from .Events import EventBus, Events
from .Events import info as event_info
from .Events import warning as event_warning from .Events import warning as event_warning
from .ItemDTO import Item, ItemDTO from .ItemDTO import Item, ItemDTO
from .Presets import Presets from .Presets import Presets
@ -63,6 +64,12 @@ class DownloadQueue(metaclass=Singleton):
_instance = None _instance = None
"""Instance of the DownloadQueue.""" """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): def __init__(self, connection: Connection, config: Config | None = None):
DownloadQueue._instance = self DownloadQueue._instance = self
@ -233,6 +240,7 @@ class DownloadQueue(metaclass=Singleton):
options: dict = {} options: dict = {}
error: str | None = None error: str | None = None
live_in: str | None = None live_in: str | None = None
is_premiere: bool = bool(entry.get("is_premiere", False))
# check if the video is live stream. # check if the video is live stream.
if "live_status" in entry and "is_upcoming" == entry.get("live_status"): 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) live_in = formatdate(entry.get("release_timestamp"), usegmt=True)
item.extras.update({"live_in": live_in}) item.extras.update({"live_in": live_in})
else: 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: else:
error = entry.get("msg") 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) is_live = bool(entry.get("is_live") or live_in or entry.get("live_status") in live_status)
try: 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: except Exception as e:
LOG.exception(e) LOG.exception(e)
return {"status": "error", "msg": str(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): if isinstance(key, str) and key.startswith("playlist") and entry.get(key):
item.extras[key] = 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( dl = ItemDTO(
id=str(entry.get("id")), id=str(entry.get("id")),
title=str(entry.get("title")), title=str(entry.get("title")),
@ -304,18 +320,20 @@ class DownloadQueue(metaclass=Singleton):
try: try:
dlInfo: Download = Download(info=dl, info_dict=entry, logs=logs) dlInfo: Download = Download(info=dl, info_dict=entry, logs=logs)
text_logs = "" text_logs: str = ""
if filtered_logs := extract_ytdlp_logs(logs): if filtered_logs := extract_ytdlp_logs(logs):
text_logs = f" Logs: {', '.join(filtered_logs)}" text_logs = f" Logs: {', '.join(filtered_logs)}"
if live_in or "is_upcoming" == entry.get("live_status"): if live_in or "is_upcoming" == entry.get("live_status"):
NotifyEvent = Events.COMPLETED NotifyEvent = Events.COMPLETED
dlInfo.info.status = "not_live" dlInfo.info.status = "not_live"
dlInfo.info.msg = "Stream is not live yet." + text_logs dlInfo.info.msg = (
itemDownload = self.done.put(dlInfo) 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: elif len(entry.get("formats", [])) < 1:
availability = entry.get("availability", "public") availability: str = entry.get("availability", "public")
msg = "No formats found." msg: str = "No formats found."
if availability and availability not in ("public",): if availability and availability not in ("public",):
msg += f" Availability is set for '{availability}'." msg += f" Availability is set for '{availability}'."
@ -323,7 +341,27 @@ class DownloadQueue(metaclass=Singleton):
dlInfo.info.status = "error" dlInfo.info.status = "error"
itemDownload = self.done.put(dlInfo) itemDownload = self.done.put(dlInfo)
NotifyEvent = Events.COMPLETED 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: else:
NotifyEvent = Events.ADDED NotifyEvent = Events.ADDED
itemDownload = self.queue.put(dlInfo) itemDownload = self.queue.put(dlInfo)
@ -464,12 +502,10 @@ class DownloadQueue(metaclass=Singleton):
if not entry: if not entry:
return {"status": "error", "msg": "Unable to extract info." + "\n".join(logs)} return {"status": "error", "msg": "Unable to extract info." + "\n".join(logs)}
if not item.requeued: if not item.requeued and (condition := Conditions.get_instance().match(info=entry)):
condition = Conditions.get_instance().match(info=entry) already.pop()
if condition is not None: LOG.info(f"Condition '{condition.name}' matched for '{item.url}'.")
already.pop() return await self.add(item=item.new_with(requeued=True, cli=condition.cli), already=already)
LOG.info(f"Condition '{condition}' matched for '{item.url}'.")
return await self.add(item=item.new_with(requeued=True, cli=condition.cli), already=already)
end_time = time.perf_counter() - started 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'.") 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 removed_files = 0
filename: str = "" filename: str = ""
LOG.info( LOG.debug(
f"{remove_file=} {itemRef} - Removing local files: {self.config.remove_files}, {item.info.status=}" 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(): if self.is_paused() or self.done.empty():
return 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) 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()): for id, item in list(self.done.items()):
if item.info.status not in status: if item.info.status not in status:
continue 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: if not item.is_live:
LOG.debug(f"Item '{item_ref}' is not a live stream.") LOG.debug(f"Item '{item_ref}' is not a live stream.")
continue 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.") LOG.debug(f"Item '{item_ref}' marked as live stream, but no date is set.")
continue 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) 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)): 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)}'.") LOG.debug(f"Item '{item_ref}' is not yet live. will start in '{dt_delta(starts_in-time_now)}'.")
continue 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.") LOG.info(f"Re-queuing item '{item_ref} {item.info.extras=}' for download.")
try: try:
@ -862,17 +910,18 @@ class DownloadQueue(metaclass=Singleton):
continue continue
try: try:
info = item.info await self.add(
new_queue = Item( item=Item(
url=info.url, url=item.info.url,
preset=info.preset, preset=item.info.preset,
folder=info.folder, folder=item.info.folder,
cookies=info.cookies, cookies=item.info.cookies,
template=info.template, template=item.info.template,
cli=item.info.cli, cli=item.info.cli,
extras=item.info.extras, extras=item.info.extras,
)
) )
await self.add(item=new_queue)
except Exception as e: except Exception as e:
LOG.error(f"Failed to re-queue item '{item_ref}'. {e!s}") self.done.put(item)
LOG.exception(e) 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

View file

@ -1,30 +1,24 @@
import asyncio
import errno
import functools import functools
import inspect
import logging import logging
import os
import shlex
import time
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
import anyio
import socketio import socketio
from aiohttp import web 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 .config import Config
from .DownloadQueue import DownloadQueue from .DownloadQueue import DownloadQueue
from .encoder import Encoder from .encoder import Encoder
from .Events import Event, EventBus, Events, error from .Events import Event, EventBus, Events
from .ItemDTO import Item 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. This class is used to handle WebSocket events.
""" """
@ -32,15 +26,11 @@ class HttpSocket(Common):
config: Config config: Config
sio: socketio.AsyncServer sio: socketio.AsyncServer
queue: DownloadQueue queue: DownloadQueue
di_context: dict[str, object] = {}
subscribers: dict[str, list[str]] = {}
"""Event subscriber list."""
log_task = None
"""Task to tail the log file."""
def __init__( def __init__(
self, self,
root_path: Path,
queue: DownloadQueue | None = None, queue: DownloadQueue | None = None,
encoder: Encoder | None = None, encoder: Encoder | None = None,
config: Config | None = None, config: Config | None = None,
@ -55,20 +45,28 @@ class HttpSocket(Common):
async_handlers=True, async_handlers=True,
async_mode="aiohttp", async_mode="aiohttp",
cors_allowed_origins=[], cors_allowed_origins=[],
transports=["websocket"], transports=["websocket", "polling"],
logger=self.config.debug, logger=self.config.debug,
engineio_logger=self.config.debug, engineio_logger=self.config.debug,
ping_interval=10, ping_interval=10,
ping_timeout=5, ping_timeout=5,
) )
encoder = encoder or Encoder() encoder = encoder or Encoder()
self.rootPath = root_path
def emit(e: Event, _, **kwargs): def emit(e: Event, _, **kwargs):
return self.sio.emit(event=e.event, data=encoder.encode(e.data), **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 @staticmethod
def ws_event(func): # type: ignore def ws_event(func): # type: ignore
@ -93,347 +91,38 @@ class HttpSocket(Common):
LOG.debug("Socket server shutdown complete.") LOG.debug("Socket server shutdown complete.")
def attach(self, app: web.Application): 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") 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( self._notify.subscribe(
Events.ADD_URL, 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", f"{__class__.__name__}.add",
) )
# register the shutdown event. load_modules(self.rootPath, self.rootPath / "routes" / "socket")
app.on_shutdown.append(self.on_shutdown)
@ws_event for route in get_routes(RouteType.SOCKET).values():
async def cli_post(self, sid: str, data): LOG.debug(
if not self.config.console_enabled: f"Add ({route.name}) {route.method.value if isinstance(route.method,RouteType) else route.method}: {route.path}."
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",
}
) )
self.sio.on(route.path)(HttpSocket._injector(route.handler, route.path, self.di_context))
try: @staticmethod
import pty def _injector(func, event: str, container: dict):
sig: inspect.Signature = inspect.signature(func)
master_fd, slave_fd = pty.openpty() async def wrapper(sid, data, **kwargs):
stdin_arg = asyncio.subprocess.DEVNULL args = {}
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 = await asyncio.create_subprocess_exec( merged = {**container, "sid": sid, "data": data, "event_name": event}
*args, if isinstance(kwargs, dict):
cwd=self.config.download_path, merged.update(kwargs)
stdin=stdin_arg,
stdout=stdout_arg,
stderr=stderr_arg,
env=_env,
)
if use_pty: for name in sig.parameters:
try: if name in merged:
os.close(slave_fd) args[name] = merged[name]
except Exception as e:
LOG.error(f"Error closing PTY. '{e!s}'.")
async def reader(sid: str): return await func(**args)
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
loop = asyncio.get_running_loop() return wrapper
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)

View file

@ -175,13 +175,14 @@ class Presets(metaclass=Singleton):
Presets: The current instance. Presets: The current instance.
""" """
has: int = len(self._items)
self.clear() self.clear()
if not self._file.exists() or self._file.stat().st_size < 10: if not self._file.exists() or self._file.stat().st_size < 10:
return self return self
try: 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()) presets: dict = json.loads(self._file.read_text())
except Exception as e: except Exception as e:
LOG.error(f"Failed to parse '{self._file}'. '{e}'.") LOG.error(f"Failed to parse '{self._file}'. '{e}'.")

View file

@ -103,7 +103,7 @@ class Scheduler(metaclass=Singleton):
self._jobs[job_id] = job 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 return job_id

View file

@ -17,7 +17,7 @@ from .Scheduler import Scheduler
from .Singleton import Singleton from .Singleton import Singleton
from .Utils import init_class from .Utils import init_class
LOG = logging.getLogger("tasks") LOG: logging.Logger = logging.getLogger("tasks")
@dataclass(kw_only=True) @dataclass(kw_only=True)
@ -124,13 +124,14 @@ class Tasks(metaclass=Singleton):
Tasks: The current instance. Tasks: The current instance.
""" """
has_tasks: bool = len(self._tasks) > 0
self.clear() self.clear()
if not self._file.exists() or self._file.stat().st_size < 1: if not self._file.exists() or self._file.stat().st_size < 1:
return self return self
try: 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()) tasks = json.loads(self._file.read_text())
except Exception as e: except Exception as e:
LOG.error(f"Error loading '{self._file}'. '{e!s}'.") LOG.error(f"Error loading '{self._file}'. '{e!s}'.")

View file

@ -17,10 +17,11 @@ from typing import TypeVar
import yt_dlp import yt_dlp
from Crypto.Cipher import AES from Crypto.Cipher import AES
from yt_dlp.utils import match_str
from .LogWrapper import LogWrapper from .LogWrapper import LogWrapper
LOG = logging.getLogger("Utils") LOG: logging.Logger = logging.getLogger("Utils")
REMOVE_KEYS: list = [ REMOVE_KEYS: list = [
{ {
@ -59,7 +60,8 @@ FILES_TYPE: list = [
{"rx": re.compile(r"\.(nfo|json|jpg|torrent|\.info\.json)$", re.IGNORECASE), "type": "metadata"}, {"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") T = TypeVar("T")
@ -204,6 +206,10 @@ def extract_info(
if not data: if not data:
return 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 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) 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]: 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 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("_"): if file.name.startswith(".") or file.name.startswith("_"):
continue continue
if file.is_symlink(): is_symlink: bool = file.is_symlink()
if is_symlink:
try: try:
test: Path = file.resolve() test: Path = file.resolve()
test.relative_to(base_path) test.relative_to(base_path)
@ -831,9 +873,10 @@ def get_files(base_path: Path | str, dir: str | None = None):
content_type = "download" content_type = "download"
stat = file.stat() stat = file.stat()
contents.append( 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, "content_type": content_type,
"name": file.name, "name": file.name,
"path": str(file.relative_to(base_path)).strip("/"), "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(), "ctime": datetime.fromtimestamp(stat.st_ctime, tz=UTC).isoformat(),
"is_dir": file.is_dir(), "is_dir": file.is_dir(),
"is_file": file.is_file(), "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:]: for line in lines[-(offset + limit) : -offset] if offset else lines[-limit:]:
line_bytes = line if isinstance(line, bytes) else line.encode() line_bytes = line if isinstance(line, bytes) else line.encode()
msg = line.decode(errors="replace") msg = line.decode(errors="replace")
dt_match = DATETIME_PATTERN.match(msg) dt_match = DT_PATTERN.match(msg)
result.append( result.append(
{ {
"id": sha256(line_bytes).hexdigest(), "id": sha256(line_bytes).hexdigest(),
@ -1002,7 +1046,7 @@ async def tail_log(file: Path, emitter: callable, sleep_time: float = 0.5):
continue continue
msg = line.decode(errors="replace") msg = line.decode(errors="replace")
dt_match = DATETIME_PATTERN.match(msg) dt_match = DT_PATTERN.match(msg)
await emitter( await emitter(
{ {
@ -1198,3 +1242,52 @@ def init_class(cls: type[T], data: dict) -> T:
from dataclasses import fields from dataclasses import fields
return cls(**{k: v for k, v in data.items() if k in {f.name for f in fields(cls)}}) 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

View file

@ -2,7 +2,7 @@ import logging
from pathlib import Path from pathlib import Path
from .config import Config from .config import Config
from .Presets import Presets, Preset from .Presets import Preset, Presets
from .Singleton import Singleton from .Singleton import Singleton
from .Utils import REMOVE_KEYS, arg_converter, calc_download_path, load_cookies, merge_dict from .Utils import REMOVE_KEYS, arg_converter, calc_download_path, load_cookies, merge_dict

View file

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

View file

@ -94,6 +94,9 @@ class Config:
db_file: str = "{config_path}/ytptube.db" db_file: str = "{config_path}/ytptube.db"
"""The path to the database file.""" """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" manual_archive: str = "{config_path}/archive.manual.log"
"""The path to the manual archive file.""" """The path to the manual archive file."""
@ -158,6 +161,9 @@ class Config:
is_native: bool = False is_native: bool = False
"Is the application running in webview." "Is the application running in webview."
prevent_live_premiere: bool = False
"""Prevent downloading of the initial premiere live broadcast."""
pictures_backends: list[str] = [ pictures_backends: list[str] = [
"https://unsplash.it/1920/1080?random", "https://unsplash.it/1920/1080?random",
"https://picsum.photos/1920/1080", "https://picsum.photos/1920/1080",
@ -209,6 +215,7 @@ class Config:
"console_enabled", "console_enabled",
"browser_enabled", "browser_enabled",
"ytdlp_auto_update", "ytdlp_auto_update",
"prevent_premiere_live",
) )
"The variables that are booleans." "The variables that are booleans."
@ -367,7 +374,7 @@ class Config:
self._ytdlp_cli_mutable += f"\n--socket-timeout {self.socket_timeout}" self._ytdlp_cli_mutable += f"\n--socket-timeout {self.socket_timeout}"
if self.keep_archive: 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(): if not archive_file.exists():
LOG.info(f"Creating archive file '{archive_file}'.") LOG.info(f"Creating archive file '{archive_file}'.")
archive_file.touch(exist_ok=True) archive_file.touch(exist_ok=True)

149
app/library/router.py Normal file
View 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, {})

View file

@ -1,24 +1,31 @@
#!/usr/bin/env python3 #!/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 asyncio
import logging import logging
import sqlite3 import sqlite3
import sys
from pathlib import Path from pathlib import Path
import caribou import caribou
import magic import magic
from aiohttp import web from aiohttp import web
from library.conditions import Conditions
from library.config import Config from app.library.conditions import Conditions
from library.DownloadQueue import DownloadQueue from app.library.config import Config
from library.Events import EventBus, Events from app.library.DownloadQueue import DownloadQueue
from library.HttpAPI import HttpAPI from app.library.Events import EventBus, Events
from library.HttpSocket import HttpSocket from app.library.HttpAPI import HttpAPI
from library.Notifications import Notification from app.library.HttpSocket import HttpSocket
from library.Presets import Presets from app.library.Notifications import Notification
from library.Scheduler import Scheduler from app.library.Presets import Presets
from library.Tasks import Tasks from app.library.Scheduler import Scheduler
from app.library.Tasks import Tasks
LOG = logging.getLogger("app") LOG = logging.getLogger("app")
MIME = magic.Magic(mime=True) MIME = magic.Magic(mime=True)
@ -58,7 +65,7 @@ class Main:
self._queue = DownloadQueue(connection=connection) self._queue = DownloadQueue(connection=connection)
self._http = HttpAPI(root_path=ROOT_PATH, queue=self._queue) 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) self._app.on_cleanup.append(_close_connection)
@ -117,7 +124,7 @@ class Main:
HTTP_LOGGER = None HTTP_LOGGER = None
if self._config.access_log: 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()) HTTP_LOGGER.addFilter(lambda record: f"GET {self._app.router['ping'].url_for()}" not in record.getMessage())

View file

@ -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 json
import os import os
import queue import queue
import socket import socket
import threading import threading
from pathlib import Path
ready = threading.Event() ready = threading.Event()
exception_holder = queue.Queue() exception_holder = queue.Queue()

135
app/routes/api/_static.py Normal file
View 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
View 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
View 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)

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

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

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

View 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.")

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

View file

@ -1,4 +1,10 @@
#!/usr/bin/env python3 #!/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 logging
import os import os

8
ui/@types/logs.ts Normal file
View 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
View 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 };

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

View file

@ -7,6 +7,7 @@
width: 80vw; width: 80vw;
} }
</style> </style>
<template> <template>
<div class="content"> <div class="content">
<h1 class="has-text-white">Not downloaded yet.</h1> <h1 class="has-text-white">Not downloaded yet.</h1>
@ -14,10 +15,8 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { onMounted, onUnmounted } from 'vue' defineProps({
const props = defineProps({
url: { url: {
type: String, type: String,
required: true, required: true,
@ -26,12 +25,13 @@ const props = defineProps({
const emitter = defineEmits(['closeModel']) const emitter = defineEmits(['closeModel'])
const eventFunc = e => { const handle_event = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key !== 'Escape') {
emitter('closeModel') return
} }
emitter('closeModel')
} }
onMounted(async () => window.addEventListener('keydown', eventFunc)) onMounted(() => document.addEventListener('keydown', handle_event))
onUnmounted(() => window.removeEventListener('keydown', eventFunc)) onBeforeUnmount(() => document.removeEventListener('keydown', handle_event))
</script> </script>

View file

@ -1,47 +1,50 @@
<template> <template>
<div> <div>
<div class="modal is-active" v-if="false === externalModel"> <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 class="modal-content" style="width:60vw;">
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading"> <div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading">
<i class="fas fa-circle-notch fa-spin"></i> <i class="fas fa-circle-notch fa-spin"></i>
</div> </div>
<div v-else> <div v-else>
<div class="p-0 m-0" style="position: relative"> <div class="content p-0 m-0" style="position: relative">
<div class="content" style="white-space: pre;"> <pre><code class="p-4 is-block" v-text="data" />
<code class="p-4 is-block" v-text="data" /> <button class="button m-4" @click="() => copyText(JSON.stringify(data, null, 4))"
<button class="button is-small m-4" @click="() => copyText(JSON.stringify(data, null, 4))"
style="position: absolute; top:0; right:0;"> style="position: absolute; top:0; right:0;">
<span class="icon"><i class="fas fa-copy"></i></span> <span class="icon"><i class="fas fa-copy"></i></span>
</button> </button>
</div> </pre>
</div> </div>
</div> </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>
<div style="width:70vw; height: 80vh;" v-else> <div style="width:70vw; height: 80vh;" v-else>
<div class="p-0 m-0" style="position: relative"> <div class="content p-0 m-0" style="position: relative">
<div class="content" style="white-space: pre;"> <pre><code class="p-4 is-block" v-text="data" /></pre>
<code class="p-4 is-block" v-text="data" /> <button class="button m-4" @click="() => copyText(JSON.stringify(data, null, 4))"
<button class="button is-small m-4" @click="() => copyText(JSON.stringify(data, null, 4))" style="position: absolute; top:0; right:0;">
style="position: absolute; top:0; right:0;"> <span class="icon"><i class="fas fa-copy"></i></span>
<span class="icon"><i class="fas fa-copy"></i></span> </button>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <style scoped>
code {
color: var(--bulma-code) !important
}
</style>
<script setup lang="ts">
import { request } from '~/utils/index' import { request } from '~/utils/index'
const emitter = defineEmits(['closeModel'])
const isLoading = ref(false)
const data = ref({})
const toast = useNotification() const toast = useNotification()
const emitter = defineEmits(['closeModel'])
const isLoading = ref<Boolean>(false)
const data = ref<any>({})
const props = defineProps({ const props = defineProps({
link: { link: {
type: String, type: String,
@ -60,16 +63,15 @@ const props = defineProps({
}, },
}) })
const closeModal = () => emitter('closeModel') const handle_event = (e: KeyboardEvent) => {
if (e.key !== 'Escape') {
const eventFunc = e => { return
if (e.key === 'Escape') {
emitter('closeModel')
} }
emitter('closeModel')
} }
onMounted(async () => { 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) const url = props.useUrl ? props.link : '/api/yt-dlp/url/info?url=' + encodePath(props.link)
@ -84,7 +86,7 @@ onMounted(async () => {
data.value = body data.value = body
} }
} catch (e) { } catch (e: any) {
console.error(e) console.error(e)
toast.error(`Error: ${e.message}`) toast.error(`Error: ${e.message}`)
} finally { } finally {
@ -92,5 +94,5 @@ onMounted(async () => {
} }
}) })
onUnmounted(() => window.removeEventListener('keydown', eventFunc)) onBeforeUnmount(() => document.removeEventListener('keydown', handle_event))
</script> </script>

View file

@ -76,7 +76,7 @@
<div class="columns is-multiline" v-if="'list' === display_style"> <div class="columns is-multiline" v-if="'list' === display_style">
<div class="column is-12" v-if="hasItems"> <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" <table class="table is-striped is-hoverable is-fullwidth is-bordered"
style="min-width: 1300px; table-layout: fixed;"> style="min-width: 1300px; table-layout: fixed;">
<thead> <thead>
@ -106,7 +106,12 @@
:id="'checkbox-' + item._id" :value="item._id"> :id="'checkbox-' + item._id" :value="item._id">
</label> </label>
</td> </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"> <div v-if="showThumbnails && item.extras.thumbnail">
<FloatingImage :image="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))" <FloatingImage :image="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
:title="item.title"> :title="item.title">
@ -164,30 +169,51 @@
<span class="icon"><i class="fa-solid fa-rotate-right" /></span> <span class="icon"><i class="fa-solid fa-rotate-right" /></span>
</button> </button>
</div> </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'"> <div class="control" v-if="item.filename && item.status === 'finished'">
<a class="button is-link is-fullwidth is-small" :href="makeDownload(config, item)" <a class="button is-link is-fullwidth is-small" :href="makeDownload(config, item)"
v-tooltip="'Download video'" :download="item.filename?.split('/').reverse()[0]"> v-tooltip="'Download video'" :download="item.filename?.split('/').reverse()[0]">
<span class="icon"><i class="fa-solid fa-download" /></span> <span class="icon"><i class="fa-solid fa-download" /></span>
</a> </a>
</div> </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"> <div class="control">
<button class="button is-danger is-fullwidth is-small" @click="removeItem(item)" <button class="button is-danger is-fullwidth is-small" @click="removeItem(item)"
v-tooltip="'Remove video'"> v-tooltip="'Remove video'">
<span class="icon"><i class="fa-solid fa-trash-can" /></span> <span class="icon"><i class="fa-solid fa-trash-can" /></span>
</button> </button>
</div> </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> </div>
</td> </td>
</tr> </tr>
@ -202,12 +228,16 @@
v-for="item in sortCompleted" :key="item._id"> v-for="item in sortCompleted" :key="item._id">
<div class="card is-flex is-full-height is-flex-direction-column" <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 }"> :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"> <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>
<div class="card-header-icon"> <div class="card-header-icon">
<span class="tag is-info" v-if="item.extras?.duration">
{{ formatTime(item.extras.duration) }}
</span>
<span v-if="!showThumbnails"> <span v-if="!showThumbnails">
<a v-if="'finished' === item.status && item.filename" href="#" @click.prevent="playVideo(item)" <a v-if="'finished' === item.status && item.filename" href="#" @click.prevent="playVideo(item)"
v-tooltip="'Play video.'"> v-tooltip="'Play video.'">
@ -305,14 +335,7 @@
</span> </span>
</a> </a>
</div> </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'"> <div class="column is-half-mobile" v-if="item.filename && item.status === 'finished'">
<a class="button is-link is-fullwidth" :href="makeDownload(config, item)" <a class="button is-link is-fullwidth" :href="makeDownload(config, item)"
:download="item.filename?.split('/').reverse()[0]"> :download="item.filename?.split('/').reverse()[0]">
@ -323,12 +346,36 @@
</a> </a>
</div> </div>
<div class="column is-half-mobile" v-if="item.url && !config.app.basic_mode"> <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)"> <Dropdown icons="fa-solid fa-cogs" label="Actions">
<span class="icon-text is-block"> <NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url)">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>Information</span> <span>Information</span>
</span> </NuxtLink>
</button>
<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> </div>
</div> </div>
@ -336,23 +383,13 @@
</LateLoader> </LateLoader>
</div> </div>
<div class="content has-text-centered" v-if="!hasItems"> <div class="columns is-multiline" v-if="!hasItems">
<p v-if="socket.isConnected"> <div class="column is-12">
<span class="icon-text"> <Message message_class="has-background-success-90 has-text-dark" title="No records in history."
<span class="icon has-text-success"> icon="fas fa-circle-check" v-if="socket.isConnected" />
<i class="fa-solid fa-circle-check" /> <Message message_class="has-background-info-90 has-text-dark" title="Connecting.." icon="fas fa-spinner fa-spin"
</span> v-else />
<span>No records.</span> </div>
</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>
<div class="modal is-active" v-if="video_item"> <div class="modal is-active" v-if="video_item">
@ -371,6 +408,10 @@
</div> </div>
<button class="modal-close is-large" aria-label="close" @click="embed_url = ''"></button> <button class="modal-close is-large" aria-label="close" @click="embed_url = ''"></button>
</div> </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> </div>
</template> </template>
@ -379,6 +420,8 @@ import moment from 'moment'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { makeDownload, formatBytes, uri } from '~/utils/index' import { makeDownload, formatBytes, uri } from '~/utils/index'
import { isEmbedable, getEmbedable } from '~/utils/embedable' import { isEmbedable, getEmbedable } from '~/utils/embedable'
import Dropdown from './Dropdown.vue'
import { NuxtLink } from '#components'
const emitter = defineEmits(['getInfo', 'add_new']) const emitter = defineEmits(['getInfo', 'add_new'])
@ -401,10 +444,21 @@ const showCompleted = useStorage('showCompleted', true)
const hideThumbnail = useStorage('hideThumbnailHistory', false) const hideThumbnail = useStorage('hideThumbnailHistory', false)
const direction = useStorage('sortCompleted', 'desc') const direction = useStorage('sortCompleted', 'desc')
const display_style = useStorage('display_style', 'cards') const display_style = useStorage('display_style', 'cards')
const table_container = ref(false)
const embed_url = ref('') const embed_url = ref('')
const video_item = ref(null) 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 playVideo = item => video_item.value = item
const closeVideo = () => video_item.value = null const closeVideo = () => video_item.value = null
@ -547,6 +601,9 @@ const setIcon = item => {
if (!item.filename) { if (!item.filename) {
return 'fa-solid fa-exclamation' 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' 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) { 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' return 'fa-solid fa-circle'
@ -589,6 +646,11 @@ const setStatus = item => {
if (!item.filename) { if (!item.filename) {
return 'Skipped?' return 'Skipped?'
} }
if (item.extras?.is_premiere) {
return 'Premiere'
}
return item.is_live ? 'Live Ended' : 'Completed' return item.is_live ? 'Live Ended' : 'Completed'
} }
@ -601,6 +663,9 @@ const setStatus = item => {
} }
if ('not_live' === item.status) { if ('not_live' === item.status) {
if (item.extras?.is_premiere) {
return 'Premiere'
}
return display_style.value === 'cards' ? 'Live Stream' : 'Live' return display_style.value === 'cards' ? 'Live Stream' : 'Live'
} }
@ -621,11 +686,39 @@ const requeueIncomplete = () => {
} }
} }
const archiveItem = item => { const addArchiveDialog = (item) => {
if (!box.confirm(`Archive '${item.title ?? item.id ?? item.url ?? '??'}'?`)) { 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 return
} }
socket.emit('archive_item', item)
socket.emit('item_delete', { id: item._id, remove_file: false }) 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 = { const item_req = {
url: item.url, url: item.url,
preset: item.preset, preset: item.preset,
@ -657,7 +750,7 @@ const reQueueItem = (item, event = null) => {
socket.emit('item_delete', { id: item._id, remove_file: false }) 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.') toast.info('Removed the item from history, and added it to the new download form.')
emitter('add_new', item_req) emitter('add_new', item_req)
return return
@ -715,4 +808,38 @@ const downloadSelected = () => {
} }
const toggle_class = e => e.currentTarget.classList.toggle('is-text-overflow') 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> </script>

View file

@ -1,30 +1,31 @@
<style> <style scoped>
.is-image { img {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
margin: auto; margin: auto;
display: block; display: block;
} }
</style> </style>
<template> <template>
<div> <div>
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading"> <div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading">
<i class="fas fa-circle-notch fa-spin"></i> <i class="fas fa-circle-notch fa-spin"></i>
</div> </div>
<div v-else> <div v-else>
<img :src="image" class="is-image"> <img :src="image">
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { request } from '~/utils/index' import { request } from '~/utils/index'
const emitter = defineEmits(['closeModel'])
const isLoading = ref(false)
const data = ref({})
const toast = useNotification() const toast = useNotification()
const image = ref('') const emitter = defineEmits(['closeModel'])
const isLoading = ref<boolean>(false)
const image = ref<string>('')
const props = defineProps({ const props = defineProps({
link: { link: {
@ -33,15 +34,15 @@ const props = defineProps({
}, },
}) })
const eventFunc = e => { const handle_event = (e: KeyboardEvent) => {
if ('Escape' === e.key) { if (e.key !== 'Escape') {
emitter('closeModel') return
} }
emitter('closeModel')
} }
onMounted(async () => { onMounted(async () => {
window.addEventListener('keydown', eventFunc) document.addEventListener('keydown', handle_event)
const url = props.link.startsWith('/') ? props.link : '/api/thumbnail?url=' + encodePath(props.link) 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()) image.value = URL.createObjectURL(await imgRequest.blob())
} catch (e) { } catch (e: any) {
console.error(e) console.error(e)
toast.error(`Error: ${e.message}`) toast.error(`Error: ${e.message}`)
} finally { } finally {
@ -62,5 +63,5 @@ onMounted(async () => {
} }
}) })
onUnmounted(() => window.removeEventListener('keydown', eventFunc)) onBeforeUnmount(() => document.removeEventListener('keydown', handle_event))
</script> </script>

View file

@ -2,19 +2,19 @@
<div> <div>
<div class="modal is-active"> <div class="modal is-active">
<div class="model-title" v-if="title" /> <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;"> <div class="modal-content" style="width:70vw;">
<slot /> <slot />
</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('close')"></button>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
const emitter = defineEmits(['close']) const emitter = defineEmits(['close'])
const props = defineProps({ defineProps({
title: { title: {
type: String, type: String,
default: '', default: '',
@ -22,14 +22,13 @@ const props = defineProps({
}, },
}) })
const closeModal = () => emitter('close') const handle_event = (e: KeyboardEvent) => {
if (e.key !== 'Escape') {
const eventFunc = e => { return
if (e.key === 'Escape') {
emitter('close')
} }
emitter('close')
} }
onMounted(() => window.addEventListener('keydown', eventFunc)) onMounted(() => document.addEventListener('keydown', handle_event))
onUnmounted(() => window.removeEventListener('keydown', eventFunc)) onBeforeUnmount(() => document.removeEventListener('keydown', handle_event))
</script> </script>

View file

@ -172,16 +172,27 @@
</span> </span>
</div> </div>
</div> </div>
<div class="column is-6-tablet is-4-mobile has-text-left"> <div class="column is-12">
<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="field is-grouped is-justify-self-end"> <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"> <div class="control">
<button type="button" class="button is-danger" @click="resetConfig" <button type="button" class="button is-danger" @click="resetConfig"
:disabled="!socket.isConnected || form?.id" v-tooltip="'Reset local settings'"> :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 config = useConfigStore()
const socket = useSocketStore() const socket = useSocketStore()
const toast = useNotification() const toast = useNotification()
@ -327,6 +338,7 @@ const resetConfig = () => {
} }
showAdvanced.value = false showAdvanced.value = false
separator.value = separators[0].value
toast.success('Local configuration has been reset.') toast.success('Local configuration has been reset.')
} }
@ -370,13 +382,17 @@ onMounted(async () => {
}) })
emitter('clear_form'); emitter('clear_form');
} }
await nextTick()
if (!separators.some(s => s.value === separator.value)) {
separator.value = separators[0].value
}
}) })
const hasFormatInConfig = computed(() => { const hasFormatInConfig = computed(() => {
if (!form?.value?.value) { if (!form.value?.cli) {
return false return false
} }
return /(?<!\S)(-f|--format)(=|\s)(\S+)/.test(form.value.cli) 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 get_preset = name => config.presets.find(item => item.name === name)
const expand_description = e => toggleClass(e.target, ['is-ellipsis', 'is-pre-wrap']) 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> </script>

View file

@ -32,7 +32,7 @@
<hr class="navbar-divider"> <hr class="navbar-divider">
</template> </template>
<div class="notification-list"> <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]"> :class="['notification-item', 'notification-' + n.level]">
<div class="is-flex-grow-1"> <div class="is-flex-grow-1">
<p class="is-size-7 mb-1 notification-message" :class="{ expanded: expandedId === n.id }" <p class="is-size-7 mb-1 notification-message" :class="{ expanded: expandedId === n.id }"

View file

@ -1,5 +1,5 @@
<template> <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-text title is-4">
<span class="icon"> <span class="icon">
<i class="fas" :class="showQueue ? 'fa-solid fa-arrow-up' : 'fa-solid fa-arrow-down'" /> <i class="fas" :class="showQueue ? 'fa-solid fa-arrow-up' : 'fa-solid fa-arrow-down'" />
@ -66,8 +66,11 @@
</label> </label>
</td> </td>
<td class="is-text-overflow is-vcentered"> <td class="is-text-overflow is-vcentered">
<div class="is-inline is-pulled-right" v-if="item.downloaded_bytes"> <div class="is-inline is-pulled-right" v-if="item.downloaded_bytes || item.extras?.duration">
<span class="tag">{{ formatBytes(item.downloaded_bytes) }}</span> <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>
<div v-if="showThumbnails && item.extras?.thumbnail"> <div v-if="showThumbnails && item.extras?.thumbnail">
<FloatingImage :image="uri('/api/thumbnail?url=' + encodePath(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> <NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
</div> </div>
<div class="card-header-icon"> <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)"> <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> <span class="icon"><i class="fa-solid fa-copy" /></span>
</a> </a>
@ -218,24 +225,6 @@
</LateLoader> </LateLoader>
</div> </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 is-active" v-if="embed_url">
<div class="modal-background" @click="embed_url = ''"></div> <div class="modal-background" @click="embed_url = ''"></div>

View file

@ -10,8 +10,8 @@
</style> </style>
<template> <template>
<div v-if="infoLoaded"> <div v-if="infoLoaded">
<video class="player" ref="video" :poster="uri(thumbnail)" :title="title" playsinline controls crossorigin="anonymous" <video class="player" ref="video" :poster="uri(thumbnail)" :title="title" playsinline controls
preload="auto" autoplay> crossorigin="anonymous" preload="auto" autoplay>
<source v-for="source in sources" :key="source.src" :src="source.src" @error="source.onerror" <source v-for="source in sources" :key="source.src" :src="source.src" @error="source.onerror"
:type="source.type" /> :type="source.type" />
<track v-for="(track, i) in tracks" :key="track.file" :kind="track.kind" :label="track.label" <track v-for="(track, i) in tracks" :key="track.file" :kind="track.kind" :label="track.label"
@ -25,11 +25,35 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { onMounted, onUpdated, ref, onUnmounted } from 'vue'
import Hls from 'hls.js' import Hls from 'hls.js'
import { makeDownload } from '~/utils/index' 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 config = useConfigStore()
const toast = useNotification() const toast = useNotification()
@ -42,9 +66,9 @@ const props = defineProps({
const emitter = defineEmits(['closeModel']) const emitter = defineEmits(['closeModel'])
const video = ref(null) const video = useTemplateRef<HTMLMediaElement>('video')
const tracks = ref([]) const tracks = ref<Array<video_track_element>>([])
const sources = ref([]) const sources = ref<Array<video_source_element>>([])
const thumbnail = ref('/images/placeholder.png') const thumbnail = ref('/images/placeholder.png')
const artist = ref('') const artist = ref('')
@ -54,19 +78,39 @@ const isApple = /(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent)
const volume = useStorage('player_volume', 1) const volume = useStorage('player_volume', 1)
const notFirefox = !navigator.userAgent.toLowerCase().includes('firefox') const notFirefox = !navigator.userAgent.toLowerCase().includes('firefox')
const infoLoaded = ref(false) const infoLoaded = ref(false)
const destroyed = ref(false)
let hls = null let hls: Hls | null = null
const eventFunc = e => { const handle_event = (e: KeyboardEvent) => {
if ('Escape' === e.key) { if (e.key !== 'Escape') {
emitter('closeModel') 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 () => { onMounted(async () => {
const req = await request(makeDownload(config, props.item, 'api/file/info')) 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) { if (!req.ok) {
toast.error(`Failed to fetch video info. ${response?.error}`) toast.error(`Failed to fetch video info. ${response?.error}`)
@ -91,13 +135,13 @@ onMounted(async () => {
sources.value.push({ sources.value.push({
src: makeDownload(config, props.item, allowedCodec ? 'api/download' : 'm3u8'), src: makeDownload(config, props.item, allowedCodec ? 'api/download' : 'm3u8'),
type: allowedCodec ? response.mimetype : 'application/x-mpegURL', type: allowedCodec ? response.mimetype : 'application/x-mpegURL',
onerror: e => src_error(e), onerror: (e: Event) => src_error(),
}) })
} else { } else {
sources.value.push({ sources.value.push({
src: makeDownload(config, props.item, 'api/download'), src: makeDownload(config, props.item, 'api/download'),
type: response.mimetype, type: response.mimetype,
onerror: e => src_error(e), onerror: e => src_error(),
}) })
} }
@ -122,7 +166,7 @@ onMounted(async () => {
isAudio.value = true isAudio.value = true
} }
response?.sidecar?.subtitle?.forEach((cap, id) => { response.sidecar?.subtitle?.forEach((cap, _) => {
tracks.value.push({ tracks.value.push({
kind: "captions", kind: "captions",
label: cap.name, label: cap.name,
@ -139,30 +183,35 @@ onMounted(async () => {
await nextTick() await nextTick()
prepareVideoPlayer() prepareVideoPlayer()
window.addEventListener('keydown', eventFunc) document.addEventListener('keydown', handle_event)
}) })
onUpdated(() => prepareVideoPlayer()) onUpdated(() => prepareVideoPlayer())
onUnmounted(() => { onBeforeUnmount(() => {
if (hls) { if (hls) {
hls.destroy() hls.destroy()
} }
window.removeEventListener('keydown', eventFunc) document.removeEventListener('keydown', handle_event)
if (title.value) { if (title.value) {
window.document.title = 'YTPTube' window.document.title = 'YTPTube'
} }
if (video.value) {
try { if (!video.value) {
video.value.pause() return
video.value.querySelectorAll("source").forEach(source => source.removeAttribute("src")) }
video.value.load() destroyed.value = true
}
catch (e) { try {
console.error(e) 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 return
} }
let mediaMetadata = { let mediaMetadata: MediaMetadataInit = { title: title.value }
title: title.value,
}
if (thumbnail.value) { if (thumbnail.value) {
mediaMetadata.artwork = [{ src: thumbnail.value, sizes: '1920x1080', type: 'image/jpeg' }] mediaMetadata.artwork = [{ src: thumbnail.value, sizes: '1920x1080', type: 'image/jpeg' }]
@ -192,22 +239,31 @@ const prepareVideoPlayer = () => {
window.document.title = `YTPTube - Playing: ${title.value}` window.document.title = `YTPTube - Playing: ${title.value}`
} }
video.value.volume = volume.value if (!video.value) {
return
}
video.value.addEventListener('volumechange', () => { video.value.volume = volume.value
volume.value = video.value.volume video.value.addEventListener('volumechange', volume_change_handler)
})
} }
const src_error = () => { const src_error = async () => {
if (hls) { if (hls) {
return return
} }
await nextTick()
if (destroyed.value) {
return
}
console.warn('Direct play failed, trying HLS.') console.warn('Direct play failed, trying HLS.')
attach_hls(makeDownload(config, props.item, 'm3u8')) attach_hls(makeDownload(config, props.item, 'm3u8'))
} }
const attach_hls = link => { const attach_hls = (link: string) => {
if (!video.value) {
return
}
hls = new Hls({ hls = new Hls({
debug: false, debug: false,
enableWorker: true, enableWorker: true,

View file

@ -60,17 +60,17 @@
</a> </a>
<div class="navbar-dropdown"> <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)" <NuxtLink class="navbar-item" to="/logs" @click.native="e => changeRoute(e)"
v-if="config.app.file_logging"> v-if="config.app.file_logging">
<span class="icon"><i class="fa-solid fa-file-lines" /></span> <span class="icon"><i class="fa-solid fa-file-lines" /></span>
<span>Logs</span> <span>Logs</span>
</NuxtLink> </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>
</div> </div>

View file

@ -2,7 +2,7 @@ import path from "path";
let extraNitro = {} let extraNitro = {}
try { try {
const API_URL = import.meta.env.NUXT_API_URL; const API_URL = process.env.NUXT_API_URL;
if (API_URL) { if (API_URL) {
extraNitro = { extraNitro = {
devProxy: { devProxy: {
@ -20,7 +20,6 @@ catch (e) {
export default defineNuxtConfig({ export default defineNuxtConfig({
ssr: false, ssr: false,
devtools: { enabled: false }, devtools: { enabled: false },
devServer: { devServer: {
port: 8082, port: 8082,
host: "0.0.0.0", host: "0.0.0.0",
@ -30,8 +29,8 @@ export default defineNuxtConfig({
], ],
runtimeConfig: { runtimeConfig: {
public: { public: {
APP_ENV: process.env.APP_ENV || "production", APP_ENV: process.env.NODE_ENV,
wss: process.env.VUE_APP_BASE_URL ?? '', wss: process.env.NUXT_PUBLIC_WSS ?? '',
sentry: process.env.NUXT_PUBLIC_SENTRY_DSN ?? '', sentry: process.env.NUXT_PUBLIC_SENTRY_DSN ?? '',
} }
}, },
@ -39,7 +38,7 @@ export default defineNuxtConfig({
transpile: ['vue-toastification'], transpile: ['vue-toastification'],
}, },
app: { app: {
baseURL: 'dev' == process.env.APP_ENV ? '/' : '', baseURL: 'production' == process.env.NODE_ENV ? '' : '/',
buildAssetsDir: "assets", buildAssetsDir: "assets",
head: { head: {
"meta": [ "meta": [
@ -67,7 +66,7 @@ export default defineNuxtConfig({
nitro: { nitro: {
output: { output: {
publicDir: path.join(__dirname, 'dev' == process.env.APP_ENV ? 'dist' : 'exported') publicDir: path.join(__dirname, 'production' === process.env.NODE_ENV ? 'exported' : 'dist')
}, },
...extraNitro, ...extraNitro,
}, },

View file

@ -11,19 +11,19 @@
}, },
"web-types": "./web-types.json", "web-types": "./web-types.json",
"dependencies": { "dependencies": {
"@pinia/nuxt": "^0.11.0", "@pinia/nuxt": "^0.11.1",
"@sentry/nuxt": "^9.24.0", "@sentry/nuxt": "^9.29.0",
"@vueuse/core": "^13.3.0", "@vueuse/core": "^13.3.0",
"@vueuse/nuxt": "^13.3.0", "@vueuse/nuxt": "^13.3.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"cron-parser": "^5.2.0", "cron-parser": "^5.3.0",
"cronstrue": "^2.61.0", "cronstrue": "^2.61.0",
"floating-vue": "^5.2.2", "floating-vue": "^5.2.2",
"hls.js": "^1.6.5", "hls.js": "^1.6.5",
"moment": "^2.30.1", "moment": "^2.30.1",
"nuxt": "^3.17.4", "nuxt": "^3.17.5",
"pinia": "^3.0.2", "pinia": "^3.0.3",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"vue": "^3.5.16", "vue": "^3.5.16",
"vue-router": "^4.5.1", "vue-router": "^4.5.1",

View file

@ -27,8 +27,7 @@
</div> </div>
<div class="control"> <div class="control">
<button class="button is-danger is-light" @click="toggleFilter" <button class="button is-danger is-light" @click="toggleFilter" v-tooltip.bottom="'Filter content'">
v-tooltip.bottom="'Filter content'">
<span class="icon"><i class="fas fa-filter" /></span> <span class="icon"><i class="fas fa-filter" /></span>
</button> </button>
</div> </div>
@ -94,7 +93,8 @@
<td class="is-text-overflow is-vcentered"> <td class="is-text-overflow is-vcentered">
<div class="field is-grouped"> <div class="field is-grouped">
<div class="control is-text-overflow is-expanded"> <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 }} {{ item.name }}
</a> </a>
<a :href="makeDownload({}, { filename: item.path, folder: '' })" <a :href="makeDownload({}, { filename: item.path, folder: '' })"
@ -113,7 +113,7 @@
</div> </div>
</td> </td>
<td class="has-text-centered is-text-overflow is-unselectable"> <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>
<td class="has-text-centered is-text-overflow is-unselectable"> <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')"> <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> </div>
<div class="column is-12" v-else> <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... Loading file browser contents...
</Message> </Message>
<Message v-else title="No Content" class="is-background-warning-80 has-text-dark" <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) { if (!e.state) {
return return
@ -357,12 +358,10 @@ onMounted(async () => {
await reloadContent(path.value, true) await reloadContent(path.value, true)
} }
window.addEventListener('popstate', popstateHandler) document.addEventListener('popstate', event_handler)
}) })
onUnmounted(() => { onBeforeUnmount(() => document.removeEventListener('popstate', event_handler))
window.removeEventListener('popstate', popstateHandler)
})
const makeBreadCrumb = path => { const makeBreadCrumb = path => {
const baseLink = '/' const baseLink = '/'
@ -400,6 +399,9 @@ watch(model_item, v => {
}) })
const setIcon = item => { const setIcon = item => {
if ('link' === item.type) {
return 'fa-link'
}
if ('dir' === item.content_type) { if ('dir' === item.content_type) {
return 'fa-folder' return 'fa-folder'
} }

View file

@ -1,14 +1,20 @@
<style>
.terminal {
padding-left: 10px;
}
</style>
<template> <template>
<div class="mt-1 columns is-multiline"> <div class="mt-1 columns is-multiline">
<div class="column is-12 is-clearfix"> <div class="column is-12 is-clearfix">
<h1 class="title is-4"> <h1 class="title is-4">
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i class="fa-solid fa-terminal" /></span> <span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>Terminal</span> <span>Console</span>
</span> </span>
</h1> </h1>
<div class="subtitle is-6 is-unselectable"> <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> <code>yt-dlp</code>
</div> </div>
</div> </div>
@ -19,8 +25,9 @@
<span class="icon"><i class="fa-solid fa-terminal" /></span> Console Output <span class="icon"><i class="fa-solid fa-terminal" /></span> Console Output
</p> </p>
<p class="card-header-icon"> <p class="card-header-icon">
<span v-tooltip.top="'Clear console window'" class="icon" @click="clearOutput"> <span v-tooltip.top="'Clear console window'" class="icon" @click="clearOutput()">
<i class="fa-solid fa-broom" /></span> <i class="fa-solid fa-broom" />
</span>
</p> </p>
</header> </header>
<section class="card-content p-0 m-0"> <section class="card-content p-0 m-0">
@ -28,12 +35,6 @@
</section> </section>
<section class="card-content p-1 m-1"> <section class="card-content p-1 m-1">
<div class="field is-grouped"> <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"> <div class="control is-expanded">
<input type="text" class="input" v-model="command" placeholder="--help" autocomplete="off" <input type="text" class="input" v-model="command" placeholder="--help" autocomplete="off"
ref="command_input" @keydown.enter="runCommand" :disabled="isLoading" id="command"> ref="command_input" @keydown.enter="runCommand" :disabled="isLoading" id="command">
@ -54,22 +55,24 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import "@xterm/xterm/css/xterm.css" import "@xterm/xterm/css/xterm.css"
import { Terminal } from "@xterm/xterm" import { Terminal } from "@xterm/xterm"
import { FitAddon } from "@xterm/addon-fit" import { FitAddon } from "@xterm/addon-fit"
import { useStorage } from '@vueuse/core' 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 config = useConfigStore()
const socket = useSocketStore() 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 => { watch(() => isLoading.value, async value => {
if (value) { if (value) {
@ -94,11 +97,11 @@ watch(() => config.app.console_enabled, async () => {
await navigateTo('/') await navigateTo('/')
}) })
const reSizeTerminal = () => { const handle_event = () => {
if (!terminal.value) { if (!terminal.value) {
return return
} }
terminalFit.value.fit() terminalFit.value?.fit()
} }
const runCommand = async () => { const runCommand = async () => {
@ -122,13 +125,14 @@ const runCommand = async () => {
cursorStyle: 'underline', cursorStyle: 'underline',
cols: 108, cols: 108,
rows: 10, rows: 10,
buffer: 1000,
disableStdin: true, disableStdin: true,
scrollback: 1000, scrollback: 1000,
}) })
terminalFit.value = new FitAddon() terminalFit.value = new FitAddon()
terminal.value.loadAddon(terminalFit.value) terminal.value.loadAddon(terminalFit.value)
terminal.value.open(terminal_window.value) if (terminal_window.value) {
terminal.value.open(terminal_window.value)
}
terminalFit.value.fit(); terminalFit.value.fit();
} }
@ -143,7 +147,7 @@ const runCommand = async () => {
terminal.value.writeln(`$ yt-dlp ${command.value}`) terminal.value.writeln(`$ yt-dlp ${command.value}`)
} }
const clearOutput = async (withCommand = false) => { const clearOutput = async (withCommand: boolean = false) => {
if (terminal.value) { if (terminal.value) {
terminal.value.clear() terminal.value.clear()
} }
@ -162,7 +166,7 @@ const focusInput = () => {
command_input.value.focus() command_input.value.focus()
} }
const writer = s => { const writer = (s: string) => {
if (!terminal.value) { if (!terminal.value) {
return return
} }
@ -182,23 +186,23 @@ watch(() => config.app.basic_mode, async () => {
}) })
onMounted(async () => { onMounted(async () => {
window.addEventListener('resize', reSizeTerminal); document.addEventListener('resize', handle_event);
focusInput() focusInput()
socket.off('cli_close', loader) socket.off('cli_close', loader)
socket.off('cli_output', writer) socket.off('cli_output', writer)
socket.on('cli_close', loader) socket.on('cli_close', loader)
socket.on('cli_output', writer) socket.on('cli_output', writer)
if (bg_enable.value) { 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_close', loader)
socket.off('cli_output', writer) socket.off('cli_output', writer)
window.removeEventListener('resize', reSizeTerminal) document.removeEventListener('resize', handle_event)
if (bg_enable.value) { if (bg_enable.value) {
document.querySelector('body').setAttribute("style", `opacity: ${bg_opacity.value}`) document.querySelector('body')?.setAttribute("style", `opacity: ${bg_opacity.value}`)
} }
}); });
</script> </script>

View file

@ -48,7 +48,7 @@
</div> </div>
<NewDownload v-if="config.showForm || config.app.basic_mode" @getInfo="url => get_info = url" :item="item_form" <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" /> <Queue @getInfo="url => get_info = url" :thumbnails="show_thumbnail" />
<History @getInfo="url => get_info = url" @add_new="item => toNewDownload(item)" :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 = ''" /> <GetInfo v-if="get_info" :link="get_info" @closeModel="get_info = ''" />
@ -58,7 +58,6 @@
<script setup> <script setup>
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
const emitter = defineEmits(['getInfo'])
const config = useConfigStore() const config = useConfigStore()
const stateStore = useStateStore() const stateStore = useStateStore()
const socket = useSocketStore() const socket = useSocketStore()

View file

@ -89,19 +89,19 @@ code {
</span> </span>
<span v-for="log in filteredItems" :key="log.id" class="is-block"> <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> <template v-if="log?.datetime">[<span class="has-tooltip" :title="log.datetime">{{ moment(log.datetime).format('HH:mm:ss') }}</span>]</template>
{{ log.line }} {{ log.line }}
</span> </span>
<span class="is-block" v-if="filteredItems.length < 1"> <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="is-block m-0 notification is-warning is-dark has-text-centered" v-if="query">
<span class="notification-title is-danger"> <span class="notification-title is-danger">
<span class="icon"><i class="fas fa-filter"/></span> <span class="icon"><i class="fas fa-filter" /></span>
No logs match this query: <u>{{ query }}</u> No logs match this query: <u>{{ query }}</u>
</span> </span>
</span> </span>
<span v-else> <span v-else>
<span class="has-text-danger">No logs available</span></span> <span class="has-text-danger">No logs available</span></span>
</span> </span>
</code> </code>
<div ref="bottomMarker"></div> <div ref="bottomMarker"></div>
</div> </div>
</div> </div>
@ -109,30 +109,45 @@ code {
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import moment from 'moment' import moment from 'moment'
import { request } from '~/utils/index' import { request } from '~/utils/index'
import { ref, onMounted, nextTick } from 'vue' import { ref, onMounted, nextTick } from 'vue'
import { useStorage } from '@vueuse/core' 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 toast = useNotification()
const socket = useSocketStore() const socket = useSocketStore()
const config = useConfigStore() const config = useConfigStore()
const route = useRoute()
const logs = ref([]) const logs = ref<Array<log_line>>([])
const offset = ref(0) const offset = ref<number>(0)
const loading = ref(false) const loading = ref<boolean>(false)
const logContainer = ref(null) const logContainer = useTemplateRef<HTMLDivElement>('logContainer')
const bottomMarker = ref(null) const bottomMarker = useTemplateRef<HTMLDivElement>('bottomMarker')
const autoScroll = ref(true) const autoScroll = ref<boolean>(true)
const textWrap = ref(true) const textWrap = ref<boolean>(true)
const reachedEnd = ref(false) const reachedEnd = ref<boolean>(false)
const bg_enable = useStorage('random_bg', true) const bg_enable = useStorage<boolean>('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.85) 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) const toggleFilter = ref(false)
watch(toggleFilter, () => { watch(toggleFilter, () => {
if (!toggleFilter.value) { if (!toggleFilter.value) {
@ -161,9 +176,11 @@ const filteredItems = computed(() => {
const context = contextMatch ? parseInt(contextMatch[1], 10) : 0 const context = contextMatch ? parseInt(contextMatch[1], 10) : 0
const searchTerm = raw.replace(/context:\d+/, '').trim() 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() const matchedIndexes = new Set()
logs.value.forEach((log, i) => { 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]) result.push(logs.value[index])
}) })
@ -251,11 +268,11 @@ const handleScroll = () => {
} }
} }
const scrollToBottom = (fast=false) => { const scrollToBottom = (fast = false) => {
autoScroll.value = true autoScroll.value = true
nextTick(() => { nextTick(() => {
if (bottomMarker.value) { 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 () => { onMounted(async () => {
await fetchLogs() await fetchLogs()
socket.emit('subscribe', 'log_lines') socket.emit('subscribe', 'log_lines')
socket.on('log_lines', data => {
socket.on('log_lines', (data: any) => {
logs.value.push(data) logs.value.push(data)
nextTick(() => { nextTick(() => {
@ -271,17 +289,19 @@ onMounted(async () => {
bottomMarker.value.scrollIntoView({ behavior: 'smooth' }) bottomMarker.value.scrollIntoView({ behavior: 'smooth' })
} }
}) })
}) })
if (bg_enable.value) { 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.emit('unsubscribe', 'log_lines')
socket.off('log_lines') socket.off('log_lines')
if (bg_enable.value) { if (bg_enable.value) {
document.querySelector('body').setAttribute("style", `opacity: ${bg_opacity.value}`) document.querySelector('body')?.setAttribute("style", `opacity: ${bg_opacity.value}`)
} }
}) })

View file

@ -36,7 +36,7 @@ div.is-centered {
</p> </p>
<p class="control"> <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"> :disabled="!socket.isConnected || isLoading" v-if="tasks && tasks.length > 0">
<span class="icon"><i class="fas fa-refresh" /></span> <span class="icon"><i class="fas fa-refresh" /></span>
</button> </button>
@ -97,9 +97,8 @@ div.is-centered {
<span v-if="item.timer" class="has-tooltip" v-tooltip="item.timer"> <span v-if="item.timer" class="has-tooltip" v-tooltip="item.timer">
{{ tryParse(item.timer) }} {{ tryParse(item.timer) }}
</span> </span>
<span v-else class="has-text-danger"> <span v-else class="has-text-warning">
<span class="icon"><i class="fa-solid fa-exclamation-triangle" /></span> No timer is set
<span>No timer is set</span>
</span> </span>
</td> </td>
<td class="is-vcentered is-items-center"> <td class="is-vcentered is-items-center">
@ -155,9 +154,16 @@ div.is-centered {
<div class="card-content is-flex-grow-1"> <div class="card-content is-flex-grow-1">
<div class="content"> <div class="content">
<p class="is-text-overflow"> <p class="is-text-overflow">
<span class="icon"><i class="fa-solid fa-clock" /></span> <span class="icon">
<span v-if="item.timer">{{ tryParse(item.timer) }} - {{ item.timer }}</span> <i class="fa-solid"
<span v-else>No timer is set</span> :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>
<p class="is-text-overflow" v-if="item.folder"> <p class="is-text-overflow" v-if="item.folder">
<span class="icon"><i class="fa-solid fa-folder" /></span> <span class="icon"><i class="fa-solid fa-folder" /></span>
@ -214,25 +220,26 @@ div.is-centered {
</main> </main>
</template> </template>
<script setup> <script setup lang="ts">
import moment from 'moment' import moment from 'moment'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { CronExpressionParser } from 'cron-parser' import { CronExpressionParser } from 'cron-parser'
import { request } from '~/utils/index' import { request } from '~/utils/index'
import type { task_item, exported_task, error_response } from '~/@types/tasks'
const box = useConfirm()
const toast = useNotification() const toast = useNotification()
const config = useConfigStore() const config = useConfigStore()
const socket = useSocketStore() const socket = useSocketStore()
const box = useConfirm()
const tasks = ref([]) const tasks = ref<Array<task_item>>([])
const task = ref({}) const task = ref<task_item | Object>({})
const taskRef = ref('') const taskRef = ref<string>('')
const toggleForm = ref(false) const toggleForm = ref<boolean>(false)
const isLoading = ref(true) const isLoading = ref<boolean>(true)
const initialLoad = ref(true) const initialLoad = ref<boolean>(true)
const addInProgress = ref(false) const addInProgress = ref<boolean>(false)
const display_style = useStorage("tasks_display_style", "cards") const display_style = useStorage<string>("tasks_display_style", "cards")
const remove_keys = ['in_progress'] const remove_keys = ['in_progress']
watch(() => config.app.basic_mode, async () => { 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 { try {
isLoading.value = true isLoading.value = true
const response = await request('/api/tasks') 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 = {} task.value = {}
taskRef.value = null taskRef.value = ''
addInProgress.value = false addInProgress.value = false
if (closeForm) { if (closeForm) {
toggleForm.value = false toggleForm.value = false
} }
} }
const updateTasks = async items => { const updateTasks = async (items: Array<task_item>) => {
let data = {}
try { try {
addInProgress.value = true addInProgress.value = true
@ -299,24 +304,24 @@ const updateTasks = async items => {
body: JSON.stringify(items.map(item => cleanObject(toRaw(item), remove_keys))), 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) { if ("error" in data) {
toast.error(`Failed to update task. ${data.error}`); toast.error(`Failed to update tasks. ${data.error}`);
return false return false
} }
tasks.value = data tasks.value = data
resetForm(true) resetForm(true)
return true return true
} catch (e) { } catch (e: any) {
toast.error(`Failed to update task. ${data?.error ?? e.message}`); toast.error(`Failed to update tasks. ${e.message}`);
} finally { } finally {
addInProgress.value = false addInProgress.value = false
} }
} }
const deleteItem = async item => { const deleteItem = async (item: task_item) => {
if (true !== box.confirm(`Delete '${item.name}' task?`, true)) { if (true !== box.confirm(`Delete '${item.name}' task?`, true)) {
return return
} }
@ -338,7 +343,7 @@ const deleteItem = async item => {
toast.success('Task deleted.') toast.success('Task deleted.')
} }
const updateItem = async ({ reference, task }) => { const updateItem = async ({ reference, task }: { reference?: string, task: task_item }) => {
if (reference) { if (reference) {
// -- find the task index. // -- find the task index.
const index = tasks.value.findIndex((t) => t?.id === reference) const index = tasks.value.findIndex((t) => t?.id === reference)
@ -354,17 +359,17 @@ const updateItem = async ({ reference, task }) => {
return return
} }
toast.success('Task updated') toast.success('Task updated.')
resetForm(true) resetForm(true)
} }
const editItem = item => { const editItem = (item: task_item) => {
task.value = item task.value = item
taskRef.value = item.id taskRef.value = item.id
toggleForm.value = true toggleForm.value = true
} }
const calcPath = path => { const calcPath = (path: string) => {
const loc = config.app.download_path || '/downloads' const loc = config.app.download_path || '/downloads'
if (path) { if (path) {
@ -382,7 +387,7 @@ onMounted(async () => {
await reloadContent(true) await reloadContent(true)
}); });
const tryParse = expression => { const tryParse = (expression: string) => {
try { try {
return moment(CronExpressionParser.parse(expression).next().toISOString()).fromNow() return moment(CronExpressionParser.parse(expression).next().toISOString()).fromNow()
} catch (e) { } 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.`)) { if (true !== box.confirm(`Run '${item.name}' now? it will also run at the scheduled time.`)) {
return return
} }
@ -400,7 +405,7 @@ const runNow = async item => {
let data = { let data = {
url: item.url, url: item.url,
preset: item.preset, preset: item.preset,
} } as task_item
if (item.folder) { if (item.folder) {
data.folder = item.folder data.folder = item.folder
@ -422,9 +427,9 @@ const runNow = async item => {
}, 500) }, 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) const { status, msg } = JSON.parse(stream)
if ('error' === status) { 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)) const info = JSON.parse(JSON.stringify(item))
let data = { let data = {
@ -442,7 +447,7 @@ const exportItem = async item => {
preset: info.preset, preset: info.preset,
timer: info.timer, timer: info.timer,
folder: info.folder, folder: info.folder,
} } as exported_task
if (info.template) { if (info.template) {
data.template = info.template data.template = info.template
@ -452,8 +457,8 @@ const exportItem = async item => {
data.cli = info.cli data.cli = info.cli
} }
data['_type'] = 'task' data._type = 'task'
data['_version'] = '2.0' data._version = '2.0'
return copyText(base64UrlEncode(JSON.stringify(data))); return copyText(base64UrlEncode(JSON.stringify(data)));
} }

File diff suppressed because it is too large Load diff

View file

@ -12,13 +12,13 @@ export const useSocketStore = defineStore('socket', () => {
const connect = () => { const connect = () => {
let opts = { let opts = {
transports: ['websocket'], transports: ['websocket', 'polling'],
withCredentials: true, withCredentials: true,
} }
let url = runtimeConfig.public.wss let url = runtimeConfig.public.wss
if ('dev' !== runtimeConfig.public.APP_ENV) { if ('development' !== runtimeConfig.public?.APP_ENV) {
url = window.origin; url = window.origin;
opts.path = `${runtimeConfig.app.baseURL.replace(/\/$/, '')}/socket.io`; opts.path = `${runtimeConfig.app.baseURL.replace(/\/$/, '')}/socket.io`;
} }

View file

@ -505,6 +505,18 @@ const uri = u => {
return eTrim(runtimeConfig.app.baseURL, '/') + '/' + sTrim(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 { export {
ag_set, ag_set,
ag, ag,
@ -533,4 +545,5 @@ export {
toggleClass, toggleClass,
cleanObject, cleanObject,
uri, uri,
formatTime,
} }