diff --git a/feed_parser.py b/feed_parser.py index b043477..5b39a3e 100644 --- a/feed_parser.py +++ b/feed_parser.py @@ -8,6 +8,7 @@ from datetime import datetime import feedparser from typing import Dict, List, Optional import re +import yt_dlp from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError @@ -198,3 +199,133 @@ class YouTubeFeedParser: db_session.commit() return channel + + +def fetch_single_video(video_url: str) -> Optional[Dict]: + """Fetch metadata for a single YouTube video using yt-dlp. + + Args: + video_url: YouTube video URL + + Returns: + Dictionary with video metadata or None if fetch fails + """ + # Check if URL contains "shorts" - reject shorts + if "shorts" in video_url.lower(): + return None + + try: + ydl_opts = { + 'quiet': True, + 'no_warnings': True, + 'extract_flat': False, + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(video_url, download=False) + + if not info: + return None + + # Extract video ID from URL + video_id = info.get('id') + if not video_id: + return None + + # Get channel info + channel_id = info.get('channel_id') + channel_name = info.get('channel') or info.get('uploader') + channel_url = info.get('channel_url') or f"https://www.youtube.com/channel/{channel_id}" + + # Get thumbnail - prefer maxresdefault, fall back to other qualities + thumbnail_url = None + if info.get('thumbnails'): + # Get highest quality thumbnail + thumbnail_url = info['thumbnails'][-1].get('url') + elif info.get('thumbnail'): + thumbnail_url = info.get('thumbnail') + + # Parse upload date + upload_date_str = info.get('upload_date') + if upload_date_str: + # Format: YYYYMMDD + published_at = datetime.strptime(upload_date_str, '%Y%m%d') + else: + published_at = datetime.utcnow() + + return { + 'video_id': video_id, + 'title': info.get('title'), + 'video_url': f"https://www.youtube.com/watch?v={video_id}", + 'description': info.get('description'), + 'thumbnail_url': thumbnail_url, + 'published_at': published_at, + 'channel_id': channel_id, + 'channel_name': channel_name, + 'channel_url': channel_url, + } + + except Exception as e: + print(f"Error fetching video metadata: {e}") + return None + + +def save_single_video_to_db(db_session: Session, video_data: Dict, user_id: int) -> VideoEntry: + """Save a single video to the database. + + Args: + db_session: SQLAlchemy database session + video_data: Dictionary containing video metadata (from fetch_single_video) + user_id: ID of the user adding this video + + Returns: + The VideoEntry model instance + + This method: + - Creates channel if it doesn't exist for this user + - Creates video entry if it doesn't exist + - Returns existing video if already in database + """ + # Get or create channel for this user + channel = db_session.query(Channel).filter_by( + user_id=user_id, + channel_id=video_data['channel_id'] + ).first() + + if not channel: + # Create new channel + channel = Channel( + user_id=user_id, + channel_id=video_data['channel_id'], + title=video_data['channel_name'], + link=video_data['channel_url'], + rss_url=f"https://www.youtube.com/feeds/videos.xml?channel_id={video_data['channel_id']}", + last_fetched_at=datetime.utcnow() + ) + db_session.add(channel) + db_session.flush() # Get the channel ID + + # Check if video already exists for this channel + existing_video = db_session.query(VideoEntry).filter_by( + channel_id=channel.id, + video_id=video_data['video_id'] + ).first() + + if existing_video: + return existing_video + + # Create new video entry + video = VideoEntry( + channel_id=channel.id, + video_id=video_data['video_id'], + title=video_data['title'], + video_url=video_data['video_url'], + thumbnail_url=video_data.get('thumbnail_url'), + description=video_data.get('description'), + published_at=video_data['published_at'], + created_at=datetime.utcnow() + ) + db_session.add(video) + db_session.commit() + + return video diff --git a/main.py b/main.py index 26c17de..659f85f 100644 --- a/main.py +++ b/main.py @@ -3,7 +3,7 @@ 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 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 @@ -249,6 +249,51 @@ def add_channel_page(): 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/", methods=["GET"]) @login_required def watch_video(video_id: int): @@ -562,6 +607,49 @@ def refresh_channel_videos(channel_id: int): return jsonify({"status": "error", "message": f"Failed to refresh channel: {str(e)}"}), 500 +@app.route("/api/channels/", 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/", methods=["GET"]) @login_required def stream_video(video_id: int): diff --git a/templates/add_video.html b/templates/add_video.html new file mode 100644 index 0000000..2031805 --- /dev/null +++ b/templates/add_video.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} + +{% block title %}Add Video - YottoB{% endblock %} + +{% block content %} +
+ + +
+
+
+ + + + Enter the full YouTube video URL. + +
+ +
+ + Cancel +
+
+
+ +
+

How to add a video

+
    +
  1. Go to the YouTube video you want to add
  2. +
  3. Copy the URL from your browser's address bar
  4. +
  5. Paste it into the form above
  6. +
  7. Click "Add Video"
  8. +
+ +

Supported URL formats

+
    +
  • https://www.youtube.com/watch?v=VIDEO_ID
  • +
  • https://youtu.be/VIDEO_ID
  • +
+ +

Note

+

YouTube Shorts are not supported and will be rejected.

+
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html index 23c2cee..4e85400 100644 --- a/templates/base.html +++ b/templates/base.html @@ -16,6 +16,7 @@
  • Channels
  • Downloads
  • Add Channel
  • +
  • Add Video