1
0

Implemented content upload app with tests and pre-commit hooks

This commit is contained in:
2026-03-13 11:30:28 +00:00
commit 342d39d457
33 changed files with 2740 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Content automation package."""

View File

@@ -0,0 +1 @@
"""Adapter implementations."""

View File

@@ -0,0 +1 @@
"""Social network adapters."""

View 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."""

View 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)

View 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

View File

@@ -0,0 +1 @@
"""Storage adapters."""

View 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."""

View 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

View 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}"

View 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

View 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,
),
}

View 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."""

View 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()

View 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)