add Previews folder with ≤1MB scaled previews, toggle in UI
This commit is contained in:
@@ -3,6 +3,6 @@ WORKDIR /app
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY . .
|
COPY . .
|
||||||
VOLUME ["/app/Pictures", "/app/booru.db"]
|
VOLUME ["/app/Pictures", "/app/Previews", "/app/booru.db"]
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
CMD ["python", "Slideshow.py"]
|
CMD ["python", "Slideshow.py"]
|
||||||
|
|||||||
0
Previews/.gitkeep
Normal file
0
Previews/.gitkeep
Normal file
41
Slideshow.py
41
Slideshow.py
@@ -5,7 +5,7 @@ import sys
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from flask import Flask, render_template, request, redirect, url_for, Response
|
from flask import Flask, render_template, request, redirect, url_for, Response, send_from_directory
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
|
||||||
from db import init_db, search_images
|
from db import init_db, search_images
|
||||||
@@ -18,23 +18,52 @@ init_db()
|
|||||||
downloads = {}
|
downloads = {}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/previews/<path:filename>')
|
||||||
|
def serve_preview(filename):
|
||||||
|
return send_from_directory('Previews', filename)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def slideshow():
|
def slideshow():
|
||||||
raw_query = request.args.get('tags', '').strip()
|
raw_query = request.args.get('tags', '').strip()
|
||||||
results = search_images(raw_query)
|
results = search_images(raw_query)
|
||||||
pictures_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'Pictures')
|
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
image_urls = [f'pictures/{r["filename"]}' for r in results]
|
pictures_dir = os.path.join(base_dir, 'Pictures')
|
||||||
post_urls = [r['post_url'] for r in results]
|
previews_dir = os.path.join(base_dir, 'Previews')
|
||||||
tags_list = [r['tags'].split() for r in results]
|
|
||||||
file_sizes = [os.path.getsize(os.path.join(pictures_dir, r['filename'])) for r in results]
|
image_urls = []
|
||||||
|
preview_urls = []
|
||||||
|
post_urls = []
|
||||||
|
tags_list = []
|
||||||
|
file_sizes = []
|
||||||
|
preview_sizes = []
|
||||||
|
|
||||||
|
for r in results:
|
||||||
|
fname = r['filename']
|
||||||
|
pname = r['preview_filename']
|
||||||
|
image_urls.append(f'pictures/{fname}')
|
||||||
|
post_urls.append(r['post_url'])
|
||||||
|
tags_list.append(r['tags'].split())
|
||||||
|
file_sizes.append(os.path.getsize(os.path.join(pictures_dir, fname)))
|
||||||
|
|
||||||
|
ppath = os.path.join(previews_dir, pname) if pname else ''
|
||||||
|
if pname and os.path.exists(ppath):
|
||||||
|
preview_urls.append(f'previews/{pname}')
|
||||||
|
preview_sizes.append(os.path.getsize(ppath))
|
||||||
|
else:
|
||||||
|
preview_urls.append(f'pictures/{fname}')
|
||||||
|
preview_sizes.append(file_sizes[-1])
|
||||||
|
|
||||||
active_tags = raw_query.split() if raw_query else []
|
active_tags = raw_query.split() if raw_query else []
|
||||||
job_id = request.args.get('job_id')
|
job_id = request.args.get('job_id')
|
||||||
return render_template(
|
return render_template(
|
||||||
'slideshow.html',
|
'slideshow.html',
|
||||||
images=image_urls,
|
images=image_urls,
|
||||||
|
preview_images=preview_urls,
|
||||||
post_urls=post_urls,
|
post_urls=post_urls,
|
||||||
tags_list=tags_list,
|
tags_list=tags_list,
|
||||||
file_sizes=file_sizes,
|
file_sizes=file_sizes,
|
||||||
|
preview_sizes=preview_sizes,
|
||||||
active_tags=active_tags,
|
active_tags=active_tags,
|
||||||
tag_query=raw_query,
|
tag_query=raw_query,
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
|
|||||||
18
db.py
18
db.py
@@ -18,6 +18,7 @@ def init_db():
|
|||||||
tags TEXT NOT NULL DEFAULT '',
|
tags TEXT NOT NULL DEFAULT '',
|
||||||
file_url TEXT NOT NULL,
|
file_url TEXT NOT NULL,
|
||||||
post_url TEXT NOT NULL DEFAULT '',
|
post_url TEXT NOT NULL DEFAULT '',
|
||||||
|
preview_filename TEXT NOT NULL DEFAULT '',
|
||||||
downloaded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
downloaded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
)""")
|
)""")
|
||||||
c.execute(
|
c.execute(
|
||||||
@@ -28,6 +29,11 @@ def init_db():
|
|||||||
c.execute("ALTER TABLE images ADD COLUMN post_url TEXT NOT NULL DEFAULT ''")
|
c.execute("ALTER TABLE images ADD COLUMN post_url TEXT NOT NULL DEFAULT ''")
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass
|
pass
|
||||||
|
# migrate existing DBs that predate the preview_filename column
|
||||||
|
try:
|
||||||
|
c.execute("ALTER TABLE images ADD COLUMN preview_filename TEXT NOT NULL DEFAULT ''")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def image_exists(site, post_id):
|
def image_exists(site, post_id):
|
||||||
@@ -40,11 +46,11 @@ def image_exists(site, post_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def insert_image(post_id, site, filename, tags, file_url, post_url):
|
def insert_image(post_id, site, filename, tags, file_url, post_url, preview_filename=''):
|
||||||
with get_conn() as c:
|
with get_conn() as c:
|
||||||
c.execute(
|
c.execute(
|
||||||
"INSERT OR IGNORE INTO images (post_id, site, filename, tags, file_url, post_url) VALUES (?,?,?,?,?,?)",
|
"INSERT OR IGNORE INTO images (post_id, site, filename, tags, file_url, post_url, preview_filename) VALUES (?,?,?,?,?,?,?)",
|
||||||
(post_id, site, filename, tags, file_url, post_url),
|
(post_id, site, filename, tags, file_url, post_url, preview_filename),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -53,13 +59,13 @@ def search_images(tag_query):
|
|||||||
with get_conn() as c:
|
with get_conn() as c:
|
||||||
if not terms:
|
if not terms:
|
||||||
rows = c.execute(
|
rows = c.execute(
|
||||||
"SELECT filename, post_url, tags FROM images ORDER BY id"
|
"SELECT filename, preview_filename, post_url, tags FROM images ORDER BY id"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
else:
|
else:
|
||||||
where = ' AND '.join(['tags LIKE ?'] * len(terms))
|
where = ' AND '.join(['tags LIKE ?'] * len(terms))
|
||||||
params = [f'%{t}%' for t in terms]
|
params = [f'%{t}%' for t in terms]
|
||||||
rows = c.execute(
|
rows = c.execute(
|
||||||
f"SELECT filename, post_url, tags FROM images WHERE {where} ORDER BY id",
|
f"SELECT filename, preview_filename, post_url, tags FROM images WHERE {where} ORDER BY id",
|
||||||
params,
|
params,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [{'filename': r[0], 'post_url': r[1], 'tags': r[2]} for r in rows]
|
return [{'filename': r[0], 'preview_filename': r[1], 'post_url': r[2], 'tags': r[3]} for r in rows]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ services:
|
|||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./Pictures:/app/Pictures
|
- ./Pictures:/app/Pictures
|
||||||
|
- ./Previews:/app/Previews
|
||||||
- ./booru.db:/app/booru.db
|
- ./booru.db:/app/booru.db
|
||||||
networks:
|
networks:
|
||||||
- caddy
|
- caddy
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import io
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import argparse
|
import argparse
|
||||||
import requests
|
import requests
|
||||||
@@ -89,7 +92,53 @@ def fetch_all_posts(adapter, query, auth, limit):
|
|||||||
return posts[:limit]
|
return posts[:limit]
|
||||||
|
|
||||||
|
|
||||||
def download_one(post, site_name, adapter, pictures_dir, session):
|
def make_preview(src_path, previews_dir, max_bytes=1_048_576):
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
src_name = os.path.basename(src_path)
|
||||||
|
name_no_ext, _ext = os.path.splitext(src_name)
|
||||||
|
|
||||||
|
if os.path.getsize(src_path) <= max_bytes:
|
||||||
|
dest_path = os.path.join(previews_dir, src_name)
|
||||||
|
shutil.copy2(src_path, dest_path)
|
||||||
|
return src_name
|
||||||
|
|
||||||
|
try:
|
||||||
|
img = Image.open(src_path)
|
||||||
|
if img.mode not in ('RGB', 'L', 'RGBA'):
|
||||||
|
img = img.convert('RGB')
|
||||||
|
elif img.mode == 'RGBA':
|
||||||
|
bg = Image.new('RGB', img.size, (255, 255, 255))
|
||||||
|
bg.paste(img, mask=img.split()[3])
|
||||||
|
img = bg
|
||||||
|
except Exception:
|
||||||
|
dest_path = os.path.join(previews_dir, src_name)
|
||||||
|
shutil.copy2(src_path, dest_path)
|
||||||
|
return src_name
|
||||||
|
|
||||||
|
file_size = os.path.getsize(src_path)
|
||||||
|
scale = math.sqrt(max_bytes / file_size)
|
||||||
|
w, h = img.size
|
||||||
|
img = img.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS)
|
||||||
|
|
||||||
|
preview_name = name_no_ext + '.jpg'
|
||||||
|
dest_path = os.path.join(previews_dir, preview_name)
|
||||||
|
|
||||||
|
quality = 85
|
||||||
|
buf = io.BytesIO()
|
||||||
|
while quality >= 20:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format='JPEG', quality=quality, optimize=True)
|
||||||
|
if buf.tell() <= max_bytes:
|
||||||
|
break
|
||||||
|
quality -= 10
|
||||||
|
|
||||||
|
with open(dest_path, 'wb') as f:
|
||||||
|
f.write(buf.getvalue())
|
||||||
|
return preview_name
|
||||||
|
|
||||||
|
|
||||||
|
def download_one(post, site_name, adapter, pictures_dir, previews_dir, session):
|
||||||
parsed = adapter['parse'](post)
|
parsed = adapter['parse'](post)
|
||||||
if not parsed:
|
if not parsed:
|
||||||
return 'skip:no_url'
|
return 'skip:no_url'
|
||||||
@@ -105,7 +154,8 @@ def download_one(post, site_name, adapter, pictures_dir, session):
|
|||||||
with open(dest, 'wb') as f:
|
with open(dest, 'wb') as f:
|
||||||
f.write(r.content)
|
f.write(r.content)
|
||||||
post_url = adapter['post_url_fmt'].format(post_id=post_id)
|
post_url = adapter['post_url_fmt'].format(post_id=post_id)
|
||||||
insert_image(post_id, site_name, filename, tags, file_url, post_url)
|
preview_filename = make_preview(dest, previews_dir)
|
||||||
|
insert_image(post_id, site_name, filename, tags, file_url, post_url, preview_filename)
|
||||||
return f'ok:{filename}'
|
return f'ok:{filename}'
|
||||||
|
|
||||||
|
|
||||||
@@ -137,8 +187,11 @@ def main():
|
|||||||
if api_key and user_id:
|
if api_key and user_id:
|
||||||
auth = {'api_key': api_key, 'user_id': user_id}
|
auth = {'api_key': api_key, 'user_id': user_id}
|
||||||
|
|
||||||
pictures_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'Pictures')
|
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
pictures_dir = os.path.join(base_dir, 'Pictures')
|
||||||
|
previews_dir = os.path.join(base_dir, 'Previews')
|
||||||
os.makedirs(pictures_dir, exist_ok=True)
|
os.makedirs(pictures_dir, exist_ok=True)
|
||||||
|
os.makedirs(previews_dir, exist_ok=True)
|
||||||
|
|
||||||
piped = not sys.stdout.isatty()
|
piped = not sys.stdout.isatty()
|
||||||
|
|
||||||
@@ -175,7 +228,7 @@ def main():
|
|||||||
done = 0
|
done = 0
|
||||||
with ThreadPoolExecutor(max_workers=adapter['threads']) as pool:
|
with ThreadPoolExecutor(max_workers=adapter['threads']) as pool:
|
||||||
futures = {
|
futures = {
|
||||||
pool.submit(download_one, p, args.site, adapter, pictures_dir, session): p
|
pool.submit(download_one, p, args.site, adapter, pictures_dir, previews_dir, session): p
|
||||||
for p in posts
|
for p in posts
|
||||||
}
|
}
|
||||||
for _ in tqdm(as_completed(futures), total=total, file=sys.stderr):
|
for _ in tqdm(as_completed(futures), total=total, file=sys.stderr):
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ flask>=3.0
|
|||||||
requests>=2.32
|
requests>=2.32
|
||||||
tqdm>=4.66
|
tqdm>=4.66
|
||||||
python-dotenv>=1.0
|
python-dotenv>=1.0
|
||||||
|
Pillow>=10.0
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
|
|
||||||
#controls{margin-top:2vh}
|
#controls{margin-top:2vh}
|
||||||
button{padding:.6rem 1.1rem;font-size:1rem;margin:.2rem;border:0;border-radius:.4rem;cursor:pointer}
|
button{padding:.6rem 1.1rem;font-size:1rem;margin:.2rem;border:0;border-radius:.4rem;cursor:pointer}
|
||||||
|
#quality-toggle{background:#446;color:#ccf}
|
||||||
|
#quality-toggle.full{background:#464;color:#cfc}
|
||||||
|
|
||||||
#counter{margin-top:1vh;font-size:1.1rem}
|
#counter{margin-top:1vh;font-size:1.1rem}
|
||||||
|
|
||||||
@@ -84,7 +86,7 @@
|
|||||||
<div id="image-area">
|
<div id="image-area">
|
||||||
<div id="file-size"></div>
|
<div id="file-size"></div>
|
||||||
<div id="fs-wrap">
|
<div id="fs-wrap">
|
||||||
<img id="slide" src="{{ images[0] }}" />
|
<img id="slide" src="{{ preview_images[0] }}" />
|
||||||
<div id="tap-prev"></div>
|
<div id="tap-prev"></div>
|
||||||
<div id="tap-next"></div>
|
<div id="tap-next"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,6 +96,7 @@
|
|||||||
<button id="next">Next ▶︎</button>
|
<button id="next">Next ▶︎</button>
|
||||||
<button id="fullscreen">⛶ Fullscreen</button>
|
<button id="fullscreen">⛶ Fullscreen</button>
|
||||||
<button id="shuffle">Shuffle</button>
|
<button id="shuffle">Shuffle</button>
|
||||||
|
<button id="quality-toggle">Full Size</button>
|
||||||
<input id="delay" type="number" value="15" style="width:4rem" />
|
<input id="delay" type="number" value="15" style="width:4rem" />
|
||||||
<label for="delay">sec</label>
|
<label for="delay">sec</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,10 +153,18 @@
|
|||||||
|
|
||||||
{% if images %}
|
{% if images %}
|
||||||
<script>
|
<script>
|
||||||
const images = {{ images|tojson }};
|
const images = {{ images|tojson }};
|
||||||
const post_urls = {{ post_urls|tojson }};
|
const previews = {{ preview_images|tojson }};
|
||||||
const tags_list = {{ tags_list|tojson }};
|
const post_urls = {{ post_urls|tojson }};
|
||||||
const file_sizes = {{ file_sizes|tojson }};
|
const tags_list = {{ tags_list|tojson }};
|
||||||
|
const file_sizes = {{ file_sizes|tojson }};
|
||||||
|
const preview_sizes = {{ preview_sizes|tojson }};
|
||||||
|
|
||||||
|
let use_full = false;
|
||||||
|
|
||||||
|
function cur_src(n) { return use_full ? images[n] : previews[n]; }
|
||||||
|
function cur_size(n) { return use_full ? file_sizes[n] : preview_sizes[n]; }
|
||||||
|
|
||||||
function fmt_size(b){ return b >= 1048576 ? (b/1048576).toFixed(1)+' MB' : Math.round(b/1024)+' KB'; }
|
function fmt_size(b){ return b >= 1048576 ? (b/1048576).toFixed(1)+' MB' : Math.round(b/1024)+' KB'; }
|
||||||
const sidebar = document.getElementById('tag-sidebar');
|
const sidebar = document.getElementById('tag-sidebar');
|
||||||
|
|
||||||
@@ -180,12 +191,13 @@
|
|||||||
`</div>`;
|
`</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
const img = document.getElementById('slide');
|
const img = document.getElementById('slide');
|
||||||
const fs_wrap = document.getElementById('fs-wrap');
|
const fs_wrap = document.getElementById('fs-wrap');
|
||||||
const btnT = document.getElementById('toggle');
|
const btnT = document.getElementById('toggle');
|
||||||
const btnF = document.getElementById('fullscreen');
|
const btnF = document.getElementById('fullscreen');
|
||||||
const counter = document.getElementById('counter');
|
const btnQ = document.getElementById('quality-toggle');
|
||||||
const file_size_el = document.getElementById('file-size');
|
const counter = document.getElementById('counter');
|
||||||
|
const file_size_el = document.getElementById('file-size');
|
||||||
|
|
||||||
const _params = new URLSearchParams(window.location.search);
|
const _params = new URLSearchParams(window.location.search);
|
||||||
const _url_idx = parseInt(_params.get('idx') || '0', 10);
|
const _url_idx = parseInt(_params.get('idx') || '0', 10);
|
||||||
@@ -256,7 +268,7 @@
|
|||||||
const idx = shuffled
|
const idx = shuffled
|
||||||
? order[(i + k) % order.length]
|
? order[(i + k) % order.length]
|
||||||
: (i + k) % images.length;
|
: (i + k) % images.length;
|
||||||
new Image().src = images[idx];
|
new Image().src = cur_src(idx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,9 +277,9 @@
|
|||||||
else n = (n+images.length)%images.length;
|
else n = (n+images.length)%images.length;
|
||||||
|
|
||||||
i = n;
|
i = n;
|
||||||
img.src = images[n];
|
img.src = cur_src(n);
|
||||||
counter.textContent = `${i+1} / ${images.length}`;
|
counter.textContent = `${i+1} / ${images.length}`;
|
||||||
file_size_el.textContent = fmt_size(file_sizes[n]);
|
file_size_el.textContent = fmt_size(cur_size(n));
|
||||||
render_tags(n);
|
render_tags(n);
|
||||||
reset_zoom();
|
reset_zoom();
|
||||||
preload_next();
|
preload_next();
|
||||||
@@ -277,12 +289,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// init to url index
|
// init to url index
|
||||||
img.src = images[i];
|
img.src = cur_src(i);
|
||||||
counter.textContent = `${i+1} / ${images.length}`;
|
counter.textContent = `${i+1} / ${images.length}`;
|
||||||
file_size_el.textContent = fmt_size(file_sizes[i]);
|
file_size_el.textContent = fmt_size(cur_size(i));
|
||||||
render_tags(i);
|
render_tags(i);
|
||||||
preload_next();
|
preload_next();
|
||||||
|
|
||||||
|
btnQ.onclick = () => {
|
||||||
|
use_full = !use_full;
|
||||||
|
btnQ.textContent = use_full ? 'Preview' : 'Full Size';
|
||||||
|
btnQ.className = use_full ? 'full' : '';
|
||||||
|
img.src = cur_src(i);
|
||||||
|
file_size_el.textContent = fmt_size(cur_size(i));
|
||||||
|
};
|
||||||
|
|
||||||
function toggleShuffle(){
|
function toggleShuffle(){
|
||||||
shuffled = !shuffled;
|
shuffled = !shuffled;
|
||||||
document.getElementById('shuffle').textContent = shuffled ? 'Unshuffle' : 'Shuffle';
|
document.getElementById('shuffle').textContent = shuffled ? 'Unshuffle' : 'Shuffle';
|
||||||
|
|||||||
Reference in New Issue
Block a user