mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:37:03 +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
123 lines
5.2 KiB
Python
123 lines
5.2 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""The album as the tagging page sees it: every read and write it needs.
|
|
|
|
Split out of ``tool/tagger.py`` when the importer landed and the two together
|
|
went past the shared 250-line cap. It lives here rather than beside the tagger
|
|
because this is the half worth testing -- ``tool/`` is outside the coverage
|
|
gate, which is exactly how an unconditional ImportError sat in ``tagger.py``
|
|
through two releases. What is left there is FastAPI decorators and
|
|
``uvicorn.run``.
|
|
|
|
Nothing here raises an HTTP exception. The routes translate; this file deals
|
|
in ``KeyError`` and ``ConfigError``, so it can be tested without a client.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from signal_bot.groups import ConfigError
|
|
from signal_bot.pack import MEDIA_DIR, MEDIA_TYPES, PEOPLE_FILE, TAGS_FILE
|
|
from signal_bot.pack_load import load
|
|
from signal_bot.pack_people import add_person, merge
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
|
|
class Album:
|
|
"""The pack directory, and every read and write the page needs."""
|
|
|
|
def __init__(self, directory: Path, *, untagged_only: bool = False) -> None:
|
|
"""Point the tagger at one pack, creating the album directory."""
|
|
self.directory = directory
|
|
self.untagged_only = untagged_only
|
|
(directory / MEDIA_DIR).mkdir(parents=True, exist_ok=True)
|
|
|
|
@property
|
|
def files(self) -> list[str]:
|
|
"""Every taggable file, in a stable order."""
|
|
album = self.directory / MEDIA_DIR
|
|
return sorted(path.name for path in album.iterdir() if path.suffix.lower() in MEDIA_TYPES)
|
|
|
|
def read(self, name: str) -> dict[str, Any]:
|
|
"""One manifest, or an empty one."""
|
|
path = self.directory / name
|
|
if not path.exists():
|
|
return {}
|
|
document: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
|
|
return document
|
|
|
|
def write(self, name: str, document: dict[str, Any]) -> None:
|
|
"""Replace one manifest atomically, so a closed tab loses nothing."""
|
|
path = self.directory / name
|
|
temporary = path.with_suffix(".tmp")
|
|
temporary.write_text(json.dumps(document, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
temporary.replace(path)
|
|
|
|
def state(self) -> dict[str, Any]:
|
|
"""Everything the page draws itself from."""
|
|
people = self.read(PEOPLE_FILE)
|
|
tags = self.read(TAGS_FILE)
|
|
return {
|
|
"configured": bool(people.get("people")),
|
|
# A view filter, never a different list: /media/{index} and
|
|
# /api/tag both index self.files, so filtering server-side
|
|
# would show item i of the album while tagging item i of the
|
|
# filtered set -- every photo tagged with the wrong people.
|
|
"untagged_only": self.untagged_only,
|
|
"people": people.get("people", []),
|
|
"combos": people.get("combos", []),
|
|
"groups": people.get("groups", []),
|
|
"files": [{"name": name, "keys": tags.get(name, [])} for name in self.files],
|
|
}
|
|
|
|
def setup(
|
|
self, groups: list[str], people: list[dict[str, Any]], combos: list[dict[str, Any]]
|
|
) -> None:
|
|
"""Merge into people.json, then prove the bot could load it.
|
|
|
|
Merged rather than replaced: this form used to overwrite the file
|
|
and hand out keys by position, so re-running it on a configured
|
|
album renumbered everyone and repointed every tag.
|
|
"""
|
|
document = self.read(PEOPLE_FILE)
|
|
self.write(
|
|
PEOPLE_FILE,
|
|
{"groups": groups, "people": merge(document, people), "combos": combos},
|
|
)
|
|
load(self.directory)
|
|
|
|
def add_person(self, triggers: list[str]) -> str:
|
|
"""Append one person to the roster, mid-session, and return the key."""
|
|
return add_person(self.directory, triggers)
|
|
|
|
def tag(self, filename: str, keys: list[str]) -> None:
|
|
"""Record who is in one file."""
|
|
if filename not in self.files:
|
|
msg = f"{filename} is not in the album"
|
|
raise KeyError(msg)
|
|
tags = self.read(TAGS_FILE)
|
|
tags[filename] = keys
|
|
self.write(TAGS_FILE, tags)
|
|
|
|
def summary(self) -> list[str]:
|
|
"""What each command would answer with, and what is still untagged."""
|
|
tags = self.read(TAGS_FILE)
|
|
lines = [f"== {len(self.files)} items, {len(self.files) - len(tags)} never opened"]
|
|
try:
|
|
pack = load(self.directory)
|
|
except ConfigError as error:
|
|
# Reported rather than raised: the summary prints as the tagger
|
|
# exits, and a half-built pack is the normal state to stop in.
|
|
return [*lines, f"!! the pack does not load yet: {error}"]
|
|
for entry in pack.entries:
|
|
# Distinct files, not the sum per option: a photo showing two of
|
|
# the three people an "either of them" entry covers is one photo
|
|
# the command can return, and summing would claim it twice.
|
|
reachable = {name for option in entry.options for name in pack.matching(option)}
|
|
mark = " " if reachable else "!!"
|
|
lines.append(f"{mark} {entry.triggers[0]}: {len(reachable)} photo(s)")
|
|
return lines
|