1
0

cache_manager.py 35 KB

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