Breaking change in guid.yaml format. if you are facing problems please follow the new spec in FAQ.md
This commit is contained in:
15
FAQ.md
15
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.
|
||||
|
||||
@@ -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'] = [];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -22,14 +22,15 @@
|
||||
<div class="field">
|
||||
<label class="label is-unselectable" for="form_guid_name">Name</label>
|
||||
<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>
|
||||
<p class="help">
|
||||
<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
|
||||
<code>_</code>, <code>lower case [a-z]</code>, <code>0-9</code>, <code>no space</code>.
|
||||
For example, <code>guid_imdb</code>.
|
||||
<span>The internal GUID reference name. The rules are <code>lower case [a-z]</code>, <code>0-9</code>,
|
||||
<code>no space</code>.
|
||||
For example, <code>guid_imdb</code>. The guid name will be automatically prefixed with
|
||||
<code>guid_</code>.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -185,13 +186,13 @@
|
||||
</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 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>
|
||||
@@ -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)
|
||||
|
||||
300
frontend/pages/custom/addlink.vue
Normal file
300
frontend/pages/custom/addlink.vue
Normal 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>
|
||||
@@ -4,7 +4,7 @@
|
||||
<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-map"></i></span>
|
||||
Custom GUIDs Mapper
|
||||
Custom GUIDs
|
||||
</span>
|
||||
<div class="is-pulled-right">
|
||||
<div class="field is-grouped">
|
||||
@@ -27,27 +27,18 @@
|
||||
</div>
|
||||
<div class="is-hidden-mobile">
|
||||
<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
|
||||
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.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12" v-if="!data">
|
||||
<Message v-if="isLoading" message_class="has-background-info-90 has-text-dark" title="Loading"
|
||||
<div class="column is-12" v-if="isLoading">
|
||||
<Message message_class="has-background-info-90 has-text-dark" title="Loading"
|
||||
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 class="column is-12" v-if="!isLoading &&data && data.guids">
|
||||
<h1 class="is-unselectable title is-4">Custom GUIDs</h1>
|
||||
<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-12" v-if="!isLoading && data">
|
||||
<div class="columns is-multiline" v-if="data?.guids?.length >0">
|
||||
<div class="column is-3-tablet" v-for="(guid, index) in data.guids" :key="guid.name">
|
||||
<div class="card">
|
||||
<header class="card-header">
|
||||
@@ -66,33 +57,38 @@
|
||||
</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 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="icon"><i class="fas fa-exchange-alt"></i></span>
|
||||
Clients GUID Mapping
|
||||
Client GUID links
|
||||
</span>
|
||||
|
||||
<div class="is-pulled-right">
|
||||
<div class="field is-grouped">
|
||||
<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>
|
||||
</button>
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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 class="column is-12" v-if="hasClientsData">
|
||||
<div class="columns is-multiline">
|
||||
<template v-if="data" v-for="client in supported" :key="client">
|
||||
<div class="column is-6-tablet" v-if="client in data && data[client].length > 0"
|
||||
v-for="(link, index) in data[client]" :key="`${client}-${index}`">
|
||||
<div class="column is-12">
|
||||
<div class="column is-12" v-if="!isLoading && data && data?.links?.length > 0">
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-6-tablet" v-for="(link,index) in data.links" :key="link.id">
|
||||
<div class="card">
|
||||
<header class="card-header">
|
||||
<template v-if="link.replace?.from">
|
||||
@@ -102,12 +98,12 @@
|
||||
class="fas"
|
||||
:class="{ 'fa-arrow-down': false === (link.show ?? false), 'fa-arrow-up': true === (link.show ?? false) }"
|
||||
></i> </span>
|
||||
{{ ucFirst(client) }} client link
|
||||
{{ ucFirst(link.type) }} client link
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="card-header-title is-text-overflow pr-1 is-unselectable">
|
||||
{{ ucFirst(client) }} client link
|
||||
{{ ucFirst(link.type) }} client link
|
||||
</p>
|
||||
</template>
|
||||
<span class="card-header-icon">
|
||||
@@ -119,18 +115,18 @@
|
||||
|
||||
<div class="card-content">
|
||||
<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> </span>
|
||||
From
|
||||
From Client GUID
|
||||
</div>
|
||||
<div class="column is-9 has-text-right">
|
||||
<div class="column is-7 has-text-right">
|
||||
{{ link.map.from }}
|
||||
</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> </span>
|
||||
To
|
||||
To WatchState GUID
|
||||
</div>
|
||||
<div class="column is-9 has-text-right">
|
||||
<div class="column is-7 has-text-right">
|
||||
{{ link.map.to }}
|
||||
</div>
|
||||
<template v-if="link.replace?.from && ('show' in link && link.show)">
|
||||
@@ -143,7 +139,7 @@
|
||||
</div>
|
||||
<div class="column is-3 has-text-left">
|
||||
<span class="icon"><i class="fas fa-check"></i> </span>
|
||||
To
|
||||
With
|
||||
</div>
|
||||
<div class="column is-9 has-text-right">
|
||||
{{ link.replace.to }}
|
||||
@@ -153,21 +149,24 @@
|
||||
</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 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>
|
||||
<li>
|
||||
Clients means the internal implementations of the backends in WatchState, for example, Plex, Emby,
|
||||
Jellyfin, etc.
|
||||
</li>
|
||||
</ul>
|
||||
</Message>
|
||||
</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>-->
|
||||
<!-- <li>-->
|
||||
<!-- </li>-->
|
||||
<!-- </ul>-->
|
||||
<!-- </Message>-->
|
||||
<!-- </div>-->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
63
frontend/web-types.json
Normal file
63
frontend/web-types.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user