List used objects. */ protected array $objects = []; /** * @var array */ protected array $pointers = []; /** * @var array List changed entities. */ protected array $changed = []; /** * @var array> */ protected array $actions = [ iState::TYPE_MOVIE => ['added' => 0, 'updated' => 0, 'failed' => 0], iState::TYPE_EPISODE => ['added' => 0, 'updated' => 0, 'failed' => 0], ]; protected array $options = []; protected bool $fullyLoaded = false; public function __construct(protected iLogger $logger, protected iDB $db) { } public function setOptions(array $options = []): iImport { $this->options = $options; return $this; } public function loadData(iDate|null $date = null): self { $this->fullyLoaded = null === $date; $opts = [ 'class' => $this->options['class'] ?? null, 'fields' => [ iState::COLUMN_ID, iState::COLUMN_TYPE, iState::COLUMN_PARENT, iState::COLUMN_GUIDS, iState::COLUMN_SEASON, iState::COLUMN_EPISODE, ], ]; foreach ($this->db->getAll($date, opts: $opts) as $entity) { $pointer = $entity->id; if (null !== ($this->objects[$pointer] ?? null)) { continue; } $this->objects[$pointer] = $pointer; $this->addPointers($entity, $pointer); } $this->logger->info('MAPPER: Preloaded [%(pointers)] pointers into memory.', [ 'mapper' => afterLast(self::class, '\\'), 'pointers' => number_format(count($this->pointers)), ]); return $this; } public function add(iState $entity, array $opts = []): self { if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) { $this->logger->warning('MAPPER: Ignoring [%(backend)] [%(title)] no valid/supported external ids.', [ 'id' => $entity->id, 'backend' => $entity->via, 'title' => $entity->getName(), ]); Message::increment("{$entity->via}.{$entity->type}.failed_no_guid"); return $this; } $metadataOnly = true === (bool)ag($opts, Options::IMPORT_METADATA_ONLY); $inDryRunMode = $this->inDryRunMode(); /** * Handle adding new item logic. */ if (null === ($local = $this->get($entity))) { if (true === $metadataOnly) { $this->actions[$entity->type]['failed']++; Message::increment("{$entity->via}.{$entity->type}.failed"); $this->logger->notice('MAPPER: Ignoring [%(backend)] [%(title)]. Does not exist in database.', [ 'metaOnly' => true, 'backend' => $entity->via, 'title' => $entity->getName(), 'data' => $entity->getAll(), ]); return $this; } try { if ($this->inTraceMode()) { $data = $entity->getAll(); unset($data['id']); $data[iState::COLUMN_UPDATED] = makeDate($data[iState::COLUMN_UPDATED]); $data[iState::COLUMN_WATCHED] = 0 === $data[iState::COLUMN_WATCHED] ? 'No' : 'Yes'; if ($entity->isMovie()) { unset($data[iState::COLUMN_SEASON], $data[iState::COLUMN_EPISODE], $data[iState::COLUMN_PARENT]); } } else { $data = [ iState::COLUMN_META_DATA => [ $entity->via => [ iState::COLUMN_ID => ag($entity->getMetadata($entity->via), iState::COLUMN_ID), iState::COLUMN_UPDATED => makeDate($entity->updated), iState::COLUMN_GUIDS => $entity->getGuids(), iState::COLUMN_PARENT => $entity->getParentGuids(), ] ], ]; } if (true === $inDryRunMode) { $entity->id = random_int((int)(PHP_INT_MAX / 2), PHP_INT_MAX); } else { $entity = $this->db->insert($entity); } $this->logger->notice('MAPPER: [%(backend)] added [%(title)] as new item.', [ 'id' => $entity->id, 'backend' => $entity->via, 'title' => $entity->getName(), $this->inTraceMode() ? 'trace' : 'metadata' => $data, ]); $this->addPointers($entity, $entity->id); if (null === ($this->changed[$entity->id] ?? null)) { $this->actions[$entity->type]['added']++; Message::increment("{$entity->via}.{$entity->type}.added"); } $this->changed[$entity->id] = $this->objects[$entity->id] = $entity->id; } catch (PDOException|Exception $e) { $this->actions[$entity->type]['failed']++; Message::increment("{$entity->via}.{$entity->type}.failed"); $this->logger->error(sprintf('MAPPER: %s', $e->getMessage()), [ 'backend' => $entity->via, 'title' => $entity->getName(), 'state' => $entity->getAll() ]); } return $this; } $keys = [iState::COLUMN_META_DATA]; /** * DO NOT operate directly on this object it should be cloned. * It should maintain pristine condition until changes are committed. */ $cloned = clone $local; /** * Only allow metadata updates no play state changes. */ if (true === $metadataOnly) { if (true === (clone $cloned)->apply(entity: $entity, fields: $keys)->isChanged(fields: $keys)) { try { $localFields = array_merge($keys, [iState::COLUMN_GUIDS]); $entity->guids = Guid::makeVirtualGuid( $entity->via, ag($entity->getMetadata($entity->via), iState::COLUMN_ID) ); $local = $local->apply(entity: $entity, fields: array_merge($localFields, [iState::COLUMN_EXTRA])); $this->removePointers($cloned)->addPointers($local, $local->id); $this->logger->notice('MAPPER: [%(backend)] updated [%(title)] metadata.', [ 'id' => $local->id, 'backend' => $entity->via, 'title' => $local->getName(), 'changes' => $local->diff(fields: $localFields) ]); if (false === $inDryRunMode) { $this->db->update($local); } if (null === ($this->changed[$local->id] ?? null)) { $this->actions[$local->type]['updated']++; Message::increment("{$entity->via}.{$local->type}.updated"); } $this->changed[$local->id] = $this->objects[$local->id] = $local->id; } catch (PDOException $e) { $this->actions[$local->type]['failed']++; Message::increment("{$entity->via}.{$local->type}.failed"); $this->logger->error(sprintf('MAPPER: %s', $e->getMessage()), [ 'id' => $cloned->id, 'backend' => $entity->via, 'title' => $cloned->getName(), 'state' => [ 'database' => $cloned->getAll(), 'backend' => $entity->getAll() ], ]); } return $this; } if ($this->inTraceMode()) { $this->logger->info('MAPPER: [%(backend)] [%(title)] No metadata changes detected.', [ 'id' => $cloned->id, 'backend' => $entity->via, 'title' => $cloned->getName(), ]); } return $this; } // -- Item date is older than recorded last sync date logic handling. if (null !== ($opts['after'] ?? null) && true === ($opts['after'] instanceof iDate)) { if ($opts['after']->getTimestamp() >= $entity->updated) { // -- Handle mark as unplayed logic. if (false === $entity->isWatched() && true === $cloned->shouldMarkAsUnplayed(backend: $entity)) { try { $local = $local->apply( entity: $entity, fields: array_merge($keys, [iState::COLUMN_EXTRA]) )->markAsUnplayed($entity); if (false === $inDryRunMode) { $this->db->update($local); } $this->logger->notice('MAPPER: [%(backend)] marked [%(title)] as unplayed.', [ 'id' => $cloned->id, 'backend' => $entity->via, 'title' => $cloned->getName(), 'changes' => $local->diff(), ]); if (null === ($this->changed[$local->id] ?? null)) { $this->actions[$local->type]['updated']++; Message::increment("{$entity->via}.{$local->type}.updated"); } $this->changed[$local->id] = $this->objects[$local->id] = $local->id; } catch (PDOException $e) { $this->actions[$local->type]['failed']++; Message::increment("{$entity->via}.{$local->type}.failed"); $this->logger->error(sprintf('MAPPER: %s', $e->getMessage()), [ 'id' => $cloned->id, 'backend' => $entity->via, 'title' => $cloned->getName(), 'state' => [ 'database' => $cloned->getAll(), 'backend' => $entity->getAll() ], ]); } return $this; } // -- this sometimes leads to never ending updates as data from backends conflicts. if (true === (bool)ag($this->options, Options::MAPPER_ALWAYS_UPDATE_META)) { if (true === (clone $cloned)->apply(entity: $entity, fields: $keys)->isChanged(fields: $keys)) { try { $localFields = array_merge($keys, [iState::COLUMN_GUIDS]); $entity->guids = Guid::makeVirtualGuid( $entity->via, ag($entity->getMetadata($entity->via), iState::COLUMN_ID) ); $local = $local->apply( entity: $entity, fields: array_merge($localFields, [iState::COLUMN_EXTRA]) ); $this->removePointers($cloned)->addPointers($local, $local->id); $changes = $local->diff(fields: $localFields); if (count($changes) >= 1) { $this->logger->notice('MAPPER: [%(backend)] updated [%(title)] metadata.', [ 'id' => $cloned->id, 'backend' => $entity->via, 'title' => $cloned->getName(), 'changes' => $local->diff(fields: $localFields), ]); } if (false === $inDryRunMode) { $this->db->update($local); } if (null === ($this->changed[$local->id] ?? null)) { $this->actions[$local->type]['updated']++; Message::increment("{$entity->via}.{$local->type}.updated"); } $this->changed[$local->id] = $this->objects[$local->id] = $local->id; } catch (PDOException $e) { $this->actions[$local->type]['failed']++; Message::increment("{$entity->via}.{$local->type}.failed"); $this->logger->error(sprintf('MAPPER: %s', $e->getMessage()), [ 'id' => $cloned->id, 'title' => $cloned->getName(), 'state' => [ 'database' => $cloned->getAll(), 'backend' => $entity->getAll() ], ]); } return $this; } } if ($this->inTraceMode()) { $this->logger->debug('MAPPER: Ignoring [%(backend)] [%(title)]. No changes detected.', [ 'id' => $cloned->id, 'backend' => $entity->via, 'title' => $cloned->getName(), ]); } Message::increment("{$entity->via}.{$entity->type}.ignored_not_played_since_last_sync"); return $this; } } $keys = $opts['diff_keys'] ?? array_flip( array_keys_diff( base: array_flip(iState::ENTITY_KEYS), list: iState::ENTITY_IGNORE_DIFF_CHANGES, has: false ) ); if (true === (clone $cloned)->apply(entity: $entity, fields: $keys)->isChanged(fields: $keys)) { try { $local = $local->apply( entity: $entity, fields: array_merge($keys, [iState::COLUMN_EXTRA]) ); $this->removePointers($cloned)->addPointers($local, $local->id); $changes = $local->diff(fields: $keys); if (count($changes) >= 1) { $this->logger->notice('MAPPER: [%(backend)] Updated [%(title)].', [ 'id' => $cloned->id, 'backend' => $entity->via, 'title' => $cloned->getName(), 'changes' => $local->diff(fields: $keys) ]); } if (false === $inDryRunMode) { $this->db->update($local); } if (null === ($this->changed[$local->id] ?? null)) { $this->actions[$local->type]['updated']++; Message::increment("{$entity->via}.{$entity->type}.updated"); } $this->changed[$local->id] = $this->objects[$local->id] = $local->id; } catch (PDOException $e) { $this->actions[$local->type]['failed']++; Message::increment("{$entity->via}.{$local->type}.failed"); $this->logger->error(sprintf('MAPPER: %s', $e->getMessage()), [ 'id' => $cloned->id, 'backend' => $entity->via, 'title' => $cloned->getName(), 'state' => [ 'database' => $cloned->getAll(), 'backend' => $entity->getAll() ], ]); } return $this; } if (true === $this->inTraceMode()) { $this->logger->debug('MAPPER: [%(backend)] [%(title)] metadata and play state is identical.', [ 'id' => $cloned->id, 'backend' => $entity->via, 'title' => $cloned->getName(), 'state' => [ 'database' => $cloned->getAll(), 'backend' => $entity->getAll(), ], ]); } Message::increment("{$entity->via}.{$entity->type}.ignored_no_change"); return $this; } public function get(iState $entity): null|iState { if (false === ($pointer = $this->getPointer($entity))) { return null; } if (true === ($pointer instanceof iState)) { return $pointer; } $entity->id = $pointer; return $this->db->get($entity); } public function remove(iState $entity): bool { $this->removePointers($entity); if (null !== ($this->objects[$entity->id] ?? null)) { unset($this->objects[$entity->id]); } if (null !== ($this->changed[$entity->id] ?? null)) { unset($this->changed[$entity->id]); } return $this->db->remove($entity); } public function commit(): mixed { $list = $this->actions; $this->reset(); return $list; } public function has(iState $entity): bool { return null !== $this->get($entity); } public function reset(): self { $this->actions = [ iState::TYPE_MOVIE => ['added' => 0, 'updated' => 0, 'failed' => 0], iState::TYPE_EPISODE => ['added' => 0, 'updated' => 0, 'failed' => 0], ]; $this->fullyLoaded = false; $this->changed = $this->objects = $this->pointers = []; return $this; } public function getObjects(array $opts = []): array { $list = []; $entity = $this->options['class'] ?? Container::get(iState::class); foreach ($this->objects as $id) { $list[] = $entity::fromArray([iState::COLUMN_ID => $id]); } if (empty($list)) { return []; } return $this->db->find(...$list); } public function getObjectsCount(): int { return count($this->objects); } public function count(): int { return count($this->changed); } public function setLogger(iLogger $logger): self { $this->logger = $logger; $this->db->setLogger($logger); return $this; } public function setDatabase(iDB $db): self { $this->db = $db; return $this; } public function inDryRunMode(): bool { return true === (bool)ag($this->options, Options::DRY_RUN, false); } public function inTraceMode(): bool { return true === (bool)ag($this->options, Options::DEBUG_TRACE, false); } public function getPointersList(): array { return $this->pointers; } public function getChangedList(): array { return $this->changed; } protected function addPointers(iState $entity, string|int $pointer): iImport { foreach ($entity->getRelativePointers() as $key) { $this->pointers[$key] = $pointer; } foreach ($entity->getPointers() as $key) { $this->pointers[$key . '/' . $entity->type] = $pointer; } return $this; } /** * Is the object already mapped? * * @param iState $entity * * @return iState|int|string|bool int|string pointer for the object, or false if not registered. */ protected function getPointer(iState $entity): iState|int|string|bool { if (null !== $entity->id && null !== ($this->objects[$entity->id] ?? null)) { return $entity->id; } foreach ($entity->getRelativePointers() as $key) { if (null !== ($this->pointers[$key] ?? null)) { return $this->pointers[$key]; } } foreach ($entity->getPointers() as $key) { $lookup = $key . '/' . $entity->type; if (null !== ($this->pointers[$lookup] ?? null)) { return $this->pointers[$lookup]; } } if (false === $this->fullyLoaded && null !== ($lazyEntity = $this->db->get($entity))) { $this->objects[$lazyEntity->id] = $lazyEntity->id; $this->addPointers($lazyEntity, $lazyEntity->id); return $entity; } return false; } protected function removePointers(iState $entity): iImport { foreach ($entity->getPointers() as $key) { $lookup = $key . '/' . $entity->type; if (null !== ($this->pointers[$lookup] ?? null)) { unset($this->pointers[$lookup]); } } foreach ($entity->getRelativePointers() as $key) { if (null !== ($this->pointers[$key] ?? null)) { unset($this->pointers[$key]); } } return $this; } }