diff --git a/FAQ.md b/FAQ.md index f15a7ef6..98e1103b 100644 --- a/FAQ.md +++ b/FAQ.md @@ -258,21 +258,23 @@ If there are no errors, the database has been repaired successfully. And you can # Which Providers id `GUIDs` supported by for PlexClient? -* tvdb://(id) `New plex agent` -* imdb://(id) `New plex agent` -* tmdb://(id) `New plex agent` -* com.plexapp.agents.imdb://(id)?lang=en `(Legacy plex agent)` -* com.plexapp.agents.tmdb://(id)?lang=en `(Legacy plex agent)` -* com.plexapp.agents.themoviedb://(id)?lang=en `(Legacy plex agent)` -* com.plexapp.agents.thetvdb://(seriesId)?lang=en `(Legacy plex agent)` -* com.plexapp.agents.xbmcnfo://(id)?lang=en `(XBMC NFO Movies agent)` -* com.plexapp.agents.xbmcnfotv://(id)?lang=en `(XBMC NFO TV agent)` +* tvdb://(id) +* imdb://(id) +* tmdb://(id) +* tv.plex.agents.nfo.movie://movie/(provider)_(id) `Where the provider one of supported providers` +* tv.plex.agents.nfo.series://(show|episode)/(provider)_(id) `Where the provider one of supported providers` + +## Legacy Plex Agents + +* com.plexapp.agents.imdb://(id)?lang=en +* com.plexapp.agents.tmdb://(id)?lang=en +* com.plexapp.agents.themoviedb://(id)?lang=en +* com.plexapp.agents.thetvdb://(seriesId)?lang=en +* com.plexapp.agents.xbmcnfo://(id)?lang=en `(XBMC NFO Movies agent) -> imdb|tmdb://(movieId)?lang=en` +* com.plexapp.agents.xbmcnfotv://(id)?lang=en `(XBMC NFO TV agent) -> tvdb://(serisId)?lang=en` * com.plexapp.agents.hama://(db)\d?-(id)?lang=en `(HAMA multi source db agent mainly for anime)` -* com.plexapp.agents.ytinforeader://(id) - ?lang=en [ytinforeader.bundle](https://github.com/arabcoders/plex-ytdlp-info-reader-agent) - With [jp_scanner.py](https://github.com/arabcoders/plex-daily-scanner) as scanner. -* com.plexapp.agents.cmdb://(id) - ?lang=en [cmdb.bundle](https://github.com/arabcoders/cmdb.bundle) `(User custom metadata database)`. +* com.plexapp.agents.ytinforeader://(id)?lang=en [ytinforeader.bundle](https://github.com/arabcoders/plex-ytdlp-info-reader-agent) With [jp_scanner.py](https://github.com/arabcoders/plex-daily-scanner) as scanner. +* com.plexapp.agents.cmdb://(id)?lang=en [cmdb.bundle](https://github.com/arabcoders/cmdb.bundle) `(User custom metadata database)`. --- diff --git a/composer.json b/composer.json index 35fa2272..6438eda2 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ ], "lint": "vendor/bin/mago lint", "frontend:update": "bun --cwd=./frontend/ update --latest", + "frontend:format": "bun --cwd=./frontend/ format", "frontend:gen": "bun --cwd=./frontend/ generate", "frontend:dev": "bun --cwd=./frontend/ dev", "frontend:tc": "bun --cwd=./frontend/ typecheck", diff --git a/composer.lock b/composer.lock index f542b6ba..2a6a2238 100644 --- a/composer.lock +++ b/composer.lock @@ -2126,7 +2126,7 @@ }, { "name": "symfony/polyfill-uuid", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", @@ -2185,7 +2185,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" }, "funding": [ { diff --git a/frontend/app/components/EventView.vue b/frontend/app/components/EventView.vue index ecb93a0c..2e2b03f9 100644 --- a/frontend/app/components/EventView.vue +++ b/frontend/app/components/EventView.vue @@ -210,14 +210,37 @@
+ :key="`${logLine.id}-${index}`" + class="block" + > + + + + {{ String(logLine.text).trim() }} +
@@ -280,10 +305,11 @@ import { createError, useHead } from '#app'; import moment from 'moment'; import { useStorage } from '@vueuse/core'; import { useDialog } from '~/composables/useDialog'; -import type { EventsItem, GenericError } from '~/types'; +import type { EventsItem, GenericError, LogEntry } from '~/types'; import { copyText, getEventStatusClass, + goto_history_item, makeEventName, notification, parse_api_response, @@ -320,14 +346,24 @@ watch(toggleFilter, () => { } }); -const filteredRows = computed>(() => { +const filteredRows = computed>(() => { + const rows = item.value.logs ?? []; + if (!query.value) { - return item.value.logs ?? []; + return rows; } - return item.value.logs?.filter((m) => m.toLowerCase().includes(query.value.toLowerCase())) ?? []; + const queryValue = query.value.toLowerCase(); + + return rows.filter((logLine) => logLine.text.toLowerCase().includes(queryValue)); }); +const formatLogLine = (logLine: LogEntry): string => { + const prefix = logLine.date ? `[${logLine.date}] ` : ''; + + return `${prefix}${String(logLine.text).trim()}`; +}; + const getEventStatusColor = (status: number) => { const value = getEventStatusClass(status); diff --git a/frontend/app/layouts/default.vue b/frontend/app/layouts/default.vue index 9ab7e85e..c77eea3a 100644 --- a/frontend/app/layouts/default.vue +++ b/frontend/app/layouts/default.vue @@ -259,6 +259,8 @@ type NavEntry = { label: string; icon: string; matchPath?: string; + exactMatch?: boolean; + excludeMatchPaths?: Array; to?: string; href?: string; target?: string; @@ -531,6 +533,25 @@ const isPathActive = (matchPath?: string) => { return current === target || current.startsWith(`${target}/`); }; +const isNavigationEntryActive = (entry: NavEntry) => { + if (!entry.matchPath) { + return false; + } + + const current = normalizePath(route.path); + const target = normalizePath(entry.matchPath); + + if (entry.excludeMatchPaths?.some((path) => isPathActive(path))) { + return false; + } + + if (true === entry.exactMatch) { + return current === target; + } + + return isPathActive(entry.matchPath); +}; + const topLevelNavigationEntries = computed(() => getTopLevelNavigationEntries({ apiUser: apiUser.value, @@ -547,6 +568,8 @@ const topLevelNavEntries = computed>(() => href: entry.href, target: entry.target, matchPath: entry.matchPath, + exactMatch: entry.exactMatch, + excludeMatchPaths: entry.excludeMatchPaths, onSelect: 'history' === entry.id ? () => dEvent('history_main_link_clicked', { clear: true }) @@ -579,7 +602,7 @@ const makeNavigationItem = (entry: NavEntry) => ({ to: entry.to, href: entry.href, target: entry.target, - active: isPathActive(entry.matchPath), + active: isNavigationEntryActive(entry), onSelect: entry.onSelect, }); @@ -693,7 +716,7 @@ const routeSearchGroups = computed>(() => { const pageTitle = computed(() => { const match = allNavEntries.value - .filter((entry) => isPathActive(entry.matchPath)) + .filter((entry) => isNavigationEntryActive(entry)) .sort( (left, right) => normalizePath(right.matchPath).length - normalizePath(left.matchPath).length, )[0]; diff --git a/frontend/app/pages/backend/[backend]/libraries.vue b/frontend/app/pages/backend/[backend]/libraries.vue index 05e66b8e..ec3d0a65 100644 --- a/frontend/app/pages/backend/[backend]/libraries.vue +++ b/frontend/app/pages/backend/[backend]/libraries.vue @@ -374,11 +374,28 @@ const forwardCommand = async (library: LibraryItem): Promise => { await navigateTo(makeConsoleCommand(r(command.command, util as unknown as JsonObject))); }; +const getIgnoreIds = (targetLibrary: LibraryItem, nextIgnoredState: boolean): Array => { + const targetId = String(targetLibrary.id); + const ignoreIds = items.value + .filter((item) => (String(item.id) === targetId ? nextIgnoredState : item.ignored)) + .map((item) => String(item.id)); + + return Array.from(new Set(ignoreIds)); +}; + const toggleIgnore = async (library: LibraryItem): Promise => { try { const newState = !library.ignored; - const response = await request(`/backend/${backend}/library/${library.id}`, { - method: newState ? 'POST' : 'DELETE', + const ignoreIds = getIgnoreIds(library, newState); + + const response = await request(`/backend/${backend}`, { + method: 'PATCH', + body: JSON.stringify([ + { + key: 'options.ignore', + value: ignoreIds.join(','), + }, + ]), }); const data = await parse_api_response(response); diff --git a/frontend/app/pages/help/index.vue b/frontend/app/pages/help/index.vue index 0a2847dd..c1137fac 100644 --- a/frontend/app/pages/help/index.vue +++ b/frontend/app/pages/help/index.vue @@ -113,5 +113,11 @@ const choices: Array<{ number: number; title: string; text: string; url: string text: 'API documentation current version.', url: '/help/api', }, + { + number: 9, + title: 'Backend OpenAPI', + text: 'Browse the bundled Plex, Jellyfin, and Emby upstream routes.', + url: '/help/openapi', + }, ]; diff --git a/frontend/app/pages/help/openapi.vue b/frontend/app/pages/help/openapi.vue new file mode 100644 index 00000000..6b41b5e0 --- /dev/null +++ b/frontend/app/pages/help/openapi.vue @@ -0,0 +1,1047 @@ + + + diff --git a/frontend/app/pages/history/[id]/index.vue b/frontend/app/pages/history/[id]/index.vue index bb0e416c..45480fa0 100644 --- a/frontend/app/pages/history/[id]/index.vue +++ b/frontend/app/pages/history/[id]/index.vue @@ -684,6 +684,15 @@ : 'text-error', ]" /> + + + Metadata via - - - + View + + {{ String(item.text).trim() }} diff --git a/frontend/app/pages/logs/[filename].vue b/frontend/app/pages/logs/[filename].vue index 29876a7b..6b1e5107 100644 --- a/frontend/app/pages/logs/[filename].vue +++ b/frontend/app/pages/logs/[filename].vue @@ -171,7 +171,10 @@
- ; + logs?: Array; /** Event options (optional) */ options?: JsonObject; /** Display toggle for event data (UI state) */ @@ -1228,3 +1228,147 @@ export interface FileDiffResult { /** Reference path segments showing common vs different parts */ referenceSegments: Array; } + +/** + * OpenAPI local reference. + */ +export interface OpenAPIReference { + $ref: string; +} + +/** + * OpenAPI example payload. + */ +export interface OpenAPIExample { + summary?: string; + description?: string; + value?: JsonValue | string; +} + +/** + * OpenAPI schema object used by the help route browser. + */ +export interface OpenAPISchema { + title?: string; + type?: string; + format?: string; + description?: string; + nullable?: boolean; + enum?: Array; + properties?: Record; + required?: Array; + items?: OpenAPISchema | OpenAPIReference; + allOf?: Array; + oneOf?: Array; + anyOf?: Array; + additionalProperties?: boolean | OpenAPISchema | OpenAPIReference; + example?: JsonValue | string; +} + +/** + * OpenAPI media content definition. + */ +export interface OpenAPIMediaType { + schema?: OpenAPISchema | OpenAPIReference; + example?: JsonValue | string; + examples?: Record; +} + +/** + * OpenAPI parameter definition. + */ +export interface OpenAPIParameter { + name?: string; + in?: string; + description?: string; + required?: boolean; + deprecated?: boolean; + schema?: OpenAPISchema | OpenAPIReference; + style?: string; + explode?: boolean; + example?: JsonValue | string; + examples?: Record; +} + +/** + * OpenAPI request body definition. + */ +export interface OpenAPIRequestBody { + description?: string; + required?: boolean; + content?: Record; +} + +/** + * OpenAPI header definition. + */ +export interface OpenAPIHeader { + description?: string; + required?: boolean; + schema?: OpenAPISchema | OpenAPIReference; +} + +/** + * OpenAPI response definition. + */ +export interface OpenAPIResponse { + description?: string; + headers?: Record; + content?: Record; +} + +/** + * OpenAPI components used by the local spec browser. + */ +export interface OpenAPIComponents { + schemas?: Record; + responses?: Record; + parameters?: Record; + requestBodies?: Record; + headers?: Record; +} + +/** + * OpenAPI document used by the help route browser. + */ +export interface OpenAPIDocument { + openapi: string; + info: { + title: string; + version?: string; + description?: string; + }; + paths?: Record; + components?: OpenAPIComponents; +} + +/** + * OpenAPI operation shape used for listing backend routes. + */ +export interface OpenAPIOperation { + summary?: string; + description?: string; + operationId?: string; + tags?: Array; + deprecated?: boolean; + parameters?: Array; + requestBody?: OpenAPIRequestBody | OpenAPIReference; + responses?: Record; +} + +/** + * OpenAPI path item shape used for listing methods per route. + */ +export interface OpenAPIPathItem { + summary?: string; + description?: string; + parameters?: Array; + get?: OpenAPIOperation; + put?: OpenAPIOperation; + post?: OpenAPIOperation; + delete?: OpenAPIOperation; + options?: OpenAPIOperation; + head?: OpenAPIOperation; + patch?: OpenAPIOperation; + trace?: OpenAPIOperation; +} diff --git a/frontend/app/utils/topLevelNavigation.ts b/frontend/app/utils/topLevelNavigation.ts index 0e9d31b7..745eec41 100644 --- a/frontend/app/utils/topLevelNavigation.ts +++ b/frontend/app/utils/topLevelNavigation.ts @@ -32,6 +32,7 @@ type TopLevelEntryId = | 'reset' | 'help' | 'api' + | 'openapi' | 'readme' | 'faq' | 'news' @@ -59,6 +60,8 @@ type TopLevelNavigationDefinition = { href?: string; target?: string; matchPath?: string; + exactMatch?: boolean; + excludeMatchPaths?: Array; visible?: (context: TopLevelNavigationContext) => boolean; }; @@ -306,6 +309,7 @@ const TOP_LEVEL_NAVIGATION: Array = [ icon: 'i-lucide-circle-help', to: '/help', matchPath: '/help', + excludeMatchPaths: ['/help/api', '/help/openapi', '/help/readme', '/help/faq', '/help/news'], }, { id: 'api', @@ -317,6 +321,16 @@ const TOP_LEVEL_NAVIGATION: Array = [ to: '/help/api', matchPath: '/help/api', }, + { + id: 'openapi', + section: 'help', + label: 'OpenAPI', + pageLabel: 'OpenAPI', + breadcrumbSectionLabel: 'Help', + icon: 'i-lucide-braces', + to: '/help/openapi', + matchPath: '/help/openapi', + }, { id: 'readme', section: 'help', diff --git a/src/API/Logs/Index.php b/src/API/Logs/Index.php index b0017724..1912fe93 100644 --- a/src/API/Logs/Index.php +++ b/src/API/Logs/Index.php @@ -346,8 +346,14 @@ private function getFile(string $file): ?string * @return array * @throws RandomException */ - public static function formatLog(string $line, array $users = []): array + public static function formatLog(mixed $line, array $users = []): array { + if (!is_string($line)) { + $line = json_encode($line); + } + + $line ??= ''; + if (empty($line)) { return [ 'id' => md5((string) (hrtime(true) + random_int(1, 10_000))), @@ -378,7 +384,7 @@ public static function formatLog(string $line, array $users = []): array $logLine['item_id'] = $idMatches['item_id']; } - if (1 === $identMatch && in_array($identMatches['user'], $users, true)) { + if (1 === $identMatch && ([] === $users || in_array($identMatches['user'], $users, true))) { $logLine['user'] = $identMatches['user']; $logLine['backend'] = $identMatches['backend']; } diff --git a/src/API/System/Events.php b/src/API/System/Events.php index c7455ba8..26e9f7fe 100644 --- a/src/API/System/Events.php +++ b/src/API/System/Events.php @@ -4,6 +4,7 @@ namespace App\API\System; +use App\API\Logs\Index as LogsIndex; use App\Libs\Attributes\Route\Delete; use App\Libs\Attributes\Route\Get; use App\Libs\Attributes\Route\Patch; @@ -235,6 +236,10 @@ private function formatEntity(EntityItem $entity): array $data = $entity->getAll(); $data['status_name'] = $entity->getStatusText(); + if (is_array($entity->logs) && count($entity->logs) > 0) { + $data['logs'] = array_map(LogsIndex::formatLog(...), $entity->logs); + } + if ($delay = ag($entity->options, Options::DELAY_BY)) { $data['delay_by'] = $delay; } diff --git a/src/Backends/Plex/PlexClient.php b/src/Backends/Plex/PlexClient.php index 3ed37911..00167015 100644 --- a/src/Backends/Plex/PlexClient.php +++ b/src/Backends/Plex/PlexClient.php @@ -105,6 +105,8 @@ class PlexClient implements iClient 'com.plexapp.agents.cmdb', 'tv.plex.agents.movie', 'tv.plex.agents.series', + 'tv.plex.agents.nfo.movie', + 'tv.plex.agents.nfo.series', ]; /** diff --git a/src/Backends/Plex/PlexGuid.php b/src/Backends/Plex/PlexGuid.php index bbbe5c49..0fe6415c 100644 --- a/src/Backends/Plex/PlexGuid.php +++ b/src/Backends/Plex/PlexGuid.php @@ -45,6 +45,14 @@ final class PlexGuid implements iGuid 'com.plexapp.agents.cmdb', ]; + /** + * @var array List of native plex NFO agents. + */ + private array $guidNfo = [ + 'tv.plex.agents.nfo.movie', + 'tv.plex.agents.nfo.series', + ]; + /** * @var array List of local plex agents. */ @@ -359,6 +367,10 @@ private function ListExternalIds(array $guids, array $context = [], bool $log = $val = $this->parseLegacyAgent(guid: $val, context: $context, log: $log); } + if (true === str_starts_with($val, 'tv.plex.agents.nfo.')) { + $val = $this->parseNfoAgent(guid: $val, context: $context, log: $log); + } + if (false === str_contains($val, '://')) { if (true === $log) { $this->logger->info("PlexGuid: Unable to parse '{backend}: {agent}' identifier.", [ @@ -398,6 +410,10 @@ private function ListExternalIds(array $guids, array $context = [], bool $log = // -- Plex in their infinite wisdom, sometimes report two keys for same data source. if (null !== ($guid[$this->guidMapper[$key]] ?? null)) { + if ($guid[$this->guidMapper[$key]] === $value) { + continue; + } + if (true === $log) { $this->logger->warning( "PlexGuid: '{client}: {backend}' reported multiple ids for same data source '{key}: {ids}' for {item.type} '{item.id}: {item.title}'.", @@ -514,6 +530,74 @@ private function parseLegacyAgent(string $guid, array $context = [], bool $log = } } + /** + * Parse native Plex NFO agents. + * + * Typed NFO GUIDs include the source in the last path segment, such as + * `tv.plex.agents.nfo.movie://movie/tmdb_383498`. We normalize those to the + * same `://` format the rest of the parser already understands. + * Fallback NFO ids like `...://movie/858024` do not identify the source and + * are intentionally left untouched. + * + * @param string $guid Guid to parse. + * @param array $context Context data. + * @param bool $log Log errors. default true. + * + * @return string The parsed GUID. + */ + private function parseNfoAgent(string $guid, array $context = [], bool $log = true): string + { + if (false === in_array(before($guid, '://'), $this->guidNfo, true)) { + return $guid; + } + + try { + $payload = after($guid, '://'); + $token = trim((string) basename($payload)); + + if ('' === $token || false === str_contains($token, '_')) { + return $guid; + } + + [$source, $sourceId] = explode('_', $token, 2); + $source = strtolower(trim($source)); + $sourceId = trim($sourceId); + + if ('' === $source || '' === $sourceId || null === ($this->guidMapper[$source] ?? null)) { + return $guid; + } + + return $source . '://' . before($sourceId, '?'); + } catch (Throwable $e) { + if (true === $log) { + $this->logger->error( + message: "PlexGuid: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing NFO agent '{agent}' identifier. Error '{error.message}' at '{error.file}:{error.line}.", + context: [ + 'backend' => $this->context->backendName, + 'client' => $this->context->clientName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'agent' => $guid, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], + ...$context, + ], + ); + } + + return $guid; + } + } + /** * Get the Plex Guid configuration. * @@ -524,6 +608,7 @@ public function getConfig(): array return [ 'guidMapper' => $this->guidMapper, 'guidLegacy' => $this->guidLegacy, + 'guidNfo' => $this->guidNfo, 'guidLocal' => $this->guidLocal, 'guidReplacer' => $this->guidReplacer, ]; diff --git a/src/Commands/State/ImportCommand.php b/src/Commands/State/ImportCommand.php index dec3c1d6..667f0c82 100644 --- a/src/Commands/State/ImportCommand.php +++ b/src/Commands/State/ImportCommand.php @@ -4,6 +4,7 @@ namespace App\Commands\State; +use App\Backends\Common\ClientInterface as iClient; use App\Backends\Common\Request; use App\Command; use App\Libs\Attributes\DI\Inject; @@ -191,9 +192,7 @@ protected function process(InputInterface $input, OutputInterface $output): int $this->mapper->setOptions($mapperOpts); - $users = get_users_context(mapper: $this->mapper, logger: $this->logger, opts: [ - DatabaseInterface::class => $dbOpts, - ]); + $users = $this->getUsers($dbOpts); if (null !== ($user = $input->getOption('user'))) { $users = array_filter($users, static fn($k) => $k === $user, mode: ARRAY_FILTER_USE_KEY); @@ -373,9 +372,7 @@ protected function process(InputInterface $input, OutputInterface $output): int } $backend['options'] = $opts; - $backend['class'] = make_backend(backend: $backend, name: $name, options: [ - UserContext::class => $userContext, - ]); + $backend['class'] = $this->makeBackend($backend, $name, $userContext); if (null !== $after) { $after = make_date($after); @@ -420,24 +417,11 @@ protected function process(InputInterface $input, OutputInterface $output): int ], ]); - $dbLayer = $userContext->db->getDBLayer(); - $startedTransaction = false; - try { - if (false === $dbLayer->inTransaction()) { - $startedTransaction = $dbLayer->start(); - } - - send_requests(requests: $queue, client: $this->http, sync: $syncRequests, logger: $this->logger); - - if (true === $startedTransaction && true === $dbLayer->inTransaction()) { - $dbLayer->commit(); - } + $userContext->db->transactional(function () use ($queue, $syncRequests): void { + $this->sendRequests($queue, $syncRequests); + }); } catch (Throwable $e) { - if (true === $startedTransaction && true === $dbLayer->inTransaction()) { - $dbLayer->rollBack(); - } - $this->logger->error( ...lw( message: "SYSTEM: Import requests for '{user}' backends failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", @@ -549,6 +533,36 @@ protected function process(InputInterface $input, OutputInterface $output): int return self::SUCCESS; } + /** + * @param array $dbOpts + * + * @return array + */ + protected function getUsers(array $dbOpts = []): array + { + return get_users_context(mapper: $this->mapper, logger: $this->logger, opts: [ + DatabaseInterface::class => $dbOpts, + ]); + } + + /** + * @param array $backend + */ + protected function makeBackend(array $backend, string $name, UserContext $userContext): iClient + { + return make_backend(backend: $backend, name: $name, options: [ + UserContext::class => $userContext, + ]); + } + + /** + * @param array $queue + */ + protected function sendRequests(array $queue, bool $syncRequests): void + { + send_requests(requests: $queue, client: $this->http, sync: $syncRequests, logger: $this->logger); + } + private function in_array(array $list, string $search): bool { return array_any($list, static fn($item) => str_starts_with($search, $item)); diff --git a/src/Libs/Database/DBLayer.php b/src/Libs/Database/DBLayer.php index 01dfc5de..90fdcdc6 100644 --- a/src/Libs/Database/DBLayer.php +++ b/src/Libs/Database/DBLayer.php @@ -137,12 +137,21 @@ public function query(PDOStatement|string $sql, array $bind = [], array $options $stmt = $isStatement ? $sql : $db->prepare($sql); + if (true === $isStatement) { + $stmt->execute($bind); + return $stmt; + } + if (!empty($bind)) { - array_map( - static fn($k, $v) => $stmt->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR), - array_keys($bind), - $bind, - ); + foreach ($bind as $key => $value) { + $type = match (true) { + null === $value => PDO::PARAM_NULL, + is_int($value) => PDO::PARAM_INT, + default => PDO::PARAM_STR, + }; + + $stmt->bindValue($key, $value, $type); + } } $stmt->execute(); diff --git a/src/Libs/Database/PDO/PDOAdapter.php b/src/Libs/Database/PDO/PDOAdapter.php index 5efd7953..015af5e6 100644 --- a/src/Libs/Database/PDO/PDOAdapter.php +++ b/src/Libs/Database/PDO/PDOAdapter.php @@ -207,13 +207,12 @@ public function insert(iState $entity): iState } $this->db->query($this->stmt['insert'], $data, options: [ - 'on_failure' => function (Throwable $e) use ($entity) { - if (false === str_contains($e->getMessage(), '21 bad parameter or other API misuse')) { - throw $e; - } - $this->stmt['insert'] = null; - return $this->insert($entity); - }, + 'on_failure' => fn(Throwable $e) => $this->retryPreparedWrite( + key: 'insert', + sql: $this->pdoInsert('state', iState::ENTITY_KEYS), + data: $data, + e: $e, + ), ]); $entity->id = (int) $this->db->lastInsertId(); @@ -432,13 +431,12 @@ public function update(iState $entity): iState } $this->db->query($this->stmt['update'], $data, options: [ - 'on_failure' => function (Throwable $e) use ($entity) { - if (false === str_contains($e->getMessage(), '21 bad parameter or other API misuse')) { - throw $e; - } - $this->stmt['update'] = null; - return $this->update($entity); - }, + 'on_failure' => fn(Throwable $e) => $this->retryPreparedWrite( + key: 'update', + sql: $this->pdoUpdate('state', iState::ENTITY_KEYS), + data: $data, + e: $e, + ), ]); } catch (PDOException $e) { $this->stmt['update'] = null; @@ -810,6 +808,26 @@ private function pdoUpdate(string $table, array $columns): string return trim(str_replace('{place} = {holder}', implode(', ', $placeholders), $queryString)); } + /** + * Retry a cached write statement once when sqlite reports prepared statement misuse. + * + * @param string $key Cached statement key. + * @param string $sql SQL statement to prepare. + * @param array $data Bound statement values. + * @param Throwable $e Triggering exception. + */ + private function retryPreparedWrite(string $key, string $sql, array $data, Throwable $e): PDOStatement + { + if (false === str_contains($e->getMessage(), '21 bad parameter or other API misuse')) { + throw $e; + } + + $statement = $this->db->prepare($sql); + $this->stmt[$key] = $statement; + + return $this->db->query($statement, $data); + } + /** * Find db entity using external id. * External id format is: (db_name)://(id) diff --git a/src/Libs/ServeStatic.php b/src/Libs/ServeStatic.php index 313ee125..e80133ca 100644 --- a/src/Libs/ServeStatic.php +++ b/src/Libs/ServeStatic.php @@ -35,16 +35,17 @@ final class ServeStatic implements LoggerAwareInterface * @var array These files are served from outside the public directory. */ private const array FILES = [ - '/CHANGELOG.json' => __DIR__ . '/../../frontend/exported/CHANGELOG.json', '/API.md' => __DIR__ . '/../../API.md', '/README.md' => __DIR__ . '/../../README.md', '/NEWS.md' => __DIR__ . '/../../NEWS.md', '/FAQ.md' => __DIR__ . '/../../FAQ.md', - '/CHANGELOG.md' => __DIR__ . '/../../CHANGELOG.md', '/guides/API.md' => __DIR__ . '/../../API.md', '/guides/README.md' => __DIR__ . '/../../README.md', '/guides/NEWS.md' => __DIR__ . '/../../NEWS.md', '/guides/FAQ.md' => __DIR__ . '/../../FAQ.md', + '/guides/openapi/plex.json' => __DIR__ . '/../Backends/Plex/plex-openai-stable.json', + '/guides/openapi/jellyfin.json' => __DIR__ . '/../Backends/Jellyfin/jellyfin-openapi-stable.json', + '/guides/openapi/emby.json' => __DIR__ . '/../Backends/Emby/emby-openapi-stable.json', ]; private const array MD_IMAGES = [ diff --git a/tests/API/Logs/IndexTest.php b/tests/API/Logs/IndexTest.php new file mode 100644 index 00000000..5ffe9f6a --- /dev/null +++ b/tests/API/Logs/IndexTest.php @@ -0,0 +1,39 @@ +assertSame('123', $parsed['item_id'], 'Log formatter should extract history item ids from structured messages.'); + $this->assertSame('main', $parsed['user'], 'Log formatter should expose the user even when no whitelist is provided.'); + $this->assertSame('office_emby', $parsed['backend'], 'Log formatter should expose the backend even when no whitelist is provided.'); + $this->assertSame('2026-04-27T10:17:56+03:00', $parsed['date'], 'Log formatter should preserve the bracketed timestamp.'); + $this->assertSame( + "NOTICE: Processing 'main@office_emby' - '#123: IppSec' item.", + $parsed['text'], + 'Log formatter should strip the timestamp prefix from the display text.' + ); + } + + public function test_formatLog_stringifies_non_string_payloads(): void + { + $parsed = Index::formatLog(['message' => 'boom', 'code' => 1]); + + $this->assertSame('{"message":"boom","code":1}', $parsed['text'], 'Non-string log payloads should be stringified for API consumers.'); + $this->assertNull($parsed['date'], 'Stringified payloads should not invent timestamps.'); + $this->assertNull($parsed['item_id'], 'Stringified payloads should not invent item ids.'); + $this->assertNull($parsed['user'], 'Stringified payloads should not invent users.'); + $this->assertNull($parsed['backend'], 'Stringified payloads should not invent backends.'); + } +} diff --git a/tests/Backends/Plex/GetLibrariesListTest.php b/tests/Backends/Plex/GetLibrariesListTest.php index e8fc1cbc..69b9183e 100644 --- a/tests/Backends/Plex/GetLibrariesListTest.php +++ b/tests/Backends/Plex/GetLibrariesListTest.php @@ -152,4 +152,37 @@ public function test_401_response_with_invalid_token(): void $this->assertNull($response->response); $this->assertTrue($response->error->hasException()); } + + public function test_nfo_agents_are_supported(): void + { + $payload = [ + 'MediaContainer' => [ + 'Directory' => [ + [ + 'key' => '1', + 'title' => 'NFO Movies', + 'type' => 'movie', + 'agent' => 'tv.plex.agents.nfo.movie', + 'scanner' => 'Plex Movie Scanner', + ], + [ + 'key' => '2', + 'title' => 'NFO Shows', + 'type' => 'show', + 'agent' => 'tv.plex.agents.nfo.series', + 'scanner' => 'Plex TV Series', + ], + ], + ], + ]; + + $resp = new MockResponse(json_encode($payload), ['http_code' => 200]); + $client = new MockHttpClient($resp); + $response = new GetLibrariesList($client, $this->logger)($this->context); + + $this->assertTrue($response->status); + $this->assertCount(2, $response->response); + $this->assertTrue((bool) $response->response[0]['supported']); + $this->assertTrue((bool) $response->response[1]['supported']); + } } diff --git a/tests/Backends/Plex/ImportTest.php b/tests/Backends/Plex/ImportTest.php index a1c5e80a..c36408c1 100644 --- a/tests/Backends/Plex/ImportTest.php +++ b/tests/Backends/Plex/ImportTest.php @@ -98,4 +98,43 @@ public function test_import_empty_libraries(): void $this->assertTrue($result->isSuccessful()); $this->assertSame([], $result->response); } + + public function test_import_nfo_library_select_includes_selected(): void + { + $sections = [ + 'MediaContainer' => [ + 'Directory' => [ + [ + 'key' => '5', + 'title' => 'NFO Movies', + 'type' => 'movie', + 'agent' => 'tv.plex.agents.nfo.movie', + ], + ], + ], + ]; + + $http = $this->makeHttpClient( + $this->makeResponse($sections), + new MockResponse('', [ + 'http_code' => 200, + 'response_headers' => ['X-Plex-Container-Total-Size' => '1'], + ]), + ); + $context = $this->makeContext([Options::LIBRARY_SELECT => ['5']]); + $action = new Import($http, $this->logger); + + $result = $action( + $context, + new PlexGuid($this->logger), + $context->userContext->mapper, + null, + [], + ); + + $this->assertTrue($result->isSuccessful()); + $this->assertCount(1, $result->response); + $logContext = $result->response[0]->extras['logContext'] ?? []; + $this->assertSame(5, (int) ag($logContext, 'library.id')); + } } diff --git a/tests/Backends/Plex/PlexGuidTest.php b/tests/Backends/Plex/PlexGuidTest.php index f543aa36..75474e38 100644 --- a/tests/Backends/Plex/PlexGuidTest.php +++ b/tests/Backends/Plex/PlexGuidTest.php @@ -351,6 +351,11 @@ public function test_isLocal() $this->getClass()->isLocal('com.plexapp.agents.imdb://123456/1/1'), 'Assert that the GUID is not local.' ); + + $this->assertFalse( + $this->getClass()->isLocal('tv.plex.agents.nfo.movie://movie/tmdb_383498'), + 'Assert that typed NFO GUIDs are treated as non-local external ids.' + ); } public function test_has() @@ -392,10 +397,23 @@ public function test_parse() ], $context), 'Assert that the GUID exists.'); + $this->assertEquals([ + Guid::GUID_IMDB => 'tt1234567', + Guid::GUID_TMDB => '383498', + Guid::GUID_TVDB => '121361', + ], + $this->getClass()->parse([ + ['id' => 'tv.plex.agents.nfo.movie://movie/tmdb_383498'], + ['id' => 'tv.plex.agents.nfo.movie://movie/imdb_tt1234567'], + ['id' => 'tv.plex.agents.nfo.series://show/tvdb_121361'], + ], $context), + 'Assert that typed NFO GUIDs are normalized into supported external ids.'); + $this->assertEquals([], $this->getClass()->parse([ ['id' => ''], ['id' => 'com.plexapp.agents.none://123456'], ['id' => 'com.plexapp.agents.imdb'], + ['id' => 'tv.plex.agents.nfo.movie://movie/858024'], ], $context), 'Assert that the GUID does not exist. for invalid GUIDs.'); } @@ -434,6 +452,16 @@ public function test_get() ['id' => 'com.plexapp.agents.imdb://2'], ], $context), 'Assert only the the oldest ID is returned for numeric GUIDs.'); + $this->assertEquals([Guid::GUID_TVDB => '84871'], $this->getClass()->get([ + ['id' => 'tvdb://84871'], + ['id' => 'tv.plex.agents.nfo.series://episode/tvdb_84871'], + ], $context), 'Assert typed NFO GUIDs do not conflict with identical canonical GUIDs.'); + + $this->assertFalse( + $this->logged(Level::Warning, 'reported multiple ids', true), + 'Assert identical canonical and NFO GUIDs do not raise duplicate warnings.' + ); + // -- as we cache the ignore list for each user now, // -- and no longer rely on config.ignore key, we needed a workaround to update the ignore list is_ignored_id( diff --git a/tests/Backends/Plex/ToEntityTest.php b/tests/Backends/Plex/ToEntityTest.php index d9ff7e67..e8589f30 100644 --- a/tests/Backends/Plex/ToEntityTest.php +++ b/tests/Backends/Plex/ToEntityTest.php @@ -77,4 +77,72 @@ public function __invoke(\App\Backends\Common\Context $context, string|int $id, $this->assertInstanceOf(StateInterface::class, $result->response); $this->assertNotEmpty($result->response->parent); } + + public function test_to_entity_uses_top_level_typed_nfo_guid(): void + { + $context = $this->makeContext(); + $item = [ + 'ratingKey' => '42', + 'type' => 'movie', + 'title' => 'NFO Movie', + 'addedAt' => 1000, + 'guid' => 'tv.plex.agents.nfo.movie://movie/tmdb_383498', + ]; + + $action = new ToEntity(new PlexGuid($this->logger)); + $result = $action($context, $item); + + $this->assertTrue($result->isSuccessful()); + $this->assertInstanceOf(StateInterface::class, $result->response); + $this->assertSame('383498', $result->response->guids['guid_tmdb'] ?? null); + } + + public function test_to_entity_episode_uses_show_level_typed_nfo_guid_when_parent_has_no_guid_list(): void + { + $context = $this->makeContext(); + $item = [ + 'ratingKey' => '11', + 'type' => 'episode', + 'title' => 'Pilot', + 'grandparentTitle' => 'Test Show', + 'parentIndex' => 1, + 'index' => 1, + 'addedAt' => 1000, + 'Guid' => [ + ['id' => 'tvdb://84871'], + ], + 'grandparentRatingKey' => 'show-1', + ]; + + $showPayload = [ + 'MediaContainer' => [ + 'Metadata' => [ + [ + 'ratingKey' => 'show-1', + 'type' => 'show', + 'title' => 'Test Show', + 'guid' => 'tv.plex.agents.nfo.series://show/tvdb_72408', + ], + ], + ], + ]; + + Container::add(GetMetaData::class, fn() => new class($showPayload) { + public function __construct(private array $payload) + { + } + + public function __invoke(\App\Backends\Common\Context $context, string|int $id, array $opts = []): Response + { + return new Response(status: true, response: $this->payload); + } + }); + + $action = new ToEntity(new PlexGuid($this->logger)); + $result = $action($context, $item); + + $this->assertTrue($result->isSuccessful()); + $this->assertInstanceOf(StateInterface::class, $result->response); + $this->assertSame('72408', $result->response->parent['guid_tvdb'] ?? null); + } } diff --git a/tests/Commands/State/ImportCommandTest.php b/tests/Commands/State/ImportCommandTest.php new file mode 100644 index 00000000..e7973a0b --- /dev/null +++ b/tests/Commands/State/ImportCommandTest.php @@ -0,0 +1,129 @@ +migrations('up'); + $db->setOptions(['class' => new StateEntity([])]); + $cache = new Psr16Cache(new ArrayAdapter()); + $entity = $db->insert(new StateEntity(require __DIR__ . '/../../Fixtures/EpisodeEntity.php')); + + $userContext = new UserContext( + name: 'main', + config: new ConfigFile( + file: __DIR__ . '/../../Fixtures/test_servers.yaml', + autoSave: false, + autoCreate: false, + autoBackup: false, + ), + mapper: new DirectMapper(logger: $logger, db: $db, cache: $cache), + cache: $cache, + db: $db, + ); + + $client = $this->createStub(iClient::class); + $client->method('pull')->willReturn([]); + + $http = $this->createStub(iHttp::class); + + $command = new class($userContext, $logger, $client, $http, $entity) extends ImportCommand { + public bool $sendRequestsCalled = false; + + public function __construct( + private readonly UserContext $userContext, + Logger $logger, + private readonly iClient $client, + iHttp $http, + private readonly StateEntity $entity, + ) { + parent::__construct( + mapper: $this->userContext->mapper, + logger: $logger, + suppressor: new LogSuppressor([]), + http: $http, + ); + } + + /** + * @return array + */ + protected function getUsers(array $dbOpts = []): array + { + return ['main' => $this->userContext]; + } + + protected function makeBackend(array $backend, string $name, UserContext $userContext): iClient + { + return $this->client; + } + + protected function sendRequests(array $queue, bool $syncRequests): void + { + Assert::assertTrue( + $this->userContext->db->getDBLayer()->inTransaction(), + 'Import request phase should run inside a single adapter-managed DB transaction.' + ); + + $this->sendRequestsCalled = true; + + $this->userContext->db->getDBLayer()->exec('DROP TABLE state'); + $this->userContext->db->update(clone $this->entity); + } + }; + + $this->checkException( + closure: fn() => $this->makeTester($command)->execute(['--dry-run' => true]), + reason: 'Import should bubble database failures from the request phase when wrapped in adapter transaction state.', + exception: PDOException::class, + exceptionMessage: 'no such table: state', + ); + + self::assertTrue($command->sendRequestsCalled, 'Import command should reach the request processing phase.'); + + $stateTable = $db->getDBLayer()->query( + sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'state'" + )->fetchColumn(); + + self::assertSame('state', $stateTable, 'Failed request-phase writes should be rolled back with the adapter transaction.'); + } + + private function makeTester(ImportCommand $command): CommandTester + { + $application = new Application(); + $application->getDefinition()->addOption(new InputOption('trace', null, InputOption::VALUE_NONE)); + $application->addCommand($command); + + return new CommandTester($application->find(ImportCommand::ROUTE)); + } +} diff --git a/tests/Database/DBLayerTest.php b/tests/Database/DBLayerTest.php index fe48dfdd..c52867fb 100644 --- a/tests/Database/DBLayerTest.php +++ b/tests/Database/DBLayerTest.php @@ -129,6 +129,50 @@ public function test_query() ); } + public function test_query_with_prepared_statement_reuse(): void + { + $this->db->insert('test', [ + 'name' => 'test', + 'watched' => 1, + 'added_at' => 1, + 'updated_at' => 2, + 'json_data' => json_encode(['id' => 1]), + 'nullable' => null, + ]); + + $stmt = $this->db->prepare( + 'UPDATE test SET name = :name, nullable = :nullable, json_data = :json_data WHERE id = :id' + ); + + $this->assertSame( + 1, + $this->db->query($stmt, [ + 'name' => 'first', + 'nullable' => null, + 'json_data' => json_encode(['id' => 2]), + 'id' => 1, + ])->rowCount(), + 'Prepared statements should execute correctly with null-bound values.' + ); + + $this->assertSame( + 1, + $this->db->query($stmt, [ + 'name' => 'second', + 'nullable' => 'set', + 'json_data' => json_encode(['id' => 3]), + 'id' => 1, + ])->rowCount(), + 'Prepared statements should be reusable across multiple executions.' + ); + + $row = $this->db->select('test', [], ['id' => 1])->fetch(PDO::FETCH_ASSOC); + + $this->assertSame('second', $row['name'], 'Prepared statement reuse should persist the latest scalar value.'); + $this->assertSame('set', $row['nullable'], 'Prepared statement reuse should persist updated nullable values.'); + $this->assertSame('{"id":3}', $row['json_data'], 'Prepared statement reuse should persist updated JSON payloads.'); + } + public function test_transactions_operations() { $this->db->start(); diff --git a/tests/Database/PDOAdapterTest.php b/tests/Database/PDOAdapterTest.php index f88abf7f..990e5885 100644 --- a/tests/Database/PDOAdapterTest.php +++ b/tests/Database/PDOAdapterTest.php @@ -21,7 +21,10 @@ use Monolog\Handler\TestHandler; use Monolog\Logger; use PDO; +use PDOException; +use PDOStatement; use Psr\SimpleCache\CacheInterface; +use ReflectionMethod; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\NullOutput; @@ -346,6 +349,65 @@ public function test_update_conditions(): void ); } + public function test_retryPreparedWrite_returns_statement_for_bad_parameter_retry(): void + { + assert($this->db instanceof PDOAdapter); + + $item = $this->db->insert(new StateEntity($this->testEpisode)); + $item->title = 'Retried Title'; + + $data = $item->getAll(); + $data[iState::COLUMN_UPDATED_AT] = time(); + + foreach (iState::ENTITY_ARRAY_KEYS as $key) { + if (!(null !== ($data[$key] ?? null) && true === is_array($data[$key]))) { + continue; + } + + ksort($data[$key]); + $data[$key] = json_encode($data[$key], flags: JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + $pdoUpdate = new ReflectionMethod($this->db, 'pdoUpdate'); + $sql = $pdoUpdate->invoke($this->db, 'state', iState::ENTITY_KEYS); + + $retryPreparedWrite = new ReflectionMethod($this->db, 'retryPreparedWrite'); + $stmt = $retryPreparedWrite->invoke( + $this->db, + 'update', + $sql, + $data, + new PDOException('21 bad parameter or other API misuse'), + ); + + $this->assertInstanceOf(PDOStatement::class, $stmt, 'Retry path should return a PDO statement, not an entity.'); + $this->assertSame( + 'Retried Title', + $this->db->get($item)->title, + 'Retry path should execute the rebuilt update statement successfully.' + ); + } + + public function test_retryPreparedWrite_rethrows_unrelated_errors(): void + { + assert($this->db instanceof PDOAdapter); + + $retryPreparedWrite = new ReflectionMethod($this->db, 'retryPreparedWrite'); + + $this->checkException( + closure: fn() => $retryPreparedWrite->invoke( + $this->db, + 'update', + 'UPDATE state SET title = :title WHERE id = :id', + ['title' => 'Ignored', 'id' => 1], + new PDOException('some other database problem'), + ), + reason: 'Retry helper should only intercept sqlite API misuse errors.', + exception: PDOException::class, + exceptionMessage: 'some other database problem', + ); + } + public function test_duplicates_uses_cache(): void { $cache = $this->makeCacheStub(); diff --git a/tests/Libs/ServeStaticTest.php b/tests/Libs/ServeStaticTest.php index 53f579e7..a8cc21cd 100644 --- a/tests/Libs/ServeStaticTest.php +++ b/tests/Libs/ServeStaticTest.php @@ -80,6 +80,14 @@ public function test_error_responses() exceptionCode: Status::BAD_REQUEST->value, ); + $this->checkException( + closure: fn() => new ServeStatic()->serve($this->createRequest('GET', '/guides/openapi/../../../README.md')), + reason: 'OpenAPI guide routes should not resolve outside the explicit spec aliases.', + exception: \League\Route\Http\Exception\NotFoundException::class, + exceptionMessage: 'not found', + exceptionCode: Status::NOT_FOUND->value, + ); + // -- Check for invalid root static path. $this->checkException( closure: fn() => new ServeStatic('/nonexistent')->serve($this->createRequest('GET', '/test.html')), @@ -119,6 +127,19 @@ public function test_responses() $this->assertEquals('text/markdown; charset=utf-8', $response->getHeaderLine('Content-Type')); $this->assertEquals(file_get_contents(__DIR__ . '/../../guides/identities.md'), (string)$response->getBody()); + $response = new ServeStatic()->serve($this->createRequest('GET', '/guides/openapi/plex.json')); + $this->assertEquals(Status::OK->value, $response->getStatusCode()); + $this->assertEquals('application/json; charset=utf-8', $response->getHeaderLine('Content-Type')); + $this->assertEquals( + file_get_contents(__DIR__ . '/../../src/Backends/Plex/plex-openai-stable.json'), + (string) $response->getBody() + ); + + $response = new ServeStatic()->serve($this->createRequest('HEAD', '/guides/openapi/jellyfin.json')); + $this->assertEquals(Status::OK->value, $response->getStatusCode()); + $this->assertEquals('application/json; charset=utf-8', $response->getHeaderLine('Content-Type')); + $this->assertSame('', (string) $response->getBody()); + // -- test screenshots serving, as screenshots path is not in public directory and not subject // -- to same path restrictions as other files. $response = $this->server->serve($this->createRequest('GET', '/screenshots/index.jpg'));