From 48e5e4d98d22f0d1079ab80f8545878644079c01 Mon Sep 17 00:00:00 2001 From: ArabCoders Date: Wed, 19 Feb 2025 13:06:36 +0300 Subject: [PATCH] experimental support for syncing watch progress for played items. #617 --- NEWS.md | 21 +++++++ README.md | 34 +++++------ config/config.php | 6 ++ config/env.spec.php | 22 +++++++ src/Backends/Emby/Action/Progress.php | 28 +++++---- src/Backends/Jellyfin/Action/Progress.php | 32 ++++++----- src/Backends/Plex/Action/Progress.php | 32 ++++++----- src/Libs/Entity/StateEntity.php | 12 ++-- src/Libs/Mappers/Import/DirectMapper.php | 36 ++++++++---- src/Libs/Mappers/Import/MemoryMapper.php | 26 ++++++--- src/Listeners/ProcessProgressEvent.php | 70 ++++++++++++++--------- 11 files changed, 207 insertions(+), 112 deletions(-) diff --git a/NEWS.md b/NEWS.md index d59296a7..33bcb6f8 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,26 @@ # Old Updates +### 2025-02-02 + +We are happy to announce that we have merged in direct support for multi-user in `state:import` and `state:export` +commands and tasks. Therefore, `state:sync` command has been removed. Once you generate the sub users configs. it will +start working alongside the main user. + +### 2025-02-01 + +Breaking changes as of version 20250201~, in earlier versions, if you want to sync multi-user play state, you only had +to run `state:sync` command, However, due to us extending support for more operation to support multi-user data, we +needed a way to generate per user config instead of relying on `state:sync`, thus we have introduced a new command +called `backends:create`, the purpose of this command is to generate the needed config files for each user. + +This change allow us to support more operations in the future. + +We also have minor breaking change in per user db name, before it was named `user_name.db`, now it's named `user.db` +this change shouldn't effect you as we have backward compatibility in place to rename the old db to the new name. + +for more information about multi-user, Please read the FAQ entry about it +at [this link](FAQ.md#is-there-support-for-multi-user-setup). + ### 2025-01-24 We are excited to share that multi-user sync is now fully supported! Our first goal was to make sure the feature worked, diff --git a/README.md b/README.md index b27da94f..a8866eb2 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,19 @@ out of the box, this tool support `Jellyfin`, `Plex` and `Emby` media servers. ## Updates +### 2025-02-19 + +We have introduced new experimental feature to allow syncing watch progress for played items. This feature is still in +early stages, and might not work as expected. and there are probably still many bugs that we need to fix. Please report +any issues you might face. + +The feature is disabled by default, to enable it you need to run add this environment variable `WS_PROGRESS_THRESHOLD` +with seconds as value, the minimum value is `180` seconds. `0` seconds means it's disabled. We think reasonable value is +`86400` or more this number is about 1day. + +We are still not keen on this feature, and it might be removed in future releases if we aren't able to deal with the +issues we are facing. + ### 2025-02-11 We recently have added support to generate accesstoken for external `Plex` users, i.e. `not home users`. so the @@ -22,27 +35,6 @@ it. We have also added support to webhooks to allow sub users, you simply have to add new hooks using `user@backend`. Please take look at [this FAQ](FAQ.md#how-to-add-webhooks) to learn how to use it for sub users. -### 2025-02-02 - -We are happy to announce that we have merged in direct support for multi-user in `state:import` and `state:export` -commands and tasks. Therefore, `state:sync` command has been removed. Once you generate the sub users configs. it will -start working alongside the main user. - -### 2025-02-01 - -Breaking changes as of version 20250201~, in earlier versions, if you want to sync multi-user play state, you only had -to run `state:sync` command, However, due to us extending support for more operation to support multi-user data, we -needed a way to generate per user config instead of relying on `state:sync`, thus we have introduced a new command -called `backends:create`, the purpose of this command is to generate the needed config files for each user. - -This change allow us to support more operations in the future. - -We also have minor breaking change in per user db name, before it was named `user_name.db`, now it's named `user.db` -this change shouldn't effect you as we have backward compatibility in place to rename the old db to the new name. - -for more information about multi-user, Please read the FAQ entry about it -at [this link](FAQ.md#is-there-support-for-multi-user-setup). - --- Refer to [NEWS](NEWS.md) for old updates. diff --git a/config/config.php b/config/config.php index 80adaff1..8b67c063 100644 --- a/config/config.php +++ b/config/config.php @@ -17,6 +17,8 @@ use Monolog\Level; return (function () { $inContainer = inContainer(); + $progressTimeCheck = fn(int $v, int $d): int => 0 === $v || $v >= 180 ? $v : $d; + $config = [ 'name' => 'WatchState', 'version' => '$(version_via_ci)', @@ -77,6 +79,10 @@ return (function () { 'sync' => [ 'progress' => (bool)env('WS_SYNC_PROGRESS', true), ], + 'progress' => [ + // -- Allows to sync watch progress for played items. + 'threshold' => $progressTimeCheck((int)env('WS_PROGRESS_THRESHOLD', 0), 60 * 10), + ], ]; $config['guid'] = [ diff --git a/config/env.spec.php b/config/env.spec.php index 8c25630a..e8097b62 100644 --- a/config/env.spec.php +++ b/config/env.spec.php @@ -181,6 +181,28 @@ return (function () { 'description' => 'The path to save the profiler data.', 'type' => 'string', ], + [ + 'key' => 'WS_PROGRESS_THRESHOLD', + 'description' => 'Allow watch progress sync for played items. Expects seconds. Minimum 180. 0 to disable.', + 'type' => 'string', + 'validate' => function (mixed $value): string { + if (!is_numeric($value) && empty($value)) { + throw new ValidationException('Invalid progress threshold. Empty value.'); + } + + if (false === is_numeric($value)) { + throw new ValidationException('Invalid progress threshold. Must be a number.'); + } + + $cmp = (int)$value; + + if (0 !== $cmp && $cmp < 181) { + throw new ValidationException('Invalid progress threshold. Must be at least 180 seconds.'); + } + + return $value; + }, + ], ]; $validateCronExpression = function (string $value): string { diff --git a/src/Backends/Emby/Action/Progress.php b/src/Backends/Emby/Action/Progress.php index fab755a1..d43beca7 100644 --- a/src/Backends/Emby/Action/Progress.php +++ b/src/Backends/Emby/Action/Progress.php @@ -9,6 +9,7 @@ use App\Backends\Common\Context; use App\Backends\Common\GuidInterface as iGuid; use App\Backends\Common\Response; use App\Backends\Emby\EmbyActionTrait; +use App\Libs\Config; use App\Libs\Container; use App\Libs\Entity\StateInterface as iState; use App\Libs\Enums\Http\Method; @@ -117,7 +118,7 @@ class Progress if ($context->backendName === $entity->via) { $this->logger->info( - message: "{action}: Not processing '{item.title}' for '{client}: {user}@{backend}'. Event originated from this backend.", + message: "{action}: Not processing '#{item.id}: {item.title}' for '{client}: {user}@{backend}'. Event originated from this backend.", context: $logContext, ); continue; @@ -125,7 +126,7 @@ class Progress if (null === ag($metadata, iState::COLUMN_ID, null)) { $this->logger->warning( - message: "{action}: Not processing '{item.title}' for '{client}: {user}@{backend}'. No metadata was found.", + message: "{action}: Not processing '#{item.id}: {item.title}' for '{client}: {user}@{backend}'. No metadata was found.", context: $logContext, ); continue; @@ -134,7 +135,7 @@ class Progress $senderDate = ag($entity->getExtra($entity->via), iState::COLUMN_EXTRA_DATE); if (null === $senderDate) { $this->logger->warning( - message: "{action}: Not processing '{item.title}' for '{client}: {user}@{backend}'. The event originator did not set a date.", + message: "{action}: Not processing '#{item.id}: {item.title}' for '{client}: {user}@{backend}'. The event originator did not set a date.", context: $logContext, ); continue; @@ -145,7 +146,7 @@ class Progress $datetime = ag($entity->getExtra($context->backendName), iState::COLUMN_EXTRA_DATE, null); if (false === $ignoreDate && null !== $datetime && makeDate($datetime)->getTimestamp() > $senderDate) { $this->logger->warning( - message: "{action}: Not processing '{item.title}' for '{client}: {user}@{backend}'. Event date is older than backend local item date.", + message: "{action}: Not processing '#{item.id}: {item.title}' for '{client}: {user}@{backend}'. Event date is older than backend local item date.", context: [ ...$logContext, 'compare' => [ @@ -161,7 +162,7 @@ class Progress if (array_key_exists($logContext['remote']['id'], $sessions)) { $this->logger->notice( - message: "{action}: Not processing '{item.title}' for '{client}: {user}@{backend}'. The item is playing right now.", + message: "{action}: Not processing '#{item.id}: {item.title}' for '{client}: {user}@{backend}'. The item is playing right now.", context: $logContext, ); continue; @@ -177,7 +178,7 @@ class Progress if (false === $ignoreDate && makeDate($remoteItem->updated)->getTimestamp() > $senderDate) { $this->logger->info( - message: "{action}: Not processing '{item.title}' for '{client}: {user}@{backend}'. Event date is older than backend remote item date.", + message: "{action}: Not processing '#{item.id}: {item.title}' for '{client}: {user}@{backend}'. Event date is older than backend remote item date.", context: [ ...$logContext, 'compare' => [ @@ -190,16 +191,19 @@ class Progress } if ($remoteItem->isWatched()) { - $this->logger->info( - message: "{action}: Not processing '{item.title}' for '{client}: {user}@{backend}'. The backend says the item is marked as watched.", - context: $logContext, - ); - continue; + $allowUpdate = (int)Config::get('progress.threshold', 0); + if (false === ($allowUpdate >= 300 && time() > ($entity->updated + $allowUpdate))) { + $this->logger->info( + message: "{action}: Not processing '#{item.id}: {item.title}' for '{client}: {user}@{backend}'. The backend says the item is marked as watched.", + context: $logContext, + ); + continue; + } } } catch (\App\Libs\Exceptions\RuntimeException|RuntimeException|InvalidArgumentException $e) { $this->logger->error( ...lw( - message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {user}@{backend}' get {item.type} '{item.title}' status. '{error.message}' at '{error.file}:{error.line}'.", + message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {user}@{backend}' get {item.type} '#{item.id}: {item.title}' status. '{error.message}' at '{error.file}:{error.line}'.", context: [ ...$logContext, 'error' => [ diff --git a/src/Backends/Jellyfin/Action/Progress.php b/src/Backends/Jellyfin/Action/Progress.php index 8b8cc5cf..9e4076ba 100644 --- a/src/Backends/Jellyfin/Action/Progress.php +++ b/src/Backends/Jellyfin/Action/Progress.php @@ -9,6 +9,7 @@ use App\Backends\Common\Context; use App\Backends\Common\GuidInterface as iGuid; use App\Backends\Common\Response; use App\Backends\Jellyfin\JellyfinActionTrait; +use App\Libs\Config; use App\Libs\Container; use App\Libs\Entity\StateInterface as iState; use App\Libs\Enums\Http\Method; @@ -144,7 +145,7 @@ class Progress if ($context->backendName === $entity->via) { $this->logger->info( - message: "{action}: Not processing '{item.title}' for '{client}: {user}@{backend}'. Event originated from this backend.", + message: "{action}: Not processing '#{item.id}: {item.title}' for '{client}: {user}@{backend}'. Event originated from this backend.", context: $logContext, ); continue; @@ -152,7 +153,7 @@ class Progress if (null === ag($metadata, iState::COLUMN_ID, null)) { $this->logger->warning( - message: "{action}: Not processing '{item.title}' for '{client}: {user}@{backend}'. No metadata was found.", + message: "{action}: Not processing '#{item.id}: {item.title}' for '{client}: {user}@{backend}'. No metadata was found.", context: $logContext, ); continue; @@ -161,7 +162,7 @@ class Progress $senderDate = ag($entity->getExtra($entity->via), iState::COLUMN_EXTRA_DATE); if (null === $senderDate) { $this->logger->warning( - message: "{action}: Not processing '{item.title}' for '{client}: {user}@{backend}'. The event originator did not set a date.", + message: "{action}: Not processing '#{item.id}: {item.title}' for '{client}: {user}@{backend}'. The event originator did not set a date.", context: $logContext, ); @@ -173,7 +174,7 @@ class Progress $datetime = ag($entity->getExtra($context->backendName), iState::COLUMN_EXTRA_DATE, null); if (false === $ignoreDate && null !== $datetime && makeDate($datetime)->getTimestamp() > $senderDate) { $this->logger->warning( - message: "{action}: Not processing '{item.title}' for '{client}: {user}@{backend}'. Event date is older than backend local item date.", + message: "{action}: Not processing '#{item.id}: {item.title}' for '{client}: {user}@{backend}'. Event date is older than backend local item date.", context: [ ...$logContext, 'compare' => ['remote' => makeDate($datetime), 'sender' => makeDate($senderDate),], @@ -186,7 +187,7 @@ class Progress if (array_key_exists($logContext['remote']['id'], $sessions)) { $this->logger->notice( - message: "{action}: Not processing '{item.title}' for '{client}: {user}@{backend}'. The item is playing right now.", + message: "{action}: Not processing '#{item.id}: {item.title}' for '{client}: {user}@{backend}'. The item is playing right now.", context: $logContext, ); @@ -199,7 +200,7 @@ class Progress if (false === $ignoreDate && makeDate($remoteItem->updated)->getTimestamp() > $senderDate) { $this->logger->info( - message: "{action}: Not processing '{item.title}' for '{client}: {user}@{backend}'. Event date is older than backend remote item date.", + message: "{action}: Not processing '#{item.id}: {item.title}' for '{client}: {user}@{backend}'. Event date is older than backend remote item date.", context: [ ...$logContext, 'compare' => [ @@ -212,16 +213,19 @@ class Progress } if ($remoteItem->isWatched()) { - $this->logger->info( - message: "{action}: Not processing '{item.title}' for '{client}: {user}@{backend}'. The backend says the item is marked as watched.", - context: $logContext, - ); - continue; + $allowUpdate = (int)Config::get('progress.threshold', 0); + if (false === ($allowUpdate >= 300 && time() > ($entity->updated + $allowUpdate))) { + $this->logger->info( + message: "{action}: Not processing '#{item.id}: {item.title}' for '{client}: {user}@{backend}'. The backend says the item is marked as watched.", + context: $logContext, + ); + continue; + } } } catch (\App\Libs\Exceptions\RuntimeException|RuntimeException|InvalidArgumentException $e) { $this->logger->error( ...lw( - message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {user}@{backend}' get {item.type} '{item.title}' status. '{error.message}' at '{error.file}:{error.line}'.", + message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {user}@{backend}' get {item.type} '#{item.id}: {item.title}' status. '{error.message}' at '{error.file}:{error.line}'.", context: [ 'error' => [ 'kind' => $e::class, @@ -255,7 +259,7 @@ class Progress $logContext['remote']['url'] = (string)$url; $this->logger->debug( - message: "{action}: Updating '{client}: {user}@{backend}' {item.type} '{item.title}' watch progress to '{progress}'.", + message: "{action}: Updating '{client}: {user}@{backend}' {item.type} '#{item.id}: {item.title}' watch progress to '{progress}'.", context: [ ...$logContext, 'progress' => $entity->hasPlayProgress() ? formatDuration($entity->getPlayProgress()) : '0:0:0', @@ -287,7 +291,7 @@ class Progress } catch (Throwable $e) { $this->logger->error( ...lw( - message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {user}@{backend}' change {item.type} '{item.title}' watch progress. '{error.message}' at '{error.file}:{error.line}'.", + message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {user}@{backend}' change {item.type} '#{item.id}: {item.title}' watch progress. '{error.message}' at '{error.file}:{error.line}'.", context: [ 'error' => [ 'kind' => $e::class, diff --git a/src/Backends/Plex/Action/Progress.php b/src/Backends/Plex/Action/Progress.php index 2fc7dfe1..2b53ab27 100644 --- a/src/Backends/Plex/Action/Progress.php +++ b/src/Backends/Plex/Action/Progress.php @@ -9,6 +9,7 @@ use App\Backends\Common\Context; use App\Backends\Common\GuidInterface as iGuid; use App\Backends\Common\Response; use App\Backends\Plex\PlexActionTrait; +use App\Libs\Config; use App\Libs\Container; use App\Libs\Entity\StateInterface as iState; use App\Libs\Enums\Http\Method; @@ -127,7 +128,7 @@ class Progress if ($context->backendName === $entity->via) { $this->logger->info( - message: "{action}: Not processing '{item.title}' for '{client}: {user}@{backend}'. Event originated from this backend.", + message: "{action}: Not processing '#{item.id}: {item.title}' for '{client}: {user}@{backend}'. Event originated from this backend.", context: $logContext, ); continue; @@ -135,7 +136,7 @@ class Progress if (null === ag($metadata, iState::COLUMN_ID, null)) { $this->logger->warning( - message: "{action}: Not processing '{item.title}' for '{client}: {user}@{backend}'. No metadata was found.", + message: "{action}: Not processing '#{item.id}: {item.title}' for '{client}: {user}@{backend}'. No metadata was found.", context: $logContext, ); continue; @@ -144,7 +145,7 @@ class Progress $senderDate = ag($entity->getExtra($entity->via), iState::COLUMN_EXTRA_DATE); if (null === $senderDate) { $this->logger->warning( - message: "{action}: Not processing '{item.title}' for '{client}: {user}@{backend}'. The event originator did not set a date.", + message: "{action}: Not processing '#{item.id}: {item.title}' for '{client}: {user}@{backend}'. The event originator did not set a date.", context: $logContext, ); @@ -157,7 +158,7 @@ class Progress if (false === $ignoreDate && null !== $datetime && makeDate($datetime)->getTimestamp() > $senderDate) { $this->logger->warning( - message: "{action}: Not processing '{item.title}' for '{client}: {user}@{backend}'. Event date is older than backend local item date.", + message: "{action}: Not processing '#{item.id}: {item.title}' for '{client}: {user}@{backend}'. Event date is older than backend local item date.", context: [ ...$logContext, 'compare' => ['remote' => makeDate($datetime), 'sender' => makeDate($senderDate),], @@ -170,7 +171,7 @@ class Progress if (array_key_exists($logContext['remote']['id'], $sessions)) { $this->logger->notice( - message: "{action}: Not processing '{item.title}' for '{client}: {user}@{backend}'. The item is playing right now.", + message: "{action}: Not processing '#{item.id}: {item.title}' for '{client}: {user}@{backend}'. The item is playing right now.", context: $logContext, ); continue; @@ -187,7 +188,7 @@ class Progress if (false === $ignoreDate && makeDate($remoteItem->updated)->getTimestamp() > $senderDate) { $this->logger->info( - message: "{action}: Not processing '{item.title}' for '{client}: {user}@{backend}'. Event date is older than backend remote item date.", + message: "{action}: Not processing '#{item.id}: {item.title}' for '{client}: {user}@{backend}'. Event date is older than backend remote item date.", context: [ ...$logContext, 'compare' => [ @@ -200,16 +201,19 @@ class Progress } if ($remoteItem->isWatched()) { - $this->logger->info( - message: "{action}: Not processing '{item.title}' for '{client}: {user}@{backend}'. The backend says the item is marked as watched.", - context: $logContext, - ); - continue; + $allowUpdate = (int)Config::get('progress.threshold', 0); + if (false === ($allowUpdate >= 300 && time() > ($entity->updated + $allowUpdate))) { + $this->logger->info( + message: "{action}: Not processing '#{item.id}: {item.title}' for '{client}: {user}@{backend}'. The backend says the item is marked as watched.", + context: $logContext, + ); + continue; + } } } catch (\App\Libs\Exceptions\RuntimeException|RuntimeException|InvalidArgumentException $e) { $this->logger->error( ...lw( - message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {user}@{backend}' get {item.type} '{item.title}' status. '{error.message}' at '{error.file}:{error.line}'.", + message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {user}@{backend}' get {item.type} '#{item.id}: {item.title}' status. '{error.message}' at '{error.file}:{error.line}'.", context: [ 'error' => [ 'kind' => $e::class, @@ -248,7 +252,7 @@ class Progress $logContext['remote']['url'] = (string)$url; $this->logger->debug( - message: "{action}: Updating '{client}: {user}@{backend}' {item.type} '{item.title}' watch progress to '{progress}'.", + message: "{action}: Updating '{client}: {user}@{backend}' {item.type} '#{item.id}: {item.title}' watch progress to '{progress}'.", context: [ ...$logContext, 'progress' => formatDuration($entity->getPlayProgress()), @@ -270,7 +274,7 @@ class Progress } catch (Throwable $e) { $this->logger->error( ...lw( - message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {user}@{backend}' change {item.type} '{item.title}' watch progress. '{error.message}' at '{error.file}:{error.line}'.", + message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {user}@{backend}' change {item.type} '#{item.id}: {item.title}' watch progress. '{error.message}' at '{error.file}:{error.line}'.", context: [ 'error' => [ 'kind' => $e::class, diff --git a/src/Libs/Entity/StateEntity.php b/src/Libs/Entity/StateEntity.php index 65b25b76..55cc8ab3 100644 --- a/src/Libs/Entity/StateEntity.php +++ b/src/Libs/Entity/StateEntity.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Libs\Entity; +use App\Libs\Config; use App\Libs\Entity\StateInterface as iState; use App\Libs\Guid; use Psr\Log\LoggerAwareTrait; @@ -566,12 +567,14 @@ final class StateEntity implements iState */ public function hasPlayProgress(): bool { - if ($this->isWatched()) { + $allowUpdate = (int)Config::get('progress.threshold', 0); + + if ($this->isWatched() && $allowUpdate < 1) { return false; } foreach ($this->getMetadata() as $metadata) { - if (0 !== (int)ag($metadata, iState::COLUMN_WATCHED, 0)) { + if (0 !== (int)ag($metadata, iState::COLUMN_WATCHED, 0) && $allowUpdate < 1) { continue; } if ((int)ag($metadata, iState::COLUMN_META_DATA_PROGRESS, 0) > 1000) { @@ -587,14 +590,15 @@ final class StateEntity implements iState */ public function getPlayProgress(): int { - if ($this->isWatched()) { + $allowUpdate = (int)Config::get('progress.threshold', 0); + if ($this->isWatched() && $allowUpdate < 1) { return 0; } $compare = []; foreach ($this->getMetadata() as $backend => $metadata) { - if (0 !== (int)ag($metadata, iState::COLUMN_WATCHED, 0)) { + if (0 !== (int)ag($metadata, iState::COLUMN_WATCHED, 0) && $allowUpdate < 1) { continue; } if ((int)ag($metadata, iState::COLUMN_META_DATA_PROGRESS, 0) < 1000) { diff --git a/src/Libs/Mappers/Import/DirectMapper.php b/src/Libs/Mappers/Import/DirectMapper.php index c636fc18..b560b4fe 100644 --- a/src/Libs/Mappers/Import/DirectMapper.php +++ b/src/Libs/Mappers/Import/DirectMapper.php @@ -333,7 +333,12 @@ class DirectMapper implements ImportInterface $this->removePointers($local)->addPointers($local, $local->id); $changes = $local->diff(fields: $keys); - $progress = !$entity->isWatched() && $playChanged && $entity->hasPlayProgress(); + $allowUpdate = (int)Config::get('progress.threshold', 0); + + $progress = $playChanged && $entity->hasPlayProgress(); + if ($entity->isWatched() && $allowUpdate < 180) { + $progress = false; + } if (count($changes) >= 1) { $_keys = array_merge($keys, [iState::COLUMN_EXTRA]); @@ -356,7 +361,7 @@ class DirectMapper implements ImportInterface if (false === $inDryRunMode) { $this->db->update($local); - if (true === $entity->hasPlayProgress() && !$entity->isWatched()) { + if (true === $progress) { $itemId = r('{type}://{id}:{tainted}@{backend}', [ 'type' => $entity->type, 'backend' => $entity->via, @@ -437,13 +442,16 @@ class DirectMapper implements ImportInterface } if ($this->inTraceMode()) { - $this->logger->info("{mapper}: [T] Ignoring '{user}@{backend}' - '#{id}: {title}'. No metadata changes detected.", [ - 'user' => $this->userContext?->name ?? 'main', - 'mapper' => afterLast(self::class, '\\'), - 'id' => $local->id ?? 'New', - 'backend' => $entity->via, - 'title' => $local->getName(), - ]); + $this->logger->info( + "{mapper}: [T] Ignoring '{user}@{backend}' - '#{id}: {title}'. No metadata changes detected.", + [ + 'user' => $this->userContext?->name ?? 'main', + 'mapper' => afterLast(self::class, '\\'), + 'id' => $local->id ?? 'New', + 'backend' => $entity->via, + 'title' => $local->getName(), + ] + ); } return $this; @@ -544,8 +552,13 @@ class DirectMapper implements ImportInterface $this->removePointers($cloned)->addPointers($local, $local->id); + $allowUpdate = (int)Config::get('progress.threshold', 0); + $changes = $local->diff(fields: $keys); - $progress = !$entity->isWatched() && $playChanged && $entity->hasPlayProgress(); + $progress = $playChanged && $entity->hasPlayProgress(); + if ($entity->isWatched() && $allowUpdate < 180) { + $progress = false; + } if (count($changes) >= 1) { $_keys = array_merge($keys, [iState::COLUMN_EXTRA]); @@ -568,8 +581,7 @@ class DirectMapper implements ImportInterface if (false === $inDryRunMode) { $this->db->update($local); - - if (true === $entity->hasPlayProgress() && !$entity->isWatched()) { + if ($progress) { $itemId = r('{type}://{id}:{tainted}@{backend}', [ 'type' => $entity->type, 'backend' => $entity->via, diff --git a/src/Libs/Mappers/Import/MemoryMapper.php b/src/Libs/Mappers/Import/MemoryMapper.php index 0163d3c9..ec0f79f0 100644 --- a/src/Libs/Mappers/Import/MemoryMapper.php +++ b/src/Libs/Mappers/Import/MemoryMapper.php @@ -314,13 +314,16 @@ class MemoryMapper implements ImportInterface } if (true === $this->inTraceMode()) { - $this->logger->info("{mapper}: [T] Ignoring '{user}@{backend}' - '#{id}: {title}'. No metadata changes detected.", [ - 'user' => $this->userContext?->name ?? 'main', - 'mapper' => afterLast(self::class, '\\'), - 'id' => $cloned->id ?? 'New', - 'backend' => $entity->via, - 'title' => $cloned->getName(), - ]); + $this->logger->info( + "{mapper}: [T] Ignoring '{user}@{backend}' - '#{id}: {title}'. No metadata changes detected.", + [ + 'user' => $this->userContext?->name ?? 'main', + 'mapper' => afterLast(self::class, '\\'), + 'id' => $cloned->id ?? 'New', + 'backend' => $entity->via, + 'title' => $cloned->getName(), + ] + ); } return $this; @@ -376,8 +379,13 @@ class MemoryMapper implements ImportInterface $this->removePointers($cloned)->addPointers($this->objects[$pointer], $pointer); + $allowUpdate = (int)Config::get('progress.threshold', 0); + $changes = $this->objects[$pointer]->diff(fields: $keys); - $progress = !$entity->isWatched() && $playChanged && $entity->hasPlayProgress(); + $progress = $playChanged && $entity->hasPlayProgress(); + if ($entity->isWatched() && $allowUpdate < 180) { + $progress = false; + } if (count($changes) >= 1) { $_keys = array_merge($keys, [iState::COLUMN_EXTRA]); @@ -400,7 +408,7 @@ class MemoryMapper implements ImportInterface ] ); - if (true === $entity->hasPlayProgress() && !$entity->isWatched()) { + if (true === $progress) { $itemId = r('{type}://{id}:{tainted}@{backend}', [ 'type' => $entity->type, 'backend' => $entity->via, diff --git a/src/Listeners/ProcessProgressEvent.php b/src/Listeners/ProcessProgressEvent.php index eb79f8b5..4971bc17 100644 --- a/src/Listeners/ProcessProgressEvent.php +++ b/src/Listeners/ProcessProgressEvent.php @@ -71,16 +71,24 @@ final readonly class ProcessProgressEvent } if ($item->isWatched()) { - $writer(Level::Info, "'{user}' item - '{id}: {title}' is marked as watched. Not updating watch process.", [ - 'id' => $item->id, - 'title' => $item->getName(), - 'user' => $userContext->name, - ]); - return $e; + $allowUpdate = (int)Config::get('progress.threshold', 0); + if (false === ($allowUpdate >= 300 && time() > ($item->updated + $allowUpdate))) { + $writer( + level: Level::Info, + message: "'{user}' item - '#{id}: {title}' is marked as watched. Not updating watch progress.", + context: [ + 'id' => $item->id, + 'title' => $item->getName(), + 'user' => $userContext->name, + ] + ); + return $e; + } } if (false === $item->hasPlayProgress()) { - $writer(Level::Info, "'{user}' item '{title}' has no watch progress to export.", [ + $writer(Level::Info, "'{user}' item '#{id}: {title}' has no watch progress to export.", [ + 'id' => $item->id, 'title' => $item->title, 'user' => $userContext->name, ]); @@ -179,10 +187,12 @@ final readonly class ProcessProgressEvent } catch (Throwable $e) { $writer( Level::Error, - "Exception '{error.kind}' was thrown unhandled during '{user}@{backend}' request to sync progress. '{error.message}' at '{error.file}:{error.line}'.", + "Exception '{error.kind}' was thrown unhandled during '{user}@{backend}' request to sync '#{id}: {title}' progress. '{error.message}' at '{error.file}:{error.line}'.", [ - 'user' => $userContext->name, + 'id' => $item->id, 'backend' => $name, + 'title' => $item->getName(), + 'user' => $userContext->name, 'error' => [ 'kind' => $e::class, 'line' => $e->getLine(), @@ -210,10 +220,10 @@ final readonly class ProcessProgressEvent $progress = formatDuration($item->getPlayProgress()); - $writer(Level::Notice, "Processing '{user}' - '{id}' - '{via}: {title}' watch progress '{progress}' event.", [ - 'user' => $userContext->name, + $writer(Level::Notice, "Processing '{user}@{backend}' - '#{id}: {title}' watch progress '{progress}' event.", [ 'id' => $item->id, - 'via' => $item->via, + 'backend' => $item->via, + 'user' => $userContext->name, 'title' => $item->getName(), 'progress' => $progress, ]); @@ -223,8 +233,9 @@ final readonly class ProcessProgressEvent $context['user'] = $userContext->name; try { - if (ag($options, 'trace')) { - $writer(Level::Debug, "Processing '{user}@{backend}: {item.title}' response.", [ + if (true === (bool)ag($options, 'trace')) { + $writer(Level::Debug, "Processing '{user}@{backend}' - '#{id}: {item.title}' response.", [ + 'id' => $item->id, 'url' => ag($context, 'remote.url', '??'), 'status_code' => $response->getStatusCode(), 'headers' => $response->getHeaders(false), @@ -233,11 +244,12 @@ final readonly class ProcessProgressEvent ]); } - if (!in_array($response->getStatusCode(), [Status::OK->value, Status::NO_CONTENT->value])) { + if (false === in_array(Status::tryFrom($response->getStatusCode()), [Status::OK, Status::NO_CONTENT])) { $writer( - Level::Error, - "Request to change '{user}@{backend}: {item.title}' watch progress returned with unexpected '{status_code}' status code.", - [ + level: Level::Error, + message: "Request to change '{user}@{backend}' - '#{id}: {item.title}' watch progress returned with unexpected '{status_code}' status code.", + context: [ + 'id' => $item->id, 'status_code' => $response->getStatusCode(), ...$context ] @@ -245,16 +257,22 @@ final readonly class ProcessProgressEvent continue; } - $writer(Level::Notice, "Updated '{user}@{backend}: {item.title}' watch progress to '{progress}'.", [ - ...$context, - 'progress' => $progress, - 'status_code' => $response->getStatusCode(), - ]); + $writer( + level: Level::Notice, + message: "Updated '{user}@{backend}' '#{id}: {item.title}' watch progress to '{progress}'.", + context: [ + ...$context, + 'id' => $item->id, + 'progress' => $progress, + 'status_code' => $response->getStatusCode(), + ] + ); } catch (Throwable $e) { $writer( - Level::Error, - "Exception '{error.kind}' was thrown unhandled during '{user}@{backend}' request to change watch progress of {item.type} '{item.title}'. '{error.message}' at '{error.file}:{error.line}'.", - [ + level: Level::Error, + message: "Exception '{error.kind}' was thrown unhandled during '{user}@{backend}' request to change watch progress of {item.type} '#{id}: {item.title}'. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'id' => $item->id, 'error' => [ 'kind' => $e::class, 'line' => $e->getLine(),