Add user authentication system with schema refactor

BREAKING CHANGE: This commit introduces significant schema changes that require
a fresh database. See migration instructions below.

Changes:
- Added Flask-Login and bcrypt dependencies to pyproject.toml
- Created User model with password hashing methods
- Updated Channel model:
  - Added user_id foreign key relationship
  - Added rss_url field
  - Renamed last_fetched to last_fetched_at
  - Added composite unique index on (user_id, channel_id)
- Updated VideoEntry model:
  - Added video_id, video_url, thumbnail_url, description, published_at fields
  - Renamed link to video_url
  - Added indexes for performance
- Updated feed_parser.py:
  - Enhanced FeedEntry to extract thumbnail, description, published date
  - Added _extract_video_id() method for parsing YouTube URLs
  - Updated save_to_db() to require user_id parameter
  - Parse and store all metadata from RSS feeds
- Generated Alembic migration: a3c56d47f42a

Migration Instructions:
1. Stop all services: docker-compose down -v
2. Apply migrations: docker-compose up -d && docker-compose exec app alembic upgrade head
3. Or for local dev: rm yottob.db && alembic upgrade head

Next Steps (TODO):
- Configure Flask-Login in main.py
- Create login/register/logout routes
- Add @login_required decorators to protected routes
- Update all routes to filter by current_user
- Create auth templates (login.html, register.html)
- Update base.html with auth navigation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-26 14:24:47 -05:00
parent 9bcd439024
commit 403d65e4ea
5 changed files with 294 additions and 24 deletions

View File

@@ -0,0 +1,83 @@
"""Add user authentication and enhance video fields
Revision ID: a3c56d47f42a
Revises: 1b18a0e65b0d
Create Date: 2025-11-26 14:22:55.689811
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a3c56d47f42a'
down_revision: Union[str, Sequence[str], None] = '1b18a0e65b0d'
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('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(length=80), nullable=False),
sa.Column('email', sa.String(length=120), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
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_index('idx_user_channel', 'channels', ['user_id', 'channel_id'], unique=True)
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_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(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(op.f('ix_users_username'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###

View File

@@ -7,6 +7,7 @@ with filtering capabilities to exclude unwanted content like Shorts.
from datetime import datetime from datetime import datetime
import feedparser import feedparser
from typing import Dict, List, Optional from typing import Dict, List, Optional
import re
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -17,15 +18,25 @@ from models import Channel, VideoEntry
class FeedEntry: class FeedEntry:
"""Represents a single entry in a YouTube RSS feed.""" """Represents a single entry in a YouTube RSS feed."""
def __init__(self, title: str, link: str): def __init__(self, title: str, video_url: str, video_id: str,
published_at: datetime, thumbnail_url: Optional[str] = None,
description: Optional[str] = None):
self.title = title self.title = title
self.link = link self.video_url = video_url
self.video_id = video_id
self.published_at = published_at
self.thumbnail_url = thumbnail_url
self.description = description
def to_dict(self) -> Dict[str, str]: def to_dict(self) -> Dict:
"""Convert entry to dictionary.""" """Convert entry to dictionary."""
return { return {
"title": self.title, "title": self.title,
"link": self.link "video_url": self.video_url,
"video_id": self.video_id,
"published_at": self.published_at.isoformat(),
"thumbnail_url": self.thumbnail_url,
"description": self.description
} }
@@ -62,34 +73,81 @@ class YouTubeFeedParser:
if filter_shorts and "shorts" in entry.link: if filter_shorts and "shorts" in entry.link:
continue continue
# Extract video ID from URL
video_id = self._extract_video_id(entry.link)
if not video_id:
continue
# Get thumbnail URL (YouTube provides this in media:group)
thumbnail_url = None
if hasattr(entry, 'media_thumbnail') and entry.media_thumbnail:
thumbnail_url = entry.media_thumbnail[0]['url']
# Get description
description = None
if hasattr(entry, 'summary'):
description = entry.summary
# Parse published date
published_at = datetime(*entry.published_parsed[:6])
entries.append(FeedEntry( entries.append(FeedEntry(
title=entry.title, title=entry.title,
link=entry.link video_url=entry.link,
video_id=video_id,
published_at=published_at,
thumbnail_url=thumbnail_url,
description=description
)) ))
return { return {
"feed_title": feed.feed.title, "feed_title": feed.feed.title,
"feed_link": feed.feed.link, "feed_link": feed.feed.link,
"rss_url": self.url,
"entries": [entry.to_dict() for entry in entries] "entries": [entry.to_dict() for entry in entries]
} }
def save_to_db(self, db_session: Session, feed_data: Dict) -> Channel: @staticmethod
def _extract_video_id(url: str) -> Optional[str]:
"""Extract video ID from YouTube URL.
Args:
url: YouTube video URL
Returns:
Video ID or None if not found
"""
# Match patterns like: youtube.com/watch?v=VIDEO_ID
match = re.search(r'[?&]v=([a-zA-Z0-9_-]{11})', url)
if match:
return match.group(1)
# Match patterns like: youtu.be/VIDEO_ID
match = re.search(r'youtu\.be/([a-zA-Z0-9_-]{11})', url)
if match:
return match.group(1)
return None
def save_to_db(self, db_session: Session, feed_data: Dict, user_id: int) -> Channel:
"""Save feed data to the database. """Save feed data to the database.
Args: Args:
db_session: SQLAlchemy database session db_session: SQLAlchemy database session
feed_data: Dictionary containing feed metadata and entries (from fetch_feed) feed_data: Dictionary containing feed metadata and entries (from fetch_feed)
user_id: ID of the user subscribing to this channel
Returns: Returns:
The Channel model instance The Channel model instance
This method uses upsert logic: This method uses upsert logic:
- Updates existing channel if it exists - Updates existing channel if it exists for this user
- Creates new channel if it doesn't exist - Creates new channel if it doesn't exist
- Only inserts new video entries (ignores duplicates) - Only inserts new video entries (ignores duplicates based on video_id and channel_id)
""" """
# Get or create channel # Get or create channel for this user
channel = db_session.query(Channel).filter_by( channel = db_session.query(Channel).filter_by(
user_id=user_id,
channel_id=self.channel_id channel_id=self.channel_id
).first() ).first()
@@ -97,30 +155,43 @@ class YouTubeFeedParser:
# Update existing channel # Update existing channel
channel.title = feed_data["feed_title"] channel.title = feed_data["feed_title"]
channel.link = feed_data["feed_link"] channel.link = feed_data["feed_link"]
channel.last_fetched = datetime.utcnow() channel.rss_url = feed_data["rss_url"]
channel.last_fetched_at = datetime.utcnow()
else: else:
# Create new channel # Create new channel
channel = Channel( channel = Channel(
user_id=user_id,
channel_id=self.channel_id, channel_id=self.channel_id,
title=feed_data["feed_title"], title=feed_data["feed_title"],
link=feed_data["feed_link"], link=feed_data["feed_link"],
last_fetched=datetime.utcnow() rss_url=feed_data["rss_url"],
last_fetched_at=datetime.utcnow()
) )
db_session.add(channel) db_session.add(channel)
db_session.flush() # Get the channel ID db_session.flush() # Get the channel ID
# Add video entries (ignore duplicates) # Add video entries (ignore duplicates)
for entry_data in feed_data["entries"]: for entry_data in feed_data["entries"]:
# Check if video already exists # Check if video already exists for this channel
existing = db_session.query(VideoEntry).filter_by( existing = db_session.query(VideoEntry).filter_by(
link=entry_data["link"] channel_id=channel.id,
video_id=entry_data["video_id"]
).first() ).first()
if not existing: if not existing:
# Parse published_at if it's a string
published_at = entry_data["published_at"]
if isinstance(published_at, str):
published_at = datetime.fromisoformat(published_at.replace('Z', '+00:00'))
video = VideoEntry( video = VideoEntry(
channel_id=channel.id, channel_id=channel.id,
video_id=entry_data["video_id"],
title=entry_data["title"], title=entry_data["title"],
link=entry_data["link"], video_url=entry_data["video_url"],
thumbnail_url=entry_data.get("thumbnail_url"),
description=entry_data.get("description"),
published_at=published_at,
created_at=datetime.utcnow() created_at=datetime.utcnow()
) )
db_session.add(video) db_session.add(video)

View File

@@ -4,7 +4,9 @@ from datetime import datetime
from typing import List, Optional from typing import List, Optional
from enum import Enum as PyEnum from enum import Enum as PyEnum
from sqlalchemy import String, DateTime, ForeignKey, Index, Enum, BigInteger import bcrypt
from flask_login import UserMixin
from sqlalchemy import String, DateTime, ForeignKey, Index, Enum, BigInteger, Text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -21,19 +23,53 @@ class Base(DeclarativeBase):
pass pass
class User(Base, UserMixin):
"""User model for authentication."""
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(80), unique=True, nullable=False, index=True)
email: Mapped[str] = mapped_column(String(120), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
# Relationships
channels: Mapped[List["Channel"]] = relationship("Channel", back_populates="user", cascade="all, delete-orphan")
def set_password(self, password: str) -> None:
"""Hash and set the password."""
self.password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def check_password(self, password: str) -> bool:
"""Check if the provided password matches the hash."""
return bcrypt.checkpw(password.encode('utf-8'), self.password_hash.encode('utf-8'))
def __repr__(self) -> str:
return f"<User(id={self.id}, username='{self.username}', email='{self.email}')>"
class Channel(Base): class Channel(Base):
"""YouTube channel model.""" """YouTube channel model."""
__tablename__ = "channels" __tablename__ = "channels"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
channel_id: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
channel_id: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(200), nullable=False) title: Mapped[str] = mapped_column(String(200), nullable=False)
link: Mapped[str] = mapped_column(String(500), nullable=False) link: Mapped[str] = mapped_column(String(500), nullable=False)
last_fetched: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow) rss_url: Mapped[str] = mapped_column(String(500), nullable=False)
last_fetched_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# Relationship to video entries # Relationships
videos: Mapped[List["VideoEntry"]] = relationship("VideoEntry", back_populates="channel", cascade="all, delete-orphan") user: Mapped["User"] = relationship("User", back_populates="channels")
video_entries: Mapped[List["VideoEntry"]] = relationship("VideoEntry", back_populates="channel", cascade="all, delete-orphan")
# Composite unique constraint on user_id and channel_id (one user can't subscribe to same channel twice)
__table_args__ = (
Index('idx_user_channel', 'user_id', 'channel_id', unique=True),
)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Channel(id={self.id}, channel_id='{self.channel_id}', title='{self.title}')>" return f"<Channel(id={self.id}, channel_id='{self.channel_id}', title='{self.title}')>"
@@ -46,8 +82,12 @@ class VideoEntry(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
channel_id: Mapped[int] = mapped_column(ForeignKey("channels.id"), nullable=False) channel_id: Mapped[int] = mapped_column(ForeignKey("channels.id"), nullable=False)
video_id: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(500), nullable=False) title: Mapped[str] = mapped_column(String(500), nullable=False)
link: Mapped[str] = mapped_column(String(500), unique=True, nullable=False) video_url: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
thumbnail_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
published_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
# Download tracking fields # Download tracking fields
@@ -63,23 +103,29 @@ class VideoEntry(Base):
file_size: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True) file_size: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
# Relationship to channel # Relationship to channel
channel: Mapped["Channel"] = relationship("Channel", back_populates="videos") channel: Mapped["Channel"] = relationship("Channel", back_populates="video_entries")
# Index for faster queries # Indexes for faster queries
__table_args__ = ( __table_args__ = (
Index('idx_channel_created', 'channel_id', 'created_at'), Index('idx_channel_created', 'channel_id', 'created_at'),
Index('idx_download_status', 'download_status'), Index('idx_download_status', 'download_status'),
Index('idx_published_at', 'published_at'),
Index('idx_video_id_channel', 'video_id', 'channel_id', unique=True),
) )
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<VideoEntry(id={self.id}, title='{self.title}', link='{self.link}', status='{self.download_status.value}')>" return f"<VideoEntry(id={self.id}, title='{self.title}', video_url='{self.video_url}', status='{self.download_status.value}')>"
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Convert to dictionary for API responses.""" """Convert to dictionary for API responses."""
return { return {
"id": self.id, "id": self.id,
"video_id": self.video_id,
"title": self.title, "title": self.title,
"link": self.link, "video_url": self.video_url,
"thumbnail_url": self.thumbnail_url,
"description": self.description,
"published_at": self.published_at.isoformat(),
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"download_status": self.download_status.value, "download_status": self.download_status.value,
"download_path": self.download_path, "download_path": self.download_path,

View File

@@ -6,9 +6,11 @@ readme = "README.md"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
"alembic>=1.13.0", "alembic>=1.13.0",
"bcrypt>=4.0.0",
"celery>=5.3.0", "celery>=5.3.0",
"feedparser>=6.0.12", "feedparser>=6.0.12",
"flask>=3.1.2", "flask>=3.1.2",
"flask-login>=0.6.0",
"psycopg2-binary>=2.9.0", "psycopg2-binary>=2.9.0",
"redis>=5.0.0", "redis>=5.0.0",
"sqlalchemy>=2.0.0", "sqlalchemy>=2.0.0",

68
uv.lock generated
View File

@@ -28,6 +28,57 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" },
] ]
[[package]]
name = "bcrypt"
version = "5.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
{ url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
{ url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
{ url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
{ url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
{ url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
{ url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
{ url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
{ url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
{ url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
{ url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
{ url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
{ url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
{ url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
{ url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
]
[[package]] [[package]]
name = "billiard" name = "billiard"
version = "4.2.3" version = "4.2.3"
@@ -152,6 +203,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" },
] ]
[[package]]
name = "flask-login"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834, upload-time = "2023-10-30T14:53:21.151Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303, upload-time = "2023-10-30T14:53:19.636Z" },
]
[[package]] [[package]]
name = "greenlet" name = "greenlet"
version = "3.2.4" version = "3.2.4"
@@ -390,9 +454,11 @@ version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "alembic" }, { name = "alembic" },
{ name = "bcrypt" },
{ name = "celery" }, { name = "celery" },
{ name = "feedparser" }, { name = "feedparser" },
{ name = "flask" }, { name = "flask" },
{ name = "flask-login" },
{ name = "psycopg2-binary" }, { name = "psycopg2-binary" },
{ name = "redis" }, { name = "redis" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
@@ -402,9 +468,11 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "alembic", specifier = ">=1.13.0" }, { name = "alembic", specifier = ">=1.13.0" },
{ name = "bcrypt", specifier = ">=4.0.0" },
{ name = "celery", specifier = ">=5.3.0" }, { name = "celery", specifier = ">=5.3.0" },
{ name = "feedparser", specifier = ">=6.0.12" }, { name = "feedparser", specifier = ">=6.0.12" },
{ name = "flask", specifier = ">=3.1.2" }, { name = "flask", specifier = ">=3.1.2" },
{ name = "flask-login", specifier = ">=0.6.0" },
{ name = "psycopg2-binary", specifier = ">=2.9.0" }, { name = "psycopg2-binary", specifier = ">=2.9.0" },
{ name = "redis", specifier = ">=5.0.0" }, { name = "redis", specifier = ">=5.0.0" },
{ name = "sqlalchemy", specifier = ">=2.0.0" }, { name = "sqlalchemy", specifier = ">=2.0.0" },