mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-12 17:14:37 +08:00
feat: 音乐搜索与识别前端联动统一
- 音乐搜索从 music/search 切换为 media/search?type=music - MediaInfoCard 支持音乐卡片(方形封面、时长、音乐实体类型、详情外链) - 订阅与资源选择器适配音乐元数据 - 同步中英繁语言包
This commit is contained in:
@@ -336,8 +336,8 @@ export interface MediaInfo {
|
||||
type?: string
|
||||
// 媒体标题
|
||||
title?: string
|
||||
// 年份
|
||||
year?: string
|
||||
// 年份(音乐等数据源返回数字,订阅接口要求字符串)
|
||||
year?: string | number
|
||||
// 标题(年)
|
||||
title_year?: string
|
||||
// 季号
|
||||
|
||||
@@ -11,6 +11,11 @@ const props = defineProps({
|
||||
// 音乐元数据使用 title,影视元数据使用 name,识别结果卡片统一兼容两种字段。
|
||||
const recognizedName = computed(() => props.context?.meta_info?.name || props.context?.meta_info?.title)
|
||||
|
||||
// 是否为音乐识别结果
|
||||
const isMusic = computed(
|
||||
() => props.context?.media_info?.type === '音乐' || props.context?.meta_info?.type === '音乐',
|
||||
)
|
||||
|
||||
// TMDB图片转换为w500大小
|
||||
function getW500Image(url = '') {
|
||||
if (!url) return ''
|
||||
@@ -24,6 +29,27 @@ function openTmdbPage(type: string, tmdbId: number) {
|
||||
const url = `https://www.themoviedb.org/${type === '电影' ? 'movie' : 'tv'}/${tmdbId}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
// 秒数格式化为 m:ss 时长文本
|
||||
function formatDuration(seconds: number | undefined) {
|
||||
if (!seconds || seconds <= 0) return ''
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const rest = seconds % 60
|
||||
return `${minutes}:${rest.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 音乐封面优先使用方形封面,仅 W500 图片处理对TMDB类 URL 生效
|
||||
const musicCover = computed(() => getW500Image(
|
||||
props.context?.media_info?.cover_url || props.context?.media_info?.poster_path || '',
|
||||
))
|
||||
|
||||
// 音乐详情外链
|
||||
const musicLink = computed(() => props.context?.media_info?.detail_link || '')
|
||||
|
||||
// 打开音乐详情外链
|
||||
function openMusicDetail() {
|
||||
if (musicLink.value) window.open(musicLink.value, '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -33,7 +59,22 @@ function openTmdbPage(type: string, tmdbId: number) {
|
||||
v-if="recognizedName"
|
||||
class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row"
|
||||
>
|
||||
<div v-if="context?.media_info?.poster_path" class="ma-auto">
|
||||
<div v-if="isMusic && musicCover" class="ma-auto">
|
||||
<VImg
|
||||
width="10rem"
|
||||
aspect-ratio="1"
|
||||
class="object-cover rounded-lg ring-1 ring-gray-500"
|
||||
:src="musicCover"
|
||||
cover
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div v-else-if="context?.media_info?.poster_path" class="ma-auto">
|
||||
<VImg
|
||||
width="10rem"
|
||||
aspect-ratio="2/3"
|
||||
@@ -77,46 +118,133 @@ function openTmdbPage(type: string, tmdbId: number) {
|
||||
>
|
||||
{{ context?.media_info?.type || context?.meta_info?.type }}
|
||||
</VChip>
|
||||
<!-- 音乐实体类型 -->
|
||||
<VChip
|
||||
v-if="isMusic && context?.media_info?.music_type"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-blue-500"
|
||||
>
|
||||
{{ context?.media_info?.music_type }}
|
||||
</VChip>
|
||||
<!-- 艺术家 -->
|
||||
<VChip v-if="context?.media_info?.artist" variant="elevated" class="me-1 mb-1 text-white bg-purple-500">
|
||||
{{ context?.media_info?.artist }}
|
||||
</VChip>
|
||||
<!-- 专辑 -->
|
||||
<VChip v-if="context?.media_info?.album" variant="elevated" class="me-1 mb-1 text-white bg-purple-500">
|
||||
{{ context?.media_info?.album }}
|
||||
</VChip>
|
||||
<!-- 专辑艺术家 -->
|
||||
<VChip
|
||||
v-if="context?.media_info?.album_artist"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-purple-500"
|
||||
>
|
||||
{{ context?.media_info?.album_artist }}
|
||||
</VChip>
|
||||
<!-- 发行日期 -->
|
||||
<VChip
|
||||
v-if="context?.media_info?.release_date"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-purple-500"
|
||||
>
|
||||
{{ context?.media_info?.release_date }}
|
||||
</VChip>
|
||||
<!-- 风格 -->
|
||||
<VChip
|
||||
v-for="genre in context?.media_info?.genres"
|
||||
:key="genre"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-purple-500"
|
||||
>
|
||||
{{ genre }}
|
||||
</VChip>
|
||||
<!-- 音频技术参数 -->
|
||||
<VChip
|
||||
v-if="context?.meta_info?.audio_format"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-orange-500"
|
||||
>
|
||||
{{ context?.meta_info?.audio_format }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="context?.meta_info?.bit_depth || context?.meta_info?.sample_rate"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-orange-500"
|
||||
>
|
||||
{{ [context?.meta_info?.bit_depth, context?.meta_info?.sample_rate].filter(Boolean).join(' kHz ') }}
|
||||
</VChip>
|
||||
<!-- 时长 -->
|
||||
<VChip
|
||||
v-if="formatDuration(context?.media_info?.duration)"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-orange-500"
|
||||
>
|
||||
{{ formatDuration(context?.media_info?.duration) }}
|
||||
</VChip>
|
||||
<!-- 曲目信息 -->
|
||||
<VChip
|
||||
v-if="context?.media_info?.track_number"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-red-500"
|
||||
>
|
||||
{{ `曲目 ${context?.media_info?.track_number}${context?.media_info?.total_tracks ? ` / ${context?.media_info?.total_tracks}` : ''}` }}
|
||||
</VChip>
|
||||
<!-- ISRC -->
|
||||
<VChip v-if="context?.media_info?.isrc" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ context?.media_info?.isrc }}
|
||||
</VChip>
|
||||
<!-- MusicBrainz 外链 -->
|
||||
<VChip
|
||||
v-if="musicLink"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-green-500"
|
||||
@click="openMusicDetail"
|
||||
>
|
||||
详情
|
||||
</VChip>
|
||||
<!-- 二级分类 -->
|
||||
<VChip v-if="context?.media_info?.category" variant="elevated" class="me-1 mb-1 text-white bg-blue-500">
|
||||
<VChip v-if="!isMusic && context?.media_info?.category" variant="elevated" class="me-1 mb-1 text-white bg-blue-500">
|
||||
{{ context?.media_info?.category }}
|
||||
</VChip>
|
||||
<!-- TMDBID -->
|
||||
<VChip
|
||||
v-if="context?.media_info?.tmdb_id"
|
||||
v-if="!isMusic && context?.media_info?.tmdb_id"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-green-500"
|
||||
@click="openTmdbPage(context?.media_info?.type || '', context?.media_info?.tmdb_id)"
|
||||
>
|
||||
{{ context?.media_info?.tmdb_id }}
|
||||
</VChip>
|
||||
<!-- meta_info -->
|
||||
<VChip v-if="context?.meta_info?.web_source" variant="elevated" class="me-1 mb-1 text-white bg-purple-500">
|
||||
{{ context?.meta_info?.web_source }}
|
||||
</VChip>
|
||||
<VChip v-if="context?.meta_info?.edition" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ context?.meta_info?.edition }}
|
||||
</VChip>
|
||||
<VChip v-if="context?.meta_info?.resource_pix" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ context?.meta_info?.resource_pix }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="context?.meta_info?.video_encode"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-orange-500"
|
||||
>
|
||||
{{ context?.meta_info?.video_encode }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="context?.meta_info?.audio_encode"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-orange-500"
|
||||
>
|
||||
{{ context?.meta_info?.audio_encode }}
|
||||
</VChip>
|
||||
<VChip v-if="context?.meta_info?.resource_team" variant="elevated" class="me-1 mb-1 text-white bg-cyan-500">
|
||||
{{ context?.meta_info?.resource_team }}
|
||||
</VChip>
|
||||
<!-- meta_info(音乐不显示影视资源信息) -->
|
||||
<template v-if="!isMusic">
|
||||
<VChip v-if="context?.meta_info?.web_source" variant="elevated" class="me-1 mb-1 text-white bg-purple-500">
|
||||
{{ context?.meta_info?.web_source }}
|
||||
</VChip>
|
||||
<VChip v-if="context?.meta_info?.edition" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ context?.meta_info?.edition }}
|
||||
</VChip>
|
||||
<VChip v-if="context?.meta_info?.resource_pix" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ context?.meta_info?.resource_pix }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="context?.meta_info?.video_encode"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-orange-500"
|
||||
>
|
||||
{{ context?.meta_info?.video_encode }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="context?.meta_info?.audio_encode"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-orange-500"
|
||||
>
|
||||
{{ context?.meta_info?.audio_encode }}
|
||||
</VChip>
|
||||
<VChip v-if="context?.meta_info?.resource_team" variant="elevated" class="me-1 mb-1 text-white bg-cyan-500">
|
||||
{{ context?.meta_info?.resource_team }}
|
||||
</VChip>
|
||||
</template>
|
||||
</VCardItem>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,4 +271,4 @@ function openTmdbPage(type: string, tmdbId: number) {
|
||||
</VExpansionPanel>
|
||||
</VExpansionPanels>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -27,9 +27,6 @@ const keyword = ref<string>()
|
||||
// 选择分类
|
||||
const selectCategory = ref<number[]>([])
|
||||
|
||||
// 选择主媒体类型,用于按站点定义的电影、电视剧、音乐分类浏览。
|
||||
const selectMediaType = ref<string>()
|
||||
|
||||
// 全部分类
|
||||
const siteCategoryList = ref<SiteCategory[]>()
|
||||
|
||||
@@ -69,11 +66,8 @@ const categoryOptions = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const mediaTypeOptions = computed(() => [
|
||||
{ title: t('mediaType.movie'), value: '电影' },
|
||||
{ title: t('mediaType.tv'), value: '电视剧' },
|
||||
{ title: t('mediaType.music'), value: '音乐' },
|
||||
])
|
||||
// 站点是否配置了资源分类
|
||||
const hasSiteCategory = computed(() => (siteCategoryList.value?.length ?? 0) > 0)
|
||||
|
||||
// 总条数
|
||||
const resourceTotalItems = computed(() => resourceDataList.value.length)
|
||||
@@ -169,7 +163,6 @@ async function getResourceList() {
|
||||
params: {
|
||||
keyword: keyword.value,
|
||||
cat: selectCategory.value?.join(','),
|
||||
mtype: selectMediaType.value,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -288,20 +281,9 @@ onMounted(() => {
|
||||
clearable
|
||||
prepend-inner-icon="mdi-folder"
|
||||
hide-details
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="2">
|
||||
<VSelect
|
||||
v-model="selectMediaType"
|
||||
:items="mediaTypeOptions"
|
||||
class="site-resource-filter-input"
|
||||
density="compact"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
clearable
|
||||
:label="t('common.type')"
|
||||
prepend-inner-icon="mdi-shape-outline"
|
||||
hide-details
|
||||
:disabled="!hasSiteCategory"
|
||||
:hint="hasSiteCategory ? '' : t('dialog.siteResource.noCategory')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="3" class="d-flex align-center">
|
||||
@@ -369,20 +351,6 @@ onMounted(() => {
|
||||
@keyup.enter="getResourceList"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
v-model="selectMediaType"
|
||||
:items="mediaTypeOptions"
|
||||
class="site-resource-filter-input"
|
||||
density="compact"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
clearable
|
||||
:label="t('common.type')"
|
||||
prepend-inner-icon="mdi-shape-outline"
|
||||
hide-details
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
v-model="selectCategory"
|
||||
@@ -398,6 +366,9 @@ onMounted(() => {
|
||||
clearable
|
||||
prepend-inner-icon="mdi-folder"
|
||||
hide-details
|
||||
:disabled="!hasSiteCategory"
|
||||
:hint="hasSiteCategory ? '' : t('dialog.siteResource.noCategory')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" class="d-flex gap-2">
|
||||
|
||||
@@ -58,19 +58,15 @@ async function searchMedias() {
|
||||
// 调用API搜索词条
|
||||
try {
|
||||
loading.value = true
|
||||
const result: MediaInfo[] =
|
||||
props.type === 'musicbrainz'
|
||||
? await api.get('music/search', {
|
||||
params: { query: searchKeyword, count: 20 },
|
||||
})
|
||||
: await api.get('media/search', {
|
||||
params: {
|
||||
title: searchKeyword,
|
||||
page: 1,
|
||||
count: 20,
|
||||
source: props.type,
|
||||
},
|
||||
})
|
||||
const result: MediaInfo[] = await api.get('media/search', {
|
||||
params: {
|
||||
title: searchKeyword,
|
||||
type: props.type === 'musicbrainz' ? 'music' : 'media',
|
||||
page: 1,
|
||||
count: 20,
|
||||
source: props.type,
|
||||
},
|
||||
})
|
||||
|
||||
// 清空
|
||||
items.value = []
|
||||
|
||||
@@ -317,7 +317,8 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
const result: { [key: string]: any } = await api.post('subscribe/', {
|
||||
name: media.title,
|
||||
type: media.type,
|
||||
year: media.year,
|
||||
// 后端的订阅模型 year 为字符串,音乐的 year 是数字,需统一转字符串避免 422
|
||||
year: media.year?.toString() ?? '',
|
||||
tmdbid: media.tmdb_id,
|
||||
doubanid: media.douban_id,
|
||||
bangumiid: media.bangumi_id,
|
||||
|
||||
@@ -2273,6 +2273,9 @@ export default {
|
||||
seasonThumb: 'Thumb',
|
||||
episodeNfo: 'NFO',
|
||||
episodeThumb: 'Thumb',
|
||||
music: 'Music',
|
||||
musicNfo: 'Audio Tags',
|
||||
musicPoster: 'Cover Art',
|
||||
scrapingSwitchSaveFailed: 'Scraping switch settings save failed: {message}',
|
||||
scrapingSwitchSaveError: 'Scraping switch settings save failed',
|
||||
policy: {
|
||||
@@ -3482,6 +3485,7 @@ export default {
|
||||
browseTitle: 'Browse - {name}',
|
||||
searchKeyword: 'Search Keyword',
|
||||
resourceCategory: 'Resource Category',
|
||||
noCategory: 'This site has no resource categories configured',
|
||||
search: 'Search',
|
||||
itemsPerPage: 'Items Per Page',
|
||||
noData: 'No Data',
|
||||
|
||||
@@ -2236,6 +2236,9 @@ export default {
|
||||
seasonThumb: '缩略图',
|
||||
episodeNfo: 'NFO',
|
||||
episodeThumb: '缩略图',
|
||||
music: '音乐',
|
||||
musicNfo: '音乐标签',
|
||||
musicPoster: '封面',
|
||||
scrapingSwitchSaveFailed: '刮削开关设置保存失败:{message}',
|
||||
scrapingSwitchSaveError: '刮削开关设置保存失败',
|
||||
policy: {
|
||||
@@ -3424,6 +3427,7 @@ export default {
|
||||
browseTitle: '浏览 - {name}',
|
||||
searchKeyword: '搜索关键字',
|
||||
resourceCategory: '资源分类',
|
||||
noCategory: '该站点未配置资源分类',
|
||||
search: '搜索',
|
||||
itemsPerPage: '每页条数',
|
||||
noData: '没有数据',
|
||||
|
||||
@@ -2235,6 +2235,9 @@ export default {
|
||||
seasonThumb: '縮略圖',
|
||||
episodeNfo: 'NFO',
|
||||
episodeThumb: '縮略圖',
|
||||
music: '音樂',
|
||||
musicNfo: '音樂標籤',
|
||||
musicPoster: '封面',
|
||||
scrapingSwitchSaveFailed: '刮削開關設定保存失敗:{message}',
|
||||
scrapingSwitchSaveError: '刮削開關設定保存失敗',
|
||||
policy: {
|
||||
@@ -3423,6 +3426,7 @@ export default {
|
||||
browseTitle: '瀏覽 - {name}',
|
||||
searchKeyword: '搜索關鍵字',
|
||||
resourceCategory: '資源分類',
|
||||
noCategory: '該站點未配置資源分類',
|
||||
search: '搜索',
|
||||
itemsPerPage: '每頁條數',
|
||||
pageText: '{0}-{1} 共 {2} 條',
|
||||
|
||||
@@ -47,7 +47,7 @@ const musicResult = {
|
||||
/** 按请求路径分派音乐搜索与订阅状态查询。 */
|
||||
function mockSearchAndSubscribeState(subscribed: boolean) {
|
||||
mocks.apiGet.mockImplementation((path: string) => {
|
||||
if (path === 'music/search') return Promise.resolve([musicResult])
|
||||
if (path === 'media/search') return Promise.resolve([musicResult])
|
||||
if (path.startsWith('subscribe/media/')) {
|
||||
return subscribed ? Promise.resolve({ id: 9 }) : Promise.reject({ response: { status: 404 } })
|
||||
}
|
||||
@@ -77,8 +77,8 @@ describe('music page', () => {
|
||||
await renderMusicPage()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('music/search', {
|
||||
params: { count: 30, query: '晴天' },
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('media/search', {
|
||||
params: { type: 'music', count: 30, title: '晴天' },
|
||||
}),
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: '搜索音乐' })).not.toBeInTheDocument()
|
||||
|
||||
@@ -26,7 +26,7 @@ async function searchMusic() {
|
||||
loading.value = true
|
||||
searched.value = true
|
||||
try {
|
||||
results.value = (await api.get('music/search', { params: { query: query.value, count: 30 } })) || []
|
||||
results.value = (await api.get('media/search', { params: { title: query.value, type: 'music', count: 30 } })) || []
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
results.value = []
|
||||
|
||||
@@ -183,6 +183,13 @@ const scrapingConfig = [
|
||||
{ key: 'episode_thumb', label: 'setting.system.episodeThumb' },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: 'music',
|
||||
items: [
|
||||
{ key: 'music_nfo', label: 'setting.system.musicNfo' },
|
||||
{ key: 'music_poster', label: 'setting.system.musicPoster' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// 刮削策略设置
|
||||
@@ -2203,7 +2210,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
<span class="ml-2">{{ t(item.label) }}</span>
|
||||
</div>
|
||||
</VCol>
|
||||
<VDivider v-if="section.section !== 'episode'" class="my-4" />
|
||||
<VDivider v-if="section.section !== 'music'" class="my-4" />
|
||||
</VRow>
|
||||
</VExpansionPanelText>
|
||||
</VExpansionPanel>
|
||||
|
||||
Reference in New Issue
Block a user