Optimize scanning and image proxy paths
Browse files- backend/app.py +116 -53
- 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 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
"regular": urls.get("regular") or "",
|
| 696 |
-
"small": urls.get("small") or "",
|
| 697 |
-
"thumb_mini": urls.get("thumb_mini") or "",
|
| 698 |
-
}
|
| 699 |
)
|
| 700 |
-
|
| 701 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 833 |
-
parse_png_metadata,
|
| 834 |
-
|
| 835 |
-
parse_jpeg_comments,
|
| 836 |
-
|
| 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
|
| 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 |
-
|
| 1079 |
-
|
| 1080 |
-
|
| 1081 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1082 |
pending = []
|
| 1083 |
try:
|
| 1084 |
-
|
| 1085 |
if task_canceled(task_id):
|
| 1086 |
raise TaskCanceled()
|
| 1087 |
-
|
| 1088 |
-
|
| 1089 |
-
|
| 1090 |
-
|
| 1091 |
-
|
| 1092 |
-
|
| 1093 |
-
|
| 1094 |
-
|
| 1095 |
-
|
| 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 |
-
|
| 1293 |
-
|
| 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 |
-
|
| 1310 |
-
async with
|
| 1311 |
-
|
| 1312 |
-
|
| 1313 |
-
|
| 1314 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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()
|