OASIS_MOBILE/src/models/torrent_downloads_model.js
oasis-bot c332afd6ea upstream: integrar Oasis 1.3.1
Base del merge a tres bandas: git 0.9.5 de epsylon, esta rama y el src de la
APK oficial 1.3.1 (la variante movil: OASIS_MOBILE, regex sin \p{}, audio y
video de Android). Corre en el mismo nodejs-mobile 18.20.4 que ya usabamos.

Se conserva de la rama: Karvan, identidades y multicuenta, topbar y barra
inferior, borrado de EXIF sin sharp, filtro hot del foro, RTC sin STUN y los
modulos apagados en movil (wallet, banking, torrents).

Se toma de upstream lo que ya hace de serie: respuesta citada en chats, ssb-lan
en movil, configuracion en ~/.ssb/oasis (sobrevive a reinstalar el backend).
El asistente de bienvenida deja elegir workflow tambien en movil.
2026-10-18 02:52:17 +02:00

169 lines
6.3 KiB
JavaScript

const pull = require('../server/node_modules/pull-stream');
const Abortable = require('../server/node_modules/pull-abortable');
const JOB_TTL_MS = 6 * 60 * 60 * 1000;
const PROBE_TIMEOUT_MS = 8000;
const RECHECK_MS = 15000;
module.exports = ({ cooler }) => {
const jobs = new Map();
let recheckTimer = null;
const openSsb = () => cooler.open();
const withTimeout = (p, ms) => Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms))]);
const peerRpcs = (ssb) => {
const out = [];
const live = ssb && ssb.peers && typeof ssb.peers === 'object' ? ssb.peers : {};
for (const id of Object.keys(live)) {
if (id === ssb.id) continue;
for (const rpc of (Array.isArray(live[id]) ? live[id] : [])) {
if (rpc && rpc.blobs && typeof rpc.blobs.get === 'function' && !rpc.closed) out.push({ id, rpc });
}
}
return out;
};
const hasLocal = (ssb, blobId) => new Promise((resolve) => {
try { ssb.blobs.has(blobId, (err, has) => resolve(!err && !!has)); } catch (_) { resolve(false); }
});
const remoteHas = (peer, blobId) => withTimeout(new Promise((resolve) => {
try { peer.rpc.blobs.has(blobId, (err, has) => resolve(!err && !!has)); } catch (_) { resolve(false); }
}), PROBE_TIMEOUT_MS).catch(() => false);
const findSeeds = async (ssb, blobId) => {
const peers = peerRpcs(ssb);
const flags = await Promise.all(peers.map((p) => remoteHas(p, blobId)));
return peers.filter((_, i) => flags[i]);
};
const fetchFrom = (ssb, seed, job) => new Promise((resolve, reject) => {
const abortable = Abortable();
job.abort = () => abortable.abort(new Error('cancelled'));
let received = 0;
const cap = job.size > 0 ? job.size : 75 * 1024 * 1024;
const opts = { key: job.blobId, max: cap };
pull(
seed.rpc.blobs.get(opts),
abortable,
pull.through((buf) => { received += buf.length; job.received = received; if (received > cap) abortable.abort(new Error('too large')); }),
ssb.blobs.add(job.blobId, (err) => { job.abort = null; err ? reject(err) : resolve(received); })
);
});
const finish = (job, state, error) => {
job.state = state;
job.error = error ? String(error.message || error) : '';
job.finishedAt = Date.now();
job.abort = null;
if (state === 'done' && job.size > 0) job.received = job.size;
};
const ensureRecheck = () => {
if (recheckTimer) return;
recheckTimer = setInterval(async () => {
const pending = Array.from(jobs.values()).filter((j) => j.state === 'nosource');
if (!pending.length) { clearInterval(recheckTimer); recheckTimer = null; return; }
let ssb;
try { ssb = await openSsb(); } catch (_) { return; }
for (const job of pending) {
if (await hasLocal(ssb, job.blobId)) { finish(job, 'done'); continue; }
const seeds = await findSeeds(ssb, job.blobId);
if (seeds.length) run(job);
}
}, RECHECK_MS);
if (recheckTimer.unref) recheckTimer.unref();
};
const run = async (job) => {
if (job.state === 'downloading' || job.state === 'probing') return;
let ssb;
try { ssb = await openSsb(); } catch (err) { finish(job, 'failed', err); return; }
if (await hasLocal(ssb, job.blobId)) { finish(job, 'done'); return; }
job.state = 'probing';
job.error = '';
const seeds = await findSeeds(ssb, job.blobId);
job.seeds = seeds.map((s) => s.id);
if (!seeds.length) {
job.state = 'nosource';
try { ssb.blobs.want(job.blobId, () => {}); } catch (_) {}
ensureRecheck();
return;
}
job.state = 'downloading';
job.startedAt = job.startedAt || Date.now();
let lastErr = null;
for (const seed of seeds) {
if (job.cancelled) { finish(job, 'cancelled'); return; }
job.peer = seed.id;
job.received = 0;
try {
await fetchFrom(ssb, seed, job);
finish(job, 'done');
return;
} catch (err) {
lastErr = err;
if (job.cancelled) { finish(job, 'cancelled'); return; }
}
}
finish(job, 'failed', lastErr);
};
const sweep = () => {
const now = Date.now();
for (const [key, job] of jobs) if (job.finishedAt && now - job.finishedAt > JOB_TTL_MS) jobs.delete(key);
};
const snapshot = (job) => ({
blobId: job.blobId,
name: job.name,
size: job.size,
received: Math.min(job.received || 0, job.size || job.received || 0),
state: job.state,
seeds: Array.isArray(job.seeds) ? job.seeds.length : 0,
peer: job.peer || '',
error: job.error || '',
torrentKey: job.torrentKey || '',
startedAt: job.startedAt || 0,
finishedAt: job.finishedAt || 0,
active: job.state === 'queued' || job.state === 'probing' || job.state === 'downloading' || job.state === 'nosource'
});
return {
start({ blobId, size, name, torrentKey } = {}) {
const id = String(blobId || '');
if (!id.startsWith('&')) throw new Error('torrentFromContentMissing');
sweep();
let job = jobs.get(id);
if (job && (job.state === 'downloading' || job.state === 'probing' || job.state === 'done')) return snapshot(job);
job = { blobId: id, size: Number(size) || 0, name: String(name || ''), torrentKey: torrentKey || '', received: 0, state: 'queued', seeds: [], error: '', cancelled: false, startedAt: 0, finishedAt: 0, abort: null, peer: '' };
jobs.set(id, job);
run(job);
return snapshot(job);
},
cancel(blobId) {
const job = jobs.get(String(blobId || ''));
if (!job) return false;
job.cancelled = true;
if (typeof job.abort === 'function') { try { job.abort(); } catch (_) {} }
else if (job.state !== 'done') finish(job, 'cancelled');
return true;
},
remove(blobId) { return jobs.delete(String(blobId || '')); },
get(blobId) {
const job = jobs.get(String(blobId || ''));
return job ? snapshot(job) : null;
},
list() {
sweep();
return Array.from(jobs.values()).map(snapshot).sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0));
},
async hasLocal(blobId) {
try { return await hasLocal(await openSsb(), String(blobId || '')); } catch (_) { return false; }
},
async seedsFor(blobId) {
try { return (await findSeeds(await openSsb(), String(blobId || ''))).map((s) => s.id); } catch (_) { return []; }
}
};
};