Tighten image routes and explorer layout
Browse files- backend/app.py +49 -16
- frontend/app.js +11 -4
- frontend/index.html +1 -0
- 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
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 557 |
if image.mode not in ("RGB", "RGBA"):
|
| 558 |
image = image.convert("RGB")
|
| 559 |
-
image.save(out, "WEBP", quality=
|
| 560 |
return out
|
| 561 |
|
| 562 |
|
| 563 |
def image_links(post_id, url):
|
| 564 |
page = page_num_from_url(url)
|
| 565 |
-
suffix = f"?
|
| 566 |
pid = quote(str(post_id), safe="")
|
| 567 |
return {
|
| 568 |
-
"image_url": f"/api/
|
| 569 |
-
"
|
|
|
|
|
|
|
| 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/
|
| 863 |
-
async def get_image_thumb(post_id: str,
|
| 864 |
-
|
| 865 |
-
|
|
|
|
| 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/
|
| 874 |
-
async def
|
| 875 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 876 |
data, content_type = await fetch_pixiv_bytes(image_url, PHPSESSID)
|
|
|
|
| 877 |
return Response(
|
| 878 |
data,
|
| 879 |
media_type=content_type,
|
| 880 |
-
headers={
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 205 |
: ""
|
| 206 |
return `<div class="result-card" data-pid="${pid}">
|
| 207 |
${thumb}
|
| 208 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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(
|
| 54 |
.result-card { background: #1a1a2e; border: 1px solid #333; border-radius: 4px; padding: .5rem; text-align: center; font-size: .75rem; }
|
| 55 |
-
.thumb { width:
|
| 56 |
.thumb::after { content: "loading"; color: #444; font-size: .7rem; }
|
| 57 |
.thumb-loaded::after { content: ""; }
|
| 58 |
-
.thumb img { max-width:
|
| 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 |
+
}
|