mps: torrent distribution — shop toggle + per-product magnet link
Shop settings: new 'Torrent Distribution' section with single checkbox (torrent_enabled, default False). When on, product edit shows a magnet link field; content page shows a Torrent button alongside Download. - models/shop.py: torrent_enabled (Boolean, default False) - models/product.py: torrent_magnet_link (UnicodeText, nullable) - views/shop.py: torrent-settings form section handler - views/product.py: save/validate magnet link on product edit - views/content.py: pass torrent_magnet_link to content template - templates/shop_settings.j2: Torrent Distribution section - templates/product_edit.j2: magnet link field (shown when torrent enabled) - templates/content.j2: Torrent button beside Download - migration: a1b2c3d4e5f6 adds both columns - tests: 6 functional tests covering enable/disable, magnet save, invalid scheme rejection, and no-leak when disabled
This commit is contained in:
parent
41a8525953
commit
d5f55f8131
10 changed files with 208 additions and 0 deletions
|
|
@ -109,6 +109,9 @@ class Product(RBase, Base):
|
|||
|
||||
total_file_bytes = Column(BigInteger, nullable=False, default=0)
|
||||
|
||||
# Optional magnet link for BitTorrent distribution (set when shop.torrent_enabled)
|
||||
torrent_magnet_link = Column(UnicodeText, nullable=True)
|
||||
|
||||
# a denormalized json field to relate products to bundles, and vice versa.
|
||||
json_bundle_metadata = Column(
|
||||
UnicodeText, default=DEFAULT_BUNDLE_METADATA, nullable=False
|
||||
|
|
|
|||
|
|
@ -183,6 +183,9 @@ class Shop(RBase, Base):
|
|||
primary_s3_cdn_endpoint = Column(Unicode(256), nullable=True)
|
||||
primary_s3_enabled = Column(Boolean, default=False)
|
||||
|
||||
# Torrent / magnet link distribution (off by default)
|
||||
torrent_enabled = Column(Boolean, default=False)
|
||||
|
||||
# Precomputed discovery ring: circular ordering of all public products
|
||||
json_discovery_ring = Column(UnicodeText, nullable=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
"""torrent magnet links
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: fd9f7e2f2b78
|
||||
Create Date: 2026-04-05 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a1b2c3d4e5f6"
|
||||
down_revision = "fd9f7e2f2b78"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"mps_shop",
|
||||
sa.Column("torrent_enabled", sa.Boolean(), nullable=True, server_default=sa.false()),
|
||||
)
|
||||
op.add_column(
|
||||
"mps_product",
|
||||
sa.Column("torrent_magnet_link", sa.UnicodeText(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("mps_shop", "torrent_enabled")
|
||||
op.drop_column("mps_product", "torrent_magnet_link")
|
||||
|
|
@ -156,6 +156,9 @@
|
|||
{% if signed_get_object_url is not none %}
|
||||
<a href="{{ signed_get_object_url }}" class="product-download-button mps-button" target="_blank" download>⭳ Download</a>
|
||||
{% endif %}
|
||||
{% if torrent_magnet_link %}
|
||||
<a href="{{ torrent_magnet_link }}" class="mps-button" style="margin-left:.5rem">◡ Torrent</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="watch-queue" class="js-only" style="display:none">
|
||||
|
|
|
|||
|
|
@ -288,6 +288,17 @@ Your cover (<code>thumbnail1</code>) will show up on search pages.
|
|||
<br />
|
||||
<br />
|
||||
|
||||
{% if torrent_enabled %}
|
||||
<label for="torrent_magnet_link">Magnet Link <span style="font-weight:normal;color:#888">(optional — torrent download for buyers)</span></label>
|
||||
<input type="text" name="torrent_magnet_link" id="torrent_magnet_link"
|
||||
value="{{ torrent_magnet_link }}"
|
||||
placeholder="magnet:?xt=urn:btih:..."
|
||||
class="mps-text-input" />
|
||||
|
||||
<br />
|
||||
<br />
|
||||
{% endif %}
|
||||
|
||||
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
|
||||
</form>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1284,6 +1284,36 @@ Existing sales honored for download buy purchasers.
|
|||
</section>
|
||||
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<section class="one-column">
|
||||
<section class="shop-settings well">
|
||||
|
||||
<h3>Torrent Distribution</h3>
|
||||
|
||||
<p>When enabled, you can add a magnet link to each product. Buyers see a torrent download option alongside the standard download link. Off by default.</p>
|
||||
|
||||
<form method="post" action="/s/{{ request.shop.id }}/settings" onsubmit="submit.disabled = true; return true;">
|
||||
<input type="hidden" name="form_section" value="torrent-settings" />
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="torrent_enabled_checkbox"
|
||||
{% if torrent_enabled %}checked{% endif %} />
|
||||
Enable torrent distribution
|
||||
</label>
|
||||
|
||||
<br /><br />
|
||||
|
||||
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
|
||||
|
||||
</form>
|
||||
|
||||
</section>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Crypto wallets toggle (still uses checkbox pattern)
|
||||
|
|
|
|||
|
|
@ -4861,3 +4861,100 @@ class TestBucketSettings(_AuthenticatedBase):
|
|||
self.assertIn("Storage bucket settings updated", flash)
|
||||
self.dbsession.refresh(shop)
|
||||
self.assertFalse(shop.primary_s3_enabled)
|
||||
|
||||
|
||||
class TestTorrentSettings(_AuthenticatedBase):
|
||||
"""Functional tests for torrent distribution settings."""
|
||||
|
||||
def test_enable_torrent(self):
|
||||
shop = self._create_shop_helper()
|
||||
self.assertFalse(shop.torrent_enabled)
|
||||
res = self.testapp.post(
|
||||
f"/s/{shop.id}/settings",
|
||||
{
|
||||
"form_section": "torrent-settings",
|
||||
"torrent_enabled_checkbox": "on",
|
||||
"submit": "Save Settings",
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
res = res.follow()
|
||||
flash = self._get_flash_messages(res)
|
||||
self.assertIn("Torrent distribution enabled", flash)
|
||||
self.dbsession.refresh(shop)
|
||||
self.assertTrue(shop.torrent_enabled)
|
||||
|
||||
def test_disable_torrent(self):
|
||||
shop = self._create_shop_helper()
|
||||
shop.torrent_enabled = True
|
||||
self.dbsession.flush()
|
||||
res = self.testapp.post(
|
||||
f"/s/{shop.id}/settings",
|
||||
{
|
||||
"form_section": "torrent-settings",
|
||||
"submit": "Save Settings",
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
res = res.follow()
|
||||
flash = self._get_flash_messages(res)
|
||||
self.assertIn("Torrent distribution disabled", flash)
|
||||
self.dbsession.refresh(shop)
|
||||
self.assertFalse(shop.torrent_enabled)
|
||||
|
||||
def test_torrent_off_by_default(self):
|
||||
shop = self._create_shop_helper()
|
||||
self.assertFalse(shop.torrent_enabled)
|
||||
|
||||
def test_magnet_link_saved_on_product(self):
|
||||
shop = self._create_shop_helper()
|
||||
shop.torrent_enabled = True
|
||||
self.dbsession.flush()
|
||||
product = self._create_product_helper(shop)
|
||||
magnet = "magnet:?xt=urn:btih:abc123&dn=test"
|
||||
res = self.testapp.post(
|
||||
f"/p/{product.id}/edit",
|
||||
{
|
||||
"title": product.title,
|
||||
"description": product.description or "",
|
||||
"price": "0.00",
|
||||
"visibility": str(product.visibility),
|
||||
"torrent_magnet_link": magnet,
|
||||
"submit": "Save Settings",
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
self.dbsession.refresh(product)
|
||||
self.assertEqual(product.torrent_magnet_link, magnet)
|
||||
|
||||
def test_magnet_link_rejected_if_invalid_scheme(self):
|
||||
shop = self._create_shop_helper()
|
||||
shop.torrent_enabled = True
|
||||
self.dbsession.flush()
|
||||
product = self._create_product_helper(shop)
|
||||
res = self.testapp.post(
|
||||
f"/p/{product.id}/edit",
|
||||
{
|
||||
"title": product.title,
|
||||
"description": product.description or "",
|
||||
"price": "0.00",
|
||||
"visibility": str(product.visibility),
|
||||
"torrent_magnet_link": "https://not-a-magnet-link",
|
||||
"submit": "Save Settings",
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
res = res.follow()
|
||||
flash = self._get_flash_messages(res)
|
||||
self.assertIn("Magnet link must start with magnet:", flash)
|
||||
self.dbsession.refresh(product)
|
||||
self.assertIsNone(product.torrent_magnet_link)
|
||||
|
||||
def test_magnet_link_not_shown_when_torrent_disabled(self):
|
||||
shop = self._create_shop_helper()
|
||||
self.assertFalse(shop.torrent_enabled)
|
||||
product = self._create_product_helper(shop)
|
||||
product.torrent_magnet_link = "magnet:?xt=urn:btih:abc123"
|
||||
self.dbsession.flush()
|
||||
res = self.testapp.get(f"/c/{product.id}/{product.slug}", status=200)
|
||||
self.assertNotIn("Torrent", res.text)
|
||||
|
|
|
|||
|
|
@ -110,4 +110,5 @@ def content(request):
|
|||
"instrumentals_url": instrumentals_url,
|
||||
"vocals_url": vocals_url,
|
||||
"karaoke_eligible": karaoke_eligible,
|
||||
"torrent_magnet_link": product.torrent_magnet_link if product.shop.torrent_enabled else None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -297,6 +297,18 @@ def product_edit(request):
|
|||
request.dbsession.add(product.set_price(price))
|
||||
request.session.flash(("You updated the product's price.", "success"))
|
||||
|
||||
if product.shop.torrent_enabled:
|
||||
torrent_magnet_link = request.params.get("torrent_magnet_link", "").strip()
|
||||
if torrent_magnet_link and not torrent_magnet_link.startswith("magnet:"):
|
||||
request.session.flash(("Magnet link must start with magnet:", "error"))
|
||||
elif torrent_magnet_link != (product.torrent_magnet_link or ""):
|
||||
product_modified = True
|
||||
product.torrent_magnet_link = torrent_magnet_link or None
|
||||
if torrent_magnet_link:
|
||||
request.session.flash(("Magnet link saved.", "success"))
|
||||
else:
|
||||
request.session.flash(("Magnet link removed.", "success"))
|
||||
|
||||
if s3_webhook_key and s3_webhook_bucket and s3_webhook_etag:
|
||||
# Check if the file exists and has a non-zero size
|
||||
try:
|
||||
|
|
@ -511,6 +523,8 @@ def product_edit(request):
|
|||
"locations": locations,
|
||||
"inventories": inventories if product.is_physical else {},
|
||||
"price_history": price_history,
|
||||
"torrent_enabled": product.shop.torrent_enabled,
|
||||
"torrent_magnet_link": product.torrent_magnet_link or "",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1176,6 +1176,18 @@ def shop_settings(request):
|
|||
else:
|
||||
request.session.flash(("Storage bucket settings updated.", "success"))
|
||||
|
||||
if form_section == "torrent-settings":
|
||||
torrent_enabled = checkbox_to_bool(
|
||||
request.params.get("torrent_enabled_checkbox", "off")
|
||||
)
|
||||
changed = False
|
||||
if shop.torrent_enabled != torrent_enabled:
|
||||
shop.torrent_enabled = torrent_enabled
|
||||
changed = True
|
||||
if changed:
|
||||
status = "enabled" if torrent_enabled else "disabled"
|
||||
request.session.flash((f"Torrent distribution {status}.", "success"))
|
||||
|
||||
# If we processed any form submission, respond accordingly
|
||||
if form_section:
|
||||
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||
|
|
@ -1380,6 +1392,7 @@ def shop_settings(request):
|
|||
"primary_s3_secret_key": shop.primary_s3_secret_key or "",
|
||||
"primary_s3_cdn_endpoint": shop.primary_s3_cdn_endpoint or "",
|
||||
"primary_s3_enabled": shop.primary_s3_enabled,
|
||||
"torrent_enabled": shop.torrent_enabled,
|
||||
"signed_posts": signed_posts,
|
||||
"get_endpoints": get_endpoints,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue