From 86e9704f620b9aed176cade6e4c579a63be1ec5a Mon Sep 17 00:00:00 2001 From: Alessandro Piana Date: Tue, 1 Sep 2026 08:35:37 +0200 Subject: [PATCH 1/2] Modernize --- README.md | 79 +++- bin/subcinode.js | 22 + package-lock.json | 178 ++++++++ package.json | 41 +- src/args.js | 117 +++++ src/config.js | 119 +++++ src/download.js | 97 +++++ src/files.js | 133 ++++++ src/hash.js | 64 +++ src/logger.js | 54 +++ src/promo.js | 53 +++ src/providers/index.js | 33 ++ src/providers/opensubtitles.js | 174 ++++++++ src/run.js | 156 +++++++ subcino.js | 123 ------ subcinoUtils.js | 627 --------------------------- test/args.test.js | 45 ++ test/config.test.js | 96 ++++ test/download.test.js | 81 ++++ test/files.test.js | 73 ++++ test/hash.test.js | 62 +++ test/promo.test.js | 78 ++++ test/providers/opensubtitles.test.js | 125 ++++++ test/run.test.js | 101 +++++ 24 files changed, 1953 insertions(+), 778 deletions(-) create mode 100644 bin/subcinode.js create mode 100644 package-lock.json create mode 100644 src/args.js create mode 100644 src/config.js create mode 100644 src/download.js create mode 100644 src/files.js create mode 100644 src/hash.js create mode 100644 src/logger.js create mode 100644 src/promo.js create mode 100644 src/providers/index.js create mode 100644 src/providers/opensubtitles.js create mode 100644 src/run.js delete mode 100644 subcino.js delete mode 100644 subcinoUtils.js create mode 100644 test/args.test.js create mode 100644 test/config.test.js create mode 100644 test/download.test.js create mode 100644 test/files.test.js create mode 100644 test/hash.test.js create mode 100644 test/promo.test.js create mode 100644 test/providers/opensubtitles.test.js create mode 100644 test/run.test.js diff --git a/README.md b/README.md index fff82c1..c4ddf5c 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,24 @@ Your subs, now from your console. -**Subcino(de)** is a npm package to automatically download the correct subtitles for your video files. It is the node version of [Subcino](http://www.subcino.com). +**Subcino(de)** is an npm package to automatically download the correct subtitles for your video +files. It is the node version of [Subcino](http://www.subcino.com), and a thin, legit wrapper around +the [OpenSubtitles](https://www.opensubtitles.com) REST API. -It is completely free, and does not require registration. Basically, it is a legit wrapper for [Open Subtitles](http://www.opensubtitles.org) APIs. +## Requirements -## Installation +* **Node.js >= 20** +* A free **OpenSubtitles API key** — register a consumer at + and export it: + + ```shell + export OPENSUBTITLES_API_KEY=your_key + # optional, needed for the /download quota of a free account: + export OPENSUBTITLES_USERNAME=your_user + export OPENSUBTITLES_PASSWORD=your_pass + ``` -Install with NPM: +## Installation ```shell npm install subcinode --global @@ -17,19 +28,27 @@ npm install subcinode --global ## Documentation ```shell -subcinode -useSubs -langs= -recursive= -extensions= -path= -save -debug +subcinode [--langs ] [--extensions ] [--path ] \ + [--recursive | --no-recursive] [--use-subs] \ + [--provider ] [--save] [--settings] [--debug] ``` -| Options | Type | Default | Description | +| Option | Type | Default | Description | |---|---|---|---| -| langs | String | 'all' | Comma-separated value to specify the langs to download. Default is 'all'. See below for valid values for languages. -| recursive | Boolean | true | If true, navigates through all folders under the current one. | -| useSubs | Boolean | false | If true, subtitles are saved under a 'subs/' folder. Otherwise, they are same in the same folder as the video file. | -| extensions | String | 'mp4,mkv,avi' | Comma-separated value of the extensions to search for. | -| path | String | Current shell directory | If specified, looks for video files under that path. | -| save | String | | If `-save` is specified, the current settings will be saved as default. | -| debug | String | | If `-debug` is specified, more information is given in the console. | -| settings | String | | If `-settings` is specified, subcino shows the current settings ( and terminates ). | +| `--langs` | String | `all` | Comma-separated language codes to download. 2- or 3-letter codes are both accepted (`en` / `eng`). See the table below. | +| `--extensions` | String | `mp4,mkv,avi` | Comma-separated list of video extensions to look for. | +| `--path` | String | current directory | Directory to scan for video files. | +| `--recursive` / `--no-recursive` | Boolean | `true` | Whether to descend into sub-folders (the output `subs/` folder is always skipped). | +| `--use-subs` | Boolean | `false` | Save subtitles under a `subs/` folder instead of next to the video file. | +| `--provider` | String | `opensubtitles` | Subtitle provider to use. | +| `--save` | Flag | – | Persist the supplied options to `settings.json` as the new defaults. | +| `--settings` | Flag | – | Print the effective settings and exit. | +| `--debug` | Flag | – | Verbose logging. | + +> **Legacy flags** — the old single-dash style (`-langs=eng,ita`, `-recursive=false`, `-useSubs`, +> `-path=…`, `-save`, `-debug`, `-settings`) is still accepted and mapped to the options above. + +A subtitle is skipped when its target file (`Movie..srt`) already exists. ### Valid languages @@ -113,13 +132,13 @@ subcinode ### Search all English and Italian subtitles for any MP4 or AVI video file in the User Downloads folder, not recursively. ```shell -subcinode -langs=eng,ita -recursive=false -extensions=mp4,avi -path="/Users/my.user/Downloads" +subcinode --langs eng,ita --no-recursive --extensions mp4,avi --path "/Users/my.user/Downloads" ``` ### Search with specific settings and save them as default ```shell -subcinode -save -langs=eng,ita -recursive=false -extensions=mp4 +subcinode --save --langs eng,ita --no-recursive --extensions mp4 ``` So, from that moment on, it is possible to write @@ -128,15 +147,39 @@ So, from that moment on, it is possible to write subcinode ``` to perform the search with the default saved settings. - + ### Show the current settings ( and terminate the program ) ```shell -subcinode -settings +subcinode --settings ``` +## Development + +```shell +npm install +npm test # runs the node:test suite +``` + +The code is plain ESM under `src/` with a thin CLI in `bin/subcinode.js`. Subtitle back-ends live in +`src/providers/` and implement `init()`, `search(fileInfo, opts)` and `resolveDownloadUrl(result)`; +register a new one with `registerProvider(name, ProviderClass)`. + ## Changelog +### Version 2.0.0 +* Rewritten as ESM with `async`/`await` and split into small modules; requires Node >= 20. +* Switched to the OpenSubtitles **REST API** (the old XML-RPC endpoint was retired). An API key is + now required. +* Pluggable provider layer (`src/providers/`). +* Modern `--flag value` CLI via `commander`; the legacy single-dash flags still work. +* Dropped the `async`, `http`, `jsonfile` and `subtitles-parser` dependencies; movie hashing is now + built in; SRT handling moved to the maintained `subtitle` package. +* Fixed: crash on the default `langs: all` path, an always-true language filter, a broken + `-extensions=` parser, `http` downloads of HTTPS links, a read-before-write race on the promo + caption, and mutation of the shared default settings. +* Added a `node:test` test suite. + ### Version 1.1.3 * Fixed dependency problems diff --git a/bin/subcinode.js b/bin/subcinode.js new file mode 100644 index 0000000..9c95b01 --- /dev/null +++ b/bin/subcinode.js @@ -0,0 +1,22 @@ +#!/usr/bin/env node +import { readFileSync } from 'node:fs'; +import { run } from '../src/run.js'; +import { parseArgs } from '../src/args.js'; +import { log, logLevels } from '../src/logger.js'; + +const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); + +const parsed = parseArgs(process.argv.slice(2), { version: pkg.version }); + +run(parsed, { version: pkg.version }).then( + (result) => { + process.exitCode = result.errors.length ? 1 : 0; + }, + (err) => { + log(`[FATAL: ${err.message}]`, logLevels.FATAL); + if (process.env.SUBCINODE_DEBUG) { + console.error(err); + } + process.exitCode = 1; + } +); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b92b610 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,178 @@ +{ + "name": "subcinode", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "subcinode", + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "commander": "^12.1.0", + "langs": "^2.0.0", + "subtitle": "^4.2.1" + }, + "bin": { + "subcinode": "bin/subcinode.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/multipipe": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/multipipe/-/multipipe-3.0.5.tgz", + "integrity": "sha512-mHBbV67bsmUtLtio0gj/GPzGsjv+Y6K1ff/48iR6YAfFfLkBtRIR0M5lZPbkMCyHGrCZM9p3VNnfY1QCws4t4w==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", + "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "engines": { + "node": ">=18" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "node_modules/langs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/langs/-/langs-2.0.0.tgz", + "integrity": "sha512-v4pxOBEQVN1WBTfB1crhTtxzNLZU9HPWgadlwzWKISJtt6Ku/CnpBrwVy+jFv8StjxsPfwPFzO0CMwdZLJ0/BA==" + }, + "node_modules/multipipe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/multipipe/-/multipipe-4.0.0.tgz", + "integrity": "sha512-jzcEAzFXoWwWwUbvHCNPwBlTz3WCWe/jPcXSmTfbo/VjRwRTfvLZ/bdvtiTdqCe8d4otCSsPCbhGYcX+eggpKQ==", + "dependencies": { + "duplexer2": "^0.1.2", + "object-assign": "^4.1.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/split2": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", + "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==", + "dependencies": { + "readable-stream": "^3.0.0" + } + }, + "node_modules/split2/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "engines": { + "node": ">=8" + } + }, + "node_modules/subtitle": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/subtitle/-/subtitle-4.2.2.tgz", + "integrity": "sha512-vCyKEcCFNXJJ3TE7iP6uqyTG/9xlhhWvJ/LGzmt2YGkEG+PUeIaU0443TMSRbs8yQean2gqGMW8O5CtgJKIlWg==", + "dependencies": { + "@types/multipipe": "^3.0.0", + "multipipe": "^4.0.0", + "split2": "^3.2.2", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + } + } +} diff --git a/package.json b/package.json index 6851986..1691dcd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { "name": "subcinode", - "version": "1.1.3", + "version": "2.0.0", "description": "Your subs, now.", + "type": "module", "keywords": [ "subtitles", "subcino", @@ -14,16 +15,36 @@ "email": "alessandro.89@gmail.com", "url": "https://www.alessandropiana.com" }, - "dependencies": { - "async": "^1.5.0", - "http": "0.0.0", - "jsonfile": "^2.2.3", - "langs": "^1.0.2", - "opensubtitles-api": "^2.3.0", - "subtitles-parser": "0.0.2" + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/alexis89x/subcinode.git" }, - "preferGlobal": true, + "bugs": { + "url": "https://github.com/alexis89x/subcinode/issues" + }, + "homepage": "https://github.com/alexis89x/subcinode#readme", + "engines": { + "node": ">=20" + }, + "main": "src/run.js", "bin": { - "subcinode": "subcino.js" + "subcinode": "bin/subcinode.js" + }, + "files": [ + "bin", + "src", + "README.md", + "LICENSE.txt" + ], + "scripts": { + "test": "node --test", + "start": "node bin/subcinode.js" + }, + "preferGlobal": true, + "dependencies": { + "commander": "^12.1.0", + "langs": "^2.0.0", + "subtitle": "^4.2.1" } } diff --git a/src/args.js b/src/args.js new file mode 100644 index 0000000..94e1e40 --- /dev/null +++ b/src/args.js @@ -0,0 +1,117 @@ +import { Command } from 'commander'; + +function splitList(value) { + return String(value) + .split(',') + .map((part) => part.trim()) + .filter(Boolean); +} + +/** + * Rewrites the legacy single-dash `-flag=value` token style into the standard + * `--flag value` form that {@link Command} understands, so documented invocations + * from older versions keep working. + * + * @param {string[]} argv user arguments (no `node`/script entries) + * @returns {string[]} + */ +export function normalizeLegacyArgv(argv) { + const out = []; + for (const token of argv) { + if (token.startsWith('--')) { + out.push(token); + continue; + } + const match = /^-([a-zA-Z]+)(?:=(.*))?$/.exec(token); + if (!match) { + out.push(token); + continue; + } + + const name = match[1].toLowerCase(); + const value = match[2]; + + switch (name) { + case 'langs': + case 'extensions': + case 'path': + case 'provider': + out.push(`--${name}`); + if (value !== undefined) { + out.push(value); + } + break; + case 'usesubs': + out.push(value === 'false' ? '--no-use-subs' : '--use-subs'); + break; + case 'recursive': + out.push(value === 'false' ? '--no-recursive' : '--recursive'); + break; + case 'save': + out.push('--save'); + break; + case 'debug': + out.push('--debug'); + break; + case 'settings': + out.push('--settings'); + break; + default: + out.push(token); + } + } + return out; +} + +/** + * Parses CLI arguments (legacy or modern form) into: + * `{ cli, save, onlySettings }` + * where `cli` contains only the options the user actually passed, ready to be merged + * onto the stored settings. + * + * @param {string[]} argv user arguments (typically `process.argv.slice(2)`) + * @param {{ version?: string, exit?: boolean }} [options] + */ +export function parseArgs(argv, options = {}) { + const program = new Command(); + program + .name('subcinode') + .description('Download the correct subtitles for your local video files.') + .version(options.version || '0.0.0') + .option('--provider ', 'subtitle provider to use') + .option('--langs ', 'comma-separated language codes, or "all"', splitList) + .option('--extensions ', 'comma-separated video file extensions', splitList) + .option('--path ', 'directory to scan (default: current directory)') + .option('--recursive', 'descend into sub-folders') + .option('--no-recursive', 'do not descend into sub-folders') + .option('--use-subs', 'save subtitles under a subs/ folder') + .option('--no-use-subs', 'save subtitles next to the video file') + .option('--save', 'persist the supplied options as the new defaults') + .option('--settings', 'print the effective settings and exit') + .option('--debug', 'verbose logging') + .allowExcessArguments(true) + .allowUnknownOption(true); + + if (options.exit === false) { + program.exitOverride(); + program.configureOutput({ writeErr: () => {}, writeOut: () => {} }); + } + + program.parse(normalizeLegacyArgv(argv), { from: 'user' }); + + const opts = program.opts(); + const fromCli = (name) => program.getOptionValueSource(name) === 'cli'; + + const cli = {}; + for (const key of ['provider', 'langs', 'extensions', 'path', 'recursive', 'useSubs', 'debug']) { + if (fromCli(key)) { + cli[key] = opts[key]; + } + } + + return { + cli, + save: Boolean(opts.save), + onlySettings: Boolean(opts.settings) + }; +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..48339ac --- /dev/null +++ b/src/config.js @@ -0,0 +1,119 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +export const SETTINGS_FILE = 'settings.json'; + +/** Persisted, user-facing defaults. */ +export const DEFAULT_SETTINGS = Object.freeze({ + provider: 'opensubtitles', + recursive: true, + extensions: ['mp4', 'mkv', 'avi'], + langs: ['all'], + path: 'CWD', // sentinel: resolved to the current working directory at runtime + useSubs: false, + debug: false +}); + +/** Keys that are computed per-run and must never be written back to settings.json. */ +const TRANSIENT_KEYS = ['save', 'onlySettings', 'credentials']; + +function deepClone(value) { + return structuredClone(value); +} + +function isPlainObject(value) { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Deep-merges `source` onto a clone of `base` (arrays replace, objects merge). + */ +export function mergeSettings(base, source) { + const out = deepClone(base); + if (!isPlainObject(source)) { + return out; + } + for (const [key, value] of Object.entries(source)) { + if (value === undefined) { + continue; + } + out[key] = isPlainObject(value) && isPlainObject(out[key]) ? mergeSettings(out[key], value) : deepClone(value); + } + return out; +} + +/** + * Reads OpenSubtitles credentials from the environment. These are never written to disk. + * @param {NodeJS.ProcessEnv} [env=process.env] + */ +export function readCredentials(env = process.env) { + return { + apiKey: env.OPENSUBTITLES_API_KEY || '', + username: env.OPENSUBTITLES_USERNAME || '', + password: env.OPENSUBTITLES_PASSWORD || '' + }; +} + +/** + * Loads settings.json (if present) and merges it onto {@link DEFAULT_SETTINGS}. + * Missing file is not an error. Does not resolve the `path` sentinel. + * + * @param {{ file?: string, cwd?: string, readFile?: typeof readFile }} [options] + * @returns {Promise} + */ +export async function loadSettings(options = {}) { + const cwd = options.cwd || process.cwd(); + const file = options.file || resolve(cwd, SETTINGS_FILE); + const read = options.readFile || readFile; + + let stored = {}; + try { + stored = JSON.parse(await read(file, 'utf8')); + } catch (err) { + if (err.code !== 'ENOENT') { + // Malformed file: warn but fall back to defaults rather than crashing. + console.warn(`[Ignoring unreadable ${SETTINGS_FILE}: ${err.message}]`); + } + } + + const merged = mergeSettings(DEFAULT_SETTINGS, stored); + for (const key of TRANSIENT_KEYS) { + delete merged[key]; + } + return merged; +} + +/** + * Resolves the `path` sentinel ("CWD") to an absolute path and strips a trailing slash. + */ +export function resolvePath(settings, cwd = process.cwd()) { + const raw = !settings.path || settings.path === 'CWD' ? cwd : settings.path; + return raw.length > 1 && raw.endsWith('/') ? raw.slice(0, -1) : raw; +} + +/** + * Writes the persistable subset of `settings` to settings.json. + * Transient keys and a resolved (non-sentinel) cwd path are not persisted verbatim — + * if `path` equals `cwd` it is stored as the "CWD" sentinel. + * + * @param {object} settings + * @param {{ file?: string, cwd?: string, writeFile?: typeof writeFile }} [options] + */ +export async function saveSettings(settings, options = {}) { + const cwd = options.cwd || process.cwd(); + const file = options.file || resolve(cwd, SETTINGS_FILE); + const write = options.writeFile || writeFile; + + const toStore = deepClone(settings); + for (const key of TRANSIENT_KEYS) { + delete toStore[key]; + } + if (!toStore.path || toStore.path === cwd) { + toStore.path = 'CWD'; + } else if (toStore.path.length > 1 && toStore.path.endsWith('/')) { + toStore.path = toStore.path.slice(0, -1); + } + + await write(file, JSON.stringify(toStore, null, 2)); + return toStore; +} diff --git a/src/download.js b/src/download.js new file mode 100644 index 0000000..1b7dadc --- /dev/null +++ b/src/download.js @@ -0,0 +1,97 @@ +import { createWriteStream } from 'node:fs'; +import { mkdir, unlink } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { get as httpsGet } from 'node:https'; +import { get as httpGet } from 'node:http'; +import { createGunzip } from 'node:zlib'; +import { pipeline } from 'node:stream/promises'; + +const MAX_REDIRECTS = 5; + +/** + * Resolves with the 200 `IncomingMessage` for `url`, following up to `redirectsLeft` + * redirects. Rejects on transport errors, unsupported protocols and non-200 responses. + */ +function fetchStream(url, requesters, redirectsLeft) { + return new Promise((resolveStream, reject) => { + let parsed; + try { + parsed = new URL(url); + } catch { + reject(new Error(`Invalid download URL: ${url}`)); + return; + } + + const getFn = requesters[parsed.protocol]; + if (!getFn) { + reject(new Error(`Unsupported protocol: ${parsed.protocol}`)); + return; + } + + const req = getFn(url, (res) => { + const { statusCode, headers } = res; + + if (statusCode >= 300 && statusCode < 400 && headers.location) { + res.resume(); // drain + if (redirectsLeft <= 0) { + reject(new Error(`Too many redirects for ${url}`)); + return; + } + const next = new URL(headers.location, url).toString(); + resolveStream(fetchStream(next, requesters, redirectsLeft - 1)); + return; + } + + if (statusCode !== 200) { + res.resume(); + reject(new Error(`Download failed: HTTP ${statusCode} for ${url}`)); + return; + } + + resolveStream(res); + }); + + req.on('error', reject); + }); +} + +/** + * Downloads `url` into `destPath`, creating parent directories, inflating gzip, and + * resolving only once the file has been fully written and flushed. A partial file is + * removed on failure. + * + * @param {string} url + * @param {string} destPath + * @param {{ httpGet?: Function, httpsGet?: Function, mkdir?: Function }} [deps] + * @returns {Promise} the written path + */ +export async function downloadFile(url, destPath, deps = {}) { + const requesters = { + 'https:': deps.httpsGet || httpsGet, + 'http:': deps.httpGet || httpGet + }; + const makeDir = deps.mkdir || mkdir; + + await makeDir(dirname(destPath), { recursive: true }); + + const response = await fetchStream(url, requesters, MAX_REDIRECTS); + + const encoding = String(response.headers['content-encoding'] || '').toLowerCase(); + const gzipped = encoding === 'gzip' || url.split('?')[0].toLowerCase().endsWith('.gz'); + + // pipeline() propagates errors from every stage and cleans up the rest. + const stages = gzipped + ? [response, createGunzip(), createWriteStream(destPath)] + : [response, createWriteStream(destPath)]; + + try { + await pipeline(...stages); + } catch (err) { + await unlink(destPath).catch(() => {}); + throw err; + } + + return destPath; +} + +export default downloadFile; diff --git a/src/files.js b/src/files.js new file mode 100644 index 0000000..6d812c9 --- /dev/null +++ b/src/files.js @@ -0,0 +1,133 @@ +import { readdirSync, statSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import langs from 'langs'; + +/** + * Normalizes a language code to its ISO 639-1 (2-letter) form when possible. + * Accepts 2- or 3-letter codes; unknown codes are returned lower-cased unchanged. + * `all` is passed through so callers can treat it as "every language". + * + * @param {string} code + * @returns {string} + */ +export function normalizeLang(code) { + if (!code) { + return code; + } + const lower = String(code).toLowerCase(); + if (lower === 'all') { + return 'all'; + } + for (const key of ['1', '2', '2T', '2B', '3']) { + const match = langs.where(key, lower); + if (match && match['1']) { + return match['1']; + } + } + return lower; +} + +/** + * Builds the subtitle filename for a video file and a language: + * `Movie.2015.mkv` + `en` -> `Movie.2015.en.srt`. + * + * @param {string} name video file name (or path) + * @param {string} lang language code as it should appear in the filename + * @returns {string} + */ +export function saveAs(name, lang) { + const dot = name.lastIndexOf('.'); + const stem = dot === -1 ? name : name.slice(0, dot); + return `${stem}.${lang}.srt`; +} + +/** + * Returns true if at least one requested language is still missing a subtitle file + * next to the video (so it is worth searching). If every requested language already + * has a `.lang.srt`, returns false. `all` / unresolvable codes always return true. + * + * @param {string} dir directory holding the video file + * @param {string} file video file name + * @param {string[]} requestedLangs language codes from the CLI/config + * @returns {boolean} + */ +export function shouldDownload(dir, file, requestedLangs) { + const list = Array.isArray(requestedLangs) && requestedLangs.length ? requestedLangs : ['all']; + for (const raw of list) { + const lang = normalizeLang(raw); + if (lang === 'all') { + return true; // can't know every language is covered — always search + } + if (!existsSync(join(dir, saveAs(file, lang)))) { + return true; + } + } + return false; +} + +function hasWantedExtension(file, extensions) { + if (!extensions || !extensions.length) { + return true; + } + const dot = file.lastIndexOf('.'); + if (dot === -1) { + return false; + } + return extensions.includes(file.slice(dot + 1).toLowerCase()); +} + +/** + * Lists video files under `dir`. Directories named `subs` are skipped (that is where + * this tool writes its output). Unreadable entries are ignored. + * + * @param {string} dir + * @param {object} [options] + * @param {string[]} [options.extensions] extensions to keep (without the dot) + * @param {string[]} [options.langs] requested languages, for the "already downloaded" skip + * @param {boolean} [options.recursive=true] descend into sub-directories + * @returns {Array<{ fileName: string, path: string, fullName: string }>} + */ +export function walk(dir, options = {}) { + const { extensions, langs: wantedLangs, recursive = true } = options; + const out = []; + + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return out; + } + + for (const entry of entries) { + const full = join(dir, entry.name); + let isDir = entry.isDirectory(); + let isFile = entry.isFile(); + + if (entry.isSymbolicLink()) { + try { + const stats = statSync(full); + isDir = stats.isDirectory(); + isFile = stats.isFile(); + } catch { + continue; + } + } + + if (isDir) { + if (recursive && entry.name !== 'subs') { + out.push(...walk(full, options)); + } + continue; + } + + if (!isFile) { + continue; + } + + if (hasWantedExtension(entry.name, extensions) && shouldDownload(dir, entry.name, wantedLangs)) { + out.push({ fileName: entry.name, path: dir, fullName: full }); + } + } + + return out; +} diff --git a/src/hash.js b/src/hash.js new file mode 100644 index 0000000..1df9781 --- /dev/null +++ b/src/hash.js @@ -0,0 +1,64 @@ +import { open, stat } from 'node:fs/promises'; + +const CHUNK_SIZE = 64 * 1024; // 64 KiB +const U64_MASK = (1n << 64n) - 1n; + +/** + * Sums every little-endian unsigned 64-bit word in `buffer`, wrapping at 2^64. + * @param {Buffer} buffer length must be a multiple of 8 + * @returns {bigint} + */ +function sumWords(buffer) { + let sum = 0n; + for (let offset = 0; offset + 8 <= buffer.length; offset += 8) { + sum = (sum + buffer.readBigUInt64LE(offset)) & U64_MASK; + } + return sum; +} + +/** + * Computes the OpenSubtitles / OSDb movie hash: `filesize + checksum(first 64 KiB) + + * checksum(last 64 KiB)`, where a checksum is the wrapping sum of the chunk's 64-bit + * little-endian words. Returned as a 16-char zero-padded lowercase hex string. + * + * Files smaller than one chunk are read whole (the two chunks overlap entirely). + * + * @param {string} filePath + * @param {{ open?: typeof open, stat?: typeof stat }} [deps] injectable fs for tests + * @returns {Promise<{ moviehash: string, moviebytesize: number }>} + */ +export async function computeHash(filePath, deps = {}) { + const fsOpen = deps.open || open; + const fsStat = deps.stat || stat; + + const { size } = await fsStat(filePath); + if (size === 0) { + throw new Error(`Cannot hash empty file: ${filePath}`); + } + + const handle = await fsOpen(filePath, 'r'); + try { + const readLen = Math.min(CHUNK_SIZE, size); + // Round down to a multiple of 8 so trailing bytes (which the algorithm ignores) are dropped. + const wordLen = readLen - (readLen % 8); + + const head = Buffer.alloc(readLen); + await handle.read(head, 0, readLen, 0); + + const tail = Buffer.alloc(readLen); + await handle.read(tail, 0, readLen, Math.max(0, size - readLen)); + + let hash = + (BigInt(size) + sumWords(head.subarray(0, wordLen)) + sumWords(tail.subarray(0, wordLen))) & + U64_MASK; + + return { + moviehash: hash.toString(16).padStart(16, '0'), + moviebytesize: size + }; + } finally { + await handle.close(); + } +} + +export default computeHash; diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..e127f10 --- /dev/null +++ b/src/logger.js @@ -0,0 +1,54 @@ +/** + * Tiny leveled logger. Higher `currentLevel` prints more. + * The numeric scale is kept from the original tool for backwards compatibility. + */ +export const logLevels = { + DEBUG: 60, + INFO: 50, + WARNING: 40, + ERROR: 30, + FATAL: 20, + ALL: 10 +}; + +let currentLevel = logLevels.INFO; + +/** + * @param {number} level one of {@link logLevels} + */ +export function setLevel(level) { + currentLevel = level; +} + +/** + * Sets the level from parsed CLI options: `--debug` raises it to DEBUG, otherwise INFO. + * @param {{ debug?: boolean }} options + */ +export function setLevelFromOptions(options) { + setLevel(options && options.debug ? logLevels.DEBUG : logLevels.INFO); +} + +export function getLevel() { + return currentLevel; +} + +/** + * Logs `message` when the current level is verbose enough for `level`. + * @param {*} message + * @param {number} [level=logLevels.DEBUG] + */ +export function log(message, level = logLevels.DEBUG) { + if (currentLevel >= level) { + console.log(message); + } +} + +export const logger = { + logLevels, + setLevel, + setLevelFromOptions, + getLevel, + log +}; + +export default logger; diff --git a/src/promo.js b/src/promo.js new file mode 100644 index 0000000..47bf31e --- /dev/null +++ b/src/promo.js @@ -0,0 +1,53 @@ +import { parseSync, stringifySync } from 'subtitle'; + +export const PROMO_TEXT = 'Downloaded with Subcino [www.subcino.com]'; + +const MIN_GAP_MS = 3000; // only fill gaps longer than this +const PROMO_PADDING_MS = 500; // keep clear of the surrounding cues +const MAX_PROMO_MS = 5000; // a promo caption lasts at most this long + +/** + * Inserts a short promo caption into wide silent gaps of an SRT document, starting a + * quarter of the way in. Faithful port of the original tool's behaviour, on top of the + * maintained `subtitle` parser (cue numbering is regenerated on serialization). + * + * @param {string} srtContent raw SRT text + * @param {{ text?: string }} [options] + * @returns {string} the re-serialized SRT (always returned, even if nothing was inserted) + */ +export function insertPromoSub(srtContent, options = {}) { + const text = options.text || PROMO_TEXT; + const nodes = parseSync(srtContent); + + const headers = nodes.filter((node) => node.type !== 'cue'); + const cues = nodes.filter((node) => node.type === 'cue'); + + if (cues.length < 2) { + return stringifySync(nodes, { format: 'srt' }); + } + + const startPos = Math.floor(cues.length / 4); + const out = []; + + for (let i = 0; i < cues.length; i++) { + out.push(cues[i]); + + if (i < startPos || i === cues.length - 1) { + continue; + } + + const gap = cues[i + 1].data.start - cues[i].data.end; + if (gap > MIN_GAP_MS) { + const start = cues[i].data.end + PROMO_PADDING_MS; + let end = cues[i + 1].data.start - PROMO_PADDING_MS; + if (end - start > MAX_PROMO_MS) { + end = start + MAX_PROMO_MS; + } + out.push({ type: 'cue', data: { start, end, text } }); + } + } + + return stringifySync([...headers, ...out], { format: 'srt' }); +} + +export default insertPromoSub; diff --git a/src/providers/index.js b/src/providers/index.js new file mode 100644 index 0000000..bf556c1 --- /dev/null +++ b/src/providers/index.js @@ -0,0 +1,33 @@ +import { OpenSubtitlesProvider } from './opensubtitles.js'; + +/** + * Provider registry. A provider is a class implementing: + * `name` (getter), `async init()`, `async search(fileInfo, opts)`, + * `async resolveDownloadUrl(result)`. + */ +const registry = new Map([['opensubtitles', OpenSubtitlesProvider]]); + +export function registerProvider(name, ProviderClass) { + registry.set(name, ProviderClass); +} + +export function availableProviders() { + return [...registry.keys()]; +} + +/** + * @param {string} [name='opensubtitles'] + * @param {object} [config] + * @param {object} [deps] + */ +export function getProvider(name = 'opensubtitles', config = {}, deps = {}) { + const ProviderClass = registry.get(name); + if (!ProviderClass) { + throw new Error( + `Unknown subtitle provider "${name}". Available: ${availableProviders().join(', ')}.` + ); + } + return new ProviderClass(config, deps); +} + +export { OpenSubtitlesProvider }; diff --git a/src/providers/opensubtitles.js b/src/providers/opensubtitles.js new file mode 100644 index 0000000..9fb3d49 --- /dev/null +++ b/src/providers/opensubtitles.js @@ -0,0 +1,174 @@ +import { normalizeLang } from '../files.js'; + +const DEFAULT_BASE_URL = 'https://api.opensubtitles.com/api/v1'; +const CONSUMER_URL = 'https://www.opensubtitles.com/consumers'; + +/** + * Turns a language spec (array, CSV string, or nullish) into the comma-separated, + * de-duplicated, alphabetically sorted 2-letter list the REST API expects. Returns + * `null` when the caller wants every language (`all` / empty), so the param is omitted. + * + * @param {string[]|string|undefined} languages + * @returns {string|null} + */ +export function normalizeLanguages(languages) { + let list = languages; + if (typeof list === 'string') { + list = list.split(','); + } + if (!Array.isArray(list) || !list.length) { + return null; + } + const codes = new Set(); + for (const raw of list) { + const code = normalizeLang(String(raw).trim()); + if (!code || code === 'all') { + return null; + } + codes.add(code); + } + return [...codes].sort().join(','); +} + +/** + * opensubtitles.com REST API v1 implementation of the subtitle-provider interface. + * + * Interface: + * - `async init()` validate credentials, obtain a bearer token + * - `async search(fileInfo, opts)` -> [{ langId, fileId, fileName, downloadCount }] + * - `async resolveDownloadUrl(result)` -> { url, fileName } + */ +export class OpenSubtitlesProvider { + /** + * @param {{ apiKey?: string, username?: string, password?: string, userAgent?: string, baseUrl?: string }} config + * @param {{ fetch?: typeof fetch }} [deps] + */ + constructor(config = {}, deps = {}) { + this.apiKey = config.apiKey || ''; + this.username = config.username || ''; + this.password = config.password || ''; + this.userAgent = config.userAgent || 'subcinode v2.0.0'; + this.baseUrl = (config.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ''); + this.fetch = deps.fetch || globalThis.fetch; + this.token = null; + } + + get name() { + return 'opensubtitles'; + } + + #headers(extra = {}) { + const headers = { + 'Api-Key': this.apiKey, + 'User-Agent': this.userAgent, + Accept: 'application/json', + ...extra + }; + if (this.token) { + headers.Authorization = `Bearer ${this.token}`; + } + return headers; + } + + async #request(path, { method = 'GET', body, query } = {}) { + let url = `${this.baseUrl}${path}`; + if (query) { + const qs = new URLSearchParams(query).toString(); + if (qs) { + url += `?${qs}`; + } + } + + const init = { method, headers: this.#headers(body ? { 'Content-Type': 'application/json' } : {}) }; + if (body) { + init.body = JSON.stringify(body); + } + + const res = await this.fetch(url, init); + const text = await res.text(); + let json = {}; + try { + json = text ? JSON.parse(text) : {}; + } catch { + json = { raw: text }; + } + + if (!res.ok) { + const detail = + json.message || + (Array.isArray(json.errors) && json.errors.join(', ')) || + res.statusText || + `HTTP ${res.status}`; + const err = new Error(`OpenSubtitles ${method} ${path} failed: ${detail}`); + err.status = res.status; + throw err; + } + return json; + } + + async init() { + if (!this.apiKey) { + throw new Error( + `Missing OpenSubtitles API key. Set OPENSUBTITLES_API_KEY (free key: ${CONSUMER_URL}).` + ); + } + if (this.username && this.password) { + const data = await this.#request('/login', { + method: 'POST', + body: { username: this.username, password: this.password } + }); + this.token = data.token || null; + } + return this; + } + + /** + * @param {{ moviehash: string, moviebytesize?: number }} fileInfo + * @param {{ languages?: string[]|string }} [opts] + */ + async search(fileInfo, opts = {}) { + const query = { moviehash: fileInfo.moviehash }; + if (fileInfo.moviebytesize) { + query.moviebytesize = String(fileInfo.moviebytesize); + } + const languages = normalizeLanguages(opts.languages); + if (languages) { + query.languages = languages; + } + + const data = await this.#request('/subtitles', { query }); + const results = []; + for (const item of data.data || []) { + const attr = item.attributes || {}; + const file = (attr.files || [])[0]; + if (!file || file.file_id == null) { + continue; + } + results.push({ + langId: String(attr.language || '').toLowerCase(), + fileId: file.file_id, + fileName: file.file_name || attr.release || `${item.id}.srt`, + downloadCount: attr.download_count || 0 + }); + } + results.sort((a, b) => b.downloadCount - a.downloadCount); + return results; + } + + /** + * @param {{ fileId: (number|string), fileName?: string }} result + * @returns {Promise<{ url: string, fileName: string }>} + */ + async resolveDownloadUrl(result) { + const data = await this.#request('/download', { + method: 'POST', + body: { file_id: result.fileId } + }); + if (!data.link) { + throw new Error(`OpenSubtitles returned no download link for file ${result.fileId}`); + } + return { url: data.link, fileName: data.file_name || result.fileName }; + } +} + +export default OpenSubtitlesProvider; diff --git a/src/run.js b/src/run.js new file mode 100644 index 0000000..9410995 --- /dev/null +++ b/src/run.js @@ -0,0 +1,156 @@ +import { existsSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { loadSettings, saveSettings, resolvePath, mergeSettings, readCredentials } from './config.js'; +import { setLevelFromOptions, log, logLevels } from './logger.js'; +import { walk, saveAs } from './files.js'; +import { computeHash } from './hash.js'; +import { getProvider } from './providers/index.js'; +import { downloadFile } from './download.js'; +import { insertPromoSub } from './promo.js'; + +const DOWNLOAD_DELAY_MS = 400; + +function delay(ms, deps) { + if (deps && deps.noDelay) { + return Promise.resolve(); + } + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function applyPromo(targetPath, deps) { + const read = deps.readFile || readFile; + const write = deps.writeFile || writeFile; + try { + const srt = await read(targetPath, 'utf8'); + const rewritten = insertPromoSub(srt); + if (rewritten) { + await write(targetPath, rewritten); + } + } catch (err) { + log(`[Could not add promo caption to ${targetPath}: ${err.message}]`, logLevels.ERROR); + } +} + +/** + * Runs the full pipeline: load settings -> scan for videos -> hash -> search the + * provider -> download -> insert the promo caption. + * + * @param {{ cli?: object, save?: boolean, onlySettings?: boolean }} parsed from {@link parseArgs} + * @param {object} [deps] injectable seams for tests (cwd, env, version, providerDeps, + * downloadDeps, readFile, writeFile, noDelay) + * @returns {Promise<{ settings: object, downloaded: string[], errors: Array<{file:string,error:string}> }>} + */ +export async function run(parsed = {}, deps = {}) { + const cwd = deps.cwd || process.cwd(); + const env = deps.env || process.env; + const version = deps.version || '0.0.0'; + + const settings = mergeSettings(await loadSettings({ cwd }), parsed.cli || {}); + setLevelFromOptions(settings); + + if (parsed.save) { + log('[Saving settings]', logLevels.ALL); + try { + await saveSettings(settings, { cwd }); + log('[Settings saved]', logLevels.ALL); + } catch (err) { + log(`[Error while saving settings: ${err.message}]`, logLevels.ERROR); + } + } + + settings.path = resolvePath(settings, cwd); + + if (parsed.onlySettings) { + console.log(settings); + return { settings, downloaded: [], errors: [] }; + } + + log('[Navigating path...]', logLevels.ALL); + const files = walk(settings.path, { + extensions: settings.extensions, + langs: settings.langs, + recursive: settings.recursive + }); + log(`[Found ${files.length} ${files.length === 1 ? 'file' : 'files'}]`, logLevels.ALL); + for (const file of files) { + log(`Found: ${file.fullName}`, logLevels.DEBUG); + } + + const downloaded = []; + const errors = []; + if (!files.length) { + return { settings, downloaded, errors }; + } + + const credentials = readCredentials(env); + const provider = getProvider( + settings.provider, + { ...credentials, userAgent: `subcinode v${version}` }, + deps.providerDeps || {} + ); + + log('[Connecting to subtitle provider...]', logLevels.ALL); + await provider.init(); + + for (const file of files) { + try { + log(`[Hashing ${file.fileName}]`, logLevels.DEBUG); + const info = await computeHash(file.fullName); + + log(`[Searching subtitles for ${file.fileName}]`, logLevels.ALL); + const matches = await provider.search(info, { languages: settings.langs }); + + // Keep the best (already sorted by download count) match per language. + const perLang = new Map(); + for (const match of matches) { + if (!perLang.has(match.langId)) { + perLang.set(match.langId, match); + } + } + if (!perLang.size) { + log(`[No subtitles found for ${file.fileName}]`, logLevels.ALL); + continue; + } + + const targetDir = settings.useSubs ? join(file.path, 'subs') : file.path; + + for (const match of perLang.values()) { + const targetName = saveAs(file.fileName, match.langId); + const targetPath = join(targetDir, targetName); + + if (existsSync(targetPath)) { + log(`[Skipping ${targetName}, already present]`, logLevels.ALL); + continue; + } + + try { + const { url } = await provider.resolveDownloadUrl(match); + await downloadFile(url, targetPath, deps.downloadDeps || {}); + await applyPromo(targetPath, deps); + downloaded.push(targetPath); + log(`[Downloaded ${targetName}]`, logLevels.ALL); + } catch (err) { + errors.push({ file: targetPath, error: err.message }); + log(`[Error downloading ${targetName}: ${err.message}]`, logLevels.ERROR); + } + + await delay(DOWNLOAD_DELAY_MS, deps); + } + } catch (err) { + errors.push({ file: file.fullName, error: err.message }); + log(`[Error processing ${file.fileName}: ${err.message}]`, logLevels.ERROR); + } + } + + log( + `[Done. Downloaded ${downloaded.length} ${downloaded.length === 1 ? 'subtitle' : 'subtitles'}, ` + + `${errors.length} ${errors.length === 1 ? 'error' : 'errors'}.]`, + logLevels.ALL + ); + log('Thanks for using Subcino. Please consider to donate at www.subcino.com!', logLevels.ALL); + + return { settings, downloaded, errors }; +} + +export default run; diff --git a/subcino.js b/subcino.js deleted file mode 100644 index f3cbe47..0000000 --- a/subcino.js +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env node - -var subUtils = require("./subcinoUtils.js"); - -// Get default settings. - -subUtils - .log( '[Loading saved settings...]', subUtils.logLevels.ALL ); - -subUtils.getDefaultSettings( function( settings ) { - subUtils - .log( '[Settings loaded]', subUtils.logLevels.ALL ); - - // Process the shell arguments. - var args = subUtils.parseArgs(); - - if ( args.onlySettings ) { - console.log( settings ); - return; - } - - subUtils.setDebugLevel( args ); - - subUtils - .log( '[Shell arguments]', subUtils.logLevels.DEBUG ) - .log( args, subUtils.logLevels.DEBUG ); - - subUtils.log('[Navigating path...]', subUtils.logLevels.ALL ); - - var files = subUtils.walkSync( args.path, null, args.extensions, args.langs, args.recursive ); - subUtils - .log('[Found ' + files.length + ' ' + (files.length != 1 ? 'files' : 'file') + ']', subUtils.logLevels.ALL ) - .log('[File list]', subUtils.logLevels.DEBUG); - - files.forEach(function( file ) { - subUtils.log( 'Found: ' + file.fullName.replace(args.path + '/', ''), subUtils.logLevels.DEBUG ); - }); - var elements = []; - subUtils - .log('[Login...]', subUtils.logLevels.ALL ) - .subtitleLogin().then(function (res) { - subUtils - .log('[Login successful]', subUtils.logLevels.ALL) - .log(arguments, subUtils.logLevels.DEBUG) - .log('[Obtaining hash information...]', subUtils.logLevels.ALL); - - subUtils.getHashInfo( files, function( hashes ) { - subUtils - .log('[Hash information obtained for all files]', subUtils.logLevels.ALL); - - subUtils - .log('[Searching subtitles...]', subUtils.logLevels.ALL); - subUtils.getSubtitles( hashes, args, function( subtitles ) { - - // Prepares subtitles list. - var dwnList = []; - for ( var j = 0, ln = subtitles.length; j= logLevel ) { - console.log( message ); - } - return this; -}; - -/** - * Sets the current log level according to the arguments. - * @param settings - */ -module.exports.setDebugLevel = function( settings ) { - if ( settings.debug ) { - this.currentLogLevel = this.logLevels.DEBUG; - } else { - this.currentLogLevel = this.logLevels.INFO; - } - return this; -}; - -/** - * Parses the shell arguments. - * @name parseArgs - */ -module.exports.parseArgs = function() { - var self = this; - var argv = process.argv; - var args = this.defaultSettings; - - var saveFile = false; // If true, save settings into a file - - for (var i = 0, len = argv.length; i < len; i++) { - var arg = argv[i]; - var match; - if ((match = arg.match(/-langs=([\w,]+)/)) || (match = arg.match(/-langs=([\w]+)/))) { - // Languages to download - args.langs = (match[1] && match[1].split(',')) || args.langs; - } else if ((match = arg.match(/-extensions=(\w,+)/)) || (match = arg.match(/-extensions=(\w+)/))) { - // File extensions - args.extensions = (match[1] && match[1].split(',')) || args.extensions; - } else if (arg === "-useSubs") { - // Use subtitles folder ( subs/ ) - args.useSubs = true; - } else if (match = arg.match(/-useSubs=(\w+)/)) { - // Same, but with specified options - args.useSubs = (match[1] == 'true'); - } else if (arg === "-debug") { - // Show more log - args.debug = true; - } else if (match = arg.match(/-recursive=(\w+)/)) { - // If true, navigate in the subfolders - args.recursive = (match[1] == 'true'); - } else if ( (arg.indexOf('-path=') === 0) ) { - // Specify a different path - args.path = (arg.replace('-path=', '') || args.path); - } else if (arg === "-save") { - // We will save the settings into a file. - saveFile = true; - } else if (arg === "-settings") { - // Show more log - args.onlySettings = true; - } - } - - var isDefaultPath = args.path === "CWD"; - - if ( saveFile ) { - self.log( '[Saving settings]', self.logLevels.ALL ); - if ( isDefaultPath ) { args.path = "CWD"; } else { - // Fix path backslash - if ( args.path.lastIndexOf('/') === (args.path.length-1) ) { - args.path = args.path.substr( 0, args.path.length - 1 ); - } - } - - this.writeJSONFile( this.SETTINGS_FILE, args, function( err ) { - if ( err ) { - self.log( '[Error while saving settings]', self.logLevels.ALL ); - } else { - self.log( '[Settings saved successfully]', self.logLevels.ALL ); - } - }); - } - - // If path is the default, use it - args.path = isDefaultPath ? process.cwd() : args.path; - - // Fix path backslash - if ( args.path.lastIndexOf('/') === (args.path.length-1) ) { - args.path = args.path.substr( 0, args.path.length - 1 ); - } - return args; -}; - -/** - * Deep object cloning - * @name extendObj - * @param dest - * @param from - */ -module.exports.extendObj = function(dest, from) { - var self = this; - var props = Object.getOwnPropertyNames(from), destination; - - props.forEach(function (name) { - if (typeof from[name] === 'object') { - if (typeof dest[name] !== 'object') { - dest[name] = {} - } - self.extendObj(dest[name],from[name]); - } else { - destination = Object.getOwnPropertyDescriptor(from, name); - Object.defineProperty(dest, name, destination); - } - }); - return this; -}; - -/** - * Obtains the current open subtitles login. - * @returns {*|exports|module.exports} - */ -module.exports.getOpenSubtitles = function() { - this.OS = this.OS || require('opensubtitles-api'); - this.OpenSubtitles = this.OpenSubtitles || new this.OS( 'Subcino v1.0' || 'OSTestUserAgent'); - return this.OpenSubtitles; -}; - -/** - * Get the hash info of the current file. - * @requires opensubtitles-api - * @param fileName - * @returns {Promise} - */ -module.exports.getHash = function( fileName ) { - return this.getOpenSubtitles().extractInfo( fileName ); // Path must be included. -}; -/** - * Logins to OpenSubtitles. - * @requires opensubtitles-api - * @returns {Promise} - */ -module.exports.subtitleLogin = function() { - var promise = this.getOpenSubtitles().login(); - return promise; - /*.then(function (res) { - if ( callbackSettings && callbackSettings.success ) { - callbackSettings.success( res ); - } - }).catch(function (err) { - if ( callbackSettings && callbackSettings.err ) { - callbackSettings.error( err ); - } - });*/ -}; - -/** - * Search subtitles. - * @requires opensubtitles-api - * @param settings - * @param fileInfo - * @param callbackSettings - */ -module.exports.search = function( settings, fileInfo, callbackSettings ) { - settings = settings || {}; - settings.langs = settings.langs || [ 'all' ]; - - this - .log( settings, this.logLevels.DEBUG ) - .log( fileInfo, this.logLevels.DEBUG ); - - if (!fileInfo) { - this - .log( '[Missing file information: ' + fileInfo.fileName + ']', this.logLevels.ERROR ); - } - - return this.getOpenSubtitles().search({ - sublanguageid: settings.langs.join(','), // Can be an array.join with comma, 'all', or be omitted. - hash: fileInfo.moviehash, // Size + 64bit checksum of the first and last 64k - filesize: fileInfo.moviebytesize // Total size, in bytes. - }); -}; - -/** - * List file in a folder, not recursively. - * @requires fs - * @param dir - * @param extensions - the valid extensions. - * @param langs - the langs to download subtitles for - * @returns {Array} - */ -module.exports.listFiles = function( dir, extensions, langs ) { - var goodFiles = []; - var fs = require('fs'); - var files = fs.readdirSync( dir ); - - if (!extensions) { - return files; - } - - for (var i in files) { - var extension = files[i].substr( files[i].lastIndexOf('.') ); - if (extensions.indexOf( extension.replace('.', '') ) > -1 && this.shouldDownload(dir, files[i], langs, 0)) { - goodFiles.push({ fileName: files[i], path: dir, fullName: (dir ? dir + '/' : '') + files[i] }); - } - } - return goodFiles; -}; - -/** - * List all files in a directory in Node.js recursively in a synchronous fashion - * @requires fs - * @param dir - * @param filelist - * @param extensions - the valid extensions. - * @param langs - the langs to download subtitles for - * @param recursive - * @returns {Array} - */ -// -module.exports.walkSync = function(dir, filelist, extensions, langs, recursive) { - var self = this; - recursive = typeof recursive === 'undefined' ? true: recursive; - filelist = filelist || []; - - if (!recursive) { - return this.listFiles( dir, extensions, langs ); - }; - - var fs = fs || require('fs'), - files = fs.readdirSync(dir); - - files.forEach(function(file) { - if ( fs.statSync(dir + '/' + file).isDirectory() ) { - filelist = self.walkSync(dir + '/' + file, filelist, extensions, langs, recursive); - } - else { - if ( extensions ) { - var extension = file.substr( file.lastIndexOf('.') ); - if (extensions.indexOf( extension.replace('.', '') ) > -1 && self.shouldDownload(dir, file, langs, 0)) { - filelist.push({ fileName: file, path: dir, fullName: (dir ? dir + '/' : '') + file }); - } - } else { - filelist.push({ fileName: file, path: dir, fullName: (dir ? dir + '/' : '') + file }); - } - } - }); - return filelist; -}; - -module.exports.shouldDownload = function(dir, file, langs, index) { - var fs = fs || require('fs'), - lang = isoLangs.where("2", langs[index])["1"]; - - var completeSubName = dir + '/' + this.saveAs(file, lang); - try { - fs.accessSync(completeSubName, fs.F_OK); - index++; - if (index < langs.length) { - return this.shouldDownload(dir, file, langs, index) - } else { - return false; - } - } catch (e) { - return true; - } -} - -/** - * Get the hashes out of a list of files. - * @param list - * @param callback - * @param result - internal for recursion - */ -module.exports.getHashInfo = function( list, callback, result ) { - var self = this; - result = result || []; - // The list is finished. - if (!list || !(list.length)) { - callback( result ); - return; - } - var objInfo = list[0]; - - this.log( '[Get hash for: ' + objInfo.fileName, this.logLevels.DEBUG ); - - this.getHash( objInfo.fullName ) - .then(function( infos ){ - self - .log('[Hash obtained]', self.logLevels.DEBUG) - .log(infos, self.logLevels.DEBUG); - // Extend current object with new info. - self.extendObj( objInfo, infos ); - result.push( objInfo ); - list.shift(); - self.getHashInfo( list, callback, result ); - }).catch(function (err) { - self - .log('[Hash error for file ' + objInfo.fileName + ']', self.logLevels.ALL) - .log(err, self.logLevels.DEBUG); - - objInfo.error = true; - objInfo.errorType = 'HASH_ERROR'; - result.push( objInfo ); - list.shift(); - self.getHashInfo( list, callback, result ); - }); -}; - -/** - * Search the subtitles for a list of file. - * @param list - * @param settings - * @param callback - * @param result - internal for recursion - */ -module.exports.getSubtitles = function( list, settings, callback, result ) { - var self = this; - result = result || []; - - // The list is finished. - if (!list || !(list.length)) { - callback( result ); - return; - } - - var objInfo = list[0]; - - // Result of an error, so we don't have to look for subs. - if (objInfo.error) { - this - .log('[Subtitles for ' + objInfo.fileName + ' skipped]', self.logLevels.ALL); - result.push( objInfo ); - list.shift(); - this.getSubtitles( list, settings, callback, result ); - return; - } - - this.log( '[Get subtitles for: ' + objInfo.fileName, this.logLevels.DEBUG ); - this.search( settings, objInfo ) - .then(function( infos ){ - self - .log('[Subtitles obtained]', self.logLevels.DEBUG) - .log(infos, self.logLevels.DEBUG); - - // Convert information in a useful structure. - var numSub = Object.keys(infos).length; - - self - .log('[Found ' + numSub + ' ' + (numSub === 1 ? 'subtitle' : 'subtitles') + ']', self.logLevels.DEBUG); - - var downloadList = []; - for ( var j in infos ) { - - if ( infos[j].url ) { - if ( settings.langs.join('') === 'all' || settings.langs.indexOf(j) ) { - downloadList.push( { url: infos[j].url, lang: j } ); - } - } - } - - // Extend current object with subtitles information - self.extendObj( objInfo, { subtitles: infos } ); - objInfo.downloadList = downloadList; - result.push( objInfo ); - list.shift(); - self.getSubtitles( list, settings, callback, result ); - }).catch(function (err) { - self - .log('[Error in obtaining subtitles for file ' + objInfo.fileName + ']', self.logLevels.ALL) - .log( err, self.logLevels.DEBUG); - - objInfo.error = true; - objInfo.errorType = 'SUBTITLE_ERROR'; - result.push( objInfo ); - list.shift(); - self.getSubtitles( list, settings, callback, result ); - }); -}; - -/** - * Get the name for the final download subtitle file, based on name of the video file - * and subtitle language - * - * @param name - * @param lang - * @returns {string} - */ -module.exports.saveAs = function (name, lang) { - return name.substr( 0, name.lastIndexOf('.') ) + - '.' + - lang + '.srt'; -} - -/** - * Download a list of files. - * @requires http - * @requires fs - * @param list - * @param callbacks - the are two callbacks. Progress and Complete. - * * @param result - internal for recursion - * @returns {boolean} - */ -module.exports.download = function (list, callbacks, result) { - var http = require('http'); - var fs = require('fs'); - var self = this; - var DOWNLOAD_DELAY = 400; - //var parser = require('subtitles-parser'); - - result = result || { errors: [], files: [] }; - - // Moves to the following iteration - function moveNext( objInfo ) { - if ( objInfo.error ) { - objInfo.errorType = 'DOWNLOAD_ERROR'; - result.errors.push( objInfo ); - } else { - result.files.push( objInfo ); - } - - list.shift(); - callbacks.progress( objInfo ); - - setTimeout(function(){ - // Download next. - self.download(list, callbacks, result); - }, DOWNLOAD_DELAY); - } - - // Prepares callbacks. - function isFunction(functionToCheck) { - var getType = {}; - return functionToCheck && getType.toString.call(functionToCheck) === '[object Function]'; - } - - callbacks = isFunction( callbacks ) ? { - complete: callbacks, - progress: function(){} - } : (callbacks || {}); - - callbacks.complete = callbacks.complete || function(){}; - callbacks.progress = callbacks.progress || function(){}; - - if (!Array.isArray(list)) { - list = [list]; - } - // Termination condition. Function is recursive - if (!list.length) { - callbacks.complete( result ); - return true; - } - - var objInfo = list[0]; - - this.log('[Downloading ' + objInfo.url + '...]', this.logLevels.DEBUG); - - try { - var destinationPath = objInfo.path; - var destinationName = objInfo.saveAs; - - if (!fs.existsSync(destinationPath)) { - fs.mkdirSync(destinationPath); - } - - var targetFilePath = destinationPath + destinationName; - targetFilePath = targetFilePath.replace('//', '/'); - - var file = fs.createWriteStream(targetFilePath); - - objInfo.targetFilePath = targetFilePath; - - var request = http.get( objInfo.url, function (response) { - response.pipe(file); - //TODO parser.fromSrt parse srt to array, add Subcino advertising, rewrite to file - moveNext( objInfo ); - }); - } catch (ex) { - self - .log('[Download error for ' + destinationName + ']', self.logLevels.ERROR) - .log(ex, self.logLevels.DEBUG); - objInfo.error = true; - moveNext( objInfo ); - } -}; - -/** - * Reads a JSON file. - * @param file - * @param callback - */ -module.exports.readJSONFile = function( file, callback ) { - var jsonfile = require('jsonfile'); - if (!file) { - return; - } - if (!callback) { - return jsonfile.readFileSync(file); - } else { - return jsonfile.readFile(file, function(err, obj) { - callback( obj || {}, err ); - }) - } -}; - -module.exports.writeJSONFile = function( file, obj, callback ) { - var jsonfile = require('jsonfile'); - if (!file || !obj) { - return; - } - if (!callback) { - return jsonfile.writeFileSync(file, obj); - } else { - return jsonfile.writeFile(file, obj, {spaces: 2}, function(err) { - if ( callback ) { - callback( err ); - } - }); - } -}; - -module.exports.insertPromoSub = function(fileStr) { - var parser = require('subtitles-parser'); - var srt = parser.fromSrt(fileStr, true); - var startPos = Math.floor(srt.length/4); //Math.floor(srt.length/2); - - var found = 0; - - for (var i = startPos; i < srt.length ; i++) { - if (i != (srt.length - 1)) { - var availableInterval = parseInt(srt[i + 1].startTime, 10) - parseInt(srt[i].endTime, 10); - //if we have more than 3 seconds between a caption and the next one - if (availableInterval > 3000) { - var matchedIndex = i + 1; - - var id = matchedIndex + 1; - - var startTime = parseInt(srt[matchedIndex - 1].endTime, 10) + 500; - var endTime = parseInt(srt[matchedIndex].startTime, 10) - 500; - if ( endTime - startTime > 5000 ) { - endTime = startTime + 5000; - // It can last 5000 milliseconds maximum. - } - - var promoSub = { - id : id.toString(), - startTime : startTime, - endTime : endTime, - text : "Downloaded with Subcino [www.subcino.com]" - }; - - srt.splice(matchedIndex, 0, promoSub); - - for (var j = matchedIndex + 1; j < srt.length ; j++) { - srt[j].id = (parseInt(srt[j].id) + 1).toString(); - } - - found++; - /*if ( found == 2 ) { - return parser.toSrt(srt); - }*/ - } - } - } - return parser.toSrt(srt); -}; - -// TODO ARGS TO RETRIVE JUST THE DEFAULT SETTINGS. - - -module.exports.getDefaultSettings = function( callback ) { - var self = this; - - self.defaultSettings = { - recursive: true, - extensions: ['mp4', 'mkv', 'avi'], - langs: [ 'all' ], - path: "CWD", // By default, uses the current working directory. - useSubs: false, - debug: false - }; - - try { - - this.readJSONFile( this.SETTINGS_FILE, function( data, error ) { - if ( error ) { - // We ignore "File not found" error. - if ( error.code !== 'ENOENT' ) { - self.log('[Error while getting saved settings]', self.logLevels.ERROR); - self.log( error, self.logLevels.DEBUG ); - } - callback( self.defaultSettings ); - } else { - self.extendObj( self.defaultSettings, data || {} ); - callback( self.defaultSettings ); - } - }); - } catch( ex ) { - self.log('[Error while getting saved settings]', self.logLevels.ERROR); - self.log( ex, self.logLevels.DEBUG ); - callback( self.defaultSettings ); - } - -}; \ No newline at end of file diff --git a/test/args.test.js b/test/args.test.js new file mode 100644 index 0000000..33dae4d --- /dev/null +++ b/test/args.test.js @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeLegacyArgv, parseArgs } from '../src/args.js'; + +const parse = (argv) => parseArgs(argv, { exit: false, version: '2.0.0' }); + +test('normalizeLegacyArgv rewrites the old single-dash style', () => { + assert.deepEqual( + normalizeLegacyArgv(['-langs=eng,ita', '-recursive=false', '-useSubs', '-path=/m', '-save', '-debug']), + ['--langs', 'eng,ita', '--no-recursive', '--use-subs', '--path', '/m', '--save', '--debug'] + ); +}); + +test('normalizeLegacyArgv leaves modern flags untouched', () => { + const modern = ['--langs', 'en', '--no-recursive']; + assert.deepEqual(normalizeLegacyArgv(modern), modern); +}); + +test('parseArgs reports only options the user actually passed', () => { + const { cli, save, onlySettings } = parse([]); + assert.deepEqual(cli, {}); + assert.equal(save, false); + assert.equal(onlySettings, false); +}); + +test('parseArgs parses the modern form', () => { + const { cli } = parse(['--langs', 'eng,ita', '--no-recursive', '--use-subs', '--path', '/movies']); + assert.deepEqual(cli.langs, ['eng', 'ita']); + assert.equal(cli.recursive, false); + assert.equal(cli.useSubs, true); + assert.equal(cli.path, '/movies'); +}); + +test('parseArgs parses the legacy form identically', () => { + const { cli } = parse(['-langs=eng,ita', '-recursive=false', '-useSubs', '-path=/movies']); + assert.deepEqual(cli.langs, ['eng', 'ita']); + assert.equal(cli.recursive, false); + assert.equal(cli.useSubs, true); + assert.equal(cli.path, '/movies'); +}); + +test('parseArgs recognises -settings and -save', () => { + assert.equal(parse(['-settings']).onlySettings, true); + assert.equal(parse(['-save']).save, true); +}); diff --git a/test/config.test.js b/test/config.test.js new file mode 100644 index 0000000..e21a530 --- /dev/null +++ b/test/config.test.js @@ -0,0 +1,96 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + DEFAULT_SETTINGS, + loadSettings, + saveSettings, + mergeSettings, + resolvePath, + readCredentials +} from '../src/config.js'; + +async function withTmpDir(fn) { + const dir = await mkdtemp(join(tmpdir(), 'subcinode-config-')); + try { + return await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test('loadSettings returns the defaults when no settings.json exists', async () => { + await withTmpDir(async (dir) => { + const settings = await loadSettings({ cwd: dir }); + assert.equal(settings.recursive, true); + assert.deepEqual(settings.extensions, ['mp4', 'mkv', 'avi']); + assert.deepEqual(settings.langs, ['all']); + assert.equal(settings.provider, 'opensubtitles'); + }); +}); + +test('loadSettings merges a stored file over the defaults (arrays replace)', async () => { + await withTmpDir(async (dir) => { + await writeFile( + join(dir, 'settings.json'), + JSON.stringify({ langs: ['en', 'it'], useSubs: true }) + ); + const settings = await loadSettings({ cwd: dir }); + assert.deepEqual(settings.langs, ['en', 'it']); + assert.equal(settings.useSubs, true); + assert.equal(settings.recursive, true); // untouched default + }); +}); + +test('loadSettings tolerates a malformed settings.json', async () => { + await withTmpDir(async (dir) => { + await writeFile(join(dir, 'settings.json'), '{ not json'); + const warnings = []; + const original = console.warn; + console.warn = (msg) => warnings.push(msg); + try { + const settings = await loadSettings({ cwd: dir }); + assert.deepEqual(settings.langs, ['all']); + } finally { + console.warn = original; + } + assert.equal(warnings.length, 1); + }); +}); + +test('mergeSettings does not mutate the frozen defaults', () => { + const merged = mergeSettings(DEFAULT_SETTINGS, { langs: ['fr'] }); + merged.langs.push('de'); + assert.deepEqual(DEFAULT_SETTINGS.langs, ['all']); +}); + +test('resolvePath expands the CWD sentinel and trims a trailing slash', () => { + assert.equal(resolvePath({ path: 'CWD' }, '/home/x'), '/home/x'); + assert.equal(resolvePath({ path: '/movies/' }), '/movies'); +}); + +test('saveSettings persists a copy, drops transient keys and re-folds the cwd path', async () => { + await withTmpDir(async (dir) => { + await saveSettings( + { ...DEFAULT_SETTINGS, path: dir, langs: ['en'], save: true, onlySettings: true }, + { cwd: dir } + ); + const stored = JSON.parse(await readFile(join(dir, 'settings.json'), 'utf8')); + assert.equal(stored.path, 'CWD'); + assert.deepEqual(stored.langs, ['en']); + assert.ok(!('save' in stored)); + assert.ok(!('onlySettings' in stored)); + }); +}); + +test('readCredentials pulls from the environment only', () => { + const creds = readCredentials({ + OPENSUBTITLES_API_KEY: 'k', + OPENSUBTITLES_USERNAME: 'u', + OPENSUBTITLES_PASSWORD: 'p' + }); + assert.deepEqual(creds, { apiKey: 'k', username: 'u', password: 'p' }); + assert.deepEqual(readCredentials({}), { apiKey: '', username: '', password: '' }); +}); diff --git a/test/download.test.js b/test/download.test.js new file mode 100644 index 0000000..bdd83ac --- /dev/null +++ b/test/download.test.js @@ -0,0 +1,81 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { get as httpGet } from 'node:http'; +import { gzipSync } from 'node:zlib'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { downloadFile } from '../src/download.js'; + +const BODY = 'WEBVTT\n\n' + '1\n00:00:01,000 --> 00:00:02,000\nHello world\n\n'.repeat(500); + +function startServer() { + const server = createServer((req, res) => { + if (req.url === '/ok') { + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end(BODY); + } else if (req.url === '/gz') { + res.writeHead(200, { 'content-encoding': 'gzip' }); + res.end(gzipSync(Buffer.from(BODY))); + } else if (req.url === '/redirect') { + res.writeHead(302, { location: '/ok' }); + res.end(); + } else { + res.writeHead(404); + res.end('nope'); + } + }); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + resolve({ server, base: `http://127.0.0.1:${port}` }); + }); + }); +} + +async function withCtx(fn) { + const { server, base } = await startServer(); + const dir = await mkdtemp(join(tmpdir(), 'subcinode-dl-')); + try { + return await fn({ base, dir }); + } finally { + server.close(); + await rm(dir, { recursive: true, force: true }); + } +} + +const deps = { httpGet }; + +test('downloads the full body and resolves after the stream finishes', async () => { + await withCtx(async ({ base, dir }) => { + const target = join(dir, 'nested', 'movie.en.srt'); + await downloadFile(`${base}/ok`, target, deps); + assert.equal(await readFile(target, 'utf8'), BODY); + }); +}); + +test('inflates a gzip-encoded response', async () => { + await withCtx(async ({ base, dir }) => { + const target = join(dir, 'movie.en.srt'); + await downloadFile(`${base}/gz`, target, deps); + assert.equal(await readFile(target, 'utf8'), BODY); + }); +}); + +test('follows a redirect', async () => { + await withCtx(async ({ base, dir }) => { + const target = join(dir, 'movie.en.srt'); + await downloadFile(`${base}/redirect`, target, deps); + assert.equal(await readFile(target, 'utf8'), BODY); + }); +}); + +test('rejects on a non-200 response', async () => { + await withCtx(async ({ base, dir }) => { + await assert.rejects( + () => downloadFile(`${base}/missing`, join(dir, 'x.srt'), deps), + /HTTP 404/ + ); + }); +}); diff --git a/test/files.test.js b/test/files.test.js new file mode 100644 index 0000000..e952683 --- /dev/null +++ b/test/files.test.js @@ -0,0 +1,73 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { walk, saveAs, shouldDownload, normalizeLang } from '../src/files.js'; + +async function fixture() { + const dir = await mkdtemp(join(tmpdir(), 'subcinode-files-')); + await writeFile(join(dir, 'movie.mkv'), 'x'); + await writeFile(join(dir, 'notes.txt'), 'x'); + await writeFile(join(dir, 'done.mkv'), 'x'); + await writeFile(join(dir, 'done.en.srt'), 'x'); // already downloaded + await mkdir(join(dir, 'season1')); + await writeFile(join(dir, 'season1', 'episode.avi'), 'x'); + await mkdir(join(dir, 'subs')); + await writeFile(join(dir, 'subs', 'stray.mkv'), 'x'); // must be ignored + return dir; +} + +test('saveAs builds Name.lang.srt keeping earlier dots', () => { + assert.equal(saveAs('Movie.2015.mkv', 'en'), 'Movie.2015.en.srt'); + assert.equal(saveAs('noext', 'it'), 'noext.it.srt'); +}); + +test('normalizeLang maps 3-letter to 2-letter and passes through the rest', () => { + assert.equal(normalizeLang('eng'), 'en'); + assert.equal(normalizeLang('EN'), 'en'); + assert.equal(normalizeLang('all'), 'all'); + assert.equal(normalizeLang('zz'), 'zz'); +}); + +test('shouldDownload skips only when every requested language is already present', () => { + assert.equal(shouldDownload('/nope', 'x.mkv', ['en']), true); +}); + +test('walk (recursive) collects wanted extensions, skips others and the subs/ folder', async () => { + const dir = await fixture(); + try { + const found = walk(dir, { extensions: ['mkv', 'avi'], langs: ['it'], recursive: true }) + .map((f) => f.fileName) + .sort(); + assert.deepEqual(found, ['episode.avi', 'done.mkv', 'movie.mkv'].sort()); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('walk honours the "already downloaded" skip', async () => { + const dir = await fixture(); + try { + const found = walk(dir, { extensions: ['mkv'], langs: ['en'], recursive: false }).map( + (f) => f.fileName + ); + assert.deepEqual(found.sort(), ['movie.mkv']); // done.mkv already has done.en.srt + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('walk (non-recursive) stays in the top directory', async () => { + const dir = await fixture(); + try { + const found = walk(dir, { extensions: ['avi'], recursive: false }); + assert.equal(found.length, 0); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('walk returns [] for a missing directory', () => { + assert.deepEqual(walk('/definitely/not/here', { extensions: ['mkv'] }), []); +}); diff --git a/test/hash.test.js b/test/hash.test.js new file mode 100644 index 0000000..e103bf8 --- /dev/null +++ b/test/hash.test.js @@ -0,0 +1,62 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { computeHash } from '../src/hash.js'; + +async function withTmpDir(fn) { + const dir = await mkdtemp(join(tmpdir(), 'subcinode-hash-')); + try { + return await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test('hashes a small file from first principles (chunks overlap the whole file)', async () => { + await withTmpDir(async (dir) => { + const file = join(dir, 'tiny.bin'); + const bytes = Buffer.from(Array.from({ length: 16 }, (_, i) => i)); // 00..0f + await writeFile(file, bytes); + + const word0 = 0x0706050403020100n; + const word1 = 0x0f0e0d0c0b0a0908n; + // size + checksum(head) + checksum(tail); head and tail both span the whole 16-byte file + const expected = ((16n + 2n * (word0 + word1)) & ((1n << 64n) - 1n)) + .toString(16) + .padStart(16, '0'); + + const { moviehash, moviebytesize } = await computeHash(file); + assert.equal(moviehash, expected); + assert.equal(moviebytesize, 16); + }); +}); + +test('ignores trailing bytes that do not fill a 64-bit word', async () => { + await withTmpDir(async (dir) => { + const file = join(dir, 'five.bin'); + await writeFile(file, Buffer.from([1, 2, 3, 4, 5])); + const { moviehash, moviebytesize } = await computeHash(file); + // wordLen is 0, so only the file size contributes + assert.equal(moviehash, '0000000000000005'); + assert.equal(moviebytesize, 5); + }); +}); + +test('produces a 16-char lowercase hex string for a large file', async () => { + await withTmpDir(async (dir) => { + const file = join(dir, 'big.bin'); + await writeFile(file, Buffer.alloc(200 * 1024, 0xab)); + const { moviehash } = await computeHash(file); + assert.match(moviehash, /^[0-9a-f]{16}$/); + }); +}); + +test('rejects an empty file', async () => { + await withTmpDir(async (dir) => { + const file = join(dir, 'empty.bin'); + await writeFile(file, Buffer.alloc(0)); + await assert.rejects(() => computeHash(file), /empty file/i); + }); +}); diff --git a/test/promo.test.js b/test/promo.test.js new file mode 100644 index 0000000..81f4ade --- /dev/null +++ b/test/promo.test.js @@ -0,0 +1,78 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseSync, stringifySync } from 'subtitle'; +import { insertPromoSub, PROMO_TEXT } from '../src/promo.js'; + +function buildSrt(intervals) { + const nodes = intervals.map(([start, end], i) => ({ + type: 'cue', + data: { start, end, text: `Line ${i + 1}` } + })); + return stringifySync(nodes, { format: 'srt' }); +} + +function cues(srt) { + return parseSync(srt).filter((n) => n.type === 'cue'); +} + +test('inserts a single promo caption into a wide gap in the back three-quarters', () => { + const srt = buildSrt([ + [0, 1000], + [1200, 2200], + [2400, 3400], + [3600, 4600], + [4800, 5800], + [6000, 7000], + [12000, 13000], // 5s gap before this cue + [13200, 14200] + ]); + + const out = insertPromoSub(srt); + const outCues = cues(out); + + const promos = outCues.filter((c) => c.data.text === PROMO_TEXT); + assert.equal(promos.length, 1); + assert.equal(outCues.length, 9); + + const promo = promos[0]; + assert.equal(promo.data.start, 7500); // previous end + 500 + assert.equal(promo.data.end, 11500); // next start - 500 (under the 5s cap) +}); + +test('caps the promo caption at 5 seconds', () => { + const srt = buildSrt([ + [0, 1000], + [1200, 2200], + [2400, 3400], + [3600, 4600], + [4800, 5800], + [6000, 7000], + [60000, 61000], // huge gap + [61200, 62200] + ]); + const promo = cues(insertPromoSub(srt)).find((c) => c.data.text === PROMO_TEXT); + assert.equal(promo.data.end - promo.data.start, 5000); +}); + +test('leaves a gap-free document without a promo caption', () => { + const srt = buildSrt([ + [0, 1000], + [1100, 2000], + [2100, 3000], + [3100, 4000], + [4100, 5000], + [5100, 6000], + [6100, 7000], + [7100, 8000] + ]); + const out = insertPromoSub(srt); + assert.ok(!out.includes(PROMO_TEXT)); + assert.equal(cues(out).length, 8); +}); + +test('does nothing with fewer than two cues', () => { + const srt = buildSrt([[0, 1000]]); + const out = insertPromoSub(srt); + assert.ok(!out.includes(PROMO_TEXT)); + assert.equal(cues(out).length, 1); +}); diff --git a/test/providers/opensubtitles.test.js b/test/providers/opensubtitles.test.js new file mode 100644 index 0000000..4783dd0 --- /dev/null +++ b/test/providers/opensubtitles.test.js @@ -0,0 +1,125 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { OpenSubtitlesProvider, normalizeLanguages } from '../../src/providers/opensubtitles.js'; +import { getProvider, availableProviders } from '../../src/providers/index.js'; + +function jsonResponse(body, { ok = true, status = 200 } = {}) { + return { ok, status, statusText: '', text: async () => JSON.stringify(body) }; +} + +/** Records every fetch call and replies from a queue of responders. */ +function fakeFetch(responders) { + const calls = []; + const queue = [...responders]; + const fetch = async (url, init) => { + calls.push({ url, init }); + const next = queue.shift(); + return typeof next === 'function' ? next(url, init) : next; + }; + fetch.calls = calls; + return fetch; +} + +test('normalizeLanguages sorts, de-dupes and drops "all"', () => { + assert.equal(normalizeLanguages(['ita', 'eng', 'en']), 'en,it'); + assert.equal(normalizeLanguages('eng,ita'), 'en,it'); + assert.equal(normalizeLanguages(['all']), null); + assert.equal(normalizeLanguages([]), null); + assert.equal(normalizeLanguages(undefined), null); +}); + +test('getProvider returns the OpenSubtitles implementation by default', () => { + assert.ok(getProvider('opensubtitles', { apiKey: 'k' }) instanceof OpenSubtitlesProvider); + assert.ok(availableProviders().includes('opensubtitles')); + assert.throws(() => getProvider('nope'), /Unknown subtitle provider/); +}); + +test('init rejects without an API key', async () => { + const provider = new OpenSubtitlesProvider({}, { fetch: fakeFetch([]) }); + await assert.rejects(() => provider.init(), /Missing OpenSubtitles API key/); +}); + +test('init logs in when credentials are supplied and caches the bearer token', async () => { + const fetch = fakeFetch([jsonResponse({ token: 'jwt-123' })]); + const provider = new OpenSubtitlesProvider( + { apiKey: 'k', username: 'u', password: 'p' }, + { fetch } + ); + await provider.init(); + + const [{ url, init }] = fetch.calls; + assert.equal(url, 'https://api.opensubtitles.com/api/v1/login'); + assert.equal(init.method, 'POST'); + assert.deepEqual(JSON.parse(init.body), { username: 'u', password: 'p' }); + assert.equal(init.headers['Api-Key'], 'k'); + assert.match(init.headers['User-Agent'], /subcinode/); + assert.equal(provider.token, 'jwt-123'); +}); + +test('search builds the query and maps results sorted by download count', async () => { + const fetch = fakeFetch([ + jsonResponse({ + data: [ + { + id: '1', + attributes: { + language: 'en', + download_count: 10, + files: [{ file_id: 111, file_name: 'a.srt' }] + } + }, + { + id: '2', + attributes: { + language: 'it', + download_count: 99, + files: [{ file_id: 222, file_name: 'b.srt' }] + } + }, + { id: '3', attributes: { language: 'de', download_count: 5, files: [] } } + ] + }) + ]); + const provider = new OpenSubtitlesProvider({ apiKey: 'k' }, { fetch }); + + const results = await provider.search( + { moviehash: 'abc123', moviebytesize: 456 }, + { languages: ['eng', 'ita'] } + ); + + const [{ url }] = fetch.calls; + assert.ok(url.startsWith('https://api.opensubtitles.com/api/v1/subtitles?')); + assert.ok(url.includes('moviehash=abc123')); + assert.ok(url.includes('moviebytesize=456')); + assert.ok(url.includes('languages=en%2Cit')); + + assert.deepEqual( + results.map((r) => r.fileId), + [222, 111] + ); + assert.equal(results[0].langId, 'it'); +}); + +test('resolveDownloadUrl posts the file id and returns the link', async () => { + const fetch = fakeFetch([jsonResponse({ link: 'https://dl.example/x.srt', file_name: 'x.srt' })]); + const provider = new OpenSubtitlesProvider({ apiKey: 'k' }, { fetch }); + provider.token = 'jwt'; + + const out = await provider.resolveDownloadUrl({ fileId: 222, fileName: 'fallback.srt' }); + + const [{ url, init }] = fetch.calls; + assert.equal(url, 'https://api.opensubtitles.com/api/v1/download'); + assert.equal(init.method, 'POST'); + assert.deepEqual(JSON.parse(init.body), { file_id: 222 }); + assert.equal(init.headers.Authorization, 'Bearer jwt'); + assert.deepEqual(out, { url: 'https://dl.example/x.srt', fileName: 'x.srt' }); +}); + +test('a non-200 response is turned into an error with the API message', async () => { + const fetch = fakeFetch([jsonResponse({ message: 'invalid api key' }, { ok: false, status: 403 })]); + const provider = new OpenSubtitlesProvider({ apiKey: 'bad' }, { fetch }); + await assert.rejects( + () => provider.search({ moviehash: 'abc' }), + /OpenSubtitles GET \/subtitles failed: invalid api key/ + ); +}); diff --git a/test/run.test.js b/test/run.test.js new file mode 100644 index 0000000..6eb9b96 --- /dev/null +++ b/test/run.test.js @@ -0,0 +1,101 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { get as httpGet } from 'node:http'; +import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { stringifySync } from 'subtitle'; +import { run } from '../src/run.js'; +import { registerProvider } from '../src/providers/index.js'; +import { PROMO_TEXT } from '../src/promo.js'; + +const SRT = stringifySync( + [ + { type: 'cue', data: { start: 0, end: 1000, text: 'One' } }, + { type: 'cue', data: { start: 1200, end: 2200, text: 'Two' } }, + { type: 'cue', data: { start: 2400, end: 3400, text: 'Three' } }, + { type: 'cue', data: { start: 3600, end: 4600, text: 'Four' } }, + { type: 'cue', data: { start: 4800, end: 5800, text: 'Five' } }, + { type: 'cue', data: { start: 6000, end: 7000, text: 'Six' } }, + { type: 'cue', data: { start: 20000, end: 21000, text: 'Seven' } }, + { type: 'cue', data: { start: 21200, end: 22200, text: 'Eight' } } + ], + { format: 'srt' } +); + +let base; +let server; + +test.before(async () => { + server = createServer((req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end(SRT); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + base = `http://127.0.0.1:${server.address().port}`; + + registerProvider( + 'mock', + class { + get name() { + return 'mock'; + } + async init() {} + async search() { + return [{ langId: 'en', fileId: 1, fileName: 'm.srt', downloadCount: 1 }]; + } + async resolveDownloadUrl() { + return { url: `${base}/sub`, fileName: 'm.srt' }; + } + } + ); +}); + +test.after(() => server.close()); + +async function withFixture(fn) { + const dir = await mkdtemp(join(tmpdir(), 'subcinode-run-')); + await writeFile(join(dir, 'Movie.2015.mkv'), Buffer.alloc(4096, 7)); + try { + return await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +const opts = (dir, extra = {}) => ({ + cli: { provider: 'mock', langs: ['en'], path: dir, ...extra } +}); +const deps = (dir) => ({ cwd: dir, env: {}, noDelay: true, version: 'test', downloadDeps: { httpGet } }); + +test('run downloads a subtitle next to the video and inserts the promo caption', async () => { + await withFixture(async (dir) => { + const result = await run(opts(dir), deps(dir)); + + assert.equal(result.errors.length, 0); + assert.equal(result.downloaded.length, 1); + + const target = join(dir, 'Movie.2015.en.srt'); + assert.ok(existsSync(target)); + assert.ok((await readFile(target, 'utf8')).includes(PROMO_TEXT)); + }); +}); + +test('run skips a language whose subtitle file already exists', async () => { + await withFixture(async (dir) => { + await writeFile(join(dir, 'Movie.2015.en.srt'), 'already here'); + const result = await run(opts(dir), deps(dir)); + assert.equal(result.downloaded.length, 0); + assert.equal(await readFile(join(dir, 'Movie.2015.en.srt'), 'utf8'), 'already here'); + }); +}); + +test('run honours useSubs by writing under a subs/ folder', async () => { + await withFixture(async (dir) => { + const result = await run(opts(dir, { useSubs: true }), deps(dir)); + assert.equal(result.downloaded.length, 1); + assert.ok(existsSync(join(dir, 'subs', 'Movie.2015.en.srt'))); + }); +}); From d35e10b16baa5b642b1931a5825f36e073263914 Mon Sep 17 00:00:00 2001 From: Alessandro Piana Date: Tue, 1 Sep 2026 08:45:03 +0200 Subject: [PATCH 2/2] Remove references to Subcino --- README.md | 424 +++++++++++++++++++++++++++++---------------------- package.json | 7 +- src/promo.js | 2 +- src/run.js | 2 +- 4 files changed, 246 insertions(+), 189 deletions(-) diff --git a/README.md b/README.md index c4ddf5c..e02491c 100644 --- a/README.md +++ b/README.md @@ -1,257 +1,309 @@ # subcinode -Your subs, now from your console. +**Your subs, now — from your console.** -**Subcino(de)** is an npm package to automatically download the correct subtitles for your video -files. It is the node version of [Subcino](http://www.subcino.com), and a thin, legit wrapper around -the [OpenSubtitles](https://www.opensubtitles.com) REST API. +`subcinode` is a command-line tool that automatically finds and downloads the *right* subtitles for +your local video files. Point it at a folder, it scans for video files, identifies each one by its +content hash, asks [OpenSubtitles](https://www.opensubtitles.com) for the best matching subtitle in +the languages you want, and drops the `.srt` next to the video. + +- Content-hash matching — subtitles are matched to the exact release, not guessed from the filename. +- Batch + recursive — process a whole library in one run. +- Multi-language — download several languages per file in a single pass. +- Idempotent — files that already have a subtitle are skipped. +- Pluggable back-ends — OpenSubtitles today, other providers via a small interface. + +--- + +## How it works + +For every video file found under the target path, `subcinode`: + +1. **Hashes** it with the OpenSubtitles/OSDb algorithm (file size + checksums of the first and last + 64 KiB). +2. **Searches** the provider for subtitles matching that hash, filtered to your languages. +3. **Downloads** the most-downloaded subtitle per language to `Movie..srt` (or into a + `subs/` sub-folder with `--use-subs`). +4. **Tags** the downloaded `.srt` with a single short caption in a silent gap + (`Downloaded with subcinode …`). + +A language is skipped when its target file already exists, so re-running is cheap. + +--- ## Requirements -* **Node.js >= 20** -* A free **OpenSubtitles API key** — register a consumer at - and export it: +- **Node.js ≥ 20** +- A **free OpenSubtitles API key** (see below). Anonymous search works, but downloads require a key, + and the per-day download quota is tied to a (free) OpenSubtitles account. + +### Getting an OpenSubtitles API key + +1. Create a free account at . +2. Go to and register a new **consumer** — this gives you + an API key. +3. Make the key (and, for downloads, your account login) available to `subcinode` via environment + variables: - ```shell - export OPENSUBTITLES_API_KEY=your_key - # optional, needed for the /download quota of a free account: - export OPENSUBTITLES_USERNAME=your_user - export OPENSUBTITLES_PASSWORD=your_pass - ``` + ```shell + export OPENSUBTITLES_API_KEY=your_api_key + export OPENSUBTITLES_USERNAME=your_username # needed for the download quota + export OPENSUBTITLES_PASSWORD=your_password + ``` + + Put these in your shell profile (`~/.zshrc`, `~/.bashrc`, …) to make them permanent. `subcinode` + never writes credentials to disk. + +--- ## Installation ```shell -npm install subcinode --global +npm install --global subcinode +``` + +Or run it without installing: + +```shell +npx subcinode --langs eng,ita ``` -## Documentation +--- + +## Quick start ```shell -subcinode [--langs ] [--extensions ] [--path ] \ - [--recursive | --no-recursive] [--use-subs] \ +cd ~/Movies +export OPENSUBTITLES_API_KEY=your_api_key +subcinode --langs eng,ita +``` + +This scans `~/Movies` (recursively), and for each `.mp4` / `.mkv` / `.avi` downloads the best English +and Italian subtitles next to the video. + +--- + +## Usage + +```shell +subcinode [--langs ] [--extensions ] [--path ] + [--recursive | --no-recursive] [--use-subs] [--provider ] [--save] [--settings] [--debug] ``` | Option | Type | Default | Description | |---|---|---|---| -| `--langs` | String | `all` | Comma-separated language codes to download. 2- or 3-letter codes are both accepted (`en` / `eng`). See the table below. | -| `--extensions` | String | `mp4,mkv,avi` | Comma-separated list of video extensions to look for. | -| `--path` | String | current directory | Directory to scan for video files. | -| `--recursive` / `--no-recursive` | Boolean | `true` | Whether to descend into sub-folders (the output `subs/` folder is always skipped). | -| `--use-subs` | Boolean | `false` | Save subtitles under a `subs/` folder instead of next to the video file. | -| `--provider` | String | `opensubtitles` | Subtitle provider to use. | -| `--save` | Flag | – | Persist the supplied options to `settings.json` as the new defaults. | -| `--settings` | Flag | – | Print the effective settings and exit. | -| `--debug` | Flag | – | Verbose logging. | - -> **Legacy flags** — the old single-dash style (`-langs=eng,ita`, `-recursive=false`, `-useSubs`, -> `-path=…`, `-save`, `-debug`, `-settings`) is still accepted and mapped to the options above. - -A subtitle is skipped when its target file (`Movie..srt`) already exists. - -### Valid languages - -| Language | Value | -|------------- |--------------| -| English | eng | -| Italiano | ita | -| French | fre | -| German | ger | -| Spanish | spa | -| Arabic | ara | -| Afrikaans | afr | -| Albanian | alb | -| Armenian | arm | -| Basque | baq | -| Belarusian | bel | -| Bengali | ben | -| Bosnian | bos | -| Breton | bre | -| Bulgarian | bul | -| Burmese | bur | -| Catalan | cat | -| Chinese (simplified) | chi | -| Croatian | hr | -| Czech | cze | -| Danish | dan | -| Dutch | dut | -| Esperanto | epo | -| Estonian | est | -| Finnish | fin | -| Galician | glg | -| Georgian | geo | -| Greek | ell | -| Hebrew | heb | -| Hindi | hin | -| Hungarian | hun | -| Icelandic | ice | -| Indonesian | ind | -| Japanese | jpn | -| Kazakh | kaz | -| Khmer | khm | -| Korean | kor | -| Latvian | lav | -| Lithuanian | lit | -| Luxembourgish | ltz | -| Macedonian | mac | -| Malay | may | -| Malayalam | mal | -| Mongolian | mon | -| Norwegian | nor | -| Occitan | oci | -| Persian | per | -| Polish | pol | -| Portuguese | por | -| Portuguese (BR) | pob | -| Romanian | rum | -| Russian | rus | -| Serbian | scc | -| Sinhalese | sin | -| Slovak | slo | -| Slovenian | slv | -| Swahili | swa | -| Swedish | swe | -| Syriac | syr | -| Tamil | tam | -| Telugu | tel | -| Thai | tha | -| Turkish | tur | -| Ukrainian | ukr | -| Urdu | urd | -| Vietnamese | vie | - -## Usage Examples - -### Search all subtitles with the default settings. +| `--langs ` | string | `all` | Comma-separated language codes to download. 2- and 3-letter codes are both accepted (`en` = `eng`). `all` downloads every available language. | +| `--extensions ` | string | `mp4,mkv,avi` | Comma-separated video extensions to look for. | +| `--path ` | string | current directory | Directory to scan. | +| `--recursive` / `--no-recursive` | boolean | `true` | Descend into sub-folders. The output `subs/` folder is always skipped. | +| `--use-subs` | flag | off | Save subtitles into a `subs/` sub-folder instead of next to the video. | +| `--provider ` | string | `opensubtitles` | Subtitle back-end to use. | +| `--save` | flag | – | Persist the supplied options to `./settings.json` as the new defaults, then continue. | +| `--settings` | flag | – | Print the effective settings and exit. | +| `--debug` | flag | – | Verbose logging. | +| `--version` / `--help` | flag | – | Print version / help and exit. | -```shell -subcinode -``` +`subcinode` exits `0` on success and `1` if any download failed or a fatal error occurred (for +example, a missing API key). -### Search all English and Italian subtitles for any MP4 or AVI video file in the User Downloads folder, not recursively. +### Legacy flags + +The single-dash style from older versions still works and is mapped to the options above: ```shell -subcinode --langs eng,ita --no-recursive --extensions mp4,avi --path "/Users/my.user/Downloads" +subcinode -langs=eng,ita -recursive=false -useSubs -path=/movies -save -debug -settings ``` -### Search with specific settings and save them as default +--- + +## Configuration (`settings.json`) + +Running with `--save` writes the current options to `settings.json` in the working directory: ```shell subcinode --save --langs eng,ita --no-recursive --extensions mp4 ``` -So, from that moment on, it is possible to write +After that, a bare `subcinode` in the same directory reuses those defaults. Precedence is: -```shell -subcinode ``` - to perform the search with the default saved settings. +built-in defaults < ./settings.json < command-line flags +``` + +`settings.json` is git-ignored by this repo and should not contain secrets — credentials always come +from the environment. + +--- -### Show the current settings ( and terminate the program ) +## Examples ```shell +# Every language, default settings, current folder (recursive) +subcinode + +# English + Italian for MP4/AVI in a specific folder, non-recursive +subcinode --langs eng,ita --no-recursive --extensions mp4,avi --path "/Users/me/Downloads" + +# Keep subtitles in a subs/ folder +subcinode --langs eng --use-subs + +# Save these as the defaults for this folder, then run +subcinode --save --langs eng,ita --no-recursive --extensions mp4 + +# Show what settings would be used subcinode --settings ``` -## Development +--- -```shell -npm install -npm test # runs the node:test suite -``` +## Language codes -The code is plain ESM under `src/` with a thin CLI in `bin/subcinode.js`. Subtitle back-ends live in -`src/providers/` and implement `init()`, `search(fileInfo, opts)` and `resolveDownloadUrl(result)`; -register a new one with `registerProvider(name, ProviderClass)`. +`--langs` accepts ISO 639 2- or 3-letter codes. Common values: -## Changelog +| Language | Code | Language | Code | Language | Code | +|---|---|---|---|---|---| +| English | `eng` | Italian | `ita` | French | `fre` | +| German | `ger` | Spanish | `spa` | Portuguese | `por` | +| Portuguese (BR) | `pob` | Dutch | `dut` | Polish | `pol` | +| Russian | `rus` | Arabic | `ara` | Hebrew | `heb` | +| Greek | `ell` | Turkish | `tur` | Czech | `cze` | +| Danish | `dan` | Finnish | `fin` | Swedish | `swe` | +| Norwegian | `nor` | Romanian | `rum` | Hungarian | `hun` | +| Chinese | `chi` | Japanese | `jpn` | Korean | `kor` | +| Hindi | `hin` | Thai | `tha` | Vietnamese | `vie` | +| Indonesian | `ind` | Ukrainian | `ukr` | Croatian | `hr` | -### Version 2.0.0 -* Rewritten as ESM with `async`/`await` and split into small modules; requires Node >= 20. -* Switched to the OpenSubtitles **REST API** (the old XML-RPC endpoint was retired). An API key is - now required. -* Pluggable provider layer (`src/providers/`). -* Modern `--flag value` CLI via `commander`; the legacy single-dash flags still work. -* Dropped the `async`, `http`, `jsonfile` and `subtitles-parser` dependencies; movie hashing is now - built in; SRT handling moved to the maintained `subtitle` package. -* Fixed: crash on the default `langs: all` path, an always-true language filter, a broken - `-extensions=` parser, `http` downloads of HTTPS links, a read-before-write race on the promo - caption, and mutation of the shared default settings. -* Added a `node:test` test suite. +The full list of codes OpenSubtitles supports is at +. -### Version 1.1.3 -* Fixed dependency problems +--- -### Version 1.1.2 -* Fixed endsWith problem for some users +## Programmatic use -### Version 1.1.1 -* Removed unnecessary files +`subcinode` is plain ESM and can be driven from code: -### Version 1.1.0 -* Added subtitle parsing -* Added -settings parameter +```js +import { run } from 'subcinode'; -### Version 1.0.1 -* Minor documentation fixes. +const result = await run( + { cli: { langs: ['eng'], path: '/movies', useSubs: true } }, + { version: '2.0.0', env: process.env } +); -### Version 1.0.0 +console.log(result.downloaded); // string[] of written paths +console.log(result.errors); // [{ file, error }] +``` -* Major version bump -* Fixed -langs settings bug -* Added possibility to set default settings. +### Adding a provider -### Version 0.0.7 +A provider is a class implementing three async methods: -* Renamed package as subcinode. +```js +class MyProvider { + get name() { return 'myprovider'; } -### Version 0.0.6 + // Validate credentials, obtain tokens, etc. + async init() {} -* Major refactoring. + // fileInfo: { moviehash, moviebytesize } + // opts: { languages: string[] | string } ("all" / [] means every language) + // returns: [{ langId, fileId, fileName, downloadCount }] + async search(fileInfo, opts) {} -### Version 0.0.5 + // result: one entry from search() + // returns: { url, fileName } + async resolveDownloadUrl(result) {} +} +``` -* Added license, documentation, readme, changelog and authors. -* Fixed multiple bugs. +Register it, then select it with `--provider`: -### Version 0.0.4 +```js +import { registerProvider } from 'subcinode/providers'; +import { MyProvider } from './my-provider.js'; -* Minor fixes to allow npm package installed globally. +registerProvider('myprovider', MyProvider); +``` -### Version 0.0.3 +--- -* Implemented complete workflow +## Development -### Version 0.0.2 +```shell +git clone https://github.com/alexis89x/subcinode.git +cd subcinode +npm install +npm test # node:test suite, no test framework needed +``` -* Added directory tree navigation +Layout: + +``` +bin/subcinode.js CLI entry point (arg parsing + wiring) +src/run.js orchestrator: scan → hash → search → download → tag +src/config.js defaults, settings.json, credentials from env +src/args.js commander setup + legacy-flag shim +src/files.js directory walking, filename helpers, language normalisation +src/hash.js OSDb movie hash +src/download.js streaming HTTPS download (redirects, gzip, atomic-ish) +src/promo.js the "Downloaded with subcinode" caption +src/providers/ subtitle back-ends (opensubtitles.js) + registry +test/ one *.test.js per module +``` -### Version 0.0.1 +--- -* Preliminary tests with nodeJS and npm +## Changelog +### 2.0.0 +- Rewritten as ESM with `async`/`await`, split into small single-purpose modules; **requires Node ≥ 20**. +- Switched to the OpenSubtitles **REST API** — the legacy XML-RPC endpoint was retired. An API key is + now required. +- Pluggable provider layer (`src/providers/`, `subcinode/providers`). +- Modern `--flag value` CLI via `commander`; legacy single-dash flags still accepted. +- Dropped the `async`, `http`, `jsonfile` and `subtitles-parser` dependencies. Movie hashing is now + built in; SRT handling uses the maintained `subtitle` package. +- Fixed: crash on the default `langs: all` path; an always-true language filter; a broken + `-extensions=` parser; HTTP downloads of HTTPS links; a read-before-write race on the subtitle + tag; mutation of the shared defaults object. +- Removed references to the defunct `www.subcino.com`. +- Added a `node:test` test suite. -## License +### 1.1.3 +- Fixed dependency problems -The MIT License (MIT) +### 1.1.2 +- Fixed `endsWith` problem for some users -Copyright (c) 2015 Alessandro Piana +### 1.1.1 +- Removed unnecessary files -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +### 1.1.0 +- Added subtitle parsing +- Added `-settings` parameter -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +### 1.0.1 +- Minor documentation fixes + +### 1.0.0 +- Major version bump +- Fixed `-langs` settings bug +- Added the ability to save default settings + +### 0.0.x +- Initial development: directory-tree navigation, full workflow, global install, license/docs. + +--- + +## Authors + +- Alessandro Piana ([@alexis89x](https://github.com/alexis89x)) — lead +- Matteo Silvestri ([@matteosilv](https://github.com/matteosilv)) +- Paola Piatti + +--- + +## License -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +[MIT](LICENSE.txt) © 2015 Alessandro Piana diff --git a/package.json b/package.json index 1691dcd..3842160 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "subcinode", "version": "2.0.0", - "description": "Your subs, now.", + "description": "Automatically download the right subtitles for your local video files, from the console.", "type": "module", "keywords": [ "subtitles", @@ -28,6 +28,11 @@ "node": ">=20" }, "main": "src/run.js", + "exports": { + ".": "./src/run.js", + "./providers": "./src/providers/index.js", + "./package.json": "./package.json" + }, "bin": { "subcinode": "bin/subcinode.js" }, diff --git a/src/promo.js b/src/promo.js index 47bf31e..449ab54 100644 --- a/src/promo.js +++ b/src/promo.js @@ -1,6 +1,6 @@ import { parseSync, stringifySync } from 'subtitle'; -export const PROMO_TEXT = 'Downloaded with Subcino [www.subcino.com]'; +export const PROMO_TEXT = 'Downloaded with subcinode - https://github.com/alexis89x/subcinode'; const MIN_GAP_MS = 3000; // only fill gaps longer than this const PROMO_PADDING_MS = 500; // keep clear of the surrounding cues diff --git a/src/run.js b/src/run.js index 9410995..11b7c88 100644 --- a/src/run.js +++ b/src/run.js @@ -148,7 +148,7 @@ export async function run(parsed = {}, deps = {}) { `${errors.length} ${errors.length === 1 ? 'error' : 'errors'}.]`, logLevels.ALL ); - log('Thanks for using Subcino. Please consider to donate at www.subcino.com!', logLevels.ALL); + log('Thanks for using subcinode! https://github.com/alexis89x/subcinode', logLevels.ALL); return { settings, downloaded, errors }; }