mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2025-12-12 01:00:29 +08:00
new snapshot api
This commit is contained in:
parent
a5fcafe2a5
commit
81fec121d0
110
kvmd/apps/kvmd/api/streamer.py
Normal file
110
kvmd/apps/kvmd/api/streamer.py
Normal file
@ -0,0 +1,110 @@
|
||||
# ========================================================================== #
|
||||
# #
|
||||
# KVMD - The main Pi-KVM daemon. #
|
||||
# #
|
||||
# Copyright (C) 2018 Maxim Devaev <mdevaev@gmail.com> #
|
||||
# #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
# #
|
||||
# ========================================================================== #
|
||||
|
||||
|
||||
import io
|
||||
import functools
|
||||
|
||||
from aiohttp.web import Request
|
||||
from aiohttp.web import Response
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from ....validators.basic import valid_bool
|
||||
from ....validators.basic import valid_int_f0
|
||||
|
||||
from ....validators.kvm import valid_stream_quality
|
||||
|
||||
from .... import aiotools
|
||||
|
||||
from ..http import UnavailableError
|
||||
from ..http import exposed_http
|
||||
from ..http import make_json_response
|
||||
|
||||
from ..streamer import StreamerSnapshot
|
||||
from ..streamer import Streamer
|
||||
|
||||
|
||||
# =====
|
||||
class StreamerApi:
|
||||
def __init__(self, streamer: Streamer) -> None:
|
||||
self.__streamer = streamer
|
||||
|
||||
# =====
|
||||
|
||||
@exposed_http("GET", "/streamer")
|
||||
async def __state_handler(self, _: Request) -> Response:
|
||||
return make_json_response(await self.__streamer.get_state())
|
||||
|
||||
@exposed_http("GET", "/streamer/snapshot")
|
||||
async def __make_snapshot_handler(self, request: Request) -> Response:
|
||||
if (snapshot := await self.__streamer.make_snapshot(
|
||||
save=valid_bool(request.query.get("save", "false")),
|
||||
load=valid_bool(request.query.get("load", "false")),
|
||||
allow_offline=valid_bool(request.query.get("allow_offline", "false")),
|
||||
)):
|
||||
if valid_bool(request.query.get("preview", "false")):
|
||||
data = await self.__make_preview(
|
||||
snapshot=snapshot,
|
||||
max_width=valid_int_f0(request.query.get("preview_max_width", "0")),
|
||||
max_height=valid_int_f0(request.query.get("preview_max_height", "0")),
|
||||
quality=valid_stream_quality(request.query.get("preview_quality", "80")),
|
||||
)
|
||||
else:
|
||||
data = snapshot.data
|
||||
return Response(
|
||||
body=data,
|
||||
headers=dict(snapshot.headers),
|
||||
content_type="image/jpeg",
|
||||
)
|
||||
raise UnavailableError()
|
||||
|
||||
@exposed_http("DELETE", "/streamer/snapshot")
|
||||
async def __remove_snapshot_handler(self, _: Request) -> Response:
|
||||
self.__streamer.remove_snapshot()
|
||||
return make_json_response()
|
||||
|
||||
# =====
|
||||
|
||||
async def __make_preview(self, snapshot: StreamerSnapshot, max_width: int, max_height: int, quality: int) -> bytes:
|
||||
if max_width == 0 and max_height == 0:
|
||||
max_width = snapshot.width // 5
|
||||
max_height = snapshot.height // 5
|
||||
else:
|
||||
max_width = min((max_width or snapshot.width), snapshot.width)
|
||||
max_height = min((max_height or snapshot.height), snapshot.height)
|
||||
|
||||
if max_width == snapshot.width and max_height == snapshot.height:
|
||||
return snapshot.data
|
||||
else:
|
||||
return (await aiotools.run_async(self.__inner_make_preview, snapshot, max_width, max_height, quality))
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def __inner_make_preview(self, snapshot: StreamerSnapshot, max_width: int, max_height: int, quality: int) -> bytes:
|
||||
assert 0 < max_width <= snapshot.width
|
||||
assert 0 < max_height <= snapshot.height
|
||||
assert not (max_width == snapshot.width and max_height == snapshot.height)
|
||||
with io.BytesIO(snapshot.data) as snapshot_bio:
|
||||
with io.BytesIO() as preview_bio:
|
||||
with Image.open(snapshot_bio) as image:
|
||||
image.thumbnail((max_width, max_height), Image.ANTIALIAS)
|
||||
image.save(preview_bio, format="jpeg", quality=quality)
|
||||
return preview_bio.getvalue()
|
||||
@ -39,6 +39,11 @@ class ForbiddenError(HttpError):
|
||||
super().__init__("Forbidden", 403)
|
||||
|
||||
|
||||
class UnavailableError(HttpError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Service Unavailable", 503)
|
||||
|
||||
|
||||
# =====
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class HttpExposed:
|
||||
|
||||
@ -83,6 +83,7 @@ from .api.wol import WolApi
|
||||
from .api.hid import HidApi
|
||||
from .api.atx import AtxApi
|
||||
from .api.msd import MsdApi
|
||||
from .api.streamer import StreamerApi
|
||||
|
||||
|
||||
# =====
|
||||
@ -133,6 +134,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
|
||||
HidApi(hid, keymap_path),
|
||||
AtxApi(atx),
|
||||
MsdApi(msd, sync_chunk_size),
|
||||
StreamerApi(streamer),
|
||||
]
|
||||
|
||||
self.__ws_handlers: Dict[str, Callable] = {}
|
||||
@ -164,11 +166,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
|
||||
async def __info_handler(self, _: aiohttp.web.Request) -> aiohttp.web.Response:
|
||||
return make_json_response(await self.__make_info())
|
||||
|
||||
# ===== STREAMER
|
||||
|
||||
@exposed_http("GET", "/streamer")
|
||||
async def __streamer_state_handler(self, _: aiohttp.web.Request) -> aiohttp.web.Response:
|
||||
return make_json_response(await self.__streamer.get_state())
|
||||
# ===== STREAMER CONTROLLER
|
||||
|
||||
@exposed_http("POST", "/streamer/set_params")
|
||||
async def __streamer_set_params_handler(self, request: aiohttp.web.Request) -> aiohttp.web.Response:
|
||||
|
||||
@ -24,7 +24,10 @@ import os
|
||||
import signal
|
||||
import asyncio
|
||||
import asyncio.subprocess
|
||||
import dataclasses
|
||||
import operator
|
||||
|
||||
from typing import Tuple
|
||||
from typing import List
|
||||
from typing import Dict
|
||||
from typing import AsyncGenerator
|
||||
@ -42,6 +45,16 @@ from ... import gpio
|
||||
|
||||
|
||||
# =====
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class StreamerSnapshot:
|
||||
online: bool
|
||||
width: int
|
||||
height: int
|
||||
mtime: float
|
||||
headers: Tuple[Tuple[str, str], ...]
|
||||
data: bytes
|
||||
|
||||
|
||||
class Streamer: # pylint: disable=too-many-instance-attributes
|
||||
def __init__( # pylint: disable=too-many-arguments,too-many-locals
|
||||
self,
|
||||
@ -101,6 +114,10 @@ class Streamer: # pylint: disable=too-many-instance-attributes
|
||||
|
||||
self.__http_session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
self.__snapshot: Optional[StreamerSnapshot] = None
|
||||
|
||||
self.__state_notifier = aiotools.AioNotifier()
|
||||
|
||||
# =====
|
||||
|
||||
@aiotools.atomic
|
||||
@ -163,6 +180,8 @@ class Streamer: # pylint: disable=too-many-instance-attributes
|
||||
# Запущено и не планирует останавливаться
|
||||
return bool(self.__streamer_task and not self.__stop_task)
|
||||
|
||||
# =====
|
||||
|
||||
def set_params(self, params: Dict) -> None:
|
||||
assert not self.__streamer_task
|
||||
self.__params = {
|
||||
@ -176,6 +195,8 @@ class Streamer: # pylint: disable=too-many-instance-attributes
|
||||
def get_params(self) -> Dict:
|
||||
return dict(self.__params)
|
||||
|
||||
# =====
|
||||
|
||||
async def get_state(self) -> Dict:
|
||||
state = None
|
||||
if self.__streamer_task:
|
||||
@ -188,18 +209,24 @@ class Streamer: # pylint: disable=too-many-instance-attributes
|
||||
pass
|
||||
except Exception:
|
||||
get_logger().exception("Invalid streamer response from /state")
|
||||
|
||||
snapshot: Optional[Dict] = None
|
||||
if self.__snapshot:
|
||||
snapshot = dataclasses.asdict(self.__snapshot)
|
||||
del snapshot["headers"]
|
||||
del snapshot["data"]
|
||||
|
||||
return {
|
||||
"limits": {"max_fps": self.__max_fps},
|
||||
"params": self.__params,
|
||||
"snapshot": {"saved": snapshot},
|
||||
"state": state,
|
||||
}
|
||||
|
||||
async def poll_state(self) -> AsyncGenerator[Dict, None]:
|
||||
notifier = aiotools.AioNotifier()
|
||||
|
||||
def signal_handler(*_: Any) -> None:
|
||||
get_logger(0).info("Got SIGUSR2, checking the stream state ...")
|
||||
asyncio.ensure_future(notifier.notify())
|
||||
asyncio.ensure_future(self.__state_notifier.notify())
|
||||
|
||||
get_logger(0).info("Installing SIGUSR2 streamer handler ...")
|
||||
asyncio.get_event_loop().add_signal_handler(signal.SIGUSR2, signal_handler)
|
||||
@ -213,10 +240,12 @@ class Streamer: # pylint: disable=too-many-instance-attributes
|
||||
prev_state = state
|
||||
|
||||
if waiter_task is None:
|
||||
waiter_task = asyncio.create_task(notifier.wait())
|
||||
waiter_task = asyncio.create_task(self.__state_notifier.wait())
|
||||
if waiter_task in (await aiotools.wait_first(asyncio.sleep(self.__state_poll), waiter_task))[0]:
|
||||
waiter_task = None
|
||||
|
||||
# =====
|
||||
|
||||
async def get_info(self) -> Dict:
|
||||
version = (await aioproc.read_process([self.__cmd[0], "--version"], err_to_null=True))[1]
|
||||
return {
|
||||
@ -224,6 +253,49 @@ class Streamer: # pylint: disable=too-many-instance-attributes
|
||||
"version": version,
|
||||
}
|
||||
|
||||
async def make_snapshot(self, save: bool, load: bool, allow_offline: bool) -> Optional[StreamerSnapshot]:
|
||||
if load:
|
||||
return self.__snapshot
|
||||
else:
|
||||
session = self.__ensure_http_session()
|
||||
try:
|
||||
async with session.get(self.__make_url("snapshot")) as response:
|
||||
htclient.raise_not_200(response)
|
||||
online = (response.headers["X-UStreamer-Online"] == "true")
|
||||
if online or allow_offline:
|
||||
snapshot = StreamerSnapshot(
|
||||
online=online,
|
||||
width=int(response.headers["X-UStreamer-Width"]),
|
||||
height=int(response.headers["X-UStreamer-Height"]),
|
||||
mtime=float(response.headers["X-Timestamp"]),
|
||||
headers=tuple(
|
||||
(key, value)
|
||||
for (key, value) in sorted(response.headers.items(), key=operator.itemgetter(0))
|
||||
if key.lower().startswith("x-ustreamer-") or key.lower() in [
|
||||
"x-timestamp",
|
||||
"access-control-allow-origin",
|
||||
"cache-control",
|
||||
"pragma",
|
||||
"expires",
|
||||
]
|
||||
),
|
||||
data=bytes(await response.read()),
|
||||
)
|
||||
if save:
|
||||
self.__snapshot = snapshot
|
||||
await self.__state_notifier.notify()
|
||||
return snapshot
|
||||
except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError):
|
||||
pass
|
||||
except Exception:
|
||||
get_logger().exception("Invalid streamer response from /snapshot")
|
||||
return None
|
||||
|
||||
def remove_snapshot(self) -> None:
|
||||
self.__snapshot = None
|
||||
|
||||
# =====
|
||||
|
||||
@aiotools.atomic
|
||||
async def cleanup(self) -> None:
|
||||
try:
|
||||
|
||||
@ -54,7 +54,7 @@ class StreamerClient:
|
||||
|
||||
async def read_stream(self) -> AsyncGenerator[Tuple[bool, int, int, bytes], None]:
|
||||
try:
|
||||
async with self.__make_http_session(infinite=True) as session:
|
||||
async with self.__make_http_session() as session:
|
||||
async with session.get(
|
||||
url=self.__make_url("stream"),
|
||||
params={"extra_headers": "1"},
|
||||
@ -84,24 +84,14 @@ class StreamerClient:
|
||||
raise StreamerError(f"{type(err).__name__}: {err}")
|
||||
raise StreamerError("Reached EOF")
|
||||
|
||||
# async def get_snapshot(self) -> Tuple[bool, bytes]:
|
||||
# async with self.__make_http_session(infinite=False) as session:
|
||||
# async with session.get(self.__make_url("snapshot")) as response:
|
||||
# htclient.raise_not_200(response)
|
||||
# return (
|
||||
# (response.headers["X-UStreamer-Online"] == "true"),
|
||||
# bytes(await response.read()),
|
||||
# )
|
||||
|
||||
def __make_http_session(self, infinite: bool) -> aiohttp.ClientSession:
|
||||
kwargs: Dict = {"headers": {"User-Agent": self.__user_agent}}
|
||||
if infinite:
|
||||
kwargs["timeout"] = aiohttp.ClientTimeout(
|
||||
def __make_http_session(self) -> aiohttp.ClientSession:
|
||||
kwargs: Dict = {
|
||||
"headers": {"User-Agent": self.__user_agent},
|
||||
"timeout": aiohttp.ClientTimeout(
|
||||
connect=self.__timeout,
|
||||
sock_read=self.__timeout,
|
||||
)
|
||||
else:
|
||||
kwargs["timeout"] = aiohttp.ClientTimeout(total=self.__timeout)
|
||||
),
|
||||
}
|
||||
if self.__unix_path:
|
||||
kwargs["connector"] = aiohttp.UnixConnector(path=self.__unix_path)
|
||||
return aiohttp.ClientSession(**kwargs)
|
||||
|
||||
@ -193,7 +193,7 @@ export function Streamer() {
|
||||
|
||||
var __clickScreenshotButton = function() {
|
||||
let el_a = document.createElement("a");
|
||||
el_a.href = "/streamer/snapshot";
|
||||
el_a.href = "/api/streamer/snapshot?allow_offline=1";
|
||||
el_a.target = "_blank";
|
||||
document.body.appendChild(el_a);
|
||||
el_a.click();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user