Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:

- name: Install addon dependencies
working-directory: addon
run: npm ci
run: npm install --ignore-scripts=false

- name: Test addon
working-directory: addon
Expand Down
7 changes: 7 additions & 0 deletions addon/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ OPENSUBTITLES_PASSWORD=
# SUBTITLE_SYNC_MAX_OFFSET_SECONDS=300
# ALASS_SPLIT_PENALTY=10

# ─── P2P Privacy Proxy ───────────────────────────────────────────────────────
# Route torrent traffic through a SOCKS5 proxy (e.g. VPN) to hide server IP.
# TORRENT_PROXY=socks5://127.0.0.1:1080

# Max concurrent torrent engines (each uses ~15-30 MB RAM).
# PROXY_MAX_ENGINES=3

# ─── Debrid API Keys (set in user configuration, not here) ────────────────────
# These are passed per-user in the addon configuration URL.
# Do NOT set them here unless running a single-user private instance.
Expand Down
2 changes: 2 additions & 0 deletions addon/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ RUN apt-get update \
python3-pip \
wget \
ca-certificates \
build-essential \
python3-setuptools \
&& python3 -m pip install --no-cache-dir --break-system-packages ffsubsync \
&& rm -rf /var/lib/apt/lists/*

Expand Down
21 changes: 18 additions & 3 deletions addon/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import express from 'express';
import swaggerStats from 'swagger-stats';
import { serverless } from './serverless.js';
import { initBestTrackers } from './lib/magnetHelper.js';
import { destroyAllEngines } from './lib/torrentProxy.js';
import { logger } from './lib/logger.js';

const app = express();
Expand All @@ -27,10 +28,24 @@ app.use('/', serverless);

const PORT = process.env.PORT || 7000;

app.listen(PORT, async () => {
logger.info(`Magnetio addon running on port ${PORT}`);
async function start() {
await initBestTrackers();
logger.info('Best trackers initialized');
});

const server = app.listen(PORT, () => {
logger.info(`Magnetio addon running on port ${PORT}`);
});

const shutdown = () => {
logger.info('Shutting down…');
destroyAllEngines();
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 10_000);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}

start();

export default app;
37 changes: 30 additions & 7 deletions addon/lib/cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,45 @@ function getStore() {
return _store;
}

const _inflight = new Map();

/**
* Fetch from cache; call loader on miss and store the result.
* Fetch from cache with stale-while-revalidate semantics.
*
* - Fresh hit (within TTL): return immediately.
* - Stale hit (past TTL but within 2x TTL): return stale data, refresh in background.
* - Miss: block on loader.
*
* @param {string} key
* @param {Function} loader async () => value
* @param {number} ttl TTL in seconds
* Stored format: { data, createdAt }
*/
export async function cacheWrap(key, loader, ttl = 3600) {
const store = getStore();
const cached = await store.get(key);
if (cached !== undefined) return cached;
const ttlMs = ttl * 1000;
const entry = await store.get(key);

if (entry?.data !== undefined && entry.createdAt) {
const age = Date.now() - entry.createdAt;
if (age < ttlMs) return entry.data;

if (!_inflight.has(key)) {
const refresh = loader()
.then(value => {
const isEmpty = Array.isArray(value) && value.length === 0;
if (!isEmpty) {
return store.set(key, { data: value, createdAt: Date.now() }, ttlMs * 2);
}
})
.catch(err => logger.warn(`SWR refresh failed [${key}]: ${err.message}`))
.finally(() => _inflight.delete(key));
_inflight.set(key, refresh);
}
return entry.data;
}

const value = await loader();
const isEmpty = Array.isArray(value) && value.length === 0;
if (!isEmpty) {
await store.set(key, value, ttl * 1000); // Keyv uses milliseconds
await store.set(key, { data: value, createdAt: Date.now() }, ttlMs * 2);
}
return value;
}
Expand Down
10 changes: 10 additions & 0 deletions addon/lib/configuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,13 @@ export function parseConfiguration(configString) {
try { config.torznabApiKey = decodeURIComponent(value); }
catch { config.torznabApiKey = value; }
break;
case 'proxy':
config.proxyStreams = parseBoolean(value, config.proxyStreams);
break;
case 'proxyurl':
try { config.proxyUrl = decodeURIComponent(value); }
catch { config.proxyUrl = value; }
break;
}
}

Expand Down Expand Up @@ -197,6 +204,9 @@ export function getDefaultConfiguration() {
// Torznab (Jackett / Prowlarr)
torznabUrl: null,
torznabApiKey: null,
// P2P privacy proxy
proxyStreams: false,
proxyUrl: null,
// Debrid keys (all null by default)
realDebridApiKey: null,
premiumizeApiKey: null,
Expand Down
65 changes: 64 additions & 1 deletion addon/lib/landingTemplate.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export function landingTemplate(manifest, initialConfig = {}) {
tmdbApiKey: initialConfig.tmdbApiKey ?? '',
torznabUrl: initialConfig.torznabUrl ?? '',
torznabApiKey: initialConfig.torznabApiKey ?? '',
proxyUrl: initialConfig.proxyUrl ?? '',
realDebridApiKey: initialConfig.realDebridApiKey ?? '',
premiumizeApiKey: initialConfig.premiumizeApiKey ?? '',
allDebridApiKey: initialConfig.allDebridApiKey ?? '',
Expand Down Expand Up @@ -923,7 +924,7 @@ export function landingTemplate(manifest, initialConfig = {}) {
<span class="gradient-text">Stream anything.</span><br />Own your setup.
</h1>
<p class="hero-sub">
Magnetio is a self-hosted Stremio addon that aggregates torrents from 22+ providers,
Magnetio is a self-hosted addon for <strong>Stremio</strong> and <strong><a href="https://nuvioplugin.com" target="_blank" rel="noreferrer" style="color:var(--accent);text-decoration:none;">Nuvio</a></strong> that aggregates torrents from 22+ providers,
resolves them through 8 debrid services, and delivers instant high-quality streams.
</p>
<div class="hero-buttons">
Expand Down Expand Up @@ -989,6 +990,11 @@ export function landingTemplate(manifest, initialConfig = {}) {
<div class="feature-title">Self-Hosted</div>
<div class="feature-desc">Runs on a Raspberry Pi. Your API keys never leave your server. No tracking, no telemetry.</div>
</div>
<div class="feature-card">
<span class="feature-icon">&#128257;</span>
<div class="feature-title">Stremio + Nuvio</div>
<div class="feature-desc">Works with both <a href="https://www.stremio.com" target="_blank" rel="noreferrer" style="color:var(--accent);">Stremio</a> and <a href="https://nuvioplugin.com" target="_blank" rel="noreferrer" style="color:var(--accent);">Nuvio</a>. Same manifest URL, same configuration — install once, use on both platforms.</div>
</div>
</div>
</section>

Expand Down Expand Up @@ -1113,6 +1119,33 @@ export function landingTemplate(manifest, initialConfig = {}) {
</div>
</div>

<div class="config-card" id="p2pWarningCard" style="display:none;border-color:rgba(251,191,36,0.4);">
<div class="config-card-title" style="color:#fbbf24;">&#9888; P2P Exposure Warning</div>
<div class="config-card-desc" style="color:#fbbf24;opacity:0.85;">You have no debrid service configured. Without a debrid service, streams are fetched directly via P2P (peer-to-peer), which <strong>exposes your IP address</strong> to other peers in the torrent swarm. To protect your privacy, either add a debrid API key above or enable the Privacy Proxy below and provide your own VPN/SOCKS5 proxy so that the Magnetio server routes torrent traffic through your VPN.</div>
</div>

<div class="config-card">
<div class="config-card-title">Privacy Proxy</div>
<div class="config-card-desc">Stream torrents through the Magnetio server instead of connecting directly to the swarm. Your device never touches the torrent network.<br /><br /><strong>Important:</strong> Without a VPN/SOCKS5 proxy configured below, the server's own IP is used for torrent connections. Each user can provide their own SOCKS5 proxy (from a VPN provider) so that all torrent traffic is routed through that VPN — keeping both you and the server operator private.</div>
<div class="field-grid">
<label>
P2P Privacy Proxy
<select id="proxyStreams">
<option value="0">Disabled</option>
<option value="1">Enabled</option>
</select>
</label>
<label id="proxyUrlLabel" style="display:none;">
Your SOCKS5 Proxy
<div class="password-wrap">
<input type="password" id="proxyUrl" autocomplete="off" placeholder="socks5://user:pass@host:1080" />
<button type="button" class="eye-toggle" data-target="proxyUrl" title="Toggle visibility">${SVG_EYE}</button>
</div>
</label>
</div>
<div class="config-card-desc" id="proxyHelpText" style="display:none;font-size:0.8rem;opacity:0.7;">Most VPN providers offer SOCKS5 proxy access (NordVPN, Surfshark, PIA, Mullvad, etc.). Enter the SOCKS5 address from your VPN provider above. This way, torrent traffic goes through your VPN — not through the server's IP.</div>
</div>

<div class="config-card">
<div class="config-card-title">Debrid Services</div>
<div class="config-card-desc">Add API keys for your debrid services. Cached torrents resolve as direct streams automatically.</div>
Expand Down Expand Up @@ -1253,6 +1286,8 @@ export function landingTemplate(manifest, initialConfig = {}) {
document.getElementById('prewarm').value = initialConfig.prewarmDebrid === false ? '0' : '1';
document.getElementById('prewarmLimit').value = String(initialConfig.prewarmLimit || 3);
document.getElementById('debridCatalogs').value = initialConfig.debridCatalogs === false ? '0' : '1';
document.getElementById('proxyStreams').value = initialConfig.proxyStreams ? '1' : '0';
document.getElementById('proxyUrl').value = initialConfig.proxyUrl || '';

setChipGrid('qualities', initialConfig.qualities || []);
setChipGrid('languages', initialConfig.languages || []);
Expand Down Expand Up @@ -1281,6 +1316,10 @@ export function landingTemplate(manifest, initialConfig = {}) {
parts.push('prewarmLimit=' + document.getElementById('prewarmLimit').value);
var debridCatalogsValue = document.getElementById('debridCatalogs').value;
if (debridCatalogsValue === '0') parts.push('debridCatalogs=0');
var proxyValue = document.getElementById('proxyStreams').value;
if (proxyValue === '1') parts.push('proxy=1');
var proxyUrlVal = document.getElementById('proxyUrl').value.trim();
if (proxyUrlVal) parts.push('proxyUrl=' + encodeURIComponent(proxyUrlVal));

var qualities = selectedValues('qualities');
var languages = selectedValues('languages');
Expand Down Expand Up @@ -1374,7 +1413,31 @@ export function landingTemplate(manifest, initialConfig = {}) {
observer.observe(section);
});

function updateProxyVisibility() {
var enabled = document.getElementById('proxyStreams').value === '1';
document.getElementById('proxyUrlLabel').style.display = enabled ? '' : 'none';
document.getElementById('proxyHelpText').style.display = enabled ? '' : 'none';
}

function updateP2pWarning() {
var keys = ['rd','pm','ad','dl','ed','oc','tb','pu'];
var hasDebrid = keys.some(function(id) { return document.getElementById(id).value.trim(); });
document.getElementById('p2pWarningCard').style.display = hasDebrid ? 'none' : '';
}

document.getElementById('proxyStreams').addEventListener('change', function() {
updateProxyVisibility();
refreshPreview();
});

var keys_for_warning = ['rd','pm','ad','dl','ed','oc','tb','pu'];
keys_for_warning.forEach(function(id) {
document.getElementById(id).addEventListener('input', updateP2pWarning);
});

applyInitialState();
updateProxyVisibility();
updateP2pWarning();
refreshPreview();
</script>
</body>
Expand Down
23 changes: 16 additions & 7 deletions addon/lib/repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { cacheWrap } from './cache.js';
import { logger } from './logger.js';

const SCRAPER_BASE_URL = process.env.SCRAPER_URL || 'http://localhost:8080';
const REQUEST_TIMEOUT = 30_000;
const REQUEST_TIMEOUT = 20_000;

function simpleHash(str) {
let h = 0;
Expand Down Expand Up @@ -32,18 +32,27 @@ export async function getStreams(type, id, config) {
const cacheKey = `streams:${type}:${id}:${providerKey}${torznabSuffix}`;

return cacheWrap(cacheKey, async () => {
try {
const params = { providers: config.providers?.join(',') };
if (config.torznabUrl) {
params.torznabUrl = config.torznabUrl;
params.torznabApiKey = config.torznabApiKey || '';
}
const params = { providers: config.providers?.join(',') };
if (config.torznabUrl) {
params.torznabUrl = config.torznabUrl;
params.torznabApiKey = config.torznabApiKey || '';
}

const fetchOnce = async () => {
const { data } = await axios.get(`${SCRAPER_BASE_URL}/streams/${type}/${id}`, {
timeout: REQUEST_TIMEOUT,
params,
});
return Array.isArray(data.streams) ? data.streams : [];
};

try {
const results = await fetchOnce();
if (results.length === 0) {
await new Promise(r => setTimeout(r, 1000));
return fetchOnce();
}
return results;
} catch (err) {
logger.warn(`Repository fetch failed [${id}]: ${err.message}`);
return [];
Expand Down
27 changes: 24 additions & 3 deletions addon/lib/streamInfo.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { extractQuality } from './sort.js';

const ADDON_PREFIX = '⚡ Magnetio';

function getPublicBaseUrl(config) {
return (config?._publicBaseUrl || process.env.ADDON_PUBLIC_URL || '').replace(/\/$/, '');
}

/**
* Convert a raw torrent record into a Stremio stream object.
*/
Expand All @@ -22,20 +26,37 @@ export function toStreamInfo(record, config) {
[seedersStr, sizeStr].filter(Boolean).join(' '),
].filter(Boolean).join('\n');

const baseUrl = getPublicBaseUrl(config);
const useProxy = config?.proxyStreams && baseUrl;
const fileIdx = record.fileIdx ?? undefined;

const stream = {
name,
title: description,
description,
infoHash: record.infoHash,
fileIdx: record.fileIdx ?? 0,
sources: buildSources(record),
behaviorHints: {
bingeGroup: getBingeGroup(record, quality),
filename: filename || undefined,
videoSize: record.size || undefined,
},
};

if (useProxy) {
const proxyParams = config.proxyUrl
? `?p=${encodeURIComponent(Buffer.from(config.proxyUrl).toString('base64url'))}`
: '';
stream.url = `${baseUrl}/proxy/stream/${record.infoHash}/${fileIdx ?? 0}${proxyParams}`;
Comment thread
peterdsp marked this conversation as resolved.
stream.behaviorHints.notWebReady = true;
const proxyLabel = config.proxyUrl ? '🛡️ VPN Proxy' : '🛡️ Privacy Proxy';
const proxyDesc = description + '\n' + proxyLabel;
stream.title = proxyDesc;
stream.description = proxyDesc;
} else {
stream.infoHash = record.infoHash;
stream.fileIdx = fileIdx;
stream.sources = buildSources(record);
}

if (record.subtitles?.length) {
stream.subtitles = enrichSubtitles(record.subtitles);
}
Expand Down
Loading