signal-bot/DOCS-add-photo.md
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

142 lines
7.2 KiB
Markdown

# Adding a photo from the chat: `!add_photo`
How a member of an album's group puts a photo into it by sending one, and
what keeps that path from leaking anything. The album itself — where it
lives, what its manifests look like, how several albums coexist — is
`DOCS-photo-pack.md`.
Send a photo captioned `!add_photo alfa wakacje2019` in one of the album's
groups. `alfa` resolves to that person's key; `wakacje2019` matches nobody and
is minted as a custom tag. The photo is retrievable through `!alfa` and
`!random wakacje2019` **immediately**, with no restart.
What holds:
- **No typed label travels.** Labels become keys in the registry's
`arg_resolver`, at the dispatcher, *before* the line that logs the command
name. The reply is counts and pseudonyms — `dodano · p1: 4 · t1: 1`.
- **No cap.** No cooldown, no size limit, no daily limit. `!random` takes the
ordinary pack cooldown; `!add_photo` takes no decorator at all.
- **At most six labels**, the same ceiling `!alfa beta …` has.
- **The attachment must be on the same message**, never a quoted one: a reply
carries the *quoted* message's media in `quote.attachments`, and taking it
would let anyone re-file somebody else's photo.
- **A duplicate changes nothing.** The stored name is a content hash, so a
photo already in the album is reported and skipped; a mixed batch stores the
new files and says how many were already there.
- **Nothing is written until it works.** Media, `tags.json`, `custom.json` and
`adds.json` are written, the album is reloaded, and only a load that
succeeds is published. A load that fails rolls all four back and keeps
serving the album that was already working.
- **The second copy goes.** signal-cli keeps its own copy of every inbound
attachment outside the album's `0700`. `!add_photo` deletes it once the file
is stored, and also when the upload was refused.
### Stripping: exiftool, with a positive check
`ffmpeg -map_metadata -1 -c copy` was tried first and **fails silently on
photos**: given a JPEG with a real APP1 Exif segment it returned a
byte-identical file, GPS marker intact, and exited 0. `-c copy` passes the
compressed frame through as an opaque packet and a JPEG's Exif lives inside
it. Do not reintroduce ffmpeg for this.
So the gate is a **positive check on the output**, never an exit code:
```sh
exiftool -all= -time:all= -o <dst> <src> # never --overwrite_original
exiftool -G0 -s -all <dst> # then read it back
```
`-time:all=` is **not** redundant with `-all=`. `media_tags.ZEROED_DATE_TAGS`
allows the QuickTime date tags "by value" on the grounds that `-all=` zeroes
rather than deletes them. Measured 2026-08-31: it does not. `-all=` alone left
`QuickTime:CreateDate` at its real value, and the consequence was that **every
mp4 was refused** — the code documented behaviour it did not get. `-time:all=`
is what actually zeroes them.
and every surviving tag must be **structural**. Measured against exiftool
13.55, the plan's original "the surviving set must be empty" would have
rejected every format except JPEG — a valid PNG always reports its IHDR under
`PNG`, a GIF its screen descriptor under `GIF`, an MP4 some fifty `QuickTime`
tags that *are* the container. So the allowlist is per **tag name**
(`signal_bot/media_tags.py`), which keeps the property that matters: an atom
nobody anticipated arrives as a name that is not on the list, and the upload
is refused.
Allowlist, not blocklist, for the reason a naive gate misses: an MP4 geotag is
a QuickTime `udta`/`©xyz` atom reported under **`QuickTime`**, not `GPS`.
### Tracks: ffmpeg, for what no tag allowlist can see
A tag allowlist is structurally blind to one class, and it is the worst one.
`IMG_6924.mp4` in the live album carries two `mebx` timed-metadata tracks, and
after a *complete* `-all= -time:all=` pass its bytes still contain:
```
com.apple.quicktime.detected-face
com.apple.quicktime.detected-face.bounds
com.apple.quicktime.detected-face.face-id
com.apple.quicktime.detected-face.roll-angle / .yaw-angle
```
Per-frame face bounding boxes and a face identity number. exiftool is not
broken for missing them: `mebx` samples live in `mdat` as opaque payload, not
as tags, so **there is no tag to refuse**. The only answer is to drop the
track, which `signal_bot/media_tracks.py` does before exiftool runs:
```sh
ffmpeg -v error -i <src> -map 0:v -map "0:a?" -c copy \
-map_metadata -1 -map_chapters -1 -y <dst>
ffprobe -v error -show_entries stream=codec_type -of csv=p=0 <dst>
```
and every surviving stream must be `video` or `audio`.
**This is not the ffmpeg mistake above.** That one was ffmpeg replacing
exiftool for *tag* removal, where it silently failed on JPEG. Here ffmpeg
removes *tracks*, exiftool still does every tag removal afterwards, and the
gate is a positive read-back of which streams survived rather than an exit
code. `-c copy` is safe for this job precisely because nothing is being
claimed about the frames — the claim is about which tracks exist, and ffprobe
answers that directly. Do not collapse the two jobs back together.
`QuickTime:MetaFormat` is deliberately **absent** from the allowlist: a file
still reporting one has a metadata track the re-mux should have removed, and
refusing it is the correct outcome.
**Both entry points obey this.** `tool/tagger.py --import` used to be a
verbatim `shutil.copy2`; since 2026-08-31 it runs the same stripper, so the
guarantee on this page covers the whole album and not just the chat path.
The list includes the **codec's own configuration** — `CompressorName`,
`VendorID`, and the HEVC parameter set (`HEVCConfigurationVersion`,
`GeneralProfileIDC`, `BitDepthLuma`, …). That was forced by measurement, not
chosen for convenience: with them off the list exiftool 13.55 could produce no
acceptable video *and* no acceptable HEIC, because `CompressorName` lives in
the `avc1` sample description and exiftool reports it as not writable at all
(`Sorry, QuickTime:CompressorName doesn't exist or isn't writable`), and a real
HEIC reports sixteen HEVC config tags. Since HEIC is the iPhone default, the
choice was codec parameters or no phone photos. They describe how the picture
is encoded, never where it was taken or who took it; every location and
identity tag is still refused, and `scripts/e2e.sh` proves a QuickTime GPS atom
does not survive on a file the album actually stored.
Formats are decided by **magic bytes**, never by the filename: jpg, png, gif,
webp, mp4, mov, m4v, heic, heif, avif, webm and svg. Every one of them must
also be in `pack.MEDIA_TYPES`, or a file that uploaded cleanly is refused by
the *next* load and takes startup down.
### ⚠️ .webm and .svg are stored unstripped
exiftool 13.55 can write neither (`exiftool -listwf` lists no `WEBM` and no
`SVG`; a strip attempt returns `Can't write SVG files`). Its SVG *reader* does
not surface `<metadata>` content either, so there is no detection to fall back
on.
kuhy's decision of 2026-08-31 is to keep both on the allowlist and store them
**as they arrive, metadata included**. The consequence, stated plainly: an
`.svg` carrying `<metadata>` or an embedded
`<image xlink:href="data:image/jpeg;base64,…">` keeps whatever is in it, and
every later `!alfa` re-sends that file verbatim. Every other format on the
list is stripped and verified.