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)