mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 09:16:58 +08:00
feat(music): 音乐订阅刷新与识别缓存
This commit is contained in:
@@ -2191,6 +2191,38 @@ export interface RecognitionCacheData {
|
|||||||
data: RecognitionCacheItem[]
|
data: RecognitionCacheItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 音乐识别缓存项
|
||||||
|
export interface MusicRecognitionCacheItem {
|
||||||
|
// 缓存键
|
||||||
|
key: string
|
||||||
|
// MusicBrainz ID,空字符串表示未识别
|
||||||
|
media_id?: string
|
||||||
|
// 识别后的曲名/专辑名
|
||||||
|
title: string
|
||||||
|
// 艺术家列表
|
||||||
|
artists?: string[]
|
||||||
|
// 所属专辑
|
||||||
|
album?: string
|
||||||
|
// 发行年份
|
||||||
|
year?: string | number
|
||||||
|
// 音乐实体类型:recording/album/artist
|
||||||
|
music_type?: string
|
||||||
|
// 封面图片地址
|
||||||
|
cover_url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 音乐识别缓存数据
|
||||||
|
export interface MusicRecognitionCacheData {
|
||||||
|
// 缓存总数
|
||||||
|
count: number
|
||||||
|
// 已识别数量
|
||||||
|
recognized: number
|
||||||
|
// 未识别数量
|
||||||
|
unrecognized: number
|
||||||
|
// 缓存数据
|
||||||
|
data: MusicRecognitionCacheItem[]
|
||||||
|
}
|
||||||
|
|
||||||
// 订阅分享统计
|
// 订阅分享统计
|
||||||
export interface SubscribeShareStatistics {
|
export interface SubscribeShareStatistics {
|
||||||
// 分享人
|
// 分享人
|
||||||
|
|||||||
+778
@@ -0,0 +1,778 @@
|
|||||||
|
<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 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 { 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' },
|
||||||
|
{ title: t('setting.cache.poster'), key: 'poster', sortable: false, width: '76px' },
|
||||||
|
{ title: t('setting.cache.cacheKey'), key: 'key', sortable: true },
|
||||||
|
{ title: t('setting.cache.recognitionResult'), key: 'result', sortable: false, width: '240px' },
|
||||||
|
{ title: recognitionIdLabel.value, key: 'media_id', sortable: true, width: '140px' },
|
||||||
|
{ title: t('setting.cache.recognitionStatus'), key: 'status', sortable: true, width: '120px' },
|
||||||
|
{ 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)
|
||||||
|
|
||||||
|
/** 重置移动端分页,让筛选或刷新后的识别缓存从第一页开始展示。 */
|
||||||
|
function resetMobilePagination() {
|
||||||
|
mobileVisibleCount.value = MOBILE_CACHE_PAGE_SIZE
|
||||||
|
mobileInfiniteKey.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 追加移动端下一页识别缓存,并由虚拟滚动限制实际渲染节点。 */
|
||||||
|
function loadMoreMobileCache({ done }: { done: (status: InfiniteScrollStatus) => void }) {
|
||||||
|
if (loading.value) {
|
||||||
|
done('ok')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mobileHasMore.value) {
|
||||||
|
done('empty')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mobileVisibleCount.value = Math.min(mobileVisibleCount.value + MOBILE_CACHE_PAGE_SIZE, filteredData.value.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 getCoverUrl(item: MusicRecognitionCacheItem): string {
|
||||||
|
if (!item.cover_url) return ''
|
||||||
|
return getDisplayImageUrl(item.cover_url, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取艺术家展示文本。 */
|
||||||
|
function getArtistText(item: MusicRecognitionCacheItem): string {
|
||||||
|
return (item.artists || []).join(' / ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断识别缓存条目是否包含有效媒体 ID。 */
|
||||||
|
function isRecognized(item: MusicRecognitionCacheItem): boolean {
|
||||||
|
return Boolean(item.media_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取移动端识别缓存卡片的稳定渲染 key。 */
|
||||||
|
function getMusicCacheItemKey(item: MusicRecognitionCacheItem): string {
|
||||||
|
return item.key
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取音乐实体类型的本地化名称。 */
|
||||||
|
function getMusicTypeLabel(musicType?: string): string {
|
||||||
|
if (musicType === 'album') return t('setting.cache.musicType.album')
|
||||||
|
if (musicType === 'artist') return t('setting.cache.musicType.artist')
|
||||||
|
return t('setting.cache.musicType.recording')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取音乐实体类型对应的主题颜色。 */
|
||||||
|
function getMusicTypeColor(musicType?: string): string {
|
||||||
|
if (musicType === 'album') return 'success'
|
||||||
|
if (musicType === 'artist') return 'info'
|
||||||
|
return 'primary'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取识别状态的本地化名称。 */
|
||||||
|
function getRecognitionStatusLabel(item: MusicRecognitionCacheItem): string {
|
||||||
|
return isRecognized(item) ? t('setting.cache.recognized') : t('setting.cache.unrecognized')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadCacheData()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([searchFilter, statusFilter], () => {
|
||||||
|
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"
|
||||||
|
:key="mobileInfiniteKey"
|
||||||
|
mode="intersect"
|
||||||
|
side="end"
|
||||||
|
:items="mobileVisibleData"
|
||||||
|
class="music-cache-mobile-scroll"
|
||||||
|
@load="loadMoreMobileCache"
|
||||||
|
>
|
||||||
|
<template #loading>
|
||||||
|
<div class="cache-panel-load-state">
|
||||||
|
<VProgressCircular indeterminate color="primary" size="22" width="3" />
|
||||||
|
<span>{{ t('setting.cache.loadingMore') }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #empty />
|
||||||
|
|
||||||
|
<ProgressiveCardGrid
|
||||||
|
v-if="mobileVisibleData.length > 0"
|
||||||
|
:items="mobileVisibleData"
|
||||||
|
:columns="1"
|
||||||
|
:gap="10"
|
||||||
|
:estimated-item-height="152"
|
||||||
|
:overscan-rows="5"
|
||||||
|
:get-item-key="getMusicCacheItemKey"
|
||||||
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<article class="music-cache-mobile-item">
|
||||||
|
<div class="music-cache-cover rounded-md">
|
||||||
|
<VImg v-if="getCoverUrl(item)" :src="getCoverUrl(item)" :alt="item.title || item.key" cover />
|
||||||
|
<VIcon v-else icon="mdi-album-outline" size="28" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="music-cache-mobile-item__content">
|
||||||
|
<div class="music-cache-mobile-item__title">
|
||||||
|
{{ item.title || t('setting.cache.unrecognized') }}
|
||||||
|
</div>
|
||||||
|
<div class="music-cache-mobile-item__meta">
|
||||||
|
<VChip size="x-small" variant="tonal" :color="getMusicTypeColor(item.music_type)">
|
||||||
|
{{ getMusicTypeLabel(item.music_type) }}
|
||||||
|
</VChip>
|
||||||
|
<span v-if="getArtistText(item)">{{ getArtistText(item) }}</span>
|
||||||
|
<span v-if="item.year">{{ item.year }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="music-cache-mobile-item__key">{{ item.key }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VBtn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
color="error"
|
||||||
|
:aria-label="t('common.delete')"
|
||||||
|
@click="deleteSingleItem(item)"
|
||||||
|
>
|
||||||
|
<VIcon icon="mdi-delete-outline" size="20" />
|
||||||
|
</VBtn>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
</ProgressiveCardGrid>
|
||||||
|
</VInfiniteScroll>
|
||||||
|
|
||||||
|
<div v-else class="cache-panel-empty">
|
||||||
|
<VIcon icon="mdi-database-search-outline" size="42" />
|
||||||
|
<strong>{{ t('setting.cache.noRecognitionCache', { source: recognitionSourceName }) }}</strong>
|
||||||
|
<span>{{ t('setting.cache.noRecognitionCacheHint') }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<VDataTable
|
||||||
|
v-else
|
||||||
|
v-model="selectedItems"
|
||||||
|
class="music-cache-table"
|
||||||
|
:headers="tableHeaders"
|
||||||
|
:items="filteredData"
|
||||||
|
:loading="loading"
|
||||||
|
item-value="key"
|
||||||
|
show-select
|
||||||
|
hover
|
||||||
|
:items-per-page-text="t('common.itemsPerPage')"
|
||||||
|
:no-data-text="t('common.noDataText')"
|
||||||
|
:loading-text="t('common.loadingText')"
|
||||||
|
>
|
||||||
|
<template #item.poster="{ item }">
|
||||||
|
<div class="music-cache-table__cover rounded-md">
|
||||||
|
<VImg v-if="getCoverUrl(item)" :src="getCoverUrl(item)" :alt="item.title || item.key" cover />
|
||||||
|
<VIcon v-else icon="mdi-album-outline" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.key="{ item }">
|
||||||
|
<div class="music-cache-table__key">{{ item.key }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.result="{ item }">
|
||||||
|
<div class="music-cache-result">
|
||||||
|
<strong>{{ item.title || t('setting.cache.unrecognized') }}</strong>
|
||||||
|
<span v-if="getArtistText(item)">{{ getArtistText(item) }}</span>
|
||||||
|
<span v-if="item.album">{{ item.album }}</span>
|
||||||
|
<span v-if="item.year">{{ item.year }}</span>
|
||||||
|
<VChip size="x-small" variant="tonal" :color="getMusicTypeColor(item.music_type)">
|
||||||
|
{{ getMusicTypeLabel(item.music_type) }}
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.media_id="{ item }">
|
||||||
|
<span v-if="isRecognized(item)" class="font-weight-medium music-cache-table__media-id">{{
|
||||||
|
item.media_id
|
||||||
|
}}</span>
|
||||||
|
<span v-else class="text-medium-emphasis">-</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.status="{ item }">
|
||||||
|
<VChip size="small" variant="tonal" :color="isRecognized(item) ? 'success' : 'warning'">
|
||||||
|
{{ getRecognitionStatusLabel(item) }}
|
||||||
|
</VChip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.actions="{ item }">
|
||||||
|
<VBtn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
color="error"
|
||||||
|
:aria-label="t('common.delete')"
|
||||||
|
@click="deleteSingleItem(item)"
|
||||||
|
>
|
||||||
|
<VIcon icon="mdi-delete-outline" size="18" />
|
||||||
|
<VTooltip activator="parent" location="start">{{ t('common.delete') }}</VTooltip>
|
||||||
|
</VBtn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #no-data>
|
||||||
|
<div class="cache-panel-empty">
|
||||||
|
<VIcon icon="mdi-database-search-outline" size="42" />
|
||||||
|
<strong>{{ t('setting.cache.noRecognitionCache', { source: recognitionSourceName }) }}</strong>
|
||||||
|
<span>{{ t('setting.cache.noRecognitionCacheHint') }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VDataTable>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||||
|
|
||||||
|
.music-cache-panel {
|
||||||
|
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);
|
||||||
|
border-radius: var(--app-surface-radius);
|
||||||
|
box-shadow: var(--app-surface-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-cache-table__cover,
|
||||||
|
.music-cache-cover {
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-cache-table__cover {
|
||||||
|
block-size: 62px;
|
||||||
|
inline-size: 62px;
|
||||||
|
margin-block: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-cache-table__cover :deep(.v-img),
|
||||||
|
.music-cache-cover :deep(.v-img) {
|
||||||
|
block-size: 100%;
|
||||||
|
inline-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-cache-table__key {
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.68);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
max-inline-size: 30rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-cache-table__media-id {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-cache-result {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-cache-result strong {
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.88);
|
||||||
|
inline-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-cache-result span {
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.56);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-panel-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.48);
|
||||||
|
gap: 8px;
|
||||||
|
min-block-size: 14rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-panel-empty strong {
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.78);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-panel-empty span {
|
||||||
|
font-size: 13px;
|
||||||
|
max-inline-size: 30rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-cache-mobile-scroll :deep(.v-infinite-scroll__container),
|
||||||
|
.music-cache-mobile-scroll :deep(.progressive-card-grid),
|
||||||
|
.music-cache-mobile-scroll :deep(.progressive-card-grid__track) {
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-cache-mobile-scroll :deep(.v-infinite-scroll__side) {
|
||||||
|
padding-block: 14px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-panel-load-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.58);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
gap: 8px;
|
||||||
|
min-block-size: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-cache-mobile-item {
|
||||||
|
display: grid;
|
||||||
|
align-items: start;
|
||||||
|
padding: 12px;
|
||||||
|
border: var(--app-surface-border);
|
||||||
|
border-radius: var(--app-surface-radius);
|
||||||
|
backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||||
|
background: var(--app-grouped-list-background);
|
||||||
|
box-shadow: var(--app-surface-shadow);
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: 62px minmax(0, 1fr) 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-cache-cover {
|
||||||
|
block-size: 62px;
|
||||||
|
inline-size: 62px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-cache-mobile-item__content {
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-cache-mobile-item__title {
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.9);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-cache-mobile-item__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.58);
|
||||||
|
font-size: 12px;
|
||||||
|
gap: 7px;
|
||||||
|
margin-block-start: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-cache-mobile-item__key {
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.48);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.35;
|
||||||
|
margin-block-start: 8px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+286
-217
@@ -6,10 +6,12 @@ import api from '@/api'
|
|||||||
import type { ApiResponse, RecognitionCacheData, RecognitionCacheItem } from '@/api/types'
|
import type { ApiResponse, RecognitionCacheData, RecognitionCacheItem } from '@/api/types'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
|
import MusicRecognitionCachePanel from '@/components/cache/MusicRecognitionCachePanel.vue'
|
||||||
import { useGlobalSettingsStore } from '@/stores'
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
|
|
||||||
type RecognitionStatusFilter = 'all' | 'recognized' | 'unrecognized'
|
type RecognitionStatusFilter = 'all' | 'recognized' | 'unrecognized'
|
||||||
|
type RecognitionCategory = 'media' | 'music'
|
||||||
type InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error'
|
type InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error'
|
||||||
|
|
||||||
const MOBILE_CACHE_PAGE_SIZE = 20
|
const MOBILE_CACHE_PAGE_SIZE = 20
|
||||||
@@ -31,6 +33,8 @@ const loading = ref(false)
|
|||||||
const searchFilter = ref('')
|
const searchFilter = ref('')
|
||||||
const statusFilter = ref<RecognitionStatusFilter>('all')
|
const statusFilter = ref<RecognitionStatusFilter>('all')
|
||||||
const selectedItems = ref<string[]>([])
|
const selectedItems = ref<string[]>([])
|
||||||
|
// 识别缓存分类:影视(TMDB)与音乐(MusicBrainz)
|
||||||
|
const recognitionCategory = ref<RecognitionCategory>('media')
|
||||||
const cacheData = ref<RecognitionCacheData>({
|
const cacheData = ref<RecognitionCacheData>({
|
||||||
count: 0,
|
count: 0,
|
||||||
recognized: 0,
|
recognized: 0,
|
||||||
@@ -253,231 +257,264 @@ watch([searchFilter, statusFilter], () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="recognition-cache-panel">
|
<section class="recognition-cache-panel">
|
||||||
<div class="cache-panel-toolbar">
|
<div class="recognition-cache-categories">
|
||||||
<div class="cache-panel-stats">
|
<VBtnToggle
|
||||||
<div class="cache-panel-stat cache-panel-stat--primary">
|
v-model="recognitionCategory"
|
||||||
<VIcon icon="mdi-database-outline" :size="isMobile ? 32 : 22" />
|
mandatory
|
||||||
<div>
|
divided
|
||||||
<strong>{{ cacheData.count }}</strong>
|
density="comfortable"
|
||||||
<span>{{ t('setting.cache.totalCount') }}</span>
|
variant="text"
|
||||||
</div>
|
color="primary"
|
||||||
</div>
|
class="recognition-cache-categories__switcher"
|
||||||
<div class="cache-panel-stat cache-panel-stat--success">
|
:aria-label="t('setting.cache.recognitionCategoryLabel')"
|
||||||
<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-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"
|
|
||||||
:key="mobileInfiniteKey"
|
|
||||||
mode="intersect"
|
|
||||||
side="end"
|
|
||||||
:items="mobileVisibleData"
|
|
||||||
class="recognition-cache-mobile-scroll"
|
|
||||||
@load="loadMoreMobileCache"
|
|
||||||
>
|
>
|
||||||
<template #loading>
|
<VBtn value="media" prepend-icon="mdi-movie-search-outline">
|
||||||
<div class="cache-panel-load-state">
|
{{ t('setting.cache.recognitionCategory.media') }}
|
||||||
<VProgressCircular indeterminate color="primary" size="22" width="3" />
|
|
||||||
<span>{{ t('setting.cache.loadingMore') }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #empty />
|
|
||||||
|
|
||||||
<ProgressiveCardGrid
|
|
||||||
v-if="mobileVisibleData.length > 0"
|
|
||||||
:items="mobileVisibleData"
|
|
||||||
:columns="1"
|
|
||||||
:gap="10"
|
|
||||||
:estimated-item-height="152"
|
|
||||||
:overscan-rows="5"
|
|
||||||
:get-item-key="getRecognitionCacheItemKey"
|
|
||||||
>
|
|
||||||
<template #default="{ item }">
|
|
||||||
<article class="recognition-cache-mobile-item">
|
|
||||||
<div class="recognition-cache-poster rounded-md">
|
|
||||||
<VImg v-if="getPosterUrl(item)" :src="getPosterUrl(item)" :alt="item.title || item.key" cover />
|
|
||||||
<VIcon v-else icon="mdi-image-off-outline" size="28" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="recognition-cache-mobile-item__content">
|
|
||||||
<div class="recognition-cache-mobile-item__title">
|
|
||||||
{{ item.title || t('setting.cache.unrecognized') }}
|
|
||||||
</div>
|
|
||||||
<div class="recognition-cache-mobile-item__meta">
|
|
||||||
<VChip size="x-small" variant="tonal" :color="getMediaTypeColor(item.media_type)">
|
|
||||||
{{ getMediaTypeLabel(item.media_type) }}
|
|
||||||
</VChip>
|
|
||||||
<span v-if="item.year">{{ item.year }}</span>
|
|
||||||
<span v-if="isRecognized(item)">{{ recognitionIdLabel }} #{{ getRecognitionId(item) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="recognition-cache-mobile-item__key">{{ item.key }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<VBtn
|
|
||||||
icon
|
|
||||||
size="small"
|
|
||||||
variant="text"
|
|
||||||
color="error"
|
|
||||||
:aria-label="t('common.delete')"
|
|
||||||
@click="deleteSingleItem(item)"
|
|
||||||
>
|
|
||||||
<VIcon icon="mdi-delete-outline" size="20" />
|
|
||||||
</VBtn>
|
|
||||||
</article>
|
|
||||||
</template>
|
|
||||||
</ProgressiveCardGrid>
|
|
||||||
</VInfiniteScroll>
|
|
||||||
|
|
||||||
<div v-else class="cache-panel-empty">
|
|
||||||
<VIcon icon="mdi-database-search-outline" size="42" />
|
|
||||||
<strong>{{ t('setting.cache.noRecognitionCache', { source: recognitionSourceName }) }}</strong>
|
|
||||||
<span>{{ t('setting.cache.noRecognitionCacheHint') }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<VDataTable
|
|
||||||
v-else
|
|
||||||
v-model="selectedItems"
|
|
||||||
class="recognition-cache-table"
|
|
||||||
:headers="tableHeaders"
|
|
||||||
:items="filteredData"
|
|
||||||
:loading="loading"
|
|
||||||
item-value="key"
|
|
||||||
show-select
|
|
||||||
hover
|
|
||||||
:items-per-page-text="t('common.itemsPerPage')"
|
|
||||||
:no-data-text="t('common.noDataText')"
|
|
||||||
:loading-text="t('common.loadingText')"
|
|
||||||
>
|
|
||||||
<template #item.poster="{ item }">
|
|
||||||
<div class="recognition-cache-table__poster rounded-md">
|
|
||||||
<VImg v-if="getPosterUrl(item)" :src="getPosterUrl(item)" :alt="item.title || item.key" cover />
|
|
||||||
<VIcon v-else icon="mdi-image-off-outline" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #item.key="{ item }">
|
|
||||||
<div class="recognition-cache-table__key">{{ item.key }}</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #item.result="{ item }">
|
|
||||||
<div class="recognition-cache-result">
|
|
||||||
<strong>{{ item.title || t('setting.cache.unrecognized') }}</strong>
|
|
||||||
<span v-if="item.year">{{ item.year }}</span>
|
|
||||||
<VChip size="x-small" variant="tonal" :color="getMediaTypeColor(item.media_type)">
|
|
||||||
{{ getMediaTypeLabel(item.media_type) }}
|
|
||||||
</VChip>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #item.recognition_id="{ item }">
|
|
||||||
<span v-if="isRecognized(item)" class="font-weight-medium">#{{ getRecognitionId(item) }}</span>
|
|
||||||
<span v-else class="text-medium-emphasis">-</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #item.status="{ item }">
|
|
||||||
<VChip size="small" variant="tonal" :color="isRecognized(item) ? 'success' : 'warning'">
|
|
||||||
{{ getRecognitionStatusLabel(item) }}
|
|
||||||
</VChip>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #item.actions="{ item }">
|
|
||||||
<VBtn icon size="small" variant="text" color="error" @click="deleteSingleItem(item)">
|
|
||||||
<VIcon icon="mdi-delete-outline" size="18" />
|
|
||||||
<VTooltip activator="parent" location="start">{{ t('common.delete') }}</VTooltip>
|
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</template>
|
<VBtn value="music" prepend-icon="mdi-music-note-outline">
|
||||||
|
{{ t('setting.cache.recognitionCategory.music') }}
|
||||||
|
</VBtn>
|
||||||
|
</VBtnToggle>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template #no-data>
|
<MusicRecognitionCachePanel v-if="recognitionCategory === 'music'" />
|
||||||
<div class="cache-panel-empty">
|
|
||||||
|
<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-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"
|
||||||
|
:key="mobileInfiniteKey"
|
||||||
|
mode="intersect"
|
||||||
|
side="end"
|
||||||
|
:items="mobileVisibleData"
|
||||||
|
class="recognition-cache-mobile-scroll"
|
||||||
|
@load="loadMoreMobileCache"
|
||||||
|
>
|
||||||
|
<template #loading>
|
||||||
|
<div class="cache-panel-load-state">
|
||||||
|
<VProgressCircular indeterminate color="primary" size="22" width="3" />
|
||||||
|
<span>{{ t('setting.cache.loadingMore') }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #empty />
|
||||||
|
|
||||||
|
<ProgressiveCardGrid
|
||||||
|
v-if="mobileVisibleData.length > 0"
|
||||||
|
:items="mobileVisibleData"
|
||||||
|
:columns="1"
|
||||||
|
:gap="10"
|
||||||
|
:estimated-item-height="152"
|
||||||
|
:overscan-rows="5"
|
||||||
|
:get-item-key="getRecognitionCacheItemKey"
|
||||||
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<article class="recognition-cache-mobile-item">
|
||||||
|
<div class="recognition-cache-poster rounded-md">
|
||||||
|
<VImg v-if="getPosterUrl(item)" :src="getPosterUrl(item)" :alt="item.title || item.key" cover />
|
||||||
|
<VIcon v-else icon="mdi-image-off-outline" size="28" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="recognition-cache-mobile-item__content">
|
||||||
|
<div class="recognition-cache-mobile-item__title">
|
||||||
|
{{ item.title || t('setting.cache.unrecognized') }}
|
||||||
|
</div>
|
||||||
|
<div class="recognition-cache-mobile-item__meta">
|
||||||
|
<VChip size="x-small" variant="tonal" :color="getMediaTypeColor(item.media_type)">
|
||||||
|
{{ getMediaTypeLabel(item.media_type) }}
|
||||||
|
</VChip>
|
||||||
|
<span v-if="item.year">{{ item.year }}</span>
|
||||||
|
<span v-if="isRecognized(item)">{{ recognitionIdLabel }} #{{ getRecognitionId(item) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="recognition-cache-mobile-item__key">{{ item.key }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VBtn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
color="error"
|
||||||
|
:aria-label="t('common.delete')"
|
||||||
|
@click="deleteSingleItem(item)"
|
||||||
|
>
|
||||||
|
<VIcon icon="mdi-delete-outline" size="20" />
|
||||||
|
</VBtn>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
</ProgressiveCardGrid>
|
||||||
|
</VInfiniteScroll>
|
||||||
|
|
||||||
|
<div v-else class="cache-panel-empty">
|
||||||
<VIcon icon="mdi-database-search-outline" size="42" />
|
<VIcon icon="mdi-database-search-outline" size="42" />
|
||||||
<strong>{{ t('setting.cache.noRecognitionCache', { source: recognitionSourceName }) }}</strong>
|
<strong>{{ t('setting.cache.noRecognitionCache', { source: recognitionSourceName }) }}</strong>
|
||||||
<span>{{ t('setting.cache.noRecognitionCacheHint') }}</span>
|
<span>{{ t('setting.cache.noRecognitionCacheHint') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</VDataTable>
|
|
||||||
|
<VDataTable
|
||||||
|
v-else
|
||||||
|
v-model="selectedItems"
|
||||||
|
class="recognition-cache-table"
|
||||||
|
:headers="tableHeaders"
|
||||||
|
:items="filteredData"
|
||||||
|
:loading="loading"
|
||||||
|
item-value="key"
|
||||||
|
show-select
|
||||||
|
hover
|
||||||
|
:items-per-page-text="t('common.itemsPerPage')"
|
||||||
|
:no-data-text="t('common.noDataText')"
|
||||||
|
:loading-text="t('common.loadingText')"
|
||||||
|
>
|
||||||
|
<template #item.poster="{ item }">
|
||||||
|
<div class="recognition-cache-table__poster rounded-md">
|
||||||
|
<VImg v-if="getPosterUrl(item)" :src="getPosterUrl(item)" :alt="item.title || item.key" cover />
|
||||||
|
<VIcon v-else icon="mdi-image-off-outline" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.key="{ item }">
|
||||||
|
<div class="recognition-cache-table__key">{{ item.key }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.result="{ item }">
|
||||||
|
<div class="recognition-cache-result">
|
||||||
|
<strong>{{ item.title || t('setting.cache.unrecognized') }}</strong>
|
||||||
|
<span v-if="item.year">{{ item.year }}</span>
|
||||||
|
<VChip size="x-small" variant="tonal" :color="getMediaTypeColor(item.media_type)">
|
||||||
|
{{ getMediaTypeLabel(item.media_type) }}
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.recognition_id="{ item }">
|
||||||
|
<span v-if="isRecognized(item)" class="font-weight-medium">#{{ getRecognitionId(item) }}</span>
|
||||||
|
<span v-else class="text-medium-emphasis">-</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.status="{ item }">
|
||||||
|
<VChip size="small" variant="tonal" :color="isRecognized(item) ? 'success' : 'warning'">
|
||||||
|
{{ getRecognitionStatusLabel(item) }}
|
||||||
|
</VChip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.actions="{ item }">
|
||||||
|
<VBtn icon size="small" variant="text" color="error" @click="deleteSingleItem(item)">
|
||||||
|
<VIcon icon="mdi-delete-outline" size="18" />
|
||||||
|
<VTooltip activator="parent" location="start">{{ t('common.delete') }}</VTooltip>
|
||||||
|
</VBtn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #no-data>
|
||||||
|
<div class="cache-panel-empty">
|
||||||
|
<VIcon icon="mdi-database-search-outline" size="42" />
|
||||||
|
<strong>{{ t('setting.cache.noRecognitionCache', { source: recognitionSourceName }) }}</strong>
|
||||||
|
<span>{{ t('setting.cache.noRecognitionCacheHint') }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VDataTable>
|
||||||
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -494,6 +531,25 @@ watch([searchFilter, statusFilter], () => {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.recognition-cache-categories {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
.cache-panel-toolbar {
|
.cache-panel-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -664,6 +720,19 @@ watch([searchFilter, statusFilter], () => {
|
|||||||
padding-inline: 16px;
|
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 {
|
.cache-panel-toolbar {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import MusicRecognitionCachePanel from '@/components/cache/MusicRecognitionCachePanel.vue'
|
||||||
|
import { screen, waitFor } 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,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
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: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderMusicRecognitionCachePanel() {
|
||||||
|
return renderWithProviders(MusicRecognitionCachePanel, {
|
||||||
|
initialState: {
|
||||||
|
globalSettings: {
|
||||||
|
data: {
|
||||||
|
GLOBAL_IMAGE_CACHE: false,
|
||||||
|
RECOGNIZE_SOURCE: 'musicbrainz',
|
||||||
|
TMDB_IMAGE_DOMAIN: 'image.tmdb.org',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MusicRecognitionCachePanel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.apiGet.mockReset()
|
||||||
|
mocks.apiDelete.mockReset()
|
||||||
|
mocks.toastError.mockReset()
|
||||||
|
mocks.toastSuccess.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty hint when music cache is empty', async () => {
|
||||||
|
mocks.apiGet.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
count: 0,
|
||||||
|
recognized: 0,
|
||||||
|
unrecognized: 0,
|
||||||
|
data: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2703,6 +2703,11 @@ export default {
|
|||||||
cacheType: 'Cache Type',
|
cacheType: 'Cache Type',
|
||||||
torrentCache: 'Resource Cache',
|
torrentCache: 'Resource Cache',
|
||||||
recognitionCache: 'Recognition Cache',
|
recognitionCache: 'Recognition Cache',
|
||||||
|
recognitionCategoryLabel: 'Recognition Cache Category',
|
||||||
|
recognitionCategory: {
|
||||||
|
media: 'Movie/TV',
|
||||||
|
music: 'Music',
|
||||||
|
},
|
||||||
totalCount: 'Total Count',
|
totalCount: 'Total Count',
|
||||||
siteCount: 'Site Count',
|
siteCount: 'Site Count',
|
||||||
recognized: 'Recognized',
|
recognized: 'Recognized',
|
||||||
@@ -2736,6 +2741,7 @@ export default {
|
|||||||
poster: 'Poster',
|
poster: 'Poster',
|
||||||
cacheKey: 'Recognition Request',
|
cacheKey: 'Recognition Request',
|
||||||
tmdbId: 'TMDB ID',
|
tmdbId: 'TMDB ID',
|
||||||
|
musicbrainzId: 'MusicBrainz ID',
|
||||||
doubanId: 'Douban ID',
|
doubanId: 'Douban ID',
|
||||||
torrentTitle: 'Title',
|
torrentTitle: 'Title',
|
||||||
site: 'Site',
|
site: 'Site',
|
||||||
@@ -2769,8 +2775,14 @@ export default {
|
|||||||
mediaType: {
|
mediaType: {
|
||||||
movie: 'Movie',
|
movie: 'Movie',
|
||||||
tv: 'TV Show',
|
tv: 'TV Show',
|
||||||
|
music: 'Music',
|
||||||
unknown: 'Unknown',
|
unknown: 'Unknown',
|
||||||
},
|
},
|
||||||
|
musicType: {
|
||||||
|
recording: 'Single',
|
||||||
|
album: 'Album',
|
||||||
|
artist: 'Artist',
|
||||||
|
},
|
||||||
clearConfirm: 'Are you sure you want to clear all cache?',
|
clearConfirm: 'Are you sure you want to clear all cache?',
|
||||||
recognitionClearConfirm: 'Clear all {source} recognition cache?',
|
recognitionClearConfirm: 'Clear all {source} recognition cache?',
|
||||||
tmdbClearConfirm: 'Clear all TheMovieDb recognition cache?',
|
tmdbClearConfirm: 'Clear all TheMovieDb recognition cache?',
|
||||||
|
|||||||
@@ -2652,6 +2652,11 @@ export default {
|
|||||||
cacheType: '缓存类型',
|
cacheType: '缓存类型',
|
||||||
torrentCache: '资源缓存',
|
torrentCache: '资源缓存',
|
||||||
recognitionCache: '识别缓存',
|
recognitionCache: '识别缓存',
|
||||||
|
recognitionCategoryLabel: '识别缓存分类',
|
||||||
|
recognitionCategory: {
|
||||||
|
media: '电影/电视剧',
|
||||||
|
music: '音乐',
|
||||||
|
},
|
||||||
totalCount: '总条数',
|
totalCount: '总条数',
|
||||||
siteCount: '站点数',
|
siteCount: '站点数',
|
||||||
recognized: '已识别',
|
recognized: '已识别',
|
||||||
@@ -2685,6 +2690,7 @@ export default {
|
|||||||
poster: '海报',
|
poster: '海报',
|
||||||
cacheKey: '识别请求',
|
cacheKey: '识别请求',
|
||||||
tmdbId: 'TMDB ID',
|
tmdbId: 'TMDB ID',
|
||||||
|
musicbrainzId: 'MusicBrainz ID',
|
||||||
doubanId: '豆瓣 ID',
|
doubanId: '豆瓣 ID',
|
||||||
torrentTitle: '标题',
|
torrentTitle: '标题',
|
||||||
site: '站点',
|
site: '站点',
|
||||||
@@ -2718,8 +2724,14 @@ export default {
|
|||||||
mediaType: {
|
mediaType: {
|
||||||
movie: '电影',
|
movie: '电影',
|
||||||
tv: '电视剧',
|
tv: '电视剧',
|
||||||
|
music: '音乐',
|
||||||
unknown: '未知',
|
unknown: '未知',
|
||||||
},
|
},
|
||||||
|
musicType: {
|
||||||
|
recording: '单曲',
|
||||||
|
album: '专辑',
|
||||||
|
artist: '艺术家',
|
||||||
|
},
|
||||||
clearConfirm: '确认清空所有缓存吗?',
|
clearConfirm: '确认清空所有缓存吗?',
|
||||||
recognitionClearConfirm: '确认清空全部 {source} 识别缓存吗?',
|
recognitionClearConfirm: '确认清空全部 {source} 识别缓存吗?',
|
||||||
tmdbClearConfirm: '确认清空全部 TheMovieDb 识别缓存吗?',
|
tmdbClearConfirm: '确认清空全部 TheMovieDb 识别缓存吗?',
|
||||||
|
|||||||
@@ -2651,6 +2651,11 @@ export default {
|
|||||||
cacheType: '緩存類型',
|
cacheType: '緩存類型',
|
||||||
torrentCache: '資源緩存',
|
torrentCache: '資源緩存',
|
||||||
recognitionCache: '識別緩存',
|
recognitionCache: '識別緩存',
|
||||||
|
recognitionCategoryLabel: '識別緩存分類',
|
||||||
|
recognitionCategory: {
|
||||||
|
media: '電影/電視劇',
|
||||||
|
music: '音樂',
|
||||||
|
},
|
||||||
totalCount: '總條數',
|
totalCount: '總條數',
|
||||||
siteCount: '站點數',
|
siteCount: '站點數',
|
||||||
recognized: '已識別',
|
recognized: '已識別',
|
||||||
@@ -2684,6 +2689,7 @@ export default {
|
|||||||
poster: '海報',
|
poster: '海報',
|
||||||
cacheKey: '識別請求',
|
cacheKey: '識別請求',
|
||||||
tmdbId: 'TMDB ID',
|
tmdbId: 'TMDB ID',
|
||||||
|
musicbrainzId: 'MusicBrainz ID',
|
||||||
doubanId: '豆瓣 ID',
|
doubanId: '豆瓣 ID',
|
||||||
torrentTitle: '標題',
|
torrentTitle: '標題',
|
||||||
site: '站點',
|
site: '站點',
|
||||||
@@ -2717,8 +2723,14 @@ export default {
|
|||||||
mediaType: {
|
mediaType: {
|
||||||
movie: '電影',
|
movie: '電影',
|
||||||
tv: '電視劇',
|
tv: '電視劇',
|
||||||
|
music: '音樂',
|
||||||
unknown: '未知',
|
unknown: '未知',
|
||||||
},
|
},
|
||||||
|
musicType: {
|
||||||
|
recording: '單曲',
|
||||||
|
album: '專輯',
|
||||||
|
artist: '藝術家',
|
||||||
|
},
|
||||||
clearConfirm: '確認清空所有緩存嗎?',
|
clearConfirm: '確認清空所有緩存嗎?',
|
||||||
recognitionClearConfirm: '確認清空全部 {source} 識別緩存嗎?',
|
recognitionClearConfirm: '確認清空全部 {source} 識別緩存嗎?',
|
||||||
tmdbClearConfirm: '確認清空全部 TheMovieDb 識別緩存嗎?',
|
tmdbClearConfirm: '確認清空全部 TheMovieDb 識別緩存嗎?',
|
||||||
|
|||||||
Reference in New Issue
Block a user