ani-auto 1.3.1 → 1.3.3
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/CHANGELOG.md +82 -0
- package/README.md +34 -17
- package/package.json +1 -1
- package/src/anilist.js +6 -24
- package/src/cli.js +35 -2
- package/src/config.js +2 -1
- package/src/daemon.js +18 -2
- package/src/db.js +1 -14
- package/src/engine.js +0 -6
- package/src/network.js +9 -43
- package/src/notify.js +27 -39
- package/src/scraper.js +14 -79
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [1.3.3]
|
|
4
|
+
### added
|
|
5
|
+
- daemon can be fully enabled or disabled — blocks all startup on boot and network restore when disabled
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## [1.3.1]
|
|
10
|
+
### fixed
|
|
11
|
+
- stability patch
|
|
12
|
+
|
|
13
|
+
### added
|
|
14
|
+
- poster images included in telegram and desktop notifications
|
|
15
|
+
- daemon and notification toggle
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## [1.3.0]
|
|
20
|
+
### added
|
|
21
|
+
- toggle to enable/disable the daemon without uninstalling it
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## [1.2.4]
|
|
26
|
+
### fixed
|
|
27
|
+
- removed node.js warnings on startup
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## [1.2.3]
|
|
32
|
+
### added
|
|
33
|
+
- check for updates and install latest version in one step
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## [1.2.2]
|
|
38
|
+
### added
|
|
39
|
+
- anilist watch list status selection on each download run (current, planning, paused, repeating)
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## [1.2.1]
|
|
44
|
+
### fixed
|
|
45
|
+
- banner design fix
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## [1.2.0]
|
|
50
|
+
### added
|
|
51
|
+
- desktop notifications (kde/linux)
|
|
52
|
+
- telegram notifications — episode downloaded, failed, run summary, daemon start/stop
|
|
53
|
+
- poster images in notifications
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## [1.1.0]
|
|
58
|
+
### added
|
|
59
|
+
- quality prompt on each download run
|
|
60
|
+
- pause downloads per anime while keeping history
|
|
61
|
+
- skips anime that already exist in your anilist list when adding manually
|
|
62
|
+
|
|
63
|
+
### fixed
|
|
64
|
+
- session handling fix
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## [1.0.4]
|
|
69
|
+
### fixed
|
|
70
|
+
- error name fix
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## [1.0.2]
|
|
75
|
+
### fixed
|
|
76
|
+
- daemon bug fix
|
|
77
|
+
- major bug fix
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## [1.0.0]
|
|
82
|
+
- initial release
|
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
# ani-auto
|
|
12
12
|
|
|
13
|
-
automatically downloads new anime episodes
|
|
13
|
+
automatically downloads new anime episodes. works with your AniList watching list or a manual list — no account required.
|
|
14
14
|
|
|
15
15
|
---
|
|
16
16
|
|
|
@@ -26,28 +26,42 @@ npm install -g ani-auto
|
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
28
|
ani-auto setup
|
|
29
|
-
ani-auto
|
|
29
|
+
ani-auto download
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
> **Tip:** connecting your AniList account lets the daemon know exactly when episodes air and download them automatically. without AniList, you can still add anime manually and the daemon will check every 2 hours.
|
|
33
|
+
|
|
32
34
|
---
|
|
33
35
|
|
|
34
36
|
## features
|
|
35
37
|
|
|
36
38
|
**1. anilist sync**
|
|
37
|
-
stays in sync with your watching list — only downloads episodes you haven't seen yet.
|
|
39
|
+
stays in sync with your watching list — only downloads episodes you haven't seen yet. supports multiple statuses (watching, paused, planning, repeating).
|
|
38
40
|
|
|
39
|
-
**2.
|
|
40
|
-
|
|
41
|
+
**2. manual list**
|
|
42
|
+
no AniList account? add any anime manually with `ani-auto add "title"` and it will be tracked and downloaded just like AniList entries.
|
|
41
43
|
|
|
42
|
-
**3.
|
|
43
|
-
|
|
44
|
+
**3. quality control**
|
|
45
|
+
choose your preferred quality on each run or set a default. set a different quality per anime with `ani-auto quality`.
|
|
44
46
|
|
|
45
|
-
**4.
|
|
46
|
-
|
|
47
|
+
**4. smart daemon**
|
|
48
|
+
connects to your AniList to know exactly when each episode airs and downloads at the right time. falls back to checking every 2 hours for manual list entries or when AniList is not connected.
|
|
49
|
+
|
|
50
|
+
**5. offline recovery**
|
|
51
|
+
if your device loses internet, the daemon detects it instantly and resumes the moment connection is restored.
|
|
52
|
+
|
|
53
|
+
**6. episode range**
|
|
54
|
+
download a specific range of episodes in one run — useful for catching up on a series.
|
|
47
55
|
|
|
48
|
-
**
|
|
56
|
+
**7. telegram notifications**
|
|
49
57
|
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`.
|
|
50
58
|
|
|
59
|
+
**8. pause & resume**
|
|
60
|
+
disable any anime at any time without losing your download history.
|
|
61
|
+
|
|
62
|
+
**9. retry failed episodes**
|
|
63
|
+
re-attempt all failed downloads in one command with `ani-auto retry`.
|
|
64
|
+
|
|
51
65
|
---
|
|
52
66
|
|
|
53
67
|
## commands
|
|
@@ -55,22 +69,22 @@ get notified on your phone when episodes download, fail, or the daemon starts. m
|
|
|
55
69
|
```
|
|
56
70
|
Usage: ani-auto [options] [command]
|
|
57
71
|
|
|
58
|
-
Automatic anime episode downloader — AniList-aware
|
|
59
|
-
|
|
60
72
|
Options:
|
|
61
73
|
-V, --version output the version number
|
|
62
74
|
-h, --help display help for command
|
|
63
75
|
|
|
64
76
|
Commands:
|
|
65
77
|
setup Interactive first-time configuration wizard
|
|
66
|
-
|
|
78
|
+
download Run one download cycle immediately
|
|
67
79
|
list Show all tracked anime and their episode download status
|
|
68
|
-
add [options] <title> Search AniList and add an anime to the manual
|
|
69
|
-
remove [title] Remove an anime from the manual list
|
|
80
|
+
add [options] <title> Search AniList and add an anime to the manual list
|
|
81
|
+
remove [title] Remove an anime from the manual list
|
|
70
82
|
enable Re-enable downloads for an anime
|
|
71
83
|
disable Pause downloads for an anime (keeps history)
|
|
84
|
+
quality [title] Set per-anime quality override
|
|
85
|
+
retry Re-attempt all failed episode downloads
|
|
86
|
+
status [options] Show recent run log or per-anime episode breakdown
|
|
72
87
|
notify [action] Enable or disable Telegram notifications (on, off)
|
|
73
|
-
status [options] Show recent run log
|
|
74
88
|
config [options] View, update, or open config file
|
|
75
89
|
update Check for updates and install the latest version
|
|
76
90
|
daemon [action] Manage the background daemon (install, start, stop, restart, status, logs)
|
|
@@ -85,7 +99,8 @@ Commands:
|
|
|
85
99
|
ani-auto config --set-quality 1080p
|
|
86
100
|
ani-auto config --set-concurrency 3
|
|
87
101
|
ani-auto config --set-output ~/videos/anime
|
|
88
|
-
ani-auto config --set-interval
|
|
102
|
+
ani-auto config --set-interval 120
|
|
103
|
+
ani-auto config --set-airing-buffer 5
|
|
89
104
|
ani-auto config --open
|
|
90
105
|
```
|
|
91
106
|
|
|
@@ -102,6 +117,8 @@ ani-auto daemon install
|
|
|
102
117
|
**other daemon commands:**
|
|
103
118
|
|
|
104
119
|
```bash
|
|
120
|
+
ani-auto daemon enable # enable daemon service
|
|
121
|
+
ani-auto daemon disable # disable daemon service
|
|
105
122
|
ani-auto daemon start # start the service
|
|
106
123
|
ani-auto daemon stop # stop the service
|
|
107
124
|
ani-auto daemon restart # restart the service
|
package/package.json
CHANGED
package/src/anilist.js
CHANGED
|
@@ -4,8 +4,6 @@ const fetch = require('node-fetch');
|
|
|
4
4
|
|
|
5
5
|
const ANILIST_GQL = 'https://graphql.anilist.co';
|
|
6
6
|
|
|
7
|
-
// ── Queries ────────────────────────────────────────────────────────────────
|
|
8
|
-
|
|
9
7
|
const MEDIA_LIST_QUERY = `
|
|
10
8
|
query ($userName: String, $status: MediaListStatus) {
|
|
11
9
|
MediaListCollection(userName: $userName, type: ANIME, status: $status) {
|
|
@@ -45,30 +43,23 @@ query ($search: String) {
|
|
|
45
43
|
}
|
|
46
44
|
}`;
|
|
47
45
|
|
|
48
|
-
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
49
|
-
|
|
50
46
|
async function gqlRequest(query, variables = {}, token = null) {
|
|
51
47
|
const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
|
|
52
48
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
53
|
-
|
|
54
49
|
const res = await fetch(ANILIST_GQL, {
|
|
55
50
|
method: 'POST',
|
|
56
51
|
headers,
|
|
57
52
|
body: JSON.stringify({ query, variables }),
|
|
58
53
|
});
|
|
59
|
-
|
|
60
54
|
if (!res.ok) {
|
|
61
55
|
const text = await res.text();
|
|
62
56
|
throw new Error(`AniList API error ${res.status}: ${text}`);
|
|
63
57
|
}
|
|
64
|
-
|
|
65
58
|
const json = await res.json();
|
|
66
59
|
if (json.errors) throw new Error(`AniList GQL error: ${json.errors[0].message}`);
|
|
67
60
|
return json.data;
|
|
68
61
|
}
|
|
69
62
|
|
|
70
|
-
// ── Public API ─────────────────────────────────────────────────────────────
|
|
71
|
-
|
|
72
63
|
async function getViewer(token) {
|
|
73
64
|
const data = await gqlRequest(VIEWER_QUERY, {}, token);
|
|
74
65
|
return data.Viewer;
|
|
@@ -77,7 +68,7 @@ async function getViewer(token) {
|
|
|
77
68
|
async function getUserList(userName, statuses, token = null) {
|
|
78
69
|
const results = [];
|
|
79
70
|
for (const status of statuses) {
|
|
80
|
-
const data
|
|
71
|
+
const data = await gqlRequest(MEDIA_LIST_QUERY, { userName, status }, token);
|
|
81
72
|
const lists = data?.MediaListCollection?.lists || [];
|
|
82
73
|
for (const list of lists) {
|
|
83
74
|
for (const entry of list.entries) {
|
|
@@ -100,10 +91,6 @@ async function getUserList(userName, statuses, token = null) {
|
|
|
100
91
|
return results;
|
|
101
92
|
}
|
|
102
93
|
|
|
103
|
-
/**
|
|
104
|
-
* Search AniList for anime by title.
|
|
105
|
-
* Returns up to 8 results with id, titles, episodes, status, year.
|
|
106
|
-
*/
|
|
107
94
|
async function searchAnime(query, token = null) {
|
|
108
95
|
const data = await gqlRequest(SEARCH_QUERY, { search: query }, token);
|
|
109
96
|
return (data?.Page?.media || []).map(m => ({
|
|
@@ -113,10 +100,10 @@ async function searchAnime(query, token = null) {
|
|
|
113
100
|
english: m.title.english,
|
|
114
101
|
native: m.title.native,
|
|
115
102
|
},
|
|
116
|
-
episodes:
|
|
117
|
-
status:
|
|
118
|
-
year:
|
|
119
|
-
format:
|
|
103
|
+
episodes: m.episodes,
|
|
104
|
+
status: m.status,
|
|
105
|
+
year: m.seasonYear,
|
|
106
|
+
format: m.format,
|
|
120
107
|
}));
|
|
121
108
|
}
|
|
122
109
|
|
|
@@ -124,9 +111,4 @@ function pickTitle(titles) {
|
|
|
124
111
|
return titles.english || titles.romaji || titles.native;
|
|
125
112
|
}
|
|
126
113
|
|
|
127
|
-
module.exports = {
|
|
128
|
-
getViewer,
|
|
129
|
-
getUserList,
|
|
130
|
-
searchAnime,
|
|
131
|
-
pickTitle,
|
|
132
|
-
};
|
|
114
|
+
module.exports = { getViewer, getUserList, searchAnime, pickTitle };
|
package/src/cli.js
CHANGED
|
@@ -681,7 +681,7 @@ program
|
|
|
681
681
|
|
|
682
682
|
program
|
|
683
683
|
.command('daemon [action]')
|
|
684
|
-
.description('Manage the background daemon (install, start, stop, restart, status, logs)')
|
|
684
|
+
.description('Manage the background daemon (install, start, stop, restart, status, logs, enable, disable)')
|
|
685
685
|
.action(async (action) => {
|
|
686
686
|
const { spawnSync, spawn } = require('child_process');
|
|
687
687
|
const SERVICE = 'ani-auto';
|
|
@@ -749,6 +749,9 @@ program
|
|
|
749
749
|
break;
|
|
750
750
|
}
|
|
751
751
|
case 'status': {
|
|
752
|
+
const config = loadConfig();
|
|
753
|
+
const enabled = config.daemonEnabled !== false;
|
|
754
|
+
console.log(`\n Daemon downloads: ${enabled ? chalk.green('enabled') : chalk.red('disabled')}`);
|
|
752
755
|
const r = run(`systemctl --user status ${SERVICE}`);
|
|
753
756
|
console.log(r.out || r.err);
|
|
754
757
|
break;
|
|
@@ -758,8 +761,38 @@ program
|
|
|
758
761
|
spawn('journalctl', ['--user', '-u', SERVICE, '-f'], { stdio: 'inherit' });
|
|
759
762
|
break;
|
|
760
763
|
}
|
|
764
|
+
|
|
765
|
+
// ── New: enable / disable daemon ──────────────────────────────
|
|
766
|
+
case 'enable': {
|
|
767
|
+
banner();
|
|
768
|
+
updateConfig({ daemonEnabled: true });
|
|
769
|
+
console.log(chalk.green('\n Done Daemon enabled.'));
|
|
770
|
+
console.log(chalk.gray(' Starting systemd service...\n'));
|
|
771
|
+
const r = run(`systemctl --user start ${SERVICE}`);
|
|
772
|
+
if (r.code === 0) {
|
|
773
|
+
console.log(chalk.green(' Daemon is now running.\n'));
|
|
774
|
+
} else {
|
|
775
|
+
console.log(chalk.yellow(' Could not start systemd service (is it installed?).'));
|
|
776
|
+
console.log(chalk.gray(` Run ${chalk.bold('ani-auto daemon install')} first, or start manually.\n`));
|
|
777
|
+
}
|
|
778
|
+
break;
|
|
779
|
+
}
|
|
780
|
+
case 'disable': {
|
|
781
|
+
banner();
|
|
782
|
+
updateConfig({ daemonEnabled: false });
|
|
783
|
+
console.log(chalk.yellow('\n Done Daemon disabled.'));
|
|
784
|
+
console.log(chalk.gray(' Stopping systemd service...\n'));
|
|
785
|
+
run(`systemctl --user stop ${SERVICE}`);
|
|
786
|
+
console.log(chalk.gray(
|
|
787
|
+
' The service is stopped and will exit immediately on any future\n' +
|
|
788
|
+
' startup (boot, network restore, manual start) until re-enabled.\n'
|
|
789
|
+
));
|
|
790
|
+
console.log(` To re-enable: ${chalk.bold('ani-auto daemon enable')}\n`);
|
|
791
|
+
break;
|
|
792
|
+
}
|
|
793
|
+
|
|
761
794
|
default:
|
|
762
|
-
console.log(chalk.yellow(`\n Unknown action "${action}". Use: install, start, stop, restart, status, logs\n`));
|
|
795
|
+
console.log(chalk.yellow(`\n Unknown action "${action}". Use: install, start, stop, restart, status, logs, enable, disable\n`));
|
|
763
796
|
}
|
|
764
797
|
});
|
|
765
798
|
|
package/src/config.js
CHANGED
|
@@ -22,7 +22,8 @@ const DEFAULTS = {
|
|
|
22
22
|
maxEpisodesPerRun: 50,
|
|
23
23
|
telegramChatId: null,
|
|
24
24
|
telegramNotify: true,
|
|
25
|
-
airingBufferMinutes: 5,
|
|
25
|
+
airingBufferMinutes: 5,
|
|
26
|
+
daemonEnabled: true, // set to false via `ani-auto daemon disable`
|
|
26
27
|
};
|
|
27
28
|
|
|
28
29
|
function ensureConfigDir() {
|
package/src/daemon.js
CHANGED
|
@@ -85,8 +85,24 @@ async function scheduleSmartChecks() {
|
|
|
85
85
|
// ── Startup ────────────────────────────────────────────────────────────────
|
|
86
86
|
|
|
87
87
|
async function startDaemon() {
|
|
88
|
-
const config
|
|
89
|
-
|
|
88
|
+
const config = loadConfig();
|
|
89
|
+
|
|
90
|
+
// ── Enabled gate ──────────────────────────────────────────────────────
|
|
91
|
+
if (config.daemonEnabled === false) {
|
|
92
|
+
console.log(chalk.yellow(`
|
|
93
|
+
┌─────────────────────────────────────────────────┐
|
|
94
|
+
│ ani-auto daemon is DISABLED │
|
|
95
|
+
│ │
|
|
96
|
+
│ The daemon will not run until you enable it. │
|
|
97
|
+
│ To re-enable, run: │
|
|
98
|
+
│ │
|
|
99
|
+
│ ani-auto daemon enable │
|
|
100
|
+
└─────────────────────────────────────────────────┘
|
|
101
|
+
`));
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const intervalMs = (config.daemonIntervalMinutes || 30) * 60 * 1000;
|
|
90
106
|
|
|
91
107
|
console.log(chalk.bold.red(`
|
|
92
108
|
█████╗ ███╗ ██╗██╗ █████╗ ██╗ ██╗████████╗ ██████╗
|
package/src/db.js
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
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
3
|
|
|
9
4
|
const fs = require('fs');
|
|
10
5
|
const path = require('path');
|
|
@@ -15,9 +10,6 @@ const DB_PATH = path.join(DB_DIR, 'state.db');
|
|
|
15
10
|
|
|
16
11
|
let _db = null;
|
|
17
12
|
|
|
18
|
-
/**
|
|
19
|
-
* Async init — call once at startup before any DB operations.
|
|
20
|
-
*/
|
|
21
13
|
async function initDb() {
|
|
22
14
|
if (_db) return _db;
|
|
23
15
|
|
|
@@ -75,8 +67,7 @@ function migrate(db) {
|
|
|
75
67
|
UNIQUE(anime_id, episode_num)
|
|
76
68
|
)
|
|
77
69
|
`);
|
|
78
|
-
|
|
79
|
-
try { db.run('ALTER TABLE anime ADD COLUMN confirmed_site_id TEXT'); } catch(e) {}
|
|
70
|
+
try { db.run('ALTER TABLE anime ADD COLUMN confirmed_site_id TEXT'); } catch(e) {}
|
|
80
71
|
try { db.run('ALTER TABLE anime ADD COLUMN confirmed_site_title TEXT'); } catch(e) {}
|
|
81
72
|
try { db.run('ALTER TABLE anime ADD COLUMN anilist_title TEXT'); } catch(e) {}
|
|
82
73
|
try { db.run('ALTER TABLE anime ADD COLUMN poster_url TEXT'); } catch(e) {}
|
|
@@ -95,7 +86,6 @@ function migrate(db) {
|
|
|
95
86
|
`);
|
|
96
87
|
}
|
|
97
88
|
|
|
98
|
-
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
99
89
|
|
|
100
90
|
function _assertReady() {
|
|
101
91
|
if (!_db) throw new Error('DB not initialised — call initDb() first at startup.');
|
|
@@ -126,7 +116,6 @@ function _run(sql, params = []) {
|
|
|
126
116
|
_save();
|
|
127
117
|
}
|
|
128
118
|
|
|
129
|
-
// ── Anime ──────────────────────────────────────────────────────────────────
|
|
130
119
|
|
|
131
120
|
function upsertAnime({ title, siteId, anilistId, source, quality, language }) {
|
|
132
121
|
_run(`
|
|
@@ -167,7 +156,6 @@ function removeAnime(siteId) {
|
|
|
167
156
|
_run('DELETE FROM anime WHERE id = ?', [row.id]);
|
|
168
157
|
}
|
|
169
158
|
|
|
170
|
-
// ── Episodes ───────────────────────────────────────────────────────────────
|
|
171
159
|
|
|
172
160
|
function isEpisodeDownloaded(animeDbId, episodeNum) {
|
|
173
161
|
return !!_get(
|
|
@@ -207,7 +195,6 @@ function getEpisodeStats(animeDbId) {
|
|
|
207
195
|
};
|
|
208
196
|
}
|
|
209
197
|
|
|
210
|
-
// ── Run log ────────────────────────────────────────────────────────────────
|
|
211
198
|
|
|
212
199
|
function logRun({ downloaded, skipped, failed, notes }) {
|
|
213
200
|
_run('INSERT INTO run_log (downloaded, skipped, failed, notes) VALUES (?, ?, ?, ?)',
|
package/src/engine.js
CHANGED
|
@@ -13,7 +13,6 @@ const scraper = require('./scraper');
|
|
|
13
13
|
const { version } = require('../package.json');
|
|
14
14
|
const notify = require('./notify');
|
|
15
15
|
|
|
16
|
-
// ── Logging ────────────────────────────────────────────────────────────────
|
|
17
16
|
|
|
18
17
|
function info(msg) { console.log(`${chalk.gray(ts())} ${chalk.cyan('ℹ')} ${msg}`); }
|
|
19
18
|
function ok(msg) { console.log(`${chalk.gray(ts())} ${chalk.green('✔')} ${msg}`); }
|
|
@@ -21,7 +20,6 @@ function warn(msg) { console.log(`${chalk.gray(ts())} ${chalk.yellow('⚠')} ${
|
|
|
21
20
|
function err(msg) { console.log(`${chalk.gray(ts())} ${chalk.red('✖')} ${msg}`); }
|
|
22
21
|
function ts() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
|
|
23
22
|
|
|
24
|
-
// ── Filename helpers ───────────────────────────────────────────────────────
|
|
25
23
|
|
|
26
24
|
function buildFilename(title, episodeNum, season = null, displayEpisodeNum = null) {
|
|
27
25
|
const s = season ? `Season ${season} ` : '';
|
|
@@ -45,7 +43,6 @@ function detectSeason(title) {
|
|
|
45
43
|
return m ? parseInt(m[1], 10) : null;
|
|
46
44
|
}
|
|
47
45
|
|
|
48
|
-
// ── Watch list ─────────────────────────────────────────────────────────────
|
|
49
46
|
|
|
50
47
|
async function resolveWatchList(config) {
|
|
51
48
|
const items = [];
|
|
@@ -103,7 +100,6 @@ async function resolveWatchList(config) {
|
|
|
103
100
|
});
|
|
104
101
|
}
|
|
105
102
|
|
|
106
|
-
// ── Anime selector prompt ──────────────────────────────────────────────────
|
|
107
103
|
|
|
108
104
|
async function promptAnimeSelection(watchList) {
|
|
109
105
|
console.log(chalk.bold('\n Available anime:'));
|
|
@@ -134,7 +130,6 @@ async function promptAnimeSelection(watchList) {
|
|
|
134
130
|
return indices.map(i => watchList[i]);
|
|
135
131
|
}
|
|
136
132
|
|
|
137
|
-
// ── Per-anime download ─────────────────────────────────────────────────────
|
|
138
133
|
|
|
139
134
|
async function processAnime(workItem, config, multiBar, auto = false, runQuality = null, runLanguage = null, range = null) {
|
|
140
135
|
// Per-anime quality: run override > DB override > workItem > global config
|
|
@@ -333,7 +328,6 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
333
328
|
return results;
|
|
334
329
|
}
|
|
335
330
|
|
|
336
|
-
// ── Main cycle ─────────────────────────────────────────────────────────────
|
|
337
331
|
|
|
338
332
|
async function runCycle({ auto = false, quality = null, language = null, range = null, statuses = null } = {}) {
|
|
339
333
|
await db.initDb();
|
package/src/network.js
CHANGED
|
@@ -1,63 +1,36 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* network.js — Real-time network monitor using `ip monitor route`
|
|
5
|
-
* Detects internet connectivity changes instantly via Linux routing events.
|
|
6
|
-
* Falls back to polling if `ip` command is unavailable.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
3
|
const { spawn } = require('child_process');
|
|
10
4
|
const dns = require('dns');
|
|
11
5
|
|
|
12
|
-
// Verify actual internet (not just interface up)
|
|
13
6
|
function checkInternet() {
|
|
14
7
|
return new Promise(resolve => {
|
|
15
8
|
dns.lookup('graphql.anilist.co', err => resolve(!err));
|
|
16
9
|
});
|
|
17
10
|
}
|
|
18
11
|
|
|
19
|
-
/**
|
|
20
|
-
* Start monitoring network connectivity.
|
|
21
|
-
* @param {object} opts
|
|
22
|
-
* @param {function} opts.onOnline — called when internet is restored
|
|
23
|
-
* @param {function} opts.onOffline — called when internet drops
|
|
24
|
-
* @returns {function} stop — call to stop monitoring
|
|
25
|
-
*/
|
|
26
12
|
function watchNetwork({ onOnline, onOffline } = {}) {
|
|
27
|
-
let online
|
|
28
|
-
let debounce
|
|
29
|
-
let stopped
|
|
30
|
-
let monitor
|
|
13
|
+
let online = true;
|
|
14
|
+
let debounce = null;
|
|
15
|
+
let stopped = false;
|
|
16
|
+
let monitor = null;
|
|
17
|
+
let pollTimer = null;
|
|
31
18
|
|
|
32
19
|
const handleChange = async () => {
|
|
33
20
|
if (stopped) return;
|
|
34
21
|
clearTimeout(debounce);
|
|
35
|
-
// Small debounce — routes can flap during reconnect
|
|
36
22
|
debounce = setTimeout(async () => {
|
|
37
23
|
const nowOnline = await checkInternet();
|
|
38
|
-
if (nowOnline && !online) {
|
|
39
|
-
|
|
40
|
-
onOnline?.();
|
|
41
|
-
} else if (!nowOnline && online) {
|
|
42
|
-
online = false;
|
|
43
|
-
onOffline?.();
|
|
44
|
-
}
|
|
24
|
+
if (nowOnline && !online) { online = true; onOnline?.(); }
|
|
25
|
+
else if (!nowOnline && online) { online = false; onOffline?.(); }
|
|
45
26
|
}, 2000);
|
|
46
27
|
};
|
|
47
28
|
|
|
48
|
-
// Try `ip monitor route` first (Linux)
|
|
49
29
|
try {
|
|
50
30
|
monitor = spawn('ip', ['monitor', 'route'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
51
31
|
monitor.stdout.on('data', handleChange);
|
|
52
|
-
monitor.on('error', () =>
|
|
32
|
+
monitor.on('error', () => { monitor = null; pollTimer = setInterval(handleChange, 10000); });
|
|
53
33
|
} catch (e) {
|
|
54
|
-
startFallback();
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Fallback: poll every 10 seconds if ip monitor unavailable
|
|
58
|
-
let pollTimer = null;
|
|
59
|
-
function startFallback() {
|
|
60
|
-
monitor = null;
|
|
61
34
|
pollTimer = setInterval(handleChange, 10000);
|
|
62
35
|
}
|
|
63
36
|
|
|
@@ -69,18 +42,11 @@ function watchNetwork({ onOnline, onOffline } = {}) {
|
|
|
69
42
|
};
|
|
70
43
|
}
|
|
71
44
|
|
|
72
|
-
/**
|
|
73
|
-
* Wait until internet is available.
|
|
74
|
-
* Resolves immediately if already online.
|
|
75
|
-
* @returns {Promise<void>}
|
|
76
|
-
*/
|
|
77
45
|
function waitForInternet() {
|
|
78
46
|
return new Promise(resolve => {
|
|
79
47
|
checkInternet().then(online => {
|
|
80
48
|
if (online) return resolve();
|
|
81
|
-
const stop = watchNetwork({
|
|
82
|
-
onOnline: () => { stop(); resolve(); }
|
|
83
|
-
});
|
|
49
|
+
const stop = watchNetwork({ onOnline: () => { stop(); resolve(); } });
|
|
84
50
|
});
|
|
85
51
|
});
|
|
86
52
|
}
|
package/src/notify.js
CHANGED
|
@@ -1,32 +1,26 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { execSync } = require('child_process');
|
|
4
|
+
const fetch = require('node-fetch');
|
|
4
5
|
const { loadConfig } = require('./config');
|
|
5
6
|
|
|
6
7
|
const BOT_TOKEN = '8695132702:AAEFDoSL6nkdwDOoz5wmoVkmuhaYa4tNw0o';
|
|
7
8
|
|
|
8
|
-
// ── Telegram helpers ───────────────────────────────────────────────────────
|
|
9
|
-
|
|
10
9
|
async function sendTelegram(payload) {
|
|
11
10
|
const config = loadConfig();
|
|
12
11
|
const chatId = config.telegramChatId;
|
|
13
12
|
if (!chatId || config.telegramNotify === false) return;
|
|
14
13
|
try {
|
|
15
|
-
const
|
|
16
|
-
const isPhoto = !!payload.photo;
|
|
17
|
-
const endpoint = isPhoto ? 'sendPhoto' : 'sendMessage';
|
|
14
|
+
const endpoint = payload.photo ? 'sendPhoto' : 'sendMessage';
|
|
18
15
|
await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/${endpoint}`, {
|
|
19
16
|
method: 'POST',
|
|
20
17
|
headers: { 'Content-Type': 'application/json' },
|
|
21
18
|
body: JSON.stringify({ chat_id: String(chatId), parse_mode: 'HTML', ...payload }),
|
|
19
|
+
timeout: 5000,
|
|
22
20
|
});
|
|
23
|
-
} catch (e) {
|
|
24
|
-
// silently fail
|
|
25
|
-
}
|
|
21
|
+
} catch (e) {}
|
|
26
22
|
}
|
|
27
23
|
|
|
28
|
-
// ── KDE desktop ────────────────────────────────────────────────────────────
|
|
29
|
-
|
|
30
24
|
async function sendDesktop(title, body, urgency = 'normal', posterUrl = null) {
|
|
31
25
|
try {
|
|
32
26
|
let icon = '';
|
|
@@ -34,7 +28,6 @@ async function sendDesktop(title, body, urgency = 'normal', posterUrl = null) {
|
|
|
34
28
|
const os = require('os');
|
|
35
29
|
const path = require('path');
|
|
36
30
|
const fs = require('fs');
|
|
37
|
-
const fetch = require('node-fetch');
|
|
38
31
|
const tmp = path.join(os.tmpdir(), 'ani-auto-poster.jpg');
|
|
39
32
|
try {
|
|
40
33
|
const res = await fetch(posterUrl);
|
|
@@ -42,25 +35,21 @@ async function sendDesktop(title, body, urgency = 'normal', posterUrl = null) {
|
|
|
42
35
|
icon = `-i "${tmp}"`;
|
|
43
36
|
} catch (e) {}
|
|
44
37
|
}
|
|
45
|
-
execSync(`notify-send -u ${urgency} ${icon} -a "ani-auto" "${title}" "${body}"`, { stdio: 'ignore' });
|
|
38
|
+
execSync(`notify-send -u ${urgency} ${icon} -a "ani-auto" "${title}" "${body}"`, { stdio: 'ignore', timeout: 5000 });
|
|
46
39
|
} catch (e) {}
|
|
47
40
|
}
|
|
48
41
|
|
|
49
|
-
// ── Public notification functions ──────────────────────────────────────────
|
|
50
|
-
|
|
51
42
|
async function notifyEpisode(animeTitle, episode, quality, posterUrl) {
|
|
52
43
|
const caption =
|
|
53
44
|
`<b>${animeTitle}</b>\n` +
|
|
54
45
|
`<code>━━━━━━━━━━━━━━━</code>\n` +
|
|
55
46
|
`✅ Episode ${episode} downloaded\n` +
|
|
56
47
|
`🎞 Quality: ${quality || 'default'}`;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
await sendDesktop(animeTitle, `Episode ${episode} downloaded`, 'normal', posterUrl);
|
|
48
|
+
const p1 = posterUrl
|
|
49
|
+
? sendTelegram({ photo: posterUrl, caption })
|
|
50
|
+
: sendTelegram({ text: `🎬 ${caption}` });
|
|
51
|
+
const p2 = sendDesktop(animeTitle, `Episode ${episode} downloaded`, 'normal', posterUrl);
|
|
52
|
+
await Promise.allSettled([p1, p2]);
|
|
64
53
|
}
|
|
65
54
|
|
|
66
55
|
async function notifyFailed(animeTitle, episode, error) {
|
|
@@ -69,8 +58,10 @@ async function notifyFailed(animeTitle, episode, error) {
|
|
|
69
58
|
`<code>━━━━━━━━━━━━━━━</code>\n` +
|
|
70
59
|
`Episode ${episode} failed\n` +
|
|
71
60
|
`<i>${error}</i>`;
|
|
72
|
-
await
|
|
73
|
-
|
|
61
|
+
await Promise.allSettled([
|
|
62
|
+
sendTelegram({ text }),
|
|
63
|
+
sendDesktop(animeTitle, `Episode ${episode} failed`, 'critical')
|
|
64
|
+
]);
|
|
74
65
|
}
|
|
75
66
|
|
|
76
67
|
async function notifySummary(totals) {
|
|
@@ -81,8 +72,10 @@ async function notifySummary(totals) {
|
|
|
81
72
|
`✅ Downloaded: ${totals.downloaded}\n` +
|
|
82
73
|
`⏭ Skipped: ${totals.skipped}\n` +
|
|
83
74
|
`❌ Failed: ${totals.failed}`;
|
|
84
|
-
await
|
|
85
|
-
|
|
75
|
+
await Promise.allSettled([
|
|
76
|
+
sendTelegram({ text }),
|
|
77
|
+
sendDesktop('ani-auto run complete', `Downloaded: ${totals.downloaded} Failed: ${totals.failed}`)
|
|
78
|
+
]);
|
|
86
79
|
}
|
|
87
80
|
|
|
88
81
|
async function notifyDaemonStart(intervalMinutes, downloadingCount) {
|
|
@@ -93,22 +86,17 @@ async function notifyDaemonStart(intervalMinutes, downloadingCount) {
|
|
|
93
86
|
`📦 Version: v${version}\n` +
|
|
94
87
|
`📺 Downloading: ${downloadingCount} anime\n` +
|
|
95
88
|
`⏱ Fallback check every ${intervalMinutes} minutes`;
|
|
96
|
-
await
|
|
97
|
-
|
|
98
|
-
`ani-auto v${version} started`,
|
|
99
|
-
|
|
100
|
-
);
|
|
89
|
+
await Promise.allSettled([
|
|
90
|
+
sendTelegram({ text }),
|
|
91
|
+
sendDesktop(`ani-auto v${version} started`, `Downloading ${downloadingCount} anime · every ${intervalMinutes} min`)
|
|
92
|
+
]);
|
|
101
93
|
}
|
|
102
94
|
|
|
103
95
|
async function notifyDaemonStop() {
|
|
104
|
-
await
|
|
105
|
-
|
|
96
|
+
await Promise.allSettled([
|
|
97
|
+
sendTelegram({ text: '🛑 <b>ani-auto daemon stopped</b>' }),
|
|
98
|
+
sendDesktop('ani-auto daemon stopped', '')
|
|
99
|
+
]);
|
|
106
100
|
}
|
|
107
101
|
|
|
108
|
-
module.exports = {
|
|
109
|
-
notifyEpisode,
|
|
110
|
-
notifyFailed,
|
|
111
|
-
notifySummary,
|
|
112
|
-
notifyDaemonStart,
|
|
113
|
-
notifyDaemonStop,
|
|
114
|
-
};
|
|
102
|
+
module.exports = { notifyEpisode, notifyFailed, notifySummary, notifyDaemonStart, notifyDaemonStop };
|
package/src/scraper.js
CHANGED
|
@@ -1,24 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
* scraper.js
|
|
5
|
-
*
|
|
6
|
-
* Thin adapter over the k-anime-cli utils.
|
|
7
|
-
* This is the ONLY file that touches the sister CLI project.
|
|
8
|
-
*
|
|
9
|
-
* Expected peer dependency layout:
|
|
10
|
-
* ../k-anime/utils.js ← the existing CLI's utils
|
|
11
|
-
*
|
|
12
|
-
* If you keep ani-auto as a separate repo, set KANIME_UTILS_PATH
|
|
13
|
-
* in your environment or config to point to utils.js.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
const path = require('path');
|
|
3
|
+
const path = require('path');
|
|
17
4
|
const fetch = require('node-fetch');
|
|
18
5
|
|
|
19
|
-
|
|
20
|
-
const UTILS_PATH = process.env.KANIME_UTILS_PATH
|
|
21
|
-
|| path.resolve(__dirname, '../utils.js');
|
|
6
|
+
const UTILS_PATH = process.env.KANIME_UTILS_PATH || path.resolve(__dirname, '../utils.js');
|
|
22
7
|
|
|
23
8
|
let _utils = null;
|
|
24
9
|
function utils() {
|
|
@@ -26,24 +11,11 @@ function utils() {
|
|
|
26
11
|
try {
|
|
27
12
|
_utils = require(UTILS_PATH);
|
|
28
13
|
} catch (e) {
|
|
29
|
-
throw new Error(
|
|
30
|
-
`Cannot find k-anime utils at "${UTILS_PATH}".\n` +
|
|
31
|
-
`Set KANIME_UTILS_PATH env var to the correct path.\n` +
|
|
32
|
-
`Original error: ${e.message}`
|
|
33
|
-
);
|
|
14
|
+
throw new Error(`Cannot find utils at "${UTILS_PATH}". Set KANIME_UTILS_PATH env var.\n${e.message}`);
|
|
34
15
|
}
|
|
35
16
|
return _utils;
|
|
36
17
|
}
|
|
37
18
|
|
|
38
|
-
// ── Public surface ────────────────────────────────────────────────────────
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Search the site for an anime by title.
|
|
42
|
-
* Returns the best match or null.
|
|
43
|
-
*
|
|
44
|
-
* @param {string} title
|
|
45
|
-
* @returns {Promise<SiteAnime|null>}
|
|
46
|
-
*/
|
|
47
19
|
async function searchAnime(title) {
|
|
48
20
|
const results = await searchAll(title);
|
|
49
21
|
if (!results.length) return null;
|
|
@@ -52,24 +24,20 @@ async function searchAnime(title) {
|
|
|
52
24
|
return exact || results[0];
|
|
53
25
|
}
|
|
54
26
|
|
|
55
|
-
/** Returns all search results (up to API limit) for user confirmation. */
|
|
56
27
|
async function searchAll(title) {
|
|
57
28
|
const { SEARCH_API, USER_AGENT } = utils();
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
29
|
+
const cleaned = title.replace(/[-\u2013\u2014~:!?()[\]{}<>]/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
30
|
+
const trySearch = async (q) => {
|
|
31
|
+
const res = await fetch(`${SEARCH_API}?q=${encodeURIComponent(q)}`, { headers: { 'User-Agent': USER_AGENT } });
|
|
32
|
+
if (!res.ok) throw new Error(`API returned ${res.status}`);
|
|
33
|
+
const data = await res.json();
|
|
34
|
+
return data.results || [];
|
|
35
|
+
};
|
|
36
|
+
let results = await trySearch(cleaned);
|
|
37
|
+
if (!results.length && cleaned !== title.toLowerCase()) results = await trySearch(title);
|
|
38
|
+
return results;
|
|
65
39
|
}
|
|
66
40
|
|
|
67
|
-
/**
|
|
68
|
-
* Fetch episode list for a given site anime session id.
|
|
69
|
-
*
|
|
70
|
-
* @param {string} animeId (session field from searchAnime result)
|
|
71
|
-
* @returns {Promise<SiteEpisode[]>}
|
|
72
|
-
*/
|
|
73
41
|
async function getEpisodes(animeId) {
|
|
74
42
|
const { INFO_API, USER_AGENT } = utils();
|
|
75
43
|
const res = await fetch(`${INFO_API}?id=${animeId}`, { headers: { 'User-Agent': USER_AGENT } });
|
|
@@ -78,13 +46,6 @@ async function getEpisodes(animeId) {
|
|
|
78
46
|
return data.results?.episodes || [];
|
|
79
47
|
}
|
|
80
48
|
|
|
81
|
-
/**
|
|
82
|
-
* Fetch download links for a single episode.
|
|
83
|
-
*
|
|
84
|
-
* @param {string} animeId
|
|
85
|
-
* @param {string} episodeId (session field from episode object)
|
|
86
|
-
* @returns {Promise<DownloadLink[]>}
|
|
87
|
-
*/
|
|
88
49
|
async function getDownloadLinks(animeId, episodeId) {
|
|
89
50
|
const { DOWNLOAD_API, USER_AGENT } = utils();
|
|
90
51
|
const res = await fetch(
|
|
@@ -96,15 +57,6 @@ async function getDownloadLinks(animeId, episodeId) {
|
|
|
96
57
|
return data.results?.downloadLinks || [];
|
|
97
58
|
}
|
|
98
59
|
|
|
99
|
-
/**
|
|
100
|
-
* Pick the best download link given quality + language preferences.
|
|
101
|
-
* Falls back gracefully: exact → quality-only → first available.
|
|
102
|
-
*
|
|
103
|
-
* @param {DownloadLink[]} links
|
|
104
|
-
* @param {string} quality e.g. '1080p'
|
|
105
|
-
* @param {string} language e.g. 'sub'
|
|
106
|
-
* @returns {DownloadLink|null}
|
|
107
|
-
*/
|
|
108
60
|
function pickLink(links, quality, language) {
|
|
109
61
|
if (!links.length) return null;
|
|
110
62
|
return (
|
|
@@ -114,26 +66,9 @@ function pickLink(links, quality, language) {
|
|
|
114
66
|
);
|
|
115
67
|
}
|
|
116
68
|
|
|
117
|
-
/**
|
|
118
|
-
* Download a single episode file.
|
|
119
|
-
* Delegates entirely to the existing CLI's downloadFile().
|
|
120
|
-
*
|
|
121
|
-
* @param {string} url
|
|
122
|
-
* @param {string} filename
|
|
123
|
-
* @param {string} outputDir
|
|
124
|
-
* @param {*} multiBar cliProgress.MultiBar instance (or null for silent)
|
|
125
|
-
* @param {number} episodeNum
|
|
126
|
-
*/
|
|
127
69
|
async function downloadEpisode(url, filename, outputDir, multiBar, episodeNum) {
|
|
128
70
|
const { downloadFile } = utils();
|
|
129
71
|
await downloadFile(url, filename, outputDir, multiBar, episodeNum);
|
|
130
72
|
}
|
|
131
73
|
|
|
132
|
-
module.exports = {
|
|
133
|
-
searchAnime,
|
|
134
|
-
searchAll,
|
|
135
|
-
getEpisodes,
|
|
136
|
-
getDownloadLinks,
|
|
137
|
-
pickLink,
|
|
138
|
-
downloadEpisode,
|
|
139
|
-
};
|
|
74
|
+
module.exports = { searchAnime, searchAll, getEpisodes, getDownloadLinks, pickLink, downloadEpisode };
|