fix: 完善音乐候选展示与手动下载确认

This commit is contained in:
jxxghp
2026-09-06 00:48:00 +08:00
parent e6d11030a9
commit bb6cdb2ece
18 changed files with 396 additions and 50 deletions
+2
View File
@@ -1604,6 +1604,8 @@ export interface Context {
media_info: MediaInfo
// 种子信息
torrent_info: TorrentInfo
match_status?: 'exact' | 'candidate' | null
match_reason?: string | null
}
// 用户信息
+14 -17
View File
@@ -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;
+34 -12
View File
@@ -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 } })
+17 -8
View File
@@ -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()
+28
View File
@@ -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()
+7 -1
View File
@@ -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)
+14
View File
@@ -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',
+14
View File
@@ -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: '清除筛选',
+14
View File
@@ -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: '清除篩選',
+28 -1
View File
@@ -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
View File
@@ -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>
<!-- 无数据显示 -->
+57
View File
@@ -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
View File
@@ -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
+48
View File
@@ -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 } : {}),
}
}