signal-bot/signal_bot/media_tracks.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

152 lines
5.4 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Dropping the tracks a video carries besides its picture and its sound.
An iPhone video is not one stream. Measured on a clip already sitting in the
album, ``IMG_6924.mp4`` carries two ``mebx`` timed-metadata tracks, and after a
*complete* ``exiftool -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
com.apple.quicktime.detected - face.yaw - angle
Per-frame face bounding boxes and a face identity number -- biometric data
about the people in a private album. exiftool cannot remove it and is not
broken for failing to: ``mebx`` samples live in ``mdat`` as opaque payload, not
in a tag. A tag allowlist is structurally incapable of seeing this class, which
is why removing the whole track is the only answer.
**This is not the ffmpeg mistake media_strip.py warns about.** That warning is
about using ffmpeg to remove *tags*, where ``-map_metadata -1 -c copy`` returned
a byte-identical JPEG with its GPS marker intact and exited 0. Here ffmpeg
removes *tracks*, exiftool still does every tag removal afterwards, and the
check below is a positive read-back of which streams survived rather than an
exit code. Do not collapse the two jobs back together.
``-c copy`` is safe for this job precisely because nothing is being claimed
about the frames: the assertion is about which tracks exist, and ffprobe
answers that directly.
"""
from __future__ import annotations
import logging
import shutil
import tempfile
from pathlib import Path
from typing import Final
from signal_bot.external import run
logger = logging.getLogger(__name__)
FFMPEG: Final = "ffmpeg"
FFPROBE: Final = "ffprobe"
# The containers that can carry a metadata track at all. Everything else the
# album accepts is a still image and goes straight past this module.
ISO_SUFFIXES: Final = frozenset({".mp4", ".mov", ".m4v"})
# The only stream kinds a stored file may have once it is flattened.
KEPT: Final = frozenset({"video", "audio"})
# Same reasoning as media_strip.FALLBACK_PATHS: a systemd unit's PATH is
# "/usr/local/bin:/usr/bin" and nothing else.
FALLBACK_DIRS: Final = ("/usr/local/bin", "/usr/bin", "/opt/homebrew/bin")
TIMEOUT_SECONDS: Final = 120
def locate(binary: str) -> str | None:
"""One tool's absolute path, or None when it is not installed."""
found = shutil.which(binary)
if found is not None:
return found
candidates = (Path(directory) / binary for directory in FALLBACK_DIRS)
return next((str(path) for path in candidates if path.is_file()), None)
def flatten(payload: bytes, suffix: str) -> bytes | None:
"""Re-mux one video keeping only its picture and sound.
Args:
payload: The file exactly as it arrived.
suffix: The sniffed suffix, which decides the container ffmpeg writes.
Returns:
The re-muxed file, or None when either tool is missing, ffmpeg fails,
or the result still reports a stream that is not video or audio.
"""
ffmpeg = locate(FFMPEG)
ffprobe = locate(FFPROBE)
if ffmpeg is None or ffprobe is None:
logger.error("ffmpeg/ffprobe not found; refusing to store %s", suffix)
return None
with tempfile.TemporaryDirectory(prefix="signal-bot-tracks-") as workspace:
return _run(Path(workspace), payload, suffix, ffmpeg, ffprobe)
def _run(workspace: Path, payload: bytes, suffix: str, ffmpeg: str, ffprobe: str) -> bytes | None:
"""Do the re-mux inside a temp directory that is removed either way."""
source = workspace / f"in{suffix}"
destination = workspace / f"out{suffix}"
source.write_bytes(payload)
result = run(
[
ffmpeg,
"-v",
"error",
"-i",
str(source),
# "0:a?" and not "0:a": a clip with no sound is still a clip, and
# a hard mapping would refuse it.
"-map",
"0:v",
"-map",
"0:a?",
"-c",
"copy",
"-map_metadata",
"-1",
"-map_chapters",
"-1",
"-y",
str(destination),
],
timeout=TIMEOUT_SECONDS,
)
if not result.ok or not destination.is_file():
logger.error("ffmpeg could not re-mux %s: %s", suffix, result.stderr.strip())
return None
kinds = streams(destination, ffprobe)
if kinds is None or not kinds or not set(kinds) <= KEPT:
logger.error("refusing %s: streams after re-mux were %s", suffix, kinds)
return None
return destination.read_bytes()
def streams(path: Path, ffprobe: str) -> tuple[str, ...] | None:
"""Every stream's kind, in order, or None when they cannot be read.
The positive check this module rests on. An empty result is treated as a
failure by the caller rather than as "no bad streams": a file ffprobe
cannot describe is one nobody can vouch for.
"""
result = run(
[
ffprobe,
"-v",
"error",
"-show_entries",
"stream=codec_type",
"-of",
"csv=p=0",
str(path),
],
timeout=TIMEOUT_SECONDS,
)
if not result.ok:
return None
return tuple(line.strip().rstrip(",") for line in result.stdout.splitlines() if line.strip())