From 9bcd43902406723ecd908ab0e7094e5b30c6de1c Mon Sep 17 00:00:00 2001 From: Ryan Chen Date: Wed, 26 Nov 2025 14:18:33 -0500 Subject: [PATCH] Add complete frontend interface with Jinja2 templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 player page - Added new API endpoints: - /api/videos/refresh/: Refresh channel videos - /api/video/stream/: Stream/download video files - Enhanced /api/download/ 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 --- CLAUDE.md | 95 ++++-- main.py | 193 ++++++++++++- static/style.css | 575 +++++++++++++++++++++++++++++++++++++ templates/add_channel.html | 58 ++++ templates/base.html | 39 +++ templates/channels.html | 75 +++++ templates/dashboard.html | 77 +++++ templates/watch.html | 110 +++++++ 8 files changed, 1198 insertions(+), 24 deletions(-) create mode 100644 static/style.css create mode 100644 templates/add_channel.html create mode 100644 templates/base.html create mode 100644 templates/channels.html create mode 100644 templates/dashboard.html create mode 100644 templates/watch.html diff --git a/CLAUDE.md b/CLAUDE.md index 86425f4..9663081 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,14 +111,35 @@ python main.py # Run the Flask web application flask --app main run ``` -The web server exposes: -- `/` - Main page (renders `index.html`) -- `/api/feed` - API endpoint for fetching feeds and saving to database -- `/api/channels` - List all tracked channels -- `/api/history/` - Get video history for a specific channel + +## Frontend Interface + +The application includes a full-featured web interface built with Jinja2 templates: + +**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 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/` - Get video history for a specific channel (GET) - `/api/download/` - Trigger video download (POST) - `/api/download/status/` - Check download status (GET) - `/api/download/batch` - Batch download multiple videos (POST) +- `/api/videos/refresh/` - Refresh videos for a channel (POST) +- `/api/video/stream/` - Stream or download video file (GET) **API Usage Examples:** ```bash @@ -192,19 +213,59 @@ The codebase follows a clean layered architecture with separation of concerns: ### Web Server Layer **`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 -**`templates/index.html`** - Frontend HTML (currently static placeholder) +**Frontend Routes:** +- `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 diff --git a/main.py b/main.py index 7048fd9..9a5c3a8 100644 --- a/main.py +++ b/main.py @@ -1,13 +1,16 @@ """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 database import init_db, get_db_session from models import Channel, VideoEntry, DownloadStatus 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") # Default channel ID for demonstration DEFAULT_CHANNEL_ID = "UCtTWOND3uyl4tVc_FarDmpw" @@ -20,8 +23,88 @@ with app.app_context(): @app.route("/", methods=["GET"]) def index(): - """Render the main page.""" - return render_template("index.html") + """Render the dashboard with all videos sorted by date.""" + 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/", 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"]) @@ -144,19 +227,32 @@ def trigger_download(video_id: int): 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({"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, - "status": "queued", - "message": "Download task queued successfully" + "message": "Download started" }) 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/", methods=["GET"]) @@ -248,6 +344,89 @@ def trigger_batch_download(): return jsonify({"error": f"Failed to queue batch download: {str(e)}"}), 500 +@app.route("/api/videos/refresh/", 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/", 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(): """CLI entry point for testing feed parser.""" parser = YouTubeFeedParser(DEFAULT_CHANNEL_ID) diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..0a979fb --- /dev/null +++ b/static/style.css @@ -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; + } +} diff --git a/templates/add_channel.html b/templates/add_channel.html new file mode 100644 index 0000000..b81fbf3 --- /dev/null +++ b/templates/add_channel.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} + +{% block title %}Add Channel - YottoB{% endblock %} + +{% block content %} +
+ + +
+
+
+ + + + Enter the RSS feed URL for the YouTube channel. + Format: https://www.youtube.com/feeds/videos.xml?channel_id=CHANNEL_ID + +
+ +
+ + Cancel +
+
+
+ +
+

How to find a channel's RSS URL

+
    +
  1. Go to the YouTube channel page
  2. +
  3. Look at the URL in your browser - it will contain the channel ID
  4. +
  5. The channel URL format is usually: +
      +
    • youtube.com/channel/CHANNEL_ID or
    • +
    • youtube.com/@username (you'll need to find the channel ID from the page source)
    • +
    +
  6. +
  7. Use the format: https://www.youtube.com/feeds/videos.xml?channel_id=CHANNEL_ID
  8. +
+ +

Example

+

+ For channel: https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw
+ RSS URL: https://www.youtube.com/feeds/videos.xml?channel_id=UC_x5XG1OV2P6uZZ5FSM9Ttw +

+
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..a935b29 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,39 @@ + + + + + + {% block title %}YottoB - YouTube Downloader{% endblock %} + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ +
+

© 2025 YottoB - YouTube Video Downloader

+
+ + {% block scripts %}{% endblock %} + + diff --git a/templates/channels.html b/templates/channels.html new file mode 100644 index 0000000..f600ffd --- /dev/null +++ b/templates/channels.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} + +{% block title %}Channels - YottoB{% endblock %} + +{% block content %} +
+ + + {% if channels %} +
+ {% for channel in channels %} +
+
+

{{ channel.title }}

+

+ {{ channel.rss_url }} +

+
+ {{ channel.video_entries|length }} videos + + Last updated: {{ channel.last_fetched_at.strftime('%b %d, %Y %I:%M %p') if channel.last_fetched_at else 'Never' }} + +
+
+
+ + View Videos +
+
+ {% endfor %} +
+ {% else %} +
+

No channels subscribed

+

Add your first YouTube channel to start downloading videos

+ Add Channel +
+ {% endif %} +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..e2fb0f6 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} + +{% block title %}Videos - YottoB{% endblock %} + +{% block content %} +
+
+

All Videos

+

{{ videos|length }} videos

+
+ + {% if videos %} +
+ {% for video in videos %} +
+ + {% if video.thumbnail_url %} + {{ video.title }} + {% else %} +
No Thumbnail
+ {% endif %} +
+ {% if video.download_status == 'completed' %} + Downloaded + {% elif video.download_status == 'downloading' %} + Downloading... + {% elif video.download_status == 'failed' %} + Failed + {% endif %} +
+
+
+

+ {{ video.title }} +

+

{{ video.channel.title }}

+
+ {{ video.published_at.strftime('%b %d, %Y') }} + {% if video.download_status != 'completed' and video.download_status != 'downloading' %} + + {% endif %} +
+
+
+ {% endfor %} +
+ {% else %} +
+

No videos yet

+

Add a channel to start downloading videos

+ Add Channel +
+ {% endif %} +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/watch.html b/templates/watch.html new file mode 100644 index 0000000..0c31e87 --- /dev/null +++ b/templates/watch.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} + +{% block title %}{{ video.title }} - YottoB{% endblock %} + +{% block content %} +
+
+ {% if video.download_status == 'completed' and video.download_path %} + + {% elif video.download_status == 'downloading' %} +
+
+

Video is downloading...

+

Please check back in a few minutes

+ +
+
+ {% elif video.download_status == 'failed' %} +
+
+

Download failed

+

There was an error downloading this video

+ +
+
+ {% else %} +
+
+ {% if video.thumbnail_url %} + {{ video.title }} + {% endif %} +

Video not downloaded yet

+ +
+
+ {% endif %} +
+ +
+

{{ video.title }}

+ + + + {% if video.description %} +
+

Description

+

{{ video.description }}

+
+ {% endif %} + + +
+
+ + +{% endblock %} + +{% block scripts %} + +{% endblock %}