diff --git a/make_post_sell/models/product.py b/make_post_sell/models/product.py index f9f05b6..10d290d 100644 --- a/make_post_sell/models/product.py +++ b/make_post_sell/models/product.py @@ -697,6 +697,40 @@ def tokenize_and_stem(text): return {stem_word(w) for w in words} +def get_ring_related_products(product, ring, limit=8): + """Get related products by walking forward from current position in the ring. + + Returns a list of Product objects in ring order starting after the current product. + Falls back to get_related_products if product not in ring. + """ + product_id_str = str(product.id) + if product_id_str not in ring: + return get_related_products(product, limit=limit) + + idx = ring.index(product_id_str) + ring_len = len(ring) + + # Walk forward from current position, wrapping around + related_ids = [] + for i in range(1, ring_len): + next_idx = (idx + i) % ring_len + related_ids.append(ring[next_idx]) + if len(related_ids) >= limit: + break + + if not related_ids: + return [] + + # Fetch products and preserve ring order + products = get_products_by_ids(product.dbsession, related_ids) + if not products: + return [] + + # Build lookup by ID string for ordering + product_map = {str(p.id): p for p in products} + return [product_map[pid] for pid in related_ids if pid in product_map] + + def get_related_products(product, limit=8): """Find related products in same shop using tiered fallback: 1. Stem overlap (highest score first) diff --git a/make_post_sell/models/shop.py b/make_post_sell/models/shop.py index e459c76..7437114 100644 --- a/make_post_sell/models/shop.py +++ b/make_post_sell/models/shop.py @@ -1,3 +1,4 @@ +import json import uuid from sqlalchemy import Column, BigInteger, Boolean, Unicode, UnicodeText, func @@ -133,6 +134,9 @@ class Shop(RBase, Base): # Email digest subscriptions subscriptions_enabled = Column(Boolean, default=True) + # Precomputed discovery ring: circular ordering of all public products + json_discovery_ring = Column(UnicodeText, nullable=True) + # many to many uses association_proxy. users = association_proxy("shop_users", "user", creator=lambda u: UserShop(user=u)) @@ -581,6 +585,108 @@ class Shop(RBase, Base): def stamp_updated_timestamp(self): self.updated_timestamp = now_timestamp() + @property + def discovery_ring(self): + """Return the discovery ring as a list of product ID strings, or [].""" + if not hasattr(self, "_discovery_ring"): + if self.json_discovery_ring: + self._discovery_ring = json.loads(self.json_discovery_ring) + else: + self._discovery_ring = [] + return self._discovery_ring + + @discovery_ring.setter + def discovery_ring(self, ring_list): + """Set the discovery ring from a list of product ID strings.""" + self._discovery_ring = ring_list + self.json_discovery_ring = json.dumps(ring_list) + + +def compute_discovery_ring(shop): + """Compute a circular ordering of all public products using greedy nearest-neighbor. + + Algorithm: + 1. Start from the newest public product + 2. Accumulate its stems into a running set + 3. Find unvisited product with highest Jaccard similarity to accumulated stems + 4. Add to ring, merge stems, repeat until all public products are ordered + + Returns a list of product ID strings. + """ + from .product import tokenize_and_stem + + # Gather all public products, sorted newest-first for deterministic seed + candidates = [] + for p in shop.products: + if p.visibility == 1: + candidates.append(p) + + if not candidates: + return [] + + # Sort newest-first; tiebreak by ID for determinism + candidates.sort(key=lambda p: (-p.created_timestamp, str(p.id))) + + if len(candidates) == 1: + return [str(candidates[0].id)] + + # Pre-compute stems for all candidates + stems_map = {} + for p in candidates: + stems_map[p.id] = tokenize_and_stem( + (p.title or '') + ' ' + (p.description or '') + ) + + # Greedy walk + ring = [] + visited = set() + accumulated_stems = set() + + # Start with newest product + current = candidates[0] + ring.append(str(current.id)) + visited.add(current.id) + accumulated_stems |= stems_map[current.id] + + while len(ring) < len(candidates): + best_score = -1 + best_candidate = None + + for p in candidates: + if p.id in visited: + continue + p_stems = stems_map[p.id] + union = accumulated_stems | p_stems + if union: + score = len(accumulated_stems & p_stems) / len(union) + else: + score = 0 + + # Tiebreak: highest score, then newest, then ID + if (score > best_score or + (score == best_score and best_candidate is not None and + (p.created_timestamp > best_candidate.created_timestamp or + (p.created_timestamp == best_candidate.created_timestamp and + str(p.id) < str(best_candidate.id))))): + best_score = score + best_candidate = p + + if best_candidate is None: + break + + ring.append(str(best_candidate.id)) + visited.add(best_candidate.id) + accumulated_stems |= stems_map[best_candidate.id] + + return ring + + +def reforge_discovery_ring(shop): + """Compute and store the discovery ring on the shop.""" + ring = compute_discovery_ring(shop) + shop.discovery_ring = ring + return ring + def is_shop_name_available(dbsession, name): return not _shop_by_name_query(dbsession, unicode(name)).count() diff --git a/make_post_sell/scripts/alembic/versions/05f7eb8cecf1_add_discovery_ring_to_shop.py b/make_post_sell/scripts/alembic/versions/05f7eb8cecf1_add_discovery_ring_to_shop.py new file mode 100644 index 0000000..5246cd6 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/05f7eb8cecf1_add_discovery_ring_to_shop.py @@ -0,0 +1,36 @@ +"""add discovery ring to shop + +Revision ID: 05f7eb8cecf1 +Revises: c3f9d1cdffb4 +Create Date: 2026-02-08 15:27:04.955833 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '05f7eb8cecf1' +down_revision = 'c3f9d1cdffb4' +branch_labels = None +depends_on = None + +from make_post_sell.models.meta import UUIDType + + +def _column_exists(table, column): + conn = op.get_bind() + result = conn.execute(sa.text(f"PRAGMA table_info({table})")) + return any(row[1] == column for row in result.fetchall()) + + +def upgrade(): + if not _column_exists("mps_shop", "json_discovery_ring"): + op.add_column( + "mps_shop", + sa.Column("json_discovery_ring", sa.UnicodeText(), nullable=True), + ) + + +def downgrade(): + op.drop_column("mps_shop", "json_discovery_ring") diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css index 2232cfe..94fe185 100644 --- a/make_post_sell/static/css/common.css +++ b/make_post_sell/static/css/common.css @@ -2739,6 +2739,30 @@ textarea { background: var(--bg-secondary, rgba(0, 0, 0, 0.04)); } +.related-content-row-watched { + opacity: 0.45; +} + +.ring-progress { + font-size: 0.8em; + color: var(--text-muted, #999); + font-variant-numeric: tabular-nums; +} + +.watch-direction-label { + display: grid; + grid-template-columns: auto auto; + gap: 6px; + align-items: center; + font-size: 0.85em; + cursor: pointer; +} + +.direction-label-text { + font-size: 0.85em; + color: var(--text-muted, #999); +} + .related-content-index { font-size: 0.75em; color: var(--text-muted, #999); diff --git a/make_post_sell/static/js/watch.js b/make_post_sell/static/js/watch.js index 6ccd0e0..9901c2e 100644 --- a/make_post_sell/static/js/watch.js +++ b/make_post_sell/static/js/watch.js @@ -16,6 +16,75 @@ var BUFFER_AHEAD_SECONDS = 30; var bufferingStarted = false; + // --- Ring state (persisted in localStorage) --- + var ringProductIds = []; + var ringPosition = 0; + var ringDirection = 1; // 1 = forward, -1 = reverse + var ringHistory = {}; // { productId: true } — set of watched IDs + + // Load ring state from localStorage + try { + var savedRing = localStorage.getItem('watchRing'); + if (savedRing) ringProductIds = JSON.parse(savedRing); + } catch (e) {} + try { + var savedPos = localStorage.getItem('watchRingPosition'); + if (savedPos !== null) ringPosition = parseInt(savedPos, 10) || 0; + } catch (e) {} + try { + var savedDir = localStorage.getItem('watchRingDirection'); + if (savedDir !== null) ringDirection = parseInt(savedDir, 10) || 1; + } catch (e) {} + try { + var savedHistory = localStorage.getItem('watchRingHistory'); + if (savedHistory) ringHistory = JSON.parse(savedHistory); + } catch (e) {} + + function saveRingState() { + try { + localStorage.setItem('watchRing', JSON.stringify(ringProductIds)); + localStorage.setItem('watchRingPosition', String(ringPosition)); + localStorage.setItem('watchRingDirection', String(ringDirection)); + localStorage.setItem('watchRingHistory', JSON.stringify(ringHistory)); + } catch (e) {} + } + + function markWatched(productId) { + if (!productId) return; + ringHistory[productId] = true; + saveRingState(); + updateProgressDisplay(); + } + + function isWatched(productId) { + return !!ringHistory[productId]; + } + + // Sync ring position to the current product + function syncRingPosition(productId) { + if (!ringProductIds.length || !productId) return; + var idx = ringProductIds.indexOf(productId); + if (idx !== -1) { + ringPosition = idx; + saveRingState(); + } + } + + // Update progress display ("23 / 87") + function updateProgressDisplay() { + var el = document.getElementById('ring-progress'); + if (!el) return; + if (!ringProductIds.length) { + el.textContent = ''; + return; + } + var watched = 0; + for (var id in ringHistory) { + if (ringProductIds.indexOf(id) !== -1) watched++; + } + el.textContent = watched + ' / ' + ringProductIds.length; + } + // Autoplay preference (persisted in localStorage) var autoplayEnabled = true; try { @@ -46,48 +115,12 @@ if (savedQueue) queue = JSON.parse(savedQueue); } catch (e) {} - // --- Recently played tracking (localStorage) --- - var RECENTLY_PLAYED_COOLDOWN = 4 * 60 * 60 * 1000; // 4 hours - var recentlyPlayed = {}; - try { - var savedRecent = localStorage.getItem('watchRecentlyPlayed'); - if (savedRecent) recentlyPlayed = JSON.parse(savedRecent); - } catch (e) {} - - function cleanRecentlyPlayed() { - var now = Date.now(); - var changed = false; - for (var id in recentlyPlayed) { - if (now - recentlyPlayed[id] > RECENTLY_PLAYED_COOLDOWN) { - delete recentlyPlayed[id]; - changed = true; - } - } - if (changed) saveRecentlyPlayed(); - } - - function saveRecentlyPlayed() { - try { localStorage.setItem('watchRecentlyPlayed', JSON.stringify(recentlyPlayed)); } catch (e) {} - } - - function markPlayed(productId) { - if (!productId) return; - recentlyPlayed[productId] = Date.now(); - saveRecentlyPlayed(); - } - - function isRecentlyPlayed(productId) { - if (!recentlyPlayed[productId]) return false; - return (Date.now() - recentlyPlayed[productId]) < RECENTLY_PLAYED_COOLDOWN; - } - - // Clean stale entries on load - cleanRecentlyPlayed(); - - // Mark current item as played + // Mark current item as watched and sync ring position var initialProductEl = document.querySelector('[data-watch-product-id]'); if (initialProductEl) { - markPlayed(initialProductEl.getAttribute('data-watch-product-id')); + var initialId = initialProductEl.getAttribute('data-watch-product-id'); + markWatched(initialId); + syncRingPosition(initialId); } // --- Autoplay with sound (for video/audio) --- @@ -382,7 +415,13 @@ // --- Update page content (title, description, related, URL) --- function updatePageContent(data) { - markPlayed(data.product_id); + // Update ring from server response + if (data.ring && data.ring.length) { + ringProductIds = data.ring; + } + markWatched(data.product_id); + syncRingPosition(data.product_id); + document.title = data.title; var h1 = document.querySelector('.product-images h1'); @@ -419,27 +458,34 @@ } var autoplayChecked = autoplayEnabled ? ' checked' : ''; + var directionChecked = ringDirection === -1 ? ' checked' : ''; var headerHtml = ''; if (!related || related.length === 0) { container.innerHTML = headerHtml + '

No more items

'; - rebindAutoplayToggle(); + rebindToggles(); return; } var html = headerHtml; var visibleIndex = 0; related.forEach(function(item) { - var played = isRecentlyPlayed(item.id); - if (played) return; // skip recently played visibleIndex++; - html += '