signal-bot/signal_bot/pack_ledger.py
Krzysztof kuhy Rudnicki 9d546fef9a feat: import a folder of photos without importing it twice
tool/tagger.py did not run at all: 48957a1 renamed pack.default_dir to
default_dirs and missed the caller, so the tagger raised ImportError at
module scope through two releases. Nothing could have caught it -- tool/ is
outside --cov and mypy ran with [signal_bot, fake_signal]. Both are widened
here, and tests/test_tool_imports.py imports every script under tool/.

--import was a filename check and shutil.copy2. Pointed at a phone export
that overlaps the album it re-imported the same photo under whatever name
the exporter used that time. It now identifies files by magic bytes, strips
them through the same media_strip path !add_photo uses, and dedups on exact
bytes twice: the source hash, then -- only for what survives -- the hash
after stripping. sources.json records both, so a second run on the same
folder is a no-op. What the album refuses is moved to _skipped/<reason>/
beside the source, never deleted.

Two things that made "strip on import" impossible until now:

media_tags claims -all= zeroes the QuickTime date tags and allows them by
value. It does not; measured, it leaves CreateDate at its real value, and
every mp4 was being refused. -time:all= is what zeroes them.

And a tag allowlist cannot see the worst class at all. IMG_6924.mp4 carries
mebx timed-metadata holding per-frame face bounding boxes and a face id,
and those survive a complete tag strip because they live in mdat as opaque
samples. media_tracks.py drops the track with ffmpeg and verifies with
ffprobe that only video and audio survived. This is not the ffmpeg mistake
media_strip warns about: that was ffmpeg replacing exiftool for tags, this
is track removal with exiftool still doing every tag, and the gate is a
positive read-back rather than an exit code.

The roster was write-once. The setup form only appeared on an empty album,
Album.setup overwrote the file whole, and keys were positional (p{len+1}),
so inserting somebody renumbered everyone after them and repointed every
tags.json entry at the wrong person. Keys now come from the highest number
in use, the form merges, and a person can be added mid-session.

--untagged filters the page and never the server's list. /media/{index} and
/api/tag both index the full album, so a shorter list server-side would
show item i of the album while tagging item i of the filtered set.

Measured on the live album: 174 source files -> 1 not media, 82 already in
pack, 24 duplicate in batch, 67 imported, and re-running imports nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBrE1b9HvFw3xvG9bJiDFt
2026-08-31 13:22:57 +02:00

161 lines
5.8 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""What the album has already seen, so importing the same folder twice is free.
Two hashes per stored file, because stripping changes the bytes:
* ``source`` -- SHA-256 of the file as it arrived, *before* metadata was
removed. This is the one that answers "have I imported this photo before?"
when the same export folder is pointed at the album a second time.
* ``stored`` -- SHA-256 of the bytes on disk now. This is the one that answers
"is the photo somebody just uploaded from the chat already here?", because
``!add_photo`` hashes *after* stripping (pack_store.content_name).
Neither alone is enough. A photo that arrived through ``--import`` and the same
photo re-sent through Signal have different source bytes -- the phone re-encodes
-- but strip to the same output; a photo re-imported from the same folder has
identical source bytes but restripping is not guaranteed byte-for-byte across
exiftool versions. Recording both makes each case a lookup.
``source`` is None when the original bytes are unrecoverable, which is exactly
the ``!add_photo`` files: their stored name is a hash of the *stripped* payload
and the upload was never kept.
Unlike pack_load, nothing here ever raises on a bad file. This ledger is an
optimisation and a safety net, not a manifest: ``pack_load`` failing at startup
is a restart loop under ``Restart=always`` (see pack_holder.reload, which
deliberately never raises), and a corrupt ledger degrading to "I have seen
nothing" costs a duplicate photo, while a corrupt ledger taking startup down
costs the bot.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Final
from signal_bot.pack import MEDIA_DIR
from signal_bot.pack_store import ADDS_FILE, write_json
if TYPE_CHECKING:
from collections.abc import Mapping
from pathlib import Path
LEDGER_FILE: Final = "sources.json"
@dataclass(frozen=True, slots=True)
class Entry:
"""The two hashes one stored file is known by."""
# The bytes as they arrived, or None when they were never kept.
source: str | None
# The bytes on disk now.
stored: str
def digest(payload: bytes) -> str:
"""The hash this module identifies bytes by."""
return hashlib.sha256(payload).hexdigest()
def read(directory: Path) -> dict[str, Entry]:
"""The ledger, or an empty one for anything unreadable or malformed.
Args:
directory: The album directory.
Returns:
Stored filename to its entry. Malformed individual entries are
dropped rather than failing the whole read: a hand-edited file
should cost the entries somebody broke, not all of them.
"""
entries: dict[str, Entry] = {}
for name, value in _document(directory / LEDGER_FILE).items():
entry = _entry(value)
if entry is not None:
entries[name] = entry
return entries
def write(directory: Path, entries: Mapping[str, Entry]) -> None:
"""Replace the ledger atomically, 0600, the way every manifest is written."""
document = {
name: {"source": entry.source, "stored": entry.stored}
for name, entry in sorted(entries.items())
}
write_json(directory / LEDGER_FILE, document)
def source_hashes(entries: Mapping[str, Entry]) -> set[str]:
"""Every known pre-strip hash, for "did this file arrive here before?"."""
return {entry.source for entry in entries.values() if entry.source is not None}
def stored_hashes(entries: Mapping[str, Entry]) -> set[str]:
"""Every known on-disk hash, for "is this photo already in the album?"."""
return {entry.stored for entry in entries.values()}
def backfill(directory: Path) -> dict[str, Entry]:
"""Record every file already in media/ that the ledger does not know.
Run this **before** any retroactive strip. The files that arrived through
``tool/tagger.py --import`` were copied verbatim (shutil.copy2), so their
current bytes *are* their source bytes -- the one and only moment that
fact is recoverable. Strip first and it is gone, and re-importing the
same export folder next year silently re-adds all of them.
The ``!add_photo`` files are told apart by ``adds.json``, which is the
exact record of what came in through the chat. Those were stripped on the
way in, so their source bytes never existed on disk and stay None.
Args:
directory: The album directory.
Returns:
The merged ledger, already written.
"""
entries = read(directory)
from_chat = set(_document(directory / ADDS_FILE))
album = directory / MEDIA_DIR
for path in sorted(album.iterdir()) if album.is_dir() else ():
if not path.is_file() or path.name in entries:
continue
stored = digest(path.read_bytes())
entries[path.name] = Entry(
source=None if path.name in from_chat else stored,
stored=stored,
)
write(directory, entries)
return entries
def _entry(value: object) -> Entry | None:
"""One ledger row, or None when it is not shaped like one."""
if not isinstance(value, dict):
return None
stored = value.get("stored")
source = value.get("source")
if not isinstance(stored, str) or not isinstance(source, str | None):
return None
return Entry(source=source, stored=stored)
def _document(path: Path) -> dict[str, Any]:
"""One JSON object, or an empty one for anything unreadable.
Forgiving on purpose -- see the module docstring. This is the same policy
pack_store._document has for adds.json, and the opposite of pack_load's.
"""
try:
raw = path.read_bytes()
except OSError:
return {}
try:
document = json.loads(raw)
except json.JSONDecodeError:
return {}
return document if isinstance(document, dict) else {}