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])
);
}