Track posted photos. Cache images.

This commit is contained in:
Edward Betts 2025-11-15 19:24:59 +00:00
parent 9fbe130841
commit dc32cc7222
7 changed files with 78 additions and 10 deletions

View file

@ -30,4 +30,4 @@ Station Announcer is a small Flask app that keeps your Immich library and Mastod
* Hit **Improve text with ChatGPT** as many times as you like; the model polishes grammar, tone, and UK English spellings while respecting your extra notes.
* When ready, choose **Post to Mastodon** to upload the selected media (with their alt text) and publish the refined caption (optionally as a reply) via the configured access token.
The app stores alt text in `instance/alt_text_cache.db`. Remove this file to clear the cache.
The app stores cached data in `instance/station_announcer.db` (or wherever `STATION_DB` points). Remove this file to clear the cache.

View file

@ -222,6 +222,11 @@ body {
cursor: pointer;
}
.select-card.used {
background: #122542;
border-color: #2b4c7a;
}
.select-card input[type=\"checkbox\"] {
width: 1rem;
height: 1rem;
@ -242,6 +247,11 @@ body {
overflow: hidden;
}
.usage {
font-size: 0.8rem;
color: #8dc5ff;
}
.compose-form {
display: flex;
flex-direction: column;

View file

@ -27,12 +27,17 @@ def create_app() -> Flask:
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
instance_dir = Path(app.instance_path)
instance_dir.mkdir(parents=True, exist_ok=True)
Path(app.instance_path).mkdir(parents=True, exist_ok=True)
db_path = app.config.get("STATION_DB")
if not db_path:
old_db = instance_dir / "alt_text_cache.db"
new_db = instance_dir / "station_announcer.db"
if old_db.exists() and not new_db.exists():
old_db.rename(new_db)
db_path = str(new_db)
app.config["STATION_DB"] = db_path
app.immich_client = ImmichClient(
base_url=app.config["IMMICH_API_URL"],

View file

@ -5,7 +5,7 @@ from __future__ import annotations
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from typing import Dict, Iterable, Optional
class AltTextCache:
@ -33,6 +33,15 @@ class AltTextCache:
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS asset_usage (
asset_id TEXT PRIMARY KEY,
last_posted_at TEXT NOT NULL,
last_post_url TEXT
)
"""
)
conn.commit()
def get(self, asset_id: str) -> Optional[str]:
@ -57,3 +66,31 @@ class AltTextCache:
(asset_id, alt_text, timestamp),
)
conn.commit()
def mark_posted(self, asset_id: str, post_url: Optional[str] = None) -> None:
timestamp = datetime.now(timezone.utc).isoformat()
with self._connect() as conn:
conn.execute(
"""
INSERT INTO asset_usage(asset_id, last_posted_at, last_post_url)
VALUES(?, ?, ?)
ON CONFLICT(asset_id)
DO UPDATE SET last_posted_at = excluded.last_posted_at,
last_post_url = excluded.last_post_url
""",
(asset_id, timestamp, post_url),
)
conn.commit()
def get_usage_map(self, asset_ids: Iterable[str]) -> Dict[str, sqlite3.Row]:
ids = [asset_id for asset_id in asset_ids if asset_id]
if not ids:
return {}
placeholders = ",".join(["?"] * len(ids))
query = (
"SELECT asset_id, last_posted_at, last_post_url FROM asset_usage"
f" WHERE asset_id IN ({placeholders})"
)
with self._connect() as conn:
rows = conn.execute(query, ids).fetchall()
return {row["asset_id"]: row for row in rows}

View file

@ -19,13 +19,15 @@ def load_settings() -> Dict[str, str]:
load_dotenv()
db_path = os.getenv("STATION_DB", "")
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", ""),
"STATION_DB": db_path,
"SECRET_KEY": os.getenv("SECRET_KEY", ""),
"MASTODON_BASE_URL": os.getenv(
"MASTODON_BASE_URL", "http://localhost:3000"

View file

@ -205,14 +205,21 @@ def compose_select():
except ImmichError as exc:
error_message = str(exc)
usage_map = alt_cache.get_usage_map(asset.id for asset in asset_objs)
assets = []
for asset in asset_objs:
usage = usage_map.get(asset.id)
assets.append(
{
"id": asset.id,
"file_name": asset.file_name,
"captured_display": _humanize_timestamp(asset.captured_at),
"alt_text": alt_cache.get(asset.id),
"posted": bool(usage),
"last_posted_display": _humanize_timestamp(
usage["last_posted_at"] if usage else None
),
}
)
@ -369,6 +376,8 @@ def compose_draft():
post_text.strip(), media_ids, in_reply_to_id=reply_id
)
status_url = status.get("url")
for asset in assets:
alt_cache.mark_posted(asset.id, status_url)
if status_url:
flash(
f'Post sent to Mastodon: <a href="{status_url}" target="_blank" rel="noopener">{status_url}</a>',
@ -407,4 +416,6 @@ def asset_proxy(asset_id: str, variant: str):
content, mimetype = immich_client.fetch_asset_content(asset_id, variant)
except ImmichError as exc:
abort(404, description=str(exc))
return Response(content, mimetype=mimetype)
response = Response(content, mimetype=mimetype)
response.headers.setdefault("Cache-Control", "public, max-age=3600")
return response

View file

@ -18,7 +18,7 @@
</div>
<div class="select-grid">
{% for asset in assets %}
<label class="select-card">
<label class="select-card {% if asset.posted %}used{% endif %}">
<input type="checkbox" name="asset_ids" value="{{ asset.id }}" {% if selected_ids and asset.id in selected_ids %}checked{% endif %}>
<div class="thumbnail">
<img src="{{ url_for('main.asset_proxy', asset_id=asset.id, variant='thumbnail') }}" alt="{{ asset.file_name }}">
@ -31,6 +31,9 @@
{% if asset.alt_text %}
<div class="alt-text-sample">{{ asset.alt_text }}</div>
{% endif %}
{% if asset.posted %}
<div class="usage">Posted{% if asset.last_posted_display %} {{ asset.last_posted_display }}{% endif %}</div>
{% endif %}
</div>
</label>
{% endfor %}