From b1e0e9ecd95d2caaf802c321c7605a68607abe6d Mon Sep 17 00:00:00 2001 From: Peifan Li Date: Fri, 12 Dec 2025 12:24:10 -0500 Subject: [PATCH] feat: Implement loading more search results --- backend/src/controllers/videoController.ts | 23 ++-- backend/src/services/downloadService.ts | 10 +- .../services/downloaders/YtDlpDownloader.ts | 28 +++- frontend/src/contexts/VideoContext.tsx | 38 +++++- frontend/src/pages/SearchPage.tsx | 120 ++++++++++-------- frontend/src/pages/SearchResults.tsx | 119 +++++++++-------- frontend/src/utils/locales/ar.ts | 1 + frontend/src/utils/locales/de.ts | 1 + frontend/src/utils/locales/en.ts | 1 + frontend/src/utils/locales/es.ts | 1 + frontend/src/utils/locales/fr.ts | 1 + frontend/src/utils/locales/ja.ts | 1 + frontend/src/utils/locales/ko.ts | 1 + frontend/src/utils/locales/pt.ts | 1 + frontend/src/utils/locales/ru.ts | 1 + frontend/src/utils/locales/zh.ts | 1 + 16 files changed, 226 insertions(+), 122 deletions(-) diff --git a/backend/src/controllers/videoController.ts b/backend/src/controllers/videoController.ts index b1a321d..46a0cb7 100644 --- a/backend/src/controllers/videoController.ts +++ b/backend/src/controllers/videoController.ts @@ -9,13 +9,13 @@ import * as downloadService from "../services/downloadService"; import { getVideoDuration } from "../services/metadataService"; import * as storageService from "../services/storageService"; import { - extractBilibiliVideoId, - extractSourceVideoId, - extractUrlFromText, - isBilibiliUrl, - isValidUrl, - resolveShortUrl, - trimBilibiliUrl, + extractBilibiliVideoId, + extractSourceVideoId, + extractUrlFromText, + isBilibiliUrl, + isValidUrl, + resolveShortUrl, + trimBilibiliUrl, } from "../utils/helpers"; // Configure Multer for file uploads @@ -44,7 +44,14 @@ export const searchVideos = async ( return res.status(400).json({ error: "Search query is required" }); } - const results = await downloadService.searchYouTube(query as string); + const limit = req.query.limit ? parseInt(req.query.limit as string) : 8; + const offset = req.query.offset ? parseInt(req.query.offset as string) : 1; + + const results = await downloadService.searchYouTube( + query as string, + limit, + offset + ); res.status(200).json({ results }); } catch (error: any) { console.error("Error searching for videos:", error); diff --git a/backend/src/services/downloadService.ts b/backend/src/services/downloadService.ts index aaa7e83..38a738a 100644 --- a/backend/src/services/downloadService.ts +++ b/backend/src/services/downloadService.ts @@ -19,7 +19,7 @@ export type { BilibiliVideoInfo, BilibiliVideosResult, CollectionDownloadResult, - DownloadResult, + DownloadResult }; // Helper function to download Bilibili video @@ -121,8 +121,12 @@ export async function downloadRemainingBilibiliParts( } // Search for videos on YouTube (using yt-dlp) -export async function searchYouTube(query: string): Promise { - return YtDlpDownloader.search(query); +export async function searchYouTube( + query: string, + limit?: number, + offset?: number +): Promise { + return YtDlpDownloader.search(query, limit, offset); } // Download generic video (using yt-dlp) diff --git a/backend/src/services/downloaders/YtDlpDownloader.ts b/backend/src/services/downloaders/YtDlpDownloader.ts index 3141cb2..b046b4d 100644 --- a/backend/src/services/downloaders/YtDlpDownloader.ts +++ b/backend/src/services/downloaders/YtDlpDownloader.ts @@ -66,19 +66,31 @@ async function extractXiaoHongShuAuthor(url: string): Promise { export class YtDlpDownloader { // Search for videos (primarily for YouTube, but could be adapted) - static async search(query: string): Promise { - console.log("Processing search request for query:", query); + static async search( + query: string, + limit: number = 8, + offset: number = 1 + ): Promise { + console.log( + `Processing search request for query: "${query}", limit: ${limit}, offset: ${offset}` + ); // Get user config for network options const userConfig = getUserYtDlpConfig(); const networkConfig = getNetworkConfigFromUserConfig(userConfig); + // Calculate the total number of items to fetch from search + // We need to request enough items to cover the offset + limit + const searchLimit = offset + limit - 1; + // Use ytsearch for searching - const searchResults = await executeYtDlpJson(`ytsearch5:${query}`, { + const searchResults = await executeYtDlpJson(`ytsearch${searchLimit}:${query}`, { ...networkConfig, noWarnings: true, skipDownload: true, - playlistEnd: 5, // Limit to 5 results + flatPlaylist: true, // Use flat playlist for faster search results + playlistStart: offset, + playlistEnd: searchLimit, extractorArgs: `youtubepot-bgutilscript:script_path=${PROVIDER_SCRIPT}`, }); @@ -91,7 +103,11 @@ export class YtDlpDownloader { id: entry.id, title: entry.title, author: entry.uploader, - thumbnailUrl: entry.thumbnail, + thumbnailUrl: + entry.thumbnail || + (entry.thumbnails && entry.thumbnails.length > 0 + ? entry.thumbnails[0].url + : ""), duration: entry.duration, viewCount: entry.view_count, sourceUrl: `https://www.youtube.com/watch?v=${entry.id}`, // Default to YT for search results @@ -99,7 +115,7 @@ export class YtDlpDownloader { })); console.log( - `Found ${formattedResults.length} search results for "${query}"` + `Found ${formattedResults.length} search results for "${query}" (requested ${limit})` ); return formattedResults; diff --git a/frontend/src/contexts/VideoContext.tsx b/frontend/src/contexts/VideoContext.tsx index 9232875..f1ee5b0 100644 --- a/frontend/src/contexts/VideoContext.tsx +++ b/frontend/src/contexts/VideoContext.tsx @@ -29,6 +29,8 @@ interface VideoContextType { selectedTags: string[]; handleTagToggle: (tag: string) => void; showYoutubeSearch: boolean; + loadMoreSearchResults: () => Promise; + loadingMore: boolean; } const VideoContext = createContext(undefined); @@ -86,6 +88,7 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre const [isSearchMode, setIsSearchMode] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const [youtubeLoading, setYoutubeLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); // Reference to the current search request's abort controller const searchAbortController = useRef(null); @@ -149,7 +152,10 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre setSearchTerm(''); setSearchResults([]); setLocalSearchResults([]); + setSearchResults([]); + setLocalSearchResults([]); setYoutubeLoading(false); + setLoadingMore(false); }; const handleSearch = async (query: string): Promise => { @@ -217,6 +223,34 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre } }; + const loadMoreSearchResults = async (): Promise => { + if (!searchTerm || loadingMore || !showYoutubeSearch) return; + + try { + setLoadingMore(true); + const currentCount = searchResults.length; + const limit = 8; + const offset = currentCount + 1; + + const response = await axios.get(`${API_URL}/search`, { + params: { + query: searchTerm, + limit, + offset + } + }); + + if (response.data.results && response.data.results.length > 0) { + setSearchResults(prev => [...prev, ...response.data.results]); + } + } catch (error) { + console.error('Error loading more results:', error); + showSnackbar(t('failedToSearch')); + } finally { + setLoadingMore(false); + } + }; + const handleTagToggle = (tag: string) => { setSelectedTags(prev => prev.includes(tag) @@ -323,7 +357,9 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre availableTags, selectedTags, handleTagToggle, - showYoutubeSearch + showYoutubeSearch, + loadMoreSearchResults, + loadingMore }}> {children} diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx index ed1f67d..3323662 100644 --- a/frontend/src/pages/SearchPage.tsx +++ b/frontend/src/pages/SearchPage.tsx @@ -30,7 +30,9 @@ const SearchPage: React.FC = () => { youtubeLoading, handleSearch, searchTerm: contextSearchTerm, - showYoutubeSearch + showYoutubeSearch, + loadMoreSearchResults, + loadingMore } = useVideo(); const { collections } = useCollection(); const { handleVideoSubmit } = useDownload(); @@ -112,61 +114,73 @@ const SearchPage: React.FC = () => { {t('loadingYouTubeResults')} ) : hasYouTubeResults ? ( - - {searchResults.map((result) => ( - - - - { - const target = e.target as HTMLImageElement; - target.onerror = null; - target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail'; - }} - /> - {result.duration && ( - + + {searchResults.map((result) => ( + + + + { + const target = e.target as HTMLImageElement; + target.onerror = null; + target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail'; + }} /> - )} - - {result.source === 'bilibili' ? : } + {result.duration && ( + + )} + + {result.source === 'bilibili' ? : } + - - - - {result.title} - - - {result.author} - - {result.viewCount && ( - - {formatViewCount(result.viewCount)} {t('views')} + + + {result.title} - )} - - - - - - - ))} - + + {result.author} + + {result.viewCount && ( + + {formatViewCount(result.viewCount)} {t('views')} + + )} + + + + + + + ))} + + + + + ) : ( {t('noYouTubeResults')} )} diff --git a/frontend/src/pages/SearchResults.tsx b/frontend/src/pages/SearchResults.tsx index 9dac4eb..2bec9b9 100644 --- a/frontend/src/pages/SearchResults.tsx +++ b/frontend/src/pages/SearchResults.tsx @@ -17,10 +17,12 @@ import React, { useEffect, useState } from 'react'; import VideoCard from '../components/VideoCard'; import { useCollection } from '../contexts/CollectionContext'; import { useDownload } from '../contexts/DownloadContext'; +import { useLanguage } from '../contexts/LanguageContext'; import { useVideo } from '../contexts/VideoContext'; import { formatDuration } from '../utils/formatUtils'; const SearchResults: React.FC = () => { + const { t } = useLanguage(); const { searchResults, localSearchResults, @@ -30,7 +32,10 @@ const SearchResults: React.FC = () => { deleteVideo, resetSearch, setIsSearchMode, - showYoutubeSearch + + showYoutubeSearch, + loadMoreSearchResults, + loadingMore } = useVideo(); const { collections } = useCollection(); const { handleVideoSubmit } = useDownload(); @@ -140,60 +145,72 @@ const SearchResults: React.FC = () => { Loading YouTube results... ) : hasYouTubeResults ? ( - - {searchResults.map((result) => - - - { - const target = e.target as HTMLImageElement; - target.onerror = null; - target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail'; - }} - /> - {result.duration && ( - + + {searchResults.map((result) => + + + { + const target = e.target as HTMLImageElement; + target.onerror = null; + target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail'; + }} /> - )} - - {result.source === 'bilibili' ? : } + {result.duration && ( + + )} + + {result.source === 'bilibili' ? : } + - - - - {result.title} - - - {result.author} - - {result.viewCount && ( - - {formatViewCount(result.viewCount)} views + + + {result.title} - )} - - - - - + + {result.author} + + {result.viewCount && ( + + {formatViewCount(result.viewCount)} views + + )} + + + + + + + )} - )} - + + + + ) : ( No YouTube results found. )} diff --git a/frontend/src/utils/locales/ar.ts b/frontend/src/utils/locales/ar.ts index 9e051e8..79e7911 100644 --- a/frontend/src/utils/locales/ar.ts +++ b/frontend/src/utils/locales/ar.ts @@ -444,4 +444,5 @@ export const ar = { customize: "تخصيص", hide: "إخفاء", reset: "إعادة تعيين", + more: "المزيد", }; diff --git a/frontend/src/utils/locales/de.ts b/frontend/src/utils/locales/de.ts index af3858d..f3b6eb0 100644 --- a/frontend/src/utils/locales/de.ts +++ b/frontend/src/utils/locales/de.ts @@ -425,4 +425,5 @@ export const de = { customize: "Anpassen", hide: "Ausblenden", reset: "Zurücksetzen", + more: "Mehr", }; diff --git a/frontend/src/utils/locales/en.ts b/frontend/src/utils/locales/en.ts index d89542d..5020916 100644 --- a/frontend/src/utils/locales/en.ts +++ b/frontend/src/utils/locales/en.ts @@ -453,4 +453,5 @@ export const en = { customize: "Customize", hide: "Hide", reset: "Reset", + more: "More", }; diff --git a/frontend/src/utils/locales/es.ts b/frontend/src/utils/locales/es.ts index de12ba1..ab88342 100644 --- a/frontend/src/utils/locales/es.ts +++ b/frontend/src/utils/locales/es.ts @@ -431,4 +431,5 @@ export const es = { customize: "Personalizar", hide: "Ocultar", reset: "Restablecer", + more: "Más", }; diff --git a/frontend/src/utils/locales/fr.ts b/frontend/src/utils/locales/fr.ts index 712fe0f..213d102 100644 --- a/frontend/src/utils/locales/fr.ts +++ b/frontend/src/utils/locales/fr.ts @@ -463,4 +463,5 @@ export const fr = { customize: "Personnaliser", hide: "Masquer", reset: "Réinitialiser", + more: "Plus", }; diff --git a/frontend/src/utils/locales/ja.ts b/frontend/src/utils/locales/ja.ts index a98dae8..20f7659 100644 --- a/frontend/src/utils/locales/ja.ts +++ b/frontend/src/utils/locales/ja.ts @@ -452,4 +452,5 @@ export const ja = { customize: "カスタマイズ", hide: "隠す", reset: "リセット", + more: "もっと見る", }; diff --git a/frontend/src/utils/locales/ko.ts b/frontend/src/utils/locales/ko.ts index a535d07..ec36a9d 100644 --- a/frontend/src/utils/locales/ko.ts +++ b/frontend/src/utils/locales/ko.ts @@ -446,4 +446,5 @@ export const ko = { customize: "사용자 지정", hide: "숨기기", reset: "초기화", + more: "더 보기", }; diff --git a/frontend/src/utils/locales/pt.ts b/frontend/src/utils/locales/pt.ts index 8df059d..3ecda17 100644 --- a/frontend/src/utils/locales/pt.ts +++ b/frontend/src/utils/locales/pt.ts @@ -457,4 +457,5 @@ export const pt = { customize: "Personalizar", hide: "Ocultar", reset: "Redefinir", + more: "Mais", }; diff --git a/frontend/src/utils/locales/ru.ts b/frontend/src/utils/locales/ru.ts index 57d2ca1..96c035f 100644 --- a/frontend/src/utils/locales/ru.ts +++ b/frontend/src/utils/locales/ru.ts @@ -451,4 +451,5 @@ export const ru = { customize: "Настроить", hide: "Скрыть", reset: "Сбросить", + more: "Ещё", }; diff --git a/frontend/src/utils/locales/zh.ts b/frontend/src/utils/locales/zh.ts index 410edd7..7cad7ab 100644 --- a/frontend/src/utils/locales/zh.ts +++ b/frontend/src/utils/locales/zh.ts @@ -442,4 +442,5 @@ export const zh = { customize: "自定义", hide: "隐藏", reset: "重置", + more: "更多", };