mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:47:17 +01:00
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
161 lines
5.8 KiB
Python
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 {}
|