diff --git a/make_post_sell/lib/cache_version.py b/make_post_sell/lib/cache_version.py
new file mode 100644
index 0000000..8f186c5
--- /dev/null
+++ b/make_post_sell/lib/cache_version.py
@@ -0,0 +1,27 @@
+import hashlib
+
+from ..views.version import GIT_HASH
+
+
+def compute_cache_version(shop):
+ """Opaque token that changes when deploys land or the ring is reforged.
+
+ Clients compare this to their localStorage copy; mismatch means
+ cached ring state (ringProductIds, ringPosition, ringHistory) is
+ stale and should be dropped before the next SPA navigation.
+
+ Inputs:
+ GIT_HASH — shifts on every deploy
+ shop.json_discovery_ring — shifts on every reforge (including
+ product adds/removes/visibility flips that trigger reforge)
+ """
+ ring_str = (shop.json_discovery_ring or "") if shop is not None else ""
+ digest = hashlib.md5(f"{GIT_HASH}:{ring_str}".encode()).hexdigest()
+ return digest[:12]
+
+
+def apply_no_store_headers(response):
+ """Tell browsers not to cache this HTML response — always revalidate."""
+ response.headers["Cache-Control"] = "no-store, must-revalidate"
+ response.headers["Pragma"] = "no-cache"
+ response.headers["Expires"] = "0"
diff --git a/make_post_sell/static/js/watch.js b/make_post_sell/static/js/watch.js
index a8b21a4..5754c29 100644
--- a/make_post_sell/static/js/watch.js
+++ b/make_post_sell/static/js/watch.js
@@ -38,6 +38,31 @@
var karaokeEligible = false;
var karaokeProcessing = false;
+ // --- Cache version: flushes stale ring state on deploy / reforge ---
+ // Server stamps every page (meta[name="mps-cache-version"]) and every
+ // watch_json response. Stored client version is compared on each
+ // page load and SPA nav; mismatch drops cached ringProductIds,
+ // ringPosition, ringHistory, and ringLoops before anything reads them.
+ function enforceCacheVersion(serverVersion) {
+ if (!serverVersion) return;
+ var stored = null;
+ try { stored = localStorage.getItem('watchCacheVersion'); } catch (e) {}
+ if (stored && stored !== serverVersion) {
+ try {
+ localStorage.removeItem('watchRing');
+ localStorage.removeItem('watchRingPosition');
+ localStorage.removeItem('watchRingHistory');
+ localStorage.removeItem('watchRingLoops');
+ localStorage.removeItem('watchQueue');
+ } catch (e) {}
+ }
+ try { localStorage.setItem('watchCacheVersion', serverVersion); } catch (e) {}
+ }
+ (function initialCacheCheck() {
+ var meta = document.querySelector('meta[name="mps-cache-version"]');
+ if (meta) enforceCacheVersion(meta.getAttribute('content'));
+ })();
+
// --- Ring state (persisted in localStorage) ---
var ringProductIds = [];
var ringPosition = 0;
@@ -745,6 +770,10 @@
// --- Update page content (title, description, related, URL) ---
function updatePageContent(data) {
+ // Server may have deployed or reforged since last nav — drop
+ // stale ring state before applying new data.
+ if (data.cache_version) enforceCacheVersion(data.cache_version);
+
// Update ring and mod status from server response
if (data.ring && data.ring.length) {
ringProductIds = data.ring;
diff --git a/make_post_sell/templates/base.j2 b/make_post_sell/templates/base.j2
index eb9c21e..12abe63 100644
--- a/make_post_sell/templates/base.j2
+++ b/make_post_sell/templates/base.j2
@@ -63,6 +63,9 @@
+ {% if cache_version %}
+
+ {% endif %}
{% if request.is_saas_domain == false and request.shop and request.shop.favicon %}
diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py
index 44668c8..0e63fb1 100644
--- a/make_post_sell/tests/test_functional.py
+++ b/make_post_sell/tests/test_functional.py
@@ -3155,6 +3155,102 @@ class AuthenticatedFunctionalTests(_AuthenticatedBase):
self.assertFalse(data["valid"])
self.assertIn(stale_id, data["stale"])
+ def test_content_page_sets_no_store_cache_header(self):
+ """Content page HTML must not be cached by the browser."""
+ import json as _json
+ shop = self._create_shop_helper(
+ shop_params={**self.shop1_params, "name": "cache-header-content-shop"}
+ )
+ shop_id = str(shop.id)
+ self.testapp.post(
+ f"/s/{shop_id}/settings",
+ {"form_section": "ribbon-settings", "watch_mode": "1", "submit": "Save Settings"},
+ status=302,
+ )
+ self.testapp.post(
+ "/c/new",
+ {"title": "Cache Header Test", "description": "tests cache header", "submit": True},
+ )
+
+ products = get_all_products(self.dbsession).all()
+ product = products[0]
+ product._file_metadata = {"product": {"extension": "mp4", "content_type": "video/mp4"}}
+ product.json_file_metadata = _json.dumps(product._file_metadata)
+ product.visibility = 1
+ self.dbsession.flush()
+ product_id = str(product.id)
+ product_slug = product.slug
+ transaction.commit()
+
+ res = self.testapp.get(f"/c/{product_id}/{product_slug}", status=200)
+ self.assertIn("no-store", res.headers.get("Cache-Control", ""))
+ # meta tag with cache version present
+ self.assertIn('name="mps-cache-version"', res.body.decode())
+
+ def test_watch_json_includes_cache_version(self):
+ """watch_json response carries cache_version for client compare."""
+ import json as _json
+ shop = self._create_shop_helper(
+ shop_params={**self.shop1_params, "name": "cache-header-watch-shop"}
+ )
+ shop_id = str(shop.id)
+ self.testapp.post(
+ f"/s/{shop_id}/settings",
+ {"form_section": "ribbon-settings", "watch_mode": "1", "submit": "Save Settings"},
+ status=302,
+ )
+ self.testapp.post(
+ "/c/new",
+ {"title": "Watch JSON Cache", "description": "desc", "submit": True},
+ )
+ products = get_all_products(self.dbsession).all()
+ product = products[0]
+ product._file_metadata = {"product": {"extension": "mp4", "content_type": "video/mp4"}}
+ product.json_file_metadata = _json.dumps(product._file_metadata)
+ product.visibility = 1
+ self.dbsession.flush()
+ product_id = str(product.id)
+ transaction.commit()
+
+ # Product has no uploaded media → 404, but cache_version still
+ # comes back on the error response so clients can flush state.
+ res = self.testapp.get(f"/watch/{product_id}/json", status=404)
+ self.assertIn("cache_version", res.json)
+ self.assertTrue(len(res.json["cache_version"]) > 0)
+
+ def test_cache_version_changes_after_reforge(self):
+ """Reforging the ring changes cache_version — clients will flush."""
+ shop = self._create_shop_helper(
+ shop_params={**self.shop1_params, "name": "cache-version-change-shop"}
+ )
+ shop_id = str(shop.id)
+ self.testapp.post(
+ f"/s/{shop_id}/settings",
+ {"form_section": "ribbon-settings", "watch_mode": "1", "submit": "Save Settings"},
+ status=302,
+ )
+ self.testapp.post(
+ f"/p/new?shop_id={shop_id}", self.product1_params
+ ).follow()
+
+ from ..models.shop import reforge_discovery_ring
+ from ..lib.cache_version import compute_cache_version
+ self.dbsession.expire(shop)
+ reforge_discovery_ring(shop)
+ self.dbsession.flush()
+ v1 = compute_cache_version(shop)
+
+ # Add a second product — next reforge will produce a different ring
+ self.testapp.post(
+ f"/p/new?shop_id={shop_id}",
+ {**self.product1_params, "title": "second product for cache version"},
+ ).follow()
+ reforge_discovery_ring(shop)
+ self.dbsession.flush()
+ v2 = compute_cache_version(shop)
+
+ self.assertNotEqual(v1, v2)
+
def test_ring_health_shop_not_found(self):
"""Unknown shop_id returns 404."""
self._create_shop_helper(
diff --git a/make_post_sell/tests/test_models.py b/make_post_sell/tests/test_models.py
index 64c7246..5453184 100644
--- a/make_post_sell/tests/test_models.py
+++ b/make_post_sell/tests/test_models.py
@@ -2688,6 +2688,63 @@ class TestValidateDiscoveryRing(unittest.TestCase):
self.assertEqual(health["public_count"], 2)
+class TestCacheVersion(unittest.TestCase):
+ """Test the cache_version helper used to invalidate client ring state."""
+
+ def _make_shop(self, ring=None):
+ shop = Shop("cv-shop", "555-555-5555", "123 CV St", "desc")
+ if ring is not None:
+ shop.discovery_ring = ring
+ return shop
+
+ def test_stable_for_unchanged_inputs(self):
+ """Same GIT_HASH + same ring → same cache_version."""
+ from ..lib.cache_version import compute_cache_version
+ shop = self._make_shop(ring=["a", "b", "c"])
+ self.assertEqual(
+ compute_cache_version(shop),
+ compute_cache_version(shop),
+ )
+
+ def test_changes_when_ring_content_changes(self):
+ """Any change to the ring list shifts the cache_version."""
+ from ..lib.cache_version import compute_cache_version
+ v1 = compute_cache_version(self._make_shop(ring=["a", "b", "c"]))
+ v2 = compute_cache_version(self._make_shop(ring=["a", "b", "d"]))
+ self.assertNotEqual(v1, v2)
+
+ def test_changes_when_ring_order_changes(self):
+ """Reordering the ring (reforge) shifts the cache_version."""
+ from ..lib.cache_version import compute_cache_version
+ v1 = compute_cache_version(self._make_shop(ring=["a", "b", "c"]))
+ v2 = compute_cache_version(self._make_shop(ring=["c", "b", "a"]))
+ self.assertNotEqual(v1, v2)
+
+ def test_none_shop_returns_token(self):
+ """Passing None shop does not crash — returns a stable token."""
+ from ..lib.cache_version import compute_cache_version
+ v = compute_cache_version(None)
+ self.assertIsInstance(v, str)
+ self.assertTrue(len(v) > 0)
+
+ def test_empty_ring_is_not_error(self):
+ """Shop with no ring still gets a cache_version."""
+ from ..lib.cache_version import compute_cache_version
+ v = compute_cache_version(self._make_shop(ring=[]))
+ self.assertIsInstance(v, str)
+ self.assertTrue(len(v) > 0)
+
+ def test_changes_with_git_hash(self):
+ """GIT_HASH flip changes cache_version (simulated via patch)."""
+ from ..lib import cache_version as cv_module
+ shop = self._make_shop(ring=["a", "b"])
+ with mock.patch.object(cv_module, "GIT_HASH", "hash-one"):
+ v1 = cv_module.compute_cache_version(shop)
+ with mock.patch.object(cv_module, "GIT_HASH", "hash-two"):
+ v2 = cv_module.compute_cache_version(shop)
+ self.assertNotEqual(v1, v2)
+
+
class TestAsyncDiscoveryRing(unittest.TestCase):
"""Test the async ring reforge with dirty bit debounce."""
diff --git a/make_post_sell/views/content.py b/make_post_sell/views/content.py
index d64d129..6ca6897 100644
--- a/make_post_sell/views/content.py
+++ b/make_post_sell/views/content.py
@@ -2,6 +2,7 @@ from pyramid.view import view_config
from . import get_referer_or_home
from ..models.product import get_media_type
+from ..lib.cache_version import compute_cache_version, apply_no_store_headers
from pyramid.httpexceptions import HTTPFound
@@ -82,6 +83,11 @@ def content(request):
else:
related_products = get_related_products(product)
+ # Dynamic content page — never cache HTML at the browser. Sidebar
+ # (Up Next) reflects live ring state; stale HTML produces "pocket"
+ # confusion where the sidebar disagrees with reality.
+ apply_no_store_headers(request.response)
+
# Generate CDN URLs for karaoke tracks if available
instrumentals_url = None
vocals_url = None
@@ -107,6 +113,7 @@ def content(request):
"comments": comments,
"shop": product.shop,
"related_products": related_products,
+ "cache_version": compute_cache_version(product.shop),
"instrumentals_url": instrumentals_url,
"vocals_url": vocals_url,
"karaoke_eligible": karaoke_eligible,
diff --git a/make_post_sell/views/product.py b/make_post_sell/views/product.py
index 8bac053..f2b7310 100644
--- a/make_post_sell/views/product.py
+++ b/make_post_sell/views/product.py
@@ -16,6 +16,7 @@ from ..models.inventory import Inventory
from ..lib.currency import validate_float, cents_to_dollars
from ..lib.time_funcs import timestamp_to_ago_string, timestamp_to_datetime
+from ..lib.cache_version import compute_cache_version, apply_no_store_headers
def checkbox_to_bool(checkbox):
@@ -118,6 +119,10 @@ def product(request):
"is_current": i == 0,
})
+ # Dynamic product page — never cache HTML at the browser. Same
+ # reason as content.py: Up Next sidebar must reflect live ring state.
+ apply_no_store_headers(request.response)
+
return {
"product": product,
"product_size": product_size,
@@ -125,6 +130,7 @@ def product(request):
"comments": comments,
"shop": product.shop,
"related_products": related_products,
+ "cache_version": compute_cache_version(product.shop),
"price_history": price_history,
}
diff --git a/make_post_sell/views/watch.py b/make_post_sell/views/watch.py
index dab34ba..f2e3f43 100644
--- a/make_post_sell/views/watch.py
+++ b/make_post_sell/views/watch.py
@@ -4,6 +4,7 @@ from pyramid.view import view_config
from ..models.product import get_media_type, get_related_products, get_ring_related_products
from ..models.shop import get_shop_by_id, validate_discovery_ring
+from ..lib.cache_version import compute_cache_version
from ..lib.currency import cents_to_dollars
from ..lib.time_funcs import timestamp_to_ago_string, timestamp_to_datetime
@@ -16,30 +17,36 @@ def watch_json(request):
product = request.product
+ # Compute cache version up front so every response (including errors)
+ # carries it — lets the client flush stale localStorage ring state
+ # even when the current product can't be played.
+ shop_for_cache = request.shop if request.shop else (product.shop if product else None)
+ cache_version = compute_cache_version(shop_for_cache)
+
if not product:
request.response.status_int = 404
- return {"error": "Product not found"}
+ return {"error": "Product not found", "cache_version": cache_version}
if not request.shop or not request.shop.watch_mode_enabled:
request.response.status_int = 404
- return {"error": "Watch mode not enabled"}
+ return {"error": "Watch mode not enabled", "cache_version": cache_version}
# Only allow public products (visibility == 1)
if product.visibility != 1:
request.response.status_int = 403
- return {"error": "Not publicly available"}
+ return {"error": "Not publicly available", "cache_version": cache_version}
# Determine which file to use
if product.is_sellable:
file_key = "preview"
if file_key not in product.extensions:
request.response.status_int = 403
- return {"error": "No preview available"}
+ return {"error": "No preview available", "cache_version": cache_version}
else:
file_key = "product"
if file_key not in product.extensions:
request.response.status_int = 404
- return {"error": "No media file available"}
+ return {"error": "No media file available", "cache_version": cache_version}
# Get extension and detect media type
extension = product.extensions.get(file_key)
@@ -204,6 +211,7 @@ def watch_json(request):
})
result = {
+ "cache_version": compute_cache_version(request.shop),
"product_id": str(product.id),
"shop_id": str(product.shop_id),
"title": product.title,