Add complete frontend interface with Jinja2 templates
- Created base.html template with navigation and flash messages - Created dashboard.html for video listing sorted by date - Created channels.html for channel management - Created add_channel.html with subscription form - Created watch.html with HTML5 video player - Created static/style.css with YouTube-inspired dark theme - Updated main.py with frontend routes: - / (index): Dashboard with all videos - /channels: Channel management page - /add-channel: Add new channel form (GET/POST) - /watch/<video_id>: Video player page - Added new API endpoints: - /api/videos/refresh/<channel_id>: Refresh channel videos - /api/video/stream/<video_id>: Stream/download video files - Enhanced /api/download/<video_id> with status checks - Updated CLAUDE.md with comprehensive frontend documentation Features: - Video grid with thumbnails and download status badges - Inline download buttons for pending videos - Channel subscription and refresh functionality - HTML5 video player for downloaded videos - Auto-refresh during video downloads - Responsive design for mobile/desktop - Flash message system for user feedback - Dark theme with hover effects and animations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
58
templates/add_channel.html
Normal file
58
templates/add_channel.html
Normal file
@@ -0,0 +1,58 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Add Channel - YottoB{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="add-channel-page">
|
||||
<div class="page-header">
|
||||
<h2>Add New Channel</h2>
|
||||
<p>Subscribe to a YouTube channel to download and track videos</p>
|
||||
</div>
|
||||
|
||||
<div class="form-container">
|
||||
<form method="POST" action="/add-channel" class="channel-form">
|
||||
<div class="form-group">
|
||||
<label for="rss_url">YouTube Channel RSS URL</label>
|
||||
<input
|
||||
type="url"
|
||||
id="rss_url"
|
||||
name="rss_url"
|
||||
placeholder="https://www.youtube.com/feeds/videos.xml?channel_id=..."
|
||||
required
|
||||
class="form-input"
|
||||
>
|
||||
<small class="form-help">
|
||||
Enter the RSS feed URL for the YouTube channel.
|
||||
Format: https://www.youtube.com/feeds/videos.xml?channel_id=CHANNEL_ID
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Subscribe & Fetch Videos</button>
|
||||
<a href="/channels" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h3>How to find a channel's RSS URL</h3>
|
||||
<ol>
|
||||
<li>Go to the YouTube channel page</li>
|
||||
<li>Look at the URL in your browser - it will contain the channel ID</li>
|
||||
<li>The channel URL format is usually:
|
||||
<ul>
|
||||
<li><code>youtube.com/channel/CHANNEL_ID</code> or</li>
|
||||
<li><code>youtube.com/@username</code> (you'll need to find the channel ID from the page source)</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Use the format: <code>https://www.youtube.com/feeds/videos.xml?channel_id=CHANNEL_ID</code></li>
|
||||
</ol>
|
||||
|
||||
<h4>Example</h4>
|
||||
<p>
|
||||
For channel: <code>https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw</code><br>
|
||||
RSS URL: <code>https://www.youtube.com/feeds/videos.xml?channel_id=UC_x5XG1OV2P6uZZ5FSM9Ttw</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
39
templates/base.html
Normal file
39
templates/base.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}YottoB - YouTube Downloader{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="nav-container">
|
||||
<h1 class="logo"><a href="/">YottoB</a></h1>
|
||||
<ul class="nav-menu">
|
||||
<li><a href="/" class="{% if request.path == '/' %}active{% endif %}">Videos</a></li>
|
||||
<li><a href="/channels" class="{% if request.path == '/channels' %}active{% endif %}">Channels</a></li>
|
||||
<li><a href="/add-channel" class="{% if request.path == '/add-channel' %}active{% endif %}">Add Channel</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<p>© 2025 YottoB - YouTube Video Downloader</p>
|
||||
</footer>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
75
templates/channels.html
Normal file
75
templates/channels.html
Normal file
@@ -0,0 +1,75 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Channels - YottoB{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="channels-page">
|
||||
<div class="page-header">
|
||||
<h2>Subscribed Channels</h2>
|
||||
<a href="/add-channel" class="btn btn-primary">Add New Channel</a>
|
||||
</div>
|
||||
|
||||
{% if channels %}
|
||||
<div class="channels-list">
|
||||
{% for channel in channels %}
|
||||
<div class="channel-card">
|
||||
<div class="channel-info">
|
||||
<h3 class="channel-title">{{ channel.title }}</h3>
|
||||
<p class="channel-url">
|
||||
<a href="{{ channel.rss_url }}" target="_blank">{{ channel.rss_url }}</a>
|
||||
</p>
|
||||
<div class="channel-meta">
|
||||
<span class="video-count">{{ channel.video_entries|length }} videos</span>
|
||||
<span class="last-updated">
|
||||
Last updated: {{ channel.last_fetched_at.strftime('%b %d, %Y %I:%M %p') if channel.last_fetched_at else 'Never' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="channel-actions">
|
||||
<button class="btn btn-secondary" onclick="refreshChannel({{ channel.id }})">
|
||||
Refresh Videos
|
||||
</button>
|
||||
<a href="/?channel={{ channel.id }}" class="btn btn-link">View Videos</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<h3>No channels subscribed</h3>
|
||||
<p>Add your first YouTube channel to start downloading videos</p>
|
||||
<a href="/add-channel" class="btn btn-primary">Add Channel</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function refreshChannel(channelId) {
|
||||
const button = event.target;
|
||||
button.disabled = true;
|
||||
button.textContent = 'Refreshing...';
|
||||
|
||||
fetch(`/api/videos/refresh/${channelId}`, {
|
||||
method: 'POST'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
alert(`Refreshed! Found ${data.new_videos} new videos`);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Failed to refresh: ' + data.message);
|
||||
button.disabled = false;
|
||||
button.textContent = 'Refresh Videos';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Error: ' + error);
|
||||
button.disabled = false;
|
||||
button.textContent = 'Refresh Videos';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
77
templates/dashboard.html
Normal file
77
templates/dashboard.html
Normal file
@@ -0,0 +1,77 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Videos - YottoB{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard">
|
||||
<div class="dashboard-header">
|
||||
<h2>All Videos</h2>
|
||||
<p class="video-count">{{ videos|length }} videos</p>
|
||||
</div>
|
||||
|
||||
{% if videos %}
|
||||
<div class="video-grid">
|
||||
{% for video in videos %}
|
||||
<div class="video-card">
|
||||
<a href="/watch/{{ video.id }}" class="video-thumbnail">
|
||||
{% if video.thumbnail_url %}
|
||||
<img src="{{ video.thumbnail_url }}" alt="{{ video.title }}">
|
||||
{% else %}
|
||||
<div class="thumbnail-placeholder">No Thumbnail</div>
|
||||
{% endif %}
|
||||
<div class="video-duration">
|
||||
{% if video.download_status == 'completed' %}
|
||||
<span class="badge badge-success">Downloaded</span>
|
||||
{% elif video.download_status == 'downloading' %}
|
||||
<span class="badge badge-info">Downloading...</span>
|
||||
{% elif video.download_status == 'failed' %}
|
||||
<span class="badge badge-error">Failed</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
<div class="video-info">
|
||||
<h3 class="video-title">
|
||||
<a href="/watch/{{ video.id }}">{{ video.title }}</a>
|
||||
</h3>
|
||||
<p class="video-channel">{{ video.channel.title }}</p>
|
||||
<div class="video-meta">
|
||||
<span class="video-date">{{ video.published_at.strftime('%b %d, %Y') }}</span>
|
||||
{% if video.download_status != 'completed' and video.download_status != 'downloading' %}
|
||||
<button class="btn-download" onclick="downloadVideo({{ video.id }})">Download</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<h3>No videos yet</h3>
|
||||
<p>Add a channel to start downloading videos</p>
|
||||
<a href="/add-channel" class="btn btn-primary">Add Channel</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function downloadVideo(videoId) {
|
||||
fetch(`/api/download/${videoId}`, {
|
||||
method: 'POST'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
alert('Download started!');
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Failed to start download: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Error: ' + error);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
110
templates/watch.html
Normal file
110
templates/watch.html
Normal file
@@ -0,0 +1,110 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ video.title }} - YottoB{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="watch-page">
|
||||
<div class="video-player-container">
|
||||
{% if video.download_status == 'completed' and video.download_path %}
|
||||
<video controls class="video-player">
|
||||
<source src="/api/video/stream/{{ video.id }}" type="video/mp4">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
{% elif video.download_status == 'downloading' %}
|
||||
<div class="video-placeholder">
|
||||
<div class="placeholder-content">
|
||||
<h3>Video is downloading...</h3>
|
||||
<p>Please check back in a few minutes</p>
|
||||
<button class="btn btn-secondary" onclick="location.reload()">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
{% elif video.download_status == 'failed' %}
|
||||
<div class="video-placeholder error">
|
||||
<div class="placeholder-content">
|
||||
<h3>Download failed</h3>
|
||||
<p>There was an error downloading this video</p>
|
||||
<button class="btn btn-primary" onclick="retryDownload()">Retry Download</button>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="video-placeholder">
|
||||
<div class="placeholder-content">
|
||||
{% if video.thumbnail_url %}
|
||||
<img src="{{ video.thumbnail_url }}" alt="{{ video.title }}" class="placeholder-thumbnail">
|
||||
{% endif %}
|
||||
<h3>Video not downloaded yet</h3>
|
||||
<button class="btn btn-primary" onclick="startDownload()">Download Video</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="video-details">
|
||||
<h1 class="video-title">{{ video.title }}</h1>
|
||||
|
||||
<div class="video-metadata">
|
||||
<div class="channel-info">
|
||||
<h3>{{ video.channel.title }}</h3>
|
||||
<a href="{{ video.channel.rss_url }}" target="_blank" class="channel-link">View Channel</a>
|
||||
</div>
|
||||
<div class="video-stats">
|
||||
<span class="publish-date">Published: {{ video.published_at.strftime('%B %d, %Y') }}</span>
|
||||
{% if video.download_status == 'completed' and video.download_completed_at %}
|
||||
<span class="download-date">Downloaded: {{ video.download_completed_at.strftime('%B %d, %Y') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if video.description %}
|
||||
<div class="video-description">
|
||||
<h4>Description</h4>
|
||||
<p>{{ video.description }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="video-links">
|
||||
<a href="{{ video.video_url }}" target="_blank" class="btn btn-link">Watch on YouTube</a>
|
||||
{% if video.download_status == 'completed' and video.download_path %}
|
||||
<a href="/api/video/stream/{{ video.id }}?download=1" class="btn btn-secondary" download>Download MP4</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="back-link">
|
||||
<a href="/">← Back to all videos</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function startDownload() {
|
||||
if (!confirm('Start downloading this video?')) return;
|
||||
|
||||
fetch('/api/download/{{ video.id }}', {
|
||||
method: 'POST'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
alert('Download started! This page will refresh automatically.');
|
||||
setTimeout(() => location.reload(), 2000);
|
||||
} else {
|
||||
alert('Failed to start download: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Error: ' + error);
|
||||
});
|
||||
}
|
||||
|
||||
function retryDownload() {
|
||||
startDownload();
|
||||
}
|
||||
|
||||
// Auto-refresh if video is downloading
|
||||
{% if video.download_status == 'downloading' %}
|
||||
setTimeout(() => location.reload(), 10000); // Refresh every 10 seconds
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user