atto-downloaders 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -86,6 +86,8 @@ Everything is initiated by pure JS — no shell script, no manual install, no ad
86
86
  5. Resolves ffmpeg: path → winget/static zip (Windows) → `pkg` (Termux) → `brew`/`apt`/`dnf`/`pacman`/`apk` → static `tar.xz` build (Linux, no root).
87
87
  6. Writes `state.json` — later calls take ~30 ms.
88
88
 
89
+ **Alpine/musl note:** musl containers get python from official Alpine apks (extracted user-space, no root) — yt-dlp runs fully. The PO-token server's `canvas` has no musl prebuilt, so PO-token stays optional there: the ladder leans on client rotation and closes at the ATTO providers. A glibc image (`ghcr.io/parkervcp/yolks:nodejs_22`) enables the complete path.
90
+
89
91
  In an npm **global** install the package dir is root-owned, so the whole bootstrap lands in `~/.atto/` instead (override with `ATTO_HOME`).
90
92
 
91
93
  ## API reference
package/atto-ytdlp.js CHANGED
@@ -335,6 +335,16 @@ function findBinary(o) {
335
335
  return which('yt-dlp') || which('yt-dlp.exe') || which('yt-dlp.cmd') || which('yt-dlp.bat');
336
336
  }
337
337
 
338
+ // musl (Alpine) containers cannot run glibc portable builds — detect before wasting a download
339
+ function isMusl() {
340
+ if (process.platform !== 'linux') return false;
341
+ try { return !process.report.getReport().header.glibcVersionRuntime; } catch (_) { /* fall through */ }
342
+ try {
343
+ const r = spawnSync('ldd', ['--version'], { encoding: 'utf8', windowsHide: true });
344
+ return /musl/i.test(`${r.stderr || ''}${r.stdout || ''}`);
345
+ } catch (_) { return true; }
346
+ }
347
+
338
348
  let pyCache = null;
339
349
  function resolvePython() {
340
350
  if (pyCache !== null) return pyCache;
@@ -1031,7 +1041,7 @@ async function setupPot(o) {
1031
1041
  if (!fs.existsSync(path.join(dir, 'server', 'package.json'))) {
1032
1042
  if (fs.existsSync(dir)) { say.err(`${dir} exists but has no server/ — remove it or pass --pot-dir`); return; }
1033
1043
  say.info(`cloning bgutil provider @ ${POT_BRANCH}`);
1034
- const clone = runTool('git', ['clone', '--depth', '1', '--branch', POT_BRANCH, POT_REPO, dir],
1044
+ const clone = runTool('git', ['-c', 'advice.detachedHead=false', 'clone', '--depth', '1', '--branch', POT_BRANCH, POT_REPO, dir],
1035
1045
  { stdio: 'inherit' });
1036
1046
  if (clone.status !== 0) { say.err('git clone failed'); return; }
1037
1047
  }
@@ -1052,7 +1062,13 @@ async function setupPot(o) {
1052
1062
  const serverDir = path.join(dir, 'server');
1053
1063
  say.info('npm ci (this pulls botguard + canvas)');
1054
1064
  if (runTool('npm', ['ci'], { cwd: serverDir, stdio: 'inherit' }).status !== 0) {
1055
- say.err('npm ci failed'); return;
1065
+ if (isMusl()) {
1066
+ say.err('npm ci failed: canvas ships no musl prebuilt — the PO-token server needs a glibc image.');
1067
+ say.err('optional: switch the egg image to ghcr.io/parkervcp/yolks:nodejs_22. Without PO-token the ladder runs pot-less (client rotation) and still closes at ATTO providers.');
1068
+ } else {
1069
+ say.err('npm ci failed');
1070
+ }
1071
+ return;
1056
1072
  }
1057
1073
  say.info('npx tsc');
1058
1074
  if (runTool('npx', ['tsc'], { cwd: serverDir, stdio: 'inherit' }).status !== 0) {
@@ -1103,7 +1119,7 @@ async function main() {
1103
1119
  module.exports = {
1104
1120
  parseArgs, runUrl, doctor, setupPot, ensurePot, probePot,
1105
1121
  classify, findBinary, buildStrategies, applyTypePreset,
1106
- spawnSpec, resolvePython, have, which, run, say,
1122
+ spawnSpec, resolvePython, have, which, run, say, isMusl,
1107
1123
  AUDIO_TYPES, RENDER_TYPES, SOURCE_TYPES, POT_PORT,
1108
1124
  };
1109
1125
 
package/package.json CHANGED
@@ -1,69 +1,72 @@
1
- {
2
- "name": "atto-downloaders",
3
- "version": "3.0.0",
4
- "description": "Typed media downloader for sites and bots - yt-dlp ladder with PO-token provider, stream-copy exports (zero re-encode), provider fallback, zero-setup bootstrap on Windows/Linux/macOS/Termux",
5
- "main": "index.js",
6
- "types": "index.d.ts",
7
- "exports": {
8
- ".": {
9
- "types": "./index.d.ts",
10
- "require": "./index.js",
11
- "default": "./index.js"
12
- },
13
- "./providers": "./atto-downloaders.js",
14
- "./ytdlp": "./atto-ytdlp.js",
15
- "./package.json": "./package.json"
16
- },
17
- "bin": {
18
- "atto": "start-atto.js"
19
- },
20
- "files": [
21
- "index.js",
22
- "index.d.ts",
23
- "start-atto.js",
24
- "atto-ytdlp.js",
25
- "atto-downloaders.js",
26
- "build-ytdlp.sh",
27
- "README.md"
28
- ],
29
- "scripts": {
30
- "start": "node start-atto.js",
31
- "setup": "node start-atto.js --setup",
32
- "atto": "node start-atto.js",
33
- "test": "node --check index.js && node --check start-atto.js && node --check atto-ytdlp.js && node --check atto-downloaders.js && node -e \"require('./index.js')\"",
34
- "help": "node start-atto.js --help",
35
- "providers": "node atto-downloaders.js --list-providers",
36
- "build": "bash build-ytdlp.sh",
37
- "doctor": "node start-atto.js --doctor",
38
- "smoke": "node atto-ytdlp.js --doctor --smoke https://www.youtube.com/watch?v=dQw4w9WgXcQ",
39
- "pot": "node atto-ytdlp.js --setup-pot",
40
- "ytdl": "node atto-ytdlp.js",
41
- "prepublishOnly": "npm test"
42
- },
43
- "keywords": [
44
- "yt-dlp",
45
- "youtube-dl",
46
- "downloader",
47
- "media",
48
- "video",
49
- "audio",
50
- "mp3",
51
- "mp4",
52
- "bot",
53
- "scraper",
54
- "savetube",
55
- "ytultra",
56
- "saveclip",
57
- "po-token",
58
- "cli",
59
- "library"
60
- ],
61
- "author": "Zyon",
62
- "license": "MIT",
63
- "optionalDependencies": {
64
- "playwright-core": "^1.64.1"
65
- },
66
- "engines": {
67
- "node": ">=18.0.0"
68
- }
69
- }
1
+ {
2
+ "name": "atto-downloaders",
3
+ "version": "3.0.1",
4
+ "description": "Typed media downloader for sites and bots - yt-dlp ladder with PO-token provider, stream-copy exports (zero re-encode), provider fallback, zero-setup bootstrap on Windows/Linux/macOS/Termux",
5
+ "main": "index.js",
6
+ "types": "index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./index.d.ts",
10
+ "require": "./index.js",
11
+ "default": "./index.js"
12
+ },
13
+ "./providers": "./atto-downloaders.js",
14
+ "./ytdlp": "./atto-ytdlp.js",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "bin": {
18
+ "atto": "start-atto.js"
19
+ },
20
+ "files": [
21
+ "index.js",
22
+ "index.d.ts",
23
+ "start-atto.js",
24
+ "atto-ytdlp.js",
25
+ "atto-downloaders.js",
26
+ "build-ytdlp.sh",
27
+ "README.md"
28
+ ],
29
+ "scripts": {
30
+ "start": "node start-atto.js",
31
+ "setup": "node start-atto.js --setup",
32
+ "atto": "node start-atto.js",
33
+ "test": "node --check index.js && node --check start-atto.js && node --check atto-ytdlp.js && node --check atto-downloaders.js && node -e \"require('./index.js')\"",
34
+ "help": "node start-atto.js --help",
35
+ "providers": "node atto-downloaders.js --list-providers",
36
+ "build": "bash build-ytdlp.sh",
37
+ "doctor": "node start-atto.js --doctor",
38
+ "smoke": "node atto-ytdlp.js --doctor --smoke https://www.youtube.com/watch?v=dQw4w9WgXcQ",
39
+ "pot": "node atto-ytdlp.js --setup-pot",
40
+ "ytdl": "node atto-ytdlp.js",
41
+ "prepublishOnly": "npm test"
42
+ },
43
+ "keywords": [
44
+ "yt-dlp",
45
+ "youtube-dl",
46
+ "downloader",
47
+ "media",
48
+ "video",
49
+ "audio",
50
+ "mp3",
51
+ "mp4",
52
+ "bot",
53
+ "scraper",
54
+ "savetube",
55
+ "ytultra",
56
+ "saveclip",
57
+ "po-token",
58
+ "cli",
59
+ "library"
60
+ ],
61
+ "author": "Zyon",
62
+ "license": "MIT",
63
+ "optionalDependencies": {
64
+ "playwright-core": "^1.64.1"
65
+ },
66
+ "engines": {
67
+ "node": ">=18.0.0"
68
+ },
69
+ "dependencies": {
70
+ "atto-downloaders": "^3.0.0"
71
+ }
72
+ }
package/start-atto.js CHANGED
@@ -42,6 +42,11 @@ const PYTHON_DIR = path.join(DIST, 'python');
42
42
  const PBS_VER = process.env.ATTO_PY_VER || '3.13.16';
43
43
  const PBS_TAG = process.env.ATTO_PY_TAG || '20261013';
44
44
  const PBS_URL = triple => `https://github.com/astral-sh/python-build-standalone/releases/download/${PBS_TAG}/cpython-${PBS_VER}+${PBS_TAG}-${triple}-install_only.tar.gz`;
45
+ // musl containers: python from official Alpine apks (extracted user-space, no root);
46
+ // repo paths are per-architecture: /alpine/v<ver>/main/<arch>/
47
+ const ALPINE_VER = process.env.ATTO_ALPINE_VER || '3.22';
48
+ const ALPINE_ARCH = { x64: 'x86_64', arm64: 'aarch64', arm: 'armv7' }[process.arch] || process.arch;
49
+ const ALPINE_REPO = `https://dl-cdn.alpinelinux.org/alpine/v${ALPINE_VER}/main/${ALPINE_ARCH}`;
45
50
  const YTDLP_PIN = process.env.ATTO_YTDLP_VERSION || '2026.08.19';
46
51
  const POT_BRANCH = process.env.ATTO_POT_BRANCH || '2.0.2';
47
52
  const POT_TARBALL = `https://codeload.github.com/Brainicism/bgutil-ytdlp-pot-provider/tar.gz/refs/heads/${POT_BRANCH}`;
@@ -74,6 +79,8 @@ function findSystemPython() {
74
79
 
75
80
  // provisioned portable python wins over PATH — pinned, deterministic, zero admin
76
81
  function portablePythonCmd() {
82
+ const alpineWrap = path.join(PYTHON_DIR, 'bin', 'atto-python');
83
+ if (fs.existsSync(alpineWrap)) return { cmd: alpineWrap, prefix: [], name: 'alpine', portable: true };
77
84
  const exe = process.platform === 'win32'
78
85
  ? path.join(PYTHON_DIR, 'python.exe')
79
86
  : path.join(PYTHON_DIR, 'bin', 'python3');
@@ -200,6 +207,13 @@ function findPyRoot(dir, depth = 0) {
200
207
  async function ensurePortablePython() {
201
208
  const have = portablePythonCmd();
202
209
  if (have) return have;
210
+ if (ytdlp.isMusl()) {
211
+ // glibc builds never run here — pull python from official Alpine apks instead
212
+ const alpine = await ensureAlpinePython();
213
+ if (alpine) return alpine;
214
+ say.warn('alpine python bootstrap failed — a glibc image (ghcr.io/parkervcp/yolks:nodejs_22) removes this problem');
215
+ return null;
216
+ }
203
217
  const triple = pbsTriple();
204
218
  if (!triple) { say.warn(`no portable python build for ${process.arch}/${process.platform}`); return null; }
205
219
  const tar = ytdlp.which('tar');
@@ -235,6 +249,108 @@ async function ensurePortablePython() {
235
249
  return port;
236
250
  }
237
251
 
252
+ // musl containers: official Alpine python3 + dependency closure, extracted from .apk
253
+ // tarballs into dist/python (apks ARE gzipped tars) — no root, no apk tooling. The
254
+ // container's musl loader serves the binaries; a wrapper exports LD_LIBRARY_PATH and
255
+ // PYTHONHOME so the relocated prefix resolves its own stdlib.
256
+ function parseApkIndex(text) {
257
+ const pkgs = new Map();
258
+ let cur = null;
259
+ for (const line of text.split(/\r?\n/)) {
260
+ if (!line) { if (cur && cur.name) pkgs.set(cur.name, cur); cur = null; continue; }
261
+ const k = line.slice(0, 2), v = line.slice(2);
262
+ if (k === 'P:') cur = { name: v, ver: '', deps: [], provides: [] };
263
+ else if (!cur) continue;
264
+ else if (k === 'V:') cur.ver = v;
265
+ else if (k === 'D:') cur.deps = v.split(/\s+/).filter(Boolean);
266
+ else if (k === 'p:') cur.provides = v.split(/\s+/).filter(Boolean);
267
+ }
268
+ if (cur && cur.name) pkgs.set(cur.name, cur);
269
+ return pkgs;
270
+ }
271
+
272
+ // resolve the dependency closure of a root package; so: providers map to their package,
273
+ // cmd:/pc:/path: tokens live in busybox/toolchain the container already has — skipped
274
+ function apkClosure(pkgs, root) {
275
+ const provider = new Map();
276
+ for (const p of pkgs.values()) for (const pr of p.provides) {
277
+ const tok = pr.split('=')[0];
278
+ if (!provider.has(tok)) provider.set(tok, p.name);
279
+ }
280
+ const out = new Map();
281
+ const queue = [root];
282
+ while (queue.length) {
283
+ const name = queue.shift();
284
+ if (out.has(name)) continue;
285
+ const p = pkgs.get(name);
286
+ if (!p) continue;
287
+ out.set(name, p);
288
+ for (let d of p.deps) {
289
+ d = d.split('=')[0];
290
+ if (!d || d.startsWith('!') || d === 'musl' || d.startsWith('cmd:') || d.startsWith('pc:') || d.startsWith('path:')) continue;
291
+ if (d.startsWith('so:')) {
292
+ const hit = provider.get(d);
293
+ if (hit && hit !== 'musl') queue.push(hit);
294
+ } else if (pkgs.has(d)) queue.push(d);
295
+ else if (provider.has(d)) queue.push(provider.get(d));
296
+ }
297
+ }
298
+ return [...out.values()];
299
+ }
300
+
301
+ async function ensureAlpinePython() {
302
+ const wrapper = path.join(PYTHON_DIR, 'bin', 'atto-python');
303
+ if (fs.existsSync(wrapper)) return { cmd: wrapper, prefix: [], name: 'alpine', portable: true };
304
+ const tar = ytdlp.which('tar');
305
+ if (!tar) { say.warn('tar missing — cannot unpack Alpine python'); return null; }
306
+ const idx = path.join(CACHE, `APKINDEX-${ALPINE_VER}.tar.gz`);
307
+ if (!fs.existsSync(idx)) {
308
+ say.info(`fetching Alpine ${ALPINE_VER} package index`);
309
+ try { await downloadFile(`${ALPINE_REPO}/APKINDEX.tar.gz`, idx, 'APKINDEX'); }
310
+ catch (e) { say.warn(`alpine index download failed: ${e.message}`); return null; }
311
+ }
312
+ const tmp = path.join(CACHE, 'apkindex-x');
313
+ fs.rmSync(tmp, { recursive: true, force: true });
314
+ fs.mkdirSync(tmp, { recursive: true });
315
+ if (runTool(tar, ['-xzf', idx, '-C', tmp], { stdio: 'ignore' }).status !== 0) {
316
+ say.warn('APKINDEX unpack failed'); return null;
317
+ }
318
+ let text = '';
319
+ try { text = fs.readFileSync(path.join(tmp, 'APKINDEX'), 'utf8'); } catch (_) { say.warn('APKINDEX missing'); return null; }
320
+ const pkgs = parseApkIndex(text);
321
+ if (!pkgs.has('python3')) { say.warn('python3 not in alpine main repo'); return null; }
322
+
323
+ const closure = apkClosure(pkgs, 'python3');
324
+ say.info(`installing alpine python ${pkgs.get('python3').ver} + ${closure.length - 1} deps (user dir, no root)`);
325
+ fs.mkdirSync(PYTHON_DIR, { recursive: true });
326
+ for (const p of closure) {
327
+ const apk = path.join(CACHE, `${p.name}-${p.ver}.apk`);
328
+ if (!fs.existsSync(apk)) {
329
+ try { await downloadFile(`${ALPINE_REPO}/${p.name}-${p.ver}.apk`, apk, p.name); }
330
+ catch (e) { say.warn(`${p.name}: download failed (${e.message}) — continuing`); continue; }
331
+ }
332
+ if (runTool(tar, ['-xzf', apk, '-C', PYTHON_DIR], { stdio: 'ignore' }).status !== 0) {
333
+ say.warn(`${p.name}: unpack failed — continuing`);
334
+ }
335
+ }
336
+ const pyBin = path.join(PYTHON_DIR, 'usr', 'bin', 'python3');
337
+ if (!fs.existsSync(pyBin)) { say.warn('python3 missing after apk extraction'); return null; }
338
+ fs.mkdirSync(path.dirname(wrapper), { recursive: true });
339
+ fs.writeFileSync(wrapper, '#!/bin/sh\n' +
340
+ 'HERE=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)\n' +
341
+ 'export LD_LIBRARY_PATH="$HERE/usr/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"\n' +
342
+ 'export PYTHONHOME="$HERE/usr"\n' +
343
+ 'exec "$HERE/usr/bin/python3" "$@"\n');
344
+ fs.chmodSync(wrapper, 0o755);
345
+ const probe = spawnSync(wrapper, ['-V'], { encoding: 'utf8', windowsHide: true });
346
+ if (probe.status !== 0) {
347
+ say.warn(`alpine python failed to start: ${(probe.stderr || probe.stdout || '').trim().slice(0, 200)}`);
348
+ return null;
349
+ }
350
+ say.ok(`alpine python ready: ${(probe.stdout || '').trim()}`);
351
+ return { cmd: wrapper, prefix: [], name: 'alpine', portable: true };
352
+ }
353
+
238
354
  // ---------------- setup steps ----------------
239
355
 
240
356
  async function stepPython(opts) {
@@ -251,6 +367,9 @@ async function stepPython(opts) {
251
367
  if (port) return { ok: true, how: 'portable', at: port.cmd };
252
368
  const sys = findSystemPython();
253
369
  if (sys) return { ok: true, how: 'found', at: sys.cmd };
370
+ if (ytdlp.isMusl()) {
371
+ return { ok: false, msg: 'musl: alpine python bootstrap failed — a glibc image (ghcr.io/parkervcp/yolks:nodejs_22) fixes this' };
372
+ }
254
373
  const plat = platInfo();
255
374
  say.warn('portable python unavailable — trying the package manager');
256
375
  if (plat.name === 'android') {
@@ -341,8 +460,17 @@ async function stepYtDlp(prevMinor) {
341
460
  // phase 1 — core (must succeed)
342
461
  let pr = pipRun([`yt-dlp==${want}`, 'yt-dlp-ejs']);
343
462
  if (pr.status !== 0) {
344
- say.warn('pip missing or failed — running ensurepip, then retrying');
463
+ say.warn('pip missing or failed — bootstrapping pip, then retrying');
345
464
  runTool(py.cmd, [...py.prefix, '-m', 'ensurepip', '--upgrade'], { encoding: 'utf8', windowsHide: true });
465
+ if (runTool(py.cmd, [...py.prefix, '-m', 'pip', '--version'], { encoding: 'utf8', windowsHide: true }).status !== 0) {
466
+ // Alpine pythons ship without pip/ensurepip — get-pip.py is pure python
467
+ try {
468
+ const gp = path.join(CACHE, 'get-pip.py');
469
+ if (!fs.existsSync(gp)) await downloadFile('https://bootstrap.pypa.io/get-pip.py', gp, 'get-pip');
470
+ runTool(py.cmd, [...py.prefix, gp, '--disable-pip-version-check', '--no-warn-script-location'],
471
+ { encoding: 'utf8', windowsHide: true });
472
+ } catch (e) { say.warn(`get-pip failed: ${e.message}`); }
473
+ }
346
474
  pr = pipRun([`yt-dlp==${want}`, 'yt-dlp-ejs']);
347
475
  if (pr.status !== 0) {
348
476
  const tail = pr.out.trim().split(/\r?\n/).slice(-6).join('\n');
@@ -815,7 +943,7 @@ async function cli() {
815
943
  process.exit(failed ? 1 : 0);
816
944
  }
817
945
 
818
- module.exports = { setup, download, links, formats, doctor, platInfo, TYPES, ROOT };
946
+ module.exports = { setup, download, links, formats, doctor, platInfo, TYPES, ROOT, parseApkIndex, apkClosure };
819
947
 
820
948
  if (require.main === module) {
821
949
  cli().catch(e => { say.err(e.stack || e.message); process.exit(1); });