mps: torrent magnet links auto-generated on file upload

lib/torrent.py: generate_torrent_async() — daemon thread that fires after
upload, downloads file from S3, builds .torrent via torf, uploads
product.torrent to S3 (public-read), saves magnet link to DB.

views/product.py: trigger generate_torrent_async when file_key=='product'
and shop.torrent_enabled. Removed manual magnet link save — it's automatic.

templates/product_edit.j2: magnet link is read-only + copy button + Open
button when generated; 'upload the product file' hint when not yet generated.

tests: updated to reflect auto-generation model, added content page test.
This commit is contained in:
russell@unturf.com 2026-04-05 20:03:47 -04:00
parent d5f55f8131
commit 7d78dff6ac
5 changed files with 199 additions and 50 deletions

View file

@ -0,0 +1,153 @@
"""torrent.py — automatic .torrent + magnet link generation for product files.
When a shop has torrent_enabled, this runs after every product file upload:
1. Stream file from S3 to a temp file
2. Build a .torrent using torf (pure Python, no C deps)
3. Upload the .torrent back to S3 at {s3_path}/product.torrent
4. Derive the magnet link from the torrent info hash
5. Save the magnet link to product.torrent_magnet_link in the DB
Fire-and-forget daemon thread same pattern as s3_mirror.
"""
import hashlib
import logging
import tempfile
import threading
import os
import boto3
log = logging.getLogger(__name__)
# Public trackers embedded in every torrent
DEFAULT_TRACKERS = [
"udp://tracker.opentrackr.org:1337/announce",
"udp://open.stealth.si:80/announce",
"udp://tracker.torrent.eu.org:451/announce",
"udp://tracker.openbittorrent.com:80/announce",
]
def _capture_s3_creds(client):
"""Extract serialisable credentials from a boto3 client."""
creds = client._request_signer._credentials
meta = client.meta
return {
"endpoint": meta.endpoint_url,
"region": meta.region_name,
"access_key": creds.access_key,
"secret_key": creds.secret_key,
}
def _make_client(creds):
return boto3.session.Session().client(
"s3",
region_name=creds["region"],
endpoint_url=creds["endpoint"],
aws_access_key_id=creds["access_key"],
aws_secret_access_key=creds["secret_key"],
)
def _build_torrent(file_path, name, trackers):
"""Create a torf.Torrent from a local file and return (torrent, magnet_uri)."""
try:
import torf
except ImportError:
raise RuntimeError(
"torf is required for torrent generation — pip install torf"
)
t = torf.Torrent(
path=file_path,
name=name,
trackers=[[tr] for tr in trackers],
private=False,
comment="permacomputer.com — CWE-407 patched OS image",
source="permacomputer",
)
t.generate()
magnet = str(t.magnet())
return t, magnet
def generate_torrent(s3_client, bucket, s3_key, s3_path, product_id,
session_factory, trackers=None):
"""Download file from S3, generate .torrent, upload, save magnet link.
Designed to run in a background thread never call directly from a request.
"""
if trackers is None:
trackers = DEFAULT_TRACKERS
name = os.path.basename(s3_key)
log.info("torrent: generating for product=%s key=%s", product_id, s3_key)
with tempfile.TemporaryDirectory() as tmpdir:
local_path = os.path.join(tmpdir, name)
torrent_path = os.path.join(tmpdir, f"{name}.torrent")
# 1. Stream file from S3
log.info("torrent: downloading s3://%s/%s", bucket, s3_key)
s3_client.download_file(bucket, s3_key, local_path)
# 2. Build .torrent + extract magnet link
log.info("torrent: building torrent for %s", local_path)
torrent_obj, magnet_uri = _build_torrent(local_path, name, trackers)
# 3. Write .torrent file
torrent_obj.write(torrent_path)
log.info("torrent: magnet=%s", magnet_uri)
# 4. Upload .torrent to S3 alongside the product file
torrent_s3_key = f"{s3_path}/product.torrent"
s3_client.upload_file(
torrent_path,
bucket,
torrent_s3_key,
ExtraArgs={
"ACL": "public-read",
"ContentType": "application/x-bittorrent",
"ContentDisposition": f'attachment; filename="{name}.torrent"',
"CacheControl": "public, max-age=86400",
},
)
log.info("torrent: uploaded .torrent to s3://%s/%s", bucket, torrent_s3_key)
# 5. Save magnet link to DB
try:
with session_factory() as session:
from ..models.product import Product
product = session.get(Product, product_id)
if product is not None:
product.torrent_magnet_link = magnet_uri
session.add(product)
session.commit()
log.info("torrent: saved magnet link for product=%s", product_id)
except Exception:
log.exception("torrent: failed to save magnet link for product=%s", product_id)
def generate_torrent_async(s3_client, bucket, s3_key, s3_path, product_id,
session_factory, trackers=None):
"""Fire-and-forget: generate torrent in a daemon thread."""
creds = _capture_s3_creds(s3_client)
def _run():
try:
fresh_client = _make_client(creds)
generate_torrent(
fresh_client, bucket, s3_key, s3_path, product_id,
session_factory, trackers=trackers,
)
except Exception:
log.exception(
"torrent: background generation failed for product=%s", product_id
)
t = threading.Thread(target=_run, daemon=True, name=f"torrent-{product_id}")
t.start()
log.info("torrent: background thread started for product=%s", product_id)

View file

@ -289,13 +289,27 @@ Your cover (<code>thumbnail1</code>) will show up on search pages.
<br /> <br />
{% if torrent_enabled %} {% if torrent_enabled %}
<label for="torrent_magnet_link">Magnet Link <span style="font-weight:normal;color:#888">(optional — torrent download for buyers)</span></label> <label>Magnet Link
<input type="text" name="torrent_magnet_link" id="torrent_magnet_link" <span style="font-weight:normal;color:#888">
value="{{ torrent_magnet_link }}" — generated automatically when you upload the product file
placeholder="magnet:?xt=urn:btih:..." </span>
class="mps-text-input" /> </label>
{% if torrent_magnet_link %}
<div style="display:flex;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;color:#888" />
<button type="button"
onclick="navigator.clipboard.writeText(document.getElementById('torrent_magnet_link_display').value)"
class="mps-button" style="white-space:nowrap">Copy</button>
<a href="{{ torrent_magnet_link }}" class="mps-button" style="white-space:nowrap">&#9697; Open</a>
</div>
{% else %}
<p style="color:#888;margin:.25rem 0">
Upload the product file to generate the magnet link automatically.
</p>
{% endif %}
<br />
<br /> <br />
{% endif %} {% endif %}

View file

@ -4906,49 +4906,28 @@ class TestTorrentSettings(_AuthenticatedBase):
shop = self._create_shop_helper() shop = self._create_shop_helper()
self.assertFalse(shop.torrent_enabled) self.assertFalse(shop.torrent_enabled)
def test_magnet_link_saved_on_product(self): 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 = self._create_shop_helper()
shop.torrent_enabled = True shop.torrent_enabled = True
self.dbsession.flush() self.dbsession.flush()
product = self._create_product_helper(shop) product = self._create_product_helper(shop)
magnet = "magnet:?xt=urn:btih:abc123&dn=test" # Simulate what the background thread does after upload
res = self.testapp.post( product.torrent_magnet_link = "magnet:?xt=urn:btih:abc123&dn=test"
f"/p/{product.id}/edit", self.dbsession.flush()
{
"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.dbsession.refresh(product)
self.assertEqual(product.torrent_magnet_link, magnet) self.assertEqual(product.torrent_magnet_link, "magnet:?xt=urn:btih:abc123&dn=test")
def test_magnet_link_rejected_if_invalid_scheme(self): def test_magnet_link_shown_on_content_page(self):
shop = self._create_shop_helper() shop = self._create_shop_helper()
shop.torrent_enabled = True shop.torrent_enabled = True
self.dbsession.flush() self.dbsession.flush()
product = self._create_product_helper(shop) product = self._create_product_helper(shop)
res = self.testapp.post( product.torrent_magnet_link = "magnet:?xt=urn:btih:abc123&dn=test"
f"/p/{product.id}/edit", self.dbsession.flush()
{ res = self.testapp.get(f"/c/{product.id}/{product.slug}", status=200)
"title": product.title, self.assertIn("Torrent", res.text)
"description": product.description or "", self.assertIn("magnet:", res.text)
"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): def test_magnet_link_not_shown_when_torrent_disabled(self):
shop = self._create_shop_helper() shop = self._create_shop_helper()

View file

@ -297,17 +297,7 @@ def product_edit(request):
request.dbsession.add(product.set_price(price)) request.dbsession.add(product.set_price(price))
request.session.flash(("You updated the product's price.", "success")) request.session.flash(("You updated the product's price.", "success"))
if product.shop.torrent_enabled: # torrent_magnet_link is auto-generated on upload — no manual save needed
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: if s3_webhook_key and s3_webhook_bucket and s3_webhook_etag:
# Check if the file exists and has a non-zero size # Check if the file exists and has a non-zero size
@ -391,6 +381,18 @@ def product_edit(request):
product.shop.id, request.registry["dbsession_factory"] product.shop.id, request.registry["dbsession_factory"]
) )
# Auto-generate .torrent + magnet link for the main product file
if file_key == "product" and product.shop.torrent_enabled:
from ..lib.torrent import generate_torrent_async
generate_torrent_async(
request.shop_uploads_client,
request.shop_bucket_name,
f"{product.s3_path}/{file_key}",
product.s3_path,
product.id,
request.registry["dbsession_factory"],
)
# Generate vocal isolation tracks if this is audio/video # Generate vocal isolation tracks if this is audio/video
from ..models.product import get_media_type from ..models.product import get_media_type
from ..lib.karaoke import process_karaoke from ..lib.karaoke import process_karaoke

View file

@ -56,3 +56,4 @@ bleach
bleach-allowlist bleach-allowlist
beautifulsoup4 beautifulsoup4
html5lib html5lib
torf