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
This commit is contained in:
131
feed_parser.py
131
feed_parser.py
@@ -8,6 +8,7 @@ from datetime import datetime
|
|||||||
import feedparser
|
import feedparser
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
import re
|
import re
|
||||||
|
import yt_dlp
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
@@ -198,3 +199,133 @@ class YouTubeFeedParser:
|
|||||||
|
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
return channel
|
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
|
||||||
|
|||||||
90
main.py
90
main.py
@@ -3,7 +3,7 @@
|
|||||||
import os
|
import os
|
||||||
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash, send_file
|
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 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 database import init_db, get_db_session
|
||||||
from models import Channel, VideoEntry, DownloadStatus, User
|
from models import Channel, VideoEntry, DownloadStatus, User
|
||||||
from download_service import download_video, download_videos_batch
|
from download_service import download_video, download_videos_batch
|
||||||
@@ -249,6 +249,51 @@ def add_channel_page():
|
|||||||
return redirect(url_for("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/<int:video_id>", methods=["GET"])
|
@app.route("/watch/<int:video_id>", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def watch_video(video_id: int):
|
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
|
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"])
|
@app.route("/api/video/stream/<int:video_id>", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def stream_video(video_id: int):
|
def stream_video(video_id: int):
|
||||||
|
|||||||
55
templates/add_video.html
Normal file
55
templates/add_video.html
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Add Video - YottoB{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="add-video-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>Add Individual Video</h2>
|
||||||
|
<p>Add a specific YouTube video to your library</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-container">
|
||||||
|
<form method="POST" action="/add-video" class="channel-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="video_url">YouTube Video URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="video_url"
|
||||||
|
name="video_url"
|
||||||
|
placeholder="https://www.youtube.com/watch?v=VIDEO_ID"
|
||||||
|
required
|
||||||
|
class="form-input"
|
||||||
|
>
|
||||||
|
<small class="form-help">
|
||||||
|
Enter the full YouTube video URL.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Add Video</button>
|
||||||
|
<a href="/" class="btn btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-section">
|
||||||
|
<h3>How to add a video</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Go to the YouTube video you want to add</li>
|
||||||
|
<li>Copy the URL from your browser's address bar</li>
|
||||||
|
<li>Paste it into the form above</li>
|
||||||
|
<li>Click "Add Video"</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h4>Supported URL formats</h4>
|
||||||
|
<ul>
|
||||||
|
<li><code>https://www.youtube.com/watch?v=VIDEO_ID</code></li>
|
||||||
|
<li><code>https://youtu.be/VIDEO_ID</code></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Note</h4>
|
||||||
|
<p>YouTube Shorts are not supported and will be rejected.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
<li><a href="/channels" class="{% if request.path == '/channels' %}active{% endif %}">Channels</a></li>
|
<li><a href="/channels" class="{% if request.path == '/channels' %}active{% endif %}">Channels</a></li>
|
||||||
<li><a href="/downloads" class="{% if request.path == '/downloads' %}active{% endif %}">Downloads</a></li>
|
<li><a href="/downloads" class="{% if request.path == '/downloads' %}active{% endif %}">Downloads</a></li>
|
||||||
<li><a href="/add-channel" class="{% if request.path == '/add-channel' %}active{% endif %}">Add Channel</a></li>
|
<li><a href="/add-channel" class="{% if request.path == '/add-channel' %}active{% endif %}">Add Channel</a></li>
|
||||||
|
<li><a href="/add-video" class="{% if request.path == '/add-video' %}active{% endif %}">Add Video</a></li>
|
||||||
<li class="nav-user">
|
<li class="nav-user">
|
||||||
<span>{{ current_user.username }}</span>
|
<span>{{ current_user.username }}</span>
|
||||||
<a href="{{ url_for('logout') }}">Logout</a>
|
<a href="{{ url_for('logout') }}">Logout</a>
|
||||||
|
|||||||
@@ -29,6 +29,9 @@
|
|||||||
<button class="btn btn-secondary" onclick="refreshChannel({{ channel.id }})">
|
<button class="btn btn-secondary" onclick="refreshChannel({{ channel.id }})">
|
||||||
Refresh Videos
|
Refresh Videos
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-danger" onclick="deleteChannel({{ channel.id }}, '{{ channel.title|replace("'", "\\'") }}')">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
<a href="/?channel={{ channel.id }}" class="btn btn-link">View Videos</a>
|
<a href="/?channel={{ channel.id }}" class="btn btn-link">View Videos</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,5 +74,36 @@
|
|||||||
button.textContent = 'Refresh Videos';
|
button.textContent = 'Refresh Videos';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteChannel(channelId, channelTitle) {
|
||||||
|
if (!confirm(`Are you sure you want to delete "${channelTitle}"?\n\nThis will permanently remove:\n- The channel subscription\n- All video history\n- ALL downloaded video files for this channel\n\nThis action cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const button = event.target;
|
||||||
|
const originalText = button.textContent;
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = 'Deleting...';
|
||||||
|
|
||||||
|
fetch(`/api/channels/${channelId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
alert(data.message);
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Failed to delete: ' + data.message);
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = originalText;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert('Error: ' + error);
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = originalText;
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user