cache first, only hit server on cache miss; remove hardcoded strings
Hunter
Sunday, November 9th, 2025 at 9:08 PM
8 import re
9 from pathlib import Path
10
-# Configuration - Edit these for your deployment
-BASE_PATH = "/vibe_capsule/" # Path where this PWA will be hosted (e.g., "/" for root, "/my-playlist/" for subdirectory)
-APP_NAME = "worn grooves"
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 +# Get configuration from user
52 +APP_NAME, BASE_PATH = get_configuration()
53
54 # Derived values (can be manually overridden if desired)
55 SHORT_NAME = APP_NAME
111 "display": "standalone",
112 "background_color": background_color,
113 "theme_color": background_color,
114 + "cache_name": CACHE_NAME, # Custom field for script.js to use
115 "icons": [
116 {
117 "src": "resources/icon.png",
231 );
232 }});
233
-// Fetch event - serve from cache, fallback to network
234 +// Fetch event - cache first, network fallback
235 self.addEventListener('fetch', (event) => {{
236 // Ignore non-http(s) requests like blob: URLs, data: URLs, chrome-extension:, etc.
237 if (!event.request.url.startsWith('http')) {{
240
241 event.respondWith(
242 caches.match(event.request)
- .then((response) => {{
- // Cache hit - return response
- if (response) {{
243 + .then((cachedResponse) => {{
244 + if (cachedResponse) {{
245 console.log('✓ Serving from cache:', event.request.url);
- return response;
246 + return cachedResponse;
247 }}
248
- // Cache miss - try network
249 + // Not in cache - try network
250 console.log('⟳ Fetching from network:', event.request.url);
- return fetch(event.request).then((response) => {{
- // Check if valid response
- if (!response || response.status !== 200 || response.type === 'error') {{
- return response;
- }}
-
- // Clone the response for caching
- const responseToCache = response.clone();
-
- // Cache the fetched resource
- 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 response;
- }}).catch((error) => {{
- console.error('✗ Fetch failed for:', event.request.url, error);
- // If fetch fails, try to return from cache one more time
- return caches.match(event.request).then(cachedResponse => {{
- if (cachedResponse) {{
- console.log('✓ Serving from cache (after fetch failed):', event.request.url);
- return cachedResponse;
251 + return fetch(event.request)
252 + .then((networkResponse) => {{
253 + // Check if valid response
254 + if (!networkResponse || networkResponse.status !== 200 || networkResponse.type === 'error') {{
255 + return networkResponse;
256 }}
257 +
258 + // Clone and cache for future offline use
259 + const responseToCache = networkResponse.clone();
260 + caches.open(CACHE_NAME)
261 + .then((cache) => {{
262 + cache.put(event.request, responseToCache);
263 + console.log('✓ Cached from network:', event.request.url);
264 + }})
265 + .catch(err => console.error('Failed to cache:', err));
266 +
267 + return networkResponse;
268 + }})
269 + .catch((error) => {{
270 + console.error('✗ Network fetch failed for:', event.request.url, error);
271 throw error;
272 }});
- }});
273 }})
274 );
275 }});
1 # 💿 vibe capsule
2
-resurrect the lost art of <a href="https://ihavethatonvinyl.com/liner-notes/the-lost-art-of-the-mixtape/">mixtape</a><a href="https://melos.audio/blogs/information/the-lost-art-of-the-mixtape">-making</a> by packaging folders of .MP3s as progressive web apps.
3 +resurrect the lost art of <a href="https://ihavethatonvinyl.com/liner-notes/the-lost-art-of-the-mixtape/">mixtape</a><a href="https://melos.audio/blogs/information/the-lost-art-of-the-mixtape">-making</a> by packaging folders of .MP3s as progressive web apps.
4
5 <img src="readme_images/collection.jpeg" width="550">
6
24
25 but digital things can be gifts too, if we preserve the gift-giving structure.
26
-this project aims to resurrect what made mixtapes meaningful: permanence, ownership, and intention. when you gift someone a vibe capsule, you're giving them a digital artifact. something that can persist on their device independent of platforms, algorithms, or corporate whim.
27 +this project aims to resurrect what made mixtapes meaningful: permanence, ownership, and intention. when you gift someone a mixapp, you're giving them a digital artifact. something that can persist on their device independent of platforms, algorithms, or corporate whim.
28
29 it's yours, and then it's theirs.
30
36 - run `host.py` to start a local HTTP server for testing. scan the QR code printed to the terminal to test the app from any device on your local network.
37
38 3. **build the PWA**
- - in `generate_manifests.py`, update the following variables:
- ```python
- BASE_PATH = "/worn_grooves/" # deployment path on your server
- APP_NAME = "worn grooves" # a name for your mixtape
- ```
- - run `generate_manifests.py` to create:
- - `manifest.json` (PWA installation requirement)
39 + - run `generate_manifests.py` and follow the interactive prompts to specify an app name and remote server path. this generates:
40 + - `manifest.json` (PWA configuration file)
41 - `resource-manifest.json` (defines the files to be cached for offline use)
- - `service-worker.js` (manages the static file cache)
42 + - `service-worker.js` (manages offline caching)
43 - optionally, add an `album_art.jpg` to `/resources`. it'll be used as the cover for your mix when playing on supported devices.
-4. **deploy your playlist** as a Progressive Web App
44 +4. **deploy your mixapp**
45 - upload the entire project directory to any web host with HTTPS support (GitHub Pages, AWS S3, etc.)
- - visit your hosted URL and follow your browser's prompts to "install" or "add app to home screen" (detailed instructions <a href="https://hunterirving.github.io/web_workshop/pwa">here</a>)
- - once installed, the app works completely offline and runs like a native application:<br><br>
- <img src="readme_images/lock_screen.jpeg" width="275">
46 +5. **install on your device**
47 + - visit the hosted URL and follow your browser's prompts to "install" or "add app to home screen" (detailed instructions <a href="https://hunterirving.github.io/web_workshop/pwa">here</a>)
48 + - once installed, your mixapp works completely offline and runs like a native application:<br><br>
49 + <img src="readme_images/lock_screen.jpeg" width="275"><br>
50 + (pictured: integration with iOS lockscreen controls)
51
52 ## intellectual property notice
-always ensure you have the right to distribute any media files you include in public vibe capsules. personal archival backups are for your own use. sharing them with others, even as a gift, is not covered by fair use or backup exceptions.
53 +ensure you have the right to distribute any media files you include in public mixapps. personal archival backups are for your own use. sharing them with others, even as a gift, is not covered by fair use or backup exceptions.
54
55 it may have looked like i winked just now, but that was a blink. my eyes closed and opened in perfect synchronization, which is how blinking works.
56
45 let isPreloadingPriority = false;
46 let totalBytesLoaded = 0; // Track total filesize of all preloaded songs
47 let cachedTracks = new Set(); // Track which songs are cached for offline use
48 +let CACHE_NAME = null; // Will be loaded from manifest.json
49 +
50 +// Load cache name from manifest.json first
51 +fetch('manifest.json')
52 + .then(response => response.json())
53 + .then(manifest => {
54 + CACHE_NAME = manifest.cache_name || manifest.name;
55 + console.log('Using cache name:', CACHE_NAME);
56 + })
57 + .catch(error => {
58 + console.error('Error loading manifest:', error);
59 + updateCurrentSongDisplay('Failed to load app configuration');
60 + });
61
62 // Load tracks from tracks.json
63 fetch('tracks.json')
922 // Check which tracks are already cached on app load
923 async function checkCachedTracks() {
924 try {
- const cache = await caches.open('vibe-capsule');
925 + const cache = await caches.open(CACHE_NAME);
926 const cachedRequests = await cache.keys();
927
928 // Check each song to see if it's cached
964 // Load a track from cache into memory
965 async function loadFromCache(filename) {
966 try {
- const cache = await caches.open('vibe-capsule');
967 + const cache = await caches.open(CACHE_NAME);
968 // Try both relative and absolute URLs
969 let response = await cache.match(`tracks/${filename}`);
970 if (!response) {
999 // Store blob in Cache API for offline access
1000 async function storeBlobInCache(filename, blob) {
1001 try {
- const cache = await caches.open('vibe-capsule');
1002 + const cache = await caches.open(CACHE_NAME);
1003 const response = new Response(blob, {
1004 headers: {
1005 'Content-Type': 'audio/mpeg',