個人的にあると便利なので作ってみました。指定フォルダを監視して自動リサイズするだけのツールです。
Web-UIで設定を変更出来ます。

LXDコンテナにインストール
LXDコンテナにインストールします。Tailscale ServeでHTTPS化しています。
インストール時に監視フォルダや画像サイズを指定しますが、これは起動後にWeb-UIで変更可能です。
ただLXDコンテナ内なのでHTTPでも十分だと思います。
#!/usr/bin/env bash
# =============================================================================
# Image Resize Service — インストールスクリプト
# - 特定フォルダを監視し、画像(jpg/jpeg/png)をリサイズして指定フォルダに保存
# - Web-UIで全設定を行う(インストール時の対話入力なし)
# - 「保存して適用」ボタンを押した時点で初めて監視開始
# - 出力先フォルダは自由に指定可能(デフォルト: /opt/lxd-data/images-resize)
# - 既にリサイズ済みのファイルはスキップ(重複生成防止)
# =============================================================================
set -euo pipefail
INSTALL_DIR="/opt/image-resize"
SERVICE_NAME="image-resize"
PORT=3324
info() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
ok() { echo -e "\033[1;32m[ OK ]\033[0m $*"; }
warn() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
die() { echo -e "\033[1;31m[ERR ]\033[0m $*" >&2; exit 1; }
# ── 前提チェック ───────────────────────────────────────────────────────────────
info "前提確認..."
command -v python3 >/dev/null 2>&1 || die "python3 が見つかりません"
TS_AVAILABLE=false
if command -v tailscale >/dev/null 2>&1 && tailscale status >/dev/null 2>&1; then
TS_AVAILABLE=true
TS_HOSTNAME=$(tailscale status --json \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d['Self']['DNSName'].rstrip('.'))" \
2>/dev/null) || TS_AVAILABLE=false
fi
ok "前提 OK"
# ── ディレクトリ作成 ──────────────────────────────────────────────────────────
info "ディレクトリ作成..."
mkdir -p "${INSTALL_DIR}"
# ── venv 作成 & 依存パッケージインストール ────────────────────────────────────
info "Python venv を作成..."
if [[ -d "${INSTALL_DIR}/venv" ]]; then
if ! "${INSTALL_DIR}/venv/bin/python" -c "import sys; print(sys.version)" >/dev/null 2>&1; then
warn "既存のvenvが壊れています。削除して再作成します..."
rm -rf "${INSTALL_DIR}/venv"
fi
fi
VENV_OK=false
if [[ ! -d "${INSTALL_DIR}/venv" ]]; then
TMPDIR_VENV=$(mktemp -d)
if python3 -m venv "${TMPDIR_VENV}/test" 2>/dev/null && "${TMPDIR_VENV}/test/bin/python" -c "import sys; print(sys.version)" >/dev/null 2>&1; then
VENV_OK=true
rm -rf "${TMPDIR_VENV}"
else
rm -rf "${TMPDIR_VENV}"
fi
fi
if [[ ! -d "${INSTALL_DIR}/venv" ]]; then
if [[ "${VENV_OK}" == "false" ]]; then
warn "venv が利用できません。python3-venv をインストールします..."
PYVER=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null)
SUDO_CMD=""
if [[ $EUID -ne 0 ]]; then
SUDO_CMD="sudo"
fi
${SUDO_CMD} apt-get update -qq 2>/dev/null
${SUDO_CMD} apt-get install -y -qq "python3.${PYVER}-venv" 2>/dev/null || \
${SUDO_CMD} apt-get install -y -qq python3-venv 2>/dev/null || \
die "python3-venv のインストールに失敗しました。手動でインストールしてください: sudo apt install python3-venv"
TMPDIR_VENV=$(mktemp -d)
if python3 -m venv "${TMPDIR_VENV}/test" 2>/dev/null && "${TMPDIR_VENV}/test/bin/python" -c "import sys; print(sys.version)" >/dev/null 2>&1; then
VENV_OK=true
fi
rm -rf "${TMPDIR_VENV}"
fi
if [[ "${VENV_OK}" == "true" ]]; then
python3 -m venv "${INSTALL_DIR}/venv" || die "venv の作成に失敗しました"
else
die "venv の作成に失敗しました。手動でインストールしてください: sudo apt install python3-venv"
fi
fi
if ! "${INSTALL_DIR}/venv/bin/python" -c "import sys; print(sys.version)" >/dev/null 2>&1; then
die "venv の作成に失敗しました。手動でインストールしてください: sudo apt install python3-venv"
fi
ok "venv 作成完了 ($("${INSTALL_DIR}/venv/bin/python" -c "import sys; print(sys.version)"))"
info "依存パッケージをインストール..."
"${INSTALL_DIR}/venv/bin/pip" install --quiet --upgrade Pillow watchdog flask
ok "依存パッケージインストール完了"
# ── アプリケーションスクリプト生成 ────────────────────────────────────────────
info "アプリケーションを生成..."
cat > "${INSTALL_DIR}/app.py" << 'PYEOF'
#!/usr/bin/env python3
"""Image Resize Service — Watch a folder and resize images to an output folder."""
import json
import os
import sys
import time
import threading
from pathlib import Path
from flask import Flask, render_template_string, request, jsonify
from PIL import Image
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
DEFAULT_CONFIG = {
"watch_folder": "/opt/lxd-data/images",
"output_folder": "/opt/lxd-data/images-resize",
"resize_width": 1024,
"resize_height": 1024,
"keep_aspect_ratio": True,
"output_suffix": "",
"extensions": [".jpg", ".jpeg", ".png"],
"enabled": False,
"port": 3324,
}
app = Flask(__name__)
config = {}
observer = None
def load_config():
global config
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, "r") as f:
config = json.load(f)
else:
config = DEFAULT_CONFIG.copy()
save_config()
def save_config():
with open(CONFIG_FILE, "w") as f:
json.dump(config, f, indent=2, ensure_ascii=False)
def resize_image(src_path, dst_path, width, height, keep_aspect):
try:
img = Image.open(src_path)
if keep_aspect:
img.thumbnail((width, height), Image.LANCZOS)
else:
img = img.resize((width, height), Image.LANCZOS)
os.makedirs(os.path.dirname(dst_path), exist_ok=True)
img.save(dst_path, quality=90)
return True
except Exception as e:
print(f"[ERROR] Failed to resize {src_path}: {e}", file=sys.stderr)
return False
def get_output_path(src_path):
p = Path(src_path)
output_dir = config.get("output_folder", "").strip()
if output_dir:
out_dir = Path(output_dir)
else:
out_dir = p.parent / "resize"
suffix = config.get("output_suffix", "")
stem = p.stem + suffix
ext = p.suffix.lower()
return str(out_dir / (stem + ext))
class ImageHandler(FileSystemEventHandler):
def __init__(self):
self._pending = {} # path -> 最後にイベントを受けた時刻
self._lock = threading.Lock()
def on_created(self, event):
if event.is_directory:
return
self._handle(event.src_path)
def on_modified(self, event):
if event.is_directory:
return
self._handle(event.src_path)
def on_moved(self, event):
if event.is_directory:
return
self._handle(event.dest_path)
def _handle(self, path):
ext = Path(path).suffix.lower()
if ext not in config.get("extensions", [".jpg", ".jpeg", ".png"]):
# .uploading 等の一時ファイルはここで弾かれる
return
# 出力フォルダ内のファイルは無視
output_dir = config.get("output_folder", "").strip()
if output_dir:
try:
Path(path).resolve().relative_to(Path(output_dir).resolve())
return
except ValueError:
pass
else:
if "resize" in Path(path).parts:
return
with self._lock:
already_pending = path in self._pending
self._pending[path] = time.time() # タイムスタンプを常に更新
if already_pending:
# スレッドは既に動いているのでタイムスタンプ更新だけして終わり
return
# 新規ペンディング → スレッド起動
threading.Thread(target=self._delayed_process, args=(path,), daemon=True).start()
def _delayed_process(self, path):
# イベントが落ち着くまで待つ(最後のイベントから1秒間隔がなければ待ち続ける)
while True:
with self._lock:
last = self._pending.get(path, 0)
if time.time() - last >= 1.0:
break
time.sleep(0.2)
with self._lock:
self._pending.pop(path, None)
if not os.path.exists(path):
return
dst = get_output_path(path)
# 既にリサイズ済みの場合はスキップ
if os.path.exists(dst):
print(f"[SKIP] Already resized: {os.path.basename(path)} -> {dst}", flush=True)
return
ok = resize_image(
path, dst,
config.get("resize_width", 1024),
config.get("resize_height", 1024),
config.get("keep_aspect_ratio", True),
)
if ok:
print(f"[OK] Resized: {os.path.basename(path)} -> {dst}", flush=True)
def start_watching():
global observer
if observer and observer.is_alive():
observer.stop()
observer.join(timeout=5)
observer = None
if not config.get("enabled", False):
print("[INFO] Watching disabled", flush=True)
return
watch_dir = config.get("watch_folder", "")
if not watch_dir or not os.path.isdir(watch_dir):
print(f"[WARN] Watch folder not found: {watch_dir}", flush=True)
return
observer = Observer()
observer.schedule(ImageHandler(), watch_dir, recursive=False)
observer.start()
print(f"[INFO] Watching: {watch_dir}", flush=True)
HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Resize Service</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f0f2f5; color: #333; }
.header { background: #1a73e8; color: white; padding: 16px 24px; display: flex; align-items: center; gap: 12px; }
.header h1 { font-size: 20px; }
.status { margin-left: auto; padding: 4px 12px; border-radius: 12px; font-size: 13px; font-weight: 600; }
.status.on { background: #34a853; }
.status.off { background: #ea4335; }
.container { max-width: 720px; margin: 24px auto; padding: 0 16px; }
.card { background: white; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.12); padding: 24px; margin-bottom: 16px; }
.card h2 { font-size: 16px; margin-bottom: 16px; color: #555; }
.field { margin-bottom: 16px; }
.field label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 4px; color: #666; }
.field input[type="text"], .field input[type="number"] {
width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 15px;
}
.field input:focus { outline: none; border-color: #1a73e8; }
.field .hint { font-size: 12px; color: #999; margin-top: 4px; }
.row { display: flex; gap: 16px; }
.row .field { flex: 1; }
.toggle { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.toggle input[type="checkbox"] { width: 20px; height: 20px; }
.btn { display: inline-block; padding: 10px 24px; border: none; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; margin-right: 8px; }
.btn-primary { background: #1a73e8; color: white; }
.btn-primary:hover { background: #1557b0; }
.btn-secondary { background: #e8f0fe; color: #1a73e8; }
.btn-secondary:hover { background: #d2e3fc; }
.log { background: #1e1e1e; color: #d4d4d4; border-radius: 8px; padding: 12px; font-family: 'Courier New', monospace; font-size: 13px; max-height: 300px; overflow-y: auto; white-space: pre-wrap; }
.log .ok { color: #4ec9b0; }
.log .err { color: #f44747; }
.log .info { color: #569cd6; }
.notice { background: #fff8e1; border-left: 4px solid #f9a825; padding: 12px 16px; border-radius: 0 8px 8px 0; margin-bottom: 16px; font-size: 14px; color: #555; }
</style>
</head>
<body>
<div class="header">
<h1>Image Resize Service</h1>
<span class="status {{ 'on' if config.enabled else 'off' }}" id="statusBadge">
{{ '監視中' if config.enabled else '停止中' }}
</span>
</div>
<div class="container">
{% if not config.enabled %}
<div class="notice">
⚙️ まだ監視は開始されていません。設定を確認して「保存して適用」を押してください。
</div>
{% endif %}
<div class="card">
<h2>設定</h2>
<div class="field">
<label>監視フォルダ</label>
<input type="text" id="watchFolder" value="{{ config.watch_folder }}">
<div class="hint">存在しない場合は自動で作成します</div>
</div>
<div class="field">
<label>出力フォルダ</label>
<input type="text" id="outputFolder" value="{{ config.output_folder }}">
<div class="hint">存在しない場合は自動で作成します(空欄の場合は監視フォルダ内の resize/ サブフォルダに保存)</div>
</div>
<div class="row">
<div class="field">
<label>リサイズ幅 (px)</label>
<input type="number" id="resizeWidth" value="{{ config.resize_width }}" min="1">
</div>
<div class="field">
<label>リサイズ高さ (px)</label>
<input type="number" id="resizeHeight" value="{{ config.resize_height }}" min="1">
</div>
</div>
<div class="toggle">
<input type="checkbox" id="keepAspect" {{ 'checked' if config.keep_aspect_ratio }}>
<label for="keepAspect">アスペクト比を保持</label>
</div>
<div class="field">
<label>出力ファイル名サフィックス</label>
<input type="text" id="outputSuffix" value="{{ config.output_suffix }}" placeholder="例: _resized(空欄で元ファイル名と同じ)">
</div>
<div class="toggle">
<input type="checkbox" id="enabled" {{ 'checked' if config.enabled }}>
<label for="enabled">監視を有効にする</label>
</div>
<button class="btn btn-primary" onclick="saveConfig()">保存して適用</button>
</div>
<div class="card">
<h2>フォルダ内容</h2>
<div id="folderContents" class="log">読み込み中...</div>
<div style="margin-top:12px">
<button class="btn btn-secondary" onclick="refreshFolder()">更新</button>
<button class="btn btn-secondary" onclick="addTestImage()">テスト画像を追加</button>
</div>
</div>
</div>
<script>
function refreshFolder() {
fetch('/api/folder').then(r => r.json()).then(data => {
const el = document.getElementById('folderContents');
el.innerHTML = '';
if (data.error) {
const line = document.createElement('span');
line.className = 'err';
line.textContent = data.error + '\\n';
el.appendChild(line);
return;
}
if (data.items.length === 0) { el.textContent = '(空)'; return; }
data.items.forEach(item => {
const line = document.createElement('span');
line.className = item.type === 'output' ? 'ok' : 'info';
line.textContent = '[' + item.type + '] ' + item.name + ' (' + item.size + ')\\n';
el.appendChild(line);
});
});
}
function saveConfig() {
const watchFolder = document.getElementById('watchFolder').value.trim();
const outputFolder = document.getElementById('outputFolder').value.trim();
const cfg = {
watch_folder: watchFolder,
output_folder: outputFolder,
resize_width: parseInt(document.getElementById('resizeWidth').value),
resize_height: parseInt(document.getElementById('resizeHeight').value),
keep_aspect_ratio: document.getElementById('keepAspect').checked,
output_suffix: document.getElementById('outputSuffix').value,
enabled: document.getElementById('enabled').checked,
};
// 監視フォルダの存在確認
fetch('/api/check-folder', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path: watchFolder}),
}).then(r => r.json()).then(data => {
if (!data.exists) {
if (!confirm('監視フォルダ「' + watchFolder + '」が存在しません。\\n作成しますか?')) return Promise.resolve(null);
cfg.create_watch = true;
}
// 出力フォルダの存在確認(空欄でなければ)
if (outputFolder) {
return fetch('/api/check-folder', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path: outputFolder}),
}).then(r => r.json()).then(od => {
if (!od.exists) {
if (!confirm('出力フォルダ「' + outputFolder + '」が存在しません。\\n作成しますか?')) return null;
cfg.create_output = true;
}
return cfg;
});
}
return cfg;
}).then(cfg => {
if (!cfg) return;
return fetch('/api/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(cfg),
});
}).then(r => r && r.json()).then(result => {
if (!result) return;
document.getElementById('statusBadge').className = 'status ' + (cfg.enabled ? 'on' : 'off');
document.getElementById('statusBadge').textContent = cfg.enabled ? '監視中' : '停止中';
// notice バナーを非表示に
const notice = document.querySelector('.notice');
if (notice) notice.style.display = 'none';
refreshFolder();
});
}
function addTestImage() {
fetch('/api/test-image', {method: 'POST'}).then(r => r.json()).then(data => {
if (data.error) { alert(data.error); return; }
setTimeout(refreshFolder, 1000);
});
}
refreshFolder();
</script>
</body>
</html>"""
@app.route("/")
def index():
return render_template_string(HTML_TEMPLATE, config=config)
@app.route("/api/config", methods=["GET", "POST"])
def api_config():
global config
if request.method == "POST":
data = request.json
# 監視フォルダ作成
if data.pop("create_watch", False):
watch_dir = data.get("watch_folder", "")
if watch_dir:
os.makedirs(watch_dir, exist_ok=True)
print(f"[INFO] Created watch folder: {watch_dir}", flush=True)
# 出力フォルダ作成
if data.pop("create_output", False):
out_dir = data.get("output_folder", "")
if out_dir:
os.makedirs(out_dir, exist_ok=True)
print(f"[INFO] Created output folder: {out_dir}", flush=True)
config.update(data)
save_config()
start_watching()
return jsonify({"ok": True})
return jsonify(config)
@app.route("/api/check-folder", methods=["POST"])
def api_check_folder():
path = request.json.get("path", "")
exists = os.path.isdir(path) if path else False
return jsonify({"exists": exists, "path": path})
@app.route("/api/folder")
def api_folder():
items = []
watch = config.get("watch_folder", "").strip()
output = config.get("output_folder", "").strip()
if not watch:
return jsonify({"items": [], "error": "監視フォルダが未設定です"})
if not os.path.isdir(watch):
return jsonify({"items": [], "error": f"監視フォルダが見つかりません: {watch}"})
exts = config.get("extensions", [".jpg", ".jpeg", ".png"])
# 監視フォルダ内の画像
for f in sorted(os.listdir(watch)):
fp = os.path.join(watch, f)
if os.path.isfile(fp) and Path(f).suffix.lower() in exts:
sz = os.path.getsize(fp)
items.append({"name": f, "size": f"{sz//1024}KB", "type": "image"})
# 出力フォルダ内の画像
if output and os.path.isdir(output):
for f in sorted(os.listdir(output)):
fp = os.path.join(output, f)
if os.path.isfile(fp) and Path(f).suffix.lower() in exts:
sz = os.path.getsize(fp)
items.append({"name": f"[出力] {f}", "size": f"{sz//1024}KB", "type": "output"})
elif not output:
# サブフォルダ方式
resize_dir = os.path.join(watch, "resize")
if os.path.isdir(resize_dir):
for f in sorted(os.listdir(resize_dir)):
fp = os.path.join(resize_dir, f)
if os.path.isfile(fp):
sz = os.path.getsize(fp)
items.append({"name": f"resize/{f}", "size": f"{sz//1024}KB", "type": "output"})
return jsonify({"items": items})
@app.route("/api/test-image", methods=["POST"])
def api_test_image():
import random
watch = config.get("watch_folder", "").strip()
if not watch:
return jsonify({"ok": False, "error": "監視フォルダが未設定です"})
os.makedirs(watch, exist_ok=True)
w, h = random.randint(1600, 4000), random.randint(1200, 3000)
img = Image.new("RGB", (w, h), (
random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)
))
from PIL import ImageDraw
draw = ImageDraw.Draw(img)
for _ in range(20):
x1, y1 = random.randint(0, w), random.randint(0, h)
x2, y2 = x1 + random.randint(50, 300), y1 + random.randint(50, 300)
draw.rectangle([x1, y1, x2, y2], fill=(
random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)
))
ts = int(time.time() * 1000)
path = os.path.join(watch, f"test_{ts}.jpg")
img.save(path, quality=95)
return jsonify({"ok": True, "path": path})
def main():
load_config()
# 起動時は enabled フラグに従う(デフォルト false なので監視しない)
start_watching()
port = config.get("port", 3324)
print(f"[INFO] Web UI: http://127.0.0.1:{port}", flush=True)
app.run(host="0.0.0.0", port=port, debug=False)
if __name__ == "__main__":
main()
PYEOF
ok "アプリケーションスクリプト生成完了"
# ── config.json 生成(初期値・監視無効) ─────────────────────────────────────
cat > "${INSTALL_DIR}/config.json" << 'CFGEOF'
{
"watch_folder": "/opt/lxd-data/images",
"output_folder": "/opt/lxd-data/images-resize",
"resize_width": 1024,
"resize_height": 1024,
"keep_aspect_ratio": true,
"output_suffix": "",
"extensions": [".jpg", ".jpeg", ".png"],
"enabled": false,
"port": 3324
}
CFGEOF
ok "config.json 生成完了"
# ── systemd ユニットファイル生成 ───────────────────────────────────────────────
info "systemd ユニットファイルを生成..."
cat > /etc/systemd/system/${SERVICE_NAME}.service << EOF
[Unit]
Description=Image Resize Service
After=network.target
[Service]
Type=simple
ExecStart=${INSTALL_DIR}/venv/bin/python ${INSTALL_DIR}/app.py
WorkingDirectory=${INSTALL_DIR}
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
ok "systemd ユニットファイル生成完了"
# ── サービス起動 ──────────────────────────────────────────────────────────────
info "サービスを起動..."
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}"
systemctl restart "${SERVICE_NAME}"
for i in $(seq 1 10); do
if curl -s --max-time 1 "http://127.0.0.1:${PORT}/" >/dev/null 2>&1; then
break
fi
sleep 1
done
if curl -s --max-time 1 "http://127.0.0.1:${PORT}/" >/dev/null 2>&1; then
ok "サービス起動完了"
else
die "サービスの起動に失敗しました。journalctl -u ${SERVICE_NAME} -n 20 で確認してください"
fi
# ── Tailscale Serve 設定 ──────────────────────────────────────────────────────
if [[ "${TS_AVAILABLE}" == "true" ]]; then
info "Tailscale Serve にポート ${PORT} を追加..."
tailscale serve --https="${PORT}" off 2>/dev/null || true
tailscale serve --bg --https="${PORT}" "http://127.0.0.1:${PORT}"
ok "Tailscale Serve 設定追加完了"
else
warn "Tailscale が利用できないため、Tailscale Serve 設定をスキップします。"
warn "Web-UI は http://127.0.0.1:${PORT} でアクセスできます。"
fi
# ── 完了サマリー ──────────────────────────────────────────────────────────────
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
ok "セットアップ完了!"
echo ""
if [[ "${TS_AVAILABLE}" == "true" ]]; then
echo " Web UI : https://${TS_HOSTNAME}:${PORT}"
else
echo " Web UI : http://127.0.0.1:${PORT}"
fi
echo " 設定ファイル: ${INSTALL_DIR}/config.json"
echo ""
echo " ▶ Web UI にアクセスして設定を確認し、「保存して適用」で監視を開始してください。"
echo ""
echo " 管理コマンド:"
echo " systemctl status ${SERVICE_NAME}"
echo " systemctl restart ${SERVICE_NAME}"
echo " journalctl -u ${SERVICE_NAME} -f"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
指定フォルダに保存した画像が自動リサイズ
監視フォルダに画像を入れれば設定したサイズに自動でリサイズされます。
これはnextExplorerで確認したところ。これでアップロードした画像もリサイズされます。

アンインストール
#!/usr/bin/env bash
# =============================================================================
# Image Resize Service — アンインストールスクリプト
# - systemdサービスの停止・無効化・ユニットファイル削除
# - Tailscale Serveの設定解除(該当ポートのみ)
# - インストールディレクトリ(/opt/image-resize)の削除
# - 監視フォルダ・出力フォルダ(画像データ)はデフォルトでは削除しない
# =============================================================================
set -euo pipefail
INSTALL_DIR="/opt/image-resize"
SERVICE_NAME="image-resize"
CONFIG_FILE="${INSTALL_DIR}/config.json"
PORT=3324
info() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
ok() { echo -e "\033[1;32m[ OK ]\033[0m $*"; }
warn() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
die() { echo -e "\033[1;31m[ERR ]\033[0m $*" >&2; exit 1; }
SUDO_CMD=""
if [[ $EUID -ne 0 ]]; then
SUDO_CMD="sudo"
fi
# ── 設定ファイルから実際のポート番号・フォルダを取得(存在すれば) ───────────
if [[ -f "${CONFIG_FILE}" ]] && command -v python3 >/dev/null 2>&1; then
REAL_PORT=$(python3 -c "import json; print(json.load(open('${CONFIG_FILE}')).get('port', ${PORT}))" 2>/dev/null) || REAL_PORT="${PORT}"
PORT="${REAL_PORT}"
WATCH_FOLDER=$(python3 -c "import json; print(json.load(open('${CONFIG_FILE}')).get('watch_folder', ''))" 2>/dev/null) || WATCH_FOLDER=""
OUTPUT_FOLDER=$(python3 -c "import json; print(json.load(open('${CONFIG_FILE}')).get('output_folder', ''))" 2>/dev/null) || OUTPUT_FOLDER=""
fi
# ── サービス停止・無効化 ──────────────────────────────────────────────────────
info "サービスを停止・無効化..."
if systemctl list-unit-files | grep -q "^${SERVICE_NAME}.service"; then
${SUDO_CMD} systemctl stop "${SERVICE_NAME}" 2>/dev/null || true
${SUDO_CMD} systemctl disable "${SERVICE_NAME}" 2>/dev/null || true
ok "サービス停止・無効化完了"
else
warn "サービスユニットが見つかりません(既に削除済みの可能性)"
fi
# ── systemd ユニットファイル削除 ──────────────────────────────────────────────
if [[ -f "/etc/systemd/system/${SERVICE_NAME}.service" ]]; then
info "systemd ユニットファイルを削除..."
${SUDO_CMD} rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
${SUDO_CMD} systemctl daemon-reload
ok "systemd ユニットファイル削除完了"
fi
# ── Tailscale Serve 設定解除 ──────────────────────────────────────────────────
if command -v tailscale >/dev/null 2>&1 && tailscale status >/dev/null 2>&1; then
info "Tailscale Serve のポート ${PORT} 設定を解除..."
tailscale serve --https="${PORT}" off 2>/dev/null || true
ok "Tailscale Serve 設定解除完了(または元々未設定)"
else
warn "Tailscale が利用できないため、Serve 設定解除をスキップします"
fi
# ── インストールディレクトリ削除 ──────────────────────────────────────────────
if [[ -d "${INSTALL_DIR}" ]]; then
info "インストールディレクトリを削除... (${INSTALL_DIR})"
${SUDO_CMD} rm -rf "${INSTALL_DIR}"
ok "インストールディレクトリ削除完了"
else
warn "インストールディレクトリが見つかりません(既に削除済みの可能性)"
fi
# ── 完了サマリー ──────────────────────────────────────────────────────────────
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
ok "アンインストール完了!"
echo ""
echo " 以下のデータは削除されていません(必要であれば手動で削除してください):"
if [[ -n "${WATCH_FOLDER:-}" ]]; then
echo " 監視フォルダ: ${WATCH_FOLDER}"
fi
if [[ -n "${OUTPUT_FOLDER:-}" ]]; then
echo " 出力フォルダ: ${OUTPUT_FOLDER}"
fi
echo ""
echo " 上記フォルダも削除する場合は次を実行してください:"
if [[ -n "${WATCH_FOLDER:-}" ]]; then
echo " rm -rf '${WATCH_FOLDER}'"
fi
if [[ -n "${OUTPUT_FOLDER:-}" ]]; then
echo " rm -rf '${OUTPUT_FOLDER}'"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
Tailscale Serveを使わずシンプルにHTTPで利用
#!/usr/bin/env bash
# =============================================================================
# Image Resize Service — インストールスクリプト
# - 特定フォルダを監視し、画像(jpg/jpeg/png)をリサイズして指定フォルダに保存
# - Web-UIで全設定を行う(インストール時の対話入力なし)
# - 「保存して適用」ボタンを押した時点で初めて監視開始
# - 出力先フォルダは自由に指定可能(デフォルト: /opt/lxd-data/images-resize)
# - 既にリサイズ済みのファイルはスキップ(重複生成防止)
# =============================================================================
set -euo pipefail
INSTALL_DIR="/opt/image-resize"
SERVICE_NAME="image-resize"
PORT=3324
info() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
ok() { echo -e "\033[1;32m[ OK ]\033[0m $*"; }
warn() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
die() { echo -e "\033[1;31m[ERR ]\033[0m $*" >&2; exit 1; }
# ── 前提チェック ───────────────────────────────────────────────────────────────
info "前提確認..."
command -v python3 >/dev/null 2>&1 || die "python3 が見つかりません"
TS_AVAILABLE=false
if command -v tailscale >/dev/null 2>&1 && tailscale status >/dev/null 2>&1; then
TS_AVAILABLE=true
TS_HOSTNAME=$(tailscale status --json \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d['Self']['DNSName'].rstrip('.'))" \
2>/dev/null) || TS_AVAILABLE=false
TS_IP=$(tailscale ip -4 2>/dev/null | head -1) || true
fi
ok "前提 OK"
# ── ディレクトリ作成 ──────────────────────────────────────────────────────────
info "ディレクトリ作成..."
mkdir -p "${INSTALL_DIR}"
# ── venv 作成 & 依存パッケージインストール ────────────────────────────────────
info "Python venv を作成..."
if [[ -d "${INSTALL_DIR}/venv" ]]; then
if ! "${INSTALL_DIR}/venv/bin/python" -c "import sys; print(sys.version)" >/dev/null 2>&1; then
warn "既存のvenvが壊れています。削除して再作成します..."
rm -rf "${INSTALL_DIR}/venv"
fi
fi
VENV_OK=false
if [[ ! -d "${INSTALL_DIR}/venv" ]]; then
TMPDIR_VENV=$(mktemp -d)
if python3 -m venv "${TMPDIR_VENV}/test" 2>/dev/null && "${TMPDIR_VENV}/test/bin/python" -c "import sys; print(sys.version)" >/dev/null 2>&1; then
VENV_OK=true
rm -rf "${TMPDIR_VENV}"
else
rm -rf "${TMPDIR_VENV}"
fi
fi
if [[ ! -d "${INSTALL_DIR}/venv" ]]; then
if [[ "${VENV_OK}" == "false" ]]; then
warn "venv が利用できません。python3-venv をインストールします..."
PYVER=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null)
SUDO_CMD=""
if [[ $EUID -ne 0 ]]; then
SUDO_CMD="sudo"
fi
${SUDO_CMD} apt-get update -qq 2>/dev/null
${SUDO_CMD} apt-get install -y -qq "python3.${PYVER}-venv" 2>/dev/null || \
${SUDO_CMD} apt-get install -y -qq python3-venv 2>/dev/null || \
die "python3-venv のインストールに失敗しました。手動でインストールしてください: sudo apt install python3-venv"
TMPDIR_VENV=$(mktemp -d)
if python3 -m venv "${TMPDIR_VENV}/test" 2>/dev/null && "${TMPDIR_VENV}/test/bin/python" -c "import sys; print(sys.version)" >/dev/null 2>&1; then
VENV_OK=true
fi
rm -rf "${TMPDIR_VENV}"
fi
if [[ "${VENV_OK}" == "true" ]]; then
python3 -m venv "${INSTALL_DIR}/venv" || die "venv の作成に失敗しました"
else
die "venv の作成に失敗しました。手動でインストールしてください: sudo apt install python3-venv"
fi
fi
if ! "${INSTALL_DIR}/venv/bin/python" -c "import sys; print(sys.version)" >/dev/null 2>&1; then
die "venv の作成に失敗しました。手動でインストールしてください: sudo apt install python3-venv"
fi
ok "venv 作成完了 ($("${INSTALL_DIR}/venv/bin/python" -c "import sys; print(sys.version)"))"
info "依存パッケージを確認..."
PIP="${INSTALL_DIR}/venv/bin/pip"
PYTHON="${INSTALL_DIR}/venv/bin/python"
for pkg in Pillow watchdog flask; do
if "${PYTHON}" -c "import importlib.metadata; importlib.metadata.version('${pkg}')" >/dev/null 2>&1; then
ok "${pkg} は既にインストール済みです(スキップ)"
else
info "${pkg} をインストール..."
"${PIP}" install --quiet "${pkg}" || die "${pkg} のインストールに失敗しました"
ok "${pkg} インストール完了"
fi
done
# ── アプリケーションスクリプト生成 ────────────────────────────────────────────
info "アプリケーションを生成..."
cat > "${INSTALL_DIR}/app.py" << 'PYEOF'
#!/usr/bin/env python3
"""Image Resize Service — Watch a folder and resize images to an output folder."""
import json
import os
import sys
import time
import threading
from pathlib import Path
from flask import Flask, render_template_string, request, jsonify
from PIL import Image
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
DEFAULT_CONFIG = {
"watch_folder": "/opt/lxd-data/images",
"output_folder": "/opt/lxd-data/images-resize",
"resize_width": 1024,
"resize_height": 1024,
"keep_aspect_ratio": True,
"output_suffix": "",
"extensions": [".jpg", ".jpeg", ".png"],
"enabled": False,
"port": 3324,
}
app = Flask(__name__)
config = {}
observer = None
def load_config():
global config
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, "r") as f:
config = json.load(f)
else:
config = DEFAULT_CONFIG.copy()
save_config()
def save_config():
with open(CONFIG_FILE, "w") as f:
json.dump(config, f, indent=2, ensure_ascii=False)
def resize_image(src_path, dst_path, width, height, keep_aspect):
try:
img = Image.open(src_path)
if keep_aspect:
img.thumbnail((width, height), Image.LANCZOS)
else:
img = img.resize((width, height), Image.LANCZOS)
os.makedirs(os.path.dirname(dst_path), exist_ok=True)
img.save(dst_path, quality=90)
return True
except Exception as e:
print(f"[ERROR] Failed to resize {src_path}: {e}", file=sys.stderr)
return False
def get_output_path(src_path):
p = Path(src_path)
output_dir = config.get("output_folder", "").strip()
if output_dir:
out_dir = Path(output_dir)
else:
out_dir = p.parent / "resize"
suffix = config.get("output_suffix", "")
stem = p.stem + suffix
ext = p.suffix.lower()
return str(out_dir / (stem + ext))
class ImageHandler(FileSystemEventHandler):
def __init__(self):
self._pending = {} # path -> 最後にイベントを受けた時刻
self._lock = threading.Lock()
def on_created(self, event):
if event.is_directory:
return
self._handle(event.src_path)
def on_modified(self, event):
if event.is_directory:
return
self._handle(event.src_path)
def on_moved(self, event):
if event.is_directory:
return
self._handle(event.dest_path)
def _handle(self, path):
ext = Path(path).suffix.lower()
if ext not in config.get("extensions", [".jpg", ".jpeg", ".png"]):
# .uploading 等の一時ファイルはここで弾かれる
return
# 出力フォルダ内のファイルは無視
output_dir = config.get("output_folder", "").strip()
if output_dir:
try:
Path(path).resolve().relative_to(Path(output_dir).resolve())
return
except ValueError:
pass
else:
if "resize" in Path(path).parts:
return
with self._lock:
already_pending = path in self._pending
self._pending[path] = time.time() # タイムスタンプを常に更新
if already_pending:
# スレッドは既に動いているのでタイムスタンプ更新だけして終わり
return
# 新規ペンディング → スレッド起動
threading.Thread(target=self._delayed_process, args=(path,), daemon=True).start()
def _delayed_process(self, path):
# イベントが落ち着くまで待つ(最後のイベントから1秒間隔がなければ待ち続ける)
while True:
with self._lock:
last = self._pending.get(path, 0)
if time.time() - last >= 1.0:
break
time.sleep(0.2)
with self._lock:
self._pending.pop(path, None)
if not os.path.exists(path):
return
dst = get_output_path(path)
# 既にリサイズ済みの場合はスキップ
if os.path.exists(dst):
print(f"[SKIP] Already resized: {os.path.basename(path)} -> {dst}", flush=True)
return
ok = resize_image(
path, dst,
config.get("resize_width", 1024),
config.get("resize_height", 1024),
config.get("keep_aspect_ratio", True),
)
if ok:
print(f"[OK] Resized: {os.path.basename(path)} -> {dst}", flush=True)
def start_watching():
global observer
if observer and observer.is_alive():
observer.stop()
observer.join(timeout=5)
observer = None
if not config.get("enabled", False):
print("[INFO] Watching disabled", flush=True)
return
watch_dir = config.get("watch_folder", "")
if not watch_dir or not os.path.isdir(watch_dir):
print(f"[WARN] Watch folder not found: {watch_dir}", flush=True)
return
observer = Observer()
observer.schedule(ImageHandler(), watch_dir, recursive=False)
observer.start()
print(f"[INFO] Watching: {watch_dir}", flush=True)
HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Resize Service</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f0f2f5; color: #333; }
.header { background: #1a73e8; color: white; padding: 16px 24px; display: flex; align-items: center; gap: 12px; }
.header h1 { font-size: 20px; }
.status { margin-left: auto; padding: 4px 12px; border-radius: 12px; font-size: 13px; font-weight: 600; }
.status.on { background: #34a853; }
.status.off { background: #ea4335; }
.container { max-width: 720px; margin: 24px auto; padding: 0 16px; }
.card { background: white; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.12); padding: 24px; margin-bottom: 16px; }
.card h2 { font-size: 16px; margin-bottom: 16px; color: #555; }
.field { margin-bottom: 16px; }
.field label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 4px; color: #666; }
.field input[type="text"], .field input[type="number"] {
width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 15px;
}
.field input:focus { outline: none; border-color: #1a73e8; }
.field .hint { font-size: 12px; color: #999; margin-top: 4px; }
.row { display: flex; gap: 16px; }
.row .field { flex: 1; }
.toggle { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.toggle input[type="checkbox"] { width: 20px; height: 20px; }
.btn { display: inline-block; padding: 10px 24px; border: none; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; margin-right: 8px; }
.btn-primary { background: #1a73e8; color: white; }
.btn-primary:hover { background: #1557b0; }
.btn-secondary { background: #e8f0fe; color: #1a73e8; }
.btn-secondary:hover { background: #d2e3fc; }
.log { background: #1e1e1e; color: #d4d4d4; border-radius: 8px; padding: 12px; font-family: 'Courier New', monospace; font-size: 13px; max-height: 300px; overflow-y: auto; white-space: pre-wrap; }
.log .ok { color: #4ec9b0; }
.log .err { color: #f44747; }
.log .info { color: #569cd6; }
.notice { background: #fff8e1; border-left: 4px solid #f9a825; padding: 12px 16px; border-radius: 0 8px 8px 0; margin-bottom: 16px; font-size: 14px; color: #555; }
</style>
</head>
<body>
<div class="header">
<h1>Image Resize Service</h1>
<span class="status {{ 'on' if config.enabled else 'off' }}" id="statusBadge">
{{ '監視中' if config.enabled else '停止中' }}
</span>
</div>
<div class="container">
{% if not config.enabled %}
<div class="notice">
⚙️ まだ監視は開始されていません。設定を確認して「保存して適用」を押してください。
</div>
{% endif %}
<div class="card">
<h2>設定</h2>
<div class="field">
<label>監視フォルダ</label>
<input type="text" id="watchFolder" value="{{ config.watch_folder }}">
<div class="hint">存在しない場合は自動で作成します</div>
</div>
<div class="field">
<label>出力フォルダ</label>
<input type="text" id="outputFolder" value="{{ config.output_folder }}">
<div class="hint">存在しない場合は自動で作成します(空欄の場合は監視フォルダ内の resize/ サブフォルダに保存)</div>
</div>
<div class="row">
<div class="field">
<label>リサイズ幅 (px)</label>
<input type="number" id="resizeWidth" value="{{ config.resize_width }}" min="1">
</div>
<div class="field">
<label>リサイズ高さ (px)</label>
<input type="number" id="resizeHeight" value="{{ config.resize_height }}" min="1">
</div>
</div>
<div class="toggle">
<input type="checkbox" id="keepAspect" {{ 'checked' if config.keep_aspect_ratio }}>
<label for="keepAspect">アスペクト比を保持</label>
</div>
<div class="field">
<label>出力ファイル名サフィックス</label>
<input type="text" id="outputSuffix" value="{{ config.output_suffix }}" placeholder="例: _resized(空欄で元ファイル名と同じ)">
</div>
<div class="toggle">
<input type="checkbox" id="enabled" {{ 'checked' if config.enabled }}>
<label for="enabled">監視を有効にする</label>
</div>
<button class="btn btn-primary" onclick="saveConfig()">保存して適用</button>
</div>
<div class="card">
<h2>フォルダ内容</h2>
<div id="folderContents" class="log">読み込み中...</div>
<div style="margin-top:12px">
<button class="btn btn-secondary" onclick="refreshFolder()">更新</button>
<button class="btn btn-secondary" onclick="addTestImage()">テスト画像を追加</button>
</div>
</div>
</div>
<script>
function refreshFolder() {
fetch('/api/folder').then(r => r.json()).then(data => {
const el = document.getElementById('folderContents');
el.innerHTML = '';
if (data.error) {
const line = document.createElement('span');
line.className = 'err';
line.textContent = data.error + '\\n';
el.appendChild(line);
return;
}
if (data.items.length === 0) { el.textContent = '(空)'; return; }
data.items.forEach(item => {
const line = document.createElement('span');
line.className = item.type === 'output' ? 'ok' : 'info';
line.textContent = '[' + item.type + '] ' + item.name + ' (' + item.size + ')\\n';
el.appendChild(line);
});
});
}
function saveConfig() {
const watchFolder = document.getElementById('watchFolder').value.trim();
const outputFolder = document.getElementById('outputFolder').value.trim();
const cfg = {
watch_folder: watchFolder,
output_folder: outputFolder,
resize_width: parseInt(document.getElementById('resizeWidth').value),
resize_height: parseInt(document.getElementById('resizeHeight').value),
keep_aspect_ratio: document.getElementById('keepAspect').checked,
output_suffix: document.getElementById('outputSuffix').value,
enabled: document.getElementById('enabled').checked,
};
// 監視フォルダの存在確認
fetch('/api/check-folder', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path: watchFolder}),
}).then(r => r.json()).then(data => {
if (!data.exists) {
if (!confirm('監視フォルダ「' + watchFolder + '」が存在しません。\\n作成しますか?')) return Promise.resolve(null);
cfg.create_watch = true;
}
// 出力フォルダの存在確認(空欄でなければ)
if (outputFolder) {
return fetch('/api/check-folder', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path: outputFolder}),
}).then(r => r.json()).then(od => {
if (!od.exists) {
if (!confirm('出力フォルダ「' + outputFolder + '」が存在しません。\\n作成しますか?')) return null;
cfg.create_output = true;
}
return cfg;
});
}
return cfg;
}).then(cfg => {
if (!cfg) return;
return fetch('/api/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(cfg),
});
}).then(r => r && r.json()).then(result => {
if (!result) return;
document.getElementById('statusBadge').className = 'status ' + (cfg.enabled ? 'on' : 'off');
document.getElementById('statusBadge').textContent = cfg.enabled ? '監視中' : '停止中';
// notice バナーを非表示に
const notice = document.querySelector('.notice');
if (notice) notice.style.display = 'none';
refreshFolder();
});
}
function addTestImage() {
fetch('/api/test-image', {method: 'POST'}).then(r => r.json()).then(data => {
if (data.error) { alert(data.error); return; }
setTimeout(refreshFolder, 1000);
});
}
refreshFolder();
</script>
</body>
</html>"""
@app.route("/")
def index():
return render_template_string(HTML_TEMPLATE, config=config)
@app.route("/api/config", methods=["GET", "POST"])
def api_config():
global config
if request.method == "POST":
data = request.json
# 監視フォルダ作成
if data.pop("create_watch", False):
watch_dir = data.get("watch_folder", "")
if watch_dir:
os.makedirs(watch_dir, exist_ok=True)
print(f"[INFO] Created watch folder: {watch_dir}", flush=True)
# 出力フォルダ作成
if data.pop("create_output", False):
out_dir = data.get("output_folder", "")
if out_dir:
os.makedirs(out_dir, exist_ok=True)
print(f"[INFO] Created output folder: {out_dir}", flush=True)
config.update(data)
save_config()
start_watching()
return jsonify({"ok": True})
return jsonify(config)
@app.route("/api/check-folder", methods=["POST"])
def api_check_folder():
path = request.json.get("path", "")
exists = os.path.isdir(path) if path else False
return jsonify({"exists": exists, "path": path})
@app.route("/api/folder")
def api_folder():
items = []
watch = config.get("watch_folder", "").strip()
output = config.get("output_folder", "").strip()
if not watch:
return jsonify({"items": [], "error": "監視フォルダが未設定です"})
if not os.path.isdir(watch):
return jsonify({"items": [], "error": f"監視フォルダが見つかりません: {watch}"})
exts = config.get("extensions", [".jpg", ".jpeg", ".png"])
# 監視フォルダ内の画像
for f in sorted(os.listdir(watch)):
fp = os.path.join(watch, f)
if os.path.isfile(fp) and Path(f).suffix.lower() in exts:
sz = os.path.getsize(fp)
items.append({"name": f, "size": f"{sz//1024}KB", "type": "image"})
# 出力フォルダ内の画像
if output and os.path.isdir(output):
for f in sorted(os.listdir(output)):
fp = os.path.join(output, f)
if os.path.isfile(fp) and Path(f).suffix.lower() in exts:
sz = os.path.getsize(fp)
items.append({"name": f"[出力] {f}", "size": f"{sz//1024}KB", "type": "output"})
elif not output:
# サブフォルダ方式
resize_dir = os.path.join(watch, "resize")
if os.path.isdir(resize_dir):
for f in sorted(os.listdir(resize_dir)):
fp = os.path.join(resize_dir, f)
if os.path.isfile(fp):
sz = os.path.getsize(fp)
items.append({"name": f"resize/{f}", "size": f"{sz//1024}KB", "type": "output"})
return jsonify({"items": items})
@app.route("/api/test-image", methods=["POST"])
def api_test_image():
import random
watch = config.get("watch_folder", "").strip()
if not watch:
return jsonify({"ok": False, "error": "監視フォルダが未設定です"})
os.makedirs(watch, exist_ok=True)
w, h = random.randint(1600, 4000), random.randint(1200, 3000)
img = Image.new("RGB", (w, h), (
random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)
))
from PIL import ImageDraw
draw = ImageDraw.Draw(img)
for _ in range(20):
x1, y1 = random.randint(0, w), random.randint(0, h)
x2, y2 = x1 + random.randint(50, 300), y1 + random.randint(50, 300)
draw.rectangle([x1, y1, x2, y2], fill=(
random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)
))
ts = int(time.time() * 1000)
path = os.path.join(watch, f"test_{ts}.jpg")
img.save(path, quality=95)
return jsonify({"ok": True, "path": path})
def main():
load_config()
# 起動時は enabled フラグに従う(デフォルト false なので監視しない)
start_watching()
port = config.get("port", 3324)
print(f"[INFO] Web UI: http://127.0.0.1:{port}", flush=True)
app.run(host="0.0.0.0", port=port, debug=False)
if __name__ == "__main__":
main()
PYEOF
ok "アプリケーションスクリプト生成完了"
# ── config.json 生成(初期値・監視無効) ─────────────────────────────────────
cat > "${INSTALL_DIR}/config.json" << 'CFGEOF'
{
"watch_folder": "/opt/lxd-data/images",
"output_folder": "/opt/lxd-data/images-resize",
"resize_width": 1024,
"resize_height": 1024,
"keep_aspect_ratio": true,
"output_suffix": "",
"extensions": [".jpg", ".jpeg", ".png"],
"enabled": false,
"port": 3324
}
CFGEOF
ok "config.json 生成完了"
# ── systemd ユニットファイル生成 ───────────────────────────────────────────────
info "systemd ユニットファイルを生成..."
cat > /etc/systemd/system/${SERVICE_NAME}.service << EOF
[Unit]
Description=Image Resize Service
After=network.target
[Service]
Type=simple
ExecStart=${INSTALL_DIR}/venv/bin/python ${INSTALL_DIR}/app.py
WorkingDirectory=${INSTALL_DIR}
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
ok "systemd ユニットファイル生成完了"
# ── サービス起動 ──────────────────────────────────────────────────────────────
info "サービスを起動..."
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}"
systemctl restart "${SERVICE_NAME}"
for i in $(seq 1 10); do
if curl -s --max-time 1 "http://127.0.0.1:${PORT}/" >/dev/null 2>&1; then
break
fi
sleep 1
done
if curl -s --max-time 1 "http://127.0.0.1:${PORT}/" >/dev/null 2>&1; then
ok "サービス起動完了"
else
die "サービスの起動に失敗しました。journalctl -u ${SERVICE_NAME} -n 20 で確認してください"
fi
# ── 完了サマリー ──────────────────────────────────────────────────────────────
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
ok "セットアップ完了!"
echo ""
if [[ "${TS_AVAILABLE}" == "true" ]]; then
echo " Web UI : http://${TS_IP}:${PORT}"
echo " Web UI : http://${TS_HOSTNAME%%.*}:${PORT} (MagicDNS)"
else
echo " Web UI : http://127.0.0.1:${PORT}"
fi
echo " 設定ファイル: ${INSTALL_DIR}/config.json"
echo ""
echo " ▶ Web UI にアクセスして設定を確認し、「保存して適用」で監視を開始してください。"
echo ""
echo " 管理コマンド:"
echo " systemctl status ${SERVICE_NAME}"
echo " systemctl restart ${SERVICE_NAME}"
echo " journalctl -u ${SERVICE_NAME} -f"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"


