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:
parent
d5f55f8131
commit
7d78dff6ac
5 changed files with 199 additions and 50 deletions
153
make_post_sell/lib/torrent.py
Normal file
153
make_post_sell/lib/torrent.py
Normal 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)
|
||||
|
|
@ -289,13 +289,27 @@ Your cover (<code>thumbnail1</code>) will show up on search pages.
|
|||
<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" />
|
||||
<label>Magnet Link
|
||||
<span style="font-weight:normal;color:#888">
|
||||
— generated automatically when you upload the product file
|
||||
</span>
|
||||
</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">◡ 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 />
|
||||
{% endif %}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -56,3 +56,4 @@ bleach
|
|||
bleach-allowlist
|
||||
beautifulsoup4
|
||||
html5lib
|
||||
torf
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue