tests/test_follow_store.py
Rama master · réplica de lectura del repositorio original.
"""追更订阅与候选落地的隔离测试。全部使用临时数据库,绝不碰真实 ledger。"""
import json
import sqlite3
import tempfile
import unittest
from dataclasses import replace
from datetime import datetime, timedelta, timezone
from pathlib import Path
from peach import follow_providers
from peach.follow import FollowSourceError
from peach.follow_sources import FollowCandidate, IwaraConnector, SourceFetch
from peach.follow_store import (
FollowStore, ReleaseGroup, author_display_text, f95_author_handles,
f95_author_name, normalized_author_name,
)
from support.ledger import fresh_ledger
ROOT = Path(__file__).resolve().parents[1]
MOMENT = datetime(2026, 8, 25, 9, 0, tzinfo=timezone.utc)
def _candidate(external_id, title, **kwargs):
kwargs.setdefault("provider", "rule34video")
return FollowCandidate(external_id=external_id, title=title, **kwargs)
def _fetch(candidates, *, provider="rule34video", ref="lazyprocrastinator",
semantics="work", raw=b"<html/>", **kwargs):
return SourceFetch(
provider=provider, ref=ref,
request_url=f"https://{provider}.test/{ref}", semantics=semantics,
candidates=tuple(candidates), raw_body=raw, **kwargs)
class _StoreCase(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name).resolve()
self.connection = sqlite3.connect(fresh_ledger(self.root))
self.connection.row_factory = sqlite3.Row
self.addCleanup(self.connection.close)
self.store = FollowStore(lambda: self.connection,
sources_root=self.root / "sources")
def _entity(self, name="LazyProcrast", aliases=("LazyProcrastinator",)):
cursor = self.connection.execute(
"INSERT INTO entity(kind,canonical_name,normalized_name,created_at,updated_at)"
" VALUES('creator',?,?,?,?)",
(name, name.lower(), "2026-08-25T00:00:00Z", "2026-08-25T00:00:00Z"))
entity_id = int(cursor.lastrowid)
for alias in aliases:
self.connection.execute(
"INSERT INTO entity_alias(entity_id,alias,normalized_alias,source)"
" VALUES(?,?,?,'test')", (entity_id, alias, alias.lower()))
return entity_id
def _source(self, provider="rule34video", ref="lazyprocrastinator", **kwargs):
kwargs.setdefault("label", "LazyProcrastinator")
kwargs.setdefault("url", f"https://{provider}.test/{ref}")
return self.store.register(provider=provider, ref=ref, moment=MOMENT, **kwargs)
class RegistrationTests(_StoreCase):
def test_register_is_idempotent_and_updates_the_label(self):
first = self._source()
second = self._source(label="Lazy P")
self.assertEqual(first, second)
rows = self.store.sources()
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["label"], "Lazy P")
def test_merging_metadata_keeps_the_keys_it_does_not_mention(self):
"""名片是登记之后才解析出来的,补它不能抹掉登记时写下的 author_key。"""
source_id = self._source(metadata={"author_key": "mrstrauz"})
self.store.merge_source_metadata(
source_id, {"official_links": [{"service": "twitter",
"handle": "Mr_Strauz"}]},
moment=MOMENT)
stored = json.loads(self.store.sources()[0]["metadata_json"])
self.assertEqual(stored["author_key"], "mrstrauz")
self.assertEqual([link["handle"] for link in stored["official_links"]],
["Mr_Strauz"])
def test_an_empty_profile_list_is_recorded_as_an_answer(self):
# 「问过了,他没留主页」要留得下来,否则每次检查都要再问一遍。
source_id = self._source()
self.store.merge_source_metadata(source_id, {"official_links": []},
moment=MOMENT)
stored = json.loads(self.store.sources()[0]["metadata_json"])
self.assertEqual(stored["official_links"], [])
def test_rule34_case_variants_are_the_same_source(self):
first = self._source(provider="rule34xxx", ref="LazyProcrastinator")
second = self._source(provider="rule34xxx", ref="lazyprocrastinator")
self.assertEqual(first, second)
self.assertEqual(len(self.store.sources()), 1)
self.assertEqual(self.store.sources()[0]["ref"], "lazyprocrastinator")
def test_register_keeps_an_existing_entity_when_the_update_omits_it(self):
entity_id = self._entity()
self._source(entity_id=entity_id)
self._source(label="again")
self.assertEqual(self.store.sources()[0]["entity_id"], entity_id)
def test_enabled_only_filters_disabled_sources(self):
source_id = self._source()
self._source(provider="kemono", ref="fanbox/30917150")
self.store.set_enabled(source_id, False, moment=MOMENT)
self.assertEqual([row["provider"] for row in self.store.sources(enabled_only=True)],
["kemono"])
def test_bad_semantics_is_rejected(self):
with self.assertRaises(FollowSourceError):
self._source(semantics="whatever")
def test_creator_aliases_include_canonical_name_and_aliases(self):
entity_id = self._entity()
self.assertEqual(self.store.creator_aliases(entity_id),
("LazyProcrast", "LazyProcrastinator"))
self.assertEqual(self.store.creator_aliases(None), ())
class RecordTests(_StoreCase):
def test_iwara_general_tags_enter_the_inventory_without_guessing_other_types(self):
from peach.web_follow import _item_tags, online_tag_inventory
candidate = IwaraConnector()._candidate({
"id": "Video123", "title": "Test animation", "tags": [
{"id": "dance", "type": "general"},
{"id": "blender", "type": "category"},
{"id": "series_title", "type": "source"},
{"id": "pose", "type": "unknown"},
{"id": "animation"}]}, {"name": "Test Creator"}, "creator")
source_id = self._source(provider="iwara", ref="creator")
self.store.record(source_id, _fetch([candidate], provider="iwara", ref="creator"),
moment=MOMENT)
rows = online_tag_inventory(self.connection)
self.assertEqual([(row["k"], row["cat"]) for row in rows], [("dance", "general")])
item = self.store.items()[0]
self.assertEqual(_item_tags(item), ["dance"])
self.assertEqual(item.metadata["source_tag_types"], {
"dance": "general", "blender": "category", "series_title": "source", "pose": "unknown"})
self.assertEqual(item.metadata["tags"], ["dance", "blender", "series_title", "pose", "animation"])
def test_booru_source_only_becomes_a_profile_when_the_handle_matches(self):
source_id = self._source(
provider="rule34xxx", ref="auxtasy", label="Auxtasy",
metadata={"author_key": "auxtasy", "official_links": [
{"service": "twitter", "handle": "OldWrongName",
"url": "https://x.com/OldWrongName"},
]},
)
self.store.record(source_id, _fetch([
_candidate("1", "one", provider="rule34xxx",
extra={"source": "https://x.com/Auxtasy/status/1"}),
_candidate("2", "two", provider="rule34xxx",
extra={"source": "https://www.patreon.com/futavr/posts/2"}),
], provider="rule34xxx", ref="auxtasy"), moment=MOMENT)
metadata = json.loads(self.store.sources()[0]["metadata_json"])
self.assertEqual(
[(link["service"], link["handle"]) for link in metadata["official_links"]],
[("twitter", "Auxtasy")],
)
def test_booru_source_accepts_a_confirmed_alias_but_not_a_collaborator(self):
self.store.upsert_author_alias(
"LazyProcrastinator", "lazyprocrast", source="manual", moment=MOMENT)
source_id = self._source(
provider="rule34xxx", ref="lazyprocrastinator",
metadata={"author_key": "lazyprocrastinator"},
)
self.store.record(source_id, _fetch([
_candidate("1", "one", provider="rule34xxx",
extra={"source": "https://lazyprocrast.fanbox.cc/posts/1"}),
_candidate("2", "two", provider="rule34xxx",
extra={"source": "https://x.com/unrelated/status/2"}),
], provider="rule34xxx", ref="lazyprocrastinator"), moment=MOMENT)
metadata = json.loads(self.store.sources()[0]["metadata_json"])
self.assertEqual([link["handle"] for link in metadata["official_links"]],
["lazyprocrast"])
def test_backfill_pages_add_to_the_profile_links_the_first_page_found(self):
"""往回抓只并集追加出处:更早那几页看不到近期作品链出去的那些站。"""
source_id = self._source(
provider="rule34xxx", ref="auxtasy", label="Auxtasy",
metadata={"author_key": "auxtasy"},
)
self.store.record(source_id, _fetch([
_candidate("1", "one", provider="rule34xxx",
extra={"source": "https://x.com/Auxtasy/status/1"}),
], provider="rule34xxx", ref="auxtasy"), moment=MOMENT)
self.store.record(source_id, _fetch([
_candidate("9", "nine", provider="rule34xxx",
extra={"source": "https://auxtasy.fanbox.cc/posts/9"}),
], provider="rule34xxx", ref="auxtasy"), moment=MOMENT, page=2)
metadata = json.loads(self.store.sources()[0]["metadata_json"])
self.assertEqual([link["handle"] for link in metadata["official_links"]],
["Auxtasy", "auxtasy"])
def test_first_fetch_adds_items_and_classifies_variants(self):
source_id = self._source(entity_id=self._entity())
outcome = self.store.record(source_id, _fetch([
_candidate("4542713", "Fiona - Paizuri"),
_candidate("4542721", "Fiona - Paizuri (Nude)"),
_candidate("4542899", "Mitsuru [WIP]"),
]), creator_aliases=self.store.creator_aliases(1), moment=MOMENT)
self.assertEqual((outcome.added, outcome.updated, outcome.discovered), (3, 0, 3))
kinds = {item.external_id: item.variant_kind for item in self.store.items()}
self.assertEqual(kinds, {"4542713": "main", "4542721": "alt", "4542899": "wip"})
def test_second_fetch_updates_without_resetting_user_state(self):
source_id = self._source()
self.store.record(source_id, _fetch([_candidate("1", "Fiona - Paizuri")]),
moment=MOMENT)
item = self.store.items()[0]
self.store.set_status(item.id, "ignored")
later = MOMENT + timedelta(hours=1)
outcome = self.store.record(
source_id, _fetch([_candidate("1", "Fiona - Paizuri", url="https://x.test/1")],
raw=b"<html>2</html>"), moment=later)
self.assertEqual((outcome.added, outcome.updated), (0, 1))
refreshed = self.store.items()[0]
self.assertEqual(refreshed.status, "ignored")
self.assertEqual(refreshed.url, "https://x.test/1")
self.assertEqual(refreshed.first_seen_at, item.first_seen_at)
self.assertNotEqual(refreshed.last_seen_at, item.last_seen_at)
def test_a_failed_detail_never_erases_a_known_upload_time(self):
"""paheal 每个候选都要单独打一次详情页,被限流时 `_detail` 返回 {}。
此前 upsert 无条件覆盖,于是上一轮已经取到的上传时间和时长被抹成 NULL,
而 `COALESCE(published_at, first_seen_at)` 会让界面改显示抓取时刻——
看着是条完整记录,时间却是错的。实测 168 条 paheal 里 7 条正是这样。
"""
source_id = self._source()
self.store.record(source_id, _fetch([_candidate(
"1", "A", published_at="2026-08-18T09:03:30Z", duration=44.7)]), moment=MOMENT)
first = self.store.items()[0]
self.assertEqual(first.published_at, "2026-08-18T09:03:30Z")
later = MOMENT + timedelta(hours=1)
self.store.record(source_id, _fetch([_candidate("1", "A")], raw=b"<html>2</html>"),
moment=later)
again = self.store.items()[0]
self.assertEqual(again.published_at, "2026-08-18T09:03:30Z")
self.assertEqual(again.duration, 44.7)
self.assertEqual(again.published_precision, "exact")
def test_a_later_fetch_with_a_real_time_still_wins(self):
"""保守只针对「取不到」;来源真的给了新时间就要覆盖,不能变成只写一次。"""
source_id = self._source()
self.store.record(source_id, _fetch([_candidate("1", "A")]), moment=MOMENT)
self.assertIsNone(self.store.items()[0].published_at)
self.store.record(source_id, _fetch([_candidate(
"1", "A", published_at="2026-08-18T09:03:30Z")], raw=b"<html>2</html>"),
moment=MOMENT + timedelta(hours=1))
item = self.store.items()[0]
self.assertEqual(item.published_at, "2026-08-18T09:03:30Z")
self.assertEqual(item.published_precision, "exact")
def test_not_modified_records_the_check_without_touching_items(self):
source_id = self._source()
self.store.record(source_id, _fetch([_candidate("1", "A")]), moment=MOMENT)
outcome = self.store.record(
source_id, _fetch([], raw=None, not_modified=True), moment=MOMENT)
self.assertTrue(outcome.not_modified)
self.assertEqual(len(self.store.items()), 1)
self.assertEqual(self.store.sources()[0]["last_status"], "not_modified")
def test_conditional_cursor_is_stored_on_the_source_row(self):
source_id = self._source()
self.store.record(source_id, _fetch([_candidate("1", "A")], etag='"v1"',
last_modified="Mon, 25 Aug 2026 09:00:00 GMT"),
moment=MOMENT)
row = self.store.sources()[0]
self.assertEqual(row["etag"], '"v1"')
self.assertEqual(row["last_modified"], "Mon, 25 Aug 2026 09:00:00 GMT")
def test_raw_evidence_is_written_once_and_immutably(self):
source_id = self._source()
outcome = self.store.record(source_id, _fetch([_candidate("1", "A")]),
moment=MOMENT)
# evidence_path 是相对 `sources/` 的路径,分隔符随平台不同——不能写死 `/`。
evidence = self.root / "sources" / Path(outcome.evidence_path)
self.assertTrue(evidence.is_file())
sidecar = json.loads(evidence.with_suffix(".json").read_text(encoding="utf-8"))
self.assertEqual(sidecar["candidates"], 1)
self.assertEqual(sidecar["request_url"], "https://rule34video.test/lazyprocrastinator")
def test_an_unusable_evidence_root_does_not_lose_the_candidates(self):
"""Mac 上 `peach-data/sources` 指向外置盘。盘不在时它是一条断链,
`mkdir(exist_ok=True)` 会抛 `FileExistsError`(链接在、目标不在)。
发现跟归档盘无关,不该让整次检查连同已抓到的候选一起炸掉。
这里用「路径被普通文件占住」构造同一个 `FileExistsError`:断链要 symlink,
而非管理员的 Windows 建不了 symlink,那样测试就只在一个平台成立。
"""
(self.root / "evidence").write_text("not a directory", encoding="utf-8")
store = FollowStore(lambda: self.connection,
sources_root=self.root / "evidence")
source_id = store.register(provider="rule34video", ref="x", label="X",
url="https://x.test/", moment=MOMENT)
outcome = store.record(source_id, _fetch([_candidate("1", "Fiona - Paizuri")]),
moment=MOMENT)
self.assertEqual(outcome.added, 1)
self.assertIsNone(outcome.evidence_path)
self.assertIn("证据未取得", outcome.evidence_error)
item = store.items()[0]
self.assertEqual(item.title, "Fiona - Paizuri")
self.assertIsNone(item.metadata.get("evidence_path"))
def test_a_readable_evidence_root_reports_no_error(self):
source_id = self._source()
outcome = self.store.record(source_id, _fetch([_candidate("1", "A")]),
moment=MOMENT)
self.assertIsNotNone(outcome.evidence_path)
self.assertIsNone(outcome.evidence_error)
def test_identical_evidence_is_reused_without_overwriting_files(self):
source_id = self._source()
fetch = _fetch([_candidate("1", "A")])
first = self.store.record(source_id, fetch, moment=MOMENT)
evidence = self.root / "sources" / Path(first.evidence_path)
sidecar = evidence.with_suffix(".json")
before = [(path.read_bytes(), path.stat().st_mtime_ns) for path in (evidence, sidecar)]
again = self.store.record(source_id, fetch, moment=MOMENT)
self.assertEqual(again.evidence_path, first.evidence_path)
self.assertEqual(again.added, 0)
self.assertEqual([(path.read_bytes(), path.stat().st_mtime_ns)
for path in (evidence, sidecar)], before)
def test_shared_response_body_keeps_distinct_snapshot_metadata(self):
for provider, spec in follow_providers.PROVIDERS.items():
if not spec.source_url:
continue
with self.subTest(provider=provider):
source_id = self._source(provider=provider, semantics=spec.semantics)
fetch = _fetch([_candidate("1", "A", provider=provider)],
provider=provider, semantics=spec.semantics)
variants = (fetch, replace(fetch, request_url=fetch.request_url + "?firstId=1"),
replace(fetch, candidates=()), replace(fetch, raw_body=b"different body"))
paths = []
for offset, variant in enumerate(variants):
# 时刻各不相同:保留规则按时间留最近几份,同一时刻的先后没有定义。
outcome = self.store.record(source_id, variant, moment=MOMENT + timedelta(seconds=offset))
self.assertIsNone(outcome.evidence_error)
paths.append(self.root / "sources" / Path(outcome.evidence_path))
self.assertEqual(len(set(paths)), 4)
for path, variant in zip(paths, variants):
self.assertEqual(path.read_bytes(), variant.raw_body)
sidecar = json.loads(path.with_suffix(".json").read_text(encoding="utf-8"))
self.assertEqual(sidecar["provider"], provider)
self.assertEqual(sidecar["request_url"], variant.request_url)
self.assertEqual(sidecar["candidates"], len(variant.candidates))
def test_conflicting_evidence_still_rejects_overwriting(self):
source_id = self._source()
fetch = _fetch([_candidate("1", "A")])
outcome = self.store.record(source_id, fetch, moment=MOMENT)
evidence = self.root / "sources" / Path(outcome.evidence_path)
sidecar = evidence.with_suffix(".json")
original = sidecar.read_bytes()
sidecar.write_bytes(b"conflicting evidence")
with self.assertRaisesRegex(FollowSourceError, "immutable follow snapshot collision"):
self.store.record(source_id, fetch, moment=MOMENT)
self.assertEqual(sidecar.read_bytes(), b"conflicting evidence")
self.assertEqual(evidence.read_bytes(), fetch.raw_body)
sidecar.write_bytes(original)
def test_approximate_precision_is_carried_through_from_the_connector(self):
source_id = self._source()
self.store.record(source_id, _fetch([
_candidate("1", "A", published_at="2026-08-18T00:00:00Z",
extra={"published_precision": "approximate"}),
_candidate("2", "B"),
]), moment=MOMENT)
precision = {i.external_id: i.published_precision for i in self.store.items()}
self.assertEqual(precision, {"1": "approximate", "2": "unknown"})
def test_record_error_keeps_the_message_and_status(self):
source_id = self._source()
self.store.record_error(source_id, "HTTP 403", moment=MOMENT,
status="unauthorized")
row = self.store.sources()[0]
self.assertEqual(row["last_status"], "unauthorized")
self.assertEqual(row["last_error"], "HTTP 403")
def test_items_can_be_filtered_by_status(self):
source_id = self._source()
self.store.record(source_id, _fetch([_candidate("1", "A"), _candidate("2", "B")]),
moment=MOMENT)
self.store.set_status(self.store.items()[0].id, "seen")
self.assertEqual(len(self.store.items(statuses=("new",))), 1)
def test_an_ignored_item_can_be_restored_to_new(self):
source_id = self._source()
self.store.record(source_id, _fetch([_candidate("1", "A")]), moment=MOMENT)
item = self.store.items()[0]
self.store.set_status(item.id, "ignored")
self.store.set_status(item.id, "new")
self.assertEqual(self.store.items()[0].status, "new")
def test_saved_status_cannot_be_set_directly(self):
source_id = self._source()
self.store.record(source_id, _fetch([_candidate("1", "A")]), moment=MOMENT)
with self.assertRaises(FollowSourceError):
self.store.set_status(self.store.items()[0].id, "saved")
with self.assertRaises(FollowSourceError):
self.store.set_status(self.store.items()[0].id, "nonsense")
def test_a_tag_the_site_later_marks_as_a_character_follows_on_older_items(self):
"""站方事后把标签改成角色:新条目记上之后,同站旧条目里的同名标签跟着改。
跨作者也改;别的站不动;只往 character 改,新条目记成 general 不降级旧行。
"""
def typed(external_id, kind, **kwargs):
return _candidate(external_id, external_id, extra={"tag_types": {
"d.mon (overwatch)": kind, "sex": "general"}}, **kwargs)
old = self._source(ref="vileclipse")
other_site = self._source(provider="rule34xxx", ref="vileclipse")
self.store.record(old, _fetch([typed("1", "general")], ref="vileclipse"),
moment=MOMENT)
self.store.record(other_site, _fetch([typed("9", "general", provider="rule34xxx")],
provider="rule34xxx", ref="vileclipse"),
moment=MOMENT)
newer = self._source(ref="someone-else")
# 探详情失败的候选是 partial,它带的类型不作数。
self.store.record(newer, _fetch([typed("2", "character", partial=True)],
ref="someone-else"), moment=MOMENT)
def kinds():
return {item.external_id: item.metadata["tag_types"]["d.mon (overwatch)"]
for item in self.store.items()}
self.assertEqual(kinds()["1"], "general")
self.store.record(newer, _fetch([typed("3", "character")], ref="someone-else"),
moment=MOMENT)
self.assertEqual(kinds(), {"1": "character", "2": "character", "3": "character",
"9": "general"})
old_item = next(item for item in self.store.items() if item.external_id == "1")
self.assertEqual(old_item.metadata["tag_types"]["sex"], "general")
self.store.record(old, _fetch([typed("4", "general")], ref="vileclipse"),
moment=MOMENT)
self.assertEqual(kinds()["1"], "character")
class GroupingTests(_StoreCase):
def test_repeated_collection_of_the_main_upload_preserves_its_variants(self):
for ref in ("hydrafxx", "zmsfm"):
source = self._source(ref=ref)
self.store.record(source, _fetch([
_candidate("main", "Evening Movie"),
_candidate("alt", "Evening Movie (4K60fps)"),
], ref=ref), moment=MOMENT)
self.assertEqual(len(self.store.group(self.store.items())), 1)
for source in self.store.sources():
self.assertEqual(len(self.store.group(self.store.items(source_id=source["id"]))), 1)
def test_stored_combined_video_specs_group_without_rewriting_rows(self):
source = self._source()
self.store.record(source, _fetch([
_candidate("one", "Evening Movie (4K60fps)(NO WM)"),
_candidate("two", "Evening Movie (60fps)(NO WM)"),
]), moment=MOMENT)
self.connection.execute("UPDATE follow_item SET release_key=? WHERE external_id='one'",
("evening movie 4k60fps",))
self.connection.commit()
groups = self.store.group(self.store.items())
self.assertEqual(len(groups), 1)
self.assertEqual({row.external_id for row in (groups[0].primary, *groups[0].variants)},
{"one", "two"})
stored = self.connection.execute("SELECT release_key FROM follow_item WHERE external_id='one'").fetchone()[0]
self.assertEqual(stored, "evening movie 4k60fps")
def _populate(self):
video = self._source()
booru = self._source(provider="rule34xxx", ref="lazyprocrastinator")
self.store.record(video, _fetch([
_candidate("4542713", "Fiona - Paizuri"),
_candidate("4542721", "Fiona - Paizuri (Nude)"),
_candidate("4542705", "Fiona - Missionary"),
]), moment=MOMENT)
self.store.record(booru, _fetch([
FollowCandidate(provider="rule34xxx", external_id="9988770",
title="fiona paizuri",
group_hint="rule34xxx:post:9988770"),
FollowCandidate(provider="rule34xxx", external_id="9988776",
title="totally different filename",
title_is_name=False,
group_hint="rule34xxx:post:9988770"),
], provider="rule34xxx"), moment=MOMENT)
def test_alt_folds_under_its_main_and_other_works_stay_apart(self):
self._populate()
groups = {g.primary.title: g for g in self.store.group(self.store.items())}
self.assertIn("Fiona - Paizuri", groups)
self.assertIn("Fiona - Missionary", groups)
paizuri = groups["Fiona - Paizuri"]
self.assertIn("Fiona - Paizuri (Nude)", [v.title for v in paizuri.variants])
self.assertEqual(groups["Fiona - Missionary"].variants, ())
def test_cross_site_duplicate_is_reported_separately_from_same_site_variants(self):
self._populate()
paizuri = next(g for g in self.store.group(self.store.items())
if g.primary.title == "Fiona - Paizuri")
self.assertEqual(sorted(paizuri.providers), ["rule34video", "rule34xxx"])
self.assertTrue(all(d.provider == "rule34xxx" for d in paizuri.duplicates))
def test_group_hint_beats_an_unrelated_title(self):
# booru 子帖的「标题」是标签拼的,和父帖毫无关系,只有来源声明的键能连起来。
self._populate()
groups = self.store.group(self.store.items())
stray = [g for g in groups if g.primary.title == "totally different filename"]
self.assertEqual(stray, [])
def test_a_shared_origin_key_merges_two_different_sites(self):
# 这是跨站去重真正靠得住的那条路:rule34.xxx 从 source 归一出的键,
# 和 kemono 上同一帖子的键完全相同,标题再不一样也能精确合并。
booru = self._source(provider="rule34xxx", ref="lazyprocrastinator")
kemono = self._source(provider="kemono", ref="fanbox/30917150")
self.store.record(booru, _fetch([
FollowCandidate(provider="rule34xxx", external_id="18534395",
title="fiona · blush", title_is_name=False,
group_hint="fanbox:12304831"),
], provider="rule34xxx", ref="lazyprocrastinator"), moment=MOMENT)
self.store.record(kemono, _fetch([
FollowCandidate(provider="kemono", external_id="12304831",
title="Fiona - Paizuri", group_hint="fanbox:12304831"),
], provider="kemono", ref="fanbox/30917150"), moment=MOMENT)
groups = self.store.group(self.store.items())
self.assertEqual(len(groups), 1)
self.assertEqual(sorted(groups[0].providers), ["kemono", "rule34xxx"])
def test_paheal_and_existing_sources_deduplicate_on_the_subscribestar_post(self):
paheal = self._source(provider="rule34paheal", ref="initiala")
mirror = self._source(provider="rule34xxx", ref="initiala")
self.store.record(paheal, _fetch([
FollowCandidate(provider="rule34paheal", external_id="7428820",
title="Amina · Tifa", title_is_name=False,
group_hint="subscribestar:2639932"),
], provider="rule34paheal", ref="initiala"), moment=MOMENT)
self.store.record(mirror, _fetch([
FollowCandidate(provider="rule34xxx", external_id="18540000",
title="Different tag label", title_is_name=False,
group_hint="subscribestar:2639932"),
], provider="rule34xxx", ref="initiala"), moment=MOMENT)
groups = self.store.group(self.store.items())
self.assertEqual(len(groups), 1)
self.assertEqual(sorted(groups[0].providers), ["rule34paheal", "rule34xxx"])
def test_a_tag_derived_title_never_merges_two_works_on_its_own(self):
# 标签拼出来的「标题」不是名字:同一作者标签相似的两条不能因此被并掉。
source_id = self._source(provider="rule34xxx", ref="lazyprocrastinator")
self.store.record(source_id, _fetch([
FollowCandidate(provider="rule34xxx", external_id="1",
title="fiona · blush", title_is_name=False),
FollowCandidate(provider="rule34xxx", external_id="2",
title="fiona · blush", title_is_name=False),
], provider="rule34xxx", ref="lazyprocrastinator"), moment=MOMENT)
self.assertEqual(len(self.store.group(self.store.items())), 2)
def _booru_post(self, external_id, minutes, *, character="angel_(kof)",
general=("cowgirl_position", "blush", "nude", "pov"), **extra):
tag_types = {"king_of_fighters": "copyright", character: "character",
"you": "character", "lazyprocrastinator": "artist",
**{tag: "general" for tag in general}}
return FollowCandidate(
provider="rule34xxx", external_id=external_id,
title=f"{character} · {general[0]}", title_is_name=False,
published_at=(MOMENT + timedelta(minutes=minutes)).isoformat(),
group_hint=f"rule34xxx:post:{external_id}",
extra={"title_from": "tags", "tag_types": tag_types, **extra})
def _record_booru(self, *posts):
source_id = self._source(provider="rule34xxx", ref="lazyprocrastinator")
self.store.record(source_id, _fetch(posts, provider="rule34xxx"), moment=MOMENT)
return self.store.group(self.store.items())
def test_posts_uploaded_in_one_burst_fold_into_one_work(self):
# 同一段动画按横屏、竖屏和机位拆成几帖连着发:角色相同、标签几乎一样。
groups = self._record_booru(
self._booru_post("1", 0), self._booru_post("2", 1),
self._booru_post("3", 2, general=("cowgirl_position", "blush", "nude", "boobjob")),
self._booru_post("4", 150))
self.assertEqual(len(groups), 1)
self.assertEqual(groups[0].primary.external_id, "1")
self.assertEqual(len(groups[0].variants), 3)
def test_a_burst_needs_the_same_characters(self):
groups = self._record_booru(
self._booru_post("1", 0), self._booru_post("2", 1, character="lavinia_voda"))
self.assertEqual(len(groups), 2)
def test_a_long_pause_starts_a_new_work(self):
groups = self._record_booru(self._booru_post("1", 0), self._booru_post("2", 60 * 4))
self.assertEqual(len(groups), 2)
def test_posts_with_little_tag_overlap_stay_apart(self):
groups = self._record_booru(
self._booru_post("1", 0),
self._booru_post("2", 1, general=("standing_sex", "against_wall", "hat", "nude")))
self.assertEqual(len(groups), 2)
def test_a_post_without_an_origin_joins_the_declared_work_it_was_uploaded_with(self):
# LazyProcrastinator 的 Aerith 一批 6 帖,5 帖写了出处,夹在中间的一帖没写。
origin = "https://www.fanbox.cc/@lazy/posts/12304831"
groups = self._record_booru(
self._booru_post("15101361", 0, source=origin),
self._booru_post("15101385", 3),
self._booru_post("15101390", 4, source=origin))
self.assertEqual(len(groups), 1)
self.assertEqual(len(groups[0].variants), 2)
def test_two_declared_works_are_not_joined_through_a_post_between_them(self):
# 来源把两帖分成了两个作品,中间那帖挂哪一组都是猜,三帖各自成卡。
groups = self._record_booru(
self._booru_post("1", 0, source="https://www.fanbox.cc/@lazy/posts/12304831"),
self._booru_post("2", 1),
self._booru_post("3", 2, source="https://www.fanbox.cc/@lazy/posts/12304832"))
self.assertEqual(len(groups), 3)
def test_posts_retagged_together_are_not_a_burst(self):
# Rekin3D 的两帖分别在 05-01 与 05-20 上传,09-14 一起被改了标签,落库时间都是
# 改标签那一分钟。帖子 id 相差近 20 万,不是连着传的。
groups = self._record_booru(
self._booru_post("17361475", 1), self._booru_post("17559518", 0))
self.assertEqual(len(groups), 2)
def test_an_untagged_booru_post_joins_its_batch_by_the_whole_tag_set(self):
# rule34.paheal 不给标签分类。InitialA 一口气传的 Lily 动画里,8 帖写着同一个
# subscribestar 出处,最后一帖没写;整组标签相同,归进那一组。
source_id = self._source(provider="rule34paheal", ref="initiala", label="initiala")
tags = {tag: "general" for tag in
("InitialA", "Lily_Artemis_II", "Stellar_Blade", "animated", "blender")}
def post(external_id, seconds, **extra):
return FollowCandidate(
provider="rule34paheal", external_id=external_id,
title="lily artemis ii · stellar blade", title_is_name=False,
published_at=(MOMENT + timedelta(seconds=seconds)).isoformat(),
group_hint="subscribestar:2685233" if "source" in extra
else f"rule34paheal:post:{external_id}",
extra={"title_from": "tags", "tag_types": tags, **extra})
origin = "https://subscribestar.adult/posts/2685233"
self.store.record(source_id, _fetch([
post("7452299", 0, source=origin), post("7452300", 1, source=origin),
post("7452301", 25),
post("7452302", 30, tag_types={**tags, "Eve": "general"}),
], provider="rule34paheal", ref="initiala"), moment=MOMENT)
groups = sorted(sorted(m.external_id for m in (g.primary, *g.variants, *g.duplicates))
for g in self.store.group(self.store.items()))
self.assertEqual(groups, [["7452299", "7452300", "7452301"], ["7452302"]])
def test_a_stored_hint_is_recomputed_from_the_recorded_source(self):
# 落库时缺协议头的出处没认出来,键退回了帖子自己的 id;读时按出处重算后并组。
stale = self._booru_post("2", 1, character="ahri",
source="x.com/vileclipse/status/2101310844021928089")
groups = self._record_booru(
self._booru_post("1", 0, source="https://x.com/vileclipse/status/2101310844021928089"),
replace(stale, group_hint="rule34xxx:post:2"))
self.assertEqual(len(groups), 1)
def _upload(self, external_id, hours, *, duration=60.054, character="grace_ashcroft",
general=("athletic_female", "blender", "cowgirl_position", "nude"), **extra):
return replace(self._booru_post(external_id, hours * 60, character=character,
general=general, **extra), duration=duration)
def _members(self, groups):
return sorted(sorted(m.external_id for m in (g.primary, *g.variants, *g.duplicates))
for g in groups)
def test_the_same_clip_uploaded_again_days_later_joins_the_first_upload(self):
# Memz 的 Grace 动画:07-18 一帖带出处,07-23 又传两帖。重新编码过,只有时长逐毫秒
# 相同;后两帖多挂一个作品标签,一般标签也不完全一样。
later = {"resident_evil_9:_requiem": "copyright", "grace_ashcroft": "character",
"memz": "artist", "athletic_female": "general", "blender": "general",
"cowgirl_position": "general", "nude": "general", "3d_animation": "general"}
groups = self._record_booru(
self._upload("18148361", 0, source="https://danbooru.donmai.us/posts/11817280"),
self._upload("18193355", 104, tag_types=later),
self._upload("18193376", 104, tag_types=later))
self.assertEqual(self._members(groups), [["18148361", "18193355", "18193376"]])
def test_an_export_length_shared_by_other_characters_is_not_evidence(self):
# LazyProcrastinator 的 20.02 秒用在上百种角色组合上:同一角色隔几天的两帖是两部。
groups = self._record_booru(
self._upload("1", 0, duration=20.02, character="reika_(doa)"),
self._upload("2", 24 * 100, duration=20.02, character="reika_(doa)"),
self._upload("3", 24 * 50, duration=20.02, character="jote_(ffxvi)"))
self.assertEqual(len(groups), 3)
def test_a_filtered_page_still_knows_an_export_length(self):
# 只看未读时,别的角色那几帖可能都不在这一页;认固定长度要按筛选前的全部条目。
self._record_booru(
self._upload("1", 0, duration=20.02, character="reika_(doa)"),
self._upload("2", 24 * 100, duration=20.02, character="reika_(doa)"),
self._upload("3", 24 * 50, duration=20.02, character="jote_(ffxvi)"))
everything = self.store.items()
page = tuple(item for item in everything if item.external_id != "3")
self.assertEqual(len(self.store.group(page, population=everything)), 2)
def test_uploads_within_one_session_are_left_to_the_burst_rule(self):
# 同一小时里时长相同的几帖是同一场景的不同体位,连发判据按相邻帖的标签切开了:
# 1 与 3 标签相近,中间隔着体位不同的 2,这里不拿时长把 1 和 3 重新串起来。
handjob = ("beach", "handjob", "duo", "pov")
groups = self._record_booru(
self._upload("1", 0, general=handjob),
self._upload("2", 0.25, general=("beach", "ass_focus", "doggy", "wall")),
self._upload("3", 0.5, general=(*handjob[:3], "kneeling")))
self.assertEqual(len(groups), 3)
def test_reuploads_need_overlapping_tags_and_a_millisecond_length(self):
groups = self._record_booru(
self._upload("1", 0), self._upload("2", 48, general=("standing", "hat", "wall", "nude")),
self._upload("3", 0, duration=45.0, character="nyotengu"),
self._upload("4", 48, duration=45.0, character="nyotengu"))
self.assertEqual(len(groups), 4)
def test_two_declared_releases_are_not_joined_as_a_reupload(self):
# Pantsushi3D 发过两条推:来源自己说是两次发布,缩略图再像也不并。
groups = self._record_booru(
self._upload("4704518", 0, source="https://twitter.com/Pantsushi3D/status/1"),
self._upload("6178800", 24 * 392, source="https://twitter.com/Pantsushi3D/status/2"))
self.assertEqual(len(groups), 2)
def _clip(self, video_id, title, *, duration=20.0, character="angel (kof)",
work="King of Fighters", **extra_tags):
tag_types = {"beach": "general", character: "general", work: "copyright",
"LazyProcrastinator": "artist", **extra_tags}
return FollowCandidate(provider="rule34video", external_id=str(video_id),
title=title, duration=duration,
extra={"tag_types": tag_types})
def _record_clips(self, *clips):
source_id = self._source(provider="rule34video", ref="lazyprocrastinator")
self.store.record(source_id, _fetch(clips), moment=MOMENT)
return sorted(sorted(m.title for m in (g.primary, *g.variants, *g.duplicates))
for g in self.store.group(self.store.items()))
def test_a_batch_of_clips_folds_per_character(self):
# 同一段 KOF 动画导出成几条 20 秒短片连着传;Angel 与 Mai 是两个角色,分成两组。
mai = "mai shiranui (dead or alive)"
groups = self._record_clips(
self._clip(4608755, "Angel-riding hugging"), self._clip(4608759, "Angel-missionary"),
self._clip(4608779, "Angel-handjob"), self._clip(4608787, "Mai-blowjob", character=mai),
self._clip(4608795, "Mai-doggy", character=mai))
self.assertEqual(groups, [["Angel-handjob", "Angel-missionary", "Angel-riding hugging"],
["Mai-blowjob", "Mai-doggy"]])
def test_a_clip_a_few_dozen_uploads_earlier_joins_its_pack(self):
# Barney's Mom 一包 8 条 20 秒短片,Riding 比下一条早 64 个 id,中间夹着别人的上传。
freyja = {"freyja (final fantasy)": "general"}
groups = self._record_clips(
self._clip(4608531, "Barney's Mom - Riding", work="Final Fantasy", **freyja),
self._clip(4608595, "Barney's Mom - Doggy 3", work="Final Fantasy", **freyja),
self._clip(4608599, "Barney's Mom - Sitting Blowjob", work="Final Fantasy",
**freyja))
self.assertEqual(len(groups), 1)
def test_the_same_character_uploaded_the_next_day_stays_apart(self):
# LazyProcrastinator 的 Lavinia 两条隔天上传,id 相差 300,是两部。
groups = self._record_clips(
self._clip(4600000, "Lavinia-cowgirl", character="lavinia (zzz)"),
self._clip(4600300, "Lavinia-doggystyle", character="lavinia (zzz)"))
self.assertEqual(len(groups), 2)
def test_clips_far_apart_or_of_another_length_stay_apart(self):
groups = self._record_clips(
self._clip(4608755, "Angel-riding hugging"), self._clip(4609755, "Angel-missionary"),
self._clip(4609759, "Angel-handjob", duration=45.0))
self.assertEqual(len(groups), 3)
def test_a_series_title_does_not_fold_different_characters(self):
# 标题开头是系列名,角色在后半截;带括号的角色标签对不上就拆开。配音演员
# `Chloeangelva (VA)` 两边都有,但它归 artist,不算角色。
groups = self._record_clips(
self._clip(4454645, "Summer Fantasy – Cindy & Lunafreya", duration=97.0,
character="cindy aurum (final fantasy)", work="Final Fantasy",
**{"Chloeangelva (VA)": "artist"}),
self._clip(4454649, "Summer Fantasy – Tifa & Aerith", duration=97.0,
character="tifa lockhart (final fantasy)", work="Final Fantasy",
**{"Chloeangelva (VA)": "artist"}))
self.assertEqual(len(groups), 2)
def test_wip_is_surfaced_on_the_group(self):
source_id = self._source()
self.store.record(source_id, _fetch([
_candidate("1", "Mitsuru School Movie"),
_candidate("2", "Mitsuru School Movie [WIP]"),
]), moment=MOMENT)
group = self.store.group(self.store.items())[0]
self.assertTrue(group.has_wip)
self.assertEqual(group.primary.variant_kind, "main")
def test_versions_posted_together_fold_by_title_family(self):
# 同一作品按清晰度分帖发:尾巴上的 `Ultra HD` 不在变体词表里,靠「键接一小段
# 尾巴、自己带变体标记、同期发布」归进同一组。
source_id = self._source()
self.store.record(source_id, _fetch([
_candidate("1", "2B Love at Sunset - 720p", published_at="2026-08-31T23:57:42Z"),
_candidate("2", "2B Love at Sunset - 1080p", published_at="2026-08-31T23:59:24Z"),
_candidate("3", "2B Love at Sunset - 4K Ultra HD Unwatermarked",
published_at="2026-09-01T00:19:38Z"),
# 续作编号、隔了几个月的相似标题、尾巴上没有变体标记的,各自成组。
_candidate("4", "Sayuri - Cowgirl [4K]", published_at="2026-09-01T00:00:00Z"),
_candidate("5", "Sayuri - Cowgirl Part 2 [4K]", published_at="2026-09-01T00:00:00Z"),
_candidate("6", "Tifa Beach [4K]", published_at="2026-05-01T00:00:00Z"),
_candidate("7", "Tifa Beach Night [4K]", published_at="2026-09-01T00:00:00Z"),
_candidate("8", "Aerith Garden", published_at="2026-09-01T00:00:00Z"),
_candidate("9", "Aerith Garden Party", published_at="2026-09-01T00:00:00Z"),
]), moment=MOMENT)
groups = self.store.group(self.store.items())
sunset = [group for group in groups
if group.primary.title.startswith("2B Love at Sunset")]
self.assertEqual(len(sunset), 1)
self.assertEqual(sorted(item.external_id for item in sunset[0].variants), ["2", "3"])
self.assertEqual(len(groups), 7)
def test_the_same_author_on_other_sites_folds_once_names_are_stripped(self):
# 来源没绑实体时,标题里夹着的作者名入库剥不掉。给了作者映射,分组前剥掉,
# 相似标题的版本也跨这位作者的来源去对;一个词的标题底下不接别的作品。
fanbox = self._source(provider="kemono", ref="fanbox/30917150", label="Pantsushi · fanbox")
video = self._source(ref="pantsushi", label="pantsushi")
self.store.record(fanbox, _fetch([
_candidate("f1", "2B Love at Sunset - 1080p", provider="kemono",
published_at="2026-08-31T23:59:24Z"),
_candidate("f2", "Kasumi Training - Alt Black 4K Ultra HD", provider="kemono",
published_at="2026-06-24T00:00:00Z"),
_candidate("f3", "Tifa Lifeguard [4K]", provider="kemono",
published_at="2026-06-20T00:00:00Z"),
], provider="kemono", ref="fanbox/30917150"), moment=MOMENT)
self.store.record(video, _fetch([
_candidate("v1", "2B Love at Sunset [pantsushi] 4K", published_at="2026-09-02T00:00:00Z"),
_candidate("v2", "Kasumi Training - Alt Black 4K", published_at="2026-06-25T00:00:00Z"),
_candidate("v3", "Tifa [pantsushi]", published_at="2026-06-23T00:00:00Z"),
# 剥完等于别的作者的标题,而这位作者自己没有这部:不借剥名去撞别人的键。
_candidate("v4", "Kyrie Canaan [pantsushi]", published_at="2026-06-01T00:00:00Z"),
], ref="pantsushi"), moment=MOMENT)
other = self._source(ref="billyhhyb", label="billyhhyb")
self.store.record(other, _fetch([
_candidate("o1", "Kyrie Canaan", published_at="2026-06-01T00:00:00Z"),
], ref="billyhhyb"), moment=MOMENT)
items = self.store.items()
pantsushi = ("name:pantsushi", frozenset({"pantsushi"}))
authors = {fanbox: pantsushi, video: pantsushi,
other: ("name:billyhhyb", frozenset({"billyhhyb"}))}
folded = {frozenset(item.external_id for item in (g.primary, *g.variants, *g.duplicates))
for g in self.store.group(items, authors)}
self.assertEqual(folded, {frozenset({"f1", "v1"}), frozenset({"f2", "v2"}),
frozenset({"f3"}), frozenset({"v3"}),
frozenset({"v4"}), frozenset({"o1"})})
self.assertEqual(len(self.store.group(items)), 8)
def test_groups_are_ordered_newest_first(self):
source_id = self._source()
self.store.record(source_id, _fetch([
_candidate("1", "Older", published_at="2026-08-01T00:00:00Z"),
_candidate("2", "Newer", published_at="2026-08-20T00:00:00Z"),
]), moment=MOMENT)
self.assertEqual([g.primary.title for g in self.store.group(self.store.items())],
["Newer", "Older"])
def test_empty_input_groups_to_nothing(self):
self.assertEqual(self.store.group(()), ())
def test_same_provider_title_collision_is_not_merged_under_work_semantics(self):
# 实测踩到的:kemono 上「February Poll Animations」和「February Poll + Animations」
# 归一化后完全相同,却是两个帖子。同站两个 main 撞车只说明标题判据到头了。
source_id = self._source(provider="kemono", ref="fanbox/30917150")
self.store.record(source_id, _fetch([
FollowCandidate(provider="kemono", external_id="10",
title="February Poll + Animations",
published_at="2026-02-15T21:27:50Z"),
FollowCandidate(provider="kemono", external_id="19",
title="February Poll Animations",
published_at="2026-01-31T22:10:13Z"),
], provider="kemono", ref="fanbox/30917150"), moment=MOMENT)
groups = self.store.group(self.store.items())
self.assertEqual(len(groups), 2)
self.assertEqual([g.variants for g in groups], [(), ()])
def test_an_alt_still_folds_when_its_provider_has_only_one_main(self):
source_id = self._source()
self.store.record(source_id, _fetch([
_candidate("1", "Fiona - Paizuri"),
_candidate("2", "Fiona - Paizuri (Nude)"),
]), moment=MOMENT)
groups = self.store.group(self.store.items())
self.assertEqual(len(groups), 1)
self.assertEqual(len(groups[0].variants), 1)
def test_f95_resource_replies_are_independent_release_groups(self):
source_id = self._source(provider="f95zone", ref="50685", semantics="release")
self.store.record(source_id, _fetch([
FollowCandidate(provider="f95zone", external_id="21383374",
title="Lazy Procrastinator Collection [2026-06-28]",
published_at="2026-08-21T04:14:09Z"),
FollowCandidate(provider="f95zone", external_id="21394555",
title="Lazy Procrastinator Collection [2026-06-28]",
published_at="2026-08-22T18:09:23Z"),
], provider="f95zone", ref="50685", semantics="release"), moment=MOMENT)
groups = self.store.group(self.store.items())
self.assertEqual(len(groups), 2)
self.assertTrue(all(group.is_release for group in groups))
self.assertTrue(all(group.variants == () for group in groups))
# 最新楼层排在前面,但不会把较早的资源楼层吞成「动态」。
self.assertEqual(groups[0].primary.external_id, "21394555")
# 标题里的日期是批次标签,不是版本:`[2026-06-28]` 不进版本位。
self.assertIsNone(groups[0].primary.version)
class SaveAssetTests(_StoreCase):
def _one_item(self, **candidate_kwargs):
entity_id = self._entity()
source_id = self._source(entity_id=entity_id)
candidate_kwargs.setdefault("url", "https://rule34video.com/video/4542713/x/")
candidate_kwargs.setdefault("duration", 20.0)
self.store.record(
source_id,
_fetch([_candidate("4542713", "Fiona - Paizuri", **candidate_kwargs)]),
moment=MOMENT)
return entity_id, self.store.items()[0]
def test_saving_requires_explicit_confirmation(self):
_, item = self._one_item()
with self.assertRaises(FollowSourceError):
self.store.save_asset(item.id)
self.assertEqual(self.connection.execute(
"SELECT count(*) FROM asset").fetchone()[0], 0)
def test_confirmed_save_creates_one_online_asset_and_links_the_creator(self):
entity_id, item = self._one_item(published_at="2026-08-18T06:23:33Z")
asset_id = self.store.save_asset(item.id, confirm=True, moment=MOMENT)
row = self.connection.execute(
"SELECT location,path,name,medium,creator,duration,release_date FROM asset"
" WHERE id=?", (asset_id,)).fetchone()
self.assertEqual(row["location"], "online")
self.assertEqual(row["path"], "https://rule34video.com/video/4542713/x/")
self.assertEqual(row["medium"], "video")
self.assertEqual(row["creator"], "LazyProcrast")
self.assertEqual(row["release_date"], "2026-08-18")
relation = self.connection.execute(
"SELECT role,source FROM asset_entity WHERE asset_id=? AND entity_id=?",
(asset_id, entity_id)).fetchone()
self.assertEqual((relation["role"], relation["source"]),
("creator", "follow:rule34video"))
self.assertEqual(self.store.items()[0].status, "saved")
def test_saving_twice_is_idempotent(self):
_, item = self._one_item()
first = self.store.save_asset(item.id, confirm=True, moment=MOMENT)
second = self.store.save_asset(item.id, confirm=True, moment=MOMENT)
self.assertEqual(first, second)
self.assertEqual(self.connection.execute(
"SELECT count(*) FROM asset").fetchone()[0], 1)
def test_an_existing_online_asset_is_reused_not_duplicated(self):
_, item = self._one_item()
self.connection.execute(
"INSERT INTO asset(location,path,name) VALUES('online',?,'existing')",
(item.url,))
asset_id = self.store.save_asset(item.id, confirm=True, moment=MOMENT)
self.assertEqual(self.connection.execute(
"SELECT name FROM asset WHERE id=?", (asset_id,)).fetchone()[0], "existing")
def test_kemono_posts_without_duration_are_saved_as_illustrations(self):
source_id = self._source(provider="kemono", ref="fanbox/30917150")
self.store.record(source_id, _fetch(
[FollowCandidate(provider="kemono", external_id="11406814", title="VVD 7",
url="https://kemono.cr/fanbox/user/30917150/post/11406814")],
provider="kemono", ref="fanbox/30917150"), moment=MOMENT)
asset_id = self.store.save_asset(self.store.items()[0].id, confirm=True,
moment=MOMENT)
self.assertEqual(self.connection.execute(
"SELECT medium FROM asset WHERE id=?", (asset_id,)).fetchone()[0],
"illustration")
def test_a_candidate_without_a_page_url_cannot_be_saved(self):
source_id = self._source()
self.store.record(source_id, _fetch([_candidate("1", "No URL")]), moment=MOMENT)
with self.assertRaises(FollowSourceError):
self.store.save_asset(self.store.items()[0].id, confirm=True)
def test_missing_item_is_an_error(self):
with self.assertRaises(FollowSourceError):
self.store.save_asset(9999, confirm=True)
class MediaReparseTests(_StoreCase):
"""`source_needs_media_reparse` 的判定口径。
这个判定交给 SQLite 的 `json_extract`,不把整个来源的 `metadata_json` 取回
Python 再逐条 `json.loads`:回填过的来源单源上千行,为一个布尔值全解一遍。
下推到 SQL 要守住同样的容忍度。
"""
def _write_metadata(self, source_id: int, external_id: str, raw) -> None:
self.connection.execute(
"INSERT INTO follow_item(source_id,external_id,release_key,title,url,"
"metadata_json,first_seen_at,last_seen_at) VALUES(?,?,?,?,?,?,?,?)",
(source_id, external_id, external_id, external_id,
f"https://x.test/{external_id}",
raw, "2026-08-25T00:00:00Z", "2026-08-25T00:00:00Z"))
def test_no_item_needs_a_reparse_on_an_empty_source(self):
self.assertFalse(self.store.source_needs_media_reparse(self._source()))
def test_one_flagged_item_is_enough(self):
source_id = self._source()
self._write_metadata(source_id, "a", json.dumps({"markers": []}))
self._write_metadata(source_id, "b",
json.dumps({"media_needs_credential": True}))
self.assertTrue(self.store.source_needs_media_reparse(source_id))
def test_an_explicit_false_does_not_count(self):
source_id = self._source()
self._write_metadata(source_id, "a",
json.dumps({"media_needs_credential": False}))
self.assertFalse(self.store.source_needs_media_reparse(source_id))
def test_unparsable_or_odd_shaped_metadata_is_skipped_not_fatal(self):
"""非法 JSON 只是「这条不知道」,不该让整个检查崩掉。
`json_extract` 遇到非法 JSON 是报错而不是返回 NULL,所以查询里那层
`json_valid` 不能省:它顶的是 `except JSONDecodeError: continue` 那一级容忍。
列上是 `NOT NULL DEFAULT '{}'`,所以「没有元数据」落盘的形状是 `{}` 和
空字串,不是 NULL;空字串并不是合法 JSON,同样得被 `json_valid` 拦住。
"""
source_id = self._source()
self._write_metadata(source_id, "a", "not json at all")
self._write_metadata(source_id, "b", "")
self._write_metadata(source_id, "c", "[1,2,3]")
self._write_metadata(source_id, "d", "{}")
self.assertFalse(self.store.source_needs_media_reparse(source_id))
self._write_metadata(source_id, "e",
json.dumps({"media_needs_credential": True}))
self.assertTrue(self.store.source_needs_media_reparse(source_id))
def test_the_flag_does_not_leak_across_sources(self):
first = self._source()
second = self._source(ref="someone-else")
self._write_metadata(second, "a",
json.dumps({"media_needs_credential": True}))
self.assertFalse(self.store.source_needs_media_reparse(first))
self.assertTrue(self.store.source_needs_media_reparse(second))
class ReleaseGroupTests(unittest.TestCase):
def test_group_is_immutable(self):
group = ReleaseGroup("k", None, (), ())
with self.assertRaises(Exception):
group.release_key = "other"
class AuthorIdentityTests(_StoreCase):
"""作者别名表的写入口径。这些 SQL 归存储层,不写在 Web 处理函数里。"""
def test_preferred_name_preserves_all_spellings_and_the_group(self):
current = "Suzutaru 3D (鈴太郎3D)"
self.store.upsert_author_alias(current, "suzutaro3d", source="manual")
self.store.upsert_author_alias(current, "鈴太郎3D", source="manual")
result = self.store.rename_author_group(normalized_author_name(current), "鈴太郎3D")
mapping, groups = self.store.author_aliases()
self.assertEqual(result, {"previous": current, "canonical": "鈴太郎3D"})
self.assertEqual(groups[0]["canonical_name"], "鈴太郎3D")
self.assertEqual({row["name"] for row in groups[0]["aliases"]}, {current, "suzutaro3d"})
self.assertEqual(set(mapping.values()), {normalized_author_name("鈴太郎3D")})
def test_preferred_name_rejects_other_groups_and_missing_groups(self):
self.store.upsert_author_alias("作者 A", "alias A", source="manual")
self.store.upsert_author_alias("作者 B", "alias B", source="manual")
before = self.store.author_aliases()
for key, name in ((normalized_author_name("作者 A"), "alias B"), ("missing", "名称"),
(normalized_author_name("作者 A"), "")):
with self.subTest(key=key, name=name), self.assertRaises(ValueError):
self.store.rename_author_group(key, name)
self.assertEqual(self.store.author_aliases(), before)
def test_the_container_suffix_is_not_part_of_the_author_name(self):
"""F95 的线程标题说的是一个容器,不是另一个作者。
真实数据是 `Lazy Procrastinator Collection`,而每一条作者来源都是
`LazyProcrastinator`;留着这个通用后缀会凭空多出一个分组。
"""
self.assertEqual(
normalized_author_name("Lazy Procrastinator Collection",
provider="f95zone"),
normalized_author_name("LazyProcrastinator"))
# 只对 F95 成立:别的站上 `Collection` 可能真是名字的一部分。
self.assertNotEqual(
normalized_author_name("Lazy Procrastinator Collection"),
normalized_author_name("LazyProcrastinator"))
def test_the_service_suffix_only_says_where_they_publish(self):
self.assertEqual(normalized_author_name("LazyProcrastinator · fanbox"),
normalized_author_name("lazyprocrastinator"))
self.assertEqual(author_display_text("Billyhhyb · patreon"), "Billyhhyb")
def test_the_f95_author_sits_in_the_last_bracket(self):
"""站点的标题约定是 `作品名 [版本或日期] [作者]`。"""
self.assertEqual(
author_display_text("Strauzek Collection [2026-09-04] [Mr_Strauz]",
provider="f95zone"), "Mr_Strauz")
self.assertEqual(
author_display_text("Some Game [v1.2] [Final] [DevName]",
provider="f95zone"), "DevName")
def test_a_title_without_an_author_bracket_falls_back_to_its_stem(self):
# `[Collection Request]` 是版块标签,`Complete Collection` 是容器措辞,
# 剥掉之后剩下的才是这个人。
self.assertEqual(
author_display_text(
"[Collection Request] Suzutaru 3D - Complete Collection",
provider="f95zone"), "Suzutaru 3D")
self.assertEqual(
author_display_text("Memz3D Models Collection", provider="f95zone"),
"Memz3D")
def test_both_handles_in_the_author_bracket_are_kept(self):
# 一个人常在作者位上写两个手柄,第一个当显示名,另一个够格做别名候选。
self.assertEqual(
f95_author_handles(
"Lazy Procrastinator Collection [2026-06-28]"
" [LazyProcrastinator/LazyProcrast]"),
("LazyProcrastinator", "LazyProcrast"))
self.assertEqual(
f95_author_name(
"Lazy Procrastinator Collection [2026-06-28]"
" [LazyProcrastinator/LazyProcrast]"), "LazyProcrastinator")
def test_the_bracket_rule_is_only_for_f95(self):
# 别的站上方括号可能真是标签的一部分,不能拿 F95 的约定去套。
self.assertEqual(
author_display_text("Strauzek Collection [2026-09-04] [Mr_Strauz]"),
"Strauzek Collection [2026-09-04] [Mr_Strauz]")
def test_the_f95_author_key_matches_the_name_on_the_other_sites(self):
self.assertEqual(
normalized_author_name("Strauzek Collection [2026-09-04] [Mr_Strauz]",
provider="f95zone"),
normalized_author_name("mr_strauz"))
def test_a_manual_alias_maps_both_names_to_one_canonical_key(self):
self.store.upsert_author_alias("Initiala", "ffxivinitiala", source="manual",
moment=MOMENT)
mapping, groups = self.store.author_aliases()
self.assertEqual(mapping["ffxivinitiala"], "initiala")
self.assertEqual(mapping["initiala"], "initiala")
self.assertEqual([group["canonical_name"] for group in groups], ["Initiala"])
self.assertEqual([alias["name"] for alias in groups[0]["aliases"]],
["ffxivinitiala"])
def test_automatic_evidence_never_overwrites_a_decision(self):
"""人工确认可以有意重新分组;自动证据只填此前未知的手柄。"""
self.store.upsert_author_alias("Initiala", "ffxivinitiala", source="manual",
moment=MOMENT)
self.assertIsNone(self.store.upsert_author_alias(
"Someone Else", "ffxivinitiala", source="official:fanbox", moment=MOMENT))
mapping, _groups = self.store.author_aliases()
self.assertEqual(mapping["ffxivinitiala"], "initiala")
def test_two_names_that_normalize_the_same_are_not_an_alias(self):
for canonical, alias in (("Initiala", "initi-ala"), ("", "x"), ("x", "")):
with self.assertRaises(ValueError):
self.store.upsert_author_alias(canonical, alias, source="manual")
def test_the_canonical_name_cannot_be_removed_as_an_alias(self):
"""删规范名会拆散整个组,剩下的别名指向一个不存在的键。"""
self.store.upsert_author_alias("Initiala", "ffxivinitiala", source="manual",
moment=MOMENT)
with self.assertRaises(ValueError):
self.store.remove_author_alias("Initiala")
with self.assertRaises(ValueError):
self.store.remove_author_alias("never-registered")
with self.assertRaises(ValueError):
self.store.remove_author_alias("")
self.store.remove_author_alias("ffxivinitiala")
self.assertEqual(self.store.author_aliases(), ({"initiala": "initiala"}, []))
def test_an_official_handle_is_learned_only_from_one_unambiguous_author(self):
learned = self.store.learn_official_author_alias(
"fanbox", "ffxivinitiala",
(_candidate("1", "a", provider="fanbox", author="Initiala"),))
self.assertEqual(learned["alias"], "ffxivinitiala")
self.assertEqual(learned["source"], "official:fanbox")
# 归档站的 ref 是数字 id,不是名字。
self.assertIsNone(self.store.learn_official_author_alias(
"kemono", "fanbox/30917150",
(_candidate("1", "a", provider="kemono", author="Initiala"),)))
def test_iwara_author_profile_provides_a_trusted_handle_alias(self):
learned = self.store.learn_official_author_alias(
"iwara", "user_creator",
(_candidate("1", "Work", provider="iwara", author="Test Creator"),))
self.assertEqual(learned["alias"], "user_creator")
self.assertEqual(learned["source"], "official:iwara")
class SourceRemovalTests(_StoreCase):
def test_removing_a_source_takes_it_out_of_the_listing(self):
source_id = self._source()
other = self._source(provider="rule34xxx", ref="tag")
self.store.remove_source(source_id)
self.assertEqual([row["id"] for row in self.store.sources()], [other])
def test_removing_a_source_takes_its_items_and_their_playback(self):
source_id = self._source()
self.store.record(source_id, _fetch([_candidate("1", "Fiona")]), moment=MOMENT)
self.store.record_playback(self.store.items()[0].id, MOMENT)
self.store.remove_source(source_id)
for table in ("follow_item", "follow_playback"):
self.assertEqual(self.connection.execute(
f"SELECT count(*) FROM {table}").fetchone()[0], 0, table)
self.assertEqual(self.connection.execute("PRAGMA foreign_key_check").fetchall(), [])
class PlaybackTests(_StoreCase):
def _item(self):
source_id = self._source()
self.store.record(source_id, _fetch([_candidate("1", "Fiona")]),
moment=MOMENT)
return self.store.items()[0].id
def test_playing_a_new_item_marks_it_seen_once(self):
item_id = self._item()
self.assertEqual(self.store.record_playback(item_id, MOMENT), "seen")
# 已经播过的不再改状态:手动标成 ignored 之后再播不该被拉回 seen。
self.store.set_status(item_id, "ignored")
self.assertEqual(self.store.record_playback(item_id, MOMENT), "ignored")
count = self.connection.execute(
"SELECT play_count FROM follow_playback WHERE follow_item_id=?",
(item_id,)).fetchone()[0]
self.assertEqual(count, 2)
def test_activity_accumulates_time_and_keeps_the_furthest_point(self):
item_id = self._item()
first = self.store.record_playback_activity(
item_id, position=30, duration=100, delta=30, moment=MOMENT)
self.assertEqual(first, {"play_seconds": 30.0, "max_reached": 0.3})
# 往回拖再看一遍:时长累加,最远位置不倒退。
second = self.store.record_playback_activity(
item_id, position=10, duration=100, delta=10, moment=MOMENT)
self.assertEqual(second, {"play_seconds": 40.0, "max_reached": 0.3})
ended = self.store.record_playback_activity(
item_id, position=99, duration=100, delta=1, ended=True, moment=MOMENT)
self.assertEqual(ended["max_reached"], 1.0)
def test_an_unknown_item_is_a_request_error_not_a_storage_failure(self):
"""调用方把 ValueError 映射成 400;这里不能变成 500。"""
for call in (lambda: self.store.record_playback(9999),
lambda: self.store.record_playback_activity(9999)):
with self.assertRaises(ValueError):
call()
def test_nonsense_numbers_do_not_reach_the_database(self):
item_id = self._item()
result = self.store.record_playback_activity(
item_id, position=-5, duration=0, delta=-3, moment=MOMENT)
self.assertEqual(result, {"play_seconds": 0.0, "max_reached": 0.0})
class PartialCandidateTests(_StoreCase):
"""`partial=True` 的候选(跳过了第二阶段)落库时不能抹掉已有的细节。
upsert 不整块替换 metadata、media_url、thumb_url 和 group_hint:第二阶段一旦
按「已补齐」跳过,同一条再落一次就会把上一轮取到的细节清成空——而清空之后
「详情本来就没有」和「这次没去问」在数据里长得一模一样。
"""
def _both(self):
source_id = self._source(provider="rule34paheal", ref="initiala")
full = FollowCandidate(
provider="rule34paheal", external_id="7428820", title="Tifa",
url="https://rule34.paheal.net/post/view/7428820",
media_url="https://r34i.paheal-cdn.net/df/fb/video",
thumb_url="https://r34t.paheal.net/df/fb/poster",
published_at="2026-08-26T15:21:00Z", duration=28.4, author="VHSephi",
group_hint="subscribestar:2639932",
extra={"tag": "initiala", "source": "https://subscribestar.adult/posts/2639932",
"tag_types": {"tifa_lockhart": "general"}})
partial = FollowCandidate(
provider="rule34paheal", external_id="7428820", title="Tifa Lockhart",
url="https://rule34.paheal.net/post/view/7428820",
media_url="https://r34i.paheal-cdn.net/df/fb/listing",
thumb_url="https://r34t.paheal.net/df/fb/thumb",
group_hint="rule34paheal:post:7428820", partial=True,
extra={"tag": "initiala", "media_kind": "video",
"tag_types": {"amina": "general"}})
return source_id, full, partial
def _item(self, source_id):
return self.store.items(source_id=source_id)[0]
def _record(self, source_id, candidate):
self.store.record(source_id, _fetch([candidate], provider="rule34paheal",
ref="initiala"), moment=MOMENT)
def test_a_partial_row_keeps_every_detail_only_column(self):
source_id, full, partial = self._both()
self._record(source_id, full)
self._record(source_id, partial)
row = self._item(source_id)
self.assertEqual(row.published_at, "2026-08-26T15:21:00Z")
self.assertEqual(row.duration, 28.4)
self.assertEqual(row.media_url, "https://r34i.paheal-cdn.net/df/fb/video")
self.assertEqual(row.thumb_url, "https://r34t.paheal.net/df/fb/poster")
self.assertEqual(row.group_hint, "subscribestar:2639932")
# 列表本来就权威的那几列照常更新。
self.assertEqual(row.title, "Tifa Lockhart")
def test_a_partial_row_merges_metadata_instead_of_replacing_it(self):
source_id, full, partial = self._both()
self._record(source_id, full)
self._record(source_id, partial)
metadata = self._item(source_id).metadata
self.assertEqual(metadata["source"], "https://subscribestar.adult/posts/2639932",
"详情给出的出处不在这次的补丁里,就该原样留着")
self.assertEqual(metadata["media_kind"], "video")
self.assertEqual(metadata["tag_types"],
{"tifa_lockhart": "general", "amina": "general"})
def test_a_complete_row_still_replaces_what_it_carries(self):
"""第二阶段成功时是完整视图,该覆盖就覆盖——否则改正过的细节写不进去。"""
source_id, full, partial = self._both()
self._record(source_id, partial)
self._record(source_id, full)
row = self._item(source_id)
self.assertEqual(row.thumb_url, "https://r34t.paheal.net/df/fb/poster")
self.assertEqual(row.group_hint, "subscribestar:2639932")
self.assertEqual(row.metadata["tag_types"], {"tifa_lockhart": "general"})
class ImageDimsTests(_StoreCase):
"""图片宽高是给图片墙占位用的比例,三处来源(接口、文件头、界面回写)同权。"""
def _record(self, source_id, candidate):
self.store.record(source_id, _fetch([candidate], provider="kemono", ref="a"),
moment=MOMENT)
return self.store.items(source_id=source_id)[0]
def _image(self, **extra):
return FollowCandidate(
provider="kemono", external_id="1", title="Sketch",
url="https://kemono.cr/fanbox/user/1/post/1",
media_url="https://kemono.cr/data/ab/cd/abcd.jpg",
thumb_url="https://kemono.cr/thumbnail/data/ab/cd/abcd.jpg", extra=extra)
def test_item_dims_fill_only_a_gap_and_say_whether_they_wrote(self):
source_id = self._source(provider="kemono", ref="a")
item = self._record(source_id, self._image())
self.assertTrue(self.store.set_image_dims(item.id, 800, 600))
self.assertFalse(self.store.set_image_dims(item.id, 1024, 768),
"已有尺寸不覆盖:三处给的都是同一张图的比例,没有谁更权威")
metadata = self.store.item(item.id).metadata
self.assertEqual((metadata["width"], metadata["height"]), (800, 600))
self.assertFalse(self.store.set_image_dims(item.id + 99, 800, 600))
with self.assertRaises(FollowSourceError):
self.store.set_image_dims(item.id, 0, 600)
def test_media_dims_land_on_that_one_media_only(self):
source_id = self._source(provider="kemono", ref="a")
item = self._record(source_id, self._image(media_items=[
{"id": "a", "media_kind": "image", "url": "https://kemono.cr/data/a.png"},
{"id": "b", "media_kind": "image", "url": "https://kemono.cr/data/b.png"},
]))
self.assertTrue(self.store.set_image_dims(item.id, 1920, 1080, media_index=1))
self.assertFalse(self.store.set_image_dims(item.id, 1, 1, media_index=5))
media_items = self.store.item(item.id).metadata["media_items"]
self.assertNotIn("width", media_items[0])
self.assertEqual((media_items[1]["width"], media_items[1]["height"]), (1920, 1080))
self.assertNotIn("width", self.store.item(item.id).metadata,
"媒体级尺寸不该顺手写成条目级")
def test_partial_item_and_media_dimensions_accept_a_complete_pair(self):
source_id = self._source(provider="kemono", ref="a")
item = self._record(source_id, self._image(width=800, media_items=[
{"id": "a", "media_kind": "image", "url": "https://kemono.cr/data/a.png", "width": 800},
{"id": "b", "media_kind": "image", "url": "https://kemono.cr/data/b.png", "height": 600},
]))
self.assertTrue(self.store.set_image_dims(item.id, 800, 600))
for index in (0, 1):
self.assertTrue(self.store.set_image_dims(item.id, 800, 600, media_index=index))
self.assertFalse(self.store.set_image_dims(item.id, 1, 1, media_index=index))
metadata = self.store.item(item.id).metadata
self.assertEqual((metadata["width"], metadata["height"]), (800, 600))
self.assertEqual([(media["width"], media["height"]) for media in metadata["media_items"]],
[(800, 600), (800, 600)])
def test_a_full_refetch_keeps_learned_dims_until_the_source_reports_its_own(self):
source_id = self._source(provider="kemono", ref="a")
item = self._record(source_id, self._image(tags="a"))
self.store.set_image_dims(item.id, 800, 600)
# 下一轮检查更新是完整候选,走整块替换 metadata 的那条 SET。
metadata = self._record(source_id, self._image(tags="a b")).metadata
self.assertEqual(metadata["tags"], "a b")
self.assertEqual((metadata["width"], metadata["height"]), (800, 600))
# 来源自己开始报尺寸时以来源为准。
metadata = self._record(source_id, self._image(width=1600, height=1200)).metadata
self.assertEqual((metadata["width"], metadata["height"]), (1600, 1200))
def test_a_full_refetch_keeps_learned_media_dims_by_media_identity(self):
"""清单里每张图学到的尺寸按媒体稳定键对回:作者在前面插了一张,尺寸不串位。"""
source_id = self._source(provider="kemono", ref="a")
item = self._record(source_id, self._image(media_items=[
{"id": "a", "media_kind": "image", "url": "https://kemono.cr/data/a.png"},
{"id": "b", "media_kind": "image", "url": "https://kemono.cr/data/b.png"},
]))
self.store.set_image_dims(item.id, 1920, 1080, media_index=1)
media_items = self._record(source_id, self._image(media_items=[
{"id": "new", "media_kind": "image", "url": "https://kemono.cr/data/new.png"},
{"id": "a", "media_kind": "image", "url": "https://kemono.cr/data/a.png"},
{"id": "b", "media_kind": "image", "url": "https://kemono.cr/data/b.png",
"width": 640, "height": 480},
{"id": "c", "media_kind": "image", "url": "https://kemono.cr/data/c.png"},
])).metadata["media_items"]
self.assertNotIn("width", media_items[0])
self.assertNotIn("width", media_items[1])
self.assertEqual((media_items[2]["width"], media_items[2]["height"]), (640, 480),
"来源自己报了尺寸时以来源为准")
self.assertNotIn("width", media_items[3])
media_items = self._record(source_id, self._image(media_items=[
{"id": "b", "media_kind": "image", "url": "https://kemono.cr/data/b.png"},
])).metadata["media_items"]
self.assertEqual((media_items[0]["width"], media_items[0]["height"]), (640, 480))
def test_a_refetched_media_tuple_keeps_learned_dims(self):
"""f95 的清单是附件与 gofile 两段拼成的 tuple,学到的尺寸照样对回。"""
source_id = self._source(provider="kemono", ref="a")
attachment = {"id": "f95-attachment-1", "media_kind": "image",
"url": "https://attachments.f95zone.to/2026/10/1_a.png"}
item = self._record(source_id, self._image(media_items=[attachment]))
self.store.set_image_dims(item.id, 2000, 1125, media_index=0)
media_items = self._record(source_id, self._image(media_items=(attachment,))).metadata["media_items"]
self.assertEqual((media_items[0]["width"], media_items[0]["height"]), (2000, 1125))
class EnrichedMarkTests(_StoreCase):
"""「这一行不必再打详情页」怎么从 ledger 里读出来。"""
def test_the_paheal_mark_is_a_published_time_the_detail_page_gave(self):
source_id = self._source(provider="rule34paheal", ref="initiala")
self.store.record(source_id, _fetch([
FollowCandidate(provider="rule34paheal", external_id="1", title="a",
published_at="2026-08-26T15:21:00Z"),
FollowCandidate(provider="rule34paheal", external_id="2", title="b",
partial=True),
], provider="rule34paheal", ref="initiala"), moment=MOMENT)
self.assertEqual(self.store.enriched_external_ids(source_id, "published_at"),
frozenset({"1"}))
def test_the_rule34xxx_mark_is_the_taxonomy_only_the_detail_page_gives(self):
source_id = self._source(provider="rule34xxx", ref="tag")
self.store.record(source_id, _fetch([
FollowCandidate(provider="rule34xxx", external_id="1", title="a",
published_at="2026-08-26T15:21:00Z",
extra={"tag_types": {"tifa": "general"}}),
# 上传时间来自列表,详情页这次被挡回来了:这一条还没补齐。
FollowCandidate(provider="rule34xxx", external_id="2", title="b",
published_at="2026-08-26T15:20:00Z", partial=True,
extra={"tag": "tag"}),
], provider="rule34xxx", ref="tag"), moment=MOMENT)
self.assertEqual(self.store.enriched_external_ids(source_id, "tag_types_duration"),
frozenset({"1"}))
def test_a_rule34xxx_video_without_duration_is_not_yet_enriched(self):
"""分类有了、mp4 的时长没读到,下次检查还要进第二阶段;图片不需要时长。"""
source_id = self._source(provider="rule34xxx", ref="tag")
types = {"tag_types": {"tifa": "general"}}
self.store.record(source_id, _fetch([
FollowCandidate(provider="rule34xxx", external_id="clip", title="a",
media_url="https://api-cdn-mp4.rule34.xxx/images/1/a.mp4",
extra=types),
FollowCandidate(provider="rule34xxx", external_id="timed", title="b",
media_url="https://api-cdn-mp4.rule34.xxx/images/1/b.mp4",
duration=12.5, extra=types),
FollowCandidate(provider="rule34xxx", external_id="still", title="c",
media_url="https://api-cdn.rule34.xxx/images/1/c.jpeg",
extra=types),
], provider="rule34xxx", ref="tag"), moment=MOMENT)
self.assertEqual(self.store.enriched_external_ids(source_id, "tag_types_duration"),
frozenset({"timed", "still"}))
def test_a_partial_candidate_fills_an_empty_duration_without_overwriting(self):
"""帖子页被挡回来时候选仍是 partial,文件头读到的时长只补空着的那一格。"""
source_id = self._source(provider="rule34xxx", ref="tag")
self.store.record(source_id, _fetch([
FollowCandidate(provider="rule34xxx", external_id="empty", title="a"),
FollowCandidate(provider="rule34xxx", external_id="known", title="b",
duration=30.0),
], provider="rule34xxx", ref="tag"), moment=MOMENT)
self.store.record(source_id, _fetch([
FollowCandidate(provider="rule34xxx", external_id="empty", title="a",
partial=True, duration=10.0),
FollowCandidate(provider="rule34xxx", external_id="known", title="b",
partial=True, duration=99.0),
], provider="rule34xxx", ref="tag"), moment=MOMENT)
durations = dict(self.connection.execute(
"SELECT external_id, duration FROM follow_item WHERE source_id=?", (source_id,)))
self.assertEqual(durations, {"empty": 10.0, "known": 30.0})
def test_a_rule34video_row_stays_detailed_when_the_list_sees_it_again(self):
"""补齐过的 rule34video 行再被列表看到时不打详情页,详情给的值都要留住。"""
source_id = self._source(provider="rule34video", ref="artist")
self.store.record(source_id, _fetch([
FollowCandidate(provider="rule34video", external_id="1", title="a",
media_url="https://rule34video.com/get_file/1_720p.mp4/",
published_at="2026-08-18T00:00:00Z",
extra={"media_kind": "video", "published_precision": "exact",
"tag_types": {"3D": "metadata"}}),
FollowCandidate(provider="rule34video", external_id="2", title="b",
media_url="https://rule34video.com/get_file/2_preview.mp4/",
partial=True, extra={"media_kind": "preview_clip"}),
], provider="rule34video", ref="artist"), moment=MOMENT)
self.assertEqual(self.store.enriched_external_ids(source_id, "tag_types"),
frozenset({"1"}))
self.store.record(source_id, _fetch([
FollowCandidate(provider="rule34video", external_id="1", title="a",
media_url="https://rule34video.com/get_file/1_preview.mp4/",
published_at="2026-08-10T00:00:00Z", partial=True,
extra={"added_text": "2 months ago"}),
], provider="rule34video", ref="artist"), moment=MOMENT + timedelta(minutes=1))
media_url, published_at, metadata = self.connection.execute(
"SELECT media_url, published_at, metadata_json FROM follow_item"
" WHERE source_id=? AND external_id='1'", (source_id,)).fetchone()
self.assertEqual(media_url, "https://rule34video.com/get_file/1_720p.mp4/")
self.assertEqual(published_at, "2026-08-18T00:00:00Z")
self.assertEqual(json.loads(metadata)["media_kind"], "video")
self.assertEqual(self.store.enriched_external_ids(source_id, "tag_types"),
frozenset({"1"}))
def test_every_kemono_row_in_the_ledger_counts_as_judged(self):
source_id = self._source(provider="kemono", ref="fanbox/1")
self.store.record(source_id, _fetch([
FollowCandidate(provider="kemono", external_id="10", title="a"),
FollowCandidate(provider="kemono", external_id="11", title="b", partial=True),
], provider="kemono", ref="fanbox/1"), moment=MOMENT)
self.assertEqual(self.store.enriched_external_ids(source_id, "kept"),
frozenset({"10", "11"}))
def test_an_unregistered_mark_is_refused_not_silently_matched(self):
source_id = self._source()
with self.assertRaises(ValueError):
self.store.enriched_external_ids(source_id, "whatever")
class StoredCompilationTests(_StoreCase):
"""判据收紧之前入库的跨作者打包,按同一判据清退。"""
def _record(self, external_id, title, models):
source_id = self._source()
self.store.record(source_id, _fetch([
_candidate(external_id, title, extra={"models": list(models),
"model_count": len(models)}),
]), moment=MOMENT)
return self.store.items()[0]
def test_voice_and_audio_credits_keep_a_single_creator_work_out_of_the_list(self):
self._record("1", "Yunara Showing Ahri Some Discipline",
["Iidssm", "Adaline (VA)", "GeminiStarsign1 (VA)",
"Huntress___ (Audio/SFX)", "HentAudio (Audio)"])
self.assertEqual(self.store.collected_compilations(), ())
def test_a_cross_artist_pack_is_listed_with_both_counts(self):
self._record("2", "Fuck Track / Futa PMV", [f"M{n}" for n in range(13)])
listed = self.store.collected_compilations()
self.assertEqual(len(listed), 1)
self.assertEqual((listed[0].credited, listed[0].visual), (13, 13))
self.assertEqual(listed[0].external_id, "2")
def test_an_item_already_saved_as_an_asset_is_left_alone(self):
item = self._record("3", "ON AND ON | HMV / PMV", [f"M{n}" for n in range(17)])
self.connection.execute("UPDATE follow_item SET url=? WHERE id=?",
("https://rule34video.com/video/3/x/", item.id))
self.store.save_asset(item.id, confirm=True, moment=MOMENT)
self.assertEqual(self.store.collected_compilations(), ())
def test_purging_requires_explicit_confirmation(self):
self._record("4", "Resident Evil - The Fallen Saga",
[f"M{n}" for n in range(14)])
listed = self.store.collected_compilations()
with self.assertRaises(FollowSourceError):
self.store.purge_compilations(listed)
self.assertEqual(self.connection.execute(
"SELECT count(*) FROM follow_item").fetchone()[0], 1)
def test_a_confirmed_purge_takes_the_playback_rows_with_it(self):
item = self._record("5", "triss blacked censored", [f"M{n}" for n in range(13)])
self.store.record_playback(item.id)
listed = self.store.collected_compilations()
self.assertEqual(self.store.purge_compilations(listed, confirm=True), 1)
self.assertEqual(self.connection.execute(
"SELECT count(*) FROM follow_item").fetchone()[0], 0)
self.assertEqual(self.connection.execute(
"SELECT count(*) FROM follow_playback").fetchone()[0], 0)
def _old_snapshot(directory: Path, index: int, size: int = 10) -> Path:
"""升级前留下的一份快照:`.raw` 加同名 `.json`,名字按序号排出先后。"""
directory.mkdir(parents=True, exist_ok=True)
raw = directory / f"20260801T{index:06d}.000000Z-{index:064x}.raw"
raw.write_bytes(b"x" * size)
raw.with_suffix(".json").write_bytes(b"{}")
return raw
class EvidenceRetentionTests(_StoreCase):
"""原始快照:相同的不重写,每个来源只留最近 3 份和被条目引用的,存量等用户确认才删。"""
def _evidence(self):
return [row[0] for row in self.connection.execute("SELECT evidence_path FROM follow_item")]
def _raws(self, provider="rule34video"):
return sorted((self.root / "sources" / "follow" / provider).rglob("*.raw"))
def test_an_unchanged_page_reuses_the_latest_snapshot(self):
source_id = self._source()
fetch = _fetch([_candidate("1", "A")])
first = self.store.record(source_id, fetch, moment=MOMENT)
again = self.store.record(source_id, fetch, moment=MOMENT + timedelta(hours=1))
self.assertEqual(again.evidence_path, first.evidence_path)
self.assertEqual(len(self._raws()), 1)
changed = self.store.record(source_id, replace(fetch, raw_body=b"<html>new</html>"),
moment=MOMENT + timedelta(hours=2))
self.assertNotEqual(changed.evidence_path, first.evidence_path)
repeat = self.store.record(source_id, fetch, moment=MOMENT + timedelta(hours=3))
self.assertNotEqual(repeat.evidence_path, first.evidence_path,
"只和最新一份比:页面改回旧样子也记一份新的")
self.assertEqual(len(self._raws()), 3)
self.assertEqual(self._evidence(), [first.evidence_path])
def test_each_source_keeps_three_recent_snapshots_and_the_referenced_ones(self):
other = self.root / "sources" / "follow" / "rule34video" / "another-source"
untouched = [_old_snapshot(other, index) for index in range(5)]
source_id = self._source()
paths = []
for index in range(5):
outcome = self.store.record(
source_id, _fetch([_candidate("1", "A")], raw=f"<html>{index}</html>".encode()),
moment=MOMENT + timedelta(hours=index))
paths.append(self.root / "sources" / Path(outcome.evidence_path))
self.assertEqual(self._evidence(), [str(paths[0].relative_to(self.root / "sources"))])
self.assertEqual([path.exists() for path in paths], [True, False, True, True, True])
self.assertFalse(paths[1].with_suffix(".json").exists())
self.assertTrue(all(path.exists() for path in untouched), "只清这次写入的那个来源目录")
def test_a_new_snapshot_only_offsets_its_own_growth_and_leaves_the_stock(self):
"""升级后第一次检查不能把存量一口气删光:那在写事务里,也绕过了「修复与迁移」的确认。"""
source_id = self._source()
fetch = _fetch([_candidate("1", "A")])
directory = (self.root / "sources" / Path(self.store.record(source_id, fetch, moment=MOMENT)
.evidence_path)).parent
stock = [_old_snapshot(directory, index) for index in range(20)]
self.store.record(source_id, replace(fetch, raw_body=b"<html>new</html>"),
moment=MOMENT + timedelta(hours=1))
self.assertEqual(sum(not raw.exists() for raw in stock), 1)
self.assertEqual(len(list(directory.glob("*.raw"))), 22 - 1)
self.store.record(source_id, replace(fetch, raw_body=b"<html>new</html>"),
moment=MOMENT + timedelta(hours=2))
self.assertEqual(sum(not raw.exists() for raw in stock), 1, "去重命中时没写新的,也不删")
def test_stock_snapshots_wait_for_the_user_and_keep_what_items_reference(self):
from peach import repair_center
from peach.follow_evidence import Reclaim
from peach.repository import LedgerDatabase
source_id = self._source()
directory = self.root / "sources" / "follow" / "rule34video" / "legacy"
raws = [_old_snapshot(directory, index, size=100) for index in range(6)]
# 迁移数据根前留下的绝对路径、反斜杠也认得出是同一个文件。
self.connection.execute(
"INSERT INTO follow_item(source_id,external_id,title,release_key,evidence_path,first_seen_at,"
"last_seen_at) VALUES(?,?,?,'old',?,?,?)", (source_id, "old", "Old", "D:\\peach-data\\sources\\follow\\rule34video"
f"\\legacy\\{raws[1].name}", "2026-08-01T00:00:00Z", "2026-08-01T00:00:00Z"))
self.connection.commit()
from peach.task_runs import TaskRunStore
database = LedgerDatabase(self.root / "ledger.db")
runs = TaskRunStore(database)
reclaim = Reclaim(self.root / "sources" / "follow", database, runs=runs)
def settle():
while reclaim.worker is not None and reclaim.worker.is_alive():
reclaim.worker.join(10)
def item():
with database.read_connection() as connection:
center = repair_center.snapshot(connection, {}, cloud_pending=0,
follow_evidence_status=reclaim.status())
return next((row for row in center["items"] if row["id"] == "follow-evidence"), None), center
self.assertTrue(item()[0]["estimating"])
settle()
waiting, _ = item()
# 留 3、4、5 三份最近的和被引用的 1,删 0、2 两份,每份 100 + 2 字节。
self.assertEqual((waiting["mode"], waiting["state"], waiting["files"], waiting["bytes"]),
("confirm", "waiting", 4, 204))
self.assertTrue(all(raw.exists() for raw in raws), "估算不删文件")
reclaim.start()
settle()
self.assertEqual([raw.exists() for raw in raws], [False, True, False, True, True, True])
done, center = item()
self.assertEqual((done["state"], done["changed"]), ("done", 4))
self.assertEqual([(row["title"], row["changed"]) for row in center["history"]],
[("清理关注抓取的旧快照", 4)])
# 删除这一轮在活动页登记:计数、结算与摘要都在任务中心。
run = runs.query(task_key="follow-evidence-reclaim")[0]
self.assertEqual((run.status, run.progress_current, run.progress_total, run.result_summary),
("succeeded", 4, 4, {"files_removed": 4, "bytes_reclaimed": 204}))
def test_stock_cleanup_pauses_and_cancels_from_the_task_center(self):
"""活动页的暂停停在两份快照之间,继续前不再删;取消结束这一轮,删了多少照实记,剩下的重新等确认。"""
import threading
import time
from unittest.mock import patch
from peach import follow_evidence, task_control
from peach.follow_evidence import Reclaim
from peach.repository import LedgerDatabase
from peach.task_runs import TaskRunStore
root = self.root / "sources" / "follow" / "rule34video"
first = [_old_snapshot(root / "a", index) for index in range(5)]
second = [_old_snapshot(root / "b", index) for index in range(5)]
database = LedgerDatabase(self.root / "ledger.db")
runs = TaskRunStore(database)
reclaim = Reclaim(self.root / "sources" / "follow", database, runs=runs)
reclaim.status()
reclaim.worker.join(10)
# 删完第一个目录后停在闸门前,等测试发出暂停再放行。
reached, release = threading.Event(), threading.Event()
listing = follow_evidence.directories
def gated(path):
for index, directory in enumerate(listing(path)):
if index == 1:
reached.set()
release.wait(10)
yield directory
def wait_for(predicate):
deadline = time.monotonic() + 10
while not predicate():
self.assertLess(time.monotonic(), deadline)
time.sleep(0.02)
with patch.object(follow_evidence, "directories", gated):
reclaim.start()
self.assertTrue(reached.wait(10))
run = runs.query(task_key="follow-evidence-reclaim")[0]
self.assertEqual(task_control.projection(runs, run)["actions"], ["pause", "cancel", "set_speed"])
task_control.request(runs, run.id, "pause")
release.set()
wait_for(lambda: task_control.projection(runs, runs.get(run.id))["state"] == "paused")
self.assertEqual([raw.exists() for raw in first], [False, False, True, True, True])
self.assertTrue(all(raw.exists() for raw in second), "暂停后不再删")
task_control.request(runs, run.id, "cancel")
reclaim.worker.join(10)
run = runs.get(run.id)
self.assertEqual((run.status, run.result_summary["files_removed"]), ("cancelled", 4))
self.assertTrue(all(raw.exists() for raw in second))
wait_for(lambda: reclaim.status()["phase"] == "ready")
self.assertEqual(reclaim.status()["files"], 4, "剩下的重新等确认")
def test_a_library_without_follow_snapshots_does_not_list_the_item(self):
from peach import repair_center
from peach.follow_evidence import Reclaim
reclaim = Reclaim(self.root / "sources" / "follow", None)
self.assertEqual(reclaim.status(), {"phase": "idle"})
center = repair_center.snapshot(self.connection, {}, cloud_pending=0,
follow_evidence_status=reclaim.status())
self.assertNotIn("follow-evidence", [row["id"] for row in center["items"]])
if __name__ == "__main__":
unittest.main()