From 5ee7302186e060c3b8abd0f16f4e27d6619d555f Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sun, 11 May 2025 19:29:12 +0300 Subject: [PATCH 1/5] Attempt to fix #660 --- src/API/Backend/Webhooks.php | 2 + src/Libs/Mappers/Import/DirectMapper.php | 89 +++++++++++++----------- 2 files changed, 49 insertions(+), 42 deletions(-) diff --git a/src/API/Backend/Webhooks.php b/src/API/Backend/Webhooks.php index c5a2894e..8e3a04c4 100644 --- a/src/API/Backend/Webhooks.php +++ b/src/API/Backend/Webhooks.php @@ -137,6 +137,7 @@ final class Webhooks } } + $debugTrace = true === (bool)ag($backend, 'options.' . Options::DEBUG_TRACE); $metadataOnly = true === (bool)ag($backend, 'options.' . Options::IMPORT_METADATA_ONLY); if (true !== $metadataOnly && true !== (bool)ag($backend, 'import.enabled')) { @@ -208,6 +209,7 @@ final class Webhooks 'tainted' => $entity->isTainted(), Options::IMPORT_METADATA_ONLY => $metadataOnly, Options::REQUEST_ID => ag($request->getServerParams(), 'X_REQUEST_ID'), + Options::DEBUG_TRACE => $debugTrace, ], Options::CONTEXT_USER => $userContext->name, ]); diff --git a/src/Libs/Mappers/Import/DirectMapper.php b/src/Libs/Mappers/Import/DirectMapper.php index a8678b0d..93949fee 100644 --- a/src/Libs/Mappers/Import/DirectMapper.php +++ b/src/Libs/Mappers/Import/DirectMapper.php @@ -295,15 +295,10 @@ class DirectMapper implements ImportInterface context: [ 'user' => $this->userContext?->name ?? 'main', 'mapper' => afterLast(self::class, '\\'), - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), - ], 'backend' => $entity->via, 'title' => $entity->getName(), - 'state' => $entity->getAll() + 'state' => $entity->getAll(), + ...exception_log($e), ], e: $e ) @@ -401,12 +396,6 @@ class DirectMapper implements ImportInterface context: [ 'user' => $this->userContext?->name ?? 'main', 'mapper' => afterLast(self::class, '\\'), - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), - ], 'id' => $local->id ?? 'New', 'backend' => $entity->via, 'title' => $local->getName(), @@ -414,6 +403,7 @@ class DirectMapper implements ImportInterface 'database' => $local->getAll(), 'backend' => $entity->getAll() ], + ...exception_log($e), ], e: $e ) @@ -528,12 +518,6 @@ class DirectMapper implements ImportInterface context: [ 'user' => $this->userContext?->name ?? 'main', 'mapper' => afterLast(self::class, '\\'), - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), - ], 'id' => $cloned->id ?? 'New', 'backend' => $entity->via, 'title' => $cloned->getName(), @@ -541,6 +525,7 @@ class DirectMapper implements ImportInterface 'database' => $cloned->getAll(), 'backend' => $entity->getAll() ], + ...exception_log($e), ], e: $e ) @@ -624,12 +609,6 @@ class DirectMapper implements ImportInterface context: [ 'user' => $this->userContext?->name ?? 'main', 'mapper' => afterLast(self::class, '\\'), - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), - ], 'id' => $cloned->id ?? 'New', 'backend' => $entity->via, 'title' => $cloned->getName(), @@ -637,6 +616,7 @@ class DirectMapper implements ImportInterface 'database' => $cloned->getAll(), 'backend' => $entity->getAll() ], + ...exception_log($e), ], e: $e ) @@ -785,6 +765,10 @@ class DirectMapper implements ImportInterface } } + $newPlayProgress = (int)ag($entity->getMetadata($entity->via), iState::COLUMN_META_DATA_PROGRESS); + $oldPlayProgress = (int)ag($cloned->getMetadata($entity->via), iState::COLUMN_META_DATA_PROGRESS); + $playChanged = false === $metadataOnly && $newPlayProgress > ($oldPlayProgress + 10); + $keys = $opts['diff_keys'] ?? array_flip( array_keys_diff( base: array_flip(iState::ENTITY_KEYS), @@ -793,21 +777,34 @@ class DirectMapper implements ImportInterface ) ); - if (true === (clone $cloned)->apply(entity: $entity, fields: $keys)->isChanged(fields: $keys)) { + if ($playChanged || true === (clone $cloned)->apply(entity: $entity, fields: $keys)->isChanged(fields: $keys)) { try { - $local = $local->apply( - entity: $entity, - fields: array_merge($keys, [iState::COLUMN_EXTRA]) - ); + $allowUpdate = (int)Config::get('progress.threshold', 0); + $minThreshold = (int)Config::get('progress.minThreshold', 86_400); + $progress = $playChanged && $entity->hasPlayProgress(); + if ($entity->isWatched() && $allowUpdate < $minThreshold) { + $progress = false; + } + + $_keys = array_merge($keys, [iState::COLUMN_EXTRA]); + if ($playChanged && $progress) { + $_keys[] = iState::COLUMN_VIA; + } + + $local = $local->apply(entity: $entity, fields: $_keys); $this->removePointers($cloned)->addPointers($local, $local->id); - $changes = $local->diff(fields: $keys); + $changes = $local->diff(fields: $_keys); $message = "{mapper}: [A] '{user}@{backend}' Updated '#{id}: {title}'."; $isPlayChanged = $cloned->isWatched() !== $local->isWatched(); + if ($playChanged && $progress) { + $message .= " Due to play progress change."; + } + if (true === $isPlayChanged) { $message = "{mapper}: [A] '{user}@{backend}' Updated and marked '#{id}: {title}' as '{state}'."; if (null !== $onStateUpdate) { @@ -816,19 +813,29 @@ class DirectMapper implements ImportInterface } if (count($changes) >= 1) { - $this->logger->log(true === $isPlayChanged ? LogLevel::NOTICE : LogLevel::INFO, $message, [ + $level = ($playChanged && $progress) || $isPlayChanged ? LogLevel::NOTICE : LogLevel::INFO; + $this->logger->log($level, $message, [ 'user' => $this->userContext?->name ?? 'main', 'mapper' => afterLast(self::class, '\\'), 'id' => $cloned->id ?? 'New', 'backend' => $entity->via, 'title' => $cloned->getName(), 'state' => $local->isWatched() ? 'played' : 'unplayed', - 'changes' => $local->diff(fields: $keys) + 'changes' => $local->diff(fields: $_keys) ]); } if (false === $inDryRunMode) { $this->db->update($local); + if (true === $progress) { + $itemId = r('{type}://{id}:{tainted}@{backend}', [ + 'type' => $entity->type, + 'backend' => $entity->via, + 'tainted' => 'untainted', + 'id' => ag($entity->getMetadata($entity->via), iState::COLUMN_ID, '??'), + ]); + $this->progressItems[$itemId] = $local; + } } if (null === ($this->changed[$local->id] ?? null)) { @@ -846,12 +853,6 @@ class DirectMapper implements ImportInterface context: [ 'user' => $this->userContext?->name ?? 'main', 'mapper' => afterLast(self::class, '\\'), - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), - ], 'id' => $cloned->id ?? 'New', 'backend' => $entity->via, 'title' => $cloned->getName(), @@ -859,7 +860,7 @@ class DirectMapper implements ImportInterface 'database' => $cloned->getAll(), 'backend' => $entity->getAll() ], - 'trace' => $e->getTrace(), + ...exception_log($e), ], e: $e ) @@ -938,16 +939,20 @@ class DirectMapper implements ImportInterface */ public function commit(): array { - if (true === (bool)Config::get('sync.progress', false) && count($this->progressItems) >= 1) { + if (true === (bool)Config::get('sync.progress', false) && count($this->progressItems) >= 1 && false === $this->inDryRunMode()) { try { + $name = '{type}://{id}@{backend}'; + $opts = ['unique' => true]; if (null !== $this->userContext) { $opts = ag_set($opts, Options::CONTEXT_USER, $this->userContext->name); + $name = $name . '/' . $this->userContext->name; } foreach ($this->progressItems as $entity) { - $opts[EventsTable::COLUMN_REFERENCE] = r('{type}://{id}@{backend}', [ + + $opts[EventsTable::COLUMN_REFERENCE] = r($name, [ 'type' => $entity->type, 'backend' => $entity->via, 'id' => ag($entity->getMetadata($entity->via), iState::COLUMN_ID, '??'), From c70442eb5aa8dbd6d961452ded331da276d54ac2 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sun, 11 May 2025 22:28:29 +0300 Subject: [PATCH 2/5] Refactor DirectMapper to improve progress update handling and simplify metadata checks; update README with video installation guide. --- README.md | 8 + src/Libs/Mappers/Import/DirectMapper.php | 198 ++++++++++++----------- 2 files changed, 116 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 517084c9..466dc098 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,14 @@ simple and to the point yt-dlp frontend to help download content from all suppor # Install +If you prefer video format [AlienTech42 YouTube Channel](https://www.youtube.com/@AlienTech42) had a video about +installing WatchState using unraid [at this link](https://www.youtube.com/watch?v=XoztOwGHGxk). Much appreciated. + +PS: I don't know the channel owner, but I appreciate the effort. There is small mistake in the video regarding the +webhook URL, please copy the URL directly from the backends page. + +---- + First, start by creating a directory to store the data, to follow along with this setup, create directory called `data` at your working directory. Then proceed to use your preferred method to install the tool. diff --git a/src/Libs/Mappers/Import/DirectMapper.php b/src/Libs/Mappers/Import/DirectMapper.php index 93949fee..89f3cff4 100644 --- a/src/Libs/Mappers/Import/DirectMapper.php +++ b/src/Libs/Mappers/Import/DirectMapper.php @@ -204,11 +204,9 @@ class DirectMapper implements ImportInterface */ private function addNewItem(iState $entity, array $opts = []): self { - $metadataOnly = true === (bool)ag($opts, Options::IMPORT_METADATA_ONLY); $inDryRunMode = $this->inDryRunMode(); - $onStateUpdate = ag($opts, Options::STATE_UPDATE_EVENT, null); - if (true === $metadataOnly) { + if (true === (bool)ag($opts, Options::IMPORT_METADATA_ONLY)) { $this->actions[$entity->type]['failed']++; Message::increment("{$entity->via}.{$entity->type}.failed"); @@ -253,7 +251,7 @@ class DirectMapper implements ImportInterface $entity->id = random_int((int)(PHP_INT_MAX / 2), PHP_INT_MAX); } else { $entity = $this->db->insert($entity); - + $onStateUpdate = ag($opts, Options::STATE_UPDATE_EVENT, null); if (null !== $onStateUpdate && true === $entity->isWatched()) { $onStateUpdate($entity); } @@ -265,7 +263,7 @@ class DirectMapper implements ImportInterface 'mapper' => afterLast(self::class, '\\'), 'backend' => $entity->via, 'title' => $entity->getName(), - true === $this->inTraceMode() ? 'trace' : 'metadata' => $data, + 'metadata' => $data, ]); $this->addPointers($entity, $entity->id); @@ -277,7 +275,7 @@ class DirectMapper implements ImportInterface $this->changed[$entity->id] = $this->objects[$entity->id] = $entity->id; - if (true === $inDryRunMode && $entity->hasPlayProgress()) { + if (false === $inDryRunMode && $entity->hasPlayProgress()) { $itemId = r('{type}://{id}:{tainted}@{backend}', [ 'type' => $entity->type, 'backend' => $entity->via, @@ -323,13 +321,9 @@ class DirectMapper implements ImportInterface $inDryRunMode = $this->inDryRunMode(); $keys = [iState::COLUMN_META_DATA]; - $cloned = clone $local; + $progressChange = $this->shouldProgressUpdate($local, $entity, $opts); - $newPlayProgress = (int)ag($entity->getMetadata($entity->via), iState::COLUMN_META_DATA_PROGRESS); - $oldPlayProgress = (int)ag($cloned->getMetadata($entity->via), iState::COLUMN_META_DATA_PROGRESS); - $playChanged = !$metadataOnly && $newPlayProgress > ($oldPlayProgress + 10); - - if ($playChanged || true === (clone $local)->apply(entity: $entity, fields: $keys)->isChanged(fields: $keys)) { + if (true === $progressChange || true === (clone $local)->apply($entity, fields: $keys)->isChanged($keys)) { try { $local = $local->apply( entity: $entity, @@ -337,46 +331,41 @@ class DirectMapper implements ImportInterface ); $this->removePointers($local)->addPointers($local, $local->id); - $changes = $local->diff(fields: $keys); - $allowUpdate = (int)Config::get('progress.threshold', 0); - $minThreshold = (int)Config::get('progress.minThreshold', 86_400); - $progress = $playChanged && $entity->hasPlayProgress(); - if ($entity->isWatched() && $allowUpdate < $minThreshold) { - $progress = false; - } - - if (count($changes) >= 1) { + if (true === $progressChange || count($changes) >= 1) { $_keys = array_merge($keys, [iState::COLUMN_EXTRA]); - if ($playChanged && $progress) { + if (true === $progressChange) { $_keys[] = iState::COLUMN_VIA; } + $local = $local->apply($entity, fields: $_keys); + + $message = "{mapper}: [T] '{user}@{backend}' updated '#{id}: {title}' "; + $this->logger->log( - $progress ? LogLevel::NOTICE : LogLevel::INFO, - $progress ? "{mapper}: [T] '{user}@{backend}' updated '#{id}: {title}' due to play progress change." : "{mapper}: [T] '{user}@{backend}' updated '#{id}: {title}' metadata.", + true === $progressChange ? LogLevel::NOTICE : LogLevel::INFO, + $message . (true === $progressChange ? "due to play progress change." : "metadata."), [ 'user' => $this->userContext?->name ?? 'main', 'mapper' => afterLast(self::class, '\\'), - 'id' => $cloned->id ?? 'New', + 'id' => $local->id ?? 'New', 'backend' => $entity->via, - 'title' => $cloned->getName(), - 'changes' => $progress ? $local->diff(fields: $_keys) : $changes, + 'title' => $local->getName(), + 'changes' => true === $progressChange ? $local->diff(fields: $_keys) : $changes, ] ); } if (false === $inDryRunMode) { $this->db->update($local); - if (true === $progress) { + if (true === $progressChange) { $itemId = r('{type}://{id}:{tainted}@{backend}', [ 'type' => $entity->type, 'backend' => $entity->via, 'tainted' => 'untainted', 'id' => ag($entity->getMetadata($entity->via), iState::COLUMN_ID, '??'), ]); - $this->progressItems[$itemId] = $local; } } @@ -472,9 +461,7 @@ class DirectMapper implements ImportInterface private function handleOldEntity(iState $local, iState $entity, array $opts = []): self { $keys = [iState::COLUMN_META_DATA]; - $metadataOnly = true === (bool)ag($opts, Options::IMPORT_METADATA_ONLY); $inDryRunMode = $this->inDryRunMode(); - $onStateUpdate = ag($opts, Options::STATE_UPDATE_EVENT, null); $cloned = clone $local; @@ -488,8 +475,7 @@ class DirectMapper implements ImportInterface if (false === $inDryRunMode) { $this->db->update($local); - - if (null !== $onStateUpdate) { + if (null !== ($onStateUpdate = ag($opts, Options::STATE_UPDATE_EVENT, null))) { $onStateUpdate($local); } } @@ -535,14 +521,14 @@ class DirectMapper implements ImportInterface return $this; } - $newPlayProgress = (int)ag($entity->getMetadata($entity->via), iState::COLUMN_META_DATA_PROGRESS); - $oldPlayProgress = (int)ag($cloned->getMetadata($entity->via), iState::COLUMN_META_DATA_PROGRESS); - $playChanged = !$metadataOnly && $newPlayProgress > ($oldPlayProgress + 10); - $metaExists = count($cloned->getMetadata($entity->via)) >= 1; + $progressChange = $this->shouldProgressUpdate($local, $entity, $opts); + + $updateMeta = true === (bool)ag($this->options, Options::MAPPER_ALWAYS_UPDATE_META); + $hasMeta = count($cloned->getMetadata($entity->via)) >= 1; // -- this sometimes leads to never ending updates as data from backends conflicts. - if (!$metaExists || $playChanged || true === (bool)ag($this->options, Options::MAPPER_ALWAYS_UPDATE_META)) { - if (true === (clone $cloned)->apply(entity: $entity, fields: $keys)->isChanged(fields: $keys)) { + if (false === $hasMeta || true === $progressChange || true === $updateMeta) { + if (false === $hasMeta || $progressChange || (clone $cloned)->apply($entity, $keys)->isChanged($keys)) { try { $local = $local->apply( entity: $entity, @@ -552,44 +538,38 @@ class DirectMapper implements ImportInterface $this->removePointers($cloned)->addPointers($local, $local->id); $changes = $local->diff(fields: $keys); - $allowUpdate = (int)Config::get('progress.threshold', 0); - $minThreshold = (int)Config::get('progress.minThreshold', 86_400); - $progress = $playChanged && $entity->hasPlayProgress(); - if ($entity->isWatched() && $allowUpdate < $minThreshold) { - $progress = false; - } - - if (count($changes) >= 1) { + if (true === $progressChange || false === $hasMeta || count($changes) >= 1) { $_keys = array_merge($keys, [iState::COLUMN_EXTRA]); - if ($playChanged && $progress) { + if (true === $progressChange) { $_keys[] = iState::COLUMN_VIA; } $local = $local->apply($entity, fields: $_keys); + + $message = "{mapper}: [O] '{user}@{backend}' updated '#{id}: {title}' "; $this->logger->log( - $progress ? LogLevel::NOTICE : LogLevel::INFO, - $progress ? "{mapper}: [O] '{user}@{backend}' updated '#{id}: {title}' due to play progress change." : "{mapper}: [O] '{user}@{backend}' updated '#{id}: {title}' metadata.", + true === $progressChange ? LogLevel::NOTICE : LogLevel::INFO, + $message . (true === $progressChange ? "due to play progress change." : "metadata."), [ 'user' => $this->userContext?->name ?? 'main', 'mapper' => afterLast(self::class, '\\'), 'id' => $cloned->id ?? 'New', 'backend' => $entity->via, 'title' => $cloned->getName(), - 'changes' => $progress ? $local->diff(fields: $_keys) : $changes, + 'changes' => true === $progressChange ? $local->diff(fields: $_keys) : $changes, ] ); } if (false === $inDryRunMode) { $this->db->update($local); - if ($progress) { + if (true === $progressChange) { $itemId = r('{type}://{id}:{tainted}@{backend}', [ 'type' => $entity->type, 'backend' => $entity->via, 'tainted' => 'untainted', 'id' => ag($entity->getMetadata($entity->via), iState::COLUMN_ID, '??'), ]); - $this->progressItems[$itemId] = $local; } } @@ -629,9 +609,10 @@ class DirectMapper implements ImportInterface Message::increment("{$entity->via}.{$entity->type}.ignored_not_played_since_last_sync"); - if ($entity->isWatched() !== $local->isWatched()) { + $hasAfter = null !== ($opts['after'] ?? null) && true === ($opts['after'] instanceof iDate); + if ($entity->isWatched() !== $local->isWatched() && $hasAfter) { $this->logger->notice( - "{mapper}: [O] '{user}@{backend}' item '#{id}: {title}' is marked as '{state}' vs local '{local_state}', however due to the remote item date '{remote_date}' being older than the last backend sync date '{local_date}'. it was not considered as valid state.", + "{mapper}: [O] '{user}@{backend}' item '#{id}: {title}' date '{remote_date}' is older than last sync date '{local_date}'. Marking the item as tainted and re-processing.", [ 'user' => $this->userContext?->name ?? 'main', 'mapper' => afterLast(self::class, '\\'), @@ -644,7 +625,15 @@ class DirectMapper implements ImportInterface 'title' => $entity->getName(), ] ); - return $this; + + $entity->metadata = ag_set( + $entity->getMetadata(), + "{$entity->via}." . iState::COLUMN_META_DATA_PLAYED_AT, + $entity->updated + ); + $entity->setIsTainted(true); + + return $this->add($entity, $opts); } if ($this->inTraceMode()) { @@ -696,10 +685,6 @@ class DirectMapper implements ImportInterface return $this; } - $metadataOnly = true === (bool)ag($opts, Options::IMPORT_METADATA_ONLY); - $inDryRunMode = $this->inDryRunMode(); - $onStateUpdate = ag($opts, Options::STATE_UPDATE_EVENT, null); - /** * Handle adding new item logic. */ @@ -717,7 +702,7 @@ class DirectMapper implements ImportInterface * ONLY update backend metadata * if metadataOnly is set or the event is tainted. */ - if (true === $metadataOnly || true === $entity->isTainted()) { + if (true === (bool)ag($opts, Options::IMPORT_METADATA_ONLY) || true === $entity->isTainted()) { return $this->handleTainted($cloned, $entity, $opts); } @@ -728,7 +713,7 @@ class DirectMapper implements ImportInterface } /** - * Fix for #329 {@see https://github.com/arabcoders/watchstate/issues/329} + * Fix for issue #329 {@see https://github.com/arabcoders/watchstate/issues/329} * * This conditional block should proceed only if specific conditions are met. * 1- the backend state is [unwatched] while the db state is [watched] @@ -742,7 +727,9 @@ class DirectMapper implements ImportInterface if (false === $hasMeta) { $message .= ' No metadata. Marking the item as tainted and re-processing.'; - } elseif (true === $hasDate) { + } + + if (true === $hasMeta && true === $hasDate) { $message .= ' db.metadata.played_at is equal to entity.updated. Marking the item as tainted and re-processing.'; } @@ -765,10 +752,19 @@ class DirectMapper implements ImportInterface } } - $newPlayProgress = (int)ag($entity->getMetadata($entity->via), iState::COLUMN_META_DATA_PROGRESS); - $oldPlayProgress = (int)ag($cloned->getMetadata($entity->via), iState::COLUMN_META_DATA_PROGRESS); - $playChanged = false === $metadataOnly && $newPlayProgress > ($oldPlayProgress + 10); + return $this->handleUntaintedEntity($cloned, $entity, $opts); + } + /** + * Handle Untainted entities. + */ + public function handleUntaintedEntity(iState $local, iState $entity, array $opts = []): self + { + $inDryRunMode = $this->inDryRunMode(); + + $cloned = clone $local; + + $progressChange = $this->shouldProgressUpdate($local, $entity, $opts); $keys = $opts['diff_keys'] ?? array_flip( array_keys_diff( base: array_flip(iState::ENTITY_KEYS), @@ -777,18 +773,10 @@ class DirectMapper implements ImportInterface ) ); - if ($playChanged || true === (clone $cloned)->apply(entity: $entity, fields: $keys)->isChanged(fields: $keys)) { + if (true === $progressChange || true === (clone $cloned)->apply($entity, fields: $keys)->isChanged($keys)) { try { - $allowUpdate = (int)Config::get('progress.threshold', 0); - $minThreshold = (int)Config::get('progress.minThreshold', 86_400); - - $progress = $playChanged && $entity->hasPlayProgress(); - if ($entity->isWatched() && $allowUpdate < $minThreshold) { - $progress = false; - } - $_keys = array_merge($keys, [iState::COLUMN_EXTRA]); - if ($playChanged && $progress) { + if (true === $progressChange) { $_keys[] = iState::COLUMN_VIA; } @@ -797,24 +785,23 @@ class DirectMapper implements ImportInterface $changes = $local->diff(fields: $_keys); - $message = "{mapper}: [A] '{user}@{backend}' Updated '#{id}: {title}'."; + $message = "{mapper}: [U] '{user}@{backend}' Updated '#{id}: {title}'."; - $isPlayChanged = $cloned->isWatched() !== $local->isWatched(); + $stateChange = $cloned->isWatched() !== $local->isWatched(); - if ($playChanged && $progress) { + if (true === $progressChange) { $message .= " Due to play progress change."; } - if (true === $isPlayChanged) { - $message = "{mapper}: [A] '{user}@{backend}' Updated and marked '#{id}: {title}' as '{state}'."; - if (null !== $onStateUpdate) { + if (true === $stateChange) { + $message = "{mapper}: [U] '{user}@{backend}' Updated and marked '#{id}: {title}' as '{state}'."; + if (null !== ($onStateUpdate = ag($opts, Options::STATE_UPDATE_EVENT, null))) { $onStateUpdate($local); } } - if (count($changes) >= 1) { - $level = ($playChanged && $progress) || $isPlayChanged ? LogLevel::NOTICE : LogLevel::INFO; - $this->logger->log($level, $message, [ + if (true === $progressChange || count($changes) >= 1) { + $this->logger->log($progressChange || $stateChange ? LogLevel::NOTICE : LogLevel::INFO, $message, [ 'user' => $this->userContext?->name ?? 'main', 'mapper' => afterLast(self::class, '\\'), 'id' => $cloned->id ?? 'New', @@ -827,7 +814,7 @@ class DirectMapper implements ImportInterface if (false === $inDryRunMode) { $this->db->update($local); - if (true === $progress) { + if (true === $progressChange) { $itemId = r('{type}://{id}:{tainted}@{backend}', [ 'type' => $entity->type, 'backend' => $entity->via, @@ -849,7 +836,7 @@ class DirectMapper implements ImportInterface Message::increment("{$entity->via}.{$local->type}.failed"); $this->logger->error( ...lw( - message: "{mapper}: [A] Exception '{error.kind}' was thrown unhandled during '{user}@{backend}' - '{title}' add. {error.message} at '{error.file}:{error.line}'.", + message: "{mapper}: [U] Exception '{error.kind}' was thrown unhandled during '{user}@{backend}' - '{title}' add. {error.message} at '{error.file}:{error.line}'.", context: [ 'user' => $this->userContext?->name ?? 'main', 'mapper' => afterLast(self::class, '\\'), @@ -887,7 +874,7 @@ class DirectMapper implements ImportInterface if ($this->inTraceMode()) { $this->logger->info( - "{mapper}: [A] Ignoring '{user}@{backend}' - '#{id}: {title}'. Metadata & play state are identical.", + "{mapper}: [U] Ignoring '{user}@{backend}' - '#{id}: {title}'. Metadata & play state are identical.", $context ); } @@ -939,7 +926,9 @@ class DirectMapper implements ImportInterface */ public function commit(): array { - if (true === (bool)Config::get('sync.progress', false) && count($this->progressItems) >= 1 && false === $this->inDryRunMode()) { + if (true === (bool)Config::get('sync.progress', false) && count( + $this->progressItems + ) >= 1 && false === $this->inDryRunMode()) { try { $name = '{type}://{id}@{backend}'; @@ -951,7 +940,6 @@ class DirectMapper implements ImportInterface } foreach ($this->progressItems as $entity) { - $opts[EventsTable::COLUMN_REFERENCE] = r($name, [ 'type' => $entity->type, 'backend' => $entity->via, @@ -1179,4 +1167,34 @@ class DirectMapper implements ImportInterface return $this; } + + /** + * Check if the progress update should be applied. + * @param iState $old The old entity. + * @param iState $new The new entity. + * @param array $opts Additional options. + * + * @return bool True if the progress update should be applied, false otherwise. + */ + private function shouldProgressUpdate(iState $old, iState $new, array $opts = []): bool + { + if (false === (bool)ag($opts, Options::IMPORT_METADATA_ONLY)) { + return false; + } + + $newPlayProgress = (int)ag($new->getMetadata($new->via), iState::COLUMN_META_DATA_PROGRESS); + $oldPlayProgress = (int)ag($old->getMetadata($new->via), iState::COLUMN_META_DATA_PROGRESS); + $playChanged = $newPlayProgress > ($oldPlayProgress + 10); + + $allowUpdate = (int)Config::get('progress.threshold', 0); + $minThreshold = (int)Config::get('progress.minThreshold', 86_400); + + $progress = $playChanged && $new->hasPlayProgress(); + + if ($new->isWatched() && $allowUpdate < $minThreshold) { + $progress = false; + } + + return $progress; + } } From b4b493c1ed966bd1ff26f64c7306d504684bc6ca Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sun, 11 May 2025 22:54:28 +0300 Subject: [PATCH 3/5] Minor docs updates --- FAQ.md | 6 +++--- NEWS.md | 30 +++++++++++++++++++++++++++++- README.md | 40 +++++----------------------------------- 3 files changed, 37 insertions(+), 39 deletions(-) diff --git a/FAQ.md b/FAQ.md index f537cf45..a6d45053 100644 --- a/FAQ.md +++ b/FAQ.md @@ -227,9 +227,9 @@ This will sync your local database state to the backend, ignoring date compariso # Is there support for Multi-user setup? -There is **basic** support for multi-user setups, but it's not fully developed yet. The tool is primarily designed for -single-user use, and multi-user functionality is built on top of that. Because of this, you might encounter some issues -when using it with multiple users. +The tool is primarily designed for single-user use, The Multi-user/sub-users functionality is built on top of that. +Because of that you *may* encounter some issues when using it with multi-users. However, from our testing, sub-users +functionality works well right now and behave as expected in the majority of cases. Follow the guidelines below. ## Getting started with a multi-user setup diff --git a/NEWS.md b/NEWS.md index 9d6c2236..3bdcad83 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,6 +1,34 @@ # NEWS -This page contains old news about the project. +### 2025-05-05 + +We’ve added a new feature that lets you send requests **sequentially** to the backends instead of using the default +**parallel** mode. This can be especially helpful if you have very large libraries, slow disks, or simply want to avoid +overloading the backends with too many concurrent requests. You can enable by enabling `WS_HTTP_SYNC_REQUESTS` +environment variable. This mode only applies to `import`, `export`, and `backup` tasks at the moment. + +Additionally, two command-line flags let you override the mode on the fly `--sync-requests` and `--async-requests`. + +We’ll be evaluating this feature, and if it proves effective (and the slowdown is acceptable), we may +make **sequential** mode the default in a future release. So far from our testing, we’ve seen between 1.5x to 2.0x +increase in import time when using the sequential mode. + +> [!NOTE] +> Because we cache many HTTP requests, comparing timings between sequential and parallel runs of `import` can be +> misleading. To get an accurate benchmark of `--sync-requests`, either start with a fresh setup (new installation) or +> purge your Redis instance before testing. + +### 2025-04-06 + +We have recently re-worked how the `backend:create` command works, and we no longer generate random name for invalid +backends names or usernames. We do a normalization step to make sure the name is valid. This should help with the +confusion of having random names. This means if you re-run the `backend:create` you most likely will get a different +name than before. So, we suggest to re-run the command with `--re-create` flag. This flag will delete the current +sub-users, and regenerate updated config files. + +We have also added new guard for the command, so if you already generated your sub-users, re-running the command will +show you a warning message and exit without doing anything. to run the command again either you need to use +`--re-create` or `--run` flag. The `--run` flag will run the command without deleting the current sub-users. ### 2025-03-13 diff --git a/README.md b/README.md index 466dc098..74c738c7 100644 --- a/README.md +++ b/README.md @@ -4,47 +4,17 @@ ![MIT License](https://img.shields.io/github/license/arabcoders/WatchState.svg) ![Docker pull](https://img.shields.io/docker/pulls/arabcoders/watchstate.svg) -This tool primary goal is to sync your backends play state without relying on third party services, -out of the box, this tool support `Jellyfin`, `Plex` and `Emby` media servers. +This tool primary goal is to sync your backends **users** play state without relying on third party services, out of the +box, this tool support `Jellyfin`, `Plex` and `Emby` media servers. # Updates -### 2025-05-05 - -We’ve added a new feature that lets you send requests **sequentially** to the backends instead of using the default -**parallel** mode. This can be especially helpful if you have very large libraries, slow disks, or simply want to avoid -overloading the backends with too many concurrent requests. You can enable by enabling `WS_HTTP_SYNC_REQUESTS` -environment variable. This mode only applies to `import`, `export`, and `backup` tasks at the moment. - -Additionally, two command-line flags let you override the mode on the fly `--sync-requests` and `--async-requests`. - -We’ll be evaluating this feature, and if it proves effective (and the slowdown is acceptable), we may -make **sequential** mode the default in a future release. So far from our testing, we’ve seen between 1.5x to 2.0x -increase in import time when using the sequential mode. - -> [!NOTE] -> Because we cache many HTTP requests, comparing timings between sequential and parallel runs of `import` can be -> misleading. To get an accurate benchmark of `--sync-requests`, either start with a fresh setup (new installation) or -> purge your Redis instance before testing. - -### 2025-04-06 - -We have recently re-worked how the `backend:create` command works, and we no longer generate random name for invalid -backends names or usernames. We do a normalization step to make sure the name is valid. This should help with the -confusion of having random names. This means if you re-run the `backend:create` you most likely will get a different -name than before. So, we suggest to re-run the command with `--re-create` flag. This flag will delete the current -sub-users, and regenerate updated config files. - -We have also added new guard for the command, so if you already generated your sub-users, re-running the command will -show you a warning message and exit without doing anything. to run the command again either you need to use -`--re-create` or `--run` flag. The `--run` flag will run the command without deleting the current sub-users. - ---- -Refer to [NEWS](NEWS.md) for old updates. +Please refer to [NEWS](/NEWS.md) for the latest updates and changes. # Features * Management via WebUI. +* **Sub-users** support. * Sync backends play state (`Many-to-Many` or `One-Way`). * Backup your backends play state into `portable` format. * Receive webhook events from media backends. @@ -63,7 +33,7 @@ If you prefer video format [AlienTech42 YouTube Channel](https://www.youtube.com installing WatchState using unraid [at this link](https://www.youtube.com/watch?v=XoztOwGHGxk). Much appreciated. PS: I don't know the channel owner, but I appreciate the effort. There is small mistake in the video regarding the -webhook URL, please copy the URL directly from the backends page. +webhook URL, please copy the URL directly from the backends page. And this tool does support multi-users. ---- From 8ac5b411f3868de18a207871400153877b8370ac Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sun, 11 May 2025 23:28:16 +0300 Subject: [PATCH 4/5] Fixed logic error in determining if we should mark entity for progress update. --- src/Libs/Mappers/Import/DirectMapper.php | 28 +++++++++++++++++++----- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/Libs/Mappers/Import/DirectMapper.php b/src/Libs/Mappers/Import/DirectMapper.php index 89f3cff4..a277defa 100644 --- a/src/Libs/Mappers/Import/DirectMapper.php +++ b/src/Libs/Mappers/Import/DirectMapper.php @@ -1178,7 +1178,10 @@ class DirectMapper implements ImportInterface */ private function shouldProgressUpdate(iState $old, iState $new, array $opts = []): bool { - if (false === (bool)ag($opts, Options::IMPORT_METADATA_ONLY)) { + if (true === (bool)ag($opts, Options::IMPORT_METADATA_ONLY, false)) { + if (true === $this->inTraceMode()) { + $this->logger?->info('only metadata updates allowed.'); + } return false; } @@ -1189,12 +1192,25 @@ class DirectMapper implements ImportInterface $allowUpdate = (int)Config::get('progress.threshold', 0); $minThreshold = (int)Config::get('progress.minThreshold', 86_400); - $progress = $playChanged && $new->hasPlayProgress(); - - if ($new->isWatched() && $allowUpdate < $minThreshold) { - $progress = false; + if (true === $new->isWatched() && $allowUpdate < $minThreshold) { + if (true === $this->inTraceMode()) { + $this->logger?->info('play progress update not allowed. threshold is too low.'); + } + return false; } - return $progress; + if (true === $this->inTraceMode()) { + $message = "{mapper}: '{user}@{backend}' - '#{id}: {title}' Is {not}marked for progress update."; + $this->logger->info($message, [ + 'mapper' => afterLast(self::class, '\\'), + 'user' => $this->userContext?->name ?? 'main', + 'backend' => $new->via, + 'id' => $old->id ?? 'New', + 'title' => $old->getName(), + 'not' => $playChanged ? '' : 'not ', + ]); + } + + return $playChanged; } } From 5ce3d9fb6b7b300a12f118f5177830324a304f16 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sun, 11 May 2025 23:39:42 +0300 Subject: [PATCH 5/5] Disable process request debug trace by default. --- src/Listeners/ProcessRequestEvent.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Listeners/ProcessRequestEvent.php b/src/Listeners/ProcessRequestEvent.php index cc81e92c..1cd9ce4a 100644 --- a/src/Listeners/ProcessRequestEvent.php +++ b/src/Listeners/ProcessRequestEvent.php @@ -79,10 +79,8 @@ final readonly class ProcessRequestEvent $writer(Level::Notice, $message); $mapper = $userContext->mapper; - if (null !== ag($e->getOptions(), Options::DEBUG_TRACE)) { - $mapper = $userContext->mapper->setOptions( - ag_set($userContext->mapper->getOptions(), Options::DEBUG_TRACE, true) - ); + if (true === (bool)ag($e->getOptions(), Options::DEBUG_TRACE, false)) { + $mapper = $mapper->setOptions(ag_set($mapper->getOptions(), Options::DEBUG_TRACE, true)); } $logger = clone $this->logger; @@ -94,7 +92,7 @@ final readonly class ProcessRequestEvent $mapper->add($entity, [ Options::IMPORT_METADATA_ONLY => (bool)ag($e->getOptions(), Options::IMPORT_METADATA_ONLY), - Options::STATE_UPDATE_EVENT => fn (iState $state) => queuePush(entity: $state, userContext: $userContext), + Options::STATE_UPDATE_EVENT => fn(iState $state) => queuePush(entity: $state, userContext: $userContext), 'after' => $lastSync, ]);