From dc7b0a4478402f2b71fc0ea2944b8f1189dc605d Mon Sep 17 00:00:00 2001 From: Peifan Li Date: Sun, 23 Nov 2025 10:55:47 -0500 Subject: [PATCH] feat: Add settings functionality and settings page --- backend/data/settings.json | 8 + backend/package-lock.json | 18 ++ backend/package.json | 2 + backend/src/controllers/settingsController.ts | 107 +++++++ backend/src/routes/settingsRoutes.ts | 10 + backend/src/server.ts | 2 + backend/src/services/downloadManager.ts | 9 + frontend/src/App.tsx | 281 +++++++++++------- frontend/src/components/Header.tsx | 70 ++++- frontend/src/pages/Home.tsx | 11 - frontend/src/pages/LoginPage.tsx | 108 +++++++ frontend/src/pages/ManagePage.tsx | 2 +- frontend/src/pages/SettingsPage.tsx | 218 ++++++++++++++ frontend/src/pages/VideoPlayer.tsx | 26 ++ 14 files changed, 746 insertions(+), 126 deletions(-) create mode 100644 backend/data/settings.json create mode 100644 backend/src/controllers/settingsController.ts create mode 100644 backend/src/routes/settingsRoutes.ts create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/SettingsPage.tsx diff --git a/backend/data/settings.json b/backend/data/settings.json new file mode 100644 index 0000000..a01a7cc --- /dev/null +++ b/backend/data/settings.json @@ -0,0 +1,8 @@ +{ + "loginEnabled": false, + "defaultAutoPlay": false, + "defaultAutoLoop": false, + "maxConcurrentDownloads": 3, + "isPasswordSet": true, + "password": "$2b$10$1vONfSGZSusSlGf3Vng2UOX8lcmRxtHkTm6eWnP8FlJ19E.QHKNC." +} diff --git a/backend/package-lock.json b/backend/package-lock.json index 8422843..37d1a8b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "axios": "^1.8.1", + "bcryptjs": "^3.0.3", "bilibili-save-nodejs": "^1.0.0", "cors": "^2.8.5", "dotenv": "^16.4.7", @@ -20,6 +21,7 @@ "youtube-dl-exec": "^2.4.17" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.19", "@types/express": "^5.0.5", "@types/fs-extra": "^11.0.4", @@ -99,6 +101,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -421,6 +430,15 @@ ], "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/bilibili-save-nodejs": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/bilibili-save-nodejs/-/bilibili-save-nodejs-1.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 163f17b..a0e287c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,6 +14,7 @@ "description": "Backend for MyTube video streaming website", "dependencies": { "axios": "^1.8.1", + "bcryptjs": "^3.0.3", "bilibili-save-nodejs": "^1.0.0", "cors": "^2.8.5", "dotenv": "^16.4.7", @@ -24,6 +25,7 @@ "youtube-dl-exec": "^2.4.17" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.19", "@types/express": "^5.0.5", "@types/fs-extra": "^11.0.4", diff --git a/backend/src/controllers/settingsController.ts b/backend/src/controllers/settingsController.ts new file mode 100644 index 0000000..f0da529 --- /dev/null +++ b/backend/src/controllers/settingsController.ts @@ -0,0 +1,107 @@ +import bcrypt from 'bcryptjs'; +import { Request, Response } from 'express'; +import fs from 'fs-extra'; +import path from 'path'; +import downloadManager from '../services/downloadManager'; + +const SETTINGS_FILE = path.join(__dirname, '../../data/settings.json'); + +interface Settings { + loginEnabled: boolean; + password?: string; + defaultAutoPlay: boolean; + defaultAutoLoop: boolean; + maxConcurrentDownloads: number; +} + +const defaultSettings: Settings = { + loginEnabled: false, + password: "", + defaultAutoPlay: false, + defaultAutoLoop: false, + maxConcurrentDownloads: 3 +}; + +export const getSettings = async (req: Request, res: Response) => { + try { + if (!fs.existsSync(SETTINGS_FILE)) { + await fs.writeJson(SETTINGS_FILE, defaultSettings, { spaces: 2 }); + return res.json(defaultSettings); + } + + const settings = await fs.readJson(SETTINGS_FILE); + // Do not send the hashed password to the frontend + const { password, ...safeSettings } = settings; + res.json({ ...safeSettings, isPasswordSet: !!password }); + } catch (error) { + console.error('Error reading settings:', error); + res.status(500).json({ error: 'Failed to read settings' }); + } +}; + +export const updateSettings = async (req: Request, res: Response) => { + try { + const newSettings: Settings = req.body; + + // Validate settings if needed + if (newSettings.maxConcurrentDownloads < 1) { + newSettings.maxConcurrentDownloads = 1; + } + + // Handle password hashing + if (newSettings.password) { + // If password is provided, hash it + const salt = await bcrypt.genSalt(10); + newSettings.password = await bcrypt.hash(newSettings.password, salt); + } else { + // If password is empty/not provided, keep existing password if file exists + if (fs.existsSync(SETTINGS_FILE)) { + const existingSettings = await fs.readJson(SETTINGS_FILE); + newSettings.password = existingSettings.password; + } + } + + await fs.writeJson(SETTINGS_FILE, newSettings, { spaces: 2 }); + + // Apply settings immediately where possible + downloadManager.setMaxConcurrentDownloads(newSettings.maxConcurrentDownloads); + + res.json({ success: true, settings: { ...newSettings, password: undefined } }); + } catch (error) { + console.error('Error updating settings:', error); + res.status(500).json({ error: 'Failed to update settings' }); + } +}; + +export const verifyPassword = async (req: Request, res: Response) => { + try { + const { password } = req.body; + + if (!fs.existsSync(SETTINGS_FILE)) { + return res.json({ success: true }); + } + + const settings = await fs.readJson(SETTINGS_FILE); + + if (!settings.loginEnabled) { + return res.json({ success: true }); + } + + if (!settings.password) { + // If no password set but login enabled, allow access (or force set password?) + // For now, allow access + return res.json({ success: true }); + } + + const isMatch = await bcrypt.compare(password, settings.password); + + if (isMatch) { + res.json({ success: true }); + } else { + res.status(401).json({ success: false, error: 'Incorrect password' }); + } + } catch (error) { + console.error('Error verifying password:', error); + res.status(500).json({ error: 'Failed to verify password' }); + } +}; diff --git a/backend/src/routes/settingsRoutes.ts b/backend/src/routes/settingsRoutes.ts new file mode 100644 index 0000000..63c6f9b --- /dev/null +++ b/backend/src/routes/settingsRoutes.ts @@ -0,0 +1,10 @@ +import express from 'express'; +import { getSettings, updateSettings, verifyPassword } from '../controllers/settingsController'; + +const router = express.Router(); + +router.get('/', getSettings); +router.post('/', updateSettings); +router.post('/verify-password', verifyPassword); + +export default router; diff --git a/backend/src/server.ts b/backend/src/server.ts index 8270a05..65dad3c 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -6,6 +6,7 @@ import cors from "cors"; import express from "express"; import { IMAGES_DIR, VIDEOS_DIR } from "./config/paths"; import apiRoutes from "./routes/api"; +import settingsRoutes from './routes/settingsRoutes'; import * as storageService from "./services/storageService"; import { VERSION } from "./version"; @@ -29,6 +30,7 @@ app.use("/images", express.static(IMAGES_DIR)); // API Routes app.use("/api", apiRoutes); +app.use('/api/settings', settingsRoutes); // Start the server app.listen(PORT, () => { diff --git a/backend/src/services/downloadManager.ts b/backend/src/services/downloadManager.ts index aae4c99..ad9d34a 100644 --- a/backend/src/services/downloadManager.ts +++ b/backend/src/services/downloadManager.ts @@ -19,6 +19,15 @@ class DownloadManager { this.maxConcurrentDownloads = 3; } + /** + * Set the maximum number of concurrent downloads + * @param limit - Maximum number of concurrent downloads + */ + setMaxConcurrentDownloads(limit: number): void { + this.maxConcurrentDownloads = limit; + this.processQueue(); + } + /** * Add a download task to the manager * @param downloadFn - Async function that performs the download diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 06af0a9..468a987 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,8 +10,10 @@ import { useSnackbar } from './contexts/SnackbarContext'; import AuthorVideos from './pages/AuthorVideos'; import CollectionPage from './pages/CollectionPage'; import Home from './pages/Home'; +import LoginPage from './pages/LoginPage'; import ManagePage from './pages/ManagePage'; import SearchResults from './pages/SearchResults'; +import SettingsPage from './pages/SettingsPage'; import VideoPlayer from './pages/VideoPlayer'; import getTheme from './theme'; import { Collection, DownloadInfo, Video } from './types'; @@ -78,6 +80,11 @@ function App() { return (localStorage.getItem('theme') as 'light' | 'dark') || 'dark'; }); + // Login state + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [loginRequired, setLoginRequired] = useState(true); // Assume required until checked + const [checkingAuth, setCheckingAuth] = useState(true); + const theme = useMemo(() => getTheme(themeMode), [themeMode]); // Apply theme to body @@ -162,6 +169,44 @@ function App() { fetchVideos(); }, []); + // Check login settings and authentication status + useEffect(() => { + const checkAuth = async () => { + try { + // Check if login is enabled in settings + const response = await axios.get(`${API_URL}/settings`); + const { loginEnabled } = response.data; + + if (!loginEnabled) { + setLoginRequired(false); + setIsAuthenticated(true); + } else { + setLoginRequired(true); + // Check if already authenticated in this session + const sessionAuth = sessionStorage.getItem('mytube_authenticated'); + if (sessionAuth === 'true') { + setIsAuthenticated(true); + } else { + setIsAuthenticated(false); + } + } + } catch (error) { + console.error('Error checking auth settings:', error); + // If error, default to requiring login for security, but maybe allow if backend is down? + // Better to fail safe. + } finally { + setCheckingAuth(false); + } + }; + + checkAuth(); + }, []); + + const handleLoginSuccess = () => { + setIsAuthenticated(true); + sessionStorage.setItem('mytube_authenticated', 'true'); + }; + // Set up localStorage and event listeners useEffect(() => { console.log('Setting up localStorage and event listeners'); @@ -656,120 +701,132 @@ function App() { return ( - -
-
+ {!isAuthenticated && loginRequired ? ( + checkingAuth ? ( +
Loading...
+ ) : ( + + ) + ) : ( + +
+
- {/* Bilibili Parts Modal */} - setShowBilibiliPartsModal(false)} - videosNumber={bilibiliPartsInfo.videosNumber} - videoTitle={bilibiliPartsInfo.title} - onDownloadAll={handleDownloadAllBilibiliParts} - onDownloadCurrent={handleDownloadCurrentBilibiliPart} - isLoading={loading || isCheckingParts} - type={bilibiliPartsInfo.type} - /> + {/* Bilibili Parts Modal */} + setShowBilibiliPartsModal(false)} + videosNumber={bilibiliPartsInfo.videosNumber} + videoTitle={bilibiliPartsInfo.title} + onDownloadAll={handleDownloadAllBilibiliParts} + onDownloadCurrent={handleDownloadCurrentBilibiliPart} + isLoading={loading || isCheckingParts} + type={bilibiliPartsInfo.type} + /> -
- - - } - /> - - } - /> - - } - /> - - } - /> - - } - /> - - } - /> - -
-
-
-
+
+ + + } + /> + + } + /> + + } + /> + + } + /> + + } + /> + + } + /> + } + /> + +
+
+
+
+ )}
); } diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 0e53cf5..be40423 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -5,7 +5,9 @@ import { CloudUpload, Download, Menu as MenuIcon, - Search + Search, + Settings, + VideoLibrary } from '@mui/icons-material'; import { AppBar, @@ -69,6 +71,7 @@ const Header: React.FC = ({ const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(''); const [anchorEl, setAnchorEl] = useState(null); + const [manageAnchorEl, setManageAnchorEl] = useState(null); const [uploadModalOpen, setUploadModalOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const navigate = useNavigate(); @@ -90,6 +93,14 @@ const Header: React.FC = ({ setAnchorEl(null); }; + const handleManageClick = (event: React.MouseEvent) => { + setManageAnchorEl(event.currentTarget); + }; + + const handleManageClose = () => { + setManageAnchorEl(null); + }; + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); @@ -211,6 +222,50 @@ const Header: React.FC = ({ {currentThemeMode === 'dark' ? : } + + + + + + + + { handleManageClose(); navigate('/manage'); }}> + Manage Content + + { handleManageClose(); navigate('/settings'); }}> + Settings + + ); @@ -307,16 +362,27 @@ const Header: React.FC = ({ onItemClick={() => setMobileMenuOpen(false)} /> - + + diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 6ac4064..432fd56 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -15,7 +15,6 @@ import { Typography } from '@mui/material'; import { useEffect, useState } from 'react'; -import { Link } from 'react-router-dom'; import AuthorsList from '../components/AuthorsList'; import Collections from '../components/Collections'; import VideoCard from '../components/VideoCard'; @@ -277,16 +276,6 @@ const Home: React.FC = ({ - - - diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..67204b4 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,108 @@ +import { LockOutlined } from '@mui/icons-material'; +import { + Alert, + Avatar, + Box, + Button, + Container, + CssBaseline, + TextField, + ThemeProvider, + Typography +} from '@mui/material'; +import axios from 'axios'; +import React, { useState } from 'react'; +import getTheme from '../theme'; + +const API_URL = import.meta.env.VITE_API_URL; + +interface LoginPageProps { + onLoginSuccess: () => void; +} + +const LoginPage: React.FC = ({ onLoginSuccess }) => { + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + // Use dark theme for login page to match app style + const theme = getTheme('dark'); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + + try { + const response = await axios.post(`${API_URL}/settings/verify-password`, { password }); + if (response.data.success) { + onLoginSuccess(); + } else { + setError('Incorrect password'); + } + } catch (err: any) { + console.error('Login error:', err); + if (err.response && err.response.status === 401) { + setError('Incorrect password'); + } else { + setError('Failed to verify password. Please try again.'); + } + } finally { + setLoading(false); + } + }; + + return ( + + + + + + + + + Sign in + + + setPassword(e.target.value)} + autoFocus + /> + {error && ( + + {error} + + )} + + + + + + ); +}; + +export default LoginPage; diff --git a/frontend/src/pages/ManagePage.tsx b/frontend/src/pages/ManagePage.tsx index f6504db..bf3382d 100644 --- a/frontend/src/pages/ManagePage.tsx +++ b/frontend/src/pages/ManagePage.tsx @@ -123,7 +123,7 @@ const ManagePage: React.FC = ({ videos, onDeleteVideo, collecti }; return ( - + Manage Content diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..e382840 --- /dev/null +++ b/frontend/src/pages/SettingsPage.tsx @@ -0,0 +1,218 @@ +import { + ArrowBack, + Save +} from '@mui/icons-material'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Container, + Divider, + FormControlLabel, + Grid, + Slider, + Snackbar, + Switch, + TextField, + Typography +} from '@mui/material'; +import axios from 'axios'; +import React, { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; + +const API_URL = import.meta.env.VITE_API_URL; + +interface Settings { + loginEnabled: boolean; + password?: string; + isPasswordSet?: boolean; + defaultAutoPlay: boolean; + defaultAutoLoop: boolean; + maxConcurrentDownloads: number; +} + +const SettingsPage: React.FC = () => { + const [settings, setSettings] = useState({ + loginEnabled: false, + password: '', + defaultAutoPlay: false, + defaultAutoLoop: false, + maxConcurrentDownloads: 3 + }); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null); + + useEffect(() => { + fetchSettings(); + }, []); + + const fetchSettings = async () => { + try { + const response = await axios.get(`${API_URL}/settings`); + setSettings(response.data); + } catch (error) { + console.error('Error fetching settings:', error); + setMessage({ text: 'Failed to load settings', type: 'error' }); + } finally { + // Loading finished + } + }; + + const handleSave = async () => { + setSaving(true); + try { + // Only send password if it has been changed (is not empty) + const settingsToSend = { ...settings }; + if (!settingsToSend.password) { + delete settingsToSend.password; + } + + await axios.post(`${API_URL}/settings`, settingsToSend); + setMessage({ text: 'Settings saved successfully', type: 'success' }); + + // Clear password field after save + setSettings(prev => ({ ...prev, password: '', isPasswordSet: true })); + } catch (error) { + console.error('Error saving settings:', error); + setMessage({ text: 'Failed to save settings', type: 'error' }); + } finally { + setSaving(false); + } + }; + + const handleChange = (field: keyof Settings, value: any) => { + setSettings(prev => ({ ...prev, [field]: value })); + }; + + return ( + + + + Settings + + + + + + + + {/* Security Settings */} + + Security + handleChange('loginEnabled', e.target.checked)} + /> + } + label="Enable Login Protection" + /> + + {settings.loginEnabled && ( + + handleChange('password', e.target.value)} + helperText={ + settings.isPasswordSet + ? "Leave empty to keep current password, or type to change" + : "Set a password for accessing the application" + } + /> + + )} + + + + + {/* Video Defaults */} + + Video Player Defaults + + handleChange('defaultAutoPlay', e.target.checked)} + /> + } + label="Auto-play Videos" + /> + handleChange('defaultAutoLoop', e.target.checked)} + /> + } + label="Auto-loop Videos" + /> + + + + + + {/* Download Settings */} + + Download Settings + + Max Concurrent Downloads: {settings.maxConcurrentDownloads} + + + handleChange('maxConcurrentDownloads', value)} + min={1} + max={10} + step={1} + marks + valueLabelDisplay="auto" + /> + + + + + + + + + + + + + setMessage(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setMessage(null)}> + {message?.text} + + + + ); +}; + +export default SettingsPage; diff --git a/frontend/src/pages/VideoPlayer.tsx b/frontend/src/pages/VideoPlayer.tsx index d5e7b0a..3949e88 100644 --- a/frontend/src/pages/VideoPlayer.tsx +++ b/frontend/src/pages/VideoPlayer.tsx @@ -155,8 +155,34 @@ const VideoPlayer: React.FC = ({ }; fetchVideo(); + fetchVideo(); }, [id, videos, navigate, isDeleting]); + // Fetch settings and apply defaults + useEffect(() => { + const fetchSettings = async () => { + try { + const response = await axios.get(`${API_URL}/settings`); + const { defaultAutoPlay, defaultAutoLoop } = response.data; + + if (videoRef.current) { + if (defaultAutoPlay) { + videoRef.current.autoplay = true; + setIsPlaying(true); + } + if (defaultAutoLoop) { + videoRef.current.loop = true; + setIsLooping(true); + } + } + } catch (error) { + console.error('Error fetching settings:', error); + } + }; + + fetchSettings(); + }, [id]); // Re-run when video changes + // Fetch comments useEffect(() => { const fetchComments = async () => {