Split three large linux_configuration scripts, defer a fourth

organize_downloads.sh 513 -> 72, setup_kcd2_dice_solver.sh 497 -> 112,
fix_ubuntu_performance.sh 494 -> 150. First 490+ line scripts done; all
three needed two or three seams.

Repeated splitting chained the libs - each sourced the next, because the
tool inserts its source line relative to the file it just wrote.
Flattened so the entry sources all three and no lib sources a sibling.

diagnose_pacman_hook_stall.sh is reverted and stays at 493. Its
transaction runner assigns LAST_ELAPSED that the caller reads and reads
PACMAN_BIN that the caller defines, so linted alone it is SC2034 plus
SC2153. The pre-commit hook lints each lib in isolation and fails, while
shellcheck -x on the entry passes - suppressing that would be papering
over a seam that does not exist.

organize_downloads.sh is the ExecStart of media-organizer.service; the
unit still resolves and the script still parses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
This commit is contained in:
Krzysztof Rudnicki 2026-08-16 19:06:16 +02:00
parent 4ac8c77abd
commit 2527fb37e6
13 changed files with 1059 additions and 919 deletions

View File

@ -0,0 +1,19 @@
{
"title": "Split three large linux_configuration scripts, deferring a fourth",
"objective": "Bring organize_downloads.sh (513), setup_kcd2_dice_solver.sh (497) and fix_ubuntu_performance.sh (494) under the 250-line cap, and establish that a seam sharing mutable state with its caller must be abandoned rather than suppressed.",
"acceptance_criteria": [
"All three entry scripts and all seven new libs are at most 250 lines.",
"The pre-commit shellcheck hook passes on every touched file, with no new suppression and no disable directive. That hook lints each lib in isolation, which is stricter than shellcheck -x on the entry script.",
"No lib sources a sibling: the entry script sources every lib directly.",
"The stubbed run reports every moved function as defined, for all three scripts.",
"organize_downloads.sh is the ExecStart of the live media-organizer.service unit, so after the split that path must still resolve and the script must still parse.",
"No script is executed. These move files in ~/Downloads, write compose stacks and change sysctl and systemd settings.",
"diagnose_pacman_hook_stall.sh is fully restored to 493 lines, leaving no partial split behind."
],
"out_of_scope": [
"Executing any organize, setup or fix step.",
"diagnose_pacman_hook_stall.sh, whose load generator assigns LAST_ELAPSED read by the caller and reads PACMAN_BIN defined by it; splitting it would need a suppression.",
"Changing what any script does when run."
],
"verifier": "meta/scripts/verify_shell_split.sh per script, plus pre-commit run shellcheck over all touched files, plus systemctl cat media-organizer and test -f on its ExecStart, plus check_file_length.sh and the real git commit for jscpd."
}

View File

@ -0,0 +1,51 @@
{
"intent": "Split three of the largest remaining linux_configuration scripts, and defer a fourth whose seam cannot be made lint-clean without a suppression.",
"scope": [
"single_use/utils/organize_downloads.sh (513 -> 72) + lib/download_classify.sh, download_sizes.sh, download_media.sh",
"single_use/features/setup_kcd2_dice_solver.sh (497 -> 112) + lib/kcd2_serve.sh, kcd2_expose.sh, kcd2_autorebuild.sh",
"single_use/fixes/fix_ubuntu_performance.sh (494 -> 150) + lib/ubuntu_perf_fixes.sh, ubuntu_perf_more.sh",
"Deferred and fully restored: single_use/fixes/diagnose_pacman_hook_stall.sh (493)",
"Non-goal: no script executed"
],
"changes": [
"Three cuts each for organize_downloads.sh and setup_kcd2_dice_solver.sh, two for fix_ubuntu_performance.sh. These are the first 490+ line scripts done, and every one needed more than one seam.",
"Repeated splitting chained the libs - each new lib sourced the next, because the tool inserts its source line relative to the file it just wrote. Flattened so organize_downloads.sh sources all three directly and no lib sources a sibling, and removed a leftover SCRIPT_DIR the chain had left in a lib.",
"diagnose_pacman_hook_stall.sh was reverted to HEAD. Its transaction runner assigns LAST_ELAPSED which the caller reads, and reads PACMAN_BIN which the caller defines; linted as a fragment that is SC2034 plus SC2153, and the pre-commit hook lints each lib in isolation. Neither is a real bug, which is exactly why suppressing them would be wrong - the honest answer is that this seam does not exist."
],
"verification": [
{
"command": "meta/scripts/verify_shell_split.sh per script",
"result": "fail then pass",
"evidence": "organize_downloads.sh first reported SC1091 for a lib sourcing a sibling; after flattening, all three report 'libs sourced, functions defined'."
},
{
"command": "pre-commit run shellcheck --files <all touched>",
"result": "fail then pass",
"evidence": "Failed on the pacman lib's SC2034/SC2153. This is the check that matters here and is stricter than shellcheck -x on the entry script, which passed on the same code - the hook lints each lib alone, with no view of the caller's globals. Passes now that the split is reverted."
},
{
"command": "systemctl cat media-organizer.service, test -f its ExecStart, bash -n that file",
"result": "pass",
"evidence": "Resolves to organize_downloads.sh and parses. This is one of only two units pointing into the repo, and it is the script that shrank most in this batch (513 -> 72)."
},
{
"command": "git checkout HEAD -- diagnose_pacman_hook_stall.sh, then shellcheck -x",
"result": "pass",
"evidence": "Restored to 493 lines and clean. Note the first `git checkout` without HEAD restored the staged split rather than the original - checked with wc -l, not assumed."
},
{
"command": "bash ~/utils/scripts/check_file_length.sh --all",
"result": "pass",
"evidence": "None of the three entry scripts or seven libs is listed."
}
],
"risks": [
"None of the three was run. organize_downloads.sh moves files in ~/Downloads and runs from a live systemd timer; verifying it by running would be exactly the mutation decision 6 forbids. The unit-path check and bash -n are the strongest available evidence.",
"The lib-chaining bug now has two instances (20-dump-stock.sh, organize_downloads.sh). Any script split more than twice needs its source lines grepped afterwards; the tool does not do this itself.",
"diagnose_pacman_hook_stall.sh remains at 493 lines, the largest deferred file. Splitting it needs the LAST_ELAPSED/PACMAN_BIN state passed explicitly rather than shared."
],
"rollback": [
"git revert the commit; the three scripts return to their previous sizes and the seven libs disappear.",
"After rollback, re-run bash -n and shellcheck on the restored scripts."
]
}

View File

@ -0,0 +1,223 @@
#!/bin/bash
# Auto-rebuild timer and unit installation for the dice solver site.
#
# Sourced by setup_kcd2_dice_solver.sh; split out to keep kcd2_expose.sh
# under the 250-line cap. Sourced rather than run, so it inherits the
# caller's strict mode and the variables above the source line.
# --- Phase 7: auto-rebuild on commit ----------------------------------------
# The build runs in a memory-capped systemd user unit rather than inside the
# git hook. MemorySwapMax=0 is required, not cosmetic: this box has ~4 GB of
# zram (swap held IN ram), so a memory-limited cgroup without it thrashes zram
# instead of dying cleanly, and freezes the machine. A git hook running tsc and
# vite is precisely the scenario that costs.
install_autorebuild() {
ensure_dir "$UNIT_DIR"
cat >"$UNIT_FILE" <<EOF
# Managed by setup_kcd2_dice_solver.sh — do not edit by hand.
[Unit]
Description=Rebuild the KCD2 dice solver static bundle
[Service]
Type=oneshot
WorkingDirectory=${DICE_SRC}
ExecStart=${DICE_BUILD}
MemoryMax=3G
MemorySwapMax=0
EOF
systemctl --user daemon-reload
log_ok "Installed ${UNIT_FILE}."
[[ -d "${REPO_ROOT}/.git" ]] || die "${REPO_ROOT} is not a git repository."
cat >"$HOOK_FILE" <<EOF
#!/bin/bash
# Managed by setup_kcd2_dice_solver.sh — do not edit by hand.
# Republishes https://${DICE_DOMAIN}/ when main gains solver changes.
[[ "\$(git rev-parse --abbrev-ref HEAD)" == main ]] || exit 0
git diff --name-only HEAD~1 HEAD -- kcd2_dice_solver/ | grep -q . || exit 0
# --no-block so the commit is never slowed, || true so systemd can never fail it.
systemctl --user start --no-block ${UNIT_NAME} || true
EOF
chmod +x "$HOOK_FILE"
log_ok "Installed ${HOOK_FILE}."
}
# --- Phase 8: firewall ------------------------------------------------------
# setup_wireguard_ssh.sh owns /etc/nftables.conf and regenerates it from
# scratch — `flush ruleset` and all — on every run. That is fine on a quiet
# machine and rude on a busy one: it momentarily drops the whole ruleset while
# games, VPN links and long-lived connections are relying on it. Since the only
# thing this deployment needs is 80/443 already being accepted, check first and
# only pay for the regeneration when the rule is genuinely missing.
# Exact token membership rather than a regex over the rule text: a loose
# pattern would also match "8080, 4433" and wrongly conclude the ports are open.
web_ports_open() {
local line ports
while read -r line; do
# Strip everything but digits and commas, then test for whole tokens.
ports=",${line//[^0-9,]/},"
if [[ $ports == *,80,* && $ports == *,443,* ]]; then
return 0
fi
done < <(sudo -n nft list ruleset 2>/dev/null | grep -oE 'tcp dport \{[^}]*\}')
return 1
}
ensure_firewall() {
if web_ports_open; then
log_ok "Ports 80/443 already accepted — leaving the ruleset untouched."
return 0
fi
log_info "Opening ports 80/443 via setup_wireguard_ssh.sh…"
sudo bash "$WG_SCRIPT" allow-web
}
# --- Phase 9: report --------------------------------------------------------
print_report() {
cat <<EOF
============================================================================
KCD2 dice solver deployed.
============================================================================
Solver : https://${DICE_DOMAIN}/
Auto-rebuild: committing to main with changes under kcd2_dice_solver/ starts
${UNIT_NAME}, which rebuilds dist/ in place.
Watch it: journalctl --user -u ${UNIT_NAME} -f
Force it: systemctl --user start ${UNIT_NAME}
NOTE: the rebuild builds the WORKING TREE, not the commit. An unrelated
uncommitted edit that happens to be present when it runs will ship to the live
site. Commit or set aside unrelated work before triggering a rebuild.
Acceptance test (do this on your phone):
Turn Wi-Fi OFF (use cellular) and open https://${DICE_DOMAIN}/ —
all 43 dice should be on one screen and tappable.
============================================================================
EOF
}
setup_cmd() {
print_setup_header "KCD2 dice solver setup"
preflight
build_solver
write_serve_stack
# The snippet goes into the SHARED sites/ directory, so it is written only
# after the backend is up and verified. Writing it first means a failure in
# start_site leaves an orphan behind that the next `caddy reload` from any
# other service picks up — the edge then serves this domain as a 502 and
# starts ACME retries for a host with no backend.
start_site
write_snippet
reload_caddy
install_autorebuild
ensure_firewall
print_report
log_ok "KCD2 dice solver setup complete."
}
# --- status: self-diagnose --------------------------------------------------
status_line() {
if [[ $1 -eq 0 ]]; then
log_ok "$2"
else
log_warn "$2"
fi
}
check_http() {
# Prints 0 to stdout if the URL returns 200 with a non-empty body, else 1.
# The body-size check matters: a host-matcher misconfiguration answers 200
# with nothing in it, which a status-only check would call healthy.
#
# Certificates are NOT skipped for https URLs — `curl -k` would report an
# expired or failed-ACME cert as a healthy site, which is precisely the kind
# of green-while-broken this command exists to catch. The loopback check runs
# over plain http, so it needs no exception.
local url="$1" out code size
out="$(curl -s -o /dev/null -w '%{http_code} %{size_download}' --max-time 10 \
${2:+-H "Host: $2"} "$url" 2>/dev/null || echo "000 0")"
read -r code size <<<"$out"
[[ $code == "200" && ${size:-0} -gt 0 ]] && echo 0 || echo 1
}
status_cmd() {
print_setup_header "KCD2 dice solver status"
if has_cmd node; then
status_line 0 "node present"
else
status_line 1 "node missing"
fi
if has_cmd pnpm; then
status_line 0 "pnpm present"
else
status_line 1 "pnpm missing"
fi
if [[ -f "${DICE_SRC}/dist/index.html" ]]; then
status_line 0 "build present (dist/index.html)"
else
status_line 1 "build missing — run setup"
fi
if docker ps --format '{{.Names}}' | grep -qx "$DICE_CONTAINER"; then
status_line 0 "${DICE_CONTAINER} container running"
local pid listeners
pid="$(docker inspect -f '{{.State.Pid}}' "$DICE_CONTAINER" 2>/dev/null || echo "")"
listeners="$(sudo -n ss -ltnp 2>/dev/null | grep "pid=${pid}," || true)"
if grep -qE ':(80|443)\b' <<<"$listeners"; then
status_line 1 "${DICE_CONTAINER} is CONTENDING for :80/:443 with ${CADDY_CONTAINER}"
else
status_line 0 "${DICE_CONTAINER} listens only on ${DICE_PORT}"
fi
else
status_line 1 "${DICE_CONTAINER} container not running"
fi
status_line "$(check_http "http://127.0.0.1:${DICE_PORT}/" "$DICE_DOMAIN")" \
"local static server (127.0.0.1:${DICE_PORT}, Host: ${DICE_DOMAIN})"
if [[ -f $DICE_SNIPPET ]]; then
status_line 0 "Caddy snippet present"
else
status_line 1 "Caddy snippet missing"
fi
if getent hosts "$DICE_DOMAIN" >/dev/null; then
status_line 0 "${DICE_DOMAIN} resolves"
else
status_line 1 "${DICE_DOMAIN} does not resolve"
fi
status_line "$(check_http "https://${DICE_DOMAIN}/")" \
"external https://${DICE_DOMAIN}/"
# The hook lives in .git/hooks, which is not version-controlled, so it is
# lost on a fresh clone and must be checked rather than assumed. Spelled as
# an explicit if: with two conditions, A && B || C really would misfire.
if [[ -x $HOOK_FILE ]] && grep -q "$UNIT_NAME" "$HOOK_FILE" 2>/dev/null; then
status_line 0 "post-commit auto-rebuild hook installed"
else
status_line 1 "post-commit hook missing — re-run setup"
fi
if [[ -f $UNIT_FILE ]]; then
status_line 0 "${UNIT_NAME} installed"
else
status_line 1 "${UNIT_NAME} missing — re-run setup"
fi
if systemctl --user is-failed --quiet "$UNIT_NAME"; then
status_line 1 "last auto-rebuild FAILED — journalctl --user -u ${UNIT_NAME}"
else
status_line 0 "no failed auto-rebuild recorded"
fi
# "not failed" also covers "never ran", so check the artefact rather than the
# unit: if any source file is newer than the published bundle, the hook is
# not firing and the site is quietly serving an old build.
local newest_src
newest_src="$(find "${DICE_SRC}/src" "${DICE_SRC}/index.html" -type f -newer \
"${DICE_SRC}/dist/index.html" -print -quit 2>/dev/null || true)"
if [[ -n $newest_src ]]; then
status_line 1 "published bundle is STALE (e.g. ${newest_src#"${DICE_SRC}/"}) — run: systemctl --user start ${UNIT_NAME}"
else
status_line 0 "published bundle is up to date with src/"
fi
}

View File

@ -0,0 +1,60 @@
#!/bin/bash
# Edge-conflict checks, site startup and Caddy reload.
#
# Sourced by setup_kcd2_dice_solver.sh; split out to keep kcd2_serve.sh
# under the 250-line cap. Sourced rather than run, so it inherits the
# caller's strict mode and the variables above the source line.
# This container shares the host network namespace with the public edge, so a
# misconfiguration here does not fail locally — it quietly steals a share of
# :80 or :443 from every other site on the box. Gate on it rather than trust
# the config to stay correct.
assert_no_edge_conflict() {
local pid listeners
pid="$(docker inspect -f '{{.State.Pid}}' "$DICE_CONTAINER" 2>/dev/null || echo "")"
[[ -n $pid && $pid != "0" ]] || die "Could not read the ${DICE_CONTAINER} PID."
listeners="$(sudo -n ss -ltnp 2>/dev/null | grep "pid=${pid}," || true)"
# Demand positive evidence FIRST. Asserting only the absence of :80/:443
# fails open: if `ss` returns nothing — a stale PID after a restart, or
# sudo -n unavailable — every "is it bad?" test misses and the guard happily
# reports success without having looked at anything.
if ! grep -qE '(^|[[:space:]])127\.0\.0\.1:'"${DICE_PORT}"'([[:space:]]|$)' <<<"$listeners"; then
docker stop "$DICE_CONTAINER" >/dev/null 2>&1 || true
die "Could not confirm ${DICE_CONTAINER} listens on 127.0.0.1:${DICE_PORT}. Container stopped."
fi
if grep -qE ':(80|443)\b' <<<"$listeners"; then
docker stop "$DICE_CONTAINER" >/dev/null 2>&1 || true
die "${DICE_CONTAINER} bound :80/:443 — it would contend with ${CADDY_CONTAINER}. Container stopped."
fi
# Must be on loopback, not 0.0.0.0/*: host networking would otherwise expose
# the unfronted static server to the whole LAN.
if grep -qE '(\*|0\.0\.0\.0):'"${DICE_PORT}"'\b' <<<"$listeners"; then
docker stop "$DICE_CONTAINER" >/dev/null 2>&1 || true
die "${DICE_CONTAINER} bound ${DICE_PORT} on all interfaces, not loopback. Container stopped."
fi
log_ok "${DICE_CONTAINER} listens on loopback:${DICE_PORT} only — no contention, not LAN-exposed."
}
start_site() {
log_info "Starting the ${DICE_CONTAINER} container…"
docker compose -f "$DICE_COMPOSE" up -d
if wait_for_site; then
log_ok "Solver answering on http://127.0.0.1:${DICE_PORT}/."
else
docker stop "$DICE_CONTAINER" >/dev/null 2>&1 || true
die "Solver did not become reachable on 127.0.0.1:${DICE_PORT} (container stopped)."
fi
assert_no_edge_conflict
}
# --- Phase 6: reload the edge -----------------------------------------------
reload_caddy() {
if docker exec "$CADDY_CONTAINER" caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>&1; then
docker exec "$CADDY_CONTAINER" caddy reload --config /etc/caddy/Caddyfile
log_ok "Reloaded ${CADDY_CONTAINER}."
else
die "Caddy config invalid — aborting reload. Check ${SITES_DIR}/*.caddy."
fi
}

View File

@ -0,0 +1,103 @@
#!/bin/bash
# Compose stack, Caddy snippet and readiness polling for the dice solver.
#
# Sourced by setup_kcd2_dice_solver.sh; split out to keep it under the 250-line cap.
# Sourced rather than run, so it inherits the caller's strict mode and
# the helper functions and variables defined above the source line.
# --- Phase 3: the static-serve stack ----------------------------------------
write_serve_stack() {
ensure_dir "$DICE_DATA_DIR"
cat >"$DICE_COMPOSE" <<EOF
# Managed by setup_kcd2_dice_solver.sh — do not edit by hand.
services:
${DICE_CONTAINER}:
image: caddy:2.8
container_name: ${DICE_CONTAINER}
restart: unless-stopped
# host networking is REQUIRED, not a style choice: the nftables forward
# chain is policy drop with no accept rules, so a bridge-networked
# container has no outbound egress at all.
network_mode: host
volumes:
- ${DICE_SRC}/dist:/srv:ro
- ./Caddyfile:/etc/caddy/Caddyfile:ro
EOF
cat >"$DICE_INNER_CADDY" <<EOF
# Managed by setup_kcd2_dice_solver.sh — do not edit by hand.
# Static file server on loopback; fronted by ${CADDY_CONTAINER} on the public
# domain.
#
# admin off is REQUIRED: this container shares the host network namespace with
# ${CADDY_CONTAINER}, so without it both would listen on the admin API (:2019)
# and a 'caddy reload' aimed at the edge could be applied here instead — which
# would make this process grab 80/443 and run its own ACME.
#
# auto_https off and the explicit http:// scheme are EQUALLY required, and this
# was found the hard way. A site address of "127.0.0.1:${DICE_PORT}" reads as a
# HOSTNAME, so Caddy turns on automatic HTTPS for it: it issues a local
# certificate, serves TLS on ${DICE_PORT} (so the plain-HTTP health check fails)
# and — the real damage — starts an HTTP->HTTPS redirect listener on :80. Under
# host networking that binds *:80 next to the edge, and SO_REUSEPORT then splits
# inbound HTTP between the two, which would intermittently break ACME
# challenges for every domain on this host.
#
# The site address is port-only and the loopback restriction is done with
# "bind". These are two different things and using the wrong one breaks in a way
# that looks like it works:
#
# A site address of "http://127.0.0.1:${DICE_PORT}" sets a host MATCHER of
# 127.0.0.1. ${CADDY_CONTAINER} forwards the original "Host:
# ${DICE_DOMAIN}" header, which then matches no route, and Caddy answers 200
# with an empty body. A local health check passes (curl sends Host:
# 127.0.0.1) while every real request serves a blank page.
#
# ":${DICE_PORT}" matches any Host, and "bind 127.0.0.1" restricts the
# listener itself — which the address never did. Without bind, host
# networking would expose this unfronted server to the whole LAN.
{
admin off
auto_https off
}
:${DICE_PORT} {
bind 127.0.0.1
root * /srv
# Single-page bundle: unknown paths should serve the app, not 404.
try_files {path} /index.html
file_server
encode gzip
}
EOF
log_ok "Wrote ${DICE_COMPOSE} and ${DICE_INNER_CADDY}."
}
# --- Phase 4: front it on the public domain ---------------------------------
write_snippet() {
cat >"$DICE_SNIPPET" <<EOF
# Managed by setup_kcd2_dice_solver.sh — do not edit by hand.
${DICE_DOMAIN} {
reverse_proxy 127.0.0.1:${DICE_PORT}
}
EOF
log_ok "Wrote ${DICE_SNIPPET}."
}
# --- Phase 5: start ---------------------------------------------------------
# Sends the Host header the edge will actually forward, and requires a
# non-empty body. A bare "curl http://127.0.0.1:PORT/" sends Host: 127.0.0.1
# and returns an empty 200 against a host-matcher misconfiguration — it passes
# while every real request serves a blank page. Ask the question the edge asks.
wait_for_site() {
local _ size
for _ in $(seq 1 30); do
size="$(curl -s -o /dev/null -w '%{size_download}' \
-H "Host: ${DICE_DOMAIN}" "http://127.0.0.1:${DICE_PORT}/" 2>/dev/null || echo 0)"
if [[ ${size:-0} -gt 0 ]]; then
return 0
fi
sleep 1
done
return 1
}

View File

@ -107,373 +107,12 @@ build_solver() {
log_ok "Solver built to ${DICE_SRC}/dist."
}
# --- Phase 3: the static-serve stack ----------------------------------------
write_serve_stack() {
ensure_dir "$DICE_DATA_DIR"
cat >"$DICE_COMPOSE" <<EOF
# Managed by setup_kcd2_dice_solver.sh — do not edit by hand.
services:
${DICE_CONTAINER}:
image: caddy:2.8
container_name: ${DICE_CONTAINER}
restart: unless-stopped
# host networking is REQUIRED, not a style choice: the nftables forward
# chain is policy drop with no accept rules, so a bridge-networked
# container has no outbound egress at all.
network_mode: host
volumes:
- ${DICE_SRC}/dist:/srv:ro
- ./Caddyfile:/etc/caddy/Caddyfile:ro
EOF
cat >"$DICE_INNER_CADDY" <<EOF
# Managed by setup_kcd2_dice_solver.sh — do not edit by hand.
# Static file server on loopback; fronted by ${CADDY_CONTAINER} on the public
# domain.
#
# admin off is REQUIRED: this container shares the host network namespace with
# ${CADDY_CONTAINER}, so without it both would listen on the admin API (:2019)
# and a 'caddy reload' aimed at the edge could be applied here instead — which
# would make this process grab 80/443 and run its own ACME.
#
# auto_https off and the explicit http:// scheme are EQUALLY required, and this
# was found the hard way. A site address of "127.0.0.1:${DICE_PORT}" reads as a
# HOSTNAME, so Caddy turns on automatic HTTPS for it: it issues a local
# certificate, serves TLS on ${DICE_PORT} (so the plain-HTTP health check fails)
# and — the real damage — starts an HTTP->HTTPS redirect listener on :80. Under
# host networking that binds *:80 next to the edge, and SO_REUSEPORT then splits
# inbound HTTP between the two, which would intermittently break ACME
# challenges for every domain on this host.
#
# The site address is port-only and the loopback restriction is done with
# "bind". These are two different things and using the wrong one breaks in a way
# that looks like it works:
#
# A site address of "http://127.0.0.1:${DICE_PORT}" sets a host MATCHER of
# 127.0.0.1. ${CADDY_CONTAINER} forwards the original "Host:
# ${DICE_DOMAIN}" header, which then matches no route, and Caddy answers 200
# with an empty body. A local health check passes (curl sends Host:
# 127.0.0.1) while every real request serves a blank page.
#
# ":${DICE_PORT}" matches any Host, and "bind 127.0.0.1" restricts the
# listener itself — which the address never did. Without bind, host
# networking would expose this unfronted server to the whole LAN.
{
admin off
auto_https off
}
:${DICE_PORT} {
bind 127.0.0.1
root * /srv
# Single-page bundle: unknown paths should serve the app, not 404.
try_files {path} /index.html
file_server
encode gzip
}
EOF
log_ok "Wrote ${DICE_COMPOSE} and ${DICE_INNER_CADDY}."
}
# --- Phase 4: front it on the public domain ---------------------------------
write_snippet() {
cat >"$DICE_SNIPPET" <<EOF
# Managed by setup_kcd2_dice_solver.sh — do not edit by hand.
${DICE_DOMAIN} {
reverse_proxy 127.0.0.1:${DICE_PORT}
}
EOF
log_ok "Wrote ${DICE_SNIPPET}."
}
# --- Phase 5: start ---------------------------------------------------------
# Sends the Host header the edge will actually forward, and requires a
# non-empty body. A bare "curl http://127.0.0.1:PORT/" sends Host: 127.0.0.1
# and returns an empty 200 against a host-matcher misconfiguration — it passes
# while every real request serves a blank page. Ask the question the edge asks.
wait_for_site() {
local _ size
for _ in $(seq 1 30); do
size="$(curl -s -o /dev/null -w '%{size_download}' \
-H "Host: ${DICE_DOMAIN}" "http://127.0.0.1:${DICE_PORT}/" 2>/dev/null || echo 0)"
if [[ ${size:-0} -gt 0 ]]; then
return 0
fi
sleep 1
done
return 1
}
# This container shares the host network namespace with the public edge, so a
# misconfiguration here does not fail locally — it quietly steals a share of
# :80 or :443 from every other site on the box. Gate on it rather than trust
# the config to stay correct.
assert_no_edge_conflict() {
local pid listeners
pid="$(docker inspect -f '{{.State.Pid}}' "$DICE_CONTAINER" 2>/dev/null || echo "")"
[[ -n $pid && $pid != "0" ]] || die "Could not read the ${DICE_CONTAINER} PID."
listeners="$(sudo -n ss -ltnp 2>/dev/null | grep "pid=${pid}," || true)"
# Demand positive evidence FIRST. Asserting only the absence of :80/:443
# fails open: if `ss` returns nothing — a stale PID after a restart, or
# sudo -n unavailable — every "is it bad?" test misses and the guard happily
# reports success without having looked at anything.
if ! grep -qE '(^|[[:space:]])127\.0\.0\.1:'"${DICE_PORT}"'([[:space:]]|$)' <<<"$listeners"; then
docker stop "$DICE_CONTAINER" >/dev/null 2>&1 || true
die "Could not confirm ${DICE_CONTAINER} listens on 127.0.0.1:${DICE_PORT}. Container stopped."
fi
if grep -qE ':(80|443)\b' <<<"$listeners"; then
docker stop "$DICE_CONTAINER" >/dev/null 2>&1 || true
die "${DICE_CONTAINER} bound :80/:443 — it would contend with ${CADDY_CONTAINER}. Container stopped."
fi
# Must be on loopback, not 0.0.0.0/*: host networking would otherwise expose
# the unfronted static server to the whole LAN.
if grep -qE '(\*|0\.0\.0\.0):'"${DICE_PORT}"'\b' <<<"$listeners"; then
docker stop "$DICE_CONTAINER" >/dev/null 2>&1 || true
die "${DICE_CONTAINER} bound ${DICE_PORT} on all interfaces, not loopback. Container stopped."
fi
log_ok "${DICE_CONTAINER} listens on loopback:${DICE_PORT} only — no contention, not LAN-exposed."
}
start_site() {
log_info "Starting the ${DICE_CONTAINER} container…"
docker compose -f "$DICE_COMPOSE" up -d
if wait_for_site; then
log_ok "Solver answering on http://127.0.0.1:${DICE_PORT}/."
else
docker stop "$DICE_CONTAINER" >/dev/null 2>&1 || true
die "Solver did not become reachable on 127.0.0.1:${DICE_PORT} (container stopped)."
fi
assert_no_edge_conflict
}
# --- Phase 6: reload the edge -----------------------------------------------
reload_caddy() {
if docker exec "$CADDY_CONTAINER" caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>&1; then
docker exec "$CADDY_CONTAINER" caddy reload --config /etc/caddy/Caddyfile
log_ok "Reloaded ${CADDY_CONTAINER}."
else
die "Caddy config invalid — aborting reload. Check ${SITES_DIR}/*.caddy."
fi
}
# --- Phase 7: auto-rebuild on commit ----------------------------------------
# The build runs in a memory-capped systemd user unit rather than inside the
# git hook. MemorySwapMax=0 is required, not cosmetic: this box has ~4 GB of
# zram (swap held IN ram), so a memory-limited cgroup without it thrashes zram
# instead of dying cleanly, and freezes the machine. A git hook running tsc and
# vite is precisely the scenario that costs.
install_autorebuild() {
ensure_dir "$UNIT_DIR"
cat >"$UNIT_FILE" <<EOF
# Managed by setup_kcd2_dice_solver.sh — do not edit by hand.
[Unit]
Description=Rebuild the KCD2 dice solver static bundle
[Service]
Type=oneshot
WorkingDirectory=${DICE_SRC}
ExecStart=${DICE_BUILD}
MemoryMax=3G
MemorySwapMax=0
EOF
systemctl --user daemon-reload
log_ok "Installed ${UNIT_FILE}."
[[ -d "${REPO_ROOT}/.git" ]] || die "${REPO_ROOT} is not a git repository."
cat >"$HOOK_FILE" <<EOF
#!/bin/bash
# Managed by setup_kcd2_dice_solver.sh — do not edit by hand.
# Republishes https://${DICE_DOMAIN}/ when main gains solver changes.
[[ "\$(git rev-parse --abbrev-ref HEAD)" == main ]] || exit 0
git diff --name-only HEAD~1 HEAD -- kcd2_dice_solver/ | grep -q . || exit 0
# --no-block so the commit is never slowed, || true so systemd can never fail it.
systemctl --user start --no-block ${UNIT_NAME} || true
EOF
chmod +x "$HOOK_FILE"
log_ok "Installed ${HOOK_FILE}."
}
# --- Phase 8: firewall ------------------------------------------------------
# setup_wireguard_ssh.sh owns /etc/nftables.conf and regenerates it from
# scratch — `flush ruleset` and all — on every run. That is fine on a quiet
# machine and rude on a busy one: it momentarily drops the whole ruleset while
# games, VPN links and long-lived connections are relying on it. Since the only
# thing this deployment needs is 80/443 already being accepted, check first and
# only pay for the regeneration when the rule is genuinely missing.
# Exact token membership rather than a regex over the rule text: a loose
# pattern would also match "8080, 4433" and wrongly conclude the ports are open.
web_ports_open() {
local line ports
while read -r line; do
# Strip everything but digits and commas, then test for whole tokens.
ports=",${line//[^0-9,]/},"
if [[ $ports == *,80,* && $ports == *,443,* ]]; then
return 0
fi
done < <(sudo -n nft list ruleset 2>/dev/null | grep -oE 'tcp dport \{[^}]*\}')
return 1
}
ensure_firewall() {
if web_ports_open; then
log_ok "Ports 80/443 already accepted — leaving the ruleset untouched."
return 0
fi
log_info "Opening ports 80/443 via setup_wireguard_ssh.sh…"
sudo bash "$WG_SCRIPT" allow-web
}
# --- Phase 9: report --------------------------------------------------------
print_report() {
cat <<EOF
============================================================================
KCD2 dice solver deployed.
============================================================================
Solver : https://${DICE_DOMAIN}/
Auto-rebuild: committing to main with changes under kcd2_dice_solver/ starts
${UNIT_NAME}, which rebuilds dist/ in place.
Watch it: journalctl --user -u ${UNIT_NAME} -f
Force it: systemctl --user start ${UNIT_NAME}
NOTE: the rebuild builds the WORKING TREE, not the commit. An unrelated
uncommitted edit that happens to be present when it runs will ship to the live
site. Commit or set aside unrelated work before triggering a rebuild.
Acceptance test (do this on your phone):
Turn Wi-Fi OFF (use cellular) and open https://${DICE_DOMAIN}/ —
all 43 dice should be on one screen and tappable.
============================================================================
EOF
}
setup_cmd() {
print_setup_header "KCD2 dice solver setup"
preflight
build_solver
write_serve_stack
# The snippet goes into the SHARED sites/ directory, so it is written only
# after the backend is up and verified. Writing it first means a failure in
# start_site leaves an orphan behind that the next `caddy reload` from any
# other service picks up — the edge then serves this domain as a 502 and
# starts ACME retries for a host with no backend.
start_site
write_snippet
reload_caddy
install_autorebuild
ensure_firewall
print_report
log_ok "KCD2 dice solver setup complete."
}
# --- status: self-diagnose --------------------------------------------------
status_line() {
if [[ $1 -eq 0 ]]; then
log_ok "$2"
else
log_warn "$2"
fi
}
check_http() {
# Prints 0 to stdout if the URL returns 200 with a non-empty body, else 1.
# The body-size check matters: a host-matcher misconfiguration answers 200
# with nothing in it, which a status-only check would call healthy.
#
# Certificates are NOT skipped for https URLs — `curl -k` would report an
# expired or failed-ACME cert as a healthy site, which is precisely the kind
# of green-while-broken this command exists to catch. The loopback check runs
# over plain http, so it needs no exception.
local url="$1" out code size
out="$(curl -s -o /dev/null -w '%{http_code} %{size_download}' --max-time 10 \
${2:+-H "Host: $2"} "$url" 2>/dev/null || echo "000 0")"
read -r code size <<<"$out"
[[ $code == "200" && ${size:-0} -gt 0 ]] && echo 0 || echo 1
}
status_cmd() {
print_setup_header "KCD2 dice solver status"
if has_cmd node; then
status_line 0 "node present"
else
status_line 1 "node missing"
fi
if has_cmd pnpm; then
status_line 0 "pnpm present"
else
status_line 1 "pnpm missing"
fi
if [[ -f "${DICE_SRC}/dist/index.html" ]]; then
status_line 0 "build present (dist/index.html)"
else
status_line 1 "build missing — run setup"
fi
if docker ps --format '{{.Names}}' | grep -qx "$DICE_CONTAINER"; then
status_line 0 "${DICE_CONTAINER} container running"
local pid listeners
pid="$(docker inspect -f '{{.State.Pid}}' "$DICE_CONTAINER" 2>/dev/null || echo "")"
listeners="$(sudo -n ss -ltnp 2>/dev/null | grep "pid=${pid}," || true)"
if grep -qE ':(80|443)\b' <<<"$listeners"; then
status_line 1 "${DICE_CONTAINER} is CONTENDING for :80/:443 with ${CADDY_CONTAINER}"
else
status_line 0 "${DICE_CONTAINER} listens only on ${DICE_PORT}"
fi
else
status_line 1 "${DICE_CONTAINER} container not running"
fi
status_line "$(check_http "http://127.0.0.1:${DICE_PORT}/" "$DICE_DOMAIN")" \
"local static server (127.0.0.1:${DICE_PORT}, Host: ${DICE_DOMAIN})"
if [[ -f $DICE_SNIPPET ]]; then
status_line 0 "Caddy snippet present"
else
status_line 1 "Caddy snippet missing"
fi
if getent hosts "$DICE_DOMAIN" >/dev/null; then
status_line 0 "${DICE_DOMAIN} resolves"
else
status_line 1 "${DICE_DOMAIN} does not resolve"
fi
status_line "$(check_http "https://${DICE_DOMAIN}/")" \
"external https://${DICE_DOMAIN}/"
# The hook lives in .git/hooks, which is not version-controlled, so it is
# lost on a fresh clone and must be checked rather than assumed. Spelled as
# an explicit if: with two conditions, A && B || C really would misfire.
if [[ -x $HOOK_FILE ]] && grep -q "$UNIT_NAME" "$HOOK_FILE" 2>/dev/null; then
status_line 0 "post-commit auto-rebuild hook installed"
else
status_line 1 "post-commit hook missing — re-run setup"
fi
if [[ -f $UNIT_FILE ]]; then
status_line 0 "${UNIT_NAME} installed"
else
status_line 1 "${UNIT_NAME} missing — re-run setup"
fi
if systemctl --user is-failed --quiet "$UNIT_NAME"; then
status_line 1 "last auto-rebuild FAILED — journalctl --user -u ${UNIT_NAME}"
else
status_line 0 "no failed auto-rebuild recorded"
fi
# "not failed" also covers "never ran", so check the artefact rather than the
# unit: if any source file is newer than the published bundle, the hook is
# not firing and the site is quietly serving an old build.
local newest_src
newest_src="$(find "${DICE_SRC}/src" "${DICE_SRC}/index.html" -type f -newer \
"${DICE_SRC}/dist/index.html" -print -quit 2>/dev/null || true)"
if [[ -n $newest_src ]]; then
status_line 1 "published bundle is STALE (e.g. ${newest_src#"${DICE_SRC}/"}) — run: systemctl --user start ${UNIT_NAME}"
else
status_line 0 "published bundle is up to date with src/"
fi
}
# shellcheck source=lib/kcd2_serve.sh
source "$SCRIPT_DIR/lib/kcd2_serve.sh"
# shellcheck source=lib/kcd2_expose.sh
source "$SCRIPT_DIR/lib/kcd2_expose.sh"
# shellcheck source=lib/kcd2_autorebuild.sh
source "$SCRIPT_DIR/lib/kcd2_autorebuild.sh"
main() {
local cmd="${1:-setup}"

View File

@ -145,288 +145,11 @@ fix_nm_wait_online() {
return 0
}
# ===================================================================
# Fix 2: Sysctl tuning (swappiness, VFS cache, dirty pages)
# ===================================================================
fix_sysctl_tuning() {
local sysctl_file="/etc/sysctl.d/99-performance-tuning.conf"
# shellcheck source=lib/ubuntu_perf_fixes.sh
source "$SCRIPT_DIR/lib/ubuntu_perf_fixes.sh"
# shellcheck source=lib/ubuntu_perf_more.sh
source "$SCRIPT_DIR/lib/ubuntu_perf_more.sh"
if [[ -f $sysctl_file ]]; then
log_ok "Sysctl performance tuning already applied — skipping."
return 0
fi
# Save current values for undo
local cur_swappiness cur_vfs cur_dirty_ratio cur_dirty_bg
cur_swappiness=$(sysctl -n vm.swappiness 2>/dev/null || echo 60)
cur_vfs=$(sysctl -n vm.vfs_cache_pressure 2>/dev/null || echo 100)
cur_dirty_ratio=$(sysctl -n vm.dirty_ratio 2>/dev/null || echo 20)
cur_dirty_bg=$(sysctl -n vm.dirty_background_ratio 2>/dev/null || echo 10)
cat >"$sysctl_file" <<'SYSCTL'
# Performance tuning for Ubuntu laptop with 32GB RAM + NVMe SSD
# Created by fix_ubuntu_performance.sh
#
# vm.swappiness=10: Prefer keeping data in RAM over swapping (32GB is plenty)
# vm.vfs_cache_pressure=50: Keep filesystem dentries/inodes cached longer (helps dev work)
# vm.dirty_ratio=15: Allow more dirty pages before forced writeback (NVMe handles bursts)
# vm.dirty_background_ratio=5: Start background writeback earlier for smoother I/O
vm.swappiness = 10
vm.vfs_cache_pressure = 50
vm.dirty_ratio = 15
vm.dirty_background_ratio = 5
SYSCTL
# Apply immediately
sysctl --system >/dev/null 2>&1
add_undo "# Undo: Remove sysctl tuning, restore defaults"
add_undo "rm -f /etc/sysctl.d/99-performance-tuning.conf"
add_undo "sysctl -w vm.swappiness=$cur_swappiness vm.vfs_cache_pressure=$cur_vfs vm.dirty_ratio=$cur_dirty_ratio vm.dirty_background_ratio=$cur_dirty_bg >/dev/null"
add_undo ""
return 0
}
# ===================================================================
# Fix 3: NVIDIA persistence mode via systemd service
# ===================================================================
fix_nvidia_persistence() {
local service_file="/etc/systemd/system/nvidia-persistence.service"
# Check if persistence is already on
if nvidia-smi -q 2>/dev/null | grep -q "Persistence Mode.*Enabled"; then
log_ok "NVIDIA persistence mode is already enabled — skipping."
return 0
fi
# On Ubuntu, nvidia-persistenced.service is "static" (no [Install] section)
# and starts with --no-persistence-mode. We create a small helper service
# that runs `nvidia-smi -pm 1` after the daemon is up.
local helper_svc="/etc/systemd/system/nvidia-persistence-mode.service"
if [[ -f $helper_svc ]] && systemctl is-enabled nvidia-persistence-mode.service >/dev/null 2>&1; then
# Already set up — just make sure it's active this boot
if ! nvidia-smi -q 2>/dev/null | grep -q "Persistence Mode.*Enabled"; then
systemctl start nvidia-persistence-mode.service 2>/dev/null || true
fi
log_ok "NVIDIA persistence mode helper already configured."
return 0
fi
if command -v nvidia-persistenced >/dev/null 2>&1; then
# Ensure the daemon is running
systemctl start nvidia-persistenced.service 2>/dev/null || true
# Create a proper service with [Install] that runs nvidia-smi -pm 1
cat >"$helper_svc" <<'NVSVC'
[Unit]
Description=Enable NVIDIA Persistence Mode
After=nvidia-persistenced.service
Requires=nvidia-persistenced.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/nvidia-smi -pm 1
ExecStop=/usr/bin/nvidia-smi -pm 0
[Install]
WantedBy=multi-user.target
NVSVC
systemctl daemon-reload
systemctl enable --now nvidia-persistence-mode.service
add_undo "# Undo: Remove NVIDIA persistence mode helper service"
add_undo "systemctl disable --now nvidia-persistence-mode.service 2>/dev/null || true"
add_undo "rm -f /etc/systemd/system/nvidia-persistence-mode.service"
add_undo "nvidia-smi -pm 0 2>/dev/null || true"
add_undo "systemctl daemon-reload"
add_undo ""
else
# Fall back to a simple systemd service using nvidia-smi
cat >"$service_file" <<'NVSVC'
[Unit]
Description=NVIDIA Persistence Mode
After=nvidia.target
Requires=nvidia.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/nvidia-smi -pm 1
ExecStop=/usr/bin/nvidia-smi -pm 0
[Install]
WantedBy=multi-user.target
NVSVC
systemctl daemon-reload
systemctl enable --now nvidia-persistence.service
add_undo "# Undo: Remove NVIDIA persistence service"
add_undo "systemctl disable --now nvidia-persistence.service 2>/dev/null || true"
add_undo "rm -f /etc/systemd/system/nvidia-persistence.service"
add_undo "systemctl daemon-reload"
add_undo ""
fi
return 0
}
# ===================================================================
# Fix 4: Install and enable earlyoom
# ===================================================================
fix_earlyoom() {
if systemctl is-active earlyoom.service >/dev/null 2>&1; then
log_ok "earlyoom is already running — skipping."
return 0
fi
if ! dpkg -l earlyoom 2>/dev/null | grep -q '^ii'; then
log_info "Installing earlyoom..."
apt-get install -y earlyoom >/dev/null 2>&1
fi
# Configure earlyoom: kill at 5% free RAM / 10% free swap
local earlyoom_conf="/etc/default/earlyoom"
if [[ -f $earlyoom_conf ]]; then
cp "$earlyoom_conf" "${earlyoom_conf}.bak"
fi
cat >"$earlyoom_conf" <<'EARLYOOM'
# earlyoom configuration - prevent OOM hard-freezes
# Created by fix_ubuntu_performance.sh
# -r 5 = act when free RAM drops below 5%
# -s 10 = act when free swap drops below 10%
# -n = send SIGTERM first (graceful), then SIGKILL
# --prefer="(firefox|chromium|chrome)" = prefer killing browsers (they recover well)
EARLYOOM_ARGS="-r 5 -s 10 -n --prefer '(firefox|chromium|chrome)'"
EARLYOOM
systemctl enable --now earlyoom.service
add_undo "# Undo: Disable and remove earlyoom"
add_undo "systemctl disable --now earlyoom.service 2>/dev/null || true"
add_undo "apt-get remove -y earlyoom >/dev/null 2>&1 || true"
add_undo ""
return 0
}
# ===================================================================
# Fix 5: Mask failed SSSD units (not needed on non-domain laptops)
# ===================================================================
fix_failed_sssd() {
local sssd_units=(
sssd-pac.service
sssd-nss.socket
sssd-pac.socket
sssd-pam-priv.socket
sssd-pam.socket
)
local any_failed=false
for unit in "${sssd_units[@]}"; do
if systemctl is-failed "$unit" >/dev/null 2>&1; then
any_failed=true
break
fi
done
if [[ $any_failed == "false" ]]; then
log_ok "No failed SSSD units — skipping."
return 0
fi
add_undo "# Undo: Unmask SSSD units"
for unit in "${sssd_units[@]}"; do
if systemctl is-failed "$unit" >/dev/null 2>&1; then
systemctl stop "$unit" 2>/dev/null || true
systemctl mask "$unit"
log_info "Masked $unit"
add_undo "systemctl unmask $unit"
fi
done
systemctl reset-failed 2>/dev/null || true
add_undo ""
return 0
}
# ===================================================================
# Fix 6: Journal vacuum + permanent size cap
# ===================================================================
fix_journal() {
# Create permanent size cap via drop-in
local dropin_dir="/etc/systemd/journald.conf.d"
local dropin_file="$dropin_dir/size-limit.conf"
if [[ -f $dropin_file ]] && grep -q 'SystemMaxUse=300M' "$dropin_file"; then
log_ok "Journal size cap already configured — skipping."
return 0
fi
mkdir -p "$dropin_dir"
cat >"$dropin_file" <<'JOURNALEOF'
[Journal]
SystemMaxUse=300M
JOURNALEOF
# Vacuum existing logs
journalctl --vacuum-size=300M 2>/dev/null || true
systemctl restart systemd-journald
add_undo "# Undo: Remove journal size cap"
add_undo "rm -f /etc/systemd/journald.conf.d/size-limit.conf"
add_undo "systemctl restart systemd-journald"
add_undo ""
return 0
}
# ===================================================================
# Fix 7: Disable snap-related boot slowness (optional but impactful)
# ===================================================================
fix_snap_startup() {
# Disable snapd.snap-repair.timer - not critical, runs periodically
if systemctl is-enabled snapd.snap-repair.timer >/dev/null 2>&1; then
systemctl disable snapd.snap-repair.timer
systemctl stop snapd.snap-repair.timer 2>/dev/null || true
add_undo "# Undo: Re-enable snap repair timer"
add_undo "systemctl enable snapd.snap-repair.timer"
add_undo ""
else
log_ok "snapd.snap-repair.timer already disabled — skipping."
fi
return 0
}
# ===================================================================
# Undo mode: run the most recent undo script
# ===================================================================
run_undo() {
local latest_undo
# shellcheck disable=SC2012
latest_undo=$(ls -1t /root/undo_ubuntu_performance_*.sh 2>/dev/null | head -1)
if [[ -z ${latest_undo:-} ]]; then
log_error "No undo script found in /root/"
exit 1
fi
log_info "Running undo script: $latest_undo"
bash "$latest_undo"
log_ok "All changes reversed."
log_info "Reboot recommended to ensure all changes take effect."
exit 0
}
# ===================================================================
# Apply all fixes
# ===================================================================
main() {
if [[ $UNDO_MODE == "true" ]]; then
run_undo

View File

@ -0,0 +1,215 @@
#!/bin/bash
# sysctl, NVIDIA persistence and earlyoom performance fixes.
#
# Sourced by fix_ubuntu_performance.sh; split out to keep it under the 250-line cap.
# Sourced rather than run, so it inherits the caller's strict mode and
# the helper functions and variables defined above the source line.
# ===================================================================
# Fix 2: Sysctl tuning (swappiness, VFS cache, dirty pages)
# ===================================================================
fix_sysctl_tuning() {
local sysctl_file="/etc/sysctl.d/99-performance-tuning.conf"
if [[ -f $sysctl_file ]]; then
log_ok "Sysctl performance tuning already applied — skipping."
return 0
fi
# Save current values for undo
local cur_swappiness cur_vfs cur_dirty_ratio cur_dirty_bg
cur_swappiness=$(sysctl -n vm.swappiness 2>/dev/null || echo 60)
cur_vfs=$(sysctl -n vm.vfs_cache_pressure 2>/dev/null || echo 100)
cur_dirty_ratio=$(sysctl -n vm.dirty_ratio 2>/dev/null || echo 20)
cur_dirty_bg=$(sysctl -n vm.dirty_background_ratio 2>/dev/null || echo 10)
cat >"$sysctl_file" <<'SYSCTL'
# Performance tuning for Ubuntu laptop with 32GB RAM + NVMe SSD
# Created by fix_ubuntu_performance.sh
#
# vm.swappiness=10: Prefer keeping data in RAM over swapping (32GB is plenty)
# vm.vfs_cache_pressure=50: Keep filesystem dentries/inodes cached longer (helps dev work)
# vm.dirty_ratio=15: Allow more dirty pages before forced writeback (NVMe handles bursts)
# vm.dirty_background_ratio=5: Start background writeback earlier for smoother I/O
vm.swappiness = 10
vm.vfs_cache_pressure = 50
vm.dirty_ratio = 15
vm.dirty_background_ratio = 5
SYSCTL
# Apply immediately
sysctl --system >/dev/null 2>&1
add_undo "# Undo: Remove sysctl tuning, restore defaults"
add_undo "rm -f /etc/sysctl.d/99-performance-tuning.conf"
add_undo "sysctl -w vm.swappiness=$cur_swappiness vm.vfs_cache_pressure=$cur_vfs vm.dirty_ratio=$cur_dirty_ratio vm.dirty_background_ratio=$cur_dirty_bg >/dev/null"
add_undo ""
return 0
}
# ===================================================================
# Fix 3: NVIDIA persistence mode via systemd service
# ===================================================================
fix_nvidia_persistence() {
local service_file="/etc/systemd/system/nvidia-persistence.service"
# Check if persistence is already on
if nvidia-smi -q 2>/dev/null | grep -q "Persistence Mode.*Enabled"; then
log_ok "NVIDIA persistence mode is already enabled — skipping."
return 0
fi
# On Ubuntu, nvidia-persistenced.service is "static" (no [Install] section)
# and starts with --no-persistence-mode. We create a small helper service
# that runs `nvidia-smi -pm 1` after the daemon is up.
local helper_svc="/etc/systemd/system/nvidia-persistence-mode.service"
if [[ -f $helper_svc ]] && systemctl is-enabled nvidia-persistence-mode.service >/dev/null 2>&1; then
# Already set up — just make sure it's active this boot
if ! nvidia-smi -q 2>/dev/null | grep -q "Persistence Mode.*Enabled"; then
systemctl start nvidia-persistence-mode.service 2>/dev/null || true
fi
log_ok "NVIDIA persistence mode helper already configured."
return 0
fi
if command -v nvidia-persistenced >/dev/null 2>&1; then
# Ensure the daemon is running
systemctl start nvidia-persistenced.service 2>/dev/null || true
# Create a proper service with [Install] that runs nvidia-smi -pm 1
cat >"$helper_svc" <<'NVSVC'
[Unit]
Description=Enable NVIDIA Persistence Mode
After=nvidia-persistenced.service
Requires=nvidia-persistenced.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/nvidia-smi -pm 1
ExecStop=/usr/bin/nvidia-smi -pm 0
[Install]
WantedBy=multi-user.target
NVSVC
systemctl daemon-reload
systemctl enable --now nvidia-persistence-mode.service
add_undo "# Undo: Remove NVIDIA persistence mode helper service"
add_undo "systemctl disable --now nvidia-persistence-mode.service 2>/dev/null || true"
add_undo "rm -f /etc/systemd/system/nvidia-persistence-mode.service"
add_undo "nvidia-smi -pm 0 2>/dev/null || true"
add_undo "systemctl daemon-reload"
add_undo ""
else
# Fall back to a simple systemd service using nvidia-smi
cat >"$service_file" <<'NVSVC'
[Unit]
Description=NVIDIA Persistence Mode
After=nvidia.target
Requires=nvidia.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/nvidia-smi -pm 1
ExecStop=/usr/bin/nvidia-smi -pm 0
[Install]
WantedBy=multi-user.target
NVSVC
systemctl daemon-reload
systemctl enable --now nvidia-persistence.service
add_undo "# Undo: Remove NVIDIA persistence service"
add_undo "systemctl disable --now nvidia-persistence.service 2>/dev/null || true"
add_undo "rm -f /etc/systemd/system/nvidia-persistence.service"
add_undo "systemctl daemon-reload"
add_undo ""
fi
return 0
}
# ===================================================================
# Fix 4: Install and enable earlyoom
# ===================================================================
fix_earlyoom() {
if systemctl is-active earlyoom.service >/dev/null 2>&1; then
log_ok "earlyoom is already running — skipping."
return 0
fi
if ! dpkg -l earlyoom 2>/dev/null | grep -q '^ii'; then
log_info "Installing earlyoom..."
apt-get install -y earlyoom >/dev/null 2>&1
fi
# Configure earlyoom: kill at 5% free RAM / 10% free swap
local earlyoom_conf="/etc/default/earlyoom"
if [[ -f $earlyoom_conf ]]; then
cp "$earlyoom_conf" "${earlyoom_conf}.bak"
fi
cat >"$earlyoom_conf" <<'EARLYOOM'
# earlyoom configuration - prevent OOM hard-freezes
# Created by fix_ubuntu_performance.sh
# -r 5 = act when free RAM drops below 5%
# -s 10 = act when free swap drops below 10%
# -n = send SIGTERM first (graceful), then SIGKILL
# --prefer="(firefox|chromium|chrome)" = prefer killing browsers (they recover well)
EARLYOOM_ARGS="-r 5 -s 10 -n --prefer '(firefox|chromium|chrome)'"
EARLYOOM
systemctl enable --now earlyoom.service
add_undo "# Undo: Disable and remove earlyoom"
add_undo "systemctl disable --now earlyoom.service 2>/dev/null || true"
add_undo "apt-get remove -y earlyoom >/dev/null 2>&1 || true"
add_undo ""
return 0
}
# ===================================================================
# Fix 5: Mask failed SSSD units (not needed on non-domain laptops)
# ===================================================================
fix_failed_sssd() {
local sssd_units=(
sssd-pac.service
sssd-nss.socket
sssd-pac.socket
sssd-pam-priv.socket
sssd-pam.socket
)
local any_failed=false
for unit in "${sssd_units[@]}"; do
if systemctl is-failed "$unit" >/dev/null 2>&1; then
any_failed=true
break
fi
done
if [[ $any_failed == "false" ]]; then
log_ok "No failed SSSD units — skipping."
return 0
fi
add_undo "# Undo: Unmask SSSD units"
for unit in "${sssd_units[@]}"; do
if systemctl is-failed "$unit" >/dev/null 2>&1; then
systemctl stop "$unit" 2>/dev/null || true
systemctl mask "$unit"
log_info "Masked $unit"
add_undo "systemctl unmask $unit"
fi
done
systemctl reset-failed 2>/dev/null || true
add_undo ""
return 0
}

View File

@ -0,0 +1,80 @@
#!/bin/bash
# Journal, sssd and snap-startup performance fixes.
#
# Sourced by fix_ubuntu_performance.sh; split out to keep ubuntu_perf_fixes.sh
# under the 250-line cap. Sourced rather than run, so it inherits the
# caller's strict mode and the variables above the source line.
# ===================================================================
# Fix 6: Journal vacuum + permanent size cap
# ===================================================================
fix_journal() {
# Create permanent size cap via drop-in
local dropin_dir="/etc/systemd/journald.conf.d"
local dropin_file="$dropin_dir/size-limit.conf"
if [[ -f $dropin_file ]] && grep -q 'SystemMaxUse=300M' "$dropin_file"; then
log_ok "Journal size cap already configured — skipping."
return 0
fi
mkdir -p "$dropin_dir"
cat >"$dropin_file" <<'JOURNALEOF'
[Journal]
SystemMaxUse=300M
JOURNALEOF
# Vacuum existing logs
journalctl --vacuum-size=300M 2>/dev/null || true
systemctl restart systemd-journald
add_undo "# Undo: Remove journal size cap"
add_undo "rm -f /etc/systemd/journald.conf.d/size-limit.conf"
add_undo "systemctl restart systemd-journald"
add_undo ""
return 0
}
# ===================================================================
# Fix 7: Disable snap-related boot slowness (optional but impactful)
# ===================================================================
fix_snap_startup() {
# Disable snapd.snap-repair.timer - not critical, runs periodically
if systemctl is-enabled snapd.snap-repair.timer >/dev/null 2>&1; then
systemctl disable snapd.snap-repair.timer
systemctl stop snapd.snap-repair.timer 2>/dev/null || true
add_undo "# Undo: Re-enable snap repair timer"
add_undo "systemctl enable snapd.snap-repair.timer"
add_undo ""
else
log_ok "snapd.snap-repair.timer already disabled — skipping."
fi
return 0
}
# ===================================================================
# Undo mode: run the most recent undo script
# ===================================================================
run_undo() {
local latest_undo
# shellcheck disable=SC2012
latest_undo=$(ls -1t /root/undo_ubuntu_performance_*.sh 2>/dev/null | head -1)
if [[ -z ${latest_undo:-} ]]; then
log_error "No undo script found in /root/"
exit 1
fi
log_info "Running undo script: $latest_undo"
bash "$latest_undo"
log_ok "All changes reversed."
log_info "Reboot recommended to ensure all changes take effect."
exit 0
}
# ===================================================================
# Apply all fixes
# ===================================================================

View File

@ -0,0 +1,29 @@
#!/bin/bash
# Media-type classification for the download sweep.
#
# Sourced by organize_downloads.sh; split out to keep it under the 250-line cap.
# Sourced rather than run, so it inherits the caller's strict mode and
# the helper functions and variables defined above the source line.
# Function to check if file has media extension (zero forks: bash 4 ${var,,})
is_media_file() {
local file="$1"
local extension="${file##*.}"
extension="${extension,,}"
# Check if it's an image
for ext in "${IMAGE_EXTENSIONS[@]}"; do
if [[ $extension == "$ext" ]]; then
return 0
fi
done
# Check if it's a video
for ext in "${VIDEO_EXTENSIONS[@]}"; do
if [[ $extension == "$ext" ]]; then
return 0
fi
done
return 1
}

View File

@ -0,0 +1,203 @@
#!/bin/bash
# Media discovery, archiving and the per-category moves.
#
# Sourced by organize_downloads.sh; split out to keep it under the 250-line cap.
# Sourced rather than run, so it inherits the caller's strict mode and
# the helper functions and variables defined above the source line.
# Function to find media files in a directory (non-recursive for home, avoid common system dirs)
find_media_files() {
local search_dir="$1"
local files=()
# Directories to exclude under Downloads
local -a EXCLUDES=(
".git" ".hg" ".svn" ".cache" "node_modules" "dist" "build" "out" "target" "coverage" "__pycache__" "venv" ".venv"
# previous staging dirs created by this script
".media_organize_" "media_organize_"
# too_big folder for oversized files
"too_big"
)
if [[ $search_dir == "$HOME_DIR" ]]; then
# For home directory, only check files directly in ~ (not subdirectories)
# Exclude common system/config directories
while IFS= read -r -d '' file; do
local basename
basename=$(basename "$file")
# Skip hidden files and common system directories
if [[ ! $basename =~ ^\. ]] && [[ -f $file ]]; then
if is_media_file "$file"; then
files+=("$file")
fi
fi
done < <(find "$search_dir" -maxdepth 1 -type f -print0 2>/dev/null)
else
# For Downloads, search recursively, pruning excluded directories
# Build prune expression
local prune_expr=()
for ex in "${EXCLUDES[@]}"; do
prune_expr+=(-name "$ex*" -o)
done
# Remove trailing -o
unset 'prune_expr[${#prune_expr[@]}-1]'
while IFS= read -r -d '' file; do
if is_media_file "$file"; then
files+=("$file")
fi
done < <(find "$search_dir" \( -type d \( "${prune_expr[@]}" \) -prune \) -o -type f -print0 2>/dev/null)
fi
printf '%s\n' "${files[@]}"
}
# Function to create timestamped zip archive
create_media_archive() {
local files=("$@")
if [[ ${#files[@]} -eq 0 ]]; then
log "No media files found to archive."
return 0
fi
# Create timestamp for archive name
local timestamp
timestamp=$(date '+%Y%m%d_%H%M%S')
local archive_name="media_archive_${timestamp}.zip"
local archive_path="$DOWNLOADS_DIR/$archive_name"
# Create temporary directory (fallback to /tmp if needed)
if ! mkdir -p "$TEMP_DIR" 2>/dev/null; then
TEMP_DIR="/tmp/media_organize_$$"
mkdir -p "$TEMP_DIR"
fi
# Ensure temp dir is cleaned up on function return; trap unsets itself after running
trap 'rm -rf "$TEMP_DIR" 2>/dev/null || true; trap - RETURN' RETURN
log "Found ${#files[@]} media files to archive."
log "Creating archive: $archive_path"
# Copy files to temp directory maintaining relative structure
local successfully_copied=()
local copy_errors=0
for file in "${files[@]}"; do
if [[ -f $file ]]; then
local relative_path=""
if [[ $file == "$DOWNLOADS_DIR"* ]]; then
relative_path="downloads/${file#"$DOWNLOADS_DIR"/}"
else
relative_path="home/${file#"$HOME_DIR"/}"
fi
local temp_file="$TEMP_DIR/$relative_path"
local temp_dir
temp_dir=$(dirname "$temp_file")
mkdir -p "$temp_dir"
# Check readability first to provide a clearer error
if [[ ! -r $file ]]; then
log "WARNING: Cannot read $file (permission denied?)"
((copy_errors++)) || true
continue
fi
# Attempt copy and capture any error for logging
local cp_err
if cp_err=$(cp -p "$file" "$temp_file" 2>&1); then
successfully_copied+=("$file")
else
# Surface the cp error so the user can see the reason
log "WARNING: Failed to copy $file -> $cp_err"
# Special hint for space issues
if echo "$cp_err" | grep -qi "No space left on device"; then
log "HINT: Not enough free space to stage files. Using $TEMP_DIR. Free up space or change TEMP_DIR."
fi
((copy_errors++)) || true
fi
fi
done
if [[ ${#successfully_copied[@]} -eq 0 ]]; then
log "ERROR: No files were successfully copied to temp directory."
rm -rf "$TEMP_DIR"
return 1
fi
if [[ $copy_errors -gt 0 ]]; then
log "WARNING: $copy_errors files failed to copy."
fi
# Create zip archive with maximum compression
log "Creating zip archive with ${#successfully_copied[@]} files..."
cd "$TEMP_DIR"
if zip -9 -r "$archive_path" . 2>&1; then
log "Successfully created archive with ${#successfully_copied[@]} files."
# Verify the zip file was actually created and is not empty
if [[ ! -f $archive_path ]]; then
log "ERROR: Archive file was not created at $archive_path"
rm -rf "$TEMP_DIR"
return 1
fi
local archive_size
archive_size=$(stat -c%s "$archive_path" 2>/dev/null || echo "0")
if [[ $archive_size -eq 0 ]]; then
log "ERROR: Archive file is empty"
rm -rf "$TEMP_DIR"
return 1
fi
# Remove original files only if zip was successful
local removed_count=0
local remove_errors=0
log "Starting to remove ${#successfully_copied[@]} original files..."
# Temporarily disable strict error handling for file removal
set +e
for file in "${successfully_copied[@]}"; do
if [[ -f $file ]]; then
if rm "$file" 2>/dev/null; then
removed_count=$((removed_count + 1))
log "Removed: $(basename "$file")"
else
remove_errors=$((remove_errors + 1))
log "ERROR: Failed to remove $(basename "$file")"
fi
else
log "WARNING: File no longer exists: $(basename "$file")"
fi
done
# Re-enable strict error handling
set -e
log "Successfully removed $removed_count original files."
if [[ $remove_errors -gt 0 ]]; then
log "WARNING: Failed to remove $remove_errors files."
fi
log "Archive size: $(du -h "$archive_path" | cut -f1)"
# Cleanup temp directory (trap will also attempt, which is safe)
rm -rf "$TEMP_DIR"
# Return success only if we removed files or there were no files to remove
if [[ $removed_count -gt 0 ]] || [[ ${#successfully_copied[@]} -eq 0 ]]; then
return 0
else
log "ERROR: Failed to remove any files after successful archive creation."
return 1
fi
else
log "ERROR: Failed to create archive. Original files preserved."
log "Zip command failed."
rm -rf "$TEMP_DIR"
return 1
fi
}
# Main execution

View File

@ -0,0 +1,59 @@
#!/bin/bash
# Size thresholds and the oversized-file moves.
#
# Sourced by organize_downloads.sh; split out to keep it under the 250-line cap.
# Sourced rather than run, so it inherits the caller's strict mode and
# the helper functions and variables defined above the source line.
# Function to check if file is too big for archiving
is_too_big() {
local file="$1"
local size
size=$(stat -c%s "$file" 2>/dev/null || echo "0")
[[ $size -gt $SIZE_THRESHOLD ]]
}
# Function to move oversized files to too_big directory
move_big_files() {
local files=("$@")
local moved_count=0
if [[ ${#files[@]} -eq 0 ]]; then
return 0
fi
# Create too_big directory if it doesn't exist
mkdir -p "$TOO_BIG_DIR"
log "Moving ${#files[@]} oversized files to $TOO_BIG_DIR..."
for file in "${files[@]}"; do
if [[ -f $file ]]; then
local basename
basename=$(basename "$file")
local dest="$TOO_BIG_DIR/$basename"
# Handle filename collision
if [[ -f $dest ]]; then
local timestamp
timestamp=$(date '+%Y%m%d_%H%M%S')
local name="${basename%.*}"
local ext="${basename##*.}"
if [[ $name == "$ext" ]]; then
dest="$TOO_BIG_DIR/${name}_${timestamp}"
else
dest="$TOO_BIG_DIR/${name}_${timestamp}.${ext}"
fi
fi
if mv "$file" "$dest" 2>/dev/null; then
log "Moved (too big): $(basename "$file") -> $dest"
moved_count=$((moved_count + 1))
else
log "ERROR: Failed to move $(basename "$file")"
fi
fi
done
log "Successfully moved $moved_count oversized files."
}

View File

@ -67,278 +67,14 @@ while [[ ${1:-} =~ ^- ]]; do
esac
done
# Function to check if file has media extension (zero forks: bash 4 ${var,,})
is_media_file() {
local file="$1"
local extension="${file##*.}"
extension="${extension,,}"
SCRIPT_DIR="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
# shellcheck source=lib/download_classify.sh
source "$SCRIPT_DIR/lib/download_classify.sh"
# shellcheck source=lib/download_sizes.sh
source "$SCRIPT_DIR/lib/download_sizes.sh"
# shellcheck source=lib/download_media.sh
source "$SCRIPT_DIR/lib/download_media.sh"
# Check if it's an image
for ext in "${IMAGE_EXTENSIONS[@]}"; do
if [[ $extension == "$ext" ]]; then
return 0
fi
done
# Check if it's a video
for ext in "${VIDEO_EXTENSIONS[@]}"; do
if [[ $extension == "$ext" ]]; then
return 0
fi
done
return 1
}
# Function to check if file is too big for archiving
is_too_big() {
local file="$1"
local size
size=$(stat -c%s "$file" 2>/dev/null || echo "0")
[[ $size -gt $SIZE_THRESHOLD ]]
}
# Function to move oversized files to too_big directory
move_big_files() {
local files=("$@")
local moved_count=0
if [[ ${#files[@]} -eq 0 ]]; then
return 0
fi
# Create too_big directory if it doesn't exist
mkdir -p "$TOO_BIG_DIR"
log "Moving ${#files[@]} oversized files to $TOO_BIG_DIR..."
for file in "${files[@]}"; do
if [[ -f $file ]]; then
local basename
basename=$(basename "$file")
local dest="$TOO_BIG_DIR/$basename"
# Handle filename collision
if [[ -f $dest ]]; then
local timestamp
timestamp=$(date '+%Y%m%d_%H%M%S')
local name="${basename%.*}"
local ext="${basename##*.}"
if [[ $name == "$ext" ]]; then
dest="$TOO_BIG_DIR/${name}_${timestamp}"
else
dest="$TOO_BIG_DIR/${name}_${timestamp}.${ext}"
fi
fi
if mv "$file" "$dest" 2>/dev/null; then
log "Moved (too big): $(basename "$file") -> $dest"
moved_count=$((moved_count + 1))
else
log "ERROR: Failed to move $(basename "$file")"
fi
fi
done
log "Successfully moved $moved_count oversized files."
}
# Function to find media files in a directory (non-recursive for home, avoid common system dirs)
find_media_files() {
local search_dir="$1"
local files=()
# Directories to exclude under Downloads
local -a EXCLUDES=(
".git" ".hg" ".svn" ".cache" "node_modules" "dist" "build" "out" "target" "coverage" "__pycache__" "venv" ".venv"
# previous staging dirs created by this script
".media_organize_" "media_organize_"
# too_big folder for oversized files
"too_big"
)
if [[ $search_dir == "$HOME_DIR" ]]; then
# For home directory, only check files directly in ~ (not subdirectories)
# Exclude common system/config directories
while IFS= read -r -d '' file; do
local basename
basename=$(basename "$file")
# Skip hidden files and common system directories
if [[ ! $basename =~ ^\. ]] && [[ -f $file ]]; then
if is_media_file "$file"; then
files+=("$file")
fi
fi
done < <(find "$search_dir" -maxdepth 1 -type f -print0 2>/dev/null)
else
# For Downloads, search recursively, pruning excluded directories
# Build prune expression
local prune_expr=()
for ex in "${EXCLUDES[@]}"; do
prune_expr+=(-name "$ex*" -o)
done
# Remove trailing -o
unset 'prune_expr[${#prune_expr[@]}-1]'
while IFS= read -r -d '' file; do
if is_media_file "$file"; then
files+=("$file")
fi
done < <(find "$search_dir" \( -type d \( "${prune_expr[@]}" \) -prune \) -o -type f -print0 2>/dev/null)
fi
printf '%s\n' "${files[@]}"
}
# Function to create timestamped zip archive
create_media_archive() {
local files=("$@")
if [[ ${#files[@]} -eq 0 ]]; then
log "No media files found to archive."
return 0
fi
# Create timestamp for archive name
local timestamp
timestamp=$(date '+%Y%m%d_%H%M%S')
local archive_name="media_archive_${timestamp}.zip"
local archive_path="$DOWNLOADS_DIR/$archive_name"
# Create temporary directory (fallback to /tmp if needed)
if ! mkdir -p "$TEMP_DIR" 2>/dev/null; then
TEMP_DIR="/tmp/media_organize_$$"
mkdir -p "$TEMP_DIR"
fi
# Ensure temp dir is cleaned up on function return; trap unsets itself after running
trap 'rm -rf "$TEMP_DIR" 2>/dev/null || true; trap - RETURN' RETURN
log "Found ${#files[@]} media files to archive."
log "Creating archive: $archive_path"
# Copy files to temp directory maintaining relative structure
local successfully_copied=()
local copy_errors=0
for file in "${files[@]}"; do
if [[ -f $file ]]; then
local relative_path=""
if [[ $file == "$DOWNLOADS_DIR"* ]]; then
relative_path="downloads/${file#"$DOWNLOADS_DIR"/}"
else
relative_path="home/${file#"$HOME_DIR"/}"
fi
local temp_file="$TEMP_DIR/$relative_path"
local temp_dir
temp_dir=$(dirname "$temp_file")
mkdir -p "$temp_dir"
# Check readability first to provide a clearer error
if [[ ! -r $file ]]; then
log "WARNING: Cannot read $file (permission denied?)"
((copy_errors++)) || true
continue
fi
# Attempt copy and capture any error for logging
local cp_err
if cp_err=$(cp -p "$file" "$temp_file" 2>&1); then
successfully_copied+=("$file")
else
# Surface the cp error so the user can see the reason
log "WARNING: Failed to copy $file -> $cp_err"
# Special hint for space issues
if echo "$cp_err" | grep -qi "No space left on device"; then
log "HINT: Not enough free space to stage files. Using $TEMP_DIR. Free up space or change TEMP_DIR."
fi
((copy_errors++)) || true
fi
fi
done
if [[ ${#successfully_copied[@]} -eq 0 ]]; then
log "ERROR: No files were successfully copied to temp directory."
rm -rf "$TEMP_DIR"
return 1
fi
if [[ $copy_errors -gt 0 ]]; then
log "WARNING: $copy_errors files failed to copy."
fi
# Create zip archive with maximum compression
log "Creating zip archive with ${#successfully_copied[@]} files..."
cd "$TEMP_DIR"
if zip -9 -r "$archive_path" . 2>&1; then
log "Successfully created archive with ${#successfully_copied[@]} files."
# Verify the zip file was actually created and is not empty
if [[ ! -f $archive_path ]]; then
log "ERROR: Archive file was not created at $archive_path"
rm -rf "$TEMP_DIR"
return 1
fi
local archive_size
archive_size=$(stat -c%s "$archive_path" 2>/dev/null || echo "0")
if [[ $archive_size -eq 0 ]]; then
log "ERROR: Archive file is empty"
rm -rf "$TEMP_DIR"
return 1
fi
# Remove original files only if zip was successful
local removed_count=0
local remove_errors=0
log "Starting to remove ${#successfully_copied[@]} original files..."
# Temporarily disable strict error handling for file removal
set +e
for file in "${successfully_copied[@]}"; do
if [[ -f $file ]]; then
if rm "$file" 2>/dev/null; then
removed_count=$((removed_count + 1))
log "Removed: $(basename "$file")"
else
remove_errors=$((remove_errors + 1))
log "ERROR: Failed to remove $(basename "$file")"
fi
else
log "WARNING: File no longer exists: $(basename "$file")"
fi
done
# Re-enable strict error handling
set -e
log "Successfully removed $removed_count original files."
if [[ $remove_errors -gt 0 ]]; then
log "WARNING: Failed to remove $remove_errors files."
fi
log "Archive size: $(du -h "$archive_path" | cut -f1)"
# Cleanup temp directory (trap will also attempt, which is safe)
rm -rf "$TEMP_DIR"
# Return success only if we removed files or there were no files to remove
if [[ $removed_count -gt 0 ]] || [[ ${#successfully_copied[@]} -eq 0 ]]; then
return 0
else
log "ERROR: Failed to remove any files after successful archive creation."
return 1
fi
else
log "ERROR: Failed to create archive. Original files preserved."
log "Zip command failed."
rm -rf "$TEMP_DIR"
return 1
fi
}
# Main execution
main() {
log "Starting media file organization..."