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:
parent
e06cf1c230
commit
d7e8ec5bc2
5 changed files with 311 additions and 32 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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">◡ Open</a>
|
||||
{% if torrent_file_url %}
|
||||
<a href="{{ torrent_file_url }}" class="mps-button" style="white-space:nowrap" download>↧ .torrent</a>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue