q6 commited on
Commit
293cca3
·
1 Parent(s): c5ec522

Paginate explorer searches

Browse files
Files changed (3) hide show
  1. backend/app.py +46 -25
  2. frontend/app.js +23 -6
  3. 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 == 1 or done % 5 == 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,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 + 200] for i in range(0, len(post_ids), 200)]
459
  scanned = {}
 
460
  for chunk in chunks:
461
  placeholders = ",".join("?" for _ in chunk)
462
- stmt = {
463
- "sql": f"SELECT post_id, url, exif_type FROM pi_scans WHERE post_id IN ({placeholders})",
464
- "args": [{"type": "text", "value": str(pid)} for pid in chunk],
465
- }
466
- resp = await turso_execute([stmt])
467
- results = resp.get("results") or []
468
- if results and "response" in results[0]:
469
- rows = results[0]["response"].get("result", {}).get("rows", [])
470
- for row in rows:
471
- pid = row[0].get("value")
472
- url_val = row[1].get("value") if row[1].get("type") != "null" else ""
473
- et = row[2].get("value") if row[2].get("type") != "null" else None
474
- scanned[pid] = {"url": url_val, "exif_type": int(et) if et else None}
 
 
 
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 id FROM pi_searches ORDER BY length(id) DESC, id DESC LIMIT 100"
777
- }
 
 
 
 
 
 
 
778
  ]
779
  )
780
  results = resp.get("results") or []
781
- if not results or "response" not in results[0]:
782
- return []
783
- rows = results[0]["response"].get("result", {}).get("rows", [])
784
- out = []
 
 
785
  for row in rows:
786
  search_id = row[0].get("value")
787
- out.append(
788
  {
789
  "id": search_id,
790
  "created_at": base26_to_time(search_id),
791
  }
792
  )
793
- return out
 
 
 
 
 
 
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("/api/searches"), fetch("/api/progress")])
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
- if (!data.length && !active.length) { list.innerHTML = "No searches yet"; return }
 
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 = data.map(s => {
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; }