Skip to content

Commit d5d923f

Browse files
committed
feat: adds frontend build fingerprinting to Decypharr
This change introduces fingerprinting to the Decypharr frontend build process. It calculates a hash based on the input files (package.json, pnpm-lock.yaml, assets directory, and minify script) and compares it with a stored fingerprint from the previous build. If the fingerprints match, the build step is skipped, improving build times. If there is a mis-match it will trigger a rebuild. It also adds logic to preserve frontend builds during clear on updates.
1 parent 9910c8e commit d5d923f

1 file changed

Lines changed: 120 additions & 3 deletions

File tree

utils/setup.py

Lines changed: 120 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from utils.traefik_setup import setup_traefik
88
from utils.user_management import chown_recursive, chown_single
99
import xml.etree.ElementTree as ET
10-
import os, shutil, random, subprocess, re, glob, secrets, shlex, time, urllib.parse, base64, threading, sys
10+
import os, shutil, random, subprocess, re, glob, secrets, shlex, time, urllib.parse, base64, threading, sys, hashlib
1111

1212

1313
user_id = CONFIG_MANAGER.get("puid")
@@ -269,6 +269,14 @@ def setup_branch_version(process_handler, config, process_name, key):
269269
exclude_dirs = None
270270
if config.get("clear_on_update"):
271271
exclude_dirs = config.get("exclude_dirs", [])
272+
if key == "decypharr":
273+
preserve_paths = [
274+
os.path.join(target_dir, "pkg", "server", "assets", "build"),
275+
os.path.join(target_dir, ".dumb_frontend_build_fingerprint"),
276+
]
277+
for preserve_path in preserve_paths:
278+
if preserve_path not in exclude_dirs:
279+
exclude_dirs.append(preserve_path)
272280
success, error = clear_directory(target_dir, exclude_dirs)
273281
if not success:
274282
return False, f"Failed to clear directory: {error}"
@@ -1961,7 +1969,9 @@ def _unmount_decypharr_mounts(config_path: str) -> tuple[bool, str | None]:
19611969
os.makedirs(decypharr_config_dir, exist_ok=True)
19621970
chown_single(decypharr_config_dir, user_id, group_id)
19631971

1964-
if (decypharr_embedded_rclone or decypharr_mount_type == "dfs") and decypharr_config_file:
1972+
if (
1973+
decypharr_embedded_rclone or decypharr_mount_type == "dfs"
1974+
) and decypharr_config_file:
19651975
_unmount_decypharr_mounts(decypharr_config_file)
19661976

19671977
force_release_install = False
@@ -1986,7 +1996,9 @@ def _unmount_decypharr_mounts(config_path: str) -> tuple[bool, str | None]:
19861996
except Exception as e:
19871997
logger.debug("Failed to read Decypharr version.txt: %s", e)
19881998

1989-
if not configure_only and (not os.path.isfile(binary_path) or force_release_install):
1999+
if not configure_only and (
2000+
not os.path.isfile(binary_path) or force_release_install
2001+
):
19902002
logger.warning(
19912003
f"Decypharr project not found at {decypharr_config_dir}. Downloading..."
19922004
)
@@ -5521,7 +5533,104 @@ def cleanup_pnpm_tmp():
55215533
scripts = package_data.get("scripts", {}) or {}
55225534
build_script = scripts.get("build")
55235535

5536+
def _hash_file(path: str) -> str:
5537+
if not os.path.isfile(path):
5538+
return ""
5539+
digest = hashlib.sha256()
5540+
with open(path, "rb") as handle:
5541+
while True:
5542+
chunk = handle.read(1024 * 1024)
5543+
if not chunk:
5544+
break
5545+
digest.update(chunk)
5546+
return digest.hexdigest()
5547+
5548+
def _hash_tree(root: str, ignore_dirs=None) -> str:
5549+
if not os.path.isdir(root):
5550+
return ""
5551+
ignore_dirs = set(ignore_dirs or [])
5552+
digest = hashlib.sha256()
5553+
for current_root, dirs, files in os.walk(root):
5554+
dirs[:] = sorted(
5555+
d for d in dirs if d not in ignore_dirs and not d.startswith(".")
5556+
)
5557+
rel_root = os.path.relpath(current_root, root)
5558+
digest.update(rel_root.encode("utf-8", errors="ignore"))
5559+
for filename in sorted(files):
5560+
file_path = os.path.join(current_root, filename)
5561+
rel_path = os.path.relpath(file_path, root)
5562+
digest.update(rel_path.encode("utf-8", errors="ignore"))
5563+
digest.update(
5564+
_hash_file(file_path).encode("utf-8", errors="ignore")
5565+
)
5566+
return digest.hexdigest()
5567+
5568+
def _frontend_outputs_exist() -> bool:
5569+
css_output = os.path.join(
5570+
config_dir, "pkg", "server", "assets", "build", "css", "styles.css"
5571+
)
5572+
js_output_dir = os.path.join(
5573+
config_dir, "pkg", "server", "assets", "build", "js"
5574+
)
5575+
if not os.path.isfile(css_output):
5576+
return False
5577+
if not os.path.isdir(js_output_dir):
5578+
return False
5579+
return any(name.endswith(".js") for name in os.listdir(js_output_dir))
5580+
5581+
def _frontend_fingerprint() -> str:
5582+
package_json = os.path.join(config_dir, "package.json")
5583+
lockfile = os.path.join(config_dir, "pnpm-lock.yaml")
5584+
assets_dir = os.path.join(config_dir, "pkg", "server", "assets")
5585+
minify_script = os.path.join(config_dir, "scripts", "minify-js.js")
5586+
if not os.path.isfile(package_json):
5587+
return ""
5588+
digest = hashlib.sha256()
5589+
digest.update(_hash_file(package_json).encode("utf-8", errors="ignore"))
5590+
digest.update(_hash_file(lockfile).encode("utf-8", errors="ignore"))
5591+
digest.update(
5592+
_hash_tree(assets_dir, ignore_dirs={"build"}).encode(
5593+
"utf-8", errors="ignore"
5594+
)
5595+
)
5596+
digest.update(_hash_file(minify_script).encode("utf-8", errors="ignore"))
5597+
return digest.hexdigest()
5598+
5599+
build_fingerprint_path = os.path.join(
5600+
config_dir, ".dumb_frontend_build_fingerprint"
5601+
)
5602+
current_fingerprint = _frontend_fingerprint()
5603+
should_skip_build = False
5604+
build_skip_reason = "fingerprint_unavailable"
5605+
outputs_exist = _frontend_outputs_exist()
5606+
if not current_fingerprint:
5607+
build_skip_reason = "fingerprint_unavailable"
5608+
elif not outputs_exist:
5609+
build_skip_reason = "outputs_missing"
5610+
elif current_fingerprint and outputs_exist:
5611+
try:
5612+
if os.path.isfile(build_fingerprint_path):
5613+
with open(build_fingerprint_path, "r") as handle:
5614+
previous_fingerprint = (handle.read() or "").strip()
5615+
if previous_fingerprint == current_fingerprint:
5616+
should_skip_build = True
5617+
build_skip_reason = "inputs_unchanged"
5618+
else:
5619+
build_skip_reason = "fingerprint_changed"
5620+
else:
5621+
build_skip_reason = "fingerprint_missing"
5622+
except Exception as e:
5623+
logger.debug("Failed reading frontend build fingerprint: %s", e)
5624+
build_skip_reason = "fingerprint_read_error"
5625+
55245626
if build_script:
5627+
logger.info(
5628+
"Frontend build decision: skip=%s reason=%s",
5629+
should_skip_build,
5630+
build_skip_reason,
5631+
)
5632+
5633+
if build_script and not should_skip_build:
55255634
logger.info("Build script found. Running pnpm build...")
55265635
if use_corepack_pnpm and "pnpm " in build_script:
55275636
script_names = []
@@ -5573,6 +5682,14 @@ def cleanup_pnpm_tmp():
55735682
process_handler.wait("pnpm_build")
55745683
if process_handler.returncode != 0:
55755684
return False, f"Error during pnpm build: {process_handler.stderr}"
5685+
if current_fingerprint:
5686+
try:
5687+
with open(build_fingerprint_path, "w") as handle:
5688+
handle.write(current_fingerprint)
5689+
except Exception as e:
5690+
logger.debug("Failed writing frontend build fingerprint: %s", e)
5691+
elif build_script and should_skip_build:
5692+
logger.info("Build script found. pnpm build skipped by fingerprint guard.")
55765693
else:
55775694
logger.info(f"No build script found. Skipping pnpm build step.")
55785695

0 commit comments

Comments
 (0)