Compile the WebUI and response with it if webui is enabled.

This commit is contained in:
abdulmohsen
2024-05-01 17:26:02 +03:00
parent a3f0026363
commit 1a2ed6b377
16 changed files with 590 additions and 563 deletions

View File

@@ -4,3 +4,5 @@
./var/*
!./var/.gitignore
.phpunit.result.cache
frontend/.nuxt
frontend/node_modules

View File

@@ -1,3 +1,9 @@
FROM node:lts-alpine as npm_builder
WORKDIR /frontend
COPY frontend ./
RUN yarn install --frozen-lockfile && npx nuxi@latest generate
FROM alpine:edge
COPY --from=composer:2 /usr/bin/composer /opt/bin/composer
@@ -35,9 +41,11 @@ RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime && echo ${TZ} > /etc/timezo
useradd -u ${USER_ID:-1000} -U -d /config -s /bin/bash user
# Copy source code to container.
#
COPY ./ /opt/app
# Copy frontend to public directory.
COPY --chown=app:app --from=npm_builder /frontend/exported/ /opt/app/public/exported/
# Link PHP if needed.
RUN if [ ! -f /usr/bin/php ]; then ln -s /usr/bin/php${PHP_V:3} /usr/bin/php; fi

28
FAQ.md
View File

@@ -315,6 +315,7 @@ These environment variables relates to the tool itself, you can load them via th
| WS_TRUST_HEADER | string | Which header contain user true IP. | `X-Forwarded-For` |
| WS_LIBRARY_SEGMENT | integer | Paginate backend library items request. Per request get total X number. | `1000` |
| WS_CACHE_URL | string | Cache server URL. | `redis://127.0.0.1:6379` |
| WS_WEBUI_ENABLED | bool | Enable Web UI. | `false` |
> [!IMPORTANT]
> for environment variables that has `{TASK}` tag, you **MUST** replace it with one
@@ -342,7 +343,8 @@ $ docker exec -ti watchstate console system:tasks
The Webhook URL is backend specific, the request path is `/v1/api/backends/[BACKEND_NAME]/webhook?apikey=[APIKEY]`,
Where `[BACKEND_NAME]` is the name of the backend you want to add webhook for, and `[APIKEY]` is the global api key
which you can get via the `system:apikey` command. Typically, the full path is `http://localhost:8080/v1/api/backends/[BACKEND_NAME]/webhook?apikey=[APIKEY]`. if the tool
which you can get via the `system:apikey` command. Typically, the full path
is `http://localhost:8080/v1/api/backends/[BACKEND_NAME]/webhook?apikey=[APIKEY]`. if the tool
port is directly exposed or via the reverse proxy you have setup.
If your media backend support sending headers then remove query parameter `?apikey=[APIKEY]`, and add this header
@@ -540,7 +542,8 @@ https://watchstate.example.org {
Set this environment variable in your `docker-compose.yaml` file `WS_DISABLE_CACHE` with value of `1`.
to use external redis server you need to alter the value of `WS_CACHE_URL` environment variable. the format for this
variable is `redis://host:port?password=auth&db=db_num`, for example to use redis from another container you could use
something like `redis://172.23.1.10:6379?password=my_secert_password&db=8`. We only support `redis` and API compatible alternative.
something like `redis://172.23.1.10:6379?password=my_secert_password&db=8`. We only support `redis` and API compatible
alternative.
Once that done, restart the container.
@@ -624,7 +627,8 @@ If everything is working correctly you should see something like this previous j
### I keep receiving this warning in log `INFO: Ignoring [xxx] Episode range, and treating it as single episode. Backend says it covers [00-00]`?
We recently added guard clause to prevent backends from sending possibly invalid episode ranges, as such if you see this,
We recently added guard clause to prevent backends from sending possibly invalid episode ranges, as such if you see
this,
this likely means your backend mis-identified episodes range. By default, we allow an episode to cover up to 4 episodes.
If this is not enough for your library content. fear not we have you covered you can increase the limit by running the
@@ -644,17 +648,20 @@ to inform you about the issue.
### I Keep receiving [jellyfin] item [id: name] is marked as [played] vs local state [unplayed], However due to the remote item date [date] being older than the last backend sync date [date]. it was not considered as valid state.
Sadly, this is due to bug in jellyfin, where it marks the item as played without updating the LastPlayedDate, and as such, watchstate doesn't really know the item has changed since last sync.
Sadly, this is due to bug in jellyfin, where it marks the item as played without updating the LastPlayedDate, and as
such, watchstate doesn't really know the item has changed since last sync.
Unfortunately, there is no way to fix this issue from our side for the `state:import` task as it working as intended.
However, we managed to somewhat implement a workaround for this issue using the webhooks feature as temporary fix. Until jellyfin devs fixes the issue. Please take look at
However, we managed to somewhat implement a workaround for this issue using the webhooks feature as temporary fix. Until
jellyfin devs fixes the issue. Please take look at
the webhooks section to enable it.
---
### Bare metal installation
We officially only support the docker container, however for the brave souls who want to install the tool directly on their server,
We officially only support the docker container, however for the brave souls who want to install the tool directly on
their server,
You can follow these steps.
#### Requirements
@@ -662,8 +669,10 @@ You can follow these steps.
* [PHP 8.3](http://https://www.php.net/downloads.php) with both the `CLI` and `fpm` mode.
* PHP Extensions `pdo`, `pdo-sqlite`, `mbstring`, `json`, `ctype`, `curl`, `redis`, `sodium` and `simplexml`.
* [Composer](https://getcomposer.org/download/) for dependency management.
* [Redis-server](https://redis.io/) for caching or a compatible implementation that works with [php-redis](https://github.com/phpredis/phpredis).
* [Caddy](https://caddyserver.com/) for frontend handling. However, you can use whatever you like. As long as it has support for fastcgi.
* [Redis-server](https://redis.io/) for caching or a compatible implementation that works
with [php-redis](https://github.com/phpredis/phpredis).
* [Caddy](https://caddyserver.com/) for frontend handling. However, you can use whatever you like. As long as it has
support for fastcgi.
#### Installation
@@ -680,7 +689,8 @@ $ cd watchstate
$ composer install --no-dev
```
3. Create `.env` inside `./var/config/` if you need to change any of the environment variables refer to [Tool specific environment variables](#tool-specific-environment-variables) for more information. For example,
3. Create `.env` inside `./var/config/` if you need to change any of the environment variables refer
to [Tool specific environment variables](#tool-specific-environment-variables) for more information. For example,
if your `redis` server is not on the same server or requires a password you can add the following to the `.env` file.
```dotenv

View File

@@ -36,6 +36,9 @@ return (function () {
'backend' => '[a-zA-Z0-9_-]+',
],
],
'webui' => [
'enabled' => (bool)env('WS_WEBUI_ENABLED', false),
],
'database' => [
'version' => 'v01',
],

View File

@@ -9,18 +9,16 @@ http:// {
header * ?X-Request-Id "{http.request.uuid}"
try_files {path} exported/{path} /index.php
php_fastcgi 127.0.0.1:9000 {
trusted_proxies private_ranges
env X_REQUEST_ID "{http.request.uuid}"
}
log
file_server {
hide .*
}
# Disabled as workaround for arm/v7 build.
#
#log {
# format transform `{request>headers>X-Forwarded-For>[0]:request>remote_ip} - [{ts}] "{request>method} {request>uri} {request>proto}" {status} {size} "{request>headers>Referer>[0]}" "{request>headers>User-Agent>[0]}" - "{resp_headers>X-Request-Id>[0]}" - "{resp_headers>X-Application-Version>[0]}"` {
# time_format "02/Jan/2006:15:04:05 -0700"
# }
#}
log
}

View File

@@ -107,3 +107,7 @@ hr {
.is-paddingless {
padding: 0 !important;
}
.is-bold {
font-weight: bold;
}

View File

@@ -0,0 +1,14 @@
<template>
<div class="notification is-warning">
<h1 class="title is-5">There is no <em class="is-bold">{{ api_var }}</em> configured.</h1>
<p>Please configure the API connection using the button <i class="fa fa-cog"></i> in the top right corner of the
page.</p>
</div>
</template>
<script setup>
import {useStorage} from "@vueuse/core";
const api_url = useStorage('api_url', '')
const api_var = computed(() => (!api_url.value) ? 'API URL' : 'API token')
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div class="container">
<nav class="navbar is-dark">
<nav class="navbar is-dark mb-4">
<div class="navbar-brand pl-5">
<NuxtLink class="navbar-item" href="/">
<span class="icon-text">
@@ -37,27 +37,84 @@
</span>
</a>
</div>
<div class="navbar-end">
<div class="navbar-end pr-3">
<div class="navbar-item">
<button class="button is-dark has-tooltip-bottom" @click="selectedTheme = 'light'"
v-if="'dark' === selectedTheme">
<span class="icon is-small is-left has-text-warning">
<button class="button is-dark" @click="selectedTheme = 'light'" v-if="'dark' === selectedTheme"
v-tooltip="'Switch to light theme'">
<span class="icon is-small has-text-warning">
<i class="fas fa-sun"></i>
</span>
</button>
<button class="button is-dark has-tooltip-bottom" @click="selectedTheme = 'dark'"
v-if="'light' === selectedTheme">
<span class="icon is-small is-left">
<button class="button is-dark" @click="selectedTheme = 'dark'" v-if="'light' === selectedTheme"
v-tooltip="'Switch to dark theme'">
<span class="icon is-small">
<i class="fas fa-moon"></i>
</span>
</button>
</div>
<div class="navbar-item">
<button class="button is-dark" @click="showConnection = !showConnection" v-tooltip="'Configure connection'">
<span class="icon is-small">
<i class="fas fa-cog"></i>
</span>
</button>
</div>
</div>
</div>
</nav>
<div class="columns">
<div class="columns is-multiline">
<div class="column is-12 mt-2" v-if="showConnection">
<form class="box" @submit.prevent="testApi">
<div class="field">
<label class="label" for="api_url">
<span class="icon-text">
<span class="icon"><i class="fas fa-link"></i></span>
<span>API URL</span>
</span>
</label>
<div class="control">
<input class="input" id="api_url" type="url" v-model="api_url" required
placeholder="API URL... http://localhost:8081"
@keyup="api_status = false; api_response = ''">
<p class="help">
Use <a href="javascript:void(0)" @click="setOrigin">current page URL</a>.
</p>
</div>
</div>
<div class="field">
<label class="label" for="api_token">
<span class="icon-text">
<span class="icon"><i class="fas fa-key"></i></span>
<span>API Token</span>
</span>
</label>
<div class="control">
<input class="input" id="api_token" type="text" v-model="api_token" required placeholder="API Token..."
@keyup="api_status = false; api_response = ''">
</div>
<p class="help">Can be obtained by using the <code>system:apikey</code> command.</p>
</div>
<div class="field is-grouped has-addons-right">
<div class="control is-expanded">
<input class="input" type="text" v-model="api_response" readonly disabled
:class="{'has-background-success': true===api_status}">
<p class="help">These settings are stored locally in your browser.</p>
</div>
<div class="control">
<button type="submit" class="button is-primary" :disabled="!api_url || !api_token">
<span class="icon-text">
<span class="icon"><i class="fas fa-signs-post"></i></span>
<span>Check</span>
</span>
</button>
</div>
</div>
</form>
</div>
<div class="column is-12">
<slot/>
</div>
@@ -80,7 +137,13 @@ import 'assets/css/style.css'
import 'assets/css/all.css'
import {useStorage} from '@vueuse/core'
const selectedTheme = useStorage('theme', (() => window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')());
const selectedTheme = useStorage('theme', (() => window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')())
const showConnection = ref(false)
const api_url = useStorage('api_url', '')
const api_token = useStorage('api_token', '')
const api_status = ref(false)
const api_response = ref('Status: Unknown')
const Year = ref(new Date().getFullYear())
const showMenu = ref(false)
@@ -93,34 +156,34 @@ const applyPreferredColorScheme = (scheme) => {
if (rule && rule.media && rule.media.mediaText.includes("prefers-color-scheme")) {
switch (scheme) {
case "light":
rule.media.appendMedium("original-prefers-color-scheme");
rule.media.appendMedium("original-prefers-color-scheme")
if (rule.media.mediaText.includes("light")) {
rule.media.deleteMedium("(prefers-color-scheme: light)");
rule.media.deleteMedium("(prefers-color-scheme: light)")
}
if (rule.media.mediaText.includes("dark")) {
rule.media.deleteMedium("(prefers-color-scheme: dark)");
rule.media.deleteMedium("(prefers-color-scheme: dark)")
}
break;
case "dark":
rule.media.appendMedium("(prefers-color-scheme: light)");
rule.media.appendMedium("(prefers-color-scheme: dark)");
rule.media.appendMedium("(prefers-color-scheme: light)")
rule.media.appendMedium("(prefers-color-scheme: dark)")
if (rule.media.mediaText.includes("original")) {
rule.media.deleteMedium("original-prefers-color-scheme");
rule.media.deleteMedium("original-prefers-color-scheme")
}
break;
default:
rule.media.appendMedium("(prefers-color-scheme: dark)");
rule.media.appendMedium("(prefers-color-scheme: dark)")
if (rule.media.mediaText.includes("light")) {
rule.media.deleteMedium("(prefers-color-scheme: light)");
rule.media.deleteMedium("(prefers-color-scheme: light)")
}
if (rule.media.mediaText.includes("original")) {
rule.media.deleteMedium("original-prefers-color-scheme");
rule.media.deleteMedium("original-prefers-color-scheme")
}
break;
}
}
} catch (e) {
console.debug(e);
console.debug(e)
}
}
}
@@ -128,15 +191,44 @@ const applyPreferredColorScheme = (scheme) => {
onMounted(() => {
try {
applyPreferredColorScheme(selectedTheme.value);
applyPreferredColorScheme(selectedTheme.value)
} catch (e) {
}
})
watch(selectedTheme, (value) => {
try {
applyPreferredColorScheme(value);
applyPreferredColorScheme(value)
} catch (e) {
}
})
const testApi = async () => {
try {
const response = await fetch(api_url.value + '/v1/api/backends', {
method: 'GET',
headers: {
'Authorization': 'Bearer ' + api_token.value,
'Content-Type': 'application/json'
}
})
const json = await response.json()
if (json.error) {
api_status.value = false;
api_response.value = `Error ${json.error.code} - ${json.error.message}`
return
}
api_status.value = 200 === response.status;
api_response.value = 200 === response.status ? `Status: OK` : `Status: ${response.status} - ${response.statusText}`;
} catch (e) {
api_status.value = false;
api_response.value = `Error: ${e.message}`;
}
}
const setOrigin = () => api_url.value = window.location.origin;
</script>

View File

@@ -22,6 +22,7 @@ export default defineNuxtConfig({
},
modules: [
'@vueuse/nuxt',
'floating-vue/nuxt'
],
nitro: {
output: {

View File

@@ -12,6 +12,7 @@
"dependencies": {
"@vueuse/core": "^10.9.0",
"@vueuse/nuxt": "^10.9.0",
"floating-vue": "^5.2.2",
"nuxt": "^3.11.2",
"vue": "^3.4.21",
"vue-router": "^4.3.0"

View File

@@ -1,3 +1,19 @@
<template>
<p>foo</p>
<template v-if="!api_url || !api_token">
<no-api/>
</template>
<template v-else>
<p>foo</p>
</template>
</template>
<script setup>
import {useStorage} from "@vueuse/core"
import NoApi from "~/components/NoApi.vue"
const api_url = useStorage('api_url', '')
const api_token = useStorage('api_token', '')
useHead({title: 'Index'})
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -4,8 +4,34 @@ declare(strict_types=1);
namespace App\Libs\Extends;
use App\Libs\HTTP_STATUS;
use League\Route\Strategy\ApplicationStrategy;
use League\Route\Strategy\OptionsHandlerInterface;
use Nyholm\Psr7\Response;
use Psr\Http\Message\ResponseInterface as iResponse;
class RouterStrategy extends ApplicationStrategy
class RouterStrategy extends ApplicationStrategy implements OptionsHandlerInterface
{
public function getOptionsCallable(array $methods): callable
{
$headers = [
'Allow' => implode(', ', $methods),
];
$mode = ag($_SERVER, 'HTTP_SEC_FETCH_MODE');
if ('cors' !== $mode) {
return fn(): iResponse => new Response(status: HTTP_STATUS::HTTP_NO_CONTENT->value, headers: $headers);
}
$headers += [
'Access-Control-Max-Age' => 600,
'Access-Control-Allow-Headers' => 'X-Apikey, *',
'Access-Control-Allow-Methods' => implode(', ', $methods),
'Access-Control-Allow-Origin' => '*',
];
return fn(): iResponse => new Response(status: HTTP_STATUS::HTTP_NO_CONTENT->value, headers: $headers);
}
}

View File

@@ -262,8 +262,26 @@ final class Initializer
$apikey = ag($request->getQueryParams(), 'apikey', $request->getHeaderLine('x-apikey'));
if (empty($apikey)) {
$response = api_response(HTTP_STATUS::HTTP_UNAUTHORIZED);
$this->write($request, Level::Info, $this->formatLog($request, $response, 'No webhook token was found.'));
if (false === (bool)Config::get('webui.enabled', false)) {
$response = api_response(HTTP_STATUS::HTTP_UNAUTHORIZED);
$this->write(
$request,
Level::Info,
$this->formatLog($request, $response, 'No webhook token was found.')
);
return $response;
}
if (file_exists(__DIR__ . '/../../public/exported/index.html')) {
return new Response(
status: HTTP_STATUS::HTTP_OK->value,
headers: ['Content-Type' => 'text/html'],
body: Stream::create(fopen(__DIR__ . '/../../public/exported/index.html', 'r'))
);
}
$response = api_response(HTTP_STATUS::HTTP_NOT_FOUND);
$this->write($request, Level::Info, $this->formatLog($request, $response, 'The Frontend is not compiled.'));
return $response;
}
@@ -369,7 +387,9 @@ final class Initializer
if (!$response->hasHeader('X-Log-Response')) {
$response = $response->withHeader('X-Log-Response', '1');
}
return $response;
return $response->withHeader('Access-Control-Allow-Origin', '*')
->withHeader('Access-Control-Allow-Credentials', 'true');
} /** @noinspection PhpRedundantCatchClauseInspection */
catch (RouterHttpException $e) {
throw new HttpException($e->getMessage(), $e->getStatusCode());

View File

@@ -26,6 +26,10 @@ final class APIKeyRequiredMiddleware implements MiddlewareInterface
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if ('OPTIONS' === $request->getMethod()) {
return $handler->handle($request);
}
foreach (self::OPEN_ROUTES as $route) {
$route = parseConfigValue($route);
if (true === str_starts_with($request->getUri()->getPath(), parseConfigValue($route))) {

View File

@@ -22,7 +22,7 @@ trait APITraits
*/
protected function getClient(string $name, array $config = []): iClient
{
$configFile = ConfigFile::open(Config::get('backends_file'), 'yaml');
$configFile = ConfigFile::open(Config::get('backends_file'), 'yaml', autoCreate: true);
if (null === $configFile->get("{$name}.type", null)) {
throw new RuntimeException(r("Backend '{backend}' doesn't exists.", ['backend' => $name]));
@@ -44,7 +44,9 @@ trait APITraits
{
$backends = [];
foreach (ConfigFile::open(Config::get('backends_file'), 'yaml')->getAll() as $backendName => $backend) {
$list = ConfigFile::open(Config::get('backends_file'), 'yaml', autoCreate: true)->getAll();
foreach ($list as $backendName => $backend) {
$backend = ['name' => $backendName, ...$backend];
if (null !== ag($backend, 'import.lastSync')) {