q6 commited on
Commit
2e3a1d3
·
1 Parent(s): d548b02

Resume reclass jobs and expand metadata detection

Browse files
Files changed (2) hide show
  1. backend/app.py +367 -56
  2. frontend/app.js +3 -3
backend/app.py CHANGED
@@ -25,7 +25,7 @@ from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
25
  from fastapi.responses import FileResponse, Response, StreamingResponse
26
  from fastapi.staticfiles import StaticFiles
27
  from cryptography.hazmat.primitives.ciphers.aead import AESGCM
28
- from PIL import Image
29
  from pydantic import BaseModel
30
 
31
  TURSO_DB_URL = os.getenv("TURSO_DB_URL", "").strip()
@@ -49,7 +49,18 @@ AI_TAGS = {
49
  "ai",
50
  "comfyui",
51
  }
52
- EXIF_TYPE_ORDER = ("novelai", "comfy", "sd", "mj", "celsys", "photoshop", "stealth")
 
 
 
 
 
 
 
 
 
 
 
53
  EXIF_TYPE_TO_CODE = {name: idx + 1 for idx, name in enumerate(EXIF_TYPE_ORDER)}
54
  EXIF_CODE_TO_TYPE = {idx + 1: name for idx, name in enumerate(EXIF_TYPE_ORDER)}
55
  POST_SCAN_LIMIT = 64
@@ -58,6 +69,7 @@ EXIF_RANGE_BYTES = 16384
58
  FULL_IMAGE_LIMIT = 32
59
  PAGE_SIZE = 60
60
  SEARCH_PAGE_SIZE = 10
 
61
  SEARCH_LIST_WHERE = "post_ids != '[]' AND found_exif > 0"
62
  POST_SEARCH_LIMIT = 1000
63
  RECLASS_LOG_KEY = "TF#g9FA@UgKdN'usVtm3dTz*yoqbria`"
@@ -179,6 +191,8 @@ async def init_db():
179
 
180
  async def migrate_exif_order():
181
  order_value = ",".join(EXIF_TYPE_ORDER)
 
 
182
  resp = await turso_execute(
183
  [
184
  {
@@ -187,22 +201,31 @@ async def migrate_exif_order():
187
  ]
188
  )
189
  rows = first_rows(resp)
190
- if rows and cell_value(rows[0], 0, "") == order_value:
 
191
  return
192
- await turso_execute(
193
- [
194
- {
195
- "sql": "UPDATE pi_scans SET exif_type = CASE exif_type "
196
- "WHEN 2 THEN -2 WHEN 3 THEN -3 ELSE exif_type END"
197
- },
198
- {"sql": "UPDATE pi_scans SET exif_type = 3 WHERE exif_type = -2"},
199
- {"sql": "UPDATE pi_scans SET exif_type = 2 WHERE exif_type = -3"},
 
 
 
 
 
 
200
  {
201
- "sql": "INSERT OR REPLACE INTO pi_meta (key, value) VALUES ('exif_order', ?)",
 
202
  "args": [{"type": "text", "value": order_value}],
203
- },
204
- ]
205
- )
 
206
 
207
 
208
  async def discord_notify(msg):
@@ -688,6 +711,8 @@ def decode_meta_text(data):
688
 
689
 
690
  def parse_png_metadata(data):
 
 
691
  fields = {}
692
  index = 8
693
  while index < len(data):
@@ -723,6 +748,99 @@ def parse_png_metadata(data):
723
  return fields or None
724
 
725
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
726
  def parse_json_maybe(value):
727
  try:
728
  return json.loads(value)
@@ -771,6 +889,76 @@ def has_comfy_metadata(fields):
771
  return False
772
 
773
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
774
  def determine_exif_type(fields):
775
  if not fields:
776
  return None
@@ -789,7 +977,13 @@ def determine_exif_type(fields):
789
  return "novelai"
790
  if has_comfy_metadata(fields):
791
  return "comfy"
792
- if "parameters" in fields or joined.startswith("parameters"):
 
 
 
 
 
 
793
  return "sd"
794
  if "midjourney" in joined:
795
  return "mj"
@@ -829,7 +1023,12 @@ async def scan_post(post_id, session, post_sem, exif_sem, img_sem):
829
  async with post_sem:
830
  try:
831
  pages = await get_post_pages(post_id, session)
832
- image_urls = [p["original"] for p in pages if "png" in p["original"]]
 
 
 
 
 
833
  for url in image_urls:
834
  metadata = await get_exif_range(url, session, exif_sem)
835
  exif_type = determine_exif_type(metadata)
@@ -839,7 +1038,7 @@ async def scan_post(post_id, session, post_sem, exif_sem, img_sem):
839
  for url in image_urls:
840
  img_data = await fetch_image(session, url, img_sem)
841
  if img_data and has_stealth_png_bytes(img_data):
842
- return post_id, url, EXIF_TYPE_TO_CODE.get("comfy")
843
  return post_id, None, None
844
  except Exception:
845
  return post_id, None, None
@@ -857,7 +1056,7 @@ async def get_exif_range(url, session, sem):
857
  else:
858
  async with session.get(url, headers=hdrs) as r:
859
  data = await r.read()
860
- return parse_png_metadata(data)
861
 
862
 
863
  async def fetch_image(session, url, sem):
@@ -869,7 +1068,7 @@ async def fetch_image(session, url, sem):
869
  return await r.read()
870
 
871
 
872
- async def run_scan(post_ids, phpsessid, task_id=None, save_live=False):
873
  post_sem = asyncio.Semaphore(POST_SCAN_LIMIT)
874
  exif_sem = asyncio.Semaphore(EXIF_RANGE_LIMIT)
875
  img_sem = asyncio.Semaphore(FULL_IMAGE_LIMIT)
@@ -889,7 +1088,7 @@ async def run_scan(post_ids, phpsessid, task_id=None, save_live=False):
889
  results.append(result)
890
  pending.append(result)
891
  if task_id and task_id in ACTIVE_TASKS:
892
- ACTIVE_TASKS[task_id]["done"] = len(results)
893
  if save_live and len(pending) >= 20:
894
  if task_canceled(task_id):
895
  raise TaskCanceled()
@@ -952,6 +1151,12 @@ async def get_reclass_targets(allowed):
952
 
953
 
954
  async def save_reclass_job(job_id, post_ids, filters):
 
 
 
 
 
 
955
  await turso_execute(
956
  [
957
  {
@@ -963,7 +1168,7 @@ async def save_reclass_job(job_id, post_ids, filters):
963
  {"type": "text", "value": json.dumps(post_ids)},
964
  {"type": "text", "value": json.dumps(filters)},
965
  {"type": "integer", "value": str(len(post_ids))},
966
- {"type": "text", "value": json.dumps({})},
967
  {"type": "text", "value": encrypt_log([])},
968
  {"type": "integer", "value": str(int(time.time()))},
969
  ],
@@ -1252,51 +1457,75 @@ async def bg_search_and_scan_task(search_id, url, pages, mode, phpsessid):
1252
  await finish_task(search_id)
1253
 
1254
 
1255
- async def bg_reclass_task(job_id, targets, filters, phpsessid):
 
 
 
 
 
 
 
 
 
 
 
1256
  ACTIVE_TASKS[job_id] = {
1257
  "type": "reclass",
1258
  "phase": "scanning",
1259
- "total": len(targets),
1260
- "done": 0,
 
1261
  }
1262
- await discord_notify(f"`{job_id}` reclass started ({len(targets)} posts)")
1263
- changes = []
1264
- changed_results = []
1265
  try:
1266
- old = {item["post_id"]: item for item in targets}
1267
- post_ids = list(old)
1268
- results = await run_scan(post_ids, phpsessid, task_id=job_id, save_live=False)
1269
- for post_id, url, exif_type in results:
1270
- item = old.get(str(post_id))
1271
- if not item:
1272
- continue
1273
- short_url = url.replace(IMG_BASE, "", 1) if url else ""
1274
- old_type = item["exif_type"]
1275
- old_url = item["url"]
1276
- if old_type != exif_type:
1277
- changes.append(
1278
- {
1279
- "post_id": str(post_id),
1280
- "from": exif_label(old_type),
1281
- "to": exif_label(exif_type),
1282
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1283
  )
1284
- if old_type != exif_type or old_url != short_url:
1285
- changed_results.append((post_id, url, exif_type))
1286
- if changed_results:
1287
- await save_scan_results(changed_results)
1288
- await update_searches_for_post_ids([str(pid) for pid, _, _ in changed_results])
1289
- await finish_reclass_job(
1290
- job_id, len(results), changes, len(changed_results), filters
1291
- )
1292
  await discord_notify(f"`{job_id}` Done")
1293
  except TaskCanceled:
1294
  done = ACTIVE_TASKS.get(job_id, {}).get("done", 0)
1295
- await finish_reclass_job(job_id, done, changes, len(changed_results), filters)
1296
  await discord_notify(f"`{job_id}` reclass canceled")
1297
  except Exception as e:
1298
  done = ACTIVE_TASKS.get(job_id, {}).get("done", 0)
1299
- await finish_reclass_job(job_id, done, changes, len(changed_results), filters)
1300
  await discord_notify(f"`{job_id}` reclass failed: {e}")
1301
  finally:
1302
  await finish_task(job_id)
@@ -1339,6 +1568,7 @@ async def startup():
1339
  await init_db()
1340
  asyncio.create_task(backfill_search_stats())
1341
  asyncio.create_task(resume_incomplete_scans())
 
1342
  except Exception as e:
1343
  print(f"WARN: DB init failed ({e}), will retry on first request")
1344
 
@@ -1384,6 +1614,85 @@ async def resume_incomplete_scans():
1384
  print(f"WARN: scan resume failed: {e}")
1385
 
1386
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1387
  @app.post("/api/submit")
1388
  async def submit_search(req: SearchRequest, bg: BackgroundTasks):
1389
  search_id = base26_time()
@@ -1488,9 +1797,11 @@ async def get_reclass(job_id: str):
1488
  active = ACTIVE_TASKS.get(job_id)
1489
  total = int(cell_value(row, 3, "0"))
1490
  scanned = int(cell_value(row, 4, "0"))
 
1491
  if active:
1492
  total = int(active.get("total") or total)
1493
  scanned = int(active.get("done") or scanned)
 
1494
  return {
1495
  "id": cell_value(row, 0, ""),
1496
  "kind": "reclass",
@@ -1498,7 +1809,7 @@ async def get_reclass(job_id: str):
1498
  "filters": json.loads(cell_value(row, 2, "[]")),
1499
  "total": total,
1500
  "scanned": scanned,
1501
- "changed": int(cell_value(row, 5, "0")),
1502
  "summary": summary,
1503
  "log": decrypt_log(cell_value(row, 7, "")),
1504
  "created_at": int(cell_value(row, 8, "0")),
 
25
  from fastapi.responses import FileResponse, Response, StreamingResponse
26
  from fastapi.staticfiles import StaticFiles
27
  from cryptography.hazmat.primitives.ciphers.aead import AESGCM
28
+ from PIL import ExifTags, Image
29
  from pydantic import BaseModel
30
 
31
  TURSO_DB_URL = os.getenv("TURSO_DB_URL", "").strip()
 
49
  "ai",
50
  "comfyui",
51
  }
52
+ EXIF_TYPE_ORDER = (
53
+ "novelai",
54
+ "comfy",
55
+ "sd",
56
+ "mj",
57
+ "celsys",
58
+ "photoshop",
59
+ "stealth",
60
+ "invoke",
61
+ "fooocus",
62
+ "swarm",
63
+ )
64
  EXIF_TYPE_TO_CODE = {name: idx + 1 for idx, name in enumerate(EXIF_TYPE_ORDER)}
65
  EXIF_CODE_TO_TYPE = {idx + 1: name for idx, name in enumerate(EXIF_TYPE_ORDER)}
66
  POST_SCAN_LIMIT = 64
 
69
  FULL_IMAGE_LIMIT = 32
70
  PAGE_SIZE = 60
71
  SEARCH_PAGE_SIZE = 10
72
+ RECLASS_BATCH_SIZE = 64
73
  SEARCH_LIST_WHERE = "post_ids != '[]' AND found_exif > 0"
74
  POST_SEARCH_LIMIT = 1000
75
  RECLASS_LOG_KEY = "TF#g9FA@UgKdN'usVtm3dTz*yoqbria`"
 
191
 
192
  async def migrate_exif_order():
193
  order_value = ",".join(EXIF_TYPE_ORDER)
194
+ swapped_order = "novelai,comfy,sd,mj,celsys,photoshop,stealth"
195
+ legacy_order = "novelai,sd,comfy,mj,celsys,photoshop,stealth"
196
  resp = await turso_execute(
197
  [
198
  {
 
201
  ]
202
  )
203
  rows = first_rows(resp)
204
+ prev_order = cell_value(rows[0], 0, "") if rows else ""
205
+ if prev_order == order_value:
206
  return
207
+ stmts = []
208
+ if prev_order == legacy_order:
209
+ stmts.extend(
210
+ [
211
+ {
212
+ "sql": "UPDATE pi_scans SET exif_type = CASE exif_type "
213
+ "WHEN 2 THEN -2 WHEN 3 THEN -3 ELSE exif_type END"
214
+ },
215
+ {"sql": "UPDATE pi_scans SET exif_type = 3 WHERE exif_type = -2"},
216
+ {"sql": "UPDATE pi_scans SET exif_type = 2 WHERE exif_type = -3"},
217
+ ]
218
+ )
219
+ if prev_order in ("", swapped_order, legacy_order):
220
+ stmts.append(
221
  {
222
+ "sql": "INSERT OR REPLACE INTO pi_meta "
223
+ "(key, value) VALUES ('exif_order', ?)",
224
  "args": [{"type": "text", "value": order_value}],
225
+ }
226
+ )
227
+ if stmts:
228
+ await turso_execute(stmts)
229
 
230
 
231
  async def discord_notify(msg):
 
711
 
712
 
713
  def parse_png_metadata(data):
714
+ if not data.startswith(b"\x89PNG\r\n\x1a\n"):
715
+ return None
716
  fields = {}
717
  index = 8
718
  while index < len(data):
 
748
  return fields or None
749
 
750
 
751
+ def decode_exif_text(value):
752
+ if value is None:
753
+ return ""
754
+ if isinstance(value, bytes):
755
+ prefixes = (
756
+ (b"ASCII\x00\x00\x00", "utf-8"),
757
+ (b"UNICODE\x00", "utf-16-be"),
758
+ (b"JIS\x00\x00\x00\x00\x00", "shift_jis"),
759
+ )
760
+ for prefix, enc in prefixes:
761
+ if value.startswith(prefix):
762
+ try:
763
+ return value[len(prefix) :].rstrip(b"\0").decode(enc).strip()
764
+ except UnicodeDecodeError:
765
+ break
766
+ return decode_meta_text(value.rstrip(b"\0"))
767
+ return str(value).strip()
768
+
769
+
770
+ def parse_pil_metadata(data):
771
+ fields = {}
772
+ try:
773
+ image = Image.open(io.BytesIO(data))
774
+ for key, value in (image.info or {}).items():
775
+ if isinstance(value, (str, bytes)):
776
+ fields[str(key)] = decode_exif_text(value)
777
+ for tag, value in image.getexif().items():
778
+ name = ExifTags.TAGS.get(tag, str(tag))
779
+ fields[name] = decode_exif_text(value)
780
+ except Exception:
781
+ pass
782
+ return {k: v for k, v in fields.items() if v} or None
783
+
784
+
785
+ def parse_jpeg_comments(data):
786
+ if not data.startswith(b"\xff\xd8"):
787
+ return None
788
+ fields = {}
789
+ index = 2
790
+ n = 1
791
+ while index + 4 <= len(data):
792
+ if data[index] != 0xFF:
793
+ index += 1
794
+ continue
795
+ marker = data[index + 1]
796
+ index += 2
797
+ if marker == 0xDA:
798
+ break
799
+ if marker in (0x01, *range(0xD0, 0xD9)):
800
+ continue
801
+ size = int.from_bytes(data[index : index + 2], "big")
802
+ if size < 2 or index + size > len(data):
803
+ break
804
+ content = data[index + 2 : index + size]
805
+ if marker == 0xFE:
806
+ fields[f"Comment{n}"] = decode_meta_text(content)
807
+ n += 1
808
+ index += size
809
+ return fields or None
810
+
811
+
812
+ def parse_xmp_metadata(data):
813
+ start = data.find(b"<x:xmpmeta")
814
+ if start < 0:
815
+ start = data.find(b"<?xpacket")
816
+ if start < 0:
817
+ return None
818
+ end = data.find(b"</x:xmpmeta>", start)
819
+ if end >= 0:
820
+ end += len(b"</x:xmpmeta>")
821
+ else:
822
+ end = data.find(b"?>", start)
823
+ if end >= 0:
824
+ end += 2
825
+ if end < 0:
826
+ return None
827
+ return {"XMP": decode_meta_text(data[start:end])}
828
+
829
+
830
+ def parse_image_metadata(data):
831
+ fields = {}
832
+ for parser in (
833
+ parse_png_metadata,
834
+ parse_pil_metadata,
835
+ parse_jpeg_comments,
836
+ parse_xmp_metadata,
837
+ ):
838
+ parsed = parser(data)
839
+ if parsed:
840
+ fields.update(parsed)
841
+ return fields or None
842
+
843
+
844
  def parse_json_maybe(value):
845
  try:
846
  return json.loads(value)
 
889
  return False
890
 
891
 
892
+ def field_json_values(fields):
893
+ for value in fields.values():
894
+ data = parse_json_maybe(value)
895
+ if isinstance(data, dict):
896
+ yield data
897
+
898
+
899
+ def has_fooocus_metadata(fields):
900
+ scheme = fields.get("fooocus_scheme") or fields.get("MakerNote")
901
+ if str(scheme).casefold() in ("fooocus", "a1111"):
902
+ return True
903
+ software = fields.get("Software", "").casefold()
904
+ if "fooocus" in software:
905
+ return True
906
+ for data in field_json_values(fields):
907
+ keys = set(data)
908
+ if {
909
+ "adaptive_cfg",
910
+ "base_model_hash",
911
+ "full_negative_prompt",
912
+ "full_prompt",
913
+ "loras",
914
+ "refiner_model_hash",
915
+ } & keys:
916
+ return True
917
+ return False
918
+
919
+
920
+ def has_invoke_metadata(fields):
921
+ if any(k.casefold() in ("dream", "sd-metadata") for k in fields):
922
+ return True
923
+ joined = "\n".join(str(v) for v in fields.values()).casefold()
924
+ if "invokeai" in joined or "invoke ai" in joined:
925
+ return True
926
+ for data in field_json_values(fields):
927
+ app = str(data.get("app_id", "")).casefold()
928
+ if "invoke" in app:
929
+ return True
930
+ return False
931
+
932
+
933
+ def has_swarm_metadata(fields):
934
+ for data in field_json_values(fields):
935
+ if {"sui_image_params", "sui_extra_data", "sui_models"} & set(data):
936
+ return True
937
+ return "sui_image_params" in "\n".join(str(v) for v in fields.values()).casefold()
938
+
939
+
940
+ def has_sd_metadata(fields):
941
+ if "parameters" in fields:
942
+ return True
943
+ for value in fields.values():
944
+ text = str(value)
945
+ folded = text.casefold()
946
+ if (
947
+ "steps:" in folded
948
+ and "seed:" in folded
949
+ and ("sampler:" in folded or "cfg scale:" in folded or "model:" in folded)
950
+ ):
951
+ return True
952
+ data = parse_json_maybe(value)
953
+ if isinstance(data, dict):
954
+ keys = set(data)
955
+ if "prompt" in keys and "seed" in keys and (
956
+ "steps" in keys or "sampler" in keys or "cfg_scale" in keys
957
+ ):
958
+ return True
959
+ return False
960
+
961
+
962
  def determine_exif_type(fields):
963
  if not fields:
964
  return None
 
977
  return "novelai"
978
  if has_comfy_metadata(fields):
979
  return "comfy"
980
+ if has_fooocus_metadata(fields):
981
+ return "fooocus"
982
+ if has_invoke_metadata(fields):
983
+ return "invoke"
984
+ if has_swarm_metadata(fields):
985
+ return "swarm"
986
+ if has_sd_metadata(fields) or joined.startswith("parameters"):
987
  return "sd"
988
  if "midjourney" in joined:
989
  return "mj"
 
1023
  async with post_sem:
1024
  try:
1025
  pages = await get_post_pages(post_id, session)
1026
+ image_urls = [
1027
+ p["original"]
1028
+ for p in pages
1029
+ if urlsplit(p["original"]).path.rsplit(".", 1)[-1].casefold()
1030
+ in ("png", "jpg", "jpeg", "webp")
1031
+ ]
1032
  for url in image_urls:
1033
  metadata = await get_exif_range(url, session, exif_sem)
1034
  exif_type = determine_exif_type(metadata)
 
1038
  for url in image_urls:
1039
  img_data = await fetch_image(session, url, img_sem)
1040
  if img_data and has_stealth_png_bytes(img_data):
1041
+ return post_id, url, EXIF_TYPE_TO_CODE.get("stealth")
1042
  return post_id, None, None
1043
  except Exception:
1044
  return post_id, None, None
 
1056
  else:
1057
  async with session.get(url, headers=hdrs) as r:
1058
  data = await r.read()
1059
+ return parse_image_metadata(data)
1060
 
1061
 
1062
  async def fetch_image(session, url, sem):
 
1068
  return await r.read()
1069
 
1070
 
1071
+ async def run_scan(post_ids, phpsessid, task_id=None, save_live=False, done_offset=0):
1072
  post_sem = asyncio.Semaphore(POST_SCAN_LIMIT)
1073
  exif_sem = asyncio.Semaphore(EXIF_RANGE_LIMIT)
1074
  img_sem = asyncio.Semaphore(FULL_IMAGE_LIMIT)
 
1088
  results.append(result)
1089
  pending.append(result)
1090
  if task_id and task_id in ACTIVE_TASKS:
1091
+ ACTIVE_TASKS[task_id]["done"] = done_offset + len(results)
1092
  if save_live and len(pending) >= 20:
1093
  if task_canceled(task_id):
1094
  raise TaskCanceled()
 
1151
 
1152
 
1153
  async def save_reclass_job(job_id, post_ids, filters):
1154
+ summary = {
1155
+ "scanned": 0,
1156
+ "changed": 0,
1157
+ "updated": 0,
1158
+ "filters": [exif_label(code) for code in filters],
1159
+ }
1160
  await turso_execute(
1161
  [
1162
  {
 
1168
  {"type": "text", "value": json.dumps(post_ids)},
1169
  {"type": "text", "value": json.dumps(filters)},
1170
  {"type": "integer", "value": str(len(post_ids))},
1171
+ {"type": "text", "value": json.dumps(summary)},
1172
  {"type": "text", "value": encrypt_log([])},
1173
  {"type": "integer", "value": str(int(time.time()))},
1174
  ],
 
1457
  await finish_task(search_id)
1458
 
1459
 
1460
+ async def bg_reclass_task(
1461
+ job_id,
1462
+ targets,
1463
+ filters,
1464
+ phpsessid,
1465
+ total=None,
1466
+ scanned=0,
1467
+ changes=None,
1468
+ updated=0,
1469
+ ):
1470
+ total = total or len(targets)
1471
+ changes = list(changes or [])
1472
  ACTIVE_TASKS[job_id] = {
1473
  "type": "reclass",
1474
  "phase": "scanning",
1475
+ "total": total,
1476
+ "done": scanned,
1477
+ "changed": len(changes),
1478
  }
1479
+ action = "resumed" if scanned else "started"
1480
+ await discord_notify(f"`{job_id}` reclass {action} ({total - scanned} posts)")
 
1481
  try:
1482
+ for i in range(0, len(targets), RECLASS_BATCH_SIZE):
1483
+ if task_canceled(job_id):
1484
+ raise TaskCanceled()
1485
+ batch = targets[i : i + RECLASS_BATCH_SIZE]
1486
+ old = {item["post_id"]: item for item in batch}
1487
+ results = await run_scan(
1488
+ list(old),
1489
+ phpsessid,
1490
+ task_id=job_id,
1491
+ save_live=False,
1492
+ done_offset=scanned,
1493
+ )
1494
+ changed_results = []
1495
+ for post_id, url, exif_type in results:
1496
+ item = old.get(str(post_id))
1497
+ if not item:
1498
+ continue
1499
+ short_url = url.replace(IMG_BASE, "", 1) if url else ""
1500
+ old_type = item["exif_type"]
1501
+ old_url = item["url"]
1502
+ if old_type != exif_type:
1503
+ changes.append(
1504
+ {
1505
+ "post_id": str(post_id),
1506
+ "from": exif_label(old_type),
1507
+ "to": exif_label(exif_type),
1508
+ }
1509
+ )
1510
+ if old_type != exif_type or old_url != short_url:
1511
+ changed_results.append((post_id, url, exif_type))
1512
+ if changed_results:
1513
+ await save_scan_results(changed_results)
1514
+ await update_searches_for_post_ids(
1515
+ [str(pid) for pid, _, _ in changed_results]
1516
  )
1517
+ updated += len(changed_results)
1518
+ scanned += len(results)
1519
+ ACTIVE_TASKS[job_id].update({"done": scanned, "changed": len(changes)})
1520
+ await finish_reclass_job(job_id, scanned, changes, updated, filters)
 
 
 
 
1521
  await discord_notify(f"`{job_id}` Done")
1522
  except TaskCanceled:
1523
  done = ACTIVE_TASKS.get(job_id, {}).get("done", 0)
1524
+ await finish_reclass_job(job_id, done, changes, updated, filters)
1525
  await discord_notify(f"`{job_id}` reclass canceled")
1526
  except Exception as e:
1527
  done = ACTIVE_TASKS.get(job_id, {}).get("done", 0)
1528
+ await finish_reclass_job(job_id, done, changes, updated, filters)
1529
  await discord_notify(f"`{job_id}` reclass failed: {e}")
1530
  finally:
1531
  await finish_task(job_id)
 
1568
  await init_db()
1569
  asyncio.create_task(backfill_search_stats())
1570
  asyncio.create_task(resume_incomplete_scans())
1571
+ asyncio.create_task(resume_incomplete_reclasses())
1572
  except Exception as e:
1573
  print(f"WARN: DB init failed ({e}), will retry on first request")
1574
 
 
1614
  print(f"WARN: scan resume failed: {e}")
1615
 
1616
 
1617
+ async def get_incomplete_reclass_jobs():
1618
+ resp = await turso_execute(
1619
+ [
1620
+ {
1621
+ "sql": "SELECT id, post_ids, filters, total, scanned, summary, log_enc "
1622
+ "FROM pi_reclass_jobs WHERE scanned < total ORDER BY id",
1623
+ }
1624
+ ]
1625
+ )
1626
+ jobs = []
1627
+ for row in first_rows(resp):
1628
+ job_id = cell_value(row, 0, "")
1629
+ post_ids = json.loads(cell_value(row, 1, "[]"))
1630
+ filters = json.loads(cell_value(row, 2, "[]"))
1631
+ total = int(cell_value(row, 3, "0"))
1632
+ scanned = min(int(cell_value(row, 4, "0")), total)
1633
+ summary = parse_json_maybe(cell_value(row, 5, "{}")) or {}
1634
+ changes = decrypt_log(cell_value(row, 6, ""))
1635
+ remaining = post_ids[scanned:]
1636
+ current = await get_scanned_post_ids(remaining)
1637
+ targets = []
1638
+ for post_id in remaining:
1639
+ item = current.get(str(post_id), {})
1640
+ targets.append(
1641
+ {
1642
+ "post_id": str(post_id),
1643
+ "url": item.get("url", ""),
1644
+ "exif_type": item.get("exif_type"),
1645
+ }
1646
+ )
1647
+ jobs.append(
1648
+ {
1649
+ "id": job_id,
1650
+ "targets": targets,
1651
+ "filters": filters,
1652
+ "total": total or len(post_ids),
1653
+ "scanned": scanned,
1654
+ "changes": changes,
1655
+ "updated": int(summary.get("updated") or 0),
1656
+ }
1657
+ )
1658
+ return jobs
1659
+
1660
+
1661
+ async def resume_incomplete_reclasses():
1662
+ try:
1663
+ started = 0
1664
+ for job in await get_incomplete_reclass_jobs():
1665
+ job_id = job["id"]
1666
+ if job_id in ACTIVE_TASKS:
1667
+ continue
1668
+ if job["targets"]:
1669
+ asyncio.create_task(
1670
+ bg_reclass_task(
1671
+ job_id,
1672
+ job["targets"],
1673
+ job["filters"],
1674
+ PHPSESSID,
1675
+ total=job["total"],
1676
+ scanned=job["scanned"],
1677
+ changes=job["changes"],
1678
+ updated=job["updated"],
1679
+ )
1680
+ )
1681
+ started += 1
1682
+ else:
1683
+ await finish_reclass_job(
1684
+ job_id,
1685
+ job["scanned"],
1686
+ job["changes"],
1687
+ job["updated"],
1688
+ job["filters"],
1689
+ )
1690
+ if started:
1691
+ print(f"Resumed {started} incomplete reclasses")
1692
+ except Exception as e:
1693
+ print(f"WARN: reclass resume failed: {e}")
1694
+
1695
+
1696
  @app.post("/api/submit")
1697
  async def submit_search(req: SearchRequest, bg: BackgroundTasks):
1698
  search_id = base26_time()
 
1797
  active = ACTIVE_TASKS.get(job_id)
1798
  total = int(cell_value(row, 3, "0"))
1799
  scanned = int(cell_value(row, 4, "0"))
1800
+ changed = int(cell_value(row, 5, "0"))
1801
  if active:
1802
  total = int(active.get("total") or total)
1803
  scanned = int(active.get("done") or scanned)
1804
+ changed = int(active.get("changed") or changed)
1805
  return {
1806
  "id": cell_value(row, 0, ""),
1807
  "kind": "reclass",
 
1809
  "filters": json.loads(cell_value(row, 2, "[]")),
1810
  "total": total,
1811
  "scanned": scanned,
1812
+ "changed": changed,
1813
  "summary": summary,
1814
  "log": decrypt_log(cell_value(row, 7, "")),
1815
  "created_at": int(cell_value(row, 8, "0")),
frontend/app.js CHANGED
@@ -1,7 +1,7 @@
1
  const $ = s => document.querySelector(s)
2
  const $$ = s => document.querySelectorAll(s)
3
- const EXIF_NAMES = { 1: "novelai", 2: "comfy", 3: "sd", 4: "mj", 5: "celsys", 6: "photoshop", 7: "stealth" }
4
- const EXIF_CODES = [1, 2, 3, 4, 5, 6, 7]
5
  const CATEGORY_NAMES = { ...EXIF_NAMES, 0: "None" }
6
  const FILTER_CODES = [...EXIF_CODES, 0]
7
  const EXIF_FILTER_KEY = "pixif2-exif-types"
@@ -225,7 +225,7 @@ async function loadSearches(page = 1, silent = false) {
225
  const savedHtml = searches.map(s => {
226
  const d = new Date(parseInt(s.created_at) * 1000)
227
  const ts = d.toLocaleString()
228
- const count = s.kind === "reclass" ? `${s.changed || 0}/${s.total || 0}` : `${s.found_exif || 0}/${s.total_searched || 0}`
229
  const kind = s.kind === "reclass" ? "reclass " : ""
230
  return `<div class="search-item" data-id="${esc(s.id)}">
231
  <span class="id">${esc(s.id)}</span>
 
1
  const $ = s => document.querySelector(s)
2
  const $$ = s => document.querySelectorAll(s)
3
+ const EXIF_NAMES = { 1: "novelai", 2: "comfy", 3: "sd", 4: "mj", 5: "celsys", 6: "photoshop", 7: "stealth", 8: "invoke", 9: "fooocus", 10: "swarm" }
4
+ const EXIF_CODES = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
5
  const CATEGORY_NAMES = { ...EXIF_NAMES, 0: "None" }
6
  const FILTER_CODES = [...EXIF_CODES, 0]
7
  const EXIF_FILTER_KEY = "pixif2-exif-types"
 
225
  const savedHtml = searches.map(s => {
226
  const d = new Date(parseInt(s.created_at) * 1000)
227
  const ts = d.toLocaleString()
228
+ const count = s.kind === "reclass" ? `${s.total_searched || 0}/${s.total || 0}` : `${s.found_exif || 0}/${s.total_searched || 0}`
229
  const kind = s.kind === "reclass" ? "reclass " : ""
230
  return `<div class="search-item" data-id="${esc(s.id)}">
231
  <span class="id">${esc(s.id)}</span>