- Switch to light mode with black and white color scheme - Simplify channel subscription to use channel ID only instead of RSS URL - Add Downloads page to track all video download jobs - Fix Flask-Login session management bug in user loader - Always filter YouTube Shorts from feeds (case-insensitive) - Fix download service video URL attribute error - Fix watch page enum comparison for download status display UI Changes: - Update CSS to pure black/white/grayscale theme - Remove colored text and buttons - Use underlines for hover states instead of color changes - Improve visual hierarchy with grayscale shades Channel Subscription: - Accept channel ID directly instead of full RSS URL - Add validation for channel ID format (UC/UU prefix) - Update help text and examples for easier onboarding Downloads Page: - New route at /downloads showing all video download jobs - Display status, progress, and metadata for each download - Sortable by status (downloading, pending, failed, completed) - Actions to download, retry, or watch videos - Responsive grid layout with thumbnails Bug Fixes: - Fix user loader to properly use database session context manager - Fix download service accessing wrong attribute (link → video_url) - Fix watch page template enum value comparisons - Fix session detachment issues when accessing channel data 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
629 lines
22 KiB
Python
629 lines
22 KiB
Python
"""Flask web application for YouTube RSS feed parsing."""
|
|
|
|
import os
|
|
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash, send_file
|
|
from flask_login import LoginManager, login_user, logout_user, login_required, current_user
|
|
from feed_parser import YouTubeFeedParser
|
|
from database import init_db, get_db_session
|
|
from models import Channel, VideoEntry, DownloadStatus, User
|
|
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")
|
|
|
|
# Configure Flask-Login
|
|
login_manager = LoginManager()
|
|
login_manager.init_app(app)
|
|
login_manager.login_view = "login"
|
|
login_manager.login_message = "Please log in to access this page."
|
|
login_manager.login_message_category = "info"
|
|
|
|
|
|
@login_manager.user_loader
|
|
def load_user(user_id):
|
|
"""Load user by ID for Flask-Login."""
|
|
with get_db_session() as session:
|
|
user = session.query(User).get(int(user_id))
|
|
if user:
|
|
# Expire the user from this session so it can be used in request context
|
|
session.expunge(user)
|
|
return user
|
|
|
|
# Default channel ID for demonstration
|
|
DEFAULT_CHANNEL_ID = "UCtTWOND3uyl4tVc_FarDmpw"
|
|
|
|
|
|
# Initialize database on app startup
|
|
with app.app_context():
|
|
init_db()
|
|
|
|
|
|
# ============================================================================
|
|
# Authentication Routes
|
|
# ============================================================================
|
|
|
|
@app.route("/register", methods=["GET", "POST"])
|
|
def register():
|
|
"""User registration page."""
|
|
if current_user.is_authenticated:
|
|
return redirect(url_for("index"))
|
|
|
|
if request.method == "POST":
|
|
username = request.form.get("username")
|
|
email = request.form.get("email")
|
|
password = request.form.get("password")
|
|
confirm_password = request.form.get("confirm_password")
|
|
|
|
# Validation
|
|
if not username or not email or not password:
|
|
flash("All fields are required", "error")
|
|
return render_template("register.html")
|
|
|
|
if password != confirm_password:
|
|
flash("Passwords do not match", "error")
|
|
return render_template("register.html")
|
|
|
|
if len(password) < 8:
|
|
flash("Password must be at least 8 characters long", "error")
|
|
return render_template("register.html")
|
|
|
|
try:
|
|
with get_db_session() as session:
|
|
# Check if username or email already exists
|
|
existing_user = session.query(User).filter(
|
|
(User.username == username) | (User.email == email)
|
|
).first()
|
|
|
|
if existing_user:
|
|
if existing_user.username == username:
|
|
flash("Username already taken", "error")
|
|
else:
|
|
flash("Email already registered", "error")
|
|
return render_template("register.html")
|
|
|
|
# Create new user
|
|
user = User(username=username, email=email)
|
|
user.set_password(password)
|
|
session.add(user)
|
|
session.commit()
|
|
|
|
flash("Registration successful! Please log in.", "success")
|
|
return redirect(url_for("login"))
|
|
|
|
except Exception as e:
|
|
flash(f"Registration failed: {str(e)}", "error")
|
|
return render_template("register.html")
|
|
|
|
return render_template("register.html")
|
|
|
|
|
|
@app.route("/login", methods=["GET", "POST"])
|
|
def login():
|
|
"""User login page."""
|
|
if current_user.is_authenticated:
|
|
return redirect(url_for("index"))
|
|
|
|
if request.method == "POST":
|
|
username = request.form.get("username")
|
|
password = request.form.get("password")
|
|
remember = request.form.get("remember") == "on"
|
|
|
|
if not username or not password:
|
|
flash("Please enter both username and password", "error")
|
|
return render_template("login.html")
|
|
|
|
try:
|
|
with get_db_session() as session:
|
|
user = session.query(User).filter_by(username=username).first()
|
|
|
|
if user and user.check_password(password):
|
|
# Expunge user from session before login
|
|
session.expunge(user)
|
|
login_user(user, remember=remember)
|
|
flash(f"Welcome back, {user.username}!", "success")
|
|
|
|
# Redirect to next page if specified
|
|
next_page = request.args.get("next")
|
|
return redirect(next_page) if next_page else redirect(url_for("index"))
|
|
else:
|
|
flash("Invalid username or password", "error")
|
|
|
|
except Exception as e:
|
|
flash(f"Login failed: {str(e)}", "error")
|
|
|
|
return render_template("login.html")
|
|
|
|
|
|
@app.route("/logout")
|
|
@login_required
|
|
def logout():
|
|
"""Log out the current user."""
|
|
logout_user()
|
|
flash("You have been logged out", "info")
|
|
return redirect(url_for("login"))
|
|
|
|
|
|
# ============================================================================
|
|
# Frontend Routes
|
|
# ============================================================================
|
|
|
|
@app.route("/", methods=["GET"])
|
|
@login_required
|
|
def index():
|
|
"""Render the dashboard with all videos sorted by date."""
|
|
try:
|
|
with get_db_session() as session:
|
|
# Query videos for current user's channels, sorted by published date (newest first)
|
|
videos = session.query(VideoEntry).join(Channel).filter(
|
|
Channel.user_id == current_user.id
|
|
).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"])
|
|
@login_required
|
|
def channels_page():
|
|
"""Render the channels management page."""
|
|
try:
|
|
with get_db_session() as session:
|
|
channels = session.query(Channel).filter_by(
|
|
user_id=current_user.id
|
|
).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("/downloads", methods=["GET"])
|
|
@login_required
|
|
def downloads_page():
|
|
"""Render the downloads page showing all download jobs."""
|
|
try:
|
|
with get_db_session() as session:
|
|
# Get all videos for current user's channels with download activity
|
|
# Order by: downloading first, then pending, then failed, then completed
|
|
# Within each status, order by download_started_at or created_at
|
|
videos = session.query(VideoEntry).join(Channel).filter(
|
|
Channel.user_id == current_user.id
|
|
).order_by(
|
|
# Custom sort: downloading=0, pending=1, failed=2, completed=3
|
|
desc(VideoEntry.download_status == DownloadStatus.DOWNLOADING),
|
|
desc(VideoEntry.download_status == DownloadStatus.PENDING),
|
|
desc(VideoEntry.download_status == DownloadStatus.FAILED),
|
|
desc(VideoEntry.download_started_at),
|
|
desc(VideoEntry.created_at)
|
|
).all()
|
|
|
|
return render_template("downloads.html", videos=videos)
|
|
except Exception as e:
|
|
flash(f"Error loading downloads: {str(e)}", "error")
|
|
return render_template("downloads.html", videos=[])
|
|
|
|
|
|
@app.route("/add-channel", methods=["GET", "POST"])
|
|
@login_required
|
|
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
|
|
channel_id = request.form.get("channel_id")
|
|
if not channel_id:
|
|
flash("Channel ID is required", "error")
|
|
return redirect(url_for("add_channel_page"))
|
|
|
|
# Validate channel ID format (should start with UC or UU)
|
|
channel_id = channel_id.strip()
|
|
if not channel_id.startswith(("UC", "UU")):
|
|
flash("Invalid channel ID format. Channel IDs should start with UC or UU.", "error")
|
|
return redirect(url_for("add_channel_page"))
|
|
|
|
try:
|
|
# Fetch feed using parser
|
|
parser = YouTubeFeedParser(channel_id)
|
|
result = parser.fetch_feed()
|
|
|
|
if result is None:
|
|
flash("Failed to fetch feed from YouTube. Please check the channel ID.", "error")
|
|
return redirect(url_for("add_channel_page"))
|
|
|
|
# Save to database with current user
|
|
with get_db_session() as session:
|
|
channel = parser.save_to_db(session, result, current_user.id)
|
|
channel_title = channel.title
|
|
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"])
|
|
@login_required
|
|
def watch_video(video_id: int):
|
|
"""Render the video watch page."""
|
|
try:
|
|
with get_db_session() as session:
|
|
# Only allow user to watch videos from their own channels
|
|
video = session.query(VideoEntry).join(Channel).filter(
|
|
VideoEntry.id == video_id,
|
|
Channel.user_id == current_user.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"])
|
|
def get_feed():
|
|
"""API endpoint to fetch YouTube channel feed and save to database.
|
|
|
|
Query parameters:
|
|
channel_id: YouTube channel ID (optional, uses default if not provided)
|
|
save: Whether to save to database (default: true)
|
|
|
|
Returns:
|
|
JSON response with feed data or error message
|
|
"""
|
|
channel_id = request.args.get("channel_id", DEFAULT_CHANNEL_ID)
|
|
save_to_db = request.args.get("save", "true").lower() == "true"
|
|
|
|
parser = YouTubeFeedParser(channel_id)
|
|
result = parser.fetch_feed(filter_shorts=True)
|
|
|
|
if result is None:
|
|
return jsonify({"error": "Failed to fetch feed"}), 500
|
|
|
|
# Save to database if requested
|
|
if save_to_db:
|
|
try:
|
|
with get_db_session() as session:
|
|
parser.save_to_db(session, result)
|
|
except Exception as e:
|
|
return jsonify({"error": f"Failed to save to database: {str(e)}"}), 500
|
|
|
|
return jsonify(result)
|
|
|
|
|
|
@app.route("/api/channels", methods=["GET"])
|
|
def get_channels():
|
|
"""API endpoint to list all tracked channels.
|
|
|
|
Returns:
|
|
JSON response with list of channels
|
|
"""
|
|
try:
|
|
with get_db_session() as session:
|
|
channels = session.query(Channel).all()
|
|
return jsonify({
|
|
"channels": [
|
|
{
|
|
"id": ch.id,
|
|
"channel_id": ch.channel_id,
|
|
"title": ch.title,
|
|
"link": ch.link,
|
|
"last_fetched": ch.last_fetched.isoformat(),
|
|
"video_count": len(ch.videos)
|
|
}
|
|
for ch in channels
|
|
]
|
|
})
|
|
except Exception as e:
|
|
return jsonify({"error": f"Failed to fetch channels: {str(e)}"}), 500
|
|
|
|
|
|
@app.route("/api/history/<channel_id>", methods=["GET"])
|
|
def get_history(channel_id: str):
|
|
"""API endpoint to get video history for a specific channel.
|
|
|
|
Args:
|
|
channel_id: YouTube channel ID
|
|
|
|
Query parameters:
|
|
limit: Maximum number of videos to return (default: 50)
|
|
|
|
Returns:
|
|
JSON response with channel info and video history
|
|
"""
|
|
limit = request.args.get("limit", "50")
|
|
try:
|
|
limit = int(limit)
|
|
except ValueError:
|
|
limit = 50
|
|
|
|
try:
|
|
with get_db_session() as session:
|
|
channel = session.query(Channel).filter_by(
|
|
channel_id=channel_id
|
|
).first()
|
|
|
|
if not channel:
|
|
return jsonify({"error": "Channel not found"}), 404
|
|
|
|
videos = session.query(VideoEntry).filter_by(
|
|
channel_id=channel.id
|
|
).order_by(VideoEntry.created_at.desc()).limit(limit).all()
|
|
|
|
return jsonify({
|
|
"channel": {
|
|
"channel_id": channel.channel_id,
|
|
"title": channel.title,
|
|
"link": channel.link,
|
|
"last_fetched": channel.last_fetched.isoformat()
|
|
},
|
|
"videos": [video.to_dict() for video in videos],
|
|
"total_videos": len(channel.videos)
|
|
})
|
|
except Exception as e:
|
|
return jsonify({"error": f"Failed to fetch history: {str(e)}"}), 500
|
|
|
|
|
|
@app.route("/api/download/<int:video_id>", methods=["POST"])
|
|
@login_required
|
|
def trigger_download(video_id: int):
|
|
"""Trigger video download for a specific video.
|
|
|
|
Args:
|
|
video_id: Database ID of the VideoEntry
|
|
|
|
Returns:
|
|
JSON response with task information
|
|
"""
|
|
try:
|
|
with get_db_session() as session:
|
|
# Only allow downloading videos from user's own channels
|
|
video = session.query(VideoEntry).join(Channel).filter(
|
|
VideoEntry.id == video_id,
|
|
Channel.user_id == current_user.id
|
|
).first()
|
|
|
|
if not video:
|
|
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,
|
|
"message": "Download started"
|
|
})
|
|
except Exception as e:
|
|
return jsonify({"status": "error", "message": f"Failed to queue download: {str(e)}"}), 500
|
|
|
|
|
|
@app.route("/api/download/status/<int:video_id>", methods=["GET"])
|
|
def get_download_status(video_id: int):
|
|
"""Get download status for a specific video.
|
|
|
|
Args:
|
|
video_id: Database ID of the VideoEntry
|
|
|
|
Returns:
|
|
JSON response with download status
|
|
"""
|
|
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
|
|
|
|
return jsonify({
|
|
"video_id": video_id,
|
|
"title": video.title,
|
|
"download_status": video.download_status.value,
|
|
"download_path": video.download_path,
|
|
"download_started_at": video.download_started_at.isoformat() if video.download_started_at else None,
|
|
"download_completed_at": video.download_completed_at.isoformat() if video.download_completed_at else None,
|
|
"download_error": video.download_error,
|
|
"file_size": video.file_size
|
|
})
|
|
except Exception as e:
|
|
return jsonify({"error": f"Failed to fetch download status: {str(e)}"}), 500
|
|
|
|
|
|
@app.route("/api/download/batch", methods=["POST"])
|
|
def trigger_batch_download():
|
|
"""Trigger batch download for multiple videos.
|
|
|
|
Query parameters:
|
|
channel_id: Download all pending videos for this channel (optional)
|
|
status: Filter by download status (default: pending)
|
|
|
|
Request body (alternative to query params):
|
|
video_ids: List of video IDs to download
|
|
|
|
Returns:
|
|
JSON response with batch task information
|
|
"""
|
|
try:
|
|
with get_db_session() as session:
|
|
# Check if video_ids provided in request body
|
|
data = request.get_json(silent=True)
|
|
if data and 'video_ids' in data:
|
|
video_ids = data['video_ids']
|
|
else:
|
|
# Filter by channel and/or status
|
|
channel_id = request.args.get("channel_id")
|
|
status_str = request.args.get("status", "pending")
|
|
|
|
try:
|
|
status = DownloadStatus(status_str)
|
|
except ValueError:
|
|
return jsonify({"error": f"Invalid status: {status_str}"}), 400
|
|
|
|
query = session.query(VideoEntry).filter_by(download_status=status)
|
|
|
|
if channel_id:
|
|
channel = session.query(Channel).filter_by(
|
|
channel_id=channel_id
|
|
).first()
|
|
if not channel:
|
|
return jsonify({"error": "Channel not found"}), 404
|
|
query = query.filter_by(channel_id=channel.id)
|
|
|
|
videos = query.all()
|
|
video_ids = [v.id for v in videos]
|
|
|
|
if not video_ids:
|
|
return jsonify({"message": "No videos to download", "total_queued": 0})
|
|
|
|
# Queue batch download task
|
|
task = download_videos_batch.delay(video_ids)
|
|
|
|
return jsonify({
|
|
"task_id": task.id,
|
|
"total_queued": len(video_ids),
|
|
"video_ids": video_ids,
|
|
"message": "Batch download queued successfully"
|
|
})
|
|
except Exception as e:
|
|
return jsonify({"error": f"Failed to queue batch download: {str(e)}"}), 500
|
|
|
|
|
|
@app.route("/api/videos/refresh/<int:channel_id>", methods=["POST"])
|
|
@login_required
|
|
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:
|
|
# Only allow refresh for user's own channels
|
|
channel = session.query(Channel).filter_by(
|
|
id=channel_id,
|
|
user_id=current_user.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, current_user.id)
|
|
|
|
# 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"])
|
|
@login_required
|
|
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:
|
|
# Only allow streaming videos from user's own channels
|
|
video = session.query(VideoEntry).join(Channel).filter(
|
|
VideoEntry.id == video_id,
|
|
Channel.user_id == current_user.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)
|
|
result = parser.fetch_feed()
|
|
|
|
if result is None:
|
|
print("Failed to retrieve RSS feed")
|
|
return
|
|
|
|
print(f"Feed Title: {result['feed_title']}")
|
|
print(f"Feed Link: {result['feed_link']}")
|
|
|
|
for entry in result['entries']:
|
|
print(f"\nEntry Title: {entry['title']}")
|
|
print(f"Entry Link: {entry['link']}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|