rapid interations of server side app and firefox extension

This commit is contained in:
Bryan Van Deusen
2026-01-24 22:52:51 -05:00
commit b9b8048a2d
81 changed files with 9675 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
"""Database models and initialization."""
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.models.subscription import Subscription
from app.models.source import Source
from app.models.download import Download
from app.models.credential import Credential
from app.models.content import ContentItem
from app.models.setting import Setting
class Base(DeclarativeBase):
"""Base class for all models."""
pass
# Re-export models
__all__ = [
"Base",
"Subscription",
"Source",
"Download",
"Credential",
"ContentItem",
"Setting",
"init_db",
"get_session",
]
# Global engine and session factory
_engine = None
_session_factory = None
async def init_db(database_url: str):
"""Initialize database engine and create tables."""
global _engine, _session_factory
_engine = create_async_engine(database_url, echo=False)
_session_factory = async_sessionmaker(_engine, class_=AsyncSession, expire_on_commit=False)
# Import all models to register them with Base
from app.models.subscription import Subscription
from app.models.source import Source
from app.models.download import Download
from app.models.credential import Credential
from app.models.content import ContentItem
from app.models.setting import Setting
return _engine
async def get_session() -> AsyncSession:
"""Get a database session."""
if _session_factory is None:
raise RuntimeError("Database not initialized. Call init_db first.")
async with _session_factory() as session:
yield session
+17
View File
@@ -0,0 +1,17 @@
"""SQLAlchemy base model."""
from datetime import datetime
from sqlalchemy import func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
"""Base class for all models."""
pass
class TimestampMixin:
"""Mixin for created_at and updated_at timestamps."""
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
+83
View File
@@ -0,0 +1,83 @@
"""ContentItem model - discovered files/content."""
from datetime import datetime
from typing import Optional
from sqlalchemy import String, Integer, Text, ForeignKey, BigInteger, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class ContentType:
"""Content type constants."""
IMAGE = "image"
VIDEO = "video"
ATTACHMENT = "attachment"
AUDIO = "audio"
OTHER = "other"
class ContentItem(Base):
"""A content item represents a discovered/downloaded file."""
__tablename__ = "content_items"
__table_args__ = (
UniqueConstraint("source_id", "external_id", name="uq_content_source_external"),
)
id: Mapped[int] = mapped_column(primary_key=True)
source_id: Mapped[int] = mapped_column(
ForeignKey("sources.id", ondelete="CASCADE"), nullable=False, index=True
)
download_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("downloads.id", ondelete="SET NULL"), nullable=True
)
# Content identification
external_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
title: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
content_type: Mapped[str] = mapped_column(String(50), default=ContentType.OTHER)
# File information
file_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
file_size: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
thumbnail_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# Timestamps
published_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
discovered_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
# Flexible metadata
metadata_: Mapped[dict] = mapped_column("metadata", JSONB, default=dict)
# Relationships
source: Mapped["Source"] = relationship(back_populates="content_items")
download: Mapped[Optional["Download"]] = relationship(back_populates="content_items")
def __repr__(self) -> str:
return f"<ContentItem {self.id}: {self.title or 'untitled'}>"
def to_dict(self) -> dict:
"""Convert to dictionary for API responses."""
return {
"id": self.id,
"source_id": self.source_id,
"download_id": self.download_id,
"external_id": self.external_id,
"title": self.title,
"content_type": self.content_type,
"file_path": self.file_path,
"file_size": self.file_size,
"thumbnail_path": self.thumbnail_path,
"published_at": self.published_at.isoformat() if self.published_at else None,
"discovered_at": self.discovered_at.isoformat() if self.discovered_at else None,
"metadata": self.metadata_,
}
# Import for type hints
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.models.source import Source
from app.models.download import Download
+53
View File
@@ -0,0 +1,53 @@
"""Credential model - encrypted cookie/token storage."""
from datetime import datetime
from typing import Optional
from sqlalchemy import String, LargeBinary, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class CredentialType:
"""Credential type constants."""
COOKIES = "cookies"
TOKEN = "token"
class Credential(Base, TimestampMixin):
"""Encrypted credential storage for platform authentication."""
__tablename__ = "credentials"
__table_args__ = (UniqueConstraint("platform", name="uq_credential_platform"),)
id: Mapped[int] = mapped_column(primary_key=True)
platform: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
credential_type: Mapped[str] = mapped_column(String(20), nullable=False)
# Encrypted credential data
data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
# Status tracking
expires_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
last_verified: Mapped[Optional[datetime]] = mapped_column(nullable=True)
def __repr__(self) -> str:
return f"<Credential {self.platform} ({self.credential_type})>"
def to_dict(self, include_data: bool = False) -> dict:
"""Convert to dictionary for API responses.
By default, does NOT include the encrypted data for security.
"""
result = {
"id": self.id,
"platform": self.platform,
"credential_type": self.credential_type,
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
"last_verified": self.last_verified.isoformat() if self.last_verified else None,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
if include_data:
result["data"] = self.data
return result
+93
View File
@@ -0,0 +1,93 @@
"""Download model - individual download jobs."""
from datetime import datetime
from typing import Optional
from sqlalchemy import String, Integer, Text, ForeignKey, BigInteger
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class DownloadStatus:
"""Download status constants."""
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
SKIPPED = "skipped"
class ErrorType:
"""Error type constants matching legacy project."""
AUTH_ERROR = "auth_error"
RATE_LIMITED = "rate_limited"
NOT_FOUND = "not_found"
ACCESS_DENIED = "access_denied"
NETWORK_ERROR = "network_error"
NO_NEW_CONTENT = "no_new_content"
TIMEOUT = "timeout"
HTTP_ERROR = "http_error"
UNSUPPORTED_URL = "unsupported_url"
UNKNOWN_ERROR = "unknown_error"
class Download(Base, TimestampMixin):
"""A download represents a single download job for a source."""
__tablename__ = "downloads"
id: Mapped[int] = mapped_column(primary_key=True)
source_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("sources.id", ondelete="SET NULL"), nullable=True, index=True
)
url: Mapped[str] = mapped_column(Text, nullable=False)
# Status
status: Mapped[str] = mapped_column(
String(20), default=DownloadStatus.PENDING, index=True
)
error_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Results
file_count: Mapped[int] = mapped_column(Integer, default=0)
total_size: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
# Timing
started_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
completed_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
# Flexible metadata
metadata_: Mapped[dict] = mapped_column("metadata", JSONB, default=dict)
# Relationships
source: Mapped[Optional["Source"]] = relationship(back_populates="downloads")
content_items: Mapped[list["ContentItem"]] = relationship(back_populates="download")
def __repr__(self) -> str:
return f"<Download {self.id} ({self.status})>"
def to_dict(self) -> dict:
"""Convert to dictionary for API responses."""
return {
"id": self.id,
"source_id": self.source_id,
"url": self.url,
"status": self.status,
"error_type": self.error_type,
"error_message": self.error_message,
"file_count": self.file_count,
"total_size": self.total_size,
"started_at": self.started_at.isoformat() if self.started_at else None,
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
"metadata": self.metadata_,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
# Import for type hints
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.models.source import Source
from app.models.content import ContentItem
+49
View File
@@ -0,0 +1,49 @@
"""Setting model - key-value application settings."""
import secrets
from datetime import datetime
from sqlalchemy import String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
def generate_api_key() -> str:
"""Generate a secure random API key."""
return secrets.token_urlsafe(32)
class Setting(Base):
"""Key-value setting storage."""
__tablename__ = "settings"
key: Mapped[str] = mapped_column(String(100), primary_key=True)
value: Mapped[dict] = mapped_column(JSONB, nullable=False)
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
def __repr__(self) -> str:
return f"<Setting {self.key}>"
def to_dict(self) -> dict:
"""Convert to dictionary for API responses."""
return {
"key": self.key,
"value": self.value,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
# Default settings that should exist
DEFAULT_SETTINGS = {
"download.parallel_limit": 3,
"download.rate_limit": 3.0,
"download.retry_count": 3,
"download.schedule_interval": 3600,
"notification.enabled": False,
"notification.webhook_url": None,
}
# Key for the extension API key setting
EXTENSION_API_KEY_SETTING = "security.extension_api_key"
+75
View File
@@ -0,0 +1,75 @@
"""Source model - platform URLs for subscriptions."""
from datetime import datetime
from typing import Optional
from sqlalchemy import String, Boolean, Integer, Text, ForeignKey, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class Source(Base, TimestampMixin):
"""A source represents a platform URL for a subscription.
Sources belong to subscriptions - one subscription (artist) can have
multiple sources (Patreon, SubscribeStar, etc.).
"""
__tablename__ = "sources"
__table_args__ = (UniqueConstraint("platform", "url", name="uq_source_platform_url"),)
id: Mapped[int] = mapped_column(primary_key=True)
subscription_id: Mapped[int] = mapped_column(
ForeignKey("subscriptions.id", ondelete="CASCADE"),
nullable=False,
index=True
)
platform: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
url: Mapped[str] = mapped_column(Text, nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
check_interval: Mapped[int] = mapped_column(Integer, default=3600) # seconds
# Status tracking
last_check: Mapped[Optional[datetime]] = mapped_column(nullable=True)
last_success: Mapped[Optional[datetime]] = mapped_column(nullable=True)
error_count: Mapped[int] = mapped_column(Integer, default=0)
# Flexible metadata storage (per-source config overrides)
metadata_: Mapped[dict] = mapped_column("metadata", JSONB, default=dict)
# Relationships
subscription: Mapped["Subscription"] = relationship(back_populates="sources")
downloads: Mapped[list["Download"]] = relationship(back_populates="source")
content_items: Mapped[list["ContentItem"]] = relationship(back_populates="source")
def __repr__(self) -> str:
return f"<Source {self.subscription.name if self.subscription else '?'}/{self.platform}>"
def to_dict(self, include_subscription: bool = True) -> dict:
"""Convert to dictionary for API responses."""
result = {
"id": self.id,
"subscription_id": self.subscription_id,
"platform": self.platform,
"url": self.url,
"enabled": self.enabled,
"check_interval": self.check_interval,
"last_check": self.last_check.isoformat() if self.last_check else None,
"last_success": self.last_success.isoformat() if self.last_success else None,
"error_count": self.error_count,
"metadata": self.metadata_,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
if include_subscription and self.subscription:
result["subscription_name"] = self.subscription.name
return result
# Import for type hints (avoid circular imports)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.models.subscription import Subscription
from app.models.download import Download
from app.models.content import ContentItem
+72
View File
@@ -0,0 +1,72 @@
"""Subscription model - represents an artist/creator to track."""
from datetime import datetime
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, Boolean, Integer, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
if TYPE_CHECKING:
from app.models.source import Source
class Subscription(Base, TimestampMixin):
"""A subscription represents an artist/creator being tracked.
Each subscription can have multiple sources (platform URLs).
The subscription name is used for the directory structure:
/{download_path}/{subscription.name}/{source.platform}/{post}
"""
__tablename__ = "subscriptions"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
priority: Mapped[int] = mapped_column(Integer, default=0)
# Optional description/notes
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Flexible metadata storage (tags, custom settings, etc.)
metadata_: Mapped[dict] = mapped_column("metadata", JSONB, default=dict)
# Relationships
sources: Mapped[list["Source"]] = relationship(
back_populates="subscription",
cascade="all, delete-orphan",
lazy="selectin"
)
def __repr__(self) -> str:
return f"<Subscription {self.name}>"
@property
def platform_count(self) -> int:
"""Number of platforms/sources for this subscription."""
return len(self.sources) if self.sources else 0
@property
def enabled_source_count(self) -> int:
"""Number of enabled sources."""
return sum(1 for s in self.sources if s.enabled) if self.sources else 0
def to_dict(self, include_sources: bool = True) -> dict:
"""Convert to dictionary for API responses."""
result = {
"id": self.id,
"name": self.name,
"enabled": self.enabled,
"priority": self.priority,
"description": self.description,
"metadata": self.metadata_,
"platform_count": self.platform_count,
"enabled_source_count": self.enabled_source_count,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
if include_sources and self.sources:
result["sources"] = [s.to_dict(include_subscription=False) for s in self.sources]
return result