Initial commit
This commit is contained in:
commit
1180c0817f
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
api_keys
|
||||
.mypy_cache
|
||||
__pycache__
|
||||
instance
|
||||
24
README.md
Normal file
24
README.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Immich Alt Text Helper
|
||||
|
||||
A small Flask app that fetches recent Immich photos and helps generate high-quality Mastodon alt text via the OpenAI API.
|
||||
|
||||
## Getting started
|
||||
|
||||
1. Create and populate a `.env` file (or reuse the existing `api_keys` file) with:
|
||||
|
||||
```
|
||||
IMMICH_API_URL=https://photos.4angle.com/
|
||||
IMMICH_API_KEY=your-immich-api-key
|
||||
OPENAI_API_KEY=your-openai-api-key
|
||||
```
|
||||
|
||||
2. Install dependencies and run the development server:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
flask --app app run --debug
|
||||
```
|
||||
|
||||
3. Visit <http://127.0.0.1:5000> to browse recent Immich photos, view cached alt text, and request new alt text guided by optional notes.
|
||||
|
||||
The app stores alt text in `instance/alt_text_cache.db`. Remove this file to clear the cache.
|
||||
83
SPEC.md
Normal file
83
SPEC.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
Here’s an updated, cleaner specification that incorporates your Immich URL and
|
||||
the `api_keys` file, and clarifies how configuration should work.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A small Flask web application that helps generate high-quality alt text for
|
||||
Mastodon posts. It retrieves recent photos from Immich, displays them as
|
||||
thumbnails, and lets the user request alt text from the OpenAI API, optionally
|
||||
guided by user-supplied notes. Generated alt text is cached and shown in the
|
||||
index view.
|
||||
|
||||
The app targets local development only and can run via the built-in Flask
|
||||
server with no authentication. Production deployment can be dealt with later.
|
||||
|
||||
## Requirements
|
||||
|
||||
### 1. Photo retrieval (Immich)
|
||||
|
||||
* Immich is available at:
|
||||
**[https://photos.4angle.com/](https://photos.4angle.com/)**
|
||||
* The app must connect to the Immich API using an API key.
|
||||
* Fetch images created within the last three days.
|
||||
* Store enough metadata to:
|
||||
|
||||
* display a thumbnail,
|
||||
* retrieve the image for alt-text generation.
|
||||
|
||||
### 2. Index view
|
||||
|
||||
* Display a grid of thumbnails for the recent photos.
|
||||
* Show cached alt text (if available) beneath each thumbnail.
|
||||
* Each thumbnail should link to a detail view.
|
||||
|
||||
### 3. Photo detail view
|
||||
|
||||
* Display a larger version of the chosen image.
|
||||
* Provide a text box for user notes (free text, optional).
|
||||
* Include a button to trigger alt-text generation via the OpenAI API.
|
||||
* The OpenAI request must include:
|
||||
|
||||
* the selected image (as URL or binary data, depending on the chosen model),
|
||||
* the user notes,
|
||||
* a short system/user prompt instructing the model to produce concise,
|
||||
high-quality Mastodon alt text.
|
||||
* Display the generated alt text and persist it to the cache.
|
||||
|
||||
### 4. Alt-text caching
|
||||
|
||||
* Cache alt text keyed by the Immich asset ID.
|
||||
* Use a simple local store:
|
||||
|
||||
* SQLite, or
|
||||
* a JSON file (fine for a prototype).
|
||||
* The index view must reuse cached values rather than regenerating them.
|
||||
|
||||
### 5. Configuration
|
||||
|
||||
* API keys for Immich and OpenAI are currently stored in a file called
|
||||
**`api_keys`**.
|
||||
* You may rename this file or relocate configuration. Suggested improvements:
|
||||
|
||||
* rename to **`.env`**, and load via `python-dotenv`, or
|
||||
* rename to **`config.toml`** and parse with standard libraries.
|
||||
* Configuration values should include:
|
||||
|
||||
* `IMMICH_API_URL` (default: `https://photos.4angle.com/`)
|
||||
* `IMMICH_API_KEY`
|
||||
* `OPENAI_API_KEY`
|
||||
* Flask should read these at startup.
|
||||
|
||||
### 6. Operation
|
||||
|
||||
* Just running `flask run` must start the app in development mode.
|
||||
* Requests can be synchronous; no background workers needed.
|
||||
* No user login or security layer required at this stage.
|
||||
|
||||
---
|
||||
|
||||
If you’d like, I can propose a directory layout, generate a `config.py`, a
|
||||
`.env` example, or scaffold the Flask app skeleton.
|
||||
|
||||
8
app.py
Normal file
8
app.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"""Entry-point for running the Flask development server."""
|
||||
|
||||
from gen_photo_alt_text import create_app
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app.run(debug=True)
|
||||
50
gen_photo_alt_text/__init__.py
Normal file
50
gen_photo_alt_text/__init__.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""Package initialization for the alt text generator app."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from .cache import AltTextCache
|
||||
from .config import load_settings
|
||||
from .immich import ImmichClient
|
||||
from .openai_client import AltTextGenerator
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
"""Create and configure the Flask application."""
|
||||
package_path = Path(__file__).resolve().parent
|
||||
project_root = package_path.parent
|
||||
app = Flask(
|
||||
__name__,
|
||||
template_folder=str(project_root / "templates"),
|
||||
static_folder=str(project_root / "static"),
|
||||
)
|
||||
|
||||
settings = load_settings()
|
||||
app.config.update(settings)
|
||||
|
||||
secret_key = app.config.get("SECRET_KEY") or "dev-secret-key"
|
||||
app.config["SECRET_KEY"] = secret_key
|
||||
|
||||
db_path = app.config.get("ALT_TEXT_DB")
|
||||
if not db_path:
|
||||
db_path = str(Path(app.instance_path) / "alt_text_cache.db")
|
||||
app.config["ALT_TEXT_DB"] = db_path
|
||||
|
||||
Path(app.instance_path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
app.immich_client = ImmichClient(
|
||||
base_url=app.config["IMMICH_API_URL"],
|
||||
api_key=app.config["IMMICH_API_KEY"],
|
||||
)
|
||||
app.alt_text_cache = AltTextCache(db_path)
|
||||
app.alt_text_generator = AltTextGenerator(
|
||||
api_key=app.config["OPENAI_API_KEY"],
|
||||
model=app.config.get("OPENAI_MODEL", "gpt-4o-mini"),
|
||||
)
|
||||
|
||||
from . import routes # pragma: no cover
|
||||
|
||||
app.register_blueprint(routes.bp)
|
||||
|
||||
return app
|
||||
58
gen_photo_alt_text/cache.py
Normal file
58
gen_photo_alt_text/cache.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""SQLite-backed cache for generated alt text."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class AltTextCache:
|
||||
"""Minimal cache with a SQLite backend."""
|
||||
|
||||
def __init__(self, db_path: str) -> None:
|
||||
self.db_path = db_path
|
||||
self._ensure_db()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _ensure_db(self) -> None:
|
||||
path = Path(self.db_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS alt_text (
|
||||
asset_id TEXT PRIMARY KEY,
|
||||
alt_text TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get(self, asset_id: str) -> Optional[str]:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT alt_text FROM alt_text WHERE asset_id = ?",
|
||||
(asset_id,),
|
||||
).fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
def set(self, asset_id: str, alt_text: str) -> None:
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO alt_text(asset_id, alt_text, updated_at)
|
||||
VALUES(?, ?, ?)
|
||||
ON CONFLICT(asset_id)
|
||||
DO UPDATE SET alt_text = excluded.alt_text, updated_at = excluded.updated_at
|
||||
""",
|
||||
(asset_id, alt_text, timestamp),
|
||||
)
|
||||
conn.commit()
|
||||
72
gen_photo_alt_text/config.py
Normal file
72
gen_photo_alt_text/config.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""Application configuration helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
DEFAULT_IMMICH_URL = "https://photos.4angle.com/"
|
||||
|
||||
|
||||
class ConfigError(RuntimeError):
|
||||
"""Raised when critical configuration is missing."""
|
||||
|
||||
|
||||
LEGACY_KEY_MAP = {
|
||||
"immich": "IMMICH_API_KEY",
|
||||
"openai": "OPENAI_API_KEY",
|
||||
}
|
||||
|
||||
|
||||
def _load_legacy_api_keys(path: Path) -> Dict[str, str]:
|
||||
"""Parse the legacy ``api_keys`` file if it exists."""
|
||||
|
||||
if not path.exists():
|
||||
return {}
|
||||
|
||||
values: Dict[str, str] = {}
|
||||
for line in path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, value = [piece.strip() for piece in line.split("=", 1)]
|
||||
mapped_key = LEGACY_KEY_MAP.get(key.lower())
|
||||
if mapped_key and value:
|
||||
values[mapped_key] = value
|
||||
return values
|
||||
|
||||
|
||||
def load_settings() -> Dict[str, str]:
|
||||
"""Load configuration from environment variables and ``api_keys``."""
|
||||
|
||||
load_dotenv()
|
||||
|
||||
settings: Dict[str, str] = {
|
||||
"IMMICH_API_URL": os.getenv("IMMICH_API_URL", DEFAULT_IMMICH_URL).rstrip("/"),
|
||||
"IMMICH_API_KEY": os.getenv("IMMICH_API_KEY", ""),
|
||||
"OPENAI_API_KEY": os.getenv("OPENAI_API_KEY", ""),
|
||||
"RECENT_DAYS": int(os.getenv("RECENT_DAYS", "3")),
|
||||
"OPENAI_MODEL": os.getenv("OPENAI_MODEL", "gpt-4o-mini"),
|
||||
"ALT_TEXT_DB": os.getenv("ALT_TEXT_DB", ""),
|
||||
"SECRET_KEY": os.getenv("SECRET_KEY", ""),
|
||||
}
|
||||
|
||||
legacy_values = _load_legacy_api_keys(Path("api_keys"))
|
||||
for key, value in legacy_values.items():
|
||||
if not settings.get(key):
|
||||
settings[key] = value
|
||||
|
||||
missing = [
|
||||
key for key in ("IMMICH_API_KEY", "OPENAI_API_KEY") if not settings.get(key)
|
||||
]
|
||||
if missing:
|
||||
raise ConfigError(
|
||||
"Missing required configuration values: " + ", ".join(missing)
|
||||
)
|
||||
|
||||
return settings
|
||||
214
gen_photo_alt_text/immich.py
Normal file
214
gen_photo_alt_text/immich.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"""Client helpers for talking to Immich."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class ImmichError(RuntimeError):
|
||||
"""Raised when the Immich API is unavailable or returns an error."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImmichAsset:
|
||||
"""Subset of metadata needed by the UI."""
|
||||
|
||||
id: str
|
||||
file_name: str
|
||||
captured_at: Optional[str]
|
||||
thumbnail_url: str
|
||||
preview_url: str
|
||||
original_url: str
|
||||
web_url: str
|
||||
latitude: Optional[float]
|
||||
longitude: Optional[float]
|
||||
location_label: Optional[str]
|
||||
|
||||
|
||||
class ImmichClient:
|
||||
"""Lightweight wrapper around the Immich REST API."""
|
||||
|
||||
def __init__(self, base_url: str, api_key: str) -> None:
|
||||
if not base_url:
|
||||
raise ValueError("base_url is required")
|
||||
if not api_key:
|
||||
raise ValueError("api_key is required")
|
||||
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
"x-api-key": api_key,
|
||||
"accept": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
||||
url = f"{self.base_url}{path}"
|
||||
timeout = kwargs.pop("timeout", 15)
|
||||
try:
|
||||
response = self.session.request(method, url, timeout=timeout, **kwargs)
|
||||
response.raise_for_status()
|
||||
if response.content:
|
||||
return response.json()
|
||||
return {}
|
||||
except (
|
||||
requests.RequestException
|
||||
) as exc: # pragma: no cover - network failure path
|
||||
raise ImmichError(str(exc)) from exc
|
||||
|
||||
def _parse_coordinate(self, value: Any) -> Optional[float]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _build_location_label(self, *sources: Dict[str, Any]) -> Optional[str]:
|
||||
parts: List[str] = []
|
||||
for source in sources:
|
||||
if not source:
|
||||
continue
|
||||
for key in ("city", "state", "country"):
|
||||
value = source.get(key)
|
||||
if value and value not in parts:
|
||||
parts.append(value)
|
||||
return ", ".join(parts) if parts else None
|
||||
|
||||
def _build_asset(self, asset_data: Dict[str, Any]) -> ImmichAsset:
|
||||
asset_id = asset_data.get("id")
|
||||
if not asset_id:
|
||||
raise ImmichError("Asset payload missing id")
|
||||
file_name = (
|
||||
asset_data.get("originalFileName") or asset_data.get("fileName") or "Photo"
|
||||
)
|
||||
captured_at = (
|
||||
asset_data.get("fileCreatedAt")
|
||||
or asset_data.get("createdAt")
|
||||
or asset_data.get("exifInfo", {}).get("dateTimeOriginal")
|
||||
)
|
||||
exif_info = asset_data.get("exifInfo") or {}
|
||||
position = asset_data.get("position") or asset_data.get("geolocation") or {}
|
||||
latitude = self._parse_coordinate(
|
||||
exif_info.get("latitude")
|
||||
or exif_info.get("gpsLatitude")
|
||||
or position.get("latitude")
|
||||
)
|
||||
longitude = self._parse_coordinate(
|
||||
exif_info.get("longitude")
|
||||
or exif_info.get("gpsLongitude")
|
||||
or position.get("longitude")
|
||||
)
|
||||
location_label = self._build_location_label(exif_info, position)
|
||||
|
||||
thumbnail_url = (
|
||||
f"{self.base_url}/api/assets/{asset_id}/thumbnail?size=thumbnail"
|
||||
)
|
||||
preview_url = f"{self.base_url}/api/assets/{asset_id}/thumbnail?size=preview"
|
||||
original_url = f"{self.base_url}/api/assets/{asset_id}/original"
|
||||
web_url = f"{self.base_url}/photos/{asset_id}"
|
||||
return ImmichAsset(
|
||||
id=asset_id,
|
||||
file_name=file_name,
|
||||
captured_at=captured_at,
|
||||
thumbnail_url=thumbnail_url,
|
||||
preview_url=preview_url,
|
||||
original_url=original_url,
|
||||
web_url=web_url,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
location_label=location_label,
|
||||
)
|
||||
|
||||
def get_recent_assets(self, days: int = 3, limit: int = 200) -> List[ImmichAsset]:
|
||||
"""Fetch assets created in the last ``days`` days."""
|
||||
|
||||
since = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
since_iso = since.isoformat().replace("+00:00", "Z")
|
||||
payload = {
|
||||
"size": limit,
|
||||
"page": 1,
|
||||
"orderBy": "takenAt",
|
||||
"orderDirection": "DESC",
|
||||
"metadata": {
|
||||
"types": ["IMAGE"],
|
||||
"takenAfter": since_iso,
|
||||
},
|
||||
}
|
||||
data = self._request("POST", "/api/search/metadata", json=payload)
|
||||
|
||||
def _normalize_items(value: Any) -> List[Any]:
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
for key in ("items", "results", "data", "assets"):
|
||||
nested = value.get(key)
|
||||
if isinstance(nested, list):
|
||||
return nested
|
||||
return []
|
||||
|
||||
items: List[Any] = []
|
||||
for key in ("items", "assets", "results", "data"):
|
||||
candidate = _normalize_items(data.get(key))
|
||||
if candidate:
|
||||
items = candidate
|
||||
break
|
||||
|
||||
assets: List[ImmichAsset] = []
|
||||
for item in items:
|
||||
asset_payload: Optional[Any] = None
|
||||
|
||||
if isinstance(item, dict):
|
||||
asset_payload = item.get("asset", item)
|
||||
if isinstance(asset_payload, str):
|
||||
asset_payload = {"id": asset_payload}
|
||||
elif isinstance(item, str):
|
||||
asset_payload = {"id": item}
|
||||
|
||||
if isinstance(asset_payload, dict):
|
||||
# Some responses only send the id; fetch missing metadata.
|
||||
if set(asset_payload.keys()) == {"id"}:
|
||||
assets.append(self.get_asset(asset_payload["id"]))
|
||||
else:
|
||||
assets.append(self._build_asset(asset_payload))
|
||||
|
||||
return assets
|
||||
|
||||
def get_asset(self, asset_id: str) -> ImmichAsset:
|
||||
"""Fetch a single asset."""
|
||||
|
||||
data = self._request("GET", f"/api/assets/{asset_id}")
|
||||
return self._build_asset(data)
|
||||
|
||||
def fetch_asset_content(self, asset_id: str, variant: str) -> Tuple[bytes, str]:
|
||||
"""Download binary image content for the requested variant."""
|
||||
|
||||
variant = variant.lower()
|
||||
if variant == "thumbnail":
|
||||
path = f"/api/assets/{asset_id}/thumbnail"
|
||||
params = {"size": "thumbnail"}
|
||||
elif variant == "preview":
|
||||
path = f"/api/assets/{asset_id}/thumbnail"
|
||||
params = {"size": "preview"}
|
||||
elif variant == "original":
|
||||
path = f"/api/assets/{asset_id}/original"
|
||||
params = None
|
||||
else:
|
||||
raise ImmichError(f"Unsupported asset variant: {variant}")
|
||||
|
||||
url = f"{self.base_url}{path}"
|
||||
try:
|
||||
response = self.session.get(url, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as exc: # pragma: no cover
|
||||
raise ImmichError(str(exc)) from exc
|
||||
|
||||
mime_type = response.headers.get("Content-Type", "application/octet-stream")
|
||||
return response.content, mime_type
|
||||
94
gen_photo_alt_text/openai_client.py
Normal file
94
gen_photo_alt_text/openai_client.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""Thin wrapper around the OpenAI API for generating alt text."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class AltTextGenerationError(RuntimeError):
|
||||
"""Raised when the OpenAI API cannot generate alt text."""
|
||||
|
||||
|
||||
class AltTextGenerator:
|
||||
"""Request alt text from a GPT-4o compatible OpenAI endpoint."""
|
||||
|
||||
def __init__(self, api_key: str, model: str = "gpt-4o-mini") -> None:
|
||||
if not api_key:
|
||||
raise ValueError("OPENAI_API_KEY is required")
|
||||
self.model = model
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
self.endpoint = "https://api.openai.com/v1/chat/completions"
|
||||
|
||||
def generate_alt_text(
|
||||
self,
|
||||
image_source: str,
|
||||
notes: Optional[str] = None,
|
||||
captured_at: Optional[str] = None,
|
||||
location: Optional[str] = None,
|
||||
coordinates: Optional[str] = None,
|
||||
) -> str:
|
||||
if not image_source:
|
||||
raise AltTextGenerationError("Image URL required for alt text generation")
|
||||
|
||||
prompt_lines = [
|
||||
"You write vivid but concise Mastodon alt text.",
|
||||
"Keep it under 400 characters and mention key visual details, colours, "
|
||||
"actions, and text. No need to mention the mood unless you think it is "
|
||||
"super relevant.",
|
||||
"Avoid speculation beyond what is visible. Use UK English spelling.",
|
||||
]
|
||||
if notes:
|
||||
prompt_lines.append(f"Creator notes: {notes.strip()}")
|
||||
if captured_at:
|
||||
prompt_lines.append(f"Captured: {captured_at}")
|
||||
if location:
|
||||
prompt_lines.append(f"Location: {location}")
|
||||
if coordinates:
|
||||
prompt_lines.append(f"Coordinates: {coordinates}")
|
||||
text_prompt = "\n".join(prompt_lines)
|
||||
|
||||
content: List[Dict[str, Any]] = [
|
||||
{"type": "text", "text": text_prompt},
|
||||
{"type": "image_url", "image_url": {"url": image_source}},
|
||||
]
|
||||
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"temperature": 0.2,
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You help write accessible alt text for social media posts.",
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": 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 AltTextGenerationError(str(exc)) from exc
|
||||
|
||||
choices = data.get("choices") or []
|
||||
if not choices:
|
||||
raise AltTextGenerationError("OpenAI response did not include choices")
|
||||
|
||||
message = choices[0].get("message", {})
|
||||
content_text = message.get("content")
|
||||
if not content_text:
|
||||
raise AltTextGenerationError("OpenAI response missing content")
|
||||
|
||||
return content_text.strip()
|
||||
165
gen_photo_alt_text/routes.py
Normal file
165
gen_photo_alt_text/routes.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"""Flask routes for the alt text generator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import datetime
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
Response,
|
||||
abort,
|
||||
current_app,
|
||||
flash,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
url_for,
|
||||
)
|
||||
|
||||
from .immich import ImmichError
|
||||
from .openai_client import AltTextGenerationError
|
||||
|
||||
bp = Blueprint("main", __name__)
|
||||
|
||||
|
||||
def _parse_timestamp(raw: str | None) -> datetime | None:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
normalized = raw.replace("Z", "+00:00")
|
||||
return datetime.fromisoformat(normalized)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def _timestamp_with_timezone(raw: str | None) -> str | None:
|
||||
dt = _parse_timestamp(raw)
|
||||
if not dt:
|
||||
return raw
|
||||
label = dt.strftime("%d %b %Y %H:%M")
|
||||
tzname = dt.tzname()
|
||||
if tzname:
|
||||
return f"{label} {tzname}"
|
||||
offset = dt.utcoffset()
|
||||
if offset is not None:
|
||||
total_minutes = int(offset.total_seconds() // 60)
|
||||
hours, minutes = divmod(abs(total_minutes), 60)
|
||||
sign = "+" if total_minutes >= 0 else "-"
|
||||
return f"{label} UTC{sign}{hours:02d}:{minutes:02d}"
|
||||
return f"{label} (timezone unknown)"
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
def index():
|
||||
immich_client = current_app.immich_client
|
||||
alt_cache = current_app.alt_text_cache
|
||||
days = current_app.config.get("RECENT_DAYS", 3)
|
||||
|
||||
assets = []
|
||||
error_message = None
|
||||
try:
|
||||
for asset in immich_client.get_recent_assets(days=days):
|
||||
assets.append(
|
||||
{
|
||||
"id": asset.id,
|
||||
"file_name": asset.file_name,
|
||||
"captured_at": asset.captured_at,
|
||||
"captured_display": _humanize_timestamp(asset.captured_at),
|
||||
"thumbnail_url": asset.thumbnail_url,
|
||||
"alt_text": alt_cache.get(asset.id),
|
||||
}
|
||||
)
|
||||
except ImmichError as exc:
|
||||
error_message = str(exc)
|
||||
|
||||
return render_template("index.html", assets=assets, error_message=error_message)
|
||||
|
||||
|
||||
@bp.route("/assets/<asset_id>", methods=["GET", "POST"])
|
||||
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 = ""
|
||||
|
||||
try:
|
||||
asset = immich_client.get_asset(asset_id)
|
||||
except ImmichError as exc:
|
||||
return render_template(
|
||||
"detail.html",
|
||||
asset=None,
|
||||
alt_text=None,
|
||||
error_message=str(exc),
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
alt_text = alt_cache.get(asset_id)
|
||||
|
||||
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,
|
||||
)
|
||||
alt_cache.set(asset_id, generated)
|
||||
flash("Alt text generated.")
|
||||
return redirect(url_for("main.asset_detail", asset_id=asset_id))
|
||||
except ImmichError as exc:
|
||||
error_message = f"Failed to fetch image: {exc}"
|
||||
except AltTextGenerationError as exc:
|
||||
error_message = str(exc)
|
||||
|
||||
formatted_asset = {
|
||||
"id": asset.id,
|
||||
"file_name": asset.file_name,
|
||||
"captured_at": asset.captured_at,
|
||||
"captured_display": _humanize_timestamp(asset.captured_at),
|
||||
"thumbnail_url": asset.thumbnail_url,
|
||||
"preview_url": asset.preview_url,
|
||||
"original_url": asset.original_url,
|
||||
"web_url": asset.web_url,
|
||||
"latitude": asset.latitude,
|
||||
"longitude": asset.longitude,
|
||||
"location": asset.location_label,
|
||||
}
|
||||
|
||||
return render_template(
|
||||
"detail.html",
|
||||
asset=formatted_asset,
|
||||
alt_text=alt_cache.get(asset_id),
|
||||
error_message=error_message,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/proxy/assets/<asset_id>/<variant>")
|
||||
def asset_proxy(asset_id: str, variant: str):
|
||||
immich_client = current_app.immich_client
|
||||
try:
|
||||
content, mimetype = immich_client.fetch_asset_content(asset_id, variant)
|
||||
except ImmichError as exc:
|
||||
abort(404, description=str(exc))
|
||||
return Response(content, mimetype=mimetype)
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Flask>=3.0,<4
|
||||
requests>=2.31,<3
|
||||
python-dotenv>=1.0,<2
|
||||
119
static/styles.css
Normal file
119
static/styles.css
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background-color: #111;
|
||||
color: #f8f9fa;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: #111;
|
||||
color: #f8f9fa;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: min(1200px, 90vw);
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
background: #202020;
|
||||
border-bottom: 1px solid #2d2d2d;
|
||||
}
|
||||
|
||||
.site-header a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.flash-messages {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.flash {
|
||||
background: #1f3b2f;
|
||||
border: 1px solid #335843;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #401f1f;
|
||||
border: 1px solid #6b2727;
|
||||
padding: 0.8rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.asset-card {
|
||||
background: #1b1b1b;
|
||||
border: 1px solid #2d2d2d;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.asset-card img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #2d2d2d;
|
||||
background: #0b0b0b;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.85rem;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
.alt-text {
|
||||
font-size: 0.9rem;
|
||||
color: #e1e1e1;
|
||||
}
|
||||
|
||||
.asset-detail {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 2rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.asset-detail img {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #2d2d2d;
|
||||
}
|
||||
|
||||
.notes-form textarea {
|
||||
width: 100%;
|
||||
margin-top: 0.4rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #3b3b3b;
|
||||
padding: 0.6rem;
|
||||
background: #121212;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.notes-form button {
|
||||
background: #2b74ff;
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 0.6rem 1.4rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notes-form button:hover {
|
||||
background: #1f5fe0;
|
||||
}
|
||||
28
templates/base.html
Normal file
28
templates/base.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Immich Alt Text Helper</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="container">
|
||||
<h1><a href="{{ url_for('main.index') }}">Immich Alt Text Helper</a></h1>
|
||||
</div>
|
||||
</header>
|
||||
<main class="container">
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<div class="flash-messages">
|
||||
{% for message in messages %}
|
||||
<div class="flash">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
52
templates/detail.html
Normal file
52
templates/detail.html
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% if not asset %}
|
||||
<div class="error">{{ error_message }}</div>
|
||||
{% else %}
|
||||
<section class="asset-detail">
|
||||
<div class="image">
|
||||
<img src="{{ url_for('main.asset_proxy', asset_id=asset.id, variant='preview') }}" alt="{{ asset.file_name }}">
|
||||
</div>
|
||||
<div class="details">
|
||||
<h2>{{ asset.file_name }}</h2>
|
||||
{% if asset.captured_display %}
|
||||
<p class="date">Captured: {{ asset.captured_display }}</p>
|
||||
{% endif %}
|
||||
{% if alt_text %}
|
||||
<div class="alt-text-block">
|
||||
<h3>Current alt text</h3>
|
||||
<p>{{ alt_text }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if asset.location or (asset.latitude and asset.longitude) %}
|
||||
<div class="location-block">
|
||||
<h3>Location</h3>
|
||||
{% if asset.location %}
|
||||
<p>{{ asset.location }}</p>
|
||||
{% endif %}
|
||||
{% if asset.latitude and asset.longitude %}
|
||||
<p class="coords">{{ "%.5f"|format(asset.latitude) }}, {{ "%.5f"|format(asset.longitude) }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if error_message %}
|
||||
<div class="error">{{ error_message }}</div>
|
||||
{% endif %}
|
||||
<form method="post" class="notes-form">
|
||||
<label for="notes">Optional notes for the model</label>
|
||||
<textarea id="notes" name="notes" rows="4" placeholder="Mention any extra context you want included.">{{ notes }}</textarea>
|
||||
<div class="actions">
|
||||
<button type="submit">Generate alt text</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="links">
|
||||
<a href="{{ url_for('main.asset_proxy', asset_id=asset.id, variant='original') }}"
|
||||
target="_blank" rel="noopener">Download original</a>
|
||||
|
|
||||
<a href="{{ asset.web_url }}" target="_blank" rel="noopener">View on Immich</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
38
templates/index.html
Normal file
38
templates/index.html
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="intro">
|
||||
<p>Choose a recent Immich photo to request Mastodon-friendly alt text.</p>
|
||||
</section>
|
||||
|
||||
{% if error_message %}
|
||||
<div class="error">{{ error_message }}</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="grid">
|
||||
{% if assets %}
|
||||
{% for asset in assets %}
|
||||
<article class="asset-card">
|
||||
<a href="{{ url_for('main.asset_detail', asset_id=asset.id) }}" class="thumbnail">
|
||||
<img src="{{ url_for('main.asset_proxy', asset_id=asset.id, variant='thumbnail') }}"
|
||||
alt="{{ asset.file_name }}">
|
||||
</a>
|
||||
<div class="meta">
|
||||
{% if asset.captured_display %}
|
||||
<div class="date">{{ asset.captured_display }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="alt-text">
|
||||
{% if asset.alt_text %}
|
||||
{{ asset.alt_text }}
|
||||
{% else %}
|
||||
<em>No alt text yet</em>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p>No recent assets were returned.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
Loading…
Reference in a new issue