Breaking change in guid.yaml format. if you are facing problems please follow the new spec in FAQ.md

This commit is contained in:
Abdulmhsen B. A. A.
2024-10-06 20:25:58 +03:00
parent db486f8c1e
commit 1b7ef714c0
8 changed files with 580 additions and 107 deletions

15
FAQ.md
View File

@@ -960,14 +960,14 @@ version: 0.0
# The key must be in lower case. and it's an array. # The key must be in lower case. and it's an array.
guids: 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. 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. 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 object. to validate the guid.
validator: 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. 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. example: "(number)" # example of the guid. For informational purposes only.
tests: tests:
valid: valid:
- "1234567" # valid guid examples. - "1234567" # valid guid examples.
@@ -978,7 +978,7 @@ guids:
links: links:
# mapping the com.plexapp.agents.foo guid from plex backends into the guid_mydb in WatchState. # 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. # 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. type: plex # the client to link the guid to. plex, jellyfin, emby.
options: # options used by the client. options: # options used by the client.
legacy: true # Tag the mapper as legacy GUID for mapping. 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. # (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 # 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. # update the map.from to match the new identifier.
# This "replace" object only works with plex legacy guids.
replace: replace:
from: com.plexapp.agents.foobar:// # Replace from this string from: com.plexapp.agents.foobar:// # Replace from this string
to: com.plexapp.agents.foo:// # Into this string. to: com.plexapp.agents.foo:// # Into this string.
# mapping the foo guid from jellyfin backends into the guid_mydb in WatchState. # 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. type: jellyfin # the client to link the guid to. plex, jellyfin, emby.
map: map:
from: foo # map.from this string. from: foo # map.from this string.
to: guid_mydb # map.to this guid. to: guid_mydb # map.to this guid.
# mapping the foo guid from emby backends into the guid_mydb in WatchState. # 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. type: emby # the client to link the guid to. plex, jellyfin, emby.
map: map:
from: foo # map.from this string. from: foo # map.from this string.

View File

@@ -192,9 +192,9 @@ return (function () {
]; ];
$config['supported'] = [ $config['supported'] = [
'plex' => PlexClient::class, strtolower(PlexClient::CLIENT_NAME) => PlexClient::class,
'emby' => EmbyClient::class, strtolower(EmbyClient::CLIENT_NAME) => EmbyClient::class,
'jellyfin' => JellyfinClient::class, strtolower(JellyfinClient::CLIENT_NAME) => JellyfinClient::class,
]; ];
$config['servers'] = []; $config['servers'] = [];

View File

@@ -9,6 +9,7 @@
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare"
}, },
"web-types": "./web-types.json",
"dependencies": { "dependencies": {
"@vueuse/core": "^10.9.0", "@vueuse/core": "^10.9.0",
"@vueuse/nuxt": "^10.9.0", "@vueuse/nuxt": "^10.9.0",

View File

@@ -22,14 +22,15 @@
<div class="field"> <div class="field">
<label class="label is-unselectable" for="form_guid_name">Name</label> <label class="label is-unselectable" for="form_guid_name">Name</label>
<div class="control has-icons-left"> <div class="control has-icons-left">
<input class="input" id="form_guid_name" type="text" v-model="form.name" placeholder="guid_foobar"> <input class="input" id="form_guid_name" type="text" v-model="form.name" placeholder="foobar">
<div class="icon is-small is-left"><i class="fas fa-passport"></i></div> <div class="icon is-small is-left"><i class="fas fa-passport"></i></div>
</div> </div>
<p class="help"> <p class="help">
<span class="icon"><i class="fas fa-info"></i></span> <span class="icon"><i class="fas fa-info"></i></span>
<span>The internal GUID reference name. The name must starts with <code>guid</code>, followed by <span>The internal GUID reference name. The rules are <code>lower case [a-z]</code>, <code>0-9</code>,
<code>_</code>, <code>lower case [a-z]</code>, <code>0-9</code>, <code>no space</code>. <code>no space</code>.
For example, <code>guid_imdb</code>. For example, <code>guid_imdb</code>. The guid name will be automatically prefixed with
<code>guid_</code>.
</span> </span>
</p> </p>
</div> </div>
@@ -185,13 +186,13 @@
</form> </form>
</div> </div>
<div class="column is-12"> <!-- <div class="column is-12">-->
<Message message_class="has-background-info-90 has-text-dark" :toggle="show_page_tips" <!-- <Message message_class="has-background-info-90 has-text-dark" :toggle="show_page_tips"-->
@toggle="show_page_tips = !show_page_tips" :use-toggle="true" title="Tips" icon="fas fa-info-circle"> <!-- @toggle="show_page_tips = !show_page_tips" :use-toggle="true" title="Tips" icon="fas fa-info-circle">-->
<ul> <!-- <ul>-->
</ul> <!-- </ul>-->
</Message> <!-- </Message>-->
</div> <!-- </div>-->
</div> </div>
</div> </div>
</template> </template>
@@ -201,7 +202,6 @@ import 'assets/css/bulma-switch.css'
import request from '~/utils/request' import request from '~/utils/request'
import {notification, stringToRegex} from '~/utils/index' import {notification, stringToRegex} from '~/utils/index'
import {useStorage} from '@vueuse/core' import {useStorage} from '@vueuse/core'
import Message from '~/components/Message'
useHead({title: 'Add Custom GUID'}) useHead({title: 'Add Custom GUID'})
@@ -256,13 +256,7 @@ const addNewGuid = async () => {
notification('error', 'Error', `GUID name must not contain spaces.`, 5000) notification('error', 'Error', `GUID name must not contain spaces.`, 5000)
return return
} }
if (!data.name.startsWith('guid_')) {
notification('error', 'Error', `GUID name must start with 'guid_'.`, 5000)
return
}
data.type = data.type.trim().toLowerCase(); data.type = data.type.trim().toLowerCase();
if (!['string'].includes(data.type)) { if (!['string'].includes(data.type)) {
notification('error', 'Error', `Invalid GUID type.`, 5000) notification('error', 'Error', `Invalid GUID type.`, 5000)

View File

@@ -0,0 +1,300 @@
<template>
<div>
<div class="columns is-multiline">
<div class="column is-12 is-clearfix is-unselectable">
<span id="env_page_title" class="title is-4">
<span class="icon"><i class="fas fa-exchange-alt"></i></span>
Add new client GUID link
</span>
<div class="is-hidden-mobile">
<span class="subtitle"></span>
</div>
</div>
<div class="column is-12">
<form id="page_form" @submit.prevent="addNewLink">
<div class="card">
<header class="card-header">
<p class="card-header-title is-unselectable is-justify-center">Add new client GUID link</p>
</header>
<div class="card-content">
<div class="field">
<label class="label is-unselectable" for="form_select_type">Client</label>
<div class="control has-icons-left">
<div class="select is-fullwidth">
<select id="form_select_type" v-model="form.type">
<option value="" disabled>Select client type</option>
<option v-for="client in supported" :value="client" :key="`client-${client}`">
{{ ucFirst(client) }}
</option>
</select>
</div>
<div class="icon is-left">
<i class="fas fa-server"></i>
</div>
</div>
<p class="help">
<span class="icon"><i class="fas fa-info"></i></span>
<span>Select which client this link association for.</span>
</p>
</div>
<div class="field">
<label class="label is-unselectable" for="form_map_from">Link client GUID</label>
<div class="control has-icons-left">
<input class="input" id="form_map_from" type="text" v-model="form.map.from">
<div class="icon is-small is-left"><i class="fas fa-a"></i></div>
</div>
<p class="help">
<span class="icon"><i class="fas fa-info"></i></span>
<span>Write the <code>{{ form.type.length > 0 ? ucFirst(form.type) : 'client' }}</code> GUID
identifier.</span>
</p>
</div>
<div class="field">
<label class="label is-unselectable" for="form_map_to">To This GUID</label>
<div class="control has-icons-left">
<div class="select is-fullwidth">
<select id="form_map_to" v-model="form.map.to">
<option value="" disabled>Select the associated GUID</option>
<option v-for="(g) in guids" :value="g.guid" :key="`guid-${g.guid}`">
{{ g.guid }}
</option>
</select>
</div>
<div class="icon is-left">
<i class="fas fa-b"></i>
</div>
</div>
<p class="help">
<span class="icon"><i class="fas fa-info"></i></span>
<span>
Select which <code>WatchState</code> GUID should link with this
<code>{{ form.type.length > 0 ? ucFirst(form.type) : 'client' }}</code> GUID identifier.
</span>
</p>
</div>
<div class="field" v-if="'plex' === form.type">
<label class="label" for="backend_import">Is this a Plex legacy agent GUID?</label>
<div class="control">
<input id="backend_import" type="checkbox" class="switch is-success" v-model="form.options.legacy">
<label for="backend_import">Enable</label>
<p class="help">Plex legacy agents starts with <code>com.plexapp.agents.</code></p>
</div>
</div>
<template v-if="'plex' === form.type && true === form.options.legacy">
<div class="field">
<label class="label is-clickable is-unselectable" @click="toggleReplace = !toggleReplace">
<span class="icon">
<i class="fas" :class="{ 'fa-arrow-up': toggleReplace, 'fa-arrow-down': !toggleReplace }"></i>
</span>
Toggle Text replacement.
</label>
<p class="help">
<span class="icon"><i class="fas fa-info"></i></span>
<span>Text replacement only works for plex legacy agents.</span>
</p>
</div>
<template v-if="toggleReplace">
<div class="field">
<label class="label is-unselectable" for="form_replace_from">Search for</label>
<div class="control has-icons-left">
<input class="input" id="form_replace_from" type="text" v-model="form.replace.from">
<div class="icon is-small is-left"><i class="fas fa-passport"></i></div>
</div>
<p class="help">
<span class="icon"><i class="fas fa-info"></i></span>
<span>The text string to replace. Sometimes it's necessary to replace legacy agent GUID into
something else. Leave it empty to ignore it.</span>
</p>
</div>
<div class="field">
<label class="label is-unselectable" for="form_replace_to">Replace with</label>
<div class="control has-icons-left">
<input class="input" id="form_replace_to" type="text" v-model="form.replace.to">
<div class="icon is-small is-left"><i class="fas fa-passport"></i></div>
</div>
<p class="help">
<span class="icon"><i class="fas fa-info"></i></span>
<span>The string replacement. If <code>replace.from</code> is empty this field will be
ignored.</span>
</p>
</div>
</template>
</template>
<div class="card-footer">
<div class="card-footer-item">
<button class="button is-fullwidth is-primary" type="submit"
:disabled="false === validForm || isSaving"
:class="{'is-loading':isSaving}">
<span class="icon-text">
<span class="icon"><i class="fas fa-save"></i></span>
<span>Save</span>
</span>
</button>
</div>
<div class="card-footer-item">
<button class="button is-fullwidth is-danger" type="button" @click="navigateTo('/custom')">
<span class="icon-text">
<span class="icon"><i class="fas fa-cancel"></i></span>
<span>Cancel</span>
</span>
</button>
</div>
</div>
</div>
</div>
</form>
</div>
<!-- <div class="column is-12">-->
<!-- <Message message_class="has-background-info-90 has-text-dark" :toggle="show_page_tips"-->
<!-- @toggle="show_page_tips = !show_page_tips" :use-toggle="true" title="Tips" icon="fas fa-info-circle">-->
<!-- <ul>-->
<!-- </ul>-->
<!-- </Message>-->
<!-- </div>-->
</div>
</div>
</template>
<script setup>
import 'assets/css/bulma-switch.css'
import request from '~/utils/request'
import {notification, parse_api_response} from '~/utils/index'
import {useStorage} from '@vueuse/core'
useHead({title: 'Add new client GUID link'})
const empty_form = {
type: '',
options: {
legacy: true,
},
map: {
from: '',
to: ''
},
replace: {
from: '',
to: ''
},
}
const show_page_tips = useStorage('show_page_tips', true)
const form = ref(JSON.parse(JSON.stringify(empty_form)))
const guids = ref([])
const supported = ref([])
const isSaving = ref(false)
const links = ref([])
const toggleReplace = ref(false)
onMounted(async () => {
try {
/** @type {Array<Promise>} */
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])
links.value = (await parse_api_response(responses[2])).links ?? []
} catch (e) {
notification('error', 'Error', `Request error. ${e.message}`, 5000)
}
})
const addNewLink = async () => {
if (!validForm.value) {
notification('error', 'Error', 'Invalid form data.', 5000)
return
}
let data = form.value
if (!supported.value.includes(data.type)) {
notification('error', 'Error', `Invalid client type.`, 5000)
return
}
if (!data.map.from) {
notification('error', 'Error', `map.from must not be empty.`, 5000)
return
}
if (!guids.value.find(g => g.guid === data.map.to)) {
notification('error', 'Error', `Invalid map.to value '${data.map.to}'.`, 5000)
return
}
for (let i = 0; i < links.value.length; i++) {
if (links.value[i].type === data.type && links.value[i].map.from === data.map.from) {
notification('error', 'Error', `Link with map.from '${data.map.from}' already exists.`, 5000)
return
}
}
let formData = {
type: data.type,
map: {
from: data.map.from,
to: data.map.to
}
}
if ('plex' === data.type) {
formData.options = {
legacy: Boolean(data.options.legacy),
}
if (data.replace.from && data.replace.to) {
formData.replace = {
from: data.replace.from,
to: data.replace.to
}
}
}
isSaving.value = true
try {
const response = await request(`/system/guids/custom/${formData.type}`, {
method: 'PUT',
body: JSON.stringify(formData)
})
const json = await parse_api_response(response)
if (!response.ok) {
notification('error', 'Error', `${json.error.code}: ${json.error.message}`, 5000)
return
}
notification('success', 'Success', 'Successfully added new client link.', 5000)
await navigateTo('/custom')
} catch (e) {
notification('error', 'Error', `Request error. ${e.message}`, 5000)
} finally {
isSaving.value = false
}
}
const validForm = computed(() => {
const data = form.value
if (!data.map.to || !data.map.from || !data.type) {
return false
}
return true
})
</script>

View File

@@ -4,7 +4,7 @@
<div class="column is-12 is-clearfix is-unselectable"> <div class="column is-12 is-clearfix is-unselectable">
<span id="env_page_title" class="title is-4"> <span id="env_page_title" class="title is-4">
<span class="icon"><i class="fas fa-map"></i></span> <span class="icon"><i class="fas fa-map"></i></span>
Custom GUIDs Mapper Custom GUIDs
</span> </span>
<div class="is-pulled-right"> <div class="is-pulled-right">
<div class="field is-grouped"> <div class="field is-grouped">
@@ -27,27 +27,18 @@
</div> </div>
<div class="is-hidden-mobile"> <div class="is-hidden-mobile">
<span class="subtitle"> <span class="subtitle">
This page allow you to add custom GUIDs to the system, this is useful when you want to map a GUID from a This page allow you to add custom GUIDs to the system and link them to the client GUIDs.
backend to a different GUID in WatchState. Or add new GUID identifiers to the system.
</span> </span>
</div> </div>
</div> </div>
<div class="column is-12" v-if="!data"> <div class="column is-12" v-if="isLoading">
<Message v-if="isLoading" message_class="has-background-info-90 has-text-dark" title="Loading" <Message message_class="has-background-info-90 has-text-dark" title="Loading"
icon="fas fa-spinner fa-spin" message="Loading data. Please wait..."/> icon="fas fa-spinner fa-spin" message="Loading data. Please wait..."/>
<Message v-else message_class="has-background-success-90 has-text-dark" title="Information" icon="fas fa-check">
There are no custom GUIDs configured. You can add new GUIDs by clicking on the <i class="fa fa-add"></i>
button.
</Message>
</div> </div>
<div class="column is-12" v-if="!isLoading &&data && data.guids"> <div class="column is-12" v-if="!isLoading && data">
<h1 class="is-unselectable title is-4">Custom GUIDs</h1> <div class="columns is-multiline" v-if="data?.guids?.length >0">
<h2 class="is-unselectable subtitle">
This section contains the custom GUIDs that are currently configured in the system.
</h2>
<div class="columns is-multiline">
<div class="column is-3-tablet" v-for="(guid, index) in data.guids" :key="guid.name"> <div class="column is-3-tablet" v-for="(guid, index) in data.guids" :key="guid.name">
<div class="card"> <div class="card">
<header class="card-header"> <header class="card-header">
@@ -66,33 +57,38 @@
</div> </div>
</div> </div>
</div> </div>
<div v-else>
<Message message_class="has-background-warning-90 has-text-dark" title="Information" icon="fas fa-check">
There are no custom GUIDs configured. You can add new GUIDs by clicking on the <i class="fa fa-add"></i>
button.
</Message>
</div>
</div> </div>
<div class="column is-12 is-clearfix is-unselectable" v-if="hasClientsData"> <div class="column is-12 is-clearfix is-unselectable" v-if="!isLoading">
<span class="title is-4"> <span class="title is-4">
<span class="icon"><i class="fas fa-exchange-alt"></i></span> <span class="icon"><i class="fas fa-exchange-alt"></i></span>
Clients GUID Mapping Client GUID links
</span> </span>
<div class="is-pulled-right"> <div class="is-pulled-right">
<div class="field is-grouped"> <div class="field is-grouped">
<p class="control"> <p class="control">
<button class="button is-primary" v-tooltip.bottom="'Add new Link'"> <NuxtLink class="button is-primary" v-tooltip.bottom="'Add new Link'" to="/custom/addlink">
<span class="icon"><i class="fas fa-add"></i></span> <span class="icon"><i class="fas fa-add"></i></span>
</button> </NuxtLink>
</p> </p>
</div> </div>
</div> </div>
<div class="is-hidden-mobile"> <div class="is-hidden-mobile">
<span class="subtitle">This section contains the client to GUID mapping.</span> <span class="subtitle">This section contains the client <> WatchState GUID links.</span>
</div> </div>
</div> </div>
<div class="column is-12" v-if="hasClientsData"> <div class="column is-12">
<div class="columns is-multiline"> <div class="column is-12" v-if="!isLoading && data && data?.links?.length > 0">
<template v-if="data" v-for="client in supported" :key="client"> <div class="columns is-multiline">
<div class="column is-6-tablet" v-if="client in data && data[client].length > 0" <div class="column is-6-tablet" v-for="(link,index) in data.links" :key="link.id">
v-for="(link, index) in data[client]" :key="`${client}-${index}`">
<div class="card"> <div class="card">
<header class="card-header"> <header class="card-header">
<template v-if="link.replace?.from"> <template v-if="link.replace?.from">
@@ -102,12 +98,12 @@
class="fas" class="fas"
:class="{ 'fa-arrow-down': false === (link.show ?? false), 'fa-arrow-up': true === (link.show ?? false) }" :class="{ 'fa-arrow-down': false === (link.show ?? false), 'fa-arrow-up': true === (link.show ?? false) }"
></i>&nbsp;</span> ></i>&nbsp;</span>
{{ ucFirst(client) }} client link {{ ucFirst(link.type) }} client link
</p> </p>
</template> </template>
<template v-else> <template v-else>
<p class="card-header-title is-text-overflow pr-1 is-unselectable"> <p class="card-header-title is-text-overflow pr-1 is-unselectable">
{{ ucFirst(client) }} client link {{ ucFirst(link.type) }} client link
</p> </p>
</template> </template>
<span class="card-header-icon"> <span class="card-header-icon">
@@ -119,18 +115,18 @@
<div class="card-content"> <div class="card-content">
<div class="columns is-mobile is-multiline"> <div class="columns is-mobile is-multiline">
<div class="column is-3 has-text-left"> <div class="column is-5 has-text-left">
<span class="icon"><i class="fas fa-arrow-right"></i>&nbsp;</span> <span class="icon"><i class="fas fa-arrow-right"></i>&nbsp;</span>
From From Client GUID
</div> </div>
<div class="column is-9 has-text-right"> <div class="column is-7 has-text-right">
{{ link.map.from }} {{ link.map.from }}
</div> </div>
<div class="column is-3 has-text-left"> <div class="column is-5 has-text-left">
<span class="icon"><i class="fas fa-arrow-left"></i>&nbsp;</span> <span class="icon"><i class="fas fa-arrow-left"></i>&nbsp;</span>
To To WatchState GUID
</div> </div>
<div class="column is-9 has-text-right"> <div class="column is-7 has-text-right">
{{ link.map.to }} {{ link.map.to }}
</div> </div>
<template v-if="link.replace?.from && ('show' in link && link.show)"> <template v-if="link.replace?.from && ('show' in link && link.show)">
@@ -143,7 +139,7 @@
</div> </div>
<div class="column is-3 has-text-left"> <div class="column is-3 has-text-left">
<span class="icon"><i class="fas fa-check"></i>&nbsp;</span> <span class="icon"><i class="fas fa-check"></i>&nbsp;</span>
To With
</div> </div>
<div class="column is-9 has-text-right"> <div class="column is-9 has-text-right">
{{ link.replace.to }} {{ link.replace.to }}
@@ -153,21 +149,24 @@
</div> </div>
</div> </div>
</div> </div>
</template> </div>
</div>
<div v-else>
<Message message_class="has-background-warning-90 has-text-dark" title="Information" icon="fas fa-xmark">
There are no client links configured. You can add new links by clicking on the <i class="fa fa-add"></i>
button.
</Message>
</div> </div>
</div> </div>
<!-- <div class="column is-12">-->
<div class="column is-12"> <!-- <Message message_class="has-background-info-90 has-text-dark" :toggle="show_page_tips"-->
<Message message_class="has-background-info-90 has-text-dark" :toggle="show_page_tips" <!-- @toggle="show_page_tips = !show_page_tips" :use-toggle="true" title="Tips" icon="fas fa-info-circle">-->
@toggle="show_page_tips = !show_page_tips" :use-toggle="true" title="Tips" icon="fas fa-info-circle"> <!-- <ul>-->
<ul> <!-- <li>-->
<li> <!-- </li>-->
Clients means the internal implementations of the backends in WatchState, for example, Plex, Emby, <!-- </ul>-->
Jellyfin, etc. <!-- </Message>-->
</li> <!-- </div>-->
</ul>
</Message>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -179,12 +178,11 @@ import {notification, parse_api_response} from '~/utils/index'
import {useStorage} from '@vueuse/core' import {useStorage} from '@vueuse/core'
import Message from '~/components/Message' import Message from '~/components/Message'
useHead({title: 'Custom Guid Mapper'}) useHead({title: 'Custom Guids'})
const data = ref({}) const data = ref({})
const show_page_tips = useStorage('show_page_tips', true) const show_page_tips = useStorage('show_page_tips', true)
const isLoading = ref(false) const isLoading = ref(false)
const supported = ref([]);
const loadContent = async () => { const loadContent = async () => {
try { try {
@@ -198,11 +196,24 @@ const loadContent = async () => {
} }
} }
onMounted(async () => { onMounted(async () => await loadContent())
const supportedClients = await request('/system/supported')
supported.value = await supportedClients.json()
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)
}
}
</script> </script>

63
frontend/web-types.json Normal file
View File

@@ -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"
}
]
}
}
}

View File

@@ -74,6 +74,9 @@ final class Guids
} }
try { try {
if (false === str_starts_with($params->get('name'), 'guid_')) {
$params = $params->with('name', 'guid_' . $params->get('name'));
}
$this->validateName($params->get('name')); $this->validateName($params->get('name'));
} catch (InvalidArgumentException $e) { } catch (InvalidArgumentException $e) {
return api_error($e->getMessage(), Status::BAD_REQUEST); return api_error($e->getMessage(), Status::BAD_REQUEST);
@@ -129,11 +132,12 @@ final class Guids
} }
$data = [ $data = [
'name' => $params->get('name'), 'id' => generateUUID(),
'type' => $params->get('type'), 'type' => $params->get('type'),
'name' => $params->get('name'),
'description' => $params->get('description'), 'description' => $params->get('description'),
'validator' => [ 'validator' => [
'pattern' => $params->get('validator.pattern'), 'pattern' => $pattern,
'example' => $params->get('validator.example'), 'example' => $params->get('validator.example'),
'tests' => [ 'tests' => [
'valid' => $params->get('validator.tests.valid'), '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); $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', []); $file->set('guids', []);
} }
@@ -153,10 +157,31 @@ final class Guids
return api_response(Status::OK, $data); return api_response(Status::OK, $data);
} }
#[Delete(self::URL . '/custom/{index:number}[/]', name: 'system.guids.custom.guid.remove')] #[Delete(self::URL . '/custom/{id:uuid}[/]', name: 'system.guids.custom.guid.remove')]
public function custom_guid_remove(iRequest $request): iResponse 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')] #[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_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')] #[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')] #[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'); $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)) { if (false === file_exists($file)) {
return $guids; return [
'version' => Config::get('guid.version'),
'guids' => [],
'links' => [],
];
} }
foreach (ConfigFile::open($file, 'yaml')->getAll() as $name => $guid) { $data = ConfigFile::open($file, 'yaml');
$guids[strtolower($name)] = $guid;
}
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 private function validateName(string $name): void
{ {
$name = after($name, 'guid_');
if (false === preg_match('/^[a-z0-9_]+$/i', $name)) { if (false === preg_match('/^[a-z0-9_]+$/i', $name)) {
throw new InvalidArgumentException('Name must be alphanumeric and underscores only.'); throw new InvalidArgumentException('Name must be alphanumeric and underscores only.');
} }