diff --git a/src/components/dialog/SearchBarDialog.vue b/src/components/dialog/SearchBarDialog.vue index 2cde004b..66975259 100644 --- a/src/components/dialog/SearchBarDialog.vue +++ b/src/components/dialog/SearchBarDialog.vue @@ -68,7 +68,7 @@ const SubscribeItems = ref([]) const chooseSiteDialog = ref(false) const selectedSites = ref([]) const allSites = ref([]) -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(() => { + + + {{ + t('dialog.searchBar.searchMusicInSites') + }} + + {{ t('common.search') }} {{ searchWord }} + {{ t('dialog.searchBar.relatedMusicResources') }} + + + diff --git a/src/components/dialog/__tests__/SearchBarDialog.spec.ts b/src/components/dialog/__tests__/SearchBarDialog.spec.ts index 45b04580..b7474b66 100644 --- a/src/components/dialog/__tests__/SearchBarDialog.spec.ts +++ b/src/components/dialog/__tests__/SearchBarDialog.spec.ts @@ -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() diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index d57aa9ed..370cc34d 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -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', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index afdbfa14..6d81b455 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -2924,8 +2924,10 @@ export default { subscribeShareSearch: '相关的订阅分享', siteResources: '站点资源', searchInSites: '在站点中搜索种子资源', + searchMusicInSites: '在站点中搜索音乐资源', searchSubtitlesInSites: '在站点中搜索字幕资源', relatedResources: '相关资源', + relatedMusicResources: '相关音乐资源', relatedSubtitles: '相关字幕', searchTip: '可搜索电影、电视剧、演员、资源等', emptySearchHint: '输入关键字开始搜索', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index b9b1f2af..c77fbbad 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -2923,8 +2923,10 @@ export default { subscribeShareSearch: '相關的訂閱分享', siteResources: '站點資源', searchInSites: '在站點中搜索種子資源', + searchMusicInSites: '在站點中搜索音樂資源', searchSubtitlesInSites: '在站點中搜索字幕資源', relatedResources: '相關資源', + relatedMusicResources: '相關音樂資源', relatedSubtitles: '相關字幕', searchTip: '可搜索電影、電視劇、演員、資源等', emptySearchHint: '輸入關鍵字開始搜索', diff --git a/src/pages/__tests__/resource.spec.ts b/src/pages/__tests__/resource.spec.ts index 8f85c2cd..8057d719 100644 --- a/src/pages/__tests__/resource.spec.ts +++ b/src/pages/__tests__/resource.spec.ts @@ -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: {