q6 commited on
Commit
277bd3c
·
1 Parent(s): a70cdc9

Tighten image routes and explorer layout

Browse files
Files changed (4) hide show
  1. backend/app.py +49 -16
  2. frontend/app.js +11 -4
  3. frontend/index.html +1 -0
  4. frontend/style.css +11 -4
backend/app.py CHANGED
@@ -525,7 +525,7 @@ async def get_pixiv_image_url(post_id, page, size, phpsessid):
525
  raise HTTPException(status_code=404, detail="image not found")
526
  page = min(max(page, 0), len(pages) - 1)
527
  urls = pages[page]
528
- if size == "full":
529
  url = urls.get("original") or urls.get("regular") or urls.get("small")
530
  else:
531
  url = urls.get("regular") or urls.get("small") or urls.get("original")
@@ -543,9 +543,9 @@ async def fetch_pixiv_bytes(url, phpsessid):
543
  return await r.read(), r.headers.get("Content-Type") or media_type_from_url(url)
544
 
545
 
546
- async def create_thumb(post_id, image_url, phpsessid, page=0):
547
  cleanup_thumbs()
548
- out = THUMB_DIR / f"{post_id}_p{page}.webp"
549
  if out.exists():
550
  os.utime(out, None)
551
  return out
@@ -553,20 +553,25 @@ async def create_thumb(post_id, image_url, phpsessid, page=0):
553
  if not data:
554
  raise HTTPException(status_code=404, detail="image not found")
555
  image = Image.open(io.BytesIO(data))
556
- image.thumbnail((144, 144))
 
 
 
557
  if image.mode not in ("RGB", "RGBA"):
558
  image = image.convert("RGB")
559
- image.save(out, "WEBP", quality=70)
560
  return out
561
 
562
 
563
  def image_links(post_id, url):
564
  page = page_num_from_url(url)
565
- suffix = f"?page={page}"
566
  pid = quote(str(post_id), safe="")
567
  return {
568
- "image_url": f"/api/image/{pid}/thumb{suffix}",
569
- "full_image_url": f"/api/image/{pid}/full{suffix}",
 
 
570
  "page": page,
571
  }
572
 
@@ -859,10 +864,11 @@ async def get_results(search_id: str, page: int = 1, exif_only: bool = True):
859
  }
860
 
861
 
862
- @app.get("/api/image/{post_id}/thumb")
863
- async def get_image_thumb(post_id: str, page: int = 0):
864
- image_url = await get_pixiv_image_url(post_id, page, "thumb", PHPSESSID)
865
- path = await create_thumb(post_id, image_url, PHPSESSID, page)
 
866
  return FileResponse(
867
  path,
868
  media_type="image/webp",
@@ -870,17 +876,44 @@ async def get_image_thumb(post_id: str, page: int = 0):
870
  )
871
 
872
 
873
- @app.get("/api/image/{post_id}/full")
874
- async def get_image_full(post_id: str, page: int = 0):
875
- image_url = await get_pixiv_image_url(post_id, page, "full", PHPSESSID)
 
 
 
 
 
 
 
 
 
 
 
 
 
876
  data, content_type = await fetch_pixiv_bytes(image_url, PHPSESSID)
 
877
  return Response(
878
  data,
879
  media_type=content_type,
880
- headers={"Cache-Control": f"public, max-age={THUMB_MAX_AGE}"},
 
 
 
881
  )
882
 
883
 
 
 
 
 
 
 
 
 
 
 
884
  @app.get("/api/thumb/{post_id}")
885
  async def get_thumb(post_id: str):
886
  return await get_image_thumb(post_id)
 
525
  raise HTTPException(status_code=404, detail="image not found")
526
  page = min(max(page, 0), len(pages) - 1)
527
  urls = pages[page]
528
+ if size in ("full", "orig"):
529
  url = urls.get("original") or urls.get("regular") or urls.get("small")
530
  else:
531
  url = urls.get("regular") or urls.get("small") or urls.get("original")
 
543
  return await r.read(), r.headers.get("Content-Type") or media_type_from_url(url)
544
 
545
 
546
+ async def create_webp(post_id, image_url, phpsessid, page=0, kind="t"):
547
  cleanup_thumbs()
548
+ out = THUMB_DIR / f"{post_id}_p{page}_{kind}.webp"
549
  if out.exists():
550
  os.utime(out, None)
551
  return out
 
553
  if not data:
554
  raise HTTPException(status_code=404, detail="image not found")
555
  image = Image.open(io.BytesIO(data))
556
+ if kind == "v":
557
+ image = image.resize((max(image.width // 2, 1), max(image.height // 2, 1)))
558
+ else:
559
+ image.thumbnail((360, 360))
560
  if image.mode not in ("RGB", "RGBA"):
561
  image = image.convert("RGB")
562
+ image.save(out, "WEBP", quality=82 if kind == "v" else 72)
563
  return out
564
 
565
 
566
  def image_links(post_id, url):
567
  page = page_num_from_url(url)
568
+ suffix = f"?p={page}"
569
  pid = quote(str(post_id), safe="")
570
  return {
571
+ "image_url": f"/api/i/{pid}/t{suffix}",
572
+ "preview_url": f"/api/i/{pid}/v{suffix}",
573
+ "download_url": f"/api/i/{pid}/o{suffix}",
574
+ "full_image_url": f"/api/i/{pid}/v{suffix}",
575
  "page": page,
576
  }
577
 
 
864
  }
865
 
866
 
867
+ @app.get("/api/i/{post_id}/t")
868
+ async def get_image_thumb(post_id: str, p=0):
869
+ p = int(p or 0)
870
+ image_url = await get_pixiv_image_url(post_id, p, "thumb", PHPSESSID)
871
+ path = await create_webp(post_id, image_url, PHPSESSID, p, "t")
872
  return FileResponse(
873
  path,
874
  media_type="image/webp",
 
876
  )
877
 
878
 
879
+ @app.get("/api/i/{post_id}/v")
880
+ async def get_image_preview(post_id: str, p=0):
881
+ p = int(p or 0)
882
+ image_url = await get_pixiv_image_url(post_id, p, "full", PHPSESSID)
883
+ path = await create_webp(post_id, image_url, PHPSESSID, p, "v")
884
+ return FileResponse(
885
+ path,
886
+ media_type="image/webp",
887
+ headers={"Cache-Control": f"public, max-age={THUMB_MAX_AGE}"},
888
+ )
889
+
890
+
891
+ @app.get("/api/i/{post_id}/o")
892
+ async def get_image_original(post_id: str, p=0):
893
+ p = int(p or 0)
894
+ image_url = await get_pixiv_image_url(post_id, p, "orig", PHPSESSID)
895
  data, content_type = await fetch_pixiv_bytes(image_url, PHPSESSID)
896
+ filename = urlsplit(image_url).path.rsplit("/", 1)[-1] or f"{post_id}_p{p}.png"
897
  return Response(
898
  data,
899
  media_type=content_type,
900
+ headers={
901
+ "Cache-Control": f"public, max-age={THUMB_MAX_AGE}",
902
+ "Content-Disposition": f'attachment; filename="{filename}"',
903
+ },
904
  )
905
 
906
 
907
+ @app.get("/api/image/{post_id}/thumb")
908
+ async def get_long_image_thumb(post_id: str, page: int = 0, p=None):
909
+ return await get_image_thumb(post_id, page if p is None else p)
910
+
911
+
912
+ @app.get("/api/image/{post_id}/full")
913
+ async def get_long_image_full(post_id: str, page: int = 0, p=None):
914
+ return await get_image_preview(post_id, page if p is None else p)
915
+
916
+
917
  @app.get("/api/thumb/{post_id}")
918
  async def get_thumb(post_id: str):
919
  return await get_image_thumb(post_id)
frontend/app.js CHANGED
@@ -200,12 +200,17 @@ function renderResults(data) {
200
  } else {
201
  badge = `<span class="no-exif">NIL</span>`
202
  }
 
203
  const thumb = item.image_url
204
- ? `<button class="thumb" data-full="${esc(item.full_image_url)}"><img src="${esc(item.image_url)}" loading="lazy" alt="${esc(label)}"></button>`
205
  : ""
206
  return `<div class="result-card" data-pid="${pid}">
207
  ${thumb}
208
- <span class="result-link">${label}</span><br>${badge}
 
 
 
 
209
  </div>`
210
  }).join("")
211
  grid.querySelectorAll(".result-link").forEach(el => {
@@ -215,16 +220,17 @@ function renderResults(data) {
215
  })
216
  })
217
  grid.querySelectorAll(".thumb").forEach(el => {
218
- el.addEventListener("click", () => openViewer(el.dataset.full))
219
  el.querySelector("img").addEventListener("load", () => el.classList.add("thumb-loaded"))
220
  el.querySelector("img").addEventListener("error", () => el.classList.add("thumb-error"))
221
  })
222
  }
223
 
224
- function openViewer(src) {
225
  if (!src) return
226
  viewerScale = 1
227
  $("#viewer-img").src = src
 
228
  $("#viewer").classList.remove("hidden")
229
  $("#viewer").setAttribute("aria-hidden", "false")
230
  applyViewerZoom()
@@ -234,6 +240,7 @@ function closeViewer() {
234
  $("#viewer").classList.add("hidden")
235
  $("#viewer").setAttribute("aria-hidden", "true")
236
  $("#viewer-img").src = ""
 
237
  }
238
 
239
  function applyViewerZoom() {
 
200
  } else {
201
  badge = `<span class="no-exif">NIL</span>`
202
  }
203
+ const download = item.download_url ? `<a class="download-link" href="${esc(item.download_url)}">Download</a>` : ""
204
  const thumb = item.image_url
205
+ ? `<button class="thumb" data-preview="${esc(item.preview_url || item.full_image_url)}" data-download="${esc(item.download_url || "")}"><img src="${esc(item.image_url)}" loading="lazy" alt="${esc(label)}"></button>`
206
  : ""
207
  return `<div class="result-card" data-pid="${pid}">
208
  ${thumb}
209
+ <div class="result-meta">
210
+ <span class="result-link">${label}</span>
211
+ ${download}
212
+ </div>
213
+ ${badge}
214
  </div>`
215
  }).join("")
216
  grid.querySelectorAll(".result-link").forEach(el => {
 
220
  })
221
  })
222
  grid.querySelectorAll(".thumb").forEach(el => {
223
+ el.addEventListener("click", () => openViewer(el.dataset.preview, el.dataset.download))
224
  el.querySelector("img").addEventListener("load", () => el.classList.add("thumb-loaded"))
225
  el.querySelector("img").addEventListener("error", () => el.classList.add("thumb-error"))
226
  })
227
  }
228
 
229
+ function openViewer(src, download) {
230
  if (!src) return
231
  viewerScale = 1
232
  $("#viewer-img").src = src
233
+ $("#viewer-download").href = download || src
234
  $("#viewer").classList.remove("hidden")
235
  $("#viewer").setAttribute("aria-hidden", "false")
236
  applyViewerZoom()
 
240
  $("#viewer").classList.add("hidden")
241
  $("#viewer").setAttribute("aria-hidden", "true")
242
  $("#viewer-img").src = ""
243
+ $("#viewer-download").href = ""
244
  }
245
 
246
  function applyViewerZoom() {
frontend/index.html CHANGED
@@ -73,6 +73,7 @@
73
  <button id="viewer-zoom-out" class="btn-secondary">-</button>
74
  <span id="viewer-zoom">100%</span>
75
  <button id="viewer-zoom-in" class="btn-secondary">+</button>
 
76
  <button id="viewer-close" class="btn-secondary">Close</button>
77
  </div>
78
  <div id="viewer-stage">
 
73
  <button id="viewer-zoom-out" class="btn-secondary">-</button>
74
  <span id="viewer-zoom">100%</span>
75
  <button id="viewer-zoom-in" class="btn-secondary">+</button>
76
+ <a id="viewer-download" class="btn-secondary" href="">Download</a>
77
  <button id="viewer-close" class="btn-secondary">Close</button>
78
  </div>
79
  <div id="viewer-stage">
frontend/style.css CHANGED
@@ -16,7 +16,7 @@ input:focus, select:focus { outline: none; border-color: #6c63ff; }
16
  .btn-primary { background: #6c63ff; color: #fff; border: none; padding: .6rem 1.5rem; border-radius: 4px; cursor: pointer; font-size: .9rem; }
17
  .btn-primary:hover { background: #5a52d5; }
18
  .btn-primary:disabled { background: #444; cursor: not-allowed; }
19
- .btn-secondary { background: #2a2a3e; color: #ccc; border: 1px solid #444; padding: .5rem 1rem; border-radius: 4px; cursor: pointer; font-size: .85rem; }
20
  .btn-secondary:hover { background: #3a3a4e; }
21
  .btn-secondary:disabled { color: #666; cursor: not-allowed; }
22
  #submit-status { margin-top: 1rem; padding: .5rem; border-radius: 4px; font-size: .9rem; }
@@ -50,15 +50,18 @@ input:focus, select:focus { outline: none; border-color: #6c63ff; }
50
  #detail-api-url a { color: #6c63ff; }
51
  #pager { display: flex; align-items: center; gap: .75rem; margin-bottom: .75rem; color: #888; font-size: .85rem; }
52
 
53
- #results-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(144px, 1fr)); gap: .5rem; max-height: 70vh; overflow-y: auto; padding: .5rem 0; }
54
  .result-card { background: #1a1a2e; border: 1px solid #333; border-radius: 4px; padding: .5rem; text-align: center; font-size: .75rem; }
55
- .thumb { width: 144px; height: 144px; margin: 0 auto .4rem; display: flex; align-items: center; justify-content: center; background: #111; border: 0; border-radius: 3px; cursor: zoom-in; overflow: hidden; }
56
  .thumb::after { content: "loading"; color: #444; font-size: .7rem; }
57
  .thumb-loaded::after { content: ""; }
58
- .thumb img { max-width: 144px; max-height: 144px; border-radius: 3px; object-fit: contain; }
59
  .thumb-error::after { content: "no thumb"; }
 
60
  .result-link { color: #6c63ff; cursor: pointer; }
61
  .result-link:hover { text-decoration: underline; }
 
 
62
  .result-card .exif-badge { display: inline-block; padding: .1rem .4rem; border-radius: 3px; font-size: .7rem; margin-top: .25rem; }
63
  .exif-1 { background: #2d1f5e; color: #b39ddb; }
64
  .exif-2 { background: #1b3a1b; color: #81c784; }
@@ -86,3 +89,7 @@ input:focus, select:focus { outline: none; border-color: #6c63ff; }
86
  #viewer-zoom { min-width: 4rem; text-align: center; color: #ccc; font-size: .85rem; }
87
  #viewer-stage { flex: 1; overflow: auto; display: flex; align-items: flex-start; justify-content: center; padding: 1rem; }
88
  #viewer-img { max-width: none; max-height: none; transform-origin: top center; transition: transform .08s ease; }
 
 
 
 
 
16
  .btn-primary { background: #6c63ff; color: #fff; border: none; padding: .6rem 1.5rem; border-radius: 4px; cursor: pointer; font-size: .9rem; }
17
  .btn-primary:hover { background: #5a52d5; }
18
  .btn-primary:disabled { background: #444; cursor: not-allowed; }
19
+ .btn-secondary { background: #2a2a3e; color: #ccc; border: 1px solid #444; padding: .5rem 1rem; border-radius: 4px; cursor: pointer; font-size: .85rem; text-decoration: none; }
20
  .btn-secondary:hover { background: #3a3a4e; }
21
  .btn-secondary:disabled { color: #666; cursor: not-allowed; }
22
  #submit-status { margin-top: 1rem; padding: .5rem; border-radius: 4px; font-size: .9rem; }
 
50
  #detail-api-url a { color: #6c63ff; }
51
  #pager { display: flex; align-items: center; gap: .75rem; margin-bottom: .75rem; color: #888; font-size: .85rem; }
52
 
53
+ #results-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .75rem; max-height: 70vh; overflow-y: auto; padding: .5rem 0; }
54
  .result-card { background: #1a1a2e; border: 1px solid #333; border-radius: 4px; padding: .5rem; text-align: center; font-size: .75rem; }
55
+ .thumb { width: 100%; aspect-ratio: 1; margin: 0 auto .5rem; display: flex; align-items: center; justify-content: center; background: #111; border: 0; border-radius: 3px; cursor: zoom-in; overflow: hidden; }
56
  .thumb::after { content: "loading"; color: #444; font-size: .7rem; }
57
  .thumb-loaded::after { content: ""; }
58
+ .thumb img { max-width: 100%; max-height: 100%; border-radius: 3px; object-fit: contain; }
59
  .thumb-error::after { content: "no thumb"; }
60
+ .result-meta { display: flex; align-items: center; justify-content: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .25rem; }
61
  .result-link { color: #6c63ff; cursor: pointer; }
62
  .result-link:hover { text-decoration: underline; }
63
+ .download-link { color: #ccc; border: 1px solid #444; border-radius: 3px; padding: .15rem .4rem; text-decoration: none; }
64
+ .download-link:hover { color: #fff; border-color: #6c63ff; }
65
  .result-card .exif-badge { display: inline-block; padding: .1rem .4rem; border-radius: 3px; font-size: .7rem; margin-top: .25rem; }
66
  .exif-1 { background: #2d1f5e; color: #b39ddb; }
67
  .exif-2 { background: #1b3a1b; color: #81c784; }
 
89
  #viewer-zoom { min-width: 4rem; text-align: center; color: #ccc; font-size: .85rem; }
90
  #viewer-stage { flex: 1; overflow: auto; display: flex; align-items: flex-start; justify-content: center; padding: 1rem; }
91
  #viewer-img { max-width: none; max-height: none; transform-origin: top center; transition: transform .08s ease; }
92
+
93
+ @media (max-width: 800px) {
94
+ #results-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
95
+ }