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:
Abdulmohsen
2024-08-12 23:23:40 +03:00
committed by GitHub
4 changed files with 151 additions and 65 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -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}";
}
}

View File

@@ -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();
}
}
}