Initial commit
This commit is contained in:
commit
a0326882f5
|
|
@ -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
|
||||
|
|
@ -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`)
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
PORT=3001
|
||||
NODE_ENV=development
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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`),
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
|
@ -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" }]
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "vidrip",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "vidrip",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
Loading…
Reference in New Issue