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:
95
CLAUDE.md
95
CLAUDE.md
@@ -111,14 +111,35 @@ python main.py
|
|||||||
# Run the Flask web application
|
# Run the Flask web application
|
||||||
flask --app main run
|
flask --app main run
|
||||||
```
|
```
|
||||||
The web server exposes:
|
|
||||||
- `/` - Main page (renders `index.html`)
|
## Frontend Interface
|
||||||
- `/api/feed` - API endpoint for fetching feeds and saving to database
|
|
||||||
- `/api/channels` - List all tracked channels
|
The application includes a full-featured web interface built with Jinja2 templates:
|
||||||
- `/api/history/<channel_id>` - Get video history for a specific channel
|
|
||||||
|
**Pages:**
|
||||||
|
- `/` - Dashboard showing all videos sorted by date (newest first)
|
||||||
|
- `/channels` - Channel management page with refresh functionality
|
||||||
|
- `/add-channel` - Form to subscribe to new YouTube channels
|
||||||
|
- `/watch/<video_id>` - Video player page for watching downloaded videos
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Video grid with thumbnails and metadata
|
||||||
|
- Real-time download status indicators (pending, downloading, completed, failed)
|
||||||
|
- Inline video downloads from dashboard
|
||||||
|
- HTML5 video player for streaming downloaded videos
|
||||||
|
- Channel subscription and management
|
||||||
|
- Refresh individual channels to fetch new videos
|
||||||
|
- Responsive design for mobile and desktop
|
||||||
|
|
||||||
|
**API Endpoints:**
|
||||||
|
- `/api/feed` - Fetch YouTube channel feed and save to database (GET)
|
||||||
|
- `/api/channels` - List all tracked channels (GET)
|
||||||
|
- `/api/history/<channel_id>` - Get video history for a specific channel (GET)
|
||||||
- `/api/download/<video_id>` - Trigger video download (POST)
|
- `/api/download/<video_id>` - Trigger video download (POST)
|
||||||
- `/api/download/status/<video_id>` - Check download status (GET)
|
- `/api/download/status/<video_id>` - Check download status (GET)
|
||||||
- `/api/download/batch` - Batch download multiple videos (POST)
|
- `/api/download/batch` - Batch download multiple videos (POST)
|
||||||
|
- `/api/videos/refresh/<channel_id>` - Refresh videos for a channel (POST)
|
||||||
|
- `/api/video/stream/<video_id>` - Stream or download video file (GET)
|
||||||
|
|
||||||
**API Usage Examples:**
|
**API Usage Examples:**
|
||||||
```bash
|
```bash
|
||||||
@@ -192,19 +213,59 @@ The codebase follows a clean layered architecture with separation of concerns:
|
|||||||
|
|
||||||
### Web Server Layer
|
### Web Server Layer
|
||||||
**`main.py`** - Flask application and routes
|
**`main.py`** - Flask application and routes
|
||||||
- `app`: Flask application instance (main.py:10)
|
|
||||||
- Database initialization on startup (main.py:16)
|
|
||||||
- `index()`: Homepage route handler (main.py:21)
|
|
||||||
- `get_feed()`: REST API endpoint (main.py:27) that fetches and saves to DB
|
|
||||||
- `get_channels()`: Lists all tracked channels (main.py:60)
|
|
||||||
- `get_history()`: Returns video history for a channel (main.py:87)
|
|
||||||
- `trigger_download()`: Queue video download task (main.py:134)
|
|
||||||
- `get_download_status()`: Check download status (main.py:163)
|
|
||||||
- `trigger_batch_download()`: Queue multiple downloads (main.py:193)
|
|
||||||
- `main()`: CLI entry point for testing (main.py:251)
|
|
||||||
|
|
||||||
### Templates
|
**Frontend Routes:**
|
||||||
**`templates/index.html`** - Frontend HTML (currently static placeholder)
|
- `index()`: Dashboard page with all videos sorted by date (main.py:24)
|
||||||
|
- `channels_page()`: Channel management page (main.py:40)
|
||||||
|
- `add_channel_page()`: Add channel form and subscription handler (main.py:52)
|
||||||
|
- `watch_video()`: Video player page (main.py:94)
|
||||||
|
|
||||||
|
**API Routes:**
|
||||||
|
- `get_feed()`: Fetch YouTube feed and save to database (main.py:110)
|
||||||
|
- `get_channels()`: List all tracked channels (main.py:145)
|
||||||
|
- `get_history()`: Video history for a channel (main.py:172)
|
||||||
|
- `trigger_download()`: Queue video download task (main.py:216)
|
||||||
|
- `get_download_status()`: Check download status (main.py:258)
|
||||||
|
- `trigger_batch_download()`: Queue multiple downloads (main.py:290)
|
||||||
|
- `refresh_channel_videos()`: Refresh videos for a channel (main.py:347)
|
||||||
|
- `stream_video()`: Stream or download video file (main.py:391)
|
||||||
|
|
||||||
|
### Frontend Templates
|
||||||
|
**`templates/base.html`** - Base template with navigation and common layout
|
||||||
|
- Navigation bar with logo and menu
|
||||||
|
- Flash message display system
|
||||||
|
- Common styles and responsive design
|
||||||
|
|
||||||
|
**`templates/dashboard.html`** - Main video listing page
|
||||||
|
- Video grid sorted by published date (newest first)
|
||||||
|
- Thumbnail display with download status badges
|
||||||
|
- Inline download buttons for pending videos
|
||||||
|
- Empty state for new installations
|
||||||
|
|
||||||
|
**`templates/channels.html`** - Channel management interface
|
||||||
|
- List of subscribed channels with metadata
|
||||||
|
- Refresh button to fetch new videos per channel
|
||||||
|
- Link to add new channels
|
||||||
|
- Video count and last updated timestamps
|
||||||
|
|
||||||
|
**`templates/add_channel.html`** - Channel subscription form
|
||||||
|
- Form to input YouTube RSS feed URL
|
||||||
|
- Help section with instructions on finding RSS URLs
|
||||||
|
- Examples and format guidance
|
||||||
|
|
||||||
|
**`templates/watch.html`** - Video player page
|
||||||
|
- HTML5 video player for downloaded videos
|
||||||
|
- Download status placeholders (downloading, failed, pending)
|
||||||
|
- Video metadata (title, channel, publish date)
|
||||||
|
- Download button for pending videos
|
||||||
|
- Auto-refresh when video is downloading
|
||||||
|
|
||||||
|
**`static/style.css`** - Application styles
|
||||||
|
- Dark theme inspired by YouTube
|
||||||
|
- Responsive grid layout
|
||||||
|
- Video card components
|
||||||
|
- Form styling
|
||||||
|
- Badge and button components
|
||||||
|
|
||||||
## Feed Parsing Implementation
|
## Feed Parsing Implementation
|
||||||
|
|
||||||
|
|||||||
193
main.py
193
main.py
@@ -1,13 +1,16 @@
|
|||||||
"""Flask web application for YouTube RSS feed parsing."""
|
"""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 feed_parser import YouTubeFeedParser
|
||||||
from database import init_db, get_db_session
|
from database import init_db, get_db_session
|
||||||
from models import Channel, VideoEntry, DownloadStatus
|
from models import Channel, VideoEntry, DownloadStatus
|
||||||
from download_service import download_video, download_videos_batch
|
from download_service import download_video, download_videos_batch
|
||||||
|
from sqlalchemy import desc
|
||||||
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
app.secret_key = os.getenv("SECRET_KEY", "dev-secret-key-change-in-production")
|
||||||
|
|
||||||
# Default channel ID for demonstration
|
# Default channel ID for demonstration
|
||||||
DEFAULT_CHANNEL_ID = "UCtTWOND3uyl4tVc_FarDmpw"
|
DEFAULT_CHANNEL_ID = "UCtTWOND3uyl4tVc_FarDmpw"
|
||||||
@@ -20,8 +23,88 @@ with app.app_context():
|
|||||||
|
|
||||||
@app.route("/", methods=["GET"])
|
@app.route("/", methods=["GET"])
|
||||||
def index():
|
def index():
|
||||||
"""Render the main page."""
|
"""Render the dashboard with all videos sorted by date."""
|
||||||
return render_template("index.html")
|
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"])
|
@app.route("/api/feed", methods=["GET"])
|
||||||
@@ -144,19 +227,32 @@ def trigger_download(video_id: int):
|
|||||||
with get_db_session() as session:
|
with get_db_session() as session:
|
||||||
video = session.query(VideoEntry).filter_by(id=video_id).first()
|
video = session.query(VideoEntry).filter_by(id=video_id).first()
|
||||||
if not video:
|
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
|
# Queue download task
|
||||||
task = download_video.delay(video_id)
|
task = download_video.delay(video_id)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
|
"status": "success",
|
||||||
"video_id": video_id,
|
"video_id": video_id,
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
"status": "queued",
|
"message": "Download started"
|
||||||
"message": "Download task queued successfully"
|
|
||||||
})
|
})
|
||||||
except Exception as e:
|
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"])
|
@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
|
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():
|
def main():
|
||||||
"""CLI entry point for testing feed parser."""
|
"""CLI entry point for testing feed parser."""
|
||||||
parser = YouTubeFeedParser(DEFAULT_CHANNEL_ID)
|
parser = YouTubeFeedParser(DEFAULT_CHANNEL_ID)
|
||||||
|
|||||||
575
static/style.css
Normal file
575
static/style.css
Normal file
@@ -0,0 +1,575 @@
|
|||||||
|
/* Global Styles */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary-color: #ff0000;
|
||||||
|
--secondary-color: #282828;
|
||||||
|
--background-color: #0f0f0f;
|
||||||
|
--card-background: #1f1f1f;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #aaaaaa;
|
||||||
|
--border-color: #303030;
|
||||||
|
--success-color: #00aa00;
|
||||||
|
--error-color: #cc0000;
|
||||||
|
--info-color: #0066cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation */
|
||||||
|
.navbar {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: 1rem 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo a {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu {
|
||||||
|
display: flex;
|
||||||
|
list-style: none;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu a {
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu a:hover,
|
||||||
|
.nav-menu a.active {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container */
|
||||||
|
.container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
min-height: calc(100vh - 200px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alerts */
|
||||||
|
.alert {
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 4px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background-color: rgba(0, 170, 0, 0.1);
|
||||||
|
border-color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background-color: rgba(204, 0, 0, 0.1);
|
||||||
|
border-color: var(--error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-info {
|
||||||
|
background-color: rgba(0, 102, 204, 0.1);
|
||||||
|
border-color: var(--info-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard */
|
||||||
|
.dashboard-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-count {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Video Grid */
|
||||||
|
.video-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-card {
|
||||||
|
background-color: var(--card-background);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-thumbnail {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-thumbnail img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-duration {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-info {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-title a {
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-title a:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-channel {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-date {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-info {
|
||||||
|
background-color: var(--info-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-error {
|
||||||
|
background-color: var(--error-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--primary-color);
|
||||||
|
border: 1px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-download {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--primary-color);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-download:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Channels Page */
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-card {
|
||||||
|
background-color: var(--card-background);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-info h3 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-url {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-url a {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-url a:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form */
|
||||||
|
.form-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-form {
|
||||||
|
background-color: var(--card-background);
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-help {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Help Section */
|
||||||
|
.help-section {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 3rem auto 0;
|
||||||
|
padding: 2rem;
|
||||||
|
background-color: var(--card-background);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section h3,
|
||||||
|
.help-section h4 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section ol,
|
||||||
|
.help-section ul {
|
||||||
|
margin-left: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section code {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Watch Page */
|
||||||
|
.watch-page {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player-container {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-content {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-thumbnail {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 300px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-details {
|
||||||
|
background-color: var(--card-background);
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-details .video-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-metadata {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-info h3 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-link {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-link:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-description {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-description h4 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-description p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link a {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link a:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.nav-container {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-card {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-actions {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-metadata {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-stats {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
58
templates/add_channel.html
Normal file
58
templates/add_channel.html
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Add Channel - YottoB{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="add-channel-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>Add New Channel</h2>
|
||||||
|
<p>Subscribe to a YouTube channel to download and track videos</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-container">
|
||||||
|
<form method="POST" action="/add-channel" class="channel-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="rss_url">YouTube Channel RSS URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="rss_url"
|
||||||
|
name="rss_url"
|
||||||
|
placeholder="https://www.youtube.com/feeds/videos.xml?channel_id=..."
|
||||||
|
required
|
||||||
|
class="form-input"
|
||||||
|
>
|
||||||
|
<small class="form-help">
|
||||||
|
Enter the RSS feed URL for the YouTube channel.
|
||||||
|
Format: https://www.youtube.com/feeds/videos.xml?channel_id=CHANNEL_ID
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Subscribe & Fetch Videos</button>
|
||||||
|
<a href="/channels" class="btn btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-section">
|
||||||
|
<h3>How to find a channel's RSS URL</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Go to the YouTube channel page</li>
|
||||||
|
<li>Look at the URL in your browser - it will contain the channel ID</li>
|
||||||
|
<li>The channel URL format is usually:
|
||||||
|
<ul>
|
||||||
|
<li><code>youtube.com/channel/CHANNEL_ID</code> or</li>
|
||||||
|
<li><code>youtube.com/@username</code> (you'll need to find the channel ID from the page source)</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Use the format: <code>https://www.youtube.com/feeds/videos.xml?channel_id=CHANNEL_ID</code></li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h4>Example</h4>
|
||||||
|
<p>
|
||||||
|
For channel: <code>https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw</code><br>
|
||||||
|
RSS URL: <code>https://www.youtube.com/feeds/videos.xml?channel_id=UC_x5XG1OV2P6uZZ5FSM9Ttw</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
39
templates/base.html
Normal file
39
templates/base.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}YottoB - YouTube Downloader{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar">
|
||||||
|
<div class="nav-container">
|
||||||
|
<h1 class="logo"><a href="/">YottoB</a></h1>
|
||||||
|
<ul class="nav-menu">
|
||||||
|
<li><a href="/" class="{% if request.path == '/' %}active{% endif %}">Videos</a></li>
|
||||||
|
<li><a href="/channels" class="{% if request.path == '/channels' %}active{% endif %}">Channels</a></li>
|
||||||
|
<li><a href="/add-channel" class="{% if request.path == '/add-channel' %}active{% endif %}">Add Channel</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<p>© 2025 YottoB - YouTube Video Downloader</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
75
templates/channels.html
Normal file
75
templates/channels.html
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Channels - YottoB{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="channels-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>Subscribed Channels</h2>
|
||||||
|
<a href="/add-channel" class="btn btn-primary">Add New Channel</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if channels %}
|
||||||
|
<div class="channels-list">
|
||||||
|
{% for channel in channels %}
|
||||||
|
<div class="channel-card">
|
||||||
|
<div class="channel-info">
|
||||||
|
<h3 class="channel-title">{{ channel.title }}</h3>
|
||||||
|
<p class="channel-url">
|
||||||
|
<a href="{{ channel.rss_url }}" target="_blank">{{ channel.rss_url }}</a>
|
||||||
|
</p>
|
||||||
|
<div class="channel-meta">
|
||||||
|
<span class="video-count">{{ channel.video_entries|length }} videos</span>
|
||||||
|
<span class="last-updated">
|
||||||
|
Last updated: {{ channel.last_fetched_at.strftime('%b %d, %Y %I:%M %p') if channel.last_fetched_at else 'Never' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="channel-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="refreshChannel({{ channel.id }})">
|
||||||
|
Refresh Videos
|
||||||
|
</button>
|
||||||
|
<a href="/?channel={{ channel.id }}" class="btn btn-link">View Videos</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3>No channels subscribed</h3>
|
||||||
|
<p>Add your first YouTube channel to start downloading videos</p>
|
||||||
|
<a href="/add-channel" class="btn btn-primary">Add Channel</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function refreshChannel(channelId) {
|
||||||
|
const button = event.target;
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = 'Refreshing...';
|
||||||
|
|
||||||
|
fetch(`/api/videos/refresh/${channelId}`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
alert(`Refreshed! Found ${data.new_videos} new videos`);
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Failed to refresh: ' + data.message);
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = 'Refresh Videos';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert('Error: ' + error);
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = 'Refresh Videos';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
77
templates/dashboard.html
Normal file
77
templates/dashboard.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Videos - YottoB{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="dashboard">
|
||||||
|
<div class="dashboard-header">
|
||||||
|
<h2>All Videos</h2>
|
||||||
|
<p class="video-count">{{ videos|length }} videos</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if videos %}
|
||||||
|
<div class="video-grid">
|
||||||
|
{% for video in videos %}
|
||||||
|
<div class="video-card">
|
||||||
|
<a href="/watch/{{ video.id }}" class="video-thumbnail">
|
||||||
|
{% if video.thumbnail_url %}
|
||||||
|
<img src="{{ video.thumbnail_url }}" alt="{{ video.title }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="thumbnail-placeholder">No Thumbnail</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="video-duration">
|
||||||
|
{% if video.download_status == 'completed' %}
|
||||||
|
<span class="badge badge-success">Downloaded</span>
|
||||||
|
{% elif video.download_status == 'downloading' %}
|
||||||
|
<span class="badge badge-info">Downloading...</span>
|
||||||
|
{% elif video.download_status == 'failed' %}
|
||||||
|
<span class="badge badge-error">Failed</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div class="video-info">
|
||||||
|
<h3 class="video-title">
|
||||||
|
<a href="/watch/{{ video.id }}">{{ video.title }}</a>
|
||||||
|
</h3>
|
||||||
|
<p class="video-channel">{{ video.channel.title }}</p>
|
||||||
|
<div class="video-meta">
|
||||||
|
<span class="video-date">{{ video.published_at.strftime('%b %d, %Y') }}</span>
|
||||||
|
{% if video.download_status != 'completed' and video.download_status != 'downloading' %}
|
||||||
|
<button class="btn-download" onclick="downloadVideo({{ video.id }})">Download</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3>No videos yet</h3>
|
||||||
|
<p>Add a channel to start downloading videos</p>
|
||||||
|
<a href="/add-channel" class="btn btn-primary">Add Channel</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function downloadVideo(videoId) {
|
||||||
|
fetch(`/api/download/${videoId}`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
alert('Download started!');
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Failed to start download: ' + data.message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert('Error: ' + error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
110
templates/watch.html
Normal file
110
templates/watch.html
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ video.title }} - YottoB{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="watch-page">
|
||||||
|
<div class="video-player-container">
|
||||||
|
{% if video.download_status == 'completed' and video.download_path %}
|
||||||
|
<video controls class="video-player">
|
||||||
|
<source src="/api/video/stream/{{ video.id }}" type="video/mp4">
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
{% elif video.download_status == 'downloading' %}
|
||||||
|
<div class="video-placeholder">
|
||||||
|
<div class="placeholder-content">
|
||||||
|
<h3>Video is downloading...</h3>
|
||||||
|
<p>Please check back in a few minutes</p>
|
||||||
|
<button class="btn btn-secondary" onclick="location.reload()">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elif video.download_status == 'failed' %}
|
||||||
|
<div class="video-placeholder error">
|
||||||
|
<div class="placeholder-content">
|
||||||
|
<h3>Download failed</h3>
|
||||||
|
<p>There was an error downloading this video</p>
|
||||||
|
<button class="btn btn-primary" onclick="retryDownload()">Retry Download</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="video-placeholder">
|
||||||
|
<div class="placeholder-content">
|
||||||
|
{% if video.thumbnail_url %}
|
||||||
|
<img src="{{ video.thumbnail_url }}" alt="{{ video.title }}" class="placeholder-thumbnail">
|
||||||
|
{% endif %}
|
||||||
|
<h3>Video not downloaded yet</h3>
|
||||||
|
<button class="btn btn-primary" onclick="startDownload()">Download Video</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="video-details">
|
||||||
|
<h1 class="video-title">{{ video.title }}</h1>
|
||||||
|
|
||||||
|
<div class="video-metadata">
|
||||||
|
<div class="channel-info">
|
||||||
|
<h3>{{ video.channel.title }}</h3>
|
||||||
|
<a href="{{ video.channel.rss_url }}" target="_blank" class="channel-link">View Channel</a>
|
||||||
|
</div>
|
||||||
|
<div class="video-stats">
|
||||||
|
<span class="publish-date">Published: {{ video.published_at.strftime('%B %d, %Y') }}</span>
|
||||||
|
{% if video.download_status == 'completed' and video.download_completed_at %}
|
||||||
|
<span class="download-date">Downloaded: {{ video.download_completed_at.strftime('%B %d, %Y') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if video.description %}
|
||||||
|
<div class="video-description">
|
||||||
|
<h4>Description</h4>
|
||||||
|
<p>{{ video.description }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="video-links">
|
||||||
|
<a href="{{ video.video_url }}" target="_blank" class="btn btn-link">Watch on YouTube</a>
|
||||||
|
{% if video.download_status == 'completed' and video.download_path %}
|
||||||
|
<a href="/api/video/stream/{{ video.id }}?download=1" class="btn btn-secondary" download>Download MP4</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="back-link">
|
||||||
|
<a href="/">← Back to all videos</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function startDownload() {
|
||||||
|
if (!confirm('Start downloading this video?')) return;
|
||||||
|
|
||||||
|
fetch('/api/download/{{ video.id }}', {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
alert('Download started! This page will refresh automatically.');
|
||||||
|
setTimeout(() => location.reload(), 2000);
|
||||||
|
} else {
|
||||||
|
alert('Failed to start download: ' + data.message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert('Error: ' + error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryDownload() {
|
||||||
|
startDownload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh if video is downloading
|
||||||
|
{% if video.download_status == 'downloading' %}
|
||||||
|
setTimeout(() => location.reload(), 10000); // Refresh every 10 seconds
|
||||||
|
{% endif %}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user