77from utils .traefik_setup import setup_traefik
88from utils .user_management import chown_recursive , chown_single
99import 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
1313user_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