build.py (7.9 KB)


  1 #!/usr/bin/env python3
  2 """
  3 Creates manifest.json and service-worker.js
  4 (PWA requirements) 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 CUSTOM_CSS = SCRIPT_DIR / "mix" / "custom.css"
 56 
 57 
 58 def get_background_color():
 59 	"""Extract the --background CSS variable, preferring custom.css over styles.css"""
 60 	# Check custom.css first (overrides styles.css)
 61 	for css_file in [CUSTOM_CSS, STYLES_CSS]:
 62 		if not css_file.exists():
 63 			continue
 64 		with open(css_file, 'r', encoding='utf-8') as f:
 65 			content = f.read()
 66 		match = re.search(
 67 			r'--background:\s*([#a-zA-Z0-9(),.\s]+?)\s*;',
 68 			content
 69 		)
 70 		if match:
 71 			color = match.group(1).strip()
 72 			print(f"Found background color in {css_file.name}: {color}")
 73 			return color
 74 
 75 	print("Warning: --background not found in any CSS file. Using default color.")
 76 	return "#080a0c"
 77 
 78 
 79 def build_pwa(app_name=None, base_path=None):
 80 	"""Generate manifest.json and service-worker.js based on tracks.json
 81 
 82 	Args:
 83 		app_name: Name of the app. If None, will be prompted via get_configuration()
 84 		base_path: Base path for the app. If None, will be prompted via get_configuration()
 85 	"""
 86 	# Get configuration if not provided
 87 	if app_name is None or base_path is None:
 88 		app_name, base_path = get_configuration()
 89 
 90 	# Derived values
 91 	short_name = app_name
 92 	cache_name = app_name
 93 	app_description = f"{app_name}"
 94 
 95 	print("Building PWA files...")
 96 	print(f"  Cache name: {cache_name}")
 97 
 98 	# Load tracks.json
 99 	if not TRACKS_JSON.exists():
100 		print("Error: tracks.json not found. Run scan.py first.")
101 		return
102 
103 	with open(TRACKS_JSON, 'r', encoding='utf-8') as f:
104 		tracks = json.load(f)
105 
106 	# Get background color from styles.css
107 	background_color = get_background_color()
108 
109 	# Generate manifest.json
110 	manifest = {
111 		"id": base_path,
112 		"name": app_name,
113 		"short_name": short_name,
114 		"description": app_description,
115 		"start_url": base_path,
116 		"scope": base_path,
117 		"display": "standalone",
118 		"background_color": background_color,
119 		"theme_color": background_color,
120 		"cache_name": cache_name,  # Custom field for script.js to use
121 		"icons": [
122 			{
123 				"src": f"{base_path}resources/icon.png",
124 				"sizes": "640x640",
125 				"type": "image/png",
126 				"purpose": "any maskable"
127 			}
128 		]
129 	}
130 
131 	with open(SCRIPT_DIR / "manifest.json", 'w', encoding='utf-8') as f:
132 		json.dump(manifest, f, indent=2)
133 	print("✓ Generated manifest.json")
134 
135 	# Build static files list for service worker
136 	static_files = [
137 		"./",
138 		"index.html",
139 		"manifest.json",
140 		"resources/styles.css",
141 		"resources/script.js",
142 		"mix/tracks.json",
143 		"resources/icon.png",
144 		"resources/play.svg",
145 		"resources/pause.svg",
146 		"resources/prev.svg",
147 		"resources/next.svg",
148 		"resources/repeat.svg",
149 		"resources/fonts/Basteleur/Basteleur-Moonlight.woff2",
150 	]
151 
152 	AUDIO_EXTS = {".mp3", ".m4a", ".ogg", ".flac", ".wav"}
153 	SKIP_NAMES = {"tracks.json", "readme.md"}
154 	for path in sorted((SCRIPT_DIR / "mix").iterdir()):
155 		if not path.is_file():
156 			continue
157 		if path.name in SKIP_NAMES:
158 			continue
159 		if path.suffix.lower() in AUDIO_EXTS:
160 			continue
161 		static_files.append(f"mix/{path.name}")
162 
163 	static_files_js = json.dumps(static_files)
164 
165 	# Generate service-worker.js
166 	service_worker_content = f'''// Auto-generated service worker for {app_name} PWA
167 const CACHE_NAME = '{cache_name}';
168 
169 // Get the base path from the service worker location
170 const getBasePath = () => {{
171 	const swPath = self.location.pathname;
172 	return swPath.substring(0, swPath.lastIndexOf('/') + 1);
173 }};
174 
175 const basePath = getBasePath();
176 
177 // Static files to cache on install
178 const STATIC_FILES = {static_files_js};
179 
180 // Install event - cache static resources only if not already cached.
181 // This makes installs immutable: once a file is in the cache, redeploys
182 // will not overwrite it, so the app stays frozen at its first-installed version.
183 // Audio files will be cached by the main app's blob preloading system.
184 self.addEventListener('install', (event) => {{
185 	console.log('Service Worker installing...', 'Base path:', basePath);
186 	event.waitUntil(
187 		caches.open(CACHE_NAME).then(cache => {{
188 			const absoluteUrls = STATIC_FILES.map(url => {{
189 				if (url === './') return new URL(basePath, self.location.href).href;
190 				return new URL(url, new URL(basePath, self.location.href)).href;
191 			}});
192 
193 			return Promise.allSettled(
194 				absoluteUrls.map(url =>
195 					cache.match(url).then(existing => {{
196 						if (existing) {{
197 							console.log('• Already cached, skipping:', url);
198 							return;
199 						}}
200 						return fetch(url, {{ cache: 'no-cache' }})
201 							.then(response => {{
202 								if (!response.ok) {{
203 									throw new Error(`HTTP error! status: ${{response.status}}`);
204 								}}
205 								return cache.put(url, response);
206 							}})
207 							.then(() => console.log('✓ Cached:', url))
208 							.catch(err => {{
209 								console.error('✗ Failed to cache:', url, err);
210 								throw err;
211 							}});
212 					}})
213 				)
214 			).then(results => {{
215 				const failed = results.filter(r => r.status === 'rejected');
216 				console.log(`Install complete: ${{results.length - failed.length}}/${{results.length}} ok`);
217 			}});
218 		}}).catch(error => {{
219 			console.error('Service Worker installation failed:', error);
220 		}})
221 	);
222 }});
223 
224 // Fetch event - cache first, network fallback
225 self.addEventListener('fetch', (event) => {{
226 	// Ignore non-http(s) requests like blob: URLs, data: URLs, chrome-extension:, etc.
227 	if (!event.request.url.startsWith('http')) {{
228 		return;
229 	}}
230 
231 	event.respondWith(
232 		caches.match(event.request)
233 			.then((cachedResponse) => {{
234 				if (cachedResponse) {{
235 					console.log('✓ Serving from cache:', event.request.url);
236 					return cachedResponse;
237 				}}
238 
239 				// Not in cache - try network
240 				console.log('⟳ Fetching from network:', event.request.url);
241 				return fetch(event.request)
242 					.then((networkResponse) => {{
243 						// Check if valid response
244 						if (!networkResponse || networkResponse.status !== 200 || networkResponse.type === 'error') {{
245 							return networkResponse;
246 						}}
247 
248 						// Clone and cache for future offline use
249 						const responseToCache = networkResponse.clone();
250 						caches.open(CACHE_NAME)
251 							.then((cache) => {{
252 								cache.put(event.request, responseToCache);
253 								console.log('✓ Cached from network:', event.request.url);
254 							}})
255 							.catch(err => console.error('Failed to cache:', err));
256 
257 						return networkResponse;
258 					}})
259 					.catch((error) => {{
260 						console.error('✗ Network fetch failed for:', event.request.url, error);
261 						throw error;
262 					}});
263 			}})
264 	);
265 }});
266 '''
267 
268 	with open(SCRIPT_DIR / "service-worker.js", 'w', encoding='utf-8') as f:
269 		f.write(service_worker_content)
270 	print("✓ Generated service-worker.js")
271 	print()
272 	print("PWA build complete!")
273 
274 
275 if __name__ == "__main__":
276 	# When run directly, get configuration and build PWA files
277 	app_name, base_path = get_configuration()
278 	build_pwa(app_name, base_path)