diff --git a/alembic/versions/1b18a0e65b0d_add_download_tracking_fields_to_.py b/alembic/versions/1b18a0e65b0d_add_download_tracking_fields_to_.py deleted file mode 100644 index 684b9f2..0000000 --- a/alembic/versions/1b18a0e65b0d_add_download_tracking_fields_to_.py +++ /dev/null @@ -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 ### diff --git a/alembic/versions/270efe6976bc_initial_migration_channel_and_.py b/alembic/versions/270efe6976bc_initial_migration_channel_and_.py deleted file mode 100644 index caee1d2..0000000 --- a/alembic/versions/270efe6976bc_initial_migration_channel_and_.py +++ /dev/null @@ -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 ### diff --git a/alembic/versions/a3c56d47f42a_add_user_authentication_and_enhance_.py b/alembic/versions/c47f20eb915d_initial_schema_with_user_authentication.py similarity index 52% rename from alembic/versions/a3c56d47f42a_add_user_authentication_and_enhance_.py rename to alembic/versions/c47f20eb915d_initial_schema_with_user_authentication.py index 9697e9b..84542ca 100644 --- a/alembic/versions/a3c56d47f42a_add_user_authentication_and_enhance_.py +++ b/alembic/versions/c47f20eb915d_initial_schema_with_user_authentication.py @@ -1,8 +1,8 @@ -"""Add user authentication and enhance video fields +"""Initial schema with user authentication -Revision ID: a3c56d47f42a -Revises: 1b18a0e65b0d -Create Date: 2025-11-26 14:22:55.689811 +Revision ID: c47f20eb915d +Revises: +Create Date: 2025-11-26 14:25:12.933911 """ from typing import Sequence, Union @@ -12,8 +12,8 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision: str = 'a3c56d47f42a' -down_revision: Union[str, Sequence[str], None] = '1b18a0e65b0d' +revision: str = 'c47f20eb915d' +down_revision: Union[str, Sequence[str], None] = None branch_labels: 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_username'), 'users', ['username'], unique=True) - op.add_column('channels', sa.Column('user_id', sa.Integer(), nullable=False)) - op.add_column('channels', sa.Column('rss_url', sa.String(length=500), nullable=False)) - op.add_column('channels', sa.Column('last_fetched_at', sa.DateTime(), nullable=True)) - 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=False) + op.create_table('channels', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_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('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(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_foreign_key(None, 'channels', 'users', ['user_id'], ['id']) - op.drop_column('channels', 'last_fetched') - op.add_column('video_entries', sa.Column('video_id', sa.String(length=50), nullable=False)) - op.add_column('video_entries', sa.Column('video_url', sa.String(length=500), nullable=False)) - op.add_column('video_entries', sa.Column('thumbnail_url', sa.String(length=500), nullable=True)) - op.add_column('video_entries', sa.Column('description', sa.Text(), nullable=True)) - op.add_column('video_entries', sa.Column('published_at', sa.DateTime(), nullable=False)) + op.create_table('video_entries', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('channel_id', sa.Integer(), nullable=False), + sa.Column('video_id', sa.String(length=50), nullable=False), + sa.Column('title', sa.String(length=500), nullable=False), + sa.Column('video_url', sa.String(length=500), 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_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_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.drop_column('video_entries', 'link') # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### 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_id'), 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_published_at', table_name='video_entries') - op.drop_column('video_entries', 'published_at') - op.drop_column('video_entries', 'description') - op.drop_column('video_entries', 'thumbnail_url') - 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('idx_download_status', table_name='video_entries') + op.drop_index('idx_channel_created', table_name='video_entries') + op.drop_table('video_entries') 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.create_index(op.f('ix_channels_channel_id'), 'channels', ['channel_id'], unique=1) - op.drop_column('channels', 'last_fetched_at') - op.drop_column('channels', 'rss_url') - op.drop_column('channels', 'user_id') + op.drop_index('idx_user_channel', table_name='channels') + op.drop_table('channels') op.drop_index(op.f('ix_users_username'), table_name='users') op.drop_index(op.f('ix_users_email'), table_name='users') op.drop_table('users') diff --git a/main.py b/main.py index 9a5c3a8..68f67e3 100644 --- a/main.py +++ b/main.py @@ -2,9 +2,10 @@ import os 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 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 sqlalchemy import desc @@ -12,6 +13,27 @@ from sqlalchemy import desc app = Flask(__name__) 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 = "UCtTWOND3uyl4tVc_FarDmpw" @@ -21,15 +43,125 @@ with app.app_context(): 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"]) +@login_required def index(): """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() + # Query videos for current user's channels, sorted by published date (newest first) + videos = session.query(VideoEntry).join(Channel).filter( + Channel.user_id == current_user.id + ).order_by(desc(VideoEntry.published_at)).all() return render_template("dashboard.html", videos=videos) except Exception as e: @@ -38,11 +170,14 @@ def index(): @app.route("/channels", methods=["GET"]) +@login_required 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() + 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) except Exception as e: flash(f"Error loading channels: {str(e)}", "error") @@ -50,6 +185,7 @@ def channels_page(): @app.route("/add-channel", methods=["GET", "POST"]) +@login_required def add_channel_page(): """Render the add channel page and handle channel subscription.""" if request.method == "GET": @@ -78,9 +214,9 @@ def add_channel_page(): flash("Failed to fetch feed from YouTube", "error") return redirect(url_for("add_channel_page")) - # Save to database + # Save to database with current user 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"]) flash(f"Successfully subscribed to {channel.title}! Added {video_count} videos.", "success") @@ -92,11 +228,17 @@ def add_channel_page(): @app.route("/watch/", methods=["GET"]) +@login_required 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() + # 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: flash("Video not found", "error") return redirect(url_for("index")) @@ -214,6 +356,7 @@ def get_history(channel_id: str): @app.route("/api/download/", methods=["POST"]) +@login_required def trigger_download(video_id: int): """Trigger video download for a specific video. @@ -225,7 +368,12 @@ def trigger_download(video_id: int): """ try: 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: return jsonify({"status": "error", "message": "Video not found"}), 404 @@ -345,6 +493,7 @@ def trigger_batch_download(): @app.route("/api/videos/refresh/", methods=["POST"]) +@login_required def refresh_channel_videos(channel_id: int): """Refresh videos for a specific channel by fetching latest from RSS feed. @@ -356,7 +505,12 @@ def refresh_channel_videos(channel_id: int): """ try: 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: 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) # 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 session.refresh(channel) @@ -389,6 +543,7 @@ def refresh_channel_videos(channel_id: int): @app.route("/api/video/stream/", methods=["GET"]) +@login_required def stream_video(video_id: int): """Stream a downloaded video file. @@ -403,7 +558,12 @@ def stream_video(video_id: int): """ try: 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: return jsonify({"error": "Video not found"}), 404 diff --git a/static/style.css b/static/style.css index 0a979fb..d8327b1 100644 --- a/static/style.css +++ b/static/style.css @@ -70,6 +70,17 @@ body { 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 { max-width: 1400px; @@ -528,6 +539,80 @@ body { 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 { background-color: var(--secondary-color); diff --git a/templates/base.html b/templates/base.html index a935b29..15f649a 100644 --- a/templates/base.html +++ b/templates/base.html @@ -11,9 +11,18 @@ diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..bb9d0b9 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} + +{% block title %}Login - YottoB{% endblock %} + +{% block content %} +
+
+

Login to YottoB

+

Access your YouTube video collection

+ +
+
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+
+ + +
+
+{% endblock %} diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..d18492b --- /dev/null +++ b/templates/register.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} + +{% block title %}Register - YottoB{% endblock %} + +{% block content %} +
+
+

Create Account

+

Join YottoB to start downloading YouTube videos

+ +
+
+ + + 3-80 characters, letters, numbers, and underscores +
+ +
+ + +
+ +
+ + + At least 8 characters +
+ +
+ + +
+ +
+ +
+
+ + +
+
+{% endblock %}