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:
@@ -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 ###
|
||||||
@@ -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)
|
||||||
|
|||||||
66
models.py
66
models.py
@@ -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,
|
||||||
|
|||||||
@@ -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
68
uv.lock
generated
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user