mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-14 18:14:40 +08:00
feat: 新增站点音乐资源直搜入口
This commit is contained in:
@@ -68,7 +68,7 @@ const SubscribeItems = ref<Subscribe[]>([])
|
||||
const chooseSiteDialog = ref(false)
|
||||
const selectedSites = ref<number[]>([])
|
||||
const allSites = ref<Site[]>([])
|
||||
const siteSearchType = ref<'torrent' | 'subtitle'>('torrent')
|
||||
const siteSearchType = ref<'torrent' | 'subtitle' | 'music'>('torrent')
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close', 'update:modelValue'])
|
||||
@@ -373,10 +373,7 @@ async function fetchSubscribes() {
|
||||
const loadUserSitePreferences = async () => {
|
||||
try {
|
||||
const result = await api.get<{ value?: number[] }>('system/setting/public/IndexerSites')
|
||||
if (result.value) {
|
||||
selectedSites.value = result.value
|
||||
return
|
||||
}
|
||||
selectedSites.value = result.value ?? []
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
@@ -388,6 +385,8 @@ async function queryAllSites() {
|
||||
const data: Site[] = await api.get('site/')
|
||||
// 过滤站点,只有启用的站点才显示
|
||||
allSites.value = data.filter(item => item.is_active)
|
||||
const availableIds = new Set(allSites.value.map(site => site.id))
|
||||
selectedSites.value = selectedSites.value.filter(siteId => availableIds.has(siteId))
|
||||
// 如果没有选择任何站点并且有可用站点,才默认选择全部
|
||||
if (selectedSites.value.length === 0 && allSites.value.length > 0) {
|
||||
selectedSites.value = allSites.value.map((site: Site) => site.id)
|
||||
@@ -397,12 +396,44 @@ async function queryAllSites() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询支持音乐分类的启用站点,并将默认选择收敛到可用范围。 */
|
||||
async function queryMusicSites() {
|
||||
try {
|
||||
const data: Site[] = await api.get('site/media/music')
|
||||
allSites.value = data.filter(item => item.is_active)
|
||||
const availableIds = new Set(allSites.value.map(site => site.id))
|
||||
selectedSites.value = selectedSites.value.filter(siteId => availableIds.has(siteId))
|
||||
if (selectedSites.value.length === 0) {
|
||||
selectedSites.value = allSites.value.map(site => site.id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
allSites.value = []
|
||||
selectedSites.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开指定资源类型的站点选择对话框。 */
|
||||
const openSiteDialog = (type: 'torrent' | 'subtitle' = 'torrent') => {
|
||||
async function openSiteDialog(type: 'torrent' | 'subtitle' | 'music' = 'torrent') {
|
||||
siteSearchType.value = type
|
||||
await loadUserSitePreferences()
|
||||
if (type === 'music') {
|
||||
await queryMusicSites()
|
||||
} else {
|
||||
await queryAllSites()
|
||||
}
|
||||
chooseSiteDialog.value = true
|
||||
}
|
||||
|
||||
/** 按当前搜索类型刷新站点选择列表。 */
|
||||
async function reloadSearchSites() {
|
||||
if (siteSearchType.value === 'music') {
|
||||
await queryMusicSites()
|
||||
return
|
||||
}
|
||||
await queryAllSites()
|
||||
}
|
||||
|
||||
// 匹配的订阅列表
|
||||
const matchedSubscribeItems = computed(() => {
|
||||
if (!searchWord.value) return []
|
||||
@@ -421,6 +452,10 @@ function searchSites(sites: number[]) {
|
||||
searchSubtitle()
|
||||
return
|
||||
}
|
||||
if (siteSearchType.value === 'music') {
|
||||
searchMusicTorrent()
|
||||
return
|
||||
}
|
||||
searchTorrent()
|
||||
}
|
||||
|
||||
@@ -458,6 +493,23 @@ function searchSubtitle() {
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
/** 使用当前关键词直接搜索站点音乐资源,不经过媒体信息识别。 */
|
||||
function searchMusicTorrent() {
|
||||
if (!searchWord.value || !hasSearchPermission.value) return
|
||||
saveRecentSearches(searchWord.value)
|
||||
router.push({
|
||||
path: '/resource',
|
||||
query: {
|
||||
keyword: searchWord.value,
|
||||
type: '音乐',
|
||||
area: 'title',
|
||||
result_type: 'torrent',
|
||||
sites: selectedSites.value.join(','),
|
||||
},
|
||||
})
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
/** 使用当前关键词搜索指定类型的媒体信息。 */
|
||||
function searchMedia(searchType: MediaSearchType) {
|
||||
if (!searchWord.value || !hasDiscoveryPermission.value) return
|
||||
@@ -881,6 +933,21 @@ onMounted(() => {
|
||||
</template>
|
||||
</VListItem>
|
||||
|
||||
<VListItem density="comfortable" link @click="openSiteDialog('music')" class="search-result-item mx-2 my-1">
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
<VIcon icon="mdi-music-box-multiple-outline" size="small" color="medium-emphasis" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">{{
|
||||
t('dialog.searchBar.searchMusicInSites')
|
||||
}}</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||
{{ t('dialog.searchBar.relatedMusicResources') }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem density="comfortable" link @click="searchSubtitle" class="search-result-item mx-2 my-1">
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
@@ -956,7 +1023,7 @@ onMounted(() => {
|
||||
:selected="selectedSites"
|
||||
@search="searchSites"
|
||||
@close="chooseSiteDialog = false"
|
||||
@reload="queryAllSites"
|
||||
@reload="reloadSearchSites"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -4,7 +4,17 @@ import { screen, waitFor, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { defineComponent } from 'vue'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: createDataApiMock({
|
||||
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
const IconStub = defineComponent({
|
||||
name: 'VIcon',
|
||||
@@ -16,6 +26,7 @@ const IconStub = defineComponent({
|
||||
|
||||
async function renderSearchBar(
|
||||
overrides: {
|
||||
searchPermission?: boolean
|
||||
searchSource?: string
|
||||
} = {},
|
||||
) {
|
||||
@@ -31,7 +42,7 @@ async function renderSearchBar(
|
||||
admin: false,
|
||||
discovery: true,
|
||||
manage: false,
|
||||
search: false,
|
||||
search: overrides.searchPermission ?? false,
|
||||
subscribe: false,
|
||||
},
|
||||
superUser: false,
|
||||
@@ -45,6 +56,7 @@ async function renderSearchBar(
|
||||
stubActions: false,
|
||||
global: {
|
||||
stubs: {
|
||||
VDialogCloseBtn: true,
|
||||
VIcon: IconStub,
|
||||
},
|
||||
},
|
||||
@@ -60,6 +72,16 @@ function getSearchItem(title: string): HTMLElement {
|
||||
describe('SearchBarDialog media source selection', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
mocks.apiGet.mockImplementation((path: string) => {
|
||||
if (path === 'system/setting/public/IndexerSites') return Promise.resolve({ value: [11, 99] })
|
||||
if (path === 'site/media/music') {
|
||||
return Promise.resolve([
|
||||
{ id: 11, is_active: true, name: '音乐站' },
|
||||
{ id: 22, is_active: false, name: '停用音乐站' },
|
||||
])
|
||||
}
|
||||
throw new Error(`Unexpected GET ${path}`)
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults media searches to TheMovieDB when no global search source is configured', async () => {
|
||||
@@ -224,6 +246,31 @@ describe('SearchBarDialog media source selection', () => {
|
||||
expect(getSearchItem('音乐')).not.toHaveTextContent('搜索音乐元数据,并进入站点资源搜索、下载和订阅流程')
|
||||
})
|
||||
|
||||
it('searches music sites by keyword without resolving a media identity', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { router } = await renderSearchBar({ searchPermission: true })
|
||||
const input = await screen.findByPlaceholderText('搜索电影、剧集以及更多...')
|
||||
|
||||
await user.type(input, '周杰伦')
|
||||
await user.click(getSearchItem('在站点中搜索音乐资源'))
|
||||
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('site/media/music'))
|
||||
expect(await screen.findByText('音乐站')).toBeInTheDocument()
|
||||
expect(screen.queryByText('停用音乐站')).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.currentRoute.value.path).toBe('/resource')
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
area: 'title',
|
||||
keyword: '周杰伦',
|
||||
result_type: 'torrent',
|
||||
sites: '11',
|
||||
type: '音乐',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('searches actors with the selected supported source', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { router } = await renderSearchBar()
|
||||
|
||||
@@ -2975,8 +2975,10 @@ export default {
|
||||
subscribeShareSearch: 'Related subscription shares',
|
||||
siteResources: 'Site Resources',
|
||||
searchInSites: 'Search for torrent resources in sites',
|
||||
searchMusicInSites: 'Search for music resources in sites',
|
||||
searchSubtitlesInSites: 'Search for subtitle resources in sites',
|
||||
relatedResources: 'Related Resources',
|
||||
relatedMusicResources: 'Related Music Resources',
|
||||
relatedSubtitles: 'Related Subtitles',
|
||||
searchTip: 'You can search for movies, TV shows, actors, resources, etc.',
|
||||
emptySearchHint: 'Enter keywords to search',
|
||||
|
||||
@@ -2924,8 +2924,10 @@ export default {
|
||||
subscribeShareSearch: '相关的订阅分享',
|
||||
siteResources: '站点资源',
|
||||
searchInSites: '在站点中搜索种子资源',
|
||||
searchMusicInSites: '在站点中搜索音乐资源',
|
||||
searchSubtitlesInSites: '在站点中搜索字幕资源',
|
||||
relatedResources: '相关资源',
|
||||
relatedMusicResources: '相关音乐资源',
|
||||
relatedSubtitles: '相关字幕',
|
||||
searchTip: '可搜索电影、电视剧、演员、资源等',
|
||||
emptySearchHint: '输入关键字开始搜索',
|
||||
|
||||
@@ -2923,8 +2923,10 @@ export default {
|
||||
subscribeShareSearch: '相關的訂閱分享',
|
||||
siteResources: '站點資源',
|
||||
searchInSites: '在站點中搜索種子資源',
|
||||
searchMusicInSites: '在站點中搜索音樂資源',
|
||||
searchSubtitlesInSites: '在站點中搜索字幕資源',
|
||||
relatedResources: '相關資源',
|
||||
relatedMusicResources: '相關音樂資源',
|
||||
relatedSubtitles: '相關字幕',
|
||||
searchTip: '可搜索電影、電視劇、演員、資源等',
|
||||
emptySearchHint: '輸入關鍵字開始搜索',
|
||||
|
||||
@@ -336,6 +336,15 @@ const searchRouteCases: SearchRouteCase[] = [
|
||||
year: '2025',
|
||||
},
|
||||
},
|
||||
{
|
||||
apiEndpoint: 'search/title',
|
||||
apiParams: { keyword: '周杰伦', mtype: '音乐', sites: '11,22' },
|
||||
displayTitle: '音乐资源结果',
|
||||
expectedPath: '/api/v1/search/title/stream',
|
||||
query: { keyword: '周杰伦', result_type: 'torrent', sites: '11,22', type: '音乐' },
|
||||
result: createTorrent({ title: '音乐资源结果' }),
|
||||
streamParams: { keyword: '周杰伦', mtype: '音乐', sites: '11,22' },
|
||||
},
|
||||
{
|
||||
apiEndpoint: `search/media/${musicBrainzAlbumId}`,
|
||||
apiParams: {
|
||||
|
||||
Reference in New Issue
Block a user