ani-auto 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -0
- package/package.json +23 -0
- package/src/anilist.js +132 -0
- package/src/cli.js +517 -0
- package/src/config.js +79 -0
- package/src/daemon.js +75 -0
- package/src/db.js +232 -0
- package/src/engine.js +343 -0
- package/src/scraper.js +137 -0
- package/utils.js +118 -0
package/src/db.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* db.js — SQLite state layer using sql.js (pure WASM, no native compilation).
|
|
5
|
+
* Drop-in replacement for the better-sqlite3 version.
|
|
6
|
+
* Compatible with Node v25+ without any C++ compilation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
|
|
13
|
+
const DB_DIR = path.join(os.homedir(), '.config', 'ani-auto');
|
|
14
|
+
const DB_PATH = path.join(DB_DIR, 'state.db');
|
|
15
|
+
|
|
16
|
+
let _db = null;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Async init — call once at startup before any DB operations.
|
|
20
|
+
*/
|
|
21
|
+
async function initDb() {
|
|
22
|
+
if (_db) return _db;
|
|
23
|
+
|
|
24
|
+
if (!fs.existsSync(DB_DIR)) fs.mkdirSync(DB_DIR, { recursive: true });
|
|
25
|
+
|
|
26
|
+
const initSqlJs = require('sql.js');
|
|
27
|
+
const SQL = await initSqlJs();
|
|
28
|
+
|
|
29
|
+
if (fs.existsSync(DB_PATH)) {
|
|
30
|
+
const fileBuffer = fs.readFileSync(DB_PATH);
|
|
31
|
+
_db = new SQL.Database(fileBuffer);
|
|
32
|
+
} else {
|
|
33
|
+
_db = new SQL.Database();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
migrate(_db);
|
|
37
|
+
return _db;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function _save() {
|
|
41
|
+
if (!_db) return;
|
|
42
|
+
const data = _db.export();
|
|
43
|
+
fs.writeFileSync(DB_PATH, Buffer.from(data));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function migrate(db) {
|
|
47
|
+
db.run(`
|
|
48
|
+
CREATE TABLE IF NOT EXISTS anime (
|
|
49
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
50
|
+
title TEXT NOT NULL,
|
|
51
|
+
site_id TEXT NOT NULL UNIQUE,
|
|
52
|
+
anilist_id INTEGER,
|
|
53
|
+
source TEXT NOT NULL DEFAULT 'anilist',
|
|
54
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
55
|
+
quality TEXT,
|
|
56
|
+
language TEXT,
|
|
57
|
+
confirmed_site_id TEXT,
|
|
58
|
+
confirmed_site_title TEXT,
|
|
59
|
+
added_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
60
|
+
)
|
|
61
|
+
`);
|
|
62
|
+
db.run(`
|
|
63
|
+
CREATE TABLE IF NOT EXISTS episodes (
|
|
64
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
65
|
+
anime_id INTEGER NOT NULL,
|
|
66
|
+
episode_num INTEGER NOT NULL,
|
|
67
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
68
|
+
filename TEXT,
|
|
69
|
+
downloaded_at TEXT,
|
|
70
|
+
error TEXT,
|
|
71
|
+
UNIQUE(anime_id, episode_num)
|
|
72
|
+
)
|
|
73
|
+
`);
|
|
74
|
+
// Add confirmed_site columns if upgrading from older DB
|
|
75
|
+
try { db.run('ALTER TABLE anime ADD COLUMN confirmed_site_id TEXT'); } catch(e) {}
|
|
76
|
+
try { db.run('ALTER TABLE anime ADD COLUMN confirmed_site_title TEXT'); } catch(e) {}
|
|
77
|
+
|
|
78
|
+
db.run(`
|
|
79
|
+
CREATE TABLE IF NOT EXISTS run_log (
|
|
80
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
81
|
+
ran_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
82
|
+
downloaded INTEGER NOT NULL DEFAULT 0,
|
|
83
|
+
skipped INTEGER NOT NULL DEFAULT 0,
|
|
84
|
+
failed INTEGER NOT NULL DEFAULT 0,
|
|
85
|
+
notes TEXT
|
|
86
|
+
)
|
|
87
|
+
`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
function _assertReady() {
|
|
93
|
+
if (!_db) throw new Error('DB not initialised — call initDb() first at startup.');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function _get(sql, params = []) {
|
|
97
|
+
_assertReady();
|
|
98
|
+
const stmt = _db.prepare(sql);
|
|
99
|
+
stmt.bind(params);
|
|
100
|
+
const row = stmt.step() ? stmt.getAsObject() : null;
|
|
101
|
+
stmt.free();
|
|
102
|
+
return row;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function _all(sql, params = []) {
|
|
106
|
+
_assertReady();
|
|
107
|
+
const results = [];
|
|
108
|
+
const stmt = _db.prepare(sql);
|
|
109
|
+
stmt.bind(params);
|
|
110
|
+
while (stmt.step()) results.push(stmt.getAsObject());
|
|
111
|
+
stmt.free();
|
|
112
|
+
return results;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function _run(sql, params = []) {
|
|
116
|
+
_assertReady();
|
|
117
|
+
_db.run(sql, params);
|
|
118
|
+
_save();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Anime ──────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
function upsertAnime({ title, siteId, anilistId, source, quality, language }) {
|
|
124
|
+
_run(`
|
|
125
|
+
INSERT INTO anime (title, site_id, anilist_id, source, quality, language)
|
|
126
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
127
|
+
ON CONFLICT(site_id) DO UPDATE SET
|
|
128
|
+
title = excluded.title,
|
|
129
|
+
anilist_id = COALESCE(excluded.anilist_id, anime.anilist_id),
|
|
130
|
+
quality = COALESCE(excluded.quality, anime.quality),
|
|
131
|
+
language = COALESCE(excluded.language, anime.language)
|
|
132
|
+
`, [title, siteId || title, anilistId || null, source || 'anilist', quality || null, language || null]);
|
|
133
|
+
return _get('SELECT * FROM anime WHERE site_id = ?', [siteId || title]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function getAnime(siteId) {
|
|
137
|
+
return _get('SELECT * FROM anime WHERE site_id = ?', [siteId]);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function getAllEnabledAnime() {
|
|
141
|
+
return _all('SELECT * FROM anime WHERE enabled = 1');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function setAnimeEnabled(siteId, enabled) {
|
|
145
|
+
_run('UPDATE anime SET enabled = ? WHERE site_id = ?', [enabled ? 1 : 0, siteId]);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function removeAnime(siteId) {
|
|
149
|
+
const row = _get('SELECT id FROM anime WHERE site_id = ?', [siteId]);
|
|
150
|
+
if (!row) return;
|
|
151
|
+
_run('DELETE FROM episodes WHERE anime_id = ?', [row.id]);
|
|
152
|
+
_run('DELETE FROM anime WHERE id = ?', [row.id]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Episodes ───────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
function isEpisodeDownloaded(animeDbId, episodeNum) {
|
|
158
|
+
return !!_get(
|
|
159
|
+
"SELECT id FROM episodes WHERE anime_id = ? AND episode_num = ? AND status = 'done'",
|
|
160
|
+
[animeDbId, episodeNum]
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function markEpisodeStatus(animeDbId, episodeNum, status, filename = null, error = null) {
|
|
165
|
+
_run(`
|
|
166
|
+
INSERT INTO episodes (anime_id, episode_num, status, filename, downloaded_at, error)
|
|
167
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
168
|
+
ON CONFLICT(anime_id, episode_num) DO UPDATE SET
|
|
169
|
+
status = excluded.status,
|
|
170
|
+
filename = COALESCE(excluded.filename, episodes.filename),
|
|
171
|
+
downloaded_at = excluded.downloaded_at,
|
|
172
|
+
error = excluded.error
|
|
173
|
+
`, [
|
|
174
|
+
animeDbId, episodeNum, status, filename || null,
|
|
175
|
+
status === 'done' ? new Date().toISOString() : null,
|
|
176
|
+
error || null,
|
|
177
|
+
]);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function getEpisodeStats(animeDbId) {
|
|
181
|
+
return {
|
|
182
|
+
done: (_get("SELECT COUNT(*) as c FROM episodes WHERE anime_id = ? AND status = 'done'", [animeDbId])?.c || 0),
|
|
183
|
+
failed: (_get("SELECT COUNT(*) as c FROM episodes WHERE anime_id = ? AND status = 'failed'", [animeDbId])?.c || 0),
|
|
184
|
+
pending: (_get("SELECT COUNT(*) as c FROM episodes WHERE anime_id = ? AND status = 'pending'", [animeDbId])?.c || 0),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Run log ────────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
function logRun({ downloaded, skipped, failed, notes }) {
|
|
191
|
+
_run('INSERT INTO run_log (downloaded, skipped, failed, notes) VALUES (?, ?, ?, ?)',
|
|
192
|
+
[downloaded, skipped, failed, notes || null]);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function getRecentRuns(limit = 10) {
|
|
196
|
+
return _all('SELECT * FROM run_log ORDER BY id DESC LIMIT ?', [limit]);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function getDbInstance() {
|
|
200
|
+
_assertReady();
|
|
201
|
+
return _db;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function getAnimeBySiteTitle(title) {
|
|
205
|
+
return _get('SELECT * FROM anime WHERE LOWER(title) = LOWER(?)', [title]);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function saveConfirmedSiteMatch(title, confirmedSiteId, confirmedSiteTitle) {
|
|
209
|
+
// Try to update existing row first
|
|
210
|
+
const existing = _get('SELECT id FROM anime WHERE LOWER(title) = LOWER(?)', [title]);
|
|
211
|
+
if (existing) {
|
|
212
|
+
_run(
|
|
213
|
+
'UPDATE anime SET confirmed_site_id = ?, confirmed_site_title = ? WHERE id = ?',
|
|
214
|
+
[confirmedSiteId, confirmedSiteTitle, existing.id]
|
|
215
|
+
);
|
|
216
|
+
} else {
|
|
217
|
+
// No row yet — insert one using confirmedSiteId as the site_id
|
|
218
|
+
_run(
|
|
219
|
+
'INSERT OR IGNORE INTO anime (title, site_id, confirmed_site_id, confirmed_site_title, source) VALUES (?, ?, ?, ?, ?)',
|
|
220
|
+
[title, confirmedSiteId, confirmedSiteId, confirmedSiteTitle, 'anilist']
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
_save();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = {
|
|
227
|
+
initDb,
|
|
228
|
+
getDb: getDbInstance,
|
|
229
|
+
upsertAnime, getAnime, getAnimeBySiteTitle, saveConfirmedSiteMatch, getAllEnabledAnime, setAnimeEnabled, removeAnime,
|
|
230
|
+
isEpisodeDownloaded, markEpisodeStatus, getEpisodeStats,
|
|
231
|
+
logRun, getRecentRuns,
|
|
232
|
+
};
|
package/src/engine.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const chalk = require('chalk');
|
|
6
|
+
const inquirer = require('inquirer');
|
|
7
|
+
const cliProgress = require('cli-progress');
|
|
8
|
+
|
|
9
|
+
const { loadConfig } = require('./config');
|
|
10
|
+
const db = require('./db');
|
|
11
|
+
const { getUserList, pickTitle } = require('./anilist');
|
|
12
|
+
const scraper = require('./scraper');
|
|
13
|
+
|
|
14
|
+
// ── Logging ────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
function info(msg) { console.log(`${chalk.gray(ts())} ${chalk.cyan('ℹ')} ${msg}`); }
|
|
17
|
+
function ok(msg) { console.log(`${chalk.gray(ts())} ${chalk.green('✔')} ${msg}`); }
|
|
18
|
+
function warn(msg) { console.log(`${chalk.gray(ts())} ${chalk.yellow('⚠')} ${msg}`); }
|
|
19
|
+
function err(msg) { console.log(`${chalk.gray(ts())} ${chalk.red('✖')} ${msg}`); }
|
|
20
|
+
function ts() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
|
|
21
|
+
|
|
22
|
+
// ── Filename helpers ───────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function buildFilename(title, episodeNum, season = null) {
|
|
25
|
+
const s = season ? `Season ${season} ` : '';
|
|
26
|
+
return `${title} ${s}Episode ${episodeNum}.mp4`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buildFolderName(title) {
|
|
30
|
+
return title.replace(/[<>:"/\\|?*]/g, '').trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function detectSeason(title) {
|
|
34
|
+
const m = title.match(/\bseason\s*(\d+)\b/i)
|
|
35
|
+
|| title.match(/\b(\d+)(?:st|nd|rd|th)\s+season\b/i)
|
|
36
|
+
|| title.match(/\bS(\d{2})\b/);
|
|
37
|
+
return m ? parseInt(m[1], 10) : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Watch list ─────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
async function resolveWatchList(config) {
|
|
43
|
+
const items = [];
|
|
44
|
+
|
|
45
|
+
if (config.anilistUsername && config.watchStatuses?.length) {
|
|
46
|
+
info(`Fetching AniList list for ${chalk.bold(config.anilistUsername)} (${config.watchStatuses.join(', ')})...`);
|
|
47
|
+
try {
|
|
48
|
+
const entries = await getUserList(
|
|
49
|
+
config.anilistUsername,
|
|
50
|
+
config.watchStatuses,
|
|
51
|
+
config.anilistToken || null
|
|
52
|
+
);
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
items.push({
|
|
55
|
+
title: pickTitle(entry.titles),
|
|
56
|
+
anilistId: entry.anilistId,
|
|
57
|
+
source: 'anilist',
|
|
58
|
+
quality: null,
|
|
59
|
+
language: null,
|
|
60
|
+
watchedEpisodes: entry.progress || 0,
|
|
61
|
+
knownEpisodes: entry.totalEpisodes,
|
|
62
|
+
animeStatus: entry.animeStatus,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
info(`AniList returned ${chalk.bold(entries.length)} entries.`);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
warn(`AniList fetch failed: ${e.message}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const manual of (config.manualList || [])) {
|
|
72
|
+
if (!manual.enabled) continue;
|
|
73
|
+
items.push({
|
|
74
|
+
title: manual.title,
|
|
75
|
+
anilistId: manual.anilistId || null,
|
|
76
|
+
source: 'manual',
|
|
77
|
+
quality: manual.quality || null,
|
|
78
|
+
language: manual.language || null,
|
|
79
|
+
watchedEpisodes: 0,
|
|
80
|
+
knownEpisodes: null,
|
|
81
|
+
animeStatus: null,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const seen = new Set();
|
|
86
|
+
return items.filter(item => {
|
|
87
|
+
const key = item.title.toLowerCase();
|
|
88
|
+
if (seen.has(key)) return false;
|
|
89
|
+
seen.add(key);
|
|
90
|
+
return true;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Anime selector prompt ──────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
async function promptAnimeSelection(watchList) {
|
|
97
|
+
// Show numbered list
|
|
98
|
+
console.log(chalk.bold('\n Available anime:'));
|
|
99
|
+
watchList.forEach((item, i) => {
|
|
100
|
+
const watched = item.watchedEpisodes ? chalk.gray(` (watched: ${item.watchedEpisodes})`) : '';
|
|
101
|
+
console.log(` ${chalk.cyan(i + 1)}. ${item.title}${watched}`);
|
|
102
|
+
});
|
|
103
|
+
console.log(` ${chalk.cyan('a')}. All`);
|
|
104
|
+
console.log('');
|
|
105
|
+
|
|
106
|
+
const { input } = await inquirer.prompt([{
|
|
107
|
+
type: 'input',
|
|
108
|
+
name: 'input',
|
|
109
|
+
message: 'Enter numbers to download (e.g. 1,3 or "a" for all):',
|
|
110
|
+
default: 'a',
|
|
111
|
+
validate: (val) => {
|
|
112
|
+
if (val.trim().toLowerCase() === 'a') return true;
|
|
113
|
+
const nums = val.split(',').map(s => parseInt(s.trim()));
|
|
114
|
+
if (nums.some(isNaN)) return 'Enter comma-separated numbers or "a"';
|
|
115
|
+
if (nums.some(n => n < 1 || n > watchList.length)) return `Numbers must be between 1 and ${watchList.length}`;
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
}]);
|
|
119
|
+
|
|
120
|
+
if (input.trim().toLowerCase() === 'a') return watchList;
|
|
121
|
+
const indices = input.split(',').map(s => parseInt(s.trim()) - 1);
|
|
122
|
+
return indices.map(i => watchList[i]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Per-anime download ─────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
async function processAnime(workItem, config, multiBar, auto = false) {
|
|
128
|
+
const q = workItem.quality || config.quality || '1080p';
|
|
129
|
+
const l = workItem.language || config.language || 'sub';
|
|
130
|
+
|
|
131
|
+
// Check if we already have a confirmed site match saved in DB
|
|
132
|
+
const existingRow = db.getAnimeBySiteTitle(workItem.title);
|
|
133
|
+
let siteAnime;
|
|
134
|
+
|
|
135
|
+
if (existingRow?.confirmed_site_id) {
|
|
136
|
+
// Already confirmed before — use saved match directly
|
|
137
|
+
siteAnime = { session: existingRow.confirmed_site_id, title: existingRow.confirmed_site_title };
|
|
138
|
+
info(`[${workItem.title}] Using saved site match: ${chalk.bold(siteAnime.title)}`);
|
|
139
|
+
} else if (auto) {
|
|
140
|
+
// Auto mode — use top search result, save it
|
|
141
|
+
const siteResults = await scraper.searchAll(workItem.title);
|
|
142
|
+
if (!siteResults.length) {
|
|
143
|
+
warn(`[${workItem.title}] Not found on site — skipping.`);
|
|
144
|
+
return { downloaded: 0, skipped: 0, failed: 0 };
|
|
145
|
+
}
|
|
146
|
+
siteAnime = siteResults[0];
|
|
147
|
+
db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title);
|
|
148
|
+
info(`[${workItem.title}] Auto-matched to: ${chalk.bold(siteAnime.title)}`);
|
|
149
|
+
} else {
|
|
150
|
+
// Search and ask user to confirm
|
|
151
|
+
const siteResults = await scraper.searchAll(workItem.title);
|
|
152
|
+
|
|
153
|
+
if (!siteResults.length) {
|
|
154
|
+
warn(`[${workItem.title}] Not found on site — skipping.`);
|
|
155
|
+
return { downloaded: 0, skipped: 0, failed: 0 };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const topResults = siteResults.slice(0, 5);
|
|
159
|
+
|
|
160
|
+
const { pick } = await inquirer.prompt([{
|
|
161
|
+
type: 'list',
|
|
162
|
+
name: 'pick',
|
|
163
|
+
message: `Site results for "${workItem.title}" — select correct match:`,
|
|
164
|
+
choices: [
|
|
165
|
+
...topResults.map(r => ({
|
|
166
|
+
name: `${r.title} ${chalk.gray(`(${r.year || '?'}) — ${r.episodes || '?'} eps`)}`,
|
|
167
|
+
value: r,
|
|
168
|
+
})),
|
|
169
|
+
{ name: chalk.yellow('Skip this anime'), value: null },
|
|
170
|
+
],
|
|
171
|
+
}]);
|
|
172
|
+
|
|
173
|
+
if (!pick) {
|
|
174
|
+
info(`[${workItem.title}] Skipped by user.`);
|
|
175
|
+
return { downloaded: 0, skipped: 0, failed: 0 };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
siteAnime = pick;
|
|
179
|
+
|
|
180
|
+
// Save confirmed match to DB so daemon never prompts again
|
|
181
|
+
db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title);
|
|
182
|
+
ok(`[${workItem.title}] Site match saved: ${chalk.bold(siteAnime.title)}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const dbRow = db.upsertAnime({
|
|
186
|
+
title: siteAnime.title || workItem.title,
|
|
187
|
+
siteId: siteAnime.session,
|
|
188
|
+
anilistId: workItem.anilistId || null,
|
|
189
|
+
source: workItem.source,
|
|
190
|
+
quality: workItem.quality || null,
|
|
191
|
+
language: workItem.language || null,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (!dbRow.enabled) {
|
|
195
|
+
info(`[${workItem.title}] Disabled — skipping.`);
|
|
196
|
+
return { downloaded: 0, skipped: 0, failed: 0 };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let episodes;
|
|
200
|
+
try {
|
|
201
|
+
episodes = await scraper.getEpisodes(siteAnime.session);
|
|
202
|
+
} catch (e) {
|
|
203
|
+
warn(`[${workItem.title}] Could not fetch episodes: ${e.message}`);
|
|
204
|
+
return { downloaded: 0, skipped: 0, failed: 1 };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!episodes.length) {
|
|
208
|
+
info(`[${workItem.title}] No episodes available yet.`);
|
|
209
|
+
return { downloaded: 0, skipped: 0, failed: 0 };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Sort episodes ascending by episode number
|
|
213
|
+
const sortedEpisodes = [...episodes].sort((a, b) => a.episode - b.episode);
|
|
214
|
+
|
|
215
|
+
// AniList progress = episodes watched *within this season/entry*.
|
|
216
|
+
// The site may use absolute episode numbers (e.g. S2E1 = EP25).
|
|
217
|
+
// So we skip the first N episodes by position, not by episode number.
|
|
218
|
+
const watched = workItem.watchedEpisodes || 0;
|
|
219
|
+
const unwatched = sortedEpisodes.slice(watched);
|
|
220
|
+
|
|
221
|
+
const toDownload = unwatched.filter(ep => !db.isEpisodeDownloaded(dbRow.id, ep.episode));
|
|
222
|
+
|
|
223
|
+
if (!toDownload.length) {
|
|
224
|
+
info(`[${workItem.title}] Nothing new (watched ${watched}/${sortedEpisodes.length} episodes).`);
|
|
225
|
+
return { downloaded: 0, skipped: episodes.length, failed: 0 };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
info(`[${workItem.title}] ${toDownload.length} unwatched episode(s) to download (Q:${q} L:${l}, skipping first ${watched}).`);
|
|
229
|
+
|
|
230
|
+
const cleanTitle = buildFolderName(siteAnime.title || workItem.title);
|
|
231
|
+
const season = detectSeason(cleanTitle);
|
|
232
|
+
const outputDir = path.join(config.outputDir, cleanTitle);
|
|
233
|
+
if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
|
|
234
|
+
|
|
235
|
+
const queue = [...toDownload];
|
|
236
|
+
const results = { downloaded: 0, skipped: 0, failed: 0 };
|
|
237
|
+
const concurrency = Math.min(config.concurrency || 2, queue.length);
|
|
238
|
+
const MAX_RETRIES = config.maxRetries || 3;
|
|
239
|
+
|
|
240
|
+
const workers = Array(concurrency).fill(0).map(async () => {
|
|
241
|
+
while (queue.length > 0) {
|
|
242
|
+
const ep = queue.shift();
|
|
243
|
+
let success = false;
|
|
244
|
+
let attempts = 0;
|
|
245
|
+
|
|
246
|
+
db.markEpisodeStatus(dbRow.id, ep.episode, 'downloading');
|
|
247
|
+
|
|
248
|
+
while (!success && attempts < MAX_RETRIES) {
|
|
249
|
+
attempts++;
|
|
250
|
+
try {
|
|
251
|
+
const links = await scraper.getDownloadLinks(siteAnime.session, ep.session);
|
|
252
|
+
const link = scraper.pickLink(links, q, l);
|
|
253
|
+
if (!link?.mp4Url) throw new Error('No suitable download link found');
|
|
254
|
+
|
|
255
|
+
const filename = buildFilename(cleanTitle, ep.episode, season);
|
|
256
|
+
await scraper.downloadEpisode(link.mp4Url, filename, outputDir, multiBar, ep.episode);
|
|
257
|
+
|
|
258
|
+
db.markEpisodeStatus(dbRow.id, ep.episode, 'done', filename);
|
|
259
|
+
ok(`[${cleanTitle}] EP${ep.episode} → ${filename}`);
|
|
260
|
+
success = true;
|
|
261
|
+
results.downloaded++;
|
|
262
|
+
} catch (e) {
|
|
263
|
+
if (attempts >= MAX_RETRIES) {
|
|
264
|
+
err(`[${workItem.title}] EP${ep.episode} failed: ${e.message}`);
|
|
265
|
+
db.markEpisodeStatus(dbRow.id, ep.episode, 'failed', null, e.message);
|
|
266
|
+
results.failed++;
|
|
267
|
+
} else {
|
|
268
|
+
warn(`[${workItem.title}] EP${ep.episode} attempt ${attempts} failed, retrying...`);
|
|
269
|
+
await sleep(2000 * attempts);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await Promise.all(workers);
|
|
277
|
+
return results;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── Main cycle ─────────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
async function runCycle({ auto = false } = {}) {
|
|
283
|
+
await db.initDb();
|
|
284
|
+
const config = loadConfig();
|
|
285
|
+
|
|
286
|
+
console.log(chalk.bold.red('\n ╔══════════════════════════════╗'));
|
|
287
|
+
console.log(chalk.bold.red(' ║ ANI-AUTO — CHECK RUN ║'));
|
|
288
|
+
console.log(chalk.bold.red(' ╚══════════════════════════════╝\n'));
|
|
289
|
+
|
|
290
|
+
const watchList = await resolveWatchList(config);
|
|
291
|
+
|
|
292
|
+
if (!watchList.length) {
|
|
293
|
+
warn('Watch list is empty. Add entries via AniList or `ani-auto add`.');
|
|
294
|
+
db.logRun({ downloaded: 0, skipped: 0, failed: 0, notes: 'empty watch list' });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
let selected;
|
|
299
|
+
if (auto) {
|
|
300
|
+
selected = watchList;
|
|
301
|
+
info(`Auto mode: processing all ${chalk.bold(watchList.length)} anime.`);
|
|
302
|
+
} else {
|
|
303
|
+
selected = await promptAnimeSelection(watchList);
|
|
304
|
+
if (!selected.length) {
|
|
305
|
+
warn('No anime selected — exiting.');
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
info(`Processing ${chalk.bold(selected.length)} anime.`);
|
|
311
|
+
|
|
312
|
+
const multiBar = new cliProgress.MultiBar({
|
|
313
|
+
clearOnDone: false,
|
|
314
|
+
hideCursor: true,
|
|
315
|
+
format: `${chalk.blue('{bar}')} {percentage}% | {value_formatted}/{total_formatted} | EP:{episode}`,
|
|
316
|
+
barCompleteChar: '\u2588',
|
|
317
|
+
barIncompleteChar: '\u2591',
|
|
318
|
+
barsize: 30,
|
|
319
|
+
}, cliProgress.Presets.shades_classic);
|
|
320
|
+
|
|
321
|
+
const totals = { downloaded: 0, skipped: 0, failed: 0 };
|
|
322
|
+
|
|
323
|
+
for (const item of selected) {
|
|
324
|
+
const result = await processAnime(item, config, multiBar, auto);
|
|
325
|
+
totals.downloaded += result.downloaded;
|
|
326
|
+
totals.skipped += result.skipped;
|
|
327
|
+
totals.failed += result.failed;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
multiBar.stop();
|
|
331
|
+
|
|
332
|
+
console.log(chalk.bold('\n ── Run Summary ──────────────────────'));
|
|
333
|
+
console.log(` ${chalk.green('Downloaded:')} ${totals.downloaded}`);
|
|
334
|
+
console.log(` ${chalk.gray('Skipped:')} ${totals.skipped}`);
|
|
335
|
+
console.log(` ${chalk.red('Failed:')} ${totals.failed}`);
|
|
336
|
+
console.log(chalk.bold(' ─────────────────────────────────────\n'));
|
|
337
|
+
|
|
338
|
+
db.logRun(totals);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
342
|
+
|
|
343
|
+
module.exports = { runCycle, resolveWatchList };
|