mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:27:20 +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
108 lines
4.1 KiB
Python
108 lines
4.1 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""The album as the tagging page sees it."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
|
|
from signal_bot.pack import MEDIA_DIR, PEOPLE_FILE, TAGS_FILE
|
|
from signal_bot.pack_album import Album
|
|
from tests.conftest import WATCHED_GROUP
|
|
from tests.pack_fixture import CLIP, PIXEL
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
|
|
def _album(tmp_path: Path, *, untagged_only: bool = False) -> Album:
|
|
media = tmp_path / MEDIA_DIR
|
|
media.mkdir(parents=True)
|
|
(media / "one.png").write_bytes(PIXEL)
|
|
(media / "two.png").write_bytes(PIXEL)
|
|
(media / "clip.mp4").write_bytes(CLIP)
|
|
(tmp_path / PEOPLE_FILE).write_text(
|
|
json.dumps({"groups": [WATCHED_GROUP], "people": [{"key": "p1", "triggers": ["!alfa"]}]}),
|
|
encoding="utf-8",
|
|
)
|
|
(tmp_path / TAGS_FILE).write_text(json.dumps({"one.png": ["p1"]}), encoding="utf-8")
|
|
return Album(tmp_path, untagged_only=untagged_only)
|
|
|
|
|
|
def test_every_taggable_file_is_listed_in_a_stable_order(tmp_path: Path) -> None:
|
|
assert _album(tmp_path).files == ["clip.mp4", "one.png", "two.png"]
|
|
|
|
|
|
def test_the_state_carries_the_filter_as_a_flag_not_a_shorter_list(tmp_path: Path) -> None:
|
|
# One list, one index space. /media/{index} and /api/tag both index
|
|
# 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, and no error anywhere.
|
|
state = _album(tmp_path, untagged_only=True).state()
|
|
assert state["untagged_only"] is True
|
|
assert [entry["name"] for entry in state["files"]] == ["clip.mp4", "one.png", "two.png"]
|
|
assert [entry["keys"] for entry in state["files"]] == [[], ["p1"], []]
|
|
|
|
|
|
def test_an_album_with_no_roster_is_not_configured(tmp_path: Path) -> None:
|
|
(tmp_path / MEDIA_DIR).mkdir(parents=True)
|
|
assert Album(tmp_path).state()["configured"] is False
|
|
|
|
|
|
def test_tagging_records_who_is_in_a_file(tmp_path: Path) -> None:
|
|
album = _album(tmp_path)
|
|
album.tag("two.png", ["p1"])
|
|
assert album.read(TAGS_FILE)["two.png"] == ["p1"]
|
|
|
|
|
|
def test_tagging_a_file_the_album_does_not_have_is_refused(tmp_path: Path) -> None:
|
|
with pytest.raises(KeyError):
|
|
_album(tmp_path).tag("../etc/passwd", ["p1"])
|
|
|
|
|
|
def test_a_manifest_that_is_not_there_reads_as_empty(tmp_path: Path) -> None:
|
|
(tmp_path / MEDIA_DIR).mkdir(parents=True)
|
|
assert Album(tmp_path).read("nothing.json") == {}
|
|
|
|
|
|
def test_the_summary_counts_what_was_never_opened(tmp_path: Path) -> None:
|
|
lines = _album(tmp_path).summary()
|
|
assert "3 items, 2 never opened" in lines[0]
|
|
|
|
|
|
def test_the_summary_reports_a_broken_pack_rather_than_raising(tmp_path: Path) -> None:
|
|
# A half-built pack is the normal state to stop the tagger in.
|
|
(tmp_path / MEDIA_DIR).mkdir(parents=True)
|
|
(tmp_path / PEOPLE_FILE).write_text(json.dumps({"groups": []}), encoding="utf-8")
|
|
assert any("does not load yet" in line for line in Album(tmp_path).summary())
|
|
|
|
|
|
def test_the_summary_marks_a_command_with_no_photos(tmp_path: Path) -> None:
|
|
# A command with zero is dead on arrival, and this is where it shows up
|
|
# rather than in the group.
|
|
album = _album(tmp_path)
|
|
(tmp_path / TAGS_FILE).write_text(json.dumps({}), encoding="utf-8")
|
|
assert any(line.startswith("!!") and "!alfa" in line for line in album.summary())
|
|
|
|
|
|
def test_a_person_can_be_added_to_a_configured_album(tmp_path: Path) -> None:
|
|
album = _album(tmp_path)
|
|
assert album.add_person(["delta"]) == "p2"
|
|
assert album.state()["people"][1]["triggers"] == ["!delta"]
|
|
|
|
|
|
def test_setup_merges_rather_than_replacing(tmp_path: Path) -> None:
|
|
album = _album(tmp_path)
|
|
album.setup([WATCHED_GROUP], [{"triggers": ["!delta"]}], [])
|
|
keys = [person["key"] for person in album.state()["people"]]
|
|
assert keys == ["p1", "p2"]
|
|
|
|
|
|
def test_a_write_is_atomic_and_leaves_no_temporary(tmp_path: Path) -> None:
|
|
album = _album(tmp_path)
|
|
album.write("thing.json", {"a": 1})
|
|
assert json.loads((tmp_path / "thing.json").read_text(encoding="utf-8")) == {"a": 1}
|
|
assert not (tmp_path / "thing.tmp").exists()
|