""" Content models with async support and enhanced features """ import hashlib import mimetypes from datetime import datetime, timedelta from enum import Enum from pathlib import Path from typing import Optional, List, Dict, Any, Union from urllib.parse import urljoin from sqlalchemy import Column, String, Integer, BigInteger, Boolean, Text, ForeignKey, Index, text, DateTime from sqlalchemy.dialects.postgresql import JSONB, ARRAY from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import relationship import structlog from app.core.models.base import BaseModel from app.core.config import settings, PROJECT_HOST logger = structlog.get_logger(__name__) class ContentType(str, Enum): """Content type enumeration""" AUDIO = "audio" VIDEO = "video" IMAGE = "image" TEXT = "text" DOCUMENT = "document" UNKNOWN = "unknown" class ContentStatus(str, Enum): """Content status enumeration""" UPLOADING = "uploading" PROCESSING = "processing" READY = "ready" FAILED = "failed" DISABLED = "disabled" DELETED = "deleted" class StorageType(str, Enum): """Storage type enumeration""" LOCAL = "local" ONCHAIN = "onchain" IPFS = "ipfs" HYBRID = "hybrid" class LicenseType(str, Enum): """License type enumeration""" LISTEN = "listen" USE = "use" RESALE = "resale" EXCLUSIVE = "exclusive" class StoredContent(BaseModel): """Enhanced content storage model""" __tablename__ = 'my_network_content' # Content identification hash = Column( String(128), nullable=False, unique=True, index=True, comment="Content hash (SHA-256 or custom)" ) content_id = Column( String(256), nullable=True, index=True, comment="Content identifier (CID for IPFS)" ) # File information filename = Column( String(512), nullable=False, comment="Original filename" ) file_size = Column( BigInteger, nullable=False, default=0, comment="File size in bytes" ) mime_type = Column( String(128), nullable=True, comment="MIME type of the content" ) # Content type and storage content_type = Column( String(32), nullable=False, default=ContentType.UNKNOWN.value, index=True, comment="Content type category" ) storage_type = Column( String(32), nullable=False, default=StorageType.LOCAL.value, index=True, comment="Storage type" ) # File path and URLs file_path = Column( String(1024), nullable=True, comment="Local file path" ) external_url = Column( String(2048), nullable=True, comment="External URL for remote content" ) # Blockchain related onchain_index = Column( Integer, nullable=True, index=True, comment="On-chain index number" ) owner_address = Column( String(256), nullable=True, index=True, comment="Blockchain owner address" ) # User and access user_id = Column( String(36), # UUID ForeignKey('users.id'), nullable=True, index=True, comment="User who uploaded the content" ) # Encryption and security encrypted = Column( Boolean, nullable=False, default=False, comment="Whether content is encrypted" ) encryption_key_id = Column( String(36), # UUID ForeignKey('encryption_keys.id'), nullable=True, comment="Encryption key reference" ) # Processing status disabled = Column( Boolean, nullable=False, default=False, index=True, comment="Whether content is disabled" ) # Content metadata title = Column( String(512), nullable=True, comment="Content title" ) description = Column( Text, nullable=True, comment="Content description" ) tags = Column( ARRAY(String), nullable=False, default=list, comment="Content tags" ) # Media-specific metadata duration = Column( Integer, nullable=True, comment="Duration in seconds (for audio/video)" ) width = Column( Integer, nullable=True, comment="Width in pixels (for images/video)" ) height = Column( Integer, nullable=True, comment="Height in pixels (for images/video)" ) bitrate = Column( Integer, nullable=True, comment="Bitrate (for audio/video)" ) # Conversion and processing processing_status = Column( String(32), nullable=False, default=ContentStatus.READY.value, index=True, comment="Processing status" ) conversion_data = Column( JSONB, nullable=False, default=dict, comment="Conversion and processing data" ) # Statistics download_count = Column( Integer, nullable=False, default=0, comment="Number of downloads" ) view_count = Column( Integer, nullable=False, default=0, comment="Number of views" ) # Relationships user = relationship('User', back_populates='content_items') encryption_key = relationship('EncryptionKey', back_populates='content_items') user_contents = relationship('UserContent', back_populates='content') user_actions = relationship('UserAction', back_populates='content') # Indexes for performance __table_args__ = ( Index('idx_content_hash', 'hash'), Index('idx_content_user_type', 'user_id', 'content_type'), Index('idx_content_storage_status', 'storage_type', 'status'), Index('idx_content_onchain', 'onchain_index'), Index('idx_content_created', 'created_at'), Index('idx_content_disabled', 'disabled'), ) def __str__(self) -> str: """String representation""" return f"StoredContent({self.id}, hash={self.hash[:8]}..., filename={self.filename})" @property def file_extension(self) -> str: """Get file extension""" return Path(self.filename).suffix.lower() @property def web_url(self) -> str: """Get web accessible URL""" if self.external_url: return self.external_url if self.hash: return urljoin(str(PROJECT_HOST), f"/api/v1.5/storage/{self.hash}") return "" @property def download_url(self) -> str: """Get download URL""" if self.hash: return urljoin(str(PROJECT_HOST), f"/api/v1/storage/{self.hash}") return "" @property def is_media(self) -> bool: """Check if content is media (audio/video/image)""" return self.content_type in [ContentType.AUDIO, ContentType.VIDEO, ContentType.IMAGE] @property def is_processed(self) -> bool: """Check if content is fully processed""" return self.processing_status == ContentStatus.READY.value @property def cache_key(self) -> str: """Override cache key to use hash""" return f"content:hash:{self.hash}" def detect_content_type(self) -> ContentType: """Detect content type from MIME type""" if not self.mime_type: # Try to guess from extension mime_type, _ = mimetypes.guess_type(self.filename) self.mime_type = mime_type if self.mime_type: if self.mime_type.startswith('audio/'): return ContentType.AUDIO elif self.mime_type.startswith('video/'): return ContentType.VIDEO elif self.mime_type.startswith('image/'): return ContentType.IMAGE elif self.mime_type.startswith('text/'): return ContentType.TEXT elif 'application/' in self.mime_type: return ContentType.DOCUMENT return ContentType.UNKNOWN def calculate_hash(self, file_data: bytes) -> str: """Calculate hash for file data""" return hashlib.sha256(file_data).hexdigest() def set_conversion_data(self, key: str, value: Any) -> None: """Set conversion data""" if not self.conversion_data: self.conversion_data = {} self.conversion_data[key] = value def get_conversion_data(self, key: str, default: Any = None) -> Any: """Get conversion data""" if not self.conversion_data: return default return self.conversion_data.get(key, default) def add_tag(self, tag: str) -> None: """Add tag to content""" if not self.tags: self.tags = [] tag = tag.strip().lower() if tag and tag not in self.tags: self.tags.append(tag) def remove_tag(self, tag: str) -> None: """Remove tag from content""" if self.tags: tag = tag.strip().lower() if tag in self.tags: self.tags.remove(tag) def increment_download_count(self) -> None: """Increment download counter""" self.download_count += 1 def increment_view_count(self) -> None: """Increment view counter""" self.view_count += 1 @classmethod async def get_by_hash( cls, session: AsyncSession, content_hash: str ) -> Optional['StoredContent']: """Get content by hash""" try: stmt = select(cls).where(cls.hash == content_hash) result = await session.execute(stmt) return result.scalar_one_or_none() except Exception as e: logger.error("Error getting content by hash", hash=content_hash, error=str(e)) return None @classmethod async def get_by_user( cls, session: AsyncSession, user_id: str, content_type: Optional[ContentType] = None, limit: Optional[int] = None, offset: Optional[int] = None ) -> List['StoredContent']: """Get content by user""" try: stmt = select(cls).where(cls.user_id == user_id) if content_type: stmt = stmt.where(cls.content_type == content_type.value) stmt = stmt.order_by(cls.created_at.desc()) if offset: stmt = stmt.offset(offset) if limit: stmt = stmt.limit(limit) result = await session.execute(stmt) return result.scalars().all() except Exception as e: logger.error("Error getting content by user", user_id=user_id, error=str(e)) return [] @classmethod async def get_recent( cls, session: AsyncSession, days: int = 7, content_type: Optional[ContentType] = None, limit: Optional[int] = None ) -> List['StoredContent']: """Get recent content""" try: cutoff_date = datetime.utcnow() - timedelta(days=days) stmt = select(cls).where( cls.created_at >= cutoff_date, cls.disabled == False, cls.processing_status == ContentStatus.READY.value ) if content_type: stmt = stmt.where(cls.content_type == content_type.value) stmt = stmt.order_by(cls.created_at.desc()) if limit: stmt = stmt.limit(limit) result = await session.execute(stmt) return result.scalars().all() except Exception as e: logger.error("Error getting recent content", days=days, error=str(e)) return [] @classmethod async def search( cls, session: AsyncSession, query: str, content_type: Optional[ContentType] = None, limit: Optional[int] = None, offset: Optional[int] = None ) -> List['StoredContent']: """Search content by title and description""" try: search_pattern = f"%{query.lower()}%" stmt = select(cls).where( (cls.title.ilike(search_pattern)) | (cls.description.ilike(search_pattern)) | (cls.filename.ilike(search_pattern)), cls.disabled == False, cls.processing_status == ContentStatus.READY.value ) if content_type: stmt = stmt.where(cls.content_type == content_type.value) stmt = stmt.order_by(cls.created_at.desc()) if offset: stmt = stmt.offset(offset) if limit: stmt = stmt.limit(limit) result = await session.execute(stmt) return result.scalars().all() except Exception as e: logger.error("Error searching content", query=query, error=str(e)) return [] def to_dict(self) -> Dict[str, Any]: """Convert to dictionary with additional computed fields""" data = super().to_dict() data.update({ 'web_url': self.web_url, 'download_url': self.download_url, 'file_extension': self.file_extension, 'is_media': self.is_media, 'is_processed': self.is_processed }) return data class UserContent(BaseModel): """User content ownership and licensing""" __tablename__ = 'user_content' # Content relationship content_id = Column( String(36), # UUID ForeignKey('my_network_content.id'), nullable=False, index=True, comment="Reference to stored content" ) user_id = Column( String(36), # UUID ForeignKey('users.id'), nullable=False, index=True, comment="User who owns this content" ) # License information license_type = Column( String(32), nullable=False, default=LicenseType.LISTEN.value, comment="Type of license" ) # Blockchain data onchain_address = Column( String(256), nullable=True, index=True, comment="On-chain contract address" ) owner_address = Column( String(256), nullable=True, index=True, comment="Blockchain owner address" ) # Transaction data purchase_transaction = Column( String(128), nullable=True, comment="Purchase transaction hash" ) purchase_amount = Column( BigInteger, nullable=True, comment="Purchase amount in minimal units" ) # Wallet connection wallet_connection_id = Column( String(36), # UUID ForeignKey('wallet_connections.id'), nullable=True, comment="Wallet connection used for purchase" ) # Access control access_granted = Column( Boolean, nullable=False, default=False, comment="Whether access is granted" ) access_expires_at = Column( DateTime, nullable=True, comment="When access expires (for temporary licenses)" ) # Usage tracking download_count = Column( Integer, nullable=False, default=0, comment="Number of downloads by this user" ) last_accessed = Column( DateTime, nullable=True, comment="Last access timestamp" ) # Relationships user = relationship('User', back_populates='content_items') content = relationship('StoredContent', back_populates='user_contents') wallet_connection = relationship('WalletConnection', back_populates='user_contents') # Indexes __table_args__ = ( Index('idx_user_content_user', 'user_id'), Index('idx_user_content_content', 'content_id'), Index('idx_user_content_onchain', 'onchain_address'), Index('idx_user_content_owner', 'owner_address'), Index('idx_user_content_status', 'status'), ) def __str__(self) -> str: """String representation""" return f"UserContent({self.id}, user={self.user_id}, content={self.content_id})" @property def is_expired(self) -> bool: """Check if access has expired""" if not self.access_expires_at: return False return datetime.utcnow() > self.access_expires_at @property def is_accessible(self) -> bool: """Check if content is accessible""" return self.access_granted and not self.is_expired and self.status == 'active' def grant_access(self, expires_at: Optional[datetime] = None) -> None: """Grant access to content""" self.access_granted = True self.access_expires_at = expires_at self.last_accessed = datetime.utcnow() def revoke_access(self) -> None: """Revoke access to content""" self.access_granted = False def record_download(self) -> None: """Record a download""" self.download_count += 1 self.last_accessed = datetime.utcnow() @classmethod async def get_user_access( cls, session: AsyncSession, user_id: str, content_id: str ) -> Optional['UserContent']: """Get user access to specific content""" try: stmt = select(cls).where( cls.user_id == user_id, cls.content_id == content_id, cls.status == 'active' ) result = await session.execute(stmt) return result.scalar_one_or_none() except Exception as e: logger.error("Error getting user access", user_id=user_id, content_id=content_id, error=str(e)) return None @classmethod async def get_user_content( cls, session: AsyncSession, user_id: str, limit: Optional[int] = None, offset: Optional[int] = None ) -> List['UserContent']: """Get all content accessible by user""" try: stmt = select(cls).where( cls.user_id == user_id, cls.status == 'active', cls.access_granted == True ).order_by(cls.created_at.desc()) if offset: stmt = stmt.offset(offset) if limit: stmt = stmt.limit(limit) result = await session.execute(stmt) return result.scalars().all() except Exception as e: logger.error("Error getting user content", user_id=user_id, error=str(e)) return [] class EncryptionKey(BaseModel): """Encryption key management""" __tablename__ = 'encryption_keys' # Key identification key_hash = Column( String(128), nullable=False, unique=True, index=True, comment="Hash of the encryption key" ) algorithm = Column( String(32), nullable=False, default="AES-256-GCM", comment="Encryption algorithm used" ) # Key metadata purpose = Column( String(64), nullable=False, comment="Purpose of the key (content, user_data, etc.)" ) # Access control owner_id = Column( String(36), # UUID ForeignKey('users.id'), nullable=True, comment="Key owner (if user-specific)" ) # Key lifecycle expires_at = Column( DateTime, nullable=True, comment="Key expiration timestamp" ) revoked_at = Column( DateTime, nullable=True, comment="Key revocation timestamp" ) # Relationships owner = relationship('User', back_populates='encryption_keys') content_items = relationship('StoredContent', back_populates='encryption_key') def __str__(self) -> str: """String representation""" return f"EncryptionKey({self.id}, hash={self.key_hash[:8]}...)" @property def is_valid(self) -> bool: """Check if key is valid (not expired or revoked)""" now = datetime.utcnow() if self.revoked_at and self.revoked_at <= now: return False if self.expires_at and self.expires_at <= now: return False return True def revoke(self) -> None: """Revoke the key""" self.revoked_at = datetime.utcnow() # Backward compatibility aliases Content = StoredContent class ContentChunk(BaseModel): """Content chunk for large file uploads""" __tablename__ = 'content_chunks' # Chunk identification content_id = Column( String(36), # UUID ForeignKey('my_network_content.id'), nullable=False, index=True, comment="Parent content ID" ) chunk_index = Column( Integer, nullable=False, comment="Chunk sequence number" ) chunk_hash = Column( String(128), nullable=False, index=True, comment="Hash of this chunk" ) # Chunk data chunk_size = Column( Integer, nullable=False, comment="Size of this chunk in bytes" ) chunk_data = Column( Text, nullable=True, comment="Base64 encoded chunk data (for small chunks)" ) file_path = Column( String(1024), nullable=True, comment="Path to chunk file (for large chunks)" ) # Upload status uploaded = Column( Boolean, nullable=False, default=False, comment="Whether chunk is uploaded" ) # Relationships content = relationship('StoredContent', back_populates='chunks') def __str__(self) -> str: return f"ContentChunk({self.id}, content={self.content_id}, index={self.chunk_index})" class FileUpload(BaseModel): """File upload session tracking""" __tablename__ = 'file_uploads' # Upload identification upload_id = Column( String(128), nullable=False, unique=True, index=True, comment="Unique upload session ID" ) filename = Column( String(512), nullable=False, comment="Original filename" ) # Upload metadata total_size = Column( BigInteger, nullable=False, comment="Total file size in bytes" ) uploaded_size = Column( BigInteger, nullable=False, default=0, comment="Uploaded size in bytes" ) chunk_size = Column( Integer, nullable=False, default=1048576, # 1MB comment="Chunk size in bytes" ) total_chunks = Column( Integer, nullable=False, comment="Total number of chunks" ) uploaded_chunks = Column( Integer, nullable=False, default=0, comment="Number of uploaded chunks" ) # Upload status upload_status = Column( String(32), nullable=False, default='pending', comment="Upload status" ) # User information user_id = Column( String(36), # UUID ForeignKey('users.id'), nullable=True, index=True, comment="User performing the upload" ) # Completion content_id = Column( String(36), # UUID ForeignKey('my_network_content.id'), nullable=True, comment="Final content ID after completion" ) # Relationships user = relationship('User', back_populates='file_uploads') content = relationship('StoredContent', back_populates='file_upload') def __str__(self) -> str: return f"FileUpload({self.id}, upload_id={self.upload_id}, status={self.upload_status})" @property def progress_percentage(self) -> float: """Get upload progress percentage""" if self.total_size == 0: return 0.0 return (self.uploaded_size / self.total_size) * 100.0 @property def is_complete(self) -> bool: """Check if upload is complete""" return self.uploaded_size >= self.total_size and self.upload_status == 'completed' def update_progress(self, chunk_size: int) -> None: """Update upload progress""" self.uploaded_size += chunk_size self.uploaded_chunks += 1 if self.uploaded_size >= self.total_size: self.upload_status = 'completed' elif self.upload_status == 'pending': self.upload_status = 'uploading' # Update relationships in StoredContent StoredContent.chunks = relationship('ContentChunk', back_populates='content') StoredContent.file_upload = relationship('FileUpload', back_populates='content', uselist=False)