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