commit
c118522fa2
20 changed files with 771 additions and 100 deletions
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
|
@ -18,7 +18,6 @@
|
||||||
"arrowless",
|
"arrowless",
|
||||||
"attl",
|
"attl",
|
||||||
"autonumber",
|
"autonumber",
|
||||||
"changeslog",
|
|
||||||
"consoletitle",
|
"consoletitle",
|
||||||
"cookiesfrombrowser",
|
"cookiesfrombrowser",
|
||||||
"copyts",
|
"copyts",
|
||||||
|
|
@ -49,6 +48,7 @@
|
||||||
"preferredquality",
|
"preferredquality",
|
||||||
"printtraffic",
|
"printtraffic",
|
||||||
"quicktime",
|
"quicktime",
|
||||||
|
"timespec",
|
||||||
"tmpfilename",
|
"tmpfilename",
|
||||||
"upgrader",
|
"upgrader",
|
||||||
"urandom",
|
"urandom",
|
||||||
|
|
|
||||||
|
|
@ -70,9 +70,9 @@ class DataStore:
|
||||||
for row in cursor:
|
for row in cursor:
|
||||||
rowDate = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S") # noqa: DTZ007
|
rowDate = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S") # noqa: DTZ007
|
||||||
data, _ = clean_item(json.loads(row["data"]), keys=ItemDTO.removed_fields())
|
data, _ = clean_item(json.loads(row["data"]), keys=ItemDTO.removed_fields())
|
||||||
key: str = data.pop("_id")
|
data.pop("_id", None)
|
||||||
item: ItemDTO = ItemDTO(**data)
|
item: ItemDTO = ItemDTO(**data)
|
||||||
item._id = key
|
item._id = row["id"]
|
||||||
item.datetime = formatdate(rowDate.replace(tzinfo=UTC).timestamp())
|
item.datetime = formatdate(rowDate.replace(tzinfo=UTC).timestamp())
|
||||||
items.append((row["id"], item))
|
items.append((row["id"], item))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ from .AsyncPool import AsyncPool
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .DataStore import DataStore
|
from .DataStore import DataStore
|
||||||
from .Download import Download
|
from .Download import Download
|
||||||
from .Events import EventBus, Events
|
from .Events import EventBus, Events, info
|
||||||
from .ItemDTO import Item, ItemDTO
|
from .ItemDTO import Item, ItemDTO
|
||||||
from .Presets import Presets
|
from .Presets import Presets
|
||||||
from .Singleton import Singleton
|
from .Singleton import Singleton
|
||||||
|
|
@ -307,6 +307,14 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
dlInfo.info.status = "not_live"
|
dlInfo.info.status = "not_live"
|
||||||
itemDownload = self.done.put(dlInfo)
|
itemDownload = self.done.put(dlInfo)
|
||||||
NotifyEvent = Events.COMPLETED
|
NotifyEvent = Events.COMPLETED
|
||||||
|
log_message = f"{dl.title or dl.id or dl._id}: stream is not live yet."
|
||||||
|
if dlInfo.info.live_in:
|
||||||
|
log_message += f" Will start in {dlInfo.info.live_in}."
|
||||||
|
|
||||||
|
await self._notify.emit(
|
||||||
|
Events.LOG_INFO,
|
||||||
|
data=info(msg=log_message, data=itemDownload.info.serialize()),
|
||||||
|
)
|
||||||
elif self.config.allow_manifestless is False and is_manifestless is True:
|
elif self.config.allow_manifestless is False and is_manifestless is True:
|
||||||
dlInfo.info.status = "error"
|
dlInfo.info.status = "error"
|
||||||
dlInfo.info.error = "Video is in post-live manifestless mode."
|
dlInfo.info.error = "Video is in post-live manifestless mode."
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,9 @@ class Events:
|
||||||
PRESETS_UPDATE = "presets_update"
|
PRESETS_UPDATE = "presets_update"
|
||||||
SCHEDULE_ADD = "schedule_add"
|
SCHEDULE_ADD = "schedule_add"
|
||||||
|
|
||||||
|
SUBSCRIBED = "subscribed"
|
||||||
|
UNSUBSCRIBED = "unsubscribed"
|
||||||
|
|
||||||
def get_all() -> list:
|
def get_all() -> list:
|
||||||
"""
|
"""
|
||||||
Get all the events.
|
Get all the events.
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ from .Utils import (
|
||||||
get_file_sidecar,
|
get_file_sidecar,
|
||||||
get_files,
|
get_files,
|
||||||
get_mime_type,
|
get_mime_type,
|
||||||
|
read_logfile,
|
||||||
validate_url,
|
validate_url,
|
||||||
validate_uuid,
|
validate_uuid,
|
||||||
)
|
)
|
||||||
|
|
@ -73,7 +74,8 @@ class HttpAPI(Common):
|
||||||
"/presets",
|
"/presets",
|
||||||
"/tasks",
|
"/tasks",
|
||||||
"/notifications",
|
"/notifications",
|
||||||
"/changeslog",
|
"/changelog",
|
||||||
|
"/logs",
|
||||||
"/browser",
|
"/browser",
|
||||||
"/browser/{path:.*}",
|
"/browser/{path:.*}",
|
||||||
]
|
]
|
||||||
|
|
@ -728,6 +730,42 @@ class HttpAPI(Common):
|
||||||
dumps=self.encoder.encode,
|
dumps=self.encoder.encode,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@route("GET", "api/logs")
|
||||||
|
async def logs(self, request: Request) -> Response:
|
||||||
|
"""
|
||||||
|
Get recent logs
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request (Request): The request object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response: The response object.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not self.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", 50))
|
||||||
|
if limit < 1 or limit > 150:
|
||||||
|
limit = 50
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
data={
|
||||||
|
"logs": await read_logfile(
|
||||||
|
file=os.path.join(self.config.config_path, "logs", "app.log"),
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
),
|
||||||
|
"offset": offset,
|
||||||
|
"limit": limit,
|
||||||
|
},
|
||||||
|
status=web.HTTPOk.status_code,
|
||||||
|
dumps=self.encoder.encode,
|
||||||
|
)
|
||||||
|
|
||||||
@route("GET", "api/presets")
|
@route("GET", "api/presets")
|
||||||
async def presets(self, request: Request) -> Response:
|
async def presets(self, request: Request) -> Response:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ from .encoder import Encoder
|
||||||
from .Events import Event, EventBus, Events, error
|
from .Events import Event, EventBus, Events, error
|
||||||
from .ItemDTO import Item
|
from .ItemDTO import Item
|
||||||
from .Presets import Presets
|
from .Presets import Presets
|
||||||
from .Utils import is_downloaded
|
from .Utils import is_downloaded, tail_log
|
||||||
|
|
||||||
LOG = logging.getLogger("socket_api")
|
LOG = logging.getLogger("socket_api")
|
||||||
|
|
||||||
|
|
@ -33,6 +33,12 @@ class HttpSocket(Common):
|
||||||
sio: socketio.AsyncServer
|
sio: socketio.AsyncServer
|
||||||
queue: DownloadQueue
|
queue: DownloadQueue
|
||||||
|
|
||||||
|
subscribers: dict[str, list[str]] = {}
|
||||||
|
"""Event subscriber list."""
|
||||||
|
|
||||||
|
log_task = None
|
||||||
|
"""Task to tail the log file."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
queue: DownloadQueue | None = None,
|
queue: DownloadQueue | None = None,
|
||||||
|
|
@ -288,3 +294,92 @@ class HttpSocket(Common):
|
||||||
async def resume(self, *_, **__):
|
async def resume(self, *_, **__):
|
||||||
self.queue.resume()
|
self.queue.resume()
|
||||||
await self._notify.emit(Events.PAUSED, data={"paused": False, "at": time.time()})
|
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.debug("Starting log tailing task.")
|
||||||
|
self.log_task = asyncio.create_task(
|
||||||
|
tail_log(
|
||||||
|
file=os.path.join(self.config.config_path, "logs", "app.log"),
|
||||||
|
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):
|
||||||
|
"""
|
||||||
|
Handle client disconnection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sid (str): The session ID of the client.
|
||||||
|
|
||||||
|
"""
|
||||||
|
LOG.debug(f"Client '{sid}' disconnected.")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
|
||||||
|
|
@ -70,11 +70,18 @@ FILES_TYPE: list = [
|
||||||
{"rx": re.compile(r"\.(nfo|json|jpg|torrent|\.info\.json)$", re.IGNORECASE), "type": "metadata"},
|
{"rx": re.compile(r"\.(nfo|json|jpg|torrent|\.info\.json)$", re.IGNORECASE), "type": "metadata"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
DATETIME_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?")
|
||||||
|
|
||||||
|
|
||||||
class StreamingError(Exception):
|
class StreamingError(Exception):
|
||||||
"""Raised when an error occurs during streaming."""
|
"""Raised when an error occurs during streaming."""
|
||||||
|
|
||||||
|
|
||||||
|
class FileLogFormatter(logging.Formatter):
|
||||||
|
def formatTime(self, record, datefmt=None): # noqa: ARG002, N802
|
||||||
|
return datetime.datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds")
|
||||||
|
|
||||||
|
|
||||||
def calc_download_path(base_path: str, folder: str | None = None, create_path: bool = True) -> str:
|
def calc_download_path(base_path: str, folder: str | None = None, create_path: bool = True) -> str:
|
||||||
"""
|
"""
|
||||||
Calculates download path and prevents folder traversal.
|
Calculates download path and prevents folder traversal.
|
||||||
|
|
@ -944,3 +951,103 @@ def strip_newline(string: str) -> str:
|
||||||
res = re.sub(r"(\r\n|\r|\n)", " ", string)
|
res = re.sub(r"(\r\n|\r|\n)", " ", string)
|
||||||
|
|
||||||
return res.strip() if res else ""
|
return res.strip() if res else ""
|
||||||
|
|
||||||
|
|
||||||
|
async def read_logfile(file: str, offset: int = 0, limit: int = 50) -> list[str]:
|
||||||
|
"""
|
||||||
|
Read log file and return limited lines from the end.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file (str): The log file path.
|
||||||
|
offset (int): The number of lines to skip from the end.
|
||||||
|
limit (int): The number of lines to read.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[str]: A list of log lines.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from anyio import open_file
|
||||||
|
|
||||||
|
if not os.path.exists(file):
|
||||||
|
return []
|
||||||
|
|
||||||
|
from hashlib import sha256
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with await open_file(file, "rb") as f:
|
||||||
|
await f.seek(0, os.SEEK_END)
|
||||||
|
size = await f.tell()
|
||||||
|
|
||||||
|
block_size = 1024
|
||||||
|
block_end = size
|
||||||
|
buffer = b""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
while len(lines) < (offset + limit) and block_end > 0:
|
||||||
|
block_start = max(0, block_end - block_size)
|
||||||
|
await f.seek(block_start)
|
||||||
|
chunk = await f.read(block_end - block_start)
|
||||||
|
buffer = chunk + buffer
|
||||||
|
lines = buffer.splitlines()
|
||||||
|
block_end = block_start
|
||||||
|
|
||||||
|
selected_lines = lines[-(offset + limit) :] if offset else lines[-limit:]
|
||||||
|
if offset:
|
||||||
|
selected_lines = selected_lines[:-offset] or []
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for line in selected_lines:
|
||||||
|
line_bytes = line if isinstance(line, bytes) else line.encode()
|
||||||
|
msg = line.decode(errors="replace")
|
||||||
|
dt_match = DATETIME_PATTERN.match(msg)
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"id": sha256(line_bytes).hexdigest(),
|
||||||
|
"line": msg[dt_match.end() :] if dt_match else msg,
|
||||||
|
"datetime": dt_match.group(1) if dt_match else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def tail_log(file: str, emitter: callable, sleep_time: float = 0.5):
|
||||||
|
"""
|
||||||
|
Continuously read a log file and emit new lines.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file (str): The log file path.
|
||||||
|
emitter (callable): A callable to emit new lines.
|
||||||
|
sleep_time (float): The time to sleep between reads.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from asyncio import sleep as asyncio_sleep
|
||||||
|
from hashlib import sha256
|
||||||
|
|
||||||
|
from anyio import open_file
|
||||||
|
|
||||||
|
if not os.path.exists(file):
|
||||||
|
return
|
||||||
|
|
||||||
|
async with await open_file(file, "rb") as f:
|
||||||
|
await f.seek(0, os.SEEK_END)
|
||||||
|
while True:
|
||||||
|
line = await f.readline()
|
||||||
|
if not line:
|
||||||
|
await asyncio_sleep(sleep_time)
|
||||||
|
continue
|
||||||
|
|
||||||
|
msg = line.decode(errors="replace")
|
||||||
|
dt_match = DATETIME_PATTERN.match(msg)
|
||||||
|
|
||||||
|
await emitter(
|
||||||
|
{
|
||||||
|
"id": sha256(line if isinstance(line, bytes) else line.encode()).hexdigest(),
|
||||||
|
"line": msg[dt_match.end() :] if dt_match else msg,
|
||||||
|
"datetime": dt_match.group(1) if dt_match else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,14 @@ import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import TimedRotatingFileHandler
|
||||||
from multiprocessing.managers import SyncManager
|
from multiprocessing.managers import SyncManager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import coloredlogs
|
import coloredlogs
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from .Utils import arg_converter
|
from .Utils import FileLogFormatter, arg_converter
|
||||||
from .version import APP_VERSION
|
from .version import APP_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -55,6 +55,9 @@ class Config:
|
||||||
log_level: str = "info"
|
log_level: str = "info"
|
||||||
"""The log level to use for the application."""
|
"""The log level to use for the application."""
|
||||||
|
|
||||||
|
log_level_file: str = "info"
|
||||||
|
"""The log level to use for the file logging."""
|
||||||
|
|
||||||
max_workers: int = 1
|
max_workers: int = 1
|
||||||
"""The maximum number of workers to use for downloading."""
|
"""The maximum number of workers to use for downloading."""
|
||||||
|
|
||||||
|
|
@ -130,7 +133,7 @@ class Config:
|
||||||
instance_title: str | None = None
|
instance_title: str | None = None
|
||||||
"The title of the instance."
|
"The title of the instance."
|
||||||
|
|
||||||
file_logging: bool = False
|
file_logging: bool = True
|
||||||
"Enable file logging."
|
"Enable file logging."
|
||||||
|
|
||||||
sentry_dsn: str | None = None
|
sentry_dsn: str | None = None
|
||||||
|
|
@ -148,6 +151,9 @@ class Config:
|
||||||
ytdlp_auto_update: bool = False
|
ytdlp_auto_update: bool = False
|
||||||
"""Enable in-place auto update of yt-dlp package."""
|
"""Enable in-place auto update of yt-dlp package."""
|
||||||
|
|
||||||
|
ytdlp_cli: str = ""
|
||||||
|
"""The command line options to use for yt-dlp."""
|
||||||
|
|
||||||
pictures_backends: list[str] = [
|
pictures_backends: list[str] = [
|
||||||
"https://unsplash.it/1920/1080?random",
|
"https://unsplash.it/1920/1080?random",
|
||||||
"https://picsum.photos/1920/1080",
|
"https://picsum.photos/1920/1080",
|
||||||
|
|
@ -171,6 +177,7 @@ class Config:
|
||||||
"tasks",
|
"tasks",
|
||||||
"new_version_available",
|
"new_version_available",
|
||||||
"started",
|
"started",
|
||||||
|
"ytdlp_cli",
|
||||||
)
|
)
|
||||||
"The variables that are immutable."
|
"The variables that are immutable."
|
||||||
|
|
||||||
|
|
@ -217,6 +224,8 @@ class Config:
|
||||||
"sentry_dsn",
|
"sentry_dsn",
|
||||||
"console_enabled",
|
"console_enabled",
|
||||||
"browser_enabled",
|
"browser_enabled",
|
||||||
|
"ytdlp_cli",
|
||||||
|
"file_logging",
|
||||||
)
|
)
|
||||||
"The variables that are relevant to the frontend."
|
"The variables that are relevant to the frontend."
|
||||||
|
|
||||||
|
|
@ -323,12 +332,12 @@ class Config:
|
||||||
if os.path.exists(opts_file) and os.path.getsize(opts_file) > 2:
|
if os.path.exists(opts_file) and os.path.getsize(opts_file) > 2:
|
||||||
LOG.info(f"Loading yt-dlp custom options from '{opts_file}'.")
|
LOG.info(f"Loading yt-dlp custom options from '{opts_file}'.")
|
||||||
with open(opts_file) as f:
|
with open(opts_file) as f:
|
||||||
ytdlp_cli_opts = f.read().strip()
|
self.ytdlp_cli = f.read().strip()
|
||||||
if ytdlp_cli_opts:
|
if self.ytdlp_cli:
|
||||||
try:
|
try:
|
||||||
removed_options = []
|
removed_options = []
|
||||||
self.ytdl_options = arg_converter(
|
self.ytdl_options = arg_converter(
|
||||||
args=ytdlp_cli_opts,
|
args=self.ytdlp_cli,
|
||||||
level=1,
|
level=1,
|
||||||
removed_options=removed_options,
|
removed_options=removed_options,
|
||||||
)
|
)
|
||||||
|
|
@ -372,11 +381,22 @@ class Config:
|
||||||
LOG.info("The frontend is running in basic mode.")
|
LOG.info("The frontend is running in basic mode.")
|
||||||
|
|
||||||
if self.file_logging:
|
if self.file_logging:
|
||||||
handler = RotatingFileHandler(
|
log_level_file = getattr(logging, self.log_level_file.upper(), None)
|
||||||
os.path.join(self.config_path, "app.log"), maxBytes=1 * 1024 * 1024, backupCount=3
|
if not isinstance(log_level_file, int):
|
||||||
|
msg = f"Invalid file log level '{self.log_level_file}' specified."
|
||||||
|
raise TypeError(msg)
|
||||||
|
|
||||||
|
loggingPath = os.path.join(self.config_path, "logs")
|
||||||
|
if not os.path.exists(loggingPath):
|
||||||
|
os.makedirs(loggingPath, exist_ok=True)
|
||||||
|
|
||||||
|
handler = TimedRotatingFileHandler(
|
||||||
|
filename=os.path.join(self.config_path, "logs", "app.log"),
|
||||||
|
when="midnight",
|
||||||
|
backupCount=3,
|
||||||
)
|
)
|
||||||
handler.setLevel(logging.ERROR)
|
handler.setLevel(log_level_file)
|
||||||
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
formatter = FileLogFormatter("%(asctime)s [%(levelname)s.%(name)s]: %(message)s")
|
||||||
handler.setFormatter(formatter)
|
handler.setFormatter(formatter)
|
||||||
logging.getLogger().addHandler(handler)
|
logging.getLogger().addHandler(handler)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,11 +51,26 @@ hr {
|
||||||
border-bottom: 1px dotted;
|
border-bottom: 1px dotted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.has-tooltip {
|
||||||
|
cursor: help;
|
||||||
|
border-bottom: 1px dotted;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
.has-tooltip {
|
||||||
|
border-bottom-color: #000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
* {
|
* {
|
||||||
unicode-bidi: plaintext;
|
unicode-bidi: plaintext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.has-tooltip {
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
|
|
@ -266,6 +281,10 @@ hr {
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.is-pre-wrap {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.has-text-bold {
|
.has-text-bold {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
@ -290,3 +309,7 @@ hr {
|
||||||
.is-auto {
|
.is-auto {
|
||||||
unicode-bidi: embed;
|
unicode-bidi: embed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.is-justify-self-end {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-half-mobile">
|
<div class="column is-half-mobile" v-if="hasDownloaded">
|
||||||
<button type="button" class="button is-fullwidth is-link" :disabled="!hasSelected" @click="downloadSelected">
|
<button type="button" class="button is-fullwidth is-link" :disabled="!hasSelected" @click="downloadSelected">
|
||||||
<span class="icon-text is-block">
|
<span class="icon-text is-block">
|
||||||
<span class="icon"><i class="fa-solid fa-download" /></span>
|
<span class="icon"><i class="fa-solid fa-download" /></span>
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
@click="deleteSelectedItems">
|
@click="deleteSelectedItems">
|
||||||
<span class="icon-text is-block">
|
<span class="icon-text is-block">
|
||||||
<span class="icon"><i class="fa-solid fa-trash-can" /></span>
|
<span class="icon"><i class="fa-solid fa-trash-can" /></span>
|
||||||
<span>Remove</span>
|
<span>{{ config.app.remove_files ? 'Remove' : 'Clear' }}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -153,7 +153,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="control" v-if="item.status != 'finished' || !item.filename">
|
<div class="control" v-if="item.status != 'finished' || !item.filename">
|
||||||
<button class="button is-warning is-fullwidth is-small" v-tooltip="'Re-queue video'"
|
<button class="button is-warning is-fullwidth is-small" v-tooltip="'Re-queue video'"
|
||||||
@click="reQueueItem(item)">
|
@click="(event) => reQueueItem(item, event)">
|
||||||
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
|
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -283,7 +283,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="columns is-mobile is-multiline">
|
<div class="columns is-mobile is-multiline">
|
||||||
<div class="column is-half-mobile" v-if="item.status != 'finished' || !item.filename">
|
<div class="column is-half-mobile" v-if="item.status != 'finished' || !item.filename">
|
||||||
<a class="button is-warning is-fullwidth" @click="reQueueItem(item)">
|
<a class="button is-warning is-fullwidth" @click="(event) => reQueueItem(item, event)">
|
||||||
<span class="icon-text is-block">
|
<span class="icon-text is-block">
|
||||||
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
|
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
|
||||||
<span>Re-queue</span>
|
<span>Re-queue</span>
|
||||||
|
|
@ -371,13 +371,13 @@
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import { makeDownload, formatBytes } from '~/utils/index'
|
import { makeDownload, formatBytes } from '~/utils/index'
|
||||||
import toast from '~/plugins/toast'
|
|
||||||
import { isEmbedable, getEmbedable } from '~/utils/embedable'
|
import { isEmbedable, getEmbedable } from '~/utils/embedable'
|
||||||
|
|
||||||
const emitter = defineEmits(['getInfo'])
|
const emitter = defineEmits(['getInfo', 'add_new'])
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
const stateStore = useStateStore()
|
const stateStore = useStateStore()
|
||||||
const socket = useSocketStore()
|
const socket = useSocketStore()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
const selectedElms = ref([])
|
const selectedElms = ref([])
|
||||||
const masterSelectAll = ref(false)
|
const masterSelectAll = ref(false)
|
||||||
|
|
@ -455,6 +455,20 @@ const hasCompleted = computed(() => {
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hasDownloaded = computed(() => {
|
||||||
|
if (Object.keys(stateStore.history)?.length < 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key in stateStore.history) {
|
||||||
|
const element = stateStore.history[key]
|
||||||
|
if (element.status === 'finished' && element.filename) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
const deleteSelectedItems = () => {
|
const deleteSelectedItems = () => {
|
||||||
if (selectedElms.value.length < 1) {
|
if (selectedElms.value.length < 1) {
|
||||||
toast.error('No items selected.')
|
toast.error('No items selected.')
|
||||||
|
|
@ -609,12 +623,10 @@ const removeItem = item => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const reQueueItem = item => {
|
const reQueueItem = (item, event = null) => {
|
||||||
socket.emit('item_delete', { id: item._id, remove_file: false })
|
|
||||||
|
|
||||||
let extras = {}
|
let extras = {}
|
||||||
|
|
||||||
if (item.extras) {
|
if (item?.extras) {
|
||||||
Object.keys(item.extras).forEach(k => {
|
Object.keys(item.extras).forEach(k => {
|
||||||
if (k && true === k.startsWith('playlist')) {
|
if (k && true === k.startsWith('playlist')) {
|
||||||
extras[k] = item.extras[k]
|
extras[k] = item.extras[k]
|
||||||
|
|
@ -622,7 +634,7 @@ const reQueueItem = item => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.emit('add_url', {
|
const item_req = {
|
||||||
url: item.url,
|
url: item.url,
|
||||||
preset: item.preset,
|
preset: item.preset,
|
||||||
folder: item.folder,
|
folder: item.folder,
|
||||||
|
|
@ -630,7 +642,17 @@ const reQueueItem = item => {
|
||||||
template: item.template,
|
template: item.template,
|
||||||
cli: item?.cli,
|
cli: item?.cli,
|
||||||
extras: extras
|
extras: extras
|
||||||
})
|
};
|
||||||
|
|
||||||
|
socket.emit('item_delete', { id: item._id, remove_file: false })
|
||||||
|
|
||||||
|
if (event && (event?.altKey && true === event?.altKey)) {
|
||||||
|
toast.info('Removed the item from history, and added it to the new download form.')
|
||||||
|
emitter('add_new', item_req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.emit('add_url', item_req)
|
||||||
}
|
}
|
||||||
|
|
||||||
const pImg = e => e.target.naturalHeight > e.target.naturalWidth ? e.target.classList.add('image-portrait') : null
|
const pImg = e => e.target.naturalHeight > e.target.naturalWidth ? e.target.classList.add('image-portrait') : null
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
</label>
|
</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input type="text" class="input" id="url" placeholder="Video or playlist link"
|
<input type="text" class="input" id="url" placeholder="Video or playlist link"
|
||||||
:disabled="!socket.isConnected || addInProgress" v-model="url">
|
:disabled="!socket.isConnected || addInProgress" v-model="form.url">
|
||||||
</div>
|
</div>
|
||||||
<span class="help">
|
<span class="help">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
<div class="control is-expanded">
|
<div class="control is-expanded">
|
||||||
<div class="select is-fullwidth">
|
<div class="select is-fullwidth">
|
||||||
<select id="preset" class="is-fullwidth"
|
<select id="preset" class="is-fullwidth"
|
||||||
:disabled="!socket.isConnected || addInProgress || hasFormatInConfig" v-model="selectedPreset"
|
:disabled="!socket.isConnected || addInProgress || hasFormatInConfig" v-model="form.preset"
|
||||||
v-tooltip.bottom="hasFormatInConfig ? 'Presets are disabled. Format key is present in the Command arguments for yt-dlp.' : ''">
|
v-tooltip.bottom="hasFormatInConfig ? 'Presets are disabled. Format key is present in the Command arguments for yt-dlp.' : ''">
|
||||||
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0">
|
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0">
|
||||||
<option v-for="item in filter_presets(false)" :key="item.name" :value="item.name">
|
<option v-for="item in filter_presets(false)" :key="item.name" :value="item.name">
|
||||||
|
|
@ -55,7 +55,7 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="control is-expanded">
|
<div class="control is-expanded">
|
||||||
<input type="text" class="input is-fullwidth" id="path" v-model="downloadPath" placeholder="Default"
|
<input type="text" class="input is-fullwidth" id="path" v-model="form.folder" placeholder="Default"
|
||||||
:disabled="!socket.isConnected || addInProgress" list="folders">
|
:disabled="!socket.isConnected || addInProgress" list="folders">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -63,7 +63,7 @@
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<button type="submit" class="button is-primary"
|
<button type="submit" class="button is-primary"
|
||||||
:class="{ 'is-loading': !socket.isConnected || addInProgress }"
|
:class="{ 'is-loading': !socket.isConnected || addInProgress }"
|
||||||
:disabled="!socket.isConnected || addInProgress || !url">
|
:disabled="!socket.isConnected || addInProgress || !form?.url">
|
||||||
<span class="icon"><i class="fa-solid fa-plus" /></span>
|
<span class="icon"><i class="fa-solid fa-plus" /></span>
|
||||||
<span>Add</span>
|
<span>Add</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -77,7 +77,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="columns is-multiline is-mobile" v-if="showAdvanced && !config.app.basic_mode">
|
<div class="columns is-multiline is-mobile" v-if="showAdvanced && !config.app.basic_mode">
|
||||||
|
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline is-unselectable" for="output_format"
|
<label class="label is-inline is-unselectable" for="output_format"
|
||||||
|
|
@ -86,7 +85,7 @@
|
||||||
Output Template
|
Output Template
|
||||||
</label>
|
</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input type="text" class="input" v-model="output_template" id="output_format"
|
<input type="text" class="input" v-model="form.template" id="output_format"
|
||||||
:disabled="!socket.isConnected || addInProgress"
|
:disabled="!socket.isConnected || addInProgress"
|
||||||
placeholder="Uses default output template naming if empty.">
|
placeholder="Uses default output template naming if empty.">
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -98,14 +97,14 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-6-tablet is-4-mobile">
|
<div class="column is-6-tablet is-12-mobile">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline is-unselectable" for="cli_options">
|
<label class="label is-inline is-unselectable" for="cli_options">
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||||
Command arguments for yt-dlp
|
Command arguments for yt-dlp
|
||||||
</label>
|
</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<textarea class="textarea is-pre" v-model="ytdlp_cli" id="cli_options"
|
<textarea class="textarea is-pre" v-model="form.cli" id="cli_options"
|
||||||
:disabled="!socket.isConnected || addInProgress"
|
:disabled="!socket.isConnected || addInProgress"
|
||||||
placeholder="command options to use, e.g. --no-embed-metadata --no-embed-thumbnail" />
|
placeholder="command options to use, e.g. --no-embed-metadata --no-embed-thumbnail" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -121,14 +120,14 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-6-tablet is-4-mobile">
|
<div class="column is-6-tablet is-12-mobile">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline is-unselectable" for="ytdlpCookies">
|
<label class="label is-inline is-unselectable" for="ytdlpCookies">
|
||||||
<span class="icon"><i class="fa-solid fa-cookie" /></span>
|
<span class="icon"><i class="fa-solid fa-cookie" /></span>
|
||||||
Cookies
|
Cookies
|
||||||
</label>
|
</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<textarea class="textarea is-pre" id="ytdlpCookies" v-model="ytdlpCookies"
|
<textarea class="textarea is-pre" id="ytdlpCookies" v-model="form.cookies"
|
||||||
:disabled="!socket.isConnected || addInProgress" />
|
:disabled="!socket.isConnected || addInProgress" />
|
||||||
</div>
|
</div>
|
||||||
<span class="help">
|
<span class="help">
|
||||||
|
|
@ -141,21 +140,20 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-6-tablet is-4-mobile has-text-left">
|
<div class="column is-6-tablet is-4-mobile has-text-left">
|
||||||
<button type="button" class="button is-info" @click="emitter('getInfo', url)"
|
<button type="button" class="button is-info" @click="emitter('getInfo', form.url)"
|
||||||
:class="{ 'is-loading': !socket.isConnected }" :disabled="!socket.isConnected || addInProgress || !url">
|
:class="{ 'is-loading': !socket.isConnected }"
|
||||||
|
:disabled="!socket.isConnected || addInProgress || !form?.url">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>Information</span>
|
<span>Information</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-6-tablet is-6-mobile has-text-right">
|
<div class="column is-6-tablet is-6-mobile has-text-right">
|
||||||
<div class="field">
|
<div class="field is-grouped is-justify-self-end">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button type="button" class="button is-danger" @click="resetConfig" :disabled="!socket.isConnected"
|
<button type="button" class="button is-danger" @click="resetConfig"
|
||||||
v-tooltip="'This configuration are stored locally in your browser.'">
|
:disabled="!socket.isConnected || form?.id" v-tooltip="'Reset local settings'">
|
||||||
<span class="icon">
|
<span class="icon"><i class="fa-solid fa-rotate-left" /></span>
|
||||||
<i class="fa-solid fa-trash" />
|
<span>Reset</span>
|
||||||
</span>
|
|
||||||
<span>Reset Local Configuration</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -173,24 +171,36 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
|
|
||||||
const emitter = defineEmits(['getInfo'])
|
const props = defineProps({
|
||||||
|
item: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default: () => { },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emitter = defineEmits(['getInfo', 'clear_form'])
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
const socket = useSocketStore()
|
const socket = useSocketStore()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const selectedPreset = useStorage('selectedPreset', config.app.default_preset)
|
|
||||||
const ytdlpCookies = useStorage('ytdlp_cookies', '')
|
|
||||||
const ytdlp_cli = useStorage('ytdlp_cli', '')
|
|
||||||
const output_template = useStorage('output_template', null)
|
|
||||||
const downloadPath = useStorage('downloadPath', null)
|
|
||||||
const url = useStorage('downloadUrl', null)
|
|
||||||
const showAdvanced = useStorage('show_advanced', false)
|
const showAdvanced = useStorage('show_advanced', false)
|
||||||
const addInProgress = ref(false)
|
const addInProgress = ref(false)
|
||||||
|
|
||||||
|
const form = useStorage('local_config_v1', {
|
||||||
|
id: null,
|
||||||
|
url: '',
|
||||||
|
preset: config.app.default_preset,
|
||||||
|
cookies: '',
|
||||||
|
cli: '',
|
||||||
|
template: '',
|
||||||
|
folder: '',
|
||||||
|
extras: {},
|
||||||
|
})
|
||||||
|
|
||||||
const addDownload = async () => {
|
const addDownload = async () => {
|
||||||
// -- send request to convert cli options to JSON
|
if (form.value?.cli && '' !== form.value.cli) {
|
||||||
if (ytdlp_cli.value && '' !== ytdlp_cli.value) {
|
const options = await convertOptions(form.value.cli)
|
||||||
const options = await convertOptions(ytdlp_cli.value)
|
|
||||||
if (null === options) {
|
if (null === options) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -198,18 +208,24 @@ const addDownload = async () => {
|
||||||
|
|
||||||
addInProgress.value = true
|
addInProgress.value = true
|
||||||
|
|
||||||
url.value.split(',').forEach(url => {
|
form.value.url.split(',').forEach(url => {
|
||||||
if (!url.trim()) {
|
if (!url.trim()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
socket.emit('add_url', {
|
const data = {
|
||||||
url: url,
|
url: url,
|
||||||
preset: config.app.basic_mode ? config.app.default_preset : selectedPreset.value,
|
preset: config.app.basic_mode ? config.app.default_preset : form.value.preset,
|
||||||
folder: config.app.basic_mode ? null : downloadPath.value,
|
folder: config.app.basic_mode ? null : form.value.folder,
|
||||||
cookies: config.app.basic_mode ? '' : ytdlpCookies.value,
|
template: config.app.basic_mode ? null : form.value.template,
|
||||||
template: config.app.basic_mode ? null : output_template.value,
|
cookies: config.app.basic_mode ? '' : form.value.cookies,
|
||||||
cli: config.app.basic_mode ? null : ytdlp_cli.value,
|
cli: config.app.basic_mode ? null : form.value.cli,
|
||||||
})
|
}
|
||||||
|
|
||||||
|
if (form.value?.extras && Object.keys(form.value.extras).length > 0) {
|
||||||
|
data.extras = form.value.extras
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.emit('add_url', data)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,12 +234,17 @@ const resetConfig = () => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedPreset.value = config.app.default_preset
|
form.value = {
|
||||||
ytdlpCookies.value = ''
|
id: null,
|
||||||
ytdlp_cli.value = ''
|
url: '',
|
||||||
output_template.value = null
|
preset: config.app.default_preset,
|
||||||
url.value = null
|
cookies: '',
|
||||||
downloadPath.value = null
|
cli: '',
|
||||||
|
template: '',
|
||||||
|
folder: '',
|
||||||
|
extras: {},
|
||||||
|
}
|
||||||
|
|
||||||
showAdvanced.value = false
|
showAdvanced.value = false
|
||||||
|
|
||||||
toast.success('Local configuration has been reset.')
|
toast.success('Local configuration has been reset.')
|
||||||
|
|
@ -239,7 +260,7 @@ const statusHandler = async stream => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
url.value = ''
|
form.value.url = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const unlockDownload = async stream => {
|
const unlockDownload = async stream => {
|
||||||
|
|
@ -247,7 +268,7 @@ const unlockDownload = async stream => {
|
||||||
if (!json?.data) {
|
if (!json?.data) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ("unlock" in json.data && json.data.unlock === true) {
|
if ("unlock" in json.data && true === json.data.unlock) {
|
||||||
addInProgress.value = false
|
addInProgress.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -257,11 +278,11 @@ const convertOptions = async args => {
|
||||||
const response = await convertCliOptions(args)
|
const response = await convertCliOptions(args)
|
||||||
|
|
||||||
if (response.output_template) {
|
if (response.output_template) {
|
||||||
output_template.value = response.output_template
|
form.value.template = response.output_template
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.download_path) {
|
if (response.download_path) {
|
||||||
downloadPath.value = response.download_path
|
form.value.folder = response.download_path
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.opts
|
return response.opts
|
||||||
|
|
@ -272,11 +293,27 @@ const convertOptions = async args => {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
socket.on('status', statusHandler)
|
socket.on('status', statusHandler)
|
||||||
socket.on('error', unlockDownload)
|
socket.on('error', unlockDownload)
|
||||||
if ('' === selectedPreset.value) {
|
|
||||||
selectedPreset.value = config.app.default_preset.value
|
await nextTick()
|
||||||
|
|
||||||
|
if ('' === form.value?.preset) {
|
||||||
|
form.value.preset = config.app.default_preset
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props?.item) {
|
||||||
|
Object.keys(props.item).forEach(key => {
|
||||||
|
if (key in form.value) {
|
||||||
|
let value = props.item[key]
|
||||||
|
if ('extras' === key) {
|
||||||
|
value = JSON.parse(JSON.stringify(props.item[key]))
|
||||||
|
}
|
||||||
|
form.value[key] = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
emitter('clear_form');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -286,11 +323,11 @@ onUnmounted(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasFormatInConfig = computed(() => {
|
const hasFormatInConfig = computed(() => {
|
||||||
if (!ytdlp_cli.value) {
|
if (!form?.value?.value) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return /(?<!\S)(-f|--format)(=|\s)(\S+)/.test(ytdlp_cli.value)
|
return /(?<!\S)(-f|--format)(=|\s)(\S+)/.test(form.value.cli)
|
||||||
})
|
})
|
||||||
|
|
||||||
const filter_presets = (flag = true) => config.presets.filter(item => item.default === flag)
|
const filter_presets = (flag = true) => config.presets.filter(item => item.default === flag)
|
||||||
|
|
|
||||||
|
|
@ -331,7 +331,7 @@ const importItem = async () => {
|
||||||
try {
|
try {
|
||||||
const item = JSON.parse(val)
|
const item = JSON.parse(val)
|
||||||
|
|
||||||
if (item?._type || 'preset' !== item._type) {
|
if (!item?._type || 'preset' !== item._type) {
|
||||||
toast.error(`Invalid import string. Expected type 'preset', got '${item._type ?? 'unknown'}'.`)
|
toast.error(`Invalid import string. Expected type 'preset', got '${item._type ?? 'unknown'}'.`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -276,7 +276,7 @@ const checkInfo = async () => {
|
||||||
try {
|
try {
|
||||||
CronExpressionParser.parse(form.timer);
|
CronExpressionParser.parse(form.timer);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e)
|
console.error(e)
|
||||||
toast.error(`Invalid CRON expression. ${e.message}`);
|
toast.error(`Invalid CRON expression. ${e.message}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,13 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
error: {
|
error: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true
|
required: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const showStacks = ref(false)
|
const showStacks = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => console.error(props.error))
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -41,15 +41,6 @@
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="navbar-item" v-if="!config.app.basic_mode && config.app.console_enabled">
|
|
||||||
<NuxtLink class="button is-dark has-tooltip-bottom" to="/console">
|
|
||||||
<span class="icon-text">
|
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
|
||||||
<span class="is-hidden-mobile">Terminal</span>
|
|
||||||
</span>
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="navbar-item" v-if="!config.app.basic_mode">
|
<div class="navbar-item" v-if="!config.app.basic_mode">
|
||||||
<NuxtLink class="button is-dark has-tooltip-bottom" to="/tasks">
|
<NuxtLink class="button is-dark has-tooltip-bottom" to="/tasks">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
|
|
@ -59,7 +50,7 @@
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="navbar-item" v-if="!config.app.basic_mode" v-tooltip.bottom="'Notifications'">
|
<div class="navbar-item" v-if="!config.app.basic_mode">
|
||||||
<NuxtLink class="button is-dark has-tooltip-bottom" to="/notifications">
|
<NuxtLink class="button is-dark has-tooltip-bottom" to="/notifications">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon"><i class="fa-solid fa-paper-plane" /></span>
|
<span class="icon"><i class="fa-solid fa-paper-plane" /></span>
|
||||||
|
|
@ -68,6 +59,24 @@
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="navbar-item" v-if="!config.app.basic_mode && config.app.console_enabled">
|
||||||
|
<NuxtLink class="button is-dark has-tooltip-bottom" to="/console">
|
||||||
|
<span class="icon-text">
|
||||||
|
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||||
|
<span class="is-hidden-mobile">Terminal</span>
|
||||||
|
</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="navbar-item" v-if="!config.app.basic_mode && config.app.file_logging">
|
||||||
|
<NuxtLink class="button is-dark has-tooltip-bottom" to="/logs">
|
||||||
|
<span class="icon-text">
|
||||||
|
<span class="icon"><i class="fa-solid fa-file-lines" /></span>
|
||||||
|
<span class="is-hidden-mobile">Logs</span>
|
||||||
|
</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="navbar-item is-hidden-mobile">
|
<div class="navbar-item is-hidden-mobile">
|
||||||
<button class="button is-dark" @click="reloadPage">
|
<button class="button is-dark" @click="reloadPage">
|
||||||
<span class="icon"><i class="fas fa-refresh"></i></span>
|
<span class="icon"><i class="fas fa-refresh"></i></span>
|
||||||
|
|
@ -96,7 +105,7 @@
|
||||||
<span class="is-hidden-mobile"> ({{ config?.app?.version || 'unknown' }})</span>
|
<span class="is-hidden-mobile"> ({{ config?.app?.version || 'unknown' }})</span>
|
||||||
- <NuxtLink target="_blank" href="https://github.com/yt-dlp/yt-dlp">yt-dlp</NuxtLink>
|
- <NuxtLink target="_blank" href="https://github.com/yt-dlp/yt-dlp">yt-dlp</NuxtLink>
|
||||||
<span class="is-hidden-mobile"> ({{ config?.app?.ytdlp_version || 'unknown' }})</span>
|
<span class="is-hidden-mobile"> ({{ config?.app?.ytdlp_version || 'unknown' }})</span>
|
||||||
- <NuxtLink :to="`/changeslog?version=${config?.app?.version || 'unknown'}`">CHANGELOG</NuxtLink>
|
- <NuxtLink :to="`/changelog?version=${config?.app?.version || 'unknown'}`">CHANGELOG</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-4-mobile" v-if="config.app?.started">
|
<div class="column is-4-mobile" v-if="config.app?.started">
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
<div>
|
<div>
|
||||||
<div class="mt-1 columns is-multiline">
|
<div class="mt-1 columns is-multiline">
|
||||||
<div class="column is-12 is-clearfix is-unselectable">
|
<div class="column is-12 is-clearfix is-unselectable">
|
||||||
<span id="env_page_title" class="title is-4">
|
<span class="title is-4">
|
||||||
<span class="icon"><i class="fas fa-cogs" /></span>
|
<span class="icon"><i class="fas fa-cogs" /></span>
|
||||||
Changes log
|
CHANGELOG
|
||||||
</span>
|
</span>
|
||||||
<div class="is-hidden-mobile">
|
<div class="is-hidden-mobile">
|
||||||
<span class="subtitle">This page displays the application change logs.</span>
|
<span class="subtitle">This page display the latest changes and updates from the project.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="control" v-if="!config.app.basic_mode">
|
<p class="control" v-if="!config.app.basic_mode">
|
||||||
<button v-tooltip.bottom="'Toggle Add Form'" class="button is-primary has-tooltip-bottom"
|
<button v-tooltip.bottom="'Toggle new download form'" class="button is-primary has-tooltip-bottom"
|
||||||
@click="config.showForm = !config.showForm">
|
@click="config.showForm = !config.showForm">
|
||||||
<span class="icon"><i class="fa-solid fa-plus" /></span>
|
<span class="icon"><i class="fa-solid fa-plus" /></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -73,9 +73,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NewDownload v-if="config.showForm || config.app.basic_mode" @getInfo="url => get_info = url" />
|
<NewDownload v-if="config.showForm || config.app.basic_mode" @getInfo="url => get_info = url" :item="item_form"
|
||||||
|
@clear_form="item_form = {}" />
|
||||||
<Queue @getInfo="url => get_info = url" />
|
<Queue @getInfo="url => get_info = url" />
|
||||||
<History @getInfo="url => get_info = url" />
|
<History @getInfo="url => get_info = url" @add_new="item => toNewDownload(item)" />
|
||||||
<GetInfo v-if="get_info" :link="get_info" @closeModel="get_info = ''" />
|
<GetInfo v-if="get_info" :link="get_info" @closeModel="get_info = ''" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -94,6 +95,7 @@ const get_info = ref('')
|
||||||
const bg_enable = useStorage('random_bg', true)
|
const bg_enable = useStorage('random_bg', true)
|
||||||
const bg_opacity = useStorage('random_bg_opacity', 0.85)
|
const bg_opacity = useStorage('random_bg_opacity', 0.85)
|
||||||
const display_style = useStorage('display_style', 'cards')
|
const display_style = useStorage('display_style', 'cards')
|
||||||
|
const item_form = ref({})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!config.app.ui_update_title) {
|
if (!config.app.ui_update_title) {
|
||||||
|
|
@ -159,4 +161,20 @@ watch(get_info, v => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const changeDisplay = () => display_style.value = display_style.value === 'cards' ? 'list' : 'cards'
|
const changeDisplay = () => display_style.value = display_style.value === 'cards' ? 'list' : 'cards'
|
||||||
|
|
||||||
|
const toNewDownload = async (item) => {
|
||||||
|
if (!item) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.showForm) {
|
||||||
|
config.showForm = false
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
item_form.value = item
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
config.showForm = true
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
273
ui/pages/logs.vue
Normal file
273
ui/pages/logs.vue
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
<style scoped>
|
||||||
|
#logView {
|
||||||
|
min-height: 72vh;
|
||||||
|
min-width: inherit;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#logView>span:nth-child(even) {
|
||||||
|
color: #ffc9d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
#logView>span:nth-child(odd) {
|
||||||
|
color: #e3c981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbox {
|
||||||
|
background-color: #333;
|
||||||
|
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
|
||||||
|
min-width: 100%;
|
||||||
|
max-height: 73vh;
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.logbox pre {
|
||||||
|
background-color: rgb(31, 34, 41);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logline {
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 2.3em;
|
||||||
|
padding: 1em;
|
||||||
|
color: #fff1b8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="mt-1 columns is-multiline">
|
||||||
|
<div class="column is-12 is-clearfix is-unselectable">
|
||||||
|
<span class="title is-4">
|
||||||
|
<span class="icon"><i class="fas fa-file-lines" :class="{ 'fa-spin': loading }" /></span>
|
||||||
|
Logs
|
||||||
|
</span>
|
||||||
|
<div class="is-pulled-right">
|
||||||
|
<div class="field is-grouped">
|
||||||
|
<div class="control">
|
||||||
|
<button v-if="!autoScroll" @click="scrollToBottom" class="button is-primary">
|
||||||
|
<span class="icon"><i class="fas fa-arrow-down"></i></span>
|
||||||
|
<span>Go to Bottom</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control has-icons-left" v-if="toggleFilter || query">
|
||||||
|
<input type="search" v-model.lazy="query" class="input" id="filter"
|
||||||
|
placeholder="Filter displayed content">
|
||||||
|
<span class="icon is-left"><i class="fas fa-filter" /></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-danger is-light" @click="toggleFilter = !toggleFilter">
|
||||||
|
<span class="icon"><i class="fas fa-filter" /></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<p class="control">
|
||||||
|
<button class="button is-purple" @click="textWrap = !textWrap" v-tooltip.bottom="'Toggle text wrap'">
|
||||||
|
<span class="icon"><i class="fas fa-text-width"></i></span>
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="is-hidden-mobile">
|
||||||
|
<span class="subtitle">The logs are displayed in real-time. You can scroll up to view older logs.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-12">
|
||||||
|
<div class="logbox is-grid" ref="logContainer" @scroll.passive="handleScroll">
|
||||||
|
<pre class="p-1 m-1"><code id="logView" class="p-0 logline is-block"
|
||||||
|
:class="{ 'is-pre-wrap': true === textWrap, 'is-pre': false === textWrap }"><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 v-if="query"><span
|
||||||
|
class="has-text-danger">No logs found for the filter: </span><span class="has-text-info">{{ query
|
||||||
|
}}</span></span><span v-else><span class="has-text-danger">No logs available</span></span>
|
||||||
|
</span></code></pre>
|
||||||
|
<div ref="bottomMarker"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import moment from 'moment'
|
||||||
|
import { request } from '~/utils/index'
|
||||||
|
import { ref, onMounted, nextTick } from 'vue'
|
||||||
|
|
||||||
|
let scrollTimeout = null
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const socket = useSocketStore()
|
||||||
|
const config = useConfigStore()
|
||||||
|
|
||||||
|
const logs = ref([])
|
||||||
|
const offset = ref(0)
|
||||||
|
const limit = 50
|
||||||
|
const maxLogLimit = 1000
|
||||||
|
const loading = ref(false)
|
||||||
|
const logContainer = ref(null)
|
||||||
|
const bottomMarker = ref(null)
|
||||||
|
const autoScroll = ref(true)
|
||||||
|
const textWrap = ref(true)
|
||||||
|
|
||||||
|
const query = ref(useRoute().query.filter ?? '')
|
||||||
|
const toggleFilter = ref(false)
|
||||||
|
watch(toggleFilter, () => {
|
||||||
|
if (!toggleFilter.value) {
|
||||||
|
query.value = ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => config.app.basic_mode, async v => {
|
||||||
|
if (!v) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await navigateTo('/')
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => config.app.file_logging, async v => {
|
||||||
|
if (v) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await navigateTo('/')
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredItems = computed(() => {
|
||||||
|
const raw = query.value.trim().toLowerCase()
|
||||||
|
const contextMatch = raw.match(/context:(\d+)/)
|
||||||
|
const context = contextMatch ? parseInt(contextMatch[1], 10) : 0
|
||||||
|
const searchTerm = raw.replace(/context:\d+/, '').trim()
|
||||||
|
|
||||||
|
if (!searchTerm) return logs.value
|
||||||
|
|
||||||
|
const result = []
|
||||||
|
const matchedIndexes = new Set()
|
||||||
|
|
||||||
|
logs.value.forEach((log, i) => {
|
||||||
|
if (log.line.toLowerCase().includes(searchTerm)) {
|
||||||
|
for (let j = Math.max(0, i - context); j <= Math.min(logs.value.length - 1, i + context); j++) {
|
||||||
|
matchedIndexes.add(j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Array.from(matchedIndexes).sort((a, b) => a - b).forEach(index => {
|
||||||
|
result.push(logs.value[index])
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchLogs = async () => {
|
||||||
|
if (logs.value.length >= maxLogLimit) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const req = await request(`/api/logs?offset=${offset.value}&limit=${limit}`)
|
||||||
|
if (!req.ok) {
|
||||||
|
toast.error('Failed to fetch logs');
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await req.json()
|
||||||
|
if (response.error) {
|
||||||
|
toast.error(response.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = response.logs ?? [];
|
||||||
|
|
||||||
|
if (lines.length) {
|
||||||
|
logs.value.unshift(...response.logs)
|
||||||
|
offset.value += lines.length;
|
||||||
|
if (logs.value.length > maxLogLimit) {
|
||||||
|
logs.value.splice(0, logs.value.length - maxLogLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-scroll only if the user was already at the bottom
|
||||||
|
nextTick(() => {
|
||||||
|
if (autoScroll.value && bottomMarker.value) {
|
||||||
|
bottomMarker.value.scrollIntoView({ behavior: 'auto' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch logs:', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!logContainer.value || query.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = logContainer.value
|
||||||
|
const nearBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 50
|
||||||
|
const nearTop = container.scrollTop < 50
|
||||||
|
|
||||||
|
autoScroll.value = nearBottom
|
||||||
|
|
||||||
|
if (nearTop && !loading.value && !scrollTimeout) {
|
||||||
|
scrollTimeout = setTimeout(async () => {
|
||||||
|
const previousHeight = container.scrollHeight
|
||||||
|
await fetchLogs()
|
||||||
|
nextTick(() => {
|
||||||
|
const newHeight = container.scrollHeight
|
||||||
|
container.scrollTop += newHeight - previousHeight
|
||||||
|
})
|
||||||
|
scrollTimeout = null
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
autoScroll.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
if (bottomMarker.value) {
|
||||||
|
bottomMarker.value.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchLogs()
|
||||||
|
socket.emit('subscribe', 'log_lines')
|
||||||
|
socket.on('log_lines', data => {
|
||||||
|
if (logs.value.length >= maxLogLimit) {
|
||||||
|
logs.value.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
logs.value.push(data)
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
if (autoScroll.value && bottomMarker.value) {
|
||||||
|
bottomMarker.value.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
socket.emit('unsubscribe', 'log_lines')
|
||||||
|
socket.off('log_lines')
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
socket.emit('unsubscribe', 'log_lines')
|
||||||
|
socket.off('log_lines')
|
||||||
|
if (scrollTimeout) clearTimeout(scrollTimeout)
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Logs' })
|
||||||
|
</script>
|
||||||
|
|
@ -127,6 +127,11 @@ div.is-centered {
|
||||||
<li>
|
<li>
|
||||||
When you export preset, it doesn't include <code>Cookies</code> field for security reasons.
|
When you export preset, it doesn't include <code>Cookies</code> field for security reasons.
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
If you have created a global <code>config/ytdlp.cli</code> file, it will be appended to your exported preset
|
||||||
|
<code><i class="fa-solid fa-terminal" /> Command arguments for yt-dlp</code> field for better compatibility
|
||||||
|
and completeness.
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</Message>
|
</Message>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -307,6 +312,15 @@ const exportItem = item => {
|
||||||
userData[key] = data[key]
|
userData[key] = data[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config?.app?.ytdlp_cli) {
|
||||||
|
const val = `# exported from ytdlp.cli #\n${config.app.ytdlp_cli}\n# exported from ytdlp.cli #\n`
|
||||||
|
if (userData.cli) {
|
||||||
|
userData.cli = val + "\n" + userData.cli
|
||||||
|
} else {
|
||||||
|
userData.cli = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
userData['_type'] = 'preset'
|
userData['_type'] = 'preset'
|
||||||
userData['_version'] = '2.0'
|
userData['_version'] = '2.0'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ const CONFIG_KEYS = {
|
||||||
sentry_dsn: null,
|
sentry_dsn: null,
|
||||||
console_enabled: false,
|
console_enabled: false,
|
||||||
browser_enabled: false,
|
browser_enabled: false,
|
||||||
|
ytdlp_cli: '',
|
||||||
|
file_logging: false,
|
||||||
},
|
},
|
||||||
presets: [
|
presets: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue