1
0

cache_manager.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863
  1. """Image Cache Manager for pre-generating and managing image previews."""
  2. import os
  3. import json
  4. import asyncio
  5. import logging
  6. from pathlib import Path
  7. from modules.core.pattern_manager import list_theta_rho_files, THETA_RHO_DIR, parse_theta_rho_file
  8. logger = logging.getLogger(__name__)
  9. # Global cache progress state
  10. cache_progress = {
  11. "is_running": False,
  12. "total_files": 0,
  13. "processed_files": 0,
  14. "current_file": "",
  15. "stage": "idle", # idle, metadata, images, complete
  16. "error": None
  17. }
  18. # Lock to prevent race conditions when writing to metadata cache
  19. # Multiple concurrent tasks (from asyncio.gather) can try to read-modify-write simultaneously
  20. # Lazily initialized to avoid "attached to a different loop" errors
  21. _metadata_cache_lock: "asyncio.Lock | None" = None
  22. def _get_metadata_cache_lock() -> asyncio.Lock:
  23. """Get or create the metadata cache lock in the current event loop."""
  24. global _metadata_cache_lock
  25. if _metadata_cache_lock is None:
  26. _metadata_cache_lock = asyncio.Lock()
  27. return _metadata_cache_lock
  28. # Constants
  29. CACHE_DIR = os.path.join(THETA_RHO_DIR, "cached_images")
  30. METADATA_CACHE_FILE = "metadata_cache.json" # Now in root directory
  31. # Cache schema version - increment when structure changes
  32. CACHE_SCHEMA_VERSION = 1
  33. # Expected cache schema structure
  34. EXPECTED_CACHE_SCHEMA = {
  35. 'version': CACHE_SCHEMA_VERSION,
  36. 'structure': {
  37. 'mtime': 'number',
  38. 'metadata': {
  39. 'first_coordinate': {'x': 'number', 'y': 'number'},
  40. 'last_coordinate': {'x': 'number', 'y': 'number'},
  41. 'total_coordinates': 'number'
  42. }
  43. }
  44. }
  45. def validate_cache_schema(cache_data):
  46. """Validate that cache data matches the expected schema structure."""
  47. try:
  48. # Check if version info exists
  49. if not isinstance(cache_data, dict):
  50. return False
  51. # Check for version field - if missing, it's old format
  52. cache_version = cache_data.get('version')
  53. if cache_version is None:
  54. logger.info("Cache file missing version info - treating as outdated schema")
  55. return False
  56. # Check if version matches current expected version
  57. if cache_version != CACHE_SCHEMA_VERSION:
  58. logger.info(f"Cache schema version mismatch: found {cache_version}, expected {CACHE_SCHEMA_VERSION}")
  59. return False
  60. # Check if data section exists
  61. if 'data' not in cache_data:
  62. logger.warning("Cache file missing 'data' section")
  63. return False
  64. # Validate structure of a few entries if they exist
  65. data_section = cache_data.get('data', {})
  66. if data_section and isinstance(data_section, dict):
  67. # Check first entry structure
  68. for pattern_file, entry in list(data_section.items())[:1]: # Just check first entry
  69. if not isinstance(entry, dict):
  70. return False
  71. if 'mtime' not in entry or 'metadata' not in entry:
  72. return False
  73. metadata = entry.get('metadata', {})
  74. required_fields = ['first_coordinate', 'last_coordinate', 'total_coordinates']
  75. if not all(field in metadata for field in required_fields):
  76. return False
  77. # Validate coordinate structure
  78. for coord_field in ['first_coordinate', 'last_coordinate']:
  79. coord = metadata.get(coord_field)
  80. if not isinstance(coord, dict) or 'x' not in coord or 'y' not in coord:
  81. return False
  82. return True
  83. except Exception as e:
  84. logger.warning(f"Error validating cache schema: {str(e)}")
  85. return False
  86. def invalidate_cache():
  87. """Delete only the metadata cache file, preserving image cache."""
  88. try:
  89. # Delete metadata cache file only
  90. if os.path.exists(METADATA_CACHE_FILE):
  91. os.remove(METADATA_CACHE_FILE)
  92. logger.info("Deleted outdated metadata cache file")
  93. # Keep image cache directory intact - images are still valid
  94. # Just ensure the cache directory structure exists
  95. ensure_cache_dir()
  96. return True
  97. except Exception as e:
  98. logger.error(f"Failed to invalidate metadata cache: {str(e)}")
  99. return False
  100. async def invalidate_cache_async():
  101. """Async version: Delete only the metadata cache file, preserving image cache."""
  102. try:
  103. # Delete metadata cache file only
  104. if await asyncio.to_thread(os.path.exists, METADATA_CACHE_FILE):
  105. await asyncio.to_thread(os.remove, METADATA_CACHE_FILE)
  106. logger.info("Deleted outdated metadata cache file")
  107. # Keep image cache directory intact - images are still valid
  108. # Just ensure the cache directory structure exists
  109. await ensure_cache_dir_async()
  110. return True
  111. except Exception as e:
  112. logger.error(f"Failed to invalidate metadata cache: {str(e)}")
  113. return False
  114. def ensure_cache_dir():
  115. """Ensure the cache directory exists with proper permissions."""
  116. try:
  117. Path(CACHE_DIR).mkdir(parents=True, exist_ok=True)
  118. # Initialize metadata cache if it doesn't exist
  119. if not os.path.exists(METADATA_CACHE_FILE):
  120. initial_cache = {
  121. 'version': CACHE_SCHEMA_VERSION,
  122. 'data': {}
  123. }
  124. with open(METADATA_CACHE_FILE, 'w') as f:
  125. json.dump(initial_cache, f)
  126. try:
  127. os.chmod(METADATA_CACHE_FILE, 0o644) # More conservative permissions
  128. except (OSError, PermissionError) as e:
  129. logger.debug(f"Could not set metadata cache file permissions: {str(e)}")
  130. for root, dirs, files in os.walk(CACHE_DIR):
  131. try:
  132. os.chmod(root, 0o755) # More conservative permissions
  133. for file in files:
  134. file_path = os.path.join(root, file)
  135. try:
  136. os.chmod(file_path, 0o644) # More conservative permissions
  137. except (OSError, PermissionError) as e:
  138. # Log as debug instead of error since this is not critical
  139. logger.debug(f"Could not set permissions for file {file_path}: {str(e)}")
  140. except (OSError, PermissionError) as e:
  141. # Log as debug instead of error since this is not critical
  142. logger.debug(f"Could not set permissions for directory {root}: {str(e)}")
  143. continue
  144. except Exception as e:
  145. logger.error(f"Failed to create cache directory: {str(e)}")
  146. async def ensure_cache_dir_async():
  147. """Async version: Ensure the cache directory exists with proper permissions."""
  148. try:
  149. await asyncio.to_thread(Path(CACHE_DIR).mkdir, parents=True, exist_ok=True)
  150. # Initialize metadata cache if it doesn't exist
  151. if not await asyncio.to_thread(os.path.exists, METADATA_CACHE_FILE):
  152. initial_cache = {
  153. 'version': CACHE_SCHEMA_VERSION,
  154. 'data': {}
  155. }
  156. def _write_initial_cache():
  157. with open(METADATA_CACHE_FILE, 'w') as f:
  158. json.dump(initial_cache, f)
  159. await asyncio.to_thread(_write_initial_cache)
  160. try:
  161. await asyncio.to_thread(os.chmod, METADATA_CACHE_FILE, 0o644)
  162. except (OSError, PermissionError) as e:
  163. logger.debug(f"Could not set metadata cache file permissions: {str(e)}")
  164. def _set_permissions():
  165. for root, dirs, files in os.walk(CACHE_DIR):
  166. try:
  167. os.chmod(root, 0o755)
  168. for file in files:
  169. file_path = os.path.join(root, file)
  170. try:
  171. os.chmod(file_path, 0o644)
  172. except (OSError, PermissionError) as e:
  173. logger.debug(f"Could not set permissions for file {file_path}: {str(e)}")
  174. except (OSError, PermissionError) as e:
  175. logger.debug(f"Could not set permissions for directory {root}: {str(e)}")
  176. continue
  177. await asyncio.to_thread(_set_permissions)
  178. except Exception as e:
  179. logger.error(f"Failed to create cache directory: {str(e)}")
  180. def get_cache_path(pattern_file):
  181. """Get the cache path for a pattern file."""
  182. # Normalize path separators to handle both forward slashes and backslashes
  183. pattern_file = pattern_file.replace('\\', '/')
  184. # Create subdirectories in cache to match the pattern file structure
  185. cache_subpath = os.path.dirname(pattern_file)
  186. if cache_subpath:
  187. # Create the same subdirectory structure in cache (including custom_patterns)
  188. # Convert forward slashes back to platform-specific separator for os.path.join
  189. cache_subpath = cache_subpath.replace('/', os.sep)
  190. cache_dir = os.path.join(CACHE_DIR, cache_subpath)
  191. else:
  192. # For files in root pattern directory
  193. cache_dir = CACHE_DIR
  194. # Ensure the subdirectory exists
  195. os.makedirs(cache_dir, exist_ok=True)
  196. try:
  197. os.chmod(cache_dir, 0o755) # More conservative permissions
  198. except (OSError, PermissionError) as e:
  199. # Log as debug instead of error since this is not critical
  200. logger.debug(f"Could not set permissions for cache subdirectory {cache_dir}: {str(e)}")
  201. # Use just the filename part for the cache file
  202. filename = os.path.basename(pattern_file)
  203. safe_name = filename.replace('\\', '_')
  204. return os.path.join(cache_dir, f"{safe_name}.webp")
  205. def delete_pattern_cache(pattern_file):
  206. """Delete cached preview image and metadata for a pattern file."""
  207. try:
  208. # Remove cached image
  209. cache_path = get_cache_path(pattern_file)
  210. if os.path.exists(cache_path):
  211. os.remove(cache_path)
  212. logger.info(f"Deleted cached image: {cache_path}")
  213. # Remove from metadata cache
  214. metadata_cache = load_metadata_cache()
  215. data_section = metadata_cache.get('data', {})
  216. if pattern_file in data_section:
  217. del data_section[pattern_file]
  218. metadata_cache['data'] = data_section
  219. save_metadata_cache(metadata_cache)
  220. logger.info(f"Removed {pattern_file} from metadata cache")
  221. return True
  222. except Exception as e:
  223. logger.error(f"Failed to delete cache for {pattern_file}: {str(e)}")
  224. return False
  225. def load_metadata_cache():
  226. """Load the metadata cache from disk with schema validation."""
  227. try:
  228. if os.path.exists(METADATA_CACHE_FILE):
  229. with open(METADATA_CACHE_FILE, 'r') as f:
  230. cache_data = json.load(f)
  231. # Validate schema
  232. if not validate_cache_schema(cache_data):
  233. logger.info("Cache schema validation failed - invalidating cache")
  234. invalidate_cache()
  235. # Return empty cache structure after invalidation
  236. return {
  237. 'version': CACHE_SCHEMA_VERSION,
  238. 'data': {}
  239. }
  240. return cache_data
  241. except Exception as e:
  242. logger.warning(f"Failed to load metadata cache: {str(e)} - invalidating cache")
  243. try:
  244. invalidate_cache()
  245. except Exception as invalidate_error:
  246. logger.error(f"Failed to invalidate corrupted cache: {str(invalidate_error)}")
  247. # Return empty cache structure
  248. return {
  249. 'version': CACHE_SCHEMA_VERSION,
  250. 'data': {}
  251. }
  252. async def load_metadata_cache_async():
  253. """Async version: Load the metadata cache from disk with schema validation."""
  254. try:
  255. if await asyncio.to_thread(os.path.exists, METADATA_CACHE_FILE):
  256. def _load_json():
  257. with open(METADATA_CACHE_FILE, 'r') as f:
  258. return json.load(f)
  259. cache_data = await asyncio.to_thread(_load_json)
  260. # Validate schema
  261. if not validate_cache_schema(cache_data):
  262. logger.info("Cache schema validation failed - invalidating cache")
  263. await invalidate_cache_async()
  264. # Return empty cache structure after invalidation
  265. return {
  266. 'version': CACHE_SCHEMA_VERSION,
  267. 'data': {}
  268. }
  269. return cache_data
  270. except Exception as e:
  271. logger.warning(f"Failed to load metadata cache: {str(e)} - invalidating cache")
  272. try:
  273. await invalidate_cache_async()
  274. except Exception as invalidate_error:
  275. logger.error(f"Failed to invalidate corrupted cache: {str(invalidate_error)}")
  276. # Return empty cache structure
  277. return {
  278. 'version': CACHE_SCHEMA_VERSION,
  279. 'data': {}
  280. }
  281. def save_metadata_cache(cache_data):
  282. """Save the metadata cache to disk with version info."""
  283. try:
  284. ensure_cache_dir()
  285. # Ensure cache data has proper structure
  286. if not isinstance(cache_data, dict) or 'version' not in cache_data:
  287. # Convert old format or create new structure
  288. if isinstance(cache_data, dict) and 'data' not in cache_data:
  289. # Old format - wrap existing data
  290. structured_cache = {
  291. 'version': CACHE_SCHEMA_VERSION,
  292. 'data': cache_data
  293. }
  294. else:
  295. structured_cache = cache_data
  296. else:
  297. structured_cache = cache_data
  298. # Atomic replace: write to a temp file, then rename over the real one.
  299. # A crash/kill mid-write must never leave a truncated JSON — a corrupt
  300. # cache is silently invalidated on the next boot, forcing a full
  301. # 1000+ pattern regeneration.
  302. tmp_path = METADATA_CACHE_FILE + ".tmp"
  303. with open(tmp_path, 'w') as f:
  304. json.dump(structured_cache, f, indent=2)
  305. os.replace(tmp_path, METADATA_CACHE_FILE)
  306. except Exception as e:
  307. logger.error(f"Failed to save metadata cache: {str(e)}")
  308. def get_pattern_metadata(pattern_file):
  309. """Get cached metadata for a pattern file."""
  310. cache_data = load_metadata_cache()
  311. data_section = cache_data.get('data', {})
  312. # Check if we have cached metadata and if the file hasn't changed
  313. if pattern_file in data_section:
  314. cached_entry = data_section[pattern_file]
  315. pattern_path = os.path.join(THETA_RHO_DIR, pattern_file)
  316. try:
  317. file_mtime = os.path.getmtime(pattern_path)
  318. if cached_entry.get('mtime') == file_mtime:
  319. return cached_entry.get('metadata')
  320. except OSError:
  321. pass
  322. return None
  323. async def get_pattern_metadata_async(pattern_file):
  324. """Async version: Get cached metadata for a pattern file."""
  325. cache_data = await load_metadata_cache_async()
  326. data_section = cache_data.get('data', {})
  327. # Check if we have cached metadata and if the file hasn't changed
  328. if pattern_file in data_section:
  329. cached_entry = data_section[pattern_file]
  330. pattern_path = os.path.join(THETA_RHO_DIR, pattern_file)
  331. try:
  332. file_mtime = await asyncio.to_thread(os.path.getmtime, pattern_path)
  333. if cached_entry.get('mtime') == file_mtime:
  334. return cached_entry.get('metadata')
  335. except OSError:
  336. pass
  337. return None
  338. async def cache_pattern_metadata_batch(entries):
  339. """Cache metadata for many patterns with a single read-modify-write.
  340. entries: iterable of (pattern_file, first_coord, last_coord, total_coords).
  341. One whole-file rewrite per batch instead of per pattern — during a full
  342. generation this is the difference between ~1080 and ~360 rewrites of a
  343. growing JSON. Uses asyncio.Lock to prevent race conditions when concurrent
  344. tasks read-modify-write the cache file simultaneously.
  345. """
  346. entries = list(entries)
  347. if not entries:
  348. return
  349. async with _get_metadata_cache_lock():
  350. try:
  351. cache_data = await asyncio.to_thread(load_metadata_cache)
  352. data_section = cache_data.get('data', {})
  353. for pattern_file, first_coord, last_coord, total_coords in entries:
  354. pattern_path = os.path.join(THETA_RHO_DIR, pattern_file)
  355. try:
  356. file_mtime = await asyncio.to_thread(os.path.getmtime, pattern_path)
  357. except OSError as e:
  358. logger.warning(f"Failed to cache metadata for {pattern_file}: {str(e)}")
  359. continue
  360. data_section[pattern_file] = {
  361. 'mtime': file_mtime,
  362. 'metadata': {
  363. 'first_coordinate': first_coord,
  364. 'last_coordinate': last_coord,
  365. 'total_coordinates': total_coords
  366. }
  367. }
  368. logger.debug(f"Cached metadata for {pattern_file}")
  369. cache_data['data'] = data_section
  370. await asyncio.to_thread(save_metadata_cache, cache_data)
  371. except Exception as e:
  372. logger.warning(f"Failed to cache metadata batch: {str(e)}")
  373. async def cache_pattern_metadata(pattern_file, first_coord, last_coord, total_coords):
  374. """Cache metadata for a single pattern file."""
  375. await cache_pattern_metadata_batch([(pattern_file, first_coord, last_coord, total_coords)])
  376. def needs_cache(pattern_file):
  377. """Check if a pattern file needs its cache generated."""
  378. # Check if image preview exists
  379. cache_path = get_cache_path(pattern_file)
  380. if not os.path.exists(cache_path):
  381. return True
  382. # Check if metadata cache exists and is valid
  383. metadata = get_pattern_metadata(pattern_file)
  384. if metadata is None:
  385. return True
  386. return False
  387. def needs_image_cache_only(pattern_file):
  388. """Quick check if a pattern file needs its image cache generated.
  389. Only checks for image file existence, not metadata validity.
  390. Used during startup for faster checking.
  391. """
  392. cache_path = get_cache_path(pattern_file)
  393. return not os.path.exists(cache_path)
  394. async def needs_cache_async(pattern_file):
  395. """Async version: Check if a pattern file needs its cache generated."""
  396. # Check if image preview exists
  397. cache_path = get_cache_path(pattern_file)
  398. if not await asyncio.to_thread(os.path.exists, cache_path):
  399. return True
  400. # Check if metadata cache exists and is valid
  401. metadata = await get_pattern_metadata_async(pattern_file)
  402. if metadata is None:
  403. return True
  404. return False
  405. async def generate_image_preview(pattern_file):
  406. """Generate image preview for a single pattern file."""
  407. from modules.core.preview import generate_preview_image
  408. from modules.core.pattern_manager import parse_theta_rho_file
  409. try:
  410. logger.debug(f"Starting preview generation for {pattern_file}")
  411. # Check if we need to update metadata cache
  412. metadata = get_pattern_metadata(pattern_file)
  413. if metadata is None:
  414. # Parse file to get metadata (this is the only time we need to parse)
  415. logger.debug(f"Parsing {pattern_file} for metadata cache")
  416. pattern_path = os.path.join(THETA_RHO_DIR, pattern_file)
  417. try:
  418. coordinates = await asyncio.to_thread(parse_theta_rho_file, pattern_path)
  419. if coordinates:
  420. first_coord = {"x": coordinates[0][0], "y": coordinates[0][1]}
  421. last_coord = {"x": coordinates[-1][0], "y": coordinates[-1][1]}
  422. total_coords = len(coordinates)
  423. # Cache the metadata for future use
  424. await cache_pattern_metadata(pattern_file, first_coord, last_coord, total_coords)
  425. logger.debug(f"Metadata cached for {pattern_file}: {total_coords} coordinates")
  426. else:
  427. logger.warning(f"No coordinates found in {pattern_file}")
  428. except Exception as e:
  429. logger.error(f"Failed to parse {pattern_file} for metadata: {str(e)}")
  430. # Continue with image generation even if metadata fails
  431. # Check if we need to generate the image
  432. cache_path = get_cache_path(pattern_file)
  433. if os.path.exists(cache_path):
  434. logger.debug(f"Skipping image generation for {pattern_file} - already cached")
  435. return True
  436. # Generate the image
  437. logger.debug(f"Generating image preview for {pattern_file}")
  438. image_content = await generate_preview_image(pattern_file)
  439. if not image_content:
  440. logger.error(f"Generated image content is empty for {pattern_file}")
  441. return False
  442. # Ensure cache directory exists
  443. ensure_cache_dir()
  444. with open(cache_path, 'wb') as f:
  445. f.write(image_content)
  446. try:
  447. os.chmod(cache_path, 0o644) # More conservative permissions
  448. except (OSError, PermissionError) as e:
  449. # Log as debug instead of error since this is not critical
  450. logger.debug(f"Could not set cache file permissions for {pattern_file}: {str(e)}")
  451. logger.debug(f"Successfully generated preview for {pattern_file}")
  452. return True
  453. except Exception as e:
  454. logger.error(f"Failed to generate image for {pattern_file}: {str(e)}")
  455. return False
  456. async def generate_all_image_previews():
  457. """Generate image previews for missing patterns using set difference."""
  458. global cache_progress
  459. try:
  460. await ensure_cache_dir_async()
  461. # Step 1: Get all pattern files
  462. pattern_files = await list_theta_rho_files_async()
  463. if not pattern_files:
  464. logger.info("No .thr pattern files found. Skipping image preview generation.")
  465. return
  466. # Step 2: Find patterns with existing cache
  467. def _find_cached_patterns():
  468. cached = set()
  469. for pattern in pattern_files:
  470. cache_path = get_cache_path(pattern)
  471. if os.path.exists(cache_path):
  472. cached.add(pattern)
  473. return cached
  474. cached_patterns = await asyncio.to_thread(_find_cached_patterns)
  475. # Step 3: Calculate delta (patterns missing image cache)
  476. pattern_set = set(pattern_files)
  477. patterns_to_cache = list(pattern_set - cached_patterns)
  478. total_files = len(patterns_to_cache)
  479. skipped_files = len(pattern_files) - total_files
  480. if total_files == 0:
  481. logger.info(f"All {skipped_files} pattern files already have image previews. Skipping image generation.")
  482. return
  483. # Update progress state
  484. cache_progress.update({
  485. "stage": "images",
  486. "total_files": total_files,
  487. "processed_files": 0,
  488. "current_file": "",
  489. "error": None
  490. })
  491. logger.info(f"Generating image cache for {total_files} uncached .thr patterns ({skipped_files} already cached)...")
  492. batch_size = 5
  493. successful = 0
  494. for i in range(0, total_files, batch_size):
  495. batch = patterns_to_cache[i:i + batch_size]
  496. tasks = [generate_image_preview(file) for file in batch]
  497. results = await asyncio.gather(*tasks)
  498. successful += sum(1 for r in results if r)
  499. # Update progress
  500. cache_progress["processed_files"] = min(i + batch_size, total_files)
  501. if i < total_files:
  502. cache_progress["current_file"] = patterns_to_cache[min(i + batch_size - 1, total_files - 1)]
  503. # Log progress
  504. progress = min(i + batch_size, total_files)
  505. logger.info(f"Image cache generation progress: {progress}/{total_files} files processed")
  506. logger.info(f"Image cache generation completed: {successful}/{total_files} patterns cached successfully, {skipped_files} patterns skipped (already cached)")
  507. except Exception as e:
  508. logger.error(f"Error during image cache generation: {str(e)}")
  509. cache_progress["error"] = str(e)
  510. raise
  511. async def generate_metadata_cache():
  512. """Generate metadata cache for missing patterns using set difference."""
  513. global cache_progress
  514. try:
  515. logger.info("Starting metadata cache generation...")
  516. # Step 1: Get all pattern files
  517. pattern_files = await list_theta_rho_files_async()
  518. if not pattern_files:
  519. logger.info("No pattern files found. Skipping metadata cache generation.")
  520. return
  521. # Step 2: Get existing metadata keys
  522. metadata_cache = await load_metadata_cache_async()
  523. existing_keys = set(metadata_cache.get('data', {}).keys())
  524. # Step 3: Calculate delta (patterns missing from metadata)
  525. pattern_set = set(pattern_files)
  526. files_to_process = list(pattern_set - existing_keys)
  527. total_files = len(files_to_process)
  528. skipped_files = len(pattern_files) - total_files
  529. if total_files == 0:
  530. logger.info(f"All {skipped_files} files already have metadata cache. Skipping metadata generation.")
  531. return
  532. # Update progress state
  533. cache_progress.update({
  534. "stage": "metadata",
  535. "total_files": total_files,
  536. "processed_files": 0,
  537. "current_file": "",
  538. "error": None
  539. })
  540. logger.info(f"Generating metadata cache for {total_files} new files ({skipped_files} files already cached)...")
  541. # Process in smaller batches for Pi Zero 2 W
  542. batch_size = 3 # Reduced from 5
  543. successful = 0
  544. for i in range(0, total_files, batch_size):
  545. batch = files_to_process[i:i + batch_size]
  546. batch_entries = []
  547. # Process files sequentially within batch (no parallel tasks)
  548. for file_name in batch:
  549. pattern_path = os.path.join(THETA_RHO_DIR, file_name)
  550. cache_progress["current_file"] = file_name
  551. try:
  552. # Parse file to get metadata
  553. coordinates = await asyncio.to_thread(parse_theta_rho_file, pattern_path)
  554. if coordinates:
  555. first_coord = {"x": coordinates[0][0], "y": coordinates[0][1]}
  556. last_coord = {"x": coordinates[-1][0], "y": coordinates[-1][1]}
  557. total_coords = len(coordinates)
  558. batch_entries.append((file_name, first_coord, last_coord, total_coords))
  559. successful += 1
  560. logger.debug(f"Generated metadata for {file_name}")
  561. # Small delay to reduce I/O pressure
  562. await asyncio.sleep(0.05)
  563. except Exception as e:
  564. logger.error(f"Failed to generate metadata for {file_name}: {str(e)}")
  565. # One cache-file rewrite per batch, so progress survives restarts
  566. # without hammering the disk once per pattern.
  567. await cache_pattern_metadata_batch(batch_entries)
  568. # Update progress
  569. cache_progress["processed_files"] = min(i + batch_size, total_files)
  570. # Log progress
  571. progress = min(i + batch_size, total_files)
  572. logger.info(f"Metadata cache generation progress: {progress}/{total_files} files processed")
  573. # Delay between batches for system recovery
  574. if i + batch_size < total_files:
  575. await asyncio.sleep(0.3)
  576. logger.info(f"Metadata cache generation completed: {successful}/{total_files} patterns cached successfully, {skipped_files} patterns skipped (already cached)")
  577. except Exception as e:
  578. logger.error(f"Error during metadata cache generation: {str(e)}")
  579. cache_progress["error"] = str(e)
  580. raise
  581. async def rebuild_cache():
  582. """Rebuild the entire cache for all pattern files."""
  583. logger.info("Starting cache rebuild...")
  584. # Ensure cache directory exists
  585. ensure_cache_dir()
  586. # First generate metadata cache for all files
  587. await generate_metadata_cache()
  588. # Then generate image previews
  589. pattern_files = [f for f in list_theta_rho_files() if f.endswith('.thr')]
  590. total_files = len(pattern_files)
  591. if total_files == 0:
  592. logger.info("No pattern files found to cache")
  593. return
  594. logger.info(f"Generating image previews for {total_files} pattern files...")
  595. # Process in batches
  596. batch_size = 5
  597. successful = 0
  598. for i in range(0, total_files, batch_size):
  599. batch = pattern_files[i:i + batch_size]
  600. tasks = [generate_image_preview(file) for file in batch]
  601. results = await asyncio.gather(*tasks)
  602. successful += sum(1 for r in results if r)
  603. # Log progress
  604. progress = min(i + batch_size, total_files)
  605. logger.info(f"Image preview generation progress: {progress}/{total_files} files processed")
  606. logger.info(f"Cache rebuild completed: {successful}/{total_files} patterns cached successfully")
  607. async def generate_cache_background():
  608. """Run cache generation in the background with progress tracking."""
  609. global cache_progress
  610. try:
  611. cache_progress.update({
  612. "is_running": True,
  613. "stage": "starting",
  614. "total_files": 0,
  615. "processed_files": 0,
  616. "current_file": "",
  617. "error": None
  618. })
  619. # First generate metadata cache
  620. await generate_metadata_cache()
  621. # Then generate image previews
  622. await generate_all_image_previews()
  623. # Mark as complete
  624. cache_progress.update({
  625. "is_running": False,
  626. "stage": "complete",
  627. "current_file": "",
  628. "error": None
  629. })
  630. logger.info("Background cache generation completed successfully")
  631. except Exception as e:
  632. logger.error(f"Background cache generation failed: {str(e)}")
  633. cache_progress.update({
  634. "is_running": False,
  635. "stage": "error",
  636. "error": str(e)
  637. })
  638. raise
  639. def get_cache_progress():
  640. """Get the current cache generation progress.
  641. Returns a reference to the cache_progress dict for read-only access.
  642. The WebSocket handler should not modify this dict.
  643. """
  644. global cache_progress
  645. return cache_progress # Return reference instead of copy for better performance
  646. def is_cache_generation_needed():
  647. """Check if cache generation is needed."""
  648. pattern_files = [f for f in list_theta_rho_files() if f.endswith('.thr')]
  649. if not pattern_files:
  650. return False
  651. # Check if any files need caching
  652. patterns_to_cache = [f for f in pattern_files if needs_cache(f)]
  653. # Check metadata cache
  654. files_needing_metadata = []
  655. for file_name in pattern_files:
  656. if get_pattern_metadata(file_name) is None:
  657. files_needing_metadata.append(file_name)
  658. return len(patterns_to_cache) > 0 or len(files_needing_metadata) > 0
  659. async def is_cache_generation_needed_async():
  660. """Check if cache generation is needed using simple set difference.
  661. Returns True if any patterns are missing from either metadata or image cache.
  662. """
  663. try:
  664. # Step 1: List all patterns
  665. pattern_files = await list_theta_rho_files_async()
  666. if not pattern_files:
  667. return False
  668. pattern_set = set(pattern_files)
  669. # Step 2: Check metadata cache
  670. metadata_cache = await load_metadata_cache_async()
  671. metadata_keys = set(metadata_cache.get('data', {}).keys())
  672. if pattern_set != metadata_keys:
  673. # Metadata is missing some patterns
  674. return True
  675. # Step 3: Check image cache
  676. def _list_cached_images():
  677. """List all patterns that have cached images."""
  678. cached = set()
  679. if os.path.exists(CACHE_DIR):
  680. for pattern in pattern_files:
  681. cache_path = get_cache_path(pattern)
  682. if os.path.exists(cache_path):
  683. cached.add(pattern)
  684. return cached
  685. cached_images = await asyncio.to_thread(_list_cached_images)
  686. if pattern_set != cached_images:
  687. # Some patterns missing image cache
  688. return True
  689. return False
  690. except Exception as e:
  691. logger.warning(f"Error checking cache status: {e}")
  692. return False # Don't block startup on errors
  693. async def list_theta_rho_files_async():
  694. """Async version: List all theta-rho files."""
  695. def _walk_files():
  696. files = []
  697. for root, _, filenames in os.walk(THETA_RHO_DIR):
  698. # Only process .thr files to reduce memory usage
  699. thr_files = [f for f in filenames if f.endswith('.thr')]
  700. for file in thr_files:
  701. relative_path = os.path.relpath(os.path.join(root, file), THETA_RHO_DIR)
  702. # Normalize path separators to always use forward slashes for consistency across platforms
  703. relative_path = relative_path.replace(os.sep, '/')
  704. files.append(relative_path)
  705. return files
  706. files = await asyncio.to_thread(_walk_files)
  707. logger.debug(f"Found {len(files)} theta-rho files")
  708. return files # Already filtered for .thr