Spaces:
Paused
Paused
| import React, { useState, useEffect, useRef } from 'react'; | |
| import { | |
| Container, | |
| Paper, | |
| TextField, | |
| Button, | |
| Select, | |
| MenuItem, | |
| FormControl, | |
| InputLabel, | |
| Typography, | |
| Box, | |
| Grid, | |
| Card, | |
| CardContent, | |
| LinearProgress, | |
| Chip, | |
| IconButton, | |
| Alert, | |
| Snackbar, | |
| Dialog, | |
| DialogTitle, | |
| DialogContent, | |
| DialogActions, | |
| List, | |
| ListItem, | |
| ListItemText, | |
| ListItemSecondaryAction, | |
| Tabs, | |
| Tab, | |
| Switch, | |
| FormControlLabel | |
| } from '@mui/material'; | |
| import { | |
| CloudUpload, | |
| Cancel, | |
| CheckCircle, | |
| Error, | |
| Refresh, | |
| Settings, | |
| Visibility, | |
| VisibilityOff | |
| } from '@mui/icons-material'; | |
| import axios from 'axios'; | |
| import io from 'socket.io-client'; | |
| import { v4 as uuidv4 } from 'uuid'; | |
| const API_URL = process.env.REACT_APP_API_URL || ''; | |
| const SOCKET_URL = process.env.REACT_APP_SOCKET_URL || ''; | |
| function App() { | |
| const [vodUrl, setVodUrl] = useState(''); | |
| const [selectedProvider, setSelectedProvider] = useState('mega'); | |
| const [selectedFormat, setSelectedFormat] = useState('best'); | |
| const [providers, setProviders] = useState([]); | |
| const [formats, setFormats] = useState([]); | |
| const [credentials, setCredentials] = useState({ | |
| mega: { email: '', password: '' }, | |
| filen: { email: '', password: '' }, | |
| drime: { email: '', password: '' } | |
| }); | |
| const [tasks, setTasks] = useState([]); | |
| const [activeTab, setActiveTab] = useState(0); | |
| const [showPassword, setShowPassword] = useState(false); | |
| const [alert, setAlert] = useState({ open: false, message: '', severity: 'info' }); | |
| const [loading, setLoading] = useState(false); | |
| const [autoScroll, setAutoScroll] = useState(true); | |
| const socketRef = useRef(null); | |
| const userIdRef = useRef(localStorage.getItem('userId') || uuidv4()); | |
| useEffect(() => { | |
| // Save user ID | |
| localStorage.setItem('userId', userIdRef.current); | |
| // Load providers | |
| loadProviders(); | |
| // Load tasks | |
| loadTasks(); | |
| // Initialize socket connection | |
| socketRef.current = io(SOCKET_URL); | |
| socketRef.current.on('connect', () => { | |
| console.log('Connected to server'); | |
| }); | |
| socketRef.current.on('task_update', (data) => { | |
| updateTask(data.task_id, data); | |
| }); | |
| return () => { | |
| if (socketRef.current) { | |
| socketRef.current.disconnect(); | |
| } | |
| }; | |
| }, []); | |
| const loadProviders = async () => { | |
| try { | |
| const response = await axios.get(`${API_URL}/api/providers`); | |
| setProviders(response.data.providers); | |
| } catch (error) { | |
| showAlert('Failed to load providers', 'error'); | |
| } | |
| }; | |
| const loadTasks = async () => { | |
| try { | |
| const response = await axios.get(`${API_URL}/api/tasks`, { | |
| headers: { 'X-User-ID': userIdRef.current } | |
| }); | |
| setTasks(response.data.tasks); | |
| // Subscribe to active tasks | |
| response.data.tasks.forEach(task => { | |
| if (task.status === 'processing' || task.status === 'queued') { | |
| socketRef.current.emit('subscribe_task', { task_id: task.id }); | |
| } | |
| }); | |
| } catch (error) { | |
| showAlert('Failed to load tasks', 'error'); | |
| } | |
| }; | |
| const loadFormats = async () => { | |
| if (!vodUrl) return; | |
| setLoading(true); | |
| try { | |
| const response = await axios.get(`${API_URL}/api/formats/${encodeURIComponent(vodUrl)}`); | |
| setFormats(response.data.formats); | |
| showAlert(`Found ${response.data.formats.length} quality options`, 'success'); | |
| } catch (error) { | |
| showAlert('Failed to load formats', 'error'); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| const validateCredentials = async () => { | |
| const creds = credentials[selectedProvider]; | |
| if (!creds.email || !creds.password) { | |
| showAlert('Please enter credentials', 'warning'); | |
| return; | |
| } | |
| setLoading(true); | |
| try { | |
| const response = await axios.post(`${API_URL}/api/validate-credentials`, { | |
| provider: selectedProvider, | |
| credentials: creds | |
| }); | |
| showAlert(response.data.message, response.data.valid ? 'success' : 'error'); | |
| } catch (error) { | |
| showAlert('Validation failed', 'error'); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| const startArchiving = async () => { | |
| const creds = credentials[selectedProvider]; | |
| if (!vodUrl || !creds.email || !creds.password) { | |
| showAlert('Please fill all required fields', 'warning'); | |
| return; | |
| } | |
| setLoading(true); | |
| try { | |
| const response = await axios.post(`${API_URL}/api/tasks`, { | |
| vod_url: vodUrl, | |
| provider: selectedProvider, | |
| format_id: selectedFormat, | |
| credentials: creds | |
| }, { | |
| headers: { 'X-User-ID': userIdRef.current } | |
| }); | |
| const newTask = { | |
| id: response.data.task_id, | |
| vod_url: vodUrl, | |
| provider: selectedProvider, | |
| status: 'queued', | |
| progress_data: { phase: 'queued', percent: 0 }, | |
| created_at: new Date().toISOString() | |
| }; | |
| setTasks([newTask, ...tasks]); | |
| // Subscribe to task updates | |
| socketRef.current.emit('subscribe_task', { task_id: response.data.task_id }); | |
| showAlert('Task started successfully', 'success'); | |
| // Clear form | |
| setVodUrl(''); | |
| setSelectedFormat('best'); | |
| } catch (error) { | |
| showAlert('Failed to start task', 'error'); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| const cancelTask = async (taskId) => { | |
| try { | |
| await axios.delete(`${API_URL}/api/tasks/${taskId}`); | |
| updateTask(taskId, { status: 'cancelled' }); | |
| showAlert('Task cancelled', 'info'); | |
| } catch (error) { | |
| showAlert('Failed to cancel task', 'error'); | |
| } | |
| }; | |
| const updateTask = (taskId, updates) => { | |
| setTasks(prevTasks => | |
| prevTasks.map(task => | |
| task.id === taskId ? { ...task, ...updates } : task | |
| ) | |
| ); | |
| }; | |
| const showAlert = (message, severity = 'info') => { | |
| setAlert({ open: true, message, severity }); | |
| }; | |
| const getStatusColor = (status) => { | |
| switch (status) { | |
| case 'completed': return 'success'; | |
| case 'failed': return 'error'; | |
| case 'processing': return 'primary'; | |
| case 'cancelled': return 'default'; | |
| default: return 'default'; | |
| } | |
| }; | |
| const getStatusIcon = (status) => { | |
| switch (status) { | |
| case 'completed': return <CheckCircle />; | |
| case 'failed': return <Error />; | |
| case 'cancelled': return <Cancel />; | |
| default: return null; | |
| } | |
| }; | |
| return ( | |
| <Container maxWidth="lg" sx={{ py: 4 }}> | |
| <Paper elevation={3} sx={{ p: 4, mb: 4 }}> | |
| <Typography variant="h4" component="h1" gutterBottom align="center"> | |
| VOD Archiver Pro | |
| </Typography> | |
| <Typography variant="subtitle1" align="center" color="text.secondary" gutterBottom> | |
| Download and archive Twitch VODs to multiple cloud providers | |
| </Typography> | |
| <Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}> | |
| <Tabs value={activeTab} onChange={(e, v) => setActiveTab(v)}> | |
| <Tab label="New Task" /> | |
| <Tab label="Active Tasks" /> | |
| <Tab label="History" /> | |
| </Tabs> | |
| </Box> | |
| {activeTab === 0 && ( | |
| <Box> | |
| <Grid container spacing={3}> | |
| <Grid item xs={12}> | |
| <TextField | |
| fullWidth | |
| label="Twitch VOD URL" | |
| value={vodUrl} | |
| onChange={(e) => setVodUrl(e.target.value)} | |
| placeholder="https://www.twitch.tv/videos/123456789" | |
| InputProps={{ | |
| endAdornment: ( | |
| <Button onClick={loadFormats} disabled={!vodUrl || loading}> | |
| Load Formats | |
| </Button> | |
| ) | |
| }} | |
| /> | |
| </Grid> | |
| <Grid item xs={12} md={6}> | |
| <FormControl fullWidth> | |
| <InputLabel>Provider</InputLabel> | |
| <Select | |
| value={selectedProvider} | |
| onChange={(e) => setSelectedProvider(e.target.value)} | |
| > | |
| {providers.map(provider => ( | |
| <MenuItem key={provider.id} value={provider.id}> | |
| {provider.name} - {provider.maxSize} | |
| </MenuItem> | |
| ))} | |
| </Select> | |
| </FormControl> | |
| </Grid> | |
| <Grid item xs={12} md={6}> | |
| <FormControl fullWidth> | |
| <InputLabel>Quality</InputLabel> | |
| <Select | |
| value={selectedFormat} | |
| onChange={(e) => setSelectedFormat(e.target.value)} | |
| > | |
| {formats.length === 0 ? ( | |
| <MenuItem value="best">Best Quality (Source)</MenuItem> | |
| ) : ( | |
| formats.map(format => ( | |
| <MenuItem key={format.format_id} value={format.format_id}> | |
| {format.label} | |
| </MenuItem> | |
| )) | |
| )} | |
| </Select> | |
| </FormControl> | |
| </Grid> | |
| <Grid item xs={12} md={6}> | |
| <TextField | |
| fullWidth | |
| label="Email" | |
| type="email" | |
| value={credentials[selectedProvider].email} | |
| onChange={(e) => setCredentials({ | |
| ...credentials, | |
| [selectedProvider]: { | |
| ...credentials[selectedProvider], | |
| email: e.target.value | |
| } | |
| })} | |
| /> | |
| </Grid> | |
| <Grid item xs={12} md={6}> | |
| <TextField | |
| fullWidth | |
| label="Password" | |
| type={showPassword ? 'text' : 'password'} | |
| value={credentials[selectedProvider].password} | |
| onChange={(e) => setCredentials({ | |
| ...credentials, | |
| [selectedProvider]: { | |
| ...credentials[selectedProvider], | |
| password: e.target.value | |
| } | |
| })} | |
| InputProps={{ | |
| endAdornment: ( | |
| <IconButton onClick={() => setShowPassword(!showPassword)}> | |
| {showPassword ? <VisibilityOff /> : <Visibility />} | |
| </IconButton> | |
| ) | |
| }} | |
| /> | |
| </Grid> | |
| <Grid item xs={12}> | |
| <Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}> | |
| <Button | |
| variant="contained" | |
| size="large" | |
| onClick={startArchiving} | |
| disabled={loading} | |
| startIcon={<CloudUpload />} | |
| > | |
| Start Archiving | |
| </Button> | |
| <Button | |
| variant="outlined" | |
| size="large" | |
| onClick={validateCredentials} | |
| disabled={loading} | |
| > | |
| Validate Credentials | |
| </Button> | |
| </Box> | |
| </Grid> | |
| </Grid> | |
| </Box> | |
| )} | |
| {(activeTab === 1 || activeTab === 2) && ( | |
| <Box> | |
| <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}> | |
| <FormControlLabel | |
| control={<Switch checked={autoScroll} onChange={(e) => setAutoScroll(e.target.checked)} />} | |
| label="Auto-scroll logs" | |
| /> | |
| <Button startIcon={<Refresh />} onClick={loadTasks}> | |
| Refresh | |
| </Button> | |
| </Box> | |
| <List> | |
| {tasks | |
| .filter(task => activeTab === 1 ? | |
| ['queued', 'processing'].includes(task.status) : | |
| ['completed', 'failed', 'cancelled'].includes(task.status) | |
| ) | |
| .map(task => ( | |
| <Card key={task.id} sx={{ mb: 2 }}> | |
| <CardContent> | |
| <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}> | |
| <Typography variant="h6" noWrap sx={{ maxWidth: '60%' }}> | |
| {task.vod_url} | |
| </Typography> | |
| <Chip | |
| label={task.status} | |
| color={getStatusColor(task.status)} | |
| icon={getStatusIcon(task.status)} | |
| /> | |
| </Box> | |
| <Box sx={{ display: 'flex', gap: 2, mb: 2 }}> | |
| <Chip label={task.provider} size="small" /> | |
| <Typography variant="caption" color="text.secondary"> | |
| {new Date(task.created_at).toLocaleString()} | |
| </Typography> | |
| </Box> | |
| {task.status === 'processing' && task.progress_data && ( | |
| <Box sx={{ mb: 2 }}> | |
| <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}> | |
| <Typography variant="body2"> | |
| {task.progress_data.phase === 'downloading' ? 'Downloading' : 'Uploading'} | |
| </Typography> | |
| <Typography variant="body2"> | |
| {Math.round(task.progress_data.percent)}% | |
| </Typography> | |
| </Box> | |
| <LinearProgress | |
| variant="determinate" | |
| value={task.progress_data.percent} | |
| /> | |
| </Box> | |
| )} | |
| {task.error && ( | |
| <Alert severity="error" sx={{ mt: 2 }}> | |
| {task.error} | |
| </Alert> | |
| )} | |
| {task.status === 'processing' && ( | |
| <Box sx={{ display: 'flex', justifyContent: 'flex-end' }}> | |
| <Button | |
| color="error" | |
| onClick={() => cancelTask(task.id)} | |
| startIcon={<Cancel />} | |
| > | |
| Cancel | |
| </Button> | |
| </Box> | |
| )} | |
| </CardContent> | |
| </Card> | |
| ))} | |
| </List> | |
| </Box> | |
| )} | |
| </Paper> | |
| <Snackbar | |
| open={alert.open} | |
| autoHideDuration={6000} | |
| onClose={() => setAlert({ ...alert, open: false })} | |
| > | |
| <Alert severity={alert.severity} onClose={() => setAlert({ ...alert, open: false })}> | |
| {alert.message} | |
| </Alert> | |
| </Snackbar> | |
| </Container> | |
| ); | |
| } | |
| export default App; |