Added URL Checker into tools menu.

This commit is contained in:
arabcoders
2025-05-19 23:42:11 +03:00
parent fa4eda07f9
commit 9856ca7856
7 changed files with 585 additions and 51 deletions

View File

@@ -51,6 +51,7 @@ RUN echo '' && \
# create /usr/bin/php that points to /opt/bin/frankenphp php-cli "$@" \
echo '#!/bin/sh' > /usr/bin/php && \
echo 'exec /opt/bin/frankenphp php-cli "$@"' >> /usr/bin/php && chmod +x /usr/bin/php && \
ln -s /usr/bin/nano /usr/bin/pico && \
# Create basic directories.
bash -c 'umask 0000 && mkdir -p /temp_data/ /opt/{app,bin,config} /config/{backup,cache,config,db,debug,logs,webhooks,profiler}' && \
# Link console.

72
FAQ.md
View File

@@ -7,7 +7,7 @@ To turn on automatic import or export tasks:
1. Go to the `Tasks` page in the WebUI.
2. Enable the tasks you want to schedule for automatic execution.
By default:
By default, the tasks are scheduled:
- **Import** task runs every **1 hour**
- **Export** task runs every **1 hour and 30 minutes**
@@ -17,7 +17,9 @@ If you want to customize the schedule, you can do so by adding environment varia
- **WS_CRON_IMPORT_AT**
- **WS_CRON_EXPORT_AT**
You can set these variables from the <code>Env</code> page.
You can set these variables from the <!--i:fa-tasks--> **Tasks**, underneath the task click on the timer, and it will
take you to the <!--i:fa-cogs--> **Env** page, where you can set the cron expression directly. Or you can go to
<!--i:fa-cogs--> **Env** page and add the variables manually.
> [!NOTE]
> A great tool to validate your cron expression is [crontab.guru](https://crontab.guru/)
@@ -119,7 +121,7 @@ state is preserved.
However, if the new backend's state is incorrect, it may unintentionally override your accurate local watch history.
## How to Fix the the play state
## How to Fix the play state
To synchronize both backends correctly:
@@ -221,7 +223,7 @@ digits, well automatically prefix it with `user_`.
----
# Does WatchState requires Webhooks to work?
# Does WatchState require Webhooks to work?
No, webhooks are **not required** for the tool to function. You can use the built-in **scheduled tasks** or manually run
**import/export operations** on demand through the WebUI or console.
@@ -421,7 +423,7 @@ After updating the environment variables, **restart the container** to apply the
# How to get WatchState working with YouTube content/library?
Due to the nature on how people name their youtube files i had to pick something specific for it to work cross supported
Due to the nature on how people name their youtube files I had to pick something specific for it to work cross supported
media agents. Please visit [this link](https://github.com/arabcoders/jf-ytdlp-info-reader-plugin#usage) to know how to
name your files. Please be aware these plugins and scanners `REQUIRE`
that you have a `yt-dlp` `.info.json` files named exactly as your media file.
@@ -454,44 +456,22 @@ relevant data if they are not matching correctly, and we hopefully can resolve i
# How to check if the container able to communicate with the media backends?
If you having problem adding a backend to `WatchState`, it most likely network related problem, where the container
If you're having problem adding a backend to `WatchState`, it most likely network related problem, where the container
isn't able to communicate with the media backend. Thus, you will get errors. To make sure the container is able to
communicate with the media backend, you can run the following command and check the output.
communicate with the media backend, run the following tests via
<!--i:fa-tools--> **Tools** > <!--i:fa-external-link--> **URL Checker**.
If the command fails for any reason, then you most likely have network related problem or invalid apikey/token.
## For Plex.
```bash
$ docker exec -ti watchstate bash
$ curl -H "Accept: application/json" -H "X-Plex-Token: [PLEX_TOKEN]" http://[PLEX_URL]/
```
From the `Pre-defined` templates select the media server you want to test against and replace the following with your
info
* Replace `[PLEX_TOKEN]` with your plex token.
* Replace `[PLEX_URL]` with your plex url. The one you selected when prompted by the command.
* Replace `[API_KEY]` with your jellyfin/emby api key.
* Replace `[IP:port]` with your media backend host or ip:port. it can be a host or ip:port i.e. `media.mydomain.ltd`
or `172.23.0.11:8096`.
```
{"MediaContainer":{"size":25,...}}
```
If everything is working correctly you should see something like this previous json output.
## For Jellyfin & Emby.
```bash
$ docker exec -ti watchstate bash
$ curl -v -H "Accept: application/json" -H "X-MediaBrowser-Token: [BACKEND_API_KEY]" http://[BACKEND_HOST]/System/Info
```
* Replace `[BACKEND_API_KEY]` with your jellyfin/emby api key.
* Replace `[BACKEND_HOST]` with your jellyfin/emby host. it can be a host or ip:port i.e. `jf.mydomain.ltd`
or `172.23.0.11:8096`
```
{"OperatingSystemDisplayName":"Linux","HasPendingRestart":false,"IsShuttingDown":false,...}}
```
If everything is working correctly you should see something like this previous json output.
If everything is working correctly you should see `200 Status code response.` with green text. this good indicator that
the container is able to communicate with the media backend. If you see `403` or `404` or any other error code, please
check your backend settings and make sure the token is correct and the ip:port is reachable from the container.
----
@@ -581,10 +561,10 @@ services:
```
This setup should work for VAAPI encoding in `x86_64` containers, There are currently an issue with nvidia h264_nvenc
encoding, the alpine build for`ffmpeg` doesn't include the codec. i am looking for a way include the codec without
encoding, the alpine build for`ffmpeg` doesn't include the codec. I am looking for a way include the codec without
ballooning the image size by 600MB+. If you have a solution please let me know.
Please know that your `video`, `render` group id might be different then mine, you can run the follow command in docker
Please know that your `video`, `render` group id might be different from mine, you can run the follow command in docker
host server to get the group ids for both groups.
```bash
@@ -690,11 +670,7 @@ To check if there is any watch progress events being registered, You can go to `
`on_progress` events, if you are seeing those, this means the progress is being synced. Check the `Tasks logs` to see
the event log.
If this is setup and working you may be ok with changing the `WS_CRON_IMPORT_AT/WS_CRON_EXPORT_AT` schedule to something
less frequent as the sync progress working will update the progress near realtime. For example you could change these
tasks to run daily instead of hourly.
```
WS_CRON_IMPORT_AT=0 11 * * *
WS_CRON_EXPORT_AT=30 11 * * *
```
If this is set up and working you may be ok with changing the `WS_CRON_IMPORT_AT/WS_CRON_EXPORT_AT` schedule to
something less frequent as the sync progress working will update the progress near realtime. For example, you could
change these tasks to run daily to know how to do that please
check [this FAQ entry](#how-to-enable-scheduledautomatic-tasks).

View File

@@ -232,13 +232,19 @@ return (function () {
'pcre.jit' => 1,
'opcache.enable' => 1,
'opcache.memory_consumption' => 128,
'opcache.interned_strings_buffer' => 8,
'opcache.interned_strings_buffer' => 16,
'opcache.max_accelerated_files' => 10000,
'opcache.max_wasted_percentage' => 5,
'opcache.validate_timestamps' => $inContainer ? 0 : 1,
'expose_php' => 0,
'date.timezone' => ag($config, 'tz', 'UTC'),
'zend.assertions' => -1
'zend.assertions' => -1,
'short_open_tag' => 0,
'opcache.jit' => 'disabled',
'opcache.jit_buffer_size' => 0,
// @TODO: keep jit disabled for now, as it is not stable yet,. and we haven't tested it with frankenphp
//'opcache.jit' => $inContainer ? 'tracing' : 'disabled',
//'opcache.jit_buffer_size' => $inContainer ? '128M' : 0,
],
'fpm' => [
'global' => [

View File

@@ -163,7 +163,7 @@ html {
}
.has-text-purple {
color: #5f00d1;
color: #5f00d1 !important;
}
@media screen and (min-width: 769px), print {

View File

@@ -72,6 +72,11 @@
<span>Processes</span>
</NuxtLink>
<NuxtLink class="navbar-item" to="/url_check" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-external-link"/></span>
<span>URL Checker</span>
</NuxtLink>
<hr class="navbar-divider">
<NuxtLink class="navbar-item" to="/parity" @click.native="(e) => changeRoute(e)">

View File

@@ -0,0 +1,448 @@
<template>
<div>
<div class="columns is-multiline">
<div class="column is-12 is-clearfix is-unselectable">
<span class="title is-4">
<span class="icon"><i class="fas fa-external-link"/></span>
URL Checker
</span>
<div class="is-pulled-right" v-if="response.response?.status">
<div class="field is-grouped">
<p class="control">
<button class="button is-info" @click="() => copyText(JSON.stringify(response,null,2))"
v-tooltip.bottom="'Copy request & response.'">
<span class="icon"><i class="fas fa-copy"/></span>
</button>
</p>
</div>
</div>
<div class="is-hidden-mobile">
<span class="subtitle">Check if <strong>WatchState</strong> is able to communicate with the given URL.</span>
</div>
</div>
<div class="column is-12">
<h1 class="title is-4 is-clickable" @click="toggleForm">
<span class="icon" v-if="response.response.status && !invalid_form">
<i class="fas" :class="{'fa-arrow-up': toggle_form, 'fa-arrow-down': !toggle_form}"/>
</span>
Request Form
</h1>
<Message message_class="has-background-warning-80 has-text-dark" v-if="has_template_values()">
<p>
The form contains <strong>template values</strong> <code>[...]</code>. Please make sure to replace them
with the actual values.
</p>
</Message>
</div>
<div class="column is-12" v-if="toggle_form || !item.url">
<form @submit.prevent="check_url">
<div class="box content">
<div class="field">
<label class="label is-unselectable" for="url">Pre-defined template</label>
<div class="control">
<div class="select is-fullwidth">
<select v-model="use_template" :disabled="is_loading">
<option value="" v-text="'Select a template'" disabled/>
<option v-for="template in templates" :key="template.key" :value="template.key"
v-text="template.key"/>
</select>
</div>
</div>
<p class="help is-bold">
<span class="icon"><i class="fas fa-info-circle"/></span>
Gives a pre-defined template for the URL to check.
</p>
</div>
<div class="field">
<label class="label is-unselectable" for="url">URL</label>
<div class="field is-grouped">
<div class="control">
<div class="select is-fullwidth">
<select v-model="item.method" :disabled="is_loading">
<option v-for="method in methods" :key="method" :value="method" v-text="method"/>
</select>
</div>
</div>
<div class="control is-expanded has-icons-left">
<input class="input" type="text" id="url" v-model="item.url" autocomplete="off"
placeholder="https://example.com/api/v1/" :disabled="is_loading">
<div class="icon is-left"><i class="fas fa-link"/></div>
</div>
</div>
<p class="help is-bold">
<span class="icon"><i class="fas fa-info-circle"/></span>
The URL to check. It must be a valid URL.
</p>
</div>
<div class="field">
<label class="label is-unselectable">
Headers -
<NuxtLink @click="add_header()" v-text="'Add'"/>
</label>
<div class="control mb-2" v-for="(header, index) in item.headers" :key="header.index">
<div class="field is-grouped">
<div class="control is-expanded">
<input class="input" type="text" v-model="header.key" placeholder="Header Key" required
:disabled="is_loading">
</div>
<div class="control is-expanded">
<input class="input" type="text" v-model="header.value" placeholder="Header Value" required
:disabled="is_loading">
</div>
<div class="control">
<button class="button is-danger" type="button" @click="item.headers.splice(index, 1)"
:disabled="is_loading">
<span class="icon"><i class="fas fa-times"/></span>
</button>
</div>
</div>
</div>
</div>
<div class="field is-grouped">
<div class="control is-expanded">
<button class="button is-fullwidth is-primary" type="submit" :disabled="invalid_form || is_loading"
:class="{'is-loading': is_loading}">
<span class="icon-text">
<span class="icon"><i class="fas fa-paper-plane"/></span>
<span>Send Request</span>
</span>
</button>
</div>
<div class="control is-expanded">
<button class="button is-fullwidth is-danger" type="button" @click="reset_form" :disabled="is_loading">
<span class="icon-text">
<span class="icon"><i class="fas fa-times"/></span>
<span>Reset Form</span>
</span>
</button>
</div>
</div>
</div>
</form>
</div>
<div class="column is-6-tablet is-12-mobile" v-if="response.response.status">
<div class="card">
<div class="card-header is-clickable" @click="toggle_request = !toggle_request">
<div class="card-header-title is-block is-ellipsis">
<span class="is-underlined has-text-danger">{{ response.request.method }}</span>
{{ response.request.url }}
</div>
<button class="card-header-icon">
<span class="icon">
<i class="fas" :class="{'fa-arrow-up': toggle_request, 'fa-arrow-down': !toggle_request}"/>
</span>
</button>
</div>
<div class="card-content content p-0 m-0" v-if="toggle_request">
<div style="height: 300px" class="is-overflow-auto">
<div class="table-container">
<table class="table is-fullwidth is-hoverable is-striped" style="table-layout: fixed;">
<thead>
<tr>
<th class="has-text-centered" style="min-width:150px">Header</th>
<th>Value</th>
</tr>
</thead>
<tbody v-if="Object.keys(response.request?.headers ?? {}).length > 0">
<tr v-for="(v,k) in response.request.headers" :key="k">
<td class="is-vcentered is-ellipsis">
<abbr :title="uc_words(k)" v-text="uc_words(k)" class="is-pointer-help"/>
</td>
<td class="is-vcentered">{{ v }}</td>
</tr>
</tbody>
<tbody v-else>
<tr>
<td colspan="2" class="has-text-centered">No request headers found.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="column is-6-tablet is-12-mobile" v-if="response.response.status">
<div class="card">
<div class="card-header is-clickable" @click="toggle_response = !toggle_response">
<div class="card-header-title is-block is-ellipsis">
<span class="is-underlined" :class="colorStatus(response.response.status)">{{
response.response.status
}}</span>
Status code response.
</div>
<button class="card-header-icon">
<span class="icon">
<i class="fas" :class="{'fa-arrow-up': toggle_response, 'fa-arrow-down': !toggle_response}"/>
</span>
</button>
</div>
<div class="card-content content p-0 m-0" v-if="toggle_response">
<div style="height: 300px" class="is-overflow-auto">
<div class="table-container">
<table class="table is-fullwidth is-bordered is-hoverable is-striped" style="table-layout: fixed;">
<thead>
<tr>
<th class="has-text-centered" style="width:150px">Header</th>
<th>Value</th>
</tr>
</thead>
<tbody v-if="Object.keys(response.response?.headers ?? {}).length > 0">
<tr v-for="(v,k) in response.response.headers" :key="k">
<td class="is-vcentered is-ellipsis">
<abbr :title="uc_words(k)" v-text="uc_words(k)" class="is-pointer-help"/>
</td>
<td class="is-vcentered" :class="colorize(k)">{{ v }}</td>
</tr>
</tbody>
<tbody v-else>
<tr>
<td colspan="2" class="has-text-centered">No response headers found.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="column is-12" v-if="response.response.status">
<div class="card">
<div class="card-header is-clickable" @click="toggle_body = !toggle_body">
<div class="card-header-title is-block is-ellipsis" :class="colorStatus(response.response.status)">
( <span class="is-underlined">{{ response.response.status }}</span> ) Response Body
</div>
<button class="card-header-icon">
<span class="icon">
<i class="fas" :class="{'fa-arrow-up': toggle_body, 'fa-arrow-down': !toggle_body}"/>
</span>
</button>
</div>
<div class="card-content content p-0 m-0" v-if="toggle_body">
<div style="max-height: 300px" class="is-overflow-auto">
<pre><code>{{
response.response.body ? tryParse(response.response.body) : 'Empty body'
}}</code></pre>
</div>
</div>
</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>
Values in the form with <code>[...]</code> are <strong>template values</strong>. If they are part of a
string, please only replace the bracket and the value inside it. For example, <code>[ip:port]</code>
should be replaced with <code>192.168.8.1:8096</code>.
</li>
<li>
If you see a <span class="has-text-success">green status code (200-299)</span>, it means the request was
successful.
</li>
<li>
If you see a <span class="has-text-danger">red status code (400-499)</span>, it means the request was
rejected. by the target or the WatchState.
</li>
<li>
If you see a <span class="has-text-warning">yellow status code (300-399)</span>, it means the request was
redirected. This is not necessarily an error or successful request, but you should check the response and
follow the redirect.
</li>
<li>
If you see a <span class="has-text-purple">purple status code (500+)</span>, it means the server
encountered an error.
</li>
<li>You can add this special header <code>ws-timeout</code> to control the connection timeout for the http
library.
</li>
</ul>
</Message>
</div>
</div>
</div>
</template>
<script setup>
import request from "~/utils/request.js";
import {notification} from "~/utils/index.js";
import Message from "~/components/Message.vue";
import {useStorage} from "@vueuse/core";
useHead({title: 'URL Checker'})
const show_page_tips = useStorage('show_page_tips', true)
const toggle_form = ref(true)
const toggle_request = ref(true)
const toggle_response = ref(true)
const toggle_body = ref(true)
const use_template = ref("")
const templates = ref([
{
"key": "Plex Media Server",
"override": {
"method": "GET",
"url": "http://[ip:port]/library/sections",
"headers": [
{"key": "Accept", "value": "application/json"},
{"key": "X-Plex-Token", "value": "[PLEX_TOKEN]"},
]
},
},
{
"key": "Jellyfin/Emby Server",
"override": {
"method": "GET",
"url": "http://[ip:port]/items",
"headers": [
{"key": "Accept", "value": "application/json"},
{"key": "X-MediaBrowser-Token", "value": "[API_KEY]"},
]
},
},
])
const methods = ref(['GET', 'POST', 'PUT', 'PATCH', 'HEAD', 'DELETE'])
const item = ref({"url": "", "method": "GET", "headers": []})
const is_loading = ref(false)
const default_response = {
request: {url: "", method: "GET", headers: []},
response: {status: null, headers: [], body: ""}
}
const response = ref(default_response)
watch(use_template, async newValue => {
if ("" === newValue) {
return
}
const template = templates.value.find(t => t.key === newValue)
if (!template) {
notification('error', 'Error', 'Template not found')
return
}
item.value = template.override
await nextTick()
use_template.value = ""
})
const reset_form = async () => item.value = default_response.request
const invalid_form = computed(() => {
if (!item.value.url) {
return true
}
if (!item.value.method) {
return true
}
try {
new URL(item.value.url)
} catch (e) {
return true
}
return false;
});
const has_template_values = () => {
if (/\[.+?]/.test(item.value.url)) {
return true
}
for (const header of item.value.headers) {
if (/\[.+?]/.test(header.key) || /\[.+?]/.test(header.value)) {
return true
}
}
return false;
}
const add_header = (k, v) => item.value.headers.push({key: k ?? "", value: v ?? ""})
const check_url = async () => {
if (true === invalid_form.value) {
notification('error', 'Error', 'Please fill in all required fields.')
return
}
if (has_template_values() && !confirm('The form contains template values. Do you want to continue?')) {
return
}
try {
response.value = default_response
await nextTick()
is_loading.value = true
const resp = await request('/system/url/check', {
method: 'POST',
body: JSON.stringify(item.value),
});
const json = await parse_api_response(resp)
if (200 !== resp.status) {
notification('error', 'Error', `${json.error.code ?? resp.status}: ${json.error.message ?? 'Unknown error'}`)
return;
}
response.value = json;
toggle_form.value = false
toggle_request.value = false
} catch (e) {
notification('error', `failed to send request. ${e}`);
} finally {
is_loading.value = false
}
}
const uc_words = str => str.replace(/_/g, ' ').replace(/\b\w/g, char => char.toUpperCase());
const tryParse = body => {
try {
return JSON.stringify(JSON.parse(body), null, 2)
} catch (e) {
return body
}
}
const colorStatus = status => {
if (status >= 200 && status < 300) {
return 'has-text-success'
} else if (status >= 300 && status < 400) {
return 'has-text-warning'
} else if (status >= 400 && status < 500) {
return 'has-text-danger'
} else if (status >= 500) {
return 'has-text-purple'
}
}
const toggleForm = () => {
if (!item.value.url) {
toggle_form.value = true
return
}
toggle_form.value = !toggle_form.value
}
const colorize = k => k.toLowerCase().startsWith('ws-') ? 'has-text-danger' : ''
onMounted(() => disableOpacity())
onBeforeUnmount(() => enableOpacity())
</script>

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\API\System;
use App\Libs\Attributes\Route\Post;
use App\Libs\DataUtil;
use App\Libs\Enums\Http\Method;
use App\Libs\Enums\Http\Status;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Symfony\Contracts\HttpClient\HttpClientInterface as iHttp;
use Throwable;
final class UrlChecker
{
public const string URL = '%{api.prefix}/system/url/check';
#[Post(self::URL . '[/]', name: 'system.url.check')]
public function __invoke(iRequest $request, iHttp $client): iResponse
{
$params = DataUtil::fromRequest($request);
if (null === ($url = $params->get('url', null))) {
return api_error('No url was given.', Status::BAD_REQUEST);
}
if (false === isValidURL($url)) {
return api_error('Invalid url.', Status::BAD_REQUEST);
}
if (null === ($method = Method::tryFrom(strtoupper($params->get('method', 'GET'))))) {
return api_error('Invalid request method.', Status::BAD_REQUEST);
}
$headers = [];
$timeout = 10;
foreach ($params->get('headers', []) as $header) {
$headerKey = ag($header, 'key');
$headerValue = ag($header, 'value');
if (empty($headerKey) || empty($headerValue)) {
continue;
}
if ('ws-timeout' === $headerKey) {
$timeout = (int)$headerValue;
continue;
}
$headers[$headerKey] = $headerValue;
}
try {
$response = $client->request($method->value, $url, [
'timeout' => $timeout,
'headers' => $headers
]);
$flattenedHeaders = [];
foreach ($response->getHeaders(false) as $key => $value) {
if (is_array($value)) {
$flattenedHeaders[$key] = implode(', ', $value);
} else {
$flattenedHeaders[$key] = $value;
}
}
return api_response(Status::OK, [
'request' => [
'url' => $url,
'method' => $method->value,
'headers' => $headers,
],
'response' => [
'status' => $response->getStatusCode(),
'headers' => $flattenedHeaders,
'body' => $response->getContent(false),
],
]);
} catch (Throwable $e) {
return api_response(Status::OK, [
'request' => [
'url' => $url,
'method' => $method->value,
'headers' => $headers,
],
'response' => [
'status' => Status::INTERNAL_SERVER_ERROR->value,
'headers' => [
'WS-Exception' => $e::class,
'WS-Error' => $e->getMessage(),
],
'body' => $e->getMessage(),
],
]);
}
}
}