From b9b943d01ca00c1b6d07db77afdb36a885f61371 Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A" Date: Sat, 7 May 2022 18:10:21 +0300 Subject: [PATCH 01/13] New database schema. --- migrations/sqlite_1651934866_move_fields.sql | 122 +++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 migrations/sqlite_1651934866_move_fields.sql diff --git a/migrations/sqlite_1651934866_move_fields.sql b/migrations/sqlite_1651934866_move_fields.sql new file mode 100644 index 00000000..06c73240 --- /dev/null +++ b/migrations/sqlite_1651934866_move_fields.sql @@ -0,0 +1,122 @@ +-- # migrate_up +ALTER TABLE "state" + RENAME TO "old_state"; + +CREATE TABLE "state" +( + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, + "type" text NOT NULL, + "updated" integer NOT NULL, + "watched" integer NOT NULL DEFAULT '0', + "via" text NULL, + "title" text NULL, + "year" integer NULL, + "season" integer NULL, + "episode" integer NULL, + "parent" text NULL, + "guids" text NULL, + "meta" text NULL, + "guid_plex" text NULL, + "guid_imdb" text NULL, + "guid_tvdb" text NULL, + "guid_tmdb" text NULL, + "guid_tvmaze" text NULL, + "guid_tvrage" text NULL, + "guid_anidb" text NULL +); + +INSERT INTO "state" ("id", "type", "updated", "watched", "meta", "guid_plex", "guid_imdb", "guid_tvdb", + "guid_tmdb", "guid_tvmaze", "guid_tvrage", "guid_anidb") +SELECT "id", + "type", + "updated", + "watched", + "meta", + "guid_plex", + "guid_imdb", + "guid_tvdb", + "guid_tmdb", + "guid_tvmaze", + "guid_tvrage", + "guid_anidb" +FROM "old_state"; + +UPDATE sqlite_sequence +SET "seq" = (SELECT MAX("id") FROM "state") +WHERE "name" = 'state'; + +DROP TABLE "old_state"; + +CREATE INDEX "state_type" ON "state" ("type"); +CREATE INDEX "state_updated" ON "state" ("updated"); +CREATE INDEX "state_watched" ON "state" ("watched"); +CREATE INDEX "state_via" ON "state" ("via"); +CREATE INDEX "state_title" ON "state" ("title"); +CREATE INDEX "state_year" ON "state" ("year"); +CREATE INDEX "state_season" ON "state" ("season"); +CREATE INDEX "state_episode" ON "state" ("episode"); +CREATE INDEX "state_parent" ON "state" ("parent"); +CREATE INDEX "state_guids" ON "state" ("guids"); +CREATE INDEX "state_meta" ON "state" ("meta"); +CREATE INDEX "state_guid_plex" ON "state" ("guid_plex"); +CREATE INDEX "state_guid_imdb" ON "state" ("guid_imdb"); +CREATE INDEX "state_guid_tvdb" ON "state" ("guid_tvdb"); +CREATE INDEX "state_guid_tvmaze" ON "state" ("guid_tvmaze"); +CREATE INDEX "state_guid_tvrage" ON "state" ("guid_tvrage"); +CREATE INDEX "state_guid_anidb" ON "state" ("guid_anidb"); + +-- # migrate_down + +ALTER TABLE "state" + RENAME TO "old_state"; + +CREATE TABLE "state" +( + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, + "type" text NOT NULL, + "updated" integer NOT NULL, + "watched" integer NOT NULL DEFAULT '0', + "meta" text NULL, + "guid_plex" text NULL, + "guid_imdb" text NULL, + "guid_tvdb" text NULL, + "guid_tmdb" text NULL, + "guid_tvmaze" text NULL, + "guid_tvrage" text NULL, + "guid_anidb" text NULL +); + +INSERT INTO "state" ("id", "type", "updated", "watched", "meta", "guid_plex", "guid_imdb", "guid_tvdb", + "guid_tmdb", "guid_tvmaze", "guid_tvrage", "guid_anidb") +SELECT "id", + "type", + "updated", + "watched", + "meta", + "guid_plex", + "guid_imdb", + "guid_tvdb", + "guid_tmdb", + "guid_tvmaze", + "guid_tvrage", + "guid_anidb" +FROM "old_state"; + +UPDATE sqlite_sequence +SET "seq" = (SELECT MAX("id") FROM "state") +WHERE "name" = 'state'; + +DROP TABLE "old_state"; + +CREATE INDEX "state_type" ON "state" ("type"); +CREATE INDEX "state_updated" ON "state" ("updated"); +CREATE INDEX "state_watched" ON "state" ("watched"); +CREATE INDEX "state_meta" ON "state" ("meta"); +CREATE INDEX "state_guid_plex" ON "state" ("guid_plex"); +CREATE INDEX "state_guid_imdb" ON "state" ("guid_imdb"); +CREATE INDEX "state_guid_tvdb" ON "state" ("guid_tvdb"); +CREATE INDEX "state_guid_tvmaze" ON "state" ("guid_tvmaze"); +CREATE INDEX "state_guid_tvrage" ON "state" ("guid_tvrage"); +CREATE INDEX "state_guid_anidb" ON "state" ("guid_anidb"); + +-- put your downgrade database commands here. From cda94a87594f1edb5f3e98e07162c8c204e3ab8b Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A" Date: Sun, 8 May 2022 04:40:09 +0300 Subject: [PATCH 02/13] Initial code changes to support new db redesign. --- README.md | 12 ++ config/config.php | 4 +- config/services.php | 11 - .../sqlite_1644418046_create_state_table.sql | 47 ++-- migrations/sqlite_1651934866_move_fields.sql | 122 ----------- src/Commands/Database/ListCommand.php | 76 ++++--- src/Commands/State/ImportCommand.php | 14 +- src/Libs/Entity/StateEntity.php | 201 +++++++++--------- src/Libs/Entity/StateInterface.php | 33 ++- src/Libs/Mappers/Export/ExportMapper.php | 51 +---- src/Libs/Mappers/ExportInterface.php | 9 - src/Libs/Mappers/Import/DirectMapper.php | 177 --------------- src/Libs/Mappers/Import/MemoryMapper.php | 100 +++------ src/Libs/Servers/EmbyServer.php | 2 +- src/Libs/Servers/JellyfinServer.php | 111 ++++------ src/Libs/Servers/PlexServer.php | 114 ++++------ src/Libs/Storage/PDO/PDOAdapter.php | 141 ++++++------ src/Libs/helpers.php | 2 +- tests/Fixtures/EpisodeEntity.php | 38 ++-- tests/Fixtures/MovieEntity.php | 28 ++- tests/Mappers/Import/DirectMapperTest.php | 163 -------------- tests/Mappers/Import/MemoryMapperTest.php | 21 +- tests/Storage/PDOAdapterTest.php | 14 +- 23 files changed, 468 insertions(+), 1023 deletions(-) delete mode 100644 migrations/sqlite_1651934866_move_fields.sql delete mode 100644 src/Libs/Mappers/Import/DirectMapper.php delete mode 100644 tests/Mappers/Import/DirectMapperTest.php diff --git a/README.md b/README.md index ba5dafff..2a3a8d1e 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,18 @@ WatchState is a CLI based tool to sync your watch state between different media services, like trakt.tv, This tool support `Plex Media Server`, `Emby` and `Jellyfin` out of the box currently, with plans for future expansion for other media servers. +# Breaking Change + +If you are using old version of the tool i.e. before (2022-05-08) you need to run manual import to populate the new +database. We had massive code/db changes. The new database name should be `watchstate_v0.db`, if you don't have a +database named `watchstate.db` then there is nothing to do. + +to manually import the run this command with --force-full flag to get all previous records. + +```bash +$ docker exec -ti watchstate console state:import --force-full -vvrm +``` + # Install create your `docker-compose.yaml` file: diff --git a/config/config.php b/config/config.php index 5b816fb6..79ff8385 100644 --- a/config/config.php +++ b/config/config.php @@ -28,10 +28,10 @@ return (function () { ], ]; - $config['tmpDir'] = fixPath(env('WS_TMP_DIR', fn() => ag($config, 'path'))); + $config['tmpDir'] = fixPath(env('WS_TMP_DIR', $config['path'])); $config['storage'] = [ - 'dsn' => 'sqlite:' . ag($config, 'path') . '/db/watchstate.db', + 'dsn' => 'sqlite:' . ag($config, 'path') . '/db/watchstate_v0.db', 'username' => null, 'password' => null, 'options' => [ diff --git a/config/services.php b/config/services.php index e86a1f4f..2ed1b1a4 100644 --- a/config/services.php +++ b/config/services.php @@ -7,7 +7,6 @@ use App\Libs\Entity\StateEntity; use App\Libs\Entity\StateInterface; use App\Libs\Mappers\Export\ExportMapper; use App\Libs\Mappers\ExportInterface; -use App\Libs\Mappers\Import\DirectMapper; use App\Libs\Mappers\Import\MemoryMapper; use App\Libs\Mappers\ImportInterface; use App\Libs\Storage\PDO\PDOAdapter; @@ -89,16 +88,6 @@ return (function (): array { ], ], - DirectMapper::class => [ - 'class' => function (LoggerInterface $logger, StorageInterface $storage): ImportInterface { - return (new DirectMapper($logger, $storage))->setUp(Config::get('mapper.import.opts', [])); - }, - 'args' => [ - LoggerInterface::class, - StorageInterface::class, - ], - ], - ImportInterface::class => [ 'class' => function (ImportInterface $mapper): ImportInterface { return $mapper; diff --git a/migrations/sqlite_1644418046_create_state_table.sql b/migrations/sqlite_1644418046_create_state_table.sql index af600f27..cbcb4271 100644 --- a/migrations/sqlite_1644418046_create_state_table.sql +++ b/migrations/sqlite_1644418046_create_state_table.sql @@ -1,31 +1,32 @@ -- # migrate_up -CREATE TABLE IF NOT EXISTS "state" +CREATE TABLE "state" ( - "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, - "type" text NOT NULL, - "updated" integer NOT NULL, - "watched" integer NOT NULL DEFAULT 0, - "meta" text NULL, - "guid_plex" text NULL, - "guid_imdb" text NULL, - "guid_tvdb" text NULL, - "guid_tmdb" text NULL, - "guid_tvmaze" text NULL, - "guid_tvrage" text NULL, - "guid_anidb" text NULL + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, + "type" text NOT NULL, + "updated" integer NOT NULL, + "watched" integer NOT NULL DEFAULT '0', + "via" text NOT NULL, + "title" text NOT NULL, + "year" integer NULL, + "season" integer NULL, + "episode" integer NULL, + "parent" text NULL, + "guids" text NULL, + "extra" text NULL ); -CREATE INDEX IF NOT EXISTS "state_type" ON "state" ("type"); -CREATE INDEX IF NOT EXISTS "state_watched" ON "state" ("watched"); -CREATE INDEX IF NOT EXISTS "state_updated" ON "state" ("updated"); -CREATE INDEX IF NOT EXISTS "state_meta" ON "state" ("meta"); -CREATE INDEX IF NOT EXISTS "state_guid_plex" ON "state" ("guid_plex"); -CREATE INDEX IF NOT EXISTS "state_guid_imdb" ON "state" ("guid_imdb"); -CREATE INDEX IF NOT EXISTS "state_guid_tvdb" ON "state" ("guid_tvdb"); -CREATE INDEX IF NOT EXISTS "state_guid_tvmaze" ON "state" ("guid_tvmaze"); -CREATE INDEX IF NOT EXISTS "state_guid_tvrage" ON "state" ("guid_tvrage"); -CREATE INDEX IF NOT EXISTS "state_guid_anidb" ON "state" ("guid_anidb"); +CREATE INDEX "state_type" ON "state" ("type"); +CREATE INDEX "state_updated" ON "state" ("updated"); +CREATE INDEX "state_watched" ON "state" ("watched"); +CREATE INDEX "state_via" ON "state" ("via"); +CREATE INDEX "state_title" ON "state" ("title"); +CREATE INDEX "state_year" ON "state" ("year"); +CREATE INDEX "state_season" ON "state" ("season"); +CREATE INDEX "state_episode" ON "state" ("episode"); +CREATE INDEX "state_parent" ON "state" ("parent"); +CREATE INDEX "state_guids" ON "state" ("guids"); +CREATE INDEX "state_extra" ON "state" ("extra"); -- # migrate_down diff --git a/migrations/sqlite_1651934866_move_fields.sql b/migrations/sqlite_1651934866_move_fields.sql deleted file mode 100644 index 06c73240..00000000 --- a/migrations/sqlite_1651934866_move_fields.sql +++ /dev/null @@ -1,122 +0,0 @@ --- # migrate_up -ALTER TABLE "state" - RENAME TO "old_state"; - -CREATE TABLE "state" -( - "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, - "type" text NOT NULL, - "updated" integer NOT NULL, - "watched" integer NOT NULL DEFAULT '0', - "via" text NULL, - "title" text NULL, - "year" integer NULL, - "season" integer NULL, - "episode" integer NULL, - "parent" text NULL, - "guids" text NULL, - "meta" text NULL, - "guid_plex" text NULL, - "guid_imdb" text NULL, - "guid_tvdb" text NULL, - "guid_tmdb" text NULL, - "guid_tvmaze" text NULL, - "guid_tvrage" text NULL, - "guid_anidb" text NULL -); - -INSERT INTO "state" ("id", "type", "updated", "watched", "meta", "guid_plex", "guid_imdb", "guid_tvdb", - "guid_tmdb", "guid_tvmaze", "guid_tvrage", "guid_anidb") -SELECT "id", - "type", - "updated", - "watched", - "meta", - "guid_plex", - "guid_imdb", - "guid_tvdb", - "guid_tmdb", - "guid_tvmaze", - "guid_tvrage", - "guid_anidb" -FROM "old_state"; - -UPDATE sqlite_sequence -SET "seq" = (SELECT MAX("id") FROM "state") -WHERE "name" = 'state'; - -DROP TABLE "old_state"; - -CREATE INDEX "state_type" ON "state" ("type"); -CREATE INDEX "state_updated" ON "state" ("updated"); -CREATE INDEX "state_watched" ON "state" ("watched"); -CREATE INDEX "state_via" ON "state" ("via"); -CREATE INDEX "state_title" ON "state" ("title"); -CREATE INDEX "state_year" ON "state" ("year"); -CREATE INDEX "state_season" ON "state" ("season"); -CREATE INDEX "state_episode" ON "state" ("episode"); -CREATE INDEX "state_parent" ON "state" ("parent"); -CREATE INDEX "state_guids" ON "state" ("guids"); -CREATE INDEX "state_meta" ON "state" ("meta"); -CREATE INDEX "state_guid_plex" ON "state" ("guid_plex"); -CREATE INDEX "state_guid_imdb" ON "state" ("guid_imdb"); -CREATE INDEX "state_guid_tvdb" ON "state" ("guid_tvdb"); -CREATE INDEX "state_guid_tvmaze" ON "state" ("guid_tvmaze"); -CREATE INDEX "state_guid_tvrage" ON "state" ("guid_tvrage"); -CREATE INDEX "state_guid_anidb" ON "state" ("guid_anidb"); - --- # migrate_down - -ALTER TABLE "state" - RENAME TO "old_state"; - -CREATE TABLE "state" -( - "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, - "type" text NOT NULL, - "updated" integer NOT NULL, - "watched" integer NOT NULL DEFAULT '0', - "meta" text NULL, - "guid_plex" text NULL, - "guid_imdb" text NULL, - "guid_tvdb" text NULL, - "guid_tmdb" text NULL, - "guid_tvmaze" text NULL, - "guid_tvrage" text NULL, - "guid_anidb" text NULL -); - -INSERT INTO "state" ("id", "type", "updated", "watched", "meta", "guid_plex", "guid_imdb", "guid_tvdb", - "guid_tmdb", "guid_tvmaze", "guid_tvrage", "guid_anidb") -SELECT "id", - "type", - "updated", - "watched", - "meta", - "guid_plex", - "guid_imdb", - "guid_tvdb", - "guid_tmdb", - "guid_tvmaze", - "guid_tvrage", - "guid_anidb" -FROM "old_state"; - -UPDATE sqlite_sequence -SET "seq" = (SELECT MAX("id") FROM "state") -WHERE "name" = 'state'; - -DROP TABLE "old_state"; - -CREATE INDEX "state_type" ON "state" ("type"); -CREATE INDEX "state_updated" ON "state" ("updated"); -CREATE INDEX "state_watched" ON "state" ("watched"); -CREATE INDEX "state_meta" ON "state" ("meta"); -CREATE INDEX "state_guid_plex" ON "state" ("guid_plex"); -CREATE INDEX "state_guid_imdb" ON "state" ("guid_imdb"); -CREATE INDEX "state_guid_tvdb" ON "state" ("guid_tvdb"); -CREATE INDEX "state_guid_tvmaze" ON "state" ("guid_tvmaze"); -CREATE INDEX "state_guid_tvrage" ON "state" ("guid_tvrage"); -CREATE INDEX "state_guid_anidb" ON "state" ("guid_anidb"); - --- put your downgrade database commands here. diff --git a/src/Commands/Database/ListCommand.php b/src/Commands/Database/ListCommand.php index 98f7a6a2..376e1d1a 100644 --- a/src/Commands/Database/ListCommand.php +++ b/src/Commands/Database/ListCommand.php @@ -39,13 +39,19 @@ final class ListCommand extends Command 'Limit results to this specified server. This filter is not reliable. and changes based on last server query.' ) ->addOption('output', null, InputOption::VALUE_REQUIRED, 'Display output as [json, yaml, table]', 'table') - ->addOption('series', null, InputOption::VALUE_REQUIRED, 'Limit results to this specified series.') - ->addOption('movie', null, InputOption::VALUE_REQUIRED, 'Limit results to this specified movie.') - ->addOption('parent', null, InputOption::VALUE_NONE, 'If set it will search parent GUIDs instead.') + ->addOption( + 'type', + null, + InputOption::VALUE_REQUIRED, + 'Limit results to this specified type can be [movie or episode].' + ) + ->addOption('title', null, InputOption::VALUE_REQUIRED, 'Limit results to this specified tv show.') ->addOption('season', null, InputOption::VALUE_REQUIRED, 'Select season number') ->addOption('episode', null, InputOption::VALUE_REQUIRED, 'Select episode number') ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Select db record number') ->addOption('sort', null, InputOption::VALUE_REQUIRED, 'sort order by [id, updated]', 'updated') + ->addOption('asc', null, InputOption::VALUE_NONE, 'Sort records in ascending order.') + ->addOption('desc', null, InputOption::VALUE_NONE, 'Sort records in descending order. (Default)') ->setDescription('List Database entries.'); foreach (array_keys(Guid::SUPPORTED) as $guid) { @@ -57,6 +63,8 @@ final class ListCommand extends Command 'Search Using ' . ucfirst($guid) . ' id.' ); } + + $this->addOption('parent', null, InputOption::VALUE_NONE, 'If set it will search parent GUIDs instead.'); } /** @@ -89,32 +97,37 @@ final class ListCommand extends Command $sql = "SELECT * FROM state "; - if ($input->getOption('via')) { - $where[] = "json_extract(meta,'$.via') = :via"; - $params['via'] = $input->getOption('via'); - } - if ($input->getOption('id')) { $where[] = "id = :id"; $params['id'] = $input->getOption('id'); } - if ($input->getOption('series')) { - $where[] = "json_extract(meta,'$.series') = :series"; - $params['series'] = $input->getOption('series'); + if ($input->getOption('via')) { + $where[] = "via = :via"; + $params['via'] = $input->getOption('via'); } - if ($input->getOption('movie')) { - $where[] = "json_extract(meta,'$.title') = :movie"; - $params['movie'] = $input->getOption('movie'); + if ($input->getOption('type')) { + $where[] = "type = :type"; + $params['type'] = match ($input->getOption('type')) { + StateInterface::TYPE_MOVIE => StateInterface::TYPE_MOVIE, + default => StateInterface::TYPE_EPISODE, + }; + } + + if ($input->getOption('title')) { + $where[] = "title LIKE '%' || :title || '%'"; + $params['title'] = $input->getOption('title'); } if (null !== $input->getOption('season')) { - $where[] = "json_extract(meta,'$.season') = " . (int)$input->getOption('season'); + $where[] = "season = :season"; + $params['season'] = $input->getOption('season'); } if (null !== $input->getOption('episode')) { - $where[] = "json_extract(meta,'$.episode') = " . (int)$input->getOption('episode'); + $where[] = "episode = :episode"; + $params['episode'] = $input->getOption('episode'); } if ($input->getOption('parent')) { @@ -122,7 +135,7 @@ final class ListCommand extends Command if (null === ($val = $input->getOption(afterLast($guid, 'guid_')))) { continue; } - $where[] = "json_extract(meta,'$.parent.{$guid}') = :{$guid}"; + $where[] = "json_extract(parent,'$.{$guid}') = :{$guid}"; $params[$guid] = $val; } } else { @@ -130,7 +143,7 @@ final class ListCommand extends Command if (null === ($val = $input->getOption(afterLast($guid, 'guid_')))) { continue; } - $where[] = "{$guid} LIKE '%' || :{$guid} || '%'"; + $where[] = "json_extract(guids,'$.{$guid}') = :{$guid}"; $params[$guid] = $val; } } @@ -139,8 +152,17 @@ final class ListCommand extends Command $sql .= 'WHERE ' . implode(' AND ', $where); } - $sort = $input->getOption('sort') === 'id' ? 'id' : 'updated'; - $sql .= " ORDER BY {$sort} DESC LIMIT :limit"; + $sort = match ($input->getOption('sort')) { + 'id' => 'id', + 'season' => 'season', + 'episode' => 'episode', + 'type' => 'type', + default => 'updated', + }; + + $sortOrder = ($input->getOption('asc')) ? 'ASC' : 'DESC'; + + $sql .= " ORDER BY {$sort} {$sortOrder} LIMIT :limit"; $stmt = $this->pdo->prepare($sql); $stmt->execute($params); @@ -200,27 +222,27 @@ final class ListCommand extends Command $type = strtolower($row['type'] ?? '??'); - $meta = json_decode(ag($row, 'meta', '{}'), true); + $extra = json_decode(ag($row, 'extra', '{}'), true); $episode = null; if (StateInterface::TYPE_EPISODE === $type) { $episode = sprintf( '%sx%s', - str_pad((string)($meta['season'] ?? 0), 2, '0', STR_PAD_LEFT), - str_pad((string)($meta['episode'] ?? 0), 2, '0', STR_PAD_LEFT), + str_pad((string)($row['season'] ?? 0), 2, '0', STR_PAD_LEFT), + str_pad((string)($row['episode'] ?? 0), 3, '0', STR_PAD_LEFT), ); } $list[] = [ $row['id'], ucfirst($row['type'] ?? '??'), - $meta['via'] ?? '??', - $meta['series'] ?? $meta['title'] ?? '??', - $meta['year'] ?? '0000', + $row['via'] ?? '??', + $row['title'] ?? '??', + $row['year'] ?? '0000', $episode ?? '-', makeDate($row['updated']), true === (bool)$row['watched'] ? 'Yes' : 'No', - $meta['webhook']['event'] ?? '-', + $extra['webhook']['event'] ?? '-', ]; if ($x < $rowCount) { diff --git a/src/Commands/State/ImportCommand.php b/src/Commands/State/ImportCommand.php index 15a01666..b9b4be77 100644 --- a/src/Commands/State/ImportCommand.php +++ b/src/Commands/State/ImportCommand.php @@ -6,11 +6,9 @@ namespace App\Commands\State; use App\Command; use App\Libs\Config; -use App\Libs\Container; use App\Libs\Data; use App\Libs\Entity\StateInterface; use App\Libs\Extends\CliLogger; -use App\Libs\Mappers\Import\DirectMapper; use App\Libs\Mappers\ImportInterface; use App\Libs\Storage\PDO\PDOAdapter; use App\Libs\Storage\StorageInterface; @@ -86,12 +84,6 @@ class ImportCommand extends Command 'Filter final status output e.g. (servername.key)', null ) - ->addOption( - 'mapper-direct', - null, - InputOption::VALUE_NONE, - 'Uses less memory. However, it\'s significantly slower then default mapper.' - ) ->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Use Alternative config file.'); } @@ -114,10 +106,6 @@ class ImportCommand extends Command $config = Config::get('path') . '/config/servers.yaml'; } - if ($input->getOption('mapper-direct')) { - $this->mapper = Container::get(DirectMapper::class); - } - $list = []; $serversFilter = (string)$input->getOption('servers-filter'); $selected = explode(',', $serversFilter); @@ -183,7 +171,7 @@ class ImportCommand extends Command /** @var array $queue */ $queue = []; - if (count($list) >= 1 && !$input->getOption('mapper-direct')) { + if (count($list) >= 1) { $this->logger->info('Preloading all mapper data.'); $this->mapper->loadData(); $this->logger->info('Finished preloading mapper data.'); diff --git a/src/Libs/Entity/StateEntity.php b/src/Libs/Entity/StateEntity.php index 7eb0e93a..22073fea 100644 --- a/src/Libs/Entity/StateEntity.php +++ b/src/Libs/Entity/StateEntity.php @@ -12,21 +12,21 @@ final class StateEntity implements StateInterface private array $data = []; private bool $tainted = false; - /** - * User Addressable Variables. - */ public null|string|int $id = null; public string $type = ''; public int $updated = 0; public int $watched = 0; - public array $meta = []; - public string|null $guid_plex = null; - public string|null $guid_imdb = null; - public string|null $guid_tvdb = null; - public string|null $guid_tmdb = null; - public string|null $guid_tvmaze = null; - public string|null $guid_tvrage = null; - public string|null $guid_anidb = null; + + public string $via = ''; + public string $title = ''; + + public int|null $year = null; + public int|null $season = null; + public int|null $episode = null; + + public array $parent = []; + public array $guids = []; + public array $extra = []; public function __construct(array $data) { @@ -46,8 +46,16 @@ final class StateEntity implements StateInterface ); } - if ('meta' === $key && is_string($val)) { - if (null === ($val = json_decode($val, true))) { + foreach (StateInterface::ENTITY_ARRAY_KEYS as $subKey) { + if ($subKey !== $key) { + continue; + } + + if (true === is_array($val)) { + continue; + } + + if (null === ($val = json_decode($val ?? '{}', true))) { $val = []; } } @@ -63,20 +71,12 @@ final class StateEntity implements StateInterface return new self($data); } - public function diff(): array + public function diff(bool $all = false): array { $changed = []; foreach ($this->getAll() as $key => $value) { - /** - * We ignore meta on purpose as it changes frequently. - * from one server to another. - */ - if ('meta' === $key && !$this->isEpisode()) { - continue; - } - - if ('meta' === $key && ($value['parent'] ?? []) === ($this->data['parent'] ?? [])) { + if (false === $all && true === in_array($key, StateInterface::ENTITY_IGNORE_DIFF_CHANGES)) { continue; } @@ -84,25 +84,21 @@ final class StateEntity implements StateInterface continue; } - if ('meta' === $key) { - $getChanged = array_diff_assoc_recursive($this->data['meta'] ?? [], $this->meta); - - foreach ($getChanged as $metaKey => $_) { - $changed['new'][$key][$metaKey] = $this->meta[$metaKey] ?? 'None'; - $changed['old'][$key][$metaKey] = $this->data[$key][$metaKey] ?? 'None'; + if (true === in_array($key, StateInterface::ENTITY_ARRAY_KEYS)) { + $changes = array_diff_assoc_recursive($this->data[$key] ?? [], $value ?? []); + if (!empty($changes)) { + foreach (array_keys($changes) as $subKey) { + $changed[$key][$subKey] = [ + 'old' => $this->data[$key][$subKey] ?? 'None', + 'new' => $value[$subKey] ?? 'None' + ]; + } } } else { - $changed['new'][$key] = $value ?? 'None'; - $changed['old'][$key] = $this->data[$key] ?? 'None'; - } - } - - if (!empty($changed) && !array_key_exists('meta', $changed['new'] ?? $changed['old'] ?? [])) { - $getChanged = array_diff_assoc_recursive($this->data['meta'] ?? [], $this->meta); - - foreach ($getChanged as $key => $_) { - $changed['new']['meta'][$key] = $this->meta[$key] ?? 'None'; - $changed['old']['meta'][$key] = $this->data['meta'][$key] ?? 'None'; + $changed[$key] = [ + 'old' => $this->data[$key] ?? 'None', + 'new' => $value ?? 'None' + ]; } } @@ -112,21 +108,16 @@ final class StateEntity implements StateInterface public function getName(): string { if ($this->isMovie()) { - return sprintf( - '%s (%d) - @%s', - $this->meta['title'] ?? $this->data['meta']['title'] ?? '??', - $this->meta['year'] ?? $this->data['meta']['year'] ?? '??', - $this->meta['via'] ?? $this->data['meta']['via'] ?? '??', - ); + return sprintf('%s (%d) - @%s', $this->title ?? '??', $this->year ?? 0000, $this->via ?? '??'); } return sprintf( - '%s (%d) - %dx%d - @%s', - $this->meta['series'] ?? $this->data['meta']['series'] ?? '??', - $this->meta['year'] ?? $this->data['meta']['year'] ?? '??', - $this->meta['season'] ?? $this->data['meta']['season'] ?? 00, - $this->meta['episode'] ?? $this->data['meta']['episode'] ?? 00, - $this->meta['via'] ?? $this->data['meta']['via'] ?? '??', + '%s (%s) - %sx%s - @%s', + $this->title ?? '??', + $this->year ?? 0000, + str_pad((string)($this->season ?? 0), 2, '0', STR_PAD_LEFT), + str_pad((string)($this->episode ?? 0), 3, '0', STR_PAD_LEFT), + $this->via ?? '??', ); } @@ -137,41 +128,35 @@ final class StateEntity implements StateInterface 'type' => $this->type, 'updated' => $this->updated, 'watched' => $this->watched, - 'meta' => $this->meta, - 'guid_plex' => $this->guid_plex, - 'guid_imdb' => $this->guid_imdb, - 'guid_tvdb' => $this->guid_tvdb, - 'guid_tmdb' => $this->guid_tmdb, - 'guid_tvmaze' => $this->guid_tvmaze, - 'guid_tvrage' => $this->guid_tvrage, - 'guid_anidb' => $this->guid_anidb, + 'via' => $this->via, + 'title' => $this->title, + 'year' => $this->year, + 'season' => $this->season, + 'episode' => $this->episode, + 'parent' => $this->parent, + 'guids' => $this->guids, + 'extra' => $this->extra, ]; } public function isChanged(): bool { - return count($this->diff()) >= 1; + return count($this->diff(all: false)) >= 1; } public function hasGuids(): bool { - foreach (array_keys(Guid::SUPPORTED) as $key) { - if (null !== $this->{$key}) { - return true; - } - } - - return false; + return count($this->guids) >= 1; } public function hasParentGuid(): bool { - return count($this->getParentGuids()) >= 1; + return count($this->parent) >= 1; } public function getParentGuids(): array { - return (array)ag($this->meta, 'parent', []); + return $this->parent; } public function isMovie(): bool @@ -186,27 +171,19 @@ final class StateEntity implements StateInterface public function hasRelativeGuid(): bool { - $parents = ag($this->meta, 'parent', []); - $season = ag($this->meta, 'season', null); - $episode = ag($this->meta, 'episode', null); - - return !(null === $season || null === $episode || 0 === $episode || empty($parents)); + return $this->isEpisode() && !empty($this->parent) && null !== $this->season && null !== $this->episode; } public function getRelativeGuids(): array { - $parents = ag($this->meta, 'parent', []); - $season = ag($this->meta, 'season', null); - $episode = ag($this->meta, 'episode', null); - - if (null === $season || null === $episode || 0 === $episode || empty($parents)) { + if (!$this->isEpisode()) { return []; } $list = []; - foreach ($parents as $key => $val) { - $list[$key] = $val . '/' . $season . '/' . $episode; + foreach ($this->parent as $key => $val) { + $list[$key] = $val . '/' . $this->season . '/' . $this->episode; } return array_intersect_key($list, Guid::SUPPORTED); @@ -214,20 +191,40 @@ final class StateEntity implements StateInterface public function getRelativePointers(): array { - return Guid::fromArray($this->getRelativeGuids())->getPointers(); + if (!$this->isEpisode()) { + return []; + } + + $list = Guid::fromArray($this->getRelativeGuids())->getPointers(); + + $rPointers = []; + + foreach ($list as $val) { + $rPointers[] = 'r' . $val; + } + + return $rPointers; } public function apply(StateInterface $entity, bool $guidOnly = false): self { + if (true === $guidOnly) { + if ($this->guids !== $entity->guids) { + $this->updateValue('guids', $entity); + } + + if ($this->parent !== $entity->parent) { + $this->updateValue('parent', $entity); + } + + return $this; + } + if ($this->isEqual($entity)) { return $this; } foreach ($entity->getAll() as $key => $val) { - if (true === $guidOnly && !str_starts_with($key, 'guid_')) { - continue; - } - $this->updateValue($key, $entity); } @@ -245,9 +242,17 @@ final class StateEntity implements StateInterface return $this->data; } - public function getPointers(): array + public function getPointers(array|null $guids = null): array { - return Guid::fromArray(array_intersect_key((array)$this, Guid::SUPPORTED))->getPointers(); + $list = array_intersect_key($this->guids, Guid::SUPPORTED); + + if ($this->isEpisode()) { + foreach ($list as $key => $val) { + $list[$key] = $val . '/' . $this->season . '/' . $this->episode; + } + } + + return Guid::fromArray($list)->getPointers(); } public function setIsTainted(bool $isTainted): StateInterface @@ -275,7 +280,7 @@ final class StateEntity implements StateInterface private function isEqualValue(string $key, StateInterface $entity): bool { - if ($key === 'updated' || $key === 'watched') { + if ('updated' === $key || 'watched' === $key) { return !($entity->updated > $this->updated && $entity->watched !== $this->watched); } @@ -288,7 +293,7 @@ final class StateEntity implements StateInterface private function updateValue(string $key, StateInterface $entity): void { - if ($key === 'updated' || $key === 'watched') { + if ('updated' === $key || 'watched' === $key) { if ($entity->updated > $this->updated && $entity->watched !== $this->watched) { $this->updated = $entity->updated; $this->watched = $entity->watched; @@ -296,12 +301,14 @@ final class StateEntity implements StateInterface return; } - if (null !== ($entity->{$key} ?? null) && $this->{$key} !== $entity->{$key}) { - if ('meta' === $key) { - $this->{$key} = array_replace_recursive($this->{$key} ?? [], $entity->{$key} ?? []); - } else { - $this->{$key} = $entity->{$key}; - } + if ('id' === $key) { + return; + } + + if (true === in_array($key, StateInterface::ENTITY_ARRAY_KEYS)) { + $this->{$key} = array_replace_recursive($this->{$key} ?? [], $entity->{$key} ?? []); + } else { + $this->{$key} = $entity->{$key}; } } } diff --git a/src/Libs/Entity/StateInterface.php b/src/Libs/Entity/StateInterface.php index 5adef224..7836cbda 100644 --- a/src/Libs/Entity/StateInterface.php +++ b/src/Libs/Entity/StateInterface.php @@ -9,19 +9,32 @@ interface StateInterface public const TYPE_MOVIE = 'movie'; public const TYPE_EPISODE = 'episode'; + public const ENTITY_IGNORE_DIFF_CHANGES = [ + 'via', + 'extra', + 'title', + 'year', + ]; + + public const ENTITY_ARRAY_KEYS = [ + 'parent', + 'guids', + 'extra' + ]; + public const ENTITY_KEYS = [ 'id', 'type', 'updated', 'watched', - 'meta', - 'guid_plex', - 'guid_imdb', - 'guid_tvdb', - 'guid_tmdb', - 'guid_tvmaze', - 'guid_tvrage', - 'guid_anidb', + 'via', + 'title', + 'year', + 'season', + 'episode', + 'parent', + 'guids', + 'extra', ]; /** @@ -36,9 +49,11 @@ interface StateInterface /** * Return An array of changed items. * + * @param bool $all check all keys. including ignored keys. + * * @return array */ - public function diff(): array; + public function diff(bool $all = false): array; /** * Get All Entity keys. diff --git a/src/Libs/Mappers/Export/ExportMapper.php b/src/Libs/Mappers/Export/ExportMapper.php index 2b4212a7..b510f0a2 100644 --- a/src/Libs/Mappers/Export/ExportMapper.php +++ b/src/Libs/Mappers/Export/ExportMapper.php @@ -4,9 +4,7 @@ declare(strict_types=1); namespace App\Libs\Mappers\Export; -use App\Libs\Container; use App\Libs\Entity\StateInterface; -use App\Libs\Guid; use App\Libs\Mappers\ExportInterface; use App\Libs\Storage\StorageInterface; use DateTimeInterface; @@ -73,40 +71,13 @@ final class ExportMapper implements ExportInterface return $this->objects[$entity->id]; } - if ($entity->hasGuids()) { - foreach ($entity->getPointers() as $key) { - if (null !== ($this->guids[$key] ?? null)) { - return $this->objects[$this->guids[$key]]; - } + foreach ($entity->getRelativePointers() as $key) { + if (null !== ($this->guids[$key] ?? null)) { + return $this->objects[$this->guids[$key]]; } } - if ($entity->isEpisode() && $entity->hasRelativeGuid()) { - foreach ($entity->getRelativePointers() as $key) { - if (null !== ($this->guids[$key] ?? null)) { - return $this->objects[$this->guids[$key]]; - } - } - } - - if (true === $this->fullyLoaded) { - return null; - } - - if (null !== ($lazyEntity = $this->storage->get($entity))) { - $this->objects[$lazyEntity->id] = $lazyEntity; - $this->addGuids($this->objects[$lazyEntity->id], $lazyEntity->id); - return $this->objects[$lazyEntity->id]; - } - - return null; - } - - public function findByIds(array $ids): null|StateInterface - { - $pointers = Guid::fromArray($ids)->getPointers(); - - foreach ($pointers as $key) { + foreach ($entity->getPointers() as $key) { if (null !== ($this->guids[$key] ?? null)) { return $this->objects[$this->guids[$key]]; } @@ -116,8 +87,6 @@ final class ExportMapper implements ExportInterface return null; } - $entity = Container::get(StateInterface::class)::fromArray($ids); - if (null !== ($lazyEntity = $this->storage->get($entity))) { $this->objects[$lazyEntity->id] = $lazyEntity; $this->addGuids($this->objects[$lazyEntity->id], $lazyEntity->id); @@ -169,16 +138,12 @@ final class ExportMapper implements ExportInterface private function addGuids(StateInterface $entity, int|string $pointer): void { - if ($entity->hasGuids()) { - foreach ($entity->getPointers() as $key) { - $this->guids[$key] = $pointer; - } + foreach ($entity->getPointers() as $key) { + $this->guids[$key] = $pointer; } - if ($entity->isEpisode() && $entity->hasRelativeGuid()) { - foreach ($entity->getRelativePointers() as $key) { - $this->guids[$key] = $pointer; - } + foreach ($entity->getRelativePointers() as $key) { + $this->guids[$key] = $pointer; } } } diff --git a/src/Libs/Mappers/ExportInterface.php b/src/Libs/Mappers/ExportInterface.php index f9ec4e2d..55a2f7fb 100644 --- a/src/Libs/Mappers/ExportInterface.php +++ b/src/Libs/Mappers/ExportInterface.php @@ -48,15 +48,6 @@ interface ExportInterface */ public function get(StateInterface $entity): null|StateInterface; - /** - * Find Entity By Ids. - * - * @param array $ids - * - * @return StateInterface|null - */ - public function findByIds(array $ids): null|StateInterface; - /** * Has Entity. * diff --git a/src/Libs/Mappers/Import/DirectMapper.php b/src/Libs/Mappers/Import/DirectMapper.php deleted file mode 100644 index 776f0b28..00000000 --- a/src/Libs/Mappers/Import/DirectMapper.php +++ /dev/null @@ -1,177 +0,0 @@ - ['added' => 0, 'updated' => 0, 'failed' => 0], - StateInterface::TYPE_EPISODE => ['added' => 0, 'updated' => 0, 'failed' => 0], - ]; - - private int $changed = 0; - - public function __construct(private LoggerInterface $logger, private StorageInterface $storage) - { - } - - public function setUp(array $opts): ImportInterface - { - return $this; - } - - public function loadData(DateTimeInterface|null $date = null): ImportInterface - { - return $this; - } - - public function add(string $bucket, string $name, StateInterface $entity, array $opts = []): self - { - if (!$entity->hasGuids() && $entity->hasRelativeGuid()) { - $this->logger->info(sprintf('Ignoring %s. No valid GUIDs.', $name)); - Data::increment($bucket, $entity->type . '_failed_no_guid'); - return $this; - } - - $item = $this->get($entity); - - if (null === $entity->id && null === $item) { - try { - $this->storage->insert($entity); - } catch (Throwable $e) { - $this->operations[$entity->type]['failed']++; - Data::append($bucket, 'storage_error', $e->getMessage()); - return $this; - } - - $this->changed++; - Data::increment($bucket, $entity->type . '_added'); - $this->operations[$entity->type]['added']++; - $this->logger->debug(sprintf('Adding %s. As new Item.', $name)); - return $this; - } - - // -- Ignore old item. - if (null !== ($opts['after'] ?? null) && ($opts['after'] instanceof DateTimeInterface)) { - if ($opts['after']->getTimestamp() >= $entity->updated) { - // -- check for updated GUIDs. - if ($item->apply($entity, guidOnly: true)->isChanged()) { - try { - $this->changed++; - if (!empty($entity->meta)) { - $item->meta = $entity->meta; - } - $this->storage->update($item); - $this->operations[$entity->type]['updated']++; - $this->logger->debug(sprintf('Updating %s. GUIDs.', $name), $item->diff()); - return $this; - } catch (Throwable $e) { - $this->operations[$entity->type]['failed']++; - Data::append($bucket, 'storage_error', $e->getMessage()); - return $this; - } - } - - $this->logger->debug(sprintf('Ignoring %s. No change since last sync.', $name)); - Data::increment($bucket, $entity->type . '_ignored_not_played_since_last_sync'); - return $this; - } - } - - $item = $item->apply($entity); - - if ($item->isChanged()) { - try { - $this->storage->update($item); - } catch (Throwable $e) { - $this->operations[$entity->type]['failed']++; - Data::append($bucket, 'storage_error', $e->getMessage()); - return $this; - } - - $this->changed++; - Data::increment($bucket, $entity->type . '_updated'); - $this->operations[$entity->type]['updated']++; - } else { - Data::increment($bucket, $entity->type . '_ignored_no_change'); - } - - return $this; - } - - public function get(StateInterface $entity): null|StateInterface - { - return $this->storage->get($entity); - } - - public function remove(StateInterface $entity): bool - { - return $this->storage->remove($entity); - } - - public function commit(): mixed - { - $op = $this->operations; - - $this->reset(); - - return $op; - } - - public function has(StateInterface $entity): bool - { - return null !== $this->storage->get($entity); - } - - public function reset(): self - { - $this->changed = 0; - - $this->operations[StateInterface::TYPE_EPISODE]['added'] = 0; - $this->operations[StateInterface::TYPE_EPISODE]['updated'] = 0; - $this->operations[StateInterface::TYPE_EPISODE]['failed'] = 0; - $this->operations[StateInterface::TYPE_MOVIE]['added'] = 0; - $this->operations[StateInterface::TYPE_MOVIE]['updated'] = 0; - $this->operations[StateInterface::TYPE_MOVIE]['failed'] = 0; - - return $this; - } - - public function getObjects(array $opts = []): array - { - return []; - } - - public function getObjectsCount(): int - { - return 0; - } - - public function setLogger(LoggerInterface $logger): self - { - $this->logger = $logger; - $this->storage->setLogger($logger); - return $this; - } - - public function setStorage(StorageInterface $storage): self - { - $this->storage = $storage; - return $this; - } - - public function count(): int - { - return $this->changed; - } -} diff --git a/src/Libs/Mappers/Import/MemoryMapper.php b/src/Libs/Mappers/Import/MemoryMapper.php index c5a72a37..b968bb6a 100644 --- a/src/Libs/Mappers/Import/MemoryMapper.php +++ b/src/Libs/Mappers/Import/MemoryMapper.php @@ -52,7 +52,7 @@ final class MemoryMapper implements ImportInterface continue; } $this->objects[$entity->id] = $entity; - $this->addGuids($this->objects[$entity->id], $entity->id); + $this->addPointers($this->objects[$entity->id], $entity->id); } return $this; @@ -73,7 +73,7 @@ final class MemoryMapper implements ImportInterface $this->changed[$pointer] = $pointer; Data::increment($bucket, $entity->type . '_added'); - $this->addGuids($this->objects[$pointer], $pointer); + $this->addPointers($this->objects[$pointer], $pointer); $this->logger->debug(sprintf('Adding %s. As new Item.', $name)); return $this; @@ -82,15 +82,17 @@ final class MemoryMapper implements ImportInterface // -- Ignore old item. if (null !== ($opts['after'] ?? null) && ($opts['after'] instanceof DateTimeInterface)) { if ($opts['after']->getTimestamp() >= $entity->updated) { + $cloned = clone $this->objects[$pointer]; // -- check for updated GUIDs. if ($this->objects[$pointer]->apply($entity, guidOnly: true)->isChanged()) { $this->changed[$pointer] = $pointer; - if (!empty($entity->meta)) { - $this->objects[$pointer]->meta = $entity->meta; - } Data::increment($bucket, $entity->type . '_updated'); - $this->addGuids($this->objects[$pointer], $pointer); - $this->logger->debug(sprintf('Updating %s. GUIDs.', $name), $this->objects[$pointer]->diff()); + $this->removePointers($cloned); + $this->addPointers($this->objects[$pointer], $pointer); + $this->logger->debug(sprintf('Updating %s. Parent & Entity GUIDs.', $name), [ + 'changes' => $this->objects[$pointer]->diff(), + ]); + return $this; } @@ -102,11 +104,15 @@ final class MemoryMapper implements ImportInterface $this->objects[$pointer] = $this->objects[$pointer]->apply($entity); - if ($this->objects[$pointer]->isChanged()) { + $cloned = clone $this->objects[$pointer]; + if (true === $this->objects[$pointer]->isChanged()) { Data::increment($bucket, $entity->type . '_updated'); $this->changed[$pointer] = $pointer; - $this->addGuids($this->objects[$pointer], $pointer); - $this->logger->debug(sprintf('Updating %s. State changed.', $name), $this->objects[$pointer]->diff()); + $this->removePointers($cloned); + $this->addPointers($this->objects[$pointer], $pointer); + $this->logger->debug(sprintf('Updating %s. State changed.', $name), [ + 'changes' => $this->objects[$pointer]->diff(all: true), + ]); return $this; } @@ -122,34 +128,7 @@ final class MemoryMapper implements ImportInterface return $this->objects[$entity->id]; } - if ($entity->hasGuids()) { - foreach ($entity->getPointers() as $key) { - if (null !== ($this->guids[$key] ?? null)) { - return $this->objects[$this->guids[$key]]; - } - } - } - - if ($entity->isEpisode() && $entity->hasRelativeGuid()) { - foreach ($entity->getRelativePointers() as $key) { - if (null !== ($this->guids[$key] ?? null)) { - return $this->objects[$this->guids[$key]]; - } - } - } - - if (true === $this->fullyLoaded) { - return null; - } - - if (null !== ($lazyEntity = $this->storage->get($entity))) { - $this->objects[] = $lazyEntity; - $id = array_key_last($this->objects); - $this->addGuids($this->objects[$id], $id); - return $this->objects[$id]; - } - - return null; + return false === ($pointer = $this->getPointer($entity)) ? null : $this->objects[$pointer]; } public function remove(StateInterface $entity): bool @@ -160,21 +139,7 @@ final class MemoryMapper implements ImportInterface $this->storage->remove($this->objects[$pointer]); - if ($entity->hasGuids()) { - foreach ($entity->getPointers() as $key) { - if (null !== ($this->guids[$key] ?? null)) { - unset($this->guids[$key]); - } - } - } - - if ($entity->isEpisode() && $entity->hasRelativeGuid()) { - foreach ($entity->getRelativePointers() as $key) { - if (null !== ($this->guids[$key] ?? null)) { - unset($this->guids[$key]); - } - } - } + $this->removePointers($this->objects[$pointer]); unset($this->objects[$pointer]); @@ -246,41 +211,34 @@ final class MemoryMapper implements ImportInterface */ private function getPointer(StateInterface $entity): int|bool { - foreach ($entity->getPointers() as $key) { + foreach ([...$entity->getRelativePointers(), ...$entity->getPointers()] as $key) { if (null !== ($this->guids[$key] ?? null)) { return $this->guids[$key]; } } - if ($entity->isEpisode()) { - foreach ($entity->getRelativePointers() as $key) { - if (null !== ($this->guids[$key] ?? null)) { - return $this->guids[$key]; - } - } - } - if (false === $this->fullyLoaded && null !== ($lazyEntity = $this->storage->get($entity))) { $this->objects[] = $lazyEntity; $id = array_key_last($this->objects); - $this->addGuids($this->objects[$id], $id); + $this->addPointers($this->objects[$id], $id); return $id; } return false; } - private function addGuids(StateInterface $entity, int $pointer): void + private function addPointers(StateInterface $entity, int $pointer): void { - if ($entity->hasGuids()) { - foreach ($entity->getPointers() as $key) { - $this->guids[$key] = $pointer; - } + foreach ([...$entity->getPointers(), ...$entity->getRelativePointers()] as $key) { + $this->guids[$key] = $pointer; } + } - if ($entity->isEpisode()) { - foreach ($entity->getRelativePointers() as $key) { - $this->guids[$key] = $pointer; + private function removePointers(StateInterface $entity): void + { + foreach ([...$entity->getPointers(), ...$entity->getRelativePointers()] as $key) { + if (isset($this->guids[$key])) { + unset($this->guids[$key]); } } } diff --git a/src/Libs/Servers/EmbyServer.php b/src/Libs/Servers/EmbyServer.php index 3c8f54eb..6c44dd41 100644 --- a/src/Libs/Servers/EmbyServer.php +++ b/src/Libs/Servers/EmbyServer.php @@ -151,7 +151,7 @@ class EmbyServer extends JellyfinServer 'updated' => time(), 'watched' => $isWatched, 'meta' => $meta, - ...$this->getGuids($providersId, $type) + ...$this->getGuids($providersId) ]; $entity = Container::get(StateInterface::class)::fromArray($row)->setIsTainted($isTainted); diff --git a/src/Libs/Servers/JellyfinServer.php b/src/Libs/Servers/JellyfinServer.php index 1254d96c..37c06ebc 100644 --- a/src/Libs/Servers/JellyfinServer.php +++ b/src/Libs/Servers/JellyfinServer.php @@ -38,7 +38,6 @@ use Throwable; class JellyfinServer implements ServerInterface { protected const GUID_MAPPER = [ - 'plex' => Guid::GUID_PLEX, 'imdb' => Guid::GUID_IMDB, 'tmdb' => Guid::GUID_TMDB, 'tvdb' => Guid::GUID_TVDB, @@ -328,7 +327,7 @@ class JellyfinServer implements ServerInterface 'updated' => time(), 'watched' => (int)(bool)ag($json, 'Played', ag($json, 'PlayedToCompletion', 0)), 'meta' => $meta, - ...$this->getGuids($providersId, $type) + ...$this->getGuids($providersId) ]; $entity = Container::get(StateInterface::class)::fromArray($row)->setIsTainted($isTainted); @@ -348,16 +347,8 @@ class JellyfinServer implements ServerInterface ); } - if ($entity->hasGuids()) { - foreach ($entity->getPointers() as $guid) { - $this->cacheData[$guid] = ag($json, 'Item.ItemId'); - } - } - - if ($entity->isEpisode() && $entity->hasRelativeGuid()) { - foreach ($entity->getPointers() as $guid) { - $this->cacheData[$guid] = ag($json, 'Item.ItemId'); - } + foreach ([...$entity->getRelativePointers(), ...$entity->getPointers()] as $guid) { + $this->cacheData[$guid] = ag($json, 'Item.ItemId'); } if (false === $isTainted && (true === Config::get('webhook.debug') || null !== ag( @@ -949,24 +940,12 @@ class JellyfinServer implements ServerInterface $entity->jf_id = null; - if ($entity->hasGuids()) { - foreach ($entity->getPointers() as $guid) { - if (null === ($this->cacheData[$guid] ?? null)) { - continue; - } - $entity->jf_id = $this->cacheData[$guid]; - break; - } - } - - if ($entity->isEpisode() && $entity->hasRelativeGuid()) { - foreach ($entity->getRelativePointers() as $guid) { - if (null === ($this->cacheData[$guid] ?? null)) { - continue; - } - $entity->jf_id = $this->cacheData[$guid]; - break; + foreach ([...$entity->getRelativePointers(), ...$entity->getPointers()] as $guid) { + if (null === ($this->cacheData[$guid] ?? null)) { + continue; } + $entity->jf_id = $this->cacheData[$guid]; + break; } } @@ -1589,7 +1568,7 @@ class JellyfinServer implements ServerInterface } } - protected function getGuids(array $ids, string|null $type = null): array + protected function getGuids(array $ids): array { $guid = []; @@ -1600,13 +1579,17 @@ class JellyfinServer implements ServerInterface continue; } - if (null !== $type) { - $value = $type . '/' . $value; + if (null !== ($guid[self::GUID_MAPPER[$key]] ?? null) && ctype_digit($value)) { + if ((int)$guid[self::GUID_MAPPER[$key]] > (int)$value) { + continue; + } } $guid[self::GUID_MAPPER[$key]] = $value; } + ksort($guid); + return $guid; } @@ -1661,49 +1644,43 @@ class JellyfinServer implements ServerInterface { $date = strtotime($item->UserData?->LastPlayedDate ?? $item->DateCreated ?? $item->PremiereDate); + /** @noinspection PhpArrayIndexImmediatelyRewrittenInspection */ + $row = [ + 'type' => $type, + 'updated' => $date, + 'watched' => (int)(bool)($item->UserData?->Played ?? false), + 'via' => $this->name, + 'title' => '??', + 'year' => $item->ProductionYear ?? 0000, + 'season' => null, + 'episode' => null, + 'parent' => [], + 'guids' => $this->getGuids((array)($item->ProviderIds ?? [])), + 'extra' => [ + 'date' => makeDate($item->PremiereDate ?? $item->ProductionYear ?? 'now')->format('Y-m-d'), + ], + ]; + if (StateInterface::TYPE_MOVIE === $type) { - $meta = [ - 'via' => $this->name, - 'title' => $item->Name ?? $item->OriginalTitle ?? '??', - 'year' => $item->ProductionYear ?? 0000, - 'date' => makeDate($item->PremiereDate ?? $item->ProductionYear ?? 'now')->format('Y-m-d'), - ]; + $row['title'] = $item->Name ?? $item->OriginalTitle ?? '??'; } else { - $meta = [ - 'via' => $this->name, - 'series' => $item->SeriesName ?? '??', - 'year' => $item->ProductionYear ?? 0000, - 'season' => $item->ParentIndexNumber ?? 0, - 'episode' => $item->IndexNumber ?? 0, - 'title' => $item->Name ?? '', - 'date' => makeDate($item->PremiereDate ?? $item->ProductionYear ?? 'now')->format('Y-m-d'), - ]; + $row['title'] = $item->SeriesName ?? '??'; + $row['season'] = $item->ParentIndexNumber ?? 0; + $row['episode'] = $item->IndexNumber ?? 0; + + if (null !== ($item->Name ?? null)) { + $row['extra']['title'] = $item->Name; + } if (null !== ($item->SeriesId ?? null)) { - $meta['parent'] = $this->showInfo[$item->SeriesId] ?? []; + $row['parent'] = $this->showInfo[$item->SeriesId] ?? []; } } - $entity = Container::get(StateInterface::class)::fromArray( - [ - 'type' => $type, - 'updated' => $date, - 'watched' => (int)(bool)($item->UserData?->Played ?? false), - 'meta' => $meta, - ...$this->getGuids((array)($item->ProviderIds ?? []), $type), - ] - ); + $entity = Container::get(StateInterface::class)::fromArray($row); - if ($entity->hasGuids()) { - foreach ($entity->getPointers() as $guid) { - $this->cacheData[$guid] = $item->Id; - } - } - - if ($entity->isEpisode()) { - foreach ($entity->getRelativePointers() as $guid) { - $this->cacheData[$guid] = $item->Id; - } + foreach ([...$entity->getRelativePointers(), ...$entity->getPointers()] as $guid) { + $this->cacheData[$guid] = $item->Id; } return $entity; diff --git a/src/Libs/Servers/PlexServer.php b/src/Libs/Servers/PlexServer.php index a94686b7..a861f148 100644 --- a/src/Libs/Servers/PlexServer.php +++ b/src/Libs/Servers/PlexServer.php @@ -375,7 +375,7 @@ class PlexServer implements ServerInterface 'updated' => time(), 'watched' => (int)(bool)ag($item, 'viewCount', 0), 'meta' => $meta, - ...$this->getGuids(ag($item, 'Guid', []), $type, isParent: false) + ...$this->getGuids(ag($item, 'Guid', []), isParent: false) ]; $entity = Container::get(StateInterface::class)::fromArray($row)->setIsTainted($isTainted); @@ -395,16 +395,8 @@ class PlexServer implements ServerInterface ); } - if ($entity->hasGuids()) { - foreach ($entity->getPointers() as $guid) { - $this->cacheData[$guid] = ag($item, 'guid'); - } - } - - if ($entity->isEpisode() && $entity->hasRelativeGuid()) { - foreach ($entity->getRelativePointers() as $guid) { - $this->cacheData[$guid] = ag($item, 'guid'); - } + foreach ([...$entity->getRelativePointers(), ...$entity->getPointers()] as $guid) { + $this->cacheData[$guid] = ag($item, 'guid'); } if (false !== $isTainted && (true === Config::get('webhook.debug') || null !== ag( @@ -977,29 +969,17 @@ class PlexServer implements ServerInterface $entity->plex_id = null; - if (null !== $entity->guid_plex) { - $entity->plex_id = 'plex://' . $entity->guid_plex; + if (null !== ($entity->guids[Guid::GUID_PLEX] ?? null)) { + $entity->plex_id = 'plex://' . $entity->guids[Guid::GUID_PLEX]; continue; } - if ($entity->hasGuids()) { - foreach ($entity->getPointers() as $guid) { - if (null === ($this->cacheData[$guid] ?? null)) { - continue; - } - $entity->plex_id = $this->cacheData[$guid]; - break; - } - } - - if ($entity->isEpisode() && $entity->hasRelativeGuid()) { - foreach ($entity->getRelativePointers() as $guid) { - if (null === ($this->cacheData[$guid] ?? null)) { - continue; - } - $entity->plex_id = $this->cacheData[$guid]; - break; + foreach ([...$entity->getRelativePointers(), ...$entity->getPointers()] as $guid) { + if (null === ($this->cacheData[$guid] ?? null)) { + continue; } + $entity->plex_id = $this->cacheData[$guid]; + break; } } @@ -1577,13 +1557,12 @@ class PlexServer implements ServerInterface } else { $iName = trim( sprintf( - '%s - %s - [%s - (%dx%d) - %s]', + '%s - %s - [%s - (%dx%d)]', $this->name, $library, $item->grandparentTitle ?? $item->originalTitle ?? '??', $item->parentIndex ?? 0, $item->index ?? 0, - $item->title ?? $item->originalTitle ?? '', ) ); } @@ -1657,7 +1636,7 @@ class PlexServer implements ServerInterface } } - protected function getGuids(array $guids, string|null $type = null, bool $isParent = false): array + protected function getGuids(array $guids, bool $isParent = false): array { $guid = []; @@ -1679,13 +1658,17 @@ class PlexServer implements ServerInterface continue; } - if ('plex' !== $key && null !== $type) { - $value = $type . '/' . $value; + if (null !== ($guid[self::GUID_MAPPER[$key]] ?? null) && ctype_digit($val)) { + if ((int)$guid[self::GUID_MAPPER[$key]] > (int)$val) { + continue; + } } $guid[self::GUID_MAPPER[$key]] = $value; } + ksort($guid); + return $guid; } @@ -1877,51 +1860,42 @@ class PlexServer implements ServerInterface $date = (int)($item->lastViewedAt ?? $item->updatedAt ?? $item->addedAt ?? 0); + /** @noinspection PhpArrayIndexImmediatelyRewrittenInspection */ + $row = [ + 'type' => $type, + 'updated' => $date, + 'watched' => (int)(bool)($item->viewCount ?? false), + 'via' => $this->name, + 'title' => '??', + 'year' => (int)($item->grandParentYear ?? $item->parentYear ?? $item->year ?? 0000), + 'season' => null, + 'episode' => null, + 'parent' => [], + 'guids' => $this->getGuids($item->Guid ?? [], isParent: false), + 'extra' => [ + 'date' => makeDate($item->originallyAvailableAt ?? 'now')->format('Y-m-d'), + ], + ]; + if (StateInterface::TYPE_MOVIE === $type) { - $meta = [ - 'via' => $this->name, - 'title' => $item->title ?? $item->originalTitle ?? '??', - 'year' => $item->year ?? 0000, - 'date' => makeDate($item->originallyAvailableAt ?? 'now')->format('Y-m-d'), - ]; + $row['title'] = $item->title ?? $item->originalTitle ?? '??'; } else { - $meta = [ - 'via' => $this->name, - 'series' => $item->grandparentTitle ?? '??', - 'year' => $item->year ?? 0000, - 'season' => $item->parentIndex ?? 0, - 'episode' => $item->index ?? 0, - 'title' => $item->title ?? $item->originalTitle ?? '??', - 'date' => makeDate($item->originallyAvailableAt ?? 'now')->format('Y-m-d'), - ]; + $row['title'] = $item->grandparentTitle ?? '??'; + $row['season'] = $item->parentIndex ?? 0; + $row['episode'] = $item->index ?? 0; + $row['extra']['title'] = $item->title ?? $item->originalTitle ?? '??'; $parentId = $item->grandparentRatingKey ?? $item->parentRatingKey ?? null; if (null !== $parentId) { - $meta['parent'] = $this->showInfo[$parentId] ?? []; + $row['parent'] = $this->showInfo[$parentId] ?? []; } } - $entity = Container::get(StateInterface::class)::fromArray( - [ - 'type' => $type, - 'updated' => $date, - 'watched' => (int)(bool)($item->viewCount ?? false), - 'meta' => $meta, - ...$this->getGuids($item->Guid ?? [], $type, isParent: false) - ] - ); + $entity = Container::get(StateInterface::class)::fromArray($row); - if ($entity->hasGuids()) { - foreach ($entity->getPointers() as $guid) { - $this->cacheData[$guid] = $item->guid; - } - } - - if ($entity->isEpisode()) { - foreach ($entity->getRelativePointers() as $guid) { - $this->cacheData[$guid] = $item->guid; - } + foreach ([...$entity->getRelativePointers(), ...$entity->getPointers()] as $guid) { + $this->cacheData[$guid] = $item->guid; } return $entity; diff --git a/src/Libs/Storage/PDO/PDOAdapter.php b/src/Libs/Storage/PDO/PDOAdapter.php index 4f0c297c..b91b5292 100644 --- a/src/Libs/Storage/PDO/PDOAdapter.php +++ b/src/Libs/Storage/PDO/PDOAdapter.php @@ -41,8 +41,11 @@ final class PDOAdapter implements StorageInterface try { $data = $entity->getAll(); - if (is_array($data['meta'])) { - $data['meta'] = json_encode($data['meta']); + foreach (StateInterface::ENTITY_ARRAY_KEYS as $key) { + if (null !== ($data[$key] ?? null) && is_array($data[$key])) { + ksort($data[$key]); + $data[$key] = json_encode($data[$key], flags: JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } } if (null !== $data['id']) { @@ -65,7 +68,7 @@ final class PDOAdapter implements StorageInterface } catch (PDOException $e) { $this->stmt['insert'] = null; if (false === $this->viaCommit) { - $this->logger->error($e->getMessage(), $entity->meta ?? []); + $this->logger->error($e->getMessage(), $entity->getAll()); return $entity; } throw $e; @@ -76,11 +79,11 @@ final class PDOAdapter implements StorageInterface public function get(StateInterface $entity): StateInterface|null { - if ($entity->hasGuids() && null !== ($item = $this->findByGuid($entity))) { + if ($entity->isEpisode() && $entity->hasRelativeGuid() && null !== ($item = $this->findByRGuid($entity))) { return $item; } - if ($entity->isEpisode() && $entity->hasRelativeGuid() && null !== ($item = $this->findByRGuid($entity))) { + if ($entity->hasGuids() && null !== ($item = $this->findByGuid($entity))) { return $item; } @@ -113,8 +116,10 @@ final class PDOAdapter implements StorageInterface try { $data = $entity->getAll(); - if (is_array($data['meta'])) { - $data['meta'] = json_encode($data['meta']); + foreach (StateInterface::ENTITY_ARRAY_KEYS as $key) { + if (is_array($data[$key] ?? [])) { + $data[$key] = json_encode($data[$key], flags: JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } } if (null === $data['id']) { @@ -131,7 +136,7 @@ final class PDOAdapter implements StorageInterface } catch (PDOException $e) { $this->stmt['update'] = null; if (false === $this->viaCommit) { - $this->logger->error($e->getMessage(), $entity->meta ?? []); + $this->logger->error($e->getMessage(), $entity->getAll()); return $entity; } throw $e; @@ -184,19 +189,10 @@ final class PDOAdapter implements StorageInterface foreach ($entities as $entity) { try { if (null === $entity->id) { - $this->logger->info( - 'Adding ' . $entity->type . ' - [' . $entity->getName() . '].', - $entity->getAll() - ); - $this->insert($entity); $list[$entity->type]['added']++; } else { - $this->logger->info( - 'Updating ' . $entity->type . ':' . $entity->id . ' - [' . $entity->getName() . '].', - $entity->diff() - ); $this->update($entity); $list[$entity->type]['updated']++; } @@ -374,58 +370,49 @@ final class PDOAdapter implements StorageInterface { $cond = $where = []; - foreach ($entity->getParentGuids() as $key => $val) { + foreach ($entity->parent as $key => $val) { if (null === ($val ?? null)) { continue; } - $where[] = "json_extract(meta,'$.parent.{$key}') = :{$key}"; + $where[] = "JSON_EXTRACT(parent,'$.{$key}') = :{$key}"; $cond[$key] = $val; } - $sqlType = ''; - - if (null !== ($entity?->type ?? null)) { - $sqlType = 'type = :s_type AND '; - $cond['s_type'] = $entity->type; - } - $sql = "SELECT * FROM state WHERE - {$sqlType} - json_extract(meta, '$.season') = " . (int)ag($entity->meta, 'season', 0) . " + ( + type = :type AND - json_extract(meta, '$.episode') = " . (int)ag($entity->meta, 'episode', 0) . " + season = :season + AND + episode = :episode + ) AND ( " . implode(' OR ', $where) . " ) + LIMIT 1 "; - $cachedKey = md5($sql); + $cond['season'] = $entity->season; + $cond['episode'] = $entity->episode; + $cond['type'] = StateInterface::TYPE_EPISODE; - try { - if (null === ($this->stmt[$cachedKey] ?? null)) { - $this->stmt[$cachedKey] = $this->pdo->prepare($sql); - } + $stmt = $this->pdo->prepare($sql); - if (false === $this->stmt[$cachedKey]->execute($cond)) { - $this->stmt[$cachedKey] = null; - throw new StorageException('Failed to execute sql query.', 61); - } - - if (false === ($row = $this->stmt[$cachedKey]->fetch(PDO::FETCH_ASSOC))) { - return null; - } - - return $entity::fromArray($row); - } catch (PDOException|StorageException $e) { - $this->stmt[$cachedKey] = null; - throw $e; + if (false === $stmt->execute($cond)) { + throw new StorageException('Failed to execute sql query.', 61); } + + if (false === ($row = $stmt->fetch(PDO::FETCH_ASSOC))) { + return null; + } + + return $entity::fromArray($row); } /** @@ -447,50 +434,46 @@ final class PDOAdapter implements StorageInterface return $entity::fromArray($row); } - $cond = $where = []; + $guids = []; + $cond = [ + 'type' => $entity->type, + ]; foreach (array_keys(Guid::SUPPORTED) as $key) { - if (null === ($entity->{$key} ?? null)) { + if (null === ($entity->guids[$key] ?? null)) { continue; } - $where[] = "{$key} = :{$key}"; - $cond[$key] = $entity->{$key}; + $guids[] = "JSON_EXTRACT(guids,'$.{$key}') = :{$key}"; + $cond[$key] = $entity->guids[$key]; } if (empty($cond)) { return null; } - $sqlWhere = implode(' OR ', $where); + $sqlEpisode = ''; - $cachedKey = md5($sqlWhere . ($entity?->type ?? '')); - - try { - if (null === ($this->stmt[$cachedKey] ?? null)) { - $sqlType = ''; - - if (null !== ($entity?->type ?? null)) { - $sqlType = 'type = :s_type AND '; - $cond['s_type'] = $entity->type; - } - - $this->stmt[$cachedKey] = $this->pdo->prepare("SELECT * FROM state WHERE {$sqlType} {$sqlWhere}"); - } - - if (false === $this->stmt[$cachedKey]->execute($cond)) { - $this->stmt[$cachedKey] = null; - throw new StorageException('Failed to execute sql query.', 61); - } - - if (false === ($row = $this->stmt[$cachedKey]->fetch(PDO::FETCH_ASSOC))) { - return null; - } - - return $entity::fromArray($row); - } catch (PDOException|StorageException $e) { - $this->stmt[$cachedKey] = null; - throw $e; + if ($entity->isEpisode()) { + $sqlEpisode = ' AND season = :season AND episode = :episode '; + $cond['season'] = $entity->season; + $cond['episode'] = $entity->episode; } + + $sqlGuids = ' AND (' . implode(' OR ', $guids) . ' ) '; + + $sql = "SELECT * FROM state WHERE ( type = :type {$sqlEpisode} ) {$sqlGuids} LIMIT 1"; + + $stmt = $this->pdo->prepare($sql); + + if (false === $stmt->execute($cond)) { + throw new StorageException('Failed to execute sql query.', 61); + } + + if (false === ($row = $stmt->fetch(PDO::FETCH_ASSOC))) { + return null; + } + + return $entity::fromArray($row); } } diff --git a/src/Libs/helpers.php b/src/Libs/helpers.php index b89e3652..c26ffe36 100644 --- a/src/Libs/helpers.php +++ b/src/Libs/helpers.php @@ -419,7 +419,7 @@ if (!function_exists('arrayToString')) { } if (is_array($val)) { - $val = json_encode($val, flags: JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $val = '[ ' . arrayToString($val) . ' ]'; } else { $val = $val ?? 'None'; } diff --git a/tests/Fixtures/EpisodeEntity.php b/tests/Fixtures/EpisodeEntity.php index 51c9f3c7..45a0b643 100644 --- a/tests/Fixtures/EpisodeEntity.php +++ b/tests/Fixtures/EpisodeEntity.php @@ -7,29 +7,31 @@ use App\Libs\Entity\StateInterface; return [ 'id' => null, 'type' => StateInterface::TYPE_EPISODE, - 'updated' => 0, + 'updated' => 1, 'watched' => 1, - 'meta' => [ - 'via' => 'Plex@Home', - 'series' => 'Series Title', - 'year' => 2020, - 'season' => 1, - 'episode' => 2, + 'via' => 'Plex@Home', + 'title' => 'Series Title', + 'year' => 2020, + 'season' => 1, + 'episode' => 2, + 'parent' => [ + 'guid_imdb' => '510', + 'guid_tvdb' => '520', + ], + 'guids' => [ + 'guid_plex' => '6000', + 'guid_imdb' => '6100', + 'guid_tvdb' => '6200', + 'guid_tmdb' => '6300', + 'guid_tvmaze' => '6400', + 'guid_tvrage' => '6500', + 'guid_anidb' => '6600', + ], + 'extra' => [ 'title' => 'Episode Title', 'date' => '2020-01-03', 'webhook' => [ 'event' => 'media.scrobble' ], - 'parent' => [ - 'guid_imdb' => '510', - 'guid_tvdb' => '520', - ], ], - 'guid_plex' => StateInterface::TYPE_EPISODE . '/6000', - 'guid_imdb' => StateInterface::TYPE_EPISODE . '/6100', - 'guid_tvdb' => StateInterface::TYPE_EPISODE . '/6200', - 'guid_tmdb' => StateInterface::TYPE_EPISODE . '/6300', - 'guid_tvmaze' => StateInterface::TYPE_EPISODE . '/6400', - 'guid_tvrage' => StateInterface::TYPE_EPISODE . '/6500', - 'guid_anidb' => StateInterface::TYPE_EPISODE . '/6600', ]; diff --git a/tests/Fixtures/MovieEntity.php b/tests/Fixtures/MovieEntity.php index 95acbd78..3ec6a7ae 100644 --- a/tests/Fixtures/MovieEntity.php +++ b/tests/Fixtures/MovieEntity.php @@ -9,19 +9,25 @@ return [ 'type' => StateInterface::TYPE_MOVIE, 'updated' => 1, 'watched' => 1, - 'meta' => [ - 'via' => 'JF@Home', - 'title' => 'Movie Title', - 'year' => 2020, + 'via' => 'JF@Home', + 'title' => 'Movie Title', + 'year' => 2020, + 'season' => null, + 'episode' => null, + 'parent' => [], + 'guids' => [ + 'guid_plex' => '1000', + 'guid_imdb' => '1100', + 'guid_tvdb' => '1200', + 'guid_tmdb' => '1300', + 'guid_tvmaze' => '1400', + 'guid_tvrage' => '1500', + 'guid_anidb' => '1600', + ], + 'extra' => [ 'webhook' => [ 'event' => 'ItemAdded' ] ], - 'guid_plex' => StateInterface::TYPE_MOVIE . '/1000', - 'guid_imdb' => StateInterface::TYPE_MOVIE . '/1100', - 'guid_tvdb' => StateInterface::TYPE_MOVIE . '/1200', - 'guid_tmdb' => StateInterface::TYPE_MOVIE . '/1300', - 'guid_tvmaze' => StateInterface::TYPE_MOVIE . '/1400', - 'guid_tvrage' => StateInterface::TYPE_MOVIE . '/1500', - 'guid_anidb' => StateInterface::TYPE_MOVIE . '/1600', + ]; diff --git a/tests/Mappers/Import/DirectMapperTest.php b/tests/Mappers/Import/DirectMapperTest.php deleted file mode 100644 index 7969b872..00000000 --- a/tests/Mappers/Import/DirectMapperTest.php +++ /dev/null @@ -1,163 +0,0 @@ -output = new NullOutput(); - $this->input = new ArrayInput([]); - - $this->testMovie = require __DIR__ . '/../../Fixtures/MovieEntity.php'; - $this->testEpisode = require __DIR__ . '/../../Fixtures/EpisodeEntity.php'; - - $logger = new CliLogger($this->output); - - $this->storage = new PDOAdapter($logger, new PDO('sqlite::memory:')); - $this->storage->migrations('up'); - - $this->mapper = new DirectMapper($logger, $this->storage); - $this->mapper->setUp(['class' => new StateEntity([])]); - } - - public function test_add_conditions(): void - { - $testMovie = new StateEntity($this->testMovie); - $testEpisode = new StateEntity($this->testEpisode); - - // -- expect 0 as we have not modified or added new item yet. - $this->assertCount(0, $this->mapper); - - $this->mapper->add('test', 'test1', $testEpisode)->add('test', 'test2', $testMovie); - - $this->assertCount(2, $this->mapper); - - $this->assertSame( - [ - StateInterface::TYPE_MOVIE => ['added' => 1, 'updated' => 0, 'failed' => 0], - StateInterface::TYPE_EPISODE => ['added' => 1, 'updated' => 0, 'failed' => 0], - ], - $this->mapper->commit() - ); - - // -- assert 0 as we have committed the changes to the db, and the state should have been reset. - $this->assertCount(0, $this->mapper); - - $testEpisode->guid_tvrage = StateInterface::TYPE_EPISODE . '/2'; - - $this->mapper->add('test', 'test1', $testEpisode); - - $this->assertCount(1, $this->mapper); - } - - public function test_get_conditions(): void - { - $testMovie = new StateEntity($this->testMovie); - $testEpisode = new StateEntity($this->testEpisode); - - // -- expect null as we haven't added anything to db yet. - $this->assertNull($this->mapper->get($testEpisode)); - - $this->storage->commit([$testEpisode, $testMovie]); - - clone $testMovie2 = $testMovie; - clone $testEpisode2 = $testEpisode; - $testMovie2->id = 2; - $testEpisode2->id = 1; - - $this->assertSame($testEpisode2->getAll(), $this->mapper->get($testEpisode)->getAll()); - $this->assertSame($testMovie2->getAll(), $this->mapper->get($testMovie)->getAll()); - } - - public function test_remove_conditions(): void - { - $testMovie = new StateEntity($this->testMovie); - $testEpisode = new StateEntity($this->testEpisode); - - $this->assertFalse($this->mapper->remove($testEpisode)); - $this->mapper->add('test', 'episode', $testEpisode)->add('test', 'movie', $testMovie)->commit(); - $this->assertTrue($this->mapper->remove($testEpisode)); - } - - public function test_commit_conditions(): void - { - $testMovie = new StateEntity($this->testMovie); - $testEpisode = new StateEntity($this->testEpisode); - - // -- expect 0 as we have not modified or added new item yet. - $this->assertCount(0, $this->mapper); - - $this->mapper->add('test', 'test1', $testEpisode)->add('test', 'test2', $testMovie); - - $this->assertCount(2, $this->mapper); - - $this->assertSame( - [ - StateInterface::TYPE_MOVIE => ['added' => 1, 'updated' => 0, 'failed' => 0], - StateInterface::TYPE_EPISODE => ['added' => 1, 'updated' => 0, 'failed' => 0], - ], - $this->mapper->commit() - ); - - $this->assertSame( - [ - StateInterface::TYPE_MOVIE => ['added' => 0, 'updated' => 0, 'failed' => 0], - StateInterface::TYPE_EPISODE => ['added' => 0, 'updated' => 0, 'failed' => 0], - ], - $this->mapper->commit() - ); - - $testEpisode->guid_tvrage = StateInterface::TYPE_EPISODE . '/1'; - $testMovie->guid_tvrage = StateInterface::TYPE_MOVIE . '/1'; - - $this->mapper->add('test', 'test1', $testEpisode)->add('test', 'test2', $testMovie); - - $this->assertSame( - [ - StateInterface::TYPE_MOVIE => ['added' => 0, 'updated' => 1, 'failed' => 0], - StateInterface::TYPE_EPISODE => ['added' => 0, 'updated' => 1, 'failed' => 0], - ], - $this->mapper->commit() - ); - } - - public function test_has_conditions(): void - { - $testEpisode = new StateEntity($this->testEpisode); - $this->assertFalse($this->mapper->has($testEpisode)); - $this->storage->commit([$testEpisode]); - $this->assertTrue($this->mapper->has($testEpisode)); - } - - public function test_reset_conditions(): void - { - $testEpisode = new StateEntity($this->testEpisode); - $this->assertCount(0, $this->mapper); - - $this->mapper->add('test', 'episode', $testEpisode); - $this->assertCount(1, $this->mapper); - - $this->mapper->reset(); - $this->assertCount(0, $this->mapper); - } -} diff --git a/tests/Mappers/Import/MemoryMapperTest.php b/tests/Mappers/Import/MemoryMapperTest.php index cd3f8f44..fd604900 100644 --- a/tests/Mappers/Import/MemoryMapperTest.php +++ b/tests/Mappers/Import/MemoryMapperTest.php @@ -97,7 +97,7 @@ class MemoryMapperTest extends TestCase // -- assert 0 as we have committed the changes to the db, and the state should have been reset. $this->assertCount(0, $this->mapper); - $testEpisode->guid_tvrage = StateInterface::TYPE_EPISODE . '/2'; + $testEpisode->guids['guid_tvrage'] = '2'; $this->mapper->add('test', 'test1', $testEpisode); @@ -116,8 +116,19 @@ class MemoryMapperTest extends TestCase public function test_get_conditions(): void { - $testMovie = new StateEntity($this->testMovie); - $testEpisode = new StateEntity($this->testEpisode); + $movie = $this->testMovie; + $episode = $this->testEpisode; + + ksort($movie['parent']); + ksort($movie['guids']); + ksort($movie['extra']); + + ksort($episode['parent']); + ksort($episode['guids']); + ksort($episode['extra']); + + $testMovie = new StateEntity($movie); + $testEpisode = new StateEntity($episode); // -- expect null as we haven't added anything to db yet. $this->assertNull($this->mapper->get($testEpisode)); @@ -168,8 +179,8 @@ class MemoryMapperTest extends TestCase $this->mapper->commit() ); - $testMovie->guid_anidb = StateInterface::TYPE_MOVIE . '/1'; - $testEpisode->guid_anidb = StateInterface::TYPE_EPISODE . '/1'; + $testMovie->guids['guid_anidb'] = '1'; + $testEpisode->guids['guid_anidb'] = '1'; $this->assertSame( [ diff --git a/tests/Storage/PDOAdapterTest.php b/tests/Storage/PDOAdapterTest.php index 577fd22d..c2e14290 100644 --- a/tests/Storage/PDOAdapterTest.php +++ b/tests/Storage/PDOAdapterTest.php @@ -53,7 +53,13 @@ class PDOAdapterTest extends TestCase public function test_get_conditions(): void { - $item = new StateEntity($this->testEpisode); + $test = $this->testEpisode; + + ksort($test['parent']); + ksort($test['guids']); + ksort($test['extra']); + + $item = new StateEntity($test); // -- db should be empty at this stage. as such we expect null. $this->assertNull($this->storage->get($item)); @@ -100,7 +106,7 @@ class PDOAdapterTest extends TestCase public function test_update_conditions(): void { $item = $this->storage->insert(new StateEntity($this->testEpisode)); - $item->guid_plex = StateInterface::TYPE_EPISODE . '/1000'; + $item->guids['guid_plex'] = StateInterface::TYPE_EPISODE . '/1000'; $updatedItem = $this->storage->update($item); @@ -140,8 +146,8 @@ class PDOAdapterTest extends TestCase $this->storage->commit([$item1, $item2]) ); - $item1->guid_anidb = StateInterface::TYPE_EPISODE . '/1'; - $item2->guid_anidb = StateInterface::TYPE_MOVIE . '/1'; + $item1->guids['guid_anidb'] = StateInterface::TYPE_EPISODE . '/1'; + $item2->guids['guid_anidb'] = StateInterface::TYPE_MOVIE . '/1'; $this->assertSame( [ From 6f0592b41e7bf6e885aa8f78e445b02aa9342773 Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A" Date: Sun, 8 May 2022 18:11:09 +0300 Subject: [PATCH 03/13] Added a dry run mode for import and deep debug flag. --- src/Commands/State/ImportCommand.php | 29 +++++-- src/Libs/Mappers/Import/MemoryMapper.php | 100 +++++++++++++++------- src/Libs/Mappers/ImportInterface.php | 16 ++++ src/Libs/Storage/PDO/PDOAdapter.php | 102 +++++++++-------------- src/Libs/Storage/StorageInterface.php | 22 ++++- 5 files changed, 172 insertions(+), 97 deletions(-) diff --git a/src/Commands/State/ImportCommand.php b/src/Commands/State/ImportCommand.php index b9b4be77..444f1a5e 100644 --- a/src/Commands/State/ImportCommand.php +++ b/src/Commands/State/ImportCommand.php @@ -70,12 +70,6 @@ class ImportCommand extends Command 'Sync selected servers, comma seperated. \'s1,s2\'.', '' ) - ->addOption( - 'import-unwatched', - null, - InputOption::VALUE_NONE, - '--DEPRECATED-- will be removed in v1.x. We import the item regardless of watched/unwatched state.' - ) ->addOption('stats-show', null, InputOption::VALUE_NONE, 'Show final status.') ->addOption( 'stats-filter', @@ -84,6 +78,13 @@ class ImportCommand extends Command 'Filter final status output e.g. (servername.key)', null ) + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Do not commit any changes.') + ->addOption( + 'deep-debug', + null, + InputOption::VALUE_NONE, + 'You should not use this flag unless told by the team.' + ) ->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Use Alternative config file.'); } @@ -123,6 +124,22 @@ class ImportCommand extends Command $this->mapper->setLogger($logger); } + $mapperOpts = []; + + if ($input->getOption('dry-run')) { + $output->writeln('Dry run mode. No changes will be committed to backend.'); + + $mapperOpts[ImportInterface::DRY_RUN] = true; + } + + if ($input->getOption('deep-debug')) { + $mapperOpts[ImportInterface::DEEP_DEBUG] = true; + } + + if (!empty($mapperOpts)) { + $this->mapper->setUp($mapperOpts); + } + foreach (Config::get('servers', []) as $serverName => $server) { $type = strtolower(ag($server, 'type', 'unknown')); diff --git a/src/Libs/Mappers/Import/MemoryMapper.php b/src/Libs/Mappers/Import/MemoryMapper.php index b968bb6a..c1419587 100644 --- a/src/Libs/Mappers/Import/MemoryMapper.php +++ b/src/Libs/Mappers/Import/MemoryMapper.php @@ -9,6 +9,7 @@ use App\Libs\Entity\StateInterface; use App\Libs\Mappers\ImportInterface; use App\Libs\Storage\StorageInterface; use DateTimeInterface; +use PDOException; use Psr\Log\LoggerInterface; final class MemoryMapper implements ImportInterface @@ -74,7 +75,20 @@ final class MemoryMapper implements ImportInterface Data::increment($bucket, $entity->type . '_added'); $this->addPointers($this->objects[$pointer], $pointer); - $this->logger->debug(sprintf('Adding %s. As new Item.', $name)); + + if (true === ($this->options[ImportInterface::DEEP_DEBUG] ?? false)) { + $data = $entity->getAll(); + unset($data['id']); + $data['updated'] = makeDate($data['updated']); + $data['watched'] = 0 === $data['watched'] ? 'No' : 'Yes'; + if ($entity->isMovie()) { + unset($data['season'], $data['episode'], $data['parent']); + } + } else { + $data = []; + } + + $this->logger->info(sprintf('Adding %s. As new Item.', $name), $data); return $this; } @@ -82,21 +96,6 @@ final class MemoryMapper implements ImportInterface // -- Ignore old item. if (null !== ($opts['after'] ?? null) && ($opts['after'] instanceof DateTimeInterface)) { if ($opts['after']->getTimestamp() >= $entity->updated) { - $cloned = clone $this->objects[$pointer]; - // -- check for updated GUIDs. - if ($this->objects[$pointer]->apply($entity, guidOnly: true)->isChanged()) { - $this->changed[$pointer] = $pointer; - Data::increment($bucket, $entity->type . '_updated'); - $this->removePointers($cloned); - $this->addPointers($this->objects[$pointer], $pointer); - $this->logger->debug(sprintf('Updating %s. Parent & Entity GUIDs.', $name), [ - 'changes' => $this->objects[$pointer]->diff(), - ]); - - return $this; - } - - $this->logger->debug(sprintf('Ignoring %s. No change since last sync.', $name)); Data::increment($bucket, $entity->type . '_ignored_not_played_since_last_sync'); return $this; } @@ -110,13 +109,13 @@ final class MemoryMapper implements ImportInterface $this->changed[$pointer] = $pointer; $this->removePointers($cloned); $this->addPointers($this->objects[$pointer], $pointer); - $this->logger->debug(sprintf('Updating %s. State changed.', $name), [ - 'changes' => $this->objects[$pointer]->diff(all: true), - ]); + $this->logger->info( + sprintf('Updating %s. State changed.', $name), + $this->objects[$pointer]->diff(all: true), + ); return $this; } - $this->logger->debug(sprintf('Ignoring %s. State unchanged.', $name)); Data::increment($bucket, $entity->type . '_ignored_no_change'); return $this; @@ -152,9 +151,41 @@ final class MemoryMapper implements ImportInterface public function commit(): mixed { - $state = $this->storage->commit( - array_intersect_key($this->objects, $this->changed) - ); + $state = $this->storage->transactional(function (StorageInterface $storage) { + $list = [ + StateInterface::TYPE_MOVIE => ['added' => 0, 'updated' => 0, 'failed' => 0], + StateInterface::TYPE_EPISODE => ['added' => 0, 'updated' => 0, 'failed' => 0], + ]; + + $count = count($this->changed); + + $this->logger->notice( + 0 === $count ? 'No changes detected.' : sprintf('Updating backend with \'%d\' changes.', $count) + ); + + foreach ($this->changed as $pointer) { + try { + $entity = &$this->objects[$pointer]; + + if (null === $entity->id) { + if (false === (bool)($this->options[ImportInterface::DRY_RUN] ?? false)) { + $storage->insert($entity); + } + $list[$entity->type]['added']++; + } else { + if (false === (bool)($this->options[ImportInterface::DRY_RUN] ?? false)) { + $storage->update($entity); + } + $list[$entity->type]['updated']++; + } + } catch (PDOException $e) { + $list[$entity->type]['failed']++; + $this->logger->error($e->getMessage(), $entity->getAll()); + } + } + + return $list; + }); $this->reset(); @@ -202,6 +233,23 @@ final class MemoryMapper implements ImportInterface return $this; } + public function __destruct() + { + if (false === ($this->options['disable_autocommit'] ?? false) && $this->count() >= 1) { + $this->commit(); + } + } + + public function inDryRunMode(): bool + { + return true === ($this->options[ImportInterface::DRY_RUN] ?? false); + } + + public function inDeepDebugMode(): bool + { + return true === ($this->options[ImportInterface::DEEP_DEBUG] ?? false); + } + /** * Is the object already mapped? * @@ -243,10 +291,4 @@ final class MemoryMapper implements ImportInterface } } - public function __destruct() - { - if (false === ($this->options['disable_autocommit'] ?? false) && $this->count() >= 1) { - $this->commit(); - } - } } diff --git a/src/Libs/Mappers/ImportInterface.php b/src/Libs/Mappers/ImportInterface.php index e08adb07..ee7ba9af 100644 --- a/src/Libs/Mappers/ImportInterface.php +++ b/src/Libs/Mappers/ImportInterface.php @@ -12,6 +12,9 @@ use Psr\Log\LoggerInterface; interface ImportInterface extends Countable { + public const DEEP_DEBUG = 'deep-debug'; + public const DRY_RUN = 'dry-run'; + /** * Initiate Mapper. * @@ -117,4 +120,17 @@ interface ImportInterface extends Countable * @return self */ public function SetStorage(StorageInterface $storage): self; + + /** + * Are we in dry run mode? + * + * @return bool + */ + public function inDryRunMode(): bool; + + /** + * Are we in deep debug mode? + * @return bool + */ + public function inDeepDebugMode(): bool; } diff --git a/src/Libs/Storage/PDO/PDOAdapter.php b/src/Libs/Storage/PDO/PDOAdapter.php index b91b5292..f1216099 100644 --- a/src/Libs/Storage/PDO/PDOAdapter.php +++ b/src/Libs/Storage/PDO/PDOAdapter.php @@ -18,8 +18,7 @@ use Psr\Log\LoggerInterface; final class PDOAdapter implements StorageInterface { - private bool $viaCommit = false; - + private bool $viaTransaction = false; private bool $singleTransaction = false; /** @@ -49,9 +48,7 @@ final class PDOAdapter implements StorageInterface } if (null !== $data['id']) { - throw new StorageException( - sprintf('Trying to insert already saved entity #%s', $data['id']), 21 - ); + throw new StorageException(sprintf('Trying to insert already saved entity #%s', $data['id']), 21); } unset($data['id']); @@ -67,7 +64,7 @@ final class PDOAdapter implements StorageInterface $entity->id = (int)$this->pdo->lastInsertId(); } catch (PDOException $e) { $this->stmt['insert'] = null; - if (false === $this->viaCommit) { + if (false === $this->viaTransaction) { $this->logger->error($e->getMessage(), $entity->getAll()); return $entity; } @@ -135,7 +132,7 @@ final class PDOAdapter implements StorageInterface $this->stmt['update']->execute($data); } catch (PDOException $e) { $this->stmt['update'] = null; - if (false === $this->viaCommit) { + if (false === $this->viaTransaction) { $this->logger->error($e->getMessage(), $entity->getAll()); return $entity; } @@ -170,41 +167,31 @@ final class PDOAdapter implements StorageInterface return true; } - public function commit(array $entities): array + public function commit(array $entities, array $opts = []): array { - return $this->transactional(function () use ($entities) { - $list = [ - StateInterface::TYPE_MOVIE => ['added' => 0, 'updated' => 0, 'failed' => 0], - StateInterface::TYPE_EPISODE => ['added' => 0, 'updated' => 0, 'failed' => 0], - ]; - - $count = count($entities); - - $this->logger->notice( - 0 === $count ? 'No changes detected.' : sprintf('Updating database with \'%d\' changes.', $count) - ); - - $this->viaCommit = true; + $actions = [ + 'added' => 0, + 'updated' => 0, + 'failed' => 0, + ]; + return $this->transactional(function () use ($entities, $actions) { foreach ($entities as $entity) { try { if (null === $entity->id) { $this->insert($entity); - - $list[$entity->type]['added']++; + $actions['added']++; } else { $this->update($entity); - $list[$entity->type]['updated']++; + $actions['updated']++; } } catch (PDOException $e) { - $list[$entity->type]['failed']++; + $actions['failed']++; $this->logger->error($e->getMessage(), $entity->getAll()); } } - $this->viaCommit = false; - - return $list; + return $actions; }); } @@ -246,11 +233,6 @@ final class PDOAdapter implements StorageInterface return $this->pdo; } - /** - * Enable Single Transaction mode. - * - * @return bool - */ public function singleTransaction(): bool { $this->singleTransaction = true; @@ -263,6 +245,32 @@ final class PDOAdapter implements StorageInterface return $this->pdo->inTransaction(); } + public function transactional(Closure $callback): mixed + { + if (true === $this->pdo->inTransaction()) { + $this->viaTransaction = true; + $result = $callback($this); + $this->viaTransaction = false; + return $result; + } + + try { + $this->pdo->beginTransaction(); + + $this->viaTransaction = true; + $result = $callback($this); + $this->viaTransaction = false; + + $this->pdo->commit(); + + return $result; + } catch (PDOException $e) { + $this->pdo->rollBack(); + $this->viaTransaction = false; + throw $e; + } + } + /** * If we are using single transaction, * commit all changes on class destruction. @@ -276,34 +284,6 @@ final class PDOAdapter implements StorageInterface $this->stmt = []; } - /** - * Wrap Transaction. - * - * @param Closure(PDO): mixed $callback - * - * @return mixed - * @throws PDOException - */ - private function transactional(Closure $callback): mixed - { - if (true === $this->pdo->inTransaction()) { - return $callback($this->pdo); - } - - try { - $this->pdo->beginTransaction(); - - $result = $callback($this->pdo); - - $this->pdo->commit(); - - return $result; - } catch (PDOException $e) { - $this->pdo->rollBack(); - throw $e; - } - } - /** * Generate SQL Insert Statement. * diff --git a/src/Libs/Storage/StorageInterface.php b/src/Libs/Storage/StorageInterface.php index 6b63fbbb..5326bb30 100644 --- a/src/Libs/Storage/StorageInterface.php +++ b/src/Libs/Storage/StorageInterface.php @@ -5,8 +5,10 @@ declare(strict_types=1); namespace App\Libs\Storage; use App\Libs\Entity\StateInterface; +use Closure; use DateTimeInterface; use PDO; +use PDOException; use Psr\Log\LoggerInterface; use RuntimeException; @@ -66,10 +68,11 @@ interface StorageInterface * Insert/Update Entities. * * @param array $entities + * @param array $opts * * @return array */ - public function commit(array $entities): array; + public function commit(array $entities, array $opts = []): array; /** * Migrate Backend Storage Schema. @@ -122,4 +125,21 @@ interface StorageInterface * @throws RuntimeException if PDO is not initialized yet. */ public function getPdo(): PDO; + + /** + * Enable Single Transaction mode. + * + * @return bool + */ + public function singleTransaction(): bool; + + /** + * Wrap Queries into single transaction. + * + * @param Closure(StorageInterface): mixed $callback + * + * @return mixed + * @throws PDOException + */ + public function transactional(Closure $callback): mixed; } From 3078861e7c8b339d233bb87182067c215c2da014 Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A" Date: Sun, 8 May 2022 18:45:53 +0300 Subject: [PATCH 04/13] Reduced unnecessary logging messages. --- src/Libs/Servers/JellyfinServer.php | 24 +++++++++++------------- src/Libs/Servers/PlexServer.php | 17 +++++++---------- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/src/Libs/Servers/JellyfinServer.php b/src/Libs/Servers/JellyfinServer.php index 37c06ebc..665510c5 100644 --- a/src/Libs/Servers/JellyfinServer.php +++ b/src/Libs/Servers/JellyfinServer.php @@ -1434,19 +1434,17 @@ class JellyfinServer implements ServerInterface protected function processShow(StdClass $item, string $library): void { - $iName = sprintf( - '%s - %s - [%s (%d)]', - $this->name, - $library, - $item->Name ?? $item->OriginalTitle ?? '??', - $item->ProductionYear ?? 0000 - ); - - $this->logger->debug(sprintf('Processing %s. For GUIDs.', $iName)); - $providersId = (array)($item->ProviderIds ?? []); if (!$this->hasSupportedIds($providersId)) { + $iName = sprintf( + '%s - %s - [%s (%d)]', + $this->name, + $library, + $item->Name ?? $item->OriginalTitle ?? '??', + $item->ProductionYear ?? 0000 + ); + $message = sprintf('Ignoring %s. No valid/supported GUIDs.', $iName); if (empty($providersId)) { $message .= ' Most likely unmatched TV show.'; @@ -1492,12 +1490,12 @@ class JellyfinServer implements ServerInterface } else { $iName = trim( sprintf( - '%s - %s - [%s - (%dx%d)]', + '%s - %s - [%s - (%sx%s)]', $this->name, $library, $item->SeriesName ?? '??', - $item->ParentIndexNumber ?? 0, - $item->IndexNumber ?? 0, + str_pad((string)($item->ParentIndexNumber ?? 0), 2, '0', STR_PAD_LEFT), + str_pad((string)($item->IndexNumber ?? 0), 3, '0', STR_PAD_LEFT), ) ); } diff --git a/src/Libs/Servers/PlexServer.php b/src/Libs/Servers/PlexServer.php index a861f148..5dba1644 100644 --- a/src/Libs/Servers/PlexServer.php +++ b/src/Libs/Servers/PlexServer.php @@ -1493,16 +1493,6 @@ class PlexServer implements ServerInterface protected function processShow(StdClass $item, string $library): void { - $iName = sprintf( - '%s - %s - [%s (%d)]', - $this->name, - $library, - $item->title ?? $item->originalTitle ?? '??', - $item->year ?? 0000 - ); - - $this->logger->debug(sprintf('Processing %s. For GUIDs.', $iName)); - if (null === ($item->Guid ?? null)) { $item->Guid = [['id' => $item->guid]]; } else { @@ -1510,6 +1500,13 @@ class PlexServer implements ServerInterface } if (!$this->hasSupportedGuids($item->Guid, true)) { + $iName = sprintf( + '%s - %s - [%s (%d)]', + $this->name, + $library, + $item->title ?? $item->originalTitle ?? '??', + $item->year ?? 0000 + ); $message = sprintf('Ignoring %s. No valid/supported GUIDs.', $iName); if (empty($item->Guid)) { $message .= ' Most likely unmatched TV show.'; From 07b489166473fb82cadb8528b37466c7b475581f Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A" Date: Sun, 8 May 2022 23:51:56 +0300 Subject: [PATCH 05/13] padding season and episode number. --- src/Libs/Servers/PlexServer.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Libs/Servers/PlexServer.php b/src/Libs/Servers/PlexServer.php index 5dba1644..9c6a75a1 100644 --- a/src/Libs/Servers/PlexServer.php +++ b/src/Libs/Servers/PlexServer.php @@ -1554,12 +1554,12 @@ class PlexServer implements ServerInterface } else { $iName = trim( sprintf( - '%s - %s - [%s - (%dx%d)]', + '%s - %s - [%s - (%sx%s)]', $this->name, $library, $item->grandparentTitle ?? $item->originalTitle ?? '??', - $item->parentIndex ?? 0, - $item->index ?? 0, + str_pad((string)($item->parentIndex ?? 0), 2, '0', STR_PAD_LEFT), + str_pad((string)($item->index ?? 0), 3, '0', STR_PAD_LEFT), ) ); } From 96355ee32cc7f73bc081ee4e0eae6fa25fe70ae2 Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A" Date: Mon, 9 May 2022 00:02:10 +0300 Subject: [PATCH 06/13] Fixed failing test. --- tests/Storage/PDOAdapterTest.php | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/tests/Storage/PDOAdapterTest.php b/tests/Storage/PDOAdapterTest.php index c2e14290..2b61a2fe 100644 --- a/tests/Storage/PDOAdapterTest.php +++ b/tests/Storage/PDOAdapterTest.php @@ -139,10 +139,7 @@ class PDOAdapterTest extends TestCase $item2 = new StateEntity($this->testMovie); $this->assertSame( - [ - StateInterface::TYPE_MOVIE => ['added' => 1, 'updated' => 0, 'failed' => 0], - StateInterface::TYPE_EPISODE => ['added' => 1, 'updated' => 0, 'failed' => 0], - ], + ['added' => 2, 'updated' => 0, 'failed' => 0], $this->storage->commit([$item1, $item2]) ); @@ -150,10 +147,7 @@ class PDOAdapterTest extends TestCase $item2->guids['guid_anidb'] = StateInterface::TYPE_MOVIE . '/1'; $this->assertSame( - [ - StateInterface::TYPE_MOVIE => ['added' => 0, 'updated' => 1, 'failed' => 0], - StateInterface::TYPE_EPISODE => ['added' => 0, 'updated' => 1, 'failed' => 0], - ], + ['added' => 0, 'updated' => 2, 'failed' => 0], $this->storage->commit([$item1, $item2]) ); } From 3a44b5c5d8ff99d5d2ff714e774ed210fec9d874 Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A" Date: Mon, 9 May 2022 07:10:35 +0300 Subject: [PATCH 07/13] Updated backends webhook handling to support new database schema, And updated Emby webhook handler to include parent External ids to enable Relative external id support. --- src/Libs/Servers/EmbyServer.php | 243 ++++++++++++++------- src/Libs/Servers/JellyfinServer.php | 327 ++++++++++++++-------------- src/Libs/Servers/PlexServer.php | 257 ++++++++++------------ src/Libs/helpers.php | 19 +- 4 files changed, 463 insertions(+), 383 deletions(-) diff --git a/src/Libs/Servers/EmbyServer.php b/src/Libs/Servers/EmbyServer.php index 6c44dd41..33d7a6db 100644 --- a/src/Libs/Servers/EmbyServer.php +++ b/src/Libs/Servers/EmbyServer.php @@ -7,10 +7,14 @@ namespace App\Libs\Servers; use App\Libs\Config; use App\Libs\Container; use App\Libs\Entity\StateInterface; +use App\Libs\Guid; use App\Libs\HttpException; use DateTimeInterface; +use JsonException; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\UriInterface; +use Psr\Log\LoggerInterface; +use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; use Throwable; class EmbyServer extends JellyfinServer @@ -51,30 +55,39 @@ class EmbyServer extends JellyfinServer public static function processRequest(ServerRequestInterface $request): ServerRequestInterface { - $userAgent = ag($request->getServerParams(), 'HTTP_USER_AGENT', ''); + try { + $userAgent = ag($request->getServerParams(), 'HTTP_USER_AGENT', ''); - if (false === Config::get('webhook.debug', false) && !str_starts_with($userAgent, 'Emby Server/')) { - return $request; - } + if (false === str_starts_with($userAgent, 'Emby Server/')) { + return $request; + } - $payload = ag($request->getParsedBody() ?? [], 'data', null); + $payload = ag($request->getParsedBody() ?? [], 'data', null); - if (null === $payload || null === ($json = json_decode((string)$payload, true))) { - return $request; - } + if (null === $payload || null === ($json = json_decode((string)$payload, true))) { + return $request; + } - $attributes = [ - 'SERVER_ID' => ag($json, 'Server.Id', ''), - 'SERVER_NAME' => ag($json, 'Server.Name', ''), - 'SERVER_VERSION' => afterLast($userAgent, '/'), - 'USER_ID' => ag($json, 'User.Id', ''), - 'USER_NAME' => ag($json, 'User.Name', ''), - 'WH_EVENT' => ag($json, 'Event', 'not_set'), - 'WH_TYPE' => ag($json, 'Item.Type', 'not_set'), - ]; + $request = $request->withParsedBody($json); - foreach ($attributes as $key => $val) { - $request = $request->withAttribute($key, $val); + $attributes = [ + 'SERVER_ID' => ag($json, 'Server.Id', ''), + 'SERVER_NAME' => ag($json, 'Server.Name', ''), + 'SERVER_VERSION' => afterLast($userAgent, '/'), + 'USER_ID' => ag($json, 'User.Id', ''), + 'USER_NAME' => ag($json, 'User.Name', ''), + 'WH_EVENT' => ag($json, 'Event', 'not_set'), + 'WH_TYPE' => ag($json, 'Item.Type', 'not_set'), + ]; + + foreach ($attributes as $key => $val) { + $request = $request->withAttribute($key, $val); + } + } catch (Throwable $e) { + Container::get(LoggerInterface::class)->error($e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]); } return $request; @@ -82,9 +95,7 @@ class EmbyServer extends JellyfinServer public function parseWebhook(ServerRequestInterface $request): StateInterface { - $payload = ag($request->getParsedBody() ?? [], 'data', null); - - if (null === $payload || null === ($json = json_decode((string)$payload, true))) { + if (null === ($json = $request->getParsedBody())) { throw new HttpException(sprintf('%s: No payload.', afterLast(__CLASS__, '\\')), 400); } @@ -101,47 +112,16 @@ class EmbyServer extends JellyfinServer throw new HttpException(sprintf('%s: Not allowed event [%s]', afterLast(__CLASS__, '\\'), $event), 200); } - $isTainted = in_array($event, self::WEBHOOK_TAINTED_EVENTS); + $event = strtolower($event); - $meta = match ($type) { - StateInterface::TYPE_MOVIE => [ - 'via' => $this->name, - 'title' => ag($json, 'Item.Name', ag($json, 'Item.OriginalTitle', '??')), - 'year' => ag($json, 'Item.ProductionYear', 0000), - 'date' => makeDate( - ag( - $json, - 'Item.PremiereDate', - ag($json, 'Item.ProductionYear', ag($json, 'Item.DateCreated', 'now')) - ) - )->format('Y-m-d'), - 'webhook' => [ - 'event' => $event, - ], - ], - StateInterface::TYPE_EPISODE => [ - 'via' => $this->name, - 'series' => ag($json, 'Item.SeriesName', '??'), - 'year' => ag($json, 'Item.ProductionYear', 0000), - 'season' => ag($json, 'Item.ParentIndexNumber', 0), - 'episode' => ag($json, 'Item.IndexNumber', 0), - 'title' => ag($json, 'Item.Name', ag($json, 'Item.OriginalTitle', '??')), - 'date' => makeDate(ag($json, 'Item.PremiereDate', ag($json, 'Item.ProductionYear', 'now')))->format( - 'Y-m-d' - ), - 'webhook' => [ - 'event' => $event, - ], - ], - default => throw new HttpException(sprintf('%s: Invalid content type.', afterLast(__CLASS__, '\\')), 400), - }; + $isTainted = in_array($event, self::WEBHOOK_TAINTED_EVENTS); if ('item.markplayed' === $event || 'playback.scrobble' === $event) { $isWatched = 1; } elseif ('item.markunplayed' === $event) { $isWatched = 0; } else { - $isWatched = (int)(bool)ag($json, 'Item.Played', ag($json, 'Item.PlayedToCompletion', 0)); + $isWatched = (int)(bool)ag($json, ['Item.Played', 'Item.PlayedToCompletion'], 0); } $providersId = ag($json, 'Item.ProviderIds', []); @@ -150,35 +130,59 @@ class EmbyServer extends JellyfinServer 'type' => $type, 'updated' => time(), 'watched' => $isWatched, - 'meta' => $meta, - ...$this->getGuids($providersId) + 'via' => $this->name, + 'title' => '??', + 'year' => ag($json, 'Item.ProductionYear', 0000), + 'season' => null, + 'episode' => null, + 'parent' => [], + 'guids' => $this->getGuids($providersId), + 'extra' => [ + 'date' => makeDate( + ag($json, ['Item.PremiereDate', 'Item.ProductionYear', 'Item.DateCreated'], 'now') + )->format('Y-m-d'), + 'webhook' => [ + 'event' => $event, + ], + ], ]; + if (StateInterface::TYPE_MOVIE === $type) { + $row['title'] = ag($json, ['Item.Name', 'Item.OriginalTitle'], '??'); + } elseif (StateInterface::TYPE_EPISODE === $type) { + $row['title'] = ag($json, 'Item.SeriesName', '??'); + $row['season'] = ag($json, 'Item.ParentIndexNumber', 0); + $row['episode'] = ag($json, 'Item.IndexNumber', 0); + $row['extra']['title'] = ag($json, ['Item.Name', 'Item.OriginalTitle'], '??'); + + if (null !== ag($json, 'Item.SeriesId')) { + $row['parent'] = $this->getEpisodeParent(ag($json, 'Item.SeriesId')); + } + } else { + throw new HttpException(sprintf('%s: Invalid content type.', afterLast(__CLASS__, '\\')), 400); + } + $entity = Container::get(StateInterface::class)::fromArray($row)->setIsTainted($isTainted); - if (!$entity->hasGuids()) { - throw new HttpException( - sprintf( - '%s: No supported GUID was given. [%s]', - afterLast(__CLASS__, '\\'), - arrayToString( - [ - 'guids' => !empty($providersId) ? $providersId : 'None', - 'rGuids' => $entity->hasRelativeGuid() ? $entity->getRelativeGuids() : 'None', - ] - ) - ), 400 - ); + if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) { + $message = sprintf('%s: No valid/supported External ids.', afterLast(__CLASS__, '\\')); + + if (empty($providersId)) { + $message .= ' Most likely unmatched movie/episode or show.'; + } + + $message .= sprintf(' [%s].', arrayToString(['guids' => !empty($providersId) ? $providersId : 'None'])); + + throw new HttpException($message, 400); } - foreach ($entity->getPointers() as $guid) { - $this->cacheData[$guid] = ag($json, 'item.Id'); + foreach ([...$entity->getRelativePointers(), ...$entity->getPointers()] as $guid) { + $this->cacheData[$guid] = ag($json, 'Item.Id'); } - if (false === $isTainted && (true === Config::get('webhook.debug') || null !== ag( - $request->getQueryParams(), - 'debug' - ))) { + $savePayload = true === Config::get('webhook.debug') || null !== ag($request->getQueryParams(), 'debug'); + + if (false === $isTainted && $savePayload) { saveWebhookPayload($this->name . '.' . $event, $request, [ 'entity' => $entity->getAll(), 'payload' => $json, @@ -188,6 +192,12 @@ class EmbyServer extends JellyfinServer return $entity; } + /** + * @param array $entities + * @param DateTimeInterface|null $after + * @return array + * @TODO need to be updated to support cached items. + */ public function push(array $entities, DateTimeInterface|null $after = null): array { $requests = []; @@ -214,14 +224,12 @@ class EmbyServer extends JellyfinServer try { $guids = []; - foreach ($entity->getPointers() as $pointer) { - if (str_starts_with($pointer, 'guid_plex://')) { + foreach ($entity->guids ?? [] as $key => $val) { + if ('guid_plex' === $key) { continue; } - if (false === preg_match('#guid_(.+?)://\w+?/(.+)#s', $pointer, $matches)) { - continue; - } - $guids[] = sprintf('%s.%s', $matches[1], $matches[2]); + + $guids[] = sprintf('%s.%s', afterLast($key, 'guid_'), $val); } if (empty($guids)) { @@ -292,7 +300,6 @@ class EmbyServer extends JellyfinServer $isWatched = (int)(bool)ag($json, 'UserData.Played', false); - if ($state->watched === $isWatched) { $this->logger->debug(sprintf('Ignoring %s. State is unchanged.', $iName)); continue; @@ -341,4 +348,72 @@ class EmbyServer extends JellyfinServer return $stateRequests; } + private function getEpisodeParent(int|string $id): array + { + if (array_key_exists($id, $this->cacheShow)) { + return $this->cacheShow[$id]; + } + + try { + $response = $this->http->request( + 'GET', + (string)$this->url->withPath( + sprintf('/Users/%s/items/' . $id, $this->user) + ), + $this->getHeaders() + ); + + if (200 !== $response->getStatusCode()) { + return []; + } + + $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); + + if (null === ($itemType = ag($json, 'Type')) || 'Series' !== $itemType) { + return []; + } + + $providersId = (array)ag($json, 'ProviderIds', []); + + if (!$this->hasSupportedIds($providersId)) { + $this->cacheShow[$id] = []; + return $this->cacheShow[$id]; + } + + $guids = []; + + foreach (Guid::fromArray($this->getGuids($providersId))->getPointers() as $guid) { + [$type, $guid] = explode('://', $guid); + $guids[$type] = $guid; + } + + $this->cacheShow[$id] = $guids; + + return $this->cacheShow[$id]; + } catch (ExceptionInterface $e) { + $this->logger->error($e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); + return []; + } catch (JsonException $e) { + $this->logger->error( + sprintf('Unable to decode %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine() + ] + ); + return []; + } catch (Throwable $e) { + $this->logger->error( + sprintf('ERROR: %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine() + ] + ); + return []; + } + } } diff --git a/src/Libs/Servers/JellyfinServer.php b/src/Libs/Servers/JellyfinServer.php index 665510c5..c1067a9b 100644 --- a/src/Libs/Servers/JellyfinServer.php +++ b/src/Libs/Servers/JellyfinServer.php @@ -16,7 +16,6 @@ use App\Libs\Mappers\ImportInterface; use Closure; use DateInterval; use DateTimeInterface; -use Exception; use JsonException; use JsonMachine\Exception\PathNotFoundException; use JsonMachine\Items; @@ -233,32 +232,39 @@ class JellyfinServer implements ServerInterface public static function processRequest(ServerRequestInterface $request): ServerRequestInterface { - $userAgent = ag($request->getServerParams(), 'HTTP_USER_AGENT', ''); + try { + $userAgent = ag($request->getServerParams(), 'HTTP_USER_AGENT', ''); - if (false === Config::get('webhook.debug', false) && !str_starts_with($userAgent, 'Jellyfin-Server/')) { - return $request; - } + if (false === str_starts_with($userAgent, 'Jellyfin-Server/')) { + return $request; + } - $body = (string)$request->getBody(); + $body = (string)$request->getBody(); - if (null === ($json = json_decode($body, true))) { - return $request; - } + if (null === ($json = json_decode($body, true))) { + return $request; + } - $request = $request->withParsedBody($json); + $request = $request->withParsedBody($json); - $attributes = [ - 'SERVER_ID' => ag($json, 'ServerId', ''), - 'SERVER_NAME' => ag($json, 'ServerName', ''), - 'SERVER_VERSION' => afterLast($userAgent, '/'), - 'USER_ID' => ag($json, 'UserId', ''), - 'USER_NAME' => ag($json, 'NotificationUsername', ''), - 'WH_EVENT' => ag($json, 'NotificationType', 'not_set'), - 'WH_TYPE' => ag($json, 'ItemType', 'not_set'), - ]; + $attributes = [ + 'SERVER_ID' => ag($json, 'ServerId', ''), + 'SERVER_NAME' => ag($json, 'ServerName', ''), + 'SERVER_VERSION' => afterLast($userAgent, '/'), + 'USER_ID' => ag($json, 'UserId', ''), + 'USER_NAME' => ag($json, 'NotificationUsername', ''), + 'WH_EVENT' => ag($json, 'NotificationType', 'not_set'), + 'WH_TYPE' => ag($json, 'ItemType', 'not_set'), + ]; - foreach ($attributes as $key => $val) { - $request = $request->withAttribute($key, $val); + foreach ($attributes as $key => $val) { + $request = $request->withAttribute($key, $val); + } + } catch (Throwable $e) { + Container::get(LoggerInterface::class)->error($e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]); } return $request; @@ -285,29 +291,6 @@ class JellyfinServer implements ServerInterface $isTainted = in_array($event, self::WEBHOOK_TAINTED_EVENTS); - $meta = match ($type) { - StateInterface::TYPE_MOVIE => [ - 'via' => $this->name, - 'title' => ag($json, 'Name', '??'), - 'year' => ag($json, 'Year', 0000), - 'webhook' => [ - 'event' => $event, - ], - ], - StateInterface::TYPE_EPISODE => [ - 'via' => $this->name, - 'series' => ag($json, 'SeriesName', '??'), - 'year' => ag($json, 'Year', 0000), - 'season' => ag($json, 'SeasonNumber', 0), - 'episode' => ag($json, 'EpisodeNumber', 0), - 'title' => ag($json, 'Name', '??'), - 'webhook' => [ - 'event' => $event, - ], - ], - default => throw new HttpException(sprintf('%s: Invalid content type.', afterLast(__CLASS__, '\\')), 400), - }; - $providersId = []; foreach ($json as $key => $val) { @@ -317,44 +300,66 @@ class JellyfinServer implements ServerInterface $providersId[self::afterString($key, 'Provider_')] = $val; } - // We use SeriesName to overcome jellyfin webhook limitation, it does not send series id. - if (StateInterface::TYPE_EPISODE === $type && null !== ag($json, 'SeriesName')) { - $meta['parent'] = $this->getEpisodeParent(ag($json, 'ItemId'), ag($json, 'SeriesName')); - } - $row = [ 'type' => $type, 'updated' => time(), - 'watched' => (int)(bool)ag($json, 'Played', ag($json, 'PlayedToCompletion', 0)), - 'meta' => $meta, - ...$this->getGuids($providersId) + 'watched' => (int)(bool)ag($json, ['Played', 'PlayedToCompletion'], 0), + 'via' => $this->name, + 'title' => '??', + 'year' => ag($json, 'Year', 0000), + 'season' => null, + 'episode' => null, + 'parent' => [], + 'guids' => $this->getGuids($providersId), + 'extra' => [ + 'date' => makeDate($item->PremiereDate ?? $item->ProductionYear ?? 'now')->format('Y-m-d'), + 'webhook' => [ + 'event' => $event, + ], + ], ]; + if (StateInterface::TYPE_MOVIE === $type) { + $row['title'] = ag($json, ['Name', 'OriginalTitle'], '??'); + } elseif (StateInterface::TYPE_EPISODE === $type) { + $row['title'] = ag($json, 'SeriesName', '??'); + $row['season'] = ag($json, 'ParentIndexNumber', 0); + $row['episode'] = ag($json, 'IndexNumber', 0); + + if (null !== ($epTitle = ag($json, ['Name', 'OriginalTitle'], null))) { + $row['extra']['title'] = $epTitle; + } + + // -- We use SeriesName to overcome jellyfin webhook limitation, it does not send series id. + // -- it might lead to incorrect result if there is a show with duplicate name. + if (null !== ag($json, 'SeriesName')) { + $row['parent'] = $this->getEpisodeParent(ag($json, 'ItemId'), ag($json, 'SeriesName')); + } + } else { + throw new HttpException(sprintf('%s: Invalid content type.', afterLast(__CLASS__, '\\')), 400); + } + $entity = Container::get(StateInterface::class)::fromArray($row)->setIsTainted($isTainted); if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) { - throw new HttpException( - sprintf( - '%s: No supported GUID was given. [%s]', - afterLast(__CLASS__, '\\'), - arrayToString( - [ - 'guids' => !empty($providersId) ? $providersId : 'None', - 'rGuids' => $entity->hasRelativeGuid() ? $entity->getRelativeGuids() : 'None', - ] - ) - ), 400 - ); + $message = sprintf('%s: No valid/supported External ids.', afterLast(__CLASS__, '\\')); + + if (empty($providersId)) { + $message .= ' Most likely unmatched movie/episode or show.'; + } + + $message .= sprintf(' [%s].', arrayToString(['guids' => !empty($providersId) ? $providersId : 'None'])); + + throw new HttpException($message, 400); } foreach ([...$entity->getRelativePointers(), ...$entity->getPointers()] as $guid) { $this->cacheData[$guid] = ag($json, 'Item.ItemId'); } - if (false === $isTainted && (true === Config::get('webhook.debug') || null !== ag( - $request->getQueryParams(), - 'debug' - ))) { + $savePayload = true === Config::get('webhook.debug') || null !== ag($request->getQueryParams(), 'debug'); + + if (false === $isTainted && $savePayload) { saveWebhookPayload($this->name . '.' . $event, $request, [ 'entity' => $entity->getAll(), 'payload' => $json, @@ -364,99 +369,6 @@ class JellyfinServer implements ServerInterface return $entity; } - protected function getEpisodeParent(mixed $id, string|null $series): array - { - if (null !== $series && array_key_exists($series, $this->cacheShow)) { - return $this->cacheShow[$series]; - } - - try { - $response = $this->http->request( - 'GET', - (string)$this->url->withPath( - sprintf('/Users/%s/items/' . $id, $this->user) - ), - $this->getHeaders() - ); - - if (200 !== $response->getStatusCode()) { - return []; - } - - $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); - - if (null === ($type = ag($json, 'Type'))) { - return []; - } - - if (StateInterface::TYPE_EPISODE !== strtolower($type)) { - return []; - } - - if (null === ($seriesId = ag($json, 'SeriesId'))) { - return []; - } - - $response = $this->http->request( - 'GET', - (string)$this->url->withPath( - sprintf('/Users/%s/items/' . $seriesId, $this->user) - )->withQuery(http_build_query(['Fields' => 'ProviderIds'])), - $this->getHeaders() - ); - - if (200 !== $response->getStatusCode()) { - return []; - } - - $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); - - $series = $json['Name'] ?? $json['OriginalTitle'] ?? $json['Id'] ?? random_int(1, PHP_INT_MAX); - - $providersId = (array)ag($json, 'ProviderIds', []); - - if (!$this->hasSupportedIds($providersId)) { - $this->cacheShow[$series] = []; - return $this->cacheShow[$series]; - } - - $guids = []; - - foreach (Guid::fromArray($this->getGuids($providersId))->getPointers() as $guid) { - [$type, $id] = explode('://', $guid); - $guids[$type] = $id; - } - - $this->cacheShow[$series] = $guids; - - return $this->cacheShow[$series]; - } catch (ExceptionInterface $e) { - $this->logger->error($e->getMessage(), [ - 'file' => $e->getFile(), - 'line' => $e->getLine() - ]); - return []; - } catch (JsonException $e) { - $this->logger->error( - sprintf('Unable to decode %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine() - ] - ); - return []; - } catch (Exception $e) { - $this->logger->error( - sprintf('ERROR: %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine() - ] - ); - return []; - } - } - protected function getHeaders(): array { $opts = [ @@ -1683,4 +1595,97 @@ class JellyfinServer implements ServerInterface return $entity; } + + private function getEpisodeParent(mixed $id, string|null $series): array + { + if (null !== $series && array_key_exists($series, $this->cacheShow)) { + return $this->cacheShow[$series]; + } + + try { + $response = $this->http->request( + 'GET', + (string)$this->url->withPath( + sprintf('/Users/%s/items/' . $id, $this->user) + ), + $this->getHeaders() + ); + + if (200 !== $response->getStatusCode()) { + return []; + } + + $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); + + if (null === ($type = ag($json, 'Type'))) { + return []; + } + + if (StateInterface::TYPE_EPISODE !== strtolower($type)) { + return []; + } + + if (null === ($seriesId = ag($json, 'SeriesId'))) { + return []; + } + + $response = $this->http->request( + 'GET', + (string)$this->url->withPath( + sprintf('/Users/%s/items/' . $seriesId, $this->user) + )->withQuery(http_build_query(['Fields' => 'ProviderIds'])), + $this->getHeaders() + ); + + if (200 !== $response->getStatusCode()) { + return []; + } + + $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); + + $series = $json['Name'] ?? $json['OriginalTitle'] ?? $json['Id'] ?? random_int(1, PHP_INT_MAX); + + $providersId = (array)ag($json, 'ProviderIds', []); + + if (!$this->hasSupportedIds($providersId)) { + $this->cacheShow[$series] = []; + return $this->cacheShow[$series]; + } + + $guids = []; + + foreach (Guid::fromArray($this->getGuids($providersId))->getPointers() as $guid) { + [$type, $id] = explode('://', $guid); + $guids[$type] = $id; + } + + $this->cacheShow[$series] = $guids; + + return $this->cacheShow[$series]; + } catch (ExceptionInterface $e) { + $this->logger->error($e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); + return []; + } catch (JsonException $e) { + $this->logger->error( + sprintf('Unable to decode %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine() + ] + ); + return []; + } catch (Throwable $e) { + $this->logger->error( + sprintf('ERROR: %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine() + ] + ); + return []; + } + } } diff --git a/src/Libs/Servers/PlexServer.php b/src/Libs/Servers/PlexServer.php index 9c6a75a1..046b29b1 100644 --- a/src/Libs/Servers/PlexServer.php +++ b/src/Libs/Servers/PlexServer.php @@ -16,7 +16,6 @@ use App\Libs\Mappers\ImportInterface; use Closure; use DateInterval; use DateTimeInterface; -use Exception; use JsonException; use JsonMachine\Exception\PathNotFoundException; use JsonMachine\Items; @@ -263,7 +262,7 @@ class PlexServer implements ServerInterface try { $userAgent = ag($request->getServerParams(), 'HTTP_USER_AGENT', ''); - if (false === Config::get('webhook.debug', false) && !str_starts_with($userAgent, 'PlexMediaServer/')) { + if (false === str_starts_with($userAgent, 'PlexMediaServer/')) { return $request; } @@ -273,6 +272,8 @@ class PlexServer implements ServerInterface return $request; } + $request = $request->withParsedBody($json); + $attributes = [ 'SERVER_ID' => ag($json, 'Server.uuid', ''), 'SERVER_NAME' => ag($json, 'Server.title', ''), @@ -298,9 +299,7 @@ class PlexServer implements ServerInterface public function parseWebhook(ServerRequestInterface $request): StateInterface { - $payload = ag($request->getParsedBody() ?? [], 'payload', null); - - if (null === $payload || null === ($json = json_decode((string)$payload, true))) { + if (null === ($json = $request->getParsedBody())) { throw new HttpException(sprintf('%s: No payload.', afterLast(__CLASS__, '\\')), 400); } @@ -316,48 +315,21 @@ class PlexServer implements ServerInterface throw new HttpException(sprintf('%s: Not allowed event [%s]', afterLast(__CLASS__, '\\'), $event), 200); } - $isTainted = in_array($event, self::WEBHOOK_TAINTED_EVENTS); - - $ignoreIds = null; - - if (null !== ($this->options['ignore'] ?? null)) { - $ignoreIds = array_map(fn($v) => trim($v), explode(',', (string)$this->options['ignore'])); + if (null !== ($ignoreIds = ag($this->options, 'ignore', null))) { + $ignoreIds = array_map(fn($v) => trim($v), explode(',', (string)$ignoreIds)); } if (null !== $ignoreIds && in_array(ag($item, 'librarySectionID', '???'), $ignoreIds)) { throw new HttpException( sprintf( - '%s: Library id \'%s\' is ignored.', + '%s: Library id \'%s\' is ignored by user server config.', afterLast(__CLASS__, '\\'), ag($item, 'librarySectionID', '???') ), 200 ); } - $meta = match ($type) { - StateInterface::TYPE_MOVIE => [ - 'via' => $this->name, - 'title' => ag($item, 'title', ag($item, 'originalTitle', '??')), - 'year' => ag($item, 'year', 0000), - 'date' => makeDate(ag($item, 'originallyAvailableAt', 'now'))->format('Y-m-d'), - 'webhook' => [ - 'event' => $event, - ], - ], - StateInterface::TYPE_EPISODE => [ - 'via' => $this->name, - 'series' => ag($item, 'grandparentTitle', '??'), - 'year' => ag($item, 'year', 0000), - 'season' => ag($item, 'parentIndex', 0), - 'episode' => ag($item, 'index', 0), - 'title' => ag($item, 'title', ag($item, 'originalTitle', '??')), - 'date' => makeDate(ag($item, 'originallyAvailableAt', 'now'))->format('Y-m-d'), - 'webhook' => [ - 'event' => $event, - ], - ], - default => throw new HttpException(sprintf('%s: Invalid content type.', afterLast(__CLASS__, '\\')), 400), - }; + $isTainted = in_array($event, self::WEBHOOK_TAINTED_EVENTS); if (null === ag($item, 'Guid', null)) { $item['Guid'] = [['id' => ag($item, 'guid')]]; @@ -365,44 +337,61 @@ class PlexServer implements ServerInterface $item['Guid'][] = ['id' => ag($item, 'guid')]; } - if (StateInterface::TYPE_EPISODE === $type) { - $parentId = ag($item, 'grandparentRatingKey', fn() => ag($item, 'parentRatingKey')); - $meta['parent'] = null !== $parentId ? $this->getEpisodeParent($parentId) : []; - } - $row = [ 'type' => $type, 'updated' => time(), 'watched' => (int)(bool)ag($item, 'viewCount', 0), - 'meta' => $meta, - ...$this->getGuids(ag($item, 'Guid', []), isParent: false) + 'via' => $this->name, + 'title' => '??', + 'year' => (int)ag($item, ['grandParentYear', 'parentYear', 'year'], 0000), + 'season' => null, + 'episode' => null, + 'parent' => [], + 'guids' => $this->getGuids(ag($item, 'Guid', []), isParent: false), + 'extra' => [ + 'date' => makeDate(ag($item, 'originallyAvailableAt', 'now'))->format('Y-m-d'), + 'webhook' => [ + 'event' => $event, + ], + ], ]; + if (StateInterface::TYPE_MOVIE === $type) { + $row['title'] = ag($item, ['title', 'originalTitle'], '??'); + } elseif (StateInterface::TYPE_EPISODE === $type) { + $row['title'] = ag($item, 'grandparentTitle', '??'); + $row['season'] = ag($item, 'parentIndex', 0); + $row['episode'] = ag($item, 'index', 0); + $row['extra']['title'] = ag($item, ['title', 'originalTitle'], '??'); + + if (null !== ($parentId = ag($item, ['grandparentRatingKey', 'parentRatingKey'], null))) { + $row['parent'] = $this->getEpisodeParent($parentId); + } + } else { + throw new HttpException(sprintf('%s: Invalid content type.', afterLast(__CLASS__, '\\')), 400); + } + $entity = Container::get(StateInterface::class)::fromArray($row)->setIsTainted($isTainted); if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) { - throw new HttpException( - sprintf( - '%s: No supported GUID was given. [%s]', - afterLast(__CLASS__, '\\'), - arrayToString( - [ - 'guids' => !empty($item['Guid']) ? $item['Guid'] : 'None', - 'rGuids' => $entity->hasRelativeGuid() ? $entity->getRelativeGuids() : 'None', - ] - ) - ), 400 - ); + $message = sprintf('%s: No valid/supported External ids.', afterLast(__CLASS__, '\\')); + + if (empty($item['Guid'])) { + $message .= ' Most likely unmatched movie/episode or show.'; + } + + $message .= sprintf(' [%s].', arrayToString(['guids' => ag($item, 'Guid', 'None')])); + + throw new HttpException($message, 400); } foreach ([...$entity->getRelativePointers(), ...$entity->getPointers()] as $guid) { $this->cacheData[$guid] = ag($item, 'guid'); } - if (false !== $isTainted && (true === Config::get('webhook.debug') || null !== ag( - $request->getQueryParams(), - 'debug' - ))) { + $savePayload = true === Config::get('webhook.debug') || null !== ag($request->getQueryParams(), 'debug'); + + if (false !== $isTainted && $savePayload) { saveWebhookPayload($this->name . '.' . $event, $request, [ 'entity' => $entity->getAll(), 'payload' => $json, @@ -412,83 +401,6 @@ class PlexServer implements ServerInterface return $entity; } - protected function getEpisodeParent(int|string $id): array - { - if (array_key_exists($id, $this->cacheShow)) { - return $this->cacheShow[$id]; - } - - try { - $response = $this->http->request( - 'GET', - (string)$this->url->withPath('/library/metadata/' . $id), - $this->getHeaders() - ); - - if (200 !== $response->getStatusCode()) { - return []; - } - - $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); - - $json = ag($json, 'MediaContainer.Metadata')[0] ?? []; - - if (null === ($type = ag($json, 'type'))) { - return []; - } - - if ('show' !== strtolower($type)) { - return []; - } - - if (null === ($json['Guid'] ?? null)) { - $json['Guid'] = [['id' => $json['guid']]]; - } else { - $json['Guid'][] = ['id' => $json['guid']]; - } - - if (!$this->hasSupportedGuids($json['Guid'], true)) { - $this->cacheShow[$id] = []; - return $this->cacheShow[$id]; - } - - $guids = []; - - foreach (Guid::fromArray($this->getGuids($json['Guid'], isParent: true))->getPointers() as $guid) { - [$type, $id] = explode('://', $guid); - $guids[$type] = $id; - } - - $this->cacheShow[$id] = $guids; - - return $this->cacheShow[$id]; - } catch (ExceptionInterface $e) { - $this->logger->error($e->getMessage(), [ - 'file' => $e->getFile(), - 'line' => $e->getLine() - ]); - return []; - } catch (JsonException $e) { - $this->logger->error( - sprintf('Unable to decode %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine() - ] - ); - return []; - } catch (Exception $e) { - $this->logger->error( - sprintf('ERROR: %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine() - ] - ); - return []; - } - } - private function getHeaders(): array { $opts = [ @@ -1897,4 +1809,77 @@ class PlexServer implements ServerInterface return $entity; } + + private function getEpisodeParent(int|string $id): array + { + if (array_key_exists($id, $this->cacheShow)) { + return $this->cacheShow[$id]; + } + + try { + $response = $this->http->request( + 'GET', + (string)$this->url->withPath('/library/metadata/' . $id), + $this->getHeaders() + ); + + if (200 !== $response->getStatusCode()) { + return []; + } + + $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); + + $json = ag($json, 'MediaContainer.Metadata')[0] ?? []; + + if (null === ($type = ag($json, 'type')) || 'show' !== $type) { + return []; + } + + if (null === ($json['Guid'] ?? null)) { + $json['Guid'] = [['id' => $json['guid']]]; + } else { + $json['Guid'][] = ['id' => $json['guid']]; + } + + if (!$this->hasSupportedGuids($json['Guid'], true)) { + $this->cacheShow[$id] = []; + return $this->cacheShow[$id]; + } + + $guids = []; + + foreach (Guid::fromArray($this->getGuids($json['Guid'], isParent: true))->getPointers() as $guid) { + [$type, $id] = explode('://', $guid); + $guids[$type] = $id; + } + + $this->cacheShow[$id] = $guids; + + return $this->cacheShow[$id]; + } catch (ExceptionInterface $e) { + $this->logger->error($e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); + return []; + } catch (JsonException $e) { + $this->logger->error( + sprintf('Unable to decode %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine() + ] + ); + return []; + } catch (Throwable $e) { + $this->logger->error( + sprintf('ERROR: %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine() + ] + ); + return []; + } + } } diff --git a/src/Libs/helpers.php b/src/Libs/helpers.php index c26ffe36..46dba6be 100644 --- a/src/Libs/helpers.php +++ b/src/Libs/helpers.php @@ -68,12 +68,27 @@ if (!function_exists('makeDate')) { } if (!function_exists('ag')) { - function ag(array $array, string|null $path, mixed $default = null, string $separator = '.'): mixed + function ag(array|object $array, string|array|null $path, mixed $default = null, string $separator = '.'): mixed { - if (null === $path) { + if (empty($path)) { return $array; } + if (!is_array($array)) { + $array = get_object_vars($array); + } + + if (is_array($path)) { + foreach ($path as $key) { + $val = ag($array, $key, '_not_set'); + if ('_not_set' === $val) { + continue; + } + return $val; + } + return getValue($default); + } + if (array_key_exists($path, $array)) { return $array[$path]; } From a31f7f895b4b3e0e73174d37b0309defea3594a3 Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A" Date: Tue, 10 May 2022 07:31:38 +0300 Subject: [PATCH 08/13] Fully migrated plex to use new database schema. --- src/Libs/Entity/StateEntity.php | 17 +- src/Libs/Entity/StateInterface.php | 28 +- src/Libs/Guid.php | 139 +++--- src/Libs/Servers/PlexServer.php | 739 ++++++++++++++++------------- 4 files changed, 516 insertions(+), 407 deletions(-) diff --git a/src/Libs/Entity/StateEntity.php b/src/Libs/Entity/StateEntity.php index 22073fea..632cd73a 100644 --- a/src/Libs/Entity/StateEntity.php +++ b/src/Libs/Entity/StateEntity.php @@ -108,16 +108,15 @@ final class StateEntity implements StateInterface public function getName(): string { if ($this->isMovie()) { - return sprintf('%s (%d) - @%s', $this->title ?? '??', $this->year ?? 0000, $this->via ?? '??'); + return sprintf('%s (%d)', $this->title ?? '??', $this->year ?? 0000); } return sprintf( - '%s (%s) - %sx%s - @%s', + '%s (%s) - %sx%s', $this->title ?? '??', $this->year ?? 0000, str_pad((string)($this->season ?? 0), 2, '0', STR_PAD_LEFT), - str_pad((string)($this->episode ?? 0), 3, '0', STR_PAD_LEFT), - $this->via ?? '??', + str_pad((string)($this->episode ?? 0), 3, '0', STR_PAD_LEFT) ); } @@ -149,6 +148,11 @@ final class StateEntity implements StateInterface return count($this->guids) >= 1; } + public function getGuids(): array + { + return $this->guids; + } + public function hasParentGuid(): bool { return count($this->parent) >= 1; @@ -169,6 +173,11 @@ final class StateEntity implements StateInterface return StateInterface::TYPE_EPISODE === $this->type; } + public function isWatched(): bool + { + return 1 === $this->watched; + } + public function hasRelativeGuid(): bool { return $this->isEpisode() && !empty($this->parent) && null !== $this->season && null !== $this->episode; diff --git a/src/Libs/Entity/StateInterface.php b/src/Libs/Entity/StateInterface.php index 7836cbda..82e8e9f9 100644 --- a/src/Libs/Entity/StateInterface.php +++ b/src/Libs/Entity/StateInterface.php @@ -70,14 +70,21 @@ interface StateInterface public function isChanged(): bool; /** - * Does the entity have GUIDs? + * Does the entity have external ids? * * @return bool */ public function hasGuids(): bool; /** - * Does the entity have Relative GUIDs? + * Get List of external ids. + * + * @return array + */ + public function getGuids(): array; + + /** + * Does the entity have relative external ids? * * @return bool */ @@ -98,14 +105,14 @@ interface StateInterface public function getRelativePointers(): array; /** - * Does the Entity have Parent IDs? + * Does the Entity have Parent external ids? * * @return bool */ public function hasParentGuid(): bool; /** - * Get Parent GUIDs. + * Get Parent external ids. * * @return array */ @@ -125,6 +132,13 @@ interface StateInterface */ public function isEpisode(): bool; + /** + * Is entity marked as watched? + * + * @return bool + */ + public function isWatched(): bool; + /** * Get constructed name. * @@ -133,7 +147,7 @@ interface StateInterface public function getName(): string; /** - * Get GUID Pointers. + * Get external ids Pointers. * * @return array */ @@ -167,8 +181,8 @@ interface StateInterface * The Tainted flag control whether we will change state or not. * If the entity is not already stored in the database, then this flag is not used. * However, if the entity already exists and the flag is set to **true**, then - * we will be checking **GUIDs** only, and if those differ then meta will be updated as well. - * otherwise, nothing will be changed, This flag serve to update GUIDs via webhook unhelpful events like + * we will be checking **external ids** only, and if those differ {@see ENTITY_IGNORE_DIFF_CHANGES} will be updated + * as well, otherwise, nothing will be changed, This flag serve to update GUIDs via webhook unhelpful events like * play/stop/resume. * * @param bool $isTainted diff --git a/src/Libs/Guid.php b/src/Libs/Guid.php index 464083be..09783173 100644 --- a/src/Libs/Guid.php +++ b/src/Libs/Guid.php @@ -4,12 +4,11 @@ declare(strict_types=1); namespace App\Libs; +use JsonException; use RuntimeException; final class Guid { - public const LOOKUP_KEY = '%s://%s'; - public const GUID_PLEX = 'guid_plex'; public const GUID_IMDB = 'guid_imdb'; public const GUID_TVDB = 'guid_tvdb'; @@ -19,37 +18,101 @@ final class Guid public const GUID_ANIDB = 'guid_anidb'; public const SUPPORTED = [ - self::GUID_PLEX => 'string', - self::GUID_IMDB => 'string', - self::GUID_TVDB => 'string', - self::GUID_TMDB => 'string', - self::GUID_TVMAZE => 'string', - self::GUID_TVRAGE => 'string', - self::GUID_ANIDB => 'string', + Guid::GUID_PLEX => 'string', + Guid::GUID_IMDB => 'string', + Guid::GUID_TVDB => 'string', + Guid::GUID_TMDB => 'string', + Guid::GUID_TVMAZE => 'string', + Guid::GUID_TVRAGE => 'string', + Guid::GUID_ANIDB => 'string', ]; + private const LOOKUP_KEY = '%s://%s'; + private array $data = []; + /** + * Create List of db => external id list. + * + * @param array $guids Key/value pair of db => external id. For example, [ "guid_imdb" => "tt123456789" ] + * + * @throws RuntimeException if key/value is of unexpected type or unsupported. + */ public function __construct(array $guids) { foreach ($guids as $key => $value) { - if (null === $value || null === (self::SUPPORTED[$key] ?? null)) { + if (null === $value || null === (Guid::SUPPORTED[$key] ?? null)) { continue; } - $this->updateGuid($key, $value); + + if ($value === ($this->data[$key] ?? null)) { + continue; + } + + if (!is_string($key)) { + throw new RuntimeException( + sprintf( + 'Unexpected offset type was given. Was expecting \'string\' but got \'%s\' instead.', + get_debug_type($key) + ), + ); + } + + if (null === (Guid::SUPPORTED[$key] ?? null)) { + throw new RuntimeException( + sprintf( + 'Unexpected key. Was expecting one of \'%s\', but got \'%s\' instead.', + implode(', ', array_keys(Guid::SUPPORTED)), + $key + ), + ); + } + + if (Guid::SUPPORTED[$key] !== ($valueType = get_debug_type($value))) { + throw new RuntimeException( + sprintf( + 'Unexpected value type for \'%s\'. Was Expecting \'%s\' but got \'%s\' instead.', + $key, + Guid::SUPPORTED[$key], + $valueType + ) + ); + } + + $this->data[$key] = $value; } } - public static function fromArray(array $guids): self + /** + * Create new instance from array payload. + * + * @param array $payload Key/value pair of db => external id. For example, [ "guid_imdb" => "tt123456789" ] + * + * @return static + */ + public static function fromArray(array $payload): self { - return new self($guids); + return new self($payload); } - public static function fromJson(string $guids): self + /** + * Create new instance from json payload. + * + * @param string $payload Key/value pair of db => external id. For example, { "guid_imdb" : "tt123456789" } + * + * @return static + * @throws JsonException if decoding JSON payload fails. + */ + public static function fromJson(string $payload): self { - return new self(json_decode($guids, true)); + return new self(json_decode(json: $payload, associative: true, flags: JSON_THROW_ON_ERROR)); } + /** + * Return suitable pointers to link entity to external id. + * + * @return array + */ public function getPointers(): array { $arr = []; @@ -61,47 +124,13 @@ final class Guid return $arr; } - public function getGuids(): array + /** + * Return list of External ids. + * + * @return array + */ + public function getAll(): array { return $this->data; } - - private function updateGuid(mixed $key, mixed $value): void - { - if ($value === ($this->data[$key] ?? null)) { - return; - } - - if (!is_string($key)) { - throw new RuntimeException( - sprintf( - 'Unexpected offset type was given. Was expecting \'string\' but got \'%s\' instead.', - get_debug_type($key) - ), - ); - } - - if (null === (self::SUPPORTED[$key] ?? null)) { - throw new RuntimeException( - sprintf( - 'Unexpected offset key. Was expecting one of \'%s\', but got \'%s\' instead.', - implode(', ', array_keys(self::SUPPORTED)), - $key - ), - ); - } - - if (self::SUPPORTED[$key] !== ($valueType = get_debug_type($value))) { - throw new RuntimeException( - sprintf( - 'Unexpected value type for \'%s\'. Was Expecting \'%s\' but got \'%s\' instead.', - $key, - self::SUPPORTED[$key], - $valueType - ) - ); - } - - $this->data[$key] = $value; - } } diff --git a/src/Libs/Servers/PlexServer.php b/src/Libs/Servers/PlexServer.php index 046b29b1..a69d5e4e 100644 --- a/src/Libs/Servers/PlexServer.php +++ b/src/Libs/Servers/PlexServer.php @@ -36,6 +36,8 @@ use Throwable; class PlexServer implements ServerInterface { + public const NAME = 'PlexBackend'; + protected const GUID_MAPPER = [ 'plex' => Guid::GUID_PLEX, 'imdb' => Guid::GUID_IMDB, @@ -123,7 +125,7 @@ class PlexServer implements ServerInterface $cloned->uuid = $uuid; $cloned->options = $options; $cloned->persist = $persist; - $cloned->cacheKey = $opts['cache_key'] ?? md5(__CLASS__ . '.' . $name . $url); + $cloned->cacheKey = $options['cache_key'] ?? md5(__CLASS__ . '.' . $name . $url); $cloned->cacheShowKey = $cloned->cacheKey . '_show'; if ($cloned->cache->has($cloned->cacheKey)) { @@ -147,19 +149,16 @@ class PlexServer implements ServerInterface $this->checkConfig(); - $this->logger->debug( - sprintf('Requesting server Unique id info from %s.', $this->name), - ['url' => $this->url->getHost()] - ); - $url = $this->url->withPath('/'); + $this->logger->debug(sprintf('%s: Requesting server Unique id.', $this->name), ['url' => $url]); + $response = $this->http->request('GET', (string)$url, $this->getHeaders()); if (200 !== $response->getStatusCode()) { $this->logger->error( sprintf( - 'Request to %s responded with unexpected code (%d).', + '%s: Request to get server unique id responded with unexpected http status code \'%d\'.', $this->name, $response->getStatusCode() ) @@ -179,11 +178,8 @@ class PlexServer implements ServerInterface { $this->checkConfig(checkUrl: false); - $url = Container::getNew(UriInterface::class)->withPort(443)->withScheme('https')->withHost( - 'plex.tv' - )->withPath( - '/api/v2/home/users/' - ); + $url = Container::getNew(UriInterface::class)->withPort(443)->withScheme('https')->withHost('plex.tv') + ->withPath('/api/v2/home/users/'); $response = $this->http->request('GET', (string)$url, [ 'headers' => [ @@ -300,7 +296,7 @@ class PlexServer implements ServerInterface public function parseWebhook(ServerRequestInterface $request): StateInterface { if (null === ($json = $request->getParsedBody())) { - throw new HttpException(sprintf('%s: No payload.', afterLast(__CLASS__, '\\')), 400); + throw new HttpException(sprintf('%s: No payload.', self::NAME), 400); } $item = ag($json, 'Metadata', []); @@ -308,11 +304,11 @@ class PlexServer implements ServerInterface $event = ag($json, 'event', null); if (null === $type || !in_array($type, self::WEBHOOK_ALLOWED_TYPES)) { - throw new HttpException(sprintf('%s: Not allowed type [%s]', afterLast(__CLASS__, '\\'), $type), 200); + throw new HttpException(sprintf('%s: Not allowed type [%s]', self::NAME, $type), 200); } if (null === $event || !in_array($event, self::WEBHOOK_ALLOWED_EVENTS)) { - throw new HttpException(sprintf('%s: Not allowed event [%s]', afterLast(__CLASS__, '\\'), $event), 200); + throw new HttpException(sprintf('%s: Not allowed event [%s]', self::NAME, $event), 200); } if (null !== ($ignoreIds = ag($this->options, 'ignore', null))) { @@ -323,7 +319,7 @@ class PlexServer implements ServerInterface throw new HttpException( sprintf( '%s: Library id \'%s\' is ignored by user server config.', - afterLast(__CLASS__, '\\'), + self::NAME, ag($item, 'librarySectionID', '???') ), 200 ); @@ -340,7 +336,7 @@ class PlexServer implements ServerInterface $row = [ 'type' => $type, 'updated' => time(), - 'watched' => (int)(bool)ag($item, 'viewCount', 0), + 'watched' => (int)(bool)ag($item, 'viewCount', false), 'via' => $this->name, 'title' => '??', 'year' => (int)ag($item, ['grandParentYear', 'parentYear', 'year'], 0000), @@ -356,9 +352,7 @@ class PlexServer implements ServerInterface ], ]; - if (StateInterface::TYPE_MOVIE === $type) { - $row['title'] = ag($item, ['title', 'originalTitle'], '??'); - } elseif (StateInterface::TYPE_EPISODE === $type) { + if (StateInterface::TYPE_EPISODE === $type) { $row['title'] = ag($item, 'grandparentTitle', '??'); $row['season'] = ag($item, 'parentIndex', 0); $row['episode'] = ag($item, 'index', 0); @@ -367,17 +361,19 @@ class PlexServer implements ServerInterface if (null !== ($parentId = ag($item, ['grandparentRatingKey', 'parentRatingKey'], null))) { $row['parent'] = $this->getEpisodeParent($parentId); } + } elseif (StateInterface::TYPE_MOVIE === $type) { + $row['title'] = ag($item, ['title', 'originalTitle'], '??'); } else { - throw new HttpException(sprintf('%s: Invalid content type.', afterLast(__CLASS__, '\\')), 400); + throw new HttpException(sprintf('%s: Invalid content type.', self::NAME), 200); } $entity = Container::get(StateInterface::class)::fromArray($row)->setIsTainted($isTainted); if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) { - $message = sprintf('%s: No valid/supported External ids.', afterLast(__CLASS__, '\\')); + $message = sprintf('%s: No valid/supported external ids.', self::NAME); if (empty($item['Guid'])) { - $message .= ' Most likely unmatched movie/episode or show.'; + $message .= ' Most likely unmatched item.'; } $message .= sprintf(' [%s].', arrayToString(['guids' => ag($item, 'Guid', 'None')])); @@ -386,16 +382,13 @@ class PlexServer implements ServerInterface } foreach ([...$entity->getRelativePointers(), ...$entity->getPointers()] as $guid) { - $this->cacheData[$guid] = ag($item, 'guid'); + $this->cacheData[$guid] = ag($item, 'ratingKey'); } $savePayload = true === Config::get('webhook.debug') || null !== ag($request->getQueryParams(), 'debug'); if (false !== $isTainted && $savePayload) { - saveWebhookPayload($this->name . '.' . $event, $request, [ - 'entity' => $entity->getAll(), - 'payload' => $json, - ]); + saveWebhookPayload($this->name . '.' . $event, $request, $entity->getAll()); } return $entity; @@ -418,19 +411,18 @@ class PlexServer implements ServerInterface $this->checkConfig(); try { - $this->logger->debug( - sprintf('Requesting libraries From %s.', $this->name), - ['url' => $this->url->getHost()] - ); - $url = $this->url->withPath('/library/sections'); + $this->logger->debug(sprintf('%s: Requesting list of server libraries.', $this->name), [ + 'url' => (string)$url + ]); + $response = $this->http->request('GET', (string)$url, $this->getHeaders()); if (200 !== $response->getStatusCode()) { $this->logger->error( sprintf( - 'Request to %s responded with unexpected code (%d).', + '%s: Request to get list of server libraries responded with unexpected code \'%d\'.', $this->name, $response->getStatusCode() ) @@ -439,28 +431,35 @@ class PlexServer implements ServerInterface return []; } - $json = json_decode($response->getContent(false), true, flags: JSON_THROW_ON_ERROR); + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); $listDirs = ag($json, 'MediaContainer.Directory', []); if (empty($listDirs)) { - $this->logger->notice(sprintf('No libraries found at %s.', $this->name)); + $this->logger->notice( + sprintf('%s: Request to get list of server libraries responded with empty list.', $this->name) + ); Data::add($this->name, 'no_import_update', true); return []; } } catch (ExceptionInterface $e) { $this->logger->error( - sprintf('Request to %s failed. Reason: \'%s\'.', $this->name, $e->getMessage()), + sprintf('%s: Request to get server libraries failed. %s', $this->name, $e->getMessage()), [ 'file' => $e->getFile(), 'line' => $e->getLine(), + 'kind' => get_class($e), ], ); Data::add($this->name, 'no_import_update', true); return []; } catch (JsonException $e) { $this->logger->error( - sprintf('Unable to decode %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), + sprintf('%s: Unable to decode get server libraries JSON response. %s', $this->name, $e->getMessage()), [ 'file' => $e->getFile(), 'line' => $e->getLine(), @@ -470,10 +469,8 @@ class PlexServer implements ServerInterface return []; } - $ignoreIds = null; - - if (null !== ($this->options['ignore'] ?? null)) { - $ignoreIds = array_map(fn($v) => (int)trim($v), explode(',', (string)$this->options['ignore'])); + if (null !== ($ignoreIds = ag($this->options, 'ignore', null))) { + $ignoreIds = array_map(fn($v) => (int)trim($v), explode(',', (string)$ignoreIds)); } $promises = []; @@ -504,10 +501,9 @@ class PlexServer implements ServerInterface ) ); - $this->logger->debug( - sprintf('Requesting %s - %s library parents content.', $this->name, $cName), - ['url' => $url] - ); + $this->logger->debug(sprintf('%s: Requesting \'%s\' series external ids.', $this->name, $cName), [ + 'url' => $url + ]); try { $promises[] = $this->http->request( @@ -523,7 +519,7 @@ class PlexServer implements ServerInterface } catch (ExceptionInterface $e) { $this->logger->error( sprintf( - 'Request to %s library - %s parents failed. Reason: %s', + '%s: Request for \'%s\' series external ids has failed. %s', $this->name, $cName, $e->getMessage() @@ -532,6 +528,7 @@ class PlexServer implements ServerInterface 'url' => $url, 'file' => $e->getFile(), 'line' => $e->getLine(), + 'kind' => get_class($e), ] ); continue; @@ -546,21 +543,26 @@ class PlexServer implements ServerInterface if ('movie' !== $type && 'show' !== $type) { $unsupported++; - $this->logger->debug(sprintf('Skipping %s library - %s. Not supported type.', $this->name, $title)); + $this->logger->debug(sprintf('%s: Skipping \'%s\' library. Unsupported type.', $this->name, $title), [ + 'id' => $key, + 'type' => $type, + ]); continue; } $type = $type === 'movie' ? StateInterface::TYPE_MOVIE : StateInterface::TYPE_EPISODE; - $cName = sprintf('(%s) - (%s:%s)', $title, $type, $key); if (null !== $ignoreIds && in_array($key, $ignoreIds)) { $ignored++; - $this->logger->notice( - sprintf('Skipping %s library - %s. Ignored by user config option.', $this->name, $cName) - ); + $this->logger->notice(sprintf('%s: Skipping \'%s\'. Ignored by user.', $this->name, $title), [ + 'id' => $key, + 'type' => $type, + ]); continue; } + $cName = sprintf('(%s) - (%s:%s)', $title, $type, $key); + $url = $this->url->withPath(sprintf('/library/sections/%d/all', $key))->withQuery( http_build_query( [ @@ -571,7 +573,9 @@ class PlexServer implements ServerInterface ) ); - $this->logger->debug(sprintf('Requesting %s - %s library content.', $this->name, $cName), ['url' => $url]); + $this->logger->debug(sprintf('%s: Requesting \'%s\' content.', $this->name, $cName), [ + 'url' => $url + ]); try { $promises[] = $this->http->request( @@ -586,7 +590,7 @@ class PlexServer implements ServerInterface ); } catch (ExceptionInterface $e) { $this->logger->error( - sprintf('Request to %s library - %s failed. Reason: %s', $this->name, $cName, $e->getMessage()), + sprintf('%s: Request for \'%s\' content has failed. %s', $this->name, $cName, $e->getMessage()), [ 'url' => $url, 'file' => $e->getFile(), @@ -598,15 +602,11 @@ class PlexServer implements ServerInterface } if (0 === count($promises)) { - $this->logger->notice( - sprintf( - 'No requests were made to any of %s libraries. (total: %d, ignored: %d, Unsupported: %d).', - $this->name, - count($listDirs), - $ignored, - $unsupported - ) - ); + $this->logger->notice(sprintf('%s: No library requests were made.', $this->name), [ + 'total' => count($listDirs), + 'ignored' => $ignored, + 'unsupported' => $unsupported, + ]); Data::add($this->name, 'no_import_update', true); return []; } @@ -619,11 +619,6 @@ class PlexServer implements ServerInterface $this->checkConfig(); try { - $this->logger->debug( - sprintf('Search for \'%s\' in %s.', $query, $this->name), - ['url' => $this->url->getHost()] - ); - $url = $this->url->withPath('/hubs/search')->withQuery( http_build_query( [ @@ -636,25 +631,31 @@ class PlexServer implements ServerInterface ) ); - $this->logger->debug('Request', ['url' => $url]); + $this->logger->debug(sprintf('%s: Sending search request for \'%s\'.', $this->name, $query), [ + 'url' => $url + ]); $response = $this->http->request('GET', (string)$url, $this->getHeaders()); if (200 !== $response->getStatusCode()) { $this->logger->error( sprintf( - 'Request to %s responded with unexpected code (%d).', + '%s: Search request for \'%s\' responded with unexpected http status code \'%d\'.', $this->name, + $query, $response->getStatusCode() ) ); - Data::add($this->name, 'no_import_update', true); return []; } $list = []; - $json = json_decode($response->getContent(false), true, flags: JSON_THROW_ON_ERROR); + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); foreach (ag($json, 'MediaContainer.Hub', []) as $item) { $type = ag($item, 'type'); @@ -679,19 +680,16 @@ class PlexServer implements ServerInterface $this->checkConfig(); try { - $this->logger->debug( - sprintf('Requesting libraries From %s.', $this->name), - ['url' => $this->url->getHost()] - ); - $url = $this->url->withPath('/library/sections'); + $this->logger->debug(sprintf('%s: Get list of server libraries.', $this->name), ['url' => $url]); + $response = $this->http->request('GET', (string)$url, $this->getHeaders()); if (200 !== $response->getStatusCode()) { $this->logger->error( sprintf( - 'Request to %s responded with unexpected code (%d).', + '%s: library list request responded with unexpected code \'%d\'.', $this->name, $response->getStatusCode() ) @@ -699,26 +697,36 @@ class PlexServer implements ServerInterface return []; } - $json = json_decode($response->getContent(false), true, flags: JSON_THROW_ON_ERROR); + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); $listDirs = ag($json, 'MediaContainer.Directory', []); if (empty($listDirs)) { - $this->logger->error(sprintf('No libraries found at %s.', $this->name)); + $this->logger->notice( + sprintf( + '%s: Responded with empty list of libraries. Possibly the token has no access to the libraries?', + $this->name + ) + ); return []; } } catch (ExceptionInterface $e) { $this->logger->error( - sprintf('Request to %s failed. Reason: \'%s\'.', $this->name, $e->getMessage()), + sprintf('%s: list of libraries request failed. %s', $this->name, $e->getMessage()), [ 'file' => $e->getFile(), 'line' => $e->getLine(), + 'kind' => get_class($e), ], ); return []; } catch (JsonException $e) { $this->logger->error( - sprintf('Unable to decode %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), + sprintf('%s: Failed to decode library list JSON response. %s', $this->name, $e->getMessage()), [ 'file' => $e->getFile(), 'line' => $e->getLine(), @@ -727,10 +735,8 @@ class PlexServer implements ServerInterface return []; } - $ignoreIds = null; - - if (null !== ($this->options['ignore'] ?? null)) { - $ignoreIds = array_map(fn($v) => (int)trim($v), explode(',', (string)$this->options['ignore'])); + if (null !== ($ignoreIds = ag($this->options, 'ignore', null))) { + $ignoreIds = array_map(fn($v) => (int)trim($v), explode(',', (string)$ignoreIds)); } $list = []; @@ -738,14 +744,12 @@ class PlexServer implements ServerInterface foreach ($listDirs as $section) { $key = (int)ag($section, 'key'); $type = ag($section, 'type', 'unknown'); - $title = ag($section, 'title', '???'); - $isIgnored = null !== $ignoreIds && in_array($key, $ignoreIds); $list[] = [ 'ID' => $key, - 'Title' => $title, + 'Title' => ag($section, 'title', '???'), 'Type' => $type, - 'Ignored' => $isIgnored ? 'Yes' : 'No', + 'Ignored' => null !== $ignoreIds && in_array($key, $ignoreIds) ? 'Yes' : 'No', 'Supported' => 'movie' !== $type && 'show' !== $type ? 'No' : 'Yes', ]; } @@ -762,7 +766,7 @@ class PlexServer implements ServerInterface if (200 !== $response->getStatusCode()) { $this->logger->error( sprintf( - 'Request to %s - %s responded with (%d) unexpected code.', + '%s: Request to \'%s\' responded with unexpected http status code \'%d\'.', $this->name, $cName, $response->getStatusCode() @@ -771,7 +775,6 @@ class PlexServer implements ServerInterface return; } - // -- sandbox external library code to prevent complete failure when error occurs. try { $it = Items::fromIterable( httpClientChunks($this->http->stream($response)), @@ -783,12 +786,20 @@ class PlexServer implements ServerInterface ] ); - $this->logger->info(sprintf('Parsing %s - %s response.', $this->name, $cName)); + $this->logger->info(sprintf('%s: Parsing \'%s\' response.', $this->name, $cName)); foreach ($it as $entity) { if ($entity instanceof DecodingError) { $this->logger->debug( - sprintf('Failed to decode one result of %s - %s response.', $this->name, $cName) + sprintf( + '%s: Failed to decode one of \'%s\' items. %s', + $this->name, + $cName, + $entity->getErrorMessage() + ), + [ + 'payload' => $entity->getMalformedJson(), + ] ); continue; } @@ -797,47 +808,40 @@ class PlexServer implements ServerInterface } catch (PathNotFoundException $e) { $this->logger->error( sprintf( - 'Failed to find media items path in %s - %s - response. Most likely empty library?', + '%s: Failed to find items in \'%s\' response. %s', $this->name, $cName, + $e->getMessage() ), [ 'file' => $e->getFile(), 'line' => $e->getLine(), 'kind' => get_class($e), - 'error' => $e->getMessage(), ], ); return; } catch (Throwable $e) { $this->logger->error( sprintf( - 'Unable to parse %s - %s response.', + '%s: Failed to handle \'%s\' response. %s', $this->name, $cName, + $e->getMessage(), ), [ 'file' => $e->getFile(), 'line' => $e->getLine(), 'kind' => get_class($e), - 'error' => $e->getMessage(), ], ); return; } - $this->logger->info( - sprintf( - 'Finished Parsing %s - %s (%d objects) response.', - $this->name, - $cName, - Data::get("{$this->name}.{$cName}_total") - ) - ); + $this->logger->info(sprintf('%s: Parsing \'%s\' response complete.', $this->name, $cName)); } catch (JsonException $e) { $this->logger->error( sprintf( - 'Failed to decode %s - %s - response. Reason: \'%s\'.', + '%s: Failed to decode \'%s\' JSON response. %s', $this->name, $cName, $e->getMessage() @@ -853,7 +857,7 @@ class PlexServer implements ServerInterface }, error: function (string $cName, string $type, UriInterface|string $url) { return fn(Throwable $e) => $this->logger->error( - sprintf('Request to %s - %s - failed. Reason: \'%s\'.', $this->name, $cName, $e->getMessage()), + sprintf('%s: Error encountered in \'%s\' request. %s', $this->name, $cName, $e->getMessage()), [ 'url' => $url, 'file' => $e->getFile(), @@ -869,99 +873,84 @@ class PlexServer implements ServerInterface { $this->checkConfig(); - $requests = []; + $requests = $stateRequests = []; - foreach ($entities as &$entity) { - if (false === ($this->options[ServerInterface::OPT_EXPORT_IGNORE_DATE] ?? false)) { + foreach ($entities as $key => $entity) { + if (null === $entity) { + continue; + } + + if (false === (ag($this->options, ServerInterface::OPT_EXPORT_IGNORE_DATE, false))) { if (null !== $after && $after->getTimestamp() > $entity->updated) { - $entity = null; continue; } } $entity->plex_id = null; - if (null !== ($entity->guids[Guid::GUID_PLEX] ?? null)) { - $entity->plex_id = 'plex://' . $entity->guids[Guid::GUID_PLEX]; - continue; - } - foreach ([...$entity->getRelativePointers(), ...$entity->getPointers()] as $guid) { if (null === ($this->cacheData[$guid] ?? null)) { continue; } $entity->plex_id = $this->cacheData[$guid]; - break; - } - } - - unset($entity); - - foreach ($entities as $entity) { - if (null === $entity) { - continue; } - if ($entity->isMovie()) { - $iName = sprintf( - '%s - [%s (%d)]', - $this->name, - ag($entity->meta, 'title', '??'), - ag($entity->meta, 'year', 0000), + $iName = $entity->getName(); + + if (null === $entity->plex_id) { + $this->logger->notice( + sprintf('%s: Ignoring \'%s\'. Not found in cache.', $this->name, $iName), + [ + 'guids' => $entity->hasGuids() ? $entity->getGuids() : 'None', + 'rGuids' => $entity->hasRelativeGuid() ? $entity->getRelativeGuids() : 'None', + ] ); - } else { - $iName = trim( - sprintf( - '%s - [%s - (%dx%d)]', - $this->name, - ag($entity->meta, 'series', '??'), - ag($entity->meta, 'season', 0), - ag($entity->meta, 'episode', 0), - ) - ); - } - - if (null === ($entity->plex_id ?? null)) { - $this->logger->notice(sprintf('Ignoring %s. Not found in \'%s\' local cache.', $iName, $this->name)); continue; } try { + $url = $this->url->withPath('/library/metadata/' . $entity->plex_id)->withQuery( + http_build_query(['includeGuids' => 1]) + ); + + $this->logger->debug(sprintf('%s: Requesting \'%s\' state from remote server.', $this->name, $iName), [ + 'url' => $url + ]); + $requests[] = $this->http->request( 'GET', - (string)$this->url->withPath('/library/all')->withQuery( - http_build_query( - [ - 'guid' => $entity->plex_id, - 'includeGuids' => 1, - ] - ) - ), + (string)$url, array_replace_recursive($this->getHeaders(), [ 'user_data' => [ + 'id' => $key, 'state' => &$entity, ] ]) ); } catch (Throwable $e) { - $this->logger->error($e->getMessage(), ['file' => $e->getFile(), 'line' => $e->getLine()]); + $this->logger->error($e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ]); } } - $stateRequests = []; - foreach ($requests as $response) { try { - $content = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); + $content = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); $json = ag($content, 'MediaContainer.Metadata', [])[0] ?? []; - $state = $response->getInfo('user_data')['state'] ?? null; - - if (null === $state) { + if (null === ($state = ag($response->getInfo('user_data'), 'state'))) { $this->logger->error( sprintf( - 'Request failed with code \'%d\'.', + '%s: Request failed with code \'%d\'.', + $this->name, $response->getStatusCode(), ), $response->getHeaders() @@ -971,38 +960,23 @@ class PlexServer implements ServerInterface assert($state instanceof StateInterface); - if (StateInterface::TYPE_MOVIE === $state->type) { - $iName = sprintf( - '%s - [%s (%d)]', - $this->name, - $state->meta['title'] ?? '??', - $state->meta['year'] ?? 0000, - ); - } else { - $iName = trim( - sprintf( - '%s - [%s - (%dx%d)]', - $this->name, - $state->meta['series'] ?? '??', - $state->meta['season'] ?? 0, - $state->meta['episode'] ?? 0, - ) - ); - } + $iName = $state->getName(); if (empty($json)) { - $this->logger->notice(sprintf('Ignoring %s. does not exists.', $iName)); + $this->logger->notice( + sprintf('%s: Ignoring \'%s\'. Remote server returned empty result.', $this->name, $iName) + ); continue; } $isWatched = (int)(bool)ag($json, 'viewCount', 0); if ($state->watched === $isWatched) { - $this->logger->debug(sprintf('Ignoring %s. State is unchanged.', $iName)); + $this->logger->debug(sprintf('%s: Ignoring \'%s\'. Play state is identical.', $this->name, $iName)); continue; } - if (false === ($this->options[ServerInterface::OPT_EXPORT_IGNORE_DATE] ?? false)) { + if (false === (ag($this->options, ServerInterface::OPT_EXPORT_IGNORE_DATE, false))) { $date = max( (int)ag($json, 'updatedAt', 0), (int)ag($json, 'lastViewedAt', 0), @@ -1010,39 +984,66 @@ class PlexServer implements ServerInterface ); if (0 === $date) { - $this->logger->debug(sprintf('Ignoring %s. State is unchanged.', $iName)); + $this->logger->notice( + sprintf('%s: Ignoring \'%s\'. Date is not set on remote item.', $this->name, $iName), + [ + 'payload' => $json, + ] + ); continue; } if ($date >= $state->updated) { - $this->logger->debug(sprintf('Ignoring %s. Date is newer then what in db.', $iName)); + $this->logger->debug( + sprintf( + '%s: Ignoring \'%s\'. Remote item date is newer or equal to backend entity.', + $this->name, + $iName + ), + [ + 'backend' => makeDate($state->updated), + 'remote' => makeDate($date), + ] + ); continue; } } - $stateRequests[] = $this->http->request( - 'GET', - (string)$this->url->withPath((1 === $state->watched ? '/:/scrobble' : '/:/unscrobble')) - ->withQuery( - http_build_query( - [ - 'identifier' => 'com.plexapp.plugins.library', - 'key' => ag($json, 'ratingKey'), - ] - ) - ), - array_replace_recursive( - $this->getHeaders(), + $url = $this->url->withPath($state->isWatched() ? '/:/scrobble' : '/:/unscrobble')->withQuery( + http_build_query( [ - 'user_data' => [ - 'state' => 1 === $state->watched ? 'Watched' : 'Unwatched', - 'itemName' => $iName, - ], + 'identifier' => 'com.plexapp.plugins.library', + 'key' => ag($json, 'ratingKey'), ] ) ); + + $this->logger->debug( + sprintf('%s: Changing \'%s\' remote state.', $this->name, $iName), + [ + 'backend' => $state->isWatched() ? 'Played' : 'Unplayed', + 'remote' => $isWatched ? 'Played' : 'Unplayed', + 'url' => (string)$url, + ] + ); + + $stateRequests[] = $this->http->request( + 'GET', + (string)$url, + array_replace_recursive($this->getHeaders(), [ + 'user_data' => [ + 'server' => $this->name, + 'state' => 1 === $state->watched ? 'Watched' : 'Unwatched', + 'itemName' => $iName, + ] + ]) + ); } catch (Throwable $e) { - $this->logger->error($e->getMessage(), ['file' => $e->getFile(), 'line' => $e->getLine()]); + $this->logger->error($e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ]); } } @@ -1060,7 +1061,7 @@ class PlexServer implements ServerInterface if (200 !== $response->getStatusCode()) { $this->logger->error( sprintf( - 'Request to %s - %s responded with unexpected http status code (%d).', + '%s: Request for \'%s\' responded with unexpected http status code (%d).', $this->name, $cName, $response->getStatusCode() @@ -1080,12 +1081,20 @@ class PlexServer implements ServerInterface ] ); - $this->logger->info(sprintf('Parsing %s - %s response.', $this->name, $cName)); + $this->logger->info(sprintf('%s: Parsing \'%s\' response.', $this->name, $cName)); foreach ($it as $entity) { if ($entity instanceof DecodingError) { - $this->logger->debug( - sprintf('Failed to decode one result of %s - %s response.', $this->name, $cName) + $this->logger->notice( + sprintf( + '%s: Failed to decode one of \'%s\' items. %s', + $this->name, + $cName, + $entity->getErrorMessage() + ), + [ + 'payload' => $entity->getMalformedJson(), + ] ); continue; } @@ -1094,47 +1103,38 @@ class PlexServer implements ServerInterface } catch (PathNotFoundException $e) { $this->logger->error( sprintf( - 'Failed to find media items path in %s - %s - response. Most likely empty library?', + '%s: Failed to find items in \'%s\' response. %s', $this->name, $cName, + $e->getMessage() ), [ 'file' => $e->getFile(), 'line' => $e->getLine(), 'kind' => get_class($e), - 'error' => $e->getMessage(), ], ); - return; } catch (Throwable $e) { $this->logger->error( sprintf( - 'Unable to parse %s - %s response.', + '%s: Failed to handle \'%s\' response. %s', $this->name, $cName, + $e->getMessage(), ), [ 'file' => $e->getFile(), 'line' => $e->getLine(), 'kind' => get_class($e), - 'error' => $e->getMessage(), ], ); - return; } - $this->logger->info( - sprintf( - 'Finished Parsing %s - %s (%d objects) response.', - $this->name, - $cName, - Data::get("{$this->name}.{$type}_total") - ) - ); + $this->logger->info(sprintf('%s: Parsing \'%s\' response complete.', $this->name, $cName)); } catch (JsonException $e) { $this->logger->error( sprintf( - 'Failed to decode %s - %s - response. Reason: \'%s\'.', + '%s: Failed to decode \'%s\' JSON response. %s', $this->name, $cName, $e->getMessage() @@ -1142,15 +1142,29 @@ class PlexServer implements ServerInterface [ 'file' => $e->getFile(), 'line' => $e->getLine(), - ] + ], ); return; + } catch (Throwable $e) { + $this->logger->error( + sprintf( + '%s: Failed to handle \'%s\' response. %s', + $this->name, + $cName, + $e->getMessage(), + ), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ], + ); } }; }, error: function (string $cName, string $type, UriInterface|string $url) { return fn(Throwable $e) => $this->logger->error( - sprintf('Request to %s - %s - failed. Reason: \'%s\'.', $this->name, $cName, $e->getMessage()), + sprintf('%s: Error encountered in \'%s\' request. %s', $this->name, $cName, $e->getMessage()), [ 'url' => $url, 'file' => $e->getFile(), @@ -1171,7 +1185,7 @@ class PlexServer implements ServerInterface if (200 !== $response->getStatusCode()) { $this->logger->error( sprintf( - 'Request to %s - %s responded with (%d) unexpected code.', + '%s: Request to \'%s\' responded with unexpected http status code \'%d\'.', $this->name, $cName, $response->getStatusCode() @@ -1191,12 +1205,20 @@ class PlexServer implements ServerInterface ] ); - $this->logger->info(sprintf('Parsing %s - %s response.', $this->name, $cName)); + $this->logger->info(sprintf('%s: Parsing \'%s\' response.', $this->name, $cName)); foreach ($it as $entity) { if ($entity instanceof DecodingError) { $this->logger->debug( - sprintf('Failed to decode one result of %s - %s response.', $this->name, $cName) + sprintf( + '%s: Failed to decode one of \'%s\' items. %s', + $this->name, + $cName, + $entity->getErrorMessage() + ), + [ + 'payload' => $entity->getMalformedJson(), + ] ); continue; } @@ -1206,18 +1228,17 @@ class PlexServer implements ServerInterface } catch (PathNotFoundException $e) { $this->logger->error( sprintf( - 'Failed to find media items path in %s - %s - response. Most likely empty library?', + '%s: Failed to find items in \'%s\' response. %s', $this->name, $cName, + $e->getMessage() ), [ 'file' => $e->getFile(), 'line' => $e->getLine(), 'kind' => get_class($e), - 'error' => $e->getMessage(), ], ); - return; } catch (Throwable $e) { $this->logger->error( sprintf( @@ -1228,18 +1249,17 @@ class PlexServer implements ServerInterface [ 'file' => $e->getFile(), 'line' => $e->getLine(), - 'kind' => get_class($e), 'error' => $e->getMessage(), + 'kind' => get_class($e), ], ); - return; } - $this->logger->info(sprintf('Finished Parsing %s - %s response.', $this->name, $cName)); + $this->logger->info(sprintf('%s: Parsing \'%s\' response complete.', $this->name, $cName)); } catch (JsonException $e) { $this->logger->error( sprintf( - 'Failed to decode %s - %s - response. Reason: \'%s\'.', + '%s: Failed to decode \'%s\' JSON response. %s', $this->name, $cName, $e->getMessage() @@ -1250,12 +1270,26 @@ class PlexServer implements ServerInterface ], ); return; + } catch (Throwable $e) { + $this->logger->error( + sprintf( + '%s: Failed to handle \'%s\' response. %s', + $this->name, + $cName, + $e->getMessage(), + ), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ], + ); } }; }, error: function (string $cName, string $type, UriInterface|string $url) { return fn(Throwable $e) => $this->logger->error( - sprintf('Request to %s - %s - failed. Reason: \'%s\'.', $this->name, $cName, $e->getMessage()), + sprintf('%s: Error encountered in \'%s\' request. %s', $this->name, $cName, $e->getMessage()), [ 'url' => $url, 'file' => $e->getFile(), @@ -1279,8 +1313,7 @@ class PlexServer implements ServerInterface if (StateInterface::TYPE_MOVIE === $type) { $iName = sprintf( - '%s - %s - [%s (%d)]', - $this->name, + '%s - [%s (%d)]', $library, $item->title ?? $item->originalTitle ?? '??', $item->year ?? 0000 @@ -1288,12 +1321,11 @@ class PlexServer implements ServerInterface } else { $iName = trim( sprintf( - '%s - %s - [%s - (%dx%d)]', - $this->name, + '%s - [%s - (%dx%d)]', $library, $item->grandparentTitle ?? $item->originalTitle ?? '??', - $item->parentIndex ?? 0, - $item->index ?? 0, + str_pad((string)($item->parentIndex ?? 0), 2, '0', STR_PAD_LEFT), + str_pad((string)($item->index ?? 0), 3, '0', STR_PAD_LEFT), ) ); } @@ -1301,7 +1333,12 @@ class PlexServer implements ServerInterface $date = $item->lastViewedAt ?? $item->updatedAt ?? $item->addedAt ?? null; if (null === $date) { - $this->logger->error(sprintf('Ignoring %s. No date is set.', $iName)); + $this->logger->notice( + sprintf('%s: Ignoring \'%s\'. Date is not set on remote item.', $this->name, $iName), + [ + 'payload' => get_object_vars($item), + ] + ); Data::increment($this->name, $type . '_ignored_no_date_is_set'); return; } @@ -1309,36 +1346,48 @@ class PlexServer implements ServerInterface $rItem = $this->createEntity($item, $type); if (!$rItem->hasGuids() && !$rItem->hasRelativeGuid()) { - $guids = $item->Guid ?? []; - $this->logger->debug( - sprintf('Ignoring %s. No valid/supported guids.', $iName), - [ - 'guids' => !empty($guids) ? $guids : 'None', - 'rGuids' => $rItem->hasRelativeGuid() ? $rItem->getRelativeGuids() : 'None', - ] - ); + if (null === ($item->Guid ?? null)) { + $item->Guid = [['id' => $item->guid]]; + } else { + $item->Guid[] = ['id' => $item->guid]; + } + + $message = sprintf('%s: Ignoring \'%s\'. No valid/supported external ids.', $this->name, $iName); + + if (empty($item->Guid)) { + $message .= ' Most likely unmatched item.'; + } + + $this->logger->debug($message, ['guids' => !empty($item->Guid) ? $item->Guid : 'None']); + Data::increment($this->name, $type . '_ignored_no_supported_guid'); return; } if (false === ($this->options[ServerInterface::OPT_EXPORT_IGNORE_DATE] ?? false)) { if (null !== $after && $rItem->updated >= $after->getTimestamp()) { - $this->logger->debug(sprintf('Ignoring %s. date is equal or newer than lastSync.', $iName)); + $this->logger->debug( + sprintf( + '%s: Ignoring \'%s\'. Remote item date is equal or newer than last sync date.', + $this->name, + $iName + ) + ); Data::increment($this->name, $type . '_ignored_date_is_equal_or_higher'); return; } } if (null === ($entity = $mapper->get($rItem))) { - $guids = $item->Guid ?? []; $this->logger->debug( sprintf( - 'Ignoring %s. [State: %s] - Not found in db.', + '%s: Ignoring \'%s\' Not found in backend store. Run state:import to import the item.', + $this->name, $iName, - $rItem->watched ? 'Played' : 'Unplayed' ), [ - 'guids' => !empty($guids) ? $guids : 'None', + 'played' => $rItem->isWatched() ? 'Yes' : 'No', + 'guids' => $rItem->hasGuids() ? $rItem->getGuids() : 'None', 'rGuids' => $rItem->hasRelativeGuid() ? $rItem->getRelativeGuids() : 'None', ] ); @@ -1347,58 +1396,60 @@ class PlexServer implements ServerInterface } if ($rItem->watched === $entity->watched) { - $this->logger->debug(sprintf('Ignoring %s. State is equal to db state.', $iName), [ - 'State' => $entity->watched ? 'Played' : 'Unplayed' + $this->logger->debug(sprintf('%s: Ignoring \'%s\'. Played state is identical.', $this->name, $iName), [ + 'backend' => $entity->isWatched() ? 'Played' : 'Unplayed', + 'remote' => $rItem->isWatched() ? 'Played' : 'Unplayed', ]); Data::increment($this->name, $type . '_ignored_state_unchanged'); return; } - if (false === ($this->options[ServerInterface::OPT_EXPORT_IGNORE_DATE] ?? false)) { + if (false === ag($this->options, ServerInterface::OPT_EXPORT_IGNORE_DATE, false)) { if ($rItem->updated >= $entity->updated) { - $this->logger->debug(sprintf('Ignoring %s. Date is newer or equal to db entry.', $iName), [ - 'db' => makeDate($rItem->updated), - 'server' => makeDate($entity->updated), - ]); + $this->logger->debug( + sprintf('%s: Ignoring \'%s\'. Date is newer or equal to backend entity.', $this->name, $iName), + [ + 'backend' => makeDate($entity->updated), + 'remote' => makeDate($rItem->updated), + ] + ); Data::increment($this->name, $type . '_ignored_date_is_newer'); return; } } + $url = $this->url->withPath('/:' . ($entity->isWatched() ? '/scrobble' : '/unscrobble'))->withQuery( + http_build_query( + [ + 'identifier' => 'com.plexapp.plugins.library', + 'key' => $item->ratingKey, + ] + ) + ); + $this->logger->info(sprintf('Queuing %s.', $iName), [ - 'State' => [ - 'db' => $entity->watched ? 'Played' : 'Unplayed', - 'server' => $rItem->watched ? 'Played' : 'Unplayed' - ], + 'backend' => $entity->isWatched() ? 'Played' : 'Unplayed', + 'remote' => $rItem->isWatched() ? 'Played' : 'Unplayed', + 'url' => $url, ]); $mapper->queue( $this->http->request( 'GET', - (string)$this->url->withPath('/:' . (1 === $entity->watched ? '/scrobble' : '/unscrobble')) - ->withQuery( - http_build_query( - [ - 'identifier' => 'com.plexapp.plugins.library', - 'key' => $item->ratingKey, - ] - ) - ), - array_replace_recursive( - $this->getHeaders(), - [ - 'user_data' => [ - 'state' => 1 === $entity->watched ? 'Played' : 'Unplayed', - 'itemName' => $iName, - ], + (string)$url, + array_replace_recursive($this->getHeaders(), [ + 'user_data' => [ + 'state' => $entity->isWatched() ? 'Played' : 'Unplayed', + 'itemName' => $iName, ] - ) + ]) ) ); } catch (Throwable $e) { $this->logger->error($e->getMessage(), [ 'file' => $e->getFile(), 'line' => $e->getLine(), + 'kind' => get_class($e), ]); } } @@ -1412,31 +1463,31 @@ class PlexServer implements ServerInterface } if (!$this->hasSupportedGuids($item->Guid, true)) { + if (null === ($item->Guid ?? null)) { + $item->Guid = [['id' => $item->guid]]; + } else { + $item->Guid[] = ['id' => $item->guid]; + } + $iName = sprintf( - '%s - %s - [%s (%d)]', - $this->name, + '%s - [%s (%d)]', $library, - $item->title ?? $item->originalTitle ?? '??', - $item->year ?? 0000 + ag($item, ['title', 'originalTitle'], '??'), + ag($item, 'year', '0000') ); - $message = sprintf('Ignoring %s. No valid/supported GUIDs.', $iName); + + $message = sprintf('%s: Ignoring \'%s\'. No valid/supported external ids.', $this->name, $iName); + if (empty($item->Guid)) { $message .= ' Most likely unmatched TV show.'; } - $this->logger->info($message, [ - 'guids' => empty($item->Guid) ? 'None' : $item->Guid - ]); + + $this->logger->info($message, ['guids' => !empty($item->Guid) ? $item->Guid : 'None']); + return; } - $guids = []; - - foreach (Guid::fromArray($this->getGuids($item->Guid, isParent: true))->getPointers() as $guid) { - [$type, $id] = explode('://', $guid); - $guids[$type] = $id; - } - - $this->showInfo[$item->ratingKey] = $guids; + $this->showInfo[$item->ratingKey] = Guid::fromArray($this->getGuids($item->Guid, isParent: true))->getAll(); } protected function processImport( @@ -1457,8 +1508,7 @@ class PlexServer implements ServerInterface if (StateInterface::TYPE_MOVIE === $type) { $iName = sprintf( - '%s - %s - [%s (%d)]', - $this->name, + '%s - [%s (%d)]', $library, $item->title ?? $item->originalTitle ?? '??', $item->year ?? 0000 @@ -1466,8 +1516,7 @@ class PlexServer implements ServerInterface } else { $iName = trim( sprintf( - '%s - %s - [%s - (%sx%s)]', - $this->name, + '%s - [%s - (%sx%s)]', $library, $item->grandparentTitle ?? $item->originalTitle ?? '??', str_pad((string)($item->parentIndex ?? 0), 2, '0', STR_PAD_LEFT), @@ -1479,7 +1528,12 @@ class PlexServer implements ServerInterface $date = (int)($item->lastViewedAt ?? $item->updatedAt ?? $item->addedAt ?? 0); if (0 === $date) { - $this->logger->error(sprintf('Ignoring %s. No date is set.', $iName)); + $this->logger->debug( + sprintf('%s: Ignoring \'%s\'. Date is not set on remote item.', $this->name, $iName), + [ + 'payload' => $item, + ] + ); Data::increment($this->name, $type . '_ignored_no_date_is_set'); return; } @@ -1501,7 +1555,7 @@ class PlexServer implements ServerInterface } } - $message = sprintf('Ignoring %s. No valid/supported GUIDs.', $iName); + $message = sprintf('%s: Ignoring \'%s\'. No valid/supported external ids.', $this->name, $iName); if (empty($item->Guid)) { $message .= ' Most likely unmatched item.'; @@ -1513,11 +1567,12 @@ class PlexServer implements ServerInterface return; } - $mapper->add($this->name, $iName, $entity, ['after' => $after]); + $mapper->add($this->name, $this->name . ' - ' . $iName, $entity, ['after' => $after]); } catch (Throwable $e) { $this->logger->error($e->getMessage(), [ 'file' => $e->getFile(), - 'line' => $e->getLine() + 'line' => $e->getLine(), + 'kind' => get_class($e), ]); } } @@ -1540,7 +1595,8 @@ class PlexServer implements ServerInterface } catch (Throwable $e) { $this->logger->error($e->getMessage(), [ 'file' => $e->getFile(), - 'line' => $e->getLine() + 'line' => $e->getLine(), + 'kind' => get_class($e), ]); } } @@ -1615,7 +1671,7 @@ class PlexServer implements ServerInterface } if (!empty($this->cacheShowKey) && !empty($this->cacheShow) && true === $this->initialized) { - $this->cache->set($this->cacheShowKey, $this->cacheShow, new DateInterval('PT30M')); + $this->cache->set($this->cacheShowKey, $this->cacheShow, new DateInterval('P7D')); } } @@ -1627,6 +1683,7 @@ class PlexServer implements ServerInterface * * @return string * @see SUPPORTED_LEGACY_AGENTS + * @see PARENT_SUPPORTED_LEGACY_AGENTS */ private function parseLegacyAgent(string $agent, bool $isParent = false): string { @@ -1661,7 +1718,10 @@ class PlexServer implements ServerInterface return $agent . '://' . before($guid, '?'); } catch (Throwable $e) { - $this->logger->error('Unable to match Legacy plex agent.', ['guid' => $agent, 'e' => $e->getMessage()]); + $this->logger->error('%s: Unable to match Plex Legacy agent identifier.', [ + 'guid' => $agent, + 'error' => $e->getMessage() + ]); return $agent; } } @@ -1669,11 +1729,11 @@ class PlexServer implements ServerInterface private function checkConfig(bool $checkUrl = true, bool $checkToken = true): void { if (true === $checkUrl && !($this->url instanceof UriInterface)) { - throw new RuntimeException(afterLast(__CLASS__, '\\') . ': No host was set.'); + throw new RuntimeException(self::NAME . ': No host was set.'); } if (true === $checkToken && null === $this->token) { - throw new RuntimeException(afterLast(__CLASS__, '\\') . ': No token was set.'); + throw new RuntimeException(self::NAME . ': No token was set.'); } } @@ -1686,10 +1746,9 @@ class PlexServer implements ServerInterface 'plex.tv' )->withPath(sprintf('/api/v2/home/users/%s/switch', $userId)); - $this->logger->debug( - sprintf('Requesting temp token for user id %s from %s.', $userId, $this->name), - ['url' => $url->getHost() . $url->getPath()] - ); + $this->logger->debug(sprintf('%s: Requesting temp token for user id \'%s\'.', $this->name, $userId), [ + 'url' => (string)$url + ]); $response = $this->http->request('POST', (string)$url, [ 'headers' => [ @@ -1702,8 +1761,9 @@ class PlexServer implements ServerInterface if (201 !== $response->getStatusCode()) { $this->logger->error( sprintf( - 'Request to %s responded with unexpected code (%d).', + '%s: Request to get temp token for userid \'%s\' responded with unexpected http status code \'%d\'.', $this->name, + $userId, $response->getStatusCode() ) ); @@ -1714,22 +1774,22 @@ class PlexServer implements ServerInterface $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); $tempToken = ag($json, 'authToken', null); - $url = Container::getNew(UriInterface::class)->withPort(443)->withScheme('https')->withHost( - 'plex.tv' - )->withPath('/api/v2/resources') - ->withQuery( + $url = Container::getNew(UriInterface::class)->withPort(443)->withScheme('https')->withHost('plex.tv') + ->withPath('/api/v2/resources')->withQuery( http_build_query( [ 'includeIPv6' => 1, 'includeHttps' => 1, - 'includeRelay' => 1, + 'includeRelay' => 1 ] ) ); $this->logger->debug( - sprintf('Requesting real server token for user id %s from %s.', $userId, $this->name), - ['url' => $url->getHost() . $url->getPath()] + sprintf('%s: Requesting real server token for user id \'%s\'.', $this->name, $userId), + [ + 'url' => (string)$url + ] ); $response = $this->http->request('GET', (string)$url, [ @@ -1754,6 +1814,7 @@ class PlexServer implements ServerInterface $this->logger->error($e->getMessage(), [ 'file' => $e->getFile(), 'line' => $e->getLine(), + 'kind' => get_class($e), ]); return null; } @@ -1769,7 +1830,6 @@ class PlexServer implements ServerInterface $date = (int)($item->lastViewedAt ?? $item->updatedAt ?? $item->addedAt ?? 0); - /** @noinspection PhpArrayIndexImmediatelyRewrittenInspection */ $row = [ 'type' => $type, 'updated' => $date, @@ -1788,7 +1848,7 @@ class PlexServer implements ServerInterface if (StateInterface::TYPE_MOVIE === $type) { $row['title'] = $item->title ?? $item->originalTitle ?? '??'; - } else { + } elseif (StateInterface::TYPE_EPISODE === $type) { $row['title'] = $item->grandparentTitle ?? '??'; $row['season'] = $item->parentIndex ?? 0; $row['episode'] = $item->index ?? 0; @@ -1797,14 +1857,16 @@ class PlexServer implements ServerInterface $parentId = $item->grandparentRatingKey ?? $item->parentRatingKey ?? null; if (null !== $parentId) { - $row['parent'] = $this->showInfo[$parentId] ?? []; + $row['parent'] = $this->getEpisodeParent($parentId); } + } else { + throw new RuntimeException('Invalid content type.'); } $entity = Container::get(StateInterface::class)::fromArray($row); foreach ([...$entity->getRelativePointers(), ...$entity->getPointers()] as $guid) { - $this->cacheData[$guid] = $item->guid; + $this->cacheData[$guid] = $item->ratingKey; } return $entity; @@ -1846,37 +1908,32 @@ class PlexServer implements ServerInterface return $this->cacheShow[$id]; } - $guids = []; - - foreach (Guid::fromArray($this->getGuids($json['Guid'], isParent: true))->getPointers() as $guid) { - [$type, $id] = explode('://', $guid); - $guids[$type] = $id; - } - - $this->cacheShow[$id] = $guids; + $this->cacheShow[$id] = Guid::fromArray($this->getGuids($json['Guid'], isParent: true))->getAll(); return $this->cacheShow[$id]; } catch (ExceptionInterface $e) { $this->logger->error($e->getMessage(), [ 'file' => $e->getFile(), - 'line' => $e->getLine() + 'line' => $e->getLine(), + 'kind' => get_class($e), ]); return []; } catch (JsonException $e) { $this->logger->error( - sprintf('Unable to decode %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), + sprintf('%s: Unable to decode show id \'%s\' JSON response. %s', $this->name, $id, $e->getMessage()), [ 'file' => $e->getFile(), - 'line' => $e->getLine() + 'line' => $e->getLine(), ] ); return []; } catch (Throwable $e) { $this->logger->error( - sprintf('ERROR: %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), + sprintf('%s: Failed to handle show id \'%s\' response. %s', $this->name, $id, $e->getMessage()), [ 'file' => $e->getFile(), - 'line' => $e->getLine() + 'line' => $e->getLine(), + 'kind' => get_class($e), ] ); return []; From ffacf05018e7ef24bdb152194cdac58de5bcb9a9 Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A" Date: Tue, 10 May 2022 21:48:55 +0300 Subject: [PATCH 09/13] Fully migrated jellyfin/emby to use new backend store. --- src/Libs/Servers/EmbyServer.php | 231 +--- src/Libs/Servers/JellyfinServer.php | 1882 +++++++++++++------------- src/Libs/Servers/PlexServer.php | 1823 ++++++++++++------------- src/Libs/Servers/ServerInterface.php | 4 +- src/Libs/helpers.php | 26 +- 5 files changed, 1872 insertions(+), 2094 deletions(-) diff --git a/src/Libs/Servers/EmbyServer.php b/src/Libs/Servers/EmbyServer.php index 33d7a6db..82043572 100644 --- a/src/Libs/Servers/EmbyServer.php +++ b/src/Libs/Servers/EmbyServer.php @@ -9,7 +9,6 @@ use App\Libs\Container; use App\Libs\Entity\StateInterface; use App\Libs\Guid; use App\Libs\HttpException; -use DateTimeInterface; use JsonException; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\UriInterface; @@ -19,6 +18,8 @@ use Throwable; class EmbyServer extends JellyfinServer { + public const NAME = 'EmbyBackend'; + protected const WEBHOOK_ALLOWED_TYPES = [ 'Movie', 'Episode', @@ -53,18 +54,22 @@ class EmbyServer extends JellyfinServer return parent::setUp($name, $url, $token, $userId, $uuid, $persist, $options); } - public static function processRequest(ServerRequestInterface $request): ServerRequestInterface + public static function processRequest(ServerRequestInterface $request, array $opts = []): ServerRequestInterface { + $logger = null; + try { + $logger = $opts[LoggerInterface::class] ?? Container::get(LoggerInterface::class); + $userAgent = ag($request->getServerParams(), 'HTTP_USER_AGENT', ''); if (false === str_starts_with($userAgent, 'Emby Server/')) { return $request; } - $payload = ag($request->getParsedBody() ?? [], 'data', null); + $payload = (string)ag($request->getParsedBody() ?? [], 'data', null); - if (null === $payload || null === ($json = json_decode((string)$payload, true))) { + if (null === ($json = json_decode(json: $payload, associative: true, flags: JSON_INVALID_UTF8_IGNORE))) { return $request; } @@ -84,9 +89,10 @@ class EmbyServer extends JellyfinServer $request = $request->withAttribute($key, $val); } } catch (Throwable $e) { - Container::get(LoggerInterface::class)->error($e->getMessage(), [ + $logger?->error($e->getMessage(), [ 'file' => $e->getFile(), 'line' => $e->getLine(), + 'kind' => get_class($e), ]); } @@ -103,17 +109,15 @@ class EmbyServer extends JellyfinServer $type = ag($json, 'Item.Type', 'not_found'); if (null === $type || !in_array($type, self::WEBHOOK_ALLOWED_TYPES)) { - throw new HttpException(sprintf('%s: Not allowed type [%s]', afterLast(__CLASS__, '\\'), $type), 200); + throw new HttpException(sprintf('%s: Not allowed type [%s]', self::NAME, $type), 200); } $type = strtolower($type); if (null === $event || !in_array($event, self::WEBHOOK_ALLOWED_EVENTS)) { - throw new HttpException(sprintf('%s: Not allowed event [%s]', afterLast(__CLASS__, '\\'), $event), 200); + throw new HttpException(sprintf('%s: Not allowed event [%s]', self::NAME, $event), 200); } - $event = strtolower($event); - $isTainted = in_array($event, self::WEBHOOK_TAINTED_EVENTS); if ('item.markplayed' === $event || 'playback.scrobble' === $event) { @@ -121,7 +125,7 @@ class EmbyServer extends JellyfinServer } elseif ('item.markunplayed' === $event) { $isWatched = 0; } else { - $isWatched = (int)(bool)ag($json, ['Item.Played', 'Item.PlayedToCompletion'], 0); + $isWatched = (int)(bool)ag($json, ['Item.Played', 'Item.PlayedToCompletion'], false); } $providersId = ag($json, 'Item.ProviderIds', []); @@ -131,7 +135,7 @@ class EmbyServer extends JellyfinServer 'updated' => time(), 'watched' => $isWatched, 'via' => $this->name, - 'title' => '??', + 'title' => ag($json, ['Item.Name', 'Item.OriginalTitle'], '??'), 'year' => ag($json, 'Item.ProductionYear', 0000), 'season' => null, 'episode' => null, @@ -147,25 +151,24 @@ class EmbyServer extends JellyfinServer ], ]; - if (StateInterface::TYPE_MOVIE === $type) { - $row['title'] = ag($json, ['Item.Name', 'Item.OriginalTitle'], '??'); - } elseif (StateInterface::TYPE_EPISODE === $type) { + if (StateInterface::TYPE_EPISODE === $type) { $row['title'] = ag($json, 'Item.SeriesName', '??'); $row['season'] = ag($json, 'Item.ParentIndexNumber', 0); $row['episode'] = ag($json, 'Item.IndexNumber', 0); - $row['extra']['title'] = ag($json, ['Item.Name', 'Item.OriginalTitle'], '??'); + + if (null !== ($epTitle = ag($json, ['Name', 'OriginalTitle'], null))) { + $row['extra']['title'] = $epTitle; + } if (null !== ag($json, 'Item.SeriesId')) { - $row['parent'] = $this->getEpisodeParent(ag($json, 'Item.SeriesId')); + $row['parent'] = $this->getEpisodeParent(ag($json, 'Item.SeriesId'), ''); } - } else { - throw new HttpException(sprintf('%s: Invalid content type.', afterLast(__CLASS__, '\\')), 400); } $entity = Container::get(StateInterface::class)::fromArray($row)->setIsTainted($isTainted); if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) { - $message = sprintf('%s: No valid/supported External ids.', afterLast(__CLASS__, '\\')); + $message = sprintf('%s: No valid/supported External ids.', self::NAME); if (empty($providersId)) { $message .= ' Most likely unmatched movie/episode or show.'; @@ -183,172 +186,13 @@ class EmbyServer extends JellyfinServer $savePayload = true === Config::get('webhook.debug') || null !== ag($request->getQueryParams(), 'debug'); if (false === $isTainted && $savePayload) { - saveWebhookPayload($this->name . '.' . $event, $request, [ - 'entity' => $entity->getAll(), - 'payload' => $json, - ]); + saveWebhookPayload($this->name . '.' . $event, $request, $entity); } return $entity; } - /** - * @param array $entities - * @param DateTimeInterface|null $after - * @return array - * @TODO need to be updated to support cached items. - */ - public function push(array $entities, DateTimeInterface|null $after = null): array - { - $requests = []; - - foreach ($entities as &$entity) { - if (false === ($this->options[ServerInterface::OPT_EXPORT_IGNORE_DATE] ?? false)) { - if (null !== $after && $after->getTimestamp() > $entity->updated) { - $entity = null; - continue; - } - } - - $entity->plex_guid = null; - } - - unset($entity); - - /** @var StateInterface $entity */ - foreach ($entities as $entity) { - if (null === $entity || false === $entity->hasGuids()) { - continue; - } - - try { - $guids = []; - - foreach ($entity->guids ?? [] as $key => $val) { - if ('guid_plex' === $key) { - continue; - } - - $guids[] = sprintf('%s.%s', afterLast($key, 'guid_'), $val); - } - - if (empty($guids)) { - continue; - } - - $requests[] = $this->http->request( - 'GET', - (string)$this->url->withPath(sprintf('/Users/%s/items', $this->user))->withQuery( - http_build_query( - [ - 'Recursive' => 'true', - 'Fields' => 'ProviderIds,DateCreated', - 'enableUserData' => 'true', - 'enableImages' => 'false', - 'AnyProviderIdEquals' => implode(',', $guids), - ] - ) - ), - array_replace_recursive($this->getHeaders(), [ - 'user_data' => [ - 'state' => &$entity, - ] - ]) - ); - } catch (Throwable $e) { - $this->logger->error($e->getMessage()); - } - } - - $stateRequests = []; - - foreach ($requests as $response) { - try { - $json = ag( - json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR), - 'Items', - [] - )[0] ?? []; - - $state = $response->getInfo('user_data')['state']; - assert($state instanceof StateInterface); - - if (StateInterface::TYPE_MOVIE === $state->type) { - $iName = sprintf( - '%s - [%s (%d)]', - $this->name, - $state->meta['title'] ?? '??', - $state->meta['year'] ?? 0000, - ); - } else { - $iName = trim( - sprintf( - '%s - [%s - (%dx%d) - %s]', - $this->name, - $state->meta['series'] ?? '??', - $state->meta['season'] ?? 0, - $state->meta['episode'] ?? 0, - $state->meta['title'] ?? '??', - ) - ); - } - - if (empty($json)) { - $this->logger->notice(sprintf('Ignoring %s. does not exists.', $iName)); - continue; - } - - $isWatched = (int)(bool)ag($json, 'UserData.Played', false); - - if ($state->watched === $isWatched) { - $this->logger->debug(sprintf('Ignoring %s. State is unchanged.', $iName)); - continue; - } - - if (false === ($this->options[ServerInterface::OPT_EXPORT_IGNORE_DATE] ?? false)) { - $date = ag( - $json, - 'UserData.LastPlayedDate', - ag($json, 'DateCreated', ag($json, 'PremiereDate', null)) - ); - - if (null === $date) { - $this->logger->notice(sprintf('Ignoring %s. No date is set.', $iName)); - continue; - } - - $date = strtotime($date); - - if ($date >= $state->updated) { - $this->logger->debug(sprintf('Ignoring %s. Date is newer then what in db.', $iName)); - continue; - } - } - - $stateRequests[] = $this->http->request( - 1 === $state->watched ? 'POST' : 'DELETE', - (string)$this->url->withPath(sprintf('/Users/%s/PlayedItems/%s', $this->user, ag($json, 'Id'))), - array_replace_recursive( - $this->getHeaders(), - [ - 'user_data' => [ - 'state' => 1 === $state->watched ? 'Watched' : 'Unwatched', - 'itemName' => $iName, - ], - ] - ) - ); - } catch (Throwable $e) { - $this->logger->error($e->getMessage(), ['file' => $e->getFile(), 'line' => $e->getLine()]); - } - } - - unset($requests); - - return $stateRequests; - } - - private function getEpisodeParent(int|string $id): array + protected function getEpisodeParent(mixed $id, string $cacheName): array { if (array_key_exists($id, $this->cacheShow)) { return $this->cacheShow[$id]; @@ -367,7 +211,11 @@ class EmbyServer extends JellyfinServer return []; } - $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); if (null === ($itemType = ag($json, 'Type')) || 'Series' !== $itemType) { return []; @@ -380,37 +228,32 @@ class EmbyServer extends JellyfinServer return $this->cacheShow[$id]; } - $guids = []; - - foreach (Guid::fromArray($this->getGuids($providersId))->getPointers() as $guid) { - [$type, $guid] = explode('://', $guid); - $guids[$type] = $guid; - } - - $this->cacheShow[$id] = $guids; + $this->cacheShow[$id] = Guid::fromArray($this->getGuids($providersId))->getAll(); return $this->cacheShow[$id]; } catch (ExceptionInterface $e) { $this->logger->error($e->getMessage(), [ 'file' => $e->getFile(), - 'line' => $e->getLine() + 'line' => $e->getLine(), + 'kind' => get_class($e), ]); return []; } catch (JsonException $e) { $this->logger->error( - sprintf('Unable to decode %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), + sprintf('%s: Unable to decode \'%s\' JSON response. %s', $this->name, $cacheName, $e->getMessage()), [ 'file' => $e->getFile(), - 'line' => $e->getLine() + 'line' => $e->getLine(), ] ); return []; } catch (Throwable $e) { $this->logger->error( - sprintf('ERROR: %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), + sprintf('%s: Failed to handle \'%s\' response. %s', $this->name, $cacheName, $e->getMessage()), [ 'file' => $e->getFile(), - 'line' => $e->getLine() + 'line' => $e->getLine(), + 'kind' => get_class($e), ] ); return []; diff --git a/src/Libs/Servers/JellyfinServer.php b/src/Libs/Servers/JellyfinServer.php index c1067a9b..17d2e8f3 100644 --- a/src/Libs/Servers/JellyfinServer.php +++ b/src/Libs/Servers/JellyfinServer.php @@ -36,6 +36,8 @@ use Throwable; class JellyfinServer implements ServerInterface { + public const NAME = 'JellyfinBackend'; + protected const GUID_MAPPER = [ 'imdb' => Guid::GUID_IMDB, 'tmdb' => Guid::GUID_TMDB, @@ -74,7 +76,6 @@ class JellyfinServer implements ServerInterface protected array $cacheData = []; protected string|int|null $uuid = null; - protected array $showInfo = []; protected array $cacheShow = []; protected string $cacheShowKey = ''; @@ -98,12 +99,13 @@ class JellyfinServer implements ServerInterface array $options = [] ): ServerInterface { if (null === $token) { - throw new RuntimeException(afterLast(__CLASS__, '\\') . '->setState(): No token is set.'); + throw new RuntimeException(self::NAME . ': No token is set.'); } $cloned = clone $this; $cloned->cacheData = []; + $cloned->cacheShow = []; $cloned->name = $name; $cloned->url = $url; $cloned->token = $token; @@ -129,7 +131,6 @@ class JellyfinServer implements ServerInterface } $cloned->options = $options; - $cloned->initialized = true; return $cloned; } @@ -142,28 +143,24 @@ class JellyfinServer implements ServerInterface $this->checkConfig(checkUser: false); - $this->logger->debug( - sprintf('Requesting server Unique id info from %s.', $this->name), - ['url' => $this->url->getHost()] - ); - $url = $this->url->withPath('/system/Info'); + $this->logger->debug(sprintf('%s: Requesting server Unique id.', $this->name), ['url' => $url]); + $response = $this->http->request('GET', (string)$url, $this->getHeaders()); if (200 !== $response->getStatusCode()) { $this->logger->error( sprintf( - 'Request to %s responded with unexpected code (%d).', + '%s: Request to get server unique id responded with unexpected http status code \'%d\'.', $this->name, $response->getStatusCode() ) ); - return null; } - $json = json_decode($response->getContent(false), true, flags: JSON_THROW_ON_ERROR); + $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); $this->uuid = ag($json, 'Id', null); @@ -174,12 +171,14 @@ class JellyfinServer implements ServerInterface { $this->checkConfig(checkUser: false); - $response = $this->http->request('GET', (string)$this->url->withPath('/Users/'), $this->getHeaders()); + $url = $this->url->withPath('/Users/'); + + $response = $this->http->request('GET', (string)$url, $this->getHeaders()); if (200 !== $response->getStatusCode()) { throw new RuntimeException( sprintf( - 'Request to %s responded with unexpected code (%d).', + '%s: Request to get users list responded with unexpected code \'%d\'.', $this->name, $response->getStatusCode() ) @@ -191,7 +190,7 @@ class JellyfinServer implements ServerInterface $list = []; foreach ($json ?? [] as $user) { - $date = $user['LastActivityDate'] ?? $user['LastLoginDate'] ?? null; + $date = ag($user, ['LastActivityDate', 'LastLoginDate'], null); $data = [ 'user_id' => ag($user, 'Id'), @@ -202,7 +201,7 @@ class JellyfinServer implements ServerInterface 'updated_at' => null !== $date ? makeDate($date) : 'Never', ]; - if (true === ($opts['tokens'] ?? false)) { + if (true === ag($opts, 'tokens', false)) { $data['token'] = $this->token; } @@ -230,18 +229,22 @@ class JellyfinServer implements ServerInterface return $this; } - public static function processRequest(ServerRequestInterface $request): ServerRequestInterface + public static function processRequest(ServerRequestInterface $request, array $opts = []): ServerRequestInterface { + $logger = null; + try { + $logger = $opts[LoggerInterface::class] ?? Container::get(LoggerInterface::class); + $userAgent = ag($request->getServerParams(), 'HTTP_USER_AGENT', ''); if (false === str_starts_with($userAgent, 'Jellyfin-Server/')) { return $request; } - $body = (string)$request->getBody(); + $payload = (string)$request->getBody(); - if (null === ($json = json_decode($body, true))) { + if (null === ($json = json_decode(json: $payload, associative: true, flags: JSON_INVALID_UTF8_IGNORE))) { return $request; } @@ -261,9 +264,10 @@ class JellyfinServer implements ServerInterface $request = $request->withAttribute($key, $val); } } catch (Throwable $e) { - Container::get(LoggerInterface::class)->error($e->getMessage(), [ + $logger?->error($e->getMessage(), [ 'file' => $e->getFile(), 'line' => $e->getLine(), + 'kind' => get_class($e), ]); } @@ -279,14 +283,14 @@ class JellyfinServer implements ServerInterface $event = ag($json, 'NotificationType', 'unknown'); $type = ag($json, 'ItemType', 'not_found'); - if (null === $type || !in_array($type, self::WEBHOOK_ALLOWED_TYPES)) { - throw new HttpException(sprintf('%s: Not allowed type [%s]', afterLast(__CLASS__, '\\'), $type), 200); + if (null === $type || false === in_array($type, self::WEBHOOK_ALLOWED_TYPES)) { + throw new HttpException(sprintf('%s: Not allowed type [%s]', self::NAME, $type), 200); } $type = strtolower($type); - if (null === $event || !in_array($event, self::WEBHOOK_ALLOWED_EVENTS)) { - throw new HttpException(sprintf('%s: Not allowed event [%s]', afterLast(__CLASS__, '\\'), $event), 200); + if (null === $event || false === in_array($event, self::WEBHOOK_ALLOWED_EVENTS)) { + throw new HttpException(sprintf('%s: Not allowed event [%s]', self::NAME, $event), 200); } $isTainted = in_array($event, self::WEBHOOK_TAINTED_EVENTS); @@ -294,18 +298,18 @@ class JellyfinServer implements ServerInterface $providersId = []; foreach ($json as $key => $val) { - if (!str_starts_with($key, 'Provider_')) { + if (false === str_starts_with($key, 'Provider_')) { continue; } - $providersId[self::afterString($key, 'Provider_')] = $val; + $providersId[after($key, 'Provider_')] = $val; } $row = [ 'type' => $type, - 'updated' => time(), + 'updated' => strtotime(ag($json, ['UtcTimestamp', 'Timestamp'], 'now')), 'watched' => (int)(bool)ag($json, ['Played', 'PlayedToCompletion'], 0), 'via' => $this->name, - 'title' => '??', + 'title' => ag($json, ['Name', 'OriginalTitle'], '??'), 'year' => ag($json, 'Year', 0000), 'season' => null, 'episode' => null, @@ -319,30 +323,25 @@ class JellyfinServer implements ServerInterface ], ]; - if (StateInterface::TYPE_MOVIE === $type) { - $row['title'] = ag($json, ['Name', 'OriginalTitle'], '??'); - } elseif (StateInterface::TYPE_EPISODE === $type) { - $row['title'] = ag($json, 'SeriesName', '??'); - $row['season'] = ag($json, 'ParentIndexNumber', 0); - $row['episode'] = ag($json, 'IndexNumber', 0); + if (StateInterface::TYPE_EPISODE === $type) { + $seriesName = ag($json, 'SeriesName'); + $row['title'] = $seriesName ?? '??'; + $row['season'] = ag($json, 'SeasonNumber', 0); + $row['episode'] = ag($json, 'EpisodeNumber', 0); if (null !== ($epTitle = ag($json, ['Name', 'OriginalTitle'], null))) { $row['extra']['title'] = $epTitle; } - // -- We use SeriesName to overcome jellyfin webhook limitation, it does not send series id. - // -- it might lead to incorrect result if there is a show with duplicate name. - if (null !== ag($json, 'SeriesName')) { - $row['parent'] = $this->getEpisodeParent(ag($json, 'ItemId'), ag($json, 'SeriesName')); + if (null !== $seriesName) { + $row['parent'] = $this->getEpisodeParent(ag($json, 'ItemId'), $seriesName . ':' . $row['year']); } - } else { - throw new HttpException(sprintf('%s: Invalid content type.', afterLast(__CLASS__, '\\')), 400); } $entity = Container::get(StateInterface::class)::fromArray($row)->setIsTainted($isTainted); if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) { - $message = sprintf('%s: No valid/supported External ids.', afterLast(__CLASS__, '\\')); + $message = sprintf('%s: No valid/supported External ids.', self::NAME); if (empty($providersId)) { $message .= ' Most likely unmatched movie/episode or show.'; @@ -360,35 +359,641 @@ class JellyfinServer implements ServerInterface $savePayload = true === Config::get('webhook.debug') || null !== ag($request->getQueryParams(), 'debug'); if (false === $isTainted && $savePayload) { - saveWebhookPayload($this->name . '.' . $event, $request, [ - 'entity' => $entity->getAll(), - 'payload' => $json, - ]); + saveWebhookPayload($this->name . '.' . $event, $request, $entity); } return $entity; } + public function search(string $query, int $limit = 25): array + { + $this->checkConfig(true); + + try { + $url = $this->url->withPath(sprintf('/Users/%s/items/', $this->user))->withQuery( + http_build_query( + [ + 'searchTerm' => $query, + 'Limit' => $limit, + 'Recursive' => 'true', + 'Fields' => 'ProviderIds', + 'enableUserData' => 'true', + 'enableImages' => 'false', + 'IncludeItemTypes' => 'Episode,Movie,Series', + ] + ) + ); + + $this->logger->debug(sprintf('%s: Sending search request for \'%s\'.', $this->name, $query), [ + 'url' => $url + ]); + + $response = $this->http->request('GET', (string)$url, $this->getHeaders()); + + if (200 !== $response->getStatusCode()) { + throw new RuntimeException( + sprintf( + '%s: Search request for \'%s\' responded with unexpected http status code \'%d\'.', + $this->name, + $query, + $response->getStatusCode() + ) + ); + } + + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); + + return ag($json, 'Items', []); + } catch (ExceptionInterface|JsonException $e) { + throw new RuntimeException(get_class($e) . ': ' . $e->getMessage(), $e->getCode(), $e); + } + } + + public function listLibraries(): array + { + $this->checkConfig(true); + + try { + $url = $this->url->withPath(sprintf('/Users/%s/items/', $this->user))->withQuery( + http_build_query( + [ + 'Recursive' => 'false', + 'Fields' => 'ProviderIds', + 'enableUserData' => 'true', + 'enableImages' => 'false', + ] + ) + ); + + $this->logger->debug(sprintf('%s: Get list of server libraries.', $this->name), ['url' => $url]); + + $response = $this->http->request('GET', (string)$url, $this->getHeaders()); + + if (200 !== $response->getStatusCode()) { + $this->logger->error( + sprintf( + '%s: library list request responded with unexpected code \'%d\'.', + $this->name, + $response->getStatusCode() + ) + ); + return []; + } + + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); + + $listDirs = ag($json, 'Items', []); + + if (empty($listDirs)) { + $this->logger->notice( + sprintf( + '%s: Responded with empty list of libraries. Possibly the token has no access to the libraries?', + $this->name + ) + ); + return []; + } + } catch (ExceptionInterface $e) { + $this->logger->error( + sprintf('%s: list of libraries request failed. %s', $this->name, $e->getMessage()), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ], + ); + return []; + } catch (JsonException $e) { + $this->logger->error( + sprintf('%s: Failed to decode library list JSON response. %s', $this->name, $e->getMessage()), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ], + ); + return []; + } + + if (null !== ($ignoreIds = ag($this->options, 'ignore', null))) { + $ignoreIds = array_map(fn($v) => (int)trim($v), explode(',', (string)$ignoreIds)); + } + + $list = []; + + foreach ($listDirs as $section) { + $key = (string)ag($section, 'Id'); + $type = ag($section, 'CollectionType', 'unknown'); + + $list[] = [ + 'ID' => $key, + 'Title' => ag($section, 'Name', '???'), + 'Type' => $type, + 'Ignored' => null !== $ignoreIds && in_array($key, $ignoreIds) ? 'Yes' : 'No', + 'Supported' => 'movies' !== $type && 'tvshows' !== $type ? 'No' : 'Yes', + ]; + } + + return $list; + } + + public function pull(ImportInterface $mapper, DateTimeInterface|null $after = null): array + { + return $this->getLibraries( + ok: function (string $cName, string $type) use ($after, $mapper) { + return function (ResponseInterface $response) use ($mapper, $cName, $type, $after) { + if (200 !== $response->getStatusCode()) { + $this->logger->error( + sprintf( + '%s: Request to \'%s\' responded with unexpected http status code \'%d\'.', + $this->name, + $cName, + $response->getStatusCode() + ) + ); + return; + } + + try { + $this->logger->info(sprintf('%s: Parsing \'%s\' response.', $this->name, $cName)); + + $it = Items::fromIterable( + httpClientChunks($this->http->stream($response)), + [ + 'pointer' => '/Items', + 'decoder' => new ErrorWrappingDecoder( + new ExtJsonDecoder(options: JSON_INVALID_UTF8_IGNORE) + ) + ] + ); + + foreach ($it as $entity) { + if ($entity instanceof DecodingError) { + $this->logger->error( + sprintf( + '%s: Failed to decode one of \'%s\' items. %s', + $this->name, + $cName, + $entity->getErrorMessage() + ), + [ + 'payload' => $entity->getMalformedJson(), + ] + ); + continue; + } + $this->processImport($mapper, $type, $cName, $entity, $after); + } + } catch (PathNotFoundException $e) { + $this->logger->error( + sprintf( + '%s: Failed to find items in \'%s\' response. %s', + $this->name, + $cName, + $e->getMessage() + ), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ], + ); + } catch (Throwable $e) { + $this->logger->error( + sprintf( + '%s: Failed to handle \'%s\' response. %s', + $this->name, + $cName, + $e->getMessage(), + ), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ], + ); + } + + $this->logger->info(sprintf('%s: Parsing \'%s\' response complete.', $this->name, $cName)); + }; + }, + error: function (string $cName, string $type, UriInterface|string $url) { + return fn(Throwable $e) => $this->logger->error( + sprintf('%s: Error encountered in \'%s\' request. %s', $this->name, $cName, $e->getMessage()), + [ + 'url' => $url, + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ] + ); + }, + includeParent: true + ); + } + + public function push(array $entities, DateTimeInterface|null $after = null): array + { + $this->checkConfig(true); + + $requests = $stateRequests = []; + + foreach ($entities as $entity) { + if (null === $entity) { + continue; + } + + if (false === (ag($this->options, ServerInterface::OPT_EXPORT_IGNORE_DATE, false))) { + if (null !== $after && $after->getTimestamp() > $entity->updated) { + continue; + } + } + + $entity->jf_id = null; + + foreach ([...$entity->getRelativePointers(), ...$entity->getPointers()] as $guid) { + if (null === ($this->cacheData[$guid] ?? null)) { + continue; + } + $entity->jf_id = $this->cacheData[$guid]; + } + + $iName = $entity->getName(); + + if (null === $entity->jf_id) { + $this->logger->notice( + sprintf('%s: Ignoring \'%s\'. Not found in cache.', $this->name, $iName), + [ + 'guids' => $entity->hasGuids() ? $entity->getGuids() : 'None', + 'rGuids' => $entity->hasRelativeGuid() ? $entity->getRelativeGuids() : 'None', + ] + ); + continue; + } + + try { + $url = $this->url->withPath(sprintf('/Users/%s/items', $this->user))->withQuery( + http_build_query( + [ + 'ids' => $entity->jf_id, + 'Fields' => 'ProviderIds,DateCreated,OriginalTitle,SeasonUserData,DateLastSaved', + 'enableUserData' => 'true', + 'enableImages' => 'false', + ] + ) + ); + + $this->logger->debug(sprintf('%s: Requesting \'%s\' state from remote server.', $this->name, $iName), [ + 'url' => $url + ]); + + $requests[] = $this->http->request( + 'GET', + (string)$url, + array_replace_recursive($this->getHeaders(), [ + 'user_data' => [ + 'state' => &$entity, + ] + ]) + ); + } catch (Throwable $e) { + $this->logger->error($e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ]); + } + } + + foreach ($requests as $response) { + try { + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR + ); + + $json = ag($json, 'Items', [])[0] ?? []; + + if (null === ($state = ag($response->getInfo('user_data'), 'state'))) { + $this->logger->error( + sprintf( + '%s: Request failed with code \'%d\'.', + $this->name, + $response->getStatusCode(), + ), + $response->getHeaders() + ); + continue; + } + + assert($state instanceof StateInterface); + + $iName = $state->getName(); + + if (empty($json)) { + $this->logger->notice( + sprintf('%s: Ignoring \'%s\'. Remote server returned empty result.', $this->name, $iName) + ); + continue; + } + + $isWatched = (int)(bool)ag($json, 'UserData.Played', false); + + if ($state->watched === $isWatched) { + $this->logger->debug(sprintf('%s: Ignoring \'%s\'. Play state is identical.', $this->name, $iName)); + continue; + } + + if (false === (ag($this->options, ServerInterface::OPT_EXPORT_IGNORE_DATE, false))) { + $date = ag($json, ['UserData.LastPlayedDate', 'DateCreated', 'PremiereDate'], null); + + if (null === $date) { + $this->logger->notice( + sprintf('%s: Ignoring \'%s\'. Date is not set on remote item.', $this->name, $iName), + [ + 'payload' => $json, + ] + ); + continue; + } + + $date = strtotime($date); + + if ($date >= $state->updated) { + $this->logger->debug( + sprintf( + '%s: Ignoring \'%s\'. Remote item date is newer or equal to backend entity.', + $this->name, + $iName + ), + [ + 'backend' => makeDate($state->updated), + 'remote' => makeDate($date), + ] + ); + continue; + } + } + + $url = $this->url->withPath(sprintf('/Users/%s/PlayedItems/%s', $this->user, ag($json, 'Id'))); + + $this->logger->debug( + sprintf('%s: Changing \'%s\' remote state.', $this->name, $iName), + [ + 'backend' => $state->isWatched() ? 'Played' : 'Unplayed', + 'remote' => $isWatched ? 'Played' : 'Unplayed', + 'method' => $state->isWatched() ? 'POST' : 'DELETE', + 'url' => (string)$url, + ] + ); + + $stateRequests[] = $this->http->request( + $state->isWatched() ? 'POST' : 'DELETE', + (string)$url, + array_replace_recursive( + $this->getHeaders(), + [ + 'user_data' => [ + 'itemName' => $iName, + 'server' => $this->name, + 'state' => $state->isWatched() ? 'Watched' : 'Unwatched', + ], + ] + ) + ); + } catch (Throwable $e) { + $this->logger->error($e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ]); + } + } + + unset($requests); + + return $stateRequests; + } + + public function export(ExportInterface $mapper, DateTimeInterface|null $after = null): array + { + return $this->getLibraries( + ok: function (string $cName, string $type) use ($mapper, $after) { + return function (ResponseInterface $response) use ($mapper, $cName, $type, $after) { + if (200 !== $response->getStatusCode()) { + $this->logger->error( + sprintf( + '%s: Request for \'%s\' responded with unexpected http status code (%d).', + $this->name, + $cName, + $response->getStatusCode() + ) + ); + return; + } + + try { + $this->logger->info(sprintf('%s: Parsing \'%s\' response.', $this->name, $cName)); + + $it = Items::fromIterable( + httpClientChunks($this->http->stream($response)), + [ + 'pointer' => '/Items', + 'decoder' => new ErrorWrappingDecoder( + new ExtJsonDecoder(options: JSON_INVALID_UTF8_IGNORE) + ) + ] + ); + + foreach ($it as $entity) { + if ($entity instanceof DecodingError) { + $this->logger->notice( + sprintf( + '%s: Failed to decode one of \'%s\' items. %s', + $this->name, + $cName, + $entity->getErrorMessage() + ), + [ + 'payload' => $entity->getMalformedJson(), + ] + ); + continue; + } + $this->processExport($mapper, $type, $cName, $entity, $after); + } + } catch (PathNotFoundException $e) { + $this->logger->error( + sprintf( + '%s: Failed to find items in \'%s\' response. %s', + $this->name, + $cName, + $e->getMessage() + ), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ], + ); + } catch (Throwable $e) { + $this->logger->error( + sprintf( + '%s: Failed to handle \'%s\' response. %s', + $this->name, + $cName, + $e->getMessage(), + ), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ], + ); + } + + $this->logger->info(sprintf('%s: Parsing \'%s\' response complete.', $this->name, $cName)); + }; + }, + error: function (string $cName, string $type, UriInterface|string $url) { + return fn(Throwable $e) => $this->logger->error( + sprintf('%s: Error encountered in \'%s\' request. %s', $this->name, $cName, $e->getMessage()), + [ + 'url' => $url, + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ] + ); + }, + includeParent: false, + ); + } + + public function cache(): array + { + return $this->getLibraries( + ok: function (string $cName, string $type) { + return function (ResponseInterface $response) use ($cName, $type) { + if (200 !== $response->getStatusCode()) { + $this->logger->error( + sprintf( + '%s: Request to \'%s\' responded with unexpected http status code \'%d\'.', + $this->name, + $cName, + $response->getStatusCode() + ) + ); + return; + } + + try { + $this->logger->info(sprintf('%s: Parsing \'%s\' response.', $this->name, $cName)); + + $it = Items::fromIterable( + httpClientChunks($this->http->stream($response)), + [ + 'pointer' => '/Items', + 'decoder' => new ErrorWrappingDecoder( + new ExtJsonDecoder(options: JSON_INVALID_UTF8_IGNORE) + ) + ] + ); + + foreach ($it as $entity) { + if ($entity instanceof DecodingError) { + $this->logger->debug( + sprintf( + '%s: Failed to decode one of \'%s\' items. %s', + $this->name, + $cName, + $entity->getErrorMessage() + ), + [ + 'payload' => $entity->getMalformedJson() + ] + ); + continue; + } + $this->processCache($entity, $type, $cName); + } + } catch (PathNotFoundException $e) { + $this->logger->error( + sprintf( + '%s: Failed to find items in \'%s\' response. %s', + $this->name, + $cName, + $e->getMessage() + ), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ], + ); + } catch (Throwable $e) { + $this->logger->error( + sprintf( + '%s: Failed to handle \'%s\' response. %s', + $this->name, + $cName, + $e->getMessage(), + ), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ], + ); + } + + $this->logger->info(sprintf('%s: Parsing \'%s\' response complete.', $this->name, $cName)); + }; + }, + error: function (string $cName, string $type, UriInterface|string $url) { + return fn(Throwable $e) => $this->logger->error( + sprintf('%s: Error encountered in \'%s\' request. %s', $this->name, $cName, $e->getMessage()), + [ + 'url' => $url, + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ] + ); + }, + includeParent: true + ); + } + + /** + * @throws InvalidArgumentException + */ + public function __destruct() + { + if (!empty($this->cacheKey) && !empty($this->cacheData) && true === $this->initialized) { + $this->cache->set($this->cacheKey, $this->cacheData, new DateInterval('P1Y')); + } + + if (!empty($this->cacheShowKey) && !empty($this->cacheShow) && true === $this->initialized) { + $this->cache->set($this->cacheShowKey, $this->cacheShow, new DateInterval('P7D')); + } + } + protected function getHeaders(): array { $opts = [ 'headers' => [ 'Accept' => 'application/json', + 'X-MediaBrowser-Token' => $this->token, ], ]; - if (true === $this->isEmby) { - $opts['headers']['X-MediaBrowser-Token'] = $this->token; - } else { - $opts['headers']['X-Emby-Authorization'] = sprintf( - 'MediaBrowser Client="%s", Device="script", DeviceId="", Version="%s", Token="%s"', - Config::get('name'), - Config::get('version'), - $this->token - ); - } - - return array_replace_recursive($opts, $this->options['client'] ?? []); + return array_replace_recursive($this->options['client'] ?? [], $opts); } protected function getLibraries(Closure $ok, Closure $error, bool $includeParent = false): array @@ -396,11 +1001,6 @@ class JellyfinServer implements ServerInterface $this->checkConfig(true); try { - $this->logger->debug( - sprintf('Requesting libraries From %s.', $this->name), - ['url' => $this->url->getHost()] - ); - $url = $this->url->withPath(sprintf('/Users/%s/items/', $this->user))->withQuery( http_build_query( [ @@ -411,12 +1011,16 @@ class JellyfinServer implements ServerInterface ) ); + $this->logger->debug(sprintf('%s: Requesting list of server libraries.', $this->name), [ + 'url' => (string)$url + ]); + $response = $this->http->request('GET', (string)$url, $this->getHeaders()); if (200 !== $response->getStatusCode()) { $this->logger->error( sprintf( - 'Request to %s responded with unexpected code (%d).', + '%s: Request to get list of server libraries responded with unexpected code \'%d\'.', $this->name, $response->getStatusCode() ) @@ -425,38 +1029,46 @@ class JellyfinServer implements ServerInterface return []; } - $json = json_decode($response->getContent(false), true, flags: JSON_THROW_ON_ERROR); + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); $listDirs = ag($json, 'Items', []); if (empty($listDirs)) { - $this->logger->notice(sprintf('No libraries found at %s.', $this->name)); + $this->logger->notice( + sprintf('%s: Request to get list of server libraries responded with empty list.', $this->name) + ); Data::add($this->name, 'no_import_update', true); return []; } } catch (ExceptionInterface $e) { - $this->logger->error($e->getMessage(), [ - 'file' => $e->getFile(), - 'line' => $e->getLine() - ]); + $this->logger->error( + sprintf('%s: Request to get server libraries failed. %s', $this->name, $e->getMessage()), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ], + ); Data::add($this->name, 'no_import_update', true); return []; } catch (JsonException $e) { $this->logger->error( - sprintf('Unable to decode %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), + sprintf('%s: Unable to decode get server libraries JSON response. %s', $this->name, $e->getMessage()), [ 'file' => $e->getFile(), - 'line' => $e->getLine() - ] + 'line' => $e->getLine(), + ], ); Data::add($this->name, 'no_import_update', true); return []; } - $ignoreIds = null; - - if (null !== ($this->options['ignore'] ?? null)) { - $ignoreIds = array_map(fn($v) => trim($v), explode(',', (string)$this->options['ignore'])); + if (null !== ($ignoreIds = ag($this->options, 'ignore', null))) { + $ignoreIds = array_map(fn($v) => (int)trim($v), explode(',', (string)$ignoreIds)); } $promises = []; @@ -490,10 +1102,9 @@ class JellyfinServer implements ServerInterface ) ); - $this->logger->debug( - sprintf('Requesting %s - %s library parents content.', $this->name, $cName), - ['url' => $url] - ); + $this->logger->debug(sprintf('%s: Requesting \'%s\' series external ids.', $this->name, $cName), [ + 'url' => $url + ]); try { $promises[] = $this->http->request( @@ -509,12 +1120,17 @@ class JellyfinServer implements ServerInterface } catch (ExceptionInterface $e) { $this->logger->error( sprintf( - 'Request to %s library - %s parents failed. Reason: %s', + '%s: Request for \'%s\' series external ids has failed. %s', $this->name, $cName, $e->getMessage() ), - ['url' => $url] + [ + 'url' => $url, + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ] ); continue; } @@ -528,19 +1144,22 @@ class JellyfinServer implements ServerInterface if ('movies' !== $type && 'tvshows' !== $type) { $unsupported++; - $this->logger->debug(sprintf('Skipping %s library - %s. Not supported type.', $this->name, $title)); - + $this->logger->debug(sprintf('%s: Skipping \'%s\' library. Unsupported type.', $this->name, $title), [ + 'id' => $key, + 'type' => $type, + ]); continue; } $type = $type === 'movies' ? StateInterface::TYPE_MOVIE : StateInterface::TYPE_EPISODE; $cName = sprintf('(%s) - (%s:%s)', $title, $type, $key); - if (null !== $ignoreIds && in_array($key, $ignoreIds, true)) { + if (null !== $ignoreIds && true === in_array($key, $ignoreIds)) { $ignored++; - $this->logger->notice( - sprintf('Skipping %s library - %s. Ignored by user config option.', $this->name, $cName) - ); + $this->logger->notice(sprintf('%s: Skipping \'%s\'. Ignored by user.', $this->name, $title), [ + 'id' => $key, + 'type' => $type, + ]); continue; } @@ -558,7 +1177,9 @@ class JellyfinServer implements ServerInterface ) ); - $this->logger->debug(sprintf('Requesting %s - %s library content.', $this->name, $cName), ['url' => $url]); + $this->logger->debug(sprintf('%s: Requesting \'%s\' content.', $this->name, $cName), [ + 'url' => $url + ]); try { $promises[] = $this->http->request( @@ -573,23 +1194,23 @@ class JellyfinServer implements ServerInterface ); } catch (ExceptionInterface $e) { $this->logger->error( - sprintf('Request to %s library - %s failed. Reason: %s', $this->name, $cName, $e->getMessage()), - ['url' => $url] + sprintf('%s: Request for \'%s\' content has failed. %s', $this->name, $cName, $e->getMessage()), + [ + 'url' => $url, + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ] ); continue; } } if (0 === count($promises)) { - $this->logger->notice( - sprintf( - 'No requests were made to any of %s libraries. (total: %d, ignored: %d, Unsupported: %d).', - $this->name, - count($listDirs), - $ignored, - $unsupported - ) - ); + $this->logger->notice(sprintf('%s: No library requests were made.', $this->name), [ + 'total' => count($listDirs), + 'ignored' => $ignored, + 'unsupported' => $unsupported, + ]); Data::add($this->name, 'no_import_update', true); return []; } @@ -597,784 +1218,6 @@ class JellyfinServer implements ServerInterface return $promises; } - public function search(string $query, int $limit = 25): array - { - $this->checkConfig(true); - - try { - $this->logger->debug( - sprintf('Search for \'%s\' in %s.', $query, $this->name), - ['url' => $this->url->getHost()] - ); - - $url = $this->url->withPath(sprintf('/Users/%s/items/', $this->user))->withQuery( - http_build_query( - [ - 'searchTerm' => $query, - 'Limit' => $limit, - 'Recursive' => 'true', - 'Fields' => 'ProviderIds', - 'enableUserData' => 'true', - 'enableImages' => 'false', - 'IncludeItemTypes' => 'Episode,Movie,Series', - ] - ) - ); - - $this->logger->debug('Request', ['url' => $url]); - - $response = $this->http->request('GET', (string)$url, $this->getHeaders()); - - if (200 !== $response->getStatusCode()) { - throw new RuntimeException( - sprintf( - 'Request to %s responded with unexpected code (%d).', - $this->name, - $response->getStatusCode() - ) - ); - } - - $json = json_decode($response->getContent(false), true, flags: JSON_THROW_ON_ERROR); - - return ag($json, 'Items', []); - } catch (ExceptionInterface|JsonException $e) { - throw new RuntimeException($e->getMessage(), $e->getCode(), $e); - } - } - - public function listLibraries(): array - { - $this->checkConfig(true); - - try { - $this->logger->debug( - sprintf('Requesting libraries From %s.', $this->name), - ['url' => $this->url->getHost()] - ); - - $url = $this->url->withPath(sprintf('/Users/%s/items/', $this->user))->withQuery( - http_build_query( - [ - 'Recursive' => 'false', - 'Fields' => 'ProviderIds', - 'enableUserData' => 'true', - 'enableImages' => 'false', - ] - ) - ); - - $response = $this->http->request('GET', (string)$url, $this->getHeaders()); - - if (200 !== $response->getStatusCode()) { - $this->logger->error( - sprintf( - 'Request to %s responded with unexpected code (%d).', - $this->name, - $response->getStatusCode() - ) - ); - return []; - } - - $json = json_decode($response->getContent(false), true, flags: JSON_THROW_ON_ERROR); - - $listDirs = ag($json, 'Items', []); - - if (empty($listDirs)) { - $this->logger->error(sprintf('No libraries found at %s.', $this->name)); - return []; - } - } catch (ExceptionInterface $e) { - $this->logger->error($e->getMessage(), [ - 'file' => $e->getFile(), - 'line' => $e->getLine() - ]); - return []; - } catch (JsonException $e) { - $this->logger->error( - sprintf('Unable to decode %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine() - ] - ); - return []; - } - - $ignoreIds = null; - - if (null !== ($this->options['ignore'] ?? null)) { - $ignoreIds = array_map(fn($v) => trim($v), explode(',', (string)$this->options['ignore'])); - } - - $list = []; - - foreach ($listDirs as $section) { - $key = (string)ag($section, 'Id'); - $title = ag($section, 'Name', '???'); - $type = ag($section, 'CollectionType', 'unknown'); - $isIgnored = null !== $ignoreIds && in_array($key, $ignoreIds); - - $list[] = [ - 'ID' => $key, - 'Title' => $title, - 'Type' => $type, - 'Ignored' => $isIgnored ? 'Yes' : 'No', - 'Supported' => 'movies' !== $type && 'tvshows' !== $type ? 'No' : 'Yes', - ]; - } - - return $list; - } - - public function pull(ImportInterface $mapper, DateTimeInterface|null $after = null): array - { - return $this->getLibraries( - ok: function (string $cName, string $type) use ($after, $mapper) { - return function (ResponseInterface $response) use ($mapper, $cName, $type, $after) { - try { - if (200 !== $response->getStatusCode()) { - $this->logger->error( - sprintf( - 'Request to %s - %s responded with (%d) unexpected code.', - $this->name, - $cName, - $response->getStatusCode() - ) - ); - return; - } - - // -- sandbox external library code to prevent complete failure when error occurs. - try { - $it = Items::fromIterable( - httpClientChunks($this->http->stream($response)), - [ - 'pointer' => '/Items', - 'decoder' => new ErrorWrappingDecoder( - new ExtJsonDecoder(options: JSON_INVALID_UTF8_IGNORE) - ) - ] - ); - - $this->logger->info(sprintf('Parsing %s - %s response.', $this->name, $cName)); - - foreach ($it as $entity) { - if ($entity instanceof DecodingError) { - $this->logger->debug( - sprintf('Failed to decode one result of %s - %s response.', $this->name, $cName) - ); - continue; - } - $this->processImport($mapper, $type, $cName, $entity, $after); - } - } catch (PathNotFoundException $e) { - $this->logger->error( - sprintf( - 'Failed to find media items path in %s - %s - response. Most likely empty section?', - $this->name, - $cName, - ), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'error' => $e->getMessage(), - ], - ); - return; - } catch (Throwable $e) { - $this->logger->error( - sprintf( - 'Unable to parse %s - %s response.', - $this->name, - $cName, - ), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'error' => $e->getMessage(), - ], - ); - return; - } - - $this->logger->info( - sprintf( - 'Finished Parsing %s - %s (%d objects) response.', - $this->name, - $cName, - Data::get("{$this->name}.{$cName}_total") - ) - ); - } catch (JsonException $e) { - $this->logger->error( - sprintf( - 'Failed to decode %s - %s - response. Reason: \'%s\'.', - $this->name, - $cName, - $e->getMessage() - ) - ); - return; - } - }; - }, - error: function (string $cName, string $type, UriInterface|string $url) { - return fn(Throwable $e) => $this->logger->error( - sprintf('Request to %s - %s - failed. Reason: \'%s\'.', $this->name, $cName, $e->getMessage()), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'url' => $url - ] - ); - }, - includeParent: true, - ); - } - - public function push(array $entities, DateTimeInterface|null $after = null): array - { - $this->checkConfig(true); - - $requests = []; - - foreach ($entities as &$entity) { - if (false === ($this->options[ServerInterface::OPT_EXPORT_IGNORE_DATE] ?? false)) { - if (null !== $after && $after->getTimestamp() > $entity->updated) { - $entity = null; - continue; - } - } - - $entity->jf_id = null; - - foreach ([...$entity->getRelativePointers(), ...$entity->getPointers()] as $guid) { - if (null === ($this->cacheData[$guid] ?? null)) { - continue; - } - $entity->jf_id = $this->cacheData[$guid]; - break; - } - } - - unset($entity); - - foreach ($entities as $entity) { - if (null === $entity) { - continue; - } - - if ($entity->isMovie()) { - $iName = sprintf( - '%s - [%s (%d)]', - $this->name, - ag($entity->meta, 'title', '??'), - ag($entity->meta, 'year', 0000), - ); - } else { - $iName = trim( - sprintf( - '%s - [%s - (%dx%d)]', - $this->name, - ag($entity->meta, 'series', '??'), - ag($entity->meta, 'season', 0), - ag($entity->meta, 'episode', 0), - ) - ); - } - - if (null === ($entity->jf_id ?? null)) { - $this->logger->notice(sprintf('Ignoring %s. Not found in \'%s\' local cache.', $iName, $this->name)); - continue; - } - - try { - $requests[] = $this->http->request( - 'GET', - (string)$this->url->withPath(sprintf('/Users/%s/items', $this->user))->withQuery( - http_build_query( - [ - 'ids' => $entity->jf_id, - 'Fields' => 'ProviderIds,DateCreated,OriginalTitle,SeasonUserData,DateLastSaved', - 'enableUserData' => 'true', - 'enableImages' => 'false', - ] - ) - ), - array_replace_recursive($this->getHeaders(), [ - 'user_data' => [ - 'state' => &$entity, - ] - ]) - ); - } catch (Throwable $e) { - $this->logger->error($e->getMessage()); - } - } - - $stateRequests = []; - - foreach ($requests as $response) { - try { - $json = ag( - json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR), - 'Items', - [] - )[0] ?? []; - - $state = $response->getInfo('user_data')['state']; - assert($state instanceof StateInterface); - - if ($state->isMovie()) { - $iName = sprintf( - '%s - [%s (%d)]', - $this->name, - $state->meta['title'] ?? '??', - $state->meta['year'] ?? 0000, - ); - } else { - $iName = trim( - sprintf( - '%s - [%s - (%dx%d)]', - $this->name, - $state->meta['series'] ?? '??', - $state->meta['season'] ?? 0, - $state->meta['episode'] ?? 0, - ) - ); - } - - if (empty($json)) { - $this->logger->info(sprintf('Ignoring %s. does not exists.', $iName)); - continue; - } - - $isWatched = (int)(bool)ag($json, 'UserData.Played', false); - - if ($state->watched === $isWatched) { - $this->logger->debug(sprintf('Ignoring %s. State is unchanged.', $iName)); - continue; - } - - if (false === ($this->options[ServerInterface::OPT_EXPORT_IGNORE_DATE] ?? false)) { - $date = ag( - $json, - 'UserData.LastPlayedDate', - ag($json, 'DateCreated', ag($json, 'PremiereDate', null)) - ); - - if (null === $date) { - $this->logger->error(sprintf('Ignoring %s. No date is set.', $iName)); - continue; - } - - $date = strtotime($date); - - if ($date >= $state->updated) { - $this->logger->debug(sprintf('Ignoring %s. Date is newer then what in db.', $iName)); - continue; - } - } - - $stateRequests[] = $this->http->request( - 1 === $state->watched ? 'POST' : 'DELETE', - (string)$this->url->withPath(sprintf('/Users/%s/PlayedItems/%s', $this->user, ag($json, 'Id'))), - array_replace_recursive( - $this->getHeaders(), - [ - 'user_data' => [ - 'state' => 1 === $state->watched ? 'Watched' : 'Unwatched', - 'itemName' => $iName, - ], - ] - ) - ); - } catch (Throwable $e) { - $this->logger->error($e->getMessage()); - } - } - - unset($requests); - - return $stateRequests; - } - - public function export(ExportInterface $mapper, DateTimeInterface|null $after = null): array - { - return $this->getLibraries( - ok: function (string $cName, string $type) use ($mapper, $after) { - return function (ResponseInterface $response) use ($mapper, $cName, $type, $after) { - try { - if (200 !== $response->getStatusCode()) { - $this->logger->error( - sprintf( - 'Request to %s - %s responded unexpected http status code with (%d).', - $this->name, - $cName, - $response->getStatusCode() - ) - ); - return; - } - - try { - $it = Items::fromIterable( - httpClientChunks($this->http->stream($response)), - [ - 'pointer' => '/Items', - 'decoder' => new ErrorWrappingDecoder( - new ExtJsonDecoder(options: JSON_INVALID_UTF8_IGNORE) - ) - ] - ); - - $this->logger->info(sprintf('Parsing %s - %s response.', $this->name, $cName)); - - foreach ($it as $entity) { - if ($entity instanceof DecodingError) { - $this->logger->debug( - sprintf('Failed to decode one result of %s - %s response.', $this->name, $cName) - ); - continue; - } - $this->processExport($mapper, $type, $cName, $entity, $after); - } - } catch (PathNotFoundException $e) { - $this->logger->error( - sprintf( - 'Failed to find media items path in %s - %s - response. Most likely empty section?', - $this->name, - $cName, - ), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'error' => $e->getMessage(), - ], - ); - return; - } catch (Throwable $e) { - $this->logger->error( - sprintf( - 'Unable to parse %s - %s response.', - $this->name, - $cName, - ), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'error' => $e->getMessage(), - ], - ); - return; - } - - $this->logger->info( - sprintf( - 'Finished Parsing %s - %s (%d objects) response.', - $this->name, - $cName, - Data::get("{$this->name}.{$type}_total") - ) - ); - } catch (JsonException $e) { - $this->logger->error( - sprintf( - 'Failed to decode %s - %s - response. Reason: \'%s\'.', - $this->name, - $cName, - $e->getMessage() - ), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ] - ); - return; - } - }; - }, - error: function (string $cName, string $type, UriInterface|string $url) { - return fn(Throwable $e) => $this->logger->error( - sprintf('Request to %s - %s - failed. Reason: \'%s\'.', $this->name, $cName, $e->getMessage()), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'url' => $url - ] - ); - }, - includeParent: false, - ); - } - - public function cache(): array - { - return $this->getLibraries( - ok: function (string $cName, string $type) { - return function (ResponseInterface $response) use ($cName, $type) { - try { - if (200 !== $response->getStatusCode()) { - $this->logger->error( - sprintf( - 'Request to %s - %s responded with (%d) unexpected code.', - $this->name, - $cName, - $response->getStatusCode() - ) - ); - return; - } - - try { - $it = Items::fromIterable( - httpClientChunks($this->http->stream($response)), - [ - 'pointer' => '/Items', - 'decoder' => new ErrorWrappingDecoder( - new ExtJsonDecoder(options: JSON_INVALID_UTF8_IGNORE) - ) - ] - ); - - $this->logger->info( - sprintf('Parsing %s - %s response.', $this->name, $cName) - ); - - foreach ($it as $entity) { - if ($entity instanceof DecodingError) { - $this->logger->debug( - sprintf('Failed to decode one result of %s - %s response.', $this->name, $cName) - ); - continue; - } - $this->processForCache($entity, $type, $cName); - } - } catch (PathNotFoundException $e) { - $this->logger->error( - sprintf( - 'Failed to find media items path in %s - %s - response. Most likely empty section?', - $this->name, - $cName, - ), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'error' => $e->getMessage(), - ], - ); - return; - } catch (Throwable $e) { - $this->logger->error( - sprintf( - 'Unable to parse %s - %s response.', - $this->name, - $cName, - ), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'error' => $e->getMessage(), - ], - ); - return; - } - - $this->logger->info(sprintf('Finished Parsing %s - %s response.', $this->name, $cName)); - } catch (JsonException $e) { - $this->logger->error( - sprintf( - 'Failed to decode %s - %s - response. Reason: \'%s\'.', - $this->name, - $cName, - $e->getMessage() - ) - ); - return; - } - }; - }, - error: function (string $cName, string $type, UriInterface|string $url) { - return fn(Throwable $e) => $this->logger->error( - sprintf('Request to %s - %s - failed. Reason: \'%s\'.', $this->name, $cName, $e->getMessage()), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'url' => $url - ] - ); - }, - includeParent: true, - ); - } - - protected function processExport( - ExportInterface $mapper, - string $type, - string $library, - StdClass $item, - DateTimeInterface|null $after = null - ): void { - Data::increment($this->name, $type . '_total'); - - try { - if (StateInterface::TYPE_MOVIE === $type) { - $iName = sprintf( - '%s - %s - [%s (%d)]', - $this->name, - $library, - $item->Name ?? $item->OriginalTitle ?? '??', - $item->ProductionYear ?? 0000 - ); - } else { - $iName = trim( - sprintf( - '%s - %s - [%s - (%dx%d)]', - $this->name, - $library, - $item->SeriesName ?? '??', - $item->ParentIndexNumber ?? 0, - $item->IndexNumber ?? 0, - ) - ); - } - - $date = $item->UserData?->LastPlayedDate ?? $item->DateCreated ?? $item->PremiereDate ?? null; - - if (null === $date) { - $this->logger->error(sprintf('Ignoring %s. No date is set.', $iName), ['item' => (array)$item]); - Data::increment($this->name, $type . '_ignored_no_date_is_set'); - return; - } - - $rItem = $this->createEntity($item, $type); - - if (!$rItem->hasGuids() && !$rItem->hasRelativeGuid()) { - $guids = (array)($item->ProviderIds ?? []); - $this->logger->debug(sprintf('Ignoring %s. No Valid/supported guids.', $iName), [ - 'guids' => !empty($guids) ? $guids : 'None', - 'rGuids' => $rItem->hasRelativeGuid() ? $rItem->getRelativeGuids() : 'None', - ]); - Data::increment($this->name, $type . '_ignored_no_supported_guid'); - return; - } - - if (false === ($this->options[ServerInterface::OPT_EXPORT_IGNORE_DATE] ?? false)) { - if (null !== $after && $rItem->updated >= $after->getTimestamp()) { - $this->logger->debug( - sprintf('Ignoring %s. Ignored date is equal or newer than lastSync.', $iName), - [ - 'itemDate' => makeDate($rItem->updated), - 'lastSync' => makeDate($after->getTimestamp()), - ] - ); - Data::increment($this->name, $type . '_ignored_date_is_equal_or_higher'); - return; - } - } - - if (null === ($entity = $mapper->get($rItem))) { - $guids = (array)($item->ProviderIds ?? []); - $this->logger->debug( - sprintf( - 'Ignoring %s. [State: %s] - Not found in db.', - $iName, - $rItem->watched ? 'Played' : 'Unplayed' - ), - [ - 'guids' => !empty($guids) ? $guids : 'None', - 'rGuids' => $rItem->hasRelativeGuid() ? $rItem->getRelativeGuids() : 'None', - ] - ); - Data::increment($this->name, $type . '_ignored_not_found_in_db'); - return; - } - - if ($rItem->watched === $entity->watched) { - $this->logger->debug(sprintf('Ignoring %s. State is equal to db state.', $iName), [ - 'State' => $entity->watched ? 'Played' : 'Unplayed' - ]); - Data::increment($this->name, $type . '_ignored_state_unchanged'); - return; - } - - if (false === ($this->options[ServerInterface::OPT_EXPORT_IGNORE_DATE] ?? false)) { - if ($rItem->updated >= $entity->updated) { - $this->logger->debug(sprintf('Ignoring %s. Date is newer or equal to db entry.', $iName), [ - 'db' => makeDate($rItem->updated), - 'server' => makeDate($entity->updated), - ]); - Data::increment($this->name, $type . '_ignored_date_is_newer'); - return; - } - } - - $this->logger->info(sprintf('Queuing %s.', $iName), [ - 'State' => [ - 'db' => $entity->watched ? 'Played' : 'Unplayed', - 'server' => $rItem->watched ? 'Played' : 'Unplayed' - ], - ]); - - $mapper->queue( - $this->http->request( - 1 === $entity->watched ? 'POST' : 'DELETE', - (string)$this->url->withPath(sprintf('/Users/%s/PlayedItems/%s', $this->user, $item->Id)), - array_replace_recursive($this->getHeaders(), [ - 'user_data' => [ - 'state' => 1 === $entity->watched ? 'Watched' : 'Unwatched', - 'itemName' => $iName, - ], - ]) - ) - ); - } catch (Throwable $e) { - $this->logger->error($e->getMessage(), [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ]); - } - } - - protected function processShow(StdClass $item, string $library): void - { - $providersId = (array)($item->ProviderIds ?? []); - - if (!$this->hasSupportedIds($providersId)) { - $iName = sprintf( - '%s - %s - [%s (%d)]', - $this->name, - $library, - $item->Name ?? $item->OriginalTitle ?? '??', - $item->ProductionYear ?? 0000 - ); - - $message = sprintf('Ignoring %s. No valid/supported GUIDs.', $iName); - if (empty($providersId)) { - $message .= ' Most likely unmatched TV show.'; - } - $this->logger->info($message, ['guids' => empty($providersId) ? 'None' : $providersId]); - return; - } - - $guids = []; - - foreach (Guid::fromArray($this->getGuids($providersId))->getPointers() as $guid) { - [$type, $id] = explode('://', $guid); - $guids[$type] = $id; - } - - $this->showInfo[$item->Id] = $guids; - } - protected function processImport( ImportInterface $mapper, string $type, @@ -1393,8 +1236,7 @@ class JellyfinServer implements ServerInterface if (StateInterface::TYPE_MOVIE === $type) { $iName = sprintf( - '%s - %s - [%s (%d)]', - $this->name, + '%s - [%s (%d)]', $library, $item->Name ?? $item->OriginalTitle ?? '??', $item->ProductionYear ?? 0000 @@ -1402,8 +1244,7 @@ class JellyfinServer implements ServerInterface } else { $iName = trim( sprintf( - '%s - %s - [%s - (%sx%s)]', - $this->name, + '%s - [%s - (%sx%s)]', $library, $item->SeriesName ?? '??', str_pad((string)($item->ParentIndexNumber ?? 0), 2, '0', STR_PAD_LEFT), @@ -1415,7 +1256,12 @@ class JellyfinServer implements ServerInterface $date = $item->UserData?->LastPlayedDate ?? $item->DateCreated ?? $item->PremiereDate ?? null; if (null === $date) { - $this->logger->error(sprintf('Ignoring %s. No date is set.', $iName)); + $this->logger->debug( + sprintf('%s: Ignoring \'%s\'. Date is not set on remote item.', $this->name, $iName), + [ + 'payload' => $item, + ] + ); Data::increment($this->name, $type . '_ignored_no_date_is_set'); return; } @@ -1427,35 +1273,42 @@ class JellyfinServer implements ServerInterface $name = Config::get('tmpDir') . '/debug/' . $this->name . '.' . $item->Id . '.json'; if (!file_exists($name)) { - file_put_contents($name, json_encode($item, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + file_put_contents( + $name, + json_encode( + $item, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_IGNORE + ) + ); } } - $guids = (array)($item->ProviderIds ?? []); + $providerIds = (array)($item->ProviderIds ?? []); - $message = sprintf('Ignoring %s. No valid/supported GUIDs.', $iName); + $message = sprintf('%s: Ignoring \'%s\'. No valid/supported external ids.', $this->name, $iName); - if (empty($guids)) { + if (empty($providerIds)) { $message .= ' Most likely unmatched item.'; } - $this->logger->info($message, ['guids' => empty($guids) ? 'None' : $guids]); + $this->logger->info($message, ['guids' => !empty($providerIds) ? $providerIds : 'None']); Data::increment($this->name, $type . '_ignored_no_supported_guid'); return; } - $mapper->add($this->name, $iName, $entity, ['after' => $after]); + $mapper->add($this->name, $this->name . ' - ' . $iName, $entity, ['after' => $after]); } catch (Throwable $e) { $this->logger->error($e->getMessage(), [ 'file' => $e->getFile(), - 'line' => $e->getLine() + 'line' => $e->getLine(), + 'kind' => get_class($e), ]); } } - protected function processForCache(StdClass $item, string $type, string $library): void + protected function processCache(StdClass $item, string $type, string $library): void { try { if ('show' === $type) { @@ -1473,11 +1326,182 @@ class JellyfinServer implements ServerInterface } catch (Throwable $e) { $this->logger->error($e->getMessage(), [ 'file' => $e->getFile(), - 'line' => $e->getLine() + 'line' => $e->getLine(), + 'kind' => get_class($e), ]); } } + protected function processExport( + ExportInterface $mapper, + string $type, + string $library, + StdClass $item, + DateTimeInterface|null $after = null + ): void { + Data::increment($this->name, $type . '_total'); + + try { + if (StateInterface::TYPE_MOVIE === $type) { + $iName = sprintf( + '%s - [%s (%d)]', + $library, + $item->Name ?? $item->OriginalTitle ?? '??', + $item->ProductionYear ?? 0000 + ); + } else { + $iName = trim( + sprintf( + '%s - [%s - (%dx%d)]', + $library, + $item->SeriesName ?? '??', + str_pad((string)($item->ParentIndexNumber ?? 0), 2, '0', STR_PAD_LEFT), + str_pad((string)($item->IndexNumber ?? 0), 3, '0', STR_PAD_LEFT), + ) + ); + } + + $date = $item->UserData?->LastPlayedDate ?? $item->DateCreated ?? $item->PremiereDate ?? null; + + if (null === $date) { + $this->logger->notice( + sprintf('%s: Ignoring \'%s\'. Date is not set on remote item.', $this->name, $iName), + [ + 'payload' => get_object_vars($item), + ] + ); + Data::increment($this->name, $type . '_ignored_no_date_is_set'); + return; + } + + $rItem = $this->createEntity($item, $type); + + if (!$rItem->hasGuids() && !$rItem->hasRelativeGuid()) { + $providerIds = (array)($item->ProviderIds ?? []); + + $message = sprintf('%s: Ignoring \'%s\'. No valid/supported external ids.', $this->name, $iName); + + if (empty($providerIds)) { + $message .= ' Most likely unmatched item.'; + } + + $this->logger->debug($message, ['guids' => !empty($providerIds) ? $providerIds : 'None']); + Data::increment($this->name, $type . '_ignored_no_supported_guid'); + return; + } + + if (false === ag($this->options, ServerInterface::OPT_EXPORT_IGNORE_DATE, false)) { + if (null !== $after && $rItem->updated >= $after->getTimestamp()) { + $this->logger->debug( + sprintf( + '%s: Ignoring \'%s\'. Remote item date is equal or newer than last sync date.', + $this->name, + $iName + ) + ); + Data::increment($this->name, $type . '_ignored_date_is_equal_or_higher'); + return; + } + } + + if (null === ($entity = $mapper->get($rItem))) { + $this->logger->debug( + sprintf( + '%s: Ignoring \'%s\' Not found in backend store. Run state:import to import the item.', + $this->name, + $iName, + ), + [ + 'played' => $rItem->isWatched() ? 'Yes' : 'No', + 'guids' => $rItem->hasGuids() ? $rItem->getGuids() : 'None', + 'rGuids' => $rItem->hasRelativeGuid() ? $rItem->getRelativeGuids() : 'None', + ] + ); + Data::increment($this->name, $type . '_ignored_not_found_in_db'); + return; + } + + if ($rItem->watched === $entity->watched) { + $this->logger->debug(sprintf('%s: Ignoring \'%s\'. Played state is identical.', $this->name, $iName), [ + 'backend' => $entity->isWatched() ? 'Played' : 'Unplayed', + 'remote' => $rItem->isWatched() ? 'Played' : 'Unplayed', + ]); + Data::increment($this->name, $type . '_ignored_state_unchanged'); + return; + } + + if (false === ag($this->options, ServerInterface::OPT_EXPORT_IGNORE_DATE, false)) { + if ($rItem->updated >= $entity->updated) { + $this->logger->debug( + sprintf('%s: Ignoring \'%s\'. Date is newer or equal to backend entity.', $this->name, $iName), + [ + 'backend' => makeDate($entity->updated), + 'remote' => makeDate($rItem->updated), + ] + ); + Data::increment($this->name, $type . '_ignored_date_is_newer'); + return; + } + } + + $url = $this->url->withPath(sprintf('/Users/%s/PlayedItems/%s', $this->user, $item->Id)); + + $this->logger->info(sprintf('%s: Queuing \'%s\'.', $this->name, $iName), [ + 'backend' => $entity->isWatched() ? 'Played' : 'Unplayed', + 'remote' => $rItem->isWatched() ? 'Played' : 'Unplayed', + 'method' => $entity->isWatched() ? 'POST' : 'DELETE', + 'url' => $url, + ]); + + $mapper->queue( + $this->http->request( + $entity->isWatched() ? 'POST' : 'DELETE', + (string)$url, + array_replace_recursive($this->getHeaders(), [ + 'user_data' => [ + 'itemName' => $iName, + 'state' => $entity->isWatched() ? 'Played' : 'Unplayed', + ], + ]) + ) + ); + } catch (Throwable $e) { + $this->logger->error($e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ]); + } + } + + protected function processShow(StdClass $item, string $library): void + { + $providersId = (array)($item->ProviderIds ?? []); + + if (!$this->hasSupportedIds($providersId)) { + $iName = sprintf( + '%s - [%s (%d)]', + $library, + $item->Name ?? $item->OriginalTitle ?? '??', + $item->ProductionYear ?? 0000 + ); + + $message = sprintf('%s: Ignoring \'%s\'. No valid/supported external ids.', $this->name, $iName); + + if (empty($providersId)) { + $message .= ' Most likely unmatched TV show.'; + } + + $this->logger->info($message, ['guids' => empty($providersId) ? 'None' : $providersId]); + + return; + } + + $cacheName = ag($item, ['Name', 'OriginalTitle'], '??') . ':' . ag($item, 'ProductionYear', 0000); + $this->cacheShow[$item->Id] = Guid::fromArray($this->getGuids($providersId))->getAll(); + $this->cacheShow[$cacheName] = &$this->cacheShow[$item->Id]; + } + protected function getGuids(array $ids): array { $guid = []; @@ -1516,37 +1540,18 @@ class JellyfinServer implements ServerInterface return false; } - /** - * @throws InvalidArgumentException - */ - public function __destruct() - { - if (!empty($this->cacheKey)) { - $this->cache->set($this->cacheKey, $this->cacheData, new DateInterval('P1Y')); - } - - if (!empty($this->cacheShowKey)) { - $this->cache->set($this->cacheShowKey, $this->cacheShow, new DateInterval('PT30M')); - } - } - - protected static function afterString(string $subject, string $search): string - { - return empty($search) ? $subject : array_reverse(explode($search, $subject, 2))[0]; - } - protected function checkConfig(bool $checkUrl = true, bool $checkToken = true, bool $checkUser = true): void { if (true === $checkUrl && !($this->url instanceof UriInterface)) { - throw new RuntimeException(afterLast(__CLASS__, '\\') . ': No host was set.'); + throw new RuntimeException(self::NAME . ': No host was set.'); } if (true === $checkToken && null === $this->token) { - throw new RuntimeException(afterLast(__CLASS__, '\\') . ': No token was set.'); + throw new RuntimeException(self::NAME . ': No token was set.'); } if (true === $checkUser && null === $this->user) { - throw new RuntimeException(afterLast(__CLASS__, '\\') . ': No User was set.'); + throw new RuntimeException(self::NAME . ': No User was set.'); } } @@ -1554,13 +1559,12 @@ class JellyfinServer implements ServerInterface { $date = strtotime($item->UserData?->LastPlayedDate ?? $item->DateCreated ?? $item->PremiereDate); - /** @noinspection PhpArrayIndexImmediatelyRewrittenInspection */ $row = [ 'type' => $type, 'updated' => $date, 'watched' => (int)(bool)($item->UserData?->Played ?? false), 'via' => $this->name, - 'title' => '??', + 'title' => $item->Name ?? $item->OriginalTitle ?? '??', 'year' => $item->ProductionYear ?? 0000, 'season' => null, 'episode' => null, @@ -1571,9 +1575,7 @@ class JellyfinServer implements ServerInterface ], ]; - if (StateInterface::TYPE_MOVIE === $type) { - $row['title'] = $item->Name ?? $item->OriginalTitle ?? '??'; - } else { + if (StateInterface::TYPE_EPISODE === $type) { $row['title'] = $item->SeriesName ?? '??'; $row['season'] = $item->ParentIndexNumber ?? 0; $row['episode'] = $item->IndexNumber ?? 0; @@ -1585,6 +1587,8 @@ class JellyfinServer implements ServerInterface if (null !== ($item->SeriesId ?? null)) { $row['parent'] = $this->showInfo[$item->SeriesId] ?? []; } + } else { + throw new RuntimeException('Invalid content type.'); } $entity = Container::get(StateInterface::class)::fromArray($row); @@ -1596,10 +1600,10 @@ class JellyfinServer implements ServerInterface return $entity; } - private function getEpisodeParent(mixed $id, string|null $series): array + protected function getEpisodeParent(mixed $id, string $cacheName): array { - if (null !== $series && array_key_exists($series, $this->cacheShow)) { - return $this->cacheShow[$series]; + if (array_key_exists($cacheName, $this->cacheShow)) { + return $this->cacheShow[$cacheName]; } try { @@ -1641,48 +1645,46 @@ class JellyfinServer implements ServerInterface return []; } - $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); - - $series = $json['Name'] ?? $json['OriginalTitle'] ?? $json['Id'] ?? random_int(1, PHP_INT_MAX); + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); $providersId = (array)ag($json, 'ProviderIds', []); if (!$this->hasSupportedIds($providersId)) { - $this->cacheShow[$series] = []; - return $this->cacheShow[$series]; + $this->cacheShow[$cacheName] = $this->cacheShow[$seriesId] = []; + return $this->cacheShow[$cacheName]; } - $guids = []; + $this->cacheShow[$seriesId] = Guid::fromArray($this->getGuids($providersId))->getAll(); + $this->cacheShow[$cacheName] = &$this->cacheShow[$seriesId]; - foreach (Guid::fromArray($this->getGuids($providersId))->getPointers() as $guid) { - [$type, $id] = explode('://', $guid); - $guids[$type] = $id; - } - - $this->cacheShow[$series] = $guids; - - return $this->cacheShow[$series]; + return $this->cacheShow[$seriesId]; } catch (ExceptionInterface $e) { $this->logger->error($e->getMessage(), [ 'file' => $e->getFile(), - 'line' => $e->getLine() + 'line' => $e->getLine(), + 'kind' => get_class($e), ]); return []; } catch (JsonException $e) { $this->logger->error( - sprintf('Unable to decode %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), + sprintf('%s: Unable to decode \'%s\' JSON response. %s', $this->name, $cacheName, $e->getMessage()), [ 'file' => $e->getFile(), - 'line' => $e->getLine() + 'line' => $e->getLine(), ] ); return []; } catch (Throwable $e) { $this->logger->error( - sprintf('ERROR: %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), + sprintf('%s: Failed to handle \'%s\' response. %s', $this->name, $cacheName, $e->getMessage()), [ 'file' => $e->getFile(), - 'line' => $e->getLine() + 'line' => $e->getLine(), + 'kind' => get_class($e), ] ); return []; diff --git a/src/Libs/Servers/PlexServer.php b/src/Libs/Servers/PlexServer.php index a69d5e4e..1bbc380e 100644 --- a/src/Libs/Servers/PlexServer.php +++ b/src/Libs/Servers/PlexServer.php @@ -89,12 +89,13 @@ class PlexServer implements ServerInterface protected array $persist = []; protected string $cacheKey = ''; protected array $cacheData = []; + + protected string $cacheShowKey = ''; + protected array $cacheShow = []; + protected string|int|null $uuid = null; protected string|int|null $user = null; - protected array $showInfo = []; - protected array $cacheShow = []; - protected string $cacheShowKey = ''; public function __construct( protected HttpClientInterface $http, @@ -118,6 +119,7 @@ class PlexServer implements ServerInterface $cloned = clone $this; $cloned->cacheData = []; + $cloned->cacheShow = []; $cloned->name = $name; $cloned->url = $url; $cloned->token = $token; @@ -127,6 +129,7 @@ class PlexServer implements ServerInterface $cloned->persist = $persist; $cloned->cacheKey = $options['cache_key'] ?? md5(__CLASS__ . '.' . $name . $url); $cloned->cacheShowKey = $cloned->cacheKey . '_show'; + $cloned->initialized = true; if ($cloned->cache->has($cloned->cacheKey)) { $cloned->cacheData = $cloned->cache->get($cloned->cacheKey); @@ -136,8 +139,6 @@ class PlexServer implements ServerInterface $cloned->cacheShow = $cloned->cache->get($cloned->cacheShowKey); } - $cloned->initialized = true; - return $cloned; } @@ -192,7 +193,7 @@ class PlexServer implements ServerInterface if (200 !== $response->getStatusCode()) { throw new RuntimeException( sprintf( - 'Request to %s responded with unexpected code (%d).', + '%s: Request to get users list responded with unexpected code \'%d\'.', $this->name, $response->getStatusCode() ) @@ -253,9 +254,13 @@ class PlexServer implements ServerInterface return $this; } - public static function processRequest(ServerRequestInterface $request): ServerRequestInterface + public static function processRequest(ServerRequestInterface $request, array $opts = []): ServerRequestInterface { + $logger = null; + try { + $logger = $opts[LoggerInterface::class] ?? Container::get(LoggerInterface::class); + $userAgent = ag($request->getServerParams(), 'HTTP_USER_AGENT', ''); if (false === str_starts_with($userAgent, 'PlexMediaServer/')) { @@ -264,7 +269,7 @@ class PlexServer implements ServerInterface $payload = ag($request->getParsedBody() ?? [], 'payload', null); - if (null === $payload || null === ($json = json_decode((string)$payload, true))) { + if (null === ($json = json_decode(json: $payload, associative: true, flags: JSON_INVALID_UTF8_IGNORE))) { return $request; } @@ -284,9 +289,10 @@ class PlexServer implements ServerInterface $request = $request->withAttribute($key, $val); } } catch (Throwable $e) { - Container::get(LoggerInterface::class)->error($e->getMessage(), [ + $logger?->error($e->getMessage(), [ 'file' => $e->getFile(), 'line' => $e->getLine(), + 'kind' => get_class($e), ]); } @@ -303,11 +309,11 @@ class PlexServer implements ServerInterface $type = ag($json, 'Metadata.type'); $event = ag($json, 'event', null); - if (null === $type || !in_array($type, self::WEBHOOK_ALLOWED_TYPES)) { + if (null === $type || false === in_array($type, self::WEBHOOK_ALLOWED_TYPES)) { throw new HttpException(sprintf('%s: Not allowed type [%s]', self::NAME, $type), 200); } - if (null === $event || !in_array($event, self::WEBHOOK_ALLOWED_EVENTS)) { + if (null === $event || false === in_array($event, self::WEBHOOK_ALLOWED_EVENTS)) { throw new HttpException(sprintf('%s: Not allowed event [%s]', self::NAME, $event), 200); } @@ -338,7 +344,7 @@ class PlexServer implements ServerInterface 'updated' => time(), 'watched' => (int)(bool)ag($item, 'viewCount', false), 'via' => $this->name, - 'title' => '??', + 'title' => ag($item, ['title', 'originalTitle'], '??'), 'year' => (int)ag($item, ['grandParentYear', 'parentYear', 'year'], 0000), 'season' => null, 'episode' => null, @@ -361,10 +367,6 @@ class PlexServer implements ServerInterface if (null !== ($parentId = ag($item, ['grandparentRatingKey', 'parentRatingKey'], null))) { $row['parent'] = $this->getEpisodeParent($parentId); } - } elseif (StateInterface::TYPE_MOVIE === $type) { - $row['title'] = ag($item, ['title', 'originalTitle'], '??'); - } else { - throw new HttpException(sprintf('%s: Invalid content type.', self::NAME), 200); } $entity = Container::get(StateInterface::class)::fromArray($row)->setIsTainted($isTainted); @@ -373,7 +375,7 @@ class PlexServer implements ServerInterface $message = sprintf('%s: No valid/supported external ids.', self::NAME); if (empty($item['Guid'])) { - $message .= ' Most likely unmatched item.'; + $message .= ' Most likely unmatched movie/episode or show.'; } $message .= sprintf(' [%s].', arrayToString(['guids' => ag($item, 'Guid', 'None')])); @@ -388,13 +390,635 @@ class PlexServer implements ServerInterface $savePayload = true === Config::get('webhook.debug') || null !== ag($request->getQueryParams(), 'debug'); if (false !== $isTainted && $savePayload) { - saveWebhookPayload($this->name . '.' . $event, $request, $entity->getAll()); + saveWebhookPayload($this->name, $request, $entity); } return $entity; } - private function getHeaders(): array + public function search(string $query, int $limit = 25): array + { + $this->checkConfig(); + + try { + $url = $this->url->withPath('/hubs/search')->withQuery( + http_build_query( + [ + 'query' => $query, + 'limit' => $limit, + 'includeGuids' => 1, + 'includeExternalMedia' => 0, + 'includeCollections' => 0, + ] + ) + ); + + $this->logger->debug(sprintf('%s: Sending search request for \'%s\'.', $this->name, $query), [ + 'url' => $url + ]); + + $response = $this->http->request('GET', (string)$url, $this->getHeaders()); + + if (200 !== $response->getStatusCode()) { + throw new RuntimeException( + sprintf( + '%s: Search request for \'%s\' responded with unexpected http status code \'%d\'.', + $this->name, + $query, + $response->getStatusCode() + ) + ); + } + + $list = []; + + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); + + foreach (ag($json, 'MediaContainer.Hub', []) as $item) { + $type = ag($item, 'type'); + + if ('show' !== $type && 'movie' !== $type) { + continue; + } + + foreach (ag($item, 'Metadata', []) as $subItem) { + $list[] = $subItem; + } + } + + return $list; + } catch (ExceptionInterface|JsonException $e) { + throw new RuntimeException(get_class($e) . ': ' . $e->getMessage(), $e->getCode(), $e); + } + } + + public function listLibraries(): array + { + $this->checkConfig(); + + try { + $url = $this->url->withPath('/library/sections'); + + $this->logger->debug(sprintf('%s: Get list of server libraries.', $this->name), ['url' => $url]); + + $response = $this->http->request('GET', (string)$url, $this->getHeaders()); + + if (200 !== $response->getStatusCode()) { + $this->logger->error( + sprintf( + '%s: library list request responded with unexpected code \'%d\'.', + $this->name, + $response->getStatusCode() + ) + ); + return []; + } + + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); + + $listDirs = ag($json, 'MediaContainer.Directory', []); + + if (empty($listDirs)) { + $this->logger->notice( + sprintf( + '%s: Responded with empty list of libraries. Possibly the token has no access to the libraries?', + $this->name + ) + ); + return []; + } + } catch (ExceptionInterface $e) { + $this->logger->error( + sprintf('%s: list of libraries request failed. %s', $this->name, $e->getMessage()), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ], + ); + return []; + } catch (JsonException $e) { + $this->logger->error( + sprintf('%s: Failed to decode library list JSON response. %s', $this->name, $e->getMessage()), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ], + ); + return []; + } + + if (null !== ($ignoreIds = ag($this->options, 'ignore', null))) { + $ignoreIds = array_map(fn($v) => (int)trim($v), explode(',', (string)$ignoreIds)); + } + + $list = []; + + foreach ($listDirs as $section) { + $key = (int)ag($section, 'key'); + $type = ag($section, 'type', 'unknown'); + + $list[] = [ + 'ID' => $key, + 'Title' => ag($section, 'title', '???'), + 'Type' => $type, + 'Ignored' => null !== $ignoreIds && in_array($key, $ignoreIds) ? 'Yes' : 'No', + 'Supported' => 'movie' !== $type && 'show' !== $type ? 'No' : 'Yes', + ]; + } + + return $list; + } + + public function pull(ImportInterface $mapper, DateTimeInterface|null $after = null): array + { + return $this->getLibraries( + ok: function (string $cName, string $type) use ($after, $mapper) { + return function (ResponseInterface $response) use ($mapper, $cName, $type, $after) { + if (200 !== $response->getStatusCode()) { + $this->logger->error( + sprintf( + '%s: Request to \'%s\' responded with unexpected http status code \'%d\'.', + $this->name, + $cName, + $response->getStatusCode() + ) + ); + return; + } + + try { + $this->logger->info(sprintf('%s: Parsing \'%s\' response.', $this->name, $cName)); + + $it = Items::fromIterable( + httpClientChunks($this->http->stream($response)), + [ + 'pointer' => '/MediaContainer/Metadata', + 'decoder' => new ErrorWrappingDecoder( + new ExtJsonDecoder(options: JSON_INVALID_UTF8_IGNORE) + ) + ] + ); + + foreach ($it as $entity) { + if ($entity instanceof DecodingError) { + $this->logger->error( + sprintf( + '%s: Failed to decode one of \'%s\' items. %s', + $this->name, + $cName, + $entity->getErrorMessage() + ), + [ + 'payload' => $entity->getMalformedJson(), + ] + ); + continue; + } + $this->processImport($mapper, $type, $cName, $entity, $after); + } + } catch (PathNotFoundException $e) { + $this->logger->error( + sprintf( + '%s: Failed to find items in \'%s\' response. %s', + $this->name, + $cName, + $e->getMessage() + ), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ], + ); + } catch (Throwable $e) { + $this->logger->error( + sprintf( + '%s: Failed to handle \'%s\' response. %s', + $this->name, + $cName, + $e->getMessage(), + ), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ], + ); + } + + $this->logger->info(sprintf('%s: Parsing \'%s\' response complete.', $this->name, $cName)); + }; + }, + error: function (string $cName, string $type, UriInterface|string $url) { + return fn(Throwable $e) => $this->logger->error( + sprintf('%s: Error encountered in \'%s\' request. %s', $this->name, $cName, $e->getMessage()), + [ + 'url' => $url, + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ] + ); + }, + includeParent: true + ); + } + + public function push(array $entities, DateTimeInterface|null $after = null): array + { + $this->checkConfig(); + + $requests = $stateRequests = []; + + foreach ($entities as $key => $entity) { + if (null === $entity) { + continue; + } + + if (false === (ag($this->options, ServerInterface::OPT_EXPORT_IGNORE_DATE, false))) { + if (null !== $after && $after->getTimestamp() > $entity->updated) { + continue; + } + } + + $entity->plex_id = null; + + foreach ([...$entity->getRelativePointers(), ...$entity->getPointers()] as $guid) { + if (null === ($this->cacheData[$guid] ?? null)) { + continue; + } + $entity->plex_id = $this->cacheData[$guid]; + } + + $iName = $entity->getName(); + + if (null === $entity->plex_id) { + $this->logger->notice( + sprintf('%s: Ignoring \'%s\'. Not found in cache.', $this->name, $iName), + [ + 'guids' => $entity->hasGuids() ? $entity->getGuids() : 'None', + 'rGuids' => $entity->hasRelativeGuid() ? $entity->getRelativeGuids() : 'None', + ] + ); + continue; + } + + try { + $url = $this->url->withPath('/library/metadata/' . $entity->plex_id)->withQuery( + http_build_query(['includeGuids' => 1]) + ); + + $this->logger->debug(sprintf('%s: Requesting \'%s\' state from remote server.', $this->name, $iName), [ + 'url' => $url + ]); + + $requests[] = $this->http->request( + 'GET', + (string)$url, + array_replace_recursive($this->getHeaders(), [ + 'user_data' => [ + 'id' => $key, + 'state' => &$entity, + ] + ]) + ); + } catch (Throwable $e) { + $this->logger->error($e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ]); + } + } + + foreach ($requests as $response) { + try { + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); + + $json = ag($json, 'MediaContainer.Metadata', [])[0] ?? []; + + if (null === ($state = ag($response->getInfo('user_data'), 'state'))) { + $this->logger->error( + sprintf( + '%s: Request failed with code \'%d\'.', + $this->name, + $response->getStatusCode(), + ), + $response->getHeaders() + ); + continue; + } + + assert($state instanceof StateInterface); + + $iName = $state->getName(); + + if (empty($json)) { + $this->logger->notice( + sprintf('%s: Ignoring \'%s\'. Remote server returned empty result.', $this->name, $iName) + ); + continue; + } + + $isWatched = (int)(bool)ag($json, 'viewCount', 0); + + if ($state->watched === $isWatched) { + $this->logger->debug(sprintf('%s: Ignoring \'%s\'. Play state is identical.', $this->name, $iName)); + continue; + } + + if (false === (ag($this->options, ServerInterface::OPT_EXPORT_IGNORE_DATE, false))) { + $date = max( + (int)ag($json, 'updatedAt', 0), + (int)ag($json, 'lastViewedAt', 0), + (int)ag($json, 'addedAt', 0) + ); + + if (0 === $date) { + $this->logger->notice( + sprintf('%s: Ignoring \'%s\'. Date is not set on remote item.', $this->name, $iName), + [ + 'payload' => $json, + ] + ); + continue; + } + + if ($date >= $state->updated) { + $this->logger->debug( + sprintf( + '%s: Ignoring \'%s\'. Remote item date is newer or equal to backend entity.', + $this->name, + $iName + ), + [ + 'backend' => makeDate($state->updated), + 'remote' => makeDate($date), + ] + ); + continue; + } + } + + $url = $this->url->withPath($state->isWatched() ? '/:/scrobble' : '/:/unscrobble')->withQuery( + http_build_query( + [ + 'identifier' => 'com.plexapp.plugins.library', + 'key' => ag($json, 'ratingKey'), + ] + ) + ); + + $this->logger->debug( + sprintf('%s: Changing \'%s\' remote state.', $this->name, $iName), + [ + 'backend' => $state->isWatched() ? 'Played' : 'Unplayed', + 'remote' => $isWatched ? 'Played' : 'Unplayed', + 'url' => (string)$url, + ] + ); + + $stateRequests[] = $this->http->request( + 'GET', + (string)$url, + array_replace_recursive($this->getHeaders(), [ + 'user_data' => [ + 'itemName' => $iName, + 'server' => $this->name, + 'state' => $state->isWatched() ? 'Watched' : 'Unwatched', + ] + ]) + ); + } catch (Throwable $e) { + $this->logger->error($e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ]); + } + } + + unset($requests); + + return $stateRequests; + } + + public function export(ExportInterface $mapper, DateTimeInterface|null $after = null): array + { + return $this->getLibraries( + ok: function (string $cName, string $type) use ($mapper, $after) { + return function (ResponseInterface $response) use ($mapper, $cName, $type, $after) { + if (200 !== $response->getStatusCode()) { + $this->logger->error( + sprintf( + '%s: Request for \'%s\' responded with unexpected http status code (%d).', + $this->name, + $cName, + $response->getStatusCode() + ) + ); + return; + } + + try { + $this->logger->info(sprintf('%s: Parsing \'%s\' response.', $this->name, $cName)); + + $it = Items::fromIterable( + httpClientChunks($this->http->stream($response)), + [ + 'pointer' => '/MediaContainer/Metadata', + 'decoder' => new ErrorWrappingDecoder( + new ExtJsonDecoder(options: JSON_INVALID_UTF8_IGNORE) + ) + ] + ); + + foreach ($it as $entity) { + if ($entity instanceof DecodingError) { + $this->logger->notice( + sprintf( + '%s: Failed to decode one of \'%s\' items. %s', + $this->name, + $cName, + $entity->getErrorMessage() + ), + [ + 'payload' => $entity->getMalformedJson(), + ] + ); + continue; + } + $this->processExport($mapper, $type, $cName, $entity, $after); + } + } catch (PathNotFoundException $e) { + $this->logger->error( + sprintf( + '%s: Failed to find items in \'%s\' response. %s', + $this->name, + $cName, + $e->getMessage() + ), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ], + ); + } catch (Throwable $e) { + $this->logger->error( + sprintf( + '%s: Failed to handle \'%s\' response. %s', + $this->name, + $cName, + $e->getMessage(), + ), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ], + ); + } + + $this->logger->info(sprintf('%s: Parsing \'%s\' response complete.', $this->name, $cName)); + }; + }, + error: function (string $cName, string $type, UriInterface|string $url) { + return fn(Throwable $e) => $this->logger->error( + sprintf('%s: Error encountered in \'%s\' request. %s', $this->name, $cName, $e->getMessage()), + [ + 'url' => $url, + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ] + ); + }, + includeParent: false + ); + } + + public function cache(): array + { + return $this->getLibraries( + ok: function (string $cName, string $type) { + return function (ResponseInterface $response) use ($cName, $type) { + if (200 !== $response->getStatusCode()) { + $this->logger->error( + sprintf( + '%s: Request to \'%s\' responded with unexpected http status code \'%d\'.', + $this->name, + $cName, + $response->getStatusCode() + ) + ); + return; + } + + try { + $this->logger->info(sprintf('%s: Parsing \'%s\' response.', $this->name, $cName)); + + $it = Items::fromIterable( + httpClientChunks($this->http->stream($response)), + [ + 'pointer' => '/MediaContainer/Metadata', + 'decoder' => new ErrorWrappingDecoder( + new ExtJsonDecoder(options: JSON_INVALID_UTF8_IGNORE) + ) + ] + ); + + foreach ($it as $entity) { + if ($entity instanceof DecodingError) { + $this->logger->debug( + sprintf( + '%s: Failed to decode one of \'%s\' items. %s', + $this->name, + $cName, + $entity->getErrorMessage() + ), + [ + 'payload' => $entity->getMalformedJson(), + ] + ); + continue; + } + + $this->processCache($entity, $type, $cName); + } + } catch (PathNotFoundException $e) { + $this->logger->error( + sprintf( + '%s: Failed to find items in \'%s\' response. %s', + $this->name, + $cName, + $e->getMessage() + ), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ], + ); + } catch (Throwable $e) { + $this->logger->error( + sprintf( + '%s: Failed to handle \'%s\' response. %s', + $this->name, + $cName, + $e->getMessage(), + ), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ], + ); + } + + $this->logger->info(sprintf('%s: Parsing \'%s\' response complete.', $this->name, $cName)); + }; + }, + error: function (string $cName, string $type, UriInterface|string $url) { + return fn(Throwable $e) => $this->logger->error( + sprintf('%s: Error encountered in \'%s\' request. %s', $this->name, $cName, $e->getMessage()), + [ + 'url' => $url, + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ] + ); + }, + includeParent: true + ); + } + + /** + * @throws InvalidArgumentException + */ + public function __destruct() + { + if (!empty($this->cacheKey) && !empty($this->cacheData) && true === $this->initialized) { + $this->cache->set($this->cacheKey, $this->cacheData, new DateInterval('P1Y')); + } + + if (!empty($this->cacheShowKey) && !empty($this->cacheShow) && true === $this->initialized) { + $this->cache->set($this->cacheShowKey, $this->cacheShow, new DateInterval('P7D')); + } + } + + protected function getHeaders(): array { $opts = [ 'headers' => [ @@ -403,7 +1027,7 @@ class PlexServer implements ServerInterface ], ]; - return array_replace_recursive($opts, $this->options['client'] ?? []); + return array_replace_recursive($this->options['client'] ?? [], $opts); } protected function getLibraries(Closure $ok, Closure $error, bool $includeParent = false): array @@ -552,7 +1176,7 @@ class PlexServer implements ServerInterface $type = $type === 'movie' ? StateInterface::TYPE_MOVIE : StateInterface::TYPE_EPISODE; - if (null !== $ignoreIds && in_array($key, $ignoreIds)) { + if (null !== $ignoreIds && true === in_array($key, $ignoreIds)) { $ignored++; $this->logger->notice(sprintf('%s: Skipping \'%s\'. Ignored by user.', $this->name, $title), [ 'id' => $key, @@ -614,691 +1238,121 @@ class PlexServer implements ServerInterface return $promises; } - public function search(string $query, int $limit = 25): array - { - $this->checkConfig(); - + protected function processImport( + ImportInterface $mapper, + string $type, + string $library, + StdClass $item, + DateTimeInterface|null $after = null + ): void { try { - $url = $this->url->withPath('/hubs/search')->withQuery( - http_build_query( - [ - 'query' => $query, - 'limit' => $limit, - 'includeGuids' => 1, - 'includeExternalMedia' => 0, - 'includeCollections' => 0, - ] - ) - ); + if ('show' === $type) { + $this->processShow($item, $library); + return; + } - $this->logger->debug(sprintf('%s: Sending search request for \'%s\'.', $this->name, $query), [ - 'url' => $url - ]); + Data::increment($this->name, $library . '_total'); + Data::increment($this->name, $type . '_total'); - $response = $this->http->request('GET', (string)$url, $this->getHeaders()); - - if (200 !== $response->getStatusCode()) { - $this->logger->error( + if (StateInterface::TYPE_MOVIE === $type) { + $iName = sprintf( + '%s - [%s (%d)]', + $library, + $item->title ?? $item->originalTitle ?? '??', + $item->year ?? 0000 + ); + } else { + $iName = trim( sprintf( - '%s: Search request for \'%s\' responded with unexpected http status code \'%d\'.', - $this->name, - $query, - $response->getStatusCode() + '%s - [%s - (%sx%s)]', + $library, + $item->grandparentTitle ?? $item->originalTitle ?? '??', + str_pad((string)($item->parentIndex ?? 0), 2, '0', STR_PAD_LEFT), + str_pad((string)($item->index ?? 0), 3, '0', STR_PAD_LEFT), ) ); - return []; } - $list = []; - - $json = json_decode( - json: $response->getContent(), - associative: true, - flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE - ); - - foreach (ag($json, 'MediaContainer.Hub', []) as $item) { - $type = ag($item, 'type'); - - if ('show' !== $type && 'movie' !== $type) { - continue; - } - - foreach (ag($item, 'Metadata', []) as $subItem) { - $list[] = $subItem; - } - } - - return $list; - } catch (ExceptionInterface|JsonException $e) { - throw new RuntimeException($e->getMessage(), $e->getCode(), $e); - } - } - - public function listLibraries(): array - { - $this->checkConfig(); - - try { - $url = $this->url->withPath('/library/sections'); - - $this->logger->debug(sprintf('%s: Get list of server libraries.', $this->name), ['url' => $url]); - - $response = $this->http->request('GET', (string)$url, $this->getHeaders()); - - if (200 !== $response->getStatusCode()) { - $this->logger->error( - sprintf( - '%s: library list request responded with unexpected code \'%d\'.', - $this->name, - $response->getStatusCode() - ) - ); - return []; - } - - $json = json_decode( - json: $response->getContent(), - associative: true, - flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE - ); - - $listDirs = ag($json, 'MediaContainer.Directory', []); - - if (empty($listDirs)) { - $this->logger->notice( - sprintf( - '%s: Responded with empty list of libraries. Possibly the token has no access to the libraries?', - $this->name - ) - ); - return []; - } - } catch (ExceptionInterface $e) { - $this->logger->error( - sprintf('%s: list of libraries request failed. %s', $this->name, $e->getMessage()), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - ], - ); - return []; - } catch (JsonException $e) { - $this->logger->error( - sprintf('%s: Failed to decode library list JSON response. %s', $this->name, $e->getMessage()), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ], - ); - return []; - } - - if (null !== ($ignoreIds = ag($this->options, 'ignore', null))) { - $ignoreIds = array_map(fn($v) => (int)trim($v), explode(',', (string)$ignoreIds)); - } - - $list = []; - - foreach ($listDirs as $section) { - $key = (int)ag($section, 'key'); - $type = ag($section, 'type', 'unknown'); - - $list[] = [ - 'ID' => $key, - 'Title' => ag($section, 'title', '???'), - 'Type' => $type, - 'Ignored' => null !== $ignoreIds && in_array($key, $ignoreIds) ? 'Yes' : 'No', - 'Supported' => 'movie' !== $type && 'show' !== $type ? 'No' : 'Yes', - ]; - } - - return $list; - } - - public function pull(ImportInterface $mapper, DateTimeInterface|null $after = null): array - { - return $this->getLibraries( - ok: function (string $cName, string $type) use ($after, $mapper) { - return function (ResponseInterface $response) use ($mapper, $cName, $type, $after) { - try { - if (200 !== $response->getStatusCode()) { - $this->logger->error( - sprintf( - '%s: Request to \'%s\' responded with unexpected http status code \'%d\'.', - $this->name, - $cName, - $response->getStatusCode() - ) - ); - return; - } - - try { - $it = Items::fromIterable( - httpClientChunks($this->http->stream($response)), - [ - 'pointer' => '/MediaContainer/Metadata', - 'decoder' => new ErrorWrappingDecoder( - new ExtJsonDecoder(options: JSON_INVALID_UTF8_IGNORE) - ) - ] - ); - - $this->logger->info(sprintf('%s: Parsing \'%s\' response.', $this->name, $cName)); - - foreach ($it as $entity) { - if ($entity instanceof DecodingError) { - $this->logger->debug( - sprintf( - '%s: Failed to decode one of \'%s\' items. %s', - $this->name, - $cName, - $entity->getErrorMessage() - ), - [ - 'payload' => $entity->getMalformedJson(), - ] - ); - continue; - } - $this->processImport($mapper, $type, $cName, $entity, $after); - } - } catch (PathNotFoundException $e) { - $this->logger->error( - sprintf( - '%s: Failed to find items in \'%s\' response. %s', - $this->name, - $cName, - $e->getMessage() - ), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - ], - ); - return; - } catch (Throwable $e) { - $this->logger->error( - sprintf( - '%s: Failed to handle \'%s\' response. %s', - $this->name, - $cName, - $e->getMessage(), - ), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - ], - ); - return; - } - - $this->logger->info(sprintf('%s: Parsing \'%s\' response complete.', $this->name, $cName)); - } catch (JsonException $e) { - $this->logger->error( - sprintf( - '%s: Failed to decode \'%s\' JSON response. %s', - $this->name, - $cName, - $e->getMessage() - ), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ], - ); - return; - } - }; - }, - error: function (string $cName, string $type, UriInterface|string $url) { - return fn(Throwable $e) => $this->logger->error( - sprintf('%s: Error encountered in \'%s\' request. %s', $this->name, $cName, $e->getMessage()), - [ - 'url' => $url, - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ] - ); - }, - includeParent: true - ); - } - - public function push(array $entities, DateTimeInterface|null $after = null): array - { - $this->checkConfig(); - - $requests = $stateRequests = []; - - foreach ($entities as $key => $entity) { - if (null === $entity) { - continue; - } - - if (false === (ag($this->options, ServerInterface::OPT_EXPORT_IGNORE_DATE, false))) { - if (null !== $after && $after->getTimestamp() > $entity->updated) { - continue; - } - } - - $entity->plex_id = null; - - foreach ([...$entity->getRelativePointers(), ...$entity->getPointers()] as $guid) { - if (null === ($this->cacheData[$guid] ?? null)) { - continue; - } - $entity->plex_id = $this->cacheData[$guid]; - } - - $iName = $entity->getName(); - - if (null === $entity->plex_id) { - $this->logger->notice( - sprintf('%s: Ignoring \'%s\'. Not found in cache.', $this->name, $iName), - [ - 'guids' => $entity->hasGuids() ? $entity->getGuids() : 'None', - 'rGuids' => $entity->hasRelativeGuid() ? $entity->getRelativeGuids() : 'None', - ] - ); - continue; - } - - try { - $url = $this->url->withPath('/library/metadata/' . $entity->plex_id)->withQuery( - http_build_query(['includeGuids' => 1]) - ); - - $this->logger->debug(sprintf('%s: Requesting \'%s\' state from remote server.', $this->name, $iName), [ - 'url' => $url - ]); - - $requests[] = $this->http->request( - 'GET', - (string)$url, - array_replace_recursive($this->getHeaders(), [ - 'user_data' => [ - 'id' => $key, - 'state' => &$entity, - ] - ]) - ); - } catch (Throwable $e) { - $this->logger->error($e->getMessage(), [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - ]); - } - } - - foreach ($requests as $response) { - try { - $content = json_decode( - json: $response->getContent(), - associative: true, - flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE - ); - - $json = ag($content, 'MediaContainer.Metadata', [])[0] ?? []; - - if (null === ($state = ag($response->getInfo('user_data'), 'state'))) { - $this->logger->error( - sprintf( - '%s: Request failed with code \'%d\'.', - $this->name, - $response->getStatusCode(), - ), - $response->getHeaders() - ); - continue; - } - - assert($state instanceof StateInterface); - - $iName = $state->getName(); - - if (empty($json)) { - $this->logger->notice( - sprintf('%s: Ignoring \'%s\'. Remote server returned empty result.', $this->name, $iName) - ); - continue; - } - - $isWatched = (int)(bool)ag($json, 'viewCount', 0); - - if ($state->watched === $isWatched) { - $this->logger->debug(sprintf('%s: Ignoring \'%s\'. Play state is identical.', $this->name, $iName)); - continue; - } - - if (false === (ag($this->options, ServerInterface::OPT_EXPORT_IGNORE_DATE, false))) { - $date = max( - (int)ag($json, 'updatedAt', 0), - (int)ag($json, 'lastViewedAt', 0), - (int)ag($json, 'addedAt', 0) - ); - - if (0 === $date) { - $this->logger->notice( - sprintf('%s: Ignoring \'%s\'. Date is not set on remote item.', $this->name, $iName), - [ - 'payload' => $json, - ] - ); - continue; - } - - if ($date >= $state->updated) { - $this->logger->debug( - sprintf( - '%s: Ignoring \'%s\'. Remote item date is newer or equal to backend entity.', - $this->name, - $iName - ), - [ - 'backend' => makeDate($state->updated), - 'remote' => makeDate($date), - ] - ); - continue; - } - } - - $url = $this->url->withPath($state->isWatched() ? '/:/scrobble' : '/:/unscrobble')->withQuery( - http_build_query( - [ - 'identifier' => 'com.plexapp.plugins.library', - 'key' => ag($json, 'ratingKey'), - ] - ) - ); + $date = (int)($item->lastViewedAt ?? $item->updatedAt ?? $item->addedAt ?? 0); + if (0 === $date) { $this->logger->debug( - sprintf('%s: Changing \'%s\' remote state.', $this->name, $iName), + sprintf('%s: Ignoring \'%s\'. Date is not set on remote item.', $this->name, $iName), [ - 'backend' => $state->isWatched() ? 'Played' : 'Unplayed', - 'remote' => $isWatched ? 'Played' : 'Unplayed', - 'url' => (string)$url, + 'payload' => $item, ] ); - - $stateRequests[] = $this->http->request( - 'GET', - (string)$url, - array_replace_recursive($this->getHeaders(), [ - 'user_data' => [ - 'server' => $this->name, - 'state' => 1 === $state->watched ? 'Watched' : 'Unwatched', - 'itemName' => $iName, - ] - ]) - ); - } catch (Throwable $e) { - $this->logger->error($e->getMessage(), [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - ]); + Data::increment($this->name, $type . '_ignored_no_date_is_set'); + return; } + + $entity = $this->createEntity($item, $type); + + if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) { + if (null === ($item->Guid ?? null)) { + $item->Guid = [['id' => $item->guid]]; + } else { + $item->Guid[] = ['id' => $item->guid]; + } + + if (true === Config::get('debug.import')) { + $name = Config::get('tmpDir') . '/debug/' . $this->name . '.' . $item->ratingKey . '.json'; + + if (!file_exists($name)) { + file_put_contents( + $name, + json_encode( + $item, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_IGNORE + ) + ); + } + } + + $message = sprintf('%s: Ignoring \'%s\'. No valid/supported external ids.', $this->name, $iName); + + if (empty($item->Guid)) { + $message .= ' Most likely unmatched item.'; + } + + $this->logger->info($message, ['guids' => !empty($item->Guid) ? $item->Guid : 'None']); + + Data::increment($this->name, $type . '_ignored_no_supported_guid'); + return; + } + + $mapper->add($this->name, $this->name . ' - ' . $iName, $entity, ['after' => $after]); + } catch (Throwable $e) { + $this->logger->error($e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ]); } - - unset($requests); - - return $stateRequests; } - public function export(ExportInterface $mapper, DateTimeInterface|null $after = null): array + protected function processCache(StdClass $item, string $type, string $library): void { - return $this->getLibraries( - ok: function (string $cName, string $type) use ($mapper, $after) { - return function (ResponseInterface $response) use ($mapper, $cName, $type, $after) { - try { - if (200 !== $response->getStatusCode()) { - $this->logger->error( - sprintf( - '%s: Request for \'%s\' responded with unexpected http status code (%d).', - $this->name, - $cName, - $response->getStatusCode() - ) - ); - return; - } + try { + if ('show' === $type) { + $this->processShow($item, $library); + return; + } - try { - $it = Items::fromIterable( - httpClientChunks($this->http->stream($response)), - [ - 'pointer' => '/MediaContainer/Metadata', - 'decoder' => new ErrorWrappingDecoder( - new ExtJsonDecoder(options: JSON_INVALID_UTF8_IGNORE) - ) - ] - ); + $date = (int)($item->lastViewedAt ?? $item->updatedAt ?? $item->addedAt ?? 0); - $this->logger->info(sprintf('%s: Parsing \'%s\' response.', $this->name, $cName)); + if (0 === $date) { + return; + } - foreach ($it as $entity) { - if ($entity instanceof DecodingError) { - $this->logger->notice( - sprintf( - '%s: Failed to decode one of \'%s\' items. %s', - $this->name, - $cName, - $entity->getErrorMessage() - ), - [ - 'payload' => $entity->getMalformedJson(), - ] - ); - continue; - } - $this->processExport($mapper, $type, $cName, $entity, $after); - } - } catch (PathNotFoundException $e) { - $this->logger->error( - sprintf( - '%s: Failed to find items in \'%s\' response. %s', - $this->name, - $cName, - $e->getMessage() - ), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - ], - ); - } catch (Throwable $e) { - $this->logger->error( - sprintf( - '%s: Failed to handle \'%s\' response. %s', - $this->name, - $cName, - $e->getMessage(), - ), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - ], - ); - } - - $this->logger->info(sprintf('%s: Parsing \'%s\' response complete.', $this->name, $cName)); - } catch (JsonException $e) { - $this->logger->error( - sprintf( - '%s: Failed to decode \'%s\' JSON response. %s', - $this->name, - $cName, - $e->getMessage() - ), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ], - ); - return; - } catch (Throwable $e) { - $this->logger->error( - sprintf( - '%s: Failed to handle \'%s\' response. %s', - $this->name, - $cName, - $e->getMessage(), - ), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - ], - ); - } - }; - }, - error: function (string $cName, string $type, UriInterface|string $url) { - return fn(Throwable $e) => $this->logger->error( - sprintf('%s: Error encountered in \'%s\' request. %s', $this->name, $cName, $e->getMessage()), - [ - 'url' => $url, - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ] - ); - }, - includeParent: false - ); - } - - public function cache(): array - { - return $this->getLibraries( - ok: function (string $cName, string $type) { - return function (ResponseInterface $response) use ($cName, $type) { - try { - if (200 !== $response->getStatusCode()) { - $this->logger->error( - sprintf( - '%s: Request to \'%s\' responded with unexpected http status code \'%d\'.', - $this->name, - $cName, - $response->getStatusCode() - ) - ); - return; - } - - try { - $it = Items::fromIterable( - httpClientChunks($this->http->stream($response)), - [ - 'pointer' => '/MediaContainer/Metadata', - 'decoder' => new ErrorWrappingDecoder( - new ExtJsonDecoder(options: JSON_INVALID_UTF8_IGNORE) - ) - ] - ); - - $this->logger->info(sprintf('%s: Parsing \'%s\' response.', $this->name, $cName)); - - foreach ($it as $entity) { - if ($entity instanceof DecodingError) { - $this->logger->debug( - sprintf( - '%s: Failed to decode one of \'%s\' items. %s', - $this->name, - $cName, - $entity->getErrorMessage() - ), - [ - 'payload' => $entity->getMalformedJson(), - ] - ); - continue; - } - - $this->processForCache($entity, $type, $cName); - } - } catch (PathNotFoundException $e) { - $this->logger->error( - sprintf( - '%s: Failed to find items in \'%s\' response. %s', - $this->name, - $cName, - $e->getMessage() - ), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - ], - ); - } catch (Throwable $e) { - $this->logger->error( - sprintf( - 'Unable to parse %s - %s response.', - $this->name, - $cName, - ), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'error' => $e->getMessage(), - 'kind' => get_class($e), - ], - ); - } - - $this->logger->info(sprintf('%s: Parsing \'%s\' response complete.', $this->name, $cName)); - } catch (JsonException $e) { - $this->logger->error( - sprintf( - '%s: Failed to decode \'%s\' JSON response. %s', - $this->name, - $cName, - $e->getMessage() - ), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ], - ); - return; - } catch (Throwable $e) { - $this->logger->error( - sprintf( - '%s: Failed to handle \'%s\' response. %s', - $this->name, - $cName, - $e->getMessage(), - ), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - ], - ); - } - }; - }, - error: function (string $cName, string $type, UriInterface|string $url) { - return fn(Throwable $e) => $this->logger->error( - sprintf('%s: Error encountered in \'%s\' request. %s', $this->name, $cName, $e->getMessage()), - [ - 'url' => $url, - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ] - ); - }, - includeParent: true - ); + $this->createEntity($item, $type); + } catch (Throwable $e) { + $this->logger->error($e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ]); + } } protected function processExport( @@ -1364,7 +1418,7 @@ class PlexServer implements ServerInterface return; } - if (false === ($this->options[ServerInterface::OPT_EXPORT_IGNORE_DATE] ?? false)) { + if (false === ag($this->options, ServerInterface::OPT_EXPORT_IGNORE_DATE, false)) { if (null !== $after && $rItem->updated >= $after->getTimestamp()) { $this->logger->debug( sprintf( @@ -1427,7 +1481,7 @@ class PlexServer implements ServerInterface ) ); - $this->logger->info(sprintf('Queuing %s.', $iName), [ + $this->logger->info(sprintf('%s: Queuing \'%s\'.', $this->name, $iName), [ 'backend' => $entity->isWatched() ? 'Played' : 'Unplayed', 'remote' => $rItem->isWatched() ? 'Played' : 'Unplayed', 'url' => $url, @@ -1439,8 +1493,8 @@ class PlexServer implements ServerInterface (string)$url, array_replace_recursive($this->getHeaders(), [ 'user_data' => [ - 'state' => $entity->isWatched() ? 'Played' : 'Unplayed', 'itemName' => $iName, + 'state' => $entity->isWatched() ? 'Played' : 'Unplayed', ] ]) ) @@ -1487,118 +1541,7 @@ class PlexServer implements ServerInterface return; } - $this->showInfo[$item->ratingKey] = Guid::fromArray($this->getGuids($item->Guid, isParent: true))->getAll(); - } - - protected function processImport( - ImportInterface $mapper, - string $type, - string $library, - StdClass $item, - DateTimeInterface|null $after = null - ): void { - try { - if ('show' === $type) { - $this->processShow($item, $library); - return; - } - - Data::increment($this->name, $library . '_total'); - Data::increment($this->name, $type . '_total'); - - if (StateInterface::TYPE_MOVIE === $type) { - $iName = sprintf( - '%s - [%s (%d)]', - $library, - $item->title ?? $item->originalTitle ?? '??', - $item->year ?? 0000 - ); - } else { - $iName = trim( - sprintf( - '%s - [%s - (%sx%s)]', - $library, - $item->grandparentTitle ?? $item->originalTitle ?? '??', - str_pad((string)($item->parentIndex ?? 0), 2, '0', STR_PAD_LEFT), - str_pad((string)($item->index ?? 0), 3, '0', STR_PAD_LEFT), - ) - ); - } - - $date = (int)($item->lastViewedAt ?? $item->updatedAt ?? $item->addedAt ?? 0); - - if (0 === $date) { - $this->logger->debug( - sprintf('%s: Ignoring \'%s\'. Date is not set on remote item.', $this->name, $iName), - [ - 'payload' => $item, - ] - ); - Data::increment($this->name, $type . '_ignored_no_date_is_set'); - return; - } - - $entity = $this->createEntity($item, $type); - - if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) { - if (null === ($item->Guid ?? null)) { - $item->Guid = [['id' => $item->guid]]; - } else { - $item->Guid[] = ['id' => $item->guid]; - } - - if (true === Config::get('debug.import')) { - $name = Config::get('tmpDir') . '/debug/' . $this->name . '.' . $item->ratingKey . '.json'; - - if (!file_exists($name)) { - file_put_contents($name, json_encode($item, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); - } - } - - $message = sprintf('%s: Ignoring \'%s\'. No valid/supported external ids.', $this->name, $iName); - - if (empty($item->Guid)) { - $message .= ' Most likely unmatched item.'; - } - - $this->logger->info($message, ['guids' => empty($item->Guid) ? 'None' : $item->Guid]); - - Data::increment($this->name, $type . '_ignored_no_supported_guid'); - return; - } - - $mapper->add($this->name, $this->name . ' - ' . $iName, $entity, ['after' => $after]); - } catch (Throwable $e) { - $this->logger->error($e->getMessage(), [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - ]); - } - } - - protected function processForCache(StdClass $item, string $type, string $library): void - { - try { - if ('show' === $type) { - $this->processShow($item, $library); - return; - } - - $date = (int)($item->lastViewedAt ?? $item->updatedAt ?? $item->addedAt ?? 0); - - if (0 === $date) { - return; - } - - $this->createEntity($item, $type); - } catch (Throwable $e) { - $this->logger->error($e->getMessage(), [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - ]); - } + $this->cacheShow[$item->ratingKey] = Guid::fromArray($this->getGuids($item->Guid, isParent: true))->getAll(); } protected function getGuids(array $guids, bool $isParent = false): array @@ -1661,31 +1604,140 @@ class PlexServer implements ServerInterface return false; } - /** - * @throws InvalidArgumentException - */ - public function __destruct() + protected function checkConfig(bool $checkUrl = true, bool $checkToken = true): void { - if (!empty($this->cacheKey) && !empty($this->cacheData) && true === $this->initialized) { - $this->cache->set($this->cacheKey, $this->cacheData, new DateInterval('P1Y')); + if (true === $checkUrl && !($this->url instanceof UriInterface)) { + throw new RuntimeException(self::NAME . ': No host was set.'); } - if (!empty($this->cacheShowKey) && !empty($this->cacheShow) && true === $this->initialized) { - $this->cache->set($this->cacheShowKey, $this->cacheShow, new DateInterval('P7D')); + if (true === $checkToken && null === $this->token) { + throw new RuntimeException(self::NAME . ': No token was set.'); } } - /** - * Parse Plex agents identifier. - * - * @param string $agent - * @param bool $isParent - * - * @return string - * @see SUPPORTED_LEGACY_AGENTS - * @see PARENT_SUPPORTED_LEGACY_AGENTS - */ - private function parseLegacyAgent(string $agent, bool $isParent = false): string + protected function createEntity(StdClass $item, string $type): StateEntity + { + if (null === ($item->Guid ?? null)) { + $item->Guid = [['id' => $item->guid]]; + } else { + $item->Guid[] = ['id' => $item->guid]; + } + + $date = (int)($item->lastViewedAt ?? $item->updatedAt ?? $item->addedAt ?? 0); + + $row = [ + 'type' => $type, + 'updated' => $date, + 'watched' => (int)(bool)($item->viewCount ?? false), + 'via' => $this->name, + 'title' => $item->title ?? $item->originalTitle ?? '??', + 'year' => (int)($item->grandParentYear ?? $item->parentYear ?? $item->year ?? 0000), + 'season' => null, + 'episode' => null, + 'parent' => [], + 'guids' => $this->getGuids($item->Guid ?? [], isParent: false), + 'extra' => [ + 'date' => makeDate($item->originallyAvailableAt ?? 'now')->format('Y-m-d'), + ], + ]; + + if (StateInterface::TYPE_EPISODE === $type) { + $row['title'] = $item->grandparentTitle ?? '??'; + $row['season'] = $item->parentIndex ?? 0; + $row['episode'] = $item->index ?? 0; + $row['extra']['title'] = $item->title ?? $item->originalTitle ?? '??'; + + $parentId = $item->grandparentRatingKey ?? $item->parentRatingKey ?? null; + + if (null !== $parentId) { + $row['parent'] = $this->getEpisodeParent($parentId); + } + } else { + throw new RuntimeException('Invalid content type.'); + } + + $entity = Container::get(StateInterface::class)::fromArray($row); + + foreach ([...$entity->getRelativePointers(), ...$entity->getPointers()] as $guid) { + $this->cacheData[$guid] = $item->ratingKey; + } + + return $entity; + } + + protected function getEpisodeParent(int|string $id): array + { + if (array_key_exists($id, $this->cacheShow)) { + return $this->cacheShow[$id]; + } + + try { + $response = $this->http->request( + 'GET', + (string)$this->url->withPath('/library/metadata/' . $id), + $this->getHeaders() + ); + + if (200 !== $response->getStatusCode()) { + return []; + } + + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); + + $json = ag($json, 'MediaContainer.Metadata')[0] ?? []; + + if (null === ($type = ag($json, 'type')) || 'show' !== $type) { + return []; + } + + if (null === ($json['Guid'] ?? null)) { + $json['Guid'] = [['id' => $json['guid']]]; + } else { + $json['Guid'][] = ['id' => $json['guid']]; + } + + if (!$this->hasSupportedGuids($json['Guid'], true)) { + $this->cacheShow[$id] = []; + return $this->cacheShow[$id]; + } + + $this->cacheShow[$id] = Guid::fromArray($this->getGuids($json['Guid'], isParent: true))->getAll(); + + return $this->cacheShow[$id]; + } catch (ExceptionInterface $e) { + $this->logger->error($e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ]); + return []; + } catch (JsonException $e) { + $this->logger->error( + sprintf('%s: Unable to decode show id \'%s\' JSON response. %s', $this->name, $id, $e->getMessage()), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ] + ); + return []; + } catch (Throwable $e) { + $this->logger->error( + sprintf('%s: Failed to handle show id \'%s\' response. %s', $this->name, $id, $e->getMessage()), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ] + ); + return []; + } + } + + protected function parseLegacyAgent(string $agent, bool $isParent = false): string { try { $supported = self::SUPPORTED_LEGACY_AGENTS; @@ -1726,18 +1778,7 @@ class PlexServer implements ServerInterface } } - private function checkConfig(bool $checkUrl = true, bool $checkToken = true): void - { - if (true === $checkUrl && !($this->url instanceof UriInterface)) { - throw new RuntimeException(self::NAME . ': No host was set.'); - } - - if (true === $checkToken && null === $this->token) { - throw new RuntimeException(self::NAME . ': No token was set.'); - } - } - - private function getUserToken(int|string $userId): int|string|null + protected function getUserToken(int|string $userId): int|string|null { try { $uuid = $this->getServerUUID(); @@ -1819,124 +1860,4 @@ class PlexServer implements ServerInterface return null; } } - - private function createEntity(StdClass $item, string $type): StateEntity - { - if (null === ($item->Guid ?? null)) { - $item->Guid = [['id' => $item->guid]]; - } else { - $item->Guid[] = ['id' => $item->guid]; - } - - $date = (int)($item->lastViewedAt ?? $item->updatedAt ?? $item->addedAt ?? 0); - - $row = [ - 'type' => $type, - 'updated' => $date, - 'watched' => (int)(bool)($item->viewCount ?? false), - 'via' => $this->name, - 'title' => '??', - 'year' => (int)($item->grandParentYear ?? $item->parentYear ?? $item->year ?? 0000), - 'season' => null, - 'episode' => null, - 'parent' => [], - 'guids' => $this->getGuids($item->Guid ?? [], isParent: false), - 'extra' => [ - 'date' => makeDate($item->originallyAvailableAt ?? 'now')->format('Y-m-d'), - ], - ]; - - if (StateInterface::TYPE_MOVIE === $type) { - $row['title'] = $item->title ?? $item->originalTitle ?? '??'; - } elseif (StateInterface::TYPE_EPISODE === $type) { - $row['title'] = $item->grandparentTitle ?? '??'; - $row['season'] = $item->parentIndex ?? 0; - $row['episode'] = $item->index ?? 0; - $row['extra']['title'] = $item->title ?? $item->originalTitle ?? '??'; - - $parentId = $item->grandparentRatingKey ?? $item->parentRatingKey ?? null; - - if (null !== $parentId) { - $row['parent'] = $this->getEpisodeParent($parentId); - } - } else { - throw new RuntimeException('Invalid content type.'); - } - - $entity = Container::get(StateInterface::class)::fromArray($row); - - foreach ([...$entity->getRelativePointers(), ...$entity->getPointers()] as $guid) { - $this->cacheData[$guid] = $item->ratingKey; - } - - return $entity; - } - - private function getEpisodeParent(int|string $id): array - { - if (array_key_exists($id, $this->cacheShow)) { - return $this->cacheShow[$id]; - } - - try { - $response = $this->http->request( - 'GET', - (string)$this->url->withPath('/library/metadata/' . $id), - $this->getHeaders() - ); - - if (200 !== $response->getStatusCode()) { - return []; - } - - $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); - - $json = ag($json, 'MediaContainer.Metadata')[0] ?? []; - - if (null === ($type = ag($json, 'type')) || 'show' !== $type) { - return []; - } - - if (null === ($json['Guid'] ?? null)) { - $json['Guid'] = [['id' => $json['guid']]]; - } else { - $json['Guid'][] = ['id' => $json['guid']]; - } - - if (!$this->hasSupportedGuids($json['Guid'], true)) { - $this->cacheShow[$id] = []; - return $this->cacheShow[$id]; - } - - $this->cacheShow[$id] = Guid::fromArray($this->getGuids($json['Guid'], isParent: true))->getAll(); - - return $this->cacheShow[$id]; - } catch (ExceptionInterface $e) { - $this->logger->error($e->getMessage(), [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - ]); - return []; - } catch (JsonException $e) { - $this->logger->error( - sprintf('%s: Unable to decode show id \'%s\' JSON response. %s', $this->name, $id, $e->getMessage()), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ] - ); - return []; - } catch (Throwable $e) { - $this->logger->error( - sprintf('%s: Failed to handle show id \'%s\' response. %s', $this->name, $id, $e->getMessage()), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - ] - ); - return []; - } - } } diff --git a/src/Libs/Servers/ServerInterface.php b/src/Libs/Servers/ServerInterface.php index 1835cf58..007926ac 100644 --- a/src/Libs/Servers/ServerInterface.php +++ b/src/Libs/Servers/ServerInterface.php @@ -55,9 +55,11 @@ interface ServerInterface * Process The request For attributes extraction. * * @param ServerRequestInterface $request + * @param array $opts + * * @return ServerRequestInterface */ - public static function processRequest(ServerRequestInterface $request): ServerRequestInterface; + public static function processRequest(ServerRequestInterface $request, array $opts = []): ServerRequestInterface; /** * Parse server specific webhook event. for play/un-played event. diff --git a/src/Libs/helpers.php b/src/Libs/helpers.php index 46dba6be..4fce3a7d 100644 --- a/src/Libs/helpers.php +++ b/src/Libs/helpers.php @@ -241,24 +241,27 @@ if (!function_exists('fsize')) { } if (!function_exists('saveWebhookPayload')) { - function saveWebhookPayload(string $name, ServerRequestInterface $request, array $parsed = []): void + function saveWebhookPayload(string $name, ServerRequestInterface $request, StateInterface $state): void { $content = [ - 'query' => $request->getQueryParams(), + 'request' => [ + 'server' => $request->getServerParams(), + 'body' => (string)$request->getBody(), + 'query' => $request->getQueryParams(), + ], 'parsed' => $request->getParsedBody(), - 'server' => $request->getServerParams(), - 'body' => (string)$request->getBody(), 'attributes' => $request->getAttributes(), - 'cParsed' => $parsed, + 'entity' => $state->getAll(), ]; @file_put_contents( Config::get('tmpDir') . '/webhooks/' . sprintf( - 'webhook.%s.%s.json', + 'webhook.%s.%s.%s.json', $name, - (string)ag($request->getServerParams(), 'X_REQUEST_ID', time()) + ag($state->extra, 'webhook.event', 'unknown'), + ag($request->getServerParams(), 'X_REQUEST_ID', time()) ), - json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + json_encode(value: $content, flags: JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ); } } @@ -374,6 +377,13 @@ if (!function_exists('before')) { } } +if (!function_exists('after')) { + function after(string $subject, string $search): string + { + return empty($search) ? $subject : array_reverse(explode($search, $subject, 2))[0]; + } +} + if (!function_exists('makeServer')) { /** * @param array{name:string|null, type:string, url:string, token:string|int|null, user:string|int|null, persist:array, options:array} $server From 4d615a9d01f44241b7e86d9b552f9813897f9dcf Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A" Date: Tue, 10 May 2022 21:57:17 +0300 Subject: [PATCH 10/13] Allow setting boolean value for servers:edit --set key --- src/Commands/Servers/EditCommand.php | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/Commands/Servers/EditCommand.php b/src/Commands/Servers/EditCommand.php index a946658a..0c826afa 100644 --- a/src/Commands/Servers/EditCommand.php +++ b/src/Commands/Servers/EditCommand.php @@ -91,12 +91,19 @@ final class EditCommand extends Command } if (null !== $value) { - if ($value === ag($server, $key)) { + if (true === ctype_digit($value)) { + $value = (int)$value; + } elseif ('true' === strtolower((string)$value) || 'false' === strtolower((string)$value)) { + $value = 'true' === $value; + } else { + $value = (string)$value; + } + + if ($value === ag($server, $key, null)) { $output->writeln('Not updating. Value already matches.'); return self::SUCCESS; } - $value = ctype_digit($value) ? (int)$value : (string)$value; $server = ag_set($server, $key, $value); $output->writeln( @@ -104,7 +111,7 @@ final class EditCommand extends Command 'Updated server:\'%s\' key \'%s\' with value of \'%s\'.', $name, $key, - $value + is_bool($value) ? (true === $value ? 'true' : 'false') : $value, ) ); } From 998ad6eec557beec60b245185d5d4da972254bf0 Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A" Date: Tue, 10 May 2022 22:19:56 +0300 Subject: [PATCH 11/13] Removed unnecessary exception in createEntity method. --- src/Libs/Servers/JellyfinServer.php | 2 -- src/Libs/Servers/PlexServer.php | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/Libs/Servers/JellyfinServer.php b/src/Libs/Servers/JellyfinServer.php index 17d2e8f3..2f055a46 100644 --- a/src/Libs/Servers/JellyfinServer.php +++ b/src/Libs/Servers/JellyfinServer.php @@ -1587,8 +1587,6 @@ class JellyfinServer implements ServerInterface if (null !== ($item->SeriesId ?? null)) { $row['parent'] = $this->showInfo[$item->SeriesId] ?? []; } - } else { - throw new RuntimeException('Invalid content type.'); } $entity = Container::get(StateInterface::class)::fromArray($row); diff --git a/src/Libs/Servers/PlexServer.php b/src/Libs/Servers/PlexServer.php index 1bbc380e..c3e288d1 100644 --- a/src/Libs/Servers/PlexServer.php +++ b/src/Libs/Servers/PlexServer.php @@ -1652,8 +1652,6 @@ class PlexServer implements ServerInterface if (null !== $parentId) { $row['parent'] = $this->getEpisodeParent($parentId); } - } else { - throw new RuntimeException('Invalid content type.'); } $entity = Container::get(StateInterface::class)::fromArray($row); From 15f789d36632f8d82f03d667b489e2fb434245ea Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A" Date: Tue, 10 May 2022 22:36:44 +0300 Subject: [PATCH 12/13] Workaround some backends sending invalid UTF8 content. --- src/Libs/Servers/JellyfinServer.php | 20 ++++++++++++++++---- src/Libs/Servers/PlexServer.php | 25 +++++++++++++++++++++---- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/Libs/Servers/JellyfinServer.php b/src/Libs/Servers/JellyfinServer.php index 2f055a46..816e2ed4 100644 --- a/src/Libs/Servers/JellyfinServer.php +++ b/src/Libs/Servers/JellyfinServer.php @@ -160,7 +160,11 @@ class JellyfinServer implements ServerInterface return null; } - $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); $this->uuid = ag($json, 'Id', null); @@ -185,7 +189,11 @@ class JellyfinServer implements ServerInterface ); } - $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); $list = []; @@ -676,7 +684,7 @@ class JellyfinServer implements ServerInterface $json = json_decode( json: $response->getContent(), associative: true, - flags: JSON_THROW_ON_ERROR + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE ); $json = ag($json, 'Items', [])[0] ?? []; @@ -1617,7 +1625,11 @@ class JellyfinServer implements ServerInterface return []; } - $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); if (null === ($type = ag($json, 'Type'))) { return []; diff --git a/src/Libs/Servers/PlexServer.php b/src/Libs/Servers/PlexServer.php index c3e288d1..befc4204 100644 --- a/src/Libs/Servers/PlexServer.php +++ b/src/Libs/Servers/PlexServer.php @@ -168,7 +168,11 @@ class PlexServer implements ServerInterface return null; } - $json = json_decode($response->getContent(false), true, flags: JSON_THROW_ON_ERROR); + $json = json_decode( + json: $response->getContent(false), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); $this->uuid = ag($json, 'MediaContainer.machineIdentifier', null); @@ -200,7 +204,11 @@ class PlexServer implements ServerInterface ); } - $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); $list = []; @@ -1810,7 +1818,12 @@ class PlexServer implements ServerInterface return null; } - $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); + $tempToken = ag($json, 'authToken', null); $url = Container::getNew(UriInterface::class)->withPort(443)->withScheme('https')->withHost('plex.tv') @@ -1839,7 +1852,11 @@ class PlexServer implements ServerInterface ], ]); - $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); foreach ($json ?? [] as $server) { if (ag($server, 'clientIdentifier') !== $uuid) { From a5b2221a64088cf0ac2718dde6f18072f0de558f Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A" Date: Tue, 10 May 2022 23:11:21 +0300 Subject: [PATCH 13/13] updated state:push to handle new db schema. --- src/Commands/State/PushCommand.php | 53 +++++++++++------------------- 1 file changed, 19 insertions(+), 34 deletions(-) diff --git a/src/Commands/State/PushCommand.php b/src/Commands/State/PushCommand.php index eaf3d5c1..34dd3603 100644 --- a/src/Commands/State/PushCommand.php +++ b/src/Commands/State/PushCommand.php @@ -16,6 +16,7 @@ use Psr\SimpleCache\CacheInterface; use Psr\SimpleCache\InvalidArgumentException; use RuntimeException; use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Helper\TableSeparator; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -99,45 +100,28 @@ class PushCommand extends Command } if ($input->getOption('queue-show')) { - $table = new Table($output); $rows = []; - $table->setHeaders( - [ - 'ID', - 'Type', - 'Date', - 'Via', - 'Main Title', - 'Year | Episode', - 'Watched' - ] - ); + + $x = 0; + $count = count($entities); foreach ($entities as $entity) { - $number = '( ' . ag($entity->meta, 'year', 0) . ' )'; - - if (StateInterface::TYPE_EPISODE === $entity->type) { - $number .= sprintf( - ' - S%sE%s', - str_pad((string)($entity->meta['season'] ?? 0), 2, '0', STR_PAD_LEFT), - str_pad((string)($entity->meta['episode'] ?? 0), 2, '0', STR_PAD_LEFT), - ); - } + $x++; $rows[] = [ - $entity->id, - $entity->type, + $entity->getName(), + $entity->isWatched() ? 'Yes' : 'No', + $entity->via ?? '??', makeDate($entity->updated), - ag($entity->meta, 'via', '??'), - ag($entity->meta, 'series', ag($entity->meta, 'title', '??')), - $number, - $entity->watched ? 'Yes' : 'No', ]; + + if ($x < $count) { + $rows[] = new TableSeparator(); + } } - $table->setRows($rows); - - $table->render(); + (new Table($output))->setHeaders(['Media Title', 'Played', 'Via', 'Record Date'] + )->setStyle('box')->setRows($rows)->render(); return self::SUCCESS; } @@ -148,7 +132,7 @@ class PushCommand extends Command foreach (Config::get('servers', []) as $serverName => $server) { $type = strtolower(ag($server, 'type', 'unknown')); - if (true !== ag($server, 'webhook.push')) { + if (true !== (bool)ag($server, 'webhook.push')) { $output->writeln( sprintf('Ignoring \'%s\' as requested by user config option.', $serverName), OutputInterface::VERBOSITY_VERBOSE @@ -231,11 +215,12 @@ class PushCommand extends Command if (200 !== $response->getStatusCode()) { throw new ServerException($response); } - $this->logger->info( + $this->logger->notice( sprintf( - 'Processed: State (%s) - %s', - ag($requestData, 'state', '??'), + '%s Processed \'%s\'. Set remote state to \'%s\'.', + ag($requestData, 'server', '??'), ag($requestData, 'itemName', '??'), + ag($requestData, 'state', '??'), ) ); } catch (ExceptionInterface $e) {