diff --git a/config/services.php b/config/services.php
index 7db05c73..26dc08c1 100644
--- a/config/services.php
+++ b/config/services.php
@@ -24,6 +24,7 @@ use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\UriInterface;
use Psr\Log\LoggerInterface as iLogger;
use Psr\SimpleCache\CacheInterface;
+use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Adapter\NullAdapter;
use Symfony\Component\Cache\Adapter\RedisAdapter;
@@ -121,6 +122,10 @@ return (function (): array {
return new Psr16Cache(new NullAdapter());
}
+ if (true === (defined('IN_TEST_MODE') && true === IN_TEST_MODE)) {
+ return new Psr16Cache(new ArrayAdapter());
+ }
+
$ns = getAppVersion();
if (null !== ($prefix = Config::get('cache.prefix')) && true === isValidName($prefix)) {
@@ -199,8 +204,15 @@ return (function (): array {
},
],
+ DBLayer::class => [
+ 'class' => fn(PDO $pdo): DBLayer => new DBLayer($pdo),
+ 'args' => [
+ PDO::class,
+ ],
+ ],
+
iDB::class => [
- 'class' => function (iLogger $logger, PDO $pdo): iDB {
+ 'class' => function (iLogger $logger, DBLayer $pdo): iDB {
$adapter = new PDOAdapter($logger, $pdo);
if (true !== $adapter->isMigrated()) {
@@ -216,14 +228,7 @@ return (function (): array {
},
'args' => [
iLogger::class,
- PDO::class,
- ],
- ],
-
- DBLayer::class => [
- 'class' => fn(PDO $pdo): DBLayer => new DBLayer($pdo),
- 'args' => [
- PDO::class,
+ DBLayer::class,
],
],
diff --git a/src/API/Backend/Delete.php b/src/API/Backend/Delete.php
index 5ab072e9..e9816aba 100644
--- a/src/API/Backend/Delete.php
+++ b/src/API/Backend/Delete.php
@@ -6,7 +6,7 @@ namespace App\API\Backend;
use App\Libs\Config;
use App\Libs\ConfigFile;
-use App\Libs\Database\DatabaseInterface as iDB;
+use App\Libs\Database\DBLayer;
use App\Libs\Enums\Http\Status;
use App\Libs\Traits\APITraits;
use Psr\Http\Message\ResponseInterface as iResponse;
@@ -16,12 +16,8 @@ final class Delete
{
use APITraits;
- public function __construct(private iDB $db)
- {
- }
-
#[\App\Libs\Attributes\Route\Delete(Index::URL . '/{name:backend}[/]', name: 'backend.delete')]
- public function __invoke(iRequest $request, array $args = []): iResponse
+ public function __invoke(DBLayer $db, iRequest $request, array $args = []): iResponse
{
if (null === ($name = ag($args, 'name'))) {
return api_error('Invalid value for name path parameter.', Status::BAD_REQUEST);
@@ -47,13 +43,13 @@ final class Delete
)
";
- $stmt = $this->db->getPDO()->prepare($sql);
+ $stmt = $db->prepare($sql);
$stmt->execute(['name_metadata' => $name, 'name_extra' => $name]);
$removedReference = $stmt->rowCount();
$sql = "DELETE FROM state WHERE id IN ( SELECT id FROM state WHERE length(metadata) < 10 )";
- $stmt = $this->db->getPDO()->query($sql);
+ $stmt = $db->query($sql);
$deletedRecords = $stmt->rowCount();
diff --git a/src/API/History/Index.php b/src/API/History/Index.php
index d25b3453..a123d61d 100644
--- a/src/API/History/Index.php
+++ b/src/API/History/Index.php
@@ -10,6 +10,7 @@ use App\Libs\Attributes\Route\Get;
use App\Libs\Attributes\Route\Route;
use App\Libs\Container;
use App\Libs\Database\DatabaseInterface as iDB;
+use App\Libs\Database\DBLayer;
use App\Libs\DataUtil;
use App\Libs\Entity\StateInterface as iState;
use App\Libs\Enums\Http\Status;
@@ -17,7 +18,6 @@ use App\Libs\Guid;
use App\Libs\Mappers\Import\DirectMapper;
use App\Libs\Traits\APITraits;
use JsonException;
-use PDO;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Psr\SimpleCache\CacheInterface as iCache;
@@ -47,17 +47,15 @@ final class Index
use APITraits;
public const string URL = '%{api.prefix}/history';
- private PDO $pdo;
public function __construct(private readonly iDB $db, private DirectMapper $mapper, private iCache $cache)
{
- $this->pdo = $this->db->getPDO();
}
#[Get(self::URL . '[/]', name: 'history.list')]
- public function list(iRequest $request): iResponse
+ public function list(DBLayer $db, iRequest $request): iResponse
{
- $es = fn(string $val) => $this->db->identifier($val);
+ $es = fn(string $val) => $db->escapeIdentifier($val, true);
$data = DataUtil::fromArray($request->getQueryParams());
$filters = [];
@@ -278,7 +276,7 @@ final class Index
$sql[] = 'WHERE ' . implode(' AND ', $where);
}
- $stmt = $this->pdo->prepare('SELECT COUNT(*) ' . implode(' ', array_map('trim', $sql)));
+ $stmt = $db->prepare('SELECT COUNT(*) ' . implode(' ', array_map('trim', $sql)));
$stmt->execute($params);
$total = $stmt->fetchColumn();
@@ -324,7 +322,7 @@ final class Index
$params['_limit'] = $perpage <= 0 ? 20 : $perpage;
$sql[] = 'ORDER BY ' . implode(', ', $sorts) . ' LIMIT :_start,:_limit';
- $stmt = $this->pdo->prepare('SELECT * ' . implode(' ', array_map('trim', $sql)));
+ $stmt = $db->prepare('SELECT * ' . implode(' ', array_map('trim', $sql)));
$stmt->execute($params);
$getUri = $request->getUri()->withHost('')->withPort(0)->withScheme('');
diff --git a/src/API/Ignore/Index.php b/src/API/Ignore/Index.php
index eb5269e3..8f543ac7 100644
--- a/src/API/Ignore/Index.php
+++ b/src/API/Ignore/Index.php
@@ -10,7 +10,7 @@ use App\Libs\Attributes\Route\Post;
use App\Libs\Config;
use App\Libs\ConfigFile;
use App\Libs\Container;
-use App\Libs\Database\DatabaseInterface as iDB;
+use App\Libs\Database\DBLayer;
use App\Libs\DataUtil;
use App\Libs\Entity\StateInterface as iState;
use App\Libs\Enums\Http\Status;
@@ -26,14 +26,10 @@ final class Index
private array $cache = [];
- private PDO $db;
-
private ConfigFile $config;
- public function __construct(iDB $db)
+ public function __construct(private readonly DBLayer $db)
{
- $this->db = $db->getPDO();
-
$this->config = ConfigFile::open(
file: Config::get('path') . '/config/ignore.yaml',
type: 'yaml',
diff --git a/src/API/System/Integrity.php b/src/API/System/Integrity.php
index 5fd46689..0eb3ce6a 100644
--- a/src/API/System/Integrity.php
+++ b/src/API/System/Integrity.php
@@ -7,14 +7,13 @@ namespace App\API\System;
use App\Libs\Attributes\Route\Delete;
use App\Libs\Attributes\Route\Get;
use App\Libs\Container;
-use App\Libs\Database\DatabaseInterface as iDB;
+use App\Libs\Database\DBLayer;
use App\Libs\DataUtil;
use App\Libs\Entity\StateInterface as iState;
use App\Libs\Enums\Http\Status;
use App\Libs\Middlewares\ExceptionHandlerMiddleware;
use App\Libs\Traits\APITraits;
use DateInterval;
-use PDO;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Psr\SimpleCache\CacheInterface as iCache;
@@ -31,22 +30,20 @@ final class Integrity
private array $checkedFile = [];
private bool $fromCache = false;
- private PDO $pdo;
/**
* @throws InvalidArgumentException
*/
- public function __construct(private iDB $db, private readonly iCache $cache)
+ public function __construct(private readonly iCache $cache)
{
set_time_limit(0);
- $this->pdo = $this->db->getPDO();
}
/**
* @throws InvalidArgumentException
*/
#[Get(self::URL . '[/]', middleware: [ExceptionHandlerMiddleware::class], name: 'system.integrity')]
- public function __invoke(iRequest $request): iResponse
+ public function __invoke(DBLayer $db, iRequest $request): iResponse
{
$params = DataUtil::fromArray($request->getQueryParams());
@@ -66,7 +63,7 @@ final class Integrity
];
$sql = "SELECT * FROM state";
- $stmt = $this->db->getPDO()->prepare($sql);
+ $stmt = $db->prepare($sql);
$stmt->execute();
$base = Container::get(iState::class);
@@ -157,7 +154,7 @@ final class Integrity
* @throws InvalidArgumentException
*/
#[Delete(self::URL . '[/]', name: 'system.integrity.reset')]
- public function resetCache(iRequest $request): iResponse
+ public function resetCache(): iResponse
{
if ($this->cache->has('system.integrity')) {
$this->cache->delete('system.integrity');
diff --git a/src/API/System/Parity.php b/src/API/System/Parity.php
index 39477ef7..f55f6651 100644
--- a/src/API/System/Parity.php
+++ b/src/API/System/Parity.php
@@ -6,11 +6,10 @@ namespace App\API\System;
use App\Libs\Attributes\Route\Delete;
use App\Libs\Attributes\Route\Get;
-use App\Libs\Database\DatabaseInterface as iDB;
+use App\Libs\Database\DBLayer;
use App\Libs\DataUtil;
use App\Libs\Enums\Http\Status;
use App\Libs\Traits\APITraits;
-use PDO;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Psr\SimpleCache\InvalidArgumentException;
@@ -21,11 +20,8 @@ final class Parity
public const string URL = '%{api.prefix}/system/parity';
- private PDO $pdo;
-
- public function __construct(private iDB $db)
+ public function __construct(private readonly DBLayer $db)
{
- $this->pdo = $this->db->getPDO();
}
/**
@@ -60,7 +56,7 @@ final class Parity
$counter = 0 === $counter ? $backendsCount : $counter;
$sql = "SELECT COUNT(*) FROM state WHERE ( SELECT COUNT(*) FROM JSON_EACH(state.metadata) ) < {$counter}";
- $stmt = $this->pdo->query($sql);
+ $stmt = $this->db->query($sql);
$total = (int)$stmt->fetchColumn();
$lastPage = @ceil($total / $perpage);
@@ -84,7 +80,7 @@ final class Parity
:_start, :_perpage
";
- $stmt = $this->db->getPDO()->prepare($sql);
+ $stmt = $this->db->prepare($sql);
$stmt->execute([
'_start' => $start,
'_perpage' => $perpage,
@@ -135,7 +131,7 @@ final class Parity
WHERE
( SELECT COUNT(*) FROM JSON_EACH(state.metadata) ) < {$counter}
";
- $stmt = $this->db->getPDO()->query($sql);
+ $stmt = $this->db->query($sql);
return api_response(Status::OK, [
'deleted_records' => $stmt->rowCount(),
diff --git a/src/Backends/Common/CommonTrait.php b/src/Backends/Common/CommonTrait.php
index a6b5c348..08f97924 100644
--- a/src/Backends/Common/CommonTrait.php
+++ b/src/Backends/Common/CommonTrait.php
@@ -36,6 +36,7 @@ trait CommonTrait
return new Response(
status: false,
error: new Error(
+ ...lw(
message: "{client}: '{backend}' {action} thrown unhandled exception '{error.kind}'. '{error.message}' at '{error.file}:{error.line}'.",
context: [
'action' => $action ?? '',
@@ -56,6 +57,8 @@ trait CommonTrait
'trace' => $e->getTrace(),
]
],
+ e: $e
+ ),
level: Levels::WARNING,
previous: $e
)
diff --git a/src/Backends/Common/Error.php b/src/Backends/Common/Error.php
index 16925077..1f9a8cf4 100644
--- a/src/Backends/Common/Error.php
+++ b/src/Backends/Common/Error.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Backends\Common;
+use App\Libs\Database\DBLayer;
+use App\Libs\Exceptions\DBLayerException;
use Stringable;
use Throwable;
@@ -67,7 +69,17 @@ final readonly class Error implements Stringable
return $this->message;
}
- return r($this->message, $this->context, ['log_behavior' => true]);
+ $context = $this->context;
+
+ if (true === ($this->previous instanceof DBLayerException)) {
+ $context[DBLayer::class] = [
+ 'query' => $this->previous->getQueryString(),
+ 'bind' => $this->previous->getQueryBind(),
+ 'error' => $this->previous->errorInfo ?? [],
+ ];
+ }
+
+ return r($this->message, $context, ['log_behavior' => true]);
}
public function __toString(): string
diff --git a/src/Backends/Emby/Action/ParseWebhook.php b/src/Backends/Emby/Action/ParseWebhook.php
index c9617c45..37f7bd93 100644
--- a/src/Backends/Emby/Action/ParseWebhook.php
+++ b/src/Backends/Emby/Action/ParseWebhook.php
@@ -307,7 +307,8 @@ final class ParseWebhook
],
'trace' => $e->getTrace(),
],
- level: Levels::ERROR
+ level: Levels::ERROR,
+ previous: $e
),
extra: [
'http_code' => 200,
diff --git a/src/Backends/Emby/Action/Progress.php b/src/Backends/Emby/Action/Progress.php
index fc9ba14d..4cea1f8c 100644
--- a/src/Backends/Emby/Action/Progress.php
+++ b/src/Backends/Emby/Action/Progress.php
@@ -226,26 +226,29 @@ class Progress
}
} catch (\App\Libs\Exceptions\RuntimeException|RuntimeException|InvalidArgumentException $e) {
$this->logger->error(
- message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' get {item.type} '{item.title}' status. '{error.message}' at '{error.file}:{error.line}'.",
- contexT: [
- 'action' => $this->action,
- 'client' => $context->clientName,
- 'backend' => $context->backendName,
- ...$logContext,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' get {item.type} '{item.title}' status. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'action' => $this->action,
+ 'client' => $context->clientName,
+ 'backend' => $context->backendName,
+ ...$logContext,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
],
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ]
+ e: $e
+ )
);
continue;
}
@@ -293,26 +296,29 @@ class Progress
}
} catch (Throwable $e) {
$this->logger->error(
- message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' change {item.type} '{item.title}' watch progress. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'action' => $this->action,
- 'client' => $context->clientName,
- 'backend' => $context->backendName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' change {item.type} '{item.title}' watch progress. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'action' => $this->action,
+ 'client' => $context->clientName,
+ 'backend' => $context->backendName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ ...$logContext,
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
],
- ...$logContext,
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ]
+ e: $e
+ )
);
}
}
diff --git a/src/Backends/Jellyfin/Action/Export.php b/src/Backends/Jellyfin/Action/Export.php
index 62bbe2ef..cc3b54e8 100644
--- a/src/Backends/Jellyfin/Action/Export.php
+++ b/src/Backends/Jellyfin/Action/Export.php
@@ -91,11 +91,17 @@ class Export extends Import
'type' => $type,
];
} catch (InvalidArgumentException $e) {
- $this->logger->info($e->getMessage(), [
- 'backend' => $context->backendName,
- ...$logContext,
- 'body' => $item,
- ]);
+ $this->logger->info(
+ ...lw(
+ message: $e->getMessage(),
+ context: [
+ 'backend' => $context->backendName,
+ ...$logContext,
+ 'body' => $item,
+ ],
+ e: $e
+ )
+ );
return;
}
@@ -257,25 +263,28 @@ class Export extends Import
);
} catch (Throwable $e) {
$this->logger->error(
- message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] export. Error [{error.message} @ {error.file}:{error.line}].',
- context: [
- 'backend' => $context->backendName,
- 'client' => $context->clientName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] export. Error [{error.message} @ {error.file}:{error.line}].',
+ context: [
+ 'backend' => $context->backendName,
+ 'client' => $context->clientName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ ...$logContext,
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
],
- ...$logContext,
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ]
+ e: $e
+ )
);
}
}
diff --git a/src/Backends/Jellyfin/Action/GetLibrariesList.php b/src/Backends/Jellyfin/Action/GetLibrariesList.php
index 8629cf0d..d1ef5fe8 100644
--- a/src/Backends/Jellyfin/Action/GetLibrariesList.php
+++ b/src/Backends/Jellyfin/Action/GetLibrariesList.php
@@ -77,7 +77,10 @@ class GetLibrariesList
$this->logger
);
} catch (RuntimeException $e) {
- return new Response(status: false, error: new Error(message: $e->getMessage(), level: Levels::ERROR));
+ return new Response(
+ status: false,
+ error: new Error(message: $e->getMessage(), level: Levels::ERROR, previous: $e)
+ );
}
if ($context->trace) {
diff --git a/src/Backends/Jellyfin/Action/Import.php b/src/Backends/Jellyfin/Action/Import.php
index 99b6b915..6c197e9b 100644
--- a/src/Backends/Jellyfin/Action/Import.php
+++ b/src/Backends/Jellyfin/Action/Import.php
@@ -192,69 +192,78 @@ class Import
}
} catch (ExceptionInterface $e) {
$this->logger->error(
- message: "Request for '{client}: {backend}' libraries has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'client' => $context->clientName,
- 'backend' => $context->backendName,
- 'error' => [
- 'line' => $e->getLine(),
- 'kind' => $e::class,
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Request for '{client}: {backend}' libraries has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'client' => $context->clientName,
+ 'backend' => $context->backendName,
+ 'error' => [
+ 'line' => $e->getLine(),
+ 'kind' => $e::class,
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => $e::class,
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
],
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => $e::class,
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ]
+ e: $e
+ )
);
Message::add("{$context->backendName}.has_errors", true);
return [];
} catch (JsonException $e) {
$this->logger->error(
- message: "Request for '{client}: {backend}' libraries returned with invalid body. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'client' => $context->clientName,
- 'backend' => $context->backendName,
- 'error' => [
- 'line' => $e->getLine(),
- 'kind' => $e::class,
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Request for '{client}: {backend}' libraries returned with invalid body. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'client' => $context->clientName,
+ 'backend' => $context->backendName,
+ 'error' => [
+ 'line' => $e->getLine(),
+ 'kind' => $e::class,
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
],
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ]
+ e: $e
+ )
);
Message::add("{$context->backendName}.has_errors", true);
return [];
} catch (Throwable $e) {
$this->logger->error(
- message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' request for libraries. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'backend' => $context->backendName,
- 'client' => $context->clientName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' request for libraries. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'backend' => $context->backendName,
+ 'client' => $context->clientName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
],
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ]
+ e: $e
+ )
);
Message::add("{$context->backendName}.has_errors", true);
return [];
@@ -319,48 +328,54 @@ class Import
);
} catch (ExceptionInterface $e) {
$this->logger->error(
- message: "Request for '{client}: {backend}' '{library.title}' items count failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'client' => $context->clientName,
- 'backend' => $context->backendName,
- 'error' => [
- 'line' => $e->getLine(),
- 'kind' => $e::class,
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Request for '{client}: {backend}' '{library.title}' items count failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'client' => $context->clientName,
+ 'backend' => $context->backendName,
+ 'error' => [
+ 'line' => $e->getLine(),
+ 'kind' => $e::class,
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
+ ...$logContext,
],
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ...$logContext,
- ]
+ e: $e
+ )
);
continue;
} catch (Throwable $e) {
$this->logger->error(
- message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title}' items count request. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'client' => $context->clientName,
- 'backend' => $context->backendName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title}' items count request. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'client' => $context->clientName,
+ 'backend' => $context->backendName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
+ ...$logContext,
],
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ...$logContext,
- ]
+ e: $e
+ )
);
continue;
}
@@ -404,6 +419,7 @@ class Import
$total[ag($logContext, 'library.id')] = $totalCount;
} catch (ExceptionInterface $e) {
$this->logger->error(
+ ...lw(
message: "Request for '{client}: {backend}' '{library.title}' total items has failed. '{error.kind}' '{error.message}' at '{error.file}:{error.line}'.",
context: [
'client' => $context->backendName,
@@ -422,31 +438,35 @@ class Import
'trace' => $e->getTrace(),
],
...$logContext,
-
- ]
+ ],
+ e: $e
+ ),
);
continue;
} catch (Throwable $e) {
$this->logger->error(
- message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' requests for items count. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'client' => $context->clientName,
- 'backend' => $context->backendName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' requests for items count. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'client' => $context->clientName,
+ 'backend' => $context->backendName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
+ ...$logContext,
],
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ...$logContext,
- ]
+ e: $e
+ )
);
continue;
}
@@ -515,29 +535,33 @@ class Import
);
} catch (ExceptionInterface $e) {
$this->logger->error(
- message: "Request for '{client}: {backend}' '{library.title}' series external ids has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'client' => $context->clientName,
- 'backend' => $context->backendName,
- 'error' => [
- 'line' => $e->getLine(),
- 'kind' => $e::class,
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Request for '{client}: {backend}' '{library.title}' series external ids has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'client' => $context->clientName,
+ 'backend' => $context->backendName,
+ 'error' => [
+ 'line' => $e->getLine(),
+ 'kind' => $e::class,
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
+ ...$logContext,
],
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ...$logContext,
- ]
+ e: $e
+ )
);
continue;
} catch (Throwable $e) {
$this->logger->error(
+ ...lw(
message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title}' series external ids request. '{error.message}' at '{error.file}:{error.line}'.",
context: [
'backend' => $context->backendName,
@@ -556,7 +580,9 @@ class Import
'trace' => $e->getTrace(),
],
...$logContext,
- ]
+ ],
+ e: $e
+ ),
);
continue;
}
@@ -663,48 +689,54 @@ class Import
);
} catch (ExceptionInterface $e) {
$this->logger->error(
- "Request for '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' content list has failed. {error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
- [
- 'client' => $context->clientName,
- 'backend' => $context->backendName,
- 'error' => [
- 'line' => $e->getLine(),
- 'kind' => $e::class,
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Request for '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' content list has failed. {error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'client' => $context->clientName,
+ 'backend' => $context->backendName,
+ 'error' => [
+ 'line' => $e->getLine(),
+ 'kind' => $e::class,
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ ...$logContext,
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
],
- ...$logContext,
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ]
+ e: $e
+ )
);
continue;
} catch (Throwable $e) {
$this->logger->error(
- message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' content list request. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'backend' => $context->backendName,
- 'client' => $context->clientName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' content list request. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'backend' => $context->backendName,
+ 'client' => $context->clientName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ ...$logContext,
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
],
- ...$logContext,
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ]
+ e: $e
+ )
);
continue;
}
@@ -850,50 +882,56 @@ class Import
}
} catch (Throwable $e) {
$this->logger->error(
- message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing '{library.title} {segment.number}/{segment.of}' item response. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'backend' => $context->backendName,
- 'client' => $context->clientName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing '{library.title} {segment.number}/{segment.of}' item response. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'backend' => $context->backendName,
+ 'client' => $context->clientName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'entity' => $entity,
+ 'exception' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'trace' => $e->getTrace(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ ...$logContext,
],
- 'entity' => $entity,
- 'exception' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'trace' => $e->getTrace(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
- ],
- ...$logContext,
- ]
+ e: $e
+ )
);
}
}
} catch (Throwable $e) {
$this->logger->error(
- message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing of '{library.title} {segment.number}/{segment.of}' response. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'backend' => $context->backendName,
- 'client' => $context->clientName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing of '{library.title} {segment.number}/{segment.of}' response. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'backend' => $context->backendName,
+ 'client' => $context->clientName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'exception' => [
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ ...$logContext,
],
- 'exception' => [
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- 'file' => after($e->getFile(), ROOT_PATH),
- ],
- ...$logContext,
- ]
+ e: $e
+ )
);
}
@@ -1035,19 +1073,22 @@ class Import
];
} catch (InvalidArgumentException $e) {
$this->logger->error(
- "Failed to parse '{client}: {backend}' item response. '{error.kind}' with '{error.message}' at '{error.file}:{error.line}' ",
- [
- 'client' => $context->clientName,
- 'backend' => $context->backendName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Failed to parse '{client}: {backend}' item response. '{error.kind}' with '{error.message}' at '{error.file}:{error.line}' ",
+ context: [
+ 'client' => $context->clientName,
+ 'backend' => $context->backendName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'body' => $item,
+ ...$logContext,
],
- 'body' => $item,
- ...$logContext,
- ]
+ e: $e
+ )
);
return;
}
@@ -1110,18 +1151,21 @@ class Import
);
} catch (Throwable $e) {
$this->logger->error(
- message: "Exception '{error.kind}' occurred during '{client}: {backend}' '{library.title}' '{item.id}: {item.title}' entity creation. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'backend' => $context->backendName,
- 'client' => $context->clientName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Exception '{error.kind}' occurred during '{client}: {backend}' '{library.title}' '{item.id}: {item.title}' entity creation. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'backend' => $context->backendName,
+ 'client' => $context->clientName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ ...$logContext,
],
- ...$logContext,
- ]
+ e: $e
+ )
);
Message::increment("{$context->backendName}.{$mappedType}.ignored_no_date_is_set");
@@ -1155,26 +1199,29 @@ class Import
$mapper->add(entity: $entity, opts: $opts);
} catch (Throwable $e) {
$this->logger->error(
- message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title}' '{item.title}' {action}. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'action' => property_exists($this, 'action') ? $this->action : 'import',
- 'backend' => $context->backendName,
- 'client' => $context->clientName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title}' '{item.title}' {action}. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'action' => property_exists($this, 'action') ? $this->action : 'import',
+ 'backend' => $context->backendName,
+ 'client' => $context->clientName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
+ ...$logContext,
],
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ...$logContext,
- ]
+ e: $e
+ )
);
}
}
diff --git a/src/Backends/Jellyfin/Action/ParseWebhook.php b/src/Backends/Jellyfin/Action/ParseWebhook.php
index a8637fcd..e04d4282 100644
--- a/src/Backends/Jellyfin/Action/ParseWebhook.php
+++ b/src/Backends/Jellyfin/Action/ParseWebhook.php
@@ -265,7 +265,8 @@ final class ParseWebhook
'payload' => $request->getParsedBody(),
],
],
- level: Levels::ERROR
+ level: Levels::ERROR,
+ previous: $e
),
extra: [
'http_code' => 200,
diff --git a/src/Backends/Jellyfin/Action/Progress.php b/src/Backends/Jellyfin/Action/Progress.php
index c1aa9c2d..09549fb7 100644
--- a/src/Backends/Jellyfin/Action/Progress.php
+++ b/src/Backends/Jellyfin/Action/Progress.php
@@ -254,6 +254,7 @@ class Progress
}
} catch (\App\Libs\Exceptions\RuntimeException|RuntimeException|InvalidArgumentException $e) {
$this->logger->error(
+ ...lw(
message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' get {item.type} '{item.title}' status. '{error.message}' at '{error.file}:{error.line}'.",
context: [
'action' => $this->action,
@@ -273,7 +274,9 @@ class Progress
'message' => $e->getMessage(),
'trace' => $e->getTrace(),
],
- ]
+ ],
+ e: $e
+ ),
);
continue;
}
@@ -321,26 +324,29 @@ class Progress
}
} catch (Throwable $e) {
$this->logger->error(
- message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' change {item.type} '{item.title}' watch progress. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'action' => $this->action,
- 'backend' => $context->backendName,
- 'client' => $context->clientName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' change {item.type} '{item.title}' watch progress. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'action' => $this->action,
+ 'backend' => $context->backendName,
+ 'client' => $context->clientName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ ...$logContext,
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
],
- ...$logContext,
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ]
+ e: $e
+ )
);
}
}
diff --git a/src/Backends/Jellyfin/Action/Push.php b/src/Backends/Jellyfin/Action/Push.php
index 745ad177..c3a9295b 100644
--- a/src/Backends/Jellyfin/Action/Push.php
+++ b/src/Backends/Jellyfin/Action/Push.php
@@ -145,25 +145,28 @@ class Push
);
} catch (Throwable $e) {
$this->logger->error(
- message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] request for {item.type} [{item.title}] metadata. Error [{error.message} @ {error.file}:{error.line}].',
- context: [
- 'backend' => $context->backendName,
- 'client' => $context->clientName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] request for {item.type} [{item.title}] metadata. Error [{error.message} @ {error.file}:{error.line}].',
+ context: [
+ 'backend' => $context->backendName,
+ 'client' => $context->clientName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ ...$logContext,
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
],
- ...$logContext,
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ]
+ e: $e
+ )
);
}
}
@@ -327,25 +330,28 @@ class Push
}
} catch (Throwable $e) {
$this->logger->error(
- message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] parsing [{library.title}] [{segment.number}/{segment.of}] response. Error [{error.message} @ {error.file}:{error.line}].',
- context: [
- 'backend' => $context->backendName,
- 'client' => $context->clientName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] parsing [{library.title}] [{segment.number}/{segment.of}] response. Error [{error.message} @ {error.file}:{error.line}].',
+ context: [
+ 'backend' => $context->backendName,
+ 'client' => $context->clientName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ ...$logContext,
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
],
- ...$logContext,
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ]
+ e: $e
+ )
);
}
}
diff --git a/src/Backends/Plex/Action/Export.php b/src/Backends/Plex/Action/Export.php
index 6eff7215..4b5cb5cb 100644
--- a/src/Backends/Plex/Action/Export.php
+++ b/src/Backends/Plex/Action/Export.php
@@ -76,19 +76,22 @@ final class Export extends Import
];
} catch (InvalidArgumentException $e) {
$this->logger->error(
- "Failed to parse '{client}: {backend}' item response. '{error.kind}' with '{error.message}' at '{error.file}:{error.line}' ",
- [
- 'client' => $context->clientName,
- 'backend' => $context->backendName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Failed to parse '{client}: {backend}' item response. '{error.kind}' with '{error.message}' at '{error.file}:{error.line}' ",
+ context: [
+ 'client' => $context->clientName,
+ 'backend' => $context->backendName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ ...$logContext,
+ 'body' => $item,
],
- ...$logContext,
- 'body' => $item,
- ]
+ e: $e
+ )
);
return;
}
@@ -242,25 +245,28 @@ final class Export extends Import
])));
} catch (Throwable $e) {
$this->logger->error(
- message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' export. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'backend' => $context->backendName,
- 'client' => $context->clientName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' export. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'backend' => $context->backendName,
+ 'client' => $context->clientName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ ...$logContext,
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
],
- ...$logContext,
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ]
+ e: $e
+ )
);
}
}
diff --git a/src/Backends/Plex/Action/GetLibrariesList.php b/src/Backends/Plex/Action/GetLibrariesList.php
index 85b833a1..4f6c77eb 100644
--- a/src/Backends/Plex/Action/GetLibrariesList.php
+++ b/src/Backends/Plex/Action/GetLibrariesList.php
@@ -66,7 +66,10 @@ final class GetLibrariesList
$this->logger
);
} catch (RuntimeException $e) {
- return new Response(status: false, error: new Error(message: $e->getMessage(), level: Levels::ERROR));
+ return new Response(
+ status: false,
+ error: new Error(message: $e->getMessage(), level: Levels::ERROR, previous: $e)
+ );
}
if ($context->trace) {
@@ -82,7 +85,7 @@ final class GetLibrariesList
return new Response(
status: false,
error: new Error(
- message: 'Request for [{backend}] libraries returned empty list.',
+ message: "Request for '{backend}' libraries returned empty list.",
context: [
'backend' => $context->backendName,
'response' => [
@@ -153,7 +156,7 @@ final class GetLibrariesList
{
$url = $context->backendUrl->withPath('/library/sections');
- $this->logger->debug('Requesting [{backend}] libraries list.', [
+ $this->logger->debug("Requesting '{backend}' libraries list.", [
'backend' => $context->backendName,
'url' => (string)$url
]);
@@ -163,7 +166,7 @@ final class GetLibrariesList
$payload = $response->getContent(false);
if ($context->trace) {
- $this->logger->debug('Processing [{backend}] response.', [
+ $this->logger->debug("Processing '{backend}' response.", [
'backend' => $context->backendName,
'url' => (string)$url,
'response' => $payload,
@@ -172,13 +175,10 @@ final class GetLibrariesList
if (200 !== $response->getStatusCode()) {
throw new RuntimeException(
- r(
- 'Request for [{backend}] libraries returned with unexpected [{status_code}] status code.',
- [
- 'backend' => $context->backendName,
- 'status_code' => $response->getStatusCode(),
- ]
- )
+ r("Request for '{backend}' libraries returned with unexpected '{status_code}' status code.", [
+ 'backend' => $context->backendName,
+ 'status_code' => $response->getStatusCode(),
+ ])
);
}
diff --git a/src/Backends/Plex/Action/GetUserToken.php b/src/Backends/Plex/Action/GetUserToken.php
index 63ffd5c0..324e878d 100644
--- a/src/Backends/Plex/Action/GetUserToken.php
+++ b/src/Backends/Plex/Action/GetUserToken.php
@@ -246,7 +246,8 @@ final class GetUserToken
'trace' => $e->getTrace(),
],
],
- level: Levels::ERROR
+ level: Levels::ERROR,
+ previous: $e
),
);
}
diff --git a/src/Backends/Plex/Action/Import.php b/src/Backends/Plex/Action/Import.php
index d30c700f..db70a3b4 100644
--- a/src/Backends/Plex/Action/Import.php
+++ b/src/Backends/Plex/Action/Import.php
@@ -173,69 +173,78 @@ class Import
}
} catch (ExceptionInterface $e) {
$this->logger->error(
- message: "Request for '{client}: {backend}' libraries has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'client' => $context->clientName,
- 'backend' => $context->backendName,
- 'error' => [
- 'line' => $e->getLine(),
- 'kind' => $e::class,
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Request for '{client}: {backend}' libraries has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'client' => $context->clientName,
+ 'backend' => $context->backendName,
+ 'error' => [
+ 'line' => $e->getLine(),
+ 'kind' => $e::class,
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => $e::class,
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
],
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => $e::class,
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ]
+ e: $e
+ )
);
Message::add("{$context->backendName}.has_errors", true);
return [];
} catch (JsonException $e) {
$this->logger->error(
- message: "Request for '{client}: {backend}' libraries returned with invalid body. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'client' => $context->clientName,
- 'backend' => $context->backendName,
- 'error' => [
- 'line' => $e->getLine(),
- 'kind' => $e::class,
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Request for '{client}: {backend}' libraries returned with invalid body. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'client' => $context->clientName,
+ 'backend' => $context->backendName,
+ 'error' => [
+ 'line' => $e->getLine(),
+ 'kind' => $e::class,
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
],
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ]
+ e: $e
+ )
);
Message::add("{$context->backendName}.has_errors", true);
return [];
} catch (Throwable $e) {
$this->logger->error(
- message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' request for libraries. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'backend' => $context->backendName,
- 'client' => $context->clientName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' request for libraries. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'backend' => $context->backendName,
+ 'client' => $context->clientName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
],
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ]
+ e: $e
+ )
);
Message::add("{$context->backendName}.has_errors", true);
return [];
@@ -351,48 +360,54 @@ class Import
}
} catch (ExceptionInterface $e) {
$this->logger->error(
- message: "Request for '{client}: {backend}' '{library.title}' items count has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'client' => $context->clientName,
- 'backend' => $context->backendName,
- 'error' => [
- 'line' => $e->getLine(),
- 'kind' => $e::class,
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Request for '{client}: {backend}' '{library.title}' items count has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'client' => $context->clientName,
+ 'backend' => $context->backendName,
+ 'error' => [
+ 'line' => $e->getLine(),
+ 'kind' => $e::class,
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
+ ...$logContext,
],
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ...$logContext,
- ]
+ e: $e
+ )
);
continue;
} catch (Throwable $e) {
$this->logger->error(
- message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' request for libraries. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'client' => $context->clientName,
- 'backend' => $context->backendName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' request for libraries. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'client' => $context->clientName,
+ 'backend' => $context->backendName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
+ ...$logContext,
],
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ...$logContext,
- ]
+ e: $e
+ )
);
continue;
}
@@ -438,48 +453,54 @@ class Import
}
} catch (ExceptionInterface $e) {
$this->logger->error(
- message: "Request for '{client}: {backend}' '{library.title}' total items has failed. '{error.kind}' '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'client' => $context->backendName,
- 'backend' => $context->backendName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Request for '{client}: {backend}' '{library.title}' total items has failed. '{error.kind}' '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'client' => $context->backendName,
+ 'backend' => $context->backendName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
+ ...$logContext,
],
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ...$logContext,
- ]
+ e: $e
+ )
);
continue;
} catch (Throwable $e) {
$this->logger->error(
- message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' request for items count. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'client' => $context->clientName,
- 'backend' => $context->backendName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' request for items count. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'client' => $context->clientName,
+ 'backend' => $context->backendName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
+ ...$logContext,
],
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ...$logContext,
- ]
+ e: $e
+ )
);
continue;
}
@@ -576,48 +597,54 @@ class Import
);
} catch (ExceptionInterface $e) {
$this->logger->error(
- message: "Request for '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' series external ids has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'client' => $context->clientName,
- 'backend' => $context->backendName,
- 'error' => [
- 'line' => $e->getLine(),
- 'kind' => $e::class,
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Request for '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' series external ids has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'client' => $context->clientName,
+ 'backend' => $context->backendName,
+ 'error' => [
+ 'line' => $e->getLine(),
+ 'kind' => $e::class,
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
+ ...$logContext,
],
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ...$logContext,
- ]
+ e: $e
+ )
);
continue;
} catch (Throwable $e) {
$this->logger->error(
- message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' series external ids request. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'backend' => $context->backendName,
- 'client' => $context->clientName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' series external ids request. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'backend' => $context->backendName,
+ 'client' => $context->clientName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ ...$logContext,
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
],
- ...$logContext,
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ]
+ e: $e
+ )
);
continue;
}
@@ -732,48 +759,54 @@ class Import
);
} catch (ExceptionInterface $e) {
$this->logger->error(
- "Request for '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' content list has failed. {error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
- [
- 'client' => $context->clientName,
- 'backend' => $context->backendName,
- 'error' => [
- 'line' => $e->getLine(),
- 'kind' => $e::class,
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Request for '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' content list has failed. {error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'client' => $context->clientName,
+ 'backend' => $context->backendName,
+ 'error' => [
+ 'line' => $e->getLine(),
+ 'kind' => $e::class,
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ ...$logContext,
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
],
- ...$logContext,
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ]
+ e: $e
+ )
);
continue;
} catch (Throwable $e) {
$this->logger->error(
- message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' content list request. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'backend' => $context->backendName,
- 'client' => $context->clientName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' content list request. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'backend' => $context->backendName,
+ 'client' => $context->clientName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ ...$logContext,
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
],
- ...$logContext,
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ]
+ e: $e
+ )
);
continue;
}
@@ -863,50 +896,56 @@ class Import
$callback(item: $entity, logContext: $logContext);
} catch (Throwable $e) {
$this->logger->error(
- message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing '{library.title} {segment.number}/{segment.of}' item response. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'backend' => $context->backendName,
- 'client' => $context->clientName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing '{library.title} {segment.number}/{segment.of}' item response. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'backend' => $context->backendName,
+ 'client' => $context->clientName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'entity' => $entity,
+ 'exception' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'trace' => $e->getTrace(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ ...$logContext,
],
- 'entity' => $entity,
- 'exception' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'trace' => $e->getTrace(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
- ],
- ...$logContext,
- ]
+ e: $e
+ )
);
}
}
} catch (Throwable $e) {
$this->logger->error(
- message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing of '{library.title} {segment.number}/{segment.of}' response. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'backend' => $context->backendName,
- 'client' => $context->clientName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing of '{library.title} {segment.number}/{segment.of}' response. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'backend' => $context->backendName,
+ 'client' => $context->clientName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'exception' => [
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ ...$logContext,
],
- 'exception' => [
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- 'file' => after($e->getFile(), ROOT_PATH),
- ],
- ...$logContext,
- ]
+ e: $e
+ )
);
}
@@ -1051,19 +1090,22 @@ class Import
];
} catch (InvalidArgumentException $e) {
$this->logger->error(
- "Failed to parse '{client}: {backend}' item response. '{error.kind}' with '{error.message}' at '{error.file}:{error.line}' ",
- [
- 'client' => $context->clientName,
- 'backend' => $context->backendName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Failed to parse '{client}: {backend}' item response. '{error.kind}' with '{error.message}' at '{error.file}:{error.line}' ",
+ context: [
+ 'client' => $context->clientName,
+ 'backend' => $context->backendName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ ...$logContext,
+ 'body' => $item,
],
- ...$logContext,
- 'body' => $item,
- ]
+ e: $e
+ )
);
return;
}
@@ -1103,18 +1145,21 @@ class Import
);
} catch (Throwable $e) {
$this->logger->error(
- message: "Exception '{error.kind}' occurred during '{client}: {backend}' '{library.title}' '{item.id}: {item.title}' entity creation. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'backend' => $context->backendName,
- 'client' => $context->clientName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Exception '{error.kind}' occurred during '{client}: {backend}' '{library.title}' '{item.id}: {item.title}' entity creation. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'backend' => $context->backendName,
+ 'client' => $context->clientName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ ...$logContext,
],
- ...$logContext,
- ]
+ e: $e
+ )
);
return;
}
@@ -1151,26 +1196,29 @@ class Import
]);
} catch (Throwable $e) {
$this->logger->error(
- message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title}' '{item.title}' {action}. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'action' => property_exists($this, 'action') ? $this->action : 'import',
- 'backend' => $context->backendName,
- 'client' => $context->clientName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title}' '{item.title}' {action}. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'action' => property_exists($this, 'action') ? $this->action : 'import',
+ 'backend' => $context->backendName,
+ 'client' => $context->clientName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
+ ...$logContext,
],
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ...$logContext,
- ]
+ e: $e
+ )
);
}
}
diff --git a/src/Backends/Plex/Action/Progress.php b/src/Backends/Plex/Action/Progress.php
index c193da4d..b4b383f5 100644
--- a/src/Backends/Plex/Action/Progress.php
+++ b/src/Backends/Plex/Action/Progress.php
@@ -230,26 +230,29 @@ class Progress
}
} catch (\App\Libs\Exceptions\RuntimeException|RuntimeException|InvalidArgumentException $e) {
$this->logger->error(
- message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' get {item.type} '{item.title}' status. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'action' => $this->action,
- 'backend' => $context->backendName,
- 'client' => $context->clientName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' get {item.type} '{item.title}' status. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'action' => $this->action,
+ 'backend' => $context->backendName,
+ 'client' => $context->clientName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ ...$logContext,
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
],
- ...$logContext,
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ]
+ e: $e
+ )
);
continue;
}
@@ -305,26 +308,29 @@ class Progress
}
} catch (Throwable $e) {
$this->logger->error(
- message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' change {item.type} '{item.title}' watch progress. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'action' => $this->action,
- 'backend' => $context->backendName,
- 'client' => $context->clientName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' change {item.type} '{item.title}' watch progress. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'action' => $this->action,
+ 'backend' => $context->backendName,
+ 'client' => $context->clientName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ ...$logContext,
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
],
- ...$logContext,
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ]
+ e: $e
+ )
);
}
}
diff --git a/src/Backends/Plex/Action/Push.php b/src/Backends/Plex/Action/Push.php
index e6f24bbe..42757983 100644
--- a/src/Backends/Plex/Action/Push.php
+++ b/src/Backends/Plex/Action/Push.php
@@ -115,25 +115,28 @@ final class Push
);
} catch (Throwable $e) {
$this->logger->error(
- message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] request for {item.type} [{item.title}] metadata. Error [{error.message} @ {error.file}:{error.line}].',
- context: [
- 'backend' => $context->backendName,
- 'client' => $context->clientName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] request for {item.type} [{item.title}] metadata. Error [{error.message} @ {error.file}:{error.line}].',
+ context: [
+ 'backend' => $context->backendName,
+ 'client' => $context->clientName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ ...$logContext,
+ 'exception' => [
+ 'file' => after($e->getFile(), ROOT_PATH),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
],
- ...$logContext,
- 'exception' => [
- 'file' => after($e->getFile(), ROOT_PATH),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ]
+ e: $e
+ )
);
}
}
@@ -308,25 +311,28 @@ final class Push
}
} catch (Throwable $e) {
$this->logger->error(
- message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] push. Error [{error.message} @ {error.file}:{error.line}].',
- context: [
- 'backend' => $context->backendName,
- 'client' => $context->clientName,
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] push. Error [{error.message} @ {error.file}:{error.line}].',
+ context: [
+ 'backend' => $context->backendName,
+ 'client' => $context->clientName,
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ ...$logContext,
+ 'exception' => [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'kind' => get_class($e),
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTrace(),
+ ],
],
- ...$logContext,
- 'exception' => [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'kind' => get_class($e),
- 'message' => $e->getMessage(),
- 'trace' => $e->getTrace(),
- ],
- ]
+ e: $e
+ )
);
}
}
diff --git a/src/Commands/System/ReportCommand.php b/src/Commands/System/ReportCommand.php
index febc3460..4b847b31 100644
--- a/src/Commands/System/ReportCommand.php
+++ b/src/Commands/System/ReportCommand.php
@@ -103,7 +103,7 @@ final class ReportCommand extends Command
$output->writeln(
r('Is the tasks runner working? {answer}', [
'answer' => (function () {
- $info = isTaskWorkerRunning(true);
+ $info = isTaskWorkerRunning(ignoreContainer: true);
return r("{status} '{container}' - {message}", [
'status' => $info['status'] ? 'Yes' : 'No',
'message' => $info['message'],
@@ -246,7 +246,7 @@ final class ReportCommand extends Command
if (true === $includeSample) {
$sql = "SELECT * FROM state WHERE via = :name ORDER BY updated DESC LIMIT 3";
- $stmt = $this->db->getPDO()->prepare($sql);
+ $stmt = $this->db->getDBLayer()->prepare($sql);
$stmt->execute([
'name' => $name,
]);
diff --git a/src/Libs/ConfigFile.php b/src/Libs/ConfigFile.php
index e6ed0227..ae810e75 100644
--- a/src/Libs/ConfigFile.php
+++ b/src/Libs/ConfigFile.php
@@ -161,7 +161,7 @@ final class ConfigFile implements ArrayAccess, LoggerAwareInterface
if (false === $override) {
clearstatcache(true, $this->file);
$newHash = $this->getFileHash();
- if ($newHash !== $this->file_hash) {
+ if (false === hash_equals($this->file_hash, $newHash)) {
$this->logger?->warning(
"File '{file}' has been modified since last load. re-applying changes on top of the new data.",
[
diff --git a/src/Libs/Database/DBLayer.php b/src/Libs/Database/DBLayer.php
index 6cf5b7b0..85d5f3ba 100644
--- a/src/Libs/Database/DBLayer.php
+++ b/src/Libs/Database/DBLayer.php
@@ -5,15 +5,19 @@ declare(strict_types=1);
namespace App\Libs\Database;
-use App\Libs\Exceptions\DatabaseException as DBException;
+use App\Libs\Exceptions\DBLayerException;
use Closure;
use PDO;
use PDOException;
use PDOStatement;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerAwareTrait;
use RuntimeException;
-final class DBLayer
+final class DBLayer implements LoggerAwareInterface
{
+ use LoggerAwareTrait;
+
private const int LOCK_RETRY = 4;
private int $count = 0;
@@ -61,55 +65,71 @@ final class DBLayer
public function exec(string $sql, array $options = []): int|false
{
try {
- $queryString = $sql;
+ return $this->wrap(function (DBLayer $db) use ($sql, $options) {
+ $queryString = $sql;
- $this->last = [
- 'sql' => $queryString,
- 'bind' => [],
- ];
+ $this->last = [
+ 'sql' => $queryString,
+ 'bind' => [],
+ ];
- $stmt = $this->pdo->exec($queryString);
+ return $db->pdo->exec($queryString);
+ });
} catch (PDOException $e) {
- throw (new DBException($e->getMessage()))
- ->setInfo($queryString, [], $e->errorInfo ?? [], $e->getCode())
- ->setFile($e->getTrace()[$options['tracer'] ?? 1]['file'] ?? $e->getFile())
- ->setLine($e->getTrace()[$options['tracer'] ?? 1]['line'] ?? $e->getLine())
- ->setOptions([]);
- }
-
- return $stmt;
- }
-
- public function query(string $queryString, array $bind = [], array $options = []): PDOStatement
- {
- try {
- $this->last = [
- 'sql' => $queryString,
- 'bind' => $bind,
- ];
-
- $stmt = $this->pdo->prepare($queryString);
-
- if (!($stmt instanceof PDOStatement)) {
- throw new PDOException('Unable to prepare statement.');
+ if ($e instanceof DBLayerException) {
+ throw $e;
}
- $stmt->execute($bind);
-
- if (false !== stripos($queryString, 'SQL_CALC_FOUND_ROWS')) {
- if (false !== ($countStatement = $this->pdo->query('SELECT FOUND_ROWS();'))) {
- $this->count = (int)$countStatement->fetch(PDO::FETCH_COLUMN);
- }
- }
- } catch (PDOException $e) {
- throw (new DBException($e->getMessage()))
- ->setInfo($queryString, $bind, $e->errorInfo ?? [], $e->getCode())
+ throw (new DBLayerException($e->getMessage()))
+ ->setInfo($sql, [], $e->errorInfo ?? [], $e->getCode())
->setFile($e->getTrace()[$options['tracer'] ?? 1]['file'] ?? $e->getFile())
->setLine($e->getTrace()[$options['tracer'] ?? 1]['line'] ?? $e->getLine())
->setOptions($options);
}
+ }
- return $stmt;
+ public function query(string|PDOStatement $sql, array $bind = [], array $options = []): PDOStatement
+ {
+ try {
+ return $this->wrap(function (DBLayer $db) use ($sql, $bind, $options) {
+ $isStatement = $sql instanceof PDOStatement;
+ $queryString = $isStatement ? $sql->queryString : $sql;
+
+ $this->last = [
+ 'sql' => $queryString,
+ 'bind' => $bind,
+ ];
+
+ $stmt = $isStatement ? $sql : $db->prepare($sql);
+ if (false === ($stmt instanceof PDOStatement)) {
+ throw new PDOException('Unable to prepare statement.');
+ }
+
+ $stmt->execute($bind);
+
+ if (false !== stripos($queryString, 'SQL_CALC_FOUND_ROWS')) {
+ if (false !== ($countStatement = $this->pdo->query('SELECT FOUND_ROWS();'))) {
+ $this->count = (int)$countStatement->fetch(PDO::FETCH_COLUMN);
+ }
+ }
+
+ return $stmt;
+ });
+ } catch (PDOException $e) {
+ if ($e instanceof DBLayerException) {
+ throw $e;
+ }
+ throw (new DBLayerException($e->getMessage(), (int)$e->getCode(), $e))
+ ->setInfo(
+ (true === ($sql instanceof PDOStatement)) ? $sql->queryString : $sql,
+ $bind,
+ $e->errorInfo ?? [],
+ $e->getCode()
+ )
+ ->setFile($e->getTrace()[$options['tracer'] ?? 1]['file'] ?? $e->getFile())
+ ->setLine($e->getTrace()[$options['tracer'] ?? 1]['line'] ?? $e->getLine())
+ ->setOptions($options);
+ }
}
public function start(): bool
@@ -136,6 +156,25 @@ final class DBLayer
return $this->pdo->inTransaction();
}
+ /**
+ * @return bool
+ * @deprecated Use {@link self::start()} instead.
+ */
+ public function beginTransaction(): bool
+ {
+ return $this->start();
+ }
+
+ public function prepare(string $sql, array $options = []): PDOStatement|false
+ {
+ return $this->pdo->prepare($sql, $options);
+ }
+
+ public function lastInsertId(): string|false
+ {
+ return $this->pdo->lastInsertId();
+ }
+
public function delete(string $table, array $conditions, array $options = []): PDOStatement
{
if (empty($conditions)) {
@@ -422,6 +461,11 @@ final class DBLayer
return $this->driver;
}
+ public function getBackend(): PDO
+ {
+ return $this->pdo;
+ }
+
private function conditionParser(array $conditions): array
{
$keys = $bind = [];
@@ -699,21 +743,22 @@ final class DBLayer
for ($i = 1; $i <= self::LOCK_RETRY; $i++) {
try {
- if (!$autoStartTransaction) {
+ if (true === $autoStartTransaction) {
$this->start();
}
$result = $callback($this);
- if (!$autoStartTransaction) {
+ if (true === $autoStartTransaction) {
$this->commit();
}
$this->last = $this->getLastStatement();
return $result;
- } catch (DBException $e) {
- if (!$autoStartTransaction && $this->inTransaction()) {
+ } catch (DBLayerException $e) {
+ /** @noinspection PhpConditionAlreadyCheckedInspection */
+ if ($autoStartTransaction && $this->inTransaction()) {
$this->rollBack();
}
@@ -737,4 +782,37 @@ final class DBLayer
*/
return null;
}
+
+ private function wrap(Closure $callback): mixed
+ {
+ for ($i = 0; $i <= self::LOCK_RETRY; $i++) {
+ try {
+ return $callback($this);
+ } catch (PDOException $e) {
+ if (true === str_contains(strtolower($e->getMessage()), 'database is locked')) {
+ if ($i >= self::LOCK_RETRY) {
+ throw (new DBLayerException($e->getMessage(), (int)$e->getCode(), $e))
+ ->setInfo($stnt->queryString, $bind, $e->errorInfo ?? [], $e->getCode())
+ ->setFile($e->getFile())
+ ->setLine($e->getLine());
+ }
+
+ $sleep = self::LOCK_RETRY + random_int(1, 3);
+
+ $this->logger?->warning("PDOAdapter: Database is locked. sleeping for '{sleep}s'.", [
+ 'sleep' => $sleep
+ ]);
+
+ sleep($sleep);
+ } else {
+ throw (new DBLayerException($e->getMessage(), (int)$e->getCode(), $e))
+ ->setInfo($stnt->queryString, $bind, $e->errorInfo ?? [], $e->getCode())
+ ->setFile($e->getFile())
+ ->setLine($e->getLine());
+ }
+ }
+ }
+
+ return false;
+ }
}
diff --git a/src/Libs/Database/DatabaseInterface.php b/src/Libs/Database/DatabaseInterface.php
index cb179676..0099ea49 100644
--- a/src/Libs/Database/DatabaseInterface.php
+++ b/src/Libs/Database/DatabaseInterface.php
@@ -7,10 +7,8 @@ namespace App\Libs\Database;
use App\Libs\Entity\StateInterface;
use Closure;
use DateTimeInterface;
-use PDO;
use PDOException;
use Psr\Log\LoggerInterface;
-use RuntimeException;
interface DatabaseInterface
{
@@ -55,15 +53,6 @@ interface DatabaseInterface
*/
public function getAll(DateTimeInterface|null $date = null, array $opts = []): array;
- /**
- * Return number of items.
- *
- * @param DateTimeInterface|null $date if provided, it will return items changes since this date.
- *
- * @return int Number of items.
- */
- public function getCount(DateTimeInterface|null $date = null): int;
-
/**
* Return database records for given items.
*
@@ -184,13 +173,11 @@ interface DatabaseInterface
public function setLogger(LoggerInterface $logger): self;
/**
- * Get PDO instance.
+ * Get DBLayer instance.
*
- * @return PDO
- *
- * @throws RuntimeException if PDO is not initialized yet.
+ * @return DBLayer
*/
- public function getPDO(): PDO;
+ public function getDBLayer(): DBLayer;
/**
* Enable single transaction mode.
diff --git a/src/Libs/Database/PDO/PDOAdapter.php b/src/Libs/Database/PDO/PDOAdapter.php
index 8b8a982f..f051c28e 100644
--- a/src/Libs/Database/PDO/PDOAdapter.php
+++ b/src/Libs/Database/PDO/PDOAdapter.php
@@ -6,8 +6,10 @@ namespace App\Libs\Database\PDO;
use App\Libs\Container;
use App\Libs\Database\DatabaseInterface as iDB;
+use App\Libs\Database\DBLayer;
use App\Libs\Entity\StateInterface as iState;
-use App\Libs\Exceptions\DatabaseException as DBException;
+use App\Libs\Exceptions\DBAdapterException as DBException;
+use App\Libs\Exceptions\DBLayerException;
use App\Libs\Options;
use Closure;
use DateTimeInterface;
@@ -34,14 +36,17 @@ final class PDOAdapter implements iDB
* @var bool Whether the current operation is in a transaction.
*/
private bool $viaTransaction = false;
+
/**
* @var bool Whether the current operation is using a single transaction.
*/
private bool $singleTransaction = false;
+
/**
* @var array Adapter options.
*/
private array $options = [];
+
/**
* @var array Prepared statements.
*/
@@ -49,6 +54,7 @@ final class PDOAdapter implements iDB
'insert' => null,
'update' => null,
];
+
/**
* @var string The database driver to be used.
*/
@@ -58,15 +64,11 @@ final class PDOAdapter implements iDB
* Creates a new instance of the class.
*
* @param LoggerInterface $logger The logger object used for logging.
- * @param PDO $pdo The PDO object used for database connections.
+ * @param DBLayer $db The PDO object used for database connections.
*/
- public function __construct(private LoggerInterface $logger, private PDO $pdo)
+ public function __construct(private LoggerInterface $logger, private readonly DBLayer $db)
{
- $driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
-
- if (is_string($driver)) {
- $this->driver = $driver;
- }
+ $this->driver = $this->db->getDriver();
}
/**
@@ -151,14 +153,14 @@ final class PDOAdapter implements iDB
}
if (null === ($this->stmt['insert'] ?? null)) {
- $this->stmt['insert'] = $this->pdo->prepare(
+ $this->stmt['insert'] = $this->db->prepare(
$this->pdoInsert('state', iState::ENTITY_KEYS)
);
}
$this->execute($this->stmt['insert'], $data);
- $entity->id = (int)$this->pdo->lastInsertId();
+ $entity->id = (int)$this->db->lastInsertId();
} catch (PDOException $e) {
$this->stmt['insert'] = null;
if (false === $this->viaTransaction && false === $this->singleTransaction) {
@@ -282,21 +284,6 @@ final class PDOAdapter implements iDB
return $arr;
}
- /**
- * @inheritdoc
- * @throws RandomException if an error occurs while generating a random number.
- */
- public function getCount(DateTimeInterface|null $date = null): int
- {
- $sql = 'SELECT COUNT(id) AS total FROM state';
-
- if (null !== $date) {
- $sql .= ' WHERE ' . iState::COLUMN_UPDATED . ' > ' . $date->getTimestamp();
- }
-
- return (int)$this->query($sql)->fetchColumn();
- }
-
/**
* @inheritdoc
* @throws RandomException if an error occurs while generating a random number.
@@ -334,7 +321,7 @@ final class PDOAdapter implements iDB
}
$sql = "SELECT * FROM state WHERE {$type_sql} JSON_EXTRACT(" . iState::COLUMN_META_DATA . ",'$.{$key}') = :id LIMIT 1";
- $stmt = $this->pdo->prepare($sql);
+ $stmt = $this->db->prepare($sql);
if (false === $this->execute($stmt, $cond)) {
throw new DBException(
@@ -404,7 +391,7 @@ final class PDOAdapter implements iDB
}
if (null === ($this->stmt['update'] ?? null)) {
- $this->stmt['update'] = $this->pdo->prepare(
+ $this->stmt['update'] = $this->db->prepare(
$this->pdoUpdate('state', iState::ENTITY_KEYS)
);
}
@@ -554,16 +541,14 @@ final class PDOAdapter implements iDB
*/
public function migrations(string $dir, array $opts = []): mixed
{
- $class = new PDOMigrations($this->pdo, $this->logger);
+ $class = new PDOMigrations($this->db, $this->logger);
return match (strtolower($dir)) {
iDB::MIGRATE_UP => $class->up(),
iDB::MIGRATE_DOWN => $class->down(),
- default => throw new DBException(
- r("PDOAdapter: Unknown migration direction '{dir}' was given.", [
- 'name' => $dir
- ]), 91
- ),
+ default => throw new DBException(r("PDOAdapter: Unknown migration direction '{dir}' was given.", [
+ 'name' => $dir
+ ]), 91),
};
}
@@ -572,7 +557,7 @@ final class PDOAdapter implements iDB
*/
public function ensureIndex(array $opts = []): mixed
{
- return (new PDOIndexer($this->pdo, $this->logger))->ensureIndex($opts);
+ return (new PDOIndexer($this->db, $this->logger))->ensureIndex($opts);
}
/**
@@ -580,7 +565,7 @@ final class PDOAdapter implements iDB
*/
public function migrateData(string $version, LoggerInterface|null $logger = null): mixed
{
- return (new PDODataMigration($this->pdo, $logger ?? $this->logger))->automatic();
+ return (new PDODataMigration($this->db, $logger ?? $this->logger))->automatic();
}
/**
@@ -588,7 +573,7 @@ final class PDOAdapter implements iDB
*/
public function isMigrated(): bool
{
- return (new PDOMigrations($this->pdo, $this->logger))->isMigrated();
+ return (new PDOMigrations($this->db, $this->logger))->isMigrated();
}
/**
@@ -596,7 +581,7 @@ final class PDOAdapter implements iDB
*/
public function makeMigration(string $name, array $opts = []): mixed
{
- return (new PDOMigrations($this->pdo, $this->logger))->make($name);
+ return (new PDOMigrations($this->db, $this->logger))->make($name);
}
/**
@@ -604,7 +589,7 @@ final class PDOAdapter implements iDB
*/
public function maintenance(array $opts = []): mixed
{
- return (new PDOMigrations($this->pdo, $this->logger))->runMaintenance();
+ return (new PDOMigrations($this->db, $this->logger))->runMaintenance();
}
/**
@@ -613,19 +598,19 @@ final class PDOAdapter implements iDB
*/
public function reset(): bool
{
- $this->pdo->beginTransaction();
+ $this->db->transactional(function (DBLayer $db) {
+ /** @noinspection SqlResolve */
+ $tables = $db->query(
+ 'SELECT name FROM sqlite_master WHERE "type" = "table" AND "name" NOT LIKE "sqlite_%"'
+ );
- $tables = $this->pdo->query(
- 'SELECT name FROM sqlite_master WHERE "type" = "table" AND "name" NOT LIKE "sqlite_%"'
- );
+ foreach ($tables->fetchAll(PDO::FETCH_COLUMN) as $table) {
+ $db->exec('DELETE FROM "' . $table . '"');
+ $db->exec('DELETE FROM sqlite_sequence WHERE "name" = "' . $table . '"');
+ }
+ });
- foreach ($tables->fetchAll(PDO::FETCH_COLUMN) as $table) {
- $this->pdo->exec('DELETE FROM "' . $table . '"');
- $this->pdo->exec('DELETE FROM sqlite_sequence WHERE "name" = "' . $table . '"');
- }
-
- $this->pdo->commit();
- $this->pdo->exec('VACUUM');
+ $this->db->exec('VACUUM');
return true;
}
@@ -640,12 +625,9 @@ final class PDOAdapter implements iDB
return $this;
}
- /**
- * @inheritdoc
- */
- public function getPDO(): PDO
+ public function getDBLayer(): DBLayer
{
- return $this->pdo;
+ return $this->db;
}
/**
@@ -655,11 +637,11 @@ final class PDOAdapter implements iDB
{
$this->singleTransaction = true;
- if (false === $this->pdo->inTransaction()) {
- $this->pdo->beginTransaction();
+ if (false === $this->db->inTransaction()) {
+ $this->db->start();
}
- return $this->pdo->inTransaction();
+ return $this->db->inTransaction();
}
/**
@@ -667,7 +649,7 @@ final class PDOAdapter implements iDB
*/
public function transactional(Closure $callback): mixed
{
- if (true === $this->pdo->inTransaction()) {
+ if (true === $this->db->inTransaction()) {
$this->viaTransaction = true;
$result = $callback($this);
$this->viaTransaction = false;
@@ -675,19 +657,20 @@ final class PDOAdapter implements iDB
}
try {
- $this->pdo->beginTransaction();
+ $this->db->start();
$this->viaTransaction = true;
$result = $callback($this);
$this->viaTransaction = false;
- $this->pdo->commit();
+ $this->db->commit();
return $result;
} catch (PDOException $e) {
- $this->pdo->rollBack();
- $this->viaTransaction = false;
+ $this->db->rollBack();
throw $e;
+ } finally {
+ $this->viaTransaction = false;
}
}
@@ -701,8 +684,8 @@ final class PDOAdapter implements iDB
*/
public function __destruct()
{
- if (true === $this->singleTransaction && true === $this->pdo->inTransaction()) {
- $this->pdo->commit();
+ if (true === $this->singleTransaction && true === $this->db->inTransaction()) {
+ $this->db->commit();
}
$this->stmt = [];
@@ -827,7 +810,7 @@ final class PDOAdapter implements iDB
$sql = "SELECT * FROM state WHERE " . iState::COLUMN_TYPE . " = :type {$sqlEpisode} {$sqlGuids} LIMIT 1";
- $stmt = $this->pdo->prepare($sql);
+ $stmt = $this->db->prepare($sql);
if (false === $this->execute($stmt, $cond)) {
throw new DBException(
@@ -857,29 +840,7 @@ final class PDOAdapter implements iDB
*/
private function execute(PDOStatement $stmt, array $cond = []): bool
{
- for ($i = 0; $i <= self::LOCK_RETRY; $i++) {
- try {
- return $stmt->execute($cond);
- } catch (PDOException $e) {
- if (true === str_contains(strtolower($e->getMessage()), 'database is locked')) {
- if ($i >= self::LOCK_RETRY) {
- throw $e;
- }
-
- $sleep = self::LOCK_RETRY + random_int(1, 3);
-
- $this->logger->warning("PDOAdapter: Database is locked. sleeping for '{sleep}s'.", [
- 'sleep' => $sleep
- ]);
-
- sleep($sleep);
- } else {
- throw $e;
- }
- }
- }
-
- return false;
+ return $this->wrap(fn(PDOAdapter $adapter) => $stmt->execute($cond));
}
/**
@@ -895,29 +856,7 @@ final class PDOAdapter implements iDB
*/
private function query(string $sql): PDOStatement|false
{
- for ($i = 0; $i <= self::LOCK_RETRY; $i++) {
- try {
- return $this->pdo->query($sql);
- } catch (PDOException $e) {
- if (true === str_contains(strtolower($e->getMessage()), 'database is locked')) {
- if ($i >= self::LOCK_RETRY) {
- throw $e;
- }
-
- $sleep = self::LOCK_RETRY + random_int(1, 3);
-
- $this->logger->warning("PDOAdapter: Database is locked. sleeping for '{sleep}s'.", [
- 'sleep' => $sleep,
- ]);
-
- sleep($sleep);
- } else {
- throw $e;
- }
- }
- }
-
- return false;
+ return $this->wrap(fn(PDOAdapter $adapter) => $adapter->db->query($sql));
}
/**
@@ -981,4 +920,57 @@ final class PDOAdapter implements iDB
default => '"' . $text . '"',
};
}
+
+ /**
+ * Wraps the given callback function with a retry mechanism to handle database locks.
+ *
+ * @param Closure $callback The callback function to be executed.
+ *
+ * @return mixed The result of the callback function.
+ *
+ * @throws DBLayerException If an error occurs while executing the callback function.
+ * @throws RandomException If an error occurs while generating a random number.
+ */
+ private function wrap(Closure $callback): mixed
+ {
+ for ($i = 0; $i <= self::LOCK_RETRY; $i++) {
+ try {
+ return $callback($this);
+ } catch (PDOException $e) {
+ if (true === str_contains(strtolower($e->getMessage()), 'database is locked')) {
+ if ($i >= self::LOCK_RETRY) {
+ throw (new DBLayerException($e->getMessage(), (int)$e->getCode(), $e))
+ ->setInfo(
+ ag($this->db->getLastStatement(), 'sql', ''),
+ ag($this->db->getLastStatement(), 'bind', []),
+ $e->errorInfo ?? [],
+ $e->getCode()
+ )
+ ->setFile($e->getFile())
+ ->setLine($e->getLine());
+ }
+
+ $sleep = self::LOCK_RETRY + random_int(1, 3);
+
+ $this->logger->warning("PDOAdapter: Database is locked. sleeping for '{sleep}s'.", [
+ 'sleep' => $sleep
+ ]);
+
+ sleep($sleep);
+ } else {
+ throw (new DBLayerException($e->getMessage(), (int)$e->getCode(), $e))
+ ->setInfo(
+ ag($this->db->getLastStatement(), 'sql', ''),
+ ag($this->db->getLastStatement(), 'bind', []),
+ $e->errorInfo ?? [],
+ $e->getCode()
+ )
+ ->setFile($e->getFile())
+ ->setLine($e->getLine());
+ }
+ }
+ }
+
+ return false;
+ }
}
diff --git a/src/Libs/Database/PDO/PDODataMigration.php b/src/Libs/Database/PDO/PDODataMigration.php
index 42efa832..f45cbb9f 100644
--- a/src/Libs/Database/PDO/PDODataMigration.php
+++ b/src/Libs/Database/PDO/PDODataMigration.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Libs\Database\PDO;
use App\Libs\Config;
+use App\Libs\Database\DBLayer;
use App\Libs\Entity\StateInterface as iFace;
use App\Libs\Guid;
use PDO;
@@ -41,10 +42,10 @@ final class PDODataMigration
/**
* Class constructor.
*
- * @param PDO $pdo The PDO instance to use for database connection.
+ * @param DBLayer $db The PDO instance to use for database connection.
* @param LoggerInterface $logger The logger instance to use for logging.
*/
- public function __construct(private PDO $pdo, private LoggerInterface $logger)
+ public function __construct(private DBLayer $db, private LoggerInterface $logger)
{
$this->version = Config::get('database.version');
$this->dbPath = dirname(after(Config::get('database.dsn'), 'sqlite:'));
@@ -125,15 +126,15 @@ final class PDODataMigration
]
);
- if (!$this->pdo->inTransaction()) {
- $this->pdo->beginTransaction();
+ if (!$this->db->inTransaction()) {
+ $this->db->start();
}
$columns = implode(', ', iFace::ENTITY_KEYS);
$binds = ':' . implode(', :', iFace::ENTITY_KEYS);
/** @noinspection SqlInsertValues */
- $insert = $this->pdo->prepare("INSERT INTO state ({$columns}) VALUES({$binds})");
+ $insert = $this->db->prepare("INSERT INTO state ({$columns}) VALUES({$binds})");
$stmt = $oldDB->query("SELECT * FROM state");
@@ -230,8 +231,8 @@ final class PDODataMigration
]);
}
- if ($this->pdo->inTransaction()) {
- $this->pdo->commit();
+ if ($this->db->inTransaction()) {
+ $this->db->commit();
}
$stmt = null;
@@ -290,15 +291,15 @@ final class PDODataMigration
PDO::SQLITE_ATTR_OPEN_FLAGS => PDO::SQLITE_OPEN_READONLY,
]);
- if (!$this->pdo->inTransaction()) {
- $this->pdo->beginTransaction();
+ if (!$this->db->inTransaction()) {
+ $this->db->start();
}
$columns = implode(', ', iFace::ENTITY_KEYS);
$binds = ':' . implode(', :', iFace::ENTITY_KEYS);
/** @noinspection SqlInsertValues */
- $insert = $this->pdo->prepare("INSERT INTO state ({$columns}) VALUES({$binds})");
+ $insert = $this->db->prepare("INSERT INTO state ({$columns}) VALUES({$binds})");
foreach ($oldDB->query("SELECT * FROM state") as $row) {
$row[iFace::COLUMN_EXTRA] = json_decode(
@@ -419,8 +420,8 @@ final class PDODataMigration
$insert->execute($arr);
}
- if ($this->pdo->inTransaction()) {
- $this->pdo->commit();
+ if ($this->db->inTransaction()) {
+ $this->db->commit();
}
$oldDB = null;
diff --git a/src/Libs/Database/PDO/PDOIndexer.php b/src/Libs/Database/PDO/PDOIndexer.php
index f7a6516a..36ca1fa7 100644
--- a/src/Libs/Database/PDO/PDOIndexer.php
+++ b/src/Libs/Database/PDO/PDOIndexer.php
@@ -5,10 +5,10 @@ declare(strict_types=1);
namespace App\Libs\Database\PDO;
use App\Libs\Config;
+use App\Libs\Database\DBLayer;
use App\Libs\Entity\StateInterface as iState;
use App\Libs\Guid;
use App\Libs\Options;
-use PDO;
use Psr\Log\LoggerInterface as iLogger;
/**
@@ -40,10 +40,10 @@ final class PDOIndexer
/**
* Class constructor.
*
- * @param PDO $db The PDO object used for database connections and queries.
+ * @param DBLayer $db The PDO object used for database connections and queries.
* @param iLogger $logger The logger object used for logging information.
*/
- public function __construct(private PDO $db, private iLogger $logger)
+ public function __construct(private DBLayer $db, private iLogger $logger)
{
}
@@ -169,7 +169,7 @@ final class PDOIndexer
if (!$this->db->inTransaction()) {
$startedTransaction = true;
- $this->db->beginTransaction();
+ $this->db->start();
}
foreach ($queries as $query) {
diff --git a/src/Libs/Database/PDO/PDOMigrations.php b/src/Libs/Database/PDO/PDOMigrations.php
index d42e2046..6eea3e5a 100644
--- a/src/Libs/Database/PDO/PDOMigrations.php
+++ b/src/Libs/Database/PDO/PDOMigrations.php
@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Libs\Database\PDO;
use App\Libs\Database\DatabaseInterface as iDB;
+use App\Libs\Database\DBLayer;
use App\Libs\Stream;
-use PDO;
use Psr\Log\LoggerInterface;
use RuntimeException;
use SplFileObject;
@@ -36,12 +36,12 @@ final class PDOMigrations
/**
* Constructs a new instance of the class.
*
- * @param PDO $pdo The database connection object.
+ * @param DBLayer $db The database connection object.
* @param LoggerInterface $logger The logger instance.
*
* @return void
*/
- public function __construct(private PDO $pdo, private LoggerInterface $logger)
+ public function __construct(private DBLayer $db, private LoggerInterface $logger)
{
$this->path = __DIR__ . '/../../../../migrations';
$this->driver = $this->getDriver();
@@ -119,7 +119,7 @@ final class PDOMigrations
'name' => ag($migrate, 'name')
]));
- $this->pdo->exec((string)ag($migrate, iDB::MIGRATE_UP));
+ $this->db->exec((string)ag($migrate, iDB::MIGRATE_UP));
$this->setVersion(ag($migrate, 'id'));
}
@@ -200,7 +200,7 @@ final class PDOMigrations
public function runMaintenance(): int|bool
{
if ('sqlite' === $this->driver) {
- return $this->pdo->exec('VACUUM;');
+ return $this->db->exec('VACUUM;');
}
return false;
@@ -213,7 +213,7 @@ final class PDOMigrations
*/
private function getVersion(): int
{
- return (int)$this->pdo->query('PRAGMA user_version')->fetchColumn();
+ return (int)$this->db->query('PRAGMA user_version')->fetchColumn();
}
/**
@@ -225,7 +225,7 @@ final class PDOMigrations
*/
private function setVersion(int $version): void
{
- $this->pdo->exec('PRAGMA user_version = ' . $version);
+ $this->db->exec('PRAGMA user_version = ' . $version);
}
/**
@@ -235,9 +235,9 @@ final class PDOMigrations
*/
private function getDriver(): string
{
- $driver = $this->pdo->getAttribute($this->pdo::ATTR_DRIVER_NAME);
+ $driver = $this->db->getDriver();
- if (empty($driver) || !is_string($driver)) {
+ if (empty($driver)) {
$driver = 'unknown';
}
diff --git a/src/Libs/Exceptions/AppExceptionInterface.php b/src/Libs/Exceptions/AppExceptionInterface.php
new file mode 100644
index 00000000..634486f7
--- /dev/null
+++ b/src/Libs/Exceptions/AppExceptionInterface.php
@@ -0,0 +1,41 @@
+bind;
}
- public function setFile(string $file): DatabaseException
+ public function setFile(string $file): DBAdapterException
{
$this->file = $file;
return $this;
}
- public function setLine(int $line): DatabaseException
+ public function setLine(int $line): DBAdapterException
{
$this->line = $line;
return $this;
}
- public function setOptions(array $options): DatabaseException
+ public function setOptions(array $options): DBAdapterException
{
$this->options = $options;
diff --git a/src/Libs/Exceptions/DBLayerException.php b/src/Libs/Exceptions/DBLayerException.php
new file mode 100644
index 00000000..ef3a5e65
--- /dev/null
+++ b/src/Libs/Exceptions/DBLayerException.php
@@ -0,0 +1,77 @@
+queryString = $queryString;
+ $this->bind = $bind;
+ $this->errorInfo = $errorInfo;
+ $this->code = $errorCode;
+
+ return $this;
+ }
+
+ public function getQueryString(): string
+ {
+ return $this->queryString;
+ }
+
+ public function getQueryBind(): array
+ {
+ return $this->bind;
+ }
+
+ public function setFile(string $file): DBLayerException
+ {
+ $this->file = $file;
+
+ return $this;
+ }
+
+ public function setLine(int $line): DBLayerException
+ {
+ $this->line = $line;
+
+ return $this;
+ }
+
+ public function setOptions(array $options): DBLayerException
+ {
+ $this->options = $options;
+
+ return $this;
+ }
+}
diff --git a/src/Libs/Exceptions/EmitterException.php b/src/Libs/Exceptions/EmitterException.php
index bceb9273..9cc4a839 100644
--- a/src/Libs/Exceptions/EmitterException.php
+++ b/src/Libs/Exceptions/EmitterException.php
@@ -6,8 +6,10 @@ namespace App\Libs\Exceptions;
use RuntimeException;
-class EmitterException extends RuntimeException
+class EmitterException extends RuntimeException implements AppExceptionInterface
{
+ use UseAppException;
+
public static function forHeadersSent(string $filename, int $line): self
{
return new self(r('Unable to emit response. Headers already sent in %s:%d', [
diff --git a/src/Libs/Exceptions/ErrorException.php b/src/Libs/Exceptions/ErrorException.php
index a7a7961f..798b9781 100644
--- a/src/Libs/Exceptions/ErrorException.php
+++ b/src/Libs/Exceptions/ErrorException.php
@@ -7,6 +7,7 @@ namespace App\Libs\Exceptions;
/**
* Class ErrorException
*/
-class ErrorException extends \ErrorException
+class ErrorException extends \ErrorException implements AppExceptionInterface
{
+ use UseAppException;
}
diff --git a/src/Libs/Exceptions/HttpException.php b/src/Libs/Exceptions/HttpException.php
index deb1c966..f1c47fd5 100644
--- a/src/Libs/Exceptions/HttpException.php
+++ b/src/Libs/Exceptions/HttpException.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Libs\Exceptions;
-class HttpException extends RuntimeException
+class HttpException extends RuntimeException implements AppExceptionInterface
{
+ use UseAppException;
}
diff --git a/src/Libs/Exceptions/InvalidArgumentException.php b/src/Libs/Exceptions/InvalidArgumentException.php
index ab707e7b..a05f9580 100644
--- a/src/Libs/Exceptions/InvalidArgumentException.php
+++ b/src/Libs/Exceptions/InvalidArgumentException.php
@@ -7,6 +7,7 @@ namespace App\Libs\Exceptions;
/**
* Class InvalidArgumentException
*/
-class InvalidArgumentException extends \InvalidArgumentException
+class InvalidArgumentException extends \InvalidArgumentException implements AppExceptionInterface
{
+ use UseAppException;
}
diff --git a/src/Libs/Exceptions/RuntimeException.php b/src/Libs/Exceptions/RuntimeException.php
index 48bb532a..8348e478 100644
--- a/src/Libs/Exceptions/RuntimeException.php
+++ b/src/Libs/Exceptions/RuntimeException.php
@@ -7,6 +7,7 @@ namespace App\Libs\Exceptions;
/**
* General runtime exception.
*/
-class RuntimeException extends \RuntimeException
+class RuntimeException extends \RuntimeException implements AppExceptionInterface
{
+ use UseAppException;
}
diff --git a/src/Libs/Exceptions/UseAppException.php b/src/Libs/Exceptions/UseAppException.php
new file mode 100644
index 00000000..1ee11d05
--- /dev/null
+++ b/src/Libs/Exceptions/UseAppException.php
@@ -0,0 +1,35 @@
+appContext = ag_set($this->appContext, $key, $value);
+ return $this;
+ }
+
+ public function setContext(array $context): AppExceptionInterface
+ {
+ $this->appContext = $context;
+
+ return $this;
+ }
+
+ public function getContext(string|null $key = null): mixed
+ {
+ if (null === $key) {
+ return $this->appContext;
+ }
+
+ return ag($this->appContext, $key);
+ }
+
+ public function hasContext(): bool
+ {
+ return !empty($this->appContext);
+ }
+}
diff --git a/src/Libs/Exceptions/ValidationException.php b/src/Libs/Exceptions/ValidationException.php
index beaccdd6..7cb7f5d3 100644
--- a/src/Libs/Exceptions/ValidationException.php
+++ b/src/Libs/Exceptions/ValidationException.php
@@ -7,6 +7,7 @@ namespace App\Libs\Exceptions;
/**
* Class ValidationException
*/
-class ValidationException extends RuntimeException
+class ValidationException extends RuntimeException implements AppExceptionInterface
{
+ use UseAppException;
}
diff --git a/src/Libs/Guid.php b/src/Libs/Guid.php
index 368fe320..2b0ce649 100644
--- a/src/Libs/Guid.php
+++ b/src/Libs/Guid.php
@@ -6,7 +6,7 @@ namespace App\Libs;
use App\Libs\Exceptions\InvalidArgumentException;
use JsonSerializable;
-use Psr\Log\LoggerInterface;
+use Psr\Log\LoggerInterface as iLogger;
use Stringable;
/**
@@ -91,9 +91,9 @@ final class Guid implements JsonSerializable, Stringable
*/
private array $data = [];
/**
- * @var null|LoggerInterface $logger The logger instance used for logging.
+ * @var null|iLogger $logger The logger instance used for logging.
*/
- private static LoggerInterface|null $logger = null;
+ private static iLogger|null $logger = null;
/**
* Create list of db => external id list.
@@ -114,7 +114,7 @@ final class Guid implements JsonSerializable, Stringable
if (false === is_string($key)) {
$this->getLogger()->info(
- 'Ignoring [{backend}] {item.type} [{item.title}] external id. Unexpected key type [{given}] was given.',
+ "Ignoring '{backend}' {item.type} '{item.title}' external id. Unexpected key type '{given}' was given.",
[
'key' => (string)$key,
'given' => get_debug_type($key),
@@ -126,7 +126,7 @@ final class Guid implements JsonSerializable, Stringable
if (null === (self::SUPPORTED[$key] ?? null)) {
$this->getLogger()->info(
- 'Ignoring [{backend}] {item.type} [{item.title}] [{key}] external id. Not supported.',
+ "Ignoring '{backend}' {item.type} '{item.title}' '{key}' external id. Not supported.",
[
'key' => $key,
...$context,
@@ -137,7 +137,7 @@ final class Guid implements JsonSerializable, Stringable
if (self::SUPPORTED[$key] !== ($valueType = get_debug_type($value))) {
$this->getLogger()->info(
- 'Ignoring [{backend}] {item.type} [{item.title}] [{key}] external id. Unexpected value type.',
+ "Ignoring '{backend}' {item.type} '{item.title}' '{key}' external id. Unexpected value type.",
[
'key' => $key,
'condition' => [
@@ -153,7 +153,7 @@ final class Guid implements JsonSerializable, Stringable
if (null !== (self::VALIDATE_GUID[$key] ?? null)) {
if (1 !== preg_match(self::VALIDATE_GUID[$key]['pattern'], $value)) {
$this->getLogger()->info(
- 'Ignoring [{backend}] {item.type} [{item.title}] [{key}] external id. Unexpected [{given}] value, expecting [{expected}].',
+ "Ignoring '{backend}' {item.type} '{item.title}' '{key}' external id. Unexpected value '{given}'. Expecting '{expected}'.",
[
'key' => $key,
'expected' => self::VALIDATE_GUID[$key]['example'],
@@ -172,9 +172,9 @@ final class Guid implements JsonSerializable, Stringable
/**
* Set the logger instance for the class.
*
- * @param LoggerInterface $logger The logger instance to be set.
+ * @param iLogger $logger The logger instance to be set.
*/
- public static function setLogger(LoggerInterface $logger): void
+ public static function setLogger(iLogger $logger): void
{
self::$logger = $logger;
}
@@ -229,12 +229,10 @@ final class Guid implements JsonSerializable, Stringable
$lookup = 'guid_' . $db;
if (false === array_key_exists($lookup, self::SUPPORTED)) {
- throw new InvalidArgumentException(
- r('Invalid db [{db}] source was given. Expecting [{db_list}].', [
- 'db' => $db,
- 'db_list' => implode(', ', array_map(fn($f) => after($f, 'guid_'), array_keys(self::SUPPORTED))),
- ])
- );
+ throw new InvalidArgumentException(r("Invalid db '{db}' source was given. Expecting '{db_list}'.", [
+ 'db' => $db,
+ 'db_list' => implode(', ', array_map(fn($f) => after($f, 'guid_'), array_keys(self::SUPPORTED))),
+ ]));
}
if (null === (self::VALIDATE_GUID[$lookup] ?? null)) {
@@ -242,13 +240,11 @@ final class Guid implements JsonSerializable, Stringable
}
if (1 !== @preg_match(self::VALIDATE_GUID[$lookup]['pattern'], $id)) {
- throw new InvalidArgumentException(
- r('Invalid [{value}] value for [{db}]. Expecting [{example}].', [
- 'db' => $db,
- 'value' => $id,
- 'example' => self::VALIDATE_GUID[$lookup]['example'],
- ])
- );
+ throw new InvalidArgumentException(r("Invalid value '{value}' for '{db}' GUID. Expecting '{example}'.", [
+ 'db' => $db,
+ 'value' => $id,
+ 'example' => self::VALIDATE_GUID[$lookup]['example'],
+ ]));
}
return true;
@@ -283,12 +279,12 @@ final class Guid implements JsonSerializable, Stringable
/**
* Get instance of logger.
*
- * @return LoggerInterface
+ * @return iLogger
*/
- private function getLogger(): LoggerInterface
+ private function getLogger(): iLogger
{
if (null === self::$logger) {
- self::$logger = Container::get(LoggerInterface::class);
+ self::$logger = Container::get(iLogger::class);
}
return self::$logger;
diff --git a/src/Libs/Mappers/Import/DirectMapper.php b/src/Libs/Mappers/Import/DirectMapper.php
index 714bf08b..1ae65cd9 100644
--- a/src/Libs/Mappers/Import/DirectMapper.php
+++ b/src/Libs/Mappers/Import/DirectMapper.php
@@ -120,7 +120,7 @@ final class DirectMapper implements iImport
$this->addPointers($entity, $pointer);
}
- $this->logger->info("MAPPER: Preloaded '{pointers}' pointers into memory.", [
+ $this->logger->info("DirectMapper: Preloaded '{pointers}' pointers into memory.", [
'mapper' => afterLast(self::class, '\\'),
'pointers' => number_format(count($this->pointers)),
]);
@@ -147,7 +147,7 @@ final class DirectMapper implements iImport
Message::increment("{$entity->via}.{$entity->type}.failed");
$this->logger->notice(
- "MAPPER: Ignoring '{backend}' '{title}'. Does not exist in database. And backend set as metadata source only.",
+ "DirectMapper: Ignoring '{backend}: {title}'. Does not exist in database. And backend set as metadata source only.",
[
'metaOnly' => true,
'backend' => $entity->via,
@@ -191,7 +191,7 @@ final class DirectMapper implements iImport
}
}
- $this->logger->notice("MAPPER: '{backend}' added '{title}' as new item.", [
+ $this->logger->notice("DirectMapper: '{backend}' added '{title}' as new item.", [
'backend' => $entity->via,
'title' => $entity->getName(),
true === $this->inTraceMode() ? 'trace' : 'metadata' => $data,
@@ -209,18 +209,21 @@ final class DirectMapper implements iImport
$this->actions[$entity->type]['failed']++;
Message::increment("{$entity->via}.{$entity->type}.failed");
$this->logger->error(
- message: "MAPPER: Exception '{error.kind}' was thrown unhandled in adding '{backend}' '{title}'. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "DirectMapper: Exception '{error.kind}' was thrown unhandled in adding '{backend}: {title}'. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'backend' => $entity->via,
+ 'title' => $entity->getName(),
+ 'state' => $entity->getAll()
],
- 'backend' => $entity->via,
- 'title' => $entity->getName(),
- 'state' => $entity->getAll()
- ]
+ e: $e
+ )
);
}
@@ -248,7 +251,7 @@ final class DirectMapper implements iImport
$this->removePointers($local)->addPointers($local, $local->id);
- $this->logger->notice("MAPPER: '{backend}' updated '{title}' metadata.", [
+ $this->logger->notice("DirectMapper: '{backend}' updated '{title}' metadata.", [
'id' => $local->id,
'backend' => $entity->via,
'title' => $local->getName(),
@@ -269,22 +272,25 @@ final class DirectMapper implements iImport
$this->actions[$local->type]['failed']++;
Message::increment("{$entity->via}.{$local->type}.failed");
$this->logger->error(
- message: "MAPPER: Exception '{error.kind}' was thrown unhandled in '{backend}' '{title}' handle tainted. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "DirectMapper: Exception '{error.kind}' was thrown unhandled in '{backend}: {title}' handle tainted. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'id' => $local->id,
+ 'backend' => $entity->via,
+ 'title' => $local->getName(),
+ 'state' => [
+ 'database' => $local->getAll(),
+ 'backend' => $entity->getAll()
+ ],
],
- 'id' => $local->id,
- 'backend' => $entity->via,
- 'title' => $local->getName(),
- 'state' => [
- 'database' => $local->getAll(),
- 'backend' => $entity->getAll()
- ],
- ]
+ e: $e
+ )
);
}
@@ -306,7 +312,7 @@ final class DirectMapper implements iImport
}
$this->logger->notice(
- "MAPPER: '{backend}' item '{id}: {title}' is marked as '{state}' vs local state '{local_state}', However due to the following reason '{reasons}' it was not considered as valid state.",
+ "DirectMapper: '{backend}' item '{id}: {title}' is marked as '{state}' vs local state '{local_state}', However due to the following reason '{reasons}' it was not considered as valid state.",
[
'id' => $local->id,
'backend' => $entity->via,
@@ -321,7 +327,7 @@ final class DirectMapper implements iImport
}
if ($this->inTraceMode()) {
- $this->logger->info("MAPPER: '{backend}' '{title}' No metadata changes detected.", [
+ $this->logger->info("DirectMapper: '{backend}: {title}' No metadata changes detected.", [
'id' => $local->id,
'backend' => $entity->via,
'title' => $local->getName(),
@@ -364,7 +370,7 @@ final class DirectMapper implements iImport
}
}
- $this->logger->notice("MAPPER: '{backend}' marked '{title}' as 'unplayed'.", [
+ $this->logger->notice("DirectMapper: '{backend}' marked '{title}' as 'unplayed'.", [
'id' => $cloned->id,
'backend' => $entity->via,
'title' => $cloned->getName(),
@@ -381,22 +387,25 @@ final class DirectMapper implements iImport
$this->actions[$local->type]['failed']++;
Message::increment("{$entity->via}.{$local->type}.failed");
$this->logger->error(
- message: "MAPPER: Exception '{error.kind}' was thrown unhandled in '{backend}' '{title}' handle old entity unplayed. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "DirectMapper: Exception '{error.kind}' was thrown unhandled in '{backend}: {title}' handle old entity unplayed. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'id' => $cloned->id,
+ 'backend' => $entity->via,
+ 'title' => $cloned->getName(),
+ 'state' => [
+ 'database' => $cloned->getAll(),
+ 'backend' => $entity->getAll()
+ ],
],
- 'id' => $cloned->id,
- 'backend' => $entity->via,
- 'title' => $cloned->getName(),
- 'state' => [
- 'database' => $cloned->getAll(),
- 'backend' => $entity->getAll()
- ],
- ]
+ e: $e
+ )
);
}
@@ -428,7 +437,7 @@ final class DirectMapper implements iImport
}
$local = $local->apply($entity, fields: $_keys);
$this->logger->notice(
- $progress ? "MAPPER: '{backend}' updated '{title}' due to play progress change." : "MAPPER: '{backend}' updated '{title}' metadata.",
+ $progress ? "DirectMapper: '{backend}' updated '{title}' due to play progress change." : "DirectMapper: '{backend}' updated '{title}' metadata.",
[
'id' => $cloned->id,
'backend' => $entity->via,
@@ -463,22 +472,25 @@ final class DirectMapper implements iImport
$this->actions[$local->type]['failed']++;
Message::increment("{$entity->via}.{$local->type}.failed");
$this->logger->error(
- message: "MAPPER: Exception '{error.kind}' was thrown unhandled in '{backend}' '{title}' handle old entity always update metadata. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "DirectMapper: Exception '{error.kind}' was thrown unhandled in '{backend}: {title}' handle old entity always update metadata. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'id' => $cloned->id,
+ 'backend' => $entity->via,
+ 'title' => $cloned->getName(),
+ 'state' => [
+ 'database' => $cloned->getAll(),
+ 'backend' => $entity->getAll()
+ ],
],
- 'id' => $cloned->id,
- 'backend' => $entity->via,
- 'title' => $cloned->getName(),
- 'state' => [
- 'database' => $cloned->getAll(),
- 'backend' => $entity->getAll()
- ],
- ]
+ e: $e
+ )
);
}
@@ -490,7 +502,7 @@ final class DirectMapper implements iImport
if ($entity->isWatched() !== $local->isWatched()) {
$this->logger->notice(
- "MAPPER: '{backend}' item '{id}: {title}' is marked as '{state}' vs local state '{local_state}', However due to the remote item date '{remote_date}' being older than the last backend sync date '{local_date}'. it was not considered as valid state.",
+ "DirectMapper: '{backend}' item '{id}: {title}' is marked as '{state}' vs local state '{local_state}', However due to the remote item date '{remote_date}' being older than the last backend sync date '{local_date}'. it was not considered as valid state.",
[
'id' => $cloned->id,
'backend' => $entity->via,
@@ -505,7 +517,7 @@ final class DirectMapper implements iImport
}
if ($this->inTraceMode()) {
- $this->logger->debug("MAPPER: Ignoring '{backend}' '{title}'. No changes detected.", [
+ $this->logger->debug("DirectMapper: Ignoring '{backend}: {title}'. No changes detected.", [
'id' => $cloned->id,
'backend' => $entity->via,
'title' => $cloned->getName(),
@@ -521,7 +533,7 @@ final class DirectMapper implements iImport
public function add(iState $entity, array $opts = []): self
{
if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) {
- $this->logger->warning("MAPPER: Ignoring '{backend}' '{title}'. No valid/supported external ids.", [
+ $this->logger->warning("DirectMapper: Ignoring '{backend}: {title}'. No valid/supported external ids.", [
'id' => $entity->id,
'backend' => $entity->via,
'title' => $entity->getName(),
@@ -532,7 +544,7 @@ final class DirectMapper implements iImport
if (true === $entity->isEpisode() && $entity->episode < 1) {
$this->logger->warning(
- "MAPPER: Ignoring '{backend}' '{id}: {title}'. Item was marked as episode but no episode number was provided.",
+ "DirectMapper: Ignoring '{backend}' '{id}: {title}'. Item was marked as episode but no episode number was provided.",
[
'id' => $entity->id ?? ag($entity->getMetadata($entity->via), iState::COLUMN_ID, ''),
'backend' => $entity->via,
@@ -584,7 +596,7 @@ final class DirectMapper implements iImport
* 3 - mark entity as tainted and re-process it.
*/
if (true === $hasAfter && true === $cloned->isWatched() && false === $entity->isWatched()) {
- $message = "MAPPER: Watch state conflict detected in '{backend}: {title}' '{new_state}' vs local state '{id}: {current_state}'.";
+ $message = "DirectMapper: Watch state conflict detected in '{backend}: {title}' '{new_state}' vs local state '{id}: {current_state}'.";
$hasMeta = count($cloned->getMetadata($entity->via)) >= 1;
$hasDate = $entity->updated === ag($cloned->getMetadata($entity->via), iState::COLUMN_META_DATA_PLAYED_AT);
@@ -630,10 +642,10 @@ final class DirectMapper implements iImport
$changes = $local->diff(fields: $keys);
- $message = "MAPPER: '{backend}' Updated '{title}'.";
+ $message = "DirectMapper: '{backend}' Updated '{title}'.";
if ($cloned->isWatched() !== $local->isWatched()) {
- $message = "MAPPER: '{backend}' Updated and marked '{id}: {title}' as '{state}'.";
+ $message = "DirectMapper: '{backend}' Updated and marked '{id}: {title}' as '{state}'.";
if (null !== $onStateUpdate) {
$onStateUpdate($local);
@@ -664,23 +676,26 @@ final class DirectMapper implements iImport
$this->actions[$local->type]['failed']++;
Message::increment("{$entity->via}.{$local->type}.failed");
$this->logger->error(
- message: "MAPPER: Exception '{error.kind}' was thrown unhandled in '{backend}' '{title}' add. '{error.message}' at '{error.file}:{error.line}'.",
- context: [
- 'error' => [
- 'kind' => $e::class,
- 'line' => $e->getLine(),
- 'message' => $e->getMessage(),
- 'file' => after($e->getFile(), ROOT_PATH),
+ ...lw(
+ message: "DirectMapper: Exception '{error.kind}' was thrown unhandled in '{backend}: {title}' add. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'id' => $cloned->id,
+ 'backend' => $entity->via,
+ 'title' => $cloned->getName(),
+ 'state' => [
+ 'database' => $cloned->getAll(),
+ 'backend' => $entity->getAll()
+ ],
+ 'trace' => $e->getTrace(),
],
- 'id' => $cloned->id,
- 'backend' => $entity->via,
- 'title' => $cloned->getName(),
- 'state' => [
- 'database' => $cloned->getAll(),
- 'backend' => $entity->getAll()
- ],
- 'trace' => $e->getTrace(),
- ]
+ e: $e
+ )
);
}
@@ -700,7 +715,10 @@ final class DirectMapper implements iImport
];
}
- $this->logger->debug("MAPPER: Ignoring '{backend}' '{title}'. Metadata & play state are identical.", $context);
+ $this->logger->debug(
+ "DirectMapper: Ignoring '{backend}: {title}'. Metadata & play state are identical.",
+ $context
+ );
Message::increment("{$entity->via}.{$entity->type}.ignored_no_change");
diff --git a/src/Libs/Mappers/Import/MemoryMapper.php b/src/Libs/Mappers/Import/MemoryMapper.php
index ba3e69d3..358b1024 100644
--- a/src/Libs/Mappers/Import/MemoryMapper.php
+++ b/src/Libs/Mappers/Import/MemoryMapper.php
@@ -604,7 +604,24 @@ final class MemoryMapper implements iImport
}
} catch (PDOException $e) {
$list[$entity->type]['failed']++;
- $this->logger->error($e->getMessage(), $entity->getAll());
+ $this->logger->error(
+ ...lw(
+ message: "MemoryMapper: Exception '{error.kind}' was thrown unhandled in {mode} '{backend}: {title}'. '{error.message}' at '{error.file}:{error.line}'.",
+ context: [
+ 'error' => [
+ 'kind' => $e::class,
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'file' => after($e->getFile(), ROOT_PATH),
+ ],
+ 'state' => $entity->getAll(),
+ 'backend' => $entity->via,
+ 'title' => $entity->getName(),
+ 'mode' => $entity->id === null ? 'add' : 'update',
+ ],
+ e: $e
+ )
+ );
}
}
diff --git a/src/Libs/TestCase.php b/src/Libs/TestCase.php
index ff02d90c..8ee72f26 100644
--- a/src/Libs/TestCase.php
+++ b/src/Libs/TestCase.php
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Libs;
+use Closure;
use Monolog\Handler\TestHandler;
+use Throwable;
class TestCase extends \PHPUnit\Framework\TestCase
{
@@ -25,4 +27,46 @@ class TestCase extends \PHPUnit\Framework\TestCase
fwrite(STDOUT, $logs['formatted'] . PHP_EOL);
}
}
+
+ /**
+ * Checks if the given closure throws an exception.
+ *
+ * @param Closure $closure
+ * @param string $reason
+ * @param Throwable|string $exception Expected exception class
+ * @param string $exceptionMessage (optional) Exception message
+ * @param int|null $exceptionCode (optional) Exception code
+ * @return void
+ */
+ protected function checkException(
+ Closure $closure,
+ string $reason,
+ Throwable|string $exception,
+ string $exceptionMessage = '',
+ int|null $exceptionCode = null,
+ ): void {
+ $caught = null;
+ try {
+ $closure();
+ } catch (Throwable $e) {
+ $caught = $e;
+ } finally {
+ if (null === $caught) {
+ $this->fail($reason);
+ } else {
+ $this->assertInstanceOf(
+ is_object($exception) ? $exception::class : $exception,
+ $caught,
+ $reason
+ );
+ if (!empty($exceptionMessage)) {
+ $this->assertStringContainsString($exceptionMessage, $caught->getMessage(), $reason);
+ }
+ if (!empty($exceptionCode)) {
+ $this->assertEquals($exceptionCode, $caught->getCode(), $reason);
+ }
+ }
+ }
+ }
+
}
diff --git a/src/Libs/helpers.php b/src/Libs/helpers.php
index e96959c2..56732ace 100644
--- a/src/Libs/helpers.php
+++ b/src/Libs/helpers.php
@@ -12,10 +12,13 @@ use App\Libs\Attributes\Scanner\Item as ScannerItem;
use App\Libs\Config;
use App\Libs\ConfigFile;
use App\Libs\Container;
+use App\Libs\Database\DBLayer;
use App\Libs\DataUtil;
use App\Libs\Entity\StateInterface as iState;
use App\Libs\Enums\Http\Status;
use App\Libs\Events\DataEvent;
+use App\Libs\Exceptions\AppExceptionInterface;
+use App\Libs\Exceptions\DBLayerException;
use App\Libs\Exceptions\InvalidArgumentException;
use App\Libs\Exceptions\RuntimeException;
use App\Libs\Extends\Date;
@@ -256,9 +259,8 @@ if (!function_exists('ag_delete')) {
}
if (is_int($path)) {
- if (isset($array[$path])) {
- unset($array[$path]);
- }
+ // -- if the path is int, and it's exists, it should have been caught by
+ // -- the first if condition. So, we can safely return the array as is.
return $array;
}
@@ -966,21 +968,13 @@ if (false === function_exists('r_array')) {
$pattern = '#' . preg_quote($tagLeft, '#') . '([\w_.]+)' . preg_quote($tagRight, '#') . '#is';
- $status = preg_match_all($pattern, $text, $matches);
-
- if (false === $status || $status < 1) {
- return ['message' => $text, 'context' => $context];
- }
+ preg_match_all($pattern, $text, $matches);
$replacements = [];
foreach ($matches[1] as $key) {
$placeholder = $tagLeft . $key . $tagRight;
- if (false === str_contains($text, $placeholder)) {
- continue;
- }
-
if (false === ag_exists($context, $key)) {
continue;
}
@@ -1025,7 +1019,7 @@ if (false === function_exists('generateRoutes')) {
*
* @return array The generated routes.
*/
- function generateRoutes(string $type = 'cli'): array
+ function generateRoutes(string $type = 'cli', array $opts = []): array
{
$dirs = [__DIR__ . '/../Commands'];
foreach (array_keys(Config::get('supported', [])) as $backend) {
@@ -1040,7 +1034,7 @@ if (false === function_exists('generateRoutes')) {
$routes_cli = (new Router($dirs))->generate();
- $cache = Container::get(iCache::class);
+ $cache = $opts[iCache::class] ?? Container::get(iCache::class);
try {
$cache->set('routes_cli', $routes_cli, new DateInterval('PT1H'));
@@ -1144,7 +1138,7 @@ if (false === function_exists('getSystemMemoryInfo')) {
*
* @return array{ MemTotal: float, MemFree: float, MemAvailable: float, SwapTotal: float, SwapFree: float }
*/
- function getSystemMemoryInfo(): array
+ function getSystemMemoryInfo(string $memFile = '/proc/meminfo'): array
{
$keys = [
'MemTotal' => 'mem_total',
@@ -1156,11 +1150,11 @@ if (false === function_exists('getSystemMemoryInfo')) {
$result = [];
- if (!is_readable('/proc/meminfo')) {
+ if (!is_readable($memFile)) {
return $result;
}
- if (false === ($lines = @file('/proc/meminfo', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES))) {
+ if (false === ($lines = @file($memFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES))) {
return $result;
}
@@ -1212,6 +1206,10 @@ if (!function_exists('checkIgnoreRule')) {
{
$urlParts = parse_url($guid);
+ if (false === is_array($urlParts)) {
+ throw new RuntimeException('Invalid ignore rule was given.');
+ }
+
if (null === ($db = ag($urlParts, 'user'))) {
throw new RuntimeException('No db source was given.');
}
@@ -1541,10 +1539,6 @@ if (!function_exists('parseEnvFile')) {
}
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
- if (empty($line)) {
- continue;
- }
-
if (true === str_starts_with($line, '#') || false === str_contains($line, '=')) {
continue;
}
@@ -1613,11 +1607,12 @@ if (!function_exists('isTaskWorkerRunning')) {
/**
* Check if the task worker is running. This function is only available when running in a container.
*
+ * @param string $pidFile (Optional) The PID file to check.
* @param bool $ignoreContainer (Optional) Whether to ignore the container check.
*
* @return array{ status: bool, message: string }
*/
- function isTaskWorkerRunning(bool $ignoreContainer = false): array
+ function isTaskWorkerRunning(string $pidFile = '/tmp/ws-job-runner.pid', bool $ignoreContainer = false): array
{
if (false === $ignoreContainer && !inContainer()) {
return [
@@ -1635,8 +1630,6 @@ if (!function_exists('isTaskWorkerRunning')) {
];
}
- $pidFile = '/tmp/ws-job-runner.pid';
-
if (!file_exists($pidFile)) {
return [
'status' => false,
@@ -1651,7 +1644,33 @@ if (!function_exists('isTaskWorkerRunning')) {
return ['status' => false, 'message' => $e->getMessage()];
}
- if (file_exists(r('/proc/{pid}/status', ['pid' => $pid]))) {
+ switch (PHP_OS) {
+ case 'Linux':
+ {
+ $status = file_exists(r('/proc/{pid}/status', ['pid' => $pid]));
+ }
+ break;
+ case 'WINNT':
+ {
+ // -- Windows does not have a /proc directory so we need different way to get the status.
+ @exec("tasklist /FI \"PID eq {$pid}\" 2>NUL", $output);
+ // -- windows doesn't return 0 if the process is not found. we need to parse the output.
+ $status = false;
+ foreach ($output as $line) {
+ if (false === str_contains($line, $pid)) {
+ continue;
+ }
+ $status = true;
+ break;
+ }
+ }
+ break;
+ default:
+ $status = false;
+ break;
+ }
+
+ if (true === $status) {
return ['status' => true, 'restartable' => true, 'message' => 'Task worker is running.'];
}
@@ -1865,6 +1884,7 @@ if (!function_exists('cacheableItem')) {
* @param Closure $function
* @param DateInterval|int|null $ttl
* @param bool $ignoreCache
+ * @param array $opts
*
* @return mixed
*/
@@ -1872,15 +1892,16 @@ if (!function_exists('cacheableItem')) {
string $key,
Closure $function,
DateInterval|int|null $ttl = null,
- bool $ignoreCache = false
+ bool $ignoreCache = false,
+ array $opts = [],
): mixed {
- $cache = Container::get(iCache::class);
+ $cache = $opts[iCache::class] ?? Container::get(iCache::class);
if (!$ignoreCache && $cache->has($key)) {
return $cache->get($key);
}
- $reflectContainer = Container::get(ReflectionContainer::class);
+ $reflectContainer = $opts[ReflectionContainer::class] ?? Container::get(ReflectionContainer::class);
$item = $reflectContainer->call($function);
if (null === $ttl) {
@@ -2050,7 +2071,73 @@ if (!function_exists('getBackend')) {
$default = $configFile->get($name);
$default['name'] = $name;
+ $data = array_replace_recursive($default, $config);
- return makeBackend(array_replace_recursive($default, $config), $name);
+ return makeBackend($data, $name);
+ }
+}
+
+if (!function_exists('lw')) {
+ /**
+ * log wrapper.
+ *
+ * The use case for this wrapper is to enhance the log context with db exception information.
+ * All logs should be wrapped with this function. it will probably be enhanced to include further context.
+ * in the future.
+ *
+ * @param string $message The log message.
+ * @param array $context The log context.
+ * @param Throwable|null $e The exception.
+ *
+ * @return array{ message: string, context: array} The wrapped log message and context.
+ */
+ function lw(string $message, array $context, Throwable|null $e = null): array
+ {
+ if (null === $e) {
+ return [
+ 'message' => $message,
+ 'context' => $context,
+ ];
+ }
+
+ if (true === ($e instanceof DBLayerException)) {
+ $context[DBLayer::class] = [
+ 'query' => $e->getQueryString(),
+ 'bind' => $e->getQueryBind(),
+ 'error' => $e->errorInfo ?? [],
+ ];
+ }
+
+ if (true === ($e instanceof AppExceptionInterface) && $e->hasContext()) {
+ $context[AppExceptionInterface::class] = $e->getContext();
+ }
+
+ return [
+ 'message' => $message,
+ 'context' => $context,
+ ];
+ }
+}
+
+if (!function_exists('timeIt')) {
+ /**
+ * Time the execution of a function.
+ *
+ * @param Closure $function The function to time.
+ * @param string $name The name of the function.
+ * @param int $round (Optional) The number of decimal places to round to.
+ *
+ * @return string
+ */
+ function timeIt(Closure $function, string $name, int $round = 6): string
+ {
+ $start = microtime(true);
+ $function();
+ $end = microtime(true);
+
+ return r("Execution time is '{time}' for '{name}'", [
+ 'name' => $name,
+ 'time' => round($end - $start, $round),
+ ]);
}
}
diff --git a/tests/Backends/Plex/GetLibrariesListTest.php b/tests/Backends/Plex/GetLibrariesListTest.php
index 558e7f7e..7f9c54b6 100644
--- a/tests/Backends/Plex/GetLibrariesListTest.php
+++ b/tests/Backends/Plex/GetLibrariesListTest.php
@@ -118,12 +118,12 @@ class GetLibrariesListTest extends TestCase
$this->assertFalse($response->status);
$this->assertNotNull($response->error);
- $this->assertSame(
- 'ERROR: Request for [Plex] libraries returned with unexpected [401] status code.',
+ $this->assertStringContainsString(
+ "ERROR: Request for 'Plex' libraries returned with unexpected '401' status code.",
(string)$response->error
);
$this->assertNull($response->response);
- $this->assertFalse($response->error->hasException());
+ $this->assertTrue($response->error->hasException());
}
}
diff --git a/tests/Database/PDOAdapterTest.php b/tests/Database/PDOAdapterTest.php
index 7b3090a2..3c09b9c0 100644
--- a/tests/Database/PDOAdapterTest.php
+++ b/tests/Database/PDOAdapterTest.php
@@ -6,10 +6,11 @@ declare(strict_types=1);
namespace Tests\Database;
use App\Libs\Database\DatabaseInterface as iDB;
+use App\Libs\Database\DBLayer;
use App\Libs\Database\PDO\PDOAdapter;
use App\Libs\Entity\StateEntity;
use App\Libs\Entity\StateInterface;
-use App\Libs\Exceptions\DatabaseException as DBException;
+use App\Libs\Exceptions\DBAdapterException as DBException;
use App\Libs\Guid;
use App\Libs\TestCase;
use DateTimeImmutable;
@@ -46,7 +47,7 @@ class PDOAdapterTest extends TestCase
$logger->pushHandler($this->handler);
Guid::setLogger($logger);
- $this->db = new PDOAdapter($logger, new PDO('sqlite::memory:'));
+ $this->db = new PDOAdapter($logger, new DBLayer(new PDO('sqlite::memory:')));
$this->db->migrations('up');
}
diff --git a/tests/Fixtures/local_data/fanart.png b/tests/Fixtures/local_data/fanart.png
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/Fixtures/local_data/poster.jpg b/tests/Fixtures/local_data/poster.jpg
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/Fixtures/local_data/test.mkv b/tests/Fixtures/local_data/test.mkv
new file mode 100644
index 00000000..573541ac
--- /dev/null
+++ b/tests/Fixtures/local_data/test.mkv
@@ -0,0 +1 @@
+0
diff --git a/tests/Fixtures/local_data/test.png b/tests/Fixtures/local_data/test.png
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/Fixtures/local_data/test.srt b/tests/Fixtures/local_data/test.srt
new file mode 100644
index 00000000..573541ac
--- /dev/null
+++ b/tests/Fixtures/local_data/test.srt
@@ -0,0 +1 @@
+0
diff --git a/tests/Fixtures/meminfo_data.txt b/tests/Fixtures/meminfo_data.txt
new file mode 100644
index 00000000..448fd0a5
--- /dev/null
+++ b/tests/Fixtures/meminfo_data.txt
@@ -0,0 +1,55 @@
+MemTotal: 131598708 kB
+MemFree: 10636272 kB
+MemAvailable: 113059644 kB
+Buffers: 1851320 kB
+Cached: 96874768 kB
+SwapCached: 199724 kB
+Active: 15252072 kB
+Inactive: 96757496 kB
+Active(anon): 4046452 kB
+Inactive(anon): 11604056 kB
+Active(file): 11205620 kB
+Inactive(file): 85153440 kB
+Unevictable: 43880 kB
+Mlocked: 0 kB
+SwapTotal: 144758584 kB
+SwapFree: 140512824 kB
+Zswap: 0 kB
+Zswapped: 0 kB
+Dirty: 28 kB
+Writeback: 0 kB
+AnonPages: 12869660 kB
+Mapped: 760892 kB
+Shmem: 2367028 kB
+KReclaimable: 7315620 kB
+Slab: 8107296 kB
+SReclaimable: 7315620 kB
+SUnreclaim: 791676 kB
+KernelStack: 42512 kB
+PageTables: 78184 kB
+SecPageTables: 0 kB
+NFS_Unstable: 0 kB
+Bounce: 0 kB
+WritebackTmp: 0 kB
+CommitLimit: 210557936 kB
+Committed_AS: 38290840 kB
+VmallocTotal: 34359738367 kB
+VmallocUsed: 198508 kB
+VmallocChunk: 0 kB
+Percpu: 37760 kB
+HardwareCorrupted: 0 kB
+AnonHugePages: 5797888 kB
+ShmemHugePages: 47104 kB
+ShmemPmdMapped: 0 kB
+FileHugePages: 0 kB
+FilePmdMapped: 0 kB
+HugePages_Total: 0
+HugePages_Free: 0
+HugePages_Rsvd: 0
+HugePages_Surp: 0
+Hugepagesize: 2048 kB
+Hugetlb: 0 kB
+DirectMap4k: 11644304 kB
+DirectMap2M: 119123968 kB
+DirectMap1G: 3145728 kB
+
diff --git a/tests/Fixtures/test_servers.yaml b/tests/Fixtures/test_servers.yaml
new file mode 100644
index 00000000..c06f8182
--- /dev/null
+++ b/tests/Fixtures/test_servers.yaml
@@ -0,0 +1,62 @@
+test_plex:
+ type: plex
+ url: 'https://plex.example.invalid'
+ token: t000000000000000000p
+ user: 11111111
+ uuid: s00000000000000000000000000000000000000p
+ export:
+ enabled: true
+ lastSync: null
+ import:
+ enabled: true
+ lastSync: 1724173445
+ webhook:
+ match:
+ user: true
+ uuid: true
+ options:
+ ignore: '22,1,2,3'
+ LIBRARY_SEGMENT: 1000
+ ADMIN_TOKEN: plex_admin_token
+ use_old_progress_endpoint: true
+ plex_user_uuid: r00000000000000p
+
+test_jellyfin:
+ type: jellyfin
+ url: 'https://jellyfin.example.invalid'
+ token: t000000000000000000000000000000j
+ user: u000000000000000000000000000000j
+ uuid: s000000000000000000000000000000j
+ export:
+ enabled: true
+ lastSync: null
+ import:
+ enabled: true
+ lastSync: 1724173445
+ webhook:
+ match:
+ user: false
+ uuid: true
+ options:
+ ignore: 'i000000000000000000000000000000j,i100000000000000000000000000000j'
+ MAX_EPISODE_RANGE: 6
+
+test_emby:
+ type: emby
+ url: 'https://emby.example.invalid'
+ token: t000000000000000000000000000000e
+ user: u000000000000000000000000000000e
+ uuid: s000000000000000000000000000000e
+ import:
+ enabled: true
+ lastSync: 1724173445
+ export:
+ enabled: true
+ lastSync: null
+ webhook:
+ match:
+ user: false
+ uuid: true
+ options:
+ MAX_EPISODE_RANGE: 6
+ IMPORT_METADATA_ONLY: true
diff --git a/tests/Libs/ConfigFileTest.php b/tests/Libs/ConfigFileTest.php
new file mode 100644
index 00000000..2bf84941
--- /dev/null
+++ b/tests/Libs/ConfigFileTest.php
@@ -0,0 +1,267 @@
+ 'bar', 'baz' => 'kaz', 'sub' => ['key' => 'val']];
+
+ private array $params = [];
+ private Logger|null $logger = null;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->handler = new TestHandler();
+ $this->logger = new Logger('logger', processors: [new LogMessageProcessor()]);
+ $this->logger->pushHandler($this->handler);
+
+ $this->params = [
+ 'file' => __DIR__ . '/../Fixtures/test_servers.yaml',
+ 'type' => 'yaml',
+ 'autoSave' => false,
+ 'autoCreate' => false,
+ 'autoBackup' => false,
+ 'opts' => [
+ 'json_decode' => JSON_UNESCAPED_UNICODE,
+ 'json_encode' => JSON_UNESCAPED_UNICODE,
+ ],
+ ];
+ }
+
+ public function test_constructor()
+ {
+ $this->checkException(
+ closure: fn() => new ConfigFile(
+ 'nonexistent.json',
+ 'json',
+ autoSave: false,
+ autoCreate: false,
+ autoBackup: false
+ ),
+ reason: 'If file does not exist, and autoCreate is set to false, an exception should be thrown.',
+ exception: InvalidArgumentException::class,
+ exceptionMessage: "File 'nonexistent.json' does not exist.",
+ );
+
+ $this->checkException(
+ closure: fn() => new ConfigFile(
+ 'nonexistent.json',
+ 'php',
+ autoSave: false,
+ autoCreate: false,
+ autoBackup: false
+ ),
+ reason: 'If type is not supported, an exception should be thrown.',
+ exception: InvalidArgumentException::class,
+ exceptionMessage: "Invalid content type 'php'.",
+ );
+
+ $this->checkException(
+ closure: fn() => new ConfigFile(
+ '/root/test.json',
+ 'json',
+ autoSave: false,
+ autoCreate: true,
+ autoBackup: false
+ ),
+ reason: 'If file is not writable, an exception should be thrown.',
+ exception: InvalidArgumentException::class,
+ exceptionMessage: "could not be created",
+ );
+
+ try {
+ $class = new ConfigFile(...$this->params);
+ $this->assertInstanceOf(ConfigFile::class, $class);
+ } catch (Throwable $e) {
+ $this->fail('If all conditions are met, NO exception should be been thrown. ' . $e->getMessage());
+ }
+
+ try {
+ $class = ConfigFile::open(...$this->params);
+ $this->assertInstanceOf(ConfigFile::class, $class);
+ } catch (Throwable $e) {
+ $this->fail('If all conditions are met, NO exception should be been thrown. ' . $e->getMessage());
+ }
+ }
+
+ public function test_setLogger()
+ {
+ $tmpFile = tempnam(sys_get_temp_dir(), 'test');
+ copy(__DIR__ . '/../Fixtures/test_servers.yaml', $tmpFile);
+ $params = $this->params;
+ $params['file'] = $tmpFile;
+ $params['autoBackup'] = true;
+ $class = new ConfigFile(...$params);
+ try {
+ $class->setLogger($this->logger);
+ $class->set('foo', 'bar');
+ $class->delete('kaz');
+ // -- trigger external change
+ ConfigFile::open(...$params)->set('bar', 'kaz')->persist();
+ // -- should trigger warning.
+ $class->persist();
+ $this->assertStringContainsString(
+ 'has been modified since last load.',
+ $this->handler->getRecords()[0]['message']
+ );
+ } catch (Throwable $e) {
+ $this->fail('If correct logger is passed, no exception should be thrown. ' . $e->getMessage());
+ } finally {
+ if (file_exists($tmpFile)) {
+ unlink($tmpFile);
+ }
+ if (file_exists($tmpFile . '.bak')) {
+ unlink($tmpFile . '.bak');
+ }
+ }
+ }
+
+ public function test_delete()
+ {
+ $tmpFile = tempnam(sys_get_temp_dir(), 'test');
+ copy(__DIR__ . '/../Fixtures/test_servers.yaml', $tmpFile);
+ $params = $this->params;
+ $params['file'] = $tmpFile;
+
+ try {
+ $this->assertArrayNotHasKey(
+ 'test_jellyfin',
+ ConfigFile::open(...$params)->delete('test_jellyfin')->getAll(),
+ '->delete: Failed to delete key from YAML file.'
+ );
+
+ $class = ConfigFile::open(...$params);
+ unset($class['test_jellyfin']);
+ $this->assertArrayNotHasKey(
+ 'test_jellyfin',
+ $class->getAll(),
+ 'ArrayAccess: Failed to delete key from YAML file.'
+ );
+ } catch (Throwable $e) {
+ $this->fail('If correct logger is passed, no exception should be thrown. ' . $e->getMessage());
+ } finally {
+ if (file_exists($tmpFile)) {
+ unlink($tmpFile);
+ }
+ }
+ }
+
+ public function test_get()
+ {
+ $class = new ConfigFile(...$this->params);
+
+ $this->assertArrayHasKey(
+ 'token',
+ $class->get('test_plex', []),
+ '->get: Invalid response from parsing YAML file.'
+ );
+ $this->assertArrayHasKey('token', $class['test_plex'], 'ArrayAccess: Invalid response from parsing YAML file.');
+
+ $this->assertArrayNotHasKey(
+ 'token',
+ $class->get('test_not_set', []),
+ 'Invalid response from parsing YAML file.'
+ );
+ $this->assertNull($class['test_not_set'], 'ArrayAccess: Must return null if key does not exist.');
+ }
+
+ public function test_has()
+ {
+ $class = new ConfigFile(...$this->params);
+ $this->assertTrue($class->has('test_plex'), '->has: Must return true if key exists.');
+ $this->assertTrue(isset($class['test_plex']), 'ArrayAccess: Must return true if key exists.');
+
+ $this->assertFalse($class->has('test_not_set'), '->has: Must return false if key does not exist.');
+ $this->assertFalse(isset($class['test_not_set']), 'ArrayAccess: Must return false if key does not exist.');
+ }
+
+ public function test_set()
+ {
+ $tmpFile = tempnam(sys_get_temp_dir(), 'test');
+ copy(__DIR__ . '/../Fixtures/test_servers.yaml', $tmpFile);
+ $params = $this->params;
+ $params['file'] = $tmpFile;
+
+ try {
+ $this->assertArrayHasKey(
+ 'test_foo',
+ ConfigFile::open(...$params)->set('test_foo', $this->data)->getAll(),
+ '->set: Failed to set key in YAML file.'
+ );
+
+ $class = ConfigFile::open(...$params);
+ $class['test_foo'] = $this->data;
+ $this->assertArrayHasKey('test_foo', $class->getAll(), 'ArrayAccess: Failed to set key.');
+
+ // -- test deep array.
+ $class->set('test_plex.options.foo', 'bar');
+ $this->assertArrayHasKey('foo', $class->get('test_plex.options', []), 'Failed to set deep key.');
+
+ $class['foo'] = ['bar' => ['jaz' => ['kaz' => 'baz']]];
+ $this->assertArrayHasKey('foo', $class->getAll(), 'ArrayAccess: Failed to set key.');
+ $class->set('foo', ['bar' => ['tax' => 'max']]);
+
+ $class->override()->persist();
+ $class = ConfigFile::open(...$params);
+
+ $this->assertArrayHasKey('test_foo', $class->getAll(), 'failed to persist changes.');
+ $this->assertArrayHasKey('foo', $class->getAll(), 'ArrayAccess: failed to persist changes.');
+ $this->assertArrayHasKey('bar', $class->get('foo', []), 'failed to persist changes.');
+ } catch (Throwable $e) {
+ $this->fail('If correct logger is passed, no exception should be thrown. ' . $e->getMessage());
+ } finally {
+ if (file_exists($tmpFile)) {
+ unlink($tmpFile);
+ }
+ }
+ }
+
+ public function test_configFile_set()
+ {
+ $params['file'] = tempnam(sys_get_temp_dir(), 'test');
+
+ try {
+ try {
+ copy(__DIR__ . '/../Fixtures/test_servers.yaml', $params['file']);
+ $class = new ConfigFile(...$params);
+ $this->assertInstanceOf(ConfigFile::class, $class);
+ } catch (Throwable $e) {
+ $this->fail('If file exists and is readable, no exception should be thrown. ' . $e->getMessage());
+ }
+
+ $class = ConfigFile::open(...$params);
+ $this->assertInstanceOf(ConfigFile::class, $class);
+
+ $this->assertArrayHasKey('token', $class->get('test_plex', []), 'Invalid response from parsing YAML file.');
+ $this->assertArrayNotHasKey(
+ 'token',
+ $class->get('test_not_set', []),
+ 'Invalid response from parsing YAML file.'
+ );
+ $this->assertTrue($class->has('test_plex'), 'Must return true if key exists.');
+ $this->assertFalse($class->has('test_not_set'), 'Must return false if key does not exist.');
+
+
+ $this->assertArrayHasKey('token', $class['test_plex'], 'Failed to get arrayAccess key correctly.');
+ $this->assertTrue(isset($class['test_plex']), 'Must return true if arrayAccess key exists.');
+ $this->assertNull($class['test_not_set'], 'Must return null if arrayAccess key does not exist.');
+ $this->assertFalse(isset($class['test_not_set']), 'Must return false if arrayAccess key does not exist.');
+ } finally {
+ if (file_exists($params['file'])) {
+ unlink($params['file']);
+ }
+ }
+ }
+}
diff --git a/tests/Libs/HelpersTest.php b/tests/Libs/HelpersTest.php
index f8517802..05b67e61 100644
--- a/tests/Libs/HelpersTest.php
+++ b/tests/Libs/HelpersTest.php
@@ -4,23 +4,108 @@ declare(strict_types=1);
namespace Tests\Libs;
+use App\Backends\Plex\PlexClient;
use App\Libs\Config;
+use App\Libs\Container;
+use App\Libs\Database\DBLayer;
use App\Libs\Entity\StateEntity;
+use App\Libs\Enums\Http\Method;
use App\Libs\Enums\Http\Status;
+use App\Libs\Exceptions\AppExceptionInterface;
+use App\Libs\Exceptions\DBLayerException;
use App\Libs\Exceptions\InvalidArgumentException;
use App\Libs\Exceptions\RuntimeException;
+use App\Libs\Extends\ReflectionContainer;
use App\Libs\TestCase;
use JsonMachine\Items;
use JsonMachine\JsonDecoder\ErrorWrappingDecoder;
use JsonMachine\JsonDecoder\ExtJsonDecoder;
+use JsonSerializable;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7\Stream;
use Nyholm\Psr7Server\ServerRequestCreator;
+use Psr\SimpleCache\CacheInterface;
+use Stringable;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
+use Symfony\Component\Yaml\Yaml;
+use TypeError;
class HelpersTest extends TestCase
{
+ protected CacheInterface|null $cache = null;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+ $this->cache = new class implements CacheInterface {
+ public array $cache = [];
+ public bool $throw = false;
+
+ public function get(string $key, mixed $default = null): mixed
+ {
+ return $this->cache[$key] ?? $default;
+ }
+
+ public function set(string $key, mixed $value, \DateInterval|int|null $ttl = null): bool
+ {
+ if ($this->throw) {
+ throw new class() extends \InvalidArgumentException implements
+ \Psr\SimpleCache\InvalidArgumentException {
+ };
+ }
+
+ $this->cache[$key] = $value;
+ return true;
+ }
+
+ public function delete(string $key): bool
+ {
+ unset($this->cache[$key]);
+ return true;
+ }
+
+ public function clear(): bool
+ {
+ $this->cache = [];
+ return true;
+ }
+
+ public function getMultiple(iterable $keys, mixed $default = null): iterable
+ {
+ foreach ($keys as $key) {
+ yield $key => $this->get($key, $default);
+ }
+ }
+
+ public function setMultiple(iterable $values, \DateInterval|int|null $ttl = null): bool
+ {
+ foreach ($values as $key => $value) {
+ $this->set($key, $value, $ttl);
+ }
+ return true;
+ }
+
+ public function deleteMultiple(iterable $keys): bool
+ {
+ foreach ($keys as $key) {
+ $this->delete($key);
+ }
+ return true;
+ }
+
+ public function has(string $key): bool
+ {
+ return isset($this->cache[$key]);
+ }
+
+ public function reset(): void
+ {
+ $this->cache = [];
+ }
+ };
+ }
+
public function test_env_conditions(): void
{
$values = [
@@ -171,6 +256,32 @@ class HelpersTest extends TestCase
ag_set([], 'foo.kaz', 'taz'),
'When a nested key is passed, it will be saved in format of [key => [nested_key => value]]'
);
+
+ $exception = null;
+ try {
+ ag_set(['foo' => 'bar'], 'foo.bar.taz', 'baz');
+ } catch (\Throwable $e) {
+ $exception = $e;
+ } finally {
+ $this->assertSame(
+ TypeError::class,
+ $exception ? $exception::class : null,
+ 'When trying to set value to non-array, exception is thrown.'
+ );
+ }
+
+ $exception = null;
+ try {
+ ag_set(['foo' => ['bar' => ['taz' => 'tt']]], 'foo.bar.taz.tt', 'baz');
+ } catch (\Throwable $e) {
+ $exception = $e;
+ } finally {
+ $this->assertSame(
+ RuntimeException::class,
+ $exception ? $exception::class : null,
+ 'When trying to set value to existing key, exception is thrown.'
+ );
+ }
}
public function test_ag_exits(): void
@@ -221,6 +332,23 @@ class HelpersTest extends TestCase
ag_delete($arr, 'foo'),
'When simple key is passed, and it exists, it is deleted, and copy of the modified array is returned'
);
+ $this->assertSame(
+ [0 => 'foo', 1 => 'bar'],
+ ag_delete([0 => 'foo', 1 => 'bar', 2 => 'taz'], 2),
+ 'When an int key is passed, and it exists, it is deleted, and copy of the modified array is returned'
+ );
+
+ $this->assertSame(
+ $arr,
+ ag_delete($arr, 121),
+ 'When an int key is passed, and it does not exist, original array is returned'
+ );
+
+ $this->assertSame(
+ $arr,
+ ag_delete($arr, 'test.bar'),
+ 'When a non-existing key is passed, original array is returned.'
+ );
}
public function test_fixPath(): void
@@ -332,6 +460,7 @@ class HelpersTest extends TestCase
]
]);
$data = ['foo' => 'bar'];
+ api_response(200, $data);
$response = api_response(Status::OK, $data);
$this->assertSame(Status::OK->value, $response->getStatusCode());
$this->assertSame('application/json', $response->getHeaderLine('Content-Type'));
@@ -354,11 +483,50 @@ class HelpersTest extends TestCase
]);
$data = ['error' => ['code' => Status::BAD_REQUEST->value, 'message' => 'error message']];
- $response = api_error('error message', Status::BAD_REQUEST);
+ $response = api_error('error message', Status::BAD_REQUEST, headers: [
+ 'X-Test-Header' => 'test',
+ ]);
$this->assertSame(Status::BAD_REQUEST->value, $response->getStatusCode());
$this->assertSame('application/json', $response->getHeaderLine('Content-Type'));
$this->assertSame(getAppVersion(), $response->getHeaderLine('X-Application-Version'));
+ $this->assertSame('test', $response->getHeaderLine('X-Test-Header'));
$this->assertSame($data, json_decode($response->getBody()->getContents(), true));
+
+ $response = api_error('error message', Status::BAD_REQUEST, opts: [
+ 'callback' => fn($response) => $response->withStatus(Status::INTERNAL_SERVER_ERROR->value)
+ ]);
+ $this->assertSame(Status::INTERNAL_SERVER_ERROR->value, $response->getStatusCode());
+ }
+
+ public function test_api_message(): void
+ {
+ Config::append([
+ 'api' => [
+ 'response' => [
+ 'headers' => [
+ 'Content-Type' => 'application/json',
+ 'X-Application-Version' => fn() => getAppVersion(),
+ 'Access-Control-Allow-Origin' => '*',
+ ],
+ ],
+ ]
+ ]);
+
+ $data = ['info' => ['code' => Status::OK->value, 'message' => 'info message']];
+ $response = api_message('info message', Status::OK, headers: [
+ 'X-Test-Header' => 'test',
+ ]);
+ $this->assertSame(Status::OK->value, $response->getStatusCode());
+ $this->assertSame('application/json', $response->getHeaderLine('Content-Type'));
+ $this->assertSame(getAppVersion(), $response->getHeaderLine('X-Application-Version'));
+ $this->assertSame('test', $response->getHeaderLine('X-Test-Header'));
+ $this->assertSame($data, json_decode($response->getBody()->getContents(), true));
+
+ $response = api_message('info message', Status::OK, opts: [
+ 'callback' => fn($response) => $response->withStatus(Status::INTERNAL_SERVER_ERROR->value)
+ ]);
+
+ $this->assertSame(Status::INTERNAL_SERVER_ERROR->value, $response->getStatusCode());
}
public function test_httpClientChunks(): void
@@ -461,6 +629,51 @@ class HelpersTest extends TestCase
arrayToString($data, '@ '),
'When array is passed, it is converted into array text separated by delimiter.'
);
+
+ $cl = new class implements JsonSerializable {
+ public function jsonSerialize(): array
+ {
+ return ['foo' => 'bar'];
+ }
+
+ public function __toString(): string
+ {
+ return json_encode($this->jsonSerialize());
+ }
+ };
+
+ $cl2 = new class implements Stringable {
+ public function __toString(): string
+ {
+ return json_encode(['foo' => 'bar']);
+ }
+ };
+
+ $cl3 = new class() {
+ public string $foo = 'bar';
+ };
+
+ $this->assertSame(
+ '(baz: {"foo":"bar"})',
+ arrayToString(['baz' => $cl]),
+ 'When array contains a class that implements JsonSerializable it is converted into array string.'
+ );
+
+ $this->assertSame(
+ '(baz: {"foo":"bar"}), (foo: true), (bar: false)',
+ arrayToString([
+ 'baz' => $cl2,
+ 'foo' => true,
+ 'bar' => false,
+ ]),
+ "When an object that implements Stringable is passed, it's casted to string"
+ );
+
+ $this->assertSame(
+ '(baz: [ (foo: bar) ])',
+ arrayToString(['baz' => $cl3]),
+ "When a class doesn't implement JsonSerializable or Stringable, it's converted to array. using object vars."
+ );
}
public function test_isValidName(): void
@@ -563,6 +776,9 @@ class HelpersTest extends TestCase
isIgnoredId('home_plex', 'movie', 'guid_tvdb', '1201', '121'),
'When ignore url is passed with and ignore list does not contain the url, false is returned.'
);
+
+ $this->expectException(InvalidArgumentException::class);
+ isIgnoredId('home_plex', 'not_real_type', 'guid_tvdb', '1200', '121');
}
public function test_r(): void
@@ -597,6 +813,32 @@ class HelpersTest extends TestCase
'When array is passed, it is converted into array and placeholders are replaced with values.'
);
+ $message = 'foo bar,taz';
+ $context = ['obj' => ['foo' => 'bar', 'baz' => 'taz']];
+ $this->assertSame(
+ ['message' => $message, 'context' => $context],
+ r_array($message, $context),
+ 'When non-existing placeholder is passed, string is returned as it is.'
+ );
+
+ $this->assertSame(
+ 'Time is: 2020-01-01T00:00:00+00:00',
+ r('Time is: {date}', ['date' => makeDate('2020-01-01', 'UTC')]),
+ 'When date is passed, it is converted into string and placeholders are replaced with values.'
+ );
+
+ $this->assertSame(
+ 'HTTP Status: 200',
+ r('HTTP Status: {status}', ['status' => Status::OK]),
+ 'When Int backed Enum is passed, it is converted into its value and the placeholder is replaced with it.'
+ );
+
+ $this->assertSame(
+ 'HTTP Method: POST',
+ r('HTTP Method: {method}', ['method' => Method::POST]),
+ 'When String backed Enum is passed, it is converted into its value and the placeholder is replaced with it.'
+ );
+
$res = fopen('php://memory', 'r');
$this->assertSame(
'foo [resource]',
@@ -759,4 +1001,630 @@ class HelpersTest extends TestCase
$this->fail('This function shouldn\'t throw exception when invalid file is given.');
}
}
+
+ public function test_generateRoutes()
+ {
+ $routes = generateRoutes('cli', [CacheInterface::class => $this->cache]);
+
+ $this->assertCount(
+ 2,
+ $this->cache->cache,
+ 'It should have generated two cache buckets for http and cli routes.'
+ );
+ $this->assertGreaterThanOrEqual(
+ 1,
+ count($this->cache->cache['routes_cli']),
+ 'It should have more than 1 route for cli routes.'
+ );
+ $this->assertGreaterThanOrEqual(
+ 1,
+ count($this->cache->cache['routes_http']),
+ 'It should have more than 1 route for cli routes.'
+ );
+
+ $this->assertSame(
+ $routes,
+ $this->cache->cache['routes_cli'],
+ 'It should return cli routes when called with cli type.'
+ );
+
+ $this->cache->reset();
+
+ $this->assertSame(
+ generateRoutes('http', [CacheInterface::class => $this->cache]),
+ $this->cache->cache['routes_http'],
+ 'It should return http routes. when called with http type.'
+ );
+
+ $this->cache->reset();
+ $this->cache->throw = true;
+ $routes = generateRoutes('http', [CacheInterface::class => $this->cache]);
+ $this->assertCount(0, $this->cache->cache, 'When cache throws exception, it should not save anything.');
+ $this->assertNotSame([], $routes, 'Routes should be generated even if cache throws exception.');
+
+ // --
+ $save = Config::get('supported', []);
+ Config::save('supported', ['not_set' => 'not_set_client', 'plex' => PlexClient::class,]);
+ $routes = generateRoutes('http', [CacheInterface::class => $this->cache]);
+ Config::save('supported', $save);
+ }
+
+ public function test_getSystemMemoryInfo()
+ {
+ /** @noinspection PhpUnhandledExceptionInspection */
+ $none = getSystemMemoryInfo(bin2hex(random_bytes(32)));
+ $this->assertIsArray($none, 'It should return array.');
+ $this->assertSame([], $none, 'When mem-file is not readable, it should return empty array.');
+
+ $info = getSystemMemoryInfo(__DIR__ . '/../Fixtures/meminfo_data.txt');
+ $this->assertIsArray($info, 'It should return array of memory info.');
+ $this->assertArrayHasKey('mem_total', $info, 'It should have total memory key.');
+ $this->assertArrayHasKey('mem_free', $info, 'It should have free memory key.');
+ $this->assertArrayHasKey('mem_available', $info, 'It should have available memory key.');
+ $this->assertArrayHasKey('swap_total', $info, 'It should have swap total key.');
+ $this->assertArrayHasKey('swap_free', $info, 'It should have swap free key.');
+
+ $keysValues = [
+ "mem_total" => 131598708000.0,
+ "mem_free" => 10636272000.0,
+ "mem_available" => 113059644000.0,
+ "swap_total" => 144758584000.0,
+ "swap_free" => 140512824000.0,
+ ];
+
+ foreach ($keysValues as $key => $value) {
+ $this->assertSame($value, $info[$key], "It should have correct value for {$key} key.");
+ }
+
+ if (is_writeable(sys_get_temp_dir())) {
+ try {
+ $fileName = tempnam(sys_get_temp_dir(), 'meminfo');
+ $none = getSystemMemoryInfo($fileName);
+ $this->assertIsArray($none, 'It should return array.');
+ $this->assertSame([], $none, 'When mem-file is empty it should return empty array.');
+ } finally {
+ if (file_exists($fileName)) {
+ unlink($fileName);
+ }
+ }
+ } else {
+ $this->markTestSkipped('Temp directory is not writable.');
+ }
+ }
+
+ public function test_checkIgnoreRule()
+ {
+ Config::save('servers', ['test_backend' => []]);
+ $rule = 'movie://tvdb:276923@test_backend?id=133367';
+ $this->assertTrue(checkIgnoreRule($rule));
+
+ // -- if no db source is given, it should throw exception.
+ $exception = null;
+ try {
+ checkIgnoreRule('movie://test_backend?id=133367&garbage=1');
+ } catch (\Throwable $e) {
+ $exception = $e;
+ } finally {
+ $this->assertSame(RuntimeException::class, $exception ? $exception::class : null);
+ $this->assertSame(
+ 'No db source was given.',
+ $exception?->getMessage(),
+ 'When no db source is given, it should throw exception.'
+ );
+ }
+
+ $exception = null;
+ try {
+ checkIgnoreRule('movie://foo@test_backend?id=133367&garbage=1');
+ } catch (\Throwable $e) {
+ $exception = $e;
+ } finally {
+ $this->assertSame(RuntimeException::class, $exception ? $exception::class : null);
+ $this->assertStringContainsString(
+ "Invalid db source name 'foo' was given.",
+ $exception?->getMessage(),
+ 'When invalid db source is given, it should throw exception.'
+ );
+ }
+
+ $exception = null;
+ try {
+ checkIgnoreRule('movie://tvdb@test_backend?id=133367&garbage=1');
+ } catch (\Throwable $e) {
+ $exception = $e;
+ } finally {
+ $this->assertSame(RuntimeException::class, $exception ? $exception::class : null);
+ $this->assertSame(
+ 'No external id was given.',
+ $exception?->getMessage(),
+ 'When no external id is given in the password part of url, it should throw exception.'
+ );
+ }
+
+ $exception = null;
+ try {
+ checkIgnoreRule('http://tvdb:123@test_backend?id=133367&garbage=1');
+ } catch (\Throwable $e) {
+ $exception = $e;
+ } finally {
+ $this->assertSame(
+ RuntimeException::class,
+ $exception ? $exception::class : null,
+ $exception?->getMessage() ?? ''
+ );
+ $this->assertStringContainsString(
+ "Invalid type 'http' was given.",
+ $exception?->getMessage(),
+ 'When invalid type is given, it should throw exception.'
+ );
+ }
+
+ $exception = null;
+ try {
+ checkIgnoreRule('movie://tvdb:123@not_set?id=133367&garbage=1');
+ } catch (\Throwable $e) {
+ $exception = $e;
+ } finally {
+ $this->assertSame(
+ RuntimeException::class,
+ $exception ? $exception::class : null,
+ $exception?->getMessage() ?? ''
+ );
+ $this->assertStringContainsString(
+ "Invalid backend name 'not_set' was given.",
+ $exception?->getMessage(),
+ 'When invalid backend name is given, it should throw exception.'
+ );
+ }
+
+ $exception = null;
+ try {
+ checkIgnoreRule('//tvdb:123@not_set?id=133367&garbage=1');
+ } catch (\Throwable $e) {
+ $exception = $e;
+ } finally {
+ $this->assertSame(
+ RuntimeException::class,
+ $exception ? $exception::class : null,
+ $exception?->getMessage() ?? ''
+ );
+ $this->assertStringContainsString(
+ 'No type was given.',
+ $exception?->getMessage(),
+ 'When no type is given, it should throw exception.'
+ );
+ }
+ $exception = null;
+ try {
+ checkIgnoreRule('//');
+ } catch (\Throwable $e) {
+ $exception = $e;
+ } finally {
+ $this->assertSame(
+ RuntimeException::class,
+ $exception ? $exception::class : null,
+ $exception?->getMessage() ?? ''
+ );
+ $this->assertStringContainsString(
+ 'Invalid ignore rule was given.',
+ $exception?->getMessage(),
+ 'When parse_url fails to parse url, it should throw exception.'
+ );
+ }
+ }
+
+ public function test_addCors()
+ {
+ $response = api_response(Status::OK, headers: ['X-Request-Id' => '1']);
+ $response = addCors($response, headers: [
+ 'X-Test-Add' => 'test',
+ 'X-Request-Id' => '2',
+ ], methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']);
+
+ $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+ $this->assertSame(
+ 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
+ $response->getHeaderLine('Access-Control-Allow-Methods')
+ );
+ $this->assertSame(
+ 'X-Application-Version, X-Request-Id, *',
+ $response->getHeaderLine('Access-Control-Allow-Headers')
+ );
+ $this->assertGreaterThanOrEqual(600, (int)$response->getHeaderLine('Access-Control-Max-Age'));
+ $this->assertSame('test', $response->getHeaderLine('X-Test-Add'));
+ $this->assertSame('1', $response->getHeaderLine('X-Request-Id'), 'The original header should not be altered.');
+ $this->assertNotSame(
+ '2',
+ $response->getHeaderLine('X-Request-Id'),
+ 'AddCors: headers should not alter already set headers.'
+ );
+ }
+
+ public function test_deepArrayMerge()
+ {
+ $array1 = [
+ 'foo' => 'bar',
+ 'baz' => 'taz',
+ 'kaz' => [
+ 'raz' => 'maz',
+ 'naz' => 'laz',
+ ],
+ ];
+
+ $array2 = [
+ 'foo' => 'baz',
+ 'kaz' => [
+ 'raz' => 'baz',
+ 'naz' => 'baz',
+ ],
+ ];
+
+ $expected = [
+ 'foo' => 'baz',
+ 'baz' => 'taz',
+ 'kaz' => [
+ 'raz' => 'baz',
+ 'naz' => 'baz',
+ ],
+ ];
+
+ $this->assertSame($expected, deepArrayMerge([$array1, $array2]), 'It should merge arrays correctly.');
+ $this->assertSame(
+ [['foo' => 'baz'], ['baz' => 'taz'],],
+ deepArrayMerge([[['foo' => 'bar']], [['foo' => 'baz'], ['baz' => 'taz'],]], true),
+ 'if preserve keys is true'
+ );
+
+ $this->assertSame(
+ [['foo' => 'bar'], ['foo' => 'baz'], ['baz' => 'taz'],],
+ deepArrayMerge([[['foo' => 'bar']], [['foo' => 'baz'], ['baz' => 'taz'],]], false),
+ 'if preserve keys is false'
+ );
+ }
+
+ public function test_tryCatch()
+ {
+ $f = null;
+ $x = tryCatch(fn() => throw new RuntimeException(), fn($e) => $e, function () use (&$f) {
+ $f = 'finally_was_called';
+ });
+
+ $this->assertInstanceOf(
+ RuntimeException::class,
+ $x,
+ 'When try block is successful, it should return the value.'
+ );
+ $this->assertSame('finally_was_called', $f, 'finally block should be executed.');
+ }
+
+ public function test_getServerColumnSpec()
+ {
+ $this->assertSame(
+ [
+ 'key' => 'user',
+ 'type' => 'string',
+ 'visible' => true,
+ 'description' => 'The user ID of the backend.',
+ ],
+ getServerColumnSpec('user'),
+ 'It should return correct column spec.'
+ );
+
+ $this->assertSame([], getServerColumnSpec('not_set'), 'It should return empty array when column is not set.');
+ }
+
+ public function test_getEnvSpec()
+ {
+ $this->assertSame(
+ [
+ 'key' => 'WS_DATA_PATH',
+ 'description' => 'Where to store main data. (config, db).',
+ 'type' => 'string',
+ ],
+ getEnvSpec('WS_DATA_PATH'),
+ 'It should return correct env spec.'
+ );
+
+ $this->assertSame([], getEnvSpec('not_set'), 'It should return empty array when env is not set.');
+ }
+
+ public function test_isTaskWorkerRunning()
+ {
+ $_ENV['IN_CONTAINER'] = false;
+ $d = isTaskWorkerRunning();
+ $this->assertTrue($d['status'], 'When not in container, and $ignoreContainer is false, it should return true.');
+ unset($_ENV['IN_CONTAINER']);
+
+ $_ENV['DISABLE_CRON'] = true;
+ $d = isTaskWorkerRunning(ignoreContainer: true);
+ $this->assertFalse($d['status'], 'When DISABLE_CRON is set, it should return false.');
+ unset($_ENV['DISABLE_CRON']);
+
+ $d = isTaskWorkerRunning(pidFile: __DIR__ . '/../Fixtures/worker.pid', ignoreContainer: true);
+ $this->assertFalse($d['status'], 'When pid file is not found, it should return false.');
+
+ $tmpFile = tempnam(sys_get_temp_dir(), 'worker');
+ try {
+ file_put_contents($tmpFile, getmypid());
+ $d = isTaskWorkerRunning(pidFile: $tmpFile, ignoreContainer: true);
+ $this->assertTrue($d['status'], 'When pid file is found, and process exists it should return true.');
+ } finally {
+ if (file_exists($tmpFile)) {
+ unlink($tmpFile);
+ }
+ }
+
+ $tmpFile = tempnam(sys_get_temp_dir(), 'worker');
+ try {
+ /** @noinspection PhpUnhandledExceptionInspection */
+ file_put_contents($tmpFile, random_int(1, 9999) . getmypid());
+ $d = isTaskWorkerRunning(pidFile: $tmpFile, ignoreContainer: true);
+ $this->assertFalse(
+ $d['status'],
+ 'When pid file is found, and process does not exists it should return false.'
+ );
+ } finally {
+ if (file_exists($tmpFile)) {
+ unlink($tmpFile);
+ }
+ }
+ }
+
+ public function test_findSideCarFiles()
+ {
+ $n = new \SplFileInfo(__DIR__ . '/../Fixtures/local_data/test.mkv');
+ $this->assertCount(
+ 4,
+ findSideCarFiles($n),
+ 'It should return side car files for given file.'
+ );
+ }
+
+ public function test_array_change_key_case_recursive()
+ {
+ $array = [
+ 'foo' => 'bar',
+ 'baz' => 'taz',
+ 'kaz' => [
+ 'raz' => 'maz',
+ 'naz' => 'laz',
+ ],
+ ];
+
+ $expected = [
+ 'FOO' => 'bar',
+ 'BAZ' => 'taz',
+ 'KAZ' => [
+ 'RAZ' => 'maz',
+ 'NAZ' => 'laz',
+ ],
+ ];
+
+ $this->assertSame(
+ $expected,
+ array_change_key_case_recursive($array, CASE_UPPER),
+ 'It should change keys case.'
+ );
+
+ $this->assertSame(
+ $array,
+ array_change_key_case_recursive($expected, CASE_LOWER),
+ 'It should change keys case.'
+ );
+
+ $this->expectException(RuntimeException::class);
+ array_change_key_case_recursive($array, 999);
+ }
+
+ public function test_getMimeType()
+ {
+ $this->assertSame(
+ 'application/json',
+ getMimeType(__DIR__ . '/../Fixtures/plex_data.json'),
+ 'It should return correct mime type.'
+ );
+ }
+
+ public function test_getExtension()
+ {
+ $this->assertSame(
+ 'json',
+ getExtension(__DIR__ . '/../Fixtures/plex_data.json'),
+ 'It should return correct extension.'
+ );
+ }
+
+ public function test_generateUUID()
+ {
+ #1ef6d04c-23c3-6442-9fd5-c87f54c3d8d1
+ $this->assertMatchesRegularExpression(
+ '/^[0-9a-f]{8}-[0-9a-f]{4}-6[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/',
+ generateUUID(),
+ 'It should match valid UUID6 pattern.'
+ );
+
+ $this->assertMatchesRegularExpression(
+ '/^test\-[0-9a-f]{8}-[0-9a-f]{4}-6[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/',
+ generateUUID('test'),
+ 'It should match valid UUID6 pattern.'
+ );
+ }
+
+ public function test_cacheableItem()
+ {
+ $reflectContainer = new class() {
+ public function call(callable $callable, array $args = []): mixed
+ {
+ return $callable(...$args);
+ }
+ };
+
+ $item = fn() => cacheableItem(
+ key: 'test',
+ function: fn() => 'foo',
+ ignoreCache: false,
+ opts: [
+ CacheInterface::class => $this->cache,
+ ReflectionContainer::class => $reflectContainer,
+ ]
+ );
+
+ $this->assertSame('foo', $item(), 'It should return correct value.');
+ $this->assertSame('foo', $item(), 'It should return correct value.');
+ }
+
+ public function test_getPagination()
+ {
+ $factory = new Psr17Factory();
+ $creator = new ServerRequestCreator($factory, $factory, $factory, $factory);
+
+ $request = $creator->fromArrays([
+ 'REQUEST_METHOD' => 'GET',
+ 'QUERY_STRING' => 'page=2&perpage=10'
+ ], get: ['page' => 2, 'perpage' => 10]);
+
+ [$page, $perpage, $start] = getPagination($request, 1);
+
+ $this->assertSame(2, $page, 'It should return correct page number.');
+ $this->assertSame(10, $perpage, 'It should return correct perpage number.');
+ $this->assertSame(10, $start, 'It should return correct start number.');
+ }
+
+ public function test_getBackend()
+ {
+ Container::init();
+ Config::init(require __DIR__ . '/../../config/config.php');
+ foreach ((array)require __DIR__ . '/../../config/services.php' as $name => $definition) {
+ Container::add($name, $definition);
+ }
+ Config::save('backends_file', __DIR__ . '/../Fixtures/test_servers.yaml');
+
+ $this->assertInstanceOf(
+ PlexClient::class,
+ getBackend('test_plex'),
+ 'It should return correct backend client.'
+ );
+
+ $this->expectException(RuntimeException::class);
+ getBackend('not_set');
+ }
+
+ public function test_makeBackend()
+ {
+ Container::init();
+ Config::init(require __DIR__ . '/../../config/config.php');
+ foreach ((array)require __DIR__ . '/../../config/services.php' as $name => $definition) {
+ Container::add($name, $definition);
+ }
+ Config::save('backends_file', __DIR__ . '/../Fixtures/test_servers.yaml');
+
+ $exception = null;
+ try {
+ makeBackend([], 'foo');
+ } catch (\Throwable $e) {
+ $exception = $e;
+ } finally {
+ $this->assertInstanceOf(
+ InvalidArgumentException::class,
+ $exception,
+ 'Should throw exception when no type is given.'
+ );
+ $this->assertStringContainsString('No backend type was set.', $exception?->getMessage());
+ }
+
+ $exception = null;
+ try {
+ makeBackend(['type' => 'plex'], 'foo');
+ } catch (\Throwable $e) {
+ $exception = $e;
+ } finally {
+ $this->assertInstanceOf(
+ InvalidArgumentException::class,
+ $exception,
+ 'Should throw exception when no url is given.'
+ );
+ $this->assertStringContainsString('No backend url was set.', $exception?->getMessage());
+ }
+
+ $exception = null;
+ try {
+ makeBackend(['type' => 'far', 'url' => 'http://test.example.invalid'], 'foo');
+ } catch (\Throwable $e) {
+ $exception = $e;
+ } finally {
+ $this->assertInstanceOf(
+ InvalidArgumentException::class,
+ $exception,
+ 'Should throw exception when no type is not supported.'
+ );
+ $this->assertStringContainsString('Unexpected client type', $exception?->getMessage());
+ }
+
+ $data = Yaml::parseFile(__DIR__ . '/../Fixtures/test_servers.yaml');
+
+ $this->assertInstanceOf(
+ PlexClient::class,
+ makeBackend($data['test_plex'], 'test_plex'),
+ 'It should return correct backend client.'
+ );
+ }
+
+ public function test_lw()
+ {
+ $exception = new RuntimeException();
+ $exception->addContext('foo', 'bar');
+
+ $this->assertSame(
+ [AppExceptionInterface::class => ['foo' => 'bar']],
+ lw('test', [], $exception)['context'],
+ 'it should return the added AppContext'
+ );
+ $this->assertSame(
+ ['bar' => 'foo'],
+ lw('test', ['bar' => 'foo'], new \RuntimeException())['context'],
+ 'If exception is not AppExceptionInterface, it should return same data.'
+ );
+
+ $exception = new DBLayerException();
+ /** @noinspection SqlResolve */
+ $exception->setInfo('SELECT * FROM foo WHERE id = :id', ['id' => 1], [], 122);
+ /** @noinspection SqlResolve */
+ $this->assertSame(
+ [
+ 'bar' => 'foo',
+ DBLayer::class => [
+ 'query' => 'SELECT * FROM foo WHERE id = :id',
+ 'bind' => ['id' => 1],
+ 'error' => [],
+ ],
+ ],
+ lw('test', ['bar' => 'foo'], $exception)['context'],
+ 'If exception is not AppExceptionInterface, it should return same data.'
+ );
+
+ $this->assertSame(
+ ['bar' => 'foo'],
+ lw('test', ['bar' => 'foo'])['context'],
+ 'If no exception is given, it should return same data.'
+ );
+ }
+
+ public function test_commandContext()
+ {
+ $_ENV['IN_CONTAINER'] = true;
+ $this->assertSame(
+ 'docker exec -ti watchstate console',
+ trim(commandContext()),
+ 'It should return correct command context. When in container.'
+ );
+ unset($_ENV['IN_CONTAINER']);
+
+ $_ENV['IN_CONTAINER'] = false;
+ $this->assertSame(
+ $_SERVER['argv'][0] ?? 'php bin/console',
+ trim(commandContext()),
+ 'If not in container, it should return argv[0] or defaults to php bin/console.'
+ );
+ unset($_ENV['IN_CONTAINER']);
+ }
}
diff --git a/tests/Mappers/Import/AbstractTestsMapper.php b/tests/Mappers/Import/AbstractTestsMapper.php
index 2ccbbeca..ebe4cf83 100644
--- a/tests/Mappers/Import/AbstractTestsMapper.php
+++ b/tests/Mappers/Import/AbstractTestsMapper.php
@@ -5,10 +5,11 @@ declare(strict_types=1);
namespace Tests\Mappers\Import;
use App\Libs\Database\DatabaseInterface as iDB;
+use App\Libs\Database\DBLayer;
use App\Libs\Database\PDO\PDOAdapter;
use App\Libs\Entity\StateEntity;
use App\Libs\Entity\StateInterface as iState;
-use App\Libs\Exceptions\DatabaseException;
+use App\Libs\Exceptions\DBAdapterException;
use App\Libs\Extends\LogMessageProcessor;
use App\Libs\Guid;
use App\Libs\Mappers\ImportInterface;
@@ -51,7 +52,7 @@ abstract class AbstractTestsMapper extends TestCase
$this->logger->pushHandler($this->handler);
Guid::setLogger($this->logger);
- $this->db = new PDOAdapter($this->logger, new PDO('sqlite::memory:'));
+ $this->db = new PDOAdapter($this->logger, new DBLayer(new PDO('sqlite::memory:')));
$this->db->migrations('up');
$this->mapper = $this->setupMapper();
@@ -529,7 +530,7 @@ abstract class AbstractTestsMapper extends TestCase
{
$testEpisode = new StateEntity($this->testEpisode);
$testEpisode->episode = 0;
- $this->expectException(DatabaseException::class);
+ $this->expectException(DBAdapterException::class);
$this->db->commit([$testEpisode]);
}
@@ -540,7 +541,7 @@ abstract class AbstractTestsMapper extends TestCase
{
$testEpisode = new StateEntity($this->testEpisode);
$testEpisode->episode = 0;
- $this->expectException(DatabaseException::class);
+ $this->expectException(DBAdapterException::class);
$this->db->insert($testEpisode);
}
@@ -551,7 +552,7 @@ abstract class AbstractTestsMapper extends TestCase
{
$testEpisode = new StateEntity($this->testEpisode);
$testEpisode->episode = 0;
- $this->expectException(DatabaseException::class);
+ $this->expectException(DBAdapterException::class);
$this->db->update($testEpisode);
}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 9f2d88bd..7af6b8b6 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -2,6 +2,10 @@
declare(strict_types=1);
+if (false === defined('IN_TEST_MODE')) {
+ define('IN_TEST_MODE', true);
+}
+
require __DIR__ . '/../pre_init.php';
if (!file_exists(__DIR__ . '/../vendor/autoload.php')) {