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,153 @@
1
+ <template>
2
+ <div>
3
+ <div class="page-header">
4
+ <h2>Download Center</h2>
5
+ <div class="subtitle">Select anime and start downloading</div>
6
+ </div>
7
+
8
+ <Card style="margin-bottom:16px">
9
+ <template #content>
10
+ <div class="form-row" style="margin-bottom:12px;max-width:400px">
11
+ <div class="form-group" style="margin-bottom:0">
12
+ <label>Quality</label>
13
+ <Select v-model="quality" :options="['1080p', '720p', '360p']" fluid />
14
+ </div>
15
+ <div class="form-group" style="margin-bottom:0">
16
+ <label>Language</label>
17
+ <Select v-model="language" :options="['sub', 'dub']" fluid />
18
+ </div>
19
+ </div>
20
+ <div style="display:flex;gap:10px;flex-wrap:wrap">
21
+ <Button label="Download All New Episodes" severity="primary" @click="startDownload" :disabled="downloadStore.running" />
22
+ <Button label="Retry Failed" @click="retryFailed" :disabled="downloadStore.running" />
23
+ </div>
24
+ </template>
25
+ </Card>
26
+
27
+ <template v-if="downloadStore.running || downloadStore.summary">
28
+ <Card v-if="downloadStore.summary" style="border-color:var(--status-done);margin-bottom:12px">
29
+ <template #content>
30
+ <div style="display:flex;gap:20px;align-items:center">
31
+ <div><Tag :value="downloadStore.summary.downloaded + ' downloaded'" severity="success" /></div>
32
+ <div><span style="color:var(--ink-500)">{{ downloadStore.summary.skipped }} skipped</span></div>
33
+ <div><Tag v-if="downloadStore.summary.failed > 0" :value="downloadStore.summary.failed + ' failed'" severity="danger" /></div>
34
+ <Button label="Clear" size="small" @click="downloadStore.reset()" style="margin-left:auto" />
35
+ </div>
36
+ </template>
37
+ </Card>
38
+
39
+ <Card v-for="(prog, id) in downloadStore.progress" :key="id" style="margin-bottom:10px">
40
+ <template #content>
41
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
42
+ <strong style="color:var(--text-bright);font-size:13px">{{ prog.title }}</strong>
43
+ <span style="font-size:11px;color:var(--ink-500)">{{ prog.done }} done / {{ prog.failed }} failed</span>
44
+ </div>
45
+ <div v-for="(ep, num) in prog.episodes" :key="num" style="margin-bottom:4px">
46
+ <div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:1px">
47
+ <span>EP {{ num }}</span>
48
+ <span v-if="ep.done" style="color:var(--status-done)">Done</span>
49
+ <span v-else-if="ep.error" style="color:var(--status-fail)">Failed</span>
50
+ <span v-else>{{ ep.speed || '' }}</span>
51
+ </div>
52
+ <ProgressBar v-if="ep.percent !== undefined && !ep.done" :value="ep.percent" style="height:4px" />
53
+ </div>
54
+ </template>
55
+ </Card>
56
+ </template>
57
+
58
+ <Card v-if="downloadStore.logs.length" style="margin-top:16px">
59
+ <template #title>
60
+ <div style="display:flex;justify-content:space-between;align-items:center">
61
+ <span style="font-size:13px">Activity Log</span>
62
+ <Button label="Clear" size="small" @click="downloadStore.logs = []" />
63
+ </div>
64
+ </template>
65
+ <template #content>
66
+ <div ref="logContainer" style="max-height:300px;overflow-y:auto;font-family:var(--font-mono);font-size:11px;line-height:1.6;background:var(--bg-primary);padding:10px;border:1px solid var(--ink-100);border-radius:var(--radius)">
67
+ <div v-for="(l, i) in downloadStore.logs" :key="i" style="white-space:pre-wrap;word-break:break-all">
68
+ <span style="color:var(--ink-500)">{{ l.timestamp?.slice(11, 19) || '' }}</span>
69
+ <span :style="{ color: levelColor(l.level) }">[{{ l.level }}]</span>
70
+ {{ l.message }}
71
+ </div>
72
+ </div>
73
+ </template>
74
+ </Card>
75
+
76
+ <template v-if="!downloadStore.running && !downloadStore.summary">
77
+ <h3 style="color:var(--text-bright);font-size:14px;margin-bottom:10px">Tracked Anime</h3>
78
+ <Card v-if="animeList.length === 0">
79
+ <template #content>
80
+ <div class="empty-content">
81
+ <h3>No anime tracked</h3>
82
+ <p>Add anime first via AniList Sync or manually.</p>
83
+ </div>
84
+ </template>
85
+ </Card>
86
+ <div v-else class="list-group">
87
+ <div v-for="a in animeList" :key="a.id" class="anime-card-row">
88
+ <img v-if="a.poster_url" :src="a.poster_url" class="anime-poster" @error="handleImgError" />
89
+ <div class="anime-info">
90
+ <div class="anime-title">{{ a.anilist_title || a.title }}</div>
91
+ <div class="anime-meta">
92
+ {{ a.stats?.done || 0 }} done &middot;
93
+ {{ a.stats?.pending || 0 }} pending &middot;
94
+ {{ a.stats?.failed || 0 }} failed
95
+ </div>
96
+ </div>
97
+ <Button v-if="a.confirmed_site_title" label="Download" severity="primary" size="small" @click="downloadAnime(a)" />
98
+ <Tag v-else value="Unmatched" severity="secondary" size="small" />
99
+ </div>
100
+ </div>
101
+ </template>
102
+ </div>
103
+ </template>
104
+
105
+ <script setup>
106
+ import { ref, watch, nextTick, onMounted } from 'vue'
107
+ import { useDownloadStore } from '../stores/download'
108
+ import { api } from '../api/client'
109
+
110
+ const downloadStore = useDownloadStore()
111
+ const animeList = ref([])
112
+ const quality = ref('1080p')
113
+ const language = ref('sub')
114
+ const logContainer = ref(null)
115
+
116
+ 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"
117
+
118
+ function handleImgError(e) {
119
+ e.target.src = FALLBACK_POSTER
120
+ }
121
+
122
+ function levelColor(level) {
123
+ switch (level) {
124
+ case 'ok': return 'var(--status-done)'
125
+ case 'warn': return 'var(--status-pending)'
126
+ case 'err': return 'var(--status-fail)'
127
+ default: return 'var(--text-dim)'
128
+ }
129
+ }
130
+
131
+ watch(() => downloadStore.logs.length, async () => {
132
+ await nextTick()
133
+ if (logContainer.value) logContainer.value.scrollTop = logContainer.value.scrollHeight
134
+ })
135
+
136
+ async function startDownload() {
137
+ await downloadStore.startDownload(quality.value, language.value)
138
+ }
139
+
140
+ async function downloadAnime(a) {
141
+ await downloadStore.downloadAnime(a.id, quality.value, language.value)
142
+ }
143
+
144
+ async function retryFailed() {
145
+ await downloadStore.retryFailed()
146
+ }
147
+
148
+ onMounted(async () => {
149
+ try {
150
+ animeList.value = await api.anime.list()
151
+ } catch (e) {}
152
+ })
153
+ </script>
@@ -0,0 +1,131 @@
1
+ <template>
2
+ <div>
3
+ <div class="page-header">
4
+ <h2>Match Anime</h2>
5
+ <div class="subtitle" v-if="anime">{{ anime.anilist_title || anime.title }}</div>
6
+ </div>
7
+
8
+ <Skeleton v-if="loading" width="100%" height="300px" />
9
+
10
+ <Card v-else-if="!anime">
11
+ <template #content>
12
+ <div class="empty-content"><h3>Anime not found</h3></div>
13
+ </template>
14
+ </Card>
15
+
16
+ <template v-else>
17
+ <Card style="margin-bottom:16px">
18
+ <template #content>
19
+ <div><strong style="color:var(--text-bright)">{{ anime.anilist_title || anime.title }}</strong></div>
20
+ <div style="color:var(--ink-500);font-size:12px;margin-top:4px">AniList ID: {{ anime.anilist_id || 'N/A' }}</div>
21
+ </template>
22
+ </Card>
23
+
24
+ <Card v-if="autoMatch" style="margin-bottom:16px;border-color:var(--status-done)">
25
+ <template #content>
26
+ <div style="display:flex;justify-content:space-between;align-items:center">
27
+ <div>
28
+ <Tag value="Auto Match Found" severity="success" style="margin-bottom:4px" />
29
+ <div style="color:var(--text-bright);font-size:16px;margin-top:4px">{{ autoMatch.title }}</div>
30
+ <div style="color:var(--ink-500);font-size:12px">{{ autoMatch.year || '?' }} &mdash; {{ autoMatch.episodes || '?' }} eps</div>
31
+ </div>
32
+ <Button label="Confirm Match" severity="primary" @click="confirmMatch(autoMatch)" />
33
+ </div>
34
+ </template>
35
+ </Card>
36
+
37
+ <Card>
38
+ <template #title>Manual Search</template>
39
+ <template #content>
40
+ <div style="display:flex;gap:10px;margin-bottom:16px">
41
+ <InputText v-model="searchQuery" placeholder="Search by title..." fluid />
42
+ <Button label="Search API" @click="search" />
43
+ </div>
44
+ <div v-if="searchResults.length">
45
+ <div v-for="r in searchResults" :key="r.session" class="match-result-card" @click="confirmMatch(r)">
46
+ <div style="display:flex;justify-content:space-between;align-items:center">
47
+ <div>
48
+ <div style="color:var(--text-bright)">{{ r.title }}</div>
49
+ <div style="color:var(--ink-500);font-size:12px">{{ r.year || '?' }} &mdash; {{ r.episodes || '?' }} eps</div>
50
+ </div>
51
+ <Button label="Select" severity="primary" size="small" @click.stop="confirmMatch(r)" />
52
+ </div>
53
+ </div>
54
+ </div>
55
+ <div v-else-if="searched" class="empty-content" style="padding:20px">
56
+ <h3>No results</h3>
57
+ </div>
58
+ </template>
59
+ </Card>
60
+ </template>
61
+ </div>
62
+ </template>
63
+
64
+ <script setup>
65
+ import { ref, onMounted } from 'vue'
66
+ import { useRoute, useRouter } from 'vue-router'
67
+ import { api } from '../api/client'
68
+
69
+ const props = defineProps({ animeId: String })
70
+ const route = useRoute()
71
+ const router = useRouter()
72
+
73
+ const loading = ref(true)
74
+ const anime = ref(null)
75
+ const autoMatch = ref(null)
76
+ const searchResults = ref([])
77
+ const searchQuery = ref('')
78
+ const searched = ref(false)
79
+
80
+ async function load() {
81
+ const id = props.animeId || route.params.animeId
82
+ try {
83
+ anime.value = await api.anime.get(id)
84
+ searchQuery.value = anime.value.anilist_title || anime.value.title
85
+ await search()
86
+ } catch (e) {
87
+ console.error(e)
88
+ } finally {
89
+ loading.value = false
90
+ }
91
+ }
92
+
93
+ async function search() {
94
+ searched.value = true
95
+ try {
96
+ const data = await api.match.search(searchQuery.value, anime.value?.anilist_id)
97
+ searchResults.value = data.results || []
98
+ autoMatch.value = data.autoMatch || null
99
+ } catch (e) {
100
+ console.error(e)
101
+ }
102
+ }
103
+
104
+ async function confirmMatch(match) {
105
+ const title = anime.value.anilist_title || anime.value.title
106
+ await api.match.confirm({
107
+ anilistTitle: title,
108
+ session: match.session,
109
+ title: match.title,
110
+ anilistId: anime.value.anilist_id,
111
+ })
112
+ router.push(`/anime/${anime.value.id}`)
113
+ }
114
+
115
+ onMounted(load)
116
+ </script>
117
+
118
+ <style scoped>
119
+ .match-result-card {
120
+ padding: 12px;
121
+ border: 1px solid var(--ink-100);
122
+ border-radius: var(--radius);
123
+ margin-bottom: 8px;
124
+ cursor: pointer;
125
+ transition: border-color 0.15s, background 0.15s;
126
+ }
127
+ .match-result-card:hover {
128
+ border-color: var(--ink-300);
129
+ background: var(--bg-hover);
130
+ }
131
+ </style>
@@ -0,0 +1,44 @@
1
+ <template>
2
+ <div class="onboarding">
3
+ <div class="onboarding-box">
4
+ <div class="logo">ANI-AUTO</div>
5
+ <p v-if="status === 'processing'">Verifying token...</p>
6
+ <p v-if="status === 'done'" style="color:var(--status-done)">Connected! Closing window...</p>
7
+ <p v-if="status === 'error'" style="color:var(--status-fail)">Failed: {{ error }}</p>
8
+ </div>
9
+ </div>
10
+ </template>
11
+
12
+ <script setup>
13
+ import { ref, onMounted } from 'vue'
14
+ import { api } from '../api/client'
15
+
16
+ const status = ref('processing')
17
+ const error = ref('')
18
+
19
+ onMounted(async () => {
20
+ const hash = window.location.hash.substring(1)
21
+ const params = new URLSearchParams(hash)
22
+ const token = params.get('access_token')
23
+ if (!token) {
24
+ status.value = 'error'
25
+ error.value = 'No token found in URL'
26
+ return
27
+ }
28
+ try {
29
+ const res = await api.config.verifyToken(token)
30
+ if (res.ok) {
31
+ await api.config.update({ anilistToken: token, anilistUsername: res.name, anilistAvatar: res.avatar })
32
+ status.value = 'done'
33
+ window.opener?.postMessage({ type: 'anilist-token', token }, '*')
34
+ setTimeout(() => window.close(), 1000)
35
+ } else {
36
+ status.value = 'error'
37
+ error.value = res.error
38
+ }
39
+ } catch (e) {
40
+ status.value = 'error'
41
+ error.value = e.message
42
+ }
43
+ })
44
+ </script>
@@ -0,0 +1,93 @@
1
+ <template>
2
+ <div class="onboarding">
3
+ <div class="onboarding-box">
4
+ <div class="logo">ANI-AUTO</div>
5
+ <div class="logo-sub">Automatic Anime Downloader</div>
6
+
7
+ <div v-if="step === 'welcome'">
8
+ <h2>Welcome</h2>
9
+ <p>Connect your AniList account to sync your watch list and automatically download new episodes. Or skip and use manual mode.</p>
10
+ <div class="btn-group">
11
+ <Button label="Connect AniList" severity="primary" @click="connectAnilist" />
12
+ <Button label="Skip — Manual Mode" @click="skipAnilist" />
13
+ </div>
14
+ </div>
15
+
16
+ <div v-if="step === 'cf-bypass'">
17
+ <h2>Cloudflare Bypass</h2>
18
+ <p>AnimePahe requires a cf_clearance cookie to bypass Cloudflare. We'll help you get it.</p>
19
+ <div class="btn-group" style="margin-top:20px">
20
+ <Button label="Auto-Detect Cookies" severity="primary" @click="startCfBypass" />
21
+ </div>
22
+ </div>
23
+
24
+ <div v-if="step === 'telegram'">
25
+ <h2>Notifications</h2>
26
+ <p>Optional: Get Telegram notifications when episodes download.</p>
27
+ <div class="form-group" style="text-align:left">
28
+ <label>Telegram Chat ID</label>
29
+ <InputText v-model="telegramChatId" placeholder="e.g. 123456789" fluid />
30
+ <p style="color:var(--ink-500);font-size:11px;margin-top:4px">Message @userinfobot on Telegram to get your ID</p>
31
+ </div>
32
+ <div class="btn-group" style="margin-top:20px">
33
+ <Button label="Save &amp; Continue" severity="primary" @click="saveTelegram" />
34
+ <Button label="Skip Telegram" @click="finish" />
35
+ </div>
36
+ </div>
37
+ </div>
38
+ </div>
39
+ </template>
40
+
41
+ <script setup>
42
+ import { ref } from 'vue'
43
+ import { useRouter } from 'vue-router'
44
+ import { api } from '../api/client'
45
+ import { useConfigStore } from '../stores/config'
46
+
47
+ const router = useRouter()
48
+ const configStore = useConfigStore()
49
+ const step = ref('welcome')
50
+ const telegramChatId = ref('')
51
+ const tokenCaptured = ref(false)
52
+ async function connectAnilist() {
53
+ const { url } = await api.config.oauthUrl()
54
+ const w = window.open(url, 'anilist-oauth', 'width=600,height=700')
55
+ const timer = setInterval(() => {
56
+ if (w.closed) {
57
+ clearInterval(timer)
58
+ if (!tokenCaptured.value) {
59
+ step.value = 'cf-bypass'
60
+ }
61
+ }
62
+ }, 500)
63
+ }
64
+
65
+ function skipAnilist() {
66
+ step.value = 'cf-bypass'
67
+ }
68
+
69
+ async function startCfBypass() {
70
+ router.push({ name: 'cf-result' })
71
+ }
72
+
73
+ async function saveTelegram() {
74
+ if (telegramChatId.value) {
75
+ await api.config.update({ telegramChatId: telegramChatId.value })
76
+ }
77
+ await finish()
78
+ }
79
+
80
+ async function finish() {
81
+ await api.config.update({ onboardingSkipped: true })
82
+ await configStore.fetch()
83
+ router.push('/')
84
+ }
85
+
86
+ window.addEventListener('message', async (e) => {
87
+ if (e.data?.type === 'anilist-token') {
88
+ tokenCaptured.value = true
89
+ await configStore.fetch()
90
+ step.value = 'cf-bypass'
91
+ }
92
+ })
93
+ </script>
@@ -0,0 +1,122 @@
1
+ <template>
2
+ <div>
3
+ <div class="page-header">
4
+ <h2>Run History</h2>
5
+ <div class="subtitle">{{ runs.length }} runs recorded</div>
6
+ </div>
7
+
8
+ <Skeleton v-if="loading" width="100%" height="300px" />
9
+
10
+ <template v-else-if="runs.length === 0">
11
+ <Card>
12
+ <template #content>
13
+ <div class="empty-content">
14
+ <h3>No runs yet</h3>
15
+ <p>Run a download cycle to see history here.</p>
16
+ </div>
17
+ </template>
18
+ </Card>
19
+ </template>
20
+
21
+ <template v-else>
22
+ <div class="stat-row">
23
+ <Card>
24
+ <template #content>
25
+ <div class="stat-inner">
26
+ <div class="stat-value" style="color:var(--status-done)">{{ totals.downloaded }}</div>
27
+ <div class="stat-label">Total Downloaded</div>
28
+ </div>
29
+ </template>
30
+ </Card>
31
+ <Card>
32
+ <template #content>
33
+ <div class="stat-inner">
34
+ <div class="stat-value" style="color:var(--status-fail)">{{ totals.failed }}</div>
35
+ <div class="stat-label">Total Failed</div>
36
+ </div>
37
+ </template>
38
+ </Card>
39
+ <Card>
40
+ <template #content>
41
+ <div class="stat-inner">
42
+ <div class="stat-value" style="color:var(--ink-500)">{{ totals.skipped }}</div>
43
+ <div class="stat-label">Total Skipped</div>
44
+ </div>
45
+ </template>
46
+ </Card>
47
+ </div>
48
+
49
+ <DataTable :value="runs" paginator :rows="20" :rowsPerPageOptions="[10,20,50]"
50
+ dataKey="id" :sortField="sortField" :sortOrder="sortOrder" @sort="onSort"
51
+ paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink"
52
+ currentPageReportTemplate="{currentPage} of {totalPages}"
53
+ stripedRows>
54
+ <Column field="id" header="#" sortable style="width:60px">
55
+ <template #body="{ data }">
56
+ <span style="font-family:var(--font-mono);color:var(--ink-500)">{{ data.id }}</span>
57
+ </template>
58
+ </Column>
59
+ <Column field="ran_at" header="Time" sortable>
60
+ <template #body="{ data }">
61
+ <span style="font-family:var(--font-mono);font-size:12px">{{ data.ran_at }}</span>
62
+ </template>
63
+ </Column>
64
+ <Column field="downloaded" header="Downloaded" sortable>
65
+ <template #body="{ data }">
66
+ <Tag :value="data.downloaded" severity="success" v-if="data.downloaded > 0" />
67
+ <span v-else style="color:var(--ink-500)">{{ data.downloaded }}</span>
68
+ </template>
69
+ </Column>
70
+ <Column field="skipped" header="Skipped" sortable>
71
+ <template #body="{ data }">
72
+ <span style="color:var(--ink-500)">{{ data.skipped }}</span>
73
+ </template>
74
+ </Column>
75
+ <Column field="failed" header="Failed" sortable>
76
+ <template #body="{ data }">
77
+ <Tag :value="data.failed" severity="danger" v-if="data.failed > 0" />
78
+ <span v-else style="color:var(--ink-500)">{{ data.failed }}</span>
79
+ </template>
80
+ </Column>
81
+ <Column field="notes" header="Notes">
82
+ <template #body="{ data }">
83
+ <span style="font-size:12px;color:var(--ink-700)">{{ data.notes || '-' }}</span>
84
+ </template>
85
+ </Column>
86
+ </DataTable>
87
+ </template>
88
+ </div>
89
+ </template>
90
+
91
+ <script setup>
92
+ import { ref, computed, onMounted } from 'vue'
93
+ import { api } from '../api/client'
94
+ import DataTable from 'primevue/datatable'
95
+ import Column from 'primevue/column'
96
+
97
+ const loading = ref(true)
98
+ const runs = ref([])
99
+ const sortField = ref('ran_at')
100
+ const sortOrder = ref(-1)
101
+
102
+ const totals = computed(() => ({
103
+ downloaded: runs.value.reduce((s, r) => s + r.downloaded, 0),
104
+ skipped: runs.value.reduce((s, r) => s + r.skipped, 0),
105
+ failed: runs.value.reduce((s, r) => s + r.failed, 0),
106
+ }))
107
+
108
+ function onSort(event) {
109
+ sortField.value = event.sortField
110
+ sortOrder.value = event.sortOrder
111
+ }
112
+
113
+ onMounted(async () => {
114
+ try {
115
+ runs.value = await api.runs.list(50)
116
+ } catch (e) {
117
+ console.error(e)
118
+ } finally {
119
+ loading.value = false
120
+ }
121
+ })
122
+ </script>