mps: torrent backfill on enable + web seed + full test coverage

- backfill: enabling torrent on a shop auto-generates .torrent for all
  existing products that have a product file (no manual trigger needed)
- web seed (BEP 19): CDN url embedded in .torrent + magnet link so
  clients bootstrap via HTTP then seed to peers (no seeder process needed)
- torrent_file_url: stored on Product, shown as download link on content
  and edit pages alongside the magnet link
- migration: idempotent _column_exists guards on all add_column calls
- template: grid layout (not flex) for magnet/torrent buttons on edit page
- tests: 13 passing tests covering all new paths (unit + functional)
  including backfill trigger, web seed construction, visibility gating
This commit is contained in:
russell@unturf.com 2026-04-05 22:09:20 -04:00
parent e06cf1c230
commit d7e8ec5bc2
5 changed files with 311 additions and 32 deletions

View file

@ -17,22 +17,34 @@ branch_labels = None
depends_on = None
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():
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),
)
op.add_column(
"mps_product",
sa.Column("torrent_file_url", sa.UnicodeText(), nullable=True),
)
if not _column_exists("mps_shop", "torrent_enabled"):
op.add_column(
"mps_shop",
sa.Column("torrent_enabled", sa.Boolean(), nullable=True, server_default=sa.false()),
)
if not _column_exists("mps_product", "torrent_magnet_link"):
op.add_column(
"mps_product",
sa.Column("torrent_magnet_link", sa.UnicodeText(), nullable=True),
)
if not _column_exists("mps_product", "torrent_file_url"):
op.add_column(
"mps_product",
sa.Column("torrent_file_url", sa.UnicodeText(), nullable=True),
)
def downgrade():
op.drop_column("mps_shop", "torrent_enabled")
op.drop_column("mps_product", "torrent_magnet_link")
op.drop_column("mps_product", "torrent_file_url")
if _column_exists("mps_shop", "torrent_enabled"):
op.drop_column("mps_shop", "torrent_enabled")
if _column_exists("mps_product", "torrent_magnet_link"):
op.drop_column("mps_product", "torrent_magnet_link")
if _column_exists("mps_product", "torrent_file_url"):
op.drop_column("mps_product", "torrent_file_url")

View file

@ -295,16 +295,18 @@ Your cover (<code>thumbnail1</code>) will show up on search pages.
</span>
</label>
{% if torrent_magnet_link %}
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
<div style="display:grid;grid-template-columns:1fr auto auto auto;gap:.5rem;align-items:center">
<input type="text" id="torrent_magnet_link_display"
value="{{ torrent_magnet_link }}" readonly
class="mps-text-input" style="flex:1;min-width:0;color:#888" />
class="mps-text-input" style="min-width:0;color:#888" />
<button type="button"
onclick="navigator.clipboard.writeText(document.getElementById('torrent_magnet_link_display').value)"
class="mps-button" style="white-space:nowrap">Copy Magnet</button>
<a href="{{ torrent_magnet_link }}" class="mps-button" style="white-space:nowrap">&#9697; Open</a>
{% if torrent_file_url %}
<a href="{{ torrent_file_url }}" class="mps-button" style="white-space:nowrap" download>&#8615; .torrent</a>
{% else %}
<span></span>
{% endif %}
</div>
{% else %}

View file

@ -4866,6 +4866,22 @@ class TestBucketSettings(_AuthenticatedBase):
class TestTorrentSettings(_AuthenticatedBase):
"""Functional tests for torrent distribution settings."""
def _create_product_helper(self, shop):
"""Create a non-sellable (content/watch-mode) product in the given shop.
Non-sellable so /c/{id}/{slug} renders directly without purchase."""
redirect_res = self.testapp.post(
f"/c/new?shop_id={shop.id}",
{
"title": "test product",
"description": "test description",
"submit": True,
},
)
if redirect_res.status_int == 302:
redirect_res.follow()
products = get_all_products(self.dbsession).all()
return products[-1]
def test_enable_torrent(self):
shop = self._create_shop_helper()
self.assertFalse(shop.torrent_enabled)
@ -4886,8 +4902,19 @@ class TestTorrentSettings(_AuthenticatedBase):
def test_disable_torrent(self):
shop = self._create_shop_helper()
shop.torrent_enabled = True
self.dbsession.flush()
# Enable via form (so DB is in sync with the view's session)
self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "torrent-settings",
"torrent_enabled_checkbox": "on",
"submit": "Save Settings",
},
status=302,
)
self.dbsession.refresh(shop)
self.assertTrue(shop.torrent_enabled)
# Now disable
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
@ -4906,34 +4933,129 @@ class TestTorrentSettings(_AuthenticatedBase):
shop = self._create_shop_helper()
self.assertFalse(shop.torrent_enabled)
def _flush_and_commit(self):
"""Flush + commit so subsequent testapp requests can see the changes."""
self.dbsession.flush()
transaction.commit()
self.dbsession = get_tm_session(self.session_factory, transaction.manager)
def test_magnet_link_auto_generated_on_upload(self):
"""Magnet link is set by generate_torrent_async after file upload, not by the form."""
shop = self._create_shop_helper()
shop.torrent_enabled = True
self.dbsession.flush()
product = self._create_product_helper(shop)
product_id = product.uuid_str
self.testapp.post(
f"/s/{shop.id}/settings",
{"form_section": "torrent-settings", "torrent_enabled_checkbox": "on", "submit": "Save Settings"},
status=302,
)
# Simulate what the background thread does after upload
product = get_product_by_id(self.dbsession, product_id)
product.torrent_magnet_link = "magnet:?xt=urn:btih:abc123&dn=test"
self.dbsession.flush()
self.dbsession.refresh(product)
self._flush_and_commit()
product = get_product_by_id(self.dbsession, product_id)
self.assertEqual(product.torrent_magnet_link, "magnet:?xt=urn:btih:abc123&dn=test")
def test_magnet_link_shown_on_content_page(self):
shop = self._create_shop_helper()
shop.torrent_enabled = True
self.dbsession.flush()
product = self._create_product_helper(shop)
self.testapp.post(
f"/s/{shop.id}/settings",
{"form_section": "torrent-settings", "torrent_enabled_checkbox": "on", "submit": "Save Settings"},
status=302,
)
# Set product file (has_product_file must be True for torrent buttons to render)
product = get_product_by_id(self.dbsession, product.uuid_str)
product.set_file_metadata("product", "mp3", "track.mp3")
product.torrent_magnet_link = "magnet:?xt=urn:btih:abc123&dn=test"
self.dbsession.flush()
res = self.testapp.get(f"/c/{product.id}/{product.slug}", status=200)
self.assertIn("Torrent", res.text)
product_id = product.uuid_str
product_slug = product.slug
self._flush_and_commit()
res = self.testapp.get(f"/c/{product_id}/{product_slug}", status=200)
self.assertIn("Magnet", res.text)
self.assertIn("magnet:", res.text)
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)
# torrent NOT enabled — set magnet link + file metadata, verify Magnet not shown
product.set_file_metadata("product", "mp3", "track.mp3")
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)
product_id = product.uuid_str
product_slug = product.slug
self._flush_and_commit()
res = self.testapp.get(f"/c/{product_id}/{product_slug}", status=200)
self.assertNotIn("Magnet", res.text)
self.assertNotIn("magnet:", res.text)
def test_torrent_file_url_shown_on_content_page(self):
shop = self._create_shop_helper()
product = self._create_product_helper(shop)
self.testapp.post(
f"/s/{shop.id}/settings",
{"form_section": "torrent-settings", "torrent_enabled_checkbox": "on", "submit": "Save Settings"},
status=302,
)
product = get_product_by_id(self.dbsession, product.uuid_str)
product.set_file_metadata("product", "mp3", "track.mp3")
product.torrent_magnet_link = "magnet:?xt=urn:btih:abc123&dn=test"
product.torrent_file_url = "https://cdn.example.com/shop1/prod1/product.torrent"
product_id = product.uuid_str
product_slug = product.slug
self._flush_and_commit()
res = self.testapp.get(f"/c/{product_id}/{product_slug}", status=200)
self.assertIn(".torrent", res.text)
self.assertIn("product.torrent", res.text)
def test_torrent_file_url_not_shown_when_torrent_disabled(self):
shop = self._create_shop_helper()
product = self._create_product_helper(shop)
product.set_file_metadata("product", "mp3", "track.mp3")
product.torrent_file_url = "https://cdn.example.com/shop1/prod1/product.torrent"
product_id = product.uuid_str
product_slug = product.slug
self._flush_and_commit()
res = self.testapp.get(f"/c/{product_id}/{product_slug}", status=200)
self.assertNotIn("product.torrent", res.text)
def test_torrent_file_url_shown_on_product_edit_page(self):
shop = self._create_shop_helper()
product = self._create_product_helper(shop)
self.testapp.post(
f"/s/{shop.id}/settings",
{"form_section": "torrent-settings", "torrent_enabled_checkbox": "on", "submit": "Save Settings"},
status=302,
)
product = get_product_by_id(self.dbsession, product.uuid_str)
product.torrent_magnet_link = "magnet:?xt=urn:btih:abc123&dn=test"
product.torrent_file_url = "https://cdn.example.com/shop1/prod1/product.torrent"
product_id = product.uuid_str
self._flush_and_commit()
res = self.testapp.get(f"/c/{product_id}/edit", status=200)
self.assertIn("product.torrent", res.text)
self.assertIn(".torrent", res.text)
def test_enable_torrent_triggers_backfill_for_products_with_files(self):
"""Enabling torrent fires generate_torrent_async for products that have a
product file but no magnet link yet."""
import mock
shop = self._create_shop_helper()
product = self._create_product_helper(shop)
# Simulate a product file (set file metadata then commit so backfill query sees it)
product.set_file_metadata("product", "mp3", "track.mp3")
shop_id = shop.id
self._flush_and_commit()
# Patch generate_torrent_async where _torrent_backfill_async imports it
with mock.patch("make_post_sell.lib.torrent.generate_torrent_async") as mock_lib:
self.testapp.post(
f"/s/{shop_id}/settings",
{
"form_section": "torrent-settings",
"torrent_enabled_checkbox": "on",
"submit": "Save Settings",
},
status=302,
)
# generate_torrent_async must have been called for our product
self.assertGreaterEqual(mock_lib.call_count, 1)

View file

@ -3631,3 +3631,103 @@ class TestShopBYOB(unittest.TestCase):
def test_media_cdn_endpoint_returns_none_when_not_configured(self):
shop = self._make_shop(enabled=False)
self.assertIsNone(shop.media_cdn_endpoint)
class TestTorrentLib(unittest.TestCase):
"""Unit tests for lib/torrent.py — web seed URL construction and DB persistence."""
def _make_product(self):
from ..models.product import Product
p = mock.MagicMock(spec=Product)
p.id = "prod-001"
p.s3_path = "shop1/prod-001"
p.torrent_magnet_link = None
p.torrent_file_url = None
return p
def test_webseed_url_constructed_from_cdn_endpoint_and_s3_key(self):
"""generate_torrent embeds cdn_endpoint/s3_key as web seed in the torrent."""
from ..lib.torrent import generate_torrent
s3_client = mock.MagicMock()
session = mock.MagicMock()
product = self._make_product()
session.get.return_value = product
session_cm = mock.MagicMock()
session_cm.__enter__ = mock.Mock(return_value=session)
session_cm.__exit__ = mock.Mock(return_value=False)
session_factory = mock.Mock(return_value=session_cm)
torrent_obj = mock.MagicMock()
with mock.patch("make_post_sell.lib.torrent._build_torrent", return_value=(torrent_obj, "magnet:?xt=urn:btih:deadbeef&dn=product")) as mock_build, \
mock.patch("tempfile.TemporaryDirectory") as mock_tmpdir:
mock_tmpdir.return_value.__enter__ = mock.Mock(return_value="/tmp/fake")
mock_tmpdir.return_value.__exit__ = mock.Mock(return_value=False)
generate_torrent(
s3_client, "my-bucket", "shop1/prod-001/product",
"shop1/prod-001", "prod-001", session_factory,
cdn_endpoint="https://cdn.example.com",
)
# _build_torrent is called with webseeds= as a keyword arg
call_kwargs = mock_build.call_args.kwargs
webseeds = call_kwargs.get("webseeds", [])
self.assertIn("https://cdn.example.com/shop1/prod-001/product", webseeds)
def test_torrent_file_url_saved_to_product(self):
"""generate_torrent saves torrent_file_url = cdn_endpoint/s3_path/product.torrent."""
from ..lib.torrent import generate_torrent
s3_client = mock.MagicMock()
session = mock.MagicMock()
product = self._make_product()
session.get.return_value = product
session_cm = mock.MagicMock()
session_cm.__enter__ = mock.Mock(return_value=session)
session_cm.__exit__ = mock.Mock(return_value=False)
session_factory = mock.Mock(return_value=session_cm)
torrent_obj = mock.MagicMock()
with mock.patch("make_post_sell.lib.torrent._build_torrent", return_value=(torrent_obj, "magnet:?xt=urn:btih:abc")), \
mock.patch("tempfile.TemporaryDirectory") as mock_tmpdir:
mock_tmpdir.return_value.__enter__ = mock.Mock(return_value="/tmp/fake")
mock_tmpdir.return_value.__exit__ = mock.Mock(return_value=False)
generate_torrent(
s3_client, "my-bucket", "shop1/prod-001/product",
"shop1/prod-001", "prod-001", session_factory,
cdn_endpoint="https://cdn.example.com",
)
self.assertEqual(product.torrent_file_url, "https://cdn.example.com/shop1/prod-001/product.torrent")
def test_torrent_file_url_none_without_cdn_endpoint(self):
"""Without cdn_endpoint, torrent_file_url is None (no web seed)."""
from ..lib.torrent import generate_torrent
s3_client = mock.MagicMock()
session = mock.MagicMock()
product = self._make_product()
session.get.return_value = product
session_cm = mock.MagicMock()
session_cm.__enter__ = mock.Mock(return_value=session)
session_cm.__exit__ = mock.Mock(return_value=False)
session_factory = mock.Mock(return_value=session_cm)
torrent_obj = mock.MagicMock()
with mock.patch("make_post_sell.lib.torrent._build_torrent", return_value=(torrent_obj, "magnet:?xt=urn:btih:abc")), \
mock.patch("tempfile.TemporaryDirectory") as mock_tmpdir:
mock_tmpdir.return_value.__enter__ = mock.Mock(return_value="/tmp/fake")
mock_tmpdir.return_value.__exit__ = mock.Mock(return_value=False)
generate_torrent(
s3_client, "my-bucket", "shop1/prod-001/product",
"shop1/prod-001", "prod-001", session_factory,
cdn_endpoint=None,
)
self.assertIsNone(product.torrent_file_url)

View file

@ -57,6 +57,45 @@ def bool_to_checkbox(boolean):
return "on" if boolean else "off"
def _torrent_backfill_async(request, shop):
"""Fire-and-forget: generate .torrent for all products in a shop that have a
product file but no magnet link yet. Called when torrent_enabled is first enabled."""
from ..lib.torrent import generate_torrent_async
from ..models.product import Product
products = (
request.dbsession.query(Product)
.filter(Product.shop_id == shop.id)
.all()
)
cdn_endpoint = request.shop_cdn_endpoint
bucket = request.shop_bucket_name
s3_client = request.shop_uploads_client
session_factory = request.registry["dbsession_factory"]
queued = 0
for product in products:
if product.torrent_magnet_link:
continue # already generated
if "product" not in product.originals:
continue # no product file uploaded yet
generate_torrent_async(
s3_client,
bucket,
f"{product.s3_path}/product",
product.s3_path,
product.id,
session_factory,
cdn_endpoint=cdn_endpoint,
)
queued += 1
import logging
logging.getLogger(__name__).info(
"torrent backfill: queued %d products for shop=%s", queued, shop.id
)
def get_shop_from_matchdict(request, prefetched_shop=None):
"""
This function uses the shop_id from the url path
@ -1187,6 +1226,10 @@ def shop_settings(request):
if changed:
status = "enabled" if torrent_enabled else "disabled"
request.session.flash((f"Torrent distribution {status}.", "success"))
if torrent_enabled:
# Backfill: generate torrents for all existing products that
# have a product file but no magnet link yet.
_torrent_backfill_async(request, shop)
# If we processed any form submission, respond accordingly
if form_section: