731 lines
20 KiB
Python
731 lines
20 KiB
Python
"""
|
|
Content models with async support and enhanced features
|
|
"""
|
|
import hashlib
|
|
import mimetypes
|
|
from datetime import datetime
|
|
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
|
|
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__ = 'stored_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('stored_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() |