mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 09:46:44 +08:00
fix: 完善音乐候选展示与手动下载确认
This commit is contained in:
@@ -1604,6 +1604,8 @@ export interface Context {
|
||||
media_info: MediaInfo
|
||||
// 种子信息
|
||||
torrent_info: TorrentInfo
|
||||
match_status?: 'exact' | 'candidate' | null
|
||||
match_reason?: string | null
|
||||
}
|
||||
|
||||
// 用户信息
|
||||
|
||||
@@ -9,6 +9,8 @@ import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDown
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
import { buildTorrentDownloadProps, getTorrentTitle } from '@/utils/torrent'
|
||||
import MusicMatchBadge from '@/components/misc/MusicMatchBadge.vue'
|
||||
|
||||
const AddDownloadDialog = defineAsyncComponent(() => import('../dialog/AddDownloadDialog.vue'))
|
||||
const TorrentMoreSourcesDialog = defineAsyncComponent(() => import('../dialog/TorrentMoreSourcesDialog.vue'))
|
||||
@@ -19,15 +21,13 @@ const props = defineProps({
|
||||
more: Array as PropType<Context[]>,
|
||||
width: String,
|
||||
height: String,
|
||||
targetMusicType: String,
|
||||
})
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 种子信息
|
||||
const torrent = ref(props.torrent?.torrent_info)
|
||||
|
||||
// 媒体信息
|
||||
const media = ref(props.torrent?.media_info)
|
||||
|
||||
// 识别元数据
|
||||
const meta = ref(props.torrent?.meta_info)
|
||||
|
||||
@@ -70,7 +70,7 @@ async function getSiteIcon(site: number | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
// 询问并添加下载
|
||||
/** 待确认音乐只提交资源本身,已确认资源沿用媒体身份下载。 */
|
||||
async function handleAddDownload(item: Context | null = null) {
|
||||
if (item && !isNullOrEmptyObject(item)) {
|
||||
downloadItem.value = item
|
||||
@@ -78,16 +78,7 @@ async function handleAddDownload(item: Context | null = null) {
|
||||
// 打开下载对话框
|
||||
openSharedDialog(
|
||||
AddDownloadDialog,
|
||||
{
|
||||
title: [
|
||||
downloadItem.value?.media_info?.title_year || downloadItem.value?.meta_info?.name,
|
||||
downloadItem.value?.meta_info?.season_episode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
media: downloadItem.value?.media_info,
|
||||
torrent: downloadItem.value?.torrent_info,
|
||||
},
|
||||
buildTorrentDownloadProps(downloadItem.value, props.targetMusicType),
|
||||
{
|
||||
done: addDownloadSuccess,
|
||||
error: addDownloadError,
|
||||
@@ -136,7 +127,6 @@ watch(
|
||||
() => props.torrent,
|
||||
value => {
|
||||
torrent.value = value?.torrent_info
|
||||
media.value = value?.media_info
|
||||
meta.value = value?.meta_info
|
||||
downloadItem.value = value
|
||||
getSiteIcon(value?.torrent_info?.site)
|
||||
@@ -168,9 +158,10 @@ watch(
|
||||
<!-- 媒体标题 -->
|
||||
<VCardItem class="pt-3 pb-0">
|
||||
<div class="d-flex flex-row flex-wrap justify-start align-center mb-2 pr-8">
|
||||
<span class="text-h6 font-weight-bold me-2">
|
||||
{{ media?.title ?? meta?.name }}
|
||||
<span class="text-h6 font-weight-bold me-2 torrent-heading">
|
||||
{{ getTorrentTitle(props.torrent) }}
|
||||
</span>
|
||||
<MusicMatchBadge :context="props.torrent" />
|
||||
<VChip
|
||||
v-if="meta?.season_episode"
|
||||
class="chip-season rounded-sm font-weight-bold"
|
||||
@@ -311,6 +302,12 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.torrent-heading {
|
||||
max-inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.discount-banner {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
|
||||
@@ -8,21 +8,21 @@ import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDown
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
import { buildTorrentDownloadProps, getTorrentTitle } from '@/utils/torrent'
|
||||
import MusicMatchBadge from '@/components/misc/MusicMatchBadge.vue'
|
||||
|
||||
const AddDownloadDialog = defineAsyncComponent(() => import('../dialog/AddDownloadDialog.vue'))
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
torrent: Object as PropType<Context>,
|
||||
targetMusicType: String,
|
||||
})
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 种子信息
|
||||
const torrent = ref(props.torrent?.torrent_info)
|
||||
|
||||
// 媒体信息
|
||||
const media = ref(props.torrent?.media_info)
|
||||
|
||||
// 识别元数据
|
||||
const meta = ref(props.torrent?.meta_info)
|
||||
|
||||
@@ -69,16 +69,12 @@ function getPromotionClass(downloadVolumeFactor: number | undefined, uploadVolum
|
||||
else return ''
|
||||
}
|
||||
|
||||
// 询问并添加下载
|
||||
/** 待确认音乐只提交资源本身,避免继承缓存中的目标媒体身份。 */
|
||||
async function handleAddDownload() {
|
||||
// 打开下载对话框
|
||||
openSharedDialog(
|
||||
AddDownloadDialog,
|
||||
{
|
||||
title: `${media.value?.title_year || meta.value?.name} ${meta.value?.season_episode || ''}`,
|
||||
media: media.value,
|
||||
torrent: torrent.value,
|
||||
},
|
||||
buildTorrentDownloadProps(props.torrent, props.targetMusicType),
|
||||
{
|
||||
done: addDownloadSuccess,
|
||||
error: addDownloadError,
|
||||
@@ -108,7 +104,6 @@ watch(
|
||||
() => props.torrent,
|
||||
value => {
|
||||
torrent.value = value?.torrent_info
|
||||
media.value = value?.media_info
|
||||
meta.value = value?.meta_info
|
||||
getSiteIcon(value?.torrent_info?.site)
|
||||
},
|
||||
@@ -156,7 +151,8 @@ watch(
|
||||
|
||||
<VListItemTitle class="whitespace-normal">
|
||||
<div class="d-flex flex-row flex-wrap align-center mb-2">
|
||||
<span class="text-h6 font-weight-bold me-2">{{ media?.title ?? meta?.name }}</span>
|
||||
<span class="text-h6 font-weight-bold me-2 torrent-heading">{{ getTorrentTitle(props.torrent) }}</span>
|
||||
<MusicMatchBadge :context="props.torrent" />
|
||||
<VChip
|
||||
v-if="meta?.season_episode"
|
||||
class="chip-season rounded-sm font-weight-bold"
|
||||
@@ -230,7 +226,7 @@ watch(
|
||||
</VListItemTitle>
|
||||
|
||||
<template v-slot:append>
|
||||
<div class="d-flex flex-column align-end gap-2">
|
||||
<div class="torrent-item-actions d-flex flex-row flex-sm-column align-center align-sm-end gap-2">
|
||||
<div class="d-flex align-center gap-3">
|
||||
<span v-if="torrent?.seeders" class="d-flex align-center font-weight-bold">
|
||||
<VIcon size="small" color="success" icon="mdi-arrow-up" class="mr-1"></VIcon>
|
||||
@@ -258,6 +254,32 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.torrent-heading {
|
||||
max-inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.torrent-item :deep(.v-list-item__content) {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
.torrent-item {
|
||||
grid-template-areas: 'prepend content' '. append';
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.torrent-item :deep(.v-list-item__append) {
|
||||
margin-block-start: 0.5rem;
|
||||
}
|
||||
|
||||
.torrent-item-actions {
|
||||
justify-content: space-between;
|
||||
inline-size: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.discount-banner {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
|
||||
@@ -150,6 +150,25 @@ describe('TorrentCard approved regressions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows unconfirmed music evidence and never forwards a cached target ID', async () => {
|
||||
const context = createContext({
|
||||
media: { type: '音乐', title: '旧目标', title_year: '旧目标 (2003)' },
|
||||
meta: { type: '音乐', title: '晴天', name: '叶惠美', season_episode: undefined },
|
||||
torrent: { title: 'Jay Chou - 晴天 FLAC', category: '音乐' },
|
||||
})
|
||||
context.match_status = 'candidate'
|
||||
context.match_reason = 'artist_unverified'
|
||||
const { container } = await renderCard(context)
|
||||
expect(screen.getByText('晴天', { exact: true })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('music-match-status')).toHaveTextContent('待确认')
|
||||
expect(screen.getByTestId('music-match-status')).toHaveAttribute('aria-label', '艺术家署名尚未核验')
|
||||
await fireEvent.click(getCard(container))
|
||||
const props = getDialogCall().props
|
||||
expect(props.media).toBeUndefined()
|
||||
expect((props.torrent as TorrentInfo).media_id).toBeUndefined()
|
||||
expect(props.title).toBe('晴天')
|
||||
})
|
||||
|
||||
it('does not open a blank detail page or trigger download when the URL is missing', async () => {
|
||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const context = createContext({ torrent: { page_url: undefined } })
|
||||
|
||||
@@ -36,6 +36,7 @@ const props = defineProps({
|
||||
title: String,
|
||||
media: Object as PropType<MediaInfo>,
|
||||
torrent: Object as PropType<TorrentInfo>,
|
||||
musicType: String as PropType<Exclude<MusicEntityType, 'artist'>>,
|
||||
})
|
||||
|
||||
// 媒体类型选择项:自动跟随媒体/种子上下文,其余类型由用户显式指定。
|
||||
@@ -135,16 +136,22 @@ const loading = ref(false)
|
||||
const showAdvancedOptions = ref(false)
|
||||
|
||||
// 当前数据源:优先使用已随媒体或种子传入的完整身份,可在高级选项手动切换。
|
||||
const mediaSource = ref<MediaDataSource>(deriveMediaSource(props.media, props.torrent))
|
||||
const mediaSource = ref<MediaDataSource>(
|
||||
props.musicType && !props.media ? 'musicbrainz' : deriveMediaSource(props.media, props.torrent),
|
||||
)
|
||||
|
||||
// 当前数据源的原生媒体ID
|
||||
const mediaId = ref<string | undefined>(deriveMediaId(props.media, props.torrent))
|
||||
|
||||
// 无完整媒体上下文时,音乐原生 ID 需要实体命名空间才能区分单曲和专辑。
|
||||
const musicType = ref<Exclude<MusicEntityType, 'artist'>>(props.media?.music_type === 'album' ? 'album' : 'recording')
|
||||
const musicType = ref<Exclude<MusicEntityType, 'artist'>>(
|
||||
(props.media?.music_type || props.musicType) === 'album' ? 'album' : 'recording',
|
||||
)
|
||||
|
||||
// 当前媒体类型:由媒体/种子类别推导,用户可在高级选项显式切换。
|
||||
const mediaType = ref<DownloadMediaType>(deriveMediaType(mediaSource.value, props.media, props.torrent))
|
||||
const mediaType = ref<DownloadMediaType>(
|
||||
props.musicType && !props.media ? '音乐' : deriveMediaType(mediaSource.value, props.media, props.torrent),
|
||||
)
|
||||
|
||||
const isMusicSelection = computed(() => mediaType.value === '音乐' || isMusicMediaSource(mediaSource.value))
|
||||
|
||||
@@ -203,13 +210,15 @@ watch(mediaType, type => {
|
||||
|
||||
// 运行中媒体/种子 props 变化时兜底同步身份,弹窗每次打开都是新实例。
|
||||
watch(
|
||||
() => [props.media, props.torrent] as const,
|
||||
([media, torrent]) => {
|
||||
mediaSource.value = deriveMediaSource(media, torrent)
|
||||
() => [props.media, props.torrent, props.musicType] as const,
|
||||
([media, torrent, musicHint]) => {
|
||||
mediaSource.value = musicHint && !media ? 'musicbrainz' : deriveMediaSource(media, torrent)
|
||||
mediaId.value = deriveMediaId(media, torrent)
|
||||
mediaType.value = deriveMediaType(mediaSource.value, media, torrent)
|
||||
mediaType.value = musicHint && !media ? '音乐' : deriveMediaType(mediaSource.value, media, torrent)
|
||||
if (media?.music_type === 'recording' || media?.music_type === 'album') {
|
||||
musicType.value = media.music_type
|
||||
} else if (musicHint) {
|
||||
musicType.value = musicHint
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -295,8 +304,8 @@ async function addDownload() {
|
||||
if (normalizedMediaId && isValidMediaSourceId(normalizedMediaId, mediaSource.value)) {
|
||||
payload.media_source = mediaSource.value
|
||||
payload.media_id = normalizedMediaId
|
||||
if (isMusicSelection.value) payload.music_type = musicType.value
|
||||
}
|
||||
if (isMusicSelection.value) payload.music_type = musicType.value
|
||||
|
||||
const endpoint = props.media ? 'download/' : 'download/add'
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Context } from '@/api/types'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import MusicMatchBadge from '@/components/misc/MusicMatchBadge.vue'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -83,6 +84,7 @@ function handleDetail(item: Context) {
|
||||
{{ item.torrent_info?.site_name?.substring(0, 1) }}
|
||||
</VAvatar>
|
||||
<span class="text-body-2 font-weight-bold">{{ item.torrent_info.site_name }}</span>
|
||||
<MusicMatchBadge :context="item" />
|
||||
|
||||
<VChip
|
||||
v-if="item.meta_info?.season_episode"
|
||||
|
||||
@@ -214,12 +214,14 @@ async function renderDialog({
|
||||
directories = [],
|
||||
downloaders = [],
|
||||
media,
|
||||
musicType,
|
||||
recognizeSource = 'themoviedb',
|
||||
torrent = createTorrent(),
|
||||
}: {
|
||||
directories?: DownloadDirectory[]
|
||||
downloaders?: Array<{ name: string; type: string }>
|
||||
media?: MediaInfo
|
||||
musicType?: 'album' | 'recording'
|
||||
recognizeSource?: string
|
||||
torrent?: TorrentInfo
|
||||
} = {}) {
|
||||
@@ -252,6 +254,7 @@ async function renderDialog({
|
||||
},
|
||||
props: {
|
||||
media,
|
||||
musicType,
|
||||
modelValue: true,
|
||||
onClose: events.close,
|
||||
onDone: events.done,
|
||||
@@ -422,6 +425,28 @@ describe('AddDownloadDialog submissions', () => {
|
||||
expect(screen.getByLabelText('MusicBrainz ID')).toBeEnabled()
|
||||
})
|
||||
|
||||
it('submits an unconfirmed album intent even without a media ID or site category', async () => {
|
||||
const user = userEvent.setup()
|
||||
const submitted = vi.fn()
|
||||
server.use(
|
||||
http.post('/api/v1/download/add', async ({ request }) => {
|
||||
submitted(await request.json())
|
||||
return HttpResponse.json({ success: true, data: { download_id: 'album-task' } })
|
||||
}),
|
||||
)
|
||||
await renderDialog({
|
||||
musicType: 'album',
|
||||
torrent: createTorrent({ category: '未知', media_id: undefined, media_source: undefined }),
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: '开始下载' }))
|
||||
await waitFor(() => expect(submitted).toHaveBeenCalledOnce())
|
||||
const payload = submitted.mock.calls[0][0]
|
||||
expect(payload.music_type).toBe('album')
|
||||
expect(payload.media_id).toBeUndefined()
|
||||
expect(payload.media_source).toBeUndefined()
|
||||
expect(payload.media_in).toBeUndefined()
|
||||
})
|
||||
|
||||
it('switches the source to MusicBrainz and reveals the entity selector when the music type is picked', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { Context } from '@/api/types'
|
||||
import { requiresMusicConfirmation } from '@/utils/music'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps<{ context?: Context }>()
|
||||
const { t, te } = useI18n()
|
||||
const reason = computed(() => {
|
||||
const key = `torrent.musicMatch.${props.context?.match_reason}`
|
||||
return te(key) ? t(key) : t('torrent.musicMatch.candidate')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VChip
|
||||
v-if="requiresMusicConfirmation(props.context)"
|
||||
color="warning"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
class="rounded-sm flex-shrink-0"
|
||||
data-testid="music-match-status"
|
||||
:prepend-icon="props.context?.match_reason === 'related_album' ? 'mdi-album' : 'mdi-help-circle-outline'"
|
||||
:aria-label="reason"
|
||||
>
|
||||
{{ t(`torrent.musicMatch.${props.context?.match_reason === 'related_album' ? 'album' : 'candidate'}`) }}
|
||||
<VTooltip activator="parent" location="top">{{ reason }}</VTooltip>
|
||||
</VChip>
|
||||
</template>
|
||||
@@ -48,6 +48,24 @@ function createTorrent(overrides: TorrentOverrides = {}): Context {
|
||||
}
|
||||
|
||||
describe('useTorrentFilter', () => {
|
||||
it('does not merge different music tracks merely because they share an album', () => {
|
||||
const filter = useTorrentFilter()
|
||||
const first = createTorrent({ name: '同一专辑', title: '第一首' })
|
||||
const second = createTorrent({ name: '同一专辑', title: '第二首' })
|
||||
first.meta_info.type = second.meta_info.type = '音乐'
|
||||
first.meta_info.title = '第一首'
|
||||
second.meta_info.title = '第二首'
|
||||
expect(filter.filterCardData([first, second])).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps unconfirmed sources individually visible', () => {
|
||||
const filter = useTorrentFilter()
|
||||
const first = createTorrent({ title: '同名资源' })
|
||||
const second = createTorrent({ title: '同名资源', site: 'Site B' })
|
||||
first.match_status = second.match_status = 'candidate'
|
||||
expect(filter.filterCardData([first, second])).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('builds unique options and naturally orders whole seasons before episodes', () => {
|
||||
const filter = useTorrentFilter()
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Context } from '@/api/types'
|
||||
import { getTorrentTitle, isMusicResource, isUnconfirmedResource } from '@/utils/torrent'
|
||||
import { cloneDeepWith } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -253,7 +254,12 @@ export function useTorrentFilter() {
|
||||
// init options
|
||||
initOptions(item)
|
||||
// group data
|
||||
const key = `${meta_info.name}_${meta_info.resource_pix}_${meta_info.edition}_${meta_info.resource_team}_${meta_info.season_episode}_${torrent_info.size}`
|
||||
const resourceName = isMusicResource(item) ? getTorrentTitle(item) : meta_info.name
|
||||
const musicQuality = isMusicResource(item)
|
||||
? `${meta_info.audio_format}_${meta_info.bit_depth}_${meta_info.sample_rate}_${meta_info.bitrate}`
|
||||
: ''
|
||||
const pendingKey = isUnconfirmedResource(item) ? index : ''
|
||||
const key = `${pendingKey}_${item.match_status || ''}_${resourceName}_${musicQuality}_${meta_info.resource_pix}_${meta_info.edition}_${meta_info.resource_team}_${meta_info.season_episode}_${torrent_info.size}`
|
||||
const groupedItem = { data: item, originalIndex: index }
|
||||
if (groupMap.has(key)) {
|
||||
const group = groupMap.get(key)
|
||||
|
||||
@@ -1587,6 +1587,20 @@ export default {
|
||||
backButton: 'Go Back',
|
||||
},
|
||||
torrent: {
|
||||
musicMatch: {
|
||||
all: 'All resources',
|
||||
exact: 'Exact matches',
|
||||
candidate: 'Unconfirmed',
|
||||
album: 'Related album',
|
||||
related_album: 'Related album; target track not verified',
|
||||
target_artist_missing: 'Target artist is missing',
|
||||
artist_unverified: 'Artist credit is unverified',
|
||||
category_unknown: 'Music category is unverified',
|
||||
version_mismatch: 'Different recording version',
|
||||
edition_unverified: 'Album edition is unverified',
|
||||
year_mismatch: 'Different release year',
|
||||
partial_album: 'Track resource; complete album not verified',
|
||||
},
|
||||
selectAll: 'Select All',
|
||||
clear: 'Clear',
|
||||
clearFilters: 'Clear Filters',
|
||||
|
||||
@@ -1566,6 +1566,20 @@ export default {
|
||||
backButton: '返回',
|
||||
},
|
||||
torrent: {
|
||||
musicMatch: {
|
||||
all: '全部资源',
|
||||
exact: '精确匹配',
|
||||
candidate: '待确认',
|
||||
album: '关联专辑',
|
||||
related_album: '所属专辑,尚未确认包含目标单曲',
|
||||
target_artist_missing: '目标缺少艺术家信息',
|
||||
artist_unverified: '艺术家署名尚未核验',
|
||||
category_unknown: '站点音乐分类尚未确认',
|
||||
version_mismatch: '录音版本与目标不同',
|
||||
edition_unverified: '专辑发行版本尚未确认',
|
||||
year_mismatch: '发行年份与目标不同',
|
||||
partial_album: '单曲资源,尚未确认完整专辑',
|
||||
},
|
||||
selectAll: '全选',
|
||||
clear: '清除',
|
||||
clearFilters: '清除筛选',
|
||||
|
||||
@@ -1538,6 +1538,20 @@ export default {
|
||||
backButton: '返回',
|
||||
},
|
||||
torrent: {
|
||||
musicMatch: {
|
||||
all: '全部資源',
|
||||
exact: '精確匹配',
|
||||
candidate: '待確認',
|
||||
album: '關聯專輯',
|
||||
related_album: '所屬專輯,尚未確認包含目標單曲',
|
||||
target_artist_missing: '目標缺少藝術家資訊',
|
||||
artist_unverified: '藝術家署名尚未核驗',
|
||||
category_unknown: '站點音樂分類尚未確認',
|
||||
version_mismatch: '錄音版本與目標不同',
|
||||
edition_unverified: '專輯發行版本尚未確認',
|
||||
year_mismatch: '發行年份與目標不同',
|
||||
partial_album: '單曲資源,尚未確認完整專輯',
|
||||
},
|
||||
selectAll: '全選',
|
||||
clear: '清除',
|
||||
clearFilters: '清除篩選',
|
||||
|
||||
@@ -102,7 +102,7 @@ interface TorrentOverrides {
|
||||
|
||||
interface SearchRouteCase {
|
||||
apiEndpoint: string
|
||||
apiParams: Record<string, string>
|
||||
apiParams: Record<string, string | boolean>
|
||||
displayTitle: string
|
||||
expectedPath: string
|
||||
query: Record<string, string>
|
||||
@@ -350,6 +350,7 @@ const searchRouteCases: SearchRouteCase[] = [
|
||||
{
|
||||
apiEndpoint: `search/media/${musicBrainzAlbumId}`,
|
||||
apiParams: {
|
||||
include_candidates: true,
|
||||
area: 'title',
|
||||
media_source: 'musicbrainz',
|
||||
mtype: '音乐',
|
||||
@@ -371,6 +372,7 @@ const searchRouteCases: SearchRouteCase[] = [
|
||||
},
|
||||
result: createTorrent({ title: '专辑资源结果' }),
|
||||
streamParams: {
|
||||
include_candidates: 'true',
|
||||
area: 'title',
|
||||
media_source: 'musicbrainz',
|
||||
mtype: '音乐',
|
||||
@@ -662,6 +664,31 @@ describe('resource page search flow', () => {
|
||||
expect(screen.queryByRole('status', { name: '当前筛选条件没有匹配项' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('separates confirmed music results from candidates without losing either view', async () => {
|
||||
await renderResource({
|
||||
path: '/resource',
|
||||
query: { keyword: '音乐确认', result_type: 'torrent', type: '音乐' },
|
||||
})
|
||||
const exact = createTorrent({ title: '精确资源' })
|
||||
exact.meta_info.type = '音乐'
|
||||
exact.match_status = 'exact'
|
||||
const pending = createTorrent({ title: '待确认资源' })
|
||||
pending.meta_info.type = '音乐'
|
||||
pending.match_status = 'candidate'
|
||||
pending.match_reason = 'artist_unverified'
|
||||
finishStream(await latestEventSource(), [exact, pending])
|
||||
expect(await screen.findByText('精确资源')).toBeInTheDocument()
|
||||
expect(screen.getByText('待确认资源')).toBeInTheDocument()
|
||||
await fireEvent.click(screen.getByRole('tab', { name: /^待确认$/ }))
|
||||
await waitFor(() => expect(screen.queryByText('精确资源')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('待确认资源')).toBeInTheDocument()
|
||||
await fireEvent.click(screen.getByRole('tab', { name: /^精确匹配$/ }))
|
||||
await waitFor(() => expect(screen.queryByText('待确认资源')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('精确资源')).toBeInTheDocument()
|
||||
await fireEvent.click(screen.getByRole('tab', { name: /^全部资源$/ }))
|
||||
expect(await screen.findByText('待确认资源')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
item: createTorrent({ title: '流式资源预览' }),
|
||||
|
||||
+48
-10
@@ -21,6 +21,7 @@ import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
import { SearchReplaceBatchCollector, isSearchReplaceBatchEvent } from '@/utils/searchStream'
|
||||
import { getCurrentLocale } from '@/plugins/i18n'
|
||||
import { isMediaDataSource, isValidMediaSourceId } from '@/utils/mediaId'
|
||||
import { requiresMusicConfirmation } from '@/utils/music'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -332,6 +333,25 @@ interface SearchTorrent extends Context {
|
||||
more?: Array<Context>
|
||||
}
|
||||
const filteredCardDataList = ref<Array<SearchTorrent>>([])
|
||||
const musicResultMode = ref<'all' | 'exact' | 'candidate'>('all')
|
||||
const hasMusicCandidates = computed(() => rawDataList.value.some(requiresMusicConfirmation))
|
||||
watch(hasMusicCandidates, available => {
|
||||
if (!available) musicResultMode.value = 'all'
|
||||
})
|
||||
|
||||
/** 在常规筛选之后切换音乐匹配等级,不改变传给推荐接口的原始资源索引。 */
|
||||
function matchesMusicMode(context: Context) {
|
||||
return (
|
||||
musicResultMode.value === 'all' ||
|
||||
(musicResultMode.value === 'candidate' ? requiresMusicConfirmation(context) : !requiresMusicConfirmation(context))
|
||||
)
|
||||
}
|
||||
|
||||
const visibleRowDataList = computed(() => filteredRowDataList.value.filter(matchesMusicMode))
|
||||
const visibleCardDataList = computed(() => filteredCardDataList.value.filter(matchesMusicMode))
|
||||
const hasVisibleMusicResults = computed(() =>
|
||||
viewType.value === 'row' ? visibleRowDataList.value.length > 0 : visibleCardDataList.value.length > 0,
|
||||
)
|
||||
|
||||
// 是否刷新过
|
||||
const isRefreshed = ref(false)
|
||||
@@ -415,7 +435,11 @@ const displayResourceCount = computed(() =>
|
||||
? streamTotalCount.value
|
||||
: isSubtitleSearch.value
|
||||
? rawSubtitleDataList.value.length
|
||||
: torrentFilter.totalFilteredCount.value,
|
||||
: musicResultMode.value === 'all'
|
||||
? torrentFilter.totalFilteredCount.value
|
||||
: viewType.value === 'row'
|
||||
? visibleRowDataList.value.length
|
||||
: visibleCardDataList.value.reduce((count, item) => count + 1 + (item.more?.length || 0), 0),
|
||||
)
|
||||
|
||||
// 搜索中只显示进度区域,避免结果抬头和进度条同时占用顶部空间。
|
||||
@@ -655,6 +679,7 @@ function buildSearchStreamUrl(params: SearchParams, requestToken?: string) {
|
||||
setSearchParam(url.searchParams, 'season', params.season)
|
||||
setSearchParam(url.searchParams, 'sites', params.sites)
|
||||
setSearchParam(url.searchParams, 'music_type', params.music_type)
|
||||
if (params.type === '音乐') setSearchParam(url.searchParams, 'include_candidates', 'true')
|
||||
} else {
|
||||
setSearchParam(url.searchParams, 'keyword', params.keyword)
|
||||
setSearchParam(url.searchParams, 'mtype', params.type)
|
||||
@@ -676,6 +701,7 @@ function resetSearchResults() {
|
||||
isRefreshed.value = false
|
||||
errorDescription.value = t('resource.noResourceFound')
|
||||
streamCandidateCount = 0
|
||||
musicResultMode.value = 'all'
|
||||
rawDataList.value = []
|
||||
rawSubtitleDataList.value = []
|
||||
originalDataList.value = []
|
||||
@@ -927,6 +953,7 @@ async function requestSearchResults(params: SearchParams, requestToken?: string)
|
||||
...(params.season ? { season: params.season } : {}),
|
||||
...(params.sites ? { sites: params.sites } : {}),
|
||||
...(params.music_type ? { music_type: params.music_type } : {}),
|
||||
...(params.type === '音乐' ? { include_candidates: true } : {}),
|
||||
_ts: requestToken,
|
||||
},
|
||||
})
|
||||
@@ -1619,6 +1646,11 @@ onUnmounted(() => {
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<div v-if="isRefreshed && hasData" class="search-results-container">
|
||||
<VTabs v-if="hasMusicCandidates && !progressActive" v-model="musicResultMode" class="mb-3" density="compact">
|
||||
<VTab value="all">{{ t('torrent.musicMatch.all') }}</VTab>
|
||||
<VTab value="exact">{{ t('torrent.musicMatch.exact') }}</VTab>
|
||||
<VTab value="candidate">{{ t('torrent.musicMatch.candidate') }}</VTab>
|
||||
</VTabs>
|
||||
<!-- 筛选栏 -->
|
||||
<TorrentFilterBar
|
||||
v-if="!progressActive && !isSubtitleSearch"
|
||||
@@ -1640,7 +1672,7 @@ onUnmounted(() => {
|
||||
/>
|
||||
|
||||
<!-- 视图切换区域 -->
|
||||
<VFadeTransition mode="out-in">
|
||||
<VFadeTransition v-if="!hasMusicCandidates || hasVisibleMusicResults || progressActive" mode="out-in">
|
||||
<div :key="viewType">
|
||||
<!-- 卡片视图模式 -->
|
||||
<div v-if="viewType === 'card'">
|
||||
@@ -1684,14 +1716,14 @@ onUnmounted(() => {
|
||||
/>
|
||||
</div>
|
||||
<ProgressiveCardGrid
|
||||
v-else-if="filteredCardDataList.length > 0"
|
||||
:items="filteredCardDataList"
|
||||
v-else-if="visibleCardDataList.length > 0"
|
||||
:items="visibleCardDataList"
|
||||
:get-item-key="getTorrentItemKey"
|
||||
:min-item-width="300"
|
||||
:estimated-item-height="400"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<TorrentCard :torrent="item" :more="item.more" />
|
||||
<TorrentCard :torrent="item" :more="item.more" :target-music-type="activeSearchParams.music_type" />
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</div>
|
||||
@@ -1744,13 +1776,13 @@ onUnmounted(() => {
|
||||
:key="getTorrentItemKey(item, index)"
|
||||
class="stream-result-item"
|
||||
>
|
||||
<TorrentItem :torrent="item" />
|
||||
<TorrentItem :torrent="item" :target-music-type="activeSearchParams.music_type" />
|
||||
<VDivider v-if="index < streamPreviewDataList.length - 1" class="my-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!isSubtitleSearch && filteredRowDataList.length > 0" class="resource-list">
|
||||
<div v-else-if="!isSubtitleSearch && visibleRowDataList.length > 0" class="resource-list">
|
||||
<ProgressiveCardGrid
|
||||
:items="filteredRowDataList"
|
||||
:items="visibleRowDataList"
|
||||
:columns="1"
|
||||
:gap="8"
|
||||
:estimated-item-height="240"
|
||||
@@ -1758,8 +1790,8 @@ onUnmounted(() => {
|
||||
:get-item-key="getTorrentItemKey"
|
||||
>
|
||||
<template #default="{ item, index }">
|
||||
<TorrentItem :torrent="item" />
|
||||
<VDivider v-if="index < filteredRowDataList.length - 1" class="my-2" />
|
||||
<TorrentItem :torrent="item" :target-music-type="activeSearchParams.music_type" />
|
||||
<VDivider v-if="index < visibleRowDataList.length - 1" class="my-2" />
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</div>
|
||||
@@ -1767,6 +1799,12 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</VFadeTransition>
|
||||
<ResourceSearchEmptyState
|
||||
v-else
|
||||
:title="t('torrent.noResults')"
|
||||
description=""
|
||||
icon="mdi-music-note-search-outline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 无数据显示 -->
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Context } from '@/api/types'
|
||||
import { buildTorrentDownloadProps, getTorrentTitle } from '@/utils/torrent'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/** 构造携带旧目标信息的未确认音乐缓存,验证下载边界不会采信它。 */
|
||||
function candidate(): Context {
|
||||
return {
|
||||
match_status: 'candidate',
|
||||
match_reason: 'artist_unverified',
|
||||
meta_info: { type: '音乐', title: '晴天', name: '叶惠美', album: '叶惠美' },
|
||||
media_info: {
|
||||
type: '音乐',
|
||||
title: '旧目标',
|
||||
title_year: '旧目标 (2003)',
|
||||
media_id: 'target',
|
||||
media_source: 'musicbrainz',
|
||||
},
|
||||
torrent_info: {
|
||||
title: 'Jay Chou - 晴天 FLAC',
|
||||
category: '音乐',
|
||||
media_id: 'source-hint',
|
||||
media_source: 'musicbrainz',
|
||||
},
|
||||
} as Context
|
||||
}
|
||||
|
||||
describe('torrent presentation and manual confirmation', () => {
|
||||
it('keeps resource title and removes unverified identities without changing the cache', () => {
|
||||
const context = candidate()
|
||||
expect(getTorrentTitle(context)).toBe('晴天')
|
||||
const props = buildTorrentDownloadProps(context, 'album')
|
||||
expect(props.title).toBe('晴天')
|
||||
expect(props.media).toBeUndefined()
|
||||
expect(props.torrent?.media_id).toBeUndefined()
|
||||
expect(props.torrent?.media_source).toBeUndefined()
|
||||
expect(props.musicType).toBe('album')
|
||||
expect(context.torrent_info.media_id).toBe('source-hint')
|
||||
expect(context.media_info.media_id).toBe('target')
|
||||
})
|
||||
|
||||
it('keeps related albums distinct from partial album tracks', () => {
|
||||
const context = candidate()
|
||||
context.match_reason = 'related_album'
|
||||
expect(buildTorrentDownloadProps(context, 'recording').musicType).toBe('album')
|
||||
context.match_reason = 'partial_album'
|
||||
expect(buildTorrentDownloadProps(context, 'album').musicType).toBe('recording')
|
||||
})
|
||||
|
||||
it('preserves the selected identity for confirmed resources', () => {
|
||||
const context = candidate()
|
||||
context.match_status = 'exact'
|
||||
const props = buildTorrentDownloadProps(context)
|
||||
expect(props.title).toBe('旧目标 (2003)')
|
||||
expect(props.media).toBe(context.media_info)
|
||||
expect(props.torrent).toBe(context.torrent_info)
|
||||
})
|
||||
})
|
||||
+7
-1
@@ -1,5 +1,6 @@
|
||||
import type { MediaDataSource, MediaInfo, MusicAlbumInfo, MusicArtistInfo, MusicEntityType } from '@/api/types'
|
||||
import type { Context, MediaDataSource, MediaInfo, MusicAlbumInfo, MusicArtistInfo, MusicEntityType } from '@/api/types'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import { isMusicResource, isUnconfirmedResource } from '@/utils/torrent'
|
||||
|
||||
export interface MusicRouteTarget {
|
||||
media_source?: MediaDataSource
|
||||
@@ -10,6 +11,11 @@ export interface MusicRouteTarget {
|
||||
year?: string | number
|
||||
}
|
||||
|
||||
/** 未确认的音乐资源不得将搜索目标当成候选自身的媒体身份。 */
|
||||
export function requiresMusicConfirmation(context?: Context): boolean {
|
||||
return isMusicResource(context) && isUnconfirmedResource(context)
|
||||
}
|
||||
|
||||
// 音乐体系中一位可跳转的艺术家
|
||||
export interface MusicArtistLink {
|
||||
name: string
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Context, MusicEntityType } from '@/api/types'
|
||||
|
||||
/** 判断资源是否仍需人工确认,不依赖其是否带有媒体或站点身份字段。 */
|
||||
export function isUnconfirmedResource(context?: Context): boolean {
|
||||
return context?.match_status === 'candidate'
|
||||
}
|
||||
|
||||
/** 从资源与媒体结构判断音乐类型,兼容旧缓存的字段缺省。 */
|
||||
export function isMusicResource(context?: Context): boolean {
|
||||
return [
|
||||
context?.meta_info?.type,
|
||||
isUnconfirmedResource(context) ? undefined : context?.media_info?.type,
|
||||
context?.torrent_info?.category,
|
||||
].some(type => ['音乐', 'music'].includes(type?.toLowerCase() || ''))
|
||||
}
|
||||
|
||||
/** 未确认项显示资源实际曲名,不能借目标标题或所属专辑名冒充资源身份。 */
|
||||
export function getTorrentTitle(context?: Context, withYear = false): string {
|
||||
const parsed = isMusicResource(context)
|
||||
? context?.meta_info?.title || context?.meta_info?.name
|
||||
: context?.meta_info?.name
|
||||
const target = isUnconfirmedResource(context)
|
||||
? undefined
|
||||
: (withYear ? context?.media_info?.title_year : undefined) || context?.media_info?.title
|
||||
return target || parsed || context?.torrent_info?.title || ''
|
||||
}
|
||||
|
||||
/** 为统一下载对话框构造参数,待确认项不预填媒体 ID,实体类型只作为人工选择提示。 */
|
||||
export function buildTorrentDownloadProps(context?: Context, targetMusicType?: string) {
|
||||
const unconfirmed = isUnconfirmedResource(context)
|
||||
const torrent =
|
||||
unconfirmed && context?.torrent_info
|
||||
? { ...context.torrent_info, media_source: undefined, media_id: undefined }
|
||||
: context?.torrent_info
|
||||
const entity =
|
||||
context?.match_reason === 'related_album'
|
||||
? 'album'
|
||||
: context?.match_reason === 'partial_album'
|
||||
? 'recording'
|
||||
: (unconfirmed ? undefined : context?.media_info?.music_type) || targetMusicType
|
||||
const musicType: Exclude<MusicEntityType, 'artist'> = entity === 'album' ? 'album' : 'recording'
|
||||
return {
|
||||
title: [getTorrentTitle(context, true), context?.meta_info?.season_episode].filter(Boolean).join(' '),
|
||||
media: unconfirmed ? undefined : context?.media_info || undefined,
|
||||
torrent,
|
||||
...(isMusicResource(context) ? { musicType } : {}),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user