diff --git a/FAQ.md b/FAQ.md index 67e4ace5..f56b898d 100644 --- a/FAQ.md +++ b/FAQ.md @@ -960,14 +960,14 @@ version: 0.0 # The key must be in lower case. and it's an array. guids: - - id: universally-unique-identifier # the guid id + - id: universally-unique-identifier # the guid id. Example, 1ef83f5d-1686-60f0-96d6-3eb5c18f2aed type: string # must be exactly string do not change it. name: guid_mydb # the name must start with guid_ with no spaces and lower case. - description: "My custom database guid" # description of the guid. + description: "My custom database guid" # description of the guid. For informational purposes only. # Validator object. to validate the guid. validator: - pattern: /^[0-9\/]+$/i # regex pattern to match the guid. The pattern must also support / being in the guid. as we use the same object to generate relative guid. - example: "(number)" # example of the guid. + pattern: "/^[0-9\/]+$/i" # regex pattern to match the guid. The pattern must also support / being in the guid. as we use the same object to generate relative guid. + example: "(number)" # example of the guid. For informational purposes only. tests: valid: - "1234567" # valid guid examples. @@ -978,7 +978,7 @@ guids: links: # mapping the com.plexapp.agents.foo guid from plex backends into the guid_mydb in WatchState. # plex legacy guids starts with com.plexapp.agents., you must set options.legacy to true. - - id: universally-unique-identifier # the link id + - id: universally-unique-identifier # the link id. example, 1ef83f5d-1686-60f0-96d6-3eb5c18f2aed 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. @@ -989,19 +989,20 @@ links: # (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. + # This "replace" object only works with plex legacy guids. replace: from: com.plexapp.agents.foobar:// # Replace from this string to: com.plexapp.agents.foo:// # Into this string. # mapping the foo guid from jellyfin backends into the guid_mydb in WatchState. - - id: universally-unique-identifier # the link id + - id: universally-unique-identifier # the link id. example, 1ef83f5d-1686-60f0-96d6-3eb5c18f2aed type: jellyfin # the client to link the guid to. plex, jellyfin, emby. map: from: foo # map.from this string. to: guid_mydb # map.to this guid. # mapping the foo guid from emby backends into the guid_mydb in WatchState. - - id: universally-unique-identifier # the link id + - id: universally-unique-identifier # the link id. example, 1ef83f5d-1686-60f0-96d6-3eb5c18f2aed type: emby # the client to link the guid to. plex, jellyfin, emby. map: from: foo # map.from this string. diff --git a/config/config.php b/config/config.php index 3cc6be68..5b8a1333 100644 --- a/config/config.php +++ b/config/config.php @@ -192,9 +192,9 @@ return (function () { ]; $config['supported'] = [ - 'plex' => PlexClient::class, - 'emby' => EmbyClient::class, - 'jellyfin' => JellyfinClient::class, + strtolower(PlexClient::CLIENT_NAME) => PlexClient::class, + strtolower(EmbyClient::CLIENT_NAME) => EmbyClient::class, + strtolower(JellyfinClient::CLIENT_NAME) => JellyfinClient::class, ]; $config['servers'] = []; diff --git a/frontend/package.json b/frontend/package.json index 01830383..0be504b7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "preview": "nuxt preview", "postinstall": "nuxt prepare" }, + "web-types": "./web-types.json", "dependencies": { "@vueuse/core": "^10.9.0", "@vueuse/nuxt": "^10.9.0", diff --git a/frontend/pages/custom/add.vue b/frontend/pages/custom/add.vue index 5dde0eaf..2d220924 100644 --- a/frontend/pages/custom/add.vue +++ b/frontend/pages/custom/add.vue @@ -22,14 +22,15 @@
- +

- The internal GUID reference name. The name must starts with guid, followed by - _, lower case [a-z], 0-9, no space. - For example, guid_imdb. + 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_.

@@ -185,13 +186,13 @@ -
- - - -
+ + + + + + + @@ -201,7 +202,6 @@ import 'assets/css/bulma-switch.css' import request from '~/utils/request' import {notification, stringToRegex} from '~/utils/index' import {useStorage} from '@vueuse/core' -import Message from '~/components/Message' useHead({title: 'Add Custom GUID'}) @@ -256,13 +256,7 @@ const addNewGuid = async () => { notification('error', 'Error', `GUID name must not contain spaces.`, 5000) return } - - if (!data.name.startsWith('guid_')) { - notification('error', 'Error', `GUID name must start with 'guid_'.`, 5000) - return - } - - + data.type = data.type.trim().toLowerCase(); if (!['string'].includes(data.type)) { notification('error', 'Error', `Invalid GUID type.`, 5000) diff --git a/frontend/pages/custom/addlink.vue b/frontend/pages/custom/addlink.vue new file mode 100644 index 00000000..f7e058b7 --- /dev/null +++ b/frontend/pages/custom/addlink.vue @@ -0,0 +1,300 @@ + + + diff --git a/frontend/pages/custom/index.vue b/frontend/pages/custom/index.vue index 042d6975..0aa1705d 100644 --- a/frontend/pages/custom/index.vue +++ b/frontend/pages/custom/index.vue @@ -4,7 +4,7 @@
- Custom GUIDs Mapper + Custom GUIDs
@@ -27,27 +27,18 @@
- This page allow you to add custom GUIDs to the system, this is useful when you want to map a GUID from a - backend to a different GUID in WatchState. Or add new GUID identifiers to the system. + This page allow you to add custom GUIDs to the system and link them to the client GUIDs.
-
- + - - There are no custom GUIDs configured. You can add new GUIDs by clicking on the - button. -
-
-

Custom GUIDs

-

- This section contains the custom GUIDs that are currently configured in the system. -

-
+
+
@@ -66,33 +57,38 @@
+
+ + There are no custom GUIDs configured. You can add new GUIDs by clicking on the + button. + +
-
+
- Clients GUID Mapping + Client GUID links

- +

- This section contains the client to GUID mapping. + This section contains the client <> WatchState GUID links.
-
-
- @@ -179,12 +178,11 @@ import {notification, parse_api_response} from '~/utils/index' import {useStorage} from '@vueuse/core' import Message from '~/components/Message' -useHead({title: 'Custom Guid Mapper'}) +useHead({title: 'Custom Guids'}) const data = ref({}) const show_page_tips = useStorage('show_page_tips', true) const isLoading = ref(false) -const supported = ref([]); const loadContent = async () => { try { @@ -198,11 +196,24 @@ const loadContent = async () => { } } -onMounted(async () => { - const supportedClients = await request('/system/supported') - supported.value = await supportedClients.json() - await loadContent() -}) +onMounted(async () => await loadContent()) -const hasClientsData = computed(() => !isLoading.value && supported.value.length > 0 && Object.keys(data.value).length > 0 && supported.value.some(client => client in data.value)) +const deleteGUID = async (index, guid) => { + if (!confirm(`Are you sure you want to delete the GUID: '${guid.name}'?`)) { + return + } + + try { + const response = await request(`/system/guids/custom/${guid.id}`, {method: 'DELETE'}) + const result = await parse_api_response(response) + if (response.ok) { + data.value.guids.splice(index, 1) + notification('success', 'Success', result.message) + } else { + notification('error', 'Error', result.error.message) + } + } catch (e) { + return notification('error', 'Error', e.message) + } +} diff --git a/frontend/web-types.json b/frontend/web-types.json new file mode 100644 index 00000000..72571802 --- /dev/null +++ b/frontend/web-types.json @@ -0,0 +1,63 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "framework": "vue", + "js-types-syntax": "typescript", + "description-markup": "markdown", + "framework-config": { + "enable-when": { + "file-extensions": [ + "vue" + ] + } + }, + "contributions": { + "html": { + "vue-directives": [ + { + "name": "tooltip", + "description": "Create a tooltip for an element.", + "doc-url": "", + "attribute-value": { + "type": "string", + "required": true + }, + "modifiers": [ + { + "name": "top", + "description": "Position the tooltip at the top of the element." + }, + { + "name": "bottom", + "description": "Position the tooltip at the bottom of the element." + }, + { + "name": "left", + "description": "Position the tooltip at the left of the element." + }, + { + "name": "right", + "description": "Position the tooltip at the right of the element." + } + ] + }, + { + "name": "highlight", + "description": "Highlight an element.", + "doc-url": "" + }, + { + "name": "autoscroll", + "description": "Automatically scroll to an element.", + "doc-url": "" + } + ], + "vue-components": [ + { + "name": "VTooltip", + "description": "Create more advanced tooltips.", + "doc-url": "https://floating-vue.starpad.dev/guide/component#tooltip" + } + ] + } + } +} diff --git a/src/API/System/Guids.php b/src/API/System/Guids.php index 4be0465f..73a6c9ed 100644 --- a/src/API/System/Guids.php +++ b/src/API/System/Guids.php @@ -74,6 +74,9 @@ final class Guids } try { + if (false === str_starts_with($params->get('name'), 'guid_')) { + $params = $params->with('name', 'guid_' . $params->get('name')); + } $this->validateName($params->get('name')); } catch (InvalidArgumentException $e) { return api_error($e->getMessage(), Status::BAD_REQUEST); @@ -129,11 +132,12 @@ final class Guids } $data = [ - 'name' => $params->get('name'), + 'id' => generateUUID(), 'type' => $params->get('type'), + 'name' => $params->get('name'), 'description' => $params->get('description'), 'validator' => [ - 'pattern' => $params->get('validator.pattern'), + 'pattern' => $pattern, 'example' => $params->get('validator.example'), 'tests' => [ 'valid' => $params->get('validator.tests.valid'), @@ -144,7 +148,7 @@ final class Guids $file = ConfigFile::open(Config::get('guid.file'), 'yaml', autoCreate: true, autoBackup: true); - if (!$file->has('guids') || !is_array($file->get('guids'))) { + if (false === $file->has('guids') || false === is_array($file->get('guids'))) { $file->set('guids', []); } @@ -153,10 +157,31 @@ final class Guids return api_response(Status::OK, $data); } - #[Delete(self::URL . '/custom/{index:number}[/]', name: 'system.guids.custom.guid.remove')] - public function custom_guid_remove(iRequest $request): iResponse + #[Delete(self::URL . '/custom/{id:uuid}[/]', name: 'system.guids.custom.guid.remove')] + public function custom_guid_remove(string $id): iResponse { - return api_response(Status::OK, $request->getParsedBody()); + $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) { + if ($guid['id'] === $id) { + $data = $guid; + $file->delete('guids.' . $index)->persist(); + $found = true; + break; + } + } + + if (false === $found) { + return api_error(r("The GUID '{id}' is not found.", ['id' => $id]), Status::NOT_FOUND); + } + + $file->persist(); + + return api_response(Status::OK, $data); } #[Get(self::URL . '/custom/{client:word}[/]', name: 'system.guids.custom.client')] @@ -166,13 +191,92 @@ final class Guids return api_error('Client name is unsupported or incorrect.', Status::NOT_FOUND); } - return api_response(Status::OK, ag($this->getData(), $client, [])); + return api_response( + Status::OK, + array_filter(ag($this->getData(), 'links', []), fn($link) => $link['type'] === $client) + ); } #[Put(self::URL . '/custom/{client:word}[/]', name: 'system.guids.custom.client.add')] - public function custom_client_guid_add(iRequest $request): iResponse + public function custom_client_guid_add(iRequest $request, string $client): iResponse { - return api_response(Status::OK, $request->getParsedBody()); + $params = DataUtil::fromRequest($request); + + $requiredFields = [ + 'type', + 'map.from', + 'map.to', + ]; + + if ('plex' === $client) { + $requiredFields[] = 'options.legacy'; + } + + foreach ($requiredFields as $field) { + if (!$params->get($field)) { + return api_error(r("Field '{field}' is required. And is missing from request.", [ + 'field' => $field + ]), Status::BAD_REQUEST); + } + } + + if (false === array_key_exists($client, Config::get('supported', []))) { + return api_error(r("Client name '{client}' is unsupported or incorrect.", [ + 'client' => $client + ]), Status::BAD_REQUEST); + } + + $mapTo = $params->get('map.to'); + if (false === str_starts_with($mapTo, 'guid_')) { + $mapTo = 'guid_' . $mapTo; + } + + if (false === array_key_exists($mapTo, Guid::getSupported())) { + return api_error(r("The map.to GUID '{guid}' is not supported.", [ + 'guid' => $params->get('map.to') + ]), Status::BAD_REQUEST); + } + + foreach (ag($this->getData(), 'links', []) as $link) { + if ($link['type'] === $client && $link['map']['from'] === $params->get('map.from')) { + return api_error(r("The client '{client}' map.from '{from}' is already exists.", [ + 'client' => $client, + 'from' => $params->get('map.from') + ]), Status::BAD_REQUEST); + } + } + + $link = [ + 'id' => generateUUID(), + 'type' => $client, + 'map' => [ + 'from' => $params->get('map.from'), + 'to' => $params->get('map.to'), + ], + ]; + + if ('plex' === $client) { + $link['options'] = [ + 'legacy' => (bool)$params->get('options.legacy'), + ]; + + if ($params->get('replace.from') && $params->get('replace.to')) { + $link['replace'] = [ + 'from' => $params->get('replace.from'), + 'to' => $params->get('replace.to'), + ]; + } + } + + $file = ConfigFile::open(Config::get('guid.file'), 'yaml', 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(); + + return api_response(Status::OK, $link); } #[Delete(self::URL . '/custom/{client:word}/{index:number}[/]', name: 'system.guids.custom.client.remove')] @@ -202,28 +306,27 @@ final class Guids { $file = Config::get('guid.file'); - $guids = [ - 'version' => Config::get('guid.version'), - 'guids' => [], - ]; - - foreach (array_keys(Config::get('supported', [])) as $name) { - $guids[$name] = []; - } - if (false === file_exists($file)) { - return $guids; + return [ + 'version' => Config::get('guid.version'), + 'guids' => [], + 'links' => [], + ]; } - foreach (ConfigFile::open($file, 'yaml')->getAll() as $name => $guid) { - $guids[strtolower($name)] = $guid; - } + $data = ConfigFile::open($file, 'yaml'); - return $guids; + return [ + 'version' => $data->get('version', Config::get('guid.version')), + 'guids' => $data->get('guids', []), + 'links' => $data->get('links', []), + ]; } private function validateName(string $name): void { + $name = after($name, 'guid_'); + if (false === preg_match('/^[a-z0-9_]+$/i', $name)) { throw new InvalidArgumentException('Name must be alphanumeric and underscores only.'); }