Enhance name recognition results with source metadata

This commit is contained in:
jxxghp
2026-07-31 15:12:41 +08:00
parent 41a36e2279
commit c957264b72
9 changed files with 478 additions and 38 deletions

View File

@@ -63,6 +63,10 @@ const bodyClasses = computed(() => [
},
])
// 允许工具视图在执行后续操作前主动关闭外层弹窗。
function closeDialog() {
visible.value = false
}
</script>
<template>
@@ -77,7 +81,7 @@ const bodyClasses = computed(() => [
</VCardItem>
<VDivider />
<VCardText :class="bodyClasses">
<Component :is="props.view" v-bind="props.viewProps" />
<Component :is="props.view" v-bind="props.viewProps" @close="closeDialog" />
</VCardText>
</VCard>
</VDialog>

View File

@@ -0,0 +1,34 @@
import ShortcutToolDialog from '@/components/dialog/ShortcutToolDialog.vue'
import { screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, markRaw } from 'vue'
import { describe, expect, it } from 'vitest'
const ToolView = defineComponent({
emits: ['close'],
template: '<button type="button" @click="$emit(\'close\')">关闭工具</button>',
})
describe('ShortcutToolDialog', () => {
it('closes the outer dialog when the active tool requests it', async () => {
const user = userEvent.setup()
const result = await renderWithProviders(ShortcutToolDialog, {
props: {
modelValue: true,
title: '测试工具',
view: markRaw(ToolView),
},
global: {
stubs: {
VDialogCloseBtn: true,
},
},
})
await user.click(screen.getByRole('button', { name: '关闭工具' }))
expect(result.emitted()['update:modelValue']).toEqual([[false]])
expect(result.emitted().close).toEqual([[]])
})
})

View File

@@ -1625,9 +1625,13 @@ export default {
title: 'Meta Info',
caption: 'Parsed name, episodes, and resource terms',
},
source: {
title: 'Recognition Source',
caption: 'The media source matched by this recognition',
},
media: {
title: 'Media Match',
caption: 'Final matched media source',
title: 'Media ID',
caption: 'The native media ID from the matched source',
},
},
},

View File

@@ -1614,9 +1614,13 @@ export default {
title: '元信息',
caption: '解析出的名称、季集和资源信息',
},
source: {
title: '识别数据源',
caption: '本次识别匹配到的媒体数据源',
},
media: {
title: '媒体匹配',
caption: '最终匹配到的媒体数据源',
title: '媒体 ID',
caption: '最终匹配到的媒体数据源原生 ID',
},
},
},

View File

@@ -1613,9 +1613,13 @@ export default {
title: '元資訊',
caption: '解析出的名稱、季集和資源資訊',
},
source: {
title: '識別資料源',
caption: '本次識別匹配到的媒體資料源',
},
media: {
title: '媒體匹配',
caption: '最終匹配到的媒體資料源',
title: '媒體 ID',
caption: '最終匹配到的媒體資料源原生 ID',
},
},
},

View File

@@ -0,0 +1,13 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
describe('text-moviepilot theme color', () => {
it('derives its gradient from the active Vuetify primary color', () => {
const commonStyles = readFileSync(resolve(process.cwd(), 'src/styles/common.scss'), 'utf8')
const textMoviePilotRule = commonStyles.match(/\.text-moviepilot\s*\{(?<rule>[\s\S]*?)\n\}/)?.groups?.rule
expect(textMoviePilotRule).toContain('var(--v-theme-primary)')
expect(textMoviePilotRule).not.toMatch(/#818cf8|#c084fc/i)
})
})

View File

@@ -1377,12 +1377,8 @@ html[data-theme='transparent'].transparent-glass-realtime .v-theme--transparent
// 文本样式
.text-moviepilot {
background-clip: text;
background-image: linear-gradient(to bottom right, var(--tw-gradient-stops));
background-image: linear-gradient(to bottom right, rgba(var(--v-theme-primary), 0.72), rgb(var(--v-theme-primary)));
color: transparent;
--tw-gradient-from: #818cf8;
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
--tw-gradient-to: #c084fc;
}
// 登录页与主框架共用的品牌文字效果。

View File

@@ -1,20 +1,55 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { computed, nextTick, reactive, ref } from 'vue'
import { useToast } from 'vue-toastification'
import { requiredValidator } from '@/@validators'
import api from '@/api'
import type { Context, MediaDataSource, MediaInfo } from '@/api/types'
import { getMediaSubscribeId } from '@/composables/useMediaSubscribe'
import { getMediaSubscribeId, getMediaSubscribeIdentity } from '@/composables/useMediaSubscribe'
import router from '@/router'
import { useGlobalSettingsStore } from '@/stores'
import { getLogoUrl } from '@/utils/imageUtils'
import { useI18n } from 'vue-i18n'
interface PipelineStep {
icon: string
identity?: MediaIdentity
source?: MediaSourceDisplay
title: string
value: string
}
interface MediaIdentity {
id: string
link?: string
source: string
sourceKey: string
}
interface MediaSourceDisplay {
icon?: string
image?: string
key: string
label: string
}
const MEDIA_SOURCE_LABELS: Record<string, string> = {
anilist: 'AniList',
bangumi: 'Bangumi',
douban: 'Douban',
themoviedb: 'TheMovieDb',
}
const MEDIA_SOURCE_LOGOS: Record<string, string> = {
bangumi: getLogoUrl('bangumi'),
douban: getLogoUrl('douban'),
themoviedb: getLogoUrl('tmdb'),
}
const NAME_TEST_TITLE_HISTORY_KEY = 'MP_NAME_TEST_TITLE_HISTORY'
const NAME_TEST_TITLE_HISTORY_LIMIT = 5
const emit = defineEmits<{ close: [] }>()
// 国际化
const { t } = useI18n()
const globalSettingsStore = useGlobalSettingsStore()
@@ -40,12 +75,47 @@ const nameTestResult = ref<Context>()
// 名称识别表单
const nameTestForm = reactive({
title: '',
subtitle: '',
customWords: '',
title: null,
subtitle: null,
customWords: null,
source: getDefaultMediaSource(),
})
/** 从本地存储读取最近使用的识别标题。 */
function loadTitleHistory() {
try {
const storedHistory: unknown = JSON.parse(localStorage.getItem(NAME_TEST_TITLE_HISTORY_KEY) || '[]')
if (!Array.isArray(storedHistory)) return []
return storedHistory
.filter((title): title is string => typeof title === 'string' && Boolean(title.trim()))
.map(title => title.trim())
.filter((title, index, titles) => titles.indexOf(title) === index)
.slice(0, NAME_TEST_TITLE_HISTORY_LIMIT)
} catch {
return []
}
}
const nameTestTitleHistory = ref<string[]>(loadTitleHistory())
/** 将本次提交的标题移到历史记录首位,并只保留最新五条。 */
function saveTitleHistory(title: string) {
const normalizedTitle = title.trim()
if (!normalizedTitle) return
nameTestTitleHistory.value = [
normalizedTitle,
...nameTestTitleHistory.value.filter(historyTitle => historyTitle !== normalizedTitle),
].slice(0, NAME_TEST_TITLE_HISTORY_LIMIT)
try {
localStorage.setItem(NAME_TEST_TITLE_HISTORY_KEY, JSON.stringify(nameTestTitleHistory.value))
} catch {
// 本地存储不可用时仍继续执行本次识别。
}
}
// 识别按钮状态
const nameTestLoading = ref(false)
@@ -91,17 +161,53 @@ const canViewMediaDetail = computed(() =>
),
)
/** 生成识别结果中的数据源原生ID摘要并兼容旧接口字段。 */
function getMediaIdentityLabel(media?: MediaInfo) {
if (!media) return t('nameTest.unrecognized')
if (media.media_id) return `${media.source || media.mediaid_prefix} ${media.media_id}`
if (media.tmdb_id) return `TMDB ${media.tmdb_id}`
if (media.douban_id) return `Douban ${media.douban_id}`
if (media.bangumi_id) return `Bangumi ${media.bangumi_id}`
if (media.anilist_id) return `AniList ${media.anilist_id}`
return media.title || t('nameTest.unrecognized')
/** 生成媒体源官方详情页地址。 */
function getMediaOfficialLink(media: MediaInfo, source: string, mediaId: string) {
const encodedId = encodeURIComponent(mediaId)
switch (source) {
case 'themoviedb': {
const mediaType = media.type?.trim().toLowerCase()
return `https://www.themoviedb.org/${mediaType === '电影' || mediaType === 'movie' ? 'movie' : 'tv'}/${encodedId}`
}
case 'douban':
return `https://movie.douban.com/subject/${encodedId}`
case 'bangumi':
return `https://bgm.tv/subject/${encodedId}`
case 'anilist':
return `https://anilist.co/anime/${encodedId}`
default:
return undefined
}
}
/** 生成识别结果中的数据源原生 ID并兼容旧接口字段。 */
function getMediaIdentity(media?: MediaInfo): MediaIdentity | undefined {
if (!media) return undefined
const identity = getMediaSubscribeIdentity(media)
if (!identity) return undefined
return {
id: identity.mediaId,
link: getMediaOfficialLink(media, identity.source, identity.mediaId),
source: MEDIA_SOURCE_LABELS[identity.source] || identity.source,
sourceKey: identity.source,
}
}
const mediaIdentity = computed(() => getMediaIdentity(mediaInfo.value))
const recognizedMediaSource = computed<MediaSourceDisplay>(() => {
const sourceKey = mediaIdentity.value?.sourceKey || nameTestForm.source
return {
icon: sourceKey === 'anilist' ? 'mdi-alpha-a-circle' : undefined,
image: MEDIA_SOURCE_LOGOS[sourceKey],
key: sourceKey,
label: mediaIdentity.value?.source || MEDIA_SOURCE_LABELS[sourceKey] || sourceKey,
}
})
const pipelineSteps = computed<PipelineStep[]>(() => [
{
icon: 'mdi-file-document-outline',
@@ -117,9 +223,16 @@ const pipelineSteps = computed<PipelineStep[]>(() => [
.join(' · ') || '-',
},
{
icon: 'mdi-movie-search-outline',
icon: 'mdi-database-search-outline',
source: recognizedMediaSource.value,
title: t('nameTest.steps.source.title'),
value: recognizedMediaSource.value.label,
},
{
icon: 'mdi-identifier',
identity: mediaIdentity.value,
title: t('nameTest.steps.media.title'),
value: getMediaIdentityLabel(mediaInfo.value),
value: mediaIdentity.value?.id || t('nameTest.unrecognized'),
},
])
@@ -129,11 +242,11 @@ function getPosterImage(url = '') {
return url.replace('original', 'w500')
}
/** 跳转查看当前识别结果匹配到的媒体详情。 */
function viewMediaDetail() {
/** 关闭识别测试弹窗后,跳转查看当前识别结果匹配到的媒体详情。 */
async function viewMediaDetail() {
if (!canViewMediaDetail.value || !mediaInfo.value) return
router.push({
const target = {
path: '/media',
query: {
mediaid: getMediaSubscribeId(mediaInfo.value),
@@ -141,12 +254,20 @@ function viewMediaDetail() {
year: mediaInfo.value.year,
type: mediaInfo.value.type,
},
})
}
emit('close')
await nextTick()
await router.push(target)
}
/** 调用媒体识别接口并刷新解析工作台,输入的识别词会临时应用于本次识别测试。 */
async function nameTest() {
if (!nameTestForm.title) return
const normalizedTitle = nameTestForm.title?.trim() || ''
if (!normalizedTitle) return
nameTestForm.title = normalizedTitle
saveTitleHistory(normalizedTitle)
try {
nameTestLoading.value = true
@@ -157,7 +278,7 @@ async function nameTest() {
params: {
title: nameTestForm.title,
subtitle: nameTestForm.subtitle,
custom_words: nameTestForm.customWords || undefined,
custom_words: nameTestForm.customWords?.trim() || undefined,
source: nameTestForm.source,
},
})
@@ -180,7 +301,7 @@ function parseCustomWordLines(text: string) {
async function saveCustomWords() {
if (savingCustomWords.value) return
const newLines = parseCustomWordLines(nameTestForm.customWords)
const newLines = parseCustomWordLines(nameTestForm.customWords || '')
if (!newLines.length) return
savingCustomWords.value = true
@@ -216,8 +337,9 @@ async function saveCustomWords() {
<VForm validate-on="submit lazy" @submit.prevent="nameTest">
<VRow class="shortcut-form">
<VCol cols="12" class="shortcut-form-col">
<VTextField
<VCombobox
v-model="nameTestForm.title"
:items="nameTestTitleHistory"
:label="t('nameTest.title')"
:hint="t('nameTest.titleHint')"
persistent-hint
@@ -263,7 +385,7 @@ async function saveCustomWords() {
size="small"
variant="tonal"
color="primary"
:disabled="!nameTestForm.customWords.trim()"
:disabled="!nameTestForm.customWords?.trim()"
:loading="savingCustomWords"
@click="saveCustomWords"
>
@@ -359,7 +481,42 @@ async function saveCustomWords() {
{{ step.title }}
</div>
<div class="text-body-2 font-weight-medium pipeline-value">
{{ step.value }}
<span
v-if="step.source"
class="media-source-display"
:aria-label="step.source.label"
:data-source="step.source.key"
:title="step.source.label"
data-testid="recognition-source"
>
<VImg
v-if="step.source.image"
class="media-source-logo"
:src="step.source.image"
:alt="step.source.label"
/>
<VIcon
v-else-if="step.source.icon"
class="media-source-logo"
color="#02a9ff"
:icon="step.source.icon"
/>
<span>{{ step.source.label }}</span>
</span>
<template v-else-if="step.identity">
<a
v-if="step.identity.link"
class="media-id-link"
:href="step.identity.link"
target="_blank"
rel="noopener noreferrer"
@click.stop
>
{{ step.identity.id }}
</a>
<span v-else>{{ step.identity.id }}</span>
</template>
<template v-else>{{ step.value }}</template>
</div>
</div>
</div>
@@ -534,6 +691,24 @@ async function saveCustomWords() {
word-break: break-word;
}
.media-id-link {
color: rgb(var(--v-theme-primary));
text-decoration: underline;
text-underline-offset: 0.15em;
}
.media-source-display {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.media-source-logo {
flex: 0 0 1.4rem;
block-size: 1.4rem;
inline-size: 1.4rem;
}
.applied-words {
display: grid;
border-block-start: var(--app-surface-border);

View File

@@ -0,0 +1,206 @@
import NameTestView from '@/views/system/NameTestView.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(),
apiPost: vi.fn(),
routerPush: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
toastWarning: vi.fn(),
}))
vi.mock('@/api', () => ({
default: {
get: mocks.apiGet,
post: mocks.apiPost,
},
}))
vi.mock('@/router', () => ({
default: {
push: mocks.routerPush,
},
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({
error: mocks.toastError,
success: mocks.toastSuccess,
warning: mocks.toastWarning,
}),
}))
interface RecognizedMedia {
media_id: string
source: string
title: string
type: string
year: string
}
async function renderRecognizedMedia(media: RecognizedMedia, onClose = vi.fn()) {
mocks.apiGet.mockResolvedValueOnce({
media_info: media,
meta_info: {
apply_words: [],
name: media.title,
org_string: 'Test.Release',
},
torrent_info: {},
})
const result = await renderWithProviders(NameTestView, {
attrs: { onClose },
initialState: {
globalSettings: {
data: { RECOGNIZE_SOURCE: 'themoviedb' },
},
},
})
const user = userEvent.setup()
await user.type(screen.getByLabelText('标题'), 'Test.Release')
await user.click(screen.getByRole('button', { name: '识别' }))
await screen.findByRole('link', { name: media.media_id })
return { ...result, onClose, user }
}
describe('NameTestView media identity', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
mocks.routerPush.mockResolvedValue(undefined)
})
it.each([
[
'TheMovieDb',
{ media_id: '271016', source: 'themoviedb', title: '测试剧集', type: '电视剧', year: '2026' },
'https://www.themoviedb.org/tv/271016',
],
[
'Douban',
{ media_id: '1295644', source: 'douban', title: '测试电影', type: '电影', year: '1994' },
'https://movie.douban.com/subject/1295644',
],
[
'Bangumi',
{ media_id: '485', source: 'bangumi', title: '测试动画', type: '电视剧', year: '2026' },
'https://bgm.tv/subject/485',
],
[
'AniList',
{ media_id: '154587', source: 'anilist', title: '测试番剧', type: '电视剧', year: '2026' },
'https://anilist.co/anime/154587',
],
])('formats %s and links its native media ID', async (sourceLabel, media, expectedLink) => {
await renderRecognizedMedia(media)
const mediaIdLink = screen.getByRole('link', { name: media.media_id })
expect(mediaIdLink).toHaveAttribute('href', expectedLink)
expect(mediaIdLink).toHaveAttribute('target', '_blank')
expect(mediaIdLink).toHaveAttribute('rel', 'noopener noreferrer')
expect(mediaIdLink.closest('.pipeline-step')).toHaveTextContent(`媒体 ID${media.media_id}`)
expect(mediaIdLink.closest('.pipeline-step')).not.toHaveTextContent(sourceLabel)
const sourceDisplay = screen.getByTestId('recognition-source')
expect(sourceDisplay).toHaveAttribute('data-source', media.source)
expect(sourceDisplay).toHaveAccessibleName(sourceLabel)
expect(sourceDisplay.closest('.pipeline-step')).toHaveTextContent(`识别数据源${sourceLabel}`)
expect(sourceDisplay.querySelector('.media-source-logo')).toBeInTheDocument()
})
it('closes the recognition dialog before navigating to the media detail', async () => {
const eventOrder: string[] = []
const onClose = vi.fn(() => eventOrder.push('close'))
mocks.routerPush.mockImplementation(async () => {
eventOrder.push('push')
})
const media = {
media_id: '271016',
source: 'themoviedb',
title: '测试剧集',
type: '电视剧',
year: '2026',
}
const { user } = await renderRecognizedMedia(media, onClose)
await user.click(screen.getByRole('button', { name: '查看详情' }))
expect(eventOrder).toEqual(['close', 'push'])
expect(onClose).toHaveBeenCalledOnce()
expect(mocks.routerPush).toHaveBeenCalledWith({
path: '/media',
query: {
mediaid: 'tmdb:271016',
title: '测试剧集',
type: '电视剧',
year: '2026',
},
})
})
it('persists the five most recent unique titles and restores them in the combobox', async () => {
mocks.apiGet.mockImplementation(async (_endpoint: string, options: { params: { title: string } }) => ({
media_info: {
media_id: '271016',
source: 'themoviedb',
title: options.params.title,
type: '电视剧',
year: '2026',
},
meta_info: {
apply_words: [],
name: options.params.title,
org_string: options.params.title,
},
torrent_info: {},
}))
const result = await renderWithProviders(NameTestView, {
initialState: {
globalSettings: {
data: { RECOGNIZE_SOURCE: 'themoviedb' },
},
},
})
const user = userEvent.setup()
const titleInput = screen.getByLabelText('标题')
const submittedTitles = ['标题一', '标题二', '标题三', '标题四', '标题五', '标题六', '标题三']
for (const [index, title] of submittedTitles.entries()) {
await user.clear(titleInput)
await user.type(titleInput, ` ${title} `)
await user.click(screen.getByRole('button', { name: index === 0 ? '识别' : '重新识别' }))
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledTimes(index + 1))
}
expect(JSON.parse(localStorage.getItem('MP_NAME_TEST_TITLE_HISTORY') || '[]')).toEqual([
'标题三',
'标题六',
'标题五',
'标题四',
'标题二',
])
result.unmount()
await renderWithProviders(NameTestView, {
initialState: {
globalSettings: {
data: { RECOGNIZE_SOURCE: 'themoviedb' },
},
},
})
const restoredInput = screen.getByLabelText('标题')
await user.click(restoredInput)
const historyOptions = await screen.findAllByRole('option')
expect(historyOptions.map(option => option.textContent)).toEqual(['标题三', '标题六', '标题五', '标题四', '标题二'])
await user.click(screen.getByRole('option', { name: '标题六' }))
expect(restoredInput).toHaveValue('标题六')
})
})