Initial commit

This commit is contained in:
Ryan Whytsell 2025-10-18 12:28:40 -04:00
commit a0326882f5
Signed by: Epithium
GPG Key ID: 940AC18C08E925EA
36 changed files with 7736 additions and 0 deletions

62
.gitignore vendored Normal file
View File

@ -0,0 +1,62 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Build outputs
dist/
dist-ssr/
build/
*.local
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Database files
*.db
*.db-shm
*.db-wal
# Application data
backend/downloads/
# Logs
*.log
logs/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
.idea/
*.swp
*.swo
*~
*.sublime-workspace
*.sublime-project
# OS files
.DS_Store
Thumbs.db
*.icloud
# Testing
coverage/
*.coverage
.nyc_output
# Temporary files
*.tmp
*.temp
.cache/
# Debug files
*.tsbuildinfo
.eslintcache

112
CLAUDE.md Normal file
View File

@ -0,0 +1,112 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
```bash
# Install dependencies
npm run install:all
# Development (run in separate terminals)
npm run dev:backend # Starts on :3001 with tsx watch
npm run dev:frontend # Starts on :3000 with Vite HMR
# Within backend/ directory
cd backend
npm run dev # Development with tsx watch
npm run build # TypeScript compilation
npm start # Run compiled version
# Within frontend/ directory
cd frontend
npm run dev # Vite dev server
npm run build # TypeScript + Vite build
npm run preview # Preview production build
```
## Architecture Overview
VidRip is a monorepo with separate backend (Express/SQLite) and frontend (React) apps.
### Core Scheduler Logic
The scheduler in `backend/src/services/scheduler.ts` operates on two separate timers:
1. **Channel Checker**: Cron job (`0 * * * *`) runs every hour to check active channels for new videos
2. **Download Cycle**: Self-scheduling recursive setTimeout that:
- Downloads one pending video
- Calculates next run: `intervalHours * 60 ± random(varianceMinutes)`
- Schedules itself again with the calculated delay
The variance prevents predictable download patterns. The scheduler uses a single global `isDownloading` flag to prevent concurrent downloads.
### Database Layer
SQLite database (`backend/data.db`) with three tables managed via `better-sqlite3`:
- `channels`: YouTube channels to monitor
- `videos`: Individual videos with status tracking (pending/downloading/completed/failed)
- `config`: Key-value store for app settings
All database operations are in `backend/src/db/database.ts` using synchronous better-sqlite3 API. The database auto-initializes on first run.
### yt-dlp Integration
`backend/src/services/ytdlp.ts` wraps the yt-dlp CLI (must be installed on system):
- Uses `child_process.spawn` to call yt-dlp
- `--dump-json` extracts metadata without downloading
- `--flat-playlist` lists channel videos efficiently
- Progress tracking via stdout parsing (looks for percentage patterns)
- Downloads merge best video+audio to MP4 format
### Frontend Proxy Setup
Vite development server (port 3000) proxies to backend (port 3001):
- `/api/*` → Backend API endpoints
- `/downloads/*` → Static video files
This is configured in `frontend/vite.config.ts`. Production deployments need a reverse proxy (nginx, etc.).
### API Structure
Routes are split by domain:
- `backend/src/routes/channels.ts`: Channel CRUD + refresh
- `backend/src/routes/videos.ts`: Video CRUD + retry
- `backend/src/routes/config.ts`: Settings + scheduler control
Video files are served statically from `backend/downloads/` directory.
## Key Constraints
- Only 1 concurrent download supported (hardcoded in scheduler)
- Download progress is ephemeral (not persisted to DB, only in-memory)
- No authentication/authorization
- yt-dlp must be in PATH (external dependency)
- Database uses WAL mode by default for better concurrency
## Important Implementation Notes
### Scheduler Restart Behavior
When config changes (e.g., interval or variance), call `restartScheduler()` from `backend/src/services/scheduler.ts`. This stops the cron task and clears the setTimeout chain, then restarts with new settings.
### Video Status State Machine
Videos progress through states: `pending → downloading → completed/failed`
- Retry functionality resets `failed` back to `pending`
- Deleting a channel cascades to delete all its videos and files
### Channel Refresh
Adding a channel fetches ALL videos from that channel using yt-dlp's `--flat-playlist`. Large channels (1000+ videos) may take time. Videos are created in `pending` state and downloaded according to the schedule.
## File Locations
- Database: `backend/data.db`
- Downloaded videos: `backend/downloads/{videoId}.mp4`
- Environment: Optional `backend/.env` (PORT, NODE_ENV)
## System Requirements
- Node.js 18+
- yt-dlp installed and in PATH (`pip install yt-dlp` or `brew install yt-dlp`)

259
PROJECT_CONTEXT.md Normal file
View File

@ -0,0 +1,259 @@
# VidRip - Project Context
## Project Overview
VidRip is a respectful YouTube video drip downloader that slowly downloads videos from channels over time to avoid aggressive scraping. Videos are downloaded at configurable intervals with random variance to make the pattern less predictable.
## Architecture
### Tech Stack
- **Backend**: Node.js, Express, TypeScript, SQLite (better-sqlite3), node-cron
- **Frontend**: React, TypeScript, Vite, Tailwind CSS, React Router
- **External**: yt-dlp (must be installed on system)
### Project Structure
```
vidrip/
├── backend/
│ ├── src/
│ │ ├── db/
│ │ │ └── database.ts # SQLite operations, schema, CRUD
│ │ ├── routes/
│ │ │ ├── channels.ts # Channel management endpoints
│ │ │ ├── videos.ts # Video management endpoints
│ │ │ └── config.ts # Settings & scheduler status
│ │ ├── services/
│ │ │ ├── ytdlp.ts # yt-dlp wrapper for downloads
│ │ │ └── scheduler.ts # Download scheduler logic
│ │ ├── types/
│ │ │ └── index.ts # TypeScript interfaces
│ │ └── server.ts # Express app entry point
│ ├── downloads/ # Downloaded video storage
│ ├── data.db # SQLite database (auto-created)
│ └── package.json
├── frontend/
│ ├── src/
│ │ ├── components/ # (empty - components in pages)
│ │ ├── pages/
│ │ │ ├── VideosPage.tsx # List/add/filter videos
│ │ │ ├── ChannelsPage.tsx # Manage channels
│ │ │ ├── SettingsPage.tsx # Configure scheduler
│ │ │ └── VideoPlayerPage.tsx # Watch videos
│ │ ├── services/
│ │ │ └── api.ts # API client functions
│ │ ├── types/
│ │ │ └── index.ts # TypeScript interfaces
│ │ ├── App.tsx # Router & navigation
│ │ ├── main.tsx # React entry point
│ │ └── index.css # Tailwind imports
│ ├── index.html
│ ├── vite.config.ts # Proxy to backend
│ └── package.json
└── README.md
```
## Database Schema
### Tables
1. **channels**
- id, url, name, channelId, addedAt, lastChecked, active
- Stores YouTube channels to monitor
2. **videos**
- id, channelId, videoId, title, url, duration, thumbnail, uploadDate
- status (pending/downloading/completed/failed), filePath, fileSize
- addedAt, downloadedAt, error
- Stores individual videos and download status
3. **config**
- id, key, value
- Stores app configuration (intervalHours, varianceMinutes, enabled, etc.)
## Key Flows
### Download Scheduler (`backend/src/services/scheduler.ts`)
1. **Channel Checking**: Runs every hour via cron to check channels for new videos
2. **Download Cycle**:
- Picks next pending video
- Downloads with progress tracking
- Marks completed or failed
- Schedules next download using: `intervalHours ± random(varianceMinutes)`
3. **Restartable**: Can be stopped/restarted when config changes
### Adding a Channel
1. User enters YouTube channel URL in frontend
2. Backend calls `getChannelInfo()` to fetch channel metadata
3. Backend calls `getChannelVideos()` to get all videos (flat playlist)
4. Creates channel record and all video records with status='pending'
5. Scheduler will pick them up based on configuration
### Video Download
1. Scheduler picks next pending video
2. Calls yt-dlp with progress callbacks
3. Downloads to `backend/downloads/{videoId}.mp4`
4. Updates database with file path, size, status
## API Endpoints
### Channels
- `GET /api/channels` - List all channels
- `GET /api/channels/:id` - Get channel details
- `GET /api/channels/:id/videos` - Get channel's videos
- `POST /api/channels` - Add new channel (body: {url})
- `PATCH /api/channels/:id` - Update channel (body: {active})
- `DELETE /api/channels/:id` - Delete channel & videos
- `POST /api/channels/:id/refresh` - Check for new videos
### Videos
- `GET /api/videos` - List all videos (optional ?status=)
- `GET /api/videos/:id` - Get video details
- `POST /api/videos` - Add single video (body: {url})
- `DELETE /api/videos/:id` - Delete video & file
- `POST /api/videos/:id/retry` - Retry failed video
### Config
- `GET /api/config` - Get all settings
- `PATCH /api/config` - Update settings (body: {key: value})
- `GET /api/config/scheduler/status` - Get scheduler status & progress
### Static
- `/downloads/{videoId}.mp4` - Serve downloaded videos
## Configuration Options
Default values in database:
- `intervalHours`: "3" - Average hours between downloads
- `varianceMinutes`: "30" - Random ± minutes to add
- `maxConcurrentDownloads`: "1" - Simultaneous downloads (currently only 1 supported)
- `enabled`: "true" - Whether scheduler is active
## Important Implementation Details
### yt-dlp Integration
- Uses spawn to call yt-dlp CLI (must be in PATH)
- `--dump-json` for metadata extraction
- `--flat-playlist` for channel video lists
- Progress parsing via stdout line parsing
- Downloads as MP4 (merges best video+audio)
### Scheduler Variance
- Prevents predictable download patterns
- Calculates: `baseMinutes = intervalHours * 60`
- Adds random: `variance = random(-varianceMinutes, +varianceMinutes)`
- Uses `setTimeout` for next download, not fixed cron
### Frontend Proxy
- Vite proxies `/api` and `/downloads` to backend (port 3001)
- Allows development without CORS issues
- Production would need reverse proxy (nginx/etc)
## Known Limitations & Future Enhancements
### Current Limitations
1. Only 1 concurrent download supported (hardcoded in scheduler)
2. No authentication/authorization
3. No video queue reordering
4. No bandwidth limiting
5. No retry limit for failed videos
6. No disk space checking
7. No video preview before download
8. Progress tracking only works during active download (not persisted)
### Potential Enhancements
- [ ] User authentication
- [ ] Video quality selection
- [ ] Download queue prioritization
- [ ] Bandwidth throttling
- [ ] Automatic old video cleanup
- [ ] Video search/filtering by title
- [ ] Channel categorization/tagging
- [ ] Download history/statistics
- [ ] Webhook notifications
- [ ] Docker containerization
- [ ] Multiple download quality profiles
- [ ] Subtitle downloading
## Development Commands
```bash
# Install all dependencies
npm run install:all
# Development (run in separate terminals)
npm run dev:backend # Backend on :3001
npm run dev:frontend # Frontend on :3000
# Production build
npm run build:backend
npm run build:frontend
# Production run
npm run start:backend
npm run start:frontend
```
## Dependencies to Install
### System Requirements
- Node.js 18+
- yt-dlp (via pip, brew, or binary)
### Installation
```bash
# yt-dlp
pip install yt-dlp
# or
brew install yt-dlp
# Node dependencies
npm run install:all
```
## Troubleshooting
### Common Issues
1. **"yt-dlp not found"**: Ensure yt-dlp is in PATH
2. **Database locked**: Only one backend instance should run
3. **Video won't play**: Check file exists in `backend/downloads/`
4. **Scheduler not running**: Check settings page, ensure enabled=true
5. **Channel refresh fails**: YouTube may be rate limiting, wait and retry
## File Locations
- **Database**: `backend/data.db`
- **Videos**: `backend/downloads/{videoId}.mp4`
- **Logs**: Console output (not persisted to file currently)
## Security Considerations
- No authentication - anyone with access can manage downloads
- Videos stored unencrypted on filesystem
- No rate limiting on API endpoints
- YouTube URLs not validated before passing to yt-dlp
- Consider running behind reverse proxy with auth in production
## Code Entry Points
To understand the codebase quickly:
1. Start with `backend/src/server.ts` - see how routes connect
2. Read `backend/src/services/scheduler.ts` - core business logic
3. Check `frontend/src/App.tsx` - understand page routing
4. Review `backend/src/db/database.ts` - database schema & operations
## Testing
Currently no automated tests. Manual testing checklist:
- [ ] Add channel and verify videos appear
- [ ] Add individual video by URL
- [ ] Watch completed video
- [ ] Change settings and verify scheduler restarts
- [ ] Delete channel and verify cascade delete
- [ ] Retry failed video
- [ ] Refresh channel for new videos
## Environment Variables
Backend supports (optional):
- `PORT` - Server port (default: 3001)
- `NODE_ENV` - Environment (development/production)
Currently no `.env` file required - uses defaults.

83
README.md Normal file
View File

@ -0,0 +1,83 @@
# VidRip
A respectful YouTube video drip downloader that slowly and gradually downloads videos from channels over time.
## Features
- Add YouTube channels or individual videos
- Configurable download intervals (default: every few hours)
- Configurable variance to avoid predictable patterns
- Watch downloaded videos in the browser
- Track download progress and status
- Lightweight SQLite database
## Tech Stack
**Backend:**
- Node.js + Express + TypeScript
- SQLite (better-sqlite3)
- yt-dlp for downloading
- node-cron for scheduling
**Frontend:**
- React + TypeScript
- Vite
- Tailwind CSS
- React Router
## Setup
### Prerequisites
- Node.js 18+
- yt-dlp installed and available in PATH
### Installation
1. Install yt-dlp:
```bash
# On Linux/Mac
pip install yt-dlp
# or
brew install yt-dlp
# On Windows
# Download from https://github.com/yt-dlp/yt-dlp/releases
```
2. Install backend dependencies:
```bash
cd backend
npm install
```
3. Install frontend dependencies:
```bash
cd frontend
npm install
```
### Running
1. Start the backend:
```bash
cd backend
npm run dev
```
2. Start the frontend:
```bash
cd frontend
npm run dev
```
3. Open http://localhost:3000
## Configuration
Default settings:
- Download interval: 3 hours
- Time variance: ±30 minutes
- Storage location: `backend/downloads/`
These can be configured in the UI settings page.

2
backend/.env.example Normal file
View File

@ -0,0 +1,2 @@
PORT=3001
NODE_ENV=development

1872
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
backend/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "vidrip-backend",
"version": "1.0.0",
"description": "Backend for VidRip - Respectful YouTube video drip downloader",
"main": "dist/server.js",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"better-sqlite3": "^9.2.2",
"node-cron": "^3.0.3",
"dotenv": "^16.3.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/cors": "^2.8.17",
"@types/better-sqlite3": "^7.6.8",
"@types/node": "^20.10.6",
"@types/node-cron": "^3.0.11",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
}
}

198
backend/src/db/database.ts Normal file
View File

@ -0,0 +1,198 @@
import Database from 'better-sqlite3';
import path from 'path';
import { Channel, Video, Config } from '../types';
const dbPath = path.join(__dirname, '../../data.db');
const db = new Database(dbPath);
// Enable foreign keys
db.pragma('foreign_keys = ON');
export function initDatabase() {
// Create tables
db.exec(`
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
channelId TEXT NOT NULL,
addedAt TEXT NOT NULL DEFAULT (datetime('now')),
lastChecked TEXT,
active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS videos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channelId INTEGER,
videoId TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
url TEXT NOT NULL,
duration INTEGER,
thumbnail TEXT,
uploadDate TEXT,
status TEXT NOT NULL DEFAULT 'pending',
filePath TEXT,
fileSize INTEGER,
addedAt TEXT NOT NULL DEFAULT (datetime('now')),
downloadedAt TEXT,
error TEXT,
FOREIGN KEY (channelId) REFERENCES channels(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_videos_status ON videos(status);
CREATE INDEX IF NOT EXISTS idx_videos_channelId ON videos(channelId);
`);
// Initialize default config
const defaultConfig = {
intervalHours: '3',
varianceMinutes: '30',
maxConcurrentDownloads: '1',
enabled: 'true'
};
const insertConfig = db.prepare(
'INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)'
);
Object.entries(defaultConfig).forEach(([key, value]) => {
insertConfig.run(key, value);
});
console.log('Database initialized');
}
// Channel operations
export const channelOperations = {
getAll: () => {
return db.prepare('SELECT * FROM channels ORDER BY addedAt DESC').all() as Channel[];
},
getById: (id: number) => {
return db.prepare('SELECT * FROM channels WHERE id = ?').get(id) as Channel | undefined;
},
create: (url: string, name: string, channelId: string) => {
const stmt = db.prepare(
'INSERT INTO channels (url, name, channelId) VALUES (?, ?, ?)'
);
const result = stmt.run(url, name, channelId);
return result.lastInsertRowid as number;
},
update: (id: number, data: Partial<Channel>) => {
const fields = Object.keys(data).filter(k => k !== 'id');
const values = fields.map(k => data[k as keyof Channel]);
const setClause = fields.map(f => `${f} = ?`).join(', ');
const stmt = db.prepare(`UPDATE channels SET ${setClause} WHERE id = ?`);
return stmt.run(...values, id);
},
delete: (id: number) => {
return db.prepare('DELETE FROM channels WHERE id = ?').run(id);
},
updateLastChecked: (id: number) => {
return db.prepare(
"UPDATE channels SET lastChecked = datetime('now') WHERE id = ?"
).run(id);
}
};
// Video operations
export const videoOperations = {
getAll: () => {
return db.prepare('SELECT * FROM videos ORDER BY addedAt DESC').all() as Video[];
},
getById: (id: number) => {
return db.prepare('SELECT * FROM videos WHERE id = ?').get(id) as Video | undefined;
},
getByChannelId: (channelId: number) => {
return db.prepare('SELECT * FROM videos WHERE channelId = ? ORDER BY addedAt DESC')
.all(channelId) as Video[];
},
getByStatus: (status: string) => {
return db.prepare('SELECT * FROM videos WHERE status = ? ORDER BY addedAt ASC')
.all(status) as Video[];
},
getNextPending: () => {
return db.prepare('SELECT * FROM videos WHERE status = ? ORDER BY addedAt ASC LIMIT 1')
.get('pending') as Video | undefined;
},
create: (video: Omit<Video, 'id' | 'addedAt' | 'downloadedAt'>) => {
const stmt = db.prepare(`
INSERT INTO videos (channelId, videoId, title, url, duration, thumbnail, uploadDate, status, filePath, fileSize, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
video.channelId,
video.videoId,
video.title,
video.url,
video.duration,
video.thumbnail,
video.uploadDate,
video.status,
video.filePath,
video.fileSize,
video.error
);
return result.lastInsertRowid as number;
},
updateStatus: (id: number, status: string, error?: string) => {
if (error !== undefined) {
return db.prepare('UPDATE videos SET status = ?, error = ? WHERE id = ?')
.run(status, error, id);
}
return db.prepare('UPDATE videos SET status = ? WHERE id = ?').run(status, id);
},
markCompleted: (id: number, filePath: string, fileSize: number) => {
return db.prepare(`
UPDATE videos
SET status = 'completed', filePath = ?, fileSize = ?, downloadedAt = datetime('now')
WHERE id = ?
`).run(filePath, fileSize, id);
},
delete: (id: number) => {
return db.prepare('DELETE FROM videos WHERE id = ?').run(id);
}
};
// Config operations
export const configOperations = {
get: (key: string) => {
const result = db.prepare('SELECT value FROM config WHERE key = ?').get(key) as Config | undefined;
return result?.value;
},
set: (key: string, value: string) => {
return db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)')
.run(key, value);
},
getAll: () => {
const configs = db.prepare('SELECT * FROM config').all() as Config[];
const result: Record<string, string> = {};
configs.forEach(c => {
result[c.key] = c.value;
});
return result;
}
};
export default db;

View File

@ -0,0 +1,186 @@
import { Router } from 'express';
import { channelOperations, videoOperations } from '../db/database';
import { getChannelInfo, getChannelVideos } from '../services/ytdlp';
const router = Router();
// Get all channels
router.get('/', (req, res) => {
try {
const channels = channelOperations.getAll();
res.json(channels);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch channels' });
}
});
// Get channel by ID
router.get('/:id', (req, res) => {
try {
const id = parseInt(req.params.id);
const channel = channelOperations.getById(id);
if (!channel) {
return res.status(404).json({ error: 'Channel not found' });
}
res.json(channel);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch channel' });
}
});
// Get videos for a channel
router.get('/:id/videos', (req, res) => {
try {
const id = parseInt(req.params.id);
const videos = videoOperations.getByChannelId(id);
res.json(videos);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch videos' });
}
});
// Add a new channel
router.post('/', async (req, res) => {
try {
const { url } = req.body;
if (!url) {
return res.status(400).json({ error: 'URL is required' });
}
// Get channel info from yt-dlp
const channelInfo = await getChannelInfo(url);
// Create channel
const channelId = channelOperations.create(
url,
channelInfo.name,
channelInfo.id
);
// Fetch videos from the channel
const videos = await getChannelVideos(url);
// Add videos to database
for (const videoInfo of videos) {
try {
videoOperations.create({
channelId,
videoId: videoInfo.id,
title: videoInfo.title,
url: videoInfo.url,
duration: videoInfo.duration,
thumbnail: videoInfo.thumbnail,
uploadDate: videoInfo.uploadDate,
status: 'pending',
filePath: null,
fileSize: null,
error: null
});
} catch (error) {
// Skip if video already exists
console.log(`Skipping duplicate video: ${videoInfo.id}`);
}
}
const channel = channelOperations.getById(channelId);
res.status(201).json(channel);
} catch (error) {
console.error('Error adding channel:', error);
res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to add channel'
});
}
});
// Update channel
router.patch('/:id', (req, res) => {
try {
const id = parseInt(req.params.id);
const { active } = req.body;
const channel = channelOperations.getById(id);
if (!channel) {
return res.status(404).json({ error: 'Channel not found' });
}
if (active !== undefined) {
channelOperations.update(id, { active });
}
const updated = channelOperations.getById(id);
res.json(updated);
} catch (error) {
res.status(500).json({ error: 'Failed to update channel' });
}
});
// Delete channel
router.delete('/:id', (req, res) => {
try {
const id = parseInt(req.params.id);
const channel = channelOperations.getById(id);
if (!channel) {
return res.status(404).json({ error: 'Channel not found' });
}
channelOperations.delete(id);
res.status(204).send();
} catch (error) {
res.status(500).json({ error: 'Failed to delete channel' });
}
});
// Refresh channel videos
router.post('/:id/refresh', async (req, res) => {
try {
const id = parseInt(req.params.id);
const channel = channelOperations.getById(id);
if (!channel) {
return res.status(404).json({ error: 'Channel not found' });
}
const videos = await getChannelVideos(channel.url);
let addedCount = 0;
for (const videoInfo of videos) {
try {
videoOperations.create({
channelId: id,
videoId: videoInfo.id,
title: videoInfo.title,
url: videoInfo.url,
duration: videoInfo.duration,
thumbnail: videoInfo.thumbnail,
uploadDate: videoInfo.uploadDate,
status: 'pending',
filePath: null,
fileSize: null,
error: null
});
addedCount++;
} catch (error) {
// Skip if video already exists
}
}
channelOperations.updateLastChecked(id);
res.json({ message: `Added ${addedCount} new videos` });
} catch (error) {
console.error('Error refreshing channel:', error);
res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to refresh channel'
});
}
});
export default router;

View File

@ -0,0 +1,49 @@
import { Router } from 'express';
import { configOperations } from '../db/database';
import { restartScheduler, getSchedulerStatus } from '../services/scheduler';
const router = Router();
// Get all config
router.get('/', (req, res) => {
try {
const config = configOperations.getAll();
res.json(config);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch config' });
}
});
// Update config
router.patch('/', (req, res) => {
try {
const updates = req.body;
for (const [key, value] of Object.entries(updates)) {
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
configOperations.set(key, String(value));
}
}
// Restart scheduler if config changed
restartScheduler();
const config = configOperations.getAll();
res.json(config);
} catch (error) {
res.status(500).json({ error: 'Failed to update config' });
}
});
// Get scheduler status
router.get('/scheduler/status', (req, res) => {
try {
const status = getSchedulerStatus();
res.json(status);
} catch (error) {
res.status(500).json({ error: 'Failed to get scheduler status' });
}
});
export default router;

View File

@ -0,0 +1,128 @@
import { Router } from 'express';
import { videoOperations } from '../db/database';
import { getVideoInfo } from '../services/ytdlp';
import fs from 'fs';
import path from 'path';
const router = Router();
// Get all videos
router.get('/', (req, res) => {
try {
const { status } = req.query;
let videos;
if (status && typeof status === 'string') {
videos = videoOperations.getByStatus(status);
} else {
videos = videoOperations.getAll();
}
res.json(videos);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch videos' });
}
});
// Get video by ID
router.get('/:id', (req, res) => {
try {
const id = parseInt(req.params.id);
const video = videoOperations.getById(id);
if (!video) {
return res.status(404).json({ error: 'Video not found' });
}
res.json(video);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch video' });
}
});
// Add a single video
router.post('/', async (req, res) => {
try {
const { url } = req.body;
if (!url) {
return res.status(400).json({ error: 'URL is required' });
}
// Get video info from yt-dlp
const videoInfo = await getVideoInfo(url);
// Create video
const videoId = videoOperations.create({
channelId: null,
videoId: videoInfo.id,
title: videoInfo.title,
url: videoInfo.url,
duration: videoInfo.duration,
thumbnail: videoInfo.thumbnail,
uploadDate: videoInfo.uploadDate,
status: 'pending',
filePath: null,
fileSize: null,
error: null
});
const video = videoOperations.getById(videoId);
res.status(201).json(video);
} catch (error) {
console.error('Error adding video:', error);
res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to add video'
});
}
});
// Delete video
router.delete('/:id', (req, res) => {
try {
const id = parseInt(req.params.id);
const video = videoOperations.getById(id);
if (!video) {
return res.status(404).json({ error: 'Video not found' });
}
// Delete file if it exists
if (video.filePath && fs.existsSync(video.filePath)) {
fs.unlinkSync(video.filePath);
}
videoOperations.delete(id);
res.status(204).send();
} catch (error) {
res.status(500).json({ error: 'Failed to delete video' });
}
});
// Retry failed video
router.post('/:id/retry', (req, res) => {
try {
const id = parseInt(req.params.id);
const video = videoOperations.getById(id);
if (!video) {
return res.status(404).json({ error: 'Video not found' });
}
if (video.status !== 'failed') {
return res.status(400).json({ error: 'Only failed videos can be retried' });
}
videoOperations.updateStatus(id, 'pending');
const updated = videoOperations.getById(id);
res.json(updated);
} catch (error) {
res.status(500).json({ error: 'Failed to retry video' });
}
});
export default router;

44
backend/src/server.ts Normal file
View File

@ -0,0 +1,44 @@
import express from 'express';
import cors from 'cors';
import path from 'path';
import { initDatabase } from './db/database';
import { startScheduler } from './services/scheduler';
import channelsRouter from './routes/channels';
import videosRouter from './routes/videos';
import configRouter from './routes/config';
const app = express();
const PORT = process.env.PORT || 3001;
// Middleware
app.use(cors());
app.use(express.json());
// Initialize database
initDatabase();
// API routes
app.use('/api/channels', channelsRouter);
app.use('/api/videos', videosRouter);
app.use('/api/config', configRouter);
// Serve downloaded videos
app.use('/downloads', express.static(path.join(__dirname, '../downloads')));
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Start server
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
console.log('Starting scheduler...');
startScheduler();
});
// Graceful shutdown
process.on('SIGINT', () => {
console.log('Shutting down gracefully...');
process.exit(0);
});

View File

@ -0,0 +1,192 @@
import cron from 'node-cron';
import { videoOperations, configOperations, channelOperations } from '../db/database';
import { downloadVideo, getChannelVideos } from './ytdlp';
import { DownloadProgress } from '../types';
let schedulerTask: cron.ScheduledTask | null = null;
let isDownloading = false;
let currentProgress: DownloadProgress | null = null;
function getRandomVariance(varianceMinutes: number): number {
return Math.floor(Math.random() * varianceMinutes * 2) - varianceMinutes;
}
function calculateNextRun(intervalHours: number, varianceMinutes: number): Date {
const baseMinutes = intervalHours * 60;
const variance = getRandomVariance(varianceMinutes);
const totalMinutes = baseMinutes + variance;
const nextRun = new Date();
nextRun.setMinutes(nextRun.getMinutes() + totalMinutes);
return nextRun;
}
async function downloadNextVideo() {
if (isDownloading) {
console.log('Already downloading a video, skipping...');
return;
}
try {
isDownloading = true;
currentProgress = null;
const video = videoOperations.getNextPending();
if (!video) {
console.log('No pending videos to download');
return;
}
console.log(`Starting download: ${video.title} (${video.videoId})`);
videoOperations.updateStatus(video.id, 'downloading');
const result = await downloadVideo(video.videoId, video.url, {
onProgress: (progress, speed, eta) => {
currentProgress = {
videoId: video.videoId,
progress,
speed,
eta
};
}
});
videoOperations.markCompleted(video.id, result.filePath, result.fileSize);
console.log(`Download completed: ${video.title}`);
currentProgress = null;
} catch (error) {
console.error('Download error:', error);
const video = videoOperations.getNextPending();
if (video) {
videoOperations.updateStatus(
video.id,
'failed',
error instanceof Error ? error.message : 'Unknown error'
);
}
currentProgress = null;
} finally {
isDownloading = false;
}
}
async function checkChannelsForNewVideos() {
const channels = channelOperations.getAll().filter(c => c.active);
for (const channel of channels) {
try {
console.log(`Checking channel: ${channel.name}`);
const videos = await getChannelVideos(channel.url);
for (const videoInfo of videos) {
// Check if video already exists
const existingVideos = videoOperations.getAll();
const exists = existingVideos.some(v => v.videoId === videoInfo.id);
if (!exists) {
console.log(`Found new video: ${videoInfo.title}`);
videoOperations.create({
channelId: channel.id,
videoId: videoInfo.id,
title: videoInfo.title,
url: videoInfo.url,
duration: videoInfo.duration,
thumbnail: videoInfo.thumbnail,
uploadDate: videoInfo.uploadDate,
status: 'pending',
filePath: null,
fileSize: null,
error: null
});
}
}
channelOperations.updateLastChecked(channel.id);
} catch (error) {
console.error(`Error checking channel ${channel.name}:`, error);
}
}
}
export function startScheduler() {
if (schedulerTask) {
console.log('Scheduler already running');
return;
}
const config = configOperations.getAll();
const enabled = config.enabled === 'true';
if (!enabled) {
console.log('Scheduler is disabled');
return;
}
const intervalHours = parseFloat(config.intervalHours || '3');
const varianceMinutes = parseFloat(config.varianceMinutes || '30');
console.log(`Starting scheduler: ${intervalHours}h ±${varianceMinutes}m`);
// Check for new videos every hour
schedulerTask = cron.schedule('0 * * * *', async () => {
console.log('Checking channels for new videos...');
await checkChannelsForNewVideos();
});
// Download next video with variable interval
async function scheduleNextDownload() {
const config = configOperations.getAll();
const enabled = config.enabled === 'true';
if (!enabled) {
console.log('Scheduler disabled, stopping...');
return;
}
await downloadNextVideo();
const intervalHours = parseFloat(config.intervalHours || '3');
const varianceMinutes = parseFloat(config.varianceMinutes || '30');
const nextRun = calculateNextRun(intervalHours, varianceMinutes);
console.log(`Next download scheduled for: ${nextRun.toLocaleString()}`);
const delay = nextRun.getTime() - Date.now();
setTimeout(scheduleNextDownload, delay);
}
// Start the first download cycle
scheduleNextDownload();
console.log('Scheduler started');
}
export function stopScheduler() {
if (schedulerTask) {
schedulerTask.stop();
schedulerTask = null;
console.log('Scheduler stopped');
}
}
export function restartScheduler() {
stopScheduler();
startScheduler();
}
export function getSchedulerStatus() {
const config = configOperations.getAll();
return {
running: schedulerTask !== null,
enabled: config.enabled === 'true',
intervalHours: parseFloat(config.intervalHours || '3'),
varianceMinutes: parseFloat(config.varianceMinutes || '30'),
isDownloading,
currentProgress
};
}
export function getCurrentProgress(): DownloadProgress | null {
return currentProgress;
}

View File

@ -0,0 +1,248 @@
import { spawn } from 'child_process';
import path from 'path';
import fs from 'fs';
const DOWNLOADS_DIR = path.join(__dirname, '../../downloads');
// Ensure downloads directory exists
if (!fs.existsSync(DOWNLOADS_DIR)) {
fs.mkdirSync(DOWNLOADS_DIR, { recursive: true });
}
export interface VideoInfo {
id: string;
title: string;
url: string;
duration: number;
thumbnail: string;
uploadDate: string;
channel: string;
channelId: string;
}
export interface ChannelInfo {
id: string;
name: string;
url: string;
}
export async function getVideoInfo(url: string): Promise<VideoInfo> {
return new Promise((resolve, reject) => {
const args = [
'--dump-json',
'--no-playlist',
url
];
const ytdlp = spawn('yt-dlp', args);
let stdout = '';
let stderr = '';
ytdlp.stdout.on('data', (data) => {
stdout += data.toString();
});
ytdlp.stderr.on('data', (data) => {
stderr += data.toString();
});
ytdlp.on('close', (code) => {
if (code !== 0) {
reject(new Error(`yt-dlp failed: ${stderr}`));
return;
}
try {
const info = JSON.parse(stdout);
resolve({
id: info.id,
title: info.title,
url: info.webpage_url,
duration: info.duration,
thumbnail: info.thumbnail,
uploadDate: info.upload_date,
channel: info.channel || info.uploader,
channelId: info.channel_id || info.uploader_id
});
} catch (error) {
reject(new Error(`Failed to parse video info: ${error}`));
}
});
});
}
export async function getChannelVideos(channelUrl: string): Promise<VideoInfo[]> {
return new Promise((resolve, reject) => {
const args = [
'--dump-json',
'--flat-playlist',
channelUrl
];
const ytdlp = spawn('yt-dlp', args);
let stdout = '';
let stderr = '';
ytdlp.stdout.on('data', (data) => {
stdout += data.toString();
});
ytdlp.stderr.on('data', (data) => {
stderr += data.toString();
});
ytdlp.on('close', (code) => {
if (code !== 0) {
reject(new Error(`yt-dlp failed: ${stderr}`));
return;
}
try {
const lines = stdout.trim().split('\n').filter(l => l);
const videos = lines.map(line => {
const info = JSON.parse(line);
return {
id: info.id,
title: info.title,
url: info.url || `https://www.youtube.com/watch?v=${info.id}`,
duration: info.duration || 0,
thumbnail: info.thumbnail || info.thumbnails?.[0]?.url || '',
uploadDate: info.upload_date || '',
channel: info.channel || info.uploader || '',
channelId: info.channel_id || info.uploader_id || ''
};
});
resolve(videos);
} catch (error) {
reject(new Error(`Failed to parse channel videos: ${error}`));
}
});
});
}
export async function getChannelInfo(channelUrl: string): Promise<ChannelInfo> {
return new Promise((resolve, reject) => {
const args = [
'--dump-json',
'--playlist-items',
'1',
channelUrl
];
const ytdlp = spawn('yt-dlp', args);
let stdout = '';
let stderr = '';
ytdlp.stdout.on('data', (data) => {
stdout += data.toString();
});
ytdlp.stderr.on('data', (data) => {
stderr += data.toString();
});
ytdlp.on('close', (code) => {
if (code !== 0) {
reject(new Error(`yt-dlp failed: ${stderr}`));
return;
}
try {
const lines = stdout.trim().split('\n');
const info = JSON.parse(lines[0]);
resolve({
id: info.channel_id || info.uploader_id,
name: info.channel || info.uploader,
url: channelUrl
});
} catch (error) {
reject(new Error(`Failed to parse channel info: ${error}`));
}
});
});
}
export interface DownloadOptions {
onProgress?: (progress: number, speed: string, eta: string) => void;
}
export async function downloadVideo(
videoId: string,
url: string,
options: DownloadOptions = {}
): Promise<{ filePath: string; fileSize: number }> {
return new Promise((resolve, reject) => {
const outputTemplate = path.join(DOWNLOADS_DIR, `${videoId}.%(ext)s`);
const args = [
'-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best',
'--merge-output-format', 'mp4',
'-o', outputTemplate,
'--no-playlist',
'--progress',
'--newline',
url
];
const ytdlp = spawn('yt-dlp', args);
let stderr = '';
let outputPath = '';
ytdlp.stdout.on('data', (data) => {
const line = data.toString();
// Parse progress
if (options.onProgress && line.includes('%')) {
const progressMatch = line.match(/(\d+\.?\d*)%/);
const speedMatch = line.match(/(\d+\.?\d*\w+\/s)/);
const etaMatch = line.match(/ETA\s+(\S+)/);
if (progressMatch) {
const progress = parseFloat(progressMatch[1]);
const speed = speedMatch ? speedMatch[1] : 'N/A';
const eta = etaMatch ? etaMatch[1] : 'N/A';
options.onProgress(progress, speed, eta);
}
}
// Capture output filename
if (line.includes('Merging formats into') || line.includes('Destination:')) {
const pathMatch = line.match(/"([^"]+)"/);
if (pathMatch) {
outputPath = pathMatch[1];
}
}
});
ytdlp.stderr.on('data', (data) => {
stderr += data.toString();
});
ytdlp.on('close', (code) => {
if (code !== 0) {
reject(new Error(`Download failed: ${stderr}`));
return;
}
// Find the downloaded file
if (!outputPath) {
const files = fs.readdirSync(DOWNLOADS_DIR);
const videoFile = files.find(f => f.startsWith(videoId) && f.endsWith('.mp4'));
if (videoFile) {
outputPath = path.join(DOWNLOADS_DIR, videoFile);
}
}
if (!outputPath || !fs.existsSync(outputPath)) {
reject(new Error('Downloaded file not found'));
return;
}
const stats = fs.statSync(outputPath);
resolve({
filePath: outputPath,
fileSize: stats.size
});
});
});
}

View File

@ -0,0 +1,46 @@
export interface Channel {
id: number;
url: string;
name: string;
channelId: string;
addedAt: string;
lastChecked: string | null;
active: boolean;
}
export interface Video {
id: number;
channelId: number | null;
videoId: string;
title: string;
url: string;
duration: number | null;
thumbnail: string | null;
uploadDate: string | null;
status: 'pending' | 'downloading' | 'completed' | 'failed';
filePath: string | null;
fileSize: number | null;
addedAt: string;
downloadedAt: string | null;
error: string | null;
}
export interface Config {
id: number;
key: string;
value: string;
}
export interface DownloadConfig {
intervalHours: number;
varianceMinutes: number;
maxConcurrentDownloads: number;
enabled: boolean;
}
export interface DownloadProgress {
videoId: string;
progress: number;
speed: string;
eta: string;
}

17
backend/tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

21
frontend/index.html Normal file
View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VidRip - YouTube Drip Downloader</title>
<script>
// Set dark mode before page renders to avoid flash
if (localStorage.getItem('darkMode') === 'false') {
document.documentElement.classList.remove('dark');
} else {
// Default to dark mode
document.documentElement.classList.add('dark');
}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2917
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
frontend/package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "vidrip-frontend",
"version": "1.0.0",
"description": "Frontend for VidRip - Respectful YouTube video drip downloader",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1"
},
"devDependencies": {
"@types/react": "^18.2.46",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.3",
"vite": "^5.0.10"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

84
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,84 @@
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import { DarkModeProvider, useDarkMode } from './contexts/DarkModeContext';
import ChannelsPage from './pages/ChannelsPage';
import VideosPage from './pages/VideosPage';
import SettingsPage from './pages/SettingsPage';
import VideoPlayerPage from './pages/VideoPlayerPage';
function AppContent() {
const { darkMode, toggleDarkMode } = useDarkMode();
return (
<Router>
<div className="min-h-screen bg-gray-100 dark:bg-gray-900">
<nav className="bg-white dark:bg-gray-800 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
<h1 className="text-2xl font-bold text-blue-600 dark:text-blue-400">VidRip</h1>
</div>
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
<Link
to="/"
className="border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600 hover:text-gray-700 dark:hover:text-white inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
Videos
</Link>
<Link
to="/channels"
className="border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600 hover:text-gray-700 dark:hover:text-white inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
Channels
</Link>
<Link
to="/settings"
className="border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600 hover:text-gray-700 dark:hover:text-white inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
Settings
</Link>
</div>
</div>
<div className="flex items-center">
<button
onClick={toggleDarkMode}
className="p-2 rounded-md text-gray-500 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label="Toggle dark mode"
>
{darkMode ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
)}
</button>
</div>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<Routes>
<Route path="/" element={<VideosPage />} />
<Route path="/channels" element={<ChannelsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/watch/:id" element={<VideoPlayerPage />} />
</Routes>
</main>
</div>
</Router>
);
}
function App() {
return (
<DarkModeProvider>
<AppContent />
</DarkModeProvider>
);
}
export default App;

View File

@ -0,0 +1,45 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
interface DarkModeContextType {
darkMode: boolean;
toggleDarkMode: () => void;
}
const DarkModeContext = createContext<DarkModeContextType | undefined>(undefined);
export function DarkModeProvider({ children }: { children: ReactNode }) {
const [darkMode, setDarkMode] = useState(() => {
// Check localStorage first, default to true (dark mode on)
const saved = localStorage.getItem('darkMode');
return saved !== null ? saved === 'true' : true;
});
useEffect(() => {
// Update the html class when darkMode changes
if (darkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Save to localStorage
localStorage.setItem('darkMode', String(darkMode));
}, [darkMode]);
const toggleDarkMode = () => {
setDarkMode(prev => !prev);
};
return (
<DarkModeContext.Provider value={{ darkMode, toggleDarkMode }}>
{children}
</DarkModeContext.Provider>
);
}
export function useDarkMode() {
const context = useContext(DarkModeContext);
if (context === undefined) {
throw new Error('useDarkMode must be used within a DarkModeProvider');
}
return context;
}

17
frontend/src/index.css Normal file
View File

@ -0,0 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

10
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,188 @@
import { useState, useEffect } from 'react';
import { channelsAPI } from '../services/api';
import { Channel } from '../types';
function ChannelsPage() {
const [channels, setChannels] = useState<Channel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [addUrl, setAddUrl] = useState('');
const [adding, setAdding] = useState(false);
useEffect(() => {
loadChannels();
}, []);
const loadChannels = async () => {
try {
setLoading(true);
const data = await channelsAPI.getAll();
setChannels(data);
setError('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load channels');
} finally {
setLoading(false);
}
};
const handleAddChannel = async (e: React.FormEvent) => {
e.preventDefault();
if (!addUrl.trim()) return;
try {
setAdding(true);
setError('');
await channelsAPI.create(addUrl);
setAddUrl('');
loadChannels();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add channel');
} finally {
setAdding(false);
}
};
const handleToggleActive = async (id: number, active: boolean) => {
try {
await channelsAPI.update(id, active);
loadChannels();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update channel');
}
};
const handleDelete = async (id: number) => {
if (!confirm('Are you sure you want to delete this channel? All associated videos will also be deleted.')) return;
try {
await channelsAPI.delete(id);
loadChannels();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete channel');
}
};
const handleRefresh = async (id: number) => {
try {
const result = await channelsAPI.refresh(id);
alert(result.message);
loadChannels();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to refresh channel');
}
};
const formatDate = (dateStr: string | null) => {
if (!dateStr) return 'Never';
return new Date(dateStr).toLocaleString();
};
return (
<div className="px-4 sm:px-6 lg:px-8">
<div className="sm:flex sm:items-center">
<div className="sm:flex-auto">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Channels</h1>
<p className="mt-2 text-sm text-gray-700 dark:text-gray-300">
Manage YouTube channels to download videos from
</p>
</div>
</div>
{error && (
<div className="mt-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded">
{error}
</div>
)}
<div className="mt-6 bg-white dark:bg-gray-800 shadow sm:rounded-lg p-6">
<form onSubmit={handleAddChannel} className="flex gap-4">
<input
type="text"
value={addUrl}
onChange={(e) => setAddUrl(e.target.value)}
placeholder="Enter YouTube channel URL..."
className="flex-1 rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-400 shadow-sm focus:border-blue-500 focus:ring-blue-500"
disabled={adding}
/>
<button
type="submit"
disabled={adding}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{adding ? 'Adding...' : 'Add Channel'}
</button>
</form>
{adding && (
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
This may take a moment while we fetch the channel information...
</p>
)}
</div>
<div className="mt-6 bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-md">
{loading ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400">Loading...</div>
) : channels.length === 0 ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
No channels added yet. Add a channel URL above to get started.
</div>
) : (
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
{channels.map((channel) => (
<li key={channel.id} className="p-6 hover:bg-gray-50 dark:hover:bg-gray-700">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
{channel.name}
</h3>
<span
className={`inline-flex rounded-full px-2 text-xs font-semibold leading-5 ${
channel.active
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
}`}
>
{channel.active ? 'Active' : 'Inactive'}
</span>
</div>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400 truncate">
{channel.url}
</p>
<div className="mt-2 flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
<span>Added: {formatDate(channel.addedAt)}</span>
<span>Last checked: {formatDate(channel.lastChecked)}</span>
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<button
onClick={() => handleToggleActive(channel.id, !channel.active)}
className="text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
{channel.active ? 'Deactivate' : 'Activate'}
</button>
<button
onClick={() => handleRefresh(channel.id)}
className="text-sm font-medium text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300"
>
Refresh
</button>
<button
onClick={() => handleDelete(channel.id)}
className="text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300"
>
Delete
</button>
</div>
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
}
export default ChannelsPage;

View File

@ -0,0 +1,252 @@
import { useState, useEffect } from 'react';
import { configAPI } from '../services/api';
import { Config, SchedulerStatus } from '../types';
function SettingsPage() {
const [config, setConfig] = useState<Config | null>(null);
const [status, setStatus] = useState<SchedulerStatus | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
useEffect(() => {
loadConfig();
loadStatus();
const interval = setInterval(loadStatus, 5000);
return () => clearInterval(interval);
}, []);
const loadConfig = async () => {
try {
setLoading(true);
const data = await configAPI.get();
setConfig(data);
setError('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load config');
} finally {
setLoading(false);
}
};
const loadStatus = async () => {
try {
const data = await configAPI.getSchedulerStatus();
setStatus(data);
} catch (err) {
console.error('Failed to load status:', err);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!config) return;
try {
setSaving(true);
setError('');
setSuccess('');
await configAPI.update(config);
setSuccess('Settings saved successfully!');
loadStatus();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save settings');
} finally {
setSaving(false);
}
};
const handleChange = (key: keyof Config, value: string) => {
if (!config) return;
setConfig({ ...config, [key]: value });
};
if (loading) {
return (
<div className="px-4 sm:px-6 lg:px-8">
<div className="p-8 text-center text-gray-500 dark:text-gray-400">Loading...</div>
</div>
);
}
if (!config) {
return (
<div className="px-4 sm:px-6 lg:px-8">
<div className="p-8 text-center text-red-500 dark:text-red-400">Failed to load configuration</div>
</div>
);
}
return (
<div className="px-4 sm:px-6 lg:px-8">
<div className="sm:flex sm:items-center">
<div className="sm:flex-auto">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Settings</h1>
<p className="mt-2 text-sm text-gray-700 dark:text-gray-300">
Configure download behavior and scheduling
</p>
</div>
</div>
{error && (
<div className="mt-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded">
{error}
</div>
)}
{success && (
<div className="mt-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-400 px-4 py-3 rounded">
{success}
</div>
)}
{status && (
<div className="mt-6 bg-white dark:bg-gray-800 shadow sm:rounded-lg p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">Scheduler Status</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-sm text-gray-500 dark:text-gray-400">Status:</span>
<span
className={`ml-2 inline-flex rounded-full px-2 text-xs font-semibold leading-5 ${
status.enabled && status.running
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
}`}
>
{status.enabled ? (status.running ? 'Running' : 'Stopped') : 'Disabled'}
</span>
</div>
<div>
<span className="text-sm text-gray-500 dark:text-gray-400">Currently Downloading:</span>
<span className="ml-2 text-sm font-medium text-gray-900 dark:text-white">
{status.isDownloading ? 'Yes' : 'No'}
</span>
</div>
{status.currentProgress && (
<>
<div className="col-span-2">
<span className="text-sm text-gray-500 dark:text-gray-400">Current Video:</span>
<span className="ml-2 text-sm font-medium text-gray-900 dark:text-white">
{status.currentProgress.videoId}
</span>
</div>
<div>
<span className="text-sm text-gray-500 dark:text-gray-400">Progress:</span>
<span className="ml-2 text-sm font-medium text-gray-900 dark:text-white">
{status.currentProgress.progress.toFixed(1)}%
</span>
</div>
<div>
<span className="text-sm text-gray-500 dark:text-gray-400">Speed:</span>
<span className="ml-2 text-sm font-medium text-gray-900 dark:text-white">
{status.currentProgress.speed}
</span>
</div>
<div>
<span className="text-sm text-gray-500 dark:text-gray-400">ETA:</span>
<span className="ml-2 text-sm font-medium text-gray-900 dark:text-white">
{status.currentProgress.eta}
</span>
</div>
</>
)}
</div>
</div>
)}
<form onSubmit={handleSubmit} className="mt-6 bg-white dark:bg-gray-800 shadow sm:rounded-lg p-6">
<div className="space-y-6">
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={config.enabled === 'true'}
onChange={(e) => handleChange('enabled', e.target.checked ? 'true' : 'false')}
className="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-700"
/>
<span className="ml-2 text-sm font-medium text-gray-900 dark:text-white">
Enable automatic downloading
</span>
</label>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
When enabled, videos will be downloaded automatically on a schedule
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Download Interval (hours)
</label>
<input
type="number"
step="0.5"
min="0.5"
value={config.intervalHours}
onChange={(e) => handleChange('intervalHours', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Average time between downloads (e.g., 3 = every 3 hours)
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Time Variance (minutes)
</label>
<input
type="number"
min="0"
value={config.varianceMinutes}
onChange={(e) => handleChange('varianceMinutes', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Random variance to make download times less predictable (±{config.varianceMinutes} minutes)
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Max Concurrent Downloads
</label>
<input
type="number"
min="1"
max="5"
value={config.maxConcurrentDownloads}
onChange={(e) => handleChange('maxConcurrentDownloads', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Number of videos to download simultaneously (recommended: 1)
</p>
</div>
</div>
<div className="mt-6 flex justify-end">
<button
type="submit"
disabled={saving}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save Settings'}
</button>
</div>
</form>
<div className="mt-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-blue-900 dark:text-blue-300">About Download Scheduling</h3>
<p className="mt-2 text-sm text-blue-700 dark:text-blue-400">
The scheduler respects rate limits by spacing out downloads over time. With the default
settings (3 hours ±30 minutes), videos will be downloaded roughly every 2.5 to 3.5 hours,
making the download pattern less predictable and more respectful to YouTube's servers.
</p>
</div>
</div>
);
}
export default SettingsPage;

View File

@ -0,0 +1,152 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { videosAPI } from '../services/api';
import { Video } from '../types';
function VideoPlayerPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [video, setVideo] = useState<Video | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
if (id) {
loadVideo(parseInt(id));
}
}, [id]);
const loadVideo = async (videoId: number) => {
try {
setLoading(true);
const data = await videosAPI.getById(videoId);
if (data.status !== 'completed') {
setError('This video has not been downloaded yet');
return;
}
setVideo(data);
setError('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load video');
} finally {
setLoading(false);
}
};
const formatFileSize = (bytes: number | null) => {
if (!bytes) return 'N/A';
const mb = bytes / (1024 * 1024);
return `${mb.toFixed(2)} MB`;
};
const formatDuration = (seconds: number | null) => {
if (!seconds) return 'N/A';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
};
if (loading) {
return (
<div className="px-4 sm:px-6 lg:px-8">
<div className="p-8 text-center text-gray-500 dark:text-gray-400">Loading...</div>
</div>
);
}
if (error || !video) {
return (
<div className="px-4 sm:px-6 lg:px-8">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded">
{error || 'Video not found'}
</div>
<button
onClick={() => navigate('/')}
className="mt-4 text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
Back to videos
</button>
</div>
);
}
return (
<div className="px-4 sm:px-6 lg:px-8">
<button
onClick={() => navigate('/')}
className="mb-4 text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 flex items-center gap-2"
>
Back to videos
</button>
<div className="bg-white dark:bg-gray-800 shadow sm:rounded-lg overflow-hidden">
<div className="aspect-video bg-black">
<video
controls
className="w-full h-full"
src={`/downloads/${video.videoId}.mp4`}
>
Your browser does not support the video tag.
</video>
</div>
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{video.title}</h1>
<div className="mt-4 grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500 dark:text-gray-400">Duration:</span>
<span className="ml-2 font-medium text-gray-900 dark:text-white">{formatDuration(video.duration)}</span>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">File Size:</span>
<span className="ml-2 font-medium text-gray-900 dark:text-white">{formatFileSize(video.fileSize)}</span>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Downloaded:</span>
<span className="ml-2 font-medium text-gray-900 dark:text-white">
{video.downloadedAt
? new Date(video.downloadedAt).toLocaleString()
: 'N/A'}
</span>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Upload Date:</span>
<span className="ml-2 font-medium text-gray-900 dark:text-white">
{video.uploadDate
? new Date(
video.uploadDate.slice(0, 4) +
'-' +
video.uploadDate.slice(4, 6) +
'-' +
video.uploadDate.slice(6, 8)
).toLocaleDateString()
: 'N/A'}
</span>
</div>
</div>
<div className="mt-6">
<a
href={video.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-sm"
>
View on YouTube
</a>
</div>
</div>
</div>
</div>
);
}
export default VideoPlayerPage;

View File

@ -0,0 +1,256 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { videosAPI } from '../services/api';
import { Video } from '../types';
function VideosPage() {
const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [addUrl, setAddUrl] = useState('');
const [adding, setAdding] = useState(false);
const [filter, setFilter] = useState<string>('all');
useEffect(() => {
loadVideos();
}, [filter]);
const loadVideos = async () => {
try {
setLoading(true);
const data = filter === 'all' ? await videosAPI.getAll() : await videosAPI.getAll(filter);
setVideos(data);
setError('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load videos');
} finally {
setLoading(false);
}
};
const handleAddVideo = async (e: React.FormEvent) => {
e.preventDefault();
if (!addUrl.trim()) return;
try {
setAdding(true);
await videosAPI.create(addUrl);
setAddUrl('');
loadVideos();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add video');
} finally {
setAdding(false);
}
};
const handleDelete = async (id: number) => {
if (!confirm('Are you sure you want to delete this video?')) return;
try {
await videosAPI.delete(id);
loadVideos();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete video');
}
};
const handleRetry = async (id: number) => {
try {
await videosAPI.retry(id);
loadVideos();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to retry video');
}
};
const formatFileSize = (bytes: number | null) => {
if (!bytes) return 'N/A';
const mb = bytes / (1024 * 1024);
return `${mb.toFixed(2)} MB`;
};
const formatDate = (dateStr: string | null) => {
if (!dateStr) return 'N/A';
return new Date(dateStr).toLocaleDateString();
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
case 'downloading':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
case 'pending':
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
case 'failed':
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
}
};
return (
<div className="px-4 sm:px-6 lg:px-8">
<div className="sm:flex sm:items-center">
<div className="sm:flex-auto">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Videos</h1>
<p className="mt-2 text-sm text-gray-700 dark:text-gray-300">
All videos from channels and individually added videos
</p>
</div>
</div>
{error && (
<div className="mt-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded">
{error}
</div>
)}
<div className="mt-6 bg-white dark:bg-gray-800 shadow sm:rounded-lg p-6">
<form onSubmit={handleAddVideo} className="flex gap-4">
<input
type="text"
value={addUrl}
onChange={(e) => setAddUrl(e.target.value)}
placeholder="Enter YouTube video URL..."
className="flex-1 rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-400 shadow-sm focus:border-blue-500 focus:ring-blue-500"
disabled={adding}
/>
<button
type="submit"
disabled={adding}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{adding ? 'Adding...' : 'Add Video'}
</button>
</form>
</div>
<div className="mt-6 flex gap-2">
<button
onClick={() => setFilter('all')}
className={`px-4 py-2 rounded ${
filter === 'all' ? 'bg-blue-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300'
}`}
>
All
</button>
<button
onClick={() => setFilter('completed')}
className={`px-4 py-2 rounded ${
filter === 'completed' ? 'bg-blue-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300'
}`}
>
Completed
</button>
<button
onClick={() => setFilter('pending')}
className={`px-4 py-2 rounded ${
filter === 'pending' ? 'bg-blue-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300'
}`}
>
Pending
</button>
<button
onClick={() => setFilter('downloading')}
className={`px-4 py-2 rounded ${
filter === 'downloading' ? 'bg-blue-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300'
}`}
>
Downloading
</button>
<button
onClick={() => setFilter('failed')}
className={`px-4 py-2 rounded ${
filter === 'failed' ? 'bg-blue-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300'
}`}
>
Failed
</button>
</div>
<div className="mt-6 bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-md">
{loading ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400">Loading...</div>
) : videos.length === 0 ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
No videos found. Add a channel or individual video to get started.
</div>
) : (
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
{videos.map((video) => (
<li key={video.id} className="p-4 hover:bg-gray-50 dark:hover:bg-gray-700">
<div className="flex items-start gap-4">
{video.thumbnail && (
<img
src={video.thumbnail}
alt={video.title}
className="w-32 h-20 object-cover rounded"
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{video.title}
</p>
<div className="mt-1 flex items-center gap-2">
<span
className={`inline-flex rounded-full px-2 text-xs font-semibold leading-5 ${getStatusColor(
video.status
)}`}
>
{video.status}
</span>
{video.fileSize && (
<span className="text-xs text-gray-500 dark:text-gray-400">
{formatFileSize(video.fileSize)}
</span>
)}
</div>
{video.error && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{video.error}</p>
)}
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Added: {formatDate(video.addedAt)}
{video.downloadedAt && ` | Downloaded: ${formatDate(video.downloadedAt)}`}
</p>
</div>
<div className="flex gap-2 ml-4">
{video.status === 'completed' && (
<Link
to={`/watch/${video.id}`}
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-sm font-medium"
>
Watch
</Link>
)}
{video.status === 'failed' && (
<button
onClick={() => handleRetry(video.id)}
className="text-yellow-600 dark:text-yellow-400 hover:text-yellow-800 dark:hover:text-yellow-300 text-sm font-medium"
>
Retry
</button>
)}
<button
onClick={() => handleDelete(video.id)}
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 text-sm font-medium"
>
Delete
</button>
</div>
</div>
</div>
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
}
export default VideosPage;

View File

@ -0,0 +1,97 @@
import { Channel, Video, Config, SchedulerStatus } from '../types';
const API_BASE = '/api';
async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Request failed' }));
throw new Error(error.error || `HTTP ${response.status}`);
}
if (response.status === 204) {
return {} as T;
}
return response.json();
}
// Channels API
export const channelsAPI = {
getAll: () => fetchJSON<Channel[]>(`${API_BASE}/channels`),
getById: (id: number) => fetchJSON<Channel>(`${API_BASE}/channels/${id}`),
getVideos: (id: number) => fetchJSON<Video[]>(`${API_BASE}/channels/${id}/videos`),
create: (url: string) =>
fetchJSON<Channel>(`${API_BASE}/channels`, {
method: 'POST',
body: JSON.stringify({ url }),
}),
update: (id: number, active: boolean) =>
fetchJSON<Channel>(`${API_BASE}/channels/${id}`, {
method: 'PATCH',
body: JSON.stringify({ active }),
}),
delete: (id: number) =>
fetchJSON<void>(`${API_BASE}/channels/${id}`, {
method: 'DELETE',
}),
refresh: (id: number) =>
fetchJSON<{ message: string }>(`${API_BASE}/channels/${id}/refresh`, {
method: 'POST',
}),
};
// Videos API
export const videosAPI = {
getAll: (status?: string) => {
const url = status
? `${API_BASE}/videos?status=${status}`
: `${API_BASE}/videos`;
return fetchJSON<Video[]>(url);
},
getById: (id: number) => fetchJSON<Video>(`${API_BASE}/videos/${id}`),
create: (url: string) =>
fetchJSON<Video>(`${API_BASE}/videos`, {
method: 'POST',
body: JSON.stringify({ url }),
}),
delete: (id: number) =>
fetchJSON<void>(`${API_BASE}/videos/${id}`, {
method: 'DELETE',
}),
retry: (id: number) =>
fetchJSON<Video>(`${API_BASE}/videos/${id}/retry`, {
method: 'POST',
}),
};
// Config API
export const configAPI = {
get: () => fetchJSON<Config>(`${API_BASE}/config`),
update: (config: Partial<Config>) =>
fetchJSON<Config>(`${API_BASE}/config`, {
method: 'PATCH',
body: JSON.stringify(config),
}),
getSchedulerStatus: () =>
fetchJSON<SchedulerStatus>(`${API_BASE}/config/scheduler/status`),
};

View File

@ -0,0 +1,49 @@
export interface Channel {
id: number;
url: string;
name: string;
channelId: string;
addedAt: string;
lastChecked: string | null;
active: boolean;
}
export interface Video {
id: number;
channelId: number | null;
videoId: string;
title: string;
url: string;
duration: number | null;
thumbnail: string | null;
uploadDate: string | null;
status: 'pending' | 'downloading' | 'completed' | 'failed';
filePath: string | null;
fileSize: number | null;
addedAt: string;
downloadedAt: string | null;
error: string | null;
}
export interface Config {
intervalHours: string;
varianceMinutes: string;
maxConcurrentDownloads: string;
enabled: string;
}
export interface SchedulerStatus {
running: boolean;
enabled: boolean;
intervalHours: number;
varianceMinutes: number;
isDownloading: boolean;
currentProgress: DownloadProgress | null;
}
export interface DownloadProgress {
videoId: string;
progress: number;
speed: string;
eta: string;
}

View File

@ -0,0 +1,11 @@
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
}

21
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

19
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true
},
'/downloads': {
target: 'http://localhost:3001',
changeOrigin: true
}
}
}
})

13
package-lock.json generated Normal file
View File

@ -0,0 +1,13 @@
{
"name": "vidrip",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "vidrip",
"version": "1.0.0",
"license": "MIT"
}
}
}

17
package.json Normal file
View File

@ -0,0 +1,17 @@
{
"name": "vidrip",
"version": "1.0.0",
"description": "Respectful YouTube video drip downloader",
"scripts": {
"install:all": "cd backend && npm install && cd ../frontend && npm install",
"dev:backend": "cd backend && npm run dev",
"dev:frontend": "cd frontend && npm run dev",
"build:backend": "cd backend && npm run build",
"build:frontend": "cd frontend && npm run build",
"start:backend": "cd backend && npm start",
"start:frontend": "cd frontend && npm run preview"
},
"keywords": ["youtube", "downloader", "yt-dlp"],
"author": "",
"license": "MIT"
}