mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:17:02 +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
102 lines
3.2 KiB
Python
102 lines
3.2 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""What an import did, in buckets that have to add up.
|
|
|
|
Split from pack_import.py at the shared 250-line cap, along the seam between
|
|
deciding what happens to a file and saying what happened. The arithmetic is
|
|
the point: every source file lands in exactly one bucket, and the printed
|
|
total is computed from the decisions rather than tracked alongside them, so a
|
|
report that does not add up is not expressible.
|
|
|
|
Every bucket prints even at zero. A suppressed zero is a silently missing
|
|
category -- "0 could not be stripped" is how an operator sees that the video
|
|
pipeline is still working.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from enum import StrEnum
|
|
from typing import TYPE_CHECKING, Final
|
|
|
|
from signal_bot.pack import MEDIA_DIR
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
# Imported for typing only: pack_import imports this module at run time,
|
|
# and a real import here would close the loop.
|
|
from signal_bot.pack_import import Decision
|
|
|
|
SKIPPED_DIR: Final = "_skipped"
|
|
|
|
|
|
class Outcome(StrEnum):
|
|
"""Why one source file did or did not end up in the album."""
|
|
|
|
NOT_MEDIA = "not-media"
|
|
UNSTRIPPABLE = "unstrippable"
|
|
ALREADY_IN_PACK = "already-in-pack"
|
|
EXACT_DUP = "exact-dup"
|
|
IMPORTED = "imported"
|
|
|
|
|
|
# Printed in this order, so the report reads as a funnel.
|
|
ORDER: Final = (
|
|
Outcome.NOT_MEDIA,
|
|
Outcome.UNSTRIPPABLE,
|
|
Outcome.ALREADY_IN_PACK,
|
|
Outcome.EXACT_DUP,
|
|
Outcome.IMPORTED,
|
|
)
|
|
|
|
LABELS: Final[dict[Outcome, str]] = {
|
|
Outcome.NOT_MEDIA: "not media",
|
|
Outcome.UNSTRIPPABLE: "could not be stripped",
|
|
Outcome.ALREADY_IN_PACK: "already in pack",
|
|
Outcome.EXACT_DUP: "duplicate in batch",
|
|
Outcome.IMPORTED: "imported",
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ImportReport:
|
|
"""Every source file, bucketed, plus what it does to the album."""
|
|
|
|
source: Path
|
|
directory: Path
|
|
decisions: tuple[Decision, ...]
|
|
before: int
|
|
|
|
@property
|
|
def counts(self) -> dict[Outcome, int]:
|
|
"""How many files fell into each bucket, every bucket present."""
|
|
tally = dict.fromkeys(ORDER, 0)
|
|
for decision in self.decisions:
|
|
tally[decision.outcome] += 1
|
|
return tally
|
|
|
|
@property
|
|
def imported(self) -> tuple[Decision, ...]:
|
|
"""The decisions that put a new file in the album."""
|
|
return tuple(d for d in self.decisions if d.outcome is Outcome.IMPORTED)
|
|
|
|
@property
|
|
def after(self) -> int:
|
|
"""How many files media/ holds once this import is applied."""
|
|
return self.before + len(self.imported)
|
|
|
|
def lines(self) -> list[str]:
|
|
"""The report, with a total that has to add up to what was seen."""
|
|
counts = self.counts
|
|
lines = [f"== {self.source} -> {self.directory}", ""]
|
|
lines.append(f"{len(self.decisions):6} files seen")
|
|
for outcome in ORDER:
|
|
note = "" if outcome is Outcome.IMPORTED else f" -> {SKIPPED_DIR}/{outcome}/"
|
|
lines.append(f"{counts[outcome]:6} {LABELS[outcome]:<21}{note}")
|
|
lines.append(" ---")
|
|
lines.append(
|
|
f"{sum(counts.values()):6} accounted for"
|
|
f" {self.before} -> {self.after} in {MEDIA_DIR}/"
|
|
)
|
|
return lines
|