- {% endif %}
{% for file_key in product.file_thumbnail_keys %}
{% if file_key in product.s3_key_thumbnails %}
@@ -193,6 +232,10 @@
{% endif %}
+
{% include 'snippets/related_content.j2' %}
diff --git a/make_post_sell/templates/snippets/related_content.j2 b/make_post_sell/templates/snippets/related_content.j2
index afecf4c..66d6ac5 100644
--- a/make_post_sell/templates/snippets/related_content.j2
+++ b/make_post_sell/templates/snippets/related_content.j2
@@ -2,12 +2,17 @@
{% endif %}
diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py
index 48491d3..558ccbb 100644
--- a/make_post_sell/tests/test_functional.py
+++ b/make_post_sell/tests/test_functional.py
@@ -2564,6 +2564,62 @@ class AuthenticatedFunctionalTests(FunctionalTests):
res = res.follow()
self.assertIn(b"Invalid unsubscribe link", res.body)
+ def test_watch_json_requires_watch_mode(self):
+ """Test that /watch/{id}/json returns 404 when watch mode is disabled."""
+ shop = self._create_shop_helper(
+ shop_params={
+ **self.shop1_params,
+ "name": "watch-json-shop",
+ }
+ )
+
+ # Create a product
+ redirect_res = self.testapp.post(
+ f"/p/new?shop_id={shop.id}", self.product1_params
+ )
+ res = redirect_res.follow()
+ self.assertIn("Great, next you may upload files.", res.body.decode())
+
+ products = get_all_products(self.dbsession).all()
+ product = products[0]
+
+ # Watch mode is off by default — should get 404
+ res = self.testapp.get(f"/watch/{product.id}/json", status=404)
+ self.assertIn("Watch mode not enabled", res.json["error"])
+
+ def test_watch_json_with_watch_mode_enabled(self):
+ """Test that /watch/{id}/json returns error for product without media file."""
+ shop = self._create_shop_helper(
+ shop_params={
+ **self.shop1_params,
+ "name": "watch-json-enabled-shop",
+ }
+ )
+
+ # Enable watch mode
+ self.testapp.post(
+ f"/s/{shop.id}/settings",
+ {
+ "form_section": "ribbon-settings",
+ "watch_mode": "1",
+ "submit": "Save Settings",
+ },
+ status=302,
+ )
+
+ # Create a product (no media file uploaded)
+ redirect_res = self.testapp.post(
+ f"/p/new?shop_id={shop.id}", self.product1_params
+ )
+ res = redirect_res.follow()
+
+ products = get_all_products(self.dbsession).all()
+ product = products[0]
+
+ # Product has no media file — should get 403 (no preview available)
+ res = self.testapp.get(f"/watch/{product.id}/json", status=403)
+ self.assertIn("No preview available", res.json["error"])
+
def test_rss_autodiscovery_links(self):
"""Test that RSS and Atom autodiscovery links are present in shop pages."""
shop = self._create_shop_helper(
diff --git a/make_post_sell/tests/test_models.py b/make_post_sell/tests/test_models.py
index 529806c..2c0111e 100644
--- a/make_post_sell/tests/test_models.py
+++ b/make_post_sell/tests/test_models.py
@@ -1977,7 +1977,7 @@ class TestStemmer(unittest.TestCase):
self.assertLess(related.index(product4), related.index(product3))
def test_get_related_products_empty_description(self):
- """Test with empty title/description."""
+ """Test with empty title/description still returns results via fallback."""
from ..models.product import Product, get_related_products
shop = Shop(
@@ -1996,6 +1996,41 @@ class TestStemmer(unittest.TestCase):
related = get_related_products(product)
self.assertEqual(related, [])
+ def test_get_related_products_empty_description_with_others(self):
+ """Test orphan product (no stems) gets fallback results."""
+ from ..models.product import Product, get_related_products
+
+ shop = Shop(
+ "test-shop",
+ "555-555-5555",
+ "123 Test St",
+ "Test shop description",
+ )
+
+ product = Product("", "")
+ product.shop = shop
+ product.visibility = 1
+ product.created_timestamp = 100
+
+ other1 = Product("Song One", "A great song")
+ other1.shop = shop
+ other1.visibility = 1
+ other1.created_timestamp = 200
+
+ other2 = Product("Song Two", "Another song")
+ other2.shop = shop
+ other2.visibility = 1
+ other2.created_timestamp = 300
+
+ all_products = [product, other1, other2]
+ with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products:
+ mock_products.return_value = all_products
+ related = get_related_products(product)
+ # Should return fallback results (tier 3: any public product)
+ self.assertEqual(len(related), 2)
+ self.assertIn(other1, related)
+ self.assertIn(other2, related)
+
def test_get_related_products_limit(self):
"""Test that limit parameter is respected."""
from ..models.product import Product, get_related_products
@@ -2031,6 +2066,98 @@ class TestStemmer(unittest.TestCase):
related = get_related_products(product1, limit=3)
self.assertLessEqual(len(related), 3)
+ def test_get_related_products_same_media_type_fallback(self):
+ """Test tier 2: same media type fills remaining slots."""
+ from ..models.product import Product, get_related_products
+
+ shop = Shop(
+ "test-shop",
+ "555-555-5555",
+ "123 Test St",
+ "Test shop description",
+ )
+
+ def _make_product(title, desc, timestamp, ext=None):
+ p = Product(title, desc)
+ p.shop = shop
+ p.visibility = 1
+ p.created_timestamp = timestamp
+ if ext:
+ p._file_metadata = {"extensions": {"product": ext}, "originals": {}}
+ return p
+
+ # Product with no stem overlap to anything
+ product1 = _make_product("Xylophone Jam", "Unique xylophone music", 100, "mp3")
+
+ # Same media type (audio) but no stem overlap
+ product2 = _make_product("Drums Solo", "Percussion performance", 200, "mp3")
+
+ # Different media type (video) and no stem overlap
+ product3 = _make_product("Cat Video", "Funny cat footage", 300, "mp4")
+
+ # Another audio with no stem overlap
+ product4 = _make_product("Bass Line", "Groovy bass track", 400, "wav")
+
+ all_products = [product1, product2, product3, product4]
+ with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products:
+ mock_products.return_value = all_products
+
+ related = get_related_products(product1)
+ # Should include all 3 others
+ self.assertEqual(len(related), 3)
+
+ # Same media type (audio) should come before different type (video)
+ audio_indices = []
+ video_indices = []
+ for i, p in enumerate(related):
+ ext = p.extensions.get("product", "")
+ if ext in ("mp3", "wav"):
+ audio_indices.append(i)
+ elif ext == "mp4":
+ video_indices.append(i)
+
+ # All audio items should come before video items
+ if audio_indices and video_indices:
+ self.assertLess(max(audio_indices), min(video_indices))
+
+ def test_get_related_products_deterministic(self):
+ """Test that results are deterministic (consistent ordering)."""
+ from ..models.product import Product, get_related_products
+
+ shop = Shop(
+ "test-shop",
+ "555-555-5555",
+ "123 Test St",
+ "Test shop description",
+ )
+
+ product1 = Product("Guitar Music", "Guitar tutorial video")
+ product1.shop = shop
+ product1.visibility = 1
+ product1.created_timestamp = 100
+
+ product2 = Product("Guitar Songs", "Guitar song collection")
+ product2.shop = shop
+ product2.visibility = 1
+ product2.created_timestamp = 200
+
+ product3 = Product("Guitar Chords", "Guitar chord lessons")
+ product3.shop = shop
+ product3.visibility = 1
+ product3.created_timestamp = 300
+
+ all_products = [product1, product2, product3]
+ with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products:
+ mock_products.return_value = all_products
+
+ # Run multiple times to verify determinism
+ related1 = get_related_products(product1)
+ related2 = get_related_products(product1)
+ self.assertEqual(
+ [p.title for p in related1],
+ [p.title for p in related2],
+ )
+
class TestShopSubscription(unittest.TestCase):
"""Test ShopSubscription model."""
diff --git a/make_post_sell/views/watch.py b/make_post_sell/views/watch.py
new file mode 100644
index 0000000..a993c5d
--- /dev/null
+++ b/make_post_sell/views/watch.py
@@ -0,0 +1,114 @@
+from pyramid.view import view_config
+
+from ..models.product import get_media_type, get_related_products
+
+
+@view_config(route_name="watch_json", renderer="json")
+def watch_json(request):
+ """JSON endpoint for watch mode SPA navigation."""
+
+ product = request.product
+
+ if not product:
+ request.response.status_int = 404
+ return {"error": "Product not found"}
+
+ if not request.shop or not request.shop.watch_mode_enabled:
+ request.response.status_int = 404
+ return {"error": "Watch mode not enabled"}
+
+ # Only allow public products (visibility == 1)
+ if product.visibility != 1:
+ request.response.status_int = 403
+ return {"error": "Not publicly available"}
+
+ # 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"}
+ else:
+ file_key = "product"
+ if file_key not in product.extensions:
+ request.response.status_int = 404
+ return {"error": "No media file available"}
+
+ # Get extension and detect media type
+ extension = product.extensions.get(file_key)
+ media_type = get_media_type(extension)
+
+ if not media_type or media_type not in ("video", "audio"):
+ request.response.status_int = 400
+ return {"error": "Not a playable media file"}
+
+ # Generate presigned URL for media
+ bucket_name = request.app["bucket.secure_uploads"]
+ s3_key = f"{product.s3_path}/{file_key}"
+
+ params = {
+ "Bucket": bucket_name,
+ "Key": s3_key,
+ }
+
+ content_disposition = f'inline; filename="{product.title}.{extension}"'
+ content_type = product.get_content_type(file_key)
+
+ if content_disposition:
+ params["ResponseContentDisposition"] = content_disposition
+
+ if content_type:
+ params["ResponseContentType"] = content_type
+
+ media_url = request.secure_uploads_client.generate_presigned_url(
+ ClientMethod="get_object",
+ Params=params,
+ ExpiresIn=900,
+ )
+
+ # Thumbnail URL
+ thumbnail_url = None
+ if "thumbnail1" in product.extensions:
+ thumbnail_url = (
+ f"{request.app['bucket.secure_uploads.get_endpoint']}"
+ f"/{product.s3_path}/thumbnail1"
+ f"?ts={product.updated_timestamp}"
+ )
+
+ # Canonical URL (relative path)
+ p_or_c = "p" if product.is_sellable else "c"
+ canonical_url = f"/{p_or_c}/{product.id}/{product.slug}"
+
+ # Related products
+ related = get_related_products(product)
+ related_data = []
+ for r in related:
+ r_ext = r.extensions.get("product", "")
+ r_media = get_media_type(r_ext)
+ r_thumb = None
+ if "thumbnail1" in r.extensions:
+ r_thumb = (
+ f"{request.app['bucket.secure_uploads.get_endpoint']}"
+ f"/{r.s3_path}/thumbnail1"
+ f"?ts={r.updated_timestamp}"
+ )
+ r_p_or_c = "p" if r.is_sellable else "c"
+ related_data.append({
+ "id": str(r.id),
+ "title": r.title,
+ "thumbnail_url": r_thumb,
+ "url": f"/{r_p_or_c}/{r.id}/{r.slug}",
+ "media_type": r_media,
+ })
+
+ return {
+ "product_id": str(product.id),
+ "title": product.title,
+ "media_url": media_url,
+ "media_type": media_type,
+ "thumbnail_url": thumbnail_url,
+ "description_html": product.description_html,
+ "canonical_url": canonical_url,
+ "is_sellable": product.is_sellable,
+ "related": related_data,
+ }