Skip to content

Commit aeb3a3a

Browse files
committed
Added new API endpoint to check for stale content from specific library.
1 parent 9c1271a commit aeb3a3a

File tree

4 files changed

+334
-2
lines changed

4 files changed

+334
-2
lines changed

frontend/layouts/default.vue

-1
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,6 @@
336336
<Markdown :file="loadFile"/>
337337
</Overlay>
338338
</template>
339-
<NuxtNotifications position="top right" :speed="800" :ignoreDuplicates="true" :width="340" :pauseOnHover="true"/>
340339
</div>
341340
</div>
342341
</template>

frontend/pages/backend/[backend]/libraries.vue

+9-1
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,14 @@
7474
<label :for="`ignore-${item.id}`"></label>
7575
<span>Ignore content from this library.</span>
7676
</div>
77+
<div class="card-footer-item">
78+
<NuxtLink :to="`/backend/${backend}/stale/${item.id}?name=${item.title}`">
79+
<span class="icon-text">
80+
<span class="icon"><i class="fas fa-sync"></i></span>
81+
<span>Check Content Staleness</span>
82+
</span>
83+
</NuxtLink>
84+
</div>
7785
</div>
7886
</div>
7987
</div>
@@ -125,7 +133,7 @@ const loadContent = async () => {
125133
if (useRoute().name !== 'backend-backend-libraries') {
126134
return
127135
}
128-
136+
129137
if (200 !== response.status) {
130138
notification('error', 'Error', `${json.error.code}: ${json.error.message}`)
131139
return
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
<template>
2+
<div>
3+
<div class="columns is-multiline">
4+
<div class="column is-12 is-clearfix is-unselectable">
5+
<span class="title is-4 ">
6+
<span class="icon"><i class="fas fa-server"></i>&nbsp;</span>
7+
<NuxtLink to="/backends" v-text="'Backends'"/>
8+
-
9+
<NuxtLink :to="`/backend/${backend}`" v-text="backend"/>
10+
: Staleness
11+
</span>
12+
<div class="is-pulled-right">
13+
<div class="field is-grouped">
14+
<p class="control">
15+
<button class="button is-info" @click.prevent="loadContent()" :disabled="isLoading"
16+
:class="{'is-loading':isLoading}">
17+
<span class="icon"><i class="fas fa-sync"></i></span>
18+
</button>
19+
</p>
20+
</div>
21+
</div>
22+
<div class="is-hidden-mobile">
23+
<span class="subtitle">
24+
This page will show items from local database that no longer exists in the remote backend library.
25+
</span>
26+
</div>
27+
</div>
28+
29+
<div class="column is-12">
30+
<div class="columns is-multiline" v-if="items.length>0">
31+
<div class="column is-12">
32+
<Message message_class="has-background-warning-80 has-text-dark" title="Warning"
33+
icon="fas fa-exclamation-triangle">
34+
<b>WatchState</b>, has found '<span class="has-text-danger is-bold"><u>{{ counts.stale }}</u></span>'
35+
items in local database, that no longer exists in <b>{{ remote.name }}</b>
36+
library <b>{{ remote.library?.title }}</b>.
37+
</Message>
38+
</div>
39+
<template v-for="item in items" :key="item.id">
40+
<Lazy :unrender="true" :min-height="343" class="column is-6-tablet">
41+
<div class="card" :class="{ 'is-success': item.watched }">
42+
<header class="card-header">
43+
<p class="card-header-title is-text-overflow pr-1">
44+
<NuxtLink :to="'/history/'+item.id" v-text="makeName(item)"/>
45+
</p>
46+
<span class="card-header-icon" @click="item.showRawData = !item?.showRawData">
47+
<span class="icon">
48+
<i class="fas"
49+
:class="{ 'fa-tv': 'episode' === item.type.toLowerCase(), 'fa-film': 'movie' === item.type.toLowerCase()}"></i>
50+
</span>
51+
</span>
52+
</header>
53+
<div class="card-content">
54+
<div class="columns is-multiline is-mobile">
55+
<div class="column is-12">
56+
<div class="field is-grouped">
57+
<div class="control is-clickable"
58+
:class="{'is-text-overflow': !item?.expand_title, 'is-text-contents': item?.expand_title}"
59+
@click="item.expand_title = !item?.expand_title">
60+
<span class="icon"><i class="fas fa-heading"></i>&nbsp;</span>
61+
<template v-if="item?.content_title">
62+
<NuxtLink :to="makeSearchLink('subtitle', item.content_title)" v-text="item.content_title"/>
63+
</template>
64+
<template v-else>
65+
<NuxtLink :to="makeSearchLink('subtitle', item.title)" v-text="item.title"/>
66+
</template>
67+
</div>
68+
<div class="control">
69+
<span class="icon is-clickable"
70+
@click="copyText(item?.content_title ?? item.title, false)">
71+
<i class="fas fa-copy"></i></span>
72+
</div>
73+
</div>
74+
</div>
75+
<div class="column is-12">
76+
<div class="field is-grouped">
77+
<div class="control is-clickable"
78+
:class="{'is-text-overflow': !item?.expand_path, 'is-text-contents': item?.expand_path}"
79+
@click="item.expand_path = !item?.expand_path">
80+
<span class="icon"><i class="fas fa-file"></i>&nbsp;</span>
81+
<NuxtLink v-if="item?.content_path" :to="makeSearchLink('path', item.content_path)"
82+
v-text="item.content_path"/>
83+
<span v-else>No path found.</span>
84+
</div>
85+
<div class="control">
86+
<span class="icon is-clickable"
87+
@click="copyText(item?.content_path ?item.content_path : null, false)">
88+
<i class="fas fa-copy"></i></span>
89+
</div>
90+
</div>
91+
</div>
92+
<div class="column is-12">
93+
<div class="field is-grouped">
94+
<div class="control is-expanded is-unselectable">
95+
<span class="icon"><i class="fas fa-info"></i>&nbsp;</span>
96+
<span>Has metadata from</span>
97+
</div>
98+
<div class="control">
99+
<NuxtLink v-for="backend in item.reported_by" :key="`${item.id}-rb-${backend}`"
100+
:to="'/backend/'+backend" v-text="backend" class="tag is-primary ml-1"/>
101+
<NuxtLink v-for="backend in item.not_reported_by" :key="`${item.id}-nrb-${backend}`"
102+
:to="'/backend/'+backend" v-text="backend" class="tag is-danger ml-1"/>
103+
</div>
104+
</div>
105+
</div>
106+
</div>
107+
</div>
108+
<div class="card-content p-0 m-0" v-if="item?.showRawData">
109+
<pre style="position: relative; max-height: 343px;"><code>{{ JSON.stringify(item, null, 2) }}</code>
110+
<button class="button is-small m-4" @click="() => copyText(JSON.stringify(item, null, 2))"
111+
style="position: absolute; top:0; right:0;">
112+
<span class="icon"><i class="fas fa-copy"></i></span>
113+
</button>
114+
</pre>
115+
</div>
116+
<div class="card-footer">
117+
<div class="card-footer-item">
118+
<span class="icon">
119+
<i class="fas" :class="{'fa-eye':item.watched,'fa-eye-slash':!item.watched}"></i>&nbsp;
120+
</span>
121+
<span class="has-text-success" v-if="item.watched">Played</span>
122+
<span class="has-text-danger" v-else>Unplayed</span>
123+
</div>
124+
<div class="card-footer-item">
125+
<span class="icon"><i class="fas fa-calendar"></i>&nbsp;</span>
126+
<span class="has-tooltip"
127+
v-tooltip="`Record updated at: ${moment.unix(item.updated_at).format(TOOLTIP_DATE_FORMAT)}`">
128+
{{ moment.unix(item.updated_at).fromNow() }}
129+
</span>
130+
</div>
131+
</div>
132+
</div>
133+
</lazy>
134+
</template>
135+
</div>
136+
137+
<div class="column is-12" v-else>
138+
<Message v-if="isLoading" message_class="has-background-info-90 has-text-dark" title="Loading"
139+
icon="fas fa-spinner fa-spin" message="Loading data. Please wait..."/>
140+
<template v-else>
141+
<Message message_class="has-background-success-90 has-text-dark" v-if="items.length < 1"
142+
title="Success" icon="fas fa-check">
143+
Great, WatchState checked '<u>{{ counts.local }}</u>' items against
144+
<b>{{ remote.name }}</b> library <b>{{ remote.library?.title }}</b>
145+
'<u>{{ counts.remote }}</u>' items and did not find any local reference that isn't in the remote library.
146+
</Message>
147+
</template>
148+
</div>
149+
150+
<div class="column is-12">
151+
<Message message_class="has-background-info-90 has-text-dark" :toggle="show_page_tips"
152+
@toggle="show_page_tips = !show_page_tips" :use-toggle="true" title="Tips" icon="fas fa-info-circle">
153+
<ul>
154+
<li>
155+
This page is used to show stale references to items in the local database compared to the backend.
156+
</li>
157+
<li>Remote data is cached in memory to speed up reloads.</li>
158+
<li>Is there harm in having stale references? there is no harm, however somethings might not work as
159+
expected if the item has changed id, for example, pushing via webhooks will fail as it's reference old
160+
item that no longer exists.
161+
</li>
162+
</ul>
163+
</Message>
164+
</div>
165+
</div>
166+
</div>
167+
</div>
168+
</template>
169+
170+
<script setup>
171+
import request from '~/utils/request'
172+
import Message from '~/components/Message'
173+
import {copyText, makeName, makeSearchLink, TOOLTIP_DATE_FORMAT} from '~/utils/index'
174+
import moment from 'moment'
175+
import {useStorage} from '@vueuse/core'
176+
import Lazy from '~/components/Lazy'
177+
178+
const route = useRoute()
179+
180+
181+
const id = route.params.id
182+
const backend = route.params.backend
183+
184+
const items = ref([])
185+
const remote = ref([])
186+
const counts = ref({remote: 0, local: 0, stale: 0})
187+
const isLoading = ref(false)
188+
const show_page_tips = useStorage('show_page_tips', true)
189+
190+
const loadContent = async () => {
191+
useHead({title: `Backends - ${backend}: ${route.query.name ?? ''} Staleness`})
192+
isLoading.value = true
193+
194+
try {
195+
const response = await request(`/backend/${backend}/stale/${id}`)
196+
const json = await response.json()
197+
items.value = json.items
198+
remote.value = json.backend
199+
counts.value = json.counts
200+
} finally {
201+
isLoading.value = false
202+
}
203+
}
204+
205+
onMounted(async () => loadContent())
206+
</script>

src/API/Backend/Stale.php

+119
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\API\Backend;
6+
7+
use App\API\Backend\Index as backendIndex;
8+
use App\Libs\Attributes\Route\Get;
9+
use App\Libs\DataUtil;
10+
use App\Libs\Entity\StateInterface as iState;
11+
use App\Libs\Enums\Http\Status;
12+
use App\Libs\Exceptions\RuntimeException;
13+
use App\Libs\Mappers\Import\MemoryMapper;
14+
use App\Libs\Mappers\Import\ReadOnlyMapper;
15+
use App\Libs\Traits\APITraits;
16+
use Psr\Http\Message\ResponseInterface as iResponse;
17+
use Psr\Http\Message\ServerRequestInterface as iRequest;
18+
19+
final class Stale
20+
{
21+
use APITraits;
22+
23+
public function __construct(private readonly ReadOnlyMapper $mapper, private readonly MemoryMapper $local)
24+
{
25+
set_time_limit(0);
26+
ini_set('memory_limit', '-1');
27+
}
28+
29+
#[Get(backendIndex::URL . '/{name:backend}/stale/{id}[/]', name: 'backend.stale')]
30+
public function __invoke(iRequest $request, string $name, string|int $id): iResponse
31+
{
32+
if (empty($name)) {
33+
return api_error('Invalid value for name path parameter.', Status::BAD_REQUEST);
34+
}
35+
36+
if (empty($id)) {
37+
return api_error('Invalid value for id path parameter.', Status::BAD_REQUEST);
38+
}
39+
40+
if (null === $this->getBackend(name: $name)) {
41+
return api_error(r("Backend '{name}' not found.", ['name' => $name]), Status::NOT_FOUND);
42+
}
43+
44+
$params = DataUtil::fromArray($request->getQueryParams());
45+
46+
$backendOpts = $list = [];
47+
48+
if ($params->get('timeout')) {
49+
$backendOpts = ag_set($backendOpts, 'client.timeout', (float)$params->get('timeout'));
50+
}
51+
52+
try {
53+
$client = $this->getClient(name: $name, config: $backendOpts);
54+
} catch (RuntimeException $e) {
55+
return api_error($e->getMessage(), Status::NOT_FOUND);
56+
}
57+
58+
$remote = cacheableItem(
59+
'remote-data-' . $name,
60+
fn() => array_map(fn($item) => ag($item->getMetadata($item->via), iState::COLUMN_ID),
61+
$client->getLibraryContent($id))
62+
, ignoreCache: (bool)$params->get('ignore', false)
63+
);
64+
65+
$this->local->loadData();
66+
67+
$localCount = 0;
68+
69+
foreach ($this->local->getObjects() as $entity) {
70+
$backendData = $entity->getMetadata($name);
71+
if (empty($backendData)) {
72+
continue;
73+
}
74+
75+
if (null === ($libraryId = ag($backendData, iState::COLUMN_META_LIBRARY))) {
76+
continue;
77+
}
78+
79+
if ((string)$libraryId !== (string)$id) {
80+
continue;
81+
}
82+
83+
$localCount++;
84+
85+
$localId = ag($backendData, iState::COLUMN_ID);
86+
87+
if (true === in_array($localId, $remote, true)) {
88+
continue;
89+
}
90+
91+
$list[] = $entity;
92+
}
93+
94+
$libraryInfo = [];
95+
foreach ($client->listLibraries() as $library) {
96+
if (null === ($libraryId = ag($library, 'id'))) {
97+
continue;
98+
}
99+
if ((string)$id !== (string)$libraryId) {
100+
continue;
101+
}
102+
$libraryInfo = $library;
103+
break;
104+
}
105+
106+
return api_response(Status::OK, [
107+
'backend' => [
108+
'library' => $libraryInfo,
109+
'name' => $client->getContext()->backendName,
110+
],
111+
'counts' => [
112+
'remote' => count($remote),
113+
'local' => $localCount,
114+
'stale' => count($list),
115+
],
116+
'items' => array_map(fn($item) => self::formatEntity($item), $list),
117+
]);
118+
}
119+
}

0 commit comments

Comments
 (0)