diff --git a/composer.lock b/composer.lock index 65621895..039d70b7 100644 --- a/composer.lock +++ b/composer.lock @@ -3588,12 +3588,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "45b01f4e60c350f72a8697056674e449e053935a" + "reference": "59be420c5cdc0c3c9cdae31804d32fefb515a918" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/45b01f4e60c350f72a8697056674e449e053935a", - "reference": "45b01f4e60c350f72a8697056674e449e053935a", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/59be420c5cdc0c3c9cdae31804d32fefb515a918", + "reference": "59be420c5cdc0c3c9cdae31804d32fefb515a918", "shasum": "" }, "conflict": { @@ -3611,7 +3611,7 @@ "airesvsg/acf-to-rest-api": "<=3.1", "akaunting/akaunting": "<2.1.13", "akeneo/pim-community-dev": "<5.0.119|>=6,<6.0.53", - "alextselegidis/easyappointments": "<=1.5", + "alextselegidis/easyappointments": "<=1.5.1", "alterphp/easyadmin-extension-bundle": ">=1.2,<1.2.11|>=1.3,<1.3.1", "amazing/media2click": ">=1,<1.3.3", "ameos/ameos_tarteaucitron": "<1.2.23", @@ -3715,7 +3715,7 @@ "contao/managed-edition": "<=1.5", "corveda/phpsandbox": "<1.3.5", "cosenary/instagram": "<=2.3", - "craftcms/cms": "<=4.14.14|>=5,<=5.6.16", + "craftcms/cms": "<4.15.3|>=5,<5.7.5", "croogo/croogo": "<4", "cuyz/valinor": "<0.12", "czim/file-handling": "<1.5|>=2,<2.3", @@ -3948,6 +3948,7 @@ "klaviyo/magento2-extension": ">=1,<3", "knplabs/knp-snappy": "<=1.4.2", "kohana/core": "<3.3.3", + "koillection/koillection": "<1.6.12", "krayin/laravel-crm": "<=1.3", "kreait/firebase-php": ">=3.2,<3.8.1", "kumbiaphp/kumbiapp": "<=1.1.1", @@ -3966,7 +3967,7 @@ "latte/latte": "<2.10.8", "lavalite/cms": "<=9|==10.1", "lcobucci/jwt": ">=3.4,<3.4.6|>=4,<4.0.4|>=4.1,<4.1.5", - "league/commonmark": "<2.6", + "league/commonmark": "<2.7", "league/flysystem": "<1.1.4|>=2,<2.1.1", "league/oauth2-server": ">=8.3.2,<8.4.2|>=8.5,<8.5.3", "leantime/leantime": "<3.3", @@ -4066,9 +4067,9 @@ "nzo/url-encryptor-bundle": ">=4,<4.3.2|>=5,<5.0.1", "october/backend": "<1.1.2", "october/cms": "<1.0.469|==1.0.469|==1.0.471|==1.1.1", - "october/october": "<=3.6.4", + "october/october": "<3.7.5", "october/rain": "<1.0.472|>=1.1,<1.1.2", - "october/system": "<1.0.476|>=1.1,<1.1.12|>=2,<2.2.34|>=3,<3.5.15", + "october/system": "<3.7.5", "oliverklee/phpunit": "<3.5.15", "omeka/omeka-s": "<4.0.3", "onelogin/php-saml": "<2.10.4", @@ -4196,8 +4197,8 @@ "serluck/phpwhois": "<=4.2.6", "sfroemken/url_redirect": "<=1.2.1", "sheng/yiicms": "<1.2.1", - "shopware/core": "<6.5.8.17-dev|>=6.6,<6.6.10.3-dev|>=6.7.0.0-RC1-dev,<6.7.0.0-RC2-dev", - "shopware/platform": "<6.5.8.17-dev|>=6.6,<6.6.10.3-dev|>=6.7.0.0-RC1-dev,<6.7.0.0-RC2-dev", + "shopware/core": "<6.6.10.3-dev|>=6.7.0.0-RC1-dev,<6.7.0.0-RC2-dev", + "shopware/platform": "<6.6.10.3-dev|>=6.7.0.0-RC1-dev,<6.7.0.0-RC2-dev", "shopware/production": "<=6.3.5.2", "shopware/shopware": "<=5.7.17", "shopware/storefront": "<=6.4.8.1|>=6.5.8,<6.5.8.7-dev", @@ -4240,7 +4241,7 @@ "slim/slim": "<2.6", "slub/slub-events": "<3.0.3", "smarty/smarty": "<4.5.3|>=5,<5.1.1", - "snipe/snipe-it": "<=7.0.13", + "snipe/snipe-it": "<8.1", "socalnick/scn-social-auth": "<1.15.2", "socialiteproviders/steam": "<1.1", "spatie/browsershot": "<5.0.5", @@ -4510,7 +4511,7 @@ "type": "tidelift" } ], - "time": "2025-05-01T20:05:59+00:00" + "time": "2025-05-08T20:06:04+00:00" }, { "name": "sebastian/cli-parser", diff --git a/frontend/pages/history/[id]/index.vue b/frontend/pages/history/[id]/index.vue index e718aaaf..dc069c92 100644 --- a/frontend/pages/history/[id]/index.vue +++ b/frontend/pages/history/[id]/index.vue @@ -4,7 +4,7 @@
-   +   History : {{ headerTitle }} @@ -13,35 +13,35 @@

@@ -49,23 +49,23 @@
+ @click="expandOverview = !expandOverview" :class="{ 'is-text-overflow': !expandOverview }"> - + {{ expandOverview ? data.content_overview : data.content_overview }}
+ :class="{ 'is-text-overflow': !expandGenres }" @click="expandGenres = !expandGenres"> - - + :key="`head-genre-${id}`"> + +
@@ -73,23 +73,23 @@
+ message="Loading data. Please wait..."/>
+ :toggle="show_history_page_warning" title="Warning" :use-toggle="true" + @toggle="show_history_page_warning = !show_history_page_warning">

- + There are no metadata regarding this {{ data.type }} from ( - + ).

- + Possible reasons
@@ -113,15 +113,15 @@
- + Latest local metadata via
- + - +
@@ -130,17 +130,17 @@
- + ID:  - +
- + Progress: {{ formatDuration(data.progress) }} - @@ -149,8 +149,8 @@
- - + + Status: @@ -160,7 +160,7 @@
- + Event: {{ ag(data.extra, `${data.via}.event`, 'Unknown') }} @@ -169,11 +169,11 @@
- + Updated:  + v-tooltip="`Backend updated this record at: ${moment.unix(data.updated).format(TOOLTIP_DATE_FORMAT)}`"> {{ moment.unix(data.updated).fromNow() }} @@ -182,36 +182,36 @@
- - + + Type:  - +
- + Season:  - +
- + Episode:  - +
- + GUIDs:  @@ -223,7 +223,7 @@
- + rGUIDs:  @@ -235,7 +235,7 @@
- + Series GUIDs:  @@ -247,27 +247,27 @@
- + Subtitle:  - +
- + File:  - +
- + Created:  + v-tooltip="`DB record created at: ${moment.unix(data.created_at).format(TOOLTIP_DATE_FORMAT)}`"> {{ moment.unix(data.created_at).fromNow() }} @@ -276,11 +276,11 @@
- + Updated:  + v-tooltip="`DB record updated at: ${moment.unix(data.updated_at).format(TOOLTIP_DATE_FORMAT)}`"> {{ moment.unix(data.updated_at).fromNow() }} @@ -289,20 +289,20 @@
- + @click="expandGenres = !expandGenres"> + Genres:  + :key="`latest-${genre}`" v-text="genre"/>
- + Content Summary
+ @click="expandOverview = !expandOverview"> {{ data.content_overview }}
@@ -314,40 +314,60 @@
+ :class="{ 'is-success': parseInt(item.watched), 'transparent-bg': styleInfo }">
- + +   +   Metadata via
- - - - - - +
+
+ + + + Delete + + +
+
+ + + + + + +
+
- +
+ ({{ item.validated_message }}) +
- + ID:  - - + +
- + Progress: {{ formatDuration(item.progress) }} - @@ -356,7 +376,7 @@
- + Status: @@ -367,7 +387,7 @@
- + Event: {{ ag(data.extra, `${key}.event`, 'Unknown') }} @@ -377,11 +397,11 @@
- + Updated:  + v-tooltip="`Backend last activity: ${getMoment(ag(data.extra, `${key}.received_at`, data.updated)).format(TOOLTIP_DATE_FORMAT)}`"> {{ getMoment(ag(data.extra, `${key}.received_at`, data.updated)).fromNow() }} @@ -390,38 +410,38 @@
- - + + Type:  - +
- + Season:  - +
- + Episode:  - +
- + GUIDs:  @@ -433,7 +453,7 @@
- + Series GUIDs:  @@ -445,37 +465,37 @@
- + Subtitle:  - +
- + @click="item.expandGenres = !item?.expandGenres"> + Genres:  + :key="`${item.id}-${genre}`" v-text="genre"/>
- + Content Summary
+ @click="item.expandOverview = !item?.expandOverview"> {{ item.extra.overview }}
- + File:  - +
@@ -490,8 +510,8 @@ - - + + Show raw unfiltered data @@ -499,23 +519,23 @@

Useful for debugging.

{{ - 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) - }} + 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) + }}
+ @toggle="show_page_tips = !show_page_tips" :use-toggle="true" title="Tips" icon="fas fa-info-circle">
  • To see if your media backends are reporting different metadata for the same file, click on the file link @@ -527,7 +547,7 @@
  • rGUIDSs are relative globally unique identifiers for episodes based on series - GUID. They are formatted as GUID://seriesID/season_number/episode_number. We use + GUID. They are formatted as GUID://seriesID/season_number/episode_number. We use rGUIDs, to identify specific episode. This is more reliable than using episode specific GUID, as they are often misreported in the source data.
  • @@ -563,12 +583,12 @@ import { ucFirst } from '~/utils/index' import moment from 'moment' -import { useBreakpoints, useStorage } from '@vueuse/core' +import {useBreakpoints, useStorage} from '@vueuse/core' import Message from '~/components/Message' const id = useRoute().params.id -useHead({ title: `History : ${id}` }) +useHead({title: `History : ${id}`}) const isLoading = ref(true) const showRawData = ref(false) @@ -576,8 +596,8 @@ const show_page_tips = useStorage('show_page_tips', true) const api_show_photos = useStorage('api_show_photos', true) const show_history_page_warning = useStorage('show_history_page_warning', true) const isDeleting = ref(false) -const breakpoints = useBreakpoints({ mobile: 0, desktop: 640 }) -const loadedImages = ref({ poster: null, background: null }) +const breakpoints = useBreakpoints({mobile: 0, desktop: 640}) +const loadedImages = ref({poster: null, background: null}) const expandOverview = ref(false) const expandGenres = ref(false) @@ -619,7 +639,7 @@ const loadContent = async (id) => { if (200 !== response.status) { notification('Error', 'Error loading data', `${json.error.code}: ${json.error.message}`); if (404 === response.status) { - await navigateTo({ name: 'history' }) + await navigateTo({name: 'history'}) } return } @@ -627,8 +647,10 @@ const loadContent = async (id) => { data.value = json data.value._toggle = true - useHead({ title: `History : ${makeName(json) ?? id}` }) + useHead({title: `History : ${makeName(json) ?? id}`}) await loadImage() + await nextTick(); + await validateItem() } watch(breakpoints.active(), async () => await loadImage()) @@ -676,7 +698,7 @@ const deleteItem = async (item) => { isDeleting.value = true try { - const response = await request(`/history/${id}`, { method: 'DELETE' }) + const response = await request(`/history/${id}`, {method: 'DELETE'}) if (200 !== response.status) { const json = await response.json() @@ -685,7 +707,7 @@ const deleteItem = async (item) => { } notification('success', 'Success!', `Deleted '${makeName(item)}'.`) - await navigateTo({ name: 'history' }) + await navigateTo({name: 'history'}) } catch (e) { notification('error', 'Error', e.message) } finally { @@ -720,6 +742,48 @@ const toggleWatched = async () => { } } +const validateItem = async () => { + try { + const response = await request(`/history/${id}/validate`) + + if (!response.ok) { + return + } + + const json = await response.json() + + for (const [backend, item] of Object.entries(json)) { + if (data.value.metadata[backend] === undefined) { + continue + } + + data.value.metadata[backend]['validated'] = item.status + data.value.metadata[backend]['validated_message'] = item.message + } + } catch (e) { + } +} +const deleteMetadata = async backend => { + if (!confirm(`Remove metadata from '${backend}'?`)) { + return + } + + try { + const response = await request(`/history/${id}/metadata/${backend}`, {method: 'DELETE'}) + + if (200 !== response.status) { + const json = await parse_api_response(response) + notification('error', 'Error', `${json.error.code}: ${json.error.message}`) + return + } + + notification('success', 'Success!', `Deleted '${backend}' metadata.`) + await loadContent(id); + } catch (e) { + notification('error', 'Error', `Request error. ${e}`) + } +} + const getMoment = (time) => time.toString().length < 13 ? moment.unix(time) : moment(time) const headerTitle = computed(() => isLoading.value ? id : makeName(data.value)) diff --git a/src/API/History/Index.php b/src/API/History/Index.php index b35cb455..55bb8827 100644 --- a/src/API/History/Index.php +++ b/src/API/History/Index.php @@ -12,6 +12,7 @@ use App\Libs\Attributes\Route\Get; use App\Libs\Attributes\Route\Route; use App\Libs\Container; use App\Libs\DataUtil; +use App\Libs\Entity\StateEntity; use App\Libs\Entity\StateInterface as iState; use App\Libs\Enums\Http\Method; use App\Libs\Enums\Http\Status; @@ -19,12 +20,15 @@ use App\Libs\Exceptions\RuntimeException; use App\Libs\Guid; use App\Libs\Mappers\Import\DirectMapper; use App\Libs\Mappers\ImportInterface as iImport; +use App\Libs\Options; use App\Libs\Traits\APITraits; +use DateInterval; use JsonException; use PDO; use Psr\Http\Message\ResponseInterface as iResponse; use Psr\Http\Message\ServerRequestInterface as iRequest; use Psr\Log\LoggerInterface as iLogger; +use Psr\SimpleCache\CacheInterface as iCache; use SplFileInfo; use Throwable; @@ -689,6 +693,105 @@ final class Index return $this->read($request, $id); } + #[Get(self::URL . '/{id:\d+}/validate[/]', name: 'history.validate')] + public function validate_item(iCache $cache, iRequest $request, string $id): iResponse + { + try { + $userContext = $this->getUserContext(request: $request, mapper: $this->mapper, logger: $this->logger); + } catch (RuntimeException $e) { + return api_error($e->getMessage(), Status::NOT_FOUND); + } + + $entity = Container::get(iState::class)::fromArray([iState::COLUMN_ID => $id]); + + if (null === ($item = $userContext->db->get($entity))) { + return api_error('Item Not found', Status::NOT_FOUND); + } + + $cacheKey = r('validate_item_{id}_{user}_{backends}', [ + 'id' => $item->id, + 'user' => $userContext->name, + 'backends' => md5(implode(',', array_keys($item->getMetadata()))), + ]); + + try { + if (null !== ($cached = $cache->get($cacheKey))) { + return api_response(Status::OK, $cached, headers: ['X-Cache' => 'HIT']); + } + } catch (Throwable) { + // Ignore cache errors. + } + + $validation = []; + + foreach ($item->getMetadata() as $name => $metadata) { + $id = ag($metadata, StateEntity::COLUMN_ID, null); + $validation[$name] = [ + 'id' => $id, + 'status' => false, + 'message' => 'Item not found.', + ]; + + if (null === $userContext->config->get($name)) { + $validation[$name]['message'] = 'Backend not found.'; + continue; + } + + if (null === $id) { + $validation[$name]['message'] = 'Item ID is missing.'; + continue; + } + + try { + $client = $this->getClient(name: $name, userContext: $userContext); + $item = $client->getMetadata($id, [Options::NO_LOGGING => true]); + if (count($item) > 0) { + $validation[$name]['status'] = true; + $validation[$name]['message'] = 'Item found.'; + } else { + $validation[$name]['status'] = false; + $validation[$name]['message'] = 'Item not found.'; + } + } catch (Throwable $e) { + $validation[$name]['message'] = $e->getMessage(); + } + } + + try { + $cache->set($cacheKey, $validation, new DateInterval('PT10M')); + } catch (Throwable) { + // Ignore cache errors. + } + + return api_response(Status::OK, $validation, headers: ['X-Cache' => 'MISS']); + } + + #[Delete(self::URL . '/{id:\d+}/metadata/{backend}[/]', name: 'history.metadata.delete')] + public function delete_item_metadata(iCache $cache, iRequest $request, string $id, string $backend): iResponse + { + try { + $userContext = $this->getUserContext(request: $request, mapper: $this->mapper, logger: $this->logger); + } catch (RuntimeException $e) { + return api_error($e->getMessage(), Status::NOT_FOUND); + } + + $entity = Container::get(iState::class)::fromArray([iState::COLUMN_ID => $id]); + + if (null === ($item = $userContext->db->get($entity))) { + return api_error('Item Not found', Status::NOT_FOUND); + } + + if (null === ($item->getMetadata($backend) ?? null)) { + return api_error('Item metadata not found.', Status::NOT_FOUND); + } + + $item->metadata = ag_delete($item->getMetadata(), $backend); + + $userContext->db->update($item); + + return $this->read($request, $id); + } + #[Get(self::URL . '/{id:\d+}/images/{type:poster|background}[/]', name: 'history.item.images')] public function images(iRequest $request, string $id, string $type): iResponse {