Skip to content

Commit ac1b9df

Browse files
authored
Merge pull request #812 from arabcoders/dev
feat: Playlist Sync
2 parents 5a20f84 + 63279a1 commit ac1b9df

49 files changed

Lines changed: 5893 additions & 13 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

NEWS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# NEWS
22

3+
### 2026-04-23
4+
5+
Plex appears to have backtracked on the API change that broke external `invited users`, so WatchState supports them again for now.
6+
Support may change again if Plex reverses course or make it harder to access invited users tokens.
7+
38
### 2026-03-26
49

510
Unfortunately, due to changes from plex regarding their API, we can no longer generate access tokens for external users i.e. `invited users`, thus we had to disable and remove

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ box, this tool supports `Jellyfin`, `Plex` and `Emby` media servers.
1010

1111
# Updates
1212

13+
### 2026-04-26
14+
15+
Cross-backend sync for playlists is now available as a **beta** feature. This is still early work, so expect some rough edges,
16+
backend-specific issues, and possible breaking changes as it matures.
17+
18+
Because playlist behavior differs across backends, the feature may change over time, and if it proves too unreliable to support consistently,
19+
it may be reworked or removed. To enable it, simply go to Tasks and enable the `Playlist` task.
20+
1321
### 2026-04-23
1422

1523
Plex appears to have backtracked on the API change that broke external `invited users`, so WatchState supports them again for now.

composer.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@
1313
}
1414
},
1515
"scripts": {
16-
"test": "vendor/bin/phpunit",
17-
"tests": "vendor/bin/phpunit",
16+
"test": "vendor/bin/phpunit --display-all-issues",
17+
"tests": "vendor/bin/phpunit --display-all-issues",
1818
"format": "vendor/bin/mago format",
1919
"format:ci": "vendor/bin/mago format --check",
20-
"test:all": "php -d display_errors=1 -d display_startup_errors=1 -d error_reporting=E_ALL vendor/bin/phpunit",
20+
"test:all": "php -d display_errors=1 -d display_startup_errors=1 -d error_reporting=E_ALL vendor/bin/phpunit --display-all-issues",
2121
"test:coverage": [
2222
"mkdir -p var/coverage",
2323
"php -d zend_extension=xdebug -d xdebug.mode=coverage -d xdebug.start_with_request=no vendor/bin/phpunit --coverage-filter src/ --coverage-clover var/coverage/clover.xml --coverage-xml var/coverage/xml --coverage-text",

config/config.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use App\Commands\State\BackupCommand;
1010
use App\Commands\State\ExportCommand;
1111
use App\Commands\State\ImportCommand;
12+
use App\Commands\State\PlaylistCommand;
1213
use App\Commands\State\ValidateCommand;
1314
use App\Commands\System\IndexCommand;
1415
use App\Commands\System\PruneCommand;
@@ -342,6 +343,14 @@
342343
'timer' => $checkTaskTimer((string) env('WS_CRON_IMPORT_AT', '0 */1 * * *'), '0 */1 * * *'),
343344
'args' => env('WS_CRON_IMPORT_ARGS', '-v'),
344345
],
346+
PlaylistCommand::TASK_NAME => [
347+
'command' => PlaylistCommand::ROUTE,
348+
'name' => PlaylistCommand::TASK_NAME,
349+
'info' => 'Sync playlists cross backends. (BETA)',
350+
'enabled' => (bool) env('WS_CRON_PLAYLIST', false),
351+
'timer' => $checkTaskTimer((string) env('WS_CRON_PLAYLIST_AT', '15 */6 * * *'), '15 */6 * * *'),
352+
'args' => env('WS_CRON_PLAYLIST_ARGS', '-v'),
353+
],
345354
ExportCommand::TASK_NAME => [
346355
'command' => ExportCommand::ROUTE,
347356
'name' => ExportCommand::TASK_NAME,

config/env.spec.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,7 @@
535535
};
536536

537537
// -- Do not forget to update the tasks list if you add a new task.
538-
$tasks = ['import', 'export', 'backup', 'prune', 'indexes', 'validate', 'dispatch'];
538+
$tasks = ['import', 'playlist', 'export', 'backup', 'prune', 'indexes', 'validate', 'dispatch'];
539539
$task_env = [
540540
[
541541
'key' => 'WS_CRON_{TASK}',

config/servers.spec.php

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@
7878
'nullable' => true,
7979
'description' => 'The last time data was exported to the backend.',
8080
],
81+
[
82+
'key' => 'export.playlist.lastSync',
83+
'type' => 'int',
84+
'visible' => true,
85+
'nullable' => true,
86+
'description' => 'The last time playlists were synced to the backend.',
87+
],
8188
[
8289
'key' => 'import.enabled',
8390
'type' => 'bool',
@@ -91,6 +98,13 @@
9198
'nullable' => true,
9299
'description' => 'The last time data was imported from the backend.',
93100
],
101+
[
102+
'key' => 'import.playlist.lastSync',
103+
'type' => 'int',
104+
'visible' => true,
105+
'nullable' => true,
106+
'description' => 'The last time playlists were synced from the backend.',
107+
],
94108
[
95109
'key' => 'options',
96110
'type' => 'array',
@@ -111,10 +125,10 @@
111125
'description' => 'How many items to get per request when syncing.',
112126
'validate' => function ($value, array $spec = []) {
113127
$limit = 300;
114-
if ((int)$value < $limit) {
128+
if ((int) $value < $limit) {
115129
throw new ValidationException(r('The value must be greater than {limit} items.', ['limit' => $limit]));
116130
}
117-
return (int)$value;
131+
return (int) $value;
118132
},
119133
],
120134
[
@@ -151,7 +165,7 @@
151165
'key' => 'options.MAX_EPISODE_RANGE',
152166
'type' => 'int',
153167
'visible' => false,
154-
'description' => 'The max range a single record/episode can cover. The default is 5.'
168+
'description' => 'The max range a single record/episode can cover. The default is 5.',
155169
],
156170
[
157171
'key' => 'options.client.timeout',
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
-- # migrate_up
2+
3+
CREATE TABLE IF NOT EXISTS "playlists"
4+
(
5+
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
6+
"backend" text NOT NULL,
7+
"backend_id" text NOT NULL,
8+
"title" text NOT NULL,
9+
"type" text NOT NULL DEFAULT 'video',
10+
"summary" text NULL,
11+
"is_editable" integer NOT NULL DEFAULT '1',
12+
"is_smart" integer NOT NULL DEFAULT '0',
13+
"is_public" integer NOT NULL DEFAULT '0',
14+
"item_count" integer NOT NULL DEFAULT '0',
15+
"sync_id" text NULL,
16+
"content_hash" text NOT NULL DEFAULT '',
17+
"remote_updated_at" integer NOT NULL DEFAULT '0',
18+
"deleted_at" integer NULL,
19+
"metadata" text NOT NULL DEFAULT '{}',
20+
"created_at" integer NOT NULL,
21+
"updated_at" integer NOT NULL,
22+
"synced_at" integer NOT NULL,
23+
UNIQUE ("backend", "backend_id")
24+
);
25+
26+
CREATE INDEX IF NOT EXISTS "playlists_backend" ON "playlists" ("backend");
27+
CREATE INDEX IF NOT EXISTS "playlists_title" ON "playlists" ("title");
28+
CREATE INDEX IF NOT EXISTS "playlists_sync_id" ON "playlists" ("sync_id");
29+
CREATE INDEX IF NOT EXISTS "playlists_remote_updated_at" ON "playlists" ("remote_updated_at");
30+
CREATE INDEX IF NOT EXISTS "playlists_deleted_at" ON "playlists" ("deleted_at");
31+
32+
CREATE TABLE IF NOT EXISTS "playlist_items"
33+
(
34+
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
35+
"playlist_id" integer NOT NULL,
36+
"position" integer NOT NULL,
37+
"state_id" integer NULL,
38+
"backend_item_id" text NULL,
39+
"backend_entry_id" text NULL,
40+
"item_type" text NULL,
41+
"title" text NOT NULL,
42+
"metadata" text NOT NULL DEFAULT '{}',
43+
"created_at" integer NOT NULL,
44+
"updated_at" integer NOT NULL
45+
);
46+
47+
CREATE UNIQUE INDEX IF NOT EXISTS "playlist_items_position" ON "playlist_items" ("playlist_id", "position");
48+
CREATE INDEX IF NOT EXISTS "playlist_items_playlist_id" ON "playlist_items" ("playlist_id");
49+
CREATE INDEX IF NOT EXISTS "playlist_items_state_id" ON "playlist_items" ("state_id");
50+
CREATE INDEX IF NOT EXISTS "playlist_items_backend_item" ON "playlist_items" ("backend_item_id");
51+
52+
-- # migrate_down
53+
54+
DROP TABLE IF EXISTS "playlist_items";
55+
DROP TABLE IF EXISTS "playlists";

src/API/Player/Playlist.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Psr\Log\LoggerInterface as iLogger;
1616
use Psr\SimpleCache\CacheInterface as iCache;
1717
use Psr\SimpleCache\InvalidArgumentException;
18+
use SensitiveParameter;
1819
use SplFileInfo;
1920
use Throwable;
2021

@@ -32,7 +33,7 @@ public function __construct(
3233
* @throws InvalidArgumentException
3334
*/
3435
#[Get(pattern: self::URL . '/{token}[/[{fake:.*}[/]]]')]
35-
public function __invoke(iRequest $request, #[\SensitiveParameter] string $token): iResponse
36+
public function __invoke(iRequest $request, #[SensitiveParameter] string $token): iResponse
3637
{
3738
if (null === ($data = $this->cache->get($token, null))) {
3839
return api_error('Token is expired or invalid.', Status::BAD_REQUEST);

src/API/System/Reset.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ final class Reset
1919
public const string URL = '%{api.prefix}/system/reset';
2020

2121
#[Delete(self::URL . '[/]', name: 'system.reset')]
22-
public function reset(iRequest $request, Redis $redis, iImport $mapper, iLogger $logger): iResponse
22+
public function reset(Redis $redis, iImport $mapper, iLogger $logger): iResponse
2323
{
2424
foreach (get_users_context($mapper, $logger) as $userContext) {
2525
// -- reset database.
@@ -29,6 +29,8 @@ public function reset(iRequest $request, Redis $redis, iImport $mapper, iLogger
2929
foreach (array_keys($userContext->config->getAll()) as $name) {
3030
$userContext->config->set("{$name}.import.lastSync", null);
3131
$userContext->config->set("{$name}.export.lastSync", null);
32+
$userContext->config->set("{$name}.export.playlist.lastSync", null);
33+
$userContext->config->set("{$name}.import.playlist.lastSync", null);
3234
}
3335

3436
// -- persist changes.
@@ -45,7 +47,7 @@ public function reset(iRequest $request, Redis $redis, iImport $mapper, iLogger
4547
}
4648

4749
#[Post(self::URL . '/opcache[/]', name: 'system.reset.opcache')]
48-
public function opcache(iRequest $request, Redis $redis, iImport $mapper, iLogger $logger): iResponse
50+
public function opcache(): iResponse
4951
{
5052
return api_response(Status::OK, [
5153
'message' => opcache_reset() ? 'OPCache reset is complete.' : 'OPCache reset failed.',

src/Backends/Common/ClientInterface.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,46 @@ public function getIdentifier(bool $forceRefresh = false): int|string|null;
235235
*/
236236
public function getUsersList(array $opts = []): array;
237237

238+
/**
239+
* Return list of editable playlists.
240+
*
241+
* @param array $opts options.
242+
*
243+
* @return array<int,array<string,mixed>>
244+
*/
245+
public function getPlaylistsList(array $opts = []): array;
246+
247+
/**
248+
* Return playlist details.
249+
*
250+
* @param string|int $id playlist id.
251+
* @param array $opts options.
252+
*
253+
* @return array<string,mixed>
254+
*/
255+
public function getPlaylist(string|int $id, array $opts = []): array;
256+
257+
/**
258+
* Create playlist.
259+
*
260+
* @param string $title playlist title.
261+
* @param array<int,string> $itemIds playlist item ids.
262+
* @param array $opts options.
263+
*
264+
* @return array<string,mixed>
265+
*/
266+
public function createPlaylist(string $title, array $itemIds = [], array $opts = []): array;
267+
268+
/**
269+
* Delete playlist.
270+
*
271+
* @param string|int $id playlist id.
272+
* @param array $opts options.
273+
*
274+
* @return array<string,mixed>
275+
*/
276+
public function deletePlaylist(string|int $id, array $opts = []): array;
277+
238278
/**
239279
* Return list of backend libraries.
240280
*

0 commit comments

Comments
 (0)