Merge pull request #533 from arabcoders/dev
Minor fixes to text/ass player support and small improvements to how we handle commands via API
This commit is contained in:
@@ -31,13 +31,14 @@ ENV FPM_PORT="${PHP_FPM_PORT}"
|
||||
RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime && echo ${TZ} > /etc/timezone && \
|
||||
for ext in ${PHP_PACKAGES}; do PACKAGES="${PACKAGES} ${PHP_V}-${ext}"; done && \
|
||||
apk add --no-cache bash caddy icu-data-full nano curl procps net-tools iproute2 ffmpeg \
|
||||
shadow sqlite redis tzdata gettext fcgi ca-certificates nss mailcap libcap ${PHP_V} ${PACKAGES} && \
|
||||
# Basic setup
|
||||
echo '' && \
|
||||
shadow sqlite redis tzdata gettext fcgi ca-certificates nss mailcap libcap fontconfig \
|
||||
ttf-freefont font-noto terminus-font font-dejavu ${PHP_V} ${PACKAGES} && \
|
||||
# Delete unused users change users group gid to allow unRaid users to use gid 100
|
||||
deluser redis && deluser caddy && groupmod -g 1588787 users && \
|
||||
# Create our own user.
|
||||
useradd -u ${USER_ID:-1000} -U -d /config -s /bin/bash user
|
||||
useradd -u ${USER_ID:-1000} -U -d /config -s /bin/bash user && \
|
||||
# Cache fonts.
|
||||
fc-cache -f && fc-list | sort
|
||||
|
||||
# Copy source code to container.
|
||||
COPY ./ /opt/app
|
||||
|
||||
@@ -112,13 +112,12 @@
|
||||
|
||||
<script setup>
|
||||
import "@xterm/xterm/css/xterm.css"
|
||||
// noinspection ES6UnusedImports
|
||||
import {Terminal} from "@xterm/xterm"
|
||||
// noinspection ES6UnusedImports
|
||||
import {FitAddon} from "@xterm/addon-fit"
|
||||
import {useStorage} from '@vueuse/core'
|
||||
import {notification} from '~/utils/index'
|
||||
import Message from '~/components/Message'
|
||||
import request from "~/utils/request.js";
|
||||
|
||||
useHead({title: `Console`})
|
||||
|
||||
@@ -174,10 +173,7 @@ const RunCommand = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams()
|
||||
searchParams.append('apikey', api_token.value)
|
||||
searchParams.append('json', btoa(JSON.stringify({command: userCommand})))
|
||||
|
||||
const commandBody = JSON.parse(JSON.stringify({command: userCommand}))
|
||||
|
||||
if (userCommand.startsWith('$')) {
|
||||
if (!allEnabled.value) {
|
||||
@@ -191,12 +187,34 @@ const RunCommand = async () => {
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
let token;
|
||||
|
||||
sse = new EventSource(`${api_url.value}${api_path.value}/system/command/?${searchParams.toString()}`)
|
||||
try {
|
||||
const response = await request('/system/command', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(commandBody)
|
||||
})
|
||||
const json = await response.json()
|
||||
|
||||
if (201 !== response.status) {
|
||||
await finished()
|
||||
notification('error', 'Error', `${json.error.code}: ${json.error.message}`, 5000)
|
||||
return;
|
||||
}
|
||||
|
||||
token = json.token
|
||||
} catch (e) {
|
||||
await finished()
|
||||
notification('error', 'Error', e.message, 5000)
|
||||
return;
|
||||
}
|
||||
|
||||
sse = new EventSource(`${api_url.value}${api_path.value}/system/command/${token}?apikey=${api_token.value}`)
|
||||
|
||||
if ('' !== command.value) {
|
||||
terminal.value.writeln(`~ ${userCommand}`)
|
||||
}
|
||||
|
||||
sse.addEventListener('data', async e => terminal.value.write(JSON.parse(e.data).data))
|
||||
sse.addEventListener('close', async () => finished())
|
||||
sse.onclose = async () => finished()
|
||||
|
||||
@@ -281,17 +281,17 @@ readonly class Segments
|
||||
$end = microtime(true);
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
if (true === $debug) {
|
||||
return api_error($process->getErrorOutput(), Status::INTERNAL_SERVER_ERROR, [
|
||||
$this->logger->error(
|
||||
r("Failed to generate segment. '{error}'", ['error' => $process->getErrorOutput()]), [
|
||||
'stdout' => $process->getOutput(),
|
||||
'stderr' => $process->getErrorOutput(),
|
||||
'Ffmpeg' => $process->getCommandLine(),
|
||||
'config' => $sConfig,
|
||||
'command' => implode(' ', $cmd),
|
||||
]);
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
return api_error('Failed to generate segment.', Status::INTERNAL_SERVER_ERROR, headers: [
|
||||
return api_error('Failed to generate segment. check logs.', Status::INTERNAL_SERVER_ERROR, headers: [
|
||||
'X-Transcode-Time' => round($end - $start, 6),
|
||||
]);
|
||||
}
|
||||
@@ -318,7 +318,19 @@ readonly class Segments
|
||||
|
||||
return $response;
|
||||
} catch (Throwable $e) {
|
||||
return api_error($e->getMessage(), Status::INTERNAL_SERVER_ERROR);
|
||||
$this->logger->error("Failed to generate segment. '{error}' at {file}:{line}", [
|
||||
'stdout' => isset($process) ? $process->getOutput() : null,
|
||||
'stderr' => isset($process) ? $process->getErrorOutput() : null,
|
||||
'Ffmpeg' => isset($process) ? $process->getCommandLine() : null,
|
||||
'config' => $sConfig,
|
||||
'command' => implode(' ', $cmd),
|
||||
'error' => $e->getMessage(),
|
||||
'line' => $e->getLine(),
|
||||
'file' => $e->getFile(),
|
||||
'trace' => $e->getTrace(),
|
||||
]);
|
||||
|
||||
return api_error('Failed to generate segment. check logs.', Status::INTERNAL_SERVER_ERROR);
|
||||
} finally {
|
||||
if (file_exists($tmpVidLock)) {
|
||||
unlink($tmpVidLock);
|
||||
@@ -369,7 +381,12 @@ readonly class Segments
|
||||
$process->wait();
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
$this->logger->error(join(' ', $cmd) . $process->getErrorOutput());
|
||||
$this->logger->error('Failed to extract subtitle.', [
|
||||
'stdout' => $process->getOutput(),
|
||||
'stderr' => $process->getErrorOutput(),
|
||||
'Ffmpeg' => $process->getCommandLine(),
|
||||
'command' => implode(' ', $cmd),
|
||||
]);
|
||||
return "{$path}:stream_index={$stream}";
|
||||
}
|
||||
|
||||
@@ -381,7 +398,16 @@ readonly class Segments
|
||||
$stream->close();
|
||||
return $cacheFile;
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error($e->getMessage(), ['trace' => $e->getTrace()]);
|
||||
$this->logger->error("Failed to extract subtitles. '{error}' at {file}:{line}", [
|
||||
'stdout' => isset($process) ? $process->getOutput() : null,
|
||||
'stderr' => isset($process) ? $process->getErrorOutput() : null,
|
||||
'Ffmpeg' => isset($process) ? $process->getCommandLine() : null,
|
||||
'command' => implode(' ', $cmd),
|
||||
'error' => $e->getMessage(),
|
||||
'line' => $e->getLine(),
|
||||
'file' => $e->getFile(),
|
||||
'trace' => $e->getTrace(),
|
||||
]);
|
||||
return "{$path}:stream_index={$stream}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,18 @@ declare(strict_types=1);
|
||||
namespace App\API\System;
|
||||
|
||||
use App\Libs\Attributes\Route\Get;
|
||||
use App\Libs\Attributes\Route\Post;
|
||||
use App\Libs\Config;
|
||||
use App\Libs\DataUtil;
|
||||
use App\Libs\Enums\Http\Status;
|
||||
use App\Libs\Extends\Date;
|
||||
use App\Libs\StreamClosure;
|
||||
use JsonException;
|
||||
use DateInterval;
|
||||
use Psr\Http\Message\ResponseInterface as iResponse;
|
||||
use Psr\Http\Message\ServerRequestInterface as iRequest;
|
||||
use Psr\SimpleCache\CacheInterface as iCache;
|
||||
use Psr\SimpleCache\InvalidArgumentException;
|
||||
use Random\RandomException;
|
||||
use Symfony\Component\Process\Exception\ProcessTimedOutException;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
@@ -25,39 +30,72 @@ final class Command
|
||||
|
||||
private bool $toBackground = false;
|
||||
|
||||
public function __construct()
|
||||
public function __construct(private iCache $cache)
|
||||
{
|
||||
set_time_limit(0);
|
||||
}
|
||||
|
||||
#[Get(self::URL . '[/]', name: 'system.command')]
|
||||
public function __invoke(iRequest $request): iResponse
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
* @throws RandomException
|
||||
*/
|
||||
#[Post(self::URL . '[/]', name: 'system.command.queue')]
|
||||
public function queue(iRequest $request): iResponse
|
||||
{
|
||||
if (null === ($json = ag($request->getQueryParams(), 'json'))) {
|
||||
$params = $request->getParsedBody();
|
||||
|
||||
if (!is_array($params) || empty($params)) {
|
||||
return api_error('No json data was given.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (null === ($cmd = ag($params, 'command', null))) {
|
||||
return api_error('No command was given.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$json = json_decode(base64_decode(rawurldecode($json)), true, flags: JSON_THROW_ON_ERROR);
|
||||
$data = DataUtil::fromArray($json);
|
||||
if (null === ($command = $data->get('command'))) {
|
||||
return api_error('No command was given.', Status::BAD_REQUEST);
|
||||
}
|
||||
} catch (JsonException $e) {
|
||||
return api_error(
|
||||
r('Unable to decode json data. {error}', ['error' => $e->getMessage()]),
|
||||
Status::BAD_REQUEST
|
||||
);
|
||||
if (!is_string($cmd)) {
|
||||
return api_error('Command is invalid.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$code = hash('sha256', random_bytes(12) . $cmd);
|
||||
|
||||
$ttl = new DateInterval('PT5M');
|
||||
$this->cache->set($code, $params, $ttl);
|
||||
|
||||
return api_response(Status::CREATED, [
|
||||
'token' => $code,
|
||||
'tracking' => r("{url}/{code}", ['url' => parseConfigValue(self::URL), 'code' => $code]),
|
||||
'expires' => makeDate()->add($ttl)->format(Date::ATOM),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Get(self::URL . '/{token}[/]', name: 'system.command.stream')]
|
||||
public function stream(string $token): iResponse
|
||||
{
|
||||
if (null === ($data = $this->cache->get($token))) {
|
||||
return api_error('Token is invalid or has expired.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($this->cache->has($token)) {
|
||||
$this->cache->delete($token);
|
||||
}
|
||||
|
||||
$data = DataUtil::fromArray($data);
|
||||
|
||||
if (null === ($command = $data->get('command'))) {
|
||||
return api_error('No command was given.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (!is_string($command)) {
|
||||
return api_error('Command is invalid.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$callable = function () use ($command, $data, $request) {
|
||||
$callable = function () use ($command, $data) {
|
||||
ignore_user_abort(true);
|
||||
|
||||
$path = realpath(__DIR__ . '/../../../');
|
||||
$cwd = $data->get('cwd', Config::get('path', fn() => getcwd()));
|
||||
|
||||
try {
|
||||
$userCommand = "{$path}/bin/console -n {$command}";
|
||||
@@ -66,7 +104,7 @@ final class Command
|
||||
}
|
||||
$process = Process::fromShellCommandline(
|
||||
command: $userCommand,
|
||||
cwd: $path,
|
||||
cwd: $cwd,
|
||||
env: array_replace_recursive([
|
||||
'LANG' => 'en_US.UTF-8',
|
||||
'LC_ALL' => 'en_US.UTF-8',
|
||||
@@ -76,25 +114,20 @@ final class Command
|
||||
timeout: $data->get('timeout', 7200),
|
||||
);
|
||||
|
||||
$this->write('cwd', (string)$cwd);
|
||||
|
||||
$process->setPty(true);
|
||||
|
||||
$process->start(callback: function ($type, $data) use ($process) {
|
||||
if (true === $this->toBackground) {
|
||||
return;
|
||||
}
|
||||
|
||||
echo "id: " . hrtime(true) . "\n";
|
||||
echo "event: data\n";
|
||||
echo "data: " . json_encode(['type' => $type, 'data' => $data]) . "\n";
|
||||
echo "\n\n";
|
||||
|
||||
flush();
|
||||
|
||||
$this->counter = self::TIMES_BEFORE_PING;
|
||||
|
||||
if (ob_get_length() > 0) {
|
||||
ob_end_flush();
|
||||
}
|
||||
$this->write(
|
||||
'data',
|
||||
json_encode(['data' => $data, 'type' => $type], flags: JSON_INVALID_UTF8_IGNORE)
|
||||
);
|
||||
|
||||
if (connection_aborted()) {
|
||||
$this->toBackground = true;
|
||||
@@ -111,35 +144,25 @@ final class Command
|
||||
|
||||
$this->counter = self::TIMES_BEFORE_PING;
|
||||
|
||||
echo "id: " . hrtime(true) . "\n";
|
||||
echo "event: ping\n";
|
||||
echo 'data: ' . makeDate() . "\n\n";
|
||||
flush();
|
||||
|
||||
if (ob_get_length() > 0) {
|
||||
ob_end_flush();
|
||||
}
|
||||
$this->write('ping', (string)makeDate());
|
||||
|
||||
if (connection_aborted()) {
|
||||
$this->toBackground = true;
|
||||
}
|
||||
}
|
||||
|
||||
$this->write('exit_code', (string)$process->getExitCode());
|
||||
} catch (ProcessTimedOutException) {
|
||||
}
|
||||
|
||||
if (false === $this->toBackground && !connection_aborted()) {
|
||||
echo "id: " . hrtime(true) . "\n";
|
||||
echo "event: close\n";
|
||||
echo 'data: ' . makeDate() . "\n\n";
|
||||
flush();
|
||||
|
||||
if (ob_get_length() > 0) {
|
||||
ob_end_flush();
|
||||
}
|
||||
$this->write('close', (string)makeDate());
|
||||
}
|
||||
exit;
|
||||
};
|
||||
|
||||
set_time_limit(0);
|
||||
|
||||
return api_response(Status::OK, body: StreamClosure::create($callable), headers: [
|
||||
'Content-Type' => 'text/event-stream',
|
||||
'Cache-Control' => 'no-cache',
|
||||
@@ -148,4 +171,22 @@ final class Command
|
||||
'Last-Event-Id' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function write(string $event, string $data, bool $multiLine = false): void
|
||||
{
|
||||
echo "id: " . hrtime(true) . "\n";
|
||||
echo "event: {$event}\n";
|
||||
if (true === $multiLine) {
|
||||
foreach (explode(PHP_EOL, $data) as $line) {
|
||||
echo "data: {$line}\n";
|
||||
}
|
||||
} else {
|
||||
echo "data: {$data}\n";
|
||||
}
|
||||
echo "\n\n";
|
||||
flush();
|
||||
if (ob_get_length() > 0) {
|
||||
ob_end_flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user