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/README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# ani-auto
|
|
2
|
+
|
|
3
|
+
Automatic anime episode downloader.
|
|
4
|
+
Watches your **AniList** list (or a manual list) and downloads new episodes the moment they appear on the site — with full quality/language control, a background daemon, cron scheduling etc.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Node.js 18+
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# ani-auto/ ← this project
|
|
17
|
+
|
|
18
|
+
cd ani-auto
|
|
19
|
+
npm install
|
|
20
|
+
|
|
21
|
+
# 2. Link the CLI globally (optional)
|
|
22
|
+
npm link
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Quick Start
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# First-time setup wizard
|
|
31
|
+
ani-auto setup
|
|
32
|
+
|
|
33
|
+
# Manually trigger one download cycle
|
|
34
|
+
ani-auto check
|
|
35
|
+
|
|
36
|
+
# Start the background daemon
|
|
37
|
+
ani-auto daemon
|
|
38
|
+
# or: npm start
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## How It Works
|
|
44
|
+
|
|
45
|
+
1. **AniList** — queries your list for the configured statuses (e.g. `CURRENT`).
|
|
46
|
+
For each anime, it searches the download site, fetches the episode list, and downloads anything not already in the database.
|
|
47
|
+
|
|
48
|
+
2. **Manual list** — entries added via `ani-auto add` are checked the same way.
|
|
49
|
+
Manual entries support per-anime quality/language overrides.
|
|
50
|
+
|
|
51
|
+
3. **State** — SQLite (`~/.config/ani-auto/state.db`) tracks every episode as `pending → downloading → done / failed`. Already-downloaded episodes are never re-fetched.
|
|
52
|
+
|
|
53
|
+
4. **Scheduling** — the daemon runs on both:
|
|
54
|
+
- A **polling interval** (default: every 30 min)
|
|
55
|
+
- A **cron schedule** (default: `0 */2 * * *` — every 2 hours)
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Commands
|
|
60
|
+
|
|
61
|
+
| Command | Description |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `setup` | Interactive first-time wizard |
|
|
64
|
+
| `check` | Run one download cycle now |
|
|
65
|
+
| `list` | Show all tracked anime |
|
|
66
|
+
| `add "<title>"` | Add to manual list |
|
|
67
|
+
| `remove "<title>"` | Remove from tracking |
|
|
68
|
+
| `enable "<title>"` | Re-enable downloads |
|
|
69
|
+
| `disable "<title>"` | Pause without removing |
|
|
70
|
+
| `status` | View recent run log |
|
|
71
|
+
| `config` | View / update settings |
|
|
72
|
+
| `daemon` | Start background daemon |
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Manual Add Options
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
# Default quality/language from global config
|
|
80
|
+
ani-auto add "Frieren: Beyond Journey's End"
|
|
81
|
+
|
|
82
|
+
# Per-anime overrides
|
|
83
|
+
ani-auto add "One Piece" --quality 720p --language dub
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Config Options
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
ani-auto config --set-quality 1080p
|
|
92
|
+
ani-auto config --set-language sub
|
|
93
|
+
ani-auto config --set-concurrency 3
|
|
94
|
+
ani-auto config --set-output ~/Videos/Anime
|
|
95
|
+
ani-auto config --set-interval 60 # daemon: poll every 60 min
|
|
96
|
+
ani-auto config --set-cron "0 6 * * *" # also trigger at 6 AM daily
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Full config lives at `~/.config/ani-auto/config.json`.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## AniList Statuses
|
|
104
|
+
|
|
105
|
+
Configured during `setup` or by editing `config.json`:
|
|
106
|
+
|
|
107
|
+
| Status | Meaning |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `CURRENT` | Currently watching (recommended) |
|
|
110
|
+
| `PLANNING` | Plan to watch |
|
|
111
|
+
| `PAUSED` | Paused |
|
|
112
|
+
| `REPEATING` | Re-watching |
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## File Structure
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
ani-auto/
|
|
124
|
+
├── src/
|
|
125
|
+
│ ├── cli.js ← entry point for all commands
|
|
126
|
+
│ ├── daemon.js ← background daemon
|
|
127
|
+
│ ├── engine.js ← core run cycle logic
|
|
128
|
+
│ ├── scraper.js ← adapter over k-anime utils
|
|
129
|
+
│ ├── anilist.js ← AniList GraphQL client
|
|
130
|
+
│ ├── db.js ← SQLite state layer
|
|
131
|
+
│ └── config.js ← JSON config manager
|
|
132
|
+
├── package.json
|
|
133
|
+
├── utils.js
|
|
134
|
+
└── README.md
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Systemd Service (Linux/Arch)
|
|
140
|
+
|
|
141
|
+
```ini
|
|
142
|
+
# ~/.config/systemd/user/ani-auto.service
|
|
143
|
+
[Unit]
|
|
144
|
+
Description=K-Anime Auto Downloader
|
|
145
|
+
|
|
146
|
+
[Service]
|
|
147
|
+
ExecStart=/usr/bin/node /path/to/ani-auto/src/daemon.js
|
|
148
|
+
Restart=on-failure
|
|
149
|
+
RestartSec=10
|
|
150
|
+
|
|
151
|
+
[Install]
|
|
152
|
+
WantedBy=default.target
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
systemctl --user enable --now ani-auto
|
|
157
|
+
systemctl --user status ani-auto
|
|
158
|
+
journalctl --user -u ani-auto -f
|
|
159
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ani-auto",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Automatic anime episode downloader",
|
|
5
|
+
"bin": {
|
|
6
|
+
"ani-auto": "./src/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "node src/daemon.js",
|
|
10
|
+
"check": "node src/cli.js check",
|
|
11
|
+
"setup": "node src/cli.js setup"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"chalk": "^4.1.2",
|
|
15
|
+
"cli-progress": "^3.12.0",
|
|
16
|
+
"commander": "^12.0.0",
|
|
17
|
+
"cron": "^3.1.6",
|
|
18
|
+
"inquirer": "^8.2.6",
|
|
19
|
+
"node-fetch": "^2.7.0",
|
|
20
|
+
"ora": "^5.4.1",
|
|
21
|
+
"sql.js": "^1.12.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/anilist.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fetch = require('node-fetch');
|
|
4
|
+
|
|
5
|
+
const ANILIST_GQL = 'https://graphql.anilist.co';
|
|
6
|
+
|
|
7
|
+
// ── Queries ────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
const MEDIA_LIST_QUERY = `
|
|
10
|
+
query ($userName: String, $status: MediaListStatus) {
|
|
11
|
+
MediaListCollection(userName: $userName, type: ANIME, status: $status) {
|
|
12
|
+
lists {
|
|
13
|
+
entries {
|
|
14
|
+
mediaId
|
|
15
|
+
status
|
|
16
|
+
progress
|
|
17
|
+
media {
|
|
18
|
+
id
|
|
19
|
+
title { romaji english native }
|
|
20
|
+
episodes
|
|
21
|
+
status
|
|
22
|
+
nextAiringEpisode { episode airingAt }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}`;
|
|
28
|
+
|
|
29
|
+
const VIEWER_QUERY = `
|
|
30
|
+
query {
|
|
31
|
+
Viewer { id name }
|
|
32
|
+
}`;
|
|
33
|
+
|
|
34
|
+
const SEARCH_QUERY = `
|
|
35
|
+
query ($search: String) {
|
|
36
|
+
Page(page: 1, perPage: 8) {
|
|
37
|
+
media(search: $search, type: ANIME) {
|
|
38
|
+
id
|
|
39
|
+
title { romaji english native }
|
|
40
|
+
episodes
|
|
41
|
+
status
|
|
42
|
+
seasonYear
|
|
43
|
+
format
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}`;
|
|
47
|
+
|
|
48
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
async function gqlRequest(query, variables = {}, token = null) {
|
|
51
|
+
const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
|
|
52
|
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
53
|
+
|
|
54
|
+
const res = await fetch(ANILIST_GQL, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers,
|
|
57
|
+
body: JSON.stringify({ query, variables }),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
const text = await res.text();
|
|
62
|
+
throw new Error(`AniList API error ${res.status}: ${text}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const json = await res.json();
|
|
66
|
+
if (json.errors) throw new Error(`AniList GQL error: ${json.errors[0].message}`);
|
|
67
|
+
return json.data;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
async function getViewer(token) {
|
|
73
|
+
const data = await gqlRequest(VIEWER_QUERY, {}, token);
|
|
74
|
+
return data.Viewer;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function getUserList(userName, statuses, token = null) {
|
|
78
|
+
const results = [];
|
|
79
|
+
for (const status of statuses) {
|
|
80
|
+
const data = await gqlRequest(MEDIA_LIST_QUERY, { userName, status }, token);
|
|
81
|
+
const lists = data?.MediaListCollection?.lists || [];
|
|
82
|
+
for (const list of lists) {
|
|
83
|
+
for (const entry of list.entries) {
|
|
84
|
+
results.push({
|
|
85
|
+
anilistId: entry.mediaId,
|
|
86
|
+
status: entry.status,
|
|
87
|
+
progress: entry.progress,
|
|
88
|
+
totalEpisodes: entry.media.episodes,
|
|
89
|
+
animeStatus: entry.media.status,
|
|
90
|
+
titles: {
|
|
91
|
+
romaji: entry.media.title.romaji,
|
|
92
|
+
english: entry.media.title.english,
|
|
93
|
+
native: entry.media.title.native,
|
|
94
|
+
},
|
|
95
|
+
nextAiring: entry.media.nextAiringEpisode || null,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return results;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Search AniList for anime by title.
|
|
105
|
+
* Returns up to 8 results with id, titles, episodes, status, year.
|
|
106
|
+
*/
|
|
107
|
+
async function searchAnime(query, token = null) {
|
|
108
|
+
const data = await gqlRequest(SEARCH_QUERY, { search: query }, token);
|
|
109
|
+
return (data?.Page?.media || []).map(m => ({
|
|
110
|
+
anilistId: m.id,
|
|
111
|
+
titles: {
|
|
112
|
+
romaji: m.title.romaji,
|
|
113
|
+
english: m.title.english,
|
|
114
|
+
native: m.title.native,
|
|
115
|
+
},
|
|
116
|
+
episodes: m.episodes,
|
|
117
|
+
status: m.status,
|
|
118
|
+
year: m.seasonYear,
|
|
119
|
+
format: m.format,
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function pickTitle(titles) {
|
|
124
|
+
return titles.english || titles.romaji || titles.native;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = {
|
|
128
|
+
getViewer,
|
|
129
|
+
getUserList,
|
|
130
|
+
searchAnime,
|
|
131
|
+
pickTitle,
|
|
132
|
+
};
|