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

View file

@ -27,12 +27,17 @@ def create_app() -> Flask:
secret_key = app.config.get("SECRET_KEY") or "dev-secret-key" secret_key = app.config.get("SECRET_KEY") or "dev-secret-key"
app.config["SECRET_KEY"] = secret_key app.config["SECRET_KEY"] = secret_key
db_path = app.config.get("ALT_TEXT_DB") instance_dir = Path(app.instance_path)
if not db_path: instance_dir.mkdir(parents=True, exist_ok=True)
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) 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( app.immich_client = ImmichClient(
base_url=app.config["IMMICH_API_URL"], base_url=app.config["IMMICH_API_URL"],

View file

@ -5,7 +5,7 @@ from __future__ import annotations
import sqlite3 import sqlite3
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Dict, Iterable, Optional
class AltTextCache: 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() conn.commit()
def get(self, asset_id: str) -> Optional[str]: def get(self, asset_id: str) -> Optional[str]:
@ -57,3 +66,31 @@ class AltTextCache:
(asset_id, alt_text, timestamp), (asset_id, alt_text, timestamp),
) )
conn.commit() 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() load_dotenv()
db_path = os.getenv("STATION_DB", "")
settings: Dict[str, str] = { settings: Dict[str, str] = {
"IMMICH_API_URL": os.getenv("IMMICH_API_URL", DEFAULT_IMMICH_URL).rstrip("/"), "IMMICH_API_URL": os.getenv("IMMICH_API_URL", DEFAULT_IMMICH_URL).rstrip("/"),
"IMMICH_API_KEY": os.getenv("IMMICH_API_KEY", ""), "IMMICH_API_KEY": os.getenv("IMMICH_API_KEY", ""),
"OPENAI_API_KEY": os.getenv("OPENAI_API_KEY", ""), "OPENAI_API_KEY": os.getenv("OPENAI_API_KEY", ""),
"RECENT_DAYS": int(os.getenv("RECENT_DAYS", "3")), "RECENT_DAYS": int(os.getenv("RECENT_DAYS", "3")),
"OPENAI_MODEL": os.getenv("OPENAI_MODEL", "gpt-4o-mini"), "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", ""), "SECRET_KEY": os.getenv("SECRET_KEY", ""),
"MASTODON_BASE_URL": os.getenv( "MASTODON_BASE_URL": os.getenv(
"MASTODON_BASE_URL", "http://localhost:3000" "MASTODON_BASE_URL", "http://localhost:3000"

View file

@ -205,14 +205,21 @@ def compose_select():
except ImmichError as exc: except ImmichError as exc:
error_message = str(exc) error_message = str(exc)
usage_map = alt_cache.get_usage_map(asset.id for asset in asset_objs)
assets = [] assets = []
for asset in asset_objs: for asset in asset_objs:
usage = usage_map.get(asset.id)
assets.append( assets.append(
{ {
"id": asset.id, "id": asset.id,
"file_name": asset.file_name, "file_name": asset.file_name,
"captured_display": _humanize_timestamp(asset.captured_at), "captured_display": _humanize_timestamp(asset.captured_at),
"alt_text": alt_cache.get(asset.id), "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 post_text.strip(), media_ids, in_reply_to_id=reply_id
) )
status_url = status.get("url") status_url = status.get("url")
for asset in assets:
alt_cache.mark_posted(asset.id, status_url)
if status_url: if status_url:
flash( flash(
f'Post sent to Mastodon: <a href="{status_url}" target="_blank" rel="noopener">{status_url}</a>', 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) content, mimetype = immich_client.fetch_asset_content(asset_id, variant)
except ImmichError as exc: except ImmichError as exc:
abort(404, description=str(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>
<div class="select-grid"> <div class="select-grid">
{% for asset in assets %} {% 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 %}> <input type="checkbox" name="asset_ids" value="{{ asset.id }}" {% if selected_ids and asset.id in selected_ids %}checked{% endif %}>
<div class="thumbnail"> <div class="thumbnail">
<img src="{{ url_for('main.asset_proxy', asset_id=asset.id, variant='thumbnail') }}" alt="{{ asset.file_name }}"> <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 %} {% if asset.alt_text %}
<div class="alt-text-sample">{{ asset.alt_text }}</div> <div class="alt-text-sample">{{ asset.alt_text }}</div>
{% endif %} {% endif %}
{% if asset.posted %}
<div class="usage">Posted{% if asset.last_posted_display %} {{ asset.last_posted_display }}{% endif %}</div>
{% endif %}
</div> </div>
</label> </label>
{% endfor %} {% endfor %}