Initial commit
11
.env.example
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
APP_ENV=production
|
||||||
|
APP_ID=CHANGE_ME_UUID
|
||||||
|
DEBUG=false
|
||||||
|
|
||||||
|
DB_HOST=db
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_NAME=scmedia
|
||||||
|
DB_USER=scmedia
|
||||||
|
DB_PASS=changeme
|
||||||
|
|
||||||
|
JWT_SECRET=CHANGE_ME_LONG_RANDOM
|
||||||
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
23
Makefile
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
.PHONY: up down build logs init shell worker
|
||||||
|
|
||||||
|
up:
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
down:
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
build:
|
||||||
|
docker compose build --no-cache
|
||||||
|
|
||||||
|
logs:
|
||||||
|
docker compose logs -f --tail=200
|
||||||
|
|
||||||
|
init:
|
||||||
|
docker compose up -d db
|
||||||
|
docker compose run --rm php bash /var/www/docker/scripts/init-db.sh
|
||||||
|
|
||||||
|
shell:
|
||||||
|
docker compose exec php sh
|
||||||
|
|
||||||
|
worker:
|
||||||
|
docker compose logs -f --tail=200 worker
|
||||||
307
app/CApp.php
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
<?php
|
||||||
|
// app/CApp.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use ScMedia\Http\Router;
|
||||||
|
|
||||||
|
final class CApp {
|
||||||
|
private ?Router $router = null;
|
||||||
|
private ?Container $container = null;
|
||||||
|
|
||||||
|
public function run(): void {
|
||||||
|
$this->loadEnv(__DIR__ . '/../.env');
|
||||||
|
$this->registerErrorHandlers();
|
||||||
|
|
||||||
|
require_once __DIR__ . '/http/helpers.php';
|
||||||
|
require_once __DIR__ . '/http/Router.php';
|
||||||
|
require_once __DIR__ . '/http/Response.php';
|
||||||
|
require_once __DIR__ . '/Container.php';
|
||||||
|
require_once __DIR__ . '/controllers/BaseController.php';
|
||||||
|
|
||||||
|
$this->router = new Router();
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||||
|
$uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||||
|
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$services = require __DIR__ . '/bootstrap.php';
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->writeErrorResponse($e, $this->isDebugEnabled(), str_starts_with($path, '/api/'));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->container = new Container($services);
|
||||||
|
$this->registerControllers();
|
||||||
|
|
||||||
|
$path = $this->handleLanguageRouting($path);
|
||||||
|
$this->authorizeRequest($path);
|
||||||
|
|
||||||
|
$this->router->dispatch($method, $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function registerControllers(): void {
|
||||||
|
require_once __DIR__ . '/controllers/pages.php';
|
||||||
|
require_once __DIR__ . '/controllers/settings.php';
|
||||||
|
require_once __DIR__ . '/controllers/scan_profiles.php';
|
||||||
|
require_once __DIR__ . '/controllers/tools.php';
|
||||||
|
require_once __DIR__ . '/controllers/layout.php';
|
||||||
|
require_once __DIR__ . '/controllers/debug.php';
|
||||||
|
require_once __DIR__ . '/controllers/media.php';
|
||||||
|
require_once __DIR__ . '/controllers/logs.php';
|
||||||
|
require_once __DIR__ . '/controllers/metadata.php';
|
||||||
|
require_once __DIR__ . '/controllers/sources.php';
|
||||||
|
require_once __DIR__ . '/controllers/events.php';
|
||||||
|
require_once __DIR__ . '/controllers/tasks.php';
|
||||||
|
require_once __DIR__ . '/controllers/auth.php';
|
||||||
|
require_once __DIR__ . '/controllers/admin.php';
|
||||||
|
require_once __DIR__ . '/controllers/account.php';
|
||||||
|
require_once __DIR__ . '/controllers/items.php';
|
||||||
|
require_once __DIR__ . '/controllers/jobs.php';
|
||||||
|
|
||||||
|
$router = $this->router;
|
||||||
|
$container = $this->container;
|
||||||
|
PagesController::register($router, $container);
|
||||||
|
SettingsController::register($router, $container);
|
||||||
|
ScanProfilesController::register($router, $container);
|
||||||
|
ToolsController::register($router, $container);
|
||||||
|
LayoutController::register($router, $container);
|
||||||
|
DebugController::register($router, $container);
|
||||||
|
MediaController::register($router, $container);
|
||||||
|
LogsController::register($router, $container);
|
||||||
|
MetadataController::register($router, $container);
|
||||||
|
SourcesController::register($router, $container);
|
||||||
|
EventsController::register($router, $container);
|
||||||
|
TasksController::register($router, $container);
|
||||||
|
AuthController::register($router, $container);
|
||||||
|
AdminController::register($router, $container);
|
||||||
|
AccountController::register($router, $container);
|
||||||
|
ItemsController::register($router, $container);
|
||||||
|
JobsController::register($router, $container);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeRequest(string $path): void {
|
||||||
|
$services = $this->container->all();
|
||||||
|
if (str_starts_with($path, '/api/')) {
|
||||||
|
$publicApi = [
|
||||||
|
'/api/auth/register',
|
||||||
|
'/api/auth/login',
|
||||||
|
'/api/auth/2fa/verify',
|
||||||
|
'/api/auth/refresh',
|
||||||
|
'/api/auth/logout',
|
||||||
|
'/api/auth/password/forgot',
|
||||||
|
'/api/auth/password/reset',
|
||||||
|
'/api/language',
|
||||||
|
'/api/events',
|
||||||
|
];
|
||||||
|
if (!in_array($path, $publicApi, true)) {
|
||||||
|
$requiredRole = str_starts_with($path, '/api/admin/') ? 'admin' : null;
|
||||||
|
/** @var \ScMedia\Services\AuthService $auth */
|
||||||
|
$auth = $services['auth'];
|
||||||
|
$this->container->set('auth_user', $auth->requireAuth($requiredRole));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||||
|
header('Pragma: no-cache');
|
||||||
|
header('Expires: 0');
|
||||||
|
if ($path !== '/login') {
|
||||||
|
/** @var \ScMedia\Services\AuthService $auth */
|
||||||
|
$auth = $services['auth'];
|
||||||
|
$this->container->set('auth_user', $auth->requirePageAuth());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleLanguageRouting(string $path): string {
|
||||||
|
if (str_starts_with($path, '/api/')) {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
$supportedLangs = ['en', 'ru', 'de'];
|
||||||
|
if ($path === '/lang') {
|
||||||
|
$lang = isset($_GET['lang']) ? strtolower((string)$_GET['lang']) : 'en';
|
||||||
|
if (!in_array($lang, $supportedLangs, true)) {
|
||||||
|
$lang = 'en';
|
||||||
|
}
|
||||||
|
$next = isset($_GET['next']) ? (string)$_GET['next'] : '/';
|
||||||
|
if ($next === '' || $next[0] !== '/' || str_contains($next, '..')) {
|
||||||
|
$next = '/';
|
||||||
|
}
|
||||||
|
$target = '/' . $lang . ($next === '/' ? '' : $next);
|
||||||
|
setcookie('scmedia_lang', $lang, [
|
||||||
|
'expires' => time() + 31536000,
|
||||||
|
'path' => '/',
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
header('Location: ' . $target, true, 302);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$first = explode('/', trim($path, '/'))[0] ?? '';
|
||||||
|
$hasPrefix = in_array($first, $supportedLangs, true);
|
||||||
|
if ($hasPrefix) {
|
||||||
|
setcookie('scmedia_lang', $first, [
|
||||||
|
'expires' => time() + 31536000,
|
||||||
|
'path' => '/',
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
$_COOKIE['scmedia_lang'] = $first;
|
||||||
|
$path = '/' . ltrim(substr($path, strlen($first) + 1), '/');
|
||||||
|
if ($path === '/') {
|
||||||
|
$path = '/';
|
||||||
|
}
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!str_starts_with($path, '/assets') && $path !== '/favicon.ico') {
|
||||||
|
$lang = isset($_COOKIE['scmedia_lang']) ? (string)$_COOKIE['scmedia_lang'] : '';
|
||||||
|
$token = (string)($_COOKIE['access_token'] ?? '');
|
||||||
|
if ($lang === '' && $token !== '' && isset($this->container->all()['auth'])) {
|
||||||
|
/** @var \ScMedia\Services\AuthService $auth */
|
||||||
|
$auth = $this->container->all()['auth'];
|
||||||
|
$lang = $auth->resolveUserLanguageFromToken($token);
|
||||||
|
}
|
||||||
|
if ($lang === '' || $lang === null) {
|
||||||
|
$lang = $this->pickLangFromHeader((string)($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? ''), $supportedLangs);
|
||||||
|
}
|
||||||
|
if ($lang === '') $lang = 'en';
|
||||||
|
$target = '/' . $lang . $path;
|
||||||
|
header('Location: ' . $target, true, 302);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pickLangFromHeader(string $header, array $supported): string {
|
||||||
|
$header = strtolower($header);
|
||||||
|
$parts = array_map('trim', explode(',', $header));
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
if ($part === '') continue;
|
||||||
|
$lang = explode(';', $part)[0] ?? '';
|
||||||
|
$lang = substr($lang, 0, 2);
|
||||||
|
if (in_array($lang, $supported, true)) return $lang;
|
||||||
|
}
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function registerErrorHandlers(): void {
|
||||||
|
set_exception_handler(function (Throwable $e): void {
|
||||||
|
$uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||||
|
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
|
||||||
|
$this->writeErrorResponse($e, $this->isDebugEnabled(), str_starts_with($path, '/api/'));
|
||||||
|
});
|
||||||
|
set_error_handler(function (int $severity, string $message, string $file, int $line): bool {
|
||||||
|
throw new ErrorException($message, 0, $severity, $file, $line);
|
||||||
|
});
|
||||||
|
register_shutdown_function(function (): void {
|
||||||
|
$error = error_get_last();
|
||||||
|
if ($error === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$fatal = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR];
|
||||||
|
if (!in_array($error['type'], $fatal, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$exception = new ErrorException($error['message'] ?? 'Fatal error', 0, $error['type'], $error['file'] ?? '', (int)($error['line'] ?? 0));
|
||||||
|
$uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||||
|
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
|
||||||
|
$this->writeErrorResponse($exception, $this->isDebugEnabled(), str_starts_with($path, '/api/'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isDebugEnabled(): bool {
|
||||||
|
$val = getenv('DEBUG');
|
||||||
|
if ($val === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$bool = filter_var($val, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||||
|
return $bool ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeErrorResponse(?Throwable $e, bool $debug, bool $isApi): void {
|
||||||
|
static $sent = false;
|
||||||
|
if ($sent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$sent = true;
|
||||||
|
if (headers_sent()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$code = 500;
|
||||||
|
$title = 'Internal Server Error';
|
||||||
|
$message = 'Something went wrong. Please try again later.';
|
||||||
|
if ($e instanceof PDOException) {
|
||||||
|
$code = 503;
|
||||||
|
$title = 'Database Unavailable';
|
||||||
|
$message = 'Database is not available. Please try again later.';
|
||||||
|
}
|
||||||
|
@http_response_code($code);
|
||||||
|
if ($isApi) {
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
$payload = [
|
||||||
|
'ok' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'SERVER_ERROR',
|
||||||
|
'message' => $message,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
if ($debug && $e !== null) {
|
||||||
|
$payload['error']['details'] = [
|
||||||
|
'type' => get_class($e),
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
header('Content-Type: text/html; charset=utf-8');
|
||||||
|
$details = '';
|
||||||
|
if ($debug && $e !== null) {
|
||||||
|
$details = '<pre>' . htmlspecialchars($e->getMessage() . "\n\n" . $e->getTraceAsString(), ENT_QUOTES) . '</pre>';
|
||||||
|
}
|
||||||
|
echo '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">';
|
||||||
|
echo '<title>' . htmlspecialchars($title, ENT_QUOTES) . '</title></head><body style="font-family:Arial, sans-serif; padding:24px;">';
|
||||||
|
echo '<h1>' . htmlspecialchars($title, ENT_QUOTES) . '</h1>';
|
||||||
|
echo '<p>' . htmlspecialchars($message, ENT_QUOTES) . '</p>';
|
||||||
|
echo $details;
|
||||||
|
echo '</body></html>';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadEnv(string $path): void {
|
||||||
|
if (!is_file($path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
|
if (!is_array($lines)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
if ($line === '' || str_starts_with($line, '#')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$parts = explode('=', $line, 2);
|
||||||
|
if (count($parts) !== 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$key = trim($parts[0]);
|
||||||
|
if ($key === '' || getenv($key) !== false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$value = trim($parts[1]);
|
||||||
|
if ($value !== '' && ($value[0] === '"' || $value[0] === "'")) {
|
||||||
|
$quote = $value[0];
|
||||||
|
if (str_ends_with($value, $quote)) {
|
||||||
|
$value = substr($value, 1, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
putenv("{$key}={$value}");
|
||||||
|
$_ENV[$key] = $value;
|
||||||
|
$_SERVER[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/Container.php
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
// app/Container.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
final class Container {
|
||||||
|
private array $services;
|
||||||
|
|
||||||
|
public function __construct(array $services) {
|
||||||
|
$this->services = $services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $key) {
|
||||||
|
return $this->services[$key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function set(string $key, $value): void {
|
||||||
|
$this->services[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function all(): array {
|
||||||
|
return $this->services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function db() {
|
||||||
|
return $this->get('db');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function settings() {
|
||||||
|
return $this->get('settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function auth() {
|
||||||
|
return $this->get('auth');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logger() {
|
||||||
|
return $this->get('logger');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function config(): array {
|
||||||
|
return (array)($this->get('config') ?? []);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
app/bootstrap.php
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
// app/bootstrap.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if (!function_exists('load_env_file')) {
|
||||||
|
function load_env_file(string $path): void {
|
||||||
|
if (!is_file($path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
|
if (!is_array($lines)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
if ($line === '' || str_starts_with($line, '#')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = explode('=', $line, 2);
|
||||||
|
if (count($parts) !== 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = trim($parts[0]);
|
||||||
|
if ($key === '' || getenv($key) !== false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = trim($parts[1]);
|
||||||
|
if ($value !== '' && ($value[0] === '"' || $value[0] === "'")) {
|
||||||
|
$quote = $value[0];
|
||||||
|
if (str_ends_with($value, $quote)) {
|
||||||
|
$value = substr($value, 1, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
putenv("{$key}={$value}");
|
||||||
|
$_ENV[$key] = $value;
|
||||||
|
$_SERVER[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load_env_file(__DIR__ . '/../.env');
|
||||||
|
|
||||||
|
$config = require __DIR__ . '/../config/config.php';
|
||||||
|
|
||||||
|
require_once __DIR__ . '/db/Db.php';
|
||||||
|
require_once __DIR__ . '/db/SchemaTool.php';
|
||||||
|
|
||||||
|
require_once __DIR__ . '/http/Response.php';
|
||||||
|
require_once __DIR__ . '/http/Router.php';
|
||||||
|
|
||||||
|
require_once __DIR__ . '/services/SettingsService.php';
|
||||||
|
require_once __DIR__ . '/services/ScanProfilesService.php';
|
||||||
|
require_once __DIR__ . '/services/LayoutService.php';
|
||||||
|
require_once __DIR__ . '/services/PathTool.php';
|
||||||
|
require_once __DIR__ . '/services/JobsService.php';
|
||||||
|
require_once __DIR__ . '/services/ScannerService.php';
|
||||||
|
require_once __DIR__ . '/services/HttpClient.php';
|
||||||
|
require_once __DIR__ . '/services/MkvToolnixService.php';
|
||||||
|
require_once __DIR__ . '/services/MediaLibraryService.php';
|
||||||
|
require_once __DIR__ . '/services/MediaApplyService.php';
|
||||||
|
require_once __DIR__ . '/services/LogService.php';
|
||||||
|
require_once __DIR__ . '/services/ShellTool.php';
|
||||||
|
require_once __DIR__ . '/services/MetadataService.php';
|
||||||
|
require_once __DIR__ . '/services/ExportService.php';
|
||||||
|
require_once __DIR__ . '/services/TransmissionService.php';
|
||||||
|
require_once __DIR__ . '/services/SourcesService.php';
|
||||||
|
require_once __DIR__ . '/services/AuthService.php';
|
||||||
|
|
||||||
|
require_once __DIR__ . '/services/metadata/MetadataProvider.php';
|
||||||
|
require_once __DIR__ . '/services/metadata/OmdbProvider.php';
|
||||||
|
require_once __DIR__ . '/services/metadata/TvdbProvider.php';
|
||||||
|
|
||||||
|
require_once __DIR__ . '/services/export/ExporterInterface.php';
|
||||||
|
require_once __DIR__ . '/services/export/KodiExporter.php';
|
||||||
|
require_once __DIR__ . '/services/export/JellyfinExporter.php';
|
||||||
|
|
||||||
|
$db = new \ScMedia\Db\Db($config['db']['dsn'], $config['db']['user'], $config['db']['pass']);
|
||||||
|
|
||||||
|
$settings = new \ScMedia\Services\SettingsService($db, $config);
|
||||||
|
$scanProfiles = new \ScMedia\Services\ScanProfilesService($db, $settings, $config);
|
||||||
|
$logger = new \ScMedia\Services\LogService($db, $settings);
|
||||||
|
$jobs = new \ScMedia\Services\JobsService($db, $logger);
|
||||||
|
$shell = new \ScMedia\Services\ShellTool();
|
||||||
|
$http = new \ScMedia\Services\HttpClient();
|
||||||
|
$mkvtoolnix = new \ScMedia\Services\MkvToolnixService($db, $settings, $logger, $shell);
|
||||||
|
$metadataProviders = [
|
||||||
|
new \ScMedia\Services\Metadata\OmdbProvider($http, $logger),
|
||||||
|
new \ScMedia\Services\Metadata\TvdbProvider($http, $logger),
|
||||||
|
];
|
||||||
|
$metadata = new \ScMedia\Services\MetadataService($db, $settings, $metadataProviders, $logger);
|
||||||
|
$mediaLibrary = new \ScMedia\Services\MediaLibraryService($db, $settings, $metadata);
|
||||||
|
$transmission = new \ScMedia\Services\TransmissionService($http, $settings, $logger);
|
||||||
|
$sources = new \ScMedia\Services\SourcesService($transmission, $settings);
|
||||||
|
$auth = new \ScMedia\Services\AuthService($db, $config);
|
||||||
|
$exporters = [
|
||||||
|
new \ScMedia\Services\Export\KodiExporter(),
|
||||||
|
new \ScMedia\Services\Export\JellyfinExporter(),
|
||||||
|
];
|
||||||
|
$exportService = new \ScMedia\Services\ExportService($settings, $exporters);
|
||||||
|
$mediaApply = new \ScMedia\Services\MediaApplyService($mediaLibrary, $mkvtoolnix, $jobs, $metadata, $exportService);
|
||||||
|
|
||||||
|
$services = [
|
||||||
|
'db' => $db,
|
||||||
|
'config' => $config,
|
||||||
|
'schema' => new \ScMedia\Db\SchemaTool($db, __DIR__ . '/../sql/schema.sql'),
|
||||||
|
|
||||||
|
'settings' => $settings,
|
||||||
|
'scan_profiles' => $scanProfiles,
|
||||||
|
'layout' => new \ScMedia\Services\LayoutService($db, $config),
|
||||||
|
'path_tool' => new \ScMedia\Services\PathTool(),
|
||||||
|
|
||||||
|
'jobs' => $jobs,
|
||||||
|
'logger' => $logger,
|
||||||
|
'shell' => $shell,
|
||||||
|
'mkvtoolnix' => $mkvtoolnix,
|
||||||
|
'media_library' => $mediaLibrary,
|
||||||
|
'media_apply' => $mediaApply,
|
||||||
|
'metadata' => $metadata,
|
||||||
|
'export_service' => $exportService,
|
||||||
|
'transmission' => $transmission,
|
||||||
|
'sources' => $sources,
|
||||||
|
'auth' => $auth,
|
||||||
|
'scanner' => new \ScMedia\Services\ScannerService($db, $settings, $scanProfiles, $jobs, $mkvtoolnix, $mediaLibrary),
|
||||||
|
];
|
||||||
|
|
||||||
|
return $services;
|
||||||
42
app/controllers/BaseController.php
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
// app/controllers/BaseController.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use ScMedia\Http\Response;
|
||||||
|
|
||||||
|
abstract class BaseController {
|
||||||
|
protected Container $container;
|
||||||
|
|
||||||
|
public function __construct(Container $container) {
|
||||||
|
$this->container = $container;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function services(): array {
|
||||||
|
return $this->container->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function db() {
|
||||||
|
return $this->container->db();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function settings() {
|
||||||
|
return $this->container->settings();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function auth() {
|
||||||
|
return $this->container->auth();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function logger() {
|
||||||
|
return $this->container->logger();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function config(): array {
|
||||||
|
return $this->container->config();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function json($payload, int $status = 200): void {
|
||||||
|
Response::json($payload, $status);
|
||||||
|
}
|
||||||
|
}
|
||||||
265
app/controllers/account.php
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
<?php
|
||||||
|
// app/controllers/account.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use ScMedia\Http\Response;
|
||||||
|
use ScMedia\Http\Router;
|
||||||
|
use ScMedia\Services\AuthService;
|
||||||
|
|
||||||
|
final class AccountController extends BaseController {
|
||||||
|
public static function register(Router $router, Container $container): void {
|
||||||
|
$self = new self($container);
|
||||||
|
$router->add('GET', '/account', fn() => $self->page());
|
||||||
|
$router->add('GET', '/api/account', fn() => $self->getAccount());
|
||||||
|
$router->add('POST', '/api/account', fn() => $self->updateAccount());
|
||||||
|
$router->add('POST', '/api/account/email', fn() => $self->updateEmail());
|
||||||
|
$router->add('POST', '/api/account/password', fn() => $self->updatePassword());
|
||||||
|
$router->add('POST', '/api/account/avatar', fn() => $self->uploadAvatar());
|
||||||
|
$router->add('GET', '/api/account/avatar', fn() => $self->getAvatar());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function page(): void {
|
||||||
|
$i18n = $this->loadI18n(['common', 'account']);
|
||||||
|
$this->renderView(__DIR__ . '/../views/pages/account.php', $i18n);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getAccount(): void {
|
||||||
|
/** @var AuthService $auth */
|
||||||
|
$auth = $this->auth();
|
||||||
|
$user = $auth->requireAuth();
|
||||||
|
$profile = self::getProfile($this->services(), (int)$user['id']);
|
||||||
|
Response::json(['ok' => true, 'data' => $profile]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateAccount(): void {
|
||||||
|
/** @var AuthService $auth */
|
||||||
|
$auth = $this->auth();
|
||||||
|
$user = $auth->requireAuth();
|
||||||
|
$body = read_json_body();
|
||||||
|
$nickname = isset($body['nickname']) ? trim((string)$body['nickname']) : null;
|
||||||
|
$ui = is_array($body['ui'] ?? null) ? $body['ui'] : null;
|
||||||
|
self::updateProfile($this->services(), (int)$user['id'], $nickname, $ui);
|
||||||
|
Response::json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateEmail(): void {
|
||||||
|
/** @var AuthService $auth */
|
||||||
|
$auth = $this->auth();
|
||||||
|
$user = $auth->requireAuth();
|
||||||
|
$body = read_json_body();
|
||||||
|
$email = (string)($body['email'] ?? '');
|
||||||
|
$res = $auth->changeEmail((int)$user['id'], $email);
|
||||||
|
if (empty($res['ok'])) {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'EMAIL_FAILED', 'message' => $res['error'] ?? 'failed']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Response::json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updatePassword(): void {
|
||||||
|
/** @var AuthService $auth */
|
||||||
|
$auth = $this->auth();
|
||||||
|
$user = $auth->requireAuth();
|
||||||
|
$body = read_json_body();
|
||||||
|
$current = (string)($body['current'] ?? '');
|
||||||
|
$next = (string)($body['next'] ?? '');
|
||||||
|
$res = $auth->changePassword((int)$user['id'], $current, $next);
|
||||||
|
if (empty($res['ok'])) {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'PASSWORD_FAILED', 'message' => $res['error'] ?? 'failed']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Response::json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function uploadAvatar(): void {
|
||||||
|
/** @var AuthService $auth */
|
||||||
|
$auth = $this->auth();
|
||||||
|
$user = $auth->requireAuth();
|
||||||
|
if (!isset($_FILES['avatar'])) {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'FILE_REQUIRED', 'message' => 'avatar required']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$file = $_FILES['avatar'];
|
||||||
|
if (!is_array($file) || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'UPLOAD_FAILED', 'message' => 'upload failed']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$size = (int)($file['size'] ?? 0);
|
||||||
|
if ($size <= 0 || $size > 5 * 1024 * 1024) {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'FILE_TOO_LARGE', 'message' => 'max 5MB']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$tmp = (string)($file['tmp_name'] ?? '');
|
||||||
|
$mime = (string)($file['type'] ?? '');
|
||||||
|
$data = $tmp !== '' ? file_get_contents($tmp) : false;
|
||||||
|
if ($data === false) {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'READ_FAILED', 'message' => 'read failed']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self::updateAvatar($this->services(), (int)$user['id'], $mime, $data);
|
||||||
|
Response::json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getAvatar(): void {
|
||||||
|
/** @var AuthService $auth */
|
||||||
|
$auth = $this->auth();
|
||||||
|
$user = $auth->requireAuth();
|
||||||
|
$row = self::getAvatarRow($this->services(), (int)$user['id']);
|
||||||
|
if (!$row || empty($row['avatar_blob'])) {
|
||||||
|
http_response_code(404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$mime = (string)($row['avatar_mime'] ?? 'image/png');
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
echo $row['avatar_blob'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadI18n(array $packages = ['common']): array {
|
||||||
|
$settings = $this->settings();
|
||||||
|
$all = $settings->getAll();
|
||||||
|
$lang = (string)($all['general']['language'] ?? 'en');
|
||||||
|
$cookieLang = isset($_COOKIE['scmedia_lang']) ? (string)$_COOKIE['scmedia_lang'] : '';
|
||||||
|
if (preg_match('/^[a-z]{2}$/', $cookieLang)) {
|
||||||
|
$lang = $cookieLang;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dict = $this->readI18nDict($lang);
|
||||||
|
if ($lang !== 'en' && count($dict) === 0) {
|
||||||
|
$lang = 'en';
|
||||||
|
$dict = $this->readI18nDict($lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dict = $this->filterI18nPackages($dict, $packages);
|
||||||
|
$t = function(string $key, string $fallback = '') use ($dict): string {
|
||||||
|
$val = $dict[$key] ?? '';
|
||||||
|
return $val !== '' ? $val : ($fallback !== '' ? $fallback : $key);
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'lang' => $lang,
|
||||||
|
'dict' => $dict,
|
||||||
|
't' => $t,
|
||||||
|
'debugToolsEnabled' => (bool)($this->config()['app']['debug_tools_enabled'] ?? false),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderView(string $path, array $vars = []): void {
|
||||||
|
if (!is_file($path)) {
|
||||||
|
Response::json(['ok' => false, 'error' => 'View not found'], 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
extract($vars, EXTR_SKIP);
|
||||||
|
ob_start();
|
||||||
|
require $path;
|
||||||
|
$html = ob_get_clean();
|
||||||
|
Response::html($html);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readI18nDict(string $lang): array {
|
||||||
|
$path = __DIR__ . '/../../public/assets/i18n/' . basename($lang) . '.json';
|
||||||
|
if (!is_file($path)) return [];
|
||||||
|
$raw = file_get_contents($path);
|
||||||
|
if ($raw === false) return [];
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
return is_array($data) ? $data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function i18nPackages(): array {
|
||||||
|
return [
|
||||||
|
'common' => [
|
||||||
|
'prefixes' => ['common', 'nav', 'queue', 'job'],
|
||||||
|
'keys' => ['auth.logout', 'actions.theme', 'settings.language'],
|
||||||
|
],
|
||||||
|
'account' => [
|
||||||
|
'prefixes' => ['account', 'theme', 'settings.ui'],
|
||||||
|
'keys' => ['settings.language'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function filterI18nPackages(array $dict, array $packages): array {
|
||||||
|
$map = $this->i18nPackages();
|
||||||
|
$out = [];
|
||||||
|
$wanted = [];
|
||||||
|
foreach ($packages as $p) {
|
||||||
|
if (isset($map[$p])) {
|
||||||
|
$wanted[] = $map[$p];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($wanted as $pkg) {
|
||||||
|
foreach (($pkg['prefixes'] ?? []) as $prefix) {
|
||||||
|
foreach ($dict as $k => $v) {
|
||||||
|
if (str_starts_with($k, $prefix . '.')) {
|
||||||
|
$out[$k] = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (($pkg['keys'] ?? []) as $key) {
|
||||||
|
if (array_key_exists($key, $dict)) {
|
||||||
|
$out[$key] = $dict[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function getProfile(array $services, int $userId): array {
|
||||||
|
$db = $services['db'];
|
||||||
|
self::ensureProfile($db, $userId);
|
||||||
|
$user = $db->fetchOne("SELECT email FROM users WHERE id = :id", [':id' => $userId]) ?? [];
|
||||||
|
$row = $db->fetchOne("SELECT nickname, ui_prefs_json, avatar_blob FROM user_profiles WHERE user_id = :id", [':id' => $userId]) ?? [];
|
||||||
|
$ui = json_decode((string)($row['ui_prefs_json'] ?? '{}'), true);
|
||||||
|
return [
|
||||||
|
'profile' => [
|
||||||
|
'email' => (string)($user['email'] ?? ''),
|
||||||
|
'nickname' => (string)($row['nickname'] ?? ''),
|
||||||
|
'avatar_present' => !empty($row['avatar_blob']),
|
||||||
|
],
|
||||||
|
'ui' => is_array($ui) ? $ui : [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function updateProfile(array $services, int $userId, ?string $nickname, ?array $ui): void {
|
||||||
|
$db = $services['db'];
|
||||||
|
self::ensureProfile($db, $userId);
|
||||||
|
if ($nickname !== null) {
|
||||||
|
$db->exec("UPDATE user_profiles SET nickname = :n, updated_at = NOW() WHERE user_id = :id", [
|
||||||
|
':n' => $nickname,
|
||||||
|
':id' => $userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if ($ui !== null) {
|
||||||
|
$row = $db->fetchOne("SELECT ui_prefs_json FROM user_profiles WHERE user_id = :id", [':id' => $userId]) ?? [];
|
||||||
|
$current = json_decode((string)($row['ui_prefs_json'] ?? '{}'), true);
|
||||||
|
$current = is_array($current) ? $current : [];
|
||||||
|
$merged = array_replace_recursive($current, $ui);
|
||||||
|
$db->exec("UPDATE user_profiles SET ui_prefs_json = :j, updated_at = NOW() WHERE user_id = :id", [
|
||||||
|
':j' => json_encode($merged, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||||
|
':id' => $userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function updateAvatar(array $services, int $userId, string $mime, string $data): void {
|
||||||
|
$db = $services['db'];
|
||||||
|
self::ensureProfile($db, $userId);
|
||||||
|
$db->exec("UPDATE user_profiles SET avatar_blob = :b, avatar_mime = :m, updated_at = NOW() WHERE user_id = :id", [
|
||||||
|
':b' => $data,
|
||||||
|
':m' => $mime,
|
||||||
|
':id' => $userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function getAvatarRow(array $services, int $userId): ?array {
|
||||||
|
$db = $services['db'];
|
||||||
|
self::ensureProfile($db, $userId);
|
||||||
|
return $db->fetchOne("SELECT avatar_blob, avatar_mime FROM user_profiles WHERE user_id = :id", [':id' => $userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ensureProfile($db, int $userId): void {
|
||||||
|
$db->exec("INSERT IGNORE INTO user_profiles (user_id, created_at, updated_at) VALUES (:id, NOW(), NOW())", [
|
||||||
|
':id' => $userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
app/controllers/admin.php
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
// app/controllers/admin.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use ScMedia\Http\Response;
|
||||||
|
use ScMedia\Http\Router;
|
||||||
|
use ScMedia\Services\AuthService;
|
||||||
|
|
||||||
|
final class AdminController extends BaseController {
|
||||||
|
public static function register(Router $router, Container $container): void {
|
||||||
|
$self = new self($container);
|
||||||
|
$router->add('GET', '/api/admin/users', fn() => $self->listUsers());
|
||||||
|
$router->add('POST', '/api/admin/users/{id}/roles', fn($params) => $self->setRoles($params));
|
||||||
|
$router->add('POST', '/api/admin/users/{id}/disable', fn($params) => $self->disableUser($params));
|
||||||
|
$router->add('POST', '/api/admin/users/{id}/reset-2fa', fn($params) => $self->resetMfa($params));
|
||||||
|
$router->add('GET', '/api/admin/roles', fn() => $self->listRoles());
|
||||||
|
$router->add('POST', '/api/admin/audit', fn() => $self->audit());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function listUsers(): void {
|
||||||
|
/** @var AuthService $auth */
|
||||||
|
$auth = $this->auth();
|
||||||
|
$auth->requireAuth('admin');
|
||||||
|
$items = $auth->listUsers();
|
||||||
|
Response::json(['ok' => true, 'data' => ['items' => $items]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setRoles(array $params): void {
|
||||||
|
/** @var AuthService $auth */
|
||||||
|
$auth = $this->auth();
|
||||||
|
$actor = $auth->requireAuth('admin');
|
||||||
|
$userId = (int)($params['id'] ?? 0);
|
||||||
|
$body = read_json_body();
|
||||||
|
$roles = is_array($body['roles'] ?? null) ? $body['roles'] : [];
|
||||||
|
$res = $auth->setUserRoles((int)($actor['id'] ?? 0), $userId, $roles);
|
||||||
|
if (empty($res['ok'])) {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'ROLES_FAILED', 'message' => $res['error'] ?? 'failed']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Response::json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function disableUser(array $params): void {
|
||||||
|
/** @var AuthService $auth */
|
||||||
|
$auth = $this->auth();
|
||||||
|
$actor = $auth->requireAuth('admin');
|
||||||
|
$userId = (int)($params['id'] ?? 0);
|
||||||
|
$body = read_json_body();
|
||||||
|
$disabled = (bool)($body['disabled'] ?? true);
|
||||||
|
$res = $auth->disableUser((int)($actor['id'] ?? 0), $userId, $disabled);
|
||||||
|
if (empty($res['ok'])) {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'USER_DISABLE_FAILED', 'message' => $res['error'] ?? 'failed']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Response::json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resetMfa(array $params): void {
|
||||||
|
/** @var AuthService $auth */
|
||||||
|
$auth = $this->auth();
|
||||||
|
$actor = $auth->requireAuth('admin');
|
||||||
|
$userId = (int)($params['id'] ?? 0);
|
||||||
|
$res = $auth->resetUserMfa((int)($actor['id'] ?? 0), $userId);
|
||||||
|
if (empty($res['ok'])) {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'MFA_RESET_FAILED', 'message' => $res['error'] ?? 'failed']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Response::json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function listRoles(): void {
|
||||||
|
/** @var AuthService $auth */
|
||||||
|
$auth = $this->auth();
|
||||||
|
$auth->requireAuth('admin');
|
||||||
|
$items = $auth->listRoles();
|
||||||
|
Response::json(['ok' => true, 'data' => ['items' => $items]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function audit(): void {
|
||||||
|
/** @var AuthService $auth */
|
||||||
|
$auth = $this->auth();
|
||||||
|
$auth->requireAuth('admin');
|
||||||
|
[$page, $perPage, $sort, $dir, $filters, $params] = table_body_params(50, 200);
|
||||||
|
$from = '';
|
||||||
|
$to = '';
|
||||||
|
$action = '';
|
||||||
|
$rangeValue = filter_value($filters, 'created_at');
|
||||||
|
$rangeOp = filter_op($filters, 'created_at');
|
||||||
|
if ($rangeOp === 'between' && is_array($rangeValue)) {
|
||||||
|
$from = (string)($rangeValue[0] ?? '');
|
||||||
|
$to = (string)($rangeValue[1] ?? '');
|
||||||
|
}
|
||||||
|
$actionValue = filter_value($filters, 'action');
|
||||||
|
if (is_string($actionValue)) {
|
||||||
|
$action = $actionValue;
|
||||||
|
}
|
||||||
|
$res = $auth->listAuditFiltered($page, $perPage, $from, $to, $action);
|
||||||
|
Response::json(['ok' => true, 'data' => [
|
||||||
|
'items' => $res['items'],
|
||||||
|
'total' => $res['total'],
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
236
app/controllers/auth.php
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
<?php
|
||||||
|
// app/controllers/auth.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use ScMedia\Http\Response;
|
||||||
|
use ScMedia\Http\Router;
|
||||||
|
use ScMedia\Services\AuthService;
|
||||||
|
|
||||||
|
final class AuthController extends BaseController {
|
||||||
|
public static function register(Router $router, Container $container): void {
|
||||||
|
$self = new self($container);
|
||||||
|
$router->add('POST', '/api/auth/register', fn() => $self->registerUser());
|
||||||
|
$router->add('POST', '/api/auth/login', fn() => $self->login());
|
||||||
|
$router->add('POST', '/api/auth/2fa/verify', fn() => $self->verifyMfa());
|
||||||
|
$router->add('POST', '/api/auth/refresh', fn() => $self->refresh());
|
||||||
|
$router->add('POST', '/api/auth/logout', fn() => $self->logout());
|
||||||
|
$router->add('POST', '/api/auth/password/forgot', fn() => $self->passwordForgot());
|
||||||
|
$router->add('POST', '/api/auth/password/reset', fn() => $self->passwordReset());
|
||||||
|
$router->add('POST', '/api/auth/2fa/setup', fn() => $self->setupMfa());
|
||||||
|
$router->add('POST', '/api/auth/2fa/confirm', fn() => $self->confirmMfa());
|
||||||
|
$router->add('POST', '/api/auth/2fa/disable', fn() => $self->disableMfa());
|
||||||
|
$router->add('POST', '/api/auth/sse-key', fn() => $self->issueSseKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function registerUser(): void {
|
||||||
|
/** @var AuthService $auth */
|
||||||
|
$auth = $this->auth();
|
||||||
|
$body = read_json_body();
|
||||||
|
$email = (string)($body['email'] ?? '');
|
||||||
|
$password = (string)($body['password'] ?? '');
|
||||||
|
$res = $auth->register($email, $password);
|
||||||
|
if (empty($res['ok'])) {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'REGISTER_FAILED', 'message' => $res['error'] ?? 'failed']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Response::json(['ok' => true, 'data' => ['user_id' => $res['user_id'], 'role' => $res['role']]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function login(): void {
|
||||||
|
/** @var AuthService $auth */
|
||||||
|
$auth = $this->auth();
|
||||||
|
$body = read_json_body();
|
||||||
|
$email = (string)($body['email'] ?? '');
|
||||||
|
$password = (string)($body['password'] ?? '');
|
||||||
|
$clientType = (string)($body['client_type'] ?? 'web');
|
||||||
|
$deviceId = (string)($body['device_id'] ?? '');
|
||||||
|
$res = $auth->login($email, $password, $clientType, $deviceId);
|
||||||
|
if (empty($res['ok'])) {
|
||||||
|
$err = ['code' => 'LOGIN_FAILED', 'message' => $res['error'] ?? 'failed'];
|
||||||
|
$status = 401;
|
||||||
|
if (!empty($res['retry_after'])) {
|
||||||
|
$err['retry_after'] = (int)$res['retry_after'];
|
||||||
|
$status = 429;
|
||||||
|
}
|
||||||
|
Response::json(['ok' => false, 'error' => $err], $status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!empty($res['mfa_required'])) {
|
||||||
|
Response::json(['ok' => true, 'data' => [
|
||||||
|
'mfa_required' => true,
|
||||||
|
'challenge_token' => $res['challenge_token'] ?? '',
|
||||||
|
]]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$tokens = $res['tokens'] ?? [];
|
||||||
|
if (!empty($tokens['access_token'])) {
|
||||||
|
setcookie('access_token', $tokens['access_token'], [
|
||||||
|
'expires' => (int)($tokens['access_expires_at'] ?? 0),
|
||||||
|
'path' => '/',
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
Response::json(['ok' => true, 'data' => $tokens]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function verifyMfa(): void {
|
||||||
|
/** @var AuthService $auth */
|
||||||
|
$auth = $this->auth();
|
||||||
|
$body = read_json_body();
|
||||||
|
$challenge = (string)($body['challenge_token'] ?? '');
|
||||||
|
$code = (string)($body['code'] ?? '');
|
||||||
|
$clientType = (string)($body['client_type'] ?? 'web');
|
||||||
|
$deviceId = (string)($body['device_id'] ?? '');
|
||||||
|
$res = $auth->verifyMfa($challenge, $code, $clientType, $deviceId);
|
||||||
|
if (empty($res['ok'])) {
|
||||||
|
$err = ['code' => 'MFA_FAILED', 'message' => $res['error'] ?? 'failed'];
|
||||||
|
$status = 401;
|
||||||
|
if (!empty($res['retry_after'])) {
|
||||||
|
$err['retry_after'] = (int)$res['retry_after'];
|
||||||
|
$status = 429;
|
||||||
|
}
|
||||||
|
Response::json(['ok' => false, 'error' => $err], $status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$tokens = $res['tokens'] ?? [];
|
||||||
|
if (!empty($tokens['access_token'])) {
|
||||||
|
setcookie('access_token', $tokens['access_token'], [
|
||||||
|
'expires' => (int)($tokens['access_expires_at'] ?? 0),
|
||||||
|
'path' => '/',
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
Response::json(['ok' => true, 'data' => $tokens]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function refresh(): void {
|
||||||
|
/** @var AuthService $auth */
|
||||||
|
$auth = $this->auth();
|
||||||
|
$body = read_json_body();
|
||||||
|
$refresh = (string)($body['refresh_token'] ?? '');
|
||||||
|
$clientType = (string)($body['client_type'] ?? 'web');
|
||||||
|
$deviceId = (string)($body['device_id'] ?? '');
|
||||||
|
$res = $auth->refresh($refresh, $clientType, $deviceId);
|
||||||
|
if (empty($res['ok'])) {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'REFRESH_FAILED', 'message' => $res['error'] ?? 'failed']], 401);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$tokens = $res['tokens'] ?? [];
|
||||||
|
if (!empty($tokens['access_token'])) {
|
||||||
|
setcookie('access_token', $tokens['access_token'], [
|
||||||
|
'expires' => (int)($tokens['access_expires_at'] ?? 0),
|
||||||
|
'path' => '/',
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
Response::json(['ok' => true, 'data' => $tokens]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logout(): void {
|
||||||
|
/** @var AuthService $auth */
|
||||||
|
$auth = $this->auth();
|
||||||
|
$body = read_json_body();
|
||||||
|
$refresh = (string)($body['refresh_token'] ?? '');
|
||||||
|
if ($refresh !== '') {
|
||||||
|
$auth->logout($refresh);
|
||||||
|
} else {
|
||||||
|
$token = (string)($_COOKIE['access_token'] ?? '');
|
||||||
|
$auth->logoutByAccessToken($token);
|
||||||
|
}
|
||||||
|
setcookie('access_token', '', [
|
||||||
|
'expires' => time() - 3600,
|
||||||
|
'path' => '/',
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
setcookie('sse_key', '', [
|
||||||
|
'expires' => time() - 3600,
|
||||||
|
'path' => '/',
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
Response::json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function passwordForgot(): void {
|
||||||
|
/** @var AuthService $auth */
|
||||||
|
$auth = $this->auth();
|
||||||
|
$body = read_json_body();
|
||||||
|
$email = (string)($body['email'] ?? '');
|
||||||
|
$res = $auth->passwordForgot($email);
|
||||||
|
$data = $res;
|
||||||
|
unset($data['ok']);
|
||||||
|
Response::json(['ok' => true, 'data' => $data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function passwordReset(): void {
|
||||||
|
/** @var AuthService $auth */
|
||||||
|
$auth = $this->auth();
|
||||||
|
$body = read_json_body();
|
||||||
|
$token = (string)($body['token'] ?? '');
|
||||||
|
$password = (string)($body['password'] ?? '');
|
||||||
|
$res = $auth->passwordReset($token, $password);
|
||||||
|
if (empty($res['ok'])) {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'RESET_FAILED', 'message' => $res['error'] ?? 'failed']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Response::json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setupMfa(): void {
|
||||||
|
/** @var AuthService $auth */
|
||||||
|
$auth = $this->auth();
|
||||||
|
$user = $auth->requireAuth();
|
||||||
|
$res = $auth->setupMfa((int)$user['id']);
|
||||||
|
if (empty($res['ok'])) {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'MFA_SETUP_FAILED', 'message' => $res['error'] ?? 'failed']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Response::json(['ok' => true, 'data' => $res]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function confirmMfa(): void {
|
||||||
|
/** @var AuthService $auth */
|
||||||
|
$auth = $this->auth();
|
||||||
|
$user = $auth->requireAuth();
|
||||||
|
$body = read_json_body();
|
||||||
|
$code = (string)($body['code'] ?? '');
|
||||||
|
$res = $auth->confirmMfa((int)$user['id'], $code);
|
||||||
|
if (empty($res['ok'])) {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'MFA_CONFIRM_FAILED', 'message' => $res['error'] ?? 'failed']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Response::json(['ok' => true, 'data' => $res]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function disableMfa(): void {
|
||||||
|
/** @var AuthService $auth */
|
||||||
|
$auth = $this->auth();
|
||||||
|
$user = $auth->requireAuth();
|
||||||
|
$res = $auth->disableMfa((int)$user['id']);
|
||||||
|
if (empty($res['ok'])) {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'MFA_DISABLE_FAILED', 'message' => $res['error'] ?? 'failed']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Response::json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function issueSseKey(): void {
|
||||||
|
/** @var AuthService $auth */
|
||||||
|
$auth = $this->auth();
|
||||||
|
$user = $auth->requireAuth();
|
||||||
|
$key = $auth->issueSseKey((int)$user['id']);
|
||||||
|
$ttl = (int)($key['expires_in'] ?? 60);
|
||||||
|
setcookie('sse_key', $key['key'], [
|
||||||
|
'expires' => time() + max(10, $ttl),
|
||||||
|
'path' => '/',
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
Response::json(['ok' => true, 'data' => $key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
214
app/controllers/debug.php
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
<?php
|
||||||
|
// app/controllers/debug.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use ScMedia\Http\Response;
|
||||||
|
use ScMedia\Http\Router;
|
||||||
|
|
||||||
|
final class DebugController extends BaseController {
|
||||||
|
public static function register(Router $router, Container $container): void {
|
||||||
|
$self = new self($container);
|
||||||
|
$router->add('POST', '/api/debug/event', fn() => $self->debugEvent());
|
||||||
|
$router->add('GET', '/api/debug/stats', fn() => $self->stats());
|
||||||
|
$router->add('GET', '/api/debug/db-tables', fn() => $self->dbTables());
|
||||||
|
$router->add('GET', '/api/debug/db-table', fn() => $self->dbTable());
|
||||||
|
$router->add('POST', '/api/debug/clear-content', fn() => $self->clearContent());
|
||||||
|
$router->add('POST', '/api/debug/clear-index', fn() => $self->clearIndex());
|
||||||
|
$router->add('POST', '/api/debug/reset-db', fn() => $self->resetDb());
|
||||||
|
$router->add('GET', '/api/debug/db-dump', fn() => $self->dbDump());
|
||||||
|
$router->add('POST', '/api/debug/db-restore', fn() => $self->dbRestore());
|
||||||
|
$router->add('GET', '/api/debug/mkvmerge-check', fn() => $self->mkvmergeCheck());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function debugEvent(): void {
|
||||||
|
require_debug($this->config(), false);
|
||||||
|
$logger = $this->logger();
|
||||||
|
$body = read_json_body();
|
||||||
|
$name = (string)($body['name'] ?? 'debug');
|
||||||
|
$payload = is_array($body['payload'] ?? null) ? $body['payload'] : [];
|
||||||
|
$logger->log('debug', 'Debug event', ['name' => $name, 'payload' => $payload]);
|
||||||
|
Response::json(['ok' => true, 'data' => ['name' => $name]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stats(): void {
|
||||||
|
require_debug($this->config(), false);
|
||||||
|
$logger = $this->logger();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$schema = $this->services()['schema'];
|
||||||
|
$content = $schema->getContentStats();
|
||||||
|
$db = $schema->getDbStats();
|
||||||
|
$dbInfo = $schema->getDbInfo();
|
||||||
|
Response::json(['ok' => true, 'data' => ['content' => $content, 'db' => $db, 'db_info' => $dbInfo]]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$logger->log('error', 'Debug stats failed', ['error' => $e->getMessage()]);
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'STATS_FAILED', 'message' => $e->getMessage()]], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dbTables(): void {
|
||||||
|
require_debug($this->config(), false);
|
||||||
|
$logger = $this->logger();
|
||||||
|
try {
|
||||||
|
$items = $this->services()['schema']->listTablesWithCounts();
|
||||||
|
Response::json(['ok' => true, 'data' => ['items' => $items]]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$logger->log('error', 'Debug db tables failed', ['error' => $e->getMessage()]);
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'DB_TABLES_FAILED', 'message' => $e->getMessage()]], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dbTable(): void {
|
||||||
|
require_debug($this->config(), false);
|
||||||
|
$logger = $this->logger();
|
||||||
|
$name = (string)($_GET['name'] ?? '');
|
||||||
|
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 50;
|
||||||
|
try {
|
||||||
|
$data = $this->services()['schema']->fetchTableRows($name, $limit);
|
||||||
|
Response::json(['ok' => true, 'data' => $data]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$logger->log('error', 'Debug db table preview failed', ['error' => $e->getMessage()]);
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'DB_TABLE_FAILED', 'message' => $e->getMessage()]], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clearContent(): void {
|
||||||
|
require_debug($this->config(), false);
|
||||||
|
$logger = $this->logger();
|
||||||
|
|
||||||
|
$body = read_json_body();
|
||||||
|
if ((string)($body['confirm'] ?? '') !== 'CLEAR CONTENT') {
|
||||||
|
$logger->log('warn', 'Clear content denied: bad confirmation');
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'CONFIRM_REQUIRED', 'message' => 'Type CLEAR CONTENT']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->services()['schema']->clearContent();
|
||||||
|
$logger->log('warn', 'Content cleared');
|
||||||
|
Response::json(['ok' => true, 'data' => ['reload' => true]]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$logger->log('error', 'Clear content failed', ['error' => $e->getMessage()]);
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'CLEAR_FAILED', 'message' => $e->getMessage()]], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clearIndex(): void {
|
||||||
|
require_debug($this->config(), false);
|
||||||
|
$logger = $this->logger();
|
||||||
|
|
||||||
|
$body = read_json_body();
|
||||||
|
if ((string)($body['confirm'] ?? '') !== 'CLEAR') {
|
||||||
|
$logger->log('warn', 'Clear index denied: bad confirmation');
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'CONFIRM_REQUIRED', 'message' => 'Type CLEAR']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->services()['schema']->clearIndex();
|
||||||
|
$logger->log('info', 'Index cleared');
|
||||||
|
Response::json(['ok' => true]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$logger->log('error', 'Clear index failed', ['error' => $e->getMessage()]);
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'CLEAR_FAILED', 'message' => $e->getMessage()]], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resetDb(): void {
|
||||||
|
require_debug($this->config(), true);
|
||||||
|
$logger = $this->logger();
|
||||||
|
|
||||||
|
$body = read_json_body();
|
||||||
|
if ((string)($body['confirm'] ?? '') !== 'RESET DATABASE') {
|
||||||
|
$logger->log('warn', 'Reset DB denied: bad confirmation');
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'CONFIRM_REQUIRED', 'message' => 'Type RESET DATABASE']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$log = $this->services()['schema']->resetDatabase();
|
||||||
|
$logger->log('warn', 'Database reset', ['log' => $log]);
|
||||||
|
Response::json(['ok' => true, 'data' => ['log' => $log, 'reload' => true]]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$logger->log('error', 'Reset DB failed', ['error' => $e->getMessage()]);
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'RESET_FAILED', 'message' => $e->getMessage()]], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dbDump(): void {
|
||||||
|
require_debug($this->config(), false);
|
||||||
|
$logger = $this->logger();
|
||||||
|
try {
|
||||||
|
$sql = $this->services()['schema']->dumpDatabase();
|
||||||
|
$filename = 'scmedia_dump_' . date('Ymd_His') . '.sql';
|
||||||
|
header('Content-Type: application/sql; charset=utf-8');
|
||||||
|
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||||
|
echo $sql;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$logger->log('error', 'DB dump failed', ['error' => $e->getMessage()]);
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'DUMP_FAILED', 'message' => $e->getMessage()]], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dbRestore(): void {
|
||||||
|
require_debug($this->config(), true);
|
||||||
|
$logger = $this->logger();
|
||||||
|
|
||||||
|
$body = read_json_body();
|
||||||
|
if ((string)($body['confirm'] ?? '') !== 'RESTORE DATABASE') {
|
||||||
|
$logger->log('warn', 'Restore DB denied: bad confirmation');
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'CONFIRM_REQUIRED', 'message' => 'Type RESTORE DATABASE']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$sql = (string)($body['sql'] ?? '');
|
||||||
|
if (trim($sql) === '') {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'SQL_REQUIRED', 'message' => 'SQL required']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$log = $this->services()['schema']->restoreDatabaseFromSql($sql);
|
||||||
|
$logger->log('warn', 'Database restored', ['log' => $log]);
|
||||||
|
Response::json(['ok' => true, 'data' => ['log' => $log, 'reload' => true]]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$logger->log('error', 'Restore DB failed', ['error' => $e->getMessage()]);
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'RESTORE_FAILED', 'message' => $e->getMessage()]], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mkvmergeCheck(): void {
|
||||||
|
require_debug($this->config(), false);
|
||||||
|
$logger = $this->logger();
|
||||||
|
$path = (string)($_GET['path'] ?? '');
|
||||||
|
if ($path === '') {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'PATH_REQUIRED', 'message' => 'path required']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$shell = $this->services()['shell'] ?? null;
|
||||||
|
$bin = ($shell instanceof \ScMedia\Services\ShellTool) ? ($shell->which('mkvmerge') ?? '') : '';
|
||||||
|
if ($bin === '') {
|
||||||
|
$logger->log('warn', 'mkvmerge check failed: binary not found');
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'NO_MKVMERGE', 'message' => 'mkvmerge not found']]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cmdBase = $bin . ' -J ' . escapeshellarg($path) . ' 2>/dev/null';
|
||||||
|
$out = ($shell instanceof \ScMedia\Services\ShellTool) ? $shell->exec($cmdBase) : null;
|
||||||
|
if (!is_string($out) || $out === '') {
|
||||||
|
$logger->log('warn', 'mkvmerge check returned empty output', ['path' => $path]);
|
||||||
|
Response::json(['ok' => true, 'data' => ['bin' => $bin, 'output' => null]]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = json_decode($out, true);
|
||||||
|
if (!is_array($json)) {
|
||||||
|
$logger->log('warn', 'mkvmerge check failed: bad json', ['path' => $path]);
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'BAD_JSON', 'message' => 'invalid mkvmerge json']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::json(['ok' => true, 'data' => ['bin' => $bin, 'output' => $json]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
353
app/controllers/events.php
Normal file
@ -0,0 +1,353 @@
|
|||||||
|
<?php
|
||||||
|
// app/controllers/events.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use ScMedia\Http\Router;
|
||||||
|
|
||||||
|
final class EventsController extends BaseController {
|
||||||
|
private \ScMedia\Services\LogService $logger;
|
||||||
|
private \ScMedia\Services\AuthService $auth;
|
||||||
|
|
||||||
|
private float $sourcesNextAt = 0.0;
|
||||||
|
private float $sourcesInterval = 5.0;
|
||||||
|
private array $sourcesLastMap = [];
|
||||||
|
private float $sourcesSnapshotAt = 0.0;
|
||||||
|
private float $jobsNextAt = 0.0;
|
||||||
|
private float $jobsInterval = 1.0;
|
||||||
|
private string $jobsLastHash = '';
|
||||||
|
private float $jobsSnapshotAt = 0.0;
|
||||||
|
private float $jobsSnapshotInterval = 5.0;
|
||||||
|
private float $keepAliveNextAt = 0.0;
|
||||||
|
private float $keepAliveInterval = 2.0;
|
||||||
|
private float $authCheckNextAt = 0.0;
|
||||||
|
private float $authCheckInterval = 0.5;
|
||||||
|
private float $tickNextAt = 0.0;
|
||||||
|
private float $tickInterval = 10.0;
|
||||||
|
private float $snapshotInterval = 120.0;
|
||||||
|
|
||||||
|
public function __construct(Container $container) {
|
||||||
|
parent::__construct($container);
|
||||||
|
$this->logger = $container->logger();
|
||||||
|
$this->auth = $container->auth();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function register(Router $router, Container $container): void {
|
||||||
|
$router->add('GET', '/api/events', function() use ($container) {
|
||||||
|
(new self($container))->handle();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void {
|
||||||
|
$userId = $this->readUserId();
|
||||||
|
if (!$userId) {
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo "Unauthorized";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessionToken = $this->auth->startSseSession($userId);
|
||||||
|
$this->initSseHeaders();
|
||||||
|
$this->sendReady();
|
||||||
|
|
||||||
|
$this->logger->log('debug', 'SSE connected', [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'session' => $sessionToken,
|
||||||
|
'ip' => $_SERVER['REMOTE_ADDR'] ?? null,
|
||||||
|
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$start = time();
|
||||||
|
$maxSeconds = 3600;
|
||||||
|
|
||||||
|
$bg = $this->settings()->getAll()['background'] ?? [];
|
||||||
|
$this->auth->setSseSessionTtl((int)($bg['sse_session_ttl_seconds'] ?? 20));
|
||||||
|
|
||||||
|
$exitReason = 'max_seconds';
|
||||||
|
while ((time() - $start) < $maxSeconds) {
|
||||||
|
$now = microtime(true);
|
||||||
|
try {
|
||||||
|
if (connection_aborted()) {
|
||||||
|
$this->logger->log('debug', 'SSE client aborted', [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'session' => $sessionToken,
|
||||||
|
]);
|
||||||
|
$exitReason = 'client_aborted';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$this->sendKeepalive($now);
|
||||||
|
if (!$this->checkSession($now, $userId, $sessionToken)) {
|
||||||
|
$exitReason = 'superseded';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$this->maybeUpdateTick($now);
|
||||||
|
$this->sendJobs($now);
|
||||||
|
$this->sendSources($now);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->logger->log('error', 'SSE loop error', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'file' => $e->getFile(),
|
||||||
|
'line' => $e->getLine(),
|
||||||
|
]);
|
||||||
|
$exitReason = 'loop_error';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
usleep(300000);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->log('debug', 'SSE closed', [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'session' => $sessionToken,
|
||||||
|
'reason' => $exitReason,
|
||||||
|
'ip' => $_SERVER['REMOTE_ADDR'] ?? null,
|
||||||
|
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and validate SSE key.
|
||||||
|
private function readUserId(): ?int {
|
||||||
|
$key = isset($_COOKIE['sse_key']) ? (string)$_COOKIE['sse_key'] : '';
|
||||||
|
$key = trim($key, "\"'");
|
||||||
|
$key = urldecode($key);
|
||||||
|
$userId = $this->auth->validateSseKey($key);
|
||||||
|
if (!$userId) {
|
||||||
|
$this->logger->log('warn', 'SSE auth failed', [
|
||||||
|
'ip' => $_SERVER['REMOTE_ADDR'] ?? null,
|
||||||
|
'has_cookie' => $key !== '',
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize SSE headers and buffers.
|
||||||
|
private function initSseHeaders(): void {
|
||||||
|
@set_time_limit(0);
|
||||||
|
@ignore_user_abort(true);
|
||||||
|
header('Content-Type: text/event-stream; charset=utf-8');
|
||||||
|
header('Cache-Control: no-cache');
|
||||||
|
header('Connection: keep-alive');
|
||||||
|
header('X-Accel-Buffering: no');
|
||||||
|
|
||||||
|
@ini_set('output_buffering', 'off');
|
||||||
|
@ini_set('zlib.output_compression', '0');
|
||||||
|
while (ob_get_level() > 0) {
|
||||||
|
ob_end_flush();
|
||||||
|
}
|
||||||
|
@ob_implicit_flush(true);
|
||||||
|
@flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send initial SSE meta events.
|
||||||
|
private function sendReady(): void {
|
||||||
|
echo "retry: 5000\n";
|
||||||
|
echo "event: ready\n";
|
||||||
|
echo "data: {}\n\n";
|
||||||
|
@flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit keepalive comments.
|
||||||
|
private function sendKeepalive(float $now): void {
|
||||||
|
if ($now < $this->keepAliveNextAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
echo ": keepalive\n\n";
|
||||||
|
$this->keepAliveNextAt = $now + $this->keepAliveInterval;
|
||||||
|
if (ob_get_level() > 0) {
|
||||||
|
@ob_flush();
|
||||||
|
}
|
||||||
|
@flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate session and refresh heartbeat.
|
||||||
|
private function checkSession(float $now, int $userId, string $sessionToken): bool {
|
||||||
|
if ($now < $this->authCheckNextAt) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!$this->auth->isSseSessionActive($userId, $sessionToken)) {
|
||||||
|
$this->logger->log('debug', 'SSE superseded', [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'session' => $sessionToken,
|
||||||
|
'ip' => $_SERVER['REMOTE_ADDR'] ?? null,
|
||||||
|
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? null,
|
||||||
|
]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$this->auth->touchSseSession($userId, $sessionToken);
|
||||||
|
$this->authCheckNextAt = $now + $this->authCheckInterval;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tick/snapshot/keepalive intervals and emit tick.
|
||||||
|
private function maybeUpdateTick(float $now): void {
|
||||||
|
if ($now < $this->tickNextAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$ui = $this->settings()->getAll()['ui'] ?? [];
|
||||||
|
$bg = $this->settings()->getAll()['background'] ?? [];
|
||||||
|
$tickSeconds = (int)($ui['sse_tick_seconds'] ?? 10);
|
||||||
|
$snapSeconds = (int)($ui['sse_snapshot_seconds'] ?? 120);
|
||||||
|
$keepAliveSeconds = (float)($ui['sse_keepalive_seconds'] ?? 2);
|
||||||
|
$this->auth->setSseSessionTtl((int)($bg['sse_session_ttl_seconds'] ?? 20));
|
||||||
|
$this->tickInterval = max(1.0, (float)$tickSeconds);
|
||||||
|
$this->snapshotInterval = max(10.0, (float)$snapSeconds);
|
||||||
|
$this->jobsSnapshotInterval = max(2.0, $this->tickInterval);
|
||||||
|
$this->keepAliveInterval = max(1.0, min(5.0, $keepAliveSeconds));
|
||||||
|
$this->tickNextAt = $now + $this->tickInterval;
|
||||||
|
echo "event: tick\n";
|
||||||
|
echo "data: " . json_encode(['ts' => time()], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n\n";
|
||||||
|
if (ob_get_level() > 0) {
|
||||||
|
@ob_flush();
|
||||||
|
}
|
||||||
|
@flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit jobs payload (if changed/snapshot).
|
||||||
|
private function sendJobs(float $now): void {
|
||||||
|
if ($now < $this->jobsNextAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$db = $this->db();
|
||||||
|
$running = $db->fetchOne("SELECT COUNT(*) AS c FROM jobs WHERE status='running'");
|
||||||
|
$queued = $db->fetchOne("SELECT COUNT(*) AS c FROM jobs WHERE status='queued'");
|
||||||
|
$errors = $db->fetchOne("SELECT COUNT(*) AS c FROM jobs WHERE status='error'");
|
||||||
|
$active = $db->fetchOne("
|
||||||
|
SELECT id, type, title, status, progress_current, progress_total, progress_pct, cancel_requested
|
||||||
|
FROM jobs
|
||||||
|
WHERE status='running'
|
||||||
|
ORDER BY started_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
");
|
||||||
|
$bg = $this->settings()->getAll()['background'] ?? [];
|
||||||
|
|
||||||
|
$activeList = $db->fetchAll("
|
||||||
|
SELECT id, type, title, status, progress_current, progress_total, progress_pct, cancel_requested, started_at
|
||||||
|
FROM jobs
|
||||||
|
WHERE status='running'
|
||||||
|
ORDER BY started_at ASC
|
||||||
|
LIMIT 50
|
||||||
|
");
|
||||||
|
$activeList = array_map(function($row) {
|
||||||
|
return [
|
||||||
|
'id' => (int)$row['id'],
|
||||||
|
'type' => (string)$row['type'],
|
||||||
|
'title' => (string)$row['title'],
|
||||||
|
'status' => (string)$row['status'],
|
||||||
|
'progress' => (int)($row['progress_current'] ?? 0),
|
||||||
|
'progress_total' => (int)($row['progress_total'] ?? 0),
|
||||||
|
'progress_pct' => (int)($row['progress_pct'] ?? 0),
|
||||||
|
'cancel_requested' => !empty($row['cancel_requested']),
|
||||||
|
'started_at' => $row['started_at'] ?? null,
|
||||||
|
];
|
||||||
|
}, $activeList);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'paused' => !empty($bg['paused']),
|
||||||
|
'running' => (int)($running['c'] ?? 0),
|
||||||
|
'queued' => (int)($queued['c'] ?? 0),
|
||||||
|
'errors' => (int)($errors['c'] ?? 0),
|
||||||
|
'active' => $active ? [
|
||||||
|
'id' => (int)$active['id'],
|
||||||
|
'type' => (string)$active['type'],
|
||||||
|
'title' => (string)$active['title'],
|
||||||
|
'status' => (string)$active['status'],
|
||||||
|
'progress' => (int)($active['progress_current'] ?? 0),
|
||||||
|
'progress_total' => (int)($active['progress_total'] ?? 0),
|
||||||
|
'progress_pct' => (int)($active['progress_pct'] ?? 0),
|
||||||
|
'cancel_requested' => !empty($active['cancel_requested']),
|
||||||
|
] : null,
|
||||||
|
'active_list' => $activeList,
|
||||||
|
];
|
||||||
|
|
||||||
|
$hash = md5(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||||
|
$changed = $hash !== $this->jobsLastHash;
|
||||||
|
$isSnapshot = $now >= $this->jobsSnapshotAt || $this->jobsSnapshotAt === 0.0;
|
||||||
|
if ($changed || $isSnapshot) {
|
||||||
|
$this->jobsLastHash = $hash;
|
||||||
|
if ($isSnapshot) {
|
||||||
|
$this->jobsSnapshotAt = $now + $this->jobsSnapshotInterval;
|
||||||
|
}
|
||||||
|
echo "event: jobs\n";
|
||||||
|
echo "data: " . json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n\n";
|
||||||
|
}
|
||||||
|
$this->jobsNextAt = $now + $this->jobsInterval;
|
||||||
|
if (ob_get_level() > 0) {
|
||||||
|
@ob_flush();
|
||||||
|
}
|
||||||
|
@flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit sources payload and adjust interval.
|
||||||
|
private function sendSources(float $now): void {
|
||||||
|
if ($now < $this->sourcesNextAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$sourcesService = $this->services()['sources'] ?? null;
|
||||||
|
if (!$sourcesService instanceof \ScMedia\Services\SourcesService) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$elapsed = 0.0;
|
||||||
|
$items = [];
|
||||||
|
try {
|
||||||
|
$t0 = microtime(true);
|
||||||
|
$items = $sourcesService->list();
|
||||||
|
$elapsed = microtime(true) - $t0;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->logger->log('warn', 'SSE sources failed', ['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
foreach ($items as $it) {
|
||||||
|
$key = (string)($it['source'] ?? '') . ':' . (string)($it['id'] ?? '');
|
||||||
|
if ($key === ':') continue;
|
||||||
|
$map[$key] = $it;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changed = [];
|
||||||
|
foreach ($map as $key => $it) {
|
||||||
|
if (!isset($this->sourcesLastMap[$key])) {
|
||||||
|
$changed[] = $it;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$prev = $this->sourcesLastMap[$key];
|
||||||
|
if (($prev['status'] ?? null) !== ($it['status'] ?? null)
|
||||||
|
|| ($prev['percent_done'] ?? null) !== ($it['percent_done'] ?? null)
|
||||||
|
|| ($prev['size_bytes'] ?? null) !== ($it['size_bytes'] ?? null)
|
||||||
|
|| ($prev['name'] ?? null) !== ($it['name'] ?? null)
|
||||||
|
|| ($prev['content_type'] ?? null) !== ($it['content_type'] ?? null)
|
||||||
|
|| ($prev['file_count'] ?? null) !== ($it['file_count'] ?? null)) {
|
||||||
|
$changed[] = $it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$removed = array_values(array_diff(array_keys($this->sourcesLastMap), array_keys($map)));
|
||||||
|
|
||||||
|
$isSnapshot = $now >= $this->sourcesSnapshotAt || $this->sourcesSnapshotAt === 0.0;
|
||||||
|
if ($isSnapshot) {
|
||||||
|
$this->sourcesSnapshotAt = $now + $this->snapshotInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isSnapshot || count($changed) > 0 || count($removed) > 0) {
|
||||||
|
echo "event: sources\n";
|
||||||
|
echo "data: " . json_encode([
|
||||||
|
'items' => $isSnapshot ? array_values($items) : $changed,
|
||||||
|
'removed' => $removed,
|
||||||
|
'snapshot' => $isSnapshot,
|
||||||
|
'ts' => time(),
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sourcesLastMap = $map;
|
||||||
|
$cfg = $this->settings()->getAll()['sources']['transmission'] ?? [];
|
||||||
|
$host = (string)($cfg['host'] ?? '');
|
||||||
|
$isLocal = in_array($host, ['127.0.0.1', 'localhost', '::1'], true);
|
||||||
|
$base = $isLocal ? 2.5 : 6.0;
|
||||||
|
$penalty = max(0.0, $elapsed - 0.4) * 2.0;
|
||||||
|
$this->sourcesInterval = min(12.0, max(1.0, $base + $penalty));
|
||||||
|
$this->sourcesNextAt = $now + $this->sourcesInterval;
|
||||||
|
if (ob_get_level() > 0) {
|
||||||
|
@ob_flush();
|
||||||
|
}
|
||||||
|
@flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
112
app/controllers/items.php
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
// app/controllers/items.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use ScMedia\Http\Router;
|
||||||
|
use ScMedia\Services\JobsService;
|
||||||
|
use ScMedia\Services\ScannerService;
|
||||||
|
|
||||||
|
final class ItemsController extends BaseController {
|
||||||
|
public static function register(Router $router, Container $container): void {
|
||||||
|
$self = new self($container);
|
||||||
|
$router->add('GET', '/api/items', fn() => $self->listItems());
|
||||||
|
$router->add('POST', '/api/items/scan', fn() => $self->scanItems());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function listItems(): void {
|
||||||
|
$db = $this->db();
|
||||||
|
$status = $_GET['status'] ?? '';
|
||||||
|
|
||||||
|
$where = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($status !== '') {
|
||||||
|
$where[] = 'i.status = :status';
|
||||||
|
$params[':status'] = $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT
|
||||||
|
i.id,
|
||||||
|
i.scan_profile_id,
|
||||||
|
i.abs_path,
|
||||||
|
i.rel_path,
|
||||||
|
i.display_name,
|
||||||
|
i.kind,
|
||||||
|
i.year,
|
||||||
|
i.structure,
|
||||||
|
i.confidence,
|
||||||
|
i.video_count,
|
||||||
|
i.file_count,
|
||||||
|
i.status,
|
||||||
|
i.last_seen_at
|
||||||
|
FROM items i
|
||||||
|
";
|
||||||
|
|
||||||
|
if (count($where) > 0) {
|
||||||
|
$sql .= " WHERE " . implode(' AND ', $where);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= " ORDER BY i.updated_at DESC, i.id DESC LIMIT 2000";
|
||||||
|
|
||||||
|
$st = $db->prepare($sql);
|
||||||
|
$st->execute($params);
|
||||||
|
$rows = $st->fetchAll();
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$items[] = [
|
||||||
|
'id' => (int)$r['id'],
|
||||||
|
'scan_profile_id' => (int)$r['scan_profile_id'],
|
||||||
|
'abs_path' => $r['abs_path'],
|
||||||
|
'rel_path' => $r['rel_path'],
|
||||||
|
'display_name' => $r['display_name'],
|
||||||
|
'kind' => $r['kind'],
|
||||||
|
'year' => $r['year'] !== null ? (int)$r['year'] : null,
|
||||||
|
'structure' => $r['structure'],
|
||||||
|
'confidence' => (int)$r['confidence'],
|
||||||
|
'video_count' => (int)$r['video_count'],
|
||||||
|
'file_count' => (int)$r['file_count'],
|
||||||
|
'status' => $r['status'],
|
||||||
|
'last_seen_at' => $r['last_seen_at'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
json_response(['ok' => true, 'items' => $items]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function scanItems(): void {
|
||||||
|
/** @var JobsService $jobs */
|
||||||
|
$jobs = $this->services()['jobs'];
|
||||||
|
/** @var ScannerService $scanner */
|
||||||
|
$scanner = $this->services()['scanner'];
|
||||||
|
$logger = $this->logger();
|
||||||
|
$settings = $this->settings();
|
||||||
|
|
||||||
|
$bg = $settings->getAll()['background'] ?? [];
|
||||||
|
if (!empty($bg['paused'])) {
|
||||||
|
json_response(['ok' => false, 'error' => 'Background jobs paused'], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobId = $jobs->create('scan', 'Scan (UI)', ['source' => 'http']);
|
||||||
|
try {
|
||||||
|
$jobs->start($jobId);
|
||||||
|
$logger->log('info', 'Scan started', ['job_id' => $jobId, 'source' => 'http']);
|
||||||
|
$result = $scanner->runScanJob($jobId);
|
||||||
|
$status = $jobs->getStatus($jobId);
|
||||||
|
if ($status !== 'canceled') {
|
||||||
|
$jobs->finish($jobId);
|
||||||
|
$logger->log('info', 'Scan finished', ['job_id' => $jobId, 'result' => $result]);
|
||||||
|
} else {
|
||||||
|
$logger->log('warn', 'Scan canceled', ['job_id' => $jobId]);
|
||||||
|
}
|
||||||
|
json_response(['ok' => true, 'job_id' => $jobId, 'result' => $result, 'status' => $status]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$jobs->log($jobId, 'error', $e->getMessage());
|
||||||
|
$jobs->fail($jobId, $e->getMessage());
|
||||||
|
$logger->log('error', 'Scan failed', ['job_id' => $jobId, 'error' => $e->getMessage()]);
|
||||||
|
json_response(['ok' => false, 'error' => $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
167
app/controllers/jobs.php
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
<?php
|
||||||
|
// app/controllers/jobs.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use ScMedia\Http\Router;
|
||||||
|
|
||||||
|
final class JobsController extends BaseController {
|
||||||
|
public static function register(Router $router, Container $container): void {
|
||||||
|
$self = new self($container);
|
||||||
|
$router->add('GET', '/api/jobs/status', fn() => $self->status());
|
||||||
|
$router->add('GET', '/api/jobs/recent', fn() => $self->recent());
|
||||||
|
$router->add('GET', '/api/jobs/get', fn() => $self->getJob());
|
||||||
|
$router->add('POST', '/api/jobs/cancel', fn() => $self->cancel());
|
||||||
|
$router->add('POST', '/api/jobs/watchdog', fn() => $self->watchdog());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function status(): void {
|
||||||
|
$db = $this->db();
|
||||||
|
$settings = $this->settings();
|
||||||
|
|
||||||
|
$running = $db->fetchOne("SELECT COUNT(*) AS c FROM jobs WHERE status='running'");
|
||||||
|
$queued = $db->fetchOne("SELECT COUNT(*) AS c FROM jobs WHERE status='queued'");
|
||||||
|
$errors = $db->fetchOne("SELECT COUNT(*) AS c FROM jobs WHERE status='error'");
|
||||||
|
$active = $db->fetchOne("
|
||||||
|
SELECT id, type, title, status, progress_current, progress_total, progress_pct, cancel_requested
|
||||||
|
FROM jobs
|
||||||
|
WHERE status='running'
|
||||||
|
ORDER BY started_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
");
|
||||||
|
$activeList = $db->fetchAll("
|
||||||
|
SELECT id, type, title, status, progress_current, progress_total, progress_pct, cancel_requested, started_at
|
||||||
|
FROM jobs
|
||||||
|
WHERE status='running'
|
||||||
|
ORDER BY started_at ASC
|
||||||
|
LIMIT 50
|
||||||
|
");
|
||||||
|
|
||||||
|
$bg = $settings->getAll()['background'] ?? [];
|
||||||
|
|
||||||
|
json_response(['ok' => true, 'data' => [
|
||||||
|
'paused' => !empty($bg['paused']),
|
||||||
|
'running' => (int)($running['c'] ?? 0),
|
||||||
|
'queued' => (int)($queued['c'] ?? 0),
|
||||||
|
'errors' => (int)($errors['c'] ?? 0),
|
||||||
|
'active' => $active ? [
|
||||||
|
'id' => (int)$active['id'],
|
||||||
|
'type' => (string)$active['type'],
|
||||||
|
'title' => (string)$active['title'],
|
||||||
|
'status' => (string)$active['status'],
|
||||||
|
'progress' => (int)($active['progress_current'] ?? 0),
|
||||||
|
'progress_total' => (int)($active['progress_total'] ?? 0),
|
||||||
|
'progress_pct' => (int)($active['progress_pct'] ?? 0),
|
||||||
|
'cancel_requested' => !empty($active['cancel_requested']),
|
||||||
|
] : null,
|
||||||
|
'active_list' => array_map(function($row) {
|
||||||
|
return [
|
||||||
|
'id' => (int)$row['id'],
|
||||||
|
'type' => (string)$row['type'],
|
||||||
|
'title' => (string)$row['title'],
|
||||||
|
'status' => (string)$row['status'],
|
||||||
|
'progress' => (int)($row['progress_current'] ?? 0),
|
||||||
|
'progress_total' => (int)($row['progress_total'] ?? 0),
|
||||||
|
'progress_pct' => (int)($row['progress_pct'] ?? 0),
|
||||||
|
'cancel_requested' => !empty($row['cancel_requested']),
|
||||||
|
'started_at' => $row['started_at'] ?? null,
|
||||||
|
];
|
||||||
|
}, $activeList),
|
||||||
|
]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recent(): void {
|
||||||
|
$db = $this->db();
|
||||||
|
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 20;
|
||||||
|
if ($limit < 1) $limit = 20;
|
||||||
|
if ($limit > 200) $limit = 200;
|
||||||
|
$rows = $db->fetchAll("
|
||||||
|
SELECT id, type, title, status, error_message, finished_at
|
||||||
|
FROM jobs
|
||||||
|
WHERE status IN ('done','error','canceled')
|
||||||
|
ORDER BY finished_at DESC
|
||||||
|
LIMIT {$limit}
|
||||||
|
");
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$out[] = [
|
||||||
|
'id' => (int)$r['id'],
|
||||||
|
'type' => (string)$r['type'],
|
||||||
|
'title' => (string)$r['title'],
|
||||||
|
'status' => (string)$r['status'],
|
||||||
|
'error_message' => $r['error_message'] ?? null,
|
||||||
|
'finished_at' => $r['finished_at'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
json_response(['ok' => true, 'data' => ['items' => $out]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getJob(): void {
|
||||||
|
$db = $this->db();
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
json_response(['ok' => false, 'error' => 'id required'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$job = $db->fetchOne("SELECT * FROM jobs WHERE id=:id", [':id' => $id]);
|
||||||
|
if (!$job) {
|
||||||
|
json_response(['ok' => false, 'error' => 'job not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $db->fetchAll("
|
||||||
|
SELECT ts, level, message
|
||||||
|
FROM job_logs
|
||||||
|
WHERE job_id=:id
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 5000
|
||||||
|
", [':id' => $id]);
|
||||||
|
|
||||||
|
$lines = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$ts = (string)($r['ts'] ?? '');
|
||||||
|
$lvl = (string)($r['level'] ?? 'info');
|
||||||
|
$msg = (string)($r['message'] ?? '');
|
||||||
|
$lines[] = "{$ts} [{$lvl}] {$msg}";
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [
|
||||||
|
'id' => (int)$job['id'],
|
||||||
|
'type' => (string)$job['type'],
|
||||||
|
'title' => (string)$job['title'],
|
||||||
|
'status' => (string)$job['status'],
|
||||||
|
'cancel_requested' => !empty($job['cancel_requested']),
|
||||||
|
'progress' => (int)($job['progress_current'] ?? 0),
|
||||||
|
'progress_total' => (int)($job['progress_total'] ?? 0),
|
||||||
|
'progress_pct' => (int)($job['progress_pct'] ?? 0),
|
||||||
|
'error_message' => $job['error_message'] ?? null,
|
||||||
|
'created_at' => $job['created_at'] ?? null,
|
||||||
|
'started_at' => $job['started_at'] ?? null,
|
||||||
|
'finished_at' => $job['finished_at'] ?? null,
|
||||||
|
'last_heartbeat' => $job['last_heartbeat'] ?? null,
|
||||||
|
'log_text' => implode("\n", $lines),
|
||||||
|
];
|
||||||
|
|
||||||
|
json_response(['ok' => true, 'job' => $out]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cancel(): void {
|
||||||
|
/** @var \ScMedia\Services\JobsService $jobs */
|
||||||
|
$jobs = $this->services()['jobs'];
|
||||||
|
$body = read_json_body();
|
||||||
|
$id = (int)($body['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
json_response(['ok' => false, 'error' => 'id required'], 400);
|
||||||
|
}
|
||||||
|
$jobs->requestCancel($id);
|
||||||
|
json_response(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function watchdog(): void {
|
||||||
|
/** @var \ScMedia\Services\JobsService $jobs */
|
||||||
|
$jobs = $this->services()['jobs'];
|
||||||
|
$body = read_json_body();
|
||||||
|
$minutes = isset($body['minutes']) ? (int)$body['minutes'] : 10;
|
||||||
|
$count = $jobs->markStalled($minutes);
|
||||||
|
json_response(['ok' => true, 'data' => ['stalled_marked' => $count]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/controllers/layout.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
// app/controllers/layout.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use ScMedia\Http\Response;
|
||||||
|
use ScMedia\Http\Router;
|
||||||
|
|
||||||
|
final class LayoutController extends BaseController {
|
||||||
|
public static function register(Router $router, Container $container): void {
|
||||||
|
$self = new self($container);
|
||||||
|
$router->add('POST', '/api/layout/preview', fn() => $self->preview());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function preview(): void {
|
||||||
|
$logger = $this->logger();
|
||||||
|
$body = read_json_body();
|
||||||
|
$all = $this->settings()->getAll();
|
||||||
|
|
||||||
|
$kind = (string)($body['kind'] ?? 'movies');
|
||||||
|
$mode = (string)($body['mode'] ?? 'from_items');
|
||||||
|
$limit = (int)($body['limit'] ?? 10);
|
||||||
|
|
||||||
|
$data = $this->services()['layout']->preview(
|
||||||
|
$all['data'] ?? $all,
|
||||||
|
$kind === 'series' ? 'series' : 'movies',
|
||||||
|
$mode === 'samples' ? 'samples' : 'from_items',
|
||||||
|
$limit
|
||||||
|
);
|
||||||
|
|
||||||
|
$logger->log('info', 'Layout preview generated', ['kind' => $kind, 'mode' => $mode, 'limit' => $limit]);
|
||||||
|
Response::json(['ok' => true, 'data' => $data]);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/controllers/logs.php
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
// app/controllers/logs.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use ScMedia\Http\Response;
|
||||||
|
use ScMedia\Http\Router;
|
||||||
|
use ScMedia\Services\LogService;
|
||||||
|
use ScMedia\Services\SettingsService;
|
||||||
|
|
||||||
|
final class LogsController extends BaseController {
|
||||||
|
public static function register(Router $router, Container $container): void {
|
||||||
|
$self = new self($container);
|
||||||
|
$router->add('POST', '/api/logs', fn() => $self->listLogs());
|
||||||
|
$router->add('POST', '/api/logs/cleanup', fn() => $self->cleanup());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function listLogs(): void {
|
||||||
|
/** @var LogService $logger */
|
||||||
|
$logger = $this->logger();
|
||||||
|
[$page, $perPage, $sort, $dir, $filters, $params] = table_body_params(100, 500);
|
||||||
|
$from = (string)($params['from'] ?? '');
|
||||||
|
$to = (string)($params['to'] ?? '');
|
||||||
|
$level = (string)($params['level'] ?? '');
|
||||||
|
|
||||||
|
if ($from === '' || $to === '') {
|
||||||
|
$rangeValue = filter_value($filters, 'ts');
|
||||||
|
$rangeOp = filter_op($filters, 'ts');
|
||||||
|
if ($rangeOp === 'between' && is_array($rangeValue)) {
|
||||||
|
$from = (string)($rangeValue[0] ?? '');
|
||||||
|
$to = (string)($rangeValue[1] ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($level === '') {
|
||||||
|
$levelValue = filter_value($filters, 'level');
|
||||||
|
if (is_string($levelValue)) {
|
||||||
|
$level = $levelValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($from === '' || $to === '') {
|
||||||
|
Response::json(['ok' => true, 'data' => [
|
||||||
|
'items' => [],
|
||||||
|
'total' => 0,
|
||||||
|
'page' => 1,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
]]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $logger->listByRangePaged($from, $to, $page, $perPage, $level, $sort, $dir);
|
||||||
|
Response::json(['ok' => true, 'data' => $rows]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cleanup(): void {
|
||||||
|
/** @var LogService $logger */
|
||||||
|
$logger = $this->logger();
|
||||||
|
/** @var SettingsService $settings */
|
||||||
|
$settings = $this->settings();
|
||||||
|
|
||||||
|
$body = read_json_body();
|
||||||
|
$keep = isset($body['keep_days']) ? (int)$body['keep_days'] : null;
|
||||||
|
if ($keep === null) {
|
||||||
|
$all = $settings->getAll();
|
||||||
|
$keep = (int)($all['logs']['retention_days'] ?? 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
$deleted = $logger->cleanup($keep);
|
||||||
|
$logger->log('info', 'Logs cleanup', ['keep_days' => $keep, 'deleted' => $deleted]);
|
||||||
|
Response::json(['ok' => true, 'data' => ['deleted' => $deleted]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
166
app/controllers/media.php
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
// app/controllers/media.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use ScMedia\Http\Response;
|
||||||
|
use ScMedia\Http\Router;
|
||||||
|
use ScMedia\Services\MediaLibraryService;
|
||||||
|
use ScMedia\Services\SettingsService;
|
||||||
|
|
||||||
|
final class MediaController extends BaseController {
|
||||||
|
public static function register(Router $router, Container $container): void {
|
||||||
|
$self = new self($container);
|
||||||
|
$router->add('POST', '/api/media/list', fn() => $self->listMedia());
|
||||||
|
$router->add('GET', '/api/media/series', fn() => $self->seriesDetail());
|
||||||
|
$router->add('GET', '/api/media/file', fn() => $self->fileDetail());
|
||||||
|
$router->add('GET', '/api/media/rules', fn() => $self->getRules());
|
||||||
|
$router->add('GET', '/api/media/dry-run', fn() => $self->dryRun());
|
||||||
|
$router->add('POST', '/api/media/rules', fn() => $self->saveRules());
|
||||||
|
$router->add('POST', '/api/media/apply', fn() => $self->applyChanges());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function listMedia(): void {
|
||||||
|
/** @var MediaLibraryService $media */
|
||||||
|
$media = $this->services()['media_library'];
|
||||||
|
$logger = $this->logger();
|
||||||
|
[$page, $perPage, $sort, $dir, $filters, $params] = table_body_params(50, 500);
|
||||||
|
$tab = (string)($params['tab'] ?? 'movies');
|
||||||
|
$tab = in_array($tab, ['movies','series','needs_mkv'], true) ? $tab : 'movies';
|
||||||
|
$result = $media->listTabPaged($tab, $page, $perPage, $sort, $dir);
|
||||||
|
$items = $result['items'] ?? [];
|
||||||
|
$total = (int)($result['total'] ?? 0);
|
||||||
|
$logger->log('debug', 'Media list', ['tab' => $tab, 'count' => count($items), 'page' => $page]);
|
||||||
|
Response::json(['ok' => true, 'data' => [
|
||||||
|
'items' => $items,
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seriesDetail(): void {
|
||||||
|
/** @var MediaLibraryService $media */
|
||||||
|
$media = $this->services()['media_library'];
|
||||||
|
/** @var \ScMedia\Services\MetadataService $metadata */
|
||||||
|
$metadata = $this->services()['metadata'];
|
||||||
|
$logger = $this->logger();
|
||||||
|
$key = (string)($_GET['key'] ?? '');
|
||||||
|
if ($key === '') {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'KEY_REQUIRED', 'message' => 'key required']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$data = $media->getSeriesFiles($key);
|
||||||
|
$metaRow = $metadata->getSubjectMeta('series', $key);
|
||||||
|
$display = $metadata->formatDisplay($metaRow);
|
||||||
|
$meta = is_array($display['meta'] ?? null) ? $display['meta'] : [];
|
||||||
|
$meta['subject_kind'] = 'series';
|
||||||
|
$meta['subject_key'] = $key;
|
||||||
|
$meta['title_display'] = $display['title_display'];
|
||||||
|
$meta['title_original'] = $display['title_original'];
|
||||||
|
$meta['year'] = $display['year'];
|
||||||
|
$logger->log('debug', 'Series detail', ['series_key' => $key, 'files' => count($data)]);
|
||||||
|
Response::json(['ok' => true, 'data' => ['files' => $data, 'meta' => $meta]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fileDetail(): void {
|
||||||
|
/** @var MediaLibraryService $media */
|
||||||
|
$media = $this->services()['media_library'];
|
||||||
|
$logger = $this->logger();
|
||||||
|
$path = (string)($_GET['path'] ?? '');
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
if ($path === '' && $id > 0) {
|
||||||
|
$db = $this->db();
|
||||||
|
$row = $db->fetchOne("SELECT abs_path FROM media_files WHERE id=:id", [':id' => $id]);
|
||||||
|
$path = $row ? (string)$row['abs_path'] : '';
|
||||||
|
}
|
||||||
|
if ($path === '') {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'PATH_REQUIRED', 'message' => 'path or id required']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$data = $media->getFileDetail($path);
|
||||||
|
if (empty($data['ok'])) {
|
||||||
|
$logger->log('warn', 'Media file detail not found', ['path' => $path, 'id' => $id]);
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'NOT_FOUND', 'message' => (string)($data['error'] ?? 'not found')]], 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$logger->log('debug', 'Media file detail', ['path' => $path, 'id' => $id]);
|
||||||
|
Response::json(['ok' => true, 'data' => $data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getRules(): void {
|
||||||
|
/** @var MediaLibraryService $media */
|
||||||
|
$media = $this->services()['media_library'];
|
||||||
|
Response::json(['ok' => true, 'data' => $media->getRules()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dryRun(): void {
|
||||||
|
/** @var MediaLibraryService $media */
|
||||||
|
$media = $this->services()['media_library'];
|
||||||
|
$logger = $this->logger();
|
||||||
|
$tab = (string)($_GET['tab'] ?? 'movies');
|
||||||
|
$tab = in_array($tab, ['movies','series','needs_mkv'], true) ? $tab : 'movies';
|
||||||
|
$data = $media->dryRun($tab);
|
||||||
|
$logger->log('info', 'Dry run computed', ['tab' => $tab, 'files' => $data['files'] ?? null]);
|
||||||
|
Response::json(['ok' => true, 'data' => $data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function saveRules(): void {
|
||||||
|
/** @var SettingsService $settings */
|
||||||
|
$settings = $this->settings();
|
||||||
|
$logger = $this->logger();
|
||||||
|
$body = read_json_body();
|
||||||
|
$rules = is_array($body['media_rules'] ?? null) ? $body['media_rules'] : [];
|
||||||
|
$payload = ['media_rules' => $rules];
|
||||||
|
if (isset($body['if_revision'])) {
|
||||||
|
$payload['if_revision'] = (int)$body['if_revision'];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$rev = $settings->saveBulk($payload);
|
||||||
|
$logger->log('info', 'Media rules saved', ['revision' => $rev]);
|
||||||
|
Response::json(['ok' => true, 'data' => ['settings_revision' => $rev]]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$logger->log('error', 'Media rules save failed', ['error' => $e->getMessage()]);
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'SAVE_FAILED', 'message' => $e->getMessage()]], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyChanges(): void {
|
||||||
|
/** @var \ScMedia\Services\MediaApplyService $apply */
|
||||||
|
$apply = $this->services()['media_apply'];
|
||||||
|
/** @var \ScMedia\Services\JobsService $jobs */
|
||||||
|
$jobs = $this->services()['jobs'];
|
||||||
|
$logger = $this->logger();
|
||||||
|
$settings = $this->settings();
|
||||||
|
|
||||||
|
$body = read_json_body();
|
||||||
|
$tab = (string)($body['tab'] ?? 'movies');
|
||||||
|
$tab = in_array($tab, ['movies','series','needs_mkv'], true) ? $tab : 'movies';
|
||||||
|
|
||||||
|
$bg = $settings->getAll()['background'] ?? [];
|
||||||
|
if (!empty($bg['paused'])) {
|
||||||
|
Response::json(['ok' => false, 'error' => 'Background jobs paused'], 409);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobId = $jobs->create('apply', 'Apply media changes', ['tab' => $tab]);
|
||||||
|
try {
|
||||||
|
$jobs->start($jobId);
|
||||||
|
$logger->log('info', 'Apply started', ['job_id' => $jobId, 'tab' => $tab]);
|
||||||
|
$result = $apply->applyTab($tab, $jobId);
|
||||||
|
$status = $jobs->getStatus($jobId);
|
||||||
|
if ($status !== 'canceled') {
|
||||||
|
$jobs->finish($jobId);
|
||||||
|
$logger->log('info', 'Apply finished', ['job_id' => $jobId, 'tab' => $tab, 'result' => $result]);
|
||||||
|
} else {
|
||||||
|
$logger->log('warn', 'Apply canceled', ['job_id' => $jobId, 'tab' => $tab]);
|
||||||
|
}
|
||||||
|
Response::json(['ok' => true, 'job_id' => $jobId, 'result' => $result, 'status' => $status]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$jobs->log($jobId, 'error', $e->getMessage());
|
||||||
|
$jobs->fail($jobId, $e->getMessage());
|
||||||
|
$logger->log('error', 'Apply failed', ['job_id' => $jobId, 'tab' => $tab, 'error' => $e->getMessage()]);
|
||||||
|
Response::json(['ok' => false, 'error' => $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/controllers/metadata.php
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
// app/controllers/metadata.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use ScMedia\Http\Response;
|
||||||
|
use ScMedia\Http\Router;
|
||||||
|
use ScMedia\Services\MetadataService;
|
||||||
|
|
||||||
|
final class MetadataController extends BaseController {
|
||||||
|
public static function register(Router $router, Container $container): void {
|
||||||
|
$self = new self($container);
|
||||||
|
$router->add('POST', '/api/metadata/search', fn() => $self->search());
|
||||||
|
$router->add('POST', '/api/metadata/select', fn() => $self->select());
|
||||||
|
$router->add('POST', '/api/metadata/manual', fn() => $self->manual());
|
||||||
|
$router->add('POST', '/api/metadata/manual/clear', fn() => $self->clearManual());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function search(): void {
|
||||||
|
/** @var MetadataService $metadata */
|
||||||
|
$metadata = $this->services()['metadata'];
|
||||||
|
$logger = $this->logger();
|
||||||
|
$body = read_json_body();
|
||||||
|
$query = (string)($body['query'] ?? '');
|
||||||
|
$type = (string)($body['type'] ?? 'movie');
|
||||||
|
$year = isset($body['year']) ? (int)$body['year'] : null;
|
||||||
|
$type = ($type === 'series') ? 'series' : 'movie';
|
||||||
|
$results = $metadata->search($query, $type, $year);
|
||||||
|
$logger->log('info', 'Metadata search', ['query' => $query, 'type' => $type, 'year' => $year, 'results' => count($results)]);
|
||||||
|
Response::json(['ok' => true, 'data' => $results]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function select(): void {
|
||||||
|
/** @var MetadataService $metadata */
|
||||||
|
$metadata = $this->services()['metadata'];
|
||||||
|
$logger = $this->logger();
|
||||||
|
$body = read_json_body();
|
||||||
|
$kind = (string)($body['subject_kind'] ?? '');
|
||||||
|
$key = (string)($body['subject_key'] ?? '');
|
||||||
|
$sel = is_array($body['selection'] ?? null) ? $body['selection'] : [];
|
||||||
|
if ($kind === '' || $key === '') {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'SUBJECT_REQUIRED', 'message' => 'subject required']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$metadata->saveSelection($kind, $key, $sel);
|
||||||
|
$metaRow = $metadata->getSubjectMeta($kind, $key);
|
||||||
|
$display = $metadata->formatDisplay($metaRow);
|
||||||
|
$logger->log('info', 'Metadata selected', ['subject_kind' => $kind, 'subject_key' => $key, 'provider' => $sel['provider'] ?? null, 'provider_id' => $sel['provider_id'] ?? null]);
|
||||||
|
Response::json(['ok' => true, 'data' => $display]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function manual(): void {
|
||||||
|
/** @var MetadataService $metadata */
|
||||||
|
$metadata = $this->services()['metadata'];
|
||||||
|
$logger = $this->logger();
|
||||||
|
$body = read_json_body();
|
||||||
|
$kind = (string)($body['subject_kind'] ?? '');
|
||||||
|
$key = (string)($body['subject_key'] ?? '');
|
||||||
|
$title = (string)($body['title'] ?? '');
|
||||||
|
$year = isset($body['year']) && $body['year'] !== '' ? (int)$body['year'] : null;
|
||||||
|
if ($kind === '' || $key === '') {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'SUBJECT_REQUIRED', 'message' => 'subject required']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$metadata->saveManual($kind, $key, $title, $year);
|
||||||
|
$metaRow = $metadata->getSubjectMeta($kind, $key);
|
||||||
|
$display = $metadata->formatDisplay($metaRow);
|
||||||
|
$logger->log('info', 'Metadata manual override', ['subject_kind' => $kind, 'subject_key' => $key, 'title' => $title, 'year' => $year]);
|
||||||
|
Response::json(['ok' => true, 'data' => $display]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clearManual(): void {
|
||||||
|
/** @var MetadataService $metadata */
|
||||||
|
$metadata = $this->services()['metadata'];
|
||||||
|
$logger = $this->logger();
|
||||||
|
$body = read_json_body();
|
||||||
|
$kind = (string)($body['subject_kind'] ?? '');
|
||||||
|
$key = (string)($body['subject_key'] ?? '');
|
||||||
|
if ($kind === '' || $key === '') {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'SUBJECT_REQUIRED', 'message' => 'subject required']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$metadata->clearManual($kind, $key);
|
||||||
|
$metaRow = $metadata->getSubjectMeta($kind, $key);
|
||||||
|
$display = $metadata->formatDisplay($metaRow);
|
||||||
|
$logger->log('info', 'Metadata manual override cleared', ['subject_kind' => $kind, 'subject_key' => $key]);
|
||||||
|
Response::json(['ok' => true, 'data' => $display]);
|
||||||
|
}
|
||||||
|
}
|
||||||
170
app/controllers/pages.php
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
<?php
|
||||||
|
// app/controllers/pages.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use ScMedia\Http\Response;
|
||||||
|
use ScMedia\Http\Router;
|
||||||
|
use ScMedia\Services\SettingsService;
|
||||||
|
|
||||||
|
final class PagesController extends BaseController {
|
||||||
|
public static function register(Router $router, Container $container): void {
|
||||||
|
$self = new self($container);
|
||||||
|
$router->add('GET', '/', fn() => $self->index());
|
||||||
|
$router->add('GET', '/settings', fn() => $self->settings());
|
||||||
|
$router->add('GET', '/login', fn() => $self->login());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function index(): void {
|
||||||
|
$i18n = $this->loadI18n(['common', 'index']);
|
||||||
|
$this->renderView(__DIR__ . '/../views/pages/index.php', $i18n);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function settings(): void {
|
||||||
|
$i18n = $this->loadI18n(['common', 'settings']);
|
||||||
|
$config = $this->config();
|
||||||
|
$debugToolsEnabled = (bool)($config['app']['debug_tools_enabled'] ?? false);
|
||||||
|
$allowDbReset = (bool)($config['app']['allow_db_reset'] ?? false);
|
||||||
|
$this->renderView(__DIR__ . '/../views/pages/settings.php', array_merge($i18n, [
|
||||||
|
'debugToolsEnabled' => $debugToolsEnabled,
|
||||||
|
'allowDbReset' => $allowDbReset,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function login(): void {
|
||||||
|
$i18n = $this->loadI18n(['login']);
|
||||||
|
$this->renderView(__DIR__ . '/../views/pages/login.php', $i18n);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderView(string $path, array $vars = []): void {
|
||||||
|
if (!is_file($path)) {
|
||||||
|
Response::json(['ok' => false, 'error' => 'View not found'], 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
extract($vars, EXTR_SKIP);
|
||||||
|
ob_start();
|
||||||
|
require $path;
|
||||||
|
$html = ob_get_clean();
|
||||||
|
Response::html($html);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadI18n(array $packages = ['common']): array {
|
||||||
|
/** @var SettingsService $settings */
|
||||||
|
$settings = $this->container->settings();
|
||||||
|
$all = $settings->getAll();
|
||||||
|
$lang = (string)($all['general']['language'] ?? 'en');
|
||||||
|
$cookieLang = isset($_COOKIE['scmedia_lang']) ? (string)$_COOKIE['scmedia_lang'] : '';
|
||||||
|
if (preg_match('/^[a-z]{2}$/', $cookieLang)) {
|
||||||
|
$lang = $cookieLang;
|
||||||
|
} else {
|
||||||
|
$authUser = $this->services()['auth_user'] ?? null;
|
||||||
|
if (is_array($authUser) && !empty($authUser['id']) && $this->db()) {
|
||||||
|
$db = $this->db();
|
||||||
|
$row = $db->fetchOne("SELECT ui_prefs_json FROM user_profiles WHERE user_id = :id", [
|
||||||
|
':id' => (int)$authUser['id'],
|
||||||
|
]) ?? [];
|
||||||
|
$ui = json_decode((string)($row['ui_prefs_json'] ?? '{}'), true);
|
||||||
|
$uiLang = is_array($ui) ? (string)($ui['language'] ?? '') : '';
|
||||||
|
if (preg_match('/^[a-z]{2}$/', $uiLang)) {
|
||||||
|
$lang = $uiLang;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$dict = $this->readI18nDict($lang);
|
||||||
|
if ($lang !== 'en' && count($dict) === 0) {
|
||||||
|
$lang = 'en';
|
||||||
|
$dict = $this->readI18nDict($lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dict = $this->filterI18nPackages($dict, $packages);
|
||||||
|
$t = function(string $key, string $fallback = '') use ($dict): string {
|
||||||
|
$val = $dict[$key] ?? '';
|
||||||
|
return $val !== '' ? $val : ($fallback !== '' ? $fallback : $key);
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'lang' => $lang,
|
||||||
|
'dict' => $dict,
|
||||||
|
't' => $t,
|
||||||
|
'debugToolsEnabled' => (bool)($this->config()['app']['debug_tools_enabled'] ?? false),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readI18nDict(string $lang): array {
|
||||||
|
$path = __DIR__ . '/../../public/assets/i18n/' . basename($lang) . '.json';
|
||||||
|
if (!is_file($path)) return [];
|
||||||
|
$raw = file_get_contents($path);
|
||||||
|
if ($raw === false) return [];
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
return is_array($data) ? $data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function i18nPackages(): array {
|
||||||
|
return [
|
||||||
|
'common' => [
|
||||||
|
'prefixes' => ['common', 'nav', 'queue', 'job'],
|
||||||
|
'keys' => ['auth.logout', 'actions.theme', 'settings.language'],
|
||||||
|
],
|
||||||
|
'login' => [
|
||||||
|
'prefixes' => ['auth'],
|
||||||
|
'keys' => ['common.back', 'settings.language'],
|
||||||
|
],
|
||||||
|
'account' => [
|
||||||
|
'prefixes' => ['account', 'theme', 'settings.ui'],
|
||||||
|
'keys' => ['settings.language'],
|
||||||
|
],
|
||||||
|
'settings' => [
|
||||||
|
'prefixes' => ['settings', 'rules', 'admin', 'filters'],
|
||||||
|
'keys' => [],
|
||||||
|
],
|
||||||
|
'index' => [
|
||||||
|
'prefixes' => [
|
||||||
|
'app',
|
||||||
|
'actions',
|
||||||
|
'types',
|
||||||
|
'bulk',
|
||||||
|
'grid',
|
||||||
|
'status',
|
||||||
|
'sources',
|
||||||
|
'queue',
|
||||||
|
'incoming',
|
||||||
|
'media',
|
||||||
|
'job',
|
||||||
|
'theme',
|
||||||
|
'filters',
|
||||||
|
'watchdog',
|
||||||
|
'scan',
|
||||||
|
'table',
|
||||||
|
],
|
||||||
|
'keys' => ['settings.language'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function filterI18nPackages(array $dict, array $packages): array {
|
||||||
|
$map = $this->i18nPackages();
|
||||||
|
$out = [];
|
||||||
|
$wanted = [];
|
||||||
|
foreach ($packages as $p) {
|
||||||
|
if (isset($map[$p])) {
|
||||||
|
$wanted[] = $map[$p];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($wanted as $pkg) {
|
||||||
|
foreach (($pkg['prefixes'] ?? []) as $prefix) {
|
||||||
|
foreach ($dict as $k => $v) {
|
||||||
|
if (str_starts_with($k, $prefix . '.')) {
|
||||||
|
$out[$k] = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (($pkg['keys'] ?? []) as $key) {
|
||||||
|
if (array_key_exists($key, $dict)) {
|
||||||
|
$out[$key] = $dict[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
76
app/controllers/scan_profiles.php
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
// app/controllers/scan_profiles.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use ScMedia\Http\Response;
|
||||||
|
use ScMedia\Http\Router;
|
||||||
|
|
||||||
|
final class ScanProfilesController extends BaseController {
|
||||||
|
public static function register(Router $router, Container $container): void {
|
||||||
|
$self = new self($container);
|
||||||
|
$router->add('GET', '/api/scan-profiles', fn() => $self->listProfiles());
|
||||||
|
$router->add('POST', '/api/scan-profiles', fn() => $self->createProfile());
|
||||||
|
$router->add('PUT', '/api/scan-profiles/{id}', fn(array $params) => $self->updateProfile($params));
|
||||||
|
$router->add('DELETE', '/api/scan-profiles/{id}', fn(array $params) => $self->deleteProfile($params));
|
||||||
|
$router->add('POST', '/api/scan-profiles/reorder', fn() => $self->reorderProfiles());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function listProfiles(): void {
|
||||||
|
$rows = $this->services()['scan_profiles']->list();
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$r['sort_order'] = (int)($r['sort_order'] ?? 0);
|
||||||
|
$r['profile_type'] = (string)($r['profile_type'] ?? 'scan');
|
||||||
|
$r['exclude_patterns'] = json_decode((string)$r['exclude_patterns_json'], true) ?: [];
|
||||||
|
$r['include_ext'] = $r['include_ext_json'] ? (json_decode((string)$r['include_ext_json'], true) ?: []) : null;
|
||||||
|
unset($r['exclude_patterns_json'], $r['include_ext_json']);
|
||||||
|
$out[] = $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::json(['ok' => true, 'data' => $out]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createProfile(): void {
|
||||||
|
$logger = $this->logger();
|
||||||
|
$body = read_json_body();
|
||||||
|
try {
|
||||||
|
$id = $this->services()['scan_profiles']->create($body);
|
||||||
|
$logger->log('info', 'Scan profile created', ['id' => $id, 'name' => $body['name'] ?? null]);
|
||||||
|
Response::json(['ok' => true, 'data' => ['id' => $id]]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$logger->log('error', 'Scan profile create failed', ['error' => $e->getMessage()]);
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'VALIDATION_ERROR', 'message' => $e->getMessage()]], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateProfile(array $params): void {
|
||||||
|
$logger = $this->logger();
|
||||||
|
$body = read_json_body();
|
||||||
|
try {
|
||||||
|
$this->services()['scan_profiles']->update((int)$params['id'], $body);
|
||||||
|
$logger->log('info', 'Scan profile updated', ['id' => (int)$params['id'], 'name' => $body['name'] ?? null]);
|
||||||
|
Response::json(['ok' => true]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$logger->log('error', 'Scan profile update failed', ['id' => (int)$params['id'], 'error' => $e->getMessage()]);
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'VALIDATION_ERROR', 'message' => $e->getMessage()]], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteProfile(array $params): void {
|
||||||
|
$logger = $this->logger();
|
||||||
|
$this->services()['scan_profiles']->delete((int)$params['id']);
|
||||||
|
$logger->log('info', 'Scan profile deleted', ['id' => (int)$params['id']]);
|
||||||
|
Response::json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reorderProfiles(): void {
|
||||||
|
$logger = $this->logger();
|
||||||
|
$body = read_json_body();
|
||||||
|
$ids = is_array($body['ids'] ?? null) ? $body['ids'] : [];
|
||||||
|
$this->services()['scan_profiles']->reorder($ids);
|
||||||
|
$logger->log('info', 'Scan profiles reordered', ['count' => count($ids)]);
|
||||||
|
Response::json(['ok' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
130
app/controllers/settings.php
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
// app/controllers/settings.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use ScMedia\Http\Response;
|
||||||
|
use ScMedia\Http\Router;
|
||||||
|
|
||||||
|
final class SettingsController extends BaseController {
|
||||||
|
public static function register(Router $router, Container $container): void {
|
||||||
|
$self = new self($container);
|
||||||
|
$router->add('GET', '/api/settings', fn() => $self->getSettings());
|
||||||
|
$router->add('POST', '/api/language', fn() => $self->updateLanguage());
|
||||||
|
$router->add('POST', '/api/settings', fn() => $self->saveSettings());
|
||||||
|
$router->add('GET', '/api/settings/diagnostics', fn() => $self->getDiagnostics());
|
||||||
|
$router->add('GET', '/api/settings/snapshots', fn() => $self->listSnapshots());
|
||||||
|
$router->add('POST', '/api/settings/snapshots', fn() => $self->createSnapshot());
|
||||||
|
$router->add('POST', '/api/settings/snapshots/restore', fn() => $self->restoreSnapshot());
|
||||||
|
$router->add('DELETE', '/api/settings/snapshots/{id}', fn(array $params) => $self->deleteSnapshot($params));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getSettings(): void {
|
||||||
|
$all = $this->settings()->getAll();
|
||||||
|
Response::json(['ok' => true, 'data' => $all]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateLanguage(): void {
|
||||||
|
$logger = $this->logger();
|
||||||
|
$body = read_json_body();
|
||||||
|
$lang = (string)($body['language'] ?? '');
|
||||||
|
if (!preg_match('/^[a-z]{2}$/', $lang)) {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'INVALID_LANGUAGE', 'message' => 'Invalid language']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$newRev = $this->settings()->saveBulk(['general' => ['language' => $lang]]);
|
||||||
|
$logger->log('info', 'Language updated', ['language' => $lang, 'revision' => $newRev]);
|
||||||
|
Response::json(['ok' => true, 'data' => ['settings_revision' => $newRev]]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$logger->log('error', 'Language update failed', ['error' => $e->getMessage()]);
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'SAVE_FAILED', 'message' => $e->getMessage()]], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function saveSettings(): void {
|
||||||
|
$logger = $this->logger();
|
||||||
|
$body = read_json_body();
|
||||||
|
try {
|
||||||
|
$newRev = $this->settings()->saveBulk($body);
|
||||||
|
$logger->log('info', 'Settings saved', ['revision' => $newRev]);
|
||||||
|
Response::json(['ok' => true, 'data' => ['settings_revision' => $newRev]]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$logger->log('error', 'Settings save failed', ['error' => $e->getMessage()]);
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'SAVE_FAILED', 'message' => $e->getMessage()]], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getDiagnostics(): void {
|
||||||
|
$db = $this->db();
|
||||||
|
$shell = $this->services()['shell'] ?? null;
|
||||||
|
$bins = ['mkvmerge', 'mkvpropedit', 'ffmpeg'];
|
||||||
|
$binOut = [];
|
||||||
|
foreach ($bins as $b) {
|
||||||
|
$binOut[$b] = ($shell instanceof \ScMedia\Services\ShellTool) ? ($shell->which($b) ?? '') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$root = dirname(__DIR__, 2);
|
||||||
|
$free = @disk_free_space($root);
|
||||||
|
$total = @disk_total_space($root);
|
||||||
|
$disk = [
|
||||||
|
'path' => $root,
|
||||||
|
'free_bytes' => is_int($free) ? $free : null,
|
||||||
|
'total_bytes' => is_int($total) ? $total : null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$running = $db->fetchOne("SELECT COUNT(*) AS c FROM jobs WHERE status='running'");
|
||||||
|
$queued = $db->fetchOne("SELECT COUNT(*) AS c FROM jobs WHERE status='queued'");
|
||||||
|
$errors = $db->fetchOne("SELECT COUNT(*) AS c FROM jobs WHERE status='error'");
|
||||||
|
|
||||||
|
Response::json(['ok' => true, 'data' => [
|
||||||
|
'php_version' => PHP_VERSION,
|
||||||
|
'disk' => $disk,
|
||||||
|
'binaries' => $binOut,
|
||||||
|
'jobs' => [
|
||||||
|
'running' => (int)($running['c'] ?? 0),
|
||||||
|
'queued' => (int)($queued['c'] ?? 0),
|
||||||
|
'errors' => (int)($errors['c'] ?? 0),
|
||||||
|
],
|
||||||
|
]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function listSnapshots(): void {
|
||||||
|
$items = $this->settings()->listSnapshots();
|
||||||
|
Response::json(['ok' => true, 'data' => ['items' => $items]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createSnapshot(): void {
|
||||||
|
$logger = $this->logger();
|
||||||
|
$body = read_json_body();
|
||||||
|
$label = (string)($body['label'] ?? '');
|
||||||
|
$user = $this->container->get('auth_user');
|
||||||
|
$userId = is_array($user) ? (int)($user['id'] ?? 0) : null;
|
||||||
|
$id = $this->settings()->createSnapshot($label, $userId > 0 ? $userId : null);
|
||||||
|
$logger->log('info', 'Settings snapshot created', ['id' => $id, 'label' => $label ?: null, 'user_id' => $userId ?: null]);
|
||||||
|
Response::json(['ok' => true, 'data' => ['id' => $id]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function restoreSnapshot(): void {
|
||||||
|
$logger = $this->logger();
|
||||||
|
$body = read_json_body();
|
||||||
|
$id = (int)($body['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'ID_REQUIRED', 'message' => 'Snapshot id required']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->settings()->restoreSnapshot($id);
|
||||||
|
$logger->log('warn', 'Settings snapshot restored', ['id' => $id]);
|
||||||
|
Response::json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteSnapshot(array $params): void {
|
||||||
|
$id = (int)($params['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'ID_REQUIRED', 'message' => 'Snapshot id required']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->settings()->deleteSnapshot($id);
|
||||||
|
Response::json(['ok' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
216
app/controllers/sources.php
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
<?php
|
||||||
|
// app/controllers/sources.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use ScMedia\Http\Response;
|
||||||
|
use ScMedia\Http\Router;
|
||||||
|
use ScMedia\Services\SourcesService;
|
||||||
|
use ScMedia\Services\TransmissionService;
|
||||||
|
|
||||||
|
final class SourcesController extends BaseController {
|
||||||
|
public static function register(Router $router, Container $container): void {
|
||||||
|
$self = new self($container);
|
||||||
|
$router->add('POST', '/api/sources/list', fn() => $self->listSources());
|
||||||
|
$router->add('GET', '/api/sources/detail', fn() => $self->getDetail());
|
||||||
|
$router->add('POST', '/api/sources/test', fn() => $self->testSource());
|
||||||
|
$router->add('GET', '/api/sources/preview', fn() => $self->getPreview());
|
||||||
|
$router->add('POST', '/api/sources/approve', fn() => $self->approveSource());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function listSources(): void {
|
||||||
|
/** @var SourcesService $sources */
|
||||||
|
$sources = $this->services()['sources'];
|
||||||
|
[$page, $perPage, $sort, $dir, $filters] = table_body_params(50, 500);
|
||||||
|
$items = $sources->list();
|
||||||
|
$items = apply_filters_array($items, $filters, [
|
||||||
|
'source' => 'source',
|
||||||
|
'type' => 'content_type',
|
||||||
|
'name' => 'name',
|
||||||
|
'size' => 'size_bytes',
|
||||||
|
'status' => 'status',
|
||||||
|
'progress' => fn($it) => (float)($it['percent_done'] ?? 0) * 100,
|
||||||
|
]);
|
||||||
|
$sortMap = ['name' => 'name', 'size' => 'size_bytes', 'status' => 'status'];
|
||||||
|
$sortKey = $sortMap[$sort] ?? $sort;
|
||||||
|
if ($sortKey !== '') {
|
||||||
|
usort($items, function ($a, $b) use ($sortKey, $dir) {
|
||||||
|
$av = $a[$sortKey] ?? null;
|
||||||
|
$bv = $b[$sortKey] ?? null;
|
||||||
|
if ($av === $bv) return 0;
|
||||||
|
if (strtolower($dir) === 'desc') return ($av < $bv) ? 1 : -1;
|
||||||
|
return ($av < $bv) ? -1 : 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
[$slice, $total] = paginate_array($items, $page, $perPage);
|
||||||
|
Response::json(['ok' => true, 'data' => [
|
||||||
|
'items' => $slice,
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getDetail(): void {
|
||||||
|
$source = (string)($_GET['source'] ?? '');
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
if ($source === '' || $id <= 0) {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'PARAMS_REQUIRED', 'message' => 'source and id required']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($source === 'transmission') {
|
||||||
|
/** @var TransmissionService $transmission */
|
||||||
|
$transmission = $this->services()['transmission'];
|
||||||
|
$settings = $this->settings();
|
||||||
|
$res = $transmission->detail($id);
|
||||||
|
if (empty($res['ok'])) {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'SOURCE_FAILED', 'message' => $res['error'] ?? 'failed']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$all = $settings->getAll();
|
||||||
|
$sources = $all['sources'] ?? [];
|
||||||
|
$cfg = is_array($sources['transmission'] ?? null) ? $sources['transmission'] : [];
|
||||||
|
$display = is_array($cfg['display_fields'] ?? null) ? $cfg['display_fields'] : [];
|
||||||
|
if (count($display) > 0) {
|
||||||
|
$res['fields'] = array_values(array_filter($res['fields'] ?? [], function ($f) use ($display) {
|
||||||
|
$key = (string)($f['key'] ?? '');
|
||||||
|
return $key !== '' && in_array($key, $display, true);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Response::json(['ok' => true, 'data' => [
|
||||||
|
'core' => $res['core'] ?? [],
|
||||||
|
'fields' => $res['fields'] ?? [],
|
||||||
|
'files' => $res['files'] ?? [],
|
||||||
|
'preview' => $this->buildTransmissionPreview($res),
|
||||||
|
'raw' => $res['raw'] ?? null,
|
||||||
|
]]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'UNKNOWN_SOURCE', 'message' => 'unknown source']], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testSource(): void {
|
||||||
|
/** @var TransmissionService $transmission */
|
||||||
|
$transmission = $this->services()['transmission'];
|
||||||
|
$logger = $this->logger();
|
||||||
|
$body = read_json_body();
|
||||||
|
$cfg = is_array($body['transmission'] ?? null) ? $body['transmission'] : [];
|
||||||
|
try {
|
||||||
|
$res = $transmission->test($cfg);
|
||||||
|
if (empty($res['ok'])) {
|
||||||
|
$logger->log('warn', 'Transmission test failed', ['error' => $res['error'] ?? 'failed']);
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'TEST_FAILED', 'message' => $res['error'] ?? 'failed']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$logger->log('info', 'Transmission test ok');
|
||||||
|
Response::json(['ok' => true]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$logger->log('error', 'Transmission test exception', ['error' => $e->getMessage()]);
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'TEST_EXCEPTION', 'message' => $e->getMessage()]], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getPreview(): void {
|
||||||
|
$source = (string)($_GET['source'] ?? '');
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
if ($source === '' || $id <= 0) {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'PARAMS_REQUIRED', 'message' => 'source and id required']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($source === 'transmission') {
|
||||||
|
/** @var TransmissionService $transmission */
|
||||||
|
$transmission = $this->services()['transmission'];
|
||||||
|
$detail = $transmission->detail($id);
|
||||||
|
if (empty($detail['ok'])) {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'SOURCE_FAILED', 'message' => $detail['error'] ?? 'failed']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$preview = $this->buildTransmissionPreview($detail);
|
||||||
|
Response::json(['ok' => true, 'data' => $preview]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'UNKNOWN_SOURCE', 'message' => 'unknown source']], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function approveSource(): void {
|
||||||
|
$body = read_json_body();
|
||||||
|
$source = (string)($body['source'] ?? '');
|
||||||
|
$id = (int)($body['id'] ?? 0);
|
||||||
|
if ($source === '' || $id <= 0) {
|
||||||
|
Response::json(['ok' => false, 'error' => ['code' => 'PARAMS_REQUIRED', 'message' => 'source and id required']], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/** @var \ScMedia\Services\JobsService $jobs */
|
||||||
|
$jobs = $this->services()['jobs'];
|
||||||
|
$logger = $this->logger();
|
||||||
|
$payload = ['source' => $source, 'id' => $id];
|
||||||
|
$jobId = $jobs->create('task', "Source approve: {$source} #{$id}", $payload);
|
||||||
|
$logger->log('info', 'Source approved', ['source' => $source, 'id' => $id, 'job_id' => $jobId]);
|
||||||
|
Response::json(['ok' => true, 'data' => ['job_id' => $jobId]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTransmissionPreview(array $detail): array {
|
||||||
|
$core = $detail['core'] ?? [];
|
||||||
|
$files = is_array($detail['files'] ?? null) ? $detail['files'] : [];
|
||||||
|
$typeInfo = $this->detectSourceType($files);
|
||||||
|
$episodes = $this->detectSeriesEpisodes($files);
|
||||||
|
$current = [
|
||||||
|
'name' => (string)($core['name'] ?? ''),
|
||||||
|
'kind' => $typeInfo['kind'],
|
||||||
|
'structure' => $typeInfo['structure'],
|
||||||
|
'file_count' => count($files),
|
||||||
|
'size_bytes' => (int)($core['size_bytes'] ?? 0),
|
||||||
|
'episodes' => $episodes,
|
||||||
|
];
|
||||||
|
$planned = $current;
|
||||||
|
$planned['note'] = 'Pending rules';
|
||||||
|
return ['current' => $current, 'planned' => $planned];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detectSourceType(array $files): array {
|
||||||
|
$structure = 'file';
|
||||||
|
$kind = 'movie';
|
||||||
|
$hasVideoTs = false;
|
||||||
|
$hasBdmv = false;
|
||||||
|
foreach ($files as $f) {
|
||||||
|
$path = (string)($f['path'] ?? '');
|
||||||
|
if ($path === '') continue;
|
||||||
|
if (stripos($path, 'VIDEO_TS/') !== false || stripos($path, '/VIDEO_TS/') !== false) $hasVideoTs = true;
|
||||||
|
if (stripos($path, 'BDMV/') !== false || stripos($path, '/BDMV/') !== false) $hasBdmv = true;
|
||||||
|
}
|
||||||
|
if ($hasBdmv) {
|
||||||
|
$structure = 'bluray';
|
||||||
|
} elseif ($hasVideoTs) {
|
||||||
|
$structure = 'dvd';
|
||||||
|
} elseif (count($files) > 1) {
|
||||||
|
$structure = 'folder';
|
||||||
|
}
|
||||||
|
|
||||||
|
$episodes = $this->detectSeriesEpisodes($files);
|
||||||
|
if (count($episodes) > 0) {
|
||||||
|
$kind = 'series';
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['structure' => $structure, 'kind' => $kind];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detectSeriesEpisodes(array $files): array {
|
||||||
|
$out = [];
|
||||||
|
foreach ($files as $f) {
|
||||||
|
$path = (string)($f['path'] ?? '');
|
||||||
|
if ($path === '') continue;
|
||||||
|
if (preg_match('/S(\\d{1,2})E(\\d{1,2})/i', $path, $m)) {
|
||||||
|
$season = (int)$m[1];
|
||||||
|
$episode = (int)$m[2];
|
||||||
|
$key = sprintf('S%02dE%02d', $season, $episode);
|
||||||
|
$out[$key] = ['season' => $season, 'episode' => $episode];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ksort($out);
|
||||||
|
return array_values($out);
|
||||||
|
}
|
||||||
|
}
|
||||||
158
app/controllers/tasks.php
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
<?php
|
||||||
|
// app/controllers/tasks.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use ScMedia\Http\Router;
|
||||||
|
|
||||||
|
final class TasksController extends BaseController {
|
||||||
|
public static function register(Router $router, Container $container): void {
|
||||||
|
$self = new self($container);
|
||||||
|
$router->add('POST', '/api/tasks/pending/list', fn() => $self->listPending());
|
||||||
|
$router->add('POST', '/api/tasks/pending', fn() => $self->savePending());
|
||||||
|
$router->add('POST', '/api/tasks/pending/delete', fn() => $self->deletePending());
|
||||||
|
$router->add('POST', '/api/tasks/approve', fn() => $self->approvePending());
|
||||||
|
$router->add('POST', '/api/tasks/list', fn() => $self->listTasks());
|
||||||
|
$router->add('POST', '/api/tasks/run', fn() => $self->runTask());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function listPending(): void {
|
||||||
|
$settings = $this->settings();
|
||||||
|
[$page, $perPage, $sort, $dir, $filters] = table_body_params(50, 200);
|
||||||
|
$data = $settings->getAll();
|
||||||
|
$pending = is_array($data['pending_tasks'] ?? null) ? $data['pending_tasks'] : [];
|
||||||
|
$items = array_values(array_filter($pending, fn($t) => is_array($t)));
|
||||||
|
$items = apply_filters_array($items, $filters, [
|
||||||
|
'name' => 'name',
|
||||||
|
'type' => 'type',
|
||||||
|
'status' => 'status',
|
||||||
|
]);
|
||||||
|
[$slice, $total] = paginate_array($items, $page, $perPage);
|
||||||
|
json_response(['ok' => true, 'data' => [
|
||||||
|
'items' => $slice,
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function savePending(): void {
|
||||||
|
$settings = $this->settings();
|
||||||
|
$body = read_json_body();
|
||||||
|
$task = is_array($body['task'] ?? null) ? $body['task'] : null;
|
||||||
|
if (!$task) {
|
||||||
|
json_response(['ok' => false, 'error' => 'task required'], 400);
|
||||||
|
}
|
||||||
|
$id = (string)($task['id'] ?? '');
|
||||||
|
if ($id === '') {
|
||||||
|
$id = 'pending_' . time() . '_' . bin2hex(random_bytes(2));
|
||||||
|
$task['id'] = $id;
|
||||||
|
}
|
||||||
|
$data = $settings->getAll();
|
||||||
|
$pending = is_array($data['pending_tasks'] ?? null) ? $data['pending_tasks'] : [];
|
||||||
|
$pending = array_values(array_filter($pending, fn($t) => is_array($t)));
|
||||||
|
$pending = array_values(array_filter($pending, fn($t) => (string)($t['id'] ?? '') !== $id));
|
||||||
|
$pending[] = $task;
|
||||||
|
$settings->saveBulk(['pending_tasks' => $pending]);
|
||||||
|
json_response(['ok' => true, 'data' => ['id' => $id]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deletePending(): void {
|
||||||
|
$settings = $this->settings();
|
||||||
|
$body = read_json_body();
|
||||||
|
$id = (string)($body['id'] ?? '');
|
||||||
|
if ($id === '') {
|
||||||
|
json_response(['ok' => false, 'error' => 'id required'], 400);
|
||||||
|
}
|
||||||
|
$data = $settings->getAll();
|
||||||
|
$pending = is_array($data['pending_tasks'] ?? null) ? $data['pending_tasks'] : [];
|
||||||
|
$pending = array_values(array_filter($pending, fn($t) => (string)($t['id'] ?? '') !== $id));
|
||||||
|
$settings->saveBulk(['pending_tasks' => $pending]);
|
||||||
|
json_response(['ok' => true, 'data' => ['id' => $id]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function approvePending(): void {
|
||||||
|
$settings = $this->settings();
|
||||||
|
$body = read_json_body();
|
||||||
|
$id = (string)($body['id'] ?? '');
|
||||||
|
if ($id === '') {
|
||||||
|
json_response(['ok' => false, 'error' => 'id required'], 400);
|
||||||
|
}
|
||||||
|
$data = $settings->getAll();
|
||||||
|
$pending = is_array($data['pending_tasks'] ?? null) ? $data['pending_tasks'] : [];
|
||||||
|
$tasks = is_array($data['tasks'] ?? null) ? $data['tasks'] : [];
|
||||||
|
$task = null;
|
||||||
|
$pending = array_values(array_filter($pending, function($t) use ($id, &$task) {
|
||||||
|
if ((string)($t['id'] ?? '') === $id) {
|
||||||
|
$task = $t;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
if (!$task) {
|
||||||
|
json_response(['ok' => false, 'error' => 'task not found'], 404);
|
||||||
|
}
|
||||||
|
$tasks[] = $task;
|
||||||
|
$settings->saveBulk(['pending_tasks' => $pending, 'tasks' => $tasks]);
|
||||||
|
json_response(['ok' => true, 'data' => ['id' => $id]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function listTasks(): void {
|
||||||
|
$settings = $this->settings();
|
||||||
|
$data = $settings->getAll();
|
||||||
|
[$page, $perPage] = table_body_params(50, 200);
|
||||||
|
$tasks = is_array($data['tasks'] ?? null) ? $data['tasks'] : [];
|
||||||
|
$out = [];
|
||||||
|
foreach ($tasks as $t) {
|
||||||
|
if (!is_array($t)) continue;
|
||||||
|
if (empty($t['enabled'])) continue;
|
||||||
|
$out[] = [
|
||||||
|
'id' => (string)($t['id'] ?? ''),
|
||||||
|
'name' => (string)($t['name'] ?? ''),
|
||||||
|
'sources' => is_array($t['sources'] ?? null) ? $t['sources'] : [],
|
||||||
|
'actions' => is_array($t['actions'] ?? null) ? $t['actions'] : [],
|
||||||
|
'enabled' => !empty($t['enabled']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
[$slice, $total] = paginate_array($out, $page, $perPage);
|
||||||
|
json_response(['ok' => true, 'data' => [
|
||||||
|
'items' => $slice,
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function runTask(): void {
|
||||||
|
$settings = $this->settings();
|
||||||
|
/** @var \ScMedia\Services\JobsService $jobs */
|
||||||
|
$jobs = $this->services()['jobs'];
|
||||||
|
$body = read_json_body();
|
||||||
|
$id = (string)($body['id'] ?? '');
|
||||||
|
if ($id === '') {
|
||||||
|
json_response(['ok' => false, 'error' => 'id required'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $settings->getAll();
|
||||||
|
$tasks = is_array($data['tasks'] ?? null) ? $data['tasks'] : [];
|
||||||
|
$task = null;
|
||||||
|
foreach ($tasks as $t) {
|
||||||
|
if (!is_array($t)) continue;
|
||||||
|
if ((string)($t['id'] ?? '') === $id) {
|
||||||
|
$task = $t;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$task) {
|
||||||
|
json_response(['ok' => false, 'error' => 'task not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = 'Task: ' . (string)($task['name'] ?? $id);
|
||||||
|
$jobId = $jobs->create('task', $title, ['task_id' => $id]);
|
||||||
|
$jobs->start($jobId);
|
||||||
|
$jobs->log($jobId, 'info', 'Task execution queued');
|
||||||
|
$jobs->log($jobId, 'warn', 'Task execution not wired to pipeline yet');
|
||||||
|
$jobs->finish($jobId);
|
||||||
|
json_response(['ok' => true, 'data' => ['job_id' => $jobId]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/controllers/tools.php
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
// app/controllers/tools.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use ScMedia\Http\Response;
|
||||||
|
use ScMedia\Http\Router;
|
||||||
|
|
||||||
|
final class ToolsController extends BaseController {
|
||||||
|
public static function register(Router $router, Container $container): void {
|
||||||
|
$self = new self($container);
|
||||||
|
$router->add('POST', '/api/tools/test-path', fn() => $self->testPath());
|
||||||
|
$router->add('GET', '/api/tools/detect-binaries', fn() => $self->detectBinaries());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testPath(): void {
|
||||||
|
$logger = $this->logger();
|
||||||
|
$body = read_json_body();
|
||||||
|
$path = (string)($body['path'] ?? '');
|
||||||
|
$checks = is_array($body['checks'] ?? null) ? $body['checks'] : ['exists','read','write','rename'];
|
||||||
|
$data = $this->services()['path_tool']->testPath($path, $checks);
|
||||||
|
$logger->log('info', 'Path test', ['path' => $path, 'checks' => $checks, 'results' => $data['results'] ?? null, 'notes' => $data['notes'] ?? null]);
|
||||||
|
Response::json(['ok' => true, 'data' => ['path' => $path] + $data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detectBinaries(): void {
|
||||||
|
$logger = $this->logger();
|
||||||
|
$shell = $this->services()['shell'] ?? null;
|
||||||
|
$bins = ['mkvmerge', 'mkvpropedit', 'ffmpeg'];
|
||||||
|
$out = [];
|
||||||
|
foreach ($bins as $b) {
|
||||||
|
$out[$b . '_path'] = ($shell instanceof \ScMedia\Services\ShellTool) ? $shell->which($b) : null;
|
||||||
|
}
|
||||||
|
$logger->log('info', 'Detect binaries', ['binaries' => $out]);
|
||||||
|
Response::json(['ok' => true, 'data' => $out]);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/db/Db.php
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
// app/db/Db.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Db;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
|
||||||
|
final class Db {
|
||||||
|
private PDO $pdo;
|
||||||
|
|
||||||
|
public function __construct(string $dsn, string $user, string $pass) {
|
||||||
|
$this->pdo = new PDO($dsn, $user, $pass, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pdo(): PDO {
|
||||||
|
return $this->pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function prepare(string $sql): \PDOStatement {
|
||||||
|
return $this->pdo->prepare($sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function query(string $sql): \PDOStatement {
|
||||||
|
return $this->pdo->query($sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lastInsertId(): string {
|
||||||
|
return $this->pdo->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetchOne(string $sql, array $params = []): ?array {
|
||||||
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
return $row === false ? null : $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetchAll(string $sql, array $params = []): array {
|
||||||
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exec(string $sql, array $params = []): int {
|
||||||
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
return $stmt->rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function begin(): void {
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function beginTransaction(): void {
|
||||||
|
$this->begin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function commit(): void {
|
||||||
|
$this->pdo->commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rollBack(): void {
|
||||||
|
if ($this->pdo->inTransaction()) {
|
||||||
|
$this->pdo->rollBack();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
355
app/db/SchemaTool.php
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
<?php
|
||||||
|
// app/db/SchemaTool.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Db;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
final class SchemaTool {
|
||||||
|
private Db $db;
|
||||||
|
private string $schemaPath;
|
||||||
|
|
||||||
|
public function __construct(Db $db, string $schemaPath) {
|
||||||
|
$this->db = $db;
|
||||||
|
$this->schemaPath = $schemaPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearIndex(): void {
|
||||||
|
$this->db->begin();
|
||||||
|
try {
|
||||||
|
$this->db->exec("DELETE FROM items");
|
||||||
|
$this->db->exec("DELETE FROM jobs");
|
||||||
|
$this->db->commit();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->db->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearContent(): void {
|
||||||
|
$this->db->begin();
|
||||||
|
try {
|
||||||
|
$this->db->exec("DELETE FROM media_metadata");
|
||||||
|
$this->db->exec("DELETE FROM media_file_meta");
|
||||||
|
$this->db->exec("DELETE FROM media_files");
|
||||||
|
$this->db->exec("DELETE FROM items");
|
||||||
|
$this->db->commit();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->db->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContentStats(): array {
|
||||||
|
$files = $this->db->fetchOne("SELECT COUNT(*) AS c FROM media_files");
|
||||||
|
$meta = $this->db->fetchOne("SELECT COUNT(*) AS c FROM media_metadata");
|
||||||
|
$items = $this->db->fetchOne("SELECT COUNT(*) AS c FROM items");
|
||||||
|
return [
|
||||||
|
'files' => (int)($files['c'] ?? 0),
|
||||||
|
'meta' => (int)($meta['c'] ?? 0),
|
||||||
|
'items' => (int)($items['c'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDbStats(): array {
|
||||||
|
$rows = $this->db->fetchAll("SHOW TABLE STATUS");
|
||||||
|
$tables = 0;
|
||||||
|
$size = 0;
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if (!isset($row['Name'])) continue;
|
||||||
|
$tables += 1;
|
||||||
|
$size += (int)($row['Data_length'] ?? 0) + (int)($row['Index_length'] ?? 0);
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'tables' => $tables,
|
||||||
|
'size_bytes' => $size,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDbInfo(): array {
|
||||||
|
$row = $this->db->fetchOne("SELECT DATABASE() AS db_name, USER() AS user_name, CURRENT_USER() AS current_user_name");
|
||||||
|
return [
|
||||||
|
'db_name' => (string)($row['db_name'] ?? ''),
|
||||||
|
'user_name' => (string)($row['user_name'] ?? ''),
|
||||||
|
'current_user' => (string)($row['current_user_name'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listTablesWithCounts(): array {
|
||||||
|
$rows = $this->db->fetchAll("SHOW TABLE STATUS");
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$name = (string)($row['Name'] ?? '');
|
||||||
|
if ($name === '') continue;
|
||||||
|
$out[] = [
|
||||||
|
'name' => $name,
|
||||||
|
'rows' => (int)($row['Rows'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetchTableRows(string $table, int $limit = 50): array {
|
||||||
|
$table = trim($table);
|
||||||
|
if ($table === '') {
|
||||||
|
throw new Exception('Table required');
|
||||||
|
}
|
||||||
|
$tables = $this->listTableNames();
|
||||||
|
if (!in_array($table, $tables, true)) {
|
||||||
|
throw new Exception('Unknown table');
|
||||||
|
}
|
||||||
|
$limit = max(1, min(500, (int)$limit));
|
||||||
|
$escaped = $this->escapeIdentifier($table);
|
||||||
|
$rows = $this->db->fetchAll("SELECT * FROM {$escaped} LIMIT {$limit}");
|
||||||
|
return [
|
||||||
|
'name' => $table,
|
||||||
|
'rows' => $rows,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dumpDatabase(): string {
|
||||||
|
$rows = $this->db->fetchAll("
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_type = 'BASE TABLE'
|
||||||
|
ORDER BY table_name
|
||||||
|
");
|
||||||
|
|
||||||
|
$tables = array_map(fn($r) => (string)$r['table_name'], $rows);
|
||||||
|
$out = "-- scMedia database dump\n";
|
||||||
|
$out .= "SET FOREIGN_KEY_CHECKS=0;\n\n";
|
||||||
|
|
||||||
|
foreach ($tables as $table) {
|
||||||
|
$escaped = "`" . str_replace("`", "``", $table) . "`";
|
||||||
|
$create = $this->db->fetchOne("SHOW CREATE TABLE {$escaped}");
|
||||||
|
$ddl = $create['Create Table'] ?? '';
|
||||||
|
if ($ddl === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out .= "DROP TABLE IF EXISTS {$escaped};\n";
|
||||||
|
$out .= $ddl . ";\n";
|
||||||
|
|
||||||
|
$data = $this->db->fetchAll("SELECT * FROM {$escaped}");
|
||||||
|
if (count($data) > 0) {
|
||||||
|
$cols = array_keys($data[0]);
|
||||||
|
$colList = implode(', ', array_map(fn($c) => "`" . str_replace("`", "``", $c) . "`", $cols));
|
||||||
|
foreach ($data as $row) {
|
||||||
|
$values = [];
|
||||||
|
foreach ($cols as $col) {
|
||||||
|
$values[] = $this->quoteValue($row[$col] ?? null);
|
||||||
|
}
|
||||||
|
$out .= "INSERT INTO {$escaped} ({$colList}) VALUES (" . implode(', ', $values) . ");\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$out .= "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$out .= "SET FOREIGN_KEY_CHECKS=1;\n";
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function listTableNames(): array {
|
||||||
|
$rows = $this->db->fetchAll("SHOW TABLES");
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$vals = array_values($row);
|
||||||
|
$name = (string)($vals[0] ?? '');
|
||||||
|
if ($name !== '') $out[] = $name;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function escapeIdentifier(string $name): string {
|
||||||
|
return '`' . str_replace('`', '``', $name) . '`';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restoreDatabaseFromSql(string $sql): array {
|
||||||
|
$log = [];
|
||||||
|
$log[] = "Restore DB: dropping all tables in current database...";
|
||||||
|
$this->dropAllTables($log);
|
||||||
|
|
||||||
|
$log[] = "Restore DB: importing dump...";
|
||||||
|
$this->importSql($sql, $log);
|
||||||
|
|
||||||
|
$log[] = "Restore DB: done.";
|
||||||
|
return $log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetDatabase(): array {
|
||||||
|
$log = [];
|
||||||
|
|
||||||
|
$log[] = "Reset DB: dropping all tables in current database...";
|
||||||
|
$this->dropAllTables($log);
|
||||||
|
|
||||||
|
$log[] = "Reset DB: importing schema.sql...";
|
||||||
|
$this->importSchema($log);
|
||||||
|
|
||||||
|
$log[] = "Reset DB: done.";
|
||||||
|
return $log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applySchema(): array {
|
||||||
|
$log = [];
|
||||||
|
$log[] = "Apply schema: importing schema.sql...";
|
||||||
|
$this->importSchema($log);
|
||||||
|
$log[] = "Apply schema: done.";
|
||||||
|
return $log;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dropAllTables(array &$log): void {
|
||||||
|
$this->db->exec("SET FOREIGN_KEY_CHECKS=0");
|
||||||
|
|
||||||
|
$rows = $this->db->fetchAll("
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_type = 'BASE TABLE'
|
||||||
|
");
|
||||||
|
|
||||||
|
if (count($rows) === 0) {
|
||||||
|
$log[] = "No tables found. Nothing to drop.";
|
||||||
|
$this->db->exec("SET FOREIGN_KEY_CHECKS=1");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tables = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$tables[] = "`" . str_replace("`", "``", (string)$r['table_name']) . "`";
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "DROP TABLE IF EXISTS " . implode(", ", $tables);
|
||||||
|
$this->db->exec($sql);
|
||||||
|
|
||||||
|
$this->db->exec("SET FOREIGN_KEY_CHECKS=1");
|
||||||
|
$log[] = "Dropped " . count($tables) . " tables.";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function importSchema(array &$log): void {
|
||||||
|
if (!is_file($this->schemaPath)) {
|
||||||
|
throw new Exception("schema.sql not found: " . $this->schemaPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = file_get_contents($this->schemaPath);
|
||||||
|
if ($sql === false) {
|
||||||
|
throw new Exception("Failed to read schema.sql");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->importSql($sql, $log);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function splitSqlStatements(string $sql): array {
|
||||||
|
// Simple splitter: handles -- comments and /* */ comments reasonably well.
|
||||||
|
// Does not support DELIMITER blocks (we don't use them).
|
||||||
|
$out = [];
|
||||||
|
$len = strlen($sql);
|
||||||
|
$buf = '';
|
||||||
|
$inString = false;
|
||||||
|
$stringChar = '';
|
||||||
|
$inLineComment = false;
|
||||||
|
$inBlockComment = false;
|
||||||
|
|
||||||
|
for ($i = 0; $i < $len; $i++) {
|
||||||
|
$ch = $sql[$i];
|
||||||
|
$next = ($i + 1 < $len) ? $sql[$i + 1] : '';
|
||||||
|
|
||||||
|
if ($inLineComment) {
|
||||||
|
if ($ch === "\n") {
|
||||||
|
$inLineComment = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($inBlockComment) {
|
||||||
|
if ($ch === '*' && $next === '/') {
|
||||||
|
$inBlockComment = false;
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$inString) {
|
||||||
|
if ($ch === '-' && $next === '-') {
|
||||||
|
$inLineComment = true;
|
||||||
|
$i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($ch === '/' && $next === '*') {
|
||||||
|
$inBlockComment = true;
|
||||||
|
$i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($inString) {
|
||||||
|
$buf .= $ch;
|
||||||
|
if ($ch === $stringChar) {
|
||||||
|
// Handle escaped quotes: '' or \" not fully, but good enough for schema.sql
|
||||||
|
$prev = ($i > 0) ? $sql[$i - 1] : '';
|
||||||
|
if ($prev !== '\\') {
|
||||||
|
$inString = false;
|
||||||
|
$stringChar = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ch === '\'' || $ch === '"') {
|
||||||
|
$inString = true;
|
||||||
|
$stringChar = $ch;
|
||||||
|
$buf .= $ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ch === ';') {
|
||||||
|
$out[] = $buf;
|
||||||
|
$buf = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$buf .= $ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim($buf) !== '') {
|
||||||
|
$out[] = $buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function importSql(string $sql, array &$log): void {
|
||||||
|
$statements = $this->splitSqlStatements($sql);
|
||||||
|
$log[] = "Statements: " . count($statements);
|
||||||
|
|
||||||
|
foreach ($statements as $i => $stmt) {
|
||||||
|
$trim = trim($stmt);
|
||||||
|
if ($trim === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->db->pdo()->exec($trim);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$log[] = "ERROR at statement #" . ($i + 1) . ": " . $e->getMessage();
|
||||||
|
$log[] = "Statement: " . mb_substr($trim, 0, 5000);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function quoteValue($value): string {
|
||||||
|
if ($value === null) {
|
||||||
|
return 'NULL';
|
||||||
|
}
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return $value ? '1' : '0';
|
||||||
|
}
|
||||||
|
if (is_int($value) || is_float($value)) {
|
||||||
|
return (string)$value;
|
||||||
|
}
|
||||||
|
return $this->db->pdo()->quote((string)$value);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/http/Response.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
// app/http/Response.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Http;
|
||||||
|
|
||||||
|
final class Response {
|
||||||
|
public static function json(array $payload, int $code = 200): void {
|
||||||
|
http_response_code($code);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
$out = function_exists('normalize_dates_in_array')
|
||||||
|
? normalize_dates_in_array($payload)
|
||||||
|
: $payload;
|
||||||
|
echo json_encode($out, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function html(string $html, int $code = 200): void {
|
||||||
|
http_response_code($code);
|
||||||
|
header('Content-Type: text/html; charset=utf-8');
|
||||||
|
echo $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve static HTML from public/assets safely (no traversal).
|
||||||
|
* Example: Response::assetHtml('settings.html');
|
||||||
|
*/
|
||||||
|
public static function assetHtml(string $assetFile, int $code = 200): void {
|
||||||
|
$assetFile = ltrim($assetFile, '/');
|
||||||
|
|
||||||
|
if (str_contains($assetFile, '..') || str_contains($assetFile, "\0")) {
|
||||||
|
http_response_code(400);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo "Bad request";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseDir = realpath(__DIR__ . '/../../public/assets');
|
||||||
|
if ($baseDir === false) {
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo "Assets directory not found";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fullPath = realpath($baseDir . '/' . $assetFile);
|
||||||
|
if ($fullPath === false || !str_starts_with($fullPath, $baseDir) || !is_file($fullPath)) {
|
||||||
|
http_response_code(404);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo "Not found";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code($code);
|
||||||
|
header('Content-Type: text/html; charset=utf-8');
|
||||||
|
readfile($fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/http/Router.php
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
// app/http/Router.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Http;
|
||||||
|
|
||||||
|
final class Router {
|
||||||
|
private array $routes = [];
|
||||||
|
|
||||||
|
public function get(string $path, callable $handler): void {
|
||||||
|
$this->routes[] = ['GET', $path, $handler];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function post(string $path, callable $handler): void {
|
||||||
|
$this->routes[] = ['POST', $path, $handler];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function put(string $path, callable $handler): void {
|
||||||
|
$this->routes[] = ['PUT', $path, $handler];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $path, callable $handler): void {
|
||||||
|
$this->routes[] = ['DELETE', $path, $handler];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add(string $method, string $path, callable $handler): void {
|
||||||
|
$method = strtoupper($method);
|
||||||
|
$allowed = ['GET', 'POST', 'PUT', 'DELETE'];
|
||||||
|
if (!in_array($method, $allowed, true)) {
|
||||||
|
throw new \InvalidArgumentException("Unsupported method: {$method}");
|
||||||
|
}
|
||||||
|
$this->routes[] = [$method, $path, $handler];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dispatch(string $method, string $uriPath): void {
|
||||||
|
foreach ($this->routes as $r) {
|
||||||
|
[$m, $p, $h] = $r;
|
||||||
|
if ($m !== $method) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$params = $this->match($p, $uriPath);
|
||||||
|
if ($params === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$h($params);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($uriPath, '/api/')) {
|
||||||
|
Response::json([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'NOT_FOUND',
|
||||||
|
'message' => 'Route not found',
|
||||||
|
],
|
||||||
|
], 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Response::html('<h1>404</h1>', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function match(string $pattern, string $path): ?array {
|
||||||
|
// Pattern supports: /api/scan-profiles/{id}
|
||||||
|
$pParts = explode('/', trim($pattern, '/'));
|
||||||
|
$uParts = explode('/', trim($path, '/'));
|
||||||
|
|
||||||
|
if (count($pParts) !== count($uParts)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$params = [];
|
||||||
|
for ($i = 0; $i < count($pParts); $i++) {
|
||||||
|
$pp = $pParts[$i];
|
||||||
|
$up = $uParts[$i];
|
||||||
|
|
||||||
|
if (preg_match('/^\{([a-zA-Z0-9_]+)\}$/', $pp, $m)) {
|
||||||
|
$params[$m[1]] = $up;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pp !== $up) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $params;
|
||||||
|
}
|
||||||
|
}
|
||||||
225
app/http/helpers.php
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
<?php
|
||||||
|
// app/http/helpers.php
|
||||||
|
// English comments: shared HTTP helpers for controllers (no framework dependencies)
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
function read_json_body(): array {
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
if ($raw === false || trim($raw) === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
if (!is_array($data)) return [];
|
||||||
|
return normalize_dates_in_array($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function json_response(array $payload, int $code = 200): void {
|
||||||
|
http_response_code($code);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode(normalize_dates_in_array($payload), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function require_debug(array $config, bool $dangerous = false): void {
|
||||||
|
$enabled = (bool)($config['app']['debug_tools_enabled'] ?? false);
|
||||||
|
if (!$enabled) {
|
||||||
|
json_response(['ok' => false, 'error' => ['code' => 'FORBIDDEN', 'message' => 'Debug tools disabled']], 403);
|
||||||
|
}
|
||||||
|
if ($dangerous) {
|
||||||
|
$allow = (bool)($config['app']['allow_db_reset'] ?? false);
|
||||||
|
if (!$allow) {
|
||||||
|
json_response(['ok' => false, 'error' => ['code' => 'FORBIDDEN', 'message' => 'DB reset disabled']], 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function table_params(int $defaultPerPage = 50, int $maxPerPage = 500): array {
|
||||||
|
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
|
||||||
|
$perPage = isset($_GET['per_page']) ? (int)$_GET['per_page'] : $defaultPerPage;
|
||||||
|
if ($page < 1) $page = 1;
|
||||||
|
if ($perPage < 1) $perPage = $defaultPerPage;
|
||||||
|
if ($perPage > $maxPerPage) $perPage = $maxPerPage;
|
||||||
|
$sort = isset($_GET['sort']) ? (string)$_GET['sort'] : '';
|
||||||
|
$dir = isset($_GET['dir']) ? strtolower((string)$_GET['dir']) : 'asc';
|
||||||
|
if (!in_array($dir, ['asc', 'desc'], true)) $dir = 'asc';
|
||||||
|
return [$page, $perPage, $sort, $dir];
|
||||||
|
}
|
||||||
|
|
||||||
|
function table_body_params(int $defaultPerPage = 50, int $maxPerPage = 500): array {
|
||||||
|
$body = read_json_body();
|
||||||
|
$page = isset($body['page']) ? (int)$body['page'] : 1;
|
||||||
|
$perPage = isset($body['per_page']) ? (int)$body['per_page'] : $defaultPerPage;
|
||||||
|
if ($page < 1) $page = 1;
|
||||||
|
if ($perPage < 1) $perPage = $defaultPerPage;
|
||||||
|
if ($perPage > $maxPerPage) $perPage = $maxPerPage;
|
||||||
|
$sort = isset($body['sort']) ? (string)$body['sort'] : '';
|
||||||
|
$dir = isset($body['dir']) ? strtolower((string)$body['dir']) : 'asc';
|
||||||
|
if (!in_array($dir, ['asc', 'desc'], true)) $dir = 'asc';
|
||||||
|
$filters = is_array($body['filters'] ?? null) ? $body['filters'] : [];
|
||||||
|
$params = is_array($body['params'] ?? null) ? $body['params'] : [];
|
||||||
|
return [$page, $perPage, $sort, $dir, $filters, $params];
|
||||||
|
}
|
||||||
|
|
||||||
|
function find_filter(array $filters, string $key): ?array {
|
||||||
|
foreach ($filters as $filter) {
|
||||||
|
if (!is_array($filter)) continue;
|
||||||
|
if ((string)($filter['key'] ?? '') === $key) {
|
||||||
|
return $filter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filter_value(array $filters, string $key) {
|
||||||
|
$filter = find_filter($filters, $key);
|
||||||
|
if (!$filter) return null;
|
||||||
|
return $filter['value'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filter_op(array $filters, string $key): string {
|
||||||
|
$filter = find_filter($filters, $key);
|
||||||
|
if (!$filter) return '';
|
||||||
|
return (string)($filter['op'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply_filters_array(array $items, array $filters, array $fieldMap = []): array {
|
||||||
|
if (count($filters) === 0) return $items;
|
||||||
|
$out = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if (!is_array($item)) continue;
|
||||||
|
$ok = true;
|
||||||
|
foreach ($filters as $filter) {
|
||||||
|
if (!is_array($filter)) continue;
|
||||||
|
$key = (string)($filter['key'] ?? '');
|
||||||
|
if ($key === '') continue;
|
||||||
|
$op = (string)($filter['op'] ?? 'eq');
|
||||||
|
$value = $filter['value'] ?? null;
|
||||||
|
$field = $fieldMap[$key] ?? $key;
|
||||||
|
$current = null;
|
||||||
|
if (is_callable($field)) {
|
||||||
|
$current = $field($item);
|
||||||
|
} elseif (is_string($field)) {
|
||||||
|
$current = $item[$field] ?? null;
|
||||||
|
}
|
||||||
|
if (!filter_match($current, $op, $value)) {
|
||||||
|
$ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($ok) $out[] = $item;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filter_match($current, string $op, $value): bool {
|
||||||
|
$op = strtolower($op);
|
||||||
|
if ($op === 'empty') {
|
||||||
|
if (is_array($current)) return count($current) === 0;
|
||||||
|
return $current === null || $current === '';
|
||||||
|
}
|
||||||
|
if ($op === 'in') {
|
||||||
|
if (!is_array($value)) return false;
|
||||||
|
return in_array($current, $value, true);
|
||||||
|
}
|
||||||
|
$cur = normalize_filter_scalar($current);
|
||||||
|
if ($op === 'like') {
|
||||||
|
$needle = strtolower((string)$value);
|
||||||
|
return $needle === '' ? true : (str_contains(strtolower((string)$cur), $needle));
|
||||||
|
}
|
||||||
|
if ($op === 'between') {
|
||||||
|
if (!is_array($value) || count($value) < 2) return false;
|
||||||
|
[$from, $to] = $value;
|
||||||
|
$left = normalize_filter_scalar($from);
|
||||||
|
$right = normalize_filter_scalar($to);
|
||||||
|
if (is_numeric($cur) && is_numeric($left) && is_numeric($right)) {
|
||||||
|
return (float)$cur >= (float)$left && (float)$cur <= (float)$right;
|
||||||
|
}
|
||||||
|
$curTs = normalize_filter_timestamp($cur);
|
||||||
|
$fromTs = normalize_filter_timestamp($left);
|
||||||
|
$toTs = normalize_filter_timestamp($right);
|
||||||
|
if ($curTs !== null && $fromTs !== null && $toTs !== null) {
|
||||||
|
return $curTs >= $fromTs && $curTs <= $toTs;
|
||||||
|
}
|
||||||
|
return (string)$cur >= (string)$left && (string)$cur <= (string)$right;
|
||||||
|
}
|
||||||
|
if ($op === 'gt' || $op === 'lt') {
|
||||||
|
if (is_numeric($cur) && is_numeric($value)) {
|
||||||
|
return $op === 'gt' ? (float)$cur > (float)$value : (float)$cur < (float)$value;
|
||||||
|
}
|
||||||
|
return $op === 'gt' ? (string)$cur > (string)$value : (string)$cur < (string)$value;
|
||||||
|
}
|
||||||
|
$needle = normalize_filter_scalar($value);
|
||||||
|
if (is_string($cur) || is_string($needle)) {
|
||||||
|
return strtolower((string)$cur) === strtolower((string)$needle);
|
||||||
|
}
|
||||||
|
return $cur === $needle;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize_filter_scalar($value) {
|
||||||
|
if (is_array($value)) return '';
|
||||||
|
if (is_bool($value)) return $value ? '1' : '0';
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize_filter_timestamp($value): ?int {
|
||||||
|
if (!is_string($value)) return null;
|
||||||
|
$ts = strtotime($value);
|
||||||
|
if ($ts === false) return null;
|
||||||
|
return $ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paginate_array(array $items, int $page, int $perPage): array {
|
||||||
|
$total = count($items);
|
||||||
|
$start = ($page - 1) * $perPage;
|
||||||
|
$slice = array_slice($items, $start, $perPage);
|
||||||
|
return [$slice, $total];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize_dates_in_array($data) {
|
||||||
|
if (!is_array($data)) return $data;
|
||||||
|
$out = [];
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
if (is_array($value)) {
|
||||||
|
$out[$key] = normalize_dates_in_array($value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (is_string($value) && should_normalize_date_key((string)$key)) {
|
||||||
|
$normalized = normalize_datetime_string($value);
|
||||||
|
$out[$key] = $normalized ?? $value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[$key] = $value;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function should_normalize_date_key(string $key): bool {
|
||||||
|
if ($key === 'ts') return true;
|
||||||
|
return preg_match('/(_at|_ts)$/', $key) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize_datetime_string(string $value): ?string {
|
||||||
|
$raw = trim($value);
|
||||||
|
if ($raw === '') return null;
|
||||||
|
|
||||||
|
$tz = new DateTimeZone('UTC');
|
||||||
|
|
||||||
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) {
|
||||||
|
$raw .= ' 00:00:00';
|
||||||
|
} elseif (preg_match('/^\d{2}\.\d{2}\.\d{4}$/', $raw)) {
|
||||||
|
$parts = explode('.', $raw);
|
||||||
|
$raw = sprintf('%s-%s-%s 00:00:00', $parts[2], $parts[1], $parts[0]);
|
||||||
|
} elseif (preg_match('/^\d{2}\.\d{2}\.\d{4}\s+\d{2}:\d{2}:\d{2}$/', $raw)) {
|
||||||
|
[$datePart, $timePart] = explode(' ', $raw, 2);
|
||||||
|
$parts = explode('.', $datePart);
|
||||||
|
$raw = sprintf('%s-%s-%s %s', $parts[2], $parts[1], $parts[0], $timePart);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$dt = new DateTimeImmutable($raw, $tz);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $dt->setTimezone($tz)->format('Y-m-d H:i:s');
|
||||||
|
}
|
||||||
1145
app/services/AuthService.php
Normal file
55
app/services/ExportService.php
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
// app/services/ExportService.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Services;
|
||||||
|
|
||||||
|
use ScMedia\Services\Export\ExporterInterface;
|
||||||
|
|
||||||
|
final class ExportService {
|
||||||
|
private SettingsService $settings;
|
||||||
|
/** @var ExporterInterface[] */
|
||||||
|
private array $exporters;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param ExporterInterface[] $exporters
|
||||||
|
*/
|
||||||
|
public function __construct(SettingsService $settings, array $exporters) {
|
||||||
|
$this->settings = $settings;
|
||||||
|
$this->exporters = $exporters;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEnabledExporters(): array {
|
||||||
|
$all = $this->settings->getAll();
|
||||||
|
$cfg = $all['exports'] ?? [];
|
||||||
|
$enabled = [];
|
||||||
|
foreach ($this->exporters as $exp) {
|
||||||
|
$id = $exp->id();
|
||||||
|
if (!empty($cfg[$id]['enabled'])) {
|
||||||
|
$enabled[$id] = $exp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportMovie(string $filePath, array $meta): array {
|
||||||
|
$enabled = $this->getEnabledExporters();
|
||||||
|
$out = [];
|
||||||
|
foreach ($enabled as $id => $exp) {
|
||||||
|
if (!$exp->supportsKind('movie')) continue;
|
||||||
|
$out[$id] = $exp->exportMovie($filePath, $meta);
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportSeries(string $seriesPath, array $meta): array {
|
||||||
|
$enabled = $this->getEnabledExporters();
|
||||||
|
$out = [];
|
||||||
|
foreach ($enabled as $id => $exp) {
|
||||||
|
if (!$exp->supportsKind('series')) continue;
|
||||||
|
$out[$id] = $exp->exportSeries($seriesPath, $meta);
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/services/HttpClient.php
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
// app/services/HttpClient.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Services;
|
||||||
|
|
||||||
|
final class HttpClient {
|
||||||
|
public function getJson(string $url, array $headers = []): array {
|
||||||
|
$res = $this->request('GET', $url, null, $headers);
|
||||||
|
return $this->decodeJson($res);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postJson(string $url, array $payload, array $headers = []): array {
|
||||||
|
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($body === false) {
|
||||||
|
$body = '{}';
|
||||||
|
}
|
||||||
|
$headers[] = 'Content-Type: application/json';
|
||||||
|
$res = $this->request('POST', $url, $body, $headers);
|
||||||
|
return $this->decodeJson($res);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function request(string $method, string $url, ?string $body, array $headers): array {
|
||||||
|
if (!function_exists('curl_init')) {
|
||||||
|
return ['ok' => false, 'status' => 0, 'error' => 'cURL not available', 'raw' => '', 'headers' => []];
|
||||||
|
}
|
||||||
|
$ch = \curl_init();
|
||||||
|
curl_setopt($ch, CURLOPT_URL, $url);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||||
|
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 8);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
|
||||||
|
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||||
|
$respHeaders = [];
|
||||||
|
\curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$respHeaders) {
|
||||||
|
$len = strlen($header);
|
||||||
|
$header = trim($header);
|
||||||
|
if ($header === '' || strpos($header, ':') === false) return $len;
|
||||||
|
[$name, $value] = explode(':', $header, 2);
|
||||||
|
$respHeaders[strtolower(trim($name))] = trim($value);
|
||||||
|
return $len;
|
||||||
|
});
|
||||||
|
if ($body !== null) {
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||||
|
}
|
||||||
|
if (count($headers) > 0) {
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||||
|
}
|
||||||
|
$raw = \curl_exec($ch);
|
||||||
|
$err = \curl_error($ch);
|
||||||
|
$code = (int)\curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||||
|
\curl_close($ch);
|
||||||
|
|
||||||
|
if ($raw === false) {
|
||||||
|
return ['ok' => false, 'status' => $code, 'error' => $err !== '' ? $err : 'request failed', 'raw' => '', 'headers' => $respHeaders];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => ($code >= 200 && $code < 300), 'status' => $code, 'raw' => (string)$raw, 'headers' => $respHeaders];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeJson(array $res): array {
|
||||||
|
if (empty($res['raw'])) {
|
||||||
|
return $res + ['data' => null];
|
||||||
|
}
|
||||||
|
$data = json_decode((string)$res['raw'], true);
|
||||||
|
return $res + ['data' => is_array($data) ? $data : null];
|
||||||
|
}
|
||||||
|
}
|
||||||
166
app/services/JobsService.php
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
// app/services/JobsService.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Services;
|
||||||
|
|
||||||
|
use ScMedia\Db\Db;
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
final class JobsService {
|
||||||
|
private Db $db;
|
||||||
|
private ?LogService $logger;
|
||||||
|
|
||||||
|
public function __construct(Db $db, ?LogService $logger = null) {
|
||||||
|
$this->db = $db;
|
||||||
|
$this->logger = $logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(string $type, string $title, ?array $payload = null): int {
|
||||||
|
$payloadJson = $payload === null ? null : json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
$this->db->exec("
|
||||||
|
INSERT INTO jobs (type, status, title, payload_json)
|
||||||
|
VALUES (:type, 'queued', :title, :payload)
|
||||||
|
", [
|
||||||
|
':type' => $type,
|
||||||
|
':title' => $title,
|
||||||
|
':payload' => $payloadJson,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$row = $this->db->fetchOne("SELECT LAST_INSERT_ID() AS id");
|
||||||
|
return (int)($row['id'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(int $id): ?array {
|
||||||
|
return $this->db->fetchOne("SELECT * FROM jobs WHERE id=:id", [':id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatus(int $id): ?string {
|
||||||
|
$row = $this->db->fetchOne("SELECT status FROM jobs WHERE id=:id", [':id' => $id]);
|
||||||
|
return $row ? (string)$row['status'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetchNextQueued(): ?array {
|
||||||
|
return $this->db->fetchOne("
|
||||||
|
SELECT * FROM jobs
|
||||||
|
WHERE status='queued'
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function start(int $id): void {
|
||||||
|
$this->db->exec("
|
||||||
|
UPDATE jobs
|
||||||
|
SET status='running', started_at=NOW(), error_message=NULL, cancel_requested=0, last_heartbeat=NOW()
|
||||||
|
WHERE id=:id AND status='queued'
|
||||||
|
", [':id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setProgress(int $id, int $current, int $total): void {
|
||||||
|
$pct = 0;
|
||||||
|
if ($total > 0) {
|
||||||
|
$pct = (int)floor(($current / $total) * 100);
|
||||||
|
if ($pct < 0) $pct = 0;
|
||||||
|
if ($pct > 100) $pct = 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->exec("
|
||||||
|
UPDATE jobs
|
||||||
|
SET progress_current=:c, progress_total=:t, progress_pct=:p, last_heartbeat=NOW()
|
||||||
|
WHERE id=:id
|
||||||
|
", [
|
||||||
|
':id' => $id,
|
||||||
|
':c' => $current,
|
||||||
|
':t' => $total,
|
||||||
|
':p' => $pct,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function log(int $jobId, string $level, string $message): void {
|
||||||
|
$level = in_array($level, ['info','warn','error','debug'], true) ? $level : 'info';
|
||||||
|
$this->db->exec("
|
||||||
|
INSERT INTO job_logs (job_id, level, message)
|
||||||
|
VALUES (:job, :level, :msg)
|
||||||
|
", [
|
||||||
|
':job' => $jobId,
|
||||||
|
':level' => $level,
|
||||||
|
':msg' => $message,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->db->exec("
|
||||||
|
UPDATE jobs
|
||||||
|
SET last_heartbeat=NOW()
|
||||||
|
WHERE id=:id
|
||||||
|
", [':id' => $jobId]);
|
||||||
|
|
||||||
|
if ($this->logger) {
|
||||||
|
$this->logger->log($level, $message, ['job_id' => $jobId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function finish(int $id): void {
|
||||||
|
$this->db->exec("
|
||||||
|
UPDATE jobs
|
||||||
|
SET status='done', finished_at=NOW()
|
||||||
|
WHERE id=:id
|
||||||
|
", [':id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fail(int $id, string $errorMessage): void {
|
||||||
|
$this->db->exec("
|
||||||
|
UPDATE jobs
|
||||||
|
SET status='error', error_message=:e, finished_at=NOW()
|
||||||
|
WHERE id=:id
|
||||||
|
", [
|
||||||
|
':id' => $id,
|
||||||
|
':e' => $errorMessage,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requestCancel(int $id): void {
|
||||||
|
$this->db->exec("
|
||||||
|
UPDATE jobs
|
||||||
|
SET cancel_requested=1
|
||||||
|
WHERE id=:id AND status IN ('queued','running')
|
||||||
|
", [':id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelNow(int $id, string $reason = 'canceled'): void {
|
||||||
|
$this->db->exec("
|
||||||
|
UPDATE jobs
|
||||||
|
SET status='canceled', error_message=:e, finished_at=NOW()
|
||||||
|
WHERE id=:id AND status IN ('queued','running')
|
||||||
|
", [
|
||||||
|
':id' => $id,
|
||||||
|
':e' => $reason,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isCancelRequested(int $id): bool {
|
||||||
|
$row = $this->db->fetchOne("SELECT cancel_requested, status FROM jobs WHERE id=:id", [':id' => $id]);
|
||||||
|
if (!$row) return false;
|
||||||
|
if ((string)($row['status'] ?? '') === 'canceled') return true;
|
||||||
|
return !empty($row['cancel_requested']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelIfRequested(int $id): bool {
|
||||||
|
if ($this->isCancelRequested($id)) {
|
||||||
|
$this->cancelNow($id, 'cancel requested');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markStalled(int $minutes): int {
|
||||||
|
$minutes = max(1, $minutes);
|
||||||
|
return $this->db->exec("
|
||||||
|
UPDATE jobs
|
||||||
|
SET status='error', error_message='stalled watchdog', finished_at=NOW()
|
||||||
|
WHERE status='running' AND last_heartbeat IS NOT NULL
|
||||||
|
AND last_heartbeat < (NOW() - INTERVAL {$minutes} MINUTE)
|
||||||
|
");
|
||||||
|
}
|
||||||
|
}
|
||||||
227
app/services/LayoutService.php
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
<?php
|
||||||
|
// app/services/LayoutService.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Services;
|
||||||
|
|
||||||
|
use ScMedia\Db\Db;
|
||||||
|
|
||||||
|
final class LayoutService {
|
||||||
|
private Db $db;
|
||||||
|
private array $config;
|
||||||
|
|
||||||
|
public function __construct(Db $db, array $config) {
|
||||||
|
$this->db = $db;
|
||||||
|
$this->config = $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function preview(array $settingsAll, string $kind, string $mode, int $limit = 10, ?array $sampleItems = null): array {
|
||||||
|
$kind = ($kind === 'series') ? 'series' : 'movies';
|
||||||
|
$limit = max(1, min($limit, 50));
|
||||||
|
|
||||||
|
$paths = $settingsAll['paths'] ?? [];
|
||||||
|
$layout = $settingsAll['layout'] ?? [];
|
||||||
|
$root = ($kind === 'series') ? (string)($paths['series_root'] ?? '') : (string)($paths['movies_root'] ?? '');
|
||||||
|
|
||||||
|
$cfg = ($kind === 'series') ? ($layout['series'] ?? []) : ($layout['movies'] ?? []);
|
||||||
|
$norm = $layout['normalization'] ?? [];
|
||||||
|
$collision = (string)($layout['collision_policy'] ?? 'stop');
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
if ($mode === 'samples') {
|
||||||
|
$items = $this->defaultSamples($kind);
|
||||||
|
} else if (is_array($sampleItems)) {
|
||||||
|
$items = $sampleItems;
|
||||||
|
} else {
|
||||||
|
// Pull some items from DB (best effort)
|
||||||
|
$rows = $this->db->fetchAll("
|
||||||
|
SELECT display_name, year
|
||||||
|
FROM items
|
||||||
|
WHERE status = 'active'
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT :lim
|
||||||
|
", [':lim' => $limit]);
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$items[] = [
|
||||||
|
'title' => (string)$r['display_name'],
|
||||||
|
'year' => isset($r['year']) ? (int)$r['year'] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (count($items) === 0) {
|
||||||
|
$items = $this->defaultSamples($kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$examples = [];
|
||||||
|
foreach (array_slice($items, 0, $limit) as $it) {
|
||||||
|
$title = (string)($it['title'] ?? '');
|
||||||
|
$year = $it['year'] ?? null;
|
||||||
|
|
||||||
|
$folderName = $this->buildDisplayFolder($title, $year);
|
||||||
|
$rel = $this->computeShardPath($cfg, $norm, $folderName, $year);
|
||||||
|
$abs = rtrim($root, '/') . '/' . ltrim($rel, '/');
|
||||||
|
|
||||||
|
$examples[] = [
|
||||||
|
'input' => ['title' => $title, 'year' => $year],
|
||||||
|
'output_rel' => $rel,
|
||||||
|
'output_abs' => $abs,
|
||||||
|
'notes' => [
|
||||||
|
'collision_policy=' . $collision,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'kind' => $kind,
|
||||||
|
'examples' => $examples,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildDisplayFolder(string $title, $year): string {
|
||||||
|
$t = trim($title);
|
||||||
|
if ($year !== null && is_int($year) && $year > 1800 && $year < 2100) {
|
||||||
|
return $t . " (" . $year . ")";
|
||||||
|
}
|
||||||
|
return $t;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function computeShardPath(array $cfg, array $norm, string $folderName, $year): string {
|
||||||
|
$strategy = (string)($cfg['strategy'] ?? 'prefix');
|
||||||
|
$params = is_array($cfg['params'] ?? null) ? $cfg['params'] : [];
|
||||||
|
$template = $cfg['template'] ?? null;
|
||||||
|
|
||||||
|
$name = $this->normalizeName($folderName, $norm);
|
||||||
|
$base = $name;
|
||||||
|
|
||||||
|
if ($strategy === 'flat') {
|
||||||
|
return $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($strategy === 'first_letter') {
|
||||||
|
$sh = $this->firstLetters($name, 1, (bool)($norm['uppercase_shards'] ?? true));
|
||||||
|
return $sh . '/' . $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($strategy === 'prefix') {
|
||||||
|
$n = (int)($params['n'] ?? 2);
|
||||||
|
$n = max(1, min($n, 4));
|
||||||
|
$sh = $this->firstLetters($name, $n, (bool)($norm['uppercase_shards'] ?? true));
|
||||||
|
return $sh . '/' . $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($strategy === 'hash_buckets') {
|
||||||
|
$b = (int)($params['buckets'] ?? 100);
|
||||||
|
if (!in_array($b, [10, 50, 100, 200], true)) {
|
||||||
|
$b = 100;
|
||||||
|
}
|
||||||
|
$idx = crc32(mb_strtolower($name, 'UTF-8')) % $b;
|
||||||
|
$sh = str_pad((string)$idx, 2, '0', STR_PAD_LEFT);
|
||||||
|
return $sh . '/' . $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($strategy === 'by_year') {
|
||||||
|
$y = (is_int($year) && $year > 1800 && $year < 2100) ? (string)$year : '_unknown';
|
||||||
|
return $y . '/' . $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($strategy === 'letter_year') {
|
||||||
|
$sh = $this->firstLetters($name, 1, (bool)($norm['uppercase_shards'] ?? true));
|
||||||
|
$y = (is_int($year) && $year > 1800 && $year < 2100) ? (string)$year : '_unknown';
|
||||||
|
return $sh . '/' . $y . '/' . $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($strategy === 'decade_year') {
|
||||||
|
$y = (is_int($year) && $year > 1800 && $year < 2100) ? $year : null;
|
||||||
|
$dec = ($y !== null) ? ((int)floor($y / 10) * 10) . "s" : '_unknown';
|
||||||
|
$yy = ($y !== null) ? (string)$y : '_unknown';
|
||||||
|
return $dec . '/' . $yy . '/' . $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($strategy === 'custom' && is_string($template) && trim($template) !== '') {
|
||||||
|
return $this->applyTemplate($template, $name, $year);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default fallback
|
||||||
|
$sh = $this->firstLetters($name, 2, (bool)($norm['uppercase_shards'] ?? true));
|
||||||
|
return $sh . '/' . $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeName(string $name, array $norm): string {
|
||||||
|
$s = $name;
|
||||||
|
|
||||||
|
if (!empty($norm['trim_dots_spaces'])) {
|
||||||
|
$s = trim($s, " \t\n\r\0\x0B.");
|
||||||
|
} else {
|
||||||
|
$s = trim($s);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($norm['ignore_articles'])) {
|
||||||
|
$lower = mb_strtolower($s, 'UTF-8');
|
||||||
|
foreach (['the ', 'a ', 'an '] as $a) {
|
||||||
|
if (str_starts_with($lower, $a)) {
|
||||||
|
$s = trim(mb_substr($s, mb_strlen($a, 'UTF-8'), null, 'UTF-8'));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($norm['replace_unsafe_chars'])) {
|
||||||
|
$s = preg_replace('/[\/\\\\:\*\?"<>\|]+/u', ' ', $s);
|
||||||
|
$s = preg_replace('/\s+/u', ' ', $s);
|
||||||
|
$s = trim((string)$s);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function firstLetters(string $name, int $n, bool $upper): string {
|
||||||
|
$t = mb_strtolower(trim($name), 'UTF-8');
|
||||||
|
if ($t === '') {
|
||||||
|
return '_';
|
||||||
|
}
|
||||||
|
$letters = mb_substr($t, 0, $n, 'UTF-8');
|
||||||
|
if ($upper) {
|
||||||
|
$letters = mb_strtoupper($letters, 'UTF-8');
|
||||||
|
}
|
||||||
|
// sanitize shard
|
||||||
|
$letters = preg_replace('/[^0-9A-Za-zА-Яа-я]+/u', '_', (string)$letters);
|
||||||
|
return $letters !== '' ? $letters : '_';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyTemplate(string $tpl, string $name, $year): string {
|
||||||
|
$y = (is_int($year) && $year > 1800 && $year < 2100) ? (string)$year : '_unknown';
|
||||||
|
$dec = (is_int($year) && $year > 1800 && $year < 2100) ? ((int)floor($year / 10) * 10) . "s" : '_unknown';
|
||||||
|
|
||||||
|
$rep = [
|
||||||
|
'{title}' => $name,
|
||||||
|
'{year}' => $y,
|
||||||
|
'{decade}' => $dec,
|
||||||
|
'{first}' => $this->firstLetters($name, 1, true),
|
||||||
|
'{first:2}' => $this->firstLetters($name, 2, true),
|
||||||
|
'{first:3}' => $this->firstLetters($name, 3, true),
|
||||||
|
'{hash:2}' => str_pad((string)(crc32(mb_strtolower($name, 'UTF-8')) % 100), 2, '0', STR_PAD_LEFT),
|
||||||
|
];
|
||||||
|
|
||||||
|
$out = strtr($tpl, $rep);
|
||||||
|
$out = trim($out, "/ \t\n\r\0\x0B");
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function defaultSamples(string $kind): array {
|
||||||
|
if ($kind === 'series') {
|
||||||
|
return [
|
||||||
|
['title' => 'Breaking Bad', 'year' => 2008],
|
||||||
|
['title' => 'The Office', 'year' => 2005],
|
||||||
|
['title' => 'Чернобыль', 'year' => 2019],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
['title' => 'Avatar', 'year' => 2009],
|
||||||
|
['title' => 'The Matrix', 'year' => 1999],
|
||||||
|
['title' => 'Сталкер', 'year' => 1979],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
184
app/services/LogService.php
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
// app/services/LogService.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Services;
|
||||||
|
|
||||||
|
use ScMedia\Db\Db;
|
||||||
|
|
||||||
|
final class LogService {
|
||||||
|
private Db $db;
|
||||||
|
private SettingsService $settings;
|
||||||
|
|
||||||
|
public function __construct(Db $db, SettingsService $settings) {
|
||||||
|
$this->db = $db;
|
||||||
|
$this->settings = $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function log(string $level, string $message, ?array $context = null): void {
|
||||||
|
$level = in_array($level, ['info','warn','error','debug'], true) ? $level : 'info';
|
||||||
|
if (!$this->shouldLog($level)) return;
|
||||||
|
$ctxJson = $context ? json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null;
|
||||||
|
$this->db->exec("
|
||||||
|
INSERT INTO app_logs (level, message, context_json)
|
||||||
|
VALUES (:level, :msg, :ctx)
|
||||||
|
", [
|
||||||
|
':level' => $level,
|
||||||
|
':msg' => $message,
|
||||||
|
':ctx' => $ctxJson,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listByDate(string $date, int $limit = 5000): array {
|
||||||
|
$date = trim($date);
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$limit = max(1, min((int)$limit, 5000));
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT id, ts, level, message, context_json
|
||||||
|
FROM app_logs
|
||||||
|
WHERE ts >= :d AND ts < DATE_ADD(:d, INTERVAL 1 DAY)
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT {$limit}
|
||||||
|
";
|
||||||
|
return $this->db->fetchAll($sql, [
|
||||||
|
':d' => $date . ' 00:00:00',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listByRange(string $from, string $to, int $limit = 5000): array {
|
||||||
|
$from = trim($from);
|
||||||
|
$to = trim($to);
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $from) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$limit = max(1, min((int)$limit, 5000));
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT id, ts, level, message, context_json
|
||||||
|
FROM app_logs
|
||||||
|
WHERE ts >= :from AND ts < DATE_ADD(:to, INTERVAL 1 DAY)
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT {$limit}
|
||||||
|
";
|
||||||
|
return $this->db->fetchAll($sql, [
|
||||||
|
':from' => $from . ' 00:00:00',
|
||||||
|
':to' => $to . ' 00:00:00',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listByDatePaged(string $date, int $page = 1, int $perPage = 100, ?string $level = null, ?string $sort = null, ?string $dir = null): array {
|
||||||
|
$date = trim($date);
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||||
|
return ['items' => [], 'total' => 0, 'page' => 1, 'per_page' => 0];
|
||||||
|
}
|
||||||
|
$per = max(1, min((int)$perPage, 500));
|
||||||
|
$p = max(1, (int)$page);
|
||||||
|
$offset = ($p - 1) * $per;
|
||||||
|
|
||||||
|
[$whereSql, $params] = $this->buildLogsFilter($date, $date, $level);
|
||||||
|
|
||||||
|
$countRow = $this->db->fetchOne("SELECT COUNT(*) AS cnt FROM app_logs {$whereSql}", $params) ?? [];
|
||||||
|
$total = (int)($countRow['cnt'] ?? 0);
|
||||||
|
|
||||||
|
$orderBy = $this->buildLogsOrder($sort, $dir);
|
||||||
|
$sql = "
|
||||||
|
SELECT id, ts, level, message, context_json
|
||||||
|
FROM app_logs
|
||||||
|
{$whereSql}
|
||||||
|
ORDER BY {$orderBy}
|
||||||
|
LIMIT {$per} OFFSET {$offset}
|
||||||
|
";
|
||||||
|
$items = $this->db->fetchAll($sql, $params);
|
||||||
|
|
||||||
|
return ['items' => $items, 'total' => $total, 'page' => $p, 'per_page' => $per];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listByRangePaged(string $from, string $to, int $page = 1, int $perPage = 100, ?string $level = null, ?string $sort = null, ?string $dir = null): array {
|
||||||
|
$from = trim($from);
|
||||||
|
$to = trim($to);
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $from) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) {
|
||||||
|
return ['items' => [], 'total' => 0, 'page' => 1, 'per_page' => 0];
|
||||||
|
}
|
||||||
|
$per = max(1, min((int)$perPage, 500));
|
||||||
|
$p = max(1, (int)$page);
|
||||||
|
$offset = ($p - 1) * $per;
|
||||||
|
|
||||||
|
[$whereSql, $params] = $this->buildLogsFilter($from, $to, $level);
|
||||||
|
|
||||||
|
$countRow = $this->db->fetchOne("SELECT COUNT(*) AS cnt FROM app_logs {$whereSql}", $params) ?? [];
|
||||||
|
$total = (int)($countRow['cnt'] ?? 0);
|
||||||
|
|
||||||
|
$orderBy = $this->buildLogsOrder($sort, $dir);
|
||||||
|
$sql = "
|
||||||
|
SELECT id, ts, level, message, context_json
|
||||||
|
FROM app_logs
|
||||||
|
{$whereSql}
|
||||||
|
ORDER BY {$orderBy}
|
||||||
|
LIMIT {$per} OFFSET {$offset}
|
||||||
|
";
|
||||||
|
$items = $this->db->fetchAll($sql, $params);
|
||||||
|
|
||||||
|
return ['items' => $items, 'total' => $total, 'page' => $p, 'per_page' => $per];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cleanup(int $keepDays): int {
|
||||||
|
if ($keepDays <= 0) {
|
||||||
|
return $this->db->exec("DELETE FROM app_logs");
|
||||||
|
}
|
||||||
|
$days = (int)$keepDays;
|
||||||
|
return $this->db->exec("
|
||||||
|
DELETE FROM app_logs
|
||||||
|
WHERE ts < DATE_SUB(NOW(), INTERVAL {$days} DAY)
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldLog(string $level): bool {
|
||||||
|
$order = ['debug' => 10, 'info' => 20, 'warn' => 30, 'error' => 40];
|
||||||
|
$lvl = $order[$level] ?? 20;
|
||||||
|
|
||||||
|
if ($level === 'debug') {
|
||||||
|
$cfg = $this->settings->getConfig();
|
||||||
|
$enabled = (bool)($cfg['app']['debug_logs_enabled'] ?? false);
|
||||||
|
if (!$enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$all = $this->settings->getAll();
|
||||||
|
$logs = $all['logs'] ?? [];
|
||||||
|
$min = (string)($logs['level'] ?? 'info');
|
||||||
|
$minVal = $order[$min] ?? 20;
|
||||||
|
|
||||||
|
return $lvl >= $minVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildLogsFilter(string $from, string $to, ?string $level): array {
|
||||||
|
$params = [
|
||||||
|
':from' => $from . ' 00:00:00',
|
||||||
|
':to' => $to . ' 00:00:00',
|
||||||
|
];
|
||||||
|
$sql = "WHERE ts >= :from AND ts < DATE_ADD(:to, INTERVAL 1 DAY)";
|
||||||
|
$allowed = ['debug', 'info', 'warn', 'error'];
|
||||||
|
if ($level && in_array($level, $allowed, true)) {
|
||||||
|
$sql .= " AND level = :level";
|
||||||
|
$params[':level'] = $level;
|
||||||
|
}
|
||||||
|
return [$sql, $params];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildLogsOrder(?string $sort, ?string $dir): string {
|
||||||
|
$map = [
|
||||||
|
'ts' => 'ts',
|
||||||
|
'level' => 'level',
|
||||||
|
'message' => 'message',
|
||||||
|
'context_json' => 'context_json',
|
||||||
|
];
|
||||||
|
$col = $map[$sort ?? ''] ?? 'id';
|
||||||
|
$direction = strtolower((string)$dir) === 'desc' ? 'DESC' : 'ASC';
|
||||||
|
return $col . ' ' . $direction;
|
||||||
|
}
|
||||||
|
}
|
||||||
194
app/services/MediaApplyService.php
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
<?php
|
||||||
|
// app/services/MediaApplyService.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Services;
|
||||||
|
|
||||||
|
final class MediaApplyService {
|
||||||
|
private MediaLibraryService $media;
|
||||||
|
private MkvToolnixService $mkvtoolnix;
|
||||||
|
private JobsService $jobs;
|
||||||
|
private MetadataService $metadata;
|
||||||
|
private ExportService $export;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
MediaLibraryService $media,
|
||||||
|
MkvToolnixService $mkvtoolnix,
|
||||||
|
JobsService $jobs,
|
||||||
|
MetadataService $metadata,
|
||||||
|
ExportService $export
|
||||||
|
) {
|
||||||
|
$this->media = $media;
|
||||||
|
$this->mkvtoolnix = $mkvtoolnix;
|
||||||
|
$this->jobs = $jobs;
|
||||||
|
$this->metadata = $metadata;
|
||||||
|
$this->export = $export;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyTab(string $tab, int $jobId): array {
|
||||||
|
$items = $this->media->listTab($tab);
|
||||||
|
$files = $this->expandFiles($tab, $items);
|
||||||
|
|
||||||
|
$total = count($files);
|
||||||
|
$this->jobs->setProgress($jobId, 0, max(1, $total));
|
||||||
|
|
||||||
|
$done = 0;
|
||||||
|
$rename = 0;
|
||||||
|
$delete = 0;
|
||||||
|
$convert = 0;
|
||||||
|
$errors = 0;
|
||||||
|
$seriesExported = [];
|
||||||
|
|
||||||
|
foreach ($files as $f) {
|
||||||
|
if ($this->jobs->cancelIfRequested($jobId)) {
|
||||||
|
$this->jobs->log($jobId, 'warn', 'Apply canceled by user');
|
||||||
|
return [
|
||||||
|
'files' => $total,
|
||||||
|
'renamed' => $rename,
|
||||||
|
'deleted' => $delete,
|
||||||
|
'converted' => $convert,
|
||||||
|
'errors' => $errors,
|
||||||
|
'canceled' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$done++;
|
||||||
|
$this->jobs->setProgress($jobId, $done, $total);
|
||||||
|
|
||||||
|
$path = (string)($f['abs_path'] ?? '');
|
||||||
|
if ($path === '') continue;
|
||||||
|
|
||||||
|
$plan = $this->media->buildEdits($path);
|
||||||
|
|
||||||
|
if (!empty($plan['convert'])) {
|
||||||
|
$res = $this->convertFile($path);
|
||||||
|
if (!$res['ok']) {
|
||||||
|
$errors++;
|
||||||
|
$this->jobs->log($jobId, 'error', "Convert failed: {$path} | {$res['error']}");
|
||||||
|
} else {
|
||||||
|
$convert++;
|
||||||
|
$this->jobs->log($jobId, 'info', "Converted: {$path} -> {$res['dest']}");
|
||||||
|
$path = $res['dest'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($plan['delete']) > 0 || count($plan['rename']) > 0) {
|
||||||
|
$res = $this->mkvtoolnix->applyTrackEdits($path, $plan['rename'], $plan['delete']);
|
||||||
|
if (!$res['ok']) {
|
||||||
|
$errors++;
|
||||||
|
$this->jobs->log($jobId, 'error', "Apply failed: {$path} | {$res['error']}");
|
||||||
|
} else if (!empty($res['changed'])) {
|
||||||
|
$rename += count($plan['rename']);
|
||||||
|
$delete += count($plan['delete']);
|
||||||
|
$this->jobs->log($jobId, 'info', "Applied: {$path} (rename " . count($plan['rename']) . ", delete " . count($plan['delete']) . ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$exported = $this->maybeExportMeta($f, $seriesExported);
|
||||||
|
foreach ($exported as $expId => $res) {
|
||||||
|
if (!empty($res['ok'])) {
|
||||||
|
$this->jobs->log($jobId, 'info', "Exported {$expId}: " . ($res['path'] ?? $path));
|
||||||
|
} else if (!empty($res['error'])) {
|
||||||
|
$this->jobs->log($jobId, 'warn', "Export {$expId} failed: " . ($res['error'] ?? 'error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'files' => $total,
|
||||||
|
'renamed' => $rename,
|
||||||
|
'deleted' => $delete,
|
||||||
|
'converted' => $convert,
|
||||||
|
'errors' => $errors,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function expandFiles(string $tab, array $items): array {
|
||||||
|
if ($tab !== 'series') return $items;
|
||||||
|
$out = [];
|
||||||
|
foreach ($items as $s) {
|
||||||
|
$key = (string)($s['series_key'] ?? '');
|
||||||
|
if ($key === '') continue;
|
||||||
|
$files = $this->media->getSeriesFiles($key);
|
||||||
|
foreach ($files as $f) $out[] = $f;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function convertFile(string $src): array {
|
||||||
|
$dir = dirname($src);
|
||||||
|
$base = pathinfo($src, PATHINFO_FILENAME);
|
||||||
|
$dest = $dir . '/' . $base . '.mkv';
|
||||||
|
if (is_file($dest)) {
|
||||||
|
$dest = $dir . '/' . $base . '_conv.mkv';
|
||||||
|
}
|
||||||
|
|
||||||
|
$res = $this->mkvtoolnix->convertToMkv($src, $dest);
|
||||||
|
if (!$res['ok']) return $res;
|
||||||
|
|
||||||
|
$meta = $this->mkvtoolnix->readFileData($dest);
|
||||||
|
$info = (is_array($meta) && isset($meta['mkvmerge']['data'])) ? $meta['mkvmerge']['data'] : null;
|
||||||
|
$this->media->updateFilePath($src, $dest, $info);
|
||||||
|
|
||||||
|
return ['ok' => true, 'dest' => $dest];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function maybeExportMeta(array $file, array &$seriesExported): array {
|
||||||
|
$enabled = $this->export->getEnabledExporters();
|
||||||
|
if (count($enabled) === 0) return [];
|
||||||
|
|
||||||
|
$kind = (string)($file['kind'] ?? 'movie');
|
||||||
|
if ($kind === 'series') {
|
||||||
|
$seriesKey = (string)($file['series_key'] ?? '');
|
||||||
|
if ($seriesKey === '' || isset($seriesExported[$seriesKey])) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$metaRow = $this->metadata->getSubjectMeta('series', $seriesKey);
|
||||||
|
if (!is_array($metaRow)) return [];
|
||||||
|
$display = $this->metadata->formatDisplay($metaRow);
|
||||||
|
$payload = $this->buildExportPayload($display);
|
||||||
|
if ($payload['title'] === '' && $payload['original_title'] === '') return [];
|
||||||
|
$seriesPath = $this->resolveSeriesPath($file);
|
||||||
|
$seriesExported[$seriesKey] = true;
|
||||||
|
return $this->export->exportSeries($seriesPath, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = (string)($file['abs_path'] ?? '');
|
||||||
|
if ($path === '') return [];
|
||||||
|
$metaRow = $this->metadata->getSubjectMeta('movie', $path);
|
||||||
|
if (!is_array($metaRow)) return [];
|
||||||
|
$display = $this->metadata->formatDisplay($metaRow);
|
||||||
|
$payload = $this->buildExportPayload($display);
|
||||||
|
if ($payload['title'] === '' && $payload['original_title'] === '') return [];
|
||||||
|
return $this->export->exportMovie($path, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildExportPayload(array $display): array {
|
||||||
|
$meta = is_array($display['meta'] ?? null) ? $display['meta'] : [];
|
||||||
|
$provider = (string)($meta['provider'] ?? '');
|
||||||
|
$providerId = (string)($meta['provider_id'] ?? '');
|
||||||
|
$providerUrl = '';
|
||||||
|
if ($provider !== '' && $providerId !== '') {
|
||||||
|
$providerUrl = $this->metadata->buildProviderUrl($provider, $providerId);
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'title' => (string)($display['title_display'] ?? ''),
|
||||||
|
'original_title' => (string)($display['title_original'] ?? ''),
|
||||||
|
'year' => $display['year'] ?? null,
|
||||||
|
'provider' => $provider,
|
||||||
|
'provider_id' => $providerId,
|
||||||
|
'provider_url' => $providerUrl,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveSeriesPath(array $file): string {
|
||||||
|
$abs = (string)($file['abs_path'] ?? '');
|
||||||
|
$rel = (string)($file['rel_path'] ?? '');
|
||||||
|
$seriesKey = (string)($file['series_key'] ?? '');
|
||||||
|
if ($abs !== '' && $rel !== '' && str_ends_with($abs, $rel) && $seriesKey !== '') {
|
||||||
|
$root = substr($abs, 0, strlen($abs) - strlen($rel));
|
||||||
|
return rtrim($root, '/') . '/' . $seriesKey;
|
||||||
|
}
|
||||||
|
return $abs !== '' ? dirname($abs) : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
751
app/services/MediaLibraryService.php
Normal file
@ -0,0 +1,751 @@
|
|||||||
|
<?php
|
||||||
|
// app/services/MediaLibraryService.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Services;
|
||||||
|
|
||||||
|
use ScMedia\Db\Db;
|
||||||
|
|
||||||
|
final class MediaLibraryService {
|
||||||
|
private Db $db;
|
||||||
|
private SettingsService $settings;
|
||||||
|
private MetadataService $metadata;
|
||||||
|
|
||||||
|
public function __construct(Db $db, SettingsService $settings, MetadataService $metadata) {
|
||||||
|
$this->db = $db;
|
||||||
|
$this->settings = $settings;
|
||||||
|
$this->metadata = $metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function upsertMediaFile(
|
||||||
|
int $profileId,
|
||||||
|
string $root,
|
||||||
|
string $abs,
|
||||||
|
array $stat,
|
||||||
|
bool $isMkv,
|
||||||
|
?array $mkvInfo
|
||||||
|
): void {
|
||||||
|
$root = rtrim($root, "/");
|
||||||
|
$rel = ltrim(str_replace($root, '', $abs), '/');
|
||||||
|
if ($rel === '') {
|
||||||
|
$rel = basename($abs);
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = basename($abs);
|
||||||
|
$ext = strtolower(pathinfo($abs, PATHINFO_EXTENSION));
|
||||||
|
$size = (int)($stat['size'] ?? 0);
|
||||||
|
$mtime = (int)($stat['mtime'] ?? 0);
|
||||||
|
|
||||||
|
$seriesKey = null;
|
||||||
|
$kind = 'unknown';
|
||||||
|
if (str_contains($rel, '/')) {
|
||||||
|
$parts = explode('/', $rel);
|
||||||
|
$seriesKey = $parts[0] !== '' ? $parts[0] : null;
|
||||||
|
$kind = $seriesKey ? 'series' : 'unknown';
|
||||||
|
} else {
|
||||||
|
$kind = 'movie';
|
||||||
|
}
|
||||||
|
|
||||||
|
[$container, $durationMs] = $this->extractContainerData($mkvInfo);
|
||||||
|
|
||||||
|
$this->db->exec("
|
||||||
|
INSERT INTO media_files
|
||||||
|
(scan_profile_id, abs_path, rel_path, name, ext, size_bytes, mtime, is_mkv, kind, series_key, container, duration_ms, last_analyzed_at)
|
||||||
|
VALUES
|
||||||
|
(:pid, :abs, :rel, :name, :ext, :size, :mtime, :is_mkv, :kind, :series, :container, :duration, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
scan_profile_id = VALUES(scan_profile_id),
|
||||||
|
rel_path = VALUES(rel_path),
|
||||||
|
name = VALUES(name),
|
||||||
|
ext = VALUES(ext),
|
||||||
|
size_bytes = VALUES(size_bytes),
|
||||||
|
mtime = VALUES(mtime),
|
||||||
|
is_mkv = VALUES(is_mkv),
|
||||||
|
kind = VALUES(kind),
|
||||||
|
series_key = VALUES(series_key),
|
||||||
|
container = VALUES(container),
|
||||||
|
duration_ms = VALUES(duration_ms),
|
||||||
|
last_analyzed_at = NOW()
|
||||||
|
", [
|
||||||
|
':pid' => $profileId,
|
||||||
|
':abs' => $abs,
|
||||||
|
':rel' => $rel,
|
||||||
|
':name' => $name,
|
||||||
|
':ext' => $ext,
|
||||||
|
':size' => $size,
|
||||||
|
':mtime' => $mtime,
|
||||||
|
':is_mkv' => $isMkv ? 1 : 0,
|
||||||
|
':kind' => $kind,
|
||||||
|
':series' => $seriesKey,
|
||||||
|
':container' => $container !== '' ? $container : null,
|
||||||
|
':duration' => $durationMs,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listTab(string $tab): array {
|
||||||
|
if ($tab === 'series') {
|
||||||
|
$rows = $this->db->fetchAll("
|
||||||
|
SELECT series_key, COUNT(*) AS total_files
|
||||||
|
FROM media_files
|
||||||
|
WHERE is_mkv=1 AND kind='series' AND series_key IS NOT NULL
|
||||||
|
GROUP BY series_key
|
||||||
|
ORDER BY series_key ASC
|
||||||
|
LIMIT 2000
|
||||||
|
");
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
$seriesKeys = array_map(fn($r) => (string)$r['series_key'], $rows);
|
||||||
|
$metaMap = $this->metadata->getSubjectMetaMap('series', $seriesKeys);
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$key = (string)$r['series_key'];
|
||||||
|
$files = $this->getSeriesFiles($key);
|
||||||
|
$stats = $this->summarizeFiles($files);
|
||||||
|
$meta = $metaMap[$key] ?? null;
|
||||||
|
$display = $this->metadata->formatDisplay($meta);
|
||||||
|
$out[] = [
|
||||||
|
'series_key' => $key,
|
||||||
|
'title_display' => $display['title_display'],
|
||||||
|
'title_original' => $display['title_original'],
|
||||||
|
'year' => $display['year'],
|
||||||
|
'meta' => $display['meta'],
|
||||||
|
'total_files' => (int)$r['total_files'],
|
||||||
|
'needs_attention' => $stats['needs_attention'],
|
||||||
|
'issues' => $stats['issues'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tab === 'needs_mkv') {
|
||||||
|
$rows = $this->db->fetchAll("
|
||||||
|
SELECT *
|
||||||
|
FROM media_files
|
||||||
|
WHERE is_mkv=0
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 2000
|
||||||
|
");
|
||||||
|
return $this->decorateFiles($rows, 'movie');
|
||||||
|
}
|
||||||
|
|
||||||
|
// movies (default)
|
||||||
|
$rows = $this->db->fetchAll("
|
||||||
|
SELECT *
|
||||||
|
FROM media_files
|
||||||
|
WHERE is_mkv=1 AND kind='movie'
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 2000
|
||||||
|
");
|
||||||
|
return $this->decorateFiles($rows, 'movie');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listTabPaged(string $tab, int $page, int $perPage, string $sort, string $dir): array {
|
||||||
|
$page = max(1, $page);
|
||||||
|
$perPage = max(1, $perPage);
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
$dir = strtolower($dir) === 'asc' ? 'ASC' : 'DESC';
|
||||||
|
|
||||||
|
if ($tab === 'series') {
|
||||||
|
$orderBy = 'series_key ASC';
|
||||||
|
if ($sort === 'name') {
|
||||||
|
$orderBy = 'series_key ' . $dir;
|
||||||
|
}
|
||||||
|
$totalRow = $this->db->fetchOne("
|
||||||
|
SELECT COUNT(DISTINCT series_key) AS total
|
||||||
|
FROM media_files
|
||||||
|
WHERE is_mkv=1 AND kind='series' AND series_key IS NOT NULL
|
||||||
|
");
|
||||||
|
$total = (int)($totalRow['total'] ?? 0);
|
||||||
|
$rows = $this->db->fetchAll("
|
||||||
|
SELECT series_key, COUNT(*) AS total_files
|
||||||
|
FROM media_files
|
||||||
|
WHERE is_mkv=1 AND kind='series' AND series_key IS NOT NULL
|
||||||
|
GROUP BY series_key
|
||||||
|
ORDER BY {$orderBy}
|
||||||
|
LIMIT {$perPage} OFFSET {$offset}
|
||||||
|
");
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
$seriesKeys = array_map(fn($r) => (string)$r['series_key'], $rows);
|
||||||
|
$metaMap = $this->metadata->getSubjectMetaMap('series', $seriesKeys);
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$key = (string)$r['series_key'];
|
||||||
|
$files = $this->getSeriesFiles($key);
|
||||||
|
$stats = $this->summarizeFiles($files);
|
||||||
|
$meta = $metaMap[$key] ?? null;
|
||||||
|
$display = $this->metadata->formatDisplay($meta);
|
||||||
|
$out[] = [
|
||||||
|
'series_key' => $key,
|
||||||
|
'title_display' => $display['title_display'],
|
||||||
|
'title_original' => $display['title_original'],
|
||||||
|
'year' => $display['year'],
|
||||||
|
'meta' => $display['meta'],
|
||||||
|
'total_files' => (int)$r['total_files'],
|
||||||
|
'needs_attention' => $stats['needs_attention'],
|
||||||
|
'issues' => $stats['issues'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return ['items' => $out, 'total' => $total];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tab === 'needs_mkv') {
|
||||||
|
$orderBy = $this->mediaOrderBy($sort, $dir, 'updated_at DESC');
|
||||||
|
$totalRow = $this->db->fetchOne("
|
||||||
|
SELECT COUNT(*) AS total
|
||||||
|
FROM media_files
|
||||||
|
WHERE is_mkv=0
|
||||||
|
");
|
||||||
|
$total = (int)($totalRow['total'] ?? 0);
|
||||||
|
$rows = $this->db->fetchAll("
|
||||||
|
SELECT *
|
||||||
|
FROM media_files
|
||||||
|
WHERE is_mkv=0
|
||||||
|
ORDER BY {$orderBy}
|
||||||
|
LIMIT {$perPage} OFFSET {$offset}
|
||||||
|
");
|
||||||
|
$items = $this->decorateFiles($rows, 'movie');
|
||||||
|
return ['items' => $items, 'total' => $total];
|
||||||
|
}
|
||||||
|
|
||||||
|
// movies (default)
|
||||||
|
$orderBy = $this->mediaOrderBy($sort, $dir, 'updated_at DESC');
|
||||||
|
$totalRow = $this->db->fetchOne("
|
||||||
|
SELECT COUNT(*) AS total
|
||||||
|
FROM media_files
|
||||||
|
WHERE is_mkv=1 AND kind='movie'
|
||||||
|
");
|
||||||
|
$total = (int)($totalRow['total'] ?? 0);
|
||||||
|
$rows = $this->db->fetchAll("
|
||||||
|
SELECT *
|
||||||
|
FROM media_files
|
||||||
|
WHERE is_mkv=1 AND kind='movie'
|
||||||
|
ORDER BY {$orderBy}
|
||||||
|
LIMIT {$perPage} OFFSET {$offset}
|
||||||
|
");
|
||||||
|
$items = $this->decorateFiles($rows, 'movie');
|
||||||
|
return ['items' => $items, 'total' => $total];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mediaOrderBy(string $sort, string $dir, string $fallback): string {
|
||||||
|
$dir = strtoupper($dir) === 'DESC' ? 'DESC' : 'ASC';
|
||||||
|
$map = [
|
||||||
|
'name' => 'name',
|
||||||
|
'mtime' => 'mtime',
|
||||||
|
'updated' => 'updated_at',
|
||||||
|
'size' => 'size_bytes',
|
||||||
|
];
|
||||||
|
if (isset($map[$sort])) {
|
||||||
|
return $map[$sort] . ' ' . $dir;
|
||||||
|
}
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeriesFiles(string $seriesKey): array {
|
||||||
|
$rows = $this->db->fetchAll("
|
||||||
|
SELECT *
|
||||||
|
FROM media_files
|
||||||
|
WHERE is_mkv=1 AND kind='series' AND series_key=:k
|
||||||
|
ORDER BY rel_path ASC
|
||||||
|
LIMIT 5000
|
||||||
|
", [':k' => $seriesKey]);
|
||||||
|
return $this->decorateFiles($rows, 'series');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dryRun(string $tab): array {
|
||||||
|
$rows = $this->listTab($tab);
|
||||||
|
$totalFiles = 0;
|
||||||
|
$rename = 0;
|
||||||
|
$delete = 0;
|
||||||
|
$unknown = 0;
|
||||||
|
$convert = 0;
|
||||||
|
$details = [];
|
||||||
|
|
||||||
|
if ($tab === 'series') {
|
||||||
|
foreach ($rows as $series) {
|
||||||
|
$files = $this->getSeriesFiles((string)$series['series_key']);
|
||||||
|
foreach ($files as $f) {
|
||||||
|
$res = $this->planFile($f['abs_path'] ?? '');
|
||||||
|
$totalFiles++;
|
||||||
|
$rename += $res['rename'];
|
||||||
|
$delete += $res['delete'];
|
||||||
|
$unknown += $res['unknown_type'];
|
||||||
|
$convert += $res['convert'];
|
||||||
|
$details[] = $res['detail'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
foreach ($rows as $f) {
|
||||||
|
$res = $this->planFile($f['abs_path'] ?? '');
|
||||||
|
$totalFiles++;
|
||||||
|
$rename += $res['rename'];
|
||||||
|
$delete += $res['delete'];
|
||||||
|
$unknown += $res['unknown_type'];
|
||||||
|
$convert += $res['convert'];
|
||||||
|
$details[] = $res['detail'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'files' => $totalFiles,
|
||||||
|
'rename' => $rename,
|
||||||
|
'delete' => $delete,
|
||||||
|
'unknown_type' => $unknown,
|
||||||
|
'convert' => $convert,
|
||||||
|
'rows' => $details,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFileDetail(string $absPath): array {
|
||||||
|
$row = $this->db->fetchOne("
|
||||||
|
SELECT *
|
||||||
|
FROM media_files
|
||||||
|
WHERE abs_path=:p
|
||||||
|
LIMIT 1
|
||||||
|
", [':p' => $absPath]);
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
return ['ok' => false, 'error' => 'File not found'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = $this->db->fetchOne("
|
||||||
|
SELECT info_json
|
||||||
|
FROM media_file_meta
|
||||||
|
WHERE abs_path=:p
|
||||||
|
LIMIT 1
|
||||||
|
", [':p' => $absPath]);
|
||||||
|
|
||||||
|
$info = null;
|
||||||
|
if ($meta && isset($meta['info_json'])) {
|
||||||
|
$decoded = json_decode((string)$meta['info_json'], true);
|
||||||
|
$info = is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rules = $this->getRules();
|
||||||
|
$tracks = $this->parseTracks($info, $rules);
|
||||||
|
$issues = $this->evaluateIssues($tracks, $rules);
|
||||||
|
if (is_array($row) && is_array($info)) {
|
||||||
|
$this->refreshContainerData((int)$row['id'], $info, $row);
|
||||||
|
$row = $this->db->fetchOne("
|
||||||
|
SELECT *
|
||||||
|
FROM media_files
|
||||||
|
WHERE abs_path=:p
|
||||||
|
LIMIT 1
|
||||||
|
", [':p' => $absPath]) ?: $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subject = $this->subjectForRow($row);
|
||||||
|
$meta = $subject ? $this->metadata->getSubjectMeta($subject['kind'], $subject['key']) : null;
|
||||||
|
$display = $this->metadata->formatDisplay($meta);
|
||||||
|
$metaPayload = is_array($display['meta'] ?? null) ? $display['meta'] : [];
|
||||||
|
if ($subject) {
|
||||||
|
$metaPayload['subject_kind'] = $subject['kind'];
|
||||||
|
$metaPayload['subject_key'] = $subject['key'];
|
||||||
|
}
|
||||||
|
$metaPayload['title_display'] = $display['title_display'];
|
||||||
|
$metaPayload['title_original'] = $display['title_original'];
|
||||||
|
$metaPayload['year'] = $display['year'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'file' => $this->decorateFileRow($row, $issues),
|
||||||
|
'tracks' => $tracks,
|
||||||
|
'meta' => $metaPayload,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRules(): array {
|
||||||
|
$all = $this->settings->getAll();
|
||||||
|
$rules = $all['media_rules'] ?? [];
|
||||||
|
return [
|
||||||
|
'name_map' => is_array($rules['name_map'] ?? null) ? $rules['name_map'] : [],
|
||||||
|
'delete_rules' => is_array($rules['delete_rules'] ?? null) ? $rules['delete_rules'] : [],
|
||||||
|
'language_priority' => is_array($rules['language_priority'] ?? null) ? $rules['language_priority'] : ['ru','en'],
|
||||||
|
'audio_type_priority' => is_array($rules['audio_type_priority'] ?? null) ? $rules['audio_type_priority'] : ['dub','voiceover','original','commentary','unknown'],
|
||||||
|
'require_audio_type' => !empty($rules['require_audio_type']),
|
||||||
|
'series_order_threshold' => (float)($rules['series_order_threshold'] ?? 0.7),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function planFile(string $absPath): array {
|
||||||
|
$row = $this->db->fetchOne("SELECT * FROM media_files WHERE abs_path=:p LIMIT 1", [':p' => $absPath]);
|
||||||
|
$name = $row ? (string)($row['name'] ?? $absPath) : $absPath;
|
||||||
|
$plan = $this->buildEdits($absPath);
|
||||||
|
|
||||||
|
$actions = [];
|
||||||
|
if (count($plan['rename']) > 0) $actions[] = 'rename';
|
||||||
|
if (count($plan['delete']) > 0) $actions[] = 'delete';
|
||||||
|
if ($plan['unknown_type'] > 0) $actions[] = 'unknown_type';
|
||||||
|
if ($plan['convert'] > 0) $actions[] = 'convert';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'rename' => count($plan['rename']),
|
||||||
|
'delete' => count($plan['delete']),
|
||||||
|
'unknown_type' => $plan['unknown_type'],
|
||||||
|
'convert' => $plan['convert'],
|
||||||
|
'detail' => [
|
||||||
|
'name' => $name,
|
||||||
|
'actions' => $actions,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateFilePath(string $absPath, string $newAbsPath, ?array $mkvInfo = null): void {
|
||||||
|
$row = $this->db->fetchOne("SELECT * FROM media_files WHERE abs_path=:p LIMIT 1", [':p' => $absPath]);
|
||||||
|
if (!$row) return;
|
||||||
|
|
||||||
|
$rel = (string)($row['rel_path'] ?? '');
|
||||||
|
$dir = $rel !== '' ? trim(dirname($rel), '.') : '';
|
||||||
|
$newName = basename($newAbsPath);
|
||||||
|
$newRel = $dir !== '' ? $dir . '/' . $newName : $newName;
|
||||||
|
$stat = @stat($newAbsPath);
|
||||||
|
$size = is_array($stat) ? (int)($stat['size'] ?? 0) : (int)($row['size_bytes'] ?? 0);
|
||||||
|
$mtime = is_array($stat) ? (int)($stat['mtime'] ?? 0) : (int)($row['mtime'] ?? 0);
|
||||||
|
|
||||||
|
[$container, $durationMs] = $this->extractContainerData($mkvInfo);
|
||||||
|
|
||||||
|
$this->db->exec("
|
||||||
|
UPDATE media_files
|
||||||
|
SET abs_path=:abs, rel_path=:rel, name=:name, ext=:ext, is_mkv=1,
|
||||||
|
size_bytes=:size, mtime=:mtime, container=:container, duration_ms=:duration,
|
||||||
|
updated_at=NOW()
|
||||||
|
WHERE abs_path=:old
|
||||||
|
", [
|
||||||
|
':abs' => $newAbsPath,
|
||||||
|
':rel' => $newRel,
|
||||||
|
':name' => $newName,
|
||||||
|
':ext' => strtolower(pathinfo($newAbsPath, PATHINFO_EXTENSION)),
|
||||||
|
':size' => $size,
|
||||||
|
':mtime' => $mtime,
|
||||||
|
':container' => $container !== '' ? $container : null,
|
||||||
|
':duration' => $durationMs,
|
||||||
|
':old' => $absPath,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildEdits(string $absPath): array {
|
||||||
|
$row = $this->db->fetchOne("SELECT * FROM media_files WHERE abs_path=:p LIMIT 1", [':p' => $absPath]);
|
||||||
|
$convert = ($row && empty($row['is_mkv'])) ? 1 : 0;
|
||||||
|
|
||||||
|
$meta = $this->db->fetchOne("SELECT info_json FROM media_file_meta WHERE abs_path=:p LIMIT 1", [':p' => $absPath]);
|
||||||
|
$info = null;
|
||||||
|
if ($meta && isset($meta['info_json'])) {
|
||||||
|
$decoded = json_decode((string)$meta['info_json'], true);
|
||||||
|
$info = is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rules = $this->getRules();
|
||||||
|
$tracks = $this->parseTracks($info, $rules);
|
||||||
|
$rename = [];
|
||||||
|
$delete = [];
|
||||||
|
$unknownCount = 0;
|
||||||
|
$deleteIds = [];
|
||||||
|
|
||||||
|
foreach ($tracks as $t) {
|
||||||
|
$id = (int)($t['id'] ?? 0);
|
||||||
|
if ($this->matchesDeleteRule($t, $rules['delete_rules'])) {
|
||||||
|
if ($id > 0) {
|
||||||
|
$deleteIds[$id] = true;
|
||||||
|
$delete[] = $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($t['name_norm'] !== '' && $t['name_norm'] !== $t['name']) {
|
||||||
|
if ($id > 0) {
|
||||||
|
$rename[] = ['id' => $id, 'name' => $t['name_norm']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($rules['require_audio_type']) && $t['type'] === 'audio' && $t['audio_type'] === 'unknown') {
|
||||||
|
$unknownCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not rename deleted tracks
|
||||||
|
$rename = array_values(array_filter($rename, fn($r) => !isset($deleteIds[(int)$r['id']])));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'rename' => $rename,
|
||||||
|
'delete' => $delete,
|
||||||
|
'unknown_type' => $unknownCount,
|
||||||
|
'convert' => $convert,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decorateFiles(array $rows, string $kind): array {
|
||||||
|
$rules = $this->getRules();
|
||||||
|
$keys = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
if ($kind === 'series') {
|
||||||
|
$keys[] = (string)($r['series_key'] ?? '');
|
||||||
|
} else {
|
||||||
|
$keys[] = (string)($r['abs_path'] ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$metaMap = $this->metadata->getSubjectMetaMap($kind === 'series' ? 'series' : 'movie', $keys);
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$meta = null;
|
||||||
|
if ((int)($r['is_mkv'] ?? 0) === 1) {
|
||||||
|
$metaRow = $this->db->fetchOne("
|
||||||
|
SELECT info_json
|
||||||
|
FROM media_file_meta
|
||||||
|
WHERE abs_path=:p
|
||||||
|
LIMIT 1
|
||||||
|
", [':p' => (string)$r['abs_path']]);
|
||||||
|
if ($metaRow && isset($metaRow['info_json'])) {
|
||||||
|
$decoded = json_decode((string)$metaRow['info_json'], true);
|
||||||
|
$meta = is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$tracks = $this->parseTracks($meta, $rules);
|
||||||
|
$issues = $this->evaluateIssues($tracks, $rules);
|
||||||
|
if (is_array($meta)) {
|
||||||
|
$this->refreshContainerData((int)$r['id'], $meta, $r);
|
||||||
|
}
|
||||||
|
$subjectKey = ($kind === 'series')
|
||||||
|
? (string)($r['series_key'] ?? '')
|
||||||
|
: (string)($r['abs_path'] ?? '');
|
||||||
|
$metaRow = $metaMap[$subjectKey] ?? null;
|
||||||
|
$display = $this->metadata->formatDisplay($metaRow);
|
||||||
|
$out[] = $this->decorateFileRow($r, $issues, $display);
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decorateFileRow(array $r, array $issues, ?array $display = null): array {
|
||||||
|
$display = $display ?? $this->metadata->formatDisplay(null);
|
||||||
|
return [
|
||||||
|
'id' => (int)($r['id'] ?? 0),
|
||||||
|
'abs_path' => (string)($r['abs_path'] ?? ''),
|
||||||
|
'rel_path' => (string)($r['rel_path'] ?? ''),
|
||||||
|
'name' => (string)($r['name'] ?? ''),
|
||||||
|
'ext' => (string)($r['ext'] ?? ''),
|
||||||
|
'is_mkv' => !empty($r['is_mkv']),
|
||||||
|
'kind' => (string)($r['kind'] ?? 'unknown'),
|
||||||
|
'series_key' => $r['series_key'] ?? null,
|
||||||
|
'container' => $r['container'] ?? null,
|
||||||
|
'duration_ms' => $r['duration_ms'] !== null ? (int)$r['duration_ms'] : null,
|
||||||
|
'size_bytes' => (int)($r['size_bytes'] ?? 0),
|
||||||
|
'needs_attention' => $issues['needs_attention'],
|
||||||
|
'issues' => $issues['issues'],
|
||||||
|
'title_display' => $display['title_display'],
|
||||||
|
'title_original' => $display['title_original'],
|
||||||
|
'year' => $display['year'],
|
||||||
|
'meta' => $display['meta'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function subjectForRow(array $row): ?array {
|
||||||
|
$kind = (string)($row['kind'] ?? 'unknown');
|
||||||
|
if ($kind === 'series') {
|
||||||
|
$seriesKey = (string)($row['series_key'] ?? '');
|
||||||
|
if ($seriesKey !== '') {
|
||||||
|
return ['kind' => 'series', 'key' => $seriesKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$abs = (string)($row['abs_path'] ?? '');
|
||||||
|
if ($abs !== '') {
|
||||||
|
return ['kind' => 'movie', 'key' => $abs];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private function summarizeFiles(array $files): array {
|
||||||
|
$needs = 0;
|
||||||
|
$issues = [];
|
||||||
|
foreach ($files as $f) {
|
||||||
|
if (!empty($f['needs_attention'])) {
|
||||||
|
$needs++;
|
||||||
|
foreach (($f['issues'] ?? []) as $i) {
|
||||||
|
$issues[$i] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'needs_attention' => $needs,
|
||||||
|
'issues' => array_values(array_keys($issues)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseTracks(?array $info, array $rules): array {
|
||||||
|
if (!is_array($info)) return [];
|
||||||
|
$tracks = is_array($info['tracks'] ?? null) ? $info['tracks'] : [];
|
||||||
|
$out = [];
|
||||||
|
foreach ($tracks as $t) {
|
||||||
|
$props = is_array($t['properties'] ?? null) ? $t['properties'] : [];
|
||||||
|
$type = (string)($t['type'] ?? '');
|
||||||
|
$name = (string)($props['track_name'] ?? '');
|
||||||
|
$lang = (string)($props['language'] ?? 'und');
|
||||||
|
$codec = (string)($props['codec_id'] ?? '');
|
||||||
|
$channels = isset($props['audio_channels']) ? (string)$props['audio_channels'] : '';
|
||||||
|
$isDefault = !empty($props['default_track']);
|
||||||
|
$isForced = !empty($props['forced_track']);
|
||||||
|
|
||||||
|
$audioType = 'unknown';
|
||||||
|
if ($type === 'audio') {
|
||||||
|
$audioType = $this->detectAudioType($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
$nameNorm = $this->applyNameMap($name, $rules['name_map']);
|
||||||
|
|
||||||
|
$out[] = [
|
||||||
|
'id' => (int)($t['id'] ?? 0),
|
||||||
|
'type' => $type,
|
||||||
|
'lang' => $lang,
|
||||||
|
'name' => $name,
|
||||||
|
'name_norm' => $nameNorm,
|
||||||
|
'codec' => $codec,
|
||||||
|
'channels' => $channels,
|
||||||
|
'default' => $isDefault,
|
||||||
|
'forced' => $isForced,
|
||||||
|
'audio_type' => $audioType,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function evaluateIssues(array $tracks, array $rules): array {
|
||||||
|
$issues = [];
|
||||||
|
foreach ($tracks as $t) {
|
||||||
|
if ($t['name_norm'] !== '' && $t['name_norm'] !== $t['name']) {
|
||||||
|
$issues['rename'] = true;
|
||||||
|
}
|
||||||
|
if ($this->matchesDeleteRule($t, $rules['delete_rules'])) {
|
||||||
|
$issues['delete'] = true;
|
||||||
|
}
|
||||||
|
if (!empty($rules['require_audio_type']) && $t['type'] === 'audio' && $t['audio_type'] === 'unknown') {
|
||||||
|
$issues['unknown_type'] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'needs_attention' => count($issues) > 0,
|
||||||
|
'issues' => array_values(array_keys($issues)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyNameMap(string $name, array $map): string {
|
||||||
|
$trim = trim($name);
|
||||||
|
if ($trim === '') return $name;
|
||||||
|
|
||||||
|
foreach ($map as $row) {
|
||||||
|
if (!is_array($row)) continue;
|
||||||
|
$mode = (string)($row['mode'] ?? 'exact');
|
||||||
|
$pattern = (string)($row['pattern'] ?? '');
|
||||||
|
$canonical = (string)($row['canonical'] ?? '');
|
||||||
|
if ($pattern === '' || $canonical === '') continue;
|
||||||
|
|
||||||
|
if ($mode === 'exact') {
|
||||||
|
if (mb_strtolower($trim) === mb_strtolower($pattern)) {
|
||||||
|
return $canonical;
|
||||||
|
}
|
||||||
|
} else if ($mode === 'regex') {
|
||||||
|
$ok = @preg_match($pattern, $trim);
|
||||||
|
if ($ok === 1) {
|
||||||
|
return $canonical;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function matchesDeleteRule(array $t, array $rules): bool {
|
||||||
|
foreach ($rules as $r) {
|
||||||
|
if (!is_array($r)) continue;
|
||||||
|
$type = (string)($r['type'] ?? '');
|
||||||
|
$lang = (string)($r['lang'] ?? '');
|
||||||
|
$audioType = (string)($r['audio_type'] ?? '');
|
||||||
|
$nameContains = (string)($r['name_contains'] ?? '');
|
||||||
|
$exceptDefault = !empty($r['except_default']);
|
||||||
|
$exceptForced = !empty($r['except_forced']);
|
||||||
|
|
||||||
|
if ($type !== '' && $t['type'] !== $type) continue;
|
||||||
|
if ($lang !== '' && $t['lang'] !== $lang) continue;
|
||||||
|
if ($audioType !== '' && $t['audio_type'] !== $audioType) continue;
|
||||||
|
if ($nameContains !== '' && !str_contains(mb_strtolower($t['name']), mb_strtolower($nameContains))) continue;
|
||||||
|
if ($exceptDefault && !empty($t['default'])) continue;
|
||||||
|
if ($exceptForced && !empty($t['forced'])) continue;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detectAudioType(string $name): string {
|
||||||
|
$n = mb_strtolower($name);
|
||||||
|
if ($n === '') return 'unknown';
|
||||||
|
|
||||||
|
$commentary = ['commentary','коммент','комментар','director','режисс'];
|
||||||
|
$dub = ['dub','dubbing','дуб','дубляж','полный'];
|
||||||
|
$voiceover = ['vo','voiceover','озвуч','многогол','двухгол','одногол'];
|
||||||
|
$original = ['original','orig','оригин'];
|
||||||
|
|
||||||
|
foreach ($commentary as $k) {
|
||||||
|
if (str_contains($n, $k)) return 'commentary';
|
||||||
|
}
|
||||||
|
foreach ($dub as $k) {
|
||||||
|
if (str_contains($n, $k)) return 'dub';
|
||||||
|
}
|
||||||
|
foreach ($voiceover as $k) {
|
||||||
|
if (str_contains($n, $k)) return 'voiceover';
|
||||||
|
}
|
||||||
|
foreach ($original as $k) {
|
||||||
|
if (str_contains($n, $k)) return 'original';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function refreshContainerData(int $id, array $info, array $row): void {
|
||||||
|
[$container, $durationMs] = $this->extractContainerData($info);
|
||||||
|
|
||||||
|
$needsUpdate = false;
|
||||||
|
if ($container !== '' && ($row['container'] ?? null) !== $container) {
|
||||||
|
$needsUpdate = true;
|
||||||
|
}
|
||||||
|
if ($durationMs !== null && (int)($row['duration_ms'] ?? 0) !== $durationMs) {
|
||||||
|
$needsUpdate = true;
|
||||||
|
}
|
||||||
|
if (!$needsUpdate) return;
|
||||||
|
|
||||||
|
$this->db->exec("
|
||||||
|
UPDATE media_files
|
||||||
|
SET container=:c, duration_ms=:d, updated_at=NOW()
|
||||||
|
WHERE id=:id
|
||||||
|
", [
|
||||||
|
':c' => $container !== '' ? $container : null,
|
||||||
|
':d' => $durationMs,
|
||||||
|
':id' => $id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractContainerData(?array $info): array {
|
||||||
|
if (!is_array($info)) {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
$container = '';
|
||||||
|
if (isset($info['container']['properties']['container_type'])) {
|
||||||
|
$container = (string)$info['container']['properties']['container_type'];
|
||||||
|
}
|
||||||
|
if ($container === '' && isset($info['container']['type'])) {
|
||||||
|
$container = (string)$info['container']['type'];
|
||||||
|
}
|
||||||
|
if ($container !== '' && preg_match('/^\d+$/', $container) && isset($info['container']['type'])) {
|
||||||
|
$container = (string)$info['container']['type'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$dur = $info['container']['properties']['duration'] ?? $info['container']['duration'] ?? null;
|
||||||
|
$durationMs = null;
|
||||||
|
if (is_numeric($dur)) {
|
||||||
|
$durVal = (float)$dur;
|
||||||
|
// mkvmerge reports duration in ns; guard for seconds in older formats
|
||||||
|
if ($durVal > 1e9) {
|
||||||
|
$durationMs = (int)round($durVal / 1e6);
|
||||||
|
} else {
|
||||||
|
$durationMs = (int)round($durVal * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$container !== '' ? $container : null, $durationMs];
|
||||||
|
}
|
||||||
|
}
|
||||||
372
app/services/MetadataService.php
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
<?php
|
||||||
|
// app/services/MetadataService.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Services;
|
||||||
|
|
||||||
|
use ScMedia\Db\Db;
|
||||||
|
use ScMedia\Services\Metadata\MetadataProvider;
|
||||||
|
|
||||||
|
final class MetadataService {
|
||||||
|
private Db $db;
|
||||||
|
private SettingsService $settings;
|
||||||
|
private ?LogService $logger;
|
||||||
|
/** @var MetadataProvider[] */
|
||||||
|
private array $providers;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param MetadataProvider[] $providers
|
||||||
|
*/
|
||||||
|
public function __construct(Db $db, SettingsService $settings, array $providers, ?LogService $logger = null) {
|
||||||
|
$this->db = $db;
|
||||||
|
$this->settings = $settings;
|
||||||
|
$this->providers = $providers;
|
||||||
|
$this->logger = $logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function search(string $query, string $type, ?int $year): array {
|
||||||
|
$query = trim($query);
|
||||||
|
if ($query === '') return [];
|
||||||
|
|
||||||
|
$cfg = $this->getMetadataSettings();
|
||||||
|
$langs = $this->getLanguages($cfg);
|
||||||
|
$order = $this->getProviderPriority($cfg);
|
||||||
|
$enabled = $this->getEnabledProviders($cfg, $order);
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
foreach ($enabled as $pid => $provider) {
|
||||||
|
$pCfg = $cfg['providers'][$pid] ?? [];
|
||||||
|
foreach ($langs as $lang) {
|
||||||
|
$this->logger?->log('debug', 'Metadata search request', [
|
||||||
|
'provider' => $pid,
|
||||||
|
'lang' => $lang,
|
||||||
|
'query' => $query,
|
||||||
|
'type' => $type,
|
||||||
|
'year' => $year,
|
||||||
|
]);
|
||||||
|
$found = $provider->search($query, $type, $year, $lang, $pCfg);
|
||||||
|
$this->logger?->log('info', 'Metadata provider results', [
|
||||||
|
'provider' => $pid,
|
||||||
|
'lang' => $lang,
|
||||||
|
'count' => count($found),
|
||||||
|
]);
|
||||||
|
foreach ($found as $row) {
|
||||||
|
$key = $pid . ':' . (string)($row['provider_id'] ?? '');
|
||||||
|
if ($key === $pid . ':') continue;
|
||||||
|
if (!isset($results[$key])) {
|
||||||
|
$results[$key] = $row;
|
||||||
|
$results[$key]['title_map'] = is_array($row['title_map'] ?? null) ? $row['title_map'] : [];
|
||||||
|
$results[$key]['_priority'] = array_search($pid, $order, true);
|
||||||
|
} else {
|
||||||
|
$existing = $results[$key]['title_map'] ?? [];
|
||||||
|
$add = is_array($row['title_map'] ?? null) ? $row['title_map'] : [];
|
||||||
|
$results[$key]['title_map'] = array_merge($existing, $add);
|
||||||
|
if (($results[$key]['original_title'] ?? '') === '' && !empty($row['original_title'])) {
|
||||||
|
$results[$key]['original_title'] = $row['original_title'];
|
||||||
|
}
|
||||||
|
if (($results[$key]['year'] ?? null) === null && isset($row['year'])) {
|
||||||
|
$results[$key]['year'] = $row['year'];
|
||||||
|
}
|
||||||
|
if (($results[$key]['poster'] ?? '') === '' && !empty($row['poster'])) {
|
||||||
|
$results[$key]['poster'] = $row['poster'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = array_values($results);
|
||||||
|
usort($out, function($a, $b) {
|
||||||
|
$pa = $a['_priority'] ?? 999;
|
||||||
|
$pb = $b['_priority'] ?? 999;
|
||||||
|
if ($pa === $pb) return 0;
|
||||||
|
return $pa < $pb ? -1 : 1;
|
||||||
|
});
|
||||||
|
foreach ($out as &$r) {
|
||||||
|
unset($r['_priority']);
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSubjectMeta(string $kind, string $key): ?array {
|
||||||
|
if ($key === '') return null;
|
||||||
|
$row = $this->db->fetchOne("
|
||||||
|
SELECT *
|
||||||
|
FROM media_metadata
|
||||||
|
WHERE subject_kind=:k AND subject_key=:s
|
||||||
|
LIMIT 1
|
||||||
|
", [':k' => $kind, ':s' => $key]);
|
||||||
|
return $row ? $this->decodeMetaRow($row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSubjectMetaMap(string $kind, array $keys): array {
|
||||||
|
$keys = array_values(array_filter($keys, fn($k) => $k !== ''));
|
||||||
|
if (count($keys) === 0) return [];
|
||||||
|
$placeholders = [];
|
||||||
|
$params = [':k' => $kind];
|
||||||
|
foreach ($keys as $i => $k) {
|
||||||
|
$ph = ':p' . $i;
|
||||||
|
$placeholders[] = $ph;
|
||||||
|
$params[$ph] = $k;
|
||||||
|
}
|
||||||
|
$rows = $this->db->fetchAll("
|
||||||
|
SELECT *
|
||||||
|
FROM media_metadata
|
||||||
|
WHERE subject_kind=:k
|
||||||
|
AND subject_key IN (" . implode(',', $placeholders) . ")
|
||||||
|
", $params);
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$row = $this->decodeMetaRow($r);
|
||||||
|
$out[(string)$row['subject_key']] = $row;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveSelection(string $kind, string $key, array $payload): void {
|
||||||
|
$provider = (string)($payload['provider'] ?? '');
|
||||||
|
$providerId = (string)($payload['provider_id'] ?? '');
|
||||||
|
$titleMap = is_array($payload['title_map'] ?? null) ? $payload['title_map'] : [];
|
||||||
|
$originalTitle = (string)($payload['original_title'] ?? '');
|
||||||
|
$year = isset($payload['year']) ? (int)$payload['year'] : null;
|
||||||
|
|
||||||
|
$this->upsertMeta($kind, $key, [
|
||||||
|
'provider' => $provider !== '' ? $provider : null,
|
||||||
|
'provider_id' => $providerId !== '' ? $providerId : null,
|
||||||
|
'title_map' => $titleMap,
|
||||||
|
'original_title' => $originalTitle !== '' ? $originalTitle : null,
|
||||||
|
'year' => $year,
|
||||||
|
'manual_title' => null,
|
||||||
|
'manual_year' => null,
|
||||||
|
'source' => 'auto',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveManual(string $kind, string $key, string $title, ?int $year): void {
|
||||||
|
$row = $this->getSubjectMeta($kind, $key);
|
||||||
|
$payload = [
|
||||||
|
'provider' => $row['provider'] ?? null,
|
||||||
|
'provider_id' => $row['provider_id'] ?? null,
|
||||||
|
'title_map' => $row['title_map'] ?? [],
|
||||||
|
'original_title' => $row['original_title'] ?? null,
|
||||||
|
'year' => $row['year'] ?? null,
|
||||||
|
'manual_title' => $title !== '' ? $title : null,
|
||||||
|
'manual_year' => $year,
|
||||||
|
'source' => 'manual',
|
||||||
|
];
|
||||||
|
$this->upsertMeta($kind, $key, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearManual(string $kind, string $key): void {
|
||||||
|
$row = $this->getSubjectMeta($kind, $key);
|
||||||
|
if (!$row) return;
|
||||||
|
$payload = [
|
||||||
|
'provider' => $row['provider'] ?? null,
|
||||||
|
'provider_id' => $row['provider_id'] ?? null,
|
||||||
|
'title_map' => $row['title_map'] ?? [],
|
||||||
|
'original_title' => $row['original_title'] ?? null,
|
||||||
|
'year' => $row['year'] ?? null,
|
||||||
|
'manual_title' => null,
|
||||||
|
'manual_year' => null,
|
||||||
|
'source' => 'auto',
|
||||||
|
];
|
||||||
|
$this->upsertMeta($kind, $key, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProviderLabel(string $providerId): string {
|
||||||
|
foreach ($this->providers as $p) {
|
||||||
|
if ($p->id() === $providerId) return $p->label();
|
||||||
|
}
|
||||||
|
return $providerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUiLanguage(): string {
|
||||||
|
$all = $this->settings->getAll();
|
||||||
|
return (string)($all['general']['language'] ?? 'en');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function formatDisplay(?array $meta, ?string $uiLang = null): array {
|
||||||
|
$uiLang = $uiLang ?? $this->getUiLanguage();
|
||||||
|
$titleMap = is_array($meta['title_map'] ?? null) ? $meta['title_map'] : [];
|
||||||
|
$manualTitle = (string)($meta['manual_title'] ?? '');
|
||||||
|
$manualYear = $meta['manual_year'] ?? null;
|
||||||
|
|
||||||
|
$titleDisplay = '';
|
||||||
|
$titleOriginal = (string)($meta['original_title'] ?? '');
|
||||||
|
$year = $meta['year'] ?? null;
|
||||||
|
|
||||||
|
if (!empty($meta) && ($meta['source'] ?? '') === 'manual' && $manualTitle !== '') {
|
||||||
|
$titleDisplay = $manualTitle;
|
||||||
|
$year = $manualYear !== null ? (int)$manualYear : $year;
|
||||||
|
} else {
|
||||||
|
if (isset($titleMap[$uiLang]) && $titleMap[$uiLang] !== '') {
|
||||||
|
$titleDisplay = (string)$titleMap[$uiLang];
|
||||||
|
} else if (count($titleMap) > 0) {
|
||||||
|
$first = array_values($titleMap)[0];
|
||||||
|
$titleDisplay = is_string($first) ? $first : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($titleOriginal === '' && $titleDisplay !== '') {
|
||||||
|
$titleOriginal = $titleDisplay;
|
||||||
|
}
|
||||||
|
|
||||||
|
$metaOut = null;
|
||||||
|
if (is_array($meta)) {
|
||||||
|
$metaOut = [
|
||||||
|
'provider' => $meta['provider'] ?? null,
|
||||||
|
'provider_id' => $meta['provider_id'] ?? null,
|
||||||
|
'source' => $meta['source'] ?? 'auto',
|
||||||
|
'title_map' => $titleMap,
|
||||||
|
'original_title' => $meta['original_title'] ?? null,
|
||||||
|
'manual_title' => $meta['manual_title'] ?? null,
|
||||||
|
'manual_year' => $meta['manual_year'] ?? null,
|
||||||
|
'year' => $meta['year'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title_display' => $titleDisplay,
|
||||||
|
'title_original' => $titleOriginal,
|
||||||
|
'year' => $year !== null ? (int)$year : null,
|
||||||
|
'meta' => $metaOut,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildProviderUrl(string $providerId, string $externalId): string {
|
||||||
|
foreach ($this->providers as $p) {
|
||||||
|
if ($p->id() === $providerId) return $p->buildUrl($externalId);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeMetaRow(array $row): array {
|
||||||
|
$map = [];
|
||||||
|
if (isset($row['title_map_json'])) {
|
||||||
|
$decoded = json_decode((string)$row['title_map_json'], true);
|
||||||
|
if (is_array($decoded)) $map = $decoded;
|
||||||
|
}
|
||||||
|
$row['title_map'] = $map;
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function upsertMeta(string $kind, string $key, array $payload): void {
|
||||||
|
$json = json_encode($payload['title_map'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($json === false) $json = '{}';
|
||||||
|
|
||||||
|
$this->db->exec("
|
||||||
|
INSERT INTO media_metadata
|
||||||
|
(subject_kind, subject_key, provider, provider_id, title_map_json, original_title, year, manual_title, manual_year, source)
|
||||||
|
VALUES
|
||||||
|
(:kind, :key, :provider, :provider_id, :title_map, :original_title, :year, :manual_title, :manual_year, :source)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
provider = VALUES(provider),
|
||||||
|
provider_id = VALUES(provider_id),
|
||||||
|
title_map_json = VALUES(title_map_json),
|
||||||
|
original_title = VALUES(original_title),
|
||||||
|
year = VALUES(year),
|
||||||
|
manual_title = VALUES(manual_title),
|
||||||
|
manual_year = VALUES(manual_year),
|
||||||
|
source = VALUES(source),
|
||||||
|
updated_at = NOW()
|
||||||
|
", [
|
||||||
|
':kind' => $kind,
|
||||||
|
':key' => $key,
|
||||||
|
':provider' => $payload['provider'],
|
||||||
|
':provider_id' => $payload['provider_id'],
|
||||||
|
':title_map' => $json,
|
||||||
|
':original_title' => $payload['original_title'],
|
||||||
|
':year' => $payload['year'],
|
||||||
|
':manual_title' => $payload['manual_title'],
|
||||||
|
':manual_year' => $payload['manual_year'],
|
||||||
|
':source' => $payload['source'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getMetadataSettings(): array {
|
||||||
|
$all = $this->settings->getAll();
|
||||||
|
$meta = $all['metadata'] ?? [];
|
||||||
|
if (!is_array($meta)) $meta = [];
|
||||||
|
if (!isset($meta['enabled'])) $meta['enabled'] = false;
|
||||||
|
if (!isset($meta['languages'])) $meta['languages'] = ['de','ru','en'];
|
||||||
|
if (!isset($meta['providers'])) $meta['providers'] = [];
|
||||||
|
if (!isset($meta['provider_priority'])) $meta['provider_priority'] = ['tvdb','omdb'];
|
||||||
|
$meta = $this->applyEnvOverrides($meta);
|
||||||
|
return $meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getLanguages(array $cfg): array {
|
||||||
|
$langs = is_array($cfg['languages'] ?? null) ? $cfg['languages'] : [];
|
||||||
|
$clean = [];
|
||||||
|
foreach ($langs as $l) {
|
||||||
|
$l = trim((string)$l);
|
||||||
|
if ($l !== '') $clean[] = $l;
|
||||||
|
}
|
||||||
|
if (count($clean) === 0) {
|
||||||
|
$clean = ['de','ru','en'];
|
||||||
|
}
|
||||||
|
return array_values(array_unique($clean));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getProviderPriority(array $cfg): array {
|
||||||
|
$list = is_array($cfg['provider_priority'] ?? null) ? $cfg['provider_priority'] : [];
|
||||||
|
$clean = [];
|
||||||
|
foreach ($list as $p) {
|
||||||
|
$p = trim((string)$p);
|
||||||
|
if ($p !== '') $clean[] = $p;
|
||||||
|
}
|
||||||
|
if (count($clean) === 0) {
|
||||||
|
$clean = ['tvdb','omdb'];
|
||||||
|
}
|
||||||
|
return $clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, MetadataProvider>
|
||||||
|
*/
|
||||||
|
private function getEnabledProviders(array $cfg, array $order): array {
|
||||||
|
if (empty($cfg['enabled'])) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$out = [];
|
||||||
|
foreach ($order as $pid) {
|
||||||
|
foreach ($this->providers as $p) {
|
||||||
|
if ($p->id() !== $pid) continue;
|
||||||
|
$pCfg = $cfg['providers'][$pid] ?? [];
|
||||||
|
if (!empty($pCfg['enabled'])) {
|
||||||
|
$out[$pid] = $p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyEnvOverrides(array $meta): array {
|
||||||
|
$envOmdb = getenv('OMDB_API_KEY') ?: '';
|
||||||
|
$envTvdb = getenv('TVDB_API_KEY') ?: '';
|
||||||
|
$envOmdbEnabled = getenv('OMDB_ENABLED');
|
||||||
|
$envTvdbEnabled = getenv('TVDB_ENABLED');
|
||||||
|
|
||||||
|
if ($envOmdb !== '') {
|
||||||
|
$meta['providers']['omdb']['api_key'] = $envOmdb;
|
||||||
|
$meta['providers']['omdb']['enabled'] = true;
|
||||||
|
} else if ($envOmdbEnabled !== false) {
|
||||||
|
$meta['providers']['omdb']['enabled'] = filter_var($envOmdbEnabled, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($envTvdb !== '') {
|
||||||
|
$meta['providers']['tvdb']['api_key'] = $envTvdb;
|
||||||
|
$meta['providers']['tvdb']['enabled'] = true;
|
||||||
|
} else if ($envTvdbEnabled !== false) {
|
||||||
|
$meta['providers']['tvdb']['enabled'] = filter_var($envTvdbEnabled, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
}
|
||||||
|
|
||||||
|
$envPin = getenv('TVDB_PIN') ?: '';
|
||||||
|
if ($envPin !== '') {
|
||||||
|
$meta['providers']['tvdb']['pin'] = $envPin;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $meta;
|
||||||
|
}
|
||||||
|
}
|
||||||
274
app/services/MkvToolnixService.php
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
<?php
|
||||||
|
// app/services/MkvToolnixService.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Services;
|
||||||
|
|
||||||
|
use ScMedia\Db\Db;
|
||||||
|
use ScMedia\Services\SettingsService;
|
||||||
|
use ScMedia\Services\ShellTool;
|
||||||
|
|
||||||
|
final class MkvToolnixService {
|
||||||
|
private Db $db;
|
||||||
|
private SettingsService $settings;
|
||||||
|
private ?LogService $logger;
|
||||||
|
private ShellTool $shell;
|
||||||
|
private ?string $mkvmergePath = null;
|
||||||
|
private ?string $mkvpropeditPath = null;
|
||||||
|
|
||||||
|
public function __construct(Db $db, SettingsService $settings, ?LogService $logger = null, ?ShellTool $shell = null) {
|
||||||
|
$this->db = $db;
|
||||||
|
$this->settings = $settings;
|
||||||
|
$this->logger = $logger;
|
||||||
|
$this->shell = $shell ?? new ShellTool();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAvailable(): bool {
|
||||||
|
return $this->getMkvmergePath() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isMkvmergeAvailable(): bool {
|
||||||
|
return $this->getMkvmergePath() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isMkvpropeditAvailable(): bool {
|
||||||
|
return $this->getMkvpropeditPath() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function readFileData(string $path): array {
|
||||||
|
$path = rtrim($path, "/");
|
||||||
|
if ($path === '') {
|
||||||
|
return ['ok' => false, 'error' => 'Empty path'];
|
||||||
|
}
|
||||||
|
if (!is_file($path)) {
|
||||||
|
return ['ok' => false, 'error' => 'File not found'];
|
||||||
|
}
|
||||||
|
if (!is_readable($path)) {
|
||||||
|
return ['ok' => false, 'error' => 'File not readable'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$stat = @stat($path);
|
||||||
|
if (!is_array($stat)) {
|
||||||
|
return ['ok' => false, 'error' => 'Cannot stat file'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = (int)$stat['size'];
|
||||||
|
$mtime = (int)$stat['mtime'];
|
||||||
|
$inode = isset($stat['ino']) ? (int)$stat['ino'] : 0;
|
||||||
|
|
||||||
|
$prev = $this->db->fetchOne("
|
||||||
|
SELECT size_bytes, mtime, inode, info_json
|
||||||
|
FROM media_file_meta
|
||||||
|
WHERE abs_path = :p
|
||||||
|
LIMIT 1
|
||||||
|
", [':p' => $path]);
|
||||||
|
|
||||||
|
$changed = true;
|
||||||
|
if ($prev) {
|
||||||
|
$prevSize = (int)($prev['size_bytes'] ?? 0);
|
||||||
|
$prevMtime = (int)($prev['mtime'] ?? 0);
|
||||||
|
$prevInode = (int)($prev['inode'] ?? 0);
|
||||||
|
if ($prevSize === $size && $prevMtime === $mtime && $prevInode === $inode) {
|
||||||
|
$changed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($prev && !$changed) {
|
||||||
|
$prevInfo = (string)($prev['info_json'] ?? '');
|
||||||
|
if ($prevInfo === '' || $prevInfo === 'null') {
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$info = null;
|
||||||
|
$fromCache = false;
|
||||||
|
if ($changed) {
|
||||||
|
$info = $this->probeMkvmerge($path);
|
||||||
|
$this->upsertMeta($path, $size, $mtime, $inode, $info);
|
||||||
|
} else if (is_array($prev)) {
|
||||||
|
$cached = json_decode((string)($prev['info_json'] ?? ''), true);
|
||||||
|
if (is_array($cached)) {
|
||||||
|
$info = $cached;
|
||||||
|
$fromCache = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'changed' => $changed,
|
||||||
|
'from_cache' => $fromCache,
|
||||||
|
'signature' => [
|
||||||
|
'size_bytes' => $size,
|
||||||
|
'mtime' => $mtime,
|
||||||
|
'inode' => $inode,
|
||||||
|
],
|
||||||
|
'file' => [
|
||||||
|
'path' => $path,
|
||||||
|
'name' => basename($path),
|
||||||
|
'ext' => strtolower(pathinfo($path, PATHINFO_EXTENSION)),
|
||||||
|
],
|
||||||
|
'mkvmerge' => [
|
||||||
|
'available' => $this->isAvailable(),
|
||||||
|
'data' => $info,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function probeMkvmerge(string $path): ?array {
|
||||||
|
if (!$this->isAvailable()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bin = $this->getMkvmergePath();
|
||||||
|
if ($bin === null) return null;
|
||||||
|
$cmd = escapeshellarg($bin) . ' -J ' . escapeshellarg($path) . ' 2>/dev/null';
|
||||||
|
$this->logCmd('debug', $cmd);
|
||||||
|
$json = $this->shell->exec($cmd);
|
||||||
|
if (!is_string($json)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
if (is_array($data)) {
|
||||||
|
$errors = $data['errors'] ?? [];
|
||||||
|
if (is_array($errors) && count($errors) > 0) {
|
||||||
|
$this->logger?->log('warn', 'mkvmerge reported errors', ['errors' => $errors]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$container = (string)($data['container']['type'] ?? '');
|
||||||
|
$dur = $data['container']['properties']['duration'] ?? $data['container']['duration'] ?? null;
|
||||||
|
$this->logger?->log('debug', 'mkvmerge parsed', [
|
||||||
|
'container' => $container,
|
||||||
|
'duration' => $dur,
|
||||||
|
]);
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
$this->logger?->log('warn', 'mkvmerge JSON parse failed');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyTrackEdits(string $path, array $rename, array $delete): array {
|
||||||
|
if (!$this->isMkvpropeditAvailable()) {
|
||||||
|
return ['ok' => false, 'error' => 'mkvpropedit not available'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$args = [];
|
||||||
|
foreach ($delete as $id) {
|
||||||
|
$id = (int)$id;
|
||||||
|
$args[] = '--delete';
|
||||||
|
$args[] = 'track:' . $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($rename as $r) {
|
||||||
|
$id = (int)($r['id'] ?? 0);
|
||||||
|
$name = (string)($r['name'] ?? '');
|
||||||
|
if ($id <= 0 || $name === '') continue;
|
||||||
|
$args[] = '--edit';
|
||||||
|
$args[] = 'track:' . $id;
|
||||||
|
$args[] = '--set';
|
||||||
|
$args[] = 'name=' . $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($args) === 0) {
|
||||||
|
return ['ok' => true, 'changed' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
$bin = $this->getMkvpropeditPath();
|
||||||
|
if ($bin === null) {
|
||||||
|
return ['ok' => false, 'error' => 'mkvpropedit not available'];
|
||||||
|
}
|
||||||
|
$cmd = escapeshellarg($bin) . ' ' . escapeshellarg($path);
|
||||||
|
foreach ($args as $a) {
|
||||||
|
$cmd .= ' ' . escapeshellarg($a);
|
||||||
|
}
|
||||||
|
$out = [];
|
||||||
|
$code = 0;
|
||||||
|
$this->logCmd('info', $this->shell->wrap($cmd));
|
||||||
|
@exec($this->shell->wrap($cmd) . ' 2>&1', $out, $code);
|
||||||
|
if ($code !== 0) {
|
||||||
|
return ['ok' => false, 'error' => implode("\n", $out)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => true, 'changed' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function convertToMkv(string $srcPath, string $destPath): array {
|
||||||
|
if (!$this->isMkvmergeAvailable()) {
|
||||||
|
return ['ok' => false, 'error' => 'mkvmerge not available'];
|
||||||
|
}
|
||||||
|
$bin = $this->getMkvmergePath();
|
||||||
|
if ($bin === null) {
|
||||||
|
return ['ok' => false, 'error' => 'mkvmerge not available'];
|
||||||
|
}
|
||||||
|
$cmd = escapeshellarg($bin) . ' -o ' . escapeshellarg($destPath) . ' ' . escapeshellarg($srcPath);
|
||||||
|
$out = [];
|
||||||
|
$code = 0;
|
||||||
|
$this->logCmd('info', $this->shell->wrap($cmd));
|
||||||
|
@exec($this->shell->wrap($cmd) . ' 2>&1', $out, $code);
|
||||||
|
if ($code !== 0) {
|
||||||
|
return ['ok' => false, 'error' => implode("\n", $out)];
|
||||||
|
}
|
||||||
|
return ['ok' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function upsertMeta(string $path, int $size, int $mtime, int $inode, ?array $info): void {
|
||||||
|
$infoJson = $info === null ? null : json_encode($info, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
$this->db->exec("
|
||||||
|
INSERT INTO media_file_meta (abs_path, size_bytes, mtime, inode, info_json, last_scanned_at)
|
||||||
|
VALUES (:p, :s, :m, :i, :j, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
size_bytes = VALUES(size_bytes),
|
||||||
|
mtime = VALUES(mtime),
|
||||||
|
inode = VALUES(inode),
|
||||||
|
info_json = VALUES(info_json),
|
||||||
|
last_scanned_at = NOW()
|
||||||
|
", [
|
||||||
|
':p' => $path,
|
||||||
|
':s' => $size,
|
||||||
|
':m' => $mtime,
|
||||||
|
':i' => $inode,
|
||||||
|
':j' => $infoJson,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getMkvmergePath(): ?string {
|
||||||
|
if ($this->mkvmergePath !== null) {
|
||||||
|
return $this->mkvmergePath;
|
||||||
|
}
|
||||||
|
$all = $this->settings->getAll();
|
||||||
|
$tools = $all['tools'] ?? [];
|
||||||
|
$cfg = trim((string)($tools['mkvmerge_path'] ?? ''));
|
||||||
|
if ($cfg !== '' && is_file($cfg)) {
|
||||||
|
$this->mkvmergePath = $cfg;
|
||||||
|
return $cfg;
|
||||||
|
}
|
||||||
|
$path = $this->shell->which('mkvmerge');
|
||||||
|
$this->mkvmergePath = $path !== null ? $path : null;
|
||||||
|
return $this->mkvmergePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getMkvpropeditPath(): ?string {
|
||||||
|
if ($this->mkvpropeditPath !== null) {
|
||||||
|
return $this->mkvpropeditPath;
|
||||||
|
}
|
||||||
|
$all = $this->settings->getAll();
|
||||||
|
$tools = $all['tools'] ?? [];
|
||||||
|
$cfg = trim((string)($tools['mkvpropedit_path'] ?? ''));
|
||||||
|
if ($cfg !== '' && is_file($cfg)) {
|
||||||
|
$this->mkvpropeditPath = $cfg;
|
||||||
|
return $cfg;
|
||||||
|
}
|
||||||
|
$path = $this->shell->which('mkvpropedit');
|
||||||
|
$this->mkvpropeditPath = $path !== null ? $path : null;
|
||||||
|
return $this->mkvpropeditPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logCmd(string $level, string $cmd): void {
|
||||||
|
if ($this->logger) {
|
||||||
|
$this->logger->log($level, 'Exec: ' . $cmd, ['op' => 'mkvtoolnix']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
85
app/services/PathTool.php
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
// app/services/PathTool.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Services;
|
||||||
|
|
||||||
|
final class PathTool {
|
||||||
|
public function testPath(string $path, array $checks): array {
|
||||||
|
$res = [
|
||||||
|
'exists' => null,
|
||||||
|
'read' => null,
|
||||||
|
'write' => null,
|
||||||
|
'rename' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$notes = [];
|
||||||
|
|
||||||
|
$path = rtrim($path, "/");
|
||||||
|
if ($path === '') {
|
||||||
|
return [
|
||||||
|
'results' => $res,
|
||||||
|
'notes' => ['Empty path'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array('exists', $checks, true)) {
|
||||||
|
$res['exists'] = file_exists($path);
|
||||||
|
if (!$res['exists']) {
|
||||||
|
$notes[] = "Path does not exist";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array('read', $checks, true)) {
|
||||||
|
$res['read'] = is_readable($path);
|
||||||
|
if (!$res['read']) {
|
||||||
|
$notes[] = "Not readable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array('write', $checks, true)) {
|
||||||
|
$res['write'] = is_writable($path);
|
||||||
|
if (!$res['write']) {
|
||||||
|
$notes[] = "Not writable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array('rename', $checks, true)) {
|
||||||
|
// Rename test: create temp file and rename it in same directory
|
||||||
|
$dir = $path;
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
$dir = dirname($path);
|
||||||
|
}
|
||||||
|
$tmp1 = $dir . "/.scmedia_test_" . bin2hex(random_bytes(6));
|
||||||
|
$tmp2 = $tmp1 . "_renamed";
|
||||||
|
|
||||||
|
$ok = false;
|
||||||
|
try {
|
||||||
|
$created = @file_put_contents($tmp1, "test");
|
||||||
|
if ($created === false) {
|
||||||
|
$notes[] = "Rename test: cannot create temp file";
|
||||||
|
} else {
|
||||||
|
$ren = @rename($tmp1, $tmp2);
|
||||||
|
if ($ren) {
|
||||||
|
$ok = true;
|
||||||
|
@unlink($tmp2);
|
||||||
|
} else {
|
||||||
|
$notes[] = "Rename test: rename() failed";
|
||||||
|
@unlink($tmp1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$notes[] = "Rename test exception: " . $e->getMessage();
|
||||||
|
@unlink($tmp1);
|
||||||
|
@unlink($tmp2);
|
||||||
|
}
|
||||||
|
$res['rename'] = $ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'results' => $res,
|
||||||
|
'notes' => $notes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
158
app/services/ScanProfilesService.php
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
<?php
|
||||||
|
// app/services/ScanProfilesService.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Services;
|
||||||
|
|
||||||
|
use ScMedia\Db\Db;
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
final class ScanProfilesService {
|
||||||
|
private Db $db;
|
||||||
|
private SettingsService $settings;
|
||||||
|
private array $config;
|
||||||
|
|
||||||
|
public function __construct(Db $db, SettingsService $settings, array $config) {
|
||||||
|
$this->db = $db;
|
||||||
|
$this->settings = $settings;
|
||||||
|
$this->config = $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function list(): array {
|
||||||
|
return $this->db->fetchAll("
|
||||||
|
SELECT
|
||||||
|
id, sort_order, profile_type, enabled, name, root_path, max_depth,
|
||||||
|
exclude_patterns_json, include_ext_mode, include_ext_json,
|
||||||
|
last_scan_at, last_result
|
||||||
|
FROM scan_profiles
|
||||||
|
ORDER BY sort_order ASC, id ASC
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(array $p): int {
|
||||||
|
$this->validate($p);
|
||||||
|
|
||||||
|
$exclude = json_encode($p['exclude_patterns'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
$include = null;
|
||||||
|
if (($p['include_ext_mode'] ?? 'default') === 'custom') {
|
||||||
|
$include = json_encode($p['include_ext'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sortOrder = isset($p['sort_order']) ? (int)$p['sort_order'] : $this->nextSortOrder();
|
||||||
|
|
||||||
|
$this->db->exec("
|
||||||
|
INSERT INTO scan_profiles
|
||||||
|
(sort_order, profile_type, enabled, name, root_path, max_depth, exclude_patterns_json, include_ext_mode, include_ext_json)
|
||||||
|
VALUES
|
||||||
|
(:sort_order, :ptype, :enabled, :name, :root, :depth, :exclude, :mode, :include)
|
||||||
|
", [
|
||||||
|
':sort_order' => $sortOrder,
|
||||||
|
':ptype' => (string)($p['profile_type'] ?? 'scan'),
|
||||||
|
':enabled' => !empty($p['enabled']) ? 1 : 0,
|
||||||
|
':name' => (string)$p['name'],
|
||||||
|
':root' => (string)$p['root_path'],
|
||||||
|
':depth' => (int)$p['max_depth'],
|
||||||
|
':exclude' => $exclude ?: '[]',
|
||||||
|
':mode' => (string)($p['include_ext_mode'] ?? 'default'),
|
||||||
|
':include' => $include,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$row = $this->db->fetchOne("SELECT LAST_INSERT_ID() AS id");
|
||||||
|
return (int)($row['id'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(int $id, array $p): void {
|
||||||
|
$this->validate($p);
|
||||||
|
|
||||||
|
$exclude = json_encode($p['exclude_patterns'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
$include = null;
|
||||||
|
if (($p['include_ext_mode'] ?? 'default') === 'custom') {
|
||||||
|
$include = json_encode($p['include_ext'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->exec("
|
||||||
|
UPDATE scan_profiles
|
||||||
|
SET
|
||||||
|
profile_type = :ptype,
|
||||||
|
enabled = :enabled,
|
||||||
|
name = :name,
|
||||||
|
root_path = :root,
|
||||||
|
max_depth = :depth,
|
||||||
|
exclude_patterns_json = :exclude,
|
||||||
|
include_ext_mode = :mode,
|
||||||
|
include_ext_json = :include
|
||||||
|
WHERE id = :id
|
||||||
|
", [
|
||||||
|
':ptype' => (string)($p['profile_type'] ?? 'scan'),
|
||||||
|
':enabled' => !empty($p['enabled']) ? 1 : 0,
|
||||||
|
':name' => (string)$p['name'],
|
||||||
|
':root' => (string)$p['root_path'],
|
||||||
|
':depth' => (int)$p['max_depth'],
|
||||||
|
':exclude' => $exclude ?: '[]',
|
||||||
|
':mode' => (string)($p['include_ext_mode'] ?? 'default'),
|
||||||
|
':include' => $include,
|
||||||
|
':id' => $id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $id): void {
|
||||||
|
$this->db->exec("DELETE FROM scan_profiles WHERE id = :id", [':id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reorder(array $ids): void {
|
||||||
|
$ids = array_values(array_filter(array_map('intval', $ids), fn($v) => $v > 0));
|
||||||
|
if (count($ids) === 0) return;
|
||||||
|
|
||||||
|
$this->db->begin();
|
||||||
|
try {
|
||||||
|
$order = 10;
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$this->db->exec("
|
||||||
|
UPDATE scan_profiles
|
||||||
|
SET sort_order = :o
|
||||||
|
WHERE id = :id
|
||||||
|
", [':o' => $order, ':id' => $id]);
|
||||||
|
$order += 10;
|
||||||
|
}
|
||||||
|
$this->db->commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->db->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validate(array $p): void {
|
||||||
|
$name = trim((string)($p['name'] ?? ''));
|
||||||
|
$root = trim((string)($p['root_path'] ?? ''));
|
||||||
|
$depth = (int)($p['max_depth'] ?? 0);
|
||||||
|
$ptype = (string)($p['profile_type'] ?? 'scan');
|
||||||
|
|
||||||
|
if ($name === '') {
|
||||||
|
throw new Exception("Profile name is required");
|
||||||
|
}
|
||||||
|
if ($root === '' || $root[0] !== '/') {
|
||||||
|
throw new Exception("Root path must be an absolute path");
|
||||||
|
}
|
||||||
|
if (!in_array($ptype, ['scan', 'analyze'], true)) {
|
||||||
|
throw new Exception("Invalid profile type");
|
||||||
|
}
|
||||||
|
|
||||||
|
$fromSettings = $this->settings->getAll()['safety'] ?? [];
|
||||||
|
$hardMaxDepth = (int)($fromSettings['max_depth'] ?? ($this->config['safety']['hard_limits']['max_depth'] ?? 10));
|
||||||
|
if ($depth < 1 || $depth > $hardMaxDepth) {
|
||||||
|
throw new Exception("Max depth out of range");
|
||||||
|
}
|
||||||
|
|
||||||
|
$mode = (string)($p['include_ext_mode'] ?? 'default');
|
||||||
|
if (!in_array($mode, ['default', 'custom'], true)) {
|
||||||
|
throw new Exception("Invalid include_ext_mode");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nextSortOrder(): int {
|
||||||
|
$row = $this->db->fetchOne("SELECT MAX(sort_order) AS max_sort FROM scan_profiles");
|
||||||
|
$max = isset($row['max_sort']) ? (int)$row['max_sort'] : 0;
|
||||||
|
return $max + 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
524
app/services/ScannerService.php
Normal file
@ -0,0 +1,524 @@
|
|||||||
|
<?php
|
||||||
|
// app/services/ScannerService.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Services;
|
||||||
|
|
||||||
|
use ScMedia\Db\Db;
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
final class ScannerService {
|
||||||
|
private Db $db;
|
||||||
|
private SettingsService $settings;
|
||||||
|
private ScanProfilesService $profiles;
|
||||||
|
private JobsService $jobs;
|
||||||
|
private MkvToolnixService $mkvtoolnix;
|
||||||
|
private MediaLibraryService $mediaLibrary;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
Db $db,
|
||||||
|
SettingsService $settings,
|
||||||
|
ScanProfilesService $profiles,
|
||||||
|
JobsService $jobs,
|
||||||
|
MkvToolnixService $mkvtoolnix,
|
||||||
|
MediaLibraryService $mediaLibrary
|
||||||
|
) {
|
||||||
|
$this->db = $db;
|
||||||
|
$this->settings = $settings;
|
||||||
|
$this->profiles = $profiles;
|
||||||
|
$this->jobs = $jobs;
|
||||||
|
$this->mkvtoolnix = $mkvtoolnix;
|
||||||
|
$this->mediaLibrary = $mediaLibrary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runScanJob(int $jobId): array {
|
||||||
|
$all = $this->settings->getAll();
|
||||||
|
|
||||||
|
$scannerDefaults = $all['scanner_defaults'] ?? [];
|
||||||
|
$globalExt = is_array($scannerDefaults['video_ext'] ?? null) ? $scannerDefaults['video_ext'] : ['mkv','mp4','avi','mov','m4v','ts','m2ts','wmv'];
|
||||||
|
|
||||||
|
$hardLimits = $this->getHardLimits();
|
||||||
|
$defaultMaxDepth = (int)($scannerDefaults['max_depth_default'] ?? 3);
|
||||||
|
$defaultMaxFilesPerItem = (int)($scannerDefaults['max_files_per_item'] ?? 3000);
|
||||||
|
$defaultMaxItemsPerScan = (int)($scannerDefaults['max_items_per_scan'] ?? 0);
|
||||||
|
|
||||||
|
$defaultMaxDepth = $this->cap($defaultMaxDepth, 1, (int)$hardLimits['max_depth']);
|
||||||
|
$defaultMaxFilesPerItem = $this->cap($defaultMaxFilesPerItem, 100, (int)$hardLimits['max_files_per_item']);
|
||||||
|
$defaultMaxItemsPerScan = $this->cap($defaultMaxItemsPerScan, 0, (int)$hardLimits['max_items_per_scan']);
|
||||||
|
|
||||||
|
$profiles = $this->profiles->list();
|
||||||
|
$profiles = array_values(array_filter($profiles, fn($p) => !empty($p['enabled'])));
|
||||||
|
|
||||||
|
$this->jobs->log($jobId, 'info', 'Scan started');
|
||||||
|
$this->jobs->setProgress($jobId, 0, max(1, count($profiles)));
|
||||||
|
|
||||||
|
$totalItemsSeen = 0;
|
||||||
|
$profileIndex = 0;
|
||||||
|
|
||||||
|
foreach ($profiles as $p) {
|
||||||
|
if ($this->jobs->cancelIfRequested($jobId)) {
|
||||||
|
$this->jobs->log($jobId, 'warn', 'Scan canceled by user');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$profileIndex++;
|
||||||
|
|
||||||
|
$profileId = (int)$p['id'];
|
||||||
|
$root = (string)$p['root_path'];
|
||||||
|
$profileType = (string)($p['profile_type'] ?? 'scan');
|
||||||
|
|
||||||
|
$maxDepth = (int)($p['max_depth'] ?? $defaultMaxDepth);
|
||||||
|
$maxDepth = $this->cap($maxDepth, 1, (int)$hardLimits['max_depth']);
|
||||||
|
|
||||||
|
$exclude = $this->toStringList($p['exclude_patterns'] ?? []);
|
||||||
|
$extMode = (string)($p['include_ext_mode'] ?? 'default');
|
||||||
|
$ext = $globalExt;
|
||||||
|
if ($extMode === 'custom') {
|
||||||
|
$custom = $this->toStringList($p['include_ext'] ?? []);
|
||||||
|
if (count($custom) > 0) $ext = $custom;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->jobs->log($jobId, 'info', "Profile #{$profileId} ({$profileType}): {$root}");
|
||||||
|
|
||||||
|
if ($profileType === 'analyze') {
|
||||||
|
$result = $this->analyzeProfileRoot(
|
||||||
|
$jobId,
|
||||||
|
$profileId,
|
||||||
|
$root,
|
||||||
|
$exclude,
|
||||||
|
$ext,
|
||||||
|
$maxDepth,
|
||||||
|
$defaultMaxItemsPerScan
|
||||||
|
);
|
||||||
|
$this->jobs->log($jobId, 'info', "Analyze done. Files: {$result['files']} Changed: {$result['changed']} Cached: {$result['cached']}");
|
||||||
|
} else {
|
||||||
|
$seenAbs = $this->scanProfileRoot(
|
||||||
|
$jobId,
|
||||||
|
$profileId,
|
||||||
|
$root,
|
||||||
|
$exclude,
|
||||||
|
$ext,
|
||||||
|
$maxDepth,
|
||||||
|
$defaultMaxFilesPerItem,
|
||||||
|
$defaultMaxItemsPerScan,
|
||||||
|
$totalItemsSeen
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark gone
|
||||||
|
$this->markGone($profileId, $seenAbs);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->exec("
|
||||||
|
UPDATE scan_profiles
|
||||||
|
SET last_scan_at=NOW(), last_result='ok'
|
||||||
|
WHERE id=:id
|
||||||
|
", [':id' => $profileId]);
|
||||||
|
|
||||||
|
$this->jobs->setProgress($jobId, $profileIndex, count($profiles));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->jobs->isCancelRequested($jobId)) {
|
||||||
|
return [
|
||||||
|
'profiles' => count($profiles),
|
||||||
|
'items_seen' => $totalItemsSeen,
|
||||||
|
'canceled' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->jobs->log($jobId, 'info', "Scan finished. Items seen: {$totalItemsSeen}");
|
||||||
|
$this->jobs->setProgress($jobId, count($profiles), count($profiles));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'profiles' => count($profiles),
|
||||||
|
'items_seen' => $totalItemsSeen,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function analyzeProfileRoot(
|
||||||
|
int $jobId,
|
||||||
|
int $profileId,
|
||||||
|
string $root,
|
||||||
|
array $excludePatterns,
|
||||||
|
array $videoExt,
|
||||||
|
int $maxDepth,
|
||||||
|
int $maxFiles
|
||||||
|
): array {
|
||||||
|
$root = rtrim($root, "/");
|
||||||
|
|
||||||
|
if ($root === '' || !is_dir($root)) {
|
||||||
|
$this->jobs->log($jobId, 'warn', "Analyze root not accessible: {$root}");
|
||||||
|
return ['files' => 0, 'changed' => 0, 'cached' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->mkvtoolnix->isAvailable()) {
|
||||||
|
$this->jobs->log($jobId, 'warn', "mkvmerge not available, analyze limited to inventory");
|
||||||
|
}
|
||||||
|
|
||||||
|
$extList = $this->normExt($videoExt);
|
||||||
|
$files = 0;
|
||||||
|
$changed = 0;
|
||||||
|
$cached = 0;
|
||||||
|
|
||||||
|
$stack = [[ 'path' => $root, 'depth' => 0 ]];
|
||||||
|
$stop = false;
|
||||||
|
|
||||||
|
while (count($stack) > 0 && !$stop) {
|
||||||
|
if ($this->jobs->cancelIfRequested($jobId)) {
|
||||||
|
$this->jobs->log($jobId, 'warn', 'Analyze canceled by user');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$node = array_pop($stack);
|
||||||
|
$path = $node['path'];
|
||||||
|
$depth = (int)$node['depth'];
|
||||||
|
|
||||||
|
$entries = @scandir($path);
|
||||||
|
if (!is_array($entries)) continue;
|
||||||
|
|
||||||
|
foreach ($entries as $name) {
|
||||||
|
if ($this->jobs->cancelIfRequested($jobId)) {
|
||||||
|
$this->jobs->log($jobId, 'warn', 'Analyze canceled by user');
|
||||||
|
$stop = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if ($name === '.' || $name === '..') continue;
|
||||||
|
if ($this->isExcluded($name, $excludePatterns)) continue;
|
||||||
|
|
||||||
|
$abs = $path . '/' . $name;
|
||||||
|
|
||||||
|
if (is_dir($abs) && $depth < $maxDepth) {
|
||||||
|
$stack[] = ['path' => $abs, 'depth' => $depth + 1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_file($abs)) continue;
|
||||||
|
|
||||||
|
$ext = strtolower(pathinfo($abs, PATHINFO_EXTENSION));
|
||||||
|
if ($ext === '' || !in_array($ext, $extList, true)) continue;
|
||||||
|
|
||||||
|
$files++;
|
||||||
|
if ($maxFiles > 0 && $files >= $maxFiles) {
|
||||||
|
$this->jobs->log($jobId, 'warn', "Analyze max files reached: {$maxFiles}");
|
||||||
|
$stop = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stat = @stat($abs);
|
||||||
|
$res = null;
|
||||||
|
if ($ext === 'mkv') {
|
||||||
|
$res = $this->mkvtoolnix->readFileData($abs);
|
||||||
|
}
|
||||||
|
if (is_array($stat)) {
|
||||||
|
$this->mediaLibrary->upsertMediaFile(
|
||||||
|
$profileId,
|
||||||
|
$root,
|
||||||
|
$abs,
|
||||||
|
$stat,
|
||||||
|
($ext === 'mkv'),
|
||||||
|
($res && isset($res['mkvmerge']['data'])) ? $res['mkvmerge']['data'] : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (is_array($res) && !empty($res['ok'])) {
|
||||||
|
if (!empty($res['changed'])) {
|
||||||
|
$changed++;
|
||||||
|
} else if (!empty($res['from_cache'])) {
|
||||||
|
$cached++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['files' => $files, 'changed' => $changed, 'cached' => $cached];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function scanProfileRoot(
|
||||||
|
int $jobId,
|
||||||
|
int $profileId,
|
||||||
|
string $root,
|
||||||
|
array $excludePatterns,
|
||||||
|
array $videoExt,
|
||||||
|
int $maxDepth,
|
||||||
|
int $maxFilesPerItem,
|
||||||
|
int $maxItemsPerScan,
|
||||||
|
int &$totalItemsSeen
|
||||||
|
): array {
|
||||||
|
$root = rtrim($root, "/");
|
||||||
|
|
||||||
|
if ($root === '' || !is_dir($root)) {
|
||||||
|
$this->jobs->log($jobId, 'warn', "Root not accessible: {$root}");
|
||||||
|
$this->db->exec("
|
||||||
|
UPDATE scan_profiles
|
||||||
|
SET last_scan_at=NOW(), last_result='error'
|
||||||
|
WHERE id=:id
|
||||||
|
", [':id' => $profileId]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries = @scandir($root);
|
||||||
|
if (!is_array($entries)) {
|
||||||
|
$this->jobs->log($jobId, 'error', "Cannot read dir: {$root}");
|
||||||
|
$this->db->exec("
|
||||||
|
UPDATE scan_profiles
|
||||||
|
SET last_scan_at=NOW(), last_result='error'
|
||||||
|
WHERE id=:id
|
||||||
|
", [':id' => $profileId]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
foreach ($entries as $name) {
|
||||||
|
if ($this->jobs->cancelIfRequested($jobId)) {
|
||||||
|
$this->jobs->log($jobId, 'warn', 'Scan canceled by user');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if ($name === '.' || $name === '..') continue;
|
||||||
|
if ($this->isExcluded($name, $excludePatterns)) continue;
|
||||||
|
|
||||||
|
$abs = $root . '/' . $name;
|
||||||
|
|
||||||
|
// Respect max items per scan (if >0)
|
||||||
|
if ($maxItemsPerScan > 0 && $totalItemsSeen >= $maxItemsPerScan) {
|
||||||
|
$this->jobs->log($jobId, 'warn', "Max items per scan reached: {$maxItemsPerScan}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$info = $this->inspectItem($abs, $videoExt, $maxDepth, $maxFilesPerItem);
|
||||||
|
|
||||||
|
$display = $name;
|
||||||
|
$structure = $info['structure'];
|
||||||
|
$videoCount = $info['video_count'];
|
||||||
|
$fileCount = $info['file_count'];
|
||||||
|
$confidence = $info['confidence'];
|
||||||
|
|
||||||
|
$this->upsertItem($profileId, $abs, $name, $display, $structure, $confidence, $videoCount, $fileCount);
|
||||||
|
|
||||||
|
$seen[] = $abs;
|
||||||
|
$totalItemsSeen++;
|
||||||
|
$seenAbsKey = $abs;
|
||||||
|
$seen[$seenAbsKey] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_keys($seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function inspectItem(string $abs, array $videoExt, int $maxDepth, int $maxFilesPerItem): array {
|
||||||
|
if (is_file($abs)) {
|
||||||
|
$ext = strtolower(pathinfo($abs, PATHINFO_EXTENSION));
|
||||||
|
$isVideo = in_array($ext, $this->normExt($videoExt), true);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'structure' => 'file',
|
||||||
|
'confidence' => $isVideo ? 70 : 10,
|
||||||
|
'video_count' => $isVideo ? 1 : 0,
|
||||||
|
'file_count' => 1,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_dir($abs)) {
|
||||||
|
return [
|
||||||
|
'structure' => 'folder',
|
||||||
|
'confidence' => 0,
|
||||||
|
'video_count' => 0,
|
||||||
|
'file_count' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// detect dvd/bluray by marker folders
|
||||||
|
if (is_dir($abs . '/BDMV')) {
|
||||||
|
$counts = $this->countFiles($abs, $videoExt, $maxDepth, $maxFilesPerItem);
|
||||||
|
return [
|
||||||
|
'structure' => 'bluray',
|
||||||
|
'confidence' => 90,
|
||||||
|
'video_count' => $counts['video'],
|
||||||
|
'file_count' => $counts['files'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_dir($abs . '/VIDEO_TS')) {
|
||||||
|
$counts = $this->countFiles($abs, $videoExt, $maxDepth, $maxFilesPerItem);
|
||||||
|
return [
|
||||||
|
'structure' => 'dvd',
|
||||||
|
'confidence' => 90,
|
||||||
|
'video_count' => $counts['video'],
|
||||||
|
'file_count' => $counts['files'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = $this->countFiles($abs, $videoExt, $maxDepth, $maxFilesPerItem);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'structure' => 'folder',
|
||||||
|
'confidence' => $counts['video'] > 0 ? 60 : 20,
|
||||||
|
'video_count' => $counts['video'],
|
||||||
|
'file_count' => $counts['files'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function countFiles(string $dir, array $videoExt, int $maxDepth, int $maxFilesPerItem): array {
|
||||||
|
$videoExt = $this->normExt($videoExt);
|
||||||
|
|
||||||
|
$files = 0;
|
||||||
|
$video = 0;
|
||||||
|
|
||||||
|
$stack = [[ 'path' => $dir, 'depth' => 0 ]];
|
||||||
|
|
||||||
|
while (count($stack) > 0) {
|
||||||
|
$node = array_pop($stack);
|
||||||
|
$path = $node['path'];
|
||||||
|
$depth = (int)$node['depth'];
|
||||||
|
|
||||||
|
$entries = @scandir($path);
|
||||||
|
if (!is_array($entries)) continue;
|
||||||
|
|
||||||
|
foreach ($entries as $name) {
|
||||||
|
if ($name === '.' || $name === '..') continue;
|
||||||
|
|
||||||
|
$abs = $path . '/' . $name;
|
||||||
|
|
||||||
|
if (is_file($abs)) {
|
||||||
|
$files++;
|
||||||
|
$ext = strtolower(pathinfo($abs, PATHINFO_EXTENSION));
|
||||||
|
if ($ext !== '' && in_array($ext, $videoExt, true)) $video++;
|
||||||
|
|
||||||
|
if ($files >= $maxFilesPerItem) {
|
||||||
|
return ['files' => $files, 'video' => $video];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_dir($abs) && $depth < $maxDepth) {
|
||||||
|
$stack[] = ['path' => $abs, 'depth' => $depth + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['files' => $files, 'video' => $video];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function upsertItem(
|
||||||
|
int $profileId,
|
||||||
|
string $absPath,
|
||||||
|
string $relPath,
|
||||||
|
string $displayName,
|
||||||
|
string $structure,
|
||||||
|
int $confidence,
|
||||||
|
int $videoCount,
|
||||||
|
int $fileCount
|
||||||
|
): void {
|
||||||
|
$this->db->exec("
|
||||||
|
INSERT INTO items
|
||||||
|
(scan_profile_id, abs_path, rel_path, display_name, structure, confidence, video_count, file_count, status, last_seen_at)
|
||||||
|
VALUES
|
||||||
|
(:pid, :abs, :rel, :name, :structure, :conf, :vcount, :fcount, 'active', NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
rel_path = VALUES(rel_path),
|
||||||
|
display_name = VALUES(display_name),
|
||||||
|
structure = VALUES(structure),
|
||||||
|
confidence = VALUES(confidence),
|
||||||
|
video_count = VALUES(video_count),
|
||||||
|
file_count = VALUES(file_count),
|
||||||
|
status = 'active',
|
||||||
|
last_seen_at = NOW(),
|
||||||
|
updated_at = NOW()
|
||||||
|
", [
|
||||||
|
':pid' => $profileId,
|
||||||
|
':abs' => $absPath,
|
||||||
|
':rel' => $relPath,
|
||||||
|
':name' => $displayName,
|
||||||
|
':structure' => $structure,
|
||||||
|
':conf' => $confidence,
|
||||||
|
':vcount' => $videoCount,
|
||||||
|
':fcount' => $fileCount,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function markGone(int $profileId, array $seenAbs): void {
|
||||||
|
// If nothing seen, do not mark everything gone (safety)
|
||||||
|
if (count($seenAbs) === 0) return;
|
||||||
|
|
||||||
|
// Mark items not seen in this scan as gone
|
||||||
|
// We do it by updating those with last_seen_at older than current scan moment.
|
||||||
|
// A simple approach: mark all as gone, then set active for seen list (but that’s heavy).
|
||||||
|
// Here: set gone for items where abs_path NOT IN (...) within this profile.
|
||||||
|
// For large lists, chunk it.
|
||||||
|
|
||||||
|
$chunkSize = 200;
|
||||||
|
$seenSet = array_values($seenAbs);
|
||||||
|
|
||||||
|
// First mark all as gone for this profile, then re-activate seen ones (fast and safe).
|
||||||
|
$this->db->exec("
|
||||||
|
UPDATE items
|
||||||
|
SET status='gone', updated_at=NOW()
|
||||||
|
WHERE scan_profile_id=:pid
|
||||||
|
", [':pid' => $profileId]);
|
||||||
|
|
||||||
|
for ($i = 0; $i < count($seenSet); $i += $chunkSize) {
|
||||||
|
$chunk = array_slice($seenSet, $i, $chunkSize);
|
||||||
|
|
||||||
|
$placeholders = [];
|
||||||
|
$params = [':pid' => $profileId];
|
||||||
|
foreach ($chunk as $idx => $abs) {
|
||||||
|
$k = ':p' . $idx;
|
||||||
|
$placeholders[] = $k;
|
||||||
|
$params[$k] = $abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
UPDATE items
|
||||||
|
SET status='active', updated_at=NOW()
|
||||||
|
WHERE scan_profile_id=:pid
|
||||||
|
AND abs_path IN (" . implode(',', $placeholders) . ")
|
||||||
|
";
|
||||||
|
|
||||||
|
$this->db->exec($sql, $params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isExcluded(string $name, array $patterns): bool {
|
||||||
|
$n = strtolower($name);
|
||||||
|
foreach ($patterns as $p) {
|
||||||
|
$p = trim((string)$p);
|
||||||
|
if ($p === '') continue;
|
||||||
|
if (str_contains($n, strtolower($p))) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normExt(array $ext): array {
|
||||||
|
$out = [];
|
||||||
|
foreach ($ext as $e) {
|
||||||
|
$e = strtolower(trim((string)$e));
|
||||||
|
if ($e === '') continue;
|
||||||
|
$out[] = $e;
|
||||||
|
}
|
||||||
|
return array_values(array_unique($out));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function toStringList($v): array {
|
||||||
|
if (!is_array($v)) return [];
|
||||||
|
$out = [];
|
||||||
|
foreach ($v as $x) {
|
||||||
|
$x = trim((string)$x);
|
||||||
|
if ($x !== '') $out[] = $x;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getHardLimits(): array {
|
||||||
|
// config safety rails (GUI cannot exceed)
|
||||||
|
$fromSettings = $this->settings->getAll()['safety'] ?? [];
|
||||||
|
$cfg = $this->settings->getConfig();
|
||||||
|
$limits = $fromSettings ?: ($cfg['safety']['hard_limits'] ?? []);
|
||||||
|
return [
|
||||||
|
'max_depth' => (int)($limits['max_depth'] ?? 10),
|
||||||
|
'max_files_per_item' => (int)($limits['max_files_per_item'] ?? 200000),
|
||||||
|
'max_items_per_scan' => (int)($limits['max_items_per_scan'] ?? 1000000),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cap(int $v, int $min, int $max): int {
|
||||||
|
if ($v < $min) return $min;
|
||||||
|
if ($v > $max) return $max;
|
||||||
|
return $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
177
app/services/SettingsService.php
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
<?php
|
||||||
|
// app/services/SettingsService.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Services;
|
||||||
|
|
||||||
|
use ScMedia\Db\Db;
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
final class SettingsService {
|
||||||
|
private Db $db;
|
||||||
|
private array $config;
|
||||||
|
|
||||||
|
public function __construct(Db $db, array $config) {
|
||||||
|
$this->db = $db;
|
||||||
|
$this->config = $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAll(): array {
|
||||||
|
$rows = $this->db->fetchAll("SELECT `key`, value_json FROM settings");
|
||||||
|
$map = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$k = (string)$r['key'];
|
||||||
|
$v = json_decode((string)$r['value_json'], true);
|
||||||
|
$map[$k] = is_array($v) ? $v : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$system = $map['_system'] ?? ['settings_revision' => 1, 'first_run_completed' => false];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'meta' => [
|
||||||
|
'app_id' => (string)($this->config['app']['app_id'] ?? ''),
|
||||||
|
'env' => (string)($this->config['app']['env'] ?? 'production'),
|
||||||
|
'debug_tools_enabled' => (bool)($this->config['app']['debug_tools_enabled'] ?? false),
|
||||||
|
'allow_db_reset' => (bool)($this->config['app']['allow_db_reset'] ?? false),
|
||||||
|
'settings_revision' => (int)($system['settings_revision'] ?? 1),
|
||||||
|
'backend_version' => (string)($this->config['app']['backend_version'] ?? ''),
|
||||||
|
'db_version' => (string)($this->config['app']['db_version'] ?? ''),
|
||||||
|
'plugins_version' => (string)($this->config['app']['plugins_version'] ?? ''),
|
||||||
|
],
|
||||||
|
'general' => $map['general'] ?? [],
|
||||||
|
'scanner_defaults' => $map['scanner_defaults'] ?? [],
|
||||||
|
'paths' => $map['paths'] ?? [],
|
||||||
|
'tools' => $map['tools'] ?? [],
|
||||||
|
'logs' => $map['logs'] ?? [],
|
||||||
|
'layout' => $map['layout'] ?? [],
|
||||||
|
'media_rules' => $map['media_rules'] ?? [],
|
||||||
|
'rules' => $map['rules'] ?? [],
|
||||||
|
'sources' => $map['sources'] ?? [],
|
||||||
|
'metadata' => $map['metadata'] ?? [],
|
||||||
|
'exports' => $map['exports'] ?? [],
|
||||||
|
'ui' => $map['ui'] ?? [],
|
||||||
|
'background' => $map['background'] ?? [],
|
||||||
|
'safety' => $map['safety'] ?? [],
|
||||||
|
'tasks' => $map['tasks'] ?? [],
|
||||||
|
'_system' => $system,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveBulk(array $payload): int {
|
||||||
|
$all = $this->getAll();
|
||||||
|
$rev = (int)($all['meta']['settings_revision'] ?? 1);
|
||||||
|
|
||||||
|
$if = isset($payload['if_revision']) ? (int)$payload['if_revision'] : null;
|
||||||
|
if ($if !== null && $if !== $rev) {
|
||||||
|
throw new Exception("Settings revision mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowed = ['general', 'scanner_defaults', 'paths', 'tools', 'logs', 'layout', 'media_rules', 'rules', 'sources', 'metadata', 'exports', 'ui', 'background', 'safety', 'tasks', 'pending_tasks'];
|
||||||
|
$toSave = [];
|
||||||
|
foreach ($allowed as $k) {
|
||||||
|
if (array_key_exists($k, $payload)) {
|
||||||
|
$toSave[$k] = $payload[$k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$newRev = $rev + 1;
|
||||||
|
$system = $all['_system'] ?? [];
|
||||||
|
$system['settings_revision'] = $newRev;
|
||||||
|
|
||||||
|
$this->db->begin();
|
||||||
|
try {
|
||||||
|
foreach ($toSave as $k => $v) {
|
||||||
|
$this->upsertJson($k, $v);
|
||||||
|
}
|
||||||
|
$this->upsertJson('_system', $system);
|
||||||
|
$this->db->commit();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->db->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $newRev;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getConfig(): array {
|
||||||
|
return $this->config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listSnapshots(int $limit = 50): array {
|
||||||
|
$limit = max(1, min(200, $limit));
|
||||||
|
return $this->db->fetchAll("
|
||||||
|
SELECT id, label, created_at, created_by
|
||||||
|
FROM settings_snapshots
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT {$limit}
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createSnapshot(?string $label = null, ?int $createdBy = null): int {
|
||||||
|
$all = $this->getAll();
|
||||||
|
unset($all['meta']);
|
||||||
|
$json = json_encode($all, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($json === false) {
|
||||||
|
$json = '{}';
|
||||||
|
}
|
||||||
|
$this->db->exec("
|
||||||
|
INSERT INTO settings_snapshots (label, data_json, created_by)
|
||||||
|
VALUES (:label, :data_json, :created_by)
|
||||||
|
", [
|
||||||
|
':label' => ($label !== '' ? $label : null),
|
||||||
|
':data_json' => $json,
|
||||||
|
':created_by' => $createdBy,
|
||||||
|
]);
|
||||||
|
$row = $this->db->fetchOne("SELECT LAST_INSERT_ID() AS id");
|
||||||
|
return (int)($row['id'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restoreSnapshot(int $id): void {
|
||||||
|
$row = $this->db->fetchOne("SELECT data_json FROM settings_snapshots WHERE id = :id", [':id' => $id]);
|
||||||
|
if (!$row || !isset($row['data_json'])) {
|
||||||
|
throw new Exception('Snapshot not found');
|
||||||
|
}
|
||||||
|
$data = json_decode((string)$row['data_json'], true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
throw new Exception('Snapshot data invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
$system = $this->getAll()['_system'] ?? [];
|
||||||
|
$system['settings_revision'] = (int)($system['settings_revision'] ?? 1) + 1;
|
||||||
|
$data['_system'] = $system;
|
||||||
|
|
||||||
|
$this->db->begin();
|
||||||
|
try {
|
||||||
|
$this->db->exec("DELETE FROM settings");
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
$this->upsertJson((string)$key, $value);
|
||||||
|
}
|
||||||
|
$this->db->commit();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->db->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteSnapshot(int $id): void {
|
||||||
|
$this->db->exec("DELETE FROM settings_snapshots WHERE id = :id", [':id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private function upsertJson(string $key, $value): void {
|
||||||
|
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($json === false) {
|
||||||
|
$json = '{}';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->exec("
|
||||||
|
INSERT INTO settings (`key`, value_json)
|
||||||
|
VALUES (:k, :v)
|
||||||
|
ON DUPLICATE KEY UPDATE value_json = VALUES(value_json)
|
||||||
|
", [
|
||||||
|
':k' => $key,
|
||||||
|
':v' => $json,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/services/ShellTool.php
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
// app/services/ShellTool.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Services;
|
||||||
|
|
||||||
|
final class ShellTool {
|
||||||
|
private string $envPrefix = 'LANG=C.UTF-8 LC_ALL=C.UTF-8 ';
|
||||||
|
|
||||||
|
public function wrap(string $cmd): string {
|
||||||
|
return $this->envPrefix . $cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exec(string $cmd): ?string {
|
||||||
|
return @shell_exec($this->wrap($cmd));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execTrim(string $cmd): ?string {
|
||||||
|
$out = $this->exec($cmd);
|
||||||
|
return $out === null ? null : trim($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function which(string $bin): ?string {
|
||||||
|
$out = $this->execTrim('command -v ' . escapeshellarg($bin) . ' 2>/dev/null');
|
||||||
|
return $out !== '' ? $out : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
163
app/services/SourcesService.php
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
// app/services/SourcesService.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Services;
|
||||||
|
|
||||||
|
final class SourcesService {
|
||||||
|
private TransmissionService $transmission;
|
||||||
|
private SettingsService $settings;
|
||||||
|
|
||||||
|
public function __construct(TransmissionService $transmission, SettingsService $settings) {
|
||||||
|
$this->transmission = $transmission;
|
||||||
|
$this->settings = $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function list(): array {
|
||||||
|
$items = $this->transmission->list();
|
||||||
|
return $this->applyFilters($items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyFilters(array $items): array {
|
||||||
|
$rules = $this->getSourceRules();
|
||||||
|
if (count($rules) === 0) return [];
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($items as $it) {
|
||||||
|
foreach ($rules as $r) {
|
||||||
|
if ($this->matchesRule($it, $r)) {
|
||||||
|
$out[] = $it;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getSourceRules(): array {
|
||||||
|
$all = $this->settings->getAll();
|
||||||
|
$rules = is_array($all['rules'] ?? null) ? $all['rules'] : [];
|
||||||
|
$out = [];
|
||||||
|
foreach ($rules as $r) {
|
||||||
|
if (!is_array($r)) continue;
|
||||||
|
if (!empty($r['enabled']) && ($r['type'] ?? '') === 'source_filter') {
|
||||||
|
$out[] = $r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function matchesRule(array $item, array $rule): bool {
|
||||||
|
$cfg = is_array($rule['config'] ?? null) ? $rule['config'] : [];
|
||||||
|
$source = (string)($cfg['source'] ?? '');
|
||||||
|
if ($source !== '' && (string)($item['source'] ?? '') !== $source) return false;
|
||||||
|
|
||||||
|
$conditions = is_array($cfg['conditions'] ?? null) ? $cfg['conditions'] : [];
|
||||||
|
if (count($conditions) === 0) {
|
||||||
|
$conditions = $this->buildLegacyConditions($cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($conditions) === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($conditions as $cond) {
|
||||||
|
if (!is_array($cond)) continue;
|
||||||
|
if (array_key_exists('enabled', $cond) && !$cond['enabled']) continue;
|
||||||
|
if (!$this->matchesCondition($item, $cond)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildLegacyConditions(array $cfg): array {
|
||||||
|
$conditions = [];
|
||||||
|
$statuses = [];
|
||||||
|
if (isset($cfg['statuses']) && is_array($cfg['statuses'])) {
|
||||||
|
$statuses = $cfg['statuses'];
|
||||||
|
} elseif (!empty($cfg['status'])) {
|
||||||
|
$statuses = [(string)$cfg['status']];
|
||||||
|
}
|
||||||
|
if (count($statuses) > 0) {
|
||||||
|
$conditions[] = ['field' => 'status', 'op' => 'in', 'value' => $statuses];
|
||||||
|
}
|
||||||
|
if (!empty($cfg['label'])) {
|
||||||
|
$conditions[] = ['field' => 'label', 'op' => 'contains', 'value' => (string)$cfg['label']];
|
||||||
|
}
|
||||||
|
if (!empty($cfg['name_regex'])) {
|
||||||
|
$conditions[] = ['field' => 'name_regex', 'op' => 'regex', 'value' => (string)$cfg['name_regex']];
|
||||||
|
}
|
||||||
|
if (!empty($cfg['path_regex'])) {
|
||||||
|
$conditions[] = ['field' => 'path_regex', 'op' => 'regex', 'value' => (string)$cfg['path_regex']];
|
||||||
|
}
|
||||||
|
if (!empty($cfg['min_size'])) {
|
||||||
|
$conditions[] = ['field' => 'min_size', 'op' => '>=', 'value' => (int)$cfg['min_size']];
|
||||||
|
}
|
||||||
|
return $conditions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function matchesCondition(array $item, array $cond): bool {
|
||||||
|
$field = (string)($cond['field'] ?? '');
|
||||||
|
$op = (string)($cond['op'] ?? '');
|
||||||
|
$value = $cond['value'] ?? null;
|
||||||
|
|
||||||
|
if ($field === 'status') {
|
||||||
|
$status = (string)($item['status'] ?? '');
|
||||||
|
$vals = is_array($value) ? $value : (is_string($value) ? [$value] : []);
|
||||||
|
if ($op === 'not_in') {
|
||||||
|
return !in_array($status, $vals, true);
|
||||||
|
}
|
||||||
|
if (count($vals) === 0) return true;
|
||||||
|
return in_array($status, $vals, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($field === 'label') {
|
||||||
|
$labels = is_array($item['labels'] ?? null) ? $item['labels'] : [];
|
||||||
|
$needle = (string)$value;
|
||||||
|
if ($needle === '') return true;
|
||||||
|
if ($op === 'not_contains') {
|
||||||
|
foreach ($labels as $l) {
|
||||||
|
if (stripos((string)$l, $needle) !== false) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($op === '=' || $op === '!=') {
|
||||||
|
$has = in_array($needle, $labels, true);
|
||||||
|
return $op === '!=' ? !$has : $has;
|
||||||
|
}
|
||||||
|
foreach ($labels as $l) {
|
||||||
|
if (stripos((string)$l, $needle) !== false) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($field === 'name_regex') {
|
||||||
|
$name = (string)($item['name'] ?? '');
|
||||||
|
if ($value === null || $value === '') return true;
|
||||||
|
$ok = @preg_match((string)$value, $name) === 1;
|
||||||
|
return ($op === 'not_regex') ? !$ok : $ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($field === 'path_regex') {
|
||||||
|
$path = (string)($item['path'] ?? '');
|
||||||
|
if ($value === null || $value === '') return true;
|
||||||
|
$ok = @preg_match((string)$value, $path) === 1;
|
||||||
|
return ($op === 'not_regex') ? !$ok : $ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($field === 'min_size') {
|
||||||
|
$size = (int)($item['size_bytes'] ?? 0);
|
||||||
|
$val = (int)($value ?? 0);
|
||||||
|
return match ($op) {
|
||||||
|
'>' => $size > $val,
|
||||||
|
'<' => $size < $val,
|
||||||
|
'<=' => $size <= $val,
|
||||||
|
'=' => $size === $val,
|
||||||
|
'!=' => $size !== $val,
|
||||||
|
default => $size >= $val,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
228
app/services/TransmissionService.php
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
<?php
|
||||||
|
// app/services/TransmissionService.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Services;
|
||||||
|
|
||||||
|
final class TransmissionService {
|
||||||
|
private HttpClient $http;
|
||||||
|
private SettingsService $settings;
|
||||||
|
private ?LogService $logger;
|
||||||
|
|
||||||
|
public function __construct(HttpClient $http, SettingsService $settings, ?LogService $logger = null) {
|
||||||
|
$this->http = $http;
|
||||||
|
$this->settings = $settings;
|
||||||
|
$this->logger = $logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function list(): array {
|
||||||
|
$cfg = $this->getConfig();
|
||||||
|
if (empty($cfg['enabled'])) return [];
|
||||||
|
$res = $this->rpcRequest($cfg, 'torrent-get', [
|
||||||
|
'fields' => ['id','name','totalSize','status','percentDone','downloadDir','labels','files'],
|
||||||
|
]);
|
||||||
|
if (!$res['ok'] || !is_array($res['data'])) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$rows = is_array($res['data']['arguments']['torrents'] ?? null) ? $res['data']['arguments']['torrents'] : [];
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $t) {
|
||||||
|
if (!is_array($t)) continue;
|
||||||
|
$files = is_array($t['files'] ?? null) ? $t['files'] : [];
|
||||||
|
$fileCount = count($files);
|
||||||
|
$contentType = ($fileCount > 1) ? 'folder' : 'file';
|
||||||
|
$out[] = [
|
||||||
|
'id' => (int)($t['id'] ?? 0),
|
||||||
|
'name' => (string)($t['name'] ?? ''),
|
||||||
|
'size_bytes' => (int)($t['totalSize'] ?? 0),
|
||||||
|
'status' => $this->statusLabel((int)($t['status'] ?? 0), (float)($t['percentDone'] ?? 0)),
|
||||||
|
'percent_done' => (float)($t['percentDone'] ?? 0),
|
||||||
|
'path' => (string)($t['downloadDir'] ?? ''),
|
||||||
|
'labels' => $t['labels'] ?? [],
|
||||||
|
'source' => 'transmission',
|
||||||
|
'file_count' => $fileCount,
|
||||||
|
'content_type' => $contentType,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test(array $cfg): array {
|
||||||
|
$cfg = $this->normalizeConfig($cfg);
|
||||||
|
$res = $this->rpcRequest($cfg, 'session-get', []);
|
||||||
|
if (!$res['ok']) {
|
||||||
|
$status = $res['status'] ?? null;
|
||||||
|
if ($status === 401) {
|
||||||
|
return ['ok' => false, 'error' => 'unauthorized'];
|
||||||
|
}
|
||||||
|
if ($status === 403) {
|
||||||
|
return ['ok' => false, 'error' => 'forbidden'];
|
||||||
|
}
|
||||||
|
return ['ok' => false, 'error' => $res['error'] ?? 'request failed'];
|
||||||
|
}
|
||||||
|
return ['ok' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function detail(int $id): array {
|
||||||
|
$cfg = $this->getConfig();
|
||||||
|
if (empty($cfg['enabled'])) {
|
||||||
|
return ['ok' => false, 'error' => 'disabled'];
|
||||||
|
}
|
||||||
|
$res = $this->rpcRequest($cfg, 'torrent-get', [
|
||||||
|
'ids' => [$id],
|
||||||
|
'fields' => [
|
||||||
|
'id','name','totalSize','status','percentDone','downloadDir','labels',
|
||||||
|
'rateDownload','rateUpload','addedDate','doneDate','eta','uploadedEver','downloadedEver',
|
||||||
|
'files','fileStats'
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
if (!$res['ok'] || !is_array($res['data'])) {
|
||||||
|
return ['ok' => false, 'error' => $res['error'] ?? 'rpc failed'];
|
||||||
|
}
|
||||||
|
$rows = is_array($res['data']['arguments']['torrents'] ?? null) ? $res['data']['arguments']['torrents'] : [];
|
||||||
|
if (count($rows) === 0) {
|
||||||
|
return ['ok' => false, 'error' => 'not found'];
|
||||||
|
}
|
||||||
|
$t = $rows[0];
|
||||||
|
$files = is_array($t['files'] ?? null) ? $t['files'] : [];
|
||||||
|
$stats = is_array($t['fileStats'] ?? null) ? $t['fileStats'] : [];
|
||||||
|
$fileList = [];
|
||||||
|
foreach ($files as $idx => $f) {
|
||||||
|
if (!is_array($f)) continue;
|
||||||
|
$stat = $stats[$idx] ?? [];
|
||||||
|
$length = (int)($f['length'] ?? 0);
|
||||||
|
$done = (int)($stat['bytesCompleted'] ?? 0);
|
||||||
|
$pct = $length > 0 ? round(($done / $length) * 100, 1) : 0.0;
|
||||||
|
$fileList[] = [
|
||||||
|
'path' => (string)($f['name'] ?? ''),
|
||||||
|
'size_bytes' => $length,
|
||||||
|
'bytes_done' => $done,
|
||||||
|
'percent_done' => $pct,
|
||||||
|
'wanted' => !empty($stat['wanted']),
|
||||||
|
'priority' => (int)($stat['priority'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$core = [
|
||||||
|
'id' => (int)($t['id'] ?? 0),
|
||||||
|
'name' => (string)($t['name'] ?? ''),
|
||||||
|
'status' => $this->statusLabel((int)($t['status'] ?? 0), (float)($t['percentDone'] ?? 0)),
|
||||||
|
'percent_done' => (float)($t['percentDone'] ?? 0),
|
||||||
|
'size_bytes' => (int)($t['totalSize'] ?? 0),
|
||||||
|
'source' => 'transmission',
|
||||||
|
];
|
||||||
|
|
||||||
|
$fields = [
|
||||||
|
['key' => 'download_dir', 'label' => 'Download dir', 'type' => 'text', 'value' => (string)($t['downloadDir'] ?? '')],
|
||||||
|
['key' => 'labels', 'label' => 'Labels', 'type' => 'list', 'value' => $t['labels'] ?? []],
|
||||||
|
['key' => 'rate_download', 'label' => 'Download speed', 'type' => 'speed', 'value' => (int)($t['rateDownload'] ?? 0)],
|
||||||
|
['key' => 'rate_upload', 'label' => 'Upload speed', 'type' => 'speed', 'value' => (int)($t['rateUpload'] ?? 0)],
|
||||||
|
['key' => 'added_date', 'label' => 'Added', 'type' => 'date', 'value' => (int)($t['addedDate'] ?? 0)],
|
||||||
|
['key' => 'done_date', 'label' => 'Completed', 'type' => 'date', 'value' => (int)($t['doneDate'] ?? 0)],
|
||||||
|
['key' => 'eta', 'label' => 'ETA', 'type' => 'seconds', 'value' => (int)($t['eta'] ?? 0)],
|
||||||
|
['key' => 'uploaded', 'label' => 'Uploaded', 'type' => 'bytes', 'value' => (int)($t['uploadedEver'] ?? 0)],
|
||||||
|
['key' => 'downloaded', 'label' => 'Downloaded', 'type' => 'bytes', 'value' => (int)($t['downloadedEver'] ?? 0)],
|
||||||
|
];
|
||||||
|
|
||||||
|
return ['ok' => true, 'core' => $core, 'fields' => $fields, 'files' => $fileList, 'raw' => $t];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getConfig(): array {
|
||||||
|
$all = $this->settings->getAll();
|
||||||
|
$sources = $all['sources'] ?? [];
|
||||||
|
$cfg = is_array($sources['transmission'] ?? null) ? $sources['transmission'] : [];
|
||||||
|
return $this->normalizeConfig($cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeConfig(array $cfg): array {
|
||||||
|
return [
|
||||||
|
'enabled' => !empty($cfg['enabled']),
|
||||||
|
'protocol' => (string)($cfg['protocol'] ?? 'http'),
|
||||||
|
'host' => (string)($cfg['host'] ?? ''),
|
||||||
|
'port' => (int)($cfg['port'] ?? 9091),
|
||||||
|
'path' => (string)($cfg['path'] ?? '/transmission/rpc'),
|
||||||
|
'username' => (string)($cfg['username'] ?? ''),
|
||||||
|
'password' => (string)($cfg['password'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rpcRequest(array $cfg, string $method, array $args): array {
|
||||||
|
$url = $this->buildUrl($cfg);
|
||||||
|
$this->logger?->log('debug', 'Transmission config', [
|
||||||
|
'protocol' => $cfg['protocol'] ?? null,
|
||||||
|
'host' => $cfg['host'] ?? null,
|
||||||
|
'port' => $cfg['port'] ?? null,
|
||||||
|
'path' => $cfg['path'] ?? null,
|
||||||
|
'has_auth' => (($cfg['username'] ?? '') !== '' || ($cfg['password'] ?? '') !== ''),
|
||||||
|
]);
|
||||||
|
$headers = $this->buildHeaders($cfg, null);
|
||||||
|
$payload = ['method' => $method, 'arguments' => $args];
|
||||||
|
|
||||||
|
$this->logger?->log('debug', 'Transmission RPC request', [
|
||||||
|
'method' => $method,
|
||||||
|
'url' => $this->sanitizeUrl($url),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$res = $this->http->postJson($url, $payload, $headers);
|
||||||
|
if (($res['status'] ?? 0) === 409) {
|
||||||
|
$sid = (string)($res['headers']['x-transmission-session-id'] ?? '');
|
||||||
|
$this->logger?->log('debug', 'Transmission session id required', ['has_sid' => $sid !== '']);
|
||||||
|
if ($sid !== '') {
|
||||||
|
$headers = $this->buildHeaders($cfg, $sid);
|
||||||
|
$res = $this->http->postJson($url, $payload, $headers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($res['ok']) || !is_array($res['data'])) {
|
||||||
|
$this->logger?->log('warn', 'Transmission RPC failed', [
|
||||||
|
'method' => $method,
|
||||||
|
'status' => $res['status'] ?? null,
|
||||||
|
'error' => $res['error'] ?? null,
|
||||||
|
]);
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'error' => $res['error'] ?? 'rpc failed',
|
||||||
|
'status' => $res['status'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($res['data']['result']) && $res['data']['result'] !== 'success') {
|
||||||
|
$this->logger?->log('warn', 'Transmission RPC result not success', ['result' => $res['data']['result'] ?? null]);
|
||||||
|
}
|
||||||
|
return ['ok' => true, 'data' => $res['data']];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildUrl(array $cfg): string {
|
||||||
|
$host = $cfg['host'] !== '' ? $cfg['host'] : 'localhost';
|
||||||
|
$path = $cfg['path'] !== '' ? $cfg['path'] : '/transmission/rpc';
|
||||||
|
if ($path[0] !== '/') $path = '/' . $path;
|
||||||
|
return $cfg['protocol'] . '://' . $host . ':' . $cfg['port'] . $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildHeaders(array $cfg, ?string $sessionId): array {
|
||||||
|
$headers = ['Content-Type: application/json'];
|
||||||
|
if ($sessionId) {
|
||||||
|
$headers[] = 'X-Transmission-Session-Id: ' . $sessionId;
|
||||||
|
}
|
||||||
|
if ($cfg['username'] !== '' || $cfg['password'] !== '') {
|
||||||
|
$basic = base64_encode($cfg['username'] . ':' . $cfg['password']);
|
||||||
|
$headers[] = 'Authorization: Basic ' . $basic;
|
||||||
|
}
|
||||||
|
return $headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function statusLabel(int $status, float $percent): string {
|
||||||
|
if ($percent >= 1.0) return 'completed';
|
||||||
|
if ($status === 1 || $status === 2) return 'checking';
|
||||||
|
if ($status === 3) return 'queued';
|
||||||
|
if ($status === 4) return 'downloading';
|
||||||
|
if ($status === 6) return 'seeding';
|
||||||
|
if ($status === 0) return 'stopped';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sanitizeUrl(string $url): string {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/services/export/ExporterInterface.php
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
// app/services/export/ExporterInterface.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Services\Export;
|
||||||
|
|
||||||
|
interface ExporterInterface {
|
||||||
|
public function id(): string;
|
||||||
|
public function label(): string;
|
||||||
|
public function supportsKind(string $kind): bool;
|
||||||
|
public function exportMovie(string $filePath, array $meta): array;
|
||||||
|
public function exportSeries(string $seriesPath, array $meta): array;
|
||||||
|
}
|
||||||
57
app/services/export/JellyfinExporter.php
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
// app/services/export/JellyfinExporter.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Services\Export;
|
||||||
|
|
||||||
|
final class JellyfinExporter implements ExporterInterface {
|
||||||
|
public function id(): string {
|
||||||
|
return 'jellyfin';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string {
|
||||||
|
return 'Jellyfin';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supportsKind(string $kind): bool {
|
||||||
|
return in_array($kind, ['movie','series'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportMovie(string $filePath, array $meta): array {
|
||||||
|
$dir = dirname($filePath);
|
||||||
|
$outPath = $dir . '/movie.nfo';
|
||||||
|
return $this->writeNfo($outPath, 'movie', $meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportSeries(string $seriesPath, array $meta): array {
|
||||||
|
$outPath = rtrim($seriesPath, '/') . '/tvshow.nfo';
|
||||||
|
return $this->writeNfo($outPath, 'tvshow', $meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeNfo(string $path, string $root, array $meta): array {
|
||||||
|
$title = (string)($meta['title'] ?? '');
|
||||||
|
$original = (string)($meta['original_title'] ?? '');
|
||||||
|
$year = $meta['year'] ?? null;
|
||||||
|
$provider = (string)($meta['provider'] ?? '');
|
||||||
|
$providerId = (string)($meta['provider_id'] ?? '');
|
||||||
|
$providerUrl = (string)($meta['provider_url'] ?? '');
|
||||||
|
|
||||||
|
$xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
|
||||||
|
$xml .= "<{$root}>\n";
|
||||||
|
if ($title !== '') $xml .= " <title>" . htmlspecialchars($title, ENT_QUOTES) . "</title>\n";
|
||||||
|
if ($original !== '') $xml .= " <originaltitle>" . htmlspecialchars($original, ENT_QUOTES) . "</originaltitle>\n";
|
||||||
|
if (is_int($year) || is_numeric($year)) $xml .= " <year>" . (int)$year . "</year>\n";
|
||||||
|
if ($provider !== '' && $providerId !== '') {
|
||||||
|
$xml .= " <uniqueid type=\"" . htmlspecialchars($provider, ENT_QUOTES) . "\" default=\"true\">" .
|
||||||
|
htmlspecialchars($providerId, ENT_QUOTES) . "</uniqueid>\n";
|
||||||
|
}
|
||||||
|
if ($providerUrl !== '') {
|
||||||
|
$xml .= " <website>" . htmlspecialchars($providerUrl, ENT_QUOTES) . "</website>\n";
|
||||||
|
}
|
||||||
|
$xml .= "</{$root}>\n";
|
||||||
|
|
||||||
|
$ok = @file_put_contents($path, $xml) !== false;
|
||||||
|
return ['ok' => $ok, 'path' => $path, 'error' => $ok ? null : 'write failed'];
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/services/export/KodiExporter.php
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
// app/services/export/KodiExporter.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Services\Export;
|
||||||
|
|
||||||
|
final class KodiExporter implements ExporterInterface {
|
||||||
|
public function id(): string {
|
||||||
|
return 'kodi';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string {
|
||||||
|
return 'Kodi';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supportsKind(string $kind): bool {
|
||||||
|
return in_array($kind, ['movie','series'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportMovie(string $filePath, array $meta): array {
|
||||||
|
$dir = dirname($filePath);
|
||||||
|
$outPath = $dir . '/movie.nfo';
|
||||||
|
return $this->writeNfo($outPath, 'movie', $meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportSeries(string $seriesPath, array $meta): array {
|
||||||
|
$outPath = rtrim($seriesPath, '/') . '/tvshow.nfo';
|
||||||
|
return $this->writeNfo($outPath, 'tvshow', $meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeNfo(string $path, string $root, array $meta): array {
|
||||||
|
$title = (string)($meta['title'] ?? '');
|
||||||
|
$original = (string)($meta['original_title'] ?? '');
|
||||||
|
$year = $meta['year'] ?? null;
|
||||||
|
$provider = (string)($meta['provider'] ?? '');
|
||||||
|
$providerId = (string)($meta['provider_id'] ?? '');
|
||||||
|
$providerUrl = (string)($meta['provider_url'] ?? '');
|
||||||
|
|
||||||
|
$xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
|
||||||
|
$xml .= "<{$root}>\n";
|
||||||
|
if ($title !== '') $xml .= " <title>" . htmlspecialchars($title, ENT_QUOTES) . "</title>\n";
|
||||||
|
if ($original !== '') $xml .= " <originaltitle>" . htmlspecialchars($original, ENT_QUOTES) . "</originaltitle>\n";
|
||||||
|
if (is_int($year) || is_numeric($year)) $xml .= " <year>" . (int)$year . "</year>\n";
|
||||||
|
if ($provider !== '' && $providerId !== '') {
|
||||||
|
$xml .= " <uniqueid type=\"" . htmlspecialchars($provider, ENT_QUOTES) . "\" default=\"true\">" .
|
||||||
|
htmlspecialchars($providerId, ENT_QUOTES) . "</uniqueid>\n";
|
||||||
|
}
|
||||||
|
if ($providerUrl !== '') {
|
||||||
|
$xml .= " <website>" . htmlspecialchars($providerUrl, ENT_QUOTES) . "</website>\n";
|
||||||
|
}
|
||||||
|
$xml .= "</{$root}>\n";
|
||||||
|
|
||||||
|
$ok = @file_put_contents($path, $xml) !== false;
|
||||||
|
return ['ok' => $ok, 'path' => $path, 'error' => $ok ? null : 'write failed'];
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/services/metadata/MetadataProvider.php
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
// app/services/metadata/MetadataProvider.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Services\Metadata;
|
||||||
|
|
||||||
|
interface MetadataProvider {
|
||||||
|
public function id(): string;
|
||||||
|
public function label(): string;
|
||||||
|
public function search(string $query, string $type, ?int $year, string $lang, array $config): array;
|
||||||
|
public function buildUrl(string $externalId): string;
|
||||||
|
public function supportsType(string $type): bool;
|
||||||
|
}
|
||||||
101
app/services/metadata/OmdbProvider.php
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
// app/services/metadata/OmdbProvider.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Services\Metadata;
|
||||||
|
|
||||||
|
use ScMedia\Services\HttpClient;
|
||||||
|
use ScMedia\Services\LogService;
|
||||||
|
|
||||||
|
final class OmdbProvider implements MetadataProvider {
|
||||||
|
private HttpClient $http;
|
||||||
|
private ?LogService $logger;
|
||||||
|
|
||||||
|
public function __construct(HttpClient $http, ?LogService $logger = null) {
|
||||||
|
$this->http = $http;
|
||||||
|
$this->logger = $logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function id(): string {
|
||||||
|
return 'omdb';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string {
|
||||||
|
return 'IMDb (OMDb)';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supportsType(string $type): bool {
|
||||||
|
return in_array($type, ['movie','series'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildUrl(string $externalId): string {
|
||||||
|
return $externalId !== '' ? ('https://www.imdb.com/title/' . $externalId) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function search(string $query, string $type, ?int $year, string $lang, array $config): array {
|
||||||
|
$apiKey = (string)($config['api_key'] ?? '');
|
||||||
|
if ($apiKey === '') {
|
||||||
|
$this->logger?->log('warn', 'OMDb API key missing');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$base = (string)($config['base_url'] ?? 'https://www.omdbapi.com/');
|
||||||
|
$q = [
|
||||||
|
'apikey' => $apiKey,
|
||||||
|
's' => $query,
|
||||||
|
];
|
||||||
|
if ($type !== '') {
|
||||||
|
$q['type'] = $type;
|
||||||
|
}
|
||||||
|
if ($year !== null && $year > 1800 && $year < 2100) {
|
||||||
|
$q['y'] = (string)$year;
|
||||||
|
}
|
||||||
|
$url = rtrim($base, '/') . '/?' . http_build_query($q);
|
||||||
|
$this->logger?->log('debug', 'OMDb request', ['url' => $this->sanitizeUrl($url)]);
|
||||||
|
$res = $this->http->getJson($url);
|
||||||
|
if (empty($res['ok']) || !is_array($res['data'])) {
|
||||||
|
$this->logger?->log('error', 'OMDb request failed', [
|
||||||
|
'status' => $res['status'] ?? null,
|
||||||
|
'error' => $res['error'] ?? null,
|
||||||
|
]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$data = $res['data'];
|
||||||
|
if (!empty($data['Response']) && $data['Response'] === 'False') {
|
||||||
|
$this->logger?->log('warn', 'OMDb response false', ['error' => $data['Error'] ?? null]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$items = is_array($data['Search'] ?? null) ? $data['Search'] : [];
|
||||||
|
$out = [];
|
||||||
|
foreach ($items as $it) {
|
||||||
|
if (!is_array($it)) continue;
|
||||||
|
$id = (string)($it['imdbID'] ?? '');
|
||||||
|
$title = (string)($it['Title'] ?? '');
|
||||||
|
$yearStr = (string)($it['Year'] ?? '');
|
||||||
|
$yr = null;
|
||||||
|
if (preg_match('/\d{4}/', $yearStr, $m)) {
|
||||||
|
$yr = (int)$m[0];
|
||||||
|
}
|
||||||
|
$poster = (string)($it['Poster'] ?? '');
|
||||||
|
if ($poster === 'N/A') $poster = '';
|
||||||
|
$out[] = [
|
||||||
|
'provider' => $this->id(),
|
||||||
|
'provider_name' => $this->label(),
|
||||||
|
'provider_id' => $id,
|
||||||
|
'provider_url' => $this->buildUrl($id),
|
||||||
|
'title_map' => [$lang => $title],
|
||||||
|
'original_title' => $title,
|
||||||
|
'year' => $yr,
|
||||||
|
'poster' => $poster,
|
||||||
|
'type' => (string)($it['Type'] ?? $type),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sanitizeUrl(string $url): string {
|
||||||
|
$debugKeys = filter_var(getenv('DEBUG_KEYS') ?: 'false', FILTER_VALIDATE_BOOLEAN);
|
||||||
|
if ($debugKeys) return $url;
|
||||||
|
return preg_replace('/apikey=[^&]+/i', 'apikey=DUMMY', $url) ?? $url;
|
||||||
|
}
|
||||||
|
}
|
||||||
127
app/services/metadata/TvdbProvider.php
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
// app/services/metadata/TvdbProvider.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ScMedia\Services\Metadata;
|
||||||
|
|
||||||
|
use ScMedia\Services\HttpClient;
|
||||||
|
use ScMedia\Services\LogService;
|
||||||
|
|
||||||
|
final class TvdbProvider implements MetadataProvider {
|
||||||
|
private HttpClient $http;
|
||||||
|
private ?LogService $logger;
|
||||||
|
|
||||||
|
public function __construct(HttpClient $http, ?LogService $logger = null) {
|
||||||
|
$this->http = $http;
|
||||||
|
$this->logger = $logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function id(): string {
|
||||||
|
return 'tvdb';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string {
|
||||||
|
return 'TVDB';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supportsType(string $type): bool {
|
||||||
|
return in_array($type, ['movie','series'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildUrl(string $externalId): string {
|
||||||
|
return $externalId !== '' ? ('https://thetvdb.com/?tab=series&id=' . $externalId) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function search(string $query, string $type, ?int $year, string $lang, array $config): array {
|
||||||
|
$apiKey = (string)($config['api_key'] ?? '');
|
||||||
|
if ($apiKey === '') {
|
||||||
|
$this->logger?->log('warn', 'TVDB API key missing');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$token = $this->login($apiKey, (string)($config['pin'] ?? ''));
|
||||||
|
if ($token === '') {
|
||||||
|
$this->logger?->log('warn', 'TVDB login failed');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$q = [
|
||||||
|
'query' => $query,
|
||||||
|
'type' => ($type === 'movie') ? 'movie' : 'series',
|
||||||
|
];
|
||||||
|
if ($year !== null && $year > 1800 && $year < 2100) {
|
||||||
|
$q['year'] = (string)$year;
|
||||||
|
}
|
||||||
|
$url = 'https://api4.thetvdb.com/v4/search?' . http_build_query($q);
|
||||||
|
$this->logger?->log('debug', 'TVDB search request', ['url' => $this->sanitizeUrl($url)]);
|
||||||
|
$headers = [
|
||||||
|
'Authorization: Bearer ' . ($this->shouldLogKeys() ? $token : 'DUMMY'),
|
||||||
|
'Accept-Language: ' . $lang,
|
||||||
|
];
|
||||||
|
$res = $this->http->getJson($url, $headers);
|
||||||
|
if (empty($res['ok']) || !is_array($res['data'])) {
|
||||||
|
$this->logger?->log('error', 'TVDB search failed', [
|
||||||
|
'status' => $res['status'] ?? null,
|
||||||
|
'error' => $res['error'] ?? null,
|
||||||
|
]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$items = is_array($res['data']['data'] ?? null) ? $res['data']['data'] : [];
|
||||||
|
$out = [];
|
||||||
|
foreach ($items as $it) {
|
||||||
|
if (!is_array($it)) continue;
|
||||||
|
$id = (string)($it['tvdb_id'] ?? $it['id'] ?? '');
|
||||||
|
$title = (string)($it['name'] ?? $it['title'] ?? '');
|
||||||
|
$yr = null;
|
||||||
|
if (isset($it['year']) && is_numeric($it['year'])) {
|
||||||
|
$yr = (int)$it['year'];
|
||||||
|
} else if (isset($it['firstAired']) && preg_match('/\d{4}/', (string)$it['firstAired'], $m)) {
|
||||||
|
$yr = (int)$m[0];
|
||||||
|
}
|
||||||
|
$image = (string)($it['image_url'] ?? $it['image'] ?? '');
|
||||||
|
$out[] = [
|
||||||
|
'provider' => $this->id(),
|
||||||
|
'provider_name' => $this->label(),
|
||||||
|
'provider_id' => $id,
|
||||||
|
'provider_url' => $this->buildUrl($id),
|
||||||
|
'title_map' => [$lang => $title],
|
||||||
|
'original_title' => $title,
|
||||||
|
'year' => $yr,
|
||||||
|
'poster' => $image,
|
||||||
|
'type' => (string)($it['type'] ?? $type),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function login(string $apiKey, string $pin): string {
|
||||||
|
$payload = ['apikey' => $apiKey];
|
||||||
|
if ($pin !== '') $payload['pin'] = $pin;
|
||||||
|
if ($this->logger) {
|
||||||
|
$safe = $payload;
|
||||||
|
if (!$this->shouldLogKeys()) {
|
||||||
|
$safe['apikey'] = 'DUMMY';
|
||||||
|
if (isset($safe['pin'])) $safe['pin'] = 'DUMMY';
|
||||||
|
}
|
||||||
|
$this->logger->log('debug', 'TVDB login request', ['payload' => $safe]);
|
||||||
|
}
|
||||||
|
$res = $this->http->postJson('https://api4.thetvdb.com/v4/login', $payload);
|
||||||
|
if (empty($res['ok']) || !is_array($res['data'])) {
|
||||||
|
$this->logger?->log('error', 'TVDB login request failed', [
|
||||||
|
'status' => $res['status'] ?? null,
|
||||||
|
'error' => $res['error'] ?? null,
|
||||||
|
]);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$token = (string)($res['data']['data']['token'] ?? '');
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldLogKeys(): bool {
|
||||||
|
return filter_var(getenv('DEBUG_KEYS') ?: 'false', FILTER_VALIDATE_BOOLEAN);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sanitizeUrl(string $url): string {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
}
|
||||||
119
app/views/pages/account.php
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
// app/views/pages/account.php
|
||||||
|
|
||||||
|
$centerTitle = $t('account.title', 'Account');
|
||||||
|
$centerI18n = 'account.title';
|
||||||
|
$statusHidden = true;
|
||||||
|
$showQueueSummary = true;
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title data-i18n="account.page_title"><?php echo htmlspecialchars($t('account.page_title', 'scMedia / Account'), ENT_QUOTES); ?></title>
|
||||||
|
<link rel="stylesheet" href="/assets/css/common.css">
|
||||||
|
<link rel="stylesheet" href="/assets/css/settings.css">
|
||||||
|
<link rel="stylesheet" href="/assets/css/account.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<?php require __DIR__ . '/../partials/header.php'; ?>
|
||||||
|
|
||||||
|
<main class="account-layout">
|
||||||
|
<section class="card">
|
||||||
|
<div class="pane-header">
|
||||||
|
<h2 data-i18n="account.profile"><?php echo htmlspecialchars($t('account.profile', 'Profile'), ENT_QUOTES); ?></h2>
|
||||||
|
</div>
|
||||||
|
<div class="account-profile">
|
||||||
|
<div class="avatar-block">
|
||||||
|
<img id="accountAvatar" class="avatar-img" src="/assets/icons/scmedia.png" alt="Avatar">
|
||||||
|
<input id="avatarFile" type="file" accept="image/*">
|
||||||
|
<button id="btnAvatarUpload" class="btn" type="button" data-i18n="account.avatar.upload"><?php echo htmlspecialchars($t('account.avatar.upload', 'Upload'), ENT_QUOTES); ?></button>
|
||||||
|
<div class="hint" data-i18n="account.avatar.hint"><?php echo htmlspecialchars($t('account.avatar.hint', 'Max 5MB'), ENT_QUOTES); ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="profile-fields">
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="account.nickname"><?php echo htmlspecialchars($t('account.nickname', 'Nickname'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="accountNickname" type="text" placeholder="<?php echo htmlspecialchars($t('account.nickname.ph', 'Your name'), ENT_QUOTES); ?>">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="account.email"><?php echo htmlspecialchars($t('account.email', 'Email'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="accountEmail" type="email">
|
||||||
|
</label>
|
||||||
|
<button id="btnAccountSave" class="btn primary" type="button" data-i18n="common.save"><?php echo htmlspecialchars($t('common.save', 'Save'), ENT_QUOTES); ?></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="pane-header">
|
||||||
|
<h2 data-i18n="account.interface"><?php echo htmlspecialchars($t('account.interface', 'Interface'), ENT_QUOTES); ?></h2>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.language"><?php echo htmlspecialchars($t('settings.language', 'Language'), ENT_QUOTES); ?></div>
|
||||||
|
<select id="accountLanguage">
|
||||||
|
<option value="ru">RU</option>
|
||||||
|
<option value="en">EN</option>
|
||||||
|
<option value="de">DE</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="account.theme"><?php echo htmlspecialchars($t('account.theme', 'Theme'), ENT_QUOTES); ?></div>
|
||||||
|
<select id="accountTheme">
|
||||||
|
<option value="dark" data-i18n="theme.dark"><?php echo htmlspecialchars($t('theme.dark', 'Dark'), ENT_QUOTES); ?></option>
|
||||||
|
<option value="light" data-i18n="theme.light"><?php echo htmlspecialchars($t('theme.light', 'Light'), ENT_QUOTES); ?></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.ui.table_mode"><?php echo htmlspecialchars($t('settings.ui.table_mode', 'Table navigation'), ENT_QUOTES); ?></div>
|
||||||
|
<select id="accountTableMode">
|
||||||
|
<option value="pagination" data-i18n="settings.ui.table_mode_pages"><?php echo htmlspecialchars($t('settings.ui.table_mode_pages', 'Pages'), ENT_QUOTES); ?></option>
|
||||||
|
<option value="infinite" data-i18n="settings.ui.table_mode_infinite"><?php echo htmlspecialchars($t('settings.ui.table_mode_infinite', 'Infinite scroll'), ENT_QUOTES); ?></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.ui.table_page_size"><?php echo htmlspecialchars($t('settings.ui.table_page_size', 'Rows per page'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="accountTableSize" type="number" min="5" max="500">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="pane-header">
|
||||||
|
<h2 data-i18n="account.security"><?php echo htmlspecialchars($t('account.security', 'Security'), ENT_QUOTES); ?></h2>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="account.password.current"><?php echo htmlspecialchars($t('account.password.current', 'Current password'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="accountPasswordCurrent" type="password">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="account.password.new"><?php echo htmlspecialchars($t('account.password.new', 'New password'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="accountPasswordNew" type="password">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="account.password.confirm"><?php echo htmlspecialchars($t('account.password.confirm', 'Confirm password'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="accountPasswordConfirm" type="password">
|
||||||
|
</label>
|
||||||
|
<div class="row">
|
||||||
|
<button id="btnPasswordChange" class="btn" type="button" data-i18n="account.password.change"><?php echo htmlspecialchars($t('account.password.change', 'Change password'), ENT_QUOTES); ?></button>
|
||||||
|
<span class="hint" id="accountPasswordHint"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.I18N = <?php echo json_encode($dict, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>;
|
||||||
|
window.APP_LANG = <?php echo json_encode($lang, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>;
|
||||||
|
window.DEBUG_TOOLS_ENABLED = <?php echo !empty($debugToolsEnabled) ? 'true' : 'false'; ?>;
|
||||||
|
</script>
|
||||||
|
<script src="/assets/js/ui.js"></script>
|
||||||
|
<script src="/assets/js/api.js"></script>
|
||||||
|
<script src="/assets/js/prefs.js"></script>
|
||||||
|
<script src="/assets/js/auth.js"></script>
|
||||||
|
<script src="/assets/js/sse.js"></script>
|
||||||
|
<script src="/assets/js/account.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
159
app/views/pages/index.php
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
// app/views/pages/index.php
|
||||||
|
|
||||||
|
$toolbarLeftHtml = '';
|
||||||
|
$toolbarRightHtml = '';
|
||||||
|
$showQueueSummary = true;
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title data-i18n="app.title"><?php echo htmlspecialchars($t('app.title', 'scMedia'), ENT_QUOTES); ?></title>
|
||||||
|
<link rel="stylesheet" href="/assets/css/common.css">
|
||||||
|
<link rel="stylesheet" href="/assets/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<?php
|
||||||
|
require __DIR__ . '/../partials/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<!-- ================= MAIN GRID ================= -->
|
||||||
|
<main class="main">
|
||||||
|
<div class="media-tabs" id="media-tabs">
|
||||||
|
<button class="tab active" data-tab="movies" data-i18n="media.tabs.movies"><?php echo htmlspecialchars($t('media.tabs.movies', 'Movies'), ENT_QUOTES); ?></button>
|
||||||
|
<button class="tab" data-tab="series" data-i18n="media.tabs.series"><?php echo htmlspecialchars($t('media.tabs.series', 'Series'), ENT_QUOTES); ?></button>
|
||||||
|
<button class="tab" data-tab="sources" data-i18n="media.tabs.sources"><?php echo htmlspecialchars($t('media.tabs.sources', 'Sources'), ENT_QUOTES); ?></button>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
$id = 'grid';
|
||||||
|
$tableClass = 'grid data-table';
|
||||||
|
$wrapClass = 'media-table';
|
||||||
|
$headers = [
|
||||||
|
['label' => '', 'i18n' => '', 'key' => '', 'filter' => null],
|
||||||
|
['label' => $t('grid.type', 'Type'), 'i18n' => 'grid.type', 'key' => 'type', 'filter' => ['type' => 'text', 'ops' => ['like','eq']]],
|
||||||
|
['label' => $t('grid.name', 'Name'), 'i18n' => 'grid.name', 'sort' => 'name', 'key' => 'name', 'filter' => ['type' => 'text', 'ops' => ['like','eq','empty']]],
|
||||||
|
['label' => $t('grid.year', 'Year'), 'i18n' => 'grid.year', 'key' => 'year', 'filter' => ['type' => 'number', 'ops' => ['eq','between','gt','lt']]],
|
||||||
|
['label' => $t('grid.status', 'Status'), 'i18n' => 'grid.status', 'key' => 'status', 'filter' => ['type' => 'text', 'ops' => ['like','eq']]],
|
||||||
|
['label' => $t('grid.issues', 'Issues'), 'i18n' => 'grid.issues', 'key' => 'issues', 'filter' => ['type' => 'text', 'ops' => ['like','eq','empty']]],
|
||||||
|
];
|
||||||
|
$attrs = ['data-table' => 'media'];
|
||||||
|
require __DIR__ . '/../partials/table.php';
|
||||||
|
?>
|
||||||
|
<?php
|
||||||
|
$id = 'sources-grid';
|
||||||
|
$tableClass = 'grid data-table';
|
||||||
|
$wrapClass = 'media-table is-hidden';
|
||||||
|
$headers = [
|
||||||
|
['label' => $t('sources.source', 'Source'), 'i18n' => 'sources.source', 'key' => 'source', 'filter' => ['type' => 'select', 'control' => 'select', 'options' => [
|
||||||
|
['value' => 'transmission', 'label' => 'Transmission'],
|
||||||
|
]]],
|
||||||
|
['label' => $t('sources.type', 'Type'), 'i18n' => 'sources.type', 'key' => 'type', 'filter' => ['type' => 'select', 'control' => 'select', 'options' => [
|
||||||
|
['value' => 'folder', 'label' => $t('sources.type.folder', 'Folder'), 'i18n' => 'sources.type.folder'],
|
||||||
|
['value' => 'file', 'label' => $t('sources.type.file', 'File'), 'i18n' => 'sources.type.file'],
|
||||||
|
]]],
|
||||||
|
['label' => $t('sources.name', 'Name'), 'i18n' => 'sources.name', 'sort' => 'name', 'key' => 'name', 'filter' => ['type' => 'text', 'ops' => ['like','eq','empty']]],
|
||||||
|
['label' => $t('sources.size', 'Size'), 'i18n' => 'sources.size', 'sort' => 'size', 'class' => 'align-right', 'key' => 'size', 'filter' => null],
|
||||||
|
['label' => $t('sources.status', 'Status'), 'i18n' => 'sources.status', 'sort' => 'status', 'class' => 'align-center', 'key' => 'status', 'filter' => ['type' => 'select', 'control' => 'select', 'options' => [
|
||||||
|
['value' => 'checking', 'label' => 'checking', 'i18n' => 'sources.status.checking'],
|
||||||
|
['value' => 'queued', 'label' => 'queued', 'i18n' => 'sources.status.queued'],
|
||||||
|
['value' => 'downloading', 'label' => $t('sources.status.downloading', 'downloading'), 'i18n' => 'sources.status.downloading'],
|
||||||
|
['value' => 'seeding', 'label' => $t('sources.status.seeding', 'seeding'), 'i18n' => 'sources.status.seeding'],
|
||||||
|
['value' => 'stopped', 'label' => $t('sources.status.stopped', 'stopped'), 'i18n' => 'sources.status.stopped'],
|
||||||
|
['value' => 'completed', 'label' => $t('sources.status.completed', 'completed'), 'i18n' => 'sources.status.completed'],
|
||||||
|
['value' => 'unknown', 'label' => $t('sources.status.unknown', 'unknown'), 'i18n' => 'sources.status.unknown'],
|
||||||
|
]]],
|
||||||
|
['label' => $t('sources.progress', 'Progress'), 'i18n' => 'sources.progress', 'class' => 'align-right', 'key' => 'progress', 'filter' => null],
|
||||||
|
];
|
||||||
|
$attrs = ['data-table' => 'sources', 'data-view-config' => '1'];
|
||||||
|
require __DIR__ . '/../partials/table.php';
|
||||||
|
?>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- ================= JOB DIALOG ================= -->
|
||||||
|
<dialog id="dlg-job">
|
||||||
|
<form method="dialog" class="dialog">
|
||||||
|
<h3 data-i18n="job.title"><?php echo htmlspecialchars($t('job.title', 'Job'), ENT_QUOTES); ?></h3>
|
||||||
|
<div id="job-status"></div>
|
||||||
|
<progress id="job-progress" value="0" max="100"></progress>
|
||||||
|
<pre id="job-log" class="mono"></pre>
|
||||||
|
<menu class="menu">
|
||||||
|
<button id="btnJobCancel" type="button" class="btn" data-i18n="common.cancel"><?php echo htmlspecialchars($t('common.cancel', 'Cancel'), ENT_QUOTES); ?></button>
|
||||||
|
<button value="cancel" data-i18n="common.close"><?php echo htmlspecialchars($t('common.close', 'Close'), ENT_QUOTES); ?></button>
|
||||||
|
</menu>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="dlg-source" data-source-dialog>
|
||||||
|
<form method="dialog" class="dialog">
|
||||||
|
<div class="dialog-head">
|
||||||
|
<h3 id="sourceTitle" data-i18n="sources.detail.title"><?php echo htmlspecialchars($t('sources.detail.title', 'Source detail'), ENT_QUOTES); ?></h3>
|
||||||
|
<button type="button" class="btn" id="btnSourceClose" data-i18n="common.close"><?php echo htmlspecialchars($t('common.close', 'Close'), ENT_QUOTES); ?></button>
|
||||||
|
</div>
|
||||||
|
<div id="sourceMeta" class="source-meta"></div>
|
||||||
|
<div id="sourceFields" class="source-fields"></div>
|
||||||
|
<details id="sourceRawWrap" class="source-raw">
|
||||||
|
<summary data-i18n="sources.detail.raw"><?php echo htmlspecialchars($t('sources.detail.raw', 'Raw data'), ENT_QUOTES); ?></summary>
|
||||||
|
<pre id="sourceRaw" class="mono"></pre>
|
||||||
|
</details>
|
||||||
|
<menu class="menu">
|
||||||
|
<button id="btnSourceApprove" type="button" class="btn primary" data-i18n="sources.detail.approve"><?php echo htmlspecialchars($t('sources.detail.approve', 'Approve'), ENT_QUOTES); ?></button>
|
||||||
|
<button value="cancel" data-i18n="common.close"><?php echo htmlspecialchars($t('common.close', 'Close'), ENT_QUOTES); ?></button>
|
||||||
|
</menu>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="dlg-table-view">
|
||||||
|
<form method="dialog" class="dialog table-view-dialog">
|
||||||
|
<h3>Table view</h3>
|
||||||
|
<div class="table-view-body">
|
||||||
|
<table class="table table-view-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>On</th>
|
||||||
|
<th>Column</th>
|
||||||
|
<th>Width</th>
|
||||||
|
<th>Header align</th>
|
||||||
|
<th>Cell align</th>
|
||||||
|
<th>Order</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody data-table-view-body></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<menu class="menu">
|
||||||
|
<button type="button" class="btn" data-table-view-reset>Reset</button>
|
||||||
|
<button type="button" class="btn primary" data-table-view-apply>Apply</button>
|
||||||
|
<button value="cancel" class="btn">Close</button>
|
||||||
|
</menu>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- ================= DRY RUN DIALOG ================= -->
|
||||||
|
<dialog id="dlg-dry-run">
|
||||||
|
<form method="dialog" class="dialog">
|
||||||
|
<h3 data-i18n="media.dry_run.title"><?php echo htmlspecialchars($t('media.dry_run.title', 'Dry run'), ENT_QUOTES); ?></h3>
|
||||||
|
<pre id="dry-run-log" class="mono"></pre>
|
||||||
|
<menu class="menu">
|
||||||
|
<button value="cancel" data-i18n="common.close"><?php echo htmlspecialchars($t('common.close', 'Close'), ENT_QUOTES); ?></button>
|
||||||
|
</menu>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.I18N = <?php echo json_encode($dict, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>;
|
||||||
|
window.APP_LANG = <?php echo json_encode($lang, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>;
|
||||||
|
window.DEBUG_TOOLS_ENABLED = <?php echo !empty($debugToolsEnabled) ? 'true' : 'false'; ?>;
|
||||||
|
</script>
|
||||||
|
<script src="/assets/js/app-config.js"></script>
|
||||||
|
<script src="/assets/js/table.js"></script>
|
||||||
|
<script src="/assets/js/ui.js"></script>
|
||||||
|
<script src="/assets/js/api.js"></script>
|
||||||
|
<script src="/assets/js/prefs.js"></script>
|
||||||
|
<script src="/assets/js/auth.js"></script>
|
||||||
|
<script src="/assets/js/sse.js"></script>
|
||||||
|
<script src="/assets/js/http.js"></script>
|
||||||
|
<script src="/assets/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
83
app/views/pages/login.php
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
// app/views/pages/login.php
|
||||||
|
|
||||||
|
$centerTitle = $t('auth.login_title', 'Sign in');
|
||||||
|
$centerI18n = 'auth.login_title';
|
||||||
|
$toolbarLeftHtml = '';
|
||||||
|
$toolbarRightHtml = '';
|
||||||
|
$statusHidden = true;
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title data-i18n="auth.page_title"><?php echo htmlspecialchars($t('auth.page_title', 'scMedia / Login'), ENT_QUOTES); ?></title>
|
||||||
|
<link rel="stylesheet" href="/assets/css/common.css">
|
||||||
|
<link rel="stylesheet" href="/assets/css/auth.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="auth-layout">
|
||||||
|
<section class="auth-card">
|
||||||
|
<div class="auth-head">
|
||||||
|
<h1 data-i18n="auth.login_title"><?php echo htmlspecialchars($t('auth.login_title', 'Sign in'), ENT_QUOTES); ?></h1>
|
||||||
|
<div class="auth-lang">
|
||||||
|
<form class="lang-form" method="get" action="/lang">
|
||||||
|
<input type="hidden" name="next" value="/login">
|
||||||
|
<select name="lang" aria-label="<?php echo htmlspecialchars($t('settings.language', 'Language'), ENT_QUOTES); ?>" onchange="this.form.submit()">
|
||||||
|
<option value="ru"<?php echo $lang === 'ru' ? ' selected' : ''; ?>>RU</option>
|
||||||
|
<option value="en"<?php echo $lang === 'en' ? ' selected' : ''; ?>>EN</option>
|
||||||
|
<option value="de"<?php echo $lang === 'de' ? ' selected' : ''; ?>>DE</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="muted" data-i18n="auth.login_hint"><?php echo htmlspecialchars($t('auth.login_hint', 'Use your email and password.'), ENT_QUOTES); ?></p>
|
||||||
|
|
||||||
|
<div id="loginForm" class="auth-form">
|
||||||
|
<label>
|
||||||
|
<span data-i18n="auth.email"><?php echo htmlspecialchars($t('auth.email', 'Email'), ENT_QUOTES); ?></span>
|
||||||
|
<input id="loginEmail" type="email" autocomplete="username" placeholder="you@example.com">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span data-i18n="auth.password"><?php echo htmlspecialchars($t('auth.password', 'Password'), ENT_QUOTES); ?></span>
|
||||||
|
<input id="loginPassword" type="password" autocomplete="current-password">
|
||||||
|
</label>
|
||||||
|
<label class="auth-remember">
|
||||||
|
<input id="loginRemember" type="checkbox" checked>
|
||||||
|
<span data-i18n="auth.remember"><?php echo htmlspecialchars($t('auth.remember', 'Remember session'), ENT_QUOTES); ?></span>
|
||||||
|
</label>
|
||||||
|
<button id="btnLogin" class="btn primary" type="button" data-i18n="auth.login_btn"><?php echo htmlspecialchars($t('auth.login_btn', 'Sign in'), ENT_QUOTES); ?></button>
|
||||||
|
<button id="btnForgot" class="btn ghost" type="button" data-i18n="auth.forgot"><?php echo htmlspecialchars($t('auth.forgot', 'Forgot password?'), ENT_QUOTES); ?></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="mfaForm" class="auth-form is-hidden">
|
||||||
|
<div class="mfa-title" data-i18n="auth.mfa_title"><?php echo htmlspecialchars($t('auth.mfa_title', 'Two-factor code'), ENT_QUOTES); ?></div>
|
||||||
|
<label>
|
||||||
|
<span data-i18n="auth.mfa_code"><?php echo htmlspecialchars($t('auth.mfa_code', 'Code'), ENT_QUOTES); ?></span>
|
||||||
|
<input id="mfaCode" type="text" inputmode="numeric" autocomplete="one-time-code">
|
||||||
|
</label>
|
||||||
|
<button id="btnMfaVerify" class="btn primary" type="button" data-i18n="auth.mfa_verify"><?php echo htmlspecialchars($t('auth.mfa_verify', 'Verify'), ENT_QUOTES); ?></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="forgotForm" class="auth-form is-hidden">
|
||||||
|
<label>
|
||||||
|
<span data-i18n="auth.email"><?php echo htmlspecialchars($t('auth.email', 'Email'), ENT_QUOTES); ?></span>
|
||||||
|
<input id="forgotEmail" type="email" autocomplete="username" placeholder="you@example.com">
|
||||||
|
</label>
|
||||||
|
<button id="btnForgotSend" class="btn primary" type="button" data-i18n="auth.forgot_send"><?php echo htmlspecialchars($t('auth.forgot_send', 'Send reset link'), ENT_QUOTES); ?></button>
|
||||||
|
<button id="btnForgotBack" class="btn ghost" type="button" data-i18n="common.back"><?php echo htmlspecialchars($t('common.back', 'Back'), ENT_QUOTES); ?></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="loginError" class="auth-error is-hidden"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.I18N = <?php echo json_encode($dict, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>;
|
||||||
|
window.APP_LANG = <?php echo json_encode($lang, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>;
|
||||||
|
</script>
|
||||||
|
<script src="/assets/js/auth.js"></script>
|
||||||
|
<script src="/assets/js/login.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
786
app/views/pages/settings.php
Normal file
@ -0,0 +1,786 @@
|
|||||||
|
<?php
|
||||||
|
// app/views/pages/settings.php
|
||||||
|
|
||||||
|
$statusText = $t('common.loading', 'Loading…');
|
||||||
|
$statusI18n = 'common.loading';
|
||||||
|
$statusInToolbar = true;
|
||||||
|
$centerTitle = '';
|
||||||
|
$centerI18n = null;
|
||||||
|
$showQueueSummary = true;
|
||||||
|
$toolbarLeftHtml = '';
|
||||||
|
$toolbarRightHtml = '';
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title data-i18n="settings.page_title"><?php echo htmlspecialchars($t('settings.page_title', 'scmedia / Settings'), ENT_QUOTES); ?></title>
|
||||||
|
<link rel="stylesheet" href="/assets/css/common.css">
|
||||||
|
<link rel="stylesheet" href="/assets/css/settings.css">
|
||||||
|
<link rel="stylesheet" href="/assets/css/admin.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<?php
|
||||||
|
require __DIR__ . '/../partials/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<main class="layout">
|
||||||
|
<nav class="tabs">
|
||||||
|
<button class="tab active" data-tab="scan" data-i18n="settings.tabs.scan_profiles"><?php echo htmlspecialchars($t('settings.tabs.scan_profiles', 'Scan Profiles'), ENT_QUOTES); ?></button>
|
||||||
|
<button class="tab" data-tab="layout" data-i18n="settings.tabs.library_layout"><?php echo htmlspecialchars($t('settings.tabs.library_layout', 'Library'), ENT_QUOTES); ?></button>
|
||||||
|
<button class="tab" data-tab="plugins" data-i18n="settings.tabs.plugins"><?php echo htmlspecialchars($t('settings.tabs.plugins', 'Plugins'), ENT_QUOTES); ?></button>
|
||||||
|
<button class="tab" data-tab="tasks" data-i18n="settings.tabs.tasks"><?php echo htmlspecialchars($t('settings.tabs.tasks', 'Tasks'), ENT_QUOTES); ?></button>
|
||||||
|
<button class="tab" data-tab="rules" data-i18n="settings.tabs.rules"><?php echo htmlspecialchars($t('settings.tabs.rules', 'Rules'), ENT_QUOTES); ?></button>
|
||||||
|
<button class="tab" data-tab="server" data-i18n="settings.tabs.server"><?php echo htmlspecialchars($t('settings.tabs.server', 'Server'), ENT_QUOTES); ?></button>
|
||||||
|
<button class="tab" data-tab="tools" data-i18n="settings.tabs.tools"><?php echo htmlspecialchars($t('settings.tabs.tools', 'Programs'), ENT_QUOTES); ?></button>
|
||||||
|
<button class="tab" data-tab="logs" data-i18n="settings.tabs.logs"><?php echo htmlspecialchars($t('settings.tabs.logs', 'Logs'), ENT_QUOTES); ?></button>
|
||||||
|
<button class="tab" data-tab="admin" data-i18n="admin.title"><?php echo htmlspecialchars($t('admin.title', 'Admin'), ENT_QUOTES); ?></button>
|
||||||
|
<button class="tab" data-tab="debug" id="tabDebug"<?php echo empty($debugToolsEnabled) ? ' style="display:none;"' : ''; ?> data-i18n="settings.tabs.debug"><?php echo htmlspecialchars($t('settings.tabs.debug', 'Debug'), ENT_QUOTES); ?></button>
|
||||||
|
<button class="tab" data-tab="about" data-i18n="settings.tabs.about"><?php echo htmlspecialchars($t('settings.tabs.about', 'About'), ENT_QUOTES); ?></button>
|
||||||
|
<?php
|
||||||
|
$statusHidden = false;
|
||||||
|
$statusClass = $statusHidden ? 'status hidden' : 'status';
|
||||||
|
$statusAttr = $statusI18n ? ' data-i18n="' . htmlspecialchars($statusI18n, ENT_QUOTES) . '"' : '';
|
||||||
|
?>
|
||||||
|
<div class="tabs-footer">
|
||||||
|
<span id="status" class="<?php echo htmlspecialchars($statusClass, ENT_QUOTES); ?>"<?php echo $statusAttr; ?>>
|
||||||
|
<?php echo htmlspecialchars($statusText, ENT_QUOTES); ?>
|
||||||
|
</span>
|
||||||
|
<button id="btnSave" class="btn primary" type="button" disabled data-i18n="common.save"><?php echo htmlspecialchars($t('common.save', 'Save'), ENT_QUOTES); ?></button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<!-- ===================== SCAN TAB ===================== -->
|
||||||
|
<div class="tabpane active" id="pane-scan">
|
||||||
|
<div class="pane-header">
|
||||||
|
<h2 data-i18n="settings.scan_profiles.title"><?php echo htmlspecialchars($t('settings.scan_profiles.title', 'Scan Profiles'), ENT_QUOTES); ?></h2>
|
||||||
|
<button id="btnAddProfile" class="btn primary" data-i18n="settings.scan_profiles.add"><?php echo htmlspecialchars($t('settings.scan_profiles.add', 'Add profile'), ENT_QUOTES); ?></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<template id="tplProfileRow">
|
||||||
|
<tr>
|
||||||
|
<td><button class="drag-handle" type="button" data-action="drag" data-i18n-aria="settings.scan_profiles.move">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path fill="currentColor" d="M7 5h10v2H7V5zm0 6h10v2H7v-2zm0 6h10v2H7v-2z"/>
|
||||||
|
</svg>
|
||||||
|
</button></td>
|
||||||
|
<td><input type="checkbox" data-field="enabled"></td>
|
||||||
|
<td data-field="profile_type"></td>
|
||||||
|
<td data-field="name"></td>
|
||||||
|
<td><code data-field="root_path"></code></td>
|
||||||
|
<td data-field="max_depth"></td>
|
||||||
|
<td data-field="exclude"></td>
|
||||||
|
<td data-field="ext"></td>
|
||||||
|
<td data-field="last_scan"></td>
|
||||||
|
<td data-field="last_result"></td>
|
||||||
|
<td>
|
||||||
|
<button class="btn" data-action="edit" data-i18n="common.edit"><?php echo htmlspecialchars($t('common.edit', 'Edit'), ENT_QUOTES); ?></button>
|
||||||
|
<button class="btn" data-action="del" data-i18n="common.delete"><?php echo htmlspecialchars($t('common.delete', 'Delete'), ENT_QUOTES); ?></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$id = 'profilesTable';
|
||||||
|
$tableClass = 'data-table';
|
||||||
|
$headers = [
|
||||||
|
['label' => '', 'i18n' => '', 'key' => '', 'filter' => null],
|
||||||
|
['label' => $t('settings.scan_profiles.th_on', 'On'), 'i18n' => 'settings.scan_profiles.th_on', 'key' => 'enabled', 'filter' => ['type' => 'boolean']],
|
||||||
|
['label' => $t('settings.scan_profiles.th_type', 'Type'), 'i18n' => 'settings.scan_profiles.th_type', 'key' => 'profile_type', 'filter' => ['type' => 'text', 'ops' => ['like','eq']]],
|
||||||
|
['label' => $t('settings.scan_profiles.th_name', 'Name'), 'i18n' => 'settings.scan_profiles.th_name', 'sort' => 'name', 'key' => 'name', 'filter' => ['type' => 'text', 'ops' => ['like','eq','empty']]],
|
||||||
|
['label' => $t('settings.scan_profiles.th_root', 'Root path'), 'i18n' => 'settings.scan_profiles.th_root', 'key' => 'root_path', 'filter' => ['type' => 'text', 'ops' => ['like','eq','empty']]],
|
||||||
|
['label' => $t('settings.scan_profiles.th_depth', 'Depth'), 'i18n' => 'settings.scan_profiles.th_depth', 'key' => 'max_depth', 'filter' => ['type' => 'number', 'ops' => ['eq','between','gt','lt']]],
|
||||||
|
['label' => $t('settings.scan_profiles.th_excludes', 'Excludes'), 'i18n' => 'settings.scan_profiles.th_excludes', 'key' => 'exclude', 'filter' => ['type' => 'text', 'ops' => ['like','eq','empty']]],
|
||||||
|
['label' => $t('settings.scan_profiles.th_ext', 'Ext'), 'i18n' => 'settings.scan_profiles.th_ext', 'key' => 'ext', 'filter' => ['type' => 'text', 'ops' => ['like','eq','empty']]],
|
||||||
|
['label' => $t('settings.scan_profiles.th_last_scan', 'Last scan'), 'i18n' => 'settings.scan_profiles.th_last_scan', 'key' => 'last_scan', 'filter' => ['type' => 'date', 'ops' => ['eq','between']]],
|
||||||
|
['label' => $t('settings.scan_profiles.th_result', 'Result'), 'i18n' => 'settings.scan_profiles.th_result', 'key' => 'last_result', 'filter' => ['type' => 'text', 'ops' => ['like','eq','empty']]],
|
||||||
|
['label' => '', 'i18n' => '', 'key' => '', 'filter' => null],
|
||||||
|
];
|
||||||
|
$attrs = ['data-table' => 'profiles'];
|
||||||
|
require __DIR__ . '/../partials/table.php';
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3 data-i18n="settings.scanner_defaults.title"><?php echo htmlspecialchars($t('settings.scanner_defaults.title', 'Global scanner defaults'), ENT_QUOTES); ?></h3>
|
||||||
|
<div class="grid">
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.scanner_defaults.video_ext"><?php echo htmlspecialchars($t('settings.scanner_defaults.video_ext', 'Video extensions (comma)'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="videoExt" type="text" placeholder="mkv,mp4,avi" data-i18n-placeholder="settings.scanner_defaults.video_ext_ph">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.scanner_defaults.max_depth"><?php echo htmlspecialchars($t('settings.scanner_defaults.max_depth', 'Max depth default'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="maxDepthDefault" type="number" min="1" max="10">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.scanner_defaults.max_files"><?php echo htmlspecialchars($t('settings.scanner_defaults.max_files', 'Max files per item'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="maxFilesPerItem" type="number" min="100" max="200000">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.scanner_defaults.max_items"><?php echo htmlspecialchars($t('settings.scanner_defaults.max_items', 'Max items per scan (0 = no limit)'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="maxItemsPerScan" type="number" min="0" max="1000000">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== LAYOUT TAB ===================== -->
|
||||||
|
<div class="tabpane" id="pane-layout">
|
||||||
|
<div class="pane-header">
|
||||||
|
<h2 data-i18n="settings.library_layout.title"><?php echo htmlspecialchars($t('settings.library_layout.title', 'Library Layout'), ENT_QUOTES); ?></h2>
|
||||||
|
<button id="btnAddRoot" class="btn primary" data-i18n="settings.library_layout.add_root"><?php echo htmlspecialchars($t('settings.library_layout.add_root', 'Add root'), ENT_QUOTES); ?></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<?php
|
||||||
|
$id = 'rootsTable';
|
||||||
|
$tableClass = 'data-table';
|
||||||
|
$headers = [
|
||||||
|
['label' => $t('settings.library_layout.th_type', 'Type'), 'i18n' => 'settings.library_layout.th_type', 'key' => 'type', 'filter' => ['type' => 'text', 'ops' => ['like','eq']]],
|
||||||
|
['label' => $t('settings.library_layout.th_path', 'Path'), 'i18n' => 'settings.library_layout.th_path', 'sort' => 'path', 'key' => 'path', 'filter' => ['type' => 'text', 'ops' => ['like','eq','empty']]],
|
||||||
|
['label' => $t('settings.library_layout.th_status', 'Status'), 'i18n' => 'settings.library_layout.th_status', 'key' => 'status', 'filter' => ['type' => 'text', 'ops' => ['like','eq']]],
|
||||||
|
['label' => '', 'i18n' => '', 'key' => '', 'filter' => null],
|
||||||
|
];
|
||||||
|
$attrs = ['data-table' => 'roots'];
|
||||||
|
require __DIR__ . '/../partials/table.php';
|
||||||
|
?>
|
||||||
|
<div class="hint" id="rootsHint"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== PLUGINS TAB ===================== -->
|
||||||
|
<div class="tabpane" id="pane-plugins">
|
||||||
|
<div class="pane-header">
|
||||||
|
<h2 data-i18n="settings.plugins.title"><?php echo htmlspecialchars($t('settings.plugins.title', 'Plugins'), ENT_QUOTES); ?></h2>
|
||||||
|
<div class="row">
|
||||||
|
<button id="btnAddPlugin" class="btn primary" data-i18n="settings.plugins.add"><?php echo htmlspecialchars($t('settings.plugins.add', 'Add plugin'), ENT_QUOTES); ?></button>
|
||||||
|
<input id="pluginFileInput" type="file" style="display:none;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<?php
|
||||||
|
$id = 'pluginsTable';
|
||||||
|
$tableClass = 'data-table';
|
||||||
|
$headers = [
|
||||||
|
['label' => $t('settings.plugins.th_name', 'Plugin'), 'i18n' => 'settings.plugins.th_name', 'sort' => 'name', 'key' => 'name', 'filter' => ['type' => 'text', 'ops' => ['like','eq','empty']]],
|
||||||
|
['label' => $t('settings.plugins.th_type', 'Type'), 'i18n' => 'settings.plugins.th_type', 'key' => 'type', 'filter' => ['type' => 'text', 'ops' => ['like','eq']]],
|
||||||
|
['label' => $t('settings.plugins.th_status', 'Status'), 'i18n' => 'settings.plugins.th_status', 'key' => 'status', 'filter' => ['type' => 'text', 'ops' => ['like','eq']]],
|
||||||
|
['label' => '', 'i18n' => '', 'key' => '', 'filter' => null],
|
||||||
|
];
|
||||||
|
$attrs = ['data-table' => 'plugins'];
|
||||||
|
require __DIR__ . '/../partials/table.php';
|
||||||
|
?>
|
||||||
|
<div class="hint" id="pluginsHint"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== TASKS TAB ===================== -->
|
||||||
|
<div class="tabpane" id="pane-tasks">
|
||||||
|
<div class="pane-header">
|
||||||
|
<h2 data-i18n="settings.tasks.title"><?php echo htmlspecialchars($t('settings.tasks.title', 'Tasks'), ENT_QUOTES); ?></h2>
|
||||||
|
<button id="btnAddTask" class="btn primary" data-i18n="settings.tasks.add"><?php echo htmlspecialchars($t('settings.tasks.add', 'Add task'), ENT_QUOTES); ?></button>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<?php
|
||||||
|
$id = 'tasksTable';
|
||||||
|
$tableClass = 'data-table';
|
||||||
|
$headers = [
|
||||||
|
['label' => $t('settings.tasks.th_name', 'Task'), 'i18n' => 'settings.tasks.th_name', 'sort' => 'name', 'key' => 'name', 'filter' => ['type' => 'text', 'ops' => ['like','eq','empty']]],
|
||||||
|
['label' => $t('settings.tasks.th_sources', 'Sources'), 'i18n' => 'settings.tasks.th_sources', 'key' => 'sources', 'filter' => ['type' => 'text', 'ops' => ['like','eq','empty']]],
|
||||||
|
['label' => $t('settings.tasks.th_actions', 'Actions'), 'i18n' => 'settings.tasks.th_actions', 'key' => 'actions', 'filter' => ['type' => 'text', 'ops' => ['like','eq','empty']]],
|
||||||
|
['label' => $t('settings.tasks.th_status', 'Status'), 'i18n' => 'settings.tasks.th_status', 'key' => 'status', 'filter' => ['type' => 'text', 'ops' => ['like','eq']]],
|
||||||
|
['label' => '', 'i18n' => '', 'key' => '', 'filter' => null],
|
||||||
|
];
|
||||||
|
$attrs = ['data-table' => 'tasks'];
|
||||||
|
require __DIR__ . '/../partials/table.php';
|
||||||
|
?>
|
||||||
|
<div class="hint" id="tasksHint"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== RULES TAB ===================== -->
|
||||||
|
<div class="tabpane" id="pane-rules">
|
||||||
|
<div class="pane-header">
|
||||||
|
<h2 data-i18n="settings.rules.title"><?php echo htmlspecialchars($t('settings.rules.title', 'Rules'), ENT_QUOTES); ?></h2>
|
||||||
|
<div class="row">
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn primary" id="btnAddRule" data-i18n="settings.rules.add_rule"><?php echo htmlspecialchars($t('settings.rules.add_rule', 'Add rule'), ENT_QUOTES); ?></button>
|
||||||
|
<div class="dropdown-menu" id="ruleTypeMenu">
|
||||||
|
<button type="button" data-rule-type="name_map" data-i18n="rules.type.name_map"><?php echo htmlspecialchars($t('rules.type.name_map', 'Name mapping'), ENT_QUOTES); ?></button>
|
||||||
|
<button type="button" data-rule-type="delete_track" data-i18n="rules.type.delete_track"><?php echo htmlspecialchars($t('rules.type.delete_track', 'Delete tracks'), ENT_QUOTES); ?></button>
|
||||||
|
<button type="button" data-rule-type="priorities" data-i18n="rules.type.priorities"><?php echo htmlspecialchars($t('rules.type.priorities', 'Priorities'), ENT_QUOTES); ?></button>
|
||||||
|
<button type="button" data-rule-type="lang_fix" data-i18n="rules.type.lang_fix"><?php echo htmlspecialchars($t('rules.type.lang_fix', 'Language fix'), ENT_QUOTES); ?></button>
|
||||||
|
<button type="button" data-rule-type="source_filter" data-i18n="rules.type.source_filter"><?php echo htmlspecialchars($t('rules.type.source_filter', 'Source filter'), ENT_QUOTES); ?></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="rule-sort">
|
||||||
|
<span class="lbl" data-i18n="rules.sort_by"><?php echo htmlspecialchars($t('rules.sort_by', 'Sort by'), ENT_QUOTES); ?></span>
|
||||||
|
<select id="rulesSortField">
|
||||||
|
<option value="name" data-i18n="rules.sort.name"><?php echo htmlspecialchars($t('rules.sort.name', 'Name'), ENT_QUOTES); ?></option>
|
||||||
|
<option value="type" data-i18n="rules.sort.type"><?php echo htmlspecialchars($t('rules.sort.type', 'Type'), ENT_QUOTES); ?></option>
|
||||||
|
</select>
|
||||||
|
<button class="btn" id="rulesSortDir" type="button">↑</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<?php
|
||||||
|
$id = 'rulesTable';
|
||||||
|
$tableClass = 'data-table';
|
||||||
|
$headers = [
|
||||||
|
['label' => $t('rules.th.name', 'Name'), 'i18n' => 'rules.th.name', 'sort' => 'name', 'key' => 'name', 'filter' => ['type' => 'text', 'ops' => ['like','eq','empty']]],
|
||||||
|
['label' => $t('rules.th.type', 'Type'), 'i18n' => 'rules.th.type', 'sort' => 'type', 'key' => 'type', 'filter' => ['type' => 'text', 'ops' => ['like','eq']]],
|
||||||
|
['label' => $t('rules.th.summary', 'Summary'), 'i18n' => 'rules.th.summary', 'key' => 'summary', 'filter' => ['type' => 'text', 'ops' => ['like','eq','empty']]],
|
||||||
|
['label' => $t('rules.th.status', 'Status'), 'i18n' => 'rules.th.status', 'key' => 'status', 'filter' => ['type' => 'text', 'ops' => ['like','eq']]],
|
||||||
|
['label' => '', 'i18n' => '', 'key' => '', 'filter' => null],
|
||||||
|
];
|
||||||
|
$attrs = ['data-table' => 'rules'];
|
||||||
|
require __DIR__ . '/../partials/table.php';
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== SERVER TAB ===================== -->
|
||||||
|
<div class="tabpane" id="pane-server">
|
||||||
|
<div class="pane-header">
|
||||||
|
<h2 data-i18n="settings.server.title"><?php echo htmlspecialchars($t('settings.server.title', 'Server'), ENT_QUOTES); ?></h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3 data-i18n="settings.server.system"><?php echo htmlspecialchars($t('settings.server.system', 'System'), ENT_QUOTES); ?></h3>
|
||||||
|
<div class="grid">
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.server.env"><?php echo htmlspecialchars($t('settings.server.env', 'Environment'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="serverEnv" type="text" readonly data-local="true">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.server.app_id"><?php echo htmlspecialchars($t('settings.server.app_id', 'App ID'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="serverAppId" type="text" readonly data-local="true">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.server.debug"><?php echo htmlspecialchars($t('settings.server.debug', 'Debug'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="serverDebug" type="text" readonly data-local="true">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.server.backend_version"><?php echo htmlspecialchars($t('settings.server.backend_version', 'Backend version'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="serverBackendVersion" type="text" readonly data-local="true">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.server.db_version"><?php echo htmlspecialchars($t('settings.server.db_version', 'DB version'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="serverDbVersion" type="text" readonly data-local="true">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3 data-i18n="settings.server.background"><?php echo htmlspecialchars($t('settings.server.background', 'Background'), ENT_QUOTES); ?></h3>
|
||||||
|
<div class="grid">
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.background.mode"><?php echo htmlspecialchars($t('settings.background.mode', 'Mode'), ENT_QUOTES); ?></div>
|
||||||
|
<select id="bgMode">
|
||||||
|
<option value="light" data-i18n="settings.background.mode_light"><?php echo htmlspecialchars($t('settings.background.mode_light', 'Light'), ENT_QUOTES); ?></option>
|
||||||
|
<option value="normal" data-i18n="settings.background.mode_normal"><?php echo htmlspecialchars($t('settings.background.mode_normal', 'Normal'), ENT_QUOTES); ?></option>
|
||||||
|
<option value="aggressive" data-i18n="settings.background.mode_aggressive"><?php echo htmlspecialchars($t('settings.background.mode_aggressive', 'Aggressive'), ENT_QUOTES); ?></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.background.max_parallel"><?php echo htmlspecialchars($t('settings.background.max_parallel', 'Max parallel jobs'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="bgMaxParallel" type="number" min="1" max="10">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.background.max_network"><?php echo htmlspecialchars($t('settings.background.max_network', 'Max network jobs'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="bgMaxNetwork" type="number" min="1" max="10">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.background.max_io"><?php echo htmlspecialchars($t('settings.background.max_io', 'Max IO jobs'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="bgMaxIo" type="number" min="1" max="10">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.background.batch_sleep"><?php echo htmlspecialchars($t('settings.background.batch_sleep', 'Sleep between batches (ms)'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="bgBatchSleep" type="number" min="0" max="60000">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.background.watchdog"><?php echo htmlspecialchars($t('settings.background.watchdog', 'Stalled watchdog (min)'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="bgWatchdog" type="number" min="1" max="120">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.background.sse_ttl"><?php echo htmlspecialchars($t('settings.background.sse_ttl', 'SSE session TTL (sec)'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="bgSseTtl" type="number" min="1" max="600">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3 data-i18n="settings.server.sse"><?php echo htmlspecialchars($t('settings.server.sse', 'SSE'), ENT_QUOTES); ?></h3>
|
||||||
|
<div class="grid">
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.ui.sse_tick"><?php echo htmlspecialchars($t('settings.ui.sse_tick', 'SSE tick interval (sec)'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="uiSseTickSeconds" type="number" min="1" max="60">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3 data-i18n="settings.server.safety"><?php echo htmlspecialchars($t('settings.server.safety', 'Safety'), ENT_QUOTES); ?></h3>
|
||||||
|
<div class="grid">
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.server.safety.max_depth"><?php echo htmlspecialchars($t('settings.server.safety.max_depth', 'Max depth'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="safetyMaxDepth" type="number" min="1" max="50">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.server.safety.max_files"><?php echo htmlspecialchars($t('settings.server.safety.max_files', 'Max files per item'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="safetyMaxFiles" type="number" min="100" max="2000000">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.server.safety.max_items"><?php echo htmlspecialchars($t('settings.server.safety.max_items', 'Max items per scan'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="safetyMaxItems" type="number" min="0" max="2000000">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3 data-i18n="settings.server.diagnostics"><?php echo htmlspecialchars($t('settings.server.diagnostics', 'Diagnostics'), ENT_QUOTES); ?></h3>
|
||||||
|
<div class="grid">
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.server.php_version"><?php echo htmlspecialchars($t('settings.server.php_version', 'PHP version'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="serverPhpVersion" type="text" readonly data-local="true">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.server.disk"><?php echo htmlspecialchars($t('settings.server.disk', 'Disk free'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="serverDisk" type="text" readonly data-local="true">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.server.binaries"><?php echo htmlspecialchars($t('settings.server.binaries', 'Binaries'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="serverBinaries" type="text" readonly data-local="true">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.server.jobs"><?php echo htmlspecialchars($t('settings.server.jobs', 'Jobs status'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="serverJobs" type="text" readonly data-local="true">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3 data-i18n="settings.server.snapshots"><?php echo htmlspecialchars($t('settings.server.snapshots', 'Snapshots'), ENT_QUOTES); ?></h3>
|
||||||
|
<div class="row">
|
||||||
|
<input id="snapshotLabel" type="text" placeholder="Snapshot label" data-i18n-placeholder="settings.server.snapshot_label" data-local="true">
|
||||||
|
<button id="btnCreateSnapshot" class="btn" type="button" data-i18n="settings.server.snapshot_create"><?php echo htmlspecialchars($t('settings.server.snapshot_create', 'Create snapshot'), ENT_QUOTES); ?></button>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table" id="snapshotsTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-i18n="settings.server.snapshot_id"><?php echo htmlspecialchars($t('settings.server.snapshot_id', 'ID'), ENT_QUOTES); ?></th>
|
||||||
|
<th data-i18n="settings.server.snapshot_label"><?php echo htmlspecialchars($t('settings.server.snapshot_label', 'Label'), ENT_QUOTES); ?></th>
|
||||||
|
<th data-i18n="settings.server.snapshot_date"><?php echo htmlspecialchars($t('settings.server.snapshot_date', 'Created'), ENT_QUOTES); ?></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="hint" id="snapshotsHint"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3 data-i18n="settings.server.danger"><?php echo htmlspecialchars($t('settings.server.danger', 'Danger zone'), ENT_QUOTES); ?></h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== TOOLS TAB ===================== -->
|
||||||
|
<div class="tabpane" id="pane-tools">
|
||||||
|
<div class="pane-header">
|
||||||
|
<h2 data-i18n="settings.tools.title"><?php echo htmlspecialchars($t('settings.tools.title', 'Programs'), ENT_QUOTES); ?></h2>
|
||||||
|
<div class="row">
|
||||||
|
<div class="dropdown">
|
||||||
|
<button id="btnAddTool" class="btn primary" data-i18n="settings.tools.add"><?php echo htmlspecialchars($t('settings.tools.add', 'Add tool'), ENT_QUOTES); ?></button>
|
||||||
|
<div class="dropdown-menu" id="toolTypeMenu">
|
||||||
|
<button type="button" data-tool-type="mkvmerge" data-i18n="settings.tools.mkvmerge"><?php echo htmlspecialchars($t('settings.tools.mkvmerge', 'mkvmerge'), ENT_QUOTES); ?></button>
|
||||||
|
<button type="button" data-tool-type="mkvpropedit" data-i18n="settings.tools.mkvpropedit"><?php echo htmlspecialchars($t('settings.tools.mkvpropedit', 'mkvpropedit'), ENT_QUOTES); ?></button>
|
||||||
|
<button type="button" data-tool-type="ffmpeg" data-i18n="settings.tools.ffmpeg"><?php echo htmlspecialchars($t('settings.tools.ffmpeg', 'ffmpeg'), ENT_QUOTES); ?></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="btnDetectTools" class="btn" data-i18n="settings.tools.detect"><?php echo htmlspecialchars($t('settings.tools.detect', 'Detect'), ENT_QUOTES); ?></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<?php
|
||||||
|
$id = 'toolsTable';
|
||||||
|
$tableClass = 'data-table';
|
||||||
|
$headers = [
|
||||||
|
['label' => $t('settings.tools.th_name', 'Tool'), 'i18n' => 'settings.tools.th_name', 'sort' => 'name', 'key' => 'name', 'filter' => ['type' => 'text', 'ops' => ['like','eq','empty']]],
|
||||||
|
['label' => $t('settings.tools.th_path', 'Path'), 'i18n' => 'settings.tools.th_path', 'key' => 'path', 'filter' => ['type' => 'text', 'ops' => ['like','eq','empty']]],
|
||||||
|
['label' => '', 'i18n' => '', 'key' => '', 'filter' => null],
|
||||||
|
];
|
||||||
|
$attrs = ['data-table' => 'tools'];
|
||||||
|
require __DIR__ . '/../partials/table.php';
|
||||||
|
?>
|
||||||
|
<div class="hint" id="toolsHint"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== LOGS TAB ===================== -->
|
||||||
|
<div class="tabpane" id="pane-logs">
|
||||||
|
<div class="pane-header">
|
||||||
|
<h2 data-i18n="settings.logs.title"><?php echo htmlspecialchars($t('settings.logs.title', 'Logs'), ENT_QUOTES); ?></h2>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="tabs logs-tabs">
|
||||||
|
<button class="tab active" data-logs-tab="view" data-i18n="settings.logs.tab_view"><?php echo htmlspecialchars($t('settings.logs.tab_view', 'View'), ENT_QUOTES); ?></button>
|
||||||
|
<button class="tab" data-logs-tab="settings" data-i18n="settings.logs.tab_settings"><?php echo htmlspecialchars($t('settings.logs.tab_settings', 'Settings'), ENT_QUOTES); ?></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="logs-pane active" id="logs-pane-view">
|
||||||
|
<div class="row logs-row logs-filters">
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.logs.type"><?php echo htmlspecialchars($t('settings.logs.type', 'Type'), ENT_QUOTES); ?></div>
|
||||||
|
<select id="logsType" data-local="true">
|
||||||
|
<option value="system" data-i18n="settings.logs.type_system"><?php echo htmlspecialchars($t('settings.logs.type_system', 'System'), ENT_QUOTES); ?></option>
|
||||||
|
<option value="audit" data-i18n="settings.logs.type_audit"><?php echo htmlspecialchars($t('settings.logs.type_audit', 'Audit'), ENT_QUOTES); ?></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="row logs-row" id="logsControlsShared">
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.logs.date_from"><?php echo htmlspecialchars($t('settings.logs.date_from', 'From'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="logsDateFrom" type="date" data-local="true">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.logs.date_to"><?php echo htmlspecialchars($t('settings.logs.date_to', 'To'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="logsDateTo" type="date" data-local="true">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="row logs-row" id="logsControlsLevel">
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.logs.filter_level"><?php echo htmlspecialchars($t('settings.logs.filter_level', 'Level'), ENT_QUOTES); ?></div>
|
||||||
|
<select id="logsFilterLevel" data-local="true">
|
||||||
|
<option value="all" data-i18n="filters.all"><?php echo htmlspecialchars($t('filters.all', 'All'), ENT_QUOTES); ?></option>
|
||||||
|
<option value="debug">debug</option>
|
||||||
|
<option value="info">info</option>
|
||||||
|
<option value="warn">warn</option>
|
||||||
|
<option value="error">error</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="row logs-row is-hidden" id="logsControlsEvent">
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.logs.filter_event"><?php echo htmlspecialchars($t('settings.logs.filter_event', 'Event'), ENT_QUOTES); ?></div>
|
||||||
|
<select id="logsFilterEvent" data-local="true">
|
||||||
|
<option value="all" data-i18n="filters.all"><?php echo htmlspecialchars($t('filters.all', 'All'), ENT_QUOTES); ?></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="row logs-row" id="logsControlsSystemActions">
|
||||||
|
<span class="hint" id="logsHint"></span>
|
||||||
|
<span class="hint" id="logsSelectionCount"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row logs-row logs-actions" id="logsControlsActions">
|
||||||
|
<button id="btnLoadLogs" class="btn" data-i18n="settings.logs.load"><?php echo htmlspecialchars($t('settings.logs.load', 'Load'), ENT_QUOTES); ?></button>
|
||||||
|
<button id="btnResetLogsDate" class="btn" data-i18n="settings.logs.reset"><?php echo htmlspecialchars($t('settings.logs.reset', 'Reset'), ENT_QUOTES); ?></button>
|
||||||
|
<button id="btnLogsSelectAll" class="btn" type="button" data-i18n="settings.logs.select_all"><?php echo htmlspecialchars($t('settings.logs.select_all', 'Select all'), ENT_QUOTES); ?></button>
|
||||||
|
<button id="btnLogsCopy" class="btn" type="button" data-i18n="settings.logs.copy_selected"><?php echo htmlspecialchars($t('settings.logs.copy_selected', 'Copy selected'), ENT_QUOTES); ?></button>
|
||||||
|
</div>
|
||||||
|
<div id="logsOutputWrap">
|
||||||
|
<?php
|
||||||
|
$id = 'logsTable';
|
||||||
|
$tableClass = 'table';
|
||||||
|
$headers = [
|
||||||
|
['label' => '', 'key' => '', 'filter' => null],
|
||||||
|
['label' => $t('settings.logs.when', 'When'), 'i18n' => 'settings.logs.when', 'key' => 'ts', 'sort' => 'ts', 'filter' => ['type' => 'date', 'ops' => ['eq','between']]],
|
||||||
|
['label' => $t('settings.logs.level', 'Level'), 'i18n' => 'settings.logs.level', 'key' => 'level', 'sort' => 'level', 'filter' => ['type' => 'select', 'options' => ['debug','info','warn','error']]],
|
||||||
|
['label' => $t('settings.logs.message', 'Message'), 'i18n' => 'settings.logs.message', 'key' => 'message', 'sort' => 'message', 'filter' => ['type' => 'text', 'ops' => ['like','eq','empty']]],
|
||||||
|
['label' => $t('settings.logs.context', 'Context'), 'i18n' => 'settings.logs.context', 'key' => 'context_json', 'sort' => 'context_json', 'filter' => ['type' => 'text', 'ops' => ['like','eq','empty']]],
|
||||||
|
];
|
||||||
|
$attrs = ['data-table-id' => 'system-logs'];
|
||||||
|
$tbodyId = 'logsTableBody';
|
||||||
|
require __DIR__ . '/../partials/table.php';
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
<div id="logsAuditWrap" class="admin-card is-hidden">
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<?php
|
||||||
|
$id = 'adminAuditTable';
|
||||||
|
$tableClass = 'grid admin-table';
|
||||||
|
$headers = [
|
||||||
|
['label' => '', 'key' => '', 'filter' => null],
|
||||||
|
['label' => 'When', 'key' => 'created_at', 'filter' => ['type' => 'date', 'ops' => ['eq','between']]],
|
||||||
|
['label' => 'Actor', 'key' => 'actor', 'filter' => ['type' => 'text', 'ops' => ['like','eq','empty']]],
|
||||||
|
['label' => 'Action', 'key' => 'action', 'filter' => ['type' => 'text', 'ops' => ['like','eq']]],
|
||||||
|
['label' => 'Target', 'key' => 'target', 'filter' => ['type' => 'text', 'ops' => ['like','eq','empty']]],
|
||||||
|
['label' => 'Meta', 'key' => 'meta_json', 'filter' => ['type' => 'text', 'ops' => ['like','eq','empty']]],
|
||||||
|
];
|
||||||
|
$attrs = ['data-table-id' => 'admin-audit'];
|
||||||
|
$footer = false;
|
||||||
|
$tbodyId = 'adminAudit';
|
||||||
|
require __DIR__ . '/../partials/table.php';
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="logs-pane" id="logs-pane-settings">
|
||||||
|
<div class="row logs-row">
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.logs.retention"><?php echo htmlspecialchars($t('settings.logs.retention', 'Retention'), ENT_QUOTES); ?></div>
|
||||||
|
<select id="logsRetention">
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="7">7</option>
|
||||||
|
<option value="14">14</option>
|
||||||
|
<option value="90">90</option>
|
||||||
|
<option value="0" data-i18n="settings.logs.forever"><?php echo htmlspecialchars($t('settings.logs.forever', 'Forever'), ENT_QUOTES); ?></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.logs.level"><?php echo htmlspecialchars($t('settings.logs.level', 'Log level'), ENT_QUOTES); ?></div>
|
||||||
|
<select id="logsLevel">
|
||||||
|
<option value="debug">debug</option>
|
||||||
|
<option value="info">info</option>
|
||||||
|
<option value="warn">warn</option>
|
||||||
|
<option value="error">error</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button id="btnCleanupLogs" class="btn" data-i18n="settings.logs.cleanup"><?php echo htmlspecialchars($t('settings.logs.cleanup', 'Cleanup'), ENT_QUOTES); ?></button>
|
||||||
|
<span class="hint" id="logsSettingsHint"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tabpane" id="pane-admin">
|
||||||
|
<div class="admin-layout">
|
||||||
|
<section class="admin-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2 data-i18n="admin.users"><?php echo htmlspecialchars($t('admin.users', 'Users'), ENT_QUOTES); ?></h2>
|
||||||
|
<button id="btnReloadUsers" class="btn" type="button" data-i18n="common.reload"><?php echo htmlspecialchars($t('common.reload', 'Reload'), ENT_QUOTES); ?></button>
|
||||||
|
</div>
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<?php
|
||||||
|
$id = 'adminUsersTable';
|
||||||
|
$tableClass = 'grid admin-table';
|
||||||
|
$headers = [
|
||||||
|
['label' => 'Email', 'key' => 'email', 'filter' => ['type' => 'text', 'ops' => ['like','eq','empty']]],
|
||||||
|
['label' => 'Roles', 'key' => 'roles', 'filter' => ['type' => 'text', 'ops' => ['like','eq','empty']]],
|
||||||
|
['label' => 'Status', 'key' => 'status', 'filter' => ['type' => 'select', 'options' => ['active','disabled']]],
|
||||||
|
['label' => 'Last login', 'key' => 'last_login_at', 'filter' => ['type' => 'date', 'ops' => ['eq','between']]],
|
||||||
|
['label' => 'Actions', 'key' => '', 'filter' => null],
|
||||||
|
];
|
||||||
|
$attrs = ['data-table-id' => 'admin-users'];
|
||||||
|
$footer = false;
|
||||||
|
$tbodyId = 'adminUsers';
|
||||||
|
require __DIR__ . '/../partials/table.php';
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== DEBUG TAB ===================== -->
|
||||||
|
<div class="tabpane" id="pane-debug">
|
||||||
|
<div class="pane-header">
|
||||||
|
<h2 data-i18n="settings.debug.title"><?php echo htmlspecialchars($t('settings.debug.title', 'Debug'), ENT_QUOTES); ?></h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card warn">
|
||||||
|
<h3 data-i18n="settings.debug.content.title"><?php echo htmlspecialchars($t('settings.debug.content.title', 'Content data'), ENT_QUOTES); ?></h3>
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<div class="lbl" data-i18n="settings.debug.content.files"><?php echo htmlspecialchars($t('settings.debug.content.files', 'Media files'), ENT_QUOTES); ?></div>
|
||||||
|
<div id="dbgContentFiles">-</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="lbl" data-i18n="settings.debug.content.meta"><?php echo htmlspecialchars($t('settings.debug.content.meta', 'Metadata'), ENT_QUOTES); ?></div>
|
||||||
|
<div id="dbgContentMeta">-</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="lbl" data-i18n="settings.debug.content.items"><?php echo htmlspecialchars($t('settings.debug.content.items', 'Indexed items'), ENT_QUOTES); ?></div>
|
||||||
|
<div id="dbgContentItems">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input id="dbgContentConfirm" type="text" placeholder="<?php echo htmlspecialchars($t('settings.debug.content.ph', 'Type CLEAR CONTENT'), ENT_QUOTES); ?>">
|
||||||
|
<button id="btnClearContent" class="btn danger" data-i18n="settings.debug.content.clear_btn"><?php echo htmlspecialchars($t('settings.debug.content.clear_btn', 'Clear content'), ENT_QUOTES); ?></button>
|
||||||
|
<pre id="dbgContentOut" class="pre"></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card danger">
|
||||||
|
<h3 data-i18n="settings.debug.db.title"><?php echo htmlspecialchars($t('settings.debug.db.title', 'Database'), ENT_QUOTES); ?></h3>
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<div class="lbl" data-i18n="settings.debug.db.tables"><?php echo htmlspecialchars($t('settings.debug.db.tables', 'Tables'), ENT_QUOTES); ?></div>
|
||||||
|
<div id="dbgDbTables">-</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="lbl" data-i18n="settings.debug.db.size"><?php echo htmlspecialchars($t('settings.debug.db.size', 'Size'), ENT_QUOTES); ?></div>
|
||||||
|
<div id="dbgDbSize">-</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="lbl">DB</div>
|
||||||
|
<div id="dbgDbName">-</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="lbl">User</div>
|
||||||
|
<div id="dbgDbUser">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<?php
|
||||||
|
$id = 'dbgDbTablesList';
|
||||||
|
$tableClass = 'table';
|
||||||
|
$headers = [
|
||||||
|
['label' => 'Table'],
|
||||||
|
['label' => 'Rows'],
|
||||||
|
];
|
||||||
|
$footer = false;
|
||||||
|
require __DIR__ . '/../partials/table.php';
|
||||||
|
?>
|
||||||
|
<div id="dbgDbTablesStatus" class="hint"></div>
|
||||||
|
<pre id="dbgDbTablePreview" class="pre"></pre>
|
||||||
|
</div>
|
||||||
|
<input id="dbgResetConfirm" type="text" placeholder="<?php echo htmlspecialchars($t('settings.debug.db.ph', 'Type RESET DATABASE'), ENT_QUOTES); ?>">
|
||||||
|
<button id="btnResetDb" class="btn danger" data-i18n="settings.debug.db.reset_btn"<?php echo empty($allowDbReset) ? ' disabled' : ''; ?>><?php echo htmlspecialchars($t('settings.debug.db.reset_btn', 'Reset DB'), ENT_QUOTES); ?></button>
|
||||||
|
<pre id="dbgResetOut" class="pre"></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3 data-i18n="settings.debug.dump.title"><?php echo htmlspecialchars($t('settings.debug.dump.title', 'Database dump'), ENT_QUOTES); ?></h3>
|
||||||
|
<div class="row">
|
||||||
|
<button id="btnDbDump" class="btn" data-i18n="settings.debug.dump.download"><?php echo htmlspecialchars($t('settings.debug.dump.download', 'Download dump'), ENT_QUOTES); ?></button>
|
||||||
|
<input id="dbgRestoreFile" type="file" accept=".sql">
|
||||||
|
<button id="btnDbRestore" class="btn danger" data-i18n="settings.debug.dump.restore"<?php echo empty($allowDbReset) ? ' disabled' : ''; ?>><?php echo htmlspecialchars($t('settings.debug.dump.restore', 'Restore dump'), ENT_QUOTES); ?></button>
|
||||||
|
</div>
|
||||||
|
<pre id="dbgDumpOut" class="pre"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== ABOUT TAB ===================== -->
|
||||||
|
<div class="tabpane" id="pane-about">
|
||||||
|
<div class="pane-header">
|
||||||
|
<h2 data-i18n="settings.about.title"><?php echo htmlspecialchars($t('settings.about.title', 'About'), ENT_QUOTES); ?></h2>
|
||||||
|
</div>
|
||||||
|
<div class="card about-card" data-version>
|
||||||
|
<div class="about-row">
|
||||||
|
<img class="about-logo" src="/assets/icons/scmedia.png" alt="scMedia">
|
||||||
|
<div class="about-body">
|
||||||
|
<div class="about-title">
|
||||||
|
<a class="about-link" href="https://safe-cap.com/software/scMedia">scMedia</a>
|
||||||
|
<span class="version">v0.0.0</span>
|
||||||
|
</div>
|
||||||
|
<div class="about-meta">
|
||||||
|
<span class="about-company">SAFE-CAP</span>
|
||||||
|
<a class="about-link muted" href="https://safe-cap.com/">safe-cap.com</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<?php
|
||||||
|
$id = 'aboutPluginsTable';
|
||||||
|
$tableClass = 'table';
|
||||||
|
$headers = [
|
||||||
|
['label' => $t('settings.about.table_name', 'Name'), 'i18n' => 'settings.about.table_name'],
|
||||||
|
['label' => $t('settings.about.table_type', 'Type'), 'i18n' => 'settings.about.table_type'],
|
||||||
|
['label' => $t('settings.about.table_author', 'Author'), 'i18n' => 'settings.about.table_author'],
|
||||||
|
['label' => $t('settings.about.table_version', 'Version'), 'i18n' => 'settings.about.table_version'],
|
||||||
|
['label' => $t('settings.about.table_update', 'Update'), 'i18n' => 'settings.about.table_update'],
|
||||||
|
];
|
||||||
|
$footer = false;
|
||||||
|
require __DIR__ . '/../partials/table.php';
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="modal" class="modal" style="display:none;">
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div class="modal-title" id="modalTitle"><?php echo htmlspecialchars($t('settings.scan_profiles.modal_edit', 'Edit profile'), ENT_QUOTES); ?></div>
|
||||||
|
<button id="modalClose" class="btn" data-i18n="common.close"><?php echo htmlspecialchars($t('common.close', 'Close'), ENT_QUOTES); ?></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="grid">
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.scan_profiles.modal_name"><?php echo htmlspecialchars($t('settings.scan_profiles.modal_name', 'Name'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="pName" type="text">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.scan_profiles.modal_root"><?php echo htmlspecialchars($t('settings.scan_profiles.modal_root', 'Root path'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="pRoot" type="text">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.scan_profiles.modal_depth"><?php echo htmlspecialchars($t('settings.scan_profiles.modal_depth', 'Max depth'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="pDepth" type="number" min="1" max="10">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.scan_profiles.modal_type"><?php echo htmlspecialchars($t('settings.scan_profiles.modal_type', 'Profile type'), ENT_QUOTES); ?></div>
|
||||||
|
<select id="pType">
|
||||||
|
<option value="scan" data-i18n="settings.scan_profiles.type_scan"><?php echo htmlspecialchars($t('settings.scan_profiles.type_scan', 'Scan'), ENT_QUOTES); ?></option>
|
||||||
|
<option value="analyze" data-i18n="settings.scan_profiles.type_analyze"><?php echo htmlspecialchars($t('settings.scan_profiles.type_analyze', 'Analyze'), ENT_QUOTES); ?></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.scan_profiles.modal_enabled"><?php echo htmlspecialchars($t('settings.scan_profiles.modal_enabled', 'Enabled'), ENT_QUOTES); ?></div>
|
||||||
|
<select id="pEnabled">
|
||||||
|
<option value="1" data-i18n="common.yes"><?php echo htmlspecialchars($t('common.yes', 'Yes'), ENT_QUOTES); ?></option>
|
||||||
|
<option value="0" data-i18n="common.no"><?php echo htmlspecialchars($t('common.no', 'No'), ENT_QUOTES); ?></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.scan_profiles.modal_excludes"><?php echo htmlspecialchars($t('settings.scan_profiles.modal_excludes', 'Exclude patterns'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="pExcludes" type="text">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div class="lbl" data-i18n="settings.scan_profiles.modal_ext_mode"><?php echo htmlspecialchars($t('settings.scan_profiles.modal_ext_mode', 'Include ext mode'), ENT_QUOTES); ?></div>
|
||||||
|
<select id="pExtMode">
|
||||||
|
<option value="default" data-i18n="settings.scan_profiles.ext_default"><?php echo htmlspecialchars($t('settings.scan_profiles.ext_default', 'default'), ENT_QUOTES); ?></option>
|
||||||
|
<option value="custom" data-i18n="settings.scan_profiles.ext_custom"><?php echo htmlspecialchars($t('settings.scan_profiles.ext_custom', 'custom'), ENT_QUOTES); ?></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label id="pExtCustomWrap" style="display:none;">
|
||||||
|
<div class="lbl" data-i18n="settings.scan_profiles.modal_ext_custom"><?php echo htmlspecialchars($t('settings.scan_profiles.modal_ext_custom', 'Custom extensions'), ENT_QUOTES); ?></div>
|
||||||
|
<input id="pExtCustom" type="text">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button id="modalSave" class="btn primary" data-i18n="common.save"><?php echo htmlspecialchars($t('common.save', 'Save'), ENT_QUOTES); ?></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ruleModal" class="modal" style="display:none;">
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div class="modal-title" id="ruleModalTitle">Rule</div>
|
||||||
|
<button id="ruleModalClose" class="btn" data-i18n="common.close"><?php echo htmlspecialchars($t('common.close', 'Close'), ENT_QUOTES); ?></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="ruleModalBody"></div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button id="ruleModalCancel" class="btn" data-i18n="common.close"><?php echo htmlspecialchars($t('common.close', 'Close'), ENT_QUOTES); ?></button>
|
||||||
|
<button id="ruleModalSave" class="btn primary" data-i18n="common.save"><?php echo htmlspecialchars($t('common.save', 'Save'), ENT_QUOTES); ?></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.I18N = <?php echo json_encode($dict, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>;
|
||||||
|
window.APP_LANG = <?php echo json_encode($lang, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>;
|
||||||
|
window.DEBUG_TOOLS_ENABLED = <?php echo !empty($debugToolsEnabled) ? 'true' : 'false'; ?>;
|
||||||
|
</script>
|
||||||
|
<script src="/assets/js/app-config.js"></script>
|
||||||
|
<script src="/assets/js/table.js"></script>
|
||||||
|
<script src="/assets/js/ui.js"></script>
|
||||||
|
<script src="/assets/js/api.js"></script>
|
||||||
|
<script src="/assets/js/prefs.js"></script>
|
||||||
|
<script src="/assets/js/auth.js"></script>
|
||||||
|
<script src="/assets/js/sse.js"></script>
|
||||||
|
<script src="/assets/js/http.js"></script>
|
||||||
|
<script src="/assets/js/settings.js"></script>
|
||||||
|
<script src="/assets/js/admin.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
119
app/views/partials/header.php
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
// app/views/partials/header.php
|
||||||
|
|
||||||
|
$centerTitle = $centerTitle ?? '';
|
||||||
|
$centerI18n = $centerI18n ?? null;
|
||||||
|
$toolbarLeftHtml = $toolbarLeftHtml ?? '';
|
||||||
|
$toolbarRightHtml = $toolbarRightHtml ?? '';
|
||||||
|
$statusText = $statusText ?? '';
|
||||||
|
$statusI18n = $statusI18n ?? null;
|
||||||
|
$statusHidden = $statusHidden ?? ($statusText === '');
|
||||||
|
$statusInToolbar = $statusInToolbar ?? false;
|
||||||
|
$showQueueSummary = $showQueueSummary ?? false;
|
||||||
|
$showTopbarMenu = $showTopbarMenu ?? true;
|
||||||
|
$lang = $lang ?? 'en';
|
||||||
|
$t = $t ?? function(string $key, string $fallback = ''): string {
|
||||||
|
return $fallback !== '' ? $fallback : $key;
|
||||||
|
};
|
||||||
|
|
||||||
|
$statusClass = $statusHidden ? 'status hidden' : 'status';
|
||||||
|
$centerAttr = $centerI18n ? ' data-i18n="' . htmlspecialchars($centerI18n, ENT_QUOTES) . '"' : '';
|
||||||
|
$statusAttr = $statusI18n ? ' data-i18n="' . htmlspecialchars($statusI18n, ENT_QUOTES) . '"' : '';
|
||||||
|
$queueActiveLabel = $t('queue.active', 'Active');
|
||||||
|
$queueErrorsLabel = $t('queue.errors', 'Errors');
|
||||||
|
$supportedLangs = ['en', 'ru', 'de'];
|
||||||
|
$rawPath = (string)($_SERVER['REQUEST_URI'] ?? '/');
|
||||||
|
$currentPath = parse_url($rawPath, PHP_URL_PATH) ?: '/';
|
||||||
|
$currentParts = array_values(array_filter(explode('/', trim($currentPath, '/'))));
|
||||||
|
if (!empty($currentParts) && in_array($currentParts[0], $supportedLangs, true)) {
|
||||||
|
array_shift($currentParts);
|
||||||
|
}
|
||||||
|
$langPathSuffix = '/' . implode('/', $currentParts);
|
||||||
|
if ($langPathSuffix === '/') {
|
||||||
|
$langPathSuffix = '';
|
||||||
|
}
|
||||||
|
$langNextPath = $langPathSuffix === '' ? '/' : $langPathSuffix;
|
||||||
|
$langPrefix = '/' . ($lang ?: 'en');
|
||||||
|
?>
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="left">
|
||||||
|
<a class="product" href="https://safe-cap.com/software/scMedia" aria-label="scMedia">
|
||||||
|
<img class="product-logo" src="/assets/icons/scmedia.png" alt="scMedia">
|
||||||
|
<span class="sr-only">scMedia</span>
|
||||||
|
</a>
|
||||||
|
<span class="sse-indicator" data-sse-indicator aria-hidden="true"></span>
|
||||||
|
<?php if ($showQueueSummary): ?>
|
||||||
|
<div class="queue-wrap">
|
||||||
|
<button id="queueSummary" class="status status-button" type="button" data-i18n="queue.summary">
|
||||||
|
<span data-queue-active data-queue-active-label="<?php echo htmlspecialchars($queueActiveLabel, ENT_QUOTES); ?>">
|
||||||
|
<?php echo htmlspecialchars($queueActiveLabel . ': 0', ENT_QUOTES); ?>
|
||||||
|
</span>
|
||||||
|
<span class="queue-divider is-hidden" data-queue-divider> | </span>
|
||||||
|
<span class="is-hidden" data-queue-errors data-queue-errors-label="<?php echo htmlspecialchars($queueErrorsLabel, ENT_QUOTES); ?>">
|
||||||
|
<?php echo htmlspecialchars($queueErrorsLabel . ': 0', ENT_QUOTES); ?>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div id="queueMenu" class="queue-menu is-hidden">
|
||||||
|
<div class="queue-section">
|
||||||
|
<div class="queue-title" data-i18n="queue.active"><?php echo htmlspecialchars($t('queue.active', 'Active'), ENT_QUOTES); ?></div>
|
||||||
|
<div id="queueMenuActive"></div>
|
||||||
|
</div>
|
||||||
|
<div class="queue-section">
|
||||||
|
<div class="queue-title" data-i18n="queue.finished"><?php echo htmlspecialchars($t('queue.finished', 'Finished'), ENT_QUOTES); ?></div>
|
||||||
|
<div id="queueMenuFinished"></div>
|
||||||
|
</div>
|
||||||
|
<div id="queueSseStats" class="queue-section is-hidden">
|
||||||
|
<div class="queue-title">SSE</div>
|
||||||
|
<div class="queue-stats"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="center"<?php echo $centerAttr; ?>><?php echo htmlspecialchars($centerTitle, ENT_QUOTES); ?></div>
|
||||||
|
<div class="actions">
|
||||||
|
<?php if (!$statusInToolbar): ?>
|
||||||
|
<div id="status" class="<?php echo $statusClass; ?>"<?php echo $statusAttr; ?>>
|
||||||
|
<?php echo htmlspecialchars($statusText, ENT_QUOTES); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div id="job-indicator" class="job-indicator hidden">
|
||||||
|
<span data-i18n="job.running"><?php echo htmlspecialchars($t('job.running', 'Job running'), ENT_QUOTES); ?></span>
|
||||||
|
<progress id="job-indicator-progress" value="0" max="100"></progress>
|
||||||
|
</div>
|
||||||
|
<?php if ($showTopbarMenu): ?>
|
||||||
|
<details class="topbar-menu">
|
||||||
|
<summary class="btn menu-button menu-avatar-button" aria-label="<?php echo htmlspecialchars($t('nav.menu', 'Menu'), ENT_QUOTES); ?>" data-i18n-aria="nav.menu">
|
||||||
|
<img id="headerAvatar" class="menu-avatar" src="/assets/icons/scmedia.png" alt="">
|
||||||
|
</summary>
|
||||||
|
<nav class="menu-dropdown" aria-label="<?php echo htmlspecialchars($t('nav.menu', 'Menu'), ENT_QUOTES); ?>">
|
||||||
|
<a class="menu-item" href="<?php echo $langPrefix; ?>/" data-i18n="nav.home"><?php echo htmlspecialchars($t('nav.home', 'Home'), ENT_QUOTES); ?></a>
|
||||||
|
<a class="menu-item" href="<?php echo $langPrefix; ?>/settings" data-i18n="nav.settings"><?php echo htmlspecialchars($t('nav.settings', 'Settings'), ENT_QUOTES); ?></a>
|
||||||
|
<a class="menu-item" href="<?php echo $langPrefix; ?>/account" data-i18n="nav.account"><?php echo htmlspecialchars($t('nav.account', 'Account'), ENT_QUOTES); ?></a>
|
||||||
|
<div class="menu-divider" role="separator"></div>
|
||||||
|
<div class="menu-inline">
|
||||||
|
<form class="lang-form" method="get" action="/lang">
|
||||||
|
<input type="hidden" name="next" value="<?php echo htmlspecialchars($langNextPath, ENT_QUOTES); ?>">
|
||||||
|
<select name="lang" class="menu-select" aria-label="<?php echo htmlspecialchars($t('settings.language', 'Language'), ENT_QUOTES); ?>" onchange="this.form.submit()">
|
||||||
|
<option value="ru"<?php echo $lang === 'ru' ? ' selected' : ''; ?>>RU</option>
|
||||||
|
<option value="en"<?php echo $lang === 'en' ? ' selected' : ''; ?>>EN</option>
|
||||||
|
<option value="de"<?php echo $lang === 'de' ? ' selected' : ''; ?>>DE</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
<button id="themeToggle" class="menu-item menu-theme" type="button" aria-label="<?php echo htmlspecialchars($t('actions.theme', 'Theme'), ENT_QUOTES); ?>" data-i18n-aria="actions.theme">
|
||||||
|
<span class="menu-theme-icon" aria-hidden="true">◐</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="menu-divider" role="separator"></div>
|
||||||
|
<button id="btnLogout" class="menu-item menu-danger" type="button" data-i18n="auth.logout"><?php echo htmlspecialchars($t('auth.logout', 'Logout'), ENT_QUOTES); ?></button>
|
||||||
|
</nav>
|
||||||
|
</details>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="toolbar">
|
||||||
|
<div class="toolbar-left"><?php echo $toolbarLeftHtml; ?></div>
|
||||||
|
<div class="toolbar-right"><?php echo $toolbarRightHtml; ?></div>
|
||||||
|
</section>
|
||||||
68
app/views/partials/table.php
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
// app/views/partials/table.php
|
||||||
|
// Shared table template. Expects: $id, $headers (array), optional $tableClass, $wrapClass, $attrs, $footer (bool), $tbodyId
|
||||||
|
|
||||||
|
$id = $id ?? '';
|
||||||
|
$headers = is_array($headers ?? null) ? $headers : [];
|
||||||
|
$tableClass = $tableClass ?? '';
|
||||||
|
$wrapClass = $wrapClass ?? '';
|
||||||
|
$attrs = is_array($attrs ?? null) ? $attrs : [];
|
||||||
|
$footer = isset($footer) ? (bool)$footer : true;
|
||||||
|
$tbodyId = $tbodyId ?? '';
|
||||||
|
$filtersEnabled = isset($filtersEnabled) ? (bool)$filtersEnabled : true;
|
||||||
|
|
||||||
|
$id = (string)$id;
|
||||||
|
if ($id !== '' && !array_key_exists('data-table-id', $attrs)) {
|
||||||
|
$attrs['data-table-id'] = $id;
|
||||||
|
}
|
||||||
|
if (!$filtersEnabled && !array_key_exists('data-filters-enabled', $attrs)) {
|
||||||
|
$attrs['data-filters-enabled'] = '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
$attrPairs = '';
|
||||||
|
foreach ($attrs as $k => $v) {
|
||||||
|
$attrPairs .= ' ' . htmlspecialchars((string)$k, ENT_QUOTES) . '="' . htmlspecialchars((string)$v, ENT_QUOTES) . '"';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="table-wrap <?php echo htmlspecialchars($wrapClass, ENT_QUOTES); ?>">
|
||||||
|
<div class="table-tools" data-table-tools>
|
||||||
|
<button class="btn table-filter-toggle" type="button" data-table-filters-toggle>Filters</button>
|
||||||
|
<button class="btn table-view-toggle" type="button" data-table-view-toggle>View</button>
|
||||||
|
<span class="table-filter-summary" data-table-filters-summary></span>
|
||||||
|
</div>
|
||||||
|
<table class="table <?php echo htmlspecialchars($tableClass, ENT_QUOTES); ?>" id="<?php echo htmlspecialchars($id, ENT_QUOTES); ?>"<?php echo $attrPairs; ?>>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<?php foreach ($headers as $h): ?>
|
||||||
|
<?php
|
||||||
|
$label = (string)($h['label'] ?? '');
|
||||||
|
$i18n = (string)($h['i18n'] ?? '');
|
||||||
|
$sort = (string)($h['sort'] ?? '');
|
||||||
|
$class = (string)($h['class'] ?? '');
|
||||||
|
$key = (string)($h['key'] ?? '');
|
||||||
|
$filter = $h['filter'] ?? null;
|
||||||
|
$filterJson = is_array($filter) ? json_encode($filter) : '';
|
||||||
|
?>
|
||||||
|
<th<?php echo $class !== '' ? ' class="' . htmlspecialchars($class, ENT_QUOTES) . '"' : ''; ?>
|
||||||
|
<?php echo $i18n !== '' ? ' data-i18n="' . htmlspecialchars($i18n, ENT_QUOTES) . '"' : ''; ?>
|
||||||
|
<?php echo $sort !== '' ? ' data-sort="' . htmlspecialchars($sort, ENT_QUOTES) . '"' : ''; ?>
|
||||||
|
<?php echo $key !== '' ? ' data-key="' . htmlspecialchars($key, ENT_QUOTES) . '"' : ''; ?>
|
||||||
|
<?php echo $filterJson !== '' ? ' data-filter="' . htmlspecialchars($filterJson, ENT_QUOTES) . '"' : ''; ?>>
|
||||||
|
<?php echo htmlspecialchars($label, ENT_QUOTES); ?>
|
||||||
|
</th>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody<?php echo $tbodyId !== '' ? ' id="' . htmlspecialchars((string)$tbodyId, ENT_QUOTES) . '"' : ''; ?>></tbody>
|
||||||
|
</table>
|
||||||
|
<?php if ($footer): ?>
|
||||||
|
<div class="table-footer" data-table-footer>
|
||||||
|
<div class="table-meta" data-table-meta></div>
|
||||||
|
<div class="table-controls" data-table-controls>
|
||||||
|
<button class="btn" type="button" data-table-prev>‹</button>
|
||||||
|
<span class="table-page" data-table-page></span>
|
||||||
|
<button class="btn" type="button" data-table-next>›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
31
cli/scan.php
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
// cli/scan.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$services = require __DIR__ . '/../app/bootstrap.php';
|
||||||
|
|
||||||
|
$jobs = $services['jobs'];
|
||||||
|
$scanner = $services['scanner'];
|
||||||
|
|
||||||
|
$jobId = $jobs->create('scan', 'Scan (CLI)', [
|
||||||
|
'source' => 'cli',
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo "Queued job id={$jobId}\n";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$jobs->start($jobId);
|
||||||
|
$result = $scanner->runScanJob($jobId);
|
||||||
|
$jobs->finish($jobId);
|
||||||
|
|
||||||
|
echo "OK: scan finished\n";
|
||||||
|
echo "Profiles: " . ($result['profiles'] ?? 0) . "\n";
|
||||||
|
echo "Items seen: " . ($result['items_seen'] ?? 0) . "\n";
|
||||||
|
exit(0);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$jobs->log($jobId, 'error', $e->getMessage());
|
||||||
|
$jobs->fail($jobId, $e->getMessage());
|
||||||
|
fwrite(STDERR, "ERROR: " . $e->getMessage() . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
81
cli/worker.php
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
// cli/worker.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$services = require __DIR__ . '/../app/bootstrap.php';
|
||||||
|
|
||||||
|
$config = $services['config'];
|
||||||
|
|
||||||
|
$jobs = $services['jobs'];
|
||||||
|
$scanner = $services['scanner'];
|
||||||
|
$schema = $services['schema'];
|
||||||
|
$db = $services['db'];
|
||||||
|
|
||||||
|
$sleep = 2;
|
||||||
|
if (isset($config['app']['worker_sleep_seconds'])) {
|
||||||
|
$sleep = (int)$config['app']['worker_sleep_seconds'];
|
||||||
|
}
|
||||||
|
if ($sleep < 1) $sleep = 1;
|
||||||
|
|
||||||
|
echo "scmedia-worker started (sleep={$sleep}s)\n";
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
$job = $jobs->fetchNextQueued();
|
||||||
|
if (!$job) {
|
||||||
|
sleep($sleep);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobId = (int)$job['id'];
|
||||||
|
$type = (string)$job['type'];
|
||||||
|
|
||||||
|
echo "Picked job id={$jobId} type={$type}\n";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$jobs->start($jobId);
|
||||||
|
$jobs->log($jobId, 'info', "Worker started job type={$type}");
|
||||||
|
|
||||||
|
if ($type === 'scan') {
|
||||||
|
$scanner->runScanJob($jobId);
|
||||||
|
$jobs->finish($jobId);
|
||||||
|
$jobs->log($jobId, 'info', "Worker finished scan");
|
||||||
|
} elseif ($type === 'db_reset') {
|
||||||
|
$cfg = $config['app'] ?? [];
|
||||||
|
if (empty($cfg['allow_db_reset'])) {
|
||||||
|
throw new Exception("DB reset is disabled by config (app.allow_db_reset=false)");
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobs->setProgress($jobId, 0, 1);
|
||||||
|
$log = [];
|
||||||
|
$schema->resetDatabase($log);
|
||||||
|
foreach ($log as $line) {
|
||||||
|
$jobs->log($jobId, 'info', (string)$line);
|
||||||
|
}
|
||||||
|
$jobs->setProgress($jobId, 1, 1);
|
||||||
|
$jobs->finish($jobId);
|
||||||
|
} elseif ($type === 'clear_index') {
|
||||||
|
$jobs->setProgress($jobId, 0, 3);
|
||||||
|
|
||||||
|
$db->exec("DELETE FROM items");
|
||||||
|
$jobs->setProgress($jobId, 1, 3);
|
||||||
|
$jobs->log($jobId, 'info', "items cleared");
|
||||||
|
|
||||||
|
// Note: keep jobs table; we clear logs for old jobs only (optional)
|
||||||
|
$db->exec("DELETE FROM job_logs WHERE job_id <> :id", [':id' => $jobId]);
|
||||||
|
$jobs->setProgress($jobId, 2, 3);
|
||||||
|
$jobs->log($jobId, 'info', "job_logs cleared (except current)");
|
||||||
|
|
||||||
|
$jobs->setProgress($jobId, 3, 3);
|
||||||
|
$jobs->finish($jobId);
|
||||||
|
} else {
|
||||||
|
throw new Exception("Unsupported job type: {$type}");
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$jobs->log($jobId, 'error', $e->getMessage());
|
||||||
|
$jobs->fail($jobId, $e->getMessage());
|
||||||
|
fwrite(STDERR, "Job {$jobId} failed: " . $e->getMessage() . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
}
|
||||||
86
config/config.php
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
// config/config.php
|
||||||
|
|
||||||
|
$envBool = static function(string $key, bool $default): bool {
|
||||||
|
$val = getenv($key);
|
||||||
|
if ($val === false) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
$bool = filter_var($val, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||||
|
return $bool ?? $default;
|
||||||
|
};
|
||||||
|
|
||||||
|
$envInt = static function(string $key, int $default): int {
|
||||||
|
$val = getenv($key);
|
||||||
|
if ($val === false || $val === '') {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
return (int)$val;
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'db' => [
|
||||||
|
'dsn' => (getenv('DB_DSN') ?: ('mysql:host=' . (getenv('DB_HOST') ?: '127.0.0.1') . ';port=' . (getenv('DB_PORT') ?: '3306') . ';dbname=' . (getenv('DB_NAME') ?: 'scmedia') . ';charset=utf8mb4')),
|
||||||
|
'user' => (getenv('DB_USER') ?: 'scmedia'),
|
||||||
|
'pass' => (getenv('DB_PASS') ?: 'changeme'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'app' => [
|
||||||
|
// Generate once and keep immutable for this instance
|
||||||
|
'app_id' => (getenv('APP_ID') ?: 'CHANGE_ME_UUID'),
|
||||||
|
'env' => (getenv('APP_ENV') ?: 'development'), // development|production
|
||||||
|
'backend_version' => (getenv('BACKEND_VERSION') ?: (getenv('APP_VERSION') ?: '0.1.0')),
|
||||||
|
'db_version' => (getenv('DB_VERSION') ?: '0.1.0'),
|
||||||
|
'plugins_version' => (getenv('PLUGINS_VERSION') ?: '0.1.0'),
|
||||||
|
|
||||||
|
// Debug tools UI visibility
|
||||||
|
'debug_tools_enabled' => $envBool('DEBUG', false),
|
||||||
|
'debug_logs_enabled' => $envBool('DEBUG', false),
|
||||||
|
'debug_keys_enabled' => $envBool('DEBUG_KEYS', false),
|
||||||
|
|
||||||
|
// Dangerous: allows /api/debug/reset-db
|
||||||
|
'allow_db_reset' => $envBool('ALLOW_DB_RESET', true),
|
||||||
|
|
||||||
|
'worker_sleep_seconds' => $envInt('WORKER_SLEEP_SECONDS', 2),
|
||||||
|
],
|
||||||
|
|
||||||
|
'paths' => [
|
||||||
|
'incoming' => (getenv('PATH_INCOMING') ?: '/data/incoming'),
|
||||||
|
'movies' => (getenv('PATH_MOVIES') ?: '/data/movies'),
|
||||||
|
'series' => (getenv('PATH_SERIES') ?: '/data/series'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'video_ext' => array_values(array_filter(array_map('trim', explode(',', (getenv('VIDEO_EXT') ?: 'mkv,mp4,avi,mov,m4v,ts,m2ts,wmv'))))),
|
||||||
|
|
||||||
|
'scanner' => [
|
||||||
|
'max_depth' => $envInt('SCANNER_MAX_DEPTH', 3),
|
||||||
|
'max_files_per_item' => $envInt('SCANNER_MAX_FILES_PER_ITEM', 3000),
|
||||||
|
],
|
||||||
|
|
||||||
|
'auth' => [
|
||||||
|
'jwt_secret' => (getenv('JWT_SECRET') ?: 'CHANGE_ME_JWT_SECRET'),
|
||||||
|
'access_ttl_seconds' => $envInt('ACCESS_TTL_SECONDS', 900),
|
||||||
|
'refresh_ttl_web_days' => $envInt('REFRESH_TTL_WEB_DAYS', 14),
|
||||||
|
'refresh_ttl_mobile_days' => $envInt('REFRESH_TTL_MOBILE_DAYS', 30),
|
||||||
|
'challenge_ttl_seconds' => $envInt('AUTH_CHALLENGE_TTL_SECONDS', 300),
|
||||||
|
'password_reset_ttl_minutes' => $envInt('PASSWORD_RESET_TTL_MINUTES', 30),
|
||||||
|
'max_devices' => $envInt('AUTH_MAX_DEVICES', 5),
|
||||||
|
'return_reset_token' => $envBool('AUTH_RETURN_RESET_TOKEN', false),
|
||||||
|
'rate_limit_login_max' => $envInt('AUTH_LOGIN_MAX', 10),
|
||||||
|
'rate_limit_login_window' => $envInt('AUTH_LOGIN_WINDOW_SECONDS', 900),
|
||||||
|
'rate_limit_login_block' => $envInt('AUTH_LOGIN_BLOCK_SECONDS', 900),
|
||||||
|
'rate_limit_mfa_max' => $envInt('AUTH_MFA_MAX', 6),
|
||||||
|
'rate_limit_mfa_window' => $envInt('AUTH_MFA_WINDOW_SECONDS', 900),
|
||||||
|
'rate_limit_mfa_block' => $envInt('AUTH_MFA_BLOCK_SECONDS', 900),
|
||||||
|
'sse_key_ttl_seconds' => $envInt('AUTH_SSE_KEY_TTL_SECONDS', 90),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Hard safety rails (GUI cannot exceed)
|
||||||
|
'safety' => [
|
||||||
|
'hard_limits' => [
|
||||||
|
'max_depth' => 10,
|
||||||
|
'max_files_per_item' => 200000,
|
||||||
|
'max_items_per_scan' => 1000000,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
98
docker-compose.yml
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: scmedia_nginx
|
||||||
|
depends_on:
|
||||||
|
- php
|
||||||
|
ports:
|
||||||
|
- "8088:80"
|
||||||
|
volumes:
|
||||||
|
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
- ./:/var/www:ro
|
||||||
|
networks:
|
||||||
|
- scmedia
|
||||||
|
|
||||||
|
php:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/php/Dockerfile
|
||||||
|
container_name: scmedia_php
|
||||||
|
environment:
|
||||||
|
APP_ENV: ${APP_ENV:-production}
|
||||||
|
APP_ID: ${APP_ID:-CHANGE_ME_UUID}
|
||||||
|
DEBUG_TOOLS_ENABLED: ${DEBUG_TOOLS_ENABLED:-false}
|
||||||
|
ALLOW_DB_RESET: ${ALLOW_DB_RESET:-false}
|
||||||
|
WORKER_SLEEP_SECONDS: ${WORKER_SLEEP_SECONDS:-2}
|
||||||
|
DB_HOST: ${DB_HOST:-db}
|
||||||
|
DB_PORT: ${DB_PORT:-3306}
|
||||||
|
DB_NAME: ${DB_NAME:-scmedia}
|
||||||
|
DB_USER: ${DB_USER:-scmedia}
|
||||||
|
DB_PASS: ${DB_PASS:-changeme}
|
||||||
|
DB_ROOT_PASS: ${DB_ROOT_PASS:-rootpass}
|
||||||
|
PATH_INCOMING: ${PATH_INCOMING:-/data/incoming}
|
||||||
|
PATH_MOVIES: ${PATH_MOVIES:-/data/movies}
|
||||||
|
PATH_SERIES: ${PATH_SERIES:-/data/series}
|
||||||
|
VIDEO_EXT: ${VIDEO_EXT:-mkv,mp4,avi,mov,m4v,ts,m2ts,wmv}
|
||||||
|
SCANNER_MAX_DEPTH: ${SCANNER_MAX_DEPTH:-3}
|
||||||
|
SCANNER_MAX_FILES_PER_ITEM: ${SCANNER_MAX_FILES_PER_ITEM:-3000}
|
||||||
|
volumes:
|
||||||
|
- ./:/var/www
|
||||||
|
# where scMedia scans media (bind-mount your CephFS path here)
|
||||||
|
- ./_data:/data
|
||||||
|
# writable storage
|
||||||
|
- scmedia_storage:/var/www/storage
|
||||||
|
networks:
|
||||||
|
- scmedia
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/php/Dockerfile
|
||||||
|
container_name: scmedia_worker
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
environment:
|
||||||
|
APP_ENV: ${APP_ENV:-production}
|
||||||
|
APP_ID: ${APP_ID:-CHANGE_ME_UUID}
|
||||||
|
DEBUG_TOOLS_ENABLED: ${DEBUG_TOOLS_ENABLED:-false}
|
||||||
|
ALLOW_DB_RESET: ${ALLOW_DB_RESET:-false}
|
||||||
|
WORKER_SLEEP_SECONDS: ${WORKER_SLEEP_SECONDS:-2}
|
||||||
|
DB_HOST: ${DB_HOST:-db}
|
||||||
|
DB_PORT: ${DB_PORT:-3306}
|
||||||
|
DB_NAME: ${DB_NAME:-scmedia}
|
||||||
|
DB_USER: ${DB_USER:-scmedia}
|
||||||
|
DB_PASS: ${DB_PASS:-changeme}
|
||||||
|
DB_ROOT_PASS: ${DB_ROOT_PASS:-rootpass}
|
||||||
|
PATH_INCOMING: ${PATH_INCOMING:-/data/incoming}
|
||||||
|
PATH_MOVIES: ${PATH_MOVIES:-/data/movies}
|
||||||
|
PATH_SERIES: ${PATH_SERIES:-/data/series}
|
||||||
|
VIDEO_EXT: ${VIDEO_EXT:-mkv,mp4,avi,mov,m4v,ts,m2ts,wmv}
|
||||||
|
SCANNER_MAX_DEPTH: ${SCANNER_MAX_DEPTH:-3}
|
||||||
|
SCANNER_MAX_FILES_PER_ITEM: ${SCANNER_MAX_FILES_PER_ITEM:-3000}
|
||||||
|
command: ["php", "cli/worker.php"]
|
||||||
|
volumes:
|
||||||
|
- ./:/var/www
|
||||||
|
- ./_data:/data
|
||||||
|
- scmedia_storage:/var/www/storage
|
||||||
|
networks:
|
||||||
|
- scmedia
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: mariadb:11
|
||||||
|
container_name: scmedia_db
|
||||||
|
environment:
|
||||||
|
MARIADB_DATABASE: ${DB_NAME:-scmedia}
|
||||||
|
MARIADB_USER: ${DB_USER:-scmedia}
|
||||||
|
MARIADB_PASSWORD: ${DB_PASS:-changeme}
|
||||||
|
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASS:-rootpass}
|
||||||
|
volumes:
|
||||||
|
- scmedia_db:/var/lib/mysql
|
||||||
|
networks:
|
||||||
|
- scmedia
|
||||||
|
|
||||||
|
networks:
|
||||||
|
scmedia:
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
scmedia_db:
|
||||||
|
scmedia_storage:
|
||||||
42
docker/nginx/default.conf
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /var/www/public;
|
||||||
|
index index.php;
|
||||||
|
|
||||||
|
# Basic hardening
|
||||||
|
client_max_body_size 64m;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.php?$query_string;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /api/events {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
|
||||||
|
fastcgi_param SCRIPT_NAME /index.php;
|
||||||
|
fastcgi_param QUERY_STRING $query_string;
|
||||||
|
fastcgi_param PATH_INFO /api/events;
|
||||||
|
fastcgi_param DOCUMENT_ROOT $document_root;
|
||||||
|
fastcgi_pass php:9000;
|
||||||
|
fastcgi_read_timeout 3600;
|
||||||
|
fastcgi_send_timeout 3600;
|
||||||
|
fastcgi_keep_conn on;
|
||||||
|
fastcgi_buffering off;
|
||||||
|
fastcgi_cache off;
|
||||||
|
add_header X-Accel-Buffering no;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ \.php$ {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
fastcgi_param DOCUMENT_ROOT $document_root;
|
||||||
|
fastcgi_pass php:9000;
|
||||||
|
fastcgi_read_timeout 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ /(\.ht|\.git) {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
docker/php/Dockerfile
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
FROM php:8.3-fpm-alpine
|
||||||
|
|
||||||
|
# System deps
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
bash \
|
||||||
|
icu-libs icu-data-full \
|
||||||
|
curl \
|
||||||
|
libzip \
|
||||||
|
mariadb-client \
|
||||||
|
&& apk add --no-cache --virtual .build-deps \
|
||||||
|
curl-dev \
|
||||||
|
icu-dev \
|
||||||
|
libzip-dev \
|
||||||
|
&& docker-php-ext-install \
|
||||||
|
pdo \
|
||||||
|
pdo_mysql \
|
||||||
|
intl \
|
||||||
|
curl \
|
||||||
|
&& apk del .build-deps
|
||||||
|
|
||||||
|
WORKDIR /var/www
|
||||||
|
|
||||||
|
# Copy app
|
||||||
|
COPY . /var/www
|
||||||
|
|
||||||
|
# PHP-FPM extra pools
|
||||||
|
COPY docker/php/conf/events.conf /usr/local/etc/php-fpm.d/events.conf
|
||||||
|
|
||||||
|
# Writable dirs (create even if you don't use yet)
|
||||||
|
RUN mkdir -p /var/www/storage/logs \
|
||||||
|
&& chown -R www-data:www-data /var/www/storage
|
||||||
|
|
||||||
|
RUN chmod +x /var/www/docker/scripts/entrypoint.sh \
|
||||||
|
&& chmod +x /var/www/docker/scripts/init-db.sh \
|
||||||
|
&& chmod +x /var/www/docker/scripts/wait-for-db.sh
|
||||||
|
|
||||||
|
USER www-data
|
||||||
|
|
||||||
|
ENTRYPOINT ["/var/www/docker/scripts/entrypoint.sh"]
|
||||||
|
CMD ["php-fpm", "-F"]
|
||||||
10
docker/php/conf/events.conf
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
[events]
|
||||||
|
user = www-data
|
||||||
|
group = www-data
|
||||||
|
listen = 9001
|
||||||
|
pm = ondemand
|
||||||
|
pm.max_children = 40
|
||||||
|
pm.process_idle_timeout = 120s
|
||||||
|
request_terminate_timeout = 0
|
||||||
|
php_admin_value[max_execution_time] = 0
|
||||||
|
php_admin_value[default_socket_timeout] = 3600
|
||||||
48
docker/scripts/entrypoint.sh
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_ENV="${APP_ENV:-production}"
|
||||||
|
APP_ID="${APP_ID:-}"
|
||||||
|
|
||||||
|
if [ -z "${APP_ID}" ]; then
|
||||||
|
if command -v uuidgen >/dev/null 2>&1; then
|
||||||
|
APP_ID="$(uuidgen)"
|
||||||
|
else
|
||||||
|
APP_ID="$(python3 - <<'PY'
|
||||||
|
import uuid
|
||||||
|
print(uuid.uuid4())
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
ENV_FILE="/var/www/.env"
|
||||||
|
if [ ! -f "${ENV_FILE}" ]; then
|
||||||
|
cat > "${ENV_FILE}" <<EOF
|
||||||
|
APP_ENV=${APP_ENV}
|
||||||
|
APP_ID=${APP_ID}
|
||||||
|
DEBUG_TOOLS_ENABLED=${DEBUG_TOOLS_ENABLED:-false}
|
||||||
|
ALLOW_DB_RESET=${ALLOW_DB_RESET:-false}
|
||||||
|
WORKER_SLEEP_SECONDS=${WORKER_SLEEP_SECONDS:-2}
|
||||||
|
|
||||||
|
DB_HOST=${DB_HOST:-db}
|
||||||
|
DB_PORT=${DB_PORT:-3306}
|
||||||
|
DB_NAME=${DB_NAME:-scmedia}
|
||||||
|
DB_USER=${DB_USER:-scmedia}
|
||||||
|
DB_PASS=${DB_PASS:-changeme}
|
||||||
|
|
||||||
|
PATH_INCOMING=${PATH_INCOMING:-/data/incoming}
|
||||||
|
PATH_MOVIES=${PATH_MOVIES:-/data/movies}
|
||||||
|
PATH_SERIES=${PATH_SERIES:-/data/series}
|
||||||
|
|
||||||
|
VIDEO_EXT=${VIDEO_EXT:-mkv,mp4,avi,mov,m4v,ts,m2ts,wmv}
|
||||||
|
SCANNER_MAX_DEPTH=${SCANNER_MAX_DEPTH:-3}
|
||||||
|
SCANNER_MAX_FILES_PER_ITEM=${SCANNER_MAX_FILES_PER_ITEM:-3000}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${AUTO_INIT_DB:-1}" = "1" ]; then
|
||||||
|
bash /var/www/docker/scripts/init-db.sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
100
docker/scripts/init-db.sh
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HOST="${DB_HOST:-db}"
|
||||||
|
PORT="${DB_PORT:-3306}"
|
||||||
|
NAME="${DB_NAME:-scmedia}"
|
||||||
|
USER="${DB_USER:-scmedia}"
|
||||||
|
PASS="${DB_PASS:-changeme}"
|
||||||
|
ROOT_PASS="${DB_ROOT_PASS:-${MARIADB_ROOT_PASSWORD:-}}"
|
||||||
|
MODE=""
|
||||||
|
ENV_PATH=""
|
||||||
|
|
||||||
|
while [ "${#}" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--destroy)
|
||||||
|
MODE="--destroy"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--env)
|
||||||
|
ENV_PATH="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "${ENV_PATH}" ]; then
|
||||||
|
if [ ! -f "${ENV_PATH}" ]; then
|
||||||
|
echo "Env file not found: ${ENV_PATH}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "${ENV_PATH}"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
HOST="${DB_HOST:-db}"
|
||||||
|
PORT="${DB_PORT:-3306}"
|
||||||
|
NAME="${DB_NAME:-scmedia}"
|
||||||
|
USER="${DB_USER:-scmedia}"
|
||||||
|
PASS="${DB_PASS:-changeme}"
|
||||||
|
ROOT_PASS="${DB_ROOT_PASS:-${MARIADB_ROOT_PASSWORD:-}}"
|
||||||
|
PROTO_ARGS=()
|
||||||
|
if [ "${HOST}" = "localhost" ] || [ "${HOST}" = "127.0.0.1" ]; then
|
||||||
|
PROTO_ARGS=(--protocol=tcp)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${ROOT_PASS}" ]; then
|
||||||
|
echo "Missing DB_ROOT_PASS/MARIADB_ROOT_PASSWORD for init" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if mysqladmin ping -h"${HOST}" -P"${PORT}" "${PROTO_ARGS[@]}" -uroot -p"${ROOT_PASS}" --silent; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "${MODE}" = "--destroy" ]; then
|
||||||
|
if mysql -h"${HOST}" -P"${PORT}" "${PROTO_ARGS[@]}" -uroot -p"${ROOT_PASS}" -N -e \
|
||||||
|
"SELECT 1 FROM mysql.user WHERE User='${USER}' AND Host='%';" | grep -q 1; then
|
||||||
|
mysql -h"${HOST}" -P"${PORT}" "${PROTO_ARGS[@]}" -uroot -p"${ROOT_PASS}" -e \
|
||||||
|
"REVOKE ALL PRIVILEGES, GRANT OPTION FROM '${USER}'@'%'; FLUSH PRIVILEGES;"
|
||||||
|
mysql -h"${HOST}" -P"${PORT}" "${PROTO_ARGS[@]}" -uroot -p"${ROOT_PASS}" -e \
|
||||||
|
"DROP USER '${USER}'@'%';"
|
||||||
|
fi
|
||||||
|
mysql -h"${HOST}" -P"${PORT}" "${PROTO_ARGS[@]}" -uroot -p"${ROOT_PASS}" -e \
|
||||||
|
"DROP DATABASE IF EXISTS \`${NAME}\`;"
|
||||||
|
echo "Destroyed database/user/privileges"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mysql -h"${HOST}" -P"${PORT}" "${PROTO_ARGS[@]}" -uroot -p"${ROOT_PASS}" -e \
|
||||||
|
"CREATE DATABASE IF NOT EXISTS \`${NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||||
|
mysql -h"${HOST}" -P"${PORT}" "${PROTO_ARGS[@]}" -uroot -p"${ROOT_PASS}" -e \
|
||||||
|
"CREATE USER IF NOT EXISTS '${USER}'@'%' IDENTIFIED BY '${PASS}';"
|
||||||
|
mysql -h"${HOST}" -P"${PORT}" "${PROTO_ARGS[@]}" -uroot -p"${ROOT_PASS}" -e \
|
||||||
|
"GRANT ALL PRIVILEGES ON \`${NAME}\`.* TO '${USER}'@'%'; FLUSH PRIVILEGES;"
|
||||||
|
|
||||||
|
if mysql -h"${HOST}" -P"${PORT}" "${PROTO_ARGS[@]}" -u"${USER}" -p"${PASS}" "${NAME}" -e "SHOW TABLES LIKE 'items'" | grep -q items; then
|
||||||
|
echo "Schema already present, skip import"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCHEMA_PATH="${SCHEMA_PATH:-/var/www/sql/schema.sql}"
|
||||||
|
if [ ! -f "${SCHEMA_PATH}" ]; then
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||||
|
if [ -f "${PROJECT_ROOT}/sql/schema.sql" ]; then
|
||||||
|
SCHEMA_PATH="${PROJECT_ROOT}/sql/schema.sql"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Importing schema.sql from ${SCHEMA_PATH}..."
|
||||||
|
mysql -h"${HOST}" -P"${PORT}" "${PROTO_ARGS[@]}" -u"${USER}" -p"${PASS}" "${NAME}" < "${SCHEMA_PATH}"
|
||||||
|
echo "Done"
|
||||||
17
docker/scripts/wait-for-db.sh
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HOST="${DB_HOST:-db}"
|
||||||
|
PORT="${DB_PORT:-3306}"
|
||||||
|
USER="${DB_USER:-scmedia}"
|
||||||
|
PASS="${DB_PASS:-changeme}"
|
||||||
|
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if mysqladmin ping -h"${HOST}" -P"${PORT}" -u"${USER}" -p"${PASS}" --silent; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "DB not ready after 60s" >&2
|
||||||
|
exit 1
|
||||||
38
public/assets/assets/branding/scmedia/CODEX.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# Codex import instructions (scMedia brand kit)
|
||||||
|
|
||||||
|
Goal: place assets into the repo and wire them into the UI.
|
||||||
|
|
||||||
|
## Recommended repo paths
|
||||||
|
|
||||||
|
- `public/assets/branding/scmedia/logo/*`
|
||||||
|
- `public/assets/branding/scmedia/icons/*`
|
||||||
|
- `public/assets/branding/scmedia/ui/*`
|
||||||
|
- `public/assets/branding/scmedia/manifest.json`
|
||||||
|
|
||||||
|
If your repo already uses a different static-assets convention, follow that convention instead.
|
||||||
|
|
||||||
|
## How to use
|
||||||
|
|
||||||
|
1) Use `logo/logo_horizontal_on_dark.png` in the top-left header (wordmark).
|
||||||
|
2) Use `logo/app_icon_square_256.png` for favicon/app icon sources; downscale as needed.
|
||||||
|
3) Use the icon shapes from `icons/icon_grid_on_dark.png` as the visual reference to implement your icon components.
|
||||||
|
|
||||||
|
## Icon semantics mapping
|
||||||
|
|
||||||
|
Use the following icon meanings consistently:
|
||||||
|
- Scan: start scanning jobs
|
||||||
|
- Refresh: reload lists / rescan UI state
|
||||||
|
- Preview: preview rename/move plan
|
||||||
|
- Apply/Execute: apply planned changes
|
||||||
|
- Settings: open configuration
|
||||||
|
- Job running: show running task status (spinner-like)
|
||||||
|
- Movie: movie category
|
||||||
|
- Series: TV series category
|
||||||
|
- Auto detect: auto-detect naming/structure
|
||||||
|
- Error/Warning: error state
|
||||||
|
- Skipped/Ignored: ignore / skip state
|
||||||
|
|
||||||
|
## Design constraints
|
||||||
|
|
||||||
|
- Dark-first UI, high contrast, soft rounded geometry.
|
||||||
|
- Prefer monochrome icons with accent usage only for states (primary action, error, success).
|
||||||
31
public/assets/assets/branding/scmedia/README.md
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# scMedia Brand Kit (v1)
|
||||||
|
|
||||||
|
This archive contains the generated **scMedia** logo + icon style reference + a UI preview, designed for a dark-first, minimalist, technical interface.
|
||||||
|
|
||||||
|
## Included files
|
||||||
|
|
||||||
|
- `logo/logo_on_dark.png` — hero logo composition (dark background)
|
||||||
|
- `logo/logo_horizontal_on_dark.png` — horizontal lockup (symbol + wordmark)
|
||||||
|
- `logo/app_icon_square_*.png` — square app icon exports (64/128/256/512)
|
||||||
|
- `icons/icon_grid_on_dark.png` — icon set grid reference (style + shapes)
|
||||||
|
- `ui/ui_preview_on_dark.png` — UI preview using icons instead of text labels
|
||||||
|
- `manifest.json` — palette + suggested paths + icon semantic mapping
|
||||||
|
|
||||||
|
## Color palette
|
||||||
|
|
||||||
|
Base:
|
||||||
|
- `#0b0f14`
|
||||||
|
- `#0f1620`
|
||||||
|
- `#111a25`
|
||||||
|
|
||||||
|
Accent:
|
||||||
|
- `#3b82f6` (blue)
|
||||||
|
- `#38bdf8` (cyan)
|
||||||
|
|
||||||
|
Status:
|
||||||
|
- Error: `#ef4444`
|
||||||
|
- Success: `#22c55e`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This kit is **PNG-based** (raster). If you want **SVG icons**, tell me the icon style preference (outline vs solid, stroke width), and I’ll generate a vector set.
|
||||||
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 123 KiB |
BIN
public/assets/assets/branding/scmedia/logo/logo_on_dark.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
78
public/assets/assets/branding/scmedia/manifest.json
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"project": "scMedia",
|
||||||
|
"palette": {
|
||||||
|
"base": [
|
||||||
|
"#0b0f14",
|
||||||
|
"#0f1620",
|
||||||
|
"#111a25"
|
||||||
|
],
|
||||||
|
"accent": [
|
||||||
|
"#3b82f6",
|
||||||
|
"#38bdf8"
|
||||||
|
],
|
||||||
|
"error": "#ef4444",
|
||||||
|
"success": "#22c55e"
|
||||||
|
},
|
||||||
|
"assets": {
|
||||||
|
"logo": {
|
||||||
|
"primary": "assets/branding/scmedia/logo/logo_on_dark.png",
|
||||||
|
"horizontal": "assets/branding/scmedia/logo/logo_horizontal_on_dark.png",
|
||||||
|
"app_icon": {
|
||||||
|
"512": "assets/branding/scmedia/logo/app_icon_square_512.png",
|
||||||
|
"256": "assets/branding/scmedia/logo/app_icon_square_256.png",
|
||||||
|
"128": "assets/branding/scmedia/logo/app_icon_square_128.png",
|
||||||
|
"64": "assets/branding/scmedia/logo/app_icon_square_64.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"grid": "assets/branding/scmedia/icons/icon_grid_on_dark.png"
|
||||||
|
},
|
||||||
|
"ui_preview": "assets/branding/scmedia/ui/ui_preview_on_dark.png"
|
||||||
|
},
|
||||||
|
"icon_semantics": [
|
||||||
|
{
|
||||||
|
"name": "scan",
|
||||||
|
"meaning": "Start scan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "refresh",
|
||||||
|
"meaning": "Refresh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "preview",
|
||||||
|
"meaning": "Preview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "apply_execute",
|
||||||
|
"meaning": "Apply / Execute"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "settings",
|
||||||
|
"meaning": "Settings"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "job_running",
|
||||||
|
"meaning": "Job / Task running"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "movie",
|
||||||
|
"meaning": "Movie"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "series",
|
||||||
|
"meaning": "Series"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "auto_detect",
|
||||||
|
"meaning": "Auto detect"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "error_warning",
|
||||||
|
"meaning": "Error / Warning"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "skipped_ignored",
|
||||||
|
"meaning": "Skipped / Ignored"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
public/assets/assets/branding/scmedia/ui/ui_preview_on_dark.png
Normal file
|
After Width: | Height: | Size: 344 KiB |
41
public/assets/css/account.css
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
/* public/assets/account.css */
|
||||||
|
|
||||||
|
.account-layout {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-profile {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-img {
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
border-radius: 12px;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-fields {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.account-profile {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
public/assets/css/admin.css
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th,
|
||||||
|
.admin-table td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
96
public/assets/css/auth.css
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-layout {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24px 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 12px 32px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-lang {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-lang .lang-form {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.ghost {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card .muted {
|
||||||
|
margin: 0 0 18px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form label.auth-remember {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-direction: row;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-remember input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #7f1d1d;
|
||||||
|
color: #fecaca;
|
||||||
|
background: rgba(127, 29, 29, 0.2);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mfa-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
599
public/assets/css/common.css
Normal file
@ -0,0 +1,599 @@
|
|||||||
|
:root {
|
||||||
|
font-family: "Trebuchet MS", "Lucida Sans", "Lucida Grande", Arial, sans-serif;
|
||||||
|
--bg: #0b0f14;
|
||||||
|
--bg-elev: #0f1620;
|
||||||
|
--bg-elev-2: #0c131c;
|
||||||
|
--text: #e8eef6;
|
||||||
|
--muted: #a7b2c4;
|
||||||
|
--border: #1d2632;
|
||||||
|
--control-bg: #111a25;
|
||||||
|
--control-border: #233145;
|
||||||
|
--table-stripe: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg: #f5f7fb;
|
||||||
|
--bg-elev: #ffffff;
|
||||||
|
--bg-elev-2: #f0f3f8;
|
||||||
|
--text: #1c2430;
|
||||||
|
--muted: #5a6475;
|
||||||
|
--border: #d9e0ea;
|
||||||
|
--control-bg: #ffffff;
|
||||||
|
--control-border: #cdd6e3;
|
||||||
|
--table-stripe: rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar .left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar .center {
|
||||||
|
justify-self: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-logo {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
justify-self: end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.8;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sse-indicator {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 6px;
|
||||||
|
background: #64748b;
|
||||||
|
box-shadow: 0 0 0 0 rgba(148, 163, 184, 0);
|
||||||
|
transition: background 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sse-indicator.sse-ok {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sse-indicator.sse-idle {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sse-indicator.sse-offline {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sse-indicator.sse-blink {
|
||||||
|
box-shadow: 0 0 0 6px rgba(34, 197, 94, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-stats {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.85;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.status-button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 34px;
|
||||||
|
left: 0;
|
||||||
|
min-width: 280px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
z-index: 20;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-divider {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-menu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-menu summary {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-menu summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-button {
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-avatar-button {
|
||||||
|
padding: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-avatar {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 36px;
|
||||||
|
right: 0;
|
||||||
|
min-width: 150px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 6px;
|
||||||
|
z-index: 30;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.25);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-label {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.8;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 4px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-inline .menu-label {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-inline .menu-select {
|
||||||
|
width: auto;
|
||||||
|
min-width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-form {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-apply {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-theme {
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-theme-icon {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:hover {
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item.is-active {
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-danger {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-section + .queue-section {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-title {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item-status {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item-actions .btn {
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.dirty {
|
||||||
|
color: #ef4444;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left,
|
||||||
|
.toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, button, .btn {
|
||||||
|
background: var(--control-bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--control-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filter-summary {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filter-row {
|
||||||
|
display: none;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap.filters-open .table-filter-row {
|
||||||
|
display: table-row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filter-row td {
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filter-row input,
|
||||||
|
.table-filter-row select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filter-range {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filter-range input {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.has-filter {
|
||||||
|
position: relative;
|
||||||
|
padding-right: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.has-filter .filter-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--control-border);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.filter-active .filter-indicator {
|
||||||
|
background: #3b82f6;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-view-dialog {
|
||||||
|
max-width: 840px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-view-body {
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-view-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-view-table th,
|
||||||
|
.table-view-table td {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-view-table th:nth-child(1),
|
||||||
|
.table-view-table td:nth-child(1),
|
||||||
|
.table-view-table th:nth-child(6),
|
||||||
|
.table-view-table td:nth-child(6) {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-view-row td:nth-child(3) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-view-row input[type="number"] {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-view-row .btn {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-controls {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-page {
|
||||||
|
min-width: 70px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th.sortable,
|
||||||
|
.grid th.sortable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:nth-child(even),
|
||||||
|
.grid tbody tr:nth-child(even) {
|
||||||
|
background: var(--table-stripe);
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary,
|
||||||
|
.btn.primary {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger,
|
||||||
|
.btn.danger {
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled,
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-select {
|
||||||
|
width: auto;
|
||||||
|
min-width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid var(--control-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.topbar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.topbar .center {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.topbar .left {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
481
public/assets/css/settings.css
Normal file
@ -0,0 +1,481 @@
|
|||||||
|
:root {
|
||||||
|
--panel-bg: #0f1620;
|
||||||
|
--tab-bg: #121b27;
|
||||||
|
--tab-active-bg: #1a2140;
|
||||||
|
--tab-active-border: #2a3570;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--panel-bg: #ffffff;
|
||||||
|
--tab-bg: #f0f3f8;
|
||||||
|
--tab-active-bg: #e1e7f1;
|
||||||
|
--tab-active-border: #c3cfde;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px 1fr;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TABS */
|
||||||
|
.tabs{
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* buttons (без изменений по сути) */
|
||||||
|
.tab{
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--tab-bg);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active{
|
||||||
|
background: var(--tab-active-bg);
|
||||||
|
border-color: var(--tab-active-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* footer always at bottom */
|
||||||
|
.tabs-footer{
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 0 10px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-footer .btn{
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2, h3 {
|
||||||
|
margin: 8px 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--panel-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-table-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-table-row:hover {
|
||||||
|
background: var(--table-stripe);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-card {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.85;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-link {
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-link.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.warn {
|
||||||
|
border-color: #4e3b00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.danger {
|
||||||
|
border-color: #5a1d1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid:not(table) {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid:not(table) .full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions .table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-builder {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(160px, 1fr) minmax(120px, 160px) minmax(200px, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cond-value select[multiple] {
|
||||||
|
min-height: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lbl {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.85;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--control-bg);
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-error {
|
||||||
|
border-color: #b02a2a;
|
||||||
|
box-shadow: 0 0 0 1px #b02a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: none;
|
||||||
|
min-width: 200px;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 6px 20px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu button {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu button:hover {
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown.open .dropdown-menu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-sort {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th, .table td {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 10px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
text-align: left;
|
||||||
|
opacity: 0.85;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-row.is-off td {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.75;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checks {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-title {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pre {
|
||||||
|
background: var(--control-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 300px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabpane { display: none; }
|
||||||
|
.tabpane.active { display: block; }
|
||||||
|
|
||||||
|
.logs-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
flex-direction: row;
|
||||||
|
border-right: none;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-tabs .tab {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-end;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-filters {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-row label {
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-filters label {
|
||||||
|
max-width: none;
|
||||||
|
width: 160px;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#logsControlsShared label {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-filters label select,
|
||||||
|
.logs-filters label input[type="date"] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#logsControlsShared,
|
||||||
|
#logsControlsLevel,
|
||||||
|
#logsControlsEvent {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-row .hint {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-actions {
|
||||||
|
align-items: center;
|
||||||
|
flex-basis: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#logsControlsShared,
|
||||||
|
#logsControlsLevel,
|
||||||
|
#logsControlsEvent {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#logsControlsShared label,
|
||||||
|
#logsControlsLevel label,
|
||||||
|
#logsControlsEvent label {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-actions .btn {
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-actions .hint {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-pane { display: none; }
|
||||||
|
.logs-pane.active { display: block; }
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--control-bg);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card {
|
||||||
|
width: 860px;
|
||||||
|
max-width: 100%;
|
||||||
|
background: var(--panel-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head, .modal-foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-foot {
|
||||||
|
border-bottom: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.layout { grid-template-columns: 1fr; }
|
||||||
|
.tabs { display: flex; gap: 8px; border-right: none; border-bottom: 1px solid var(--border); }
|
||||||
|
.tab { width: auto; }
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
.plugin-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
419
public/assets/css/style.css
Normal file
@ -0,0 +1,419 @@
|
|||||||
|
/* public/assets/style.css */
|
||||||
|
|
||||||
|
.actions button,
|
||||||
|
.actions .btn {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding: 10px 14px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-tabs .tab {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-tabs .tab.active {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid th, .grid td {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 8px 8px;
|
||||||
|
vertical-align: top;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid th {
|
||||||
|
text-align: left;
|
||||||
|
opacity: 0.85;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-panel {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-status {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-counts {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-active {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-tasks {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-task-btn {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-btn {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--control-bg);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row td {
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-meta {
|
||||||
|
padding: 6px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-box {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 8px 0 12px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-current {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-line {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 110px 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-search {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-search input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-results {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-result {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 46px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 6px;
|
||||||
|
background: var(--control-bg);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-result:hover {
|
||||||
|
border-color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-poster {
|
||||||
|
width: 46px;
|
||||||
|
height: 68px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-result-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-result-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-manual {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-manual input {
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-table th, .detail-table td {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 6px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-right: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.editable {
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.editable input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
color: var(--text);
|
||||||
|
width: max-content;
|
||||||
|
max-width: 92vw;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
padding: 12px 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-builder {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builder-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builder-row label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builder-row .full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-builder .checks {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-builder .checks .row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-field {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-field .lbl {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-raw {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-files {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-preview {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-tree {
|
||||||
|
list-style: none;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-tree li {
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-folder {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-meta {
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.incoming-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.source-meta {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.source-fields {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.builder-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.task-builder .checks {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------
|
||||||
|
Global job indicator
|
||||||
|
----------------------- */
|
||||||
|
|
||||||
|
.job-indicator progress {
|
||||||
|
width: 140px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
543
public/assets/i18n/de.json
Normal file
@ -0,0 +1,543 @@
|
|||||||
|
{
|
||||||
|
"app.title": "scMedia",
|
||||||
|
|
||||||
|
"types.auto": "Auto",
|
||||||
|
"types.movie": "Film",
|
||||||
|
"types.series": "Serie",
|
||||||
|
|
||||||
|
"actions.scan": "Scannen",
|
||||||
|
"actions.refresh": "Aktualisieren",
|
||||||
|
"actions.preview": "Vorschau",
|
||||||
|
"actions.apply": "Anwenden",
|
||||||
|
"actions.settings": "Einstellungen",
|
||||||
|
"actions.theme": "Thema",
|
||||||
|
|
||||||
|
"bulk.type": "Typ",
|
||||||
|
"bulk.year": "Jahr",
|
||||||
|
"bulk.set": "Setzen",
|
||||||
|
"bulk.clear": "Löschen",
|
||||||
|
"bulk.skip": "Überspringen",
|
||||||
|
"bulk.unskip": "Zurücknehmen",
|
||||||
|
"bulk.year_placeholder": "Jahr",
|
||||||
|
|
||||||
|
"filters.status": "Status",
|
||||||
|
"filters.issues": "Probleme",
|
||||||
|
"filters.all": "Alle",
|
||||||
|
"filters.has_issues": "Mit Problemen",
|
||||||
|
"filters.no_issues": "Ohne Probleme",
|
||||||
|
|
||||||
|
"grid.type": "Typ",
|
||||||
|
"grid.name": "Name",
|
||||||
|
"grid.year": "Jahr",
|
||||||
|
"grid.raw": "Originalname",
|
||||||
|
"grid.source": "Quelle",
|
||||||
|
"grid.videos": "Videos",
|
||||||
|
"grid.issues": "Probleme",
|
||||||
|
"grid.status": "Status",
|
||||||
|
"grid.path": "Pfad",
|
||||||
|
"grid.structure": "Struktur",
|
||||||
|
"grid.files": "Dateien",
|
||||||
|
|
||||||
|
"status.scanned": "Gescannt",
|
||||||
|
"status.draft": "Entwurf",
|
||||||
|
"status.planned": "Geplant",
|
||||||
|
"status.applied": "Angewendet",
|
||||||
|
"status.error": "Fehler",
|
||||||
|
"status.skipped": "Übersprungen",
|
||||||
|
"status.active": "Aktiv",
|
||||||
|
"status.gone": "Nicht vorhanden",
|
||||||
|
"status.ignored": "Ignoriert",
|
||||||
|
"status.ok": "OK",
|
||||||
|
"status.needs": "Benötigt",
|
||||||
|
"theme.light": "Hell",
|
||||||
|
"theme.dark": "Dunkel",
|
||||||
|
"app.section.library": "Bibliothek",
|
||||||
|
|
||||||
|
"preview.title": "Vorschau",
|
||||||
|
"preview.operations": "Operationen",
|
||||||
|
"preview.conflicts": "Konflikte",
|
||||||
|
|
||||||
|
"job.running": "Aufgabe läuft",
|
||||||
|
"job.title": "Aufgabe",
|
||||||
|
"job.cancel_confirm": "Job abbrechen?",
|
||||||
|
"queue.status": "Warteschlange",
|
||||||
|
"queue.pause": "Pause",
|
||||||
|
"queue.resume": "Fortsetzen",
|
||||||
|
"queue.cancel_active": "Aktiven abbrechen",
|
||||||
|
"queue.paused": "Pause",
|
||||||
|
"queue.running": "Laufend",
|
||||||
|
"queue.queued": "Wartend",
|
||||||
|
"queue.errors": "Fehler",
|
||||||
|
"queue.summary": "Aktiv: {active} | Fehler: {errors}",
|
||||||
|
"queue.active": "Aktiv",
|
||||||
|
"queue.finished": "Abgeschlossen",
|
||||||
|
"queue.none_active": "Keine aktiven Aufgaben",
|
||||||
|
"queue.none_finished": "Keine abgeschlossenen Aufgaben",
|
||||||
|
"queue.cancel": "Abbrechen",
|
||||||
|
"tasks.run": "Aufgabe starten",
|
||||||
|
"settings.tasks.modal_title": "Aufgabe",
|
||||||
|
"settings.tasks.field_name": "Name",
|
||||||
|
"settings.tasks.field_sources": "Quellen",
|
||||||
|
"settings.tasks.field_actions": "Aktionen",
|
||||||
|
"settings.tasks.field_enabled": "Aktiv",
|
||||||
|
"settings.tasks.source.library": "Bibliothek",
|
||||||
|
"settings.tasks.source.transmission": "Transmission",
|
||||||
|
"settings.tasks.source.staging": "Staging",
|
||||||
|
"settings.tasks.action.analyze": "Analyse",
|
||||||
|
"settings.tasks.action.identify": "Identifizieren",
|
||||||
|
"settings.tasks.action.normalize": "Normalisieren",
|
||||||
|
"settings.tasks.action.rename": "Umbenennen",
|
||||||
|
"settings.tasks.action.export": "Export",
|
||||||
|
"settings.tasks.status.on": "An",
|
||||||
|
"settings.tasks.status.off": "Aus",
|
||||||
|
"settings.tasks.confirm_delete": "Aufgabe loschen?",
|
||||||
|
"job.status": "Status",
|
||||||
|
|
||||||
|
"errors.scan_failed": "Scan fehlgeschlagen",
|
||||||
|
"errors.preview_failed": "Vorschau fehlgeschlagen",
|
||||||
|
"errors.apply_failed": "Anwendung fehlgeschlagen",
|
||||||
|
"errors.job_fetch": "Fehler beim Abrufen des Aufgabenstatus",
|
||||||
|
|
||||||
|
"messages.scan_finished": "Scan abgeschlossen",
|
||||||
|
"messages.apply_started": "Anwendung gestartet",
|
||||||
|
|
||||||
|
"common.none": "—",
|
||||||
|
"common.close": "Schließen",
|
||||||
|
"common.back": "Zurück",
|
||||||
|
"common.save": "Speichern",
|
||||||
|
"common.cancel": "Abbrechen",
|
||||||
|
"common.export": "Export",
|
||||||
|
"common.edit": "Bearbeiten",
|
||||||
|
"common.delete": "Löschen",
|
||||||
|
"common.never": "nie",
|
||||||
|
"common.loading": "Laden…",
|
||||||
|
"common.loading_settings": "Einstellungen werden geladen…",
|
||||||
|
"common.loaded": "Geladen",
|
||||||
|
"common.saving": "Speichern…",
|
||||||
|
"common.saved": "Gespeichert",
|
||||||
|
"common.unsaved_changes": "Nicht gespeicherte Änderungen",
|
||||||
|
"settings.unsaved_confirm": "Nicht gespeicherte Änderungen. Einstellungen verlassen?",
|
||||||
|
"common.testing": "Prüfen…",
|
||||||
|
"common.running": "Läuft…",
|
||||||
|
"common.error": "Fehler",
|
||||||
|
"common.exists": "exists",
|
||||||
|
"common.read": "read",
|
||||||
|
"common.write": "write",
|
||||||
|
"common.rename": "rename",
|
||||||
|
"common.generating_preview": "Vorschau wird erzeugt…",
|
||||||
|
"common.na": "k. A.",
|
||||||
|
"common.test": "Test",
|
||||||
|
"common.yes": "Ja",
|
||||||
|
"common.no": "Nein",
|
||||||
|
"meta.title": "Titel",
|
||||||
|
"meta.original": "Original",
|
||||||
|
"meta.year": "Jahr",
|
||||||
|
"meta.provider": "Provider",
|
||||||
|
"meta.source": "Quelle",
|
||||||
|
"meta.search": "Suchen",
|
||||||
|
"meta.search_placeholder": "Titel suchen",
|
||||||
|
"meta.manual_title": "Manueller Titel",
|
||||||
|
"meta.manual_year": "Jahr",
|
||||||
|
"meta.save": "Speichern",
|
||||||
|
"meta.clear": "Zurücksetzen",
|
||||||
|
"meta.no_results": "Keine Ergebnisse",
|
||||||
|
|
||||||
|
"settings.page_title": "scMedia / Einstellungen",
|
||||||
|
"settings.title": "Einstellungen",
|
||||||
|
"settings.back": "Zurück",
|
||||||
|
"settings.tabs.scan_profiles": "Scan-Profile",
|
||||||
|
"settings.tabs.library_layout": "Bibliothek",
|
||||||
|
"settings.tabs.plugins": "Plugins",
|
||||||
|
"settings.tabs.tasks": "Aufgaben",
|
||||||
|
"settings.tabs.rules": "Regeln",
|
||||||
|
"settings.tabs.tools": "Programme",
|
||||||
|
"settings.tabs.ui": "Interface",
|
||||||
|
"settings.tabs.logs": "Logs",
|
||||||
|
"settings.tabs.debug": "Debug",
|
||||||
|
"settings.tabs.about": "Info",
|
||||||
|
"settings.scan_profiles.title": "Scan-Profile",
|
||||||
|
"settings.scan_profiles.add": "Profil hinzufügen",
|
||||||
|
"settings.scan_profiles.modal_add": "Profil hinzufügen",
|
||||||
|
"settings.scan_profiles.modal_edit": "Profil bearbeiten",
|
||||||
|
"settings.scan_profiles.confirm_delete": "Profil löschen?",
|
||||||
|
"settings.scan_profiles.ext_default": "Standard",
|
||||||
|
"settings.scanner_defaults.title": "Globale Scanner-Defaults",
|
||||||
|
"settings.library_layout.title": "Library",
|
||||||
|
"settings.library_layout.preview": "Vorschau",
|
||||||
|
"settings.library_layout.roots": "Ordner",
|
||||||
|
"settings.library_layout.add_root": "Hinzufuegen",
|
||||||
|
"settings.library_layout.th_type": "Typ",
|
||||||
|
"settings.library_layout.th_path": "Pfad",
|
||||||
|
"settings.library_layout.th_status": "Status",
|
||||||
|
"settings.library_layout.modal_title": "Ordner",
|
||||||
|
"settings.library_layout.confirm_delete": "Ordner löschen?",
|
||||||
|
"root.type.movie": "Filme",
|
||||||
|
"root.type.series": "Serien",
|
||||||
|
"root.type.staging": "Staging",
|
||||||
|
"settings.rules.add_rule": "Regel hinzufügen",
|
||||||
|
|
||||||
|
"rules.type.name_map": "Namensmapping",
|
||||||
|
"rules.type.delete_track": "Tracks löschen",
|
||||||
|
"rules.type.priorities": "Prioritäten",
|
||||||
|
"rules.type.lang_fix": "Sprache korrigieren",
|
||||||
|
"rules.type.source_filter": "Quelle filtern",
|
||||||
|
"rules.sort_by": "Sortieren",
|
||||||
|
"rules.sort.name": "Name",
|
||||||
|
"rules.sort.type": "Typ",
|
||||||
|
"rules.th.name": "Name",
|
||||||
|
"rules.th.type": "Typ",
|
||||||
|
"rules.th.summary": "Zusammenfassung",
|
||||||
|
"rules.th.status": "Status",
|
||||||
|
"rules.status.on": "An",
|
||||||
|
"rules.status.off": "Aus",
|
||||||
|
"rules.enable": "Aktivieren",
|
||||||
|
"rules.disable": "Deaktivieren",
|
||||||
|
"rules.unnamed": "Unbenannt",
|
||||||
|
"rules.confirm_delete": "Regel löschen?",
|
||||||
|
"rules.modal_title": "Regel",
|
||||||
|
"rules.field.name": "Name",
|
||||||
|
"rules.field.enabled": "Aktiv",
|
||||||
|
"rules.field.pattern": "Muster",
|
||||||
|
"rules.field.canonical": "Kanonisch",
|
||||||
|
"rules.field.mode": "Modus",
|
||||||
|
"rules.field.track_type": "Track-Typ",
|
||||||
|
"rules.field.lang": "Sprache",
|
||||||
|
"rules.field.audio_type": "Audio-Typ",
|
||||||
|
"rules.field.name_contains": "Name enthält",
|
||||||
|
"rules.field.except_default": "Außer default",
|
||||||
|
"rules.field.except_forced": "Außer forced",
|
||||||
|
"rules.field.languages": "Sprachen (Komma)",
|
||||||
|
"rules.field.audio_types": "Audio-Typen (Komma)",
|
||||||
|
"rules.field.from_lang": "Sprache von",
|
||||||
|
"rules.field.to_lang": "Sprache nach",
|
||||||
|
"rules.field.source": "Quelle",
|
||||||
|
"rules.field.status": "Status",
|
||||||
|
"rules.field.conditions": "Bedingungen",
|
||||||
|
"rules.cond.field": "Feld",
|
||||||
|
"rules.cond.op": "Op",
|
||||||
|
"rules.cond.value": "Wert",
|
||||||
|
"rules.cond.enabled": "Aktiv",
|
||||||
|
"rules.cond.add": "Bedingung hinzufügen",
|
||||||
|
"rules.cond.status": "Status",
|
||||||
|
"rules.cond.label": "Label",
|
||||||
|
"rules.cond.name_regex": "Name Regex",
|
||||||
|
"rules.cond.path_regex": "Pfad Regex",
|
||||||
|
"rules.cond.min_size": "Min. Groesse",
|
||||||
|
"rules.op.contains": "enthaelt",
|
||||||
|
"rules.op.not_contains": "enthaelt nicht",
|
||||||
|
"rules.op.any": "beliebig",
|
||||||
|
"rules.logic.or": "ODER",
|
||||||
|
"rules.statuses.none": "Keine Status",
|
||||||
|
"rules.field.label": "Label",
|
||||||
|
"rules.field.name_regex": "Name Regex",
|
||||||
|
"rules.field.path_regex": "Pfad Regex",
|
||||||
|
"rules.field.min_size": "Min. Größe (Bytes)",
|
||||||
|
"rules.mode.exact": "exakt",
|
||||||
|
"rules.mode.regex": "regex",
|
||||||
|
"rules.any": "Beliebig",
|
||||||
|
"sources.name": "Name",
|
||||||
|
"sources.size": "Größe",
|
||||||
|
"sources.status": "Status",
|
||||||
|
"sources.progress": "Fertig",
|
||||||
|
"sources.type": "Typ",
|
||||||
|
"sources.type.file": "Datei",
|
||||||
|
"sources.type.folder": "Ordner",
|
||||||
|
"sources.files": "Dateien",
|
||||||
|
"sources.detail.title": "Quellen-Details",
|
||||||
|
"sources.detail.raw": "Raw data",
|
||||||
|
"sources.detail.approve": "Bestaetigen",
|
||||||
|
"sources.preview.title": "Vorschau",
|
||||||
|
"sources.preview.current": "Aktuell",
|
||||||
|
"sources.preview.planned": "Geplant",
|
||||||
|
"sources.preview.name": "Name",
|
||||||
|
"sources.preview.kind": "Typ",
|
||||||
|
"sources.preview.structure": "Struktur",
|
||||||
|
"sources.status.completed": "abgeschlossen",
|
||||||
|
"sources.status.downloading": "download",
|
||||||
|
"sources.status.seeding": "seeding",
|
||||||
|
"sources.status.stopped": "gestoppt",
|
||||||
|
"sources.status.unknown": "unbekannt",
|
||||||
|
"sources.source": "Quelle",
|
||||||
|
"sources.path": "Pfad",
|
||||||
|
"settings.plugins.title": "Plugins",
|
||||||
|
"settings.plugins.add": "Plugin hinzufügen",
|
||||||
|
"settings.plugins.meta_settings": "Metadaten-Einstellungen",
|
||||||
|
"settings.plugins.omdb_label": "IMDb (OMDb)",
|
||||||
|
"settings.plugins.tvdb_label": "TVDB",
|
||||||
|
"settings.plugins.kodi_label": "Kodi",
|
||||||
|
"settings.plugins.jellyfin_label": "Jellyfin",
|
||||||
|
"settings.plugins.transmission_label": "Transmission",
|
||||||
|
"settings.plugins.th_name": "Plugin",
|
||||||
|
"settings.plugins.th_type": "Typ",
|
||||||
|
"settings.plugins.th_status": "Status",
|
||||||
|
"settings.plugins.kind.meta": "Metadaten",
|
||||||
|
"settings.plugins.kind.export": "Export",
|
||||||
|
"settings.plugins.kind.source": "Quelle",
|
||||||
|
"settings.plugins.modal_title": "Plugin",
|
||||||
|
"settings.plugins.requires_meta": "Metadaten zuerst aktivieren",
|
||||||
|
"settings.plugins.requires_test": "Verbindung zuerst testen",
|
||||||
|
"settings.plugins.install_placeholder": "Installation kommt bald",
|
||||||
|
"settings.plugins.metadata": "Metadaten-Provider",
|
||||||
|
"settings.plugins.languages": "Metadaten-Sprachen (Komma)",
|
||||||
|
"settings.plugins.provider_priority": "Provider-Priorität (Komma)",
|
||||||
|
"settings.plugins.enable": "Aktiviert",
|
||||||
|
"settings.plugins.omdb_key": "OMDb API-Schlüssel",
|
||||||
|
"settings.plugins.tvdb_key": "TVDB API-Schlüssel",
|
||||||
|
"settings.plugins.tvdb_pin": "TVDB PIN (optional)",
|
||||||
|
"settings.plugins.exports": "Export-Plugins",
|
||||||
|
"settings.plugins.sources": "Source-Plugins",
|
||||||
|
"settings.plugins.transmission_protocol": "Protokoll",
|
||||||
|
"settings.plugins.transmission_host": "Host",
|
||||||
|
"settings.plugins.transmission_port": "Port",
|
||||||
|
"settings.plugins.transmission_path": "RPC-Pfad",
|
||||||
|
"settings.plugins.transmission_user": "Benutzername",
|
||||||
|
"settings.plugins.transmission_pass": "Passwort",
|
||||||
|
"settings.plugins.transmission_display_fields": "Anzeigefelder (Komma)",
|
||||||
|
"settings.plugins.transmission_test": "Verbindung testen",
|
||||||
|
"settings.plugins.transmission_ok": "OK",
|
||||||
|
"settings.plugins.transmission_missing": "Pflichtfelder ausfüllen",
|
||||||
|
"settings.plugins.transmission_unauthorized": "Nicht autorisiert. RPC-Benutzer/Passwort prüfen.",
|
||||||
|
"settings.plugins.transmission_forbidden": "Zugriff verweigert. Whitelist oder Zugangsdaten prüfen.",
|
||||||
|
"settings.plugins.transmission_rpc_failed": "RPC fehlgeschlagen. Adresse oder Zugriff prüfen.",
|
||||||
|
"settings.plugins.kodi_hint": "Schreibt movie.nfo / tvshow.nfo neben Dateien",
|
||||||
|
"settings.plugins.jellyfin_hint": "Schreibt movie.nfo / tvshow.nfo neben Dateien",
|
||||||
|
"settings.debug.title": "Debug",
|
||||||
|
"settings.debug.content.title": "Inhaltsdaten",
|
||||||
|
"settings.debug.content.files": "Medien-Dateien",
|
||||||
|
"settings.debug.content.meta": "Metadaten",
|
||||||
|
"settings.debug.content.items": "Indizierte Elemente",
|
||||||
|
"settings.debug.content.clear_btn": "Inhalt löschen",
|
||||||
|
"settings.debug.db.title": "Datenbank",
|
||||||
|
"settings.debug.db.tables": "Tabellen",
|
||||||
|
"settings.debug.db.size": "Groesse",
|
||||||
|
"settings.debug.db.reset_btn": "DB zurücksetzen",
|
||||||
|
"settings.debug.dump.title": "Datenbank-Dump",
|
||||||
|
"settings.debug.dump.download": "Dump herunterladen",
|
||||||
|
"settings.debug.dump.restore": "Dump wiederherstellen",
|
||||||
|
"settings.debug.dump.restore_confirm": "Datenbank aus dem Dump wiederherstellen? Aktuelle Daten werden überschrieben.",
|
||||||
|
"settings.language": "Sprache",
|
||||||
|
"settings.scan_profiles.th_on": "An",
|
||||||
|
"settings.scan_profiles.th_type": "Typ",
|
||||||
|
"settings.scan_profiles.th_name": "Name",
|
||||||
|
"settings.scan_profiles.th_root": "Pfad",
|
||||||
|
"settings.scan_profiles.th_depth": "Tiefe",
|
||||||
|
"settings.scan_profiles.th_excludes": "Ausschlüsse",
|
||||||
|
"settings.scan_profiles.th_ext": "Ext",
|
||||||
|
"settings.scan_profiles.th_last_scan": "Letzter Scan",
|
||||||
|
"settings.scan_profiles.th_result": "Ergebnis",
|
||||||
|
|
||||||
|
"settings.scanner_defaults.video_ext": "Video-Endungen (Komma)",
|
||||||
|
"settings.scanner_defaults.video_ext_ph": "mkv,mp4,avi",
|
||||||
|
"settings.scanner_defaults.max_depth": "Max. Tiefe (Default)",
|
||||||
|
"settings.scanner_defaults.max_files": "Max. Dateien pro Element",
|
||||||
|
"settings.scanner_defaults.max_items": "Max. Elemente pro Scan (0 = ohne Limit)",
|
||||||
|
|
||||||
|
"settings.library_layout.movies_root": "Filme-Ordner",
|
||||||
|
"settings.library_layout.movies_root_ph": "/mnt/media/library/movies",
|
||||||
|
"settings.library_layout.series_root": "Serien-Ordner",
|
||||||
|
"settings.library_layout.series_root_ph": "/mnt/media/library/series",
|
||||||
|
"settings.library_layout.staging_root": "Staging (optional)",
|
||||||
|
"settings.library_layout.staging_root_ph": "/mnt/media/.staging",
|
||||||
|
|
||||||
|
"settings.library_layout.movies_strategy_title": "Filme-Strategie",
|
||||||
|
"settings.library_layout.series_strategy_title": "Serien-Strategie",
|
||||||
|
"settings.library_layout.strategy": "Strategie",
|
||||||
|
"settings.library_layout.season_naming": "Staffelbezeichnung",
|
||||||
|
"settings.library_layout.normalization_title": "Normalisierung",
|
||||||
|
"settings.library_layout.collision_title": "Kollisionsregel",
|
||||||
|
"settings.library_layout.preview_title": "Vorschau",
|
||||||
|
"settings.library_layout.preview_hint": "Klicke auf „Vorschau erzeugen“. ",
|
||||||
|
|
||||||
|
"settings.strategy.flat": "Flach",
|
||||||
|
"settings.strategy.first_letter": "Nach erstem Buchstaben",
|
||||||
|
"settings.strategy.prefix": "Nach den ersten N Buchstaben",
|
||||||
|
"settings.strategy.hash_buckets": "Nach Zahlen-Buckets (Hash)",
|
||||||
|
"settings.strategy.by_year": "Nach Jahr",
|
||||||
|
"settings.strategy.letter_year": "Buchstabe + Jahr",
|
||||||
|
"settings.strategy.decade_year": "Jahrzehnt + Jahr",
|
||||||
|
"settings.strategy.custom": "Eigenes Template",
|
||||||
|
"settings.strategy.n": "N",
|
||||||
|
"settings.strategy.buckets": "Buckets",
|
||||||
|
"settings.strategy.template": "Template",
|
||||||
|
"settings.strategy.template_ph_movies": "{first:2}/{title} ({year})",
|
||||||
|
"settings.strategy.template_ph_series": "{first}/{title}",
|
||||||
|
"settings.strategy.vars_hint": "Variablen: {title} {year} {decade} {first} {first:2} {first:3} {hash:2}",
|
||||||
|
|
||||||
|
"settings.season.season_2digit": "Season 01",
|
||||||
|
"settings.season.s_2digit": "S01",
|
||||||
|
"settings.season.season_plain": "Season 1",
|
||||||
|
|
||||||
|
"settings.norm.ignore_articles": "Artikel ignorieren (The/A/An)",
|
||||||
|
"settings.norm.uppercase_shards": "Shard-Ordner groß schreiben",
|
||||||
|
"settings.norm.replace_unsafe": "Unsichere Zeichen ersetzen",
|
||||||
|
"settings.norm.trim_dots": "Punkte/Leerzeichen trimmen",
|
||||||
|
"settings.norm.transliterate_later": "Nicht-lateinisch transliterieren (später)",
|
||||||
|
"settings.norm.ignore_words": "Ignore-Wörter (Komma)",
|
||||||
|
"settings.norm.ignore_words_ph": "sample,extras",
|
||||||
|
|
||||||
|
"settings.collision.stop": "Stoppen und Konflikt markieren",
|
||||||
|
"settings.collision.append_num": "Mit -1, -2 anhängen",
|
||||||
|
"settings.collision.append_hash": "Kurzen Hash anhängen",
|
||||||
|
|
||||||
|
"settings.debug.content.ph": "CLEAR CONTENT eingeben",
|
||||||
|
"settings.debug.db.ph": "RESET DATABASE eingeben",
|
||||||
|
|
||||||
|
"settings.scan_profiles.modal_title": "Profil",
|
||||||
|
"settings.scan_profiles.modal_enabled": "Aktiv",
|
||||||
|
"settings.scan_profiles.modal_name": "Name",
|
||||||
|
"settings.scan_profiles.modal_name_ph": "Incoming",
|
||||||
|
"settings.scan_profiles.modal_root": "Pfad",
|
||||||
|
"settings.scan_profiles.modal_root_ph": "/mnt/downloads/complete",
|
||||||
|
"settings.scan_profiles.modal_depth": "Max. Tiefe",
|
||||||
|
"settings.scan_profiles.modal_type": "Profiltyp",
|
||||||
|
"settings.scan_profiles.type_scan": "Suche",
|
||||||
|
"settings.scan_profiles.type_analyze": "Analyse",
|
||||||
|
"settings.scan_profiles.move": "Verschieben",
|
||||||
|
"settings.scan_profiles.modal_excludes": "Ausschlussmuster (Komma)",
|
||||||
|
"settings.scan_profiles.modal_excludes_ph": "@eaDir,sample,extras",
|
||||||
|
"settings.scan_profiles.modal_ext_mode": "Endungen-Modus",
|
||||||
|
"settings.scan_profiles.ext_custom": "Eigen",
|
||||||
|
"settings.scan_profiles.modal_ext_custom": "Eigene Endungen (Komma)",
|
||||||
|
"settings.scan_profiles.modal_ext_custom_ph": "mkv,mp4",
|
||||||
|
"settings.about.title": "Info",
|
||||||
|
"settings.about.ui_version": "UI-Version",
|
||||||
|
"settings.about.backend_version": "Backend-Version",
|
||||||
|
"settings.about.db_version": "DB-Version",
|
||||||
|
"settings.about.table_name": "Name",
|
||||||
|
"settings.about.table_type": "Typ",
|
||||||
|
"settings.about.table_author": "Autor",
|
||||||
|
"settings.about.table_version": "Version",
|
||||||
|
"settings.about.table_update": "Update",
|
||||||
|
|
||||||
|
"media.tabs.movies": "Filme",
|
||||||
|
"media.tabs.series": "Serien",
|
||||||
|
"media.tabs.sources": "Quellen",
|
||||||
|
"media.container": "Container",
|
||||||
|
"media.size": "Größe",
|
||||||
|
"media.duration": "Dauer",
|
||||||
|
"media.track.type": "Typ",
|
||||||
|
"media.track.lang": "Sprache",
|
||||||
|
"media.track.name": "Name",
|
||||||
|
"media.track.codec": "Codec",
|
||||||
|
"media.track.channels": "Kanäle",
|
||||||
|
"media.track.flags": "Flags",
|
||||||
|
"media.track.audio_type": "Audiotyp",
|
||||||
|
"media.actions.dry_run": "Dry run",
|
||||||
|
"media.actions.apply": "Apply",
|
||||||
|
"media.dry_run.title": "Dry run",
|
||||||
|
"media.dry_run.summary": "Zusammenfassung",
|
||||||
|
"media.dry_run.files": "Dateien",
|
||||||
|
"media.dry_run.rename": "Umbenennen",
|
||||||
|
"media.dry_run.delete": "Löschen",
|
||||||
|
"media.dry_run.unknown": "Unbekannter Typ",
|
||||||
|
"media.dry_run.convert": "Konvertieren",
|
||||||
|
"media.dry_run.details": "Details",
|
||||||
|
|
||||||
|
"settings.rules.title": "Regeln",
|
||||||
|
"settings.rules.name_map": "Namensmapping",
|
||||||
|
"settings.rules.pattern": "Muster",
|
||||||
|
"settings.rules.canonical": "Kanonisch",
|
||||||
|
"settings.rules.mode": "Modus",
|
||||||
|
"settings.rules.mode_exact": "genau",
|
||||||
|
"settings.rules.mode_regex": "regex",
|
||||||
|
"settings.rules.add_name_map": "Mapping hinzufügen",
|
||||||
|
"settings.rules.delete_rules": "Löschregeln",
|
||||||
|
"settings.rules.type": "Typ",
|
||||||
|
"settings.rules.lang": "Sprache",
|
||||||
|
"settings.rules.audio_type": "Audiotyp",
|
||||||
|
"settings.rules.name_contains": "Name enthält",
|
||||||
|
"settings.rules.except_default": "Default ausnehmen",
|
||||||
|
"settings.rules.except_forced": "Forced ausnehmen",
|
||||||
|
"settings.rules.add_delete_rule": "Regel hinzufügen",
|
||||||
|
"settings.rules.priorities": "Prioritäten",
|
||||||
|
"settings.rules.language_priority": "Sprachpriorität (Komma)",
|
||||||
|
"settings.rules.audio_priority": "Audiotyp-Priorität (Komma)",
|
||||||
|
"settings.rules.require_audio_type": "Audiotyp erforderlich",
|
||||||
|
"settings.rules.series_threshold": "Schwelle Reihenfolge",
|
||||||
|
|
||||||
|
"settings.tools.title": "Programme",
|
||||||
|
"settings.tools.add": "Hinzufügen",
|
||||||
|
"settings.tools.detect": "Erkennen",
|
||||||
|
"settings.tools.th_name": "Programm",
|
||||||
|
"settings.tools.th_path": "Pfad",
|
||||||
|
"settings.ui.title": "Interface",
|
||||||
|
"settings.ui.table_mode": "Tabellennavigation",
|
||||||
|
"settings.ui.table_mode_pages": "Seiten",
|
||||||
|
"settings.ui.table_mode_infinite": "Endloses Scrollen",
|
||||||
|
"settings.ui.table_page_size": "Zeilen pro Seite",
|
||||||
|
"settings.ui.sse_tick": "SSE Tick (sek)",
|
||||||
|
"settings.ui.sse_snapshot": "SSE Snapshot (sek)",
|
||||||
|
"settings.ui.note": "Gilt fur alle Tabellen",
|
||||||
|
"settings.background.title": "Hintergrundrichtlinien",
|
||||||
|
"settings.background.mode": "Modus",
|
||||||
|
"settings.background.mode_light": "Leicht",
|
||||||
|
"settings.background.mode_normal": "Normal",
|
||||||
|
"settings.background.mode_aggressive": "Aggressiv",
|
||||||
|
"settings.background.max_parallel": "Max parallele Jobs",
|
||||||
|
"settings.background.max_network": "Max Netzwerk-Jobs",
|
||||||
|
"settings.background.max_io": "Max IO Jobs",
|
||||||
|
"settings.background.batch_sleep": "Pause zwischen Paketen (ms)",
|
||||||
|
"settings.background.watchdog": "Watchdog fur Hanger (min)",
|
||||||
|
"settings.background.pause": "Hintergrund-Jobs pausieren",
|
||||||
|
"settings.background.note": "Gilt fur alle Hintergrund-Jobs",
|
||||||
|
"settings.tools.modal_title": "Programm",
|
||||||
|
"settings.tools.detected": "Erkannt",
|
||||||
|
"settings.tools.mkvmerge": "mkvmerge Pfad",
|
||||||
|
"settings.tools.mkvpropedit": "mkvpropedit Pfad",
|
||||||
|
"settings.tools.ffmpeg": "ffmpeg Pfad",
|
||||||
|
|
||||||
|
"settings.logs.title": "Logs",
|
||||||
|
"settings.logs.date": "Datum",
|
||||||
|
"settings.logs.date_from": "Von",
|
||||||
|
"settings.logs.date_to": "Bis",
|
||||||
|
"settings.logs.filter_level": "Level",
|
||||||
|
"settings.logs.retention": "Aufbewahrung",
|
||||||
|
"settings.logs.level": "Log-Level",
|
||||||
|
"settings.logs.forever": "Immer",
|
||||||
|
"settings.logs.load": "Anzeigen",
|
||||||
|
"settings.logs.cleanup": "Aufräumen",
|
||||||
|
"settings.logs.cleaned": "Bereinigt",
|
||||||
|
"settings.logs.date_required": "Zeitraum wählen",
|
||||||
|
"settings.logs.reset": "Zurücksetzen",
|
||||||
|
"settings.logs.tab_view": "Ansicht",
|
||||||
|
"settings.logs.tab_settings": "Einstellungen",
|
||||||
|
"settings.logs.retention_warn": "Alte Logs werden gelöscht. Fortfahren?",
|
||||||
|
"settings.logs.delete_all_warn": "Sie löschen alle Logs. Fortfahren?",
|
||||||
|
"settings.logs.empty": "Keine Einträge",
|
||||||
|
|
||||||
|
"settings.preview.movies": "MOVIES",
|
||||||
|
"settings.preview.series": "SERIES",
|
||||||
|
"settings.tasks.title": "Aufgaben",
|
||||||
|
"settings.tasks.add": "Aufgabe hinzufugen",
|
||||||
|
"settings.tasks.th_name": "Aufgabe",
|
||||||
|
"settings.tasks.th_sources": "Quellen",
|
||||||
|
"settings.tasks.th_actions": "Aktionen",
|
||||||
|
"settings.tasks.th_status": "Status",
|
||||||
|
|
||||||
|
"auth.page_title": "scMedia / Login",
|
||||||
|
"auth.login_title": "Anmelden",
|
||||||
|
"auth.login_hint": "Verwenden Sie E-Mail und Passwort.",
|
||||||
|
"auth.email": "E-Mail",
|
||||||
|
"auth.password": "Passwort",
|
||||||
|
"auth.remember": "Sitzung merken",
|
||||||
|
"auth.login_btn": "Anmelden",
|
||||||
|
"auth.forgot": "Passwort vergessen?",
|
||||||
|
"auth.forgot_send": "Link senden",
|
||||||
|
"auth.mfa_title": "Zwei-Faktor-Code",
|
||||||
|
"auth.mfa_code": "Code",
|
||||||
|
"auth.mfa_verify": "Bestätigen",
|
||||||
|
"auth.error.email_password_required": "E-Mail und Passwort erforderlich",
|
||||||
|
"auth.error.invalid_credentials": "Ungültige Anmeldedaten",
|
||||||
|
"auth.error.email_required": "E-Mail erforderlich",
|
||||||
|
"auth.error.request_failed": "Anfrage fehlgeschlagen",
|
||||||
|
"auth.error.reset_sent": "Wenn diese E-Mail existiert, wird ein Link zum Zurücksetzen gesendet.",
|
||||||
|
"auth.error.code_required": "Code erforderlich",
|
||||||
|
"auth.error.invalid_code": "Ungültiger Code",
|
||||||
|
"auth.error.login_failed": "Anmeldung fehlgeschlagen",
|
||||||
|
"auth.error.too_many_attempts": "Zu viele Versuche. Versuchen Sie es in {seconds}s erneut.",
|
||||||
|
"auth.logout": "Abmelden",
|
||||||
|
"nav.menu": "Menü",
|
||||||
|
"nav.home": "Start",
|
||||||
|
"nav.settings": "Einstellungen",
|
||||||
|
"nav.account": "Profil"
|
||||||
|
}
|
||||||
543
public/assets/i18n/en.json
Normal file
@ -0,0 +1,543 @@
|
|||||||
|
{
|
||||||
|
"app.title": "scMedia",
|
||||||
|
|
||||||
|
"types.auto": "Auto",
|
||||||
|
"types.movie": "Movie",
|
||||||
|
"types.series": "Series",
|
||||||
|
|
||||||
|
"actions.scan": "Scan",
|
||||||
|
"actions.refresh": "Refresh",
|
||||||
|
"actions.preview": "Preview",
|
||||||
|
"actions.apply": "Apply",
|
||||||
|
"actions.settings": "Settings",
|
||||||
|
"actions.theme": "Theme",
|
||||||
|
|
||||||
|
"bulk.type": "Type",
|
||||||
|
"bulk.year": "Year",
|
||||||
|
"bulk.set": "Set",
|
||||||
|
"bulk.clear": "Clear",
|
||||||
|
"bulk.skip": "Skip",
|
||||||
|
"bulk.unskip": "Unskip",
|
||||||
|
"bulk.year_placeholder": "Year",
|
||||||
|
|
||||||
|
"filters.status": "Status",
|
||||||
|
"filters.issues": "Issues",
|
||||||
|
"filters.all": "All",
|
||||||
|
"filters.has_issues": "Has issues",
|
||||||
|
"filters.no_issues": "No issues",
|
||||||
|
|
||||||
|
"grid.type": "Type",
|
||||||
|
"grid.name": "Name",
|
||||||
|
"grid.year": "Year",
|
||||||
|
"grid.raw": "Raw name",
|
||||||
|
"grid.source": "Source",
|
||||||
|
"grid.videos": "Videos",
|
||||||
|
"grid.issues": "Issues",
|
||||||
|
"grid.status": "Status",
|
||||||
|
"grid.path": "Path",
|
||||||
|
"grid.structure": "Structure",
|
||||||
|
"grid.files": "Files",
|
||||||
|
|
||||||
|
"status.scanned": "Scanned",
|
||||||
|
"status.draft": "Draft",
|
||||||
|
"status.planned": "Planned",
|
||||||
|
"status.applied": "Applied",
|
||||||
|
"status.error": "Error",
|
||||||
|
"status.skipped": "Skipped",
|
||||||
|
"status.active": "Active",
|
||||||
|
"status.gone": "Gone",
|
||||||
|
"status.ignored": "Ignored",
|
||||||
|
"status.ok": "OK",
|
||||||
|
"status.needs": "Needs",
|
||||||
|
"theme.light": "Light",
|
||||||
|
"theme.dark": "Dark",
|
||||||
|
"app.section.library": "Library",
|
||||||
|
|
||||||
|
"preview.title": "Preview",
|
||||||
|
"preview.operations": "Operations",
|
||||||
|
"preview.conflicts": "Conflicts",
|
||||||
|
|
||||||
|
"job.running": "Job running",
|
||||||
|
"job.title": "Job",
|
||||||
|
"job.cancel_confirm": "Cancel job?",
|
||||||
|
"queue.status": "Queue",
|
||||||
|
"queue.pause": "Pause",
|
||||||
|
"queue.resume": "Resume",
|
||||||
|
"queue.cancel_active": "Cancel active",
|
||||||
|
"queue.paused": "Paused",
|
||||||
|
"queue.running": "Running",
|
||||||
|
"queue.queued": "Queued",
|
||||||
|
"queue.errors": "Errors",
|
||||||
|
"queue.summary": "Active: {active} | Errors: {errors}",
|
||||||
|
"queue.active": "Active",
|
||||||
|
"queue.finished": "Finished",
|
||||||
|
"queue.none_active": "No active tasks",
|
||||||
|
"queue.none_finished": "No finished tasks",
|
||||||
|
"queue.cancel": "Cancel",
|
||||||
|
"tasks.run": "Run task",
|
||||||
|
"settings.tasks.modal_title": "Task",
|
||||||
|
"settings.tasks.field_name": "Name",
|
||||||
|
"settings.tasks.field_sources": "Sources",
|
||||||
|
"settings.tasks.field_actions": "Actions",
|
||||||
|
"settings.tasks.field_enabled": "Enabled",
|
||||||
|
"settings.tasks.source.library": "Library",
|
||||||
|
"settings.tasks.source.transmission": "Transmission",
|
||||||
|
"settings.tasks.source.staging": "Staging",
|
||||||
|
"settings.tasks.action.analyze": "Analyze",
|
||||||
|
"settings.tasks.action.identify": "Identify",
|
||||||
|
"settings.tasks.action.normalize": "Normalize",
|
||||||
|
"settings.tasks.action.rename": "Rename",
|
||||||
|
"settings.tasks.action.export": "Export",
|
||||||
|
"settings.tasks.status.on": "On",
|
||||||
|
"settings.tasks.status.off": "Off",
|
||||||
|
"settings.tasks.confirm_delete": "Delete task?",
|
||||||
|
"job.status": "Status",
|
||||||
|
|
||||||
|
"errors.scan_failed": "Scan failed",
|
||||||
|
"errors.preview_failed": "Preview failed",
|
||||||
|
"errors.apply_failed": "Apply failed",
|
||||||
|
"errors.job_fetch": "Error fetching job status",
|
||||||
|
|
||||||
|
"messages.scan_finished": "Scan finished",
|
||||||
|
"messages.apply_started": "Apply started",
|
||||||
|
|
||||||
|
"common.none": "—",
|
||||||
|
"common.close": "Close",
|
||||||
|
"common.back": "Back",
|
||||||
|
"common.save": "Save",
|
||||||
|
"common.cancel": "Cancel",
|
||||||
|
"common.export": "Export",
|
||||||
|
"common.edit": "Edit",
|
||||||
|
"common.delete": "Delete",
|
||||||
|
"common.never": "never",
|
||||||
|
"common.loading": "Loading…",
|
||||||
|
"common.loading_settings": "Loading settings…",
|
||||||
|
"common.loaded": "Loaded",
|
||||||
|
"common.saving": "Saving…",
|
||||||
|
"common.saved": "Saved",
|
||||||
|
"common.unsaved_changes": "Unsaved changes",
|
||||||
|
"settings.unsaved_confirm": "Unsaved changes. Leave settings?",
|
||||||
|
"common.testing": "Testing…",
|
||||||
|
"common.running": "Running…",
|
||||||
|
"common.error": "Error",
|
||||||
|
"common.exists": "exists",
|
||||||
|
"common.read": "read",
|
||||||
|
"common.write": "write",
|
||||||
|
"common.rename": "rename",
|
||||||
|
"common.generating_preview": "Generating preview…",
|
||||||
|
"common.na": "n/a",
|
||||||
|
"common.test": "Test",
|
||||||
|
"common.yes": "Yes",
|
||||||
|
"common.no": "No",
|
||||||
|
"meta.title": "Title",
|
||||||
|
"meta.original": "Original",
|
||||||
|
"meta.year": "Year",
|
||||||
|
"meta.provider": "Provider",
|
||||||
|
"meta.source": "Source",
|
||||||
|
"meta.search": "Search",
|
||||||
|
"meta.search_placeholder": "Search title",
|
||||||
|
"meta.manual_title": "Manual title",
|
||||||
|
"meta.manual_year": "Year",
|
||||||
|
"meta.save": "Save",
|
||||||
|
"meta.clear": "Clear",
|
||||||
|
"meta.no_results": "No results",
|
||||||
|
|
||||||
|
"settings.page_title": "scMedia / Settings",
|
||||||
|
"settings.title": "Settings",
|
||||||
|
"settings.back": "Back",
|
||||||
|
"settings.tabs.scan_profiles": "Scan Profiles",
|
||||||
|
"settings.tabs.library_layout": "Library",
|
||||||
|
"settings.tabs.plugins": "Plugins",
|
||||||
|
"settings.tabs.tasks": "Tasks",
|
||||||
|
"settings.tabs.rules": "Rules",
|
||||||
|
"settings.tabs.tools": "Programs",
|
||||||
|
"settings.tabs.ui": "Interface",
|
||||||
|
"settings.tabs.logs": "Logs",
|
||||||
|
"settings.tabs.debug": "Debug",
|
||||||
|
"settings.tabs.about": "About",
|
||||||
|
"settings.scan_profiles.title": "Scan Profiles",
|
||||||
|
"settings.scan_profiles.add": "Add profile",
|
||||||
|
"settings.scan_profiles.modal_add": "Add profile",
|
||||||
|
"settings.scan_profiles.modal_edit": "Edit profile",
|
||||||
|
"settings.scan_profiles.confirm_delete": "Delete profile?",
|
||||||
|
"settings.scan_profiles.ext_default": "default",
|
||||||
|
"settings.scanner_defaults.title": "Global scanner defaults",
|
||||||
|
"settings.library_layout.title": "Library",
|
||||||
|
"settings.library_layout.preview": "Preview",
|
||||||
|
"settings.library_layout.roots": "Folders",
|
||||||
|
"settings.library_layout.add_root": "Add",
|
||||||
|
"settings.library_layout.th_type": "Type",
|
||||||
|
"settings.library_layout.th_path": "Path",
|
||||||
|
"settings.library_layout.th_status": "Status",
|
||||||
|
"settings.library_layout.modal_title": "Root",
|
||||||
|
"settings.library_layout.confirm_delete": "Delete root?",
|
||||||
|
"root.type.movie": "Movie",
|
||||||
|
"root.type.series": "Series",
|
||||||
|
"root.type.staging": "Staging",
|
||||||
|
"settings.rules.add_rule": "Add rule",
|
||||||
|
|
||||||
|
"rules.type.name_map": "Name mapping",
|
||||||
|
"rules.type.delete_track": "Delete tracks",
|
||||||
|
"rules.type.priorities": "Priorities",
|
||||||
|
"rules.type.lang_fix": "Language fix",
|
||||||
|
"rules.type.source_filter": "Source filter",
|
||||||
|
"rules.sort_by": "Sort by",
|
||||||
|
"rules.sort.name": "Name",
|
||||||
|
"rules.sort.type": "Type",
|
||||||
|
"rules.th.name": "Name",
|
||||||
|
"rules.th.type": "Type",
|
||||||
|
"rules.th.summary": "Summary",
|
||||||
|
"rules.th.status": "Status",
|
||||||
|
"rules.status.on": "On",
|
||||||
|
"rules.status.off": "Off",
|
||||||
|
"rules.enable": "Enable",
|
||||||
|
"rules.disable": "Disable",
|
||||||
|
"rules.unnamed": "Untitled",
|
||||||
|
"rules.confirm_delete": "Delete rule?",
|
||||||
|
"rules.modal_title": "Rule",
|
||||||
|
"rules.field.name": "Name",
|
||||||
|
"rules.field.enabled": "Enabled",
|
||||||
|
"rules.field.pattern": "Pattern",
|
||||||
|
"rules.field.canonical": "Canonical",
|
||||||
|
"rules.field.mode": "Mode",
|
||||||
|
"rules.field.track_type": "Track type",
|
||||||
|
"rules.field.lang": "Language",
|
||||||
|
"rules.field.audio_type": "Audio type",
|
||||||
|
"rules.field.name_contains": "Name contains",
|
||||||
|
"rules.field.except_default": "Except default",
|
||||||
|
"rules.field.except_forced": "Except forced",
|
||||||
|
"rules.field.languages": "Languages (comma)",
|
||||||
|
"rules.field.audio_types": "Audio types (comma)",
|
||||||
|
"rules.field.from_lang": "From language",
|
||||||
|
"rules.field.to_lang": "To language",
|
||||||
|
"rules.field.source": "Source",
|
||||||
|
"rules.field.status": "Status",
|
||||||
|
"rules.field.conditions": "Conditions",
|
||||||
|
"rules.cond.field": "Field",
|
||||||
|
"rules.cond.op": "Op",
|
||||||
|
"rules.cond.value": "Value",
|
||||||
|
"rules.cond.enabled": "Active",
|
||||||
|
"rules.cond.add": "Add condition",
|
||||||
|
"rules.cond.status": "Status",
|
||||||
|
"rules.cond.label": "Label",
|
||||||
|
"rules.cond.name_regex": "Name regex",
|
||||||
|
"rules.cond.path_regex": "Path regex",
|
||||||
|
"rules.cond.min_size": "Min size",
|
||||||
|
"rules.op.contains": "contains",
|
||||||
|
"rules.op.not_contains": "not contains",
|
||||||
|
"rules.op.any": "any",
|
||||||
|
"rules.logic.or": "OR",
|
||||||
|
"rules.statuses.none": "No statuses",
|
||||||
|
"rules.field.label": "Label",
|
||||||
|
"rules.field.name_regex": "Name regex",
|
||||||
|
"rules.field.path_regex": "Path regex",
|
||||||
|
"rules.field.min_size": "Min size (bytes)",
|
||||||
|
"rules.mode.exact": "exact",
|
||||||
|
"rules.mode.regex": "regex",
|
||||||
|
"rules.any": "Any",
|
||||||
|
"sources.name": "Name",
|
||||||
|
"sources.size": "Size",
|
||||||
|
"sources.status": "Status",
|
||||||
|
"sources.progress": "Progress",
|
||||||
|
"sources.type": "Type",
|
||||||
|
"sources.type.file": "File",
|
||||||
|
"sources.type.folder": "Folder",
|
||||||
|
"sources.files": "Files",
|
||||||
|
"sources.detail.title": "Source detail",
|
||||||
|
"sources.detail.raw": "Raw data",
|
||||||
|
"sources.detail.approve": "Approve",
|
||||||
|
"sources.preview.title": "Preview",
|
||||||
|
"sources.preview.current": "Current",
|
||||||
|
"sources.preview.planned": "Planned",
|
||||||
|
"sources.preview.name": "Name",
|
||||||
|
"sources.preview.kind": "Type",
|
||||||
|
"sources.preview.structure": "Structure",
|
||||||
|
"sources.status.completed": "completed",
|
||||||
|
"sources.status.downloading": "downloading",
|
||||||
|
"sources.status.seeding": "seeding",
|
||||||
|
"sources.status.stopped": "stopped",
|
||||||
|
"sources.status.unknown": "unknown",
|
||||||
|
"sources.source": "Source",
|
||||||
|
"sources.path": "Path",
|
||||||
|
"settings.plugins.title": "Plugins",
|
||||||
|
"settings.plugins.add": "Add plugin",
|
||||||
|
"settings.plugins.meta_settings": "Metadata settings",
|
||||||
|
"settings.plugins.omdb_label": "IMDb (OMDb)",
|
||||||
|
"settings.plugins.tvdb_label": "TVDB",
|
||||||
|
"settings.plugins.kodi_label": "Kodi",
|
||||||
|
"settings.plugins.jellyfin_label": "Jellyfin",
|
||||||
|
"settings.plugins.transmission_label": "Transmission",
|
||||||
|
"settings.plugins.th_name": "Plugin",
|
||||||
|
"settings.plugins.th_type": "Type",
|
||||||
|
"settings.plugins.th_status": "Status",
|
||||||
|
"settings.plugins.kind.meta": "Metadata",
|
||||||
|
"settings.plugins.kind.export": "Export",
|
||||||
|
"settings.plugins.kind.source": "Source",
|
||||||
|
"settings.plugins.modal_title": "Plugin",
|
||||||
|
"settings.plugins.requires_meta": "Enable metadata settings first",
|
||||||
|
"settings.plugins.requires_test": "Test connection first",
|
||||||
|
"settings.plugins.install_placeholder": "Installer coming soon",
|
||||||
|
"settings.plugins.metadata": "Metadata providers",
|
||||||
|
"settings.plugins.languages": "Metadata languages (comma)",
|
||||||
|
"settings.plugins.provider_priority": "Provider priority (comma)",
|
||||||
|
"settings.plugins.enable": "Enabled",
|
||||||
|
"settings.plugins.omdb_key": "OMDb API key",
|
||||||
|
"settings.plugins.tvdb_key": "TVDB API key",
|
||||||
|
"settings.plugins.tvdb_pin": "TVDB PIN (optional)",
|
||||||
|
"settings.plugins.exports": "Export plugins",
|
||||||
|
"settings.plugins.sources": "Source plugins",
|
||||||
|
"settings.plugins.transmission_protocol": "Protocol",
|
||||||
|
"settings.plugins.transmission_host": "Host",
|
||||||
|
"settings.plugins.transmission_port": "Port",
|
||||||
|
"settings.plugins.transmission_path": "RPC path",
|
||||||
|
"settings.plugins.transmission_user": "Username",
|
||||||
|
"settings.plugins.transmission_pass": "Password",
|
||||||
|
"settings.plugins.transmission_display_fields": "Display fields (comma)",
|
||||||
|
"settings.plugins.transmission_test": "Test connection",
|
||||||
|
"settings.plugins.transmission_ok": "OK",
|
||||||
|
"settings.plugins.transmission_missing": "Fill required fields",
|
||||||
|
"settings.plugins.transmission_unauthorized": "Unauthorized. Check RPC username/password.",
|
||||||
|
"settings.plugins.transmission_forbidden": "Access denied. Check whitelist or credentials.",
|
||||||
|
"settings.plugins.transmission_rpc_failed": "RPC failed. Check address or auth.",
|
||||||
|
"settings.plugins.kodi_hint": "Writes movie.nfo / tvshow.nfo near files",
|
||||||
|
"settings.plugins.jellyfin_hint": "Writes movie.nfo / tvshow.nfo near files",
|
||||||
|
"settings.debug.title": "Debug",
|
||||||
|
"settings.debug.content.title": "Content data",
|
||||||
|
"settings.debug.content.files": "Media files",
|
||||||
|
"settings.debug.content.meta": "Metadata",
|
||||||
|
"settings.debug.content.items": "Indexed items",
|
||||||
|
"settings.debug.content.clear_btn": "Clear content",
|
||||||
|
"settings.debug.db.title": "Database",
|
||||||
|
"settings.debug.db.tables": "Tables",
|
||||||
|
"settings.debug.db.size": "Size",
|
||||||
|
"settings.debug.db.reset_btn": "Reset DB",
|
||||||
|
"settings.debug.dump.title": "Database dump",
|
||||||
|
"settings.debug.dump.download": "Download dump",
|
||||||
|
"settings.debug.dump.restore": "Restore dump",
|
||||||
|
"settings.debug.dump.restore_confirm": "Restore database from dump? This will overwrite current data.",
|
||||||
|
"settings.language": "Language",
|
||||||
|
"settings.scan_profiles.th_on": "On",
|
||||||
|
"settings.scan_profiles.th_type": "Type",
|
||||||
|
"settings.scan_profiles.th_name": "Name",
|
||||||
|
"settings.scan_profiles.th_root": "Root path",
|
||||||
|
"settings.scan_profiles.th_depth": "Depth",
|
||||||
|
"settings.scan_profiles.th_excludes": "Excludes",
|
||||||
|
"settings.scan_profiles.th_ext": "Ext",
|
||||||
|
"settings.scan_profiles.th_last_scan": "Last scan",
|
||||||
|
"settings.scan_profiles.th_result": "Result",
|
||||||
|
|
||||||
|
"settings.scanner_defaults.video_ext": "Video extensions (comma)",
|
||||||
|
"settings.scanner_defaults.video_ext_ph": "mkv,mp4,avi",
|
||||||
|
"settings.scanner_defaults.max_depth": "Max depth default",
|
||||||
|
"settings.scanner_defaults.max_files": "Max files per item",
|
||||||
|
"settings.scanner_defaults.max_items": "Max items per scan (0 = no limit)",
|
||||||
|
|
||||||
|
"settings.library_layout.movies_root": "Movies root",
|
||||||
|
"settings.library_layout.movies_root_ph": "/mnt/media/library/movies",
|
||||||
|
"settings.library_layout.series_root": "Series root",
|
||||||
|
"settings.library_layout.series_root_ph": "/mnt/media/library/series",
|
||||||
|
"settings.library_layout.staging_root": "Staging root (optional)",
|
||||||
|
"settings.library_layout.staging_root_ph": "/mnt/media/.staging",
|
||||||
|
|
||||||
|
"settings.library_layout.movies_strategy_title": "Movies strategy",
|
||||||
|
"settings.library_layout.series_strategy_title": "Series strategy",
|
||||||
|
"settings.library_layout.strategy": "Strategy",
|
||||||
|
"settings.library_layout.season_naming": "Season naming",
|
||||||
|
"settings.library_layout.normalization_title": "Normalization",
|
||||||
|
"settings.library_layout.collision_title": "Collision policy",
|
||||||
|
"settings.library_layout.preview_title": "Preview",
|
||||||
|
"settings.library_layout.preview_hint": "Click \"Generate preview\".",
|
||||||
|
|
||||||
|
"settings.strategy.flat": "Flat",
|
||||||
|
"settings.strategy.first_letter": "By first letter",
|
||||||
|
"settings.strategy.prefix": "By first N letters",
|
||||||
|
"settings.strategy.hash_buckets": "By numeric buckets (hash)",
|
||||||
|
"settings.strategy.by_year": "By year",
|
||||||
|
"settings.strategy.letter_year": "Letter + year",
|
||||||
|
"settings.strategy.decade_year": "Decade + year",
|
||||||
|
"settings.strategy.custom": "Custom template",
|
||||||
|
"settings.strategy.n": "N",
|
||||||
|
"settings.strategy.buckets": "Buckets",
|
||||||
|
"settings.strategy.template": "Template",
|
||||||
|
"settings.strategy.template_ph_movies": "{first:2}/{title} ({year})",
|
||||||
|
"settings.strategy.template_ph_series": "{first}/{title}",
|
||||||
|
"settings.strategy.vars_hint": "Vars: {title} {year} {decade} {first} {first:2} {first:3} {hash:2}",
|
||||||
|
|
||||||
|
"settings.season.season_2digit": "Season 01",
|
||||||
|
"settings.season.s_2digit": "S01",
|
||||||
|
"settings.season.season_plain": "Season 1",
|
||||||
|
|
||||||
|
"settings.norm.ignore_articles": "Ignore articles (The/A/An)",
|
||||||
|
"settings.norm.uppercase_shards": "Uppercase shard folders",
|
||||||
|
"settings.norm.replace_unsafe": "Replace unsafe characters",
|
||||||
|
"settings.norm.trim_dots": "Trim dots/spaces",
|
||||||
|
"settings.norm.transliterate_later": "Transliterate non-latin (later)",
|
||||||
|
"settings.norm.ignore_words": "Ignore words list (comma)",
|
||||||
|
"settings.norm.ignore_words_ph": "sample,extras",
|
||||||
|
|
||||||
|
"settings.collision.stop": "Stop and mark conflict",
|
||||||
|
"settings.collision.append_num": "Append -1, -2",
|
||||||
|
"settings.collision.append_hash": "Append short hash",
|
||||||
|
|
||||||
|
"settings.debug.content.ph": "Type CLEAR CONTENT",
|
||||||
|
"settings.debug.db.ph": "Type RESET DATABASE",
|
||||||
|
"settings.about.ui_version": "UI version",
|
||||||
|
"settings.about.backend_version": "Backend version",
|
||||||
|
"settings.about.db_version": "DB version",
|
||||||
|
"settings.about.table_name": "Name",
|
||||||
|
"settings.about.table_type": "Type",
|
||||||
|
"settings.about.table_author": "Author",
|
||||||
|
"settings.about.table_version": "Version",
|
||||||
|
"settings.about.table_update": "Update",
|
||||||
|
|
||||||
|
"settings.scan_profiles.modal_title": "Profile",
|
||||||
|
"settings.scan_profiles.modal_enabled": "Enabled",
|
||||||
|
"settings.scan_profiles.modal_name": "Name",
|
||||||
|
"settings.scan_profiles.modal_name_ph": "Incoming",
|
||||||
|
"settings.scan_profiles.modal_root": "Root path",
|
||||||
|
"settings.scan_profiles.modal_root_ph": "/mnt/downloads/complete",
|
||||||
|
"settings.scan_profiles.modal_depth": "Max depth",
|
||||||
|
"settings.scan_profiles.modal_type": "Profile type",
|
||||||
|
"settings.scan_profiles.type_scan": "Scan",
|
||||||
|
"settings.scan_profiles.type_analyze": "Analyze",
|
||||||
|
"settings.scan_profiles.move": "Move",
|
||||||
|
"settings.scan_profiles.modal_excludes": "Exclude patterns (comma)",
|
||||||
|
"settings.scan_profiles.modal_excludes_ph": "@eaDir,sample,extras",
|
||||||
|
"settings.scan_profiles.modal_ext_mode": "Include ext mode",
|
||||||
|
"settings.scan_profiles.ext_custom": "Custom",
|
||||||
|
"settings.scan_profiles.modal_ext_custom": "Custom extensions (comma)",
|
||||||
|
"settings.scan_profiles.modal_ext_custom_ph": "mkv,mp4",
|
||||||
|
"settings.about.title": "About",
|
||||||
|
|
||||||
|
"media.tabs.movies": "Movies",
|
||||||
|
"media.tabs.series": "Series",
|
||||||
|
"media.tabs.sources": "Sources",
|
||||||
|
"media.container": "Container",
|
||||||
|
"media.size": "Size",
|
||||||
|
"media.duration": "Duration",
|
||||||
|
"media.track.type": "Type",
|
||||||
|
"media.track.lang": "Lang",
|
||||||
|
"media.track.name": "Name",
|
||||||
|
"media.track.codec": "Codec",
|
||||||
|
"media.track.channels": "Channels",
|
||||||
|
"media.track.flags": "Flags",
|
||||||
|
"media.track.audio_type": "Audio type",
|
||||||
|
"media.actions.dry_run": "Dry run",
|
||||||
|
"media.actions.apply": "Apply",
|
||||||
|
"media.dry_run.title": "Dry run",
|
||||||
|
"media.dry_run.summary": "Summary",
|
||||||
|
"media.dry_run.files": "Files",
|
||||||
|
"media.dry_run.rename": "Rename",
|
||||||
|
"media.dry_run.delete": "Delete",
|
||||||
|
"media.dry_run.unknown": "Unknown type",
|
||||||
|
"media.dry_run.convert": "Convert",
|
||||||
|
"media.dry_run.details": "Details",
|
||||||
|
|
||||||
|
"settings.rules.title": "Rules",
|
||||||
|
"settings.rules.name_map": "Name mapping",
|
||||||
|
"settings.rules.pattern": "Pattern",
|
||||||
|
"settings.rules.canonical": "Canonical",
|
||||||
|
"settings.rules.mode": "Mode",
|
||||||
|
"settings.rules.mode_exact": "exact",
|
||||||
|
"settings.rules.mode_regex": "regex",
|
||||||
|
"settings.rules.add_name_map": "Add mapping",
|
||||||
|
"settings.rules.delete_rules": "Delete rules",
|
||||||
|
"settings.rules.type": "Type",
|
||||||
|
"settings.rules.lang": "Lang",
|
||||||
|
"settings.rules.audio_type": "Audio type",
|
||||||
|
"settings.rules.name_contains": "Name contains",
|
||||||
|
"settings.rules.except_default": "Except default",
|
||||||
|
"settings.rules.except_forced": "Except forced",
|
||||||
|
"settings.rules.add_delete_rule": "Add delete rule",
|
||||||
|
"settings.rules.priorities": "Priorities",
|
||||||
|
"settings.rules.language_priority": "Language priority (comma)",
|
||||||
|
"settings.rules.audio_priority": "Audio type priority (comma)",
|
||||||
|
"settings.rules.require_audio_type": "Require audio type",
|
||||||
|
"settings.rules.series_threshold": "Series order threshold",
|
||||||
|
|
||||||
|
"settings.tools.title": "Programs",
|
||||||
|
"settings.tools.add": "Add tool",
|
||||||
|
"settings.tools.detect": "Detect",
|
||||||
|
"settings.tools.th_name": "Tool",
|
||||||
|
"settings.tools.th_path": "Path",
|
||||||
|
"settings.ui.title": "Interface",
|
||||||
|
"settings.ui.table_mode": "Table navigation",
|
||||||
|
"settings.ui.table_mode_pages": "Pages",
|
||||||
|
"settings.ui.table_mode_infinite": "Infinite scroll",
|
||||||
|
"settings.ui.table_page_size": "Rows per page",
|
||||||
|
"settings.ui.sse_tick": "SSE tick (sec)",
|
||||||
|
"settings.ui.sse_snapshot": "SSE snapshot (sec)",
|
||||||
|
"settings.ui.note": "Applies to all tables",
|
||||||
|
"settings.background.title": "Background policies",
|
||||||
|
"settings.background.mode": "Mode",
|
||||||
|
"settings.background.mode_light": "Light",
|
||||||
|
"settings.background.mode_normal": "Normal",
|
||||||
|
"settings.background.mode_aggressive": "Aggressive",
|
||||||
|
"settings.background.max_parallel": "Max parallel jobs",
|
||||||
|
"settings.background.max_network": "Max network jobs",
|
||||||
|
"settings.background.max_io": "Max IO jobs",
|
||||||
|
"settings.background.batch_sleep": "Sleep between batches (ms)",
|
||||||
|
"settings.background.watchdog": "Stalled watchdog (min)",
|
||||||
|
"settings.background.pause": "Pause background jobs",
|
||||||
|
"settings.background.note": "Applies to all background tasks",
|
||||||
|
"settings.tools.modal_title": "Tool",
|
||||||
|
"settings.tools.detected": "Detected",
|
||||||
|
"settings.tools.mkvmerge": "mkvmerge path",
|
||||||
|
"settings.tools.mkvpropedit": "mkvpropedit path",
|
||||||
|
"settings.tools.ffmpeg": "ffmpeg path",
|
||||||
|
|
||||||
|
"settings.logs.title": "Logs",
|
||||||
|
"settings.logs.date": "Date",
|
||||||
|
"settings.logs.date_from": "From",
|
||||||
|
"settings.logs.date_to": "To",
|
||||||
|
"settings.logs.filter_level": "Level",
|
||||||
|
"settings.logs.retention": "Retention",
|
||||||
|
"settings.logs.level": "Log level",
|
||||||
|
"settings.logs.forever": "Forever",
|
||||||
|
"settings.logs.load": "Load",
|
||||||
|
"settings.logs.cleanup": "Cleanup",
|
||||||
|
"settings.logs.cleaned": "Cleaned",
|
||||||
|
"settings.logs.date_required": "Select a date range",
|
||||||
|
"settings.logs.reset": "Reset",
|
||||||
|
"settings.logs.tab_view": "View",
|
||||||
|
"settings.logs.tab_settings": "Settings",
|
||||||
|
"settings.logs.retention_warn": "Older logs will be deleted. Continue?",
|
||||||
|
"settings.logs.delete_all_warn": "You are deleting all logs. Continue?",
|
||||||
|
"settings.logs.empty": "Nothing here",
|
||||||
|
|
||||||
|
"settings.preview.movies": "MOVIES",
|
||||||
|
"settings.preview.series": "SERIES",
|
||||||
|
"settings.tasks.title": "Tasks",
|
||||||
|
"settings.tasks.add": "Add task",
|
||||||
|
"settings.tasks.th_name": "Task",
|
||||||
|
"settings.tasks.th_sources": "Sources",
|
||||||
|
"settings.tasks.th_actions": "Actions",
|
||||||
|
"settings.tasks.th_status": "Status",
|
||||||
|
|
||||||
|
"auth.page_title": "scMedia / Login",
|
||||||
|
"auth.login_title": "Sign in",
|
||||||
|
"auth.login_hint": "Use your email and password.",
|
||||||
|
"auth.email": "Email",
|
||||||
|
"auth.password": "Password",
|
||||||
|
"auth.remember": "Remember session",
|
||||||
|
"auth.login_btn": "Sign in",
|
||||||
|
"auth.forgot": "Forgot password?",
|
||||||
|
"auth.forgot_send": "Send reset link",
|
||||||
|
"auth.mfa_title": "Two-factor code",
|
||||||
|
"auth.mfa_code": "Code",
|
||||||
|
"auth.mfa_verify": "Verify",
|
||||||
|
"auth.error.email_password_required": "Email and password required",
|
||||||
|
"auth.error.invalid_credentials": "Invalid credentials",
|
||||||
|
"auth.error.email_required": "Email required",
|
||||||
|
"auth.error.request_failed": "Request failed",
|
||||||
|
"auth.error.reset_sent": "If this email exists, a reset link will be sent.",
|
||||||
|
"auth.error.code_required": "Code required",
|
||||||
|
"auth.error.invalid_code": "Invalid code",
|
||||||
|
"auth.error.login_failed": "Login failed",
|
||||||
|
"auth.error.too_many_attempts": "Too many attempts. Try again in {seconds}s.",
|
||||||
|
"auth.logout": "Logout",
|
||||||
|
"nav.menu": "Menu",
|
||||||
|
"nav.home": "Home",
|
||||||
|
"nav.settings": "Settings",
|
||||||
|
"nav.account": "Account"
|
||||||
|
}
|
||||||
545
public/assets/i18n/ru.json
Normal file
@ -0,0 +1,545 @@
|
|||||||
|
{
|
||||||
|
"app.title": "scMedia",
|
||||||
|
|
||||||
|
"types.auto": "Авто",
|
||||||
|
"types.movie": "Фильм",
|
||||||
|
"types.series": "Сериал",
|
||||||
|
|
||||||
|
"actions.scan": "Сканировать",
|
||||||
|
"actions.refresh": "Обновить",
|
||||||
|
"actions.preview": "Превью",
|
||||||
|
"actions.apply": "Применить",
|
||||||
|
"actions.settings": "Настройки",
|
||||||
|
"actions.theme": "Тема",
|
||||||
|
|
||||||
|
"bulk.type": "Тип",
|
||||||
|
"bulk.year": "Год",
|
||||||
|
"bulk.set": "Задать",
|
||||||
|
"bulk.clear": "Очистить",
|
||||||
|
"bulk.skip": "Пропустить",
|
||||||
|
"bulk.unskip": "Вернуть",
|
||||||
|
"bulk.year_placeholder": "Год",
|
||||||
|
|
||||||
|
"filters.status": "Статус",
|
||||||
|
"filters.issues": "Проблемы",
|
||||||
|
"filters.all": "Все",
|
||||||
|
"filters.has_issues": "Есть проблемы",
|
||||||
|
"filters.no_issues": "Без проблем",
|
||||||
|
|
||||||
|
"grid.type": "Тип",
|
||||||
|
"grid.name": "Название",
|
||||||
|
"grid.year": "Год",
|
||||||
|
"grid.raw": "Исходное имя",
|
||||||
|
"grid.source": "Источник",
|
||||||
|
"grid.videos": "Видео",
|
||||||
|
"grid.issues": "Проблемы",
|
||||||
|
"grid.status": "Статус",
|
||||||
|
"grid.path": "Путь",
|
||||||
|
"grid.structure": "Структура",
|
||||||
|
"grid.files": "Файлы",
|
||||||
|
|
||||||
|
"status.scanned": "Просканировано",
|
||||||
|
"status.draft": "Черновик",
|
||||||
|
"status.planned": "Запланировано",
|
||||||
|
"status.applied": "Применено",
|
||||||
|
"status.error": "Ошибка",
|
||||||
|
"status.skipped": "Пропущено",
|
||||||
|
"status.active": "Активно",
|
||||||
|
"status.gone": "Нет",
|
||||||
|
"status.ignored": "Игнор",
|
||||||
|
"status.ok": "ОК",
|
||||||
|
"status.needs": "Нужно",
|
||||||
|
"theme.light": "Светлая",
|
||||||
|
"theme.dark": "Темная",
|
||||||
|
"app.section.library": "Библиотека",
|
||||||
|
|
||||||
|
"preview.title": "Превью",
|
||||||
|
"preview.operations": "Операции",
|
||||||
|
"preview.conflicts": "Конфликты",
|
||||||
|
|
||||||
|
"job.running": "Выполняется задача",
|
||||||
|
"job.title": "Задача",
|
||||||
|
"job.cancel_confirm": "Отменить задачу?",
|
||||||
|
"queue.status": "Очередь",
|
||||||
|
"queue.pause": "Пауза",
|
||||||
|
"queue.resume": "Продолжить",
|
||||||
|
"queue.cancel_active": "Отменить активную",
|
||||||
|
"queue.paused": "Пауза",
|
||||||
|
"queue.running": "Активно",
|
||||||
|
"queue.queued": "В очереди",
|
||||||
|
"queue.errors": "Ошибки",
|
||||||
|
"queue.summary": "Активно: {active} | Ошибки: {errors}",
|
||||||
|
"queue.active": "Активные",
|
||||||
|
"queue.finished": "Завершенные",
|
||||||
|
"queue.none_active": "Нет активных задач",
|
||||||
|
"queue.none_finished": "Нет завершенных задач",
|
||||||
|
"queue.cancel": "Отмена",
|
||||||
|
"tasks.run": "Запустить задачу",
|
||||||
|
"settings.tasks.modal_title": "Задача",
|
||||||
|
"settings.tasks.field_name": "Название",
|
||||||
|
"settings.tasks.field_sources": "Источники",
|
||||||
|
"settings.tasks.field_actions": "Действия",
|
||||||
|
"settings.tasks.field_enabled": "Включена",
|
||||||
|
"settings.tasks.source.library": "Библиотека",
|
||||||
|
"settings.tasks.source.transmission": "Transmission",
|
||||||
|
"settings.tasks.source.staging": "Staging",
|
||||||
|
"settings.tasks.action.analyze": "Анализ",
|
||||||
|
"settings.tasks.action.identify": "Поиск",
|
||||||
|
"settings.tasks.action.normalize": "Нормализация",
|
||||||
|
"settings.tasks.action.rename": "Переименование",
|
||||||
|
"settings.tasks.action.export": "Экспорт",
|
||||||
|
"settings.tasks.status.on": "Вкл",
|
||||||
|
"settings.tasks.status.off": "Выкл",
|
||||||
|
"settings.tasks.confirm_delete": "Удалить задачу?",
|
||||||
|
"job.status": "Статус",
|
||||||
|
|
||||||
|
"errors.scan_failed": "Ошибка сканирования",
|
||||||
|
"errors.preview_failed": "Ошибка превью",
|
||||||
|
"errors.apply_failed": "Ошибка применения",
|
||||||
|
"errors.job_fetch": "Ошибка получения статуса задачи",
|
||||||
|
|
||||||
|
"messages.scan_finished": "Сканирование завершено",
|
||||||
|
"messages.apply_started": "Применение запущено",
|
||||||
|
|
||||||
|
"common.none": "—",
|
||||||
|
"common.close": "Закрыть",
|
||||||
|
"common.back": "Назад",
|
||||||
|
"common.save": "Сохранить",
|
||||||
|
"common.cancel": "Отмена",
|
||||||
|
"common.export": "Экспорт",
|
||||||
|
"common.edit": "Редактировать",
|
||||||
|
"common.delete": "Удалить",
|
||||||
|
"common.never": "никогда",
|
||||||
|
"common.loading": "Загрузка…",
|
||||||
|
"common.loading_settings": "Загрузка настроек…",
|
||||||
|
"common.loaded": "Загружено",
|
||||||
|
"common.saving": "Сохранение…",
|
||||||
|
"common.saved": "Сохранено",
|
||||||
|
"common.unsaved_changes": "Есть несохранённые изменения",
|
||||||
|
"settings.unsaved_confirm": "Есть несохранённые изменения. Выйти из настроек?",
|
||||||
|
"common.testing": "Проверка…",
|
||||||
|
"common.running": "Выполняется…",
|
||||||
|
"common.error": "Ошибка",
|
||||||
|
"common.exists": "exists",
|
||||||
|
"common.read": "read",
|
||||||
|
"common.write": "write",
|
||||||
|
"common.rename": "rename",
|
||||||
|
"common.generating_preview": "Генерация превью…",
|
||||||
|
"common.na": "н/д",
|
||||||
|
"common.test": "Тест",
|
||||||
|
"common.yes": "Да",
|
||||||
|
"common.no": "Нет",
|
||||||
|
"meta.title": "Название",
|
||||||
|
"meta.original": "Оригинал",
|
||||||
|
"meta.year": "Год",
|
||||||
|
"meta.provider": "Провайдер",
|
||||||
|
"meta.source": "Источник",
|
||||||
|
"meta.search": "Поиск",
|
||||||
|
"meta.search_placeholder": "Поиск по названию",
|
||||||
|
"meta.manual_title": "Ручное название",
|
||||||
|
"meta.manual_year": "Год",
|
||||||
|
"meta.save": "Сохранить",
|
||||||
|
"meta.clear": "Очистить",
|
||||||
|
"meta.no_results": "Ничего не найдено",
|
||||||
|
|
||||||
|
"settings.page_title": "scMedia / Настройки",
|
||||||
|
"settings.title": "Настройки",
|
||||||
|
"settings.back": "Назад",
|
||||||
|
"settings.tabs.scan_profiles": "Профили сканирования",
|
||||||
|
"settings.tabs.library_layout": "Библиотека",
|
||||||
|
"settings.tabs.plugins": "Плагины",
|
||||||
|
"settings.tabs.tasks": "Задачи",
|
||||||
|
"settings.tabs.rules": "Правила",
|
||||||
|
"settings.tabs.tools": "Программы",
|
||||||
|
"settings.tabs.ui": "Интерфейс",
|
||||||
|
"settings.tabs.logs": "Логи",
|
||||||
|
"settings.tabs.debug": "Отладка",
|
||||||
|
"settings.tabs.about": "О программе",
|
||||||
|
"settings.scan_profiles.title": "Профили сканирования",
|
||||||
|
"settings.scan_profiles.add": "Добавить профиль",
|
||||||
|
"settings.scan_profiles.modal_add": "Добавить профиль",
|
||||||
|
"settings.scan_profiles.modal_edit": "Редактировать профиль",
|
||||||
|
"settings.scan_profiles.confirm_delete": "Удалить профиль?",
|
||||||
|
"settings.scan_profiles.ext_default": "по умолчанию",
|
||||||
|
"settings.scanner_defaults.title": "Глобальные настройки сканера",
|
||||||
|
"settings.library_layout.title": "Библиотека",
|
||||||
|
"settings.library_layout.preview": "Превью",
|
||||||
|
"settings.library_layout.roots": "Папки",
|
||||||
|
"settings.library_layout.add_root": "Добавить",
|
||||||
|
"settings.library_layout.th_type": "Тип",
|
||||||
|
"settings.library_layout.th_path": "Путь",
|
||||||
|
"settings.library_layout.th_status": "Статус",
|
||||||
|
"settings.library_layout.modal_title": "Папка",
|
||||||
|
"settings.library_layout.confirm_delete": "Удалить папку?",
|
||||||
|
"root.type.movie": "Фильмы",
|
||||||
|
"root.type.series": "Сериалы",
|
||||||
|
"root.type.staging": "Стейджинг",
|
||||||
|
"settings.rules.add_rule": "Добавить правило",
|
||||||
|
|
||||||
|
"rules.type.name_map": "Маппинг названий",
|
||||||
|
"rules.type.delete_track": "Удаление треков",
|
||||||
|
"rules.type.priorities": "Приоритеты",
|
||||||
|
"rules.type.lang_fix": "Исправление языка",
|
||||||
|
"rules.type.source_filter": "Фильтр источника",
|
||||||
|
"rules.sort_by": "Сортировка",
|
||||||
|
"rules.sort.name": "Название",
|
||||||
|
"rules.sort.type": "Тип",
|
||||||
|
"rules.th.name": "Название",
|
||||||
|
"rules.th.type": "Тип",
|
||||||
|
"rules.th.summary": "Описание",
|
||||||
|
"rules.th.status": "Статус",
|
||||||
|
"rules.status.on": "Вкл",
|
||||||
|
"rules.status.off": "Выкл",
|
||||||
|
"rules.enable": "Включить",
|
||||||
|
"rules.disable": "Выключить",
|
||||||
|
"rules.unnamed": "Без названия",
|
||||||
|
"rules.confirm_delete": "Удалить правило?",
|
||||||
|
"rules.modal_title": "Правило",
|
||||||
|
"rules.field.name": "Название",
|
||||||
|
"rules.field.enabled": "Активно",
|
||||||
|
"rules.field.pattern": "Шаблон",
|
||||||
|
"rules.field.canonical": "Каноническое",
|
||||||
|
"rules.field.mode": "Режим",
|
||||||
|
"rules.field.track_type": "Тип трека",
|
||||||
|
"rules.field.lang": "Язык",
|
||||||
|
"rules.field.audio_type": "Тип аудио",
|
||||||
|
"rules.field.name_contains": "Имя содержит",
|
||||||
|
"rules.field.except_default": "Кроме default",
|
||||||
|
"rules.field.except_forced": "Кроме forced",
|
||||||
|
"rules.field.languages": "Языки (через запятую)",
|
||||||
|
"rules.field.audio_types": "Типы аудио (через запятую)",
|
||||||
|
"rules.field.from_lang": "Язык из",
|
||||||
|
"rules.field.to_lang": "Язык в",
|
||||||
|
"rules.field.source": "Источник",
|
||||||
|
"rules.field.status": "Статус",
|
||||||
|
"rules.field.conditions": "Условия",
|
||||||
|
"rules.cond.field": "Поле",
|
||||||
|
"rules.cond.op": "Оператор",
|
||||||
|
"rules.cond.value": "Значение",
|
||||||
|
"rules.cond.enabled": "Активно",
|
||||||
|
"rules.cond.add": "Добавить условие",
|
||||||
|
"rules.cond.status": "Статус",
|
||||||
|
"rules.cond.label": "Метка",
|
||||||
|
"rules.cond.name_regex": "Regex имени",
|
||||||
|
"rules.cond.path_regex": "Regex пути",
|
||||||
|
"rules.cond.min_size": "Мин. размер",
|
||||||
|
"rules.op.contains": "содержит",
|
||||||
|
"rules.op.not_contains": "не содержит",
|
||||||
|
"rules.op.any": "любой",
|
||||||
|
"rules.logic.or": "ИЛИ",
|
||||||
|
"rules.statuses.none": "Нет статусов",
|
||||||
|
"rules.field.label": "Метка",
|
||||||
|
"rules.field.name_regex": "Regex имени",
|
||||||
|
"rules.field.path_regex": "Regex пути",
|
||||||
|
"rules.field.min_size": "Мин. размер (байты)",
|
||||||
|
"rules.mode.exact": "точное",
|
||||||
|
"rules.mode.regex": "regex",
|
||||||
|
"rules.any": "Любой",
|
||||||
|
"sources.name": "Название",
|
||||||
|
"sources.size": "Размер",
|
||||||
|
"sources.status": "Статус",
|
||||||
|
"sources.progress": "Готово",
|
||||||
|
"sources.type": "Тип",
|
||||||
|
"sources.type.file": "Файл",
|
||||||
|
"sources.type.folder": "Папка",
|
||||||
|
"sources.files": "Файлы",
|
||||||
|
"sources.detail.title": "Детали источника",
|
||||||
|
"sources.detail.raw": "Сырые данные",
|
||||||
|
"sources.detail.approve": "Подтвердить",
|
||||||
|
"sources.preview.title": "Превью",
|
||||||
|
"sources.preview.current": "Сейчас",
|
||||||
|
"sources.preview.planned": "Будет",
|
||||||
|
"sources.preview.name": "Название",
|
||||||
|
"sources.preview.kind": "Тип",
|
||||||
|
"sources.preview.structure": "Структура",
|
||||||
|
"sources.status.completed": "завершено",
|
||||||
|
"sources.status.downloading": "скачивание",
|
||||||
|
"sources.status.seeding": "раздача",
|
||||||
|
"sources.status.stopped": "остановлено",
|
||||||
|
"sources.status.unknown": "неизвестно",
|
||||||
|
"sources.source": "Источник",
|
||||||
|
"sources.path": "Путь",
|
||||||
|
"settings.plugins.title": "Плагины",
|
||||||
|
"settings.plugins.add": "Добавить плагин",
|
||||||
|
"settings.plugins.meta_settings": "Настройки метаданных",
|
||||||
|
"settings.plugins.omdb_label": "IMDb (OMDb)",
|
||||||
|
"settings.plugins.tvdb_label": "TVDB",
|
||||||
|
"settings.plugins.kodi_label": "Kodi",
|
||||||
|
"settings.plugins.jellyfin_label": "Jellyfin",
|
||||||
|
"settings.plugins.transmission_label": "Transmission",
|
||||||
|
"settings.plugins.th_name": "Плагин",
|
||||||
|
"settings.plugins.th_type": "Тип",
|
||||||
|
"settings.plugins.th_status": "Статус",
|
||||||
|
"settings.plugins.kind.meta": "Метаданные",
|
||||||
|
"settings.plugins.kind.export": "Экспорт",
|
||||||
|
"settings.plugins.kind.source": "Источник",
|
||||||
|
"settings.plugins.modal_title": "Плагин",
|
||||||
|
"settings.plugins.requires_meta": "Сначала включите настройки метаданных",
|
||||||
|
"settings.plugins.requires_test": "Сначала проверьте подключение",
|
||||||
|
"settings.plugins.install_placeholder": "Установка скоро появится",
|
||||||
|
"settings.plugins.metadata": "Провайдеры метаданных",
|
||||||
|
"settings.plugins.languages": "Языки метаданных (через запятую)",
|
||||||
|
"settings.plugins.provider_priority": "Приоритет провайдеров (через запятую)",
|
||||||
|
"settings.plugins.enable": "Включено",
|
||||||
|
"settings.plugins.omdb_key": "OMDb API ключ",
|
||||||
|
"settings.plugins.tvdb_key": "TVDB API ключ",
|
||||||
|
"settings.plugins.tvdb_pin": "TVDB PIN (опционально)",
|
||||||
|
"settings.plugins.exports": "Плагины экспорта",
|
||||||
|
"settings.plugins.sources": "Плагины источников",
|
||||||
|
"settings.plugins.transmission_protocol": "Протокол",
|
||||||
|
"settings.plugins.transmission_host": "Хост",
|
||||||
|
"settings.plugins.transmission_port": "Порт",
|
||||||
|
"settings.plugins.transmission_path": "RPC путь",
|
||||||
|
"settings.plugins.transmission_user": "Пользователь",
|
||||||
|
"settings.plugins.transmission_pass": "Пароль",
|
||||||
|
"settings.plugins.transmission_display_fields": "Поля для отображения (через запятую)",
|
||||||
|
"settings.plugins.transmission_test": "Проверить подключение",
|
||||||
|
"settings.plugins.transmission_ok": "OK",
|
||||||
|
"settings.plugins.transmission_missing": "Заполните обязательные поля",
|
||||||
|
"settings.plugins.transmission_unauthorized": "Нет доступа. Проверьте RPC логин/пароль.",
|
||||||
|
"settings.plugins.transmission_forbidden": "Доступ запрещен. Проверьте whitelist или учетные данные.",
|
||||||
|
"settings.plugins.transmission_rpc_failed": "RPC не ответил. Проверьте адрес или доступ.",
|
||||||
|
"settings.plugins.kodi_hint": "Пишет movie.nfo / tvshow.nfo рядом с файлами",
|
||||||
|
"settings.plugins.jellyfin_hint": "Пишет movie.nfo / tvshow.nfo рядом с файлами",
|
||||||
|
"settings.debug.title": "Отладка",
|
||||||
|
"settings.debug.content.title": "Данные контента",
|
||||||
|
"settings.debug.content.files": "Медиафайлы",
|
||||||
|
"settings.debug.content.meta": "Метаданные",
|
||||||
|
"settings.debug.content.items": "Индексированные элементы",
|
||||||
|
"settings.debug.content.clear_btn": "Очистить контент",
|
||||||
|
"settings.debug.db.title": "База данных",
|
||||||
|
"settings.debug.db.tables": "Таблицы",
|
||||||
|
"settings.debug.db.size": "Размер",
|
||||||
|
"settings.debug.db.reset_btn": "Сбросить БД",
|
||||||
|
"settings.debug.dump.title": "Дамп базы",
|
||||||
|
"settings.debug.dump.download": "Скачать дамп",
|
||||||
|
"settings.debug.dump.restore": "Восстановить дамп",
|
||||||
|
"settings.debug.dump.restore_confirm": "Восстановить базу из дампа? Текущие данные будут перезаписаны.",
|
||||||
|
|
||||||
|
"settings.language": "Язык",
|
||||||
|
|
||||||
|
"settings.scan_profiles.th_on": "Вкл",
|
||||||
|
"settings.scan_profiles.th_type": "Тип",
|
||||||
|
"settings.scan_profiles.th_name": "Название",
|
||||||
|
"settings.scan_profiles.th_root": "Путь",
|
||||||
|
"settings.scan_profiles.th_depth": "Глубина",
|
||||||
|
"settings.scan_profiles.th_excludes": "Исключения",
|
||||||
|
"settings.scan_profiles.th_ext": "Ext",
|
||||||
|
"settings.scan_profiles.th_last_scan": "Последний скан",
|
||||||
|
"settings.scan_profiles.th_result": "Результат",
|
||||||
|
|
||||||
|
"settings.scanner_defaults.video_ext": "Расширения видео (через запятую)",
|
||||||
|
"settings.scanner_defaults.video_ext_ph": "mkv,mp4,avi",
|
||||||
|
"settings.scanner_defaults.max_depth": "Макс. глубина по умолчанию",
|
||||||
|
"settings.scanner_defaults.max_files": "Макс. файлов на элемент",
|
||||||
|
"settings.scanner_defaults.max_items": "Макс. элементов за скан (0 = без лимита)",
|
||||||
|
|
||||||
|
"settings.library_layout.movies_root": "Папка фильмов",
|
||||||
|
"settings.library_layout.movies_root_ph": "/mnt/media/library/movies",
|
||||||
|
"settings.library_layout.series_root": "Папка сериалов",
|
||||||
|
"settings.library_layout.series_root_ph": "/mnt/media/library/series",
|
||||||
|
"settings.library_layout.staging_root": "Staging (опционально)",
|
||||||
|
"settings.library_layout.staging_root_ph": "/mnt/media/.staging",
|
||||||
|
|
||||||
|
"settings.library_layout.movies_strategy_title": "Стратегия фильмов",
|
||||||
|
"settings.library_layout.series_strategy_title": "Стратегия сериалов",
|
||||||
|
"settings.library_layout.strategy": "Стратегия",
|
||||||
|
"settings.library_layout.season_naming": "Название сезона",
|
||||||
|
"settings.library_layout.normalization_title": "Нормализация",
|
||||||
|
"settings.library_layout.collision_title": "Политика коллизий",
|
||||||
|
"settings.library_layout.preview_title": "Превью",
|
||||||
|
"settings.library_layout.preview_hint": "Нажмите «Сгенерировать превью».",
|
||||||
|
|
||||||
|
"settings.strategy.flat": "Плоско",
|
||||||
|
"settings.strategy.first_letter": "По первой букве",
|
||||||
|
"settings.strategy.prefix": "По первым N буквам",
|
||||||
|
"settings.strategy.hash_buckets": "По числовым корзинам (hash)",
|
||||||
|
"settings.strategy.by_year": "По году",
|
||||||
|
"settings.strategy.letter_year": "Буква + год",
|
||||||
|
"settings.strategy.decade_year": "Десятилетие + год",
|
||||||
|
"settings.strategy.custom": "Пользовательский шаблон",
|
||||||
|
"settings.strategy.n": "N",
|
||||||
|
"settings.strategy.buckets": "Корзины",
|
||||||
|
"settings.strategy.template": "Шаблон",
|
||||||
|
"settings.strategy.template_ph_movies": "{first:2}/{title} ({year})",
|
||||||
|
"settings.strategy.template_ph_series": "{first}/{title}",
|
||||||
|
"settings.strategy.vars_hint": "Переменные: {title} {year} {decade} {first} {first:2} {first:3} {hash:2}",
|
||||||
|
|
||||||
|
"settings.season.season_2digit": "Season 01",
|
||||||
|
"settings.season.s_2digit": "S01",
|
||||||
|
"settings.season.season_plain": "Season 1",
|
||||||
|
|
||||||
|
"settings.norm.ignore_articles": "Игнорировать артикли (The/A/An)",
|
||||||
|
"settings.norm.uppercase_shards": "Папки-шарды в верхнем регистре",
|
||||||
|
"settings.norm.replace_unsafe": "Заменять небезопасные символы",
|
||||||
|
"settings.norm.trim_dots": "Убирать точки/пробелы по краям",
|
||||||
|
"settings.norm.transliterate_later": "Транслитерация не-латиницы (позже)",
|
||||||
|
"settings.norm.ignore_words": "Список игнорируемых слов (через запятую)",
|
||||||
|
"settings.norm.ignore_words_ph": "sample,extras",
|
||||||
|
|
||||||
|
"settings.collision.stop": "Остановиться и отметить конфликт",
|
||||||
|
"settings.collision.append_num": "Добавлять -1, -2",
|
||||||
|
"settings.collision.append_hash": "Добавлять короткий hash",
|
||||||
|
|
||||||
|
"settings.debug.content.ph": "Введите CLEAR CONTENT",
|
||||||
|
"settings.debug.db.ph": "Введите RESET DATABASE",
|
||||||
|
|
||||||
|
"settings.scan_profiles.modal_title": "Профиль",
|
||||||
|
"settings.scan_profiles.modal_enabled": "Включен",
|
||||||
|
"settings.scan_profiles.modal_name": "Название",
|
||||||
|
"settings.scan_profiles.modal_name_ph": "Incoming",
|
||||||
|
"settings.scan_profiles.modal_root": "Путь",
|
||||||
|
"settings.scan_profiles.modal_root_ph": "/mnt/downloads/complete",
|
||||||
|
"settings.scan_profiles.modal_depth": "Макс. глубина",
|
||||||
|
"settings.scan_profiles.modal_type": "Тип профиля",
|
||||||
|
"settings.scan_profiles.type_scan": "Поиск",
|
||||||
|
"settings.scan_profiles.type_analyze": "Анализ",
|
||||||
|
"settings.scan_profiles.move": "Переместить",
|
||||||
|
"settings.scan_profiles.modal_excludes": "Исключающие шаблоны (через запятую)",
|
||||||
|
"settings.scan_profiles.modal_excludes_ph": "@eaDir,sample,extras",
|
||||||
|
"settings.scan_profiles.modal_ext_mode": "Режим расширений",
|
||||||
|
"settings.scan_profiles.ext_custom": "Пользовательский",
|
||||||
|
"settings.scan_profiles.modal_ext_custom": "Пользовательские расширения (через запятую)",
|
||||||
|
"settings.scan_profiles.modal_ext_custom_ph": "mkv,mp4",
|
||||||
|
"settings.about.title": "О программе",
|
||||||
|
"settings.about.ui_version": "Версия UI",
|
||||||
|
"settings.about.backend_version": "Версия backend",
|
||||||
|
"settings.about.db_version": "Версия БД",
|
||||||
|
"settings.about.table_name": "Название",
|
||||||
|
"settings.about.table_type": "Тип",
|
||||||
|
"settings.about.table_author": "Автор",
|
||||||
|
"settings.about.table_version": "Версия",
|
||||||
|
"settings.about.table_update": "Обновление",
|
||||||
|
|
||||||
|
"media.tabs.movies": "Фильмы",
|
||||||
|
"media.tabs.series": "Сериалы",
|
||||||
|
"media.tabs.sources": "Источники",
|
||||||
|
"media.container": "Контейнер",
|
||||||
|
"media.size": "Размер",
|
||||||
|
"media.duration": "Длительность",
|
||||||
|
"media.track.type": "Тип",
|
||||||
|
"media.track.lang": "Язык",
|
||||||
|
"media.track.name": "Название",
|
||||||
|
"media.track.codec": "Кодек",
|
||||||
|
"media.track.channels": "Каналы",
|
||||||
|
"media.track.flags": "Флаги",
|
||||||
|
"media.track.audio_type": "Тип аудио",
|
||||||
|
"media.actions.dry_run": "Сухой прогон",
|
||||||
|
"media.actions.apply": "Применить",
|
||||||
|
"media.dry_run.title": "Сухой прогон",
|
||||||
|
"media.dry_run.summary": "Сводка",
|
||||||
|
"media.dry_run.files": "Файлы",
|
||||||
|
"media.dry_run.rename": "Переименовать",
|
||||||
|
"media.dry_run.delete": "Удалить",
|
||||||
|
"media.dry_run.unknown": "Неизвестный тип",
|
||||||
|
"media.dry_run.convert": "Конвертировать",
|
||||||
|
"media.dry_run.details": "Детали",
|
||||||
|
|
||||||
|
"settings.rules.title": "Правила",
|
||||||
|
"settings.rules.name_map": "Справочник озвучек",
|
||||||
|
"settings.rules.pattern": "Шаблон",
|
||||||
|
"settings.rules.canonical": "Канон",
|
||||||
|
"settings.rules.mode": "Режим",
|
||||||
|
"settings.rules.mode_exact": "точно",
|
||||||
|
"settings.rules.mode_regex": "regex",
|
||||||
|
"settings.rules.add_name_map": "Добавить правило",
|
||||||
|
"settings.rules.delete_rules": "Удаление дорожек",
|
||||||
|
"settings.rules.type": "Тип",
|
||||||
|
"settings.rules.lang": "Язык",
|
||||||
|
"settings.rules.audio_type": "Тип аудио",
|
||||||
|
"settings.rules.name_contains": "Имя содержит",
|
||||||
|
"settings.rules.except_default": "Искл. default",
|
||||||
|
"settings.rules.except_forced": "Искл. forced",
|
||||||
|
"settings.rules.add_delete_rule": "Добавить правило",
|
||||||
|
"settings.rules.priorities": "Приоритеты",
|
||||||
|
"settings.rules.language_priority": "Приоритет языков (через запятую)",
|
||||||
|
"settings.rules.audio_priority": "Приоритет типов аудио (через запятую)",
|
||||||
|
"settings.rules.require_audio_type": "Требовать тип аудио",
|
||||||
|
"settings.rules.series_threshold": "Порог совпадения порядка",
|
||||||
|
|
||||||
|
"settings.tools.title": "Программы",
|
||||||
|
"settings.tools.add": "Добавить",
|
||||||
|
"settings.tools.detect": "Определить",
|
||||||
|
"settings.tools.th_name": "Программа",
|
||||||
|
"settings.tools.th_path": "Путь",
|
||||||
|
"settings.ui.title": "Интерфейс",
|
||||||
|
"settings.ui.table_mode": "Навигация таблиц",
|
||||||
|
"settings.ui.table_mode_pages": "Страницы",
|
||||||
|
"settings.ui.table_mode_infinite": "Бесконечная прокрутка",
|
||||||
|
"settings.ui.table_page_size": "Строк на странице",
|
||||||
|
"settings.ui.sse_tick": "SSE тик (сек)",
|
||||||
|
"settings.ui.sse_snapshot": "SSE снимок (сек)",
|
||||||
|
"settings.ui.note": "Применяется ко всем таблицам",
|
||||||
|
"settings.background.title": "Фоновые политики",
|
||||||
|
"settings.background.mode": "Режим",
|
||||||
|
"settings.background.mode_light": "Легкий",
|
||||||
|
"settings.background.mode_normal": "Нормальный",
|
||||||
|
"settings.background.mode_aggressive": "Агрессивный",
|
||||||
|
"settings.background.max_parallel": "Макс параллельных задач",
|
||||||
|
"settings.background.max_network": "Макс сетевых задач",
|
||||||
|
"settings.background.max_io": "Макс IO задач",
|
||||||
|
"settings.background.batch_sleep": "Пауза между пакетами (мс)",
|
||||||
|
"settings.background.watchdog": "Watchdog зависаний (мин)",
|
||||||
|
"settings.background.pause": "Пауза фоновых задач",
|
||||||
|
"settings.background.note": "Применяется ко всем фоновым задачам",
|
||||||
|
"settings.tools.modal_title": "Программа",
|
||||||
|
"settings.tools.detected": "Найдено",
|
||||||
|
"settings.tools.mkvmerge": "Путь к mkvmerge",
|
||||||
|
"settings.tools.mkvpropedit": "Путь к mkvpropedit",
|
||||||
|
"settings.tools.ffmpeg": "Путь к ffmpeg",
|
||||||
|
|
||||||
|
"settings.logs.title": "Логи",
|
||||||
|
"settings.logs.date": "Дата",
|
||||||
|
"settings.logs.date_from": "С",
|
||||||
|
"settings.logs.date_to": "По",
|
||||||
|
"settings.logs.filter_level": "Уровень",
|
||||||
|
"settings.logs.retention": "Хранение",
|
||||||
|
"settings.logs.level": "Уровень логов",
|
||||||
|
"settings.logs.forever": "Всегда",
|
||||||
|
"settings.logs.load": "Показать",
|
||||||
|
"settings.logs.cleanup": "Очистить",
|
||||||
|
"settings.logs.cleaned": "Очищено",
|
||||||
|
"settings.logs.date_required": "Выберите период",
|
||||||
|
"settings.logs.reset": "Сброс",
|
||||||
|
"settings.logs.tab_view": "Показ",
|
||||||
|
"settings.logs.tab_settings": "Настройки",
|
||||||
|
"settings.logs.retention_warn": "Старые логи будут удалены. Продолжить?",
|
||||||
|
"settings.logs.delete_all_warn": "Вы удаляете все логи. Продолжить?",
|
||||||
|
"settings.logs.empty": "Ничего нет",
|
||||||
|
|
||||||
|
"settings.preview.movies": "MOVIES",
|
||||||
|
"settings.preview.series": "SERIES",
|
||||||
|
"settings.tasks.title": "Задачи",
|
||||||
|
"settings.tasks.add": "Добавить задачу",
|
||||||
|
"settings.tasks.th_name": "Задача",
|
||||||
|
"settings.tasks.th_sources": "Источники",
|
||||||
|
"settings.tasks.th_actions": "Действия",
|
||||||
|
"settings.tasks.th_status": "Статус",
|
||||||
|
|
||||||
|
"auth.page_title": "scMedia / Вход",
|
||||||
|
"auth.login_title": "Вход",
|
||||||
|
"auth.login_hint": "Используйте email и пароль.",
|
||||||
|
"auth.email": "Email",
|
||||||
|
"auth.password": "Пароль",
|
||||||
|
"auth.remember": "Запомнить сессию",
|
||||||
|
"auth.login_btn": "Войти",
|
||||||
|
"auth.forgot": "Забыли пароль?",
|
||||||
|
"auth.forgot_send": "Отправить ссылку",
|
||||||
|
"auth.mfa_title": "Код 2FA",
|
||||||
|
"auth.mfa_code": "Код",
|
||||||
|
"auth.mfa_verify": "Проверить",
|
||||||
|
"auth.error.email_password_required": "Email и пароль обязательны",
|
||||||
|
"auth.error.invalid_credentials": "Неверные учетные данные",
|
||||||
|
"auth.error.email_required": "Введите email",
|
||||||
|
"auth.error.request_failed": "Запрос не выполнен",
|
||||||
|
"auth.error.reset_sent": "Если email существует, будет отправлена ссылка для сброса.",
|
||||||
|
"auth.error.code_required": "Введите код",
|
||||||
|
"auth.error.invalid_code": "Неверный код",
|
||||||
|
"auth.error.login_failed": "Вход не выполнен",
|
||||||
|
"auth.error.too_many_attempts": "Слишком много попыток. Повторите через {seconds}с.",
|
||||||
|
"auth.logout": "Выйти",
|
||||||
|
"nav.menu": "Меню",
|
||||||
|
"nav.home": "Главная",
|
||||||
|
"nav.settings": "Настройки",
|
||||||
|
"nav.account": "Профиль"
|
||||||
|
}
|
||||||
BIN
public/assets/icons/scmedia.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
161
public/assets/js/account.js
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
// public/assets/account.js
|
||||||
|
/* English comments: account profile + preferences */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const state = {
|
||||||
|
profile: null,
|
||||||
|
ui: null,
|
||||||
|
};
|
||||||
|
let avatarUrl = null;
|
||||||
|
|
||||||
|
function qs(id) {
|
||||||
|
return window.UI?.qs ? window.UI.qs(id) : document.getElementById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text) {
|
||||||
|
const hint = qs('accountPasswordHint');
|
||||||
|
if (hint) hint.textContent = text || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAvatarBlob(blob) {
|
||||||
|
const avatar = qs('accountAvatar');
|
||||||
|
if (!avatar || !blob) return;
|
||||||
|
if (avatarUrl) {
|
||||||
|
URL.revokeObjectURL(avatarUrl);
|
||||||
|
}
|
||||||
|
avatarUrl = URL.createObjectURL(blob);
|
||||||
|
avatar.src = avatarUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAvatar() {
|
||||||
|
const token = window.Auth?.getAccessToken?.();
|
||||||
|
if (!token) return;
|
||||||
|
const res = await fetch('/api/account/avatar', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
|
const blob = await res.blob();
|
||||||
|
setAvatarBlob(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyProfile(profile) {
|
||||||
|
state.profile = profile || {};
|
||||||
|
if (qs('accountNickname')) qs('accountNickname').value = state.profile.nickname || '';
|
||||||
|
if (qs('accountEmail')) qs('accountEmail').value = state.profile.email || '';
|
||||||
|
if (state.profile.avatar_present) {
|
||||||
|
fetchAvatar().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyUi(ui) {
|
||||||
|
state.ui = ui || {};
|
||||||
|
if (qs('accountLanguage')) qs('accountLanguage').value = state.ui.language || 'en';
|
||||||
|
if (qs('accountTheme')) qs('accountTheme').value = state.ui.theme || 'dark';
|
||||||
|
if (qs('accountTableMode')) qs('accountTableMode').value = state.ui.table_mode || 'pagination';
|
||||||
|
if (qs('accountTableSize')) qs('accountTableSize').value = String(state.ui.table_page_size || 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAccount() {
|
||||||
|
const res = await window.Api.request('/api/account', { method: 'GET' });
|
||||||
|
const data = res?.data || res || {};
|
||||||
|
applyProfile(data.profile || {});
|
||||||
|
applyUi(data.ui || {});
|
||||||
|
if (window.UserPrefs?.load) {
|
||||||
|
await window.UserPrefs.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProfile() {
|
||||||
|
const nickname = (qs('accountNickname')?.value || '').trim();
|
||||||
|
const email = (qs('accountEmail')?.value || '').trim();
|
||||||
|
const ui = {
|
||||||
|
language: qs('accountLanguage')?.value || 'en',
|
||||||
|
theme: qs('accountTheme')?.value || 'dark',
|
||||||
|
table_mode: qs('accountTableMode')?.value || 'pagination',
|
||||||
|
table_page_size: Number(qs('accountTableSize')?.value || 50),
|
||||||
|
};
|
||||||
|
await window.Api.request('/api/account', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { nickname, ui },
|
||||||
|
});
|
||||||
|
if (window.UserPrefs?.setUi) {
|
||||||
|
Object.keys(ui).forEach((key) => window.UserPrefs.setUi(key, ui[key]));
|
||||||
|
}
|
||||||
|
if (email && email !== state.profile?.email) {
|
||||||
|
await window.Api.request('/api/account/email', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (ui.theme && window.UI?.setTheme) {
|
||||||
|
window.UI.setTheme(ui.theme);
|
||||||
|
}
|
||||||
|
if (ui.language && ui.language !== (window.APP_LANG || 'en')) {
|
||||||
|
localStorage.setItem('scmedia_lang', ui.language);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAvatar() {
|
||||||
|
const file = qs('avatarFile')?.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
alert('File too large (max 5MB)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('avatar', file);
|
||||||
|
await window.Api.request('/api/account/avatar', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
headers: {},
|
||||||
|
});
|
||||||
|
await fetchAvatar();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePassword() {
|
||||||
|
const current = qs('accountPasswordCurrent')?.value || '';
|
||||||
|
const next = qs('accountPasswordNew')?.value || '';
|
||||||
|
const confirm = qs('accountPasswordConfirm')?.value || '';
|
||||||
|
if (!current || !next) {
|
||||||
|
setStatus('Missing password');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (next !== confirm) {
|
||||||
|
setStatus('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus('Saving…');
|
||||||
|
await window.Api.request('/api/account/password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { current, next },
|
||||||
|
});
|
||||||
|
setStatus('Saved');
|
||||||
|
qs('accountPasswordCurrent').value = '';
|
||||||
|
qs('accountPasswordNew').value = '';
|
||||||
|
qs('accountPasswordConfirm').value = '';
|
||||||
|
if (window.Auth?.logout) {
|
||||||
|
window.Auth.logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window.Auth?.clearTokens) {
|
||||||
|
window.Auth.clearTokens();
|
||||||
|
}
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (window.Auth?.requireAuth) {
|
||||||
|
window.Auth.requireAuth();
|
||||||
|
}
|
||||||
|
window.UI?.initHeader?.();
|
||||||
|
window.UI?.initThemeToggle?.();
|
||||||
|
window.UI?.bindThemePreference?.();
|
||||||
|
loadAccount().catch(() => {});
|
||||||
|
|
||||||
|
qs('btnAccountSave')?.addEventListener('click', () => saveProfile().catch((e) => alert(e.message)));
|
||||||
|
qs('btnAvatarUpload')?.addEventListener('click', () => uploadAvatar().catch((e) => alert(e.message)));
|
||||||
|
qs('btnPasswordChange')?.addEventListener('click', () => changePassword().catch((e) => setStatus(e.message)));
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
})();
|
||||||
157
public/assets/js/admin.js
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
// public/assets/admin.js
|
||||||
|
/* English comments: admin user management + audit */
|
||||||
|
|
||||||
|
function fmtDate(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
const d = new Date(s.replace(' ', 'T') + 'Z');
|
||||||
|
if (Number.isNaN(d.getTime())) return s;
|
||||||
|
return d.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminApi(path, opts = {}) {
|
||||||
|
return window.Api.request(path, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAdminError(err) {
|
||||||
|
const message = err?.message || 'Request failed';
|
||||||
|
alert(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRoles() {
|
||||||
|
const res = await adminApi('/api/admin/roles');
|
||||||
|
return res?.data?.items || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
const res = await adminApi('/api/admin/users');
|
||||||
|
return res?.data?.items || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAudit() {
|
||||||
|
const res = await adminApi('/api/admin/audit', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { page: 1, per_page: 50 },
|
||||||
|
});
|
||||||
|
return res?.data?.items || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUsers(users, roles) {
|
||||||
|
const body = UI.qs('adminUsers');
|
||||||
|
if (!body) return;
|
||||||
|
body.innerHTML = '';
|
||||||
|
users.forEach((u) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const rolesList = (u.roles || '').split(',').filter(Boolean);
|
||||||
|
const status = u.status || '';
|
||||||
|
|
||||||
|
const roleOptions = roles.map((r) => {
|
||||||
|
const selected = rolesList.includes(r.name) ? 'selected' : '';
|
||||||
|
return `<option value="${r.name}" ${selected}>${r.name}</option>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${u.email || ''}</td>
|
||||||
|
<td>
|
||||||
|
<select data-action="role" data-id="${u.id}">
|
||||||
|
${roleOptions}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>${status}</td>
|
||||||
|
<td>${fmtDate(u.last_login_at)}</td>
|
||||||
|
<td>
|
||||||
|
<div class="admin-actions">
|
||||||
|
<button class="btn" data-action="toggle" data-id="${u.id}" data-status="${status}">
|
||||||
|
${status === 'disabled' ? 'Enable' : 'Disable'}
|
||||||
|
</button>
|
||||||
|
<button class="btn" data-action="reset2fa" data-id="${u.id}">Reset 2FA</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
body.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAudit(items) {
|
||||||
|
const body = UI.qs('adminAudit');
|
||||||
|
if (!body) return;
|
||||||
|
body.innerHTML = '';
|
||||||
|
items.forEach((a) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${fmtDate(a.created_at)}</td>
|
||||||
|
<td>${a.actor_email || a.actor_user_id || ''}</td>
|
||||||
|
<td>${a.action || ''}</td>
|
||||||
|
<td>${a.target_type || ''} ${a.target_id || ''}</td>
|
||||||
|
<td class="muted">${a.meta_json || ''}</td>
|
||||||
|
`;
|
||||||
|
body.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let actionsBound = false;
|
||||||
|
|
||||||
|
async function bindActions(roles) {
|
||||||
|
if (actionsBound) return;
|
||||||
|
const body = UI.qs('adminUsers');
|
||||||
|
if (!body) return;
|
||||||
|
body.addEventListener('change', async (e) => {
|
||||||
|
const sel = e.target;
|
||||||
|
if (!sel || sel.dataset.action !== 'role') return;
|
||||||
|
const userId = sel.dataset.id;
|
||||||
|
const role = sel.value;
|
||||||
|
try {
|
||||||
|
await adminApi(`/api/admin/users/${userId}/roles`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ roles: [role] }),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
handleAdminError(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
body.addEventListener('click', async (e) => {
|
||||||
|
const btn = e.target.closest('button');
|
||||||
|
if (!btn) return;
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
const userId = btn.dataset.id;
|
||||||
|
if (action === 'toggle') {
|
||||||
|
const disabled = btn.dataset.status !== 'disabled';
|
||||||
|
try {
|
||||||
|
await adminApi(`/api/admin/users/${userId}/disable`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ disabled }),
|
||||||
|
});
|
||||||
|
init();
|
||||||
|
} catch (err) {
|
||||||
|
handleAdminError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (action === 'reset2fa') {
|
||||||
|
try {
|
||||||
|
await adminApi(`/api/admin/users/${userId}/reset-2fa`, { method: 'POST' });
|
||||||
|
} catch (err) {
|
||||||
|
handleAdminError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
actionsBound = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
window.Auth.requireAuth();
|
||||||
|
window.UI?.initHeader?.();
|
||||||
|
if (!window.Auth.isAdmin()) {
|
||||||
|
UI.qs('adminUsers').innerHTML = '<tr><td colspan="5">Admin only</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [roles, users, audit] = await Promise.all([loadRoles(), loadUsers(), loadAudit()]);
|
||||||
|
renderUsers(users, roles);
|
||||||
|
renderAudit(audit);
|
||||||
|
bindActions(roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnReloadUsers = UI.qs('btnReloadUsers');
|
||||||
|
if (btnReloadUsers) btnReloadUsers.addEventListener('click', init);
|
||||||
|
const btnReloadAudit = UI.qs('btnReloadAudit');
|
||||||
|
if (btnReloadAudit) btnReloadAudit.addEventListener('click', init);
|
||||||
|
|
||||||
|
init();
|
||||||
86
public/assets/js/api.js
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
// public/assets/api.js
|
||||||
|
/* English comments: centralized API wrapper */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
function normalizeDateString(value) {
|
||||||
|
if (typeof value !== 'string') return value;
|
||||||
|
const s = value.trim();
|
||||||
|
if (s === '') return value;
|
||||||
|
const isoDate = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
const isoDateTime = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/;
|
||||||
|
const dotDate = /^\d{2}\.\d{2}\.\d{4}$/;
|
||||||
|
const dotDateTime = /^\d{2}\.\d{2}\.\d{4}\s+\d{2}:\d{2}:\d{2}$/;
|
||||||
|
let v = s;
|
||||||
|
if (isoDate.test(v)) v = `${v} 00:00:00`;
|
||||||
|
if (dotDate.test(v)) {
|
||||||
|
const parts = v.split('.');
|
||||||
|
v = `${parts[2]}-${parts[1]}-${parts[0]} 00:00:00`;
|
||||||
|
} else if (dotDateTime.test(v)) {
|
||||||
|
const [datePart, timePart] = v.split(/\s+/, 2);
|
||||||
|
const parts = datePart.split('.');
|
||||||
|
v = `${parts[2]}-${parts[1]}-${parts[0]} ${timePart}`;
|
||||||
|
}
|
||||||
|
if (isoDateTime.test(v)) {
|
||||||
|
const d = new Date(v.replace(' ', 'T') + 'Z');
|
||||||
|
if (!Number.isNaN(d.getTime())) {
|
||||||
|
return d.toISOString().slice(0, 19).replace('T', ' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDates(payload) {
|
||||||
|
if (payload === null || payload === undefined) return payload;
|
||||||
|
if (Array.isArray(payload)) return payload.map(normalizeDates);
|
||||||
|
if (typeof payload !== 'object') return payload;
|
||||||
|
const out = {};
|
||||||
|
Object.keys(payload).forEach((key) => {
|
||||||
|
const value = payload[key];
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
out[key] = normalizeDates(value);
|
||||||
|
} else if (typeof value === 'string' && (key === 'ts' || key.endsWith('_at') || key.endsWith('_ts'))) {
|
||||||
|
out[key] = normalizeDateString(value);
|
||||||
|
} else {
|
||||||
|
out[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path, opts = {}) {
|
||||||
|
const headers = { ...(opts.headers || {}) };
|
||||||
|
const hasBodyObject = opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData);
|
||||||
|
const body = hasBodyObject ? JSON.stringify(normalizeDates(opts.body)) : opts.body;
|
||||||
|
if (hasBodyObject && !headers['Content-Type']) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
if (window.Auth?.isAccessExpired?.() && window.Auth?.refreshTokens) {
|
||||||
|
await window.Auth.refreshTokens();
|
||||||
|
}
|
||||||
|
const token = window.Auth?.getAccessToken?.();
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
let res = await fetch(path, { credentials: 'same-origin', ...opts, headers, body });
|
||||||
|
if (res.status === 401 && window.Auth?.refreshTokens) {
|
||||||
|
const refreshed = await window.Auth.refreshTokens();
|
||||||
|
if (refreshed) {
|
||||||
|
const newToken = window.Auth.getAccessToken();
|
||||||
|
if (newToken) headers['Authorization'] = `Bearer ${newToken}`;
|
||||||
|
res = await fetch(path, { credentials: 'same-origin', ...opts, headers, body });
|
||||||
|
} else if (window.Auth?.redirectToLogin) {
|
||||||
|
window.Auth.redirectToLogin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const json = await res.json().catch(() => null);
|
||||||
|
if (!json || json.ok !== true) {
|
||||||
|
const msg = json?.error?.message || `Request failed: ${res.status}`;
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Api = {
|
||||||
|
request,
|
||||||
|
};
|
||||||
|
})();
|
||||||
1
public/assets/js/app-config.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
const APP_VERSION = "0.1.0";
|
||||||
1743
public/assets/js/app.js
Normal file
217
public/assets/js/auth.js
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
// public/assets/auth.js
|
||||||
|
/* English comments: auth token helpers and login UI */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const LS_ACCESS = 'scmedia_access_token';
|
||||||
|
const LS_REFRESH = 'scmedia_refresh_token';
|
||||||
|
const LS_CLIENT = 'scmedia_client_type';
|
||||||
|
let refreshPromise = null;
|
||||||
|
let refreshTimer = null;
|
||||||
|
|
||||||
|
function getAccessToken() {
|
||||||
|
return localStorage.getItem(LS_ACCESS) || sessionStorage.getItem(LS_ACCESS) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRefreshToken() {
|
||||||
|
return localStorage.getItem(LS_REFRESH) || sessionStorage.getItem(LS_REFRESH) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientType() {
|
||||||
|
return localStorage.getItem(LS_CLIENT) || sessionStorage.getItem(LS_CLIENT) || 'web';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTokens(tokens, clientType = 'web', remember = true) {
|
||||||
|
const storage = remember ? localStorage : sessionStorage;
|
||||||
|
if (tokens?.access_token) storage.setItem(LS_ACCESS, tokens.access_token);
|
||||||
|
if (tokens?.refresh_token) storage.setItem(LS_REFRESH, tokens.refresh_token);
|
||||||
|
storage.setItem(LS_CLIENT, clientType);
|
||||||
|
const other = remember ? sessionStorage : localStorage;
|
||||||
|
other.removeItem(LS_ACCESS);
|
||||||
|
other.removeItem(LS_REFRESH);
|
||||||
|
other.removeItem(LS_CLIENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTokens() {
|
||||||
|
localStorage.removeItem(LS_ACCESS);
|
||||||
|
localStorage.removeItem(LS_REFRESH);
|
||||||
|
localStorage.removeItem(LS_CLIENT);
|
||||||
|
sessionStorage.removeItem(LS_ACCESS);
|
||||||
|
sessionStorage.removeItem(LS_REFRESH);
|
||||||
|
sessionStorage.removeItem(LS_CLIENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJwt(token) {
|
||||||
|
if (!token) return null;
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
||||||
|
return payload || null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoles() {
|
||||||
|
const payload = parseJwt(getAccessToken());
|
||||||
|
return Array.isArray(payload?.roles) ? payload.roles : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAccessExpired(leewaySeconds = 30) {
|
||||||
|
const payload = parseJwt(getAccessToken());
|
||||||
|
const exp = Number(payload?.exp || 0);
|
||||||
|
if (!exp) return true;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
return exp <= (now + leewaySeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdmin() {
|
||||||
|
return getRoles().includes('admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectToLogin() {
|
||||||
|
if (window.location.pathname !== '/login') {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAuth() {
|
||||||
|
const access = getAccessToken();
|
||||||
|
const refresh = getRefreshToken();
|
||||||
|
if (!access && !refresh) {
|
||||||
|
redirectToLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!access && refresh) {
|
||||||
|
refreshTokens().then((ok) => {
|
||||||
|
if (!ok) redirectToLogin();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshTokens() {
|
||||||
|
if (refreshPromise) return refreshPromise;
|
||||||
|
const refresh = getRefreshToken();
|
||||||
|
if (!refresh) return false;
|
||||||
|
const clientType = getClientType();
|
||||||
|
refreshPromise = (async () => {
|
||||||
|
const tryRefresh = async (token) => {
|
||||||
|
const res = await fetch('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refresh_token: token, client_type: clientType }),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data?.ok) return null;
|
||||||
|
return data?.data || {};
|
||||||
|
};
|
||||||
|
|
||||||
|
let data = await tryRefresh(refresh);
|
||||||
|
if (!data) {
|
||||||
|
const current = getRefreshToken();
|
||||||
|
if (current && current !== refresh) {
|
||||||
|
data = await tryRefresh(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
clearTokens();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setTokens(data, clientType);
|
||||||
|
return true;
|
||||||
|
})();
|
||||||
|
try {
|
||||||
|
return await refreshPromise;
|
||||||
|
} finally {
|
||||||
|
refreshPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
const refresh = getRefreshToken();
|
||||||
|
if (window.UI?.stopSseClient) {
|
||||||
|
window.UI.stopSseClient();
|
||||||
|
}
|
||||||
|
clearTokens();
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(refresh ? { refresh_token: refresh } : {}),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore logout network errors.
|
||||||
|
}
|
||||||
|
redirectToLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSessionRefresh() {
|
||||||
|
if (refreshTimer) {
|
||||||
|
clearTimeout(refreshTimer);
|
||||||
|
refreshTimer = null;
|
||||||
|
}
|
||||||
|
const token = getAccessToken();
|
||||||
|
const payload = parseJwt(token);
|
||||||
|
const exp = Number(payload?.exp || 0);
|
||||||
|
if (!exp) return;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const lead = 45;
|
||||||
|
const delayMs = Math.max(1000, (exp - now - lead) * 1000);
|
||||||
|
refreshTimer = setTimeout(async () => {
|
||||||
|
const ok = await refreshTokens();
|
||||||
|
if (ok) {
|
||||||
|
initSessionRefresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTokens();
|
||||||
|
redirectToLogin();
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initHeaderControls() {
|
||||||
|
const logoutBtn = document.getElementById('btnLogout');
|
||||||
|
if (logoutBtn) {
|
||||||
|
logoutBtn.addEventListener('click', () => {
|
||||||
|
logout();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const adminBtn = document.getElementById('btnAdmin');
|
||||||
|
if (adminBtn) {
|
||||||
|
adminBtn.classList.toggle('is-hidden', !isAdmin());
|
||||||
|
}
|
||||||
|
loadHeaderAvatar().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHeaderAvatar() {
|
||||||
|
const img = document.getElementById('headerAvatar');
|
||||||
|
if (!img) return;
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (!token) return;
|
||||||
|
const res = await fetch('/api/account/avatar', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
img.onload = () => URL.revokeObjectURL(url);
|
||||||
|
img.src = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Auth = {
|
||||||
|
getAccessToken,
|
||||||
|
getRefreshToken,
|
||||||
|
setTokens,
|
||||||
|
clearTokens,
|
||||||
|
parseJwt,
|
||||||
|
getRoles,
|
||||||
|
isAdmin,
|
||||||
|
isAccessExpired,
|
||||||
|
requireAuth,
|
||||||
|
refreshTokens,
|
||||||
|
logout,
|
||||||
|
redirectToLogin,
|
||||||
|
initHeaderControls,
|
||||||
|
initSessionRefresh,
|
||||||
|
};
|
||||||
|
})();
|
||||||
12
public/assets/js/http.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
// public/assets/http.js
|
||||||
|
/* English comments: shared JSON fetch with auth refresh */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
async function apiJson(path, opts = {}) {
|
||||||
|
return window.Api.request(path, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Http = {
|
||||||
|
apiJson,
|
||||||
|
};
|
||||||
|
})();
|
||||||
211
public/assets/js/login.js
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
// public/assets/login.js
|
||||||
|
/* English comments: login + MFA flow */
|
||||||
|
|
||||||
|
const qs = (id) => document.getElementById(id);
|
||||||
|
const i18n = window.I18N || {};
|
||||||
|
const t = (key, fallback) => {
|
||||||
|
if (key && Object.prototype.hasOwnProperty.call(i18n, key)) {
|
||||||
|
const val = i18n[key];
|
||||||
|
return val !== '' ? val : fallback;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorKeyByMessage = {
|
||||||
|
'Email and password required': 'auth.error.email_password_required',
|
||||||
|
'Invalid credentials': 'auth.error.invalid_credentials',
|
||||||
|
'Email required': 'auth.error.email_required',
|
||||||
|
'Request failed': 'auth.error.request_failed',
|
||||||
|
'Code required': 'auth.error.code_required',
|
||||||
|
'Invalid code': 'auth.error.invalid_code',
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeErrorMessage(message, fallbackKey, fallbackText) {
|
||||||
|
if (message && Object.prototype.hasOwnProperty.call(errorKeyByMessage, message)) {
|
||||||
|
return t(errorKeyByMessage[message], message);
|
||||||
|
}
|
||||||
|
return message || t(fallbackKey, fallbackText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
const el = qs('loginError');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg;
|
||||||
|
el.classList.remove('is-hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearError() {
|
||||||
|
if (retryTimer) {
|
||||||
|
clearInterval(retryTimer);
|
||||||
|
retryTimer = null;
|
||||||
|
}
|
||||||
|
const el = qs('loginError');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = '';
|
||||||
|
el.classList.add('is-hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
let retryTimer = null;
|
||||||
|
let retryUntilTs = 0;
|
||||||
|
let retryShown = null;
|
||||||
|
|
||||||
|
function formatRetryMessage(secondsLeft) {
|
||||||
|
return t('auth.error.too_many_attempts', 'Too many attempts. Try again in {seconds}s.')
|
||||||
|
.replace('{seconds}', String(secondsLeft));
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRetryError(seconds) {
|
||||||
|
const el = qs('loginError');
|
||||||
|
if (!el) return;
|
||||||
|
const now = Date.now();
|
||||||
|
retryUntilTs = now + Math.max(0, seconds) * 1000;
|
||||||
|
retryShown = null;
|
||||||
|
el.textContent = formatRetryMessage(Math.max(0, Math.ceil((retryUntilTs - now) / 1000)));
|
||||||
|
el.classList.remove('is-hidden');
|
||||||
|
if (retryTimer) {
|
||||||
|
clearInterval(retryTimer);
|
||||||
|
}
|
||||||
|
retryTimer = setInterval(() => {
|
||||||
|
const remaining = Math.max(0, Math.ceil((retryUntilTs - Date.now()) / 1000));
|
||||||
|
if (retryShown === null) {
|
||||||
|
retryShown = remaining;
|
||||||
|
} else if (retryShown > remaining) {
|
||||||
|
// Smoothly converge if timer drifts or tab throttles.
|
||||||
|
retryShown = Math.max(remaining, retryShown - 1);
|
||||||
|
} else {
|
||||||
|
retryShown = remaining;
|
||||||
|
}
|
||||||
|
el.textContent = formatRetryMessage(retryShown);
|
||||||
|
if (remaining <= 0) {
|
||||||
|
clearInterval(retryTimer);
|
||||||
|
retryTimer = null;
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
clearError();
|
||||||
|
const email = qs('loginEmail')?.value || '';
|
||||||
|
const password = qs('loginPassword')?.value || '';
|
||||||
|
const remember = !!qs('loginRemember')?.checked;
|
||||||
|
if (!email || !password) {
|
||||||
|
showError(t('auth.error.email_password_required', 'Email and password required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password, client_type: 'web' }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data?.ok) {
|
||||||
|
const retry = data?.error?.retry_after;
|
||||||
|
if (retry) {
|
||||||
|
showRetryError(retry);
|
||||||
|
} else {
|
||||||
|
const msg = normalizeErrorMessage(data?.error?.message || '', 'auth.error.login_failed', 'Login failed');
|
||||||
|
showError(msg);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data?.data?.mfa_required) {
|
||||||
|
window.__mfaChallenge = data?.data?.challenge_token || '';
|
||||||
|
window.__rememberSession = remember;
|
||||||
|
qs('loginForm')?.classList.add('is-hidden');
|
||||||
|
qs('mfaForm')?.classList.remove('is-hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.Auth.setTokens(data?.data || {}, 'web', remember);
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showForgot() {
|
||||||
|
qs('loginForm')?.classList.add('is-hidden');
|
||||||
|
qs('mfaForm')?.classList.add('is-hidden');
|
||||||
|
qs('forgotForm')?.classList.remove('is-hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoginForm() {
|
||||||
|
qs('forgotForm')?.classList.add('is-hidden');
|
||||||
|
qs('mfaForm')?.classList.add('is-hidden');
|
||||||
|
qs('loginForm')?.classList.remove('is-hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendForgot() {
|
||||||
|
clearError();
|
||||||
|
const email = qs('forgotEmail')?.value || '';
|
||||||
|
if (!email) {
|
||||||
|
showError(t('auth.error.email_required', 'Email required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch('/api/auth/password/forgot', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
if (!data?.ok) {
|
||||||
|
showError(normalizeErrorMessage(data?.error?.message || '', 'auth.error.request_failed', 'Request failed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showError(t('auth.error.reset_sent', 'If this email exists, a reset link will be sent.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyMfa() {
|
||||||
|
clearError();
|
||||||
|
const code = qs('mfaCode')?.value || '';
|
||||||
|
const challenge = window.__mfaChallenge || '';
|
||||||
|
const remember = window.__rememberSession !== false;
|
||||||
|
if (!code || !challenge) {
|
||||||
|
showError(t('auth.error.code_required', 'Code required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch('/api/auth/2fa/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ challenge_token: challenge, code, client_type: 'web' }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data?.ok) {
|
||||||
|
const retry = data?.error?.retry_after;
|
||||||
|
if (retry) {
|
||||||
|
showRetryError(retry);
|
||||||
|
} else {
|
||||||
|
const msg = normalizeErrorMessage(data?.error?.message || '', 'auth.error.invalid_code', 'Invalid code');
|
||||||
|
showError(msg);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.Auth.setTokens(data?.data || {}, 'web', remember);
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const access = window.Auth?.getAccessToken?.() || '';
|
||||||
|
const refresh = window.Auth?.getRefreshToken?.() || '';
|
||||||
|
if (access && refresh && window.Auth?.refreshTokens) {
|
||||||
|
window.Auth.refreshTokens().then((ok) => {
|
||||||
|
if (ok) {
|
||||||
|
window.location.href = '/';
|
||||||
|
} else if (window.Auth?.clearTokens) {
|
||||||
|
window.Auth.clearTokens();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (access && window.Auth?.clearTokens) {
|
||||||
|
window.Auth.clearTokens();
|
||||||
|
}
|
||||||
|
const loginBtn = qs('btnLogin');
|
||||||
|
if (loginBtn) loginBtn.addEventListener('click', login);
|
||||||
|
const forgotBtn = qs('btnForgot');
|
||||||
|
if (forgotBtn) forgotBtn.addEventListener('click', showForgot);
|
||||||
|
const forgotSend = qs('btnForgotSend');
|
||||||
|
if (forgotSend) forgotSend.addEventListener('click', sendForgot);
|
||||||
|
const forgotBack = qs('btnForgotBack');
|
||||||
|
if (forgotBack) forgotBack.addEventListener('click', showLoginForm);
|
||||||
|
const verifyBtn = qs('btnMfaVerify');
|
||||||
|
if (verifyBtn) verifyBtn.addEventListener('click', verifyMfa);
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
80
public/assets/js/prefs.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
// public/assets/prefs.js
|
||||||
|
/* English comments: user preference storage and sync */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const DEFAULT_PREFS = {
|
||||||
|
language: 'en',
|
||||||
|
theme: 'dark',
|
||||||
|
table_mode: 'pagination',
|
||||||
|
table_page_size: 50,
|
||||||
|
tables: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
let prefs = { ...DEFAULT_PREFS };
|
||||||
|
let loaded = false;
|
||||||
|
let saveTimer = null;
|
||||||
|
|
||||||
|
function mergePrefs(next) {
|
||||||
|
if (!next || typeof next !== 'object') return;
|
||||||
|
prefs = {
|
||||||
|
...prefs,
|
||||||
|
...next,
|
||||||
|
tables: { ...(prefs.tables || {}), ...(next.tables || {}) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (loaded) return prefs;
|
||||||
|
const res = await window.Api.request('/api/account', { method: 'GET' });
|
||||||
|
const data = res?.data || res || {};
|
||||||
|
mergePrefs(data.ui || {});
|
||||||
|
loaded = true;
|
||||||
|
return prefs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function get() {
|
||||||
|
return prefs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTable(id) {
|
||||||
|
if (!id) return {};
|
||||||
|
return (prefs.tables && prefs.tables[id]) ? prefs.tables[id] : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSave() {
|
||||||
|
if (!loaded) return;
|
||||||
|
if (saveTimer) clearTimeout(saveTimer);
|
||||||
|
saveTimer = setTimeout(() => {
|
||||||
|
saveTimer = null;
|
||||||
|
save().catch(() => {});
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!loaded) return;
|
||||||
|
await window.Api.request('/api/account', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { ui: prefs },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTable(id, data) {
|
||||||
|
if (!id) return;
|
||||||
|
prefs.tables = { ...(prefs.tables || {}), [id]: { ...(prefs.tables?.[id] || {}), ...data } };
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUi(key, value) {
|
||||||
|
if (!key) return;
|
||||||
|
prefs[key] = value;
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.UserPrefs = {
|
||||||
|
load,
|
||||||
|
get,
|
||||||
|
getTable,
|
||||||
|
setTable,
|
||||||
|
setUi,
|
||||||
|
};
|
||||||
|
})();
|
||||||
3510
public/assets/js/settings.js
Normal file
209
public/assets/js/sse.js
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
// public/assets/sse.js
|
||||||
|
/* English comments: shared SSE client with lease + cache */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const SSE_LEASE_KEY = 'scmedia_sse_lease';
|
||||||
|
const SSE_LAST = 'scmedia_sse_last';
|
||||||
|
const SSE_LAST_TYPE = 'scmedia_sse_last_type';
|
||||||
|
const SSE_CONNECTED = 'scmedia_sse_connected';
|
||||||
|
const SSE_RECONNECTS = 'scmedia_sse_reconnects';
|
||||||
|
const SSE_KEY_EXP = 'scmedia_sse_key_exp';
|
||||||
|
|
||||||
|
let client = null;
|
||||||
|
let retryMs = 5000;
|
||||||
|
let tabId = '';
|
||||||
|
let leaseTimer = null;
|
||||||
|
let opening = false;
|
||||||
|
const listeners = {};
|
||||||
|
|
||||||
|
function emit(type, payload) {
|
||||||
|
const list = listeners[type] || [];
|
||||||
|
list.forEach((cb) => {
|
||||||
|
try {
|
||||||
|
cb(payload);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function on(type, cb) {
|
||||||
|
if (!type || typeof cb !== 'function') return;
|
||||||
|
listeners[type] = listeners[type] || [];
|
||||||
|
listeners[type].push(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadKeyExp() {
|
||||||
|
return Number(sessionStorage.getItem(SSE_KEY_EXP) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheKeyExp(ttlSeconds) {
|
||||||
|
const exp = Date.now() + Math.max(10, ttlSeconds) * 1000;
|
||||||
|
sessionStorage.setItem(SSE_KEY_EXP, String(exp));
|
||||||
|
return exp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKeyFresh() {
|
||||||
|
const exp = loadKeyExp();
|
||||||
|
return exp > Date.now() + 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConnected(ok) {
|
||||||
|
localStorage.setItem(SSE_CONNECTED, ok ? '1' : '0');
|
||||||
|
if (ok) {
|
||||||
|
localStorage.setItem(SSE_LAST, String(Date.now()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function noteEvent(type) {
|
||||||
|
setConnected(true);
|
||||||
|
localStorage.setItem(SSE_LAST, String(Date.now()));
|
||||||
|
if (type) {
|
||||||
|
localStorage.setItem(SSE_LAST_TYPE, String(type));
|
||||||
|
}
|
||||||
|
refreshLease();
|
||||||
|
}
|
||||||
|
|
||||||
|
function claimLease() {
|
||||||
|
if (!tabId) {
|
||||||
|
const existing = sessionStorage.getItem('scmedia_sse_tab_id');
|
||||||
|
tabId = existing || Math.random().toString(36).slice(2);
|
||||||
|
sessionStorage.setItem('scmedia_sse_tab_id', tabId);
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const ttlMs = 15000;
|
||||||
|
const raw = localStorage.getItem(SSE_LEASE_KEY) || '';
|
||||||
|
let lease = null;
|
||||||
|
try {
|
||||||
|
lease = raw ? JSON.parse(raw) : null;
|
||||||
|
} catch (e) {
|
||||||
|
lease = null;
|
||||||
|
}
|
||||||
|
if (lease && lease.ts && (now - lease.ts) < ttlMs && lease.id !== tabId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
localStorage.setItem(SSE_LEASE_KEY, JSON.stringify({ ts: now, id: tabId }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshLease() {
|
||||||
|
localStorage.setItem(SSE_LEASE_KEY, JSON.stringify({ ts: Date.now(), id: tabId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseLease() {
|
||||||
|
const raw = localStorage.getItem(SSE_LEASE_KEY) || '';
|
||||||
|
try {
|
||||||
|
const lease = raw ? JSON.parse(raw) : null;
|
||||||
|
if (lease && lease.id && lease.id !== tabId) return;
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
localStorage.removeItem(SSE_LEASE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open() {
|
||||||
|
if (opening) return;
|
||||||
|
opening = true;
|
||||||
|
if (!claimLease()) {
|
||||||
|
opening = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window.Auth?.isAccessExpired?.() && window.Auth?.refreshTokens) {
|
||||||
|
await window.Auth.refreshTokens();
|
||||||
|
}
|
||||||
|
const token = window.Auth?.getAccessToken?.();
|
||||||
|
if (!token) {
|
||||||
|
opening = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isKeyFresh()) {
|
||||||
|
const res = await fetch('/api/auth/sse-key', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
const key = data?.data?.key || '';
|
||||||
|
const ttl = Number(data?.data?.expires_in || 60);
|
||||||
|
if (!data?.ok || !key) {
|
||||||
|
releaseLease();
|
||||||
|
opening = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cacheKeyExp(ttl);
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
const es = new EventSource('/api/events', { withCredentials: true });
|
||||||
|
client = es;
|
||||||
|
es.addEventListener('open', () => {
|
||||||
|
retryMs = 5000;
|
||||||
|
setConnected(true);
|
||||||
|
refreshLease();
|
||||||
|
if (leaseTimer) clearInterval(leaseTimer);
|
||||||
|
leaseTimer = setInterval(refreshLease, 5000);
|
||||||
|
emit('open');
|
||||||
|
});
|
||||||
|
const handle = (type) => {
|
||||||
|
noteEvent(type);
|
||||||
|
emit(type);
|
||||||
|
};
|
||||||
|
es.addEventListener('message', () => handle('message'));
|
||||||
|
es.addEventListener('tick', () => handle('tick'));
|
||||||
|
es.addEventListener('sources', (e) => {
|
||||||
|
noteEvent('sources');
|
||||||
|
let payload = null;
|
||||||
|
try { payload = JSON.parse(e.data); } catch (e2) { payload = null; }
|
||||||
|
emit('sources', payload);
|
||||||
|
});
|
||||||
|
es.addEventListener('jobs', (e) => {
|
||||||
|
noteEvent('jobs');
|
||||||
|
let payload = null;
|
||||||
|
try { payload = JSON.parse(e.data); } catch (e2) { payload = null; }
|
||||||
|
emit('jobs', payload);
|
||||||
|
});
|
||||||
|
es.addEventListener('error', () => {
|
||||||
|
if (client) {
|
||||||
|
client.close();
|
||||||
|
client = null;
|
||||||
|
}
|
||||||
|
if (leaseTimer) {
|
||||||
|
clearInterval(leaseTimer);
|
||||||
|
leaseTimer = null;
|
||||||
|
}
|
||||||
|
const prev = Number(localStorage.getItem(SSE_RECONNECTS) || 0);
|
||||||
|
localStorage.setItem(SSE_RECONNECTS, String(prev + 1));
|
||||||
|
releaseLease();
|
||||||
|
opening = false;
|
||||||
|
const delay = retryMs;
|
||||||
|
retryMs = Math.min(retryMs * 2, 60000);
|
||||||
|
setTimeout(open, delay);
|
||||||
|
});
|
||||||
|
opening = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (!window.EventSource) return;
|
||||||
|
if (client) return;
|
||||||
|
open().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (client) {
|
||||||
|
client.close();
|
||||||
|
client = null;
|
||||||
|
}
|
||||||
|
if (leaseTimer) {
|
||||||
|
clearInterval(leaseTimer);
|
||||||
|
leaseTimer = null;
|
||||||
|
}
|
||||||
|
localStorage.removeItem(SSE_CONNECTED);
|
||||||
|
localStorage.removeItem(SSE_LAST);
|
||||||
|
localStorage.removeItem(SSE_LAST_TYPE);
|
||||||
|
localStorage.removeItem(SSE_LEASE_KEY);
|
||||||
|
sessionStorage.removeItem(SSE_KEY_EXP);
|
||||||
|
document.cookie = 'sse_key=; path=/; max-age=0';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Sse = { start, stop, on };
|
||||||
|
})();
|
||||||