QoL features
This commit is contained in:
parent
ad83f0643c
commit
d2f9045084
43
CLAUDE.md
43
CLAUDE.md
|
|
@ -110,3 +110,46 @@ Adding a channel fetches ALL videos from that channel using yt-dlp's `--flat-pla
|
|||
|
||||
- Node.js 18+
|
||||
- yt-dlp installed and in PATH (`pip install yt-dlp` or `brew install yt-dlp`)
|
||||
|
||||
## Troubleshooting 403 Errors
|
||||
|
||||
If you encounter 403 Forbidden errors when downloading videos:
|
||||
|
||||
### Built-in Mitigations
|
||||
|
||||
The app automatically includes these anti-403 measures:
|
||||
- Browser-like User-Agent headers
|
||||
- YouTube referer
|
||||
- Multiple retry attempts (5 extractor retries, 10 connection retries)
|
||||
- Request throttling (1-5 second delays between requests)
|
||||
- Fragment retries for long videos
|
||||
|
||||
### Additional Solution: Browser Cookies
|
||||
|
||||
For persistent 403 errors, export cookies from your browser:
|
||||
|
||||
1. **Install browser extension**:
|
||||
- Chrome/Edge: "Get cookies.txt LOCALLY"
|
||||
- Firefox: "cookies.txt"
|
||||
|
||||
2. **Export YouTube cookies**:
|
||||
- Visit youtube.com while logged in
|
||||
- Click extension icon and export cookies
|
||||
- Save as `cookies.txt`
|
||||
|
||||
3. **Add to VidRip**:
|
||||
- Place `cookies.txt` in `backend/` directory
|
||||
- VidRip will automatically use it if present
|
||||
- No restart needed
|
||||
|
||||
4. **Keep cookies fresh**:
|
||||
- YouTube cookies expire periodically
|
||||
- Re-export if 403 errors return
|
||||
- Consider using an account with YouTube Premium to avoid restrictions
|
||||
|
||||
### Other Tips
|
||||
|
||||
- Update yt-dlp: `pip install --upgrade yt-dlp` (YouTube changes frequently)
|
||||
- Increase download interval to avoid rate limiting
|
||||
- Use variance to randomize download times
|
||||
- Avoid downloading too many videos from the same channel quickly
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import { Channel, Video, Config } from '../types';
|
||||
import { Channel, Playlist, Video, Config } from '../types';
|
||||
|
||||
const dbPath = path.join(__dirname, '../../data.db');
|
||||
const db = new Database(dbPath);
|
||||
|
|
@ -9,7 +9,7 @@ const db = new Database(dbPath);
|
|||
db.pragma('foreign_keys = ON');
|
||||
|
||||
export function initDatabase() {
|
||||
// Create tables
|
||||
// Create base tables first (without foreign keys that reference tables not yet created)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
|
@ -21,22 +21,14 @@ export function initDatabase() {
|
|||
active INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS videos (
|
||||
CREATE TABLE IF NOT EXISTS playlists (
|
||||
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,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
playlistId TEXT NOT NULL,
|
||||
addedAt TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
downloadedAt TEXT,
|
||||
error TEXT,
|
||||
FOREIGN KEY (channelId) REFERENCES channels(id) ON DELETE CASCADE
|
||||
lastChecked TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
|
|
@ -44,14 +36,57 @@ export function initDatabase() {
|
|||
key TEXT NOT NULL UNIQUE,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Check if videos table exists and if it has playlistId column
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='videos'").all() as Array<{ name: string }>;
|
||||
const videosTableExists = tables.length > 0;
|
||||
|
||||
if (videosTableExists) {
|
||||
// Videos table exists - check if it needs migration
|
||||
const tableInfo = db.prepare("PRAGMA table_info(videos)").all() as Array<{ name: string }>;
|
||||
const hasPlaylistId = tableInfo.some(col => col.name === 'playlistId');
|
||||
|
||||
if (!hasPlaylistId) {
|
||||
console.log('Migrating database: Adding playlistId column to videos table...');
|
||||
db.exec('ALTER TABLE videos ADD COLUMN playlistId INTEGER');
|
||||
console.log('Migration completed successfully');
|
||||
}
|
||||
} else {
|
||||
// Videos table doesn't exist - create it with playlistId
|
||||
db.exec(`
|
||||
CREATE TABLE videos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
channelId INTEGER,
|
||||
playlistId 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,
|
||||
FOREIGN KEY (playlistId) REFERENCES playlists(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
// Create indexes
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_videos_status ON videos(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_videos_channelId ON videos(channelId);
|
||||
CREATE INDEX IF NOT EXISTS idx_videos_playlistId ON videos(playlistId);
|
||||
`);
|
||||
|
||||
// Initialize default config
|
||||
const defaultConfig = {
|
||||
intervalHours: '3',
|
||||
intervalMinutes: '180', // 180 minutes = 3 hours
|
||||
varianceMinutes: '30',
|
||||
maxConcurrentDownloads: '1',
|
||||
enabled: 'true'
|
||||
|
|
@ -65,6 +100,21 @@ export function initDatabase() {
|
|||
insertConfig.run(key, value);
|
||||
});
|
||||
|
||||
// Migration: Convert intervalHours to intervalMinutes if it exists
|
||||
try {
|
||||
const intervalHours = configOperations.get('intervalHours');
|
||||
if (intervalHours) {
|
||||
const hours = parseFloat(intervalHours);
|
||||
const minutes = hours * 60;
|
||||
console.log(`Migrating config: Converting ${hours} hours to ${minutes} minutes`);
|
||||
configOperations.set('intervalMinutes', minutes.toString());
|
||||
// Remove old key
|
||||
db.prepare('DELETE FROM config WHERE key = ?').run('intervalHours');
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore migration errors
|
||||
}
|
||||
|
||||
console.log('Database initialized');
|
||||
}
|
||||
|
||||
|
|
@ -106,6 +156,44 @@ export const channelOperations = {
|
|||
}
|
||||
};
|
||||
|
||||
// Playlist operations
|
||||
export const playlistOperations = {
|
||||
getAll: () => {
|
||||
return db.prepare('SELECT * FROM playlists ORDER BY addedAt DESC').all() as Playlist[];
|
||||
},
|
||||
|
||||
getById: (id: number) => {
|
||||
return db.prepare('SELECT * FROM playlists WHERE id = ?').get(id) as Playlist | undefined;
|
||||
},
|
||||
|
||||
create: (url: string, name: string, playlistId: string) => {
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO playlists (url, name, playlistId) VALUES (?, ?, ?)'
|
||||
);
|
||||
const result = stmt.run(url, name, playlistId);
|
||||
return result.lastInsertRowid as number;
|
||||
},
|
||||
|
||||
update: (id: number, data: Partial<Playlist>) => {
|
||||
const fields = Object.keys(data).filter(k => k !== 'id');
|
||||
const values = fields.map(k => data[k as keyof Playlist]);
|
||||
const setClause = fields.map(f => `${f} = ?`).join(', ');
|
||||
|
||||
const stmt = db.prepare(`UPDATE playlists SET ${setClause} WHERE id = ?`);
|
||||
return stmt.run(...values, id);
|
||||
},
|
||||
|
||||
delete: (id: number) => {
|
||||
return db.prepare('DELETE FROM playlists WHERE id = ?').run(id);
|
||||
},
|
||||
|
||||
updateLastChecked: (id: number) => {
|
||||
return db.prepare(
|
||||
"UPDATE playlists SET lastChecked = datetime('now') WHERE id = ?"
|
||||
).run(id);
|
||||
}
|
||||
};
|
||||
|
||||
// Video operations
|
||||
export const videoOperations = {
|
||||
getAll: () => {
|
||||
|
|
@ -121,6 +209,11 @@ export const videoOperations = {
|
|||
.all(channelId) as Video[];
|
||||
},
|
||||
|
||||
getByPlaylistId: (playlistId: number) => {
|
||||
return db.prepare('SELECT * FROM videos WHERE playlistId = ? ORDER BY addedAt DESC')
|
||||
.all(playlistId) as Video[];
|
||||
},
|
||||
|
||||
getByStatus: (status: string) => {
|
||||
return db.prepare('SELECT * FROM videos WHERE status = ? ORDER BY addedAt ASC')
|
||||
.all(status) as Video[];
|
||||
|
|
@ -147,11 +240,12 @@ export const videoOperations = {
|
|||
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO videos (channelId, playlistId, videoId, title, url, duration, thumbnail, uploadDate, status, filePath, fileSize, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const result = stmt.run(
|
||||
video.channelId,
|
||||
video.playlistId,
|
||||
video.videoId,
|
||||
video.title,
|
||||
video.url,
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ router.post('/', async (req, res) => {
|
|||
try {
|
||||
videoOperations.create({
|
||||
channelId,
|
||||
playlistId: null,
|
||||
videoId: videoInfo.id,
|
||||
title: videoInfo.title,
|
||||
url: videoInfo.url,
|
||||
|
|
@ -154,6 +155,7 @@ router.post('/:id/refresh', async (req, res) => {
|
|||
try {
|
||||
videoOperations.create({
|
||||
channelId: id,
|
||||
playlistId: null,
|
||||
videoId: videoInfo.id,
|
||||
title: videoInfo.title,
|
||||
url: videoInfo.url,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
import { Router } from 'express';
|
||||
import { playlistOperations, videoOperations } from '../db/database';
|
||||
import { getPlaylistInfo, getPlaylistVideos } from '../services/ytdlp';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Get all playlists
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const playlists = playlistOperations.getAll();
|
||||
res.json(playlists);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch playlists' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get playlist by ID
|
||||
router.get('/:id', (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const playlist = playlistOperations.getById(id);
|
||||
|
||||
if (!playlist) {
|
||||
return res.status(404).json({ error: 'Playlist not found' });
|
||||
}
|
||||
|
||||
res.json(playlist);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch playlist' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get videos for a playlist
|
||||
router.get('/:id/videos', (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const videos = videoOperations.getByPlaylistId(id);
|
||||
res.json(videos);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch videos' });
|
||||
}
|
||||
});
|
||||
|
||||
// Add a new playlist
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { url } = req.body;
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL is required' });
|
||||
}
|
||||
|
||||
// Get playlist info from yt-dlp
|
||||
const playlistInfo = await getPlaylistInfo(url);
|
||||
|
||||
// Create playlist
|
||||
const playlistId = playlistOperations.create(
|
||||
url,
|
||||
playlistInfo.name,
|
||||
playlistInfo.id
|
||||
);
|
||||
|
||||
// Fetch videos from the playlist
|
||||
const videos = await getPlaylistVideos(url);
|
||||
|
||||
// Add videos to database
|
||||
for (const videoInfo of videos) {
|
||||
try {
|
||||
videoOperations.create({
|
||||
channelId: null,
|
||||
playlistId,
|
||||
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 playlist = playlistOperations.getById(playlistId);
|
||||
res.status(201).json(playlist);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error adding playlist:', error);
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to add playlist'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update playlist
|
||||
router.patch('/:id', (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const { active } = req.body;
|
||||
|
||||
const playlist = playlistOperations.getById(id);
|
||||
if (!playlist) {
|
||||
return res.status(404).json({ error: 'Playlist not found' });
|
||||
}
|
||||
|
||||
if (active !== undefined) {
|
||||
playlistOperations.update(id, { active });
|
||||
}
|
||||
|
||||
const updated = playlistOperations.getById(id);
|
||||
res.json(updated);
|
||||
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to update playlist' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete playlist
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
|
||||
const playlist = playlistOperations.getById(id);
|
||||
if (!playlist) {
|
||||
return res.status(404).json({ error: 'Playlist not found' });
|
||||
}
|
||||
|
||||
playlistOperations.delete(id);
|
||||
res.status(204).send();
|
||||
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to delete playlist' });
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh playlist videos
|
||||
router.post('/:id/refresh', async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
|
||||
const playlist = playlistOperations.getById(id);
|
||||
if (!playlist) {
|
||||
return res.status(404).json({ error: 'Playlist not found' });
|
||||
}
|
||||
|
||||
const videos = await getPlaylistVideos(playlist.url);
|
||||
|
||||
let addedCount = 0;
|
||||
for (const videoInfo of videos) {
|
||||
try {
|
||||
videoOperations.create({
|
||||
channelId: null,
|
||||
playlistId: 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
|
||||
}
|
||||
}
|
||||
|
||||
playlistOperations.updateLastChecked(id);
|
||||
|
||||
res.json({ message: `Added ${addedCount} new videos` });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error refreshing playlist:', error);
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to refresh playlist'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
import { Router } from 'express';
|
||||
import { videoOperations } from '../db/database';
|
||||
import { getVideoInfo } from '../services/ytdlp';
|
||||
import { getVideoInfo, downloadVideo } from '../services/ytdlp';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Track ongoing downloads to prevent duplicates
|
||||
const downloadingVideos = new Set<number>();
|
||||
|
||||
// Get all videos
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
|
|
@ -62,6 +65,7 @@ router.post('/', async (req, res) => {
|
|||
// Create video
|
||||
const videoId = videoOperations.create({
|
||||
channelId: null,
|
||||
playlistId: null,
|
||||
videoId: videoInfo.id,
|
||||
title: videoInfo.title,
|
||||
url: videoInfo.url,
|
||||
|
|
@ -132,4 +136,54 @@ router.post('/:id/retry', (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// Download video immediately
|
||||
router.post('/:id/download', async (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 !== 'pending') {
|
||||
return res.status(400).json({ error: 'Only pending videos can be downloaded' });
|
||||
}
|
||||
|
||||
if (downloadingVideos.has(id)) {
|
||||
return res.status(409).json({ error: 'Video is already being downloaded' });
|
||||
}
|
||||
|
||||
// Start download in background
|
||||
downloadingVideos.add(id);
|
||||
|
||||
// Don't await - let it run in background
|
||||
(async () => {
|
||||
try {
|
||||
console.log(`Starting immediate download: ${video.title} (${video.videoId})`);
|
||||
videoOperations.updateStatus(id, 'downloading');
|
||||
|
||||
const result = await downloadVideo(video.videoId, video.url);
|
||||
videoOperations.markCompleted(id, result.filePath, result.fileSize);
|
||||
console.log(`Immediate download completed: ${video.title}`);
|
||||
} catch (error) {
|
||||
console.error('Immediate download error:', error);
|
||||
videoOperations.updateStatus(
|
||||
id,
|
||||
'failed',
|
||||
error instanceof Error ? error.message : 'Download failed'
|
||||
);
|
||||
} finally {
|
||||
downloadingVideos.delete(id);
|
||||
}
|
||||
})();
|
||||
|
||||
// Return immediately
|
||||
res.json({ message: 'Download started', videoId: video.videoId });
|
||||
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to start download' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import path from 'path';
|
|||
import { initDatabase } from './db/database';
|
||||
import { startScheduler } from './services/scheduler';
|
||||
import channelsRouter from './routes/channels';
|
||||
import playlistsRouter from './routes/playlists';
|
||||
import videosRouter from './routes/videos';
|
||||
import configRouter from './routes/config';
|
||||
|
||||
|
|
@ -19,6 +20,7 @@ initDatabase();
|
|||
|
||||
// API routes
|
||||
app.use('/api/channels', channelsRouter);
|
||||
app.use('/api/playlists', playlistsRouter);
|
||||
app.use('/api/videos', videosRouter);
|
||||
app.use('/api/config', configRouter);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,10 +11,9 @@ function getRandomVariance(varianceMinutes: number): number {
|
|||
return Math.floor(Math.random() * varianceMinutes * 2) - varianceMinutes;
|
||||
}
|
||||
|
||||
function calculateNextRun(intervalHours: number, varianceMinutes: number): Date {
|
||||
const baseMinutes = intervalHours * 60;
|
||||
function calculateNextRun(intervalMinutes: number, varianceMinutes: number): Date {
|
||||
const variance = getRandomVariance(varianceMinutes);
|
||||
const totalMinutes = baseMinutes + variance;
|
||||
const totalMinutes = intervalMinutes + variance;
|
||||
|
||||
const nextRun = new Date();
|
||||
nextRun.setMinutes(nextRun.getMinutes() + totalMinutes);
|
||||
|
|
@ -88,6 +87,7 @@ async function checkChannelsForNewVideos() {
|
|||
console.log(`Found new video: ${videoInfo.title}`);
|
||||
videoOperations.create({
|
||||
channelId: channel.id,
|
||||
playlistId: null,
|
||||
videoId: videoInfo.id,
|
||||
title: videoInfo.title,
|
||||
url: videoInfo.url,
|
||||
|
|
@ -123,10 +123,10 @@ export function startScheduler() {
|
|||
return;
|
||||
}
|
||||
|
||||
const intervalHours = parseFloat(config.intervalHours || '3');
|
||||
const intervalMinutes = parseFloat(config.intervalMinutes || '180'); // Default 180 minutes (3 hours)
|
||||
const varianceMinutes = parseFloat(config.varianceMinutes || '30');
|
||||
|
||||
console.log(`Starting scheduler: ${intervalHours}h ±${varianceMinutes}m`);
|
||||
console.log(`Starting scheduler: ${intervalMinutes}min ±${varianceMinutes}min`);
|
||||
|
||||
// Check for new videos every hour
|
||||
schedulerTask = cron.schedule('0 * * * *', async () => {
|
||||
|
|
@ -144,11 +144,13 @@ export function startScheduler() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Download video first (this may take a long time)
|
||||
await downloadNextVideo();
|
||||
|
||||
const intervalHours = parseFloat(config.intervalHours || '3');
|
||||
// AFTER download completes, calculate next run
|
||||
const intervalMinutes = parseFloat(config.intervalMinutes || '180'); // Default 180 minutes (3 hours)
|
||||
const varianceMinutes = parseFloat(config.varianceMinutes || '30');
|
||||
const nextRun = calculateNextRun(intervalHours, varianceMinutes);
|
||||
const nextRun = calculateNextRun(intervalMinutes, varianceMinutes);
|
||||
|
||||
console.log(`Next download scheduled for: ${nextRun.toLocaleString()}`);
|
||||
|
||||
|
|
@ -180,7 +182,7 @@ export function getSchedulerStatus() {
|
|||
return {
|
||||
running: schedulerTask !== null,
|
||||
enabled: config.enabled === 'true',
|
||||
intervalHours: parseFloat(config.intervalHours || '3'),
|
||||
intervalMinutes: parseFloat(config.intervalMinutes || '180'),
|
||||
varianceMinutes: parseFloat(config.varianceMinutes || '30'),
|
||||
isDownloading,
|
||||
currentProgress
|
||||
|
|
|
|||
|
|
@ -9,6 +9,26 @@ if (!fs.existsSync(DOWNLOADS_DIR)) {
|
|||
fs.mkdirSync(DOWNLOADS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Path to cookies file (optional, for additional authentication)
|
||||
const COOKIES_PATH = path.join(__dirname, '../../cookies.txt');
|
||||
|
||||
// Common args to avoid 403 errors
|
||||
const getCommonArgs = () => {
|
||||
const args = [
|
||||
'--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'--referer', 'https://www.youtube.com/',
|
||||
'--extractor-retries', '5',
|
||||
'--retries', '10'
|
||||
];
|
||||
|
||||
// Add cookies if file exists
|
||||
if (fs.existsSync(COOKIES_PATH)) {
|
||||
args.push('--cookies', COOKIES_PATH);
|
||||
}
|
||||
|
||||
return args;
|
||||
};
|
||||
|
||||
export interface VideoInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
|
|
@ -26,9 +46,16 @@ export interface ChannelInfo {
|
|||
url: string;
|
||||
}
|
||||
|
||||
export interface PlaylistInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export async function getVideoInfo(url: string): Promise<VideoInfo> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
...getCommonArgs(),
|
||||
'--dump-json',
|
||||
'--no-playlist',
|
||||
url
|
||||
|
|
@ -74,6 +101,7 @@ export async function getVideoInfo(url: string): Promise<VideoInfo> {
|
|||
export async function getChannelVideos(channelUrl: string): Promise<VideoInfo[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
...getCommonArgs(),
|
||||
'--dump-json',
|
||||
'--flat-playlist',
|
||||
channelUrl
|
||||
|
|
@ -123,6 +151,7 @@ export async function getChannelVideos(channelUrl: string): Promise<VideoInfo[]>
|
|||
export async function getChannelInfo(channelUrl: string): Promise<ChannelInfo> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
...getCommonArgs(),
|
||||
'--dump-json',
|
||||
'--playlist-items',
|
||||
'1',
|
||||
|
|
@ -162,6 +191,101 @@ export async function getChannelInfo(channelUrl: string): Promise<ChannelInfo> {
|
|||
});
|
||||
}
|
||||
|
||||
export async function getPlaylistVideos(playlistUrl: string): Promise<VideoInfo[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
...getCommonArgs(),
|
||||
'--dump-json',
|
||||
'--flat-playlist',
|
||||
'--yes-playlist',
|
||||
playlistUrl
|
||||
];
|
||||
|
||||
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 playlist videos: ${error}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPlaylistInfo(playlistUrl: string): Promise<PlaylistInfo> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
...getCommonArgs(),
|
||||
'--dump-json',
|
||||
'--playlist-items',
|
||||
'1',
|
||||
'--yes-playlist',
|
||||
playlistUrl
|
||||
];
|
||||
|
||||
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.playlist_id || info.id,
|
||||
name: info.playlist_title || info.playlist || info.title || 'Unknown Playlist',
|
||||
url: playlistUrl
|
||||
});
|
||||
} catch (error) {
|
||||
reject(new Error(`Failed to parse playlist info: ${error}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface DownloadOptions {
|
||||
onProgress?: (progress: number, speed: string, eta: string) => void;
|
||||
}
|
||||
|
|
@ -175,12 +299,22 @@ export async function downloadVideo(
|
|||
const outputTemplate = path.join(DOWNLOADS_DIR, `${videoId}.%(ext)s`);
|
||||
|
||||
const args = [
|
||||
...getCommonArgs(),
|
||||
'-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best',
|
||||
'--merge-output-format', 'mp4',
|
||||
'-o', outputTemplate,
|
||||
'--no-playlist',
|
||||
'--progress',
|
||||
'--newline',
|
||||
// Additional anti-403 measures for downloads
|
||||
'--add-header', 'Accept-Language:en-US,en;q=0.9',
|
||||
'--add-header', 'Accept:text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'--add-header', 'Accept-Encoding:gzip, deflate, br',
|
||||
'--add-header', 'Connection:keep-alive',
|
||||
'--fragment-retries', '10',
|
||||
'--sleep-requests', '1',
|
||||
'--sleep-interval', '1',
|
||||
'--max-sleep-interval', '5',
|
||||
url
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,20 @@ export interface Channel {
|
|||
active: boolean;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
id: number;
|
||||
url: string;
|
||||
name: string;
|
||||
playlistId: string;
|
||||
addedAt: string;
|
||||
lastChecked: string | null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface Video {
|
||||
id: number;
|
||||
channelId: number | null;
|
||||
playlistId: number | null;
|
||||
videoId: string;
|
||||
title: string;
|
||||
url: string;
|
||||
|
|
@ -32,7 +43,7 @@ export interface Config {
|
|||
}
|
||||
|
||||
export interface DownloadConfig {
|
||||
intervalHours: number;
|
||||
intervalMinutes: number;
|
||||
varianceMinutes: number;
|
||||
maxConcurrentDownloads: number;
|
||||
enabled: boolean;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
|
||||
import { DarkModeProvider, useDarkMode } from './contexts/DarkModeContext';
|
||||
import ChannelsPage from './pages/ChannelsPage';
|
||||
import PlaylistsPage from './pages/PlaylistsPage';
|
||||
import VideosPage from './pages/VideosPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import VideoPlayerPage from './pages/VideoPlayerPage';
|
||||
|
|
@ -31,6 +32,12 @@ function AppContent() {
|
|||
>
|
||||
Channels
|
||||
</Link>
|
||||
<Link
|
||||
to="/playlists"
|
||||
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"
|
||||
>
|
||||
Playlists
|
||||
</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"
|
||||
|
|
@ -64,6 +71,7 @@ function AppContent() {
|
|||
<Routes>
|
||||
<Route path="/" element={<VideosPage />} />
|
||||
<Route path="/channels" element={<ChannelsPage />} />
|
||||
<Route path="/playlists" element={<PlaylistsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/watch/:id" element={<VideoPlayerPage />} />
|
||||
</Routes>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { playlistsAPI } from '../services/api';
|
||||
import { Playlist } from '../types';
|
||||
|
||||
function PlaylistsPage() {
|
||||
const [playlists, setPlaylists] = useState<Playlist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [addUrl, setAddUrl] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadPlaylists();
|
||||
}, []);
|
||||
|
||||
const loadPlaylists = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await playlistsAPI.getAll();
|
||||
setPlaylists(data);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load playlists');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddPlaylist = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!addUrl.trim()) return;
|
||||
|
||||
try {
|
||||
setAdding(true);
|
||||
setError('');
|
||||
await playlistsAPI.create(addUrl);
|
||||
setAddUrl('');
|
||||
loadPlaylists();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to add playlist');
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActive = async (id: number, active: boolean) => {
|
||||
try {
|
||||
await playlistsAPI.update(id, active);
|
||||
loadPlaylists();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update playlist');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Are you sure you want to delete this playlist? All associated videos will also be deleted.')) return;
|
||||
|
||||
try {
|
||||
await playlistsAPI.delete(id);
|
||||
loadPlaylists();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete playlist');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async (id: number) => {
|
||||
try {
|
||||
const result = await playlistsAPI.refresh(id);
|
||||
alert(result.message);
|
||||
loadPlaylists();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to refresh playlist');
|
||||
}
|
||||
};
|
||||
|
||||
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">Playlists</h1>
|
||||
<p className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Manage YouTube playlists 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={handleAddPlaylist} className="flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
value={addUrl}
|
||||
onChange={(e) => setAddUrl(e.target.value)}
|
||||
placeholder="Enter YouTube playlist 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 Playlist'}
|
||||
</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 playlist 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>
|
||||
) : playlists.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
No playlists added yet. Add a playlist URL above to get started.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{playlists.map((playlist) => (
|
||||
<li key={playlist.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">
|
||||
{playlist.name}
|
||||
</h3>
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 text-xs font-semibold leading-5 ${
|
||||
playlist.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'
|
||||
}`}
|
||||
>
|
||||
{playlist.active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||
{playlist.url}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>Added: {formatDate(playlist.addedAt)}</span>
|
||||
<span>Last checked: {formatDate(playlist.lastChecked)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<button
|
||||
onClick={() => handleToggleActive(playlist.id, !playlist.active)}
|
||||
className="text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
|
||||
>
|
||||
{playlist.active ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRefresh(playlist.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(playlist.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 PlaylistsPage;
|
||||
|
|
@ -177,18 +177,18 @@ function SettingsPage() {
|
|||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Download Interval (hours)
|
||||
Download Interval (minutes)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0.5"
|
||||
value={config.intervalHours}
|
||||
onChange={(e) => handleChange('intervalHours', e.target.value)}
|
||||
step="1"
|
||||
min="1"
|
||||
value={config.intervalMinutes}
|
||||
onChange={(e) => handleChange('intervalMinutes', 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)
|
||||
Time to wait AFTER a download completes before starting the next one (e.g., 180 = 3 hours, 60 = 1 hour, 30 = 30 minutes)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ function VideoPlayerPage() {
|
|||
const [video, setVideo] = useState<Video | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [theaterMode, setTheaterMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
|
|
@ -78,16 +79,22 @@ function VideoPlayerPage() {
|
|||
}
|
||||
|
||||
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={theaterMode ? '' : 'px-4 sm:px-6 lg:px-8'}>
|
||||
{!theaterMode && (
|
||||
<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">
|
||||
<div className={`${theaterMode ? 'w-screen relative left-1/2 -translate-x-1/2' : ''} bg-white dark:bg-gray-800 shadow overflow-hidden ${
|
||||
theaterMode ? '' : 'sm:rounded-lg'
|
||||
}`}>
|
||||
<div className={`bg-black relative ${
|
||||
theaterMode ? '' : 'aspect-video'
|
||||
}`}>
|
||||
<video
|
||||
controls
|
||||
className="w-full h-full"
|
||||
|
|
@ -95,55 +102,74 @@ function VideoPlayerPage() {
|
|||
>
|
||||
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>
|
||||
{/* Theater Mode Toggle Button */}
|
||||
<button
|
||||
onClick={() => setTheaterMode(!theaterMode)}
|
||||
className="absolute top-4 right-4 bg-black/70 hover:bg-black/90 text-white p-2 rounded-lg transition-colors z-10"
|
||||
title={theaterMode ? 'Exit Theater Mode' : 'Enter Theater Mode'}
|
||||
>
|
||||
{theaterMode ? (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 5l7 7-7 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<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"
|
||||
{/* Exit Theater Mode Button (appears on hover in theater mode) */}
|
||||
{theaterMode && (
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="absolute top-4 left-4 bg-black/70 hover:bg-black/90 text-white px-4 py-2 rounded-lg transition-colors z-10 flex items-center gap-2"
|
||||
>
|
||||
View on YouTube →
|
||||
</a>
|
||||
</div>
|
||||
← Back to videos
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!theaterMode && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { videosAPI } from '../services/api';
|
||||
import { Video } from '../types';
|
||||
import { videosAPI, configAPI } from '../services/api';
|
||||
import { Video, SchedulerStatus } from '../types';
|
||||
|
||||
function VideosPage() {
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
|
|
@ -11,9 +11,15 @@ function VideosPage() {
|
|||
const [adding, setAdding] = useState(false);
|
||||
const [filter, setFilter] = useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [schedulerStatus, setSchedulerStatus] = useState<SchedulerStatus | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadVideos();
|
||||
loadSchedulerStatus();
|
||||
|
||||
// Poll scheduler status every 2 seconds to update progress
|
||||
const interval = setInterval(loadSchedulerStatus, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [filter, searchQuery]);
|
||||
|
||||
const loadVideos = async () => {
|
||||
|
|
@ -31,6 +37,16 @@ function VideosPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const loadSchedulerStatus = async () => {
|
||||
try {
|
||||
const status = await configAPI.getSchedulerStatus();
|
||||
setSchedulerStatus(status);
|
||||
} catch (err) {
|
||||
// Silently fail - don't show error for status updates
|
||||
console.error('Failed to load scheduler status:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddVideo = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!addUrl.trim()) return;
|
||||
|
|
@ -67,6 +83,15 @@ function VideosPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleDownloadNow = async (id: number) => {
|
||||
try {
|
||||
await videosAPI.downloadNow(id);
|
||||
loadVideos();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to start download');
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number | null) => {
|
||||
if (!bytes) return 'N/A';
|
||||
const mb = bytes / (1024 * 1024);
|
||||
|
|
@ -247,6 +272,24 @@ function VideosPage() {
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
{video.status === 'downloading' && schedulerStatus?.currentProgress?.videoId === video.videoId && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{schedulerStatus.currentProgress.progress.toFixed(1)}%
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{schedulerStatus.currentProgress.speed} • ETA: {schedulerStatus.currentProgress.eta}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 dark:bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${schedulerStatus.currentProgress.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{video.error && (
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{video.error}</p>
|
||||
)}
|
||||
|
|
@ -264,6 +307,14 @@ function VideosPage() {
|
|||
Watch
|
||||
</Link>
|
||||
)}
|
||||
{video.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => handleDownloadNow(video.id)}
|
||||
className="text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300 text-sm font-medium"
|
||||
>
|
||||
Download Now
|
||||
</button>
|
||||
)}
|
||||
{video.status === 'failed' && (
|
||||
<button
|
||||
onClick={() => handleRetry(video.id)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Channel, Video, Config, SchedulerStatus } from '../types';
|
||||
import { Channel, Playlist, Video, Config, SchedulerStatus } from '../types';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
|
|
@ -54,6 +54,37 @@ export const channelsAPI = {
|
|||
}),
|
||||
};
|
||||
|
||||
// Playlists API
|
||||
export const playlistsAPI = {
|
||||
getAll: () => fetchJSON<Playlist[]>(`${API_BASE}/playlists`),
|
||||
|
||||
getById: (id: number) => fetchJSON<Playlist>(`${API_BASE}/playlists/${id}`),
|
||||
|
||||
getVideos: (id: number) => fetchJSON<Video[]>(`${API_BASE}/playlists/${id}/videos`),
|
||||
|
||||
create: (url: string) =>
|
||||
fetchJSON<Playlist>(`${API_BASE}/playlists`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url }),
|
||||
}),
|
||||
|
||||
update: (id: number, active: boolean) =>
|
||||
fetchJSON<Playlist>(`${API_BASE}/playlists/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ active }),
|
||||
}),
|
||||
|
||||
delete: (id: number) =>
|
||||
fetchJSON<void>(`${API_BASE}/playlists/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
|
||||
refresh: (id: number) =>
|
||||
fetchJSON<{ message: string }>(`${API_BASE}/playlists/${id}/refresh`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
};
|
||||
|
||||
// Videos API
|
||||
export const videosAPI = {
|
||||
getAll: (status?: string, search?: string) => {
|
||||
|
|
@ -85,6 +116,11 @@ export const videosAPI = {
|
|||
fetchJSON<Video>(`${API_BASE}/videos/${id}/retry`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
downloadNow: (id: number) =>
|
||||
fetchJSON<{ message: string; videoId: string }>(`${API_BASE}/videos/${id}/download`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
};
|
||||
|
||||
// Config API
|
||||
|
|
|
|||
|
|
@ -8,9 +8,20 @@ export interface Channel {
|
|||
active: boolean;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
id: number;
|
||||
url: string;
|
||||
name: string;
|
||||
playlistId: string;
|
||||
addedAt: string;
|
||||
lastChecked: string | null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface Video {
|
||||
id: number;
|
||||
channelId: number | null;
|
||||
playlistId: number | null;
|
||||
videoId: string;
|
||||
title: string;
|
||||
url: string;
|
||||
|
|
@ -26,7 +37,7 @@ export interface Video {
|
|||
}
|
||||
|
||||
export interface Config {
|
||||
intervalHours: string;
|
||||
intervalMinutes: string;
|
||||
varianceMinutes: string;
|
||||
maxConcurrentDownloads: string;
|
||||
enabled: string;
|
||||
|
|
@ -35,7 +46,7 @@ export interface Config {
|
|||
export interface SchedulerStatus {
|
||||
running: boolean;
|
||||
enabled: boolean;
|
||||
intervalHours: number;
|
||||
intervalMinutes: number;
|
||||
varianceMinutes: number;
|
||||
isDownloading: boolean;
|
||||
currentProgress: DownloadProgress | null;
|
||||
|
|
|
|||
Loading…
Reference in New Issue