Paginate explorer searches
Browse files- backend/app.py +46 -25
- frontend/app.js +23 -6
- frontend/style.css +1 -0
backend/app.py
CHANGED
|
@@ -50,6 +50,7 @@ POST_SCAN_LIMIT = 64
|
|
| 50 |
EXIF_RANGE_LIMIT = 96
|
| 51 |
FULL_IMAGE_LIMIT = 32
|
| 52 |
PAGE_SIZE = 60
|
|
|
|
| 53 |
THUMB_MAX_AGE = 1800
|
| 54 |
THUMB_DIR = Path(tempfile.gettempdir()) / "pixif2-thumbs"
|
| 55 |
PAGE_URL_CACHE_MAX_AGE = 1800
|
|
@@ -232,7 +233,6 @@ async def pixiv_search_live(url, pages, mode, phpsessid, search_id):
|
|
| 232 |
keywords = get_search_keywords(url)
|
| 233 |
api_url = get_search_api_url(url, keywords)
|
| 234 |
first_url = f"{api_url}&p=1"
|
| 235 |
-
await save_search(search_id, [])
|
| 236 |
cookies = {"PHPSESSID": phpsessid}
|
| 237 |
post_ids = []
|
| 238 |
seen = set()
|
|
@@ -257,7 +257,7 @@ async def pixiv_search_live(url, pages, mode, phpsessid, search_id):
|
|
| 257 |
if pid and pid not in seen:
|
| 258 |
seen.add(pid)
|
| 259 |
post_ids.append(pid)
|
| 260 |
-
if done
|
| 261 |
await save_search(search_id, post_ids)
|
| 262 |
await save_search(search_id, post_ids)
|
| 263 |
return post_ids, keywords, first_url
|
|
@@ -455,23 +455,27 @@ async def save_scan_results(results):
|
|
| 455 |
async def get_scanned_post_ids(post_ids):
|
| 456 |
if not post_ids:
|
| 457 |
return {}
|
| 458 |
-
chunks = [post_ids[i : i +
|
| 459 |
scanned = {}
|
|
|
|
| 460 |
for chunk in chunks:
|
| 461 |
placeholders = ",".join("?" for _ in chunk)
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
|
|
|
|
|
|
|
|
|
| 475 |
return scanned
|
| 476 |
|
| 477 |
|
|
@@ -769,28 +773,45 @@ async def scan_search(req: ScanRequest, bg: BackgroundTasks):
|
|
| 769 |
|
| 770 |
|
| 771 |
@app.get("/api/searches")
|
| 772 |
-
async def list_searches():
|
|
|
|
|
|
|
| 773 |
resp = await turso_execute(
|
| 774 |
[
|
| 775 |
{
|
| 776 |
-
"sql": "SELECT
|
| 777 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 778 |
]
|
| 779 |
)
|
| 780 |
results = resp.get("results") or []
|
| 781 |
-
if
|
| 782 |
-
return []
|
| 783 |
-
|
| 784 |
-
|
|
|
|
|
|
|
| 785 |
for row in rows:
|
| 786 |
search_id = row[0].get("value")
|
| 787 |
-
|
| 788 |
{
|
| 789 |
"id": search_id,
|
| 790 |
"created_at": base26_to_time(search_id),
|
| 791 |
}
|
| 792 |
)
|
| 793 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 794 |
|
| 795 |
|
| 796 |
@app.get("/api/search/{search_id}")
|
|
|
|
| 50 |
EXIF_RANGE_LIMIT = 96
|
| 51 |
FULL_IMAGE_LIMIT = 32
|
| 52 |
PAGE_SIZE = 60
|
| 53 |
+
SEARCH_PAGE_SIZE = 5
|
| 54 |
THUMB_MAX_AGE = 1800
|
| 55 |
THUMB_DIR = Path(tempfile.gettempdir()) / "pixif2-thumbs"
|
| 56 |
PAGE_URL_CACHE_MAX_AGE = 1800
|
|
|
|
| 233 |
keywords = get_search_keywords(url)
|
| 234 |
api_url = get_search_api_url(url, keywords)
|
| 235 |
first_url = f"{api_url}&p=1"
|
|
|
|
| 236 |
cookies = {"PHPSESSID": phpsessid}
|
| 237 |
post_ids = []
|
| 238 |
seen = set()
|
|
|
|
| 257 |
if pid and pid not in seen:
|
| 258 |
seen.add(pid)
|
| 259 |
post_ids.append(pid)
|
| 260 |
+
if done % 25 == 0:
|
| 261 |
await save_search(search_id, post_ids)
|
| 262 |
await save_search(search_id, post_ids)
|
| 263 |
return post_ids, keywords, first_url
|
|
|
|
| 455 |
async def get_scanned_post_ids(post_ids):
|
| 456 |
if not post_ids:
|
| 457 |
return {}
|
| 458 |
+
chunks = [post_ids[i : i + 500] for i in range(0, len(post_ids), 500)]
|
| 459 |
scanned = {}
|
| 460 |
+
stmts = []
|
| 461 |
for chunk in chunks:
|
| 462 |
placeholders = ",".join("?" for _ in chunk)
|
| 463 |
+
stmts.append(
|
| 464 |
+
{
|
| 465 |
+
"sql": f"SELECT post_id, url, exif_type FROM pi_scans WHERE post_id IN ({placeholders})",
|
| 466 |
+
"args": [{"type": "text", "value": str(pid)} for pid in chunk],
|
| 467 |
+
}
|
| 468 |
+
)
|
| 469 |
+
resp = await turso_execute(stmts)
|
| 470 |
+
for result in resp.get("results") or []:
|
| 471 |
+
if "response" not in result:
|
| 472 |
+
continue
|
| 473 |
+
rows = result["response"].get("result", {}).get("rows", [])
|
| 474 |
+
for row in rows:
|
| 475 |
+
pid = row[0].get("value")
|
| 476 |
+
url_val = row[1].get("value") if row[1].get("type") != "null" else ""
|
| 477 |
+
et = row[2].get("value") if row[2].get("type") != "null" else None
|
| 478 |
+
scanned[pid] = {"url": url_val, "exif_type": int(et) if et else None}
|
| 479 |
return scanned
|
| 480 |
|
| 481 |
|
|
|
|
| 773 |
|
| 774 |
|
| 775 |
@app.get("/api/searches")
|
| 776 |
+
async def list_searches(page: int = 1):
|
| 777 |
+
page = max(page, 1)
|
| 778 |
+
offset = (page - 1) * SEARCH_PAGE_SIZE
|
| 779 |
resp = await turso_execute(
|
| 780 |
[
|
| 781 |
{
|
| 782 |
+
"sql": "SELECT COUNT(*) FROM pi_searches"
|
| 783 |
+
},
|
| 784 |
+
{
|
| 785 |
+
"sql": "SELECT id FROM pi_searches ORDER BY id DESC LIMIT ? OFFSET ?",
|
| 786 |
+
"args": [
|
| 787 |
+
{"type": "integer", "value": str(SEARCH_PAGE_SIZE)},
|
| 788 |
+
{"type": "integer", "value": str(offset)},
|
| 789 |
+
],
|
| 790 |
+
},
|
| 791 |
]
|
| 792 |
)
|
| 793 |
results = resp.get("results") or []
|
| 794 |
+
if len(results) < 2 or "response" not in results[1]:
|
| 795 |
+
return {"items": [], "total": 0, "page": page, "pages": 1}
|
| 796 |
+
count_rows = results[0].get("response", {}).get("result", {}).get("rows", [])
|
| 797 |
+
total = int(count_rows[0][0].get("value", "0")) if count_rows else 0
|
| 798 |
+
rows = results[1]["response"].get("result", {}).get("rows", [])
|
| 799 |
+
items = []
|
| 800 |
for row in rows:
|
| 801 |
search_id = row[0].get("value")
|
| 802 |
+
items.append(
|
| 803 |
{
|
| 804 |
"id": search_id,
|
| 805 |
"created_at": base26_to_time(search_id),
|
| 806 |
}
|
| 807 |
)
|
| 808 |
+
return {
|
| 809 |
+
"items": items,
|
| 810 |
+
"total": total,
|
| 811 |
+
"page": page,
|
| 812 |
+
"page_size": SEARCH_PAGE_SIZE,
|
| 813 |
+
"pages": max((total + SEARCH_PAGE_SIZE - 1) // SEARCH_PAGE_SIZE, 1),
|
| 814 |
+
}
|
| 815 |
|
| 816 |
|
| 817 |
@app.get("/api/search/{search_id}")
|
frontend/app.js
CHANGED
|
@@ -3,6 +3,7 @@ const $$ = s => document.querySelectorAll(s)
|
|
| 3 |
const EXIF_NAMES = { 1: "novelai", 2: "sd", 3: "comfy", 4: "mj", 5: "celsys", 6: "photoshop", 7: "stealth" }
|
| 4 |
const LONG_DIGITS_RE = /\d{6,}/g
|
| 5 |
const PAGE_SIZE = 60
|
|
|
|
| 6 |
let viewerScale = 1
|
| 7 |
|
| 8 |
|
|
@@ -68,7 +69,7 @@ function route() {
|
|
| 68 |
if (parts[1]) {
|
| 69 |
openSearch(decodeURIComponent(parts[1]), parseInt(params.get("page")) || 1, params.get("exif") !== "0")
|
| 70 |
} else {
|
| 71 |
-
loadSearches()
|
| 72 |
}
|
| 73 |
}
|
| 74 |
}
|
|
@@ -79,17 +80,18 @@ function explorerHash(id, page, exifOnly) {
|
|
| 79 |
}
|
| 80 |
|
| 81 |
|
| 82 |
-
async function loadSearches() {
|
| 83 |
const list = $("#search-list")
|
| 84 |
const detail = $("#search-detail")
|
| 85 |
detail.classList.add("hidden")
|
| 86 |
list.innerHTML = "Loading..."
|
| 87 |
try {
|
| 88 |
-
const [searchResp, taskResp] = await Promise.all([fetch(
|
| 89 |
const data = await searchResp.json()
|
| 90 |
const tasks = await taskResp.json()
|
| 91 |
const active = tasks.filter(t => t.type === "search" || t.type === "search+scan" || t.type === "user_search")
|
| 92 |
-
|
|
|
|
| 93 |
const activeHtml = active.map(t => {
|
| 94 |
const pct = t.total > 0 ? Math.round(t.done / t.total * 100) : 0
|
| 95 |
const label = t.total > 0 ? `${t.done}/${t.total}` : "..."
|
|
@@ -99,7 +101,7 @@ async function loadSearches() {
|
|
| 99 |
<div class="mini-bar"><div style="width:${pct}%"></div></div>
|
| 100 |
</div>`
|
| 101 |
}).join("")
|
| 102 |
-
const savedHtml =
|
| 103 |
const d = new Date(parseInt(s.created_at) * 1000)
|
| 104 |
const ts = d.toLocaleString()
|
| 105 |
return `<div class="search-item" data-id="${esc(s.id)}">
|
|
@@ -111,7 +113,7 @@ async function loadSearches() {
|
|
| 111 |
</span>
|
| 112 |
</div>`
|
| 113 |
}).join("")
|
| 114 |
-
list.innerHTML = activeHtml + savedHtml
|
| 115 |
list.querySelectorAll(".search-item").forEach(el => {
|
| 116 |
if (!el.dataset.id) return
|
| 117 |
el.querySelector(".id").addEventListener("click", () => { location.hash = explorerHash(el.dataset.id, 1, true) })
|
|
@@ -120,11 +122,26 @@ async function loadSearches() {
|
|
| 120 |
if (rename) rename.addEventListener("click", e => { e.stopPropagation(); renameSearch(el.dataset.id) })
|
| 121 |
if (del) del.addEventListener("click", e => { e.stopPropagation(); deleteSearch(el.dataset.id) })
|
| 122 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
} catch (e) {
|
| 124 |
list.innerHTML = `Error: ${e.message}`
|
| 125 |
}
|
| 126 |
}
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
async function openSearch(id, page = 1, exifOnly = true) {
|
| 129 |
const list = $("#search-list")
|
| 130 |
const detail = $("#search-detail")
|
|
|
|
| 3 |
const EXIF_NAMES = { 1: "novelai", 2: "sd", 3: "comfy", 4: "mj", 5: "celsys", 6: "photoshop", 7: "stealth" }
|
| 4 |
const LONG_DIGITS_RE = /\d{6,}/g
|
| 5 |
const PAGE_SIZE = 60
|
| 6 |
+
const SEARCH_PAGE_SIZE = 5
|
| 7 |
let viewerScale = 1
|
| 8 |
|
| 9 |
|
|
|
|
| 69 |
if (parts[1]) {
|
| 70 |
openSearch(decodeURIComponent(parts[1]), parseInt(params.get("page")) || 1, params.get("exif") !== "0")
|
| 71 |
} else {
|
| 72 |
+
loadSearches(parseInt(params.get("page")) || 1)
|
| 73 |
}
|
| 74 |
}
|
| 75 |
}
|
|
|
|
| 80 |
}
|
| 81 |
|
| 82 |
|
| 83 |
+
async function loadSearches(page = 1) {
|
| 84 |
const list = $("#search-list")
|
| 85 |
const detail = $("#search-detail")
|
| 86 |
detail.classList.add("hidden")
|
| 87 |
list.innerHTML = "Loading..."
|
| 88 |
try {
|
| 89 |
+
const [searchResp, taskResp] = await Promise.all([fetch(`/api/searches?page=${page}`), fetch("/api/progress")])
|
| 90 |
const data = await searchResp.json()
|
| 91 |
const tasks = await taskResp.json()
|
| 92 |
const active = tasks.filter(t => t.type === "search" || t.type === "search+scan" || t.type === "user_search")
|
| 93 |
+
const searches = data.items || []
|
| 94 |
+
if (!searches.length && !active.length) { list.innerHTML = "No searches yet"; return }
|
| 95 |
const activeHtml = active.map(t => {
|
| 96 |
const pct = t.total > 0 ? Math.round(t.done / t.total * 100) : 0
|
| 97 |
const label = t.total > 0 ? `${t.done}/${t.total}` : "..."
|
|
|
|
| 101 |
<div class="mini-bar"><div style="width:${pct}%"></div></div>
|
| 102 |
</div>`
|
| 103 |
}).join("")
|
| 104 |
+
const savedHtml = searches.map(s => {
|
| 105 |
const d = new Date(parseInt(s.created_at) * 1000)
|
| 106 |
const ts = d.toLocaleString()
|
| 107 |
return `<div class="search-item" data-id="${esc(s.id)}">
|
|
|
|
| 113 |
</span>
|
| 114 |
</div>`
|
| 115 |
}).join("")
|
| 116 |
+
list.innerHTML = activeHtml + savedHtml + renderSearchPager(data)
|
| 117 |
list.querySelectorAll(".search-item").forEach(el => {
|
| 118 |
if (!el.dataset.id) return
|
| 119 |
el.querySelector(".id").addEventListener("click", () => { location.hash = explorerHash(el.dataset.id, 1, true) })
|
|
|
|
| 122 |
if (rename) rename.addEventListener("click", e => { e.stopPropagation(); renameSearch(el.dataset.id) })
|
| 123 |
if (del) del.addEventListener("click", e => { e.stopPropagation(); deleteSearch(el.dataset.id) })
|
| 124 |
})
|
| 125 |
+
const prev = $("#search-prev")
|
| 126 |
+
const next = $("#search-next")
|
| 127 |
+
if (prev) prev.onclick = () => { location.hash = `#/explorer?page=${Math.max(page - 1, 1)}` }
|
| 128 |
+
if (next) next.onclick = () => { location.hash = `#/explorer?page=${Math.min(page + 1, data.pages)}` }
|
| 129 |
} catch (e) {
|
| 130 |
list.innerHTML = `Error: ${e.message}`
|
| 131 |
}
|
| 132 |
}
|
| 133 |
|
| 134 |
+
function renderSearchPager(data) {
|
| 135 |
+
if (!data.total || data.pages <= 1) return ""
|
| 136 |
+
const start = (data.page - 1) * SEARCH_PAGE_SIZE + 1
|
| 137 |
+
const end = Math.min(data.page * SEARCH_PAGE_SIZE, data.total)
|
| 138 |
+
return `<div class="list-pager">
|
| 139 |
+
<button class="btn-secondary" id="search-prev" ${data.page <= 1 ? "disabled" : ""}>Prev</button>
|
| 140 |
+
<span>${start}-${end} of ${data.total} | page ${data.page}/${data.pages}</span>
|
| 141 |
+
<button class="btn-secondary" id="search-next" ${data.page >= data.pages ? "disabled" : ""}>Next</button>
|
| 142 |
+
</div>`
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
async function openSearch(id, page = 1, exifOnly = true) {
|
| 146 |
const list = $("#search-list")
|
| 147 |
const detail = $("#search-detail")
|
frontend/style.css
CHANGED
|
@@ -39,6 +39,7 @@ input:focus, select:focus { outline: none; border-color: #6c63ff; }
|
|
| 39 |
.active-task { border-color: #ffb74d; }
|
| 40 |
.mini-bar { width: 120px; height: 4px; background: #333; border-radius: 2px; overflow: hidden; margin-left: .75rem; }
|
| 41 |
.mini-bar div { height: 100%; background: #ffb74d; }
|
|
|
|
| 42 |
|
| 43 |
.detail-header { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
| 44 |
#detail-title { font-weight: bold; color: #6c63ff; }
|
|
|
|
| 39 |
.active-task { border-color: #ffb74d; }
|
| 40 |
.mini-bar { width: 120px; height: 4px; background: #333; border-radius: 2px; overflow: hidden; margin-left: .75rem; }
|
| 41 |
.mini-bar div { height: 100%; background: #ffb74d; }
|
| 42 |
+
.list-pager { display: flex; align-items: center; gap: .75rem; margin-top: .5rem; color: #888; font-size: .85rem; }
|
| 43 |
|
| 44 |
.detail-header { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
| 45 |
#detail-title { font-weight: bold; color: #6c63ff; }
|