build.py (10.1 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 # Bootstrap into the shared venv first thing (re-execs on first run),
 12 # so scan.rescan() has mutagen available when we call it before building.
 13 import scan
 14 scan.bootstrap(__file__)
 15 
 16 
 17 def get_configuration():
 18 	"""Prompt user for configuration values"""
 19 	print("=" * 60)
 20 	print("PWA Configuration")
 21 	print("=" * 60)
 22 	print()
 23 
 24 	# Get app name
 25 	app_name = input("Enter a name for your mixapp: ").strip()
 26 	if not app_name:
 27 		print("Error: App name is required")
 28 		exit(1)
 29 
 30 	# Get base path with smart default
 31 	default_path = app_name.lower().replace(" ", "_")
 32 	print()
 33 	print(f"Enter the deployment path (or press Return/Enter for default)")
 34 	print(f"Default: /{default_path}/")
 35 	base_path_input = input("Path: ").strip()
 36 
 37 	if base_path_input:
 38 		# User provided a path - ensure it has leading/trailing slashes
 39 		base_path = base_path_input
 40 		if not base_path.startswith("/"):
 41 			base_path = "/" + base_path
 42 		if not base_path.endswith("/"):
 43 			base_path = base_path + "/"
 44 	else:
 45 		# Use default
 46 		base_path = f"/{default_path}/"
 47 		print(f"Using default path: {base_path}")
 48 
 49 	print()
 50 	print(f"Configuration:")
 51 	print(f"  App Name: {app_name}")
 52 	print(f"  Base Path: {base_path}")
 53 	print()
 54 
 55 	return app_name, base_path
 56 
 57 # File paths (no need to edit these)
 58 SCRIPT_DIR = Path(__file__).parent.absolute()
 59 TRACKS_JSON = SCRIPT_DIR / "mix" / "tracks.json"
 60 STYLES_CSS = SCRIPT_DIR / "resources" / "styles.css"
 61 CUSTOM_CSS = SCRIPT_DIR / "mix" / "custom.css"
 62 
 63 
 64 def get_background_color():
 65 	"""Extract the --background CSS variable, preferring custom.css over styles.css"""
 66 	# Check custom.css first (overrides styles.css)
 67 	for css_file in [CUSTOM_CSS, STYLES_CSS]:
 68 		if not css_file.exists():
 69 			continue
 70 		with open(css_file, 'r', encoding='utf-8') as f:
 71 			content = f.read()
 72 		match = re.search(
 73 			r'--background:\s*([#a-zA-Z0-9(),.\s]+?)\s*;',
 74 			content
 75 		)
 76 		if match:
 77 			color = match.group(1).strip()
 78 			print(f"Found background color in {css_file.name}: {color}")
 79 			return color
 80 
 81 	print("Warning: --background not found in any CSS file. Using default color.")
 82 	return "#080a0c"
 83 
 84 
 85 def build_pwa(app_name=None, base_path=None):
 86 	"""Generate manifest.json and service-worker.js based on tracks.json
 87 
 88 	Args:
 89 		app_name: Name of the app. If None, will be prompted via get_configuration()
 90 		base_path: Base path for the app. If None, will be prompted via get_configuration()
 91 	"""
 92 	# Get configuration if not provided
 93 	if app_name is None or base_path is None:
 94 		app_name, base_path = get_configuration()
 95 
 96 	# Derived values
 97 	short_name = app_name
 98 	cache_name = app_name
 99 	app_description = f"{app_name}"
100 
101 	print("Building PWA files...")
102 	print(f"  Cache name: {cache_name}")
103 
104 	# Rescan /mix so tracks.json reflects any additions/removals on disk
105 	# before we snapshot it into the service worker's static file list.
106 	scan.rescan(silent=True)
107 
108 	if not TRACKS_JSON.exists():
109 		print("Error: tracks.json not found.")
110 		return
111 
112 	with open(TRACKS_JSON, 'r', encoding='utf-8') as f:
113 		tracks = json.load(f)
114 
115 	# Get background color from styles.css
116 	background_color = get_background_color()
117 
118 	# mix/album_art.jpg overrides the default
119 	custom_album_art = SCRIPT_DIR / "mix" / "album_art.jpg"
120 	album_art_file = custom_album_art if custom_album_art.exists() else SCRIPT_DIR / "resources" / "album_art.jpg"
121 	album_art_path = "mix/album_art.jpg" if custom_album_art.exists() else "resources/album_art.jpg"
122 	# append a content hash so the URL changes when the image changes
123 	if album_art_file.exists():
124 		import hashlib
125 		art_hash = hashlib.md5(album_art_file.read_bytes()).hexdigest()[:8]
126 		album_art_path = f"{album_art_path}?v={art_hash}"
127 	print(f"  Album art: {album_art_path}")
128 
129 	# Generate manifest.json
130 	manifest = {
131 		"id": base_path,
132 		"name": app_name,
133 		"short_name": short_name,
134 		"description": app_description,
135 		"start_url": base_path,
136 		"scope": base_path,
137 		"display": "standalone",
138 		"background_color": background_color,
139 		"theme_color": background_color,
140 		"cache_name": cache_name,  # Custom field for script.js to use
141 		"album_art": album_art_path,  # Custom field for script.js to use
142 		"icons": [
143 			{
144 				"src": f"{base_path}resources/icon.png",
145 				"sizes": "640x640",
146 				"type": "image/png",
147 				"purpose": "any maskable"
148 			}
149 		]
150 	}
151 
152 	with open(SCRIPT_DIR / "manifest.json", 'w', encoding='utf-8') as f:
153 		json.dump(manifest, f, indent=2)
154 	print("✓ Generated manifest.json")
155 
156 	# Build static files list for service worker
157 	static_files = [
158 		"./",
159 		"index.html",
160 		"manifest.json",
161 		"resources/styles.css",
162 		"resources/script.js",
163 		"mix/tracks.json",
164 		"resources/icon.png",
165 		album_art_path,
166 		"resources/play.svg",
167 		"resources/pause.svg",
168 		"resources/prev.svg",
169 		"resources/next.svg",
170 		"resources/repeat.svg",
171 		"resources/fonts/Basteleur/Basteleur-Moonlight.woff2",
172 	]
173 
174 	AUDIO_EXTS = {".mp3", ".m4a", ".ogg", ".flac", ".wav"}
175 	# album_art.jpg added explicitly above with a hash; skip to avoid a bare dupe
176 	SKIP_NAMES = {"tracks.json", "readme.md", "album_art.jpg"}
177 	for path in sorted((SCRIPT_DIR / "mix").iterdir()):
178 		if not path.is_file():
179 			continue
180 		if path.name in SKIP_NAMES:
181 			continue
182 		if path.suffix.lower() in AUDIO_EXTS:
183 			continue
184 		static_files.append(f"mix/{path.name}")
185 
186 	static_files_js = json.dumps(static_files)
187 
188 	# Generate service-worker.js
189 	service_worker_content = f'''// Auto-generated service worker for {app_name} PWA
190 const CACHE_NAME = '{cache_name}';
191 
192 // Get the base path from the service worker location
193 const getBasePath = () => {{
194 	const swPath = self.location.pathname;
195 	return swPath.substring(0, swPath.lastIndexOf('/') + 1);
196 }};
197 
198 const basePath = getBasePath();
199 
200 // Static files to cache on install
201 const STATIC_FILES = {static_files_js};
202 
203 // Install event - cache static resources only if not already cached.
204 // This makes installs immutable: once a file is in the cache, redeploys
205 // will not overwrite it, so the app stays frozen at its first-installed version.
206 // Audio files will be cached by the main app's blob preloading system.
207 self.addEventListener('install', (event) => {{
208 	console.log('Service Worker installing...', 'Base path:', basePath);
209 	self.skipWaiting();
210 	event.waitUntil(
211 		caches.open(CACHE_NAME).then(cache => {{
212 			const absoluteUrls = STATIC_FILES.map(url => {{
213 				if (url === './') return new URL(basePath, self.location.href).href;
214 				return new URL(url, new URL(basePath, self.location.href)).href;
215 			}});
216 
217 			return Promise.allSettled(
218 				absoluteUrls.map(url =>
219 					cache.match(url).then(existing => {{
220 						if (existing) {{
221 							console.log('• Already cached, skipping:', url);
222 							return;
223 						}}
224 						return fetch(url, {{ cache: 'no-cache' }})
225 							.then(response => {{
226 								if (!response.ok) {{
227 									throw new Error(`HTTP error! status: ${{response.status}}`);
228 								}}
229 								return cache.put(url, response);
230 							}})
231 							.then(() => console.log('✓ Cached:', url))
232 							.catch(err => {{
233 								console.error('✗ Failed to cache:', url, err);
234 								throw err;
235 							}});
236 					}})
237 				)
238 			).then(results => {{
239 				const failed = results.filter(r => r.status === 'rejected');
240 				console.log(`Install complete: ${{results.length - failed.length}}/${{results.length}} ok`);
241 			}});
242 		}}).catch(error => {{
243 			console.error('Service Worker installation failed:', error);
244 		}})
245 	);
246 }});
247 
248 self.addEventListener('activate', (event) => {{
249 	event.waitUntil(self.clients.claim());
250 }});
251 
252 // Network fetch capped with a timeout. A mixapp installed off the LAN points
253 // at a private-IP origin that's usually gone by launch time; without a cap an
254 // uncached request hangs on a TCP timeout and stalls startup (black→white
255 // screen). Aborting fast turns that hang into an ordinary cache-miss failure.
256 const fetchWithTimeout = (request, ms = 2000) => {{
257 	const controller = new AbortController();
258 	const timer = setTimeout(() => controller.abort(), ms);
259 	return fetch(request, {{ signal: controller.signal }})
260 		.finally(() => clearTimeout(timer));
261 }};
262 
263 // Fetch event - cache first, network fallback
264 self.addEventListener('fetch', (event) => {{
265 	// Ignore non-http(s) requests like blob: URLs, data: URLs, chrome-extension:, etc.
266 	if (!event.request.url.startsWith('http')) {{
267 		return;
268 	}}
269 
270 	event.respondWith(
271 		caches.match(event.request)
272 			.then((cachedResponse) => {{
273 				if (cachedResponse) {{
274 					console.log('✓ Serving from cache:', event.request.url);
275 					return cachedResponse;
276 				}}
277 
278 				// Navigation that missed the exact cache key: serve the cached
279 				// app shell so the document always renders from cache, online
280 				// or off, even if the launch URL doesn't byte-match a key.
281 				if (event.request.mode === 'navigate') {{
282 					const shellUrl = new URL(basePath, self.location.href).href;
283 					return caches.match(shellUrl)
284 						.then(shell => shell || caches.match('index.html'))
285 						.then(shell => shell || fetchWithTimeout(event.request));
286 				}}
287 
288 				// Not in cache - try network (time-boxed; see fetchWithTimeout)
289 				console.log('⟳ Fetching from network:', event.request.url);
290 				return fetchWithTimeout(event.request)
291 					.then((networkResponse) => {{
292 						// Check if valid response
293 						if (!networkResponse || networkResponse.status !== 200 || networkResponse.type === 'error') {{
294 							return networkResponse;
295 						}}
296 
297 						// Clone and cache for future offline use
298 						const responseToCache = networkResponse.clone();
299 						caches.open(CACHE_NAME)
300 							.then((cache) => {{
301 								cache.put(event.request, responseToCache);
302 								console.log('✓ Cached from network:', event.request.url);
303 							}})
304 							.catch(err => console.error('Failed to cache:', err));
305 
306 						return networkResponse;
307 					}})
308 					.catch((error) => {{
309 						console.error('✗ Network fetch failed for:', event.request.url, error);
310 						throw error;
311 					}});
312 			}})
313 	);
314 }});
315 '''
316 
317 	with open(SCRIPT_DIR / "service-worker.js", 'w', encoding='utf-8') as f:
318 		f.write(service_worker_content)
319 	print("✓ Generated service-worker.js")
320 	print()
321 	print("PWA build complete!")
322 
323 
324 if __name__ == "__main__":
325 	# When run directly, get configuration and build PWA files
326 	app_name, base_path = get_configuration()
327 	build_pwa(app_name, base_path)