From b1cb07ae8c94e505bce40067d3e9d7b3de4da441 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 9 Jun 2026 06:47:09 +0800 Subject: [PATCH] feat: add subtitle search functionality and download feature - Added subtitle search results support in zh-TW locale. - Enhanced resource page to handle subtitle search results, including new computed properties and methods for managing subtitle data. - Introduced SubtitleCard and SubtitleItem components for displaying subtitle information. - Created AddSubtitleDownloadDialog for managing subtitle downloads with directory selection and media ID options. - Implemented subtitle download caching mechanism to track downloaded subtitles. --- src/api/types.ts | 46 +++ src/components/cards/SubtitleCard.vue | 210 ++++++++++++++ src/components/cards/SubtitleItem.vue | 213 ++++++++++++++ .../dialog/AddSubtitleDownloadDialog.vue | 270 ++++++++++++++++++ src/components/dialog/SearchBarDialog.vue | 55 +++- src/locales/en-US.ts | 13 + src/locales/zh-CN.ts | 13 + src/locales/zh-TW.ts | 13 + src/pages/resource.vue | 238 +++++++++++++-- src/utils/subtitleDownloadCache.ts | 13 + 10 files changed, 1062 insertions(+), 22 deletions(-) create mode 100644 src/components/cards/SubtitleCard.vue create mode 100644 src/components/cards/SubtitleItem.vue create mode 100644 src/components/dialog/AddSubtitleDownloadDialog.vue create mode 100644 src/utils/subtitleDownloadCache.ts diff --git a/src/api/types.ts b/src/api/types.ts index 45b8b31e..8b692ca1 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -768,6 +768,52 @@ export interface TorrentInfo { category: string } +// 字幕信息 +export interface SubtitleInfo { + // 站点ID + site?: number + // 站点名称 + site_name?: string + // 站点Cookie + site_cookie?: string + // 站点UA + site_ua?: string + // 站点是否使用代理 + site_proxy?: boolean + // 站点优先级 + site_order?: number + // 字幕标题 + title?: string + // 字幕描述 + description?: string + // 字幕下载链接 + enclosure?: string + // 详情页面 + page_url?: string + // 语言 + language?: string + // 语言图标 + language_icon?: string + // 字幕大小 + size?: number + // 发布时间 + pubdate?: string + // 已过时间 + date_elapsed?: string + // 点击/下载次数 + grabs?: number + // 上传者 + uploader?: string + // 举报页面 + report_url?: string + // 种子ID + torrent_id?: string + // 字幕ID + subtitle_id?: string + // 下载文件名 + file_name?: string +} + // 识别元数据 export interface MetaInfo { // 是否处理的文件 diff --git a/src/components/cards/SubtitleCard.vue b/src/components/cards/SubtitleCard.vue new file mode 100644 index 00000000..e14c5b83 --- /dev/null +++ b/src/components/cards/SubtitleCard.vue @@ -0,0 +1,210 @@ + + + + + diff --git a/src/components/cards/SubtitleItem.vue b/src/components/cards/SubtitleItem.vue new file mode 100644 index 00000000..9f3186df --- /dev/null +++ b/src/components/cards/SubtitleItem.vue @@ -0,0 +1,213 @@ + + + + + diff --git a/src/components/dialog/AddSubtitleDownloadDialog.vue b/src/components/dialog/AddSubtitleDownloadDialog.vue new file mode 100644 index 00000000..2ee3354f --- /dev/null +++ b/src/components/dialog/AddSubtitleDownloadDialog.vue @@ -0,0 +1,270 @@ + + + diff --git a/src/components/dialog/SearchBarDialog.vue b/src/components/dialog/SearchBarDialog.vue index 569e8577..0894368d 100644 --- a/src/components/dialog/SearchBarDialog.vue +++ b/src/components/dialog/SearchBarDialog.vue @@ -79,6 +79,7 @@ const SubscribeItems = ref([]) const chooseSiteDialog = ref(false) const selectedSites = ref([]) const allSites = ref([]) +const siteSearchType = ref<'torrent' | 'subtitle'>('torrent') // 定义事件 const emit = defineEmits(['close', 'update:modelValue']) @@ -247,7 +248,8 @@ async function queryAllSites() { } // 打开站点选择对话框 -const openSiteDialog = () => { +const openSiteDialog = (type: 'torrent' | 'subtitle' = 'torrent') => { + siteSearchType.value = type chooseSiteDialog.value = true } @@ -265,6 +267,10 @@ const matchedSubscribeItems = computed(() => { function searchSites(sites: number[]) { chooseSiteDialog.value = false selectedSites.value = sites + if (siteSearchType.value === 'subtitle') { + searchSubtitle() + return + } searchTorrent() } @@ -279,6 +285,7 @@ function searchTorrent() { query: { keyword: searchWord.value, area: 'title', + result_type: 'torrent', sites: selectedSites.value.join(','), }, }) @@ -287,6 +294,23 @@ function searchTorrent() { emit('close') } +// 搜索字幕资源 +function searchSubtitle() { + if (!searchWord.value) return + saveRecentSearches(searchWord.value) + router.push({ + path: '/resource', + query: { + keyword: searchWord.value, + area: 'title', + result_type: 'subtitle', + sites: selectedSites.value.join(','), + }, + }) + dialog.value = false + emit('close') +} + // 跳转媒体搜索页面 function searchMedia(searchType: string) { // 搜索类型 media/person @@ -537,6 +561,33 @@ onMounted(() => { {{ subscribe.type }} + + + + {{ + t('dialog.searchBar.searchSubtitlesInSites') + }} + + {{ t('common.search') }} {{ searchWord }} + {{ t('dialog.searchBar.relatedSubtitles') }} + + + @@ -622,7 +673,7 @@ onMounted(() => { variant="tonal" color="primary" rounded="pill" - @click.stop="openSiteDialog" + @click.stop="openSiteDialog('torrent')" > {{ t('dialog.searchBar.selectSites') }} diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 8840bce7..80ded98e 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -1107,6 +1107,7 @@ export default { }, resource: { searchResults: 'Resource Search Results', + subtitleSearchResults: 'Subtitle Search Results', keyword: 'Keyword', title: 'Title', year: 'Year', @@ -2318,7 +2319,9 @@ export default { subscribeShareSearch: 'Related subscription shares', siteResources: 'Site Resources', searchInSites: 'Search for torrent resources in sites', + searchSubtitlesInSites: 'Search for subtitle resources in sites', relatedResources: 'Related Resources', + relatedSubtitles: 'Related Subtitles', searchTip: 'You can search for movies, TV shows, actors, resources, etc.', emptySearchHint: 'Enter keywords to search', escClose: 'Close', @@ -2350,6 +2353,16 @@ export default { showAdvancedOptions: 'Show Advanced Options', hideAdvancedOptions: 'Hide Advanced Options', }, + addSubtitleDownload: { + confirmDownload: 'Confirm Subtitle Download', + saveDirectory: 'Save Directory (Auto)', + autoPlaceholder: 'Leave empty for auto-match', + downloading: 'Downloading...', + startDownload: 'Download Subtitle', + downloaded: 'Downloaded', + downloadSuccess: '{site} {title} subtitle downloaded successfully!', + downloadFailed: '{site} {title} subtitle download failed: {message}!', + }, subscribeShare: { shareSubscription: 'Share Subscription', season: 'Season {number}', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 5e40e480..c22bb7d8 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -1102,6 +1102,7 @@ export default { }, resource: { searchResults: '资源搜索结果', + subtitleSearchResults: '字幕搜索结果', keyword: '关键词', title: '标题', year: '年份', @@ -2274,7 +2275,9 @@ export default { subscribeShareSearch: '相关的订阅分享', siteResources: '站点资源', searchInSites: '在站点中搜索种子资源', + searchSubtitlesInSites: '在站点中搜索字幕资源', relatedResources: '相关资源', + relatedSubtitles: '相关字幕', searchTip: '可搜索电影、电视剧、演员、资源等', emptySearchHint: '输入关键字开始搜索', escClose: '关闭', @@ -2306,6 +2309,16 @@ export default { showAdvancedOptions: '显示高级选项', hideAdvancedOptions: '隐藏高级选项', }, + addSubtitleDownload: { + confirmDownload: '确认下载字幕', + saveDirectory: '保存目录(自动)', + autoPlaceholder: '留空自动匹配', + downloading: '下载中...', + startDownload: '下载字幕', + downloaded: '已下载', + downloadSuccess: '{site} {title} 字幕下载成功!', + downloadFailed: '{site} {title} 字幕下载失败:{message}!', + }, subscribeShare: { shareSubscription: '分享订阅', season: '第 {number} 季', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index 43812d9f..9c1e01f9 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -1102,6 +1102,7 @@ export default { }, resource: { searchResults: '資源搜索結果', + subtitleSearchResults: '字幕搜索結果', keyword: '關鍵詞', title: '標題', year: '年份', @@ -2275,7 +2276,9 @@ export default { subscribeShareSearch: '相關的訂閱分享', siteResources: '站點資源', searchInSites: '在站點中搜索種子資源', + searchSubtitlesInSites: '在站點中搜索字幕資源', relatedResources: '相關資源', + relatedSubtitles: '相關字幕', searchTip: '可搜索電影、電視劇、演員、資源等', emptySearchHint: '輸入關鍵字開始搜索', escClose: '關閉', @@ -2307,6 +2310,16 @@ export default { showAdvancedOptions: '顯示高級選項', hideAdvancedOptions: '隱藏高級選項', }, + addSubtitleDownload: { + confirmDownload: '確認下載字幕', + saveDirectory: '保存目錄(自動)', + autoPlaceholder: '留空自動匹配', + downloading: '下載中...', + startDownload: '下載字幕', + downloaded: '已下載', + downloadSuccess: '{site} {title} 字幕下載成功!', + downloadFailed: '{site} {title} 字幕下載失敗:{message}!', + }, subscribeShare: { shareSubscription: '分享訂閱', season: '第 {number} 季', diff --git a/src/pages/resource.vue b/src/pages/resource.vue index cf722439..128f052a 100644 --- a/src/pages/resource.vue +++ b/src/pages/resource.vue @@ -3,9 +3,11 @@ import { debounce } from 'lodash-es' import type { LocationQuery } from 'vue-router' import NoDataFound from '@/components/NoDataFound.vue' import api from '@/api' -import type { Context } from '@/api/types' +import type { Context, SubtitleInfo } from '@/api/types' import TorrentCard from '@/components/cards/TorrentCard.vue' import TorrentItem from '@/components/cards/TorrentItem.vue' +import SubtitleCard from '@/components/cards/SubtitleCard.vue' +import SubtitleItem from '@/components/cards/SubtitleItem.vue' import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue' import TorrentFilterBar from '@/components/filter/TorrentFilterBar.vue' import { useI18n } from 'vue-i18n' @@ -42,13 +44,14 @@ interface SearchParams { year: string season: string sites: string + result_type: string } interface LastSearchContextResponse { success?: boolean data?: { params?: Partial - results?: Context[] + results?: Array } } @@ -63,6 +66,7 @@ function createSearchParams(query: LocationQuery): SearchParams { year: query?.year?.toString() ?? '', season: query?.season?.toString() ?? '', sites: query?.sites?.toString() ?? '', + result_type: query?.result_type?.toString() === 'subtitle' ? 'subtitle' : 'torrent', } } @@ -75,6 +79,7 @@ function normalizeSearchParams(params?: Partial | null): SearchPar year: params?.year?.toString() ?? '', season: params?.season?.toString() ?? '', sites: params?.sites?.toString() ?? '', + result_type: params?.result_type?.toString() === 'subtitle' ? 'subtitle' : 'torrent', } } @@ -189,6 +194,12 @@ const season = computed(() => activeSearchParams.value.season) // 搜索站点,以,分离多个 const sites = computed(() => activeSearchParams.value.sites) +// 搜索结果类型 +const resultType = computed(() => (activeSearchParams.value.result_type === 'subtitle' ? 'subtitle' : 'torrent')) + +// 是否为字幕搜索 +const isSubtitleSearch = computed(() => resultType.value === 'subtitle') + // 视图类型,从localStorage中读取 const viewType = ref(localStorage.getItem('MPTorrentsViewType') ?? 'card') @@ -218,6 +229,9 @@ const enableFilterAnimation = ref(true) // 原始数据列表(未筛选) const rawDataList = ref>([]) +// 原始字幕数据列表 +const rawSubtitleDataList = ref>([]) + // 筛选后的数据列表(用于行视图) const filteredRowDataList = ref>([]) @@ -298,15 +312,21 @@ const searchStreamDoneCloseDelay = 1500 const streamTotalCount = ref(0) const streamPreviewDataList = ref>([]) +const streamPreviewSubtitleDataList = ref>([]) const displayResourceCount = computed(() => - progressActive.value ? streamTotalCount.value : torrentFilter.totalFilteredCount.value, + progressActive.value + ? streamTotalCount.value + : isSubtitleSearch.value + ? rawSubtitleDataList.value.length + : torrentFilter.totalFilteredCount.value, ) // 搜索中只显示进度区域,避免结果抬头和进度条同时占用顶部空间。 const showResultHeader = computed(() => isRefreshed.value && !progressActive.value) let pendingStreamItems: Array = [] +let pendingSubtitleStreamItems: Array = [] let streamFlushTimer: ReturnType | null = null let streamFinalResultApplied = false let pendingProgressText: string | null = null @@ -324,6 +344,8 @@ watch( // 应用筛选 function applyFilter() { + if (isSubtitleSearch.value) return + if (viewType.value === 'row') { filteredRowDataList.value = torrentFilter.filterRowData(rawDataList.value) } else { @@ -432,10 +454,12 @@ function clearStreamFlushTimer() { function clearStreamPreviewState(resetFinalState: boolean = false) { clearStreamFlushTimer() pendingStreamItems = [] + pendingSubtitleStreamItems = [] pendingProgressText = null pendingProgressValue = null pendingStreamTotalCount = null streamPreviewDataList.value = [] + streamPreviewSubtitleDataList.value = [] if (resetFinalState) { streamFinalResultApplied = false } @@ -459,6 +483,15 @@ function flushBufferedStreamState() { pendingProgressValue = null pendingStreamTotalCount = null + if (pendingSubtitleStreamItems.length) { + streamPreviewSubtitleDataList.value = [...pendingSubtitleStreamItems, ...streamPreviewSubtitleDataList.value].slice( + 0, + streamPreviewLimit, + ) + pendingSubtitleStreamItems = [] + isRefreshed.value = true + } + if (!pendingStreamItems.length) return streamPreviewDataList.value = [...pendingStreamItems, ...streamPreviewDataList.value].slice(0, streamPreviewLimit) @@ -493,9 +526,18 @@ function setSearchParam(params: URLSearchParams, key: string, value: unknown) { // 构建搜索流URL function buildSearchStreamUrl(params: SearchParams, requestToken?: string) { const isMediaSearch = /^[a-zA-Z]+:/.test(params.keyword) - const url = getApiUrl(isMediaSearch ? `search/media/${encodeURIComponent(params.keyword)}/stream` : 'search/title/stream') + const url = getApiUrl( + params.result_type === 'subtitle' + ? 'search/subtitle/title/stream' + : isMediaSearch + ? `search/media/${encodeURIComponent(params.keyword)}/stream` + : 'search/title/stream', + ) - if (isMediaSearch) { + if (params.result_type === 'subtitle') { + setSearchParam(url.searchParams, 'keyword', params.keyword) + setSearchParam(url.searchParams, 'sites', params.sites) + } else if (isMediaSearch) { setSearchParam(url.searchParams, 'mtype', params.type) setSearchParam(url.searchParams, 'area', params.area) setSearchParam(url.searchParams, 'title', params.title) @@ -518,6 +560,7 @@ function buildSearchStreamUrl(params: SearchParams, requestToken?: string) { function resetSearchResults() { clearStreamPreviewState(true) rawDataList.value = [] + rawSubtitleDataList.value = [] originalDataList.value = [] streamTotalCount.value = 0 aiRecommended.value = false @@ -531,7 +574,8 @@ function resetSearchResults() { // 判断当前页面是否已经完成过一次带关键词的空结果搜索,避免 keep-alive 返回时自动重搜。 function hasLoadedEmptySearchResult() { - return isRefreshed.value && !progressActive.value && rawDataList.value.length === 0 && hasSearchKeyword(activeSearchParams.value) + const dataLength = isSubtitleSearch.value ? rawSubtitleDataList.value.length : rawDataList.value.length + return isRefreshed.value && !progressActive.value && dataLength === 0 && hasSearchKeyword(activeSearchParams.value) } // 更新搜索进度 @@ -558,6 +602,7 @@ function updateSearchProgress(eventData: { [key: string]: any }, flushNow: boole function setStreamResults(items: Context[]) { clearStreamPreviewState() rawDataList.value = items + rawSubtitleDataList.value = [] originalDataList.value = items if (!progressActive.value) { streamTotalCount.value = items.length @@ -566,6 +611,18 @@ function setStreamResults(items: Context[]) { applyFilter() } +// 设置字幕搜索结果 +function setSubtitleStreamResults(items: SubtitleInfo[]) { + clearStreamPreviewState() + rawSubtitleDataList.value = items + rawDataList.value = [] + originalDataList.value = [] + if (!progressActive.value) { + streamTotalCount.value = items.length + } + isRefreshed.value = true +} + // 追加流式搜索预览结果 function appendStreamResults(items: Context[]) { if (!items.length) return @@ -577,12 +634,30 @@ function appendStreamResults(items: Context[]) { scheduleStreamFlush() } +// 追加流式字幕搜索预览结果 +function appendSubtitleStreamResults(items: SubtitleInfo[]) { + if (!items.length) return + + pendingSubtitleStreamItems.unshift(...items) + if (pendingSubtitleStreamItems.length > streamPreviewBufferLimit) { + pendingSubtitleStreamItems = pendingSubtitleStreamItems.slice(0, streamPreviewBufferLimit) + } + scheduleStreamFlush() +} + function applyFinalStreamResults(items: Context[]) { streamFinalResultApplied = true flushBufferedStreamState() setStreamResults(items) } +// 应用最终字幕搜索结果 +function applyFinalSubtitleStreamResults(items: SubtitleInfo[]) { + streamFinalResultApplied = true + flushBufferedStreamState() + setSubtitleStreamResults(items) +} + // 获取磁力链接的key function getTorrentItemKey(item: Context, index: number) { return ( @@ -593,6 +668,16 @@ function getTorrentItemKey(item: Context, index: number) { ) } +// 获取字幕结果的key +function getSubtitleItemKey(item: SubtitleInfo, index: number) { + return ( + item.enclosure || + item.page_url || + `${item.site_name || ''}-${item.subtitle_id || ''}-${item.title || ''}` || + `subtitle-${index}` + ) +} + // 处理搜索流消息 function handleSearchStreamMessage(eventData: { [key: string]: any }) { if (eventData.type === 'error') { @@ -601,6 +686,23 @@ function handleSearchStreamMessage(eventData: { [key: string]: any }) { return } + if (isSubtitleSearch.value) { + const subtitleItems = Array.isArray(eventData.items) ? (eventData.items as SubtitleInfo[]) : [] + if (eventData.type === 'append') { + updateSearchProgress(eventData) + appendSubtitleStreamResults(subtitleItems) + } else if (eventData.type === 'replace') { + updateSearchProgress(eventData, true) + applyFinalSubtitleStreamResults(subtitleItems) + } else if (eventData.type === 'done' && subtitleItems.length > 0 && !streamFinalResultApplied) { + updateSearchProgress(eventData, true) + applyFinalSubtitleStreamResults(subtitleItems) + } else { + updateSearchProgress(eventData) + } + return + } + const items = Array.isArray(eventData.items) ? (eventData.items as Context[]) : [] if (eventData.type === 'append') { updateSearchProgress(eventData) @@ -620,14 +722,26 @@ function handleSearchStreamMessage(eventData: { [key: string]: any }) { async function searchByRequest(params: SearchParams, requestToken?: string) { const items = await requestSearchResults(params, requestToken) streamTotalCount.value = items.length - setStreamResults(items) + if (params.result_type === 'subtitle') { + setSubtitleStreamResults(items as SubtitleInfo[]) + } else { + setStreamResults(items as Context[]) + } } // 静默刷新使用普通请求,保留当前结果直到新数据完整返回,避免返回页面时露出搜索进度态。 async function requestSearchResults(params: SearchParams, requestToken?: string) { let result: { [key: string]: any } // 如果keyword的格式是 xxxx:xxxxx 且:前面的xxxx为字符,则按照媒体ID格式搜索 - if (/^[a-zA-Z]+:/.test(params.keyword)) { + if (params.result_type === 'subtitle') { + result = await api.get('search/subtitle/title', { + params: { + keyword: params.keyword, + sites: params.sites, + _ts: requestToken, + }, + }) + } else if (/^[a-zA-Z]+:/.test(params.keyword)) { result = await api.get(`search/media/${params.keyword}`, { params: { mtype: params.type, @@ -651,7 +765,7 @@ async function requestSearchResults(params: SearchParams, requestToken?: string) } if (result && result.success) { - return (result.data || []) as Context[] + return (result.data || []) as Array } errorDescription.value = result?.message || t('resource.noResourceFound') @@ -744,19 +858,28 @@ async function fetchData(options: { force?: boolean; params?: SearchParams; sile rememberSearchParams(currentSearchParams) } const requestToken = options.force || Boolean(currentSearchParams.keyword) ? createSearchRequestToken() : undefined - const silentRefresh = Boolean(options.silent && isRefreshed.value && rawDataList.value.length > 0) + const hasCurrentResults = isSubtitleSearch.value ? rawSubtitleDataList.value.length > 0 : rawDataList.value.length > 0 + const silentRefresh = Boolean(options.silent && isRefreshed.value && hasCurrentResults) try { enableFilterAnimation.value = true if (!hasSearchKeyword(currentSearchParams)) { // 查询上次搜索结果,并同步可重放的搜索参数 const results = await fetchLastSearchContext() - setStreamResults(results || []) + if (activeSearchParams.value.result_type === 'subtitle') { + setSubtitleStreamResults((results || []) as SubtitleInfo[]) + } else { + setStreamResults((results || []) as Context[]) + } } else if (silentRefresh) { // keep-alive 重新进入时后台刷新,旧结果继续显示,等新结果完整返回后一次性替换。 const results = await requestSearchResults(currentSearchParams, requestToken) streamTotalCount.value = results.length - setStreamResults(results) + if (currentSearchParams.result_type === 'subtitle') { + setSubtitleStreamResults(results as SubtitleInfo[]) + } else { + setStreamResults(results as Context[]) + } } else { resetSearchResults() startLoadingProgress() @@ -1054,6 +1177,13 @@ async function checkAiRecommendStatus() { // 计算当前显示的数据是否有数据 const hasData = computed(() => { + if (isSubtitleSearch.value) { + if (progressActive.value) { + return streamPreviewSubtitleDataList.value.length > 0 || rawSubtitleDataList.value.length > 0 + } + return rawSubtitleDataList.value.length > 0 + } + if (progressActive.value) { return streamPreviewDataList.value.length > 0 || rawDataList.value.length > 0 } @@ -1071,6 +1201,7 @@ watchEffect(() => { // 需要满足:AI 功能启用、数据已加载、尚未检查 if ( aiRecommendEnabled.value && + !isSubtitleSearch.value && originalDataList.value.length > 0 && !progressActive.value && !aiStatusChecked.value @@ -1187,7 +1318,7 @@ onUnmounted(() => {
@@ -1210,7 +1341,7 @@ onUnmounted(() => {
@@ -1255,7 +1386,7 @@ onUnmounted(() => {
{
+ +
+ + + +
{ -
+
{{ t('torrent.noResults') }}
@@ -1310,11 +1470,49 @@ onUnmounted(() => {
-
+
{{ t('torrent.noResults') }}
-
+
+
+ + +
+
+
+ + + +
+
{
-
+
>({}) + +export function markSubtitleDownloaded(url?: string | null) { + if (!url) { + return + } + + downloadedSubtitleMap[url] = true +} + +export { downloadedSubtitleMap }