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"
+ >
+ [
+ {{
+ moment(logLine.date).format('HH:mm:ss')
+ }} ]
+
+
+ {{ String(logLine.text).trim() }}
+
copyText(filteredRows.join('\n'))"
+ @click="
+ () => copyText(filteredRows.map((logLine) => formatLogLine(logLine)).join('\n'))
+ "
/>
@@ -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 @@
+
+
+
+
+
+
+ {{ pageShell.sectionLabel }}
+ /
+ {{ pageShell.pageLabel }}
+
+
+
+
{{ specTitle }}
+
{{ specMeta }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No routes match {{ query }}.
+
+
No routes are available for the selected backend.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Parameters
+
+
+
+
+
+
+ {{ parameter.name }}
+
+
+
+ {{ parameter.location }}
+
+
+
+ Required
+
+
+
+ {{ parameter.schemaSummary }}
+
+
+
+
+ {{ parameter.description }}
+
+
+
{{ parameter.shape }}
+
+
+
+
+
+
+
+ Request Body
+
+
+
+
+
+ {{ route.requestBody.mediaType }}
+
+
+
+ Required
+
+
+
+ {{ route.requestBody.schemaSummary }}
+
+
+
+
+ {{ route.requestBody.description }}
+
+
+
{{ route.requestBody.shape }}
+
+
+ No request schema documented.
+
+
+
+
+
+
+
+ Responses
+
+
+
+
+
+
+ {{ response.status }}
+
+
+ {{ response.label }}
+
+
+ {{ response.mediaType }}
+
+
+
+ {{ response.schemaSummary }}
+
+
+
+
+ {{ response.description }}
+
+
+
{{ response.shape }}
+
+
+ No response body documented.
+
+
+
+
+ No responses documented.
+
+
+
+
+
+
+
+
+
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
[
- {{
- moment(item.date).format('HH:mm:ss')
- }} ]
-
-
-
+ >
+ [
+ {{
+ moment(item.date).format('HH:mm:ss')
+ }} ]
-
-
+ 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 @@
- [{{ formatDate(item.date) }}]: ;
+ 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'));