From 7d78dff6ac60fad0ddf261a2ed59051ea01e11b4 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Sun, 5 Apr 2026 20:03:47 -0400 Subject: [PATCH] mps: torrent magnet links auto-generated on file upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- make_post_sell/lib/torrent.py | 153 +++++++++++++++++++++++ make_post_sell/templates/product_edit.j2 | 26 +++- make_post_sell/tests/test_functional.py | 45 ++----- make_post_sell/views/product.py | 24 ++-- requirements.txt | 1 + 5 files changed, 199 insertions(+), 50 deletions(-) create mode 100644 make_post_sell/lib/torrent.py diff --git a/make_post_sell/lib/torrent.py b/make_post_sell/lib/torrent.py new file mode 100644 index 0000000..927b180 --- /dev/null +++ b/make_post_sell/lib/torrent.py @@ -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) diff --git a/make_post_sell/templates/product_edit.j2 b/make_post_sell/templates/product_edit.j2 index 71f4bb3..a00846c 100644 --- a/make_post_sell/templates/product_edit.j2 +++ b/make_post_sell/templates/product_edit.j2 @@ -289,13 +289,27 @@ Your cover (thumbnail1) will show up on search pages.
{% if torrent_enabled %} - - + + {% if torrent_magnet_link %} +
+ + + ◡ Open +
+ {% else %} +

+ Upload the product file to generate the magnet link automatically. +

+ {% endif %} -

{% endif %} diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index 3f4bb71..c29c8e5 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -4906,49 +4906,28 @@ class TestTorrentSettings(_AuthenticatedBase): shop = self._create_shop_helper() 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.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, - ) + # Simulate what the background thread does after upload + product.torrent_magnet_link = "magnet:?xt=urn:btih:abc123&dn=test" + self.dbsession.flush() 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.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) + 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) + self.assertIn("magnet:", res.text) def test_magnet_link_not_shown_when_torrent_disabled(self): shop = self._create_shop_helper() diff --git a/make_post_sell/views/product.py b/make_post_sell/views/product.py index f2c47f2..e2e3fce 100644 --- a/make_post_sell/views/product.py +++ b/make_post_sell/views/product.py @@ -297,17 +297,7 @@ 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")) + # torrent_magnet_link is auto-generated on upload — no manual save needed if s3_webhook_key and s3_webhook_bucket and s3_webhook_etag: # 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"] ) + # 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 from ..models.product import get_media_type from ..lib.karaoke import process_karaoke diff --git a/requirements.txt b/requirements.txt index a291c9b..9ec40d6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -56,3 +56,4 @@ bleach bleach-allowlist beautifulsoup4 html5lib +torf