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/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/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, ) ); } diff --git a/src/Commands/State/ImportCommand.php b/src/Commands/State/ImportCommand.php index 15a01666..444f1a5e 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; @@ -72,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', @@ -86,11 +78,12 @@ 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( - 'mapper-direct', + 'deep-debug', null, InputOption::VALUE_NONE, - 'Uses less memory. However, it\'s significantly slower then default mapper.' + 'You should not use this flag unless told by the team.' ) ->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Use Alternative config file.'); } @@ -114,10 +107,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); @@ -135,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')); @@ -183,7 +188,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/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) { diff --git a/src/Libs/Entity/StateEntity.php b/src/Libs/Entity/StateEntity.php index 7eb0e93a..632cd73a 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,15 @@ 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)', $this->title ?? '??', $this->year ?? 0000); } 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', + $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) ); } @@ -137,41 +127,40 @@ 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 count($this->guids) >= 1; + } - return false; + public function getGuids(): array + { + return $this->guids; } 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 @@ -184,29 +173,26 @@ final class StateEntity implements StateInterface return StateInterface::TYPE_EPISODE === $this->type; } + public function isWatched(): bool + { + return 1 === $this->watched; + } + 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 +200,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 +251,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 +289,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 +302,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 +310,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..82e8e9f9 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. @@ -55,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 */ @@ -83,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 */ @@ -110,6 +132,13 @@ interface StateInterface */ public function isEpisode(): bool; + /** + * Is entity marked as watched? + * + * @return bool + */ + public function isWatched(): bool; + /** * Get constructed name. * @@ -118,7 +147,7 @@ interface StateInterface public function getName(): string; /** - * Get GUID Pointers. + * Get external ids Pointers. * * @return array */ @@ -152,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/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..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 @@ -52,7 +53,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,8 +74,21 @@ final class MemoryMapper implements ImportInterface $this->changed[$pointer] = $pointer; Data::increment($bucket, $entity->type . '_added'); - $this->addGuids($this->objects[$pointer], $pointer); - $this->logger->debug(sprintf('Adding %s. As new Item.', $name)); + $this->addPointers($this->objects[$pointer], $pointer); + + 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,19 +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) { - // -- 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()); - 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; } @@ -102,15 +103,19 @@ 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->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; @@ -122,34 +127,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 +138,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]); @@ -187,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(); @@ -237,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? * @@ -246,49 +259,36 @@ 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]); } } } - 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/Servers/EmbyServer.php b/src/Libs/Servers/EmbyServer.php index 3c8f54eb..82043572 100644 --- a/src/Libs/Servers/EmbyServer.php +++ b/src/Libs/Servers/EmbyServer.php @@ -7,14 +7,19 @@ 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 { + public const NAME = 'EmbyBackend'; + protected const WEBHOOK_ALLOWED_TYPES = [ 'Movie', 'Episode', @@ -49,32 +54,46 @@ 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 { - $userAgent = ag($request->getServerParams(), 'HTTP_USER_AGENT', ''); + $logger = null; - if (false === Config::get('webhook.debug', false) && !str_starts_with($userAgent, 'Emby Server/')) { - return $request; - } + try { + $logger = $opts[LoggerInterface::class] ?? Container::get(LoggerInterface::class); - $payload = ag($request->getParsedBody() ?? [], 'data', null); + $userAgent = ag($request->getServerParams(), 'HTTP_USER_AGENT', ''); - if (null === $payload || null === ($json = json_decode((string)$payload, true))) { - return $request; - } + if (false === str_starts_with($userAgent, 'Emby Server/')) { + 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'), - ]; + $payload = (string)ag($request->getParsedBody() ?? [], 'data', null); - foreach ($attributes as $key => $val) { - $request = $request->withAttribute($key, $val); + if (null === ($json = json_decode(json: $payload, associative: true, flags: JSON_INVALID_UTF8_IGNORE))) { + return $request; + } + + $request = $request->withParsedBody($json); + + $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) { + $logger?->error($e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ]); } return $request; @@ -82,9 +101,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); } @@ -92,56 +109,23 @@ 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); } $isTainted = in_array($event, self::WEBHOOK_TAINTED_EVENTS); - $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), - }; - 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'], false); } $providersId = ag($json, 'Item.ProviderIds', []); @@ -150,195 +134,129 @@ class EmbyServer extends JellyfinServer 'type' => $type, 'updated' => time(), 'watched' => $isWatched, - 'meta' => $meta, - ...$this->getGuids($providersId, $type) + 'via' => $this->name, + 'title' => ag($json, ['Item.Name', 'Item.OriginalTitle'], '??'), + '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_EPISODE === $type) { + $row['title'] = ag($json, 'Item.SeriesName', '??'); + $row['season'] = ag($json, 'Item.ParentIndexNumber', 0); + $row['episode'] = ag($json, 'Item.IndexNumber', 0); + + 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'), ''); + } + } + $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.', self::NAME); + + 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' - ))) { - saveWebhookPayload($this->name . '.' . $event, $request, [ - 'entity' => $entity->getAll(), - 'payload' => $json, - ]); + $savePayload = true === Config::get('webhook.debug') || null !== ag($request->getQueryParams(), 'debug'); + + if (false === $isTainted && $savePayload) { + saveWebhookPayload($this->name . '.' . $event, $request, $entity); } return $entity; } - public function push(array $entities, DateTimeInterface|null $after = null): array + protected function getEpisodeParent(mixed $id, string $cacheName): 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; + if (array_key_exists($id, $this->cacheShow)) { + return $this->cacheShow[$id]; } - unset($entity); + try { + $response = $this->http->request( + 'GET', + (string)$this->url->withPath( + sprintf('/Users/%s/items/' . $id, $this->user) + ), + $this->getHeaders() + ); - /** @var StateInterface $entity */ - foreach ($entities as $entity) { - if (null === $entity || false === $entity->hasGuids()) { - continue; + if (200 !== $response->getStatusCode()) { + return []; } - try { - $guids = []; + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); - foreach ($entity->getPointers() as $pointer) { - if (str_starts_with($pointer, 'guid_plex://')) { - continue; - } - if (false === preg_match('#guid_(.+?)://\w+?/(.+)#s', $pointer, $matches)) { - continue; - } - $guids[] = sprintf('%s.%s', $matches[1], $matches[2]); - } - - 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()); + 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]; + } + + $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(), + 'kind' => get_class($e), + ]); + return []; + } catch (JsonException $e) { + $this->logger->error( + sprintf('%s: Unable to decode \'%s\' JSON response. %s', $this->name, $cacheName, $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, $cacheName, $e->getMessage()), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ] + ); + return []; } - - $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; } - } diff --git a/src/Libs/Servers/JellyfinServer.php b/src/Libs/Servers/JellyfinServer.php index 1254d96c..816e2ed4 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; @@ -37,8 +36,9 @@ use Throwable; class JellyfinServer implements ServerInterface { + public const NAME = 'JellyfinBackend'; + protected const GUID_MAPPER = [ - 'plex' => Guid::GUID_PLEX, 'imdb' => Guid::GUID_IMDB, 'tmdb' => Guid::GUID_TMDB, 'tvdb' => Guid::GUID_TVDB, @@ -76,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 = ''; @@ -100,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; @@ -131,7 +131,6 @@ class JellyfinServer implements ServerInterface } $cloned->options = $options; - $cloned->initialized = true; return $cloned; } @@ -144,28 +143,28 @@ 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( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); $this->uuid = ag($json, 'Id', null); @@ -176,24 +175,30 @@ 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() ) ); } - $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 = []; foreach ($json ?? [] as $user) { - $date = $user['LastActivityDate'] ?? $user['LastLoginDate'] ?? null; + $date = ag($user, ['LastActivityDate', 'LastLoginDate'], null); $data = [ 'user_id' => ag($user, 'Id'), @@ -204,7 +209,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; } @@ -232,34 +237,46 @@ class JellyfinServer implements ServerInterface return $this; } - public static function processRequest(ServerRequestInterface $request): ServerRequestInterface + public static function processRequest(ServerRequestInterface $request, array $opts = []): ServerRequestInterface { - $userAgent = ag($request->getServerParams(), 'HTTP_USER_AGENT', ''); + $logger = null; - if (false === Config::get('webhook.debug', false) && !str_starts_with($userAgent, 'Jellyfin-Server/')) { - return $request; - } + try { + $logger = $opts[LoggerInterface::class] ?? Container::get(LoggerInterface::class); - $body = (string)$request->getBody(); + $userAgent = ag($request->getServerParams(), 'HTTP_USER_AGENT', ''); - if (null === ($json = json_decode($body, true))) { - return $request; - } + if (false === str_starts_with($userAgent, 'Jellyfin-Server/')) { + return $request; + } - $request = $request->withParsedBody($json); + $payload = (string)$request->getBody(); - $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'), - ]; + if (null === ($json = json_decode(json: $payload, associative: true, flags: JSON_INVALID_UTF8_IGNORE))) { + return $request; + } - foreach ($attributes as $key => $val) { - $request = $request->withAttribute($key, $val); + $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'), + ]; + + foreach ($attributes as $key => $val) { + $request = $request->withAttribute($key, $val); + } + } catch (Throwable $e) { + $logger?->error($e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ]); } return $request; @@ -274,195 +291,704 @@ 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); - $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) { - if (!str_starts_with($key, 'Provider_')) { + if (false === str_starts_with($key, 'Provider_')) { continue; } - $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')); + $providersId[after($key, 'Provider_')] = $val; } $row = [ 'type' => $type, - 'updated' => time(), - 'watched' => (int)(bool)ag($json, 'Played', ag($json, 'PlayedToCompletion', 0)), - 'meta' => $meta, - ...$this->getGuids($providersId, $type) + 'updated' => strtotime(ag($json, ['UtcTimestamp', 'Timestamp'], 'now')), + 'watched' => (int)(bool)ag($json, ['Played', 'PlayedToCompletion'], 0), + 'via' => $this->name, + 'title' => ag($json, ['Name', 'OriginalTitle'], '??'), + '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_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; + } + + if (null !== $seriesName) { + $row['parent'] = $this->getEpisodeParent(ag($json, 'ItemId'), $seriesName . ':' . $row['year']); + } + } + $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.', self::NAME); - if ($entity->hasGuids()) { - foreach ($entity->getPointers() as $guid) { - $this->cacheData[$guid] = ag($json, 'Item.ItemId'); + 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); } - 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( - $request->getQueryParams(), - 'debug' - ))) { - saveWebhookPayload($this->name . '.' . $event, $request, [ - 'entity' => $entity->getAll(), - 'payload' => $json, - ]); + $savePayload = true === Config::get('webhook.debug') || null !== ag($request->getQueryParams(), 'debug'); + + if (false === $isTainted && $savePayload) { + saveWebhookPayload($this->name . '.' . $event, $request, $entity); } return $entity; } - protected function getEpisodeParent(mixed $id, string|null $series): array + public function search(string $query, int $limit = 25): array { - if (null !== $series && array_key_exists($series, $this->cacheShow)) { - return $this->cacheShow[$series]; - } + $this->checkConfig(true); try { - $response = $this->http->request( - 'GET', - (string)$this->url->withPath( - sprintf('/Users/%s/items/' . $id, $this->user) - ), - $this->getHeaders() + $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', + ] + ) ); - 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() + $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('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() - ] + 'line' => $e->getLine(), + ], ); return []; - } catch (Exception $e) { - $this->logger->error( - sprintf('ERROR: %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), - [ + } + + 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() - ] - ); - return []; + '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, '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')); } } @@ -471,21 +997,11 @@ class JellyfinServer implements ServerInterface $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 @@ -493,11 +1009,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( [ @@ -508,12 +1019,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() ) @@ -522,38 +1037,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 = []; @@ -587,10 +1110,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( @@ -606,12 +1128,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; } @@ -625,19 +1152,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; } @@ -655,7 +1185,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( @@ -670,23 +1202,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 []; } @@ -694,798 +1226,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; - - 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; - } - } - } - - 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 - { - $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)) { - $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, @@ -1504,8 +1244,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 @@ -1513,12 +1252,11 @@ class JellyfinServer implements ServerInterface } else { $iName = trim( sprintf( - '%s - %s - [%s - (%dx%d)]', - $this->name, + '%s - [%s - (%sx%s)]', $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), ) ); } @@ -1526,7 +1264,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; } @@ -1538,35 +1281,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) { @@ -1584,12 +1334,183 @@ 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 getGuids(array $ids, string|null $type = null): array + 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 = []; @@ -1600,13 +1521,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; } @@ -1623,37 +1548,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.'); } } @@ -1661,51 +1567,137 @@ class JellyfinServer implements ServerInterface { $date = strtotime($item->UserData?->LastPlayedDate ?? $item->DateCreated ?? $item->PremiereDate); - if (StateInterface::TYPE_MOVIE === $type) { - $meta = [ - 'via' => $this->name, - 'title' => $item->Name ?? $item->OriginalTitle ?? '??', - 'year' => $item->ProductionYear ?? 0000, + $row = [ + 'type' => $type, + 'updated' => $date, + 'watched' => (int)(bool)($item->UserData?->Played ?? false), + 'via' => $this->name, + 'title' => $item->Name ?? $item->OriginalTitle ?? '??', + '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'), - ]; - } 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'), - ]; + ], + ]; + + if (StateInterface::TYPE_EPISODE === $type) { + $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; } + + protected function getEpisodeParent(mixed $id, string $cacheName): array + { + if (array_key_exists($cacheName, $this->cacheShow)) { + return $this->cacheShow[$cacheName]; + } + + 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( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); + + 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( + 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[$cacheName] = $this->cacheShow[$seriesId] = []; + return $this->cacheShow[$cacheName]; + } + + $this->cacheShow[$seriesId] = Guid::fromArray($this->getGuids($providersId))->getAll(); + $this->cacheShow[$cacheName] = &$this->cacheShow[$seriesId]; + + return $this->cacheShow[$seriesId]; + } 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 \'%s\' JSON response. %s', $this->name, $cacheName, $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, $cacheName, $e->getMessage()), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + ] + ); + return []; + } + } } diff --git a/src/Libs/Servers/PlexServer.php b/src/Libs/Servers/PlexServer.php index a94686b7..befc4204 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; @@ -37,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, @@ -88,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, @@ -117,6 +119,7 @@ class PlexServer implements ServerInterface $cloned = clone $this; $cloned->cacheData = []; + $cloned->cacheShow = []; $cloned->name = $name; $cloned->url = $url; $cloned->token = $token; @@ -124,8 +127,9 @@ 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'; + $cloned->initialized = true; if ($cloned->cache->has($cloned->cacheKey)) { $cloned->cacheData = $cloned->cache->get($cloned->cacheKey); @@ -135,8 +139,6 @@ class PlexServer implements ServerInterface $cloned->cacheShow = $cloned->cache->get($cloned->cacheShowKey); } - $cloned->initialized = true; - return $cloned; } @@ -148,19 +150,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() ) @@ -169,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); @@ -180,11 +183,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' => [ @@ -197,14 +197,18 @@ 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() ) ); } - $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 = []; @@ -258,21 +262,27 @@ 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 === Config::get('webhook.debug', false) && !str_starts_with($userAgent, 'PlexMediaServer/')) { + if (false === str_starts_with($userAgent, 'PlexMediaServer/')) { return $request; } $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; } + $request = $request->withParsedBody($json); + $attributes = [ 'SERVER_ID' => ag($json, 'Server.uuid', ''), 'SERVER_NAME' => ag($json, 'Server.title', ''), @@ -287,9 +297,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), ]); } @@ -298,66 +309,37 @@ 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))) { - throw new HttpException(sprintf('%s: No payload.', afterLast(__CLASS__, '\\')), 400); + if (null === ($json = $request->getParsedBody())) { + throw new HttpException(sprintf('%s: No payload.', self::NAME), 400); } $item = ag($json, 'Metadata', []); $type = ag($json, 'Metadata.type'); $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); + 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)) { - 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); - - $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.', - afterLast(__CLASS__, '\\'), + '%s: Library id \'%s\' is ignored by user server config.', + self::NAME, 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,139 +347,686 @@ 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', []), $type, isParent: false) + 'watched' => (int)(bool)ag($item, 'viewCount', false), + 'via' => $this->name, + 'title' => ag($item, ['title', 'originalTitle'], '??'), + '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_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); + } + } + $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.', self::NAME); - if ($entity->hasGuids()) { - foreach ($entity->getPointers() as $guid) { - $this->cacheData[$guid] = ag($item, 'guid'); + 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); } - 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, 'ratingKey'); } - if (false !== $isTainted && (true === Config::get('webhook.debug') || null !== ag( - $request->getQueryParams(), - 'debug' - ))) { - saveWebhookPayload($this->name . '.' . $event, $request, [ - 'entity' => $entity->getAll(), - 'payload' => $json, - ]); + $savePayload = true === Config::get('webhook.debug') || null !== ag($request->getQueryParams(), 'debug'); + + if (false !== $isTainted && $savePayload) { + saveWebhookPayload($this->name, $request, $entity); } return $entity; } - protected function getEpisodeParent(int|string $id): array + public function search(string $query, int $limit = 25): array { - if (array_key_exists($id, $this->cacheShow)) { - return $this->cacheShow[$id]; - } + $this->checkConfig(); try { - $response = $this->http->request( - 'GET', - (string)$this->url->withPath('/library/metadata/' . $id), - $this->getHeaders() + $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()) { - return []; + throw new RuntimeException( + sprintf( + '%s: Search request for \'%s\' responded with unexpected http status code \'%d\'.', + $this->name, + $query, + $response->getStatusCode() + ) + ); } - $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); + $list = []; - $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() - ] + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE ); - return []; - } catch (Exception $e) { - $this->logger->error( - sprintf('ERROR: %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine() - ] - ); - return []; + + 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); } } - private function getHeaders(): array + 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' => [ @@ -506,7 +1035,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 @@ -514,19 +1043,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() ) @@ -535,28 +1063,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(), @@ -566,10 +1101,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 = []; @@ -600,10 +1133,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( @@ -619,7 +1151,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() @@ -628,6 +1160,7 @@ class PlexServer implements ServerInterface 'url' => $url, 'file' => $e->getFile(), 'line' => $e->getLine(), + 'kind' => get_class($e), ] ); continue; @@ -642,21 +1175,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)) { + 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; } + $cName = sprintf('(%s) - (%s:%s)', $title, $type, $key); + $url = $this->url->withPath(sprintf('/library/sections/%d/all', $key))->withQuery( http_build_query( [ @@ -667,7 +1205,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( @@ -682,7 +1222,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(), @@ -694,15 +1234,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 []; } @@ -710,846 +1246,6 @@ class PlexServer implements ServerInterface return $promises; } - public function search(string $query, int $limit = 25): array - { - $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( - [ - 'query' => $query, - 'limit' => $limit, - 'includeGuids' => 1, - 'includeExternalMedia' => 0, - 'includeCollections' => 0, - ] - ) - ); - - $this->logger->debug('Request', ['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).', - $this->name, - $response->getStatusCode() - ) - ); - Data::add($this->name, 'no_import_update', true); - return []; - } - - $list = []; - - $json = json_decode($response->getContent(false), true, flags: JSON_THROW_ON_ERROR); - - 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 { - $this->logger->debug( - sprintf('Requesting libraries From %s.', $this->name), - ['url' => $this->url->getHost()] - ); - - $url = $this->url->withPath('/library/sections'); - - $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, 'MediaContainer.Directory', []); - - if (empty($listDirs)) { - $this->logger->error(sprintf('No libraries found at %s.', $this->name)); - return []; - } - } catch (ExceptionInterface $e) { - $this->logger->error( - sprintf('Request to %s failed. Reason: \'%s\'.', $this->name, $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) => (int)trim($v), explode(',', (string)$this->options['ignore'])); - } - - $list = []; - - 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, - 'Type' => $type, - 'Ignored' => $isIgnored ? '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( - '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' => '/MediaContainer/Metadata', - '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 library?', - $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() - ), - [ - '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()), - [ - 'url' => $url, - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ] - ); - }, - includeParent: true - ); - } - - public function push(array $entities, DateTimeInterface|null $after = null): array - { - $this->checkConfig(); - - $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_id = null; - - if (null !== $entity->guid_plex) { - $entity->plex_id = 'plex://' . $entity->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; - } - } - } - - 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->plex_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('/library/all')->withQuery( - http_build_query( - [ - 'guid' => $entity->plex_id, - 'includeGuids' => 1, - ] - ) - ), - array_replace_recursive($this->getHeaders(), [ - 'user_data' => [ - 'state' => &$entity, - ] - ]) - ); - } catch (Throwable $e) { - $this->logger->error($e->getMessage(), ['file' => $e->getFile(), 'line' => $e->getLine()]); - } - } - - $stateRequests = []; - - foreach ($requests as $response) { - try { - $content = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); - - $json = ag($content, 'MediaContainer.Metadata', [])[0] ?? []; - - $state = $response->getInfo('user_data')['state'] ?? null; - - if (null === $state) { - $this->logger->error( - sprintf( - 'Request failed with code \'%d\'.', - $response->getStatusCode(), - ), - $response->getHeaders() - ); - continue; - } - - 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, - ) - ); - } - - if (empty($json)) { - $this->logger->notice(sprintf('Ignoring %s. does not exists.', $iName)); - continue; - } - - $isWatched = (int)(bool)ag($json, 'viewCount', 0); - - 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 = max( - (int)ag($json, 'updatedAt', 0), - (int)ag($json, 'lastViewedAt', 0), - (int)ag($json, 'addedAt', 0) - ); - - if (0 === $date) { - $this->logger->debug(sprintf('Ignoring %s. State is unchanged.', $iName)); - continue; - } - - if ($date >= $state->updated) { - $this->logger->debug(sprintf('Ignoring %s. Date is newer then what in db.', $iName)); - 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(), - [ - '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; - } - - 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 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('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 library?', - $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()), - [ - '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( - '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' => '/MediaContainer/Metadata', - '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 library?', - $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() - ), - [ - '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()), - [ - 'url' => $url, - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ] - ); - }, - includeParent: true - ); - } - - protected function processExport( - ExportInterface $mapper, - string $type, - string $library, - StdClass $item, - DateTimeInterface|null $after = null - ): void { - try { - Data::increment($this->name, $type . '_total'); - - if (StateInterface::TYPE_MOVIE === $type) { - $iName = sprintf( - '%s - %s - [%s (%d)]', - $this->name, - $library, - $item->title ?? $item->originalTitle ?? '??', - $item->year ?? 0000 - ); - } else { - $iName = trim( - sprintf( - '%s - %s - [%s - (%dx%d)]', - $this->name, - $library, - $item->grandparentTitle ?? $item->originalTitle ?? '??', - $item->parentIndex ?? 0, - $item->index ?? 0, - ) - ); - } - - $date = $item->lastViewedAt ?? $item->updatedAt ?? $item->addedAt ?? null; - - if (null === $date) { - $this->logger->error(sprintf('Ignoring %s. No date is set.', $iName)); - Data::increment($this->name, $type . '_ignored_no_date_is_set'); - return; - } - - $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', - ] - ); - 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)); - 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.', - $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( - '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, - ], - ] - ) - ) - ); - } catch (Throwable $e) { - $this->logger->error($e->getMessage(), [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ]); - } - } - - 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 { - $item->Guid[] = ['id' => $item->guid]; - } - - if (!$this->hasSupportedGuids($item->Guid, true)) { - $message = sprintf('Ignoring %s. No valid/supported GUIDs.', $iName); - if (empty($item->Guid)) { - $message .= ' Most likely unmatched TV show.'; - } - $this->logger->info($message, [ - 'guids' => empty($item->Guid) ? 'None' : $item->Guid - ]); - 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; - } - protected function processImport( ImportInterface $mapper, string $type, @@ -1568,8 +1264,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 @@ -1577,13 +1272,11 @@ class PlexServer implements ServerInterface } else { $iName = trim( sprintf( - '%s - %s - [%s - (%dx%d) - %s]', - $this->name, + '%s - [%s - (%sx%s)]', $library, $item->grandparentTitle ?? $item->originalTitle ?? '??', - $item->parentIndex ?? 0, - $item->index ?? 0, - $item->title ?? $item->originalTitle ?? '', + str_pad((string)($item->parentIndex ?? 0), 2, '0', STR_PAD_LEFT), + str_pad((string)($item->index ?? 0), 3, '0', STR_PAD_LEFT), ) ); } @@ -1591,7 +1284,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; } @@ -1609,32 +1307,39 @@ class PlexServer implements ServerInterface $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)); + file_put_contents( + $name, + json_encode( + $item, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_IGNORE + ) + ); } } - $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.'; } - $this->logger->info($message, ['guids' => empty($item->Guid) ? 'None' : $item->Guid]); + $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, $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) { @@ -1652,12 +1357,202 @@ 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), ]); } } - protected function getGuids(array $guids, string|null $type = null, bool $isParent = false): array + protected function processExport( + ExportInterface $mapper, + string $type, + string $library, + StdClass $item, + DateTimeInterface|null $after = null + ): void { + try { + 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 - (%dx%d)]', + $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 = $item->lastViewedAt ?? $item->updatedAt ?? $item->addedAt ?? 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()) { + 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 === 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('/:' . ($entity->isWatched() ? '/scrobble' : '/unscrobble'))->withQuery( + http_build_query( + [ + 'identifier' => 'com.plexapp.plugins.library', + 'key' => $item->ratingKey, + ] + ) + ); + + $this->logger->info(sprintf('%s: Queuing \'%s\'.', $this->name, $iName), [ + 'backend' => $entity->isWatched() ? 'Played' : 'Unplayed', + 'remote' => $rItem->isWatched() ? 'Played' : 'Unplayed', + 'url' => $url, + ]); + + $mapper->queue( + $this->http->request( + 'GET', + (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 + { + if (null === ($item->Guid ?? null)) { + $item->Guid = [['id' => $item->guid]]; + } else { + $item->Guid[] = ['id' => $item->guid]; + } + + 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 (%d)]', + $library, + ag($item, ['title', 'originalTitle'], '??'), + ag($item, 'year', '0000') + ); + + $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) ? $item->Guid : 'None']); + + return; + } + + $this->cacheShow[$item->ratingKey] = Guid::fromArray($this->getGuids($item->Guid, isParent: true))->getAll(); + } + + protected function getGuids(array $guids, bool $isParent = false): array { $guid = []; @@ -1679,13 +1574,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; } @@ -1713,30 +1612,138 @@ 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('PT30M')); + 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 - */ - 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); + } + } + + $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; @@ -1769,23 +1776,15 @@ 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; } } - 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.'); - } - - if (true === $checkToken && null === $this->token) { - throw new RuntimeException(afterLast(__CLASS__, '\\') . ': 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(); @@ -1794,10 +1793,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' => [ @@ -1810,8 +1808,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() ) ); @@ -1819,25 +1818,30 @@ 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' - )->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, [ @@ -1848,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) { @@ -1862,68 +1870,9 @@ class PlexServer implements ServerInterface $this->logger->error($e->getMessage(), [ 'file' => $e->getFile(), 'line' => $e->getLine(), + 'kind' => get_class($e), ]); 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); - - 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'), - ]; - } 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'), - ]; - - $parentId = $item->grandparentRatingKey ?? $item->parentRatingKey ?? null; - - if (null !== $parentId) { - $meta['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) - ] - ); - - 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; - } - } - - return $entity; - } } 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/Storage/PDO/PDOAdapter.php b/src/Libs/Storage/PDO/PDOAdapter.php index 4f0c297c..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; /** @@ -41,14 +40,15 @@ 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']) { - 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']); @@ -64,8 +64,8 @@ final class PDOAdapter implements StorageInterface $entity->id = (int)$this->pdo->lastInsertId(); } catch (PDOException $e) { $this->stmt['insert'] = null; - if (false === $this->viaCommit) { - $this->logger->error($e->getMessage(), $entity->meta ?? []); + if (false === $this->viaTransaction) { + $this->logger->error($e->getMessage(), $entity->getAll()); return $entity; } throw $e; @@ -76,11 +76,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 +113,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']) { @@ -130,8 +132,8 @@ final class PDOAdapter implements StorageInterface $this->stmt['update']->execute($data); } catch (PDOException $e) { $this->stmt['update'] = null; - if (false === $this->viaCommit) { - $this->logger->error($e->getMessage(), $entity->meta ?? []); + if (false === $this->viaTransaction) { + $this->logger->error($e->getMessage(), $entity->getAll()); return $entity; } throw $e; @@ -165,50 +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->logger->info( - 'Adding ' . $entity->type . ' - [' . $entity->getName() . '].', - $entity->getAll() - ); - $this->insert($entity); - - $list[$entity->type]['added']++; + $actions['added']++; } else { - $this->logger->info( - 'Updating ' . $entity->type . ':' . $entity->id . ' - [' . $entity->getName() . '].', - $entity->diff() - ); $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; }); } @@ -250,11 +233,6 @@ final class PDOAdapter implements StorageInterface return $this->pdo; } - /** - * Enable Single Transaction mode. - * - * @return bool - */ public function singleTransaction(): bool { $this->singleTransaction = true; @@ -267,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. @@ -280,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. * @@ -374,58 +350,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 +414,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/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; } diff --git a/src/Libs/helpers.php b/src/Libs/helpers.php index b89e3652..4fce3a7d 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]; } @@ -226,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) ); } } @@ -359,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 @@ -419,7 +444,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..2b61a2fe 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); @@ -133,21 +139,15 @@ 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]) ); - $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( - [ - 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]) ); }