completely refactored HttpAPI into more manageable routes.
This commit is contained in:
parent
698791b7fc
commit
400cf7bec3
33 changed files with 2324 additions and 1898 deletions
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
|
|
@ -63,6 +63,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
|
||||
|
||||
|
|
@ -566,7 +572,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=}"
|
||||
)
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -12,7 +12,6 @@ import anyio
|
|||
import socketio
|
||||
from aiohttp import web
|
||||
|
||||
from .common import Common
|
||||
from .config import Config
|
||||
from .DownloadQueue import DownloadQueue
|
||||
from .encoder import Encoder
|
||||
|
|
@ -24,7 +23,7 @@ from .Utils import is_downloaded, tail_log
|
|||
LOG = logging.getLogger("socket_api")
|
||||
|
||||
|
||||
class HttpSocket(Common):
|
||||
class HttpSocket:
|
||||
"""
|
||||
This class is used to handle WebSocket events.
|
||||
"""
|
||||
|
|
@ -68,8 +67,6 @@ class HttpSocket(Common):
|
|||
|
||||
self._notify.subscribe("frontend", emit, f"{__class__.__name__}.emit")
|
||||
|
||||
super().__init__(queue=queue, encoder=encoder, config=config)
|
||||
|
||||
@staticmethod
|
||||
def ws_event(func): # type: ignore
|
||||
"""
|
||||
|
|
@ -102,7 +99,7 @@ class HttpSocket(Common):
|
|||
|
||||
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",
|
||||
)
|
||||
|
||||
|
|
@ -237,7 +234,7 @@ class HttpSocket(Common):
|
|||
try:
|
||||
await self._notify.emit(
|
||||
event=Events.STATUS,
|
||||
data=await self.add(item=Item.format(data)),
|
||||
data=await self.queue.add(item=Item.format(data)),
|
||||
to=sid,
|
||||
)
|
||||
except ValueError as e:
|
||||
|
|
|
|||
|
|
@ -175,13 +175,14 @@ class Presets(metaclass=Singleton):
|
|||
Presets: The current instance.
|
||||
|
||||
"""
|
||||
has: int = len(self._items)
|
||||
self.clear()
|
||||
|
||||
if not self._file.exists() or self._file.stat().st_size < 10:
|
||||
return self
|
||||
|
||||
try:
|
||||
LOG.info(f"Loading '{self._file}'.")
|
||||
LOG.info(f"{'Reloading' if has else 'Loading'} '{self._file}'.")
|
||||
presets: dict = json.loads(self._file.read_text())
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to parse '{self._file}'. '{e}'.")
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ class Scheduler(metaclass=Singleton):
|
|||
|
||||
self._jobs[job_id] = job
|
||||
|
||||
LOG.debug(f"Added job '{job_id}' to the schedule.")
|
||||
LOG.debug(f"Added '{job_id}' to the scheduler.")
|
||||
|
||||
return job_id
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from .Scheduler import Scheduler
|
|||
from .Singleton import Singleton
|
||||
from .Utils import init_class
|
||||
|
||||
LOG = logging.getLogger("tasks")
|
||||
LOG: logging.Logger = logging.getLogger("tasks")
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
|
|
@ -124,13 +124,14 @@ class Tasks(metaclass=Singleton):
|
|||
Tasks: The current instance.
|
||||
|
||||
"""
|
||||
has_tasks: bool = len(self._tasks) > 0
|
||||
self.clear()
|
||||
|
||||
if not self._file.exists() or self._file.stat().st_size < 1:
|
||||
return self
|
||||
|
||||
try:
|
||||
LOG.info(f"Loading '{self._file}'.")
|
||||
LOG.info(f"{'Reloading' if has_tasks else 'Loading'} '{self._file}'.")
|
||||
tasks = json.loads(self._file.read_text())
|
||||
except Exception as e:
|
||||
LOG.error(f"Error loading '{self._file}'. '{e!s}'.")
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from Crypto.Cipher import AES
|
|||
|
||||
from .LogWrapper import LogWrapper
|
||||
|
||||
LOG = logging.getLogger("Utils")
|
||||
LOG: logging.Logger = logging.getLogger("Utils")
|
||||
|
||||
REMOVE_KEYS: list = [
|
||||
{
|
||||
|
|
@ -803,7 +803,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 +832,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 +845,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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -20,11 +20,10 @@ class Common:
|
|||
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
|
||||
self.queue: DownloadQueue = queue or DownloadQueue.get_instance()
|
||||
self.encoder: Encoder = encoder or Encoder()
|
||||
config: Config = config or Config.get_instance()
|
||||
self.default_preset: str = config.default_preset
|
||||
|
||||
async def add(self, item: Item) -> dict[str, str]:
|
||||
"""
|
||||
|
|
|
|||
124
app/library/router.py
Normal file
124
app/library/router.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import logging
|
||||
import re
|
||||
from collections.abc import Awaitable
|
||||
from functools import wraps
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Route:
|
||||
"""
|
||||
A class to represent an HTTP 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 = method.upper()
|
||||
self.path = path
|
||||
self.name = name
|
||||
self.handler: Awaitable = handler
|
||||
|
||||
|
||||
ROUTES: dict[str, Route] = {}
|
||||
|
||||
|
||||
def make_route_name(method: str, path: str) -> str:
|
||||
method = method.lower()
|
||||
path = path.strip("/")
|
||||
|
||||
segments = []
|
||||
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: str, path: str, name: str | None = None, **kwargs) -> Awaitable:
|
||||
"""
|
||||
Decorator to mark a method as an HTTP route handler.
|
||||
|
||||
Args:
|
||||
method (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)
|
||||
|
||||
ROUTES[name] = Route(method=method.upper(), path=path, name=name, handler=wrapper)
|
||||
if path.endswith("/") and "/" != path and not kwargs.get("no_slash", False):
|
||||
ROUTES[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: str, path: str, handler: Awaitable, name: str | None = None, **kwargs):
|
||||
"""
|
||||
Decorator to mark a method as an HTTP route handler.
|
||||
|
||||
Args:
|
||||
method (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)
|
||||
|
||||
ROUTES[name] = Route(method=method.upper(), path=path, name=name, handler=handler)
|
||||
if path.endswith("/") and "/" != path and not kwargs.get("no_slash", False):
|
||||
ROUTES[f"{name}_no_slash"] = Route(
|
||||
method=method.upper(), path=path[:-1], name=f"{name}_no_slash", handler=handler
|
||||
)
|
||||
|
||||
|
||||
def get_route(name: str) -> dict[str, Route] | None:
|
||||
"""
|
||||
Get the route information by name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the route.
|
||||
|
||||
Returns:
|
||||
dict: The route information, or None if not found.
|
||||
|
||||
"""
|
||||
return ROUTES.get(name)
|
||||
|
||||
|
||||
def get_routes() -> dict[str, Route]:
|
||||
"""
|
||||
Get all registered routes.
|
||||
|
||||
Returns:
|
||||
dict[str, dict]: A dictionary of all registered routes.
|
||||
|
||||
"""
|
||||
return ROUTES
|
||||
31
app/main.py
31
app/main.py
|
|
@ -1,24 +1,31 @@
|
|||
#!/usr/bin/env python3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
APP_ROOT = str((Path(__file__).parent / "..").resolve())
|
||||
if APP_ROOT not in sys.path:
|
||||
sys.path.insert(0, APP_ROOT)
|
||||
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import caribou
|
||||
import magic
|
||||
from aiohttp import web
|
||||
from library.conditions import Conditions
|
||||
from library.config import Config
|
||||
from library.DownloadQueue import DownloadQueue
|
||||
from library.Events import EventBus, Events
|
||||
from library.HttpAPI import HttpAPI
|
||||
from library.HttpSocket import HttpSocket
|
||||
from library.Notifications import Notification
|
||||
from library.Presets import Presets
|
||||
from library.Scheduler import Scheduler
|
||||
from library.Tasks import Tasks
|
||||
|
||||
from app.library.conditions import Conditions
|
||||
from app.library.config import Config
|
||||
from app.library.DownloadQueue import DownloadQueue
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.HttpAPI import HttpAPI
|
||||
from app.library.HttpSocket import HttpSocket
|
||||
from app.library.Notifications import Notification
|
||||
from app.library.Presets import Presets
|
||||
from app.library.Scheduler import Scheduler
|
||||
from app.library.Tasks import Tasks
|
||||
|
||||
LOG = logging.getLogger("app")
|
||||
MIME = magic.Magic(mime=True)
|
||||
|
|
@ -117,7 +124,7 @@ class Main:
|
|||
|
||||
HTTP_LOGGER = None
|
||||
if self._config.access_log:
|
||||
from library.HttpAPI import LOG as HTTP_LOGGER
|
||||
from app.library.HttpAPI import LOG as HTTP_LOGGER
|
||||
|
||||
HTTP_LOGGER.addFilter(lambda record: f"GET {self._app.router['ping'].url_for()}" not in record.getMessage())
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
#!/usr/bin/env python3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
APP_ROOT = str((Path(__file__).parent / "..").resolve())
|
||||
if APP_ROOT not in sys.path:
|
||||
sys.path.insert(0, APP_ROOT)
|
||||
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import socket
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
ready = threading.Event()
|
||||
exception_holder = queue.Queue()
|
||||
|
|
|
|||
135
app/routes/_static.py
Normal file
135
app/routes/_static.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import magic
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.router import add_route
|
||||
|
||||
MIME = magic.Magic(mime=True)
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
STATIC_FILES: dict[str, dict] = {}
|
||||
|
||||
EXT_TO_MIME: dict = {
|
||||
".html": "text/html",
|
||||
".css": "text/css",
|
||||
".js": "application/javascript",
|
||||
".json": "application/json",
|
||||
".ico": "image/x-icon",
|
||||
}
|
||||
|
||||
FRONTEND_ROUTES: list[str] = [
|
||||
"/console/",
|
||||
"/presets/",
|
||||
"/tasks/",
|
||||
"/notifications/",
|
||||
"/changelog/",
|
||||
"/logs/",
|
||||
"/conditions/",
|
||||
"/browser/",
|
||||
"/browser/{path:.*}",
|
||||
]
|
||||
|
||||
|
||||
async def serve_static_file(request: Request, config: Config) -> Response:
|
||||
"""
|
||||
Preload static files from the ui/exported folder.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
path: str = request.path
|
||||
|
||||
if "/" != config.base_path and path.startswith(config.base_path):
|
||||
path = path.replace(config.base_path[:-1], "", 1)
|
||||
|
||||
if path not in STATIC_FILES:
|
||||
for k in STATIC_FILES:
|
||||
if path.startswith(k):
|
||||
path = k
|
||||
break
|
||||
else:
|
||||
return web.json_response({"error": "File not found.", "file": path}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
item: dict = STATIC_FILES[path]
|
||||
|
||||
return web.FileResponse(
|
||||
path=item["file"],
|
||||
headers={
|
||||
"Pragma": "public",
|
||||
"Cache-Control": "public, max-age=31536000",
|
||||
"Content-Type": item.get("content_type"),
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
)
|
||||
|
||||
|
||||
def preload_static(root_path: Path, config: Config) -> None:
|
||||
"""
|
||||
Preload static files from the ui/exported folder.
|
||||
|
||||
Args:
|
||||
root_path (Path): The root path of the application.
|
||||
config (Config): The configuration instance.
|
||||
|
||||
"""
|
||||
global STATIC_FILES # noqa: PLW0602
|
||||
|
||||
static_dir: Path = (root_path / "ui" / "exported").absolute()
|
||||
if not static_dir.exists():
|
||||
static_dir = (root_path.parent / "ui" / "exported").absolute()
|
||||
if not static_dir.exists():
|
||||
msg: str = f"Could not find the frontend UI static assets. '{static_dir}'."
|
||||
raise ValueError(msg)
|
||||
|
||||
preloaded = 0
|
||||
|
||||
for file in static_dir.rglob("*.*"):
|
||||
if ".map" == file.suffix:
|
||||
continue
|
||||
|
||||
uri_path: str = f"/{file.relative_to(static_dir).as_posix()!s}"
|
||||
# uri_path: str = f"/{str(file.as_posix()).replace(f'{static_dir.as_posix()!s}/', '')}"
|
||||
contentType = EXT_TO_MIME.get(file.suffix)
|
||||
if not contentType:
|
||||
contentType = MIME.from_file(file)
|
||||
|
||||
STATIC_FILES[uri_path] = {
|
||||
"uri": uri_path,
|
||||
"content_type": contentType,
|
||||
"file": file,
|
||||
}
|
||||
|
||||
add_route(method="GET", path=uri_path, handler=serve_static_file)
|
||||
preloaded += 1
|
||||
|
||||
if "/index.html" in STATIC_FILES:
|
||||
for path in FRONTEND_ROUTES:
|
||||
STATIC_FILES[path] = STATIC_FILES["/index.html"]
|
||||
STATIC_FILES[path.lstrip("/")] = STATIC_FILES["/index.html"]
|
||||
add_route(method="GET", path=path, handler=serve_static_file)
|
||||
LOG.debug(f"Preloading frontend static route '{path}'.")
|
||||
preloaded += 1
|
||||
|
||||
# Add main app route
|
||||
STATIC_FILES["/"] = STATIC_FILES["/index.html"]
|
||||
STATIC_FILES[config.base_path] = STATIC_FILES["/index.html"]
|
||||
add_route(method="GET", path=config.base_path, handler=serve_static_file, name="index")
|
||||
|
||||
if preloaded < 1:
|
||||
message = f"Failed to find any static files in '{static_dir}'."
|
||||
if config.ignore_ui:
|
||||
LOG.warning(message)
|
||||
return
|
||||
|
||||
raise ValueError(message)
|
||||
|
||||
LOG.info(f"Loaded '{preloaded}' static files.")
|
||||
157
app/routes/browser.py
Normal file
157
app/routes/browser.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import logging
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote_plus
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.ffprobe import ffprobe
|
||||
from app.library.router import route
|
||||
from app.library.Utils import get_file, get_file_sidecar, get_files, get_mime_type
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route("GET", "api/file/ffprobe/{file:.*}", "ffprobe")
|
||||
async def get_ffprobe(request: Request, config: Config, encoder: Encoder, app: web.Application) -> Response:
|
||||
"""
|
||||
Get the ffprobe data.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration object.
|
||||
encoder (Encoder): The encoder object.
|
||||
app (web.Application): The aiohttp application object.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
file: str = request.match_info.get("file")
|
||||
if not file:
|
||||
return web.json_response(data={"error": "file is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
realFile, status = get_file(download_path=config.download_path, file=file)
|
||||
if web.HTTPFound.status_code == status:
|
||||
return Response(
|
||||
status=web.HTTPFound.status_code,
|
||||
headers={
|
||||
"Location": str(
|
||||
app.router["ffprobe"].url_for(
|
||||
file=str(realFile.relative_to(config.download_path).as_posix()).strip("/")
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if web.HTTPNotFound.status_code == status:
|
||||
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||
|
||||
return web.json_response(data=await ffprobe(realFile), status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
except Exception as e:
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
|
||||
|
||||
|
||||
@route("GET", "api/file/info/{file:.*}", "file_info")
|
||||
async def get_file_info(request: Request, config: Config, encoder: Encoder, app: web.Application) -> Response:
|
||||
"""
|
||||
Get file info
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration object.
|
||||
encoder (Encoder): The encoder object.
|
||||
app (web.Application): The aiohttp application object.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
file: str = request.match_info.get("file")
|
||||
if not file:
|
||||
return web.json_response(data={"error": "file is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
realFile, status = get_file(download_path=config.download_path, file=file)
|
||||
if web.HTTPFound.status_code == status:
|
||||
return Response(
|
||||
status=web.HTTPFound.status_code,
|
||||
headers={
|
||||
"Location": str(
|
||||
app.router["file_info"].url_for(
|
||||
file=str(realFile.relative_to(config.download_path).as_posix()).strip("/")
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if web.HTTPNotFound.status_code == status:
|
||||
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||
|
||||
ff_info = await ffprobe(realFile)
|
||||
|
||||
response = {
|
||||
"title": realFile.stem,
|
||||
"ffprobe": ff_info,
|
||||
"mimetype": get_mime_type(ff_info.get("metadata", {}), realFile),
|
||||
"sidecar": get_file_sidecar(realFile),
|
||||
}
|
||||
|
||||
for key in response["sidecar"]:
|
||||
for i, f in enumerate(response["sidecar"][key]):
|
||||
response["sidecar"][key][i]["file"] = str(
|
||||
realFile.with_name(f["file"].name).relative_to(config.download_path)
|
||||
).strip("/")
|
||||
|
||||
return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
|
||||
|
||||
|
||||
@route("GET", "api/file/browser/{path:.*}", "file_browser")
|
||||
async def file_browser(request: Request, config: Config, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Get the file browser.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration object.
|
||||
encoder (Encoder): The encoder object.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
if not config.browser_enabled:
|
||||
return web.json_response(data={"error": "File browser is disabled."}, status=web.HTTPForbidden.status_code)
|
||||
|
||||
req_path: str = request.match_info.get("path")
|
||||
req_path: str = "/" if not req_path else unquote_plus(req_path)
|
||||
|
||||
test: Path = Path(config.download_path).joinpath(req_path)
|
||||
if not test.exists():
|
||||
return web.json_response(
|
||||
data={"error": f"path '{req_path}' does not exist."}, status=web.HTTPNotFound.status_code
|
||||
)
|
||||
|
||||
if not test.is_dir() and not test.is_symlink():
|
||||
return web.json_response(
|
||||
data={"error": f"path '{req_path}' is not a directory."}, status=web.HTTPBadRequest.status_code
|
||||
)
|
||||
|
||||
try:
|
||||
return web.json_response(
|
||||
data={
|
||||
"path": req_path,
|
||||
"contents": get_files(base_path=Path(config.download_path), dir=req_path),
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
except OSError as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
|
||||
191
app/routes/conditions.py
Normal file
191
app/routes/conditions.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import logging
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.cache import Cache
|
||||
from app.library.conditions import Condition, Conditions
|
||||
from app.library.config import Config
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.router import route
|
||||
from app.library.Utils import extract_info, init_class, validate_uuid
|
||||
from app.library.YTDLPOpts import YTDLPOpts
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route("GET", "api/conditions/", "conditions_list")
|
||||
async def conditions_list(encoder: Encoder) -> Response:
|
||||
"""
|
||||
Get the conditions
|
||||
|
||||
Args:
|
||||
encoder (Encoder): The encoder instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
return web.json_response(
|
||||
data=Conditions.get_instance().get_all(),
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("PUT", "api/conditions/", "conditions_add")
|
||||
async def conditions_add(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
"""
|
||||
Save Conditions.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
notify (EventBus): The event bus instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object
|
||||
|
||||
"""
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, list):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting list with dicts."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
items: list = []
|
||||
|
||||
cls = Conditions.get_instance()
|
||||
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting list with dicts."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if not item.get("name"):
|
||||
return web.json_response(
|
||||
{"error": "name is required.", "data": item}, status=web.HTTPBadRequest.status_code
|
||||
)
|
||||
|
||||
if not item.get("filter"):
|
||||
return web.json_response(
|
||||
{"error": "filter is required.", "data": item}, status=web.HTTPBadRequest.status_code
|
||||
)
|
||||
|
||||
if not item.get("cli"):
|
||||
return web.json_response(
|
||||
{"error": "command options for yt-dlp is required.", "data": item},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if not item.get("id", None) or not validate_uuid(item.get("id"), version=4):
|
||||
item["id"] = str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
cls.validate(item)
|
||||
except ValueError as e:
|
||||
return web.json_response(
|
||||
{"error": f"Failed to validate condition '{item.get('name')}'. '{e!s}'"},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
items.append(init_class(Condition, item))
|
||||
|
||||
try:
|
||||
items = cls.save(items=items).load().get_all()
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(
|
||||
{"error": "Failed to save conditions.", "message": str(e)},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
await notify.emit(Events.CONDITIONS_UPDATE, data=items)
|
||||
return web.json_response(data=items, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("POST", "api/conditions/test/", "conditions_test")
|
||||
async def conditions_test(request: Request, encoder: Encoder, cache: Cache, config: Config) -> Response:
|
||||
"""
|
||||
Test condition against URL.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
cache (Cache): The cache instance.
|
||||
config (Config): The configuration instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object
|
||||
|
||||
"""
|
||||
params = await request.json()
|
||||
|
||||
if not isinstance(params, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting dict."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
url: str | None = params.get("url")
|
||||
if not url:
|
||||
return web.json_response({"error": "url is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
cond: str | None = params.get("condition")
|
||||
if not cond:
|
||||
return web.json_response({"error": "condition is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
preset: str = params.get("preset", config.default_preset)
|
||||
key: str = cache.hash(url + str(preset))
|
||||
if not cache.has(key):
|
||||
opts = {}
|
||||
if ytdlp_proxy := config.get_ytdlp_args().get("proxy", None):
|
||||
opts["proxy"] = ytdlp_proxy
|
||||
ytdlp_opts = YTDLPOpts.get_instance().preset(name=preset).add(opts).get_all()
|
||||
|
||||
data = extract_info(
|
||||
config=ytdlp_opts,
|
||||
url=url,
|
||||
debug=False,
|
||||
no_archive=True,
|
||||
follow_redirect=True,
|
||||
sanitize_info=True,
|
||||
)
|
||||
if not data:
|
||||
return web.json_response(
|
||||
data={"error": f"Failed to extract info from '{url!s}'."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
cache.set(key=key, value=data, ttl=600)
|
||||
else:
|
||||
data = cache.get(key)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(
|
||||
data={"error": f"Failed to extract video info. '{e!s}'"},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
from yt_dlp.utils import match_str
|
||||
|
||||
status = match_str(cond, data)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(
|
||||
data={"error": str(e)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
data={"status": status, "condition": cond, "data": data},
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
180
app/routes/dev.py
Normal file
180
app/routes/dev.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library import DownloadQueue
|
||||
from app.library.config import Config
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.router import route
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route("GET", "api/dev/loop/", "debug_loop")
|
||||
async def debug_asyncio(config: Config, encoder: Encoder) -> Response:
|
||||
if not config.is_dev():
|
||||
return web.json_response(
|
||||
data={"error": "This endpoint is only available in development mode."},
|
||||
status=web.HTTPForbidden.status_code,
|
||||
)
|
||||
|
||||
import traceback
|
||||
|
||||
tasks = []
|
||||
for task in asyncio.all_tasks():
|
||||
task_info = {"task": str(task), "stack": []}
|
||||
for frame in task.get_stack():
|
||||
formatted = traceback.format_stack(f=frame)
|
||||
task_info["stack"].extend(formatted)
|
||||
|
||||
tasks.append(task_info)
|
||||
|
||||
return web.json_response(
|
||||
data={
|
||||
"total_tasks": len(tasks),
|
||||
"loop": str(asyncio.get_event_loop()),
|
||||
"tasks": tasks,
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("GET", "api/dev/workers/", "pool_list")
|
||||
async def pool_list(config: Config, queue: DownloadQueue) -> Response:
|
||||
"""
|
||||
Get the workers status.
|
||||
|
||||
Args:
|
||||
config (Config): The configuration object.
|
||||
queue (DownloadQueue): The download queue object.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
if not config.is_dev():
|
||||
return web.json_response(
|
||||
{"error": "This endpoint is only available in development mode."},
|
||||
status=web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
if queue.pool is None:
|
||||
return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
status = queue.pool.get_workers_status()
|
||||
|
||||
data = []
|
||||
|
||||
for worker in status:
|
||||
worker_status = status.get(worker)
|
||||
data.append(
|
||||
{
|
||||
"id": worker,
|
||||
"data": {"status": "Waiting for download."} if worker_status is None else worker_status,
|
||||
}
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
data={
|
||||
"open": queue.pool.has_open_workers(),
|
||||
"count": queue.pool.get_available_workers(),
|
||||
"workers": data,
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=lambda obj: json.dumps(obj, default=lambda o: f"<<non-serializable: {type(o).__qualname__}>>"),
|
||||
)
|
||||
|
||||
|
||||
@route("POST", "api/dev/workers/", "pool_start")
|
||||
async def pool_restart(config: Config, queue: DownloadQueue) -> Response:
|
||||
"""
|
||||
Restart the workers pool.
|
||||
|
||||
Args:
|
||||
config (Config): The configuration object.
|
||||
queue (DownloadQueue): The download queue object.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
if not config.is_dev():
|
||||
return web.json_response(
|
||||
{"error": "This endpoint is only available in development mode."},
|
||||
status=web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
if queue.pool is None:
|
||||
return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
queue.pool.start()
|
||||
|
||||
return web.json_response({"message": "Workers pool being restarted."}, status=web.HTTPOk.status_code)
|
||||
|
||||
|
||||
@route("PATCH", "api/dev/workers/{id}", "worker_restart")
|
||||
async def worker_restart(request: Request, config: Config, queue: DownloadQueue) -> Response:
|
||||
"""
|
||||
Restart a worker.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration object.
|
||||
queue (DownloadQueue): The download queue object.
|
||||
|
||||
Returns:
|
||||
Response: The response object
|
||||
|
||||
"""
|
||||
if not config.is_dev():
|
||||
return web.json_response(
|
||||
{"error": "This endpoint is only available in development mode."},
|
||||
status=web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
worker_id: str = request.match_info.get("id")
|
||||
if not worker_id:
|
||||
return web.json_response({"error": "worker id is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if queue.pool is None:
|
||||
return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
status = await queue.pool.restart(worker_id, "requested by user.")
|
||||
|
||||
return web.json_response({"status": "restarted" if status else "in_error_state"}, status=web.HTTPOk.status_code)
|
||||
|
||||
|
||||
@route("DELETE", "api/dev/workers/{id}", "worker_stop")
|
||||
async def worker_stop(request: Request, config: Config, queue: DownloadQueue) -> Response:
|
||||
"""
|
||||
Stop a worker.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration object.
|
||||
queue (DownloadQueue): The download queue object.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
if not config.is_dev():
|
||||
return web.json_response(
|
||||
{"error": "This endpoint is only available in development mode."},
|
||||
status=web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
worker_id: str = request.match_info.get("id")
|
||||
if not worker_id:
|
||||
raise web.HTTPBadRequest(text="worker id is required.")
|
||||
|
||||
if queue.pool is None:
|
||||
return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
status = await queue.pool.stop(worker_id, "requested by user.")
|
||||
|
||||
return web.json_response({"status": "stopped" if status else "in_error_state"}, status=web.HTTPOk.status_code)
|
||||
201
app/routes/history.py
Normal file
201
app/routes/history.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.DownloadQueue import DownloadQueue
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.ItemDTO import Item
|
||||
from app.library.Presets import Preset, Presets
|
||||
from app.library.router import route
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from library.Download import Download
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route("GET", r"api/history/", "items_list")
|
||||
async def items_list(queue: DownloadQueue, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Get the history.
|
||||
|
||||
Args:
|
||||
queue (DownloadQueue): The download queue instance.
|
||||
encoder (Encoder): The encoder instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
data: dict = {"queue": [], "history": []}
|
||||
q = queue.get()
|
||||
|
||||
data["queue"].extend([q.get("queue", {}).get(k) for k in q.get("queue", {})])
|
||||
data["history"].extend([q.get("done", {}).get(k) for k in q.get("done", {})])
|
||||
|
||||
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("DELETE", "api/history/", "item_delete")
|
||||
async def item_delete(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Delete an item from the queue.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
queue (DownloadQueue): The download queue instance.
|
||||
encoder (Encoder): The encoder instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
data = await request.json()
|
||||
ids = data.get("ids")
|
||||
where = data.get("where")
|
||||
if not ids or where not in ["queue", "done"]:
|
||||
return web.json_response(data={"error": "ids and where are required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
remove_file: bool = bool(data.get("remove_file", True))
|
||||
|
||||
return web.json_response(
|
||||
data=await (queue.cancel(ids) if where == "queue" else queue.clear(ids, remove_file=remove_file)),
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("POST", "api/history/{id}", "item_update")
|
||||
async def item_update(request: Request, queue: DownloadQueue, encoder: Encoder, notify: EventBus) -> Response:
|
||||
"""
|
||||
Update an item in the history.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
queue (DownloadQueue): The download queue instance.
|
||||
encoder (Encoder): The encoder instance.
|
||||
notify (EventBus): The event bus instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
id: str = request.match_info.get("id")
|
||||
if not id:
|
||||
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
item: Download | None = queue.done.get_by_id(id)
|
||||
if not item:
|
||||
return web.json_response(data={"error": "item not found."}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
post = await request.json()
|
||||
if not post:
|
||||
return web.json_response(data={"error": "no data provided."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
updated = False
|
||||
|
||||
for k, v in post.items():
|
||||
if not hasattr(item.info, k):
|
||||
continue
|
||||
|
||||
if getattr(item.info, k) == v:
|
||||
continue
|
||||
|
||||
updated = True
|
||||
setattr(item.info, k, v)
|
||||
LOG.debug(f"Updated '{k}' to '{v}' for '{item.info.id}'")
|
||||
|
||||
if updated:
|
||||
queue.done.put(item)
|
||||
await notify.emit(Events.UPDATE, data=item.info)
|
||||
|
||||
return web.json_response(
|
||||
data=item.info,
|
||||
status=web.HTTPOk.status_code if updated else web.HTTPNotModified.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("GET", "api/history/add/", "item_add")
|
||||
async def item_add(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Add a URL to the download queue.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
queue (DownloadQueue): The download queue instance.
|
||||
encoder (Encoder): The encoder instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object
|
||||
|
||||
"""
|
||||
url: str | None = request.query.get("url")
|
||||
if not url:
|
||||
return web.json_response(data={"error": "url param is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
data: dict[str, str] = {"url": url}
|
||||
|
||||
preset: str | None = request.query.get("preset")
|
||||
if preset:
|
||||
exists: Preset | None = Presets.get_instance().get(preset)
|
||||
if not exists:
|
||||
return web.json_response(
|
||||
data={"status": False, "message": f"Preset '{preset}' does not exist."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
data["preset"] = preset
|
||||
|
||||
try:
|
||||
status: dict[str, str] = await queue.add(item=Item.format(data))
|
||||
except ValueError as e:
|
||||
return web.json_response(data={"status": False, "message": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
return web.json_response(
|
||||
data={"status": status.get("status") == "ok", "message": status.get("msg", "URL added")},
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("POST", "api/history/", "items_add")
|
||||
async def items_add(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Add a URL to the download queue.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
queue (DownloadQueue): The download queue instance.
|
||||
encoder (Encoder): The encoder instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
data = await request.json()
|
||||
|
||||
if isinstance(data, dict):
|
||||
data = [data]
|
||||
|
||||
items = []
|
||||
for item in data:
|
||||
try:
|
||||
items.append(Item.format(item))
|
||||
except ValueError as e:
|
||||
return web.json_response(data={"error": str(e), "data": item}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
status: list = await asyncio.wait_for(
|
||||
fut=asyncio.gather(*[queue.add(item=item) for item in items]),
|
||||
timeout=None,
|
||||
)
|
||||
|
||||
response: list = []
|
||||
|
||||
for i, item in enumerate(items):
|
||||
response.append({"item": item, "status": "ok" == status[i].get("status"), "msg": status[i].get("msg")})
|
||||
|
||||
return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
186
app/routes/images.py
Normal file
186
app/routes/images.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import logging
|
||||
import random
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.ag_utils import ag
|
||||
from app.library.cache import Cache
|
||||
from app.library.config import Config
|
||||
from app.library.router import route
|
||||
from app.library.Utils import validate_url
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
IS_REQUESTING_BACKGROUND: bool = False
|
||||
|
||||
|
||||
@route("GET", "api/thumbnail/", "get_thumbnail")
|
||||
async def get_thumbnail(request: Request, config: Config) -> Response:
|
||||
"""
|
||||
Get the thumbnail.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration object.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
url = request.query.get("url")
|
||||
if not url:
|
||||
return web.json_response(data={"error": "URL is required."}, status=web.HTTPForbidden.status_code)
|
||||
|
||||
try:
|
||||
validate_url(url)
|
||||
except ValueError as e:
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPForbidden.status_code)
|
||||
|
||||
try:
|
||||
ytdlp_args = config.get_ytdlp_args()
|
||||
opts = {
|
||||
"proxy": ytdlp_args.get("proxy", None),
|
||||
"headers": {
|
||||
"User-Agent": ytdlp_args.get(
|
||||
"user_agent", request.headers.get("User-Agent", f"YTPTube/{config.version}")
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(**opts) as client:
|
||||
LOG.debug(f"Fetching thumbnail from '{url}'.")
|
||||
response = await client.request(method="GET", url=url)
|
||||
return web.Response(
|
||||
body=response.content,
|
||||
headers={
|
||||
"Content-Type": response.headers.get("Content-Type"),
|
||||
"Pragma": "public",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Cache-Control": f"public, max-age={time.time() + 31536000}",
|
||||
"Expires": time.strftime(
|
||||
"%a, %d %b %Y %H:%M:%S GMT",
|
||||
datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple(),
|
||||
),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.error(f"Error fetching thumbnail from '{url}'. '{e}'.")
|
||||
return web.json_response(
|
||||
data={"error": "failed to retrieve the thumbnail."}, status=web.HTTPInternalServerError.status_code
|
||||
)
|
||||
|
||||
|
||||
@route("GET", "api/random/background/", "get_background")
|
||||
async def get_background(request: Request, config: Config, cache: Cache) -> Response:
|
||||
"""
|
||||
Get random background.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration object.
|
||||
cache (Cache): The cache object.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
global IS_REQUESTING_BACKGROUND # noqa: PLW0603
|
||||
|
||||
backend = None
|
||||
|
||||
if IS_REQUESTING_BACKGROUND:
|
||||
return web.Response(status=web.HTTPTooManyRequests.status_code)
|
||||
|
||||
try:
|
||||
IS_REQUESTING_BACKGROUND = True
|
||||
backend = random.choice(config.pictures_backends) # noqa: S311
|
||||
CACHE_KEY_BING = "random_background_bing"
|
||||
CACHE_KEY = "random_background"
|
||||
|
||||
if cache.has(CACHE_KEY) and not request.query.get("force", False):
|
||||
data = await cache.aget(CACHE_KEY)
|
||||
return web.Response(
|
||||
body=data.get("content"),
|
||||
headers={
|
||||
"X-Cache": "HIT",
|
||||
"X-Cache-TTL": str(await cache.attl(CACHE_KEY)),
|
||||
"X-Image-Via": data.get("backend"),
|
||||
**data.get("headers"),
|
||||
},
|
||||
)
|
||||
|
||||
ytdlp_args = config.get_ytdlp_args()
|
||||
opts = {
|
||||
"proxy": ytdlp_args.get("proxy", None),
|
||||
"headers": {
|
||||
"User-Agent": ytdlp_args.get("user_agent", f"YTPTube/{config.version}"),
|
||||
},
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(**opts) as client:
|
||||
if backend.startswith("https://www.bing.com/HPImageArchive.aspx"):
|
||||
if not cache.has(CACHE_KEY_BING):
|
||||
response = await client.request(method="GET", url=backend)
|
||||
if response.status_code != web.HTTPOk.status_code:
|
||||
return web.json_response(
|
||||
data={"error": "failed to retrieve the random background image."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
img_url: str | None = ag(response.json(), "images.0.url")
|
||||
if not img_url:
|
||||
return web.json_response(
|
||||
data={"error": "failed to retrieve the random background image."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
backend = f"https://www.bing.com{img_url}"
|
||||
await cache.aset(key=CACHE_KEY_BING, value=backend, ttl=3600 * 24)
|
||||
else:
|
||||
backend: str = await cache.aget(CACHE_KEY_BING)
|
||||
|
||||
LOG.debug(f"Requesting random picture from '{backend!s}'.")
|
||||
|
||||
response = await client.request(method="GET", url=backend, follow_redirects=True)
|
||||
|
||||
if response.status_code != web.HTTPOk.status_code:
|
||||
return web.json_response(
|
||||
data={"error": "failed to retrieve the random background image."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
data = {
|
||||
"content": response.content,
|
||||
"backend": urlparse(backend).netloc,
|
||||
"headers": {
|
||||
"Content-Type": response.headers.get("Content-Type", "image/jpeg"),
|
||||
"Content-Length": str(len(response.content)),
|
||||
},
|
||||
}
|
||||
|
||||
await cache.aset(key=CACHE_KEY, value=data, ttl=3600)
|
||||
|
||||
LOG.debug(f"Random background image from '{backend!s}' cached.")
|
||||
|
||||
return web.Response(
|
||||
body=data.get("content"),
|
||||
headers={
|
||||
"X-Cache": "MISS",
|
||||
"X-Cache-TTL": "3600",
|
||||
"X-Image-Via": data.get("backend"),
|
||||
**data.get("headers"),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to request random background image from '{backend!s}'.'. '{e!s}'.")
|
||||
return web.json_response(
|
||||
data={"error": "failed to retrieve the random background image."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
finally:
|
||||
IS_REQUESTING_BACKGROUND = False
|
||||
50
app/routes/logs.py
Normal file
50
app/routes/logs.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.router import route
|
||||
from app.library.Utils import read_logfile
|
||||
|
||||
|
||||
@route("GET", "api/logs/", "logs")
|
||||
async def logs(request: Request, config: Config, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Get recent logs
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration instance.
|
||||
encoder (Encoder): The encoder instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
if not config.file_logging:
|
||||
return web.json_response(data={"error": "File logging is not enabled."}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
offset = int(request.query.get("offset", 0))
|
||||
limit = int(request.query.get("limit", 100))
|
||||
if limit < 1 or limit > 150:
|
||||
limit = 50
|
||||
|
||||
logs_data = await read_logfile(
|
||||
file=Path(config.config_path) / "logs" / "app.log",
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
data={
|
||||
"logs": logs_data["logs"],
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"next_offset": logs_data["next_offset"],
|
||||
"end_is_reached": logs_data["end_is_reached"],
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
111
app/routes/notifications.py
Normal file
111
app/routes/notifications.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import logging
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events, message
|
||||
from app.library.Notifications import Notification, NotificationEvents
|
||||
from app.library.router import route
|
||||
from app.library.Utils import validate_uuid
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route("GET", "api/notifications/", "notifications_list")
|
||||
async def notifications_list(encoder: Encoder) -> Response:
|
||||
"""
|
||||
Get the notification targets.
|
||||
|
||||
Args:
|
||||
encoder (Encoder): The encoder instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
return web.json_response(
|
||||
data={
|
||||
"notifications": Notification.get_instance().get_targets(),
|
||||
"allowedTypes": list(NotificationEvents.get_events().values()),
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("PUT", "api/notifications/", "notification_add")
|
||||
async def notification_add(request: Request, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Add notification targets.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
notify (EventBus): The event bus instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
post = await request.json()
|
||||
if not isinstance(post, list):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting list with dicts."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
targets: list = []
|
||||
|
||||
ins: Notification = Notification.get_instance()
|
||||
for item in post:
|
||||
if not isinstance(item, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting list with dicts."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if not item.get("id", None) or validate_uuid(item.get("id"), version=4):
|
||||
item["id"] = str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
Notification.validate(item)
|
||||
except ValueError as e:
|
||||
return web.json_response(
|
||||
{"error": f"Invalid notification target settings. {e!s}", "data": item},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
targets.append(ins.make_target(item))
|
||||
|
||||
try:
|
||||
ins.save(targets=targets)
|
||||
ins.load()
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response({"error": "Failed to save tasks."}, status=web.HTTPInternalServerError.status_code)
|
||||
|
||||
data = {"notifications": targets, "allowedTypes": list(NotificationEvents.get_events().values())}
|
||||
|
||||
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("POST", "api/notifications/test/", "notification_test")
|
||||
async def notification_test(encoder: Encoder, notify: EventBus) -> Response:
|
||||
"""
|
||||
Test the notification.
|
||||
|
||||
Args:
|
||||
encoder (Encoder): The encoder instance.
|
||||
notify (EventBus): The event bus instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
data = message("test", "This is a test notification.")
|
||||
|
||||
await notify.emit(Events.TEST, data=data)
|
||||
|
||||
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
19
app/routes/ping.py
Normal file
19
app/routes/ping.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from aiohttp import web
|
||||
from aiohttp.web import Response
|
||||
|
||||
from app.library.DownloadQueue import DownloadQueue
|
||||
from app.library.router import route
|
||||
|
||||
|
||||
@route("GET", "api/ping/", "ping")
|
||||
async def ping(queue: DownloadQueue) -> Response:
|
||||
"""
|
||||
Ping the server.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
await queue.queue.test()
|
||||
|
||||
return web.json_response(data={"status": "pong"}, status=web.HTTPOk.status_code)
|
||||
278
app/routes/player.py
Normal file
278
app/routes/player.py
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.M3u8 import M3u8
|
||||
from app.library.Playlist import Playlist
|
||||
from app.library.router import route
|
||||
from app.library.Segments import Segments
|
||||
from app.library.Subtitle import Subtitle
|
||||
from app.library.Utils import StreamingError, get_file
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route("GET", "api/player/playlist/{file:.*}.m3u8", "playlist_create")
|
||||
async def playlist_create(request: Request, config: Config, app: web.Application) -> Response:
|
||||
"""
|
||||
Get the playlist.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration instance.
|
||||
app (web.Application): The aiohttp application instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
file: str = request.match_info.get("file")
|
||||
|
||||
if not file:
|
||||
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
base_path: str = config.base_path.rstrip("/")
|
||||
|
||||
try:
|
||||
realFile, status = get_file(download_path=config.download_path, file=file)
|
||||
if web.HTTPFound.status_code == status:
|
||||
return Response(
|
||||
status=web.HTTPFound.status_code,
|
||||
headers={
|
||||
"Location": str(
|
||||
app.router["playlist_create"].url_for(
|
||||
file=str(realFile).replace(config.download_path, "").strip("/")
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if web.HTTPNotFound.status_code == status:
|
||||
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||
|
||||
return web.Response(
|
||||
text=await Playlist(download_path=Path(config.download_path), url=f"{base_path}/").make(file=realFile),
|
||||
headers={
|
||||
"Content-Type": "application/x-mpegURL",
|
||||
"Cache-Control": "no-cache",
|
||||
"Access-Control-Max-Age": "300",
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
)
|
||||
except StreamingError as e:
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
|
||||
@route("GET", "api/player/m3u8/{mode}/{file:.*}.m3u8", "m3u8_create")
|
||||
async def m3u8_create(request: Request, config: Config, app: web.Application) -> Response:
|
||||
"""
|
||||
Get the m3u8 file.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration instance.
|
||||
app (web.Application): The aiohttp application instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
file: str = request.match_info.get("file")
|
||||
mode: str = request.match_info.get("mode")
|
||||
|
||||
if mode not in ["video", "subtitle"]:
|
||||
return web.json_response(
|
||||
data={"error": "Only video and subtitle modes are supported."}, status=web.HTTPBadRequest.status_code
|
||||
)
|
||||
|
||||
if not file:
|
||||
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
duration = request.query.get("duration", None)
|
||||
|
||||
if "subtitle" in mode:
|
||||
if not duration:
|
||||
return web.json_response(data={"error": "duration is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
duration = float(duration)
|
||||
|
||||
base_path: str = config.base_path.rstrip("/")
|
||||
|
||||
try:
|
||||
cls = M3u8(download_path=Path(config.download_path), url=f"{base_path}/")
|
||||
|
||||
realFile, status = get_file(download_path=config.download_path, file=file)
|
||||
if web.HTTPFound.status_code == status:
|
||||
return Response(
|
||||
status=status,
|
||||
headers={
|
||||
"Location": str(
|
||||
app.router["m3u8_create"].url_for(
|
||||
mode=mode, file=str(realFile).replace(config.download_path, "").strip("/")
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if web.HTTPNotFound.status_code == status:
|
||||
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||
|
||||
if "subtitle" in mode:
|
||||
text = await cls.make_subtitle(file=realFile, duration=duration)
|
||||
else:
|
||||
text = await cls.make_stream(file=realFile)
|
||||
except StreamingError as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
return web.Response(
|
||||
text=text,
|
||||
headers={
|
||||
"Content-Type": "application/x-mpegURL",
|
||||
"Cache-Control": "no-cache",
|
||||
"Access-Control-Max-Age": "300",
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
)
|
||||
|
||||
|
||||
@route("GET", r"api/player/segments/{segment:\d+}/{file:.*}.ts", "segments_stream")
|
||||
async def segments_stream(request: Request, config: Config, app: web.Application) -> Response:
|
||||
"""
|
||||
Get the segments.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration instance.
|
||||
app (web.Application): The aiohttp application instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
file: str = request.match_info.get("file")
|
||||
segment: int = request.match_info.get("segment")
|
||||
sd: int = request.query.get("sd")
|
||||
vc: int = int(request.query.get("vc", 0))
|
||||
ac: int = int(request.query.get("ac", 0))
|
||||
|
||||
if not file:
|
||||
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not segment:
|
||||
return web.json_response(data={"error": "segment id is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
realFile, status = get_file(download_path=config.download_path, file=file)
|
||||
if web.HTTPFound.status_code == status:
|
||||
return Response(
|
||||
status=status,
|
||||
headers={
|
||||
"Location": str(
|
||||
app.router["segments"].url_for(
|
||||
segment=segment,
|
||||
file=str(realFile).replace(config.download_path, "").strip("/"),
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if web.HTTPNotFound.status_code == status:
|
||||
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||
|
||||
mtime = realFile.stat().st_mtime
|
||||
|
||||
if request.if_modified_since and request.if_modified_since.timestamp() == mtime:
|
||||
lastMod = time.strftime("%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple())
|
||||
return web.Response(status=web.HTTPNotModified.status_code, headers={"Last-Modified": lastMod})
|
||||
|
||||
resp = web.StreamResponse(
|
||||
status=web.HTTPOk.status_code,
|
||||
headers={
|
||||
"Content-Type": "video/mpegts",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Pragma": "public",
|
||||
"Cache-Control": f"public, max-age={time.time() + 31536000}",
|
||||
"Last-Modified": time.strftime(
|
||||
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple()
|
||||
),
|
||||
"Expires": time.strftime(
|
||||
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple()
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
await resp.prepare(request)
|
||||
|
||||
await Segments(
|
||||
download_path=config.download_path,
|
||||
index=int(segment),
|
||||
duration=float(f"{float(sd if sd else M3u8.duration):.6f}"),
|
||||
vconvert=vc == 1,
|
||||
aconvert=ac == 1,
|
||||
).stream(realFile, resp)
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
@route("GET", "api/player/subtitle/{file:.*}.vtt", "subtitles_get")
|
||||
async def subtitles_get(request: Request, config: Config, app: web.Application) -> Response:
|
||||
"""
|
||||
Get the subtitles.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration instance.
|
||||
app (web.Application): The aiohttp application instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
file: str = request.match_info.get("file")
|
||||
|
||||
if not file:
|
||||
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
realFile, status = get_file(download_path=config.download_path, file=file)
|
||||
if web.HTTPFound.status_code == status:
|
||||
return Response(
|
||||
status=status,
|
||||
headers={
|
||||
"Location": str(
|
||||
app.router["subtitles_get"].url_for(file=str(realFile).replace(config.download_path, "").strip("/"))
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if web.HTTPNotFound.status_code == status:
|
||||
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||
|
||||
mtime = realFile.stat().st_mtime
|
||||
|
||||
if request.if_modified_since and request.if_modified_since.timestamp() == mtime:
|
||||
lastMod = time.strftime("%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple())
|
||||
return web.Response(status=web.HTTPNotModified.status_code, headers={"Last-Modified": lastMod})
|
||||
|
||||
return web.Response(
|
||||
body=await Subtitle().make(file=realFile),
|
||||
headers={
|
||||
"Content-Type": "text/vtt; charset=UTF-8",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Pragma": "public",
|
||||
"Cache-Control": f"public, max-age={time.time() + 31536000}",
|
||||
"Last-Modified": time.strftime(
|
||||
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple()
|
||||
),
|
||||
"Expires": time.strftime(
|
||||
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple()
|
||||
),
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
)
|
||||
100
app/routes/presets.py
Normal file
100
app/routes/presets.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import logging
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.Presets import Preset, Presets
|
||||
from app.library.router import route
|
||||
from app.library.Utils import init_class, validate_uuid
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route("GET", "api/presets/", "presets")
|
||||
async def presets(request: Request, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Get the presets.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
data: list[Preset] = Presets.get_instance().get_all()
|
||||
filter_fields: str | None = request.query.get("filter", None)
|
||||
|
||||
if filter_fields:
|
||||
fields: list[str] = [field.strip() for field in filter_fields.split(",")]
|
||||
data = [{key: value for key, value in preset.serialize().items() if key in fields} for preset in data]
|
||||
|
||||
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("PUT", "api/presets/", "presets_add")
|
||||
async def presets_add(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
"""
|
||||
Add presets.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
notify (EventBus): The event bus instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object
|
||||
|
||||
"""
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, list):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting list with dicts."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
presets: list = []
|
||||
|
||||
cls = Presets.get_instance()
|
||||
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting list with dicts."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if not item.get("name"):
|
||||
return web.json_response(
|
||||
{"error": "name is required.", "data": item}, status=web.HTTPBadRequest.status_code
|
||||
)
|
||||
|
||||
if not item.get("id", None) or not validate_uuid(item.get("id"), version=4):
|
||||
item["id"] = str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
cls.validate(item)
|
||||
except ValueError as e:
|
||||
return web.json_response(
|
||||
{"error": f"Failed to validate preset '{item.get('name')}'. '{e!s}'"},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
presets.append(init_class(Preset, item))
|
||||
|
||||
try:
|
||||
presets = cls.save(items=presets).load().get_all()
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(
|
||||
{"error": "Failed to save presets.", "message": str(e)},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
await notify.emit(Events.PRESETS_UPDATE, data=presets)
|
||||
return web.json_response(data=presets, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
93
app/routes/tasks.py
Normal file
93
app/routes/tasks.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import logging
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.router import route
|
||||
from app.library.Tasks import Task, Tasks
|
||||
from app.library.Utils import init_class, validate_uuid
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route("GET", "api/tasks/", "tasks")
|
||||
async def tasks(encoder: Encoder) -> Response:
|
||||
"""
|
||||
Get the tasks.
|
||||
|
||||
Args:
|
||||
encoder (Encoder): The encoder instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
return web.json_response(data=Tasks.get_instance().get_all(), status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("PUT", "api/tasks/", "tasks_add")
|
||||
async def tasks_add(request: Request, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Add tasks to the queue.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object
|
||||
|
||||
"""
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, list):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting list with dicts."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
tasks: list = []
|
||||
|
||||
ins = Tasks.get_instance()
|
||||
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting list with dicts."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if not item.get("url"):
|
||||
return web.json_response({"error": "url is required.", "data": item}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not item.get("id", None) or not validate_uuid(item.get("id"), version=4):
|
||||
item["id"] = str(uuid.uuid4())
|
||||
|
||||
if not item.get("template", None):
|
||||
item["template"] = ""
|
||||
|
||||
if not item.get("cli", None):
|
||||
item["cli"] = ""
|
||||
|
||||
try:
|
||||
Tasks.validate(item)
|
||||
except ValueError as e:
|
||||
return web.json_response(
|
||||
{"error": f"Failed to validate task '{item.get('name')}'. '{e!s}'"},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
tasks.append(init_class(Task, item))
|
||||
|
||||
try:
|
||||
tasks = ins.save(tasks=tasks).load().get_all()
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(
|
||||
{"error": "Failed to save tasks.", "message": str(e)},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
return web.json_response(data=tasks, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
258
app/routes/yt_dlp.py
Normal file
258
app/routes/yt_dlp.py
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.cache import Cache
|
||||
from app.library.config import Config
|
||||
from app.library.Presets import Preset, Presets
|
||||
from app.library.router import route
|
||||
from app.library.Utils import REMOVE_KEYS, arg_converter, extract_info, validate_url
|
||||
from app.library.YTDLPOpts import YTDLPOpts
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route("POST", "api/yt-dlp/convert/", "convert")
|
||||
async def convert(request: Request) -> Response:
|
||||
"""
|
||||
Convert the yt-dlp args to a dict.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
post = await request.json()
|
||||
args: str | None = post.get("args")
|
||||
|
||||
if not args:
|
||||
return web.json_response(data={"error": "args param is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
response = {"opts": {}, "output_template": None, "download_path": None}
|
||||
|
||||
data = arg_converter(args, dumps=True)
|
||||
|
||||
if "outtmpl" in data and "default" in data["outtmpl"]:
|
||||
response["output_template"] = data["outtmpl"]["default"]
|
||||
|
||||
if "paths" in data and "home" in data["paths"]:
|
||||
response["download_path"] = data["paths"]["home"]
|
||||
|
||||
if "format" in data:
|
||||
response["format"] = data["format"]
|
||||
|
||||
bad_options = {k: v for d in REMOVE_KEYS for k, v in d.items()}
|
||||
removed_options = []
|
||||
|
||||
for key in data:
|
||||
if key in bad_options.items():
|
||||
removed_options.append(bad_options[key])
|
||||
continue
|
||||
if not key.startswith("_"):
|
||||
response["opts"][key] = data[key]
|
||||
|
||||
if len(removed_options) > 0:
|
||||
response["removed_options"] = removed_options
|
||||
|
||||
return web.json_response(data=response, status=web.HTTPOk.status_code)
|
||||
except Exception as e:
|
||||
err = str(e).strip()
|
||||
err = err.split("\n")[-1] if "\n" in err else err
|
||||
err = err.replace("main.py: error: ", "").strip().capitalize()
|
||||
return web.json_response(
|
||||
data={"error": f"Failed to parse command options for yt-dlp. '{err}'."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
|
||||
@route("GET", "api/yt-dlp/url/info/", "get_info")
|
||||
async def get_info(request: Request, cache: Cache) -> Response:
|
||||
"""
|
||||
Get the video info.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
cache (Cache): The cache instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object
|
||||
|
||||
"""
|
||||
url: str | None = request.query.get("url")
|
||||
if not url:
|
||||
return web.json_response(data={"error": "URL is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
validate_url(url)
|
||||
except ValueError as e:
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
config = Config.get_instance()
|
||||
|
||||
preset = request.query.get("preset")
|
||||
if preset:
|
||||
exists: Preset | None = Presets.get_instance().get(preset)
|
||||
if not exists:
|
||||
return web.json_response(
|
||||
data={"status": False, "message": f"Preset '{preset}' does not exist."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
else:
|
||||
preset: str = config.default_preset
|
||||
|
||||
try:
|
||||
key: str = cache.hash(f"{preset}:{url}")
|
||||
|
||||
if cache.has(key) and not request.query.get("force", False):
|
||||
data: Any | None = cache.get(key)
|
||||
data["_cached"] = {
|
||||
"status": "hit",
|
||||
"key": key,
|
||||
"ttl": data.get("_cached", {}).get("ttl", 300),
|
||||
"ttl_left": data.get("_cached", {}).get("expires", time.time() + 300) - time.time(),
|
||||
"expires": data.get("_cached", {}).get("expires", time.time() + 300),
|
||||
}
|
||||
return web.Response(body=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code)
|
||||
|
||||
opts: dict = {}
|
||||
|
||||
if ytdlp_proxy := config.get_ytdlp_args().get("proxy", None):
|
||||
opts["proxy"] = ytdlp_proxy
|
||||
|
||||
ytdlp_opts = YTDLPOpts.get_instance().preset(name=preset).add(opts).get_all()
|
||||
|
||||
data = extract_info(
|
||||
config=ytdlp_opts,
|
||||
url=url,
|
||||
debug=False,
|
||||
no_archive=True,
|
||||
follow_redirect=True,
|
||||
sanitize_info=True,
|
||||
)
|
||||
|
||||
if "formats" in data:
|
||||
from yt_dlp.cookies import LenientSimpleCookie
|
||||
|
||||
for index, item in enumerate(data["formats"]):
|
||||
if "cookies" in item and len(item["cookies"]) > 0:
|
||||
cookies: list[str] = [f"{c.key}={c.value}" for c in LenientSimpleCookie(item["cookies"]).values()]
|
||||
if len(cookies) > 0:
|
||||
data["formats"][index]["h_cookies"] = "; ".join(cookies)
|
||||
data["formats"][index]["h_cookies"] = data["formats"][index]["h_cookies"].strip()
|
||||
|
||||
data["_cached"] = {
|
||||
"status": "miss",
|
||||
"key": key,
|
||||
"ttl": 300,
|
||||
"ttl_left": 300,
|
||||
"expires": time.time() + 300,
|
||||
}
|
||||
|
||||
data = OrderedDict(sorted(data.items(), key=lambda item: len(str(item[1]))))
|
||||
|
||||
cache.set(key=key, value=data, ttl=300)
|
||||
|
||||
return web.Response(body=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Error encountered while getting video info for '{url}'. '{e!s}'.")
|
||||
return web.json_response(
|
||||
data={
|
||||
"error": "failed to get video info.",
|
||||
"message": str(e),
|
||||
"formats": [],
|
||||
},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
|
||||
@route("GET", "api/yt-dlp/archive/recheck/", "archive_recheck")
|
||||
async def archive_recheck(cache: Cache) -> Response:
|
||||
"""
|
||||
Recheck the manual archive entries.
|
||||
|
||||
Args:
|
||||
cache (Cache): The cache instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object
|
||||
|
||||
"""
|
||||
config: Config = Config.get_instance()
|
||||
if not config.manual_archive:
|
||||
return web.json_response(data={"error": "Manual archive is not enabled."}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
manual_archive = Path(config.manual_archive)
|
||||
if not manual_archive.exists():
|
||||
return web.json_response(
|
||||
data={"error": "Manual archive file not found.", "file": manual_archive},
|
||||
status=web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
tasks: list = []
|
||||
response: list = []
|
||||
|
||||
def info_wrapper(id: str, url: str) -> tuple[str, dict]:
|
||||
try:
|
||||
return (
|
||||
id,
|
||||
extract_info(
|
||||
config={
|
||||
"proxy": config.get_ytdlp_args().get("proxy", None),
|
||||
"simulate": True,
|
||||
"dump_single_json": True,
|
||||
},
|
||||
url=url,
|
||||
no_archive=True,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
return (id, {"error": str(e)})
|
||||
|
||||
async with await anyio.open_file(manual_archive) as f:
|
||||
# line format is "youtube ID - at: ISO8601"
|
||||
async for line in f:
|
||||
line = line.strip()
|
||||
|
||||
if not line or not line.startswith("youtube"):
|
||||
continue
|
||||
|
||||
id = line.split(" ")[1].strip()
|
||||
|
||||
if not id:
|
||||
continue
|
||||
|
||||
url = f"https://www.youtube.com/watch?v={id}"
|
||||
key = cache.hash(id)
|
||||
|
||||
if cache.has(key):
|
||||
data = cache.get(key)
|
||||
response.append({id: bool(data.get("id", None)) if isinstance(data, dict) else False})
|
||||
continue
|
||||
|
||||
tasks.append(
|
||||
asyncio.get_event_loop().run_in_executor(None, lambda i=id, url=url: info_wrapper(id=i, url=url))
|
||||
)
|
||||
|
||||
if len(tasks) > 0:
|
||||
results = await asyncio.gather(*tasks)
|
||||
for data in results:
|
||||
if not data:
|
||||
continue
|
||||
|
||||
id, info = data
|
||||
cache.set(key=cache.hash(id), value=info, ttl=3600 * 6)
|
||||
response.append({id: bool(data.get("id", None)) if isinstance(data, dict) else False})
|
||||
|
||||
return web.json_response(data=response, status=web.HTTPOk.status_code)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
70
ui/components/Dropdown.vue
Normal file
70
ui/components/Dropdown.vue
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<template>
|
||||
<div class="dropdown" :class="{ 'is-active': isOpen }" 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">
|
||||
<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">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: 'Select'
|
||||
},
|
||||
icons: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const isOpen = ref(false)
|
||||
const dropdown = ref(null)
|
||||
|
||||
const toggle = () => isOpen.value = !isOpen.value
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdown.value && !dropdown.value.contains(event.target)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', handleClickOutside))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', handleClickOutside))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dropdown {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dropdown-trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
<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" />
|
||||
<code class="p-4 is-block" style="overflow: scroll;" 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>
|
||||
|
|
|
|||
|
|
@ -705,4 +705,18 @@ const downloadSelected = () => {
|
|||
}
|
||||
|
||||
const toggle_class = e => e.currentTarget.classList.toggle('is-text-overflow')
|
||||
|
||||
const trigger_option = (event, item) => {
|
||||
if (!event?.target?.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const eventName = event.target.value
|
||||
event.target.value = ''
|
||||
|
||||
if ('get_info' === eventName) {
|
||||
emitter('getInfo', item.url)
|
||||
return
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ catch (e) {
|
|||
export default defineNuxtConfig({
|
||||
ssr: false,
|
||||
devtools: { enabled: false },
|
||||
|
||||
devServer: {
|
||||
port: 8082,
|
||||
host: "0.0.0.0",
|
||||
|
|
@ -30,7 +29,7 @@ export default defineNuxtConfig({
|
|||
],
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
APP_ENV: process.env.APP_ENV || "production",
|
||||
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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@
|
|||
<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"
|
||||
<a :href="uri(`/browser/${item.path}`)" v-if="'dir' === item.content_type"
|
||||
@click.prevent="handleClick(item)">
|
||||
{{ item.name }}
|
||||
</a>
|
||||
|
|
@ -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')">
|
||||
|
|
@ -401,6 +401,9 @@ watch(model_item, v => {
|
|||
})
|
||||
|
||||
const setIcon = item => {
|
||||
if ('link' === item.type) {
|
||||
return 'fa-link'
|
||||
}
|
||||
if ('dir' === item.content_type) {
|
||||
return 'fa-folder'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export const useSocketStore = defineStore('socket', () => {
|
|||
|
||||
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`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue