Implement complete user authentication system
- Configured Flask-Login with user_loader - Added register, login, logout routes with proper validation - Created login.html and register.html templates with auth forms - Updated base.html navigation to show username and conditional menu - Added auth page styling to style.css - Protected all routes with @login_required decorator - Updated all routes to filter by current_user.id - Added user ownership validation for: - Channels (can only view/refresh own channels) - Videos (can only watch/download own videos) - Streams (can only stream videos from own channels) - Updated save_to_db() calls to pass current_user.id - Improved user_loader to properly handle session management Features: - User registration with password confirmation - Secure password hashing with bcrypt - Login with "remember me" functionality - Flash messages for all auth actions - Redirect to requested page after login - User-specific data isolation (multi-tenant) Security: - All sensitive routes require authentication - Users can only access their own data - Passwords hashed with bcrypt salt - Session-based authentication via Flask-Login 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,44 +0,0 @@
|
|||||||
"""Add download tracking fields to VideoEntry
|
|
||||||
|
|
||||||
Revision ID: 1b18a0e65b0d
|
|
||||||
Revises: 270efe6976bc
|
|
||||||
Create Date: 2025-11-26 14:01:41.900938
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '1b18a0e65b0d'
|
|
||||||
down_revision: Union[str, Sequence[str], None] = '270efe6976bc'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Upgrade schema."""
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.add_column('video_entries', sa.Column('download_status', sa.Enum('PENDING', 'DOWNLOADING', 'COMPLETED', 'FAILED', name='downloadstatus'), nullable=False))
|
|
||||||
op.add_column('video_entries', sa.Column('download_path', sa.String(length=1000), nullable=True))
|
|
||||||
op.add_column('video_entries', sa.Column('download_started_at', sa.DateTime(), nullable=True))
|
|
||||||
op.add_column('video_entries', sa.Column('download_completed_at', sa.DateTime(), nullable=True))
|
|
||||||
op.add_column('video_entries', sa.Column('download_error', sa.String(length=2000), nullable=True))
|
|
||||||
op.add_column('video_entries', sa.Column('file_size', sa.BigInteger(), nullable=True))
|
|
||||||
op.create_index('idx_download_status', 'video_entries', ['download_status'], unique=False)
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Downgrade schema."""
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.drop_index('idx_download_status', table_name='video_entries')
|
|
||||||
op.drop_column('video_entries', 'file_size')
|
|
||||||
op.drop_column('video_entries', 'download_error')
|
|
||||||
op.drop_column('video_entries', 'download_completed_at')
|
|
||||||
op.drop_column('video_entries', 'download_started_at')
|
|
||||||
op.drop_column('video_entries', 'download_path')
|
|
||||||
op.drop_column('video_entries', 'download_status')
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
"""Initial migration: Channel and VideoEntry tables
|
|
||||||
|
|
||||||
Revision ID: 270efe6976bc
|
|
||||||
Revises:
|
|
||||||
Create Date: 2025-11-26 13:55:52.270543
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '270efe6976bc'
|
|
||||||
down_revision: Union[str, Sequence[str], None] = None
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Upgrade schema."""
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.create_table('channels',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('channel_id', sa.String(length=50), nullable=False),
|
|
||||||
sa.Column('title', sa.String(length=200), nullable=False),
|
|
||||||
sa.Column('link', sa.String(length=500), nullable=False),
|
|
||||||
sa.Column('last_fetched', sa.DateTime(), nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_channels_channel_id'), 'channels', ['channel_id'], unique=True)
|
|
||||||
op.create_table('video_entries',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('channel_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('title', sa.String(length=500), nullable=False),
|
|
||||||
sa.Column('link', sa.String(length=500), nullable=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['channel_id'], ['channels.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('link')
|
|
||||||
)
|
|
||||||
op.create_index('idx_channel_created', 'video_entries', ['channel_id', 'created_at'], unique=False)
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Downgrade schema."""
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.drop_index('idx_channel_created', table_name='video_entries')
|
|
||||||
op.drop_table('video_entries')
|
|
||||||
op.drop_index(op.f('ix_channels_channel_id'), table_name='channels')
|
|
||||||
op.drop_table('channels')
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Add user authentication and enhance video fields
|
"""Initial schema with user authentication
|
||||||
|
|
||||||
Revision ID: a3c56d47f42a
|
Revision ID: c47f20eb915d
|
||||||
Revises: 1b18a0e65b0d
|
Revises:
|
||||||
Create Date: 2025-11-26 14:22:55.689811
|
Create Date: 2025-11-26 14:25:12.933911
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
@@ -12,8 +12,8 @@ import sqlalchemy as sa
|
|||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = 'a3c56d47f42a'
|
revision: str = 'c47f20eb915d'
|
||||||
down_revision: Union[str, Sequence[str], None] = '1b18a0e65b0d'
|
down_revision: Union[str, Sequence[str], None] = None
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
@@ -31,52 +31,64 @@ def upgrade() -> None:
|
|||||||
)
|
)
|
||||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||||
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
|
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
|
||||||
op.add_column('channels', sa.Column('user_id', sa.Integer(), nullable=False))
|
op.create_table('channels',
|
||||||
op.add_column('channels', sa.Column('rss_url', sa.String(length=500), nullable=False))
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
op.add_column('channels', sa.Column('last_fetched_at', sa.DateTime(), nullable=True))
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
op.drop_index(op.f('ix_channels_channel_id'), table_name='channels')
|
sa.Column('channel_id', sa.String(length=50), nullable=False),
|
||||||
op.create_index(op.f('ix_channels_channel_id'), 'channels', ['channel_id'], unique=False)
|
sa.Column('title', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('link', sa.String(length=500), nullable=False),
|
||||||
|
sa.Column('rss_url', sa.String(length=500), nullable=False),
|
||||||
|
sa.Column('last_fetched_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
op.create_index('idx_user_channel', 'channels', ['user_id', 'channel_id'], unique=True)
|
op.create_index('idx_user_channel', 'channels', ['user_id', 'channel_id'], unique=True)
|
||||||
|
op.create_index(op.f('ix_channels_channel_id'), 'channels', ['channel_id'], unique=False)
|
||||||
op.create_index(op.f('ix_channels_user_id'), 'channels', ['user_id'], unique=False)
|
op.create_index(op.f('ix_channels_user_id'), 'channels', ['user_id'], unique=False)
|
||||||
op.create_foreign_key(None, 'channels', 'users', ['user_id'], ['id'])
|
op.create_table('video_entries',
|
||||||
op.drop_column('channels', 'last_fetched')
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
op.add_column('video_entries', sa.Column('video_id', sa.String(length=50), nullable=False))
|
sa.Column('channel_id', sa.Integer(), nullable=False),
|
||||||
op.add_column('video_entries', sa.Column('video_url', sa.String(length=500), nullable=False))
|
sa.Column('video_id', sa.String(length=50), nullable=False),
|
||||||
op.add_column('video_entries', sa.Column('thumbnail_url', sa.String(length=500), nullable=True))
|
sa.Column('title', sa.String(length=500), nullable=False),
|
||||||
op.add_column('video_entries', sa.Column('description', sa.Text(), nullable=True))
|
sa.Column('video_url', sa.String(length=500), nullable=False),
|
||||||
op.add_column('video_entries', sa.Column('published_at', sa.DateTime(), nullable=False))
|
sa.Column('thumbnail_url', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('published_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('download_status', sa.Enum('PENDING', 'DOWNLOADING', 'COMPLETED', 'FAILED', name='downloadstatus'), nullable=False),
|
||||||
|
sa.Column('download_path', sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column('download_started_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('download_completed_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('download_error', sa.String(length=2000), nullable=True),
|
||||||
|
sa.Column('file_size', sa.BigInteger(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['channel_id'], ['channels.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('idx_channel_created', 'video_entries', ['channel_id', 'created_at'], unique=False)
|
||||||
|
op.create_index('idx_download_status', 'video_entries', ['download_status'], unique=False)
|
||||||
op.create_index('idx_published_at', 'video_entries', ['published_at'], unique=False)
|
op.create_index('idx_published_at', 'video_entries', ['published_at'], unique=False)
|
||||||
op.create_index('idx_video_id_channel', 'video_entries', ['video_id', 'channel_id'], unique=True)
|
op.create_index('idx_video_id_channel', 'video_entries', ['video_id', 'channel_id'], unique=True)
|
||||||
op.create_index(op.f('ix_video_entries_published_at'), 'video_entries', ['published_at'], unique=False)
|
op.create_index(op.f('ix_video_entries_published_at'), 'video_entries', ['published_at'], unique=False)
|
||||||
op.create_index(op.f('ix_video_entries_video_id'), 'video_entries', ['video_id'], unique=False)
|
op.create_index(op.f('ix_video_entries_video_id'), 'video_entries', ['video_id'], unique=False)
|
||||||
op.create_index(op.f('ix_video_entries_video_url'), 'video_entries', ['video_url'], unique=False)
|
op.create_index(op.f('ix_video_entries_video_url'), 'video_entries', ['video_url'], unique=False)
|
||||||
op.drop_column('video_entries', 'link')
|
|
||||||
# ### end Alembic commands ###
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
"""Downgrade schema."""
|
"""Downgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.add_column('video_entries', sa.Column('link', sa.VARCHAR(length=500), nullable=False))
|
|
||||||
op.drop_index(op.f('ix_video_entries_video_url'), table_name='video_entries')
|
op.drop_index(op.f('ix_video_entries_video_url'), table_name='video_entries')
|
||||||
op.drop_index(op.f('ix_video_entries_video_id'), table_name='video_entries')
|
op.drop_index(op.f('ix_video_entries_video_id'), table_name='video_entries')
|
||||||
op.drop_index(op.f('ix_video_entries_published_at'), table_name='video_entries')
|
op.drop_index(op.f('ix_video_entries_published_at'), table_name='video_entries')
|
||||||
op.drop_index('idx_video_id_channel', table_name='video_entries')
|
op.drop_index('idx_video_id_channel', table_name='video_entries')
|
||||||
op.drop_index('idx_published_at', table_name='video_entries')
|
op.drop_index('idx_published_at', table_name='video_entries')
|
||||||
op.drop_column('video_entries', 'published_at')
|
op.drop_index('idx_download_status', table_name='video_entries')
|
||||||
op.drop_column('video_entries', 'description')
|
op.drop_index('idx_channel_created', table_name='video_entries')
|
||||||
op.drop_column('video_entries', 'thumbnail_url')
|
op.drop_table('video_entries')
|
||||||
op.drop_column('video_entries', 'video_url')
|
|
||||||
op.drop_column('video_entries', 'video_id')
|
|
||||||
op.add_column('channels', sa.Column('last_fetched', sa.DATETIME(), nullable=False))
|
|
||||||
op.drop_constraint(None, 'channels', type_='foreignkey')
|
|
||||||
op.drop_index(op.f('ix_channels_user_id'), table_name='channels')
|
op.drop_index(op.f('ix_channels_user_id'), table_name='channels')
|
||||||
op.drop_index('idx_user_channel', table_name='channels')
|
|
||||||
op.drop_index(op.f('ix_channels_channel_id'), table_name='channels')
|
op.drop_index(op.f('ix_channels_channel_id'), table_name='channels')
|
||||||
op.create_index(op.f('ix_channels_channel_id'), 'channels', ['channel_id'], unique=1)
|
op.drop_index('idx_user_channel', table_name='channels')
|
||||||
op.drop_column('channels', 'last_fetched_at')
|
op.drop_table('channels')
|
||||||
op.drop_column('channels', 'rss_url')
|
|
||||||
op.drop_column('channels', 'user_id')
|
|
||||||
op.drop_index(op.f('ix_users_username'), table_name='users')
|
op.drop_index(op.f('ix_users_username'), table_name='users')
|
||||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||||
op.drop_table('users')
|
op.drop_table('users')
|
||||||
186
main.py
186
main.py
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash, send_file
|
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash, send_file
|
||||||
|
from flask_login import LoginManager, login_user, logout_user, login_required, current_user
|
||||||
from 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, User
|
||||||
from download_service import download_video, download_videos_batch
|
from download_service import download_video, download_videos_batch
|
||||||
from sqlalchemy import desc
|
from sqlalchemy import desc
|
||||||
|
|
||||||
@@ -12,6 +13,27 @@ from sqlalchemy import desc
|
|||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = os.getenv("SECRET_KEY", "dev-secret-key-change-in-production")
|
app.secret_key = os.getenv("SECRET_KEY", "dev-secret-key-change-in-production")
|
||||||
|
|
||||||
|
# Configure Flask-Login
|
||||||
|
login_manager = LoginManager()
|
||||||
|
login_manager.init_app(app)
|
||||||
|
login_manager.login_view = "login"
|
||||||
|
login_manager.login_message = "Please log in to access this page."
|
||||||
|
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:
|
||||||
|
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 for demonstration
|
||||||
DEFAULT_CHANNEL_ID = "UCtTWOND3uyl4tVc_FarDmpw"
|
DEFAULT_CHANNEL_ID = "UCtTWOND3uyl4tVc_FarDmpw"
|
||||||
|
|
||||||
@@ -21,15 +43,125 @@ with app.app_context():
|
|||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Authentication Routes
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@app.route("/register", methods=["GET", "POST"])
|
||||||
|
def register():
|
||||||
|
"""User registration page."""
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
username = request.form.get("username")
|
||||||
|
email = request.form.get("email")
|
||||||
|
password = request.form.get("password")
|
||||||
|
confirm_password = request.form.get("confirm_password")
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
if not username or not email or not password:
|
||||||
|
flash("All fields are required", "error")
|
||||||
|
return render_template("register.html")
|
||||||
|
|
||||||
|
if password != confirm_password:
|
||||||
|
flash("Passwords do not match", "error")
|
||||||
|
return render_template("register.html")
|
||||||
|
|
||||||
|
if len(password) < 8:
|
||||||
|
flash("Password must be at least 8 characters long", "error")
|
||||||
|
return render_template("register.html")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with get_db_session() as session:
|
||||||
|
# Check if username or email already exists
|
||||||
|
existing_user = session.query(User).filter(
|
||||||
|
(User.username == username) | (User.email == email)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_user:
|
||||||
|
if existing_user.username == username:
|
||||||
|
flash("Username already taken", "error")
|
||||||
|
else:
|
||||||
|
flash("Email already registered", "error")
|
||||||
|
return render_template("register.html")
|
||||||
|
|
||||||
|
# Create new user
|
||||||
|
user = User(username=username, email=email)
|
||||||
|
user.set_password(password)
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
flash("Registration successful! Please log in.", "success")
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Registration failed: {str(e)}", "error")
|
||||||
|
return render_template("register.html")
|
||||||
|
|
||||||
|
return render_template("register.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/login", methods=["GET", "POST"])
|
||||||
|
def login():
|
||||||
|
"""User login page."""
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
username = request.form.get("username")
|
||||||
|
password = request.form.get("password")
|
||||||
|
remember = request.form.get("remember") == "on"
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
flash("Please enter both username and password", "error")
|
||||||
|
return render_template("login.html")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with get_db_session() as session:
|
||||||
|
user = session.query(User).filter_by(username=username).first()
|
||||||
|
|
||||||
|
if user and user.check_password(password):
|
||||||
|
# Expunge user from session before login
|
||||||
|
session.expunge(user)
|
||||||
|
login_user(user, remember=remember)
|
||||||
|
flash(f"Welcome back, {user.username}!", "success")
|
||||||
|
|
||||||
|
# Redirect to next page if specified
|
||||||
|
next_page = request.args.get("next")
|
||||||
|
return redirect(next_page) if next_page else redirect(url_for("index"))
|
||||||
|
else:
|
||||||
|
flash("Invalid username or password", "error")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Login failed: {str(e)}", "error")
|
||||||
|
|
||||||
|
return render_template("login.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/logout")
|
||||||
|
@login_required
|
||||||
|
def logout():
|
||||||
|
"""Log out the current user."""
|
||||||
|
logout_user()
|
||||||
|
flash("You have been logged out", "info")
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Frontend Routes
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
@app.route("/", methods=["GET"])
|
@app.route("/", methods=["GET"])
|
||||||
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
"""Render the dashboard with all videos sorted by date."""
|
"""Render the dashboard with all videos sorted by date."""
|
||||||
try:
|
try:
|
||||||
with get_db_session() as session:
|
with get_db_session() as session:
|
||||||
# Query all videos with their channels, sorted by published date (newest first)
|
# Query videos for current user's channels, sorted by published date (newest first)
|
||||||
videos = session.query(VideoEntry).join(Channel).order_by(
|
videos = session.query(VideoEntry).join(Channel).filter(
|
||||||
desc(VideoEntry.published_at)
|
Channel.user_id == current_user.id
|
||||||
).all()
|
).order_by(desc(VideoEntry.published_at)).all()
|
||||||
|
|
||||||
return render_template("dashboard.html", videos=videos)
|
return render_template("dashboard.html", videos=videos)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -38,11 +170,14 @@ def index():
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/channels", methods=["GET"])
|
@app.route("/channels", methods=["GET"])
|
||||||
|
@login_required
|
||||||
def channels_page():
|
def channels_page():
|
||||||
"""Render the channels management page."""
|
"""Render the channels management page."""
|
||||||
try:
|
try:
|
||||||
with get_db_session() as session:
|
with get_db_session() as session:
|
||||||
channels = session.query(Channel).order_by(desc(Channel.last_fetched_at)).all()
|
channels = session.query(Channel).filter_by(
|
||||||
|
user_id=current_user.id
|
||||||
|
).order_by(desc(Channel.last_fetched_at)).all()
|
||||||
return render_template("channels.html", channels=channels)
|
return render_template("channels.html", channels=channels)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f"Error loading channels: {str(e)}", "error")
|
flash(f"Error loading channels: {str(e)}", "error")
|
||||||
@@ -50,6 +185,7 @@ def channels_page():
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/add-channel", methods=["GET", "POST"])
|
@app.route("/add-channel", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
def add_channel_page():
|
def add_channel_page():
|
||||||
"""Render the add channel page and handle channel subscription."""
|
"""Render the add channel page and handle channel subscription."""
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
@@ -78,9 +214,9 @@ def add_channel_page():
|
|||||||
flash("Failed to fetch feed from YouTube", "error")
|
flash("Failed to fetch feed from YouTube", "error")
|
||||||
return redirect(url_for("add_channel_page"))
|
return redirect(url_for("add_channel_page"))
|
||||||
|
|
||||||
# Save to database
|
# Save to database with current user
|
||||||
with get_db_session() as session:
|
with get_db_session() as session:
|
||||||
channel = parser.save_to_db(session, result)
|
channel = parser.save_to_db(session, result, current_user.id)
|
||||||
video_count = len(result["entries"])
|
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")
|
||||||
@@ -92,11 +228,17 @@ def add_channel_page():
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/watch/<int:video_id>", methods=["GET"])
|
@app.route("/watch/<int:video_id>", methods=["GET"])
|
||||||
|
@login_required
|
||||||
def watch_video(video_id: int):
|
def watch_video(video_id: int):
|
||||||
"""Render the video watch page."""
|
"""Render the video watch page."""
|
||||||
try:
|
try:
|
||||||
with get_db_session() as session:
|
with get_db_session() as session:
|
||||||
video = session.query(VideoEntry).filter_by(id=video_id).first()
|
# Only allow user to watch videos from their own channels
|
||||||
|
video = session.query(VideoEntry).join(Channel).filter(
|
||||||
|
VideoEntry.id == video_id,
|
||||||
|
Channel.user_id == current_user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
if not video:
|
if not video:
|
||||||
flash("Video not found", "error")
|
flash("Video not found", "error")
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
@@ -214,6 +356,7 @@ def get_history(channel_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/api/download/<int:video_id>", methods=["POST"])
|
@app.route("/api/download/<int:video_id>", methods=["POST"])
|
||||||
|
@login_required
|
||||||
def trigger_download(video_id: int):
|
def trigger_download(video_id: int):
|
||||||
"""Trigger video download for a specific video.
|
"""Trigger video download for a specific video.
|
||||||
|
|
||||||
@@ -225,7 +368,12 @@ def trigger_download(video_id: int):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with get_db_session() as session:
|
with get_db_session() as session:
|
||||||
video = session.query(VideoEntry).filter_by(id=video_id).first()
|
# Only allow downloading videos from user's own channels
|
||||||
|
video = session.query(VideoEntry).join(Channel).filter(
|
||||||
|
VideoEntry.id == video_id,
|
||||||
|
Channel.user_id == current_user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
if not video:
|
if not video:
|
||||||
return jsonify({"status": "error", "message": "Video not found"}), 404
|
return jsonify({"status": "error", "message": "Video not found"}), 404
|
||||||
|
|
||||||
@@ -345,6 +493,7 @@ def trigger_batch_download():
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/api/videos/refresh/<int:channel_id>", methods=["POST"])
|
@app.route("/api/videos/refresh/<int:channel_id>", methods=["POST"])
|
||||||
|
@login_required
|
||||||
def refresh_channel_videos(channel_id: int):
|
def refresh_channel_videos(channel_id: int):
|
||||||
"""Refresh videos for a specific channel by fetching latest from RSS feed.
|
"""Refresh videos for a specific channel by fetching latest from RSS feed.
|
||||||
|
|
||||||
@@ -356,7 +505,12 @@ def refresh_channel_videos(channel_id: int):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with get_db_session() as session:
|
with get_db_session() as session:
|
||||||
channel = session.query(Channel).filter_by(id=channel_id).first()
|
# Only allow refresh for user's own channels
|
||||||
|
channel = session.query(Channel).filter_by(
|
||||||
|
id=channel_id,
|
||||||
|
user_id=current_user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
if not channel:
|
if not channel:
|
||||||
return jsonify({"status": "error", "message": "Channel not found"}), 404
|
return jsonify({"status": "error", "message": "Channel not found"}), 404
|
||||||
|
|
||||||
@@ -371,7 +525,7 @@ def refresh_channel_videos(channel_id: int):
|
|||||||
existing_count = len(channel.video_entries)
|
existing_count = len(channel.video_entries)
|
||||||
|
|
||||||
# Save to database (upsert logic will handle duplicates)
|
# Save to database (upsert logic will handle duplicates)
|
||||||
parser.save_to_db(session, result)
|
parser.save_to_db(session, result, current_user.id)
|
||||||
|
|
||||||
# Count after save
|
# Count after save
|
||||||
session.refresh(channel)
|
session.refresh(channel)
|
||||||
@@ -389,6 +543,7 @@ def refresh_channel_videos(channel_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/api/video/stream/<int:video_id>", methods=["GET"])
|
@app.route("/api/video/stream/<int:video_id>", methods=["GET"])
|
||||||
|
@login_required
|
||||||
def stream_video(video_id: int):
|
def stream_video(video_id: int):
|
||||||
"""Stream a downloaded video file.
|
"""Stream a downloaded video file.
|
||||||
|
|
||||||
@@ -403,7 +558,12 @@ def stream_video(video_id: int):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with get_db_session() as session:
|
with get_db_session() as session:
|
||||||
video = session.query(VideoEntry).filter_by(id=video_id).first()
|
# Only allow streaming videos from user's own channels
|
||||||
|
video = session.query(VideoEntry).join(Channel).filter(
|
||||||
|
VideoEntry.id == video_id,
|
||||||
|
Channel.user_id == current_user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
if not video:
|
if not video:
|
||||||
return jsonify({"error": "Video not found"}), 404
|
return jsonify({"error": "Video not found"}), 404
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,17 @@ body {
|
|||||||
background-color: var(--border-color);
|
background-color: var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-user span {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
/* Container */
|
/* Container */
|
||||||
.container {
|
.container {
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
@@ -528,6 +539,80 @@ body {
|
|||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Auth Pages */
|
||||||
|
.auth-page {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: calc(100vh - 200px);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container {
|
||||||
|
max-width: 450px;
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--card-background);
|
||||||
|
padding: 3rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container h2 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-subtitle {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input[type="checkbox"] {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-block {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer {
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer a {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
/* Footer */
|
/* Footer */
|
||||||
.footer {
|
.footer {
|
||||||
background-color: var(--secondary-color);
|
background-color: var(--secondary-color);
|
||||||
|
|||||||
@@ -11,9 +11,18 @@
|
|||||||
<div class="nav-container">
|
<div class="nav-container">
|
||||||
<h1 class="logo"><a href="/">YottoB</a></h1>
|
<h1 class="logo"><a href="/">YottoB</a></h1>
|
||||||
<ul class="nav-menu">
|
<ul class="nav-menu">
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
<li><a href="/" class="{% if request.path == '/' %}active{% endif %}">Videos</a></li>
|
<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="/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>
|
<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>
|
||||||
|
<a href="{{ url_for('logout') }}">Logout</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li><a href="{{ url_for('login') }}" class="{% if request.path == '/login' %}active{% endif %}">Login</a></li>
|
||||||
|
<li><a href="{{ url_for('register') }}" class="{% if request.path == '/register' %}active{% endif %}">Register</a></li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
54
templates/login.html
Normal file
54
templates/login.html
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Login - YottoB{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="auth-page">
|
||||||
|
<div class="auth-container">
|
||||||
|
<h2>Login to YottoB</h2>
|
||||||
|
<p class="auth-subtitle">Access your YouTube video collection</p>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('login') }}" class="auth-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group checkbox-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" name="remember" id="remember">
|
||||||
|
<span>Remember me</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">Login</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="auth-footer">
|
||||||
|
<p>Don't have an account? <a href="{{ url_for('register') }}">Register here</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
77
templates/register.html
Normal file
77
templates/register.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Register - YottoB{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="auth-page">
|
||||||
|
<div class="auth-container">
|
||||||
|
<h2>Create Account</h2>
|
||||||
|
<p class="auth-subtitle">Join YottoB to start downloading YouTube videos</p>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('register') }}" class="auth-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Choose a username"
|
||||||
|
minlength="3"
|
||||||
|
maxlength="80"
|
||||||
|
>
|
||||||
|
<small class="form-help">3-80 characters, letters, numbers, and underscores</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Enter your email address"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Choose a strong password"
|
||||||
|
minlength="8"
|
||||||
|
>
|
||||||
|
<small class="form-help">At least 8 characters</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="confirm_password">Confirm Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="confirm_password"
|
||||||
|
name="confirm_password"
|
||||||
|
required
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Re-enter your password"
|
||||||
|
minlength="8"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">Create Account</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="auth-footer">
|
||||||
|
<p>Already have an account? <a href="{{ url_for('login') }}">Login here</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user