Skip to content

Commit

Permalink
Added initial support for multi-user play state sync. this feature st…
Browse files Browse the repository at this point in the history
…ill in alpha stage.
  • Loading branch information
arabcoders committed Jan 17, 2025
1 parent aeb3a3a commit 97896ad
Show file tree
Hide file tree
Showing 16 changed files with 1,262 additions and 46 deletions.
55 changes: 43 additions & 12 deletions FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,22 +211,53 @@ database state back to the selected backend.

### Is there support for Multi-user setup?

No, The tool is designed to work for single user. However, It's possible to run container for each user. You can also
use single container for all users, however it's not really easy refer
to [issue #136](https://github.com/arabcoders/watchstate/issues/136).
There is a minimal support for multi-user setup via `state:sync` command. However, it still requires that you add your
backends as usual for single user setup and to use `state:sync` command, it's required that all backends have admin
access to be able to retrieve access-tokens for users. That means for Plex you need an admin token, and for
jellyfin/emby you need API key, not `user:password` limited access.

For `Jellyfin` and `Emby`, you can just generate new API tokens and link it to a user.
To get started using `state:sync` command, as mentioned before setup your backends as normal, then create a
`/config/config/mapper.yaml` file if your backends doesn't have the same user. for example

For Plex, You should use your admin token and by running the `config:add` command and selecting a user the tool will
attempt to generate a token for that user.
```yaml
- backend_name1:
name: "mike_jones"
options: { }
backend_name2:
name: "jones_mike"
options: { }
backend_name3:
name: "mikeJones"
options: { }

- backend_name1:
name: "jiji_jones"
options: { }
backend_name2:
name: "jones_jiji"
options: { }
backend_name3:
name: "jijiJones"
options: { }
```
> [!Note]
> If the tool fails to generate an access token for the user, you can run the following command to generate the access
> token manually.
This yaml file helps map your users accounts in the different backends, so the tool can sync the correct user data.
```bash
$ docker exec -ti console backend:users:list -s backend_name --with-tokens
```
Then simply run `state:sync -v` it will generate the required tokens and match users data between the backends.
then sync the difference, Keep in mind that it will be slow and that's expected as it needs to do the same thing without
caching for all users servers and backends. it's recommended to not run this command frequently. as it's puts a lot of
load on the backends. By default, it will sync once every 3 hours. you can ofc change it to suit your needs.

> [!NOTE]
> Known issues:

* Currently, state:sync doesn't have a way of syncing plex users that has PIN enabled.
* Majority of the command flags aren't working or not implemented yet.

> [!IMPORTANT]
> Please keep in mind the new command is still in alpha stage, so things will probably break. Please report any bugs
> you encounter. Also, please make sure to have a backup of your data before running the command. just in-case,
> while we did test it on our live data, it's always better to be safe than sorry.

----

Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@ out of the box, this tool support `Jellyfin`, `Plex` and `Emby` media servers.

## Updates

### 2025-01-18

Due to popular demand, we finally have added the ability to sync all users data, however, it's limited to only
play state, no progress syncing implemented at this stage. This feature still in alpha expect bugs and issues.

However our local tests shows that it's working as expected, but we need more testing to be sure. Please report any
issues you encounter. To enable this feature, you will see new task in the `Tasks` page called `Sync`.

This task will sync all your users play state, However you need to have the backends added with admin token for plex and
API key for jellyfin and emby. Enable the task and let it run, it will sync all users play state.

Please read the FAQ entry about it at [this link](FAQ.md#is-there-support-for-multi-user-setup).

### 2024-12-30

We have removed the old environment variables `WS_CRON_PROGRESS` and `WS_CRON_PUSH` in favor of the new ones
Expand Down
10 changes: 10 additions & 0 deletions config/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use App\Commands\State\BackupCommand;
use App\Commands\State\ExportCommand;
use App\Commands\State\ImportCommand;
use App\Commands\State\SyncCommand;
use App\Commands\System\IndexCommand;
use App\Commands\System\PruneCommand;
use App\Libs\Mappers\Import\MemoryMapper;
Expand Down Expand Up @@ -87,6 +88,7 @@
];

$config['backends_file'] = fixPath(env('WS_BACKENDS_FILE', ag($config, 'path') . '/config/servers.yaml'));
$config['mapper_file'] = fixPath(env('WS_MAPPER_FILE', ag($config, 'path') . '/config/mapper.yaml'));

date_default_timezone_set(ag($config, 'tz', 'UTC'));
$logDateFormat = makeDate()->format('Ymd');
Expand Down Expand Up @@ -273,6 +275,14 @@
'timer' => $checkTaskTimer((string)env('WS_CRON_EXPORT_AT', '30 */1 * * *'), '30 */1 * * *'),
'args' => env('WS_CRON_EXPORT_ARGS', '-v'),
],
SyncCommand::TASK_NAME => [
'command' => SyncCommand::ROUTE,
'name' => SyncCommand::TASK_NAME,
'info' => '[Alpha stage] Sync All users play state. Read the FAQ.',
'enabled' => (bool)env('WS_CRON_SYNC', false),
'timer' => $checkTaskTimer((string)env('WS_CRON_SYNC_AT', '9 */3 * * *'), '9 */3 * * *'),
'args' => env('WS_CRON_SYNC_ARGS', '-v'),
],
BackupCommand::TASK_NAME => [
'command' => BackupCommand::ROUTE,
'name' => BackupCommand::TASK_NAME,
Expand Down
2 changes: 1 addition & 1 deletion config/env.spec.php
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@
};

// -- Do not forget to update the tasks list if you add a new task.
$tasks = ['import', 'export', 'backup', 'prune', 'indexes'];
$tasks = ['import', 'export', 'backup', 'prune', 'indexes', 'sync'];
$task_env = [
[
'key' => 'WS_CRON_{TASK}',
Expand Down
16 changes: 12 additions & 4 deletions src/Backends/Common/ClientInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
use Psr\Http\Message\UriInterface as iUri;
use Psr\Log\LoggerInterface as iLogger;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseInterface as iResponse;

interface ClientInterface
{
Expand Down Expand Up @@ -85,7 +85,7 @@ public function parseWebhook(iRequest $request): iState;
* @param iImport $mapper mapper to use.
* @param iDate|null $after only import items after this date.
*
* @return array<array-key,ResponseInterface> responses.
* @return array<array-key,iResponse> responses.
*/
public function pull(iImport $mapper, iDate|null $after = null): array;

Expand All @@ -96,7 +96,7 @@ public function pull(iImport $mapper, iDate|null $after = null): array;
* @param iStream|null $writer writer to use.
* @param array $opts options for backup.
*
* @return array<array-key,ResponseInterface> responses.
* @return array<array-key,iResponse> responses.
*/
public function backup(iImport $mapper, iStream|null $writer = null, array $opts = []): array;

Expand All @@ -107,7 +107,7 @@ public function backup(iImport $mapper, iStream|null $writer = null, array $opts
* @param QueueRequests $queue queue to use.
* @param iDate|null $after only export items after this date.
*
* @return array<array-key,ResponseInterface> responses.
* @return array<array-key,iResponse> responses.
*/
public function export(iImport $mapper, QueueRequests $queue, iDate|null $after = null): array;

Expand Down Expand Up @@ -325,4 +325,12 @@ public function generateAccessToken(string|int $identifier, string $password, ar
* @return GuidInterface
*/
public function getGuid(): GuidInterface;

/**
* Generate request to change the backend item play state.
*
* @param array<iState> $entities state entity.
* @param array $opts options.
*/
public function updateState(array $entities, QueueRequests $queue, array $opts = []): void;
}
10 changes: 10 additions & 0 deletions src/Backends/Emby/Action/UpdateState.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace App\Backends\Emby\Action;

class UpdateState extends \App\Backends\Jellyfin\Action\UpdateState
{
protected string $action = 'emby.UpdateState';
}
20 changes: 19 additions & 1 deletion src/Backends/Emby/EmbyClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use App\Backends\Emby\Action\GetMetaData;
use App\Backends\Emby\Action\GetSessions;
use App\Backends\Emby\Action\GetUsersList;
use App\Backends\Emby\Action\GetVersion;
use App\Backends\Emby\Action\GetWebUrl;
use App\Backends\Emby\Action\Import;
use App\Backends\Emby\Action\InspectRequest;
Expand All @@ -28,7 +29,7 @@
use App\Backends\Emby\Action\SearchId;
use App\Backends\Emby\Action\SearchQuery;
use App\Backends\Emby\Action\ToEntity;
use App\Backends\Jellyfin\Action\GetVersion;
use App\Backends\Emby\Action\UpdateState;
use App\Backends\Jellyfin\JellyfinClient;
use App\Libs\Config;
use App\Libs\Container;
Expand Down Expand Up @@ -650,6 +651,23 @@ public function validateContext(Context $context): bool
return Container::get(EmbyValidateContext::class)($context);
}

/**
* @inheritdoc
*/
public function updateState(array $entities, QueueRequests $queue, array $opts = []): void
{
$response = Container::get(UpdateState::class)(
context: $this->context,
entities: $entities,
queue: $queue,
opts: $opts
);

if ($response->hasError()) {
$this->logger->log($response->error->level(), $response->error->message, $response->error->context);
}
}

/**
* @inheritdoc
*/
Expand Down
99 changes: 99 additions & 0 deletions src/Backends/Jellyfin/Action/UpdateState.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

namespace App\Backends\Jellyfin\Action;

use App\Backends\Common\CommonTrait;
use App\Backends\Common\Context;
use App\Backends\Common\Response;
use App\Backends\Jellyfin\JellyfinClient;
use App\Libs\Entity\StateInterface as iState;
use App\Libs\Extends\Date;
use App\Libs\QueueRequests;
use Psr\Log\LoggerInterface as iLogger;
use Symfony\Contracts\HttpClient\HttpClientInterface as iHttp;

class UpdateState
{
use CommonTrait;

protected string $action = 'jellyfin.updateState';

public function __construct(protected iHttp $http, protected iLogger $logger)
{
}

/**
* Get Backend unique identifier.
*
* @param Context $context Context instance.
* @param array<iState> $entities State instance.
* @param QueueRequests $queue QueueRequests instance.
* @param array $opts optional options.
*
* @return Response
*/
public function __invoke(Context $context, array $entities, QueueRequests $queue, array $opts = []): Response
{
return $this->tryResponse(
context: $context,
fn: function () use ($context, $entities, $opts, $queue) {
foreach ($entities as $entity) {
$meta = $entity->getMetadata($context->backendName);
if (count($meta) < 1) {
continue;
}

if ($entity->isWatched() === (bool)ag($meta, iState::COLUMN_WATCHED)) {
continue;
}

if (null === ($itemId = ag($meta, iState::COLUMN_ID))) {
continue;
}

$url = $context->backendUrl->withPath(
r('/Users/{user_id}/PlayedItems/{item_id}', [
'user_id' => $context->backendUser,
'item_id' => $itemId,
])
);

if ($context->clientName === JellyfinClient::CLIENT_NAME) {
$url = $url->withQuery(
http_build_query([
'DatePlayed' => makeDate($entity->updated)->format(Date::ATOM)
])
);
}

$queue->add(
$this->http->request(
method: $entity->isWatched() ? 'POST' : 'DELETE',
url: (string)$url,
options: $context->backendHeaders + [
'user_data' => [
'context' => [
'backend' => $context->backendName,
'play_state' => $entity->isWatched() ? 'played' : 'unplayed',
'item' => [
'id' => $itemId,
'title' => $entity->getName(),
'type' => $entity->type == iState::TYPE_EPISODE ? 'episode' : 'movie',
'state' => $entity->isWatched() ? 'played' : 'unplayed',
],
'url' => (string)$url,
]
],
]
)
);
}

return new Response(status: true);
},
action: $this->action
);
}
}
Loading

0 comments on commit 97896ad

Please sign in to comment.