diff --git a/src/Libs/Entity/StateEntity.php b/src/Libs/Entity/StateEntity.php index 37ae4db9..97bd7a7f 100644 --- a/src/Libs/Entity/StateEntity.php +++ b/src/Libs/Entity/StateEntity.php @@ -654,6 +654,46 @@ final class StateEntity implements iState return ag($this->context, $key, $default); } + /** + * @inheritdoc + */ + public function getMeta(string $key, mixed $default = null): mixed + { + if (empty($this->via)) { + $this->logger?->warning('StateEntity: No backend was set in $this->via parameter.'); + return $default; + } + + $values = []; + $total = count($this->metadata); + $quorum = round($total / 2, 0, PHP_ROUND_HALF_UP); + + if ($quorum < 2) { + $this->logger?->warning("StateEntity: Quorum is less than 2. '{quorum}' Using default value.", [ + 'quorum' => $quorum + ]); + return ag($this->metadata[$this->via], $key, $default); + } + + foreach ($this->metadata as $data) { + if (null === ($value = ag($data, $key, null))) { + continue; + } + + $values[$value] = isset($values[$value]) ? $values[$value] + 1 : 1; + } + + foreach ($values as $value => $count) { + if ($count >= $quorum) { + $this->logger?->info('StateEntity: quorum found. Using value from {value}.', ['value' => $value]); + return $value; + } + } + + $this->logger?->warning('StateEntity: no quorum found. Using default value.'); + return ag($this->metadata[$this->via], $key, $default); + } + /** * @inheritdoc */ diff --git a/src/Libs/Entity/StateInterface.php b/src/Libs/Entity/StateInterface.php index ead77d24..993cfa56 100644 --- a/src/Libs/Entity/StateInterface.php +++ b/src/Libs/Entity/StateInterface.php @@ -402,6 +402,18 @@ interface StateInterface extends LoggerAwareInterface */ public function getContext(string|null $key = null, mixed $default = null): mixed; + /** + * Get the metadata that is likely to be correct based on the quorum. + * To constitute a quorum, 2/3 of the backends must have the same metadata, otherwise fallback to + * {@see ag($this->getMetadata($this->via), $key, $default)} + * + * @param string $key key + * @param mixed|null $default default value. + * + * @return mixed + */ + public function getMeta(string $key, mixed $default = null): mixed; + /** * Check if entity has contextual data. * diff --git a/src/Libs/Traits/APITraits.php b/src/Libs/Traits/APITraits.php index 631b6cd4..315b5317 100644 --- a/src/Libs/Traits/APITraits.php +++ b/src/Libs/Traits/APITraits.php @@ -192,11 +192,7 @@ trait APITraits $item[iState::COLUMN_META_DATA_PROGRESS] = $entity->hasPlayProgress() ? $entity->getPlayProgress() : null; $item[iState::COLUMN_EXTRA_EVENT] = ag($entity->getExtra($entity->via), iState::COLUMN_EXTRA_EVENT, null); - $item['content_title'] = $entity->isEpisode() ? ag( - $entity->getMetadata($entity->via), - iState::COLUMN_EXTRA . '.' . iState::COLUMN_TITLE, - null - ) : null; + $item['content_title'] = $entity->getMeta(iState::COLUMN_EXTRA . '.' . iState::COLUMN_TITLE, null); $item['content_path'] = ag($entity->getMetadata($entity->via), iState::COLUMN_META_PATH); $item['rguids'] = []; diff --git a/tests/Fixtures/EpisodeEntity.php b/tests/Fixtures/EpisodeEntity.php index 697ef7a2..acc23bf2 100644 --- a/tests/Fixtures/EpisodeEntity.php +++ b/tests/Fixtures/EpisodeEntity.php @@ -43,12 +43,50 @@ return [ iState::COLUMN_META_DATA_ADDED_AT => 1, iState::COLUMN_META_DATA_PLAYED_AT => 2, ], + 'home_jellyfin' => [ + iState::COLUMN_ID => 122, + iState::COLUMN_TYPE => iState::TYPE_EPISODE, + iState::COLUMN_WATCHED => 1, + iState::COLUMN_TITLE => 'Series Title', + iState::COLUMN_YEAR => '2020', + iState::COLUMN_SEASON => '1', + iState::COLUMN_EPISODE => '2', + iState::COLUMN_META_DATA_EXTRA => [ + iState::COLUMN_META_DATA_EXTRA_DATE => '2020-01-03', + iState::COLUMN_META_DATA_EXTRA_TITLE => 'to test quorum', + ], + iState::COLUMN_META_DATA_ADDED_AT => 1, + iState::COLUMN_META_DATA_PLAYED_AT => 2, + ], + 'home_emby' => [ + iState::COLUMN_ID => 122, + iState::COLUMN_TYPE => iState::TYPE_EPISODE, + iState::COLUMN_WATCHED => 1, + iState::COLUMN_TITLE => 'Series Title', + iState::COLUMN_YEAR => '2020', + iState::COLUMN_SEASON => '1', + iState::COLUMN_EPISODE => '2', + iState::COLUMN_META_DATA_EXTRA => [ + iState::COLUMN_META_DATA_EXTRA_DATE => '2020-01-03', + iState::COLUMN_META_DATA_EXTRA_TITLE => 'to test quorum', + ], + iState::COLUMN_META_DATA_ADDED_AT => 1, + iState::COLUMN_META_DATA_PLAYED_AT => 2, + ], ], iState::COLUMN_EXTRA => [ 'home_plex' => [ iState::COLUMN_EXTRA_DATE => 1, iState::COLUMN_EXTRA_EVENT => 'media.scrobble' ], + 'home_jellyfin' => [ + iState::COLUMN_EXTRA_DATE => 1, + iState::COLUMN_EXTRA_EVENT => 'media.scrobble' + ], + 'home_emby' => [ + iState::COLUMN_EXTRA_DATE => 1, + iState::COLUMN_EXTRA_EVENT => 'media.scrobble' + ], ], iState::COLUMN_CREATED_AT => 2, iState::COLUMN_UPDATED_AT => 2, diff --git a/tests/Libs/StateEntityTest.php b/tests/Libs/StateEntityTest.php index 57101f09..7a21518c 100644 --- a/tests/Libs/StateEntityTest.php +++ b/tests/Libs/StateEntityTest.php @@ -6,7 +6,10 @@ namespace Tests\Libs; use App\Libs\Entity\StateEntity; use App\Libs\Entity\StateInterface as iState; +use App\Libs\Extends\LogMessageProcessor; use App\Libs\TestCase; +use Monolog\Handler\TestHandler; +use Monolog\Logger; use RuntimeException; class StateEntityTest extends TestCase @@ -14,10 +17,16 @@ class StateEntityTest extends TestCase private array $testMovie = []; private array $testEpisode = []; + private TestHandler|null $lHandler = null; + private Logger|null $logger = null; + protected function setUp(): void { $this->testMovie = require __DIR__ . '/../Fixtures/MovieEntity.php'; $this->testEpisode = require __DIR__ . '/../Fixtures/EpisodeEntity.php'; + $this->lHandler = new TestHandler(); + $this->logger = new Logger('logger', processors: [new LogMessageProcessor()]); + $this->logger->pushHandler($this->lHandler); } public function test_init_bad_type(): void @@ -893,4 +902,62 @@ class StateEntityTest extends TestCase 'When hasContext() is called with non-existing key, it returns false' ); } + + public function test_getMeta(): void + { + $real = $this->testEpisode; + $entity = new StateEntity($real); + $entity->via = ''; + $this->assertSame( + '__not_set', + $entity->getMeta('extra.title', '__not_set'), + 'When no via is set, returns the default value' + ); + + $real = $this->testEpisode; + + unset($real['metadata']['home_jellyfin']); + $entity = new StateEntity($real); + + $this->assertSame( + ag($real, 'metadata.home_plex.extra.title'), + $entity->getMeta('extra.title'), + 'When quorum is not met returns the entity via backend metadata.' + ); + + $entity->via = 'home_emby'; + $this->assertNotSame( + ag($real, 'metadata.home_plex.extra.title'), + $entity->getMeta('extra.title'), + 'When quorum is not met returns the entity via backend metadata.' + ); + + $entity = new StateEntity($this->testEpisode); + + $this->assertSame( + ag($this->testEpisode, 'metadata.home_jellyfin.extra.title'), + $entity->getMeta('extra.title'), + 'When quorum is met for key return that value instead of the default via metadata.' + ); + + $entity = new StateEntity( + ag_set($this->testEpisode, 'metadata.home_jellyfin.extra.title', 'random') + ); + + $this->assertSame( + ag($real, 'metadata.home_plex.extra.title'), + $entity->getMeta('extra.title'), + 'When no quorum for value reached, return default via metadata.' + ); + + $entity = new StateEntity( + ag_set($this->testEpisode, 'metadata.home_jellyfin.extra.title', null) + ); + + $this->assertSame( + ag($real, 'metadata.home_plex.extra.title'), + $entity->getMeta('extra.title'), + 'Quorum will not be met if one of the values is null.' + ); + } }