New mastodon post feature
This commit is contained in:
parent
1180c0817f
commit
eada9049fa
13 changed files with 746 additions and 23 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
88
gen_photo_alt_text/mastodon.py
Normal file
88
gen_photo_alt_text/mastodon.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue