Files
yottob/main.py
Ryan Chen 337d46cbb5 Add feed deletion and single video addition features
- Implemented DELETE /api/channels/<id> to remove channels and cleanup downloaded files
- Added delete button to channels page with confirmation dialog
- Added functionality to add single videos via URL
- Updated navigation menu
2025-11-26 15:56:29 -05:00

717 lines
25 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, fetch_single_video, save_single_video_to_db
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("/add-video", methods=["GET", "POST"])
@login_required
def add_video_page():
"""Render the add video page and handle video submission."""
if request.method == "GET":
return render_template("add_video.html")
# Handle POST - add new video
video_url = request.form.get("video_url")
if not video_url:
flash("Video URL is required", "error")
return redirect(url_for("add_video_page"))
# Validate it's a YouTube URL
video_url = video_url.strip()
if not ("youtube.com/watch" in video_url or "youtu.be/" in video_url):
flash("Invalid YouTube URL. Please provide a valid YouTube video link.", "error")
return redirect(url_for("add_video_page"))
# Check if it's a Shorts video
if "shorts" in video_url.lower():
flash("YouTube Shorts are not supported.", "error")
return redirect(url_for("add_video_page"))
try:
# Fetch video metadata
video_data = fetch_single_video(video_url)
if video_data is None:
flash("Failed to fetch video from YouTube. Please check the URL.", "error")
return redirect(url_for("add_video_page"))
# Save to database with current user
with get_db_session() as session:
video = save_single_video_to_db(session, video_data, current_user.id)
video_title = video.title
flash(f"Successfully added video: {video_title}!", "success")
return redirect(url_for("index"))
except Exception as e:
flash(f"Error adding video: {str(e)}", "error")
return redirect(url_for("add_video_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/channels/<int:channel_id>", methods=["DELETE"])
@login_required
def delete_channel(channel_id: int):
"""Delete a channel and all its associated data (videos and files).
Args:
channel_id: Database ID of the Channel
Returns:
JSON response indicating success or failure
"""
try:
with get_db_session() as session:
# Verify ownership and get channel with videos
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
# Delete downloaded files
deleted_files_count = 0
for video in channel.video_entries:
if video.download_path and os.path.exists(video.download_path):
try:
os.remove(video.download_path)
deleted_files_count += 1
except OSError as e:
print(f"Error deleting file {video.download_path}: {e}")
# Delete channel (cascade will handle video entries in DB)
session.delete(channel)
return jsonify({
"status": "success",
"message": f"Channel deleted successfully. Removed {deleted_files_count} downloaded files."
})
except Exception as e:
return jsonify({"status": "error", "message": f"Failed to delete 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()