q6 commited on
Commit
072676e
·
1 Parent(s): 3968355

Optimize scanning and image proxy paths

Browse files
Files changed (2) hide show
  1. backend/app.py +116 -53
  2. frontend/app.js +1 -0
backend/app.py CHANGED
@@ -77,6 +77,7 @@ THUMB_MAX_AGE = 1800
77
  THUMB_CLEAN_INTERVAL = 300
78
  THUMB_DIR = Path(tempfile.gettempdir()) / "pixif2-thumbs"
79
  PAGE_URL_CACHE_MAX_AGE = 1800
 
80
  WEBP_SCALE = 0.4
81
  WEBP_QUALITY = 82
82
  STEALTH_ALPHA_SIGS = (b"stealth_pnginfo", b"stealth_pngcomp")
@@ -86,7 +87,9 @@ app = FastAPI()
86
  ACTIVE_TASKS = {}
87
  TASK_EVENT_QUEUES = set()
88
  PAGE_URL_CACHE = {}
 
89
  TURSO_CLIENT = None
 
90
  LAST_THUMB_CLEANUP = 0
91
 
92
  FRONTEND_DIR = os.path.join(os.getcwd(), "frontend")
@@ -132,6 +135,15 @@ async def turso_client():
132
  return TURSO_CLIENT
133
 
134
 
 
 
 
 
 
 
 
 
 
135
  async def turso_execute(stmts):
136
  url = turso_url() + "/v2/pipeline"
137
  headers = {
@@ -685,20 +697,40 @@ async def get_post_pages(post_id, session):
685
  now = time.time()
686
  if cached and now - cached["time"] < PAGE_URL_CACHE_MAX_AGE:
687
  return cached["pages"]
688
- data = await fetch_page(session, f"https://www.pixiv.net/ajax/illust/{post_id}/pages")
689
- pages = []
690
- for page in data.get("body") or []:
691
- urls = page.get("urls") or {}
692
- pages.append(
693
- {
694
- "original": urls.get("original") or "",
695
- "regular": urls.get("regular") or "",
696
- "small": urls.get("small") or "",
697
- "thumb_mini": urls.get("thumb_mini") or "",
698
- }
699
  )
700
- PAGE_URL_CACHE[key] = {"time": now, "pages": pages}
701
- return pages
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
702
 
703
 
704
  def decode_meta_text(data):
@@ -829,15 +861,22 @@ def parse_xmp_metadata(data):
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
 
@@ -994,6 +1033,10 @@ def determine_exif_type(fields):
994
  return None
995
 
996
 
 
 
 
 
997
  def byteize(bits):
998
  bits = bits[: (bits.shape[0] // 8) * 8]
999
  bits = np.bitwise_and(bits, 1)
@@ -1026,8 +1069,7 @@ async def scan_post(post_id, session, post_sem, exif_sem, img_sem):
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)
@@ -1035,7 +1077,7 @@ async def scan_post(post_id, session, post_sem, exif_sem, img_sem):
1035
  if exif_type:
1036
  code = EXIF_TYPE_TO_CODE.get(exif_type)
1037
  return post_id, url, code
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")
@@ -1075,25 +1117,42 @@ async def run_scan(post_ids, phpsessid, task_id=None, save_live=False, done_offs
1075
  cookies = {"PHPSESSID": phpsessid}
1076
  results = []
1077
  async with aiohttp.ClientSession(cookies=cookies, headers=PIXIV_HEADERS) as session:
1078
- tasks = [
1079
- asyncio.create_task(scan_post(pid, session, post_sem, exif_sem, img_sem))
1080
- for pid in post_ids
1081
- ]
 
 
 
 
 
 
 
 
 
 
 
 
1082
  pending = []
1083
  try:
1084
- for coro in asyncio.as_completed(tasks):
1085
  if task_canceled(task_id):
1086
  raise TaskCanceled()
1087
- result = await coro
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()
1095
- await save_scan_results(pending)
1096
- pending = []
 
 
 
 
 
1097
  if save_live and pending:
1098
  if task_canceled(task_id):
1099
  raise TaskCanceled()
@@ -1289,9 +1348,8 @@ def media_type_from_url(url):
1289
 
1290
 
1291
  async def get_pixiv_image_url(post_id, page, size, phpsessid):
1292
- cookies = {"PHPSESSID": phpsessid}
1293
- async with aiohttp.ClientSession(cookies=cookies, headers=PIXIV_HEADERS) as session:
1294
- pages = await get_post_pages(post_id, session)
1295
  if not pages:
1296
  raise HTTPException(status_code=404, detail="image not found")
1297
  page = min(max(page, 0), len(pages) - 1)
@@ -1306,12 +1364,21 @@ async def get_pixiv_image_url(post_id, page, size, phpsessid):
1306
 
1307
 
1308
  async def fetch_pixiv_bytes(url, phpsessid):
1309
- cookies = {"PHPSESSID": phpsessid}
1310
- async with aiohttp.ClientSession(cookies=cookies, headers=PIXIV_HEADERS) as session:
1311
- async with session.get(url) as r:
1312
- if r.status >= 400:
1313
- raise HTTPException(status_code=r.status, detail="image fetch failed")
1314
- return await r.read(), r.headers.get("Content-Type") or media_type_from_url(url)
 
 
 
 
 
 
 
 
 
1315
 
1316
 
1317
  async def create_webp(post_id, image_url, phpsessid, page=0, kind="t"):
@@ -1324,13 +1391,7 @@ async def create_webp(post_id, image_url, phpsessid, page=0, kind="t"):
1324
  data, _ = await fetch_pixiv_bytes(image_url, phpsessid)
1325
  if not data:
1326
  raise HTTPException(status_code=404, detail="image not found")
1327
- image = Image.open(io.BytesIO(data))
1328
- image = image.resize(
1329
- (max(int(image.width * WEBP_SCALE), 1), max(int(image.height * WEBP_SCALE), 1))
1330
- )
1331
- if image.mode not in ("RGB", "RGBA"):
1332
- image = image.convert("RGB")
1333
- image.save(out, "WEBP", quality=WEBP_QUALITY)
1334
  return out
1335
 
1336
 
@@ -1577,6 +1638,8 @@ async def startup():
1577
  async def shutdown():
1578
  if TURSO_CLIENT and not TURSO_CLIENT.is_closed:
1579
  await TURSO_CLIENT.aclose()
 
 
1580
 
1581
 
1582
  async def backfill_search_stats():
 
77
  THUMB_CLEAN_INTERVAL = 300
78
  THUMB_DIR = Path(tempfile.gettempdir()) / "pixif2-thumbs"
79
  PAGE_URL_CACHE_MAX_AGE = 1800
80
+ PAGE_URL_CACHE_LIMIT = 5000
81
  WEBP_SCALE = 0.4
82
  WEBP_QUALITY = 82
83
  STEALTH_ALPHA_SIGS = (b"stealth_pnginfo", b"stealth_pngcomp")
 
87
  ACTIVE_TASKS = {}
88
  TASK_EVENT_QUEUES = set()
89
  PAGE_URL_CACHE = {}
90
+ PAGE_URL_INFLIGHT = {}
91
  TURSO_CLIENT = None
92
+ PIXIV_CLIENT = None
93
  LAST_THUMB_CLEANUP = 0
94
 
95
  FRONTEND_DIR = os.path.join(os.getcwd(), "frontend")
 
135
  return TURSO_CLIENT
136
 
137
 
138
+ async def pixiv_client():
139
+ global PIXIV_CLIENT
140
+ if PIXIV_CLIENT is None or PIXIV_CLIENT.closed:
141
+ PIXIV_CLIENT = aiohttp.ClientSession(
142
+ cookies={"PHPSESSID": PHPSESSID}, headers=PIXIV_HEADERS
143
+ )
144
+ return PIXIV_CLIENT
145
+
146
+
147
  async def turso_execute(stmts):
148
  url = turso_url() + "/v2/pipeline"
149
  headers = {
 
697
  now = time.time()
698
  if cached and now - cached["time"] < PAGE_URL_CACHE_MAX_AGE:
699
  return cached["pages"]
700
+ inflight = PAGE_URL_INFLIGHT.get(key)
701
+ if inflight:
702
+ return await inflight
703
+
704
+ async def load():
705
+ data = await fetch_page(
706
+ session, f"https://www.pixiv.net/ajax/illust/{post_id}/pages"
 
 
 
 
707
  )
708
+ pages = []
709
+ for page in data.get("body") or []:
710
+ urls = page.get("urls") or {}
711
+ pages.append(
712
+ {
713
+ "original": urls.get("original") or "",
714
+ "regular": urls.get("regular") or "",
715
+ "small": urls.get("small") or "",
716
+ "thumb_mini": urls.get("thumb_mini") or "",
717
+ }
718
+ )
719
+ PAGE_URL_CACHE[key] = {"time": time.time(), "pages": pages}
720
+ if len(PAGE_URL_CACHE) > PAGE_URL_CACHE_LIMIT:
721
+ cutoff = time.time() - PAGE_URL_CACHE_MAX_AGE
722
+ for cid, row in list(PAGE_URL_CACHE.items()):
723
+ if row["time"] < cutoff:
724
+ PAGE_URL_CACHE.pop(cid, None)
725
+ return pages
726
+
727
+ task = asyncio.create_task(load())
728
+ PAGE_URL_INFLIGHT[key] = task
729
+ try:
730
+ return await task
731
+ finally:
732
+ if PAGE_URL_INFLIGHT.get(key) is task:
733
+ PAGE_URL_INFLIGHT.pop(key, None)
734
 
735
 
736
  def decode_meta_text(data):
 
861
 
862
  def parse_image_metadata(data):
863
  fields = {}
864
+ if data.startswith(b"\x89PNG\r\n\x1a\n"):
865
+ parsers = (parse_png_metadata, parse_xmp_metadata)
866
+ elif data.startswith(b"\xff\xd8"):
867
+ parsers = (parse_pil_metadata, parse_jpeg_comments, parse_xmp_metadata)
868
+ elif data.startswith(b"RIFF") and data[8:12] == b"WEBP":
869
+ parsers = (parse_pil_metadata, parse_xmp_metadata)
870
+ else:
871
+ parsers = (parse_pil_metadata, parse_xmp_metadata)
872
+ for parser in parsers:
873
  parsed = parser(data)
874
  if parsed:
875
  fields.update(parsed)
876
+ if not fields and data.startswith(b"\x89PNG\r\n\x1a\n"):
877
+ parsed = parse_pil_metadata(data)
878
+ if parsed:
879
+ fields.update(parsed)
880
  return fields or None
881
 
882
 
 
1033
  return None
1034
 
1035
 
1036
+ def image_ext(url):
1037
+ return urlsplit(url).path.rsplit(".", 1)[-1].casefold()
1038
+
1039
+
1040
  def byteize(bits):
1041
  bits = bits[: (bits.shape[0] // 8) * 8]
1042
  bits = np.bitwise_and(bits, 1)
 
1069
  image_urls = [
1070
  p["original"]
1071
  for p in pages
1072
+ if image_ext(p["original"]) in ("png", "jpg", "jpeg", "webp")
 
1073
  ]
1074
  for url in image_urls:
1075
  metadata = await get_exif_range(url, session, exif_sem)
 
1077
  if exif_type:
1078
  code = EXIF_TYPE_TO_CODE.get(exif_type)
1079
  return post_id, url, code
1080
+ for url in (u for u in image_urls if image_ext(u) == "png"):
1081
  img_data = await fetch_image(session, url, img_sem)
1082
  if img_data and has_stealth_png_bytes(img_data):
1083
  return post_id, url, EXIF_TYPE_TO_CODE.get("stealth")
 
1117
  cookies = {"PHPSESSID": phpsessid}
1118
  results = []
1119
  async with aiohttp.ClientSession(cookies=cookies, headers=PIXIV_HEADERS) as session:
1120
+ post_iter = iter(post_ids)
1121
+ tasks = set()
1122
+
1123
+ def fill_tasks():
1124
+ while len(tasks) < POST_SCAN_LIMIT:
1125
+ try:
1126
+ pid = next(post_iter)
1127
+ except StopIteration:
1128
+ break
1129
+ tasks.add(
1130
+ asyncio.create_task(
1131
+ scan_post(pid, session, post_sem, exif_sem, img_sem)
1132
+ )
1133
+ )
1134
+
1135
+ fill_tasks()
1136
  pending = []
1137
  try:
1138
+ while tasks:
1139
  if task_canceled(task_id):
1140
  raise TaskCanceled()
1141
+ done, tasks = await asyncio.wait(
1142
+ tasks, return_when=asyncio.FIRST_COMPLETED
1143
+ )
1144
+ for task in done:
1145
+ result = task.result()
1146
+ results.append(result)
1147
+ pending.append(result)
1148
+ if task_id and task_id in ACTIVE_TASKS:
1149
+ ACTIVE_TASKS[task_id]["done"] = done_offset + len(results)
1150
+ if save_live and len(pending) >= 20:
1151
+ if task_canceled(task_id):
1152
+ raise TaskCanceled()
1153
+ await save_scan_results(pending)
1154
+ pending = []
1155
+ fill_tasks()
1156
  if save_live and pending:
1157
  if task_canceled(task_id):
1158
  raise TaskCanceled()
 
1348
 
1349
 
1350
  async def get_pixiv_image_url(post_id, page, size, phpsessid):
1351
+ session = await pixiv_client()
1352
+ pages = await get_post_pages(post_id, session)
 
1353
  if not pages:
1354
  raise HTTPException(status_code=404, detail="image not found")
1355
  page = min(max(page, 0), len(pages) - 1)
 
1364
 
1365
 
1366
  async def fetch_pixiv_bytes(url, phpsessid):
1367
+ session = await pixiv_client()
1368
+ async with session.get(url) as r:
1369
+ if r.status >= 400:
1370
+ raise HTTPException(status_code=r.status, detail="image fetch failed")
1371
+ return await r.read(), r.headers.get("Content-Type") or media_type_from_url(url)
1372
+
1373
+
1374
+ def write_webp(data, out):
1375
+ image = Image.open(io.BytesIO(data))
1376
+ image = image.resize(
1377
+ (max(int(image.width * WEBP_SCALE), 1), max(int(image.height * WEBP_SCALE), 1))
1378
+ )
1379
+ if image.mode not in ("RGB", "RGBA"):
1380
+ image = image.convert("RGB")
1381
+ image.save(out, "WEBP", quality=WEBP_QUALITY)
1382
 
1383
 
1384
  async def create_webp(post_id, image_url, phpsessid, page=0, kind="t"):
 
1391
  data, _ = await fetch_pixiv_bytes(image_url, phpsessid)
1392
  if not data:
1393
  raise HTTPException(status_code=404, detail="image not found")
1394
+ await asyncio.to_thread(write_webp, data, out)
 
 
 
 
 
 
1395
  return out
1396
 
1397
 
 
1638
  async def shutdown():
1639
  if TURSO_CLIENT and not TURSO_CLIENT.is_closed:
1640
  await TURSO_CLIENT.aclose()
1641
+ if PIXIV_CLIENT and not PIXIV_CLIENT.closed:
1642
+ await PIXIV_CLIENT.close()
1643
 
1644
 
1645
  async def backfill_search_stats():
frontend/app.js CHANGED
@@ -434,6 +434,7 @@ async function openSearch(id, page = 1, show = false, view = explorerDetailView(
434
  }
435
 
436
  async function loadReclass(id) {
 
437
  try {
438
  const resp = await fetch(`/api/reclass/${encodeURIComponent(id)}`)
439
  const data = await resp.json()
 
434
  }
435
 
436
  async function loadReclass(id) {
437
+ if (!id.endsWith("_reclass")) return null
438
  try {
439
  const resp = await fetch(`/api/reclass/${encodeURIComponent(id)}`)
440
  const data = await resp.json()