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:
2025-11-26 14:18:33 -05:00
parent cf692d2299
commit 9bcd439024
8 changed files with 1198 additions and 24 deletions

View File

@@ -111,14 +111,35 @@ python main.py
# Run the Flask web application
flask --app main run
```
The web server exposes:
- `/` - Main page (renders `index.html`)
- `/api/feed` - API endpoint for fetching feeds and saving to database
- `/api/channels` - List all tracked channels
- `/api/history/<channel_id>` - Get video history for a specific channel
## Frontend Interface
The application includes a full-featured web interface built with Jinja2 templates:
**Pages:**
- `/` - Dashboard showing all videos sorted by date (newest first)
- `/channels` - Channel management page with refresh functionality
- `/add-channel` - Form to subscribe to new YouTube channels
- `/watch/<video_id>` - Video player page for watching downloaded videos
**Features:**
- Video grid with thumbnails and metadata
- Real-time download status indicators (pending, downloading, completed, failed)
- Inline video downloads from dashboard
- HTML5 video player for streaming downloaded videos
- Channel subscription and management
- Refresh individual channels to fetch new videos
- Responsive design for mobile and desktop
**API Endpoints:**
- `/api/feed` - Fetch YouTube channel feed and save to database (GET)
- `/api/channels` - List all tracked channels (GET)
- `/api/history/<channel_id>` - Get video history for a specific channel (GET)
- `/api/download/<video_id>` - Trigger video download (POST)
- `/api/download/status/<video_id>` - Check download status (GET)
- `/api/download/batch` - Batch download multiple videos (POST)
- `/api/videos/refresh/<channel_id>` - Refresh videos for a channel (POST)
- `/api/video/stream/<video_id>` - Stream or download video file (GET)
**API Usage Examples:**
```bash
@@ -192,19 +213,59 @@ The codebase follows a clean layered architecture with separation of concerns:
### Web Server Layer
**`main.py`** - Flask application and routes
- `app`: Flask application instance (main.py:10)
- Database initialization on startup (main.py:16)
- `index()`: Homepage route handler (main.py:21)
- `get_feed()`: REST API endpoint (main.py:27) that fetches and saves to DB
- `get_channels()`: Lists all tracked channels (main.py:60)
- `get_history()`: Returns video history for a channel (main.py:87)
- `trigger_download()`: Queue video download task (main.py:134)
- `get_download_status()`: Check download status (main.py:163)
- `trigger_batch_download()`: Queue multiple downloads (main.py:193)
- `main()`: CLI entry point for testing (main.py:251)
### Templates
**`templates/index.html`** - Frontend HTML (currently static placeholder)
**Frontend Routes:**
- `index()`: Dashboard page with all videos sorted by date (main.py:24)
- `channels_page()`: Channel management page (main.py:40)
- `add_channel_page()`: Add channel form and subscription handler (main.py:52)
- `watch_video()`: Video player page (main.py:94)
**API Routes:**
- `get_feed()`: Fetch YouTube feed and save to database (main.py:110)
- `get_channels()`: List all tracked channels (main.py:145)
- `get_history()`: Video history for a channel (main.py:172)
- `trigger_download()`: Queue video download task (main.py:216)
- `get_download_status()`: Check download status (main.py:258)
- `trigger_batch_download()`: Queue multiple downloads (main.py:290)
- `refresh_channel_videos()`: Refresh videos for a channel (main.py:347)
- `stream_video()`: Stream or download video file (main.py:391)
### Frontend Templates
**`templates/base.html`** - Base template with navigation and common layout
- Navigation bar with logo and menu
- Flash message display system
- Common styles and responsive design
**`templates/dashboard.html`** - Main video listing page
- Video grid sorted by published date (newest first)
- Thumbnail display with download status badges
- Inline download buttons for pending videos
- Empty state for new installations
**`templates/channels.html`** - Channel management interface
- List of subscribed channels with metadata
- Refresh button to fetch new videos per channel
- Link to add new channels
- Video count and last updated timestamps
**`templates/add_channel.html`** - Channel subscription form
- Form to input YouTube RSS feed URL
- Help section with instructions on finding RSS URLs
- Examples and format guidance
**`templates/watch.html`** - Video player page
- HTML5 video player for downloaded videos
- Download status placeholders (downloading, failed, pending)
- Video metadata (title, channel, publish date)
- Download button for pending videos
- Auto-refresh when video is downloading
**`static/style.css`** - Application styles
- Dark theme inspired by YouTube
- Responsive grid layout
- Video card components
- Form styling
- Badge and button components
## Feed Parsing Implementation

193
main.py
View File

@@ -1,13 +1,16 @@
"""Flask web application for YouTube RSS feed parsing."""
from flask import Flask, render_template, request, jsonify
import os
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash, send_file
from feed_parser import YouTubeFeedParser
from database import init_db, get_db_session
from models import Channel, VideoEntry, DownloadStatus
from download_service import download_video, download_videos_batch
from sqlalchemy import desc
app = Flask(__name__)
app.secret_key = os.getenv("SECRET_KEY", "dev-secret-key-change-in-production")
# Default channel ID for demonstration
DEFAULT_CHANNEL_ID = "UCtTWOND3uyl4tVc_FarDmpw"
@@ -20,8 +23,88 @@ with app.app_context():
@app.route("/", methods=["GET"])
def index():
"""Render the main page."""
return render_template("index.html")
"""Render the dashboard with all videos sorted by date."""
try:
with get_db_session() as session:
# Query all videos with their channels, sorted by published date (newest first)
videos = session.query(VideoEntry).join(Channel).order_by(
desc(VideoEntry.published_at)
).all()
return render_template("dashboard.html", videos=videos)
except Exception as e:
flash(f"Error loading videos: {str(e)}", "error")
return render_template("dashboard.html", videos=[])
@app.route("/channels", methods=["GET"])
def channels_page():
"""Render the channels management page."""
try:
with get_db_session() as session:
channels = session.query(Channel).order_by(desc(Channel.last_fetched_at)).all()
return render_template("channels.html", channels=channels)
except Exception as e:
flash(f"Error loading channels: {str(e)}", "error")
return render_template("channels.html", channels=[])
@app.route("/add-channel", methods=["GET", "POST"])
def add_channel_page():
"""Render the add channel page and handle channel subscription."""
if request.method == "GET":
return render_template("add_channel.html")
# Handle POST - add new channel
rss_url = request.form.get("rss_url")
if not rss_url:
flash("RSS URL is required", "error")
return redirect(url_for("add_channel_page"))
# Extract channel_id from RSS URL
# Format: https://www.youtube.com/feeds/videos.xml?channel_id=CHANNEL_ID
try:
if "channel_id=" in rss_url:
channel_id = rss_url.split("channel_id=")[1].split("&")[0]
else:
flash("Invalid RSS URL format. Must contain channel_id parameter.", "error")
return redirect(url_for("add_channel_page"))
# Fetch feed using parser
parser = YouTubeFeedParser(channel_id)
result = parser.fetch_feed()
if result is None:
flash("Failed to fetch feed from YouTube", "error")
return redirect(url_for("add_channel_page"))
# Save to database
with get_db_session() as session:
channel = parser.save_to_db(session, result)
video_count = len(result["entries"])
flash(f"Successfully subscribed to {channel.title}! Added {video_count} videos.", "success")
return redirect(url_for("channels_page"))
except Exception as e:
flash(f"Error subscribing to channel: {str(e)}", "error")
return redirect(url_for("add_channel_page"))
@app.route("/watch/<int:video_id>", methods=["GET"])
def watch_video(video_id: int):
"""Render the video watch page."""
try:
with get_db_session() as session:
video = session.query(VideoEntry).filter_by(id=video_id).first()
if not video:
flash("Video not found", "error")
return redirect(url_for("index"))
return render_template("watch.html", video=video)
except Exception as e:
flash(f"Error loading video: {str(e)}", "error")
return redirect(url_for("index"))
@app.route("/api/feed", methods=["GET"])
@@ -144,19 +227,32 @@ def trigger_download(video_id: int):
with get_db_session() as session:
video = session.query(VideoEntry).filter_by(id=video_id).first()
if not video:
return jsonify({"error": "Video not found"}), 404
return jsonify({"status": "error", "message": "Video not found"}), 404
# Check if already downloaded or downloading
if video.download_status == DownloadStatus.COMPLETED:
return jsonify({
"status": "success",
"message": "Video already downloaded"
})
if video.download_status == DownloadStatus.DOWNLOADING:
return jsonify({
"status": "success",
"message": "Video is already downloading"
})
# Queue download task
task = download_video.delay(video_id)
return jsonify({
"status": "success",
"video_id": video_id,
"task_id": task.id,
"status": "queued",
"message": "Download task queued successfully"
"message": "Download started"
})
except Exception as e:
return jsonify({"error": f"Failed to queue download: {str(e)}"}), 500
return jsonify({"status": "error", "message": f"Failed to queue download: {str(e)}"}), 500
@app.route("/api/download/status/<int:video_id>", methods=["GET"])
@@ -248,6 +344,89 @@ def trigger_batch_download():
return jsonify({"error": f"Failed to queue batch download: {str(e)}"}), 500
@app.route("/api/videos/refresh/<int:channel_id>", methods=["POST"])
def refresh_channel_videos(channel_id: int):
"""Refresh videos for a specific channel by fetching latest from RSS feed.
Args:
channel_id: Database ID of the Channel
Returns:
JSON response with number of new videos found
"""
try:
with get_db_session() as session:
channel = session.query(Channel).filter_by(id=channel_id).first()
if not channel:
return jsonify({"status": "error", "message": "Channel not found"}), 404
# Fetch latest feed
parser = YouTubeFeedParser(channel.channel_id)
result = parser.fetch_feed()
if result is None:
return jsonify({"status": "error", "message": "Failed to fetch feed"}), 500
# Count existing videos before save
existing_count = len(channel.video_entries)
# Save to database (upsert logic will handle duplicates)
parser.save_to_db(session, result)
# Count after save
session.refresh(channel)
new_count = len(channel.video_entries) - existing_count
return jsonify({
"status": "success",
"channel_id": channel_id,
"new_videos": max(0, new_count), # Ensure non-negative
"total_videos": len(channel.video_entries),
"message": f"Found {max(0, new_count)} new videos"
})
except Exception as e:
return jsonify({"status": "error", "message": f"Failed to refresh channel: {str(e)}"}), 500
@app.route("/api/video/stream/<int:video_id>", methods=["GET"])
def stream_video(video_id: int):
"""Stream a downloaded video file.
Args:
video_id: Database ID of the VideoEntry
Query parameters:
download: If set to 1, force download instead of streaming
Returns:
Video file stream or error
"""
try:
with get_db_session() as session:
video = session.query(VideoEntry).filter_by(id=video_id).first()
if not video:
return jsonify({"error": "Video not found"}), 404
if video.download_status != DownloadStatus.COMPLETED or not video.download_path:
return jsonify({"error": "Video not downloaded yet"}), 404
# Check if file exists
if not os.path.exists(video.download_path):
return jsonify({"error": "Video file not found on disk"}), 404
# Check if download is requested
as_attachment = request.args.get("download") == "1"
return send_file(
video.download_path,
mimetype="video/mp4",
as_attachment=as_attachment,
download_name=f"{video.title}.mp4" if as_attachment else None
)
except Exception as e:
return jsonify({"error": f"Failed to stream video: {str(e)}"}), 500
def main():
"""CLI entry point for testing feed parser."""
parser = YouTubeFeedParser(DEFAULT_CHANNEL_ID)

575
static/style.css Normal file
View File

@@ -0,0 +1,575 @@
/* Global Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary-color: #ff0000;
--secondary-color: #282828;
--background-color: #0f0f0f;
--card-background: #1f1f1f;
--text-primary: #ffffff;
--text-secondary: #aaaaaa;
--border-color: #303030;
--success-color: #00aa00;
--error-color: #cc0000;
--info-color: #0066cc;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: var(--background-color);
color: var(--text-primary);
line-height: 1.6;
}
/* Navigation */
.navbar {
background-color: var(--secondary-color);
border-bottom: 1px solid var(--border-color);
padding: 1rem 0;
position: sticky;
top: 0;
z-index: 1000;
}
.nav-container {
max-width: 1400px;
margin: 0 auto;
padding: 0 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo a {
color: var(--primary-color);
text-decoration: none;
font-size: 1.5rem;
font-weight: bold;
}
.nav-menu {
display: flex;
list-style: none;
gap: 2rem;
}
.nav-menu a {
color: var(--text-primary);
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.2s;
}
.nav-menu a:hover,
.nav-menu a.active {
background-color: var(--border-color);
}
/* Container */
.container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
min-height: calc(100vh - 200px);
}
/* Alerts */
.alert {
padding: 1rem;
margin-bottom: 1rem;
border-radius: 4px;
border-left: 4px solid;
}
.alert-success {
background-color: rgba(0, 170, 0, 0.1);
border-color: var(--success-color);
}
.alert-error {
background-color: rgba(204, 0, 0, 0.1);
border-color: var(--error-color);
}
.alert-info {
background-color: rgba(0, 102, 204, 0.1);
border-color: var(--info-color);
}
/* Dashboard */
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.video-count {
color: var(--text-secondary);
}
/* Video Grid */
.video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.video-card {
background-color: var(--card-background);
border-radius: 8px;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
.video-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.video-thumbnail {
position: relative;
display: block;
aspect-ratio: 16/9;
overflow: hidden;
background-color: var(--secondary-color);
}
.video-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbnail-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
}
.video-duration {
position: absolute;
bottom: 8px;
right: 8px;
}
.video-info {
padding: 1rem;
}
.video-title {
font-size: 1rem;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.video-title a {
color: var(--text-primary);
text-decoration: none;
}
.video-title a:hover {
color: var(--primary-color);
}
.video-channel {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.video-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
}
.video-date {
color: var(--text-secondary);
}
/* Badges */
.badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
}
.badge-success {
background-color: var(--success-color);
color: white;
}
.badge-info {
background-color: var(--info-color);
color: white;
}
.badge-error {
background-color: var(--error-color);
color: white;
}
/* Buttons */
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
display: inline-block;
font-size: 0.9rem;
transition: opacity 0.2s;
}
.btn:hover {
opacity: 0.8;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-secondary {
background-color: var(--border-color);
color: var(--text-primary);
}
.btn-link {
background-color: transparent;
color: var(--primary-color);
border: 1px solid var(--primary-color);
}
.btn-download {
background-color: transparent;
color: var(--primary-color);
border: none;
cursor: pointer;
font-size: 0.85rem;
padding: 0.25rem 0.5rem;
}
.btn-download:hover {
text-decoration: underline;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 4rem 2rem;
}
.empty-state h3 {
margin-bottom: 1rem;
font-size: 1.5rem;
}
.empty-state p {
color: var(--text-secondary);
margin-bottom: 2rem;
}
/* Channels Page */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.channels-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.channel-card {
background-color: var(--card-background);
border-radius: 8px;
padding: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.channel-info h3 {
margin-bottom: 0.5rem;
}
.channel-url {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.channel-url a {
color: var(--text-secondary);
text-decoration: none;
}
.channel-url a:hover {
color: var(--primary-color);
}
.channel-meta {
display: flex;
gap: 2rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.channel-actions {
display: flex;
gap: 1rem;
}
/* Form */
.form-container {
max-width: 600px;
margin: 0 auto;
}
.channel-form {
background-color: var(--card-background);
padding: 2rem;
border-radius: 8px;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-input {
width: 100%;
padding: 0.75rem;
background-color: var(--secondary-color);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-size: 1rem;
}
.form-input:focus {
outline: none;
border-color: var(--primary-color);
}
.form-help {
display: block;
margin-top: 0.5rem;
color: var(--text-secondary);
font-size: 0.85rem;
}
.form-actions {
display: flex;
gap: 1rem;
}
/* Help Section */
.help-section {
max-width: 800px;
margin: 3rem auto 0;
padding: 2rem;
background-color: var(--card-background);
border-radius: 8px;
}
.help-section h3,
.help-section h4 {
margin-bottom: 1rem;
}
.help-section ol,
.help-section ul {
margin-left: 2rem;
margin-bottom: 1rem;
}
.help-section li {
margin-bottom: 0.5rem;
}
.help-section code {
background-color: var(--secondary-color);
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
}
/* Watch Page */
.watch-page {
max-width: 1200px;
margin: 0 auto;
}
.video-player-container {
width: 100%;
aspect-ratio: 16/9;
background-color: var(--secondary-color);
border-radius: 8px;
overflow: hidden;
margin-bottom: 1.5rem;
}
.video-player {
width: 100%;
height: 100%;
}
.video-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.placeholder-content {
padding: 2rem;
}
.placeholder-thumbnail {
max-width: 100%;
max-height: 300px;
margin-bottom: 1rem;
border-radius: 8px;
}
.video-details {
background-color: var(--card-background);
padding: 2rem;
border-radius: 8px;
}
.video-details .video-title {
font-size: 1.5rem;
margin-bottom: 1rem;
}
.video-metadata {
display: flex;
justify-content: space-between;
padding-bottom: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.channel-info h3 {
margin-bottom: 0.5rem;
}
.channel-link {
color: var(--text-secondary);
text-decoration: none;
font-size: 0.9rem;
}
.channel-link:hover {
color: var(--primary-color);
}
.video-stats {
display: flex;
flex-direction: column;
align-items: flex-end;
font-size: 0.9rem;
color: var(--text-secondary);
}
.video-description {
margin-bottom: 1.5rem;
}
.video-description h4 {
margin-bottom: 0.5rem;
}
.video-description p {
color: var(--text-secondary);
white-space: pre-wrap;
}
.video-links {
display: flex;
gap: 1rem;
}
.back-link {
margin-top: 2rem;
}
.back-link a {
color: var(--text-secondary);
text-decoration: none;
}
.back-link a:hover {
color: var(--primary-color);
}
/* Footer */
.footer {
background-color: var(--secondary-color);
border-top: 1px solid var(--border-color);
padding: 2rem;
text-align: center;
color: var(--text-secondary);
}
/* Responsive */
@media (max-width: 768px) {
.nav-container {
flex-direction: column;
gap: 1rem;
}
.video-grid {
grid-template-columns: 1fr;
}
.channel-card {
flex-direction: column;
gap: 1rem;
}
.channel-actions {
width: 100%;
flex-direction: column;
}
.page-header {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.video-metadata {
flex-direction: column;
gap: 1rem;
}
.video-stats {
align-items: flex-start;
}
}

View 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
View 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>&copy; 2025 YottoB - YouTube Video Downloader</p>
</footer>
{% block scripts %}{% endblock %}
</body>
</html>

75
templates/channels.html Normal file
View 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
View 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
View 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="/">&larr; 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 %}