Implement UI improvements and download management features
- Switch to light mode with black and white color scheme - Simplify channel subscription to use channel ID only instead of RSS URL - Add Downloads page to track all video download jobs - Fix Flask-Login session management bug in user loader - Always filter YouTube Shorts from feeds (case-insensitive) - Fix download service video URL attribute error - Fix watch page enum comparison for download status display UI Changes: - Update CSS to pure black/white/grayscale theme - Remove colored text and buttons - Use underlines for hover states instead of color changes - Improve visual hierarchy with grayscale shades Channel Subscription: - Accept channel ID directly instead of full RSS URL - Add validation for channel ID format (UC/UU prefix) - Update help text and examples for easier onboarding Downloads Page: - New route at /downloads showing all video download jobs - Display status, progress, and metadata for each download - Sortable by status (downloading, pending, failed, completed) - Actions to download, retry, or watch videos - Responsive grid layout with thumbnails Bug Fixes: - Fix user loader to properly use database session context manager - Fix download service accessing wrong attribute (link → video_url) - Fix watch page template enum value comparisons - Fix session detachment issues when accessing channel data 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ RUN apt-get update && apt-get install -y \
|
||||
|
||||
# Install uv for faster Python package management
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
ENV PATH="/root/.cargo/bin:$PATH"
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
@@ -30,4 +30,4 @@ RUN mkdir -p downloads
|
||||
EXPOSE 5000
|
||||
|
||||
# Default command (can be overridden in docker-compose)
|
||||
CMD ["flask", "--app", "main", "run", "--host=0.0.0.0"]
|
||||
CMD ["uv", "run", "flask", "--app", "main", "run", "--host=0.0.0.0"]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version: '3.8'
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
@@ -32,14 +32,14 @@ services:
|
||||
app:
|
||||
build: .
|
||||
container_name: yottob-app
|
||||
command: flask --app main run --host=0.0.0.0 --port=5000
|
||||
command: uv run flask --app main run --host=0.0.0.0 --port=5000
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-yottob}:${POSTGRES_PASSWORD:-yottob_password}@postgres:5432/${POSTGRES_DB:-yottob}
|
||||
CELERY_BROKER_URL: redis://redis:6379/0
|
||||
CELERY_RESULT_BACKEND: redis://redis:6379/0
|
||||
FLASK_ENV: ${FLASK_ENV:-development}
|
||||
ports:
|
||||
- "5000:5000"
|
||||
- "5123:5000"
|
||||
volumes:
|
||||
- ./downloads:/app/downloads
|
||||
- ./:/app
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
celery:
|
||||
build: .
|
||||
container_name: yottob-celery
|
||||
command: celery -A celery_app worker --loglevel=info
|
||||
command: uv run celery -A celery_app worker --loglevel=info
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-yottob}:${POSTGRES_PASSWORD:-yottob_password}@postgres:5432/${POSTGRES_DB:-yottob}
|
||||
CELERY_BROKER_URL: redis://redis:6379/0
|
||||
|
||||
@@ -58,8 +58,8 @@ def download_video(self, video_id: int) -> dict:
|
||||
session.commit()
|
||||
|
||||
try:
|
||||
# Extract video ID from YouTube URL
|
||||
youtube_url = video.link
|
||||
# Get video URL from database
|
||||
youtube_url = video.video_url
|
||||
|
||||
# Configure yt-dlp options for MP4 output
|
||||
ydl_opts = {
|
||||
|
||||
@@ -70,7 +70,7 @@ class YouTubeFeedParser:
|
||||
|
||||
entries = []
|
||||
for entry in feed.entries:
|
||||
if filter_shorts and "shorts" in entry.link:
|
||||
if filter_shorts and "shorts" in entry.link.lower():
|
||||
continue
|
||||
|
||||
# Extract video ID from URL
|
||||
|
||||
60
main.py
60
main.py
@@ -24,15 +24,12 @@ login_manager.login_message_category = "info"
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
"""Load user by ID for Flask-Login."""
|
||||
session = next(get_db_session())
|
||||
try:
|
||||
with get_db_session() as session:
|
||||
user = session.query(User).get(int(user_id))
|
||||
if user:
|
||||
# Expire the user from this session so it can be used in request context
|
||||
session.expunge(user)
|
||||
return user
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# Default channel ID for demonstration
|
||||
DEFAULT_CHANNEL_ID = "UCtTWOND3uyl4tVc_FarDmpw"
|
||||
@@ -184,6 +181,32 @@ def channels_page():
|
||||
return render_template("channels.html", channels=[])
|
||||
|
||||
|
||||
@app.route("/downloads", methods=["GET"])
|
||||
@login_required
|
||||
def downloads_page():
|
||||
"""Render the downloads page showing all download jobs."""
|
||||
try:
|
||||
with get_db_session() as session:
|
||||
# Get all videos for current user's channels with download activity
|
||||
# Order by: downloading first, then pending, then failed, then completed
|
||||
# Within each status, order by download_started_at or created_at
|
||||
videos = session.query(VideoEntry).join(Channel).filter(
|
||||
Channel.user_id == current_user.id
|
||||
).order_by(
|
||||
# Custom sort: downloading=0, pending=1, failed=2, completed=3
|
||||
desc(VideoEntry.download_status == DownloadStatus.DOWNLOADING),
|
||||
desc(VideoEntry.download_status == DownloadStatus.PENDING),
|
||||
desc(VideoEntry.download_status == DownloadStatus.FAILED),
|
||||
desc(VideoEntry.download_started_at),
|
||||
desc(VideoEntry.created_at)
|
||||
).all()
|
||||
|
||||
return render_template("downloads.html", videos=videos)
|
||||
except Exception as e:
|
||||
flash(f"Error loading downloads: {str(e)}", "error")
|
||||
return render_template("downloads.html", videos=[])
|
||||
|
||||
|
||||
@app.route("/add-channel", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def add_channel_page():
|
||||
@@ -192,34 +215,33 @@ def add_channel_page():
|
||||
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")
|
||||
channel_id = request.form.get("channel_id")
|
||||
if not channel_id:
|
||||
flash("Channel ID 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"))
|
||||
# Validate channel ID format (should start with UC or UU)
|
||||
channel_id = channel_id.strip()
|
||||
if not channel_id.startswith(("UC", "UU")):
|
||||
flash("Invalid channel ID format. Channel IDs should start with UC or UU.", "error")
|
||||
return redirect(url_for("add_channel_page"))
|
||||
|
||||
try:
|
||||
# Fetch feed using parser
|
||||
parser = YouTubeFeedParser(channel_id)
|
||||
result = parser.fetch_feed()
|
||||
|
||||
if result is None:
|
||||
flash("Failed to fetch feed from YouTube", "error")
|
||||
flash("Failed to fetch feed from YouTube. Please check the channel ID.", "error")
|
||||
return redirect(url_for("add_channel_page"))
|
||||
|
||||
# Save to database with current user
|
||||
with get_db_session() as session:
|
||||
channel = parser.save_to_db(session, result, current_user.id)
|
||||
channel_title = channel.title
|
||||
video_count = len(result["entries"])
|
||||
|
||||
flash(f"Successfully subscribed to {channel.title}! Added {video_count} videos.", "success")
|
||||
flash(f"Successfully subscribed to {channel_title}! Added {video_count} videos.", "success")
|
||||
return redirect(url_for("channels_page"))
|
||||
|
||||
except Exception as e:
|
||||
@@ -255,18 +277,16 @@ def get_feed():
|
||||
|
||||
Query parameters:
|
||||
channel_id: YouTube channel ID (optional, uses default if not provided)
|
||||
filter_shorts: Whether to filter out Shorts (default: true)
|
||||
save: Whether to save to database (default: true)
|
||||
|
||||
Returns:
|
||||
JSON response with feed data or error message
|
||||
"""
|
||||
channel_id = request.args.get("channel_id", DEFAULT_CHANNEL_ID)
|
||||
filter_shorts = request.args.get("filter_shorts", "true").lower() == "true"
|
||||
save_to_db = request.args.get("save", "true").lower() == "true"
|
||||
|
||||
parser = YouTubeFeedParser(channel_id)
|
||||
result = parser.fetch_feed(filter_shorts=filter_shorts)
|
||||
result = parser.fetch_feed(filter_shorts=True)
|
||||
|
||||
if result is None:
|
||||
return jsonify({"error": "Failed to fetch feed"}), 500
|
||||
|
||||
176
static/style.css
176
static/style.css
@@ -6,16 +6,16 @@
|
||||
}
|
||||
|
||||
: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;
|
||||
--primary-color: #000000;
|
||||
--secondary-color: #f5f5f5;
|
||||
--background-color: #ffffff;
|
||||
--card-background: #fafafa;
|
||||
--text-primary: #000000;
|
||||
--text-secondary: #666666;
|
||||
--border-color: #e0e0e0;
|
||||
--success-color: #000000;
|
||||
--error-color: #000000;
|
||||
--info-color: #000000;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -45,7 +45,7 @@ body {
|
||||
}
|
||||
|
||||
.logo a {
|
||||
color: var(--primary-color);
|
||||
color: #000000;
|
||||
text-decoration: none;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
@@ -98,18 +98,21 @@ body {
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: rgba(0, 170, 0, 0.1);
|
||||
background-color: #f5f5f5;
|
||||
border-color: var(--success-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: rgba(204, 0, 0, 0.1);
|
||||
background-color: #f5f5f5;
|
||||
border-color: var(--error-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: rgba(0, 102, 204, 0.1);
|
||||
background-color: #f5f5f5;
|
||||
border-color: var(--info-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Dashboard */
|
||||
@@ -140,7 +143,7 @@ body {
|
||||
|
||||
.video-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.video-thumbnail {
|
||||
@@ -188,7 +191,7 @@ body {
|
||||
}
|
||||
|
||||
.video-title a:hover {
|
||||
color: var(--primary-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.video-channel {
|
||||
@@ -217,17 +220,17 @@ body {
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: var(--success-color);
|
||||
background-color: #000000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background-color: var(--info-color);
|
||||
background-color: #666666;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background-color: var(--error-color);
|
||||
background-color: #000000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -248,24 +251,25 @@ body {
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
background-color: #000000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
background-color: #f5f5f5;
|
||||
color: #000000;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background-color: transparent;
|
||||
color: var(--primary-color);
|
||||
border: 1px solid var(--primary-color);
|
||||
color: #000000;
|
||||
border: 1px solid #000000;
|
||||
}
|
||||
|
||||
.btn-download {
|
||||
background-color: transparent;
|
||||
color: var(--primary-color);
|
||||
color: #000000;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
@@ -331,7 +335,7 @@ body {
|
||||
}
|
||||
|
||||
.channel-url a:hover {
|
||||
color: var(--primary-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.channel-meta {
|
||||
@@ -346,6 +350,103 @@ body {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Downloads Page */
|
||||
.downloads-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.download-card {
|
||||
background-color: var(--card-background);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr auto auto;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.download-thumbnail {
|
||||
width: 200px;
|
||||
aspect-ratio: 16/9;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.download-thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.download-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.download-title {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.download-title a {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.download-title a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.download-channel {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.download-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.download-status-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.download-size {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.download-error {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.5rem;
|
||||
max-width: 150px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.download-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.download-actions .btn {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.form-container {
|
||||
max-width: 600px;
|
||||
@@ -497,7 +598,7 @@ body {
|
||||
}
|
||||
|
||||
.channel-link:hover {
|
||||
color: var(--primary-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.video-stats {
|
||||
@@ -536,7 +637,7 @@ body {
|
||||
}
|
||||
|
||||
.back-link a:hover {
|
||||
color: var(--primary-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Auth Pages */
|
||||
@@ -554,7 +655,8 @@ body {
|
||||
background-color: var(--card-background);
|
||||
padding: 3rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.auth-container h2 {
|
||||
@@ -605,8 +707,9 @@ body {
|
||||
}
|
||||
|
||||
.auth-footer a {
|
||||
color: var(--primary-color);
|
||||
color: #000000;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-footer a:hover {
|
||||
@@ -657,4 +760,17 @@ body {
|
||||
.video-stats {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.download-card {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.download-thumbnail {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.download-actions {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,18 +12,17 @@
|
||||
<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>
|
||||
<label for="channel_id">YouTube Channel ID</label>
|
||||
<input
|
||||
type="url"
|
||||
id="rss_url"
|
||||
name="rss_url"
|
||||
placeholder="https://www.youtube.com/feeds/videos.xml?channel_id=..."
|
||||
type="text"
|
||||
id="channel_id"
|
||||
name="channel_id"
|
||||
placeholder="UC_x5XG1OV2P6uZZ5FSM9Ttw"
|
||||
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
|
||||
Enter the YouTube channel ID (starts with UC or UU).
|
||||
</small>
|
||||
</div>
|
||||
|
||||
@@ -35,23 +34,24 @@
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h3>How to find a channel's RSS URL</h3>
|
||||
<h3>How to find a channel ID</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:
|
||||
<li>Look at the URL in your browser</li>
|
||||
<li>If the URL is <code>youtube.com/channel/CHANNEL_ID</code>, copy the CHANNEL_ID part</li>
|
||||
<li>If the URL is <code>youtube.com/@username</code>, you'll need to:
|
||||
<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>
|
||||
<li>Right-click the page and select "View Page Source"</li>
|
||||
<li>Search for "channelId" or "browse_id"</li>
|
||||
<li>Copy the ID that starts with "UC"</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>
|
||||
For channel URL: <code>https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw</code><br>
|
||||
Channel ID: <code>UC_x5XG1OV2P6uZZ5FSM9Ttw</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
{% if current_user.is_authenticated %}
|
||||
<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="/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 class="nav-user">
|
||||
<span>{{ current_user.username }}</span>
|
||||
|
||||
79
templates/downloads.html
Normal file
79
templates/downloads.html
Normal file
@@ -0,0 +1,79 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Downloads - YottoB{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="downloads-page">
|
||||
<div class="page-header">
|
||||
<h2>Download Jobs</h2>
|
||||
<p>View and manage all video downloads</p>
|
||||
</div>
|
||||
|
||||
{% if videos %}
|
||||
<div class="downloads-list">
|
||||
{% for video in videos %}
|
||||
<div class="download-card">
|
||||
<div class="download-thumbnail">
|
||||
{% if video.thumbnail_url %}
|
||||
<img src="{{ video.thumbnail_url }}" alt="{{ video.title }}">
|
||||
{% else %}
|
||||
<div class="thumbnail-placeholder">No thumbnail</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="download-info">
|
||||
<h3 class="download-title">
|
||||
<a href="/watch/{{ video.id }}">{{ video.title }}</a>
|
||||
</h3>
|
||||
<p class="download-channel">{{ video.channel.title }}</p>
|
||||
<div class="download-meta">
|
||||
<span class="download-date">Published: {{ video.published_at.strftime('%Y-%m-%d') }}</span>
|
||||
{% if video.download_started_at %}
|
||||
<span class="download-date">Started: {{ video.download_started_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||
{% endif %}
|
||||
{% if video.download_completed_at %}
|
||||
<span class="download-date">Completed: {{ video.download_completed_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="download-status-section">
|
||||
{% if video.download_status.value == 'completed' %}
|
||||
<span class="badge badge-success">Completed</span>
|
||||
{% if video.file_size %}
|
||||
<span class="download-size">{{ (video.file_size / 1024 / 1024) | round(2) }} MB</span>
|
||||
{% endif %}
|
||||
{% elif video.download_status.value == 'downloading' %}
|
||||
<span class="badge badge-info">Downloading...</span>
|
||||
{% elif video.download_status.value == 'failed' %}
|
||||
<span class="badge badge-error">Failed</span>
|
||||
{% if video.download_error %}
|
||||
<p class="download-error">{{ video.download_error }}</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="badge badge-info">Pending</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="download-actions">
|
||||
{% if video.download_status.value == 'pending' or video.download_status.value == 'failed' %}
|
||||
<form method="POST" action="/api/download/{{ video.id }}" style="display: inline;">
|
||||
<button type="submit" class="btn btn-primary">Download</button>
|
||||
</form>
|
||||
{% elif video.download_status.value == 'completed' %}
|
||||
<a href="/watch/{{ video.id }}" class="btn btn-primary">Watch</a>
|
||||
<a href="/api/video/stream/{{ video.id }}?download=1" class="btn btn-secondary">Download File</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<h3>No Videos Yet</h3>
|
||||
<p>Subscribe to channels to see videos here.</p>
|
||||
<a href="/add-channel" class="btn btn-primary">Add Channel</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -5,12 +5,12 @@
|
||||
{% block content %}
|
||||
<div class="watch-page">
|
||||
<div class="video-player-container">
|
||||
{% if video.download_status == 'completed' and video.download_path %}
|
||||
{% if video.download_status.value == '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' %}
|
||||
{% elif video.download_status.value == 'downloading' %}
|
||||
<div class="video-placeholder">
|
||||
<div class="placeholder-content">
|
||||
<h3>Video is downloading...</h3>
|
||||
@@ -18,7 +18,7 @@
|
||||
<button class="btn btn-secondary" onclick="location.reload()">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
{% elif video.download_status == 'failed' %}
|
||||
{% elif video.download_status.value == 'failed' %}
|
||||
<div class="video-placeholder error">
|
||||
<div class="placeholder-content">
|
||||
<h3>Download failed</h3>
|
||||
@@ -49,7 +49,7 @@
|
||||
</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 %}
|
||||
{% if video.download_status.value == 'completed' and video.download_completed_at %}
|
||||
<span class="download-date">Downloaded: {{ video.download_completed_at.strftime('%B %d, %Y') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -64,7 +64,7 @@
|
||||
|
||||
<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 %}
|
||||
{% if video.download_status.value == 'completed' and video.download_path %}
|
||||
<a href="/api/video/stream/{{ video.id }}?download=1" class="btn btn-secondary" download>Download MP4</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -103,7 +103,7 @@
|
||||
}
|
||||
|
||||
// Auto-refresh if video is downloading
|
||||
{% if video.download_status == 'downloading' %}
|
||||
{% if video.download_status.value == 'downloading' %}
|
||||
setTimeout(() => location.reload(), 10000); // Refresh every 10 seconds
|
||||
{% endif %}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user