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:
Aiman 2023-04-28 09:57:33 +08:00 committed by GitHub
parent dc5a6b62d7
commit 1fc6c0fe50
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 226 additions and 48 deletions

View File

@ -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",

View File

@ -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">

View File

@ -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) {

View 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>

View File

@ -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
};

View File

@ -58,6 +58,9 @@ const vuetify = createVuetify({
variant: 'outlined',
color: 'primary'
},
VCheckbox: {
color: 'primary'
},
VBtn: {
color: '',
variant: 'text'

View File

@ -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
*

View File

@ -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']

View File

@ -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<