Merge pull request #537 from arabcoders/dev
Migrate state:push to the new event system
This commit is contained in:
2
FAQ.md
2
FAQ.md
@@ -402,7 +402,7 @@ command via CLI.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> for environment variables that has `{TASK}` tag, you **MUST** replace it with one
|
||||
> of `IMPORT`, `EXPORT`, `PUSH`, `BACKUP`, `PRUNE`, `INDEXES`. To see tasks active settings run
|
||||
> of `IMPORT`, `EXPORT`, `BACKUP`, `PRUNE`, `INDEXES`. To see tasks active settings run
|
||||
|
||||
```bash
|
||||
$ docker exec -ti watchstate console system:tasks
|
||||
|
||||
40
NEWS.md
40
NEWS.md
@@ -1,5 +1,45 @@
|
||||
# Old Updates
|
||||
|
||||
### 2024-08-10
|
||||
|
||||
I have recently added new experimental feature, to play your content directly from the WebUI. This feature is still in
|
||||
alpha, and missing a lot of features. But it's a start. Right now it does auto transcode on the fly to play any content in the browser.
|
||||
|
||||
The feature requires that you mount your media directories to the `WatchState` container similar to the `File integrity` feature. I have plans to expand
|
||||
the feature to support more controls, however, right now it's only support basic subtitles streams and default audio stream or first audio stream.
|
||||
|
||||
The transcoder works by converting the media on the fly to `HLS` segments, and the subtitles are selectable via the player ui which are also converted to `vtt` format.
|
||||
|
||||
Expects bugs and issues, as the feature is still in alpha. But I would love to hear your feedback. You can play the media by visiting
|
||||
the history page of the item you will see red play button on top right corner of the page. If the items has a play button, then you correctly mounted
|
||||
the media directories. otherwise, the button be disabled with tooltip of `Media is inaccessible`.
|
||||
|
||||
The feature is not meant to replace your backend media player, the purpose of this feature is to quickly check the media without leaving the WebUI.
|
||||
|
||||
### 2024-08-01
|
||||
|
||||
We recently enabled listening on tls connections via `8443` which can be controlled by `HTTPS_PORT` environment variable.
|
||||
Before today, we simply only exposed the port via the `Dockerfile`, but we weren't listening for connections on it.
|
||||
|
||||
However, please keep in mind that the certificate is self-signed, and you might get a warning from your browser. You can
|
||||
either accept the warning or add the certificate to your trusted certificates. We strongly recommend using a reverse proxy.
|
||||
instead of relying on self-signed certificates.
|
||||
|
||||
### 2024-07-22
|
||||
|
||||
We have recently added a new WebUI feature, `File integrity`, this feature will help you to check if your media backends
|
||||
are reporting files that are not available on the disk. This feature is still in alpha, and we are working on improving
|
||||
it.
|
||||
|
||||
This feature `REQUIRES` that you mount your media directories to the `WatchState` container preferably as readonly. There is plans to add
|
||||
a path replacement feature to allow you change the pathing, but it's not implemented yet.
|
||||
|
||||
This feature will work on both local and remote cloud storages provided they are mounted into the container. We also may recommend not to
|
||||
use this feature depending on how your cloud storage provider treats file stat calls. As it might lead to unnecessary money spending. and of course
|
||||
it will be slower.
|
||||
|
||||
For more information about how we cache the stat calls, please refer to the [FAQ](FAQ.md#How-does-the-file-integrity-feature-works).
|
||||
|
||||
### 2024-07-06
|
||||
|
||||
Recently we have introduced a new feature that allows you to use Jellyfin and Emby OAuth access tokens for syncing
|
||||
|
||||
52
README.md
52
README.md
@@ -9,6 +9,18 @@ out of the box, this tool support `Jellyfin`, `Plex` and `Emby` media servers.
|
||||
|
||||
## Updates
|
||||
|
||||
### 2024-08-19
|
||||
|
||||
We have migrated the `state:push` task into the new events system, as such the old task `state:push` is now gone.
|
||||
To enable the new event handler for push events, use the new environment variable `WS_PUSH_ENABLED` and set it to `true`.
|
||||
Right now, it's disabled by default. However, for people who had the old task enabled, it will reuse that setting.
|
||||
|
||||
Keep in mind, the new event handler is more efficient and will only push data when there is a change in the play state. And it's much faster
|
||||
than the old task. This event handler will push data within a minute of the change.
|
||||
|
||||
PS: Please enable the task by setting its new environment variable `WS_PUSH_ENABLED` to `true`. The old `WS_CRON_PUSH` is now gone.
|
||||
and will be removed in the future releases.
|
||||
|
||||
### 2024-08-18
|
||||
|
||||
We have started migrating the old events system to a new one, so far we have migrated the `progress` and `requests` to it. As such,
|
||||
@@ -17,46 +29,6 @@ environment variable `WS_SYNC_PROGRESS` which you can set to `true` to enable th
|
||||
|
||||
We will continue to migrate the rest of the events to the new system, and we will keep you updated.
|
||||
|
||||
### 2024-08-10
|
||||
|
||||
I have recently added new experimental feature, to play your content directly from the WebUI. This feature is still in
|
||||
alpha, and missing a lot of features. But it's a start. Right now it does auto transcode on the fly to play any content in the browser.
|
||||
|
||||
The feature requires that you mount your media directories to the `WatchState` container similar to the `File integrity` feature. I have plans to expand
|
||||
the feature to support more controls, however, right now it's only support basic subtitles streams and default audio stream or first audio stream.
|
||||
|
||||
The transcoder works by converting the media on the fly to `HLS` segments, and the subtitles are selectable via the player ui which are also converted to `vtt` format.
|
||||
|
||||
Expects bugs and issues, as the feature is still in alpha. But I would love to hear your feedback. You can play the media by visiting
|
||||
the history page of the item you will see red play button on top right corner of the page. If the items has a play button, then you correctly mounted
|
||||
the media directories. otherwise, the button be disabled with tooltip of `Media is inaccessible`.
|
||||
|
||||
The feature is not meant to replace your backend media player, the purpose of this feature is to quickly check the media without leaving the WebUI.
|
||||
|
||||
### 2024-08-01
|
||||
|
||||
We recently enabled listening on tls connections via `8443` which can be controlled by `HTTPS_PORT` environment variable.
|
||||
Before today, we simply only exposed the port via the `Dockerfile`, but we weren't listening for connections on it.
|
||||
|
||||
However, please keep in mind that the certificate is self-signed, and you might get a warning from your browser. You can
|
||||
either accept the warning or add the certificate to your trusted certificates. We strongly recommend using a reverse proxy.
|
||||
instead of relying on self-signed certificates.
|
||||
|
||||
### 2024-07-22
|
||||
|
||||
We have recently added a new WebUI feature, `File integrity`, this feature will help you to check if your media backends
|
||||
are reporting files that are not available on the disk. This feature is still in alpha, and we are working on improving
|
||||
it.
|
||||
|
||||
This feature `REQUIRES` that you mount your media directories to the `WatchState` container preferably as readonly. There is plans to add
|
||||
a path replacement feature to allow you change the pathing, but it's not implemented yet.
|
||||
|
||||
This feature will work on both local and remote cloud storages provided they are mounted into the container. We also may recommend not to
|
||||
use this feature depending on how your cloud storage provider treats file stat calls. As it might lead to unnecessary money spending. and of course
|
||||
it will be slower.
|
||||
|
||||
For more information about how we cache the stat calls, please refer to the [FAQ](FAQ.md#How-does-the-file-integrity-feature-works).
|
||||
|
||||
Refer to [NEWS](NEWS.md) for old updates.
|
||||
|
||||
# Features
|
||||
|
||||
@@ -9,7 +9,6 @@ use App\Commands\Events\DispatchCommand;
|
||||
use App\Commands\State\BackupCommand;
|
||||
use App\Commands\State\ExportCommand;
|
||||
use App\Commands\State\ImportCommand;
|
||||
use App\Commands\State\PushCommand;
|
||||
use App\Commands\System\IndexCommand;
|
||||
use App\Commands\System\PruneCommand;
|
||||
use App\Libs\Mappers\Import\MemoryMapper;
|
||||
@@ -75,7 +74,10 @@ return (function () {
|
||||
'header' => (string)env('WS_TRUST_HEADER', 'X-Forwarded-For'),
|
||||
],
|
||||
'sync' => [
|
||||
'progress' => (bool)env('WS_SYNC_PROGRESS', false),
|
||||
'progress' => (bool)env('WS_SYNC_PROGRESS', (bool)env('WS_CRON_PROGRESS', false)),
|
||||
],
|
||||
'push' => [
|
||||
'enabled' => (bool)env('WS_PUSH_ENABLED', (bool)env('WS_CRON_PUSH', false)),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -266,14 +268,6 @@ return (function () {
|
||||
'timer' => $checkTaskTimer((string)env('WS_CRON_EXPORT_AT', '30 */1 * * *'), '30 */1 * * *'),
|
||||
'args' => env('WS_CRON_EXPORT_ARGS', '-v'),
|
||||
],
|
||||
PushCommand::TASK_NAME => [
|
||||
'command' => PushCommand::ROUTE,
|
||||
'name' => PushCommand::TASK_NAME,
|
||||
'info' => 'Send queued events to backends.',
|
||||
'enabled' => (bool)env('WS_CRON_PUSH', true),
|
||||
'timer' => $checkTaskTimer((string)env('WS_CRON_PUSH_AT', '*/10 * * * *'), '*/10 * * * *'),
|
||||
'args' => env('WS_CRON_PUSH_ARGS', '-v'),
|
||||
],
|
||||
BackupCommand::TASK_NAME => [
|
||||
'command' => BackupCommand::ROUTE,
|
||||
'name' => BackupCommand::TASK_NAME,
|
||||
|
||||
@@ -166,6 +166,11 @@ return (function () {
|
||||
'description' => 'Enable watch progress sync.',
|
||||
'type' => 'bool',
|
||||
],
|
||||
[
|
||||
'key' => 'WS_PUSH_ENABLED',
|
||||
'description' => 'Enable Push play state to backends. This feature depends on webhooks being enabled.',
|
||||
'type' => 'bool',
|
||||
],
|
||||
];
|
||||
|
||||
$validateCronExpression = function (string $value): string {
|
||||
@@ -191,7 +196,7 @@ return (function () {
|
||||
};
|
||||
|
||||
// -- Do not forget to update the tasks list if you add a new task.
|
||||
$tasks = ['import', 'export', 'push', 'backup', 'prune', 'indexes'];
|
||||
$tasks = ['import', 'export', 'backup', 'prune', 'indexes'];
|
||||
$task_env = [
|
||||
[
|
||||
'key' => 'WS_CRON_{TASK}',
|
||||
|
||||
@@ -83,11 +83,6 @@
|
||||
<span>Events</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="navbar-item" to="/old_events" @click.native="(e) => changeRoute(e)">
|
||||
<span class="icon"><i class="fas fa-list"></i></span>
|
||||
<span>Old Events</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="navbar-item" to="/ignore" @click.native="(e) => changeRoute(e)">
|
||||
<span class="icon"><i class="fas fa-ban"></i></span>
|
||||
<span>Ignore List</span>
|
||||
|
||||
@@ -39,13 +39,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline" v-if="items.length < 1">
|
||||
<div class="columns is-multiline" v-if="filteredRows.length < 1">
|
||||
<div class="column is-12">
|
||||
<Message v-if="isLoading" 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 class="has-background-warning-80 has-text-dark" title="Warning"
|
||||
icon="fas fa-exclamation-triangle">
|
||||
<p>No items found.</p>
|
||||
<p v-if="query">Search for <strong>{{ query }}</strong> returned no results.</p>
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,7 +80,7 @@
|
||||
</div>
|
||||
<span class="card-footer-item">
|
||||
<span class="icon"><i class="fas fa-calendar"></i></span>
|
||||
<time class="has-tooltip" v-tooltip="`Created at: ${moment(item.created_at).format(tooltip_dateformat)}`">
|
||||
<time class="has-tooltip" v-tooltip="`Created at: ${moment(item.created_at)}`">
|
||||
{{ moment(item.created_at).fromNow() }}
|
||||
</time>
|
||||
</span>
|
||||
@@ -87,8 +88,7 @@
|
||||
<span v-if="!item.updated_at" class="icon"><i class="fas fa-spinner fa-spin"></i></span>
|
||||
<template v-else>
|
||||
<span class="icon"><i class="fas fa-calendar-alt"></i></span>
|
||||
<time class="has-tooltip"
|
||||
v-tooltip="`Updated at: ${moment(item.updated_at).format(tooltip_dateformat)}`">
|
||||
<time class="has-tooltip" v-tooltip="`Updated at: ${moment(item.updated_at)}`">
|
||||
{{ moment(item.updated_at).fromNow() }}
|
||||
</time>
|
||||
</template>
|
||||
@@ -173,7 +173,7 @@ const filteredRows = computed(() => {
|
||||
|
||||
return items.value.filter(i => {
|
||||
return Object.keys(i).some(k => {
|
||||
if (typeof i[k] === 'object') {
|
||||
if (typeof i[k] === 'object' && null !== i[k]) {
|
||||
return Object.values(i[k]).some(v => typeof v === 'string' ? v.toLowerCase().includes(toTower) : false)
|
||||
}
|
||||
return typeof i[k] === 'string' ? i[k].toLowerCase().includes(toTower) : false
|
||||
|
||||
@@ -46,18 +46,22 @@
|
||||
<div class="column is-12">
|
||||
<div class="notification">
|
||||
<p class="title is-5">
|
||||
Event <span class="tag is-info">{{ item.event }}</span> was created at
|
||||
Event <span class="tag is-info">{{ item.event }}</span>
|
||||
<template v-if="item.reference">
|
||||
with reference <span class="tag is-info is-light">{{ item.reference }}</span>
|
||||
</template>
|
||||
was created
|
||||
<span class="tag is-warning">
|
||||
<time class="has-tooltip" v-tooltip="moment(item.created_at).format(tooltip_dateformat)">
|
||||
{{ moment(item.created_at).fromNow() }}
|
||||
</time>
|
||||
</span>, and last updated at
|
||||
</span>, and last updated
|
||||
<span class="tag is-danger">
|
||||
<span v-if="!item.updated_at">not started</span>
|
||||
<time v-else class="has-tooltip" v-tooltip="moment(item.updated_at).format(tooltip_dateformat)">
|
||||
{{ moment(item.updated_at).fromNow() }}
|
||||
</time>
|
||||
</span>.
|
||||
</span>,
|
||||
with status of <span class="tag" :class="getStatusClass(item.status)">{{ item.status }}:
|
||||
{{ item.status_name }}</span>.
|
||||
</p>
|
||||
|
||||
@@ -429,7 +429,14 @@
|
||||
</span>
|
||||
</span>
|
||||
<div v-if="showRawData" class="mt-2">
|
||||
<code class="is-block is-pre-wrap">{{ JSON.stringify(data, null, 2) }}</code>
|
||||
<code class="is-block is-pre-wrap">{{
|
||||
JSON.stringify(Object.keys(data)
|
||||
.filter(key => !['files', 'hardware', 'content_exists', '_toggle'].includes(key))
|
||||
.reduce((obj, key) => {
|
||||
obj[key] = data[key];
|
||||
return obj;
|
||||
}, {}), null, 2)
|
||||
}}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
<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-list-check"></i></span>
|
||||
Legacy Events
|
||||
</span>
|
||||
<div class="is-pulled-right">
|
||||
<div class="field is-grouped">
|
||||
<p class="control">
|
||||
<button class="button is-info" @click.prevent="loadContent">
|
||||
<span class="icon"><i class="fas fa-sync"></i></span>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="is-hidden-mobile">
|
||||
<span class="subtitle">
|
||||
This page will show events that are queued to be handled or sent to the backends.
|
||||
This endpoint is being deprecated and will be removed in the future, We are migrating to the new
|
||||
<code>events</code> endpoint.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12" v-if="queue.length < 1 && progress.length < 1">
|
||||
<Message v-if="isLoading" 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="is-background-success-90 has-text-dark" title="Information"
|
||||
icon="fas fa-info-circle"
|
||||
message="There are currently no queued events."/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline" v-if="queue.length > 0">
|
||||
<div class="column is-12">
|
||||
<span class="title is-5">
|
||||
<span class="icon"><i class="fas fa-eye"></i></span>
|
||||
State events
|
||||
</span>
|
||||
<div class="subtitle is-hidden-mobile">
|
||||
Events that are changing the play state. Consumed by <code>state:push</code> task.
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-4 is-6-tablet" v-for="i in queue" :key="`queue-${i.key}`">
|
||||
<div class="card" :class="{ 'is-success': i.item.watched }">
|
||||
<header class="card-header">
|
||||
<p class="card-header-title is-text-overflow pr-1">
|
||||
<span class="icon">
|
||||
<i class="fas" :class="{'fa-eye-slash': !i.item.watched,'fa-eye': i.item.watched}"></i>
|
||||
</span>
|
||||
<NuxtLink :to="'/history/'+i.item.id" v-text="makeName(i.item)"/>
|
||||
</p>
|
||||
<span class="card-header-icon">
|
||||
<button class="button is-danger is-small" @click="deleteItem(i.item, 'queue', i.key)">
|
||||
<span class="icon"><i class="fas fa-trash"></i></span>
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
<div class="card-content">
|
||||
<div class="columns is-multiline is-mobile has-text-centered">
|
||||
<div class="column is-12 has-text-left" v-if="i.item?.content_title">
|
||||
<div class="is-text-overflow">
|
||||
<span class="icon"><i class="fas fa-heading"></i> </span>
|
||||
<NuxtLink :to="makeSearchLink('subtitle',i.item.content_title)" v-text="i.item.content_title"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-12 has-text-left" v-if="i.item?.content_path">
|
||||
<div class="is-text-overflow">
|
||||
<span class="icon"><i class="fas fa-file"></i> </span>
|
||||
<NuxtLink :to="makeSearchLink('path',i.item.content_path)" v-text="i.item.content_path"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-12 has-text-left" v-if="i.item?.progress">
|
||||
<div class="is-text-overflow">
|
||||
<span class="icon"><i class="fas fa-bars-progress"></i></span>
|
||||
{{ formatDuration(i.item.progress) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer has-text-centered">
|
||||
<div class="card-footer-item">
|
||||
<div class="is-text-overflow">
|
||||
<span class="icon"><i class="fas fa-calendar"></i> </span>
|
||||
<span class="has-tooltip"
|
||||
v-tooltip="`${getMoment(ag(i.item.extra, `${i.item.via}.received_at`, i.item.updated_at)).format(TOOLTIP_DATE_FORMAT)}`">
|
||||
{{ getMoment(ag(i.item.extra, `${i.item.via}.received_at`, i.item.updated_at)).fromNow() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer-item">
|
||||
<div class="is-text-overflow">
|
||||
<span class="icon"><i class="fas fa-server"></i> </span>
|
||||
<NuxtLink :to="'/backend/'+i.item.via" v-text="i.item.via"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer-item">
|
||||
<div class="is-text-overflow">
|
||||
<span class="icon"><i class="fas fa-envelope"></i> </span>
|
||||
<span>{{ i.item.event ?? '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline" v-if="progress.length > 0">
|
||||
<div class="column is-12">
|
||||
<span class="title is-5">
|
||||
<span class="icon"><i class="fas fa-bars-progress"></i></span>
|
||||
Watch progress events
|
||||
</span>
|
||||
<div class="subtitle is-hidden-mobile">
|
||||
Events that are changing the play progress. Consumed by <code>state:progress</code> task.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-4 is-6-tablet" v-for="i in progress" :key="`progress-${i.key}`">
|
||||
<div class="card" :class="{ 'is-success': i.item.watched }">
|
||||
<header class="card-header">
|
||||
<p class="card-header-title is-text-overflow pr-1">
|
||||
<span class="icon">
|
||||
<i class="fas" :class="{'fa-eye-slash': !i.item.watched,'fa-eye': i.item.watched}"></i>
|
||||
</span>
|
||||
<NuxtLink :to="'/history/'+i.item.id" v-text="makeName(i.item)"/>
|
||||
</p>
|
||||
<span class="card-header-icon">
|
||||
<button class="button is-danger is-small" @click="deleteItem(i.item, 'progress', i.key)">
|
||||
<span class="icon"><i class="fas fa-trash"></i></span>
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
<div class="card-content">
|
||||
<div class="columns is-multiline is-mobile has-text-centered">
|
||||
<div class="column is-12 has-text-left" v-if="i.item?.content_title">
|
||||
<div class="is-text-overflow">
|
||||
<span class="icon"><i class="fas fa-heading"></i> </span>
|
||||
<NuxtLink :to="makeSearchLink('subtitle',i.item.content_title)" v-text="i.item.content_title"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-12 has-text-left" v-if="i.item?.content_path">
|
||||
<div class="is-text-overflow">
|
||||
<span class="icon"><i class="fas fa-file"></i> </span>
|
||||
<NuxtLink :to="makeSearchLink('path',i.item.content_path)" v-text="i.item.content_path"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6 has-text-left">
|
||||
<div class="is-text-overflow">
|
||||
<span class="icon"><i class="fas fa-info-circle"></i></span>
|
||||
is Tainted: {{ i.item?.isTainted ? 'Yes' : 'No' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6 has-text-right" v-if="i.item?.progress">
|
||||
<div class="is-text-overflow">
|
||||
<span class="icon"><i class="fas fa-bars-progress"></i></span>
|
||||
{{ formatDuration(i.item.progress) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer has-text-centered">
|
||||
<div class="card-footer-item">
|
||||
<div class="is-text-overflow">
|
||||
<span class="icon"><i class="fas fa-calendar"></i> </span>
|
||||
<span class="has-tooltip"
|
||||
v-tooltip="`${getMoment(ag(i.item.extra, `${i.item.via}.received_at`, i.item.updated_at)).format(TOOLTIP_DATE_FORMAT)}`">
|
||||
{{ getMoment(ag(i.item.extra, `${i.item.via}.received_at`, i.item.updated_at)).fromNow() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer-item">
|
||||
<div class="is-text-overflow">
|
||||
<span class="icon"><i class="fas fa-server"></i> </span>
|
||||
<NuxtLink :to="'/backend/'+i.item.via" v-text="i.item.via"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer-item">
|
||||
<div class="is-text-overflow">
|
||||
<span class="icon"><i class="fas fa-envelope"></i> </span>
|
||||
<span>{{ i.item.event ?? '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline">
|
||||
<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>
|
||||
Events marked with <code>is Tainted: Yes</code>, are interesting but are too chaotic to be useful be used
|
||||
to
|
||||
determine play state. However, we do use them to update local metadata & play progress.
|
||||
</li>
|
||||
<li>
|
||||
Events marked with <code>is Tainted: No</code>, are events that are used to determine play state.
|
||||
</li>
|
||||
<li>
|
||||
If you are fast enough, you might be able to see the event before it is consumed by the backend. which
|
||||
allow
|
||||
you to delete it from the queue if you desire.
|
||||
</li>
|
||||
</ul>
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import request from '~/utils/request'
|
||||
import moment from 'moment'
|
||||
import Message from '~/components/Message'
|
||||
import {ag, formatDuration, makeName, makeSearchLink, notification, TOOLTIP_DATE_FORMAT} from '~/utils/index'
|
||||
import {useStorage} from '@vueuse/core'
|
||||
|
||||
useHead({title: 'Queue'})
|
||||
|
||||
const queue = ref([])
|
||||
const progress = ref([])
|
||||
const isLoading = ref(false)
|
||||
const show_page_tips = useStorage('show_page_tips', true)
|
||||
|
||||
const loadContent = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
queue.value = []
|
||||
progress.value = []
|
||||
|
||||
const response = await request(`/system/old_events`)
|
||||
let json
|
||||
|
||||
try {
|
||||
json = await response.json()
|
||||
if (useRoute().name !== 'events') {
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
json = {
|
||||
error: {
|
||||
code: response.status,
|
||||
message: response.statusText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
notification('error', 'Error', `${json.error.code}: ${json.error.message}`)
|
||||
return
|
||||
}
|
||||
|
||||
queue.value = json?.queue
|
||||
progress.value = json?.progress
|
||||
} catch (e) {
|
||||
return notification('error', 'Error', e.message)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteItem = async (item, type, key) => {
|
||||
if (!confirm(`Remove '${makeName(item)}' from the '${type}' list?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(`/system/old_events/0`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({type: type, id: key})
|
||||
})
|
||||
|
||||
if (200 !== response.status) {
|
||||
let json
|
||||
|
||||
try {
|
||||
json = await response.json()
|
||||
} catch (e) {
|
||||
json = {
|
||||
error: {
|
||||
code: response.status,
|
||||
message: response.statusText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
notification('error', 'Error', `${json.error.code}: ${json.error.message}`)
|
||||
return
|
||||
}
|
||||
|
||||
notification('success', 'Success', 'Item successfully deleted from queue.')
|
||||
|
||||
switch (type) {
|
||||
case 'queue':
|
||||
queue.value = queue.value.filter(i => i.key !== key)
|
||||
break
|
||||
case 'progress':
|
||||
progress.value = progress.value.filter(i => i.key !== key)
|
||||
break
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
return notification('error', 'Error', e.message)
|
||||
}
|
||||
}
|
||||
const getMoment = (time) => time.toString().length < 13 ? moment.unix(time) : moment(time)
|
||||
|
||||
onMounted(async () => loadContent())
|
||||
</script>
|
||||
@@ -3,7 +3,7 @@ const makeName = id => id.split('-').slice(0)[0]
|
||||
const getStatusClass = status => {
|
||||
switch (status) {
|
||||
case 0:
|
||||
return 'is-light'
|
||||
return 'is-light has-text-dark'
|
||||
case 1:
|
||||
return 'is-warning'
|
||||
case 2:
|
||||
@@ -13,7 +13,7 @@ const getStatusClass = status => {
|
||||
case 4:
|
||||
return 'is-danger is-light'
|
||||
default:
|
||||
return 'is-light'
|
||||
return 'is-light has-text-dark'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\API\System;
|
||||
|
||||
use App\Libs\Attributes\Route\Delete;
|
||||
use App\Libs\Attributes\Route\Get;
|
||||
use App\Libs\Container;
|
||||
use App\Libs\Database\DatabaseInterface as iDB;
|
||||
use App\Libs\DataUtil;
|
||||
use App\Libs\Entity\StateInterface as iState;
|
||||
use App\Libs\Enums\Http\Status;
|
||||
use App\Libs\Traits\APITraits;
|
||||
use DateInterval;
|
||||
use Psr\Http\Message\ResponseInterface as iResponse;
|
||||
use Psr\Http\Message\ServerRequestInterface as iRequest;
|
||||
use Psr\SimpleCache\CacheInterface as iCache;
|
||||
use Psr\SimpleCache\InvalidArgumentException;
|
||||
|
||||
final class OldEvents
|
||||
{
|
||||
use APITraits;
|
||||
|
||||
public const string URL = '%{api.prefix}/system/old_events';
|
||||
|
||||
private const array TYPES = ['queue', 'progress', 'requests'];
|
||||
|
||||
public function __construct(private iCache $cache, private iDB $db)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Get(self::URL . '[/]', name: 'system.events')]
|
||||
public function __invoke(iRequest $request): iResponse
|
||||
{
|
||||
$response = [
|
||||
'queue' => [],
|
||||
'progress' => [],
|
||||
'requests' => [],
|
||||
];
|
||||
|
||||
foreach ($this->cache->get('queue', []) as $key => $item) {
|
||||
if (null === ($entity = $this->db->get(Container::get(iState::class)::fromArray($item)))) {
|
||||
continue;
|
||||
}
|
||||
$response['queue'][] = ['key' => $key, 'item' => $this->formatEntity($entity)];
|
||||
}
|
||||
|
||||
foreach ($this->cache->get('progress', []) as $key => $item) {
|
||||
if (null !== ($entity = $this->db->get($item))) {
|
||||
$item->id = $entity->id;
|
||||
}
|
||||
|
||||
$response['progress'][] = ['key' => $key, 'item' => $this->formatEntity($item)];
|
||||
}
|
||||
|
||||
foreach ($this->cache->get('requests', []) as $key => $request) {
|
||||
if (null === ($item = ag($request, 'entity')) || false === ($item instanceof iState)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (null !== ($entity = $this->db->get($item))) {
|
||||
$item->id = $entity->id;
|
||||
}
|
||||
|
||||
$response['requests'][] = ['key' => $key, 'item' => $this->formatEntity($item)];
|
||||
}
|
||||
|
||||
return api_response(Status::OK, $response);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Delete(self::URL . '/{id}[/]', name: 'system.events.delete')]
|
||||
public function deleteEvent(iRequest $request, array $args = []): iResponse
|
||||
{
|
||||
$params = DataUtil::fromRequest($request, true);
|
||||
|
||||
if (null === ($id = $params->get('id', ag($args, 'id')))) {
|
||||
return api_error('Invalid id.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$type = $params->get('type', 'queue');
|
||||
|
||||
if (false === in_array($type, self::TYPES, true)) {
|
||||
return api_error(r("Invalid type '{type}'. Only '{types}' are supported.", [
|
||||
'type' => $type,
|
||||
'types' => implode(", ", self::TYPES),
|
||||
]), Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$items = $this->cache->get($type, []);
|
||||
|
||||
if (empty($items)) {
|
||||
return api_error(r('{type} is empty.', ['type' => $type]), Status::NOT_FOUND);
|
||||
}
|
||||
|
||||
if (false === array_key_exists($id, $items)) {
|
||||
return api_error(r("Record id '{id}' doesn't exists. for '{type}' list.", [
|
||||
'id' => $id,
|
||||
'type' => $type,
|
||||
]), Status::NOT_FOUND);
|
||||
}
|
||||
|
||||
if ('queue' === $type) {
|
||||
$item = Container::get(iState::class)::fromArray(['id' => $id]);
|
||||
queuePush($item, remove: true);
|
||||
} else {
|
||||
unset($items[$id]);
|
||||
$this->cache->set($type, $items, new DateInterval('P3D'));
|
||||
}
|
||||
|
||||
return api_response(Status::OK, ['id' => $id, 'type' => $type, 'status' => 'deleted']);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ use Throwable;
|
||||
#[Cli(command: self::ROUTE)]
|
||||
final class DispatchCommand extends Command
|
||||
{
|
||||
public const string TASK_NAME = 'Dispatch';
|
||||
public const string TASK_NAME = 'dispatch';
|
||||
|
||||
public const string ROUTE = 'events:dispatch';
|
||||
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Commands\State;
|
||||
|
||||
use App\Command;
|
||||
use App\Libs\Attributes\Route\Cli;
|
||||
use App\Libs\Config;
|
||||
use App\Libs\Container;
|
||||
use App\Libs\Database\DatabaseInterface as iDB;
|
||||
use App\Libs\Entity\StateInterface as iState;
|
||||
use App\Libs\Options;
|
||||
use App\Libs\QueueRequests;
|
||||
use Psr\Log\LoggerInterface as iLogger;
|
||||
use Psr\SimpleCache\CacheInterface as iCache;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Class PushCommand
|
||||
*
|
||||
* This class represents a command that pushes webhook queued events.
|
||||
* It sends change play state requests to the supported backends.
|
||||
*/
|
||||
#[Cli(command: self::ROUTE)]
|
||||
class PushCommand extends Command
|
||||
{
|
||||
public const ROUTE = 'state:push';
|
||||
|
||||
public const TASK_NAME = 'push';
|
||||
|
||||
/**
|
||||
* Constructor for the given class.
|
||||
*
|
||||
* @param iLogger $logger The logger instance.
|
||||
* @param iCache $cache The cache instance.
|
||||
* @param iDB $db The database instance.
|
||||
* @param QueueRequests $queue The queue instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
private iLogger $logger,
|
||||
private iCache $cache,
|
||||
private iDB $db,
|
||||
private QueueRequests $queue
|
||||
) {
|
||||
set_time_limit(0);
|
||||
ini_set('memory_limit', '-1');
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure command.
|
||||
*/
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName(self::ROUTE)
|
||||
->setDescription('Push webhook queued events.')
|
||||
->addOption('keep', 'k', InputOption::VALUE_NONE, 'Do not expunge queue after run is complete.')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Do not commit changes to backends.')
|
||||
->addOption('ignore-date', null, InputOption::VALUE_NONE, 'Ignore date comparison.')
|
||||
->setHelp(
|
||||
r(
|
||||
<<<HELP
|
||||
|
||||
This command push <notice>webhook</notice> updated play state to export enabled backends.
|
||||
You should not run this manually and instead rely on scheduled task to run this command.
|
||||
|
||||
This command require the <notice>metadata</notice> to be already saved in database.
|
||||
If no metadata available for a backend, then the item will be ignored for that backend.
|
||||
|
||||
If the item was ignored during <cmd>{route}</cmd> run, it will be picked up later by next <cmd>{export_route}</cmd> run.
|
||||
|
||||
HELP,
|
||||
[
|
||||
'cmd' => trim(commandContext()),
|
||||
'route' => self::ROUTE,
|
||||
'export_route' => ExportCommand::ROUTE,
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure the command is not running in parallel.
|
||||
*
|
||||
* @param InputInterface $input The input interface.
|
||||
* @param OutputInterface $output The output interface.
|
||||
*
|
||||
* @return int Returns the process result status code.
|
||||
* @throws \Psr\SimpleCache\InvalidArgumentException if the cache key is not a legal value.
|
||||
*/
|
||||
protected function runCommand(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
return $this->single(fn(): int => $this->process($input), $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the queue items and send change play state requests to the supported backends.
|
||||
*
|
||||
* @param InputInterface $input The input interface.
|
||||
*
|
||||
* @return int Returns the process result status code.
|
||||
* @throws \Psr\SimpleCache\InvalidArgumentException if the cache key is not a legal value.
|
||||
*/
|
||||
protected function process(InputInterface $input): int
|
||||
{
|
||||
if (!$this->cache->has('queue')) {
|
||||
$this->logger->info('No items in the queue.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$entities = $items = [];
|
||||
|
||||
foreach ($this->cache->get('queue', []) as $item) {
|
||||
$items[] = Container::get(iState::class)::fromArray($item);
|
||||
}
|
||||
|
||||
if (!empty($items)) {
|
||||
foreach ($this->db->find(...$items) as $item) {
|
||||
$entities[$item->id] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
$items = null;
|
||||
|
||||
if (empty($entities)) {
|
||||
$this->cache->delete('queue');
|
||||
$this->logger->debug('No items in the queue.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$list = [];
|
||||
$supported = Config::get('supported', []);
|
||||
|
||||
foreach ((array)Config::get('servers', []) as $backendName => $backend) {
|
||||
$type = strtolower(ag($backend, 'type', 'unknown'));
|
||||
|
||||
if (true !== (bool)ag($backend, 'export.enabled')) {
|
||||
$this->logger->info('SYSTEM: Export to [{backend}] is disabled by user.', [
|
||||
'backend' => $backendName,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($supported[$type])) {
|
||||
$this->logger->error('SYSTEM: [{backend}] Invalid type.', [
|
||||
'backend' => $backendName,
|
||||
'condition' => [
|
||||
'expected' => implode(', ', array_keys($supported)),
|
||||
'given' => $type,
|
||||
],
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (null === ($url = ag($backend, 'url')) || false === isValidURL($url)) {
|
||||
$this->logger->error('SYSTEM: [{backend}] Invalid url.', [
|
||||
'backend' => $backendName,
|
||||
'url' => $url ?? 'None',
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$backend['name'] = $backendName;
|
||||
$list[$backendName] = $backend;
|
||||
}
|
||||
|
||||
if (empty($list)) {
|
||||
$this->logger->warning('SYSTEM: There are no backends with export enabled.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
foreach ($list as $name => &$backend) {
|
||||
$opts = ag($backend, 'options', []);
|
||||
|
||||
if ($input->getOption('ignore-date')) {
|
||||
$opts[Options::IGNORE_DATE] = true;
|
||||
}
|
||||
|
||||
if ($input->getOption('dry-run')) {
|
||||
$opts[Options::DRY_RUN] = true;
|
||||
}
|
||||
|
||||
if ($input->getOption('trace')) {
|
||||
$opts[Options::DEBUG_TRACE] = true;
|
||||
}
|
||||
|
||||
$backend['options'] = $opts;
|
||||
$backend['class'] = $this->getBackend(name: $name, config: $backend);
|
||||
|
||||
$backend['class']->push(entities: $entities, queue: $this->queue);
|
||||
}
|
||||
|
||||
unset($backend);
|
||||
|
||||
$total = count($this->queue);
|
||||
|
||||
if ($total >= 1) {
|
||||
$start = makeDate();
|
||||
$this->logger->notice('SYSTEM: Sending [{total}] change play state requests.', [
|
||||
'total' => $total,
|
||||
'time' => [
|
||||
'start' => $start,
|
||||
],
|
||||
]);
|
||||
|
||||
foreach ($this->queue->getQueue() as $response) {
|
||||
$context = ag($response->getInfo('user_data'), 'context', []);
|
||||
|
||||
try {
|
||||
if (200 !== $response->getStatusCode()) {
|
||||
$this->logger->error(
|
||||
'SYSTEM: Request to change [{backend}] [{item.title}] play state returned with unexpected [{status_code}] status code.',
|
||||
$context
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->logger->notice('SYSTEM: Marked [{backend}] [{item.title}] as [{play_state}].', $context);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error(
|
||||
message: 'SYSTEM: Exception [{error.kind}] was thrown unhandled during [{backend}] request to change play state of {item.type} [{item.title}]. Error [{error.message} @ {error.file}:{error.line}].',
|
||||
context: [
|
||||
'error' => [
|
||||
'kind' => $e::class,
|
||||
'line' => $e->getLine(),
|
||||
'message' => $e->getMessage(),
|
||||
'file' => after($e->getFile(), ROOT_PATH),
|
||||
],
|
||||
...$context,
|
||||
'exception' => [
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'kind' => get_class($e),
|
||||
'message' => $e->getMessage(),
|
||||
'trace' => $e->getTrace(),
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$end = makeDate();
|
||||
|
||||
$this->logger->notice('SYSTEM: Sent [{total}] change play state requests.', [
|
||||
'total' => $total,
|
||||
'time' => [
|
||||
'start' => $start,
|
||||
'end' => $end,
|
||||
'duration' => $end->getTimestamp() - $start->getTimestamp(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->logger->notice('Using WatchState Version - \'{version}\'.', ['version' => getAppVersion()]);
|
||||
} else {
|
||||
$this->logger->notice('SYSTEM: No play state changes detected.');
|
||||
}
|
||||
|
||||
if (false === $input->getOption('keep') && false === $input->getOption('dry-run')) {
|
||||
$this->cache->delete('queue');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -609,15 +609,24 @@ final class PDOAdapter implements iDB
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @noinspection SqlWithoutWhere
|
||||
*/
|
||||
public function reset(): bool
|
||||
{
|
||||
$this->pdo->exec('DELETE FROM `state` WHERE `id` > 0');
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
if ('sqlite' === $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME)) {
|
||||
$this->pdo->exec('DELETE FROM sqlite_sequence WHERE name = "state"');
|
||||
$tables = $this->pdo->query(
|
||||
'SELECT name FROM sqlite_master WHERE "type" = "table" AND "name" NOT LIKE "sqlite_%"'
|
||||
);
|
||||
|
||||
foreach ($tables->fetchAll(PDO::FETCH_COLUMN) as $table) {
|
||||
$this->pdo->exec('DELETE FROM "' . $table . '"');
|
||||
$this->pdo->exec('DELETE FROM sqlite_sequence WHERE "name" = "' . $table . '"');
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
$this->pdo->exec('VACUUM');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ use App\Libs\Response;
|
||||
use App\Libs\Router;
|
||||
use App\Libs\Stream;
|
||||
use App\Libs\Uri;
|
||||
use App\Listeners\ProcessPushEvent;
|
||||
use App\Model\Events\Event as EventInfo;
|
||||
use App\Model\Events\EventListener;
|
||||
use App\Model\Events\EventsRepository;
|
||||
@@ -536,47 +537,48 @@ if (!function_exists('httpClientChunks')) {
|
||||
|
||||
if (!function_exists('queuePush')) {
|
||||
/**
|
||||
* Pushes the entity to the queue.
|
||||
*
|
||||
* This method adds the entity to the queue for further processing.
|
||||
* Add push event to the events queue.
|
||||
*
|
||||
* @param iState $entity The entity to push to the queue.
|
||||
* @param bool $remove (optional) Whether to remove the entity from the queue if it already exists (default is false).
|
||||
* @param bool $remove Whether to remove the event from the queue if it's in pending state. Default is false.
|
||||
*/
|
||||
function queuePush(iState $entity, bool $remove = false): void
|
||||
{
|
||||
if (!$remove && !$entity->hasGuids() && !$entity->hasRelativeGuid()) {
|
||||
$logger = Container::get(iLogger::class);
|
||||
|
||||
if (false === (bool)Config::get('push.enabled', false)) {
|
||||
$logger->debug("Push is disabled. Unable to push '{via}: {entity}'.", [
|
||||
'via' => $entity->via,
|
||||
'entity' => $entity->getName()
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$cache = Container::get(iCache::class);
|
||||
|
||||
$list = $cache->get('queue', []);
|
||||
|
||||
if (true === $remove && array_key_exists($entity->id, $list)) {
|
||||
unset($list[$entity->id]);
|
||||
} else {
|
||||
$list[$entity->id] = ['id' => $entity->id];
|
||||
}
|
||||
|
||||
$cache->set('queue', $list, new DateInterval('P7D'));
|
||||
} catch (\Psr\SimpleCache\InvalidArgumentException $e) {
|
||||
Container::get(iLogger::class)->error(
|
||||
message: 'Exception [{error.kind}] was thrown unhandled during saving [{backend} - {title}} into queue. Error [{error.message} @ {error.file}:{error.line}].',
|
||||
context: [
|
||||
'backend' => $entity->via,
|
||||
'title' => $entity->getName(),
|
||||
'error' => [
|
||||
'kind' => $e::class,
|
||||
'line' => $e->getLine(),
|
||||
'message' => $e->getMessage(),
|
||||
'file' => after($e->getFile(), ROOT_PATH),
|
||||
],
|
||||
'trace' => $e->getTrace(),
|
||||
],
|
||||
);
|
||||
if (!$entity->id) {
|
||||
$logger->error("Unable to push event '{via}: {entity}'. It has no local id yet.", [
|
||||
'via' => $entity->via,
|
||||
'entity' => $entity->getName()
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (true === $remove) {
|
||||
Container::get(EventsRepository::class)->removeByReference(r('push://{id}', ['id' => $entity->id]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) {
|
||||
$logger->error("Unable to push '{id}' event '{via}: {entity}'. It has no GUIDs.", [
|
||||
'id' => $entity->id,
|
||||
'via' => $entity->via,
|
||||
'entity' => $entity->getName()
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
queueEvent(ProcessPushEvent::NAME, [iState::COLUMN_ID => $entity->id], [
|
||||
EventsTable::COLUMN_REFERENCE => r('push://{id}', ['id' => $entity->id]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,14 +72,15 @@ final readonly class ProcessProgressEvent
|
||||
$type = strtolower(ag($backend, 'type', 'unknown'));
|
||||
|
||||
if (true !== (bool)ag($backend, 'export.enabled')) {
|
||||
$writer(Level::Notice, "SYSTEM: Export to '{backend}' is disabled by user.", [
|
||||
$writer(Level::Notice, "Export to '{backend}' is disabled by user.", [
|
||||
'backend' => $backendName
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($supported[$type])) {
|
||||
$writer(Level::Error, "SYSTEM: '{backend}' Invalid type.", [
|
||||
$writer(Level::Error, "The backend '{backend}' is using invalid type '{type}'.", [
|
||||
'type' => $type,
|
||||
'backend' => $backendName,
|
||||
'condition' => [
|
||||
'expected' => implode(', ', array_keys($supported)),
|
||||
@@ -90,7 +91,7 @@ final readonly class ProcessProgressEvent
|
||||
}
|
||||
|
||||
if (null === ($url = ag($backend, 'url')) || false === isValidURL($url)) {
|
||||
$writer(Level::Error, "SYSTEM: '{backend}' Invalid url.", [
|
||||
$writer(Level::Error, "The backend '{backend}' URL is invalid.", [
|
||||
'backend' => $backendName,
|
||||
'url' => $url ?? 'None',
|
||||
]);
|
||||
@@ -102,7 +103,7 @@ final readonly class ProcessProgressEvent
|
||||
}
|
||||
|
||||
if (empty($list)) {
|
||||
$writer(Level::Info, 'SYSTEM: There are no backends with export enabled.');
|
||||
$writer(Level::Error, 'There are no backends with export enabled.');
|
||||
return $e;
|
||||
}
|
||||
|
||||
@@ -128,7 +129,7 @@ final readonly class ProcessProgressEvent
|
||||
} catch (UnexpectedVersionException|NotImplementedException $e) {
|
||||
$writer(
|
||||
Level::Notice,
|
||||
"SYSTEM: This feature is not available for '{backend}'. '{error.message}' at '{error.file}:{error.line}'.",
|
||||
"This feature is not available for '{backend}'. '{error.message}' at '{error.file}:{error.line}'.",
|
||||
[
|
||||
'backend' => $name,
|
||||
'error' => [
|
||||
@@ -149,7 +150,7 @@ final readonly class ProcessProgressEvent
|
||||
} catch (Throwable $e) {
|
||||
$writer(
|
||||
Level::Error,
|
||||
"SYSTEM: Exception '{error.kind}' was thrown unhandled during '{backend}' request to sync progress. '{error.message}' at '{error.file}:{error.line}'.",
|
||||
"Exception '{error.kind}' was thrown unhandled during '{backend}' request to sync progress. '{error.message}' at '{error.file}:{error.line}'.",
|
||||
[
|
||||
'backend' => $name,
|
||||
'error' => [
|
||||
@@ -172,83 +173,73 @@ final readonly class ProcessProgressEvent
|
||||
|
||||
unset($backend);
|
||||
|
||||
$total = count($this->queue);
|
||||
if (count($this->queue) < 1) {
|
||||
$writer(Level::Notice, "Backend handlers didn't queue items to be updated.");
|
||||
return $e;
|
||||
}
|
||||
|
||||
if ($total >= 1) {
|
||||
$start = makeDate();
|
||||
$progress = formatDuration($item->getPlayProgress());
|
||||
|
||||
$writer(Level::Notice, "SYSTEM: Sending '{total}' progress update requests.", [
|
||||
'total' => $total,
|
||||
'time' => [
|
||||
'start' => $start,
|
||||
],
|
||||
]);
|
||||
$writer(Level::Notice, "Processing '{id}' - '{via}: {title}' watch progress '{progress}' event.", [
|
||||
'id' => $item->id,
|
||||
'via' => $item->via,
|
||||
'title' => $item->getName(),
|
||||
'progress' => $progress,
|
||||
]);
|
||||
|
||||
foreach ($this->queue->getQueue() as $response) {
|
||||
$context = ag($response->getInfo('user_data'), 'context', []);
|
||||
foreach ($this->queue->getQueue() as $response) {
|
||||
$context = ag($response->getInfo('user_data'), 'context', []);
|
||||
|
||||
try {
|
||||
if (ag($options, 'trace')) {
|
||||
$writer(Level::Debug, "Processing '{backend}' - '{item.title}' response.", [
|
||||
'url' => ag($context, 'remote.url', '??'),
|
||||
'status_code' => $response->getStatusCode(),
|
||||
'headers' => $response->getHeaders(false),
|
||||
'response' => $response->getContent(false),
|
||||
...$context
|
||||
]);
|
||||
}
|
||||
|
||||
if (!in_array($response->getStatusCode(), [Status::OK->value, Status::NO_CONTENT->value])) {
|
||||
$writer(
|
||||
Level::Error,
|
||||
"SYSTEM: Request to change '{backend}' '{item.title}' watch progress returned with unexpected '{status_code}' status code.",
|
||||
[
|
||||
'status_code' => $response->getStatusCode(),
|
||||
...$context
|
||||
]
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
$writer(Level::Notice, "SYSTEM: Updated '{backend}' '{item.title}' watch progress.", [
|
||||
...$context,
|
||||
try {
|
||||
if (ag($options, 'trace')) {
|
||||
$writer(Level::Debug, "Processing '{backend}: {item.title}' response.", [
|
||||
'url' => ag($context, 'remote.url', '??'),
|
||||
'status_code' => $response->getStatusCode(),
|
||||
'headers' => $response->getHeaders(false),
|
||||
'response' => $response->getContent(false),
|
||||
...$context
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
|
||||
if (!in_array($response->getStatusCode(), [Status::OK->value, Status::NO_CONTENT->value])) {
|
||||
$writer(
|
||||
Level::Error,
|
||||
"SYSTEM: Exception '{error.kind}' was thrown unhandled during '{backend}' request to change watch progress of {item.type} '{item.title}'. '{error.message}' at '{error.file}:{error.line}'.",
|
||||
"Request to change '{backend}: {item.title}' watch progress returned with unexpected '{status_code}' status code.",
|
||||
[
|
||||
'error' => [
|
||||
'kind' => $e::class,
|
||||
'line' => $e->getLine(),
|
||||
'message' => $e->getMessage(),
|
||||
'file' => after($e->getFile(), ROOT_PATH),
|
||||
],
|
||||
'exception' => [
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'kind' => get_class($e),
|
||||
'message' => $e->getMessage(),
|
||||
'trace' => $e->getTrace(),
|
||||
],
|
||||
...$context,
|
||||
'status_code' => $response->getStatusCode(),
|
||||
...$context
|
||||
]
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$end = makeDate();
|
||||
$writer(Level::Notice, "SYSTEM: Sent '{total}' watch progress requests.", [
|
||||
'total' => $total,
|
||||
'time' => [
|
||||
'start' => $start,
|
||||
'end' => $end,
|
||||
'duration' => $end->getTimestamp() - $start->getTimestamp(),
|
||||
],
|
||||
]);
|
||||
} else {
|
||||
$writer(Level::Notice, 'SYSTEM: No watch progress changes detected.');
|
||||
$writer(Level::Notice, "Updated '{backend}: {item.title}' watch progress to '{progress}'.", [
|
||||
...$context,
|
||||
'progress' => $progress,
|
||||
'status_code' => $response->getStatusCode(),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$writer(
|
||||
Level::Error,
|
||||
"Exception '{error.kind}' was thrown unhandled during '{backend}' request to change watch progress of {item.type} '{item.title}'. '{error.message}' at '{error.file}:{error.line}'.",
|
||||
[
|
||||
'error' => [
|
||||
'kind' => $e::class,
|
||||
'line' => $e->getLine(),
|
||||
'message' => $e->getMessage(),
|
||||
'file' => after($e->getFile(), ROOT_PATH),
|
||||
],
|
||||
'exception' => [
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'kind' => get_class($e),
|
||||
'message' => $e->getMessage(),
|
||||
'trace' => $e->getTrace(),
|
||||
],
|
||||
...$context,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $e;
|
||||
|
||||
190
src/Listeners/ProcessPushEvent.php
Normal file
190
src/Listeners/ProcessPushEvent.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Libs\Config;
|
||||
use App\Libs\Container;
|
||||
use App\Libs\Database\DatabaseInterface as iDB;
|
||||
use App\Libs\Entity\StateInterface as iState;
|
||||
use App\Libs\Enums\Http\Status;
|
||||
use App\libs\Events\DataEvent;
|
||||
use App\Libs\Options;
|
||||
use App\Libs\QueueRequests;
|
||||
use App\Model\Events\EventListener;
|
||||
use Monolog\Level;
|
||||
use Psr\Log\LoggerInterface as iLogger;
|
||||
use Throwable;
|
||||
|
||||
#[EventListener(self::NAME)]
|
||||
final readonly class ProcessPushEvent
|
||||
{
|
||||
public const string NAME = 'on_push';
|
||||
|
||||
/**
|
||||
* Class constructor.
|
||||
*
|
||||
* @param iLogger $logger The logger object.
|
||||
*/
|
||||
public function __construct(private iLogger $logger, private iDB $db, private QueueRequests $queue)
|
||||
{
|
||||
set_time_limit(0);
|
||||
ini_set('memory_limit', '-1');
|
||||
}
|
||||
|
||||
public function __invoke(DataEvent $e): DataEvent
|
||||
{
|
||||
$writer = function (Level $level, string $message, array $context = []) use ($e) {
|
||||
$e->addLog($level->getName() . ': ' . r($message, $context));
|
||||
$this->logger->log($level, $message, $context);
|
||||
};
|
||||
|
||||
$e->stopPropagation();
|
||||
|
||||
if (null === ($item = $this->db->get(Container::get(iState::class)::fromArray($e->getData())))) {
|
||||
$writer(Level::Error, "Item '{id}' is not found or has been deleted.", [
|
||||
'id' => ag($e->getData(), 'id', '?')
|
||||
]);
|
||||
return $e;
|
||||
}
|
||||
|
||||
$options = $e->getOptions();
|
||||
$list = [];
|
||||
$supported = Config::get('supported', []);
|
||||
|
||||
foreach ((array)Config::get('servers', []) as $backendName => $backend) {
|
||||
$type = strtolower(ag($backend, 'type', 'unknown'));
|
||||
|
||||
if (true !== (bool)ag($backend, 'export.enabled')) {
|
||||
$writer(Level::Notice, "Export to '{backend}' is disabled by user.", [
|
||||
'backend' => $backendName
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($supported[$type])) {
|
||||
$writer(Level::Error, "The backend '{backend}' is using invalid type '{type}'.", [
|
||||
'type' => $type,
|
||||
'backend' => $backendName,
|
||||
'condition' => [
|
||||
'expected' => implode(', ', array_keys($supported)),
|
||||
'given' => $type,
|
||||
],
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (null === ($url = ag($backend, 'url')) || false === isValidURL($url)) {
|
||||
$writer(Level::Error, "The backend '{backend}' URL is invalid.", [
|
||||
'backend' => $backendName,
|
||||
'url' => $url ?? 'None',
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$backend['name'] = $backendName;
|
||||
$list[$backendName] = $backend;
|
||||
}
|
||||
|
||||
if (empty($list)) {
|
||||
$writer(Level::Error, 'There are no backends with export enabled.');
|
||||
return $e;
|
||||
}
|
||||
|
||||
foreach ($list as $name => &$backend) {
|
||||
try {
|
||||
$opts = ag($backend, 'options', []);
|
||||
|
||||
if (ag($options, 'ignore-date')) {
|
||||
$opts[Options::IGNORE_DATE] = true;
|
||||
}
|
||||
|
||||
if (ag($options, 'dry-run')) {
|
||||
$opts[Options::DRY_RUN] = true;
|
||||
}
|
||||
|
||||
if (ag($options, 'trace')) {
|
||||
$opts[Options::DEBUG_TRACE] = true;
|
||||
}
|
||||
|
||||
$backend['options'] = $opts;
|
||||
$backend['class'] = getBackend(name: $name, config: $backend);
|
||||
$backend['class']->push(entities: [$item->id => $item], queue: $this->queue);
|
||||
} catch (Throwable $e) {
|
||||
$writer(
|
||||
Level::Error,
|
||||
"Exception '{error.kind}' was thrown unhandled during '{backend}' push events. '{error.message}' at '{error.file}:{error.line}'.",
|
||||
[
|
||||
'backend' => $name,
|
||||
'error' => [
|
||||
'kind' => $e::class,
|
||||
'line' => $e->getLine(),
|
||||
'message' => $e->getMessage(),
|
||||
'file' => after($e->getFile(), ROOT_PATH),
|
||||
],
|
||||
'exception' => [
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'kind' => get_class($e),
|
||||
'message' => $e->getMessage(),
|
||||
'trace' => $e->getTrace(),
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
unset($backend);
|
||||
|
||||
if (count($this->queue) < 1) {
|
||||
$writer(Level::Notice, 'SYSTEM: No play state changes detected.');
|
||||
return $e;
|
||||
}
|
||||
|
||||
$writer(Level::Notice, "Processing '{id}' - '{via}: {title}' '{state}' push event.", [
|
||||
'id' => $item->id,
|
||||
'via' => $item->via,
|
||||
'title' => $item->getName(),
|
||||
'state' => $item->isWatched() ? 'played' : 'unplayed',
|
||||
]);
|
||||
|
||||
foreach ($this->queue->getQueue() as $response) {
|
||||
$context = ag($response->getInfo('user_data'), 'context', []);
|
||||
|
||||
try {
|
||||
if (Status::OK !== Status::from($response->getStatusCode())) {
|
||||
$writer(
|
||||
Level::Error,
|
||||
"Request to change '{backend}: {item.title}' play state returned with unexpected '{status_code}' status code.",
|
||||
$context
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
$writer(Level::Notice, "Updated '{backend}: {item.title}' watch state to '{play_state}'.", $context);
|
||||
} catch (Throwable $e) {
|
||||
$writer(
|
||||
Level::Error,
|
||||
"Exception '{error.kind}' was thrown unhandled during '{backend}' request to change play state of {item.type} '{item.title}'. '{error.message}' at '{error.file}:{error.line}'.",
|
||||
[
|
||||
'error' => [
|
||||
'kind' => $e::class,
|
||||
'line' => $e->getLine(),
|
||||
'message' => $e->getMessage(),
|
||||
'file' => after($e->getFile(), ROOT_PATH),
|
||||
],
|
||||
...$context,
|
||||
'exception' => [
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'kind' => get_class($e),
|
||||
'message' => $e->getMessage(),
|
||||
'trace' => $e->getTrace(),
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
return $e;
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ final readonly class ProcessRequestEvent
|
||||
$lastSync = makeDate($lastSync);
|
||||
}
|
||||
|
||||
$message = r('SYSTEM: Processing [{backend}] [{title}] {tainted} request.', [
|
||||
$message = r("Processing '{backend}: {title}' {tainted} request.", [
|
||||
'backend' => $entity->via,
|
||||
'title' => $entity->getName(),
|
||||
'event' => ag($entity->getExtra($entity->via), iState::COLUMN_EXTRA_EVENT, '??'),
|
||||
|
||||
@@ -49,6 +49,23 @@ final class EventsRepository
|
||||
return $items[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Will return the last event by reference.
|
||||
*
|
||||
* @param string|int $reference reference id to remove.
|
||||
* @param array $criteria Filter criteria. By default, it will only remove pending events.
|
||||
*/
|
||||
public function removeByReference(string|int $reference, array $criteria = []): bool
|
||||
{
|
||||
if (empty($criteria)) {
|
||||
$criteria[EntityTable::COLUMN_STATUS] = EventStatus::PENDING->value;
|
||||
}
|
||||
|
||||
$criteria[EntityTable::COLUMN_REFERENCE] = $reference;
|
||||
|
||||
return $this->_remove($criteria);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $criteria Criteria to search by.
|
||||
* @param array $cols Columns to select.
|
||||
|
||||
Reference in New Issue
Block a user