Initial commit

This commit is contained in:
Alexander Schiemann 2026-01-16 22:53:04 +01:00
commit 43047ec499
105 changed files with 22545 additions and 0 deletions

11
.env.example Normal file
View 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
View File

@ -0,0 +1,3 @@
.env
.env.*
!.env.example

23
Makefile Normal file
View 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
View 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
View 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
View 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;

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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]]);
}
}

View 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
View 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
View 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);
}
}
}

View 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
View 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;
}
}

View 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]);
}
}

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

File diff suppressed because it is too large Load Diff

View 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;
}
}

View 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];
}
}

View 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)
");
}
}

View 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
View 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;
}
}

View 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) : '';
}
}

View 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];
}
}

View 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;
}
}

View 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
View 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,
];
}
}

View 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;
}
}

View 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 thats 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;
}
}

View 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,
]);
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}

View 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'];
}
}

View 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'];
}
}

View 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;
}

View 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;
}
}

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

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

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

View 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
View 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
View 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
View 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
View 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
View 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
View 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"]

View 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

View 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
View 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"

View 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

View 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).

View 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 Ill generate a vector set.

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View 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"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

View 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;
}
}

View 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;
}

View 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;
}

View 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;
}

View 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
View 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
View 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
View 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
View 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": "Профиль"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

161
public/assets/js/account.js Normal file
View 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
View 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
View 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,
};
})();

View File

@ -0,0 +1 @@
const APP_VERSION = "0.1.0";

1743
public/assets/js/app.js Normal file

File diff suppressed because it is too large Load Diff

217
public/assets/js/auth.js Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

209
public/assets/js/sse.js Normal file
View 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 };
})();

Some files were not shown because too many files have changed in this diff Show More