diff --git a/CLAUDE.md b/CLAUDE.md index cdfb6f1..7623722 100644 --- a/CLAUDE.md +++ b/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 diff --git a/backend/src/db/database.ts b/backend/src/db/database.ts index 15169e4..be52f89 100644 --- a/backend/src/db/database.ts +++ b/backend/src/db/database.ts @@ -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) => { + 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) => { 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, diff --git a/backend/src/routes/channels.ts b/backend/src/routes/channels.ts index 5136cc0..4c55a43 100644 --- a/backend/src/routes/channels.ts +++ b/backend/src/routes/channels.ts @@ -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, diff --git a/backend/src/routes/playlists.ts b/backend/src/routes/playlists.ts new file mode 100644 index 0000000..39d9e2f --- /dev/null +++ b/backend/src/routes/playlists.ts @@ -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; diff --git a/backend/src/routes/videos.ts b/backend/src/routes/videos.ts index b0027d4..91ca91b 100644 --- a/backend/src/routes/videos.ts +++ b/backend/src/routes/videos.ts @@ -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(); + // 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; diff --git a/backend/src/server.ts b/backend/src/server.ts index 9d1162e..1d0398f 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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); diff --git a/backend/src/services/scheduler.ts b/backend/src/services/scheduler.ts index 7b6800b..df1b50d 100644 --- a/backend/src/services/scheduler.ts +++ b/backend/src/services/scheduler.ts @@ -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 diff --git a/backend/src/services/ytdlp.ts b/backend/src/services/ytdlp.ts index 05e6396..e14f485 100644 --- a/backend/src/services/ytdlp.ts +++ b/backend/src/services/ytdlp.ts @@ -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 { return new Promise((resolve, reject) => { const args = [ + ...getCommonArgs(), '--dump-json', '--no-playlist', url @@ -74,6 +101,7 @@ export async function getVideoInfo(url: string): Promise { export async function getChannelVideos(channelUrl: string): Promise { return new Promise((resolve, reject) => { const args = [ + ...getCommonArgs(), '--dump-json', '--flat-playlist', channelUrl @@ -123,6 +151,7 @@ export async function getChannelVideos(channelUrl: string): Promise export async function getChannelInfo(channelUrl: string): Promise { return new Promise((resolve, reject) => { const args = [ + ...getCommonArgs(), '--dump-json', '--playlist-items', '1', @@ -162,6 +191,101 @@ export async function getChannelInfo(channelUrl: string): Promise { }); } +export async function getPlaylistVideos(playlistUrl: string): Promise { + 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 { + 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 ]; diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 8771f16..915cd6b 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -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; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f4c4aab..758c978 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 + + Playlists + } /> } /> + } /> } /> } /> diff --git a/frontend/src/pages/PlaylistsPage.tsx b/frontend/src/pages/PlaylistsPage.tsx new file mode 100644 index 0000000..63baf22 --- /dev/null +++ b/frontend/src/pages/PlaylistsPage.tsx @@ -0,0 +1,188 @@ +import { useState, useEffect } from 'react'; +import { playlistsAPI } from '../services/api'; +import { Playlist } from '../types'; + +function PlaylistsPage() { + const [playlists, setPlaylists] = useState([]); + 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 ( +
+
+
+

Playlists

+

+ Manage YouTube playlists to download videos from +

+
+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ 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} + /> + +
+ {adding && ( +

+ This may take a moment while we fetch the playlist information... +

+ )} +
+ +
+ {loading ? ( +
Loading...
+ ) : playlists.length === 0 ? ( +
+ No playlists added yet. Add a playlist URL above to get started. +
+ ) : ( +
    + {playlists.map((playlist) => ( +
  • +
    +
    +
    +

    + {playlist.name} +

    + + {playlist.active ? 'Active' : 'Inactive'} + +
    +

    + {playlist.url} +

    +
    + Added: {formatDate(playlist.addedAt)} + Last checked: {formatDate(playlist.lastChecked)} +
    +
    +
    + + + +
    +
    +
  • + ))} +
+ )} +
+
+ ); +} + +export default PlaylistsPage; diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 506b78d..3f47cf7 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -177,18 +177,18 @@ function SettingsPage() {
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" />

- 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)

diff --git a/frontend/src/pages/VideoPlayerPage.tsx b/frontend/src/pages/VideoPlayerPage.tsx index fa4c14f..269a9a1 100644 --- a/frontend/src/pages/VideoPlayerPage.tsx +++ b/frontend/src/pages/VideoPlayerPage.tsx @@ -9,6 +9,7 @@ function VideoPlayerPage() { const [video, setVideo] = useState