feat: Add showYoutubeSearch feature

This commit is contained in:
Peifan Li
2025-12-11 16:50:12 -05:00
parent 02e91fc6af
commit 11de4878c5
11 changed files with 164 additions and 125 deletions

View File

@@ -27,6 +27,7 @@ interface Settings {
websiteName?: string;
itemsPerPage?: number;
ytDlpConfig?: string;
showYoutubeSearch?: boolean;
}
const defaultSettings: Settings = {
@@ -44,6 +45,7 @@ const defaultSettings: Settings = {
subtitlesEnabled: true,
websiteName: "MyTube",
itemsPerPage: 12,
showYoutubeSearch: true,
};
export const getSettings = async (_req: Request, res: Response) => {

View File

@@ -1,4 +1,4 @@
import { Box, FormControl, InputLabel, MenuItem, Select, TextField, Typography } from '@mui/material';
import { Box, FormControl, FormControlLabel, InputLabel, MenuItem, Select, Switch, TextField, Typography } from '@mui/material';
import React from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
@@ -6,11 +6,12 @@ interface GeneralSettingsProps {
language: string;
websiteName?: string;
itemsPerPage?: number;
onChange: (field: string, value: string | number) => void;
showYoutubeSearch?: boolean;
onChange: (field: string, value: string | number | boolean) => void;
}
const GeneralSettings: React.FC<GeneralSettingsProps> = (props) => {
const { language, websiteName, onChange } = props;
const { language, websiteName, showYoutubeSearch, onChange } = props;
const { t } = useLanguage();
return (
@@ -63,6 +64,16 @@ const GeneralSettings: React.FC<GeneralSettingsProps> = (props) => {
helperText={t('itemsPerPageHelper') || "Number of videos to show per page (Default: 12)"}
slotProps={{ htmlInput: { min: 1 } }}
/>
<FormControlLabel
control={
<Switch
checked={showYoutubeSearch ?? true}
onChange={(e) => onChange('showYoutubeSearch', e.target.checked)}
/>
}
label={t('showYoutubeSearch') || "Show YouTube Search Results"}
/>
</Box>
</Box>
);

View File

@@ -25,9 +25,6 @@ const TagsSettings: React.FC<TagsSettingsProps> = ({ tags, onTagsChange }) => {
return (
<Box>
<Typography variant="h6" gutterBottom>{t('tagsManagement') || 'Tags Management'}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{t('tagsManagementNote') || 'Please remember to click "Save Settings" after adding or removing tags to apply changes.'}
</Typography>
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap' }}>
{tags && tags.map((tag) => (
<Chip

View File

@@ -28,6 +28,7 @@ interface VideoContextType {
availableTags: string[];
selectedTags: string[];
handleTagToggle: (tag: string) => void;
showYoutubeSearch: boolean;
}
const VideoContext = createContext<VideoContextType | undefined>(undefined);
@@ -63,17 +64,20 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
retryDelay: 1000,
});
// Tags Query
const { data: availableTags = [] } = useQuery({
queryKey: ['tags'],
// Settings Query (tags and showYoutubeSearch)
const { data: settingsData } = useQuery({
queryKey: ['settings'],
queryFn: async () => {
const response = await axios.get(`${API_URL}/settings`);
return response.data.tags || [];
return response.data;
},
retry: 10,
retryDelay: 1000,
});
const availableTags = settingsData?.tags || [];
const showYoutubeSearch = settingsData?.showYoutubeSearch ?? true;
const [selectedTags, setSelectedTags] = useState<string[]>([]);
// Search state
@@ -168,25 +172,32 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
const localResults = searchLocalVideos(query);
setLocalSearchResults(localResults);
setYoutubeLoading(true);
// Only search YouTube if showYoutubeSearch is enabled
if (showYoutubeSearch) {
setYoutubeLoading(true);
try {
const response = await axios.get(`${API_URL}/search`, {
params: { query },
signal: signal
});
try {
const response = await axios.get(`${API_URL}/search`, {
params: { query },
signal: signal
});
if (!signal.aborted) {
setSearchResults(response.data.results);
}
} catch (youtubeErr: any) {
if (youtubeErr.name !== 'CanceledError' && youtubeErr.name !== 'AbortError') {
console.error('Error searching YouTube:', youtubeErr);
}
} finally {
if (!signal.aborted) {
setYoutubeLoading(false);
if (!signal.aborted) {
setSearchResults(response.data.results);
}
} catch (youtubeErr: any) {
if (youtubeErr.name !== 'CanceledError' && youtubeErr.name !== 'AbortError') {
console.error('Error searching YouTube:', youtubeErr);
}
} finally {
if (!signal.aborted) {
setYoutubeLoading(false);
}
}
} else {
// Clear any existing YouTube results when disabled
setSearchResults([]);
setYoutubeLoading(false);
}
return { success: true };
@@ -311,7 +322,8 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
setIsSearchMode,
availableTags,
selectedTags,
handleTagToggle
handleTagToggle,
showYoutubeSearch
}}>
{children}
</VideoContext.Provider>

View File

@@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}

View File

@@ -29,7 +29,8 @@ const SearchPage: React.FC = () => {
searchResults,
youtubeLoading,
handleSearch,
searchTerm: contextSearchTerm
searchTerm: contextSearchTerm,
showYoutubeSearch
} = useVideo();
const { collections } = useCollection();
const { handleVideoSubmit } = useDownload();
@@ -95,6 +96,7 @@ const SearchPage: React.FC = () => {
</Box>
{/* YouTube Search Results */}
{showYoutubeSearch && (
<Box>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600, color: '#ff0000' }}>
{t('fromYouTube')}
@@ -164,6 +166,7 @@ const SearchPage: React.FC = () => {
<Typography color="text.secondary">{t('noYouTubeResults')}</Typography>
)}
</Box>
)}
</Container>
);
};

View File

@@ -29,7 +29,8 @@ const SearchResults: React.FC = () => {
youtubeLoading,
deleteVideo,
resetSearch,
setIsSearchMode
setIsSearchMode,
showYoutubeSearch
} = useVideo();
const { collections } = useCollection();
const { handleVideoSubmit } = useDownload();
@@ -75,8 +76,8 @@ const SearchResults: React.FC = () => {
}
const hasLocalResults = localSearchResults && localSearchResults.length > 0;
const hasYouTubeResults = searchResults && searchResults.length > 0;
const noResults = !hasLocalResults && !hasYouTubeResults && !youtubeLoading;
const hasYouTubeResults = showYoutubeSearch && searchResults && searchResults.length > 0;
const noResults = !hasLocalResults && !hasYouTubeResults && (!showYoutubeSearch || !youtubeLoading);
// Helper function to format view count
const formatViewCount = (count?: number) => {
@@ -130,74 +131,76 @@ const SearchResults: React.FC = () => {
</Box>
{/* YouTube Search Results */}
<Box>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600, color: '#ff0000' }}>
From YouTube
</Typography>
{showYoutubeSearch && (
<Box>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600, color: '#ff0000' }}>
From YouTube
</Typography>
{youtubeLoading ? (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 4 }}>
<CircularProgress color="error" />
<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' }}
{youtubeLoading ? (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 4 }}>
<CircularProgress color="error" />
<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';
}}
/>
)}
<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.sourceUrl)}
>
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.sourceUrl)}
>
Download
</Button>
</CardActions>
</Card>
</Grid>
)}
</Grid>
)}
</Grid>
) : (
<Typography color="text.secondary">No YouTube results found.</Typography>
)}
</Box>
) : (
<Typography color="text.secondary">No YouTube results found.</Typography>
)}
</Box>
)}
</Container>
);
};

View File

@@ -1,10 +1,7 @@
import {
Save
} from '@mui/icons-material';
import {
Alert,
Box,
Button,
Card,
CardContent,
Container,
@@ -13,7 +10,7 @@ import {
Snackbar,
Typography
} from '@mui/material';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery } from '@tanstack/react-query';
import axios from 'axios';
import React, { useEffect, useState } from 'react';
import ConfirmationModal from '../components/ConfirmationModal';
@@ -29,6 +26,7 @@ import VideoDefaultSettings from '../components/Settings/VideoDefaultSettings';
import YtDlpSettings from '../components/Settings/YtDlpSettings';
import { useDownload } from '../contexts/DownloadContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useDebounce } from '../hooks/useDebounce';
import { Settings } from '../types';
import ConsoleManager from '../utils/consoleManager';
import { SNACKBAR_AUTO_HIDE_DURATION } from '../utils/constants';
@@ -37,7 +35,6 @@ import { Language } from '../utils/translations';
const API_URL = import.meta.env.VITE_API_URL;
const SettingsPage: React.FC = () => {
const queryClient = useQueryClient();
const { t, setLanguage } = useLanguage();
const { activeDownloads } = useDownload();
@@ -54,9 +51,12 @@ const SettingsPage: React.FC = () => {
openListToken: '',
cloudDrivePath: '',
itemsPerPage: 12,
ytDlpConfig: ''
ytDlpConfig: '',
showYoutubeSearch: true
});
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' | 'warning' | 'info' } | null>(null);
const debouncedSettings = useDebounce(settings, 1000);
const [isFirstLoad, setIsFirstLoad] = useState(true);
// Modal states
const [showDeleteLegacyModal, setShowDeleteLegacyModal] = useState(false);
@@ -82,13 +82,21 @@ const SettingsPage: React.FC = () => {
});
useEffect(() => {
if (settingsData) {
if (settingsData && isFirstLoad) {
setSettings({
...settingsData,
tags: settingsData.tags || []
});
setIsFirstLoad(false);
}
}, [settingsData]);
}, [settingsData, isFirstLoad]);
// Autosave effect
useEffect(() => {
if (!isFirstLoad) {
saveMutation.mutate(debouncedSettings);
}
}, [debouncedSettings]);
// Save settings mutation
const saveMutation = useMutation({
@@ -101,19 +109,15 @@ const SettingsPage: React.FC = () => {
await axios.post(`${API_URL}/settings`, settingsToSend);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings'] });
// Do not invalidate queries to prevent overwriting user input while typing
setMessage({ text: t('settingsSaved'), type: 'success' });
// Clear password field after save
setSettings(prev => ({ ...prev, password: '', isPasswordSet: true }));
},
onError: () => {
setMessage({ text: t('settingsFailed'), type: 'error' });
}
});
const handleSave = () => {
saveMutation.mutate(settings);
};
// Migrate data mutation
const migrateMutation = useMutation({
@@ -302,6 +306,7 @@ const SettingsPage: React.FC = () => {
language={settings.language}
websiteName={settings.websiteName}
itemsPerPage={settings.itemsPerPage}
showYoutubeSearch={settings.showYoutubeSearch}
onChange={(field, value) => handleChange(field as keyof Settings, value)}
/>
</Grid>
@@ -401,19 +406,8 @@ const SettingsPage: React.FC = () => {
/>
</Grid>
<Grid size={12}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
<Button
variant="contained"
size="large"
startIcon={<Save />}
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? t('saving') : t('saveSettings')}
</Button>
</Box>
</Grid>
</Grid>
</CardContent>
</Card>

View File

@@ -72,4 +72,5 @@ export interface Settings {
websiteName?: string;
itemsPerPage?: number;
ytDlpConfig?: string;
showYoutubeSearch?: boolean;
}

View File

@@ -52,9 +52,6 @@ export const en = {
tagsManagement: "Tags Management",
newTag: "New Tag",
tags: "Tags",
tagsManagementNote:
'Please remember to click "Save Settings" after adding or removing tags to apply changes.',
// Database
database: "Database",
migrateDataDescription:
@@ -107,6 +104,7 @@ export const en = {
formatFilenamesError: "Failed to format filenames: {error}",
itemsPerPage: "Items Per Page",
itemsPerPageHelper: "Number of videos to show per page (Default: 12)",
showYoutubeSearch: "Show YouTube Search Results",
cleanupTempFilesSuccess: "Successfully deleted {count} temporary file(s).",
cleanupTempFilesFailed: "Failed to clean up temporary files",

View File

@@ -104,6 +104,7 @@ export const zh = {
"有活动下载时无法清理。请等待所有下载完成或取消它们。",
itemsPerPage: "每页显示数量",
itemsPerPageHelper: "每页显示的视频数量 (默认: 12)",
showYoutubeSearch: "显示 YouTube 搜索结果",
cleanupTempFilesSuccess: "成功删除了 {count} 个临时文件。",
cleanupTempFilesFailed: "清理临时文件失败",