fix(music):优化音乐识别缓存管理UI

This commit is contained in:
jxxghp
2026-08-11 12:58:35 +08:00
parent 5db3ccc6a1
commit 6e76de293f
7 changed files with 437 additions and 661 deletions

View File

@@ -1,51 +1,38 @@
<script setup lang="ts">
import { useToast } from 'vue-toastification'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import api from '@/api'
import type { ApiResponse, MusicRecognitionCacheData, MusicRecognitionCacheItem } from '@/api/types'
import { useConfirm } from '@/composables/useConfirm'
import type { MusicRecognitionCacheItem } from '@/api/types'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
type RecognitionStatusFilter = 'all' | 'recognized' | 'unrecognized'
type InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error'
const MOBILE_CACHE_PAGE_SIZE = 20
const MUSIC_CACHE_ENDPOINT = 'music/cache'
// 纯展示组件:数据加载与删除请求由父级识别缓存面板统一负责
const props = defineProps<{
// 已按搜索与状态条件过滤的音乐识别缓存
items: MusicRecognitionCacheItem[]
loading: boolean
selectedItems: string[]
}>()
const emit = defineEmits<{
(e: 'update:selectedItems', value: string[]): void
(e: 'delete', key: string): void
}>()
const { t } = useI18n()
const display = useDisplay()
const createConfirm = useConfirm()
const $toast = useToast()
const globalSettingsStore = useGlobalSettingsStore()
const isMobile = computed(() => display.smAndDown.value)
const recognitionSourceName = computed(() => t('setting.cache.recognitionSource.musicbrainz'))
const recognitionIdLabel = computed(() => t('setting.cache.musicbrainzId'))
const recognitionFilterPlaceholder = computed(() =>
t('setting.cache.filterRecognitionCache', { source: recognitionSourceName.value }),
)
const loading = ref(false)
const searchFilter = ref('')
const statusFilter = ref<RecognitionStatusFilter>('all')
const selectedItems = ref<string[]>([])
const cacheData = ref<MusicRecognitionCacheData>({
count: 0,
recognized: 0,
unrecognized: 0,
data: [],
})
const mobileVisibleCount = ref(MOBILE_CACHE_PAGE_SIZE)
const mobileInfiniteKey = ref(0)
let cacheLoadRequestId = 0
const statusOptions = computed(() => [
{ title: t('setting.cache.allStatuses'), value: 'all' },
{ title: t('setting.cache.recognizedOnly'), value: 'recognized' },
{ title: t('setting.cache.unrecognizedOnly'), value: 'unrecognized' },
])
const tableHeaders = computed(() => [
{ title: '', key: 'data-table-select', sortable: false, width: '48px' },
@@ -57,22 +44,8 @@ const tableHeaders = computed(() => [
{ title: t('setting.cache.actions'), key: 'actions', sortable: false, width: '72px' },
])
const filteredData = computed(() => {
const keyword = searchFilter.value.trim().toLowerCase()
return cacheData.value.data.filter(item => {
const matchesKeyword =
!keyword ||
[item.key, item.title, item.album, item.media_id, getArtistText(item), String(item.year ?? '')].some(value =>
(value || '').toLowerCase().includes(keyword),
)
const matchesStatus =
statusFilter.value === 'all' || (statusFilter.value === 'recognized' ? isRecognized(item) : !isRecognized(item))
return matchesKeyword && matchesStatus
})
})
const mobileVisibleData = computed(() => filteredData.value.slice(0, mobileVisibleCount.value))
const mobileHasMore = computed(() => mobileVisibleData.value.length < filteredData.value.length)
const mobileVisibleData = computed(() => props.items.slice(0, mobileVisibleCount.value))
const mobileHasMore = computed(() => mobileVisibleData.value.length < props.items.length)
/** 重置移动端分页,让筛选或刷新后的识别缓存从第一页开始展示。 */
function resetMobilePagination() {
@@ -82,7 +55,7 @@ function resetMobilePagination() {
/** 追加移动端下一页识别缓存,并由虚拟滚动限制实际渲染节点。 */
function loadMoreMobileCache({ done }: { done: (status: InfiniteScrollStatus) => void }) {
if (loading.value) {
if (props.loading) {
done('ok')
return
}
@@ -92,104 +65,13 @@ function loadMoreMobileCache({ done }: { done: (status: InfiniteScrollStatus) =>
return
}
mobileVisibleCount.value = Math.min(mobileVisibleCount.value + MOBILE_CACHE_PAGE_SIZE, filteredData.value.length)
mobileVisibleCount.value = Math.min(mobileVisibleCount.value + MOBILE_CACHE_PAGE_SIZE, props.items.length)
done(mobileHasMore.value ? 'ok' : 'empty')
}
/** 加载音乐识别缓存列表。 */
async function loadCacheData(showSuccess = false) {
const requestId = ++cacheLoadRequestId
try {
loading.value = true
const response = (await api.get(MUSIC_CACHE_ENDPOINT)) as unknown as ApiResponse<MusicRecognitionCacheData>
if (requestId !== cacheLoadRequestId) return
const responseData = response.data ?? {
count: 0,
recognized: 0,
unrecognized: 0,
data: [],
}
cacheData.value = {
...responseData,
data: (responseData.data ?? []).map(item => ({ ...item })),
}
selectedItems.value = selectedItems.value.filter(key => cacheData.value.data.some(item => item.key === key))
resetMobilePagination()
if (showSuccess) $toast.success(t('setting.cache.listRefreshSuccess'))
} catch (error) {
if (requestId !== cacheLoadRequestId) return
console.error(error)
$toast.error(t('setting.cache.loadFailed'))
} finally {
if (requestId === cacheLoadRequestId) loading.value = false
}
}
/** 清空全部音乐识别缓存。 */
async function clearAllCache() {
const confirmed = await createConfirm({
type: 'warn',
title: t('common.confirm'),
content: t('setting.cache.recognitionClearConfirm', { source: recognitionSourceName.value }),
})
if (!confirmed) return
try {
loading.value = true
const response = (await api.delete(MUSIC_CACHE_ENDPOINT)) as unknown as ApiResponse
if (!response.success) throw new Error(response.message)
$toast.success(response.message || t('setting.cache.clearSuccess'))
await loadCacheData()
selectedItems.value = []
} catch (error) {
console.error(error)
$toast.error(t('setting.cache.clearFailed'))
} finally {
loading.value = false
}
}
/** 请求接口删除指定音乐识别缓存。 */
async function deleteCacheItem(key: string) {
const response = (await api.delete(`${MUSIC_CACHE_ENDPOINT}/${encodeURIComponent(key)}`)) as unknown as ApiResponse
if (!response.success) throw new Error(response.message)
}
/** 删除桌面端表格中选中的识别缓存。 */
async function deleteSelectedItems() {
if (selectedItems.value.length === 0) {
$toast.warning(t('setting.cache.selectDeleteWarning'))
return
}
const deleteCount = selectedItems.value.length
try {
loading.value = true
await Promise.all(selectedItems.value.map(deleteCacheItem))
$toast.success(t('setting.cache.deleteSelectedSuccess', { count: deleteCount }))
await loadCacheData()
selectedItems.value = []
} catch (error) {
console.error(error)
$toast.error(t('setting.cache.deleteSelectedFailed'))
} finally {
loading.value = false
}
}
/** 删除单条识别缓存。 */
async function deleteSingleItem(item: MusicRecognitionCacheItem) {
try {
loading.value = true
await deleteCacheItem(item.key)
$toast.success(t('setting.cache.deleteSuccess'))
await loadCacheData()
} catch (error) {
console.error(error)
$toast.error(t('setting.cache.deleteFailed'))
} finally {
loading.value = false
}
/** 更新桌面端表格选中项。 */
function updateSelectedItems(value: unknown) {
emit('update:selectedItems', (value as string[]) ?? [])
}
/** 获取音乐识别缓存封面的可展示地址。 */
@@ -232,103 +114,11 @@ function getRecognitionStatusLabel(item: MusicRecognitionCacheItem): string {
return isRecognized(item) ? t('setting.cache.recognized') : t('setting.cache.unrecognized')
}
onMounted(() => {
void loadCacheData()
})
watch([searchFilter, statusFilter], () => {
resetMobilePagination()
})
watch(() => props.items, resetMobilePagination)
</script>
<template>
<section class="music-cache-panel">
<div class="cache-panel-toolbar">
<div class="cache-panel-stats">
<div class="cache-panel-stat cache-panel-stat--primary">
<VIcon icon="mdi-database-outline" :size="isMobile ? 32 : 22" />
<div>
<strong>{{ cacheData.count }}</strong>
<span>{{ t('setting.cache.totalCount') }}</span>
</div>
</div>
<div class="cache-panel-stat cache-panel-stat--success">
<VIcon icon="mdi-check-decagram-outline" :size="isMobile ? 32 : 22" />
<div>
<strong>{{ cacheData.recognized }}</strong>
<span>{{ t('setting.cache.recognized') }}</span>
</div>
</div>
<div class="cache-panel-stat cache-panel-stat--warning">
<VIcon icon="mdi-help-circle-outline" :size="isMobile ? 32 : 22" />
<div>
<strong>{{ cacheData.unrecognized }}</strong>
<span>{{ t('setting.cache.unrecognized') }}</span>
</div>
</div>
</div>
<div v-if="!isMobile" class="cache-panel-actions">
<VBtn icon variant="text" color="primary" :loading="loading" @click="loadCacheData(true)">
<VIcon icon="mdi-refresh" />
<VTooltip activator="parent" location="bottom">{{ t('setting.cache.refreshList') }}</VTooltip>
</VBtn>
<VBtn
icon
variant="text"
color="warning"
:disabled="selectedItems.length === 0"
:loading="loading"
@click="deleteSelectedItems"
>
<VIcon icon="mdi-delete-sweep-outline" />
<VTooltip activator="parent" location="bottom">
{{ t('setting.cache.deleteSelected') }} ({{ selectedItems.length }})
</VTooltip>
</VBtn>
<VBtn icon variant="text" color="error" :loading="loading" @click="clearAllCache">
<VIcon icon="mdi-delete-variant" />
<VTooltip activator="parent" location="bottom">{{ t('setting.cache.clearAll') }}</VTooltip>
</VBtn>
</div>
</div>
<div class="cache-panel-filters">
<VTextField
v-model="searchFilter"
class="cache-panel-filter"
:label="isMobile ? undefined : recognitionFilterPlaceholder"
:placeholder="isMobile ? recognitionFilterPlaceholder : undefined"
prepend-inner-icon="mdi-magnify"
variant="outlined"
:density="isMobile ? 'comfortable' : 'compact'"
:single-line="isMobile"
clearable
hide-details
/>
<VSelect
v-model="statusFilter"
class="cache-panel-filter"
:label="isMobile ? undefined : t('setting.cache.recognitionStatus')"
:placeholder="isMobile ? t('setting.cache.recognitionStatus') : undefined"
:items="statusOptions"
prepend-inner-icon="mdi-list-status"
variant="outlined"
:density="isMobile ? 'comfortable' : 'compact'"
:single-line="isMobile"
hide-details
/>
</div>
<div v-if="isMobile" class="cache-panel-mobile-actions">
<VBtn variant="tonal" color="primary" :loading="loading" prepend-icon="mdi-refresh" @click="loadCacheData(true)">
{{ t('setting.cache.refresh') }}
</VBtn>
<VBtn variant="tonal" color="error" :loading="loading" prepend-icon="mdi-delete-variant" @click="clearAllCache">
{{ t('setting.cache.clearAll') }}
</VBtn>
</div>
<template v-if="isMobile">
<VInfiniteScroll
v-if="mobileVisibleData.length > 0 || loading"
@@ -384,7 +174,7 @@ watch([searchFilter, statusFilter], () => {
variant="text"
color="error"
:aria-label="t('common.delete')"
@click="deleteSingleItem(item)"
@click="emit('delete', item.key)"
>
<VIcon icon="mdi-delete-outline" size="20" />
</VBtn>
@@ -402,10 +192,10 @@ watch([searchFilter, statusFilter], () => {
<VDataTable
v-else
v-model="selectedItems"
:model-value="selectedItems"
class="music-cache-table"
:headers="tableHeaders"
:items="filteredData"
:items="items"
:loading="loading"
item-value="key"
show-select
@@ -413,6 +203,7 @@ watch([searchFilter, statusFilter], () => {
:items-per-page-text="t('common.itemsPerPage')"
:no-data-text="t('common.noDataText')"
:loading-text="t('common.loadingText')"
@update:model-value="updateSelectedItems"
>
<template #item.poster="{ item }">
<div class="music-cache-table__cover rounded-md">
@@ -457,7 +248,7 @@ watch([searchFilter, statusFilter], () => {
variant="text"
color="error"
:aria-label="t('common.delete')"
@click="deleteSingleItem(item)"
@click="emit('delete', item.key)"
>
<VIcon icon="mdi-delete-outline" size="18" />
<VTooltip activator="parent" location="start">{{ t('common.delete') }}</VTooltip>
@@ -482,93 +273,9 @@ watch([searchFilter, statusFilter], () => {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 16px;
min-block-size: 0;
}
.cache-panel-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.cache-panel-stats {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.cache-panel-stat {
display: flex;
align-items: center;
border: var(--app-surface-border);
border-radius: var(--app-surface-radius);
background: var(--app-grouped-list-background);
box-shadow: var(--app-surface-shadow);
gap: 10px;
min-block-size: 58px;
min-inline-size: 126px;
padding-block: 10px;
padding-inline: 14px;
}
.cache-panel-stat strong,
.cache-panel-stat span {
display: block;
}
.cache-panel-stat strong {
color: rgba(var(--v-theme-on-surface), 0.9);
font-size: 18px;
line-height: 1.15;
}
.cache-panel-stat span {
color: rgba(var(--v-theme-on-surface), 0.58);
font-size: 12px;
margin-block-start: 3px;
}
.cache-panel-stat--primary {
color: rgb(var(--v-theme-primary));
}
.cache-panel-stat--success {
color: rgb(var(--v-theme-success));
}
.cache-panel-stat--warning {
color: rgb(var(--v-theme-warning));
}
.cache-panel-actions {
display: flex;
flex: 0 0 auto;
gap: 2px;
}
.cache-panel-filters {
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr) minmax(180px, 0.35fr);
}
.cache-panel-filters :deep(.v-field) {
border-radius: var(--app-field-radius);
background: var(--app-grouped-list-background);
}
.cache-panel-mobile-actions {
display: grid;
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cache-panel-mobile-actions :deep(.v-btn) {
min-block-size: 44px;
}
.music-cache-table {
overflow: hidden;
border: var(--app-surface-border);
@@ -652,54 +359,6 @@ watch([searchFilter, statusFilter], () => {
}
@media (width <= 959.98px) {
.cache-panel-toolbar {
align-items: flex-start;
}
.cache-panel-stats {
display: grid;
flex: 1 1 auto;
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cache-panel-stat {
flex-direction: row;
align-items: center;
padding: 18px;
gap: 14px;
min-block-size: 92px;
min-inline-size: 0;
}
.cache-panel-stat strong {
font-size: 28px;
font-weight: 800;
line-height: 1.05;
white-space: nowrap;
}
.cache-panel-stat span {
font-size: 14px;
font-weight: 600;
margin-block-start: 8px;
}
.cache-panel-filters {
gap: 10px;
grid-template-columns: 1fr;
}
.cache-panel-filter :deep(.v-field__outline) {
color: rgba(var(--v-theme-on-surface), 0.18);
}
.cache-panel-filter :deep(.v-field__input) {
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 16px;
min-block-size: 54px;
}
.music-cache-mobile-scroll {
overflow: visible !important;
min-block-size: 20rem;

View File

@@ -3,7 +3,13 @@ import { useToast } from 'vue-toastification'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import api from '@/api'
import type { ApiResponse, RecognitionCacheData, RecognitionCacheItem } from '@/api/types'
import type {
ApiResponse,
MusicRecognitionCacheData,
MusicRecognitionCacheItem,
RecognitionCacheData,
RecognitionCacheItem,
} from '@/api/types'
import { useConfirm } from '@/composables/useConfirm'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import MusicRecognitionCachePanel from '@/components/cache/MusicRecognitionCachePanel.vue'
@@ -11,11 +17,12 @@ import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
type RecognitionStatusFilter = 'all' | 'recognized' | 'unrecognized'
type RecognitionCategory = 'media' | 'music'
type RecognitionTypeFilter = 'all' | 'media' | 'music'
type InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error'
const MOBILE_CACHE_PAGE_SIZE = 20
const RECOGNITION_CACHE_ENDPOINT = 'tmdb/cache'
const TMDB_CACHE_ENDPOINT = 'tmdb/cache'
const MUSIC_CACHE_ENDPOINT = 'music/cache'
const { t } = useI18n()
const display = useDisplay()
@@ -25,16 +32,16 @@ const globalSettingsStore = useGlobalSettingsStore()
const isMobile = computed(() => display.smAndDown.value)
const recognitionSourceName = computed(() => t('setting.cache.recognitionSource.themoviedb'))
const musicSourceName = computed(() => t('setting.cache.recognitionSource.musicbrainz'))
const recognitionIdLabel = computed(() => t('setting.cache.tmdbId'))
const recognitionFilterPlaceholder = computed(() =>
t('setting.cache.filterRecognitionCache', { source: recognitionSourceName.value }),
)
const recognitionFilterPlaceholder = computed(() => t('setting.cache.filterAllRecognitionCache'))
const loading = ref(false)
const searchFilter = ref('')
const statusFilter = ref<RecognitionStatusFilter>('all')
// 类型筛选:全部 / 影视TMDB/ 音乐MusicBrainz切换后对应表格切换显示
const typeFilter = ref<RecognitionTypeFilter>('all')
const selectedItems = ref<string[]>([])
// 识别缓存分类影视TMDB与音乐MusicBrainz
const recognitionCategory = ref<RecognitionCategory>('media')
const selectedMusicItems = ref<string[]>([])
const cacheData = ref<RecognitionCacheData>({
count: 0,
recognized: 0,
@@ -43,6 +50,12 @@ const cacheData = ref<RecognitionCacheData>({
shared_recognize_enabled: false,
data: [],
})
const musicCacheData = ref<MusicRecognitionCacheData>({
count: 0,
recognized: 0,
unrecognized: 0,
data: [],
})
const mobileVisibleCount = ref(MOBILE_CACHE_PAGE_SIZE)
const mobileInfiniteKey = ref(0)
let cacheLoadRequestId = 0
@@ -53,6 +66,21 @@ const statusOptions = computed(() => [
{ title: t('setting.cache.unrecognizedOnly'), value: 'unrecognized' },
])
const typeOptions = computed(() => [
{ title: t('setting.cache.recognitionTypeOptions.all'), value: 'all' },
{ title: t('setting.cache.recognitionTypeOptions.media'), value: 'media' },
{ title: t('setting.cache.recognitionTypeOptions.music'), value: 'music' },
])
// 影视与音乐缓存统计汇总展示
const totalCount = computed(() => cacheData.value.count + musicCacheData.value.count)
const recognizedCount = computed(() => cacheData.value.recognized + musicCacheData.value.recognized)
const unrecognizedCount = computed(() => cacheData.value.unrecognized + musicCacheData.value.unrecognized)
const totalSelectedCount = computed(() => selectedItems.value.length + selectedMusicItems.value.length)
const showMediaSection = computed(() => typeFilter.value !== 'music')
const showMusicSection = computed(() => typeFilter.value !== 'media')
const tableHeaders = computed(() => [
{ title: '', key: 'data-table-select', sortable: false, width: '48px' },
{ title: t('setting.cache.poster'), key: 'poster', sortable: false, width: '76px' },
@@ -75,6 +103,21 @@ const filteredData = computed(() => {
})
})
const filteredMusicData = computed(() => {
const keyword = searchFilter.value.trim().toLowerCase()
return musicCacheData.value.data.filter(item => {
const matchesKeyword =
!keyword ||
[item.key, item.title, item.album, item.media_id, getMusicArtistText(item), String(item.year ?? '')].some(value =>
(value || '').toLowerCase().includes(keyword),
)
const matchesStatus =
statusFilter.value === 'all' ||
(statusFilter.value === 'recognized' ? isMusicRecognized(item) : !isMusicRecognized(item))
return matchesKeyword && matchesStatus
})
})
const mobileVisibleData = computed(() => filteredData.value.slice(0, mobileVisibleCount.value))
const mobileHasMore = computed(() => mobileVisibleData.value.length < filteredData.value.length)
@@ -100,12 +143,15 @@ function loadMoreMobileCache({ done }: { done: (status: InfiniteScrollStatus) =>
done(mobileHasMore.value ? 'ok' : 'empty')
}
/** 加载 TMDB 主识别缓存列表。 */
/** 并行加载影视TMDB与音乐MusicBrainz识别缓存。 */
async function loadCacheData(showSuccess = false) {
const requestId = ++cacheLoadRequestId
try {
loading.value = true
const response = (await api.get(RECOGNITION_CACHE_ENDPOINT)) as unknown as ApiResponse<RecognitionCacheData>
const [response, musicResponse] = (await Promise.all([
api.get(TMDB_CACHE_ENDPOINT),
api.get(MUSIC_CACHE_ENDPOINT),
])) as unknown as [ApiResponse<RecognitionCacheData>, ApiResponse<MusicRecognitionCacheData>]
if (requestId !== cacheLoadRequestId) return
const responseData = response.data ?? {
count: 0,
@@ -121,7 +167,15 @@ async function loadCacheData(showSuccess = false) {
shared_recognize_enabled: responseData.shared_recognize_enabled ?? false,
data: responseData.data.map(item => ({ ...item, recognition_id: getRecognitionId(item) })),
}
const musicData = musicResponse.data ?? { count: 0, recognized: 0, unrecognized: 0, data: [] }
musicCacheData.value = {
...musicData,
data: (musicData.data ?? []).map(item => ({ ...item })),
}
selectedItems.value = selectedItems.value.filter(key => cacheData.value.data.some(item => item.key === key))
selectedMusicItems.value = selectedMusicItems.value.filter(key =>
musicCacheData.value.data.some(item => item.key === key),
)
resetMobilePagination()
if (showSuccess) $toast.success(t('setting.cache.listRefreshSuccess'))
} catch (error) {
@@ -133,22 +187,34 @@ async function loadCacheData(showSuccess = false) {
}
}
/** 清空全部 TMDB 主识别缓存。 */
/** 清空当前类型筛选范围内的识别缓存,全部类型时影视与音乐一并清空。 */
async function clearAllCache() {
const clearTargets: string[] = []
if (typeFilter.value !== 'music') clearTargets.push(TMDB_CACHE_ENDPOINT)
if (typeFilter.value !== 'media') clearTargets.push(MUSIC_CACHE_ENDPOINT)
const content =
typeFilter.value === 'all'
? t('setting.cache.recognitionClearAllConfirm')
: t('setting.cache.recognitionClearConfirm', {
source: typeFilter.value === 'media' ? recognitionSourceName.value : musicSourceName.value,
})
const confirmed = await createConfirm({
type: 'warn',
title: t('common.confirm'),
content: t('setting.cache.recognitionClearConfirm', { source: recognitionSourceName.value }),
content,
})
if (!confirmed) return
try {
loading.value = true
const response = (await api.delete(RECOGNITION_CACHE_ENDPOINT)) as unknown as ApiResponse
if (!response.success) throw new Error(response.message)
$toast.success(response.message || t('setting.cache.clearSuccess'))
const responses = (await Promise.all(
clearTargets.map(endpoint => api.delete(endpoint)),
)) as unknown as ApiResponse[]
if (responses.some(item => !item.success)) throw new Error(responses.find(item => !item.success)?.message)
$toast.success(t('setting.cache.clearSuccess'))
await loadCacheData()
selectedItems.value = []
selectedMusicItems.value = []
} catch (error) {
console.error(error)
$toast.error(t('setting.cache.clearFailed'))
@@ -157,28 +223,36 @@ async function clearAllCache() {
}
}
/** 请求 TMDB 接口删除指定识别缓存。 */
/** 请求接口删除指定影视识别缓存。 */
async function deleteCacheItem(key: string) {
const response = (await api.delete(
`${RECOGNITION_CACHE_ENDPOINT}/${encodeURIComponent(key)}`,
)) as unknown as ApiResponse
const response = (await api.delete(`${TMDB_CACHE_ENDPOINT}/${encodeURIComponent(key)}`)) as unknown as ApiResponse
if (!response.success) throw new Error(response.message)
}
/** 删除桌面端表格中选中的识别缓存。 */
/** 请求接口删除指定音乐识别缓存。 */
async function deleteMusicCacheItem(key: string) {
const response = (await api.delete(`${MUSIC_CACHE_ENDPOINT}/${encodeURIComponent(key)}`)) as unknown as ApiResponse
if (!response.success) throw new Error(response.message)
}
/** 删除两个表格中选中的识别缓存。 */
async function deleteSelectedItems() {
if (selectedItems.value.length === 0) {
if (totalSelectedCount.value === 0) {
$toast.warning(t('setting.cache.selectDeleteWarning'))
return
}
const deleteCount = selectedItems.value.length
const deleteCount = totalSelectedCount.value
try {
loading.value = true
await Promise.all(selectedItems.value.map(deleteCacheItem))
await Promise.all([
...selectedItems.value.map(deleteCacheItem),
...selectedMusicItems.value.map(deleteMusicCacheItem),
])
$toast.success(t('setting.cache.deleteSelectedSuccess', { count: deleteCount }))
await loadCacheData()
selectedItems.value = []
selectedMusicItems.value = []
} catch (error) {
console.error(error)
$toast.error(t('setting.cache.deleteSelectedFailed'))
@@ -187,7 +261,7 @@ async function deleteSelectedItems() {
}
}
/** 删除单条识别缓存。 */
/** 删除单条影视识别缓存。 */
async function deleteSingleItem(item: RecognitionCacheItem) {
try {
loading.value = true
@@ -202,6 +276,21 @@ async function deleteSingleItem(item: RecognitionCacheItem) {
}
}
/** 删除单条音乐识别缓存,由音乐表格组件上抛事件触发。 */
async function deleteMusicItem(key: string) {
try {
loading.value = true
await deleteMusicCacheItem(key)
$toast.success(t('setting.cache.deleteSuccess'))
await loadCacheData()
} catch (error) {
console.error(error)
$toast.error(t('setting.cache.deleteFailed'))
} finally {
loading.value = false
}
}
/** 获取识别缓存海报的可展示地址。 */
function getPosterUrl(item: RecognitionCacheItem): string {
if (!item.poster_path) return ''
@@ -216,12 +305,22 @@ function getRecognitionId(item: RecognitionCacheItem): string {
return item.tmdb_id ? String(item.tmdb_id) : ''
}
/** 判断识别缓存条目是否包含有效媒体 ID。 */
/** 判断影视识别缓存条目是否包含有效媒体 ID。 */
function isRecognized(item: RecognitionCacheItem): boolean {
const recognitionId = getRecognitionId(item)
return Boolean(recognitionId && recognitionId !== '0')
}
/** 判断音乐识别缓存条目是否包含有效媒体 ID。 */
function isMusicRecognized(item: MusicRecognitionCacheItem): boolean {
return Boolean(item.media_id)
}
/** 获取音乐缓存条目的艺术家展示文本,参与关键词搜索。 */
function getMusicArtistText(item: MusicRecognitionCacheItem): string {
return (item.artists || []).join(' / ')
}
/** 获取移动端识别缓存卡片的稳定渲染 key。 */
function getRecognitionCacheItemKey(item: RecognitionCacheItem): string {
return item.key
@@ -250,136 +349,122 @@ onMounted(() => {
void loadCacheData()
})
watch([searchFilter, statusFilter], () => {
watch([searchFilter, statusFilter, typeFilter], () => {
resetMobilePagination()
})
</script>
<template>
<section class="recognition-cache-panel">
<div class="recognition-cache-categories">
<VBtnToggle
v-model="recognitionCategory"
mandatory
divided
density="comfortable"
variant="text"
color="primary"
class="recognition-cache-categories__switcher"
:aria-label="t('setting.cache.recognitionCategoryLabel')"
>
<VBtn value="media" prepend-icon="mdi-movie-search-outline">
{{ t('setting.cache.recognitionCategory.media') }}
<div class="cache-panel-toolbar">
<div class="cache-panel-stats">
<div class="cache-panel-stat cache-panel-stat--primary">
<VIcon icon="mdi-database-outline" :size="isMobile ? 32 : 22" />
<div>
<strong>{{ totalCount }}</strong>
<span>{{ t('setting.cache.totalCount') }}</span>
</div>
</div>
<div class="cache-panel-stat cache-panel-stat--success">
<VIcon icon="mdi-check-decagram-outline" :size="isMobile ? 32 : 22" />
<div>
<strong>{{ recognizedCount }}</strong>
<span>{{ t('setting.cache.recognized') }}</span>
</div>
</div>
<div v-if="!isMobile || cacheData.shared_recognize_enabled" class="cache-panel-stat cache-panel-stat--warning">
<VIcon icon="mdi-help-circle-outline" size="22" />
<div>
<strong>{{ unrecognizedCount }}</strong>
<span>{{ t('setting.cache.unrecognized') }}</span>
</div>
</div>
<div v-if="cacheData.shared_recognize_enabled" class="cache-panel-stat cache-panel-stat--info">
<VIcon icon="mdi-cloud-check-outline" :size="isMobile ? 32 : 22" />
<div>
<strong>{{ cacheData.shared_recognized }}</strong>
<span>{{ t('setting.cache.sharedRecognized') }}</span>
</div>
</div>
</div>
<div v-if="!isMobile" class="cache-panel-actions">
<VBtn icon variant="text" color="primary" :loading="loading" @click="loadCacheData(true)">
<VIcon icon="mdi-refresh" />
<VTooltip activator="parent" location="bottom">{{ t('setting.cache.refreshList') }}</VTooltip>
</VBtn>
<VBtn value="music" prepend-icon="mdi-music-note-outline">
{{ t('setting.cache.recognitionCategory.music') }}
<VBtn
icon
variant="text"
color="warning"
:disabled="totalSelectedCount === 0"
:loading="loading"
@click="deleteSelectedItems"
>
<VIcon icon="mdi-delete-sweep-outline" />
<VTooltip activator="parent" location="bottom">
{{ t('setting.cache.deleteSelected') }} ({{ totalSelectedCount }})
</VTooltip>
</VBtn>
</VBtnToggle>
<VBtn icon variant="text" color="error" :loading="loading" @click="clearAllCache">
<VIcon icon="mdi-delete-variant" />
<VTooltip activator="parent" location="bottom">{{ t('setting.cache.clearAll') }}</VTooltip>
</VBtn>
</div>
</div>
<MusicRecognitionCachePanel v-if="recognitionCategory === 'music'" />
<div class="cache-panel-filters">
<VTextField
v-model="searchFilter"
class="cache-panel-filter"
:label="isMobile ? undefined : recognitionFilterPlaceholder"
:placeholder="isMobile ? recognitionFilterPlaceholder : undefined"
prepend-inner-icon="mdi-magnify"
variant="outlined"
:density="isMobile ? 'comfortable' : 'compact'"
:single-line="isMobile"
clearable
hide-details
/>
<VSelect
v-model="typeFilter"
class="cache-panel-filter"
:label="isMobile ? undefined : t('setting.cache.recognitionType')"
:placeholder="isMobile ? t('setting.cache.recognitionType') : undefined"
:items="typeOptions"
prepend-inner-icon="mdi-shape-outline"
variant="outlined"
:density="isMobile ? 'comfortable' : 'compact'"
:single-line="isMobile"
hide-details
/>
<VSelect
v-model="statusFilter"
class="cache-panel-filter"
:label="isMobile ? undefined : t('setting.cache.recognitionStatus')"
:placeholder="isMobile ? t('setting.cache.recognitionStatus') : undefined"
:items="statusOptions"
prepend-inner-icon="mdi-list-status"
variant="outlined"
:density="isMobile ? 'comfortable' : 'compact'"
:single-line="isMobile"
hide-details
/>
</div>
<template v-else>
<div class="cache-panel-toolbar">
<div class="cache-panel-stats">
<div class="cache-panel-stat cache-panel-stat--primary">
<VIcon icon="mdi-database-outline" :size="isMobile ? 32 : 22" />
<div>
<strong>{{ cacheData.count }}</strong>
<span>{{ t('setting.cache.totalCount') }}</span>
</div>
</div>
<div class="cache-panel-stat cache-panel-stat--success">
<VIcon icon="mdi-check-decagram-outline" :size="isMobile ? 32 : 22" />
<div>
<strong>{{ cacheData.recognized }}</strong>
<span>{{ t('setting.cache.recognized') }}</span>
</div>
</div>
<div
v-if="!isMobile || cacheData.shared_recognize_enabled"
class="cache-panel-stat cache-panel-stat--warning"
>
<VIcon icon="mdi-help-circle-outline" size="22" />
<div>
<strong>{{ cacheData.unrecognized }}</strong>
<span>{{ t('setting.cache.unrecognized') }}</span>
</div>
</div>
<div v-if="cacheData.shared_recognize_enabled" class="cache-panel-stat cache-panel-stat--info">
<VIcon icon="mdi-cloud-check-outline" :size="isMobile ? 32 : 22" />
<div>
<strong>{{ cacheData.shared_recognized }}</strong>
<span>{{ t('setting.cache.sharedRecognized') }}</span>
</div>
</div>
</div>
<div v-if="isMobile" class="cache-panel-mobile-actions">
<VBtn variant="tonal" color="primary" :loading="loading" prepend-icon="mdi-refresh" @click="loadCacheData(true)">
{{ t('setting.cache.refresh') }}
</VBtn>
<VBtn variant="tonal" color="error" :loading="loading" prepend-icon="mdi-delete-variant" @click="clearAllCache">
{{ t('setting.cache.clearAll') }}
</VBtn>
</div>
<div v-if="!isMobile" class="cache-panel-actions">
<VBtn icon variant="text" color="primary" :loading="loading" @click="loadCacheData(true)">
<VIcon icon="mdi-refresh" />
<VTooltip activator="parent" location="bottom">{{ t('setting.cache.refreshList') }}</VTooltip>
</VBtn>
<VBtn
icon
variant="text"
color="warning"
:disabled="selectedItems.length === 0"
:loading="loading"
@click="deleteSelectedItems"
>
<VIcon icon="mdi-delete-sweep-outline" />
<VTooltip activator="parent" location="bottom">
{{ t('setting.cache.deleteSelected') }} ({{ selectedItems.length }})
</VTooltip>
</VBtn>
<VBtn icon variant="text" color="error" :loading="loading" @click="clearAllCache">
<VIcon icon="mdi-delete-variant" />
<VTooltip activator="parent" location="bottom">{{ t('setting.cache.clearAll') }}</VTooltip>
</VBtn>
</div>
</div>
<div class="cache-panel-filters">
<VTextField
v-model="searchFilter"
class="cache-panel-filter"
:label="isMobile ? undefined : recognitionFilterPlaceholder"
:placeholder="isMobile ? recognitionFilterPlaceholder : undefined"
prepend-inner-icon="mdi-magnify"
variant="outlined"
:density="isMobile ? 'comfortable' : 'compact'"
:single-line="isMobile"
clearable
hide-details
/>
<VSelect
v-model="statusFilter"
class="cache-panel-filter"
:label="isMobile ? undefined : t('setting.cache.recognitionStatus')"
:placeholder="isMobile ? t('setting.cache.recognitionStatus') : undefined"
:items="statusOptions"
prepend-inner-icon="mdi-list-status"
variant="outlined"
:density="isMobile ? 'comfortable' : 'compact'"
:single-line="isMobile"
hide-details
/>
</div>
<div v-if="isMobile" class="cache-panel-mobile-actions">
<VBtn
variant="tonal"
color="primary"
:loading="loading"
prepend-icon="mdi-refresh"
@click="loadCacheData(true)"
>
{{ t('setting.cache.refresh') }}
</VBtn>
<VBtn variant="tonal" color="error" :loading="loading" prepend-icon="mdi-delete-variant" @click="clearAllCache">
{{ t('setting.cache.clearAll') }}
</VBtn>
<template v-if="showMediaSection">
<div v-if="typeFilter === 'all'" class="recognition-cache-section-title">
<VIcon icon="mdi-movie-search-outline" size="18" />
<span>{{ recognitionSourceName }}</span>
</div>
<template v-if="isMobile">
@@ -515,6 +600,21 @@ watch([searchFilter, statusFilter], () => {
</template>
</VDataTable>
</template>
<template v-if="showMusicSection">
<div v-if="typeFilter === 'all'" class="recognition-cache-section-title">
<VIcon icon="mdi-music-note-outline" size="18" />
<span>{{ musicSourceName }}</span>
</div>
<MusicRecognitionCachePanel
:items="filteredMusicData"
:loading="loading"
:selected-items="selectedMusicItems"
@update:selected-items="selectedMusicItems = $event"
@delete="deleteMusicItem"
/>
</template>
</section>
</template>
@@ -531,23 +631,14 @@ watch([searchFilter, statusFilter], () => {
overflow-y: auto;
}
.recognition-cache-categories {
.recognition-cache-section-title {
display: flex;
flex: 0 0 auto;
justify-content: center;
}
.recognition-cache-categories__switcher {
overflow: hidden;
border: var(--app-surface-border);
border-radius: var(--app-control-radius);
backdrop-filter: var(--app-grouped-list-backdrop-filter);
background: var(--app-grouped-list-background);
box-shadow: var(--app-surface-shadow);
}
.recognition-cache-categories__switcher :deep(.v-btn) {
min-inline-size: 150px;
align-items: center;
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 14px;
font-weight: 700;
gap: 8px;
margin-block-start: 4px;
}
.cache-panel-toolbar {
@@ -619,7 +710,7 @@ watch([searchFilter, statusFilter], () => {
.cache-panel-filters {
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr) minmax(180px, 0.35fr);
grid-template-columns: minmax(0, 1fr) minmax(150px, 0.28fr) minmax(150px, 0.28fr);
}
.cache-panel-filters :deep(.v-field) {
@@ -720,19 +811,6 @@ watch([searchFilter, statusFilter], () => {
padding-inline: 16px;
}
.recognition-cache-categories {
inline-size: 100%;
}
.recognition-cache-categories__switcher {
inline-size: 100%;
}
.recognition-cache-categories__switcher :deep(.v-btn) {
flex: 1 1 0;
min-inline-size: 0;
}
.cache-panel-toolbar {
align-items: flex-start;
}

View File

@@ -1,66 +1,49 @@
import MusicRecognitionCachePanel from '@/components/cache/MusicRecognitionCachePanel.vue'
import { screen, waitFor } from '@testing-library/vue'
import type { MusicRecognitionCacheItem } from '@/api/types'
import { screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
apiDelete: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('@/api', () => ({
default: {
get: mocks.apiGet,
delete: mocks.apiDelete,
},
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({
error: mocks.toastError,
success: mocks.toastSuccess,
}),
}))
import { describe, expect, it } from 'vitest'
const cacheKey = '[音乐]晴天-周杰伦-叶惠美-2003'
function mockMusicCacheData() {
mocks.apiGet.mockResolvedValue({
data: {
count: 2,
recognized: 1,
unrecognized: 1,
data: [
{
key: cacheKey,
media_id: 'rec-1',
title: '晴天',
artists: ['周杰伦'],
album: '叶惠美',
year: 2003,
music_type: 'recording',
cover_url: '',
},
{
key: '[音乐]未知曲目--None-None',
media_id: '',
title: '未知曲目',
artists: [],
album: '',
year: '',
music_type: 'recording',
cover_url: '',
},
],
},
})
const musicItems: MusicRecognitionCacheItem[] = [
{
key: cacheKey,
media_id: 'rec-1',
title: '晴天',
artists: ['周杰伦'],
album: '叶惠美',
year: 2003,
music_type: 'recording',
cover_url: '',
},
{
key: '[音乐]未知曲目--None-None',
media_id: '',
title: '未知曲目',
artists: [],
album: '',
year: '',
music_type: 'recording',
cover_url: '',
},
]
interface MusicPanelProps {
items?: MusicRecognitionCacheItem[]
loading?: boolean
selectedItems?: string[]
}
async function renderMusicRecognitionCachePanel() {
function renderMusicPanel(props: MusicPanelProps = {}) {
return renderWithProviders(MusicRecognitionCachePanel, {
props: {
items: musicItems,
loading: false,
selectedItems: [],
...props,
},
initialState: {
globalSettings: {
data: {
@@ -73,54 +56,33 @@ async function renderMusicRecognitionCachePanel() {
})
}
describe('MusicRecognitionCachePanel', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
mocks.apiDelete.mockReset()
mocks.toastError.mockReset()
mocks.toastSuccess.mockReset()
})
describe('MusicRecognitionCachePanel table section', () => {
it('renders music cache items provided through props', async () => {
await renderMusicPanel()
it('loads music recognition cache from the music endpoint', async () => {
mockMusicCacheData()
await renderMusicRecognitionCachePanel()
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('music/cache'))
expect(await screen.findByText('晴天')).toBeInTheDocument()
expect(screen.getByText('周杰伦')).toBeInTheDocument()
expect(screen.getByText('未知曲目')).toBeInTheDocument()
expect(screen.getByText('MusicBrainz ID')).toBeInTheDocument()
expect(screen.getByText('已识别')).toBeInTheDocument()
expect(screen.getByText('未识别')).toBeInTheDocument()
})
it('shows empty hint when music cache is empty', async () => {
mocks.apiGet.mockResolvedValue({
data: {
count: 0,
recognized: 0,
unrecognized: 0,
data: [],
},
})
it('shows empty hint when no items are provided', async () => {
await renderMusicPanel({ items: [] })
await renderMusicRecognitionCachePanel()
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('music/cache'))
expect(await screen.findByText('暂无 MusicBrainz 识别缓存')).toBeInTheDocument()
})
it('deletes a music cache item through the encoded endpoint', async () => {
mockMusicCacheData()
mocks.apiDelete.mockResolvedValue({ success: true, message: '音乐识别缓存删除成功' })
await renderMusicRecognitionCachePanel()
it('emits delete with the cache key when a row delete button is clicked', async () => {
const { emitted } = await renderMusicPanel()
await screen.findByText('晴天')
const user = userEvent.setup()
const deleteButtons = screen.getAllByRole('button', { name: '删除' })
await user.click(deleteButtons[0])
await waitFor(() => expect(mocks.apiDelete).toHaveBeenCalledWith(`music/cache/${encodeURIComponent(cacheKey)}`))
expect(mocks.toastSuccess).toHaveBeenCalled()
expect(emitted('delete')).toBeTruthy()
expect(emitted('delete')?.[0]).toEqual([cacheKey])
})
})

View File

@@ -22,6 +22,22 @@ vi.mock('vue-toastification', () => ({
}),
}))
const emptyMusicResponse = {
data: {
count: 0,
recognized: 0,
unrecognized: 0,
data: [],
},
}
/** 按请求端点分别返回 TMDB 与音乐识别缓存数据。 */
function mockCacheApis(tmdbData: Record<string, unknown>) {
mocks.apiGet.mockImplementation((url: string) =>
Promise.resolve(url === 'music/cache' ? emptyMusicResponse : { data: tmdbData }),
)
}
async function renderRecognitionCachePanel(recognitionSource = 'themoviedb') {
return renderWithProviders(RecognitionCachePanel, {
initialState: {
@@ -44,15 +60,13 @@ describe('RecognitionCachePanel shared recognition statistics', () => {
})
it('shows the persisted shared recognition count when sharing is enabled', async () => {
mocks.apiGet.mockResolvedValue({
data: {
count: 12,
recognized: 9,
unrecognized: 3,
shared_recognized: 27,
shared_recognize_enabled: true,
data: [],
},
mockCacheApis({
count: 12,
recognized: 9,
unrecognized: 3,
shared_recognized: 27,
shared_recognize_enabled: true,
data: [],
})
await renderRecognitionCachePanel()
@@ -63,15 +77,13 @@ describe('RecognitionCachePanel shared recognition statistics', () => {
})
it('hides shared recognition statistics when sharing is disabled', async () => {
mocks.apiGet.mockResolvedValue({
data: {
count: 12,
recognized: 9,
unrecognized: 3,
shared_recognized: 27,
shared_recognize_enabled: false,
data: [],
},
mockCacheApis({
count: 12,
recognized: 9,
unrecognized: 3,
shared_recognized: 27,
shared_recognize_enabled: false,
data: [],
})
await renderRecognitionCachePanel()
@@ -81,15 +93,13 @@ describe('RecognitionCachePanel shared recognition statistics', () => {
})
it('loads TMDB cache even when Douban is selected as the recognition source', async () => {
mocks.apiGet.mockResolvedValue({
data: {
count: 0,
recognized: 0,
unrecognized: 0,
shared_recognized: 0,
shared_recognize_enabled: false,
data: [],
},
mockCacheApis({
count: 0,
recognized: 0,
unrecognized: 0,
shared_recognized: 0,
shared_recognize_enabled: false,
data: [],
})
await renderRecognitionCachePanel('douban')
@@ -97,3 +107,61 @@ describe('RecognitionCachePanel shared recognition statistics', () => {
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('tmdb/cache'))
})
})
describe('RecognitionCachePanel unified movie/TV and music management', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
mocks.toastError.mockReset()
mocks.toastSuccess.mockReset()
})
it('loads both cache sources and aggregates the statistics', async () => {
mocks.apiGet.mockImplementation((url: string) =>
Promise.resolve(
url === 'music/cache'
? {
data: {
count: 3,
recognized: 2,
unrecognized: 1,
data: [
{
key: '[音乐]晴天-周杰伦-叶惠美-2003',
media_id: 'rec-1',
title: '晴天',
artists: ['周杰伦'],
album: '叶惠美',
year: 2003,
music_type: 'recording',
cover_url: '',
},
],
},
}
: {
data: {
count: 12,
recognized: 9,
unrecognized: 3,
shared_recognized: 0,
shared_recognize_enabled: false,
data: [],
},
},
),
)
await renderRecognitionCachePanel()
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('tmdb/cache'))
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('music/cache'))
// 总条数 12 + 3已识别 9 + 2未识别 3 + 1
expect(await screen.findByText('15')).toBeInTheDocument()
expect(screen.getByText('11')).toBeInTheDocument()
expect(screen.getByText('4')).toBeInTheDocument()
// 音乐条目直接展示在统一面板中
expect(screen.getByText('晴天')).toBeInTheDocument()
// 类型筛选下拉框默认展示“全部”
expect(screen.getByText('全部')).toBeInTheDocument()
})
})

View File

@@ -2703,8 +2703,9 @@ export default {
cacheType: 'Cache Type',
torrentCache: 'Resource Cache',
recognitionCache: 'Recognition Cache',
recognitionCategoryLabel: 'Recognition Cache Category',
recognitionCategory: {
recognitionType: 'Type',
recognitionTypeOptions: {
all: 'All',
media: 'Movie/TV',
music: 'Music',
},
@@ -2718,6 +2719,7 @@ export default {
filterByTitle: 'Filter by Title',
filterBySite: 'Filter by Site',
filterRecognitionCache: 'Search cache key, title, or {source} ID',
filterAllRecognitionCache: 'Search cache key, title, artist, or recognition ID',
selectSite: 'Select Site',
loadingMore: 'Loading...',
refresh: 'Refresh Cache',
@@ -2785,6 +2787,7 @@ export default {
},
clearConfirm: 'Are you sure you want to clear all cache?',
recognitionClearConfirm: 'Clear all {source} recognition cache?',
recognitionClearAllConfirm: 'Clear all recognition cache (movie/TV and music)?',
tmdbClearConfirm: 'Clear all TheMovieDb recognition cache?',
recognitionSource: {
themoviedb: 'TheMovieDb',

View File

@@ -2652,8 +2652,9 @@ export default {
cacheType: '缓存类型',
torrentCache: '资源缓存',
recognitionCache: '识别缓存',
recognitionCategoryLabel: '识别缓存分类',
recognitionCategory: {
recognitionType: '类',
recognitionTypeOptions: {
all: '全部',
media: '电影/电视剧',
music: '音乐',
},
@@ -2667,6 +2668,7 @@ export default {
filterByTitle: '按标题筛选',
filterBySite: '按站点筛选',
filterRecognitionCache: '搜索缓存键、标题或 {source} ID',
filterAllRecognitionCache: '搜索缓存键、标题、艺术家或识别 ID',
selectSite: '选择站点',
loadingMore: '加载中...',
refresh: '刷新缓存',
@@ -2734,6 +2736,7 @@ export default {
},
clearConfirm: '确认清空所有缓存吗?',
recognitionClearConfirm: '确认清空全部 {source} 识别缓存吗?',
recognitionClearAllConfirm: '确认清空全部识别缓存(影视与音乐)吗?',
tmdbClearConfirm: '确认清空全部 TheMovieDb 识别缓存吗?',
recognitionSource: {
themoviedb: 'TheMovieDb',

View File

@@ -2651,8 +2651,9 @@ export default {
cacheType: '緩存類型',
torrentCache: '資源緩存',
recognitionCache: '識別緩存',
recognitionCategoryLabel: '識別緩存分類',
recognitionCategory: {
recognitionType: '類',
recognitionTypeOptions: {
all: '全部',
media: '電影/電視劇',
music: '音樂',
},
@@ -2666,6 +2667,7 @@ export default {
filterByTitle: '按標題篩選',
filterBySite: '按站點篩選',
filterRecognitionCache: '搜索緩存鍵、標題或 {source} ID',
filterAllRecognitionCache: '搜索緩存鍵、標題、藝術家或識別 ID',
selectSite: '選擇站點',
loadingMore: '加載中...',
refresh: '刷新緩存',
@@ -2733,6 +2735,7 @@ export default {
},
clearConfirm: '確認清空所有緩存嗎?',
recognitionClearConfirm: '確認清空全部 {source} 識別緩存嗎?',
recognitionClearAllConfirm: '確認清空全部識別緩存(影視與音樂)嗎?',
tmdbClearConfirm: '確認清空全部 TheMovieDb 識別緩存嗎?',
recognitionSource: {
themoviedb: 'TheMovieDb',