experimental support for syncing watch progress for played items. #617

This commit is contained in:
ArabCoders
2025-02-19 13:06:36 +03:00
parent 2f287ae9fd
commit 48e5e4d98d
11 changed files with 207 additions and 112 deletions

21
NEWS.md
View File

@@ -1,5 +1,26 @@
# Old Updates # 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 ### 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, We are excited to share that multi-user sync is now fully supported! Our first goal was to make sure the feature worked,

View File

@@ -9,6 +9,19 @@ out of the box, this tool support `Jellyfin`, `Plex` and `Emby` media servers.
## Updates ## 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 ### 2025-02-11
We recently have added support to generate accesstoken for external `Plex` users, i.e. `not home users`. so the 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 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. 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. Refer to [NEWS](NEWS.md) for old updates.

View File

@@ -17,6 +17,8 @@ use Monolog\Level;
return (function () { return (function () {
$inContainer = inContainer(); $inContainer = inContainer();
$progressTimeCheck = fn(int $v, int $d): int => 0 === $v || $v >= 180 ? $v : $d;
$config = [ $config = [
'name' => 'WatchState', 'name' => 'WatchState',
'version' => '$(version_via_ci)', 'version' => '$(version_via_ci)',
@@ -77,6 +79,10 @@ return (function () {
'sync' => [ 'sync' => [
'progress' => (bool)env('WS_SYNC_PROGRESS', true), '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'] = [ $config['guid'] = [

View File

@@ -181,6 +181,28 @@ return (function () {
'description' => 'The path to save the profiler data.', 'description' => 'The path to save the profiler data.',
'type' => 'string', '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 { $validateCronExpression = function (string $value): string {

View File

@@ -9,6 +9,7 @@ use App\Backends\Common\Context;
use App\Backends\Common\GuidInterface as iGuid; use App\Backends\Common\GuidInterface as iGuid;
use App\Backends\Common\Response; use App\Backends\Common\Response;
use App\Backends\Emby\EmbyActionTrait; use App\Backends\Emby\EmbyActionTrait;
use App\Libs\Config;
use App\Libs\Container; use App\Libs\Container;
use App\Libs\Entity\StateInterface as iState; use App\Libs\Entity\StateInterface as iState;
use App\Libs\Enums\Http\Method; use App\Libs\Enums\Http\Method;
@@ -117,7 +118,7 @@ class Progress
if ($context->backendName === $entity->via) { if ($context->backendName === $entity->via) {
$this->logger->info( $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, context: $logContext,
); );
continue; continue;
@@ -125,7 +126,7 @@ class Progress
if (null === ag($metadata, iState::COLUMN_ID, null)) { if (null === ag($metadata, iState::COLUMN_ID, null)) {
$this->logger->warning( $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, context: $logContext,
); );
continue; continue;
@@ -134,7 +135,7 @@ class Progress
$senderDate = ag($entity->getExtra($entity->via), iState::COLUMN_EXTRA_DATE); $senderDate = ag($entity->getExtra($entity->via), iState::COLUMN_EXTRA_DATE);
if (null === $senderDate) { if (null === $senderDate) {
$this->logger->warning( $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, context: $logContext,
); );
continue; continue;
@@ -145,7 +146,7 @@ class Progress
$datetime = ag($entity->getExtra($context->backendName), iState::COLUMN_EXTRA_DATE, null); $datetime = ag($entity->getExtra($context->backendName), iState::COLUMN_EXTRA_DATE, null);
if (false === $ignoreDate && null !== $datetime && makeDate($datetime)->getTimestamp() > $senderDate) { if (false === $ignoreDate && null !== $datetime && makeDate($datetime)->getTimestamp() > $senderDate) {
$this->logger->warning( $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: [ context: [
...$logContext, ...$logContext,
'compare' => [ 'compare' => [
@@ -161,7 +162,7 @@ class Progress
if (array_key_exists($logContext['remote']['id'], $sessions)) { if (array_key_exists($logContext['remote']['id'], $sessions)) {
$this->logger->notice( $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, context: $logContext,
); );
continue; continue;
@@ -177,7 +178,7 @@ class Progress
if (false === $ignoreDate && makeDate($remoteItem->updated)->getTimestamp() > $senderDate) { if (false === $ignoreDate && makeDate($remoteItem->updated)->getTimestamp() > $senderDate) {
$this->logger->info( $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: [ context: [
...$logContext, ...$logContext,
'compare' => [ 'compare' => [
@@ -190,16 +191,19 @@ class Progress
} }
if ($remoteItem->isWatched()) { if ($remoteItem->isWatched()) {
$this->logger->info( $allowUpdate = (int)Config::get('progress.threshold', 0);
message: "{action}: Not processing '{item.title}' for '{client}: {user}@{backend}'. The backend says the item is marked as watched.", if (false === ($allowUpdate >= 300 && time() > ($entity->updated + $allowUpdate))) {
context: $logContext, $this->logger->info(
); message: "{action}: Not processing '#{item.id}: {item.title}' for '{client}: {user}@{backend}'. The backend says the item is marked as watched.",
continue; context: $logContext,
);
continue;
}
} }
} catch (\App\Libs\Exceptions\RuntimeException|RuntimeException|InvalidArgumentException $e) { } catch (\App\Libs\Exceptions\RuntimeException|RuntimeException|InvalidArgumentException $e) {
$this->logger->error( $this->logger->error(
...lw( ...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: [ context: [
...$logContext, ...$logContext,
'error' => [ 'error' => [

View File

@@ -9,6 +9,7 @@ use App\Backends\Common\Context;
use App\Backends\Common\GuidInterface as iGuid; use App\Backends\Common\GuidInterface as iGuid;
use App\Backends\Common\Response; use App\Backends\Common\Response;
use App\Backends\Jellyfin\JellyfinActionTrait; use App\Backends\Jellyfin\JellyfinActionTrait;
use App\Libs\Config;
use App\Libs\Container; use App\Libs\Container;
use App\Libs\Entity\StateInterface as iState; use App\Libs\Entity\StateInterface as iState;
use App\Libs\Enums\Http\Method; use App\Libs\Enums\Http\Method;
@@ -144,7 +145,7 @@ class Progress
if ($context->backendName === $entity->via) { if ($context->backendName === $entity->via) {
$this->logger->info( $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, context: $logContext,
); );
continue; continue;
@@ -152,7 +153,7 @@ class Progress
if (null === ag($metadata, iState::COLUMN_ID, null)) { if (null === ag($metadata, iState::COLUMN_ID, null)) {
$this->logger->warning( $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, context: $logContext,
); );
continue; continue;
@@ -161,7 +162,7 @@ class Progress
$senderDate = ag($entity->getExtra($entity->via), iState::COLUMN_EXTRA_DATE); $senderDate = ag($entity->getExtra($entity->via), iState::COLUMN_EXTRA_DATE);
if (null === $senderDate) { if (null === $senderDate) {
$this->logger->warning( $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, context: $logContext,
); );
@@ -173,7 +174,7 @@ class Progress
$datetime = ag($entity->getExtra($context->backendName), iState::COLUMN_EXTRA_DATE, null); $datetime = ag($entity->getExtra($context->backendName), iState::COLUMN_EXTRA_DATE, null);
if (false === $ignoreDate && null !== $datetime && makeDate($datetime)->getTimestamp() > $senderDate) { if (false === $ignoreDate && null !== $datetime && makeDate($datetime)->getTimestamp() > $senderDate) {
$this->logger->warning( $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: [ context: [
...$logContext, ...$logContext,
'compare' => ['remote' => makeDate($datetime), 'sender' => makeDate($senderDate),], 'compare' => ['remote' => makeDate($datetime), 'sender' => makeDate($senderDate),],
@@ -186,7 +187,7 @@ class Progress
if (array_key_exists($logContext['remote']['id'], $sessions)) { if (array_key_exists($logContext['remote']['id'], $sessions)) {
$this->logger->notice( $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, context: $logContext,
); );
@@ -199,7 +200,7 @@ class Progress
if (false === $ignoreDate && makeDate($remoteItem->updated)->getTimestamp() > $senderDate) { if (false === $ignoreDate && makeDate($remoteItem->updated)->getTimestamp() > $senderDate) {
$this->logger->info( $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: [ context: [
...$logContext, ...$logContext,
'compare' => [ 'compare' => [
@@ -212,16 +213,19 @@ class Progress
} }
if ($remoteItem->isWatched()) { if ($remoteItem->isWatched()) {
$this->logger->info( $allowUpdate = (int)Config::get('progress.threshold', 0);
message: "{action}: Not processing '{item.title}' for '{client}: {user}@{backend}'. The backend says the item is marked as watched.", if (false === ($allowUpdate >= 300 && time() > ($entity->updated + $allowUpdate))) {
context: $logContext, $this->logger->info(
); message: "{action}: Not processing '#{item.id}: {item.title}' for '{client}: {user}@{backend}'. The backend says the item is marked as watched.",
continue; context: $logContext,
);
continue;
}
} }
} catch (\App\Libs\Exceptions\RuntimeException|RuntimeException|InvalidArgumentException $e) { } catch (\App\Libs\Exceptions\RuntimeException|RuntimeException|InvalidArgumentException $e) {
$this->logger->error( $this->logger->error(
...lw( ...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: [ context: [
'error' => [ 'error' => [
'kind' => $e::class, 'kind' => $e::class,
@@ -255,7 +259,7 @@ class Progress
$logContext['remote']['url'] = (string)$url; $logContext['remote']['url'] = (string)$url;
$this->logger->debug( $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: [ context: [
...$logContext, ...$logContext,
'progress' => $entity->hasPlayProgress() ? formatDuration($entity->getPlayProgress()) : '0:0:0', 'progress' => $entity->hasPlayProgress() ? formatDuration($entity->getPlayProgress()) : '0:0:0',
@@ -287,7 +291,7 @@ class Progress
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw( ...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: [ context: [
'error' => [ 'error' => [
'kind' => $e::class, 'kind' => $e::class,

View File

@@ -9,6 +9,7 @@ use App\Backends\Common\Context;
use App\Backends\Common\GuidInterface as iGuid; use App\Backends\Common\GuidInterface as iGuid;
use App\Backends\Common\Response; use App\Backends\Common\Response;
use App\Backends\Plex\PlexActionTrait; use App\Backends\Plex\PlexActionTrait;
use App\Libs\Config;
use App\Libs\Container; use App\Libs\Container;
use App\Libs\Entity\StateInterface as iState; use App\Libs\Entity\StateInterface as iState;
use App\Libs\Enums\Http\Method; use App\Libs\Enums\Http\Method;
@@ -127,7 +128,7 @@ class Progress
if ($context->backendName === $entity->via) { if ($context->backendName === $entity->via) {
$this->logger->info( $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, context: $logContext,
); );
continue; continue;
@@ -135,7 +136,7 @@ class Progress
if (null === ag($metadata, iState::COLUMN_ID, null)) { if (null === ag($metadata, iState::COLUMN_ID, null)) {
$this->logger->warning( $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, context: $logContext,
); );
continue; continue;
@@ -144,7 +145,7 @@ class Progress
$senderDate = ag($entity->getExtra($entity->via), iState::COLUMN_EXTRA_DATE); $senderDate = ag($entity->getExtra($entity->via), iState::COLUMN_EXTRA_DATE);
if (null === $senderDate) { if (null === $senderDate) {
$this->logger->warning( $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, context: $logContext,
); );
@@ -157,7 +158,7 @@ class Progress
if (false === $ignoreDate && null !== $datetime && makeDate($datetime)->getTimestamp() > $senderDate) { if (false === $ignoreDate && null !== $datetime && makeDate($datetime)->getTimestamp() > $senderDate) {
$this->logger->warning( $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: [ context: [
...$logContext, ...$logContext,
'compare' => ['remote' => makeDate($datetime), 'sender' => makeDate($senderDate),], 'compare' => ['remote' => makeDate($datetime), 'sender' => makeDate($senderDate),],
@@ -170,7 +171,7 @@ class Progress
if (array_key_exists($logContext['remote']['id'], $sessions)) { if (array_key_exists($logContext['remote']['id'], $sessions)) {
$this->logger->notice( $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, context: $logContext,
); );
continue; continue;
@@ -187,7 +188,7 @@ class Progress
if (false === $ignoreDate && makeDate($remoteItem->updated)->getTimestamp() > $senderDate) { if (false === $ignoreDate && makeDate($remoteItem->updated)->getTimestamp() > $senderDate) {
$this->logger->info( $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: [ context: [
...$logContext, ...$logContext,
'compare' => [ 'compare' => [
@@ -200,16 +201,19 @@ class Progress
} }
if ($remoteItem->isWatched()) { if ($remoteItem->isWatched()) {
$this->logger->info( $allowUpdate = (int)Config::get('progress.threshold', 0);
message: "{action}: Not processing '{item.title}' for '{client}: {user}@{backend}'. The backend says the item is marked as watched.", if (false === ($allowUpdate >= 300 && time() > ($entity->updated + $allowUpdate))) {
context: $logContext, $this->logger->info(
); message: "{action}: Not processing '#{item.id}: {item.title}' for '{client}: {user}@{backend}'. The backend says the item is marked as watched.",
continue; context: $logContext,
);
continue;
}
} }
} catch (\App\Libs\Exceptions\RuntimeException|RuntimeException|InvalidArgumentException $e) { } catch (\App\Libs\Exceptions\RuntimeException|RuntimeException|InvalidArgumentException $e) {
$this->logger->error( $this->logger->error(
...lw( ...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: [ context: [
'error' => [ 'error' => [
'kind' => $e::class, 'kind' => $e::class,
@@ -248,7 +252,7 @@ class Progress
$logContext['remote']['url'] = (string)$url; $logContext['remote']['url'] = (string)$url;
$this->logger->debug( $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: [ context: [
...$logContext, ...$logContext,
'progress' => formatDuration($entity->getPlayProgress()), 'progress' => formatDuration($entity->getPlayProgress()),
@@ -270,7 +274,7 @@ class Progress
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw( ...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: [ context: [
'error' => [ 'error' => [
'kind' => $e::class, 'kind' => $e::class,

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Libs\Entity; namespace App\Libs\Entity;
use App\Libs\Config;
use App\Libs\Entity\StateInterface as iState; use App\Libs\Entity\StateInterface as iState;
use App\Libs\Guid; use App\Libs\Guid;
use Psr\Log\LoggerAwareTrait; use Psr\Log\LoggerAwareTrait;
@@ -566,12 +567,14 @@ final class StateEntity implements iState
*/ */
public function hasPlayProgress(): bool public function hasPlayProgress(): bool
{ {
if ($this->isWatched()) { $allowUpdate = (int)Config::get('progress.threshold', 0);
if ($this->isWatched() && $allowUpdate < 1) {
return false; return false;
} }
foreach ($this->getMetadata() as $metadata) { 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; continue;
} }
if ((int)ag($metadata, iState::COLUMN_META_DATA_PROGRESS, 0) > 1000) { if ((int)ag($metadata, iState::COLUMN_META_DATA_PROGRESS, 0) > 1000) {
@@ -587,14 +590,15 @@ final class StateEntity implements iState
*/ */
public function getPlayProgress(): int public function getPlayProgress(): int
{ {
if ($this->isWatched()) { $allowUpdate = (int)Config::get('progress.threshold', 0);
if ($this->isWatched() && $allowUpdate < 1) {
return 0; return 0;
} }
$compare = []; $compare = [];
foreach ($this->getMetadata() as $backend => $metadata) { 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; continue;
} }
if ((int)ag($metadata, iState::COLUMN_META_DATA_PROGRESS, 0) < 1000) { if ((int)ag($metadata, iState::COLUMN_META_DATA_PROGRESS, 0) < 1000) {

View File

@@ -333,7 +333,12 @@ class DirectMapper implements ImportInterface
$this->removePointers($local)->addPointers($local, $local->id); $this->removePointers($local)->addPointers($local, $local->id);
$changes = $local->diff(fields: $keys); $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) { if (count($changes) >= 1) {
$_keys = array_merge($keys, [iState::COLUMN_EXTRA]); $_keys = array_merge($keys, [iState::COLUMN_EXTRA]);
@@ -356,7 +361,7 @@ class DirectMapper implements ImportInterface
if (false === $inDryRunMode) { if (false === $inDryRunMode) {
$this->db->update($local); $this->db->update($local);
if (true === $entity->hasPlayProgress() && !$entity->isWatched()) { if (true === $progress) {
$itemId = r('{type}://{id}:{tainted}@{backend}', [ $itemId = r('{type}://{id}:{tainted}@{backend}', [
'type' => $entity->type, 'type' => $entity->type,
'backend' => $entity->via, 'backend' => $entity->via,
@@ -437,13 +442,16 @@ class DirectMapper implements ImportInterface
} }
if ($this->inTraceMode()) { if ($this->inTraceMode()) {
$this->logger->info("{mapper}: [T] Ignoring '{user}@{backend}' - '#{id}: {title}'. No metadata changes detected.", [ $this->logger->info(
'user' => $this->userContext?->name ?? 'main', "{mapper}: [T] Ignoring '{user}@{backend}' - '#{id}: {title}'. No metadata changes detected.",
'mapper' => afterLast(self::class, '\\'), [
'id' => $local->id ?? 'New', 'user' => $this->userContext?->name ?? 'main',
'backend' => $entity->via, 'mapper' => afterLast(self::class, '\\'),
'title' => $local->getName(), 'id' => $local->id ?? 'New',
]); 'backend' => $entity->via,
'title' => $local->getName(),
]
);
} }
return $this; return $this;
@@ -544,8 +552,13 @@ class DirectMapper implements ImportInterface
$this->removePointers($cloned)->addPointers($local, $local->id); $this->removePointers($cloned)->addPointers($local, $local->id);
$allowUpdate = (int)Config::get('progress.threshold', 0);
$changes = $local->diff(fields: $keys); $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) { if (count($changes) >= 1) {
$_keys = array_merge($keys, [iState::COLUMN_EXTRA]); $_keys = array_merge($keys, [iState::COLUMN_EXTRA]);
@@ -568,8 +581,7 @@ class DirectMapper implements ImportInterface
if (false === $inDryRunMode) { if (false === $inDryRunMode) {
$this->db->update($local); $this->db->update($local);
if ($progress) {
if (true === $entity->hasPlayProgress() && !$entity->isWatched()) {
$itemId = r('{type}://{id}:{tainted}@{backend}', [ $itemId = r('{type}://{id}:{tainted}@{backend}', [
'type' => $entity->type, 'type' => $entity->type,
'backend' => $entity->via, 'backend' => $entity->via,

View File

@@ -314,13 +314,16 @@ class MemoryMapper implements ImportInterface
} }
if (true === $this->inTraceMode()) { if (true === $this->inTraceMode()) {
$this->logger->info("{mapper}: [T] Ignoring '{user}@{backend}' - '#{id}: {title}'. No metadata changes detected.", [ $this->logger->info(
'user' => $this->userContext?->name ?? 'main', "{mapper}: [T] Ignoring '{user}@{backend}' - '#{id}: {title}'. No metadata changes detected.",
'mapper' => afterLast(self::class, '\\'), [
'id' => $cloned->id ?? 'New', 'user' => $this->userContext?->name ?? 'main',
'backend' => $entity->via, 'mapper' => afterLast(self::class, '\\'),
'title' => $cloned->getName(), 'id' => $cloned->id ?? 'New',
]); 'backend' => $entity->via,
'title' => $cloned->getName(),
]
);
} }
return $this; return $this;
@@ -376,8 +379,13 @@ class MemoryMapper implements ImportInterface
$this->removePointers($cloned)->addPointers($this->objects[$pointer], $pointer); $this->removePointers($cloned)->addPointers($this->objects[$pointer], $pointer);
$allowUpdate = (int)Config::get('progress.threshold', 0);
$changes = $this->objects[$pointer]->diff(fields: $keys); $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) { if (count($changes) >= 1) {
$_keys = array_merge($keys, [iState::COLUMN_EXTRA]); $_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}', [ $itemId = r('{type}://{id}:{tainted}@{backend}', [
'type' => $entity->type, 'type' => $entity->type,
'backend' => $entity->via, 'backend' => $entity->via,

View File

@@ -71,16 +71,24 @@ final readonly class ProcessProgressEvent
} }
if ($item->isWatched()) { if ($item->isWatched()) {
$writer(Level::Info, "'{user}' item - '{id}: {title}' is marked as watched. Not updating watch process.", [ $allowUpdate = (int)Config::get('progress.threshold', 0);
'id' => $item->id, if (false === ($allowUpdate >= 300 && time() > ($item->updated + $allowUpdate))) {
'title' => $item->getName(), $writer(
'user' => $userContext->name, level: Level::Info,
]); message: "'{user}' item - '#{id}: {title}' is marked as watched. Not updating watch progress.",
return $e; context: [
'id' => $item->id,
'title' => $item->getName(),
'user' => $userContext->name,
]
);
return $e;
}
} }
if (false === $item->hasPlayProgress()) { 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, 'title' => $item->title,
'user' => $userContext->name, 'user' => $userContext->name,
]); ]);
@@ -179,10 +187,12 @@ final readonly class ProcessProgressEvent
} catch (Throwable $e) { } catch (Throwable $e) {
$writer( $writer(
Level::Error, 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, 'backend' => $name,
'title' => $item->getName(),
'user' => $userContext->name,
'error' => [ 'error' => [
'kind' => $e::class, 'kind' => $e::class,
'line' => $e->getLine(), 'line' => $e->getLine(),
@@ -210,10 +220,10 @@ final readonly class ProcessProgressEvent
$progress = formatDuration($item->getPlayProgress()); $progress = formatDuration($item->getPlayProgress());
$writer(Level::Notice, "Processing '{user}' - '{id}' - '{via}: {title}' watch progress '{progress}' event.", [ $writer(Level::Notice, "Processing '{user}@{backend}' - '#{id}: {title}' watch progress '{progress}' event.", [
'user' => $userContext->name,
'id' => $item->id, 'id' => $item->id,
'via' => $item->via, 'backend' => $item->via,
'user' => $userContext->name,
'title' => $item->getName(), 'title' => $item->getName(),
'progress' => $progress, 'progress' => $progress,
]); ]);
@@ -223,8 +233,9 @@ final readonly class ProcessProgressEvent
$context['user'] = $userContext->name; $context['user'] = $userContext->name;
try { try {
if (ag($options, 'trace')) { if (true === (bool)ag($options, 'trace')) {
$writer(Level::Debug, "Processing '{user}@{backend}: {item.title}' response.", [ $writer(Level::Debug, "Processing '{user}@{backend}' - '#{id}: {item.title}' response.", [
'id' => $item->id,
'url' => ag($context, 'remote.url', '??'), 'url' => ag($context, 'remote.url', '??'),
'status_code' => $response->getStatusCode(), 'status_code' => $response->getStatusCode(),
'headers' => $response->getHeaders(false), '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( $writer(
Level::Error, level: Level::Error,
"Request to change '{user}@{backend}: {item.title}' watch progress returned with unexpected '{status_code}' status code.", 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(), 'status_code' => $response->getStatusCode(),
...$context ...$context
] ]
@@ -245,16 +257,22 @@ final readonly class ProcessProgressEvent
continue; continue;
} }
$writer(Level::Notice, "Updated '{user}@{backend}: {item.title}' watch progress to '{progress}'.", [ $writer(
...$context, level: Level::Notice,
'progress' => $progress, message: "Updated '{user}@{backend}' '#{id}: {item.title}' watch progress to '{progress}'.",
'status_code' => $response->getStatusCode(), context: [
]); ...$context,
'id' => $item->id,
'progress' => $progress,
'status_code' => $response->getStatusCode(),
]
);
} catch (Throwable $e) { } catch (Throwable $e) {
$writer( $writer(
Level::Error, level: 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}'.", 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' => [ 'error' => [
'kind' => $e::class, 'kind' => $e::class,
'line' => $e->getLine(), 'line' => $e->getLine(),