mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 15:08:31 +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
152 lines
5.4 KiB
Python
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())
|