Initial commit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Todd
2026-03-29 22:42:55 -04:00
commit 0d7b2b1aab
389 changed files with 280296 additions and 0 deletions

View File

@@ -0,0 +1,312 @@
"""
Pydantic models for Paid Content feature
"""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, List, Optional, Any
@dataclass
class Attachment:
"""Represents a file attachment from a post"""
name: str
server_path: str
file_type: Optional[str] = None
extension: Optional[str] = None
download_url: Optional[str] = None
file_size: Optional[int] = None
width: Optional[int] = None
height: Optional[int] = None
duration: Optional[int] = None
needs_quality_recheck: bool = False
is_preview: bool = False
@classmethod
def from_api(cls, data: Dict, base_url: str = '') -> 'Attachment':
"""Create Attachment from API response"""
name = data.get('name', '')
path = data.get('path', '')
# Detect file type from extension
ext = ''
if '.' in name:
ext = name.rsplit('.', 1)[-1].lower()
file_type = 'unknown'
image_exts = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tiff', 'heic'}
video_exts = {'mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'wmv', 'flv'}
archive_exts = {'zip', 'rar', '7z', 'tar', 'gz'}
if ext in image_exts:
file_type = 'image'
elif ext in video_exts:
file_type = 'video'
elif ext in archive_exts:
file_type = 'archive'
elif ext in {'pdf', 'doc', 'docx', 'txt'}:
file_type = 'document'
return cls(
name=name,
server_path=path,
file_type=file_type,
extension=ext if ext else None,
download_url=f"{base_url}/data{path}" if base_url and path else None
)
def to_dict(self) -> Dict:
"""Convert to dictionary for database storage"""
d = {
'name': self.name,
'server_path': self.server_path,
'file_type': self.file_type,
'extension': self.extension,
'download_url': self.download_url,
'file_size': self.file_size,
'width': self.width,
'height': self.height,
'duration': self.duration
}
if self.needs_quality_recheck:
d['needs_quality_recheck'] = 1
return d
@dataclass
class Post:
"""Represents a post from a creator"""
post_id: str
service_id: str
platform: str
creator_id: str
title: Optional[str] = None
content: Optional[str] = None
published_at: Optional[str] = None
added_at: Optional[str] = None
edited_at: Optional[str] = None
attachments: List[Attachment] = field(default_factory=list)
embed_urls: List[str] = field(default_factory=list)
is_pinned: bool = False
pinned_at: Optional[str] = None
auto_tags: List[str] = field(default_factory=list) # Tag names to auto-apply on sync
tagged_users: List[str] = field(default_factory=list) # Instagram users tagged in the post
@classmethod
def from_api(cls, data: Dict, service_id: str, platform: str, creator_id: str, base_url: str = '') -> 'Post':
"""Create Post from API response"""
# Parse attachments
attachments = []
for att_data in data.get('attachments', []):
attachments.append(Attachment.from_api(att_data, base_url))
# Also check file field (some APIs use this instead of attachments)
if 'file' in data and data['file']:
file_data = data['file']
if isinstance(file_data, dict):
attachments.append(Attachment.from_api(file_data, base_url))
elif isinstance(file_data, str):
attachments.append(Attachment(
name=file_data.split('/')[-1] if '/' in file_data else file_data,
server_path=file_data
))
# Parse dates
published = data.get('published')
added = data.get('added')
edited = data.get('edited')
# Content: use 'content' if available, fallback to 'substring' (list endpoint returns truncated)
content = data.get('content') or data.get('substring') or ''
# Single post endpoint returns HTML content (e.g. <p>text</p>), strip tags
if content and '<' in content:
import re
content = re.sub(r'<br\s*/?>', '\n', content)
content = re.sub(r'</p>\s*<p>', '\n\n', content)
content = re.sub(r'<[^>]+>', '', content)
content = content.strip()
title = data.get('title')
# OnlyFans posts on Coomer have the post text in 'title' and empty 'content'.
# Copy title to content and clear title (OF posts don't have real titles).
if not content and title:
content = title
title = None
return cls(
post_id=str(data.get('id', '')),
service_id=service_id,
platform=platform,
creator_id=creator_id,
title=title,
content=content,
published_at=published,
added_at=added,
edited_at=edited,
attachments=attachments,
embed_urls=data.get('embed', []) or []
)
def to_dict(self) -> Dict:
"""Convert to dictionary for database storage"""
return {
'post_id': self.post_id,
'title': self.title,
'content': self.content,
'published_at': self.published_at,
'added_at': self.added_at,
'edited_at': self.edited_at,
'has_attachments': 1 if self.attachments else 0,
'attachment_count': len(self.attachments),
'embed_count': len(self.embed_urls),
'is_pinned': 1 if self.is_pinned else 0,
'pinned_at': self.pinned_at
}
@dataclass
class Message:
"""Represents a chat message from/to a creator"""
message_id: str
platform: str
service_id: str
creator_id: str # Platform-specific creator ID
text: Optional[str] = None
sent_at: Optional[str] = None
is_from_creator: bool = True
is_tip: bool = False
tip_amount: Optional[float] = None
price: Optional[float] = None
is_free: bool = True
is_purchased: bool = False
reply_to_message_id: Optional[str] = None
attachments: List[Attachment] = field(default_factory=list)
def to_dict(self) -> Dict:
"""Convert to dictionary for database storage"""
return {
'message_id': self.message_id,
'text': self.text,
'sent_at': self.sent_at,
'is_from_creator': 1 if self.is_from_creator else 0,
'is_tip': 1 if self.is_tip else 0,
'tip_amount': self.tip_amount,
'price': self.price,
'is_free': 1 if self.is_free else 0,
'is_purchased': 1 if self.is_purchased else 0,
'has_attachments': 1 if self.attachments else 0,
'attachment_count': len(self.attachments),
'reply_to_message_id': self.reply_to_message_id,
}
@dataclass
class Creator:
"""Represents a creator from Coomer/Kemono"""
creator_id: str
service_id: str
platform: str
username: str
display_name: Optional[str] = None
profile_image_url: Optional[str] = None
banner_image_url: Optional[str] = None
bio: Optional[str] = None
post_count: int = 0
@classmethod
def from_api(cls, data: Dict, service_id: str, platform: str, base_url: str = None) -> 'Creator':
"""Create Creator from API response"""
creator_id = str(data.get('id', ''))
# Construct image domain - use .st instead of .party (coomer.party redirects to coomer.st)
img_domain = None
if base_url and creator_id:
from urllib.parse import urlparse
parsed = urlparse(base_url)
# Convert .party to .st for image URLs (coomer.party/kemono.party images are at .st)
netloc = parsed.netloc.replace('.party', '.st')
img_domain = f"img.{netloc}"
# Construct profile image URL from icon endpoint
profile_image_url = data.get('profile_image')
if not profile_image_url and img_domain:
# Icon URLs are at img.{domain}/icons/{platform}/{creator_id}
profile_image_url = f"https://{img_domain}/icons/{platform}/{creator_id}"
# Construct banner image URL
banner_image_url = data.get('banner_image')
if not banner_image_url and img_domain:
# Banner URLs are at img.{domain}/banners/{platform}/{creator_id}
banner_image_url = f"https://{img_domain}/banners/{platform}/{creator_id}"
return cls(
creator_id=creator_id,
service_id=service_id,
platform=platform,
username=data.get('name', ''),
display_name=data.get('name'),
profile_image_url=profile_image_url,
banner_image_url=banner_image_url,
post_count=data.get('post_count', 0)
)
def to_dict(self) -> Dict:
"""Convert to dictionary for database storage"""
return {
'service_id': self.service_id,
'platform': self.platform,
'creator_id': self.creator_id,
'username': self.username,
'display_name': self.display_name,
'profile_image_url': self.profile_image_url,
'banner_image_url': self.banner_image_url,
'bio': self.bio,
'post_count': self.post_count
}
@dataclass
class SyncResult:
"""Result of a creator sync operation"""
success: bool
new_posts: int = 0
new_attachments: int = 0
downloaded_files: int = 0
failed_files: int = 0
skipped_files: int = 0
error: Optional[str] = None
downloaded_file_info: Optional[List[Dict]] = None # List of {file_path, filename, source, content_type}
def to_dict(self) -> Dict:
return {
'success': self.success,
'new_posts': self.new_posts,
'new_attachments': self.new_attachments,
'downloaded_files': self.downloaded_files,
'failed_files': self.failed_files,
'skipped_files': self.skipped_files,
'error': self.error
}
@dataclass
class DownloadResult:
"""Result of a download operation"""
success: bool
file_path: Optional[str] = None
file_hash: Optional[str] = None
file_size: Optional[int] = None
error: Optional[str] = None
is_duplicate: bool = False
def to_dict(self) -> Dict:
return {
'success': self.success,
'file_path': self.file_path,
'file_hash': self.file_hash,
'file_size': self.file_size,
'error': self.error,
'is_duplicate': self.is_duplicate
}