diff --git a/config/migrate_add_media_items.sql b/config/migrate_add_media_items.sql
new file mode 100644
index 0000000..3616ac3
--- /dev/null
+++ b/config/migrate_add_media_items.sql
@@ -0,0 +1,27 @@
+-- Migration: Add multi-media support to generated_posts
+-- Date: 2026-02-11
+-- Description: Adds media_items JSONB array to support up to 3 images or 1 video per post
+
+-- Add media_items column
+ALTER TABLE generated_posts ADD COLUMN IF NOT EXISTS media_items JSONB DEFAULT '[]'::JSONB;
+
+-- Add GIN index for performance on JSONB queries
+CREATE INDEX IF NOT EXISTS idx_generated_posts_media_items ON generated_posts USING GIN (media_items);
+
+-- Migrate existing image_url to media_items array
+UPDATE generated_posts
+SET media_items = jsonb_build_array(
+ jsonb_build_object(
+ 'type', 'image',
+ 'url', image_url,
+ 'order', 0,
+ 'content_type', 'image/jpeg',
+ 'uploaded_at', NOW()
+ )
+)
+WHERE image_url IS NOT NULL AND media_items = '[]'::JSONB;
+
+-- Note: image_url column is kept for backward compatibility
+-- New code should use media_items, but existing code still works
+
+COMMENT ON COLUMN generated_posts.media_items IS 'JSONB array of media items (images/videos). Max 3 items. Structure: [{type, url, order, content_type, uploaded_at, metadata}]';
diff --git a/src/database/models.py b/src/database/models.py
index a390e7c..2df83a1 100644
--- a/src/database/models.py
+++ b/src/database/models.py
@@ -1,5 +1,5 @@
"""Pydantic models for database entities."""
-from datetime import datetime, date
+from datetime import datetime, date, timezone
from enum import Enum
from typing import Optional, Dict, Any, List
from uuid import UUID
@@ -312,6 +312,16 @@ class ApiUsageLog(DBModel):
created_at: Optional[datetime] = None
+class MediaItem(BaseModel):
+ """Single media item (image or video) for a post."""
+ type: str # "image" | "video"
+ url: str
+ order: int
+ content_type: str # MIME type (e.g., 'image/jpeg', 'video/mp4')
+ uploaded_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+ metadata: Optional[Dict[str, Any]] = None
+
+
class GeneratedPost(DBModel):
"""Generated post model."""
id: Optional[UUID] = None
@@ -327,7 +337,9 @@ class GeneratedPost(DBModel):
approved_at: Optional[datetime] = None
published_at: Optional[datetime] = None
post_type_id: Optional[UUID] = None
- # Image
+ # Media (multi-media support)
+ media_items: List[MediaItem] = Field(default_factory=list)
+ # DEPRECATED: Image (kept for backward compatibility)
image_url: Optional[str] = None
# Scheduling fields
scheduled_at: Optional[datetime] = None
@@ -335,6 +347,11 @@ class GeneratedPost(DBModel):
# Metadata for additional info (e.g., LinkedIn post URL, auto-posting status)
metadata: Optional[Dict[str, Any]] = None
+ @property
+ def has_media(self) -> bool:
+ """Check if post has any media items."""
+ return len(self.media_items) > 0
+
# ==================== LICENSE KEY MODELS ====================
diff --git a/src/services/linkedin_service.py b/src/services/linkedin_service.py
index 4247d76..7a3acb5 100644
--- a/src/services/linkedin_service.py
+++ b/src/services/linkedin_service.py
@@ -35,7 +35,8 @@ class LinkedInService:
self,
linkedin_account_id: UUID,
text: str,
- image_url: Optional[str] = None
+ image_url: Optional[str] = None,
+ media_items: Optional[list] = None
) -> Dict:
"""
Post content to LinkedIn using UGC Posts API.
@@ -43,7 +44,9 @@ class LinkedInService:
Args:
linkedin_account_id: ID of the linkedin_accounts record
text: Post text content
- image_url: Optional image URL from Supabase storage
+ image_url: (DEPRECATED) Optional single image URL - use media_items instead
+ media_items: Optional list of media items (dicts with 'type', 'url', 'order')
+ Supports up to 3 images (carousel) or 1 video
Returns:
Dict with 'url' key containing the LinkedIn post URL
@@ -80,17 +83,53 @@ class LinkedInService:
}
}
- # Handle image upload if provided
- if image_url:
+ # Handle media upload (new multi-media support)
+ if media_items and len(media_items) > 0:
+ try:
+ media_type = media_items[0].get("type", "image") # All items have same type
+
+ if media_type == "video":
+ # Single video upload
+ video_urn = await self._upload_video(
+ access_token,
+ linkedin_user_id,
+ media_items[0].get("url")
+ )
+ if video_urn:
+ payload["specificContent"]["com.linkedin.ugc.ShareContent"]["shareMediaCategory"] = "VIDEO"
+ payload["specificContent"]["com.linkedin.ugc.ShareContent"]["media"] = [
+ {"status": "READY", "media": video_urn}
+ ]
+
+ elif media_type == "image":
+ # Upload all images (Carousel if > 1)
+ media_urns = []
+ for item in sorted(media_items, key=lambda x: x.get("order", 0)):
+ urn = await self._upload_image(
+ access_token,
+ linkedin_user_id,
+ item.get("url")
+ )
+ if urn:
+ media_urns.append(urn)
+
+ if media_urns:
+ payload["specificContent"]["com.linkedin.ugc.ShareContent"]["shareMediaCategory"] = "IMAGE"
+ payload["specificContent"]["com.linkedin.ugc.ShareContent"]["media"] = [
+ {"status": "READY", "media": urn} for urn in media_urns
+ ]
+
+ except Exception as e:
+ logger.warning(f"Media upload failed for {linkedin_account_id}: {e}. Posting without media.")
+
+ # Backward compatibility: single image_url
+ elif image_url:
try:
media_urn = await self._upload_image(access_token, linkedin_user_id, image_url)
if media_urn:
payload["specificContent"]["com.linkedin.ugc.ShareContent"]["shareMediaCategory"] = "IMAGE"
payload["specificContent"]["com.linkedin.ugc.ShareContent"]["media"] = [
- {
- "status": "READY",
- "media": media_urn
- }
+ {"status": "READY", "media": media_urn}
]
except Exception as e:
logger.warning(f"Image upload failed for {linkedin_account_id}: {e}. Posting without image.")
@@ -213,6 +252,79 @@ class LinkedInService:
logger.error(f"Image upload error: {e}")
return None
+ async def _upload_video(
+ self,
+ access_token: str,
+ linkedin_user_id: str,
+ video_url: str
+ ) -> Optional[str]:
+ """
+ Upload video to LinkedIn for use in post.
+
+ Returns media URN if successful, None otherwise.
+ """
+ try:
+ # Step 1: Register upload with VIDEO recipe
+ register_payload = {
+ "registerUploadRequest": {
+ "recipes": ["urn:li:digitalmediaRecipe:feedshare-video"], # VIDEO recipe
+ "owner": f"urn:li:person:{linkedin_user_id}",
+ "serviceRelationships": [
+ {
+ "relationshipType": "OWNER",
+ "identifier": "urn:li:userGeneratedContent"
+ }
+ ]
+ }
+ }
+
+ async with httpx.AsyncClient(timeout=60.0) as client: # Longer timeout for videos
+ register_response = await client.post(
+ f"{self.BASE_URL}/assets?action=registerUpload",
+ headers={
+ "Authorization": f"Bearer {access_token}",
+ "Content-Type": "application/json"
+ },
+ json=register_payload
+ )
+
+ if register_response.status_code != 200:
+ logger.error(f"Video register failed: {register_response.status_code}")
+ return None
+
+ register_data = register_response.json()
+ upload_url = register_data["value"]["uploadMechanism"]["com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"]["uploadUrl"]
+ asset_urn = register_data["value"]["asset"]
+
+ # Step 2: Download video from Supabase
+ video_response = await client.get(video_url)
+ if video_response.status_code != 200:
+ logger.error(f"Failed to download video from {video_url}")
+ return None
+
+ video_data = video_response.content
+
+ # Step 3: Upload to LinkedIn
+ upload_response = await client.put(
+ upload_url,
+ headers={
+ "Authorization": f"Bearer {access_token}",
+ "Content-Type": "application/octet-stream"
+ },
+ content=video_data
+ )
+
+ if upload_response.status_code in [200, 201]:
+ logger.info(f"Video uploaded successfully: {asset_urn}")
+ return asset_urn
+ else:
+ logger.error(f"Video upload failed: {upload_response.status_code}")
+ return None
+
+ except Exception as e:
+ logger.error(f"Video upload error: {e}")
+ return None
+
async def refresh_access_token(self, linkedin_account_id: UUID) -> bool:
"""
Attempt to refresh the access token using refresh token.
diff --git a/src/services/scheduler_service.py b/src/services/scheduler_service.py
index f737e01..013346e 100644
--- a/src/services/scheduler_service.py
+++ b/src/services/scheduler_service.py
@@ -111,11 +111,20 @@ class SchedulerService:
# Token refresh failed -> Fall back to email
raise Exception("Token expired and refresh failed")
- # Post to LinkedIn
+ # Post to LinkedIn with media items
+ # Convert media_items to dict if needed
+ media_items_list = None
+ if post.media_items:
+ media_items_list = [
+ item.dict() if hasattr(item, 'dict') else item
+ for item in post.media_items
+ ]
+
result = await linkedin_service.post_to_linkedin(
linkedin_account_id=linkedin_account.id,
text=post.post_content,
- image_url=post.image_url
+ media_items=media_items_list,
+ image_url=post.image_url # Backward compatibility fallback
)
# Update post as published with LinkedIn metadata
@@ -178,6 +187,19 @@ class SchedulerService:
display_name = profile.display_name or "dort"
+ # Generate media HTML
+ media_html = ""
+ if post.media_items and len(post.media_items) > 0:
+ media_items = [item.dict() if hasattr(item, 'dict') else item for item in post.media_items]
+ for item in sorted(media_items, key=lambda x: x.get('order', 0)):
+ if item.get('type') == 'image':
+ media_html += f''
+ elif item.get('type') == 'video':
+ media_html += f''
+ elif post.image_url:
+ # Backward compatibility
+ media_html = f'
'
+
html_content = f"""
Nächste Schritte:
@@ -229,6 +251,19 @@ class SchedulerService: display_name = profile.display_name or "dort" linkedin_url = result.get('url', 'https://www.linkedin.com/feed/') + # Generate media HTML + media_html = "" + if post.media_items and len(post.media_items) > 0: + media_items = [item.dict() if hasattr(item, 'dict') else item for item in post.media_items] + for item in sorted(media_items, key=lambda x: x.get('order', 0)): + if item.get('type') == 'image': + media_html += f'diff --git a/src/services/storage_service.py b/src/services/storage_service.py index b14dd41..a1465d4 100644 --- a/src/services/storage_service.py +++ b/src/services/storage_service.py @@ -7,8 +7,13 @@ from loguru import logger from src.config import settings -ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"} -MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB +ALLOWED_CONTENT_TYPES = { + "image/jpeg", "image/png", "image/gif", "image/webp", + "video/mp4", "video/webm", "video/quicktime" +} +MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB +MAX_VIDEO_SIZE = 50 * 1024 * 1024 # 50MB +MAX_FILE_SIZE = MAX_IMAGE_SIZE # Deprecated: for backward compatibility BUCKET_NAME = "post-images" CONTENT_TYPE_EXTENSIONS = { @@ -16,6 +21,9 @@ CONTENT_TYPE_EXTENSIONS = { "image/png": "png", "image/gif": "gif", "image/webp": "webp", + "video/mp4": "mp4", + "video/webm": "webm", + "video/quicktime": "mov", } @@ -49,23 +57,41 @@ class StorageService: raise self._bucket_ensured = True - async def upload_image( + async def upload_media( self, file_content: bytes, content_type: str, user_id: str, ) -> str: - """Upload an image to Supabase Storage. + """Upload an image or video to Supabase Storage. - Returns the public URL of the uploaded image. + Args: + file_content: File bytes + content_type: MIME type (e.g., 'image/jpeg', 'video/mp4') + user_id: User ID for folder organization + + Returns: + Public URL of the uploaded media. + + Raises: + ValueError: If content type is not allowed or file is too large. """ if content_type not in ALLOWED_CONTENT_TYPES: - raise ValueError(f"Unzulässiger Dateityp: {content_type}. Erlaubt: JPEG, PNG, GIF, WebP") + raise ValueError( + f"Unzulässiger Dateityp: {content_type}. " + f"Erlaubt: JPEG, PNG, GIF, WebP, MP4, WebM, MOV" + ) - if len(file_content) > MAX_FILE_SIZE: - raise ValueError(f"Datei zu groß (max. {MAX_FILE_SIZE // 1024 // 1024} MB)") + # Check size based on type + is_video = content_type.startswith("video/") + max_size = MAX_VIDEO_SIZE if is_video else MAX_IMAGE_SIZE - ext = CONTENT_TYPE_EXTENSIONS[content_type] + if len(file_content) > max_size: + max_mb = max_size // 1024 // 1024 + media_type = "Video" if is_video else "Bild" + raise ValueError(f"{media_type} zu groß (max. {max_mb} MB)") + + ext = CONTENT_TYPE_EXTENSIONS.get(content_type, "bin") file_name = f"{user_id}/{uuid.uuid4()}.{ext}" def _upload(): @@ -79,9 +105,24 @@ class StorageService: await asyncio.to_thread(_upload) public_url = f"{settings.supabase_url}/storage/v1/object/public/{BUCKET_NAME}/{file_name}" - logger.info(f"Uploaded image: {file_name}") + media_type = "video" if is_video else "image" + logger.info(f"Uploaded {media_type}: {file_name}") return public_url + async def upload_image( + self, + file_content: bytes, + content_type: str, + user_id: str, + ) -> str: + """DEPRECATED: Upload an image to Supabase Storage. + + Use upload_media() instead. This method is kept for backward compatibility. + + Returns the public URL of the uploaded image. + """ + return await self.upload_media(file_content, content_type, user_id) + async def delete_image(self, image_url: str) -> None: """Delete an image from Supabase Storage by its public URL.""" prefix = f"{settings.supabase_url}/storage/v1/object/public/{BUCKET_NAME}/" diff --git a/src/web/templates/user/company_manage_create.html b/src/web/templates/user/company_manage_create.html index ba28b18..69c1c4c 100644 --- a/src/web/templates/user/company_manage_create.html +++ b/src/web/templates/user/company_manage_create.html @@ -232,28 +232,29 @@ oder 'Meine Meinung dazu ist...'"