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 = None
|
||||||
"""Instance of the DownloadQueue."""
|
"""Instance of the DownloadQueue."""
|
||||||
|
|
||||||
|
queue: DataStore
|
||||||
|
"""DataStore for the download queue."""
|
||||||
|
|
||||||
|
done: DataStore
|
||||||
|
"""DataStore for the completed downloads."""
|
||||||
|
|
||||||
def __init__(self, connection: Connection, config: Config | None = None):
|
def __init__(self, connection: Connection, config: Config | None = None):
|
||||||
DownloadQueue._instance = self
|
DownloadQueue._instance = self
|
||||||
|
|
||||||
|
|
@ -566,7 +572,7 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
removed_files = 0
|
removed_files = 0
|
||||||
filename: str = ""
|
filename: str = ""
|
||||||
|
|
||||||
LOG.info(
|
LOG.debug(
|
||||||
f"{remove_file=} {itemRef} - Removing local files: {self.config.remove_files}, {item.info.status=}"
|
f"{remove_file=} {itemRef} - Removing local files: {self.config.remove_files}, {item.info.status=}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -12,7 +12,6 @@ import anyio
|
||||||
import socketio
|
import socketio
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from .common import Common
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .DownloadQueue import DownloadQueue
|
from .DownloadQueue import DownloadQueue
|
||||||
from .encoder import Encoder
|
from .encoder import Encoder
|
||||||
|
|
@ -24,7 +23,7 @@ from .Utils import is_downloaded, tail_log
|
||||||
LOG = logging.getLogger("socket_api")
|
LOG = logging.getLogger("socket_api")
|
||||||
|
|
||||||
|
|
||||||
class HttpSocket(Common):
|
class HttpSocket:
|
||||||
"""
|
"""
|
||||||
This class is used to handle WebSocket events.
|
This class is used to handle WebSocket events.
|
||||||
"""
|
"""
|
||||||
|
|
@ -68,8 +67,6 @@ class HttpSocket(Common):
|
||||||
|
|
||||||
self._notify.subscribe("frontend", emit, f"{__class__.__name__}.emit")
|
self._notify.subscribe("frontend", emit, f"{__class__.__name__}.emit")
|
||||||
|
|
||||||
super().__init__(queue=queue, encoder=encoder, config=config)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def ws_event(func): # type: ignore
|
def ws_event(func): # type: ignore
|
||||||
"""
|
"""
|
||||||
|
|
@ -102,7 +99,7 @@ class HttpSocket(Common):
|
||||||
|
|
||||||
self._notify.subscribe(
|
self._notify.subscribe(
|
||||||
Events.ADD_URL,
|
Events.ADD_URL,
|
||||||
lambda data, _, **kwargs: self.add(item=Item.format(data.data)), # noqa: ARG005
|
lambda data, _, **kwargs: self.queue.add(item=Item.format(data.data)), # noqa: ARG005
|
||||||
f"{__class__.__name__}.add",
|
f"{__class__.__name__}.add",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -237,7 +234,7 @@ class HttpSocket(Common):
|
||||||
try:
|
try:
|
||||||
await self._notify.emit(
|
await self._notify.emit(
|
||||||
event=Events.STATUS,
|
event=Events.STATUS,
|
||||||
data=await self.add(item=Item.format(data)),
|
data=await self.queue.add(item=Item.format(data)),
|
||||||
to=sid,
|
to=sid,
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|
|
||||||
|
|
@ -175,13 +175,14 @@ class Presets(metaclass=Singleton):
|
||||||
Presets: The current instance.
|
Presets: The current instance.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
has: int = len(self._items)
|
||||||
self.clear()
|
self.clear()
|
||||||
|
|
||||||
if not self._file.exists() or self._file.stat().st_size < 10:
|
if not self._file.exists() or self._file.stat().st_size < 10:
|
||||||
return self
|
return self
|
||||||
|
|
||||||
try:
|
try:
|
||||||
LOG.info(f"Loading '{self._file}'.")
|
LOG.info(f"{'Reloading' if has else 'Loading'} '{self._file}'.")
|
||||||
presets: dict = json.loads(self._file.read_text())
|
presets: dict = json.loads(self._file.read_text())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.error(f"Failed to parse '{self._file}'. '{e}'.")
|
LOG.error(f"Failed to parse '{self._file}'. '{e}'.")
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ class Scheduler(metaclass=Singleton):
|
||||||
|
|
||||||
self._jobs[job_id] = job
|
self._jobs[job_id] = job
|
||||||
|
|
||||||
LOG.debug(f"Added job '{job_id}' to the schedule.")
|
LOG.debug(f"Added '{job_id}' to the scheduler.")
|
||||||
|
|
||||||
return job_id
|
return job_id
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ from .Scheduler import Scheduler
|
||||||
from .Singleton import Singleton
|
from .Singleton import Singleton
|
||||||
from .Utils import init_class
|
from .Utils import init_class
|
||||||
|
|
||||||
LOG = logging.getLogger("tasks")
|
LOG: logging.Logger = logging.getLogger("tasks")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(kw_only=True)
|
@dataclass(kw_only=True)
|
||||||
|
|
@ -124,13 +124,14 @@ class Tasks(metaclass=Singleton):
|
||||||
Tasks: The current instance.
|
Tasks: The current instance.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
has_tasks: bool = len(self._tasks) > 0
|
||||||
self.clear()
|
self.clear()
|
||||||
|
|
||||||
if not self._file.exists() or self._file.stat().st_size < 1:
|
if not self._file.exists() or self._file.stat().st_size < 1:
|
||||||
return self
|
return self
|
||||||
|
|
||||||
try:
|
try:
|
||||||
LOG.info(f"Loading '{self._file}'.")
|
LOG.info(f"{'Reloading' if has_tasks else 'Loading'} '{self._file}'.")
|
||||||
tasks = json.loads(self._file.read_text())
|
tasks = json.loads(self._file.read_text())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.error(f"Error loading '{self._file}'. '{e!s}'.")
|
LOG.error(f"Error loading '{self._file}'. '{e!s}'.")
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ from Crypto.Cipher import AES
|
||||||
|
|
||||||
from .LogWrapper import LogWrapper
|
from .LogWrapper import LogWrapper
|
||||||
|
|
||||||
LOG = logging.getLogger("Utils")
|
LOG: logging.Logger = logging.getLogger("Utils")
|
||||||
|
|
||||||
REMOVE_KEYS: list = [
|
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("_"):
|
if file.name.startswith(".") or file.name.startswith("_"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if file.is_symlink():
|
is_symlink: bool = file.is_symlink()
|
||||||
|
if is_symlink:
|
||||||
try:
|
try:
|
||||||
test: Path = file.resolve()
|
test: Path = file.resolve()
|
||||||
test.relative_to(base_path)
|
test.relative_to(base_path)
|
||||||
|
|
@ -831,9 +832,10 @@ def get_files(base_path: Path | str, dir: str | None = None):
|
||||||
content_type = "download"
|
content_type = "download"
|
||||||
|
|
||||||
stat = file.stat()
|
stat = file.stat()
|
||||||
|
|
||||||
contents.append(
|
contents.append(
|
||||||
{
|
{
|
||||||
"type": "file" if file.is_file() else "dir",
|
"type": "file" if file.is_file() else "link" if is_symlink else "dir",
|
||||||
"content_type": content_type,
|
"content_type": content_type,
|
||||||
"name": file.name,
|
"name": file.name,
|
||||||
"path": str(file.relative_to(base_path)).strip("/"),
|
"path": str(file.relative_to(base_path)).strip("/"),
|
||||||
|
|
@ -843,6 +845,7 @@ def get_files(base_path: Path | str, dir: str | None = None):
|
||||||
"ctime": datetime.fromtimestamp(stat.st_ctime, tz=UTC).isoformat(),
|
"ctime": datetime.fromtimestamp(stat.st_ctime, tz=UTC).isoformat(),
|
||||||
"is_dir": file.is_dir(),
|
"is_dir": file.is_dir(),
|
||||||
"is_file": file.is_file(),
|
"is_file": file.is_file(),
|
||||||
|
"is_symlink": is_symlink,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .Presets import Presets, Preset
|
from .Presets import Preset, Presets
|
||||||
from .Singleton import Singleton
|
from .Singleton import Singleton
|
||||||
from .Utils import REMOVE_KEYS, arg_converter, calc_download_path, load_cookies, merge_dict
|
from .Utils import REMOVE_KEYS, arg_converter, calc_download_path, load_cookies, merge_dict
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,10 @@ class Common:
|
||||||
config: Config | None = None,
|
config: Config | None = None,
|
||||||
):
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.queue = queue or DownloadQueue.get_instance()
|
self.queue: DownloadQueue = queue or DownloadQueue.get_instance()
|
||||||
self.encoder = encoder or Encoder()
|
self.encoder: Encoder = encoder or Encoder()
|
||||||
|
config: Config = config or Config.get_instance()
|
||||||
config = config or Config.get_instance()
|
self.default_preset: str = config.default_preset
|
||||||
self.default_preset = config.default_preset
|
|
||||||
|
|
||||||
async def add(self, item: Item) -> dict[str, str]:
|
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
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
APP_ROOT = str((Path(__file__).parent / "..").resolve())
|
||||||
|
if APP_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, APP_ROOT)
|
||||||
|
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import caribou
|
import caribou
|
||||||
import magic
|
import magic
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from library.conditions import Conditions
|
|
||||||
from library.config import Config
|
from app.library.conditions import Conditions
|
||||||
from library.DownloadQueue import DownloadQueue
|
from app.library.config import Config
|
||||||
from library.Events import EventBus, Events
|
from app.library.DownloadQueue import DownloadQueue
|
||||||
from library.HttpAPI import HttpAPI
|
from app.library.Events import EventBus, Events
|
||||||
from library.HttpSocket import HttpSocket
|
from app.library.HttpAPI import HttpAPI
|
||||||
from library.Notifications import Notification
|
from app.library.HttpSocket import HttpSocket
|
||||||
from library.Presets import Presets
|
from app.library.Notifications import Notification
|
||||||
from library.Scheduler import Scheduler
|
from app.library.Presets import Presets
|
||||||
from library.Tasks import Tasks
|
from app.library.Scheduler import Scheduler
|
||||||
|
from app.library.Tasks import Tasks
|
||||||
|
|
||||||
LOG = logging.getLogger("app")
|
LOG = logging.getLogger("app")
|
||||||
MIME = magic.Magic(mime=True)
|
MIME = magic.Magic(mime=True)
|
||||||
|
|
@ -117,7 +124,7 @@ class Main:
|
||||||
|
|
||||||
HTTP_LOGGER = None
|
HTTP_LOGGER = None
|
||||||
if self._config.access_log:
|
if self._config.access_log:
|
||||||
from library.HttpAPI import LOG as HTTP_LOGGER
|
from app.library.HttpAPI import LOG as HTTP_LOGGER
|
||||||
|
|
||||||
HTTP_LOGGER.addFilter(lambda record: f"GET {self._app.router['ping'].url_for()}" not in record.getMessage())
|
HTTP_LOGGER.addFilter(lambda record: f"GET {self._app.router['ping'].url_for()}" not in record.getMessage())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
APP_ROOT = str((Path(__file__).parent / "..").resolve())
|
||||||
|
if APP_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, APP_ROOT)
|
||||||
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
ready = threading.Event()
|
ready = threading.Event()
|
||||||
exception_holder = queue.Queue()
|
exception_holder = queue.Queue()
|
||||||
|
|
|
||||||
135
app/routes/_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
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
APP_ROOT = str((Path(__file__).parent / "..").resolve())
|
||||||
|
if APP_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, APP_ROOT)
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
|
||||||
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 v-else>
|
||||||
<div class="p-0 m-0" style="position: relative">
|
<div class="p-0 m-0" style="position: relative">
|
||||||
<div class="content" style="white-space: pre;">
|
<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))"
|
<button class="button is-small m-4" @click="() => copyText(JSON.stringify(data, null, 4))"
|
||||||
style="position: absolute; top:0; right:0;">
|
style="position: absolute; top:0; right:0;">
|
||||||
<span class="icon"><i class="fas fa-copy"></i></span>
|
<span class="icon"><i class="fas fa-copy"></i></span>
|
||||||
|
|
|
||||||
|
|
@ -705,4 +705,18 @@ const downloadSelected = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggle_class = e => e.currentTarget.classList.toggle('is-text-overflow')
|
const toggle_class = e => e.currentTarget.classList.toggle('is-text-overflow')
|
||||||
|
|
||||||
|
const 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>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ catch (e) {
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
ssr: false,
|
ssr: false,
|
||||||
devtools: { enabled: false },
|
devtools: { enabled: false },
|
||||||
|
|
||||||
devServer: {
|
devServer: {
|
||||||
port: 8082,
|
port: 8082,
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
|
|
@ -30,7 +29,7 @@ export default defineNuxtConfig({
|
||||||
],
|
],
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
public: {
|
public: {
|
||||||
APP_ENV: process.env.APP_ENV || "production",
|
APP_ENV: process.env.NODE_ENV,
|
||||||
wss: process.env.NUXT_PUBLIC_WSS ?? '',
|
wss: process.env.NUXT_PUBLIC_WSS ?? '',
|
||||||
sentry: process.env.NUXT_PUBLIC_SENTRY_DSN ?? '',
|
sentry: process.env.NUXT_PUBLIC_SENTRY_DSN ?? '',
|
||||||
}
|
}
|
||||||
|
|
@ -39,7 +38,7 @@ export default defineNuxtConfig({
|
||||||
transpile: ['vue-toastification'],
|
transpile: ['vue-toastification'],
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
baseURL: 'dev' == process.env.APP_ENV ? '/' : '',
|
baseURL: 'production' == process.env.NODE_ENV ? '' : '/',
|
||||||
buildAssetsDir: "assets",
|
buildAssetsDir: "assets",
|
||||||
head: {
|
head: {
|
||||||
"meta": [
|
"meta": [
|
||||||
|
|
@ -67,7 +66,7 @@ export default defineNuxtConfig({
|
||||||
|
|
||||||
nitro: {
|
nitro: {
|
||||||
output: {
|
output: {
|
||||||
publicDir: path.join(__dirname, 'dev' === process.env.APP_ENV ? 'dist' : 'exported')
|
publicDir: path.join(__dirname, 'production' === process.env.NODE_ENV ? 'exported' : 'dist')
|
||||||
},
|
},
|
||||||
...extraNitro,
|
...extraNitro,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@
|
||||||
<td class="is-text-overflow is-vcentered">
|
<td class="is-text-overflow is-vcentered">
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
<div class="control is-text-overflow is-expanded">
|
<div class="control is-text-overflow is-expanded">
|
||||||
<a :href="uri(`/browser/${item.path}`)" v-if="'dir' === item.type"
|
<a :href="uri(`/browser/${item.path}`)" v-if="'dir' === item.content_type"
|
||||||
@click.prevent="handleClick(item)">
|
@click.prevent="handleClick(item)">
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -113,7 +113,7 @@
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="has-text-centered is-text-overflow is-unselectable">
|
<td class="has-text-centered is-text-overflow is-unselectable">
|
||||||
{{ 'file' === item.type ? formatBytes(item.size) : 'Dir' }}
|
{{ 'file' === item.type ? formatBytes(item.size) : ucFirst(item.type) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="has-text-centered is-text-overflow is-unselectable">
|
<td class="has-text-centered is-text-overflow is-unselectable">
|
||||||
<span :data-datetime="item.mtime" v-tooltip="moment(item.mtime).format('MMMM Do YYYY, h:mm:ss a')">
|
<span :data-datetime="item.mtime" v-tooltip="moment(item.mtime).format('MMMM Do YYYY, h:mm:ss a')">
|
||||||
|
|
@ -401,6 +401,9 @@ watch(model_item, v => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const setIcon = item => {
|
const setIcon = item => {
|
||||||
|
if ('link' === item.type) {
|
||||||
|
return 'fa-link'
|
||||||
|
}
|
||||||
if ('dir' === item.content_type) {
|
if ('dir' === item.content_type) {
|
||||||
return 'fa-folder'
|
return 'fa-folder'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export const useSocketStore = defineStore('socket', () => {
|
||||||
|
|
||||||
let url = runtimeConfig.public.wss
|
let url = runtimeConfig.public.wss
|
||||||
|
|
||||||
if ('dev' !== runtimeConfig.public.APP_ENV) {
|
if ('development' !== runtimeConfig.public?.APP_ENV) {
|
||||||
url = window.origin;
|
url = window.origin;
|
||||||
opts.path = `${runtimeConfig.app.baseURL.replace(/\/$/, '')}/socket.io`;
|
opts.path = `${runtimeConfig.app.baseURL.replace(/\/$/, '')}/socket.io`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue