New mastodon post feature

This commit is contained in:
Edward Betts 2025-11-15 12:48:36 +00:00
parent 1180c0817f
commit eada9049fa
13 changed files with 746 additions and 23 deletions

View file

@ -8,6 +8,7 @@ from .cache import AltTextCache
from .config import load_settings
from .immich import ImmichClient
from .openai_client import AltTextGenerator
from .mastodon import MastodonClient
def create_app() -> Flask:
@ -42,6 +43,14 @@ def create_app() -> Flask:
api_key=app.config["OPENAI_API_KEY"],
model=app.config.get("OPENAI_MODEL", "gpt-4o-mini"),
)
mastodon_token = app.config.get("MASTODON_ACCESS_TOKEN")
if mastodon_token:
app.mastodon_client = MastodonClient(
base_url=app.config.get("MASTODON_BASE_URL", "http://localhost:3000"),
access_token=mastodon_token,
)
else:
app.mastodon_client = None
from . import routes # pragma: no cover

View file

@ -54,6 +54,12 @@ def load_settings() -> Dict[str, str]:
"OPENAI_MODEL": os.getenv("OPENAI_MODEL", "gpt-4o-mini"),
"ALT_TEXT_DB": os.getenv("ALT_TEXT_DB", ""),
"SECRET_KEY": os.getenv("SECRET_KEY", ""),
"MASTODON_BASE_URL": os.getenv(
"MASTODON_BASE_URL", "http://localhost:3000"
).rstrip("/"),
"MASTODON_ACCESS_TOKEN": os.getenv("MASTODON_ACCESS_TOKEN", ""),
"MASTODON_CLIENT_KEY": os.getenv("MASTODON_CLIENT_KEY", ""),
"MASTODON_CLIENT_SECRET": os.getenv("MASTODON_CLIENT_SECRET", ""),
}
legacy_values = _load_legacy_api_keys(Path("api_keys"))

View file

@ -0,0 +1,88 @@
"""Client helpers for posting to Mastodon."""
from __future__ import annotations
from typing import List, Sequence
import requests
class MastodonError(RuntimeError):
"""Raised when the Mastodon API indicates a failure."""
class MastodonClient:
"""Minimal Mastodon API wrapper for uploading media and posting statuses."""
def __init__(self, base_url: str, access_token: str) -> None:
if not base_url:
raise ValueError("base_url is required")
if not access_token:
raise ValueError("access_token is required")
self.base_url = base_url.rstrip("/")
self.session = requests.Session()
self.session.headers.update(
{
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
"User-Agent": "ImmichAltTextHelper/0.1 (+https://photos.4angle.com)",
}
)
def _raise_for_error(self, response: requests.Response) -> None:
try:
payload = response.json()
except Exception: # pragma: no cover - fallback path
payload = response.text
message = f"{response.status_code} {response.reason}: {payload}"
raise MastodonError(message)
def upload_media(
self, filename: str, data: bytes, mime_type: str, alt_text: str
) -> str:
url = f"{self.base_url}/api/v2/media"
files = {
"file": (
filename or "photo.jpg",
data,
mime_type or "application/octet-stream",
)
}
form = {}
if alt_text:
form["description"] = alt_text
try:
response = self.session.post(url, files=files, data=form, timeout=30)
except requests.RequestException as exc: # pragma: no cover - network failure
raise MastodonError(str(exc)) from exc
if not response.ok:
self._raise_for_error(response)
payload = response.json()
media_id = payload.get("id")
if not media_id:
raise MastodonError("Mastodon response missing media id")
return media_id
def create_status(self, text: str, media_ids: Sequence[str]) -> str:
if not text.strip():
raise MastodonError("Post text cannot be empty")
url = f"{self.base_url}/api/v1/statuses"
payload = {
"status": text,
"language": "en",
"media_ids": list(media_ids),
}
try:
response = self.session.post(url, json=payload, timeout=30)
except requests.RequestException as exc: # pragma: no cover
raise MastodonError(str(exc)) from exc
if not response.ok:
self._raise_for_error(response)
data = response.json()
status_id = data.get("id")
if not status_id:
raise MastodonError("Mastodon response missing status id")
return status_id

View file

@ -7,10 +7,18 @@ from typing import Any, Dict, List, Optional
import requests
class AltTextGenerationError(RuntimeError):
class OpenAIClientError(RuntimeError):
"""Raised when the OpenAI API cannot fulfill a request."""
class AltTextGenerationError(OpenAIClientError):
"""Raised when the OpenAI API cannot generate alt text."""
class TextImprovementError(OpenAIClientError):
"""Raised when the OpenAI API cannot improve post text."""
class AltTextGenerator:
"""Request alt text from a GPT-4o compatible OpenAI endpoint."""
@ -92,3 +100,43 @@ class AltTextGenerator:
raise AltTextGenerationError("OpenAI response missing content")
return content_text.strip()
def improve_post_text(
self, draft_text: str, instructions: Optional[str] = None
) -> str:
if not draft_text or not draft_text.strip():
raise TextImprovementError("Post text cannot be empty")
prompt_parts = [
"You review Mastodon drafts and rewrite them in UK English.",
"Keep the tone warm, accessible, and descriptive without exaggeration.",
"Ensure clarity, fix spelling or grammar, and keep content suitable for social media.",
]
if instructions:
prompt_parts.append(f"Additional instructions: {instructions.strip()}")
prompt_parts.append("Return only the improved post text.")
user_content = f"Draft post:\\n{draft_text.strip()}"
payload = {
"model": self.model,
"temperature": 0.4,
"max_tokens": 400,
"messages": [
{"role": "system", "content": "\n".join(prompt_parts)},
{"role": "user", "content": user_content},
],
}
try:
response = self.session.post(self.endpoint, json=payload, timeout=30)
response.raise_for_status()
data = response.json()
except requests.RequestException as exc: # pragma: no cover
raise TextImprovementError(str(exc)) from exc
choices = data.get("choices") or []
if not choices:
raise TextImprovementError("OpenAI response did not include choices")
content_text = choices[0].get("message", {}).get("content")
if not content_text:
raise TextImprovementError("OpenAI response missing content")
return content_text.strip()

View file

@ -17,8 +17,9 @@ from flask import (
url_for,
)
from .immich import ImmichError
from .openai_client import AltTextGenerationError
from .immich import ImmichAsset, ImmichError
from .mastodon import MastodonError
from .openai_client import AltTextGenerationError, TextImprovementError
bp = Blueprint("main", __name__)
@ -37,7 +38,7 @@ def _humanize_timestamp(raw: str | None) -> str | None:
dt = _parse_timestamp(raw)
if not dt:
return raw
return dt.strftime("%d %b %Y / %H:%M")
return dt.strftime("%d %b %Y %H:%M")
def _timestamp_with_timezone(raw: str | None) -> str | None:
@ -57,6 +58,53 @@ def _timestamp_with_timezone(raw: str | None) -> str | None:
return f"{label} (timezone unknown)"
def _coordinates_text(asset: ImmichAsset) -> str | None:
if asset.latitude is None or asset.longitude is None:
return None
return f"{asset.latitude:.5f}, {asset.longitude:.5f}"
def _generate_alt_text_for_asset(asset: ImmichAsset, notes: str | None = None) -> str:
immich_client = current_app.immich_client
generator = current_app.alt_text_generator
content, mime_type = immich_client.fetch_asset_content(asset.id, "preview")
data_url = "data:{};base64,{}".format(
mime_type, base64.b64encode(content).decode("ascii")
)
return generator.generate_alt_text(
data_url,
notes,
captured_at=_timestamp_with_timezone(asset.captured_at),
location=asset.location_label,
coordinates=_coordinates_text(asset),
)
def _ensure_alt_text(asset: ImmichAsset) -> str:
cache = current_app.alt_text_cache
cached = cache.get(asset.id)
if cached:
return cached
generated = _generate_alt_text_for_asset(asset)
cache.set(asset.id, generated)
return generated
def _unique_asset_ids(values: list[str]) -> list[str]:
seen: set[str] = set()
ordered: list[str] = []
for value in values:
value = (value or "").strip()
if not value or value in seen:
continue
seen.add(value)
ordered.append(value)
return ordered
MAX_MEDIA_ATTACHMENTS = 4
@bp.route("/")
def index():
immich_client = current_app.immich_client
@ -87,7 +135,6 @@ def index():
def asset_detail(asset_id: str):
immich_client = current_app.immich_client
alt_cache = current_app.alt_text_cache
generator = current_app.alt_text_generator
error_message = None
notes = ""
@ -108,22 +155,7 @@ def asset_detail(asset_id: str):
if request.method == "POST":
notes = request.form.get("notes", "")
try:
content, mime_type = immich_client.fetch_asset_content(asset_id, "preview")
data_url = "data:{};base64,{}".format(
mime_type, base64.b64encode(content).decode("ascii")
)
location_text = asset.location_label
coordinates_text = None
if asset.latitude is not None and asset.longitude is not None:
coordinates_text = f"{asset.latitude:.5f}, {asset.longitude:.5f}"
captured_text = _timestamp_with_timezone(asset.captured_at)
generated = generator.generate_alt_text(
data_url,
notes,
captured_at=captured_text,
location=location_text,
coordinates=coordinates_text,
)
generated = _generate_alt_text_for_asset(asset, notes)
alt_cache.set(asset_id, generated)
flash("Alt text generated.")
return redirect(url_for("main.asset_detail", asset_id=asset_id))
@ -155,6 +187,169 @@ def asset_detail(asset_id: str):
)
@bp.route("/compose/select", methods=["GET", "POST"])
def compose_select():
immich_client = current_app.immich_client
alt_cache = current_app.alt_text_cache
days = current_app.config.get("RECENT_DAYS", 3)
asset_objs: list[ImmichAsset] = []
error_message = None
try:
asset_objs = immich_client.get_recent_assets(days=days, limit=200)
except ImmichError as exc:
error_message = str(exc)
assets = []
for asset in asset_objs:
assets.append(
{
"id": asset.id,
"file_name": asset.file_name,
"captured_display": _humanize_timestamp(asset.captured_at),
"alt_text": alt_cache.get(asset.id),
}
)
selected_set: set[str] = set()
if request.method == "POST":
selected = _unique_asset_ids(request.form.getlist("asset_ids"))
selected_set = set(selected)
if not selected:
error_message = "Select at least one photo."
elif len(selected) > MAX_MEDIA_ATTACHMENTS:
error_message = f"Select up to {MAX_MEDIA_ATTACHMENTS} photos."
else:
return redirect(url_for("main.compose_draft", ids=",".join(selected)))
return render_template(
"compose_select.html",
assets=assets,
error_message=error_message,
max_photos=MAX_MEDIA_ATTACHMENTS,
selected_ids=selected_set,
)
@bp.route("/compose/draft", methods=["GET", "POST"])
def compose_draft():
mastodon_client = getattr(current_app, "mastodon_client", None)
immich_client = current_app.immich_client
alt_cache = current_app.alt_text_cache
generator = current_app.alt_text_generator
error_message = None
post_text = ""
instructions = ""
if request.method == "POST":
asset_ids = _unique_asset_ids(request.form.getlist("asset_ids"))
post_text = request.form.get("post_text", "")
instructions = request.form.get("post_instructions", "")
else:
ids_param = request.args.get("ids", "")
asset_ids = _unique_asset_ids(ids_param.split(",") if ids_param else [])
if not asset_ids:
flash("Choose photos before composing a post.")
return redirect(url_for("main.compose_select"))
if len(asset_ids) > MAX_MEDIA_ATTACHMENTS:
flash(f"Select at most {MAX_MEDIA_ATTACHMENTS} photos.")
return redirect(url_for("main.compose_select"))
assets: list[ImmichAsset] = []
try:
for asset_id in asset_ids:
assets.append(immich_client.get_asset(asset_id))
except ImmichError as exc:
return render_template(
"compose_draft.html",
assets=[],
error_message=str(exc),
post_text=post_text,
instructions=instructions,
mastodon_ready=bool(mastodon_client),
)
asset_entries = []
for asset in assets:
if request.method == "POST":
alt_value = request.form.get(f"alt_text_{asset.id}", "")
else:
try:
alt_value = _ensure_alt_text(asset)
except (ImmichError, AltTextGenerationError) as exc:
alt_value = ""
if not error_message:
error_message = str(exc)
asset_entries.append(
{
"id": asset.id,
"file_name": asset.file_name,
"captured_display": _humanize_timestamp(asset.captured_at),
"preview_url": url_for(
"main.asset_proxy", asset_id=asset.id, variant="preview"
),
"alt_text": alt_value,
}
)
if request.method == "POST":
action = request.form.get("action")
if action == "refine":
try:
post_text = generator.improve_post_text(post_text, instructions)
flash("Post refined with ChatGPT.")
except TextImprovementError as exc:
error_message = str(exc)
elif action == "post":
if not mastodon_client:
error_message = "Configure Mastodon access before posting."
elif not post_text.strip():
error_message = "Enter post text before posting."
else:
try:
media_ids: list[str] = []
for entry, asset in zip(asset_entries, assets):
alt_value = entry["alt_text"].strip()
if not alt_value:
raise ValueError(
f"Alt text missing for {entry['file_name']}"
)
entry["alt_text"] = alt_value
alt_cache.set(asset.id, alt_value)
content, mime_type = immich_client.fetch_asset_content(
asset.id, "original"
)
media_id = mastodon_client.upload_media(
asset.file_name, content, mime_type, alt_value
)
media_ids.append(media_id)
mastodon_client.create_status(post_text.strip(), media_ids)
flash("Post sent to Mastodon.")
return redirect(url_for("main.index"))
except ValueError as exc:
error_message = str(exc)
except ImmichError as exc:
error_message = f"Failed to fetch media: {exc}"
except MastodonError as exc:
error_message = str(exc)
elif action:
error_message = "Unknown action."
return render_template(
"compose_draft.html",
assets=asset_entries,
error_message=error_message,
post_text=post_text,
instructions=instructions,
mastodon_ready=bool(mastodon_client),
max_photos=MAX_MEDIA_ATTACHMENTS,
)
@bp.route("/proxy/assets/<asset_id>/<variant>")
def asset_proxy(asset_id: str, variant: str):
immich_client = current_app.immich_client