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

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)