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.
| Environment Variable | Description | Default |
| ------------------------ | ---------------------------------------------------------------- | ---------------------------------- |
| YTP_OUTPUT_TEMPLATE | The template for the filenames of the downloaded videos | `%(title)s.%(ext)s` |
| YTP_DEFAULT_PRESET | The default preset to use for the download | `default` |
| YTP_INSTANCE_TITLE | The title of the instance | `empty string` |
| YTP_FILE_LOGGING | Whether to log to file | `false` |
| YTP_DOWNLOAD_PATH | Path to where the downloads will be saved | `/downloads` |
| YTP_MAX_WORKERS | How many works to use for downloads | `1` |
| YTP_AUTH_USERNAME | Username for basic authentication | `empty string` |
| YTP_AUTH_PASSWORD | Password for basic authentication | `empty string` |
| YTP_CONSOLE_ENABLED | Whether to enable the console | `false` |
| YTP_REMOVE_FILES | Remove the actual file when clicking the remove button | `false` |
| YTP_CONFIG_PATH | Path to where the config files will be stored. | `/config` |
| YTP_TEMP_PATH | Path to where tmp files are stored. | `/tmp` |
| YTP_TEMP_KEEP | Whether to keep the Individual video temp directory or remove it | `false` |
| YTP_KEEP_ARCHIVE | Keep history of downloaded videos | `true` |
| YTP_YTDL_DEBUG | Whether to turn debug logging for the internal `yt-dlp` package | `false` |
| YTP_HOST | Which IP address to bind to | `0.0.0.0` |
| YTP_PORT | Which port to bind to | `8081` |
| YTP_LOG_LEVEL | Log level | `info` |
| YTP_STREAMER_VCODEC | The video codec to use for in-browser streaming | `libx264` |
| YTP_STREAMER_ACODEC | The audio codec to use for in-browser streaming | `aac` |
| YTP_ACCESS_LOG | Whether to log access to the web server | `true` |
| YTP_DEBUG | Whether to turn on debug mode | `false` |
| YTP_DEBUGPY_PORT | The port to use for the debugpy debugger | `5678` |
| YTP_SOCKET_TIMEOUT | The timeout for the yt-dlp socket connection variable | `30` |
| YTP_EXTRACT_INFO_TIMEOUT | The timeout for extracting video information | `70` |
| YTP_DB_FILE | The path to the SQLite database file | `{config_path}/ytptube.db` |
| YTP_MANUAL_ARCHIVE | The path to the manual archive file | `{config_path}/manual_archive.log` |
| YTP_UI_UPDATE_TITLE | Whether to update the title of the page with the current stats | `true` |
| YTP_PIP_PACKAGES | A space separated list of pip packages to install | `empty string` |
| YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` |
| YTP_BASIC_MODE | Whether to run WebUI in basic mode | `false` |
| YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use. | `empty string` |
| YTP_BROWSER_ENABLED | Whether to enable the file browser | `false` |
| YTP_YTDLP_AUTO_UPDATE | Whether to enable the auto update for yt-dlp | `true` |
| YTP_BASE_PATH | Set this if you are serving YTPTube from sub-folder | `/` |
| Environment Variable | Description | Default |
| ------------------------- | ------------------------------------------------------------------ | ---------------------------------- |
| YTP_OUTPUT_TEMPLATE | The template for the filenames of the downloaded videos | `%(title)s.%(ext)s` |
| YTP_DEFAULT_PRESET | The default preset to use for the download | `default` |
| YTP_INSTANCE_TITLE | The title of the instance | `empty string` |
| YTP_FILE_LOGGING | Whether to log to file | `false` |
| YTP_DOWNLOAD_PATH | Path to where the downloads will be saved | `/downloads` |
| YTP_MAX_WORKERS | How many works to use for downloads | `1` |
| YTP_AUTH_USERNAME | Username for basic authentication | `empty string` |
| YTP_AUTH_PASSWORD | Password for basic authentication | `empty string` |
| YTP_CONSOLE_ENABLED | Whether to enable the console | `false` |
| YTP_REMOVE_FILES | Remove the actual file when clicking the remove button | `false` |
| YTP_CONFIG_PATH | Path to where the config files will be stored. | `/config` |
| YTP_TEMP_PATH | Path to where tmp files are stored. | `/tmp` |
| YTP_TEMP_KEEP | Whether to keep the Individual video temp directory or remove it | `false` |
| YTP_KEEP_ARCHIVE | Keep history of downloaded videos | `true` |
| YTP_YTDL_DEBUG | Whether to turn debug logging for the internal `yt-dlp` package | `false` |
| YTP_HOST | Which IP address to bind to | `0.0.0.0` |
| YTP_PORT | Which port to bind to | `8081` |
| YTP_LOG_LEVEL | Log level | `info` |
| YTP_STREAMER_VCODEC | The video codec to use for in-browser streaming | `libx264` |
| YTP_STREAMER_ACODEC | The audio codec to use for in-browser streaming | `aac` |
| YTP_ACCESS_LOG | Whether to log access to the web server | `true` |
| YTP_DEBUG | Whether to turn on debug mode | `false` |
| YTP_DEBUGPY_PORT | The port to use for the debugpy debugger | `5678` |
| YTP_SOCKET_TIMEOUT | The timeout for the yt-dlp socket connection variable | `30` |
| YTP_EXTRACT_INFO_TIMEOUT | The timeout for extracting video information | `70` |
| YTP_DB_FILE | The path to the SQLite database file | `{config_path}/ytptube.db` |
| YTP_MANUAL_ARCHIVE | The path to the manual archive file | `{config_path}/manual_archive.log` |
| YTP_UI_UPDATE_TITLE | Whether to update the title of the page with the current stats | `true` |
| YTP_PIP_PACKAGES | A space separated list of pip packages to install | `empty string` |
| YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` |
| YTP_BASIC_MODE | Whether to run WebUI in basic mode | `false` |
| YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use. | `empty string` |
| YTP_BROWSER_ENABLED | Whether to enable the file browser | `false` |
| YTP_YTDLP_AUTO_UPDATE | Whether to enable the auto update for yt-dlp | `true` |
| YTP_BASE_PATH | Set this if you are serving YTPTube from sub-folder | `/` |
| YTP_PREVENT_LIVE_PREMIERE | Prevents the initial youtube premiere stream from being downloaded | `false` |

0
app/__init__.py Normal file
View file

View file

@ -18,6 +18,7 @@ from .config import Config
from .DataStore import DataStore
from .Download import Download
from .Events import EventBus, Events
from .Events import info as event_info
from .Events import warning as event_warning
from .ItemDTO import Item, ItemDTO
from .Presets import Presets
@ -63,6 +64,12 @@ class DownloadQueue(metaclass=Singleton):
_instance = None
"""Instance of the DownloadQueue."""
queue: DataStore
"""DataStore for the download queue."""
done: DataStore
"""DataStore for the completed downloads."""
def __init__(self, connection: Connection, config: Config | None = None):
DownloadQueue._instance = self
@ -233,6 +240,7 @@ class DownloadQueue(metaclass=Singleton):
options: dict = {}
error: str | None = None
live_in: str | None = None
is_premiere: bool = bool(entry.get("is_premiere", False))
# check if the video is live stream.
if "live_status" in entry and "is_upcoming" == entry.get("live_status"):
@ -240,7 +248,7 @@ class DownloadQueue(metaclass=Singleton):
live_in = formatdate(entry.get("release_timestamp"), usegmt=True)
item.extras.update({"live_in": live_in})
else:
error = "Live stream not yet started. And no date is set."
error = f"No start time is set for {'premiere' if is_premiere else 'live stream'}."
else:
error = entry.get("msg")
@ -268,7 +276,7 @@ class DownloadQueue(metaclass=Singleton):
is_live = bool(entry.get("is_live") or live_in or entry.get("live_status") in live_status)
try:
download_dir = calc_download_path(base_path=self.config.download_path, folder=item.folder)
download_dir: str = calc_download_path(base_path=self.config.download_path, folder=item.folder)
except Exception as e:
LOG.exception(e)
return {"status": "error", "msg": str(e)}
@ -281,6 +289,14 @@ class DownloadQueue(metaclass=Singleton):
if isinstance(key, str) and key.startswith("playlist") and entry.get(key):
item.extras[key] = entry.get(key)
item.extras["duration"] = entry.get("duration", item.extras.get("duration"))
if not item.extras.get("live_in") and live_in:
item.extras["live_in"] = live_in
if not item.extras.get("is_premiere") and is_premiere:
item.extras["is_premiere"] = is_premiere
dl = ItemDTO(
id=str(entry.get("id")),
title=str(entry.get("title")),
@ -304,18 +320,20 @@ class DownloadQueue(metaclass=Singleton):
try:
dlInfo: Download = Download(info=dl, info_dict=entry, logs=logs)
text_logs = ""
text_logs: str = ""
if filtered_logs := extract_ytdlp_logs(logs):
text_logs = f" Logs: {', '.join(filtered_logs)}"
if live_in or "is_upcoming" == entry.get("live_status"):
NotifyEvent = Events.COMPLETED
dlInfo.info.status = "not_live"
dlInfo.info.msg = "Stream is not live yet." + text_logs
itemDownload = self.done.put(dlInfo)
dlInfo.info.msg = (
f"{'Premiere video' if is_premiere else 'Live Stream' } is not available yet." + text_logs
)
itemDownload: Download = self.done.put(dlInfo)
elif len(entry.get("formats", [])) < 1:
availability = entry.get("availability", "public")
msg = "No formats found."
availability: str = entry.get("availability", "public")
msg: str = "No formats found."
if availability and availability not in ("public",):
msg += f" Availability is set for '{availability}'."
@ -323,7 +341,27 @@ class DownloadQueue(metaclass=Singleton):
dlInfo.info.status = "error"
itemDownload = self.done.put(dlInfo)
NotifyEvent = Events.COMPLETED
await self._notify.emit(Events.LOG_WARNING, data=event_warning(msg))
await self._notify.emit(Events.LOG_WARNING, data=event_warning(f"No formats found for '{dl.title}'."))
elif is_premiere and self.config.prevent_live_premiere:
dlInfo.info.error = (
f"Premiering right now. Delaying download by '{300+dl.extras.get('duration',0)}' seconds."
)
_live_in = live_in or item.extras.get("live_in", None)
if _live_in:
starts_in = parsedate_to_datetime(live_in)
starts_in = starts_in.replace(tzinfo=UTC) if starts_in.tzinfo is None else starts_in.astimezone(UTC)
starts_in = starts_in + timedelta(minutes=5, seconds=dl.extras.get("duration", 0))
dlInfo.info.error += f" Starts in {starts_in.isoformat()}."
dlInfo.info.status = "not_live"
itemDownload = self.done.put(dlInfo)
NotifyEvent = Events.COMPLETED
await self._notify.emit(
Events.LOG_INFO,
data=event_info(
f"'{dl.title}' is premiering. Download delayed by '{300+dl.extras.get('duration')}'s."
),
)
else:
NotifyEvent = Events.ADDED
itemDownload = self.queue.put(dlInfo)
@ -464,12 +502,10 @@ class DownloadQueue(metaclass=Singleton):
if not entry:
return {"status": "error", "msg": "Unable to extract info." + "\n".join(logs)}
if not item.requeued:
condition = Conditions.get_instance().match(info=entry)
if condition is not None:
already.pop()
LOG.info(f"Condition '{condition}' matched for '{item.url}'.")
return await self.add(item=item.new_with(requeued=True, cli=condition.cli), already=already)
if not item.requeued and (condition := Conditions.get_instance().match(info=entry)):
already.pop()
LOG.info(f"Condition '{condition.name}' matched for '{item.url}'.")
return await self.add(item=item.new_with(requeued=True, cli=condition.cli), already=already)
end_time = time.perf_counter() - started
LOG.debug(f"extract_info: for 'URL: {item.url}' is done in '{end_time:.3f}'. Length: '{len(entry)}/keys'.")
@ -566,7 +602,7 @@ class DownloadQueue(metaclass=Singleton):
removed_files = 0
filename: str = ""
LOG.info(
LOG.debug(
f"{remove_file=} {itemRef} - Removing local files: {self.config.remove_files}, {item.info.status=}"
)
@ -827,32 +863,44 @@ class DownloadQueue(metaclass=Singleton):
if self.is_paused() or self.done.empty():
return
LOG.debug("Checking for live stream items in the history queue.")
LOG.debug("Checking history queue for queued live stream links.")
time_now = datetime.now(tz=UTC)
status = ["not_live", "is_upcoming", "is_live"]
status: list[str] = ["not_live", "is_upcoming", "is_live"]
for id, item in list(self.done.items()):
if item.info.status not in status:
continue
item_ref = f"{id=} {item.info.id=} {item.info.title=}"
item_ref: str = f"{id=} {item.info.id=} {item.info.title=}"
if not item.is_live:
LOG.debug(f"Item '{item_ref}' is not a live stream.")
continue
if not item.info.live_in:
live_in: str | None = item.info.live_in or item.info.extras.get("live_in", None)
if not live_in:
LOG.debug(f"Item '{item_ref}' marked as live stream, but no date is set.")
continue
starts_in = parsedate_to_datetime(item.info.live_in)
starts_in = parsedate_to_datetime(live_in)
starts_in = starts_in.replace(tzinfo=UTC) if starts_in.tzinfo is None else starts_in.astimezone(UTC)
if time_now < (starts_in + timedelta(minutes=1)):
LOG.debug(f"Item '{item_ref}' is not yet live. will start in '{dt_delta(starts_in-time_now)}'.")
continue
duration: int | None = item.info.extras.get("duration", None)
is_premiere: bool = item.info.extras.get("is_premiere", False)
if is_premiere and duration and self.config.prevent_live_premiere:
premiere_ends: datetime = starts_in + timedelta(minutes=5, seconds=duration)
if time_now < premiere_ends:
LOG.debug(
f"Item '{item_ref}' is premiering and download is delayed by '{300+duration}' seconds. Will start at '{premiere_ends.isoformat()}'"
)
continue
LOG.info(f"Re-queuing item '{item_ref} {item.info.extras=}' for download.")
try:
@ -862,17 +910,18 @@ class DownloadQueue(metaclass=Singleton):
continue
try:
info = item.info
new_queue = Item(
url=info.url,
preset=info.preset,
folder=info.folder,
cookies=info.cookies,
template=info.template,
cli=item.info.cli,
extras=item.info.extras,
await self.add(
item=Item(
url=item.info.url,
preset=item.info.preset,
folder=item.info.folder,
cookies=item.info.cookies,
template=item.info.template,
cli=item.info.cli,
extras=item.info.extras,
)
)
await self.add(item=new_queue)
except Exception as e:
LOG.error(f"Failed to re-queue item '{item_ref}'. {e!s}")
self.done.put(item)
LOG.exception(e)
LOG.error(f"Failed to re-queue item '{item_ref}'. {e!s}")

File diff suppressed because it is too large Load diff

View file

@ -1,30 +1,24 @@
import asyncio
import errno
import functools
import inspect
import logging
import os
import shlex
import time
from datetime import UTC, datetime
from pathlib import Path
import anyio
import socketio
from aiohttp import web
from .common import Common
from app.library.router import RouteType, get_routes
from app.library.Utils import load_modules
from .config import Config
from .DownloadQueue import DownloadQueue
from .encoder import Encoder
from .Events import Event, EventBus, Events, error
from .Events import Event, EventBus, Events
from .ItemDTO import Item
from .Presets import Presets
from .Utils import is_downloaded, tail_log
LOG = logging.getLogger("socket_api")
LOG: logging.Logger = logging.getLogger("socket_api")
class HttpSocket(Common):
class HttpSocket:
"""
This class is used to handle WebSocket events.
"""
@ -32,15 +26,11 @@ class HttpSocket(Common):
config: Config
sio: socketio.AsyncServer
queue: DownloadQueue
subscribers: dict[str, list[str]] = {}
"""Event subscriber list."""
log_task = None
"""Task to tail the log file."""
di_context: dict[str, object] = {}
def __init__(
self,
root_path: Path,
queue: DownloadQueue | None = None,
encoder: Encoder | None = None,
config: Config | None = None,
@ -55,20 +45,28 @@ class HttpSocket(Common):
async_handlers=True,
async_mode="aiohttp",
cors_allowed_origins=[],
transports=["websocket"],
transports=["websocket", "polling"],
logger=self.config.debug,
engineio_logger=self.config.debug,
ping_interval=10,
ping_timeout=5,
)
encoder = encoder or Encoder()
self.rootPath = root_path
def emit(e: Event, _, **kwargs):
return self.sio.emit(event=e.event, data=encoder.encode(e.data), **kwargs)
self._notify.subscribe("frontend", emit, f"{__class__.__name__}.emit")
self.di_context = {
"config": self.config,
"queue": self.queue,
"sio": self.sio,
"encoder": encoder,
"notify": self._notify,
"root_path": self.rootPath,
}
super().__init__(queue=queue, encoder=encoder, config=config)
self._notify.subscribe("frontend", emit, f"{__class__.__name__}.emit")
@staticmethod
def ws_event(func): # type: ignore
@ -93,347 +91,38 @@ class HttpSocket(Common):
LOG.debug("Socket server shutdown complete.")
def attach(self, app: web.Application):
app.on_shutdown.append(self.on_shutdown)
self.sio.attach(app, socketio_path=f"{self.config.base_path.rstrip('/')}/socket.io")
for attr_name in dir(self):
method = getattr(self, attr_name)
if hasattr(method, "_ws_event") and self.sio:
self.sio.on(method._ws_event)(method) # type: ignore
self._notify.subscribe(
Events.ADD_URL,
lambda data, _, **kwargs: self.add(item=Item.format(data.data)), # noqa: ARG005
lambda data, _, **kwargs: self.queue.add(item=Item.format(data.data)), # noqa: ARG005
f"{__class__.__name__}.add",
)
# register the shutdown event.
app.on_shutdown.append(self.on_shutdown)
load_modules(self.rootPath, self.rootPath / "routes" / "socket")
@ws_event
async def cli_post(self, sid: str, data):
if not self.config.console_enabled:
await self._notify.emit(Events.ERROR, data=error("Console is disabled."), to=sid)
return
if not data:
await self._notify.emit(Events.CLI_CLOSE, data={"exitcode": 0}, to=sid)
return
try:
LOG.info(f"Cli command from client '{sid}'. '{data}'")
args = ["yt-dlp", *shlex.split(data)]
_env = os.environ.copy()
_env.update(
{
"TERM": "xterm-256color",
"LANG": "en_US.UTF-8",
"SHELL": "/bin/bash",
"LC_ALL": "en_US.UTF-8",
"PWD": self.config.download_path,
"FORCE_COLOR": "1",
"PYTHONUNBUFFERED": "1",
}
for route in get_routes(RouteType.SOCKET).values():
LOG.debug(
f"Add ({route.name}) {route.method.value if isinstance(route.method,RouteType) else route.method}: {route.path}."
)
self.sio.on(route.path)(HttpSocket._injector(route.handler, route.path, self.di_context))
try:
import pty
@staticmethod
def _injector(func, event: str, container: dict):
sig: inspect.Signature = inspect.signature(func)
master_fd, slave_fd = pty.openpty()
stdin_arg = asyncio.subprocess.DEVNULL
stdout_arg = stderr_arg = slave_fd
use_pty = True
except ImportError:
use_pty = False
master_fd = slave_fd = None
stdin_arg = asyncio.subprocess.DEVNULL
stdout_arg = asyncio.subprocess.PIPE
stderr_arg = asyncio.subprocess.STDOUT
async def wrapper(sid, data, **kwargs):
args = {}
proc = await asyncio.create_subprocess_exec(
*args,
cwd=self.config.download_path,
stdin=stdin_arg,
stdout=stdout_arg,
stderr=stderr_arg,
env=_env,
)
merged = {**container, "sid": sid, "data": data, "event_name": event}
if isinstance(kwargs, dict):
merged.update(kwargs)
if use_pty:
try:
os.close(slave_fd)
except Exception as e:
LOG.error(f"Error closing PTY. '{e!s}'.")
for name in sig.parameters:
if name in merged:
args[name] = merged[name]
async def reader(sid: str):
if use_pty is False:
assert proc.stdout is not None
async for raw_line in proc.stdout:
line = raw_line.rstrip(b"\n")
await self._notify.emit(
Events.CLI_OUTPUT,
data={"type": "stdout", "line": line.decode("utf-8", errors="replace")},
to=sid,
)
return
return await func(**args)
loop = asyncio.get_running_loop()
buffer = b""
while True:
try:
chunk = await loop.run_in_executor(None, lambda: os.read(master_fd, 1024))
except OSError as e:
if e.errno == errno.EIO:
break
raise
if not chunk:
# No more output
if buffer:
# Emit any remaining partial line
await self._notify.emit(
Events.CLI_OUTPUT,
data={"type": "stdout", "line": buffer.decode("utf-8", errors="replace")},
to=sid,
)
break
buffer += chunk
*lines, buffer = buffer.split(b"\n")
for line in lines:
await self._notify.emit(
Events.CLI_OUTPUT,
data={"type": "stdout", "line": line.decode("utf-8", errors="replace")},
to=sid,
)
try:
os.close(master_fd)
except Exception as e:
LOG.error(f"Error closing PTY. '{e!s}'.")
# Start reading output from PTY
read_task = asyncio.create_task(reader(sid=sid))
# Wait until process finishes
returncode = await proc.wait()
# Ensure reading is done
await read_task
await self._notify.emit(Events.CLI_CLOSE, data={"exitcode": returncode}, to=sid)
except Exception as e:
LOG.error(f"CLI execute exception was thrown for client '{sid}'.")
LOG.exception(e)
await self._notify.emit(Events.CLI_OUTPUT, data={"type": "stderr", "line": str(e)}, to=sid)
await self._notify.emit(Events.CLI_CLOSE, data={"exitcode": -1}, to=sid)
@ws_event
async def add_url(self, sid: str, data: dict):
url: str | None = data.get("url")
if not url:
await self._notify.emit(Events.ERROR, data=error("No URL provided.", data={"unlock": True}), to=sid)
return
try:
await self._notify.emit(
event=Events.STATUS,
data=await self.add(item=Item.format(data)),
to=sid,
)
except ValueError as e:
LOG.exception(e)
await self._notify.emit(Events.ERROR, data=error(str(e)), to=sid)
return
@ws_event
async def item_cancel(self, sid: str, id: str):
if not id:
await self._notify.emit(Events.ERROR, data=error("Invalid request."), to=sid)
return
status: dict[str, str] = {}
status = await self.queue.cancel([id])
status.update({"identifier": id})
await self._notify.emit(Events.ITEM_CANCEL, data=status)
@ws_event
async def item_delete(self, sid: str, data: dict):
if not data:
await self._notify.emit(Events.ERROR, data=error("Invalid request."), to=sid)
return
id: str | None = data.get("id")
if not id:
await self._notify.emit(Events.ERROR, data=error("Invalid request."), to=sid)
return
status: dict[str, str] = {}
status = await self.queue.clear([id], remove_file=bool(data.get("remove_file", False)))
status.update({"identifier": id})
await self._notify.emit(Events.ITEM_DELETE, data=status)
@ws_event
async def archive_item(self, _: str, data: dict):
if not isinstance(data, dict) or "url" not in data:
return
from .YTDLPOpts import YTDLPOpts
params = YTDLPOpts.get_instance()
if "preset" in data and isinstance(data["preset"], str):
params.preset(name=data["preset"])
if "cli" in data and isinstance(data["cli"], str) and len(data["cli"]) > 1:
params.add_cli(data["cli"], from_user=True)
params = params.get_all()
file: str = params.get("download_archive", None)
if not file:
return
exists, idDict = is_downloaded(file, data["url"])
if exists or "archive_id" not in idDict or idDict["archive_id"] is None:
return
async with await anyio.open_file(file, "a") as f:
await f.write(f"{idDict['archive_id']}\n")
manual_archive = self.config.manual_archive
if manual_archive:
manual_archive = Path(manual_archive)
if not manual_archive.exists():
manual_archive.touch(exist_ok=True)
previouslyArchived = False
async with await anyio.open_file(manual_archive) as f:
async for line in f:
if idDict["archive_id"] in line:
previouslyArchived = True
break
if not previouslyArchived:
async with await anyio.open_file(manual_archive, "a") as f:
await f.write(f"{idDict['archive_id']} - at: {datetime.now(UTC).isoformat()}\n")
LOG.info(f"Archiving url '{data['url']}' with id '{idDict['archive_id']}'.")
else:
LOG.info(f"URL '{data['url']}' with id '{idDict['archive_id']}' already archived.")
@ws_event
async def connect(self, sid: str, _=None):
data = {
**self.queue.get(),
"config": self.config.frontend(),
"presets": Presets.get_instance().get_all(),
"paused": self.queue.is_paused(),
}
data["folders"] = [folder.name for folder in Path(self.config.download_path).iterdir() if folder.is_dir()]
await self._notify.emit(Events.INITIAL_DATA, data=data, to=sid)
@ws_event
async def pause(self, *_, **__):
self.queue.pause()
await self._notify.emit(Events.PAUSED, data={"paused": True, "at": time.time()})
@ws_event
async def resume(self, *_, **__):
self.queue.resume()
await self._notify.emit(Events.PAUSED, data={"paused": False, "at": time.time()})
@ws_event
async def subscribe(self, sid: str, event: str):
"""
Subscribe to a specific event.
Args:
sid (str): The session ID of the client.
event (str): The event to subscribe to.
"""
if not isinstance(event, str):
await self._notify.emit(Events.ERROR, data=error("Invalid event."), to=sid)
return
if event not in self.subscribers:
self.subscribers[event] = []
if sid not in self.subscribers[event]:
self.subscribers[event].append(sid)
LOG.debug(f"Client '{sid}' subscribed to event '{event}'.")
await self.sio.emit(Events.SUBSCRIBED, data={"event": event}, to=sid)
async def emit_logs(data: dict):
await self.subscribe_emit(event=event, data=data)
if "log_lines" == event and self.log_task is None:
log_file = Path(self.config.config_path) / "logs" / "app.log"
LOG.debug(f"Starting tailing '{log_file!s}'.")
self.log_task = asyncio.create_task(
tail_log(
file=log_file,
emitter=emit_logs,
),
name="tail_log",
)
@ws_event
async def unsubscribe(self, sid: str, event: str):
"""
Unsubscribe from a specific event.
Args:
sid (str): The session ID of the client.
event (str): The event to unsubscribe from.
"""
if event not in self.subscribers:
return
if sid not in self.subscribers[event]:
return
self.subscribers[event].remove(sid)
await self.sio.emit(Events.UNSUBSCRIBED, data={"event": event}, to=sid)
LOG.debug(f"Client '{sid}' unsubscribed from event '{event}'.")
if "log_lines" != event or not self.log_task or "log_lines" not in self.subscribers:
return
if len(self.subscribers["log_lines"]) < 1:
try:
LOG.debug("Stopping log tailing task.")
self.log_task.cancel()
self.log_task = None
except asyncio.CancelledError:
pass
@ws_event
async def disconnect(self, sid: str, reason: str):
"""
Handle client disconnection.
Args:
sid (str): The session ID of the client.
reason (str): The reason for disconnection.
"""
LOG.debug(f"Client '{sid}' disconnected. {reason}")
for event in self.subscribers:
if sid in self.subscribers[event]:
await self.unsubscribe(sid=sid, event=event)
async def subscribe_emit(self, event: str, data: dict):
if event not in self.subscribers or len(self.subscribers[event]) < 1:
return
for sid in self.subscribers[event]:
await self.sio.emit(event=event, data=data, to=sid)
return wrapper

View file

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

View file

@ -103,7 +103,7 @@ class Scheduler(metaclass=Singleton):
self._jobs[job_id] = job
LOG.debug(f"Added job '{job_id}' to the schedule.")
LOG.debug(f"Added '{job_id}' to the scheduler.")
return job_id

View file

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

View file

@ -17,10 +17,11 @@ from typing import TypeVar
import yt_dlp
from Crypto.Cipher import AES
from yt_dlp.utils import match_str
from .LogWrapper import LogWrapper
LOG = logging.getLogger("Utils")
LOG: logging.Logger = logging.getLogger("Utils")
REMOVE_KEYS: list = [
{
@ -59,7 +60,8 @@ FILES_TYPE: list = [
{"rx": re.compile(r"\.(nfo|json|jpg|torrent|\.info\.json)$", re.IGNORECASE), "type": "metadata"},
]
DATETIME_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?")
TAG_REGEX: re.Pattern[str] = re.compile(r"%{([^:}]+)(?::([^}]*))?}c")
DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?")
T = TypeVar("T")
@ -204,6 +206,10 @@ def extract_info(
if not data:
return data
data["is_premiere"] = match_str("media_type=video & duration & is_live", data)
if not data["is_premiere"]:
data["is_premiere"] = "video" == data.get("media_type") and "is_upcoming" == data.get("live_status")
return yt_dlp.YoutubeDL.sanitize_info(data) if sanitize_info else data
@ -277,6 +283,41 @@ def is_downloaded(archive_file: str, url: str) -> tuple[bool, dict[str | None, s
return (False, idDict)
def remove_from_archive(archive_file: str | Path, url: str) -> bool:
"""
Remove the downloaded video record from the archive file.
Args:
archive_file (str): Archive file path.
url (str): URL to check and remove.
Returns:
bool: True if the record removed, False otherwise.
"""
if not url or not archive_file:
return False
archive_path: Path = Path(archive_file) if not isinstance(archive_file, Path) else archive_file
if not archive_path.exists():
return False
idDict = get_archive_id(url=url)
archive_id: str | None = idDict.get("archive_id")
if not archive_id:
return False
lines: list[str] = archive_path.read_text(encoding="utf-8").splitlines()
new_lines: list[str] = [line for line in lines if archive_id not in line]
if len(lines) == len(new_lines):
return False
archive_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
return True
def load_file(file: str, check_type=None) -> tuple[dict | list, bool, str]:
"""
Load a JSON or JSON5 file and return the contents as a dictionary
@ -803,7 +844,8 @@ def get_files(base_path: Path | str, dir: str | None = None):
if file.name.startswith(".") or file.name.startswith("_"):
continue
if file.is_symlink():
is_symlink: bool = file.is_symlink()
if is_symlink:
try:
test: Path = file.resolve()
test.relative_to(base_path)
@ -831,9 +873,10 @@ def get_files(base_path: Path | str, dir: str | None = None):
content_type = "download"
stat = file.stat()
contents.append(
{
"type": "file" if file.is_file() else "dir",
"type": "file" if file.is_file() else "link" if is_symlink else "dir",
"content_type": content_type,
"name": file.name,
"path": str(file.relative_to(base_path)).strip("/"),
@ -843,6 +886,7 @@ def get_files(base_path: Path | str, dir: str | None = None):
"ctime": datetime.fromtimestamp(stat.st_ctime, tz=UTC).isoformat(),
"is_dir": file.is_dir(),
"is_file": file.is_file(),
"is_symlink": is_symlink,
}
)
@ -960,7 +1004,7 @@ async def read_logfile(file: Path, offset: int = 0, limit: int = 50) -> dict:
for line in lines[-(offset + limit) : -offset] if offset else lines[-limit:]:
line_bytes = line if isinstance(line, bytes) else line.encode()
msg = line.decode(errors="replace")
dt_match = DATETIME_PATTERN.match(msg)
dt_match = DT_PATTERN.match(msg)
result.append(
{
"id": sha256(line_bytes).hexdigest(),
@ -1002,7 +1046,7 @@ async def tail_log(file: Path, emitter: callable, sleep_time: float = 0.5):
continue
msg = line.decode(errors="replace")
dt_match = DATETIME_PATTERN.match(msg)
dt_match = DT_PATTERN.match(msg)
await emitter(
{
@ -1198,3 +1242,52 @@ def init_class(cls: type[T], data: dict) -> T:
from dataclasses import fields
return cls(**{k: v for k, v in data.items() if k in {f.name for f in fields(cls)}})
def load_modules(root_path: Path, directory: Path):
"""
Load all modules from a given directory relative to the root path.
Args:
root_path (Path): The root path of the application.
directory (Path): The directory from which to load modules.
"""
import importlib
import pkgutil
package_name: str = str(directory.relative_to(root_path).as_posix()).replace("/", ".")
LOG.debug(f"Loading routes from '{directory}' with package name '{package_name}'.")
for _, name, _ in pkgutil.iter_modules([directory]):
full_name: str = f"{package_name}.{name}"
if name.startswith("_"):
continue
try:
LOG.debug(f"Loading module '{full_name}'.")
importlib.import_module(full_name)
except ImportError as e:
LOG.error(f"Failed to import module '{full_name}': {e}")
def parse_tags(text: str) -> tuple[str, dict[str, str | bool]]:
"""
Parse tags from a string formatted with %{tag_name[:value]}c.
Args:
text (str): The input string containing tags.
Returns:
tuple[str, dict[str, str | bool]]: A tuple containing the string with tags removed and a dictionary of tags.
"""
tags: dict[str, str | bool] = {}
def replacer(match: re.Match) -> str:
name = match.group(1)
value = match.group(2)
tags[name] = value if value is not None else True
return ""
return TAG_REGEX.sub(replacer, text).strip(), tags

View file

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

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

149
app/library/router.py Normal file
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
import sys
from pathlib import Path
APP_ROOT = str((Path(__file__).parent / "..").resolve())
if APP_ROOT not in sys.path:
sys.path.insert(0, APP_ROOT)
import asyncio
import logging
import sqlite3
import sys
from pathlib import Path
import caribou
import magic
from aiohttp import web
from library.conditions import Conditions
from library.config import Config
from library.DownloadQueue import DownloadQueue
from library.Events import EventBus, Events
from library.HttpAPI import HttpAPI
from library.HttpSocket import HttpSocket
from library.Notifications import Notification
from library.Presets import Presets
from library.Scheduler import Scheduler
from library.Tasks import Tasks
from app.library.conditions import Conditions
from app.library.config import Config
from app.library.DownloadQueue import DownloadQueue
from app.library.Events import EventBus, Events
from app.library.HttpAPI import HttpAPI
from app.library.HttpSocket import HttpSocket
from app.library.Notifications import Notification
from app.library.Presets import Presets
from app.library.Scheduler import Scheduler
from app.library.Tasks import Tasks
LOG = logging.getLogger("app")
MIME = magic.Magic(mime=True)
@ -58,7 +65,7 @@ class Main:
self._queue = DownloadQueue(connection=connection)
self._http = HttpAPI(root_path=ROOT_PATH, queue=self._queue)
self._socket = HttpSocket(queue=self._queue)
self._socket = HttpSocket(root_path=ROOT_PATH, queue=self._queue)
self._app.on_cleanup.append(_close_connection)
@ -117,7 +124,7 @@ class Main:
HTTP_LOGGER = None
if self._config.access_log:
from library.HttpAPI import LOG as HTTP_LOGGER
from app.library.HttpAPI import LOG as HTTP_LOGGER
HTTP_LOGGER.addFilter(lambda record: f"GET {self._app.router['ping'].url_for()}" not in record.getMessage())

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

135
app/routes/api/_static.py Normal file
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
import sys
from pathlib import Path
APP_ROOT = str((Path(__file__).parent / "..").resolve())
if APP_ROOT not in sys.path:
sys.path.insert(0, APP_ROOT)
import logging
import os

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

View file

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

View file

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

View file

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

View file

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

View file

@ -172,16 +172,27 @@
</span>
</div>
</div>
<div class="column is-6-tablet is-4-mobile has-text-left">
<button type="button" class="button is-info" @click="emitter('getInfo', form.url)"
:class="{ 'is-loading': !socket.isConnected }"
:disabled="!socket.isConnected || addInProgress || !form?.url">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Information</span>
</button>
</div>
<div class="column is-6-tablet is-6-mobile has-text-right">
<div class="column is-12">
<div class="field is-grouped is-justify-self-end">
<div class="control">
<button type="button" class="button is-info" @click="emitter('getInfo', form.url)"
:class="{ 'is-loading': !socket.isConnected }"
:disabled="!socket.isConnected || addInProgress || !form?.url">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Information</span>
</button>
</div>
<div class="control">
<button type="button" class="button is-warning" @click="removeFromArchive(form.url)"
:class="{ 'is-loading': !socket.isConnected }"
:disabled="!socket.isConnected || addInProgress || !form?.url">
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
<span>Remove from archive</span>
</button>
</div>
<div class="control">
<button type="button" class="button is-danger" @click="resetConfig"
:disabled="!socket.isConnected || form?.id" v-tooltip="'Reset local settings'">
@ -212,7 +223,7 @@ const props = defineProps({
},
})
const emitter = defineEmits(['getInfo', 'clear_form'])
const emitter = defineEmits(['getInfo', 'clear_form', 'remove_archive'])
const config = useConfigStore()
const socket = useSocketStore()
const toast = useNotification()
@ -327,6 +338,7 @@ const resetConfig = () => {
}
showAdvanced.value = false
separator.value = separators[0].value
toast.success('Local configuration has been reset.')
}
@ -370,13 +382,17 @@ onMounted(async () => {
})
emitter('clear_form');
}
await nextTick()
if (!separators.some(s => s.value === separator.value)) {
separator.value = separators[0].value
}
})
const hasFormatInConfig = computed(() => {
if (!form?.value?.value) {
if (!form.value?.cli) {
return false
}
return /(?<!\S)(-f|--format)(=|\s)(\S+)/.test(form.value.cli)
})
@ -384,4 +400,25 @@ const filter_presets = (flag = true) => config.presets.filter(item => item.defau
const get_preset = name => config.presets.find(item => item.name === name)
const expand_description = e => toggleClass(e.target, ['is-ellipsis', 'is-pre-wrap'])
const removeFromArchive = async url => {
try {
const req = await request(`/api/archive/0`, {
credentials: 'include',
method: 'DELETE',
body: JSON.stringify({ url }),
})
const data = await req.json()
if (!req.ok) {
toast.error(data.error)
return
}
toast.success(data.message ?? `Removed item from archive.`)
} catch (e) {
toast.error(`Error: ${e.message}`)
}
}
</script>

View file

@ -32,7 +32,7 @@
<hr class="navbar-divider">
</template>
<div class="notification-list">
<div v-for="n in store.notifications" :key="n.id" class="navbar-item is-flex is-align-items-start"
<div v-for="n in store.notifications" :key="n.id" class="pr-1 pl-1 navbar-item is-flex is-align-items-start"
:class="['notification-item', 'notification-' + n.level]">
<div class="is-flex-grow-1">
<p class="is-size-7 mb-1 notification-message" :class="{ expanded: expandedId === n.id }"

View file

@ -1,5 +1,5 @@
<template>
<h1 class="mt-3 is-size-3 is-clickable is-unselectable" @click="showQueue = !showQueue">
<h1 class="mt-3 is-size-3 is-clickable is-unselectable" @click="showQueue = !showQueue" v-if="hasQueuedItems">
<span class="icon-text title is-4">
<span class="icon">
<i class="fas" :class="showQueue ? 'fa-solid fa-arrow-up' : 'fa-solid fa-arrow-down'" />
@ -66,8 +66,11 @@
</label>
</td>
<td class="is-text-overflow is-vcentered">
<div class="is-inline is-pulled-right" v-if="item.downloaded_bytes">
<span class="tag">{{ formatBytes(item.downloaded_bytes) }}</span>
<div class="is-inline is-pulled-right" v-if="item.downloaded_bytes || item.extras?.duration">
<span class="tag" v-if="item.downloaded_bytes">{{ formatBytes(item.downloaded_bytes) }}</span>
<span class="tag is-info" v-if="item.extras?.duration">
{{ formatTime(item.extras.duration) }}
</span>
</div>
<div v-if="showThumbnails && item.extras?.thumbnail">
<FloatingImage :image="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
@ -139,6 +142,10 @@
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
</div>
<div class="card-header-icon">
<span class="tag is-info" v-if="item.extras?.duration">
{{ formatTime(item.extras.duration) }}
</span>
<a :href="item.url" class="has-text-primary" v-tooltip="'Copy url.'" @click.prevent="copyText(item.url)">
<span class="icon"><i class="fa-solid fa-copy" /></span>
</a>
@ -218,24 +225,6 @@
</LateLoader>
</div>
<div class="content has-text-centered" v-if="!hasQueuedItems">
<p v-if="socket.isConnected">
<span class="icon-text">
<span class="icon has-text-success">
<i class="fa-solid fa-circle-check" />
</span>
<span>No queued items.</span>
</span>
</p>
<p v-else>
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-spinner fa-spin" />
</span>
<span>Connecting...</span>
</span>
</p>
</div>
<div class="modal is-active" v-if="embed_url">
<div class="modal-background" @click="embed_url = ''"></div>

View file

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

View file

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

View file

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

View file

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

View file

@ -27,8 +27,7 @@
</div>
<div class="control">
<button class="button is-danger is-light" @click="toggleFilter"
v-tooltip.bottom="'Filter content'">
<button class="button is-danger is-light" @click="toggleFilter" v-tooltip.bottom="'Filter content'">
<span class="icon"><i class="fas fa-filter" /></span>
</button>
</div>
@ -94,7 +93,8 @@
<td class="is-text-overflow is-vcentered">
<div class="field is-grouped">
<div class="control is-text-overflow is-expanded">
<a :href="uri(`/browser/${item.path}`)" v-if="'dir' === item.type" @click.prevent="handleClick(item)">
<a :href="uri(`/browser/${item.path}`)" v-if="'dir' === item.content_type"
@click.prevent="handleClick(item)">
{{ item.name }}
</a>
<a :href="makeDownload({}, { filename: item.path, folder: '' })"
@ -113,7 +113,7 @@
</div>
</td>
<td class="has-text-centered is-text-overflow is-unselectable">
{{ 'file' === item.type ? formatBytes(item.size) : 'Dir' }}
{{ 'file' === item.type ? formatBytes(item.size) : ucFirst(item.type) }}
</td>
<td class="has-text-centered is-text-overflow is-unselectable">
<span :data-datetime="item.mtime" v-tooltip="moment(item.mtime).format('MMMM Do YYYY, h:mm:ss a')">
@ -126,7 +126,8 @@
</div>
</div>
<div class="column is-12" v-else>
<Message title="Loading content" class="is-background-info-80" icon="fas fa-refresh fa-spin" v-if="isLoading">
<Message title="Loading content" class="has-background-info-90 has-text-dark" icon="fas fa-refresh fa-spin"
v-if="isLoading">
Loading file browser contents...
</Message>
<Message v-else title="No Content" class="is-background-warning-80 has-text-dark"
@ -339,7 +340,7 @@ const reloadContent = async (dir = '/', fromMounted = false) => {
}
}
const popstateHandler = e => {
const event_handler = e => {
if (!e.state) {
return
@ -357,12 +358,10 @@ onMounted(async () => {
await reloadContent(path.value, true)
}
window.addEventListener('popstate', popstateHandler)
document.addEventListener('popstate', event_handler)
})
onUnmounted(() => {
window.removeEventListener('popstate', popstateHandler)
})
onBeforeUnmount(() => document.removeEventListener('popstate', event_handler))
const makeBreadCrumb = path => {
const baseLink = '/'
@ -400,6 +399,9 @@ watch(model_item, v => {
})
const setIcon = item => {
if ('link' === item.type) {
return 'fa-link'
}
if ('dir' === item.content_type) {
return 'fa-folder'
}

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -505,6 +505,18 @@ const uri = u => {
return eTrim(runtimeConfig.app.baseURL, '/') + '/' + sTrim(u, '/');
}
const formatTime = seconds => {
const hrs = Math.floor(seconds / 3600)
const mins = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
const pad = (n) => n.toString().padStart(2, '0')
if (hrs > 0) return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`
if (mins > 0) return `${pad(mins)}:${pad(secs)}`
return `${secs}`
}
export {
ag_set,
ag,
@ -533,4 +545,5 @@ export {
toggleClass,
cleanObject,
uri,
formatTime,
}