ani-auto 1.1.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -1
- package/package.json +1 -1
- package/src/cli.js +36 -8
- package/src/config.js +22 -51
- package/src/daemon.js +14 -2
- package/src/engine.js +25 -4
- package/src/notify.js +90 -0
package/README.md
CHANGED
|
@@ -21,6 +21,25 @@ ani-auto check
|
|
|
21
21
|
|
|
22
22
|
---
|
|
23
23
|
|
|
24
|
+
## features
|
|
25
|
+
|
|
26
|
+
**1. anilist sync**
|
|
27
|
+
stays in sync with your watching list — only downloads episodes you haven't seen yet.
|
|
28
|
+
|
|
29
|
+
**2. quality control**
|
|
30
|
+
choose your preferred quality on each run or set a default.
|
|
31
|
+
|
|
32
|
+
**3. background daemon**
|
|
33
|
+
set it and forget it — checks for new episodes automatically on a schedule.
|
|
34
|
+
|
|
35
|
+
**4. pause & resume**
|
|
36
|
+
disable any anime at any time without losing your download history.
|
|
37
|
+
|
|
38
|
+
**5. telegram notifications**
|
|
39
|
+
get notified on your phone when episodes download, fail, or the daemon starts. message @userinfobot on telegram to get your chat id, then run `ani-auto setup`.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
24
43
|
## commands
|
|
25
44
|
|
|
26
45
|
```
|
|
@@ -48,7 +67,7 @@ Commands:
|
|
|
48
67
|
|
|
49
68
|
---
|
|
50
69
|
|
|
51
|
-
##
|
|
70
|
+
## configuration
|
|
52
71
|
|
|
53
72
|
```bash
|
|
54
73
|
ani-auto config --set-quality 1080p
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -89,6 +89,7 @@ program
|
|
|
89
89
|
{ type: 'number', name: 'concurrency', message: 'Parallel downloads per run (1-10):', default: current.concurrency || 2, validate: v => (v >= 1 && v <= 10) ? true : 'Enter a number 1–10' },
|
|
90
90
|
{ type: 'input', name: 'outputDir', message: 'Output directory for downloads:', default: current.outputDir },
|
|
91
91
|
{ type: 'number', name: 'daemonIntervalMinutes', message: 'Daemon polling interval (minutes):', default: current.daemonIntervalMinutes || 30 },
|
|
92
|
+
{ type: 'input', name: 'telegramChatId', message: 'Telegram chat ID for notifications (leave blank to skip):\n → Message @userinfobot on Telegram to get your ID:', default: current.telegramChatId || '' },
|
|
92
93
|
]);
|
|
93
94
|
answers.anilistUsername = anilistUsername;
|
|
94
95
|
answers.anilistToken = anilistToken;
|
|
@@ -101,14 +102,15 @@ program
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
updateConfig({
|
|
104
|
-
anilistUsername:
|
|
105
|
-
anilistToken:
|
|
106
|
-
watchStatuses:
|
|
107
|
-
quality:
|
|
108
|
-
language:
|
|
109
|
-
concurrency:
|
|
110
|
-
outputDir:
|
|
105
|
+
anilistUsername: answers.anilistUsername || null,
|
|
106
|
+
anilistToken: answers.anilistToken || null,
|
|
107
|
+
watchStatuses: answers.watchStatuses || current.watchStatuses,
|
|
108
|
+
quality: answers.quality,
|
|
109
|
+
language: answers.language,
|
|
110
|
+
concurrency: answers.concurrency,
|
|
111
|
+
outputDir: answers.outputDir,
|
|
111
112
|
daemonIntervalMinutes: answers.daemonIntervalMinutes,
|
|
113
|
+
telegramChatId: answers.telegramChatId || null,
|
|
112
114
|
});
|
|
113
115
|
|
|
114
116
|
console.log(chalk.green(`\n ✔ Config saved to ${CONFIG_FILE}\n`));
|
|
@@ -126,6 +128,7 @@ program
|
|
|
126
128
|
const config = loadConfig();
|
|
127
129
|
let quality = config.quality || '1080p';
|
|
128
130
|
let language = config.language || 'sub';
|
|
131
|
+
let range = null;
|
|
129
132
|
|
|
130
133
|
if (!opts.auto) {
|
|
131
134
|
const { pickedQuality, saveAsDefault } = await inquirer.prompt([
|
|
@@ -148,9 +151,34 @@ program
|
|
|
148
151
|
updateConfig({ quality });
|
|
149
152
|
console.log(chalk.green(` ✔ Saved default quality: ${quality}\n`));
|
|
150
153
|
}
|
|
154
|
+
|
|
155
|
+
const { useRange } = await inquirer.prompt([{
|
|
156
|
+
type: 'confirm',
|
|
157
|
+
name: 'useRange',
|
|
158
|
+
message: 'Download a specific episode range?',
|
|
159
|
+
default: false,
|
|
160
|
+
}]);
|
|
161
|
+
if (useRange) {
|
|
162
|
+
const { rangeInput } = await inquirer.prompt([{
|
|
163
|
+
type: 'input',
|
|
164
|
+
name: 'rangeInput',
|
|
165
|
+
message: 'Enter range (e.g. 1-24 or 5 for single episode):',
|
|
166
|
+
validate: (v) => {
|
|
167
|
+
const clean = v.trim();
|
|
168
|
+
if (/^\d+$/.test(clean)) return true;
|
|
169
|
+
if (/^\d+-\d+$/.test(clean)) {
|
|
170
|
+
const [a, b] = clean.split('-').map(Number);
|
|
171
|
+
if (a > b) return 'Start must be less than or equal to end';
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
return 'Enter a number (e.g. 5) or range (e.g. 1-24)';
|
|
175
|
+
}
|
|
176
|
+
}]);
|
|
177
|
+
range = rangeInput.trim();
|
|
178
|
+
}
|
|
151
179
|
}
|
|
152
180
|
|
|
153
|
-
await runCycle({ auto: !!opts.auto, quality, language });
|
|
181
|
+
await runCycle({ auto: !!opts.auto, quality, language, range });
|
|
154
182
|
}
|
|
155
183
|
catch (e) { console.error(chalk.red('[ERROR]'), e.message); process.exit(1); }
|
|
156
184
|
});
|
package/src/config.js
CHANGED
|
@@ -1,57 +1,37 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const fs
|
|
3
|
+
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
-
const os
|
|
5
|
+
const os = require('os');
|
|
6
6
|
|
|
7
|
-
const CONFIG_DIR
|
|
7
|
+
const CONFIG_DIR = path.join(os.homedir(), '.config', 'ani-auto');
|
|
8
8
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
9
9
|
|
|
10
10
|
const DEFAULTS = {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
concurrency: 2,
|
|
25
|
-
outputDir: path.join(os.homedir(), 'anime'),
|
|
26
|
-
maxRetries: 3,
|
|
27
|
-
|
|
28
|
-
// Scheduling
|
|
29
|
-
daemonIntervalMinutes: 30, // how often the daemon polls
|
|
30
|
-
cronSchedule: '0 */2 * * *', // every 2 hours (for cron mode)
|
|
31
|
-
|
|
32
|
-
// Behaviour
|
|
33
|
-
skipExisting: true, // skip episodes already in DB
|
|
34
|
-
maxEpisodesPerRun: 50, // safety cap per check cycle
|
|
11
|
+
anilistToken: null,
|
|
12
|
+
anilistUsername: null,
|
|
13
|
+
watchStatuses: ['CURRENT'],
|
|
14
|
+
manualList: [],
|
|
15
|
+
quality: '1080p',
|
|
16
|
+
language: 'sub',
|
|
17
|
+
concurrency: 2,
|
|
18
|
+
outputDir: path.join(os.homedir(), 'anime'),
|
|
19
|
+
maxRetries: 3,
|
|
20
|
+
daemonIntervalMinutes: 30,
|
|
21
|
+
cronSchedule: '0 */2 * * *',
|
|
22
|
+
skipExisting: true,
|
|
23
|
+
maxEpisodesPerRun: 50,
|
|
35
24
|
};
|
|
36
25
|
|
|
37
26
|
function ensureConfigDir() {
|
|
38
|
-
if (!fs.existsSync(CONFIG_DIR)) {
|
|
39
|
-
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
40
|
-
}
|
|
27
|
+
if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
41
28
|
}
|
|
42
29
|
|
|
43
30
|
function loadConfig() {
|
|
44
31
|
ensureConfigDir();
|
|
45
|
-
if (!fs.existsSync(CONFIG_FILE)) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
try {
|
|
50
|
-
const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
|
|
51
|
-
return { ...DEFAULTS, ...JSON.parse(raw) };
|
|
52
|
-
} catch {
|
|
53
|
-
return { ...DEFAULTS };
|
|
54
|
-
}
|
|
32
|
+
if (!fs.existsSync(CONFIG_FILE)) { saveConfig(DEFAULTS); return { ...DEFAULTS }; }
|
|
33
|
+
try { return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')) }; }
|
|
34
|
+
catch { return { ...DEFAULTS }; }
|
|
55
35
|
}
|
|
56
36
|
|
|
57
37
|
function saveConfig(config) {
|
|
@@ -60,20 +40,11 @@ function saveConfig(config) {
|
|
|
60
40
|
}
|
|
61
41
|
|
|
62
42
|
function updateConfig(partial) {
|
|
63
|
-
const
|
|
64
|
-
const updated = { ...current, ...partial };
|
|
43
|
+
const updated = { ...loadConfig(), ...partial };
|
|
65
44
|
saveConfig(updated);
|
|
66
45
|
return updated;
|
|
67
46
|
}
|
|
68
47
|
|
|
69
48
|
const getConfigPath = () => CONFIG_FILE;
|
|
70
49
|
|
|
71
|
-
module.exports = {
|
|
72
|
-
CONFIG_FILE,
|
|
73
|
-
CONFIG_DIR,
|
|
74
|
-
DEFAULTS,
|
|
75
|
-
loadConfig,
|
|
76
|
-
saveConfig,
|
|
77
|
-
updateConfig,
|
|
78
|
-
getConfigPath,
|
|
79
|
-
};
|
|
50
|
+
module.exports = { CONFIG_FILE, CONFIG_DIR, DEFAULTS, loadConfig, saveConfig, updateConfig, getConfigPath };
|
package/src/daemon.js
CHANGED
|
@@ -12,6 +12,7 @@ const chalk = require('chalk');
|
|
|
12
12
|
const { CronJob } = require('cron');
|
|
13
13
|
const { loadConfig } = require('./config');
|
|
14
14
|
const { runCycle } = require('./engine');
|
|
15
|
+
const notify = require('./notify');
|
|
15
16
|
|
|
16
17
|
let running = false; // prevents overlapping runs
|
|
17
18
|
|
|
@@ -52,6 +53,9 @@ async function startDaemon() {
|
|
|
52
53
|
console.log(chalk.cyan(` Output: ${config.outputDir}`));
|
|
53
54
|
console.log(chalk.gray(` Started: ${ts()}\n`));
|
|
54
55
|
|
|
56
|
+
// Notify daemon started
|
|
57
|
+
await notify.notifyDaemonStart(config.daemonIntervalMinutes || 30);
|
|
58
|
+
|
|
55
59
|
// Run immediately on start
|
|
56
60
|
await safeRun();
|
|
57
61
|
|
|
@@ -65,8 +69,16 @@ async function startDaemon() {
|
|
|
65
69
|
console.log(chalk.green(` Daemon running. Press Ctrl+C to stop.\n`));
|
|
66
70
|
|
|
67
71
|
// Graceful shutdown
|
|
68
|
-
process.on('SIGINT', () => {
|
|
69
|
-
|
|
72
|
+
process.on('SIGINT', async () => {
|
|
73
|
+
console.log(chalk.yellow('\n Shutting down daemon...'));
|
|
74
|
+
await notify.notifyDaemonStop();
|
|
75
|
+
process.exit(0);
|
|
76
|
+
});
|
|
77
|
+
process.on('SIGTERM', async () => {
|
|
78
|
+
console.log(chalk.yellow('\n Daemon terminated.'));
|
|
79
|
+
await notify.notifyDaemonStop();
|
|
80
|
+
process.exit(0);
|
|
81
|
+
});
|
|
70
82
|
}
|
|
71
83
|
|
|
72
84
|
startDaemon().catch(e => {
|
package/src/engine.js
CHANGED
|
@@ -11,6 +11,7 @@ const db = require('./db');
|
|
|
11
11
|
const { getUserList, pickTitle } = require('./anilist');
|
|
12
12
|
const scraper = require('./scraper');
|
|
13
13
|
const { version } = require('../package.json');
|
|
14
|
+
const notify = require('./notify');
|
|
14
15
|
|
|
15
16
|
// ── Logging ────────────────────────────────────────────────────────────────
|
|
16
17
|
|
|
@@ -130,7 +131,7 @@ async function promptAnimeSelection(watchList) {
|
|
|
130
131
|
|
|
131
132
|
// ── Per-anime download ─────────────────────────────────────────────────────
|
|
132
133
|
|
|
133
|
-
async function processAnime(workItem, config, multiBar, auto = false, runQuality = null, runLanguage = null) {
|
|
134
|
+
async function processAnime(workItem, config, multiBar, auto = false, runQuality = null, runLanguage = null, range = null) {
|
|
134
135
|
const q = runQuality || workItem.quality || config.quality || '1080p';
|
|
135
136
|
const l = runLanguage || workItem.language || config.language || 'sub';
|
|
136
137
|
|
|
@@ -215,7 +216,24 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
215
216
|
const sortedEpisodes = [...episodes].sort((a, b) => a.episode - b.episode);
|
|
216
217
|
const watched = workItem.watchedEpisodes || 0;
|
|
217
218
|
const unwatched = sortedEpisodes.slice(watched);
|
|
218
|
-
|
|
219
|
+
let toDownload = unwatched.filter(ep => !db.isEpisodeDownloaded(dbRow.id, ep.episode));
|
|
220
|
+
|
|
221
|
+
// Apply episode range filter if specified
|
|
222
|
+
if (range) {
|
|
223
|
+
const clean = range.trim();
|
|
224
|
+
let rangeFrom, rangeTo;
|
|
225
|
+
if (/^\d+$/.test(clean)) {
|
|
226
|
+
rangeFrom = rangeTo = parseInt(clean);
|
|
227
|
+
} else {
|
|
228
|
+
[rangeFrom, rangeTo] = clean.split('-').map(Number);
|
|
229
|
+
}
|
|
230
|
+
// Range is relative to the unwatched slice (1 = first unwatched episode)
|
|
231
|
+
toDownload = toDownload.filter((ep, idx) => {
|
|
232
|
+
const pos = idx + 1;
|
|
233
|
+
return pos >= rangeFrom && pos <= rangeTo;
|
|
234
|
+
});
|
|
235
|
+
info(`[${workItem.title}] Range filter: episodes ${rangeFrom}${rangeTo !== rangeFrom ? '–' + rangeTo : ''} (${toDownload.length} matched).`);
|
|
236
|
+
}
|
|
219
237
|
|
|
220
238
|
if (!toDownload.length) {
|
|
221
239
|
info(`[${workItem.title}] Nothing new (watched ${watched}/${sortedEpisodes.length} episodes).`);
|
|
@@ -263,12 +281,14 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
263
281
|
|
|
264
282
|
db.markEpisodeStatus(dbRow.id, ep.episode, 'done', filename);
|
|
265
283
|
ok(`[${cleanTitle}] EP${ep.episode} → ${filename}`);
|
|
284
|
+
await notify.notifyEpisode(cleanTitle, relativeEp || ep.episode);
|
|
266
285
|
success = true;
|
|
267
286
|
results.downloaded++;
|
|
268
287
|
} catch (e) {
|
|
269
288
|
if (attempts >= MAX_RETRIES) {
|
|
270
289
|
err(`[${workItem.title}] EP${ep.episode} failed: ${e.message}`);
|
|
271
290
|
db.markEpisodeStatus(dbRow.id, ep.episode, 'failed', null, e.message);
|
|
291
|
+
await notify.notifyFailed(workItem.title, ep.episode, e.message);
|
|
272
292
|
results.failed++;
|
|
273
293
|
} else {
|
|
274
294
|
warn(`[${workItem.title}] EP${ep.episode} attempt ${attempts} failed, retrying...`);
|
|
@@ -285,7 +305,7 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
285
305
|
|
|
286
306
|
// ── Main cycle ─────────────────────────────────────────────────────────────
|
|
287
307
|
|
|
288
|
-
async function runCycle({ auto = false, quality = null, language = null } = {}) {
|
|
308
|
+
async function runCycle({ auto = false, quality = null, language = null, range = null } = {}) {
|
|
289
309
|
await db.initDb();
|
|
290
310
|
const config = loadConfig();
|
|
291
311
|
|
|
@@ -327,7 +347,7 @@ async function runCycle({ auto = false, quality = null, language = null } = {})
|
|
|
327
347
|
const totals = { downloaded: 0, skipped: 0, failed: 0 };
|
|
328
348
|
|
|
329
349
|
for (const item of selected) {
|
|
330
|
-
const result = await processAnime(item, config, multiBar, auto, quality, language);
|
|
350
|
+
const result = await processAnime(item, config, multiBar, auto, quality, language, range);
|
|
331
351
|
totals.downloaded += result.downloaded;
|
|
332
352
|
totals.skipped += result.skipped;
|
|
333
353
|
totals.failed += result.failed;
|
|
@@ -342,6 +362,7 @@ async function runCycle({ auto = false, quality = null, language = null } = {})
|
|
|
342
362
|
console.log(chalk.bold(' ─────────────────────────────────────\n'));
|
|
343
363
|
|
|
344
364
|
db.logRun(totals);
|
|
365
|
+
await notify.notifySummary(totals);
|
|
345
366
|
}
|
|
346
367
|
|
|
347
368
|
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
package/src/notify.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const { loadConfig } = require('./config');
|
|
5
|
+
|
|
6
|
+
// Bot token is shared — each user only needs their own chat ID
|
|
7
|
+
// Users get their chat ID by messaging @anime_auto_downloader_bot on Telegram
|
|
8
|
+
const BOT_TOKEN = '8695132702:AAEFDoSL6nkdwDOoz5wmoVkmuhaYa4tNw0o';
|
|
9
|
+
|
|
10
|
+
// ── Telegram ───────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
async function sendTelegram(text) {
|
|
13
|
+
const config = loadConfig();
|
|
14
|
+
const chatId = config.telegramChatId;
|
|
15
|
+
if (!chatId) return;
|
|
16
|
+
try {
|
|
17
|
+
const fetch = require('node-fetch');
|
|
18
|
+
await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: { 'Content-Type': 'application/json' },
|
|
21
|
+
body: JSON.stringify({
|
|
22
|
+
chat_id: String(chatId),
|
|
23
|
+
text,
|
|
24
|
+
parse_mode: 'HTML',
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
27
|
+
} catch (e) {
|
|
28
|
+
// silently fail — never crash the download for a notification
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── KDE Desktop (notify-send) ──────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
function sendDesktop(title, body, urgency = 'normal') {
|
|
35
|
+
try {
|
|
36
|
+
// KDE uses notify-send from libnotify
|
|
37
|
+
execSync(`notify-send -u ${urgency} -a "ani-auto" "${title}" "${body}"`, { stdio: 'ignore' });
|
|
38
|
+
} catch (e) {
|
|
39
|
+
// silently fail if notify-send not available
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Public notification functions ─────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
async function notifyEpisode(animeTitle, episode) {
|
|
46
|
+
const msg = `✅ <b>${animeTitle}</b>\nEpisode ${episode} downloaded`;
|
|
47
|
+
const short = `Episode ${episode} downloaded`;
|
|
48
|
+
await sendTelegram(msg);
|
|
49
|
+
sendDesktop(animeTitle, short);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function notifyFailed(animeTitle, episode, error) {
|
|
53
|
+
const msg = `❌ <b>${animeTitle}</b>\nEpisode ${episode} failed: ${error}`;
|
|
54
|
+
const short = `Episode ${episode} failed`;
|
|
55
|
+
await sendTelegram(msg);
|
|
56
|
+
sendDesktop(animeTitle, short, 'critical');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function notifySummary(totals) {
|
|
60
|
+
if (totals.downloaded === 0 && totals.failed === 0) return; // skip empty runs
|
|
61
|
+
const msg =
|
|
62
|
+
`📋 <b>ani-auto — run complete</b>\n` +
|
|
63
|
+
`✅ Downloaded: ${totals.downloaded}\n` +
|
|
64
|
+
`⏭ Skipped: ${totals.skipped}\n` +
|
|
65
|
+
`❌ Failed: ${totals.failed}`;
|
|
66
|
+
const short = `↓${totals.downloaded} ✗${totals.failed}`;
|
|
67
|
+
await sendTelegram(msg);
|
|
68
|
+
sendDesktop('ani-auto run complete', short);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function notifyDaemonStart(intervalMinutes) {
|
|
72
|
+
const msg = `🚀 <b>ani-auto daemon started</b>\nChecking every ${intervalMinutes} minutes`;
|
|
73
|
+
const short = `Checking every ${intervalMinutes} minutes`;
|
|
74
|
+
await sendTelegram(msg);
|
|
75
|
+
sendDesktop('ani-auto daemon started', short);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function notifyDaemonStop() {
|
|
79
|
+
const msg = `🛑 <b>ani-auto daemon stopped</b>`;
|
|
80
|
+
await sendTelegram(msg);
|
|
81
|
+
sendDesktop('ani-auto daemon stopped', '');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = {
|
|
85
|
+
notifyEpisode,
|
|
86
|
+
notifyFailed,
|
|
87
|
+
notifySummary,
|
|
88
|
+
notifyDaemonStart,
|
|
89
|
+
notifyDaemonStop,
|
|
90
|
+
};
|