generate_manifests.py (9.7 KB)


  1 #!/usr/bin/env python3
  2 """
  3 Creates manifest.json, resource-manifest.json, and service-worker.js
  4 based on the contents of tracks.json
  5 """
  6 
  7 import json
  8 import re
  9 from pathlib import Path
 10 
 11 def get_configuration():
 12 	"""Prompt user for configuration values"""
 13 	print("=" * 60)
 14 	print("PWA Configuration")
 15 	print("=" * 60)
 16 	print()
 17 
 18 	# Get app name
 19 	app_name = input("Enter a name for your mixapp: ").strip()
 20 	if not app_name:
 21 		print("Error: App name is required")
 22 		exit(1)
 23 
 24 	# Get base path with smart default
 25 	default_path = app_name.lower().replace(" ", "_")
 26 	print()
 27 	print(f"Enter the deployment path (or press Return/Enter for default)")
 28 	print(f"Default: /{default_path}/")
 29 	base_path_input = input("Path: ").strip()
 30 
 31 	if base_path_input:
 32 		# User provided a path - ensure it has leading/trailing slashes
 33 		base_path = base_path_input
 34 		if not base_path.startswith("/"):
 35 			base_path = "/" + base_path
 36 		if not base_path.endswith("/"):
 37 			base_path = base_path + "/"
 38 	else:
 39 		# Use default
 40 		base_path = f"/{default_path}/"
 41 		print(f"Using default path: {base_path}")
 42 
 43 	print()
 44 	print(f"Configuration:")
 45 	print(f"  App Name: {app_name}")
 46 	print(f"  Base Path: {base_path}")
 47 	print()
 48 
 49 	return app_name, base_path
 50 
 51 # File paths (no need to edit these)
 52 SCRIPT_DIR = Path(__file__).parent.absolute()
 53 TRACKS_JSON = SCRIPT_DIR / "mix" / "tracks.json"
 54 STYLES_CSS = SCRIPT_DIR / "resources" / "styles.css"
 55 
 56 
 57 def get_background_color():
 58 	"""Extract the --background CSS variable from styles.css"""
 59 	if not STYLES_CSS.exists():
 60 		print("Warning: styles.css not found. Using default color.")
 61 		return "#080a0c"
 62 
 63 	with open(STYLES_CSS, 'r', encoding='utf-8') as f:
 64 		content = f.read()
 65 
 66 	# Look for --background: <color>; pattern
 67 	match = re.search(
 68 		r'--background:\s*([#a-zA-Z0-9(),.\s]+?)\s*;',
 69 		content
 70 	)
 71 	if match:
 72 		color = match.group(1).strip()
 73 		print(f"Found background color in styles.css: {color}")
 74 		return color
 75 
 76 	print("Warning: --background not found in styles.css. Using default color.")
 77 	return "#080a0c"
 78 
 79 
 80 def _next_cache_name(app_name, manifest_path):
 81 	"""Determine the next cache name by incrementing the version in an existing manifest.
 82 
 83 	If manifest.json doesn't exist or has no versioned cache_name, returns "{app_name}-v1".
 84 	If it already has e.g. "heaven-v1", returns "heaven-v1.1".
 85 	If it already has e.g. "heaven-v1.3", returns "heaven-v1.4".
 86 	"""
 87 	if manifest_path.exists():
 88 		try:
 89 			with open(manifest_path, 'r', encoding='utf-8') as f:
 90 				existing = json.load(f)
 91 			old_cache = existing.get("cache_name", "")
 92 			# Match pattern like "appname-v1" or "appname-v1.3"
 93 			match = re.match(r'^(.+)-v(\d+)(?:\.(\d+))?$', old_cache)
 94 			if match:
 95 				prefix = match.group(1)
 96 				major = int(match.group(2))
 97 				minor = int(match.group(3)) if match.group(3) is not None else 0
 98 				# If app was renamed, start fresh with new name
 99 				if prefix != app_name:
100 					return f"{app_name}-v1"
101 				return f"{app_name}-v{major}.{minor + 1}"
102 		except (json.JSONDecodeError, KeyError):
103 			pass
104 	return f"{app_name}-v1"
105 
106 
107 def generate_pwa_manifests(app_name=None, base_path=None):
108 	"""Generate PWA manifest files based on tracks.json
109 
110 	Args:
111 		app_name: Name of the app. If None, will be prompted via get_configuration()
112 		base_path: Base path for the app. If None, will be prompted via get_configuration()
113 	"""
114 	# Get configuration if not provided
115 	if app_name is None or base_path is None:
116 		app_name, base_path = get_configuration()
117 
118 	# Derived values
119 	short_name = app_name
120 	cache_name = _next_cache_name(app_name, SCRIPT_DIR / "manifest.json")
121 	app_description = f"{app_name}"
122 
123 	print("Generating PWA manifests...")
124 	print(f"  Cache name: {cache_name}")
125 
126 	# Load tracks.json
127 	if not TRACKS_JSON.exists():
128 		print("Error: tracks.json not found. Run build.py first.")
129 		return
130 
131 	with open(TRACKS_JSON, 'r', encoding='utf-8') as f:
132 		tracks = json.load(f)
133 
134 	# Get background color from styles.css
135 	background_color = get_background_color()
136 
137 	# Generate manifest.json
138 	manifest = {
139 		"id": base_path,
140 		"name": app_name,
141 		"short_name": short_name,
142 		"description": app_description,
143 		"start_url": base_path,
144 		"scope": base_path,
145 		"display": "standalone",
146 		"background_color": background_color,
147 		"theme_color": background_color,
148 		"cache_name": cache_name,  # Custom field for script.js to use
149 		"icons": [
150 			{
151 				"src": f"{base_path}resources/icon.png",
152 				"sizes": "640x640",
153 				"type": "image/png",
154 				"purpose": "any maskable"
155 			}
156 		]
157 	}
158 
159 	with open(SCRIPT_DIR / "manifest.json", 'w', encoding='utf-8') as f:
160 		json.dump(manifest, f, indent=2)
161 	print("✓ Generated manifest.json")
162 
163 	# Generate resource-manifest.json
164 	static_files = [
165 		"./",
166 		"index.html",
167 		"resources/styles.css",
168 		"resources/script.js",
169 		"mix/tracks.json",
170 		"resources/icon.png",
171 		"resources/play.svg",
172 		"resources/pause.svg",
173 		"resources/prev.svg",
174 		"resources/next.svg",
175 		"resources/repeat.svg",
176 	]
177 
178 	# Conditionally include optional files if they exist
179 	for optional_file in ["album_art.jpg", "custom.css", "custom.js"]:
180 		if (SCRIPT_DIR / "mix" / optional_file).exists():
181 			static_files.append(f"mix/{optional_file}")
182 
183 	resource_manifest = {
184 		"static_files": static_files,
185 		"tracks": [f"mix/{track['filename']}" for track in tracks]
186 	}
187 
188 	with open(SCRIPT_DIR / "resource-manifest.json", 'w', encoding='utf-8') as f:
189 		json.dump(resource_manifest, f, indent=2)
190 	print("✓ Generated resource-manifest.json")
191 
192 	# Generate service-worker.js
193 	static_files = resource_manifest["static_files"]
194 	service_worker_content = f'''// Auto-generated service worker for {app_name} PWA
195 const CACHE_NAME = '{cache_name}';
196 const staticFilesToCache = {json.dumps(static_files, indent=2)};
197 
198 // Get the base path from the service worker location
199 const getBasePath = () => {{
200 	const swPath = self.location.pathname;
201 	return swPath.substring(0, swPath.lastIndexOf('/') + 1);
202 }};
203 
204 const basePath = getBasePath();
205 
206 // Install event - cache only static resources (not audio files)
207 // Audio files will be cached by the main app's blob preloading system
208 self.addEventListener('install', (event) => {{
209 	console.log('Service Worker installing...', 'Base path:', basePath);
210 	event.waitUntil(
211 		caches.open(CACHE_NAME)
212 			.then((cache) => {{
213 				console.log('Opened cache');
214 				// Make URLs absolute relative to service worker location
215 				const absoluteUrls = staticFilesToCache.map(url => {{
216 					if (url === './') return basePath;
217 					return new URL(url, basePath + 'index.html').href;
218 				}});
219 				console.log('Caching', absoluteUrls.length, 'static resources');
220 				console.log('URLs to cache:', absoluteUrls);
221 
222 				// Cache files individually with better error handling
223 				// Using Promise.allSettled to continue even if some fail
224 				return Promise.allSettled(
225 					absoluteUrls.map(url =>
226 						fetch(url, {{ cache: 'no-cache' }})
227 							.then(response => {{
228 								if (!response.ok) {{
229 									throw new Error(`HTTP error! status: ${{response.status}}`);
230 								}}
231 								return cache.put(url, response);
232 							}})
233 							.then(() => console.log('✓ Cached:', url))
234 							.catch(err => {{
235 								console.error('✗ Failed to cache:', url, err);
236 								throw err;
237 							}})
238 					)
239 				).then(results => {{
240 					const failed = results.filter(r => r.status === 'rejected');
241 					const succeeded = results.filter(r => r.status === 'fulfilled');
242 					console.log(`Cached ${{succeeded.length}}/${{results.length}} static resources`);
243 					if (failed.length > 0) {{
244 						console.warn(`Failed to cache ${{failed.length}} resources`);
245 					}}
246 				}});
247 			}})
248 			.then(() => {{
249 				console.log('Service Worker installation complete');
250 				return self.skipWaiting();
251 			}})
252 			.catch(error => {{
253 				console.error('Service Worker installation failed:', error);
254 			}})
255 	);
256 }});
257 
258 // Activate event - clean up old caches
259 self.addEventListener('activate', (event) => {{
260 	console.log('Service Worker activating...');
261 	event.waitUntil(
262 		caches.keys().then((cacheNames) => {{
263 			return Promise.all(
264 				cacheNames.map((cacheName) => {{
265 					if (cacheName !== CACHE_NAME) {{
266 						console.log('Deleting old cache:', cacheName);
267 						return caches.delete(cacheName);
268 					}}
269 				}})
270 			);
271 		}}).then(() => self.clients.claim())
272 	);
273 }});
274 
275 // Fetch event - cache first, network fallback
276 self.addEventListener('fetch', (event) => {{
277 	// Ignore non-http(s) requests like blob: URLs, data: URLs, chrome-extension:, etc.
278 	if (!event.request.url.startsWith('http')) {{
279 		return;
280 	}}
281 
282 	event.respondWith(
283 		caches.match(event.request)
284 			.then((cachedResponse) => {{
285 				if (cachedResponse) {{
286 					console.log('✓ Serving from cache:', event.request.url);
287 					return cachedResponse;
288 				}}
289 
290 				// Not in cache - try network
291 				console.log('⟳ Fetching from network:', event.request.url);
292 				return fetch(event.request)
293 					.then((networkResponse) => {{
294 						// Check if valid response
295 						if (!networkResponse || networkResponse.status !== 200 || networkResponse.type === 'error') {{
296 							return networkResponse;
297 						}}
298 
299 						// Clone and cache for future offline use
300 						const responseToCache = networkResponse.clone();
301 						caches.open(CACHE_NAME)
302 							.then((cache) => {{
303 								cache.put(event.request, responseToCache);
304 								console.log('✓ Cached from network:', event.request.url);
305 							}})
306 							.catch(err => console.error('Failed to cache:', err));
307 
308 						return networkResponse;
309 					}})
310 					.catch((error) => {{
311 						console.error('✗ Network fetch failed for:', event.request.url, error);
312 						throw error;
313 					}});
314 			}})
315 	);
316 }});
317 '''
318 
319 	with open(SCRIPT_DIR / "service-worker.js", 'w', encoding='utf-8') as f:
320 		f.write(service_worker_content)
321 	print("✓ Generated service-worker.js")
322 	print()
323 	print("PWA manifests generated successfully!")
324 
325 
326 if __name__ == "__main__":
327 	# When run directly, get configuration and generate manifests
328 	app_name, base_path = get_configuration()
329 	generate_pwa_manifests(app_name, base_path)