commit 43047ec499b77de456fbc52d6948400b8d434354 Author: safe Date: Fri Jan 16 22:53:04 2026 +0100 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2b5627d --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2cef73e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +.env.* +!.env.example diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ada3731 --- /dev/null +++ b/Makefile @@ -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 diff --git a/app/CApp.php b/app/CApp.php new file mode 100644 index 0000000..cf9ce9a --- /dev/null +++ b/app/CApp.php @@ -0,0 +1,307 @@ +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 = '
' . htmlspecialchars($e->getMessage() . "\n\n" . $e->getTraceAsString(), ENT_QUOTES) . '
'; + } + echo ''; + echo '' . htmlspecialchars($title, ENT_QUOTES) . ''; + echo '

' . htmlspecialchars($title, ENT_QUOTES) . '

'; + echo '

' . htmlspecialchars($message, ENT_QUOTES) . '

'; + echo $details; + echo ''; + } + + 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; + } + } +} diff --git a/app/Container.php b/app/Container.php new file mode 100644 index 0000000..2980114 --- /dev/null +++ b/app/Container.php @@ -0,0 +1,44 @@ +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') ?? []); + } +} diff --git a/app/bootstrap.php b/app/bootstrap.php new file mode 100644 index 0000000..df5d9d1 --- /dev/null +++ b/app/bootstrap.php @@ -0,0 +1,133 @@ + $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; diff --git a/app/controllers/BaseController.php b/app/controllers/BaseController.php new file mode 100644 index 0000000..ceee4fd --- /dev/null +++ b/app/controllers/BaseController.php @@ -0,0 +1,42 @@ +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); + } +} diff --git a/app/controllers/account.php b/app/controllers/account.php new file mode 100644 index 0000000..d1ac2af --- /dev/null +++ b/app/controllers/account.php @@ -0,0 +1,265 @@ +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, + ]); + } +} diff --git a/app/controllers/admin.php b/app/controllers/admin.php new file mode 100644 index 0000000..b1aa82f --- /dev/null +++ b/app/controllers/admin.php @@ -0,0 +1,106 @@ +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, + ]]); + } +} diff --git a/app/controllers/auth.php b/app/controllers/auth.php new file mode 100644 index 0000000..2822ae8 --- /dev/null +++ b/app/controllers/auth.php @@ -0,0 +1,236 @@ +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]); + } +} diff --git a/app/controllers/debug.php b/app/controllers/debug.php new file mode 100644 index 0000000..32bee89 --- /dev/null +++ b/app/controllers/debug.php @@ -0,0 +1,214 @@ +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]]); + } +} diff --git a/app/controllers/events.php b/app/controllers/events.php new file mode 100644 index 0000000..30dd7f7 --- /dev/null +++ b/app/controllers/events.php @@ -0,0 +1,353 @@ +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(); + } +} diff --git a/app/controllers/items.php b/app/controllers/items.php new file mode 100644 index 0000000..29af405 --- /dev/null +++ b/app/controllers/items.php @@ -0,0 +1,112 @@ +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); + } + } +} diff --git a/app/controllers/jobs.php b/app/controllers/jobs.php new file mode 100644 index 0000000..2fbaf2e --- /dev/null +++ b/app/controllers/jobs.php @@ -0,0 +1,167 @@ +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]]); + } +} diff --git a/app/controllers/layout.php b/app/controllers/layout.php new file mode 100644 index 0000000..1feb420 --- /dev/null +++ b/app/controllers/layout.php @@ -0,0 +1,34 @@ +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]); + } +} diff --git a/app/controllers/logs.php b/app/controllers/logs.php new file mode 100644 index 0000000..f8afada --- /dev/null +++ b/app/controllers/logs.php @@ -0,0 +1,72 @@ +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]]); + } +} diff --git a/app/controllers/media.php b/app/controllers/media.php new file mode 100644 index 0000000..5cfec4e --- /dev/null +++ b/app/controllers/media.php @@ -0,0 +1,166 @@ +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); + } + } +} diff --git a/app/controllers/metadata.php b/app/controllers/metadata.php new file mode 100644 index 0000000..3df9fc6 --- /dev/null +++ b/app/controllers/metadata.php @@ -0,0 +1,89 @@ +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]); + } +} diff --git a/app/controllers/pages.php b/app/controllers/pages.php new file mode 100644 index 0000000..ca246db --- /dev/null +++ b/app/controllers/pages.php @@ -0,0 +1,170 @@ +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; + } +} diff --git a/app/controllers/scan_profiles.php b/app/controllers/scan_profiles.php new file mode 100644 index 0000000..0a7cbc0 --- /dev/null +++ b/app/controllers/scan_profiles.php @@ -0,0 +1,76 @@ +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]); + } +} diff --git a/app/controllers/settings.php b/app/controllers/settings.php new file mode 100644 index 0000000..ff9baf5 --- /dev/null +++ b/app/controllers/settings.php @@ -0,0 +1,130 @@ +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]); + } +} diff --git a/app/controllers/sources.php b/app/controllers/sources.php new file mode 100644 index 0000000..590ef34 --- /dev/null +++ b/app/controllers/sources.php @@ -0,0 +1,216 @@ +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); + } +} diff --git a/app/controllers/tasks.php b/app/controllers/tasks.php new file mode 100644 index 0000000..59c1e0c --- /dev/null +++ b/app/controllers/tasks.php @@ -0,0 +1,158 @@ +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]]); + } +} diff --git a/app/controllers/tools.php b/app/controllers/tools.php new file mode 100644 index 0000000..5107a86 --- /dev/null +++ b/app/controllers/tools.php @@ -0,0 +1,37 @@ +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]); + } +} diff --git a/app/db/Db.php b/app/db/Db.php new file mode 100644 index 0000000..fa237e8 --- /dev/null +++ b/app/db/Db.php @@ -0,0 +1,74 @@ +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(); + } + } +} diff --git a/app/db/SchemaTool.php b/app/db/SchemaTool.php new file mode 100644 index 0000000..5cb2f19 --- /dev/null +++ b/app/db/SchemaTool.php @@ -0,0 +1,355 @@ +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); + } +} diff --git a/app/http/Response.php b/app/http/Response.php new file mode 100644 index 0000000..b473ed9 --- /dev/null +++ b/app/http/Response.php @@ -0,0 +1,58 @@ +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('

404

', 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; + } +} diff --git a/app/http/helpers.php b/app/http/helpers.php new file mode 100644 index 0000000..2d30027 --- /dev/null +++ b/app/http/helpers.php @@ -0,0 +1,225 @@ + 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'); +} diff --git a/app/services/AuthService.php b/app/services/AuthService.php new file mode 100644 index 0000000..186c5ca --- /dev/null +++ b/app/services/AuthService.php @@ -0,0 +1,1145 @@ +db = $db; + $this->config = $config; + + $auth = $config['auth'] ?? []; + $this->jwtSecret = (string)($auth['jwt_secret'] ?? 'CHANGE_ME_JWT_SECRET'); + $this->accessTtl = (int)($auth['access_ttl_seconds'] ?? 900); + $this->refreshTtlWebDays = (int)($auth['refresh_ttl_web_days'] ?? 14); + $this->refreshTtlMobileDays = (int)($auth['refresh_ttl_mobile_days'] ?? 30); + $this->challengeTtl = (int)($auth['challenge_ttl_seconds'] ?? 300); + $this->passwordResetTtlMinutes = (int)($auth['password_reset_ttl_minutes'] ?? 30); + $this->maxDevices = (int)($auth['max_devices'] ?? 5); + $this->returnResetToken = (bool)($auth['return_reset_token'] ?? false); + $this->loginMax = (int)($auth['rate_limit_login_max'] ?? 10); + $this->loginWindow = (int)($auth['rate_limit_login_window'] ?? 900); + $this->loginBlock = (int)($auth['rate_limit_login_block'] ?? 900); + $this->mfaMax = (int)($auth['rate_limit_mfa_max'] ?? 6); + $this->mfaWindow = (int)($auth['rate_limit_mfa_window'] ?? 900); + $this->mfaBlock = (int)($auth['rate_limit_mfa_block'] ?? 900); + $this->sseKeyTtl = (int)($auth['sse_key_ttl_seconds'] ?? 90); + $this->sseSessionTtl = (int)($auth['sse_session_ttl_seconds'] ?? 20); + } + + public function setSseSessionTtl(int $seconds): void { + $this->sseSessionTtl = max(1, $seconds); + } + + public function requireAuth(?string $role = null): array { + $token = $this->readBearerToken(); + if ($token === '') { + Response::json(['ok' => false, 'error' => ['code' => 'UNAUTHORIZED', 'message' => 'Missing token']], 401); + exit; + } + + $claims = $this->verifyJwt($token); + if ($claims === null) { + Response::json(['ok' => false, 'error' => ['code' => 'UNAUTHORIZED', 'message' => 'Invalid token']], 401); + exit; + } + + $userId = (int)($claims['sub'] ?? 0); + if ($userId <= 0) { + Response::json(['ok' => false, 'error' => ['code' => 'UNAUTHORIZED', 'message' => 'Invalid token subject']], 401); + exit; + } + + $user = $this->db->fetchOne("SELECT id, email, status, token_version FROM users WHERE id = :id", [':id' => $userId]); + if (!$user || (string)($user['status'] ?? '') !== 'active') { + Response::json(['ok' => false, 'error' => ['code' => 'UNAUTHORIZED', 'message' => 'User disabled']], 401); + exit; + } + + $tokenVersion = (int)($claims['ver'] ?? 0); + if ($tokenVersion !== (int)($user['token_version'] ?? 0)) { + Response::json(['ok' => false, 'error' => ['code' => 'UNAUTHORIZED', 'message' => 'Token expired']], 401); + exit; + } + + $roles = $this->getUserRoles($userId); + if ($role !== null && !in_array($role, $roles, true)) { + Response::json(['ok' => false, 'error' => ['code' => 'FORBIDDEN', 'message' => 'Insufficient role']], 403); + exit; + } + + return [ + 'id' => $userId, + 'email' => (string)($user['email'] ?? ''), + 'roles' => $roles, + ]; + } + + public function requirePageAuth(?string $role = null): array { + $token = $this->readBearerToken(); + if ($token === '') { + $this->redirectToLogin(); + } + + $claims = $this->verifyJwt($token); + if ($claims === null) { + $this->redirectToLogin(); + } + + $userId = (int)($claims['sub'] ?? 0); + if ($userId <= 0) { + $this->redirectToLogin(); + } + + $user = $this->db->fetchOne("SELECT id, email, status, token_version FROM users WHERE id = :id", [':id' => $userId]); + if (!$user || (string)($user['status'] ?? '') !== 'active') { + $this->redirectToLogin(); + } + + $tokenVersion = (int)($claims['ver'] ?? 0); + if ($tokenVersion !== (int)($user['token_version'] ?? 0)) { + $this->redirectToLogin(); + } + + $roles = $this->getUserRoles($userId); + if ($role !== null && !in_array($role, $roles, true)) { + $this->redirectToLogin(); + } + + return [ + 'id' => (int)$user['id'], + 'email' => (string)$user['email'], + 'roles' => $roles, + ]; + } + + public function register(string $email, string $password): array { + $email = strtolower(trim($email)); + if ($email === '' || $password === '') { + return ['ok' => false, 'error' => 'Email and password required']; + } + + $exists = $this->db->fetchOne("SELECT id FROM users WHERE email = :e", [':e' => $email]); + if ($exists) { + return ['ok' => false, 'error' => 'User already exists']; + } + + $hash = password_hash($password, PASSWORD_DEFAULT); + $this->db->begin(); + try { + $this->db->exec(" + INSERT INTO users (email, password_hash, status, token_version, created_at, updated_at) + VALUES (:e, :h, 'active', 1, NOW(), NOW()) + ", [':e' => $email, ':h' => $hash]); + $userId = (int)$this->db->lastInsertId(); + + $role = $this->isFirstUser($userId) ? 'admin' : 'user'; + $this->assignRolesInternal($userId, [$role]); + + $this->audit('register', $userId, 'user', $userId, ['role' => $role]); + $this->db->commit(); + return ['ok' => true, 'user_id' => $userId, 'role' => $role]; + } catch (Throwable $e) { + $this->db->rollBack(); + return ['ok' => false, 'error' => $e->getMessage()]; + } + } + + public function login(string $email, string $password, string $clientType, string $deviceId = ''): array { + $blocked = $this->isRateLimited('login'); + if ($blocked > 0) { + return ['ok' => false, 'error' => 'Too many attempts', 'retry_after' => $blocked]; + } + $email = strtolower(trim($email)); + $user = $this->db->fetchOne("SELECT id, email, password_hash, status FROM users WHERE email = :e", [':e' => $email]); + if (!$user || (string)($user['status'] ?? '') !== 'active') { + $this->registerFailure('login'); + $this->audit('login_failed', null, 'user', null, ['email' => $email]); + return ['ok' => false, 'error' => 'Invalid credentials']; + } + + if (!password_verify($password, (string)($user['password_hash'] ?? ''))) { + $this->registerFailure('login'); + $this->audit('login_failed', (int)$user['id'], 'user', (int)$user['id'], ['reason' => 'bad_password']); + return ['ok' => false, 'error' => 'Invalid credentials']; + } + + $userId = (int)$user['id']; + $this->resetRateLimit('login'); + if ($this->isMfaEnabled($userId)) { + $challenge = $this->createLoginChallenge($userId); + $this->audit('login_mfa_required', $userId, 'user', $userId); + return ['ok' => true, 'mfa_required' => true, 'challenge_token' => $challenge]; + } + + $tokens = $this->issueTokens($userId, $clientType, $deviceId); + $this->db->exec("UPDATE users SET last_login_at = NOW() WHERE id = :id", [':id' => $userId]); + $this->audit('login', $userId, 'user', $userId); + return ['ok' => true, 'tokens' => $tokens]; + } + + public function verifyMfa(string $challengeToken, string $code, string $clientType, string $deviceId = ''): array { + $blocked = $this->isRateLimited('mfa'); + if ($blocked > 0) { + return ['ok' => false, 'error' => 'Too many attempts', 'retry_after' => $blocked]; + } + $challenge = $this->findChallenge($challengeToken); + if (!$challenge) { + $this->registerFailure('mfa'); + return ['ok' => false, 'error' => 'Invalid or expired challenge']; + } + + $userId = (int)$challenge['user_id']; + $valid = $this->verifyMfaCode($userId, $code); + if (!$valid) { + $this->registerFailure('mfa'); + $this->audit('mfa_failed', $userId, 'user', $userId); + return ['ok' => false, 'error' => 'Invalid code']; + } + + $this->resetRateLimit('mfa'); + $this->db->exec("UPDATE auth_challenges SET used_at = NOW() WHERE id = :id", [':id' => (int)$challenge['id']]); + $tokens = $this->issueTokens($userId, $clientType, $deviceId); + $this->db->exec("UPDATE users SET last_login_at = NOW() WHERE id = :id", [':id' => $userId]); + $this->audit('login', $userId, 'user', $userId, ['mfa' => true]); + return ['ok' => true, 'tokens' => $tokens]; + } + + public function refresh(string $refreshToken, string $clientType, string $deviceId = ''): array { + $tokenRow = $this->findRefreshToken($refreshToken); + if (!$tokenRow) { + return ['ok' => false, 'error' => 'Invalid refresh token']; + } + if (!empty($tokenRow['revoked_at'])) { + return ['ok' => false, 'error' => 'Refresh token revoked']; + } + $expiresAt = strtotime((string)($tokenRow['expires_at'] ?? '')); + if ($expiresAt !== false && $expiresAt <= time()) { + return ['ok' => false, 'error' => 'Refresh token expired']; + } + + $userId = (int)$tokenRow['user_id']; + $user = $this->db->fetchOne("SELECT id, status FROM users WHERE id = :id", [':id' => $userId]); + if (!$user || (string)($user['status'] ?? '') !== 'active') { + return ['ok' => false, 'error' => 'User disabled']; + } + + $this->db->exec("UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = :id", [':id' => (int)$tokenRow['id']]); + $tokens = $this->issueTokens($userId, $clientType, $deviceId, (int)$tokenRow['id']); + $this->audit('refresh', $userId, 'user', $userId); + return ['ok' => true, 'tokens' => $tokens]; + } + + public function logout(string $refreshToken): array { + $tokenRow = $this->findRefreshToken($refreshToken); + if (!$tokenRow) { + return ['ok' => true]; + } + + $userId = (int)$tokenRow['user_id']; + $this->revokeUserSessions($userId); + $this->audit('logout', $userId, 'user', $userId); + return ['ok' => true]; + } + + public function logoutByAccessToken(string $accessToken): void { + $claims = $this->verifyJwt($accessToken); + if ($claims === null) { + return; + } + $userId = (int)($claims['sub'] ?? 0); + if ($userId <= 0) { + return; + } + $this->revokeUserSessions($userId); + $this->audit('logout', $userId, 'user', $userId); + } + + public function resolveUserLanguageFromToken(string $accessToken): ?string { + if ($accessToken === '') { + return null; + } + $claims = $this->verifyJwt($accessToken); + if ($claims === null) { + return null; + } + $userId = (int)($claims['sub'] ?? 0); + if ($userId <= 0) { + return null; + } + $user = $this->db->fetchOne("SELECT id, status, token_version FROM users WHERE id = :id", [':id' => $userId]); + if (!$user || (string)($user['status'] ?? '') !== 'active') { + return null; + } + $tokenVersion = (int)($claims['ver'] ?? 0); + if ($tokenVersion !== (int)($user['token_version'] ?? 0)) { + return null; + } + $row = $this->db->fetchOne("SELECT ui_prefs_json FROM user_profiles WHERE user_id = :id", [':id' => $userId]) ?? []; + $ui = json_decode((string)($row['ui_prefs_json'] ?? '{}'), true); + if (!is_array($ui)) { + return null; + } + $lang = (string)($ui['language'] ?? ''); + return preg_match('/^[a-z]{2}$/', $lang) ? $lang : null; + } + + public function setupMfa(int $userId): array { + $secret = $this->generateTotpSecret(); + $this->db->exec("DELETE FROM mfa_methods WHERE user_id = :id AND enabled_at IS NULL", [':id' => $userId]); + $this->db->exec(" + INSERT INTO mfa_methods (user_id, type, secret_enc, enabled_at, created_at) + VALUES (:uid, 'totp', :s, NULL, NOW()) + ", [':uid' => $userId, ':s' => $secret]); + + $issuer = (string)($this->config['app']['app_id'] ?? 'scmedia'); + $label = rawurlencode($issuer . ':' . $this->getUserEmail($userId)); + $otpauth = "otpauth://totp/{$label}?secret={$secret}&issuer=" . rawurlencode($issuer); + + $this->audit('mfa_setup', $userId, 'user', $userId); + return ['ok' => true, 'secret' => $secret, 'otpauth_url' => $otpauth]; + } + + public function confirmMfa(int $userId, string $code): array { + $method = $this->db->fetchOne(" + SELECT id, secret_enc + FROM mfa_methods + WHERE user_id = :uid AND enabled_at IS NULL AND type = 'totp' + ORDER BY id DESC + LIMIT 1 + ", [':uid' => $userId]); + if (!$method) { + return ['ok' => false, 'error' => 'No pending MFA setup']; + } + + $secret = (string)($method['secret_enc'] ?? ''); + if (!$this->verifyTotp($secret, $code)) { + return ['ok' => false, 'error' => 'Invalid code']; + } + + $this->db->exec("UPDATE mfa_methods SET enabled_at = NOW() WHERE id = :id", [':id' => (int)$method['id']]); + $codes = $this->generateBackupCodes($userId); + $this->audit('mfa_enabled', $userId, 'user', $userId); + return ['ok' => true, 'backup_codes' => $codes]; + } + + public function disableMfa(int $userId): array { + $this->db->begin(); + try { + $this->db->exec("DELETE FROM mfa_methods WHERE user_id = :uid", [':uid' => $userId]); + $this->db->exec("DELETE FROM mfa_backup_codes WHERE user_id = :uid", [':uid' => $userId]); + $this->db->commit(); + } catch (Throwable $e) { + $this->db->rollBack(); + return ['ok' => false, 'error' => $e->getMessage()]; + } + $this->invalidateUserSessions($userId, 'mfa_disabled'); + $this->audit('mfa_disabled', $userId, 'user', $userId); + return ['ok' => true]; + } + + public function passwordForgot(string $email): array { + $email = strtolower(trim($email)); + $user = $this->db->fetchOne("SELECT id FROM users WHERE email = :e", [':e' => $email]); + if (!$user) { + return ['ok' => true]; + } + + $token = $this->randomToken(32); + $hash = $this->hashToken($token); + $this->db->exec(" + INSERT INTO password_resets (user_id, token_hash, expires_at, created_at) + VALUES (:uid, :h, DATE_ADD(NOW(), INTERVAL :mins MINUTE), NOW()) + ", [':uid' => (int)$user['id'], ':h' => $hash, ':mins' => $this->passwordResetTtlMinutes]); + + $payload = ['ok' => true]; + if ($this->returnResetToken) { + $payload['reset_token'] = $token; + } + $this->audit('password_reset_requested', (int)$user['id'], 'user', (int)$user['id']); + return $payload; + } + + public function passwordReset(string $token, string $newPassword): array { + if ($token === '' || $newPassword === '') { + return ['ok' => false, 'error' => 'Token and password required']; + } + + $row = $this->findPasswordReset($token); + if (!$row) { + return ['ok' => false, 'error' => 'Invalid token']; + } + + $hash = password_hash($newPassword, PASSWORD_DEFAULT); + $userId = (int)$row['user_id']; + $this->db->begin(); + try { + $this->db->exec("UPDATE users SET password_hash = :h, password_changed_at = NOW(), token_version = token_version + 1 WHERE id = :id", [ + ':h' => $hash, + ':id' => $userId, + ]); + $this->db->exec("UPDATE password_resets SET used_at = NOW() WHERE id = :id", [':id' => (int)$row['id']]); + $this->db->exec("UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = :uid AND revoked_at IS NULL", [':uid' => $userId]); + $this->db->commit(); + } catch (Throwable $e) { + $this->db->rollBack(); + return ['ok' => false, 'error' => $e->getMessage()]; + } + + $this->audit('password_reset', $userId, 'user', $userId); + return ['ok' => true]; + } + + public function changePassword(int $userId, string $currentPassword, string $newPassword): array { + if ($currentPassword === '' || $newPassword === '') { + return ['ok' => false, 'error' => 'Password required']; + } + $row = $this->db->fetchOne("SELECT password_hash FROM users WHERE id = :id", [':id' => $userId]); + if (!$row) { + return ['ok' => false, 'error' => 'User not found']; + } + if (!password_verify($currentPassword, (string)($row['password_hash'] ?? ''))) { + $this->audit('password_change_failed', $userId, 'user', $userId); + return ['ok' => false, 'error' => 'Invalid password']; + } + $hash = password_hash($newPassword, PASSWORD_DEFAULT); + $this->db->exec("UPDATE users SET password_hash = :h, password_changed_at = NOW(), token_version = token_version + 1 WHERE id = :id", [ + ':h' => $hash, + ':id' => $userId, + ]); + $this->audit('password_change', $userId, 'user', $userId); + return ['ok' => true]; + } + + public function changeEmail(int $userId, string $email): array { + $email = strtolower(trim($email)); + if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + return ['ok' => false, 'error' => 'Invalid email']; + } + $exists = $this->db->fetchOne("SELECT id FROM users WHERE email = :e AND id <> :id", [ + ':e' => $email, + ':id' => $userId, + ]); + if ($exists) { + return ['ok' => false, 'error' => 'Email already in use']; + } + $this->db->exec("UPDATE users SET email = :e, updated_at = NOW() WHERE id = :id", [ + ':e' => $email, + ':id' => $userId, + ]); + $this->audit('email_change', $userId, 'user', $userId, ['email' => $email]); + return ['ok' => true]; + } + + public function listUsers(): array { + return $this->db->fetchAll(" + SELECT u.id, u.email, u.status, u.last_login_at, u.created_at, + GROUP_CONCAT(r.name ORDER BY r.name SEPARATOR ',') AS roles + FROM users u + LEFT JOIN user_roles ur ON ur.user_id = u.id + LEFT JOIN roles r ON r.id = ur.role_id + GROUP BY u.id + ORDER BY u.id ASC + "); + } + + public function listRoles(): array { + return $this->db->fetchAll("SELECT id, name, description FROM roles ORDER BY id ASC"); + } + + public function listAudit(int $page, int $perPage): array { + if ($page < 1) $page = 1; + if ($perPage < 1) $perPage = 50; + if ($perPage > 500) $perPage = 500; + $offset = ($page - 1) * $perPage; + $items = $this->db->fetchAll(" + SELECT a.id, a.actor_user_id, u.email AS actor_email, a.action, a.target_type, + a.target_id, a.ip_addr, a.user_agent, a.meta_json, a.created_at + FROM audit_log a + LEFT JOIN users u ON u.id = a.actor_user_id + ORDER BY a.id DESC + LIMIT {$perPage} OFFSET {$offset} + "); + $totalRow = $this->db->fetchOne("SELECT COUNT(*) AS c FROM audit_log"); + return [ + 'items' => $items, + 'total' => (int)($totalRow['c'] ?? 0), + ]; + } + + public function listAuditFiltered(int $page, int $perPage, string $from = '', string $to = '', string $action = ''): array { + if ($page < 1) $page = 1; + if ($perPage < 1) $perPage = 50; + if ($perPage > 500) $perPage = 500; + $offset = ($page - 1) * $perPage; + + $where = []; + $params = []; + if ($from !== '' && $to !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $from) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) { + $where[] = "a.created_at >= :from AND a.created_at < DATE_ADD(:to, INTERVAL 1 DAY)"; + $params[':from'] = $from . ' 00:00:00'; + $params[':to'] = $to . ' 00:00:00'; + } + if ($action !== '') { + $where[] = "a.action = :action"; + $params[':action'] = $action; + } + $whereSql = count($where) > 0 ? ('WHERE ' . implode(' AND ', $where)) : ''; + + $items = $this->db->fetchAll(" + SELECT a.id, a.actor_user_id, u.email AS actor_email, a.action, a.target_type, + a.target_id, a.ip_addr, a.user_agent, a.meta_json, a.created_at + FROM audit_log a + LEFT JOIN users u ON u.id = a.actor_user_id + {$whereSql} + ORDER BY a.id DESC + LIMIT {$perPage} OFFSET {$offset} + ", $params); + $totalRow = $this->db->fetchOne("SELECT COUNT(*) AS c FROM audit_log {$whereSql}", $params); + return [ + 'items' => $items, + 'total' => (int)($totalRow['c'] ?? 0), + ]; + } + + public function setUserRoles(int $actorId, int $userId, array $roles): array { + $roles = array_values(array_filter(array_map('trim', $roles))); + if (count($roles) === 0) { + return ['ok' => false, 'error' => 'Roles required']; + } + + $this->db->begin(); + try { + $this->db->exec("DELETE FROM user_roles WHERE user_id = :uid", [':uid' => $userId]); + $this->assignRolesInternal($userId, $roles); + $this->db->exec("UPDATE users SET token_version = token_version + 1 WHERE id = :id", [':id' => $userId]); + $this->db->exec("UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = :uid AND revoked_at IS NULL", [':uid' => $userId]); + $this->db->commit(); + } catch (Throwable $e) { + $this->db->rollBack(); + return ['ok' => false, 'error' => $e->getMessage()]; + } + + $this->audit('roles_changed', $actorId, 'user', $userId, ['roles' => $roles]); + return ['ok' => true]; + } + + public function disableUser(int $actorId, int $userId, bool $disabled): array { + if ($disabled) { + $isAdmin = $this->db->fetchOne(" + SELECT 1 + FROM user_roles ur + JOIN roles r ON r.id = ur.role_id + WHERE ur.user_id = :uid AND r.name = 'admin' + LIMIT 1 + ", [':uid' => $userId]); + if ($isAdmin) { + $row = $this->db->fetchOne(" + SELECT COUNT(*) AS c + FROM users u + JOIN user_roles ur ON ur.user_id = u.id + JOIN roles r ON r.id = ur.role_id + WHERE r.name = 'admin' AND u.status = 'active' + "); + $admins = (int)($row['c'] ?? 0); + if ($admins <= 1) { + return ['ok' => false, 'error' => 'Cannot disable last admin']; + } + } + } + $status = $disabled ? 'disabled' : 'active'; + $this->db->exec("UPDATE users SET status = :s WHERE id = :id", [':s' => $status, ':id' => $userId]); + $this->invalidateUserSessions($userId, 'user_disabled'); + $this->audit($disabled ? 'user_disabled' : 'user_enabled', $actorId, 'user', $userId); + return ['ok' => true]; + } + + public function resetUserMfa(int $actorId, int $userId): array { + $this->db->begin(); + try { + $this->db->exec("DELETE FROM mfa_methods WHERE user_id = :uid", [':uid' => $userId]); + $this->db->exec("DELETE FROM mfa_backup_codes WHERE user_id = :uid", [':uid' => $userId]); + $this->db->commit(); + } catch (Throwable $e) { + $this->db->rollBack(); + return ['ok' => false, 'error' => $e->getMessage()]; + } + $this->invalidateUserSessions($userId, 'mfa_reset'); + $this->audit('mfa_reset', $actorId, 'user', $userId); + return ['ok' => true]; + } + + public function issueSseKey(int $userId): array { + $key = $this->randomToken(24); + $hash = $this->hashToken($key); + $this->db->exec(" + INSERT INTO auth_sse_keys (user_id, key_hash, expires_at, created_at) + VALUES (:uid, :h, DATE_ADD(NOW(), INTERVAL :ttl SECOND), NOW()) + ", [ + ':uid' => $userId, + ':h' => $hash, + ':ttl' => $this->sseKeyTtl, + ]); + return ['key' => $key, 'expires_in' => $this->sseKeyTtl]; + } + + public function validateSseKey(string $key): ?int { + if ($key === '') { + return null; + } + $hash = $this->hashToken($key); + $row = $this->db->fetchOne(" + SELECT user_id + FROM auth_sse_keys + WHERE key_hash = :h AND expires_at > NOW() + LIMIT 1 + ", [':h' => $hash]); + if (!$row) { + return null; + } + return (int)($row['user_id'] ?? 0); + } + + public function startSseSession(int $userId): string { + $existing = $this->db->fetchOne(" + SELECT session_token, updated_at + FROM auth_sse_sessions + WHERE user_id = :uid + LIMIT 1 + ", [':uid' => $userId]); + if ($existing && $this->isRecentSseUpdate($existing['updated_at'] ?? null)) { + return (string)($existing['session_token'] ?? ''); + } + $token = $this->hashToken($this->randomToken(24)); + $this->db->exec(" + INSERT INTO auth_sse_sessions (user_id, session_token, updated_at) + VALUES (:uid, :token, NOW()) + ON DUPLICATE KEY UPDATE session_token = VALUES(session_token), updated_at = NOW() + ", [ + ':uid' => $userId, + ':token' => $token, + ]); + return $token; + } + + public function isSseSessionActive(int $userId, string $token): bool { + $row = $this->db->fetchOne(" + SELECT session_token, updated_at + FROM auth_sse_sessions + WHERE user_id = :uid + LIMIT 1 + ", [':uid' => $userId]); + if (!$row) return false; + if (!hash_equals((string)$row['session_token'], $token)) { + return false; + } + return $this->isRecentSseUpdate($row['updated_at'] ?? null); + } + + public function touchSseSession(int $userId, string $token): void { + $this->db->exec(" + UPDATE auth_sse_sessions + SET updated_at = NOW() + WHERE user_id = :uid AND session_token = :token + ", [ + ':uid' => $userId, + ':token' => $token, + ]); + } + + private function isRecentSseUpdate(?string $updatedAt): bool { + if (!$updatedAt) return false; + $ts = strtotime($updatedAt); + if ($ts === false) return false; + return (time() - $ts) <= $this->sseSessionTtl; + } + + private function issueTokens(int $userId, string $clientType, string $deviceId = '', int $rotatedFromId = 0): array { + $roles = $this->getUserRoles($userId); + $user = $this->db->fetchOne("SELECT token_version FROM users WHERE id = :id", [':id' => $userId]); + $tokenVersion = (int)($user['token_version'] ?? 1); + $now = time(); + $payload = [ + 'sub' => $userId, + 'roles' => $roles, + 'ver' => $tokenVersion, + 'iat' => $now, + 'exp' => $now + $this->accessTtl, + 'jti' => $this->randomToken(16), + ]; + + $access = $this->signJwt($payload); + $refresh = $this->randomToken(32); + $refreshHash = $this->hashToken($refresh); + $deviceId = $deviceId !== '' ? $deviceId : $this->randomToken(12); + $ttlDays = ($clientType === 'mobile') ? $this->refreshTtlMobileDays : $this->refreshTtlWebDays; + + $this->db->exec(" + INSERT INTO refresh_tokens (user_id, device_id, token_hash, user_agent, ip_addr, expires_at, rotated_from_id, created_at) + VALUES (:uid, :did, :h, :ua, :ip, DATE_ADD(NOW(), INTERVAL :days DAY), :rf, NOW()) + ", [ + ':uid' => $userId, + ':did' => $deviceId, + ':h' => $refreshHash, + ':ua' => $this->getUserAgent(), + ':ip' => $this->getIpAddress(), + ':days' => $ttlDays, + ':rf' => $rotatedFromId > 0 ? $rotatedFromId : null, + ]); + + $this->enforceDeviceLimit($userId); + + return [ + 'access_token' => $access, + 'refresh_token' => $refresh, + 'expires_in' => $this->accessTtl, + 'token_type' => 'Bearer', + ]; + } + + private function enforceDeviceLimit(int $userId): void { + $rows = $this->db->fetchAll(" + SELECT id + FROM refresh_tokens + WHERE user_id = :uid AND revoked_at IS NULL AND expires_at > NOW() + ORDER BY created_at ASC + ", [':uid' => $userId]); + + $extra = count($rows) - $this->maxDevices; + if ($extra <= 0) { + return; + } + + $ids = array_slice(array_map(fn($r) => (int)$r['id'], $rows), 0, $extra); + if (count($ids) > 0) { + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $this->db->exec("UPDATE refresh_tokens SET revoked_at = NOW() WHERE id IN ({$placeholders})", $ids); + } + } + + private function createLoginChallenge(int $userId): string { + $token = $this->randomToken(24); + $hash = $this->hashToken($token); + $this->db->exec(" + INSERT INTO auth_challenges (user_id, token_hash, expires_at, created_at) + VALUES (:uid, :h, DATE_ADD(NOW(), INTERVAL :ttl SECOND), NOW()) + ", [':uid' => $userId, ':h' => $hash, ':ttl' => $this->challengeTtl]); + return $token; + } + + private function findChallenge(string $token): ?array { + if ($token === '') { + return null; + } + $hash = $this->hashToken($token); + return $this->db->fetchOne(" + SELECT id, user_id + FROM auth_challenges + WHERE token_hash = :h AND used_at IS NULL AND expires_at > NOW() + ORDER BY id DESC + LIMIT 1 + ", [':h' => $hash]); + } + + private function findRefreshToken(string $token): ?array { + if ($token === '') { + return null; + } + $hash = $this->hashToken($token); + return $this->db->fetchOne(" + SELECT id, user_id, expires_at, revoked_at + FROM refresh_tokens + WHERE token_hash = :h + LIMIT 1 + ", [':h' => $hash]); + } + + private function findPasswordReset(string $token): ?array { + $hash = $this->hashToken($token); + return $this->db->fetchOne(" + SELECT id, user_id + FROM password_resets + WHERE token_hash = :h AND used_at IS NULL AND expires_at > NOW() + LIMIT 1 + ", [':h' => $hash]); + } + + private function getUserRoles(int $userId): array { + $rows = $this->db->fetchAll(" + SELECT r.name + FROM roles r + INNER JOIN user_roles ur ON ur.role_id = r.id + WHERE ur.user_id = :uid + ", [':uid' => $userId]); + return array_values(array_filter(array_map(fn($r) => (string)$r['name'], $rows))); + } + + private function assignRolesInternal(int $userId, array $roles): void { + foreach ($roles as $role) { + $row = $this->db->fetchOne("SELECT id FROM roles WHERE name = :n", [':n' => $role]); + if (!$row) { + continue; + } + $this->db->exec("INSERT IGNORE INTO user_roles (user_id, role_id) VALUES (:u, :r)", [ + ':u' => $userId, + ':r' => (int)$row['id'], + ]); + } + } + + private function isMfaEnabled(int $userId): bool { + $row = $this->db->fetchOne("SELECT id FROM mfa_methods WHERE user_id = :uid AND enabled_at IS NOT NULL LIMIT 1", [':uid' => $userId]); + return (bool)$row; + } + + private function verifyMfaCode(int $userId, string $code): bool { + $code = trim($code); + if ($code === '') { + return false; + } + + $method = $this->db->fetchOne(" + SELECT secret_enc + FROM mfa_methods + WHERE user_id = :uid AND enabled_at IS NOT NULL AND type = 'totp' + ORDER BY id DESC LIMIT 1 + ", [':uid' => $userId]); + if ($method) { + $secret = (string)($method['secret_enc'] ?? ''); + if ($this->verifyTotp($secret, $code)) { + return true; + } + } + + $hash = $this->hashToken($code); + $row = $this->db->fetchOne(" + SELECT id + FROM mfa_backup_codes + WHERE user_id = :uid AND code_hash = :h AND used_at IS NULL + LIMIT 1 + ", [':uid' => $userId, ':h' => $hash]); + if ($row) { + $this->db->exec("UPDATE mfa_backup_codes SET used_at = NOW() WHERE id = :id", [':id' => (int)$row['id']]); + return true; + } + + return false; + } + + private function generateBackupCodes(int $userId, int $count = 10): array { + $this->db->exec("DELETE FROM mfa_backup_codes WHERE user_id = :uid", [':uid' => $userId]); + $codes = []; + for ($i = 0; $i < $count; $i++) { + $code = strtoupper(bin2hex(random_bytes(4))); + $codes[] = $code; + $this->db->exec(" + INSERT INTO mfa_backup_codes (user_id, code_hash, created_at) + VALUES (:uid, :h, NOW()) + ", [':uid' => $userId, ':h' => $this->hashToken($code)]); + } + return $codes; + } + + private function verifyTotp(string $secret, string $code, int $window = 1): bool { + $secret = trim($secret); + if ($secret === '' || !ctype_digit($code)) { + return false; + } + $timestamp = time(); + $timeStep = 30; + $t = (int)floor($timestamp / $timeStep); + + for ($i = -$window; $i <= $window; $i++) { + $hash = $this->hotp($secret, $t + $i); + if (hash_equals($hash, $code)) { + return true; + } + } + return false; + } + + private function hotp(string $secret, int $counter, int $digits = 6): string { + $key = $this->base32Decode($secret); + $binCounter = pack('N*', 0) . pack('N*', $counter); + $hash = hash_hmac('sha1', $binCounter, $key, true); + $offset = ord(substr($hash, -1)) & 0x0F; + $part = substr($hash, $offset, 4); + $value = unpack('N', $part)[1] & 0x7fffffff; + $mod = $value % (10 ** $digits); + return str_pad((string)$mod, $digits, '0', STR_PAD_LEFT); + } + + private function base32Decode(string $str): string { + $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + $str = strtoupper($str); + $str = preg_replace('/[^A-Z2-7]/', '', $str); + if ($str === null) { + $str = ''; + } + $bits = ''; + for ($i = 0; $i < strlen($str); $i++) { + $val = strpos($alphabet, $str[$i]); + if ($val === false) { + continue; + } + $bits .= str_pad(decbin($val), 5, '0', STR_PAD_LEFT); + } + $bytes = ''; + for ($i = 0; $i + 8 <= strlen($bits); $i += 8) { + $bytes .= chr(bindec(substr($bits, $i, 8))); + } + return $bytes; + } + + private function generateTotpSecret(int $length = 16): string { + $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + $secret = ''; + for ($i = 0; $i < $length; $i++) { + $secret .= $alphabet[random_int(0, 31)]; + } + return $secret; + } + + private function signJwt(array $payload): string { + $header = ['alg' => 'HS256', 'typ' => 'JWT']; + $segments = [ + $this->base64UrlEncode(json_encode($header, JSON_UNESCAPED_SLASHES)), + $this->base64UrlEncode(json_encode($payload, JSON_UNESCAPED_SLASHES)), + ]; + $data = implode('.', $segments); + $sig = hash_hmac('sha256', $data, $this->jwtSecret, true); + $segments[] = $this->base64UrlEncode($sig); + return implode('.', $segments); + } + + private function verifyJwt(string $token): ?array { + $parts = explode('.', $token); + if (count($parts) !== 3) { + return null; + } + [$h, $p, $s] = $parts; + $data = $h . '.' . $p; + $expected = $this->base64UrlEncode(hash_hmac('sha256', $data, $this->jwtSecret, true)); + if (!hash_equals($expected, $s)) { + return null; + } + $payload = json_decode($this->base64UrlDecode($p), true); + if (!is_array($payload)) { + return null; + } + $exp = (int)($payload['exp'] ?? 0); + if ($exp > 0 && time() > $exp) { + return null; + } + return $payload; + } + + private function base64UrlEncode(string $data): string { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + private function base64UrlDecode(string $data): string { + $pad = strlen($data) % 4; + if ($pad > 0) { + $data .= str_repeat('=', 4 - $pad); + } + return base64_decode(strtr($data, '-_', '+/')) ?: ''; + } + + private function randomToken(int $bytes): string { + return rtrim(strtr(base64_encode(random_bytes($bytes)), '+/', '-_'), '='); + } + + private function hashToken(string $token): string { + return hash('sha256', $token); + } + + private function readBearerToken(): string { + $header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''; + if ($header === '' && function_exists('apache_request_headers')) { + $headers = apache_request_headers(); + $header = $headers['Authorization'] ?? ''; + } + if (preg_match('/^Bearer\s+(.+)$/i', $header, $m)) { + return trim($m[1]); + } + $cookie = $_COOKIE['access_token'] ?? ''; + if (is_string($cookie) && $cookie !== '') { + return $cookie; + } + return ''; + } + + private function redirectToLogin(): void { + header('Location: /login', true, 302); + exit; + } + + private function getUserAgent(): string { + return substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255); + } + + private function getIpAddress(): string { + return substr((string)($_SERVER['REMOTE_ADDR'] ?? ''), 0, 64); + } + + private function getUserEmail(int $userId): string { + $row = $this->db->fetchOne("SELECT email FROM users WHERE id = :id", [':id' => $userId]); + return (string)($row['email'] ?? ''); + } + + private function invalidateUserSessions(int $userId, string $reason): void { + $this->revokeUserSessions($userId); + $this->audit('forced_logout', $userId, 'user', $userId, ['reason' => $reason]); + } + + private function revokeUserSessions(int $userId): void { + $this->db->exec("UPDATE users SET token_version = token_version + 1 WHERE id = :id", [':id' => $userId]); + $this->db->exec("UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = :uid AND revoked_at IS NULL", [':uid' => $userId]); + } + + private function isFirstUser(int $userId): bool { + $row = $this->db->fetchOne("SELECT COUNT(*) AS c FROM users"); + return (int)($row['c'] ?? 0) === 1; + } + + private function isRateLimited(string $action): int { + $ip = $this->getIpAddress(); + $row = $this->db->fetchOne(" + SELECT id, failed_count, window_started_at, blocked_until + FROM auth_rate_limits + WHERE ip_addr = :ip AND action = :action + LIMIT 1 + ", [':ip' => $ip, ':action' => $action]); + + if (!$row) { + return 0; + } + + $blockedUntil = $row['blocked_until'] ? strtotime((string)$row['blocked_until']) : null; + if ($blockedUntil !== null && $blockedUntil > time()) { + return $blockedUntil - time(); + } + + $windowStart = strtotime((string)$row['window_started_at']); + $window = $this->getRateWindow($action); + if ($windowStart !== false && (time() - $windowStart) > $window) { + $this->db->exec(" + UPDATE auth_rate_limits + SET failed_count = 0, window_started_at = NOW(), blocked_until = NULL + WHERE id = :id + ", [':id' => (int)$row['id']]); + } + + return 0; + } + + private function registerFailure(string $action): void { + $ip = $this->getIpAddress(); + $row = $this->db->fetchOne(" + SELECT id, failed_count, window_started_at + FROM auth_rate_limits + WHERE ip_addr = :ip AND action = :action + LIMIT 1 + ", [':ip' => $ip, ':action' => $action]); + + $window = $this->getRateWindow($action); + $max = $this->getRateMax($action); + $block = $this->getRateBlock($action); + $now = time(); + + if (!$row) { + $blockedUntil = $max <= 1 ? date('Y-m-d H:i:s', $now + $block) : null; + $this->db->exec(" + INSERT INTO auth_rate_limits (ip_addr, action, failed_count, window_started_at, blocked_until) + VALUES (:ip, :action, :cnt, NOW(), :blocked) + ", [ + ':ip' => $ip, + ':action' => $action, + ':cnt' => 1, + ':blocked' => $blockedUntil, + ]); + return; + } + + $windowStart = strtotime((string)$row['window_started_at']); + $count = (int)$row['failed_count']; + if ($windowStart === false || ($now - $windowStart) > $window) { + $count = 0; + $windowStart = $now; + } + $count++; + $blockedUntil = $count >= $max ? date('Y-m-d H:i:s', $now + $block) : null; + + $this->db->exec(" + UPDATE auth_rate_limits + SET failed_count = :cnt, window_started_at = :ws, blocked_until = :blocked + WHERE id = :id + ", [ + ':cnt' => $count, + ':ws' => date('Y-m-d H:i:s', $windowStart), + ':blocked' => $blockedUntil, + ':id' => (int)$row['id'], + ]); + } + + private function resetRateLimit(string $action): void { + $ip = $this->getIpAddress(); + $this->db->exec("DELETE FROM auth_rate_limits WHERE ip_addr = :ip AND action = :action", [ + ':ip' => $ip, + ':action' => $action, + ]); + } + + private function getRateMax(string $action): int { + return $action === 'mfa' ? $this->mfaMax : $this->loginMax; + } + + private function getRateWindow(string $action): int { + return $action === 'mfa' ? $this->mfaWindow : $this->loginWindow; + } + + private function getRateBlock(string $action): int { + return $action === 'mfa' ? $this->mfaBlock : $this->loginBlock; + } + + private function audit(string $action, ?int $actorId, ?string $targetType, ?int $targetId, array $meta = []): void { + $this->db->exec(" + INSERT INTO audit_log (actor_user_id, action, target_type, target_id, ip_addr, user_agent, meta_json, created_at) + VALUES (:actor, :action, :tt, :tid, :ip, :ua, :meta, NOW()) + ", [ + ':actor' => $actorId, + ':action' => $action, + ':tt' => $targetType, + ':tid' => $targetId, + ':ip' => $this->getIpAddress(), + ':ua' => $this->getUserAgent(), + ':meta' => json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]); + } +} diff --git a/app/services/ExportService.php b/app/services/ExportService.php new file mode 100644 index 0000000..fbd8ec8 --- /dev/null +++ b/app/services/ExportService.php @@ -0,0 +1,55 @@ +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; + } +} diff --git a/app/services/HttpClient.php b/app/services/HttpClient.php new file mode 100644 index 0000000..88857f7 --- /dev/null +++ b/app/services/HttpClient.php @@ -0,0 +1,69 @@ +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]; + } +} diff --git a/app/services/JobsService.php b/app/services/JobsService.php new file mode 100644 index 0000000..e853162 --- /dev/null +++ b/app/services/JobsService.php @@ -0,0 +1,166 @@ +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) + "); + } +} diff --git a/app/services/LayoutService.php b/app/services/LayoutService.php new file mode 100644 index 0000000..8e339f9 --- /dev/null +++ b/app/services/LayoutService.php @@ -0,0 +1,227 @@ +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], + ]; + } +} diff --git a/app/services/LogService.php b/app/services/LogService.php new file mode 100644 index 0000000..acd303e --- /dev/null +++ b/app/services/LogService.php @@ -0,0 +1,184 @@ +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; + } +} diff --git a/app/services/MediaApplyService.php b/app/services/MediaApplyService.php new file mode 100644 index 0000000..c42a3ef --- /dev/null +++ b/app/services/MediaApplyService.php @@ -0,0 +1,194 @@ +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) : ''; + } +} diff --git a/app/services/MediaLibraryService.php b/app/services/MediaLibraryService.php new file mode 100644 index 0000000..d48ad09 --- /dev/null +++ b/app/services/MediaLibraryService.php @@ -0,0 +1,751 @@ +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]; + } +} diff --git a/app/services/MetadataService.php b/app/services/MetadataService.php new file mode 100644 index 0000000..b6665c3 --- /dev/null +++ b/app/services/MetadataService.php @@ -0,0 +1,372 @@ +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 + */ + 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; + } +} diff --git a/app/services/MkvToolnixService.php b/app/services/MkvToolnixService.php new file mode 100644 index 0000000..36c386c --- /dev/null +++ b/app/services/MkvToolnixService.php @@ -0,0 +1,274 @@ +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']); + } + } + +} diff --git a/app/services/PathTool.php b/app/services/PathTool.php new file mode 100644 index 0000000..053d3ba --- /dev/null +++ b/app/services/PathTool.php @@ -0,0 +1,85 @@ + 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, + ]; + } +} diff --git a/app/services/ScanProfilesService.php b/app/services/ScanProfilesService.php new file mode 100644 index 0000000..2c0cbe9 --- /dev/null +++ b/app/services/ScanProfilesService.php @@ -0,0 +1,158 @@ +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; + } +} diff --git a/app/services/ScannerService.php b/app/services/ScannerService.php new file mode 100644 index 0000000..34c9337 --- /dev/null +++ b/app/services/ScannerService.php @@ -0,0 +1,524 @@ +db = $db; + $this->settings = $settings; + $this->profiles = $profiles; + $this->jobs = $jobs; + $this->mkvtoolnix = $mkvtoolnix; + $this->mediaLibrary = $mediaLibrary; + } + + public function runScanJob(int $jobId): array { + $all = $this->settings->getAll(); + + $scannerDefaults = $all['scanner_defaults'] ?? []; + $globalExt = is_array($scannerDefaults['video_ext'] ?? null) ? $scannerDefaults['video_ext'] : ['mkv','mp4','avi','mov','m4v','ts','m2ts','wmv']; + + $hardLimits = $this->getHardLimits(); + $defaultMaxDepth = (int)($scannerDefaults['max_depth_default'] ?? 3); + $defaultMaxFilesPerItem = (int)($scannerDefaults['max_files_per_item'] ?? 3000); + $defaultMaxItemsPerScan = (int)($scannerDefaults['max_items_per_scan'] ?? 0); + + $defaultMaxDepth = $this->cap($defaultMaxDepth, 1, (int)$hardLimits['max_depth']); + $defaultMaxFilesPerItem = $this->cap($defaultMaxFilesPerItem, 100, (int)$hardLimits['max_files_per_item']); + $defaultMaxItemsPerScan = $this->cap($defaultMaxItemsPerScan, 0, (int)$hardLimits['max_items_per_scan']); + + $profiles = $this->profiles->list(); + $profiles = array_values(array_filter($profiles, fn($p) => !empty($p['enabled']))); + + $this->jobs->log($jobId, 'info', 'Scan started'); + $this->jobs->setProgress($jobId, 0, max(1, count($profiles))); + + $totalItemsSeen = 0; + $profileIndex = 0; + + foreach ($profiles as $p) { + if ($this->jobs->cancelIfRequested($jobId)) { + $this->jobs->log($jobId, 'warn', 'Scan canceled by user'); + break; + } + $profileIndex++; + + $profileId = (int)$p['id']; + $root = (string)$p['root_path']; + $profileType = (string)($p['profile_type'] ?? 'scan'); + + $maxDepth = (int)($p['max_depth'] ?? $defaultMaxDepth); + $maxDepth = $this->cap($maxDepth, 1, (int)$hardLimits['max_depth']); + + $exclude = $this->toStringList($p['exclude_patterns'] ?? []); + $extMode = (string)($p['include_ext_mode'] ?? 'default'); + $ext = $globalExt; + if ($extMode === 'custom') { + $custom = $this->toStringList($p['include_ext'] ?? []); + if (count($custom) > 0) $ext = $custom; + } + + $this->jobs->log($jobId, 'info', "Profile #{$profileId} ({$profileType}): {$root}"); + + if ($profileType === 'analyze') { + $result = $this->analyzeProfileRoot( + $jobId, + $profileId, + $root, + $exclude, + $ext, + $maxDepth, + $defaultMaxItemsPerScan + ); + $this->jobs->log($jobId, 'info', "Analyze done. Files: {$result['files']} Changed: {$result['changed']} Cached: {$result['cached']}"); + } else { + $seenAbs = $this->scanProfileRoot( + $jobId, + $profileId, + $root, + $exclude, + $ext, + $maxDepth, + $defaultMaxFilesPerItem, + $defaultMaxItemsPerScan, + $totalItemsSeen + ); + + // Mark gone + $this->markGone($profileId, $seenAbs); + } + + $this->db->exec(" + UPDATE scan_profiles + SET last_scan_at=NOW(), last_result='ok' + WHERE id=:id + ", [':id' => $profileId]); + + $this->jobs->setProgress($jobId, $profileIndex, count($profiles)); + } + + if ($this->jobs->isCancelRequested($jobId)) { + return [ + 'profiles' => count($profiles), + 'items_seen' => $totalItemsSeen, + 'canceled' => true, + ]; + } + + $this->jobs->log($jobId, 'info', "Scan finished. Items seen: {$totalItemsSeen}"); + $this->jobs->setProgress($jobId, count($profiles), count($profiles)); + + return [ + 'profiles' => count($profiles), + 'items_seen' => $totalItemsSeen, + ]; + } + + private function analyzeProfileRoot( + int $jobId, + int $profileId, + string $root, + array $excludePatterns, + array $videoExt, + int $maxDepth, + int $maxFiles + ): array { + $root = rtrim($root, "/"); + + if ($root === '' || !is_dir($root)) { + $this->jobs->log($jobId, 'warn', "Analyze root not accessible: {$root}"); + return ['files' => 0, 'changed' => 0, 'cached' => 0]; + } + + if (!$this->mkvtoolnix->isAvailable()) { + $this->jobs->log($jobId, 'warn', "mkvmerge not available, analyze limited to inventory"); + } + + $extList = $this->normExt($videoExt); + $files = 0; + $changed = 0; + $cached = 0; + + $stack = [[ 'path' => $root, 'depth' => 0 ]]; + $stop = false; + + while (count($stack) > 0 && !$stop) { + if ($this->jobs->cancelIfRequested($jobId)) { + $this->jobs->log($jobId, 'warn', 'Analyze canceled by user'); + break; + } + $node = array_pop($stack); + $path = $node['path']; + $depth = (int)$node['depth']; + + $entries = @scandir($path); + if (!is_array($entries)) continue; + + foreach ($entries as $name) { + if ($this->jobs->cancelIfRequested($jobId)) { + $this->jobs->log($jobId, 'warn', 'Analyze canceled by user'); + $stop = true; + break; + } + if ($name === '.' || $name === '..') continue; + if ($this->isExcluded($name, $excludePatterns)) continue; + + $abs = $path . '/' . $name; + + if (is_dir($abs) && $depth < $maxDepth) { + $stack[] = ['path' => $abs, 'depth' => $depth + 1]; + continue; + } + + if (!is_file($abs)) continue; + + $ext = strtolower(pathinfo($abs, PATHINFO_EXTENSION)); + if ($ext === '' || !in_array($ext, $extList, true)) continue; + + $files++; + if ($maxFiles > 0 && $files >= $maxFiles) { + $this->jobs->log($jobId, 'warn', "Analyze max files reached: {$maxFiles}"); + $stop = true; + break; + } + + $stat = @stat($abs); + $res = null; + if ($ext === 'mkv') { + $res = $this->mkvtoolnix->readFileData($abs); + } + if (is_array($stat)) { + $this->mediaLibrary->upsertMediaFile( + $profileId, + $root, + $abs, + $stat, + ($ext === 'mkv'), + ($res && isset($res['mkvmerge']['data'])) ? $res['mkvmerge']['data'] : null + ); + } + if (is_array($res) && !empty($res['ok'])) { + if (!empty($res['changed'])) { + $changed++; + } else if (!empty($res['from_cache'])) { + $cached++; + } + } + } + } + + return ['files' => $files, 'changed' => $changed, 'cached' => $cached]; + } + + private function scanProfileRoot( + int $jobId, + int $profileId, + string $root, + array $excludePatterns, + array $videoExt, + int $maxDepth, + int $maxFilesPerItem, + int $maxItemsPerScan, + int &$totalItemsSeen + ): array { + $root = rtrim($root, "/"); + + if ($root === '' || !is_dir($root)) { + $this->jobs->log($jobId, 'warn', "Root not accessible: {$root}"); + $this->db->exec(" + UPDATE scan_profiles + SET last_scan_at=NOW(), last_result='error' + WHERE id=:id + ", [':id' => $profileId]); + return []; + } + + $entries = @scandir($root); + if (!is_array($entries)) { + $this->jobs->log($jobId, 'error', "Cannot read dir: {$root}"); + $this->db->exec(" + UPDATE scan_profiles + SET last_scan_at=NOW(), last_result='error' + WHERE id=:id + ", [':id' => $profileId]); + return []; + } + + $seen = []; + + foreach ($entries as $name) { + if ($this->jobs->cancelIfRequested($jobId)) { + $this->jobs->log($jobId, 'warn', 'Scan canceled by user'); + break; + } + if ($name === '.' || $name === '..') continue; + if ($this->isExcluded($name, $excludePatterns)) continue; + + $abs = $root . '/' . $name; + + // Respect max items per scan (if >0) + if ($maxItemsPerScan > 0 && $totalItemsSeen >= $maxItemsPerScan) { + $this->jobs->log($jobId, 'warn', "Max items per scan reached: {$maxItemsPerScan}"); + break; + } + + $info = $this->inspectItem($abs, $videoExt, $maxDepth, $maxFilesPerItem); + + $display = $name; + $structure = $info['structure']; + $videoCount = $info['video_count']; + $fileCount = $info['file_count']; + $confidence = $info['confidence']; + + $this->upsertItem($profileId, $abs, $name, $display, $structure, $confidence, $videoCount, $fileCount); + + $seen[] = $abs; + $totalItemsSeen++; + $seenAbsKey = $abs; + $seen[$seenAbsKey] = true; + } + + return array_keys($seen); + } + + private function inspectItem(string $abs, array $videoExt, int $maxDepth, int $maxFilesPerItem): array { + if (is_file($abs)) { + $ext = strtolower(pathinfo($abs, PATHINFO_EXTENSION)); + $isVideo = in_array($ext, $this->normExt($videoExt), true); + + return [ + 'structure' => 'file', + 'confidence' => $isVideo ? 70 : 10, + 'video_count' => $isVideo ? 1 : 0, + 'file_count' => 1, + ]; + } + + if (!is_dir($abs)) { + return [ + 'structure' => 'folder', + 'confidence' => 0, + 'video_count' => 0, + 'file_count' => 0, + ]; + } + + // detect dvd/bluray by marker folders + if (is_dir($abs . '/BDMV')) { + $counts = $this->countFiles($abs, $videoExt, $maxDepth, $maxFilesPerItem); + return [ + 'structure' => 'bluray', + 'confidence' => 90, + 'video_count' => $counts['video'], + 'file_count' => $counts['files'], + ]; + } + + if (is_dir($abs . '/VIDEO_TS')) { + $counts = $this->countFiles($abs, $videoExt, $maxDepth, $maxFilesPerItem); + return [ + 'structure' => 'dvd', + 'confidence' => 90, + 'video_count' => $counts['video'], + 'file_count' => $counts['files'], + ]; + } + + $counts = $this->countFiles($abs, $videoExt, $maxDepth, $maxFilesPerItem); + + return [ + 'structure' => 'folder', + 'confidence' => $counts['video'] > 0 ? 60 : 20, + 'video_count' => $counts['video'], + 'file_count' => $counts['files'], + ]; + } + + private function countFiles(string $dir, array $videoExt, int $maxDepth, int $maxFilesPerItem): array { + $videoExt = $this->normExt($videoExt); + + $files = 0; + $video = 0; + + $stack = [[ 'path' => $dir, 'depth' => 0 ]]; + + while (count($stack) > 0) { + $node = array_pop($stack); + $path = $node['path']; + $depth = (int)$node['depth']; + + $entries = @scandir($path); + if (!is_array($entries)) continue; + + foreach ($entries as $name) { + if ($name === '.' || $name === '..') continue; + + $abs = $path . '/' . $name; + + if (is_file($abs)) { + $files++; + $ext = strtolower(pathinfo($abs, PATHINFO_EXTENSION)); + if ($ext !== '' && in_array($ext, $videoExt, true)) $video++; + + if ($files >= $maxFilesPerItem) { + return ['files' => $files, 'video' => $video]; + } + continue; + } + + if (is_dir($abs) && $depth < $maxDepth) { + $stack[] = ['path' => $abs, 'depth' => $depth + 1]; + } + } + } + + return ['files' => $files, 'video' => $video]; + } + + private function upsertItem( + int $profileId, + string $absPath, + string $relPath, + string $displayName, + string $structure, + int $confidence, + int $videoCount, + int $fileCount + ): void { + $this->db->exec(" + INSERT INTO items + (scan_profile_id, abs_path, rel_path, display_name, structure, confidence, video_count, file_count, status, last_seen_at) + VALUES + (:pid, :abs, :rel, :name, :structure, :conf, :vcount, :fcount, 'active', NOW()) + ON DUPLICATE KEY UPDATE + rel_path = VALUES(rel_path), + display_name = VALUES(display_name), + structure = VALUES(structure), + confidence = VALUES(confidence), + video_count = VALUES(video_count), + file_count = VALUES(file_count), + status = 'active', + last_seen_at = NOW(), + updated_at = NOW() + ", [ + ':pid' => $profileId, + ':abs' => $absPath, + ':rel' => $relPath, + ':name' => $displayName, + ':structure' => $structure, + ':conf' => $confidence, + ':vcount' => $videoCount, + ':fcount' => $fileCount, + ]); + } + + private function markGone(int $profileId, array $seenAbs): void { + // If nothing seen, do not mark everything gone (safety) + if (count($seenAbs) === 0) return; + + // Mark items not seen in this scan as gone + // We do it by updating those with last_seen_at older than current scan moment. + // A simple approach: mark all as gone, then set active for seen list (but that’s heavy). + // Here: set gone for items where abs_path NOT IN (...) within this profile. + // For large lists, chunk it. + + $chunkSize = 200; + $seenSet = array_values($seenAbs); + + // First mark all as gone for this profile, then re-activate seen ones (fast and safe). + $this->db->exec(" + UPDATE items + SET status='gone', updated_at=NOW() + WHERE scan_profile_id=:pid + ", [':pid' => $profileId]); + + for ($i = 0; $i < count($seenSet); $i += $chunkSize) { + $chunk = array_slice($seenSet, $i, $chunkSize); + + $placeholders = []; + $params = [':pid' => $profileId]; + foreach ($chunk as $idx => $abs) { + $k = ':p' . $idx; + $placeholders[] = $k; + $params[$k] = $abs; + } + + $sql = " + UPDATE items + SET status='active', updated_at=NOW() + WHERE scan_profile_id=:pid + AND abs_path IN (" . implode(',', $placeholders) . ") + "; + + $this->db->exec($sql, $params); + } + } + + private function isExcluded(string $name, array $patterns): bool { + $n = strtolower($name); + foreach ($patterns as $p) { + $p = trim((string)$p); + if ($p === '') continue; + if (str_contains($n, strtolower($p))) return true; + } + return false; + } + + private function normExt(array $ext): array { + $out = []; + foreach ($ext as $e) { + $e = strtolower(trim((string)$e)); + if ($e === '') continue; + $out[] = $e; + } + return array_values(array_unique($out)); + } + + private function toStringList($v): array { + if (!is_array($v)) return []; + $out = []; + foreach ($v as $x) { + $x = trim((string)$x); + if ($x !== '') $out[] = $x; + } + return $out; + } + + private function getHardLimits(): array { + // config safety rails (GUI cannot exceed) + $fromSettings = $this->settings->getAll()['safety'] ?? []; + $cfg = $this->settings->getConfig(); + $limits = $fromSettings ?: ($cfg['safety']['hard_limits'] ?? []); + return [ + 'max_depth' => (int)($limits['max_depth'] ?? 10), + 'max_files_per_item' => (int)($limits['max_files_per_item'] ?? 200000), + 'max_items_per_scan' => (int)($limits['max_items_per_scan'] ?? 1000000), + ]; + } + + private function cap(int $v, int $min, int $max): int { + if ($v < $min) return $min; + if ($v > $max) return $max; + return $v; + } +} diff --git a/app/services/SettingsService.php b/app/services/SettingsService.php new file mode 100644 index 0000000..27d5bd8 --- /dev/null +++ b/app/services/SettingsService.php @@ -0,0 +1,177 @@ +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, + ]); + } +} diff --git a/app/services/ShellTool.php b/app/services/ShellTool.php new file mode 100644 index 0000000..cd17844 --- /dev/null +++ b/app/services/ShellTool.php @@ -0,0 +1,28 @@ +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; + } +} diff --git a/app/services/SourcesService.php b/app/services/SourcesService.php new file mode 100644 index 0000000..688b213 --- /dev/null +++ b/app/services/SourcesService.php @@ -0,0 +1,163 @@ +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; + } +} diff --git a/app/services/TransmissionService.php b/app/services/TransmissionService.php new file mode 100644 index 0000000..e0faa9a --- /dev/null +++ b/app/services/TransmissionService.php @@ -0,0 +1,228 @@ +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; + } +} diff --git a/app/services/export/ExporterInterface.php b/app/services/export/ExporterInterface.php new file mode 100644 index 0000000..dae598e --- /dev/null +++ b/app/services/export/ExporterInterface.php @@ -0,0 +1,14 @@ +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 = "\n"; + $xml .= "<{$root}>\n"; + if ($title !== '') $xml .= " " . htmlspecialchars($title, ENT_QUOTES) . "\n"; + if ($original !== '') $xml .= " " . htmlspecialchars($original, ENT_QUOTES) . "\n"; + if (is_int($year) || is_numeric($year)) $xml .= " " . (int)$year . "\n"; + if ($provider !== '' && $providerId !== '') { + $xml .= " " . + htmlspecialchars($providerId, ENT_QUOTES) . "\n"; + } + if ($providerUrl !== '') { + $xml .= " " . htmlspecialchars($providerUrl, ENT_QUOTES) . "\n"; + } + $xml .= "\n"; + + $ok = @file_put_contents($path, $xml) !== false; + return ['ok' => $ok, 'path' => $path, 'error' => $ok ? null : 'write failed']; + } +} diff --git a/app/services/export/KodiExporter.php b/app/services/export/KodiExporter.php new file mode 100644 index 0000000..61ae489 --- /dev/null +++ b/app/services/export/KodiExporter.php @@ -0,0 +1,57 @@ +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 = "\n"; + $xml .= "<{$root}>\n"; + if ($title !== '') $xml .= " " . htmlspecialchars($title, ENT_QUOTES) . "\n"; + if ($original !== '') $xml .= " " . htmlspecialchars($original, ENT_QUOTES) . "\n"; + if (is_int($year) || is_numeric($year)) $xml .= " " . (int)$year . "\n"; + if ($provider !== '' && $providerId !== '') { + $xml .= " " . + htmlspecialchars($providerId, ENT_QUOTES) . "\n"; + } + if ($providerUrl !== '') { + $xml .= " " . htmlspecialchars($providerUrl, ENT_QUOTES) . "\n"; + } + $xml .= "\n"; + + $ok = @file_put_contents($path, $xml) !== false; + return ['ok' => $ok, 'path' => $path, 'error' => $ok ? null : 'write failed']; + } +} diff --git a/app/services/metadata/MetadataProvider.php b/app/services/metadata/MetadataProvider.php new file mode 100644 index 0000000..5ce3535 --- /dev/null +++ b/app/services/metadata/MetadataProvider.php @@ -0,0 +1,14 @@ +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; + } +} diff --git a/app/services/metadata/TvdbProvider.php b/app/services/metadata/TvdbProvider.php new file mode 100644 index 0000000..53966bf --- /dev/null +++ b/app/services/metadata/TvdbProvider.php @@ -0,0 +1,127 @@ +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; + } +} diff --git a/app/views/pages/account.php b/app/views/pages/account.php new file mode 100644 index 0000000..bfe3395 --- /dev/null +++ b/app/views/pages/account.php @@ -0,0 +1,119 @@ + + + + + + + <?php echo htmlspecialchars($t('account.page_title', 'scMedia / Account'), ENT_QUOTES); ?> + + + + + + + +
+
+
+

+
+ +
+ +
+
+

+
+
+ + + + +
+
+ +
+
+

+
+
+ + + +
+ + +
+
+
+
+ + + + + + + + + + diff --git a/app/views/pages/index.php b/app/views/pages/index.php new file mode 100644 index 0000000..9b25e72 --- /dev/null +++ b/app/views/pages/index.php @@ -0,0 +1,159 @@ + + + + + + + <?php echo htmlspecialchars($t('app.title', 'scMedia'), ENT_QUOTES); ?> + + + + + + + +
+
+ + + +
+ '', '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'; + ?> + $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'; + ?> +
+ + + +
+

+
+ +

+      
+        
+        
+      
+    
+
+ + +
+
+

+ +
+
+
+
+ +

+      
+ + + + +
+
+ + +
+

Table view

+
+ + + + + + + + + + + + +
OnColumnWidthHeader alignCell alignOrder
+
+ + + + + +
+
+ + + +
+

+

+      
+        
+      
+    
+
+ + + + + + + + + + + + + diff --git a/app/views/pages/login.php b/app/views/pages/login.php new file mode 100644 index 0000000..abacb5f --- /dev/null +++ b/app/views/pages/login.php @@ -0,0 +1,83 @@ + + + + + + + <?php echo htmlspecialchars($t('auth.page_title', 'scMedia / Login'), ENT_QUOTES); ?> + + + + +
+
+
+

+
+
+ + +
+
+
+

+ +
+ + + + + +
+ + + + + + +
+
+ + + + + + diff --git a/app/views/pages/settings.php b/app/views/pages/settings.php new file mode 100644 index 0000000..3b9954b --- /dev/null +++ b/app/views/pages/settings.php @@ -0,0 +1,786 @@ + + + + + + + <?php echo htmlspecialchars($t('settings.page_title', 'scmedia / Settings'), ENT_QUOTES); ?> + + + + + + + +
+ + +
+ +
+
+

+ +
+ +
+ + + '', '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'; + ?> +
+ +
+

+
+ + + + +
+
+
+ + +
+
+

+ +
+ +
+ $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'; + ?> +
+
+ +
+ + +
+
+

+
+ + +
+
+ +
+ $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'; + ?> +
+
+
+ + +
+
+

+ +
+
+ $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'; + ?> +
+
+
+ + +
+
+

+
+ + +
+
+ +
+ $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'; + ?> +
+
+ + +
+
+

+
+ +
+

+
+ + + + + +
+
+ +
+

+
+ + + + + + + +
+
+ +
+

+
+ +
+
+ +
+

+
+ + + +
+
+ +
+

+
+ + + + +
+
+ +
+

+
+ + +
+
+ + + + + + + + + + +
+
+
+
+ +
+

+
+
+ + +
+
+

+
+ + +
+
+
+ $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'; + ?> +
+
+
+ + +
+
+

+
+
+
+ + +
+ +
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+
+ + + + +
+
+ '', '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'; + ?> +
+ +
+ +
+
+ + + + +
+
+
+
+ +
+
+
+
+

+ +
+
+ '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'; + ?> +
+
+
+
+ + +
+
+

+
+ +
+

+
+
+
+
-
+
+
+
+
-
+
+
+
+
-
+
+
+ + +

+        
+ +
+

+
+
+
+
-
+
+
+
+
-
+
+
+
DB
+
-
+
+
+
User
+
-
+
+
+
+ 'Table'], + ['label' => 'Rows'], + ]; + $footer = false; + require __DIR__ . '/../partials/table.php'; + ?> +
+

+          
+ + +

+        
+ +
+

+
+ + + +
+

+        
+
+ + +
+
+

+
+
+
+ +
+
+ scMedia + v0.0.0 +
+
+ SAFE-CAP + safe-cap.com +
+
+
+
+
+ $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'; + ?> +
+
+
+
+ + + + + + + + + + + + + + + + + + diff --git a/app/views/partials/header.php b/app/views/partials/header.php new file mode 100644 index 0000000..0a72644 --- /dev/null +++ b/app/views/partials/header.php @@ -0,0 +1,119 @@ + + +
+
+ + + scMedia + + + +
+ + +
+ +
+
>
+
+ +
> + +
+ + + +
+ + + + +
+ +
+
+ +
+
+
+
diff --git a/app/views/partials/table.php b/app/views/partials/table.php new file mode 100644 index 0000000..a6714c4 --- /dev/null +++ b/app/views/partials/table.php @@ -0,0 +1,68 @@ + $v) { + $attrPairs .= ' ' . htmlspecialchars((string)$k, ENT_QUOTES) . '="' . htmlspecialchars((string)$v, ENT_QUOTES) . '"'; +} +?> +
+
+ + + +
+ > + + + + + + + + + > + + + + + + > +
+ + + +
diff --git a/cli/scan.php b/cli/scan.php new file mode 100644 index 0000000..91e0370 --- /dev/null +++ b/cli/scan.php @@ -0,0 +1,31 @@ +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); +} diff --git a/cli/worker.php b/cli/worker.php new file mode 100644 index 0000000..304c3d3 --- /dev/null +++ b/cli/worker.php @@ -0,0 +1,81 @@ +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); +} diff --git a/config/config.php b/config/config.php new file mode 100644 index 0000000..0b889c6 --- /dev/null +++ b/config/config.php @@ -0,0 +1,86 @@ + [ + '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, + ], + ], +]; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bde69d1 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf new file mode 100644 index 0000000..97dd640 --- /dev/null +++ b/docker/nginx/default.conf @@ -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; + } +} diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile new file mode 100644 index 0000000..c4b8cbf --- /dev/null +++ b/docker/php/Dockerfile @@ -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"] diff --git a/docker/php/conf/events.conf b/docker/php/conf/events.conf new file mode 100644 index 0000000..5ef97b9 --- /dev/null +++ b/docker/php/conf/events.conf @@ -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 diff --git a/docker/scripts/entrypoint.sh b/docker/scripts/entrypoint.sh new file mode 100644 index 0000000..7e80fe6 --- /dev/null +++ b/docker/scripts/entrypoint.sh @@ -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}" <&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" diff --git a/docker/scripts/wait-for-db.sh b/docker/scripts/wait-for-db.sh new file mode 100644 index 0000000..f3c2698 --- /dev/null +++ b/docker/scripts/wait-for-db.sh @@ -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 diff --git a/public/assets/assets/branding/scmedia/CODEX.md b/public/assets/assets/branding/scmedia/CODEX.md new file mode 100644 index 0000000..61bacf4 --- /dev/null +++ b/public/assets/assets/branding/scmedia/CODEX.md @@ -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). diff --git a/public/assets/assets/branding/scmedia/README.md b/public/assets/assets/branding/scmedia/README.md new file mode 100644 index 0000000..94183d5 --- /dev/null +++ b/public/assets/assets/branding/scmedia/README.md @@ -0,0 +1,31 @@ +# scMedia Brand Kit (v1) + +This archive contains the generated **scMedia** logo + icon style reference + a UI preview, designed for a dark-first, minimalist, technical interface. + +## Included files + +- `logo/logo_on_dark.png` — hero logo composition (dark background) +- `logo/logo_horizontal_on_dark.png` — horizontal lockup (symbol + wordmark) +- `logo/app_icon_square_*.png` — square app icon exports (64/128/256/512) +- `icons/icon_grid_on_dark.png` — icon set grid reference (style + shapes) +- `ui/ui_preview_on_dark.png` — UI preview using icons instead of text labels +- `manifest.json` — palette + suggested paths + icon semantic mapping + +## Color palette + +Base: +- `#0b0f14` +- `#0f1620` +- `#111a25` + +Accent: +- `#3b82f6` (blue) +- `#38bdf8` (cyan) + +Status: +- Error: `#ef4444` +- Success: `#22c55e` + +## Notes + +- This kit is **PNG-based** (raster). If you want **SVG icons**, tell me the icon style preference (outline vs solid, stroke width), and I’ll generate a vector set. diff --git a/public/assets/assets/branding/scmedia/icons/icon_grid_on_dark.png b/public/assets/assets/branding/scmedia/icons/icon_grid_on_dark.png new file mode 100644 index 0000000..f97cee1 Binary files /dev/null and b/public/assets/assets/branding/scmedia/icons/icon_grid_on_dark.png differ diff --git a/public/assets/assets/branding/scmedia/logo/app_icon_square_128.png b/public/assets/assets/branding/scmedia/logo/app_icon_square_128.png new file mode 100644 index 0000000..e9b27ab Binary files /dev/null and b/public/assets/assets/branding/scmedia/logo/app_icon_square_128.png differ diff --git a/public/assets/assets/branding/scmedia/logo/app_icon_square_256.png b/public/assets/assets/branding/scmedia/logo/app_icon_square_256.png new file mode 100644 index 0000000..158424d Binary files /dev/null and b/public/assets/assets/branding/scmedia/logo/app_icon_square_256.png differ diff --git a/public/assets/assets/branding/scmedia/logo/app_icon_square_512.png b/public/assets/assets/branding/scmedia/logo/app_icon_square_512.png new file mode 100644 index 0000000..369ebe4 Binary files /dev/null and b/public/assets/assets/branding/scmedia/logo/app_icon_square_512.png differ diff --git a/public/assets/assets/branding/scmedia/logo/app_icon_square_64.png b/public/assets/assets/branding/scmedia/logo/app_icon_square_64.png new file mode 100644 index 0000000..40d12a4 Binary files /dev/null and b/public/assets/assets/branding/scmedia/logo/app_icon_square_64.png differ diff --git a/public/assets/assets/branding/scmedia/logo/logo_horizontal_on_dark.png b/public/assets/assets/branding/scmedia/logo/logo_horizontal_on_dark.png new file mode 100644 index 0000000..118860a Binary files /dev/null and b/public/assets/assets/branding/scmedia/logo/logo_horizontal_on_dark.png differ diff --git a/public/assets/assets/branding/scmedia/logo/logo_on_dark.png b/public/assets/assets/branding/scmedia/logo/logo_on_dark.png new file mode 100644 index 0000000..b528fac Binary files /dev/null and b/public/assets/assets/branding/scmedia/logo/logo_on_dark.png differ diff --git a/public/assets/assets/branding/scmedia/manifest.json b/public/assets/assets/branding/scmedia/manifest.json new file mode 100644 index 0000000..3de7bfd --- /dev/null +++ b/public/assets/assets/branding/scmedia/manifest.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/public/assets/assets/branding/scmedia/ui/ui_preview_on_dark.png b/public/assets/assets/branding/scmedia/ui/ui_preview_on_dark.png new file mode 100644 index 0000000..bbd36ff Binary files /dev/null and b/public/assets/assets/branding/scmedia/ui/ui_preview_on_dark.png differ diff --git a/public/assets/css/account.css b/public/assets/css/account.css new file mode 100644 index 0000000..8d1eb49 --- /dev/null +++ b/public/assets/css/account.css @@ -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; + } +} diff --git a/public/assets/css/admin.css b/public/assets/css/admin.css new file mode 100644 index 0000000..c2db461 --- /dev/null +++ b/public/assets/css/admin.css @@ -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; +} diff --git a/public/assets/css/auth.css b/public/assets/css/auth.css new file mode 100644 index 0000000..ae24033 --- /dev/null +++ b/public/assets/css/auth.css @@ -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; +} diff --git a/public/assets/css/common.css b/public/assets/css/common.css new file mode 100644 index 0000000..7781ac4 --- /dev/null +++ b/public/assets/css/common.css @@ -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; + } diff --git a/public/assets/css/settings.css b/public/assets/css/settings.css new file mode 100644 index 0000000..ec34af7 --- /dev/null +++ b/public/assets/css/settings.css @@ -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; } +} diff --git a/public/assets/css/style.css b/public/assets/css/style.css new file mode 100644 index 0000000..cec5f26 --- /dev/null +++ b/public/assets/css/style.css @@ -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; +} diff --git a/public/assets/i18n/de.json b/public/assets/i18n/de.json new file mode 100644 index 0000000..29748d4 --- /dev/null +++ b/public/assets/i18n/de.json @@ -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" +} diff --git a/public/assets/i18n/en.json b/public/assets/i18n/en.json new file mode 100644 index 0000000..46526df --- /dev/null +++ b/public/assets/i18n/en.json @@ -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" +} diff --git a/public/assets/i18n/ru.json b/public/assets/i18n/ru.json new file mode 100644 index 0000000..d9adb1f --- /dev/null +++ b/public/assets/i18n/ru.json @@ -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": "Профиль" +} diff --git a/public/assets/icons/scmedia.png b/public/assets/icons/scmedia.png new file mode 100644 index 0000000..40d12a4 Binary files /dev/null and b/public/assets/icons/scmedia.png differ diff --git a/public/assets/js/account.js b/public/assets/js/account.js new file mode 100644 index 0000000..842818a --- /dev/null +++ b/public/assets/js/account.js @@ -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(); +})(); diff --git a/public/assets/js/admin.js b/public/assets/js/admin.js new file mode 100644 index 0000000..038586c --- /dev/null +++ b/public/assets/js/admin.js @@ -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 ``; + }).join(''); + + tr.innerHTML = ` + ${u.email || ''} + + + + ${status} + ${fmtDate(u.last_login_at)} + +
+ + +
+ + `; + 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 = ` + ${fmtDate(a.created_at)} + ${a.actor_email || a.actor_user_id || ''} + ${a.action || ''} + ${a.target_type || ''} ${a.target_id || ''} + ${a.meta_json || ''} + `; + 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 = 'Admin only'; + 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(); diff --git a/public/assets/js/api.js b/public/assets/js/api.js new file mode 100644 index 0000000..84026c4 --- /dev/null +++ b/public/assets/js/api.js @@ -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, + }; +})(); diff --git a/public/assets/js/app-config.js b/public/assets/js/app-config.js new file mode 100644 index 0000000..4754044 --- /dev/null +++ b/public/assets/js/app-config.js @@ -0,0 +1 @@ +const APP_VERSION = "0.1.0"; diff --git a/public/assets/js/app.js b/public/assets/js/app.js new file mode 100644 index 0000000..f6cb7d8 --- /dev/null +++ b/public/assets/js/app.js @@ -0,0 +1,1743 @@ +// public/assets/app.js +/* English comments: minimal JS without external libs */ + +const state = { + mediaItems: [], + mediaTab: 'movies', + + i18n: {}, + lang: 'en', + + ui: { + table_mode: 'pagination', + table_page_size: 50, + }, + background: { + mode: 'light', + max_parallel_jobs: 1, + max_network_jobs: 1, + max_io_jobs: 1, + batch_sleep_ms: 500, + watchdog_minutes: 10, + paused: false, + }, + tables: {}, + + activeJobId: null, + jobPollTimer: null, + queuePollTimer: null, + watchdogTimer: null, + sourcesPollTimer: null, + sourcesCache: null, + sourceDetail: null, + sourceDetailItem: null, + sourceDetailData: null, + sourceDetailLastFetch: 0, + eventsSource: null, + tasks: [], + lastTasksFetch: 0, + queueActiveList: [], + queueRecentList: [], + debugToolsEnabled: false, + sse: { + connected: false, + lastEventAt: 0, + lastEventType: '', + reconnects: 0, + retryDelayMs: 5000, + leaseKey: 'scmedia_sse_lease', + tabId: '', + }, +}; + +const LS_ACTIVE_JOB = 'scmedia_active_job_id'; + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +function paginateItems(items, page, perPage) { + const total = items.length; + const size = Math.max(1, Number(perPage || 50)); + const p = Math.max(1, Number(page || 1)); + const start = (p - 1) * size; + const slice = items.slice(start, start + size); + return { items: slice, total, page: p, per_page: size }; +} + +function api(path, opts = {}) { + return window.Api.request(path, opts); +} + +/* --------------------------- + i18n +--------------------------- */ + +function t(key, fallback = null) { + const v = state.i18n?.[key]; + if (typeof v === 'string') return v; + if (fallback !== null) return fallback; + return key; +} + +function updateThemeLabel() { + const label = UI.qs('themeState'); + if (label) { + const current = document.documentElement.getAttribute('data-theme') || 'dark'; + label.textContent = (current === 'light') + ? t('theme.light', 'Light') + : t('theme.dark', 'Dark'); + } +} + +function initSourceDialog() { + const closeBtn = UI.qs('btnSourceClose'); + const dlg = UI.qs('dlg-source'); + const approveBtn = UI.qs('btnSourceApprove'); + if (closeBtn && dlg) { + closeBtn.addEventListener('click', () => dlg.close()); + } + if (approveBtn) { + approveBtn.addEventListener('click', () => { + if (!state.sourceDetail) return; + api('/api/sources/approve', { + method: 'POST', + body: JSON.stringify({ source: state.sourceDetail.source, id: state.sourceDetail.id }), + }).catch(() => {}); + }); + } + if (dlg) { + dlg.addEventListener('close', () => { + state.sourceDetail = null; + state.sourceDetailItem = null; + state.sourceDetailData = null; + }); + } +} + + +function setVersion() { + const el = document.querySelector('[data-version] .version'); + if (el && typeof APP_VERSION === 'string') { + el.textContent = `v${APP_VERSION}`; + } +} +/* --------------------------- + Filters + Grid +--------------------------- */ + +async function loadUiSettings() { + try { + const res = await api('/api/settings'); + const data = res.data || res; + state.debugToolsEnabled = !!(data.meta?.debug_tools_enabled); + const prefs = window.UserPrefs ? await window.UserPrefs.load().catch(() => null) : null; + const ui = prefs || {}; + state.ui.table_mode = ui.table_mode || 'pagination'; + state.ui.table_page_size = ui.table_page_size || 50; + const bg = data.background || {}; + state.background = { + mode: bg.mode || 'light', + max_parallel_jobs: Number(bg.max_parallel_jobs || 1), + max_network_jobs: Number(bg.max_network_jobs || 1), + max_io_jobs: Number(bg.max_io_jobs || 1), + batch_sleep_ms: Number(bg.batch_sleep_ms || 500), + watchdog_minutes: Number(bg.watchdog_minutes || 10), + paused: !!bg.paused, + }; + } catch (e) { + state.ui.table_mode = 'pagination'; + state.ui.table_page_size = 50; + } +} + +function setSseIndicator(stateClass) { + if (window.UI?.updateSseIndicator) { + window.UI.updateSseIndicator(stateClass.replace('sse-', '')); + return; + } + const items = document.querySelectorAll('[data-sse-indicator]'); + items.forEach((el) => { + el.classList.remove('sse-ok', 'sse-idle', 'sse-offline'); + el.classList.add(stateClass); + }); +} + +function pulseSseIndicator() { + if (window.UI?.blinkSseIndicator) { + window.UI.blinkSseIndicator(); + return; + } + const items = document.querySelectorAll('[data-sse-indicator]'); + items.forEach((el) => { + el.classList.add('sse-blink'); + setTimeout(() => el.classList.remove('sse-blink'), 250); + }); +} + +function updateSseStats() { + const wrap = UI.qs('queueSseStats'); + if (!wrap || !state.debugToolsEnabled) return; + wrap.classList.remove('is-hidden'); + const el = wrap.querySelector('.queue-stats'); + if (!el) return; + const sse = window.UI?.getSseStats?.() || null; + const lastAt = state.sse.lastEventAt ? new Date(state.sse.lastEventAt).toLocaleTimeString() : '—'; + const reconnects = sse ? sse.reconnects : Number(state.sse.reconnects || 0); + state.sse.reconnects = reconnects; + el.textContent = `last: ${lastAt} | type: ${state.sse.lastEventType || '—'} | reconnects: ${reconnects}`; +} + +function noteSseEvent(type) { + state.sse.lastEventAt = Date.now(); + state.sse.lastEventType = type; + localStorage.setItem('scmedia_sse_last', String(state.sse.lastEventAt)); + pulseSseIndicator(); + updateSseStats(); +} + +function setSseConnected(connected) { + state.sse.connected = connected; + localStorage.setItem('scmedia_sse_connected', connected ? '1' : '0'); + if (connected && state.sse.lastEventAt === 0) { + state.sse.lastEventAt = Date.now(); + localStorage.setItem('scmedia_sse_last', String(state.sse.lastEventAt)); + } + const now = Date.now(); + if (!connected) { + setSseIndicator('sse-offline'); + return; + } + const recent = state.sse.lastEventAt > 0 && (now - state.sse.lastEventAt) <= 15000; + setSseIndicator(recent ? 'sse-ok' : 'sse-idle'); +} + +function initTables() { + if (!window.TableController) return; + const mode = state.ui.table_mode || 'pagination'; + const pageSize = state.ui.table_page_size || 50; + const grid = UI.qs('grid'); + const sourcesGrid = UI.qs('sources-grid'); + const getPrefs = (table) => { + const id = table?.dataset?.tableId || table?.id || ''; + const prefs = window.UserPrefs?.getTable?.(id) || {}; + return { id, prefs }; + }; + + if (grid) { + const { id, prefs } = getPrefs(grid); + state.tables.media = new TableController({ + table: grid, + mode, + pageSize, + sort: prefs.sort || '', + dir: prefs.dir || 'asc', + onSortChange: (sort, dir) => window.UserPrefs?.setTable?.(id, { sort, dir }), + fetchPage: async ({ page, per_page, sort, dir, filters, params }) => { + const res = await api('/api/media/list', { + method: 'POST', + body: { + page, + per_page, + sort, + dir, + filters, + params: { + tab: params?.tab || state.mediaTab || 'movies', + }, + }, + }); + const data = res.data || {}; + return { + items: data.items || [], + total: data.total || 0, + page: data.page || page, + per_page: data.per_page || per_page, + }; + }, + renderRow: renderMediaRow, + }); + } + + if (sourcesGrid) { + const { id, prefs } = getPrefs(sourcesGrid); + state.tables.sources = new TableController({ + table: sourcesGrid, + mode, + pageSize, + sort: prefs.sort || '', + dir: prefs.dir || 'asc', + onSortChange: (sort, dir) => window.UserPrefs?.setTable?.(id, { sort, dir }), + fetchPage: async ({ page, per_page, sort, dir, filters }) => { + if (Array.isArray(state.sourcesCache)) { + const filtered = applySourceFilters(state.sourcesCache, filters); + const sorted = sortSources(filtered, sort, dir); + return paginateItems(sorted, page, per_page); + } + const res = await api('/api/sources/list', { + method: 'POST', + body: { + page, + per_page, + sort, + dir, + filters, + }, + }); + const data = res.data || {}; + return { + items: data.items || [], + total: data.total || 0, + page: data.page || page, + per_page: data.per_page || per_page, + }; + }, + renderRow: renderSourceRow, + }); + } +} + +function sortSources(items, sort, dir) { + if (!sort) return [...items]; + const desc = dir === 'desc'; + const list = [...items]; + const get = (it) => { + if (sort === 'name') return (it.name || '').toLowerCase(); + if (sort === 'size') return Number(it.size_bytes || 0); + if (sort === 'status') return (it.status || '').toLowerCase(); + if (sort === 'source') return (it.source || '').toLowerCase(); + if (sort === 'progress') return Number(it.percent_done || 0); + return (it[sort] ?? '').toString().toLowerCase(); + }; + list.sort((a, b) => { + const av = get(a); + const bv = get(b); + if (av === bv) return 0; + if (desc) return av < bv ? 1 : -1; + return av > bv ? 1 : -1; + }); + return list; +} + +function matchFilterValue(current, op, value) { + const opKey = (op || 'eq').toLowerCase(); + if (opKey === 'empty') { + if (Array.isArray(current)) return current.length === 0; + return current === null || current === ''; + } + if (opKey === 'like') { + const needle = String(value || '').toLowerCase(); + const hay = String(current ?? '').toLowerCase(); + return needle === '' ? true : hay.includes(needle); + } + if (opKey === 'between' && Array.isArray(value) && value.length >= 2) { + const left = value[0]; + const right = value[1]; + const cur = Number(current); + const l = Number(left); + const r = Number(right); + if (!Number.isNaN(cur) && !Number.isNaN(l) && !Number.isNaN(r)) { + return cur >= l && cur <= r; + } + return String(current ?? '') >= String(left ?? '') && String(current ?? '') <= String(right ?? ''); + } + if (opKey === 'gt' || opKey === 'lt') { + const cur = Number(current); + const val = Number(value); + if (!Number.isNaN(cur) && !Number.isNaN(val)) { + return opKey === 'gt' ? cur > val : cur < val; + } + return opKey === 'gt' + ? String(current ?? '') > String(value ?? '') + : String(current ?? '') < String(value ?? ''); + } + return String(current ?? '').toLowerCase() === String(value ?? '').toLowerCase(); +} + +function applySourceFilters(items, filters) { + if (!Array.isArray(filters) || filters.length === 0) return items; + const map = { + source: it => it.source, + type: it => it.content_type, + name: it => it.name, + size: it => it.size_bytes, + status: it => it.status, + progress: it => Number(it.percent_done || 0) * 100, + }; + return items.filter((it) => { + return filters.every((f) => { + if (!f || !f.key) return true; + const getter = map[f.key]; + const current = getter ? getter(it) : it[f.key]; + return matchFilterValue(current, f.op, f.value); + }); + }); +} + +function renderMediaRow(body, it) { + if (state.mediaTab === 'series') { + renderSeriesRow(body, it); + } else { + renderFileRow(body, it); + } +} + +function renderSourceRow(body, it) { + const tr = document.createElement('tr'); + tr.appendChild(cellText(it.source ?? '', '', 'source')); + tr.appendChild(cellText(sourceTypeLabel(it.content_type), '', 'type')); + tr.appendChild(cellText(it.name ?? '', '', 'name')); + tr.appendChild(cellText(it.size_bytes ? formatSize(it.size_bytes) : '', 'align-right', 'size')); + tr.appendChild(cellText(it.status ?? '', 'align-center', 'status')); + const percent = Number(it.percent_done ?? 0); + const pctLabel = Number.isFinite(percent) ? Math.round(percent * 100) : 0; + tr.appendChild(cellText(`${pctLabel}%`, 'align-right', 'progress')); + tr.addEventListener('click', () => openSourceDetail(it)); + body.appendChild(tr); +} + +function sourceTypeLabel(type) { + if (type === 'folder') return t('sources.type.folder', 'Folder'); + if (type === 'file') return t('sources.type.file', 'File'); + return ''; +} + +function cellText(txt, className = '', key = '') { + const td = document.createElement('td'); + td.textContent = txt ?? ''; + if (className) td.className = className; + if (key) td.dataset.key = key; + return td; +} + +function createYearCell(year) { + const td = document.createElement('td'); + td.className = 'col-year'; + td.textContent = (year !== null && year !== undefined && year !== '') ? String(year) : ''; + return td; +} + +function createTitleCell(display, original, fallback) { + const td = document.createElement('td'); + td.className = 'col-title'; + const span = document.createElement('span'); + const displayText = display || fallback || ''; + const originalText = original || displayText; + span.textContent = displayText; + span.dataset.display = displayText; + span.dataset.original = originalText; + attachTitleToggle(span); + td.appendChild(span); + return td; +} + +function attachTitleToggle(el) { + const showOriginal = () => { + if (el.dataset.original) el.textContent = el.dataset.original; + }; + const showDisplay = () => { + if (el.dataset.display) el.textContent = el.dataset.display; + }; + el.addEventListener('mousedown', (e) => { + if (e.button !== 0) return; + showOriginal(); + }); + el.addEventListener('mouseup', showDisplay); + el.addEventListener('mouseleave', showDisplay); + el.addEventListener('touchstart', showOriginal, { passive: true }); + el.addEventListener('touchend', showDisplay); +} + +function typeLabel(v) { + if (v === 'auto') return t('types.auto', 'Auto'); + if (v === 'movie') return t('types.movie', 'Movie'); + if (v === 'series') return t('types.series', 'Series'); + return v; +} + +function cellType(it) { + const td = document.createElement('td'); + td.textContent = typeLabel(it.kind ?? 'auto'); + return td; +} + +function issueBadges(issues) { + if (!Array.isArray(issues) || issues.length === 0) return ''; + return issues.map(i => { + if (i === 'rename') return '📝'; + if (i === 'delete') return '🗑'; + if (i === 'unknown_type') return '❓'; + return '⚠'; + }).join(' '); +} + +function renderFileRow(body, it) { + const tr = document.createElement('tr'); + tr.dataset.path = it.abs_path || ''; + + const tdExpand = document.createElement('td'); + const btn = document.createElement('button'); + btn.className = 'expand-btn'; + btn.textContent = '+'; + btn.addEventListener('click', () => toggleFileRow(tr, it, btn)); + tdExpand.appendChild(btn); + tr.appendChild(tdExpand); + + tr.appendChild(cellType(it)); + const displayTitle = it.title_display || it.name || ''; + const originalTitle = it.title_original || displayTitle; + tr.appendChild(createTitleCell(displayTitle, originalTitle, it.name ?? '')); + tr.appendChild(createYearCell(it.year ?? '')); + tr.appendChild(cellText(it.needs_attention ? t('status.needs', 'Needs') : t('status.ok', 'OK'))); + tr.appendChild(cellText(issueBadges(it.issues))); + + body.appendChild(tr); +} + +function renderSeriesRow(body, it) { + const tr = document.createElement('tr'); + tr.dataset.series = it.series_key || ''; + + const tdExpand = document.createElement('td'); + const btn = document.createElement('button'); + btn.className = 'expand-btn'; + btn.textContent = '+'; + btn.addEventListener('click', () => toggleSeriesRow(tr, it, btn)); + tdExpand.appendChild(btn); + tr.appendChild(tdExpand); + + tr.appendChild(cellText(t('types.series', 'Series'))); + const displayTitle = it.title_display || it.series_key || ''; + const originalTitle = it.title_original || displayTitle; + tr.appendChild(createTitleCell(displayTitle, originalTitle, it.series_key ?? '')); + tr.appendChild(createYearCell(it.year ?? '')); + const status = (it.needs_attention > 0) ? t('status.needs', 'Needs') : t('status.ok', 'OK'); + tr.appendChild(cellText(status)); + tr.appendChild(cellText(issueBadges(it.issues))); + + body.appendChild(tr); +} + +async function loadMediaList() { + const mediaTable = state.tables.media; + const sourcesTable = state.tables.sources; + if (state.mediaTab === 'sources') { + if (sourcesTable) { + sourcesTable.setParams({}); + await sourcesTable.load(1, false); + } + return; + } + if (mediaTable) { + mediaTable.setParams({ tab: state.mediaTab }); + await mediaTable.load(1, false); + } +} + +async function loadSources() { + const sourcesTable = state.tables.sources; + if (sourcesTable) { + await sourcesTable.load(1, false); + } +} + +async function toggleFileRow(tr, it, btn) { + const next = tr.nextElementSibling; + if (next && next.classList.contains('detail-row')) { + next.remove(); + if (btn) btn.textContent = '+'; + return; + } + if (btn) btn.textContent = '-'; + + const detail = document.createElement('tr'); + detail.className = 'detail-row'; + const td = document.createElement('td'); + td.colSpan = 6; + td.textContent = t('common.loading', 'Loading…'); + detail.appendChild(td); + tr.after(detail); + + const res = await api('/api/media/file?path=' + encodeURIComponent(it.abs_path || '')); + if (!res.ok || !res.data) { + td.textContent = t('common.error', 'Error'); + return; + } + td.innerHTML = renderDetail(res.data); + bindMetadataHandlers(td); +} + +async function toggleSeriesRow(tr, it, btn) { + const next = tr.nextElementSibling; + if (next && next.classList.contains('detail-row')) { + next.remove(); + if (btn) btn.textContent = '+'; + return; + } + if (btn) btn.textContent = '-'; + + const detail = document.createElement('tr'); + detail.className = 'detail-row'; + const td = document.createElement('td'); + td.colSpan = 6; + td.textContent = t('common.loading', 'Loading…'); + detail.appendChild(td); + tr.after(detail); + + const res = await api('/api/media/series?key=' + encodeURIComponent(it.series_key || '')); + if (!res.ok || !res.data) { + td.textContent = t('common.error', 'Error'); + return; + } + td.innerHTML = renderSeriesDetail(res.data || {}); + bindSeriesDetailHandlers(td); + bindMetadataHandlers(td); +} + +function renderDetail(data) { + const file = data.file || {}; + const tracks = data.tracks || []; + const meta = data.meta || {}; + + let html = ''; + html += `
`; + html += `
${escapeHtml(file.abs_path || '')}
`; + html += `
` + + `${t('media.container','Container')}: ${escapeHtml(file.container || t('common.na','n/a'))} ` + + `${t('media.size','Size')}: ${formatSize(file.size_bytes || 0)} ` + + `${t('media.duration','Duration')}: ${formatDuration(file.duration_ms || 0)}` + + `
`; + html += `
`; + html += renderMetaBox(meta, file.name || '', file.kind || 'movie'); + + html += `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + ``; + + for (const tr of tracks) { + const flags = []; + if (tr.default) flags.push('default'); + if (tr.forced) flags.push('forced'); + html += `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + ``; + } + + html += `
${t('media.track.type','Type')}${t('media.track.lang','Lang')}${t('media.track.name','Name')}${t('media.track.codec','Codec')}${t('media.track.channels','Channels')}${t('media.track.flags','Flags')}${t('media.track.audio_type','Audio type')}
${escapeHtml(tr.type || '')}${escapeHtml(tr.lang || '')}${escapeHtml(tr.name_norm || tr.name || '')}${escapeHtml(tr.codec || '')}${escapeHtml(tr.channels || '')}${escapeHtml(flags.join(','))}${escapeHtml(tr.audio_type || '')}
`; + return html; +} + +function renderSeriesDetail(payload) { + const files = Array.isArray(payload.files) ? payload.files : []; + const meta = payload.meta || {}; + let html = renderMetaBox(meta, meta.title_display || '', 'series'); + html += `` + + `` + + `` + + `` + + `` + + `` + + ``; + for (const f of files) { + html += `` + + `` + + `` + + `` + + `` + + `` + + ``; + } + html += `
${t('grid.name','Name')}${t('grid.path','Path')}${t('grid.status','Status')}${t('grid.issues','Issues')}
${escapeHtml(f.name || '')}${escapeHtml(f.rel_path || f.abs_path || '')}${escapeHtml(f.needs_attention ? t('status.needs','Needs') : t('status.ok','OK'))}${escapeHtml(issueBadges(f.issues))}
`; + return html; +} + +function renderMetaBox(meta, fallbackTitle, type) { + const subjectKind = meta.subject_kind || type || 'movie'; + const subjectKey = meta.subject_key || ''; + const titleDisplay = meta.title_display || ''; + const titleOriginal = meta.title_original || ''; + const year = (meta.year !== null && meta.year !== undefined) ? String(meta.year) : ''; + const provider = meta.provider || ''; + const providerId = meta.provider_id || ''; + const source = meta.source || 'auto'; + const manualTitle = meta.manual_title || ''; + const manualYear = (meta.manual_year !== null && meta.manual_year !== undefined) ? String(meta.manual_year) : ''; + + let html = ''; + html += `
`; + html += `
`; + html += `
${t('meta.title','Title')}${escapeHtml(titleDisplay || fallbackTitle)}
`; + html += `
${t('meta.original','Original')}${escapeHtml(titleOriginal)}
`; + html += `
${t('meta.year','Year')}${escapeHtml(year)}
`; + html += `
${t('meta.provider','Provider')}${escapeHtml(providerId ? (provider + ' / ' + providerId) : provider)}
`; + html += `
${t('meta.source','Source')}${escapeHtml(source)}
`; + html += `
`; + + html += `
`; + html += ``; + html += `
`; + + html += `
` + + `` + + `` + + `` + + `` + + `
`; + html += `
`; + html += `
`; + return html; +} + +function bindSeriesDetailHandlers(root) { + root.querySelectorAll('button[data-action="file"]').forEach(btn => { + btn.addEventListener('click', async () => { + const tr = btn.closest('tr'); + if (!tr) return; + const path = tr.dataset.path || ''; + const next = tr.nextElementSibling; + if (next && next.classList.contains('detail-row')) { + next.remove(); + btn.textContent = '+'; + return; + } + btn.textContent = '-'; + const detail = document.createElement('tr'); + detail.className = 'detail-row'; + const td = document.createElement('td'); + td.colSpan = 5; + td.textContent = t('common.loading', 'Loading…'); + detail.appendChild(td); + tr.after(detail); + const res = await api('/api/media/file?path=' + encodeURIComponent(path)); + if (!res.ok || !res.data) { + td.textContent = t('common.error', 'Error'); + return; + } + td.innerHTML = renderDetail(res.data); + bindMetadataHandlers(td); + }); + }); +} + +function bindMetadataHandlers(root) { + root.querySelectorAll('.meta-box').forEach(box => { + const subjectKind = box.dataset.subjectKind || 'movie'; + const subjectKey = box.dataset.subjectKey || ''; + const type = box.dataset.type || 'movie'; + const resultsEl = box.querySelector('[data-role="results"]'); + const queryInput = box.querySelector('[data-role="query"]'); + const manualTitle = box.querySelector('[data-role="manual-title"]'); + const manualYear = box.querySelector('[data-role="manual-year"]'); + + const setDisplay = (data) => { + if (!data) return; + const title = data.title_display || ''; + const original = data.title_original || ''; + const year = (data.year !== null && data.year !== undefined) ? String(data.year) : ''; + const provider = data.meta?.provider || data.provider || ''; + const providerId = data.meta?.provider_id || data.provider_id || ''; + const source = data.meta?.source || data.source || 'auto'; + + const titleEl = box.querySelector('[data-role="title"]'); + const origEl = box.querySelector('[data-role="original"]'); + const yearEl = box.querySelector('[data-role="year"]'); + const provEl = box.querySelector('[data-role="provider"]'); + const srcEl = box.querySelector('[data-role="source"]'); + + if (titleEl) titleEl.textContent = title; + if (origEl) origEl.textContent = original; + if (yearEl) yearEl.textContent = year; + if (provEl) provEl.textContent = providerId ? `${provider} / ${providerId}` : provider; + if (srcEl) srcEl.textContent = source; + + if (manualTitle) manualTitle.value = data.meta?.manual_title || ''; + if (manualYear) manualYear.value = data.meta?.manual_year ?? ''; + + updateRowMeta(subjectKind, subjectKey, data); + }; + + const onSearch = async () => { + const query = queryInput?.value?.trim() || ''; + if (!query || subjectKey === '') return; + if (resultsEl) resultsEl.textContent = t('common.loading', 'Loading…'); + const res = await api('/api/metadata/search', { + method: 'POST', + body: JSON.stringify({ query, type }), + }); + if (!res.ok) { + if (resultsEl) resultsEl.textContent = t('common.error', 'Error'); + return; + } + renderMetaResults(resultsEl, res.data || [], async (selection) => { + const save = await api('/api/metadata/select', { + method: 'POST', + body: JSON.stringify({ + subject_kind: subjectKind, + subject_key: subjectKey, + selection, + }), + }); + if (save.ok) { + setDisplay(save.data || {}); + } + }); + }; + + const onSaveManual = async () => { + const title = manualTitle?.value?.trim() || ''; + const year = manualYear?.value?.trim() || ''; + if (subjectKey === '') return; + const save = await api('/api/metadata/manual', { + method: 'POST', + body: JSON.stringify({ + subject_kind: subjectKind, + subject_key: subjectKey, + title, + year: year === '' ? null : Number(year), + }), + }); + if (save.ok) { + setDisplay(save.data || {}); + } + }; + + const onClearManual = async () => { + if (subjectKey === '') return; + const save = await api('/api/metadata/manual/clear', { + method: 'POST', + body: JSON.stringify({ + subject_kind: subjectKind, + subject_key: subjectKey, + }), + }); + if (save.ok) { + setDisplay(save.data || {}); + } + }; + + const btnSearch = box.querySelector('[data-action="meta-search"]'); + const btnSave = box.querySelector('[data-action="meta-save"]'); + const btnClear = box.querySelector('[data-action="meta-clear"]'); + if (btnSearch) btnSearch.addEventListener('click', onSearch); + if (btnSave) btnSave.addEventListener('click', onSaveManual); + if (btnClear) btnClear.addEventListener('click', onClearManual); + }); +} + +function renderMetaResults(root, results, onSelect) { + if (!root) return; + root.innerHTML = ''; + if (!Array.isArray(results) || results.length === 0) { + root.textContent = t('meta.no_results', 'No results'); + return; + } + results.forEach(r => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'meta-result'; + + const img = document.createElement('img'); + img.alt = ''; + img.loading = 'lazy'; + img.src = r.poster || ''; + img.className = 'meta-poster'; + if (!r.poster) img.style.display = 'none'; + + const body = document.createElement('div'); + body.className = 'meta-result-body'; + + const title = document.createElement('div'); + title.className = 'meta-result-title'; + const titleMap = r.title_map || {}; + const bestTitle = titleMap[state.lang] || r.original_title || titleMap[Object.keys(titleMap)[0]] || ''; + title.textContent = bestTitle; + + const meta = document.createElement('div'); + meta.className = 'meta-result-meta'; + const year = (r.year !== null && r.year !== undefined) ? String(r.year) : ''; + const providerLabel = r.provider_name || r.provider || ''; + meta.textContent = `${providerLabel}${r.provider_id ? ' / ' + r.provider_id : ''}${year ? ' • ' + year : ''}`; + + body.appendChild(title); + body.appendChild(meta); + + btn.appendChild(img); + btn.appendChild(body); + btn.addEventListener('click', () => onSelect(r)); + root.appendChild(btn); + }); +} + +function updateRowMeta(kind, key, data) { + const rows = Array.from(document.querySelectorAll('#grid tbody tr')); + const row = rows.find(r => (kind === 'series') + ? r.dataset.series === key + : r.dataset.path === key); + if (!row) return; + const titleCell = row.querySelector('.col-title span'); + const yearCell = row.querySelector('.col-year'); + const title = data.title_display || ''; + const original = data.title_original || title; + const year = (data.year !== null && data.year !== undefined) ? String(data.year) : ''; + if (titleCell) { + titleCell.textContent = title; + titleCell.dataset.display = title; + titleCell.dataset.original = original; + } + if (yearCell) yearCell.textContent = year; +} + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, c => ( + c === '&' ? '&' : + c === '<' ? '<' : + c === '>' ? '>' : + c === '"' ? '"' : ''' + )); +} + +function formatSize(bytes) { + const b = Number(bytes || 0); + if (!b) return '0 B'; + const units = ['B','KB','MB','GB','TB']; + let i = 0; + let v = b; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${v.toFixed(1)} ${units[i]}`; +} + +function formatDuration(ms) { + const m = Number(ms || 0); + if (!m) return t('common.na','n/a'); + const total = Math.floor(m / 1000); + const h = Math.floor(total / 3600); + const min = Math.floor((total % 3600) / 60); + const s = total % 60; + return `${h}:${String(min).padStart(2,'0')}:${String(s).padStart(2,'0')}`; +} + +function formatSpeed(bytesPerSec) { + return `${UI.formatBytes(bytesPerSec)}/s`; +} + +function formatDate(ts) { + const n = Number(ts || 0); + if (!Number.isFinite(n) || n <= 0) return t('common.na', 'n/a'); + return new Date(n * 1000).toLocaleString(); +} + +function formatSeconds(sec) { + const n = Number(sec || 0); + if (!Number.isFinite(n) || n <= 0) return t('common.na', 'n/a'); + const h = Math.floor(n / 3600); + const m = Math.floor((n % 3600) / 60); + const s = n % 60; + return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`; +} + +function openSourceDetail(item) { + const dlg = UI.qs('dlg-source'); + if (!dlg || !item?.id || !item?.source) return; + state.sourceDetail = { id: item.id, source: item.source }; + state.sourceDetailItem = item; + const title = UI.qs('sourceTitle'); + const meta = UI.qs('sourceMeta'); + const fieldsWrap = UI.qs('sourceFields'); + const raw = UI.qs('sourceRaw'); + if (title) title.textContent = t('sources.detail.title', 'Source detail'); + if (meta) meta.textContent = t('common.loading', 'Loading…'); + if (fieldsWrap) fieldsWrap.innerHTML = ''; + if (raw) raw.textContent = ''; + + fetchSourceDetail(item.source, item.id); + if (typeof dlg.showModal === 'function') dlg.showModal(); +} + +function refreshOpenSourceDetail() { + if (!state.sourceDetail) return; + const now = Date.now(); + if (now - state.sourceDetailLastFetch < 1500) return; + fetchSourceDetail(state.sourceDetail.source, state.sourceDetail.id); +} + +function fetchSourceDetail(source, id) { + state.sourceDetailLastFetch = Date.now(); + const title = UI.qs('sourceTitle'); + const meta = UI.qs('sourceMeta'); + const fieldsWrap = UI.qs('sourceFields'); + const raw = UI.qs('sourceRaw'); + const fallback = state.sourceDetailItem || {}; + api('/api/sources/detail?source=' + encodeURIComponent(source) + '&id=' + encodeURIComponent(id)) + .then(res => { + if (!res || res.ok !== true) { + const msg = res?.error?.message || res?.error || 'Request failed'; + throw new Error(msg); + } + const data = res.data || {}; + state.sourceDetailData = data; + const core = data.core || {}; + if (title) title.textContent = `${core.name || fallback.name || ''}`; + if (meta) { + const pct = Math.round((core.percent_done || 0) * 100); + meta.innerHTML = ` +
${t('sources.source','Source')}: ${core.source || fallback.source || ''}
+
${t('sources.status','Status')}: ${core.status || fallback.status || ''}
+
${t('sources.size','Size')}: ${UI.formatBytes(core.size_bytes || 0)}
+
${t('sources.progress','Progress')}: ${pct}%
+ `; + } + if (fieldsWrap) { + const fields = Array.isArray(data.fields) ? data.fields : []; + fieldsWrap.innerHTML = ''; + fields.forEach(f => { + const val = f.value; + let display = ''; + if (f.type === 'bytes') display = UI.formatBytes(val); + else if (f.type === 'speed') display = formatSpeed(val); + else if (f.type === 'date') display = formatDate(val); + else if (f.type === 'seconds') display = formatSeconds(val); + else if (f.type === 'list') display = Array.isArray(val) ? val.join(', ') : ''; + else display = (val ?? '').toString(); + const card = document.createElement('div'); + card.className = 'source-field'; + card.innerHTML = `
${f.label || f.key || ''}
${display}
`; + fieldsWrap.appendChild(card); + }); + const files = Array.isArray(data.files) ? data.files : []; + if (files.length > 0) { + const tree = buildFileTree(files); + const block = document.createElement('div'); + block.className = 'source-files'; + block.innerHTML = `
${t('sources.files', 'Files')}
`; + block.appendChild(renderFileTree(tree)); + fieldsWrap.appendChild(block); + } + const preview = data.preview || null; + if (preview) { + const block = document.createElement('div'); + block.className = 'source-preview'; + block.innerHTML = ` +
${t('sources.preview.title','Preview')}
+
+
+
${t('sources.preview.current','Current')}
+
${t('sources.preview.name','Name')}: ${preview.current?.name || ''}
+
${t('sources.preview.kind','Type')}: ${preview.current?.kind || ''}
+
${t('sources.preview.structure','Structure')}: ${preview.current?.structure || ''}
+
+
+
${t('sources.preview.planned','Planned')}
+
${t('sources.preview.name','Name')}: ${preview.planned?.name || ''}
+
${t('sources.preview.kind','Type')}: ${preview.planned?.kind || ''}
+
${t('sources.preview.structure','Structure')}: ${preview.planned?.structure || ''}
+ ${preview.planned?.note ? `
${preview.planned?.note}
` : ''} +
+
+ `; + fieldsWrap.appendChild(block); + } + } + if (raw) { + raw.textContent = JSON.stringify(data.raw || {}, null, 2); + } + }) + .catch(e => { + if (meta) meta.textContent = `${t('common.error','Error')}: ${e.message}`; + }); +} + + +function buildFileTree(files) { + const root = { name: '', children: new Map(), files: [] }; + files.forEach(f => { + const path = (f.path || '').split('/').filter(Boolean); + let node = root; + for (let i = 0; i < path.length; i++) { + const part = path[i]; + if (i === path.length - 1) { + node.files.push({ name: part, meta: f }); + } else { + if (!node.children.has(part)) { + node.children.set(part, { name: part, children: new Map(), files: [] }); + } + node = node.children.get(part); + } + } + }); + return root; +} + +function renderFileTree(node) { + const ul = document.createElement('ul'); + ul.className = 'file-tree'; + node.children.forEach(child => { + const li = document.createElement('li'); + li.innerHTML = `${child.name}`; + li.appendChild(renderFileTree(child)); + ul.appendChild(li); + }); + node.files.forEach(f => { + const li = document.createElement('li'); + const pct = Number.isFinite(f.meta?.percent_done) ? f.meta.percent_done : 0; + const pctText = pct ? ` (${pct}%)` : ''; + li.innerHTML = `${f.name}${pctText}`; + ul.appendChild(li); + }); + return ul; +} +/* --------------------------- + Jobs: global indicator + persistence +--------------------------- */ + +function setActiveJob(jobId) { + state.activeJobId = jobId ? String(jobId) : null; + if (state.activeJobId) { + localStorage.setItem(LS_ACTIVE_JOB, state.activeJobId); + } else { + localStorage.removeItem(LS_ACTIVE_JOB); + } + updateJobIndicatorVisible(!!state.activeJobId); +} + +function updateJobIndicatorVisible(show) { + const el = UI.qs('job-indicator'); + if (!el) return; + if (show) el.classList.remove('hidden'); + else el.classList.add('hidden'); +} + +function updateJobIndicator(job) { + const p = UI.qs('job-indicator-progress'); + if (!p) return; + + const total = Number(job.progress_total || 0); + const cur = Number(job.progress || 0); + const pct = total > 0 ? Math.floor((cur / total) * 100) : 0; + + p.max = 100; + p.value = pct; +} + +function updateQueueSummary(data) { + const summaryEl = UI.qs('queueSummary'); + if (!summaryEl) return; + const activeEl = summaryEl.querySelector('[data-queue-active]'); + const errorsEl = summaryEl.querySelector('[data-queue-errors]'); + const dividerEl = summaryEl.querySelector('[data-queue-divider]'); + if (activeEl || errorsEl) { + const activeLabel = activeEl?.dataset.queueActiveLabel || t('queue.active', 'Active'); + const errorsLabel = errorsEl?.dataset.queueErrorsLabel || t('queue.errors', 'Errors'); + const activeCount = Number(data.running || 0); + const errorsCount = Number(data.errors || 0); + if (activeEl) activeEl.textContent = `${activeLabel}: ${activeCount}`; + if (errorsEl) { + errorsEl.textContent = `${errorsLabel}: ${errorsCount}`; + errorsEl.classList.toggle('is-hidden', errorsCount <= 0); + } + if (dividerEl) dividerEl.classList.toggle('is-hidden', errorsCount <= 0); + return; + } + const textEl = summaryEl.querySelector('[data-queue-text]'); + const template = t('queue.summary', 'Active: {active} | Errors: {errors}'); + const text = template + .replace('{active}', String(data.running || 0)) + .replace('{errors}', String(data.errors || 0)); + if (textEl) { + textEl.textContent = text; + } else { + summaryEl.textContent = text; + } +} + +function isQueueMenuOpen() { + const menu = UI.qs('queueMenu'); + return !!menu && !menu.classList.contains('is-hidden'); +} + +function toggleQueueMenu() { + const menu = UI.qs('queueMenu'); + if (!menu) return; + menu.classList.toggle('is-hidden'); + if (!menu.classList.contains('is-hidden')) { + loadQueueStatus(); + loadQueueRecent(); + } +} + +async function cancelJobById(id) { + if (!id) return; + if (!confirm(t('job.cancel_confirm', 'Cancel job?'))) return; + try { + await api('/api/jobs/cancel', { + method: 'POST', + body: JSON.stringify({ id }), + }); + } catch (e) { + alert(`${t('common.error','Error')}: ${e.message}`); + } +} + +async function loadQueueRecent() { + try { + const res = await api('/api/jobs/recent'); + if (!res.ok || !res.data) return; + state.queueRecentList = res.data.items || []; + if (isQueueMenuOpen()) renderQueueMenu(); + } catch (e) { + // ignore + } +} + +function renderQueueMenu() { + const activeWrap = UI.qs('queueMenuActive'); + const finishedWrap = UI.qs('queueMenuFinished'); + if (!activeWrap || !finishedWrap) return; + + activeWrap.innerHTML = ''; + finishedWrap.innerHTML = ''; + + if (state.queueActiveList.length === 0) { + const empty = document.createElement('div'); + empty.className = 'queue-item-status'; + empty.textContent = t('queue.none_active', 'No active tasks'); + activeWrap.appendChild(empty); + } else { + state.queueActiveList.forEach(j => { + const row = document.createElement('div'); + row.className = 'queue-item'; + const title = document.createElement('div'); + const pct = j.progress_total > 0 ? Math.floor((j.progress / j.progress_total) * 100) : 0; + title.textContent = `${j.title} (${pct}%)`; + const actions = document.createElement('div'); + actions.className = 'queue-item-actions'; + const btn = document.createElement('button'); + btn.className = 'btn'; + btn.textContent = t('queue.cancel', 'Cancel'); + btn.addEventListener('click', () => cancelJobById(j.id)); + actions.appendChild(btn); + row.appendChild(title); + row.appendChild(actions); + activeWrap.appendChild(row); + }); + } + + if (state.queueRecentList.length === 0) { + const empty = document.createElement('div'); + empty.className = 'queue-item-status'; + empty.textContent = t('queue.none_finished', 'No finished tasks'); + finishedWrap.appendChild(empty); + } else { + state.queueRecentList.forEach(j => { + const row = document.createElement('div'); + row.className = 'queue-item'; + const title = document.createElement('div'); + title.textContent = j.title || ''; + const status = document.createElement('div'); + status.className = 'queue-item-status'; + status.textContent = j.status || ''; + row.appendChild(title); + row.appendChild(status); + finishedWrap.appendChild(row); + }); + } + updateSseStats(); +} + +async function loadQueueStatus() { + try { + const res = await api('/api/jobs/status'); + if (!res.ok || !res.data) return; + const data = res.data; + state.background.paused = !!data.paused; + state.queueActiveList = Array.isArray(data.active_list) ? data.active_list : []; + updateQueueSummary(data); + if (data.active) updateJobIndicator(data.active); + if (isQueueMenuOpen()) renderQueueMenu(); + } catch (e) { + // ignore + } +} + +function initQueuePanel() { + const summary = UI.qs('queueSummary'); + if (summary) { + summary.addEventListener('click', toggleQueueMenu); + } + document.addEventListener('click', (e) => { + const menu = UI.qs('queueMenu'); + if (!menu) return; + if (summary && (summary.contains(e.target) || menu.contains(e.target))) return; + menu.classList.add('is-hidden'); + }); + + updateQueueSummary({ running: 0, errors: 0 }); + loadQueueStatus(); +} + +function initWatchdog() { + const minutes = Number(state.background.watchdog_minutes || 0); + if (minutes <= 0) return; + const intervalMs = Math.max(1, Math.floor(minutes / 2)) * 60 * 1000; + if (state.watchdogTimer) clearInterval(state.watchdogTimer); + state.watchdogTimer = setInterval(() => { + api('/api/jobs/watchdog', { + method: 'POST', + body: JSON.stringify({ minutes }), + }).catch(() => {}); + }, intervalMs); +} + +function initSourcesPolling() { + if (state.sourcesPollTimer) clearInterval(state.sourcesPollTimer); +} + +function claimSseLease() { + if (!state.sse.tabId) { + const existing = sessionStorage.getItem('scmedia_sse_tab_id'); + state.sse.tabId = existing || Math.random().toString(36).slice(2); + sessionStorage.setItem('scmedia_sse_tab_id', state.sse.tabId); + } + const now = Date.now(); + const ttlMs = 15000; + const raw = localStorage.getItem(state.sse.leaseKey) || ''; + let lease = null; + try { + lease = raw ? JSON.parse(raw) : null; + } catch (e) { + lease = null; + } + if (lease && lease.ts && (now - lease.ts) < ttlMs && lease.id !== state.sse.tabId) { + return false; + } + localStorage.setItem(state.sse.leaseKey, JSON.stringify({ ts: now, id: state.sse.tabId })); + return true; +} + +function refreshSseLease() { + localStorage.setItem(state.sse.leaseKey, JSON.stringify({ ts: Date.now(), id: state.sse.tabId })); +} + +function releaseSseLease() { + const raw = localStorage.getItem(state.sse.leaseKey) || ''; + try { + const lease = raw ? JSON.parse(raw) : null; + if (lease && lease.id && lease.id !== state.sse.tabId) { + return; + } + } catch (e) { + // ignore + } + localStorage.removeItem(state.sse.leaseKey); +} + +function initEventsPipe() { + if (!window.EventSource) return false; + if (!claimSseLease()) { + return false; + } + try { + if (state.eventsSource) { + state.eventsSource.close(); + state.eventsSource = null; + } + const open = async () => { + if (!window.Auth?.getAccessToken?.() && window.Auth?.refreshTokens) { + await window.Auth.refreshTokens(); + } + const res = await window.Http.apiJson('/api/auth/sse-key', { method: 'POST' }); + const key = res?.data?.key || ''; + const ttl = Number(res?.data?.expires_in || 60); + if (!res?.ok || !key) { + initSourcesPolling(); + return; + } + document.cookie = `sse_key=${encodeURIComponent(key)}; path=/; max-age=${Math.max(10, ttl)}`; + await new Promise((r) => setTimeout(r, 50)); + const es = new EventSource('/api/events'); + state.eventsSource = es; + es.addEventListener('open', () => { + setSseConnected(true); + state.sse.retryDelayMs = 5000; + refreshSseLease(); + if (state.queuePollTimer) { + clearInterval(state.queuePollTimer); + state.queuePollTimer = null; + } + if (state.sourcesPollTimer) { + clearInterval(state.sourcesPollTimer); + state.sourcesPollTimer = null; + } + loadQueueStatus(); + }); + es.addEventListener('jobs', (e) => { + try { + const data = JSON.parse(e.data); + noteSseEvent('jobs'); + refreshSseLease(); + state.queueActiveList = Array.isArray(data.active_list) ? data.active_list : []; + updateQueueSummary(data); + if (data.active) updateJobIndicator(data.active); + if (isQueueMenuOpen()) renderQueueMenu(); + } catch (err) { + // ignore + } + }); + es.addEventListener('sources', (e) => { + try { + const payload = JSON.parse(e.data); + noteSseEvent('sources'); + refreshSseLease(); + const items = Array.isArray(payload.items) ? payload.items : []; + const removed = Array.isArray(payload.removed) ? payload.removed : []; + const isSnapshot = !!payload.snapshot; + + if (!Array.isArray(state.sourcesCache) || isSnapshot) { + state.sourcesCache = items; + } else { + const map = new Map(); + state.sourcesCache.forEach(it => { + const key = `${it.source || ''}:${it.id || ''}`; + if (key !== ':') map.set(key, it); + }); + items.forEach(it => { + const key = `${it.source || ''}:${it.id || ''}`; + if (key !== ':') map.set(key, it); + }); + removed.forEach(key => map.delete(key)); + state.sourcesCache = Array.from(map.values()); + } + + if (state.mediaTab === 'sources') { + const sourcesTable = state.tables.sources; + if (sourcesTable) sourcesTable.load(sourcesTable.page || 1, false); + } + refreshOpenSourceDetail(); + } catch (err) { + // ignore + } + }); + es.addEventListener('tick', () => { + noteSseEvent('tick'); + refreshSseLease(); + }); + es.addEventListener('error', () => { + if (state.eventsSource) state.eventsSource.close(); + state.eventsSource = null; + state.sse.reconnects += 1; + setSseConnected(false); + const delay = state.sse.retryDelayMs; + state.sse.retryDelayMs = Math.min(state.sse.retryDelayMs * 2, 60000); + releaseSseLease(); + setTimeout(() => initEventsPipe(), delay); + }); + }; + open().catch(() => { + initSourcesPolling(); + releaseSseLease(); + }); + return true; + } catch (e) { + state.eventsSource = null; + releaseSseLease(); + return false; + } +} + +async function restoreJobFromStorage() { + const id = localStorage.getItem(LS_ACTIVE_JOB); + if (!id) return; + + setActiveJob(id); + + // start polling silently + if (state.jobPollTimer) clearInterval(state.jobPollTimer); + state.jobPollTimer = setInterval(async () => { + try { + const res = await api('/api/jobs/get?id=' + encodeURIComponent(id)); + if (!res.ok || !res.job) return; + + updateJobIndicator(res.job); + + if (res.job.status === 'done' || res.job.status === 'error' || res.job.status === 'canceled') { + clearInterval(state.jobPollTimer); + state.jobPollTimer = null; + setActiveJob(null); + } + } catch (e) { + // ignore + } + }, 1200); +} + +/* --------------------------- + Scan +--------------------------- */ + +function openJobDialog() { + const dlg = UI.qs('dlg-job'); + if (dlg && typeof dlg.showModal === 'function') dlg.showModal(); +} + +async function cancelActiveJob() { + if (!state.activeJobId) return; + if (!confirm(t('job.cancel_confirm', 'Cancel job?'))) return; + try { + await api('/api/jobs/cancel', { + method: 'POST', + body: JSON.stringify({ id: state.activeJobId }), + }); + } catch (e) { + alert(`${t('common.error','Error')}: ${e.message}`); + } +} + +async function runScan() { + // Prefer job-based scan. If backend returns job_id, we track progress. + const res = await api('/api/items/scan', { method: 'POST', body: '{}' }); + + if (!res.ok) { + alert(t('errors.scan_failed', 'Scan failed') + ': ' + (res.error || '')); + return; + } + + if (res.job_id) { + openJobDialog(); + await watchJob(res.job_id, { autoClose: true }); + await loadMediaList(); + return; + } + + // Fallback (legacy): scan completes immediately + alert(t('messages.scan_finished', 'Scan finished')); + await loadMediaList(); +} + +async function watchJob(jobId, opts = {}) { + const statusEl = UI.qs('job-status'); + const prog = UI.qs('job-progress'); + const logEl = UI.qs('job-log'); + + setActiveJob(jobId); + + for (;;) { + const res = await api('/api/jobs/get?id=' + encodeURIComponent(jobId)); + if (!res.ok) { + if (statusEl) statusEl.textContent = t('errors.job_fetch', 'Error fetching job'); + setActiveJob(null); + return; + } + + const job = res.job; + updateJobIndicator(job); + + const total = Number(job.progress_total || 0); + const cur = Number(job.progress || 0); + const pct = total > 0 ? Math.floor((cur / total) * 100) : 0; + + if (statusEl) { + statusEl.textContent = + `${t('job.status', 'Status')}: ${job.status} | ${cur}/${total}`; + } + + if (prog) { + prog.max = 100; + prog.value = pct; + } + + if (logEl) { + logEl.textContent = job.log_text || ''; + } + + if (job.status === 'done' || job.status === 'error' || job.status === 'canceled') { + setActiveJob(null); + + if (opts.autoClose) { + // do not force-close dialog; user can read log + } + return; + } + + await sleep(1000); + } +} + +async function runDryRun() { + const dlg = UI.qs('dlg-dry-run'); + const out = UI.qs('dry-run-log'); + if (out) out.textContent = t('common.loading', 'Loading…'); + if (dlg && typeof dlg.showModal === 'function') dlg.showModal(); + + const q = new URLSearchParams(); + q.set('tab', state.mediaTab || 'movies'); + const res = await api('/api/media/dry-run?' + q.toString()); + if (!res.ok || !res.data) { + if (out) out.textContent = t('common.error', 'Error'); + return; + } + + const data = res.data; + let text = ''; + text += `${t('media.dry_run.summary','Summary')}\n`; + text += `${t('media.dry_run.files','Files')}: ${data.files}\n`; + text += `${t('media.dry_run.rename','Rename')}: ${data.rename}\n`; + text += `${t('media.dry_run.delete','Delete')}: ${data.delete}\n`; + text += `${t('media.dry_run.unknown','Unknown type')}: ${data.unknown_type}\n`; + text += `${t('media.dry_run.convert','Convert')}: ${data.convert}\n\n`; + text += `${t('media.dry_run.details','Details')}\n`; + for (const row of data.rows || []) { + text += `- ${row.name} | ${row.actions.join(', ')}\n`; + } + if (out) out.textContent = text; +} + +async function runApply() { + const res = await api('/api/media/apply', { + method: 'POST', + body: JSON.stringify({ tab: state.mediaTab || 'movies' }), + }); + + if (!res.ok) { + alert(t('errors.apply_failed', 'Apply failed') + ': ' + (res.error || '')); + return; + } + + if (res.job_id) { + openJobDialog(); + await watchJob(res.job_id, { autoClose: true }); + await loadMediaList(); + } +} + +/* --------------------------- + Init +--------------------------- */ + +function init() { + if (window.Auth?.requireAuth) { + window.Auth.requireAuth(); + } + window.UI?.initHeader?.(); + state.i18n = window.I18N || {}; + state.lang = window.APP_LANG || 'en'; + setVersion(); + if (window.UserPrefs?.load) { + window.UserPrefs.load().then((prefs) => { + if (prefs?.theme && window.UI?.setTheme) { + window.UI.setTheme(prefs.theme); + } + if (prefs?.language && prefs.language !== state.lang) { + localStorage.setItem('scmedia_lang', prefs.language); + } + }).catch(() => {}); + } + UI.initThemeToggle(); + updateThemeLabel(); + UI.bindThemePreference?.(() => updateThemeLabel()); + initSourceDialog(); + if (UI.qs('btn-scan')) UI.qs('btn-scan').addEventListener('click', runScan); + if (UI.qs('btnJobCancel')) UI.qs('btnJobCancel').addEventListener('click', cancelActiveJob); + + restoreJobFromStorage(); + initMediaTabs(); + updateTabVisibility(); + loadUiSettings() + .then(() => { + initTables(); + if (window.Sse?.on) { + window.Sse.on('jobs', (data) => { + if (!data) return; + noteSseEvent('jobs'); + state.queueActiveList = Array.isArray(data.active_list) ? data.active_list : []; + updateQueueSummary(data); + if (data.active) updateJobIndicator(data.active); + if (isQueueMenuOpen()) renderQueueMenu(); + }); + window.Sse.on('sources', (payload) => { + if (!payload) return; + noteSseEvent('sources'); + const items = Array.isArray(payload.items) ? payload.items : []; + const removed = Array.isArray(payload.removed) ? payload.removed : []; + const isSnapshot = !!payload.snapshot; + if (!Array.isArray(state.sourcesCache) || isSnapshot) { + state.sourcesCache = items; + } else { + const map = new Map(); + state.sourcesCache.forEach(it => { + const key = `${it.source || ''}:${it.id || ''}`; + if (key !== ':') map.set(key, it); + }); + items.forEach(it => { + const key = `${it.source || ''}:${it.id || ''}`; + if (key !== ':') map.set(key, it); + }); + removed.forEach(key => map.delete(key)); + state.sourcesCache = Array.from(map.values()); + } + if (state.mediaTab === 'sources') { + const sourcesTable = state.tables.sources; + if (sourcesTable) sourcesTable.load(sourcesTable.page || 1, false); + } + refreshOpenSourceDetail(); + }); + window.Sse.on('tick', () => { + noteSseEvent('tick'); + }); + } + window.Sse?.start?.(); + initWatchdog(); + loadMediaList(); + }) + .catch(() => { + initTables(); + window.Sse?.start?.(); + initWatchdog(); + loadMediaList(); + }); +} + +init(); + +window.addEventListener('pagehide', () => { + window.Sse?.stop?.(); +}); + +function initMediaTabs() { + const tabs = document.querySelectorAll('#media-tabs .tab'); + tabs.forEach(btn => { + btn.addEventListener('click', () => { + tabs.forEach(x => x.classList.remove('active')); + btn.classList.add('active'); + state.mediaTab = btn.dataset.tab || 'movies'; + updateTabVisibility(); + loadMediaList(); + }); + }); +} + +function updateTabVisibility() { + const grid = UI.qs('grid'); + const sourcesGrid = UI.qs('sources-grid'); + const showSources = state.mediaTab === 'sources'; + const gridWrap = grid?.closest('.table-wrap'); + const sourcesWrap = sourcesGrid?.closest('.table-wrap'); + if (gridWrap) gridWrap.classList.toggle('is-hidden', showSources); + if (sourcesWrap) sourcesWrap.classList.toggle('is-hidden', !showSources); +} diff --git a/public/assets/js/auth.js b/public/assets/js/auth.js new file mode 100644 index 0000000..b81e2d8 --- /dev/null +++ b/public/assets/js/auth.js @@ -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, + }; +})(); diff --git a/public/assets/js/http.js b/public/assets/js/http.js new file mode 100644 index 0000000..6a7fec2 --- /dev/null +++ b/public/assets/js/http.js @@ -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, + }; +})(); diff --git a/public/assets/js/login.js b/public/assets/js/login.js new file mode 100644 index 0000000..18ea88a --- /dev/null +++ b/public/assets/js/login.js @@ -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(); diff --git a/public/assets/js/prefs.js b/public/assets/js/prefs.js new file mode 100644 index 0000000..5cc09ba --- /dev/null +++ b/public/assets/js/prefs.js @@ -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, + }; +})(); diff --git a/public/assets/js/settings.js b/public/assets/js/settings.js new file mode 100644 index 0000000..2a31f3a --- /dev/null +++ b/public/assets/js/settings.js @@ -0,0 +1,3510 @@ +/* public/assets/settings.js */ + +const state = { + settings: null, + profiles: [], + dirty: false, + editProfileId: null, + draggingId: null, + mediaRules: null, + rulesList: [], + ruleSort: { + field: 'name', + dir: 'asc', + }, + tasksList: [], + toolsList: [], + toolEditType: null, + ruleEditId: null, + ruleEditType: null, + rootsList: [], + rootEditId: null, + taskEditId: null, + logsView: { + from: '', + to: '', + }, + auditItems: [], + auditSelected: new Map(), + auditItemsCache: new Map(), + logSelected: new Map(), + logItemsCache: new Map(), + snapshots: [], + ui: { + table_mode: 'pagination', + table_page_size: 50, + }, + tables: {}, + settingsLoadPromise: null, +}; + +const AUDIT_EVENTS = [ + 'register', + 'login_failed', + 'login_mfa_required', + 'login', + 'mfa_failed', + 'refresh', + 'logout', + 'mfa_setup', + 'mfa_enabled', + 'mfa_disabled', + 'password_reset_requested', + 'password_reset', + 'roles_changed', + 'user_disabled', + 'user_enabled', + 'mfa_reset', + 'forced_logout', + 'password_change', + 'password_change_failed', + 'email_change', +]; + +const i18n = { + lang: 'ru', + dict: {}, +}; + +function t(key, fallback) { + const v = i18n.dict && Object.prototype.hasOwnProperty.call(i18n.dict, key) ? i18n.dict[key] : null; + return (typeof v === 'string' && v.length) ? v : (fallback ?? key); +} + +function setStatus(text) { + const el = UI.qs('#status'); + if (el) el.textContent = text; +} + +function setDirty(d) { + state.dirty = d; + const btn = UI.qs('#btnSave'); + if (btn) btn.disabled = !d; + const statusEl = UI.qs('#status'); + + if (d) { + setStatus(t('common.unsaved_changes', 'Unsaved changes')); + if (statusEl) statusEl.classList.add('dirty'); + localStorage.setItem('scmedia_settings_dirty', '1'); + } + if (!d) { + if (statusEl) statusEl.classList.remove('dirty'); + localStorage.removeItem('scmedia_settings_dirty'); + } +} + +function paginateItems(items, page, perPage) { + const total = items.length; + const size = Math.max(1, Number(perPage || 50)); + const p = Math.max(1, Number(page || 1)); + const start = (p - 1) * size; + return { + items: items.slice(start, start + size), + total, + page: p, + per_page: size, + }; +} + +function sortItems(items, sortKey, dir, valueMap) { + if (!sortKey || !valueMap?.[sortKey]) return items; + const getter = valueMap[sortKey]; + const asc = dir !== 'desc'; + const out = [...items]; + out.sort((a, b) => { + const av = getter(a); + const bv = getter(b); + if (av === bv) return 0; + if (asc) return av < bv ? -1 : 1; + return av > bv ? -1 : 1; + }); + return out; +} + +const RULE_TYPES = { + name_map: { label: 'Name mapping', i18n: 'rules.type.name_map' }, + delete_track: { label: 'Delete tracks', i18n: 'rules.type.delete_track' }, + priorities: { label: 'Priorities', i18n: 'rules.type.priorities' }, + lang_fix: { label: 'Language fix', i18n: 'rules.type.lang_fix' }, + source_filter: { label: 'Source filter', i18n: 'rules.type.source_filter' }, +}; + +function ruleTypeLabel(type) { + const info = RULE_TYPES[type]; + if (!info) return type || ''; + return t(info.i18n, info.label); +} + +function buildSourceOptions(selected) { + const out = []; + const sourcesCfg = state.pluginConfig?.sources || {}; + if (sourcesCfg.transmission) { + out.push({ value: 'transmission', label: t('settings.plugins.transmission_label', 'Transmission') }); + } + if (selected && !out.some(o => o.value === selected)) { + out.push({ value: selected, label: selected }); + } + if (out.length === 0) { + out.push({ value: 'transmission', label: t('settings.plugins.transmission_label', 'Transmission') }); + } + return out; +} + +function renderSourceOptions(selected) { + return buildSourceOptions(selected) + .map(o => ``) + .join(''); +} + +function applyI18n() { + const themeLabel = UI.qs('#themeState'); + if (themeLabel) { + const mode = document.documentElement.getAttribute('data-theme') || 'dark'; + themeLabel.textContent = (mode === 'light') + ? t('theme.light', 'Light') + : t('theme.dark', 'Dark'); + } +} + +async function api(url, method = 'GET', body = null) { + const opts = { method }; + if (body !== null) { + opts.body = body; + } + const res = await window.Api.request(url, opts); + return res.data; +} + +function commaToList(s) { + const t = (s || '').split(',').map(x => x.trim()).filter(Boolean); + return Array.from(new Set(t)); +} + +function listToComma(a) { + if (!Array.isArray(a)) return ''; + return a.join(','); +} + +function formatBytes(n) { + const v = Number(n || 0); + if (!isFinite(v) || v <= 0) return ''; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let idx = 0; + let cur = v; + while (cur >= 1024 && idx < units.length - 1) { + cur /= 1024; + idx += 1; + } + return `${cur.toFixed(cur >= 10 ? 0 : 1)} ${units[idx]}`; +} + +function initTabs() { + UI.qsa('nav.tabs .tab').forEach(btn => { + btn.addEventListener('click', () => { + console.log('[debug] tab click', btn.dataset.tab); + UI.qsa('nav.tabs .tab').forEach(x => x.classList.remove('active')); + UI.qsa('.tabpane').forEach(x => x.classList.remove('active')); + + btn.classList.add('active'); + const pane = UI.qs(`#pane-${btn.dataset.tab}`); + if (pane) pane.classList.add('active'); + if (btn.dataset.tab === 'debug') { + console.log('[debug] tab debug: loadDebugStats'); + loadDebugStats().catch(() => {}); + } + }); + }); +} + +function enforceAdminAccess() { + const auth = window.Auth; + if (!auth) return; + const access = auth.getAccessToken?.(); + const refresh = auth.getRefreshToken?.(); + if (!access && refresh && auth.refreshTokens) { + auth.refreshTokens().then((ok) => { + if (!ok || (auth.isAdmin && !auth.isAdmin())) { + window.location.href = '/'; + } + }); + return; + } + if (auth.isAdmin && !auth.isAdmin()) { + window.location.href = '/'; + } +} + +function bindDirtyInputs() { + // NOTE: language switch should NOT mark settings dirty + UI.qsa('input,select').forEach(el => { + if (el.dataset.local === 'true') return; + if (el.closest('#ruleModal')) return; + + el.addEventListener('change', () => setDirty(true)); + el.addEventListener('input', () => setDirty(true)); + }); +} + +function setVersion() { + const el = UI.qs('[data-version] .version'); + if (el && typeof APP_VERSION === 'string') { + el.textContent = `v${APP_VERSION}`; + } +} + +function updateAboutVersions(meta) { + const uiVersion = (typeof APP_VERSION === 'string' && APP_VERSION) ? APP_VERSION : ''; + const backendVersion = meta?.backend_version || ''; + const dbVersion = meta?.db_version || ''; + + const pluginsTable = UI.qs('#aboutPluginsTable tbody'); + if (pluginsTable) { + const plugins = [ + { name: 'UI', type: 'Core', author: 'SAFE-CAP', version: uiVersion || '-', update: '-' }, + { name: 'Backend', type: 'Core', author: 'SAFE-CAP', version: backendVersion || '-', update: '-' }, + { name: 'Database', type: 'Core', author: 'SAFE-CAP', version: dbVersion || '-', update: '-' }, + { name: 'OMDb', type: 'Metadata', author: 'OMDb API', version: '0.1.0', update: '-' }, + { name: 'TVDB', type: 'Metadata', author: 'TheTVDB', version: '0.1.0', update: '-' }, + { name: 'Kodi', type: 'Export', author: 'Kodi', version: '0.1.0', update: '-' }, + { name: 'Jellyfin', type: 'Export', author: 'Jellyfin', version: '0.1.0', update: '-' }, + { name: 'Transmission', type: 'Source', author: 'Transmission', version: '0.1.0', update: '-' }, + ]; + pluginsTable.innerHTML = ''; + for (const p of plugins) { + const tr = document.createElement('tr'); + const tdName = document.createElement('td'); + tdName.textContent = p.name; + const tdType = document.createElement('td'); + tdType.textContent = p.type; + const tdAuthor = document.createElement('td'); + tdAuthor.textContent = p.author; + const tdVersion = document.createElement('td'); + tdVersion.textContent = p.version; + const tdUpdate = document.createElement('td'); + tdUpdate.textContent = p.update; + tr.append(tdName, tdType, tdAuthor, tdVersion, tdUpdate); + pluginsTable.appendChild(tr); + } + } +} + +/* --------------------------- + Profiles +--------------------------- */ + +function renderProfileRow(tbody, p) { + const tpl = UI.qs('#tplProfileRow'); + const frag = tpl.content.cloneNode(true); + const tr = frag.querySelector('tr'); + tr.dataset.id = String(p.id); + tr.draggable = true; + + const enabled = tr.querySelector('[data-field="enabled"]'); + enabled.checked = !!p.enabled; + enabled.dataset.action = 'toggle'; + enabled.dataset.id = String(p.id); + + tr.querySelector('[data-field="profile_type"]').textContent = typeLabel(p.profile_type || 'scan'); + tr.querySelector('[data-field="name"]').textContent = p.name || ''; + tr.querySelector('[data-field="root_path"]').textContent = p.root_path || ''; + tr.querySelector('[data-field="max_depth"]').textContent = String(p.max_depth ?? ''); + tr.querySelector('[data-field="exclude"]').textContent = (p.exclude_patterns || []).join(', '); + tr.querySelector('[data-field="ext"]').textContent = + (p.include_ext_mode === 'custom') + ? ((p.include_ext || []).join(',')) + : t('settings.scan_profiles.ext_default', 'default'); + + tr.querySelector('[data-field="last_scan"]').textContent = + p.last_scan_at ? String(p.last_scan_at) : t('common.never', 'never'); + + tr.querySelector('[data-field="last_result"]').textContent = + p.last_result || t('common.never', 'never'); + + const btns = tr.querySelectorAll('button[data-action]'); + btns.forEach(b => { + b.dataset.id = String(p.id); + b.addEventListener('click', onProfileAction); + }); + enabled.addEventListener('change', onProfileToggle); + + tbody.appendChild(frag); +} + +function renderProfiles() { + const table = state.tables.profiles; + if (table) { + table.reload().then(applyI18n); + return; + } + + const tbody = UI.qs('#profilesTable tbody'); + tbody.innerHTML = ''; + for (const p of state.profiles) { + renderProfileRow(tbody, p); + } + applyI18n(); +} + +function typeLabel(v) { + if (v === 'analyze') return t('settings.scan_profiles.type_analyze', 'Analyze'); + return t('settings.scan_profiles.type_scan', 'Scan'); +} + +async function onProfileToggle(e) { + const id = Number(e.target.dataset.id); + const p = state.profiles.find(x => x.id === id); + if (!p) return; + + p.enabled = e.target.checked ? 1 : 0; + + await api(`/api/scan-profiles/${id}`, 'PUT', { + enabled: !!p.enabled, + name: p.name, + root_path: p.root_path, + max_depth: p.max_depth, + profile_type: p.profile_type || 'scan', + exclude_patterns: p.exclude_patterns || [], + include_ext_mode: p.include_ext_mode || 'default', + include_ext: p.include_ext || null, + }); + + setStatus(t('common.saved', 'Saved')); +} + +async function onProfileAction(e) { + const action = e.target.dataset.action; + const id = Number(e.target.dataset.id); + + if (action === 'edit') { + openProfileModal(id); + return; + } + + if (action === 'drag') { + return; + } + + if (action === 'del') { + if (!confirm(t('settings.scan_profiles.confirm_delete', 'Delete profile?'))) return; + await api(`/api/scan-profiles/${id}`, 'DELETE'); + state.profiles = state.profiles.filter(x => x.id !== id); + renderProfiles(); + return; + } +} + +function openProfileModal(id = null) { + state.editProfileId = id; + + const modal = UI.qs('#modal'); + modal.style.display = 'flex'; + + const isNew = id === null; + UI.qs('#modalTitle').textContent = isNew + ? t('settings.scan_profiles.modal_add', 'Add profile') + : t('settings.scan_profiles.modal_edit', 'Edit profile'); + + const p = isNew ? { + enabled: 1, + name: '', + root_path: '', + max_depth: 3, + exclude_patterns: [], + include_ext_mode: 'default', + include_ext: null, + profile_type: 'scan', + } : state.profiles.find(x => x.id === id); + + UI.qs('#pEnabled').value = p.enabled ? '1' : '0'; + UI.qs('#pName').value = p.name || ''; + UI.qs('#pRoot').value = p.root_path || ''; + UI.qs('#pDepth').value = String(p.max_depth || 3); + UI.qs('#pType').value = p.profile_type || 'scan'; + UI.qs('#pExcludes').value = listToComma(p.exclude_patterns || []); + UI.qs('#pExtMode').value = p.include_ext_mode || 'default'; + UI.qs('#pExtCustom').value = listToComma(p.include_ext || []); + refreshExtCustomVisibility(); +} + +function closeProfileModal() { + UI.qs('#modal').style.display = 'none'; + state.editProfileId = null; +} + +function refreshExtCustomVisibility() { + const mode = UI.qs('#pExtMode').value; + UI.qs('#pExtCustomWrap').style.display = (mode === 'custom') ? 'block' : 'none'; +} + +async function saveProfileModal() { + const isNew = state.editProfileId === null; + + const payload = { + enabled: UI.qs('#pEnabled').value === '1', + name: UI.qs('#pName').value.trim(), + root_path: UI.qs('#pRoot').value.trim(), + max_depth: Number(UI.qs('#pDepth').value || 3), + profile_type: UI.qs('#pType').value || 'scan', + exclude_patterns: commaToList(UI.qs('#pExcludes').value), + include_ext_mode: UI.qs('#pExtMode').value, + include_ext: (UI.qs('#pExtMode').value === 'custom') ? commaToList(UI.qs('#pExtCustom').value) : null, + }; + + if (isNew) { + const data = await api('/api/scan-profiles', 'POST', payload); + payload.id = data.id; + state.profiles.push(payload); + } else { + const id = state.editProfileId; + await api(`/api/scan-profiles/${id}`, 'PUT', payload); + const idx = state.profiles.findIndex(x => x.id === id); + state.profiles[idx] = { ...state.profiles[idx], ...payload }; + } + + closeProfileModal(); + await loadProfiles(); +} + +function initProfileReorder() { + const tbody = UI.qs('#profilesTable tbody'); + if (!tbody) return; + + tbody.addEventListener('dragstart', (e) => { + const handle = e.target.closest('.drag-handle'); + const tr = e.target.closest('tr'); + if (!handle || !tr) { + e.preventDefault(); + return; + } + state.draggingId = tr.dataset.id || null; + e.dataTransfer.effectAllowed = 'move'; + }); + + tbody.addEventListener('dragover', (e) => { + e.preventDefault(); + const tr = e.target.closest('tr'); + if (!tr || !state.draggingId) return; + + const dragging = tbody.querySelector(`tr[data-id="${state.draggingId}"]`); + if (!dragging || dragging === tr) return; + + const rect = tr.getBoundingClientRect(); + const after = e.clientY > rect.top + rect.height / 2; + if (after) { + tr.after(dragging); + } else { + tr.before(dragging); + } + }); + + tbody.addEventListener('drop', async (e) => { + e.preventDefault(); + if (!state.draggingId) return; + state.draggingId = null; + await persistProfileOrder(); + }); +} + +async function persistProfileOrder() { + const rows = Array.from(UI.qs('#profilesTable tbody').querySelectorAll('tr')); + const ids = rows.map(r => Number(r.dataset.id)).filter(Boolean); + if (ids.length === 0) return; + + await api('/api/scan-profiles/reorder', 'POST', { ids }); + state.profiles.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id)); + setStatus(t('common.saved', 'Saved')); +} + +/* --------------------------- + Settings UI mapping +--------------------------- */ + +function applySettingsToUI(all, userUi = null) { + const data = all.data || all; // tolerate nesting + state.settings = data; + + // Debug visibility + const meta = data.meta || {}; + if (meta.debug_tools_enabled) { + UI.qs('#tabDebug').style.display = 'block'; + } + if (!meta.allow_db_reset) { + UI.qs('#btnResetDb').disabled = true; + const restoreBtn = UI.qs('#btnDbRestore'); + if (restoreBtn) restoreBtn.disabled = true; + } + updateAboutVersions(meta); + const envEl = UI.qs('#serverEnv'); + if (envEl) envEl.value = meta.env || ''; + const appIdEl = UI.qs('#serverAppId'); + if (appIdEl) appIdEl.value = meta.app_id || ''; + const debugEl = UI.qs('#serverDebug'); + if (debugEl) debugEl.value = meta.debug_tools_enabled ? 'true' : 'false'; + const backendEl = UI.qs('#serverBackendVersion'); + if (backendEl) backendEl.value = meta.backend_version || ''; + const dbVerEl = UI.qs('#serverDbVersion'); + if (dbVerEl) dbVerEl.value = meta.db_version || ''; + + // Scanner defaults + const sd = data.scanner_defaults || {}; + UI.qs('#videoExt').value = listToComma(sd.video_ext || []); + UI.qs('#maxDepthDefault').value = String(sd.max_depth_default ?? 3); + UI.qs('#maxFilesPerItem').value = String(sd.max_files_per_item ?? 3000); + UI.qs('#maxItemsPerScan').value = String(sd.max_items_per_scan ?? 0); + + // Paths (roots list) + const paths = data.paths || {}; + state.rootsList = buildRootsList(paths); + renderRootsList(); + + // Layout (rules moved to Rules tab) + const layout = data.layout || {}; + const cp = layout.collision_policy || 'stop'; + const collisionEl = UI.qs('#collisionPolicy'); + if (collisionEl) collisionEl.value = cp; + + // Tools list + const tools = data.tools || {}; + state.toolsList = buildToolsList(tools); + renderToolsList(); + + // Logs + const logs = data.logs || {}; + UI.qs('#logsRetention').value = String(logs.retention_days ?? 7); + UI.qs('#logsLevel').value = logs.level || 'info'; + + initLogsViewDates(); + + // Rules list (new UI) + const rulesList = Array.isArray(data.rules) ? data.rules : []; + state.rulesList = rulesList.map(r => normalizeRule(r)); + renderRulesList(); + + // Tasks list + const tasks = Array.isArray(data.tasks) ? data.tasks : []; + state.tasksList = tasks.map(t => normalizeTask(t)); + renderTasksList(); + + // Plugins + state.pluginConfig = { + metadata: data.metadata || {}, + exports: data.exports || {}, + sources: data.sources || {}, + }; + normalizePluginConfig(); + renderPluginsList(); + + // UI settings (per-user, stored in account prefs) + const ui = userUi || {}; + const tableMode = ui.table_mode || 'pagination'; + const tableSize = ui.table_page_size || 50; + state.ui = { table_mode: tableMode, table_page_size: tableSize }; + const serverUi = data.ui || {}; + UI.qs('#uiSseTickSeconds').value = String(serverUi.sse_tick_seconds ?? 10); + + // Background policies + const bg = data.background || {}; + const bgMode = UI.qs('#bgMode'); + if (bgMode) bgMode.value = bg.mode || 'light'; + const bgParallel = UI.qs('#bgMaxParallel'); + if (bgParallel) bgParallel.value = String(bg.max_parallel_jobs ?? 1); + const bgNetwork = UI.qs('#bgMaxNetwork'); + if (bgNetwork) bgNetwork.value = String(bg.max_network_jobs ?? 1); + const bgIo = UI.qs('#bgMaxIo'); + if (bgIo) bgIo.value = String(bg.max_io_jobs ?? 1); + const bgSleep = UI.qs('#bgBatchSleep'); + if (bgSleep) bgSleep.value = String(bg.batch_sleep_ms ?? 500); + const bgWatchdog = UI.qs('#bgWatchdog'); + if (bgWatchdog) bgWatchdog.value = String(bg.watchdog_minutes ?? 10); + const bgSseTtl = UI.qs('#bgSseTtl'); + if (bgSseTtl) bgSseTtl.value = String(bg.sse_session_ttl_seconds ?? 20); + + const safety = data.safety || {}; + const safetyDepth = UI.qs('#safetyMaxDepth'); + if (safetyDepth) safetyDepth.value = String(safety.max_depth ?? 10); + const safetyFiles = UI.qs('#safetyMaxFiles'); + if (safetyFiles) safetyFiles.value = String(safety.max_files_per_item ?? 200000); + const safetyItems = UI.qs('#safetyMaxItems'); + if (safetyItems) safetyItems.value = String(safety.max_items_per_scan ?? 1000000); + + setDirty(false); +} + +function collectSettingsFromUI() { + const meta = state.settings?.meta || {}; + const rev = meta.settings_revision ?? 1; + + const scanner_defaults = { + video_ext: commaToList(UI.qs('#videoExt').value), + max_depth_default: Number(UI.qs('#maxDepthDefault').value || 3), + max_files_per_item: Number(UI.qs('#maxFilesPerItem').value || 3000), + max_items_per_scan: Number(UI.qs('#maxItemsPerScan').value || 0), + }; + + const paths = buildPathsPayload(); + + const layout = { + collision_policy: UI.qs('#collisionPolicy')?.value || 'stop', + }; + + const tools = { + mkvmerge_path: toolPathByType('mkvmerge'), + mkvpropedit_path: toolPathByType('mkvpropedit'), + ffmpeg_path: toolPathByType('ffmpeg'), + }; + + const logs = { + retention_days: Number(UI.qs('#logsRetention').value || 7), + level: UI.qs('#logsLevel').value || 'info', + }; + + const background = { + mode: UI.qs('#bgMode')?.value || 'light', + max_parallel_jobs: Number(UI.qs('#bgMaxParallel')?.value || 1), + max_network_jobs: Number(UI.qs('#bgMaxNetwork')?.value || 1), + max_io_jobs: Number(UI.qs('#bgMaxIo')?.value || 1), + batch_sleep_ms: Number(UI.qs('#bgBatchSleep')?.value || 500), + watchdog_minutes: Number(UI.qs('#bgWatchdog')?.value || 10), + sse_session_ttl_seconds: Number(UI.qs('#bgSseTtl')?.value || 20), + paused: !!state.settings?.background?.paused, + }; + const ui = { + sse_tick_seconds: Number(UI.qs('#uiSseTickSeconds')?.value || 10), + }; + const safety = { + max_depth: Number(UI.qs('#safetyMaxDepth')?.value || 10), + max_files_per_item: Number(UI.qs('#safetyMaxFiles')?.value || 200000), + max_items_per_scan: Number(UI.qs('#safetyMaxItems')?.value || 1000000), + }; + + const rules = state.rulesList || []; + const tasks = state.tasksList || []; + + const metadata = state.pluginConfig?.metadata || {}; + const exportsCfg = state.pluginConfig?.exports || {}; + const sources = state.pluginConfig?.sources || {}; + + return { + if_revision: rev, + scanner_defaults, + paths, + tools, + logs, + layout, + ui, + safety, + rules, + sources, + metadata, + exports: exportsCfg, + background, + tasks, + }; +} + +function refreshStrategyVisibility() { + // No-op (layout rules moved to Rules tab) +} + +/* --------------------------- + Data loading +--------------------------- */ + +async function loadSettings() { + setStatus(t('common.loading_settings', 'Loading settings…')); + const data = await api('/api/settings', 'GET'); + console.log('[debug] loadSettings', data); + const meta = data.data?.meta || data.meta || {}; + state.debugToolsEnabled = !!meta.debug_tools_enabled; + const prefs = window.UserPrefs ? await window.UserPrefs.load().catch(() => null) : null; + + // Prefer per-user language, fall back to server default + const lang = (prefs?.language || window.APP_LANG || data.data?.general?.language || data.general?.language || 'ru'); + + // Apply settings to UI, then set status + applySettingsToUI(data, prefs); + if (meta.debug_tools_enabled) { + loadDebugStats().catch(() => {}); + } + + setStatus(t('common.loaded', 'Loaded')); +} + +async function loadDiagnostics() { + const data = await api('/api/settings/diagnostics', 'GET'); + const diag = data.data || data || {}; + const phpEl = UI.qs('#serverPhpVersion'); + if (phpEl) phpEl.value = diag.php_version || ''; + const diskEl = UI.qs('#serverDisk'); + if (diskEl) { + const free = formatBytes(diag.disk?.free_bytes); + const total = formatBytes(diag.disk?.total_bytes); + diskEl.value = (free && total) ? `${free} / ${total}` : ''; + } + const binsEl = UI.qs('#serverBinaries'); + if (binsEl) { + const bins = diag.binaries || {}; + const parts = Object.keys(bins).map(k => `${k}:${bins[k] ? 'ok' : 'missing'}`); + binsEl.value = parts.join(' '); + } + const jobsEl = UI.qs('#serverJobs'); + if (jobsEl) { + const jobs = diag.jobs || {}; + jobsEl.value = `running=${jobs.running || 0} queued=${jobs.queued || 0} error=${jobs.errors || 0}`; + } +} + +async function loadSnapshots() { + const data = await api('/api/settings/snapshots', 'GET'); + const items = data.data?.items || data.items || []; + state.snapshots = items; + renderSnapshots(); +} + +function renderSnapshots() { + const tbody = UI.qs('#snapshotsTable tbody'); + if (!tbody) return; + tbody.innerHTML = ''; + (state.snapshots || []).forEach((s) => { + const tr = document.createElement('tr'); + tr.dataset.id = String(s.id || ''); + const tdId = document.createElement('td'); + tdId.textContent = String(s.id || ''); + const tdLabel = document.createElement('td'); + tdLabel.textContent = s.label || ''; + const tdDate = document.createElement('td'); + tdDate.textContent = s.created_at || ''; + const tdActions = document.createElement('td'); + const btnRestore = document.createElement('button'); + btnRestore.className = 'btn'; + btnRestore.textContent = t('settings.server.snapshot_restore', 'Restore'); + btnRestore.addEventListener('click', () => restoreSnapshot(Number(s.id || 0))); + const btnDelete = document.createElement('button'); + btnDelete.className = 'btn'; + btnDelete.textContent = t('common.delete', 'Delete'); + btnDelete.addEventListener('click', () => deleteSnapshot(Number(s.id || 0))); + tdActions.appendChild(btnRestore); + tdActions.appendChild(btnDelete); + tr.appendChild(tdId); + tr.appendChild(tdLabel); + tr.appendChild(tdDate); + tr.appendChild(tdActions); + tbody.appendChild(tr); + }); +} + +async function createSnapshot() { + const hint = UI.qs('#snapshotsHint'); + if (hint) hint.textContent = t('common.saving', 'Saving…'); + try { + const label = UI.qs('#snapshotLabel')?.value || ''; + await api('/api/settings/snapshots', 'POST', { label }); + if (UI.qs('#snapshotLabel')) UI.qs('#snapshotLabel').value = ''; + await loadSnapshots(); + if (hint) hint.textContent = t('common.saved', 'Saved'); + } catch (e) { + if (hint) hint.textContent = `${t('common.error', 'Error')}: ${e.message}`; + } +} + +async function restoreSnapshot(id) { + if (!id) return; + const ok = confirm(t('settings.server.snapshot_restore_confirm', 'Restore this snapshot? Current settings will be replaced.')); + if (!ok) return; + const hint = UI.qs('#snapshotsHint'); + if (hint) hint.textContent = t('common.running', 'Running…'); + try { + await api('/api/settings/snapshots/restore', 'POST', { id }); + await loadSettings(); + if (hint) hint.textContent = t('common.saved', 'Saved'); + } catch (e) { + if (hint) hint.textContent = `${t('common.error', 'Error')}: ${e.message}`; + } +} + +async function deleteSnapshot(id) { + if (!id) return; + const ok = confirm(t('settings.server.snapshot_delete_confirm', 'Delete this snapshot?')); + if (!ok) return; + const hint = UI.qs('#snapshotsHint'); + if (hint) hint.textContent = t('common.running', 'Running…'); + try { + await api(`/api/settings/snapshots/${id}`, 'DELETE'); + await loadSnapshots(); + if (hint) hint.textContent = t('common.saved', 'Saved'); + } catch (e) { + if (hint) hint.textContent = `${t('common.error', 'Error')}: ${e.message}`; + } +} + +/* --------------------------- + Rules UI (new) +--------------------------- */ + +function normalizeRule(r) { + const id = r?.id || `r_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + return { + id, + type: r?.type || 'name_map', + name: r?.name || '', + enabled: (r?.enabled !== false), + config: r?.config || {}, + }; +} + +function normalizeTask(t) { + const id = t?.id || `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + return { + id, + name: t?.name || '', + enabled: t?.enabled !== false, + sources: Array.isArray(t?.sources) ? t.sources : [], + actions: Array.isArray(t?.actions) ? t.actions : [], + }; +} + +function renderRuleRow(tbody, r) { + const tr = document.createElement('tr'); + tr.dataset.id = r.id; + + const tdName = document.createElement('td'); + tdName.textContent = r.name || t('rules.unnamed', 'Untitled'); + + const tdType = document.createElement('td'); + tdType.textContent = ruleTypeLabel(r.type); + + const tdSummary = document.createElement('td'); + tdSummary.textContent = ruleSummary(r); + + const tdStatus = document.createElement('td'); + tdStatus.textContent = r.enabled ? t('rules.status.on', 'On') : t('rules.status.off', 'Off'); + + const tdActions = document.createElement('td'); + const btnEdit = document.createElement('button'); + btnEdit.className = 'btn'; + btnEdit.textContent = t('common.edit', 'Edit'); + btnEdit.addEventListener('click', () => openRuleModal(r.type, r.id)); + + const btnToggle = document.createElement('button'); + btnToggle.className = 'btn'; + btnToggle.textContent = r.enabled ? t('rules.disable', 'Disable') : t('rules.enable', 'Enable'); + btnToggle.addEventListener('click', () => { + r.enabled = !r.enabled; + renderRulesList(); + setDirty(true); + }); + + const btnDel = document.createElement('button'); + btnDel.className = 'btn'; + btnDel.textContent = t('common.delete', 'Delete'); + btnDel.addEventListener('click', () => { + if (!confirm(t('rules.confirm_delete', 'Delete rule?'))) return; + state.rulesList = state.rulesList.filter(x => x.id !== r.id); + renderRulesList(); + setDirty(true); + }); + + tdActions.appendChild(btnEdit); + tdActions.appendChild(btnToggle); + tdActions.appendChild(btnDel); + + tr.appendChild(tdName); + tr.appendChild(tdType); + tr.appendChild(tdSummary); + tr.appendChild(tdStatus); + tr.appendChild(tdActions); + + tbody.appendChild(tr); +} + +function renderRulesList() { + const field = state.ruleSort.field || 'name'; + const dir = state.ruleSort.dir || 'asc'; + const sortField = UI.qs('#rulesSortField'); + if (sortField) sortField.value = field; + const sortBtn = UI.qs('#rulesSortDir'); + if (sortBtn) sortBtn.textContent = (dir === 'asc') ? '↑' : '↓'; + + const table = state.tables.rules; + if (table) { + table.setSort(field, dir); + table.reload(); + return; + } + + const tbody = UI.qs('#rulesTable tbody'); + if (!tbody) return; + tbody.innerHTML = ''; + + const rows = sortItems(state.rulesList, field, dir, { + name: r => (r.name || '').toLowerCase(), + type: r => (r.type || '').toLowerCase(), + }); + + rows.forEach(r => renderRuleRow(tbody, r)); +} + +function renderTaskRow(tbody, task) { + const tr = document.createElement('tr'); + tr.dataset.id = task.id; + const tdName = document.createElement('td'); + tdName.textContent = task.name || t('rules.unnamed', 'Untitled'); + + const tdSources = document.createElement('td'); + tdSources.textContent = (task.sources || []).map(taskSourceLabel).join(', '); + + const tdActions = document.createElement('td'); + tdActions.textContent = (task.actions || []).map(taskActionLabel).join(', '); + + const tdStatus = document.createElement('td'); + tdStatus.textContent = task.enabled ? t('settings.tasks.status.on', 'On') : t('settings.tasks.status.off', 'Off'); + + const tdButtons = document.createElement('td'); + const btnEdit = document.createElement('button'); + btnEdit.className = 'btn'; + btnEdit.textContent = t('common.edit', 'Edit'); + btnEdit.addEventListener('click', () => openTaskModal(task.id)); + const btnToggle = document.createElement('button'); + btnToggle.className = 'btn'; + btnToggle.textContent = task.enabled ? t('rules.disable', 'Disable') : t('rules.enable', 'Enable'); + btnToggle.addEventListener('click', () => { + task.enabled = !task.enabled; + renderTasksList(); + setDirty(true); + }); + const btnDel = document.createElement('button'); + btnDel.className = 'btn'; + btnDel.textContent = t('common.delete', 'Delete'); + btnDel.addEventListener('click', () => { + if (!confirm(t('settings.tasks.confirm_delete', 'Delete task?'))) return; + state.tasksList = state.tasksList.filter(x => x.id !== task.id); + renderTasksList(); + setDirty(true); + }); + tdButtons.appendChild(btnEdit); + tdButtons.appendChild(btnToggle); + tdButtons.appendChild(btnDel); + + tr.appendChild(tdName); + tr.appendChild(tdSources); + tr.appendChild(tdActions); + tr.appendChild(tdStatus); + tr.appendChild(tdButtons); + tbody.appendChild(tr); +} + +function renderTasksList() { + const table = state.tables.tasks; + if (table) { + table.reload(); + return; + } + const tbody = UI.qs('#tasksTable tbody'); + if (!tbody) return; + tbody.innerHTML = ''; + state.tasksList.forEach(task => renderTaskRow(tbody, task)); +} + +function taskSourceLabel(src) { + if (src === 'library') return t('settings.tasks.source.library', 'Library'); + if (src === 'transmission') return t('settings.tasks.source.transmission', 'Transmission'); + if (src === 'staging') return t('settings.tasks.source.staging', 'Staging'); + return src || ''; +} + +function taskActionLabel(action) { + if (action === 'analyze') return t('settings.tasks.action.analyze', 'Analyze'); + if (action === 'identify') return t('settings.tasks.action.identify', 'Identify'); + if (action === 'normalize') return t('settings.tasks.action.normalize', 'Normalize'); + if (action === 'rename') return t('settings.tasks.action.rename', 'Rename'); + if (action === 'export') return t('settings.tasks.action.export', 'Export'); + return action || ''; +} + +function sourceStatusOptions(source) { + if (source === 'transmission') { + return [ + { value: 'completed', label: t('sources.status.completed', 'completed') }, + { value: 'downloading', label: t('sources.status.downloading', 'downloading') }, + { value: 'seeding', label: t('sources.status.seeding', 'seeding') }, + { value: 'stopped', label: t('sources.status.stopped', 'stopped') }, + { value: 'unknown', label: t('sources.status.unknown', 'unknown') }, + ]; + } + return []; +} + +function renderStatusOptions(source) { + const options = sourceStatusOptions(source); + if (options.length === 0) { + return ``; + } + return options.map(opt => ``).join(''); +} + +function conditionFieldOptions() { + return [ + { value: 'status', label: t('rules.cond.status', 'Status') }, + { value: 'label', label: t('rules.cond.label', 'Label') }, + { value: 'name_regex', label: t('rules.cond.name_regex', 'Name regex') }, + { value: 'path_regex', label: t('rules.cond.path_regex', 'Path regex') }, + { value: 'min_size', label: t('rules.cond.min_size', 'Min size') }, + ]; +} + +function conditionOperatorOptions(field) { + if (field === 'status') { + return [ + { value: 'any', label: t('rules.op.any', 'any') }, + { value: 'in', label: '=' }, + { value: 'not_in', label: '!=' }, + ]; + } + if (field === 'min_size') { + return [ + { value: '>=', label: '>=' }, + { value: '>', label: '>' }, + { value: '<=', label: '<=' }, + { value: '<', label: '<' }, + { value: '=', label: '=' }, + { value: '!=', label: '!=' }, + ]; + } + if (field === 'name_regex' || field === 'path_regex') { + return [ + { value: 'regex', label: 'regex' }, + { value: 'not_regex', label: '!regex' }, + ]; + } + if (field === 'label') { + return [ + { value: 'contains', label: t('rules.op.contains', 'contains') }, + { value: 'not_contains', label: t('rules.op.not_contains', 'not contains') }, + { value: '=', label: '=' }, + { value: '!=', label: '!=' }, + ]; + } + return [ + { value: '=', label: '=' }, + ]; +} + +function createConditionValueControl(field, source, op, value) { + if (field === 'status') { + const sel = document.createElement('select'); + sel.multiple = true; + sel.dataset.field = 'cond_value'; + sel.innerHTML = renderStatusOptions(source); + const values = Array.isArray(value) ? value : []; + Array.from(sel.options).forEach(opt => { + if (values.includes(opt.value)) opt.selected = true; + }); + if (op === 'any') { + sel.disabled = true; + } + return sel; + } + if (field === 'min_size') { + const input = document.createElement('input'); + input.type = 'number'; + input.dataset.field = 'cond_value'; + input.value = value ?? ''; + return input; + } + const input = document.createElement('input'); + input.type = 'text'; + input.dataset.field = 'cond_value'; + input.value = value ?? ''; + return input; +} + +function formatConditionValue(field, source, value, op) { + if (field === 'status') { + if (op === 'any') return t('rules.op.any', 'any'); + const values = Array.isArray(value) ? value : []; + const labels = sourceStatusOptions(source).reduce((acc, s) => { + acc[s.value] = s.label; + return acc; + }, {}); + const parts = values.map(v => labels[v] || v).filter(Boolean); + const orLabel = t('rules.logic.or', 'OR'); + return parts.length ? parts.join(` ${orLabel} `) : t('rules.statuses.none', 'No statuses'); + } + if (field === 'min_size') { + return String(value ?? ''); + } + return String(value ?? ''); +} + +function formatConditionField(field) { + const opt = conditionFieldOptions().find(o => o.value === field); + return opt ? opt.label : field; +} + +function formatConditionOp(field, op) { + if (field === 'status' && op === 'any') return '='; + if (op) return op; + const opts = conditionOperatorOptions(field); + return opts.length ? opts[0].value : '='; +} + +function ruleSummary(r) { + const cfg = r.config || {}; + if (r.type === 'name_map') { + return `${cfg.mode || 'exact'}: ${cfg.pattern || '*'} -> ${cfg.canonical || '*'}`; + } + if (r.type === 'delete_track') { + return `type=${cfg.track_type || '*'} lang=${cfg.lang || '*'} audio=${cfg.audio_type || '*'} contains=${cfg.name_contains || '*'}`; + } + if (r.type === 'priorities') { + const langs = Array.isArray(cfg.languages) ? cfg.languages.join(',') : ''; + const aud = Array.isArray(cfg.audio_types) ? cfg.audio_types.join(',') : ''; + return `langs=${langs || '*'} audio=${aud || '*'}`; + } + if (r.type === 'lang_fix') { + return `${cfg.from_lang || '*'} -> ${cfg.to_lang || '*'} (${cfg.mode || 'exact'})`; + } + if (r.type === 'source_filter') { + const conds = Array.isArray(cfg.conditions) ? cfg.conditions : []; + const condText = conds + .filter(c => c?.enabled !== false) + .map(c => `${c.field || '?'}${c.op || '='}${Array.isArray(c.value) ? c.value.join(',') : (c.value ?? '')}`) + .join(' '); + return `src=${cfg.source || '*'} ${condText || '*'}`; + } + return ''; +} + +function initRulesUi() { + const addBtn = UI.qs('#btnAddRule'); + const menu = UI.qs('#ruleTypeMenu'); + const sortField = UI.qs('#rulesSortField'); + const sortDir = UI.qs('#rulesSortDir'); + const modalClose = UI.qs('#ruleModalClose'); + const modalCancel = UI.qs('#ruleModalCancel'); + const modalSave = UI.qs('#ruleModalSave'); + + if (addBtn && menu) { + addBtn.addEventListener('click', (e) => { + e.preventDefault(); + addBtn.parentElement?.classList.toggle('open'); + }); + menu.querySelectorAll('button[data-rule-type]').forEach(btn => { + btn.addEventListener('click', () => { + const type = btn.dataset.ruleType || 'name_map'; + addBtn.parentElement?.classList.remove('open'); + openRuleModal(type, null); + }); + }); + document.addEventListener('click', (e) => { + if (!addBtn.parentElement?.contains(e.target)) { + addBtn.parentElement?.classList.remove('open'); + } + }); + } + + if (sortField) { + sortField.addEventListener('change', () => { + state.ruleSort.field = sortField.value || 'name'; + renderRulesList(); + }); + } + + if (sortDir) { + sortDir.addEventListener('click', () => { + state.ruleSort.dir = (state.ruleSort.dir === 'asc') ? 'desc' : 'asc'; + renderRulesList(); + }); + } + + if (modalClose) modalClose.addEventListener('click', closeRuleModal); + if (modalCancel) modalCancel.addEventListener('click', closeRuleModal); + if (modalSave) { + modalSave.addEventListener('click', () => { + if (state.taskEditId !== null) return; + saveRuleModal(); + }); + } +} + +function initTaskModalRouting() { + const modalSave = UI.qs('#ruleModalSave'); + const modalCancel = UI.qs('#ruleModalCancel'); + const modalClose = UI.qs('#ruleModalClose'); + if (modalSave) { + modalSave.addEventListener('click', () => { + const body = UI.qs('#ruleModalBody'); + if (!body) return; + if (state.taskEditId !== null) { + saveTaskModal(body); + return; + } + }); + } + if (modalCancel) modalCancel.addEventListener('click', closeRuleModal); + if (modalClose) modalClose.addEventListener('click', closeRuleModal); +} + +function openRuleModal(type, id = null) { + const modal = UI.qs('#ruleModal'); + const body = UI.qs('#ruleModalBody'); + const title = UI.qs('#ruleModalTitle'); + if (!modal || !body || !title) return; + + const rule = id ? state.rulesList.find(r => r.id === id) : null; + const data = rule ? { ...rule } : normalizeRule({ type }); + data.type = type || data.type; + + state.ruleEditId = data.id; + state.ruleEditType = data.type; + state.toolEditType = null; + state.taskEditId = null; + + title.textContent = `${t('rules.modal_title', 'Rule')}: ${ruleTypeLabel(data.type)}`; + body.innerHTML = renderRuleForm(data); + applyRuleFormValues(body, data); + if (data.type === 'source_filter') { + const sourceSel = body.querySelector('[data-field="source"]'); + if (sourceSel) { + sourceSel.addEventListener('change', () => { + renderConditionsTable(body, sourceSel.value, readConditionsTable(body)); + }); + } + } + modal.style.display = 'flex'; +} + +function closeRuleModal() { + const modal = UI.qs('#ruleModal'); + if (modal) modal.style.display = 'none'; + state.ruleEditId = null; + state.ruleEditType = null; + state.toolEditType = null; + state.rootEditId = null; + state.pluginEditType = null; + state.taskEditId = null; +} + +function renderRuleForm(rule) { + const cfg = rule.config || {}; + let fields = ''; + if (rule.type === 'name_map') { + fields += ` + + + + `; + } else if (rule.type === 'delete_track') { + fields += ` + + + + + + + `; + } else if (rule.type === 'priorities') { + fields += ` + + + `; + } else if (rule.type === 'lang_fix') { + fields += ` + + + + `; + } else if (rule.type === 'source_filter') { + const selectedSource = cfg.source || 'transmission'; + const conditions = Array.isArray(cfg.conditions) ? cfg.conditions : []; + fields += ` + + + `; + } + + return ` +
+ + + ${fields} +
+ `; +} + +function applyRuleFormValues(body, rule) { + const cfg = rule.config || {}; + const setVal = (field, value) => { + const el = body.querySelector(`[data-field="${field}"]`); + if (el) el.value = value ?? ''; + }; + setVal('name', rule.name || ''); + setVal('enabled', rule.enabled ? '1' : '0'); + if (rule.type === 'name_map') { + setVal('pattern', cfg.pattern || ''); + setVal('canonical', cfg.canonical || ''); + setVal('mode', cfg.mode || 'exact'); + } else if (rule.type === 'delete_track') { + setVal('track_type', cfg.track_type || ''); + setVal('lang', cfg.lang || ''); + setVal('audio_type', cfg.audio_type || ''); + setVal('name_contains', cfg.name_contains || ''); + setVal('except_default', cfg.except_default ? '1' : '0'); + setVal('except_forced', cfg.except_forced ? '1' : '0'); + } else if (rule.type === 'priorities') { + setVal('languages', (cfg.languages || []).join(',')); + setVal('audio_types', (cfg.audio_types || []).join(',')); + } else if (rule.type === 'lang_fix') { + setVal('from_lang', cfg.from_lang || ''); + setVal('to_lang', cfg.to_lang || ''); + setVal('mode', cfg.mode || 'exact'); + } else if (rule.type === 'source_filter') { + setVal('source', cfg.source || 'transmission'); + const conditions = Array.isArray(cfg.conditions) ? cfg.conditions : buildLegacyConditions(cfg); + renderConditionsTable(body, cfg.source || 'transmission', conditions); + } +} + +function buildLegacyConditions(cfg) { + const conditions = []; + const statuses = Array.isArray(cfg.statuses) ? cfg.statuses : (cfg.status ? [cfg.status] : []); + if (statuses.length > 0) { + conditions.push({ field: 'status', op: 'in', value: statuses }); + } + if (cfg.label) { + conditions.push({ field: 'label', op: 'contains', value: cfg.label }); + } + if (cfg.name_regex) { + conditions.push({ field: 'name_regex', op: 'regex', value: cfg.name_regex }); + } + if (cfg.path_regex) { + conditions.push({ field: 'path_regex', op: 'regex', value: cfg.path_regex }); + } + if (cfg.min_size) { + conditions.push({ field: 'min_size', op: '>=', value: Number(cfg.min_size) }); + } + return conditions; +} + +function renderConditionsTable(body, source, conditions) { + const table = body.querySelector('[data-field="conditions_table"]'); + if (!table) return; + const tbody = table.querySelector('tbody'); + if (!tbody) return; + tbody.innerHTML = ''; + (conditions || []).forEach(c => renderConditionRow(tbody, source, c)); + + initConditionBuilder(body, source, () => { + const cond = readConditionBuilder(body); + if (!cond) return; + renderConditionRow(tbody, source, cond); + }); +} + +function renderConditionRow(tbody, source, cond) { + const tr = document.createElement('tr'); + const fieldCell = document.createElement('td'); + const opCell = document.createElement('td'); + const valueCell = document.createElement('td'); + const enabledCell = document.createElement('td'); + const delCell = document.createElement('td'); + + const field = cond.field || 'status'; + const op = cond.op || ''; + const value = cond.value ?? ''; + + tr.dataset.field = field; + tr.dataset.op = op; + tr.dataset.value = JSON.stringify(value); + tr.dataset.enabled = (cond.enabled === false) ? '0' : '1'; + + fieldCell.textContent = formatConditionField(field); + opCell.textContent = formatConditionOp(field, op); + valueCell.textContent = formatConditionValue(field, source, value, op); + + const enabledBtn = document.createElement('button'); + enabledBtn.type = 'button'; + enabledBtn.className = 'btn'; + const setEnabledLabel = (on) => { + enabledBtn.textContent = on ? t('common.yes','Yes') : t('common.no','No'); + }; + setEnabledLabel(cond.enabled !== false); + enabledBtn.addEventListener('click', () => { + const on = tr.dataset.enabled !== '1'; + tr.dataset.enabled = on ? '1' : '0'; + setEnabledLabel(on); + }); + + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'btn'; + removeBtn.textContent = 'X'; + removeBtn.addEventListener('click', () => tr.remove()); + + enabledCell.appendChild(enabledBtn); + delCell.appendChild(removeBtn); + tr.append(fieldCell, opCell, valueCell, enabledCell, delCell); + tbody.appendChild(tr); +} + +function readConditionsTable(body) { + const rows = Array.from(body.querySelectorAll('[data-field="conditions_table"] tbody tr')); + return rows.map(row => { + const field = row.dataset.field || ''; + const op = row.dataset.op || ''; + let value = row.dataset.value || ''; + try { + value = JSON.parse(value); + } catch { + // keep raw + } + const enabled = row.dataset.enabled !== '0'; + return { field, op, value, enabled }; + }).filter(c => c.field); +} + +function initConditionBuilder(body, source, onAdd) { + const builder = body.querySelector('[data-field="conditions_builder"]'); + if (!builder) return; + const fieldSel = builder.querySelector('[data-field="cond_builder_field"]'); + const opSel = builder.querySelector('[data-field="cond_builder_op"]'); + const valueWrap = builder.querySelector('[data-field="cond_builder_value"]'); + const addBtn = builder.querySelector('[data-action="add_condition"]'); + if (!fieldSel || !opSel || !valueWrap || !addBtn) return; + + fieldSel.innerHTML = ''; + conditionFieldOptions().forEach(opt => { + const o = document.createElement('option'); + o.value = opt.value; + o.textContent = opt.label; + fieldSel.appendChild(o); + }); + + const refreshBuilder = (field, op, value) => { + opSel.innerHTML = ''; + conditionOperatorOptions(field).forEach(opt => { + const o = document.createElement('option'); + o.value = opt.value; + o.textContent = opt.label; + if (opt.value === op) o.selected = true; + opSel.appendChild(o); + }); + if (!opSel.value && opSel.options.length > 0) opSel.value = opSel.options[0].value; + valueWrap.innerHTML = ''; + valueWrap.appendChild(createConditionValueControl(field, source, opSel.value, value)); + }; + + refreshBuilder(fieldSel.value || 'status', opSel.value || '', ''); + fieldSel.onchange = () => refreshBuilder(fieldSel.value, '', ''); + opSel.onchange = () => { + const currentEl = valueWrap.querySelector('[data-field="cond_value"]'); + let val = ''; + if (currentEl && currentEl.multiple) { + val = Array.from(currentEl.selectedOptions).map(o => o.value).filter(Boolean); + } else if (currentEl) { + val = currentEl.value ?? ''; + } + refreshBuilder(fieldSel.value, opSel.value, val); + }; + + addBtn.onclick = () => onAdd(); +} + +function readConditionBuilder(body) { + const builder = body.querySelector('[data-field="conditions_builder"]'); + if (!builder) return null; + const field = builder.querySelector('[data-field="cond_builder_field"]')?.value || ''; + const op = builder.querySelector('[data-field="cond_builder_op"]')?.value || ''; + const valueEl = builder.querySelector('[data-field="cond_value"]'); + let value = ''; + if (valueEl && valueEl.multiple) { + value = Array.from(valueEl.selectedOptions).map(o => o.value).filter(Boolean); + } else if (valueEl) { + value = valueEl.value ?? ''; + } + if (field === 'min_size') { + value = Number(value || 0); + } + if (!field) return null; + return { field, op, value, enabled: true }; +} + +function saveRuleModal() { + const modal = UI.qs('#ruleModal'); + const body = UI.qs('#ruleModalBody'); + if (!modal || !body) return; + if (state.pluginEditType) { + savePluginModal(body); + setDirty(true); + closeRuleModal(); + return; + } + if (state.rootEditId !== null) { + const type = body.querySelector('[data-field="root_type"]')?.value || 'movie'; + const path = body.querySelector('[data-field="root_path"]')?.value?.trim() || ''; + const enabled = body.querySelector('[data-field="root_enabled"]')?.value === '1'; + const current = state.rootsList.find(r => r.id === state.rootEditId); + if (current) { + current.type = type; + current.path = path; + current.enabled = enabled; + } else { + state.rootsList.push({ + id: state.rootEditId, + type, + path, + enabled, + }); + } + renderRootsList(); + setDirty(true); + closeRuleModal(); + return; + } + if (state.toolEditType) { + const path = body.querySelector('[data-field="tool_path"]')?.value?.trim() || ''; + updateToolPath(state.toolEditType, path); + setDirty(true); + closeRuleModal(); + return; + } + const id = state.ruleEditId; + const type = state.ruleEditType; + const current = state.rulesList.find(r => r.id === id) || normalizeRule({ type }); + + const getVal = (field) => body.querySelector(`[data-field="${field}"]`)?.value ?? ''; + const name = getVal('name').trim(); + const enabled = getVal('enabled') === '1'; + const config = {}; + + if (type === 'name_map') { + config.pattern = getVal('pattern').trim(); + config.canonical = getVal('canonical').trim(); + config.mode = getVal('mode') || 'exact'; + } else if (type === 'delete_track') { + config.track_type = getVal('track_type').trim(); + config.lang = getVal('lang').trim(); + config.audio_type = getVal('audio_type').trim(); + config.name_contains = getVal('name_contains').trim(); + config.except_default = getVal('except_default') === '1'; + config.except_forced = getVal('except_forced') === '1'; + } else if (type === 'priorities') { + config.languages = commaToList(getVal('languages')); + config.audio_types = commaToList(getVal('audio_types')); + } else if (type === 'lang_fix') { + config.from_lang = getVal('from_lang').trim(); + config.to_lang = getVal('to_lang').trim(); + config.mode = getVal('mode') || 'exact'; + } else if (type === 'source_filter') { + config.source = getVal('source').trim(); + config.conditions = readConditionsTable(body); + } + + const updated = { + id: current.id, + type, + name, + enabled, + config, + }; + + const idx = state.rulesList.findIndex(r => r.id === current.id); + if (idx >= 0) { + state.rulesList[idx] = updated; + } else { + state.rulesList.push(updated); + } + renderRulesList(); + setDirty(true); + closeRuleModal(); +} + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, c => ( + c === '&' ? '&' : + c === '<' ? '<' : + c === '>' ? '>' : + c === '"' ? '"' : ''' + )); +} + +function cellText(value) { + const td = document.createElement('td'); + td.textContent = value ?? ''; + return td; +} + +async function loadProfiles() { + const data = await api('/api/scan-profiles', 'GET'); + state.profiles = data; + renderProfiles(); +} + +function initTableControllers() { + if (!window.TableController) return; + const mode = state.ui.table_mode || 'pagination'; + const pageSize = state.ui.table_page_size || 50; + const getPrefs = (table) => { + const id = table?.dataset?.tableId || table?.id || ''; + const prefs = window.UserPrefs?.getTable?.(id) || {}; + return { id, prefs }; + }; + + const profilesTable = UI.qs('#profilesTable'); + if (profilesTable) { + const { id, prefs } = getPrefs(profilesTable); + state.tables.profiles = new TableController({ + table: profilesTable, + mode, + pageSize, + sort: prefs.sort || '', + dir: prefs.dir || 'asc', + onSortChange: (sort, dir) => window.UserPrefs?.setTable?.(id, { sort, dir }), + fetchPage: ({ page, per_page, sort, dir }) => { + const sorted = sortItems(state.profiles, sort, dir, { + name: p => (p.name || '').toLowerCase(), + }); + return Promise.resolve(paginateItems(sorted, page, per_page)); + }, + renderRow: renderProfileRow, + }); + } + + const rootsTable = UI.qs('#rootsTable'); + if (rootsTable) { + const { id, prefs } = getPrefs(rootsTable); + state.tables.roots = new TableController({ + table: rootsTable, + mode, + pageSize, + sort: prefs.sort || '', + dir: prefs.dir || 'asc', + onSortChange: (sort, dir) => window.UserPrefs?.setTable?.(id, { sort, dir }), + fetchPage: ({ page, per_page, sort, dir }) => { + const sorted = sortItems(state.rootsList, sort, dir, { + path: r => (r.path || '').toLowerCase(), + }); + return Promise.resolve(paginateItems(sorted, page, per_page)); + }, + renderRow: renderRootRow, + }); + } + + const toolsTable = UI.qs('#toolsTable'); + if (toolsTable) { + const { id, prefs } = getPrefs(toolsTable); + state.tables.tools = new TableController({ + table: toolsTable, + mode, + pageSize, + sort: prefs.sort || '', + dir: prefs.dir || 'asc', + onSortChange: (sort, dir) => window.UserPrefs?.setTable?.(id, { sort, dir }), + fetchPage: ({ page, per_page, sort, dir }) => { + const sorted = sortItems(state.toolsList, sort, dir, { + name: t => (t.label || '').toLowerCase(), + }); + return Promise.resolve(paginateItems(sorted, page, per_page)); + }, + renderRow: renderToolRow, + }); + } + + const rulesTable = UI.qs('#rulesTable'); + if (rulesTable) { + const { id, prefs } = getPrefs(rulesTable); + state.tables.rules = new TableController({ + table: rulesTable, + mode, + pageSize, + sort: prefs.sort || '', + dir: prefs.dir || 'asc', + onSortChange: (sort, dir) => window.UserPrefs?.setTable?.(id, { sort, dir }), + fetchPage: ({ page, per_page, sort, dir }) => { + const sortKey = sort || state.ruleSort.field; + const sortDir = dir || state.ruleSort.dir; + const sorted = sortItems(state.rulesList, sortKey, sortDir, { + name: r => (r.name || '').toLowerCase(), + type: r => (r.type || '').toLowerCase(), + }); + return Promise.resolve(paginateItems(sorted, page, per_page)); + }, + renderRow: renderRuleRow, + }); + } + + const pluginsTable = UI.qs('#pluginsTable'); + if (pluginsTable) { + const { id, prefs } = getPrefs(pluginsTable); + state.tables.plugins = new TableController({ + table: pluginsTable, + mode, + pageSize, + sort: prefs.sort || '', + dir: prefs.dir || 'asc', + onSortChange: (sort, dir) => window.UserPrefs?.setTable?.(id, { sort, dir }), + fetchPage: ({ page, per_page, sort, dir }) => { + const list = buildPluginsList(); + const sorted = sortItems(list, sort, dir, { + name: p => (p.label || '').toLowerCase(), + }); + return Promise.resolve(paginateItems(sorted, page, per_page)); + }, + renderRow: renderPluginRow, + }); + } + + const tasksTable = UI.qs('#tasksTable'); + if (tasksTable) { + const { id, prefs } = getPrefs(tasksTable); + state.tables.tasks = new TableController({ + table: tasksTable, + mode, + pageSize, + sort: prefs.sort || '', + dir: prefs.dir || 'asc', + onSortChange: (sort, dir) => window.UserPrefs?.setTable?.(id, { sort, dir }), + fetchPage: ({ page, per_page, sort, dir }) => { + const sorted = sortItems(state.tasksList, sort, dir, { + name: t => (t.name || '').toLowerCase(), + }); + return Promise.resolve(paginateItems(sorted, page, per_page)); + }, + renderRow: renderTaskRow, + }); + } +} + +/* --------------------------- + Actions +--------------------------- */ + +async function doSave() { + const prevRetention = Number(state.settings?.logs?.retention_days ?? 7); + const payload = collectSettingsFromUI(); + const newRetention = Number(payload.logs?.retention_days ?? 7); + if (prevRetention > 0 && newRetention > 0 && newRetention < prevRetention) { + if (!confirm(t('settings.logs.retention_warn', 'Older logs will be deleted. Continue?'))) { + return; + } + } + setStatus(t('common.saving', 'Saving…')); + await api('/api/settings', 'POST', payload); + setDirty(false); + await loadSettings(); + setStatus(t('common.saved', 'Saved')); +} + +async function generatePreview() { + const box = UI.qs('#previewBox'); + if (!box) return; + + box.textContent = t('common.generating_preview', 'Generating preview…'); + + try { + const movies = await api('/api/layout/preview', 'POST', { kind: 'movies', mode: 'samples', limit: 10 }); + const series = await api('/api/layout/preview', 'POST', { kind: 'series', mode: 'samples', limit: 10 }); + + let txt = ''; + txt += `${t('settings.preview.movies','MOVIES')}\n`; + for (const ex of movies.examples || []) { + txt += `- ${ex.input.title} (${ex.input.year ?? t('common.na','n/a')}) -> ${ex.output_abs}\n`; + } + txt += `\n${t('settings.preview.series','SERIES')}\n`; + for (const ex of series.examples || []) { + txt += `- ${ex.input.title} (${ex.input.year ?? t('common.na','n/a')}) -> ${ex.output_abs}\n`; + } + + box.textContent = txt; + } catch (e) { + box.textContent = `${t('common.error','Error')}: ${e.message}`; + } +} + +async function loadDebugStats() { + const filesEl = UI.qs('#dbgContentFiles'); + if (!filesEl) return; + console.log('[debug] loadDebugStats: start'); + try { + const data = await api('/api/debug/stats', 'GET'); + console.log('[debug] loadDebugStats: response', data); + const content = data?.content || {}; + const db = data?.db || {}; + const info = data?.db_info || {}; + UI.qs('#dbgContentFiles').textContent = String(content.files ?? 0); + UI.qs('#dbgContentMeta').textContent = String(content.meta ?? 0); + UI.qs('#dbgContentItems').textContent = String(content.items ?? 0); + UI.qs('#dbgDbTables').textContent = String(db.tables ?? 0); + UI.qs('#dbgDbSize').textContent = UI.formatBytes(db.size_bytes ?? 0); + UI.qs('#dbgDbName').textContent = String(info.db_name || '-'); + UI.qs('#dbgDbUser').textContent = String(info.current_user || info.user_name || '-'); + console.log('[debug] loadDebugStats: db_info', info); + loadDebugTables(); + } catch (e) { + console.log('[debug] loadDebugStats: error', e); + UI.qs('#dbgContentFiles').textContent = '-'; + UI.qs('#dbgContentMeta').textContent = '-'; + UI.qs('#dbgContentItems').textContent = '-'; + UI.qs('#dbgDbTables').textContent = '-'; + UI.qs('#dbgDbSize').textContent = '-'; + UI.qs('#dbgDbName').textContent = '-'; + UI.qs('#dbgDbUser').textContent = '-'; + const body = UI.qs('#dbgDbTablesList tbody'); + if (body) body.innerHTML = ''; + const preview = UI.qs('#dbgDbTablePreview'); + if (preview) preview.textContent = ''; + } +} + +async function loadDebugTables() { + const table = UI.qs('#dbgDbTablesList'); + if (!table) return; + const body = table.querySelector('tbody'); + const status = UI.qs('#dbgDbTablesStatus'); + if (body) body.innerHTML = ''; + if (status) status.textContent = t('common.loading', 'Loading…'); + console.log('[debug] loadDebugTables: start'); + try { + const data = await api('/api/debug/db-tables', 'GET'); + const items = Array.isArray(data?.items) ? data.items : []; + if (status) status.textContent = `items: ${items.length}`; + console.log('[debug] loadDebugTables: items', items.length, items.slice(0, 3)); + if (body) { + if (items.length === 0) { + const row = document.createElement('tr'); + const cell = document.createElement('td'); + cell.colSpan = 2; + cell.textContent = t('common.empty', 'Empty'); + row.appendChild(cell); + body.appendChild(row); + } else { + items.forEach((item) => { + const row = document.createElement('tr'); + row.className = 'db-table-row'; + row.dataset.tableName = item.name || ''; + const name = document.createElement('td'); + name.textContent = item.name || ''; + const count = document.createElement('td'); + count.textContent = String(item.rows ?? 0); + row.appendChild(name); + row.appendChild(count); + body.appendChild(row); + }); + } + } + } catch (e) { + if (body) { + body.innerHTML = `${escapeHtml(e.message)}`; + } + if (status) status.textContent = `error: ${e.message}`; + } +} + +async function loadDebugTablePreview(tableName) { + const preview = UI.qs('#dbgDbTablePreview'); + if (!preview) return; + if (!tableName) { + preview.textContent = ''; + return; + } + preview.textContent = t('common.loading', 'Loading…'); + try { + const data = await api('/api/debug/db-table?name=' + encodeURIComponent(tableName) + '&limit=50', 'GET'); + const rows = Array.isArray(data?.rows) ? data.rows : []; + preview.textContent = rows.length === 0 + ? t('common.empty', 'Empty') + : JSON.stringify(rows, null, 2); + } catch (e) { + preview.textContent = `${t('common.error','Error')}: ${e.message}`; + } +} + +async function debugClearContent() { + UI.qs('#dbgContentOut').textContent = t('common.running', 'Running…'); + try { + await api('/api/debug/clear-content', 'POST', { confirm: UI.qs('#dbgContentConfirm').value.trim() }); + UI.qs('#dbgContentOut').textContent = 'OK'; + window.location.href = '/'; + } catch (e) { + UI.qs('#dbgContentOut').textContent = `${t('common.error','Error')}: ${e.message}`; + } +} + +async function debugResetDb() { + UI.qs('#dbgResetOut').textContent = t('common.running', 'Running…'); + try { + const data = await api('/api/debug/reset-db', 'POST', { confirm: UI.qs('#dbgResetConfirm').value.trim() }); + const log = data.log || []; + UI.qs('#dbgResetOut').textContent = log.join('\n'); + window.location.href = '/'; + } catch (e) { + UI.qs('#dbgResetOut').textContent = `${t('common.error','Error')}: ${e.message}`; + } +} + +async function debugDump() { + const out = UI.qs('#dbgDumpOut'); + out.textContent = t('common.running', 'Running…'); + try { + const res = await fetch('/api/debug/db-dump'); + if (!res.ok) { + throw new Error(`Request failed: ${res.status}`); + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + const a = document.createElement('a'); + a.href = url; + a.download = `scmedia_dump_${ts}.sql`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + out.textContent = 'OK'; + } catch (e) { + out.textContent = `${t('common.error','Error')}: ${e.message}`; + } +} + +async function debugRestore() { + const out = UI.qs('#dbgDumpOut'); + const file = UI.qs('#dbgRestoreFile')?.files?.[0]; + if (!file) { + out.textContent = `${t('common.error','Error')}: ${t('settings.debug.dump.restore', 'Restore dump')}`; + return; + } + const ok = confirm(t('settings.debug.dump.restore_confirm', 'Restore database from dump? This will overwrite current data.')); + if (!ok) return; + out.textContent = t('common.running', 'Running…'); + try { + const sql = await file.text(); + await api('/api/debug/db-restore', 'POST', { confirm: 'RESTORE DATABASE', sql }); + out.textContent = 'OK'; + window.location.href = '/'; + } catch (e) { + out.textContent = `${t('common.error','Error')}: ${e.message}`; + } +} + +function init() { + if (window.Auth?.requireAuth) { + window.Auth.requireAuth(); + } + enforceAdminAccess(); + window.UI?.initHeader?.(); + if (window.UserPrefs?.load) { + window.UserPrefs.load().then((prefs) => { + if (prefs?.theme && window.UI?.setTheme) { + window.UI.setTheme(prefs.theme); + } + if (prefs?.language && prefs.language !== (window.APP_LANG || 'en')) { + localStorage.setItem('scmedia_lang', prefs.language); + } + }).catch(() => {}); + } + i18n.dict = window.I18N || {}; + i18n.lang = window.APP_LANG || 'en'; + initTabs(); + setVersion(); + initProfileReorder(); + const logsType = UI.qs('#logsType'); + if (logsType) { + logsType.addEventListener('change', updateLogsTypeUI); + updateLogsTypeUI(); + } + updateAuditEventOptions([]); + const logsEvent = UI.qs('#logsFilterEvent'); + if (logsEvent) { + logsEvent.value = loadLogsEventFilter(); + logsEvent.addEventListener('change', async () => { + saveLogsEventFilter(logsEvent.value || 'all'); + if (!state.tables.audit) return; + const hint = UI.qs('#logsHint'); + if (hint) hint.textContent = t('common.loading', 'Loading…'); + try { + const from = UI.qs('#logsDateFrom').value || ''; + const to = UI.qs('#logsDateTo').value || ''; + state.auditItemsCache = new Map(); + clearAuditSelections(); + const filters = []; + if (from && to) { + filters.push({ key: 'created_at', op: 'between', value: [from, to] }); + } + const eventValue = logsEvent.value || 'all'; + if (eventValue !== 'all') { + filters.push({ key: 'action', op: 'eq', value: eventValue }); + } + state.tables.audit.setFilters(filters); + await state.tables.audit.load(1, false); + const total = Number(state.tables.audit.total || 0); + if (hint) hint.textContent = `${t('common.loaded', 'Loaded')} (${total})`; + } catch (e) { + if (hint) hint.textContent = `${t('common.error','Error')}: ${e.message}`; + } + }); + } + UI.qs('#btnSave')?.addEventListener('click', doSave); + UI.qs('#btnExport')?.addEventListener('click', () => window.open('/api/settings', '_blank')); + + UI.qs('#btnAddProfile')?.addEventListener('click', () => openProfileModal(null)); + UI.qs('#modalClose')?.addEventListener('click', closeProfileModal); + UI.qs('#modalSave')?.addEventListener('click', saveProfileModal); + UI.qs('#pExtMode')?.addEventListener('change', refreshExtCustomVisibility); + + UI.qs('#btnPreview')?.addEventListener('click', generatePreview); + UI.qs('#btnAddRoot')?.addEventListener('click', () => openRootModal(null)); + + UI.qs('#btnClearContent')?.addEventListener('click', debugClearContent); + UI.qs('#btnResetDb')?.addEventListener('click', debugResetDb); + UI.qs('#btnDbDump')?.addEventListener('click', debugDump); + UI.qs('#btnDbRestore')?.addEventListener('click', debugRestore); + const dbTable = UI.qs('#dbgDbTablesList'); + if (dbTable) { + dbTable.addEventListener('dblclick', (e) => { + const row = e.target.closest('tr[data-table-name]'); + if (!row) return; + loadDebugTablePreview(row.dataset.tableName || ''); + }); + } + UI.qs('#btnDetectTools')?.addEventListener('click', detectTools); + UI.qs('#btnCreateSnapshot')?.addEventListener('click', createSnapshot); + UI.qs('#btnLoadLogs')?.addEventListener('click', loadLogs); + UI.qs('#btnCleanupLogs')?.addEventListener('click', cleanupLogs); + UI.qs('#btnResetLogsDate')?.addEventListener('click', resetLogsDates); + UI.qs('#btnLogsCopy')?.addEventListener('click', () => copySelectedLogs().catch(() => {})); + UI.qs('#btnLogsSelectAll')?.addEventListener('click', () => selectAllLogsOnPage()); + initLogsTabs(); + initToolsUi(); + initPluginsUi(); + initTasksUi(); + initTaskModalRouting(); + + initRulesUi(); + + bindDirtyInputs(); + UI.initThemeToggle(); + UI.bindThemePreference?.(() => applyI18n()); + initUnsavedGuard(); + + state.settingsLoadPromise = loadSettings(); + Promise.all([state.settingsLoadPromise, loadProfiles(), loadDiagnostics(), loadSnapshots()]) + .then(() => { + initTableControllers(); + initLogsTable(); + renderProfiles(); + renderRootsList(); + renderToolsList(); + renderRulesList(); + renderPluginsList(); + renderTasksList(); + }) + .catch(e => setStatus(`${t('common.error','Error')}: ${e.message}`)); +} + +init(); + +function initUnsavedGuard() { + window.addEventListener('beforeunload', (e) => { + if (!state.dirty) return; + e.preventDefault(); + e.returnValue = ''; + }); + + document.querySelectorAll('a[href]').forEach(a => { + a.addEventListener('click', (e) => { + if (!state.dirty) return; + const ok = confirm(t('settings.unsaved_confirm', 'Unsaved changes. Leave settings?')); + if (!ok) { + e.preventDefault(); + } + }); + }); +} + +async function loadLogs() { + const type = UI.qs('#logsType')?.value || 'system'; + if (type === 'audit') { + await loadAuditLogs(); + return; + } + const from = UI.qs('#logsDateFrom').value; + const to = UI.qs('#logsDateTo').value; + const level = UI.qs('#logsFilterLevel').value || 'all'; + const hint = UI.qs('#logsHint'); + if (!from || !to) { + if (hint) hint.textContent = t('settings.logs.date_required', 'Select a date range'); + return; + } + if (hint) hint.textContent = t('common.loading', 'Loading…'); + try { + initLogsTable(); + if (!state.tables.logs) return; + state.logItemsCache = new Map(); + clearLogSelections(); + const filters = []; + if (from && to) { + filters.push({ key: 'ts', op: 'between', value: [from, to] }); + } + if (level && level !== 'all') { + filters.push({ key: 'level', op: 'eq', value: level }); + } + state.tables.logs.setFilters(filters); + await state.tables.logs.load(1, false); + const total = Number(state.tables.logs.total || 0); + if (hint) hint.textContent = `${t('common.loaded', 'Loaded')} (${total})`; + saveLogsViewDates(from, to); + } catch (e) { + if (hint) hint.textContent = `${t('common.error','Error')}: ${e.message}`; + } +} + +async function loadAuditLogs() { + const hint = UI.qs('#logsHint'); + if (hint) hint.textContent = t('common.loading', 'Loading…'); + try { + initAuditTable(); + if (!state.tables.audit) return; + state.auditItemsCache = new Map(); + clearAuditSelections(); + const from = UI.qs('#logsDateFrom').value || ''; + const to = UI.qs('#logsDateTo').value || ''; + const eventFilter = UI.qs('#logsFilterEvent').value || 'all'; + const filters = []; + if (from && to) { + filters.push({ key: 'created_at', op: 'between', value: [from, to] }); + } + if (eventFilter && eventFilter !== 'all') { + filters.push({ key: 'action', op: 'eq', value: eventFilter }); + } + state.tables.audit.setFilters(filters); + await state.tables.audit.load(1, false); + const total = Number(state.tables.audit.total || 0); + if (hint) hint.textContent = `${t('common.loaded', 'Loaded')} (${total})`; + } catch (e) { + if (hint) hint.textContent = `${t('common.error','Error')}: ${e.message}`; + } +} + +function updateAuditEventOptions(items) { + const sel = UI.qs('#logsFilterEvent'); + if (!sel) return; + const current = sel.value || 'all'; + const saved = loadLogsEventFilter(); + const fromItems = Array.from(new Set(items.map(x => x.action).filter(Boolean))); + const combined = [...AUDIT_EVENTS, ...fromItems.filter(x => !AUDIT_EVENTS.includes(x))]; + const options = ['all', ...combined]; + sel.innerHTML = options.map(v => { + if (v === 'all') { + return ``; + } + return ``; + }).join(''); + const preferred = current !== 'all' ? current : saved; + sel.value = options.includes(preferred) ? preferred : 'all'; +} + +function renderAuditLogs() {} + +function normalizeAuditDate(value) { + if (!value) return ''; + const s = String(value); + if (/^\d{2}\.\d{2}\.\d{4}/.test(s)) { + const parts = s.slice(0, 10).split('.'); + return `${parts[2]}-${parts[1]}-${parts[0]}`; + } + if (/^\d{4}-\d{2}-\d{2}/.test(s)) { + return s.slice(0, 10); + } + const d = new Date(s); + if (Number.isNaN(d.getTime())) return ''; + return d.toISOString().slice(0, 10); +} + +function formatLogContext(value) { + if (!value || value === 'null') { + return ''; + } + try { + return ' ' + JSON.stringify(JSON.parse(value)); + } catch (e) { + return ' ' + value; + } +} + +function logRowId(row) { + if (row?.id !== undefined && row?.id !== null) return String(row.id); + return `${row?.ts || ''}|${row?.level || ''}|${row?.message || ''}`; +} + +function updateLogSelectionCount() { + const el = UI.qs('#logsSelectionCount'); + if (!el) return; + const count = state.logSelected.size; + el.textContent = count ? `${t('settings.logs.selected', 'Selected')}: ${count}` : ''; +} + +function clearLogSelections() { + state.logSelected = new Map(); + updateLogSelectionCount(); +} + +function updateAuditSelectionCount() { + const el = UI.qs('#logsSelectionCount'); + if (!el) return; + const count = state.auditSelected.size; + el.textContent = count ? `${t('settings.logs.selected', 'Selected')}: ${count}` : ''; +} + +function clearAuditSelections() { + state.auditSelected = new Map(); + updateAuditSelectionCount(); +} + +function renderLogRow(body, row) { + const tr = document.createElement('tr'); + const id = logRowId(row); + state.logItemsCache.set(id, row); + + const selTd = document.createElement('td'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.dataset.logSelect = '1'; + checkbox.dataset.id = id; + checkbox.checked = state.logSelected.has(id); + selTd.appendChild(checkbox); + tr.appendChild(selTd); + + const ctx = (formatLogContext(row.context_json) || '').trim(); + tr.appendChild(cellText(row.ts || '')); + tr.appendChild(cellText(row.level || '')); + tr.appendChild(cellText(row.message || '')); + tr.appendChild(cellText(ctx)); + + body.appendChild(tr); +} + +function renderAuditRow(body, row) { + const tr = document.createElement('tr'); + const id = logRowId(row); + state.auditItemsCache.set(id, row); + + const selTd = document.createElement('td'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.dataset.auditSelect = '1'; + checkbox.dataset.id = id; + checkbox.checked = state.auditSelected.has(id); + selTd.appendChild(checkbox); + tr.appendChild(selTd); + + tr.appendChild(cellText(row.created_at || '')); + tr.appendChild(cellText(row.actor_email || row.actor_user_id || '')); + tr.appendChild(cellText(row.action || '')); + tr.appendChild(cellText(`${row.target_type || ''} ${row.target_id || ''}`.trim())); + tr.appendChild(cellText(row.meta_json || '')); + + body.appendChild(tr); +} + +function initLogsTable() { + const table = UI.qs('#logsTable'); + if (!table || !window.TableController) return; + const mode = state.ui.table_mode || 'pagination'; + const pageSize = state.ui.table_page_size || 50; + const id = table?.dataset?.tableId || table?.id || ''; + const prefs = window.UserPrefs?.getTable?.(id) || {}; + + state.tables.logs = new TableController({ + table, + mode, + pageSize, + sort: prefs.sort || '', + dir: prefs.dir || 'asc', + onSortChange: (sort, dir) => window.UserPrefs?.setTable?.(id, { sort, dir }), + fetchPage: async ({ page, per_page, sort, dir, filters }) => { + const data = await api('/api/logs', 'POST', { + page, + per_page, + sort, + dir, + filters, + }); + return { + items: data.items || [], + total: data.total || 0, + page: data.page || page, + per_page: data.per_page || per_page, + }; + }, + renderRow: renderLogRow, + }); + state.tables.logs.total = 0; + state.tables.logs.page = 1; + state.tables.logs.updateFooter(); + + const body = table.querySelector('tbody'); + if (body && !body.dataset.bindLogs) { + body.dataset.bindLogs = '1'; + body.addEventListener('change', (e) => { + const input = e.target; + if (!input || input.dataset.logSelect !== '1') return; + const id = input.dataset.id || ''; + if (!id) return; + if (input.checked) { + const row = state.logItemsCache.get(id); + if (row) state.logSelected.set(id, row); + } else { + state.logSelected.delete(id); + } + updateLogSelectionCount(); + }); + } +} + +function initAuditTable() { + const table = UI.qs('#adminAuditTable'); + if (!table || !window.TableController) return; + const mode = state.ui.table_mode || 'pagination'; + const pageSize = state.ui.table_page_size || 50; + const id = table?.dataset?.tableId || table?.id || ''; + const prefs = window.UserPrefs?.getTable?.(id) || {}; + + state.tables.audit = new TableController({ + table, + mode, + pageSize, + sort: prefs.sort || '', + dir: prefs.dir || 'asc', + onSortChange: (sort, dir) => window.UserPrefs?.setTable?.(id, { sort, dir }), + fetchPage: async ({ page, per_page, sort, dir, filters }) => { + const data = await api('/api/admin/audit', 'POST', { + page, + per_page, + sort, + dir, + filters, + }); + const items = data.items || []; + updateAuditEventOptions(items); + return { + items, + total: data.total || 0, + page: data.page || page, + per_page: data.per_page || per_page, + }; + }, + renderRow: renderAuditRow, + }); + state.tables.audit.total = 0; + state.tables.audit.page = 1; + state.tables.audit.updateFooter(); + + const body = table.querySelector('tbody'); + if (body && !body.dataset.bindAudit) { + body.dataset.bindAudit = '1'; + body.addEventListener('change', (e) => { + const input = e.target; + if (!input || input.dataset.auditSelect !== '1') return; + const id = input.dataset.id || ''; + if (!id) return; + if (input.checked) { + const row = state.auditItemsCache.get(id); + if (row) state.auditSelected.set(id, row); + } else { + state.auditSelected.delete(id); + } + updateAuditSelectionCount(); + }); + } +} + +async function copySelectedLogs() { + const type = UI.qs('#logsType')?.value || 'system'; + const items = type === 'audit' + ? Array.from(state.auditSelected.values()) + : Array.from(state.logSelected.values()); + if (!items.length) return; + const lines = items.map((r) => { + if (type === 'audit') { + return `${r.created_at || ''} | ${r.actor_email || r.actor_user_id || ''} | ${r.action || ''} | ${r.target_type || ''} ${r.target_id || ''} | ${r.meta_json || ''}`; + } + const ctx = formatLogContext(r.context_json); + return `${r.ts || ''} [${r.level || ''}] ${r.message || ''}${ctx}`; + }); + const text = lines.join('\n'); + try { + await navigator.clipboard.writeText(text); + } catch (e) { + const ta = document.createElement('textarea'); + ta.value = text; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + ta.remove(); + } +} + +function selectAllLogsOnPage() { + const type = UI.qs('#logsType')?.value || 'system'; + const body = type === 'audit' ? UI.qs('#adminAudit') : UI.qs('#logsTableBody'); + if (!body) return; + const inputs = Array.from(body.querySelectorAll(type === 'audit' ? 'input[data-audit-select="1"]' : 'input[data-log-select="1"]')); + if (!inputs.length) return; + const allSelected = inputs.every(input => input.checked); + inputs.forEach((input) => { + if (input.checked === !allSelected) return; + input.checked = !allSelected; + input.dispatchEvent(new Event('change', { bubbles: true })); + }); +} + +async function cleanupLogs() { + const hint = UI.qs('#logsSettingsHint'); + if (!confirm(t('settings.logs.delete_all_warn', 'Delete all logs?'))) { + return; + } + const keepDays = 0; + if (hint) hint.textContent = t('common.running', 'Running…'); + try { + const data = await api('/api/logs/cleanup', 'POST', { keep_days: keepDays }); + if (hint) hint.textContent = t('settings.logs.cleaned', 'Cleaned') + ` (${data.deleted || 0})`; + if (state.tables.logs?.tbody) { + state.tables.logs.tbody.innerHTML = ''; + } + if (state.tables.logs) { + state.tables.logs.total = 0; + state.tables.logs.page = 1; + state.tables.logs.updateFooter(); + } + clearLogSelections(); + } catch (e) { + if (hint) hint.textContent = `${t('common.error','Error')}: ${e.message}`; + } +} + +function initLogsTabs() { + const tabs = document.querySelectorAll('.logs-tabs .tab'); + const panes = document.querySelectorAll('.logs-pane'); + tabs.forEach(btn => { + btn.addEventListener('click', () => { + tabs.forEach(x => x.classList.remove('active')); + panes.forEach(x => x.classList.remove('active')); + btn.classList.add('active'); + const target = btn.dataset.logsTab || 'view'; + UI.qs(`#logs-pane-${target}`).classList.add('active'); + }); + }); +} + +function initLogsViewDates() { + const saved = loadLogsViewDates(); + if (saved) { + UI.qs('#logsDateFrom').value = saved.from; + UI.qs('#logsDateTo').value = saved.to; + return; + } + resetLogsDates(); +} + +function resetLogsDates() { + const today = new Date(); + const yyyy = String(today.getFullYear()); + const mm = String(today.getMonth() + 1).padStart(2, '0'); + const dd = String(today.getDate()).padStart(2, '0'); + const d = `${yyyy}-${mm}-${dd}`; + UI.qs('#logsDateFrom').value = d; + UI.qs('#logsDateTo').value = d; + saveLogsViewDates(d, d); + if (state.tables.logs?.tbody) { + state.tables.logs.tbody.innerHTML = ''; + } + if (state.tables.logs) { + state.tables.logs.total = 0; + state.tables.logs.page = 1; + state.tables.logs.updateFooter(); + } + clearLogSelections(); + clearAuditSelections(); + const hint = UI.qs('#logsHint'); + if (hint) hint.textContent = ''; +} + +function updateLogsTypeUI() { + const sel = UI.qs('#logsType'); + const type = sel ? sel.value : 'system'; + const levelControls = UI.qs('#logsControlsLevel'); + const eventControls = UI.qs('#logsControlsEvent'); + const systemActions = UI.qs('#logsControlsSystemActions'); + const output = UI.qs('#logsOutputWrap'); + const auditWrap = UI.qs('#logsAuditWrap'); + const auditBody = UI.qs('#adminAudit'); + const hint = UI.qs('#logsHint'); + + const isAudit = type === 'audit'; + if (levelControls) levelControls.classList.toggle('is-hidden', isAudit); + if (eventControls) eventControls.classList.toggle('is-hidden', !isAudit); + if (systemActions) systemActions.classList.toggle('is-hidden', false); + if (output) output.classList.toggle('is-hidden', isAudit); + if (auditWrap) auditWrap.classList.toggle('is-hidden', !isAudit); + if (isAudit) { + state.auditItems = []; + clearLogSelections(); + if (auditBody) auditBody.innerHTML = ''; + if (hint) hint.textContent = ''; + updateAuditSelectionCount(); + } else { + updateLogSelectionCount(); + } +} + +function saveLogsViewDates(from, to) { + state.logsView.from = from; + state.logsView.to = to; + localStorage.setItem('scmedia_logs_from', from); + localStorage.setItem('scmedia_logs_to', to); +} + +function loadLogsViewDates() { + const from = localStorage.getItem('scmedia_logs_from') || ''; + const to = localStorage.getItem('scmedia_logs_to') || ''; + if (!from || !to) return null; + return { from, to }; +} + +function saveLogsEventFilter(value) { + localStorage.setItem('scmedia_logs_event', value || 'all'); +} + +function loadLogsEventFilter() { + return localStorage.getItem('scmedia_logs_event') || 'all'; +} + +async function detectTools() { + if (state.settingsLoadPromise) { + await state.settingsLoadPromise; + } + const hint = UI.qs('#toolsHint'); + if (hint) hint.textContent = t('common.running', 'Running…'); + try { + const data = await api('/api/tools/detect-binaries', 'GET'); + state.toolsList = buildToolsList(data || {}); + renderToolsList(); + setDirty(true); + if (hint) hint.textContent = t('settings.tools.detected', 'Detected'); + } catch (e) { + if (hint) hint.textContent = `${t('common.error','Error')}: ${e.message}`; + } +} + +function buildToolsList(tools) { + const list = []; + if (tools.mkvmerge_path) list.push({ type: 'mkvmerge', label: 'mkvmerge', path: tools.mkvmerge_path }); + if (tools.mkvpropedit_path) list.push({ type: 'mkvpropedit', label: 'mkvpropedit', path: tools.mkvpropedit_path }); + if (tools.ffmpeg_path) list.push({ type: 'ffmpeg', label: 'ffmpeg', path: tools.ffmpeg_path }); + return list; +} + +function toolPathByType(type) { + const t = state.toolsList.find(x => x.type === type); + return t ? t.path : ''; +} + +function updateToolPath(type, path) { + const existing = state.toolsList.find(x => x.type === type); + if (path === '' && existing) { + state.toolsList = state.toolsList.filter(x => x.type !== type); + } else if (existing) { + existing.path = path; + } else if (path !== '') { + state.toolsList.push({ type, label: type, path }); + } + renderToolsList(); +} + +function renderToolRow(tbody, tl) { + const tr = document.createElement('tr'); + const tdName = document.createElement('td'); + tdName.textContent = t(`settings.tools.${tl.type}`, tl.label); + const tdPath = document.createElement('td'); + tdPath.textContent = tl.path || ''; + const tdActions = document.createElement('td'); + const btnEdit = document.createElement('button'); + btnEdit.className = 'btn'; + btnEdit.textContent = t('common.edit', 'Edit'); + btnEdit.addEventListener('click', () => openToolModal(tl.type)); + const btnRemove = document.createElement('button'); + btnRemove.className = 'btn'; + btnRemove.textContent = t('common.delete', 'Delete'); + btnRemove.addEventListener('click', () => { + updateToolPath(tl.type, ''); + setDirty(true); + }); + tdActions.appendChild(btnEdit); + tdActions.appendChild(btnRemove); + tr.appendChild(tdName); + tr.appendChild(tdPath); + tr.appendChild(tdActions); + tbody.appendChild(tr); +} + +function renderToolsList() { + const table = state.tables.tools; + if (table) { + table.reload(); + return; + } + const tbody = UI.qs('#toolsTable tbody'); + if (!tbody) return; + tbody.innerHTML = ''; + state.toolsList.forEach(tl => renderToolRow(tbody, tl)); +} + +function initToolsUi() { + const btnAdd = UI.qs('#btnAddTool'); + const menu = UI.qs('#toolTypeMenu'); + if (btnAdd && menu) { + btnAdd.addEventListener('click', (e) => { + e.preventDefault(); + btnAdd.parentElement?.classList.toggle('open'); + }); + menu.querySelectorAll('button[data-tool-type]').forEach(btn => { + btn.addEventListener('click', () => { + const type = btn.dataset.toolType || 'mkvmerge'; + btnAdd.parentElement?.classList.remove('open'); + openToolModal(type); + }); + }); + document.addEventListener('click', (e) => { + if (!btnAdd.parentElement?.contains(e.target)) { + btnAdd.parentElement?.classList.remove('open'); + } + }); + } +} + +function initTasksUi() { + const btnAdd = UI.qs('#btnAddTask'); + if (!btnAdd) return; + btnAdd.addEventListener('click', () => openTaskModal(null)); +} + +function openTaskModal(id = null) { + const modal = UI.qs('#ruleModal'); + const body = UI.qs('#ruleModalBody'); + const title = UI.qs('#ruleModalTitle'); + if (!modal || !body || !title) return; + + state.ruleEditId = null; + state.ruleEditType = null; + state.toolEditType = null; + state.taskEditId = (id !== null && id !== undefined) ? id : 'new'; + + const task = id ? state.tasksList.find(t => t.id === id) : null; + const current = task || normalizeTask({}); + + title.textContent = t('settings.tasks.modal_title', 'Task'); + const checked = (list, value) => list.includes(value) ? 'checked' : ''; + + body.innerHTML = ` +
+ + +
+
${t('settings.tasks.field_sources', 'Sources')}
+
+ + + +
+
+
+
${t('settings.tasks.field_actions', 'Actions')}
+
+ + + + + +
+
+
+ `; + const enabledSel = body.querySelector('[data-field="task_enabled"]'); + if (enabledSel) enabledSel.value = current.enabled ? '1' : '0'; + + modal.style.display = 'flex'; +} + +function saveTaskModal(body) { + const name = body.querySelector('[data-field="task_name"]')?.value?.trim() || ''; + const enabled = body.querySelector('[data-field="task_enabled"]')?.value === '1'; + const sources = []; + if (body.querySelector('[data-field="src_library"]')?.checked) sources.push('library'); + if (body.querySelector('[data-field="src_transmission"]')?.checked) sources.push('transmission'); + if (body.querySelector('[data-field="src_staging"]')?.checked) sources.push('staging'); + const actions = []; + if (body.querySelector('[data-field="act_analyze"]')?.checked) actions.push('analyze'); + if (body.querySelector('[data-field="act_identify"]')?.checked) actions.push('identify'); + if (body.querySelector('[data-field="act_normalize"]')?.checked) actions.push('normalize'); + if (body.querySelector('[data-field="act_rename"]')?.checked) actions.push('rename'); + if (body.querySelector('[data-field="act_export"]')?.checked) actions.push('export'); + + const updated = { + id: state.taskEditId || normalizeTask({}).id, + name, + enabled, + sources, + actions, + }; + + const idx = state.tasksList.findIndex(t => t.id === updated.id); + if (idx >= 0) { + state.tasksList[idx] = updated; + } else { + state.tasksList.push(updated); + } + renderTasksList(); + setDirty(true); + closeRuleModal(); +} +function openToolModal(type) { + const modal = UI.qs('#ruleModal'); + const body = UI.qs('#ruleModalBody'); + const title = UI.qs('#ruleModalTitle'); + if (!modal || !body || !title) return; + state.ruleEditId = null; + state.ruleEditType = null; + state.toolEditType = type; + title.textContent = t('settings.tools.modal_title', 'Tool'); + const current = toolPathByType(type); + body.innerHTML = ` +
+ + +
+ `; + modal.style.display = 'flex'; +} + +function normalizePluginConfig() { + if (!state.pluginConfig) state.pluginConfig = { metadata: {}, exports: {}, sources: {} }; + const meta = state.pluginConfig.metadata || {}; + const exportsCfg = state.pluginConfig.exports || {}; + const sourcesCfg = state.pluginConfig.sources || {}; + + if (typeof meta.enabled !== 'boolean') meta.enabled = false; + if (!Array.isArray(meta.languages)) meta.languages = ['de','ru','en']; + if (!Array.isArray(meta.provider_priority)) meta.provider_priority = ['tvdb','omdb']; + if (!meta.providers) meta.providers = {}; + if (!meta.providers.omdb) meta.providers.omdb = { enabled: false, api_key: '', base_url: 'https://www.omdbapi.com/' }; + if (!meta.providers.tvdb) meta.providers.tvdb = { enabled: false, api_key: '', pin: '' }; + + if (!exportsCfg.kodi) exportsCfg.kodi = { enabled: false }; + if (!exportsCfg.jellyfin) exportsCfg.jellyfin = { enabled: false }; + + if (!sourcesCfg.transmission) { + sourcesCfg.transmission = { enabled: false, last_test_ok: false, last_test_at: null, protocol: 'http', host: '', port: 9091, path: '/transmission/rpc', username: '', password: '', display_fields: [] }; + } + if (typeof sourcesCfg.transmission.last_test_ok !== 'boolean') { + sourcesCfg.transmission.last_test_ok = false; + } + if (!Array.isArray(sourcesCfg.transmission.display_fields)) { + sourcesCfg.transmission.display_fields = []; + } + + state.pluginConfig.metadata = meta; + state.pluginConfig.exports = exportsCfg; + state.pluginConfig.sources = sourcesCfg; +} + +function renderPluginRow(tbody, p) { + const tr = document.createElement('tr'); + tr.className = `plugin-row${p.enabled ? '' : ' is-off'}`; + const tdName = document.createElement('td'); + tdName.textContent = p.label; + const tdType = document.createElement('td'); + tdType.textContent = p.kindLabel; + const tdStatus = document.createElement('td'); + tdStatus.textContent = p.enabled === null ? t('rules.status.on', 'On') : (p.enabled ? t('rules.status.on', 'On') : t('rules.status.off', 'Off')); + const tdActions = document.createElement('td'); + + const btnEdit = document.createElement('button'); + btnEdit.className = 'btn'; + btnEdit.textContent = t('common.edit', 'Edit'); + btnEdit.addEventListener('click', () => openPluginModal(p.type)); + tdActions.appendChild(btnEdit); + + if (p.enabled !== null) { + const btnToggle = document.createElement('button'); + btnToggle.className = 'btn'; + btnToggle.textContent = p.enabled ? t('rules.disable', 'Disable') : t('rules.enable', 'Enable'); + btnToggle.addEventListener('click', () => { + setPluginEnabled(p.type, !p.enabled); + renderPluginsList(); + setDirty(true); + }); + tdActions.appendChild(btnToggle); + } + + tr.appendChild(tdName); + tr.appendChild(tdType); + tr.appendChild(tdStatus); + tr.appendChild(tdActions); + tbody.appendChild(tr); +} + +function renderPluginsList() { + const table = state.tables.plugins; + if (table) { + table.reload(); + return; + } + const tbody = UI.qs('#pluginsTable tbody'); + if (!tbody) return; + tbody.innerHTML = ''; + + const items = buildPluginsList(); + items.forEach(p => renderPluginRow(tbody, p)); +} + +function buildPluginsList() { + const meta = state.pluginConfig?.metadata || {}; + const providers = meta.providers || {}; + const exportsCfg = state.pluginConfig?.exports || {}; + const sourcesCfg = state.pluginConfig?.sources || {}; + + return [ + { type: 'meta_settings', label: t('settings.plugins.meta_settings', 'Metadata settings'), kindLabel: t('settings.plugins.kind.meta', 'Metadata'), enabled: !!meta.enabled }, + { type: 'omdb', label: t('settings.plugins.omdb_label', 'IMDb (OMDb)'), kindLabel: t('settings.plugins.kind.meta', 'Metadata'), enabled: !!providers.omdb?.enabled && !!meta.enabled }, + { type: 'tvdb', label: t('settings.plugins.tvdb_label', 'TVDB'), kindLabel: t('settings.plugins.kind.meta', 'Metadata'), enabled: !!providers.tvdb?.enabled && !!meta.enabled }, + { type: 'kodi', label: t('settings.plugins.kodi_label', 'Kodi'), kindLabel: t('settings.plugins.kind.export', 'Export'), enabled: !!exportsCfg.kodi?.enabled }, + { type: 'jellyfin', label: t('settings.plugins.jellyfin_label', 'Jellyfin'), kindLabel: t('settings.plugins.kind.export', 'Export'), enabled: !!exportsCfg.jellyfin?.enabled }, + { type: 'transmission', label: t('settings.plugins.transmission_label', 'Transmission'), kindLabel: t('settings.plugins.kind.source', 'Source'), enabled: !!sourcesCfg.transmission?.enabled }, + ]; +} + +function ensureTransmissionRule() { + const existing = state.rulesList.find(r => r.type === 'source_filter' && r.config?.source === 'transmission'); + if (existing) { + if (!existing.enabled) existing.enabled = true; + return; + } + const rule = normalizeRule({ + type: 'source_filter', + name: 'Transmission (all)', + enabled: true, + config: { + source: 'transmission', + status: '', + label: '', + min_size: 0, + name_regex: '', + path_regex: '', + }, + }); + state.rulesList.push(rule); +} + +function setPluginEnabled(type, enabled) { + normalizePluginConfig(); + if ((type === 'omdb' || type === 'tvdb') && !state.pluginConfig.metadata.enabled && enabled) { + const hint = UI.qs('#pluginsHint'); + if (hint) hint.textContent = t('settings.plugins.requires_meta', 'Enable metadata settings first'); + return; + } + if (type === 'transmission' && enabled && !state.pluginConfig.sources.transmission.last_test_ok) { + const hint = UI.qs('#pluginsHint'); + if (hint) hint.textContent = t('settings.plugins.requires_test', 'Test connection first'); + return; + } + if (type === 'omdb') state.pluginConfig.metadata.providers.omdb.enabled = enabled; + if (type === 'tvdb') state.pluginConfig.metadata.providers.tvdb.enabled = enabled; + if (type === 'meta_settings') state.pluginConfig.metadata.enabled = enabled; + if (type === 'kodi') state.pluginConfig.exports.kodi.enabled = enabled; + if (type === 'jellyfin') state.pluginConfig.exports.jellyfin.enabled = enabled; + if (type === 'transmission') { + state.pluginConfig.sources.transmission.enabled = enabled; + if (enabled) { + ensureTransmissionRule(); + renderRulesList(); + setDirty(true); + } + } +} + +function initPluginsUi() { + const btnAdd = UI.qs('#btnAddPlugin'); + const fileInput = UI.qs('#pluginFileInput'); + if (btnAdd && fileInput) { + btnAdd.addEventListener('click', (e) => { + e.preventDefault(); + fileInput.click(); + }); + fileInput.addEventListener('change', () => { + // Placeholder only: no install yet + fileInput.value = ''; + const hint = UI.qs('#pluginsHint'); + if (hint) hint.textContent = t('settings.plugins.install_placeholder', 'Installer coming soon'); + }); + } +} + +function openPluginModal(type) { + const modal = UI.qs('#ruleModal'); + const body = UI.qs('#ruleModalBody'); + const title = UI.qs('#ruleModalTitle'); + if (!modal || !body || !title) return; + state.ruleEditId = null; + state.ruleEditType = null; + state.toolEditType = null; + state.rootEditId = null; + + state.pluginEditType = type; + title.textContent = t('settings.plugins.modal_title', 'Plugin'); + body.innerHTML = renderPluginForm(type); + applyPluginFormValues(body, type); + modal.style.display = 'flex'; +} + +function renderPluginForm(type) { + const meta = state.pluginConfig?.metadata || {}; + const providers = meta.providers || {}; + const exportsCfg = state.pluginConfig?.exports || {}; + const sourcesCfg = state.pluginConfig?.sources || {}; + const tr = sourcesCfg.transmission || {}; + + if (type === 'meta_settings') { + return ` +
+ + + +
+ `; + } + + if (type === 'omdb') { + return ` +
+ + +
+ `; + } + + if (type === 'tvdb') { + return ` +
+ + + +
+ `; + } + + if (type === 'kodi' || type === 'jellyfin') { + const enabled = (type === 'kodi') ? exportsCfg.kodi?.enabled : exportsCfg.jellyfin?.enabled; + return ` +
+ +
${type === 'kodi' ? t('settings.plugins.kodi_hint','Writes movie.nfo / tvshow.nfo near files') : t('settings.plugins.jellyfin_hint','Writes movie.nfo / tvshow.nfo near files')}
+
+ `; + } + + if (type === 'transmission') { + return ` +
+ + + + + + + + +
+
+ +
+
+ `; + } + + return ''; +} + +function applyPluginFormValues(body, type) { + normalizePluginConfig(); + const meta = state.pluginConfig.metadata; + const providers = meta.providers || {}; + const exportsCfg = state.pluginConfig.exports; + const sourcesCfg = state.pluginConfig.sources; + + const setVal = (field, value) => { + const el = body.querySelector(`[data-field="${field}"]`); + if (el) el.value = value ?? ''; + }; + + if (type === 'meta_settings') { + setVal('enabled', meta.enabled ? '1' : '0'); + setVal('meta_languages', (meta.languages || []).join(',')); + setVal('meta_priority', (meta.provider_priority || []).join(',')); + } else if (type === 'omdb') { + setVal('enabled', providers.omdb?.enabled ? '1' : '0'); + setVal('api_key', providers.omdb?.api_key || ''); + } else if (type === 'tvdb') { + setVal('enabled', providers.tvdb?.enabled ? '1' : '0'); + setVal('api_key', providers.tvdb?.api_key || ''); + setVal('pin', providers.tvdb?.pin || ''); + } else if (type === 'kodi') { + setVal('enabled', exportsCfg.kodi?.enabled ? '1' : '0'); + } else if (type === 'jellyfin') { + setVal('enabled', exportsCfg.jellyfin?.enabled ? '1' : '0'); + } else if (type === 'transmission') { + const tr = sourcesCfg.transmission || {}; + setVal('enabled', tr.enabled ? '1' : '0'); + setVal('protocol', tr.protocol || 'http'); + setVal('host', tr.host || ''); + setVal('port', String(tr.port ?? 9091)); + setVal('path', tr.path || '/transmission/rpc'); + setVal('username', tr.username || ''); + setVal('password', tr.password || ''); + setVal('display_fields', (tr.display_fields || []).join(',')); + const btn = body.querySelector('#btnPluginTest'); + if (btn) { + btn.addEventListener('click', async () => { + const hint = body.querySelector('#pluginTestHint'); + if (hint) hint.textContent = t('common.testing', 'Testing…'); + clearFieldErrors(body, ['host','port','path']); + const payload = { transmission: readTransmissionFields(body) }; + const missing = []; + if (!payload.transmission.host) missing.push('host'); + if (!payload.transmission.port) missing.push('port'); + if (!payload.transmission.path) missing.push('path'); + if (missing.length > 0) { + markFieldErrors(body, missing); + if (hint) hint.textContent = t('settings.plugins.transmission_missing', 'Fill required fields'); + return; + } + try { + await api('/api/sources/test', 'POST', payload); + state.pluginConfig.sources.transmission.last_test_ok = true; + state.pluginConfig.sources.transmission.last_test_at = new Date().toISOString(); + renderPluginsList(); + setDirty(true); + if (hint) hint.textContent = t('settings.plugins.transmission_ok', 'OK'); + } catch (e) { + state.pluginConfig.sources.transmission.last_test_ok = false; + state.pluginConfig.sources.transmission.last_test_at = new Date().toISOString(); + renderPluginsList(); + setDirty(true); + const msg = normalizeTransmissionErrorMessage(e.message); + if (hint) hint.textContent = `${t('common.error','Error')}: ${msg}`; + } + }); + } + } +} + +function normalizeTransmissionErrorMessage(message) { + if (!message) return t('settings.plugins.transmission_rpc_failed', 'RPC failed. Check address or auth.'); + if (message === 'unauthorized') { + return t('settings.plugins.transmission_unauthorized', 'Unauthorized. Check RPC username/password.'); + } + if (message === 'forbidden') { + return t('settings.plugins.transmission_forbidden', 'Access denied. Check whitelist or credentials.'); + } + if (message === 'rpc failed') { + return t('settings.plugins.transmission_rpc_failed', 'RPC failed. Check address or auth.'); + } + return message; +} + +function isTaskModalOpen() { + return state.taskEditId !== null; +} + +function savePluginModal(body) { + normalizePluginConfig(); + const type = state.pluginEditType; + if (!type) return; + const meta = state.pluginConfig.metadata; + const providers = meta.providers || {}; + const exportsCfg = state.pluginConfig.exports; + const sourcesCfg = state.pluginConfig.sources; + + const getVal = (field) => body.querySelector(`[data-field="${field}"]`)?.value ?? ''; + + if (type === 'meta_settings') { + meta.enabled = getVal('enabled') === '1'; + meta.languages = commaToList(getVal('meta_languages')); + meta.provider_priority = commaToList(getVal('meta_priority')); + } else if (type === 'omdb') { + providers.omdb = providers.omdb || {}; + providers.omdb.enabled = getVal('enabled') === '1'; + providers.omdb.api_key = getVal('api_key').trim(); + providers.omdb.base_url = providers.omdb.base_url || 'https://www.omdbapi.com/'; + } else if (type === 'tvdb') { + providers.tvdb = providers.tvdb || {}; + providers.tvdb.enabled = getVal('enabled') === '1'; + providers.tvdb.api_key = getVal('api_key').trim(); + providers.tvdb.pin = getVal('pin').trim(); + } else if (type === 'kodi') { + exportsCfg.kodi.enabled = getVal('enabled') === '1'; + } else if (type === 'jellyfin') { + exportsCfg.jellyfin.enabled = getVal('enabled') === '1'; + } else if (type === 'transmission') { + sourcesCfg.transmission = readTransmissionFields(body); + if (sourcesCfg.transmission.enabled && !sourcesCfg.transmission.last_test_ok) { + sourcesCfg.transmission.enabled = false; + const hint = UI.qs('#pluginsHint'); + if (hint) hint.textContent = t('settings.plugins.requires_test', 'Test connection first'); + } + } + + state.pluginConfig.metadata = meta; + state.pluginConfig.exports = exportsCfg; + state.pluginConfig.sources = sourcesCfg; + renderPluginsList(); +} + +function readTransmissionFields(body) { + return { + enabled: body.querySelector('[data-field="enabled"]')?.value === '1', + last_test_ok: state.pluginConfig?.sources?.transmission?.last_test_ok ?? false, + last_test_at: state.pluginConfig?.sources?.transmission?.last_test_at ?? null, + protocol: body.querySelector('[data-field="protocol"]')?.value || 'http', + host: body.querySelector('[data-field="host"]')?.value?.trim() || '', + port: Number(body.querySelector('[data-field="port"]')?.value || 9091), + path: body.querySelector('[data-field="path"]')?.value?.trim() || '/transmission/rpc', + username: body.querySelector('[data-field="username"]')?.value?.trim() || '', + password: body.querySelector('[data-field="password"]')?.value ?? '', + display_fields: commaToList(body.querySelector('[data-field="display_fields"]')?.value ?? ''), + }; +} + +function markFieldErrors(body, fields) { + fields.forEach(f => { + const el = body.querySelector(`[data-field="${f}"]`); + if (el) el.classList.add('input-error'); + }); +} + +function clearFieldErrors(body, fields) { + fields.forEach(f => { + const el = body.querySelector(`[data-field="${f}"]`); + if (el) el.classList.remove('input-error'); + }); +} + +function buildRootsList(paths) { + const roots = Array.isArray(paths.roots) ? paths.roots : []; + if (roots.length > 0) { + return roots.map(r => normalizeRoot(r)); + } + const out = []; + if (paths.movies_root) out.push(normalizeRoot({ type: 'movie', path: paths.movies_root, enabled: true })); + if (paths.series_root) out.push(normalizeRoot({ type: 'series', path: paths.series_root, enabled: true })); + if (paths.staging_root) out.push(normalizeRoot({ type: 'staging', path: paths.staging_root, enabled: true })); + return out; +} + +function normalizeRoot(r) { + return { + id: r?.id || `root_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + type: r?.type || 'movie', + path: r?.path || '', + enabled: r?.enabled !== false, + }; +} + +function renderRootRow(tbody, r) { + const tr = document.createElement('tr'); + const tdType = document.createElement('td'); + tdType.textContent = rootTypeLabel(r.type); + const tdPath = document.createElement('td'); + tdPath.textContent = r.path || ''; + const tdStatus = document.createElement('td'); + tdStatus.textContent = r.enabled ? t('rules.status.on', 'On') : t('rules.status.off', 'Off'); + const tdActions = document.createElement('td'); + const btnEdit = document.createElement('button'); + btnEdit.className = 'btn'; + btnEdit.textContent = t('common.edit', 'Edit'); + btnEdit.addEventListener('click', () => openRootModal(r.id)); + const btnTest = document.createElement('button'); + btnTest.className = 'btn'; + btnTest.textContent = t('common.test', 'Test'); + btnTest.addEventListener('click', () => testRootPath(r)); + const btnDel = document.createElement('button'); + btnDel.className = 'btn'; + btnDel.textContent = t('common.delete', 'Delete'); + btnDel.addEventListener('click', () => { + if (!confirm(t('settings.library_layout.confirm_delete', 'Delete root?'))) return; + state.rootsList = state.rootsList.filter(x => x.id !== r.id); + renderRootsList(); + setDirty(true); + }); + tdActions.appendChild(btnEdit); + tdActions.appendChild(btnTest); + tdActions.appendChild(btnDel); + + tr.appendChild(tdType); + tr.appendChild(tdPath); + tr.appendChild(tdStatus); + tr.appendChild(tdActions); + tbody.appendChild(tr); +} + +function renderRootsList() { + const table = state.tables.roots; + if (table) { + table.reload(); + return; + } + const tbody = UI.qs('#rootsTable tbody'); + if (!tbody) return; + tbody.innerHTML = ''; + state.rootsList.forEach(r => renderRootRow(tbody, r)); +} + +function rootTypeLabel(type) { + if (type === 'movie') return t('root.type.movie', 'Movie'); + if (type === 'series') return t('root.type.series', 'Series'); + if (type === 'staging') return t('root.type.staging', 'Staging'); + return type || ''; +} + +function openRootModal(id = null) { + const modal = UI.qs('#ruleModal'); + const body = UI.qs('#ruleModalBody'); + const title = UI.qs('#ruleModalTitle'); + if (!modal || !body || !title) return; + state.ruleEditId = null; + state.ruleEditType = null; + state.toolEditType = null; + + const root = id ? state.rootsList.find(r => r.id === id) : null; + const data = root ? { ...root } : normalizeRoot({}); + state.rootEditId = data.id; + + title.textContent = t('settings.library_layout.modal_title', 'Root'); + body.innerHTML = ` +
+ + + +
+ `; + const typeSel = body.querySelector('[data-field="root_type"]'); + const enabledSel = body.querySelector('[data-field="root_enabled"]'); + if (typeSel) typeSel.value = data.type; + if (enabledSel) enabledSel.value = data.enabled ? '1' : '0'; + modal.style.display = 'flex'; +} + +function buildPathsPayload() { + const roots = state.rootsList || []; + const movie = roots.find(r => r.type === 'movie' && r.enabled); + const series = roots.find(r => r.type === 'series' && r.enabled); + const staging = roots.find(r => r.type === 'staging' && r.enabled); + return { + movies_root: movie?.path || '', + series_root: series?.path || '', + staging_root: staging?.path || '', + roots, + }; +} + +async function testRootPath(root) { + const hint = UI.qs('#rootsHint'); + if (hint) hint.textContent = t('common.testing', 'Testing…'); + try { + const data = await api('/api/tools/test-path', 'POST', { path: root.path, checks: ['exists','read','write','rename'] }); + const r = data.results || {}; + const txt = `${t('common.exists','exists')}=${r.exists} ${t('common.read','read')}=${r.read} ${t('common.write','write')}=${r.write} ${t('common.rename','rename')}=${r.rename} ${(data.notes || []).join('; ')}`; + if (hint) hint.textContent = txt; + } catch (e) { + if (hint) hint.textContent = `${t('common.error','Error')}: ${e.message}`; + } +} diff --git a/public/assets/js/sse.js b/public/assets/js/sse.js new file mode 100644 index 0000000..b9dddaa --- /dev/null +++ b/public/assets/js/sse.js @@ -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 }; +})(); diff --git a/public/assets/js/table.js b/public/assets/js/table.js new file mode 100644 index 0000000..aa9b251 --- /dev/null +++ b/public/assets/js/table.js @@ -0,0 +1,835 @@ +// public/assets/table.js +// Shared table controller for pagination/infinite mode + +class TableController { + constructor(opts) { + this.table = opts.table; + this.tbody = this.table.querySelector('tbody'); + this.wrap = this.table.closest('.table-wrap'); + this.footer = this.wrap?.querySelector('[data-table-footer]'); + this.metaEl = this.wrap?.querySelector('[data-table-meta]'); + this.pageEl = this.wrap?.querySelector('[data-table-page]'); + this.prevBtn = this.wrap?.querySelector('[data-table-prev]'); + this.nextBtn = this.wrap?.querySelector('[data-table-next]'); + this.renderRow = opts.renderRow; + this.fetchPage = opts.fetchPage; + this.mode = opts.mode || 'pagination'; + this.pageSize = Number(opts.pageSize || 50); + this.page = 1; + this.total = 0; + this.sort = opts.sort || ''; + this.dir = opts.dir || 'asc'; + this.onSortChange = opts.onSortChange || null; + this.params = opts.params || {}; + this.filters = Array.isArray(opts.filters) ? opts.filters : []; + this.loading = false; + + this.attachSortHandlers(); + this.attachPagerHandlers(); + this.initFilters(); + this.initViewEditor(); + this.defaultColumns = this.buildColumnsFromHeaders([]); + const tableId = this.getTableId(); + if (tableId && window.UserPrefs?.getTable) { + const prefs = window.UserPrefs.getTable(tableId); + if (prefs && prefs.columns) { + this.applyColumnPrefs(prefs); + } + } + } + + setMode(mode) { + this.mode = mode === 'infinite' ? 'infinite' : 'pagination'; + } + + setPageSize(size) { + this.pageSize = Math.max(1, Number(size || 50)); + } + + setParams(params) { + this.params = params || {}; + } + + setFilters(filters) { + this.filters = Array.isArray(filters) ? filters : []; + this.syncFilterInputs(); + } + + setSort(sort, dir) { + this.sort = sort || ''; + this.dir = dir === 'desc' ? 'desc' : 'asc'; + } + + attachSortHandlers() { + const headers = this.table.querySelectorAll('thead th[data-sort]'); + headers.forEach(th => { + th.classList.add('sortable'); + th.addEventListener('click', () => { + const key = th.dataset.sort || ''; + if (!key) return; + if (this.sort === key) { + this.dir = (this.dir === 'asc') ? 'desc' : 'asc'; + } else { + this.sort = key; + this.dir = 'asc'; + } + if (typeof this.onSortChange === 'function') { + this.onSortChange(this.sort, this.dir); + } + this.load(1, false); + }); + }); + } + + attachPagerHandlers() { + if (this.prevBtn) { + this.prevBtn.addEventListener('click', () => { + if (this.page > 1) this.load(this.page - 1, false); + }); + } + if (this.nextBtn) { + this.nextBtn.addEventListener('click', () => { + const totalPages = this.totalPages(); + if (this.page < totalPages) this.load(this.page + 1, false); + }); + } + + const handleScroll = () => { + if (this.mode !== 'infinite') return; + if (this.loading) return; + if (this.page >= this.totalPages()) return; + if (this.isWrapScrollable()) { + const nearBottom = this.wrap.scrollTop + this.wrap.clientHeight >= this.wrap.scrollHeight - 80; + if (nearBottom) this.load(this.page + 1, true); + } else { + const doc = document.documentElement; + const nearBottom = window.scrollY + window.innerHeight >= doc.scrollHeight - 120; + if (nearBottom) this.load(this.page + 1, true); + } + }; + + if (this.wrap) { + this.wrap.addEventListener('scroll', handleScroll); + } + window.addEventListener('scroll', handleScroll); + } + + initFilters() { + const headRow = this.table?.querySelector('thead tr'); + if (!headRow) return; + const filtersEnabled = this.table?.dataset?.filtersEnabled; + if (filtersEnabled === '0') { + const tools = this.wrap?.querySelector('[data-table-tools]'); + if (tools) tools.classList.add('is-hidden'); + return; + } + const ths = Array.from(headRow.querySelectorAll('th')); + const hasFilters = ths.some(th => th.dataset.filter); + const tools = this.wrap?.querySelector('[data-table-tools]'); + const toggleBtn = this.wrap?.querySelector('[data-table-filters-toggle]'); + const summaryEl = this.wrap?.querySelector('[data-table-filters-summary]'); + + if (!hasFilters) { + if (tools) tools.classList.add('is-hidden'); + return; + } + if (tools) tools.classList.remove('is-hidden'); + + const ensureIndicator = (th) => { + if (!th.classList.contains('has-filter')) { + th.classList.add('has-filter'); + } + if (!th.querySelector('.filter-indicator')) { + const dot = document.createElement('span'); + dot.className = 'filter-indicator'; + dot.setAttribute('aria-hidden', 'true'); + th.appendChild(dot); + } + }; + + ths.forEach((th) => { + if (th.dataset.filter) { + ensureIndicator(th); + } + }); + + let row = this.table.querySelector('thead tr[data-filter-row]'); + if (!row) { + row = document.createElement('tr'); + row.className = 'table-filter-row'; + row.dataset.filterRow = '1'; + headRow.insertAdjacentElement('afterend', row); + ths.forEach((th) => { + const td = document.createElement('td'); + const key = th.dataset.key || ''; + if (key) td.dataset.key = key; + row.appendChild(td); + }); + } + this.filterRow = row; + this.filterThs = ths; + this.filterSummaryEl = summaryEl; + + const parseFilter = (value) => { + if (!value) return null; + try { + const obj = JSON.parse(value); + if (obj && typeof obj === 'object') return obj; + } catch (e) { + return null; + } + return null; + }; + + const defaultOp = (meta, type) => { + if (Array.isArray(meta?.ops) && meta.ops.length > 0) return meta.ops[0]; + if (type === 'text') return 'like'; + return 'eq'; + }; + + const buildInput = (meta, key) => { + const type = meta?.type || 'text'; + const control = meta?.control || ''; + const isSelect = type === 'select' || control === 'select' || Array.isArray(meta?.options); + const ops = Array.isArray(meta?.ops) ? meta.ops : []; + const op = defaultOp(meta, type); + if (isSelect) { + const dict = window.I18N || {}; + const t = (i18nKey, fallback) => { + if (i18nKey && Object.prototype.hasOwnProperty.call(dict, i18nKey)) { + const v = dict[i18nKey]; + if (typeof v === 'string' && v.length) return v; + } + return fallback; + }; + const select = document.createElement('select'); + select.dataset.filterKey = key; + select.dataset.filterOp = op; + const optAny = document.createElement('option'); + optAny.value = ''; + optAny.textContent = t('filters.all', 'All'); + select.appendChild(optAny); + const options = Array.isArray(meta?.options) ? meta.options : []; + options.forEach((value) => { + const opt = document.createElement('option'); + if (value && typeof value === 'object') { + opt.value = String(value.value ?? ''); + opt.textContent = t(value.i18n || '', String(value.label ?? value.value ?? '')); + } else { + opt.value = String(value); + opt.textContent = String(value); + } + select.appendChild(opt); + }); + return select; + } + if (type === 'boolean') { + const select = document.createElement('select'); + select.dataset.filterKey = key; + select.dataset.filterOp = op; + const optAny = document.createElement('option'); + optAny.value = ''; + optAny.textContent = 'All'; + select.appendChild(optAny); + const optTrue = document.createElement('option'); + optTrue.value = 'true'; + optTrue.textContent = 'Yes'; + select.appendChild(optTrue); + const optFalse = document.createElement('option'); + optFalse.value = 'false'; + optFalse.textContent = 'No'; + select.appendChild(optFalse); + return select; + } + if ((type === 'number' || type === 'date') && ops.includes('between')) { + const wrap = document.createElement('div'); + wrap.className = 'table-filter-range'; + const inputFrom = document.createElement('input'); + const inputTo = document.createElement('input'); + inputFrom.type = type === 'date' ? 'date' : 'number'; + inputTo.type = type === 'date' ? 'date' : 'number'; + inputFrom.dataset.filterKey = key; + inputFrom.dataset.filterOp = 'between'; + inputFrom.dataset.filterRange = 'from'; + inputTo.dataset.filterKey = key; + inputTo.dataset.filterOp = 'between'; + inputTo.dataset.filterRange = 'to'; + wrap.appendChild(inputFrom); + wrap.appendChild(inputTo); + return wrap; + } + const input = document.createElement('input'); + input.type = type === 'number' ? 'number' : (type === 'date' ? 'date' : 'text'); + input.dataset.filterKey = key; + input.dataset.filterOp = op; + return input; + }; + + const cells = Array.from(row.children); + ths.forEach((th, idx) => { + const meta = parseFilter(th.dataset.filter || ''); + const key = th.dataset.key || ''; + const cell = cells[idx]; + if (!cell) return; + cell.innerHTML = ''; + if (!meta || key === '') return; + const control = buildInput(meta, key); + cell.appendChild(control); + }); + + let debounceId = null; + const readFilters = () => { + const filters = []; + const inputs = row.querySelectorAll('[data-filter-key]'); + const rangeMap = new Map(); + inputs.forEach((el) => { + const key = el.dataset.filterKey || ''; + const op = el.dataset.filterOp || 'eq'; + if (!key) return; + if (el.dataset.filterRange) { + const entry = rangeMap.get(key) || { from: '', to: '', op }; + if (el.dataset.filterRange === 'from') entry.from = el.value || ''; + if (el.dataset.filterRange === 'to') entry.to = el.value || ''; + rangeMap.set(key, entry); + return; + } + const value = el.value; + if (value === '' || value === null || value === undefined) return; + filters.push({ key, op, value }); + }); + rangeMap.forEach((entry, key) => { + if (entry.from && entry.to) { + filters.push({ key, op: 'between', value: [entry.from, entry.to] }); + } + }); + return filters; + }; + + const updateIndicators = (filters) => { + const active = new Set(filters.map(f => f.key)); + ths.forEach((th) => { + if (!th.dataset.filter) return; + const key = th.dataset.key || ''; + th.classList.toggle('filter-active', key !== '' && active.has(key)); + }); + if (summaryEl) { + summaryEl.textContent = filters.length ? `Filters: ${filters.length}` : ''; + } + }; + + const applyFilters = () => { + const filters = readFilters(); + this.setFilters(filters); + updateIndicators(filters); + this.load(1, false); + }; + + const handleInput = (e) => { + const target = e.target; + if (!target || !target.dataset.filterKey) return; + if (target.tagName === 'INPUT' && target.type === 'text') { + if (debounceId) clearTimeout(debounceId); + debounceId = setTimeout(applyFilters, 350); + return; + } + applyFilters(); + }; + + row.addEventListener('input', handleInput); + row.addEventListener('change', handleInput); + + if (toggleBtn && !toggleBtn.dataset.filtersInit) { + toggleBtn.dataset.filtersInit = '1'; + toggleBtn.addEventListener('click', () => { + if (!this.wrap) return; + this.wrap.classList.toggle('filters-open'); + }); + } + updateIndicators(this.filters); + } + + initViewEditor() { + const dialog = document.getElementById('dlg-table-view'); + const tools = this.wrap?.querySelector('[data-table-tools]'); + const viewBtn = this.wrap?.querySelector('[data-table-view-toggle]'); + if (!dialog || !viewBtn) return; + const hasConfig = this.table?.dataset?.viewConfig === '1'; + if (!hasConfig) { + if (viewBtn) viewBtn.classList.add('is-hidden'); + if (tools && !this.table?.querySelector('thead th[data-filter]')) { + tools.classList.add('is-hidden'); + } + return; + } + viewBtn.classList.remove('is-hidden'); + if (viewBtn.dataset.viewInit === '1') return; + viewBtn.dataset.viewInit = '1'; + + viewBtn.addEventListener('click', () => { + this.openViewEditor(dialog); + }); + } + + openViewEditor(dialog) { + if (!dialog) return; + const body = dialog.querySelector('[data-table-view-body]'); + if (!body) return; + const tableId = this.getTableId(); + const prefs = window.UserPrefs?.getTable?.(tableId) || {}; + const columns = this.buildColumnsFromHeaders(prefs.columns || []); + + body.innerHTML = ''; + columns.forEach((col, idx) => { + const tr = document.createElement('tr'); + tr.className = 'table-view-row'; + tr.dataset.key = col.key; + + const tdOn = document.createElement('td'); + const chk = document.createElement('input'); + chk.type = 'checkbox'; + chk.checked = col.visible !== false; + tdOn.appendChild(chk); + + const tdLabel = document.createElement('td'); + tdLabel.textContent = col.label; + + const tdWidth = document.createElement('td'); + const sel = document.createElement('select'); + const optAuto = document.createElement('option'); + optAuto.value = 'auto'; + optAuto.textContent = 'Auto'; + const optManual = document.createElement('option'); + optManual.value = 'manual'; + optManual.textContent = 'Manual'; + sel.appendChild(optAuto); + sel.appendChild(optManual); + const widthInput = document.createElement('input'); + widthInput.type = 'number'; + widthInput.min = '40'; + widthInput.max = '800'; + widthInput.value = col.width ? String(col.width) : ''; + const widthMode = col.width ? 'manual' : 'auto'; + sel.value = widthMode; + widthInput.disabled = widthMode !== 'manual'; + sel.addEventListener('change', () => { + widthInput.disabled = sel.value !== 'manual'; + }); + tdWidth.appendChild(sel); + tdWidth.appendChild(widthInput); + + const tdHeadAlign = document.createElement('td'); + const headAlign = document.createElement('select'); + ['left', 'center', 'right'].forEach((val) => { + const opt = document.createElement('option'); + opt.value = val; + opt.textContent = val; + headAlign.appendChild(opt); + }); + headAlign.value = col.headAlign || 'left'; + tdHeadAlign.appendChild(headAlign); + + const tdCellAlign = document.createElement('td'); + const cellAlign = document.createElement('select'); + ['left', 'center', 'right'].forEach((val) => { + const opt = document.createElement('option'); + opt.value = val; + opt.textContent = val; + cellAlign.appendChild(opt); + }); + cellAlign.value = col.cellAlign || 'left'; + tdCellAlign.appendChild(cellAlign); + + const tdOrder = document.createElement('td'); + const upBtn = document.createElement('button'); + upBtn.type = 'button'; + upBtn.className = 'btn'; + upBtn.textContent = '↑'; + const downBtn = document.createElement('button'); + downBtn.type = 'button'; + downBtn.className = 'btn'; + downBtn.textContent = '↓'; + upBtn.addEventListener('click', () => { + const prev = tr.previousElementSibling; + if (prev) prev.before(tr); + }); + downBtn.addEventListener('click', () => { + const next = tr.nextElementSibling; + if (next) next.after(tr); + }); + tdOrder.appendChild(upBtn); + tdOrder.appendChild(downBtn); + + tr.appendChild(tdOn); + tr.appendChild(tdLabel); + tr.appendChild(tdWidth); + tr.appendChild(tdHeadAlign); + tr.appendChild(tdCellAlign); + tr.appendChild(tdOrder); + body.appendChild(tr); + }); + + const applyBtn = dialog.querySelector('[data-table-view-apply]'); + const resetBtn = dialog.querySelector('[data-table-view-reset]'); + if (applyBtn && applyBtn.dataset.bound !== '1') { + applyBtn.dataset.bound = '1'; + applyBtn.addEventListener('click', () => { + const rows = Array.from(body.querySelectorAll('tr.table-view-row')); + const cols = rows.map((row) => { + const key = row.dataset.key || ''; + const inputs = row.querySelectorAll('input, select'); + const chk = inputs[0]; + const mode = inputs[1]; + const widthInput = inputs[2]; + const headAlign = inputs[3]; + const cellAlign = inputs[4]; + const width = mode && mode.value === 'manual' ? Number(widthInput.value || 0) : 0; + return { + key, + visible: chk?.checked !== false, + width: width > 0 ? width : 0, + headAlign: headAlign?.value || 'left', + cellAlign: cellAlign?.value || 'left', + }; + }).filter(c => c.key !== ''); + window.UserPrefs?.setTable?.(tableId, { columns: cols }); + this.applyColumnPrefs({ columns: cols }); + dialog.close(); + }); + } + if (resetBtn && resetBtn.dataset.bound !== '1') { + resetBtn.dataset.bound = '1'; + resetBtn.addEventListener('click', () => { + window.UserPrefs?.setTable?.(tableId, { columns: [] }); + this.applyColumnPrefs({ columns: [] }); + dialog.close(); + }); + } + dialog.showModal(); + } + + buildColumnsFromHeaders(saved) { + const headRow = this.table?.querySelector('thead tr'); + if (!headRow) return []; + const ths = Array.from(headRow.querySelectorAll('th')); + const base = ths.map((th) => ({ + key: th.dataset.key || '', + label: th.textContent?.trim() || '', + visible: true, + width: 0, + headAlign: th.classList.contains('align-right') ? 'right' : (th.classList.contains('align-center') ? 'center' : 'left'), + cellAlign: th.classList.contains('align-right') ? 'right' : (th.classList.contains('align-center') ? 'center' : 'left'), + })).filter(c => c.key !== ''); + + if (!Array.isArray(saved) || saved.length === 0) return base; + const map = new Map(saved.map(c => [c.key, c])); + const ordered = []; + saved.forEach((c) => { + const baseCol = base.find(b => b.key === c.key); + if (!baseCol) return; + ordered.push({ + ...baseCol, + visible: c.visible !== false, + width: Number(c.width || 0), + headAlign: c.headAlign || baseCol.headAlign || 'left', + cellAlign: c.cellAlign || baseCol.cellAlign || 'left', + }); + }); + base.forEach((c) => { + if (!map.has(c.key)) ordered.push(c); + }); + return ordered; + } + + getTableId() { + return this.table?.dataset?.tableId || this.table?.id || ''; + } + + applyColumnPrefs(prefs) { + let columns = Array.isArray(prefs?.columns) ? prefs.columns : []; + if (columns.length === 0 && Array.isArray(this.defaultColumns)) { + columns = this.defaultColumns.map(c => ({ ...c })); + } + if (columns.length === 0) return; + this.columnPrefs = columns; + const headRow = this.table?.querySelector('thead tr'); + if (!headRow) return; + const ths = Array.from(headRow.querySelectorAll('th')); + const keyToIndex = new Map(); + ths.forEach((th, idx) => { + const key = th.dataset.key || ''; + if (key) keyToIndex.set(key, idx); + }); + const order = columns.map(c => c.key).filter(k => keyToIndex.has(k)); + const used = new Set(order); + ths.forEach((th) => { + const key = th.dataset.key || ''; + if (key && !used.has(key)) order.push(key); + }); + this.columnOrder = order; + this.reorderColumns(order, ths.length, keyToIndex); + this.applyColumnStyles(); + } + + reorderColumns(order, count, keyToIndex) { + if (!this.table) return; + const rows = Array.from(this.table.querySelectorAll('tr')); + rows.forEach((row) => { + const cells = Array.from(row.children); + if (cells.length !== count) return; + const cellByKey = new Map(); + let hasKeys = false; + cells.forEach((cell) => { + const key = cell.dataset?.key || ''; + if (key) { + cellByKey.set(key, cell); + hasKeys = true; + } + }); + if (hasKeys) { + const newCells = []; + const used = new Set(); + order.forEach((key) => { + const cell = cellByKey.get(key); + if (cell) { + newCells.push(cell); + used.add(cell); + } + }); + cells.forEach((cell) => { + if (!used.has(cell)) newCells.push(cell); + }); + if (newCells.length !== cells.length) return; + newCells.forEach((cell) => row.appendChild(cell)); + return; + } + const newCells = []; + order.forEach((key) => { + const idx = keyToIndex.get(key); + if (idx >= 0 && cells[idx]) newCells.push(cells[idx]); + }); + if (newCells.length !== count) return; + newCells.forEach((cell) => row.appendChild(cell)); + }); + } + + applyColumnVisibility(columns, order) { + if (!this.table) return; + const visibility = new Map(columns.map(c => [c.key, c.visible !== false])); + const rows = Array.from(this.table.querySelectorAll('tr')); + rows.forEach((row) => { + const cells = Array.from(row.children); + if (cells.length !== order.length) return; + order.forEach((key, idx) => { + const visible = visibility.get(key); + if (visible === undefined) return; + cells[idx].classList.toggle('is-hidden', !visible); + }); + }); + } + + applyFilterVisibility(columns, order) { + const row = this.filterRow; + if (!row) return; + const visibility = new Map(columns.map(c => [c.key, c.visible !== false])); + const visibleKeys = new Set(columns.filter(c => c.visible !== false).map(c => c.key)); + const cells = Array.from(row.children); + cells.forEach((cell, idx) => { + const key = cell.dataset?.key || order[idx] || ''; + if (!key) return; + const visible = visibility.get(key); + if (visible === undefined) return; + cell.classList.toggle('is-hidden', !visible); + const inputs = cell.querySelectorAll('input, select, textarea, button'); + inputs.forEach((el) => { + el.disabled = !visible; + if (!visible && 'value' in el) el.value = ''; + }); + }); + + const before = Array.isArray(this.filters) ? this.filters : []; + const next = before.filter(f => visibleKeys.has(f.key)); + if (next.length !== before.length) { + this.filters = next; + this.syncFilterInputs(); + } + } + + applyColumnWidths(columns, order) { + if (!this.table) return; + const widths = new Map(columns.map(c => [c.key, Number(c.width || 0)])); + const rows = Array.from(this.table.querySelectorAll('tr')); + rows.forEach((row) => { + const cells = Array.from(row.children); + if (cells.length !== order.length) return; + order.forEach((key, idx) => { + const width = widths.get(key) || 0; + if (width > 0) { + cells[idx].style.width = `${width}px`; + } else { + cells[idx].style.width = ''; + } + }); + }); + this.table.style.tableLayout = 'fixed'; + } + + applyColumnStyles() { + const columns = Array.isArray(this.columnPrefs) ? this.columnPrefs : []; + const order = Array.isArray(this.columnOrder) ? this.columnOrder : []; + if (columns.length === 0 || order.length === 0) return; + const headRow = this.table?.querySelector('thead tr'); + if (headRow) { + const ths = Array.from(headRow.querySelectorAll('th')); + const keyToIndex = new Map(); + ths.forEach((th, idx) => { + const key = th.dataset.key || ''; + if (key) keyToIndex.set(key, idx); + }); + this.reorderColumns(order, ths.length, keyToIndex); + } + this.applyColumnVisibility(columns, order); + this.applyFilterVisibility(columns, order); + this.applyColumnWidths(columns, order); + this.applyColumnAlign(columns, order); + } + + applyColumnAlign(columns, order) { + if (!this.table) return; + const headAlign = new Map(columns.map(c => [c.key, c.headAlign || 'left'])); + const cellAlign = new Map(columns.map(c => [c.key, c.cellAlign || 'left'])); + const rows = Array.from(this.table.querySelectorAll('tr')); + rows.forEach((row) => { + const isHead = row.parentElement?.tagName === 'THEAD'; + const cells = Array.from(row.children); + if (cells.length !== order.length) return; + order.forEach((key, idx) => { + const align = (isHead ? headAlign.get(key) : cellAlign.get(key)) || 'left'; + const cell = cells[idx]; + if (!cell) return; + cell.classList.remove('align-left', 'align-center', 'align-right'); + cell.classList.add(`align-${align}`); + }); + }); + } + + syncFilterInputs() { + const row = this.filterRow; + if (!row) return; + const inputs = row.querySelectorAll('[data-filter-key]'); + const rangeMap = new Map(); + inputs.forEach((el) => { + if (el.dataset.filterRange) { + const key = el.dataset.filterKey || ''; + if (!key) return; + const entry = rangeMap.get(key) || { fromEl: null, toEl: null }; + if (el.dataset.filterRange === 'from') entry.fromEl = el; + if (el.dataset.filterRange === 'to') entry.toEl = el; + rangeMap.set(key, entry); + return; + } + el.value = ''; + }); + rangeMap.forEach((entry) => { + if (entry.fromEl) entry.fromEl.value = ''; + if (entry.toEl) entry.toEl.value = ''; + }); + const filters = Array.isArray(this.filters) ? this.filters : []; + filters.forEach((f) => { + const key = f?.key || ''; + if (!key) return; + if (f.op === 'between' && Array.isArray(f.value)) { + const entry = rangeMap.get(key); + if (entry?.fromEl) entry.fromEl.value = String(f.value[0] ?? ''); + if (entry?.toEl) entry.toEl.value = String(f.value[1] ?? ''); + return; + } + inputs.forEach((el) => { + if (el.dataset.filterKey !== key) return; + if (el.dataset.filterRange) return; + el.value = f.value ?? ''; + }); + }); + if (this.filterThs) { + const active = new Set(filters.map(f => f.key)); + this.filterThs.forEach((th) => { + if (!th.dataset.filter) return; + const key = th.dataset.key || ''; + th.classList.toggle('filter-active', key !== '' && active.has(key)); + }); + } + if (this.filterSummaryEl) { + this.filterSummaryEl.textContent = filters.length ? `Filters: ${filters.length}` : ''; + } + } + + isWrapScrollable() { + if (!this.wrap) return false; + return this.wrap.scrollHeight > this.wrap.clientHeight + 4; + } + + totalPages() { + return Math.max(1, Math.ceil((this.total || 0) / this.pageSize)); + } + + async load(page = 1, append = false) { + if (!this.fetchPage || !this.renderRow || !this.tbody) return; + if (this.loading) return; + this.loading = true; + + const payload = { + page, + per_page: this.pageSize, + sort: this.sort, + dir: this.dir, + filters: this.filters, + params: this.params, + }; + + try { + const res = await this.fetchPage(payload); + const items = res.items || []; + this.total = Number(res.total || 0); + this.page = Number(res.page || page); + if (!append) { + this.tbody.innerHTML = ''; + } + items.forEach(item => this.renderRow(this.tbody, item)); + this.applyColumnStyles(); + this.updateFooter(); + } finally { + this.loading = false; + } + } + + reload() { + return this.load(this.page, false); + } + + updateFooter() { + if (!this.footer) return; + if (this.mode === 'infinite') { + if (this.prevBtn) this.prevBtn.disabled = true; + if (this.nextBtn) this.nextBtn.disabled = true; + } else { + const totalPages = this.totalPages(); + if (this.prevBtn) this.prevBtn.disabled = this.page <= 1; + if (this.nextBtn) this.nextBtn.disabled = this.page >= totalPages; + } + + if (this.pageEl) { + const totalPages = this.totalPages(); + this.pageEl.textContent = `${this.page} / ${totalPages}`; + } + if (this.metaEl) { + const start = (this.page - 1) * this.pageSize + 1; + const end = Math.min(this.total, this.page * this.pageSize); + if (this.total === 0) { + this.metaEl.textContent = ''; + } else { + this.metaEl.textContent = `${start}-${end} / ${this.total}`; + } + } + } +} + +window.TableController = TableController; diff --git a/public/assets/js/ui.js b/public/assets/js/ui.js new file mode 100644 index 0000000..c222b87 --- /dev/null +++ b/public/assets/js/ui.js @@ -0,0 +1,343 @@ +// public/assets/ui.js +/* English comments: shared DOM + UI helpers */ + +(function () { + const LS_THEME = 'scmedia_theme'; + let themeToggleReady = false; + let queueState = { active: [], recent: [] }; + let sseOpening = false; + + function qs(selOrId) { + if (typeof selOrId !== 'string') return null; + const s = selOrId.trim(); + if (s === '') return null; + if (s[0] === '#' || s[0] === '.' || s[0] === '[' || s.includes(' ') || s.includes('>')) { + return document.querySelector(s); + } + return document.getElementById(s); + } + + function qsa(selector) { + return Array.from(document.querySelectorAll(selector)); + } + + function setTheme(theme) { + const mode = theme === 'light' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', mode); + localStorage.setItem(LS_THEME, mode); + } + + + function initThemeToggle() { + const btn = qs('themeToggle'); + const saved = localStorage.getItem(LS_THEME) || 'dark'; + setTheme(saved); + if (!btn || themeToggleReady) return; + btn.addEventListener('click', () => { + const current = document.documentElement.getAttribute('data-theme') || 'dark'; + setTheme(current === 'dark' ? 'light' : 'dark'); + }); + themeToggleReady = true; + } + + function formatBytes(bytes) { + const value = Number(bytes || 0); + if (!Number.isFinite(value) || value <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + let idx = 0; + let n = value; + while (n >= 1024 && idx < units.length - 1) { + n /= 1024; + idx += 1; + } + return `${n.toFixed(n >= 10 || idx === 0 ? 0 : 1)} ${units[idx]}`; + } + + window.UI = { + qs, + qsa, + setTheme, + initThemeToggle, + formatBytes, + }; + + window.UI.bindThemePreference = function (onChange) { + const btn = qs('themeToggle'); + if (!btn || btn.dataset.themePrefBind === '1') return; + btn.dataset.themePrefBind = '1'; + btn.addEventListener('click', () => { + const current = document.documentElement.getAttribute('data-theme') || 'dark'; + window.UserPrefs?.setUi?.('theme', current); + if (typeof onChange === 'function') { + onChange(current); + } + }); + }; + + window.UI.updateSseIndicator = function (status) { + const items = document.querySelectorAll('[data-sse-indicator]'); + items.forEach((el) => { + el.classList.remove('sse-ok', 'sse-idle', 'sse-offline'); + if (status === 'ok') el.classList.add('sse-ok'); + else if (status === 'idle') el.classList.add('sse-idle'); + else if (status === 'offline') el.classList.add('sse-offline'); + }); + }; + + window.UI.blinkSseIndicator = function () { + const items = document.querySelectorAll('[data-sse-indicator]'); + items.forEach((el) => { + el.classList.add('sse-blink'); + setTimeout(() => el.classList.remove('sse-blink'), 250); + }); + }; + + window.UI.startSseIndicatorPolling = function () { + const updateFromStorage = () => { + const last = Number(localStorage.getItem('scmedia_sse_last') || 0); + const connectedRaw = localStorage.getItem('scmedia_sse_connected'); + const connected = connectedRaw === '1'; + if (!last && !connectedRaw) { + const items = document.querySelectorAll('[data-sse-indicator]'); + items.forEach((el) => { + el.classList.remove('sse-ok', 'sse-idle', 'sse-offline'); + }); + return; + } + const recent = last > 0 && (Date.now() - last) <= 15000; + if (recent) { + window.UI.updateSseIndicator('ok'); + return; + } + if (connected) { + window.UI.updateSseIndicator('idle'); + return; + } + window.UI.updateSseIndicator('offline'); + }; + updateFromStorage(); + setInterval(updateFromStorage, 5000); + }; + window.UI.startSseClient = function () { + window.Sse?.start?.(); + }; + + window.UI.stopSseClient = function () { + window.Sse?.stop?.(); + }; + + window.UI.getSseStats = function () { + const connected = localStorage.getItem('scmedia_sse_connected') === '1'; + const last = Number(localStorage.getItem('scmedia_sse_last') || 0); + const lastType = localStorage.getItem('scmedia_sse_last_type') || '—'; + const reconnects = Number(localStorage.getItem('scmedia_sse_reconnects') || 0); + return { connected, last, lastType, reconnects }; + }; + + window.UI.initQueuePanel = function () { + const summary = qs('queueSummary'); + const menu = qs('queueMenu'); + if (!summary || !menu) return; + if (summary.dataset.queueInit === '1') return; + summary.dataset.queueInit = '1'; + + const t = (key, fallback) => { + const dict = window.I18N || {}; + const v = dict && Object.prototype.hasOwnProperty.call(dict, key) ? dict[key] : null; + return (typeof v === 'string' && v.length) ? v : fallback; + }; + + const updateQueueSummary = (data) => { + const activeEl = summary.querySelector('[data-queue-active]'); + const errorsEl = summary.querySelector('[data-queue-errors]'); + const dividerEl = summary.querySelector('[data-queue-divider]'); + if (activeEl || errorsEl) { + const activeLabel = activeEl?.dataset.queueActiveLabel || t('queue.active', 'Active'); + const errorsLabel = errorsEl?.dataset.queueErrorsLabel || t('queue.errors', 'Errors'); + const activeCount = Number(data.running || 0); + const errorsCount = Number(data.errors || 0); + if (activeEl) activeEl.textContent = `${activeLabel}: ${activeCount}`; + if (errorsEl) { + errorsEl.textContent = `${errorsLabel}: ${errorsCount}`; + errorsEl.classList.toggle('is-hidden', errorsCount <= 0); + } + if (dividerEl) dividerEl.classList.toggle('is-hidden', errorsCount <= 0); + } + }; + + const updateJobIndicator = (job) => { + const wrap = qs('job-indicator'); + const prog = qs('job-indicator-progress'); + if (!wrap || !prog) return; + if (!job) { + wrap.classList.add('hidden'); + return; + } + wrap.classList.remove('hidden'); + const total = Number(job.progress_total || 0); + const cur = Number(job.progress || 0); + const pct = total > 0 ? Math.floor((cur / total) * 100) : 0; + prog.max = 100; + prog.value = pct; + }; + + const isMenuOpen = () => !menu.classList.contains('is-hidden'); + + const cancelJobById = async (id) => { + if (!id) return; + if (!confirm(t('job.cancel_confirm', 'Cancel job?'))) return; + try { + await apiJson('/api/jobs/cancel', { + method: 'POST', + body: JSON.stringify({ id }), + headers: { 'Content-Type': 'application/json' }, + }); + } catch (e) { + // ignore + } + }; + + const renderQueueMenu = () => { + const activeWrap = qs('queueMenuActive'); + const finishedWrap = qs('queueMenuFinished'); + if (!activeWrap || !finishedWrap) return; + activeWrap.innerHTML = ''; + finishedWrap.innerHTML = ''; + + if (queueState.active.length === 0) { + const empty = document.createElement('div'); + empty.className = 'queue-item-status'; + empty.textContent = t('queue.none_active', 'No active tasks'); + activeWrap.appendChild(empty); + } else { + queueState.active.forEach(j => { + const row = document.createElement('div'); + row.className = 'queue-item'; + const title = document.createElement('div'); + const pct = j.progress_total > 0 ? Math.floor((j.progress / j.progress_total) * 100) : 0; + title.textContent = `${j.title || ''} (${pct}%)`; + const actions = document.createElement('div'); + actions.className = 'queue-item-actions'; + const btn = document.createElement('button'); + btn.className = 'btn'; + btn.textContent = t('queue.cancel', 'Cancel'); + btn.addEventListener('click', () => cancelJobById(j.id)); + actions.appendChild(btn); + row.appendChild(title); + row.appendChild(actions); + activeWrap.appendChild(row); + }); + } + + if (queueState.recent.length === 0) { + const empty = document.createElement('div'); + empty.className = 'queue-item-status'; + empty.textContent = t('queue.none_finished', 'No finished tasks'); + finishedWrap.appendChild(empty); + } else { + queueState.recent.forEach(j => { + const row = document.createElement('div'); + row.className = 'queue-item'; + const title = document.createElement('div'); + title.textContent = j.title || ''; + const status = document.createElement('div'); + status.className = 'queue-item-status'; + status.textContent = j.status || ''; + row.appendChild(title); + row.appendChild(status); + finishedWrap.appendChild(row); + }); + } + updateSseStats(); + }; + + const updateSseStats = () => { + const wrap = qs('queueSseStats'); + const stats = qs('queueSseStats')?.querySelector('.queue-stats'); + if (!wrap || !stats) return; + const enabled = window.DEBUG_TOOLS_ENABLED === true; + wrap.classList.toggle('is-hidden', !enabled); + if (!enabled) return; + const sse = window.UI.getSseStats(); + const lastAt = sse.last > 0 ? new Date(sse.last).toLocaleTimeString() : '—'; + stats.textContent = `last: ${lastAt} | type: ${sse.lastType} | reconnects: ${sse.reconnects}`; + }; + + const apiJson = async (path, opts = {}) => { + if (window.Api?.request) { + const res = await window.Api.request(path, opts); + return res?.data || res || null; + } + const token = window.Auth?.getAccessToken?.(); + const headers = { ...(opts.headers || {}) }; + if (token) headers.Authorization = `Bearer ${token}`; + const res = await fetch(path, { ...opts, headers }); + const json = await res.json().catch(() => null); + return json?.data || null; + }; + + const loadRecent = async () => { + // disabled: SSE-only mode + }; + + summary.addEventListener('click', () => { + menu.classList.toggle('is-hidden'); + if (!menu.classList.contains('is-hidden')) { + renderQueueMenu(); + } + }); + document.addEventListener('click', (e) => { + if (summary.contains(e.target) || menu.contains(e.target)) return; + menu.classList.add('is-hidden'); + }); + + updateQueueSummary({ running: 0, errors: 0 }); + updateSseStats(); + + if (window.Sse?.on) { + window.Sse.on('jobs', (payload) => { + if (!payload) return; + updateQueueSummary(payload); + updateJobIndicator(payload.active || null); + queueState.active = Array.isArray(payload.active_list) ? payload.active_list : []; + if (isMenuOpen()) renderQueueMenu(); + }); + window.Sse.on('tick', () => updateSseStats()); + } + }; + + window.UI.initHeader = function () { + if (window.Auth?.initHeaderControls) { + window.Auth.initHeaderControls(); + } + if (window.Auth?.initSessionRefresh) { + window.Auth.initSessionRefresh(); + } + window.UI.initThemeToggle(); + window.UI.startSseIndicatorPolling(); + window.UI.initQueuePanel(); + window.UI.startSseClient(); + const menu = document.querySelector('.topbar-menu'); + if (menu && menu.dataset.outsideClose !== '1') { + menu.dataset.outsideClose = '1'; + document.addEventListener('click', (e) => { + if (!menu.hasAttribute('open')) return; + const target = e.target; + if (target && menu.contains(target)) return; + menu.removeAttribute('open'); + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && menu.hasAttribute('open')) { + menu.removeAttribute('open'); + } + }); + } + if (window.Sse?.on && window.UI?.blinkSseIndicator) { + const blink = () => window.UI.blinkSseIndicator(); + window.Sse.on('message', blink); + window.Sse.on('jobs', blink); + window.Sse.on('sources', blink); + window.Sse.on('tick', blink); + } + }; +})(); diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..d3c8e26 --- /dev/null +++ b/public/index.php @@ -0,0 +1,10 @@ +run(); +$app = null; diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..20765c1 --- /dev/null +++ b/readme.md @@ -0,0 +1,19 @@ +# mediaprep (mvp) + +A minimal Jellyfin pre-processor for CephFS-based libraries. + +## Features (MVP) +- Scan profiles with configurable roots and filters +- Items table with status, structure, and counts +- Background scan jobs with logs/progress + +## Setup +1) Configure `.env` and prepare the database +2) Run scan: + php cli/scan.php +3) Run worker: + php cli/worker.php +4) Serve `public/` via nginx/apache and open in browser + +## Notes +- Worker executes scan jobs and updates progress/logs. diff --git a/sql/schema.sql b/sql/schema.sql new file mode 100644 index 0000000..e67a28e --- /dev/null +++ b/sql/schema.sql @@ -0,0 +1,461 @@ +SET NAMES utf8mb4; +SET time_zone = '+00:00'; + +-- ----------------------- +-- settings +-- ----------------------- +CREATE TABLE IF NOT EXISTS settings ( + `key` VARCHAR(64) NOT NULL, + value_json LONGTEXT NOT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------- +-- settings_snapshots +-- ----------------------- +CREATE TABLE IF NOT EXISTS settings_snapshots ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + label VARCHAR(255) NULL, + data_json LONGTEXT NOT NULL, + created_by BIGINT UNSIGNED NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------- +-- scan_profiles +-- ----------------------- +CREATE TABLE IF NOT EXISTS scan_profiles ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + sort_order INT NOT NULL DEFAULT 1000, + profile_type ENUM('scan','analyze') NOT NULL DEFAULT 'scan', + enabled TINYINT(1) NOT NULL DEFAULT 1, + name VARCHAR(128) NOT NULL, + root_path VARCHAR(1024) NOT NULL, + max_depth TINYINT UNSIGNED NOT NULL DEFAULT 3, + exclude_patterns_json LONGTEXT NOT NULL DEFAULT '[]', + include_ext_mode ENUM('default','custom') NOT NULL DEFAULT 'default', + include_ext_json LONGTEXT NULL, + last_scan_at DATETIME NULL, + last_result ENUM('ok','error','never') NOT NULL DEFAULT 'never', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_sort (sort_order), + KEY idx_enabled (enabled), + KEY idx_root_path (root_path(255)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------- +-- items +-- ----------------------- +CREATE TABLE IF NOT EXISTS items ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + + scan_profile_id INT UNSIGNED NOT NULL, + abs_path VARCHAR(2048) NOT NULL, + rel_path VARCHAR(2048) NULL, + + display_name VARCHAR(512) NOT NULL, + kind ENUM('auto','movie','series') NOT NULL DEFAULT 'auto', + year SMALLINT UNSIGNED NULL, + + structure ENUM('file','folder','dvd','bluray') NOT NULL DEFAULT 'folder', + confidence TINYINT UNSIGNED NOT NULL DEFAULT 0, + + video_count INT UNSIGNED NOT NULL DEFAULT 0, + file_count INT UNSIGNED NOT NULL DEFAULT 0, + + status ENUM('active','gone','ignored') NOT NULL DEFAULT 'active', + last_seen_at DATETIME NULL, + + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + PRIMARY KEY (id), + UNIQUE KEY uq_profile_path (scan_profile_id, abs_path(255)), + KEY idx_kind (kind), + KEY idx_status (status), + KEY idx_last_seen (last_seen_at), + CONSTRAINT fk_items_profile + FOREIGN KEY (scan_profile_id) REFERENCES scan_profiles(id) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------- +-- jobs +-- ----------------------- +CREATE TABLE IF NOT EXISTS jobs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + type ENUM('scan','apply','db_reset','clear_index','task') NOT NULL, + status ENUM('queued','running','done','error','canceled') NOT NULL DEFAULT 'queued', + + title VARCHAR(255) NOT NULL, + payload_json LONGTEXT NULL, + + progress_current BIGINT UNSIGNED NOT NULL DEFAULT 0, + progress_total BIGINT UNSIGNED NOT NULL DEFAULT 0, + progress_pct TINYINT UNSIGNED NOT NULL DEFAULT 0, + + cancel_requested TINYINT(1) NOT NULL DEFAULT 0, + last_heartbeat DATETIME NULL, + + error_message TEXT NULL, + + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + started_at DATETIME NULL, + finished_at DATETIME NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + PRIMARY KEY (id), + KEY idx_type (type), + KEY idx_status (status), + KEY idx_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------- +-- job_logs +-- ----------------------- +CREATE TABLE IF NOT EXISTS job_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + job_id BIGINT UNSIGNED NOT NULL, + ts DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + level ENUM('info','warn','error','debug') NOT NULL DEFAULT 'info', + message TEXT NOT NULL, + PRIMARY KEY (id), + KEY idx_job_ts (job_id, ts), + CONSTRAINT fk_job_logs_job + FOREIGN KEY (job_id) REFERENCES jobs(id) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------- +-- app_logs +-- ----------------------- +CREATE TABLE IF NOT EXISTS app_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + ts DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + level ENUM('info','warn','error','debug') NOT NULL DEFAULT 'info', + message TEXT NOT NULL, + context_json LONGTEXT NULL, + PRIMARY KEY (id), + KEY idx_app_logs_ts (ts), + KEY idx_app_logs_level (level) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------- +-- media_file_meta +-- ----------------------- +CREATE TABLE IF NOT EXISTS media_file_meta ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + abs_path VARCHAR(2048) NOT NULL, + size_bytes BIGINT UNSIGNED NOT NULL, + mtime INT UNSIGNED NOT NULL, + inode BIGINT UNSIGNED NOT NULL DEFAULT 0, + info_json LONGTEXT NULL, + last_scanned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_media_path (abs_path(255)), + KEY idx_media_mtime (mtime) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------- +-- media_files +-- ----------------------- +CREATE TABLE IF NOT EXISTS media_files ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + scan_profile_id INT UNSIGNED NOT NULL, + abs_path VARCHAR(2048) NOT NULL, + rel_path VARCHAR(2048) NOT NULL, + name VARCHAR(512) NOT NULL, + ext VARCHAR(16) NOT NULL, + size_bytes BIGINT UNSIGNED NOT NULL, + mtime INT UNSIGNED NOT NULL, + is_mkv TINYINT(1) NOT NULL DEFAULT 0, + kind ENUM('movie','series','unknown') NOT NULL DEFAULT 'unknown', + series_key VARCHAR(512) NULL, + container VARCHAR(64) NULL, + duration_ms BIGINT UNSIGNED NULL, + last_analyzed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_media_files_path (abs_path(255)), + KEY idx_media_files_profile (scan_profile_id), + KEY idx_media_files_kind (kind), + KEY idx_media_files_series (series_key(255)), + CONSTRAINT fk_media_files_profile + FOREIGN KEY (scan_profile_id) REFERENCES scan_profiles(id) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------- +-- media_metadata +-- ----------------------- +CREATE TABLE IF NOT EXISTS media_metadata ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + subject_kind ENUM('movie','series') NOT NULL, + subject_key VARCHAR(2048) NOT NULL, + provider VARCHAR(64) NULL, + provider_id VARCHAR(128) NULL, + title_map_json LONGTEXT NULL, + original_title VARCHAR(512) NULL, + year SMALLINT UNSIGNED NULL, + manual_title VARCHAR(512) NULL, + manual_year SMALLINT UNSIGNED NULL, + source ENUM('auto','manual') NOT NULL DEFAULT 'auto', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_media_meta_subject (subject_kind, subject_key(255)), + KEY idx_media_meta_provider (provider, provider_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------- +-- auth: users and roles +-- ----------------------- +CREATE TABLE IF NOT EXISTS users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + email VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + status ENUM('active','disabled') NOT NULL DEFAULT 'active', + token_version INT UNSIGNED NOT NULL DEFAULT 1, + last_login_at DATETIME NULL, + password_changed_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_users_email (email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_profiles ( + user_id BIGINT UNSIGNED NOT NULL, + nickname VARCHAR(128) NULL, + avatar_blob MEDIUMBLOB NULL, + avatar_mime VARCHAR(64) NULL, + ui_prefs_json LONGTEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (user_id), + CONSTRAINT fk_user_profiles_user + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS roles ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(64) NOT NULL, + description VARCHAR(255) NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_roles_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS permissions ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + `key` VARCHAR(128) NOT NULL, + description VARCHAR(255) NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_permissions_key (`key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS role_permissions ( + role_id INT UNSIGNED NOT NULL, + permission_id INT UNSIGNED NOT NULL, + PRIMARY KEY (role_id, permission_id), + CONSTRAINT fk_role_permissions_role + FOREIGN KEY (role_id) REFERENCES roles(id) + ON DELETE CASCADE, + CONSTRAINT fk_role_permissions_permission + FOREIGN KEY (permission_id) REFERENCES permissions(id) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_roles ( + user_id BIGINT UNSIGNED NOT NULL, + role_id INT UNSIGNED NOT NULL, + PRIMARY KEY (user_id, role_id), + CONSTRAINT fk_user_roles_user + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE CASCADE, + CONSTRAINT fk_user_roles_role + FOREIGN KEY (role_id) REFERENCES roles(id) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + device_id VARCHAR(64) NOT NULL, + token_hash CHAR(64) NOT NULL, + user_agent VARCHAR(255) NULL, + ip_addr VARCHAR(64) NULL, + expires_at DATETIME NOT NULL, + revoked_at DATETIME NULL, + rotated_from_id BIGINT UNSIGNED NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_refresh_token_hash (token_hash), + KEY idx_refresh_user (user_id), + KEY idx_refresh_expires (expires_at), + CONSTRAINT fk_refresh_user + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS auth_challenges ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + token_hash CHAR(64) NOT NULL, + expires_at DATETIME NOT NULL, + used_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_auth_challenge_hash (token_hash), + KEY idx_auth_challenge_user (user_id), + KEY idx_auth_challenge_expires (expires_at), + CONSTRAINT fk_auth_challenge_user + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS auth_rate_limits ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + ip_addr VARCHAR(64) NOT NULL, + action VARCHAR(32) NOT NULL, + failed_count INT UNSIGNED NOT NULL DEFAULT 0, + window_started_at DATETIME NOT NULL, + blocked_until DATETIME NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_auth_rate_key (ip_addr, action), + KEY idx_auth_rate_blocked (blocked_until) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS auth_sse_keys ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + key_hash CHAR(64) NOT NULL, + expires_at DATETIME NOT NULL, + used_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_sse_key_hash (key_hash), + KEY idx_sse_user (user_id), + KEY idx_sse_expires (expires_at), + CONSTRAINT fk_sse_user + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS auth_sse_sessions ( + user_id BIGINT UNSIGNED NOT NULL, + session_token CHAR(64) NOT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (user_id), + KEY idx_sse_session_updated (updated_at), + CONSTRAINT fk_sse_session_user + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS mfa_methods ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + type ENUM('totp') NOT NULL DEFAULT 'totp', + secret_enc VARCHAR(255) NOT NULL, + enabled_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_mfa_user (user_id), + CONSTRAINT fk_mfa_user + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS mfa_backup_codes ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + code_hash CHAR(64) NOT NULL, + used_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_mfa_backup_user (user_id), + CONSTRAINT fk_mfa_backup_user + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS password_resets ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + token_hash CHAR(64) NOT NULL, + expires_at DATETIME NOT NULL, + used_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_password_reset_hash (token_hash), + KEY idx_password_reset_user (user_id), + KEY idx_password_reset_expires (expires_at), + CONSTRAINT fk_password_reset_user + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS audit_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + actor_user_id BIGINT UNSIGNED NULL, + action VARCHAR(64) NOT NULL, + target_type VARCHAR(64) NULL, + target_id BIGINT UNSIGNED NULL, + ip_addr VARCHAR(64) NULL, + user_agent VARCHAR(255) NULL, + meta_json LONGTEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_audit_actor (actor_user_id), + KEY idx_audit_action (action), + CONSTRAINT fk_audit_actor + FOREIGN KEY (actor_user_id) REFERENCES users(id) + ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------- +-- Seed settings (minimal) +-- ----------------------- +INSERT IGNORE INTO settings (`key`, value_json) VALUES +('general', '{"timezone":"Europe/Berlin","language":"ru","safe_mode":false,"auto_refresh_seconds":0,"date_format":"YYYY-MM-DD"}'), +('scanner_defaults', '{"video_ext":["mkv","mp4","avi","mov","m4v","ts","m2ts","wmv"],"max_depth_default":3,"max_files_per_item":3000,"max_items_per_scan":0}'), +('paths', '{"movies_root":"","series_root":"","staging_root":""}'), +('tools', '{"mkvmerge_path":"","mkvpropedit_path":"","ffmpeg_path":""}'), +('logs', '{"retention_days":7,"level":"info"}'), +('layout', '{"movies":{"strategy":"prefix","params":{"n":2},"template":null},"series":{"strategy":"first_letter","params":{},"template":null,"season_naming":"season_2digit"},"normalization":{"ignore_articles":true,"transliterate_non_latin":true,"uppercase_shards":true,"replace_unsafe_chars":true,"trim_dots_spaces":true,"ignore_words":[]},"collision_policy":"stop"}'), +('media_rules', '{"name_map":[],"delete_rules":[],"language_priority":["ru","en"],"audio_type_priority":["dub","voiceover","original","commentary","unknown"],"require_audio_type":true,"series_order_threshold":0.7}'), +('rules', '[]'), +('sources', '{"transmission":{"enabled":false,"last_test_ok":false,"last_test_at":null,"protocol":"http","host":"","port":9091,"path":"/transmission/rpc","username":"","password":""}}'), +('metadata', '{"enabled":false,"languages":["de","ru","en"],"provider_priority":["tvdb","omdb"],"providers":{"omdb":{"enabled":false,"api_key":"","base_url":"https://www.omdbapi.com/"},"tvdb":{"enabled":false,"api_key":"","pin":""}}}'), +('exports', '{"kodi":{"enabled":false},"jellyfin":{"enabled":false}}'), +('background', '{"mode":"light","max_parallel_jobs":1,"max_network_jobs":1,"max_io_jobs":1,"batch_sleep_ms":500,"watchdog_minutes":10,"sse_session_ttl_seconds":20,"paused":false}'), +('ui', '{"sse_tick_seconds":10}'), +('safety', '{"max_depth":10,"max_files_per_item":200000,"max_items_per_scan":1000000}'), +('_system', '{"settings_revision":1,"first_run_completed":false}'); + +-- ----------------------- +-- Seed roles +-- ----------------------- +INSERT IGNORE INTO roles (id, name, description) VALUES +(1, 'admin', 'Full access'), +(2, 'manager', 'Manager access'), +(3, 'user', 'User access'); + +-- ----------------------- +-- Seed admin user +-- ----------------------- +INSERT IGNORE INTO users (id, email, password_hash, status, token_version, created_at, updated_at) +VALUES (1, 'admin@admin.local', '$2y$12$2op4ztskqxDR376xnZ6sR.Ccbi.zyPu3KRwJsDAPmeNf5B.ZjEcfC', 'active', 1, NOW(), NOW()); + +INSERT IGNORE INTO user_roles (user_id, role_id) +VALUES (1, 1);