diff --git a/FAQ.md b/FAQ.md index f56b898d..78ff897c 100644 --- a/FAQ.md +++ b/FAQ.md @@ -952,11 +952,12 @@ Note: the tip about adding the group_add came from the user `binarypancakes` in ### Advanced: How to extend the GUID parser to support more GUIDs or custom ones? -You can extend the parser by creating new file at `/config/config/guid.yaml` with the following content. +By going to `More > Custom GUIDs` in the WebUI, you can add custom GUIDs to the parser. We know not all people, +like using GUI, as such You can extend the parser by creating new file at `/config/config/guid.yaml` with the following content. ```yaml -# The version of the guid file. right now in beta so it's 0.0. not required to be present. -version: 0.0 +# (Optional) The version of the guid file. If omitted, it will default to the latest version. +version: 1.0 # The key must be in lower case. and it's an array. guids: @@ -982,10 +983,6 @@ links: type: plex # the client to link the guid to. plex, jellyfin, emby. options: # options used by the client. legacy: true # Tag the mapper as legacy GUID for mapping. - # Required map object. to map the new guid to WatchState guid. - map: - from: com.plexapp.agents.foo # map.from this string. - to: guid_mydb # map.to this guid. # (Optional) Replace helper. Sometimes you need to replace the guid identifier to another. # The replacement happens before the mapping, so if you replace the guid identifier, you should also # update the map.from to match the new identifier. @@ -993,6 +990,10 @@ links: replace: from: com.plexapp.agents.foobar:// # Replace from this string to: com.plexapp.agents.foo:// # Into this string. + # Required map object. to map the new guid to WatchState guid. + map: + from: com.plexapp.agents.foo # map.from this string. + to: guid_mydb # map.to this guid. # mapping the foo guid from jellyfin backends into the guid_mydb in WatchState. - id: universally-unique-identifier # the link id. example, 1ef83f5d-1686-60f0-96d6-3eb5c18f2aed @@ -1010,11 +1011,11 @@ links: ``` As you can see from the config, it's roughly how we expected it to be. The `guids` array is where you define your new -guids. the `links` array is where you map from backends guids to the new guid into the WatchState guid. +custom GUIDs. the `links` array is where you map from client/backends GUIDs to the custom GUID in `WatchState`. Everything in this file should be in lower case. If error occurs, the tool will log a warning and ignore the guid, By default, we only show `ERROR` levels in log file, You can lower it by setting `WS_LOGGER_FILE_LEVEL` environment variable to `WARNING`. -If you added or removed a guid from the `guid.yaml` file, you should run `system:reindex --force-reindex` command to update the +If you added or removed a guid from the `guid.yaml` file, you should run `system:index --force-reindex` command to update the database indexes with the new guids. diff --git a/NEWS.md b/NEWS.md index 59dd6bf9..bbfdcb03 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,17 @@ # Old Updates +### 2024-08-19 + +We have migrated the `state:push` task into the new events system, as such the old task `state:push` is now gone. +To enable the new event handler for push events, use the new environment variable `WS_PUSH_ENABLED` and set it to `true`. +Right now, it's disabled by default. However, for people who had the old task enabled, it will reuse that setting. + +Keep in mind, the new event handler is more efficient and will only push data when there is a change in the play state. And it's much faster +than the old task. This event handler will push data within a minute of the change. + +PS: Please enable the task by setting its new environment variable `WS_PUSH_ENABLED` to `true`. The old `WS_CRON_PUSH` is now gone. +and will be removed in the future releases. + ### 2024-08-18 We have started migrating the old events system to a new one, so far we have migrated the `progress` and `requests` to it. As such, diff --git a/README.md b/README.md index 0eb0fd37..17981e4c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,13 @@ out of the box, this tool support `Jellyfin`, `Plex` and `Emby` media servers. ## Updates +### 2024-10-07 + +We have added a WebUI page for Custom GUIDs and stabilized on `v1.0` for the `guid.yaml` file spec. We strongly recommend +to use the `WebUI` to manage the GUIDs, as it's much easier to use than editing the `guid.yaml` file directly. and both the +`WebUI` and `API` have safeguards to prevent you from breaking the parser. For more information please check out the associated +FAQ entry about it at [this link](FAQ.md#advanced-how-to-extend-the-guid-parser-to-support-more-guids-or-custom-ones). + ### 2024-09-14 We have recently added support for extending WatchState with more GUIDs, as of now, the support for it is done via @@ -18,18 +25,6 @@ FAQ entry about it at [this link](FAQ.md#advanced-how-to-extend-the-guid-parser- The mapping should work for all officially supported clients. If you have a client that is not supported, you have to manually add support for that client, or request the maintainer to add support for it. -### 2024-08-19 - -We have migrated the `state:push` task into the new events system, as such the old task `state:push` is now gone. -To enable the new event handler for push events, use the new environment variable `WS_PUSH_ENABLED` and set it to `true`. -Right now, it's disabled by default. However, for people who had the old task enabled, it will reuse that setting. - -Keep in mind, the new event handler is more efficient and will only push data when there is a change in the play state. And it's much faster -than the old task. This event handler will push data within a minute of the change. - -PS: Please enable the task by setting its new environment variable `WS_PUSH_ENABLED` to `true`. The old `WS_CRON_PUSH` is now gone. -and will be removed in the future releases. - --- Refer to [NEWS](NEWS.md) for old updates. diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index 0b991712..dcf75e91 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -99,10 +99,18 @@ Log Suppression + + + Custom GUIDs + + + + Backups + @@ -341,7 +349,6 @@ import 'assets/css/all.css' import {useStorage} from '@vueuse/core' import request from '~/utils/request.js' import Markdown from '~/components/Markdown.vue' -import {dEvent} from '~/utils/index.js' import TaskRunnerStatus from "~/components/TaskRunnerStatus.vue"; const selectedTheme = useStorage('theme', (() => window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')()) diff --git a/frontend/pages/console.vue b/frontend/pages/console.vue index e33bba44..7fa08c1a 100644 --- a/frontend/pages/console.vue +++ b/frontend/pages/console.vue @@ -307,7 +307,9 @@ onMounted(async () => { allEnabled.value = false } - if (Boolean(route.query?.run ?? '0') || '' === command.value) { + const run = route.query?.run ? Boolean(route.query.run) : false + + if (run || '' === command.value) { await RunCommand() } }) diff --git a/frontend/pages/custom/add.vue b/frontend/pages/custom/add.vue index 2d220924..c4893766 100644 --- a/frontend/pages/custom/add.vue +++ b/frontend/pages/custom/add.vue @@ -7,192 +7,178 @@ Add Custom GUID
- + + This custom GUID allows you to extend WatchState GUID parser with your custom GUIDs. Using this + feature, You are able to use more metadata databases for references between the backends. +
-
-
-

Add Custom GUID

-
+
+ +
+ +
+
+

+ + The internal GUID reference name. The rules are lower case [a-z], 0-9, + no space. + For example, guid_imdb. The guid name will be automatically prefixed with + guid_. + +

+
-
+
+ +
+ +
+
+

+ + GUID description, For information purposes only. +

+
-
- -
- -
-
-

- - The internal GUID reference name. The rules are lower case [a-z], 0-9, - no space. - For example, guid_imdb. The guid name will be automatically prefixed with - guid_. - -

+
+ +
+
+
- -
- -
- -
-
-

- - GUID description, For information purposes only. -

-
- -
- -
-
- -
-
- -
-
-

- - We currently only support string type. -

-
- -
- -
- -
-
-

- - - A Valid regular expression to check the value GUID value. To test your patterns, you can use this - website - - . - -

-
-
- -
- -
-
-

- - The example to show when invalid value was checked. For example, (number). For - information purposes only. -

-
- -
- -
- -
-

- - - The values added here must match the pattern defined above. Example: 123. - Additionally, the pattern also must support / being part of the value. as we used it - for relative GUIDs. The (number)/1/1 refers to a relative GUID. - There must be a minimum of 1 correct value. - -

-
- -
- -
- -
-

- - GUID values with should not match the pattern defined above. Example: abc. There - must be a minimum of 1 incorrect value. -

+
+
+

+ + We currently only support string type. +

+
- - - - - - - - -
@@ -218,6 +204,7 @@ const empty_form = { } } } + const show_page_tips = useStorage('show_page_tips', true) const form = ref(JSON.parse(JSON.stringify(empty_form))) const guids = ref([]) @@ -256,7 +243,7 @@ const addNewGuid = async () => { notification('error', 'Error', `GUID name must not contain spaces.`, 5000) return } - + data.type = data.type.trim().toLowerCase(); if (!['string'].includes(data.type)) { notification('error', 'Error', `Invalid GUID type.`, 5000) @@ -264,12 +251,12 @@ const addNewGuid = async () => { } try { - toRaw(guids.value).forEach(g => { - const name = data.name.split('_')[1] - if (g.guid === name) { - throw new Error(`GUID with name '${data.name}' already exists.`) + for (const g of guids.value) { + if (g.guid === data.name) { + notification('error', 'Error', `GUID with name '${data.name}' already exists.`, 5000) + return false } - }) + } } catch (e) { notification('error', 'Error', `${e}`, 5000) return false diff --git a/frontend/pages/custom/addlink.vue b/frontend/pages/custom/addlink.vue index f7e058b7..17d4d8f9 100644 --- a/frontend/pages/custom/addlink.vue +++ b/frontend/pages/custom/addlink.vue @@ -7,159 +7,147 @@ Add new client GUID link
- + + This page allows you to add a new client GUID link. The client GUID link is used to link the client/backend + GUID to the WatchState GUID or your custom GUID. +
-
-
-

Add new client GUID link

-
-
+
+ +
+
+ +
+
+ +
+
+

+ + Select which client this link association for. +

+
+ +
+ +
+ +
+
+

+ + Write the {{ form.type.length > 0 ? ucFirst(form.type) : 'client' }} GUID + identifier. +

+
+ +
+ +
+
+ +
+
+ +
+
+

+ + + Select which WatchState GUID should link with this + {{ form.type.length > 0 ? ucFirst(form.type) : 'client' }} GUID identifier. + +

+
+ +
+ +
+ + +

Plex legacy agents starts with com.plexapp.agents.

+
+
+ + -
- -
-
- -
-
- -
-
-

- - - Select which WatchState GUID should link with this - {{ form.type.length > 0 ? ucFirst(form.type) : 'client' }} GUID identifier. - -

-
- -
- -
- - -

Plex legacy agents starts with com.plexapp.agents.

-
-
- - - - +
+
+ +
+
+
- - - - - - - -
@@ -197,15 +185,15 @@ const toggleReplace = ref(false) onMounted(async () => { try { - /** @type {Array} */ + /** @type {Array>} */ const responses = await Promise.all([ request('/system/guids'), request('/system/supported'), request('/system/guids/custom'), ]) - guids.value = await parse_api_response(responses[0]) - supported.value = await parse_api_response(responses[1]) + guids.value = await parse_api_response(responses[0]) ?? [] + supported.value = await parse_api_response(responses[1]) ?? [] links.value = (await parse_api_response(responses[2])).links ?? [] } catch (e) { @@ -288,13 +276,5 @@ const addNewLink = async () => { } } -const validForm = computed(() => { - const data = form.value - - if (!data.map.to || !data.map.from || !data.type) { - return false - } - - return true -}) +const validForm = computed(() => !(!form.value.map.to || !form.value.map.from || !form.value.type)) diff --git a/frontend/pages/custom/index.vue b/frontend/pages/custom/index.vue index 0aa1705d..9e01433f 100644 --- a/frontend/pages/custom/index.vue +++ b/frontend/pages/custom/index.vue @@ -26,9 +26,7 @@
- - This page allow you to add custom GUIDs to the system and link them to the client GUIDs. - + User defined custom GUIDs.
@@ -37,9 +35,9 @@ icon="fas fa-spinner fa-spin" message="Loading data. Please wait..."/> -
-
-
+
+
+

@@ -58,7 +56,8 @@

- + There are no custom GUIDs configured. You can add new GUIDs by clicking on the button. @@ -81,14 +80,14 @@
- This section contains the client <> WatchState GUID links. + Client <--> WatchState GUID links.
-
+
-
+
@@ -174,20 +208,27 @@ diff --git a/src/API/System/Guids.php b/src/API/System/Guids.php index 73a6c9ed..3a4e56b5 100644 --- a/src/API/System/Guids.php +++ b/src/API/System/Guids.php @@ -56,20 +56,20 @@ final class Guids $params = DataUtil::fromRequest($request); $requiredFields = [ - 'name', - 'type', - 'description', - 'validator.pattern', - 'validator.example', - 'validator.tests.valid', - 'validator.tests.invalid' + 'name' => 'string', + 'type' => join('|', array_keys(Config::get('supported'))), + 'description' => 'string', + 'validator.pattern' => 'string', + 'validator.example' => 'string', + 'validator.tests.valid' => ['string'], + 'validator.tests.invalid' => ['string'], ]; - foreach ($requiredFields as $field) { + foreach ($requiredFields as $field => $type) { if (!$params->get($field)) { return api_error(r("Field '{field}' is required. And is missing from request.", [ 'field' => $field - ]), Status::BAD_REQUEST); + ]), httpCode: Status::BAD_REQUEST, body: ['requiredFields' => $requiredFields]); } } @@ -146,13 +146,15 @@ final class Guids ] ]; - $file = ConfigFile::open(Config::get('guid.file'), 'yaml', autoCreate: true, autoBackup: true); + $file = ConfigFile::open(Config::get('guid.file'), 'yaml', autoSave: false, autoCreate: true, autoBackup: true); if (false === $file->has('guids') || false === is_array($file->get('guids'))) { $file->set('guids', []); } - $file->set('guids.' . count($file->get('guids', [])), $data)->persist(); + $guids = array_values($file->get('guids', [])); + $guids[] = $data; + $file->set('guids', null)->set('guids', $guids)->persist(); return api_response(Status::OK, $data); } @@ -160,16 +162,15 @@ final class Guids #[Delete(self::URL . '/custom/{id:uuid}[/]', name: 'system.guids.custom.guid.remove')] public function custom_guid_remove(string $id): iResponse { - $guids = ag($this->getData(), 'guids', []); - $file = ConfigFile::open(Config::get('guid.file'), 'yaml', autoCreate: true, autoBackup: true); $data = []; $found = false; - foreach ($guids as $index => $guid) { + + foreach ($file->get('guids', []) as $index => $guid) { if ($guid['id'] === $id) { $data = $guid; - $file->delete('guids.' . $index)->persist(); + $file->delete('guids.' . $index); $found = true; break; } @@ -179,6 +180,22 @@ final class Guids return api_error(r("The GUID '{id}' is not found.", ['id' => $id]), Status::NOT_FOUND); } + $guids = $file->get('guids', []); + $file->set('guids', null)->set('guids', array_values($guids))->persist(); + + $linkRemoved = false; + foreach ($file->get('links', []) as $index => $link) { + if (ag($link, 'map.to') === ag($data, 'name')) { + $file->delete('links.' . $index); + $linkRemoved = true; + } + } + + if (true === $linkRemoved) { + $links = $file->get('links', []); + $file->set('links', null)->set('links', array_values($links)); + } + $file->persist(); return api_response(Status::OK, $data); @@ -197,8 +214,8 @@ final class Guids ); } - #[Put(self::URL . '/custom/{client:word}[/]', name: 'system.guids.custom.client.add')] - public function custom_client_guid_add(iRequest $request, string $client): iResponse + #[Put(self::URL . '/custom/{client:word}[/]', name: 'system.guids.custom.client.link.add')] + public function custom_client_link_add(iRequest $request, string $client): iResponse { $params = DataUtil::fromRequest($request); @@ -251,7 +268,7 @@ final class Guids 'type' => $client, 'map' => [ 'from' => $params->get('map.from'), - 'to' => $params->get('map.to'), + 'to' => $mapTo, ], ]; @@ -268,21 +285,51 @@ final class Guids } } - $file = ConfigFile::open(Config::get('guid.file'), 'yaml', autoCreate: true, autoBackup: true); + $file = ConfigFile::open(Config::get('guid.file'), 'yaml', autoSave: false, autoCreate: true, autoBackup: true); if (false === $file->has('links') || false === is_array($file->get('links'))) { $file->set('links', []); } - $file->set('links.' . count($file->get('links', [])), $link)->persist(); + $links = array_values($file->get('links', [])); + $links[] = $link; + + $file->set('links', null)->set('links', $links)->persist(); return api_response(Status::OK, $link); } - #[Delete(self::URL . '/custom/{client:word}/{index:number}[/]', name: 'system.guids.custom.client.remove')] - public function custom_client_guid_remove(iRequest $request): iResponse + #[Delete(self::URL . '/custom/{client:word}/{id:uuid}[/]', name: 'system.guids.custom.client.remove')] + public function custom_client_guid_remove(string $client, string $id): iResponse { - return api_response(Status::OK, $request->getParsedBody()); + $file = ConfigFile::open(Config::get('guid.file'), 'yaml', autoCreate: true, autoBackup: true); + $refIndex = $refData = null; + + foreach ($file->get('links', []) as $i => $link) { + if (ag($link, 'type') !== $client || ag($link, 'id') !== $id) { + continue; + } + + $refIndex = $i; + $refData = $link; + break; + } + + if (null === $refIndex) { + return api_error(r("The client '{client}' link id '{id}' is not found.", [ + 'client' => $client, + 'id' => $id + ]), Status::NOT_FOUND); + } + + $file->delete('links.' . $refIndex); + + $links = $file->get('links', []); + $file->set('links', null) + ->set('links', array_values($links)) + ->persist(); + + return api_response(Status::OK, $refData); } #[Get(self::URL . '/custom/{client:word}/{index:number}[/]', name: 'system.guids.custom.client.guid.view')] @@ -325,18 +372,22 @@ final class Guids private function validateName(string $name): void { - $name = after($name, 'guid_'); + $guidName = after($name, 'guid_'); - if (false === preg_match('/^[a-z0-9_]+$/i', $name)) { + if (false === preg_match('/^[a-z0-9_]+$/i', $guidName)) { throw new InvalidArgumentException('Name must be alphanumeric and underscores only.'); } - if (strtolower($name) !== $name) { + if (strtolower($guidName) !== $guidName) { throw new InvalidArgumentException('Name must be lowercase.'); } - if (str_contains($name, ' ')) { + if (str_contains($guidName, ' ')) { throw new InvalidArgumentException('Name must not contain spaces.'); } + + if (true === array_key_exists($name, Guid::getSupported())) { + throw new InvalidArgumentException(r("GUID name '{name}' is already in use.", ['name' => $name])); + } } } diff --git a/src/Backends/Plex/PlexGuid.php b/src/Backends/Plex/PlexGuid.php index bd8b9a3f..c81d3278 100644 --- a/src/Backends/Plex/PlexGuid.php +++ b/src/Backends/Plex/PlexGuid.php @@ -174,10 +174,10 @@ final class PlexGuid implements iGuid continue; } - if (null !== ($replace = ag($map, 'replace', null))) { + if (null !== ($replace = ag($map, 'options.replace', null))) { if (false === is_array($replace)) { $this->logger->warning( - "Ignoring 'links.{key}'. replace value must be an object. '{given}' is given.", + "Ignoring 'links.{key}'. options.replace value must be an object. '{given}' is given.", [ 'key' => $key, 'given' => get_debug_type($replace), @@ -190,14 +190,17 @@ final class PlexGuid implements iGuid $to = ag($replace, 'to', null); if (empty($from) || false === is_string($from)) { - $this->logger->warning("Ignoring 'links.{key}'. replace.from field is empty or not a string.", [ - 'key' => $key, - ]); + $this->logger->warning( + "Ignoring 'links.{key}'. options.replace.from field is empty or not a string.", + [ + 'key' => $key, + ] + ); continue; } if (false === is_string($to)) { - $this->logger->warning("Ignoring 'links.{key}'. replacer.to field is not a string.", [ + $this->logger->warning("Ignoring 'links.{key}'. options.replace.to field is not a string.", [ 'key' => $key, ]); continue; diff --git a/src/Commands/System/IndexCommand.php b/src/Commands/System/IndexCommand.php index 02a5e7d4..4a705423 100644 --- a/src/Commands/System/IndexCommand.php +++ b/src/Commands/System/IndexCommand.php @@ -86,6 +86,10 @@ final class IndexCommand extends Command 'force-reindex' => (bool)$input->getOption('force-reindex'), ]); + if ($input->getOption('force-reindex')) { + $output->writeln('Indexes have been recreated successfully.'); + } + return self::SUCCESS; } } diff --git a/tests/Backends/Plex/PlexGuidTest.php b/tests/Backends/Plex/PlexGuidTest.php index 03384016..4534181e 100644 --- a/tests/Backends/Plex/PlexGuidTest.php +++ b/tests/Backends/Plex/PlexGuidTest.php @@ -206,10 +206,10 @@ class PlexGuidTest extends TestCase $this->getClass()->parseGUIDFile($tmpFile); $this->assertTrue( $this->logged(Level::Warning, 'Value must be an object.', true), - 'Assert replace key is an object.' + 'Assert link value is an object.' ); - $yaml = ag_set($yaml, 'links.0.replace', 'foo'); + $yaml = ag_set($yaml, 'links.0.options.replace', 'foo'); file_put_contents($tmpFile, Yaml::dump($yaml)); $this->getClass()->parseGUIDFile($tmpFile); $this->assertTrue( @@ -217,23 +217,23 @@ class PlexGuidTest extends TestCase 'Assert replace key is an object.' ); - $yaml = ag_set($yaml, 'links.0.replace', []); + $yaml = ag_set($yaml, 'links.0.options.replace', []); file_put_contents($tmpFile, Yaml::dump($yaml)); $this->getClass()->parseGUIDFile($tmpFile); $this->assertTrue( - $this->logged(Level::Warning, 'replace.from field is empty or not a string.', true), + $this->logged(Level::Warning, 'options.replace.from field is empty or not a string.', true), 'Assert to field is a string.' ); - $yaml = ag_set($yaml, 'links.0.replace.from', 'foo'); + $yaml = ag_set($yaml, 'links.0.options.replace.from', 'foo'); file_put_contents($tmpFile, Yaml::dump($yaml)); $this->getClass()->parseGUIDFile($tmpFile); $this->assertTrue( - $this->logged(Level::Warning, 'replacer.to field is not a string.', true), + $this->logged(Level::Warning, 'options.replace.to field is not a string.', true), 'Assert to field is a string.' ); - $yaml = ag_set($yaml, 'links.0.replace.to', 'bar'); + $yaml = ag_set($yaml, 'links.0.options.replace.to', 'bar'); file_put_contents($tmpFile, Yaml::dump($yaml)); $this->getClass()->parseGUIDFile($tmpFile); $this->assertCount(0, $this->handler->getRecords(), "There should be no error messages logged."); @@ -252,7 +252,7 @@ class PlexGuidTest extends TestCase $this->getClass()->parseGUIDFile($tmpFile); $this->assertTrue( $this->logged(Level::Warning, 'map value must be an object.', true), - 'Assert replace key is an object.' + 'Assert map key is an object.' ); $yaml = ag_set($yaml, 'links.0.map', []);