import importlib.util import json import sqlite3 import sys import tempfile import unittest from unittest import mock from pathlib import Path SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" def _load(name): spec = importlib.util.spec_from_file_location(name, SCRIPTS / f"{name}.py") module = importlib.util.module_from_spec(spec) sys.modules[name] = module spec.loader.exec_module(module) return module _script = _load("audit_code_creators") confirm = _load("confirm_code_dirs") class _Audit: """判定与清理归 `peach.code_creators`,命令行入口留在脚本里;测试两边都要够得着。""" from peach.catalog_rules import release_code_from_filename as code_from_filename from peach.catalog_rules import release_code_from_text as canonical_code from peach.code_creators import ( # noqa: F401 逐个点名,测试不跟着模块表面漂移 FIELDS, VERDICT_CODE, VERDICT_KEEP, VERDICT_SITE, VERDICT_UNCLEAR, apply_rows, classify, collect, is_filesystem_path, ) build_parser = staticmethod(_script.build_parser) run = staticmethod(_script.run) main = staticmethod(_script.main) audit = _Audit SCHEMA = """ CREATE TABLE asset( id INTEGER PRIMARY KEY, location TEXT NOT NULL, path TEXT NOT NULL, name TEXT, medium TEXT, creator TEXT, code TEXT, duration REAL, field_owners TEXT, mutation_revision INTEGER NOT NULL DEFAULT 0, UNIQUE(location,path)); CREATE TABLE entity( id INTEGER PRIMARY KEY, kind TEXT, canonical_name TEXT, normalized_name TEXT, metadata_json TEXT DEFAULT '{}', created_at TEXT, updated_at TEXT, UNIQUE(kind,normalized_name)); CREATE TABLE asset_entity( asset_id INTEGER, entity_id INTEGER, role TEXT, source TEXT, confidence REAL DEFAULT 1.0, metadata_json TEXT DEFAULT '{}', first_seen_at TEXT, last_seen_at TEXT, UNIQUE(asset_id,entity_id,role,source)); """ class CodeShapeTests(unittest.TestCase): def test_digitless_creator_names_do_not_parse_their_files_as_embedded_codes(self): from peach import code_creators assets = [{'path': f'/media/{index}.mp4', 'name': f'ABP-{index:03d}.mp4', 'code': ''} for index in range(1000)] with mock.patch.object(code_creators, 'code_from_filename', side_effect=AssertionError('无需解析影片名')): self.assertEqual(code_creators.classify('Example Creator', assets)[0], audit.VERDICT_KEEP) def test_quality_prefix_is_not_part_of_the_code(self): # 这批目录名正是本次要修的:HD 是画质,不是番号的一部分。 self.assertEqual(audit.canonical_code("HD-abp-758"), "ABP-758") self.assertEqual(audit.canonical_code("HD_MIZD-997"), "MIZD-997") self.assertEqual(audit.canonical_code("HD_hrv-041"), "HRV-041") self.assertEqual(audit.canonical_code("FHD-ssis-070"), "SSIS-070") def test_version_suffix_is_stripped(self): self.assertEqual(audit.canonical_code("pppd-937ch"), "PPPD-937") self.assertEqual(audit.canonical_code("KBI044C"), "KBI-044") self.assertEqual(audit.canonical_code("NHDTB500C"), "NHDTB-500") def test_digits_are_normalised_to_three_places(self): self.assertEqual(audit.canonical_code("abw104"), "ABW-104") self.assertEqual(audit.canonical_code("IPVR00296"), "IPVR-296") def test_date_code_systems(self): # 分隔符是片商标识:加勒比写 `-`、一本道写 `_`,同日同序号是两部不同影片, # 目录名给的是哪一个就留哪一个。 self.assertEqual(audit.canonical_code("Carib-040221-001-FHD"), "040221-001") self.assertEqual(audit.canonical_code("1pondo-071213_625-HD"), "071213_625") def test_names_that_are_not_codes(self): for name in ("涼森れむ", "suzuq", "Timepasserby", "MyElla"): self.assertIsNone(audit.canonical_code(name), name) def test_uuid_first_segment_is_not_a_code(self): self.assertIsNone( audit.canonical_code("DCE7230C-730E-45DA-9FDC-D167663BC84E")) def test_filename_noise_is_stripped_before_matching(self): self.assertEqual(audit.code_from_filename("HD-abp-758.mp4"), "ABP-758") self.assertEqual(audit.code_from_filename("HD_hrv-041-1.mp4"), "HRV-041") self.assertEqual(audit.code_from_filename("BNST033(2).jpg"), "BNST-033") self.assertEqual(audit.code_from_filename("040221-001-carib-1080p.mp4"), "040221-001") def test_online_identities_are_not_filesystem_paths(self): self.assertFalse(audit.is_filesystem_path("https://www.pixiv.net/users/93812377")) self.assertTrue(audit.is_filesystem_path(r"B:\云下载\HD-abp-758\HD-abp-758.mp4")) def _row(name, path, code=None): return {"name": name, "path": path, "code": code} class ClassifyTests(unittest.TestCase): def test_release_folder_named_after_its_own_code(self): verdict, identity, _ = audit.classify( "HD-abp-758", [_row("HD-abp-758.mp4", r"B:\云下载\HD-abp-758\HD-abp-758.mp4")]) self.assertEqual((verdict, identity), (audit.VERDICT_CODE, "ABP-758")) def test_existing_code_column_counts_as_evidence(self): verdict, identity, _ = audit.classify( "pppd-937ch", [_row("PPPD-937CH.mp4", r"B:\云下载\pppd-937ch\PPPD-937CH.mp4", "PPPD-937")]) self.assertEqual((verdict, identity), (audit.VERDICT_CODE, "PPPD-937")) def test_uploader_account_with_title_filenames_is_not_touched(self): # banbi_555 形状上像番号,但目录里全是作品标题,没有同番号文件。 verdict, _, _ = audit.classify("banbi_555", [ _row("18歳Eカップ彼氏持ち美女.mp4", r"A:\Pack From Shared\pen\banbi_555\18歳.mp4"), _row("2_2024_06_08_172104.mp4", r"A:\Pack From Shared\pen\banbi_555\2_2024.mp4"), ]) self.assertEqual(verdict, audit.VERDICT_UNCLEAR) def test_a_download_site_folder_around_the_code_is_still_a_release_folder(self): """站名、画质和分享标记贴在番号前后,目录仍是发行目录,不是创作者。 本机实测的写法:`Jav.li_MIAD573_HD`、`[98t.tv][98t.tv]ABW-251`、 `nes@第一会所@ATID-479`、`kpxvs-300MIUM-698`。整名比对认不出它们,而 11 个这样的 「创作者」名下挂着 33 条资产,在创作者索引里各占一个假身份。 """ for name, sample, code, identity in ( ("Jav.li_MIAD573_HD", "MIAD573_01.wmv", "MIAD-573", "MIAD-573"), ("[98t.tv][98t.tv]ABW-251", "ABW-251.mp4", "", "ABW-251"), ("nes@第一会所@ATID-479", "ATID-479.mp4", "", "ATID-479"), ): with self.subTest(name=name): verdict, found, _ = audit.classify( name, [_row(sample, rf"B:\云下载\{name}\{sample}", code)]) self.assertEqual((verdict, found), (audit.VERDICT_CODE, identity)) def test_an_uploader_account_shaped_like_a_code_is_not_matched_by_containment(self): """按包含关系找番号时,上传者账号是最容易被误伤的一类。 `banbi_555` 的 code 列存的就是目录名 `BANBI_555`,紧凑形一比自然「包含」。 发行番号这道形态门槛把它挡在外面,所以它仍然只是存疑,留给人看。 """ verdict, _, _ = audit.classify("banbi_555", [ _row("18歳Eカップ彼氏持ち美女.mp4", r"A:\Pack From Shared\pen\banbi_555\18歳.mp4", "BANBI_555"), ]) self.assertEqual(verdict, audit.VERDICT_UNCLEAR) def test_pixiv_artist_is_kept_out_of_the_review_entirely(self): verdict, _, _ = audit.classify( "AH18", [_row("AH18", "https://www.pixiv.net/users/93812377")]) self.assertEqual(verdict, audit.VERDICT_KEEP) def test_site_post_id_is_classified_apart_from_codes(self): verdict, identity, _ = audit.classify("fantia-3760310", [ _row("avruby.png", r"B:\xxr\fantia-3760310\avruby.png"), _row("fantia-3760310.mp4", r"B:\xxr\fantia-3760310\fantia-3760310.mp4"), ]) self.assertEqual((verdict, identity), (audit.VERDICT_SITE, "fantia-3760310")) class ApplyTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.db = Path(self.tmp.name) / "ledger.db" self.connection = sqlite3.connect(self.db) self.addCleanup(self.connection.close) self.connection.executescript(SCHEMA) # 媒介按扩展名给,和真实导入一致:目录里混着图片正是本组要覆盖的形态。 def _creator(self, entity_id, name, assets): self.connection.execute( "INSERT INTO entity(id,kind,canonical_name,normalized_name) VALUES(?,'creator',?,?)", (entity_id, name, name.casefold())) for asset_id, asset_name, path, code in assets: self.connection.execute( "INSERT INTO asset(id,location,path,name,medium,creator,code) " "VALUES(?,'local',?,?,?,?,?)", (asset_id, path, asset_name, "image" if asset_name.lower().endswith((".jpg", ".png")) else "video", name, code)) self.connection.execute( "INSERT INTO asset_entity(asset_id,entity_id,role,source) " "VALUES(?,?,'creator','legacy:asset')", (asset_id, entity_id)) def test_apply_removes_the_false_creator_and_fills_the_code(self): self._creator(1, "HD-abp-758", [ (10, "HD-abp-758.mp4", r"B:\云下载\HD-abp-758\HD-abp-758.mp4", None)]) self._creator(2, "banbi_555", [ (20, "18歳.mp4", r"A:\Pack From Shared\pen\banbi_555\18歳.mp4", None)]) self.connection.commit() rows = audit.collect(self.connection) counts = audit.apply_rows(self.connection, rows) self.assertEqual(counts["links"], 1) self.assertEqual(counts["entities"], 1) self.assertEqual(counts["codes"], 1) self.assertEqual( self.connection.execute("SELECT creator,code FROM asset WHERE id=10").fetchone(), (None, "ABP-758")) self.assertIsNone( self.connection.execute("SELECT id FROM entity WHERE id=1").fetchone()) # 存疑的上传者账号必须原样保留:实体、关系和扁平字段都不动。 self.assertIsNotNone( self.connection.execute("SELECT id FROM entity WHERE id=2").fetchone()) self.assertEqual( self.connection.execute("SELECT creator FROM asset WHERE id=20").fetchone()[0], "banbi_555") def test_the_code_it_writes_names_its_owner(self): self._creator(6, "HD-abp-758", [ (60, "HD-abp-758.mp4", r"B:\云下载\HD-abp-758\HD-abp-758.mp4", None)]) self.connection.commit() audit.apply_rows(self.connection, audit.collect(self.connection)) self.assertEqual( json.loads(self.connection.execute( "SELECT field_owners FROM asset WHERE id=60").fetchone()[0]), {"code": "script:code-creators", "creator": "script:code-creators"}) def test_a_code_the_user_cleared_stays_cleared(self): # 用户把某条的 code 清空是一次判断,补空不该把它再填回去。 self._creator(7, "Tokyo-Hot n0781-HD", [ (70, "Tokyo-Hot.mp4", r"B:\云下载\Tokyo-Hot n0781-HD\Tokyo-Hot.mp4", "n0781"), (71, "花絮.mp4", r"B:\云下载\Tokyo-Hot n0781-HD\花絮.mp4", None)]) self.connection.execute( """UPDATE asset SET field_owners='{"code":"user:manual"}' WHERE id=71""") self.connection.commit() counts = audit.apply_rows(self.connection, audit.collect(self.connection)) self.assertEqual(counts["codes"], 0) self.assertIsNone( self.connection.execute("SELECT code FROM asset WHERE id=71").fetchone()[0]) def test_existing_code_is_never_overwritten(self): self._creator(3, "MIDA-117ch", [ (30, "MIDA-117CH.mp4", r"B:\云下载\MIDA-117ch\MIDA-117CH.mp4", "MIDA-117")]) self.connection.commit() audit.apply_rows(self.connection, audit.collect(self.connection)) self.assertEqual( self.connection.execute("SELECT code FROM asset WHERE id=30").fetchone()[0], "MIDA-117") def test_site_post_id_clears_the_creator_without_writing_a_code(self): self._creator(4, "fantia-3760310", [ (40, "fantia-3760310.mp4", r"B:\xxr\fantia-3760310\fantia-3760310.mp4", None)]) self.connection.commit() audit.apply_rows(self.connection, audit.collect(self.connection)) self.assertEqual( self.connection.execute("SELECT creator,code FROM asset WHERE id=40").fetchone(), (None, None)) def test_apply_refuses_to_run_without_a_backup(self): parser = audit.build_parser() with self.assertRaises(SystemExit): audit.run(parser.parse_args(["--db", str(self.db), "--apply"])) class DurationEvidenceTests(unittest.TestCase): """目录名像番号、文件却不带番号时,片长是第二条证据(用户 2026-09-16)。""" def test_a_couple_of_minutes_of_ads_still_counts_as_the_same_release(self): confirmed, _ = confirm.duration_confirms_code(98 * 60 + 90, 98) self.assertTrue(confirmed) def test_a_different_length_is_a_different_thing(self): # `bbsxv.xyz-DOCP-324` 目录里只有一条 90 秒的广告,DOCP-324 本身 120 分钟。 confirmed, reason = confirm.duration_confirms_code(90, 120) self.assertFalse(confirmed) self.assertIn("超出容差", reason) def test_no_runtime_from_the_source_decides_nothing(self): self.assertEqual(confirm.duration_confirms_code(3600, None), (False, "来源没有给片长")) def test_the_longest_video_is_the_feature(self): """目录里还躺着论坛文宣和封面图,拿它们比片长必然对不上。""" assets = [ {"medium": "video", "duration": 5896.8}, {"medium": "video", "duration": 31.0}, {"medium": "image", "duration": None}, ] self.assertEqual(confirm.main_video_seconds(assets), 5896.8) self.assertIsNone(confirm.main_video_seconds([{"medium": "image", "duration": None}])) class ConfirmScriptTests(unittest.TestCase): class _Provider: """按番号答片长的假来源。""" def __init__(self, runtimes): self.runtimes, self.asked = runtimes, [] def query(self, code, **_): self.asked.append(code) raise LookupError("r18.dev 没有这个番号") def community(self, code, **_): runtime = self.runtimes.get(code) if runtime is None: raise LookupError("社区来源都没有这个番号") # 真实返回顺序里 javbus 排在 javdb 前面,`n0780` 它报的是 36 分。 return [("javbus", {"runtime": 36}), ("javdb", {"runtime": runtime})] def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.db = Path(self.tmp.name) / "ledger.db" self.connection = sqlite3.connect(self.db) self.addCleanup(self.connection.close) self.connection.executescript(SCHEMA) def _creator(self, entity_id, name, assets): self.connection.execute( "INSERT INTO entity(id,kind,canonical_name,normalized_name) VALUES(?,'creator',?,?)", (entity_id, name, name.casefold())) for asset_id, asset_name, path, medium, duration in assets: self.connection.execute( "INSERT INTO asset(id,location,path,name,medium,creator,duration) " "VALUES(?,'local',?,?,?,?,?)", (asset_id, path, asset_name, medium, name, duration)) self.connection.execute( "INSERT INTO asset_entity(asset_id,entity_id,role,source) " "VALUES(?,?,'creator','legacy:asset')", (asset_id, entity_id)) def test_a_release_folder_is_confirmed_by_its_runtime_and_an_account_is_not(self): self._creator(1, "Tokyo-Hot n0780-HD", [ (10, "Tokyo-Hot.mp4", r"B:\云下载\Tokyo-Hot n0780-HD\Tokyo-Hot.mp4", "video", 5896.8), (11, "封殺001.jpg", r"B:\云下载\Tokyo-Hot n0780-HD\論壇文宣\封殺001.jpg", "image", None)]) self._creator(2, "banbi_555", [ (20, "18歳.mp4", r"A:\Pack From Shared\pen\banbi_555\18歳.mp4", "video", 1800.0)]) self.connection.commit() provider = self._Provider({"n0780": 98}) rows = confirm.examine(self.connection, provider) verdicts = {row["creator"]: row["confirmed"] for row in rows} self.assertEqual(verdicts, {"Tokyo-Hot n0780-HD": "是", "banbi_555": "否"}) self.assertEqual(provider.asked, ["n0780", "BANBI-555"]) # 片长取 javdb 的:同一个番号 javbus 报 36 分,盘里那条 98 分。 self.assertEqual([row["source"] for row in rows if row["confirmed"] == "是"], ["javdb"]) counts = audit.apply_rows( self.connection, [row for row in rows if row["verdict"] == audit.VERDICT_CODE]) self.assertEqual(counts["codes"], 1) self.assertEqual( self.connection.execute("SELECT creator,code FROM asset WHERE id=10").fetchone(), (None, "n0780")) # 目录里的论坛文宣不是这部片的文件:假创作者照样摘掉,番号不给它。 self.assertEqual( self.connection.execute("SELECT creator,code FROM asset WHERE id=11").fetchone(), (None, None)) # 片长对不上的目录一条都不动。 self.assertEqual( self.connection.execute("SELECT creator FROM asset WHERE id=20").fetchone()[0], "banbi_555") def test_apply_refuses_to_run_without_a_backup(self): parser = confirm.build_parser() with self.assertRaises(SystemExit): confirm.run(parser.parse_args(["--db", str(self.db), "--apply"])) if __name__ == "__main__": unittest.main()