feat: Implement loading more search results

This commit is contained in:
Peifan Li
2025-12-12 12:24:10 -05:00
parent 113dc2e258
commit b1e0e9ecd9
16 changed files with 226 additions and 122 deletions

View File

@@ -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);

View File

@@ -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<any[]> {
return YtDlpDownloader.search(query);
export async function searchYouTube(
query: string,
limit?: number,
offset?: number
): Promise<any[]> {
return YtDlpDownloader.search(query, limit, offset);
}
// Download generic video (using yt-dlp)

View File

@@ -66,19 +66,31 @@ async function extractXiaoHongShuAuthor(url: string): Promise<string | null> {
export class YtDlpDownloader {
// Search for videos (primarily for YouTube, but could be adapted)
static async search(query: string): Promise<any[]> {
console.log("Processing search request for query:", query);
static async search(
query: string,
limit: number = 8,
offset: number = 1
): Promise<any[]> {
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;

View File

@@ -29,6 +29,8 @@ interface VideoContextType {
selectedTags: string[];
handleTagToggle: (tag: string) => void;
showYoutubeSearch: boolean;
loadMoreSearchResults: () => Promise<void>;
loadingMore: boolean;
}
const VideoContext = createContext<VideoContextType | undefined>(undefined);
@@ -86,6 +88,7 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
const [isSearchMode, setIsSearchMode] = useState<boolean>(false);
const [searchTerm, setSearchTerm] = useState<string>('');
const [youtubeLoading, setYoutubeLoading] = useState<boolean>(false);
const [loadingMore, setLoadingMore] = useState<boolean>(false);
// Reference to the current search request's abort controller
const searchAbortController = useRef<AbortController | null>(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<any> => {
@@ -217,6 +223,34 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
}
};
const loadMoreSearchResults = async (): Promise<void> => {
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}
</VideoContext.Provider>

View File

@@ -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 = () => {
<Typography sx={{ mt: 2 }}>{t('loadingYouTubeResults')}</Typography>
</Box>
) : hasYouTubeResults ? (
<Grid container spacing={3}>
{searchResults.map((result) => (
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={result.id}>
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ position: 'relative', paddingTop: '56.25%' }}>
<CardMedia
component="img"
image={result.thumbnailUrl || 'https://via.placeholder.com/480x360?text=No+Thumbnail'}
alt={result.title}
sx={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.onerror = null;
target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
}}
/>
{result.duration && (
<Chip
label={formatDuration(result.duration)}
size="small"
sx={{ position: 'absolute', bottom: 8, right: 8, bgcolor: 'rgba(0,0,0,0.8)', color: 'white' }}
<>
<Grid container spacing={3}>
{searchResults.map((result) => (
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={result.id}>
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ position: 'relative', paddingTop: '56.25%' }}>
<CardMedia
component="img"
image={result.thumbnailUrl || 'https://via.placeholder.com/480x360?text=No+Thumbnail'}
alt={result.title}
sx={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.onerror = null;
target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
}}
/>
)}
<Box sx={{ position: 'absolute', top: 8, right: 8, bgcolor: 'rgba(0,0,0,0.7)', borderRadius: '50%', p: 0.5, display: 'flex' }}>
{result.source === 'bilibili' ? <OndemandVideo sx={{ color: '#23ade5' }} /> : <YouTube sx={{ color: '#ff0000' }} />}
{result.duration && (
<Chip
label={formatDuration(result.duration)}
size="small"
sx={{ position: 'absolute', bottom: 8, right: 8, bgcolor: 'rgba(0,0,0,0.8)', color: 'white' }}
/>
)}
<Box sx={{ position: 'absolute', top: 8, right: 8, bgcolor: 'rgba(0,0,0,0.7)', borderRadius: '50%', p: 0.5, display: 'flex' }}>
{result.source === 'bilibili' ? <OndemandVideo sx={{ color: '#23ade5' }} /> : <YouTube sx={{ color: '#ff0000' }} />}
</Box>
</Box>
</Box>
<CardContent sx={{ flexGrow: 1, p: 2 }}>
<Typography gutterBottom variant="subtitle1" component="div" sx={{ fontWeight: 600, lineHeight: 1.2, mb: 1, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{result.title}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{result.author}
</Typography>
{result.viewCount && (
<Typography variant="caption" color="text.secondary">
{formatViewCount(result.viewCount)} {t('views')}
<CardContent sx={{ flexGrow: 1, p: 2 }}>
<Typography gutterBottom variant="subtitle1" component="div" sx={{ fontWeight: 600, lineHeight: 1.2, mb: 1, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{result.title}
</Typography>
)}
</CardContent>
<CardActions sx={{ p: 2, pt: 0 }}>
<Button
fullWidth
variant="contained"
startIcon={<Download />}
onClick={() => handleDownload(result.id, result.sourceUrl)}
disabled={downloadingId === result.id}
>
{t('download')}
</Button>
</CardActions>
</Card>
</Grid>
))}
</Grid>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{result.author}
</Typography>
{result.viewCount && (
<Typography variant="caption" color="text.secondary">
{formatViewCount(result.viewCount)} {t('views')}
</Typography>
)}
</CardContent>
<CardActions sx={{ p: 2, pt: 0 }}>
<Button
fullWidth
variant="contained"
startIcon={<Download />}
onClick={() => handleDownload(result.id, result.sourceUrl)}
disabled={downloadingId === result.id}
>
{t('download')}
</Button>
</CardActions>
</Card>
</Grid>
))}
</Grid>
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'center' }}>
<Button
variant="outlined"
onClick={loadMoreSearchResults}
disabled={loadingMore}
startIcon={loadingMore ? <CircularProgress size={20} color="inherit" /> : null}
>
{loadingMore ? t('loading') : t('more')}
</Button>
</Box>
</>
) : (
<Typography color="text.secondary">{t('noYouTubeResults')}</Typography>
)}

View File

@@ -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 = () => {
<Typography sx={{ mt: 2 }}>Loading YouTube results...</Typography>
</Box>
) : hasYouTubeResults ? (
<Grid container spacing={3}>
{searchResults.map((result) => <Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={result.id}>
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ position: 'relative', paddingTop: '56.25%' }}>
<CardMedia
component="img"
image={result.thumbnailUrl || 'https://via.placeholder.com/480x360?text=No+Thumbnail'}
alt={result.title}
sx={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.onerror = null;
target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
}}
/>
{result.duration && (
<Chip
label={formatDuration(result.duration)}
size="small"
sx={{ position: 'absolute', bottom: 8, right: 8, bgcolor: 'rgba(0,0,0,0.8)', color: 'white' }}
<>
<Grid container spacing={3}>
{searchResults.map((result) => <Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={result.id}>
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ position: 'relative', paddingTop: '56.25%' }}>
<CardMedia
component="img"
image={result.thumbnailUrl || 'https://via.placeholder.com/480x360?text=No+Thumbnail'}
alt={result.title}
sx={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.onerror = null;
target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
}}
/>
)}
<Box sx={{ position: 'absolute', top: 8, right: 8, bgcolor: 'rgba(0,0,0,0.7)', borderRadius: '50%', p: 0.5, display: 'flex' }}>
{result.source === 'bilibili' ? <OndemandVideo sx={{ color: '#23ade5' }} /> : <YouTube sx={{ color: '#ff0000' }} />}
{result.duration && (
<Chip
label={formatDuration(result.duration)}
size="small"
sx={{ position: 'absolute', bottom: 8, right: 8, bgcolor: 'rgba(0,0,0,0.8)', color: 'white' }}
/>
)}
<Box sx={{ position: 'absolute', top: 8, right: 8, bgcolor: 'rgba(0,0,0,0.7)', borderRadius: '50%', p: 0.5, display: 'flex' }}>
{result.source === 'bilibili' ? <OndemandVideo sx={{ color: '#23ade5' }} /> : <YouTube sx={{ color: '#ff0000' }} />}
</Box>
</Box>
</Box>
<CardContent sx={{ flexGrow: 1, p: 2 }}>
<Typography gutterBottom variant="subtitle1" component="div" sx={{ fontWeight: 600, lineHeight: 1.2, mb: 1, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{result.title}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{result.author}
</Typography>
{result.viewCount && (
<Typography variant="caption" color="text.secondary">
{formatViewCount(result.viewCount)} views
<CardContent sx={{ flexGrow: 1, p: 2 }}>
<Typography gutterBottom variant="subtitle1" component="div" sx={{ fontWeight: 600, lineHeight: 1.2, mb: 1, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{result.title}
</Typography>
)}
</CardContent>
<CardActions sx={{ p: 2, pt: 0 }}>
<Button
fullWidth
variant="contained"
startIcon={<Download />}
onClick={() => handleDownload(result.id, result.sourceUrl)}
disabled={downloadingId === result.id}
>
Download
</Button>
</CardActions>
</Card>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{result.author}
</Typography>
{result.viewCount && (
<Typography variant="caption" color="text.secondary">
{formatViewCount(result.viewCount)} views
</Typography>
)}
</CardContent>
<CardActions sx={{ p: 2, pt: 0 }}>
<Button
fullWidth
variant="contained"
startIcon={<Download />}
onClick={() => handleDownload(result.id, result.sourceUrl)}
disabled={downloadingId === result.id}
>
Download
</Button>
</CardActions>
</Card>
</Grid>
)}
</Grid>
)}
</Grid>
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'center' }}>
<Button
variant="outlined"
onClick={loadMoreSearchResults}
disabled={loadingMore}
startIcon={loadingMore ? <CircularProgress size={20} color="inherit" /> : null}
>
{loadingMore ? t('loading') : t('more')}
</Button>
</Box>
</>
) : (
<Typography color="text.secondary">No YouTube results found.</Typography>
)}

View File

@@ -444,4 +444,5 @@ export const ar = {
customize: "تخصيص",
hide: "إخفاء",
reset: "إعادة تعيين",
more: "المزيد",
};

View File

@@ -425,4 +425,5 @@ export const de = {
customize: "Anpassen",
hide: "Ausblenden",
reset: "Zurücksetzen",
more: "Mehr",
};

View File

@@ -453,4 +453,5 @@ export const en = {
customize: "Customize",
hide: "Hide",
reset: "Reset",
more: "More",
};

View File

@@ -431,4 +431,5 @@ export const es = {
customize: "Personalizar",
hide: "Ocultar",
reset: "Restablecer",
more: "Más",
};

View File

@@ -463,4 +463,5 @@ export const fr = {
customize: "Personnaliser",
hide: "Masquer",
reset: "Réinitialiser",
more: "Plus",
};

View File

@@ -452,4 +452,5 @@ export const ja = {
customize: "カスタマイズ",
hide: "隠す",
reset: "リセット",
more: "もっと見る",
};

View File

@@ -446,4 +446,5 @@ export const ko = {
customize: "사용자 지정",
hide: "숨기기",
reset: "초기화",
more: "더 보기",
};

View File

@@ -457,4 +457,5 @@ export const pt = {
customize: "Personalizar",
hide: "Ocultar",
reset: "Redefinir",
more: "Mais",
};

View File

@@ -451,4 +451,5 @@ export const ru = {
customize: "Настроить",
hide: "Скрыть",
reset: "Сбросить",
more: "Ещё",
};

View File

@@ -442,4 +442,5 @@ export const zh = {
customize: "自定义",
hide: "隐藏",
reset: "重置",
more: "更多",
};