mirror of
https://github.com/jellyfin/jellyfin-vue.git
synced 2024-10-07 11:33:40 +00:00
feat: add Refresh Metadata context menu to anything other than library (#1963)
Co-authored-by: Fernando Fernández <ferferga@hotmail.com>
This commit is contained in:
parent
dc5a6b62d7
commit
1fc6c0fe50
@ -1,5 +1,4 @@
|
||||
{
|
||||
"auto": "Automatic",
|
||||
"3DFormat": "3D format",
|
||||
"NoMediaSourcesAvailable": "No media sources available",
|
||||
"actor": "Actor",
|
||||
@ -13,7 +12,7 @@
|
||||
"appbar": {
|
||||
"tasks": {
|
||||
"configSync": "Syncing settings…",
|
||||
"scanningLibrary": "Scanning library '{library}'…"
|
||||
"scanningItem": "Scanning '{item}'…"
|
||||
},
|
||||
"tooltips": {
|
||||
"tasks": "Running tasks"
|
||||
@ -25,6 +24,7 @@
|
||||
"aspectRatio": "Aspect ratio",
|
||||
"audio": "Audio",
|
||||
"audioCodecNotSupported": "The audio codec is not supported",
|
||||
"auto": "Automatic",
|
||||
"badRequest": "Bad request. Try again",
|
||||
"books": "Books",
|
||||
"browserNotSupported": "Your browser is not supported for playing this file.",
|
||||
@ -218,6 +218,13 @@
|
||||
}
|
||||
},
|
||||
"menu": "Menu",
|
||||
"refreshMetadata": "Refresh metadata",
|
||||
"replaceAllMetadata": "Replace all metadata",
|
||||
"refreshMetadataHint": "Metadata is refreshed based on settings and internet services that are enabled in the Dashboard",
|
||||
"searchMissingMetadata": "Search for missing metadata",
|
||||
"replaceExistingImages": "Replace existing images",
|
||||
"scanForNewAndUpdatedFiles": "Scan for new and updated files",
|
||||
"metadataRefreshQueued": "Metadata refresh enqueued",
|
||||
"metadata": {
|
||||
"source": "Source",
|
||||
"sourceAll": "All",
|
||||
@ -308,6 +315,7 @@
|
||||
"quality": "Quality",
|
||||
"queue": "Queue",
|
||||
"rating": "Rating",
|
||||
"refresh": "Refresh",
|
||||
"refreshLibrary": "Refresh library",
|
||||
"releaseDate": "Release date",
|
||||
"remoteDevices": "Remote devices",
|
||||
@ -340,9 +348,9 @@
|
||||
"refreshKeysFailure": "Error refreshing API keys",
|
||||
"revoke": "Revoke",
|
||||
"revokeAll": "Revoke all API keys",
|
||||
"revokeConfirm": "Confirm API key revocation",
|
||||
"revokeAllFailure": "Error revoking all API keys",
|
||||
"revokeAllSuccess": "Successfully revoked all API keys",
|
||||
"revokeConfirm": "Confirm API key revocation",
|
||||
"revokeFailure": "Error revoking API key",
|
||||
"revokeSuccess": "Successfully revoked API key"
|
||||
},
|
||||
@ -351,9 +359,9 @@
|
||||
"appVersion": "App version",
|
||||
"delete": "Delete",
|
||||
"deleteAll": "Delete all",
|
||||
"deleteConfirm": "Confirm device deletion",
|
||||
"deleteAllDevicesError": "Error deleting all devices",
|
||||
"deleteAllDevicesSuccess": "All devices deleted successfully",
|
||||
"deleteConfirm": "Confirm device deletion",
|
||||
"deleteDeviceError": "Error deleting device",
|
||||
"deleteDeviceSuccess": "Device deleted successfully",
|
||||
"deviceName": "Device name",
|
||||
@ -468,9 +476,9 @@
|
||||
"themeVideo": "Theme Video",
|
||||
"tooltips": {
|
||||
"changeLanguage": "Language",
|
||||
"switchToAuto": "Follow system theme",
|
||||
"switchToDarkMode": "Switch to dark mode",
|
||||
"switchToLightMode": "Switch to light mode",
|
||||
"switchToAuto": "Follow system theme"
|
||||
"switchToLightMode": "Switch to light mode"
|
||||
},
|
||||
"trailer": "Trailer",
|
||||
"transcodingInfo": {
|
||||
@ -515,7 +523,6 @@
|
||||
"undefined": "Undefined",
|
||||
"unexpectedError": "Unexpected error",
|
||||
"unhandledException": "Unhandled exception",
|
||||
"unknown": "Unknown",
|
||||
"units": {
|
||||
"bitrate": {
|
||||
"kbps": "{value} kbps",
|
||||
@ -525,6 +532,7 @@
|
||||
"seconds": "{count} second | {count} seconds"
|
||||
}
|
||||
},
|
||||
"unknown": "Unknown",
|
||||
"unliked": "Unliked",
|
||||
"unplayed": "Unplayed",
|
||||
"upNext": "Up next",
|
||||
|
@ -14,11 +14,11 @@
|
||||
{{ state.text }}
|
||||
</v-card-text>
|
||||
<v-card-actions class="align-center justify-center">
|
||||
<v-btn variant="elevated" color="secondary" @click="cancel">
|
||||
<v-btn variant="elevated" color="secondary" width="8em" @click="cancel">
|
||||
{{ t('cancel') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
max-width="100%"
|
||||
width="8em"
|
||||
variant="elevated"
|
||||
:color="state.confirmColor ?? 'error'"
|
||||
@click="confirm">
|
||||
|
@ -33,9 +33,13 @@
|
||||
</v-menu>
|
||||
</v-btn>
|
||||
<metadata-editor-dialog
|
||||
v-if="item.Id"
|
||||
v-if="metadataDialog && item.Id"
|
||||
v-model:dialog="metadataDialog"
|
||||
:item-id="item.Id" />
|
||||
<refresh-metadata-dialog
|
||||
v-if="refreshDialog && item.Id"
|
||||
:item="menuProps.item"
|
||||
@close="refreshDialog = false" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@ -43,7 +47,6 @@ import { computed, getCurrentInstance, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import { getItemRefreshApi } from '@jellyfin/sdk/lib/utils/api/item-refresh-api';
|
||||
import IMdiPlaySpeed from 'virtual:icons/mdi/play-speed';
|
||||
import IMdiArrowExpandUp from 'virtual:icons/mdi/arrow-expand-up';
|
||||
import IMdiArrowExpandDown from 'virtual:icons/mdi/arrow-expand-down';
|
||||
@ -58,8 +61,7 @@ import IMdiRefresh from 'virtual:icons/mdi/refresh';
|
||||
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useRemote, useSnackbar, useConfirmDialog } from '@/composables';
|
||||
import { canInstantMix, canResume } from '@/utils/items';
|
||||
import { TaskType } from '@/store/taskManager';
|
||||
import { canInstantMix, canRefreshMetadata, canResume } from '@/utils/items';
|
||||
import { playbackManagerStore, taskManagerStore } from '@/store';
|
||||
|
||||
type MenuOption = {
|
||||
@ -95,6 +97,7 @@ const show = ref(false);
|
||||
const positionX = ref<number | undefined>(undefined);
|
||||
const positionY = ref<number | undefined>(undefined);
|
||||
const metadataDialog = ref(false);
|
||||
const refreshDialog = ref(false);
|
||||
const playbackManager = playbackManagerStore();
|
||||
const taskManager = taskManagerStore();
|
||||
const errorMessage = t('errors.anErrorHappened');
|
||||
@ -185,31 +188,11 @@ const instantMixAction = {
|
||||
/**
|
||||
* Item related actions
|
||||
*/
|
||||
const refreshLibraryAction = {
|
||||
title: t('refreshLibrary'),
|
||||
const refreshAction = {
|
||||
title: t('refreshMetadata'),
|
||||
icon: IMdiRefresh,
|
||||
action: async (): Promise<void> => {
|
||||
if (remote.sdk.api && menuProps.item.Id) {
|
||||
try {
|
||||
await getItemRefreshApi(remote.sdk.api).refreshItem({
|
||||
itemId: menuProps.item.Id,
|
||||
replaceAllImages: false,
|
||||
replaceAllMetadata: false
|
||||
});
|
||||
|
||||
useSnackbar(t('libraryRefreshQueued'), 'normal');
|
||||
taskManager.startTask({
|
||||
type: TaskType.LibraryRefresh,
|
||||
id: menuProps.item.Id || '',
|
||||
data: menuProps.item.Name || '',
|
||||
progress: 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
useSnackbar(t('unableToRefreshLibrary'), 'error');
|
||||
}
|
||||
}
|
||||
action: (): void => {
|
||||
refreshDialog.value = true;
|
||||
},
|
||||
disabled: isItemRefreshing.value
|
||||
};
|
||||
@ -322,13 +305,8 @@ function getPlaybackOptions(): MenuOption[] {
|
||||
function getLibraryOptions(): MenuOption[] {
|
||||
const libraryOptions: MenuOption[] = [];
|
||||
|
||||
if (
|
||||
remote.auth.currentUser?.Policy?.IsAdministrator &&
|
||||
['Folder', 'CollectionFolder', 'UserView'].includes(
|
||||
menuProps.item.Type || ''
|
||||
)
|
||||
) {
|
||||
libraryOptions.push(refreshLibraryAction);
|
||||
if (canRefreshMetadata(menuProps.item)) {
|
||||
libraryOptions.push(refreshAction);
|
||||
}
|
||||
|
||||
if (remote.auth.currentUser?.Policy?.IsAdministrator) {
|
||||
|
157
frontend/src/components/Item/Metadata/RefreshMetadataDialog.vue
Normal file
157
frontend/src/components/Item/Metadata/RefreshMetadataDialog.vue
Normal file
@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
width="auto"
|
||||
:model-value="model"
|
||||
:fullscreen="$vuetify.display.mobile"
|
||||
@after-leave="emit('close')">
|
||||
<v-card class="pa-3">
|
||||
<v-card-title class="text-center">
|
||||
{{ t('refreshMetadata') }}
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<!-- TODO: Investigate why style is needed for mobile breakpoint -->
|
||||
<v-select
|
||||
v-model="selectedMethod"
|
||||
:items="refreshMethods"
|
||||
:hint="t('refreshMetadataHint')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
single-line
|
||||
persistent-hint
|
||||
return-object
|
||||
style="display: unset" />
|
||||
|
||||
<v-spacer v-if="selectedMethod.value !== 'scan'" />
|
||||
<v-checkbox
|
||||
v-if="selectedMethod.value !== 'scan'"
|
||||
v-model="replace"
|
||||
:label="t('replaceExistingImages')" />
|
||||
|
||||
<v-card-actions
|
||||
class="d-flex align-center"
|
||||
:class="{
|
||||
'justify-end': !$vuetify.display.mobile,
|
||||
'justify-center': $vuetify.display.mobile
|
||||
}">
|
||||
<v-btn
|
||||
variant="flat"
|
||||
width="8em"
|
||||
color="secondary"
|
||||
@click="model = false">
|
||||
{{ t('cancel') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="flat"
|
||||
width="8em"
|
||||
color="primary"
|
||||
:loading="loading"
|
||||
@click="refreshMetadata">
|
||||
{{ t('refresh') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
BaseItemDto,
|
||||
MetadataRefreshMode
|
||||
} from '@jellyfin/sdk/lib/generated-client';
|
||||
import { getItemRefreshApi } from '@jellyfin/sdk/lib/utils/api/item-refresh-api';
|
||||
import { useRemote, useSnackbar } from '@/composables';
|
||||
import taskManager, { TaskType } from '@/store/taskManager';
|
||||
|
||||
interface RefreshMethod {
|
||||
title: string;
|
||||
value: 'scan' | 'missing' | 'all';
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
item: BaseItemDto;
|
||||
}>();
|
||||
|
||||
const model = ref(true);
|
||||
const loading = ref(false);
|
||||
const replace = ref(false);
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
}>();
|
||||
const { t } = useI18n();
|
||||
const selectedMethod = ref<RefreshMethod>({
|
||||
title: t('scanForNewAndUpdatedFiles'),
|
||||
value: 'scan'
|
||||
});
|
||||
const refreshMethods = computed<RefreshMethod[]>(() => [
|
||||
{
|
||||
title: t('scanForNewAndUpdatedFiles'),
|
||||
value: 'scan'
|
||||
},
|
||||
{
|
||||
title: t('searchMissingMetadata'),
|
||||
value: 'missing'
|
||||
},
|
||||
{
|
||||
title: t('replaceAllMetadata'),
|
||||
value: 'all'
|
||||
}
|
||||
]);
|
||||
const refreshMode = computed<MetadataRefreshMode>(() => {
|
||||
switch (selectedMethod.value.value) {
|
||||
case 'scan': {
|
||||
return 'Default';
|
||||
}
|
||||
case 'missing': {
|
||||
return 'FullRefresh';
|
||||
}
|
||||
case 'all': {
|
||||
return 'FullRefresh';
|
||||
}
|
||||
default: {
|
||||
return 'Default';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Refresh metadata of the current item
|
||||
*/
|
||||
async function refreshMetadata(): Promise<void> {
|
||||
const remote = useRemote();
|
||||
const replaceMetadata = selectedMethod.value.value === 'all';
|
||||
|
||||
if (!props.item.Id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
|
||||
await remote.sdk.newUserApi(getItemRefreshApi).refreshItem({
|
||||
itemId: props.item.Id,
|
||||
metadataRefreshMode: refreshMode.value,
|
||||
imageRefreshMode: refreshMode.value,
|
||||
replaceAllMetadata: replaceMetadata,
|
||||
replaceAllImages: replace.value
|
||||
});
|
||||
|
||||
taskManager.startTask({
|
||||
type: TaskType.LibraryRefresh,
|
||||
id: props.item.Id || '',
|
||||
data: props.item.Name || 'ID ' + props.item.Id,
|
||||
progress: 0
|
||||
});
|
||||
|
||||
useSnackbar(t('metadataRefreshQueued'), 'success');
|
||||
model.value = false;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
useSnackbar(t('anErrorHappened'), 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
@ -72,9 +72,9 @@ const mappedTaskList = computed<TaskInfo[]>(() => {
|
||||
case TaskType.LibraryRefresh: {
|
||||
return {
|
||||
progress: t.progress,
|
||||
textKey: 'appbar.tasks.scanningLibrary',
|
||||
textKey: 'appbar.tasks.scanningItem',
|
||||
textParams: {
|
||||
library: t.data ?? ''
|
||||
item: t.data ?? ''
|
||||
},
|
||||
id: t.id
|
||||
};
|
||||
|
@ -58,6 +58,9 @@ const vuetify = createVuetify({
|
||||
variant: 'outlined',
|
||||
color: 'primary'
|
||||
},
|
||||
VCheckbox: {
|
||||
color: 'primary'
|
||||
},
|
||||
VBtn: {
|
||||
color: '',
|
||||
variant: 'text'
|
||||
|
@ -26,6 +26,7 @@ import IMdiBookMusic from 'virtual:icons/mdi/book-music';
|
||||
import IMdiFolderMultiple from 'virtual:icons/mdi/folder-multiple';
|
||||
import IMdiFilmstrip from 'virtual:icons/mdi/filmstrip';
|
||||
import IMdiAlbum from 'virtual:icons/mdi/album';
|
||||
import { useRemote } from '@/composables';
|
||||
|
||||
/**
|
||||
* A list of valid collections that should be treated as folders.
|
||||
@ -81,6 +82,13 @@ export function isValidMD5(input: string): boolean {
|
||||
return /[\dA-Fa-f]{32}/.test(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the item is a library
|
||||
*/
|
||||
export function isLibrary(item: BaseItemDto): boolean {
|
||||
return validLibraryTypes.includes(item.Type ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Material Design Icon name associated with a type of library
|
||||
*
|
||||
@ -258,6 +266,29 @@ export function canInstantMix(item: BaseItemDto): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an item's metadata can be refreshed.
|
||||
*/
|
||||
export function canRefreshMetadata(item: BaseItemDto): boolean {
|
||||
const remote = useRemote();
|
||||
const invalidRefreshType = ['Timer', 'SeriesTimer', 'Program', 'TvChannel'];
|
||||
|
||||
if (item.CollectionType === 'livetv') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const incompleteRecording =
|
||||
item.Type === BaseItemKind.Recording && item.Status !== 'Completed';
|
||||
const IsAdministrator =
|
||||
remote.auth.currentUser?.Policy?.IsAdministrator ?? false;
|
||||
|
||||
return (
|
||||
IsAdministrator &&
|
||||
!incompleteRecording &&
|
||||
!invalidRefreshType.includes(item.Type ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a link to the item's details page route
|
||||
*
|
||||
|
1
frontend/types/global/components.d.ts
vendored
1
frontend/types/global/components.d.ts
vendored
@ -110,6 +110,7 @@ declare module '@vue/runtime-core' {
|
||||
PlayPauseButton: typeof import('./../../src/components/Buttons/Playback/PlayPauseButton.vue')['default']
|
||||
PreviousTrackButton: typeof import('./../../src/components/Buttons/Playback/PreviousTrackButton.vue')['default']
|
||||
QueueButton: typeof import('./../../src/components/Buttons/QueueButton.vue')['default']
|
||||
RefreshMetadataDialog: typeof import('./../../src/components/Item/Metadata/RefreshMetadataDialog.vue')['default']
|
||||
RelatedItems: typeof import('./../../src/components/Item/RelatedItems.vue')['default']
|
||||
RepeatButton: typeof import('./../../src/components/Buttons/Playback/RepeatButton.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
|
4
frontend/types/global/routes.d.ts
vendored
4
frontend/types/global/routes.d.ts
vendored
@ -31,7 +31,7 @@ import type {
|
||||
// data fetching
|
||||
_DataLoader,
|
||||
_DefineLoaderOptions,
|
||||
} from 'unplugin-vue-router'
|
||||
} from 'unplugin-vue-router/types'
|
||||
|
||||
declare module 'vue-router/auto/routes' {
|
||||
export interface RouteNamedMap {
|
||||
@ -112,7 +112,7 @@ declare module 'vue-router/auto' {
|
||||
export function onBeforeRouteUpdate(guard: NavigationGuard<RouteNamedMap>): void
|
||||
|
||||
export const RouterLink: RouterLinkTyped<RouteNamedMap>
|
||||
|
||||
|
||||
// Experimental Data Fetching
|
||||
|
||||
export function defineLoader<
|
||||
|
Loading…
Reference in New Issue
Block a user