build.py -> catalog.py; generate_manifests.py -> build.py

Hunter
Monday, April 6th, 2026 at 4:06 PM

build.py+295-167
build.py -> catalog.py  
generate_manifests.pydeleted
mix/readme.md+1-1
readme.md+3-3
rip.py+1-1

build.py
  1  #!/usr/bin/env python3
  2  """
    -Scans /mix directory and populates tracks.json with metadata
    -Supports MP3, M4A, OGG, FLAC, and WAV formats
    -Automatically manages a virtual environment for dependencies
  3 +Creates manifest.json and service-worker.js
  4 +(PWA requirements) based on the contents of tracks.json
  5  """
  6  
    -import os
    -import sys
    -import subprocess
  7  import json
  8 +import re
  9  from pathlib import Path
 10  
    -SCRIPT_DIR = Path(__file__).parent.absolute()
    -VENV_DIR = SCRIPT_DIR / "venv"
    -MIX_DIR = SCRIPT_DIR / "mix"
    -OUTPUT_FILE = MIX_DIR / "tracks.json"
    -
    -
    -def setup_venv():
    -	"""Create and set up virtual environment if it doesn't exist"""
    -	# Determine the path to pip and python in the venv
    -	if sys.platform == "win32":
    -		pip_path = VENV_DIR / "Scripts" / "pip"
    -		python_path = VENV_DIR / "Scripts" / "python"
 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:
    -		pip_path = VENV_DIR / "bin" / "pip"
    -		python_path = VENV_DIR / "bin" / "python3"
    -
    -	# Check if venv needs to be created or recreated
    -	if not VENV_DIR.exists() or not python_path.exists():
    -		if VENV_DIR.exists():
    -			print("Virtual environment incomplete, recreating...")
    -			import shutil
    -			shutil.rmtree(VENV_DIR)
    -		else:
    -			print("Creating virtual environment...")
    -
    -		try:
    -			subprocess.check_call([sys.executable, "-m", "venv", str(VENV_DIR)])
    -			print("Virtual environment created successfully.")
    -		except subprocess.CalledProcessError as e:
    -			print(f"Error creating virtual environment: {e}")
    -			sys.exit(1)
    -
    -	# Ensure pip is available (sometimes venv doesn't include it)
    -	if not pip_path.exists():
    -		print("Installing pip in virtual environment...")
    -		try:
    -			subprocess.check_call([str(python_path), "-m", "ensurepip", "--upgrade"])
    -		except subprocess.CalledProcessError as e:
    -			print(f"Error ensuring pip: {e}")
    -			sys.exit(1)
    -
    -	check = subprocess.run(
    -		[str(python_path), "-c", "import mutagen"],
    -		capture_output=True
    -	)
    -	if check.returncode != 0:
    -		try:
    -			subprocess.check_call([str(python_path), "-m", "pip", "install", "-q", "mutagen"])
    -		except subprocess.CalledProcessError:
    -			print("Note: Could not install mutagen (offline?). Metadata will be derived from filenames.\n")
    -
    -	return python_path
 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  
    -def run_in_venv():
    -	"""Re-run this script in the virtual environment"""
    -	python_path = setup_venv()
    -
    -	# Re-run this script with the venv Python
    -	print("Running scanner in virtual environment...\n")
    -	subprocess.check_call([str(python_path), __file__, "--in-venv"])
    -	sys.exit(0)
 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  
    -def scan_tracks():
    -	"""Main function to scan audio files and generate tracks.json"""
    -	# Import mutagen here (only after venv is active)
    -	try:
    -		from mutagen import File as MutagenFile
    -		has_mutagen = True
    -	except ImportError:
    -		MutagenFile = None
    -		has_mutagen = False
    -		print("Mutagen not available. Metadata will be derived from filenames.\n")
 56  
    -	# Supported audio formats
    -	SUPPORTED_EXTENSIONS = ('.mp3', '.m4a', '.ogg', '.flac', '.wav')
 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  
    -	# Check if tracks directory exists, create if it doesn't
    -	if not MIX_DIR.exists():
    -		print(f"Creating {MIX_DIR.name} directory...")
    -		MIX_DIR.mkdir(parents=True, exist_ok=True)
    -		print(f"✓ {MIX_DIR.name} directory created.")
    -		print(f"\nAdd audio files to the {MIX_DIR.name} directory and run this script again.")
    -		print(f"Supported formats: {', '.join(SUPPORTED_EXTENSIONS)}")
    -		sys.exit(0)
 63 +	with open(STYLES_CSS, 'r', encoding='utf-8') as f:
 64 +		content = f.read()
 65  
    -	# Find all supported audio files
    -	audio_files = [f for f in MIX_DIR.iterdir() if f.suffix.lower() in SUPPORTED_EXTENSIONS]
 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  
    -	if not audio_files:
    -		print(f"No audio files found in {MIX_DIR}")
    -		print(f"\nPlease add audio files to the {MIX_DIR.name} directory and run this script again.")
    -		print(f"Supported formats: {', '.join(SUPPORTED_EXTENSIONS)}")
    -		sys.exit(0)
 76 +	print("Warning: --background not found in styles.css. Using default color.")
 77 +	return "#080a0c"
 78  
    -	print(f"Found {len(audio_files)} audio file(s). Extracting metadata...\n")
 79  
    -	tracks = []
 80 +def _next_cache_name(app_name, manifest_path):
 81 +	"""Determine the next cache name by incrementing the version in an existing manifest.
 82  
    -	for audio_file in sorted(audio_files):
 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:
    -			title = None
    -			artist = None
    -
    -			if has_mutagen:
    -				audio = MutagenFile(audio_file, easy=True)
    -				if audio and audio.tags:
    -					title = audio.tags.get('title', [None])[0]
    -					artist = audio.tags.get('artist', [None])[0]
    -
    -			# Fallback to filename for title if not found
    -			if not title:
    -				title = audio_file.stem  # filename without extension
    -
    -			# Fallback to "Unknown Artist" if not found
    -			if not artist:
    -				artist = "Unknown Artist"
    -
    -			track_info = {
    -				"title": title,
    -				"artist": artist,
    -				"filename": audio_file.name
 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 catalog.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  			}
    -
    -			tracks.append(track_info)
    -			print(f"✓ {track_info['artist']} - {track_info['title']}")
    -
    -		except Exception as e:
    -			print(f"✗ Error reading {audio_file.name}: {e}")
    -			continue
    -
    -	# Check if ALL titles start with numbers
    -	# If so, strip the leading numbers from all titles
    -	import re
    -	all_have_leading_numbers = all(
    -		re.match(r'^\d+\s*[-.]?\s*', track['title'])
    -		for track in tracks
    -	)
    -
    -	if all_have_leading_numbers and tracks:
    -		print("\nDetected track numbers in all titles. Stripping them...")
    -		for track in tracks:
    -			original_title = track['title']
    -			# Remove leading number pattern
    -			cleaned_title = re.sub(r'^\d+\s*[-.]?\s*', '', original_title)
    -			if cleaned_title:  # Only update if something remains
    -				track['title'] = cleaned_title
    -				if cleaned_title != original_title:
    -					print(f"  {original_title} → {cleaned_title}")
    -
    -	if not tracks:
    -		print("\nNo valid audio files could be processed.")
    -		sys.exit(1)
    -
    -	# Write to tracks.json
    -	try:
    -		with open(OUTPUT_FILE, 'w', encoding='utf-8') as f:
    -			json.dump(tracks, f, indent="\t", ensure_ascii=False)
    -
    -		print(f"\n✓ Successfully generated {OUTPUT_FILE.name} with {len(tracks)} track(s).")
    -
    -	except Exception as e:
    -		print(f"\nError writing {OUTPUT_FILE.name}: {e}")
    -		sys.exit(1)
    -
    -
    -def main():
    -	"""Main entry point"""
    -	# Check if we're already running in venv
    -	if "--in-venv" not in sys.argv:
    -		run_in_venv()
    -	else:
    -		scan_tracks()
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 +	# Build static files list for service worker
164 +	static_files = [
165 +		"./",
166 +		"index.html",
167 +		"manifest.json",
168 +		"resources/styles.css",
169 +		"resources/script.js",
170 +		"mix/tracks.json",
171 +		"resources/icon.png",
172 +		"resources/play.svg",
173 +		"resources/pause.svg",
174 +		"resources/prev.svg",
175 +		"resources/next.svg",
176 +		"resources/repeat.svg",
177 +	]
178 +
179 +	# Conditionally include optional files if they exist
180 +	for optional_file in ["album_art.jpg", "custom.css", "custom.js"]:
181 +		if (SCRIPT_DIR / "mix" / optional_file).exists():
182 +			static_files.append(f"mix/{optional_file}")
183 +
184 +	static_files_js = json.dumps(static_files)
185 +
186 +	# Generate service-worker.js
187 +	service_worker_content = f'''// Auto-generated service worker for {app_name} PWA
188 +const CACHE_NAME = '{cache_name}';
189 +
190 +// Get the base path from the service worker location
191 +const getBasePath = () => {{
192 +	const swPath = self.location.pathname;
193 +	return swPath.substring(0, swPath.lastIndexOf('/') + 1);
194 +}};
195 +
196 +const basePath = getBasePath();
197 +
198 +// Static files to cache on install
199 +const STATIC_FILES = {static_files_js};
200 +
201 +// Install event - cache static resources
202 +// Audio files will be cached by the main app's blob preloading system
203 +self.addEventListener('install', (event) => {{
204 +	console.log('Service Worker installing...', 'Base path:', basePath);
205 +	event.waitUntil(
206 +		caches.open(CACHE_NAME).then(cache => {{
207 +			// Make URLs absolute relative to service worker location
208 +			const absoluteUrls = STATIC_FILES.map(url => {{
209 +				if (url === './') return basePath;
210 +				return new URL(url, basePath + 'index.html').href;
211 +			}});
212 +			console.log('Caching', absoluteUrls.length, 'static resources');
213 +
214 +			// Cache files individually with better error handling
215 +			// Using Promise.allSettled to continue even if some fail
216 +			return Promise.allSettled(
217 +				absoluteUrls.map(url =>
218 +					fetch(url, {{ cache: 'no-cache' }})
219 +						.then(response => {{
220 +							if (!response.ok) {{
221 +								throw new Error(`HTTP error! status: ${{response.status}}`);
222 +							}}
223 +							return cache.put(url, response);
224 +						}})
225 +						.then(() => console.log('✓ Cached:', url))
226 +						.catch(err => {{
227 +							console.error('✗ Failed to cache:', url, err);
228 +							throw err;
229 +						}})
230 +				)
231 +			).then(results => {{
232 +				const failed = results.filter(r => r.status === 'rejected');
233 +				const succeeded = results.filter(r => r.status === 'fulfilled');
234 +				console.log(`Cached ${{succeeded.length}}/${{results.length}} static resources`);
235 +				if (failed.length > 0) {{
236 +					console.warn(`Failed to cache ${{failed.length}} resources`);
237 +				}}
238 +			}});
239 +		}}).then(() => {{
240 +			console.log('Service Worker installation complete');
241 +			return self.skipWaiting();
242 +		}}).catch(error => {{
243 +			console.error('Service Worker installation failed:', error);
244 +		}})
245 +	);
246 +}});
247 +
248 +// Activate event - clean up old caches
249 +self.addEventListener('activate', (event) => {{
250 +	console.log('Service Worker activating...');
251 +	event.waitUntil(
252 +		caches.keys().then((cacheNames) => {{
253 +			return Promise.all(
254 +				cacheNames.map((cacheName) => {{
255 +					if (cacheName !== CACHE_NAME) {{
256 +						console.log('Deleting old cache:', cacheName);
257 +						return caches.delete(cacheName);
258 +					}}
259 +				}})
260 +			);
261 +		}}).then(() => self.clients.claim())
262 +	);
263 +}});
264 +
265 +// Fetch event - cache first, network fallback
266 +self.addEventListener('fetch', (event) => {{
267 +	// Ignore non-http(s) requests like blob: URLs, data: URLs, chrome-extension:, etc.
268 +	if (!event.request.url.startsWith('http')) {{
269 +		return;
270 +	}}
271 +
272 +	event.respondWith(
273 +		caches.match(event.request)
274 +			.then((cachedResponse) => {{
275 +				if (cachedResponse) {{
276 +					console.log('✓ Serving from cache:', event.request.url);
277 +					return cachedResponse;
278 +				}}
279 +
280 +				// Not in cache - try network
281 +				console.log('⟳ Fetching from network:', event.request.url);
282 +				return fetch(event.request)
283 +					.then((networkResponse) => {{
284 +						// Check if valid response
285 +						if (!networkResponse || networkResponse.status !== 200 || networkResponse.type === 'error') {{
286 +							return networkResponse;
287 +						}}
288 +
289 +						// Clone and cache for future offline use
290 +						const responseToCache = networkResponse.clone();
291 +						caches.open(CACHE_NAME)
292 +							.then((cache) => {{
293 +								cache.put(event.request, responseToCache);
294 +								console.log('✓ Cached from network:', event.request.url);
295 +							}})
296 +							.catch(err => console.error('Failed to cache:', err));
297 +
298 +						return networkResponse;
299 +					}})
300 +					.catch((error) => {{
301 +						console.error('✗ Network fetch failed for:', event.request.url, error);
302 +						throw error;
303 +					}});
304 +			}})
305 +	);
306 +}});
307 +'''
308 +
309 +	with open(SCRIPT_DIR / "service-worker.js", 'w', encoding='utf-8') as f:
310 +		f.write(service_worker_content)
311 +	print("✓ Generated service-worker.js")
312 +	print()
313 +	print("PWA manifests generated successfully!")
314  
315  
316  if __name__ == "__main__":
    -	main()
317 +	# When run directly, get configuration and generate manifests
318 +	app_name, base_path = get_configuration()
319 +	generate_pwa_manifests(app_name, base_path)
build.py -> catalog.py
generate_manifests.py
  -#!/usr/bin/env python3
  -"""
  -Creates manifest.json and service-worker.js
  -based on the contents of tracks.json
  -"""
  -
  -import json
  -import re
  -from pathlib import Path
  -
  -def get_configuration():
  -	"""Prompt user for configuration values"""
  -	print("=" * 60)
  -	print("PWA Configuration")
  -	print("=" * 60)
  -	print()
  -
  -	# Get app name
  -	app_name = input("Enter a name for your mixapp: ").strip()
  -	if not app_name:
  -		print("Error: App name is required")
  -		exit(1)
  -
  -	# Get base path with smart default
  -	default_path = app_name.lower().replace(" ", "_")
  -	print()
  -	print(f"Enter the deployment path (or press Return/Enter for default)")
  -	print(f"Default: /{default_path}/")
  -	base_path_input = input("Path: ").strip()
  -
  -	if base_path_input:
  -		# User provided a path - ensure it has leading/trailing slashes
  -		base_path = base_path_input
  -		if not base_path.startswith("/"):
  -			base_path = "/" + base_path
  -		if not base_path.endswith("/"):
  -			base_path = base_path + "/"
  -	else:
  -		# Use default
  -		base_path = f"/{default_path}/"
  -		print(f"Using default path: {base_path}")
  -
  -	print()
  -	print(f"Configuration:")
  -	print(f"  App Name: {app_name}")
  -	print(f"  Base Path: {base_path}")
  -	print()
  -
  -	return app_name, base_path
  -
  -# File paths (no need to edit these)
  -SCRIPT_DIR = Path(__file__).parent.absolute()
  -TRACKS_JSON = SCRIPT_DIR / "mix" / "tracks.json"
  -STYLES_CSS = SCRIPT_DIR / "resources" / "styles.css"
  -
  -
  -def get_background_color():
  -	"""Extract the --background CSS variable from styles.css"""
  -	if not STYLES_CSS.exists():
  -		print("Warning: styles.css not found. Using default color.")
  -		return "#080a0c"
  -
  -	with open(STYLES_CSS, 'r', encoding='utf-8') as f:
  -		content = f.read()
  -
  -	# Look for --background: <color>; pattern
  -	match = re.search(
  -		r'--background:\s*([#a-zA-Z0-9(),.\s]+?)\s*;',
  -		content
  -	)
  -	if match:
  -		color = match.group(1).strip()
  -		print(f"Found background color in styles.css: {color}")
  -		return color
  -
  -	print("Warning: --background not found in styles.css. Using default color.")
  -	return "#080a0c"
  -
  -
  -def _next_cache_name(app_name, manifest_path):
  -	"""Determine the next cache name by incrementing the version in an existing manifest.
  -
  -	If manifest.json doesn't exist or has no versioned cache_name, returns "{app_name}-v1".
  -	If it already has e.g. "heaven-v1", returns "heaven-v1.1".
  -	If it already has e.g. "heaven-v1.3", returns "heaven-v1.4".
  -	"""
  -	if manifest_path.exists():
  -		try:
  -			with open(manifest_path, 'r', encoding='utf-8') as f:
  -				existing = json.load(f)
  -			old_cache = existing.get("cache_name", "")
  -			# Match pattern like "appname-v1" or "appname-v1.3"
  -			match = re.match(r'^(.+)-v(\d+)(?:\.(\d+))?$', old_cache)
  -			if match:
  -				prefix = match.group(1)
  -				major = int(match.group(2))
  -				minor = int(match.group(3)) if match.group(3) is not None else 0
  -				# If app was renamed, start fresh with new name
  -				if prefix != app_name:
  -					return f"{app_name}-v1"
  -				return f"{app_name}-v{major}.{minor + 1}"
  -		except (json.JSONDecodeError, KeyError):
  -			pass
  -	return f"{app_name}-v1"
  -
  -
  -def generate_pwa_manifests(app_name=None, base_path=None):
  -	"""Generate PWA manifest files based on tracks.json
  -
  -	Args:
  -		app_name: Name of the app. If None, will be prompted via get_configuration()
  -		base_path: Base path for the app. If None, will be prompted via get_configuration()
  -	"""
  -	# Get configuration if not provided
  -	if app_name is None or base_path is None:
  -		app_name, base_path = get_configuration()
  -
  -	# Derived values
  -	short_name = app_name
  -	cache_name = _next_cache_name(app_name, SCRIPT_DIR / "manifest.json")
  -	app_description = f"{app_name}"
  -
  -	print("Generating PWA manifests...")
  -	print(f"  Cache name: {cache_name}")
  -
  -	# Load tracks.json
  -	if not TRACKS_JSON.exists():
  -		print("Error: tracks.json not found. Run build.py first.")
  -		return
  -
  -	with open(TRACKS_JSON, 'r', encoding='utf-8') as f:
  -		tracks = json.load(f)
  -
  -	# Get background color from styles.css
  -	background_color = get_background_color()
  -
  -	# Generate manifest.json
  -	manifest = {
  -		"id": base_path,
  -		"name": app_name,
  -		"short_name": short_name,
  -		"description": app_description,
  -		"start_url": base_path,
  -		"scope": base_path,
  -		"display": "standalone",
  -		"background_color": background_color,
  -		"theme_color": background_color,
  -		"cache_name": cache_name,  # Custom field for script.js to use
  -		"icons": [
  -			{
  -				"src": f"{base_path}resources/icon.png",
  -				"sizes": "640x640",
  -				"type": "image/png",
  -				"purpose": "any maskable"
  -			}
  -		]
  -	}
  -
  -	with open(SCRIPT_DIR / "manifest.json", 'w', encoding='utf-8') as f:
  -		json.dump(manifest, f, indent=2)
  -	print("✓ Generated manifest.json")
  -
  -	# Build static files list for service worker
  -	static_files = [
  -		"./",
  -		"index.html",
  -		"manifest.json",
  -		"resources/styles.css",
  -		"resources/script.js",
  -		"mix/tracks.json",
  -		"resources/icon.png",
  -		"resources/play.svg",
  -		"resources/pause.svg",
  -		"resources/prev.svg",
  -		"resources/next.svg",
  -		"resources/repeat.svg",
  -	]
  -
  -	# Conditionally include optional files if they exist
  -	for optional_file in ["album_art.jpg", "custom.css", "custom.js"]:
  -		if (SCRIPT_DIR / "mix" / optional_file).exists():
  -			static_files.append(f"mix/{optional_file}")
  -
  -	static_files_js = json.dumps(static_files)
  -
  -	# Generate service-worker.js
  -	service_worker_content = f'''// Auto-generated service worker for {app_name} PWA
  -const CACHE_NAME = '{cache_name}';
  -
  -// Get the base path from the service worker location
  -const getBasePath = () => {{
  -	const swPath = self.location.pathname;
  -	return swPath.substring(0, swPath.lastIndexOf('/') + 1);
  -}};
  -
  -const basePath = getBasePath();
  -
  -// Static files to cache on install
  -const STATIC_FILES = {static_files_js};
  -
  -// Install event - cache static resources
  -// Audio files will be cached by the main app's blob preloading system
  -self.addEventListener('install', (event) => {{
  -	console.log('Service Worker installing...', 'Base path:', basePath);
  -	event.waitUntil(
  -		caches.open(CACHE_NAME).then(cache => {{
  -			// Make URLs absolute relative to service worker location
  -			const absoluteUrls = STATIC_FILES.map(url => {{
  -				if (url === './') return basePath;
  -				return new URL(url, basePath + 'index.html').href;
  -			}});
  -			console.log('Caching', absoluteUrls.length, 'static resources');
  -
  -			// Cache files individually with better error handling
  -			// Using Promise.allSettled to continue even if some fail
  -			return Promise.allSettled(
  -				absoluteUrls.map(url =>
  -					fetch(url, {{ cache: 'no-cache' }})
  -						.then(response => {{
  -							if (!response.ok) {{
  -								throw new Error(`HTTP error! status: ${{response.status}}`);
  -							}}
  -							return cache.put(url, response);
  -						}})
  -						.then(() => console.log('✓ Cached:', url))
  -						.catch(err => {{
  -							console.error('✗ Failed to cache:', url, err);
  -							throw err;
  -						}})
  -				)
  -			).then(results => {{
  -				const failed = results.filter(r => r.status === 'rejected');
  -				const succeeded = results.filter(r => r.status === 'fulfilled');
  -				console.log(`Cached ${{succeeded.length}}/${{results.length}} static resources`);
  -				if (failed.length > 0) {{
  -					console.warn(`Failed to cache ${{failed.length}} resources`);
  -				}}
  -			}});
  -		}}).then(() => {{
  -			console.log('Service Worker installation complete');
  -			return self.skipWaiting();
  -		}}).catch(error => {{
  -			console.error('Service Worker installation failed:', error);
  -		}})
  -	);
  -}});
  -
  -// Activate event - clean up old caches
  -self.addEventListener('activate', (event) => {{
  -	console.log('Service Worker activating...');
  -	event.waitUntil(
  -		caches.keys().then((cacheNames) => {{
  -			return Promise.all(
  -				cacheNames.map((cacheName) => {{
  -					if (cacheName !== CACHE_NAME) {{
  -						console.log('Deleting old cache:', cacheName);
  -						return caches.delete(cacheName);
  -					}}
  -				}})
  -			);
  -		}}).then(() => self.clients.claim())
  -	);
  -}});
  -
  -// Fetch event - cache first, network fallback
  -self.addEventListener('fetch', (event) => {{
  -	// Ignore non-http(s) requests like blob: URLs, data: URLs, chrome-extension:, etc.
  -	if (!event.request.url.startsWith('http')) {{
  -		return;
  -	}}
  -
  -	event.respondWith(
  -		caches.match(event.request)
  -			.then((cachedResponse) => {{
  -				if (cachedResponse) {{
  -					console.log('✓ Serving from cache:', event.request.url);
  -					return cachedResponse;
  -				}}
  -
  -				// Not in cache - try network
  -				console.log('⟳ Fetching from network:', event.request.url);
  -				return fetch(event.request)
  -					.then((networkResponse) => {{
  -						// Check if valid response
  -						if (!networkResponse || networkResponse.status !== 200 || networkResponse.type === 'error') {{
  -							return networkResponse;
  -						}}
  -
  -						// Clone and cache for future offline use
  -						const responseToCache = networkResponse.clone();
  -						caches.open(CACHE_NAME)
  -							.then((cache) => {{
  -								cache.put(event.request, responseToCache);
  -								console.log('✓ Cached from network:', event.request.url);
  -							}})
  -							.catch(err => console.error('Failed to cache:', err));
  -
  -						return networkResponse;
  -					}})
  -					.catch((error) => {{
  -						console.error('✗ Network fetch failed for:', event.request.url, error);
  -						throw error;
  -					}});
  -			}})
  -	);
  -}});
  -'''
  -
  -	with open(SCRIPT_DIR / "service-worker.js", 'w', encoding='utf-8') as f:
  -		f.write(service_worker_content)
  -	print("✓ Generated service-worker.js")
  -	print()
  -	print("PWA manifests generated successfully!")
  -
  -
  -if __name__ == "__main__":
  -	# When run directly, get configuration and generate manifests
  -	app_name, base_path = get_configuration()
  -	generate_pwa_manifests(app_name, base_path)
mix/readme.md
1  # /mix
2  
  -add your audio files here, then run `build.py` to create `tracks.json`.
3 +add your audio files here, then run `catalog.py` to create `tracks.json`.
4  
5  supported formats: `.mp3`, `.m4a`, `.ogg`, `.flac`, `.wav`
6  
readme.md
40  	- add your audio files to the `/mix` directory, or use:
41  		- `./rip.py` to rip tracks from a physical CD
42  		- `./buy.py` to search for tracks to purchase (opens in iTunes on MacOS, <a href="https://song.link/i/1651294855">song.link</a> otherwise)
   -	- run `./build.py` to parse `/mix` and populate `tracks.json`, which defines the tracks available to the player. after running `./build.py` once, you can manually edit `tracks.json` to refine your mix.
43 +	- run `./catalog.py` to parse `/mix` and populate `tracks.json`, which defines the tracks available to the player. after running `./catalog.py` once, you can manually edit `tracks.json` to refine your mix.
44  	- optionally, add an `album_art.jpg` to `/mix` to set the cover art for your mix.
45  	- supported audio formats: `.mp3`, `.m4a`, `.ogg`, `.flac`, `.wav`
46  

48  	- run `./host.py` to start a local HTTP server for testing. you can scan the QR code printed to the terminal to test the app from any device on your local network.
49  
50  3. **manifesting**
   -	- run `./generate_manifests.py` and follow the interactive prompts to specify an app name and the remote server path where your app will be hosted.
   -		- this creates the config files that enable offline functionality: `manifest.json` and `service-worker.js`.
51 +	- run `./build.py` and follow the interactive prompts to specify an app name and the remote server path where your mixapp will be hosted.
52 +		- this creates the config files that enable offline functionality: `manifest.json` and `service-worker.js`
53  
54  4. **ship it**
55  	- upload the entire project directory to any web host with HTTPS support (GitHub Pages, AWS S3, etc.)
rip.py
363  	if success_count > 0:
364  		print(f"\nTracks saved to: {MIX_DIR}")
365  		print("\nNext steps:")
    -		print("  1. Run build.py to generate tracks.json with metadata")
366 +		print("  1. Run catalog.py to generate tracks.json with metadata")
367  		print("  2. Run host.py to test your mixtape locally")
368  
369  		# Eject the CD