ani-auto 1.4.4 → 2.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.
Files changed (89) hide show
  1. package/anime/LICENSE +21 -0
  2. package/anime/api/index.js +110 -0
  3. package/anime/app.js +79 -0
  4. package/anime/controllers/animeInfoController.js +48 -0
  5. package/anime/controllers/animeListController.js +21 -0
  6. package/anime/controllers/homeController.js +42 -0
  7. package/anime/controllers/playController.js +140 -0
  8. package/anime/controllers/queueController.js +20 -0
  9. package/anime/controllers/testController.js +667 -0
  10. package/anime/index.js +139 -0
  11. package/anime/middleware/cache.js +64 -0
  12. package/anime/middleware/errorHandler.js +33 -0
  13. package/anime/middleware/rateLimiter.js +73 -0
  14. package/anime/models/animeInfoModel.js +193 -0
  15. package/anime/models/animeListModel.js +69 -0
  16. package/anime/models/homeModel.js +33 -0
  17. package/anime/models/playModel.js +528 -0
  18. package/anime/models/queueModel.js +22 -0
  19. package/anime/package.json +27 -0
  20. package/anime/pnpm-lock.yaml +1774 -0
  21. package/anime/readme.md +236 -0
  22. package/anime/routes/animeInfoRoutes.js +9 -0
  23. package/anime/routes/animeListRoutes.js +9 -0
  24. package/anime/routes/homeRoutes.js +10 -0
  25. package/anime/routes/playRoutes.js +11 -0
  26. package/anime/routes/queueRoutes.js +8 -0
  27. package/anime/routes/testRoutes.js +325 -0
  28. package/anime/routes/webhookRoutes.js +23 -0
  29. package/anime/scrapers/animepahe.js +1053 -0
  30. package/anime/test-server.js +10 -0
  31. package/anime/test.sh +275 -0
  32. package/anime/utils/browser.js +172 -0
  33. package/anime/utils/config.js +209 -0
  34. package/anime/utils/dataProcessor.js +172 -0
  35. package/anime/utils/diskCache.js +228 -0
  36. package/anime/utils/flaresolverr.js +361 -0
  37. package/anime/utils/jsParser.js +19 -0
  38. package/anime/utils/redis.js +64 -0
  39. package/anime/utils/requestManager.js +706 -0
  40. package/anime/utils/urlConverter.js +88 -0
  41. package/anime/utils/webhookNotifier.js +190 -0
  42. package/cookiereader.py +291 -0
  43. package/package.json +14 -6
  44. package/src/anilist.js +15 -2
  45. package/src/api/anilist.js +32 -0
  46. package/src/api/anime.js +181 -0
  47. package/src/api/cf-bypass.js +131 -0
  48. package/src/api/config.js +134 -0
  49. package/src/api/daemon.js +62 -0
  50. package/src/api/download.js +98 -0
  51. package/src/api/match.js +58 -0
  52. package/src/api/runs.js +15 -0
  53. package/src/api/update.js +96 -0
  54. package/src/cli.js +18 -2
  55. package/src/config.js +14 -2
  56. package/src/daemon.js +1 -1
  57. package/src/db.js +38 -11
  58. package/src/engine.js +62 -11
  59. package/src/scraper.js +2 -2
  60. package/src/server.js +108 -0
  61. package/utils.js +21 -4
  62. package/web/index.html +13 -0
  63. package/web/package-lock.json +1420 -0
  64. package/web/package.json +24 -0
  65. package/web/src/App.vue +200 -0
  66. package/web/src/api/client.js +75 -0
  67. package/web/src/assets/styles/base.css +961 -0
  68. package/web/src/components/UpdateModal.vue +157 -0
  69. package/web/src/components/sidebar/SidebarResizable.vue +170 -0
  70. package/web/src/components/sidebar/sidebarConfig.js +24 -0
  71. package/web/src/main.js +104 -0
  72. package/web/src/router/index.js +44 -0
  73. package/web/src/stores/config.js +27 -0
  74. package/web/src/stores/download.js +107 -0
  75. package/web/src/stores/update.js +73 -0
  76. package/web/src/views/About.vue +42 -0
  77. package/web/src/views/AniListSync.vue +122 -0
  78. package/web/src/views/AnimeDetail.vue +202 -0
  79. package/web/src/views/AnimeList.vue +110 -0
  80. package/web/src/views/CFResult.vue +312 -0
  81. package/web/src/views/DaemonControl.vue +83 -0
  82. package/web/src/views/Dashboard.vue +187 -0
  83. package/web/src/views/DownloadCenter.vue +153 -0
  84. package/web/src/views/MatchFinder.vue +131 -0
  85. package/web/src/views/OAuthCallback.vue +44 -0
  86. package/web/src/views/Onboarding.vue +93 -0
  87. package/web/src/views/RunHistory.vue +122 -0
  88. package/web/src/views/Settings.vue +410 -0
  89. package/web/vite.config.js +23 -0
@@ -0,0 +1,312 @@
1
+ <template>
2
+ <div class="cf-result">
3
+ <h2>Cloudflare Bypass</h2>
4
+
5
+ <div v-if="loading" class="loading-state">
6
+ <i class="pi pi-spin pi-spinner" style="font-size:2rem" />
7
+ <p>Scanning browsers for AnimePahe cookies...</p>
8
+ </div>
9
+
10
+ <div v-else-if="error" class="result-card error-card">
11
+ <i class="pi pi-exclamation-triangle" style="font-size:2rem;color:var(--red-500)" />
12
+ <h3>Detection Failed</h3>
13
+ <p>{{ error }}</p>
14
+ <Button label="Retry" @click="detect" />
15
+ </div>
16
+
17
+ <div v-else-if="foundBrowser" class="result-card success-card">
18
+ <i class="pi pi-check-circle" style="font-size:2rem;color:var(--green-500)" />
19
+ <h3>Cookies Found in {{ browserLabel }}</h3>
20
+
21
+ <div class="cookie-display">
22
+ <label>cf_clearance Value</label>
23
+ <textarea ref="cookieTextarea" :value="cfClearanceValue" readonly rows="3" class="cookie-textarea" />
24
+ <Button label="Copy cf_clearance" icon="pi pi-copy" @click="copyToClipboard" :class="{ copied: copied }" />
25
+ <span v-if="copied" class="copied-msg">Copied!</span>
26
+ </div>
27
+
28
+ <div class="ua-hint">
29
+ <p><strong>Also need your User-Agent</strong> — it will be auto-filled. You can change it in Settings.</p>
30
+ <p class="ua-preview">UA: <code>{{ defaultUA }}</code></p>
31
+ </div>
32
+
33
+ <p class="hint">Go to Settings to paste and verify:</p>
34
+ <Button label="Go to Settings" severity="primary" @click="goToSettings" />
35
+ </div>
36
+
37
+ <div v-else class="result-card manual-card">
38
+ <i class="pi pi-info-circle" style="font-size:2rem;color:var(--blue-500)" />
39
+ <h3>No Cookies Auto-Detected</h3>
40
+
41
+ <div v-if="detectedBrowsers.length > 0" class="browser-list">
42
+ <p>Found profiles in these browsers but couldn't read cookies:</p>
43
+ <ul>
44
+ <li v-for="b in detectedBrowsers" :key="b.name">
45
+ <strong>{{ b.label }}</strong>: {{ b.error }}
46
+ </li>
47
+ </ul>
48
+ </div>
49
+
50
+ <div class="manual-steps">
51
+ <h4>Manual Steps:</h4>
52
+ <ol>
53
+ <li>Open <a :href="animepaheUrl" target="_blank">AnimePahe</a> in your browser</li>
54
+ <li>Solve the Cloudflare Turnstile challenge if prompted</li>
55
+ <li>Make sure you're logged in and can browse the site</li>
56
+ <li>Come back here and click "Retry"</li>
57
+ </ol>
58
+ </div>
59
+
60
+ <div class="manual-paste-hint">
61
+ <p>Alternatively, copy from your browser manually:</p>
62
+ <ol>
63
+ <li>Open AnimePahe in your browser and solve Turnstile</li>
64
+ <li>Open DevTools (F12) → Storage → Cookies → animepahe.pw</li>
65
+ <li>Copy the <code>cf_clearance</code> cookie value</li>
66
+ <li>Open DevTools → Network tab → click any request → find <code>User-Agent</code> in Request Headers</li>
67
+ <li>Go to Settings → paste both → Save & Verify</li>
68
+ </ol>
69
+ </div>
70
+
71
+ <div class="action-buttons">
72
+ <Button label="Retry" severity="secondary" @click="detect" />
73
+ <Button label="Go to Settings" severity="primary" @click="goToSettingsEmpty" />
74
+ </div>
75
+ </div>
76
+ </div>
77
+ </template>
78
+
79
+ <script setup>
80
+ import { ref, onMounted, computed } from 'vue'
81
+ import { useRouter } from 'vue-router'
82
+ import { api } from '../api/client'
83
+
84
+ const router = useRouter()
85
+ const loading = ref(true)
86
+ const error = ref(null)
87
+ const detectResult = ref(null)
88
+ const copied = ref(false)
89
+ const cookieTextarea = ref(null)
90
+
91
+ const animepaheUrl = 'https://animepahe.pw'
92
+
93
+ const browserLabels = {
94
+ firefox: 'Firefox',
95
+ chrome: 'Chrome',
96
+ chromium: 'Chromium',
97
+ brave: 'Brave',
98
+ edge: 'Edge',
99
+ }
100
+
101
+ const userAgentTemplates = {
102
+ firefox: 'Mozilla/5.0 (X11; Linux x86_64; rv:151.0) Gecko/20100101 Firefox/151.0',
103
+ chrome: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
104
+ brave: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
105
+ edge: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36 Edg/101.0.1210.47',
106
+ }
107
+
108
+ const foundBrowser = computed(() => {
109
+ if (!detectResult.value?.browsers) return null
110
+ for (const [b, data] of Object.entries(detectResult.value.browsers)) {
111
+ if (data.found) return b
112
+ }
113
+ return null
114
+ })
115
+
116
+ const browserLabel = computed(() => browserLabels[foundBrowser.value] || foundBrowser.value)
117
+
118
+ const defaultUA = computed(() => userAgentTemplates[foundBrowser.value] || userAgentTemplates.firefox)
119
+
120
+ const cfClearanceValue = computed(() => {
121
+ if (!foundBrowser.value || !detectResult.value?.browsers?.[foundBrowser.value]) return ''
122
+ const cookies = detectResult.value.browsers[foundBrowser.value].cookies || {}
123
+ return cookies.cf_clearance || ''
124
+ })
125
+
126
+ const detectedBrowsers = computed(() => {
127
+ if (!detectResult.value?.browsers) return []
128
+ const list = []
129
+ for (const [b, data] of Object.entries(detectResult.value.browsers)) {
130
+ if (!data.found) {
131
+ list.push({ name: b, label: browserLabels[b] || b, error: data.error || 'Unknown' })
132
+ }
133
+ }
134
+ return list
135
+ })
136
+
137
+ async function detect() {
138
+ loading.value = true
139
+ error.value = null
140
+ detectResult.value = null
141
+ try {
142
+ detectResult.value = await api.cfBypass.detect()
143
+ if (!detectResult.value.ok) {
144
+ error.value = detectResult.value.error || 'Detection failed'
145
+ }
146
+ } catch (e) {
147
+ error.value = e.message
148
+ } finally {
149
+ loading.value = false
150
+ }
151
+ }
152
+
153
+ function copyToClipboard() {
154
+ if (!cookieTextarea.value) return
155
+ cookieTextarea.value.select()
156
+ navigator.clipboard.writeText(cookieString.value)
157
+ copied.value = true
158
+ setTimeout(() => copied.value = false, 2000)
159
+ }
160
+
161
+ function goToSettings() {
162
+ router.push({
163
+ name: 'settings',
164
+ state: { cfClearance: cfClearanceValue.value, userAgent: defaultUA.value },
165
+ })
166
+ }
167
+
168
+ function goToSettingsEmpty() {
169
+ router.push({ name: 'settings' })
170
+ }
171
+
172
+ onMounted(detect)
173
+ </script>
174
+
175
+ <style scoped>
176
+ .cf-result {
177
+ max-width: 700px;
178
+ margin: 2rem auto;
179
+ padding: 0 1rem;
180
+ }
181
+
182
+ .cf-result h2 {
183
+ margin-bottom: 1.5rem;
184
+ }
185
+
186
+ .loading-state {
187
+ text-align: center;
188
+ padding: 3rem;
189
+ }
190
+
191
+ .loading-state p {
192
+ margin-top: 1rem;
193
+ color: var(--ink-400);
194
+ }
195
+
196
+ .result-card {
197
+ padding: 2rem;
198
+ border-radius: 8px;
199
+ margin-bottom: 1rem;
200
+ border: 1px solid var(--border-color);
201
+ }
202
+
203
+ .success-card {
204
+ border-color: var(--green-300);
205
+ background: color-mix(in srgb, var(--green-500) 5%, transparent);
206
+ }
207
+
208
+ .manual-card {
209
+ border-color: var(--blue-300);
210
+ }
211
+
212
+ .error-card {
213
+ border-color: var(--red-300);
214
+ background: color-mix(in srgb, var(--red-500) 5%, transparent);
215
+ }
216
+
217
+ .cookie-display {
218
+ margin: 1.5rem 0;
219
+ }
220
+
221
+ .cookie-display label {
222
+ display: block;
223
+ font-weight: 600;
224
+ margin-bottom: 0.5rem;
225
+ }
226
+
227
+ .cookie-textarea {
228
+ width: 100%;
229
+ font-family: monospace;
230
+ font-size: 12px;
231
+ padding: 0.5rem;
232
+ border: 1px solid var(--border-color);
233
+ border-radius: 4px;
234
+ background: var(--ink-50);
235
+ color: var(--ink-800);
236
+ resize: vertical;
237
+ }
238
+
239
+ .ua-hint {
240
+ margin: 1rem 0;
241
+ padding: 0.75rem 1rem;
242
+ background: var(--ink-50);
243
+ border-radius: 6px;
244
+ font-size: 13px;
245
+ }
246
+
247
+ .ua-hint .ua-preview {
248
+ margin-top: 4px;
249
+ font-size: 12px;
250
+ word-break: break-all;
251
+ }
252
+
253
+ .ua-hint code {
254
+ background: var(--ink-100);
255
+ padding: 1px 4px;
256
+ border-radius: 3px;
257
+ font-size: 11px;
258
+ }
259
+
260
+ .copied-msg {
261
+ margin-left: 8px;
262
+ color: var(--green-600);
263
+ font-weight: 600;
264
+ }
265
+
266
+ .hint {
267
+ color: var(--ink-400);
268
+ font-size: 13px;
269
+ margin: 1rem 0;
270
+ }
271
+
272
+ .browser-list {
273
+ margin: 1rem 0;
274
+ }
275
+
276
+ .browser-list ul {
277
+ padding-left: 1.5rem;
278
+ }
279
+
280
+ .browser-list li {
281
+ margin-bottom: 0.5rem;
282
+ }
283
+
284
+ .manual-steps, .manual-paste-hint {
285
+ margin: 1rem 0;
286
+ padding: 1rem;
287
+ border-radius: 6px;
288
+ background: var(--ink-50);
289
+ }
290
+
291
+ .manual-steps h4, .manual-paste-hint h4 {
292
+ margin-bottom: 0.5rem;
293
+ }
294
+
295
+ .manual-steps ol, .manual-paste-hint ol {
296
+ padding-left: 1.5rem;
297
+ line-height: 1.8;
298
+ }
299
+
300
+ .manual-paste-hint code {
301
+ background: var(--ink-100);
302
+ padding: 2px 6px;
303
+ border-radius: 3px;
304
+ font-size: 12px;
305
+ }
306
+
307
+ .action-buttons {
308
+ display: flex;
309
+ gap: 1rem;
310
+ margin-top: 1.5rem;
311
+ }
312
+ </style>
@@ -0,0 +1,83 @@
1
+ <template>
2
+ <div>
3
+ <div class="page-header">
4
+ <h2>Daemon Control</h2>
5
+ </div>
6
+
7
+ <Skeleton v-if="loading" width="100%" height="300px" />
8
+
9
+ <Card v-else-if="notInstalled">
10
+ <template #content>
11
+ <div class="empty-content" style="padding:20px">
12
+ <div style="font-size:14px;color:var(--text-bright);margin-bottom:12px">Daemon Not Installed</div>
13
+ <p style="color:var(--ink-500);font-size:13px;margin-bottom:20px">The systemd service is not set up yet. Install it from the terminal:</p>
14
+ <pre style="background:var(--bg-primary);border:1px solid var(--ink-100);padding:12px 16px;font-family:var(--font-mono);font-size:12px;color:var(--text-primary);border-radius:var(--radius);display:inline-block">ani-auto daemon install</pre>
15
+ </div>
16
+ </template>
17
+ </Card>
18
+
19
+ <template v-else>
20
+ <Card style="margin-bottom:16px">
21
+ <template #content>
22
+ <div style="text-align:center;padding:16px">
23
+ <Tag :value="daemon.running ? 'RUNNING' : daemon.enabled ? 'IDLE' : 'DISABLED'"
24
+ :severity="daemon.running ? 'success' : daemon.enabled ? 'secondary' : 'danger'"
25
+ style="font-size:14px;font-weight:600;padding:8px 24px" />
26
+ </div>
27
+ <div style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap">
28
+ <Button label="Start" severity="primary" size="small" @click="daemonAction('start')" :disabled="daemon.running || !daemon.enabled" />
29
+ <Button label="Stop" size="small" @click="daemonAction('stop')" :disabled="!daemon.running" />
30
+ <Button label="Restart" size="small" @click="daemonAction('restart')" :disabled="!daemon.running" />
31
+ <Button v-if="daemon.enabled" label="Disable" severity="danger" size="small" @click="daemonAction('disable')" />
32
+ <Button v-else label="Enable" severity="primary" size="small" @click="daemonAction('enable')" />
33
+ </div>
34
+ </template>
35
+ </Card>
36
+
37
+ <Card>
38
+ <template #title>
39
+ <span style="font-size:13px">Output</span>
40
+ </template>
41
+ <template #content>
42
+ <pre style="background:var(--bg-primary);border:1px solid var(--ink-100);border-radius:var(--radius);padding:12px;font-family:var(--font-mono);font-size:11px;max-height:300px;overflow-y:auto;white-space:pre-wrap;color:var(--ink-700)">{{ daemon.output || 'No output' }}</pre>
43
+ </template>
44
+ </Card>
45
+ </template>
46
+ </div>
47
+ </template>
48
+
49
+ <script setup>
50
+ import { ref, computed, onMounted } from 'vue'
51
+ import { api } from '../api/client'
52
+
53
+ const loading = ref(true)
54
+ const daemon = ref({ enabled: false, running: false, output: '' })
55
+
56
+ const notInstalled = computed(() =>
57
+ daemon.value.output?.toLowerCase().includes('could not be found') ||
58
+ daemon.value.output?.toLowerCase().includes('not found') ||
59
+ daemon.value.output?.toLowerCase().includes('unit not found')
60
+ )
61
+
62
+ async function loadStatus() {
63
+ try {
64
+ daemon.value = await api.daemon.status()
65
+ } catch (e) {
66
+ console.error(e)
67
+ daemon.value = { enabled: false, running: false, output: `Error: ${e.message}` }
68
+ } finally {
69
+ loading.value = false
70
+ }
71
+ }
72
+
73
+ async function daemonAction(action) {
74
+ try {
75
+ await api.daemon[action]()
76
+ await loadStatus()
77
+ } catch (e) {
78
+ alert(`Failed: ${e.message}`)
79
+ }
80
+ }
81
+
82
+ onMounted(loadStatus)
83
+ </script>
@@ -0,0 +1,187 @@
1
+ <template>
2
+ <div>
3
+ <div class="page-header">
4
+ <div>
5
+ <h2>Dashboard</h2>
6
+ <div class="subtitle" v-if="configStore.config?.anilistUsername">
7
+ {{ configStore.config.anilistUsername }}
8
+ </div>
9
+ </div>
10
+ <div class="page-header-actions">
11
+ <Button label="Download New Episodes" severity="primary" @click="startDownload" />
12
+ <Button label="Sync AniList" @click="$router.push('/anilist')" />
13
+ <Button label="Refresh Posters" severity="secondary" @click="refreshPosters" :disabled="refreshing" />
14
+ </div>
15
+ </div>
16
+
17
+ <Skeleton v-if="loading" width="100%" height="300px" />
18
+
19
+ <template v-else>
20
+ <div class="stat-row">
21
+ <Card>
22
+ <template #content>
23
+ <div class="stat-inner">
24
+ <div class="stat-value" style="color:var(--red-primary)">{{ trackedCount }}</div>
25
+ <div class="stat-label">Tracked</div>
26
+ </div>
27
+ </template>
28
+ </Card>
29
+ <Card>
30
+ <template #content>
31
+ <div class="stat-inner">
32
+ <div class="stat-value" style="color:var(--status-done)">{{ totalDownloaded }}</div>
33
+ <div class="stat-label">Downloaded</div>
34
+ </div>
35
+ </template>
36
+ </Card>
37
+ <Card>
38
+ <template #content>
39
+ <div class="stat-inner">
40
+ <div class="stat-value" style="color:var(--status-fail)">{{ totalFailed }}</div>
41
+ <div class="stat-label">Failed</div>
42
+ </div>
43
+ </template>
44
+ </Card>
45
+ <Card>
46
+ <template #content>
47
+ <div class="stat-inner">
48
+ <div class="stat-value" style="color:var(--ink-500)">{{ daemonStatus }}</div>
49
+ <div class="stat-label">Daemon</div>
50
+ </div>
51
+ </template>
52
+ </Card>
53
+ </div>
54
+
55
+ <div v-if="trackedAnime.length === 0">
56
+ <Card>
57
+ <template #content>
58
+ <div class="empty-content">
59
+ <h3>No anime tracked yet</h3>
60
+ <p>Sync your AniList list or add anime manually.</p>
61
+ <Button label="Sync AniList" @click="$router.push('/anilist')" style="margin-top:12px" />
62
+ </div>
63
+ </template>
64
+ </Card>
65
+ </div>
66
+
67
+ <div v-else>
68
+ <h3 style="color:var(--text-bright);font-size:14px;margin-bottom:12px">Currently Watching</h3>
69
+ <div class="anime-grid">
70
+ <div v-for="a in trackedAnime" :key="a.id" class="anime-card" @click="$router.push(`/anime/${a.id}`)">
71
+ <img :src="a.poster_url || FALLBACK_POSTER" class="anime-poster" @error="handleImgError" />
72
+ <div class="anime-info">
73
+ <div class="anime-title">{{ a.anilist_title || a.title }}</div>
74
+ <div class="anime-meta">
75
+ {{ a.source }} &mdash;
76
+ <Tag :value="a.confirmed_site_title ? 'matched' : 'unmatched'" :severity="a.confirmed_site_title ? 'success' : 'secondary'" size="small" />
77
+ </div>
78
+ <div class="anime-progress">
79
+ <div class="bar" :style="{ width: progressPercent(a) + '%' }"></div>
80
+ </div>
81
+ <div class="anime-stats">
82
+ <span class="done">{{ a.stats?.done || 0 }} done</span>
83
+ <span class="failed">{{ a.stats?.failed || 0 }} failed</span>
84
+ <span class="pending">{{ a.stats?.pending || 0 }} pending</span>
85
+ </div>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ </div>
90
+
91
+ <div v-if="recentRuns.length" style="margin-top:28px">
92
+ <h3 style="color:var(--text-bright);font-size:14px;margin-bottom:12px">Recent Runs</h3>
93
+ <DataTable :value="recentRuns" stripedRows>
94
+ <Column field="ran_at" header="Time">
95
+ <template #body="{ data }">
96
+ <span style="font-family:var(--font-mono);font-size:12px">{{ data.ran_at }}</span>
97
+ </template>
98
+ </Column>
99
+ <Column field="downloaded" header="Downloaded">
100
+ <template #body="{ data }">
101
+ <Tag v-if="data.downloaded > 0" :value="data.downloaded" severity="success" />
102
+ <span v-else style="color:var(--ink-500)">{{ data.downloaded }}</span>
103
+ </template>
104
+ </Column>
105
+ <Column field="skipped" header="Skipped">
106
+ <template #body="{ data }">
107
+ <span style="color:var(--ink-500)">{{ data.skipped }}</span>
108
+ </template>
109
+ </Column>
110
+ <Column field="failed" header="Failed">
111
+ <template #body="{ data }">
112
+ <Tag v-if="data.failed > 0" :value="data.failed" severity="danger" />
113
+ <span v-else style="color:var(--ink-500)">{{ data.failed }}</span>
114
+ </template>
115
+ </Column>
116
+ </DataTable>
117
+ </div>
118
+ </template>
119
+ </div>
120
+ </template>
121
+
122
+ <script setup>
123
+ import { ref, computed, onMounted } from 'vue'
124
+ import { useRouter } from 'vue-router'
125
+ import { useConfigStore } from '../stores/config'
126
+ import { useDownloadStore } from '../stores/download'
127
+ import { api } from '../api/client'
128
+ import DataTable from 'primevue/datatable'
129
+ import Column from 'primevue/column'
130
+
131
+ const router = useRouter()
132
+ const configStore = useConfigStore()
133
+ const downloadStore = useDownloadStore()
134
+
135
+ const loading = ref(true)
136
+ const refreshing = ref(false)
137
+ const trackedAnime = ref([])
138
+ const recentRuns = ref([])
139
+ const daemonStatus = ref('--')
140
+
141
+ const trackedCount = computed(() => trackedAnime.value.length)
142
+ const totalDownloaded = computed(() => trackedAnime.value.reduce((s, a) => s + (a.stats?.done || 0), 0))
143
+ const totalFailed = computed(() => trackedAnime.value.reduce((s, a) => s + (a.stats?.failed || 0), 0))
144
+
145
+ const FALLBACK_POSTER = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='150' viewBox='0 0 100 150'%3E%3Crect fill='%23111111' width='100' height='150'/%3E%3Cpath fill='%23333333' d='M40 55l30 20-30 20z'/%3E%3C/svg%3E"
146
+
147
+ function handleImgError(e) {
148
+ e.target.src = FALLBACK_POSTER
149
+ }
150
+
151
+ function progressPercent(a) {
152
+ const total = (a.stats?.done || 0) + (a.stats?.failed || 0) + (a.stats?.pending || 0)
153
+ if (!total) return 0
154
+ return Math.round(((a.stats?.done || 0) / total) * 100)
155
+ }
156
+
157
+ async function refreshPosters() {
158
+ refreshing.value = true
159
+ try {
160
+ const res = await fetch('/api/anime/refresh-posters', { method: 'POST' })
161
+ const data = await res.json()
162
+ trackedAnime.value = await api.anime.list()
163
+ } catch (e) {
164
+ console.error(e)
165
+ } finally {
166
+ refreshing.value = false
167
+ }
168
+ }
169
+
170
+ async function startDownload() {
171
+ downloadStore.startDownload()
172
+ router.push('/download')
173
+ }
174
+
175
+ onMounted(async () => {
176
+ try {
177
+ trackedAnime.value = await api.anime.list()
178
+ recentRuns.value = await api.runs.list(5)
179
+ const daemon = await api.daemon.status()
180
+ daemonStatus.value = daemon.running ? 'active' : daemon.enabled ? 'idle' : 'off'
181
+ } catch (e) {
182
+ console.error(e)
183
+ } finally {
184
+ loading.value = false
185
+ }
186
+ })
187
+ </script>