QoL features

This commit is contained in:
Ryan Whytsell 2025-10-21 11:01:03 -04:00
parent ad83f0643c
commit d2f9045084
Signed by: Epithium
GPG Key ID: 940AC18C08E925EA
16 changed files with 944 additions and 94 deletions

View File

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

View File

@ -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,9 +21,44 @@ 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,
url TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
playlistId TEXT NOT NULL,
addedAt TEXT NOT NULL DEFAULT (datetime('now')),
lastChecked TEXT,
active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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,
@ -36,22 +71,22 @@ export function initDatabase() {
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
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,

View File

@ -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,

View File

@ -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;

View File

@ -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;

View File

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

View File

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

View File

@ -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
];

View File

@ -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;

View File

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

View File

@ -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;

View File

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

View File

@ -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">
<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,8 +102,37 @@ function VideoPlayerPage() {
>
Your browser does not support the video tag.
</video>
{/* 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>
{/* 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"
>
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>
@ -132,18 +168,8 @@ function VideoPlayerPage() {
</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>
);

View File

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

View File

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

View File

@ -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;