Finalize guid.yaml support for beta testing

This commit is contained in:
Abdulmhsen B. A. A.
2024-09-14 17:55:50 +03:00
parent 9978625c92
commit 2f7cb49596
8 changed files with 1186 additions and 11 deletions

65
FAQ.md
View File

@@ -947,3 +947,68 @@ In my docker host the group id for `video` is `44` and for `render` is `105`. ch
file to match your setup.
Note: the tip about adding the group_add came from the user `binarypancakes` in discord.
---
### Advanced: How to extend the GUID parser to support more GUIDs or custom ones?
You can extend the parser by creating new file at `/config/config/guid.yaml` with the following content.
```yaml
# The version of the guid file. right now in beta so it's 0.0. not required to be present.
version: 0.0
# The key must be in lower case. and it's an array.
guids:
- type: string # must be exactly string do not change it.
name: guid_mydb # the name must start with guid_ with no spaces and lower case.
description: "My custom database guid" # description of the guid.
# Validator object. to validate the guid.
validator:
pattern: /^[0-9\/]+$/i # regex pattern to match the guid. The pattern must also support / being in the guid. as we use the same object to generate relative guid.
example: "(number)" # example of the guid.
tests:
valid:
- "1234567" # valid guid examples.
invalid:
- "1234567a" # invalid guid examples.
- "a111234567" # invalid guid examples.
# Extend Plex client to support the new guid.
plex:
- legacy: true # Tag the mapper as legacy GUID for mapping.
# Required map object. to map the new guid to WatchState guid.
map:
from: com.plexapp.agents.foo # map.from this string.
to: guid_mydb # map.to this guid.
# (Optional) Replace helper. Sometimes you need to replace the guid identifier to another.
# The replacement happens before the mapping, so if you replace the guid identifier, you should also
# update the map.from to match the new identifier.
replace:
from: com.plexapp.agents.foobar:// # Replace from this string
to: com.plexapp.agents.foo:// # Into this string.
# Extend Jellyfin client to support the new guid.
jellyfin:
# Required map object. to map the new guid to WatchState guid.
- map:
from: foo # map.from this string.
to: guid_mydb # map.to this guid.
# Extend Emby client to support the new guid.
emby:
# Required map object. to map the new guid to WatchState guid.
- map:
from: foo # map.from this string.
to: guid_mydb # map.to this guid.
```
As you can see from the config, it's roughly how we expected it to be. The `guids` array is where you define your new
guids. The `plex`, `jellyfin` and `emby` objects are where you map the new guid to the WatchState guid.
Everything in this file should be in lower case. If error occurs, the tool will log a warning and ignore the guid,
By default, we only show `ERROR` levels in log file, You can lower it by setting `WS_LOGGER_FILE_LEVEL` environment variable
to `WARNING`.
If you added or removed a guid from the `guid.yaml` file, you should run `system:reindex --force-reindex` command to update the
database indexes with the new guids.

View File

@@ -6,18 +6,25 @@ namespace App\Backends\Jellyfin;
use App\Backends\Common\Context;
use App\Backends\Common\GuidInterface as iGuid;
use App\Libs\Config;
use App\Libs\Exceptions\Backends\InvalidArgumentException;
use App\Libs\Guid;
use Psr\Log\LoggerInterface;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
use Throwable;
class JellyfinGuid implements iGuid
{
private const array GUID_MAPPER = [
private string $type;
private array $guidMapper = [
'imdb' => Guid::GUID_IMDB,
'tmdb' => Guid::GUID_TMDB,
'tvdb' => Guid::GUID_TVDB,
'tvmaze' => Guid::GUID_TVMAZE,
'tvrage' => Guid::GUID_TVRAGE,
'anidb' => Guid::GUID_ANIDB,
'ytinforeader' => Guid::GUID_YOUTUBE,
'cmdb' => Guid::GUID_CMDB,
];
@@ -31,6 +38,149 @@ class JellyfinGuid implements iGuid
*/
public function __construct(protected LoggerInterface $logger)
{
$this->type = str_contains(static::class, 'EmbyGuid') ? 'emby' : 'jellyfin';
$file = Config::get('guid.file', null);
try {
if (null !== $file && true === file_exists($file)) {
$this->parseGUIDFile($file);
}
} catch (Throwable $e) {
$this->logger->error("{class}: Failed to read or parse '{guid}' file. Error '{error}'.", [
'class' => afterLast(static::class, '\\'),
'guid' => $file,
'error' => $e->getMessage(),
'exception' => [
'message' => $e->getMessage(),
'code' => $e->getCode(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTrace(),
],
]);
}
}
/**
* Extends WatchState GUID parsing to include external GUIDs.
*
* @param string $file The path to the external GUID mapping file.
*
* @throws InvalidArgumentException if the file does not exist or is not readable.
* @throws InvalidArgumentException if the GUIDs file cannot be parsed.
* @throws InvalidArgumentException if the file version is not supported.
*/
public function parseGUIDFile(string $file): void
{
if (false === file_exists($file) || false === is_readable($file)) {
throw new InvalidArgumentException(r("The file '{file}' does not exist or is not readable.", [
'file' => $file,
]));
}
if (filesize($file) < 1) {
$this->logger->info("The external GUID mapping file '{file}' is empty.", ['file' => $file]);
return;
}
try {
$yaml = Yaml::parseFile($file);
if (false === is_array($yaml)) {
throw new InvalidArgumentException(r("The GUIDs file '{file}' is not an array.", [
'file' => $file,
]));
}
} catch (ParseException $e) {
throw new InvalidArgumentException(r("Failed to parse GUIDs file. Error '{error}'.", [
'error' => $e->getMessage(),
]), code: (int)$e->getCode(), previous: $e);
}
$supported = array_keys(Guid::getSupported());
$supportedVersion = Config::get('guid.version', '0.0');
$guidVersion = (string)ag($yaml, 'version', $supportedVersion);
if (true === version_compare($supportedVersion, $guidVersion, '<')) {
throw new InvalidArgumentException(r("Unsupported file version '{version}'. Expecting '{supported}'.", [
'version' => $guidVersion,
'supported' => $supportedVersion,
]));
}
$mapping = ag($yaml, $this->type, []);
if (false === is_array($mapping)) {
throw new InvalidArgumentException(r("The GUIDs file '{file}' {type} sub key is not an array.", [
'type' => $this->type,
'file' => $file,
]));
}
if (count($mapping) < 1) {
return;
}
foreach ($mapping as $key => $map) {
if (false === is_array($map)) {
$this->logger->warning("Ignoring '{type}.{key}'. Value must be an object. '{given}' is given.", [
'key' => $key,
'type' => $this->type,
'given' => get_debug_type($map),
]);
continue;
}
$mapper = ag($map, 'map', null);
if (false === is_array($mapper)) {
$this->logger->warning("Ignoring '{type}.{key}'. map value must be an object. '{given}' is given.", [
'key' => $key,
'type' => $this->type,
'given' => get_debug_type($mapper),
]);
continue;
}
$from = ag($mapper, 'from', null);
$to = ag($mapper, 'to', null);
if (empty($from) || false === is_string($from)) {
$this->logger->warning("Ignoring '{type}.{key}'. map.from field is empty or not a string.", [
'type' => $this->type,
'key' => $key,
]);
continue;
}
if (empty($to) || false === is_string($to)) {
$this->logger->warning("Ignoring '{type}.{key}'. map.to field is empty or not a string.", [
'type' => $this->type,
'key' => $key,
]);
continue;
}
if (false === Guid::validateGUIDName($to)) {
$this->logger->warning("Ignoring '{type}.{key}'. map.to '{to}' field does not starts with 'guid_'.", [
'type' => $this->type,
'key' => $key,
'to' => $to,
]);
continue;
}
if (false === in_array($to, $supported)) {
$this->logger->warning("Ignoring '{type}.{key}'. map.to field is not a supported GUID type.", [
'type' => $this->type,
'key' => $key,
'to' => $to,
]);
continue;
}
$this->guidMapper[$from] = $to;
}
}
public function withContext(Context $context): self
@@ -79,7 +229,7 @@ class JellyfinGuid implements iGuid
$type = JellyfinClient::TYPE_MAPPER[$type] ?? $type;
foreach (array_change_key_case($guids, CASE_LOWER) as $key => $value) {
if (null === (self::GUID_MAPPER[$key] ?? null) || empty($value)) {
if (null === ($this->guidMapper[$key] ?? null) || empty($value)) {
continue;
}
@@ -87,8 +237,10 @@ class JellyfinGuid implements iGuid
if (true === isIgnoredId($this->context->backendName, $type, $key, $value, $id)) {
if (true === $log) {
$this->logger->debug(
'Ignoring [{backend}] external id [{source}] for {item.type} [{item.title}] as requested.',
"{class}: Ignoring '{client}: {backend}' external id '{source}' for {item.type} '{item.id}: {item.title}' as requested.",
[
'class' => afterLast(static::class, '\\'),
'client' => $this->context->clientName,
'backend' => $this->context->backendName,
'source' => $key . '://' . $value,
'guid' => [
@@ -102,12 +254,13 @@ class JellyfinGuid implements iGuid
continue;
}
$guid[self::GUID_MAPPER[$key]] = $value;
$guid[$this->guidMapper[$key]] = $value;
} catch (Throwable $e) {
if (true === $log) {
$this->logger->error(
message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] parsing [{agent}] identifier. Error [{error.message} @ {error.file}:{error.line}].',
message: "{class}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing '{agent}' identifier. Error '{error.message}' at '{error.file}:{error.line}'.",
context: [
'class' => afterLast(static::class, '\\'),
'backend' => $this->context->backendName,
'client' => $this->context->clientName,
'error' => [
@@ -136,4 +289,16 @@ class JellyfinGuid implements iGuid
return $guid;
}
/**
* Get the configuration.
*
* @return array
*/
public function getConfig(): array
{
return [
'guidMapper' => $this->guidMapper,
];
}
}

View File

@@ -342,7 +342,7 @@ final class PlexGuid implements iGuid
if (false === str_contains($val, '://')) {
if (true === $log) {
$this->logger->info('PlexGuid: Unable to parse [{backend}] [{agent}] identifier.', [
$this->logger->info("PlexGuid: Unable to parse '{backend}: {agent}' identifier.", [
'backend' => $this->context->backendName,
'agent' => $val,
...$context
@@ -472,6 +472,10 @@ final class PlexGuid implements iGuid
$agentGuid = explode('://', after($guid, 'agents.'));
if (false === isset($agentGuid[1])) {
return $guid;
}
return $agentGuid[0] . '://' . before($agentGuid[1], '?');
} catch (Throwable $e) {
if (true === $log) {

View File

@@ -268,7 +268,7 @@ final class Guid implements JsonSerializable, Stringable
}
$name = ag($def, 'name');
if (null === $name || false === str_starts_with($name, 'guid_')) {
if (null === $name || false === self::validateGUIDName($name)) {
self::$logger?->warning(
"Ignoring 'guids.{key}'. name must start with 'guid_'. '{given}' is given.",
[
@@ -574,4 +574,16 @@ final class Guid implements JsonSerializable, Stringable
{
self::$checkedExternalFile = false;
}
/**
* Validate Externally Added GUID Names.
*
* @param string $name The name to validate.
*
* @return bool True if the name is valid, false otherwise.
*/
public static function validateGUIDName(string $name): bool
{
return str_starts_with($name, 'guid_') && isValidName($name);
}
}

View File

@@ -23,9 +23,14 @@ class TestCase extends \PHPUnit\Framework\TestCase
return;
}
foreach ($this->handler->getRecords() as $logs) {
fwrite(STDOUT, $logs['formatted'] . PHP_EOL);
}
$d = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[0];
$getScript = ag($d, 'file');
$line = ag($d, 'line');
dump(
$getScript . ':' . $line,
array_map(fn($v) => $v['formatted'] ?? $v['message'], $this->handler->getRecords())
);
}
/**

View File

@@ -0,0 +1,400 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
declare(strict_types=1);
namespace Tests\Backends\Emby;
use App\Backends\Common\Cache;
use App\Backends\Common\Context;
use App\Backends\Emby\EmbyClient;
use App\Backends\Emby\EmbyGuid;
use App\Libs\Config;
use App\Libs\Exceptions\Backends\InvalidArgumentException;
use App\Libs\Extends\LogMessageProcessor;
use App\Libs\Guid;
use App\Libs\TestCase;
use App\Libs\Uri;
use Monolog\Handler\TestHandler;
use Monolog\Level;
use Monolog\Logger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Psr16Cache;
use Symfony\Component\Yaml\Yaml;
class EmbyGuidTest extends TestCase
{
protected Logger|null $logger = null;
private function logged(Level $level, string $message, bool $clear = false): bool
{
try {
foreach ($this->handler->getRecords() as $record) {
if ($level !== $record->level) {
continue;
}
if (null !== $record->formatted && true === str_contains($record->formatted, $message)) {
return true;
}
if (true === str_contains($record->message, $message)) {
return true;
}
}
return false;
} finally {
if (true === $clear) {
$this->handler->clear();
}
}
}
private function getClass(): EmbyGuid
{
$this->handler->clear();
return (new EmbyGuid($this->logger))->withContext(
new Context(
clientName: EmbyClient::CLIENT_NAME,
backendName: 'test_emby',
backendUrl: new Uri('http://127.0.0.1:8096'),
cache: new Cache($this->logger, new Psr16Cache(new ArrayAdapter())),
logger: $this->logger,
backendId: 's000000000000000000000000000000e',
backendToken: 't000000000000000000000000000000e',
backendUser: 'u000000000000000000000000000000e',
)
);
}
protected function setUp(): void
{
parent::setUp();
$this->handler = new TestHandler();
$this->logger = new Logger('logger', processors: [new LogMessageProcessor()]);
$this->logger->pushHandler($this->handler);
Guid::setLogger($this->logger);
}
public function test__construct()
{
$oldGuidFile = Config::get('guid.file');
$tmpFile = tempnam(sys_get_temp_dir(), 'guid');
try {
file_put_contents($tmpFile, "{'foo' => 'too' }");
Config::save('guid.file', $tmpFile);
$this->getClass();
$this->assertTrue(
$this->logged(Level::Error, 'Failed to parse GUIDs file', true),
"Assert message logged when the value type does not match the expected type."
);
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
Config::save('guid.file', $oldGuidFile);
}
}
public function test_parseGUIDFile()
{
Config::save('guid.file', null);
$tmpFile = tempnam(sys_get_temp_dir(), 'guid');
try {
$this->checkException(
closure: function () use ($tmpFile) {
file_put_contents($tmpFile, 'version: 2.0');
$this->getClass()->parseGUIDFile($tmpFile);
},
reason: "Failed to throw exception when the GUID file version is not supported.",
exception: InvalidArgumentException::class,
exceptionMessage: 'Unsupported file version'
);
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
$this->checkException(
closure: fn() => $this->getClass()->parseGUIDFile('not_set.yml'),
reason: "Failed to assert that the GUID file is not found.",
exception: InvalidArgumentException::class,
exceptionMessage: 'does not exist'
);
$tmpFile = tempnam(sys_get_temp_dir(), 'guid');
try {
$this->checkException(
closure: function () use ($tmpFile) {
file_put_contents($tmpFile, 'fff: {_]');
$this->getClass()->parseGUIDFile($tmpFile);
},
reason: "Failed to throw exception when the GUID file is invalid.",
exception: InvalidArgumentException::class,
exceptionMessage: 'Failed to parse GUIDs file'
);
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
$tmpFile = tempnam(sys_get_temp_dir(), 'guid');
try {
$this->checkException(
closure: function () use ($tmpFile) {
file_put_contents($tmpFile, 'invalid');
$this->getClass()->parseGUIDFile($tmpFile);
},
reason: "Failed to throw exception when the GUID file is invalid.",
exception: InvalidArgumentException::class,
exceptionMessage: 'is not an array'
);
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
$tmpFile = tempnam(sys_get_temp_dir(), 'guid');
try {
$this->getClass()->parseGUIDFile($tmpFile);
$this->assertTrue(
$this->logged(Level::Info, 'is empty', true),
"Failed to assert that the GUID file is empty."
);
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
$tmpFile = tempnam(sys_get_temp_dir(), 'guid');
try {
$this->checkException(
closure: function () use ($tmpFile) {
file_put_contents($tmpFile, Yaml::dump(['emby' => 'foo']));
$this->getClass()->parseGUIDFile($tmpFile);
},
reason: "Should throw an exception when there are no GUIDs mapping.",
exception: InvalidArgumentException::class,
exceptionMessage: 'emby sub key is not an array'
);
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
$tmpFile = tempnam(sys_get_temp_dir(), 'guid');
try {
$this->handler->clear();
$yaml = ['emby' => []];
file_put_contents($tmpFile, Yaml::dump($yaml));
$this->assertCount(0, $this->handler->getRecords(), "There should be no messages logged for empty list.");
$this->handler->clear();
file_put_contents($tmpFile, Yaml::dump(ag_set($yaml, 'emby.0', 'ff')));
$this->getClass()->parseGUIDFile($tmpFile);
$this->assertTrue(
$this->logged(Level::Warning, 'Value must be an object.', true),
'Assert replace key is an object.'
);
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
$this->handler->clear();
$tmpFile = tempnam(sys_get_temp_dir(), 'guid');
try {
$yaml = ag_set(['emby' => []], 'emby.0.map', 'foo');
file_put_contents($tmpFile, Yaml::dump($yaml));
$this->getClass()->parseGUIDFile($tmpFile);
$this->assertTrue(
$this->logged(Level::Warning, 'map value must be an object.', true),
'Assert replace key is an object.'
);
$yaml = ag_set($yaml, 'emby.0', ['map' => []]);
file_put_contents($tmpFile, Yaml::dump($yaml));
$this->getClass()->parseGUIDFile($tmpFile);
$this->assertTrue(
$this->logged(Level::Warning, 'map.from field is empty or not a string.', true),
'Assert to field is a string.'
);
$yaml = ag_set($yaml, 'emby.0.map.from', 'foo');
file_put_contents($tmpFile, Yaml::dump($yaml));
$this->getClass()->parseGUIDFile($tmpFile);
$this->assertTrue(
$this->logged(Level::Warning, 'map.to field is empty or not a string.', true),
'Assert to field is a string.'
);
$yaml = ag_set($yaml, 'emby.0.map.to', 'foobar');
file_put_contents($tmpFile, Yaml::dump($yaml));
$this->getClass()->parseGUIDFile($tmpFile);
$this->assertTrue(
$this->logged(Level::Warning, 'field does not starts with', true),
'Assert to field is a string.'
);
$yaml = ag_set($yaml, 'emby.0.map.to', 'guid_foobar');
file_put_contents($tmpFile, Yaml::dump($yaml));
$this->getClass()->parseGUIDFile($tmpFile);
$this->assertTrue(
$this->logged(Level::Warning, 'map.to field is not a supported', true),
'Assert to field is a string.'
);
$yaml = ag_set($yaml, 'emby.0.map', [
'from' => 'tsdb',
'to' => Guid::GUID_IMDB,
]);
$this->handler->clear();
file_put_contents($tmpFile, Yaml::dump($yaml));
$class = $this->getClass();
$class->parseGUIDFile($tmpFile);
$this->assertArrayHasKey(
'tsdb',
ag($class->getConfig(), 'guidMapper', []),
'Assert that the GUID mapping has been added.'
);
$this->handler->clear();
$yaml = ag_set($yaml, 'emby.0', [
'legacy' => false,
'map' => [
'from' => 'imthedb',
'to' => 'guid_imdb',
]
]);
file_put_contents($tmpFile, Yaml::dump($yaml));
$class = $this->getClass();
$class->parseGUIDFile($tmpFile);
$this->assertArrayHasKey(
'imthedb',
ag($class->getConfig(), 'guidMapper', []),
'Assert that the GUID mapping has been added.'
);
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
}
public function test_isLocal()
{
$this->assertFalse(
$this->getClass()->isLocal('test://123456/1/1'),
'Should always return false, as emby does not have local GUIDs.'
);
}
public function test_has()
{
$context = [
'item' => [
'id' => 123,
'type' => EmbyClient::TYPE_EPISODE,
'title' => 'Test title',
'year' => 2021
]
];
$this->assertTrue($this->getClass()->has([
'imdb' => '123456',
'tvdb' => '123456',
], $context), 'Assert that the GUID exists.');
$this->assertFalse($this->getClass()->has([
['none' => '123456'],
['imdb' => ''],
], $context), 'Assert that the GUID does not exist.');
}
public function test_parse()
{
$context = [
'item' => [
'id' => 123,
'type' => 'episode',
'title' => 'Test title',
'year' => 2021,
],
];
$this->assertEquals([
Guid::GUID_IMDB => '123456',
Guid::GUID_TMDB => '123456',
Guid::GUID_ANIDB => '123456',
],
$this->getClass()->parse([
'imdb' => '123456',
'tmdb' => '123456',
'anidb' => '123456',
], $context),
'Assert that the GUID exists.'
);
$this->assertEquals([], $this->getClass()->parse([
'' => '',
'none' => '123456',
'imdb' => ''
], $context), 'Assert that the GUID does not exist. for invalid GUIDs.');
}
public function test_get()
{
$context = ['item' => ['id' => 123, 'type' => 'episode', 'title' => 'Test title', 'year' => 2021]];
$this->assertEquals([], $this->getClass()->get([
['imdb' => ''],
], $context), 'Assert invalid guid return empty array.');
$this->assertEquals([Guid::GUID_IMDB => '1', Guid::GUID_CMDB => 'afa', Guid::GUID_TVDB => '123'],
$this->getClass()->get([
'imdb' => '1',
'cmdb' => 'afa',
'tvdb' => '123',
'none' => '123',
], $context),
'Assert only the the oldest ID is returned for numeric GUIDs.'
);
}
public function test_get_ignore()
{
$context = [
'item' => [
'id' => 123,
'type' => EmbyClient::TYPE_SHOW,
'title' => 'Test title',
'year' => 2021
]
];
Config::save('ignore', [(string)makeIgnoreId('show://imdb:123@test_emby') => 1]);
$this->assertEquals([],
$this->getClass()->get(['imdb' => '123'], $context),
'Assert only the the oldest ID is returned for numeric GUIDs.');
$this->assertTrue(
$this->logged(Level::Debug, 'EmbyGuid: Ignoring', true),
'Assert that a log is raised when the GUID is ignored by user choice.'
);
}
}

View File

@@ -0,0 +1,400 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
declare(strict_types=1);
namespace Tests\Backends\Jellyfin;
use App\Backends\Common\Cache;
use App\Backends\Common\Context;
use App\Backends\Jellyfin\JellyfinClient;
use App\Backends\Jellyfin\JellyfinGuid;
use App\Libs\Config;
use App\Libs\Exceptions\Backends\InvalidArgumentException;
use App\Libs\Extends\LogMessageProcessor;
use App\Libs\Guid;
use App\Libs\TestCase;
use App\Libs\Uri;
use Monolog\Handler\TestHandler;
use Monolog\Level;
use Monolog\Logger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Psr16Cache;
use Symfony\Component\Yaml\Yaml;
class JellyfinGuidTest extends TestCase
{
protected Logger|null $logger = null;
private function logged(Level $level, string $message, bool $clear = false): bool
{
try {
foreach ($this->handler->getRecords() as $record) {
if ($level !== $record->level) {
continue;
}
if (null !== $record->formatted && true === str_contains($record->formatted, $message)) {
return true;
}
if (true === str_contains($record->message, $message)) {
return true;
}
}
return false;
} finally {
if (true === $clear) {
$this->handler->clear();
}
}
}
private function getClass(): JellyfinGuid
{
$this->handler->clear();
return (new JellyfinGuid($this->logger))->withContext(
new Context(
clientName: JellyfinClient::CLIENT_NAME,
backendName: 'test_jellyfin',
backendUrl: new Uri('http://127.0.0.1:8096'),
cache: new Cache($this->logger, new Psr16Cache(new ArrayAdapter())),
logger: $this->logger,
backendId: 's000000000000000000000000000000j',
backendToken: 't000000000000000000000000000000j',
backendUser: 'u000000000000000000000000000000j',
)
);
}
protected function setUp(): void
{
parent::setUp();
$this->handler = new TestHandler();
$this->logger = new Logger('logger', processors: [new LogMessageProcessor()]);
$this->logger->pushHandler($this->handler);
Guid::setLogger($this->logger);
}
public function test__construct()
{
$oldGuidFile = Config::get('guid.file');
$tmpFile = tempnam(sys_get_temp_dir(), 'guid');
try {
file_put_contents($tmpFile, "{'foo' => 'too' }");
Config::save('guid.file', $tmpFile);
$this->getClass();
$this->assertTrue(
$this->logged(Level::Error, 'Failed to parse GUIDs file', true),
"Assert message logged when the value type does not match the expected type."
);
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
Config::save('guid.file', $oldGuidFile);
}
}
public function test_parseGUIDFile()
{
Config::save('guid.file', null);
$tmpFile = tempnam(sys_get_temp_dir(), 'guid');
try {
$this->checkException(
closure: function () use ($tmpFile) {
file_put_contents($tmpFile, 'version: 2.0');
$this->getClass()->parseGUIDFile($tmpFile);
},
reason: "Failed to throw exception when the GUID file version is not supported.",
exception: InvalidArgumentException::class,
exceptionMessage: 'Unsupported file version'
);
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
$this->checkException(
closure: fn() => $this->getClass()->parseGUIDFile('not_set.yml'),
reason: "Failed to assert that the GUID file is not found.",
exception: InvalidArgumentException::class,
exceptionMessage: 'does not exist'
);
$tmpFile = tempnam(sys_get_temp_dir(), 'guid');
try {
$this->checkException(
closure: function () use ($tmpFile) {
file_put_contents($tmpFile, 'fff: {_]');
$this->getClass()->parseGUIDFile($tmpFile);
},
reason: "Failed to throw exception when the GUID file is invalid.",
exception: InvalidArgumentException::class,
exceptionMessage: 'Failed to parse GUIDs file'
);
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
$tmpFile = tempnam(sys_get_temp_dir(), 'guid');
try {
$this->checkException(
closure: function () use ($tmpFile) {
file_put_contents($tmpFile, 'invalid');
$this->getClass()->parseGUIDFile($tmpFile);
},
reason: "Failed to throw exception when the GUID file is invalid.",
exception: InvalidArgumentException::class,
exceptionMessage: 'is not an array'
);
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
$tmpFile = tempnam(sys_get_temp_dir(), 'guid');
try {
$this->getClass()->parseGUIDFile($tmpFile);
$this->assertTrue(
$this->logged(Level::Info, 'is empty', true),
"Failed to assert that the GUID file is empty."
);
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
$tmpFile = tempnam(sys_get_temp_dir(), 'guid');
try {
$this->checkException(
closure: function () use ($tmpFile) {
file_put_contents($tmpFile, Yaml::dump(['jellyfin' => 'foo']));
$this->getClass()->parseGUIDFile($tmpFile);
},
reason: "Should throw an exception when there are no GUIDs mapping.",
exception: InvalidArgumentException::class,
exceptionMessage: 'jellyfin sub key is not an array'
);
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
$tmpFile = tempnam(sys_get_temp_dir(), 'guid');
try {
$this->handler->clear();
$yaml = ['jellyfin' => []];
file_put_contents($tmpFile, Yaml::dump($yaml));
$this->assertCount(0, $this->handler->getRecords(), "There should be no messages logged for empty list.");
$this->handler->clear();
file_put_contents($tmpFile, Yaml::dump(ag_set($yaml, 'jellyfin.0', 'ff')));
$this->getClass()->parseGUIDFile($tmpFile);
$this->assertTrue(
$this->logged(Level::Warning, 'Value must be an object.', true),
'Assert replace key is an object.'
);
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
$this->handler->clear();
$tmpFile = tempnam(sys_get_temp_dir(), 'guid');
try {
$yaml = ag_set(['jellyfin' => []], 'jellyfin.0.map', 'foo');
file_put_contents($tmpFile, Yaml::dump($yaml));
$this->getClass()->parseGUIDFile($tmpFile);
$this->assertTrue(
$this->logged(Level::Warning, 'map value must be an object.', true),
'Assert replace key is an object.'
);
$yaml = ag_set($yaml, 'jellyfin.0', ['map' => []]);
file_put_contents($tmpFile, Yaml::dump($yaml));
$this->getClass()->parseGUIDFile($tmpFile);
$this->assertTrue(
$this->logged(Level::Warning, 'map.from field is empty or not a string.', true),
'Assert to field is a string.'
);
$yaml = ag_set($yaml, 'jellyfin.0.map.from', 'foo');
file_put_contents($tmpFile, Yaml::dump($yaml));
$this->getClass()->parseGUIDFile($tmpFile);
$this->assertTrue(
$this->logged(Level::Warning, 'map.to field is empty or not a string.', true),
'Assert to field is a string.'
);
$yaml = ag_set($yaml, 'jellyfin.0.map.to', 'foobar');
file_put_contents($tmpFile, Yaml::dump($yaml));
$this->getClass()->parseGUIDFile($tmpFile);
$this->assertTrue(
$this->logged(Level::Warning, 'field does not starts with', true),
'Assert to field is a string.'
);
$yaml = ag_set($yaml, 'jellyfin.0.map.to', 'guid_foobar');
file_put_contents($tmpFile, Yaml::dump($yaml));
$this->getClass()->parseGUIDFile($tmpFile);
$this->assertTrue(
$this->logged(Level::Warning, 'map.to field is not a supported', true),
'Assert to field is a string.'
);
$yaml = ag_set($yaml, 'jellyfin.0.map', [
'from' => 'tsdb',
'to' => Guid::GUID_IMDB,
]);
$this->handler->clear();
file_put_contents($tmpFile, Yaml::dump($yaml));
$class = $this->getClass();
$class->parseGUIDFile($tmpFile);
$this->assertArrayHasKey(
'tsdb',
ag($class->getConfig(), 'guidMapper', []),
'Assert that the GUID mapping has been added.'
);
$this->handler->clear();
$yaml = ag_set($yaml, 'jellyfin.0', [
'legacy' => false,
'map' => [
'from' => 'imthedb',
'to' => 'guid_imdb',
]
]);
file_put_contents($tmpFile, Yaml::dump($yaml));
$class = $this->getClass();
$class->parseGUIDFile($tmpFile);
$this->assertArrayHasKey(
'imthedb',
ag($class->getConfig(), 'guidMapper', []),
'Assert that the GUID mapping has been added.'
);
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
}
public function test_isLocal()
{
$this->assertFalse(
$this->getClass()->isLocal('test://123456/1/1'),
'Should always return false, as Jellyfin does not have local GUIDs.'
);
}
public function test_has()
{
$context = [
'item' => [
'id' => 123,
'type' => JellyfinClient::TYPE_EPISODE,
'title' => 'Test title',
'year' => 2021
]
];
$this->assertTrue($this->getClass()->has([
'imdb' => '123456',
'tvdb' => '123456',
], $context), 'Assert that the GUID exists.');
$this->assertFalse($this->getClass()->has([
['none' => '123456'],
['imdb' => ''],
], $context), 'Assert that the GUID does not exist.');
}
public function test_parse()
{
$context = [
'item' => [
'id' => 123,
'type' => 'episode',
'title' => 'Test title',
'year' => 2021,
],
];
$this->assertEquals([
Guid::GUID_IMDB => '123456',
Guid::GUID_TMDB => '123456',
Guid::GUID_ANIDB => '123456',
],
$this->getClass()->parse([
'imdb' => '123456',
'tmdb' => '123456',
'anidb' => '123456',
], $context),
'Assert that the GUID exists.'
);
$this->assertEquals([], $this->getClass()->parse([
'' => '',
'none' => '123456',
'imdb' => ''
], $context), 'Assert that the GUID does not exist. for invalid GUIDs.');
}
public function test_get()
{
$context = ['item' => ['id' => 123, 'type' => 'episode', 'title' => 'Test title', 'year' => 2021]];
$this->assertEquals([], $this->getClass()->get([
['imdb' => ''],
], $context), 'Assert invalid guid return empty array.');
$this->assertEquals([Guid::GUID_IMDB => '1', Guid::GUID_CMDB => 'afa', Guid::GUID_TVDB => '123'],
$this->getClass()->get([
'imdb' => '1',
'cmdb' => 'afa',
'tvdb' => '123',
'none' => '123',
], $context),
'Assert only the the oldest ID is returned for numeric GUIDs.'
);
}
public function test_get_ignore()
{
$context = [
'item' => [
'id' => 123,
'type' => JellyfinClient::TYPE_SHOW,
'title' => 'Test title',
'year' => 2021
]
];
Config::save('ignore', [(string)makeIgnoreId('show://imdb:123@test_jellyfin') => 1]);
$this->assertEquals([],
$this->getClass()->get(['imdb' => '123'], $context),
'Assert only the the oldest ID is returned for numeric GUIDs.');
$this->assertTrue(
$this->logged(Level::Debug, 'JellyfinGuid: Ignoring', true),
'Assert that a log is raised when the GUID is ignored by user choice.'
);
}
}

View File

@@ -5,15 +5,21 @@ declare(strict_types=1);
namespace Tests\Backends\Plex;
use App\Backends\Common\Cache;
use App\Backends\Common\Context;
use App\Backends\Plex\PlexClient;
use App\Backends\Plex\PlexGuid;
use App\Libs\Config;
use App\Libs\Exceptions\Backends\InvalidArgumentException;
use App\Libs\Extends\LogMessageProcessor;
use App\Libs\Guid;
use App\Libs\TestCase;
use App\Libs\Uri;
use Monolog\Handler\TestHandler;
use Monolog\Level;
use Monolog\Logger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Psr16Cache;
use Symfony\Component\Yaml\Yaml;
class PlexGuidTest extends TestCase
@@ -47,7 +53,19 @@ class PlexGuidTest extends TestCase
private function getClass(): PlexGuid
{
return new PlexGuid($this->logger);
$this->handler->clear();
return (new PlexGuid($this->logger))->withContext(
new Context(
clientName: PlexClient::CLIENT_NAME,
backendName: 'test_plex',
backendUrl: new Uri('http://127.0.0.1:34000'),
cache: new Cache($this->logger, new Psr16Cache(new ArrayAdapter())),
logger: $this->logger,
backendId: 's00000000000000000000000000000000000000p',
backendToken: 't000000000000000000p',
backendUser: '11111111',
)
);
}
protected function setUp(): void
@@ -319,4 +337,110 @@ class PlexGuidTest extends TestCase
}
}
public function test_isLocal()
{
$this->assertTrue(
$this->getClass()->isLocal('com.plexapp.agents.none://123456/1/1'),
'Assert that the GUID is local.'
);
$this->assertFalse(
$this->getClass()->isLocal('com.plexapp.agents.imdb://123456/1/1'),
'Assert that the GUID is not local.'
);
}
public function test_has()
{
$context = ['item' => ['id' => 123, 'type' => 'episode', 'title' => 'Test title', 'year' => 2021]];
$this->assertTrue($this->getClass()->has([
['id' => 'com.plexapp.agents.imdb://123456'],
['id' => 'com.plexapp.agents.tvdb://123456'],
], $context), 'Assert that the GUID exists.');
$this->assertFalse($this->getClass()->has([
['id' => ''],
['id' => 'com.plexapp.agents.none://123456'],
['id' => 'com.plexapp.agents.imdb'],
], $context), 'Assert that the GUID does not exist.');
}
public function test_parse()
{
$context = [
'item' => [
'id' => 123,
'type' => 'episode',
'title' => 'Test title',
'year' => 2021,
],
];
$this->assertEquals([
Guid::GUID_IMDB => '123456',
Guid::GUID_TMDB => '123456',
Guid::GUID_ANIDB => '123456',
],
$this->getClass()->parse([
['id' => 'com.plexapp.agents.imdb://123456'],
['id' => 'com.plexapp.agents.tmdb://123456'],
['id' => 'com.plexapp.agents.hama://anidb-123456'],
], $context),
'Assert that the GUID exists.');
$this->assertEquals([], $this->getClass()->parse([
['id' => ''],
['id' => 'com.plexapp.agents.none://123456'],
['id' => 'com.plexapp.agents.imdb'],
], $context), 'Assert that the GUID does not exist. for invalid GUIDs.');
}
public function test_get()
{
$context = ['item' => ['id' => 123, 'type' => 'episode', 'title' => 'Test title', 'year' => 2021]];
$this->assertEquals([], $this->getClass()->get([
['id' => 'com.plexapp.agents.imdb'],
], $context), 'Assert invalid guid return empty array.');
$this->assertTrue(
$this->logged(Level::Info, 'Unable to parse', true),
'Assert that the invalid GUID is logged.'
);
$this->assertEquals([Guid::GUID_IMDB => '1', Guid::GUID_CMDB => 'afa', Guid::GUID_TVDB => '123'],
$this->getClass()->get([
['id' => 'com.plexapp.agents.imdb://2'],
['id' => 'com.plexapp.agents.imdb://1'],
['id' => 'com.plexapp.agents.cmdb://afa'],
['id' => 'com.plexapp.agents.cmdb://faf'],
['id' => 'com.plexapp.agents.hama://tvdb-123'],
['id' => 'com.plexapp.agents.hama://notSet-123'],
['id' => 'com.plexapp.agents.hama://notSet-'],
], $context),
'Assert only the the oldest ID is returned for numeric GUIDs.'
);
$this->assertTrue(
$this->logged(Level::Warning, 'reported multiple ids', true),
'Assert that a log is raised when multiple GUIDs for the same provider are found.'
);
$this->assertEquals([Guid::GUID_IMDB => '1'], $this->getClass()->get([
['id' => 'com.plexapp.agents.imdb://1'],
['id' => 'com.plexapp.agents.imdb://2'],
], $context), 'Assert only the the oldest ID is returned for numeric GUIDs.');
Config::save('ignore', [(string)makeIgnoreId('show://imdb:123@test_plex') => 1]);
$this->assertEquals([],
$this->getClass()->get([
['id' => 'com.plexapp.agents.imdb://123'],
], ag_set($context, 'item.type', 'show')),
'Assert only the the oldest ID is returned for numeric GUIDs.');
$this->assertTrue(
$this->logged(Level::Debug, 'PlexGuid: Ignoring', true),
'Assert that a log is raised when the GUID is ignored by user choice.'
);
}
}