forked from LiveCarta/ContentAutomation
Implemented content upload app with tests and pre-commit hooks
This commit is contained in:
1
src/content_automation/__init__.py
Normal file
1
src/content_automation/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Content automation package."""
|
||||
1
src/content_automation/adapters/__init__.py
Normal file
1
src/content_automation/adapters/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Adapter implementations."""
|
||||
1
src/content_automation/adapters/social/__init__.py
Normal file
1
src/content_automation/adapters/social/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Social network adapters."""
|
||||
14
src/content_automation/adapters/social/base.py
Normal file
14
src/content_automation/adapters/social/base.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class SocialNetworkBaseAdapter(ABC):
|
||||
"""Abstract base class for social media posting adapters."""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
|
||||
@abstractmethod
|
||||
def post_media(self, media_url: str, caption: str) -> str:
|
||||
"""Publish media and return a provider-specific identifier."""
|
||||
67
src/content_automation/adapters/social/instagram.py
Normal file
67
src/content_automation/adapters/social/instagram.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
from uplink import Body, Consumer, post, returns
|
||||
from uplink.auth import BearerToken
|
||||
|
||||
from content_automation.adapters.social.base import SocialNetworkBaseAdapter
|
||||
from content_automation.interfaces import SocialNetworkAdapter
|
||||
|
||||
|
||||
class InstagramContainerCreatePayload(BaseModel):
|
||||
media_type: str = "REELS"
|
||||
video_url: str
|
||||
caption: str
|
||||
|
||||
|
||||
class InstagramContainerPublishPayload(BaseModel):
|
||||
creation_id: str
|
||||
|
||||
|
||||
class InstagramGraphClient(Consumer):
|
||||
@returns.json
|
||||
@post("/{user_id}/media", args={"payload": Body})
|
||||
def create_container(
|
||||
self,
|
||||
user_id: str,
|
||||
payload: Body(type=InstagramContainerCreatePayload),
|
||||
) -> dict[str, str]:
|
||||
pass
|
||||
|
||||
@returns.json
|
||||
@post("/{user_id}/media_publish", args={"payload": Body})
|
||||
def publish_container(
|
||||
self,
|
||||
user_id: str,
|
||||
payload: Body(type=InstagramContainerPublishPayload),
|
||||
) -> dict[str, str]:
|
||||
pass
|
||||
|
||||
|
||||
class InstagramAdapter(SocialNetworkBaseAdapter, SocialNetworkAdapter):
|
||||
"""Instagram reel publisher via Meta Graph API."""
|
||||
|
||||
def __init__(
|
||||
self, access_token: str, user_id: str, api_version: str = "v25.0"
|
||||
) -> None:
|
||||
super().__init__(name="instagram")
|
||||
self._user_id = user_id
|
||||
self._client = InstagramGraphClient(
|
||||
base_url=f"https://graph.instagram.com/{api_version}",
|
||||
auth=BearerToken(access_token),
|
||||
)
|
||||
|
||||
def post_media(self, media_url: str, caption: str) -> str:
|
||||
container_response = self._client.create_container(
|
||||
user_id=self._user_id,
|
||||
payload=InstagramContainerCreatePayload(
|
||||
video_url=media_url, caption=caption
|
||||
),
|
||||
)
|
||||
creation_id = container_response["id"]
|
||||
|
||||
publish_response = self._client.publish_container(
|
||||
user_id=self._user_id,
|
||||
payload=InstagramContainerPublishPayload(creation_id=creation_id),
|
||||
)
|
||||
return publish_response.get("id", creation_id)
|
||||
295
src/content_automation/adapters/social/youtube.py
Normal file
295
src/content_automation/adapters/social/youtube.py
Normal file
@@ -0,0 +1,295 @@
|
||||
import json
|
||||
import mimetypes
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from google.auth.transport.requests import Request
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.http import MediaFileUpload
|
||||
from pydantic import BaseModel
|
||||
|
||||
from content_automation.adapters.social.base import SocialNetworkBaseAdapter
|
||||
from content_automation.interfaces import SocialNetworkAdapter
|
||||
|
||||
DEFAULT_GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token"
|
||||
DEFAULT_YOUTUBE_UPLOAD_SCOPE = "https://www.googleapis.com/auth/youtube.upload"
|
||||
|
||||
|
||||
class YouTubeSnippet(BaseModel):
|
||||
title: str
|
||||
description: str
|
||||
categoryId: str
|
||||
|
||||
|
||||
class YouTubeStatus(BaseModel):
|
||||
privacyStatus: str
|
||||
|
||||
|
||||
class YouTubeVideoInsertPayload(BaseModel):
|
||||
snippet: YouTubeSnippet
|
||||
status: YouTubeStatus
|
||||
sourceUrl: str
|
||||
|
||||
|
||||
class YouTubeDataApiClient:
|
||||
def __init__(
|
||||
self,
|
||||
access_token: str,
|
||||
category_id: str,
|
||||
privacy_status: str,
|
||||
refresh_token: str | None = None,
|
||||
client_id: str | None = None,
|
||||
client_secret: str | None = None,
|
||||
token_uri: str = DEFAULT_GOOGLE_TOKEN_URI,
|
||||
scopes: list[str] | None = None,
|
||||
expiry: str | None = None,
|
||||
credentials_file_path: str | Path | None = None,
|
||||
) -> None:
|
||||
self._category_id = category_id
|
||||
self._privacy_status = privacy_status
|
||||
self._scopes = scopes or [DEFAULT_YOUTUBE_UPLOAD_SCOPE]
|
||||
self._credentials = self._build_credentials(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
token_uri=token_uri,
|
||||
scopes=self._scopes,
|
||||
expiry=expiry,
|
||||
credentials_file_path=credentials_file_path,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_expiry(expiry: str | None) -> datetime | None:
|
||||
if not expiry:
|
||||
return None
|
||||
normalized = expiry.replace("Z", "+00:00")
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
if parsed.tzinfo is not None:
|
||||
return parsed.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return parsed
|
||||
|
||||
def _build_credentials(
|
||||
self,
|
||||
access_token: str,
|
||||
refresh_token: str | None,
|
||||
client_id: str | None,
|
||||
client_secret: str | None,
|
||||
token_uri: str,
|
||||
scopes: list[str],
|
||||
expiry: str | None,
|
||||
credentials_file_path: str | Path | None,
|
||||
) -> Credentials:
|
||||
if credentials_file_path:
|
||||
return Credentials.from_authorized_user_file(
|
||||
str(credentials_file_path),
|
||||
scopes=scopes,
|
||||
)
|
||||
|
||||
return Credentials(
|
||||
token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_uri=token_uri,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
scopes=scopes,
|
||||
expiry=self._parse_expiry(expiry),
|
||||
)
|
||||
|
||||
def _ensure_valid_access_token(self) -> None:
|
||||
if self._credentials.valid:
|
||||
return
|
||||
|
||||
if not self._credentials.refresh_token:
|
||||
raise RuntimeError(
|
||||
"YouTube credentials are invalid and no refresh token is available."
|
||||
)
|
||||
|
||||
self._credentials.refresh(Request())
|
||||
if not self._credentials.token:
|
||||
raise RuntimeError("Token refresh did not return an access token.")
|
||||
|
||||
def _build_service(self):
|
||||
self._ensure_valid_access_token()
|
||||
return build(
|
||||
"youtube",
|
||||
"v3",
|
||||
credentials=self._credentials,
|
||||
cache_discovery=False,
|
||||
)
|
||||
|
||||
def _build_metadata(self, caption: str) -> dict[str, dict[str, str]]:
|
||||
return {
|
||||
"snippet": {
|
||||
"title": caption[:80] if caption else "Auto-uploaded short",
|
||||
"description": caption,
|
||||
"categoryId": self._category_id,
|
||||
},
|
||||
"status": {
|
||||
"privacyStatus": self._privacy_status,
|
||||
},
|
||||
}
|
||||
|
||||
def build_insert_payload(
|
||||
self, source_url: str, caption: str
|
||||
) -> YouTubeVideoInsertPayload:
|
||||
metadata = self._build_metadata(caption=caption)
|
||||
return YouTubeVideoInsertPayload(
|
||||
snippet=YouTubeSnippet(**metadata["snippet"]),
|
||||
status=YouTubeStatus(**metadata["status"]),
|
||||
sourceUrl=source_url,
|
||||
)
|
||||
|
||||
def insert_video(
|
||||
self,
|
||||
part: str,
|
||||
payload: YouTubeVideoInsertPayload,
|
||||
) -> dict[str, object]:
|
||||
service = self._build_service()
|
||||
body = payload.model_dump()
|
||||
request = service.videos().insert(part=part, body=body)
|
||||
return request.execute()
|
||||
|
||||
def upload_video_regular(self, local_file_path: Path, caption: str) -> str:
|
||||
mime_type = (
|
||||
mimetypes.guess_type(local_file_path.name)[0] or "application/octet-stream"
|
||||
)
|
||||
body = self._build_metadata(caption=caption)
|
||||
media = MediaFileUpload(
|
||||
str(local_file_path),
|
||||
mimetype=mime_type,
|
||||
resumable=False,
|
||||
)
|
||||
service = self._build_service()
|
||||
response = (
|
||||
service.videos()
|
||||
.insert(
|
||||
part="snippet,status",
|
||||
body=body,
|
||||
media_body=media,
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
return str(response["id"])
|
||||
|
||||
def upload_video_resumable(
|
||||
self,
|
||||
local_file_path: Path,
|
||||
caption: str,
|
||||
resumable_chunk_size: int,
|
||||
) -> str:
|
||||
mime_type = (
|
||||
mimetypes.guess_type(local_file_path.name)[0] or "application/octet-stream"
|
||||
)
|
||||
body = self._build_metadata(caption=caption)
|
||||
media = MediaFileUpload(
|
||||
str(local_file_path),
|
||||
mimetype=mime_type,
|
||||
chunksize=resumable_chunk_size,
|
||||
resumable=True,
|
||||
)
|
||||
service = self._build_service()
|
||||
request = service.videos().insert(
|
||||
part="snippet,status",
|
||||
body=body,
|
||||
media_body=media,
|
||||
)
|
||||
|
||||
response = None
|
||||
while response is None:
|
||||
_, response = request.next_chunk()
|
||||
|
||||
return str(response["id"])
|
||||
|
||||
|
||||
class YouTubeAdapter(SocialNetworkBaseAdapter, SocialNetworkAdapter):
|
||||
"""YouTube posting adapter via Data API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
access_token: str,
|
||||
category_id: str = "22",
|
||||
privacy_status: str = "public",
|
||||
use_resumable_upload: bool = True,
|
||||
resumable_chunk_size: int = 8 * 1024 * 1024,
|
||||
refresh_token: str | None = None,
|
||||
client_id: str | None = None,
|
||||
client_secret: str | None = None,
|
||||
token_uri: str = DEFAULT_GOOGLE_TOKEN_URI,
|
||||
scopes: list[str] | None = None,
|
||||
expiry: str | None = None,
|
||||
credentials_file_path: str | Path | None = None,
|
||||
) -> None:
|
||||
super().__init__(name="youtube")
|
||||
self._use_resumable_upload = use_resumable_upload
|
||||
self._resumable_chunk_size = resumable_chunk_size
|
||||
self._client = YouTubeDataApiClient(
|
||||
access_token=access_token,
|
||||
category_id=category_id,
|
||||
privacy_status=privacy_status,
|
||||
refresh_token=refresh_token,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
token_uri=token_uri,
|
||||
scopes=scopes,
|
||||
expiry=expiry,
|
||||
credentials_file_path=credentials_file_path,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def obtain_credentials_from_client_secret_file(
|
||||
client_secret_file_path: str | Path,
|
||||
scopes: list[str] | None = None,
|
||||
token_output_path: str | Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
resolved_scopes = scopes or [DEFAULT_YOUTUBE_UPLOAD_SCOPE]
|
||||
flow = InstalledAppFlow.from_client_secrets_file(
|
||||
str(client_secret_file_path),
|
||||
resolved_scopes,
|
||||
)
|
||||
credentials = flow.run_local_server()
|
||||
|
||||
credentials_payload = json.loads(credentials.to_json())
|
||||
if token_output_path is not None:
|
||||
token_path = Path(token_output_path)
|
||||
token_path.write_text(
|
||||
json.dumps(credentials_payload, indent=4),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
return credentials_payload
|
||||
|
||||
def post_media(self, media_url: str, caption: str) -> str:
|
||||
local_file_path = self._resolve_local_file_path(media_url)
|
||||
if local_file_path is not None:
|
||||
if self._use_resumable_upload:
|
||||
return self._client.upload_video_resumable(
|
||||
local_file_path=local_file_path,
|
||||
caption=caption,
|
||||
resumable_chunk_size=self._resumable_chunk_size,
|
||||
)
|
||||
return self._client.upload_video_regular(
|
||||
local_file_path=local_file_path,
|
||||
caption=caption,
|
||||
)
|
||||
|
||||
payload = self._client.build_insert_payload(
|
||||
source_url=media_url,
|
||||
caption=caption,
|
||||
)
|
||||
response = self._client.insert_video(part="snippet,status", payload=payload)
|
||||
return str(response["id"])
|
||||
|
||||
@staticmethod
|
||||
def _resolve_local_file_path(media_url: str) -> Path | None:
|
||||
parsed_url = urlparse(media_url)
|
||||
if parsed_url.scheme == "file":
|
||||
return Path(unquote(parsed_url.path))
|
||||
|
||||
raw_path = Path(media_url)
|
||||
if raw_path.exists():
|
||||
return raw_path
|
||||
return None
|
||||
1
src/content_automation/adapters/storage/__init__.py
Normal file
1
src/content_automation/adapters/storage/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Storage adapters."""
|
||||
15
src/content_automation/adapters/storage/base.py
Normal file
15
src/content_automation/adapters/storage/base.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class StorageAdapterBase(ABC):
|
||||
"""Abstract storage adapter for file presence and URL access."""
|
||||
|
||||
@abstractmethod
|
||||
def exists(self, relative_path: str) -> bool:
|
||||
"""Check if the media file exists in storage."""
|
||||
|
||||
@abstractmethod
|
||||
def get_public_url(self, relative_path: str) -> str:
|
||||
"""Resolve a URL that can be consumed by social APIs."""
|
||||
24
src/content_automation/adapters/storage/local.py
Normal file
24
src/content_automation/adapters/storage/local.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from content_automation.adapters.storage.base import StorageAdapterBase
|
||||
|
||||
|
||||
class LocalFilesystemStorageAdapter(StorageAdapterBase):
|
||||
"""Storage adapter for local files."""
|
||||
|
||||
def __init__(self, root_directory: str) -> None:
|
||||
self._root_directory = Path(root_directory)
|
||||
|
||||
def exists(self, relative_path: str) -> bool:
|
||||
return self._resolve(relative_path).exists()
|
||||
|
||||
def get_public_url(self, relative_path: str) -> str:
|
||||
file_path = self._resolve(relative_path)
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
return file_path.resolve().as_uri()
|
||||
|
||||
def _resolve(self, relative_path: str) -> Path:
|
||||
return self._root_directory / relative_path
|
||||
57
src/content_automation/adapters/storage/s3.py
Normal file
57
src/content_automation/adapters/storage/s3.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import boto3
|
||||
from botocore.client import BaseClient
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from content_automation.adapters.storage.base import StorageAdapterBase
|
||||
|
||||
|
||||
class S3StorageAdapter(StorageAdapterBase):
|
||||
"""Storage adapter backed by AWS S3-compatible API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bucket_name: str,
|
||||
key_prefix: str = "",
|
||||
region_name: str | None = None,
|
||||
endpoint_url: str | None = None,
|
||||
public_url_base: str | None = None,
|
||||
url_expiration_seconds: int = 3600,
|
||||
) -> None:
|
||||
self._bucket_name = bucket_name
|
||||
self._key_prefix = key_prefix.strip("/")
|
||||
self._public_url_base = public_url_base.rstrip("/") if public_url_base else None
|
||||
self._url_expiration_seconds = url_expiration_seconds
|
||||
self._client: BaseClient = boto3.client(
|
||||
"s3",
|
||||
region_name=region_name,
|
||||
endpoint_url=endpoint_url,
|
||||
)
|
||||
|
||||
def exists(self, relative_path: str) -> bool:
|
||||
key = self._key(relative_path)
|
||||
try:
|
||||
self._client.head_object(Bucket=self._bucket_name, Key=key)
|
||||
return True
|
||||
except ClientError as exc:
|
||||
error_code = exc.response.get("Error", {}).get("Code", "")
|
||||
if error_code in {"404", "NoSuchKey", "NotFound"}:
|
||||
return False
|
||||
raise
|
||||
|
||||
def get_public_url(self, relative_path: str) -> str:
|
||||
key = self._key(relative_path)
|
||||
if self._public_url_base:
|
||||
return f"{self._public_url_base}/{key}"
|
||||
return self._client.generate_presigned_url(
|
||||
ClientMethod="get_object",
|
||||
Params={"Bucket": self._bucket_name, "Key": key},
|
||||
ExpiresIn=self._url_expiration_seconds,
|
||||
)
|
||||
|
||||
def _key(self, relative_path: str) -> str:
|
||||
sanitized = relative_path.lstrip("/")
|
||||
if not self._key_prefix:
|
||||
return sanitized
|
||||
return f"{self._key_prefix}/{sanitized}"
|
||||
34
src/content_automation/controller.py
Normal file
34
src/content_automation/controller.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from content_automation.adapters.storage.base import StorageAdapterBase
|
||||
from content_automation.interfaces import SocialNetworkAdapter
|
||||
from content_automation.settings import AppSettings
|
||||
|
||||
|
||||
class PublishController:
|
||||
"""Coordinates storage lookup and cross-network publishing."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: AppSettings,
|
||||
storage: StorageAdapterBase,
|
||||
social_adapters: dict[str, SocialNetworkAdapter],
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._storage = storage
|
||||
self._social_adapters = social_adapters
|
||||
|
||||
def publish(self, relative_path: str, caption: str) -> dict[str, str]:
|
||||
if not self._storage.exists(relative_path):
|
||||
raise FileNotFoundError(
|
||||
f"Media file is not available in storage: {relative_path}"
|
||||
)
|
||||
|
||||
media_url = self._storage.get_public_url(relative_path)
|
||||
result: dict[str, str] = {}
|
||||
for network in self._settings.target_social_networks:
|
||||
adapter = self._social_adapters.get(network)
|
||||
if adapter is None:
|
||||
raise ValueError(f"No adapter configured for network: {network}")
|
||||
result[network] = adapter.post_media(media_url=media_url, caption=caption)
|
||||
return result
|
||||
51
src/content_automation/factories.py
Normal file
51
src/content_automation/factories.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from content_automation.adapters.social.instagram import InstagramAdapter
|
||||
from content_automation.adapters.social.youtube import YouTubeAdapter
|
||||
from content_automation.adapters.storage.base import StorageAdapterBase
|
||||
from content_automation.adapters.storage.local import LocalFilesystemStorageAdapter
|
||||
from content_automation.adapters.storage.s3 import S3StorageAdapter
|
||||
from content_automation.interfaces import SocialNetworkAdapter
|
||||
from content_automation.settings import AppSettings
|
||||
|
||||
|
||||
def build_storage_adapter(settings: AppSettings) -> StorageAdapterBase:
|
||||
backend = settings.storage.backend.lower()
|
||||
if backend == "local":
|
||||
return LocalFilesystemStorageAdapter(
|
||||
root_directory=settings.storage.local.root_directory
|
||||
)
|
||||
if backend == "s3":
|
||||
return S3StorageAdapter(
|
||||
bucket_name=settings.storage.s3.bucket_name,
|
||||
key_prefix=settings.storage.s3.key_prefix,
|
||||
region_name=settings.storage.s3.region_name,
|
||||
endpoint_url=settings.storage.s3.endpoint_url,
|
||||
public_url_base=settings.storage.s3.public_url_base,
|
||||
url_expiration_seconds=settings.storage.s3.url_expiration_seconds,
|
||||
)
|
||||
raise ValueError(f"Unsupported storage backend: {settings.storage.backend}")
|
||||
|
||||
|
||||
def build_social_adapters(settings: AppSettings) -> dict[str, SocialNetworkAdapter]:
|
||||
return {
|
||||
"instagram": InstagramAdapter(
|
||||
access_token=settings.instagram.access_token,
|
||||
user_id=settings.instagram.user_id,
|
||||
api_version=settings.instagram.api_version,
|
||||
),
|
||||
"youtube": YouTubeAdapter(
|
||||
access_token=settings.youtube.access_token,
|
||||
category_id=settings.youtube.category_id,
|
||||
privacy_status=settings.youtube.privacy_status,
|
||||
use_resumable_upload=settings.youtube.use_resumable_upload,
|
||||
resumable_chunk_size=settings.youtube.resumable_chunk_size,
|
||||
credentials_file_path=settings.youtube.credentials_file_path or None,
|
||||
refresh_token=settings.youtube.refresh_token or None,
|
||||
client_id=settings.youtube.client_id or None,
|
||||
client_secret=settings.youtube.client_secret or None,
|
||||
token_uri=settings.youtube.token_uri,
|
||||
scopes=settings.youtube.scopes,
|
||||
expiry=settings.youtube.expiry or None,
|
||||
),
|
||||
}
|
||||
12
src/content_automation/interfaces.py
Normal file
12
src/content_automation/interfaces.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class SocialNetworkAdapter(Protocol):
|
||||
"""Contract for social network posting adapters."""
|
||||
|
||||
name: str
|
||||
|
||||
def post_media(self, media_url: str, caption: str) -> str:
|
||||
"""Publish media and return an external post identifier."""
|
||||
37
src/content_automation/main.py
Normal file
37
src/content_automation/main.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from content_automation.controller import PublishController
|
||||
from content_automation.factories import build_social_adapters, build_storage_adapter
|
||||
from content_automation.settings import AppSettings
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Publish media to configured social networks."
|
||||
)
|
||||
parser.add_argument("relative_path", help="Storage-relative media path.")
|
||||
parser.add_argument(
|
||||
"--caption", default="", help="Caption/description for the post."
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
settings = AppSettings()
|
||||
storage = build_storage_adapter(settings)
|
||||
adapters = build_social_adapters(settings)
|
||||
controller = PublishController(
|
||||
settings=settings, storage=storage, social_adapters=adapters
|
||||
)
|
||||
publish_results = controller.publish(
|
||||
relative_path=args.relative_path, caption=args.caption
|
||||
)
|
||||
for network, post_id in publish_results.items():
|
||||
print(f"{network}: {post_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
56
src/content_automation/settings.py
Normal file
56
src/content_automation/settings.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class AppSettings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="CONTENT_AUTOMATION_", env_nested_delimiter="__"
|
||||
)
|
||||
|
||||
class InstagramSettings(BaseModel):
|
||||
access_token: str = ""
|
||||
user_id: str = ""
|
||||
api_version: str = "v25.0"
|
||||
|
||||
class YoutubeSettings(BaseModel):
|
||||
access_token: str = ""
|
||||
credentials_file_path: str = ""
|
||||
refresh_token: str = ""
|
||||
client_id: str = ""
|
||||
client_secret: str = ""
|
||||
token_uri: str = "https://oauth2.googleapis.com/token"
|
||||
scopes: list[str] = Field(
|
||||
default_factory=lambda: ["https://www.googleapis.com/auth/youtube.upload"]
|
||||
)
|
||||
expiry: str = ""
|
||||
category_id: str = "22"
|
||||
privacy_status: str = "public"
|
||||
use_resumable_upload: bool = True
|
||||
resumable_chunk_size: int = 8 * 1024 * 1024
|
||||
|
||||
class StorageSettings(BaseModel):
|
||||
class LocalSettings(BaseModel):
|
||||
root_directory: str = "."
|
||||
|
||||
class S3Settings(BaseModel):
|
||||
bucket_name: str = ""
|
||||
key_prefix: str = ""
|
||||
region_name: str | None = None
|
||||
endpoint_url: str | None = None
|
||||
public_url_base: str | None = None
|
||||
url_expiration_seconds: int = 3600
|
||||
|
||||
backend: str = "local"
|
||||
local: LocalSettings = Field(default_factory=LocalSettings)
|
||||
s3: S3Settings = Field(default_factory=S3Settings)
|
||||
|
||||
target_social_networks: list[str] = Field(
|
||||
default_factory=lambda: ["instagram", "youtube"]
|
||||
)
|
||||
instagram: InstagramSettings = Field(default_factory=InstagramSettings)
|
||||
youtube: YoutubeSettings = Field(default_factory=YoutubeSettings)
|
||||
storage: StorageSettings = Field(default_factory=StorageSettings)
|
||||
Reference in New Issue
Block a user