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)